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.
The gate
Section titled “The gate”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.
The authorization flow
Section titled “The authorization flow”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.
Public clients (no client_secret)
Section titled “Public clients (no client_secret)”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.
Manual (paste-back) completion
Section titled “Manual (paste-back) completion”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:
redirect_uricomes from the statically-configured redirect — the catalogoauth.redirectUrior amcp_oauth_clientsentry, whichever configures the server — rather than the hosted callback. Because Zimmer cannot receive that redirect, the flow is marked manual.initiaterenders a paste-back page instead of redirecting: it shows the authorize link and an input for the redirect URL.- 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=…). - You paste that full URL (or the bare
code) back.completeextracts thecode, validatesstateagainst the persisted flow (the same CSRF check the hosted callback does), and finishes the exchange using the persisted PKCEcode_verifierandredirect_uri.
Configuring the official Slack MCP
Section titled “Configuring the official Slack MCP”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 indicatorThe 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 fromcodex mcp logindon’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.
Refresh
Section titled “Refresh”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.
Seeing where every connector stands
Section titled “Seeing where every connector stands”/connectors — Connectors 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:
| State | Meaning |
|---|---|
| Ready | OAuth is complete and the credential saved, or every required ${VAR} resolves |
| Needs authorization | An OAuth-capable server with no stored credential. The row carries an Authorize button |
| Token expired | Expired, but has a refresh token — RefreshMcpOauthTokensJob will renew it |
| Needs re-auth | Expired with no refresh token; the row carries a Re-authorize button that replaces the credential in place |
| Missing configuration | A required ${VAR} has no value. The row says where it goes — see The Secrets Console |
| Unavailable | The 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 unreachable | The store did not answer. Deliberately not “missing” — see Secrets in the Parameter Store |
| No credential required | The catalog entry configures no credential at all |
| Probe failed | Anything 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
lazydefers 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.
Authorizing from the Connectors page
Section titled “Authorizing from the Connectors page”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
/connectorsrather than to a session — through the callback, through paste-back, and through everyinitiateerror 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.
McpOauthResumeServiceis 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.