Skip to content

feat: per-Dot MCP connections with owner approval for non-read-only tools - #52

Merged
jerelvelarde merged 7 commits into
CopilotKit:mainfrom
jibraaan:pr/mcp-connections
Oct 6, 2026
Merged

jerelvelarde merged 7 commits into
CopilotKit:mainfrom
jibraaan:pr/mcp-connections

Conversation

@jibraaan

@jibraaan jibraaan commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

Workflow

A Dot can use tools from remote MCP servers, such as email, calendars, issue trackers, notes or the owner's own services. Read-only tools run on their own. Any tool that changes something waits for the owner to approve it in chat.

  1. In a Dot's settings (Edit specialist), a new Connections section lets the owner add an MCP server: name, Streamable HTTP URL and an optional bearer token. OpenDots lists its tools.
  2. Each tool has an enabled switch and an Ask first switch. Tools the server marks readOnlyHint start with Ask first off. Every other tool starts with it on.
  3. In chat, the Dot calls read-only tools directly. For an Ask first tool, the tool returns approval_required. The Dot then raises a request_connection_action human-in-the-loop card showing the summary and the exact arguments, and Approve & run executes the call.

Design notes for review

  • The model never executes a gated tool. The only path that runs one is the owner route POST /api/conversations/:id/connection-actions. It checks that the conversation's Dot currently has that tool enabled, claims (threadId, toolCallId) once, and stores the result. Retries and double clicks return the stored result, and reopening the conversation restores the card from it. This mirrors the existing review_space_page flow.
  • Slack and headless runs (scheduled tasks, voice compute) cannot show the card. There, gated tools return unavailable and the Dot is told to continue in the web app.
  • Changing a Dot's connections or tool settings aborts its active turn. This works the same way as the existing permission watcher.
  • Tokens are stored server-side in SQLite and never returned to the browser (hasToken only). Tool results are capped at 20k characters, and the system prompt marks them as untrusted.
  • Model-facing tool names are derived deterministically (<connection>__<tool>), sanitized to ^[a-zA-Z0-9_-]{1,64}$ and de-duplicated.
  • Endpoints must be http(s) without embedded credentials. Local addresses are allowed on purpose, so the owner can run MCP servers on the same machine. This is noted in the docs.
  • Uses the existing @modelcontextprotocol/sdk dependency. No new dependencies.
  • Not included: OAuth-only MCP servers (documented), and stdio servers.

Verification

  • npm run check-format, lint, typecheck, test (175 passing, 11 new) and build all pass.
  • tests/connections.test.ts runs a real McpServer over the SDK's in-memory transport. It covers:
    • discovery and default gating
    • that the token is never returned
    • URL validation
    • name sanitizing and collisions
    • read-only tools running while gated tools never reach the server handler
    • headless runs reporting unavailable
    • owner changes applying mid-turn
    • an approved action running exactly once and its result being restored
    • refusing tools from another Dot or a thread this owner doesn't own
    • refresh keeping owner choices, and reporting unreachable servers
    • result normalization
  • tests/tanstack-agent.test.ts checks that the model request includes connection tools, and includes the approval tool only when the web client offers it.
  • Live, against a local Streamable HTTP MCP server with bearer auth: connecting, tool discovery, settings surviving refresh, and clear errors for a bad token and an unreachable host.
  • UI checked at 375px width (no horizontal overflow) and with the keyboard (Enter in the add-connection fields doesn't submit the surrounding Dot form; tab order follows the visual order).
  • Not yet verified: a live model turn that raises and approves the in-chat card, because no Intelligence or model keys were available. The card's initial render is covered by tests/connection-card.test.tsx.

Docs: docs/CONNECTIONS.md, plus a README section and a row in the Features table.

🤖 Generated with Claude Code

jibraaan and others added 2 commits October 4, 2026 04:35
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 <noreply@anthropic.com>
The card imports api.ts, which reads sessionStorage at load. The test only
passed when another file had set one up first, so it failed when run
alone or in a different worker order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@jerelvelarde jerelvelarde left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MCP connections are a valuable template extension, but approval binding and permission races need fixing before merge. Existing owner/thread checks, token omission, headless gating, and intentional local-server support were reviewed. Validation: 175 tests, typecheck, lint, formatting, and the rerun production build passed. Both server findings were reproduced using SQLite and in-memory MCP transport.

Comment thread src/server/connections.ts Outdated
Comment thread src/server/connections.ts Outdated
Comment thread src/client/ConnectionActionCard.tsx Outdated
return () => {
active = false;
};
}, [threadId, toolCallId, attempt]);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Allow running receipts to recover without another reload. Reopening a conversation while an approved action is running fetches the receipt once. After the server finishes, this card stays in the running state because there is no polling or refresh action for a successful running response. Poll running receipts or expose a refresh control.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 68b527a. While a receipt is running, the card polls it every 2 seconds and stops once it completes. The approve controls stay hidden during that time, and the card says the action is still running on the server.

