Skip to content

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:

server.py
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 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:

server.py
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 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.