A tool call that gets cut off is four different failures, and only one of them is a timeout
Every agent framework has a number it will not let a tool call exceed. When a long call hits it, the framework
reports one word — timeout — and the agent does one thing: call again. If the tool was a paid, non-idempotent
call, that is the worst possible response, because the first call usually still finished.
I maintain a decision endpoint that takes 180–300 seconds by design. It is a long call on purpose, and no
argument in the tool schema makes it shorter: the limit lives in the client, not in the call. So I have watched
the same failure arrive in four different shapes, and I collected the documented client behaviour for each.
The point of this post is a decision table you can paste next to your retry logic: what came back, what it means,
and whether retrying is safe.
Four "timeouts", four different amounts of information
| What the caller sees | Did the job run? | Can you retry? | Correct move |
|---|---|---|---|
| Client aborts the call (its own limit fires) | Usually yes — the server keeps going | No, until you have looked | Retrieve by id; if you have no id, list what your credential created |
Server returns an error result (isError: true) |
It was rejected before or during work | Depends on the block you were given | Branch on the HTTP status in the error block, not on the word "timeout" |
| Framework moves the call to a background task and hands you a task id | Yes | No | Wait for the task notification, or retrieve by id |
| Idle abort: the call sent nothing for the idle window | Usually yes | No | Same as the first row — retrieve |
Two of these look like a timeout to the model and are not one. The third is not a failure at all — it is the
framework being polite. And the first is the one that costs money: the answer exists, it was paid for, and the
agent calls again because "timeout" told it to.
What each client actually does, from its own documentation
Timeout handling is not transport behaviour, so the MCP specification cannot promise you anything here. This table
is assembled from the clients' own docs; where a client does not document a knob, I say so rather than guess.
| Client | Startup / connect | Per tool call | Idle window | Notes |
|---|---|---|---|---|
| Claude Code |
MCP_TIMEOUT (ms) |
per-server timeout field in .mcp.json (ms), overriding MCP_TOOL_TIMEOUT; unset ≈ 28 h |
5 minutes for HTTP/SSE, 30 min for stdio; CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT; 0 disables |
Calls running past 2 minutes move to a background task and report through a task notification |
| Cline | — |
timeout field in cline_mcp_settings.json, in seconds
|
— | The historical bug: calls could die near 60 s regardless of the setting (issue #2296) |
| Cursor / Windsurf / VS Code | not documented in their MCP pages | not documented | not documented | Treat the transport/client default as unknown; do not tune what you cannot see |
Two consequences worth more than the table itself.
First: the wall-clock limit is usually not the limit that fires. On an HTTP connection, Claude Code also runs a
per-request timer that starts at the greater of 60 seconds, the server's tool timeout, and MCP_TIMEOUT — but that
timer stops at the first response byte. A server that thinks for five minutes and then answers is racing the
idle window, not the wall-clock one.
Second: an idle window is the one that punishes a slow-but-correct tool. A tool call that sends no response
and no progress notification for the idle window is aborted, no matter how generous the per-call limit is. If your
tool takes minutes and emits nothing in between, your real ceiling is the idle window — five minutes on HTTP in
Claude Code — and you may never have seen that number, because it is documented one page away from the timeout you
went looking for.
The mitigation is cheap and it is documented in the same place: a per-server timeout of at least 1000 ms also
acts as a floor on the idle timeout — the client will not abort that server's calls for idleness sooner than the
per-server value. So the practical config for a long call is not "raise the big number", it is:
{
"mcpServers": {
"LongCall": {
"type": "http",
"url": "https://example.invalid/mcp",
"timeout": 600000
}
}
}
(timeout here is milliseconds, and it is the smaller number for that server in the idle comparison — the exact
semantics are in the client's MCP page; read it before you copy a number from a blog post. Mine.)
The retrieval path, and where it breaks
If the call was cut off after the server accepted it, the right primitive is a retrieval call, not a second
call. Design it as a tool, not as something the agent does with curl:
- With an id — the job's status, and once it finished, the same payload the original call would have returned.
- Without an id — the ids that this credential created recently. This is the case that matters, because a client-side abort never gave you an id.
- Read-only and free, so it is always safe to repeat. A retrieval call has no business being billable or idempotency-sensitive.
-
The credential stays with the host. Inside an MCP host the agent usually does not hold the API key, so a
retrieval implemented over the vendor's REST API from inside the agent fails with
401while the same job is perfectly readable through the tool surface. Retrieval over the tool surface works precisely because the host attaches the credential and the key is never a tool argument — putting it in an argument would push it into prompts and transcripts.
Two implementation details I would copy if you are building the server side:
Answer "not yours" and "does not exist" identically. If they differ, the retrieval endpoint becomes an oracle
for probing other tenants' job ids. Same status, same body, on purpose — and write that down in the tool
description, or a future maintainer will "fix" it into a leak.
State the window, and return an empty list as a normal result. My server keeps ids for 7 days and answers an
unknown id the same way it answers someone else's. An empty list is not an error and there is no "id 0" sentinel;
if you invent one, every client eventually probes it. Here is the same contract over REST, for an operator who does
hold the key:
# list what this credential created (no id needed)
curl -s -H "Authorization: Bearer $PASS" https://api.example.invalid/v1/jobs
# fetch one job
curl -s -H "Authorization: Bearer $PASS" 'https://api.example.invalid/v1/jobs?job_id=<id>'
Test the retrieval path before you need it
This is the operational step that people skip, and it is twenty seconds of work: call retrieval with no argument
while nothing is broken.
| Result | What it tells you |
|---|---|
401 / unauthorized
|
Retrieval is not reachable from where the agent runs. Your recovery plan is a diagram, not a capability — fix the reachability before you need it |
| Success with an empty list | The credential is valid, the endpoint answers, and an empty window is handled. This is the state you want before the first cut-off |
| Success with ids | Retrieval works end to end. Fetch the oldest id and confirm the payload matches what a call would have returned |
Do it once in your own harness, and record the outcome in the tool's description so the next agent that reads
the tool list knows whether it can retrieve or only retry. A tool description is the one place a recovery policy
reaches the model without any code.
The design conclusion
For anything non-idempotent and paid, make retry impossible to choose by accident:
- Return an id with every result, including the successful ones. An id you only issue on failure is an id nobody recorded.
- Document the expected duration in the tool description — "reserve 180–300 seconds" is not marketing, it is the number the client needs to stop cutting the call off.
-
Say the timeout is the client's setting, not a parameter. An agent that believes it can pass
timeout: 300will pass it forever and never change the actual behaviour. - Make retrieval read-only, free, idempotent and credential-free from the agent's side.
- Write the not-yours rule into the contract, so the answer stays uninformative to strangers.
The shape I run is at https://mcp.turingcorp.net for anyone who wants to see the tool descriptions rather than
trust a summary: tools/list is open by design, so you can read the recovery contract — including the retrieval
tool and the 180–300 second expectation — without a credential. If you would rather see it decide first, the
recorded decisions and the benchmark method are public.
Top comments (1)
The row I'd worry about is "without an id." Listing what this credential created works for one agent, and it gets ambiguous the moment two runs share the key. With parallel agents that's the normal case: run A gets cut off, lists recent jobs, sees run B's job finish in the same window, and adopts it as its own answer. Nothing errors. The payload is just for the wrong input.
The fix I'd reach for is letting the caller mint the id. The agent passes a request key as an argument, the server treats a repeat of that key as "return the existing job," and retrieval works by that key even when the abort happened before the server id ever came back. Then a retry stops being dangerous instead of merely discouraged, which matters, because some agent will retry no matter what the tool description says.