Your Agent Stalls When One MCP Tool Server Slows Down
A single flaky MCP tool server can hang your whole agent. The model is fine, the provider is fine, but one third-party tool starts answering in forty seconds instead of one, and every turn that touches it stalls for the full client timeout, three times over once retries kick in.
A team I was helping ran an agent with about twenty MCP tools wired in: internal services for the account data, a couple of search tools, and three or four third-party servers for things they did not want to build, enrichment, geocoding, a tax lookup. It was solid for weeks. Then one Tuesday afternoon the whole agent went sludgy. Turn times that normally sat under three seconds climbed to over a minute, support tickets started, and the on-call engineer spent an hour staring at model latency dashboards that showed nothing wrong.
The model was fine. The model provider was fine. One of the third-party tool servers, the tax lookup, had started answering in forty seconds instead of under one. It had not gone down, which would have been easier. It had gone slow. Because the agent called every tool through the same path with the same generous sixty second timeout, every turn that touched that one tool sat and waited the full sixty seconds before moving on, and the retry waited sixty seconds again. One slow dependency out of twenty was now the only thing the loop was doing.
This is the failure mode that the move to MCP quietly made everyone's problem. Your agent's tools are no longer functions you wrote; a growing share of them are remote servers run by someone else, with no service guarantee, that can get slow or flaky on their own schedule. The protocol standardizes how the agent discovers and calls those tools, but not how it survives one of them having a bad afternoon. That part is yours, and the shape of it is the resilience kit you already put in front of your model calls, pointed at tools instead.
The failure: one tool server stalls the whole loop
Walk the loop the way the runtime runs it. The model emits a tool call, the agent dispatches it to the right MCP server, and the agent blocks on that call until it returns, because the model cannot decide its next step without the result. For a tool that answers in a few hundred milliseconds this is invisible. For a tool that has gone slow, the agent is now holding the entire turn open on a single dependency, and every other tool in the toolbox is idle behind it.
The reason this hurts more for agents than for an ordinary service call is the loop. A web request that depends on a slow backend returns one slow response and ends. An agent turn might call six tools in sequence, and the slow one poisons the whole chain: the turn is as slow as the sum of its tool calls, so if any one stalls, the user watches a spinner for a minute. Worse, the model often cannot tell a slow call from a failed one, so when the step finally errors it may try the same tool again and double the wait. That is the same dynamic as an agent repeating a failing call with no guard to stop it, except the cost here is wall-clock time bleeding out of every turn.
It is tempting to reach for the fix you already have for model outages. If you have built a circuit breaker and failover in front of every model call, you know the moves. But a tool is a different dependency. You usually cannot fail a tax lookup over to a second tax provider mid-run the way you fail one model API over to another. The breaker shape carries over; the recovery does not. For a tool, failing fast and handing the model a clear error to plan around is the recovery.
Why the agent cannot tell a slow tool from a dead one
The root problem is that the common dispatch path treats all tools as equally trustworthy and equally fast. There is one timeout, set high enough that the slowest legitimate tool does not get cut off, which means it is set far too high for everything else. A tool that normally answers in 200 milliseconds gets the same sixty second rope as a tool that genuinely needs thirty, so when the fast tool hangs, nobody notices for a very long time.
The MCP spec does not rescue you here, and it is honest about why. The 2026-07-28 specification partitions the JSON-RPC error space for protocol-level failures but explicitly notes that errors purely local to an implementation, such as a request timeout raised inside an SDK, are not assigned a code, and that implementations surfacing them must make sure they cannot be mistaken for an error that actually came from the peer. Deciding how long to wait, and what a timeout means, is left to the client. If you have not made that decision deliberately, your SDK's default made it for you, and the default is almost always one number applied to every tool.
There is a second, nastier reason the agent cannot tell health from sickness: some servers answer. A tool server that returns HTTP 200 with an error object buried in the response body looks healthy to the transport and broken to the caller, which is exactly the shape of a silent failure your dashboards show as success. If your reliability logic keys off the HTTP status, a tool can be failing every call and never trip anything, because on the wire it keeps saying 200.
A hard timeout on every tool call
Start with the timeout, because it is the cheapest win and the foundation everything else sits on. Wrap every tool invocation in a deadline sized to that tool, not to the slowest tool in the set. Most tools get a tight budget. The handful that are legitimately slow get a longer one, and the genuinely long-running ones do not belong behind a synchronous call at all; those belong behind a task handle the client polls.
// Per-tool deadline. The default is tight; slow tools are the exception,
// declared once, not discovered at p99 in production.
const TOOL_TIMEOUT_MS: Record<string, number> = {
default: 3_000,
tax_lookup: 10_000, // vendor is genuinely slow, but bounded
geocode: 5_000,
};
async function callWithTimeout(server: McpServer, name: string, args: unknown) {
const ms = TOOL_TIMEOUT_MS[name] ?? TOOL_TIMEOUT_MS.default;
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), ms);
try {
// Pass the signal through so a slow call is actually cancelled,
// not just abandoned while the socket keeps the server busy.
return await server.callTool({ name, args }, { signal: ac.signal });
} finally {
clearTimeout(timer);
}
}
The detail that matters is the signal. A timeout that only stops the caller waiting, while the underlying request keeps running against the server, leaks work and connections: you have stopped caring about the call, but you are still paying for it, and under load those abandoned calls pile up and exhaust your connection pool. Propagating cancellation so the slow call actually stops is the same discipline as bounding deadlines and propagating cancellation through an agent. Abort the request, free the slot, move on.
A circuit breaker per tool, not per server
A timeout stops a single slow call from running forever. It does not stop the next hundred calls from each paying that timeout before failing. During the bad afternoon, every turn that reached the tax tool still waited the full ten seconds to discover it was slow, over and over. The fix is a circuit breaker that remembers recent failures and fails the next call instantly once a tool has clearly gone bad.
The decision that trips people up is whether to key the breaker on the server or the tool. Key it on the tool. A single MCP server can expose a dozen tools, and it is common for exactly one of them to be broken while the rest answer fine; a breaker keyed on the shared server connection would pool failures across all of them and cut off healthy tools along with the broken one. That exact bug, a wrapper breaker keyed on a shared execute path pooling unrelated failures, is what an IBM gateway project fixed by moving to per-tool isolation, and it is worth keeping a separate per-server breaker only for the transport-level case where the whole server is unreachable. The mcp-context-forge timeout and circuit breaker design lays out the per-tool version with distinct error codes for a timeout versus an open circuit, which the agent can tell apart.
import time
from enum import Enum
class State(Enum):
CLOSED = "closed" # healthy, let calls through
OPEN = "open" # tool is bad, fail fast
HALF_OPEN = "half_open" # cooldown elapsed, allow one probe
class ToolBreaker:
def __init__(self, fail_threshold=4, cooldown=20.0):
self.fail_threshold = fail_threshold
self.cooldown = cooldown
self.failures = 0
self.state = State.CLOSED
self.opened_at = 0.0
def allow(self) -> bool:
if self.state is State.OPEN:
if time.monotonic() - self.opened_at >= self.cooldown:
self.state = State.HALF_OPEN # one probe to test recovery
return True
return False
return True
def record(self, ok: bool):
if ok:
self.failures, self.state = 0, State.CLOSED
return
self.failures += 1
if self.state is State.HALF_OPEN or self.failures >= self.fail_threshold:
self.state, self.opened_at = State.OPEN, time.monotonic()
# One breaker per tool name, created on first use.
breakers: dict[str, ToolBreaker] = {}
def breaker_for(tool: str) -> ToolBreaker:
return breakers.setdefault(tool, ToolBreaker())
With a breaker per tool in front of the dispatch, the slow tax tool trips its circuit after four failures and then fails in microseconds for the length of the cooldown, while the agent keeps calling every other tool normally. The open breaker returns a fast, specific error rather than a ten second stall, and that changes the agent's behavior from blocking the loop to routing around a known-bad tool, the same way tool-call validation and recovery turns a raw failure into something the model can act on. Keep the threshold high enough that a couple of unlucky timeouts on a healthy tool do not trip it, or your breaker becomes its own outage.
Count the right failures, and budget the whole trajectory
A breaker is only as good as its definition of failure. Trip it on the wrong things and you cut off healthy tools; trip it on too few things and a broken tool runs forever. The rule is to count only failures that mean the dependency itself is unhealthy, and to treat a tool that answered with a legitimate business result as a success even when that result is "not found."
def classify(result, exc) -> bool:
"""Return True if this counts as a breaker FAILURE (dependency unhealthy)."""
if exc is not None:
return True # timeout, connection reset, 5xx: the tool is unhealthy
# The 200-with-error-in-body trap: transport looked fine, tool did not work.
if getattr(result, "is_error", False):
return True
# A tool that answered normally, including a business "not found", is healthy.
return False
def invoke(server, tool: str, args):
br = breaker_for(tool)
if not br.allow():
# Fast, specific error the model can plan around, not a stall.
return tool_error(tool, "circuit_open", retry_after=br.cooldown)
try:
result = call_with_timeout(server, tool, args)
br.record(ok=not classify(result, None))
return result
except Exception as e:
br.record(ok=False)
return tool_error(tool, "timeout_or_transport", cause=str(e))
The is_error check is what saves you from the silent-failure trap. A server that keeps returning 200 with an error flag inside will now trip its breaker like any other unhealthy dependency, instead of looking perfectly healthy on the wire while every call is useless. This is also where a changed tool contract shows up: if a server silently reshapes its output, your parse fails, you count it as a failure, and the breaker flags the tool the same way a fingerprint gate catches a tool definition that drifted out from under you.
There is one more level above the single call. An agent turn is a sequence of tool calls, and a per-call timeout does not bound the turn: six tools at three seconds each is an eighteen second turn that no single timeout caught. Give the whole trajectory a budget and spend it down as each tool call completes, so a turn that is clearly not going to finish in time gets cut off deliberately rather than crawling. Allocating a timeout budget across sequential tool calls, rather than giving each one an independent deadline, is one of the production patterns the recent design patterns for deploying agents with MCP work calls out as missing from the protocol itself. The budget belongs in the agent, not the server.
The parts that bite
A per-tool timeout set from the happy path cuts off the slow-but-fine tool. The tax vendor that answers in eight seconds on a busy day gets killed by a three second default, and now you have manufactured an outage on a tool that was working. Size each slow tool's budget from its real p99, not its median, and leave headroom. Tools you have never measured get the tight default and a note to measure them.
The breaker that is too eager is worse than no breaker. Set the failure threshold too low and a couple of noisy-network timeouts open the circuit on a healthy tool, the agent routes around a tool it needed, and the task fails for a reason that has nothing to do with the tool. Watch how often each breaker opens on a normal day and tune so that number sits near zero when nothing is wrong.
A fast error the model cannot understand is just a different failure. Returning "circuit_open" helps only if the model knows what to do with it. If your agent treats every tool error as fatal, an open breaker ends the turn instead of letting the model finish with the tools that still work. The payoff of failing fast comes from the model planning around the missing tool, so the error has to be legible to the model, not just to your logs.
Per-tool breakers do not catch a dead server. If the whole server is down or the transport is wedged, every tool on it times out independently and trips its own breaker one by one, paying the timeout each time. Keep a per-server breaker in front of the per-tool ones for exactly this: one check that short-circuits every tool on a connection that is clearly gone.
Breaker state has to be shared if your runtime is not. If you run multiple replicas each holding breakers in local memory, every replica relearns that a tool is bad the hard way, and a user routed to a fresh replica eats the full stall again. Beyond a single process, the breaker counts belong in a shared store.
The takeaway
The agent was never slow. One tool out of twenty was slow, and nothing stood between that dependency and the whole loop, so a single vendor's bad afternoon became every user's bad afternoon. MCP made this a structural risk rather than an occasional one, because a real share of your tools are now remote servers you do not run and cannot fix, and the protocol gives you a way to call them but not a way to survive them. Isolation is the missing layer: a hard timeout per tool, a circuit breaker per tool that trips on real failures and fails fast once a tool is bad, an honest classification that counts the 200-with-an-error reply as the failure it is, and a budget on the whole turn so the loop cannot bleed out one tool at a time.
None of this is exotic. It is the resilience thinking you already apply to model calls, aimed at the dependency that quietly became just as risky and half as visible. Build it once, as a gate every tool call passes through, and a flaky tool server turns from an incident into a line in a log that says the breaker did its job.
If your agent leans on third-party MCP servers and you would rather not learn which one is fragile during an incident, book a consultation call and we can map the tool dependencies in your agent and put a gate in front of the ones that can ruin an afternoon.
Questions this essay answers
01Why does one slow MCP tool slow down the entire agent?
Because most agent runtimes call every MCP tool the same way, through one path with one generous timeout, and the model drives the loop one step at a time. When a tool server goes slow, the call does not fail, it just takes the full client timeout before giving up, and the agent cannot take its next step until that call returns. If the step retries, you pay that wait again. A tool that is one of twenty on a normal day becomes the only thing the loop is doing, because nothing isolated it from the rest.
02Should I put a circuit breaker per tool or per server?
Use both, for two different failures. A per-tool breaker stops one bad tool from being retried while leaving the server's other tools reachable, which matters because a server can expose a dozen tools and only one is broken. A per-server breaker catches the case where the whole server is down or the transport is dead, so every call to it would burn the full timeout. Key the per-tool breaker on the tool name and the per-server breaker on the connection, and let the server breaker short-circuit before you even check the tool one.
03What failures should trip an MCP tool circuit breaker?
Only failures that mean the dependency itself is unhealthy: timeouts, connection errors, 5xx responses, and malformed replies. Do not trip the breaker on a tool that answered correctly with a business error, like a lookup that returned not found, because the server is working and cutting it off punishes a healthy dependency. The hard case is a server that returns HTTP 200 with an error buried in the body. Classify that as a failure for breaker purposes even though the transport looked fine, or a broken tool will never trip the breaker.
04How is this different from provider failover for model calls?
Provider failover protects the model call: when one model API degrades you route to another. This protects the tool call, which is a different dependency with no equivalent fallback. You usually cannot fail a weather tool over to a second weather provider mid-run, so the move is to fail fast and hand the model a clear error it can plan around, by skipping that tool or finishing without it. The circuit breaker shape is the same, but the recovery is routing inside the agent's reasoning rather than swapping a backend underneath it.
