Skip to content

feat(ai): MCP client — in-app agent consumes external MCP servers (AGENT-3)#145

Merged
eliotlim merged 2 commits into
mainfrom
feat/agent-3-mcp-client
Jul 13, 2026
Merged

feat(ai): MCP client — in-app agent consumes external MCP servers (AGENT-3)#145
eliotlim merged 2 commits into
mainfrom
feat/agent-3-mcp-client

Conversation

@eliotlim

Copy link
Copy Markdown
Owner

What

OpenBook's in-app AI agent can now use tools from external, user-registered MCP servers. Board: AGENT-3 (Epic: Agent-first-class), Wave-1 in-repo. Built to the reviewed design (scratchpad/agent3-design.md) with owner-resolved security decisions.

Pieces

  • packages/server/src/ai/mcpClients.tsMcpClientManager: ai.mcp settings key with secret-preserving merge + redaction; pooled lazy @modelcontextprotocol/sdk connections; toolsForRun(deadlineMs) (one 3s deadline via Promise.allSettled, 5-min tool cache, 60s failure backoff, 40-tool cap, 300-char desc clip); callTool (per-call timeout, untrusted-output envelope + 4000-char clip, isError→throw into AGENT-4's recoverable path); test; dispose. OPENBOOK_MCP_CLIENTS=0 hard-disables the subsystem.
  • SDK: McpTransport/McpServerConfig/McpClientConfig types, aiMcp/aiMcpTest routes, getMcpConfig/putMcpConfig/testMcpServer client methods, LocalDataClient stubs.
  • Routes: admin-gated GET/PUT/test /api/ai/mcp (requireInstanceAdmin); external tools gathered beside collectPluginTools, only for writer principals.
  • Agent (agent.ts): externalTools option, externalToolDefs() (write:false, mcp__<server>__<tool> namespaced, schema pass-through), untrusted-output system-prompt rule, consent pause, taint.
  • UI: settings/McpSettings.tsx (admin-self-gated, off-by-default, stdio hidden when disallowed, token set/replace/clear, per-row Test, warnings) + "external" badge and consent card in AgentPanel.tsx.

Security decisions (owner + manager resolved)

  • stdio by instance trust-level (owner): instance is claimed iff store.getInstanceConfig().ownerSubject is set (same signal requireInstanceAdmin uses). stdioAllowed = ownerSubject === undefined. Enforced in three places — setConfig rejects stdio on a claimed instance, test() refuses stdio dry-run, and toolsForRun filters stale stdio at run time (covers a desktop instance later becoming claimed) — plus UI hiding. HTTP-only on claimed multi-user.
  • taint (owner): first external-tool use flips tainted; propose() forces writes back through review even if allowDirectEdits was granted.
  • consent (manager): first mcp__* call sets permission_request{kind:'external_tools'} and returns before executing (nothing leaves the box pre-consent); sticky per conversation.
  • writer-gating (manager): external tools gathered only when decideCreateAccess(principal).canWrite — guests get none.
  • off by default (manager): global + per-server enabled:false.
  • Secrets write-only over the wire (apiKey resolveKey contract), redacted in GET/PUT, HTTP→Bearer header / stdio→env var never argv; stdio child stderr:'ignore'.

Scope

Wave-1 in-repo only. EXCLUDES the outward/remote surface (AGENT-5/6, owner-gated), OAuth flows, MCP resources/prompts/sampling/roots, stdio sandboxing.

Tests / verify

mcpClients.test.ts (10: secret merge, redaction, slug/dup rejection, stdio trust-level gate incl. runtime skip, discovery+dispatch over an in-process InMemoryTransport server, untrusted-wrap, isError→throw, backoff) + agentLoop.test.ts (+7: external success, throw/timeout→recoverable, consent pause never-runs, taint→review, control). pnpm verify exit 0: sdk 188, ui 981, server 681, server e2e 251, mcp e2e 43.

… (AGENT-3)

Add a server-side McpClientManager that pools connections to admin-registered
external MCP servers and hands the agent route pre-built, namespaced
`mcp__<server>__<tool>` tools it merges like plugin tools (no run-loop changes).

Security controls (per resolved decisions):
- Q1 trust-level stdio gate: stdio transport allowed only on desktop/unclaimed
  instances (ownerSubject unset); rejected at registration AND at run time on a
  claimed multi-user instance; hidden in the settings UI there. HTTP-only there.
- Q2 taint: once a run uses any external tool, later document writes are forced
  back through the review layer even with direct-edit access.
- Q3 writer-gated: external tools are offered only to a principal passing the
  create gate; a guest run gets none.
- Q4 per-conversation consent: the first external call pauses via a
  permission_request (kind:external_tools), sticky once granted.
- Q5 off/empty by default; global + per-server enable + OPENBOOK_MCP_CLIENTS=0.

Admin-only GET/PUT/test /api/ai/mcp (requireInstanceAdmin), write-only auth
tokens (redacted, preserve/replace/clear), untrusted-output label-wrap + clip,
per-call timeout riding AGENT-4's recoverable-error path, 3s discovery deadline
with per-server cache + failure backoff. Settings section + "external" badge on
mcp__* tool events. Unit tests (merge/namespacing/sanitize/redaction/backoff +
stdio gate via InMemoryTransport) and agentLoop tests (success, throw/timeout
recoverable, consent pause, taint forces review).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KFk2T9k3p7ghCjdzMfkA5w
@vercel

vercel Bot commented Jul 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
app.book.pub Ready Ready Preview, Comment Jul 13, 2026 5:20am

Request Review

…taint, add-flow (AGENT-3)

Address both AGENT-3 review REQUEST-CHANGES on the same branch:

HIGH — consent gate was bypassable by a parallel native tool batch: the guard
only fired when `interactive` was unset, so a second `mcp__*` call in one batch
(after the first set `interactive`) ran and shipped args to the untrusted server
before consent. The gate is now INDEPENDENT of `interactive` — no external tool
runs until `externalConsent`, period — plus a batch-level `break` once the run
pauses. Regression tests cover two parallel externals and request_edit_access +
external in one batch.

HIGH — "Add server" was broken against a real (HTTP) server: it PUT a blank row
(empty id) which `setConfig` rejected (SLUG_RE → 400) so the row never rendered.
Add now updates LOCAL state; the per-field onBlur persists once id + endpoint are
valid.

MAJOR — taint is now conversation-sticky (was per-run): the client re-sends
`externalToolsUsed` once it has seen any `mcp__*` tool event, OR'd into the
per-run taint at run start, so a later turn that edits without itself calling an
external tool still routes through review. Threaded sdk→client→route→runner; test
added.

MEDIUM — connection pool leak under concurrent runs: `connection()` now shares an
in-flight connect promise per server id (the second run no longer opens a
duplicate socket/child and orphans the first).

MINOR (security) — an `isError` result now gets the same untrusted-output
envelope before it throws (its text is attacker-controlled); `errorClass()`
returns the error class (+ numeric code) only, never the free-text message
(§S6). Local-mode `putMcpConfig` stub now redacts the echoed tokens.

Adds route-level tests for GET/PUT/test `/api/ai/mcp` (admin gating, redaction,
slug + stdio-on-claimed 400s).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KFk2T9k3p7ghCjdzMfkA5w
@eliotlim eliotlim merged commit 9786ef9 into main Jul 13, 2026
10 checks passed
@eliotlim eliotlim deleted the feat/agent-3-mcp-client branch July 13, 2026 05:35
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.

1 participant