CLAUDE LABJP
2.1.268 — The headline in this release is not a feature. It is a fix for a bug that had been breaking compatible endpoints since 2.1.265BASE_URL — Third-party Anthropic-compatible endpoints behind ANTHROPIC_BASE_URL were failing every turn with HTTP 400. Upgrade before you touch your configWEBFETCH — WebFetch no longer waits forever on a server that keeps the response open. It now gives up after 300 seconds, tunable via CLAUDE_CODE_WEBFETCH_DEADLINE_MSSECRET — Secrets resolved from ${VAR} placeholders in MCP configs were being printed by /mcp and claude mcp list. Worth knowing if you share your screenCPU — A busy loop that pinned a core during long idle sessions is fixed, so that fan you could not explain may finally settle downRESUME — --continue and --resume now show the conversation right away instead of waiting on SessionStart hooks2.1.268 — The headline in this release is not a feature. It is a fix for a bug that had been breaking compatible endpoints since 2.1.265BASE_URL — Third-party Anthropic-compatible endpoints behind ANTHROPIC_BASE_URL were failing every turn with HTTP 400. Upgrade before you touch your configWEBFETCH — WebFetch no longer waits forever on a server that keeps the response open. It now gives up after 300 seconds, tunable via CLAUDE_CODE_WEBFETCH_DEADLINE_MSSECRET — Secrets resolved from ${VAR} placeholders in MCP configs were being printed by /mcp and claude mcp list. Worth knowing if you share your screenCPU — A busy loop that pinned a core during long idle sessions is fixed, so that fan you could not explain may finally settle downRESUME — --continue and --resume now show the conversation right away instead of waiting on SessionStart hooks
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.

MCP53Claude Code253CI/CD19Unattended automationOperations18

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

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 $15 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-09-04
The ~/.claude.json Rollback Is Fixed in v2.1.259. Putting Back What It Erased Is Still Your Job
Concurrent sessions used to silently roll back each other's ~/.claude.json changes. v2.1.259 fixed that, but nothing restores the trust settings and MCP servers you already lost. Here is how I find and rebuild them, with a small key-path diff script.
Claude Code2026-09-03
Aligning Log Timezones at Display Time, or Fixing Them at Write Time
AdMob, the store reports, and my own logs each ended the day at a different moment. Here is how I went back and forth between display-side and write-side timezone handling, and what I settled on.
📚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