Progress, cancellation and timeouts when nothing can resume

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

  1. Progress is opt-in, per request. The client sends a progressToken, and the server MAY send notifications/progress while the request runs, with a value that MUST increase each time.
  2. Cancellation depends on the transport. On Streamable HTTP, closing the response stream is the cancellation; on stdio, the client MUST send notifications/cancelled.
  3. A cancellation can cross the result on the wire. Both sides MUST handle that gracefully.
  4. Every request SHOULD have a timeout. The side waiting MAY reset its clock when progress arrives, but SHOULD still enforce a maximum.
  5. 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:

  • progress MUST 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:

TransportCancellation signal
Streamable HTTPClosing the response stream is the cancellation. The server MUST treat a client disconnect as cancelling that request. No notifications/cancelled is required or expected.
stdioThere’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.

Note (Client, Server): Streamable HTTP; Client → Server: POST tools/call (id 7), SSE response opens; Server → Client: notifications/progress; Client → Server: client closes the response stream; Note (Server): Disconnect = cancel request 7 stop work, free resources, no response; Note (Client, Server): stdio; Client → Server: tools/call (id 8); Client → Server: notifications/cancelled (requestId 8, reason); Note (Server): Stop work for request 8, send no responseServerClientStreamable HTTPDisconnect = cancel request 7stop work, free resources, no responsestdioStop work for request 8, send no responsePOST tools/call (id 7), SSE response opensnotifications/progressclient closes the response streamtools/call (id 8)notifications/cancelled (requestId 8, reason)

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.

Client → Server: request (id 123); Note (Server): Processing starts; Client → Server: notifications/cancelled (id 123); alt: Work already finished; Server → Client: response (id 123); Note (Client): Client ignores the late response; else: Still running; Note (Server): Stop processing, no responseServerClientProcessing startsClient ignores the late responseStop processing, no responsealt[Work already finished][Still running]request (id 123)notifications/cancelled (id 123)response (id 123)

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_seconds on the Client or on one call_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:

  1. 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.
  2. 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 your requestState expiry cleans up (part 13 has the flow). Each retry is a new request, so a client that wants progress must send a fresh progressToken.
  3. 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 mcp 2.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.