[BUG] Claude Desktop and Claude Code CLI never re-register MCP tools after OAuth 2.1 handshake on a remote HTTP server

Resolved 💬 2 comments Opened May 28, 2026 by spipu Closed May 30, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

After a successful OAuth 2.1 + PKCE S256 authorization flow against a remote http-transport MCP server, both Claude Desktop and Claude Code CLI silently fail to register the post-auth tool catalog.

  • The two pre-auth virtual tools (mcp__<name>__authenticate, mcp__<name>__complete_authentication) disappear from the session as expected after the OAuth completes, signalling that the client correctly observed the auth state transition.
  • However, the real tool catalog advertised by the server's tools/list (24 tools in our case, across multiple providers) is never exposed to the agent.
  • The user ends up with zero mcp__<name>__* tools usable in the session for that server.
  • A clean restart (quit Claude Desktop + kill all processes + purge cached OAuth credentials in ~/.claude.json + relaunch) does not restore the behaviour.

We have spent significant time eliminating the server side as a cause:

  • The server has been validated as strictly conformant to OAuth 2.1, RFC 6749 §5.1, RFC 8414, RFC 9728, RFC 7591, and the MCP spec 2025-06-18, from three independent angles:
  • manual curl walk-through of the entire OAuth + MCP flow (every step succeeds, every response shape conforms),
  • the official MCP Inspector (@modelcontextprotocol/inspector v0.21.2),
  • a line-by-line RFC audit of every response (body and headers).
  • Two targeted dependency rollbacks of the server side were applied to production and re-tested with Claude Desktop:
  • Round 1: roll back mcp/sdk v0.5.0 → v0.4.0 (transitively also symfony/mcp-bundle v0.9.0 → v0.8.0, and phpdocumentor/reflection-docblock 6→5 + type-resolver 2→1). → No change.
  • Round 2: roll back every Symfony 7.4.9..13 component to 7.4.8 (35 components, reintroducing 20 CVEs flagged by composer audit, accepted strictly for the bisect). → No change.

Both rollbacks were reverted afterwards. Neither matched the cause.

The remaining never-rolled-back deltas from the same composer bump (AWS SDK patch, Doctrine ORM patch, Guzzle patches, Twig minor, webmozart/assert minor) are all outside the inbound MCP request path and are not plausible candidates.

Conclusion: the cause is client-side, in the code path that should re-handshake an http-transport MCP server after a successful OAuth dance and call tools/list on it.

The fact that the affected server is silently dropped from the very first replaceRemoteMcpServers call — before any per-server log line is emitted — suggests the drop happens during initial config parsing or server-bootstrap validation, possibly tied to recent client-side hardening on http-transport MCP servers using OAuth 2.1 Dynamic Client Registration.

What Should Happen?

After the user completes the OAuth + consent flow in the system browser and the callback is consumed by the embedded local callback server:

  1. The pre-auth virtual tools should disappear from the session — this part works.
  2. The client should issue an MCP initialize JSON-RPC call against the server with the freshly-obtained Authorization: Bearer <access_token> header. The server responds with 200 OK carrying Mcp-Session-Id: <uuid>, capabilities, serverInfo, and the negotiated protocolVersion: "2025-06-18".
  3. The client should issue notifications/initialized (replied with 202 Accepted).
  4. The client should issue tools/list — the server returns the full tool catalog.
  5. The agent should now have the real tools (mcp__<name>__<tool>) usable in the same session, without requiring a restart.

curl reproduces the full sequence successfully against our production server with any freshly-issued Bearer token.

Error Messages/Logs

### Claude Desktop (`%APPDATA%\Claude\logs\main.log`)


[CCD] [replaceRemoteMcpServers] Calling SDK with 9 total servers
[CCD] LocalSessions.replaceRemoteMcpServers: sessionId=local_…, serverCount=2


- 9 servers are configured in `~/.claude.json`; only 2 reach the local session.
- The affected server is among the 7 silently dropped between those two log lines.
- **No log line containing the affected server's name or hostname appears anywhere in `main.log` during boot.** Whatever filter drops it does not log its reason, which is the main blocker for self-diagnosis.

