Add the client-side subscriptions/listen driver (#3047)

This commit is contained in:
Max 2026-07-07 14:26:09 +01:00 • committed by GitHub
commit 9bdc03d54e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 2587 additions and 295 deletions

View file

@ -35,7 +35,7 @@ from mcp_types import (
from mcp_types.version import LATEST_HANDSHAKE_VERSION
from pydantic import FileUrl
from mcp import MCPError
from mcp import MCPDeprecationWarning, MCPError
from mcp.client._memory import InMemoryTransport
from mcp.client._transport import TransportStreams
from mcp.client.client import Client
@ -310,13 +310,15 @@ async def test_client_send_progress_notification():
async def test_client_subscribe_resource(simple_server: Server):
async with Client(simple_server, mode="legacy") as client:
result = await client.subscribe_resource("memory://test")
with pytest.warns(MCPDeprecationWarning, match="use Client.listen"):
result = await client.subscribe_resource("memory://test") # pyright: ignore[reportDeprecated]
assert result == snapshot(EmptyResult())
async def test_client_unsubscribe_resource(simple_server: Server):
async with Client(simple_server, mode="legacy") as client:
result = await client.unsubscribe_resource("memory://test")
with pytest.warns(MCPDeprecationWarning, match="use Client.listen"):
result = await client.unsubscribe_resource("memory://test") # pyright: ignore[reportDeprecated]
assert result == snapshot(EmptyResult())

View file

@ -22,7 +22,7 @@ from mcp_types import (
from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION
from mcp.client.session import ClientSession
from mcp.shared.dispatcher import CallOptions, OnNotify, OnRequest
from mcp.shared.dispatcher import CallOptions, OnNotify, OnNotifyIntercept, OnRequest
from mcp.shared.inbound import MCP_NAME_HEADER, MCP_PROTOCOL_VERSION_HEADER, encode_header_value
@ -36,6 +36,7 @@ class _RecordingDispatcher:
self,
on_request: OnRequest,
on_notify: OnNotify,
on_notify_intercept: OnNotifyIntercept | None = None,
*,
task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED,
) -> None:

View file

@ -39,11 +39,13 @@ from mcp import MCPError
from mcp.client import ClientRequestContext
from mcp.client.client import Client
from mcp.client.session import DEFAULT_CLIENT_INFO, ClientSession
from mcp.client.subscriptions import ToolsListChanged, listen
from mcp.server import Server, ServerRequestContext
from mcp.shared.direct_dispatcher import create_direct_dispatcher_pair
from mcp.shared.dispatcher import CallOptions, DispatchContext, OnNotify, OnRequest
from mcp.shared.dispatcher import CallOptions, DispatchContext, OnNotify, OnNotifyIntercept, OnRequest
from mcp.shared.message import SessionMessage
from mcp.shared.session import RequestResponder
from mcp.shared.subscriptions import SUBSCRIPTION_ID_META_KEY
from mcp.shared.transport_context import TransportContext
_SendToClient = anyio.streams.memory.MemoryObjectSendStream[SessionMessage | Exception]
@ -1341,6 +1343,7 @@ class _OptsRecordingDispatcher:
self,
on_request: OnRequest,
on_notify: OnNotify,
on_notify_intercept: OnNotifyIntercept | None = None,
*,
task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED,
) -> None:
@ -1428,6 +1431,7 @@ async def test_aenter_cancelled_while_dispatcher_starts_unwinds_cleanly():
self,
on_request: OnRequest,
on_notify: OnNotify,
on_notify_intercept: OnNotifyIntercept | None = None,
*,
task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED,
) -> None:
@ -1486,6 +1490,7 @@ class _ScriptedDispatcher:
self,
on_request: OnRequest,
on_notify: OnNotify,
on_notify_intercept: OnNotifyIntercept | None = None,
*,
task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED,
) -> None:
@ -1808,3 +1813,137 @@ async def test_session_read_resource_returns_input_required_result_when_opted_in
result = await client.session.read_resource("memory://r", allow_input_required=True)
assert isinstance(result, types.InputRequiredResult)
assert result.request_state == "resource-state"
@pytest.mark.anyio
async def test_a_late_ack_for_a_closed_driver_listen_reaches_message_handler():
"""Ack consumption is keyed on the live route registry alone: a stray ack for a
closed subscription's id surfaces through message_handler like any other unowned frame."""
seen: list[object] = []
follow_up = anyio.Event()
async def handler(msg: object) -> None:
seen.append(msg)
if len(seen) == 2:
follow_up.set()
async with raw_client_session(message_handler=handler) as (session, to_client, _):
_set_negotiated_version(session, "2026-07-28")
session._register_listen_route("listen-99") # pyright: ignore[reportPrivateUsage]
session._unregister_listen_route("listen-99") # pyright: ignore[reportPrivateUsage]
await to_client.send(
SessionMessage(
JSONRPCNotification(
jsonrpc="2.0",
method="notifications/subscriptions/acknowledged",
params={
"notifications": {"toolsListChanged": True},
"_meta": {SUBSCRIPTION_ID_META_KEY: "listen-99"},
},
)
)
)
await to_client.send(
SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/tools/list_changed", params={}))
)
with anyio.fail_after(5):
await follow_up.wait()
assert [type(message).__name__ for message in seen] == [
"SubscriptionsAcknowledgedNotification",
"ToolListChangedNotification",
]
@pytest.mark.anyio
async def test_a_graceful_result_does_not_outrun_the_events_that_preceded_it():
"""[ack, event, result] written back-to-back: the event delivers and the wire ack's filter
survives a parked message_handler tee, because routes settle on the dispatcher's receive path in wire order."""
async def parked_handler(message: object) -> None:
await anyio.sleep_forever()
events: list[object] = []
honored: list[types.SubscriptionFilter] = []
async with raw_client_session(message_handler=parked_handler) as (session, to_client, from_client):
_set_negotiated_version(session, "2026-07-28")
with anyio.fail_after(5):
async with anyio.create_task_group() as tg: # pragma: no branch
async def consume() -> None:
async with listen(session, tools_list_changed=True) as sub: # pragma: no branch
honored.append(sub.honored)
events.extend([event async for event in sub])
tg.start_soon(consume)
request = await from_client.receive()
assert isinstance(request.message, JSONRPCRequest)
meta = {SUBSCRIPTION_ID_META_KEY: request.message.id}
for message in (
JSONRPCNotification(
jsonrpc="2.0",
method="notifications/subscriptions/acknowledged",
params={"notifications": {"toolsListChanged": True}, "_meta": meta},
),
JSONRPCNotification(
jsonrpc="2.0", method="notifications/tools/list_changed", params={"_meta": meta}
),
JSONRPCResponse(jsonrpc="2.0", id=request.message.id, result={"_meta": meta}),
):
await to_client.send(SessionMessage(message))
assert honored == [types.SubscriptionFilter(tools_list_changed=True)]
assert events == [ToolsListChanged()]
def _intercept_only_session() -> ClientSession:
"""A never-entered session whose intercept can be driven directly (it is synchronous)."""
dispatcher, _peer = create_direct_dispatcher_pair()
return ClientSession(dispatcher=dispatcher)
def test_intercept_settles_only_the_named_listen_route_on_cancelled():
"""SDK demux contract: a server-sent cancel settles exactly the listen route it names and is never consumed."""
session = _intercept_only_session()
route = session._register_listen_route("listen-1") # pyright: ignore[reportPrivateUsage]
intercept = session._intercept_notification # pyright: ignore[reportPrivateUsage]
assert intercept("notifications/cancelled", {"requestId": "unrelated"}) is False
assert route.end is None
assert intercept("notifications/cancelled", {"requestId": "listen-1"}) is False
assert route.end == "lost"
def test_intercept_ignores_frames_without_a_route_or_with_broken_meta():
"""SDK demux contract: frames that correlate to no live route flow through to the normal notification path."""
session = _intercept_only_session()
intercept = session._intercept_notification # pyright: ignore[reportPrivateUsage]
assert intercept("notifications/tools/list_changed", {"_meta": {SUBSCRIPTION_ID_META_KEY: "listen-1"}}) is False
route = session._register_listen_route("listen-1") # pyright: ignore[reportPrivateUsage]
route.set_acked(types.SubscriptionFilter(tools_list_changed=True))
assert intercept("notifications/tools/list_changed", None) is False
# A non-mapping `_meta` is constructible on pre-2026 wires.
assert intercept("notifications/tools/list_changed", {"_meta": "oops"}) is False
assert intercept("notifications/tools/list_changed", {"_meta": {SUBSCRIPTION_ID_META_KEY: "other"}}) is False
# A non-string uri is not an event; surface validation owns it.
meta = {"_meta": {SUBSCRIPTION_ID_META_KEY: "listen-1"}}
assert intercept("notifications/resources/updated", {"uri": 7, **meta}) is False
assert route._pending == {} # pyright: ignore[reportPrivateUsage]
def test_intercept_consumes_acks_for_live_routes_and_leaves_malformed_ones():
"""SDK demux contract: a well-formed ack for a live route is consumed as driver state; malformed acks pass on."""
session = _intercept_only_session()
route = session._register_listen_route("listen-1") # pyright: ignore[reportPrivateUsage]
intercept = session._intercept_notification # pyright: ignore[reportPrivateUsage]
meta = {"_meta": {SUBSCRIPTION_ID_META_KEY: "listen-1"}}
assert intercept("notifications/subscriptions/acknowledged", {"notifications": ["nope"], **meta}) is False
assert route.honored is None
# A missing `notifications` field must not be read as an (all-refusing) empty filter.
assert intercept("notifications/subscriptions/acknowledged", dict(meta)) is False
assert route.honored is None
assert (
intercept("notifications/subscriptions/acknowledged", {"notifications": {"toolsListChanged": True}, **meta})
is True
)
assert route.honored == types.SubscriptionFilter(tools_list_changed=True)
# Events deliver but are never consumed - they still tee to message_handler.
assert intercept("notifications/tools/list_changed", meta) is False
assert list(route._pending) == [ToolsListChanged()] # pyright: ignore[reportPrivateUsage]

