mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-26 23:44:17 +02:00
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:
parent
258554d93c
commit
7ab6442aeb
8 changed files with 322 additions and 66 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue