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
667 lines
30 KiB
Python
667 lines
30 KiB
Python
"""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"
|