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
143 lines
6.0 KiB
Python
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
|