diff --git a/docs/clients/client.mdx b/docs/clients/client.mdx index fb910777f..8ea9e21be 100644 --- a/docs/clients/client.mdx +++ b/docs/clients/client.mdx @@ -212,7 +212,7 @@ async with Client("https://example.com/mcp", mode="auto") as client: `mode="auto"` is the default as of FastMCP 4.0. Earlier versions defaulted to `"legacy"`. If a server behaves unexpectedly under discovery, or you depend on the legacy `initialize` result, pin the old behavior with `Client(..., mode="legacy")`. -The SSE transport is legacy-only — it cannot carry the sessionless modern era — so a client connecting over SSE always negotiates the legacy handshake, even under `mode="auto"`. A multi-server config (`MCPConfigTransport` with more than one server) is likewise legacy-only, because it mounts each backend behind a legacy-era proxy; a single-server config mirrors its one backend transport's era. +The SSE transport is legacy-only — it cannot carry the sessionless modern era — so a client connecting over SSE always negotiates the legacy handshake, even under `mode="auto"`. A multi-server config (`MCPConfigTransport` with more than one server) negotiates the best era shared by every connected backend: it stays modern when every backend is modern-capable and reconnects every leg under the handshake era when any backend requires legacy. Pinning `mode="legacy"` or a modern version applies that mode to every backend. All-modern configurations follow normal modern semantics; applications that rely on handshake-era server-initiated sampling or elicitation should pin `mode="legacy"`. ## Response caching diff --git a/docs/more/faq.mdx b/docs/more/faq.mdx index 5566e908f..df30c3d51 100644 --- a/docs/more/faq.mdx +++ b/docs/more/faq.mdx @@ -16,7 +16,7 @@ One change is silent, so go looking for it: an `except httpx.ConnectError:` arou `fastmcp.Client` defaults to `mode="auto"` as of FastMCP 4, so it negotiates the newest era both sides speak rather than pinning the handshake. Over streamable HTTP or stdio to a FastMCP server that means the sessionless `2026-07-28` protocol, where FastMCP 3 connected at `2025-11-25`. -Two transports are exceptions: SSE predates the sessionless era and cannot carry it, and a multi-server `MCPConfigTransport` mounts each backend behind a legacy-era composite. Under `mode="auto"` the client recognizes both and settles on the handshake without probing, so seeing `2025-11-25` there is correct rather than a negotiation failure. Pinning a modern version explicitly on either skips that substitution and asks the transport for something it cannot serve, so leave them on auto or legacy. +SSE is the transport exception: it predates the sessionless era and cannot carry it. Under `mode="auto"` the client recognizes SSE and settles on the handshake without probing, so seeing `2025-11-25` there is correct rather than a negotiation failure. A multi-server `MCPConfigTransport` resolves one era for the entire aggregate: all-modern backends negotiate `2026-07-28`, while any legacy backend moves the composite and every backend leg to the handshake era. Pinning a mode applies it to every backend in the configuration. All-modern configurations follow normal modern semantics; applications that rely on handshake-era server-initiated sampling or elicitation should pin `mode="legacy"`. The client probes `server/discover` and adopts the modern protocol when the server answers, falling back to the `initialize` handshake for anything that is not positive evidence of a modern peer — so a mixed fleet of servers still connects. Pin the old behavior per client with `Client(url, mode="legacy")`. See [Protocol negotiation](/clients/client#protocol-negotiation). diff --git a/fastmcp_slim/fastmcp/client/client.py b/fastmcp_slim/fastmcp/client/client.py index ae1dde726..148bf7609 100644 --- a/fastmcp_slim/fastmcp/client/client.py +++ b/fastmcp_slim/fastmcp/client/client.py @@ -9,7 +9,7 @@ import ssl import uuid from collections.abc import AsyncIterator, Callable, Coroutine, Mapping, Sequence from contextlib import AsyncExitStack, asynccontextmanager, suppress -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from pathlib import Path from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, cast, overload @@ -800,11 +800,23 @@ class Client( @asynccontextmanager async def _context_manager(self): + transport_options = self._transport_options + if ( + isinstance(self.transport, MCPConfigTransport) + and len(self.transport.config.mcpServers) > 1 + ): + # Resolve mutable connection policy here rather than at construction: + # both `Client.mode` and an MCPConfig's server set may change before + # the client connects. Preserve options contributed by other layers. + transport_options = replace( + transport_options or TransportOptions(), backend_mode=self.mode + ) + # Only passed when this client actually wants non-default settings, so an # ordinary client never sends an argument a transport might not accept. - if self._transport_options is not None: + if transport_options is not None: connection = self.transport.connect_session( - transport_options=self._transport_options, **self._session_kwargs + transport_options=transport_options, **self._session_kwargs ) else: connection = self.transport.connect_session(**self._session_kwargs) @@ -847,9 +859,10 @@ class Client( else: timeout = normalize_timeout_to_seconds(timeout) - # A legacy-only transport (SSE, a multi-server proxy config) cannot serve - # the modern era; treat "auto" as "legacy" there rather than probing - # server/discover, which some such servers answer but then cannot serve. + # A legacy-only transport cannot serve the modern era; treat "auto" as + # "legacy" there rather than probing server/discover. Multi-server config + # transports resolve this flag from their connected backend set before + # negotiation reaches this point. effective_mode = self.mode if effective_mode == "auto" and self.transport.legacy_only: effective_mode = "legacy" diff --git a/fastmcp_slim/fastmcp/client/transports/base.py b/fastmcp_slim/fastmcp/client/transports/base.py index 9a3d9e09f..32ca3a5c3 100644 --- a/fastmcp_slim/fastmcp/client/transports/base.py +++ b/fastmcp_slim/fastmcp/client/transports/base.py @@ -44,6 +44,8 @@ class TransportOptions: These belong to the client rather than to the transport, so a transport shared between clients doesn't leak one client's settings to another. + Different client layers may own different fields; a layer that adds its + settings must preserve the existing options rather than replace the bundle. Attributes: session_class: The ClientSession class to instantiate. Proxies supply a @@ -59,9 +61,10 @@ class TransportOptions: transport builds on this client's behalf, so a chain of connections speaks one protocol era end to end. `None` leaves each backend client at its own default. Honored by `MCPConfigTransport`, whose - multi-server form mounts a proxy per configured server; ignored by - transports that connect to a single backend directly, since those - carry the connecting client's own session and era. + multi-server form mounts a proxy per configured server and resolves + one shared era for the aggregate; ignored by transports that connect + to a single backend directly, since those carry the connecting + client's own session and era. """ session_class: type[ClientSession] = ClientSession diff --git a/fastmcp_slim/fastmcp/client/transports/config.py b/fastmcp_slim/fastmcp/client/transports/config.py index 3e391b44f..0ab60638e 100644 --- a/fastmcp_slim/fastmcp/client/transports/config.py +++ b/fastmcp_slim/fastmcp/client/transports/config.py @@ -3,6 +3,7 @@ from collections.abc import AsyncIterator from typing import TYPE_CHECKING, Any from mcp import ClientSession +from mcp_types.version import MODERN_PROTOCOL_VERSIONS from typing_extensions import Unpack from fastmcp import _install_hints @@ -85,6 +86,7 @@ class MCPConfigTransport(ClientTransport): self.name_as_prefix = name_as_prefix self._transports: list[ClientTransport] = [] self._request_state_security: RequestStateSecurity | None = None + self._resolved_legacy_only = False if not self.config.mcpServers: raise ValueError("No MCP servers defined in the config") @@ -112,18 +114,17 @@ class MCPConfigTransport(ClientTransport): @property def legacy_only(self) -> bool: - """Whether this config can only carry the legacy protocol era. + """Whether the connected composite resolved to the legacy protocol era. A single-server config delegates directly to the underlying transport - (no proxy), so it inherits that transport's era capability — a modern - Streamable HTTP backend must stay modern-capable under `mode="auto"`. - A multi-server config mounts each backend behind a legacy-era - `ProxyClient` on a composite server, so the composite it exposes is - legacy-era and `mode="auto"` should negotiate the handshake. + (no proxy), so it inherits that transport's era capability. A + multi-server config resolves this value while connecting its backends: + all-modern backends leave the composite modern-capable, while any + legacy backend moves every leg to the handshake era. """ if len(self.config.mcpServers) == 1: return self.transport.legacy_only - return True + return self._resolved_legacy_only @contextlib.asynccontextmanager async def connect_session( @@ -152,49 +153,96 @@ class MCPConfigTransport(ClientTransport): ) from exc timeout = session_kwargs.get("read_timeout_seconds") - composite = FastMCP[Any]( - name="MCPRouter", request_state_security=self._request_state_security + requested_mode = ( + transport_options.backend_mode + if transport_options is not None + and transport_options.backend_mode is not None + else "legacy" ) - # The composite is only a router: every real backend is reached through - # one of the mounted proxies below, so the era the connecting client - # negotiates with the composite means nothing unless those backend legs - # negotiate it too. `backend_mode` carries the connecting client's era - # down to them, keeping the whole chain on one era end to end. - backend_mode = ( - transport_options.backend_mode if transport_options is not None else None - ) + # Close transports retained from a previous connection before replacing + # them. The active connection's exit stack owns their normal cleanup. + for transport in self._transports: + await transport.close() + self._transports = [] - async with contextlib.AsyncExitStack() as stack: - # Close any previous transports from prior connections to avoid leaking - for t in self._transports: - await t.close() - self._transports = [] + stack = contextlib.AsyncExitStack() + await stack.__aenter__() + try: + composite, backend_versions = await self._build_composite( + FastMCP, timeout, stack, requested_mode + ) - for name, server_config in self.config.mcpServers.items(): - try: - transport, _client, proxy = await self._create_proxy( - name, server_config, timeout, stack, backend_mode - ) - except Exception: # Broad catch is intentional: failure modes - # are diverse (OSError, TimeoutError, RuntimeError, etc.) - # and the whole point is to skip any server that can't connect. - logger.warning( - "Failed to connect to MCP server %r, skipping", - name, - exc_info=True, - ) - continue - self._transports.append(transport) - composite.mount(proxy, namespace=name if self.name_as_prefix else None) - - if not self._transports: - raise ConnectionError("All MCP servers failed to connect") + # `auto` is an aggregate negotiation: the composite can only expose + # one era to its caller, so a single legacy backend makes legacy the + # best mutual era. Reconnect the modern backends under that era too; + # otherwise push- and result-based interactions would meet halfway + # through the proxy chain and fail despite the frontend fallback. + legacy_backends = [ + version not in MODERN_PROTOCOL_VERSIONS for version in backend_versions + ] + if ( + requested_mode == "auto" + and any(legacy_backends) + and not all(legacy_backends) + ): + await stack.aclose() + stack = contextlib.AsyncExitStack() + await stack.__aenter__() + composite, _ = await self._build_composite( + FastMCP, timeout, stack, "legacy" + ) + self._resolved_legacy_only = True + else: + self._resolved_legacy_only = requested_mode == "legacy" or all( + legacy_backends + ) async with FastMCPTransport(mcp=composite).connect_session( transport_options=transport_options, **session_kwargs ) as session: yield session + finally: + await stack.aclose() + self._resolved_legacy_only = False + + async def _build_composite( + self, + fastmcp_type: type["FastMCP[Any]"], + timeout: float | None, + stack: contextlib.AsyncExitStack, + backend_mode: str, + ) -> tuple["FastMCP[Any]", list[str]]: + """Connect configured backends and mount their proxies on one router.""" + composite = fastmcp_type( + name="MCPRouter", request_state_security=self._request_state_security + ) + self._transports = [] + backend_versions: list[str] = [] + + for name, server_config in self.config.mcpServers.items(): + try: + transport, client, proxy = await self._create_proxy( + name, server_config, timeout, stack, backend_mode + ) + except Exception: # Broad catch is intentional: failure modes + # are diverse (OSError, TimeoutError, RuntimeError, etc.) and + # one unavailable server must not take down healthy siblings. + logger.warning( + "Failed to connect to MCP server %r, skipping", + name, + exc_info=True, + ) + continue + self._transports.append(transport) + assert client.protocol_version is not None + backend_versions.append(client.protocol_version) + composite.mount(proxy, namespace=name if self.name_as_prefix else None) + + if not self._transports: + raise ConnectionError("All MCP servers failed to connect") + + return composite, backend_versions async def _create_proxy( self, diff --git a/fastmcp_slim/fastmcp/server/providers/proxy.py b/fastmcp_slim/fastmcp/server/providers/proxy.py index c77a3b9d4..b26398d62 100644 --- a/fastmcp_slim/fastmcp/server/providers/proxy.py +++ b/fastmcp_slim/fastmcp/server/providers/proxy.py @@ -104,6 +104,17 @@ PROXY_TRANSPORT_OPTIONS = TransportOptions( ) +def _with_proxy_transport_options( + options: TransportOptions | None, +) -> TransportOptions: + """Layer proxy-owned settings onto options supplied by another client layer.""" + return replace( + options or TransportOptions(), + session_class=PROXY_TRANSPORT_OPTIONS.session_class, + forward_incoming_headers=PROXY_TRANSPORT_OPTIONS.forward_incoming_headers, + ) + + #: Transport-level failures that can escape a backend connection attempt. #: `Client._connect` wraps most connect failures in a ``RuntimeError("Client #: failed to connect: ...")``, but a transport can also surface an httpx or @@ -1353,7 +1364,8 @@ def _create_client_factory( # stopping at the composite router (see # `TransportOptions.backend_mode`). fresh._transport_options = replace( - PROXY_TRANSPORT_OPTIONS, backend_mode=fresh.mode + _with_proxy_transport_options(fresh._transport_options), + backend_mode=fresh.mode, ) return fresh @@ -1423,7 +1435,8 @@ def _create_client_factory( # moment a client is built for this request — so it tracks the # front era rather than whatever was true at construction. fresh._transport_options = replace( - PROXY_TRANSPORT_OPTIONS, backend_mode=backend_mode + _with_proxy_transport_options(fresh._transport_options), + backend_mode=backend_mode, ) return fresh @@ -1744,7 +1757,7 @@ class ProxyClient(Client[ClientTransportT]): self._proxy_restoring_handler_keys.add(key) super().__init__(transport=transport, **kwargs) # ty: ignore[no-matching-overload] - self._transport_options = PROXY_TRANSPORT_OPTIONS + self._transport_options = _with_proxy_transport_options(self._transport_options) def _bind_restoring_handlers(self) -> None: if "roots" in self._proxy_restoring_handler_keys: diff --git a/tests/server/test_mrtr_guards.py b/tests/server/test_mrtr_guards.py index 8355bdebe..f9af66656 100644 --- a/tests/server/test_mrtr_guards.py +++ b/tests/server/test_mrtr_guards.py @@ -636,6 +636,22 @@ class TestMultiServerConfigEraMirroring: """Two entries so the transport takes its multi-server composite path.""" return {"mcpServers": {"a": {"url": url}, "b": {"url": url}}} + async def test_direct_multi_server_client_drives_modern_guard_round_trips(self): + """The config-based client itself stays modern through every backend leg.""" + async with run_server_async(_era_reporting_backend()) as url: + asked: list[str] = [] + async with Client( + self._config(url), + elicitation_handler=_two_answer_handler(asked), + ) as client: + era = await client.call_tool("a_backend_era", {}) + result = await client.call_tool("a_book_flight", {}) + + assert client.protocol_version is None + assert era.data == "2026-07-28" + assert result.data == "Booked Paris on 2026-08-01" + assert len(asked) == 2 + async def test_modern_front_reaches_modern_backends(self): """A modern front reaches each real backend on a modern session, and a backend guard tool round-trips end to end across both proxy hops.""" diff --git a/tests/test_mcp_config.py b/tests/test_mcp_config.py index 93d673e61..bde02458b 100644 --- a/tests/test_mcp_config.py +++ b/tests/test_mcp_config.py @@ -15,6 +15,7 @@ from unittest.mock import AsyncMock, patch import psutil import pytest from mcp_types import TextContent +from mcp_types.version import MODERN_PROTOCOL_VERSIONS from pydantic import ConfigDict from fastmcp import Context, FastMCP @@ -39,6 +40,7 @@ from fastmcp.mcp_config import ( TransformingStdioMCPServer, ) from fastmcp.server.elicitation import AcceptedElicitation +from fastmcp.server.providers.proxy import ProxyClient from fastmcp.tools.base import Tool as FastMCPTool # Some tests in this module spawn subprocess servers via stdio, each paying a @@ -100,13 +102,26 @@ class InMemoryStdioMCPServer(StdioMCPServer): return FastMCPTransport(mcp=self.mcp) -class TestConfigTransportLegacyOnly: - """`MCPConfigTransport.legacy_only` gating (regression for the over-broad flag). +class LegacyFastMCPTransport(FastMCPTransport): + """In-memory transport that requires the handshake protocol era.""" + + legacy_only = True + + +class LegacyInMemoryStdioMCPServer(InMemoryStdioMCPServer): + """In-memory config entry that behaves like a legacy-only backend.""" + + def to_transport(self) -> FastMCPTransport: + return LegacyFastMCPTransport(mcp=self.mcp) + + +class TestConfigTransportEraNegotiation: + """`MCPConfigTransport` negotiates one era across every connection leg. A single-server config delegates directly to the underlying transport with no - proxy, so it must mirror that transport's era capability rather than being - forced legacy. Only the multi-server composite (backed by legacy-era - ProxyClients) is legacy-only. + proxy. A multi-server config discovers its backends before the composite client + negotiates, allowing an all-modern configuration to stay modern and a mixed + configuration to fall back consistently to the handshake era. """ def test_single_modern_capable_server_is_not_forced_legacy(self): @@ -129,8 +144,7 @@ class TestConfigTransportLegacyOnly: assert isinstance(transport.transport, SSETransport) assert transport.legacy_only is True - def test_multi_server_config_is_legacy_only(self): - """A multi-server composite is legacy-only regardless of backend eras.""" + def test_multi_server_config_is_not_assumed_legacy_before_connect(self): config = { "mcpServers": { "a": {"url": "https://a.example.com/mcp"}, @@ -138,7 +152,7 @@ class TestConfigTransportLegacyOnly: }, } transport = MCPConfigTransport(config) - assert transport.legacy_only is True + assert transport.legacy_only is False def test_transforming_single_server_wrapper_is_legacy_only(self): """A single-server config that uses tool transforms or tag filters wraps @@ -158,6 +172,151 @@ class TestConfigTransportLegacyOnly: assert transport.legacy_only is True +def _make_protocol_era_server(name: str) -> FastMCP: + server = FastMCP(name) + + @server.tool + async def protocol_era(ctx: Context) -> str: + assert ctx.request_context is not None + return ctx.request_context.protocol_version + + @server.tool + def add(a: int, b: int) -> int: + return a + b + + return server + + +async def test_multi_server_auto_negotiates_modern_end_to_end(): + """Modern backends keep the default multi-server client modern end to end.""" + config = MCPConfig( + mcpServers={ + "alpha": InMemoryStdioMCPServer(mcp=_make_protocol_era_server("alpha")), + "beta": InMemoryStdioMCPServer(mcp=_make_protocol_era_server("beta")), + } + ) + + async with Client(config) as client: + assert client.protocol_version == "2026-07-28" + + tools = await client.list_tools() + assert {tool.name for tool in tools} == { + "alpha_add", + "alpha_protocol_era", + "beta_add", + "beta_protocol_era", + } + + alpha_era = await client.call_tool("alpha_protocol_era", {}) + beta_era = await client.call_tool("beta_protocol_era", {}) + result = await client.call_tool("alpha_add", {"a": 2, "b": 3}) + + assert alpha_era.data == "2026-07-28" + assert beta_era.data == "2026-07-28" + assert result.data == 5 + + +async def test_multi_server_auto_falls_back_all_legs_when_one_backend_is_legacy(): + """A mixed config never leaves the composite and its backends on different eras.""" + config = MCPConfig( + mcpServers={ + "modern": InMemoryStdioMCPServer(mcp=_make_protocol_era_server("modern")), + "legacy": LegacyInMemoryStdioMCPServer( + mcp=_make_protocol_era_server("legacy") + ), + } + ) + + async with Client(config) as client: + assert client.protocol_version not in MODERN_PROTOCOL_VERSIONS + modern_era = await client.call_tool("modern_protocol_era", {}) + legacy_era = await client.call_tool("legacy_protocol_era", {}) + + assert modern_era.data not in MODERN_PROTOCOL_VERSIONS + assert legacy_era.data not in MODERN_PROTOCOL_VERSIONS + + +@pytest.mark.parametrize( + ("mode", "is_modern"), + [("legacy", False), ("2026-07-28", True)], +) +async def test_multi_server_explicit_mode_reaches_every_backend(mode, is_modern): + config = MCPConfig( + mcpServers={ + "alpha": InMemoryStdioMCPServer(mcp=_make_protocol_era_server("alpha")), + "beta": InMemoryStdioMCPServer(mcp=_make_protocol_era_server("beta")), + } + ) + + async with Client(config, mode=mode) as client: + alpha_era = await client.call_tool("alpha_protocol_era", {}) + beta_era = await client.call_tool("beta_protocol_era", {}) + + assert (client.protocol_version in MODERN_PROTOCOL_VERSIONS) is is_modern + + assert (alpha_era.data in MODERN_PROTOCOL_VERSIONS) is is_modern + assert (beta_era.data in MODERN_PROTOCOL_VERSIONS) is is_modern + + +async def test_multi_server_proxy_client_auto_negotiates_modern_end_to_end(): + """ProxyClient keeps its proxy options without losing the aggregate era.""" + config = MCPConfig( + mcpServers={ + "alpha": InMemoryStdioMCPServer(mcp=_make_protocol_era_server("alpha")), + "beta": InMemoryStdioMCPServer(mcp=_make_protocol_era_server("beta")), + } + ) + + async with ProxyClient(config, mode="auto") as client: + assert client.protocol_version == "2026-07-28" + alpha_era = await client.call_tool("alpha_protocol_era", {}) + beta_era = await client.call_tool("beta_protocol_era", {}) + + assert alpha_era.data == "2026-07-28" + assert beta_era.data == "2026-07-28" + + +async def test_multi_server_mode_is_resolved_when_proxy_client_connects(): + config = MCPConfig( + mcpServers={ + "alpha": InMemoryStdioMCPServer(mcp=_make_protocol_era_server("alpha")), + "beta": InMemoryStdioMCPServer(mcp=_make_protocol_era_server("beta")), + } + ) + client = ProxyClient(config, mode="legacy") + client.mode = "auto" + + async with client: + alpha_era = await client.call_tool("alpha_protocol_era", {}) + beta_era = await client.call_tool("beta_protocol_era", {}) + + assert alpha_era.data == "2026-07-28" + assert beta_era.data == "2026-07-28" + + +async def test_multi_server_shape_is_resolved_when_client_connects(): + config = MCPConfig( + mcpServers={ + "alpha": InMemoryStdioMCPServer(mcp=_make_protocol_era_server("alpha")) + } + ) + client = Client(config) + config.add_server( + "beta", InMemoryStdioMCPServer(mcp=_make_protocol_era_server("beta")) + ) + + async with client: + assert client.protocol_version == "2026-07-28" + tools = await client.list_tools() + + assert {tool.name for tool in tools} == { + "alpha_add", + "alpha_protocol_era", + "beta_add", + "beta_protocol_era", + } + + def test_parse_single_stdio_config(): config = { "mcpServers": { @@ -927,7 +1086,9 @@ async def test_multi_client_with_elicitation(): config = MCPConfig( mcpServers={ "test_server": InMemoryStdioMCPServer(mcp=_make_elicit_server()), - "test_server_2": InMemoryStdioMCPServer(mcp=_make_elicit_server()), + # One legacy-only backend makes the aggregate reconnect every leg + # under the handshake era, where server-initiated elicitation works. + "test_server_2": LegacyInMemoryStdioMCPServer(mcp=_make_elicit_server()), } ) @@ -1046,7 +1207,9 @@ async def test_multi_server_session_persistence(): config = MCPConfig( mcpServers={ "server1": InMemoryStdioMCPServer(mcp=_make_session_server()), - "server2": InMemoryStdioMCPServer(mcp=_make_session_server()), + # Session identity is a handshake-era feature. A legacy-only sibling + # verifies aggregate auto-negotiation preserves it on every backend. + "server2": LegacyInMemoryStdioMCPServer(mcp=_make_session_server()), } )