●PRICING — September 1 was the scheduled date for the Sonnet 5 price increase, and it did not happen. The introductory $2/$10 per MTok now stands as the regular price●PARTNER — Salesforce and Anthropic announced Claudeforce, an expanded partnership. The Salesforce in Claude plugin ships with 37 prebuilt sales skills, from meeting prep to pipeline management●TRUST — Claudeforce serves Claude through Amazon Bedrock inside the Salesforce Trust Boundary, so data and inference never leave the security perimeter — an answer aimed squarely at regulated industries●BETA — Salesforce in Claude is with select pilot customers for now, with an open beta expected during September●LIMITS — The 50% weekly-limit boost runs through September 13. From September 14 the permanent level is 25% above the pre-promotion baseline, roughly a 17% cut from today●RELEASE — Claude Code has shipped nothing since v2.1.251 on August 28. Against a pace of one release every 0.8 days, a four-day gap is among the longest yet●PRICING — September 1 was the scheduled date for the Sonnet 5 price increase, and it did not happen. The introductory $2/$10 per MTok now stands as the regular price●PARTNER — Salesforce and Anthropic announced Claudeforce, an expanded partnership. The Salesforce in Claude plugin ships with 37 prebuilt sales skills, from meeting prep to pipeline management●TRUST — Claudeforce serves Claude through Amazon Bedrock inside the Salesforce Trust Boundary, so data and inference never leave the security perimeter — an answer aimed squarely at regulated industries●BETA — Salesforce in Claude is with select pilot customers for now, with an open beta expected during September●LIMITS — The 50% weekly-limit boost runs through September 13. From September 14 the permanent level is 25% above the pre-promotion baseline, roughly a 17% cut from today●RELEASE — Claude Code has shipped nothing since v2.1.251 on August 28. Against a pace of one release every 0.8 days, a four-day gap is among the longest yet
Once I passed a dozen MCP servers, I stopped trusting the startup list
Only some of the servers that say connection failed at startup are ones you can actually fix. Here is a probe that separates no-response, launch failure, and protocol rejection, measured across a 14-server fleet where sequential 22.03s became parallel 5.09s, and where trimming the deadline quietly turned healthy servers into failures.
One morning I sat down to work and stopped short. A server I had used the day before was nowhere in the list.
In its place were seven lines reading "connection failed." I had not touched the config. The network was fine. I shrugged, spent five minutes on something else, and opened the list again — four of the seven were back as if nothing had happened.
They had not been broken. Their handshake simply had not finished yet.
From that morning on, I stopped reading the startup list at face value. Instead I shake hands myself and sort the results. Here is that implementation, along with the numbers I got after growing the fleet to fourteen.
"Unusable" turned out to cover several different things
The wording in the list is uniform, but what happens underneath is not. What I collected sorted into at least four outcomes.
Outcome
What is actually happening
What you can do about it
No response
The process is alive, but no reply to initialize arrives before the deadline
Wait, or raise concurrency. Config changes will not help
Launch failure
The process dies immediately, with a reason on stderr
Fixable right now: credentials, paths, execute permissions
Protocol rejection
The handshake reached the server, which refused what you declared
Fixable right now: version alignment, client identity
Ready
initialize returned a result
Nothing
What matters is that each of these calls for a completely different action. Re-entering credentials on a server that never answers is wasted effort, and waiting on a server that crashed at launch means waiting forever. When the list collapses all four into a single "connection failed," you lose that distinction and burn time brute-forcing every fix in turn.
There is one more layer not covered here: the case where the handshake and tools/list both succeed but zero tools come back. That belongs to the permission layer rather than the connection layer, and I wrote about it separately in Your MCP server is connected, but it returns zero tools. Today I am only looking at what happens before the handshake completes.
Writing a probe that classifies
The job is simple. Launch the server, send exactly one initialize, read one line under a deadline, and branch on what came back — or on why nothing did.
There is one trap you will hit if you write it the obvious way. You cannot pass a deadline to readline(). Standard I/O reads have nowhere to put a timeout, so if the other side goes quiet, the call never returns. The fix is to push the read onto a separate thread and use that thread's join(timeout) as the deadline.
# probe.py - classify an MCP server's startup state into four outcomesimport json, os, subprocess, sys, threading, timeREADY, TIMEOUT, EXITED, PROTOCOL_ERROR = "READY", "TIMEOUT", "EXITED", "PROTOCOL_ERROR"def probe(name, command, env_overrides, timeout): """Send initialize to one MCP server; return (name, outcome, elapsed, detail).""" env = dict(os.environ) env.update(env_overrides) t0 = time.monotonic() try: proc = subprocess.Popen( command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=env, ) except OSError as ex: # Missing executable or no permission - this fails before any handshake return (name, EXITED, round(time.monotonic() - t0, 3), str(ex)) request = { "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "startup-probe", "version": "1.0"}, }, } box = {} def read_one_line(): box["line"] = proc.stdout.readline() # no deadline available, so isolate it reader = threading.Thread(target=read_one_line, daemon=True) try: proc.stdin.write(json.dumps(request) + "\n") proc.stdin.flush() except (BrokenPipeError, OSError): pass # already dead; the EXITED branch below recovers the reason reader.start() reader.join(timeout) elapsed = round(time.monotonic() - t0, 3) if reader.is_alive(): proc.kill() return (name, TIMEOUT, elapsed, f"no response to initialize within {timeout}s") line = box.get("line") or "" if not line: proc.kill() stderr_tail = (proc.stderr.read() or "").strip().splitlines() detail = stderr_tail[-1] if stderr_tail else "closed stdout without responding" return (name, EXITED, elapsed, detail) message = json.loads(line) proc.kill() if "error" in message: return (name, PROTOCOL_ERROR, elapsed, message["error"]["message"]) return (name, READY, elapsed, message["result"]["serverInfo"]["name"])
proc.kill() appears in every branch because the probe only needs the handshake and is done after that. If the probe leaves its processes running and the real client then starts, the same server comes up twice. Skip the cleanup and the tool you built to inspect things starts contaminating the thing you wanted to inspect.
✦
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
✦You will be able to split unusable servers into no-response, launch failure, and protocol rejection, and spend your time only on the ones your own hands can fix
✦You will be able to avoid shipping a deadline setting that discards healthy-but-slow servers, before it reaches your startup path
✦You will be able to pick a probe concurrency and deadline for your own fleet size, using measurements where 14 servers dropped from 22.03s sequential to 5.09s parallel
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.
To confirm the classification behaves, I built six servers that differ only in how they respond — instant, delayed, silent, crash-on-launch, and protocol rejection — using nothing but the standard library.
Counterpart
Deadline
Outcome
Elapsed
Detail
Instant
5.0s
READY
0.018s
serverInfo received
3s delay
5.0s
READY
3.022s
serverInfo received
3s delay
2.0s
TIMEOUT
2.001s
no response within deadline
Silent
5.0s
TIMEOUT
5.001s
no response within deadline
Crash on launch
5.0s
EXITED
0.021s
fatal: missing API key
Protocol rejection
5.0s
PROTOCOL_ERROR
0.019s
unsupported protocolVersion
The third row is the one to read closely. It is the exact same healthy server as row two, but trimming the deadline from 5.0s to 2.0s alone flipped READY into TIMEOUT.
And in the log, that TIMEOUT is character-for-character identical to the one from the genuinely silent server in row four. A tightened deadline quietly moves healthy servers onto the broken pile.
Meanwhile rows five and six both resolve in 0.02 seconds. The two categories you can actually fix announce themselves immediately. Designing a longer wait in the hope that "more patience might reveal something" does not surface those discoveries one second sooner.
At fleet scale, concurrency mattered and the deadline did not
I grew this to fourteen servers and measured. The mix: nine healthy servers responding in 0.2s, two healthy servers taking 2.5s, and three that never answer. Deadline 5.0s across the board.
Probe strategy
Total wall time
READY
TIMEOUT
Sequential (one at a time)
22.03s
11
3
Parallel (16 worker cap)
5.09s
11
3
Identical verdicts, and the time dropped to under a quarter. The slowest READY on the parallel run was 2.573s, which means roughly half of the 5.09s total was spent waiting for healthy servers and the rest was spent watching three silent ones run out their deadline.
Here is where my expectation was wrong. Under parallel probing the total is the maximum across servers, so as long as even one silent server remains, the total pins to the deadline value itself. Cutting three silent servers to two, or to one, leaves 5.09s untouched.
For a while I believed that removing servers I no longer used would make startup lighter. That held while I was probing sequentially, and stopped holding the moment I went parallel. Cleanup pays off in wall time only when you eliminate every silent counterpart. Read the other way: once probing is parallel, leaving broken servers in place costs you nothing at startup.
Trimming the deadline turns healthy servers into failures
So what deadline is right? I spread healthy response times from 0.05s to 3.4s across ten servers, mixed in three silent ones, and varied only the deadline.
Deadline
Judged READY
Healthy servers marked TIMEOUT
Total wall time
1.0s
7
3
1.06s
2.0s
8
2
2.07s
3.0s
9
1
3.07s
5.0s
10
0
5.06s
8.0s
10
0
8.07s
The shape is straightforward. Total wall time tracks the deadline almost exactly, and the READY count plateaus once you clear the slowest healthy server at 3.4s. Stretching 5.0s to 8.0s buys nothing and costs three seconds.
The interesting side is the other direction. Dropping to 1.0s does cut wall time from 5.06s to 1.06s — a real four seconds. The price of those four seconds is three healthy servers relocated to the failure pile, where they are indistinguishable from the three that are genuinely silent.
Which means the savings from failing fast are paid for entirely by healthy-but-slow servers, and have nothing to do with how many servers are actually silent. If you cannot see what is being handed over, the trade looks free when it is not. I had been choosing the deadline from how long I was willing to wait, and that is backwards. The number to choose from is how long your slowest healthy server takes; your patience is a constraint you check afterward.
In practice I leave about 1.5x headroom above the slowest healthy value — 5.0s against a 3.4s tail here. When the counterpart is an external service whose response time swings day to day, take that headroom up front, or your fleet will quietly shrink on busy mornings.
Give each outcome its own response
Once classification works, all that remains is routing. Here is how I assign it.
Outcome
Handling
Surface to a human?
READY
Use it
No
TIMEOUT
Retry once; if it fails again, proceed without it this run
Count only
EXITED
Do not retry. Print the last stderr line verbatim
Always
PROTOCOL_ERROR
Do not retry. Print the error message verbatim
Always
TIMEOUT is the only one I allow a retry, because it is the only outcome time can resolve. The four servers that came back five minutes into that morning were all of this kind. Retrying EXITED or PROTOCOL_ERROR just buys the same failure twice.
The visibility split exists because I run some of this unattended. Working as an indie developer, where the first thirty minutes of the morning largely decide how far the day gets, narrowing the lines worth reading converts directly into time. Printing every TIMEOUT fills the log with "three down again today," and the single EXITED line hiding among them gets skimmed past. Put the fixable failures in front, fold the unfixable ones into a count. That alone cut the number of lines worth reading first thing in the morning to almost nothing.
Unattended runs need one more step: if the READY set differs from last time, decide whether to proceed at all. Whether it is acceptable to run to completion when yesterday's 14-of-14 is today's 11 depends on the nature of the job. For aggregation or delivery work — the kind that finishes cleanly even with pieces missing — treating the READY count as a precondition proved safer.
Not re-measuring on every startup
The four outcomes differ enormously in how likely they are to change. EXITED and PROTOCOL_ERROR will return the same answer until you fix the config. TIMEOUT changes with time. Giving them different lifetimes rather than one uniform cache makes subsequent startups much lighter.
# registry.py - remember results with a lifetime per outcomeimport json, os, timefrom concurrent.futures import ThreadPoolExecutorfrom probe import probe, READY, TIMEOUT, EXITED, PROTOCOL_ERRORCACHE_PATH = os.path.expanduser("~/.cache/mcp-readiness.json")TTL = {READY: 900, TIMEOUT: 0, EXITED: 3600, PROTOCOL_ERROR: 3600}# ready: 15 min | no-response: always re-measure | fixed-until-you-fix-it: 1 hourdef _load(): try: with open(CACHE_PATH) as fp: return json.load(fp) except (OSError, ValueError): return {}def refresh(servers, timeout=5.0, max_workers=16): """servers: [(name, command, env_overrides), ...]""" cache, now, pending, results = _load(), time.time(), [], {} for name, command, env_overrides in servers: entry = cache.get(name) if entry and now - entry["at"] < TTL.get(entry["status"], 0): results[name] = entry # still within its lifetime; skip the handshake else: pending.append((name, command, env_overrides, timeout)) if pending: with ThreadPoolExecutor(max_workers=max_workers) as pool: for name, status, elapsed, detail in pool.map(lambda a: probe(*a), pending): results[name] = {"status": status, "elapsed": elapsed, "detail": detail, "at": now} os.makedirs(os.path.dirname(CACHE_PATH), exist_ok=True) tmp = CACHE_PATH + ".tmp" with open(tmp, "w") as fp: json.dump(results, fp, ensure_ascii=False) os.replace(tmp, CACHE_PATH) # never leave a half-written JSON behind return results
TTL[TIMEOUT] = 0 is the point of the whole thing. No-response is re-measured every time. Cache it as long as the others and a server that failed once at 8am is treated as absent for the rest of the day. Those four servers from that morning came very close to costing me a full day of output that way.
os.replace guards the write so an interrupted run does not leave corrupt JSON behind. If that file becomes unreadable, _load() returns empty and every server gets re-probed. That fails safe, but if it happens on every startup, caching stopped meaning anything.
What I still have not settled
Whether to set the deadline per server is still open. My fleet has a 0.05s responder living next to a 3.4s one, and handing 5.0s to all of them is plainly crude. But per-server values mean per-server maintenance. I tried deriving the deadline automatically from past READY timings; it learned from busy days and the deadline kept creeping upward, so I rolled it back.
The other loose end is what happens when a server dies after being judged READY. A successful handshake is not a promise about the rest of the session. That sits in the call layer rather than the connection layer, and it needs a different mechanism.
If you want a starting point, run probe.py once across your current MCP server list with a 5.0s deadline. The moment the results split into four outcomes, you know which ones deserve your attention today. In my case, out of seven lines saying "connection failed," exactly two were worth fixing.
Share
Thank You for Reading
Claude 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.