Long-running tool calls are the norm in MCP: a test suite runs, a crawler paginates, a model streams. Sooner or later the caller stops caring — the user hits stop, a rephrased question supersedes the old one, an orchestrator abandons the loser of a race. MCP’s answer is one one-way message, notifications/cancelled, carrying the id of the request to abandon and an optional reason. Everything interesting follows from what that message deliberately is not: not a request, not acknowledged, and no promise that anything actually stopped. It is advisory. This piece works through the consequences — the race that can never be closed, what a server should really do on receipt, why partial side effects are the hard part, and the bookkeeping that keeps a late response from being mistaken for a live one.

A notification, not a request — and why that is deliberate

Cancellation travels as a JSON-RPC notification: a message with a method and params but no id of its own, and therefore no response and no error. The sender writes notifications/cancelled with the requestId it wants abandoned, optionally a reason, and moves on. Nothing comes back.

That shape is a choice, not an oversight. If cancelling were itself a request it would have an id, which means it could hang, time out, fail — and need cancelling — a supervisory channel on top of the channel you were trying to interrupt. Fire-and-forget also matches the semantics honestly: the sender is not asking permission, and the receiver cannot truthfully answer “yes, it stopped” the instant the message lands. An unacknowledged hint is a smaller lie than a confirmation you cannot back up. One request is conventionally off-limits: initialize has no mid-flight state to unwind.

Advertisement

The race you cannot close

Cancellation and completion travel in opposite directions over the same wire, and neither side sees the other’s in-flight bytes. A client that cancels at T may find the server finished at T minus one millisecond and already flushed a perfectly good response. No protocol trick removes this window: closing it needs a synchronous round trip, exactly what a notification refuses to be.

The race is therefore not a bug to fix but a condition to tolerate, symmetrically. The requester must accept a full response for a request it already cancelled and drop it. The receiver must accept a cancellation for an id it no longer recognizes — already completed, already cancelled, never seen — and ignore it silently. Neither is an error. Treating either as one is the classic bug: a server replying “unknown request id” turns a timing artifact into noise, and a client that surfaces a late result shows output the user explicitly asked to stop.

What a server should actually do on receipt

The correct posture is best-effort abort. On receipt, a server looks the id up in its table of in-flight requests. No entry, no action. If there is one, it trips that request’s cancellation primitive — an AbortController, a cancelled asyncio task, a Go context — and returns immediately, without waiting to see whether the handler noticed.

Then it stays quiet. A cancelled request gets no response: not a result, and not a JSON-RPC error either. Abandoned is not failed, and inventing an error reply recreates the ambiguity the design was avoiding. The one real obligation is to make handlers cancellable at all — a token nobody checks is decoration — by threading it into every slow call, so an HTTP read or model stream aborts instead of running to completion inside a task the runtime believes it cancelled.

MCP cancellation — notifications/cancelled races the in-flight request to a safe stopadvisory, best-effort, race-awareClient calleruser cancels / timeoutRequest trackerrequestId + AbortControllerTransportstdio / streamable HTTPServer dispatcherper-request contextcancelled notifrequestId + reasonIn-flight handlertool / LLM / IO callCancellation tokenchecked at await pointsResource cleanupsockets, temp, spendResponse suppressiondrop late result, no errorRace windowresult already sent = ignoreOps — orphan detection + cancel latency + leaked-work metrics + idempotency keysemittrackroutedispatchsuppressunwindreleaseoperateoperate
MCP cancellation: the client sends notifications/cancelled by requestId; the server races it against the in-flight handler, unwinding work and suppressing any late response.

Making handlers cooperative

Cancellation on every mainstream runtime is cooperative: nothing forcibly kills a running function, so the handler must reach a point where it observes the token and unwinds. That yields a checklist. Check the token before each expensive step and inside any loop over a large input, so a thousand-item batch does not grind through item 998 after the caller left. Pass the token, not just a URL, to your HTTP client so the socket read is genuinely abortable. Break out of streaming loops when it trips instead of draining the stream.

The trap is blocking code. A synchronous CPU-bound loop, or a driver with no cancellation support, holds its thread until it finishes, token or not; the remedies are running it somewhere you can abandon (a worker thread, a subprocess) or chunking it so checkpoints exist. Measure the delay from cancel received to work actually stopped — that number, not the protocol, is your real guarantee.

Cleanup and partial side effects — the genuinely hard part

