fix: support modern protocol in multi-server clients (#4893)

* Support modern multi-server clients

🤖 Generated with Codex

* Preserve multi-server proxy protocol mode

🤖 Generated with Codex

* Layer proxy transport options

🤖 Generated with Codex

* Resolve aggregate protocol mode on connect

🤖 Generated with Codex
This commit is contained in:
nate nowack 2026-08-24 21:09:41 -05:00 committed by GitHub
commit 7ab6442aeb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 322 additions and 66 deletions

View file

@ -212,7 +212,7 @@ async with Client("https://example.com/mcp", mode="auto") as client:
<Note>
`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"`.
</Note>
## Response caching

View file

@ -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).

View file

@ -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"

View file

@ -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

View file

@ -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,

View file

@ -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:

View file

@ -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."""

View file

@ -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()),
}
)