MCP shutdown can lose a rotated refresh token during session cleanup

Open 💬 0 comments Opened Aug 27, 2026 by liangxiwei

Summary

Codex can lose a rotated MCP OAuth refresh token when an MCP client is shut down while transport cleanup is refreshing credentials. The provider successfully consumes RT1 and returns AT2/RT2, but Codex may drop the in-memory credential store before copying RT2 to its durable credential store. A later process then retries RT1, which a rotating-token provider correctly treats as reuse and may revoke the entire token family.

This is a lifecycle/persistence race, not an ordinary access-token expiration problem.

Observed case (redacted)

This happened with Codex desktop's bundled CLI 0.150.0-alpha.8 on macOS arm64. Provider and local credential-store timestamps showed:

  • 07:50:40.602 UTC: AT2 expired.
  • 07:50:47.614 UTC: the Codex app-server began shutting down an inactive task instance.
  • 07:50:47.902 UTC: the OAuth provider committed the RT2 exchange and issued AT3/RT3, about 288 ms after task teardown began.
  • The durable MCP OAuth credential entry retained AT2/RT2 and its modification time did not advance past 06:50:40 UTC.
  • 07:52:42.652 UTC: another Codex process retried RT2, about 115 seconds after the successful rotation. The provider detected reuse and revoked the token family.

Token values, client IDs, family IDs, workspace IDs, and private endpoints are intentionally omitted.

The exact client-side refresh-request start timestamp was not retained, so the shutdown-triggered request cannot be proven from timestamps alone. However, the source path below, the 288 ms overlap, the successful provider-side rotation, and the unchanged durable store make transport cleanup during teardown the strongest source-backed explanation.

Root cause

Codex configures RMCP with an InMemoryCredentialStore and separately copies changes into its durable credential store through OAuthPersistor after normal initialization and tool-call paths.

During RmcpClient::shutdown(), Codex previously replaced the state with Closed and dropped the prior ClientState::Ready. Dropping RMCP's running streamable-HTTP service asynchronously performs session cleanup, including an authenticated DELETE. RMCP obtains an access token for that request and can refresh it when the token is expired or near expiry. That refresh updates only RMCP's in-memory credential store.

At that point there is no later Codex persistence hook: the OAuthPersistor is being dropped together with the service. If the outer shutdown future or task instance is cancelled while the provider is answering, the replacement refresh token is lost even though the provider committed the rotation.

The current refresh request also has no Idempotency-Key, but adding request idempotency alone would not make the returned replacement token durable. Shutdown must not expose a provider-side rotation unless Codex can persist the replacement.

Expected behavior

MCP teardown must satisfy both properties:

  1. Cancellation of the shutdown caller must not discard an in-flight rotating refresh response.
  2. Transport cleanup must not independently consume a refresh token after the durable OAuth owner has begun shutting down.

Proposed fix and proof of concept

I implemented a proof of concept here:

The change:

  • Moves ownership of the previous client state into an independently owned Tokio shutdown task, so cancelling the caller does not drop the service or OAuth state mid-response.
  • Before dropping the transport, persists any RMCP-managed credential change and runs the existing serialized/cross-process refresh transaction when the token is near expiry.
  • Persists the replacement before exposing it to transport cleanup.
  • Removes the refresh token only from RMCP's cleanup-only in-memory view, so the final authenticated session DELETE can use the access token but cannot trigger another hidden rotation.

Regression test

The added integration test models the observed lifecycle race:

  1. Store AT1/RT1 with a 32-second access-token lifetime.
  2. Enter RMCP's 30-second refresh window.
  3. Start shutdown; delay the provider's RT1 -> AT2/RT2 response by 300 ms.
  4. Cancel the outer shutdown caller after 100 ms.
  5. Verify exactly one refresh exchange occurred, AT2/RT2 are in the durable store, and transport cleanup sends its session DELETE with AT2.

Targeted result:

just test -p codex-rmcp-client --test streamable_http_oauth_shutdown
1 passed, 1 skipped

The full codex-rmcp-client package run completed with 266 passing tests, 8 skipped, and 5 unrelated environment-dependent failures (proxy authorization expectations, two remote-executor mock expectations, and a missing locally built target/debug/codex binary). just fmt and just fix -p codex-rmcp-client completed successfully.

Related reports

  • #32590 tracks the broader symptom that MCP OAuth sessions expire and require reauthentication/restart.
  • #39054 describes a complementary failure mode where a rejected refresh token is retained and retried.

This issue focuses specifically on losing a successfully rotated refresh token during MCP task/client teardown.

View original on GitHub ↗