I haven't added a unit test for the polling, because these tests render the card statically and have no DOM environment.

jibraaan and others added 3 commits October 6, 2026 03:21
# Conflicts:
#	src/server/dot-agent.ts
#	tests/tanstack-agent.test.ts
…resh

Review fixes for the MCP connections PR.

- [P1] Approvals bind to a server-side record. A gated call stores its
  connection, real tool name, and arguments and returns an approvalId;
  request_connection_action carries only that id and a summary. The card
  shows the stored arguments, and the approval route runs exactly that
  record (never arguments sent with the approval), only from the same
  conversation, within an hour, and only if that exact tool is still
  enabled. Model-facing names are also assigned over all tools, so
  disabling one no longer moves its name to a look-alike.
- [P2] Refresh merges discovered metadata with the owner's settings as
  they are when discovery finishes, so a revocation or Ask first change
  made mid-refresh is kept.
- [P2] The approval card polls a running receipt until it completes.

Regression tests cover the look-alike collision (send mail! / send mail?),
argument injection, cross-thread and expired approvals, and the refresh
race (fails on the previous logic, passes now).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jibraaan

jibraaan commented Oct 6, 2026

Copy link
Copy Markdown
Contributor Author

@jerelvelarde Ready for another look whenever you have time. All three findings are addressed in 68b527a, with replies on each thread:

  • Approvals bind to a stored connection, tool and arguments.
  • Refresh keeps owner changes made mid-discovery.
  • Running receipts now poll until they finish.

The branch is merged with current main and conflict-free.

@jerelvelarde jerelvelarde left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The per-Dot MCP connections and owner approval flow fit the template well, and the previous tool-alias and refresh races are fixed. The 37 focused tests pass. One receipt-binding issue remains: a completed action can be presented as approval of a different stored request. Full formatting, lint, typecheck, 267 tests, build, and GitHub CI pass.

Comment thread src/server/connection-routes.ts Outdated
.parse(await c.req.json());
const thread = workspace.requireThread(c.req.param('id'));
const previous = connections.store.action(thread.id, body.toolCallId);
if (previous?.result) return c.json(previous.result);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Bind recovered action results to the stored approval

This returns the previous result using only threadId/toolCallId, without checking that it belongs to body.approvalId. After approving a request for Alice, posting the same toolCallId with a different approval for Bob returns Alice's success. The GET receipt also lacks approvalId, so the actual card displays Bob's arguments as Approved and Continue conversation responds approved:true with Alice's result. Store the approval identity in the action receipt and reject mismatched recovery in both the route and card; retries of the original approval should still recover its result.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in be38e2b.

  • Receipts: each saved result now records the approval that produced it (mcp_actions.approvalId, added with a migration).
  • Route: POST /connection-actions returns a saved result only when approvalId matches. A different approval on the same toolCallId gets a 409 ("This action belongs to a different approval request.") and nothing runs. Retrying the original approval still recovers its result.
  • Card: the GET receipt now includes approvalId. If it doesn't match the card's own approval, the card shows that the saved result belongs to a different request and offers no Approve or Continue.

Regression test: returns a saved result only for the approval that produced it. It reproduces your Alice/Bob case, and I checked that it fails without the new check. I also merged current main (291 tests, typecheck, lint, format and build pass). The card's mismatch path has no unit test, since these tests render it statically without a DOM environment.

jibraaan and others added 2 commits October 7, 2026 03:21
Review fix: a saved action result was recovered by thread and tool call
only, so posting the same toolCallId with a different approval returned
the first approval's result, and the card showed it as approving the
other request.

- Receipts store their approvalId (migrated column).
- The approval route returns a saved result only for the same approval;
  a different approval gets 409 and nothing runs. Retrying the original
  approval still recovers its result.
- The GET receipt includes approvalId; the card ignores a receipt from a
  different approval, says so, and offers no approve/continue.

Regression test reproduces the Alice/Bob case (fails without the check).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@jerelvelarde jerelvelarde left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per-Dot MCP connections fit the template, and this recheck confirms the remaining approval receipt identity issue is fixed. Server receipts now carry their approval identity; Alice/Bob replay is rejected with409, retries of the original request recover the saved result without rerunning, and mounted pending cards suppress mismatched results and Continue. Legacy receipts without identity fail closed. Earlier stable-tool-alias and refresh race regressions also pass. Verification at be38e2b: 13 focused tests and all291 repository tests pass; formatter, lint, typecheck and client/server production build pass. The pre-fix server failure was reproduced before verifying the fix, and mounted card fixtures cover matched, mismatched and legacy receipts. No new security blocker found; intentional owner-configured local MCP URLs remain supported. GitHub CI is green and this head includes current main1b2425d without conflicts. Live model/MCP interaction and browser narrow-screen/keyboard behavior were not rerun in this recheck; testing used credential-free in-memory MCP and React fixtures. Merge remains subject to fresh exact-head/current-main checks.

@jerelvelarde
jerelvelarde merged commit fa4cef7 into CopilotKit:main Oct 6, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants