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

97 lines
6.4 KiB
Markdown

# 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.ts``MCP_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.