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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions