◉CLAUDE LABJP
●2.1.311 — Adds idle-hold options for self-hosted runners and fixes startup freezes and stalled interrupts (10/26)●2.1.307 — Fixes MCP failing to reconnect after sleep or a keychain lock (10/21). 2.1.306 also fixed the false claude -p login message●SONNET 4.5 — Retires on the Claude API on 11/30, 33 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●API — In API version 2026-10-21, passing temperature, top_p or top_k returns a 400 error●NEW — I Put a Ten-Line Promise File at the To…●2.1.311 — Adds idle-hold options for self-hosted runners and fixes startup freezes and stalled interrupts (10/26)●2.1.307 — Fixes MCP failing to reconnect after sleep or a keychain lock (10/21). 2.1.306 also fixed the false claude -p login message●SONNET 4.5 — Retires on the Claude API on 11/30, 33 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●API — In API version 2026-10-21, passing temperature, top_p or top_k returns a 400 error●NEW — I Put a Ten-Line Promise File at the To…
Articles/Claude Code
⟐ Claude Code/2026-07-29Advanced

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.

MCP59Claude Code275CI/CD19Unattended automationOperations24

✦ Premium Article

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. I had never trained myself to doubt a log line that looked sensible.

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, sys
 
TOKEN = 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, time
 
def 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))

Output from my Linux box, Python 3.10:

--- credential present ---
{"handshake_ok": true, "server": "report-server", "tools_list_ok": true, "tool_count": 2, "tool_names": ["fetch_daily_report", "list_report_dates"], "elapsed_ms": 19.8}
--- credential missing ---
{"handshake_ok": true, "server": "report-server", "tools_list_ok": true, "tool_count": 0, "tool_names": [], "elapsed_ms": 18.6}

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
✦When permissions are only partly short, the tool count is 1, not 0. A count-based check passes it; measured across three scopes, a name-level diff still points at the exact missing tool
✦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.

or
Unlock all articles with Membership →
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.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

⟐ Claude Code2026-10-23
When a Nightly claude -p Said MCP Needed Authentication, I Added a Preflight That Splits Sleep From Keychain
Claude Code 2.1.307 fixed MCP failures caused by macOS sleep and a locked login keychain. Here is a preflight script that helps with or without the fix, and a way to match failures against sleep history.
⟐ Claude Code2026-10-23
When a Nightly claude -p Says MCP "Needs Authentication": Check Mac Sleep and the Keychain First
If an unattended claude -p run leaves "needs authentication" or MCP timeouts in the morning log, check whether the Mac slept or the login keychain was locked. What v2.1.307 changed, and what it still leaves to you.
⟐ Claude Code2026-10-19
If You Wrote MCP Allow Rules as Just `mcp__servername` — After v2.1.299, Only Servers With `__` in Their Names Need a Recheck
Claude Code v2.1.299 changed MCP allow rules to match only the server they name. A bare mcp__servername is affected only when the server name contains __ or ends in _. Includes a short audit script.