From 3fdc13ccef3024394d0ae66ecb995a7dd3be2789 Mon Sep 17 00:00:00 2001 From: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com> Date: Sat, 19 Sep 2026 15:51:01 -0700 Subject: [PATCH 1/3] test(mcp): cover SDK redirect compatibility --- litellm/experimental_mcp_client/Readme.md | 6 + .../test_mcp_client.py | 110 +++++++++++++++++- 2 files changed, 113 insertions(+), 3 deletions(-) diff --git a/litellm/experimental_mcp_client/Readme.md b/litellm/experimental_mcp_client/Readme.md index 0c7b0aa76b9..12bde78877d 100644 --- a/litellm/experimental_mcp_client/Readme.md +++ b/litellm/experimental_mcp_client/Readme.md @@ -15,3 +15,9 @@ Upgrade SDK1-dependent libraries before installing them alongside `litellm[mcp]` The shared unit-test workflow runs the MCP integration suite once, with SDK2 in the gateway environment and an isolated SDK1 peer. Keep the SDK1 list/call compatibility test while SDK1 clients are supported; remove it when that support is explicitly retired and the client migration is documented See the official [SDK migration guide](https://py.sdk.modelcontextprotocol.io/migration/) for Python API changes + +## HTTP redirects + +The MCP SDK follows redirects within the configured endpoint's origin, so a redirect to another path on the same scheme, host and port works. It also permits an HTTP-to-HTTPS upgrade on the same host using the default ports + +Redirects to a different origin are rejected before the destination receives a request or credentials. Configure the final MCP endpoint URL directly if the server redirects to a different host or port. Setting the HTTP client's `follow_redirects` option does not override the SDK's policy diff --git a/tests/test_litellm/experimental_mcp_client/test_mcp_client.py b/tests/test_litellm/experimental_mcp_client/test_mcp_client.py index b78d61c7bd4..14ef5213d7a 100644 --- a/tests/test_litellm/experimental_mcp_client/test_mcp_client.py +++ b/tests/test_litellm/experimental_mcp_client/test_mcp_client.py @@ -6,7 +6,7 @@ import sys from collections.abc import AsyncIterator from pathlib import Path from typing import Final -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch import anyio import httpx2 @@ -18,12 +18,14 @@ from mcp.types import ( CONNECTION_CLOSED, INTERNAL_ERROR, REQUEST_TIMEOUT, + CallToolRequestParams, CallToolResult, ErrorData, Implementation, InitializeResult, JSONRPCError, JSONRPCMessage, + JSONRPCRequest, JSONRPCResponse, LoggingMessageNotificationParams, ServerCapabilities, @@ -61,8 +63,10 @@ class _MockTransportClient(MCPClient): super().__init__(**kwargs) self._respond = respond - def _create_transport_context(self): - http_client = httpx2.AsyncClient(transport=httpx2.MockTransport(self._respond)) + def _create_transport_context(self) -> tuple[_TransportContext, httpx2.AsyncClient]: + http_client: Final = self._create_httpx_client_factory(transport=httpx2.MockTransport(self._respond))( + headers=self._get_auth_headers(), timeout=httpx2.Timeout(self.timeout) + ) return streamable_http_client(self.server_url, http_client=http_client), http_client @@ -1178,6 +1182,106 @@ def test_v1_static_headers_still_win_their_own_slot(): assert headers["Authorization"] == "Bearer static-upstream-mcp-token" +@pytest.mark.asyncio +async def test_sdk_same_origin_redirect_lists_and_calls_tools() -> None: + def respond(request: httpx2.Request) -> httpx2.Response: + if request.url.path == "/mcp": + return httpx2.Response(307, headers={"Location": "/final/mcp"}) + assert request.url == "https://upstream.example.com/final/mcp" + assert request.headers["x-upstream-token"] == "Bearer synthetic-token" + if request.method != "POST": + return httpx2.Response(405) + payload: Final = _JSONRPC_MESSAGE_ADAPTER.validate_json(request.content) + if not isinstance(payload, JSONRPCRequest): + return httpx2.Response(202) + match payload.method: + case "initialize": + return httpx2.Response( + 200, + json={ + "jsonrpc": "2.0", + "id": payload.id, + "result": { + "protocolVersion": LATEST_HANDSHAKE_VERSION, + "capabilities": {"tools": {}}, + "serverInfo": {"name": "redirect-test", "version": "1"}, + }, + }, + ) + case "tools/list": + return httpx2.Response( + 200, + json={ + "jsonrpc": "2.0", + "id": payload.id, + "result": {"tools": [{"name": "add", "inputSchema": {"type": "object"}}]}, + }, + ) + case "tools/call": + assert payload.params is not None + assert payload.params["name"] == "add" + assert payload.params["arguments"] == {"a": 2, "b": 3} + return httpx2.Response( + 200, + json={ + "jsonrpc": "2.0", + "id": payload.id, + "result": {"content": [{"type": "text", "text": "5"}], "isError": False}, + }, + ) + case _: + pytest.fail(f"Unexpected MCP request: {payload.method}") + + responder: Final = Mock(side_effect=respond) + client: Final = _MockTransportClient( + responder, + server_url="https://upstream.example.com/mcp", + auth_type=MCPAuth.bearer_token, + auth_value="synthetic-token", + auth_header_name="x-upstream-token", + timeout=5, + ) + with anyio.fail_after(10): + tools: Final = await client.list_tools(raise_on_error=True) + result: Final = await client.call_tool( + CallToolRequestParams(name="add", arguments={"a": 2, "b": 3}), raise_on_error=True + ) + assert [tool.name for tool in tools] == ["add"] + assert result.is_error is False + assert len(result.content) == 1 + assert result.content[0].type == "text" + assert result.content[0].text == "5" + assert any(call.args[0].url.path == "/mcp" for call in responder.call_args_list) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("operation", ("list", "call")) +async def test_sdk_cross_origin_redirect_never_contacts_destination(operation: str) -> None: + responder: Final = Mock( + return_value=httpx2.Response(307, headers={"Location": "https://destination.example.com/mcp"}) + ) + client: Final = _MockTransportClient( + responder, + server_url="https://upstream.example.com/mcp", + auth_type=MCPAuth.bearer_token, + auth_value="synthetic-token", + auth_header_name="x-upstream-token", + timeout=5, + ) + pending_operation: Final = ( + client.list_tools(raise_on_error=True) + if operation == "list" + else client.call_tool(CallToolRequestParams(name="add", arguments={"a": 2, "b": 3}), raise_on_error=True) + ) + with anyio.fail_after(10), pytest.raises(MCPError, match=r"Redirect to .*destination.* not followed"): + await pending_operation + assert responder.call_count == 1 + request: Final = responder.call_args.args[0] + assert request.url == "https://upstream.example.com/mcp" + assert request.headers["x-upstream-token"] == "Bearer synthetic-token" + assert all(call.args[0].url.host != "destination.example.com" for call in responder.call_args_list) + + @pytest.mark.asyncio async def test_a_custom_credential_header_is_stripped_when_a_redirect_crosses_origin(): """httpx drops Authorization across origins but keeps every other header, so a credential the From 064b7d10df9af2baac0aa731c6f65949879b20d3 Mon Sep 17 00:00:00 2001 From: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com> Date: Sat, 19 Sep 2026 15:52:07 -0700 Subject: [PATCH 2/3] docs(mcp): clarify method-preserving redirect scope --- litellm/experimental_mcp_client/Readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/experimental_mcp_client/Readme.md b/litellm/experimental_mcp_client/Readme.md index 12bde78877d..14decce0256 100644 --- a/litellm/experimental_mcp_client/Readme.md +++ b/litellm/experimental_mcp_client/Readme.md @@ -18,6 +18,6 @@ See the official [SDK migration guide](https://py.sdk.modelcontextprotocol.io/mi ## HTTP redirects -The MCP SDK follows redirects within the configured endpoint's origin, so a redirect to another path on the same scheme, host and port works. It also permits an HTTP-to-HTTPS upgrade on the same host using the default ports +For streamable HTTP POST requests, the MCP SDK follows method-preserving redirects such as HTTP 307/308 within the configured endpoint's origin. Redirects to another path on the same scheme, host and port work. The SDK also permits an HTTP-to-HTTPS upgrade on the same host using the default ports Redirects to a different origin are rejected before the destination receives a request or credentials. Configure the final MCP endpoint URL directly if the server redirects to a different host or port. Setting the HTTP client's `follow_redirects` option does not override the SDK's policy From 7cd96e9c2dc920977ffbc3ea149edf17f7e983b9 Mon Sep 17 00:00:00 2001 From: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com> Date: Sat, 19 Sep 2026 16:01:42 -0700 Subject: [PATCH 3/3] test(mcp): assert redirect rejection without SDK wording --- tests/test_litellm/experimental_mcp_client/test_mcp_client.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_litellm/experimental_mcp_client/test_mcp_client.py b/tests/test_litellm/experimental_mcp_client/test_mcp_client.py index 14ef5213d7a..7e4598c2e58 100644 --- a/tests/test_litellm/experimental_mcp_client/test_mcp_client.py +++ b/tests/test_litellm/experimental_mcp_client/test_mcp_client.py @@ -1273,10 +1273,11 @@ async def test_sdk_cross_origin_redirect_never_contacts_destination(operation: s if operation == "list" else client.call_tool(CallToolRequestParams(name="add", arguments={"a": 2, "b": 3}), raise_on_error=True) ) - with anyio.fail_after(10), pytest.raises(MCPError, match=r"Redirect to .*destination.* not followed"): + with anyio.fail_after(10), pytest.raises(MCPError): await pending_operation assert responder.call_count == 1 request: Final = responder.call_args.args[0] + assert request.method == "POST" assert request.url == "https://upstream.example.com/mcp" assert request.headers["x-upstream-token"] == "Bearer synthetic-token" assert all(call.args[0].url.host != "destination.example.com" for call in responder.call_args_list)