Files
boocode/openspec/changes/v1.14.1-mcp-poc/proposal.md
indifferentketchup 5692e99a5d v1.14.1-mcp-poc: single-server MCP client against Context7
Validates the MCP-client loop end-to-end against one real MCP server before
the full v1.15 port. New services/mcp-client.ts wraps @modelcontextprotocol/sdk
v1.29.0 with Streamable HTTP transport. On startup (when MCP_CONTEXT7_URL is
set), connects to Context7, discovers tools via tools/list, wraps each as a
ToolDef prefixed context7_<name>, and appends to ALL_TOOLS via appendMcpTools.

Read-only invariant guard rejects any tool with readOnlyHint: false. Tool
dispatch is transparent — executeToolCall routes MCP calls through the ToolDef
execute wrapper, which strips the prefix before calling the MCP server. Result
size capped at 5MB with truncation. Graceful degradation: server down at
startup → zero tools; server down mid-session → error result, model
self-corrects.

Adversarial review caught that a Zod .default() on the URL config made MCP
always-on instead of opt-in — fixed by removing the default. MCP_CONTEXT7_URL
must be explicitly set to enable.

ALL_TOOLS changed from ReadonlyArray to mutable to support late-registration.
appendMcpTools re-sorts and rebuilds TOOLS_BY_NAME after append.

348/348 server tests passing (16 new mcp-client tests). No schema changes,
no frontend changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:58:09 +00:00

6.4 KiB

v1.14.1-mcp-poc — single-server MCP client proof-of-concept

Validate the MCP-client loop end-to-end against one real MCP server (Context7) before committing to the full opencode mcp/index.ts port at v1.15. Small, throwaway-if-needed.

Why

BooCode's tool registry (ALL_TOOLS in tools.ts) is static — tools are hardcoded TypeScript modules. MCP is the protocol for dynamic tool discovery. Wiring one real MCP server end-to-end proves: tool-discovery → tool-list → tool-call → result-render → context-budget accounting all hold. If Context7 works, any MCP server will work via the same plumbing.

Scope

S1. Install @modelcontextprotocol/sdk

New dependency in apps/server/package.json. The official TypeScript MCP client SDK (MIT). Provides Client, StreamableHTTPClientTransport, tool-call/result types.

S2. New service: apps/server/src/services/mcp-client.ts

