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:
Jeremiah Lowin 2026-07-20 12:31:02 -04:00 committed by GitHub
commit 0ba3db1a56
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 389 additions and 24 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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