From 377626b73003ffa72348b3e783f873713113ccd5 Mon Sep 17 00:00:00 2001 From: jibraaan Date: Sun, 4 Oct 2026 03:41:37 +0500 Subject: [PATCH 1/4] feat: per-Dot MCP connections with owner approval Dots can now use tools from remote MCP servers (Streamable HTTP, optional bearer token). Each Dot's settings list its connections and tools; each tool can be enabled or disabled and set to "Ask first". - Read-only tools (readOnlyHint) run directly. Every other tool starts as "Ask first": the model gets approval_required and must raise an in-chat approval card. Only the owner's approval route executes the call, after re-checking the conversation's Dot still has the tool, and each approval runs at most once with a restorable receipt. - Slack and headless runs cannot approve, so gated tools tell the Dot to continue in the web app. - Changing a Dot's connections aborts its active turn. - Tokens stay server-side; results are bounded and treated as untrusted. Co-Authored-By: Claude Opus 5.5 --- README.md | 5 + docs/CONNECTIONS.md | 30 +++ src/client/Chat.tsx | 19 +- src/client/ConnectionActionCard.tsx | 203 +++++++++++++++++++ src/client/ConnectionsSection.tsx | 228 +++++++++++++++++++++ src/client/WorkspaceDialog.tsx | 4 + src/client/style.css | 137 +++++++++++++ src/server/app.ts | 6 + src/server/connection-routes.ts | 111 ++++++++++ src/server/connection-store.ts | 135 +++++++++++++ src/server/connection-tools.ts | 45 +++++ src/server/connections.ts | 273 +++++++++++++++++++++++++ src/server/dot-agent.ts | 39 +++- src/server/platform.ts | 3 + src/server/workspace.ts | 3 + src/shared/connection-types.ts | 39 ++++ tests/connection-card.test.tsx | 24 +++ tests/connections.test.ts | 302 ++++++++++++++++++++++++++++ tests/tanstack-agent.test.ts | 54 +++++ 19 files changed, 1654 insertions(+), 6 deletions(-) create mode 100644 docs/CONNECTIONS.md create mode 100644 src/client/ConnectionActionCard.tsx create mode 100644 src/client/ConnectionsSection.tsx create mode 100644 src/server/connection-routes.ts create mode 100644 src/server/connection-store.ts create mode 100644 src/server/connection-tools.ts create mode 100644 src/server/connections.ts create mode 100644 src/shared/connection-types.ts create mode 100644 tests/connection-card.test.tsx create mode 100644 tests/connections.test.ts diff --git a/README.md b/README.md index 49dfde40..8be2c573 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,10 @@ _Ask Scout to open a website, summarize it, save notes, and verify the file. Eve Ask a Dot to show a draft before saving it. A CopilotKit human-in-the-loop card pauses the conversation for **Approve & save** or **Decline**. Approval creates the page in an authorized Space and returns a link; retries recover the same saved page. The agent continues after your decision. +### Connections + +Give a Dot tools from any MCP server, such as email, calendar, GitHub, or your own services. Read-only tools run on their own. Any other tool pauses for an **Approve & run** card in chat, and only your approval runs it. You can turn each tool on or off per Dot. See [Connections](docs/CONNECTIONS.md). + ### Text and calls A continuous conversation keeps the Dot's avatar and status above the messages, with text and call controls close at hand. Work updates, source links, and call receipts appear in the timeline; a side panel shows results or the agent's computer. @@ -176,6 +180,7 @@ See [Setup](docs/SETUP.md) for configuration, Slack, calls, the browser service, | Background work | Scheduled server-side turns in their original conversation, with pause and retry controls | | Browser | Separate read-only public-page service with page capture and navigation limits | | Dot computers | Per-Dot browser profiles, files, shell, takeover, permissions, and action records through OpenBot | +| Connections | Per-Dot MCP servers, per-tool access, and owner approval for non-read-only actions | | Memory | User-managed preferences that permitted Dots can use | | Automatic Learning | Per-Dot Learning containers, conversation evidence routing, and published-skill delivery; see [setup](docs/SETUP.md#automatic-learning) | | Deployment | Local Node setup and separate application/browser containers | diff --git a/docs/CONNECTIONS.md b/docs/CONNECTIONS.md new file mode 100644 index 00000000..50218c40 --- /dev/null +++ b/docs/CONNECTIONS.md @@ -0,0 +1,30 @@ +# Connections + +Connections give a Dot tools from remote [MCP](https://modelcontextprotocol.io) servers: email, calendars, issue trackers, notes, or your own services. Each connection belongs to one Dot. + +## Add a connection + +1. Open a Dot's settings (**Edit specialist**). +2. Under **Connections**, enter a name, the server's Streamable HTTP endpoint (for example `https://example.com/mcp`), and an optional bearer token. +3. Select **Connect**. OpenDots lists the server's tools and saves them. + +Use **Refresh** after the server adds or changes tools. Your choices for existing tools are kept. + +## 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. + +When a Dot calls an **Ask first** tool, the tool does not run. The Dot shows an approval card in chat with a summary and the exact arguments. The action runs only when you select **Approve & run**, through an owner-only server route. That route checks that the conversation's Dot still has the tool enabled. Each approval runs at most once, and reopening the conversation shows the saved result. + +The read-only hint comes from the server, so it is only a hint. Turn on **Ask first** for any tool you do not fully trust, and turn off tools a Dot does not need. + +Approval cards appear only in the web app. Through Slack or in scheduled runs, an **Ask first** tool tells the Dot to ask you to continue in the web app. + +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`. +- 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/src/client/Chat.tsx b/src/client/Chat.tsx index 4797aaf3..d59e6772 100644 --- a/src/client/Chat.tsx +++ b/src/client/Chat.tsx @@ -1,5 +1,10 @@ import { PageReviewCard } from './PageReviewCard'; import { pageReviewSchema, pageReviewTool } from '../shared/page-review'; +import { + connectionActionSchema, + connectionActionTool, +} from '../shared/connection-types'; +import { ConnectionActionCard } from './ConnectionActionCard'; import { contextualMessage, type PageContext } from './page-context'; import { api } from './api'; import type { Page } from '../server/pages'; @@ -178,6 +183,17 @@ export function Chat({ }, [thread.id, onSaved], ); + useHumanInTheLoop( + { + name: connectionActionTool.name, + description: connectionActionTool.description, + parameters: connectionActionSchema, + render: (props) => ( + + ), + }, + [thread.id], + ); const computerCalls = agent.messages.flatMap((message) => message.role === 'assistant' ? (message.toolCalls ?? []) : [], ); @@ -219,7 +235,8 @@ export function Chat({ message.toolCalls?.some( (call) => call.function.name.startsWith('computer_') || - call.function.name === pageReviewTool.name, + call.function.name === pageReviewTool.name || + call.function.name === connectionActionTool.name, ))), ); return ( diff --git a/src/client/ConnectionActionCard.tsx b/src/client/ConnectionActionCard.tsx new file mode 100644 index 00000000..ff785f27 --- /dev/null +++ b/src/client/ConnectionActionCard.tsx @@ -0,0 +1,203 @@ +import { useEffect, useRef, useState } from 'react'; +import { Check, PlugZap } from 'lucide-react'; +import { + connectionActionSchema, + type ConnectionActionResult, +} from '../shared/connection-types'; +import { api } from './api'; +import { computerToolResult } from './ComputerToolCard'; +type Receipt = { + status: 'running' | 'done'; + result: ConnectionActionResult | null; +}; +const actions = (threadId: string) => + `/conversations/${encodeURIComponent(threadId)}/connection-actions`; +const display = (value: unknown) => + typeof value === 'string' ? value : JSON.stringify(value, null, 2); +export function ConnectionActionCard({ + args, + status, + result, + respond, + threadId, + toolCallId, +}: { + args: unknown; + status: string; + result?: unknown; + respond?: (result: unknown) => Promise; + threadId: string; + toolCallId: string; +}) { + const action = connectionActionSchema.safeParse(args); + const recorded = computerToolResult(result); + const [label, setLabel] = useState<{ connection: string; title: string }>(); + const [receipt, setReceipt] = useState(); + const [error, setError] = useState(''); + const [busy, setBusy] = useState(false); + const [attempt, setAttempt] = useState(0); + const pending = useRef(false); + const finished = status === 'complete'; + const tool = action.success ? action.data.tool : ''; + useEffect(() => { + if (!tool) return; + let active = true; + void api<{ connection: string; title: string }>( + `/conversations/${encodeURIComponent(threadId)}/connection-tools/${encodeURIComponent(tool)}`, + ) + .then((value) => active && setLabel(value)) + .catch(() => {}); + return () => { + active = false; + }; + }, [threadId, tool]); + useEffect(() => { + let active = true; + setError(''); + void api( + `${actions(threadId)}/${encodeURIComponent(toolCallId)}`, + ) + .then((value) => active && setReceipt(value)) + .catch((cause) => { + if (active) + setError( + cause instanceof Error + ? cause.message + : 'Could not check this action.', + ); + }); + return () => { + active = false; + }; + }, [threadId, toolCallId, attempt]); + const outcome = receipt?.result ?? null; + const approved = recorded.approved === true || receipt?.status === 'done'; + const declined = recorded.approved === false; + const ready = receipt !== undefined; + const decide = async (approve: boolean) => { + if (!respond || !action.success || pending.current) return; + pending.current = true; + setBusy(true); + setError(''); + try { + if (!approve && !outcome) { + await respond({ + approved: false, + message: + 'The owner declined this action. Do not perform it or try another way.', + }); + return; + } + // A previous approval may have run even if its response never arrived. + const value = + outcome ?? + (await api(actions(threadId), 'POST', { + toolCallId, + tool: action.data.tool, + arguments: action.data.arguments, + })); + setReceipt({ status: 'done', result: value }); + await respond({ approved: true, ...value }); + } catch (cause) { + setError( + cause instanceof Error ? cause.message : 'Could not run this action.', + ); + } finally { + pending.current = false; + setBusy(false); + } + }; + const entries = action.success ? Object.entries(action.data.arguments) : []; + return ( +
+
+ + + {label ? `${label.connection} · ${label.title}` : tool || 'Action'} + + + {approved + ? outcome?.isError + ? 'Failed' + : 'Approved' + : declined + ? 'Declined' + : finished + ? 'Ended' + : !ready + ? 'Checking' + : 'Needs your approval'} + +
+
+

