Files
modelcontextprotocol--pytho…/tests/client/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

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"