Stopping is easy. Stopping safely, when the operation already changed something, is the real work. An abandoned handler may hold an open transaction, a half-written temp file, a lock, a leased sandbox, a pooled connection. Structured unwinding — try/finally, context managers, defer — is non-negotiable, because cleanup sprinkled through a happy path never runs on the abandonment path.

The harder class is effects you cannot take back. If the tool already POSTed a payment or triggered a deployment, cancellation stops the polling, not the consequence. Three habits keep that survivable: order the handler so irreversible external steps happen as late as possible, shrinking the mid-commit window; carry an idempotency key so a cancelled-then-retried operation reuses the effect rather than duplicating it; and record the abandonment, because a request cancelled after committing an external effect is a reconciliation item, not a no-op.

Advertisement

Propagating downward — subprocesses, fan-out, and gateways

A tool call is rarely a leaf. It may have spawned a subprocess, issued three parallel HTTP calls, asked the host for an LLM completion via sampling, or forwarded work to another MCP server behind a gateway. Cancellation is only as good as its reach, so the per-request token should be the root of a tree: every child operation derives from it and dies with it.

Each edge needs its own mechanism. Child tasks inherit the context. Subprocesses need a signal, usually a process group signal — killing a shell that spawned a compiler leaves the compiler running — with a grace period before a hard kill. A gateway or proxy must translate an inbound cancellation into an outbound notifications/cancelled for the id it minted downstream, which means keeping that id mapping alive for exactly this purpose. Anything you forget to wire becomes an orphan: work that continues, and bills, with nobody waiting for it.

Cancellation vs timeout vs disconnect

These three get conflated and behave differently. A timeout is a local decision by the requester that it has waited long enough; it produces an error toward the caller — something did go wrong — and a well-behaved client also sends a cancellation so the server stops producing an answer nobody will read. A cancellation is deliberate abandonment: no error is warranted and nobody is surprised.

A disconnect is different again — the transport is gone, so no notification can be sent at all. A stdio server sees its pipe close; an HTTP-based server sees a stream drop or a session expire. Servers must treat transport loss as an implicit cancellation of everything that connection had in flight, or a crashed client leaves zombie work behind. Note the asymmetry: with one connection multiplexing many requests, dropping it is the only cancellation primitive that is not per-request — precisely why the in-band notification must exist.

Client-side bookkeeping and the optimistic UI

The client owns the correlation table, so most of the safety lives there. Each outbound request gets an entry: the id, the promise or future waiting on it, and an abort primitive. Cancelling means two independent things — settle the local waiter so the agent loop moves on immediately, and emit the notification so the server can stop. Neither depends on the other.

The subtle part is the entry afterwards. Do not delete it the moment you cancel: a late response would then hit an empty table and look like a protocol violation. Mark the id cancelled and keep it briefly, so an arriving result is positively identified as belonging to an abandoned request and dropped without a warning. The adjacent hazard is id reuse — a client that recycles ids can hand a stale response to a brand-new request. Monotonic, non-reused ids per session make late results provably harmless.

Progress notifications, their own topic entirely, are usually what prompts the click; the UI’s job at that moment is to be optimistic. Show the operation cancelled, free the slot, let the user move on — and never let a late result repaint the screen or feed the model.

Testing and operating it

Cancellation paths rot because normal use rarely exercises them. Test them deliberately, and make the high-value cases the ordering ones: cancel while the handler is mid-await, cancel a few milliseconds after the response is written, cancel an id that never existed, cancel the same id twice. Each should produce silence and clean state — never an error reply or a stack trace.

In production, three signals tell you whether cancellation is real. Cancel latency: time from notification received to handler exit, per tool — a long tail names the handler ignoring its token. Orphan count: work still running after a grace period, which should be zero and catches an unwired subprocess or downstream call. Abandoned-with-effects: cancels landing after an irreversible external step — a reconciliation queue, not an alert. Log the reason alongside these; it is the only explanation of why you will ever get.

MCP cancellation is one fire-and-forget notification carrying a request id and an optional reason — deliberately unacknowledged, because a cancel that could itself hang would need cancelling. The race is permanent, so both sides tolerate it: a requester drops a response for a request it cancelled, and a receiver silently ignores a cancel for an id it does not know. A server trips the request’s token, sends no reply at all — not a result, not an error — and stops best-effort, which is only as real as the token checks threaded through its handlers, subprocesses, and downstream calls. The hard part is never stopping; it is unwinding safely when something was already written, which is what finally blocks, late-ordered irreversible steps, and idempotency keys are for. Treat cancellation as optimistic in the UI, keep cancelled ids long enough to discard late replies, and measure cancel latency and orphans.