teachyou.ai academy
← All posts
MCP

MCP Server Timeout Handling: Avoiding Hung Agent Sessions

Ira Menon · May 24, 2026 · 14 min read

An agent that never responds is worse than one that fails loudly. When an MCP server hangs mid-call, the calling agent doesn't crash — it just sits there, burning its context window on silence, waiting for a response that will never arrive. The user sees a spinner. The orchestrator sees a healthy process. Nobody sees the problem until someone gets paged at 2 a.m. because a support bot has been "thinking" for eleven minutes. If you've shipped any agent that talks to tools over the Model Context Protocol, you've probably already met this failure mode, even if you didn't have a name for it. This article is about naming it, reproducing it on purpose, and closing it off with timeout handling that actually holds under load.

Why MCP servers hang in the first place

MCP servers are, at their core, long-running processes that expose tools, resources, and prompts to a client over stdio or HTTP/SSE transport. That "long-running" part is exactly where things go wrong. A hang isn't usually a bug in the protocol — it's a bug in the assumptions the server author made about the outside world.

The most common causes, in rough order of frequency:

  • Upstream API calls with no deadline. A tool handler calls a third-party REST API or database and never sets a client-side timeout. If that upstream service stalls — not errors, just stalls — your MCP server stalls with it.
  • Unbounded retries. A naive retry loop that doesn't cap total elapsed time can retry a slow-failing dependency for minutes while looking, from the outside, exactly like a hang.
  • Blocking I/O on the wrong thread. In a single-threaded or event-loop-based server, one blocking file read or DNS lookup can freeze the entire process, including the heartbeat/keep-alive logic that would otherwise signal life.
  • Deadlocks between concurrent tool calls. If two tools share a mutex, connection pool, or in-memory cache with a lock, a slow tool call can hold that lock long enough to starve every other request.
  • Process supervision gaps. The server process is alive (the OS sees a PID), but the event loop inside it is wedged. Health checks that only check "is the process running" miss this entirely.
  • Silent stdio buffering issues. For stdio-transport servers, a child process that writes to stdout without flushing, or that fills a pipe buffer because nobody is reading fast enough, can look identical to a genuine timeout from the client's point of view.

None of these are exotic. They're the same failure modes that have plagued RPC systems for decades. What's new is the blast radius: an agent session isn't a single request-response pair, it's a chain of tool calls stitched together by a model that is patiently waiting for each one to resolve before deciding what to do next. One stuck link freezes the whole chain.

What "hung" actually costs you

It's worth being concrete about the damage, because "the agent got stuck" undersells it.

  1. Context window burn. Some agent harnesses insert a "still waiting" system message or retry the same tool call, quietly eating tokens that never produce useful output.
  2. User trust. A user who watches a spinner for 90 seconds and gets nothing will not file a bug report — they'll just stop trusting the agent, even after you fix the underlying issue.
  3. Session poisoning. If the transport doesn't recover cleanly, the *next* tool call in the same session can also fail, because the connection is left in a half-open state.
  4. Resource leaks. Hung connections often hold open sockets, database connections, or file handles. Enough of them and you tip into cascading failure — the classic thread-pool exhaustion pattern, just wearing an "agentic" costume.
  5. Cost. If your MCP server is billed per invocation upstream (an LLM call inside a tool, a paid API), a hang that eventually times out and retries can multiply your bill for zero additional value delivered.

None of this requires a catastrophic bug. A single dependency having a bad five minutes is enough, and dependencies have bad five minutes constantly.

Layer your timeouts, don't rely on one

The single biggest mistake teams make is setting one timeout somewhere — usually at the client, sometimes at the load balancer — and assuming that's coverage. In practice you need timeouts at every hop, each one shorter than the layer above it, so the innermost failure surfaces first and cleanly.

Think of it as concentric rings:

  1. Upstream call timeout — inside the tool handler, the HTTP client or DB driver call itself has a deadline.
  2. Tool execution timeout — the MCP server wraps the whole tool handler (which might make several upstream calls) in an overall deadline.
  3. Transport/session timeout — the MCP client (the agent runtime) has a per-request timeout waiting for a response over stdio or SSE.
  4. Session/turn timeout — the orchestrator has a ceiling on how long an entire agent turn can take before it aborts and surfaces an error to the user.

Here's what that looks like for a Python MCP server tool handler that calls an external API:

import asyncio
import httpx
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("weather-tools")

# Ring 1: upstream call timeout, tight and specific to this dependency
UPSTREAM_TIMEOUT = httpx.Timeout(connect=3.0, read=5.0, write=3.0, pool=2.0)

# Ring 2: overall tool execution timeout, slightly looser than the upstream one
TOOL_TIMEOUT_SECONDS = 8.0

