From 8a5ea67b6a5fea4f4519f29519fcc999252d5c77 Mon Sep 17 00:00:00 2001 From: jibraaan Date: Wed, 7 Oct 2026 13:55:18 +0500 Subject: [PATCH] feat: sign in to OAuth-protected MCP servers Follow-up to #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 --- .env.example | 3 + docs/CONNECTIONS.md | 15 ++- package-lock.json | 2 + package.json | 2 + src/client/ConnectionsSection.tsx | 98 ++++++++++++++- src/client/style.css | 17 +++ src/server/app.ts | 11 +- src/server/connection-oauth.ts | 78 ++++++++++++ src/server/connection-routes.ts | 65 ++++++++++ src/server/connection-store.ts | 98 ++++++++++++++- src/server/connections.ts | 149 ++++++++++++++++++----- src/server/index.ts | 2 + src/server/platform-config.ts | 18 +++ src/shared/connection-types.ts | 3 + tests/connection-oauth.test.ts | 187 +++++++++++++++++++++++++++++ tests/fixtures/oauth-mcp-server.ts | 120 ++++++++++++++++++ vite.config.ts | 5 +- 17 files changed, 834 insertions(+), 39 deletions(-) create mode 100644 src/server/connection-oauth.ts create mode 100644 tests/connection-oauth.test.ts create mode 100644 tests/fixtures/oauth-mcp-server.ts diff --git a/.env.example b/.env.example index 702dc04e..5f55b84e 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,9 @@ OWNER_ID=opendots-owner # APP_ORIGIN=http://localhost:5173,http://127.0.0.1:5173 # Required for an external HOST binding (24+ characters); enables local login too. # OWNER_TOKEN= +# Public address of this server, for MCP sign-in callbacks behind a proxy or +# on a hosted domain (PUBLIC_URL/oauth/mcp/callback). Defaults to the browser's. +# PUBLIC_URL=https://dots.example.com # `copilotkit project select` or `local connect` writes CPK_INTELLIGENCE_API_KEY. # The server accepts either name and prefers a non-empty CPK_INTELLIGENCE_API_KEY. diff --git a/docs/CONNECTIONS.md b/docs/CONNECTIONS.md index 83c46e34..cf02ba45 100644 --- a/docs/CONNECTIONS.md +++ b/docs/CONNECTIONS.md @@ -10,6 +10,18 @@ Connections give a Dot tools from remote [MCP](https://modelcontextprotocol.io) Use **Refresh** after the server adds or changes tools. Your choices for existing tools are kept. +## Signing in (OAuth) + +Many services ask you to sign in with your account instead of pasting a token. Add the server without a token. If it requires sign-in, the connection shows **needs sign-in**. + +1. Select **Sign in**. A new tab opens the service's sign-in and consent page. +2. Approve access. The tab returns to OpenDots and says you're signed in. +3. The settings update on their own and list the service's tools. + +OpenDots follows the MCP authorization spec through the official SDK: discovery, dynamic client registration, PKCE and refresh tokens. Access tokens refresh automatically. If the service stops accepting them, the connection asks you to sign in again, and its tools are hidden from the Dot until you do. **Sign out** forgets the tokens and stops the Dot's active turn. + +Set `PUBLIC_URL` when OpenDots runs behind a proxy or on a hosted domain. The service sends you back to `PUBLIC_URL/oauth/mcp/callback`; without `PUBLIC_URL`, OpenDots uses the address your browser is on. A sign-in link works once and expires after 10 minutes. + ## Approvals Every tool starts enabled. A tool the server marks as read-only (`readOnlyHint`) runs on its own. Every other tool starts with **Ask first** on. @@ -24,7 +36,6 @@ Changing a Dot's connections or tool settings stops that Dot's active turn. ## Security notes -- Tokens are stored in the server's SQLite database and are never sent to the browser. Protect `DATABASE_PATH` the way you protect `.env`. +- Tokens, OAuth tokens and OAuth client registrations are stored in the server's SQLite database and are never sent to the browser. Protect `DATABASE_PATH` the way you protect `.env`. - Tool results are passed to the model as untrusted data. - Endpoints must use `http` or `https` and cannot contain credentials in the URL. Local addresses are allowed, so you can run MCP servers on the same machine. Only add servers you trust. -- OAuth-only servers are not supported yet. Use a server that accepts a bearer token, or put a token-authenticated proxy in front of it. diff --git a/package-lock.json b/package-lock.json index 05d28509..43f83753 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@types/express": "^5.0.6", "@types/node": "^26.6.3", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", @@ -50,6 +51,7 @@ "cross-env": "^10.1.0", "eslint": "^10.11.0", "eslint-plugin-react-hooks": "^7.1.1", + "express": "^5.2.1", "postcss": "^8.5.28", "prettier": "^3.9.9", "react-test-renderer": "^19.3.0", diff --git a/package.json b/package.json index 308d87c7..29cc02a0 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@types/express": "^5.0.6", "@types/node": "^26.6.3", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", @@ -65,6 +66,7 @@ "cross-env": "^10.1.0", "eslint": "^10.11.0", "eslint-plugin-react-hooks": "^7.1.1", + "express": "^5.2.1", "postcss": "^8.5.28", "prettier": "^3.9.9", "react-test-renderer": "^19.3.0", diff --git a/src/client/ConnectionsSection.tsx b/src/client/ConnectionsSection.tsx index 9b4c9234..f72bf3cd 100644 --- a/src/client/ConnectionsSection.tsx +++ b/src/client/ConnectionsSection.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, type KeyboardEvent } from 'react'; -import { PlugZap, RefreshCw, Trash2 } from 'lucide-react'; +import { LogIn, PlugZap, RefreshCw, Trash2 } from 'lucide-react'; import type { Connection } from '../shared/connection-types'; import { api } from './api'; // Lives inside the Dot form, so it saves immediately through its own @@ -14,6 +14,29 @@ export function ConnectionsSection({ dotId }: { dotId: string }) { const [token, setToken] = useState(''); const [busy, setBusy] = useState(''); const [error, setError] = useState(''); + const [waiting, setWaiting] = useState(); + const [signInUrl, setSignInUrl] = useState(); + // While the owner signs in in another tab, watch for the callback to land. + useEffect(() => { + if (!waiting) return; + const started = Date.now(); + const timer = setInterval(() => { + if (Date.now() - started > 5 * 60_000) { + setWaiting(undefined); + return; + } + void api(`/dots/${encodeURIComponent(dotId)}/connections`) + .then((list) => { + setConnections(list); + if (list.find((item) => item.id === waiting)?.signedIn) { + setWaiting(undefined); + setSignInUrl(undefined); + } + }) + .catch(() => {}); + }, 2000); + return () => clearInterval(timer); + }, [waiting, dotId]); useEffect(() => { let active = true; void api(`/dots/${encodeURIComponent(dotId)}/connections`) @@ -46,6 +69,28 @@ export function ConnectionsSection({ dotId }: { dotId: string }) { setConnections((list) => list?.map((item) => (item.id === next.id ? next : item)), ); + const signIn = async (connection: Connection) => { + // Open the tab during the click so popup blockers allow it. + const tab = window.open('about:blank', '_blank'); + const result = await run(connection.id, () => + api<{ authorizationUrl?: string; connection?: Connection }>( + `/connections/${connection.id}/sign-in`, + 'POST', + {}, + ), + ); + if (!result || result.connection) { + tab?.close(); + if (result?.connection) replace(result.connection); + return; + } + const url = result.authorizationUrl!; + if (tab) { + tab.opener = null; + tab.location.href = url; + } else setSignInUrl(url); + setWaiting(connection.id); + }; const add = async () => { const created = await run('add', () => api( @@ -69,8 +114,8 @@ export function ConnectionsSection({ dotId }: { dotId: string }) { Connections

Give this Dot tools from MCP servers. Read-only tools run on their own; - anything else asks you in chat before it runs. Tokens stay on the - server. + anything else asks you in chat before it runs. If a server needs an + account, leave the token empty and sign in. Tokens stay on the server.

{connections?.map((connection) => (
@@ -81,8 +126,41 @@ export function ConnectionsSection({ dotId }: { dotId: string }) { {new URL(connection.url).host} {connection.hasToken ? ' · token saved' : ''} + {connection.authMode === 'oauth' + ? connection.signedIn + ? ' · signed in' + : waiting === connection.id + ? ' · waiting for sign-in…' + : ' · needs sign-in' + : ''} + {connection.authMode === 'oauth' && ( + + )}