+ {action.success ? action.data.summary : 'Preparing the action…'} +

+ {entries.length > 0 && ( +
+ {entries.map(([key, value]) => ( +
+
{key}
+
{display(value)}
+
+ ))} +
+ )} + {outcome && ( +
+ + {outcome.isError ? 'Service error' : 'Service response'} + +
{outcome.text}
+
+ )} +
+ {error &&

{error}

} +
+ {!ready && error && ( + + )} + {!finished && respond && ready && receipt?.status !== 'running' && ( + <> + + {!outcome && ( + + )} + + )} + + {receipt?.status === 'running' + ? 'This action is still running on the server.' + : approved || declined || finished + ? '' + : 'Nothing runs until you approve.'} + +
+
+ ); +} diff --git a/src/client/ConnectionsSection.tsx b/src/client/ConnectionsSection.tsx new file mode 100644 index 00000000..9b4c9234 --- /dev/null +++ b/src/client/ConnectionsSection.tsx @@ -0,0 +1,228 @@ +import { useEffect, useState, type KeyboardEvent } from 'react'; +import { 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 +// requests and keeps Enter from submitting the surrounding form. +const stayInSection = (event: KeyboardEvent) => { + if (event.key === 'Enter') event.preventDefault(); +}; +export function ConnectionsSection({ dotId }: { dotId: string }) { + const [connections, setConnections] = useState(); + const [name, setName] = useState(''); + const [url, setUrl] = useState(''); + const [token, setToken] = useState(''); + const [busy, setBusy] = useState(''); + const [error, setError] = useState(''); + useEffect(() => { + let active = true; + void api(`/dots/${encodeURIComponent(dotId)}/connections`) + .then((value) => active && setConnections(value)) + .catch( + (cause) => + active && + setError( + cause instanceof Error + ? cause.message + : 'Could not load connections.', + ), + ); + return () => { + active = false; + }; + }, [dotId]); + const run = async (key: string, request: () => Promise) => { + setBusy(key); + setError(''); + try { + return await request(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : 'Request failed.'); + } finally { + setBusy(''); + } + }; + const replace = (next: Connection) => + setConnections((list) => + list?.map((item) => (item.id === next.id ? next : item)), + ); + const add = async () => { + const created = await run('add', () => + api( + `/dots/${encodeURIComponent(dotId)}/connections`, + 'POST', + { + name: name.trim(), + url: url.trim(), + ...(token.trim() ? { token: token.trim() } : {}), + }, + ), + ); + if (!created) return; + setConnections((list) => [...(list ?? []), created]); + setName(''); + setUrl(''); + setToken(''); + }; + return ( +
+ 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. +

+ {connections?.map((connection) => ( +
+
+ + + {connection.name} + + {new URL(connection.url).host} + {connection.hasToken ? ' · token saved' : ''} + + + + +
+ {connection.error && ( +

+ {connection.error} +

+ )} + {!connection.tools.length && ( +

This server offers no tools.

+ )} +
    + {connection.tools.map((tool) => { + const patch = async (value: { + enabled?: boolean; + requiresApproval?: boolean; + }) => { + const next = await run(connection.id, () => + api( + `/connections/${connection.id}/tools/${encodeURIComponent(tool.name)}`, + 'PATCH', + value, + ), + ); + if (next) replace(next); + }; + return ( +
  • + + +
  • + ); + })} +
+
+ ))} +
+ + setName(event.target.value)} + /> + setUrl(event.target.value)} + /> + setToken(event.target.value)} + /> + +
+ {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/src/client/WorkspaceDialog.tsx b/src/client/WorkspaceDialog.tsx index 234fa4d2..9ec9f555 100644 --- a/src/client/WorkspaceDialog.tsx +++ b/src/client/WorkspaceDialog.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import { X } from 'lucide-react'; import type { Dot, Memory, State, WorkspaceState } from '../shared/types'; +import { ConnectionsSection } from './ConnectionsSection'; export type Dialog = | { type: 'space' } | { type: 'dot'; dot?: Dot; spaceId: string } @@ -337,6 +338,9 @@ export function WorkspaceDialog({ )} + {dialog.type === 'dot' && dialog.dot && ( + + )} {dialog.type === 'schedule' && ( <>