Harden proxy metadata boundaries

🤖 Generated with OpenAI Codex
This commit is contained in:
Jake Kaplan 2026-08-06 09:35:35 -04:00
commit 4377f6ee8f
3 changed files with 87 additions and 26 deletions

View file

@ -60,11 +60,9 @@ To mount a proxy inside another FastMCP server, see [Mounting External Servers](
## Connection Semantics
FastMCP proxies are lazy bridges. Creating the proxy object and starting the local server do not contact the upstream server. The upstream connection begins when an MCP client sends an `initialize` request to the proxy.
FastMCP proxies are lazy bridges. Creating the proxy object and starting the local server do not contact the upstream server. During client negotiation, the proxy makes a best-effort connection to read optional server metadata; an unavailable backend does not prevent the client from connecting to the proxy.
During initialization, the proxy initializes the upstream server before responding locally. If the upstream server is unavailable, the URL does not point to an MCP endpoint, or upstream authentication cannot complete, the proxy initialization fails. This keeps the local proxy's connection status aligned with the upstream server it represents.
After initialization, the proxy forwards MCP requests such as `ping`, `tools/list`, `resources/list`, `prompts/list`, tool calls, resource reads, sampling, elicitation, logging, and progress through the upstream client.
Subsequent MCP requests such as `ping`, `tools/list`, `resources/list`, `prompts/list`, tool calls, resource reads, sampling, elicitation, logging, and progress connect to the backend as needed. Component provider failures follow `provider_error_strategy`: the default `"warn"` logs and skips a failed provider, while `"raise"` reports the failure to the client.
## Transport Bridging
@ -408,7 +406,7 @@ gateway = FastMCP(
)
```
By default the gateway keeps its own `serverInfo`; pass `identity="upstream"` to use the backend identity when available. Frontend instructions and `_meta` values win on collisions. The middleware never copies upstream protocol versions, capabilities, cache policy, `resultType`, or unknown top-level fields. If the backend is unavailable, the client can still connect without its optional metadata.
By default the gateway keeps its own `serverInfo`; pass `identity="upstream"` to use the backend identity when available. Frontend instructions and `_meta` values win on collisions. The middleware never copies upstream protocol versions, connection metadata, capabilities, cache policy, `resultType`, or unknown top-level fields. If the backend is unavailable, the client can still connect without its optional metadata.
### FastMCPProxy Class

View file

@ -30,6 +30,7 @@ from mcp_types import (
TextResourceContents,
)
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
from pydantic import ValidationError
from pydantic.networks import AnyUrl
from fastmcp._warnings import FastMCPDeprecationWarning
@ -164,6 +165,15 @@ def _forwardable_request_meta(ctx: Context | None) -> dict[str, Any] | None:
return forwarded or None
def _forwardable_server_meta(meta: dict[str, Any] | None) -> dict[str, Any]:
"""Backend result metadata that may cross onto the frontend connection."""
return {
key: value
for key, value in (meta or {}).items()
if key not in _CONNECTION_META_KEYS and key != mcp_types.SERVER_INFO_META_KEY
}
def _session_request_meta(
meta: dict[str, Any] | None,
) -> mcp_types.RequestParamsMeta | None:
@ -1035,16 +1045,37 @@ class _UpstreamServerMetadata:
server_info: mcp_types.Implementation | None
meta: dict[str, Any]
@classmethod
def from_result(
cls,
result: mcp_types.InitializeResult | mcp_types.DiscoverResult,
server_info: mcp_types.Implementation | None,
) -> _UpstreamServerMetadata:
return cls(
instructions=result.instructions,
server_info=server_info,
meta=dict(result.meta or {}),
)
@classmethod
def from_client(cls, client: Client) -> _UpstreamServerMetadata | None:
result = client.session.initialize_result or client.session.discover_result
if result is None:
return None
return cls(
instructions=result.instructions,
server_info=client.session.server_info,
meta=dict(result.meta or {}),
)
return cls.from_result(result, client.session.server_info)
@classmethod
def from_discover(cls, result: mcp_types.DiscoverResult) -> _UpstreamServerMetadata:
raw_server_info = (result.meta or {}).get(mcp_types.SERVER_INFO_META_KEY)
try:
server_info = (
mcp_types.Implementation.model_validate(raw_server_info)
if raw_server_info is not None
else None
)
except ValidationError:
server_info = None
return cls.from_result(result, server_info)
class ProxyMetadataMiddleware(Middleware):
@ -1075,23 +1106,32 @@ class ProxyMetadataMiddleware(Middleware):
client = cast(Client, await client)
connected = client.is_connected()
# A pinned modern client without a prior discovery result adopts a
# synthesized result without contacting the server. Probe with a copy so
# metadata comes from the backend without changing the configured mode.
if (
not connected
and client.mode in MODERN_PROTOCOL_VERSIONS
and client.prior_discover is None
):
synthesized_discover = (
client.mode in MODERN_PROTOCOL_VERSIONS and client.prior_discover is None
)
# A disconnected pinned client can probe with a copy. A connected client
# must keep using its existing transport, so query discovery directly
# without replacing the result already adopted by its session.
if synthesized_discover and not connected:
client = client.new()
client.mode = "auto"
if context is not None:
_stash_proxy_request_context(client, context)
if connected:
return _UpstreamServerMetadata.from_client(client)
try:
if synthesized_discover and connected:
raw = await client.session.send_discover(client.mode)
result_type = raw.get("resultType")
if (
isinstance(result_type, str)
and result_type not in mcp_types.CORE_RESULT_TYPES
):
return None
result = mcp_types.DiscoverResult.model_validate(raw)
return _UpstreamServerMetadata.from_discover(result)
if connected:
return _UpstreamServerMetadata.from_client(client)
async with client:
return _UpstreamServerMetadata.from_client(client)
except (MCPError, *_PROXY_TRANSPORT_ERRORS) as error:
@ -1103,11 +1143,7 @@ class ProxyMetadataMiddleware(Middleware):
result: mcp_types.InitializeResult | mcp_types.DiscoverResult,
upstream: _UpstreamServerMetadata,
) -> dict[str, Any]:
meta = {
key: value
for key, value in upstream.meta.items()
if key != mcp_types.SERVER_INFO_META_KEY
}
meta = _forwardable_server_meta(upstream.meta)
meta.update(result.meta or {})
updates: dict[str, Any] = {"meta": meta or None}

View file

@ -40,6 +40,9 @@ class UpstreamMetadataMiddleware(Middleware):
def _updates(self, result: mcp_types.Result) -> dict[str, Any]:
meta = {
**(result.meta or {}),
mcp_types.PROTOCOL_VERSION_META_KEY: "upstream-version",
mcp_types.CLIENT_INFO_META_KEY: {"name": "upstream-client"},
mcp_types.CLIENT_CAPABILITIES_META_KEY: {"upstream": True},
"com.example/upstream": {"enabled": True},
"com.example/shared": "upstream",
}
@ -178,6 +181,12 @@ async def test_forwards_metadata_across_all_protocol_era_combinations(
assert client.server_info.name == "gateway"
assert result.meta is not None
assert result.meta["com.example/upstream"] == {"enabled": True}
for key in (
mcp_types.PROTOCOL_VERSION_META_KEY,
mcp_types.CLIENT_INFO_META_KEY,
mcp_types.CLIENT_CAPABILITIES_META_KEY,
):
assert key not in result.meta
stamped_info = result.meta.get(mcp_types.SERVER_INFO_META_KEY)
assert result.capabilities.experimental is None
@ -263,7 +272,7 @@ async def test_forwards_backend_logs_while_reading_metadata():
assert messages == ["metadata connection"]
async def test_pinned_client_uses_prior_discover_metadata():
async def test_pinned_clients_use_available_metadata():
prior_info = mcp_types.Implementation(name="prior", version="1.0")
prior = mcp_types.DiscoverResult(
supported_versions=[MODERN_PROTOCOL_VERSIONS[0]],
@ -297,6 +306,24 @@ async def test_pinned_client_uses_prior_discover_metadata():
assert result.meta is not None
assert result.meta["com.example/prior"] is True
version = MODERN_PROTOCOL_VERSIONS[0]
upstream = make_upstream()
async with Client(upstream, mode=version) as backend_client:
assert backend_client.instructions is None
proxy = create_proxy(backend_client, identity="upstream")
async with Client(proxy, mode="auto") as client:
result = client.session.discover_result
assert result is not None
assert client.instructions == "upstream instructions"
assert client.server_info == UPSTREAM_INFO
assert result.meta is not None
assert result.meta["com.example/upstream"] == {"enabled": True}
# The metadata probe must not replace the connected client's adopted
# synthetic result.
assert backend_client.instructions is None
async def test_client_factory_errors_are_not_swallowed():
def broken_factory() -> Client: