another bug report has hit the fastmcp
also docs. codex tried a hell ton, and then deleted them all.
This commit is contained in:
@@ -0,0 +1,66 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
import mcp.types as mcp_types
|
||||||
|
from fastmcp import Client
|
||||||
|
from fastmcp.client.transports import FastMCPTransport
|
||||||
|
from fastmcp.server import create_proxy
|
||||||
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
|
from pydantic import AnyUrl
|
||||||
|
|
||||||
|
server = FastMCP("notification-fixture")
|
||||||
|
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
async def emit_notifications_tool(ctx: Context) -> dict[str, bool]:
|
||||||
|
await ctx.request_context.session.send_tool_list_changed()
|
||||||
|
await ctx.request_context.session.send_resource_list_changed()
|
||||||
|
await ctx.request_context.session.send_prompt_list_changed()
|
||||||
|
await ctx.request_context.session.send_resource_updated(
|
||||||
|
AnyUrl("fixture://docs/welcome")
|
||||||
|
)
|
||||||
|
await ctx.info("fixture emitted notifications")
|
||||||
|
return {"emitted": True}
|
||||||
|
|
||||||
|
|
||||||
|
upstream_seen: list[str] = []
|
||||||
|
downstream_seen: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
async def upstream_message_handler(message: object) -> None:
|
||||||
|
if isinstance(message, mcp_types.ServerNotification):
|
||||||
|
upstream_seen.append(message.root.method)
|
||||||
|
|
||||||
|
|
||||||
|
async def downstream_message_handler(message: object) -> None:
|
||||||
|
if isinstance(message, mcp_types.ServerNotification):
|
||||||
|
downstream_seen.append(message.root.method)
|
||||||
|
|
||||||
|
|
||||||
|
upstream_transport = FastMCPTransport(server) # any transport will do, tried python stdio, and fastmcp, both reproduce.
|
||||||
|
|
||||||
|
|
||||||
|
async def prog() -> None:
|
||||||
|
upstream_client = Client(
|
||||||
|
upstream_transport,
|
||||||
|
message_handler=upstream_message_handler,
|
||||||
|
)
|
||||||
|
proxy = create_proxy(upstream_client)
|
||||||
|
downstream_client = Client(
|
||||||
|
proxy,
|
||||||
|
message_handler=downstream_message_handler,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with downstream_client:
|
||||||
|
await downstream_client.call_tool("emit_notifications_tool")
|
||||||
|
seen = [
|
||||||
|
"notifications/tools/list_changed",
|
||||||
|
"notifications/resources/list_changed",
|
||||||
|
"notifications/prompts/list_changed",
|
||||||
|
"notifications/resources/updated",
|
||||||
|
"notifications/message",
|
||||||
|
]
|
||||||
|
assert upstream_seen == seen
|
||||||
|
assert downstream_seen == seen, "bug here: notifications should be forwarded by the proxy"
|
||||||
|
|
||||||
|
|
||||||
|
asyncio.run(prog())
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
---
|
||||||
|
title: Should proxies forward upstream list/resource notifications downstream?
|
||||||
|
---
|
||||||
|
|
||||||
|
## summary
|
||||||
|
|
||||||
|
An upstream client used by a FastMCP proxy receives ordinary server
|
||||||
|
notifications correctly.
|
||||||
|
However, the downstream client connected to the proxy does not receive those
|
||||||
|
notifications.
|
||||||
|
|
||||||
|
I am not sure whether this is intended or just not implemented yet. The proxy
|
||||||
|
docs mention automatic forwarding for roots, sampling, elicitation, logging,
|
||||||
|
and progress, but I could not tell whether list/resource notifications are
|
||||||
|
meant to be forwarded too.
|
||||||
|
|
||||||
|
Tested on FastMCP `3.3.0`.
|
||||||
|
|
||||||
|
## minimal example
|
||||||
|
|
||||||
|
The upstream fixture emits ordinary MCP server notifications from one tool call:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
|
from pydantic import AnyUrl
|
||||||
|
|
||||||
|
server = FastMCP("notification-fixture")
|
||||||
|
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
async def emit_notifications_tool(ctx: Context) -> dict[str, bool]:
|
||||||
|
await ctx.request_context.session.send_tool_list_changed()
|
||||||
|
await ctx.request_context.session.send_resource_list_changed()
|
||||||
|
await ctx.request_context.session.send_prompt_list_changed()
|
||||||
|
await ctx.request_context.session.send_resource_updated(
|
||||||
|
AnyUrl("fixture://docs/welcome")
|
||||||
|
)
|
||||||
|
await ctx.info("fixture emitted notifications")
|
||||||
|
return {"emitted": True}
|
||||||
|
```
|
||||||
|
|
||||||
|
When called directly, a client can observe all of them.
|
||||||
|
|
||||||
|
With a proxy in the middle:
|
||||||
|
|
||||||
|
```python
|
||||||
|
upstream_client = Client(
|
||||||
|
upstream_transport,
|
||||||
|
message_handler=upstream_message_handler,
|
||||||
|
)
|
||||||
|
proxy = create_proxy(upstream_client)
|
||||||
|
downstream_client = Client(
|
||||||
|
proxy,
|
||||||
|
message_handler=downstream_message_handler,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with downstream_client:
|
||||||
|
await downstream_client.call_tool("emit_notifications_tool")
|
||||||
|
```
|
||||||
|
|
||||||
|
the upstream-side handler still observes the notifications, but the downstream
|
||||||
|
handler observes none.
|
||||||
|
|
||||||
|
## observed behavior
|
||||||
|
|
||||||
|
```text
|
||||||
|
upstream_seen == [
|
||||||
|
"notifications/tools/list_changed",
|
||||||
|
"notifications/resources/list_changed",
|
||||||
|
"notifications/prompts/list_changed",
|
||||||
|
"notifications/resources/updated",
|
||||||
|
"notifications/message",
|
||||||
|
]
|
||||||
|
|
||||||
|
downstream_seen == []
|
||||||
|
```
|
||||||
|
|
||||||
|
## question
|
||||||
|
|
||||||
|
Should a FastMCP proxy forward these upstream notifications to the downstream
|
||||||
|
client, or is the current behavior intentional?
|
||||||
|
|
||||||
|
## why i expected forwarding
|
||||||
|
|
||||||
|
From the downstream client's point of view, the upstream server's visible
|
||||||
|
surface can change while it is behind the proxy:
|
||||||
|
|
||||||
|
- tool list changes
|
||||||
|
- resource list changes
|
||||||
|
- prompt list changes
|
||||||
|
- a subscribed resource updates
|
||||||
|
|
||||||
|
If those notifications are not forwarded, a downstream client connected through
|
||||||
|
the proxy cannot react the same way it could when connected to the upstream
|
||||||
|
server directly.
|
||||||
|
|
||||||
|
## possible expected behavior
|
||||||
|
|
||||||
|
Server notifications received by the upstream side of a FastMCP proxy should be
|
||||||
|
forwarded to the downstream client, or there should be a supported proxy hook
|
||||||
|
for forwarding them.
|
||||||
|
|
||||||
|
## scope of this issue
|
||||||
|
|
||||||
|
This is specifically about upstream server notifications that already reach the
|
||||||
|
proxy's upstream client but do not reach the downstream client connected to the
|
||||||
|
proxy.
|
||||||
|
|
||||||
|
The minimal repro covers:
|
||||||
|
|
||||||
|
- `notifications/tools/list_changed`
|
||||||
|
- `notifications/resources/list_changed`
|
||||||
|
- `notifications/prompts/list_changed`
|
||||||
|
- `notifications/resources/updated`
|
||||||
|
- `notifications/message`
|
||||||
@@ -226,6 +226,8 @@ Still unproven:
|
|||||||
- whether future explicit relay code should project list-changed notifications
|
- whether future explicit relay code should project list-changed notifications
|
||||||
one-for-one, coalesce them, or translate them into local catalog refresh
|
one-for-one, coalesce them, or translate them into local catalog refresh
|
||||||
events first
|
events first
|
||||||
|
- whether future explicit relay code should use FastMCP's public proxy hooks or
|
||||||
|
a small local wrapper around the upstream message handler
|
||||||
|
|
||||||
## Capability Negotiation Inventory
|
## Capability Negotiation Inventory
|
||||||
|
|
||||||
|
|||||||
@@ -28,11 +28,22 @@ capability before we can bridge it end to end would make `wf-mcp` lie to clients
|
|||||||
|
|
||||||
Known remaining uncertainty:
|
Known remaining uncertainty:
|
||||||
|
|
||||||
- which upstream notifications FastMCP proxying already forwards
|
|
||||||
- which advanced MCP requests FastMCP can already bridge
|
- which advanced MCP requests FastMCP can already bridge
|
||||||
- how initialization capabilities should be projected through a broker with
|
- how initialization capabilities should be projected through a broker with
|
||||||
multiple enabled upstream connections
|
multiple enabled upstream connections
|
||||||
|
|
||||||
|
Resolved after this spec was written:
|
||||||
|
|
||||||
|
- generic upstream notifications such as list-changed, resource-updated, and
|
||||||
|
logging notifications do **not** automatically reach downstream clients
|
||||||
|
through the current proxy path
|
||||||
|
- current capability negotiation is not the reason for that gap
|
||||||
|
|
||||||
|
See [`../../mcp_protocol_proxy_inventory.md`](../../mcp_protocol_proxy_inventory.md)
|
||||||
|
for the measured facts and
|
||||||
|
[`../../wf_mcp_proxy_reality_and_roadmap.md`](../../wf_mcp_proxy_reality_and_roadmap.md)
|
||||||
|
for the current practical roadmap.
|
||||||
|
|
||||||
## Core Distinction
|
## Core Distinction
|
||||||
|
|
||||||
MCP has two different capability directions.
|
MCP has two different capability directions.
|
||||||
|
|||||||
@@ -97,6 +97,10 @@ unmount/provider lifecycle when it becomes available.
|
|||||||
Do not add notification proxying or long-lived subscription handling across
|
Do not add notification proxying or long-lived subscription handling across
|
||||||
reloads without first introducing an explicit mount lifecycle boundary.
|
reloads without first introducing an explicit mount lifecycle boundary.
|
||||||
|
|
||||||
|
The current practical proxy roadmap, including which FastMCP gaps are worth
|
||||||
|
working around locally and which should stay upstream-dependent for now, lives
|
||||||
|
in [`wf_mcp_proxy_reality_and_roadmap.md`](wf_mcp_proxy_reality_and_roadmap.md).
|
||||||
|
|
||||||
## Future Extraction
|
## Future Extraction
|
||||||
|
|
||||||
If this becomes multiple distributions, likely split points are:
|
If this becomes multiple distributions, likely split points are:
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
# wf_mcp Proxy Reality And Roadmap
|
||||||
|
|
||||||
|
This document is the current practical position for `wf-mcp` proxy work after
|
||||||
|
the first live protocol investigations. It is intentionally more opinionated
|
||||||
|
than [`mcp_protocol_proxy_inventory.md`](mcp_protocol_proxy_inventory.md),
|
||||||
|
which remains the fact log.
|
||||||
|
|
||||||
|
## Current Position
|
||||||
|
|
||||||
|
`wf-mcp` should keep using FastMCP as its proxy foundation.
|
||||||
|
|
||||||
|
FastMCP is already giving us substantial value:
|
||||||
|
|
||||||
|
- ordinary proxied tools work
|
||||||
|
- listed resources and resource templates work
|
||||||
|
- resource reads through namespaced URIs work
|
||||||
|
- proxy mounting, transforms, and client/server plumbing are real leverage
|
||||||
|
- selected advanced forwarding paths already exist upstream, including roots,
|
||||||
|
sampling, elicitation, logging, and progress
|
||||||
|
|
||||||
|
But FastMCP proxying is not currently a fully transparent MCP relay. We should
|
||||||
|
not block the rest of the product on making it one.
|
||||||
|
|
||||||
|
## What Works Today
|
||||||
|
|
||||||
|
### Proxy Surface
|
||||||
|
|
||||||
|
- normal `tools/list` and `tools/call`
|
||||||
|
- tool title, description, and JSON-schema metadata
|
||||||
|
- listed resource and resource-template namespacing
|
||||||
|
- namespaced resource reads
|
||||||
|
- current dotted tool names and slash-separated resource URI namespaces
|
||||||
|
|
||||||
|
### Local wf-mcp Behavior
|
||||||
|
|
||||||
|
- local admin reload emits downstream list-changed notifications for the current
|
||||||
|
request session
|
||||||
|
- internal event projection for local tool/resource/prompt changes exists
|
||||||
|
- unchanged mounted connections are reused across reload through
|
||||||
|
`ProxyMountRegistry`
|
||||||
|
|
||||||
|
### Local Workarounds We Intentionally Own
|
||||||
|
|
||||||
|
- tool-returned `ResourceLink` URIs are rewritten by
|
||||||
|
`wf_mcp.proxy_results`
|
||||||
|
|
||||||
|
This workaround is worth owning because it is bounded, local, well-tested, and
|
||||||
|
deletable if FastMCP gains a general result-transform hook later.
|
||||||
|
|
||||||
|
## Confirmed Gaps
|
||||||
|
|
||||||
|
### 1. Generic upstream notifications are not relayed downstream
|
||||||
|
|
||||||
|
Confirmed on 2026-05-16 with fixture-backed tests and the runnable
|
||||||
|
`docs/f/` repros.
|
||||||
|
|
||||||
|
The upstream-side client sees:
|
||||||
|
|
||||||
|
- `notifications/tools/list_changed`
|
||||||
|
- `notifications/resources/list_changed`
|
||||||
|
- `notifications/prompts/list_changed`
|
||||||
|
- `notifications/resources/updated`
|
||||||
|
- `notifications/message`
|
||||||
|
|
||||||
|
The downstream client connected through the proxy sees none of them.
|
||||||
|
|
||||||
|
This is currently tracked upstream as:
|
||||||
|
|
||||||
|
- `PrefectHQ/fastmcp#4161`
|
||||||
|
|
||||||
|
The closest related upstream issue found so far is:
|
||||||
|
|
||||||
|
- `PrefectHQ/fastmcp#4124`
|
||||||
|
- downstream cancellation is not propagated upstream
|
||||||
|
|
||||||
|
Together, these suggest FastMCP's proxy layer forwards selected protocol
|
||||||
|
features but is not yet a general bidirectional protocol relay.
|
||||||
|
|
||||||
|
### 2. Tool-returned ResourceLinks were not rewritten
|
||||||
|
|
||||||
|
FastMCP namespaced listed resources correctly, but ordinary `ResourceLink`
|
||||||
|
content inside proxied tool results kept raw upstream URIs.
|
||||||
|
|
||||||
|
This is currently tracked upstream as:
|
||||||
|
|
||||||
|
- `PrefectHQ/fastmcp#4154`
|
||||||
|
|
||||||
|
We already have the local workaround described above.
|
||||||
|
|
||||||
|
### 3. Session resource behavior is still unresolved
|
||||||
|
|
||||||
|
The Everything server's session resource returned by
|
||||||
|
`gzip-file-as-resource` was not readable through the proxy during the live
|
||||||
|
probe. Direct reading also failed in that Codex session, so this is not yet
|
||||||
|
classified as a proxy bug.
|
||||||
|
|
||||||
|
### 4. Tasks remain unsupported
|
||||||
|
|
||||||
|
Task-required tools are discoverable but not usable through our current surface.
|
||||||
|
This is a real protocol-support gap, not an ordinary tool-call issue.
|
||||||
|
|
||||||
|
## What Is Probably Not Worth Owning Yet
|
||||||
|
|
||||||
|
Do **not** rush to implement a custom full protocol relay for:
|
||||||
|
|
||||||
|
- generic upstream notification forwarding
|
||||||
|
- cancellation propagation
|
||||||
|
- resource subscription ownership
|
||||||
|
- task execution
|
||||||
|
- broad replacement of FastMCP proxy internals
|
||||||
|
|
||||||
|
Those are cross-cutting lifecycle problems. They touch request identity,
|
||||||
|
session ownership, namespace projection, reconnect behavior, and possibly
|
||||||
|
transport-specific semantics. A small local workaround here would likely turn
|
||||||
|
into a second proxy framework by accident.
|
||||||
|
|
||||||
|
## Decision Rule For Local Workarounds
|
||||||
|
|
||||||
|
Own a local workaround only when all are true:
|
||||||
|
|
||||||
|
1. the gap blocks near-term product work
|
||||||
|
2. the behavior boundary is narrow and testable
|
||||||
|
3. the workaround can live in one isolated module family
|
||||||
|
4. deleting it later will be cheap if upstream support lands
|
||||||
|
|
||||||
|
`ResourceLink` rewriting passed that test.
|
||||||
|
|
||||||
|
Generic notification relay does not pass it yet.
|
||||||
|
|
||||||
|
## Roadmap From Here
|
||||||
|
|
||||||
|
### Continue Building Now
|
||||||
|
|
||||||
|
These areas do not require a perfect transparent proxy:
|
||||||
|
|
||||||
|
1. capability and source inventory surfaced clearly to users and LLM clients
|
||||||
|
2. admin/control UX over configured sources
|
||||||
|
3. workflow artifacts, deployments, and dependency validation
|
||||||
|
4. `wf.std` and `wf.mcp` authoring/runtime affordances
|
||||||
|
5. LLM-facing workflow construction using existing sources
|
||||||
|
|
||||||
|
These keep compounding even if upstream proxy transparency remains imperfect for
|
||||||
|
a while.
|
||||||
|
|
||||||
|
### Keep Investigating Selectively
|
||||||
|
|
||||||
|
Continue protocol investigation only where it changes near-term design:
|
||||||
|
|
||||||
|
1. which upstream capabilities are safe to expose publicly
|
||||||
|
2. what per-source capability metadata the admin surface should show
|
||||||
|
3. whether session resources matter to workflows we actually want soon
|
||||||
|
4. whether a specific advanced FastMCP forwarding path is needed by a real
|
||||||
|
source before we depend on it
|
||||||
|
|
||||||
|
### Revisit Later
|
||||||
|
|
||||||
|
Come back to deeper proxy work when one of these becomes true:
|
||||||
|
|
||||||
|
- FastMCP ships official support we can adopt
|
||||||
|
- a concrete user-facing workflow needs the missing protocol feature
|
||||||
|
- the missing feature becomes small enough to isolate cleanly
|
||||||
|
|
||||||
|
## Near-Term Recommended Next Work
|
||||||
|
|
||||||
|
The best next repo work is **not** another proxy internals expedition.
|
||||||
|
|
||||||
|
Recommended order:
|
||||||
|
|
||||||
|
1. expose richer source/capability inventory through the public/admin surfaces
|
||||||
|
2. keep per-source protocol support visible rather than pretending all mounted
|
||||||
|
sources are equivalent
|
||||||
|
3. continue the workflow/platform layer that consumes those sources
|
||||||
|
|
||||||
|
That preserves the long-term proxy ambition without letting it dominate the
|
||||||
|
project before the upstream foundation is ready.
|
||||||
@@ -82,6 +82,15 @@ def connection_id_to_resource_path(connection_id: str) -> str:
|
|||||||
return connection_id.replace(".", "/")
|
return connection_id.replace(".", "/")
|
||||||
|
|
||||||
|
|
||||||
|
def namespace_resource_uri(connection_id: str, uri: str) -> str:
|
||||||
|
"""Project one upstream resource URI into its downstream proxy namespace."""
|
||||||
|
match = _URI_PATTERN.match(uri)
|
||||||
|
if match is None:
|
||||||
|
return uri
|
||||||
|
protocol, path = match.groups()
|
||||||
|
return f"{protocol}{connection_id_to_resource_path(connection_id)}/{path}"
|
||||||
|
|
||||||
|
|
||||||
class ProxyNamespace(Transform):
|
class ProxyNamespace(Transform):
|
||||||
"""Project MCP proxy names with dots for callables and slashes for URIs."""
|
"""Project MCP proxy names with dots for callables and slashes for URIs."""
|
||||||
|
|
||||||
@@ -188,11 +197,7 @@ class ProxyNamespace(Transform):
|
|||||||
return name[len(self._name_prefix) :]
|
return name[len(self._name_prefix) :]
|
||||||
|
|
||||||
def _uri(self, uri: str) -> str:
|
def _uri(self, uri: str) -> str:
|
||||||
match = _URI_PATTERN.match(uri)
|
return namespace_resource_uri(self._connection_id, uri)
|
||||||
if match is None:
|
|
||||||
return uri
|
|
||||||
protocol, path = match.groups()
|
|
||||||
return f"{protocol}{self._resource_prefix}{path}"
|
|
||||||
|
|
||||||
def _local_uri(self, uri: str) -> str | None:
|
def _local_uri(self, uri: str) -> str | None:
|
||||||
match = _URI_PATTERN.match(uri)
|
match = _URI_PATTERN.match(uri)
|
||||||
|
|||||||
Reference in New Issue
Block a user