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>
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:
- Connects to Context7 at
MCP_CONTEXT7_URL(defaulthttps://mcp.context7.com/mcp) via Streamable HTTP transport. - Optional
MCP_CONTEXT7_API_KEYenv var passed as a header. - On
initialize(): callstools/list, wraps each MCP tool as aToolDef, prefixes names withcontext7_to avoid collisions with BooCode's native tools. - Read-only invariant guard: rejects any tool whose
annotations?.readOnlyis explicitlyfalse. Tools withreadOnly: trueor noannotationsfield are accepted (fail-open on read-only, since most MCP tools don't set annotations yet — Context7's tools don't). callTool(name, args)→ calls the MCP server'stools/callendpoint and returns the result content.getTools(): ToolDef[]→ returns the discovered tools wrapped as BooCodeToolDefobjects.- 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, defaulthttps://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/sdkpnpm-lock.yaml— auto-updatedapps/server/src/config.ts—MCP_CONTEXT7_URL,MCP_CONTEXT7_API_KEYapps/server/src/services/mcp-client.ts— NEW, ~100 linesapps/server/src/services/tools.ts— lazy-init or append MCP tools to ALL_TOOLSapps/server/src/index.ts— callmcpClient.initialize()at startupapps/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
- Set
MCP_CONTEXT7_URL=https://mcp.context7.com/mcpin.env(or docker-compose env). - Restart boocode container.
- Check logs: should see "mcp: initialized Context7, discovered N tools" (or similar).
- Open a chat with no agent selected. Send "What does the
streamTextfunction do in the AI SDK? Use context7 to look it up." - Confirm: model calls
context7_resolve-library-idthencontext7_query-docs(or whatever Context7's tool names are after prefixing). - Confirm: tool results render normally in the chat.
- Without
MCP_CONTEXT7_URLset: restart, confirm BooCode starts normally with zero MCP tools.