@mcp.tool()
async def get_current_weather(city: str) -> dict:
    """Fetch current weather for a city, bounded by two timeout layers."""
    async def _fetch():
        async with httpx.AsyncClient(timeout=UPSTREAM_TIMEOUT) as client:
            resp = await client.get(
                "https://api.example-weather.com/v1/current",
                params={"city": city},
            )
            resp.raise_for_status()
            return resp.json()

    try:
        return await asyncio.wait_for(_fetch(), timeout=TOOL_TIMEOUT_SECONDS)
    except asyncio.TimeoutError:
        return {
            "error": "timeout",
            "message": f"Weather lookup for '{city}' exceeded {TOOL_TIMEOUT_SECONDS}s",
        }
    except httpx.HTTPStatusError as e:
        return {"error": "upstream_error", "status": e.response.status_code}

Notice two things. First, the httpx.Timeout object splits connect, read, write, and pool-acquisition timeouts separately — a single flat number hides which phase actually stalled. Second, asyncio.wait_for wraps the whole operation with its own, slightly larger, deadline so that even if the httpx timeout logic itself misbehaves, the tool still returns *something* to the model instead of hanging the event loop.

Returning a timeout, not raising one, into the agent's context

This is the detail that separates "handled" from "handled well." When a tool call times out, don't let the exception propagate as an unhandled transport error that just looks like a dead connection to the agent. Catch it and return a structured, model-readable result.

An LLM-driven agent can recover gracefully from "the weather API timed out after 8 seconds, try a different city or skip this step" — it's just text, and the model reasons over it like any other tool output. It cannot recover from a raw stack trace or, worse, from receiving nothing at all until the client-side transport timeout fires 30 seconds later and produces a generic "MCP request failed" message with zero context.

Structure the error payload the same way you'd structure a success payload, so the model can act on it:

{
  "error": "timeout",
  "tool": "get_current_weather",
  "elapsed_seconds": 8.02,
  "retryable": true,
  "message": "Upstream weather API did not respond in time. Safe to retry once."
}

Giving the model retryable: true/false matters more than it looks. Without it, agents tend to either retry forever (burning tokens on a dependency that's genuinely down) or give up on something that was a one-off blip. A one-bit signal fixes both failure modes.

Setting the client-side timeout correctly

On the agent runtime side — whatever is acting as the MCP client — you need a request timeout that assumes the server-side layering above might still fail. Treat it as a backstop, not the primary defense.

A few concrete rules that hold up in practice:

  • Set it meaningfully above your slowest legitimate tool, not the average. If your p50 tool call is 200ms but a legitimate PDF-parsing tool takes 25 seconds, a 10-second client timeout will kill valid work.
  • Differentiate by tool, not globally, if your MCP client supports per-call overrides. A search_web tool and a run_migration tool have wildly different legitimate durations.
  • Never set it to `None`/infinite "just to be safe." That's the single decision most responsible for hung sessions in the wild — someone disabled the timeout during debugging and never turned it back on.
  • Log the timeout value alongside the actual elapsed time whenever a call is aborted. Without this pairing, you can't tell whether you need a longer timeout or a faster server.

A minimal example of a client wrapper enforcing this discipline:

async function callMcpTool(client, toolName, args, { timeoutMs = 15000 } = {}) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);

  const start = Date.now();
  try {
    const result = await client.callTool(
      { name: toolName, arguments: args },
      { signal: controller.signal }
    );
    return result;
  } catch (err) {
    const elapsed = Date.now() - start;
    if (controller.signal.aborted) {
      return {
        error: "client_timeout",
        tool: toolName,
        timeoutMs,
        elapsedMs: elapsed,
      };
    }
    throw err;
  } finally {
    clearTimeout(timer);
  }
}

The key idea: the client never blocks past timeoutMs regardless of what the server does, and it always returns a structured object the calling agent logic can branch on — instead of letting an unhandled rejection kill the whole session.

Heartbeats and keep-alives for long-running tools

Some tools are legitimately slow — a video transcription job, a large dataset export, a multi-step web crawl. Timing these out at 10 seconds is wrong, but leaving them unbounded is equally wrong. The fix is not "one long timeout," it's progress signaling.

If your transport supports streaming (SSE or a notification channel), emit periodic progress updates so the client can distinguish "still working, making progress" from "stuck." MCP's progress notification mechanism exists for exactly this — a tool that expects to run long should report intermediate progress rather than going silent for the entire duration.

A pattern that works well in practice:

  1. The tool immediately acknowledges the call and reports status: started.
  2. Every N seconds (or every meaningful step — page 3 of 10 crawled, chunk 4 of 12 transcribed), it emits a progress notification.
  3. The client resets its "stall detector" — not its overall deadline — every time a progress notification arrives.
  4. If no progress notification arrives within the stall window, the client treats the tool as hung and aborts, even if the overall deadline hasn't been reached yet.

This separates two different questions that a single timeout conflates: "has too much total time passed" versus "has the tool stopped making progress." A 90-second video transcription is fine. A tool that hasn't emitted a single byte of progress in 90 seconds is not fine, even if your overall deadline is 120 seconds.

Handling partial failure in multi-tool chains

Agent turns rarely call one tool in isolation — they chain several: search, then fetch, then summarize, then write. If tool #2 in a five-tool chain times out, what should happen to #1's already-committed side effects, and to #3 through #5?

