diff --git a/docs/development/v4-notes/change-register.mdx b/docs/development/v4-notes/change-register.mdx index 9c051d63c..345086a79 100644 --- a/docs/development/v4-notes/change-register.mdx +++ b/docs/development/v4-notes/change-register.mdx @@ -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. diff --git a/docs/servers/providers/proxy.mdx b/docs/servers/providers/proxy.mdx index bd0978693..5ec71d487 100644 --- a/docs/servers/providers/proxy.mdx +++ b/docs/servers/providers/proxy.mdx @@ -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 + + + +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 diff --git a/fastmcp_slim/fastmcp/client/transports/base.py b/fastmcp_slim/fastmcp/client/transports/base.py index bec8bd0ae..f111ce446 100644 --- a/fastmcp_slim/fastmcp/client/transports/base.py +++ b/fastmcp_slim/fastmcp/client/transports/base.py @@ -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 diff --git a/fastmcp_slim/fastmcp/client/transports/config.py b/fastmcp_slim/fastmcp/client/transports/config.py index 6b271ab53..ea027a08a 100644 --- a/fastmcp_slim/fastmcp/client/transports/config.py +++ b/fastmcp_slim/fastmcp/client/transports/config.py @@ -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 diff --git a/fastmcp_slim/fastmcp/server/providers/proxy.py b/fastmcp_slim/fastmcp/server/providers/proxy.py index f13f365e2..a3e9c46fd 100644 --- a/fastmcp_slim/fastmcp/server/providers/proxy.py +++ b/fastmcp_slim/fastmcp/server/providers/proxy.py @@ -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] diff --git a/fastmcp_slim/fastmcp/server/server.py b/fastmcp_slim/fastmcp/server/server.py index b5ff38231..88a7624d9 100644 --- a/fastmcp_slim/fastmcp/server/server.py +++ b/fastmcp_slim/fastmcp/server/server.py @@ -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: diff --git a/tests/server/test_mrtr_guards.py b/tests/server/test_mrtr_guards.py index e574b20ca..e89889c24 100644 --- a/tests/server/test_mrtr_guards.py +++ b/tests/server/test_mrtr_guards.py @@ -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