[BUG] Cowork: custom MCP connector reports "needs authorization / no tools" on every session spawn, despite valid working auth — server-side logs included

Status Closed — not planned
Maintainer reply None cached
Activity 4 comments · opened Jul 10, 2026 · closed Sep 27, 2026

Environment

  • Surface: Cowork (Claude desktop app), macOS 14 (Darwin 23.6.0)
  • Connector: custom remote MCP (Streamable HTTP), published to the team via a

plugin (.mcp.json → "add to team" → authorised in connector settings)

  • Server: OAuth 2.1 AS+RS per the MCP auth spec — RFC 9728 Protected Resource

Metadata, RFC 8414 AS metadata, RFC 7591 DCR, RFC 8707 resource indicators,
RS256 JWTs, offline_access advertised, refresh-token rotation. 401s carry
WWW-Authenticate with the resource_metadata pointer.

Symptom

On every Cowork session spawn (app launch or new conversation), the first use
of the connector reports *"The <connector> needs authorization first — it isn't
connected" / "no tools are exposed yet"*, even though the connector is
authorised and demonstrably working. Asking again in the same session typically
succeeds. Re-authenticating does not stop the next session from repeating it.

Server-side evidence (timestamps UTC, 2026-07-10)

We operate the MCP server and correlated its logs with the client behaviour.

Clean-slate reproduction — connector fully removed, plugin re-added, one
fresh authorisation:

15:28:46  GET  /.well-known/* (discovery)            200
15:28:47  POST /register                              201   ← single fresh DCR client
15:28:47  GET  /authorize → Google SSO → callback     302
15:28:51  POST /token (authorization_code)            200   ← auth complete
15:28:52–15:29:08  11 × POST /mcp                     200   ← tools listing + calls all work
15:29:44  POST /mcp                                   401   ← 36s later: request with NO valid credentials
15:29:44  GET  /.well-known/* (discovery)             200
15:29:44  POST /register                              201   ← client re-registers via DCR… and stops

The 15:29:44 sequence is the next session's first probe. It arrives without
the credentials issued 53 seconds earlier
, receives a spec-correct 401 +
WWW-Authenticate, performs a fresh DCR registration (abandoning the live
token set), and the session then tells the user the connector needs
authorising. The same pattern repeats on every session spawn — we observe one
401 → discovery → /register → stop cycle per launch, indefinitely.

Meanwhile the previous credential set remains fully valid server-side: silent
refresh works (POST /token refresh grant → 200 in ~0.28s), access tokens
verify, tool calls succeed.

What we ruled out (server side)

We spent the day eliminating our own failure modes first, with log evidence for
each:

  • Cold starts: the AS endpoints are on min-instances=1; refresh /token

calls complete warm in ~0.25–0.3s with 200.

  • Lost DCR registrations: client registrations are persisted in a database

and survive instance replacement (previously they were in-memory; fixed and
verified — the 401s above are NOT invalid_client).

  • Token validity: the 401'd probes carry no valid token (the token issued at

15:28:51 was 36 seconds old and worked concurrently for the other path).

  • Spec compliance: 401 semantics, PRM, AS metadata, PKCE, resource

indicators all verified against the spec during the investigation.

Impact

  • Users are trained to "ask twice" for every connector, every session — or they

conclude the integration is broken.

  • Every session spawn creates an abandoned DCR client registration

server-side (one per launch, per user, per connector). Registrations
accumulate without bound; servers that persist registrations (as the SDK's
OAuthRegisteredClientsStore contract encourages) accrue orphan records
indefinitely.

Control experiment: Claude Code CLI works correctly against the same server

Same machine, same user, same server, same day (16:02–16:06 UTC):

  • claude mcp add --transport http … + probe → 401 → **correctly classified

! Needs authentication**, needs-auth cache entry written.

  • Interactive /mcp auth → success → needs-auth cache entry cleared, tools

work in-session.

  • Fresh process (claude mcp list in a new invocation) → ✔ Connected —

the stored token is presented immediately: server logs show only POST /mcp
2xx. No 401, no discovery, no re-registration.

So credential persistence and reuse across process spawns works on the CLI
surface. Claude Chat (claude.ai) also works with the same connector — same
account, same server: three of Anthropic's surfaces exercised, and only
Cowork's session bootstrap fails. The per-spawn credential-less probe + DCR re-registration is specific
to Cowork's connector path (backend-managed credentials / claudeai-proxy),
which matches #52565's "divergent code paths for custom vs managed connectors".

Likely related (all closed stale/not-planned — this report adds the missing server-side evidence)

  • #52565 — custom connector OAuth tokens fail to persist across restart; 401

misclassified as generic connect failure; Cowork listed as affected; stale
DCR entries accumulate. Our observations match all three mechanisms.

  • #48670 — machine-wide 15-minute "needs-auth cache" skips the connection

attempt even after successful re-auth. Ruled out for Cowork by test: after
45 minutes of zero Cowork activity (any cache long expired) and with a
still-valid unexpired access token held by the backend, a fresh session at
16:14:15 UTC still probed credential-less (401 → discovery → /register →
stop). The bootstrap never attaches credentials at all; it is not a stale
cache verdict.

  • #42922 — eager per-session connect + per-session credential isolation;

matches the one-probe-per-spawn pattern in our logs.

Suggested fixes

  1. Attach the connector's stored credentials before (or with) the session's

first probe — or connect lazily on first tool use — instead of probing
unauthenticated and caching the failure.

  1. Classify a custom-connector 401 as **needs-auth → try stored refresh

token / backend credentials** before surfacing an auth prompt, matching
managed-connector behaviour.

  1. Invalidate any needs-auth cache when an authorisation completes.
  2. On 401, reuse the existing DCR registration rather than re-registering — the

current behaviour orphans the registration and its refresh-token chain.

We're happy to provide correlated server logs for any reproduction window,
or to test candidate builds against our server.

Related reports (same defect family — Cowork/desktop session ↔ connector credential wiring)

The symptoms in this report are not specific to our server or to custom
connectors; the same family is reported across managed connectors and both
desktop platforms:

  • #61682 — GitHub (managed) connector shows "Connected" but exposes no tools in Cowork
  • #18194 — managed connectors (Asana, Google Calendar) 403 in Cowork despite working in Claude Chat
  • #60222 — reconnect restores the tools list but tools/call silently fails (split state)
  • #34832 — Cowork connectors lose auth after context compaction (ephemeral session auth state)
  • #52565 — custom connector tokens fail to persist across restart; 401 misclassified (Cowork listed)
  • #59854 — Cowork GitHub connector: misleading connection state, dead Disconnect

This issue adds what those lack: server-side measurements (credential-attach
latency table, no-token-exchange attach, split credential state within one
session) — see the two evidence comments below.

View original on GitHub ↗

3 Comments

popmatik · 3 months ago

Update: new evidence narrows the mechanism to a race between session tool-discovery and an async credential-attach/refresh — and escalates severity for scheduled tasks.

The failure is upstream of tool calls: the tools never enter the session

In failing sessions, the connector's tools are entirely absent from the session's tool registry. The session's tool lookup reports No matching deferred tools found, and the model correctly concludes it can do nothing — there is no tool to call, so no model-level retry/mitigation is possible (we tried a skill-level "retry before surfacing auth" instruction; it can never execute because there is nothing to invoke).

The recovery path exists — it just loses the race most of the time

Three consecutive Cowork app launches, ~40s apart, same authorised connector (server-side timestamps, UTC 2026-07-13):

09:50:50  401 /mcp → discovery → /register 201 → nothing more     ← run 1: session reports "needs authorization"
09:51:22  401 /mcp → discovery → /register 201
09:51:35     └→ POST /token 200  (refresh_token grant — no /authorize, no IdP roundtrip)
09:51:36+       POST /mcp 200, 200 …                              ← run 2: tools present, query succeeds
09:52:08  401 /mcp → discovery → /register 201 → nothing more     ← run 3: "needs authorization" again

All three spawns begin identically: a credential-less probe, correct 401 + WWW-Authenticate, then a fresh (orphan) DCR registration. In the one successful run, a silent refresh-token exchange followed ~13 seconds after the probe, credentials attached, and the tools appeared. In the failing runs that follow-up never fired. So the client-side recovery mechanism exists and works — whether a session gets tools appears to be a race between async credential attachment and the session's tool-discovery step. This is also consistent with "ask again on a later turn usually works": by the next turn the refresh has completed.

Severity: scheduled tasks make this unattended

Cowork's scheduled tasks run locally (the app must be open), i.e. through this same session bootstrap. A scheduled run that loses the race has no second turn and no human to retry — in our 3-launch sample above, 2 of 3 spawns lost it. This effectively makes OAuth custom connectors unusable for scheduled/automated tasks until this is fixed.

Suggested-fix refinement

Given the above, the minimal fix looks precisely like the original report's #1/#2: block (or re-run) tool discovery on completion of credential attachment — the refresh that already happens at ~13s just needs to gate/refresh the session's tool registry instead of racing it. Fix #4 (reuse the existing DCR registration rather than re-registering per spawn) remains important: every launch currently orphans a registration server-side.

Happy to supply correlated server logs for any reproduction window.

popmatik · 3 months ago

Final characterisation: connector credential-attach is late (25–100s) in ~half of session spawns and absent entirely in the rest. Client-side mitigation is exhausted.

We instrumented both sides further: the server now serves the discovery handshake anonymously (so every session registers the tools and failures become observable tools/call 401s instead of an empty tool registry), and a skill-level retry ladder makes the model retry auth failures. Result across six instrumented Cowork session spawns (2026-07-13, UTC):

| Session spawn | Credentials attached |
|---|---|
| 11:05 | ~25s after spawn → recovered, tool calls succeeded |
| 11:06 | never |
| 11:07 | never |
| 11:17 | ~100s → recovered on a later user turn |
| 11:30 | ~31s — 4 seconds after the model exhausted its retries |
| 11:34 | never (observed ≥68s) |

Additional observations that should help localise the defect:

  • No token exchange is involved in a successful attach. The 11:30 recovery served tool calls with an access token minted 24 minutes earlier in a different session (no /authorize, no /token in the window) — the backend already holds valid credentials and simply connects them to the session late, or never.
  • Split credential state within one session: the SSE GET /mcp connected with a valid token while tools/call POSTs from the same session presented none/dead ones (11:17 session).
  • The client's error wording is unreliable: "token has expired — requires re-authorization" was reported for a session whose backend demonstrably held a still-valid token (it served the 200s minutes later, unrefreshed).

With tool registration guaranteed server-side and model-level retries in place, the remaining failure rate is entirely the never-attach population — unreachable by any server or prompt mitigation. The fix needs to be in the session bootstrap: attach stored connector credentials before (or gate tool availability on) the session's first tool use, and treat a 401 as "re-check backend credentials", not "prompt the user".

Happy to share correlated server logs for any window, or test candidate builds.

popmatik · 3 months ago

Update 3: header-presence instrumentation + a decisive experiment. The failing client never sends credentials at all; the defect localizes to plugin-declared MCP servers in Cowork's sandbox; connector-only setup eliminates the user-facing failure.

We added two things to the server since the last comment: every /mcp 401 now logs whether an Authorization header was present, and every failed token verification logs the failing claim. That closes the gap in our earlier comments, which could not distinguish "no token" from "dead token". Findings from today's instrumented sessions (2026-07-13, UTC):

1. Failing calls carry no Authorization header — ever.
Every failing tools/call in every session logged hasAuthHeader: false. Not one expired or invalid token was presented in any failure window (token_verification_failed never fired). The client-side error wording ("token has expired — requires re-authorization") describes a token that was never sent.

2. There are two distinct MCP clients per session, and only one of them ever authenticates.

  • claude-code/2.1.205 (local-agent, agent-sdk/0.3.205), connecting from the user's machine: sends initialize/tools/list/tools/call with no Authorization header, in every request of every session, including retries 45+ seconds in and after a mid-session reconnect handshake.
  • Claude-User (Anthropic infrastructure): presents a valid JWT from the stored grant and has never failed — including serving the same query seconds after the local-agent's 401s.

This reframes our earlier "credentials attach late (25–100s) or never" table: server-side, credentials never attach to the local-agent client at all. What we measured as "attach" is the call being served by the other client. It is a routing/architecture defect, not attach latency.

3. Decisive experiment: the local-agent client comes from the plugin's .mcp.json.
We removed the MCP server declaration from the plugin (keeping the same server authorized as a claude.ai custom connector) and re-ran the repro. Result: the first query of a fresh Cowork session succeeded first-time via Claude-User — zero tools/call 401s, no auth prompt. Sequence for the full session:

15:02:31  local-agent: initialize 200, tools/list 200 (served anonymously), GET /mcp 401,
          discovery fetches, POST /register 201        ← sandbox still probes, credential-less
15:03:10  Claude-User: tools/call 200 (valid JWT)       ← user's query, first attempt
15:03:31  Claude-User: tools/call 200

So: a plugin-declared remote MCP server with OAuth is structurally broken in Cowork — the sandbox client it spawns has no credential path, while the working backend client sits beside it. Connector-only configuration is a full workaround for the user-facing failure.

4. Still broken even in connector-only mode: the credential-less probe and the orphan DCR registrations.
The sandbox client still probes on every session spawn and performs a fresh DCR registration each time (observed: 6 orphan POST /register 201 in one afternoon, including two within a single session). Servers that persist registrations per the SDK contract accrue these without bound.

Refined suggested fixes:

  1. Route sandbox MCP traffic for OAuth connectors through the backend client that holds the grant (it already exists and works), or provision session-scoped credentials to the sandbox client — either resolves the plugin-declared case too.
  2. Don't DCR-register on every spawn; reuse the existing registration.
  3. Fix the error wording: a request that sent no token should not report "token has expired".

Happy to provide correlated logs for any window or test candidate builds.

Showing cached comments. Read the full discussion on GitHub ↗