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 — with the turn it was blocked on, which is not always its first (which prompt the resume delivers).

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. That retry survives a different server in the same handshake failing definitively: the classifier degrades and retries per server, so a co-failing static-credential rejection no longer writes off the server whose cache was just cleared before the retry the clear exists to enable ever happens (#689). 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.

oauth_required is not exclusively a first-turn failure, so a resume is not exclusively “replay the stored prompt”. Three doors reach failed + oauth_required, and two of them can do it to a session that is already owed a message a human sent:

DoorWhat it failsWhat the session is owed
AgentSessionJob’s pre-spawn gate on a first turna session whose prompt was never deliverednothing — the stored prompt is the turn
AgentSessionJob’s pre-spawn gate on a follow-up (“Follow-up blocked: OAuth authorization required for MCP servers”)the turn carrying a human’s messagethat message
Edit MCP servers / Edit plugins (Sessions::UpdateCatalogSelection)an idle session, never a running onewhatever undelivered turn it was already holding

A follow-up exists only as its AgentSessionJob’s argument. The job returns when the gate blocks, nothing re-queues it, and the resume builds a job of its own — so before failing the session the gate hands the prompt back as pending_follow_up_prompt, the marker every recovery path already reads as “handed to a job, not yet delivered”, and says in the session’s timeline that the message has not been delivered yet. A nudge is the exception and is deliberately dropped: AutomatedPrompts.nudge? is the class of prompt that says nothing the session does not already know — the heartbeat beat, “you may have been interrupted, carry on” — and HeartbeatSweepJob refuses to stamp its own beat for the same reason, that a beat delivered for a moment which has already passed is worse than one not delivered. McpOauthResumeService#resume! then asks that marker first:

  • owed a turnSession#deliver_follow_up! — the session resumes to running and the job carries that prompt;
  • owed nothingAgentSessionJob.enqueue_new_session — the session goes back to waiting and the stored prompt is replayed, exactly as it always did.

Before #887 the resume always took the second branch, so a human who sent a follow-up, saw the Authorize banner, and authorized got their session back running its original prompt, with the message dropped and nothing in the log saying so — and on a session with a clone that meant re-running work it had already done.

The one case the resume cannot honour is a session with no runtime session_id: there is no conversation for a follow-up to continue, and AgentSessionJob would reclassify it as a fresh start and run the stored prompt anyway. It resumes the first turn and writes a warning into the session’s own timeline quoting the text so it can be re-sent. Saying so is the point — a silent substitution is the defect this closes. It also gives up custody of the marker in the same breath, moving the text to oauth_undelivered_follow_up_prompt, a key the service owns and nothing consumes: a standing pending_follow_up_prompt promises a delivery, and CleanupOrphanedSessionsJob reads that promise as a delivery in flight and stops reaping the session while it waits for one that is not coming.

Two places actually ask a remote server whether it requires OAuth: the spawn gate above, and McpOauthProbe, which re-checks a session’s servers whenever its MCP-server or plugin selection changes. Both run RFC 9728 / RFC 8414 discovery and an unauthenticated GET against the server URL. The Connectors page cannot afford a network call, and used to fall back to a guess: a remote server with no static credential header and no stored token might need OAuth, so assume it does.

McpServerOauthRequirement is where the answer is kept, so the surfaces that cannot ask read a recorded fact instead. One row per server config — keyed on the same {type, url, headers} digest as the credential, so a catalog edit that invalidates the credential key invalidates the determination with it — carrying one of three values:

DeterminationWhat it meansRecorded when
advertised_requiredThe server advertises OAuthDiscovery resolved an authorization server, or the unauthenticated GET came back 401 with a Bearer challenge
advertised_not_requiredThe server served us without a tokenThe unauthenticated GET returned 2xx and answered in application/json or text/event-stream
undeterminedWe asked and could not tellA connection error, a timeout, a 401 whose challenge is not Bearer, or any other status

undetermined still means what the guess used to mean: assume OAuth might be required. That fallback is deliberate and load-bearing in the other direction. Deciding “this server does not need OAuth” and being wrong is the worse failure, because it is silent — the server never gets credentials and fails at the point of use, where an over-eager assumption merely offers an Authorize button nobody needed. Two rules keep the quiet direction narrow:

  • Only an MCP-shaped 2xx records advertised_not_required. A 400, 404, 405 or 5xx is undetermined — a streamable-HTTP server that answers a bare GET with 400 missing session id before it ever looks at a token has told us nothing about authorization. Neither does a 200 in text/html: the probe sends a bare GET with Accept: application/json, which is not a well-formed MCP request, so a 2xx is at least as likely to be a CDN interstitial or a landing page sharing the origin as it is to be the MCP endpoint. The content type is what makes the 2xx evidence rather than coincidence.
  • advertised_not_required expires after seven days and degrades back to undetermined, so a server that starts requiring OAuth cannot be permanently believed not to. advertised_required does not expire; the failure it causes is a visible button, not a silent one.

Nothing about which servers block a spawn changes: the gate still probes live and acts on its own answer, and the Connectors page still offers the Authorize button on every OAuth-capable server with no credential. What the record buys is that the state is inspectable. “The agent says it needs to authorize this server” has three distinct causes — this guess, a credential key that stopped matching, and a server that issues no refresh token — and a Needs authorization row now says which branch it took: silent when the server advertised the requirement, and explicit that Zimmer is assuming when nothing has come back. The spawn log says the same thing, naming “advertised: not required” apart from “could not determine whether OAuth is required” rather than reporting both as “does not require OAuth”.

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.

token_endpoint decides two things at once: where the grant goes, and whether it is encrypted — McpOauthService#post_form derives use_ssl from that same string. Both grants posted through it carry the client_secret as a form parameter, alongside the authorization code (initial exchange) or the refresh token (renewal). So an http:// value publishes long-lived credentials in a cleartext POST body, and the endpoint answers 200, so the refresh reports success and nothing surfaces.

Zimmer refuses it, and the rule is exceptionless — loopback included. That matters more here than for the X credential, because this endpoint is discovered: it comes out of the MCP server’s own RFC 8414 metadata. An exception would be triggerable by the one party outside Zimmer’s control, and a remote server naming Zimmer’s own loopback has no honest meaning — it would aim a POST carrying an operator-supplied client secret at whatever answers on this host’s port. A local MCP server on http://localhost is refused too, with a message saying why, rather than quietly putting the secret on the wire.

Where it is enforced:

  • McpOauthController#initiate — refuses before the consent redirect, with a flash naming the endpoint, so no authorization code is minted for an endpoint Zimmer will not talk to.
  • McpOauthPendingFlow — refuses to store one, so the initial exchange cannot leak it.
  • McpOauthCredential — refuses to save one. Blank stays legal: it means “this credential cannot be renewed, re-authorize it”, which is what #can_refresh? and the Connectors page read.
  • McpOauthCredential#can_refresh? — false for a cleartext endpoint, so RefreshMcpOauthTokensJob and McpOauthCredentialInjector never call refresh! on one. This is the anti-brick guarantee: refresh! POSTs before it saves, so a row that reached it would leak the secret, let the provider rotate the single-use refresh token, and only then fail validation on the way to persisting it.
  • McpOauthService#post_form — raises InsecureEndpoint rather than opening a cleartext connection, covering any caller holding a bare URI.
  • McpOauthService#post_json — the same rule on the registration endpoint. Dynamic Client Registration is the same hole one request earlier: the endpoint comes from the same discovery document, and the DCR response carries a freshly minted client_id and client_secret. It runs inside check_oauth_requirement, i.e. before initiate’s check, so the guard has to be in the transport. A refusal degrades to the existing “Dynamic Client Registration failed” message.

ClearNonHttpsMcpOauthTokenEndpoints clears any row that predates the rule. It clears to NULL rather than to a default, because an MCP token endpoint has no default — re-authorizing the connector rediscovers it.

Userinfo is deliberately permitted (https://id:secret@host/token): post_form reads it as basic auth on purpose, because some providers document client_secret_basic that way, and over TLS it is as confidential as any other header. That is where this rule differs from XOauthCredential’s, whose transport overwrites the Authorization header and would silently ignore it.

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.

Pi takes a token by handoff, and cannot give one back

Section titled “Pi takes a token by handoff, and cannot give one back”

PiMcpCredentialWriter is the third writer, and it exists because an OAuth-credentialed MCP server was otherwise unusable on Pi. pi-mcp-adapter answers a connect to one with an instruction to run mcp({ action: "auth-start" }) or /mcp-auth <server> — and neither route exists in an unattended session, where there is no browser and nobody to click.

The adapter has a documented ingest path, so Zimmer writes into it: a plaintext entry at $MCP_OAUTH_DIR/sha256-<server-hash>/tokens.json, or under <Pi agent dir>/mcp-oauth/ when that variable is unset, where <server-hash> is SHA-256 of the .mcp.json server key. On the next read the adapter imports it into the OS credential store and deletes the file, which is the intended lifecycle rather than a leftover. Every field comes from ResolvedMcpCredential; nothing is synthesized.

The handoff goes both ways. Zimmer gives Pi a token it already holds, and reads one Pi refreshed for itself back out of the credential store — Pi ships its own MCP OAuth client and rotates like Claude Code does, so the write half alone would leave a rotating provider’s credential stale. See capturing the token the runtime rotates, and the limitation that does remain — nothing pushes a refreshed token into a session that is already running.

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, and it cannot become the only one. Claude Code and Pi both ship their own MCP OAuth client: when an access token lapses mid-session they refresh it and write the new pair back — Claude Code to ~/.claude/.credentials.json, Pi (pi-mcp-adapter) through an MCP-SDK OAuth provider whose saveTokens writes into the OS credential store. Notion (and other OAuth 2.1 servers) rotate refresh tokens — every refresh mints a new refresh token and revokes the prior one — so once either runtime refreshes, the refresh token in Zimmer’s DB is already dead.

Neither runtime can be told to stop. pi-mcp-adapter’s only relevant switch is oauth: false, which disables OAuth for the server outright rather than pinning the token Zimmer staged, and no environment variable narrows it; Claude Code exposes nothing either. So “Zimmer is the sole authority, runtime refresh disabled” is not a configuration that exists — adopt-back is the design, not a workaround for one. The ordering it needs is supplied by the adoption rule below rather than by a lock: only a strictly later access-token expiry is adopted, so the chain advances in one direction and a re-stamp of an older pair can never win.

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. It reads the store of that session’s runtime.
  • 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. The cron has no session and therefore no runtime, so it reads every registered runtime’s store (RuntimeRegistry.mcp_credential_writer_classes). Reading only one is how a rotation performed on another runtime got burned. Order does not matter: each store is compared against the row as it stands after the previous one, so the newest pair wins whichever order they are read in.

Which key each store uses is the runtime’s own, and all three differ. Claude Code keys by the protocol-level credential_key (server_name|hash), because its #credential_key_for is McpOauthCredential.compute_credential_key. Codex uses the same shape over a different hash — it forces type: "http" and empty headers where the protocol key hashes the server’s real type and headers, so the two coincide only for a headerless streamable-http server and diverge for an sse one or one carrying a header. Pi keys by the bare .mcp.json server name. A caller holding a server config asks #credential_key_for; the cron, which holds only a DB row, asks #runtime_key_for, and each writer answers in its own shape. The shared contract test asserts the two agree per writer, because a writer whose key shapes disagree probes its store for something it never wrote and misses in silence.

How the store gets read differs too, and the contract says which kind a writer is. #enumerable_store? is true for a runtime that keeps one readable file (Claude Code, Codex): the reconciler reads it once and serves every credential from that snapshot. It is false for Pi, whose entries live in the OS credential store addressed by sha256(server_name) with no listing — so #read_runtime_credentials takes the keys the caller wants and probes exactly those accounts through the adapter’s own mcp-keyring-helper.cjs, memoized so a repeated key costs one probe. A store that cannot be reached is “nothing to adopt”, never an error: an adoption probe is bounded at two seconds (tighter than the write and delete paths, because it is the only one that can be skipped without consequence) and a failure leaves Zimmer’s own copy in place.

Two things are refused rather than adopted, both of which would otherwise overwrite a live token with a dead one. A DB row with no expiry is not adopted over: a nil expires_at means the provider issued no expires_in, the access token does not expire, and a non-expiring token is one no runtime ever refreshes — so reading “the runtime recorded an expiry and we did not” as newer would let a stale on-disk pair land on top of a freshly authorized one. And an entry recorded against a different server URL is not this credential’s, however its key matched: server_name is not unique across McpOauthCredential rows, so on Pi’s name-keyed store a server whose URL changed leaves an old row probing the same account. pi-mcp-adapter draws the same line from its own side (getAuthForUrl returns nothing once the URL has moved).

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

Codex is the one runtime with nothing to adopt: it is written-not-trusted (Zimmer rewrites its store every spawn and Codex does not refresh MCP tokens itself), so reconciling against it is a harmless no-op rather than an exception to carve out.

What adoption is not is a push. Credentials are injected at spawn, and nothing writes a refreshed token into a session that is already running — nor would it take effect if it did. pi-mcp-adapter memoizes each server’s auth entry in a process-local cache and drops it only when its own provider rejects an access token, so a mid-session write is invisible to the live process until it next hits a 401 — at which point it re-reads and picks up the newer token anyway. Adoption is therefore periodic: every spawn, and every 30 minutes from cron. That is early enough that nothing goes stale, because the cron never presents a rotated-away token; it is not a push. See limitations.

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, and says whether the requirement was advertised or assumed
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 anyone 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.

The human surfaces read the same four states through McpServerOptions: the MCP-server pickers on the new-session form, the trigger form and the session detail page show such a server with an Unavailable badge and its reason, sorted below the ones that work, and GET /api/v1/configs and GET /api/v1/mcp_servers carry unavailable and unavailable_reason per server. They flag rather than omit because a human, unlike an agent, is usually one click on this page away from fixing the reason. → 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.

The X MCP server does not go through anything above. It runs in static access-token mode, and the token comes from an XOauthCredential row that XOauthTokenVendor resolves as ${X_OAUTH_ACCESS_TOKEN} at prepare time and RefreshXOauthTokensJob keeps fresh. X rotates refresh tokens single-use and expires them, so that row is not minted once and forgotten: a reused token, a double exchange or an expiry breaks the chain, the refresh fails permanently, and the only way back is a human consenting on X again.

That consent runs from the Supervisor panel (Supervisor::XOauthAuthorizationsController):

  • Re-authorize with X on a credential’s page (/supervisor/x_oauth_credentials/:id) re-runs consent for that credential’s account and env var.
  • Connect an X account on the index asks for an account key and env var, then does the same for an account with no row yet.

Things that hold on every path:

  • Every leg is behind the operator realm, the callback included. The callback arrives in the operator’s own browser, which already holds the /supervisor credential, and the fleet’s sessions do not hold it (CliSpawnEnv clears SUPERVISOR_PASSWORD). So an agent can neither start a flow nor finish one. That is a tighter boundary than the MCP flow’s, which has none beyond the perimeter.
  • The state X echoes back is the only way to a flow. A missing, unknown, replaced or expired state stops the request before anything is sent to X. Claiming deletes the row, so a replayed callback, a double-submitted paste and a second tab all find nothing. An expired flow is deleted when it is refused, and every start sweeps expired flows.
  • The paste-back requires the whole redirect URL. A bare code is refused, because the state is what ties the code to the verifier that can redeem it.
  • A failed exchange stores nothing. The code is single-use at X, so the page says to start again, and the credential Zimmer already holds is untouched.

Which of the two completions a deployment gets is decided by X_OAUTH_REDIRECT_URI: the flow finishes on its own only when that is byte-for-byte XOauthBootstrap.hosted_redirect_uri (https://<APP_HOST>/supervisor/x_oauth/callback). The default is http://localhost:8080/callback, the one URI the X app already has registered, which is why paste-back is what ships. To get the hosted callback, register https://<APP_HOST>/supervisor/x_oauth/callback on the X app in X’s developer portal, then set X_OAUTH_REDIRECT_URI to it. Registering it is a step on X’s side that no API reaches.

Consents in progress are listed at /supervisor/x_oauth_pending_flows (verifier not shown), where one can be cancelled. There is deliberately no MCP tool for any of this: consent is a browser handshake, and keeping the flow behind the operator realm is what keeps agents off it.