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
2 changed files with 2587 additions and 295 deletions

View file

@ -203,6 +203,7 @@ async def test_list_resource_templates_returns_registered_templates(connect: Con
)
@pytest.mark.filterwarnings("ignore::mcp.MCPDeprecationWarning")
@requirement("resources:subscribe")
async def test_subscribe_resource_delivers_uri_to_handler(connect: Connect) -> None:
"""Subscribing to a resource delivers the URI to the server's subscribe handler and returns an empty result."""
@ -214,11 +215,12 @@ async def test_subscribe_resource_delivers_uri_to_handler(connect: Connect) -> N
server = Server("library", on_subscribe_resource=subscribe_resource)
async with connect(server) as client:
result = await client.subscribe_resource("file:///watched.txt")
result = await client.subscribe_resource("file:///watched.txt") # pyright: ignore[reportDeprecated]
assert result == snapshot(EmptyResult())
@pytest.mark.filterwarnings("ignore::mcp.MCPDeprecationWarning")
@requirement("resources:subscribe:capability-required")
async def test_subscribe_without_a_subscribe_handler_is_method_not_found(connect: Connect) -> None:
"""Subscribing to a server that registered no subscribe handler is rejected with METHOD_NOT_FOUND.
@ -237,13 +239,14 @@ async def test_subscribe_without_a_subscribe_handler_is_method_not_found(connect
async with connect(server) as client:
with pytest.raises(MCPError) as exc_info:
await client.subscribe_resource("file:///watched.txt")
await client.subscribe_resource("file:///watched.txt") # pyright: ignore[reportDeprecated]
assert exc_info.value.error == snapshot(
ErrorData(code=METHOD_NOT_FOUND, message="Method not found", data="resources/subscribe")
)
@pytest.mark.filterwarnings("ignore::mcp.MCPDeprecationWarning")
@requirement("resources:unsubscribe")
async def test_unsubscribe_resource_delivers_uri_to_handler(connect: Connect) -> None:
"""Unsubscribing from a resource delivers the URI to the server's unsubscribe handler."""
@ -255,7 +258,7 @@ async def test_unsubscribe_resource_delivers_uri_to_handler(connect: Connect) ->
server = Server("library", on_unsubscribe_resource=unsubscribe_resource)
async with connect(server) as client:
result = await client.unsubscribe_resource("file:///watched.txt")
result = await client.unsubscribe_resource("file:///watched.txt") # pyright: ignore[reportDeprecated]
assert result == snapshot(EmptyResult())

View file

@ -0,0 +1,142 @@
"""Client.listen stream endings against lowlevel servers over the connect matrix."""
from typing import Any
import anyio
import mcp_types as types
import pytest
from mcp import MCPError
from mcp.client.subscriptions import SubscriptionLost, ToolsListChanged
from mcp.server import Server, ServerRequestContext
from mcp.server.subscriptions import SUBSCRIPTION_ID_META_KEY, InMemorySubscriptionBus, ListenHandler
from tests.interaction._connect import Connect
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
@requirement("subscriptions:listen:client:graceful-close")
async def test_a_graceful_server_close_ends_iteration_after_buffered_events(connect: Connect) -> None:
"""`ListenHandler.close()` sends the result last; iteration drains published events, then ends cleanly."""
bus = InMemorySubscriptionBus()
handler = ListenHandler(bus)
server = Server("subs", on_subscriptions_listen=handler)
events: list[object] = []
async with connect(server) as client:
with anyio.fail_after(10):
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()]
@requirement("subscriptions:listen:client:lost")
async def test_a_stream_dropped_after_the_ack_raises_subscription_lost(connect: Connect) -> None:
"""Erroring the listen request after the ack (abrupt, not graceful) raises SubscriptionLost from iteration."""
proceed = anyio.Event()
async def dropping_listen(
ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams
) -> types.SubscriptionsListenResult:
assert ctx.request_id is not None
await ctx.session.send_notification(
types.SubscriptionsAcknowledgedNotification(
params=types.SubscriptionsAcknowledgedNotificationParams(
notifications=params.notifications,
_meta={SUBSCRIPTION_ID_META_KEY: ctx.request_id},
)
),
related_request_id=ctx.request_id,
)
await proceed.wait()
raise MCPError(types.INTERNAL_ERROR, "stream torn down")
server = Server("subs", on_subscriptions_listen=dropping_listen)
async with connect(server) as client:
with anyio.fail_after(10):
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)
@requirement("protocol:request-id:caller-supplied")
async def test_the_subscription_id_is_the_listen_request_id_the_server_saw(connect: Connect) -> None:
"""The handle's `subscription_id` is the listen request's own JSON-RPC id, known to the caller
while the request is still in flight - the key the server stamps every frame with for demux.
The assertion runs inside the open stream: the ack has arrived but the listen request's
response has not, so the id cannot have come from a response.
"""
bus = InMemorySubscriptionBus()
stock = ListenHandler(bus)
seen: list[types.RequestId] = []
async def recording_listen(
ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams
) -> types.SubscriptionsListenResult:
assert ctx.request_id is not None
seen.append(ctx.request_id)
return await stock(ctx, params)
server = Server("subs", on_subscriptions_listen=recording_listen)
async with connect(server) as client:
with anyio.fail_after(10):
async with client.listen(tools_list_changed=True) as sub: # pragma: no branch
assert seen == [sub.subscription_id]
stock.close()
async for _event in sub:
raise NotImplementedError # unreachable: nothing was published
@requirement("subscriptions:listen:client:concurrent-demux")
@requirement("protocol:request-id:caller-supplied")
async def test_concurrent_listen_streams_each_receive_their_own_ack(connect: Connect) -> None:
"""Two subscriptions opened concurrently each surface the honored filter of their own request:
ack frames route by subscription id, not broadcast to every open route.
The server gates both acks until both listen requests have arrived, so both client routes are
live and unacknowledged when the first ack lands - a client that broadcast subscription frames
would cross-pollute that ack into both handles.
"""
bus = InMemorySubscriptionBus()
stock = ListenHandler(bus)
arrived: list[types.RequestId] = []
both_arrived = anyio.Event()
async def gated_listen(
ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams
) -> types.SubscriptionsListenResult:
assert ctx.request_id is not None
arrived.append(ctx.request_id)
if len(arrived) == 2:
both_arrived.set()
with anyio.fail_after(10):
await both_arrived.wait()
return await stock(ctx, params)
server = Server("subs", on_subscriptions_listen=gated_listen)
honored: dict[str, types.SubscriptionFilter] = {}
async with connect(server) as client:
async def open_tools() -> None:
async with client.listen(tools_list_changed=True) as sub:
honored["tools"] = sub.honored
async def open_prompts() -> None:
async with client.listen(prompts_list_changed=True) as sub:
honored["prompts"] = sub.honored
with anyio.fail_after(10):
async with anyio.create_task_group() as tg: # pragma: no branch
tg.start_soon(open_tools)
tg.start_soon(open_prompts)
assert honored == {
"tools": types.SubscriptionFilter(tools_list_changed=True),
"prompts": types.SubscriptionFilter(prompts_list_changed=True),
}
assert len(set(arrived)) == 2