MCP OAuth: persist_if_needed() overwrites the stored refresh_token with the provider's partial refresh response
Nobody has claimed this yet.
Assessment
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Newbie friendliness
- 72/100
- Issue type
- Bug
- Clarity
- Clearly specified
- Activity status
- Quiet
- Tech stack
- rust
- Domain
- authentication
Research direction
Start in oauth.rs at persist_if_needed(), then compare its credential persistence with refreshed_tokens() in oauth/refresh_transaction.rs. Review the persist_if_needed() call sites in rmcp_client.rs and reproduce with a provider whose refresh response omits refresh_token. Done means the durable credential retains the existing refresh token and later refreshes do not require reconnection.
Written by the indexing model from the issue text.
Description
Version
codex-cli 0.146.0-alpha.3.1 (the binary bundled in ChatGPT.app 26.721.41059, /Applications/ChatGPT.app/Contents/Resources/codex)
Platform
Darwin 25.5.0 arm64 arm
What issue are you seeing?
A remote HTTP MCP server keeps showing "Authentication expired. Reconnect to keep using this MCP server." every ~15 minutes, even though the server issued a refresh token valid for 30 days.
The stored keychain credential (Codex MCP Credentials / <server>|<hash>) ends up as:
{"server_name":"...","url":"...","client_id":"...",
"token_response":{"access_token":"<redacted>","token_type":"bearer","expires_in":900},
"expires_at":1784969933943}
— the refresh_token that the authorization-code exchange returned is gone. Other MCP servers in the same keychain (Notion, Linear, Cloudflare) all retain refresh_token, so this is specific to how this provider's refresh response is handled.
Root cause
The affected provider omits refresh_token from its refresh-grant response. That is legal per RFC 6749 §6 — the client must keep the previously issued one.
oauth/refresh_transaction.rs::refreshed_tokens() already handles this correctly for the proactive, expiry-driven refresh path:
fn refreshed_tokens(mut token_response: OAuthTokenResponse, previous: &StoredOAuthTokens, ...) -> StoredOAuthTokens {
if token_response.refresh_token().is_none() {
token_response.set_refresh_token(previous.token_response.0.refresh_token().cloned());
}
...
}
But oauth.rs::persist_if_needed() is a second write path with no such merge — it reads whatever RMCP's AuthorizationManager currently holds and saves it verbatim:
pub(crate) async fn persist_if_needed(&self) -> Result<()> {
let (client_id, maybe_credentials) = { ... guard.get_credentials().await }?;
match maybe_credentials {
Some(credentials) => {
let new_token_response = WrappedOAuthTokenResponse(credentials.clone());
...
self.inner.credential_store.save(&DefaultKeyringStore, &self.inner.server_name, &stored)?;
When RMCP's AuthClient performs its own reactive refresh (e.g. on a 401 during handshake), the manager is left holding the provider's partial response with no refresh token, and persist_if_needed() — called from several places in rmcp_client.rs (~lines 487, 756, 963, 1184), including after initialize — writes that partial response over the durable credential. From then on refresh_if_needed() short-circuits at the refresh_token().is_none_or(...) guard and returns AuthorizationRequired, so the user is asked to reconnect every time the 15-minute access token expires. The loss is permanent until a full re-authorization, and repeats immediately after it.
The comment in refresh_transaction.rs ("so that hook cannot overwrite durable credentials with the provider's partial response") shows the hazard is already understood for the refresh path; persist_if_needed() looks like the remaining gap.
What steps can reproduce the bug?
- Configure an HTTP MCP server whose refresh-grant response omits
refresh_tokenand whose access tokens are short-lived. Self-hosted Activepieces CE0.86.3(https://<host>/mcp/platform) reproduces it out of the box — access token TTL 900 s, refresh response ={access_token, token_type, expires_in}only. - Authorize the server from Codex. The initial exchange returns a
refresh_tokenand everything works. - Keep the session alive past the first access-token expiry.
- Read the keychain entry:
refresh_tokenis no longer present, and Codex begins asking to reconnect on every subsequent expiry.
What is the expected behavior?
persist_if_needed() should apply the same carry-forward as refreshed_tokens() — never persist a token response that drops a refresh_token/scopes the durable credential already has — so that providers which legally omit refresh_token on refresh keep working until the refresh token itself expires.
Additional information
Verified against the sources at tag rust-v0.146.0-alpha.3.1. The carry-forward in refreshed_tokens() was introduced in 6962a2e ("Serialize MCP OAuth credential refreshes", #32229); persist_if_needed() was not covered by that change.
- Dominant language
- Rust
- Stars
- 125k
- Forks
- 19.5k
- Avg merge
- 1m
- Merged PRs (30d)
- 1k
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from openai/codex
-
enhancement remote
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
-
bug CLI windows-os
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
-
macOS sandbox blocks hw.optional.arm64 sysctl, causing Flutter to misdetect Apple Silicon as x64 Openbug CLI sandbox
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
-
bug CLI TUI
Difficulty 2/5 1-3 hours Newbie friendliness 90/100
-
CLI config enhancement skills
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 86/100
kwakseongjae/auto-hwp#319 ·
-
area:cli bug filter-quality good first issue priority:medium
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
-
Difficulty 1/5 Under an hour Newbie friendliness 72/100
bevyengine/bevy#25861 ·
-
comp-datalake
Difficulty 2/5 1-3 hours Newbie friendliness 88/100
ClickHouse/ClickHouse#121222 ·
-
A-linter
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
oxc-project/oxc#26863 ·