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:
import anyio
from mcp.server import MCPServer
mcp = MCPServer("Bookshop")
holds: set[str] = set()
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)
- The
finallyruns however the tool ends: it returned, it raised, or it was cancelled. - Cleanup that has to
awaitneedsshield=True. In a cancelled handler every furtherawaitraises too, so without the shieldrelease_holdwould stop at its first line. - Nothing can cancel a shielded block, so give it a time limit. Here that is
5seconds.
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:
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")
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
finallyhere too. Nothing in a thread awaits, so it needs no shield. - A
deftool 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 afinally, and put cleanup that awaits insideanyio.move_on_after(seconds, shield=True).- Plain
def: callanyio.from_thread.check_cancelled()between units of work, or the tool runs to the end. A plainfinallycleans up. json_response=True(modern connections) andstateless_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.