Design for this explicitly rather than discovering it in production:

  • Idempotent tool design. Where possible, make tools safe to retry — a create_ticket tool should accept a client-generated idempotency key so a timeout-triggered retry doesn't create a duplicate ticket.
  • Compensating actions. If a tool has side effects that can't be made idempotent, pair it with an explicit rollback tool the agent can call, and document in the tool description when to use it.
  • Fail the chain deliberately, not silently. If tool #2 times out, the orchestrator should surface that clearly to the model as part of the next turn's context — "step 2 of 5 timed out, steps 3-5 were not executed" — rather than pretending the whole chain succeeded because no exception bubbled all the way up.
  • Bound total chain time, separate from any individual tool's timeout. A five-tool chain where each tool is allowed 10 seconds could still legitimately run for 50 seconds; if your product needs a response inside 15 seconds, that's a chain-level budget the orchestrator has to enforce, not something any individual tool's timeout can guarantee.

Observability: you can't fix what you can't see

Timeout handling without logging is just delayed confusion. At minimum, instrument three things for every MCP tool call:

  • Start timestamp, end timestamp, and outcome (success, timeout, error) for every invocation, tagged by tool name.
  • A histogram of durations per tool, not just an average — averages hide the long tail that actually causes hangs. Track p50, p95, and p99 separately.
  • Timeout-to-total-call ratio as an alerting signal. If a tool's timeout rate crosses a threshold (say, 2% of calls), that's a leading indicator of an upstream degradation, well before it becomes a full outage.

A simple structured log line per call goes a long way:

import time
import logging

logger = logging.getLogger("mcp.tools")

async def timed_tool_call(tool_name, coro, timeout_seconds):
    start = time.monotonic()
    try:
        result = await asyncio.wait_for(coro, timeout=timeout_seconds)
        logger.info(
            "tool_call_ok",
            extra={
                "tool": tool_name,
                "duration_ms": round((time.monotonic() - start) * 1000, 1),
            },
        )
        return result
    except asyncio.TimeoutError:
        logger.warning(
            "tool_call_timeout",
            extra={
                "tool": tool_name,
                "timeout_seconds": timeout_seconds,
                "duration_ms": round((time.monotonic() - start) * 1000, 1),
            },
        )
        raise

Once this exists, you can answer the question that actually matters after an incident: was this tool's timeout too aggressive, or is the upstream dependency genuinely degrading? Without the data, every postmortem turns into guesswork and someone just doubles the timeout value, which usually just delays the next hang instead of preventing it.

Testing for hangs on purpose

Most teams discover their timeout handling is broken in production, because nobody tested the failure path deliberately. Treat "the dependency stalls" as a test case with the same seriousness as "the dependency returns a 500."

Practical ways to do this:

  1. Inject artificial delay. Wrap a test double for the upstream dependency that sleeps for longer than your configured timeout, and assert that the tool returns a structured timeout error within the expected window, not an unhandled exception.
  2. Simulate partial responses. For streaming or chunked responses, test what happens if the stream opens but never completes — this catches the "process alive, event loop wedged" class of bug that health checks miss.
  3. Load-test concurrent hangs. Spin up N concurrent calls to a tool with an artificially stalled dependency and confirm the server doesn't exhaust its connection pool or thread pool as a result — one slow dependency shouldn't take down unrelated tool calls.
  4. Chaos-test the transport itself, not just the tool logic — kill the MCP server process mid-call and verify the client's timeout still fires and the agent session recovers with a sane error rather than hanging indefinitely on a dead pipe.

None of this is exotic infrastructure. A handful of asyncio.sleep-based fakes in your test suite will catch the majority of hangs before a user ever does.

A short checklist before you ship an MCP server

  • Every upstream network or disk call has an explicit timeout — no client library left at its default, since defaults are often either absent or too generous.
  • Every tool handler is wrapped in an outer execution deadline independent of the upstream call's own timeout.
  • Timeout errors return structured, model-readable payloads, including whether a retry is safe.
  • Long-running tools emit progress notifications so clients can distinguish "slow" from "stuck."
  • The MCP client enforces its own timeout per tool, sized to the slowest legitimate case, not the average.
  • Multi-tool chains have a chain-level time budget in addition to individual tool timeouts.
  • Side-effecting tools are idempotent or have documented compensating actions.
  • Duration and outcome are logged per tool call, with alerting on rising timeout rates.
  • Hangs are tested deliberately, with injected delays and killed processes, before they're discovered in production.

Closing thoughts

Timeout handling isn't a defensive afterthought bolted onto an MCP server after the first outage — it's part of the contract a tool makes with the agent calling it. A tool that can hang indefinitely is a tool that hasn't finished being designed, no matter how correct its happy path is. The teams that get this right treat every external call as something that might never return, and build the layered timeouts, structured error payloads, and progress signaling to match.

If you're building agent systems on MCP and want to go deeper — designing tool schemas, wiring up transports correctly, handling concurrency and state across a session — that's exactly the ground we cover in Building & Integrating MCP Servers here on teachyou.ai, with hands-on labs instead of just slides.