### Claude Code CLI (interactive session, Linux)

The agent observes the pre-auth tools disappear after the user pastes the OAuth callback URL, then no further tool registration happens. No structured error is surfaced in stdout; the MCP server simply reports as disconnected from the client's perspective shortly after.

Calling `claude mcp list` afterwards rapports `✓ Connected` (the OAuth tokens are cached, the credentials are valid), but the in-session tool catalogue stays empty.

### Server side

`POST /` (with the access token issued during the failed Desktop / CLI session) returns the full conformant `initialize` response when called from `curl`. The server's access logs show **no** `initialize` request from the Claude client after the `/oauth/token` exchange — the client never tries.

Steps to Reproduce

# On Linux with the standalone CLI (also reproducible on Claude Desktop on Windows)
claude mcp add --transport http -s user <name> https://<mcp-server-host>/
claude mcp list
# → <name>: https://<mcp-server-host>/ (HTTP) - ! Needs authentication

claude   # start interactive session

In the session:

  1. Agent invokes mcp__<name>__authenticate. It returns an authorization URL like:

``
https://<mcp-server-host>/oauth/authorize?response_type=code&client_id=<id>&code_challenge=<S256>&code_challenge_method=S256&redirect_uri=http%3A%2F%2Flocalhost%3A<port>%2Fcallback&state=<state>&resource=https%3A%2F%2F<mcp-server-host>%2F
`
The local callback server is listening on
localhost:<port>`.

  1. User opens the URL in a system browser, completes SSO + consent.
  2. Browser is redirected to http://localhost:<port>/callback?code=<code>&state=<state> — the local callback server consumes the code.
  3. Client exchanges the code at POST /oauth/token and gets back { access_token, refresh_token, expires_in: 3600, token_type: "Bearer" }. Token rotation works correctly on subsequent refreshes.
  4. The two pre-auth virtual tools disappear from the session (correct).
  5. Observed: no real tools surface. The session is left with zero mcp__<name>__* tools.

For comparison, the same flow done outside the client (manual curl with the same Bearer token issued in step 4) succeeds end-to-end and returns the full tools/list of 24 tools.

Claude Model

None

Is this a regression?

Yes, this worked in a previous version

Last Working Version

_No response_

Claude Code Version

2.1.149

Platform

Anthropic API

Operating System

Ubuntu/Debian Linux

Terminal/Shell

Terminal.app (macOS)

Additional Information

Environment

| Field | Value |
|---|---|
| OS (client) | Windows 11 |
| Claude Desktop (Electron shell) | 1.9255.2 |
| Embedded Claude Code (CCD) | 2.1.149 |
| Node embedded in CCD | 24.15.0 |
| Claude Code CLI (standalone, Linux) | 2.1.152 (also reproducing) |
| MCP transport | remote http (Streamable HTTP, JSON-RPC 2.0) |
| MCP server URL pattern | https://<mcp-server-host>/ |
| Auth | OAuth 2.1 + PKCE S256, Dynamic Client Registration (RFC 7591) |
| Client config location | ~/.claude.jsonmcpServers.<name> (type http) |

Related

  • #62298 (Windows, May 25 2026) — same broader class of bug (MCP post-OAuth broken on http transport), but a different failure mechanism: there, the mcp-needs-auth-cache.json is not cleaned up after a successful auth, so the client keeps re-flagging the server as ! Needs authentication. Here, the cache is cleaned (the pre-auth virtual tools disappear, signalling the client knows the OAuth succeeded), yet the post-auth tools/list handshake never happens. Same area of the code path is likely involved.

Appendix A — Server-side conformance proofs (so you can skip triaging this)

Manual curl walk-through (production server, real tokens)

