Skip to content
Chapter 4 of 7 · Debugging MCPTimeouts, progress and cancellation

18 min read

Timeouts, progress and cancellation

On this page

A stalled call raises a question a frame log cannot answer: what was supposed to stop waiting. Chapter 3, where a frame log located a stalled call by joining two logs on a request id, ends with the last frame that moved and in which direction. Whether anybody was still entitled to wait for the next one, and what should happen to a request whose caller has already given up, are questions about deadlines and connection state.

Both of those live almost entirely on the client side of this protocol, which is a surprise to anyone arriving from a backend RPC background, and the two notification types that exist to move a deadline are the parts of MCP most often implemented on one side and not the other. Getting them wrong produces a server that holds resources for work nobody will read, and a client that reports every failure past its ceiling with the same message.

Nothing in the request tells the server how long it has

A tools/call carries a method, an id, a tool name and arguments. It does not carry a deadline, and there is no header, field or convention by which the client's patience is communicated to the server. This is a genuine difference from the RPC systems most backend engineers already have habits from: gRPC propagates a deadline in request metadata and every hop can read it, so a server knows it has 200 milliseconds left and can decline to start a query that averages 400. In MCP the server knows nothing of the sort. It receives a request, works on it for as long as the work takes, and writes a response into a transport that may or may not still have a reader on the other end.

Two consequences follow. The first is that a client-side timeout does not stop server-side work; it stops the client waiting. Unless something else intervenes, the handler continues to hold its database connection, finish its query, serialise 41 KB of results and write them to a socket nobody is reading. On a server where a slow tool is called often, that is how a timeout on one side becomes connection-pool exhaustion on the other, with a symptom — everything is slow now — that points nowhere near the client that caused it.

The second is that "how long should this wait" is a decision made entirely by whoever wrote the client, usually once, usually as a constant, usually without knowing which servers it would eventually talk to. Different clients pick different numbers, which is why the same server can look reliable to one and flaky to another, and why the answer to "what is the timeout" is never in your codebase.

The first of those two keeps producing surprises, because it is a single property predicting several unrelated-looking symptoms. The property is that a deadline is a fact about the client's own bookkeeping and about nothing else: when it fires, the client stops waiting and forgets the request, and no message has crossed the wire, so nothing about the server has changed. A timeout is a statement about how long one program is prepared to remember an outstanding call. Four things follow from that, and they are usually met as four separate bugs.

The work runs to completion, because nothing interrupted it. The resources it holds are therefore held for the duration of the work rather than the duration of the wait, which is why the cost of a timeout is paid by the server long after the client has moved on. A retry then stacks rather than replaces: the first attempt was never stopped, so a second runs alongside it, and a tool slow enough to trip the deadline is by construction slow enough for the copies to overlap. Load on the server rises with the retry count at precisely the moment the client has concluded the server is struggling.

The consequence that makes an argument between two teams unwinnable is the fourth. The handler finishes and returns a result, so the server's own log records a success for the call the user watched fail, and every dashboard built from server-side logs is blind to that failure by construction. Both teams are reading correct instruments. A server-side success rate that never dips and a user-visible failure anybody can reproduce are not in contradiction, because the two are counting different events — one counts handlers that returned, the other counts answers that arrived — and the difference between the totals is exactly the set of requests whose reader left. That also makes the set measurable rather than notional: log the elapsed time of each handler on one side and each expiry on the other, and the requests present in the first record and absent from the second are the orphans, by name and by id.

Two messages exist precisely so the deadline can move

The protocol's answer to a long-running call is not a longer constant. It is a notification the server sends while it works, using a token the client attached to the original request:

json
{
  "jsonrpc": "2.0",
  "method": "tools/call",
  "id": 51,
  "params": {
    "name": "rebuild_search_index",
    "arguments": { "since": "2026-07-01" },
    "_meta": { "progressToken": "idx-51" }
  }
}
json
{
  "jsonrpc": "2.0",
  "method": "notifications/progress",
  "params": {
    "progressToken": "idx-51",
    "progress": 3400,
    "total": 12000,
    "message": "reindexing invoices"
  }
}

A client that implements an idle deadline rather than a total one resets its timer whenever one of these arrives, so a job emitting progress every ten seconds can run for twenty minutes under a thirty-second timeout without ever tripping it. The token is the join key, exactly as the request id is for a request and its response, and a server that omits the token or invents its own will emit notifications the client cannot associate with anything and will discard.

What such a notification changes is precisely one quantity, and seeing which one explains both when it helps and when it cannot. A total deadline measures the time from the request going out until now, and a progress notification does not touch that quantity at all, so a client holding a total deadline expires the call on schedule however much progress arrives. An idle deadline measures the time since the last frame was received. A progress notification is a frame received, so it sets that quantity back to zero. The deadline stops bounding how long the call may take and starts bounding how long the server may be silent, and the ceiling a server has to respect is no longer its total runtime but the longest gap between any two of its own notifications.

