headersHelper server falls into OAuth dynamic client registration on 401 instead of re-running the helper
Environment
- Claude Code 2.1.220, Windows 11
- MCP server, user scope,
type: http, configured withheadersHelperand nooauthblock - The helper mints a short-lived bearer (Entra ID delegated token via Azure CLI) and prints
{"Authorization": "Bearer <token>"}on stdout - The auth server is Microsoft Entra ID, which does not implement RFC 7591 (dynamic client registration) — its metadata has no
registration_endpoint
Expected
Per the docs:
As of v2.1.193, if a tool call returns401 Unauthorizedor403 Forbidden, Claude Code automatically re-runs the helper, reconnects with the fresh headers, and retries the call once.
Actual
Once the bearer expires, every tool call on that server fails with:
Incompatible auth server: does not support dynamic client registration
The headersHelper is never executed. I verified this by instrumenting the helper to append a timestamped line on every invocation — there is no entry at the time of failure. The server stays broken until a manual reconnect via /mcp, which does run the helper, obtains a fresh token, and restores normal operation.
Reproduction
- Register an HTTP MCP server with a
headersHelperthat returns a bearer valid for ~60 minutes, against an OAuth authorization server whose metadata has noregistration_endpoint. - Use the server successfully.
- Wait for the bearer to expire.
- Call any tool on that server.
Evidence that the helper mechanism itself works
Earlier in the same session the helper was re-run automatically, roughly 17 seconds before the bearer expired, and the immediately following tool call succeeded. Instrumentation log (token values never logged):
11:02:16 OK expires=12:24:00 in=82min <- helper re-run automatically, pre-expiry
11:04:30 OK expires=12:24:00 in=79min <- manual reconnect
11:21:30 OK expires=12:24:00 in=62min <- last invocation of the day
<- 13:17 tool calls failed, NO helper entry
So the helper path is functional. It simply is not taken on a post-expiry 401.
Possibly relevant
In the tool-call retry logic, the OAuth candidate appears to be gated on the server not having a headersHelper (!r.headersHelper), which would make the dynamic-registration path unreachable for this configuration. The DCR attempt therefore seems to originate from a different path that lacks that guard.
Impact
Any MCP server using headersHelper against an Entra-protected endpoint becomes unusable when the token expires and requires a manual reconnect. The documented automatic recovery never fires.
Separately, the surfaced error is misleading: it points the user at a server misconfiguration ("auth server does not support dynamic client registration") when the actual condition is an expired credential. For non-technical operators this is very hard to map to "your session expired, reconnect".
Workaround
Configuring oauth.clientId with a pre-registered client avoids the failure, since dynamic client registration is then never attempted.
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
Update: sharper reproduction, and the OAuth path was a red herring
I narrowed this down. Two findings.
1. The OAuth/DCR attempt happens even though
headersHelperis configuredThe transport only skips constructing an OAuth provider when the server config carries a static
Authorizationheader —headersHelperalone does not suppress it:Adding a dummy static header alongside the existing
headersHelper:(the helper's output overwrites the dummy on every connect) changes the error. Instead of:
the operator now gets:
So for a
headersHelperserver against an auth server with noregistration_endpoint, the misleading DCR error is what surfaces by default. That message points users at a server misconfiguration when the real condition is an expired credential — worth fixing independently of the rest.2. Even with the OAuth path suppressed, the documented helper re-run still does not happen
This is the actual bug, and it is now isolated from the OAuth machinery.
With the static header in place, an expired bearer produces a genuine 401, and the client recognises it — the internal log records:
But the helper is still never re-executed. I have the helper instrumented to append a timestamped line on every invocation; there is no entry at the time of failure. The operator still has to reconnect manually.
What makes this look like a bug rather than expected behaviour: the retry gate appears to be satisfied. In the tool-call error handler,
is true for this config (
type: "http"with aheadersHelper), andMis true as well — the "Tool call returned 401" log line is emitted from insideif(M). SoM && Rin the inner conditionshould hold, yet the branch does not run. Something after
M && Ris blocking it:$w(server disabled) andA1(server blocked) both look inapplicable to a working, enabled, approved server, so I could not pin down which guard fires.Environment
type: "http",headersHelper+ the static dummyAuthorizationheader described aboveregistration_endpointNet effect
The documented recovery ("re-runs the helper, reconnects with the fresh headers, and retries the call once") never fires for this configuration, so every credential expiry costs a manual reconnect.
Confirmed on 2.1.220 / macOS, with a deterministic repro that does not require waiting for expiry
Independent confirmation from a separate estate (Entra-protected MCP gateway,
headersHelperminting a delegated user token viaaz account get-access-token), on darwin rather than Windows, so this is not OS-specific.Two contributions: a way to reproduce the 401 leg in about 30 seconds instead of waiting an hour, and evidence that narrows where the defect is not.
Deterministic repro without waiting for token expiry
Waiting ~60 min for a real bearer to expire makes this painful to bisect. Putting a pass-through proxy in front of the resource server, which injects exactly one 401 on the first
tools/calland otherwise forwards verbatim, reproduces it on demand. The proxy also serves spec-correct RFC 9728 PRM for its own origin, so discovery is clean and the only variable is the client's 401 handling.Setup:
type: httpserver pointed at the proxy,headersHelperwrapping the real token minter and appending a timestamped line on every invocation (no token values logged).Proxy request log for one run,
claude2.1.220, darwin, node v26.3.0:Helper invocation log for the same run, in full:
One invocation, at connect. The
tools/call401 produced a PRM fetch and then:The helper was never re-executed. This matches your instrumentation exactly, on a different OS and a different gateway, at 2.1.220.
The empty-body parse crash is the same defect wearing a different mask
An earlier run of the same repro, where the discovery sub-path answered 404 instead of 200, failed differently:
Same trigger, same single helper invocation, different surface text. So the error message tracks whatever the discovery probe returns, which is further evidence that the fault is in taking the OAuth branch at all rather than in anything the resource server says. That variant looks like the same root cause as #75976.
What is already fixed in 2.1.220, and what is not
Worth separating, because it narrows the defect usefully:
type: http+headersHelperconnects, lists tools, and a reallist_docscall returns 145 documents. On 2.1.215 the same configuration aborted at the DCR step during connect, before ever sending the header the helper had just returned. That connect-time failure is gone.Server-side confounds are eliminated
The gateway is spec-correct, verified this run:
resource_metadatapresent in the 401 challenge, RFC 9728 PRM served at both root and sub-path withresourcematching the request origin, and a well formed JSON 404 on/.well-known/oauth-authorization-serverexplicitly saying it is not an authorization server. The behaviour above is with all of that in place.Note the bind: the only server-side mitigation that makes Claude Code work is stripping the discovery surface, which breaks VS Code and every other spec-compliant client. We ended up abandoning
type: httpentirely and shipping a stdio bridge that owns the token refresh itself, purely to work around this.Impact beyond the manual reconnect
/mcpreconnect is only a workaround where there is a human present. Restarting the backend behind the gateway severs sessions mid-flight, all servers sharing the origin latch simultaneously, and scheduled orclaude -psessions cannot re-auth interactively, so they just die. That is what makes this a blocker for unattended agent operation rather than an irritation.Suggested acceptance criteria
headersHelpershould not construct an OAuth provider at all, on connect or on retry. Treat the helper the way a staticAuthorizationheader is already treated inWde.registration_endpointin AS metadata) should revert to the helper rather than latch for the session.Happy to share the proxy script, the helper wrapper, and full debug logs if useful.
Confirmed on a third auth provider, Amazon Cognito, in a non-interactive automation context.
We run a remote MCP server behind API Gateway with a Cognito JWT authorizer. Auth goes through headersHelper (a script that mints a fresh Cognito bearer token per call), not the OAuth authorization_code flow, because our Cognito app client is USER_PASSWORD_AUTH only and was never built to support an interactive grant or DCR.
The failure matches this issue exactly. Cognito returns a 401 mid-session, which is normal resource-server behavior. Instead of re-running headersHelper for a fresh token and retrying, the client constructs an OAuth provider and attempts dynamic client registration against Cognito's discovered authorization-server metadata. Cognito has no registration_endpoint, so the attempt fails immediately with "Incompatible auth server, does not support dynamic client registration," and the connection latches broken for the rest of that session. No further tool call on that connector self-heals without an interactive /mcp reconnect.
That last part matters for us specifically. Our automation runs non-interactive Claude Code sessions (claude -p, scripted session-wrap flows) that write to this server as part of normal operation. Those sessions have no interactive /mcp to fall back on, so once the latch hits, writes to that server are dead for the rest of the run. We traced this across 24 local session transcripts (grep -rl "Incompatible auth server" ~/.claude/projects//.jsonl), hit five different tools on the same correctly-configured connector, and confirmed zero self-heals across every occurrence.
Reproduced on Claude Code 2.1.226, macOS. Happy to share sanitized log excerpts if useful.