Skip to content

feat(mcp): refresh-token auth type + set_refresh_token_mcp tool - #90

Merged
jonnyparris merged 1 commit into
mainfrom
feat/refresh-token-mcp
May 26, 2026
Merged

feat(mcp): refresh-token auth type + set_refresh_token_mcp tool#90
jonnyparris merged 1 commit into
mainfrom
feat/refresh-token-mcp

Conversation

@jonnyparris

Copy link
Copy Markdown
Owner

What

Adds a third MCP auth type — refresh_token — for connecting to upstream MCP servers whose OAuth authorize endpoint only accepts loopback redirect URIs. cf-portal is the motivating case (see #88, #89): DCR works from any host, but the authorize step rejects non-127.0.0.1 redirect URIs. The previous PRs proved this is unfixable from Dodo's side — but the bearer-token path against the MCP endpoint itself works fine, so we just need a way to get tokens into Dodo.

This PR provides that via an MCP tool: set_refresh_token_mcp. A local helper (OpenCode, or a small script) does the OAuth dance on 127.0.0.1, then calls Dodo's tool with the resulting tokens. From then on, Dodo refreshes against the OAuth token endpoint itself.

How

Three components:

  1. auth_type = 'refresh_token' added to the mcp_configs schema, alongside two new nullable columns: oauth_token_endpoint and oauth_client_id. Idempotent ALTER TABLE migrations on onStart.

  2. POST /refresh-token-mcp on UserControl — atomic upsert keyed by URL. Stores the token endpoint + client ID in mcp_configs, the tokens + expires_at in encrypted_secrets (existing envelope encryption).

  3. GET /mcp-configs/:id/access-token on UserControl — returns a valid access token, refreshing if needed. ?force=1 skips the expiry check (used by the reconnect-once-on-401 path).

  4. connectMcpServers in CodingAgent — for refresh_token configs, reads the bearer from UserControl, injects Authorization: Bearer <token>. On connect failure with an auth-looking error, force-refreshes and reconnects once.

  5. set_refresh_token_mcp MCP tool in Dodo's MCP server — takes the same args the upsert needs. Idempotent on url so re-running the local helper just rotates the tokens in place.

Concurrency

Refresh tokens rotate. Two simultaneous refreshes for the same config would race and the second would fail with invalid_grant — the Kenny Johnson bug from his MCP-Portal-Re-Auth-Loop wiki page. We avoid it by routing all refresh through UserControl, which is single-threaded per user. Session DOs never refresh directly.

What stays unchanged

  • The static_headers auth path (existing static MCP integrations)
  • The oauth auth path (Agents-SDK-managed OAuth, federated through the per-user hub DO)
  • The integration UI — no frontend changes; the new auth type renders as a regular config

Verification

  • npm run typecheck clean
  • npx vitest run 823/823 pass (817 existing + 6 new)
  • 6 new tests in test/refresh-token-mcp-unit.test.ts:
    • upsert creates a new row with auth_type=refresh_token
    • upsert with same URL updates in place
    • cached read returns the token without calling fetch (when not expired)
    • expired token triggers a refresh against the configured endpoint
    • ?force=1 refreshes even when not expired
    • 502 surfaced when the token endpoint rejects the refresh

What's next (out of scope for this PR)

A slash command in agent-hq (/dodo-piggyback-cf-portal) that reads ~/.local/share/opencode/mcp-auth.json and calls the new MCP tool — eliminates manual paste for the cf-portal case.

beep-boop-🤖

Lets Dodo connect to MCP servers whose OAuth authorize endpoint only
accepts loopback redirect URIs — the cf-portal case, where DCR works
from any host but the authorize step rejects non-loopback redirect URIs.

The flow:

1. A local helper (e.g. OpenCode, or a small script) performs the OAuth
   authorization-code flow against the upstream provider, registering
   itself with a 127.0.0.1 redirect URI that the upstream accepts.
2. The helper calls Dodo's new `set_refresh_token_mcp` MCP tool with
   { name, url, tokenEndpoint, clientId, accessToken, refreshToken,
     expiresAt? }.
3. Dodo stores them encrypted in UserControl. The mcp_configs row
   gets auth_type = 'refresh_token' and two new non-secret columns
   (oauth_token_endpoint, oauth_client_id) for the refresh call.
4. Every session DO that needs the access token reads it via
   /mcp-configs/:id/access-token. UserControl owns the cache + refresh
   loop. Per-user DO single-threading collapses concurrent reads near
   expiry into one refresh — exactly the property that prevents the
   single-use refresh token race that Kenny Johnson's wiki page
   documents.
5. Session DOs reconnect-once-on-401 with ?force=1 to force a refresh
   if the cached token is stale despite a not-yet-elapsed expires_at
   (clock skew, server-side revoke).

The OAuth tokens (access, refresh, expires_at) live in
encrypted_secrets, bundled with the existing envelope encryption.
The non-secret OAuth metadata (token endpoint URL, client_id) lives
in two new nullable columns on mcp_configs that are idempotently
created via ALTER TABLE on every onStart.

Six new unit tests in test/refresh-token-mcp-unit.test.ts exercise:
- create-on-new-URL vs update-in-place
- cached read (no fetch made when token still valid)
- refresh on expiry
- force-refresh via ?force=1
- 502 surfacing when the token endpoint rejects the refresh

The existing connectMcpServers static-headers path is unchanged.
oauth (Agents SDK-managed) configs are still filtered out. The only
new behaviour is when auth_type === 'refresh_token', where the
Authorization header is sourced from UserControl.

Tests: 823/823 pass. Typecheck clean.

beep-boop-🤖
@jonnyparris
jonnyparris merged commit ff63822 into main May 26, 2026
1 of 2 checks passed
@jonnyparris
jonnyparris deleted the feat/refresh-token-mcp branch May 26, 2026 12:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant