mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-23 05:54:19 +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
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue