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
474 lines
17 KiB
Python
474 lines
17 KiB
Python
"""Tests for `subscriptions/listen` serving (mcp.server.subscriptions)."""
|
|
|
|
from collections.abc import Callable
|
|
from typing import Any, cast
|
|
|
|
import anyio
|
|
import pytest
|
|
from mcp_types import (
|
|
INVALID_REQUEST,
|
|
PromptListChangedNotification,
|
|
RequestId,
|
|
ResourceListChangedNotification,
|
|
ResourceUpdatedNotification,
|
|
ServerNotification,
|
|
SubscriptionFilter,
|
|
SubscriptionsAcknowledgedNotification,
|
|
SubscriptionsListenRequestParams,
|
|
SubscriptionsListenResult,
|
|
ToolListChangedNotification,
|
|
)
|
|
|
|
from mcp.server.context import ServerRequestContext
|
|
from mcp.server.session import ServerSession
|
|
from mcp.server.subscriptions import (
|
|
SUBSCRIPTION_ID_META_KEY,
|
|
InMemorySubscriptionBus,
|
|
ListenHandler,
|
|
PromptsListChanged,
|
|
ResourcesListChanged,
|
|
ResourceUpdated,
|
|
ServerEvent,
|
|
ToolsListChanged,
|
|
)
|
|
from mcp.shared.exceptions import MCPError
|
|
|
|
|
|
class _RecordingSession:
|
|
"""Stands in for `ServerSession`: records sent notifications and wakes waiters."""
|
|
|
|
def __init__(self) -> None:
|
|
self.sent: list[tuple[ServerNotification, RequestId | None]] = []
|
|
self._arrival = anyio.Event()
|
|
|
|
async def send_notification(
|
|
self, notification: ServerNotification, related_request_id: RequestId | None = None
|
|
) -> None:
|
|
self.sent.append((notification, related_request_id))
|
|
self._arrival.set()
|
|
self._arrival = anyio.Event()
|
|
|
|
async def wait_for(self, count: int) -> None:
|
|
with anyio.fail_after(5):
|
|
while len(self.sent) < count:
|
|
await self._arrival.wait()
|
|
|
|
|
|
def _ctx(session: _RecordingSession, request_id: RequestId | None = 7) -> ServerRequestContext[Any, Any]:
|
|
return ServerRequestContext(
|
|
session=cast(ServerSession, session),
|
|
lifespan_context={},
|
|
protocol_version="2026-07-28",
|
|
method="subscriptions/listen",
|
|
request_id=request_id,
|
|
)
|
|
|
|
|
|
def _params(**fields: Any) -> SubscriptionsListenRequestParams:
|
|
return SubscriptionsListenRequestParams(notifications=SubscriptionFilter(**fields))
|
|
|
|
|
|
class _SpyBus(InMemorySubscriptionBus):
|
|
"""Counts unsubscribe calls so tests can assert stream cleanup."""
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.unsubscribed = 0
|
|
|
|
def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]:
|
|
unsubscribe = super().subscribe(listener)
|
|
|
|
def counting_unsubscribe() -> None:
|
|
self.unsubscribed += 1
|
|
unsubscribe()
|
|
|
|
return counting_unsubscribe
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_in_memory_bus_fans_out_until_unsubscribed() -> None:
|
|
"""SDK-defined bus contract: fan-out to all listeners; unsubscribe is idempotent."""
|
|
bus = InMemorySubscriptionBus()
|
|
seen_a: list[ServerEvent] = []
|
|
seen_b: list[ServerEvent] = []
|
|
unsubscribe_a = bus.subscribe(seen_a.append)
|
|
bus.subscribe(seen_b.append)
|
|
|
|
await bus.publish(ToolsListChanged())
|
|
assert seen_a == [ToolsListChanged()]
|
|
assert seen_b == [ToolsListChanged()]
|
|
|
|
unsubscribe_a()
|
|
unsubscribe_a() # idempotent
|
|
await bus.publish(PromptsListChanged())
|
|
assert seen_a == [ToolsListChanged()]
|
|
assert seen_b == [ToolsListChanged(), PromptsListChanged()]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_in_memory_bus_keeps_equal_callables_distinct() -> None:
|
|
"""SDK-defined: registering the same callable twice yields two registrations,
|
|
and each unsubscribe detaches exactly one (bound methods compare equal)."""
|
|
bus = InMemorySubscriptionBus()
|
|
seen: list[ServerEvent] = []
|
|
first = bus.subscribe(seen.append)
|
|
bus.subscribe(seen.append)
|
|
|
|
await bus.publish(ToolsListChanged())
|
|
assert len(seen) == 2
|
|
|
|
first()
|
|
await bus.publish(ToolsListChanged())
|
|
assert len(seen) == 3
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_ack_first_honored_subset_and_stamped_graceful_result() -> None:
|
|
"""Spec-mandated: the ack is the first frame, echoes the honored subset, and
|
|
every frame (graceful result included) carries the subscription-id tag."""
|
|
bus = _SpyBus()
|
|
handler = ListenHandler(bus)
|
|
session = _RecordingSession()
|
|
results: list[SubscriptionsListenResult] = []
|
|
|
|
async with anyio.create_task_group() as tg:
|
|
|
|
async def run() -> None:
|
|
results.append(
|
|
await handler(
|
|
_ctx(session),
|
|
_params(tools_list_changed=True, prompts_list_changed=False, resource_subscriptions=["r://a"]),
|
|
)
|
|
)
|
|
|
|
tg.start_soon(run)
|
|
await session.wait_for(1)
|
|
|
|
ack, related = session.sent[0]
|
|
assert isinstance(ack, SubscriptionsAcknowledgedNotification)
|
|
assert related == 7
|
|
# Honored subset: requested-false and absent kinds are omitted, not echoed.
|
|
assert ack.params.notifications == SubscriptionFilter(tools_list_changed=True, resource_subscriptions=["r://a"])
|
|
assert ack.params.meta == {SUBSCRIPTION_ID_META_KEY: 7}
|
|
|
|
await bus.publish(ToolsListChanged())
|
|
await session.wait_for(2)
|
|
event, related = session.sent[1]
|
|
assert isinstance(event, ToolListChangedNotification)
|
|
assert related == 7
|
|
assert event.params is not None and event.params.meta == {SUBSCRIPTION_ID_META_KEY: 7}
|
|
|
|
handler.close()
|
|
|
|
assert results[0].meta == {SUBSCRIPTION_ID_META_KEY: 7}
|
|
assert bus.unsubscribed == 1 # the stream unsubscribed on the way out
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_only_requested_event_kinds_are_delivered() -> None:
|
|
"""Spec-mandated: the server never sends a notification type (or resource URI)
|
|
the client did not request on this stream."""
|
|
bus = InMemorySubscriptionBus()
|
|
handler = ListenHandler(bus)
|
|
session = _RecordingSession()
|
|
|
|
async with anyio.create_task_group() as tg:
|
|
|
|
async def run() -> None:
|
|
await handler(
|
|
_ctx(session),
|
|
_params(prompts_list_changed=True, resources_list_changed=True, resource_subscriptions=["r://a"]),
|
|
)
|
|
|
|
tg.start_soon(run)
|
|
await session.wait_for(1)
|
|
|
|
await bus.publish(ToolsListChanged()) # not requested
|
|
await bus.publish(ResourceUpdated(uri="r://other")) # URI not subscribed
|
|
await bus.publish(PromptsListChanged())
|
|
await bus.publish(ResourcesListChanged())
|
|
await bus.publish(ResourceUpdated(uri="r://a"))
|
|
await session.wait_for(4)
|
|
handler.close()
|
|
|
|
delivered = [notification for notification, _ in session.sent[1:]]
|
|
assert isinstance(delivered[0], PromptListChangedNotification)
|
|
assert isinstance(delivered[1], ResourceListChangedNotification)
|
|
assert isinstance(delivered[2], ResourceUpdatedNotification)
|
|
assert delivered[2].params.uri == "r://a"
|
|
assert delivered[2].params.meta == {SUBSCRIPTION_ID_META_KEY: 7}
|
|
assert len(delivered) == 3
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_empty_filter_honors_nothing_and_delivers_nothing() -> None:
|
|
"""SDK-defined: falsy flags and an empty URI list are dropped from the ack
|
|
rather than echoed, and such a stream delivers nothing."""
|
|
bus = InMemorySubscriptionBus()
|
|
handler = ListenHandler(bus)
|
|
session = _RecordingSession()
|
|
|
|
async with anyio.create_task_group() as tg:
|
|
|
|
async def run() -> None:
|
|
await handler(_ctx(session), _params(tools_list_changed=False, resource_subscriptions=[]))
|
|
|
|
tg.start_soon(run)
|
|
await session.wait_for(1)
|
|
|
|
ack, _ = session.sent[0]
|
|
assert isinstance(ack, SubscriptionsAcknowledgedNotification)
|
|
assert ack.params.notifications == SubscriptionFilter()
|
|
|
|
for event in (ToolsListChanged(), PromptsListChanged(), ResourcesListChanged(), ResourceUpdated(uri="r://a")):
|
|
await bus.publish(event)
|
|
handler.close()
|
|
|
|
assert len(session.sent) == 1 # the ack only
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_publish_after_close_is_dropped() -> None:
|
|
"""SDK-defined: an event racing `close()` while the stream unwinds is dropped."""
|
|
bus = InMemorySubscriptionBus()
|
|
handler = ListenHandler(bus)
|
|
session = _RecordingSession()
|
|
|
|
async with anyio.create_task_group() as tg:
|
|
|
|
async def run() -> None:
|
|
await handler(_ctx(session), _params(tools_list_changed=True))
|
|
|
|
tg.start_soon(run)
|
|
await session.wait_for(1)
|
|
|
|
handler.close()
|
|
# The handler task has not resumed yet, so the listener is still
|
|
# subscribed but its stream is closed: the event is dropped.
|
|
await bus.publish(ToolsListChanged())
|
|
|
|
assert len(session.sent) == 1
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_event_published_during_ack_send_is_delivered_after_the_ack() -> None:
|
|
"""SDK-defined: the stream subscribes before sending the ack, so an event
|
|
published while the ack write is suspended is buffered and delivered after
|
|
it - never lost, and never ahead of the ack frame."""
|
|
bus = InMemorySubscriptionBus()
|
|
handler = ListenHandler(bus)
|
|
|
|
class _PublishDuringAck(_RecordingSession):
|
|
async def send_notification(
|
|
self, notification: ServerNotification, related_request_id: RequestId | None = None
|
|
) -> None:
|
|
if not self.sent:
|
|
# Publish while the handler is still inside the ack send.
|
|
await bus.publish(ToolsListChanged())
|
|
await super().send_notification(notification, related_request_id)
|
|
|
|
session = _PublishDuringAck()
|
|
|
|
async with anyio.create_task_group() as tg:
|
|
|
|
async def run() -> None:
|
|
await handler(_ctx(session), _params(tools_list_changed=True))
|
|
|
|
tg.start_soon(run)
|
|
await session.wait_for(2)
|
|
handler.close()
|
|
|
|
assert isinstance(session.sent[0][0], SubscriptionsAcknowledgedNotification)
|
|
assert isinstance(session.sent[1][0], ToolListChangedNotification)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_listen_requires_a_request_id() -> None:
|
|
"""SDK-defined: a context without a request id cannot open a stream."""
|
|
handler = ListenHandler(InMemorySubscriptionBus())
|
|
|
|
with pytest.raises(MCPError) as exc_info:
|
|
await handler(_ctx(_RecordingSession(), request_id=None), _params())
|
|
assert exc_info.value.error.code == INVALID_REQUEST
|
|
|
|
|
|
def test_close_without_open_streams_is_a_no_op() -> None:
|
|
"""SDK-defined: `close()` with nothing open does nothing."""
|
|
ListenHandler(InMemorySubscriptionBus()).close()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_raising_listener_is_isolated_from_others() -> None:
|
|
"""SDK-defined: one raising listener is logged and skipped; later listeners
|
|
and the publishing handler are unaffected."""
|
|
bus = InMemorySubscriptionBus()
|
|
|
|
def bad(event: ServerEvent) -> None:
|
|
raise RuntimeError("boom")
|
|
|
|
seen: list[ServerEvent] = []
|
|
bus.subscribe(bad)
|
|
bus.subscribe(seen.append)
|
|
|
|
await bus.publish(ToolsListChanged())
|
|
assert seen == [ToolsListChanged()]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_raising_unsubscribe_does_not_skip_stream_cleanup() -> None:
|
|
"""SDK-defined: a custom bus whose unsubscribe callable raises is logged
|
|
and isolated - the stream still releases its subscription slot, closes its
|
|
buffers, and returns the graceful result."""
|
|
|
|
class _RaisingUnsubscribeBus(InMemorySubscriptionBus):
|
|
def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]:
|
|
super().subscribe(listener)
|
|
|
|
def unsubscribe() -> None:
|
|
raise RuntimeError("boom")
|
|
|
|
return unsubscribe
|
|
|
|
handler = ListenHandler(_RaisingUnsubscribeBus(), max_subscriptions=1)
|
|
session = _RecordingSession()
|
|
results: list[SubscriptionsListenResult] = []
|
|
|
|
async with anyio.create_task_group() as tg:
|
|
|
|
async def run() -> None:
|
|
results.append(await handler(_ctx(session), _params(tools_list_changed=True)))
|
|
|
|
tg.start_soon(run)
|
|
await session.wait_for(1)
|
|
handler.close()
|
|
|
|
assert results[0].meta == {SUBSCRIPTION_ID_META_KEY: 7} # the graceful result still returned
|
|
|
|
# The slot was released despite the raising unsubscribe: a second listen
|
|
# is accepted at the cap of one.
|
|
late_session = _RecordingSession()
|
|
late_results: list[SubscriptionsListenResult] = []
|
|
|
|
async with anyio.create_task_group() as tg:
|
|
|
|
async def run_late() -> None:
|
|
late_results.append(await handler(_ctx(late_session, request_id=8), _params(tools_list_changed=True)))
|
|
|
|
tg.start_soon(run_late)
|
|
await late_session.wait_for(1)
|
|
handler.close()
|
|
|
|
assert late_results[0].meta == {SUBSCRIPTION_ID_META_KEY: 8}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_subscription_limit_rejects_further_streams_pre_ack() -> None:
|
|
"""SDK-defined cap (mirrors the TypeScript SDK): past `max_subscriptions`,
|
|
a listen request is rejected before any ack frame."""
|
|
handler = ListenHandler(InMemorySubscriptionBus(), max_subscriptions=1)
|
|
session = _RecordingSession()
|
|
|
|
async with anyio.create_task_group() as tg:
|
|
|
|
async def run() -> None:
|
|
await handler(_ctx(session), _params(tools_list_changed=True))
|
|
|
|
tg.start_soon(run)
|
|
await session.wait_for(1)
|
|
|
|
rejected_session = _RecordingSession()
|
|
with pytest.raises(MCPError) as exc_info:
|
|
await handler(_ctx(rejected_session, request_id=8), _params())
|
|
assert exc_info.value.error.message == "Subscription limit reached"
|
|
assert rejected_session.sent == []
|
|
|
|
handler.close()
|
|
|
|
|
|
class _GatedSession(_RecordingSession):
|
|
"""Lets the ack through, then wedges event sends until released - a client
|
|
that stopped reading the transport."""
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.wedged = anyio.Event()
|
|
self.release = anyio.Event()
|
|
|
|
async def send_notification(
|
|
self, notification: ServerNotification, related_request_id: RequestId | None = None
|
|
) -> None:
|
|
if self.sent: # the ack is the first frame; only event sends wedge
|
|
self.wedged.set()
|
|
await self.release.wait()
|
|
await super().send_notification(notification, related_request_id)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_backlog_overflow_ends_the_stream_and_frees_its_slot() -> None:
|
|
"""SDK-defined: a stream whose client stopped reading is ended at
|
|
`max_buffered_events` rather than buffering forever. The subscription slot
|
|
frees at overflow time - the stream's own cleanup may be wedged in a
|
|
transport write nothing can wake - and the backlog still drains into the
|
|
stamped graceful result once that write completes."""
|
|
bus = InMemorySubscriptionBus()
|
|
handler = ListenHandler(bus, max_subscriptions=1, max_buffered_events=1)
|
|
session = _GatedSession()
|
|
results: list[SubscriptionsListenResult] = []
|
|
late_session = _RecordingSession()
|
|
late_results: list[SubscriptionsListenResult] = []
|
|
|
|
async with anyio.create_task_group() as tg:
|
|
|
|
async def run() -> None:
|
|
results.append(await handler(_ctx(session), _params(tools_list_changed=True)))
|
|
|
|
tg.start_soon(run)
|
|
await session.wait_for(1)
|
|
|
|
await bus.publish(ToolsListChanged()) # consumed, then wedged mid-send
|
|
with anyio.fail_after(5):
|
|
await session.wedged.wait()
|
|
await bus.publish(ToolsListChanged()) # fills the one-slot buffer
|
|
await bus.publish(ToolsListChanged()) # overflows: the stream is ended
|
|
|
|
async def run_late() -> None:
|
|
late_results.append(await handler(_ctx(late_session, request_id=8), _params(tools_list_changed=True)))
|
|
|
|
# The ended stream's slot is free immediately - a new listen does not
|
|
# wait for the wedged write to die with its connection.
|
|
tg.start_soon(run_late)
|
|
await late_session.wait_for(1)
|
|
|
|
session.release.set()
|
|
handler.close()
|
|
|
|
delivered = [notification for notification, _ in session.sent[1:]]
|
|
assert len(delivered) == 2 # the wedged event and the buffered one still drained
|
|
assert results[0].meta == {SUBSCRIPTION_ID_META_KEY: 7}
|
|
assert late_results[0].meta == {SUBSCRIPTION_ID_META_KEY: 8}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_same_task_publish_burst_does_not_overflow_a_healthy_stream() -> None:
|
|
"""SDK-defined: `publish` ends with a checkpoint, so a burst of events from
|
|
one task (no yields of its own) lets a reading stream drain between
|
|
publishes instead of deterministically overflowing the buffer."""
|
|
bus = InMemorySubscriptionBus()
|
|
handler = ListenHandler(bus, max_buffered_events=99)
|
|
session = _RecordingSession()
|
|
|
|
async with anyio.create_task_group() as tg:
|
|
|
|
async def run() -> None:
|
|
await handler(_ctx(session), _params(tools_list_changed=True))
|
|
|
|
tg.start_soon(run)
|
|
await session.wait_for(1)
|
|
|
|
for _ in range(100):
|
|
await bus.publish(ToolsListChanged())
|
|
await session.wait_for(101)
|
|
handler.close()
|
|
|
|
assert len(session.sent) == 101 # the ack plus every event in the burst
|