View file

@ -28,7 +28,7 @@ from typing_extensions import assert_type
from mcp.client.extension import ClaimContext, ResultClaim, UnexpectedClaimedResult
from mcp.client.session import ClientSession, _CallToolResultAdapter
from mcp.shared.dispatcher import CallOptions, OnNotify, OnRequest
from mcp.shared.dispatcher import CallOptions, OnNotify, OnNotifyIntercept, OnRequest
_TASKS_EXT = "com.example/tasks"
_AD_ONLY_EXT = "com.example/flags"
@ -75,6 +75,7 @@ class _RecordingDispatcher:
self,
on_request: OnRequest,
on_notify: OnNotify,
on_notify_intercept: OnNotifyIntercept | None = None,
*,
task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED,
) -> None:

View file

@ -18,6 +18,8 @@ from inline_snapshot import snapshot
from mcp_types import (
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
CONNECTION_CLOSED,
INVALID_REQUEST,
METHOD_NOT_FOUND,
PROTOCOL_VERSION_META_KEY,
JSONRPCError,
@ -28,10 +30,16 @@ from mcp_types import (
from mcp_types.version import LATEST_MODERN_VERSION
from starlette.types import Receive, Scope, Send
from mcp.client.streamable_http import streamable_http_client
from mcp.client.streamable_http import (
MAX_RECONNECTION_ATTEMPTS,
RequestContext,
StreamableHTTPTransport,
streamable_http_client,
)
from mcp.server import Server
from mcp.server._streamable_http_modern import handle_modern_request
from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler, ServerEvent
from mcp.shared._context_streams import ContextSendStream, create_context_streams
from mcp.shared.dispatcher import CallOptions, DispatchContext
from mcp.shared.inbound import MCP_METHOD_HEADER, MCP_PROTOCOL_VERSION_HEADER, encode_header_value
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
@ -583,3 +591,160 @@ async def test_a_finished_post_task_does_not_evict_a_reused_ids_new_registration
)
await parked.closed.wait()
assert [body["method"] for body in posted] == ["tools/call", "subscriptions/listen"]
class _DyingSSEStream(httpx.AsyncByteStream):
"""Emits one id-less comment then breaks - a non-resumable stream dropping."""
def __init__(self) -> None:
self.opened = anyio.Event()
async def __aiter__(self) -> AsyncIterator[bytes]:
self.opened.set()
yield b": hello\n\n"
raise httpx.ReadError("connection reset")
async def aclose(self) -> None:
pass
@pytest.mark.anyio
async def test_a_non_resumable_sse_drop_resolves_the_request_with_an_error() -> None:
"""A per-request SSE stream that dies having carried no event ids can never deliver its
response; the transport resolves the waiter with CONNECTION_CLOSED instead of hanging forever."""
dying = _DyingSSEStream()
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=dying)
with anyio.fail_after(5):
async with (
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
):
await write.send(
SessionMessage(JSONRPCRequest(jsonrpc="2.0", id="listen-1", method="subscriptions/listen", params={}))
)
reply = await read.receive()
assert isinstance(reply, SessionMessage)
assert isinstance(reply.message, JSONRPCError)
assert reply.message.id == "listen-1"
assert reply.message.error.code == CONNECTION_CLOSED
class _DeliverOnCommandSSEStream(httpx.AsyncByteStream):
"""Parks after opening, then delivers one JSON-RPC response when told."""
def __init__(self, response_body: dict[str, Any]) -> None:
self._event = f"data: {json.dumps(response_body)}\n\n".encode()
self.opened = anyio.Event()
self.deliver = anyio.Event()
async def __aiter__(self) -> AsyncIterator[bytes]:
self.opened.set()
await self.deliver.wait()
yield self._event
async def aclose(self) -> None:
pass
@pytest.mark.anyio
async def test_a_superseded_posts_late_real_response_cannot_answer_the_successor() -> None:
"""SDK-defined: re-issuing an id severs the superseded POST, so nothing from its
stream (a late real response, or a synthesized error for its death) can resolve
the reused id's waiter; only the successor's own response arrives."""
stale = _DeliverOnCommandSSEStream({"jsonrpc": "2.0", "id": "dup-1", "result": {"origin": "stale"}})
succeeding = _DeliverOnCommandSSEStream({"jsonrpc": "2.0", "id": "dup-1", "result": {"origin": "fresh"}})
streams: list[httpx.AsyncByteStream] = [stale, succeeding]
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=streams.pop(0))
with anyio.fail_after(5):
async with (
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
):
await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id="dup-1", method="tools/call", params={})))
await stale.opened.wait()
await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id="dup-1", method="tools/call", params={})))
await succeeding.opened.wait()
stale.deliver.set()
await anyio.wait_all_tasks_blocked()
succeeding.deliver.set()
reply = await read.receive()
assert isinstance(reply, SessionMessage)
assert isinstance(reply.message, JSONRPCResponse), reply.message
assert reply.message.result == {"origin": "fresh"}
@pytest.mark.anyio
async def test_a_202_to_a_request_resolves_the_waiter_with_an_error() -> None:
"""SDK-defined: a server that answers a request with 202 Accepted has declared no
response will follow (the spec requires SSE or JSON for requests); the transport
resolves the waiter with INVALID_REQUEST instead of parking the caller forever."""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(202)
with anyio.fail_after(5):
async with (
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
):
await write.send(
SessionMessage(JSONRPCRequest(jsonrpc="2.0", id="listen-1", method="subscriptions/listen", params={}))
)
reply = await read.receive()
assert isinstance(reply, SessionMessage)
assert isinstance(reply.message, JSONRPCError)
assert reply.message.id == "listen-1"
assert reply.message.error.code == INVALID_REQUEST
def _abandoned_request_context(
http: httpx.AsyncClient, send: ContextSendStream[SessionMessage | Exception]
) -> RequestContext:
return RequestContext(
client=http,
session_id=None,
session_message=SessionMessage(
JSONRPCRequest(jsonrpc="2.0", id="listen-1", method="subscriptions/listen", params={})
),
metadata=None,
read_stream_writer=send,
)
@pytest.mark.anyio
async def test_exhausted_reconnection_attempts_resolve_the_request_with_an_error() -> None:
"""An id-bearing stream that exhausts its reconnection budget also resolves the waiter with CONNECTION_CLOSED."""
transport = StreamableHTTPTransport("http://test/mcp")
send, receive = create_context_streams[SessionMessage | Exception](1)
async with httpx.AsyncClient() as http:
with anyio.fail_after(5):
await transport._handle_reconnection( # pyright: ignore[reportPrivateUsage]
_abandoned_request_context(http, send), "evt-7", None, MAX_RECONNECTION_ATTEMPTS
)
reply = await receive.receive()
assert isinstance(reply, SessionMessage)
assert isinstance(reply.message, JSONRPCError)
assert reply.message.id == "listen-1"
assert reply.message.error.code == CONNECTION_CLOSED
send.close()
receive.close()
@pytest.mark.anyio
async def test_resolving_an_abandoned_request_after_the_reader_closed_is_contained() -> None:
"""Teardown race: a stream dying after the reader closed resolves best-effort and must not crash."""
transport = StreamableHTTPTransport("http://test/mcp")
send, receive = create_context_streams[SessionMessage | Exception](1)
receive.close()
async with httpx.AsyncClient() as http:
with anyio.fail_after(5):
await transport._handle_reconnection( # pyright: ignore[reportPrivateUsage]
_abandoned_request_context(http, send), "evt-7", None, MAX_RECONNECTION_ATTEMPTS
)
send.close()

