diff --git a/docs/development/tests.mdx b/docs/development/tests.mdx
index 11e61f1aa..37760e7aa 100644
--- a/docs/development/tests.mdx
+++ b/docs/development/tests.mdx
@@ -26,7 +26,7 @@ uv run pytest --cov=fastmcp
uv run pytest -m "not integration"
# Skip tests that spawn processes
-uv run pytest -m "not integration and not client_process"
+uv run pytest -m "not integration and not client_process and not subprocess_heavy"
```
Tests should complete in under 1 second unless marked as integration tests. This speed encourages running them frequently, catching issues early.
@@ -61,6 +61,40 @@ async def test_stdio_transport():
assert result.content[0].text == "test"
```
+A third marker, `subprocess_heavy`, exists specifically for Windows CI stability. See [Windows CI and Test Parallelism](#windows-ci-and-test-parallelism) below for when to use it and why it exists.
+
+### Windows CI and Test Parallelism
+
+Windows CI ran the unit suite serially for months. [#2715](https://github.com/PrefectHQ/fastmcp/pull/2715) tried enabling `pytest-xdist` parallelism there in December 2025; [#2726](https://github.com/PrefectHQ/fastmcp/pull/2726) reverted it the next day because "Windows tests continue to fail with intermittent worker crashes." [#4554](https://github.com/PrefectHQ/fastmcp/pull/4554) re-enabled it after removing most of the subprocess pressure that caused those crashes, taking the Windows unit step from roughly 460s to 175s.
+
+That pressure came from three sources, all addressed by #4554: most HTTP tests moved in-process via `asgi_client` instead of binding real sockets, stdio lifecycle tests spawn a minimal stdlib responder (`tests/client/minimal_stdio_server.py`, ~0.03s to start) instead of a subprocess that runs `import fastmcp` (~0.7s), and roughly 80 real `sleep()` calls became deterministic waits on the condition each test actually cared about. Fewer, cheaper subprocesses competing under parallel workers left fewer chances for a worker to die.
+
+#### The `subprocess_heavy` marker
+
+One class of test still spawns a full Python interpreter that imports FastMCP — checking that a bare install doesn't need optional dependencies, or that a decorator works from a fresh process. Each spawn pays a full interpreter's startup and memory footprint, and a 2-core Windows runner already running 2 xdist workers has little headroom left to absorb that. These tests carry `@pytest.mark.subprocess_heavy` and run in the existing serial `client_process` CI step instead of alongside the parallel workers — `.github/actions/run-pytest/action.yml` routes `client_process or subprocess_heavy` to that step (`MAX_PROCS=0`) and excludes both markers from the parallel unit step.
+
+If a test runs `subprocess.run([sys.executable, "-c", ...])`, or otherwise starts a fresh interpreter that imports `fastmcp`, mark it `subprocess_heavy`. A subprocess that runs a minimal stdlib script with no FastMCP import doesn't need the marker — it's the interpreter startup and import that's expensive, not the subprocess itself.
+
+#### This is a mitigation, not a proof
+
+There is no root-cause diagnosis behind this fix, only a plausible one. During validation, one Windows run genuinely crashed a worker on `test_fastmcp_imports_without_legacy_httpx` — a fresh-interpreter test — with pytest-xdist reporting `worker 'gw1' crashed while running '...'` after execnet's channel saw `ConnectionResetError: [WinError 10054]`. Nothing in that log says *why* the worker died: memory exhaustion, handle exhaustion, and some Windows-specific `subprocess`/`execnet` interaction are all still consistent with what was observed. Marking the fresh-interpreter tests `subprocess_heavy` made the crash stop recurring, but "it stopped" is not the same as "we know why."
+
+Treat the next Windows worker crash as a test of this diagnosis. **If it lands on a test that is not a fresh-interpreter spawner, the `subprocess_heavy` theory was wrong** — the real problem is subprocess-under-xdist on Windows more generally, and isolating one marker's worth of tests was never going to fix that. The fallback is one conditional back in `run-pytest/action.yml`, restoring the pre-#4554 behavior:
+
+```bash
+PARALLEL_FLAGS=""
+if [ "$MAX_PROCS" != "0" ] && [ "${{ runner.os }}" != "Windows" ]; then
+ PARALLEL_FLAGS="--numprocesses auto --maxprocesses $MAX_PROCS --dist worksteal"
+fi
+```
+
+#### Two traps that aren't Windows-specific
+
+Two test-authoring bugs surfaced while validating this change. Neither is about Windows or parallelism, but both are worth watching for anywhere a real `sleep()` gets replaced with a wait:
+
+- **Match the wait condition to the assertion.** A test waited for "any callback fired," then asserted that a `completed` callback existed. That races, because an earlier `working` notification satisfies the wait before the `completed` one arrives. A deterministic wait is only as good as the condition it waits on — wait for the thing you actually assert.
+- **Don't assert on incidental timing.** A crash-recovery test asserted "at least one concurrent request fails" while a subprocess restarts, which quietly depended on the restart being slow. Once restart got faster, recovery could beat every in-flight request and the test started failing because the behavior *improved*. Assert the invariant instead: no hang, and no result served by the dead process.
+
## Writing Tests
diff --git a/docs/development/v4-notes/change-register.mdx b/docs/development/v4-notes/change-register.mdx
index 02d56f938..464403e9d 100644
--- a/docs/development/v4-notes/change-register.mdx
+++ b/docs/development/v4-notes/change-register.mdx
@@ -401,6 +401,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 b6f4ced66..760e0a2e8 100644
--- a/fastmcp_slim/fastmcp/client/transports/base.py
+++ b/fastmcp_slim/fastmcp/client/transports/base.py
@@ -52,10 +52,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 ede949402..3e391b44f 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()
@property
def legacy_only(self) -> bool:
@@ -133,7 +152,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
@@ -144,7 +174,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.)
@@ -172,6 +202,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.
@@ -179,6 +210,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
@@ -203,7 +237,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 8e4f4ed12..c795212ba 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,15 +1292,22 @@ 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. This pin is explicit
- # rather than inherited, so flipping `Client`'s own default to `"auto"`
- # never changes proxy behavior.
+ # 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.
+ #
+ # The handshake default is pinned explicitly rather than inherited from
+ # `Client`, whose own default is `"auto"`: mirroring only applies when
+ # there is a front request to mirror, so this is the fallback for a
+ # directly-constructed ProxyClient, and it must not drift with the
+ # client default.
kwargs.setdefault("mode", "legacy")
# Install context-restoring handler wrappers BEFORE super().__init__
# registers them with the Client's session kwargs.
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 e0e015551..d6938d7e3 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