Skip to content

Commit 4f8fb2d

Browse files
committed
Rewrite the subscriptions docs around a worked example
Replace the notes domain with a task board whose text rendering makes 'the event is a cue, refetch to see what changed' visible in the output. Add a client-side walkthrough: follow the stream and act on events, run a watcher beside the main flow (asyncio, trio and anyio tabs), and keep it alive across stream endings. Corrections found while writing: - sub.honored echoes every kind MCPServer was asked for, so the page no longer claims an unsupported kind is dropped from it. - The lowlevel example published resource updates for a resource it never served, and did not keep a handle on its ListenHandler. - The watcher tabs previously blocked on an Event the watcher set after subscribing, which deadlocked if listen() raised. Opening the subscription in main() and handing the handle to the task removes the deadlock and the machinery both. - The client admits any resource update once URI subscriptions are honored, so the page tells readers to dispatch on event.uri. - Unconsumed events coalesce only when identical, and a consumer that falls 1024 events behind loses the subscription. Both are now stated. Also: no em-dashes, shorter paragraphs, and a heading for the server half, to match the other handler pages.
1 parent f5ae856 commit 4f8fb2d

11 files changed

Lines changed: 463 additions & 240 deletions

File tree

docs/handlers/subscriptions.md

Lines changed: 156 additions & 63 deletions
Large diffs are not rendered by default.

