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>
This commit is contained in:
39
openspec/changes/v1.14.1-mcp-poc/design.md
Normal file
39
openspec/changes/v1.14.1-mcp-poc/design.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# 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}`.
|
||||
96
openspec/changes/v1.14.1-mcp-poc/proposal.md
Normal file
96
openspec/changes/v1.14.1-mcp-poc/proposal.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# 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.
|
||||
80
openspec/changes/v1.14.1-mcp-poc/tasks.md
Normal file
80
openspec/changes/v1.14.1-mcp-poc/tasks.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# v1.14.1-mcp-poc tasks
|
||||
|
||||
## B1 — Backups
|
||||
|
||||
- [ ] `apps/server/src/services/tools.ts`
|
||||
- [ ] `apps/server/src/config.ts`
|
||||
- [ ] `apps/server/src/index.ts`
|
||||
|
||||
## B2 — Install `@modelcontextprotocol/sdk`
|
||||
|
||||
- [ ] `pnpm -C apps/server add @modelcontextprotocol/sdk`
|
||||
- [ ] Verify `pnpm -C apps/server build` still works after install
|
||||
- [ ] Note the installed version
|
||||
|
||||
## B3 — Config extension
|
||||
|
||||
- [ ] `apps/server/src/config.ts` — add `MCP_CONTEXT7_URL` (string, optional, default `https://mcp.context7.com/mcp`)
|
||||
- [ ] `apps/server/src/config.ts` — add `MCP_CONTEXT7_API_KEY` (string, optional)
|
||||
- [ ] Both via Zod `.optional()` with `.default()` for the URL
|
||||
|
||||
## B4 — MCP client service
|
||||
|
||||
- [ ] NEW `apps/server/src/services/mcp-client.ts`
|
||||
- [ ] Import `Client`, `StreamableHTTPClientTransport` from `@modelcontextprotocol/sdk/client`
|
||||
- [ ] `initialize(config, log)` — connect to Context7, call `tools/list`, wrap each as ToolDef, apply read-only guard
|
||||
- [ ] `callTool(name, args)` — call MCP server `tools/call`, extract text content, return as string
|
||||
- [ ] `getTools()` — return wrapped ToolDef[]
|
||||
- [ ] `isInitialized()` — boolean
|
||||
- [ ] Read-only guard: skip tools with `annotations?.readOnly === false`; accept all others
|
||||
- [ ] Graceful degradation: catch connection errors, log warning, expose zero tools
|
||||
- [ ] Tool name prefixing: `context7_<original_name>`
|
||||
- [ ] ToolDef wrapping: map MCP inputSchema (JSONSchema) to ToolJsonSchema `function.parameters`; use `z.any()` for Zod inputSchema (MCP already validated on the server side)
|
||||
- [ ] Execute wrapper: strip `context7_` prefix before calling MCP, join result content blocks with `\n`
|
||||
|
||||
## B5 — Tool registration (lazy-init)
|
||||
|
||||
- [ ] `apps/server/src/services/tools.ts` — convert `ALL_TOOLS` from a module-level constant to a lazy-initialized array
|
||||
- [ ] Add `initializeTools(mcpTools: ToolDef[])` function that builds the final sorted list
|
||||
- [ ] `TOOLS_BY_NAME`, `READ_ONLY_TOOL_NAMES` derived from the initialized list
|
||||
- [ ] Ensure all existing callers of `ALL_TOOLS` / `TOOLS_BY_NAME` still work (they import from tools.ts — verify the export shape)
|
||||
- [ ] OR simpler: keep ALL_TOOLS as-is (native tools), add `appendMcpTools(tools)` that mutates + re-sorts + rebuilds TOOLS_BY_NAME. Less clean but less invasive.
|
||||
|
||||
## B6 — Startup wiring
|
||||
|
||||
- [ ] `apps/server/src/index.ts` — after `applySchema()`, before route registration:
|
||||
- If `config.MCP_CONTEXT7_URL` is set: `await mcpClient.initialize(config, app.log)`
|
||||
- `appendMcpTools(mcpClient.getTools())` (or equivalent)
|
||||
- Log tool count
|
||||
- [ ] If URL not set: skip, log "mcp: Context7 not configured, skipping"
|
||||
|
||||
## B7 — Verification
|
||||
|
||||
- [ ] `npx tsc --noEmit -p apps/server` — 0 errors
|
||||
- [ ] `pnpm -C apps/server test` — all existing tests pass (MCP client is startup-only; tests don't initialize it)
|
||||
- [ ] `pnpm -C apps/web build` — green (no web changes)
|
||||
|
||||
## B8 — Unit tests
|
||||
|
||||
- [ ] NEW `apps/server/src/services/__tests__/mcp-client.test.ts`
|
||||
- [ ] Test: tool wrapping produces correct ToolDef shape (name, description, jsonSchema, execute fn)
|
||||
- [ ] Test: read-only guard rejects tools with `readOnly: false`
|
||||
- [ ] Test: read-only guard accepts tools with `readOnly: true` or no annotations
|
||||
- [ ] Test: name prefixing — `resolve-library-id` → `context7_resolve-library-id`
|
||||
- [ ] Test: result extraction — single text content block → string; multiple → joined with `\n`
|
||||
- [ ] Test: error result — MCP error → `{error: true, output: ...}` shape
|
||||
|
||||
## B9 — Deploy + smoke
|
||||
|
||||
- [ ] Add `MCP_CONTEXT7_URL=https://mcp.context7.com/mcp` to docker-compose env (or .env)
|
||||
- [ ] `docker compose up --build -d`
|
||||
- [ ] Check logs for MCP initialization message
|
||||
- [ ] Live-smoke: send a chat asking about AI SDK docs via Context7
|
||||
- [ ] Verify tool calls + results render normally
|
||||
|
||||
## B10 — Docs + tag
|
||||
|
||||
- [ ] `CHANGELOG.md` entry
|
||||
- [ ] `boocode_roadmap.md` retrospective bullet
|
||||
- [ ] `CLAUDE.md` — mention MCP client in the tools/services section
|
||||
- [ ] Commit, tag `v1.14.1-mcp-poc`, push, rebuild
|
||||
Reference in New Issue
Block a user