Skip to content

Commit e25d6ae

Browse files
committed
Show cleanup in a finally in the plain def cancellation example
1 parent 3751521 commit e25d6ae

3 files changed

Lines changed: 32 additions & 11 deletions

File tree

‎docs/handlers/cancellation.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,12 @@ Put the cleanup in a `finally`:
2626

2727
A plain `def` tool runs in a thread, and nothing can interrupt a thread from outside. The tool has to ask:
2828

29-
```python title="server.py" hl_lines="18"
29+
```python title="server.py" hl_lines="22 25-26"
3030
--8<-- "docs_src/cancellation/tutorial002.py"
3131
```
3232

3333
* `anyio.from_thread.check_cancelled()` does nothing while the call is live, and raises once it has been cancelled. Call it between units of work.
34+
* Cleanup goes in a `finally` here too. Nothing in a thread awaits, so it needs no shield.
3435
* A `def` tool that never asks runs to the end, and its result is thrown away.
3536

3637
## Where it applies
@@ -48,7 +49,7 @@ It works the same over stdio and Streamable HTTP. With this SDK's `Client`, givi
4849

4950
* When the client gives up on a call, the SDK cancels the handler: tool, prompt or resource.
5051
* `async def`: clean up in a `finally`, and put cleanup that awaits inside `anyio.move_on_after(seconds, shield=True)`.
51-
* Plain `def`: call `anyio.from_thread.check_cancelled()` between units of work, or the tool runs to the end.
52+
* Plain `def`: call `anyio.from_thread.check_cancelled()` between units of work, or the tool runs to the end. A plain `finally` cleans up.
5253
* `json_response=True` (modern connections) and `stateless_http=True` (legacy ones) switch cancellation off.
5354

5455
Progress and cancellation are between a running tool and its *caller*. The lines it logs for *you*, the person operating the server, are a different channel: **[Logging](logging.md)**.

‎docs_src/cancellation/tutorial002.py‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,21 @@
66

77
mcp = MCPServer("Bookshop")
88

9+
offline: set[str] = set()
10+
911

1012
def index_book(title: str) -> None:
1113
time.sleep(1) # slow work with nothing to await
1214

1315

1416
@mcp.tool()
1517
def rebuild_index(titles: list[str]) -> str:
16-
"""Rebuild the search index, one book at a time."""
17-
for title in titles:
18-
anyio.from_thread.check_cancelled()
19-
index_book(title)
20-
return f"Indexed {len(titles)} books."
18+
"""Take search offline and rebuild its index, one book at a time."""
19+
offline.add("search")
20+
try:
21+
for title in titles:
22+
anyio.from_thread.check_cancelled()
23+
index_book(title)
24+
return f"Indexed {len(titles)} books."
25+
finally:
26+
offline.discard("search")

‎tests/docs_src/test_cancellation.py‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
# "auto" dispatches in process; "legacy" puts a JSON-RPC stream, and so a cancellation message, in between.
2121
both_connections = pytest.mark.parametrize("mode", ["auto", "legacy"])
2222

23+
TITLES = ["Dune", "Emma", "Ulysses"]
24+
2325

2426
async def abandon(
2527
call: Callable[[], Awaitable[object]], started: anyio.Event, then: Callable[[], object] = lambda: None
@@ -115,23 +117,35 @@ async def test_a_client_timeout_cancels_the_tool_the_same_way(
115117

116118
@both_connections
117119
async def test_check_cancelled_stops_a_def_tool_at_its_next_check(mode: str, monkeypatch: pytest.MonkeyPatch) -> None:
118-
"""tutorial002: cancelled during the first book, the loop raises at its next check and indexes no more."""
120+
"""tutorial002: cancelled during the first book, the loop raises at its next check and the `finally` cleans up."""
119121
started = anyio.Event()
120122
resume = threading.Event()
121123
indexed: list[str] = []
122124

123125
def index_book(title: str) -> None:
126+
assert tutorial002.offline == {"search"}
124127
indexed.append(title)
125128
anyio.from_thread.run_sync(started.set)
126129
assert resume.wait(5)
127130

128131
monkeypatch.setattr(tutorial002, "index_book", index_book)
129-
titles = ["Dune", "Emma", "Ulysses"]
130132
with anyio.fail_after(5):
131-
# Leaving the block waits for the tool's thread, so `indexed` is final after it.
133+
# Leaving the block waits for the tool's thread, so what it left behind is final after it.
132134
async with Client(tutorial002.mcp, mode=mode) as client:
133-
await abandon(lambda: client.call_tool("rebuild_index", {"titles": titles}), started, then=resume.set)
135+
await abandon(lambda: client.call_tool("rebuild_index", {"titles": TITLES}), started, then=resume.set)
134136
assert indexed == ["Dune"]
137+
assert tutorial002.offline == set()
138+
139+
140+
async def test_a_def_tool_nobody_cancels_indexes_every_book_and_cleans_up(monkeypatch: pytest.MonkeyPatch) -> None:
141+
"""tutorial002: while the call is live the checks do nothing, and the `finally` runs on a normal finish too."""
142+
indexed: list[str] = []
143+
monkeypatch.setattr(tutorial002, "index_book", indexed.append)
144+
async with Client(tutorial002.mcp) as client:
145+
result = await client.call_tool("rebuild_index", {"titles": TITLES})
146+
assert result.structured_content == {"result": "Indexed 3 books."}
147+
assert indexed == TITLES
148+
assert tutorial002.offline == set()
135149

136150

137151
async def test_a_def_tool_that_never_checks_runs_to_the_end() -> None:

0 commit comments

Comments
 (0)