From bcf90d5ae70e0d337894688b645fb2bc80ce89f5 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:50:35 -0400 Subject: [PATCH] Remove unnecessary mode="legacy" pins from proxy tests Most pins in tests/server/providers/proxy/ were added only to keep tests green while unpinning changed which protocol era the proxy's backend connection used, not because the test's subject cared about the era. With proxy era-mirroring (#4573) landed, a front client on auto correctly moves the whole chain to the modern protocol, so plain tool/resource/prompt calls through a proxy no longer need a pin. Kept pins fall into three buckets, each commented at the call site: tests whose subject is genuinely handshake-only (sampling, roots, elicitation push-forwarding, ping, initialize handshake mechanics); tests whose backend is a directly-constructed ProxyClient/StatefulProxyClient, which always defaults to legacy independent of the front era; and two tests left pinned with a TODO documenting a real defect this audit surfaced (upstream instructions not forwarded to a modern-era client through a proxy, and ProxyProvider.list_tools leaking an unwrapped connection error instead of an MCPError). --- .../providers/proxy/test_proxy_client.py | 27 ++- .../providers/proxy/test_proxy_server.py | 198 +++++++++++------- .../proxy/test_stateful_proxy_client.py | 19 +- 3 files changed, 171 insertions(+), 73 deletions(-) diff --git a/tests/server/providers/proxy/test_proxy_client.py b/tests/server/providers/proxy/test_proxy_client.py index e99190c0e..bc3b861fc 100644 --- a/tests/server/providers/proxy/test_proxy_client.py +++ b/tests/server/providers/proxy/test_proxy_client.py @@ -125,6 +125,23 @@ def fastmcp_server(): async def proxy_server(fastmcp_server: FastMCP): """ A proxy server that forwards interactions with the proxy client to the given fastmcp server. + + `ProxyClient(fastmcp_server)` defaults to `mode="legacy"` (see + `TestProxyClientEraDefault` above — a directly-constructed `ProxyClient` + always pins the handshake era, independent of `create_proxy`'s era + mirroring). Every test below that forwards a tool call through this + fixture (not just a listing) needs its front `Client` pinned to + `mode="legacy"` too, for either or both of two reasons: + + - The test's subject is itself a handshake-only feature (roots / sampling + / elicitation push, logging, progress): the modern era has no + back-channel for server-initiated requests at all, so these forwarding + paths cannot exist there. + - Even for subjects that work on both eras, a modern front's request + `_meta` carries reserved modern-envelope keys that `ProxyTool.run`'s + legacy-backend path forwards verbatim onto this legacy-locked backend + session, which the backend server then rejects as a protocol + violation. """ return create_proxy(ProxyClient(fastmcp_server)) @@ -134,7 +151,7 @@ class TestProxyClient: """ Test that the proxy client correctly forwards the `echo` tool meta. """ - async with Client(proxy_server, mode="legacy") as client: + async with Client(proxy_server) as client: tools = await client.list_tools() echo_tool = next(t for t in tools if t.name == "echo") assert echo_tool.meta == {"fastmcp": {"tags": ["echo"]}} @@ -456,7 +473,7 @@ class TestProxyClient: from fastmcp.server.providers.proxy import FastMCPProxy # Create a disconnected client (should use fresh sessions per request) - base_client = Client(fastmcp_server, mode="legacy") + base_client = Client(fastmcp_server) # Test both create_proxy convenience function and direct client_factory usage proxy_via_create_proxy = create_proxy(base_client) @@ -526,6 +543,12 @@ class TestProxyServerInitiatedForwardingNonTool: Before the fix, only ProxyTool.run stashed the proxy's request context, so resources/templates/prompts forwarded the request into the backend's own context and deadlocked. + + Every test here pins the front to `mode="legacy"`: `roots/list` is a + server-initiated request over the handshake's back-channel, which the + modern (2026-07-28) era removes entirely — a modern front raises "this + transport context has no back-channel for server-initiated requests" + rather than reaching the roots handler at all. """ async def test_proxied_resource_forwards_list_roots( diff --git a/tests/server/providers/proxy/test_proxy_server.py b/tests/server/providers/proxy/test_proxy_server.py index 7b3895095..cedd5f595 100644 --- a/tests/server/providers/proxy/test_proxy_server.py +++ b/tests/server/providers/proxy/test_proxy_server.py @@ -163,7 +163,20 @@ def fastmcp_server(): @pytest.fixture async def proxy_server(fastmcp_server): - """Fixture that creates a FastMCP proxy server.""" + """Fixture that creates a FastMCP proxy server. + + Passing an already-constructed `ProxyClient` as the target (rather than a + raw `FastMCP`/URL/etc.) means `create_proxy` reuses that client as-is + instead of building one through the era-mirroring factory — so this + backend stays pinned to `ProxyClient`'s own default of `mode="legacy"` + regardless of what era the front client negotiates. A test that actually + forwards a tool *call* through this fixture (not just a listing) needs + its own front `Client` pinned to `mode="legacy"` too: otherwise a modern + front's request `_meta` carries the reserved modern-envelope keys, which + `ProxyTool.run`'s legacy-backend path forwards verbatim onto this + legacy-locked backend session, and the backend server rejects it as a + protocol violation. + """ return create_proxy(ProxyClient(transport=FastMCPTransport(fastmcp_server))) @@ -180,7 +193,7 @@ async def test_create_proxy_with_client(fastmcp_server): async def test_create_proxy_with_server(fastmcp_server): """create_proxy should accept a FastMCP instance.""" proxy = create_proxy(fastmcp_server) - async with Client(proxy, mode="legacy") as client: + async with Client(proxy) as client: result = await client.call_tool("greet", {"name": "Test"}) assert result.data == "Hello, Test!" @@ -188,7 +201,7 @@ async def test_create_proxy_with_server(fastmcp_server): async def test_create_proxy_with_transport(fastmcp_server): """create_proxy should accept a ClientTransport.""" proxy = create_proxy(FastMCPTransport(fastmcp_server)) - async with Client(proxy, mode="legacy") as client: + async with Client(proxy) as client: result = await client.call_tool("greet", {"name": "Test"}) assert result.data == "Hello, Test!" @@ -198,9 +211,16 @@ async def test_proxy_forwards_upstream_instructions(): upstream = FastMCP(name="upstream", instructions="USE_THIS_MARKER_123") proxy = create_proxy(upstream, name="proxy") + # TODO(mode="legacy" pin): `ProxyInitializeMiddleware.on_initialize` is the + # only place that copies the upstream's instructions onto the proxy's own + # handshake result, and `on_initialize` only fires for the legacy handshake. + # A front client on `mode="auto"` negotiates `server/discover` instead, whose + # result the SDK builds straight from `self.instructions` with no equivalent + # hook — so upstream instructions silently never reach a modern-era client + # through a proxy. `client.session.instructions` (era-neutral) comes back + # None here instead of "USE_THIS_MARKER_123". Real defect, not a test bug. async with Client(proxy, mode="legacy") as client: - assert client.initialize_result is not None - assert client.initialize_result.instructions == "USE_THIS_MARKER_123" + assert client.session.instructions == "USE_THIS_MARKER_123" async def test_proxy_own_instructions_take_precedence(): @@ -208,9 +228,8 @@ async def test_proxy_own_instructions_take_precedence(): upstream = FastMCP(name="upstream", instructions="upstream instructions") proxy = create_proxy(upstream, name="proxy", instructions="proxy instructions") - async with Client(proxy, mode="legacy") as client: - assert client.initialize_result is not None - assert client.initialize_result.instructions == "proxy instructions" + async with Client(proxy) as client: + assert client.session.instructions == "proxy instructions" async def test_proxy_instructions_none_when_upstream_has_none(): @@ -218,9 +237,8 @@ async def test_proxy_instructions_none_when_upstream_has_none(): upstream = FastMCP(name="upstream") proxy = create_proxy(upstream, name="proxy") - async with Client(proxy, mode="legacy") as client: - assert client.initialize_result is not None - assert client.initialize_result.instructions is None + async with Client(proxy) as client: + assert client.session.instructions is None def test_create_proxy_with_url(): @@ -261,6 +279,15 @@ async def test_proxy_ping_surfaces_wrong_remote_path(): async with run_server_async(remote, transport="http") as url: proxy = create_proxy(StreamableHttpTransport(url.removesuffix("/mcp"))) + # This asserts the error surfaces from merely *connecting* to the proxy, + # with no operation performed. That only happens on the legacy handshake: + # `ProxyInitializeMiddleware.on_initialize` eagerly probes the backend + # during the front's own `initialize` call. A modern front negotiates + # `server/discover` instead, which never runs that middleware hook, so + # connecting succeeds regardless of backend health and the failure would + # only surface on first real use. Pinned because the subject here is + # that eager, handshake-time probe. + # # SDK v2 surfaces a wrong remote path as an HTTP "Not Found" rather than # the v1 "Session terminated" message. with pytest.raises(MCPError, match="Not Found"): @@ -275,6 +302,9 @@ async def test_proxy_initialize_forwards_remote_connection_error(): provider_error_strategy="raise", ) + # Same reasoning as test_proxy_ping_surfaces_wrong_remote_path above: the + # error surfaces from connecting alone only via the legacy handshake's + # eager backend probe in `ProxyInitializeMiddleware.on_initialize`. with pytest.raises(MCPError, match="Client failed to connect"): async with Client(proxy, mode="legacy"): pass @@ -298,6 +328,17 @@ async def test_proxy_list_tools_client_surfaces_remote_connection_error(): provider_error_strategy="raise", ) + # TODO(mode="legacy" pin): with a modern front, connecting succeeds (no + # eager backend probe — see test_proxy_ping_surfaces_wrong_remote_path) + # and the failure only surfaces once `list_tools()` actually hits the + # dead backend. But `ProxyProvider._list_tools` only catches `MCPError`; + # a raw `httpx2.ConnectError` from the failed backend connect propagates + # unwrapped instead of becoming the `MCPError("Client failed to + # connect...")` this test expects. `ProxyInitializeMiddleware.on_initialize` + # and `ProxyTool.run` already normalize connection failures this way — + # the same `except (RuntimeError, TimeoutError, httpx2.HTTPError, ...)` + # handling appears to be missing from `ProxyProvider`'s list methods. + # Real defect, not a test bug. with pytest.raises(MCPError, match="Client failed to connect"): async with Client(proxy, mode="legacy") as client: await client.list_tools() @@ -356,7 +397,7 @@ class TestTools: ) proxy = create_proxy(server) - async with Client(proxy, mode="legacy") as client: + async with Client(proxy) as client: result = await client.call_tool("add_transformed", {"a": 1, "b": 2}) assert result.data == 3 @@ -366,16 +407,18 @@ class TestTools: assert tool.description is None async def test_list_tools_same_as_original(self, fastmcp_server, proxy_server): - async with Client(fastmcp_server, mode="legacy") as original_client: + async with Client(fastmcp_server) as original_client: original = await original_client.list_tools() - async with Client(proxy_server, mode="legacy") as proxy_client: + async with Client(proxy_server) as proxy_client: proxied = await proxy_client.list_tools() assert proxied == original async def test_call_tool_result_same_as_original( self, fastmcp_server: FastMCP, proxy_server: FastMCPProxy ): - async with Client(fastmcp_server, mode="legacy") as original_client: + # proxy_server's backend is pinned to legacy (see its fixture docstring); + # match the front so a real tool call doesn't cross eras. + async with Client(fastmcp_server) as original_client: result = await original_client.call_tool("greet", {"name": "Alice"}) async with Client(proxy_server, mode="legacy") as proxy_client: proxy_result = await proxy_client.call_tool("greet", {"name": "Alice"}) @@ -384,11 +427,13 @@ class TestTools: assert result.data == proxy_result.data async def test_call_tool_calls_tool(self, proxy_server): + # See proxy_server fixture docstring: its backend is pinned to legacy. async with Client(proxy_server, mode="legacy") as client: proxy_result = await client.call_tool("add", {"a": 1, "b": 2}) assert proxy_result.data == 3 async def test_error_tool_raises_error(self, proxy_server): + # See proxy_server fixture docstring: its backend is pinned to legacy. with pytest.raises(ToolError, match="This is a test error"): async with Client(proxy_server, mode="legacy") as client: await client.call_tool("error_tool", {}) @@ -407,7 +452,7 @@ class TestTools: Client, "call_tool_mcp", new_callable=AsyncMock, return_value=error_result ): with pytest.raises(ToolError): - async with Client(proxy_server, mode="legacy") as client: + async with Client(proxy_server) as client: await client.call_tool("error_tool", {}) async def test_error_tool_with_empty_content(self, proxy_server): @@ -420,7 +465,7 @@ class TestTools: Client, "call_tool_mcp", new_callable=AsyncMock, return_value=error_result ): with pytest.raises(ToolError): - async with Client(proxy_server, mode="legacy") as client: + async with Client(proxy_server) as client: await client.call_tool("error_tool", {}) async def test_error_tool_passthrough_preserves_content(self, proxy_server): @@ -437,7 +482,7 @@ class TestTools: with patch.object( Client, "call_tool_mcp", new_callable=AsyncMock, return_value=error_result ): - async with Client(proxy_server, mode="legacy") as client: + async with Client(proxy_server) as client: result = await client.call_tool("error_tool", {}, raise_on_error=False) assert result.is_error is True @@ -456,6 +501,7 @@ class TestTools: meta={"custom_key": "custom_value", "processed": True}, ) + # See proxy_server fixture docstring: its backend is pinned to legacy. async with Client(proxy_server, mode="legacy") as client: result = await client.call_tool("tool_with_meta", {"value": "test"}) @@ -472,7 +518,7 @@ class TestTools: def greet(name: str, extra: str = "extra") -> str: return f"Overwritten, {name}! {extra}" - async with Client(proxy_server, mode="legacy") as client: + async with Client(proxy_server) as client: result = await client.call_tool("greet", {"name": "Marvin", "extra": "abc"}) assert result.data == "Overwritten, Marvin! abc" @@ -485,7 +531,7 @@ class TestTools: def greet(name: str, extra: str = "extra") -> str: return f"Overwritten, {name}! {extra}" - async with Client(proxy_server, mode="legacy") as client: + async with Client(proxy_server) as client: tools = await client.list_tools() greet_tool = next(t for t in tools if t.name == "greet") assert "extra" in greet_tool.input_schema["properties"] @@ -508,27 +554,27 @@ class TestResources: assert wave_resource.icons == [Icon(src="https://example.com/wave-icon.png")] async def test_list_resources_same_as_original(self, fastmcp_server, proxy_server): - async with Client(fastmcp_server, mode="legacy") as original_client: + async with Client(fastmcp_server) as original_client: original = await original_client.list_resources() - async with Client(proxy_server, mode="legacy") as proxy_client: + async with Client(proxy_server) as proxy_client: proxied = await proxy_client.list_resources() assert proxied == original async def test_read_resource(self, proxy_server: FastMCPProxy): - async with Client(proxy_server, mode="legacy") as client: + async with Client(proxy_server) as client: result = await client.read_resource("resource://wave") assert isinstance(result[0], TextResourceContents) assert result[0].text == "👋" async def test_read_resource_same_as_original(self, fastmcp_server, proxy_server): - async with Client(fastmcp_server, mode="legacy") as client: + async with Client(fastmcp_server) as client: result = await client.read_resource("resource://wave") - async with Client(proxy_server, mode="legacy") as client: + async with Client(proxy_server) as client: proxy_result = await client.read_resource("resource://wave") assert proxy_result == result async def test_read_json_resource(self, proxy_server: FastMCPProxy): - async with Client(proxy_server, mode="legacy") as client: + async with Client(proxy_server) as client: result = await client.read_resource("data://users") assert len(result) == 1 assert isinstance(result[0], TextResourceContents) @@ -541,11 +587,11 @@ class TestResources: ): """Test that proxy correctly returns all resource contents, not just the first one.""" # Read from original server - async with Client(fastmcp_server, mode="legacy") as client: + async with Client(fastmcp_server) as client: original_result = await client.read_resource("data://multi") # Read from proxy server - async with Client(proxy_server, mode="legacy") as client: + async with Client(proxy_server) as client: proxy_result = await client.read_resource("data://multi") # Both should return the same number of contents @@ -574,7 +620,7 @@ class TestResources: with pytest.raises( MCPError, match="Resource not found: 'resource://nonexistent'" ): - async with Client(proxy_server, mode="legacy") as client: + async with Client(proxy_server) as client: await client.read_resource("resource://nonexistent") async def test_proxy_can_overwrite_proxied_resource(self, proxy_server): @@ -586,7 +632,7 @@ class TestResources: def overwritten_wave() -> str: return "Overwritten wave! 🌊" - async with Client(proxy_server, mode="legacy") as client: + async with Client(proxy_server) as client: result = await client.read_resource("resource://wave") assert isinstance(result[0], TextResourceContents) assert result[0].text == "Overwritten wave! 🌊" @@ -600,7 +646,7 @@ class TestResources: def overwritten_wave() -> str: return "Overwritten wave! 🌊" - async with Client(proxy_server, mode="legacy") as client: + async with Client(proxy_server) as client: resources = await client.list_resources() wave_resource = next( r for r in resources if str(r.uri) == "resource://wave" @@ -627,15 +673,15 @@ class TestResourceTemplates: async def test_list_resource_templates_same_as_original( self, fastmcp_server, proxy_server ): - async with Client(fastmcp_server, mode="legacy") as original_client: + async with Client(fastmcp_server) as original_client: result = await original_client.list_resource_templates() - async with Client(proxy_server, mode="legacy") as proxy_client: + async with Client(proxy_server) as proxy_client: proxy_result = await proxy_client.list_resource_templates() assert proxy_result == result @pytest.mark.parametrize("id", [1, 2, 3]) async def test_read_resource_template(self, proxy_server: FastMCPProxy, id: int): - async with Client(proxy_server, mode="legacy") as client: + async with Client(proxy_server) as client: result = await client.read_resource(f"data://user/{id}") assert isinstance(result[0], TextResourceContents) assert json.loads(result[0].text) == USERS[id - 1] @@ -643,9 +689,9 @@ class TestResourceTemplates: async def test_read_resource_template_same_as_original( self, fastmcp_server, proxy_server ): - async with Client(fastmcp_server, mode="legacy") as client: + async with Client(fastmcp_server) as client: result = await client.read_resource("data://user/1") - async with Client(proxy_server, mode="legacy") as client: + async with Client(proxy_server) as client: proxy_result = await client.read_resource("data://user/1") assert proxy_result == result @@ -654,11 +700,11 @@ class TestResourceTemplates: ): """Test that proxy template correctly returns all resource contents.""" # Read from original server - async with Client(fastmcp_server, mode="legacy") as client: + async with Client(fastmcp_server) as client: original_result = await client.read_resource("data://multi/test123") # Read from proxy server - async with Client(proxy_server, mode="legacy") as client: + async with Client(proxy_server) as client: proxy_result = await client.read_resource("data://multi/test123") # Both should return the same number of contents @@ -696,7 +742,7 @@ class TestResourceTemplates: } ) - async with Client(proxy_server, mode="legacy") as client: + async with Client(proxy_server) as client: result = await client.read_resource("data://user/1") assert isinstance(result[0], TextResourceContents) user_data = json.loads(result[0].text) @@ -712,7 +758,7 @@ class TestResourceTemplates: def overwritten_get_user(user_id: str) -> dict[str, Any]: return {"id": user_id, "name": "Overwritten User", "active": True} - async with Client(proxy_server, mode="legacy") as client: + async with Client(proxy_server) as client: templates = await client.list_resource_templates() user_template = next( t for t in templates if t.uri_template == "data://user/{user_id}" @@ -730,8 +776,8 @@ class TestResourceTemplateQueryParams: def get_data(id: str, format: str = "json") -> str: return f"id={id} format={format}" - proxy = create_proxy(Client(remote, mode="legacy")) - async with Client(proxy, mode="legacy") as client: + proxy = create_proxy(Client(remote)) + async with Client(proxy) as client: result = await client.read_resource("data://123?format=xml") assert isinstance(result[0], TextResourceContents) assert result[0].text == "id=123 format=xml" @@ -743,8 +789,8 @@ class TestResourceTemplateQueryParams: def get_data(id: str, format: str = "json") -> str: return f"id={id} format={format}" - proxy = create_proxy(Client(remote, mode="legacy")) - async with Client(proxy, mode="legacy") as client: + proxy = create_proxy(Client(remote)) + async with Client(proxy) as client: result = await client.read_resource("data://123") assert isinstance(result[0], TextResourceContents) assert result[0].text == "id=123 format=json" @@ -756,8 +802,8 @@ class TestResourceTemplateQueryParams: def get_data(id: str, limit: int = 10, offset: int = 0) -> str: return f"id={id} limit={limit} offset={offset}" - proxy = create_proxy(Client(remote, mode="legacy")) - async with Client(proxy, mode="legacy") as client: + proxy = create_proxy(Client(remote)) + async with Client(proxy) as client: result = await client.read_resource("data://abc?limit=5&offset=20") assert isinstance(result[0], TextResourceContents) assert result[0].text == "id=abc limit=5 offset=20" @@ -769,8 +815,8 @@ class TestResourceTemplateQueryParams: def get_data(id: str) -> str: return f"id={id}" - proxy = create_proxy(Client(remote, mode="legacy")) - async with Client(proxy, mode="legacy") as client: + proxy = create_proxy(Client(remote)) + async with Client(proxy) as client: result = await client.read_resource("data://a%2Fb") assert isinstance(result[0], TextResourceContents) assert result[0].text == "id=a/b" @@ -782,8 +828,8 @@ class TestResourceTemplateQueryParams: def get_data(id: str, api_version: str = "v1") -> str: return f"id={id} api_version={api_version}" - proxy = create_proxy(Client(remote, mode="legacy")) - async with Client(proxy, mode="legacy") as client: + proxy = create_proxy(Client(remote)) + async with Client(proxy) as client: result = await client.read_resource("data://123?api-version=v2") assert isinstance(result[0], TextResourceContents) assert result[0].text == "id=123 api_version=v2" @@ -803,8 +849,8 @@ class TestResourceTemplateQueryParams: def get_data(id: str, api_version: str = "v1") -> str: return f"id={id} api_version={api_version}" - proxy = create_proxy(Client(remote, mode="legacy")) - async with Client(proxy, mode="legacy") as client: + proxy = create_proxy(Client(remote)) + async with Client(proxy) as client: result = await client.read_resource("data://123?api-version=a%2Fb") assert isinstance(result[0], TextResourceContents) assert result[0].text == "id=123 api_version=a/b" @@ -825,23 +871,23 @@ class TestPrompts: ] async def test_list_prompts_same_as_original(self, fastmcp_server, proxy_server): - async with Client(fastmcp_server, mode="legacy") as client: + async with Client(fastmcp_server) as client: result = await client.list_prompts() - async with Client(proxy_server, mode="legacy") as client: + async with Client(proxy_server) as client: proxy_result = await client.list_prompts() assert proxy_result == result async def test_render_prompt_same_as_original( self, fastmcp_server: FastMCP, proxy_server: FastMCPProxy ): - async with Client(fastmcp_server, mode="legacy") as client: + async with Client(fastmcp_server) as client: result = await client.get_prompt("welcome", {"name": "Alice"}) - async with Client(proxy_server, mode="legacy") as client: + async with Client(proxy_server) as client: proxy_result = await client.get_prompt("welcome", {"name": "Alice"}) assert proxy_result == result async def test_render_prompt_calls_prompt(self, proxy_server): - async with Client(proxy_server, mode="legacy") as client: + async with Client(proxy_server) as client: result = await client.get_prompt("welcome", {"name": "Alice"}) assert result.messages[0].role == "user" assert isinstance(result.messages[0].content, TextContent) @@ -856,7 +902,7 @@ class TestPrompts: def welcome(name: str, extra: str = "friend") -> str: return f"Overwritten welcome, {name}! You are my {extra}." - async with Client(proxy_server, mode="legacy") as client: + async with Client(proxy_server) as client: result = await client.get_prompt( "welcome", {"name": "Alice", "extra": "colleague"} ) @@ -876,7 +922,7 @@ class TestPrompts: def welcome(name: str, extra: str = "friend") -> str: return f"Overwritten welcome, {name}! You are my {extra}." - async with Client(proxy_server, mode="legacy") as client: + async with Client(proxy_server) as client: prompts = await client.list_prompts() welcome_prompt = next(p for p in prompts if p.name == "welcome") # Check that the overwritten prompt has the additional 'extra' parameter @@ -887,9 +933,9 @@ class TestPrompts: self, fastmcp_server: FastMCP, proxy_server: FastMCPProxy ): """Test that ProxyPrompt preserves ImageContent without lossy conversion.""" - async with Client(fastmcp_server, mode="legacy") as client: + async with Client(fastmcp_server) as client: result = await client.get_prompt("image_prompt") - async with Client(proxy_server, mode="legacy") as client: + async with Client(proxy_server) as client: proxy_result = await client.get_prompt("image_prompt") # The proxy result should match the original exactly @@ -1188,9 +1234,14 @@ class TestProxyOutputSchemaEnforcement: async def _call_without_validating(self, server: FastMCP, tool: str): """Call through a client that does not enforce the schema itself.""" - # The proxy backend here is a legacy-era ProxyClient, so pin the end - # client to the handshake era too; the modern-end-client-through-proxy - # path is the separate proxy era-mirroring workstream. + # This proxy's backend is built via `ProxyProvider(lambda: ProxyClient(...))` + # directly rather than through `create_proxy`'s era-mirroring factory, so it + # stays pinned to `ProxyClient`'s own default of `mode="legacy"` regardless + # of the front era (see the `proxy_server` fixture docstring above for the + # full explanation). Pin the end client to match: a modern front's request + # `_meta` carries reserved modern-envelope keys that `ProxyTool.run`'s + # legacy-backend path forwards verbatim, and this legacy-locked backend + # session rejects them as a protocol violation. client = Client(server, mode="legacy") client._transport_options = TransportOptions( session_class=_ForwardingClientSession @@ -1233,7 +1284,8 @@ class TestProxyOutputSchemaEnforcement: ProxyProvider(lambda: ProxyClient(backend_violating_its_schema)) ) - # Legacy-era ProxyClient backend: pin the end client to match (see above). + # `ProxyClient(backend_violating_its_schema)` above is pinned to legacy + # (see `_call_without_validating`'s comment); match the front here too. async with Client(proxy, mode="legacy") as client: with pytest.raises(RuntimeError, match="Invalid structured content"): await client.call_tool_mcp("undeclared_status", {}) @@ -1279,7 +1331,8 @@ class TestProxyOutputSchemaEnforcement: proxy = FastMCP("Proxy") proxy.add_provider(ProxyProvider(lambda: ProxyClient(backend))) - # Legacy-era ProxyClient backend: pin the end client to match (see above). + # `ProxyClient(backend)` above is pinned to legacy (see + # `_call_without_validating`'s comment); match the front here too. async with Client(proxy, mode="legacy") as client: await client.call_tool("echo", {"n": 1}) lists_after_first = counts["list"] @@ -1357,10 +1410,15 @@ class TestProxyForwardingAppliesToEveryBackendClient: async def _forwarded( self, server: FastMCP, tool: str = "status", mode: str = "auto" ): - # `mode` follows the proxy backend's era: a modern-capable backend (plain - # Client / single-server config) lets the end client stay on the default - # auto era, while a legacy-only backend (multi-server config) needs the end - # client pinned to legacy until proxy era-mirroring lands. + # `mode` follows the proxy backend's era: a plain Client or single-server + # config connects the backend directly, so it mirrors the front's auto + # era. A multi-server config instead mounts a router with a + # StatefulProxyClient per configured server leg — an already-constructed + # ProxyClient subclass, same as the `proxy_server` fixture above, pinned + # to `mode="legacy"` regardless of the front. Callers with that backend + # shape must pin the end client to legacy too, for the reason explained + # there (a modern front's request `_meta` gets forwarded verbatim onto a + # legacy-locked backend session and rejected as a protocol violation). client = Client(server, mode=mode) client._transport_options = TransportOptions( session_class=_ForwardingClientSession diff --git a/tests/server/providers/proxy/test_stateful_proxy_client.py b/tests/server/providers/proxy/test_stateful_proxy_client.py index f9389d608..1b64afd2d 100644 --- a/tests/server/providers/proxy/test_stateful_proxy_client.py +++ b/tests/server/providers/proxy/test_stateful_proxy_client.py @@ -58,6 +58,16 @@ def fastmcp_server(): @pytest.fixture async def stateful_proxy_server(fastmcp_server: FastMCP): + # `StatefulProxyClient` is a `ProxyClient` subclass, so it inherits the same + # `mode="legacy"` default for a directly-constructed instance (see + # `TestProxyClientEraDefault` in test_proxy_client.py) — this backend isn't + # built through `create_proxy`'s era-mirroring factory, so it stays pinned + # regardless of the front era. Every test below that forwards a real tool + # call through this fixture pins its front `Client` to `mode="legacy"` too: + # otherwise a modern front's request `_meta` carries reserved + # modern-envelope keys that `ProxyTool.run`'s legacy-backend path forwards + # verbatim, and this legacy-locked backend session rejects them as a + # protocol violation. client = StatefulProxyClient(transport=FastMCPTransport(fastmcp_server)) return FastMCPProxy(client_factory=client.new_stateful) @@ -119,6 +129,7 @@ class TestStatefulProxyClient: async def test_stateful_proxy(self, stateful_proxy_server: FastMCP): """Test that the state shared across multiple calls for the same client (fixes #959).""" + # See stateful_proxy_server fixture: its backend is pinned to legacy. async with Client(stateful_proxy_server, mode="legacy") as client: with pytest.raises(ToolError, match="Value not found"): await client.call_tool("stateful_get", {}) @@ -130,6 +141,7 @@ class TestStatefulProxyClient: async def test_stateless_proxy(self, stateless_server: str): """Test that the state will not be shared across different calls, even if they are from the same client.""" + # See stateful_proxy_server fixture: its backend is pinned to legacy. async with Client(stateless_server, mode="legacy") as client: await client.call_tool("stateful_put", {"value": 1}) @@ -158,6 +170,8 @@ class TestStatefulProxyClient: multi_proxy_mcp.mount(proxy_mcp_a, namespace="a") multi_proxy_mcp.mount(proxy_mcp_b, namespace="b") + # Both mounted backends are directly-constructed StatefulProxyClients + # (see stateful_proxy_server fixture note above), pinned to legacy. async with Client(multi_proxy_mcp, mode="legacy") as client: result_a = await client.call_tool("a_tool_a", {}) result_b = await client.call_tool("b_tool_b", {}) @@ -203,7 +217,10 @@ class TestStatefulProxyClient: return ElicitResult(action="accept", content=response_type(name="Alice")) # Run the proxy over HTTP so the transport uses - # related_request_id routing for server-initiated messages. + # related_request_id routing for server-initiated messages. Elicitation + # is a handshake-only back-channel feature, and the backend is a + # directly-constructed StatefulProxyClient pinned to legacy regardless + # (see stateful_proxy_server fixture note above) — pin the front to match. async with run_server_async(proxy) as proxy_url: async with Client( proxy_url, mode="legacy", elicitation_handler=elicitation_handler