Skip to content

feat: sign in to OAuth-protected MCP servers - #117

Open
jibraaan wants to merge 1 commit into
CopilotKit:mainfrom
jibraaan:pr/mcp-oauth
Open

jibraaan wants to merge 1 commit into
CopilotKit:mainfrom
jibraaan:pr/mcp-oauth

Conversation

@jibraaan

@jibraaan jibraaan commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

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:

  1. Sign in opens the service's sign-in and consent page in a new tab.
  2. After approval, the service redirects back to OpenDots, which shows "Signed in to …".
  3. The Dot settings update on their own and list the service's tools. Everything from feat: per-Dot MCP connections with owner approval for non-read-only tools #52 then applies: per-tool access, Ask first, and the stored-request approvals.

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

  • The official MCP SDK runs the spec flow: protected-resource and authorization-server discovery, dynamic client registration, PKCE, code exchange and refresh. StoredOAuthProvider (src/server/connection-oauth.ts) persists the SDK's state per connection in mcp_oauth, and the transport gets it as authProvider.
  • POST /api/connections/:id/sign-in (owner-only) returns the authorization URL, and only http(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/callback sits outside /api, because a browser redirect can't carry the owner token. A single-use state that 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.
  • Tokens and client registrations stay server-side and are never returned to the browser. The UI only sees authMode and signedIn.
  • New, optional 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 /oauth to the API server.

Verification

  • npm run check-format, lint, typecheck, test (306 passing) and build all pass.
  • tests/connection-oauth.test.ts runs a real OAuth-protected MCP server: the SDK's mcpAuthRouter and bearer middleware, plus a demo provider extended with refresh tokens and a 401 invalid_token for expired tokens. It covers:
    • full sign-in through the consent redirect
    • tools discovered after sign-in
    • tokens never returned
    • a tool call, then a silent refresh after access tokens expire
    • replayed, forged and denied callbacks
    • escaping on the callback page
    • sign-out hiding tools and prompting a new sign-in
    • PUBLIC_URL validation
  • Live, in the app on this branch, against that server run locally: add, sign in, consent, callback ("Signed in to Calendar"), then settings showing "signed in" with the tool listed, with PUBLIC_URL set. Earlier testing in my fork also covered the popup-blocked fallback link.
  • Dependencies: express and @types/express are dev dependencies for the test fixture (express was already installed via the MCP SDK). package-lock.json differs from main only by those two root entries.
  • Not covered: a real third-party OAuth provider's consent screen, since none was available here. The flow follows the MCP authorization spec through the SDK.

🤖 Generated with Claude Code

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 paoValle left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/server/connections.ts
);
} catch (error) {
return this.store.setError(id, failure(error));
return this.store.setError(id, failure(error, connection.authMode));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

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.

2 participants