Fixing "MCP error -32001: Request timed out" errors
Kashish Hora
Co-founder of AgentCat
If a tool call sits there long enough, the client gives up and you get MCP error -32001: Request timed out. The code is real and it comes from the SDK, not from your server. In the TypeScript SDK -32001 is RequestTimeout, one of two SDK-defined codes that live in the range JSON-RPC reserves for server errors (-32000 to -32099) (TypeScript SDK, ErrorCode). The exact message you see, MCP error -32001: Request timed out, is assembled by the SDK's McpError when a pending request outlives its timeout (TypeScript SDK, protocol.ts).
That framing is the whole fix. This is a client-side deadline, not a server crash. The client started a clock when it sent the request, the clock ran out before a response came back, and the client rejected the call. Your server may still be happily working. So the fixes are about the relationship between how long the work takes and how long the client is willing to wait: make the work fit the deadline, tell the client to wait longer, or keep the client informed so it resets the clock. For the JSON-RPC error object itself and how MCP uses error codes, the JSON-RPC in MCP guide is the reference; this one is about the timeout specifically.
The short version
The default request timeout in the TypeScript SDK is 60 seconds (TypeScript SDK, protocol.ts). It is a default, not a ceiling, and you have three ways to stop tripping it, in order of preference:
- Emit progress from the server. A
notifications/progressmessage resets the client's timeout when the client turns onresetTimeoutOnProgress, so a slow-but-alive tool keeps buying itself more time. - Raise the timeout on the client. Both official SDKs let a caller pass a longer per-request timeout.
- Return fast, finish later. If a job genuinely takes minutes, hand back an id right away and let the caller poll, rather than holding one request open.
The rest of this guide is when to reach for each, with the real API surface for both SDKs.
First, confirm it's actually a timeout
The error carries its own deadline. The TypeScript SDK puts the timeout it waited on into the error's data, so a caught McpError looks like this (TypeScript SDK, protocol.ts):
McpError: MCP error -32001: Request timed out
{ code: -32001, data: { timeout: 60000 } }The 60000 tells you the client waited 60 seconds, which is the SDK default. If you see a different number there, someone has already configured a custom timeout and that's the value that expired.
Before you touch timeouts, rule out the boring explanation: a server that never responds at all. Watch the server logs while you reproduce the error. If the server logs show it received the request and is grinding through work, you have a genuine long-running operation and the sections below apply. If the server shows nothing, or the process died, this isn't really a timeout problem, it's a connection or startup problem, and -32000: Connection closed is the guide you want.
One more distinction worth making early. A slow tool that eventually finishes is a timeout problem. A tool that runs but returns a logical failure (an upstream API 500'd, a file was missing) is not a protocol error at all: that comes back as a normal result with isError: true, which the model can read and react to. Timeouts are strictly about the clock running out before any response arrives.
Fix 1: emit progress so the timeout resets
This is the fix that fits how MCP is designed to work, and it's the one the audit-era version of this guide got half-right and half-wrong. The mechanism is real; the claim that TypeScript can't use it was not.
Here's how it fits together. When a client wants progress updates for a request, it puts a progressToken in the request's _meta. The server can then send notifications/progress messages referencing that token, each carrying a progress value, an optional total, and an optional human-readable message (MCP spec, Progress). That much is standard MCP.
The timeout reset is an SDK behavior layered on top. In the TypeScript SDK, a caller passes resetTimeoutOnProgress: true in the request options, and every progress notification that arrives resets the request's timeout clock (TypeScript SDK, protocol.ts):
const result = await client.request(
{ method: "tools/call", params: { name: "reindex", arguments: {} } },
CallToolResultSchema,
{
timeout: 60000, // reset to this window on each progress ping
resetTimeoutOnProgress: true,
maxTotalTimeout: 600000, // hard cap: 10 min no matter how much progress
onprogress: (p) => console.log(`${p.progress}/${p.total ?? "?"}`),
},
);Two things make this safe. resetTimeoutOnProgress means the 60-second window restarts each time a progress ping lands, so a tool that reports every few seconds never times out. And maxTotalTimeout is the backstop: it's an absolute limit that the reset can't extend past, so a runaway job still gets cut off (TypeScript SDK, protocol.ts). Without resetTimeoutOnProgress, progress notifications still fire your onprogress callback but do not touch the timeout.
The server side is the easy half. In the Python SDK's MCPServer (renamed from FastMCP in mcp 2.0.0, the current PyPI release — the module moved from mcp.server.fastmcp to mcp.server.mcpserver), request a Context argument and call report_progress. It sends a notifications/progress only if the client actually supplied a progress token, so it's a no-op when nobody's listening (Python SDK, Context.report_progress):
from mcp.server.mcpserver import MCPServer, Context
mcp = MCPServer("indexer")
@mcp.tool()
async def reindex(collection: str, ctx: Context) -> str:
total = count_documents(collection)
for i, doc in enumerate(iter_documents(collection)):
index_one(doc)
await ctx.report_progress(progress=i + 1, total=total)
return f"Reindexed {total} documents"The signature is report_progress(progress, total=None, message=None), and the token plumbing is handled for you (Python SDK, Context.report_progress). On the TypeScript server, the tool callback's extra argument gives you sendNotification, which you use to send the same notifications/progress message keyed by the token the client passed (TypeScript SDK, protocol.ts). (If you're still on mcp<2, the import is from mcp.server.fastmcp import FastMCP, Context and the class is FastMCP("indexer") — same decorator, same report_progress call.)
The important caveat: reset-on-progress is a TypeScript SDK feature. The Python SDK forwards progress to your progress_callback but doesn't reset the read timeout when a progress notification arrives, so on a Python client you also need Fix 2.
Fix 2: raise the client's timeout
Sometimes the honest answer is that the work takes four minutes and you'd rather just wait. Both SDKs let the caller widen the window per request, which is cleaner than a blanket global timeout because only the one slow tool pays the cost.
On the TypeScript client, pass timeout (and optionally maxTotalTimeout) in the request options, exactly as in the snippet above; if you skip it, you get the 60-second default (TypeScript SDK, protocol.ts).
On the Python client, the timeout is a plain float of seconds as of mcp 2.0.0, the current PyPI release (earlier releases took a datetime.timedelta here — more on that below). You can set a default for the whole session or override it per call, and the per-call value wins (Python SDK, ClientSession):
# The client awaits this, so it has to be async, not a plain lambda.
async def on_progress(progress: float, total: float | None, message: str | None) -> None:
print(f"{progress}/{total}")
async with ClientSession(
read, write,
read_timeout_seconds=30, # session default, in seconds
) as session:
await session.initialize()
result = await session.call_tool(
"reindex",
{"collection": "docs"},
read_timeout_seconds=300, # override for this call, in seconds
progress_callback=on_progress,
)Before mcp 2.0.0, this same code used from datetime import timedelta and read_timeout_seconds=timedelta(seconds=30) / timedelta(minutes=5). The migration is mechanical — pass the equivalent number of seconds, or call .total_seconds() on an existing timedelta — but a leftover timedelta doesn't fail loudly here: the first request that arms the timeout crashes inside anyio with a TypeError that never names the parameter, so it's worth grepping for after an upgrade (python-sdk migration guide, timeouts take float seconds).
Worth knowing so a caught error doesn't confuse you: as of mcp 2.0.0, a Python client timeout raises an MCPError with the same code as TypeScript, -32001 (REQUEST_TIMEOUT, importable from mcp.types) — the message changed too, from "Timed out while waiting for response to ClientRequest. Waited 5.0 seconds." to "Request 'tools/call' timed out" (python-sdk migration guide, client request timeouts). Before mcp 2.0.0, the Python client used the HTTP 408 Request Timeout value instead (Python SDK, shared/session.py); if you're still on mcp<2, 408 — not -32001 — is the code to check for. Either way, match on the REQUEST_TIMEOUT constant rather than a hardcoded number: a migrated e.error.code == 408 check runs without error and silently never matches.
If you're running against a client you don't control, like a desktop host that configures servers through a JSON file, check that client's own docs for a per-server timeout setting. The value lives in the client, and the field name varies by client, so there's no single config snippet that's correct everywhere.
Fix 3: return fast, finish in the background
Progress and bigger timeouts both keep one request open for the whole job. Past a certain length, that's the wrong shape. A twenty-minute export shouldn't hold a request open for twenty minutes, because any dropped connection loses all of it.
The durable pattern is to split the work: one tool kicks off the job and returns an id immediately, a second tool reports status when the model asks. The first call finishes in well under the timeout, and the slow work happens off to the side.
@mcp.tool()
async def start_export(dataset: str) -> str:
job_id = enqueue_export(dataset) # hand off to a queue/worker
return f"Export started. Poll with check_export('{job_id}')."
@mcp.tool()
async def check_export(job_id: str) -> str:
job = get_job(job_id)
if job.done:
return f"Done: {job.result_url}"
return f"Still running: {job.percent}% complete"You trade the simplicity of a single blocking call for resilience: nothing is holding a socket open, a flaky network doesn't cost you the whole run, and the model gets a natural way to check back. MCP has a first-class version of this shape too, as of 2026-07-28: the official Tasks extension, io.modelcontextprotocol/tasks (MCP Tasks extension). The server hands back a task handle instead of a result, and the client polls it with tasks/get, supplies follow-up input with tasks/update, and stops it with tasks/cancel. Being an extension, it's off unless both sides opt in through capabilities. It replaces the experimental tasks from 2025-11-25, and the redesign dropped the blocking tasks/result and the tasks/list method, so polling is the whole model now.
When you want to stop a slow request
If a call is taking too long and you'd rather abandon it than wait, MCP has a first-class way to say so. As of 2026-07-28 the signal depends on the transport (MCP spec, Cancellation):
- stdio: the client sends
notifications/cancelled, carrying therequestIdto cancel and an optionalreason. There's no per-request stream to close, so the notification is the signal. - Streamable HTTP: closing the SSE response stream is the cancellation, and the server must treat the client disconnect as cancelling that request. No
notifications/cancelledis sent or expected; this revision defines no client-to-server notifications over Streamable HTTP at all.
Either way, the receiver should stop work and free resources, and shouldn't send a response for the cancelled request. Earlier revisions added one rule on top, that the initialize request must not be cancelled; that one is moot now, since 2026-07-28 removed initialize.
The SDKs wire this to ordinary cancellation primitives. In the TypeScript SDK, passing an AbortSignal in the request options aborts the in-flight request; in Python you cancel the surrounding task the way you'd cancel any anyio operation. Cancellation and a timeout end up in a similar place (no useful result), but cancellation is the deliberate, tell-the-other-side version, whereas -32001 is the client giving up on its own.
There's a race worth handling: because a cancellation travels over the network, it can arrive after the server already finished (MCP spec, Cancellation). Both sides need to tolerate a response that shows up for a request that was already cancelled, and a cancellation for a request that already completed.
Choosing among the three
- The work usually finishes but occasionally runs long, and you want the model to see it's progressing? Emit progress and set
resetTimeoutOnProgresson a TypeScript client. This is the default answer for most slow tools. - The work reliably takes longer than 60 seconds and there's no natural progress to report? Raise the client timeout for that one call.
- The work can take many minutes, or must survive a dropped connection? Return an id and poll, or adopt the Tasks extension if your client supports it.
These stack. A long import can report progress and run with a raised total timeout, and a genuinely huge job can do both while still being structured as a background task. Once real clients are hitting your server, the useful next question is which tools actually run long and how often they stall, which is the kind of visibility AgentCat gives you for a live MCP server. For monitoring connections and catching stalls before they become timeouts, see implementing connection health checks.
Related Guides
Implementing connection health checks and monitoring
Implement health checks and monitoring for MCP servers to ensure reliable production deployments.
Fixing "MCP error -32000: Connection closed" errors
Resolve MCP error 32000 connection closed issues with platform-specific solutions and debugging steps.
Understanding the JSON-RPC protocol and how it's used in MCP
How MCP uses JSON-RPC 2.0 for every client-server message: request, response, and notification shapes, the per-request metadata that replaced the initialize handshake, core methods, and the real error codes.