If you read one line: the default your framework hands you is await tool() with no deadline, and it's a landmine — the first stalled downstream API hangs the whole agent turn. Two things have to be true: the call has a deadline (AbortSignal.timeout(ms) in JS, asyncio.timeout(s) in Python), and cancelling actually stops the work by propagating that abort all the way to the fetch/DB call. Over MCP the protocol does this for you if you cooperate — pass extra.signal into every request your tool makes.
Why a missing timeout is worse than it looks#
Picture the failure. A tool calls a third-party API. That API doesn't error — it stalls, holding the socket open, sending nothing. Your await never resolves. The agent's turn can't advance because it's waiting on a tool result that isn't coming. The user watches a spinner. Eventually something upstream — a load balancer's idle timeout, the model provider's request cap — kills the entire request, and the user gets a generic 5xx two minutes later instead of "that tool timed out, here's what I got without it."
You never chose that behavior. It's just what await does with no clock on it. And the fix is not one thing — it's two.
Rule 1 — the call must have a deadline#
A deadline makes your await return after N seconds instead of never. Both runtimes have a primitive for exactly this.
JavaScript — hand AbortSignal.timeout() to anything that accepts a signal:
async function callTool(fetchArgs, ms = 15_000) {
const signal = AbortSignal.timeout(ms); // aborts after `ms`
try {
const res = await fetch(url, { ...fetchArgs, signal });
return await res.json();
} catch (err) {
if (err.name === "TimeoutError" || err.name === "AbortError") {
return { error: `tool timed out after ${ms}ms` }; // clean, not a hang
}
throw err;
}
}
Python — wrap the call in asyncio.timeout():
async def call_tool(args, seconds: float = 15.0):
try:
async with asyncio.timeout(seconds):
return await do_work(args)
except TimeoutError:
return {"error": f"tool timed out after {seconds}s"}
Pick the number from the tool's real p99 latency plus headroom — and, critically, make it shorter than the timeout of whatever calls your agent, so you return a clean, partial result first instead of your caller killing the whole turn.
Rule 2 — cancelling must actually stop the work#
Here's the mistake that feels done but isn't. asyncio.timeout and Promise.race-style timeouts return control to you — but if the signal never reached the underlying fetch or query, that work keeps running. You stopped waiting; you didn't stop working. The abandoned call still holds a connection, still burns tokens if it's another model call, still holds a database lock. Under load, those orphans stack up and take the process down anyway — the exact outcome the timeout was supposed to prevent.
The fix is to propagate one signal from the deadline down to the leaf call. AbortSignal.timeout() already is that signal — the win is passing it through every layer, not creating a fresh timer at each one:
// The signal born at the top reaches the actual network call.
async function tool(args, signal) {
const rows = await db.query(sql, { signal }); // DB honors it
const enriched = await fetch(api, { signal }); // so does fetch
return shape(rows, await enriched.json());
}
// one deadline governs the whole chain:
await tool(args, AbortSignal.timeout(15_000));
If a library in your chain doesn't accept a signal, that library is where your cancellation leaks — wrap it, bound it separately, or replace it. A deadline is only as real as the least cancellable call underneath it.
Over MCP, the protocol does the bookkeeping#
If your tool runs behind an MCP server, you don't invent this machinery — the protocol lifecycle defines it, and you cooperate:
- The client owns the deadline. It sets a per-request timeout. When that elapses with no result, it sends
notifications/cancelledfor the request and stops waiting. - The server gets a signal. The SDK surfaces an
AbortSignalto your tool handler asextra.signal. Your only job is Rule 2 — pass it into everyfetch/DB call inside the tool:
server.tool("search", schema, async (args, extra) => {
const res = await fetch(url, { signal: extra.signal }); // cancel propagates
return { content: [{ type: "text", text: await res.text() }] };
});
- Long tools can buy time. Include a
progressTokenand emit progress notifications as the work advances; a client may extend its timeout while progress keeps arriving — but the spec is clear that a maximum cap always applies. Progress is how a legitimately slow tool avoids being killed at 30s; it is not a license to run forever.
The one edge case: a cancel can arrive after you finished#
Cancellation is a notification, and notifications race with responses. The result you sent and the client's cancel can cross on the wire — so your handler can receive a cancel for a request that's already complete or unknown. The spec says to treat this as fine to ignore, and you must:
function onCancelled(requestId) {
const inflight = pending.get(requestId);
if (!inflight) return; // already done or never existed — do nothing
inflight.abort(); // live work: stop it
pending.delete(requestId);
}
Treat cancellation as advisory: if there's live work to stop, stop it; if there isn't, do nothing. Letting a late or unknown cancel throw turns a harmless, expected race into a crash — the opposite of what all this was for.
The checklist#
- Every tool call has a deadline. No bare
await tool(). - The deadline's signal reaches the leaf
fetch/query — cancelling stops the work, not just the waiting. - The tool's timeout is shorter than the caller's, so you return the clean error first.
- On MCP: pass
extra.signaldown; useprogressTokenfor slow tools with a hard cap; treat a late cancel as a no-op. - A tool that fits no reasonable bound becomes asynchronous — return a job id and poll — instead of setting the timeout to infinity.



