document current behavior of notif and capabilities
This commit is contained in:
@@ -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?
|
||||||
@@ -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) :]}"
|
||||||
|
|||||||
Vendored
+16
-2
@@ -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
|
||||||
@@ -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 == []
|
||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user