When an Unresponsive MCP Server Freezes Your Agent: Separate Timeouts for Connect, List, and Call
Antigravity CLI 1.1.3 closed the case where an unresponsive MCP server stalls an agent forever, by adding timeouts to connect, list-tools, and call-tool. This walks through why the three boundaries fail differently, and builds a defensive wrapper with a circuit breaker and failure-only notifications, backed by working code and a week of overnight runs.
The batch I run at 2 a.m. was only half done by morning. The last line in the log read "connected to MCP server," with no error and no exception after it. The connection had succeeded. The list-tools call I fired right after it simply never came back, and it waited that way until sunrise.
When you run several sites as an indie developer, this kind of "not even an error" stall is the worst kind. If something crashes, you can restart it. But a silent, endless wait gives you nothing to notice until you open the log the next day. This article starts from the path that Antigravity CLI 1.1.3 closed — an unresponsive MCP server stalling an agent forever — and turns it into a per-boundary timeout design you can drop around your own automation so it never dies in the same hole.
What 1.1.3 actually closed
The Antigravity CLI 1.1.3 release notes (2026-07-16) include one MCP-related fix. An MCP server that never returns a response used to stall the agent indefinitely; the fix resolves this by adding timeouts to each of connect, list-tools, and call-tool.
It looks minor, but if you run tasks unattended, it changes an assumption. Before, a single server that completed the handshake and then went silent could freeze the whole agent right there. With timeouts in place, at least the "wait forever" case is gone.
Still, the CLI no longer waiting internally does not make the outside of your own automation safe. If you invoke the CLI headless with agy -p and wrap it in cron or a shell, what happens after the CLI gives up is your design, not the CLI's. Skip that, and you trade one stall for another: "the CLI gave up at 120 seconds, but my script swallowed the failure and moved to the next job, leaving everything half-finished."
This piece is about not leaning entirely on the CLI's internal fix, and instead laying your own timeout layer around any automation that uses MCP. For the groundwork of running the CLI non-interactively, I covered that first in Running the Antigravity CLI Non-Interactively: Design Before You Put It on CI and Cron. Read together, they make it clearer whether the timeout should live on the inside (the CLI) or the outside (your operations).
Why one blanket timeout is not enough
It is tempting to write "time everything out at 60 seconds." That is exactly what I did first. But the three boundaries fail so differently that a single number is guaranteed to break one of them.
Boundary
Typical duration
Example cause of a stall
Safe to retry?
connect
tens of ms to a few s
process not started, port not listening, stalled TLS handshake
Yes (no side effects)
list tools
hundreds of ms
lock during init, infinite loop building the schema
Yes (read only)
call tool
hundreds of ms to tens of s
slow external API, heavy input, waiting on a write
Depends (dangerous if not idempotent)
Allow 120 seconds for connect, and you wait two minutes on a server that failed to start. Give call-tool only 5 seconds, and you wrongly cut a tool that is legitimately doing heavy work. And only the call boundary ties its retry safety to the tool's idempotency. Re-issuing list-tools is a read, so it is carefree; blindly retrying a tool that writes can create a second problem, like a duplicate record.
So a timeout is not one number — it is a per-boundary budget. In my overnight batch I start from connect 15s, list-tools 20s, call-tool 120s. The split depends heavily on your environment, so I tighten it later against measurements.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Understand why connect, list-tools, and call-tool fail in different ways, so you can assign a separate timeout budget to each boundary instead of one blanket number
✦Stop a single unresponsive MCP server from wedging your whole overnight pipeline, using a defensive wrapper that combines timeouts, exponential backoff, and a circuit breaker
✦Add a failure-only notification that says which server stalled at which boundary, cutting the time you spend diagnosing it the next morning
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
First, a minimal wrapper that lets you apply a different timeout to each boundary. I abstract away the concrete MCP client and assume an object with three methods: connect, list_tools, and call_tool. Wrapping each boundary in asyncio.wait_for is the heart of it.
import asynciofrom dataclasses import dataclass@dataclass(frozen=True)class TimeoutBudget: connect: float = 15.0 # seconds: up to the handshake list_tools: float = 20.0 # seconds: enumerating tools call_tool: float = 120.0 # seconds: one invocationclass BoundaryTimeout(Exception): """Carries which boundary timed out.""" def __init__(self, server: str, boundary: str, limit: float): self.server = server self.boundary = boundary self.limit = limit super().__init__(f"{server}: {boundary} timed out after {limit}s")async def _guard(coro, *, server: str, boundary: str, limit: float): try: return await asyncio.wait_for(coro, timeout=limit) except asyncio.TimeoutError as exc: # A bare TimeoutError erases *where* it stalled. # The point is to re-raise with the boundary and server attached. raise BoundaryTimeout(server, boundary, limit) from excasync def open_session(client, server: str, budget: TimeoutBudget): await _guard(client.connect(), server=server, boundary="connect", limit=budget.connect) tools = await _guard( client.list_tools(), server=server, boundary="list_tools", limit=budget.list_tools ) return tools
The key is not letting asyncio.TimeoutError flow straight up. The standard exception only tells you that something timed out. When you later notify or triage, what you need is two facts: which server, and which boundary. Re-raising as BoundaryTimeout keeps both alive to the end. It is the same instinct as 1.1.3 writing the needed detail to stderr — make the failure itself diagnosable.
Adding exponential backoff and a circuit breaker
Timeouts alone still hand an unresponsive server a dutiful 120 seconds every time. If you fire ten tasks at the same server overnight, a persistent no-response burns twenty minutes. This is where exponential backoff plus a circuit breaker earns its place.
Retry only the safe boundaries. Connect and list-tools are reads, so try them freely. Retry a tool call only when it is idempotent; otherwise, give up after one attempt on the safe side.
import asyncioimport timefrom dataclasses import dataclass@dataclassclass Breaker: """After too many consecutive failures, stop trying that server for a while.""" threshold: int = 3 cooldown: float = 300.0 # seconds: skip while open fails: int = 0 opened_at: float = 0.0 def allow(self) -> bool: if self.fails < self.threshold: return True if time.monotonic() - self.opened_at >= self.cooldown: # After cooldown, allow exactly one half-open probe self.fails = self.threshold - 1 return True return False def record(self, ok: bool) -> None: if ok: self.fails = 0 else: self.fails += 1 if self.fails == self.threshold: self.opened_at = time.monotonic()class BreakerOpen(Exception): passasync def with_retry(make_coro, *, server, boundary, limit, breaker, attempts=3, base_delay=1.0, retryable=True): if not breaker.allow(): raise BreakerOpen(f"{server}: breaker open, skipping {boundary}") last = None tries = attempts if retryable else 1 for i in range(tries): try: result = await _guard(make_coro(), server=server, boundary=boundary, limit=limit) breaker.record(ok=True) return result except BoundaryTimeout as exc: last = exc breaker.record(ok=False) if i < tries - 1: # Exponential backoff: 1s, 2s, 4s ... thin out charges at a dead server await asyncio.sleep(base_delay * (2 ** i)) raise last
I take make_coro as a function because each retry needs a fresh coroutine. A coroutine you have already awaited cannot be reused. Pass it by value and the second attempt dies instantly with a RuntimeError. That is the exact trap I hit first — I spent about an hour on a symptom where "I added backoff but it only retries once."
The circuit breaker is the mechanism that leaves an unresponsive server alone for a while. After three consecutive timeouts, it skips that server entirely for the next five minutes, so the rest of the night's jobs do not each donate 120 seconds to a dead server. The half-open probe is there too, so the moment the server revives, it resumes on its own. How to handle something that is stuck but still alive is also worth reading from the agent's side, in Spotting Agents That Are Alive but Stuck: Designing a Progress Heartbeat and Watchdog.
Tell me which server stalled where — but only on failure
The worst thing an automation notification can do is fire on every success. If "succeeded" arrives every night, people stop looking almost immediately. What you want to arrive is: when it stalls, which server stalled at which boundary, and nothing else.
I put the boundary name into the JSON so the next-morning triage is a glance. If boundary is connect, the server likely never started. If it stalls at list_tools, the handshake worked, so I suspect init or schema generation. If it is call_tool, I go look at the external API that one tool is hitting. Just knowing where it stopped points the first investigation of the day in minutes. This "emit only the failure, in a diagnosable shape" stance is continuous with the design 1.1.3 took for its headless fix. For the CLI-side handling of soft-deny and allow rules, I wrote it up in Turning Silent Auto-Approvals into Allow Rules, One Soft-Deny at a Time.
Numbers from actually running it
I laid this layer over the MCP calls in an overnight batch that runs six sites, for one week. The numbers are only from my environment, but the shape should be useful.
Metric
Before
After
Max wait on hitting an unresponsive server
~8 min average (until noticed from outside)
Hard cutoff at 120s
Wasted attempts at a dead server
every job, every time
cut after 3, then 5-min pause
Next-morning root-cause triage
15-20 min tracing the log
a few min from one boundary line
Interrupted jobs per week
4 (2 of them silent stalls)
3 (all delivered a notification)
The count of interrupted jobs did not drop much. This wrapper does not fix the servers being flaky in the first place. What changed is how they stall — from a "silent stop" to a "failure that notifies." As an indie developer, having a state where you notice a problem first thing in the morning is usually worth more than driving the count to zero. The initial connect 15s / call 120s settled to a 90s call budget after a week of measurement, because the 99th percentile of healthy calls landed in the low 70-second range.
Common pitfalls
Passing the coroutine by value. As above, a retry needs a fresh coroutine each time. Call it with parentheses — with_retry(client.list_tools()) — and it is consumed on the first attempt and broken on the second. Pass the function reference (client.list_tools) instead.
asyncio.wait_for cancels the coroutine, but the socket underneath can linger. The timeout cancels the coroutine, but the MCP server's process or socket does not always clean up immediately. In environments where the next connect turns into "port already in use," you need an explicit close after the timeout.
Sharing breaker state across processes. If you launch each overnight job as a separate process, the breaker's fails does not carry across process boundaries. To span them, you need to persist state to SQLite or a small file. Start within a single process and externalize only when you actually need to.
Allowing call retries on non-idempotent tools uniformly. Reads can be tried any number of times, but blindly retrying a tool that writes or charges will run a job twice that merely timed out yet actually succeeded. retryable is a flag to decide per tool, by its nature.
Where I stop trusting automation
Finally, with per-boundary timeouts in place, here is where I stop letting the machine run on its own.
Reads — connect and list-tools — I hand to the machine without hesitation. There are no side effects however many times they fail, so retries and backoff can run automatically. For tool calls that write, I auto-retry only the ones whose idempotency I can guarantee, and let the rest fail on the first attempt and go to the notifier. Servers that keep not responding get cut automatically by the breaker, but the fact that a cut happened always reaches a person. A server being quietly severed, with nobody noticing it was severed, is the scariest state of all.
As a next step, try applying three separate timeouts — connect, list_tools, call_tool — to your own MCP calls. Splitting one blanket number into three is enough to keep the "where" when something stalls. How to tune the budget from there is something a week of measurement will teach you. I am still adjusting the split myself, but just turning silent stops into failures that notify has made overnight automation far easier to delegate.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.