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