feat(mcp): refresh-token auth type + set_refresh_token_mcp tool - #90
Merged
Conversation
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-🤖
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
auth_type = 'refresh_token'added to themcp_configsschema, alongside two new nullable columns:oauth_token_endpointandoauth_client_id. IdempotentALTER TABLEmigrations ononStart.POST /refresh-token-mcponUserControl— atomic upsert keyed by URL. Stores the token endpoint + client ID inmcp_configs, the tokens + expires_at inencrypted_secrets(existing envelope encryption).GET /mcp-configs/:id/access-tokenonUserControl— returns a valid access token, refreshing if needed.?force=1skips the expiry check (used by the reconnect-once-on-401 path).connectMcpServersin CodingAgent — forrefresh_tokenconfigs, reads the bearer from UserControl, injectsAuthorization: Bearer <token>. On connect failure with an auth-looking error, force-refreshes and reconnects once.set_refresh_token_mcpMCP tool in Dodo's MCP server — takes the same args the upsert needs. Idempotent onurlso 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
static_headersauth path (existing static MCP integrations)oauthauth path (Agents-SDK-managed OAuth, federated through the per-user hub DO)Verification
npm run typecheckcleannpx vitest run823/823 pass (817 existing + 6 new)test/refresh-token-mcp-unit.test.ts:?force=1refreshes even when not expiredWhat'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.jsonand calls the new MCP tool — eliminates manual paste for the cf-portal case.beep-boop-🤖