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>
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:
jsonSchemais built directly from the MCP tool'sinputSchema(it's already JSON Schema).inputSchemausesz.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: truein the response: return{error: true, output: joinedContent}.