headersHelper server falls into OAuth dynamic client registration on 401 instead of re-running the helper

Status Closed — duplicate
Reported on v2.1.220
Maintainer reply None cached
Activity 4 comments · opened Jul 28, 2026 · closed Aug 20, 2026

Environment

  • Claude Code 2.1.220, Windows 11
  • MCP server, user scope, type: http, configured with headersHelper and no oauth block
  • 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 returns 401 Unauthorized or 403 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

  1. Register an HTTP MCP server with a headersHelper that returns a bearer valid for ~60 minutes, against an OAuth authorization server whose metadata has no registration_endpoint.
  2. Use the server successfully.
  3. Wait for the bearer to expire.
  4. 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.

View original on GitHub ↗

3 Comments

spinzon-go · 1 month ago

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 headersHelper is configured

The transport only skips constructing an OAuth provider when the server config carries a static Authorization header — headersHelper alone does not suppress it:

function Wde(e){ return Object.keys(e.headers??{}).some(t => t.toLowerCase()==="authorization") }

Adding a dummy static header alongside the existing headersHelper:

"headers": { "Authorization": "Bearer PLACEHOLDER" }

(the helper's output overwrites the dummy on every connect) changes the error. Instead of:

Incompatible auth server: does not support dynamic client registration

the operator now gets:

MCP server "<name>" requires re-authorization (token expired)

So for a headersHelper server against an auth server with no registration_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:

Tool call returned 401 Unauthorized - token may have expired

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,

R = (r.type==="http"||r.type==="sse"||r.type==="ws") && !!r.headersHelper

is true for this config (type: "http" with a headersHelper), and M is true as well — the "Tool call returned 401" log line is emitted from inside if(M). So M && R in the inner condition

if ((F || M&&R || M&&k || N) && !$w(t) && !A1(t,r)) { /* re-run helper, reconnect, retry once */ }

should hold, yet the branch does not run. Something after M && R is blocking it: $w (server disabled) and A1 (server blocked) both look inapplicable to a working, enabled, approved server, so I could not pin down which guard fires.

Environment

  • Claude Code 2.1.220, Windows 11
  • MCP server: user scope, type: "http", headersHelper + the static dummy Authorization header described above
  • Bearer valid ~60-90 min, minted by a CLI helper; auth server has no registration_endpoint

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

stu-ball · 1 month ago

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, headersHelper minting a delegated user token via az 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/call and 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: http server pointed at the proxy, headersHelper wrapping the real token minter and appending a timestamped line on every invocation (no token values logged).

Proxy request log for one run, claude 2.1.220, darwin, node v26.3.0:

10:34:04  SERVE PRM /.well-known/oauth-protected-resource/adapters/<a>/mcp -> 200
10:34:11  POST /adapters/<a>/mcp (initialize)              -> 200
10:34:11  POST /adapters/<a>/mcp (notifications/initialized) -> 202
10:34:11  GET  /adapters/<a>/mcp                            -> 405
10:34:12  POST /adapters/<a>/mcp (resources/list)          -> 200
10:34:12  POST /adapters/<a>/mcp (prompts/list)             -> 200
10:34:12  POST /adapters/<a>/mcp (tools/list)               -> 200
10:34:15  INJECT 401 on tools/call
10:34:15  SERVE PRM /.well-known/oauth-protected-resource/adapters/<a>/mcp -> 200

Helper invocation log for the same run, in full:

18:34:10  helper invocation=1

One invocation, at connect. The tools/call 401 produced a PRM fetch and then:

Incompatible auth server: does not support dynamic client registration

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:

HTTP 404: Invalid OAuth error response: SyntaxError: JSON Parse error: Unexpected EOF. Raw body:

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:

  • Cold connect is fine. A fresh session against the real gateway with type: http + headersHelper connects, lists tools, and a real list_docs call 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.
  • The 401 leg is not fixed. Post-connect, the behaviour is exactly as you describe.
  • A 401 during connect fails silently. If the helper's token is already invalid at connect time, the helper is likewise not re-run, and the server simply never appears. The model in the session reports no such tool exists, with no auth error surfaced anywhere the user can see it. Different symptom, same missing re-run.

Server-side confounds are eliminated

The gateway is spec-correct, verified this run: resource_metadata present in the 401 challenge, RFC 9728 PRM served at both root and sub-path with resource matching the request origin, and a well formed JSON 404 on /.well-known/oauth-authorization-server explicitly 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: http entirely and shipping a stdio bridge that owns the token refresh itself, purely to work around this.

Impact beyond the manual reconnect

/mcp reconnect 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 or claude -p sessions 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

  1. A server configured with headersHelper should not construct an OAuth provider at all, on connect or on retry. Treat the helper the way a static Authorization header is already treated in Wde.
  2. An auth failure on any leg (connect, tool call, SSE re-establish) should re-run the helper and retry once, matching the documented contract.
  3. A fallback that cannot possibly complete (no registration_endpoint in AS metadata) should revert to the helper rather than latch for the session.
  4. A discovery 404 with any body, including an empty one, should conclude the probe rather than surface a JSON parse error.
  5. Failing all of the above, the surfaced message should name the real condition (expired credential) instead of pointing the operator at a non-existent server misconfiguration.

Happy to share the proxy script, the helper wrapper, and full debug logs if useful.

AllisonGaddy · 20 days ago

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.

Showing cached comments. Read the full discussion on GitHub ↗