another bug report has hit the fastmcp

also docs.

codex tried a hell ton, and then deleted them all.
This commit is contained in:
lda
2026-05-16 16:50:57 +07:00 Verified
parent 55feba1258
commit 1397993217
7 changed files with 384 additions and 6 deletions
+66
View File
@@ -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`
+2
View File
@@ -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.
+4
View File
@@ -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:
+175
View File
@@ -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.
+10 -5
View File
@@ -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)