|
5 | 5 | import anyio |
6 | 6 | import mcp_types as types |
7 | 7 | import pytest |
| 8 | +from trio.testing import MockClock |
8 | 9 |
|
9 | | -from docs_src.subscriptions import tutorial001, tutorial002, tutorial003 |
| 10 | +from docs_src.subscriptions import tutorial001, tutorial002, tutorial003, tutorial004 |
10 | 11 | from mcp import Client |
11 | | -from mcp.server.subscriptions import SUBSCRIPTION_ID_META_KEY, ToolsListChanged |
| 12 | +from mcp.server.context import ServerRequestContext |
| 13 | +from mcp.server.lowlevel import Server |
| 14 | +from mcp.server.subscriptions import ( |
| 15 | + SUBSCRIPTION_ID_META_KEY, |
| 16 | + InMemorySubscriptionBus, |
| 17 | + ListenHandler, |
| 18 | + ResourceUpdated, |
| 19 | + ToolsListChanged, |
| 20 | +) |
12 | 21 |
|
13 | 22 | # See test_index.py for why this is a per-module mark and not a conftest hook. |
14 | 23 | pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] |
@@ -153,3 +162,107 @@ async def watch() -> None: |
153 | 162 | async with Client(tutorial001.mcp) as editor: # pragma: no branch |
154 | 163 | await editor.call_tool("edit_note", {"name": "todo", "text": "water plants"}) |
155 | 164 | assert results == ["changed: note://todo"] |
| 165 | + |
| 166 | + |
| 167 | +class _Reads: |
| 168 | + """Counts server-side resource reads and lets tests await a count.""" |
| 169 | + |
| 170 | + def __init__(self) -> None: |
| 171 | + self.count = 0 |
| 172 | + self._bump = anyio.Event() |
| 173 | + |
| 174 | + def hit(self) -> None: |
| 175 | + self.count += 1 |
| 176 | + self._bump.set() |
| 177 | + self._bump = anyio.Event() |
| 178 | + |
| 179 | + async def wait_for(self, count: int) -> None: |
| 180 | + with anyio.fail_after(5): |
| 181 | + while self.count < count: |
| 182 | + await self._bump.wait() |
| 183 | + |
| 184 | + |
| 185 | +@pytest.mark.parametrize( |
| 186 | + "anyio_backend", |
| 187 | + [pytest.param(("trio", {"clock": MockClock(autojump_threshold=0)}), id="trio-mockclock")], |
| 188 | +) |
| 189 | +async def test_watcher_re_listens_after_both_endings() -> None: |
| 190 | + """tutorial004: watch() refetches on entry and per event, and re-listens after |
| 191 | + a graceful server close and after `SubscriptionLost`. |
| 192 | +
|
| 193 | + Runs on trio's autojumping MockClock so the loop's backoff sleep takes no wall-clock time.""" |
| 194 | + DROP_SCHEMA: dict[str, Any] = { |
| 195 | + "type": "object", |
| 196 | + "properties": {"subscription_id": {"type": "string"}}, |
| 197 | + "required": ["subscription_id"], |
| 198 | + } |
| 199 | + bus = InMemorySubscriptionBus() |
| 200 | + handler = ListenHandler(bus) |
| 201 | + reads = _Reads() |
| 202 | + stream = _Stream() |
| 203 | + |
| 204 | + async def read_resource( |
| 205 | + ctx: ServerRequestContext[Any], params: types.ReadResourceRequestParams |
| 206 | + ) -> types.ReadResourceResult: |
| 207 | + reads.hit() |
| 208 | + return types.ReadResourceResult(contents=[types.TextResourceContents(uri=params.uri, text="fresh")]) |
| 209 | + |
| 210 | + async def list_tools( |
| 211 | + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None |
| 212 | + ) -> types.ListToolsResult: |
| 213 | + return types.ListToolsResult( |
| 214 | + tools=[types.Tool(name="drop", description="End a subscription abruptly.", input_schema=DROP_SCHEMA)] |
| 215 | + ) |
| 216 | + |
| 217 | + async def drop_stream(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: |
| 218 | + # The abrupt ending: the server cancels the named subscription without a |
| 219 | + # graceful close. Sent request-scoped: the 2026 wire has no standalone stream. |
| 220 | + subscription_id = (params.arguments or {})["subscription_id"] |
| 221 | + await ctx.session.send_notification( |
| 222 | + types.CancelledNotification(params=types.CancelledNotificationParams(request_id=subscription_id)), |
| 223 | + related_request_id=ctx.request_id, |
| 224 | + ) |
| 225 | + return types.CallToolResult(content=[]) |
| 226 | + |
| 227 | + server = Server( |
| 228 | + "watched", |
| 229 | + on_read_resource=read_resource, |
| 230 | + on_list_tools=list_tools, |
| 231 | + on_call_tool=drop_stream, |
| 232 | + on_subscriptions_listen=handler, |
| 233 | + ) |
| 234 | + |
| 235 | + def teed_subscription_id(index: int) -> Any: |
| 236 | + updated = stream.received[index] |
| 237 | + assert isinstance(updated, types.ResourceUpdatedNotification) |
| 238 | + assert updated.params.meta is not None |
| 239 | + return updated.params.meta[SUBSCRIPTION_ID_META_KEY] |
| 240 | + |
| 241 | + async with Client(server, mode="2026-07-28", message_handler=stream.handler) as client: |
| 242 | + async with anyio.create_task_group() as tg: |
| 243 | + tg.start_soon(tutorial004.watch, client, "note://todo") |
| 244 | + |
| 245 | + # Stream 1: the entry refetch proves the ack arrived; an event drives one more refetch. |
| 246 | + await reads.wait_for(1) |
| 247 | + await bus.publish(ResourceUpdated(uri="note://todo")) |
| 248 | + await reads.wait_for(2) |
| 249 | + await stream.wait_for(1) |
| 250 | + |
| 251 | + # Graceful close: the watcher backs off, re-listens, and refetches. |
| 252 | + handler.close() |
| 253 | + await reads.wait_for(3) |
| 254 | + await bus.publish(ResourceUpdated(uri="note://todo")) |
| 255 | + await reads.wait_for(4) |
| 256 | + await stream.wait_for(2) |
| 257 | + second_id = teed_subscription_id(1) |
| 258 | + assert second_id != teed_subscription_id(0) |
| 259 | + |
| 260 | + # Abrupt ending: the watcher swallows SubscriptionLost and re-listens again. |
| 261 | + await client.call_tool("drop", {"subscription_id": second_id}) |
| 262 | + await reads.wait_for(5) |
| 263 | + await bus.publish(ResourceUpdated(uri="note://todo")) |
| 264 | + await reads.wait_for(6) |
| 265 | + await stream.wait_for(3) |
| 266 | + assert teed_subscription_id(2) != second_id |
| 267 | + |
| 268 | + tg.cancel_scope.cancel() |
0 commit comments