Skip to content

Commit c4b5e1b

Browse files
committed
Move the docs watch-loop example into docs_src and prove it
1 parent ebfdc0d commit c4b5e1b

3 files changed

Lines changed: 137 additions & 16 deletions

File tree

docs/handlers/subscriptions.md

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -98,20 +98,8 @@ Consuming a subscription is one context manager:
9898
* Leaving the block ends the subscription, with the transport's own spelling: over streamable HTTP the request's response stream is closed (that is the 2026 cancellation signal), on stream transports `notifications/cancelled` is sent.
9999
* The stream's two endings are control flow. The server closing gracefully simply ends the `async for`; an abrupt drop raises `SubscriptionLost`. The distinction is diagnostic — a clean end versus a connection worth suspecting — not a difference in what to do next: either way the stream is gone, nothing is replayed, and a watcher that still cares re-listens and refetches. Servers close streams gracefully for their own reasons — shutdown, or shedding a subscriber whose backlog grew past bounds, as this SDK's `ListenHandler` does — so a graceful close is not a signal to stop watching:
100100

101-
```python
102-
async def watch(client: Client, uri: str) -> None:
103-
while True:
104-
try:
105-
async with client.listen(resource_subscriptions=[uri]) as sub:
106-
await client.read_resource(uri) # refetch: no replay across streams
107-
async for _event in sub:
108-
await client.read_resource(uri)
109-
except SubscriptionLost:
110-
pass
111-
# Graceful close or abrupt drop, the stream is gone either way. Back
112-
# off before re-listening - a graceful close may be the server
113-
# shedding load, and reconnecting instantly recreates the pressure.
114-
await anyio.sleep(1)
101+
```python title="watch.py" hl_lines="15 16"
102+
--8<-- "docs_src/subscriptions/tutorial004.py"
115103
```
116104

117105
* Checking the acknowledgment (the spec's client SHOULD) is reading `sub.honored` — the kinds this stream will actually receive. A server may narrow the filter it agrees to honor (a multi-tenant server declining a URI, say), and `sub.honored` is that delivery contract — it says nothing about what exists in the catalog. Multiple subscriptions may be open concurrently; each demultiplexes by its own subscription id.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import anyio
2+
3+
from mcp import Client
4+
from mcp.client.subscriptions import SubscriptionLost
5+
6+
7+
async def watch(client: Client, uri: str) -> None:
8+
"""Keep one resource fresh for as long as the client lives."""
9+
while True:
10+
try:
11+
async with client.listen(resource_subscriptions=[uri]) as sub:
12+
await client.read_resource(uri) # refetch: no replay across streams
13+
async for _event in sub:
14+
await client.read_resource(uri)
15+
except SubscriptionLost:
16+
pass
17+
# Graceful close or abrupt drop, the stream is gone either way. Back
18+
# off before re-listening - a graceful close may be the server
19+
# shedding load, and reconnecting instantly recreates the pressure.
20+
await anyio.sleep(1)

tests/docs_src/test_subscriptions.py

Lines changed: 115 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,19 @@
55
import anyio
66
import mcp_types as types
77
import pytest
8+
from trio.testing import MockClock
89

9-
from docs_src.subscriptions import tutorial001, tutorial002, tutorial003
10+
from docs_src.subscriptions import tutorial001, tutorial002, tutorial003, tutorial004
1011
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+
)
1221

1322
# See test_index.py for why this is a per-module mark and not a conftest hook.
1423
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
@@ -153,3 +162,107 @@ async def watch() -> None:
153162
async with Client(tutorial001.mcp) as editor: # pragma: no branch
154163
await editor.call_tool("edit_note", {"name": "todo", "text": "water plants"})
155164
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

Comments
 (0)