# Progress, cancellation and timeouts when nothing can resume

October 20, 2026 · Part 16 of 35

![Progress, cancellation and timeouts when nothing can resume](https://pournasserian.com/_astro/lead.DHaJT4ou_2hKQru.webp)

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](https://pournasserian.com/writing/mcp-2026-elicitation). 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](https://pournasserian.com/writing/mcp-2026-stateless-requests) 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:

```json
{ "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`:

```json
{ "jsonrpc": "2.0", "method": "notifications/progress",
  "params": { "progressToken": "reindex-7", "progress": 40, "total": 120, "message": "Indexed 40 of 120 documents" } }
```

The [progress page](https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/progress) 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](https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/cancellation) 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. |

```json
{ "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.

```mermaid
sequenceDiagram
    participant C as Client
    participant S as Server

    Note over C,S: Streamable HTTP
    C->>S: POST tools/call (id 7), SSE response opens
    S-->>C: notifications/progress
    C--xS: client closes the response stream
    Note over S: Disconnect = cancel request 7<br/>stop work, free resources, no response

    Note over C,S: stdio
    C->>S: tools/call (id 8)
    C->>S: notifications/cancelled (requestId 8, reason)
    Note over S: Stop work for request 8, send no response
```

### 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.

```mermaid
sequenceDiagram
    participant C as Client
    participant S as Server
    C->>S: request (id 123)
    Note over S: Processing starts
    C-)S: notifications/cancelled (id 123)
    alt Work already finished
        S-->>C: response (id 123)
        Note over C: Client ignores the late response
    else Still running
        Note over S: Stop processing, no response
    end
```

## 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`:

```python
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:

```text
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](https://py.sdk.modelcontextprotocol.io/handlers/cancellation/) 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](https://code.claude.com/docs/en/mcp), 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](https://pournasserian.com/writing/mcp-2026-transports) 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](https://pournasserian.com/writing/mcp-2026-multi-round-trip-requests) 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.

I'm [Amir Pournasserian](https://pournasserian.com/about). I build AI and platform systems for a living, maintain [FluentCMS](https://pournasserian.com/open-source/fluentcms), [YeSvelte](https://pournasserian.com/open-source/yesvelte) and [mcp-my-site](https://pournasserian.com/open-source/mcp-my-site), and write here about what I find along the way.