POST /oauth/register                                  → 201, client_id issued
GET  /.well-known/oauth-authorization-server          → 200, RFC 8414 conformant
GET  /.well-known/oauth-protected-resource            → 200, RFC 9728 conformant
GET  /oauth/authorize?…                                → 200, consent page
POST /oauth/consent …                                  → 302 with auth code
POST /oauth/token grant_type=authorization_code        → 200, access_token + refresh_token + expires_in: 3600
POST /                            initialize           → 200
                                                          Mcp-Session-Id: <uuid>
                                                          protocolVersion: "2025-06-18"
                                                          capabilities: {logging, completions, prompts.listChanged, resources.listChanged, tools.listChanged}
                                                          serverInfo: {name, version, description, icons}
POST /                            notifications/initialized → 202
POST /                            tools/list           → 200, 24 tools returned (full inputSchema, descriptions, names)
POST /                            tools/call <name>.hello → 200, isError: false
POST /oauth/token grant_type=refresh_token             → 200, new pair issued, old access revoked

CORS headers emitted on POST / (so it works even from browser-style clients)

Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS
Access-Control-Allow-Headers: Accept,Authorization,Content-Type,Last-Event-ID,Mcp-Protocol-Version,Mcp-Session-Id
Access-Control-Expose-Headers: Mcp-Session-Id

Known minor non-conformance (not blocking, flagged for honesty)

  • POST /oauth/token headers carry Cache-Control: no-cache, private instead of the RFC 6749 §5.1 mandated no-store, and the Pragma: no-cache header is missing. No major OAuth client enforces these in practice. Both curl and MCP Inspector are happy with the response.

Appendix B — Bisect attempts already done

Round 1 — MCP SDK rollback (mcp/sdk v0.5.0 → v0.4.0)

Reasoning: mcp/sdk v0.5.0 (released 2026-04-26) shipped a refactor of OAuth2 to a middleware-based architecture, an Add top-level title to Tool schema-level change, a relax StrictOidcDiscoveryMetadataPolicy + Dynamic Client Registration middleware (RFC 7591) change, and a Session::save() lazy-init fix. All plausible candidates for breaking a strict client. Composer also transitively downgraded symfony/mcp-bundle v0.9.0 → v0.8.0 and phpdocumentor/reflection-docblock 6.x → 5.x + type-resolver 2.x → 1.x.

Result: no change. Claude Desktop still drops the server during replaceRemoteMcpServers.

Round 2 — Symfony 7.4 patches rollback (every 7.4.9..13 → 7.4.8)

Reasoning: the 7.4.9..7.4.13 patch series contained CVE fixes touching http-kernel, security-http (notably PR #64181 HttpUtils::createRequest() URL rebuild), framework-bundle (PR #64189 argument_resolver priority change), security-bundle (HEAD method bypass fix), serializer, http-foundation, mime, cache, runtime. Any of these could plausibly change response shape or middleware ordering. 35 components downgraded; 20 CVEs deliberately reintroduced for the bisect via composer --no-security-blocking (then reverted).

Result: no change. Claude Desktop still drops the server.

Suspects eliminated by Rounds 1 + 2 combined

  • mcp/sdk v0.5.0 (OAuth middleware refactor, Tool::__construct BC break, Session::save() fix)
  • symfony/mcp-bundle v0.9.0 (TraceableRegistry ResetInterface removal)
  • phpdocumentor/reflection-docblock 6.x + type-resolver 2.x (used by the SDK to reflect tool annotations into schemas)
  • All Symfony 7.4.9..7.4.13 component patches

Remaining unrolled deltas from the same server-side composer bump: AWS SDK patch, Doctrine ORM patch, Guzzle patches, Twig 3.24 → 3.26, webmozart/assert 2.3 → 2.4. None of these sit on the inbound MCP request path.

---

What we would need from Anthropic

  1. Confirmation that the symptom matches a known client-side regression in the http-transport + OAuth 2.1 code path, ideally with rough timing of when it was introduced.
  2. If unknown: a debug switch (env var, CLI flag, log level) to surface the silent drop reason in replaceRemoteMcpServers — currently the absence of any log line for the dropped server is the main blocker for self-diagnosis.
  3. A reproducible test case or a fix.

Full conformance traces, server access logs around the failing handshakes, and a screen recording of the symptom can be provided on request.

View original on GitHub ↗

This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