Field notes
How to handle timeouts in an MCP server
MCP timeouts belong to the client, not your server. Here is what the SDK does when a call times out, and why your handler keeps running after it.
Mark
Head of Marketing, MCPOrbit
- Published
- Updated
- · Updated
- Read time
- · 10 min read
In the Model Context Protocol (MCP), the timeout belongs to the caller, not to your server. The client decides how long it will wait, gives up on its own, and sends a cancellation. Your tool handler is not stopped for you. Unless you read the abort signal, it keeps running to completion long after nobody is listening.
That gap is where the real bug lives. A request that times out looks like a clean failure from the client side. On the server side the work carries on, holding a database connection or an upstream request that no longer has a destination. Under load you are not timing out one call, you are accumulating orphaned work.
Who enforces the timeout in MCP, the client or the server?
The client. The 2026-07-28 specification puts the duty on whoever sent the request: implementations SHOULD establish timeouts for all sent requests, to prevent hung connections and resource exhaustion. When no response arrives in time, the sender SHOULD cancel the request and stop waiting. For a tools/call the sender is the client, so the client owns the clock.
Your server is never told what that deadline is. There is no field in the request carrying the caller's timeout, and no way to ask. A tool handler cannot know whether it has one second or one minute. This is the single most useful thing to understand about MCP timeouts, and it is why server-side defensive work matters.
The default matters because almost nobody overrides it. In @modelcontextprotocol/client 2.0.0, DEFAULT_REQUEST_TIMEOUT_MSEC is 60000. Every request gets one minute unless the caller passes a timeout in the per-request options.
What happens on the wire when an MCP request times out?
The client raises an error locally and tells the server to stop. Both halves are automatic. Here is a real run: a tool asked to work for 3000ms, called with a 1000ms timeout.
>>> {"method":"tools/call","params":{"name":"slow_naive","arguments":{"ms":3000}},"jsonrpc":"2.0","id":1}
[server t+ 16ms] slow_naive: starting 3000ms of work
>>> {"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":1,"reason":"SdkError: Request timed out"}}
THREW after 1005ms: code=REQUEST_TIMEOUT isTimeout=true message="Request timed out"
[server t+ 3018ms] slow_naive: FINISHED all 3000ms of workThree things to read out of that. The error is an SdkError whose code is SdkErrorCode.RequestTimeout, and that code is the string 'REQUEST_TIMEOUT', not the numeric JSON-RPC code you may be expecting from older SDKs. The client sent notifications/cancelled by itself, with a reason naming the timeout. And the server logged FINISHED all 3000ms of work two seconds after the client had already given up.
The spec splits cancellation by transport, which is worth knowing before you debug this. On stdio there is no per-request stream to close, so the client MUST send notifications/cancelled naming the request id. On Streamable HTTP, closing the response stream is itself the cancellation signal, and the spec says no notifications/cancelled message is required or expected. In practice the SDK client sends the notification on both transports.
One rule changed in the 2026-07-28 revision and catches people migrating. Servers MUST NOT send notifications/cancelled for any purpose other than tearing down a subscriptions/listen stream. If you have a server emitting cancellations for its own reasons, that is now non-conformant.
Why does an MCP server keep working after the client gives up?
Because nothing stops it. The cancellation arrives, the SDK aborts the request's signal, and a handler that never reads that signal is unaffected. await sleep(3000) does not care that anyone cancelled.
This is the default outcome, not an edge case. The obvious way to write a tool handler produces it. Here is the server used for every measurement in this post.
import { McpServer } from '@modelcontextprotocol/server'
import { serveStdio } from '@modelcontextprotocol/server/stdio'
import { z } from 'zod'
const log = (...args) => console.error('[server]', ...args)
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
function build() {
const server = new McpServer({ name: 'timeout-lab', version: '1.0.0' })
// Ignores cancellation. This is the shape of most handlers people write.
server.registerTool(
'slow_naive',
{ description: 'Sleeps, ignoring cancellation', inputSchema: z.object({ ms: z.number() }) },
async ({ ms }) => {
log(`slow_naive: starting ${ms}ms of work`)
await sleep(ms)
log(`slow_naive: FINISHED all ${ms}ms of work`)
return { content: [{ type: 'text', text: `done after ${ms}ms` }] }
}
)
// Honors the abort signal on ctx.mcpReq.
server.registerTool(
'slow_cancellable',
{ description: 'Sleeps, aborts on cancellation', inputSchema: z.object({ ms: z.number() }) },
async ({ ms }, ctx) => {
const { signal } = ctx.mcpReq
log(`slow_cancellable: starting ${ms}ms, signal.aborted=${signal.aborted}`)
await new Promise((resolve, reject) => {
const timer = setTimeout(resolve, ms)
signal.addEventListener('abort', () => {
clearTimeout(timer)
log('slow_cancellable: ABORTED, work stopped early')
reject(new Error('cancelled'))
})
})
log(`slow_cancellable: FINISHED all ${ms}ms of work`)
return { content: [{ type: 'text', text: `done after ${ms}ms` }] }
}
)
return server
}
serveStdio(build)And the client that drives it. Save both files, then run node client.mjs.
import { Client, SdkError, SdkErrorCode, DEFAULT_REQUEST_TIMEOUT_MSEC } from '@modelcontextprotocol/client'
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'
console.log('DEFAULT_REQUEST_TIMEOUT_MSEC =', DEFAULT_REQUEST_TIMEOUT_MSEC)
const transport = new StdioClientTransport({
command: process.execPath,
args: ['server.mjs'],
stderr: 'inherit',
})
// Log every frame the client puts on the wire.
const realSend = transport.send.bind(transport)
transport.send = async (msg, opts) => {
console.log(' >>> WIRE', JSON.stringify(msg))
return realSend(msg, opts)
}
const client = new Client({ name: 'timeout-lab-client', version: '1.0.0' })
await client.connect(transport)
const t0 = Date.now()
try {
await client.callTool({ name: 'slow_naive', arguments: { ms: 3000 } }, { timeout: 1000 })
} catch (err) {
console.log(
`THREW after ${Date.now() - t0}ms:`,
`code=${err.code}`,
`isTimeout=${err.code === SdkErrorCode.RequestTimeout}`,
`message=${JSON.stringify(err.message)}`
)
}
// Give the server time to prove it is still working.
await new Promise((r) => setTimeout(r, 3500))
await client.close()
process.exit(0)npm install @modelcontextprotocol/[email protected] @modelcontextprotocol/[email protected] [email protected]
node client.mjsHow do you cancel an MCP tool call that is already running?
Read ctx.mcpReq.signal, the second argument to your tool callback. It is a standard AbortSignal, aborted when the caller cancels. Wire it into whatever you are waiting on: fetch takes a signal directly, and so do most database drivers.
The slow_cancellable tool above does exactly that, and on stdio it works. Same 3000ms of work, same 1000ms client timeout.
[server t+ 4524ms] slow_cancellable: starting 3000ms, signal.aborted=false
>>> {"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":2,"reason":"SdkError: Request timed out"}}
THREW after 1004ms: code=REQUEST_TIMEOUT message="Request timed out"
[server t+ 5528ms] slow_cancellable: ABORTED, work stopped earlyThe handler stopped 1004ms after it started, a third of the way through its job. No FINISHED line. The work was abandoned and the resources released, which is what the spec asks of a server receiving a cancellation: stop processing, free associated resources, and do not send a response for the cancelled request.
Does cancellation reach the handler over Streamable HTTP?
Not early, in the setup tested here. This is the result worth planning around, because it is the opposite of what the stdio run suggests. Serving the same two tools through createMcpHandler and toNodeHandler on node:http, the abort signal fired about 1ms after the handler had already finished all 3000ms of work.
[server t+ 36ms] handler start, aborted=false
THREW after 1005ms: code=REQUEST_TIMEOUT message="Request timed out"
[server t+ 3041ms] handler finished all 3000ms
[server t+ 3044ms] *** SIGNAL FIRED ***That was checked three ways and behaved the same each time: with responseMode: 'auto', with responseMode: 'sse', and with an explicit caller-side AbortController instead of a timeout. The signal consistently fired at teardown, never during the call. The reason follows from the stateless model in the 2026-07-28 revision. The cancellation lands as a separate HTTP POST, and a per-request handler has no route from that POST back to the instance holding the in-flight call.
So do not rely on ctx.mcpReq.signal as your only stop condition on HTTP. Give the work its own deadline and combine the two signals. AbortSignal.any makes that one line, and it degrades correctly: on stdio the caller's cancellation wins, on HTTP your own budget does.
server.registerTool(
'bounded',
{ description: 'Work with its own deadline', inputSchema: z.object({}) },
async (_args, ctx) => {
// Abort when the caller cancels OR when our own budget runs out,
// whichever happens first.
const budget = AbortSignal.timeout(10_000)
const signal = AbortSignal.any([ctx.mcpReq.signal, budget])
const res = await fetch('https://upstream.example/report', { signal })
return { content: [{ type: 'text', text: await res.text() }] }
}
)Implementations SHOULD always enforce a maximum timeout, regardless of progress notifications, to limit the impact of a misbehaving client or server.
How do you stop a long MCP tool call from timing out?
Send progress notifications and have the caller opt into resetting the clock. The opt-in is the part people miss. resetTimeoutOnProgress defaults to false, so a server that dutifully reports progress into a default client still times out on schedule.
Add a tool that reports progress. The progress token arrives at ctx.mcpReq._meta.progressToken and is only present if the caller asked for progress, so check it before sending.
server.registerTool(
'slow_with_progress',
{
description: 'Sleeps, reporting progress',
inputSchema: z.object({ ms: z.number(), steps: z.number() }),
},
async ({ ms, steps }, ctx) => {
const token = ctx.mcpReq._meta?.progressToken
for (let i = 1; i <= steps; i++) {
await sleep(ms / steps)
if (token !== undefined) {
await ctx.mcpReq.notify({
method: 'notifications/progress',
params: { progressToken: token, progress: i, total: steps },
})
}
}
return { content: [{ type: 'text', text: `done after ${ms}ms` }] }
}
)Then compare the three caller configurations. The measured results are in the comments.
// Default: progress does NOT extend the deadline. Times out at ~1001ms.
await client.callTool(
{ name: 'slow_with_progress', arguments: { ms: 3000, steps: 6 } },
{ timeout: 1000, onprogress: (p) => console.log('progress', p) }
)
// resetTimeoutOnProgress: each notification restarts the 1000ms clock.
// Resolves after ~3014ms.
await client.callTool(
{ name: 'slow_with_progress', arguments: { ms: 3000, steps: 6 } },
{ timeout: 1000, resetTimeoutOnProgress: true, onprogress: (p) => console.log('progress', p) }
)
// maxTotalTimeout is the ceiling progress cannot lift.
// Throws at ~2009ms with "Maximum total timeout exceeded".
await client.callTool(
{ name: 'slow_with_progress', arguments: { ms: 3000, steps: 6 } },
{ timeout: 1000, resetTimeoutOnProgress: true, maxTotalTimeout: 2000 }
)Read the middle case carefully. A 1000ms timeout carried a 3000ms job to completion because every progress notification restarted the clock. That is the mechanism, and it is also the hazard: with resetTimeoutOnProgress alone, a server that keeps emitting progress can hold a client forever. maxTotalTimeout is the ceiling that cannot be lifted. It threw at 2009ms with the message Maximum total timeout exceeded, under the same REQUEST_TIMEOUT code.
For work that genuinely outlives a request, progress is the wrong tool. Return immediately with a job id and let the caller poll, or use the Tasks extension introduced in the 2026-07-28 revision. Stretching a request timeout to cover a ten-minute job means holding a connection open for ten minutes, and any proxy in the path gets a vote on whether that survives.
Frequently asked questions
Frequently asked questions
- What is the default MCP request timeout?
- 60 seconds.
DEFAULT_REQUEST_TIMEOUT_MSECis60000in@modelcontextprotocol/client2.0.0, applied per request unless the caller passes its owntimeoutin the request options. - Can an MCP server set its own timeout for a tool call?
- Not for the caller's clock. The spec assigns the timeout to the sender of the request, and the server is never told what it is. A server can enforce its own deadline internally, which is the recommended pattern: combine
ctx.mcpReq.signalwith anAbortSignal.timeoutof your own. - Why does my MCP tool keep running after the client times out?
- Because your handler is not reading the abort signal. The client's cancellation aborts
ctx.mcpReq.signal, but code likeawait sleep(3000)or an un-signalledfetchignores it and runs to completion. Pass the signal into whatever you await. - How do I cancel an in-flight MCP request?
- Pass an
AbortSignalin the request options, or let the timeout fire. Either way the SDK client sends anotifications/cancellednaming the request id. On Streamable HTTP the spec treats closing the response stream as the cancellation signal instead. - Does sending progress notifications prevent an MCP timeout?
- Only if the caller sets
resetTimeoutOnProgress: true, which is not the default. With the defaultfalse, progress notifications arrive and the request still times out on the original schedule. - What error does an MCP client throw on timeout?
- An
SdkErrorwithcodeequal toSdkErrorCode.RequestTimeout, which is the string'REQUEST_TIMEOUT'. The message isRequest timed out, orMaximum total timeout exceededwhenmaxTotalTimeoutis what fired.
About the author
Mark
Head of Marketing, MCPOrbit
Mark leads marketing at MCPOrbit, the free desktop client for the Model Context Protocol. He writes the build-it and reliability guides, and the code in them is run before it ships.
