Serve subscriptions/listen with a pluggable event bus (SEP-2575) (#3035)
This commit is contained in:
parent
48ef569f7e
commit
ca10dade2c
7 changed files with 1722 additions and 66 deletions
|
|
@ -121,11 +121,12 @@ async def test_read_resource_fills_in_a_template() -> None:
|
|||
assert contents.text == "3 books filed under poetry."
|
||||
|
||||
|
||||
async def test_mcpserver_does_not_implement_resource_subscriptions() -> None:
|
||||
"""The Resources section: MCPServer advertises subscribe=False and rejects subscribe_resource with -32601."""
|
||||
async def test_resource_subscriptions_are_listen_based_on_the_modern_wire() -> None:
|
||||
"""The Resources section: at 2026-07-28 `resources.subscribe` is True (served via
|
||||
subscriptions/listen) while the legacy subscribe_resource verb answers -32601."""
|
||||
async with Client(tutorial004.mcp) as client:
|
||||
assert client.server_capabilities.resources is not None
|
||||
assert client.server_capabilities.resources.subscribe is False
|
||||
assert client.server_capabilities.resources.subscribe is True
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.subscribe_resource("catalog://genres")
|
||||
assert exc_info.value.error.code == -32601
|
||||
|
|
|
|||
|
|
@ -89,9 +89,9 @@ async def test_the_three_primitive_capabilities_are_always_declared() -> None:
|
|||
# The exact dictionary the page prints from `model_dump(exclude_none=True)`.
|
||||
assert declared.model_dump(exclude_none=True) == snapshot(
|
||||
{
|
||||
"prompts": {"list_changed": False},
|
||||
"resources": {"subscribe": False, "list_changed": False},
|
||||
"tools": {"list_changed": False},
|
||||
"prompts": {"list_changed": True},
|
||||
"resources": {"subscribe": True, "list_changed": True},
|
||||
"tools": {"list_changed": True},
|
||||
}
|
||||
)
|
||||
async with Client(MCPServer("Empty")) as client:
|
||||
|
|
|
|||
138
tests/docs_src/test_subscriptions.py
Normal file
138
tests/docs_src/test_subscriptions.py
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
"""`docs/advanced/subscriptions.md`: every claim the page makes, proved against the real SDK."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import anyio
|
||||
import mcp_types as types
|
||||
import pytest
|
||||
|
||||
from docs_src.subscriptions import tutorial001, tutorial002
|
||||
from mcp import Client
|
||||
from mcp.server.subscriptions import SUBSCRIPTION_ID_META_KEY, ToolsListChanged
|
||||
|
||||
# See test_index.py for why this is a per-module mark and not a conftest hook.
|
||||
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
|
||||
|
||||
|
||||
class _Stream:
|
||||
"""Collects listen-stream notifications and lets tests await arrival counts."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.received: list[types.ServerNotification] = []
|
||||
self._arrival = anyio.Event()
|
||||
|
||||
async def handler(
|
||||
self,
|
||||
message: object,
|
||||
) -> None:
|
||||
# The only messages these connections produce are the stream's frames.
|
||||
assert isinstance(
|
||||
message,
|
||||
types.SubscriptionsAcknowledgedNotification
|
||||
| types.ResourceUpdatedNotification
|
||||
| types.ToolListChangedNotification,
|
||||
), message
|
||||
self.received.append(message)
|
||||
self._arrival.set()
|
||||
self._arrival = anyio.Event()
|
||||
|
||||
async def wait_for(self, count: int) -> None:
|
||||
with anyio.fail_after(5):
|
||||
while len(self.received) < count:
|
||||
await self._arrival.wait()
|
||||
|
||||
|
||||
def _listen_request(**fields: Any) -> types.SubscriptionsListenRequest:
|
||||
return types.SubscriptionsListenRequest(
|
||||
params=types.SubscriptionsListenRequestParams(notifications=types.SubscriptionFilter(**fields))
|
||||
)
|
||||
|
||||
|
||||
async def test_publishes_reach_the_stream_filtered_and_tagged() -> None:
|
||||
"""tutorial001: the full arc - ack first, exact-URI filtering, list_changed
|
||||
leading to a refreshed tool list, and client-side close."""
|
||||
stream = _Stream()
|
||||
async with Client(tutorial001.mcp, mode="2026-07-28", message_handler=stream.handler) as client:
|
||||
async with anyio.create_task_group() as tg:
|
||||
|
||||
async def listen() -> None:
|
||||
await client.session.send_request(
|
||||
_listen_request(tools_list_changed=True, resource_subscriptions=["note://todo"]),
|
||||
types.SubscriptionsListenResult,
|
||||
)
|
||||
|
||||
tg.start_soon(listen)
|
||||
await stream.wait_for(1)
|
||||
|
||||
ack = stream.received[0]
|
||||
assert isinstance(ack, types.SubscriptionsAcknowledgedNotification)
|
||||
assert ack.params.notifications == types.SubscriptionFilter(
|
||||
tools_list_changed=True, resource_subscriptions=["note://todo"]
|
||||
)
|
||||
assert ack.params.meta is not None and SUBSCRIPTION_ID_META_KEY in ack.params.meta
|
||||
|
||||
# An edit to a URI the stream did not subscribe to stays silent...
|
||||
await client.call_tool("edit_note", {"name": "journal", "text": "day two"})
|
||||
# ...and the subscribed URI delivers, tagged with the same subscription id.
|
||||
await client.call_tool("edit_note", {"name": "todo", "text": "water plants"})
|
||||
await stream.wait_for(2)
|
||||
updated = stream.received[1]
|
||||
assert isinstance(updated, types.ResourceUpdatedNotification)
|
||||
assert updated.params.uri == "note://todo"
|
||||
assert updated.params.meta == ack.params.meta
|
||||
|
||||
await client.call_tool("enable_search", {})
|
||||
await stream.wait_for(3)
|
||||
assert isinstance(stream.received[2], types.ToolListChangedNotification)
|
||||
|
||||
# The client ends the stream by closing it - cancel the parked request.
|
||||
tg.cancel_scope.cancel()
|
||||
|
||||
# The list_changed told us to re-fetch: the new tool is there, and the
|
||||
# session outlives the closed stream.
|
||||
tools = await client.list_tools()
|
||||
assert "search" in {tool.name for tool in tools.tools}
|
||||
contents = (await client.read_resource("note://todo")).contents[0]
|
||||
assert isinstance(contents, types.TextResourceContents)
|
||||
assert contents.text == "water plants"
|
||||
|
||||
|
||||
async def test_publish_with_no_subscribers_is_a_no_op() -> None:
|
||||
"""tutorial001: publishing to an idle server does nothing and breaks nothing."""
|
||||
async with Client(tutorial001.mcp, mode="2026-07-28") as client:
|
||||
result = await client.call_tool("edit_note", {"name": "todo", "text": "buy milk"})
|
||||
assert result.is_error is not True
|
||||
|
||||
|
||||
async def test_lowlevel_composition_serves_the_same_stream() -> None:
|
||||
"""tutorial002: bus + ListenHandler on the lowlevel Server is the same machinery."""
|
||||
stream = _Stream()
|
||||
async with Client(tutorial002.server, mode="2026-07-28", message_handler=stream.handler) as client:
|
||||
tools = await client.list_tools()
|
||||
assert [tool.name for tool in tools.tools] == ["edit_note"]
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
|
||||
async def listen() -> None:
|
||||
await client.session.send_request(
|
||||
_listen_request(resource_subscriptions=["note://todo"]),
|
||||
types.SubscriptionsListenResult,
|
||||
)
|
||||
|
||||
tg.start_soon(listen)
|
||||
await stream.wait_for(1)
|
||||
|
||||
await client.call_tool("edit_note", {"name": "todo", "text": "water plants"})
|
||||
await stream.wait_for(2)
|
||||
updated = stream.received[1]
|
||||
assert isinstance(updated, types.ResourceUpdatedNotification)
|
||||
assert updated.params.uri == "note://todo"
|
||||
|
||||
# The bus you constructed is also the publish surface outside a
|
||||
# request; an unrequested kind never reaches this stream.
|
||||
await tutorial002.bus.publish(ToolsListChanged())
|
||||
await client.call_tool("edit_note", {"name": "todo", "text": "done"})
|
||||
await stream.wait_for(3)
|
||||
assert isinstance(stream.received[2], types.ResourceUpdatedNotification)
|
||||
|
||||
tg.cancel_scope.cancel()
|
||||
|
|
@ -12,18 +12,26 @@ import mcp_types as types
|
|||
import pytest
|
||||
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
|
||||
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
|
||||
# `Server._handle_discover` ignores its `ctx` argument entirely (it derives the
|
||||
# result from server state), so a sentinel keeps the call site type-correct
|
||||
# without dragging session machinery into a unit test.
|
||||
_UNUSED_CTX = cast("ServerRequestContext[Any]", None)
|
||||
from mcp.server import NotificationOptions, Server, ServerRequestContext
|
||||
|
||||
|
||||
async def _discover(server: Server[Any]) -> types.DiscoverResult:
|
||||
# `Server._handle_discover` reads only `ctx.protocol_version` (capabilities are
|
||||
# era-dependent), so a minimal context keeps the call site honest without
|
||||
# dragging session machinery into a unit test.
|
||||
def _ctx(protocol_version: str) -> ServerRequestContext[Any]:
|
||||
return ServerRequestContext(
|
||||
session=cast("Any", None),
|
||||
lifespan_context={},
|
||||
protocol_version=protocol_version,
|
||||
method="server/discover",
|
||||
request_id=1,
|
||||
)
|
||||
|
||||
|
||||
async def _discover(server: Server[Any], protocol_version: str = MODERN_PROTOCOL_VERSIONS[0]) -> types.DiscoverResult:
|
||||
entry = server.get_request_handler("server/discover")
|
||||
assert entry is not None
|
||||
result = await entry.handler(_UNUSED_CTX, types.RequestParams())
|
||||
result = await entry.handler(_ctx(protocol_version), types.RequestParams())
|
||||
assert isinstance(result, types.DiscoverResult)
|
||||
return result
|
||||
|
||||
|
|
@ -149,3 +157,64 @@ async def test_overridable_via_add_request_handler() -> None:
|
|||
server.add_request_handler("server/discover", types.RequestParams, custom_discover)
|
||||
result = await _discover(server)
|
||||
assert result is custom
|
||||
|
||||
|
||||
async def _listen_stub(
|
||||
ctx: ServerRequestContext[Any], params: types.SubscriptionsListenRequestParams
|
||||
) -> types.SubscriptionsListenResult:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_modern_subscription_bits_derive_from_listen_serving() -> None:
|
||||
"""Spec-driven (SEP-2575): at 2026-07-28, change notifications exist only on
|
||||
`subscriptions/listen` streams, so the `listChanged`/`subscribe` bits mean
|
||||
"this server serves listen" - they flip together with the handler."""
|
||||
|
||||
async def list_tools(
|
||||
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
|
||||
) -> types.ListToolsResult:
|
||||
raise NotImplementedError
|
||||
|
||||
async def list_resources(
|
||||
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
|
||||
) -> types.ListResourcesResult:
|
||||
raise NotImplementedError
|
||||
|
||||
server = Server("caps", on_list_tools=list_tools, on_list_resources=list_resources)
|
||||
|
||||
before = await _discover(server)
|
||||
assert before.capabilities.tools is not None and before.capabilities.tools.list_changed is False
|
||||
assert before.capabilities.resources is not None
|
||||
assert before.capabilities.resources.subscribe is False
|
||||
assert before.capabilities.resources.list_changed is False
|
||||
|
||||
server.add_request_handler("subscriptions/listen", types.SubscriptionsListenRequestParams, _listen_stub)
|
||||
|
||||
after = await _discover(server)
|
||||
assert after.capabilities.tools is not None and after.capabilities.tools.list_changed is True
|
||||
assert after.capabilities.resources is not None
|
||||
assert after.capabilities.resources.subscribe is True
|
||||
assert after.capabilities.resources.list_changed is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_legacy_capability_derivation_ignores_listen() -> None:
|
||||
"""SDK-defined: without `protocol_version`, `get_capabilities` keeps the
|
||||
handshake-era derivation - `NotificationOptions` drives `listChanged` and the
|
||||
`resources/subscribe` handler drives `subscribe`; a registered listen handler
|
||||
changes nothing on that path."""
|
||||
|
||||
async def list_tools(
|
||||
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
|
||||
) -> types.ListToolsResult:
|
||||
raise NotImplementedError
|
||||
|
||||
server = Server("caps", on_list_tools=list_tools)
|
||||
server.add_request_handler("subscriptions/listen", types.SubscriptionsListenRequestParams, _listen_stub)
|
||||
|
||||
legacy = server.get_capabilities()
|
||||
assert legacy.tools is not None and legacy.tools.list_changed is False
|
||||
|
||||
opted_in = server.get_capabilities(NotificationOptions(tools_changed=True))
|
||||
assert opted_in.tools is not None and opted_in.tools.list_changed is True
|
||||
|
|
|
|||
|
|
@ -51,6 +51,14 @@ from mcp.server.mcpserver.exceptions import ResourceNotFoundError, ToolError
|
|||
from mcp.server.mcpserver.prompts.base import Message, UserMessage
|
||||
from mcp.server.mcpserver.resources import FileResource, FunctionResource
|
||||
from mcp.server.mcpserver.utilities.types import Audio, Image
|
||||
from mcp.server.subscriptions import (
|
||||
InMemorySubscriptionBus,
|
||||
PromptsListChanged,
|
||||
ResourcesListChanged,
|
||||
ResourceUpdated,
|
||||
ServerEvent,
|
||||
ToolsListChanged,
|
||||
)
|
||||
from mcp.server.transport_security import TransportSecuritySettings
|
||||
from mcp.shared.exceptions import MCPError
|
||||
from mcp.shared.uri_template import InvalidUriTemplate
|
||||
|
|
@ -2248,3 +2256,84 @@ async def test_context_input_responses_and_request_state_are_none_on_initial_rou
|
|||
await client.call_tool("probe")
|
||||
|
||||
assert captured == {"responses": None, "state": None}
|
||||
|
||||
|
||||
async def test_context_notify_methods_publish_to_the_configured_bus() -> None:
|
||||
bus = InMemorySubscriptionBus()
|
||||
mcp = MCPServer(subscriptions=bus)
|
||||
seen: list[ServerEvent] = []
|
||||
bus.subscribe(seen.append)
|
||||
|
||||
@mcp.tool()
|
||||
async def touch(ctx: Context) -> str:
|
||||
await ctx.notify_tools_changed()
|
||||
await ctx.notify_prompts_changed()
|
||||
await ctx.notify_resources_changed()
|
||||
await ctx.notify_resource_updated("r://x")
|
||||
return "ok"
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with Client(mcp) as client:
|
||||
await client.call_tool("touch")
|
||||
|
||||
assert seen == [ToolsListChanged(), PromptsListChanged(), ResourcesListChanged(), ResourceUpdated(uri="r://x")]
|
||||
|
||||
|
||||
async def test_programmatic_entry_points_carry_the_subscription_bus() -> None:
|
||||
"""`ctx.notify_*` works when tools, resources, and prompts are invoked
|
||||
programmatically (no wire request): the server-scoped bus rides along in
|
||||
the fallback Context."""
|
||||
bus = InMemorySubscriptionBus()
|
||||
mcp = MCPServer(subscriptions=bus)
|
||||
seen: list[ServerEvent] = []
|
||||
bus.subscribe(seen.append)
|
||||
|
||||
@mcp.tool()
|
||||
async def touch_tools(ctx: Context) -> str:
|
||||
await ctx.notify_tools_changed()
|
||||
return "ok"
|
||||
|
||||
@mcp.resource("res://{name}")
|
||||
async def thing(name: str, ctx: Context) -> str:
|
||||
await ctx.notify_resources_changed()
|
||||
return "data"
|
||||
|
||||
@mcp.prompt()
|
||||
async def ask(ctx: Context) -> str:
|
||||
await ctx.notify_prompts_changed()
|
||||
return "question"
|
||||
|
||||
await mcp.call_tool("touch_tools", {})
|
||||
await mcp.read_resource("res://thing")
|
||||
await mcp.get_prompt("ask")
|
||||
|
||||
assert seen == [ToolsListChanged(), ResourcesListChanged(), PromptsListChanged()]
|
||||
|
||||
|
||||
def test_context_mcp_server_outside_request_raises() -> None:
|
||||
with pytest.raises(ValueError, match="outside of a request"):
|
||||
_ = Context().mcp_server
|
||||
|
||||
|
||||
async def test_context_notify_outside_a_request_raises() -> None:
|
||||
with pytest.raises(ValueError, match="outside of a request"):
|
||||
await Context().notify_tools_changed()
|
||||
|
||||
|
||||
def test_context_exposes_its_mcp_server() -> None:
|
||||
mcp = MCPServer()
|
||||
assert Context(mcp_server=mcp).mcp_server is mcp
|
||||
|
||||
|
||||
def test_remove_prompt_removes_and_unknown_name_raises() -> None:
|
||||
mcp = MCPServer()
|
||||
|
||||
@mcp.prompt()
|
||||
def greeting() -> str: # pragma: no cover
|
||||
return "hello"
|
||||
|
||||
assert len(mcp._prompt_manager.list_prompts()) == 1
|
||||
mcp.remove_prompt("greeting")
|
||||
assert mcp._prompt_manager.list_prompts() == []
|
||||
with pytest.raises(ValueError, match="Unknown prompt: greeting"):
|
||||
mcp.remove_prompt("greeting")
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ the closed back-channel on the dispatch context, and the request-validation ladd
|
|||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import anyio
|
||||
|
|
@ -44,6 +45,7 @@ from mcp.server._streamable_http_modern import (
|
|||
_to_jsonrpc_response,
|
||||
handle_modern_request,
|
||||
)
|
||||
from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler, ServerEvent
|
||||
from mcp.server.transport_security import TransportSecuritySettings
|
||||
from mcp.shared.exceptions import MCPError, NoBackChannelError
|
||||
from mcp.shared.inbound import MCP_METHOD_HEADER, MCP_NAME_HEADER, MCP_PROTOCOL_VERSION_HEADER
|
||||
|
|
@ -1008,3 +1010,75 @@ async def test_modern_post_with_deeply_nested_body_is_parse_error_not_a_crash()
|
|||
response = await http.post("/mcp", content=body, headers={"content-type": "application/json"})
|
||||
assert response.status_code == 400
|
||||
assert response.json()["error"]["code"] == PARSE_ERROR
|
||||
|
||||
|
||||
class _OpenSignalBus(InMemorySubscriptionBus):
|
||||
"""Sets an event when a listen stream subscribes, so tests can sequence close()."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.opened = anyio.Event()
|
||||
|
||||
def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]:
|
||||
unsubscribe = super().subscribe(listener)
|
||||
self.opened.set()
|
||||
return unsubscribe
|
||||
|
||||
|
||||
def _listen_body() -> dict[str, Any]:
|
||||
"""A minimal valid 2026-07-28 `subscriptions/listen` request body."""
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 9,
|
||||
"method": "subscriptions/listen",
|
||||
"params": {
|
||||
"notifications": {"toolsListChanged": True},
|
||||
"_meta": {
|
||||
PROTOCOL_VERSION_META_KEY: LATEST_MODERN_VERSION,
|
||||
CLIENT_INFO_META_KEY: {"name": "raw", "version": "0.0.0"},
|
||||
CLIENT_CAPABILITIES_META_KEY: {},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def test_subscriptions_listen_requires_the_sse_accept_even_in_json_mode() -> None:
|
||||
"""SDK-defined: a listen response is always SSE, so a request whose Accept
|
||||
lacks `text/event-stream` is rejected with 406 rather than served a content
|
||||
type it never accepted - JSON-response mode included."""
|
||||
server = Server("test", on_subscriptions_listen=ListenHandler(InMemorySubscriptionBus()))
|
||||
async with _asgi_client(server, json_response=True, accept="application/json") as http:
|
||||
response = await http.post("/mcp", json=_listen_body(), headers={MCP_METHOD_HEADER: "subscriptions/listen"})
|
||||
assert response.status_code == 406
|
||||
|
||||
|
||||
async def test_json_response_mode_still_streams_subscriptions_listen() -> None:
|
||||
"""SDK-defined (TypeScript/Go parity): a listen response IS a notification
|
||||
stream, so `json_response=True` does not apply to it - the request takes
|
||||
the SSE path, acks first, and ends with the stamped result on close()."""
|
||||
bus = _OpenSignalBus()
|
||||
handler = ListenHandler(bus)
|
||||
server = Server("test", on_subscriptions_listen=handler)
|
||||
body = _listen_body()
|
||||
|
||||
responses: list[httpx.Response] = []
|
||||
async with _asgi_client(server, json_response=True) as http:
|
||||
async with anyio.create_task_group() as tg:
|
||||
|
||||
async def post() -> None:
|
||||
responses.append(
|
||||
await http.post("/mcp", json=body, headers={MCP_METHOD_HEADER: "subscriptions/listen"})
|
||||
)
|
||||
|
||||
tg.start_soon(post)
|
||||
with anyio.fail_after(5):
|
||||
await bus.opened.wait()
|
||||
handler.close()
|
||||
|
||||
response = responses[0]
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"].split(";", 1)[0] == "text/event-stream"
|
||||
events = _sse_payloads(response.text)
|
||||
assert events[0]["method"] == "notifications/subscriptions/acknowledged"
|
||||
assert events[1]["id"] == 9
|
||||
assert events[1]["result"]["_meta"] == {"io.modelcontextprotocol/subscriptionId": 9}
|
||||
|
|
|
|||
473
tests/server/test_subscriptions.py
Normal file
473
tests/server/test_subscriptions.py
Normal file
|
|
@ -0,0 +1,473 @@
|
|||
"""Tests for `subscriptions/listen` serving (mcp.server.subscriptions)."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any, cast
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
from mcp_types import (
|
||||
INVALID_REQUEST,
|
||||
PromptListChangedNotification,
|
||||
RequestId,
|
||||
ResourceListChangedNotification,
|
||||
ResourceUpdatedNotification,
|
||||
ServerNotification,
|
||||
SubscriptionFilter,
|
||||
SubscriptionsAcknowledgedNotification,
|
||||
SubscriptionsListenRequestParams,
|
||||
SubscriptionsListenResult,
|
||||
ToolListChangedNotification,
|
||||
)
|
||||
|
||||
from mcp.server.context import ServerRequestContext
|
||||
from mcp.server.session import ServerSession
|
||||
from mcp.server.subscriptions import (
|
||||
SUBSCRIPTION_ID_META_KEY,
|
||||
InMemorySubscriptionBus,
|
||||
ListenHandler,
|
||||
PromptsListChanged,
|
||||
ResourcesListChanged,
|
||||
ResourceUpdated,
|
||||
ServerEvent,
|
||||
ToolsListChanged,
|
||||
)
|
||||
from mcp.shared.exceptions import MCPError
|
||||
|
||||
|
||||
class _RecordingSession:
|
||||
"""Stands in for `ServerSession`: records sent notifications and wakes waiters."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.sent: list[tuple[ServerNotification, RequestId | None]] = []
|
||||
self._arrival = anyio.Event()
|
||||
|
||||
async def send_notification(
|
||||
self, notification: ServerNotification, related_request_id: RequestId | None = None
|
||||
) -> None:
|
||||
self.sent.append((notification, related_request_id))
|
||||
self._arrival.set()
|
||||
self._arrival = anyio.Event()
|
||||
|
||||
async def wait_for(self, count: int) -> None:
|
||||
with anyio.fail_after(5):
|
||||
while len(self.sent) < count:
|
||||
await self._arrival.wait()
|
||||
|
||||
|
||||
def _ctx(session: _RecordingSession, request_id: RequestId | None = 7) -> ServerRequestContext[Any, Any]:
|
||||
return ServerRequestContext(
|
||||
session=cast(ServerSession, session),
|
||||
lifespan_context={},
|
||||
protocol_version="2026-07-28",
|
||||
method="subscriptions/listen",
|
||||
request_id=request_id,
|
||||
)
|
||||
|
||||
|
||||
def _params(**fields: Any) -> SubscriptionsListenRequestParams:
|
||||
return SubscriptionsListenRequestParams(notifications=SubscriptionFilter(**fields))
|
||||
|
||||
|
||||
class _SpyBus(InMemorySubscriptionBus):
|
||||
"""Counts unsubscribe calls so tests can assert stream cleanup."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.unsubscribed = 0
|
||||
|
||||
def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]:
|
||||
unsubscribe = super().subscribe(listener)
|
||||
|
||||
def counting_unsubscribe() -> None:
|
||||
self.unsubscribed += 1
|
||||
unsubscribe()
|
||||
|
||||
return counting_unsubscribe
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_in_memory_bus_fans_out_until_unsubscribed() -> None:
|
||||
"""SDK-defined bus contract: fan-out to all listeners; unsubscribe is idempotent."""
|
||||
bus = InMemorySubscriptionBus()
|
||||
seen_a: list[ServerEvent] = []
|
||||
seen_b: list[ServerEvent] = []
|
||||
unsubscribe_a = bus.subscribe(seen_a.append)
|
||||
bus.subscribe(seen_b.append)
|
||||
|
||||
await bus.publish(ToolsListChanged())
|
||||
assert seen_a == [ToolsListChanged()]
|
||||
assert seen_b == [ToolsListChanged()]
|
||||
|
||||
unsubscribe_a()
|
||||
unsubscribe_a() # idempotent
|
||||
await bus.publish(PromptsListChanged())
|
||||
assert seen_a == [ToolsListChanged()]
|
||||
assert seen_b == [ToolsListChanged(), PromptsListChanged()]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_in_memory_bus_keeps_equal_callables_distinct() -> None:
|
||||
"""SDK-defined: registering the same callable twice yields two registrations,
|
||||
and each unsubscribe detaches exactly one (bound methods compare equal)."""
|
||||
bus = InMemorySubscriptionBus()
|
||||
seen: list[ServerEvent] = []
|
||||
first = bus.subscribe(seen.append)
|
||||
bus.subscribe(seen.append)
|
||||
|
||||
await bus.publish(ToolsListChanged())
|
||||
assert len(seen) == 2
|
||||
|
||||
first()
|
||||
await bus.publish(ToolsListChanged())
|
||||
assert len(seen) == 3
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_ack_first_honored_subset_and_stamped_graceful_result() -> None:
|
||||
"""Spec-mandated: the ack is the first frame, echoes the honored subset, and
|
||||
every frame (graceful result included) carries the subscription-id tag."""
|
||||
bus = _SpyBus()
|
||||
handler = ListenHandler(bus)
|
||||
session = _RecordingSession()
|
||||
results: list[SubscriptionsListenResult] = []
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
|
||||
async def run() -> None:
|
||||
results.append(
|
||||
await handler(
|
||||
_ctx(session),
|
||||
_params(tools_list_changed=True, prompts_list_changed=False, resource_subscriptions=["r://a"]),
|
||||
)
|
||||
)
|
||||
|
||||
tg.start_soon(run)
|
||||
await session.wait_for(1)
|
||||
|
||||
ack, related = session.sent[0]
|
||||
assert isinstance(ack, SubscriptionsAcknowledgedNotification)
|
||||
assert related == 7
|
||||
# Honored subset: requested-false and absent kinds are omitted, not echoed.
|
||||
assert ack.params.notifications == SubscriptionFilter(tools_list_changed=True, resource_subscriptions=["r://a"])
|
||||
assert ack.params.meta == {SUBSCRIPTION_ID_META_KEY: 7}
|
||||
|
||||
await bus.publish(ToolsListChanged())
|
||||
await session.wait_for(2)
|
||||
event, related = session.sent[1]
|
||||
assert isinstance(event, ToolListChangedNotification)
|
||||
assert related == 7
|
||||
assert event.params is not None and event.params.meta == {SUBSCRIPTION_ID_META_KEY: 7}
|
||||
|
||||
handler.close()
|
||||
|
||||
assert results[0].meta == {SUBSCRIPTION_ID_META_KEY: 7}
|
||||
assert bus.unsubscribed == 1 # the stream unsubscribed on the way out
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_only_requested_event_kinds_are_delivered() -> None:
|
||||
"""Spec-mandated: the server never sends a notification type (or resource URI)
|
||||
the client did not request on this stream."""
|
||||
bus = InMemorySubscriptionBus()
|
||||
handler = ListenHandler(bus)
|
||||
session = _RecordingSession()
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
|
||||
async def run() -> None:
|
||||
await handler(
|
||||
_ctx(session),
|
||||
_params(prompts_list_changed=True, resources_list_changed=True, resource_subscriptions=["r://a"]),
|
||||
)
|
||||
|
||||
tg.start_soon(run)
|
||||
await session.wait_for(1)
|
||||
|
||||
await bus.publish(ToolsListChanged()) # not requested
|
||||
await bus.publish(ResourceUpdated(uri="r://other")) # URI not subscribed
|
||||
await bus.publish(PromptsListChanged())
|
||||
await bus.publish(ResourcesListChanged())
|
||||
await bus.publish(ResourceUpdated(uri="r://a"))
|
||||
await session.wait_for(4)
|
||||
handler.close()
|
||||
|
||||
delivered = [notification for notification, _ in session.sent[1:]]
|
||||
assert isinstance(delivered[0], PromptListChangedNotification)
|
||||
assert isinstance(delivered[1], ResourceListChangedNotification)
|
||||
assert isinstance(delivered[2], ResourceUpdatedNotification)
|
||||
assert delivered[2].params.uri == "r://a"
|
||||
assert delivered[2].params.meta == {SUBSCRIPTION_ID_META_KEY: 7}
|
||||
assert len(delivered) == 3
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_empty_filter_honors_nothing_and_delivers_nothing() -> None:
|
||||
"""SDK-defined: falsy flags and an empty URI list are dropped from the ack
|
||||
rather than echoed, and such a stream delivers nothing."""
|
||||
bus = InMemorySubscriptionBus()
|
||||
handler = ListenHandler(bus)
|
||||
session = _RecordingSession()
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
|
||||
async def run() -> None:
|
||||
await handler(_ctx(session), _params(tools_list_changed=False, resource_subscriptions=[]))
|
||||
|
||||
tg.start_soon(run)
|
||||
await session.wait_for(1)
|
||||
|
||||
ack, _ = session.sent[0]
|
||||
assert isinstance(ack, SubscriptionsAcknowledgedNotification)
|
||||
assert ack.params.notifications == SubscriptionFilter()
|
||||
|
||||
for event in (ToolsListChanged(), PromptsListChanged(), ResourcesListChanged(), ResourceUpdated(uri="r://a")):
|
||||
await bus.publish(event)
|
||||
handler.close()
|
||||
|
||||
assert len(session.sent) == 1 # the ack only
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_publish_after_close_is_dropped() -> None:
|
||||
"""SDK-defined: an event racing `close()` while the stream unwinds is dropped."""
|
||||
bus = InMemorySubscriptionBus()
|
||||
handler = ListenHandler(bus)
|
||||
session = _RecordingSession()
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
|
||||
async def run() -> None:
|
||||
await handler(_ctx(session), _params(tools_list_changed=True))
|
||||
|
||||
tg.start_soon(run)
|
||||
await session.wait_for(1)
|
||||
|
||||
handler.close()
|
||||
# The handler task has not resumed yet, so the listener is still
|
||||
# subscribed but its stream is closed: the event is dropped.
|
||||
await bus.publish(ToolsListChanged())
|
||||
|
||||
assert len(session.sent) == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_event_published_during_ack_send_is_delivered_after_the_ack() -> None:
|
||||
"""SDK-defined: the stream subscribes before sending the ack, so an event
|
||||
published while the ack write is suspended is buffered and delivered after
|
||||
it - never lost, and never ahead of the ack frame."""
|
||||
bus = InMemorySubscriptionBus()
|
||||
handler = ListenHandler(bus)
|
||||
|
||||
class _PublishDuringAck(_RecordingSession):
|
||||
async def send_notification(
|
||||
self, notification: ServerNotification, related_request_id: RequestId | None = None
|
||||
) -> None:
|
||||
if not self.sent:
|
||||
# Publish while the handler is still inside the ack send.
|
||||
await bus.publish(ToolsListChanged())
|
||||
await super().send_notification(notification, related_request_id)
|
||||
|
||||
session = _PublishDuringAck()
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
|
||||
async def run() -> None:
|
||||
await handler(_ctx(session), _params(tools_list_changed=True))
|
||||
|
||||
tg.start_soon(run)
|
||||
await session.wait_for(2)
|
||||
handler.close()
|
||||
|
||||
assert isinstance(session.sent[0][0], SubscriptionsAcknowledgedNotification)
|
||||
assert isinstance(session.sent[1][0], ToolListChangedNotification)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_listen_requires_a_request_id() -> None:
|
||||
"""SDK-defined: a context without a request id cannot open a stream."""
|
||||
handler = ListenHandler(InMemorySubscriptionBus())
|
||||
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await handler(_ctx(_RecordingSession(), request_id=None), _params())
|
||||
assert exc_info.value.error.code == INVALID_REQUEST
|
||||
|
||||
|
||||
def test_close_without_open_streams_is_a_no_op() -> None:
|
||||
"""SDK-defined: `close()` with nothing open does nothing."""
|
||||
ListenHandler(InMemorySubscriptionBus()).close()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_raising_listener_is_isolated_from_others() -> None:
|
||||
"""SDK-defined: one raising listener is logged and skipped; later listeners
|
||||
and the publishing handler are unaffected."""
|
||||
bus = InMemorySubscriptionBus()
|
||||
|
||||
def bad(event: ServerEvent) -> None:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
seen: list[ServerEvent] = []
|
||||
bus.subscribe(bad)
|
||||
bus.subscribe(seen.append)
|
||||
|
||||
await bus.publish(ToolsListChanged())
|
||||
assert seen == [ToolsListChanged()]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_raising_unsubscribe_does_not_skip_stream_cleanup() -> None:
|
||||
"""SDK-defined: a custom bus whose unsubscribe callable raises is logged
|
||||
and isolated - the stream still releases its subscription slot, closes its
|
||||
buffers, and returns the graceful result."""
|
||||
|
||||
class _RaisingUnsubscribeBus(InMemorySubscriptionBus):
|
||||
def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]:
|
||||
super().subscribe(listener)
|
||||
|
||||
def unsubscribe() -> None:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
return unsubscribe
|
||||
|
||||
handler = ListenHandler(_RaisingUnsubscribeBus(), max_subscriptions=1)
|
||||
session = _RecordingSession()
|
||||
results: list[SubscriptionsListenResult] = []
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
|
||||
async def run() -> None:
|
||||
results.append(await handler(_ctx(session), _params(tools_list_changed=True)))
|
||||
|
||||
tg.start_soon(run)
|
||||
await session.wait_for(1)
|
||||
handler.close()
|
||||
|
||||
assert results[0].meta == {SUBSCRIPTION_ID_META_KEY: 7} # the graceful result still returned
|
||||
|
||||
# The slot was released despite the raising unsubscribe: a second listen
|
||||
# is accepted at the cap of one.
|
||||
late_session = _RecordingSession()
|
||||
late_results: list[SubscriptionsListenResult] = []
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
|
||||
async def run_late() -> None:
|
||||
late_results.append(await handler(_ctx(late_session, request_id=8), _params(tools_list_changed=True)))
|
||||
|
||||
tg.start_soon(run_late)
|
||||
await late_session.wait_for(1)
|
||||
handler.close()
|
||||
|
||||
assert late_results[0].meta == {SUBSCRIPTION_ID_META_KEY: 8}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_subscription_limit_rejects_further_streams_pre_ack() -> None:
|
||||
"""SDK-defined cap (mirrors the TypeScript SDK): past `max_subscriptions`,
|
||||
a listen request is rejected before any ack frame."""
|
||||
handler = ListenHandler(InMemorySubscriptionBus(), max_subscriptions=1)
|
||||
session = _RecordingSession()
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
|
||||
async def run() -> None:
|
||||
await handler(_ctx(session), _params(tools_list_changed=True))
|
||||
|
||||
tg.start_soon(run)
|
||||
await session.wait_for(1)
|
||||
|
||||
rejected_session = _RecordingSession()
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await handler(_ctx(rejected_session, request_id=8), _params())
|
||||
assert exc_info.value.error.message == "Subscription limit reached"
|
||||
assert rejected_session.sent == []
|
||||
|
||||
handler.close()
|
||||
|
||||
|
||||
class _GatedSession(_RecordingSession):
|
||||
"""Lets the ack through, then wedges event sends until released - a client
|
||||
that stopped reading the transport."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.wedged = anyio.Event()
|
||||
self.release = anyio.Event()
|
||||
|
||||
async def send_notification(
|
||||
self, notification: ServerNotification, related_request_id: RequestId | None = None
|
||||
) -> None:
|
||||
if self.sent: # the ack is the first frame; only event sends wedge
|
||||
self.wedged.set()
|
||||
await self.release.wait()
|
||||
await super().send_notification(notification, related_request_id)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_backlog_overflow_ends_the_stream_and_frees_its_slot() -> None:
|
||||
"""SDK-defined: a stream whose client stopped reading is ended at
|
||||
`max_buffered_events` rather than buffering forever. The subscription slot
|
||||
frees at overflow time - the stream's own cleanup may be wedged in a
|
||||
transport write nothing can wake - and the backlog still drains into the
|
||||
stamped graceful result once that write completes."""
|
||||
bus = InMemorySubscriptionBus()
|
||||
handler = ListenHandler(bus, max_subscriptions=1, max_buffered_events=1)
|
||||
session = _GatedSession()
|
||||
results: list[SubscriptionsListenResult] = []
|
||||
late_session = _RecordingSession()
|
||||
late_results: list[SubscriptionsListenResult] = []
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
|
||||
async def run() -> None:
|
||||
results.append(await handler(_ctx(session), _params(tools_list_changed=True)))
|
||||
|
||||
tg.start_soon(run)
|
||||
await session.wait_for(1)
|
||||
|
||||
await bus.publish(ToolsListChanged()) # consumed, then wedged mid-send
|
||||
with anyio.fail_after(5):
|
||||
await session.wedged.wait()
|
||||
await bus.publish(ToolsListChanged()) # fills the one-slot buffer
|
||||
await bus.publish(ToolsListChanged()) # overflows: the stream is ended
|
||||
|
||||
async def run_late() -> None:
|
||||
late_results.append(await handler(_ctx(late_session, request_id=8), _params(tools_list_changed=True)))
|
||||
|
||||
# The ended stream's slot is free immediately - a new listen does not
|
||||
# wait for the wedged write to die with its connection.
|
||||
tg.start_soon(run_late)
|
||||
await late_session.wait_for(1)
|
||||
|
||||
session.release.set()
|
||||
handler.close()
|
||||
|
||||
delivered = [notification for notification, _ in session.sent[1:]]
|
||||
assert len(delivered) == 2 # the wedged event and the buffered one still drained
|
||||
assert results[0].meta == {SUBSCRIPTION_ID_META_KEY: 7}
|
||||
assert late_results[0].meta == {SUBSCRIPTION_ID_META_KEY: 8}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_same_task_publish_burst_does_not_overflow_a_healthy_stream() -> None:
|
||||
"""SDK-defined: `publish` ends with a checkpoint, so a burst of events from
|
||||
one task (no yields of its own) lets a reading stream drain between
|
||||
publishes instead of deterministically overflowing the buffer."""
|
||||
bus = InMemorySubscriptionBus()
|
||||
handler = ListenHandler(bus, max_buffered_events=99)
|
||||
session = _RecordingSession()
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
|
||||
async def run() -> None:
|
||||
await handler(_ctx(session), _params(tools_list_changed=True))
|
||||
|
||||
tg.start_soon(run)
|
||||
await session.wait_for(1)
|
||||
|
||||
for _ in range(100):
|
||||
await bus.publish(ToolsListChanged())
|
||||
await session.wait_for(101)
|
||||
handler.close()
|
||||
|
||||
assert len(session.sent) == 101 # the ack plus every event in the burst
|
||||
Loading…
Reference in a new issue