document current behavior of notif and capabilities

This commit is contained in:
lda
2026-05-16 12:04:13 +07:00 Verified
parent db2e4235d1
commit 55feba1258
7 changed files with 572 additions and 22 deletions
+83 -10
View File
@@ -133,7 +133,7 @@ Official spec pages checked:
Installed local packages checked: Installed local packages checked:
- `fastmcp==3.2.4` - `fastmcp==3.3.0`
- `mcp==1.27.0` - `mcp==1.27.0`
### Protocol Notifications ### Protocol Notifications
@@ -194,6 +194,79 @@ The obvious notification path is request/session scoped through FastMCP
event bus can emit outside a currently executing MCP tool call, while MCP event bus can emit outside a currently executing MCP tool call, while MCP
notifications need a connected client session. notifications need a connected client session.
### Observed Upstream Relay Behavior
Fixture probe date: 2026-05-16
The fixture server exposes `emit_notifications_tool`, which emits these
notifications during one ordinary tool call:
| Notification | Direct upstream client | Through unified proxy |
| --- | --- | --- |
| `notifications/tools/list_changed` | observed | not observed |
| `notifications/resources/list_changed` | observed | not observed |
| `notifications/prompts/list_changed` | observed | not observed |
| `notifications/resources/updated` | observed | not observed |
| `notifications/message` | observed | not observed |
This is covered by `tests/wf_mcp/test_protocol_relay.py`.
The current conclusion is narrow but important: ordinary mounted proxy tool
calls do **not** automatically relay upstream server notifications to the
downstream client, even though the same upstream emits those notifications
correctly to a direct MCP client. The upstream logging notification is consumed
locally enough for FastMCP to log it, but it is not re-emitted downstream.
Still unproven:
- `notifications/progress`, because it requires a request with
`_meta.progressToken`
- `notifications/tasks/status`, because task-augmented execution is not
implemented yet
- whether future explicit relay code should project list-changed notifications
one-for-one, coalesce them, or translate them into local catalog refresh
events first
## Capability Negotiation Inventory
Fixture probe date: 2026-05-16
This pass compares one direct fixture-server initialization with the same
fixture mounted through unified proxy mode.
| Capability field | Direct fixture server | Unified proxy |
| --- | --- | --- |
| `tools.listChanged` | `false` | `true` |
| `resources.subscribe` | `false` | `false` |
| `resources.listChanged` | `false` | `true` |
| `prompts.listChanged` | `false` | `true` |
| `logging` | absent | present |
| `extensions["io.modelcontextprotocol/ui"]` | absent | present |
This is covered by `tests/wf_mcp/test_protocol_capabilities.py`.
Two conclusions matter:
1. Unified proxy mode currently advertises the FastMCP server surface it can
serve locally, not a union of the mounted upstream server capability objects.
2. The missing upstream notification relay is not explained by current
capability negotiation. In the same fixture, direct upstream calls receive
notifications even though the fixture advertises `listChanged=false`, while
unified proxy advertises `listChanged=true` for its own local surface and
still does not forward upstream notifications.
Current truthful interpretation:
- `tools.listChanged`, `resources.listChanged`, and `prompts.listChanged` are
truthful for **local** proxy reload behavior because `wf.admin.reload_config`
emits those notifications to the current downstream session.
- They do not imply upstream list-changed notifications are relayed.
- `resources.subscribe` remains `false`, which matches current lack of
subscription ownership/relay.
- The public initialize result does not yet expose per-upstream differences;
that still belongs in future admin inventory rather than the coarse top-level
capability object.
### Mapping From wf-mcp Events ### Mapping From wf-mcp Events
Current internal events that can map to MCP notifications: Current internal events that can map to MCP notifications:
@@ -298,12 +371,12 @@ Expected outcomes:
- clients that ignore notifications can still manually call list/search tools - clients that ignore notifications can still manually call list/search tools
- no workflow or broker correctness depends on notification delivery - no workflow or broker correctness depends on notification delivery
6. Only after local list-changed works, investigate upstream notification 6. Done: investigate baseline upstream notification forwarding.
forwarding.
Upstream forwarding is a separate problem because mounted proxies may or may Direct fixture-server tests show that list-changed, resource-updated, and
not already forward upstream notifications. It also needs namespaced resource logging notifications are emitted upstream but are not forwarded automatically
URIs for `notifications/resources/updated`. through mounted proxy tool calls. Any relay support here will be explicit work,
and `notifications/resources/updated` will also need namespaced resource URIs.
### Deferred Notification Work ### Deferred Notification Work
@@ -312,13 +385,13 @@ URIs for `notifications/resources/updated`.
- `notifications/progress`: requires an active request `_meta.progressToken`. - `notifications/progress`: requires an active request `_meta.progressToken`.
- `notifications/message`: should respect client logging level behavior. - `notifications/message`: should respect client logging level behavior.
- `notifications/tasks/status`: should wait for task-augmented execution. - `notifications/tasks/status`: should wait for task-augmented execution.
- Upstream-to-downstream notification forwarding: needs a focused FastMCP proxy - Upstream-to-downstream notification forwarding: proven absent for ordinary
behavior test before implementation. mounted proxy tool calls; needs an explicit bridge if we want it.
### Open Questions ### Open Questions
- Can a mounted FastMCP proxy forward upstream notifications to the downstream - Which upstream notifications should be forwarded one-for-one, and which ones
client automatically, or only notifications generated by local handlers? should be converted into local catalog refresh events first?
- Can we attach a durable notification sink to every active FastMCP session - Can we attach a durable notification sink to every active FastMCP session
without relying on private APIs? without relying on private APIs?
- Does Codex surface list-changed, resource-updated, logging, or progress - Does Codex surface list-changed, resource-updated, logging, or progress
@@ -0,0 +1,274 @@
# MCP Protocol Relay Design
## Goal
Make `wf-mcp` a truthful high-quality MCP proxy, not merely a tool forwarder.
The next phase is about understanding and then projecting advanced MCP protocol
behavior correctly:
- upstream notifications
- server capability advertisement
- downstream client capability relay
- later request forwarding for client-side features such as elicitation, roots,
sampling, and task-augmented requests
The immediate next work is still **investigation first**, because advertising a
capability before we can bridge it end to end would make `wf-mcp` lie to clients.
## Current State
`wf-mcp` already has:
- a unified MCP surface
- proxy mounting with stable public names
- local list-changed notifications after reload
- ordinary tool/resource/prompt proxying
- resource-link URI rewriting for ordinary tool-returned `ResourceLink`s
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
## Core Distinction
MCP has two different capability directions.
### Server capabilities
These are advertised by `wf-mcp` to its downstream client:
- `tools`
- `resources`
- `prompts`
- `logging`
- `completions`
- `tasks`
`wf-mcp` may advertise a server capability only when the unified server can
actually serve it. The correct eventual shape is a **selected union**:
- local capabilities implemented by `wf-mcp`
- plus enabled upstream server capabilities that `wf-mcp` can proxy safely
### Client capabilities
These are advertised by the downstream client to `wf-mcp`:
- `roots`
- `sampling`
- `elicitation`
- client-side `tasks`
`wf-mcp` must not advertise these as its own server capabilities. Instead, when
an upstream server needs one of them, `wf-mcp` may relay the downstream client's
real capabilities upstream only if the corresponding request path can also be
forwarded correctly.
Examples:
- If the downstream client supports elicitation and `wf-mcp` can relay
`elicitation/create`, an upstream server may use elicitation through the
proxy.
- If the downstream client supports roots and `wf-mcp` can relay `roots/list`,
an upstream server may request roots through the proxy.
## Design Principles
1. **Truthful advertisement**
Never advertise a capability solely because some upstream declares it.
2. **Direction-aware relay**
Keep server-side projection and client-side relay as separate concepts.
3. **Selected union, not blind union**
Disabled upstreams and unsupported bridge paths must not inflate the public
capability set.
4. **Protocol first, workflow later**
This phase improves MCP proxy behavior. Workflow artifacts and planner-facing
source models should not absorb this complexity.
5. **Use official MCP boundary types**
Model protocol behavior with `mcp.types` capability and notification models
where possible. Do not create parallel ad hoc protocol dicts.
6. **Consent at source connection time**
A connected proxy source should normally be able to use the capabilities it
declares, but powerful client-side relays should be visible to the user when
the source is connected, similar to an app-permissions screen. Later admin UI
may disable individual relays such as sampling, roots, or elicitation for a
source without changing the base protocol model.
## Phase 1: Upstream Notification Inventory
### Questions
1. Does FastMCP already forward upstream:
- `notifications/tools/list_changed`
- `notifications/resources/list_changed`
- `notifications/prompts/list_changed`
- `notifications/resources/updated`
- `notifications/message`
- `notifications/progress`
- `notifications/tasks/status`
2. If some notifications are forwarded, are payloads transformed correctly?
3. For resource updates, are resource URIs projected into downstream namespace
form or leaked as raw upstream URIs?
4. Do local client surfaces we can inspect, such as Inspector or Codex, visibly
react to the forwarded notifications?
### Method
1. Extend the fixture server only where needed to emit deterministic events.
2. Probe direct upstream behavior first.
3. Probe the same behavior through `wf-mcp`.
4. Record observed behavior in `docs/mcp_protocol_proxy_inventory.md`.
5. Do not implement forwarding until the behavior gap is proven.
### Expected Outputs
- a table of each notification kind:
- direct upstream behavior
- through-proxy behavior
- payload correctness
- whether action is needed
- regression tests for any behavior we decide to rely on
## Phase 2: Capability Negotiation Inventory
### Questions
1. What server capabilities does the current unified `wf-mcp` server advertise?
2. What server capabilities do configured upstreams advertise?
3. What downstream client capabilities are visible to `wf-mcp` during
initialization?
4. Which advanced features can FastMCP already bridge today?
5. Which advertised upstream capabilities would be false for `wf-mcp` to expose
until request forwarding is added?
### Expected Outputs
- a server-capability projection matrix
- a client-capability relay matrix
- a concrete list of:
- already safe to advertise
- bridgeable with small glue
- unsupported until a larger forwarding subsystem exists
## Intended Architecture
Likely later modules:
```text
wf_mcp.protocol_relay/
server_capabilities.py
client_capabilities.py
notifications.py
```
Conceptually:
```text
enabled upstream capabilities
+
local wf-mcp capabilities
|
v
ServerCapabilityProjection
|
v
downstream initialize result
downstream client capabilities
|
v
ClientCapabilityRelay
|
v
selected upstream initialize requests
```
The exact module names may change after the inventory. The separation should
not:
- projecting what `wf-mcp` offers as a server
- relaying what the downstream client offers to upstream servers
Mixed upstream support should not be hidden. MCP's top-level capability object is
coarse, so `wf-mcp` should eventually expose per-source and, where useful,
per-capability detail through its admin inventory tools even when the public
server capability advertisement is a selected union.
## Feature Notes
### Elicitation
Elicitation is a client capability, not a server capability. Supporting it
through `wf-mcp` means forwarding upstream elicitation requests to the
downstream client and returning the response.
### Roots
Roots are also a client capability. They represent client-exposed filesystem or
workspace roots that a server may query. Supporting roots through `wf-mcp` means
relaying `roots/list`, not pretending `wf-mcp` itself owns roots.
### Sampling
Sampling is another client capability. It lets a server ask the client-side LLM
to generate content. This is powerful and should remain explicit because it can
cross trust boundaries.
### Tasks
Tasks exist in both directions:
- server tasks: downstream client task-augments calls into `wf-mcp`
- client tasks: upstream servers task-augment requests they send toward the
downstream client
Treat these as separate negotiated capabilities. Do not assume “tasks supported”
is one global boolean.
### MCP Apps And `_meta.ui`
`_meta.ui` belongs to the MCP Apps extension layer, not the base server/client
capability split above. `wf-mcp` should still preserve and proxy it faithfully.
Expected proxy behavior:
- preserve upstream `_meta.ui` values
- rewrite referenced UI resource URIs when they cross a namespace boundary, the
same way ordinary proxied resource URIs are projected
- do not rely on `_meta.ui` for core protocol correctness, because clients may
ignore extension metadata they do not understand
Possible later use:
- `wf-mcp` may expose its own source-consent, login/logout, catalog, and toggle
surfaces as MCP Apps UI resources/tools
- that is a UI delivery choice, not a reason to mix UI metadata into the core
capability negotiation model
## Non-Goals For The Next Pass
- Do not blindly union all upstream capability dicts.
- Do not build a custom protocol stack around FastMCP before measuring what it
already forwards.
- Do not solve workflow-artifact dependency validation here.
- Do not build UI controls here.
- Do not claim support for elicitation, roots, sampling, or tasks until an
end-to-end relay path is demonstrated.
## Success Criteria
The next pass is successful when we can answer, with evidence:
1. Which upstream notifications already survive the proxy?
2. Which server capabilities may `wf-mcp` advertise truthfully today?
3. Which downstream client capabilities can safely be relayed upstream today?
4. What exact small implementation should happen next, instead of guessing?
+7 -3
View File
@@ -94,7 +94,9 @@ class ProxyNamespace(Transform):
return f"ProxyNamespace({self._connection_id!r})" return f"ProxyNamespace({self._connection_id!r})"
async def list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]: async def list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]:
return [tool.model_copy(update={"name": self._name(tool.name)}) for tool in tools] return [
tool.model_copy(update={"name": self._name(tool.name)}) for tool in tools
]
async def get_tool( async def get_tool(
self, self,
@@ -172,7 +174,9 @@ class ProxyNamespace(Transform):
return ( return (
None None
if template is None if template is None
else template.model_copy(update={"uri_template": self._uri(template.uri_template)}) else template.model_copy(
update={"uri_template": self._uri(template.uri_template)}
)
) )
def _name(self, name: str) -> str: def _name(self, name: str) -> str:
@@ -197,4 +201,4 @@ class ProxyNamespace(Transform):
protocol, path = match.groups() protocol, path = match.groups()
if not path.startswith(self._resource_prefix): if not path.startswith(self._resource_prefix):
return None return None
return f"{protocol}{path[len(self._resource_prefix):]}" return f"{protocol}{path[len(self._resource_prefix) :]}"
+16 -2
View File
@@ -1,9 +1,10 @@
from __future__ import annotations from __future__ import annotations
from typing import Annotated, TypedDict from typing import Annotated, Any, TypedDict
import mcp.types as mcp_types import mcp.types as mcp_types
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import Context, FastMCP
from pydantic import AnyUrl
from pydantic import Field from pydantic import Field
@@ -36,6 +37,19 @@ async def resource_link_tool() -> list[mcp_types.ResourceLink]:
] ]
@server.tool(title="Emit notifications tool")
async def emit_notifications_tool(ctx: Context[Any, Any, Any]) -> dict[str, bool]:
"""Emit protocol notifications so proxy relay behavior can be tested."""
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}
@server.resource( @server.resource(
"fixture://docs/welcome", "fixture://docs/welcome",
name="resource.welcome", name="resource.welcome",
@@ -0,0 +1,79 @@
from __future__ import annotations
import asyncio
import sys
import mcp.types as mcp_types
import pytest
from mcp.client.session import ClientSession
from mcp.client.stdio import StdioServerParameters, stdio_client
from wf_mcp.models import BrokerConfig, ConnectionConfig
from wf_mcp.transparent_proxy import create_transparent_proxy_client
from .test_support import fixture_server_path, local_temp_root
def test_fixture_server_initialize_capabilities_are_observable_directly() -> None:
async def inspect_capabilities() -> mcp_types.ServerCapabilities:
params = StdioServerParameters(
command=sys.executable,
args=[fixture_server_path()],
)
async with stdio_client(params) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session:
result = await session.initialize()
return result.capabilities
try:
capabilities = asyncio.run(inspect_capabilities())
except PermissionError as exc:
pytest.skip(f"stdio MCP transport is not permitted in this environment: {exc}")
assert capabilities.tools is not None
assert capabilities.tools.listChanged is False
assert capabilities.resources is not None
assert capabilities.resources.subscribe is False
assert capabilities.resources.listChanged is False
assert capabilities.prompts is not None
assert capabilities.prompts.listChanged is False
assert capabilities.logging is None
def test_unified_proxy_initialize_capabilities_reflect_local_surface() -> None:
config = BrokerConfig(
store_root=local_temp_root() / "protocol_capabilities_store",
connections=[
ConnectionConfig(
id="fixture.personal",
server="fixture",
account="personal",
metadata={
"transport": "stdio",
"command": sys.executable,
"args": [fixture_server_path()],
},
)
],
)
async def inspect_capabilities() -> mcp_types.ServerCapabilities:
client = create_transparent_proxy_client(config)
async with client:
initialize_result = client.initialize_result
assert initialize_result is not None
return initialize_result.capabilities
try:
capabilities = asyncio.run(inspect_capabilities())
except PermissionError as exc:
pytest.skip(f"stdio MCP transport is not permitted in this environment: {exc}")
assert capabilities.tools is not None
assert capabilities.tools.listChanged is True
assert capabilities.resources is not None
assert capabilities.resources.subscribe is False
assert capabilities.resources.listChanged is True
assert capabilities.prompts is not None
assert capabilities.prompts.listChanged is True
assert capabilities.logging is not None
+107
View File
@@ -0,0 +1,107 @@
from __future__ import annotations
import asyncio
import sys
from collections.abc import Awaitable, Callable
import mcp.types as mcp_types
import pytest
from mcp.client.session import ClientSession
from mcp.client.stdio import StdioServerParameters, stdio_client
from wf_mcp.models import BrokerConfig, ConnectionConfig
from wf_mcp.transparent_proxy import create_transparent_proxy_client
from .test_support import fixture_server_path, local_temp_root
NotificationProbe = Callable[
[Callable[[mcp_types.ServerNotification], None]],
Awaitable[None],
]
def _notification_methods(
notifications: list[mcp_types.ServerNotification],
) -> list[str]:
return [notification.root.method for notification in notifications]
async def _capture_notifications(
probe: NotificationProbe,
) -> list[mcp_types.ServerNotification]:
notifications: list[mcp_types.ServerNotification] = []
def record(notification: mcp_types.ServerNotification) -> None:
notifications.append(notification)
await probe(record)
return notifications
def test_fixture_server_emits_observable_protocol_notifications_directly() -> None:
async def probe(record: Callable[[mcp_types.ServerNotification], None]) -> None:
async def message_handler(message: object) -> None:
if isinstance(message, mcp_types.ServerNotification):
record(message)
params = StdioServerParameters(
command=sys.executable,
args=[fixture_server_path()],
)
async with stdio_client(params) as (read_stream, write_stream):
async with ClientSession(
read_stream,
write_stream,
message_handler=message_handler,
) as session:
await session.initialize()
await session.call_tool("emit_notifications_tool")
try:
notifications = asyncio.run(_capture_notifications(probe))
except PermissionError as exc:
pytest.skip(f"stdio MCP transport is not permitted in this environment: {exc}")
methods = _notification_methods(notifications)
assert "notifications/tools/list_changed" in methods
assert "notifications/resources/list_changed" in methods
assert "notifications/prompts/list_changed" in methods
assert "notifications/resources/updated" in methods
assert "notifications/message" in methods
def test_transparent_proxy_does_not_relay_upstream_protocol_notifications_yet() -> None:
config = BrokerConfig(
store_root=local_temp_root() / "protocol_relay_store",
connections=[
ConnectionConfig(
id="fixture.personal",
server="fixture",
account="personal",
metadata={
"transport": "stdio",
"command": sys.executable,
"args": [fixture_server_path()],
},
)
],
)
async def probe(record: Callable[[mcp_types.ServerNotification], None]) -> None:
async def message_handler(message: object) -> None:
if isinstance(message, mcp_types.ServerNotification):
record(message)
client = create_transparent_proxy_client(config)
client._session_kwargs["message_handler"] = message_handler
async with client:
await client.call_tool("fixture.personal.emit_notifications_tool")
try:
notifications = asyncio.run(_capture_notifications(probe))
except PermissionError as exc:
pytest.skip(f"stdio MCP transport is not permitted in this environment: {exc}")
methods = _notification_methods(notifications)
assert methods == []
+6 -7
View File
@@ -83,16 +83,15 @@ def test_transparent_proxy_lists_and_calls_upstream_tools() -> None:
proxy_tools_payload = _structured(proxy_tools_result) proxy_tools_payload = _structured(proxy_tools_result)
proxy_tools = proxy_tools_payload["tools"] proxy_tools = proxy_tools_payload["tools"]
assert proxy_tools_payload["nextCursor"] is None assert proxy_tools_payload["nextCursor"] is None
assert proxy_tools_payload["total"] == 2 assert proxy_tools_payload["total"] == 3
assert len(proxy_tools) == 2 assert len(proxy_tools) == 3
assert proxy_tools[0]["proxy_name"] == "fixture.personal.echo_tool" assert proxy_tools[0]["proxy_name"] == "fixture.personal.echo_tool"
assert proxy_tools[0]["connection_id"] == "fixture.personal" assert proxy_tools[0]["connection_id"] == "fixture.personal"
assert proxy_tools[0]["local_name"] == "echo_tool" assert proxy_tools[0]["local_name"] == "echo_tool"
assert proxy_tools[0]["enabled"] is True assert proxy_tools[0]["enabled"] is True
assert ( proxy_names = [tool["proxy_name"] for tool in proxy_tools]
proxy_tools[1]["proxy_name"] assert "fixture.personal.emit_notifications_tool" in proxy_names
== "fixture.personal.resource_link_tool" assert "fixture.personal.resource_link_tool" in proxy_names
)
proxy_tool_result = await client.call_tool( proxy_tool_result = await client.call_tool(
"wf.admin.get_proxy_tool", "wf.admin.get_proxy_tool",
@@ -302,7 +301,7 @@ def test_transparent_proxy_proxy_tool_listing_supports_filters_and_cursor() -> N
first_page = _structured(first_page_result) first_page = _structured(first_page_result)
assert len(first_page["tools"]) == 1 assert len(first_page["tools"]) == 1
assert first_page["nextCursor"] is not None assert first_page["nextCursor"] is not None
assert first_page["total"] == 4 assert first_page["total"] == 6
second_page_result = await client.call_tool( second_page_result = await client.call_tool(
"wf.admin.list_proxy_tools", "wf.admin.list_proxy_tools",