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

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