Skip to content

Commit 9feb1f3

Browse files
authored
Forward proxy server metadata across protocol eras (#4776)
* Forward proxy negotiation metadata 🤖 Generated with OpenAI Codex * Limit forwarded proxy metadata 🤖 Generated with OpenAI Codex * Tighten negotiation metadata forwarding 🤖 Generated with OpenAI Codex * Tighten proxy metadata docs 🤖 Generated with OpenAI Codex * Keep proxy metadata middleware with provider 🤖 Generated with OpenAI Codex * Simplify proxy negotiation middleware API 🤖 Generated with OpenAI Codex * Name proxy metadata middleware directly 🤖 Generated with OpenAI Codex * Preserve discovery middleware contracts 🤖 Generated with OpenAI Codex * Clarify proxy metadata ownership 🤖 Generated with OpenAI Codex * Align proxy metadata wording 🤖 Generated with OpenAI Codex * Call forwarded values server metadata 🤖 Generated with OpenAI Codex * Harden proxy metadata reads 🤖 Generated with OpenAI Codex * Expose configured discovery result 🤖 Generated with OpenAI Codex * Preserve proxy discovery compatibility 🤖 Generated with OpenAI Codex * Preserve deprecated initialization middleware 🤖 Generated with OpenAI Codex * Harden proxy metadata boundaries 🤖 Generated with OpenAI Codex * Restore deprecated middleware location 🤖 Generated with OpenAI Codex * Simplify proxy metadata client lifecycle 🤖 Generated with OpenAI Codex * Clarify proxy metadata lifecycle 🤖 Generated with OpenAI Codex * Preserve proxy factory errors 🤖 Generated with OpenAI Codex * Detach forwarded proxy metadata 🤖 Generated with OpenAI Codex * Simplify proxy metadata implementation 🤖 Generated with OpenAI Codex * Distinguish proxy metadata failures 🤖 Generated with OpenAI Codex * Narrow proxy metadata validation fallback 🤖 Generated with OpenAI Codex * Retrigger CI 🤖 Generated with OpenAI Codex
1 parent 75fb116 commit 9feb1f3

11 files changed

Lines changed: 1113 additions & 134 deletions

File tree

docs/servers/middleware.mdx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,22 @@ async def on_initialize(self, context: MiddlewareContext, call_next):
310310
Rejection works only **before** `call_next()`. Raising `McpError` afterward logs the error without sending it — the client still receives a successful initialize response.
311311
</Warning>
312312

313+
#### on_discover
314+
315+
Called when a modern client negotiates through `server/discover`. Core discovery responses are returned as `DiscoverResult`; extension-owned result types are returned as dictionaries and should be passed through unless the middleware handles that extension.
316+
317+
```python
318+
from mcp_types import DiscoverResult
319+
320+
async def on_discover(self, context, call_next):
321+
result = await call_next(context)
322+
if not isinstance(result, DiscoverResult):
323+
return result
324+
return result.model_copy(update={"instructions": "Custom instructions"})
325+
```
326+
327+
Fields such as `supported_versions`, `capabilities`, and cache policy should only be changed when the server's public behavior also changes.
328+
313329
### Raw Handler
314330

315331
For complete control over all messages, override `__call__` instead of individual hooks:

docs/servers/providers/proxy.mdx

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,9 @@ To mount a proxy inside another FastMCP server, see [Mounting External Servers](
6060

6161
## Connection Semantics
6262

63-
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.
63+
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 request for optional server metadata using the backend client's existing lifecycle and negotiation mode; an unavailable backend does not prevent the client from connecting to the proxy.
6464

65-
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.
66-
67-
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.
65+
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.
6866

6967
## Transport Bridging
7068

@@ -388,6 +386,28 @@ Only reuse sessions when you know the backend is stateless (e.g. stateless HTTP)
388386

389387
## Advanced Usage
390388

389+
### Forwarding Server Metadata
390+
391+
Add `ProxyMetadataMiddleware` when a gateway built with `ProxyProvider` should also expose backend instructions and namespaced `_meta`:
392+
393+
```python
394+
from fastmcp import FastMCP
395+
from fastmcp.server.providers.proxy import (
396+
ProxyClient,
397+
ProxyMetadataMiddleware,
398+
ProxyProvider,
399+
)
400+
401+
backend = ProxyProvider(lambda: ProxyClient("http://backend:8000/mcp", mode="auto"))
402+
gateway = FastMCP(
403+
"Controlled Gateway",
404+
providers=[backend],
405+
middleware=[ProxyMetadataMiddleware(backend)],
406+
)
407+
```
408+
409+
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.
410+
391411
### FastMCPProxy Class
392412

393413
For explicit session control, use `FastMCPProxy` directly:

fastmcp_slim/fastmcp/client/client.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -657,6 +657,11 @@ def session(self) -> ClientSession:
657657

658658
return self._session_state.session
659659

660+
@property
661+
def prior_discover(self) -> mcp_types.DiscoverResult | None:
662+
"""The configured result to adopt when `mode` pins a modern version."""
663+
return self._prior_discover
664+
660665
@property
661666
def initialize_result(self) -> mcp_types.InitializeResult | None:
662667
"""Get the result of the initialization request.

fastmcp_slim/fastmcp/server/low_level.py

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -153,10 +153,11 @@ class FastMCPServerMiddleware:
153153
154154
Dispatch shapes:
155155
156-
- ``initialize`` runs the *whole* FastMCP chain here (``on_message`` ->
157-
``on_request`` -> ``on_initialize``) because there is no interior handler
158-
adapter for it: the SDK builds the ``InitializeResult`` directly, so this is
159-
the only place ``on_initialize`` can observe it or veto with ``MCPError``.
156+
- Negotiation runs the *whole* FastMCP chain here: ``initialize`` dispatches
157+
through ``on_initialize`` and ``server/discover`` through ``on_discover``.
158+
Neither has an interior FastMCP handler adapter, and the SDK serializes both
159+
results before returning through its middleware seam, so this root adapter
160+
restores core results to typed models before FastMCP middleware observes them.
160161
- The component methods (``tools/call``, ``tools/list``, ``resources/read``,
161162
...) still run their FastMCP chain *interior*, in the handler adapter, where
162163
``on_call_tool`` receives the typed component result and a tool exception
@@ -192,6 +193,8 @@ async def __call__(
192193
return await call_next(ctx)
193194
if ctx.method == "initialize" and ctx.request_id is not None:
194195
return await self._run_initialize_mw(fastmcp, ctx, call_next)
196+
if ctx.method == "server/discover" and ctx.request_id is not None:
197+
return await self._run_discover_mw(fastmcp, ctx, call_next)
195198
if ctx.request_id is not None and ctx.method in _INTERIOR_METHODS:
196199
return await self._dispatch_component(fastmcp, ctx, call_next)
197200
return await self._run_outer_mw(fastmcp, ctx, call_next, _raise=None)
@@ -318,6 +321,62 @@ def _apply_shared_context(self, fastmcp: FastMCP | None) -> Iterator[None]:
318321
for var, token in reversed(tokens):
319322
var.reset(token)
320323

324+
async def _run_discover_mw(
325+
self,
326+
fastmcp: FastMCP,
327+
ctx: ServerRequestContext,
328+
call_next: CallNext,
329+
) -> HandlerResult:
330+
"""Run discovery through the typed FastMCP middleware hook."""
331+
from fastmcp.server.context import Context
332+
from fastmcp.server.middleware.middleware import MiddlewareContext
333+
334+
try:
335+
discover_message = mcp_types.DiscoverRequest.model_validate(
336+
{"method": "server/discover", "params": ctx.params}, by_name=False
337+
)
338+
except ValidationError as exc:
339+
return await self._run_outer_mw(fastmcp, ctx, call_next, _raise=exc)
340+
341+
async def call_original_handler(
342+
_mw_ctx: MiddlewareContext,
343+
) -> mcp_types.DiscoverResult | dict[str, Any]:
344+
message = _mw_ctx.message
345+
params = (
346+
message.params.model_dump(by_alias=True, mode="json", exclude_none=True)
347+
if message.params is not None
348+
else None
349+
)
350+
raw = await call_next(replace(ctx, params=params))
351+
if isinstance(raw, mcp_types.DiscoverResult):
352+
return raw
353+
if isinstance(raw, Mapping):
354+
result = dict(raw)
355+
result_type = result.get("resultType")
356+
if (
357+
isinstance(result_type, str)
358+
and result_type not in mcp_types.CORE_RESULT_TYPES
359+
):
360+
return result
361+
return mcp_types.DiscoverResult.model_validate(result)
362+
raise TypeError(
363+
"server/discover handler returned "
364+
f"{type(raw).__name__}; expected DiscoverResult or mapping"
365+
)
366+
367+
async with Context(fastmcp=fastmcp, session=ctx.session) as fastmcp_ctx:
368+
mw_context = MiddlewareContext(
369+
message=discover_message,
370+
source="client",
371+
type="request",
372+
method="server/discover",
373+
fastmcp_context=fastmcp_ctx,
374+
)
375+
return await fastmcp._run_middleware(
376+
mw_context,
377+
cast("FastMCPCallNext[Any, Any]", call_original_handler),
378+
)
379+
321380
async def _run_initialize_mw(
322381
self,
323382
fastmcp: FastMCP,

fastmcp_slim/fastmcp/server/middleware/middleware.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,8 @@ async def _dispatch_handler(
170170
match context.method:
171171
case "initialize":
172172
handler = make_handler_wrapper(self.on_initialize, handler)
173+
case "server/discover":
174+
handler = make_handler_wrapper(self.on_discover, handler)
173175
case "tools/call":
174176
handler = make_handler_wrapper(self.on_call_tool, handler)
175177
case "resources/read":
@@ -227,6 +229,13 @@ async def on_initialize(
227229
) -> mt.InitializeResult | None:
228230
return await call_next(context)
229231

232+
async def on_discover(
233+
self,
234+
context: MiddlewareContext[mt.DiscoverRequest],
235+
call_next: CallNext[mt.DiscoverRequest, mt.DiscoverResult | dict[str, Any]],
236+
) -> mt.DiscoverResult | dict[str, Any]:
237+
return await call_next(context)
238+
230239
async def on_call_tool(
231240
self,
232241
context: MiddlewareContext[mt.CallToolRequestParams],

0 commit comments

Comments
 (0)