●MCP — Support for the 2026-07-28 spec is rolling out across Claude. The protocol moves from bidirectional and stateful to request/response, so MCP servers can now live on serverless and edge infrastructure●EXTENSIONS — Three official extensions have landed: MCP Apps for server-rendered UI, Tasks for async and long-running work, and Enterprise Managed Auth for IdP-based org-wide provisioning●ADOPTION — MCP passed 400 million monthly SDK downloads, roughly 4x growth this year, settling into its role as the standard way to connect agents to applications●QUOTA — Today, August 19, is the last day of the 50 percent weekly usage boost for Claude Code subscribers. If you have long agent runs queued, this is the window●PRICING — Claude Sonnet 5's introductory rate of $2 per million input tokens and $10 output ends August 31; standard pricing of $3 and $15 takes over on September 1, twelve days out●FIX — A bug where MCP v2 connections endlessly reopened subscriptions against servers with fixed timeouts is resolved, and a forward_user_identity setting was added for user attribution●MCP — Support for the 2026-07-28 spec is rolling out across Claude. The protocol moves from bidirectional and stateful to request/response, so MCP servers can now live on serverless and edge infrastructure●EXTENSIONS — Three official extensions have landed: MCP Apps for server-rendered UI, Tasks for async and long-running work, and Enterprise Managed Auth for IdP-based org-wide provisioning●ADOPTION — MCP passed 400 million monthly SDK downloads, roughly 4x growth this year, settling into its role as the standard way to connect agents to applications●QUOTA — Today, August 19, is the last day of the 50 percent weekly usage boost for Claude Code subscribers. If you have long agent runs queued, this is the window●PRICING — Claude Sonnet 5's introductory rate of $2 per million input tokens and $10 output ends August 31; standard pricing of $3 and $15 takes over on September 1, twelve days out●FIX — A bug where MCP v2 connections endlessly reopened subscriptions against servers with fixed timeouts is resolved, and a forward_user_identity setting was added for user attribution
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 — on stdio and on remote HTTP alike, tool count drops 2 to 0 with no error and no timing signal
✦A preflight that probes both transports behind one interface, enforces a deadline, and exits 78 when the observed tool surface is short
✦Three defects found in the verification tool itself — the position of Popen, a readline no timeout could reach, and lock generation that recorded a failed probe as the expectation
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.
Rereading the rest with that lens, the holes did not stop there.
The preflight that never came back
Reading my own implementation with that lens turned up more gaps in a row.
I added a server that answers initialize but returns nothing at all for tools/list. The preflight never finished. Ten seconds passed, then twenty, with MCP_PROBE_TIMEOUT_S set to 10. Control only came back when an outer timeout 30 killed it.
$ REPORT_API_TOKEN=test-token MCP_PROBE_TIMEOUT_S=10 timeout 30 python3 preflight.pyexit=124 elapsed=30s # 124 means timeout(1) had to step in
The number I passed only reached proc.wait(timeout=...). The call that actually blocks sits earlier — proc.stdout.readline() — and it waits for as long as it takes. When a server goes quiet, wait is never reached at all.
For unattended work this is worse than the original symptom. Zero tools still lets the job finish. A stuck preflight holds the slot and nothing moves.
I rewrote it around a single deadline, re-checked on every read.
#!/usr/bin/env python3"""Deadline-aware line read for a stdio MCP probe."""import json, selectors, subprocess, timeclass ProbeTimeout(Exception): passdef read_line_before(stream, deadline): """Return one line, or raise ProbeTimeout once the deadline passes.""" sel = selectors.DefaultSelector() sel.register(stream, selectors.EVENT_READ) try: while True: remaining = deadline - time.monotonic() if remaining <= 0: raise ProbeTimeout("no response before deadline") if not sel.select(timeout=remaining): continue # woke early; loop re-checks the clock line = stream.readline() # ready to read: this no longer blocks if line == "": raise ProbeTimeout("server closed the stream") return line finally: sel.unregister(stream) sel.close()def probe(command, args, env, budget_s=10.0): deadline = time.monotonic() + budget_s proc = subprocess.Popen([command, *args], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, env=env, bufsize=1) try: 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(read_line_before(proc.stdout, deadline)) call("initialize", {"protocolVersion": "2026-07-28", "capabilities": {}, "clientInfo": {"name": "mcp-preflight", "version": "1.1"}}, rid=1) call("notifications/initialized") listed = call("tools/list", {}, rid=2) return sorted(t["name"] for t in listed["result"]["tools"]) finally: proc.kill() # the budget is spent; no polite shutdown proc.wait(timeout=2)
Three details carry the weight here.
The deadline belongs to the whole probe, not to each call. Spend nine seconds on initialize and tools/list gets one.
sel.select() can return with nothing ready, so the clock is re-read at the top of every iteration.
An empty string means the server died. Miss that and you wait out the full budget on a process that already exited.
The same two servers, run with a two-second budget:
healthy: tools=['fetch_daily_report', 'list_report_dates'] in 24 msstalled: ProbeTimeout(no response before deadline) after 2004 ms (budget 2.0s)
The healthy path stays at 24 ms; the stuck one returns at 2,004 ms. In CI, the gap between waiting forever and giving up at two seconds is the gap between an unnoticed occupied runner and a failure someone can read.
Choosing the number took some thought. I keep the budget at two seconds per server: healthy probes measurably finish in 20 to 30 ms, so a hundred times that reads as a fair ceiling. Configurations that include servers across a network go back to ten. But a generous default hides "occasionally slow" inside "occasionally stuck", and starting tight and loosening on evidence has served me better.
Remote servers were never covered by any of this
Everything above only reads config entries with command and args — stdio servers spawned as a local process.
Since the 28 July 2026 specification moved MCP from a bidirectional stateful connection to request/response, putting a server on serverless or edge infrastructure became practical. Entries carrying type and url are showing up in real .mcp.json files. A preflight that silently skips them leaves a wide hole.
Request/response is, if anything, convenient for a prober. There is no session to maintain, so two POSTs are enough.
def probe_remote(spec, budget_s): """Probe a remote (HTTP) MCP server. Nothing to keep alive between calls.""" url = spec["url"] headers = {"Content-Type": "application/json"} headers.update({k: expand(v) for k, v in (spec.get("headers") or {}).items()}) deadline = time.monotonic() + budget_s def rpc(method, params, rid): remaining = deadline - time.monotonic() if remaining <= 0: raise ProbeTimeout("budget spent before request") req = urllib.request.Request(url, data=json.dumps( {"jsonrpc": "2.0", "id": rid, "method": method, "params": params}).encode(), method="POST", headers=headers) with urllib.request.urlopen(req, timeout=remaining) as resp: return json.loads(resp.read()) rpc("initialize", {"protocolVersion": "2026-07-28", "capabilities": {}, "clientInfo": {"name": "mcp-preflight", "version": "1.1"}}, 1) listed = rpc("tools/list", {}, 2) return sorted(t["name"] for t in listed["result"]["tools"])def probe(spec, budget_s=10.0): """Return a sorted tool-name list, or a dict describing why the probe failed.""" handler = probe_remote if spec.get("type") in ("http", "sse") or "url" in spec else probe_stdio try: return handler(spec, budget_s) except Exception as exc: return {"error": f"{type(exc).__name__}: {exc}"}
To measure it I stood up a small HTTP server whose only behaviour is to check the authorization header.
Identical to stdio. A rejected token still produces HTTP 200, still produces a JSON-RPC result, and only the array inside is empty. Timing gives nothing away either — everything landed between 1.3 ms and 4.0 ms with no pattern tied to authentication.
That the failure keeps its shape across transports is good news: the fix in this article carries over unchanged.
The first implementation could not expand an embedded ${VAR}
Right after adding remote support, I kept getting zero tools while passing what I believed was the correct token. My own expand() was the culprit.
It only substitutes when the entire value is ${VAR}. A stdio env entry looks like "REPORT_API_TOKEN": "${REPORT_API_TOKEN}", so that path worked. An auth header looks like "Bearer ${REPORT_API_TOKEN}".
The server rejected the literal string, the tool list came back empty, and the preflight failed — correctly, and for entirely the wrong reason. Failing closed is the safe outcome, but the cause was string handling inside the verification tool, not a missing credential. The thing built to catch silent failures had manufactured one of its own.
Substituting every occurrence fixes it.
VAR = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")def expand(value: str) -> str: """Substitute every ${VAR} occurrence, not only a value that is exactly ${VAR}.""" return VAR.sub(lambda m: os.environ.get(m.group(1), ""), value)
Lock generation can bake in a broken state
With both transports handled, I ran --update-lock against three servers — a healthy stdio one, a healthy remote one, and the unresponsive one — and stopped again.
{ "report": ["fetch_daily_report", "list_report_dates"], "report-remote": ["fetch_daily_report", "list_report_dates"], "stalled": { "error": "ProbeTimeout: no response before deadline" }}
A failed observation had been written down as an expectation. The verifier detects failures by type, so that entry will now report "probe failed" on every future run. Worse in the other direction: if a server had returned zero tools at generation time, an empty array gets baked in and that server passes verification no matter what happens to it.
A lock file holds expectations. A run that failed to observe anything must not be promoted into one. So the generator got a gate.
if "--update-lock" in sys.argv: broken = {n: v["error"] for n, v in observed.items() if isinstance(v, dict)} if broken: print("refusing to write the lock: some probes failed") for n, e in broken.items(): print(f" - {n}: {e}") return 78 json.dump(observed, open(LOCK, "w"), indent=2, sort_keys=True) print(f"wrote {LOCK}") return 0
Generation against the three-server config, then verification after regenerating from the two healthy ones:
$ ... --update-lock # config includes the unresponsive serverrefusing to write the lock: some probes failed - stalled: ProbeTimeout: no response before deadlineexit=78$ ... --update-lock # stdio + remotewrote mcp-tools.lock.jsonexit=0$ REPORT_API_TOKEN=test-token python3 preflight.py; echo "exit=$?"MCP preflight OK in 29 ms (4 tools verified)exit=0$ env -u REPORT_API_TOKEN python3 preflight.py; echo "exit=$?"MCP preflight FAILED in 27 ms - report: 2/2 tools missing -> ['fetch_daily_report', 'list_report_dates'] - report-remote: 2/2 tools missing -> ['fetch_daily_report', 'list_report_dates']exit=78
Removing one credential empties both transports in exactly the same shape. Not needing a separate watch per transport was an unplanned benefit of the design.
Leave the unresponsive server in the config and verification halts at 2,032 ms — the difference from the version that waited forever, expressed as a number.
MCP preflight FAILED in 2032 ms - stalled: probe failed (ProbeTimeout: no response before deadline)exit=78
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
No response within the deadline
Halt
Capability was never confirmed; unknown is not the same as healthy
A probe failed during lock generation
Refuse to write
Promoting a failed observation into the expectation voids every later check
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 — for servers you did not expect, and for remote entries that were skipped
Remove one credential and confirm the preflight actually fails
Add one server that never answers and confirm it gives up on schedule
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.