That single change of quantity has three consequences, and each of them catches somebody. A server that computes for a long uninterrupted stretch and reports progress at the end is not helped in the slightest, because the notification arrives after the moment it was meant to reset has already passed; progress has to be emitted from inside the loop. The cadence then has to be chosen without knowing the deadline it is racing, since the client never tells the server its number, so the only safe cadence is a conservative one. And a progress notification is evidence that the code path emitting it is still running, which is not the same as evidence that the work is advancing — a loop that has stopped making headway while still ticking a counter will hold the deadline off indefinitely, and a client reading "progress arrived" as "progress happened" has traded an unbounded stall for an unbounded stall that reports well.

The message going the other way is cancellation:

json
{
  "jsonrpc": "2.0",
  "method": "notifications/cancelled",
  "params": { "requestId": 51, "reason": "timeout" }
}

This is the client saying that request 51 no longer has a reader. It is a notification rather than a request, so there is no acknowledgement and no guarantee it arrived before the server finished. A server that handles it stops the work, releases the resources, and does not send a response; a server that ignores it does the work anyway and writes a response the client will discard as unrecognised. Handling it is the difference between a client-side timeout costing one abandoned request and costing a database connection held for another ninety seconds. The one request that must never be cancelled this way is initialize, because the session does not exist yet for a cancellation to refer to.

The tempting reading of that message is that it stops the work, and it is tempting for a good reason: nearly every other system an engineer arrives from gives cancellation teeth, whether by closing the stream, by returning an error to the caller, or by killing a thread. Here it has none. A notification carries no id, so there is no response to it, no acknowledgement and no error, which means the sender learns nothing whatever about whether it arrived, whether it was understood, or whether anything was released as a result. Emitting notifications/cancelled is a request that the server stop, and a client treating it as a guarantee has adopted a defence that cannot fail visibly: a server dropping the message on the floor is indistinguishable, from the client's side, from one that acted on it immediately. The only place the answer exists is on the server — a handler that logs when it was told to stop, and whether it did. The same absence of a reply is why initialize is the request that cannot be cancelled this way at all: a cancellation is a message about a session's outstanding requests, and until initialize completes there is no session to hold one, so the only lever available before a session exists is closing the transport.

There is an unavoidable race in both directions. The response and the cancellation can cross on the wire, so a server may receive a cancellation for a request it has already answered, and a client may receive a response to a request it has already abandoned. Both sides are required to tolerate that, which in practice means ignoring a cancellation for an unknown id and ignoring a response for a request no longer being tracked, quietly and without logging an error at warning level.

State diagram: start → Sent; Sent → InFlight (server parsed and dispatched); InFlight → InFlight (progress notification resets client timer); InFlight → Answered (response frame written and read); InFlight → Cancelled (client sends notifications/cancelled); InFlight → Expired (client deadline fires); Expired → Orphaned (server keeps working, no reader); Cancelled → Orphaned (server ignores cancellation); Answered → start; Orphaned → start.

A hang is diagnosed from connection state, not from frames

For a call that is still hanging right now, the useful evidence is about the things a frame log cannot describe: whether there is still a process, whether there is still a connection, and whether anything is counting.

QuestionEvidenceHow you get it
Is the server process alive?pid present, exit code if notps on the child; for stdio, whether the client saw EOF on stdout
Is the connection open?socket state, SSE stream attachedss -tnp on both ends; on HTTP, whether a GET stream is still held
Is the session still valid?404 on a POST carrying the session idthe client's HTTP status log, which is below the JSON-RPC layer
Is the handler blocked?a stack for the worker thread or taskpy-spy dump, a JVM thread dump, node --inspect and a pause
Is a deadline running at all?configured value and whether it firedthe client's config, plus the presence of notifications/cancelled
Did anything intermediate expire?idle timeouts on a proxy or load balancerthe proxy's access log, where the request appears with a 504

The last row deserves attention because it is where "the server never answered" most often turns out to be false. A managed load balancer with a 60-second idle timeout will close a Streamable HTTP connection carrying a tool call that takes 75 seconds, and it will do so without either endpoint's application code noticing anything except a stream that ended. The server's log shows a successful handler; the client's shows nothing arriving; the only record of the truth is in a component neither team was looking at. "Debugging MCP Servers: A Practical Troubleshooting Guide" catalogues a number of these symptom-to-cause mappings, including the ECONNREFUSED and EPIPE families that indicate the transport never came up at all.

The reason that row goes missing so reliably is a belief about coverage rather than an oversight. Two teams each hold a log, both logs are correct, and the natural conclusion is that between them they cover the path. They do not. The path has three participants and there are two logs, and every component able to terminate a connection is a place the request can end. A component that terminates connections without running either team's code will not appear in either team's evidence however carefully that evidence is read, so the conclusion drawn from two clean logs is that no one terminated anything — which is the one conclusion the two logs are incapable of supporting. Monitoring both endpoints is monitoring the endpoints; it is not monitoring the session, and the difference only surfaces when something in between decides the session is over.

