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
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Cancellation

A client can give up on a call: the user pressed stop, or a timeout ran out.

When it does, the SDK **cancels your handler**. The `await` it is waiting on raises, the function unwinds, and nothing it returns is sent. Most handlers need to do nothing about that.

Two kinds do: a handler with something to clean up, and a handler that is a plain `def`.

## Clean up in an `async def` tool

Put the cleanup in a `finally`:

```python title="server.py" hl_lines="23 26-28"
--8<-- "docs_src/cancellation/tutorial001.py"
```

* The `finally` runs however the tool ends: it returned, it raised, or it was cancelled.
* Cleanup that has to `await` needs `shield=True`. In a cancelled handler every further `await` raises too, so without the shield `release_hold` would stop at its first line.
* Nothing can cancel a shielded block, so give it a time limit. Here that is `5` seconds.

!!! tip
Reach for `finally`, not `except`. The cancellation has to keep travelling up once your cleanup
is done, and a `finally` lets it.

## Stop early in a plain `def` tool

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

```python title="server.py" hl_lines="22 25-26"
--8<-- "docs_src/cancellation/tutorial002.py"
```

* `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.
* Cleanup goes in a `finally` here too. Nothing in a thread awaits, so it needs no shield.
* A `def` tool that never asks runs to the end, and its result is thrown away.

## Where it applies

Prompt and resource functions are cancelled exactly like tools.

It works the same over stdio and Streamable HTTP. With this SDK's `Client`, giving up means cancelling the task that awaits `call_tool`, or letting its `read_timeout_seconds` run out.

!!! warning
Two Streamable HTTP options keep the news from your handler: `json_response=True` on a
`2026-07-28` connection, and `stateless_http=True` on a legacy one. There the handler runs to
the end whatever the client did.

## Recap

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

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)**.
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
"""`docs/handlers/cancellation.md`: every claim the page makes, proved against the real SDK."""

import threading
from collections.abc import Awaitable, Callable

import anyio
import anyio.from_thread
import pytest
from mcp_types import REQUEST_TIMEOUT

from docs_src.cancellation import tutorial001, tutorial002
from mcp import Client, MCPError
from mcp.client.streamable_http import streamable_http_client
from mcp.server import MCPServer
from tests.interaction._connect import BASE_URL, mounted_app

# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]

# "auto" dispatches in process; "legacy" puts a JSON-RPC stream, and so a cancellation message, in between.
both_connections = pytest.mark.parametrize("mode", ["auto", "legacy"])

TITLES = ["Dune", "Emma", "Ulysses"]


async def abandon(
call: Callable[[], Awaitable[object]], started: anyio.Event, then: Callable[[], object] = lambda: None
) -> None:
"""Start `call`, cancel the task awaiting it once `started` is set, let the server settle, then run `then`."""
scope = anyio.CancelScope()

async def doomed() -> None:
with scope:
await call()
raise NotImplementedError # unreachable: the call never resolves

async with anyio.create_task_group() as tg:
tg.start_soon(doomed)
await started.wait()
scope.cancel()
await anyio.wait_all_tasks_blocked()
then()


@pytest.fixture
def payment_started(monkeypatch: pytest.MonkeyPatch) -> anyio.Event:
"""Replace tutorial001's `take_payment` with one that says the tool reached it and then never finishes."""
started = anyio.Event()

async def take_payment(title: str) -> None:
assert title in tutorial001.holds
started.set()
await anyio.sleep_forever()

monkeypatch.setattr(tutorial001, "take_payment", take_payment)
return started


@pytest.fixture
def hold_released(monkeypatch: pytest.MonkeyPatch) -> anyio.Event:
"""Wrap tutorial001's own `release_hold` so the test can wait for it to reach its last line."""
released = anyio.Event()
release_hold = tutorial001.release_hold

async def announcing_release_hold(title: str) -> None:
await release_hold(title)
released.set()
Comment on lines +59 to +67

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.

🟡 (optional) Every run of the suite pays a fixed anyio.sleep(0.1) in each of the six tutorial001 test arms, a timing wait the repository's test guidance forbids. The hold_released fixture at tests/docs_src/test_cancellation.py:59-67 wraps the real release_hold, whose body at docs_src/cancellation/tutorial001.py:15 is await anyio.sleep(0.1); the test then waits on that sleep completing. The sleep is not a timeout feature under test, it is simulated work that pads each arm. Fix: patch release_hold in the fixture the way payment_started patches take_payment (record the call and set the event without sleeping), or keep the real one only in a single test, so no arm waits on a fixed-duration sleep.

Why this was flagged

Trigger: test_abandoning_the_call_runs_the_shielded_cleanup_to_the_end (two arms), test_abandoning_the_call_over_streamable_http_runs_the_cleanup_too (two arms) and test_a_client_timeout_cancels_the_tool_the_same_way (two arms) at tests/docs_src/test_cancellation.py:70-112 all use the…

Verification: nit. Trigger: every run of the six tutorial001 arms that take hold_released (not eight). hold_released at tests/docs_src/test_cancellation.py:59-70 wraps the real release_hold, whose body at docs_src/cancellation/tutorial001.py:15 is await anyio.sleep(0.1), so each arm blocks ~0.1 s on await hold_released.wait(). AGENTS.md:83-86 forbids fixed-duration sleeps. Nothing breaks: a bounded ~0.6 s cost per suite run.


monkeypatch.setattr(tutorial001, "release_hold", announcing_release_hold)
return released


@both_connections
async def test_abandoning_the_call_runs_the_shielded_cleanup_to_the_end(
mode: str, payment_started: anyio.Event, hold_released: anyio.Event
) -> None:
"""tutorial001: the client gives up mid-payment, and the `finally` still awaits `release_hold` to completion."""
with anyio.fail_after(5):
async with Client(tutorial001.mcp, mode=mode) as client:
await abandon(lambda: client.call_tool("order_book", {"title": "Dune"}), payment_started)
await hold_released.wait()
assert tutorial001.holds == set()


@pytest.mark.parametrize("mode", ["2026-07-28", "legacy"])
async def test_abandoning_the_call_over_streamable_http_runs_the_cleanup_too(
mode: str, payment_started: anyio.Event, hold_released: anyio.Event
) -> None:
"""The last section: with the default options, either era's way of cancelling over HTTP reaches tutorial001."""
with anyio.fail_after(5):
async with (
mounted_app(tutorial001.mcp) as (http, _),
Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http), mode=mode) as client,
):
await abandon(lambda: client.call_tool("order_book", {"title": "Dune"}), payment_started)
await hold_released.wait()
# Let the legacy transport's late answer to the abandoned call land while the client is still open.
await anyio.wait_all_tasks_blocked()
assert tutorial001.holds == set()


@both_connections
async def test_a_client_timeout_cancels_the_tool_the_same_way(
mode: str, payment_started: anyio.Event, hold_released: anyio.Event
) -> None:
"""The last section: `read_timeout_seconds` running out is the other way this SDK's client gives up."""
with anyio.fail_after(5):
async with Client(tutorial001.mcp, mode=mode) as client:
with pytest.raises(MCPError) as exc_info:
# The tool never answers, so any positive timeout expires; this one adds no wall-clock time.
await client.call_tool("order_book", {"title": "Dune"}, read_timeout_seconds=0.000001)
await hold_released.wait()
assert exc_info.value.error.code == REQUEST_TIMEOUT
assert payment_started.is_set()
assert tutorial001.holds == set()


@both_connections
async def test_check_cancelled_stops_a_def_tool_at_its_next_check(mode: str, monkeypatch: pytest.MonkeyPatch) -> None:
"""tutorial002: cancelled during the first book, the loop raises at its next check and the `finally` cleans up."""
started = anyio.Event()
resume = threading.Event()
indexed: list[str] = []

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

monkeypatch.setattr(tutorial002, "index_book", index_book)
with anyio.fail_after(5):
# Leaving the block waits for the tool's thread, so what it left behind is final after it.
async with Client(tutorial002.mcp, mode=mode) as client:
await abandon(lambda: client.call_tool("rebuild_index", {"titles": TITLES}), started, then=resume.set)
assert indexed == ["Dune"]
assert tutorial002.offline == set()


async def test_a_def_tool_nobody_cancels_indexes_every_book_and_cleans_up(monkeypatch: pytest.MonkeyPatch) -> None:
"""tutorial002: while the call is live the checks do nothing, and the `finally` runs on a normal finish too."""
indexed: list[str] = []
monkeypatch.setattr(tutorial002, "index_book", indexed.append)
async with Client(tutorial002.mcp) as client:
result = await client.call_tool("rebuild_index", {"titles": TITLES})
assert result.structured_content == {"result": "Indexed 3 books."}
assert indexed == TITLES
assert tutorial002.offline == set()


async def test_a_def_tool_that_never_checks_runs_to_the_end() -> None:
"""The `def` section's last bullet: nothing interrupts the thread, so the tool outlives its own cancellation."""
started = anyio.Event()
resume = threading.Event()
finished: list[str] = []
mcp = MCPServer("Bookshop")

@mcp.tool()
def rebuild_index() -> str:
anyio.from_thread.run_sync(started.set)
assert resume.wait(5)
finished.append("rebuild_index")
return "Indexed 3 books."

with anyio.fail_after(5):
async with Client(mcp, mode="legacy") as client:
await abandon(lambda: client.call_tool("rebuild_index", {}), started, then=resume.set)
assert finished == ["rebuild_index"]


async def test_prompt_and_resource_functions_are_cancelled_like_tools() -> None:
"""The last section: a prompt or a resource function parked on an `await` is cancelled when the client gives up."""
started = {"blurb": anyio.Event(), "stock": anyio.Event()}
cancelled = {"blurb": anyio.Event(), "stock": anyio.Event()}
mcp = MCPServer("Bookshop")

async def park(name: str) -> str:
started[name].set()
try:
await anyio.sleep_forever()
finally:
cancelled[name].set()
raise NotImplementedError # unreachable: only cancellation ends the sleep

@mcp.prompt()
async def blurb() -> str:
return await park("blurb")

@mcp.resource("stock://all")
async def stock() -> str:
return await park("stock")

with anyio.fail_after(5):
async with Client(mcp, mode="legacy") as client:
await abandon(lambda: client.get_prompt("blurb"), started["blurb"])
await cancelled["blurb"].wait()
await abandon(lambda: client.read_resource("stock://all"), started["stock"])
await cancelled["stock"].wait()


@pytest.mark.parametrize(
("json_response", "stateless_http", "mode"),
[(True, False, "2026-07-28"), (False, True, "legacy")],
ids=["json_response-modern", "stateless_http-legacy"],
)
async def test_two_http_options_keep_the_cancellation_from_the_handler(
json_response: bool, stateless_http: bool, mode: str
) -> None:
"""The `!!! warning`: on these two pairings the abandoned tool is still there once everything has settled.
Pins known gaps. A stateless legacy server has no session in which to find the request that
`notifications/cancelled` names. In JSON-response mode the 2026-07-28 entry does not watch for
the disconnect that is that revision's cancellation signal; if that arm starts timing out, the
Comment on lines +207 to +213

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): maintainers triaging a future failure of this pinned-gap test get no tracking issue to consult, and the docstring runs past the two-sentence limit. The docstring at tests/docs_src/test_cancellation.py:207-213 names two known gaps (json_response=True on 2026-07-28, stateless_http=True on legacy) but cites no issue, and its second paragraph is explanatory comment text. Fix: reference the tracking issue(s) for both gaps in the docstring (or in the !!! warning on the docs page) and move the mechanism explanation to comments next to the parametrize arms, so the docstring stays at 1-2 sentences with provenance. [also at: tests/docs_src/test_cancellation.py:194 - nit: maintainers get a pinned known gap with nowhere to track it. The docstring at tests/docs_src/test_cancellation.py:188-194 pins that a 2026-07-28 connection with json_response=True never cancels the handler, and the page's !!! warning tells users the same, but no tracking issue is named anywhere.]

Why this was flagged

The test at tests/docs_src/test_cancellation.py:205 pins two SDK gaps where a client's cancellation never reaches the handler over Streamable HTTP. The repository test-quality guide requires pinned gaps to be recorded as data with a tracking issue named in the docstring, and docstrings to be 1-2 sentences with comments placed next to the lines they explain. The docstring at lines 207-213 is four sentences across two paragraphs and names no issue. When the gap is later closed and this test starts timing out, the maintainer has only the sentence 'the warning should lose that half' and no issue link to close; nothing breaks at runtime. The base branch has no such test, so this is new text under the convention.

Verification: nit. AGENTS.md:68 reads "When writing or reviewing tests, conform to .claude/skills/test-quality/SKILL.md", and SKILL.md:16-19 says "Docstrings: 1–2 sentences of behaviour". The new docstring at tests/docs_src/test_cancellation.py:209-215 is four sentences across two paragraphs, so the changed code breaks the 1–2 sentence rule. Nothing fails at runtime.

gap was closed and the warning should lose that half.
"""
started = anyio.Event()
resume = anyio.Event()
finished = anyio.Event()
mcp = MCPServer("Bookshop")

@mcp.tool()
async def order_book() -> str:
started.set()
await resume.wait()
finished.set()
return "Ordered."

with anyio.fail_after(5):
async with (
mounted_app(mcp, json_response=json_response, stateless_http=stateless_http) as (http, _),
Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http), mode=mode) as client,
):
await abandon(lambda: client.call_tool("order_book", {}), started, then=resume.set)
await finished.wait()
Loading