Files
boocode/openspec/changes/v1.14.1-mcp-poc/design.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

3.2 KiB

v1.14.1-mcp-poc — design decisions

D1. Transport: Streamable HTTP (not stdio)

Context7 is a remote service at https://mcp.context7.com/mcp. Uses the MCP Streamable HTTP transport. The @modelcontextprotocol/sdk TypeScript client supports this via StreamableHTTPClientTransport. No stdio needed.

D2. Tool name prefixing

MCP tools get a context7_ prefix to avoid collisions with BooCode's native tools. Context7's tools are resolve-library-id and query-docs — these become context7_resolve-library-id and context7_query-docs. The prefix is stripped before calling the MCP server's tools/call.

D3. Read-only invariant guard

BooChat is read-only through v1.x. The MCP client rejects any tool whose annotations?.readOnly === false. Tools with readOnly: true or no annotations are accepted. Context7's tools are all read-only (they query documentation — no write side effects). Fail-open on missing annotations is a deliberate choice: most MCP servers don't set annotations yet, and rejecting all un-annotated tools would make the feature useless. The guard catches explicitly-declared write tools.

D4. Zod inputSchema for MCP tools

MCP tools come with a JSON Schema inputSchema. BooCode's ToolDef has both a Zod inputSchema (for server-side validation) and a jsonSchema (for the LLM's tool schema). For MCP tools:

  • jsonSchema is built directly from the MCP tool's inputSchema (it's already JSON Schema).
  • inputSchema uses z.record(z.unknown()) as a pass-through — the MCP server does its own validation. Double-validating with a generated Zod schema from JSON Schema adds complexity with no value for a PoC.

D5. Tool registration: append + re-sort (not lazy-init)

The simplest approach: keep ALL_TOOLS as the native tool array. Add an appendMcpTools(tools: ToolDef[]) function that pushes MCP tools, re-sorts alphabetically, and rebuilds TOOLS_BY_NAME and READ_ONLY_TOOL_NAMES. Called once at startup after MCP init. More invasive approaches (lazy-init, factory function) change the import shape for every consumer. Mutation-at-startup is ugly but contained to one call site and matches the existing alpha-sort-at-module-level pattern.

D6. No per-session toggle

Web tools have session.web_search_enabled. MCP tools do NOT get a session toggle in v1.14.1. If configured via env var, MCP tools are always available. Per-session MCP control is a v1.15 concern (when multiple MCP servers and the permission ruleset land together).

D7. Graceful degradation

MCP server down at startup → log warning, expose zero MCP tools, BooCode functions normally. MCP server down mid-session (tool call fails) → the execute wrapper catches the error and returns {error: true, output: "MCP server unreachable"} — the model sees the error and can self-correct (use native tools instead).

D8. Result content extraction

MCP tools/call returns {content: ContentBlock[]} where each block is {type: 'text', text: string} or {type: 'resource', ...}. For the PoC:

  • Text blocks: join with \n.
  • Resource blocks: serialize as JSON (the model can read structured data).
  • Empty content: return "(no output)".
  • isError: true in the response: return {error: true, output: joinedContent}.