Stack evidence resolves the ambiguity that connection state cannot. A handler blocked on a lock, a handler blocked on an outbound HTTP call with no timeout of its own, and a handler in a tight loop look identical from outside the process and completely different in a stack dump. Taking one is thirty seconds of work and is skipped astonishingly often, usually because by the time anyone thinks of it the process has been restarted.

A bounded failure is an invitation to retry

The other thing a timeout produces, usually within a week of shipping, is a retry — either an explicit one in the client or an implicit one from the model, which sees an error and tries the call again. Both are reasonable responses to a bounded failure and both are dangerous against a server that has no way to recognise a repeat.

The protocol carries no idempotency key, meaning no field a client can repeat so that a server recognises the second attempt as the same operation and performs it once. A request id is unique within a session and is regenerated on the retry, so a create_refund that timed out after 30 seconds while the payment provider took 34 is now a second refund, and the first one succeeded. The trace shows two requests and one response, which reads as "one of them failed" rather than "both of them worked". This is the failure mode with a real financial number attached, and it comes from the interaction of two changes that were each individually sensible.

That trace repays reading carefully, because the count is not quite what it appears to be. Two requests went out and two answers came back: the provider answered the first attempt four seconds after the client had already stopped waiting for it, and answered the second in the ordinary way. What the trace shows as one response is one response that could be paired. The first attempt's answer arrived carrying an id the client was no longer tracking, and by the tolerance rule above it was discarded as unrecognised, quietly and without a warning. The refund nobody knows about is exactly the frame that failed to pair. This is the same id matching that made a stalled call diagnosable in chapter 3, where two frame logs were joined on a request id, running in the other direction: there an unmatched request meant a missing answer, and here an unmatched answer means a completed side effect with no record on the client at all.

There is a consequence in that arithmetic which runs against the instinct a deadline is usually chosen on. A duplicate is created exactly when the retry goes out before the first attempt's side effect has landed, which is to say whenever the deadline is shorter than the work. Tightening the deadline does not narrow that window, it widens it, because a shorter deadline issues the retry earlier against the same fixed provider latency. The setting that feels cautious is the one manufacturing duplicates, and the only deadline that removes them outright is one longer than the slowest work anybody will ever wait on, which is the unbounded stall the change was made to stop. That is why the answer here has to be idempotency rather than tuning.

Servers that expose anything with side effects need to handle it on their own side, because there is no protocol layer to lean on: accept a caller-supplied idempotency value as a normal tool argument, describe it in the schema so the model knows to hold it stable across a retry, and deduplicate on it for some window measured in minutes. Where that is impractical, the fallback is to make the write cheap to detect — a natural key, a uniqueness constraint, a query the tool runs before it writes — and to return the existing record with a note that it already existed rather than a conflict error the model will interpret as a reason to try a third time.

The same reasoning applies to the intermittency a cap manufactures. A 3% error rate with no visible pattern invites a concurrency theory, and a concurrency theory invites changes that alter timing, which is the trap chapter 7 is about. Sizing the deadline per tool is what keeps the cap from inventing a population of failures that has no cause to find.

Sizing per tool is necessary and it is not sufficient, because a percentile is a statement about a population and one tool can contain more than one population. If a fixed share of a tool's calls takes a fixed long time, that share sits inside the tool's own tail, and every percentile above the complement of that share reports the slow path's duration rather than the fast path's. A deadline sized from a percentile taken over a mixed population therefore has no good setting: put it below the slow mode and it fails those calls every time, put it above and it stops bounding the fast ones. The figure that tells you what to do is not a percentile at all but the shape underneath it — whether the latency distribution has one mode or two, and if two, which input selects between them. A tool whose slow path is chosen by a customer's record count has an input readable before the call is made, and a deadline is the wrong instrument for a condition you can detect in the arguments.

A deadline belongs on the client because that is the only place that knows how long the user is prepared to wait, and the two notifications exist so that the number does not have to be a guess about the slowest tool anybody will ever write. A server that emits progress is asking for more time in a way a client can grant; a client that emits cancellation is releasing a server from work nobody will read. Implementing both is what makes a single generous constant unnecessary, and a single generous constant is what produces the unbounded stall the eleven-line PR was written to stop.

Bounding a stall does not tell you why it happened, and a stall is only one of the failures that lands on a board in a given week. A call that never returns is the one a deadline addresses. A client reporting an invalid argument for a request that never reached the server, and a feature that simply never happens on one client with no error anywhere, need different evidence entirely, and one of them will not appear in a trace at all.

Debugging MCP — read the current version at https://daviddominguez.dev/learn/en/debugging-mcp/timeouts-progress-and-cancellation

Back to top