docs/migration.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2065,8 +2065,8 @@ One behavioral caveat when moving progress-reporting handlers onto `Client(serve
20652065
They keep working against 2025-era servers; a 2026-07-28 server answers them with `-32601` (method not found). Migrate to the listen driver:
20662066

20672067
```python
2068-
async with client.listen(resource_subscriptions=["note://todo"]) as sub:
2069-
async for event in sub: # ResourceUpdated(uri="note://todo")
2068+
async with client.listen(resource_subscriptions=["board://sprint"]) as sub:
2069+
async for event in sub: # ResourceUpdated(uri="board://sprint")
20702070
...
20712071
```
20722072

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,33 @@
11
from mcp.server.mcpserver import Context, MCPServer
22

3-
mcp = MCPServer("Notebook")
3+
mcp = MCPServer("Sprint Board")
44

5-
NOTES = {"todo": "buy milk", "journal": "day one"}
5+
BOARDS = {
6+
"sprint": {"design": False, "build": False, "ship": False},
7+
"backlog": {"tidy docs": False},
8+
}
69

710

8-
@mcp.resource("note://{name}")
9-
def note(name: str) -> str:
10-
return NOTES[name]
11+
@mcp.resource("board://{name}")
12+
def board(name: str) -> str:
13+
tasks = BOARDS[name]
14+
return "\n".join(f"[{'x' if done else ' '}] {task}" for task, done in tasks.items())
1115

1216

1317
@mcp.tool()
14-
async def edit_note(name: str, text: str, ctx: Context) -> str:
15-
NOTES[name] = text
16-
await ctx.notify_resource_updated(f"note://{name}")
17-
return "saved"
18+
async def complete_task(board: str, task: str, ctx: Context) -> str:
19+
BOARDS[board][task] = True
20+
await ctx.notify_resource_updated(f"board://{board}")
21+
return f"{task}: done"
1822

1923

20-
def search(query: str) -> list[str]:
21-
return [name for name, text in NOTES.items() if query in text]
24+
def sprint_report() -> str:
25+
done = sum(done for tasks in BOARDS.values() for done in tasks.values())
26+
return f"{done} task(s) done"
2227

2328

2429
@mcp.tool()
25-
async def enable_search(ctx: Context) -> str:
26-
mcp.add_tool(search)
30+
async def enable_reports(ctx: Context) -> str:
31+
mcp.add_tool(sprint_report)
2732
await ctx.notify_tools_changed()
28-
return "search is live"
33+
return "reporting is live"

docs_src/subscriptions/tutorial002.py

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,34 +7,43 @@
77
from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler, ResourceUpdated
88

99
bus = InMemorySubscriptionBus()
10+
listen_handler = ListenHandler(bus)
1011

11-
NOTES = {"todo": "buy milk"}
12+
BOARD = {"design": False, "build": False}
1213

13-
EDIT_NOTE_SCHEMA: dict[str, Any] = {
14+
COMPLETE_TASK_SCHEMA: dict[str, Any] = {
1415
"type": "object",
15-
"properties": {"name": {"type": "string"}, "text": {"type": "string"}},
16-
"required": ["name", "text"],
16+
"properties": {"task": {"type": "string"}},
17+
"required": ["task"],
1718
}
1819

1920

21+
async def read_resource(
22+
ctx: ServerRequestContext[Any], params: types.ReadResourceRequestParams
23+
) -> types.ReadResourceResult:
24+
board = "\n".join(f"[{'x' if done else ' '}] {task}" for task, done in BOARD.items())
25+
return types.ReadResourceResult(contents=[types.TextResourceContents(uri=params.uri, text=board)])
26+
27+
2028
async def list_tools(
2129
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
2230
) -> types.ListToolsResult:
2331
return types.ListToolsResult(
24-
tools=[types.Tool(name="edit_note", description="Replace a note's text.", input_schema=EDIT_NOTE_SCHEMA)]
32+
tools=[types.Tool(name="complete_task", description="Mark a task done.", input_schema=COMPLETE_TASK_SCHEMA)]
2533
)
2634

2735

2836
async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult:
2937
args = params.arguments or {}
30-
NOTES[args["name"]] = args["text"]
31-
await bus.publish(ResourceUpdated(uri=f"note://{args['name']}"))
32-
return types.CallToolResult(content=[types.TextContent(type="text", text="saved")])
38+
BOARD[args["task"]] = True
39+
await bus.publish(ResourceUpdated(uri="board://sprint"))
40+
return types.CallToolResult(content=[types.TextContent(type="text", text="done")])
3341

3442

3543
server = Server(
36-
"notebook",
44+
"sprint-board",
45+
on_read_resource=read_resource,
3746
on_list_tools=list_tools,
3847
on_call_tool=call_tool,
39-
on_subscriptions_listen=ListenHandler(bus),
48+
on_subscriptions_listen=listen_handler,
4049
)
Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,25 @@
1+
from mcp_types import TextResourceContents
2+
13
from mcp import Client
2-
from mcp.client.subscriptions import ResourceUpdated
4+
from mcp.client.subscriptions import ResourceUpdated, ToolsListChanged
5+
6+
BOARD = "board://sprint"
7+
38

4-
from .tutorial001 import mcp
9+
async def read_board(client: Client, uri: str = BOARD) -> str:
10+
[contents] = (await client.read_resource(uri)).contents
11+
assert isinstance(contents, TextResourceContents)
12+
return contents.text
513

614

7-
async def watch_todo() -> str:
8-
"""Wait for the todo note to change once, then stop listening."""
9-
async with Client(mcp) as client:
10-
async with client.listen(resource_subscriptions=["note://todo"]) as sub:
11-
async for event in sub:
12-
assert isinstance(event, ResourceUpdated)
13-
return f"changed: {event.uri}"
14-
return "the server closed the stream before any change"
15+
async def follow_board(client: Client) -> None:
16+
async with client.listen(tools_list_changed=True, resource_subscriptions=[BOARD]) as sub:
17+
async for event in sub:
18+
match event:
19+
case ResourceUpdated(uri=uri):
20+
print(await read_board(client, uri))
21+
case ToolsListChanged():
22+
tools = await client.list_tools()
23+
print("tools:", [tool.name for tool in tools.tools])
24+
case _:
25+
pass # kinds the filter did not ask for never arrive

docs_src/subscriptions/tutorial004.py

Lines changed: 0 additions & 20 deletions
This file was deleted.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import anyio
2+
3+
from mcp import Client
4+
from mcp.client.subscriptions import Subscription
5+
6+
from .tutorial001 import mcp
7+
from .tutorial003 import read_board
8+
9+
10+
async def watch(client: Client, sub: Subscription) -> None:
11+
async for _event in sub:
12+
board = await read_board(client)
13+
print(board)
14+
if "[ ]" not in board:
15+
return # sprint finished: the stream closes when main() leaves the block
16+
17+
18+
async def main() -> None:
19+
async with Client(mcp) as client:
20+
async with client.listen(resource_subscriptions=["board://sprint"]) as sub:
21+
print(await read_board(client)) # snapshot: acknowledged, so nothing after this is missed
22+
async with anyio.create_task_group() as tg:
23+
tg.start_soon(watch, client, sub)
24+
for task in ("design", "build", "ship"):
25+
await client.call_tool("complete_task", {"board": "sprint", "task": task})
26+
27+
28+
if __name__ == "__main__":
29+
anyio.run(main)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import asyncio
2+
3+
from mcp import Client
4+
from mcp.client.subscriptions import Subscription
5+
6+
from .tutorial001 import mcp
7+
from .tutorial003 import read_board
8+
9+
10+
async def watch(client: Client, sub: Subscription) -> None:
11+
async for _event in sub:
12+
board = await read_board(client)
13+
print(board)
14+
if "[ ]" not in board:
15+
return # sprint finished: the stream closes when main() leaves the block
16+
17+
18+
async def main() -> None:
19+
async with Client(mcp) as client:
20+
async with client.listen(resource_subscriptions=["board://sprint"]) as sub:
21+
print(await read_board(client)) # snapshot: acknowledged, so nothing after this is missed
22+
watcher = asyncio.create_task(watch(client, sub))
23+
for task in ("design", "build", "ship"):
24+
await client.call_tool("complete_task", {"board": "sprint", "task": task})
25+
await watcher # returns once the watcher has seen the finished board
26+
27+
28+
if __name__ == "__main__":
29+
asyncio.run(main())
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import trio
2+
3+
from mcp import Client
4+
from mcp.client.subscriptions import Subscription
5+
6+
from .tutorial001 import mcp
7+
from .tutorial003 import read_board
8+
9+
10+
async def watch(client: Client, sub: Subscription) -> None:
11+
async for _event in sub:
12+
board = await read_board(client)
13+
print(board)
14+
if "[ ]" not in board:
15+
return # sprint finished: the stream closes when main() leaves the block
16+
17+
18+
async def main() -> None:
19+
async with Client(mcp) as client:
20+
async with client.listen(resource_subscriptions=["board://sprint"]) as sub:
21+
print(await read_board(client)) # snapshot: acknowledged, so nothing after this is missed
22+
async with trio.open_nursery() as nursery:
23+
nursery.start_soon(watch, client, sub)
24+
for task in ("design", "build", "ship"):
25+
await client.call_tool("complete_task", {"board": "sprint", "task": task})
26+
27+
28+
if __name__ == "__main__":
29+
trio.run(main)
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+
from .tutorial003 import read_board
7+
8+
9+
async def keep_following(client: Client) -> None:
10+
while True:
11+
try:
12+
async with client.listen(resource_subscriptions=["board://sprint"]) as sub:
13+
print(await read_board(client)) # refetch: no replay across streams
14+
async for _event in sub:
15+
print(await read_board(client))
16+
except SubscriptionLost:
17+
pass
18+
# Either ending means the stream is gone. Back off before re-listening:
19+
# a graceful close may be the server shedding load.
20+
await anyio.sleep(1)

0 commit comments

Comments
 (0)