An MCP server can vanish from your AI agent mid-conversation. Here's the 30-second timeout that did it to me. A developer discovered that their MCP server for Safari browser control could silently disappear from an AI agent's tool catalog mid-conversation due to a 30-second initialization timeout. The `safari-mcp` server's top-level await for profile detection sometimes exceeded the 30-second handshake deadline, causing the client to kill the server without warning the user or the agent, which then continued operating with an incomplete tool catalog. The failure was invisible to users, as the agent simply reimplemented missing functionality with alternative tools like `Bash` and `curl` instead of reporting the missing capabilities. The bug report was: "the browser tools are gone." I'd been running the same Claude Code session for an hour, calling safari navigate , safari click , safari read page — the usual flow. Then I opened a new conversation in the same project and the safari tools weren't in the catalog at all. The agent didn't say "I tried to use safari-mcp and it's not available." It just… didn't use them. It re-implemented half of what I needed with Bash and curl . That second part is the worst part. The agent doesn't know that the tool catalog is incomplete. It only knows what's in front of it. If a tool is missing, it makes do with what it has — and the user has no idea their last release broke discoverability. This post is about the 30-second timeout that caused it, the diagnosis path, and the one-line fix. But more than that, it's about a failure mode in stdio MCP that I think every MCP author needs to know about and most don't. safari-mcp is an MCP server that drives the real macOS Safari. When the user wants their agent to use a separate browser profile e.g. "Work" vs "Personal" , they launch the server with SAFARI PROFILE=work and the server scopes every tool call to that profile's window. That means at startup the server has to find the window — call AppleScript, enumerate Safari's open windows, match by profile name, cache the window ref. Here's what the startup code used to do: js if SAFARI PROFILE { await new Promise r = setTimeout r, 50 ; await refreshTargetWindow true ; // <-- this line if targetWindowRef { logProfile Startup: Profile "${SAFARI PROFILE}" → ${ targetWindowRef} ; } else { logProfile WARNING: Profile "${SAFARI PROFILE}" window NOT found ; } } ES module top-level await. Looks fine. Profile detection runs once, the server knows which window to target, life is good. In testing this took ~50–200ms. In production it sometimes took longer than 30 seconds. When Claude Code launches an MCP server it expects an initialize response within 30 seconds. That's the handshake — the server announces its protocol version and tool catalog, the client says "ok, here's my session." Until that handshake completes, the server's tools don't enter the conversation's tool catalog. If your top-level await runs 30s before the stdio loop gets a chance to respond, the handshake misses the deadline. The client gives up. The server is killed. No retry. No warning surfaced to the user, just a log entry deep in the Claude Code internals that says "MCP server failed to initialize in 30s." And critically: the conversation continues . The agent's tool catalog is whatever responded in time. Safari tools just aren't there. The agent has no way to know they were supposed to be there. I want to underline this: the failure was completely invisible to me as a user. I didn't see a stack trace. I didn't see a "your tools didn't load." I saw an agent that didn't reach for the tools I'd just shipped a fix for. refreshTargetWindow true calls into a Swift helper that runs: tell application "Safari" return name of every window end tell On a fresh Safari with three tabs this returns in 12ms. On a real user's Safari, it does any of the following: name of every window waits for each window's title to settle. 5–20s. ~/Library/Containers/com.apple.Safari/ . The TCC privacy subsystem reverifies your bundle's automation permission. Anywhere from instant to "until the user moves their mouse."None of those are bugs. They are normal macOS behavior. They were not in the 99th percentile when I tested — they showed up in the 99.9th percentile when the server hit my actual user base. The first thing I did was assume the bug was in the MCP protocol layer. I went and looked at the stdio framing code, the JSON-RPC parser, the request dispatcher. None of it was the problem. The second thing I did was look at the refreshTargetWindow call and think "well, it works in my testing." Which is the most expensive sentence in software. The actual diagnostic, which took me about 20 minutes to find, was to read the Claude Code MCP debug logs: MCP safari-mcp: spawned pid 47192 MCP safari-mcp: sending initialize request MCP safari-mcp: initialize timed out after 30000ms, killing process That's it. That's the only signal. The MCP client doesn't tell you what the server was doing. It doesn't ask the server "are you stuck?" It just kills it. Once I had that line, the rest was obvious: the only thing that runs before stdio is refreshTargetWindow . If refreshTargetWindow is slow, stdio never gets a chance. Therefore: don't block stdio on it. js if SAFARI PROFILE { async = { await new Promise r = setTimeout r, 50 ; await refreshTargetWindow true ; if targetWindowRef { logProfile Startup: Profile "${SAFARI PROFILE}" → ${ targetWindowRef} ; } else { logProfile WARNING: Profile "${SAFARI PROFILE}" window NOT found ; } } ; } Wrap the whole startup probe in a fire-and-forget IIFE. Module init returns immediately. Stdio loop binds. Initialize handshake responds in ~5ms. By the time the first safari tool call arrives, the profile window probe has usually finished — and if it hasn't, getTargetWindowRef already has a lazy-refresh path that handles a missing cache by running the probe inline. The correctness story is: the probe is a cache warm-up, not a hard prerequisite. The tool call path already knows how to handle a cold cache. So there is no reason to make module init wait. Three lines changed. The bug is gone. If your MCP server does anything at startup that touches an external process, an external API, the filesystem outside your bundle, or a system service, you cannot let it block initialize . tools/list or first tool call, not at import. xprop ? You have no SLA on those. Defer. ~/.config/