diff --git a/README.md b/README.md index 027f60d7..8588d1d5 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,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 with the same draft recover that saved page. A changed draft needs a new review. 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. @@ -197,6 +201,7 @@ CopilotKit SDK telemetry collects usage metadata separately from conversation pe | 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..83c46e34 --- /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 server stores the exact connection, tool and arguments, and the Dot shows an approval card in chat with a summary and those stored arguments. The action runs only when you select **Approve & run**, through an owner-only server route. That route runs the stored request, never arguments sent with the approval, and only if the conversation's Dot still has that exact tool enabled. Requests expire after an hour. Each approval runs at most once. Reopening the conversation shows the saved result, or keeps checking while the action is still running. A saved result is only ever shown for the approval that produced it. + +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 b326fc02..8ec3ce3d 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'; @@ -180,6 +185,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 ?? []) : [], ); @@ -221,7 +237,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..3de99dc8 --- /dev/null +++ b/src/client/ConnectionActionCard.tsx @@ -0,0 +1,244 @@ +import { useEffect, useRef, useState } from 'react'; +import { Check, PlugZap } from 'lucide-react'; +import { + connectionActionSchema, + type ConnectionActionResult, + type PendingApproval, +} from '../shared/connection-types'; +import { api } from './api'; +import { computerToolResult } from './ComputerToolCard'; +type Receipt = { + approvalId: string | null; + status: 'running' | 'done'; + result: ConnectionActionResult | null; +}; +const conversation = (threadId: string) => + `/conversations/${encodeURIComponent(threadId)}`; +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 [approval, setApproval] = useState(); + const [approvalError, setApprovalError] = useState(''); + 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 approvalId = action.success ? action.data.approvalId : ''; + // Show the server's record of what will run, not the model's description. + useEffect(() => { + if (!approvalId) return; + let active = true; + setApprovalError(''); + void api( + `${conversation(threadId)}/connection-approvals/${encodeURIComponent(approvalId)}`, + ) + .then((value) => active && setApproval(value)) + .catch( + (cause) => + active && + setApprovalError( + cause instanceof Error + ? cause.message + : 'Could not load this request.', + ), + ); + return () => { + active = false; + }; + }, [threadId, approvalId]); + // A saved result counts only if it came from this card's approval. + const mismatch = !!receipt && receipt.approvalId !== approvalId; + const own = mismatch ? null : receipt; + // A receipt that is still running is checked until the server finishes. + const running = own?.status === 'running'; + useEffect(() => { + let active = true; + const load = () => + api( + `${conversation(threadId)}/connection-actions/${encodeURIComponent(toolCallId)}`, + ) + .then((value) => active && setReceipt(value)) + .catch((cause) => { + if (active) + setError( + cause instanceof Error + ? cause.message + : 'Could not check this action.', + ); + }); + setError(''); + void load(); + const timer = running ? setInterval(() => void load(), 2000) : undefined; + return () => { + active = false; + clearInterval(timer); + }; + }, [threadId, toolCallId, attempt, running]); + const outcome = own?.result ?? null; + const approved = recorded.approved === true || own?.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( + `${conversation(threadId)}/connection-actions`, + 'POST', + { toolCallId, approvalId: action.data.approvalId }, + )); + setReceipt({ approvalId, 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 = approval ? Object.entries(approval.arguments) : []; + return ( +
+
+ + + {approval + ? `${approval.connection} · ${approval.title}` + : 'Connected service'} + + + {approved + ? outcome?.isError + ? 'Failed' + : 'Approved' + : declined + ? 'Declined' + : running + ? 'Running' + : 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)}
+
+ ))} +
+ )} + {mismatch && ( +
+ Cannot run +
+              This card’s saved result belongs to a different approval request.
+              Nothing was run for this one.
+            
+
+ )} + {approvalError && !outcome && ( +
+ Cannot run +
{approvalError}
+
+ )} + {outcome && ( +
+ + {outcome.isError ? 'Service error' : 'Service response'} + +
{outcome.text}
+
+ )} +
+ {error &&

{error}

} +
+ {!ready && error && ( + + )} + {!finished && respond && ready && !running && !mismatch && ( + <> + + {!outcome && ( + + )} + + )} + + {running + ? 'This action is still running on the server.' + : approved || declined || finished + ? '' + : 'Nothing runs until you approve. These are the exact arguments.'} + +
+
+ ); +} 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 dd3f0fe2..2cdb2f2e 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' && ( <>