mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 07:09:11 +02:00
Mirror the frontend's protocol era on a proxy's backend connection (#4573)
* Mirror front protocol era onto proxy backend connection A proxy created from a non-Client target now negotiates, on its backend, whatever era its front client negotiated, instead of pinning one era. Explicit create_proxy(mode=...) still overrides. Guards the eager backend initialize() so an explicit modern pin behind a handshake front no longer crashes. * Carry the mirrored proxy era into multi-server config backends A multi-server MCPConfig target mounts one proxy per configured server on a composite router, so setting the era on the outer client stopped at the router and every real backend stayed on its default era. TransportOptions.backend_mode carries it down, resolved per request alongside the outer mirroring. The router is also sealed under a policy held on the transport rather than a fresh per-router ephemeral key, so a guard tool's request_state survives the router being rebuilt between rounds.
This commit is contained in:
parent
effbc568ff
commit
0ba3db1a56
7 changed files with 389 additions and 24 deletions
|
|
@ -380,6 +380,12 @@ A tool can gather client input across rounds on a `2026-07-28` call by returning
|
|||
|
||||
*Verify:* `fastmcp_slim/fastmcp/server/context.py` (`input_responses`/`request_state` properties), `fastmcp_slim/fastmcp/server/low_level.py` (`RequestStateBoundary` install), `fastmcp_slim/fastmcp/server/server.py` (`request_state_security` param), `fastmcp_slim/fastmcp/server/mixins/mcp_operations.py` (`_on_call_tool` input-required passthrough + era gate), `fastmcp_slim/fastmcp/tools/base.py` (`InputRequiredToolResult`), `tests/server/test_mrtr_guards.py`.
|
||||
|
||||
### Proxy era mirroring — New (behavior)
|
||||
|
||||
A proxy is a server on its front and a client on its back, and the two eras have mutually exclusive interaction models on a single session: the handshake era pushes server-initiated requests (sampling/elicitation/roots) that the proxy forwards to its client, while the modern era forbids those and round-trips a guard tool's `InputRequiredResult` as a result instead. A proxy created from a non-Client target with no explicit `mode` now MIRRORS the front connection's negotiated era onto its backend session per request, so the whole chain speaks one era end-to-end — a modern client reaches a modern backend (guard round-trips work), a handshake client reaches a handshake backend (push-forwarding works), and the same proxy serves both without a backend session ever crossing eras. Because the default factory builds a fresh backend client per request and derives its `mode` from the front era at call time, only the metadata-only component caches are shared across eras. An explicit `create_proxy(target, mode=...)` still pins the backend era regardless of the front, overriding mirroring for a backend that only speaks one era; the resulting cross-era feature mismatches surface through the existing era gates. `ProxyInitializeMiddleware` no longer force-calls the handshake-only `client.initialize()` when the backend negotiated the modern era, so an explicit modern pin behind a handshake front no longer crashes on connect. The mirrored era carries through a multi-server `MCPConfig` target as well: that form mounts one proxy per configured server onto a composite router, and `TransportOptions.backend_mode` hands the era down to those mounted legs so every real backend negotiates it, not just the router in front of them. That router is also now sealed under a policy held on the transport rather than a fresh per-router ephemeral key, so a guard tool's `request_state` survives the router being rebuilt between rounds.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/server/providers/proxy.py` (`_mirror_front_era_mode`, the `_create_client_factory` non-Client branch, the era guard in `ProxyInitializeMiddleware.on_initialize`), `fastmcp_slim/fastmcp/client/transports/base.py` (`TransportOptions.backend_mode`), `fastmcp_slim/fastmcp/client/transports/config.py` (`MCPConfigTransport.connect_session` / `_create_proxy`), `fastmcp_slim/fastmcp/server/server.py` (`create_proxy` docstring), `tests/server/test_mrtr_guards.py` (`TestProxyEraMirroring`, `TestMultiServerConfigEraMirroring`).
|
||||
|
||||
### The xfail register — Known gap
|
||||
|
||||
Roughly forty `xfail` markers across the test tree (concentrated in `tests/server/tasks/`, `tests/client/tasks/`, and `test_protocol_eras.py`) are the built-in beta tracker: each names the SDK gap it waits on. They are enumerated and mapped to sdk-feedback findings on the [Known Gaps](/development/v4-notes/known-gaps) page.
|
||||
|
|
|
|||
|
|
@ -190,6 +190,54 @@ async with Client(proxy) as client:
|
|||
|
||||
Skipping the check also avoids a `tools/list` round trip to the backend on every proxied call, since validation would need the backend's schemas and a proxy builds a fresh connection per request.
|
||||
|
||||
### Protocol Era Mirroring
|
||||
|
||||
<VersionBadge version="4.0.0" />
|
||||
|
||||
A proxy is a server on its front and a client on its back, and the two MCP protocol eras have mutually exclusive interaction models on a single session. On the handshake era (≤2025-11-25) the backend can push server-initiated requests — sampling, elicitation, roots — which the proxy forwards to your client. On the modern era (2026-07-28) those pushes are gone; a backend guard tool instead returns an input request that the proxy relays back as a result. A single proxy session speaks one era, so the whole chain has to agree end-to-end.
|
||||
|
||||
By default the proxy relays the era: whatever era your client negotiates on the front, the proxy negotiates the same era on its backend connection, per request. A handshake client reaches a handshake backend, so server-initiated forwarding works; a modern client reaches a modern backend, so a guard tool's input request round-trips. Different clients hitting the same proxy each get a backend session in their own era — the eras never cross.
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
from fastmcp.server import create_proxy
|
||||
|
||||
# No mode: the backend mirrors each client's negotiated era.
|
||||
proxy = create_proxy("backend_server.py")
|
||||
|
||||
# A handshake client gets a handshake backend (push-forwarding works).
|
||||
async with Client(proxy, mode="legacy") as client:
|
||||
...
|
||||
|
||||
# A modern client gets a modern backend (guard tools round-trip).
|
||||
async with Client(proxy, mode="auto") as client:
|
||||
...
|
||||
```
|
||||
|
||||
Passing an explicit `mode` pins the backend to one era regardless of the client:
|
||||
|
||||
```python
|
||||
# Always negotiate the modern era upstream, whatever the client speaks.
|
||||
proxy = create_proxy("backend_server.py", mode="auto")
|
||||
```
|
||||
|
||||
Pinning breaks the end-to-end era agreement, so reserve it for a backend that only speaks one era. When the client's era and the pinned backend era disagree on a feature — a modern client asking for a guard round-trip against a handshake-pinned backend, say — the mismatch surfaces through the normal era gates rather than silently degrading. Mirroring applies to proxies created from a target the proxy connects itself (a URL, path, config, or `FastMCP` instance); when you hand `create_proxy` an already-configured `Client`, that client carries its own mode and mirroring does not override it.
|
||||
|
||||
A multi-server configuration adds a hop: FastMCP mounts one proxy per configured server onto a router, and your client talks to that router rather than to any backend directly. The era carries through the whole depth, so each real backend negotiates the era your client did — not just the router in front of them.
|
||||
|
||||
```python
|
||||
proxy = create_proxy(
|
||||
{
|
||||
"mcpServers": {
|
||||
"weather": {"url": "https://weather.example.com/mcp"},
|
||||
"calendar": {"url": "https://calendar.example.com/mcp"},
|
||||
}
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
A modern client here reaches both `weather` and `calendar` on modern sessions, so a guard tool on either one round-trips end to end. An explicit `mode` pins every backend in the configuration, the same way it pins a single one.
|
||||
|
||||
## Configuration-Based Proxies
|
||||
|
||||
<VersionBadge version="2.4.0" />
|
||||
|
|
|
|||
|
|
@ -50,10 +50,18 @@ class TransportOptions:
|
|||
authorization header upstream. Only appropriate for proxies, where
|
||||
the caller's credentials are meant to be propagated. Honored by the
|
||||
HTTP and SSE transports; ignored by the others.
|
||||
backend_mode: The connect `mode` to give backend clients that a wrapping
|
||||
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.
|
||||
"""
|
||||
|
||||
session_class: type[ClientSession] = ClientSession
|
||||
forward_incoming_headers: bool = False
|
||||
backend_mode: str | None = None
|
||||
|
||||
|
||||
# SessionKwargs stays exactly the ClientSession constructor's parameters, so a
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ from fastmcp.mcp_config import (
|
|||
from fastmcp.utilities.logging import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mcp.server.request_state import RequestStateSecurity
|
||||
|
||||
from fastmcp.server.server import FastMCP
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
|
@ -82,6 +84,7 @@ class MCPConfigTransport(ClientTransport):
|
|||
self.config = config
|
||||
self.name_as_prefix = name_as_prefix
|
||||
self._transports: list[ClientTransport] = []
|
||||
self._request_state_security: RequestStateSecurity | None = None
|
||||
|
||||
if not self.config.mcpServers:
|
||||
raise ValueError("No MCP servers defined in the config")
|
||||
|
|
@ -90,6 +93,22 @@ class MCPConfigTransport(ClientTransport):
|
|||
if len(self.config.mcpServers) == 1:
|
||||
self.transport = next(iter(self.config.mcpServers.values())).to_transport()
|
||||
self._transports.append(self.transport)
|
||||
else:
|
||||
# Sealing policy for the composite router built in `connect_session`.
|
||||
# It is held here, not on the router, because the router is rebuilt
|
||||
# on every connection while a guard tool's multi-round-trip spans
|
||||
# several of them (a proxy builds a fresh backend client per
|
||||
# request). A per-router key would seal `request_state` on one round
|
||||
# and reject its own token on the next. Only multi-server configs
|
||||
# mount a router, so single-server configs skip the import entirely
|
||||
# (it pulls in the SDK's server tier). Aliased so the local binding
|
||||
# does not shadow the type-checking-only name in the annotation
|
||||
# above.
|
||||
from mcp.server.request_state import (
|
||||
RequestStateSecurity as _RequestStateSecurity,
|
||||
)
|
||||
|
||||
self._request_state_security = _RequestStateSecurity.ephemeral()
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def connect_session(
|
||||
|
|
@ -118,7 +137,18 @@ class MCPConfigTransport(ClientTransport):
|
|||
) from exc
|
||||
|
||||
timeout = session_kwargs.get("read_timeout_seconds")
|
||||
composite = FastMCP[Any](name="MCPRouter")
|
||||
composite = FastMCP[Any](
|
||||
name="MCPRouter", request_state_security=self._request_state_security
|
||||
)
|
||||
|
||||
# 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
|
||||
)
|
||||
|
||||
async with contextlib.AsyncExitStack() as stack:
|
||||
# Close any previous transports from prior connections to avoid leaking
|
||||
|
|
@ -129,7 +159,7 @@ class MCPConfigTransport(ClientTransport):
|
|||
for name, server_config in self.config.mcpServers.items():
|
||||
try:
|
||||
transport, _client, proxy = await self._create_proxy(
|
||||
name, server_config, timeout, stack
|
||||
name, server_config, timeout, stack, backend_mode
|
||||
)
|
||||
except Exception: # Broad catch is intentional: failure modes
|
||||
# are diverse (OSError, TimeoutError, RuntimeError, etc.)
|
||||
|
|
@ -157,6 +187,7 @@ class MCPConfigTransport(ClientTransport):
|
|||
config: MCPServerTypes,
|
||||
timeout: float | None,
|
||||
stack: contextlib.AsyncExitStack,
|
||||
backend_mode: str | None = None,
|
||||
) -> tuple[ClientTransport, Any, "FastMCP[Any]"]:
|
||||
"""Create underlying transport, proxy client, and proxy server for a single backend.
|
||||
|
||||
|
|
@ -164,6 +195,9 @@ class MCPConfigTransport(ClientTransport):
|
|||
passed to create_proxy so the factory sees it as connected and reuses
|
||||
the same session for all tool calls (instead of creating fresh copies).
|
||||
|
||||
`backend_mode` is the connect mode the calling client wants this backend
|
||||
leg to negotiate; `None` leaves the client at its own default era.
|
||||
|
||||
Returns a tuple of (transport, proxy_client, proxy_server).
|
||||
"""
|
||||
# Import here to avoid circular dependency
|
||||
|
|
@ -188,7 +222,12 @@ class MCPConfigTransport(ClientTransport):
|
|||
else:
|
||||
transport = config.to_transport()
|
||||
|
||||
client = StatefulProxyClient(transport=transport, timeout=timeout)
|
||||
client_kwargs: dict[str, Any] = {}
|
||||
if backend_mode is not None:
|
||||
client_kwargs["mode"] = backend_mode
|
||||
client = StatefulProxyClient(
|
||||
transport=transport, timeout=timeout, **client_kwargs
|
||||
)
|
||||
# Connect the client *before* create_proxy so _create_client_factory
|
||||
# detects it as connected and reuses it for all tool calls, preserving
|
||||
# the session ID across requests. StatefulProxyClient is used instead
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import base64
|
|||
import inspect
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from dataclasses import replace
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
import anyio
|
||||
|
|
@ -145,12 +146,21 @@ class ProxyInitializeMiddleware(Middleware):
|
|||
ctx._fastmcp,
|
||||
)
|
||||
async with client:
|
||||
await client.initialize()
|
||||
# Capture the upstream's instructions while the session is live;
|
||||
# `initialize_result` clears once the client context exits.
|
||||
init_result = client.initialize_result
|
||||
if init_result is not None:
|
||||
upstream_instructions = init_result.instructions
|
||||
# Entering the context already ran connect-time negotiation.
|
||||
# `initialize()` returns the handshake result on a legacy backend,
|
||||
# but raises on a modern (server/discover) backend, which has no
|
||||
# InitializeResult. That mismatch only arises when an explicit
|
||||
# `mode=` pins the backend to a different era than this legacy
|
||||
# front (the era-mirroring default keeps the two in lockstep, so
|
||||
# a legacy front always reaches a legacy backend here). Skip the
|
||||
# handshake-only call when the backend negotiated the modern era.
|
||||
if client.protocol_version not in MODERN_PROTOCOL_VERSIONS:
|
||||
await client.initialize()
|
||||
# Capture the upstream's instructions while the session is
|
||||
# live; `initialize_result` clears once the context exits.
|
||||
init_result = client.initialize_result
|
||||
if init_result is not None:
|
||||
upstream_instructions = init_result.instructions
|
||||
except MCPError:
|
||||
raise
|
||||
except (
|
||||
|
|
@ -880,6 +890,38 @@ class ProxyProvider(Provider):
|
|||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _mirror_front_era_mode() -> str | None:
|
||||
"""Return the backend connect ``mode`` that mirrors the front connection's era.
|
||||
|
||||
A proxy is a server on its front and a client on its back. The two protocol
|
||||
eras have mutually exclusive interaction models on a single session, so the
|
||||
whole chain must speak one era end-to-end: a modern front must reach a modern
|
||||
backend (a guard tool's `InputRequiredResult` round-trips), and a handshake
|
||||
front must reach a handshake backend (server-initiated sampling / elicitation
|
||||
/ roots push-forwarding works). Rather than pin its own era, the proxy speaks
|
||||
on its back whatever era was negotiated on its front.
|
||||
|
||||
Reads the negotiated protocol version from the active front request context:
|
||||
|
||||
- modern front → that exact version, so the backend negotiates the same era
|
||||
(pinning the version rather than ``"auto"`` makes the eras truly match).
|
||||
- handshake front → ``"legacy"``.
|
||||
- no request context (e.g. proxy construction before any request) → ``None``,
|
||||
leaving the factory's configured default mode in place.
|
||||
"""
|
||||
try:
|
||||
ctx = get_context()
|
||||
except RuntimeError:
|
||||
return None
|
||||
rc = ctx.request_context
|
||||
if rc is None:
|
||||
return None
|
||||
version = rc.protocol_version
|
||||
if version in MODERN_PROTOCOL_VERSIONS:
|
||||
return version
|
||||
return "legacy"
|
||||
|
||||
|
||||
def _create_client_factory(
|
||||
target: (
|
||||
Client[ClientTransportT]
|
||||
|
|
@ -913,7 +955,13 @@ def _create_client_factory(
|
|||
credentials get forwarded upstream.
|
||||
"""
|
||||
fresh = c.new()
|
||||
fresh._transport_options = PROXY_TRANSPORT_OPTIONS
|
||||
# The caller chose this client's era, so a multi-server MCPConfig
|
||||
# target's mounted backend legs should negotiate it too rather than
|
||||
# stopping at the composite router (see
|
||||
# `TransportOptions.backend_mode`).
|
||||
fresh._transport_options = replace(
|
||||
PROXY_TRANSPORT_OPTIONS, backend_mode=fresh.mode
|
||||
)
|
||||
return fresh
|
||||
|
||||
if client.is_connected() and type(client) is ProxyClient:
|
||||
|
|
@ -949,12 +997,42 @@ def _create_client_factory(
|
|||
|
||||
return fresh_client_factory
|
||||
else:
|
||||
# target is not a Client, so it's compatible with ProxyClient.__init__
|
||||
client_kwargs: dict[str, Any] = {} if mode is None else {"mode": mode}
|
||||
# target is not a Client, so it's compatible with ProxyClient.__init__.
|
||||
#
|
||||
# With no explicit mode, the backend MIRRORS the front connection's
|
||||
# negotiated era per request (see `_mirror_front_era_mode`): a fresh
|
||||
# client is built for each request and its mode is set from the front
|
||||
# era, so the whole chain speaks one era end-to-end. Because every
|
||||
# request gets its own client whose mode is derived at call time, front
|
||||
# connections of different eras never share a backend session — there is
|
||||
# no era to bleed across the (metadata-only) provider caches.
|
||||
#
|
||||
# An explicit mode pins the backend era regardless of the front. This
|
||||
# breaks era-consistency and is only appropriate when the backend speaks
|
||||
# a single era; the mismatch surfaces through the normal era gates.
|
||||
explicit_mode = mode is not None
|
||||
client_kwargs: dict[str, Any] = {"mode": mode} if explicit_mode else {}
|
||||
base_client = ProxyClient(cast(Any, target), **client_kwargs)
|
||||
|
||||
def proxy_client_factory() -> Client:
|
||||
return base_client.new()
|
||||
fresh = base_client.new()
|
||||
backend_mode = mode
|
||||
if not explicit_mode:
|
||||
backend_mode = _mirror_front_era_mode()
|
||||
if backend_mode is not None:
|
||||
fresh.mode = backend_mode
|
||||
if backend_mode is not None:
|
||||
# A multi-server MCPConfig target reaches its real backends
|
||||
# through proxies mounted on a composite router, so setting the
|
||||
# era on this client alone would stop at the router. Carry the
|
||||
# era down to those backend legs too (see
|
||||
# `TransportOptions.backend_mode`), resolved here — at the
|
||||
# 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
|
||||
)
|
||||
return fresh
|
||||
|
||||
return proxy_client_factory
|
||||
|
||||
|
|
@ -1214,13 +1292,16 @@ class ProxyClient(Client[ClientTransportT]):
|
|||
):
|
||||
if "name" not in kwargs:
|
||||
kwargs["name"] = self.generate_name()
|
||||
# ProxyClient defaults to the handshake era: a dual-era backend serves
|
||||
# both, and a single proxy session can only be one era. Handshake keeps
|
||||
# the server-initiated push forwarding (sampling / elicitation / roots,
|
||||
# via the handlers installed below) that proxies rely on. To round-trip
|
||||
# an upstream guard tool's InputRequiredResult (SEP-2322) instead, opt
|
||||
# into the modern era explicitly with `create_proxy(target, mode="auto")`
|
||||
# — the two are mutually exclusive per session.
|
||||
# ProxyClient itself defaults to the handshake era when constructed
|
||||
# directly: a single proxy session can only be one era, and handshake
|
||||
# keeps the server-initiated push forwarding (sampling / elicitation /
|
||||
# roots, via the handlers installed below) that proxies rely on. When a
|
||||
# proxy is created from a non-Client target (`create_proxy(target)` /
|
||||
# `_create_client_factory`) with no explicit mode, the factory instead
|
||||
# MIRRORS the front connection's negotiated era onto this client per
|
||||
# request, so the whole chain speaks one era end-to-end. An explicit
|
||||
# `mode=` (e.g. `create_proxy(target, mode="auto")`) pins the era and
|
||||
# overrides mirroring. The eras are mutually exclusive per session.
|
||||
# Install context-restoring handler wrappers BEFORE super().__init__
|
||||
# registers them with the Client's session kwargs.
|
||||
self._proxy_rc_ref = [None]
|
||||
|
|
|
|||
|
|
@ -2419,10 +2419,16 @@ def create_proxy(
|
|||
- A Path to a server script
|
||||
- An MCPConfig or dict
|
||||
mode: Protocol-era negotiation for auto-created proxy clients (a
|
||||
non-Client target). Defaults to the handshake era; pass
|
||||
`"auto"` to negotiate the modern era so an upstream guard
|
||||
tool's `InputRequiredResult` (SEP-2322) round-trips. Ignored
|
||||
when `target` is already a `Client` (which carries its own mode).
|
||||
non-Client target). By default (``None``) the backend MIRRORS the
|
||||
front connection's negotiated era per request, so the whole chain
|
||||
speaks one era end-to-end: a modern front reaches a modern backend
|
||||
(a guard tool's `InputRequiredResult` (SEP-2322) round-trips) and a
|
||||
handshake front reaches a handshake backend (server-initiated
|
||||
sampling / elicitation / roots push-forwarding works). Pass an
|
||||
explicit mode (e.g. ``"auto"`` or a version string) to pin the
|
||||
backend era regardless of the front; this overrides mirroring and is
|
||||
appropriate when the backend only speaks one era. Ignored when
|
||||
`target` is already a `Client` (which carries its own mode).
|
||||
**settings: Additional settings passed to FastMCPProxy (name, etc.)
|
||||
|
||||
Returns:
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ the ≤2025-11-25 era gate. The client-side *answering* path is covered by
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Annotated
|
||||
|
||||
import mcp_types
|
||||
|
|
@ -25,6 +26,7 @@ from mcp.client._input_required import InputRequiredRoundsExceededError
|
|||
from mcp.server.request_state import RequestStateSecurity
|
||||
from mcp.shared.exceptions import MCPError
|
||||
from mcp_types import ElicitRequest, InputRequiredResult
|
||||
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
|
||||
from pydantic import Field
|
||||
from typing_extensions import TypeAliasType
|
||||
|
||||
|
|
@ -509,6 +511,181 @@ class TestProxyServer:
|
|||
assert received == [(1, 2, "halfway"), (2, 2, "done")]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Person:
|
||||
name: str
|
||||
|
||||
|
||||
def _era_reporting_backend() -> FastMCP:
|
||||
"""A dual-era backend for the mirroring tests.
|
||||
|
||||
Hosts the three-round guard tool (``book_flight``), a tool that reports the
|
||||
protocol version its own backend session negotiated (``backend_era``), and a
|
||||
server-initiated elicitation tool (``ask_name``) that only works when the
|
||||
session is handshake-era, so a single backend proves which era the proxy
|
||||
mirrored onto it.
|
||||
"""
|
||||
mcp = two_question_server()
|
||||
|
||||
@mcp.tool
|
||||
async def backend_era(ctx: Context) -> str:
|
||||
rc = ctx.request_context
|
||||
assert rc is not None
|
||||
return rc.protocol_version
|
||||
|
||||
@mcp.tool
|
||||
async def ask_name(ctx: Context) -> str:
|
||||
result = await ctx.elicit("What is your name?", response_type=_Person)
|
||||
if result.action == "accept":
|
||||
assert isinstance(result.data, _Person)
|
||||
return f"Hello, {result.data.name}!"
|
||||
return "no name"
|
||||
|
||||
return mcp
|
||||
|
||||
|
||||
class TestProxyEraMirroring:
|
||||
"""A proxy created from a non-Client target with no explicit mode mirrors the
|
||||
front connection's negotiated era onto its backend session, so the whole
|
||||
chain speaks one era end-to-end."""
|
||||
|
||||
async def test_modern_front_mirrors_modern_backend(self):
|
||||
"""A modern front through a proxy with NO explicit mode gets a modern
|
||||
backend session, so a guard tool round-trips end-to-end."""
|
||||
from fastmcp.server import create_proxy
|
||||
|
||||
proxy = create_proxy(_era_reporting_backend())
|
||||
|
||||
asked: list[str] = []
|
||||
async with Client(
|
||||
proxy, mode="auto", elicitation_handler=_two_answer_handler(asked)
|
||||
) as client:
|
||||
era = await client.call_tool("backend_era", {})
|
||||
result = await client.call_tool("book_flight", {})
|
||||
|
||||
assert era.data == "2026-07-28"
|
||||
assert result.data == "Booked Paris on 2026-08-01"
|
||||
|
||||
async def test_legacy_front_mirrors_handshake_backend(self):
|
||||
"""A legacy front through a proxy with NO explicit mode gets a handshake
|
||||
backend session, so server-initiated elicitation push-forwards through
|
||||
the proxy to the front client's handler."""
|
||||
from fastmcp.server import create_proxy
|
||||
|
||||
proxy = create_proxy(_era_reporting_backend())
|
||||
|
||||
async def name_handler(message, response_type, params, ctx):
|
||||
return ElicitResult(action="accept", content=response_type(name="Ada"))
|
||||
|
||||
async with Client(
|
||||
proxy, mode="legacy", elicitation_handler=name_handler
|
||||
) as client:
|
||||
era = await client.call_tool("backend_era", {})
|
||||
greeting = await client.call_tool("ask_name", {})
|
||||
|
||||
assert era.data not in MODERN_PROTOCOL_VERSIONS
|
||||
assert greeting.data == "Hello, Ada!"
|
||||
|
||||
async def test_same_proxy_serves_both_eras_without_bleed(self):
|
||||
"""The SAME proxy instance serves a legacy front and a modern front (and
|
||||
a legacy front again); each gets its matching backend era. This is the
|
||||
session-cache trap: a backend session pinned to one era must never be
|
||||
reused across front connections of a different era."""
|
||||
from fastmcp.server import create_proxy
|
||||
|
||||
proxy = create_proxy(_era_reporting_backend())
|
||||
|
||||
async with Client(proxy, mode="legacy") as client:
|
||||
legacy_era = await client.call_tool("backend_era", {})
|
||||
async with Client(proxy, mode="auto") as client:
|
||||
modern_era = await client.call_tool("backend_era", {})
|
||||
async with Client(proxy, mode="legacy") as client:
|
||||
legacy_again = await client.call_tool("backend_era", {})
|
||||
|
||||
assert legacy_era.data not in MODERN_PROTOCOL_VERSIONS
|
||||
assert modern_era.data == "2026-07-28"
|
||||
assert legacy_again.data not in MODERN_PROTOCOL_VERSIONS
|
||||
|
||||
async def test_explicit_mode_overrides_mirroring(self):
|
||||
"""An explicit ``create_proxy(mode=...)`` pins the backend era regardless
|
||||
of the front connection's era, overriding mirroring."""
|
||||
from fastmcp.server import create_proxy
|
||||
|
||||
proxy = create_proxy(_era_reporting_backend(), mode="auto")
|
||||
|
||||
# Legacy front, but the backend is pinned modern by the explicit mode.
|
||||
async with Client(proxy, mode="legacy") as client:
|
||||
era = await client.call_tool("backend_era", {})
|
||||
|
||||
assert era.data == "2026-07-28"
|
||||
|
||||
|
||||
class TestMultiServerConfigEraMirroring:
|
||||
"""A multi-server `MCPConfig` target puts an extra hop between the proxy and
|
||||
the real backends: `MCPConfigTransport` mounts one proxy per configured
|
||||
server on a composite router. Setting the era on the outer client alone
|
||||
would stop at that router, leaving every real backend on its own default
|
||||
era, so the mirrored era has to reach the mounted legs too.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _config(url: str) -> dict[str, object]:
|
||||
"""Two entries so the transport takes its multi-server composite path."""
|
||||
return {"mcpServers": {"a": {"url": url}, "b": {"url": url}}}
|
||||
|
||||
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."""
|
||||
from fastmcp.server import create_proxy
|
||||
|
||||
async with run_server_async(_era_reporting_backend()) as url:
|
||||
proxy = create_proxy(self._config(url))
|
||||
|
||||
asked: list[str] = []
|
||||
async with Client(
|
||||
proxy, mode="auto", 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 era.data == "2026-07-28"
|
||||
assert result.data == "Booked Paris on 2026-08-01"
|
||||
assert len(asked) == 2
|
||||
|
||||
async def test_legacy_front_reaches_handshake_backends(self):
|
||||
"""A legacy front reaches each real backend on a handshake session, so
|
||||
server-initiated elicitation still push-forwards up the whole chain."""
|
||||
from fastmcp.server import create_proxy
|
||||
|
||||
async def name_handler(message, response_type, params, ctx):
|
||||
return ElicitResult(action="accept", content=response_type(name="Ada"))
|
||||
|
||||
async with run_server_async(_era_reporting_backend()) as url:
|
||||
proxy = create_proxy(self._config(url))
|
||||
|
||||
async with Client(
|
||||
proxy, mode="legacy", elicitation_handler=name_handler
|
||||
) as client:
|
||||
era = await client.call_tool("a_backend_era", {})
|
||||
greeting = await client.call_tool("a_ask_name", {})
|
||||
|
||||
assert era.data not in MODERN_PROTOCOL_VERSIONS
|
||||
assert greeting.data == "Hello, Ada!"
|
||||
|
||||
async def test_explicit_mode_overrides_mirroring(self):
|
||||
"""An explicit ``create_proxy(mode=...)`` pins the era all the way down,
|
||||
overriding what the front negotiated."""
|
||||
from fastmcp.server import create_proxy
|
||||
|
||||
async with run_server_async(_era_reporting_backend()) as url:
|
||||
proxy = create_proxy(self._config(url), mode="auto")
|
||||
|
||||
async with Client(proxy, mode="legacy") as client:
|
||||
era = await client.call_tool("a_backend_era", {})
|
||||
|
||||
assert era.data == "2026-07-28"
|
||||
|
||||
|
||||
class TestEraGate:
|
||||
async def test_legacy_connection_rejects_with_era_error(self):
|
||||
"""Returning an InputRequiredResult on a ≤2025-11-25 connection produces
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue