Cancellation in MCP is a notification, not a kill switch. When a client hits its request timeout it sends notifications/cancelled, stops waiting, and moves on, but nothing in the protocol forces your server to stop executing. If your handler never observes that notification, the export keeps paginating, the transaction keeps committing, and the agent retries the same call against a server that is already halfway through the first attempt.
Key takeaways
- A client timeout produces a cancellation notification and an abandoned request, not a stopped process. The spec says receivers SHOULD stop processing and free resources, which means your code has to do it.
- Handlers that never yield to the event loop cannot observe cancellation at all. Blocking CPU work or a synchronous driver call makes the notification unreadable until the work finishes.
- A dropped SSE connection is explicitly not a cancellation. Servers that tear down work on disconnect break resumable streams, and clients that reconnect by re-calling the tool double the side effects.
- Progress notifications are the pressure valve for slow tools, because clients MAY reset the timeout clock on each one, and SHOULD still enforce a ceiling.
- For anything with side effects, return a job handle instead of holding a request open for minutes. Cancellation then becomes an explicit tool call rather than a race with a timer.
What actually happens when a client times out
The client sends a JSON-RPC notification naming the request it gave up on, then discards any response that arrives later. The MCP lifecycle spec states that implementations SHOULD establish timeouts for all sent requests and, on expiry, SHOULD issue a cancellation notification and stop waiting. The notification itself is minimal:
{
"jsonrpc": "2.0",
"method": "notifications/cancelled",
"params": {
"requestId": "123",
"reason": "Request timed out"
}
}
Per the cancellation utility spec, receivers SHOULD stop processing, free associated resources, and not send a response for the cancelled request. Every one of those is a SHOULD, and none of them happen for free. The transport decides how much damage the gap does. Over stdio the client usually closes stdin and terminates the subprocess, so orphaned work dies with the process. Over Streamable HTTP the server is a long-lived process serving many clients, so an abandoned handler keeps its database connection, its rate limit budget, and its half-written state.
How to tell orphaned work from a slow tool
Log the request ID at three points and compare them: handler entry, handler exit, and cancellation receipt. Orphaned work shows up as an exit line whose request ID already appeared in a cancellation line, often minutes later. Slow tools produce long entry-to-exit gaps with no cancellation at all. The symptom set is distinctive once you look for it.
- Duplicate rows or duplicate outbound calls with timestamps clustered around a fixed interval, which is the client timeout plus the agent's retry delay.
- Connection pool exhaustion under light agent traffic, because every timed-out call still holds its connection until natural completion.
- Handler completion logs with no matching response delivery, since the client discarded the response.
- Server CPU or upstream API spend that does not track the number of tool results the agent actually used.
One check finds the most common root cause quickly. Add a log line inside the framework's cancellation path and call a deliberately slow tool, then cancel it. If the line never prints, your server is not routing the notification. If it prints but the handler keeps going, your handler is not listening.
Why a handler that never awaits cannot be cancelled
Cancellation arrives as an inbound message on the same connection the handler is running on, so a handler that monopolises the event loop blocks the delivery of its own cancellation. A Python tool that calls a synchronous HTTP client or runs a tight parsing loop will not read notifications/cancelled until it finishes, at which point cancelling is pointless. The same applies to a TypeScript handler doing CPU-bound work between awaits.
The fix is structural, not a flag. Push blocking work off the loop (anyio.to_thread.run_sync in Python, a worker thread or child process in Node), and break long loops into units small enough that you can check for cancellation between them. If a single unit of work takes longer than the client timeout, no amount of signal plumbing helps.
How to wire cancellation into a TypeScript tool handler
Read the AbortSignal from the handler's extra argument and pass it into every downstream call. The TypeScript SDK aborts that signal when a cancellation notification arrives for the in-flight request, and fetch, most database drivers, and any well-behaved client library accept a signal directly.
server.registerTool(
"export_report",
{ title: "Export report", inputSchema: { accountId: z.string() } },
async ({ accountId }, extra) => {
const { signal, sendNotification, _meta } = extra;
const token = _meta?.progressToken;
for (let page = 0; page < TOTAL_PAGES; page++) {
signal.throwIfAborted();
const rows = await fetchPage(accountId, page, { signal });
await writeRows(rows);
if (token) {
await sendNotification({
method: "notifications/progress",
params: { progressToken: token, progress: page + 1, total: TOTAL_PAGES }
});
}
}
return { content: [{ type: "text", text: "Export complete" }] };
}
);
Two details matter. signal.throwIfAborted() belongs at the top of the loop body, before the expensive call, so an abort during page 40 does not still fetch page 41. And passing signal into fetchPage is what makes an in-flight network call terminate rather than run to completion inside a handler that has already given up.
How to cancel in Python without losing your cleanup
Catch the cancellation exception, run compensation inside a shielded scope, then re-raise. The Python SDK runs handlers under anyio task groups, so cancellation surfaces as a cancelled exception, and any await in your cleanup path will itself be cancelled immediately unless you shield it.
import anyio
from mcp.server.fastmcp import Context
@mcp.tool()
async def export_report(account_id: str, ctx: Context) -> str:
written: list[int] = []
try:
for page in range(TOTAL_PAGES):
rows = await fetch_page(account_id, page)
await write_rows(rows)
written.append(page)
await ctx.report_progress(progress=page + 1, total=TOTAL_PAGES)
return "Export complete"
except anyio.get_cancelled_exc_class():
with anyio.CancelScope(shield=True):
with anyio.move_on_after(5):
await mark_export_aborted(account_id, written)
raise
Swallowing the cancellation instead of re-raising it corrupts the task group's state and can hang shutdown. Bound the shielded block with a deadline as well, because a compensation step that itself hangs turns a cancelled request into a stuck one. If your cleanup path is doing anything more complex than marking state, that is a signal the tool should not be holding a request open in the first place.
Why a dropped SSE stream is not a cancellation
Treating a disconnect as a cancel is one of the most common bugs in remote MCP servers, and the spec calls it out directly. From the Streamable HTTP transport section:
Disconnection SHOULD NOT be interpreted as the client cancelling its request. To cancel, the client SHOULD explicitly send an MCP CancelledNotification.
The transport also defines the recovery path: servers MAY attach an id to each SSE event, and a client reconnecting with the Last-Event-ID header can have the server replay messages sent after that point on the same stream. Two mistakes break this. A server hosted behind a proxy that kills idle connections after 60 seconds, with no event IDs, silently loses the response for every tool call slower than that. And a client that responds to a broken stream by re-invoking the tool produces exactly the duplicate side effects that resumability exists to prevent, which is why side-effecting tools need idempotency keys regardless of how careful the transport layer is.
How to stop the client from timing out at all
Emit progress notifications, because clients are allowed to reset the timeout clock when they see one. The progress spec requires the caller to supply a progressToken in the request's _meta, and requires the progress value to increase with every notification even when total is unknown. The lifecycle spec then says implementations MAY reset the timeout on a corresponding progress notification, while still enforcing a maximum timeout regardless.
That combination sets the design rule. Progress buys you extensions, not immunity. If a tool can exceed the client's hard ceiling, no progress cadence saves it, and the fix belongs in the tool's shape rather than its timers. Details on the streaming mechanics are in streaming progress from a long-running MCP tool. Two practical points: send progress only when the token was supplied, since notifications referencing unknown tokens are protocol violations, and rate limit them, because a per-row notification on a 50,000 row export floods the client's context and its message queue.
How to make long tools cancellable by design
Return a job handle immediately and let the agent poll or cancel through separate tools. This converts an implicit race between a timer and your handler into explicit calls the model can reason about, and it removes the entire orphaned-work class because no request is ever held open long enough to time out.
start_export(account_id) -> { job_id, status: "running", poll_after_ms: 5000 }
get_export(job_id) -> { status: "running" | "done" | "failed", result?, error? }
cancel_export(job_id) -> { status: "cancelling" | "done" }
Three things make this work in practice. The job record lives in shared storage rather than process memory, so a reconnect or a different server instance can still answer, which is the same constraint discussed in keeping MCP server state without sessions. The worker checks a cancellation flag between units and records a terminal state either way. And get_export returns a description of what the agent should do next, since a status of failed with no guidance produces the same aimless retry loop as any other unrecoverable tool error.
Trade-offs and what I would actually ship
The job handle pattern costs you a state store, two extra tools in the schema, and more turns per task. Those extra turns are not free, since each poll is a model call with the full context attached, and a tool that finishes in eight seconds does not deserve that overhead. My split is by side effects and duration together, not either alone.
| Tool shape | Approach | Why |
|---|---|---|
| Read-only, under 30s | Signal plumbing only | Abandoned work wastes budget but corrupts nothing |
| Read-only, minutes | Signal plus progress notifications | Progress resets the clock, cancellation frees the connection |
| Writes, any duration | Job handle plus idempotency key | Cancellation mid-write is the dangerous case, so never race it |
| Writes, under a second | Ignore cancellation entirely | Finishing is safer than a partially applied abort |
That last row is the one people argue with, and it is deliberate. For a sub-second write, honouring cancellation midway leaves state the client cannot inspect, because it has already stopped listening for your response. Completing the write and letting the agent discover the outcome on its next read is the better failure mode. Cancellation is a resource control, not a transaction boundary, and treating it as a rollback trigger is how servers end up with state no one can explain.
On timeouts, set the server's own upstream deadlines shorter than the client's request timeout. When the ordering is inverted, the client cancels first and your handler keeps burning an upstream budget for a result nobody will read. Most clients expose a per-request timeout, and the spec asks SDKs to allow exactly that, so pick the number rather than inheriting a default you never measured.