If you run claude -p overnight on a Mac at home, you may recognize a certain kind of morning. You open the log and an MCP server says "needs authentication", or it is treated as timed out and stays failed to the end. You changed nothing the night before.
The Claude Code 2.1.307 changelog has two fixes that match this situation.
| Symptom | What 2.1.307 says changed |
|---|---|
| On macOS, MCP shows "needs authentication" in `claude -p` while the login keychain is locked | It now says why, and the next run after an unlock connects without a new sign-in |
| MCP servers reported as timed out, and staying failed in headless runs, when macOS or Linux went to sleep while they were connecting | Fixed |
If it is fixed, you could simply update. I decided differently. Even after the fix, keeping a check for "can MCP be used right now?" before a nightly run keeps the morning loss small. This is the preflight I use, and the steps I follow to find the cause when a run still fails.
Split the failure into three
Failures that look alike in the morning have different causes.
- Sleep: the Mac went to sleep mid-connection, and the failed state outlived the wake
- Keychain: the login keychain was locked, so stored credentials could not be read
- Expired authentication: the token was revoked on the server side; waiting does not help
The first two tend to heal by waiting or by fixing the environment. The third needs a person to sign in again. The point of checking before the run is to tell them apart in advance: wait once for a failure that can heal, and skip at once for one that cannot.
The preflight: turn claude mcp list into one JSON line
#!/usr/bin/env python3
"""Read the output of claude mcp list and return one line describing MCP health before a nightly run.
Exit code: 0 = all connected / 3 = needs auth, failed or unreadable / 4 = claude cannot run
"""
import re, subprocess, sys, json
STATE_PATTERNS = [
("connected", re.compile(r"Connected", re.I)),
("needs_auth", re.compile(r"Needs authentication", re.I)),
("failed", re.compile(r"Failed to connect|timed out|timeout", re.I)),
]
def classify(line: str) -> str:
for name, pat in STATE_PATTERNS:
if pat.search(line):
return name
return "unknown"
def parse(text: str) -> dict:
servers = {}
for raw in text.splitlines():
line = raw.strip()
# e.g. "github: npx -y @x/server - ✓ Connected"; the server name ends at the first ": "
m = re.match(r"^([\w.\-]+):\s+(.*)$", line)
if not m:
continue
servers[m.group(1)] = classify(m.group(2))
return servers
def main() -> int:
try:
out = subprocess.run(["claude", "mcp", "list"], capture_output=True,
text=True, timeout=90).stdout
except (FileNotFoundError, subprocess.TimeoutExpired) as e:
print(json.dumps({"preflight": "no_claude", "error": type(e).__name__}))
return 4
servers = parse(out)
if not servers: # zero servers is not "all connected"; it means "could not read"
print(json.dumps({"preflight": "empty_list", "raw_head": out[:120]}, ensure_ascii=False))
return 3
bad = {k: v for k, v in servers.items() if v != "connected"}
print(json.dumps({"preflight": "ok" if not bad else "degraded",
"total": len(servers), "bad": bad}, ensure_ascii=False))
return 0 if not bad else 3
if __name__ == "__main__":
sys.exit(main())In the output I could check, claude mcp list printed lines shaped like "server name: command - status". Symbols and wording may differ by version, so the regular expressions match only the words for states. Run claude mcp list once on your own machine and confirm the line shape first.
A blind spot I hit while writing it
My first version returned ok whenever bad was empty. Then I ran it where claude mcp list printed nothing. With zero servers it reported ok and exited 0.
"No problem servers" and "no servers could be read" are different states. A preflight that returns ok on zero entries waves things through exactly when the command itself is not behaving as expected. The empty_list branch above is the fix. If you use no MCP servers at all, change it to treat zero as normal.