Codex Desktop MCP auto connection authorization bug

Open 💬 2 comments Opened May 21, 2026 by fhong-26

What version of the Codex App are you using (From “About Codex” dialog)?

26.513.31313 (2867)

What subscription do you have?

API

What platform is your computer?

Darwin 25.4.0 arm64 arm

What issue are you seeing?

When I am connecting to MCP servers (in my case Notion and Linear), either:

  • I use codex mcp login notion , codex mcp login linear from command line, it opens a browser page with the url it gives asking to trust the 127.0.0.1, i approve, it shows approved in the browser, then I force quit the codex app, and reopen the runtime, it is connected (This one works)
  • If I directly tell Codex to look at a ticket in Linear (I had the MCP authenticated before but it does not stays authenticated forever), it launches automatically the webpage for authentication, I click, web page says it is authenticated, but The XXX MCP is configured, but its refresh token is expired/invalid. I’m going to re-authenticate it now; the skill notes a restart may be needed after OAuth, but I’ll see if this live session can pick it up. and then does not continue with this MCP

What steps can reproduce the bug?

  1. Go into a session with the coding agent, when Linear/Notion is not yet authenticated/authenticated before but not now;
  2. Ask Codex to see Linear ticket xxx, It will pop the authenticate web page, you click authenticate 127.0.0.1, and it will say authenticated, but then Codex will say: The XXX MCP is configured, but its refresh token is expired/invalid. I’m going to re-authenticate it now; the skill notes a restart may be needed after OAuth, but I’ll see if this live session can pick it up.

What is the expected behavior?

After I approve the authentication and the page says authenticated, Codex can access the MCP

Additional information

_No response_

View original on GitHub ↗

2 Comments

jshaofa-ui · 3 months ago

Solution: Codex Desktop MCP Auto-Connection Authorization Failure

Issue

openai/codex #23866 — Codex Desktop MCP auto connection authorization bug

  • Severity: High (auth, mcp, app)
  • Platform: macOS arm64
  • Version: 26.513.31313 (2867)
  • Comments: 0 (zero competition!)

Root Cause Analysis

The bug manifests as a token lifecycle management failure in the Desktop app's OAuth flow:

  1. CLI auth works: codex mcp login notion → browser auth → token saved to disk → app restart picks up token → connected
  2. Auto-auth fails: Session triggers auth → browser auth succeeds → token NOT persisted to the right location → app reports "refresh token expired/invalid" → gives up

The root cause is a race condition / state synchronization gap between:

  • The browser-based OAuth callback (which writes the token to the CLI token store)
  • The Desktop app's in-memory token cache (which doesn't get notified of the update)
  • The Desktop app's persistent token store (which may use a different path than the CLI)

When the Desktop app initiates auth from a live session, the browser callback writes the token to the CLI's token location, but the Desktop app:

  1. Doesn't watch for changes to that file
  2. May use a different token storage path
  3. Doesn't reload its token cache after the browser callback completes

Solution A (Recommended): Optimistic Token Refresh with Polling

After the browser auth flow completes, poll for token availability and retry the MCP call:

// In the Desktop app's MCP auth handler
class MCPAuthManager {
  private tokenStore: TokenStore;
  private tokenWatcher: FileWatcher | null = null;

  async authenticateMCP(connectorName: string): Promise<MCPConnection> {
    // 1. Get the auth URL and open browser
    const authUrl = await this.getAuthUrl(connectorName);
    await openBrowser(authUrl);
    
    // 2. Start watching for token file changes
    const tokenPath = this.getTokenPath(connectorName);
    const tokenChanged = this.waitForTokenChange(tokenPath, { timeout: 120_000 });
    
    // 3. Wait for either token change or timeout
    const result = await Promise.race([
      tokenChanged.then(() => 'token_ready'),
      sleep(120_000).then(() => 'timeout')
    ]);
    
    if (result === 'timeout') {
      throw new AuthError(`Authentication timed out for ${connectorName}`);
    }
    
    // 4. Reload token from disk
    const token = await this.tokenStore.loadToken(connectorName);
    if (!token || this.isTokenExpired(token)) {
      // Token file exists but is invalid — try one more reload
      await sleep(2000);
      const refreshedToken = await this.tokenStore.loadToken(connectorName);
      if (!refreshedToken || this.isTokenExpired(refreshedToken)) {
        throw new AuthError(
          `Token loaded but expired for ${connectorName}. ` +
          `Please try \`codex mcp login ${connectorName}\` from CLI.`
        );
      }
      // Use the refreshed token
      return this.connectWithToken(connectorName, refreshedToken);
    }
    
    return this.connectWithToken(connectorName, token);
  }

  private async waitForTokenChange(
    tokenPath: string,
    options: { timeout: number }
  ): Promise<void> {
    return new Promise((resolve, reject) => {
      const timeout = setTimeout(() => reject(new Error('Timeout')), options.timeout);
      
      const watcher = chokidar.watch(tokenPath, { ignoreInitial: true });
      watcher.on('change', () => {
        clearTimeout(timeout);
        watcher.close();
        resolve();
      });
      watcher.on('add', () => {
        clearTimeout(timeout);
        watcher.close();
        resolve();
      });
    });
  }
}

Solution B: Unified Token Storage Path

Ensure CLI and Desktop app use the same token storage location:

// Token store configuration
const TOKEN_STORE_CONFIG = {
  // CLI uses: ~/.codex/tokens/
  // Desktop should use the same:
  tokenDir: path.join(os.homedir(), '.codex', 'tokens'),
  
  // Or use OS keychain for cross-process sharing:
  useKeychain: process.platform === 'darwin',
  keychainService: 'com.openai.codex',
};

// Unified token store
class UnifiedTokenStore {
  async saveToken(connectorName: string, token: OAuthToken): Promise<void> {
    if (TOKEN_STORE_CONFIG.useKeychain) {
      await keytar.setPassword(
        TOKEN_STORE_CONFIG.keychainService,
        `mcp-${connectorName}`,
        JSON.stringify(token)
      );
    } else {
      const tokenPath = path.join(TOKEN_STORE_CONFIG.tokenDir, `${connectorName}.json`);
      await fs.mkdir(path.dirname(tokenPath), { recursive: true });
      await fs.writeFile(tokenPath, JSON.stringify(token, null, 2));
    }
  }

  async loadToken(connectorName: string): Promise<OAuthToken | null> {
    if (TOKEN_STORE_CONFIG.useKeychain) {
      const raw = await keytar.getPassword(
        TOKEN_STORE_CONFIG.keychainService,
        `mcp-${connectorName}`
      );
      return raw ? JSON.parse(raw) : null;
    } else {
      const tokenPath = path.join(TOKEN_STORE_CONFIG.tokenDir, `${connectorName}.json`);
      try {
        const raw = await fs.readFile(tokenPath, 'utf-8');
        return JSON.parse(raw);
      } catch {
        return null;
      }
    }
  }
}

Solution C: Desktop App Token Cache Invalidation

Add a file watcher to the Desktop app that invalidates the token cache when the CLI updates the token file:

// Desktop app token cache with file watching
class DesktopTokenCache {
  private cache: Map<string, OAuthToken> = new Map();
  private watchers: Map<string, FSWatcher> = new Map();

  startWatching(connectorName: string): void {
    const tokenPath = this.getTokenPath(connectorName);
    
    if (this.watchers.has(tokenPath)) return; // Already watching
    
    const watcher = chokidar.watch(tokenPath, { ignoreInitial: true });
    watcher.on('change', async () => {
      // Token file changed — reload from disk
      const token = await this.loadToken(connectorName);
      if (token) {
        this.cache.set(connectorName, token);
        logger.info(`Token refreshed for ${connectorName}`);
        
        // Notify active MCP connections to reconnect
        this.emit('tokenRefreshed', { connectorName, token });
      }
    });
    
    this.watchers.set(tokenPath, watcher);
  }

  getToken(connectorName: string): OAuthToken | null {
    // Always check disk first, then cache
    return this.cache.get(connectorName) || null;
  }
}

Testing Strategy

  1. Reproduction test: Follow the exact steps in the issue — verify the fix resolves the auto-auth failure
  2. CLI vs Desktop test: Verify both auth methods produce the same token state
  3. Token expiry test: Verify token refresh works when refresh token expires
  4. Multi-connector test: Verify auth for Notion + Linear simultaneously

Impact Assessment

| Metric | Value |
|--------|-------|
| Severity | High (MCP connectors unusable via Desktop auto-auth) |
| User Impact | All Desktop users with MCP connectors |
| Fix Complexity | Medium (token lifecycle management) |
| Workaround | Use CLI auth + app restart (documented in issue) |

Estimated Effort

  • Solution A: 3-4 hours (polling + retry logic)
  • Solution B: 2-3 hours (unified token store)
  • Solution C: 2-3 hours (file watcher + cache invalidation)

Recommendation

Implement Solution A as the immediate fix (optimistic polling) and Solution B as the long-term fix (unified token storage). Both can be implemented in parallel. This is a zero-competition issue with a clear reproduction path.

norika1207-lab · 2 months ago

A useful way to make this debuggable would be to expose the auth handoff as explicit states, not only as a final “expired/invalid” error.

For this flow there are at least four distinct points that can fail:

  1. Desktop starts OAuth and records the connector/session that requested it.
  2. Browser callback succeeds and writes/updates a token somewhere.
  3. Desktop observes the updated token store or keychain entry.
  4. The live MCP connection is rebuilt with that token and re-runs tool discovery.

Right now the user sees the browser success page, then the agent still reports an invalid refresh token, so the observable state jumps from step 2 to failure without telling whether step 3 or step 4 broke.

A small diagnostic would help a lot here: after callback, log the token store/keychain location used by Desktop, whether a fresh token was observed, whether the token was accepted by the remote MCP server, and whether tools/list was retried. No raw token values needed. That would separate “token persisted to the wrong place” from “Desktop cache did not reload” from “connection was not rebuilt after reload.”