Singleton MCP client that:

  1. Connects to Context7 at MCP_CONTEXT7_URL (default https://mcp.context7.com/mcp) via Streamable HTTP transport.
  2. Optional MCP_CONTEXT7_API_KEY env var passed as a header.
  3. On initialize(): calls tools/list, wraps each MCP tool as a ToolDef, prefixes names with context7_ to avoid collisions with BooCode's native tools.
  4. Read-only invariant guard: rejects any tool whose annotations?.readOnly is explicitly false. Tools with readOnly: true or no annotations field are accepted (fail-open on read-only, since most MCP tools don't set annotations yet — Context7's tools don't).
  5. callTool(name, args) → calls the MCP server's tools/call endpoint and returns the result content.
  6. getTools(): ToolDef[] → returns the discovered tools wrapped as BooCode ToolDef objects.
  7. Graceful degradation: if the MCP server is unreachable at startup, log a warning and expose zero MCP tools. BooCode functions normally with its native tools.

S3. Config extension

apps/server/src/config.ts gains two optional env vars:

  • MCP_CONTEXT7_URL (string, default https://mcp.context7.com/mcp)
  • MCP_CONTEXT7_API_KEY (string, optional)

S4. Tool registration

apps/server/src/services/tools.ts — after building ALL_TOOLS from native tools, append MCP-discovered tools from mcpClient.getTools(). The alpha-sort at the end of ALL_TOOLS construction covers both native and MCP tools. TOOLS_BY_NAME map includes MCP tools.

MCP tools are registered with category: 'read_only' (per the read-only invariant guard in S2).

S5. Tool dispatch

apps/server/src/services/inference/tool-phase.ts executeToolCall already dispatches via TOOLS_BY_NAME[toolName].execute(...). MCP tools' execute function calls mcpClient.callTool(name, args) — the dispatch is transparent to the rest of the inference loop. No changes to executeToolCall needed.

S6. MCP tool result → BooCode format

MCP tools/call returns { content: [{type: 'text', text: string}, ...] }. BooCode's executeToolCall expects a string or JSON-serializable output. The execute wrapper in the ToolDef extracts content[0].text (or joins multiple content blocks with \n). If the MCP server returns an error, the wrapper returns {error: true, output: errorMessage} matching BooCode's existing error-result shape.

S7. Startup initialization

apps/server/src/index.ts — after applySchema() and before route registration, call mcpClient.initialize(). If MCP_CONTEXT7_URL is not set (or empty), skip initialization entirely (MCP is opt-in). Log the number of discovered tools on success.

Tool registration (S4) must happen AFTER MCP initialization, since getTools() returns the discovered tools. Current flow: ALL_TOOLS is a module-level constant. This needs to change to a lazy-init pattern — either a function that returns the tool list (called once at startup after MCP init), or a mutable array that MCP tools get appended to during startup.

S8. Agent tool whitelist interaction

MCP tools are prefixed context7_*. Existing agents' tools: whitelists don't include MCP tool names — so MCP tools are only available to the default agent (no agent selected, which gets ALL_TOOLS). To make MCP tools available to specific agents, their AGENTS.md tools: list would need to include context7_* names. For the PoC, this is fine — the default agent (most common) gets MCP tools.

Non-goals

  • No stdio transport. Context7 is HTTP-only.
  • No OAuth. Context7 uses an API key header.
  • No multiple servers. One hardcoded server (Context7).
  • No per-agent MCP server allow/deny. All agents that don't have a tools: whitelist get MCP tools.
  • No per-session MCP toggle. If configured, MCP tools are always available.
  • No UI changes. MCP tools surface in the tool list the model sees; results render as normal tool-result parts.
  • No schema changes. MCP state is in-memory only.

Hard rules

  • No git commit/push. Sam commits.
  • Read-only invariant: reject any MCP tool with readOnly: false.
  • Graceful degradation: MCP server down → zero MCP tools, BooCode works normally.
  • One new dep only: @modelcontextprotocol/sdk.
  • Alpha-sort of ALL_TOOLS preserved (v1.13.3 prompt-cache invariant).

Files expected to touch

  • apps/server/package.json — add @modelcontextprotocol/sdk
  • pnpm-lock.yaml — auto-updated
  • apps/server/src/config.tsMCP_CONTEXT7_URL, MCP_CONTEXT7_API_KEY
  • apps/server/src/services/mcp-client.ts — NEW, ~100 lines
  • apps/server/src/services/tools.ts — lazy-init or append MCP tools to ALL_TOOLS
  • apps/server/src/index.ts — call mcpClient.initialize() at startup
  • apps/server/src/services/__tests__/mcp-client.test.ts — NEW, unit tests for tool wrapping + read-only guard

Estimate

~150 LoC. The MCP SDK handles the protocol; BooCode's job is wrapping discovered tools as ToolDefs and routing calls through the SDK client.

Smoke plan

  1. Set MCP_CONTEXT7_URL=https://mcp.context7.com/mcp in .env (or docker-compose env).
  2. Restart boocode container.
  3. Check logs: should see "mcp: initialized Context7, discovered N tools" (or similar).
  4. Open a chat with no agent selected. Send "What does the streamText function do in the AI SDK? Use context7 to look it up."
  5. Confirm: model calls context7_resolve-library-id then context7_query-docs (or whatever Context7's tool names are after prefixing).
  6. Confirm: tool results render normally in the chat.
  7. Without MCP_CONTEXT7_URL set: restart, confirm BooCode starts normally with zero MCP tools.