View file

@ -0,0 +1,666 @@
"""Behavioral tests for the client-side `subscriptions/listen` driver (SDK-defined contract).
Public API only, against in-process servers; wire-shape assertions live in the interaction suite.
"""
from itertools import count
from typing import Any
import anyio
import mcp_types as types
import pytest
from mcp_types import SubscriptionFilter
import mcp.client.subscriptions as subscriptions_module
from mcp import Client, MCPError
from mcp.client.session import ClientSession
from mcp.client.subscriptions import (
ListenNotSupportedError,
ListenRoute,
PromptsListChanged,
ResourcesListChanged,
ResourceUpdated,
ServerEvent,
Subscription,
SubscriptionLost,
ToolsListChanged,
listen,
)
from mcp.server import Server, ServerRequestContext
from mcp.server.subscriptions import (
SUBSCRIPTION_ID_META_KEY,
InMemorySubscriptionBus,
ListenHandler,
)
from mcp.shared.direct_dispatcher import create_direct_dispatcher_pair
from mcp.shared.dispatcher import CallOptions
pytestmark = pytest.mark.anyio
def _bus_server(bus: InMemorySubscriptionBus, *, max_subscriptions: int | None = None) -> Server[Any]:
"""A lowlevel server whose only feature is serving listen streams from `bus`."""
handler = (
ListenHandler(bus) if max_subscriptions is None else ListenHandler(bus, max_subscriptions=max_subscriptions)
)
return Server("subs", on_subscriptions_listen=handler)
async def _ack(ctx: ServerRequestContext[Any, Any], honored: SubscriptionFilter) -> dict[str, Any]:
"""Send a hand-rolled ack for a scripted listen handler; returns the stamped meta."""
assert ctx.request_id is not None
meta: dict[str, Any] = {SUBSCRIPTION_ID_META_KEY: ctx.request_id}
await ctx.session.send_notification(
types.SubscriptionsAcknowledgedNotification(
params=types.SubscriptionsAcknowledgedNotificationParams(notifications=honored, _meta=meta)
),
related_request_id=ctx.request_id,
)
return meta
async def test_listen_surfaces_the_honored_filter_and_subscription_id():
"""Entering waits for the server ack and surfaces the honored filter and subscription id."""
bus = InMemorySubscriptionBus()
async with Client(_bus_server(bus)) as client:
with anyio.fail_after(5):
async with client.listen( # pragma: no branch
tools_list_changed=True, resource_subscriptions=["note://todo"]
) as sub:
assert isinstance(sub, Subscription)
assert sub.honored.tools_list_changed is True
assert sub.honored.resource_subscriptions == ["note://todo"]
assert isinstance(sub.subscription_id, str)
assert sub.subscription_id.startswith("listen-")
async def test_listen_delivers_all_four_typed_event_kinds():
"""Bus publishes come back as the same typed event values, in order."""
bus = InMemorySubscriptionBus()
async with Client(_bus_server(bus)) as client:
with anyio.fail_after(5):
async with client.listen( # pragma: no branch
tools_list_changed=True,
prompts_list_changed=True,
resources_list_changed=True,
resource_subscriptions=["note://todo"],
) as sub:
for event in (
ToolsListChanged(),
PromptsListChanged(),
ResourcesListChanged(),
ResourceUpdated(uri="note://todo"),
):
await bus.publish(event)
assert await anext(sub) == event
async def test_unconsumed_duplicate_events_coalesce():
"""Events are level triggers: duplicates pending consumption collapse to one."""
bus = InMemorySubscriptionBus()
async with Client(_bus_server(bus)) as client:
with anyio.fail_after(5):
async with client.listen( # pragma: no branch
tools_list_changed=True, resource_subscriptions=["note://todo"]
) as sub:
for _ in range(3):
await bus.publish(ToolsListChanged())
await bus.publish(ResourceUpdated(uri="note://todo"))
await anyio.wait_all_tasks_blocked()
assert await anext(sub) == ToolsListChanged()
assert await anext(sub) == ResourceUpdated(uri="note://todo")
async def test_graceful_server_close_ends_the_loop_cleanly():
"""The server's deliberate close ends iteration cleanly, after draining prior events."""
bus = InMemorySubscriptionBus()
handler = ListenHandler(bus)
server = Server("subs", on_subscriptions_listen=handler)
events: list[object] = []
async with Client(server) as client:
with anyio.fail_after(5):
async with client.listen(tools_list_changed=True) as sub: # pragma: no branch
await bus.publish(ToolsListChanged())
handler.close()
events.extend([event async for event in sub])
assert events == [ToolsListChanged()]
async def test_abrupt_stream_end_raises_subscription_lost():
"""A stream dying without the graceful result raises `SubscriptionLost` with the cause chained."""
proceed = anyio.Event()
async def dropping_listen(
ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams
) -> types.SubscriptionsListenResult:
await _ack(ctx, params.notifications)
await proceed.wait()
raise MCPError(types.INTERNAL_ERROR, "stream torn down")
server = Server("subs", on_subscriptions_listen=dropping_listen)
async with Client(server) as client:
with anyio.fail_after(5):
async with client.listen(tools_list_changed=True) as sub: # pragma: no branch
proceed.set()
with pytest.raises(SubscriptionLost) as exc_info: # pragma: no branch
await anext(sub)
assert isinstance(exc_info.value.__cause__, MCPError)
assert exc_info.value.__cause__.error.message == "stream torn down"
async def test_listen_on_a_legacy_connection_raises_the_typed_steer():
"""On a 2025 connection `listen` fails fast with the typed error steering to the legacy verbs."""
bus = InMemorySubscriptionBus()
async with Client(_bus_server(bus), mode="legacy") as client:
with anyio.fail_after(5):
# Entering is where the guard fires; __aenter__ directly avoids an unreachable with-body.
with pytest.raises(ListenNotSupportedError) as exc_info: # pragma: no branch
await client.listen(tools_list_changed=True).__aenter__()
assert exc_info.value.negotiated_version == "2025-11-25"
assert "subscribe_resource" in str(exc_info.value)
async def test_server_rejection_raises_from_enter_not_from_iteration():
"""A server without the listen handler fails the open from entering the context."""
server = Server("no-listen")
async with Client(server) as client:
with anyio.fail_after(5):
with pytest.raises(MCPError) as exc_info: # pragma: no branch
await client.listen(tools_list_changed=True).__aenter__()
assert exc_info.value.error.code == types.METHOD_NOT_FOUND
async def test_immediate_result_without_ack_opens_already_closed():
"""A bare result with no ack yields a subscription already gracefully over: no filter, no events."""
async def degenerate_listen(
ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams
) -> types.SubscriptionsListenResult:
assert ctx.request_id is not None
return types.SubscriptionsListenResult(_meta={SUBSCRIPTION_ID_META_KEY: ctx.request_id})
server = Server("subs", on_subscriptions_listen=degenerate_listen)
async with Client(server) as client:
with anyio.fail_after(5):
async with client.listen(tools_list_changed=True) as sub: # pragma: no branch
assert sub.honored == SubscriptionFilter()
with pytest.raises(StopAsyncIteration): # pragma: no branch
await anext(sub)
async def test_server_sent_cancelled_for_the_listen_id_raises_subscription_lost():
"""Server-sent notifications/cancelled for the listen id surfaces as a lost subscription."""
proceed = anyio.Event()
async def cancelling_listen(
ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams
) -> types.SubscriptionsListenResult:
assert ctx.request_id is not None
await _ack(ctx, params.notifications)
await proceed.wait()
await ctx.session.send_notification(
types.CancelledNotification(params=types.CancelledNotificationParams(request_id=ctx.request_id)),
related_request_id=ctx.request_id,
)
await anyio.sleep_forever()
raise AssertionError("unreachable") # pragma: no cover
server = Server("subs", on_subscriptions_listen=cancelling_listen)
async with Client(server) as client:
with anyio.fail_after(5):
async with client.listen(tools_list_changed=True) as sub: # pragma: no branch
proceed.set()
with pytest.raises(SubscriptionLost): # pragma: no branch
await anext(sub)
async def test_exiting_the_context_frees_the_server_slot():
"""Leaving the block ends the subscription server-side: a one-slot handler admits a second listen."""
bus = InMemorySubscriptionBus()
async with Client(_bus_server(bus, max_subscriptions=1)) as client:
with anyio.fail_after(5):
async with client.listen(tools_list_changed=True) as first:
assert first.honored.tools_list_changed is True
async with client.listen(tools_list_changed=True) as second: # pragma: no branch
assert second.honored.tools_list_changed is True
assert second.subscription_id != first.subscription_id
async def test_concurrent_subscriptions_demux_independently():
"""Two open subscriptions each receive only their own filter's events."""
bus = InMemorySubscriptionBus()
async with Client(_bus_server(bus)) as client:
with anyio.fail_after(5):
async with ( # pragma: no branch
client.listen(tools_list_changed=True) as tools_sub,
client.listen(resource_subscriptions=["note://todo"]) as notes_sub,
):
await bus.publish(ToolsListChanged())
await bus.publish(ResourceUpdated(uri="note://todo"))
assert await anext(tools_sub) == ToolsListChanged()
assert await anext(notes_sub) == ResourceUpdated(uri="note://todo")
# Neither stream received the other's event.
await bus.publish(ToolsListChanged())
assert await anext(tools_sub) == ToolsListChanged()
async def test_change_notifications_still_reach_message_handler():
"""The demux tees: a delivered event's notification still reaches message_handler; the ack never does."""
bus = InMemorySubscriptionBus()
seen: list[str] = []
async def on_message(message: object) -> None:
assert not isinstance(message, types.SubscriptionsAcknowledgedNotification)
if isinstance(message, types.ToolListChangedNotification): # pragma: no branch
seen.append("tools-changed")
async with Client(_bus_server(bus), message_handler=on_message) as client:
with anyio.fail_after(5):
async with client.listen(tools_list_changed=True) as sub: # pragma: no branch
await bus.publish(ToolsListChanged())
assert await anext(sub) == ToolsListChanged()
await anyio.wait_all_tasks_blocked()
assert seen == ["tools-changed"]
async def test_enter_times_out_when_the_ack_never_arrives():
"""The ack wait rides the session's read timeout, so a wedged server cannot hang the open."""
async def silent_listen(
ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams
) -> types.SubscriptionsListenResult:
await anyio.sleep_forever()
raise AssertionError("unreachable") # pragma: no cover
server = Server("subs", on_subscriptions_listen=silent_listen)
async with Client(server, read_timeout_seconds=0.05) as client:
with anyio.fail_after(5):
with pytest.raises(TimeoutError): # pragma: no branch
await client.listen(tools_list_changed=True).__aenter__()
async def test_an_open_stream_outlives_the_session_read_timeout():
"""The listen request is exempt from the read timeout: the stream delivers after the deadline."""
bus = InMemorySubscriptionBus()
async with Client(_bus_server(bus), read_timeout_seconds=0.05) as client:
with anyio.fail_after(5):
async with client.listen(tools_list_changed=True) as sub: # pragma: no branch
# Real clock on purpose: this pins a timeout feature.
await anyio.sleep(0.2)
await bus.publish(ToolsListChanged())
assert await anext(sub) == ToolsListChanged()
async def test_a_duplicate_ack_does_not_overwrite_the_honored_filter():
"""The first ack wins; a later conflicting ack is a no-op."""
proceed = anyio.Event()
async def double_acking_listen(
ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams
) -> types.SubscriptionsListenResult:
assert ctx.request_id is not None
await _ack(ctx, params.notifications)
await _ack(ctx, SubscriptionFilter())
await proceed.wait()
return types.SubscriptionsListenResult(_meta={SUBSCRIPTION_ID_META_KEY: ctx.request_id})
server = Server("subs", on_subscriptions_listen=double_acking_listen)
async with Client(server) as client:
with anyio.fail_after(5):
async with client.listen(tools_list_changed=True) as sub: # pragma: no branch
assert sub.honored.tools_list_changed is True
proceed.set()
async def test_a_non_event_frame_with_the_subscription_id_is_teed_not_delivered():
"""A stamped non-event notification never surfaces as an event; it flows to message_handler."""
proceed = anyio.Event()
async def logging_listen(
ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams
) -> types.SubscriptionsListenResult:
assert ctx.request_id is not None
meta = await _ack(ctx, params.notifications)
await ctx.session.send_notification(
types.LoggingMessageNotification(
params=types.LoggingMessageNotificationParams(level="info", data="not an event", _meta=meta)
),
related_request_id=ctx.request_id,
)
await proceed.wait()
return types.SubscriptionsListenResult(_meta=meta)
logged: list[str] = []
async def on_message(message: object) -> None:
if isinstance(message, types.LoggingMessageNotification): # pragma: no branch
logged.append(str(message.params.data))
server = Server("subs", on_subscriptions_listen=logging_listen)
async with Client(server, message_handler=on_message) as client:
with anyio.fail_after(5):
async with client.listen(tools_list_changed=True) as sub: # pragma: no branch
await anyio.wait_all_tasks_blocked()
proceed.set()
with pytest.raises(StopAsyncIteration): # pragma: no branch
await anext(sub)
assert logged == ["not an event"]
async def test_session_teardown_unblocks_a_sibling_consumer_with_subscription_lost():
"""Session teardown settles every open route as lost, unblocking parked consumers."""
bus = InMemorySubscriptionBus()
outcome: list[str] = []
entered = anyio.Event()
async def consume(client: Client) -> None:
with pytest.raises(SubscriptionLost):
async with client.listen(tools_list_changed=True) as sub:
entered.set()
await anext(sub)
outcome.append("lost")
with anyio.fail_after(5):
async with anyio.create_task_group() as tg:
async with Client(_bus_server(bus)) as client: # pragma: no branch
tg.start_soon(consume, client)
await entered.wait()
assert outcome == ["lost"]
async def test_server_cancel_before_the_ack_raises_subscription_lost_from_enter():
"""A stream torn down before it was ever acknowledged is a failed open: enter raises."""
async def cancel_first_listen(
ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams
) -> types.SubscriptionsListenResult:
assert ctx.request_id is not None
await ctx.session.send_notification(
types.CancelledNotification(params=types.CancelledNotificationParams(request_id=ctx.request_id)),
related_request_id=ctx.request_id,
)
await anyio.sleep_forever()
raise AssertionError("unreachable") # pragma: no cover
server = Server("subs", on_subscriptions_listen=cancel_first_listen)
async with Client(server) as client:
with anyio.fail_after(5):
with pytest.raises(SubscriptionLost, match="before it was acknowledged"): # pragma: no branch
await client.listen(tools_list_changed=True).__aenter__()
async def test_listen_on_an_exited_session_raises_and_leaks_no_route():
"""Opening on an exited session fails loudly and leaves no demux registration behind."""
bus = InMemorySubscriptionBus()
client = Client(_bus_server(bus))
async with client:
session = client.session
with pytest.raises(RuntimeError):
await listen(session, tools_list_changed=True).__aenter__()
assert session._listen_routes == {} # pyright: ignore[reportPrivateUsage]
async def test_listen_on_a_never_entered_session_raises_runtime_error():
"""An adopted-but-never-entered session has no task group to drive the stream."""
dispatcher, _peer = create_direct_dispatcher_pair()
session = ClientSession(dispatcher=dispatcher)
session.adopt(
types.DiscoverResult(
supported_versions=["2026-07-28"],
capabilities=types.ServerCapabilities(),
server_info=types.Implementation(name="stub", version="0"),
)
)
with pytest.raises(RuntimeError, match="entered session"):
await listen(session, tools_list_changed=True).__aenter__()
assert session._listen_routes == {} # pyright: ignore[reportPrivateUsage]
async def test_a_retained_handle_after_exit_does_not_serve_stale_events():
"""Leaving the block abandons the backlog: a stashed handle must not replay buffered events."""
bus = InMemorySubscriptionBus()
async with Client(_bus_server(bus)) as client:
with anyio.fail_after(5):
async with client.listen(tools_list_changed=True) as sub:
await bus.publish(ToolsListChanged())
await anyio.wait_all_tasks_blocked()
with pytest.raises(StopAsyncIteration): # pragma: no branch
await anext(sub)
async def test_a_stray_ack_outside_the_driver_namespace_still_reaches_message_handler():
"""Acks for ids the driver never minted flow to message_handler (the raw-listen escape hatch)."""
proceed = anyio.Event()
async def stray_acking_listen(
ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams
) -> types.SubscriptionsListenResult:
assert ctx.request_id is not None
await _ack(ctx, params.notifications)
await ctx.session.send_notification(
types.SubscriptionsAcknowledgedNotification(
params=types.SubscriptionsAcknowledgedNotificationParams(
notifications=SubscriptionFilter(), _meta={SUBSCRIPTION_ID_META_KEY: 424242}
)
),
related_request_id=ctx.request_id,
)
await proceed.wait()
return types.SubscriptionsListenResult(_meta={SUBSCRIPTION_ID_META_KEY: ctx.request_id})
handled: list[str] = []
async def on_message(message: object) -> None:
handled.append(type(message).__name__)
server = Server("subs", on_subscriptions_listen=stray_acking_listen)
async with Client(server, message_handler=on_message) as client:
with anyio.fail_after(5):
async with client.listen(tools_list_changed=True) as sub: # pragma: no branch
await anyio.wait_all_tasks_blocked()
proceed.set()
with pytest.raises(StopAsyncIteration): # pragma: no branch
await anext(sub)
assert "SubscriptionsAcknowledgedNotification" in handled
async def test_a_bare_string_for_resource_subscriptions_is_rejected():
"""A bare string would explode into per-character URIs; it is rejected before touching the wire."""
bus = InMemorySubscriptionBus()
async with Client(_bus_server(bus)) as client:
with pytest.raises(TypeError, match="sequence of URIs"):
await client.listen(resource_subscriptions="note://todo").__aenter__() # pyright: ignore[reportArgumentType]
def test_the_route_admits_only_honored_events_and_only_while_live():
"""Route admission: nothing before the ack, only honored events while live, nothing after the end."""
route = ListenRoute()
route.deliver(ToolsListChanged())
assert route._pending == {} # pyright: ignore[reportPrivateUsage]
route.set_acked(SubscriptionFilter(tools_list_changed=True, resource_subscriptions=["note://todo"]))
route.deliver(PromptsListChanged()) # kind not honored
route.deliver(ResourceUpdated(uri="note://todo/draft")) # sub-resource of a subscribed URI: spec says admit
route.deliver(ResourceUpdated(uri="note://todo"))
route.deliver(ToolsListChanged())
route.deliver(ToolsListChanged()) # duplicate pending consumption collapses
assert list(route._pending) == [ # pyright: ignore[reportPrivateUsage]
ResourceUpdated(uri="note://todo/draft"),
ResourceUpdated(uri="note://todo"),
ToolsListChanged(),
]
route.settle("graceful")
route.deliver(ResourceUpdated(uri="note://todo")) # post-close noise is refused
assert len(route._pending) == 3 # pyright: ignore[reportPrivateUsage]
def test_a_peer_flooding_distinct_uris_costs_the_subscription_not_client_memory():
"""A peer flooding distinct URIs trips the `_MAX_PENDING_EVENTS` backstop: the route
settles lost instead of growing client memory without bound."""
route = ListenRoute()
route.set_acked(SubscriptionFilter(resource_subscriptions=["note://todo"]))
for n in range(subscriptions_module._MAX_PENDING_EVENTS): # pyright: ignore[reportPrivateUsage]
route.deliver(ResourceUpdated(uri=f"note://todo/{n}"))
assert route.end is None
route.deliver(ResourceUpdated(uri="note://todo/one-too-many"))
assert route.end == "lost"
assert route.error is not None
assert "backlog" in route.error.error.message
# The overflowing event was not queued.
assert len(route._pending) == subscriptions_module._MAX_PENDING_EVENTS # pyright: ignore[reportPrivateUsage]
async def test_a_cancelled_on_event_barrier_does_not_lose_the_event():
"""Cancelling `anext` mid-barrier leaves the event queued; the next `anext` re-runs the
idempotent barrier and returns it."""
bus = InMemorySubscriptionBus()
entered = anyio.Event()
release = anyio.Event()
async def parked_barrier(event: ServerEvent) -> None:
entered.set()
await release.wait()
async with Client(_bus_server(bus)) as client:
with anyio.fail_after(5):
async with listen(
client.session, tools_list_changed=True, on_event=parked_barrier
) as sub: # pragma: no branch
await bus.publish(ToolsListChanged())
async with anyio.create_task_group() as tg:
cancel_scope = anyio.CancelScope()
async def first_attempt() -> None:
with cancel_scope:
await anext(sub)
raise AssertionError("must be cancelled mid-barrier") # pragma: no cover
tg.start_soon(first_attempt)
await entered.wait()
cancel_scope.cancel()
release.set()
assert await anext(sub) == ToolsListChanged()
async def test_events_outside_the_honored_filter_are_never_delivered():
"""A server violating its acknowledged filter cannot reach the consumer or grow the backlog."""
proceed = anyio.Event()
async def overreaching_listen(
ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams
) -> types.SubscriptionsListenResult:
meta = await _ack(ctx, params.notifications) # honors exactly what was requested: tools only
await ctx.session.send_notification(
types.ResourceUpdatedNotification(
params=types.ResourceUpdatedNotificationParams(uri="note://uninvited", _meta=meta)
),
related_request_id=ctx.request_id,
)
await ctx.session.send_notification(
types.ToolListChangedNotification(params=types.NotificationParams(_meta=meta)),
related_request_id=ctx.request_id,
)
await proceed.wait()
return types.SubscriptionsListenResult(_meta=meta)
server = Server("subs", on_subscriptions_listen=overreaching_listen)
async with Client(server) as client:
with anyio.fail_after(5):
async with client.listen(tools_list_changed=True) as sub: # pragma: no branch
assert await anext(sub) == ToolsListChanged()
proceed.set()
with pytest.raises(StopAsyncIteration): # pragma: no branch
await anext(sub)
async def test_the_on_event_barrier_completes_before_each_event_is_returned():
"""`on_event` is awaited before the iterator returns each event (the Client wires cache eviction here)."""
bus = InMemorySubscriptionBus()
order: list[str] = []
async def barrier(event: ServerEvent) -> None:
order.append(f"barrier:{type(event).__name__}")
async with Client(_bus_server(bus)) as client:
with anyio.fail_after(5):
async with listen(client.session, tools_list_changed=True, on_event=barrier) as sub: # pragma: no branch
await bus.publish(ToolsListChanged())
event = await anext(sub)
order.append(f"returned:{type(event).__name__}")
assert order == ["barrier:ToolsListChanged", "returned:ToolsListChanged"]
async def test_client_listen_installs_the_cache_eviction_barrier_exactly_when_a_cache_exists():
"""`Client.listen` wires the response-cache evictor as the barrier only when a cache exists."""
bus = InMemorySubscriptionBus()
async with Client(_bus_server(bus)) as cached_client:
with anyio.fail_after(5):
async with cached_client.listen(tools_list_changed=True) as sub: # pragma: no branch
assert sub._on_event == cached_client._evict_for_listen_event # pyright: ignore[reportPrivateUsage]
async with Client(_bus_server(bus), cache=False) as uncached_client:
with anyio.fail_after(5):
async with uncached_client.listen(tools_list_changed=True) as sub: # pragma: no branch
assert sub._on_event is None # pyright: ignore[reportPrivateUsage]
async def test_the_cache_eviction_barrier_maps_events_and_contains_store_faults(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The barrier evicts through the same notification mapping as the message_handler wrapper;
a raising store costs a log line, not the delivery."""
client = Client(_bus_server(InMemorySubscriptionBus()))
cache = client._response_cache # pyright: ignore[reportPrivateUsage]
assert cache is not None
evicted: list[types.ServerNotification] = []
async def record(notification: types.ServerNotification) -> None:
evicted.append(notification)
monkeypatch.setattr(cache, "evict_for_notification", record)
await client._evict_for_listen_event(ResourceUpdated(uri="note://x")) # pyright: ignore[reportPrivateUsage]
assert isinstance(evicted[0], types.ResourceUpdatedNotification)
assert evicted[0].params.uri == "note://x"
async def broken(notification: types.ServerNotification) -> None:
raise RuntimeError("store down")
monkeypatch.setattr(cache, "evict_for_notification", broken)
# Contained: a cache fault must not block delivery.
await client._evict_for_listen_event(ToolsListChanged()) # pyright: ignore[reportPrivateUsage]
async def test_a_raw_request_id_collision_fails_the_subscription_not_the_session(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A raw caller occupying the driver's next minted id fails that one listen from enter;
the session survives and the next listen opens normally."""
monkeypatch.setattr(subscriptions_module, "_listen_ids", count(7000))
bus = InMemorySubscriptionBus()
async with Client(_bus_server(bus)) as client:
with anyio.fail_after(5):
async with anyio.create_task_group() as tg: # pragma: no branch
raw_scope = anyio.CancelScope()
async def raw_listen() -> None:
request = types.SubscriptionsListenRequest(
params=types.SubscriptionsListenRequestParams(
notifications=SubscriptionFilter(tools_list_changed=True)
)
)
data = request.model_dump(by_alias=True, mode="json", exclude_none=True)
opts: CallOptions = {"request_id": "listen-7000"}
client.session._stamp(data, opts) # pyright: ignore[reportPrivateUsage]
with raw_scope:
await client.session._dispatcher.send_raw_request( # pyright: ignore[reportPrivateUsage]
data["method"], data.get("params"), opts
)
tg.start_soon(raw_listen)
await anyio.wait_all_tasks_blocked()
with pytest.raises(MCPError) as exc_info:
await client.listen(tools_list_changed=True).__aenter__()
assert "already in flight" in exc_info.value.error.message
# The failed open released the colliding id's demux registration.
assert client.session._listen_routes == {} # pyright: ignore[reportPrivateUsage]
raw_scope.cancel()
async with client.listen(tools_list_changed=True) as sub: # pragma: no branch
assert sub.subscription_id == "listen-7001"