●MCPSPEC — Claude now supports the MCP 2026-07-28 spec, which moves from a bidirectional stateful protocol to request/response so servers can run on serverless and edge infrastructure●AUTHZ — The new spec strengthens OAuth and OIDC authorization and splits Apps and Tasks out into versioned extensions●SDK400 — MCP SDK downloads passed 400 million per month, roughly a 4x increase over the year●FORK — /fork copies your conversation into a new background session, so you can try a second approach without pausing the first●MCPLOGIN — claude mcp login authenticates a configured MCP server from your shell instead of the interactive /mcp menu, and claude mcp logout clears the stored credentials●REVIEWBG — /code-review now runs in the background, so the conversation keeps moving while the review works●MCPSPEC — Claude now supports the MCP 2026-07-28 spec, which moves from a bidirectional stateful protocol to request/response so servers can run on serverless and edge infrastructure●AUTHZ — The new spec strengthens OAuth and OIDC authorization and splits Apps and Tasks out into versioned extensions●SDK400 — MCP SDK downloads passed 400 million per month, roughly a 4x increase over the year●FORK — /fork copies your conversation into a new background session, so you can try a second approach without pausing the first●MCPLOGIN — claude mcp login authenticates a configured MCP server from your shell instead of the interactive /mcp menu, and claude mcp logout clears the stored credentials●REVIEWBG — /code-review now runs in the background, so the conversation keeps moving while the review works
The MCP Server Connects Fine and Still Hands Back Zero Tools — Catching Silent Capability Drift with a Tool Manifest Diff
A missing credential does not break the MCP handshake. initialize succeeds, tools/list succeeds, and the array comes back empty. Here is the measured behaviour, and a preflight that locks the expected tool surface and fails closed before the agent ever starts.
I was reading through an unattended run log when something stopped me.
Exit code 0. The agent had produced a coherent summary. But the figures it was supposed to have pulled from our internal reporting server were nowhere in the output.
Not a single error line anywhere.
The cause was a missing credential. The MCP server had started, the handshake had completed, and it had offered exactly zero tools. The agent, seeing no tools available, answered from what it already knew. Nothing crashed, which is precisely why it took so long to notice.
Once you understand the protocol, this behaviour is unsurprising. It still feels like a betrayal when you are the one who handed the work over. As an indie developer running jobs overnight, nobody is watching until morning.
Connectivity and capability live on different layers
MCP startup has two meaningful steps. initialize negotiates protocol version and capabilities. tools/list then reports what can actually be called.
Credentials usually get consulted at or after that second step. What a server does when authentication is absent is left to whoever wrote it:
return an error and refuse the connection
keep the connection and return an empty tool list
advertise the tools and fail only when one is invoked
The second option is the dangerous one. Nothing about it violates the protocol. The JSON-RPC response carries result, not error. Your exception handler has nothing to catch.
And Claude Code does not halt when the available tool set shrinks. That leniency is a kindness in an interactive session. Unattended, it inverts.
Changing only the credential, and measuring
Rather than reason about it, I built the smallest server that could reproduce it — about eighty lines of dependency-free Python speaking JSON-RPC over stdio.
#!/usr/bin/env python3"""Minimal stdio MCP server. Exposes tools only when REPORT_API_TOKEN is present."""import json, os, sysTOKEN = os.environ.get("REPORT_API_TOKEN")TOOLS = [ {"name": "fetch_daily_report", "description": "Fetch the daily sales report", "inputSchema": {"type": "object", "properties": {"date": {"type": "string"}}, "required": ["date"]}}, {"name": "list_report_dates", "description": "List available report dates", "inputSchema": {"type": "object", "properties": {}}},]def respond(rid, result): sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": rid, "result": result}) + "\n") sys.stdout.flush()def main(): for line in sys.stdin: line = line.strip() if not line: continue req = json.loads(line) method, rid = req.get("method"), req.get("id") if method == "initialize": # The handshake succeeds regardless of credential state. respond(rid, { "protocolVersion": "2026-07-28", "capabilities": {"tools": {}}, "serverInfo": {"name": "report-server", "version": "1.0.0"}, }) elif method == "notifications/initialized": continue elif method == "tools/list": # Unauthenticated returns an EMPTY list, not an error. respond(rid, {"tools": TOOLS if TOKEN else []}) elif method == "shutdown": respond(rid, {}) return elif rid is not None: respond(rid, {})if __name__ == "__main__": main()
The probe collects handshake outcome, tools/list outcome, and the resulting tool names in a single launch.
#!/usr/bin/env python3"""Probe an stdio MCP server: measure handshake outcome and the resulting tool surface."""import json, os, subprocess, sys, timedef probe(cmd, env): proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=env, bufsize=1) def call(method, params=None, rid=None): msg = {"jsonrpc": "2.0", "method": method} if params is not None: msg["params"] = params if rid is not None: msg["id"] = rid proc.stdin.write(json.dumps(msg) + "\n"); proc.stdin.flush() if rid is None: return None return json.loads(proc.stdout.readline()) t0 = time.perf_counter() init = call("initialize", {"protocolVersion": "2026-07-28", "capabilities": {}, "clientInfo": {"name": "preflight-probe", "version": "0.1"}}, rid=1) call("notifications/initialized") listed = call("tools/list", {}, rid=2) elapsed_ms = (time.perf_counter() - t0) * 1000 call("shutdown", None, rid=3) proc.wait(timeout=5) tools = listed.get("result", {}).get("tools", []) return { "handshake_ok": "result" in init, "server": init.get("result", {}).get("serverInfo", {}).get("name"), "tools_list_ok": "result" in listed, "tool_count": len(tools), "tool_names": sorted(t["name"] for t in tools), "elapsed_ms": round(elapsed_ms, 1), }if __name__ == "__main__": server = [sys.executable, os.path.join(os.path.dirname(__file__), "report_server.py")] for label, extra in (("credential present", {"REPORT_API_TOKEN": "test-token"}), ("credential missing", {})): env = {k: v for k, v in os.environ.items() if k != "REPORT_API_TOKEN"} env.update(extra) print(f"--- {label} ---") print(json.dumps(probe(server, env), ensure_ascii=False))
This is where my expectation turned out to be backwards.
I had assumed a failed credential would surface as an anomaly at handshake time. Instead both handshake_ok and tools_list_ok stayed true. The only difference was tool_count: 2 against 0. Even the timing was flat — 19.8 ms versus 18.6 ms, no meaningful gap.
The failure does not arrive as slowness, and it does not arrive as an exception. It arrives as a quietly smaller number. If your monitoring watches connectivity, this difference is recorded nowhere at all.
✦
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
✦Measured proof that a missing credential still passes initialize and tools/list — tool count drops 2 to 0 with no error and no timing difference (19.8 ms vs 18.6 ms)
✦A ~120-line preflight that locks the expected tool names to a committed file and exits 78 when the observed surface is short
✦The bug that made the preflight itself crash — why Popen belongs inside the try block, and how its position changes what an on-call engineer concludes
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.
I settled on a single approach: commit the set of tool names that ought to exist, and compare it against observation before the agent starts.
It is the same instinct as a lockfile. I had been pinning dependency versions while leaving the shape of the agent's granted capability entirely unpinned.
The preflight reads the same .mcp.json shape Claude Code already uses.
Lock generation and verification live in one script.
#!/usr/bin/env python3"""Fail closed when the effective MCP tool surface differs from the committed lock file."""import json, os, subprocess, sys, timeCONFIG = os.environ.get("MCP_CONFIG", ".mcp.json")LOCK = os.environ.get("MCP_TOOL_LOCK", "mcp-tools.lock.json")TIMEOUT_S = float(os.environ.get("MCP_PROBE_TIMEOUT_S", "10"))def expand(value: str) -> str: # ${VAR} in the config resolves from the *launch* environment. if value.startswith("${") and value.endswith("}"): return os.environ.get(value[2:-1], "") return valuedef probe(name, spec): env = {k: v for k, v in os.environ.items()} for k, v in (spec.get("env") or {}).items(): env[k] = expand(v) try: # Popen must live INSIDE the try: a mistyped command raises FileNotFoundError here, # and letting it escape turns a config error into a preflight crash. proc = subprocess.Popen([spec["command"], *spec.get("args", [])], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, env=env, bufsize=1) def call(method, params=None, rid=None): msg = {"jsonrpc": "2.0", "method": method} if params is not None: msg["params"] = params if rid is not None: msg["id"] = rid proc.stdin.write(json.dumps(msg) + "\n"); proc.stdin.flush() return json.loads(proc.stdout.readline()) if rid is not None else None call("initialize", {"protocolVersion": "2026-07-28", "capabilities": {}, "clientInfo": {"name": "mcp-preflight", "version": "1.0"}}, rid=1) call("notifications/initialized") listed = call("tools/list", {}, rid=2) call("shutdown", None, rid=3) proc.wait(timeout=TIMEOUT_S) return sorted(t["name"] for t in listed.get("result", {}).get("tools", [])) except Exception as exc: # spawn failure, malformed frame, timeout if "proc" in dir(): proc.kill() return {"error": f"{type(exc).__name__}: {exc}"}def main(): started = time.perf_counter() servers = json.load(open(CONFIG))["mcpServers"] observed = {name: probe(name, spec) for name, spec in servers.items()} if "--update-lock" in sys.argv: json.dump(observed, open(LOCK, "w"), indent=2, sort_keys=True) print(f"wrote {LOCK}") return 0 expected = json.load(open(LOCK)) failures = [] for name, want in expected.items(): got = observed.get(name) if isinstance(got, dict): failures.append(f"{name}: probe failed ({got['error']})") continue missing = [t for t in want if t not in (got or [])] if missing: failures.append(f"{name}: {len(missing)}/{len(want)} tools missing -> {missing}") took = (time.perf_counter() - started) * 1000 if failures: print(f"MCP preflight FAILED in {took:.0f} ms") for f in failures: print(f" - {f}") return 78 # EX_CONFIG: wiring problem, not a runtime fault print(f"MCP preflight OK in {took:.0f} ms ({sum(len(v) for v in expected.values())} tools verified)") return 0if __name__ == "__main__": sys.exit(main())
Generating the lock, then verifying both states:
$ REPORT_API_TOKEN=test-token python3 preflight.py --update-lockwrote mcp-tools.lock.json$ cat mcp-tools.lock.json{ "report": [ "fetch_daily_report", "list_report_dates" ]}$ REPORT_API_TOKEN=test-token python3 preflight.py; echo "exit=$?"MCP preflight OK in 23 ms (2 tools verified)exit=0$ env -u REPORT_API_TOKEN python3 preflight.py; echo "exit=$?"MCP preflight FAILED in 23 ms - report: 2/2 tools missing -> ['fetch_daily_report', 'list_report_dates']exit=78
The silent failure now has a line of output and an exit code attached to it.
I measured the overhead too. With five servers configured, three consecutive runs took 116, 116 and 117 ms — roughly 5x the single-server figure of about 23 ms, scaling close to linearly with server count. Under 0.2 seconds per unattended run is a price I pay without hesitation.
The preflight crashed on me
Shortly after writing it, I threw in a server that could not start. The preflight died.
FileNotFoundError: [Errno 2] No such file or directory: './no-such-binary'exit=1
subprocess.Popen sat outside the try block. A nonexistent command raises at the Popen call itself, so the inner except never saw it.
The position looks like a triviality. In CI it is not. A stack trace with exit code 1 reads as "the verification tool is broken", and whoever is on call starts debugging the preflight. The actual problem was a typo in a command name — a long detour to a short answer.
After moving Popen inside the try:
MCP preflight FAILED in 1 ms - ghost: probe failed (FileNotFoundError: [Errno 2] No such file or directory: './no-such-binary')exit=78
Same failure, but now the reader is handed a next step. When you write a verification tool, its own error paths deserve as much design attention as its happy path — a checker that misdiagnoses is worse than no checker.
Exit codes and CI placement
I chose 78 to match EX_CONFIG from sysexits.h. Exit code 1 says nothing beyond "something failed". A distinct number lets the workflow decide whether to retry or page a human.
Order matters. Discovering the gap after the agent has started does not refund the tokens it already spent. Running the preflight first reclassifies missing capability as a configuration error caught before execution.
Update the lock by running --update-lock when tools legitimately change, and put the diff through review. Regenerating it automatically defeats the entire mechanism: a tool could silently disappear and the manifest would simply follow it down. The lock is meant to be read by a person.
How strict is strict enough
Whether to require exact equality or accept a superset took some thought.
I require only that every name in the lock is present, and I let unknown extras through. Halting an overnight job because the server author shipped an additional tool struck me as a poor trade.
Situation
Verdict
Reasoning
Any locked tool is missing
Halt
The agent is narrower than intended and results degrade silently
Unknown extra tools observed
Pass
Added capability does not corrupt results; review at lock-update time
Server fails to start
Halt
Config, dependency or permission is broken
Same name, changed input schema
Not caught here
A name set is too coarse; cover it with caller-side contract tests
That last row marks the limit of the approach. A set of names is a coarse net, blind to argument compatibility. You could hash inputSchema into the lock as well, but in my environment that tripped constantly on harmless wording changes on the server side, and the check stopped being run at all. A coarse net that survives contact with daily operations beats a fine one that gets switched off.
Reliability in unattended work comes less from preventing failure than from making failure observable as failure. That was the single lesson here.
To try it yourself:
Run --update-lock against your current .mcp.json and write out today's tool surface
Read the resulting list with your own eyes, checking for servers you did not expect
Remove one credential and confirm the preflight actually fails
Insert it immediately before the agent-launch step in CI
Please do not skip step three. A verification tool that has never been verified is worse than none, and I very nearly trusted mine while Popen was still in the wrong place.
Thank you for reading. If this shortens someone else's hunt through a quiet log, that would make me glad.
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.