[BUG] HTTP 403 (no WWW-Authenticate) is misclassified as "requires OAuth"; stale flag is keyed by server name and not cleared by mcp remove
[BUG] HTTP 403 (no WWW-Authenticate) is misclassified as "requires OAuth"; stale flag is keyed by server name and not cleared by mcp remove
Summary
When an HTTP MCP endpoint is fronted by a WAF/CDN (e.g. Cloudflare) that returns 403 Forbidden, Claude Code interprets it as "this server requires authentication," writes a persistent flag to ~/.claude/mcp-needs-auth-cache.json, and thereafter shows the server as ! Needs authentication.
Three compounding problems make this hard to diagnose and recover from:
- A
403with noWWW-Authenticateheader is treated as an OAuth challenge. Per the MCP authorization spec, the OAuth discovery flow should be triggered by401 Unauthorized+WWW-Authenticate, not by a bare403. A 403 is a WAF/geo/network block and should surface as a connection error, not an auth prompt. - **The needs-auth flag is keyed by the free-text server name, not the canonical endpoint URL.** Changing a server's
url(while keeping the same name) silently inherits the stale flag from the old URL. claude mcp remove(and re-add) does not purge the server's entry inmcp-needs-auth-cache.json. The only recovery is hand-editing the cache file.
Environment
- Claude Code:
2.1.211 - Platform: macOS 15.7.7 (24G720)
- Transport: HTTP (Streamable HTTP)
Repro
- Register an HTTP MCP server named e.g.
myserverpointing at a URL fronted by Cloudflare that returns403(WAF/bot-management block):
// ~/.claude.json
"mcpServers": {
"myserver": { "type": "http", "url": "https://blocked.example.com/mcp" }
}
The blocked endpoint responds like this — note no WWW-Authenticate header, server: cloudflare, HTML body:
$ curl -sS -i -X POST https://blocked.example.com/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"diag","version":"0.0.1"}}}'
HTTP/2 403
content-type: text/html; charset=UTF-8
set-cookie: __cf_bm=...; HttpOnly; SameSite=None; Secure
server: cloudflare
cf-ray: ...
<!DOCTYPE html> ... CDN/WAF block page (HTML) ...
- Observe Claude Code records the server as needing auth:
// ~/.claude/mcp-needs-auth-cache.json
{ "myserver": { "timestamp": 1784225258749 } } // note: keyed by name, no managed id
- Fix the URL to a working, public endpoint that returns a clean
200(same server name):
"myserver": { "type": "http", "url": "https://working.example.com/mcp" }
The working endpoint responds correctly, no auth needed:
HTTP/1.1 200 OK
content-type: application/json
{"result":{"protocolVersion":"2025-06-18","capabilities":{...},
"serverInfo":{"name":"...","version":"..."}},"jsonrpc":"2.0","id":1}
claude mcp liststill reportsmyserver ... ! Needs authentication, because the stale flag keyed by"myserver"survives the URL change.claude mcp remove myserver && claude mcp add ...does not clear it.
Expected
- A
403(or any non-401, or a401without aWWW-Authenticateheader) should not trigger the OAuth flow or set the needs-auth flag; it should surface as a connection/HTTP error with the status code. - The needs-auth cache should key on the canonical endpoint URL so that changing a server's
urldoes not inherit stale auth state. claude mcp remove(and re-adding a server) should purge that server'smcp-needs-auth-cache.jsonentry.
Actual
403→ server flagged! Needs authentication.- Flag keyed by server name, so it persists across
urlchanges. - Only recovery is manually deleting the entry from
~/.claude/mcp-needs-auth-cache.jsonand restarting.
Workaround
# Remove the poisoned entry, then restart Claude Code
python3 - <<'EOF'
import json, pathlib
p = pathlib.Path.home()/".claude/mcp-needs-auth-cache.json"
d = json.loads(p.read_text()); d.pop("myserver", None); p.write_text(json.dumps(d, indent=2))
EOF
Impact
Any MCP endpoint behind Cloudflare/Akamai/a corporate WAF that returns 403 to a subset of clients (bot heuristics, IP reputation, geo) gets permanently mislabeled as auth-gated. The failure is confusing because MCP Inspector connects fine (no per-name cache) and colleagues who added the server fresh are unaffected — so it presents as "works for everyone but me."