chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user