CLAUDE LABJP
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 infrastructureAUTHZ — The new spec strengthens OAuth and OIDC authorization and splits Apps and Tasks out into versioned extensionsSDK400 — MCP SDK downloads passed 400 million per month, roughly a 4x increase over the yearFORK — /fork copies your conversation into a new background session, so you can try a second approach without pausing the firstMCPLOGIN — claude mcp login authenticates a configured MCP server from your shell instead of the interactive /mcp menu, and claude mcp logout clears the stored credentialsREVIEWBG — /code-review now runs in the background, so the conversation keeps moving while the review worksMCPSPEC — 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 infrastructureAUTHZ — The new spec strengthens OAuth and OIDC authorization and splits Apps and Tasks out into versioned extensionsSDK400 — MCP SDK downloads passed 400 million per month, roughly a 4x increase over the yearFORK — /fork copies your conversation into a new background session, so you can try a second approach without pausing the firstMCPLOGIN — claude mcp login authenticates a configured MCP server from your shell instead of the interactive /mcp menu, and claude mcp logout clears the stored credentialsREVIEWBG — /code-review now runs in the background, so the conversation keeps moving while the review works
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.

MCP49Claude Code205CI/CD19Unattended automationOperations12

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.

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 — 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.

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-07-16
Your Overnight Session Wakes Up at 3GB — Four Places Memory Piles Up, and How to Tell Them Apart
The Claude Code process I left running overnight had grown to 3.4GB of resident memory by morning. Here are the four accumulation sources closed in 2.1.209, how to separate what's left in your own setup by sampling RSS slope, and a watchdog pattern that folds a session before it hurts.
Claude Code2026-07-27
Whose Environment Expands That Variable? Fingerprinting Your Effective Managed MCP Policy
Variable references in the Managed MCP allowlist and denylist now resolve from the startup environment and the managed-settings env block. I rebuilt both resolution orders locally to see where verdicts diverge, then wrote a preflight check that reduces the effective policy to a comparable fingerprint.
Claude Code2026-07-18
The MCP Server I Declared Vanished from the List — Designing Config for a Namespace You Share with the Vendor
When a vendor reserves an MCP server name, your .mcp.json declaration goes quiet instead of failing. Here is the preflight check and prefix convention I use to turn that silent gap into a loud stop before an unattended run.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →