mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-28 10:18:08 +02:00
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:
parent
effbc568ff
commit
0ba3db1a56
7 changed files with 389 additions and 24 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue