Files
modelcontextprotocol--pytho…/tests/interaction/lowlevel/test_subscriptions.py
T
wehub-resource-sync 49b9bb6724
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 12:10:27 +08:00

143 lines
6.0 KiB
Python

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