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:

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() }] };
});

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#