Skip to content

[BUG] MCP OAuth fails for spec-compliant servers that don't rotate refresh tokens (refresh_token_not_rotated) #1238

Description

@mav-fieldguide

Summary

Droid's MCP OAuth client fails to connect to any OAuth-protected MCP server whose authorization server does not rotate the refresh token on token refresh. The driver treats a non-rotated (reused) refresh token as a fatal error (reason: "refresh_token_not_rotated"), drops the connection, and surfaces Failed to connect to MCP server.

Refresh-token rotation is optional per OAuth 2.0 (RFC 6749 §6: the authorization server MAY issue a new refresh token). A server that returns a new access token while keeping the same refresh token is fully spec-compliant, so Droid should not treat that as an error.

This makes such servers permanently unusable: re-authenticating issues a fresh short-lived access token that works only until the first refresh, after which every reconnect fails.

Environment

  • Droid CLI: 0.149.0
  • OS: macOS 26.5.1 (build 25F80), arm64
  • Transport: HTTP MCP server with OAuth (dynamic client registration + PKCE)

Affected server (public, reproducible)

  • Socket MCPhttps://mcp.socket.dev/
  • Authorization server: https://api.socket.dev (scope packages:list)
  • Socket issues short-lived access tokens (~15 min) and reuses the same refresh token on refresh (does not rotate). This is allowed by RFC 6749 §6.

By contrast, the Notion (https://mcp.notion.com/mcp) MCP server connects fine in the same Droid install because their authorization servers do rotate the refresh token on every refresh.

Steps to reproduce

  1. Add the Socket MCP server:
    droid mcp add socket https://mcp.socket.dev/ --type http
    
  2. Authenticate via /mcp (browser OAuth completes successfully; a credential with access + refresh token is stored).
  3. Wait for the short-lived access token to expire (~15 min), or reconnect / run droid mcp list.
  4. Droid attempts a token refresh, which returns a new access token but the same refresh token.

Expected behavior

A refresh response that omits/reuses the refresh token is valid (RFC 6749 §6). Droid should accept the new access token, keep using the existing refresh token, and connect successfully — same as it does for servers that do rotate.

Actual behavior

The connection fails. droid mcp list shows:

socket  http  failed: Failed to connect to MCP server  [user]

Evidence (logs, ~/.factory/logs/droid-log-single.log)

Occurs immediately after a clean re-authentication once the token needs refreshing:

WARN: MCP OAuth token refresh failed | {"name":"socket","url":"https://mcp.socket.dev",
  "reason":"refresh_token_not_rotated","errorMessage":"MCP OAuth refresh token was not rotated"}
WARN: [McpHub] Error in MCP server | {"name":"socket","cause":{"name":"MetaError",
  "message":"MCP OAuth refresh token was not rotated",
  "stack":"MetaError: MCP OAuth refresh token was not rotated
    at refreshCredential (src/services/mcp/oauth/core/driver.ts:500:19)"}}
WARN: [McpHub] Error starting MCP server | "Failed to connect to MCP server"
   (at KBA (src/mcp/clients/http/shared.ts:183:14))
INFO: MCP servers reload completed | {"mcpServersStarted":["notion","playwright","shortcut"],
  "mcpServersErrored":["socket"],"mcpServerErrors":{"socket":"Failed to connect to MCP server"}}

Suspected root cause

In src/services/mcp/oauth/core/driver.ts (refreshCredential, ~line 500), the refresh logic appears to require the token endpoint to return a new/rotated refresh token and throws refresh_token_not_rotated otherwise. Per RFC 6749 §6 the server MAY reuse the existing refresh token, so this should not be a hard failure.

Suggested fix

When a refresh response does not include a new refresh token (or returns the same one), retain the existing refresh token and proceed with the new access token instead of failing. Optionally fall back to a full re-authorization only if the refresh actually fails (HTTP 4xx from the token endpoint), not merely because rotation didn't occur.

Workarounds

  • Re-authenticating only works until the next refresh (~15 min for Socket), so it is not a durable fix.
  • A local stdio MCP server with a static API key bypasses the OAuth refresh path entirely, but that requires the ability to mint a provider API key, which not all users have.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions