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
|
||||
one-for-one, coalesce them, or translate them into local catalog refresh
|
||||
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
|
||||
|
||||
|
||||
@@ -28,11 +28,22 @@ capability before we can bridge it end to end would make `wf-mcp` lie to clients
|
||||
|
||||
Known remaining uncertainty:
|
||||
|
||||
- which upstream notifications FastMCP proxying already forwards
|
||||
- which advanced MCP requests FastMCP can already bridge
|
||||
- how initialization capabilities should be projected through a broker with
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
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(".", "/")
|
||||
|
||||
|
||||
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):
|
||||
"""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) :]
|
||||
|
||||
def _uri(self, uri: str) -> str:
|
||||
match = _URI_PATTERN.match(uri)
|
||||
if match is None:
|
||||
return uri
|
||||
protocol, path = match.groups()
|
||||
return f"{protocol}{self._resource_prefix}{path}"
|
||||
return namespace_resource_uri(self._connection_id, uri)
|
||||
|
||||
def _local_uri(self, uri: str) -> str | None:
|
||||
match = _URI_PATTERN.match(uri)
|
||||
|
||||
Reference in New Issue
Block a user