Skip to content

Progress, Cancellation, and Timeouts

Not every tool call is instant. Importing a catalog, running a test suite, rendering a report — these take seconds to minutes, and during that time two things can go wrong: the client gives up (a timeout), or the user changes their mind and nobody tells the server. Progress notifications fix the first; cancellation fixes the second.

Both stay inside a single request. If the work would outlive any reasonable request — minutes to hours, or must survive a disconnect — reach for the Tasks extension instead.

Progress is client-initiated: a client that wants updates puts a progressToken in the request’s _meta. The token is a string or integer the client chooses; it must be unique across the client’s active requests.

{
"jsonrpc": "2.0",
"id": 7,
"method": "tools/call",
"params": {
"name": "import_catalog",
"arguments": { "urls": ["https://a.example/1.csv", "https://a.example/2.csv"] },
"_meta": {
"progressToken": "imp-7",
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}

No token, no progress — a server must only send progress for tokens it actually received on an in-flight request.

{
"jsonrpc": "2.0",
"method": "notifications/progress",
"params": {
"progressToken": "imp-7",
"progress": 1,
"total": 2,
"message": "Imported https://a.example/1.csv"
}
}

Rules that matter:

  • progress must increase with every notification, even when total is unknown.
  • total and message are optional; both values may be floating point.
  • Servers may send none, or as many as they like, but should rate-limit rather than flood.
  • Notifications must stop once the request completes.
sequenceDiagram
  participant C as Client
  participant S as Server
  C->>S: tools/call with progressToken
  S-->>C: notifications/progress 1 of 3
  S-->>C: notifications/progress 2 of 3
  S-->>C: notifications/progress 3 of 3
  S-->>C: result
  Note over C,S: no more progress after the result
Progress rides on the request it belongs to
  • Streamable HTTP: the server answers the POST with an SSE stream scoped to that request; progress events arrive on it, followed by the final JSON-RPC response, which closes the stream. Progress never appears on a subscriptions/listen stream.
  • stdio: everything shares one stdout channel; the client matches notifications to requests by progressToken.
server.registerTool(
'import_catalog',
{ description: 'Import catalog files', inputSchema: z.object({ urls: z.array(z.string()) }) },
async ({ urls }, ctx) => {
const progressToken = ctx.mcpReq._meta?.progressToken;
for (let i = 0; i < urls.length; i++) {
await importOne(urls[i]);
if (progressToken !== undefined) {
await ctx.mcpReq.notify({
method: 'notifications/progress',
params: { progressToken, progress: i + 1, total: urls.length, message: `Imported ${urls[i]}` },
});
}
}
return { content: [{ type: 'text', text: `Imported ${urls.length} files` }] };
}
);

The client can abandon a request it no longer cares about. How depends on the transport, because the two transports have different ideas of what “a request” physically is.

TransportHow the client cancelsWhy
stdiosends notifications/cancelled with the request’s requestId (and optional reason)one shared channel — there is no per-request stream to close
Streamable HTTPcloses the SSE response stream of that requesteach request has its own stream, so the disconnect is unambiguous; no notifications/cancelled is expected
{
"jsonrpc": "2.0",
"method": "notifications/cancelled",
"params": { "requestId": 7, "reason": "user pressed stop" }
}

On either transport the server should stop work as soon as practical and must not send any further messages for that request — no late progress, no result. The client, for its part, should ignore anything that does arrive after it cancelled; races happen.

A few edge rules worth knowing:

  • A subscriptions/listen stream is cancelled the same way — close the stream on HTTP, or send notifications/cancelled for the listen request’s id on stdio.
  • Cancellation is best-effort and cooperative. A tool halfway through a non-reversible side effect may finish it; design tools so that what has already happened is visible (return handles, make writes idempotent).
  • Legacy note: on stdio, notifications/cancelled is the only client-to-server notification left in the 2026-07-28 core protocol.

MCP does not mandate a timeout; it leaves the policy to the client, and that is a design choice you will make in every host you build. Two sane options:

  1. Fixed deadline — simplest. Every request gets, say, 60 s; if it elapses, cancel it. Predictable, but it kills legitimately long tools.
  2. Progress-reset deadline — the timer restarts each time a notifications/progress arrives, with a hard ceiling so a chatty-but-stuck server cannot run forever. This is the pattern most hosts converge on: a silent server is presumed dead; a talking one gets patience, up to a limit.

Servers should assume option 2 exists and emit progress on anything slower than a couple of seconds — it is the difference between “still importing 4,000 rows” and “request timed out”.

When a timeout fires, the client should actually cancel (close the stream or send notifications/cancelled), not just stop listening, so the server can free resources.

  • Progress is for the user, not the model. Put human-readable text in message; do not encode data the LLM is supposed to act on.
  • Never block on progress. Sending a notification must not stall the work; treat it as fire-and-forget.
  • A long tool with no progress is a bug in production. If the work cannot report progress, it is probably a candidate for the Tasks extension.
When may a server send notifications/progress?
How does a Streamable HTTP client cancel an in-flight tools/call?
Which statement about the progress value is correct?
After receiving a cancellation, what must the server not do?