Document how a handler deals with cancellation by maxisbey · Pull Request #3625 · modelcontextprotocol/python-sdk · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions docs/handlers/cancellation.md
2 changes: 2 additions & 0 deletions docs/handlers/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ What it can do while it runs:
**[Sampling and roots](sampling-and-roots.md)**, deprecated but still
served.
* Report **[Progress](progress.md)** on something slow.
* Clean up, or stop early, when the client gives up on the call, with
**[Cancellation](cancellation.md)**.
* Write logs (to standard error, for whoever operates the server) with
**[Logging](logging.md)**.
* Tell subscribed clients that something changed with
Expand Down
2 changes: 1 addition & 1 deletion docs/handlers/progress.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,4 +115,4 @@ The callback receives `total=None`. A client can still show *activity* ("3 impor
* No callback on the call means `report_progress` does nothing. Report unconditionally.
* Omit `total` when you don't know it; the callback gets `None`.

Progress is what a running tool shows the *user*. The lines it logs for *you*, the person operating the server, are a different channel: **[Logging](logging.md)**.
Progress is for a client that is still waiting. What your tool sees when the client stops waiting is **[Cancellation](cancellation.md)**.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This sentence overstates the guarantee: the cancellation guide documents Streamable HTTP configurations where the client stops waiting but the handler receives no cancellation. Qualify the statement by noting that it applies when the transport delivers cancellation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/handlers/progress.md, line 118:

<comment>This sentence overstates the guarantee: the cancellation guide documents Streamable HTTP configurations where the client stops waiting but the handler receives no cancellation. Qualify the statement by noting that it applies when the transport delivers cancellation.</comment>

<file context>
@@ -115,4 +115,4 @@ The callback receives `total=None`. A client can still show *activity* ("3 impor
 * Omit `total` when you don't know it; the callback gets `None`.
 
-Progress is what a running tool shows the *user*. The lines it logs for *you*, the person operating the server, are a different channel: **[Logging](logging.md)**.
+Progress is for a client that is still waiting. What your tool sees when the client stops waiting is **[Cancellation](cancellation.md)**.
</file context>
Suggested change
Progress is for a client that is still waiting. What your tool sees when the client stops waiting is **[Cancellation](cancellation.md)**.
Progress is for a client that is still waiting. When the transport delivers it, your tool sees **[Cancellation](cancellation.md)** after the client stops waiting.

2 changes: 1 addition & 1 deletion docs/servers/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ You can mix and match: plain parameters next to model parameters, nested models,

If a tool does I/O (calls an API, reads a file, queries a database), declare it `async def` and `await` inside it. The SDK awaits it.

A plain `def` tool works too: the SDK runs it in a thread so it never blocks the server.
A plain `def` tool works too: the SDK runs it in a thread so it never blocks the server. A long one can check whether the client is still waiting; see **[Cancellation](../handlers/cancellation.md)**.

There is nothing else to configure.

Expand Down
Empty file.
28 changes: 28 additions & 0 deletions docs_src/cancellation/tutorial001.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import anyio

from mcp.server import MCPServer

mcp = MCPServer("Bookshop")

holds: set[str] = set()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: holds stores only a title, so concurrent orders for the same book share one reservation: whichever request finishes or is cancelled first calls release_hold and removes the entry while the other payment is still running. Track each hold by call, or maintain a reference count, before using this pattern for concurrent handlers.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs_src/cancellation/tutorial001.py, line 7:

<comment>`holds` stores only a title, so concurrent orders for the same book share one reservation: whichever request finishes or is cancelled first calls `release_hold` and removes the entry while the other payment is still running. Track each hold by call, or maintain a reference count, before using this pattern for concurrent handlers.</comment>

<file context>
@@ -0,0 +1,28 @@
+
+mcp = MCPServer("Bookshop")
+
+holds: set[str] = set()
+
+
</file context>



async def take_payment(title: str) -> None:
await anyio.sleep(30) # the customer is typing a card number


async def release_hold(title: str) -> None:
await anyio.sleep(0.1) # a round trip to the stock system
holds.discard(title)


@mcp.tool()
async def order_book(title: str) -> str:
"""Hold a copy of a book while the customer pays for it."""
holds.add(title)
try:
await take_payment(title)
return f"Ordered {title!r}."
finally:
with anyio.move_on_after(5, shield=True):
await release_hold(title)
26 changes: 26 additions & 0 deletions docs_src/cancellation/tutorial002.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import time

import anyio.from_thread

from mcp.server import MCPServer

mcp = MCPServer("Bookshop")

offline: set[str] = set()


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


@mcp.tool()
def rebuild_index(titles: list[str]) -> str:
"""Take search offline and rebuild its index, one book at a time."""
offline.add("search")
try:
for title in titles:
anyio.from_thread.check_cancelled()
index_book(title)
return f"Indexed {len(titles)} books."
finally:
offline.discard("search")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This cleanup clears the shared offline marker when any invocation finishes, so overlapping rebuild_index calls can observe search as online while another rebuild is still running. Track active rebuilds with a reference count/lock, or use per-operation state instead of a shared set marker.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs_src/cancellation/tutorial002.py, line 26:

<comment>This cleanup clears the shared `offline` marker when any invocation finishes, so overlapping `rebuild_index` calls can observe search as online while another rebuild is still running. Track active rebuilds with a reference count/lock, or use per-operation state instead of a shared set marker.</comment>

<file context>
@@ -6,15 +6,21 @@
+            index_book(title)
+        return f"Indexed {len(titles)} books."
+    finally:
+        offline.discard("search")
</file context>

1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ nav:
- Multi-round-trip requests: handlers/multi-round-trip.md
- Sampling and roots: handlers/sampling-and-roots.md
- Progress: handlers/progress.md
- Cancellation: handlers/cancellation.md

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit (optional): AGENTS.md Documentation says to find the page in mkdocs.yml covering the feature rather than adding a new one: this PR adds a new nav entry and page docs/handlers/cancellation.md. No existing nav page covers handler-side cancellation (only whats-new.md and the closed migration.md mention it), so a new page may be the right call; the closest existing homes are the async def section of docs/servers/tools.md and the end of docs/handlers/progress.md, both of which the PR already links from. Fix: either fold the two examples into one of those pages (e.g. a Cancellation section closing out Progress, which already hands off to it), or keep the new page as an explicit maintainer decision.

Why this was flagged

Nothing fails at runtime. The instruction guards against docs sprawl: readers who look under Progress or Tools for what happens when the client stops waiting would not find it there, and the i18n copies (not regenerated here, per the PR) gain an extra page to track. At base, no page under the mkdocs.yml nav sections documents handler-side cancellation; the feature is only described in docs/whats-new.md:111 and docs/migration.md:1977 (closed to new entries), so the author had no obvious existing page and placed the new one after Progress in 'Inside your handler'. Small consequence either way; the maintainer decides whether a dedicated page or a section on Progress/Tools is wanted.

Verification: AGENTS.md (base) Documentation section reads verbatim: "Docs are organised by the nav: sections in mkdocs.yml ... Find the page covering the feature you touched in mkdocs.yml rather than adding a new one." The diff adds a new nav entry at mkdocs.yml:43 (- Cancellation: handlers/cancellation.md) and a new page docs/handlers/cancellation.md rather than extending an existing page in the "Inside your handler" section (e.g. the async def paragraph of docs/servers/tools.md:139 or the end of docs/handlers/progress.md:118, both of which the diff already edits to cross-link the new page).

- Logging: handlers/logging.md
- Subscriptions: handlers/subscriptions.md
- Running your server:
Expand Down
234 changes: 234 additions & 0 deletions tests/docs_src/test_cancellation.py
Loading