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,303 @@
|
||||
"""`docs/{handlers,client}/subscriptions.md`: every claim the two pages make, proved against the real SDK."""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
import anyio
|
||||
import mcp_types as types
|
||||
import pytest
|
||||
from trio.testing import MockClock
|
||||
|
||||
from docs_src.subscriptions import (
|
||||
tutorial001,
|
||||
tutorial002,
|
||||
tutorial003,
|
||||
tutorial004_anyio,
|
||||
tutorial004_asyncio,
|
||||
tutorial004_trio,
|
||||
tutorial005,
|
||||
)
|
||||
from mcp import Client
|
||||
from mcp.server.context import ServerRequestContext
|
||||
from mcp.server.lowlevel import Server
|
||||
from mcp.server.subscriptions import SUBSCRIPTION_ID_META_KEY, ListenHandler, ToolsListChanged
|
||||
|
||||
_ReadResource = Callable[
|
||||
[ServerRequestContext[Any], types.ReadResourceRequestParams], Awaitable[types.ReadResourceResult]
|
||||
]
|
||||
|
||||
# See test_index.py for why this is a per-module mark and not a conftest hook.
|
||||
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _module_runner_lease() -> None:
|
||||
"""Opt out of the shared per-module event loop: this module parametrizes `anyio_backend`."""
|
||||
|
||||
|
||||
class _Stream:
|
||||
"""Collects listen-stream notifications and lets tests await arrival counts."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.received: list[types.ServerNotification] = []
|
||||
self._arrival = anyio.Event()
|
||||
|
||||
async def handler(
|
||||
self,
|
||||
message: object,
|
||||
) -> None:
|
||||
# The only messages these connections produce are the stream's frames.
|
||||
assert isinstance(
|
||||
message,
|
||||
types.SubscriptionsAcknowledgedNotification
|
||||
| types.ResourceUpdatedNotification
|
||||
| types.ToolListChangedNotification,
|
||||
), message
|
||||
self.received.append(message)
|
||||
self._arrival.set()
|
||||
self._arrival = anyio.Event()
|
||||
|
||||
async def wait_for(self, count: int) -> None:
|
||||
with anyio.fail_after(5):
|
||||
while len(self.received) < count:
|
||||
await self._arrival.wait()
|
||||
|
||||
|
||||
class _Reads:
|
||||
"""Counts server-side resource reads so a test can await the Nth refetch."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.count = 0
|
||||
self._bump = anyio.Event()
|
||||
|
||||
def counting(self, handler: _ReadResource) -> _ReadResource:
|
||||
async def counted(
|
||||
ctx: ServerRequestContext[Any], params: types.ReadResourceRequestParams
|
||||
) -> types.ReadResourceResult:
|
||||
result = await handler(ctx, params)
|
||||
self.count += 1
|
||||
self._bump.set()
|
||||
self._bump = anyio.Event()
|
||||
return result
|
||||
|
||||
return counted
|
||||
|
||||
async def wait_for(self, count: int) -> None:
|
||||
with anyio.fail_after(5):
|
||||
while self.count < count:
|
||||
await self._bump.wait()
|
||||
|
||||
|
||||
def _listen_request(**fields: Any) -> types.SubscriptionsListenRequest:
|
||||
return types.SubscriptionsListenRequest(
|
||||
params=types.SubscriptionsListenRequestParams(notifications=types.SubscriptionFilter(**fields))
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fresh_server_state() -> Any:
|
||||
"""Each test starts from an all-unfinished board and the base tool set.
|
||||
|
||||
The tutorials mutate module state deliberately (that is what publishes events), so the
|
||||
board contents and the `enable_reports` registration have to be undone between tests.
|
||||
"""
|
||||
boards = {name: dict(tasks) for name, tasks in tutorial001.BOARDS.items()}
|
||||
lowlevel_board = dict(tutorial002.BOARD)
|
||||
tools = dict(tutorial001.mcp._tool_manager._tools) # pyright: ignore[reportPrivateUsage]
|
||||
yield
|
||||
tutorial001.BOARDS.clear()
|
||||
tutorial001.BOARDS.update(boards)
|
||||
tutorial002.BOARD.clear()
|
||||
tutorial002.BOARD.update(lowlevel_board)
|
||||
tutorial001.mcp._tool_manager._tools.clear() # pyright: ignore[reportPrivateUsage]
|
||||
tutorial001.mcp._tool_manager._tools.update(tools) # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
|
||||
async def test_publishes_reach_the_stream_filtered_and_tagged() -> None:
|
||||
"""tutorial001: the full arc - ack first, exact-URI filtering, list_changed
|
||||
leading to a refreshed tool list, and client-side close."""
|
||||
stream = _Stream()
|
||||
async with Client(tutorial001.mcp, mode="2026-07-28", message_handler=stream.handler) as client:
|
||||
async with anyio.create_task_group() as tg:
|
||||
|
||||
async def listen() -> None:
|
||||
await client.session.send_request(
|
||||
_listen_request(tools_list_changed=True, resource_subscriptions=["board://sprint"]),
|
||||
types.SubscriptionsListenResult,
|
||||
)
|
||||
|
||||
tg.start_soon(listen)
|
||||
await stream.wait_for(1)
|
||||
|
||||
ack = stream.received[0]
|
||||
assert isinstance(ack, types.SubscriptionsAcknowledgedNotification)
|
||||
assert ack.params.notifications == types.SubscriptionFilter(
|
||||
tools_list_changed=True, resource_subscriptions=["board://sprint"]
|
||||
)
|
||||
assert ack.params.meta is not None and SUBSCRIPTION_ID_META_KEY in ack.params.meta
|
||||
|
||||
# An edit to a URI the stream did not subscribe to stays silent...
|
||||
await client.call_tool("complete_task", {"board": "backlog", "task": "tidy docs"})
|
||||
# ...and the subscribed URI delivers, tagged with the same subscription id.
|
||||
await client.call_tool("complete_task", {"board": "sprint", "task": "design"})
|
||||
await stream.wait_for(2)
|
||||
updated = stream.received[1]
|
||||
assert isinstance(updated, types.ResourceUpdatedNotification)
|
||||
assert updated.params.uri == "board://sprint"
|
||||
assert updated.params.meta == ack.params.meta
|
||||
|
||||
await client.call_tool("enable_reports", {})
|
||||
await stream.wait_for(3)
|
||||
assert isinstance(stream.received[2], types.ToolListChangedNotification)
|
||||
|
||||
# The client ends the stream by closing it - cancel the parked request.
|
||||
tg.cancel_scope.cancel()
|
||||
|
||||
# The list_changed told us to re-fetch: the new tool is there, and the
|
||||
# session outlives the closed stream.
|
||||
tools = await client.list_tools()
|
||||
assert "sprint_report" in {tool.name for tool in tools.tools}
|
||||
contents = (await client.read_resource("board://sprint")).contents[0]
|
||||
assert isinstance(contents, types.TextResourceContents)
|
||||
assert contents.text == "[x] design\n[ ] build\n[ ] ship"
|
||||
|
||||
|
||||
async def test_publish_with_no_subscribers_is_a_no_op() -> None:
|
||||
"""tutorial001: publishing to an idle server does nothing and breaks nothing."""
|
||||
async with Client(tutorial001.mcp, mode="2026-07-28") as client:
|
||||
result = await client.call_tool("complete_task", {"board": "sprint", "task": "design"})
|
||||
assert result.is_error is not True
|
||||
|
||||
|
||||
async def test_lowlevel_composition_serves_the_same_stream() -> None:
|
||||
"""tutorial002: bus + ListenHandler on the lowlevel Server is the same machinery."""
|
||||
stream = _Stream()
|
||||
async with Client(tutorial002.server, mode="2026-07-28", message_handler=stream.handler) as client:
|
||||
tools = await client.list_tools()
|
||||
assert [tool.name for tool in tools.tools] == ["complete_task"]
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
|
||||
async def listen() -> None:
|
||||
await client.session.send_request(
|
||||
_listen_request(resource_subscriptions=["board://sprint"]),
|
||||
types.SubscriptionsListenResult,
|
||||
)
|
||||
|
||||
tg.start_soon(listen)
|
||||
await stream.wait_for(1)
|
||||
|
||||
await client.call_tool("complete_task", {"task": "design"})
|
||||
await stream.wait_for(2)
|
||||
updated = stream.received[1]
|
||||
assert isinstance(updated, types.ResourceUpdatedNotification)
|
||||
assert updated.params.uri == "board://sprint"
|
||||
|
||||
# The bus you constructed is also the publish surface outside a
|
||||
# request; an unrequested kind never reaches this stream.
|
||||
await tutorial002.bus.publish(ToolsListChanged())
|
||||
await client.call_tool("complete_task", {"task": "build"})
|
||||
await stream.wait_for(3)
|
||||
assert isinstance(stream.received[2], types.ResourceUpdatedNotification)
|
||||
|
||||
tg.cancel_scope.cancel()
|
||||
|
||||
|
||||
async def test_follow_board_prints_the_refetched_board_and_the_new_tool_list(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""tutorial003: each event drives a refetch - the board reprints, and a tools change reprints the tool names."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
async with anyio.create_task_group() as tg:
|
||||
tg.start_soon(tutorial003.follow_board, client)
|
||||
# Let the watcher park on its stream (ack complete) before publishing.
|
||||
await anyio.wait_all_tasks_blocked()
|
||||
await client.call_tool("complete_task", {"board": "sprint", "task": "design"})
|
||||
await anyio.wait_all_tasks_blocked()
|
||||
await client.call_tool("enable_reports", {})
|
||||
await anyio.wait_all_tasks_blocked()
|
||||
tg.cancel_scope.cancel()
|
||||
|
||||
printed = capsys.readouterr().out
|
||||
assert "[x] design\n[ ] build\n[ ] ship" in printed
|
||||
assert "sprint_report" in printed
|
||||
|
||||
|
||||
EMPTY_BOARD = "[ ] design\n[ ] build\n[ ] ship"
|
||||
FINISHED_BOARD = "[x] design\n[x] build\n[x] ship"
|
||||
|
||||
|
||||
def _assert_snapshot_then_current_board(printed: str) -> None:
|
||||
"""The snapshot taken inside the open subscription came first, and the watcher ended up current.
|
||||
|
||||
How many times the watcher printed is deliberately not asserted: identical events that pile up
|
||||
unconsumed coalesce, so a fast main flow can turn three completions into one refetch. What the
|
||||
stream guarantees is that no change after the acknowledgment is missed.
|
||||
"""
|
||||
assert printed.startswith(EMPTY_BOARD), printed
|
||||
assert printed.strip().endswith(FINISHED_BOARD), printed
|
||||
|
||||
|
||||
async def test_the_asyncio_watcher_runs_beside_the_main_flow(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""tutorial004 (asyncio tab): run_sprint opens the subscription, snapshots the board, then a watcher
|
||||
task reprints it while the main flow keeps calling tools.
|
||||
|
||||
The example connects over HTTP; the in-memory client here is the maintainer-side stand-in."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
await tutorial004_asyncio.run_sprint(client)
|
||||
_assert_snapshot_then_current_board(capsys.readouterr().out)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("anyio_backend", [pytest.param("trio", id="trio")])
|
||||
async def test_the_trio_watcher_runs_beside_the_main_flow(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""tutorial004 (trio tab): the same shape as the asyncio tab, with a nursery owning the watcher."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
await tutorial004_trio.run_sprint(client)
|
||||
_assert_snapshot_then_current_board(capsys.readouterr().out)
|
||||
|
||||
|
||||
async def test_the_anyio_watcher_runs_beside_the_main_flow(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""tutorial004 (anyio tab): the same shape again, with a task group owning the watcher."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
await tutorial004_anyio.run_sprint(client)
|
||||
_assert_snapshot_then_current_board(capsys.readouterr().out)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"anyio_backend",
|
||||
[pytest.param(("trio", {"clock": MockClock(autojump_threshold=0)}), id="trio-mockclock")],
|
||||
)
|
||||
async def test_the_follower_re_listens_after_the_stream_ends(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""tutorial005: a graceful server close ends one stream; the loop backs off, re-listens, and refetches.
|
||||
|
||||
Runs on trio's autojumping MockClock so the loop's backoff sleep takes no wall-clock time.
|
||||
"""
|
||||
reads = _Reads()
|
||||
handler = ListenHandler(tutorial002.bus)
|
||||
server = Server(
|
||||
"sprint-board",
|
||||
on_read_resource=reads.counting(tutorial002.read_resource),
|
||||
on_list_tools=tutorial002.list_tools,
|
||||
on_call_tool=tutorial002.call_tool,
|
||||
on_subscriptions_listen=handler,
|
||||
)
|
||||
|
||||
async with Client(server) as client:
|
||||
async with anyio.create_task_group() as tg:
|
||||
tg.start_soon(tutorial005.keep_following, client)
|
||||
# First stream: the entry refetch reads the board, then an event reads it again.
|
||||
await reads.wait_for(1)
|
||||
await client.call_tool("complete_task", {"task": "design"})
|
||||
await reads.wait_for(2)
|
||||
|
||||
# End that stream gracefully. The loop backs off (the mock clock jumps the
|
||||
# sleep), re-listens, and refetches on entry: that is the third read.
|
||||
handler.close()
|
||||
await reads.wait_for(3)
|
||||
await client.call_tool("complete_task", {"task": "build"})
|
||||
await reads.wait_for(4)
|
||||
tg.cancel_scope.cancel()
|
||||
|
||||
printed = capsys.readouterr().out
|
||||
assert "[x] design\n[ ] build" in printed # first stream, after design
|
||||
assert "[x] design\n[x] build" in printed # second stream, after build
|
||||
Reference in New Issue
Block a user