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' && ( + + )}