Repository navigation
Conversation
Follow-up to CopilotKit#52. Adding an MCP server without a token now detects when it requires an account and offers Sign in. The official MCP SDK runs the spec flow (discovery, dynamic client registration, PKCE, code exchange, refresh); OpenDots persists its state per connection and handles the browser leg. - POST /api/connections/:id/sign-in returns the authorization URL (http(s) only). The tab opens during the click, with a visible fallback link if a popup blocker stops it; settings poll until signed in. - GET /oauth/mcp/callback sits outside /api because a redirect cannot carry the owner token. A single-use state that expires in 10 minutes ties it to an owner-started sign-in; background refreshes never replace it. The result page escapes all text. - Tokens refresh automatically; if the service stops accepting them, the connection asks to sign in again and its tools are hidden from the Dot. Sign out forgets tokens and stops the Dot's active turn. - Tokens and client registrations stay server-side. - New optional PUBLIC_URL (validated at startup) sets the callback base behind a proxy or on a hosted domain; otherwise the browser's origin. Tests run a real OAuth-protected MCP server (SDK auth router and demo provider with refresh tokens): full sign-in, tool call, silent refresh, replay/forged/denied callbacks, escaping, sign-out, and PUBLIC_URL validation. express and @types/express are dev dependencies for that fixture; the lockfile changes only by those two root entries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
paoValle
left a comment
There was a problem hiding this comment.
Read the whole diff and ran the branch locally (Node 22.23.3, after npm ci for the new devDependency): npm run typecheck, npm run lint, npm run check-format clean, npm test → 49 files / 306 tests pass, including the new OAuth suite. The flow itself is right: the callback sits outside /api on purpose and is tied to a single-use, 10-minute state; the tokens never reach the browser (the test asserts it); the callback page escapes everything it prints; sign-out hides the tools and changes the fingerprint. Two defects, both on the paths the PR adds.
1. A silent token refresh aborts the Dot's running turn. saveOAuth bumps mcp_connections.updatedAt whenever tokens is in the patch, and the SDK calls saveTokens() on every refresh, not only on sign-in. DotAgent's 100 ms check() treats any fingerprint change as "the owner changed access" and calls abortRun(). Probe on this branch with the new fixture, signed in and with the access token expired:
call ok=true refreshes=1
signedIn before=true after=true
fingerprint changed by the refresh: true
So a turn that uses an OAuth tool whose token has expired is killed mid-answer while the owner's access is unchanged. Bumping updatedAt only on a transition (!hadTokens && hasTokens, or the reverse — sign-in and sign-out, which is what the fingerprint is for) fixes it.
2. "Refresh" before sign-in shows an internal SDK error. On an OAuth connection with no tokens, refresh() runs the SDK auth path with no verifier and an empty redirect_uris, and the owner sees:
refresh before sign-in -> error="Could not reach the connected service: Either provider.prepareTokenRequest() or authorizationCode is required"
unauthorized() does not classify that error, so it falls through to the generic branch. For authMode === 'oauth' && !signedIn the useful answer is "Sign in to this service first." — and it also avoids sending an empty redirect_uris to the registration endpoint.
One question, not a blocker: express + @types/express are added as devDependencies only for tests/fixtures/oauth-mcp-server.ts. If that is because the SDK's mcpAuthRouter is an express router, fine — worth one line in the body, since this is the first express in the repo (the server is Hono).
Good: PUBLIC_URL is validated (http(s), no credentials) and documented, the Vite proxy covers /oauth, the browser tab is opened during the click so popup blockers allow it, and the tests cover replay, forged state, access_denied, and script injection.
| json.discovery, | ||
| ); | ||
| // Signing in or out changes what the Dot can do: stop active turns. | ||
| if ('tokens' in patch) |
There was a problem hiding this comment.
saveTokens() runs on every silent refresh, so this bump is not only a sign-in/sign-out change — and DotAgent.check() aborts the running turn on any fingerprint change. Verified on this branch with the new fixture: after expireAccessTokens() and one tool call (refreshes=1), signedIn is still true and fingerprint changed anyway, so the Dot's turn dies mid-answer although the owner's access did not change. Bumping only on the transition (!hadTokens && hasTokens or the reverse) keeps the abort for real access changes.
| ); | ||
| } catch (error) { | ||
| return this.store.setError(id, failure(error)); | ||
| return this.store.setError(id, failure(error, connection.authMode)); |
There was a problem hiding this comment.
On an OAuth connection that has never been signed in, Refresh produces an internal SDK message: Could not reach the connected service: Either provider.prepareTokenRequest() or authorizationCode is required (probe on this branch). unauthorized() cannot classify it, so it lands in the generic branch. For authMode === 'oauth' && !connection.signedIn, failure() should say "Sign in to this service first." — that is also the state in which clientMetadata.redirect_uris is empty, so no registration attempt should be made at all.
Follow-up to #52.
Workflow
Many MCP servers, such as Google, GitHub or Notion style services, ask you to sign in rather than paste a bearer token. With this change, the owner adds the server without a token. If it requires an account, the connection shows needs sign-in:
Access tokens refresh automatically. If the service stops accepting them, the connection asks the owner to sign in again, and its tools are hidden from the Dot until they do. Sign out forgets the tokens and stops the Dot's active turn, like other access changes.
How it works
StoredOAuthProvider(src/server/connection-oauth.ts) persists the SDK's state per connection inmcp_oauth, and the transport gets it asauthProvider.POST /api/connections/:id/sign-in(owner-only) returns the authorization URL, and onlyhttp(s)URLs are accepted. The client opens the tab during the click so popup blockers allow it. If a blocker stops it anyway, a visible "Open sign-in" link appears. The settings poll until the connection is signed in.GET /oauth/mcp/callbacksits outside/api, because a browser redirect can't carry the owner token. A single-usestatethat expires after 10 minutes ties it to an owner-started sign-in. Background refreshes during tool calls never create or replace that state. The result page escapes all text.authModeandsignedIn.PUBLIC_URL, validated at startup: http(s), no credentials. It sets the callback base when OpenDots runs behind a proxy or on a hosted domain. Without it, the callback uses the origin the owner's browser is on. The dev proxy forwards/oauthto the API server.Verification
npm run check-format,lint,typecheck,test(306 passing) andbuildall pass.tests/connection-oauth.test.tsruns a real OAuth-protected MCP server: the SDK'smcpAuthRouterand bearer middleware, plus a demo provider extended with refresh tokens and a 401invalid_tokenfor expired tokens. It covers:PUBLIC_URLvalidationPUBLIC_URLset. Earlier testing in my fork also covered the popup-blocked fallback link.expressand@types/expressare dev dependencies for the test fixture (expresswas already installed via the MCP SDK).package-lock.jsondiffers frommainonly by those two root entries.🤖 Generated with Claude Code