Skip to content

Codex Desktop MCP auto connection authorization bug #23866

Open

Description

@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

Activity

  1. added
    appIssues related to the Codex desktop app
    on May 21, 2026
  2. added
    bugSomething isn't working
    mcpIssues related to the use of model context protocol (MCP) servers
    authIssues related to authentication and accounts
    on May 21, 2026
  3. jshaofa-ui commented on May 21, 2026

    @jshaofa-ui

    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.

  4. norika1207-lab commented on Jun 1, 2026

    @norika1207-lab

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

  5. liangxiwei commented on Sep 1, 2026

    @liangxiwei

    it's a stupid bug.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    appIssues related to the Codex desktop appauthIssues related to authentication and accountsbugSomething isn't workingmcpIssues related to the use of model context protocol (MCP) servers

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions