Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 13 additions & 2 deletions docs/CONNECTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
2 changes: 2 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
98 changes: 94 additions & 4 deletions src/client/ConnectionsSection.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<string>();
const [signInUrl, setSignInUrl] = useState<string>();
// 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<Connection[]>(`/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<Connection[]>(`/dots/${encodeURIComponent(dotId)}/connections`)
Expand Down Expand Up @@ -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<Connection>(
Expand All @@ -69,8 +114,8 @@ export function ConnectionsSection({ dotId }: { dotId: string }) {
<legend>Connections</legend>
<p className="muted">
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.
</p>
{connections?.map((connection) => (
<div className="connection" key={connection.id}>
Expand All @@ -81,8 +126,41 @@ export function ConnectionsSection({ dotId }: { dotId: string }) {
<small>
{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'
: ''}
</small>
</span>
{connection.authMode === 'oauth' && (
<button
type="button"
className="connection-signin"
disabled={!!busy}
onClick={async () => {
if (!connection.signedIn) return void signIn(connection);
const next = await run(connection.id, () =>
api<Connection>(
`/connections/${connection.id}/sign-out`,
'POST',
{},
),
);
if (next) replace(next);
}}
>
{connection.signedIn ? (
'Sign out'
) : (
<>
<LogIn size={13} /> Sign in
</>
)}
</button>
)}
<button
type="button"
className="icon-button"
Expand Down Expand Up @@ -126,8 +204,20 @@ export function ConnectionsSection({ dotId }: { dotId: string }) {
{connection.error}
</p>
)}
{waiting === connection.id && signInUrl && (
<p className="muted">
Your browser blocked the sign-in tab.{' '}
<a href={signInUrl} target="_blank" rel="noreferrer noopener">
Open sign-in
</a>
</p>
)}
{!connection.tools.length && (
<p className="muted">This server offers no tools.</p>
<p className="muted">
{connection.authMode === 'oauth' && !connection.signedIn
? 'Sign in to see and use this service’s tools.'
: 'This server offers no tools.'}
</p>
)}
<ul className="connection-tools">
{connection.tools.map((tool) => {
Expand Down
17 changes: 17 additions & 0 deletions src/client/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -4069,6 +4069,23 @@ h3 {
font-size: 11px;
}

.connection-signin {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 10px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
font-size: 11px;
white-space: nowrap;
cursor: pointer;
}
.connection-signin:disabled {
opacity: 0.5;
cursor: default;
}

/* Polish: one ink accent everywhere, readable secondary text (WCAG AA), and
disabled controls that look disabled. Later compact-chrome layers moved
the app to ink; these older lavender surfaces now follow it. */
Expand Down
11 changes: 9 additions & 2 deletions src/server/app.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { computerRoutes } from './computer-routes.js';
import { connectionRoutes } from './connection-routes.js';
import { connectionRoutes, oauthCallbackRoute } from './connection-routes.js';
import { OAUTH_CALLBACK_PATH } from './connection-oauth.js';
import { Hono } from 'hono';
import { bodyLimit } from 'hono/body-limit';
import { timingSafeEqual } from 'node:crypto';
Expand Down Expand Up @@ -94,8 +95,14 @@ export function createApp({
if (platform)
app.route(
'/api',
connectionRoutes(platform.workspace, platform.connections),
connectionRoutes(
platform.workspace,
platform.connections,
platform.config.publicUrl,
),
);
if (platform)
app.route(OAUTH_CALLBACK_PATH, oauthCallbackRoute(platform.connections));
const voice = platform ? new VoiceService(platform) : undefined;
if (platform && voice) app.route('/api', workspaceRoutes(platform, voice));
app.get('/api/state', (c) =>
Expand Down
78 changes: 78 additions & 0 deletions src/server/connection-oauth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { randomBytes } from 'node:crypto';
import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js';
import type {
OAuthClientInformationMixed,
OAuthClientMetadata,
OAuthTokens,
} from '@modelcontextprotocol/sdk/shared/auth.js';
import type { ConnectionStore } from './connection-store.js';
export const OAUTH_CALLBACK_PATH = '/oauth/mcp/callback';
const STATE_TTL_MS = 10 * 60_000;
// Persists the MCP SDK's OAuth client state (registration, PKCE verifier,
// tokens, discovery) for one connection. The SDK runs discovery, dynamic
// client registration, PKCE, code exchange, and refresh; OpenDots stores the
// results and hands the authorization URL to the owner's browser.
export class StoredOAuthProvider implements OAuthClientProvider {
authorizationUrl?: URL;
constructor(
private store: ConnectionStore,
private id: string,
// Only an owner-started sign-in may create a pending state; background
// refreshes during tool calls must not replace it.
private interactive = false,
) {}
get redirectUrl() {
return this.store.oauth(this.id).redirectUrl ?? undefined;
}
get clientMetadata(): OAuthClientMetadata {
return {
client_name: 'OpenDots',
redirect_uris: this.redirectUrl ? [this.redirectUrl] : [],
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
token_endpoint_auth_method: 'none',
};
}
state() {
const state = randomBytes(24).toString('base64url');
if (this.interactive)
this.store.setState(this.id, state, Date.now() + STATE_TTL_MS);
return state;
}
clientInformation() {
return this.store.oauth(this.id).client as
OAuthClientInformationMixed | undefined;
}
saveClientInformation(client: OAuthClientInformationMixed) {
this.store.saveOAuth(this.id, { client });
}
tokens() {
return this.store.oauth(this.id).tokens as OAuthTokens | undefined;
}
saveTokens(tokens: OAuthTokens) {
this.store.saveOAuth(this.id, { tokens });
}
redirectToAuthorization(url: URL) {
this.authorizationUrl = url;
}
saveCodeVerifier(verifier: string) {
this.store.saveOAuth(this.id, { verifier });
}
codeVerifier() {
const verifier = this.store.oauth(this.id).verifier;
if (!verifier) throw new Error('No sign-in is in progress.');
return verifier;
}
invalidateCredentials(
scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery',
) {
this.store.saveOAuth(this.id, {
...(scope === 'all' || scope === 'client' ? { client: undefined } : {}),
...(scope === 'all' || scope === 'tokens' ? { tokens: undefined } : {}),
...(scope === 'all' || scope === 'verifier' ? { verifier: null } : {}),
...(scope === 'all' || scope === 'discovery'
? { discovery: undefined }
: {}),
});
}
}
Loading
Loading