Serve subscriptions/listen with a pluggable event bus (SEP-2575) (#3035)

This commit is contained in:
Max 2026-06-30 23:01:04 +01:00 • committed by GitHub
commit ca10dade2c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 1722 additions and 66 deletions

View file

@ -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

View file

@ -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:

View 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()

View file

@ -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

View file

@ -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")

View file

@ -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}

View 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