Progress, cancellation and timeouts when nothing can resume

What surprised me in this part of the spec is how little it takes to cancel a request over HTTP: you don’t send a message, you just close the response stream. The flip side is that a Model Context Protocol (MCP) server can no longer pick up a broken stream, so a dropped connection cancels the work too.
This is part 16 of my series on what an MCP server does under the 2026-07-28 specification, after elicitation in part 15. The facts are as I read them in October 2026.
In brief
- Progress is opt-in, per request. The client sends a
progressToken, and the server MAY sendnotifications/progresswhile the request runs, with a value that MUST increase each time. - Cancellation depends on the transport. On Streamable HTTP, closing the response stream is the cancellation; on stdio, the client MUST send
notifications/cancelled. - A cancellation can cross the result on the wire. Both sides MUST handle that gracefully.
- Every request SHOULD have a timeout. The side waiting MAY reset its clock when progress arrives, but SHOULD still enforce a maximum.
- My advice: make long tools idempotent and send progress while they run. A dropped stream can’t be resumed.
Progress: opt in, report, stop
A client that wants progress puts a progressToken in the request’s _meta (part 5 covers the other keys). The token, a string or an integer of the client’s choosing, MUST be unique across the client’s active requests:
{ "jsonrpc": "2.0", "id": 7, "method": "tools/call",
"params": { "name": "reindex", "arguments": {}, "_meta": { "progressToken": "reindex-7" } } }
The server MAY then send notifications/progress with the token, the current progress, and optionally a total and a human-readable message:
{ "jsonrpc": "2.0", "method": "notifications/progress",
"params": { "progressToken": "reindex-7", "progress": 40, "total": 120, "message": "Indexed 40 of 120 documents" } }
The progress page sets the rules:
progressMUST increase with every notification, even when the total is unknown. Both values may be floating point.- Notifications MUST reference only tokens from active, in-progress requests, and MUST stop once the operation completes.
- A server may send no progress at all, report at any frequency, and leave out
total. Both sides SHOULD rate-limit.
On Streamable HTTP, request-scoped notifications (progress, and logging where it’s still used) travel on their own request’s response stream, never on the subscriptions/listen stream.
Cancellation on each transport
The cancellation page ties cancellation to the transport. On Streamable HTTP, each request gets its own response, either a single JSON response or a Server-Sent Events (SSE) stream:
| Transport | Cancellation signal |
|---|---|
| Streamable HTTP | Closing the response stream is the cancellation. The server MUST treat a client disconnect as cancelling that request. No notifications/cancelled is required or expected. |
| stdio | There’s no per-request stream, so the client MUST send notifications/cancelled with the request ID. |
{ "jsonrpc": "2.0", "method": "notifications/cancelled",
"params": { "requestId": "7", "reason": "User pressed stop" } }
A client cancels only requests it issued and believes are still in progress. A server sends notifications/cancelled for exactly one purpose, tearing down a subscriptions/listen stream; any other use is forbidden.
On receipt, a server SHOULD stop processing, free resources and send no response. It MAY ignore a cancellation for a request that’s unknown, already finished, or can’t be cancelled.
The race is normal
Network latency means a cancellation can arrive after the work has finished, even after the response was sent. Both sides MUST handle this gracefully: the client SHOULD ignore a response that arrives after it cancelled, and invalid cancellations (unknown IDs, completed requests, malformed notifications) SHOULD be ignored. The diagram shows the stdio form, where the cancellation is a message.
Progress and cancellation in Python
In the official Python software development kit (SDK), mcp 2.3.0, a tool reports progress with ctx.report_progress. There’s no cancellation token: anyio cancels the tool’s coroutine, the await it’s waiting on raises, and the function unwinds. So cleanup goes in a finally:
import anyio
from mcp import Client
from mcp.server import MCPServer
from mcp.server.mcpserver import Context
mcp = MCPServer("search")
locked: set[str] = set()
@mcp.tool()
async def reindex(collection: str, ctx: Context) -> str:
"""Rebuild the search index for one collection."""
total = 120
locked.add(collection) # held while the index is rebuilt
try:
for done in range(40, total + 1, 40):
await anyio.sleep(0.5) # index the next 40 documents
await ctx.report_progress(done, total, f"Indexed {done} of {total} documents")
return f"Reindexed {total} documents in {collection}"
finally:
locked.discard(collection) # runs on return, error or cancellation
async def show(progress: float, total: float | None, message: str | None) -> None:
of_total = f" of {total:g}" if total is not None else ""
print(f"progress {progress:g}{of_total}: {message}")
async def main():
async with Client(mcp) as client:
result = await client.call_tool("reindex", {"collection": "docs"}, progress_callback=show)
print(result.content[0].text)
if __name__ == "__main__":
anyio.run(main)
It printed:
progress 40 of 120: Indexed 40 of 120 documents
progress 80 of 120: Indexed 80 of 120 documents
progress 120 of 120: Indexed 120 of 120 documents
Reindexed 120 documents in docs
report_progress does nothing when the caller didn’t ask for progress. When I set read_timeout_seconds=0.7 on a second call, it raised MCPError, and the finally had already freed the lock. Over stdio, a variant of the tool sent the same reports as notifications/progress, and its finally ran on the timeout too.
The SDK’s cancellation docs add two warnings. Cleanup that has to await needs anyio.move_on_after(seconds, shield=True), because in a cancelled handler every further await raises too. And with the SDK’s json_response=True option for Streamable HTTP, on a 2026-07-28 connection, the handler isn’t told of the cancellation and runs to the end.
My advice: don’t let cancellation stop at the protocol layer while the backend keeps working and keeps costing money. In an async tool, anyio already cancels the call you’re awaiting. A plain def tool runs in a thread that nothing can interrupt, so call anyio.from_thread.check_cancelled() between its backend calls (HTTP clients, database commands, queue operations), as the SDK’s docs show.
Timeouts, and what statelessness changes
The cancellation page also covers timeouts:
- Implementations SHOULD set timeouts on all requests they send. When one expires, they cancel with the transport’s mechanism: close the stream on HTTP, send the notification on stdio.
- SDKs SHOULD let timeouts be configured per request. The Python SDK takes
read_timeout_secondson theClientor on onecall_tool. - An implementation MAY reset the clock when a progress notification arrives, but SHOULD still enforce a maximum timeout, so a misbehaving peer can’t hold resources forever.
Hosts add their own limits. Claude Code, as of October 2026:
- applies a per-server wall-clock tool timeout, which progress notifications don’t extend;
- applies an idle timeout when a server sends neither a response nor progress: five minutes for HTTP servers and 30 minutes for stdio servers by default;
- moves main-conversation calls that run past two minutes into a background task, so the session isn’t blocked.
Three things statelessness changes
These three are my advice, not the spec’s:
- A dropped connection cancels work for good. Resumability is gone (part 7 has the transport side), so a flaky proxy or a load balancer’s idle timeout cancels your server-side work, and the client re-issues the request with a new ID. Make long tools idempotent, or move them to Tasks, which a later part covers.
- Cancelling between rounds of Multi Round-Trip Requests (MRTR) just means not retrying. After
input_required, the original request has finished, so there’s nothing to cancel, and yourrequestStateexpiry cleans up (part 13 has the flow). Each retry is a new request, so a client that wants progress must send a freshprogressToken. - Progress doubles as a keep-alive. Long silences trip intermediary idle timeouts; progress every 10 to 20 seconds keeps the stream alive and host idle timers quiet. It doesn’t buy unlimited time, as the timeout rules above and Claude Code’s wall-clock limit show.
Method and caveats
- Built from my guide’s chapter on these three topics, written against the 2026-07-28 specification; Claude Code’s limits are as of October 2026.
- The Python sample ran against
mcp2.3.0 on 9 October 2026 in memory, and a variant of it over stdio; the outputs shown come from those runs. - Where the cancellation page is more precise than my notes on keep-alives (a peer resets its timeout on progress only if it chooses to), this article follows the specification.