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 MCP —
https://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
- Add the Socket MCP server:
droid mcp add socket https://mcp.socket.dev/ --type http
- Authenticate via
/mcp (browser OAuth completes successfully; a credential with access + refresh token is stored).
- Wait for the short-lived access token to expire (~15 min), or reconnect / run
droid mcp list.
- 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.
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 surfacesFailed 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
0.149.0Affected server (public, reproducible)
https://mcp.socket.dev/https://api.socket.dev(scopepackages:list)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
/mcp(browser OAuth completes successfully; a credential with access + refresh token is stored).droid mcp list.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 listshows:Evidence (logs, ~/.factory/logs/droid-log-single.log)
Occurs immediately after a clean re-authentication once the token needs refreshing:
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 throwsrefresh_token_not_rotatedotherwise. 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