Skip to content

MCP server OAuth

When an MCP server needs OAuth, Zimmer runs the whole flow itself (discovery, registration, PKCE, token exchange, refresh) and then writes the tokens into the agent CLI’s own credential file so the agent’s MCP client finds them.

That last step is why this is harder than it sounds: Zimmer has to produce a file in a format that another vendor’s private code will read.

Before spawning, McpOauthCredentialInjector#check_credentials_status looks at every remote MCP server on the session (http / streamable-http / sse):

A session that needs OAuth fails fast: it goes to failed with failure_reason: oauth_required instead of hanging or prompting, and the UI turns that into Authorize buttons. Completing the flow resumes it.

The post-spawn MCP-failure classifier (AgentSessionJob#check_and_handle_mcp_failure) applies the same rule. An auth-shaped error (401, Unauthorized, Supported scopes, invalid_token, …) only becomes oauth_required when the server is actually OAuth-capable — McpOauthCredentialInjector.oauth_capable_server?: in the catalog, remote transport, and no static credential header.

A “static credential header” is decided by the header’s name, matched against a word list (CREDENTIAL_HEADER_PATTERN): authorization, auth, api-key/apikey, token, secret, password, credential(s), as whole -/_-delimited parts. Vendors spell that header however they like — X-API-Key, X-Goog-Api-Key, X-Figma-Token, PRIVATE-TOKEN — and the spelling says nothing about whether an OAuth flow exists, so matching words beats matching names. The list stays narrow on purpose: key counts only within api-key, so Idempotency-Key is not a credential, and auth must be a whole part, so X-Author is not one either. Reading a routine header as a credential would hide the Authorize button on a server that genuinely needs one, which is the worse failure of the two.

A static-header server (e.g. Zimmer’s own zimmer* entries, which send X-API-Key: ${ZIMMER_PROD_API_KEY}) returns the same 401 when its token is invalid or under-scoped, but no OAuth flow can mint a valid API token, so it is never routed to a dead-end Authorize button. It is left out and the session keeps running, with the raw error and the credential to check written into the session log — see When a server cannot connect. This is the single predicate shared with the pre-spawn gate above.

That split is the whole fatality policy: oauth_required is the one failure class that still stops a session, because it is the one a human can resolve by clicking Authorize. Every other class is definitive — no amount of waiting or authorizing changes it — so stopping buys nothing and costs the transcript.

There is a second dead-end the classifier avoids: a server Zimmer already holds a valid credential for that still returns 401. That is not a missing authorization — it is the runtime failing to honor the token Zimmer injected, most often because Claude Code’s host-global negative-auth cache (~/.claude/mcp-needs-auth-cache.json) short-circuited the connection (Skipping connection (cached needs-auth)) before it ever reached the network. Routing it to oauth_required is pointless: McpOauthController#initiate short-circuits on the existing credential, so the Authorize button can only redirect straight back — which reads to the user as “the button does nothing”. So the classifier (and the OAuth banner, and the initiate controller) all consult McpOauthServerAuthorization.authorized?, and a failure for an already-authorized server instead clears the runtime needs-auth cache and retries, so the next spawn reconnects with the token already on hand. Injecting a credential (McpOauthCredentialInjector#inject_credentials!) always clears that cache entry for the same reason, and the OAuth banner filters oauth_required_servers through the same predicate so a stale entry (e.g. a recovery job cleared failure_reason but left the list behind) never renders an Authorize button that cannot resolve.

That “we already hold a credential” check asks whether a row exists and is unexpired — which is not the same question as “the provider still honors it”. So one class of failure is carved out ahead of it: when the error says the provider rejected Zimmer’s refresh grant — Claude Code reports Token refresh failed with invalid_grant: Invalid refresh token — the stored credential is permanently dead no matter how unexpired the row looks. Retrying can never revive it; without the carve-out the server was filed as “already authorized” and rode the retry ladder into a terminal mcp_connection_failed, orphaning the session with no Authorize button (#222). Instead the credential is retired and the server is routed to oauth_required — which now resolves, because the short-circuit in initiate no longer sees an active credential. Only cache- and transport-shaped auth failures keep the clear-cache-and-retry path.

Retiring takes two stores, not one. McpOauthServerAuthorization.invalidate! drops the revoked refresh token and force-expires the DB row — force-expiring the access token too, deliberately: a runtime refreshes ahead of expiry, so the paired access token may have minutes of TTL left, and those minutes buy nothing once the credential is terminal while leaving the row active is exactly what re-shadows the Authorize button. But the runtime’s copy still carries its original future expiry, so McpOauthRuntimeReconciler would read it as a strictly newer pair and adopt the dead tokens back into the DB on the next spawn. So the classifier also calls delete_credentials on the runtime credential writer, leaving nothing to adopt.

REFRESH_TOKEN_REJECTED_PATTERN keys on the refresh-failure phrasing (Token refresh failed with <grant error>, or Invalid refresh token) rather than on a bare invalid_grant anywhere in the text. A server that brokers a downstream OAuth of its own can report its provider’s invalid_grant while Zimmer’s credential for that server is healthy, and retiring it there would force a re-auth that cannot fix anything. An unrecognized phrasing costs nothing — it falls through to the retry path.

Public clients and manual (paste-back) completion

Section titled “Public clients and manual (paste-back) completion”

Two capabilities let Zimmer authorize against servers that expose a public OAuth client (no client secret) and only permit a localhost / out-of-band redirect — the motivating case being the official hosted Slack MCP server (https://mcp.slack.com/mcp), which is designed to be used with Slack’s own app client_id + a localhost redirect + PKCE, and which deliberately does not support DCR.

A mcp_oauth_clients entry — or a catalog oauth block naming only a clientId — may omit the client secret entirely. Such a client is a public client (RFC 6749 §2.1) that proves possession with PKCE alone (RFC 7636). The token exchange omits the client_secret parameter when the flow has no secret and relies on the persisted code_verifier; when a secret is configured, the previous client_secret_post behavior is preserved.

Every outbound call McpOauthService makes — probe, discovery, DCR, and the token exchange itself — sets open_timeout and read_timeout to REQUEST_TIMEOUT (30 seconds). McpOauthCredential#refresh! posts its refresh grant through the same helper, so the unattended path carries the same bound as the interactive one. An auth server that accepts the connection and then never answers fails the exchange rather than holding a request thread — or, on the cron path, a GoodJob thread.

Some public clients only permit a redirect URI they already whitelisted — for the official Slack client that is http://localhost:3118/callback, the loopback redirect the Claude Code Slack plugin uses. Zimmer’s hosted callback (https://<host>/mcp_oauth/callback) cannot be added to someone else’s app, so those flows complete out-of-band:

  1. redirect_uri comes from the statically-configured redirect — the catalog oauth.redirectUri or a mcp_oauth_clients entry, whichever configures the server — rather than the hosted callback. Because Zimmer cannot receive that redirect, the flow is marked manual.
  2. initiate renders a paste-back page instead of redirecting: it shows the authorize link and an input for the redirect URL.
  3. You open the authorize link, consent in your own browser, and land on the localhost redirect with nothing listening — that failed page load is expected; the value you need is in the address bar (?code=…&state=…).
  4. You paste that full URL (or the bare code) back. complete extracts the code, validates state against the persisted flow (the same CSRF check the hosted callback does), and finishes the exchange using the persisted PKCE code_verifier and redirect_uri.

The official Slack app is a public client. Wire it up entirely through credentials — no code change per server:

mcp_oauth_clients:
slack:
client_id: "1601185624273.8899143856786" # official Slack app (public; not a secret)
authorization_endpoint: "https://slack.com/oauth/v2_user/authorize"
token_endpoint: "https://slack.com/api/oauth.v2.user.access"
scopes: "channels:history,groups:history,search:read.public,users:read"
redirect_uri: "http://localhost:3118/callback" # the loopback redirect the Slack app permits
manual: true
resource: "" # Slack OAuth is not RFC 8707 — suppress the indicator

The key (slack) must match the MCP server name in the catalog, whose URL is https://mcp.slack.com/mcp. Slack returns the user token nested under authed_user.access_token (a top-level access_token, when present, is the bot token); Zimmer unwraps it on both the initial exchange and the cron token refresh, so a rotation-enabled credential survives past its first expiry. resource: "" is set because Slack’s OAuth endpoints are not the RFC 8707 audience-binding kind — for a genuine MCP auth server, omit the key instead and the pre-registered path derives the resource indicator from the server URL automatically.

The credential key is a copy of Claude Code’s private algorithm

Section titled “The credential key is a copy of Claude Code’s private algorithm”

To make the agent’s MCP client find the token, Zimmer must key it exactly the way Claude Code keys it. McpOauthCredential.compute_credential_key:

"#{server_name}|#{SHA256(compact_json({type, url, headers}))[0,16]}"

…where “compact JSON” is faked by string-munging ": "":" and ", "",", and streamable-http is normalized to http. A canary test pins the literal key for two fixed configs — notion|3fad03f7abd02b9c for {"type":"http","url":"https://mcp.notion.com/v1/mcp","headers":{}} — so that a change on Zimmer’s side of the algorithm fails a test instead of silently missing every credential lookup.

And it only exists because of two open Codex bugs

Section titled “And it only exists because of two open Codex bugs”

CodexMcpCredentialWriter’s header explains why Zimmer rewrites Codex’s entire MCP credential store on every session spawn:

  • openai/codex#15122 — credentials from codex mcp login don’t persist across restarts.
  • openai/codex#17265 — Codex won’t use the stored refresh token, so MCP calls fail with “Authorization required.”

So Zimmer refreshes the tokens itself every 30 minutes and re-writes them at spawn, so Codex never has to. It’s a workaround for someone else’s bugs, and it will need to be removed when they’re fixed.

RefreshMcpOauthTokensJob, every 30 minutes. It refreshes credentials expiring within an hour — but throttled by PROACTIVE_REFRESH_MIN_INTERVAL (won’t touch anything updated in the last 4 hours), deliberately, to reduce exposure to rotating-refresh-token reuse detection.

It splits network errors carefully:

  • Retryable — the connection was never established, so the server never saw the request. Safe to retry in-band.
  • Ambiguous — the request went out and the response was lost. Never retried in-band; deferred to the next cron run. Retrying could burn a single-use refresh token.

That distinction is the kind of care that’s easy to skip and expensive to skip.

A refresh is treated as permanent when the token endpoint rejects the refresh_token grant with any 4xx — the refresh token is dead and re-auth is required. Most servers signal this with a spec-compliant JSON body ({"error": "invalid_grant" | "invalid_client" | "unauthorized_client"}), but some return a bare HTML 400 Bad Request, so the 4xx status code — not the body — is what classifies it. On a permanent failure it nulls the refresh token but keeps a still-valid access token instead of force-expiring it. Transient failures — 429 rate-limits and 5xx outages — are excluded first: the refresh token itself is not implicated, so it is left intact and the failure stays on the loud ERROR log path (which pages #alerts) to retry on the next cron run. This transient / permanent split matches XOauthCredential.

Capturing the token the runtime rotates (write-back)

Section titled “Capturing the token the runtime rotates (write-back)”

Zimmer is not the only party that refreshes these tokens. Claude Code has its own MCP OAuth client: when an access token lapses mid-session it refreshes it and writes the new pair back to ~/.claude/.credentials.json. Notion (and other OAuth 2.1 servers) rotate refresh tokens — every refresh mints a new refresh token and revokes the prior one — so once Claude Code refreshes, the refresh token in Zimmer’s DB is already dead.

ClaudeMcpCredentialWriter#merge_preserving_fresher! protects that fresher on-disk entry only while its paired access token is still valid. Across an idle gap longer than the access token’s TTL (~1h for Notion) the on-disk access token lapses, so on the next spawn Zimmer’s stale DB entry wins and clobbers the good on-disk refresh token. The next refresh — Claude Code’s at connect time, or RefreshMcpOauthTokensJob’s from cron — then presents the revoked token and gets invalid_grant: Invalid refresh token, and the server drops offline until a human re-authorizes.

McpOauthRuntimeReconciler closes that loop. Before Zimmer refreshes or injects a credential it reads the runtime’s on-disk store (RuntimeMcpCredentialWriter#read_runtime_credentials) and, if the runtime holds a strictly newer token pair — a later access-token expiry means the runtime refreshed after Zimmer last wrote the row — adopts that pair into the DB. Crucially it adopts even when the on-disk access token has already expired: a rotated refresh token is the live head of the chain regardless of its paired access token’s TTL, which is the exact case merge_preserving_fresher! drops. ClaudeAccount#sync_tokens_from_filesystem! does the same thing for the runtime’s own account tokens; MCP OAuth credentials had no equivalent, which is why they went stale.

The reconciler runs in two places:

  • McpOauthCredentialInjector, on every spawn, before it decides whether to refresh or gate the session — so a session never injects (or re-auth-prompts against) a rotated-away token.
  • RefreshMcpOauthTokensJob, before the cron refreshes each credential — so the cron adopts a session’s rotation instead of burning the stale DB token against the provider’s reuse detection.

Which store it reads depends on the session-scoped credentials setting. With it off, one host-global ~/.claude/.credentials.json that every session on the worker read-modify-writes under a flock. With it on, ClaudeMcpCredentialWriter.for_session points the writer at that session’s own CLAUDE_CONFIG_DIR — same keys, same adoption rule, one writer per file, so the read-modify-write stops racing. The cron and the revocation path still target the host-global file: they have no session to scope to, so a revoked credential is not removed from a session that is already running (it gets a fresh directory next time).

Only Claude Code refreshes MCP tokens mid-session; Codex is written-not-trusted (Zimmer rewrites its store every spawn), so reconciling against Codex is a harmless no-op.

This is also what makes an OAuth MCP connection survive a worker/clone recreation. When a session is recovered after a deploy or restart, the relaunch goes through the follow-up spawn path, which re-injects credentials and re-writes .mcp.json before spawning claude --resume. Before the write-back existed, that relaunch re-injected the stale DB refresh token, so Claude Code’s reconnect refreshed against a rotated-away token, got invalid_grant, and the server came back with all its tools reporting No such tool available. The restart didn’t break the token — it forced the reconnect that exposed an already-stale one. With the reconciler, the relaunch injects the token the previous run rotated to, and the server reconnects.

/connectorsConnectors in the left-hand nav — lists every MCP server in the catalog with its current auth status, one lazily-loaded Turbo Frame per server so the list renders before any status resolves. It replaced the older “OAuth Status” page, which could only show servers that already had a credential row and so was silent about precisely the servers that needed attention.

ConnectorStatusProbe reads the same three inputs a spawn reads, in the same order, so a connector reported Ready is one that will actually connect:

StateMeaning
ReadyOAuth is complete and the credential saved, or every required ${VAR} resolves
Needs authorizationAn OAuth-capable server with no stored credential. The row carries an Authorize button
Token expiredExpired, but has a refresh token — RefreshMcpOauthTokensJob will renew it
Needs re-authExpired with no refresh token; the row carries a Re-authorize button that replaces the credential in place
Missing configurationA required ${VAR} has no value. The row says where it goes — see The Secrets Console
UnavailableThe catalog entry carries an unavailable declaration — breakage no local check can infer. Nothing on the page fixes it; the entry has to change
Secret store unreachableThe store did not answer. Deliberately not “missing” — see Secrets in the Parameter Store
No credential requiredThe catalog entry configures no credential at all
Probe failedAnything unexpected, isolated to that one row

One line cuts across the states rather than being one of them: a credential whose server issued no refresh token carries an amber note on its row saying so, in every state including Ready. Nothing can renew that credential, so the re-authorization is permanent and periodic — see Servers without offline_access below.

A credential filed under a different credential key than the catalog currently computes is deliberately not matched — the injector would not find it either, so counting it would report Ready for a server that cannot connect. Those show up instead under “Unclaimed credentials” at the bottom of the page, where they can be deleted.

The page never contacts the MCP server itself and never displays a secret value; it reports presence and where to set what is absent.

The same probe decides what an agent is offered. Missing configuration, Needs authorization, Needs re-auth and Unavailable block a spawn, so get_configs leaves those servers out of its MCP-server list and names them in a trailing Unavailable roster instead — one line and a compact reason each. Token expired does not block, because the refresh job resolves it. → Availability, and what an agent is offered

How the list fills in, and why it re-orders itself

Section titled “How the list fills in, and why it re-orders itself”

The rows ship as loading="lazy" frames and connector_list_controller then promotes them to eager — six at a time, releasing a slot as each frame loads (turbo:frame-load), comes back without a matching frame (turbo:frame-missing, which is what a 404 or an error page produces), fails at the network level, or hits a 15-second watchdog. All four matter: a row whose probe 404s never fires turbo:frame-load, so without the second listener it would hold its slot for the full watchdog and the sort would wait on it.

Each half of that is load-bearing:

  • Lazy in the markup is the floor. Before the controller connects — and if it throws, or its bundle fails to load — the frames still resolve on appearance, which is what they did before. It is a floor, not a no-JavaScript fallback: Turbo’s lazy loading is itself JavaScript, so with none the rows never resolve either way.
  • Promoting them is the fix. Turbo’s lazy defers a frame until it scrolls into view, so on a ~100-server catalog every badge below the fold stayed blank until you went looking for it.
  • Six at a time is what keeps the fix from being a regression. Un-gating all ~100 frames at once fires ~100 requests at a Puma pool of a handful of threads, and the queueing makes the first badge slower than it was before.

Ordering follows from the same design. A server’s state is computed inside its own frame, so ConnectorsController does not know at index-render time which servers have problems; sorting server-side would mean probing all ~100 up front and holding the whole page on the slowest one — exactly what the frames exist to avoid. So the sort happens in the browser, once, after the frames settle: rows are alphabetical while they load, then re-order by severity with the problems first and a N need attention, listed first count in the header. Sorting during the load was rejected deliberately — it moves content under the reader for the whole load.

Severity itself is not decided in JavaScript. ConnectorsHelper::SEVERITY_RANKS maps each probe state to a rank, the resolved row carries it as data-connector-rank, the attention threshold is passed in as a value, and the controller only compares numbers. A test asserts the rank table covers ConnectorStatusProbe::STATES exactly, so a new state cannot quietly default into the healthy group.

Two edges are handled where they would otherwise be invisible. Turbo Drive’s page cache restores this list as the controller left it — resolved bodies, and the eager the controller itself wrote — so on a back-navigation onto a half-loaded page the controller pre-settles anything already carrying content and counts only frames it started; otherwise the in-flight count goes negative, the window blows open, and the list never sorts. And a reorder moves DOM nodes, which drops keyboard focus, so the sort stands down while focus is inside the list and retries once it leaves.

A row that needs a consent screen runs one: Authorize (or Re-authorize on a needs_reauth row) POSTs to the same /mcp_oauth/initiate the session banner uses, just without a session_id. Authorizing a connector is something you do to Zimmer, not to one session, and it used to cost you a throwaway session to do it.

A session-less flow differs from an in-session one in exactly two places:

  • It returns to /connectors rather than to a session — through the callback, through paste-back, and through every initiate error exit. (A callback that fails outright renders the shared OAuth error page, as an in-session one does.)
  • It resumes nothing, because nothing is parked on it. McpOauthResumeService is skipped rather than called with no session.

Everything in between — discovery, DCR, PKCE, the hosted callback, the paste-back page, the stored McpOauthCredential — is the same code on the same path. The credential is keyed on the server config, not on who started the flow, so a connector authorized here is a connector every future session inherits.

Only rows where a consent screen is actually the fix get the button: needs_authorization and needs_reauth. A token_expired row does not — the refresh job resolves it without you. A missing_configuration row does not either: its credential is a ${VAR} secret and no OAuth provider will ever set it. Nor does a server authenticated by a static header, whatever the vendor named it: with its ${VAR} set it is ready, and with it unset it is missing_configuration — never needs_authorization, because there is no OAuth flow behind that button to run.

The button offers only catalog servers, and the session-less initiate enforces that server-side: the server must be in the catalog and pass McpOauthCredentialInjector.oauth_capable_server?. Both paths now read the server URL from the catalog whenever the catalog has the server, so a server_url submitted alongside a session-less initiate is ignored outright.

A session-less flow has no session to be reaped with (Session has_many :mcp_oauth_pending_flows, dependent: :destroy is what collects the in-session ones), so initiate calls McpOauthPendingFlow.sweep_expired_session_less! each time it starts a flow. An abandoned Connectors-page flow would otherwise sit indefinitely holding a PKCE code_verifier and a client secret.