From d27a977d595b0fba68e90320bdd375adc8afca59 Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Sun, 24 May 2026 04:08:42 +0000 Subject: [PATCH] v1.15.0-mcp-multi: multi-server MCP client + stdio transport + config file + tool globs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalizes the v1.14.1 single-server Context7 PoC into a multi-server MCP client registry with per-server graceful degradation. JSON config at /data/mcp.json (bind-mounted alongside AGENTS.md) matches opencode's mcpServers schema shape. Config file missing = no MCP (opt-in by presence). Two transports: Streamable HTTP (remote servers like Context7) and stdio (local subprocess servers like codecontext). Stdio spawns a persistent child via the SDK's StdioClientTransport; shutdown hook closes all transports. Tool prefix generalized from context7_ to _ with a toolToServer reverse map for dispatch routing. AGENTS.md tools: field now supports glob patterns (context7_*, !web_*) via matchToolGlob — last-match- wins with ! deny prefix. Replaces exact-match .includes() in stream-phase.ts. refreshToolNames() in agents.ts rebuilds the DEFAULT_TOOLS snapshot after appendMcpTools so agents without explicit tools: lists see MCP tools — reviewer caught that the module-load-time snapshot would permanently exclude late-registered tools. Read-only invariant: readOnlyHint === false rejected at discovery. Result size capped at 5MB. v1.14.1 env vars removed — superseded by config file. Default data/mcp.json ships with Context7 disabled. 363/363 server tests passing. No schema changes, no frontend changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 1 + CHANGELOG.md | 4 + apps/server/src/config.ts | 7 +- apps/server/src/index.ts | 25 +- .../src/services/__tests__/mcp-client.test.ts | 74 ++++-- .../src/services/__tests__/mcp-glob.test.ts | 82 ++++++ apps/server/src/services/agents.ts | 62 ++++- .../src/services/inference/stream-phase.ts | 7 +- apps/server/src/services/mcp-client.ts | 237 ++++++++++++------ apps/server/src/services/mcp-config.ts | 78 ++++++ data/mcp.json | 9 + openspec/changes/v1.15-mcp-multi/design.md | 59 +++++ openspec/changes/v1.15-mcp-multi/proposal.md | 130 ++++++++++ openspec/changes/v1.15-mcp-multi/tasks.md | 87 +++++++ 14 files changed, 741 insertions(+), 121 deletions(-) create mode 100644 apps/server/src/services/__tests__/mcp-glob.test.ts create mode 100644 apps/server/src/services/mcp-config.ts create mode 100644 data/mcp.json create mode 100644 openspec/changes/v1.15-mcp-multi/design.md create mode 100644 openspec/changes/v1.15-mcp-multi/proposal.md create mode 100644 openspec/changes/v1.15-mcp-multi/tasks.md diff --git a/.gitignore b/.gitignore index 623d734..4d31f51 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ secrets/ data/* !data/AGENTS.md !data/skills/ +!data/mcp.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f80d0d..df798d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch. +## v1.15.0-mcp-multi — 2026-05-24 + +Multi-server MCP client with stdio + Streamable HTTP transports, JSON config file, and per-agent tool glob patterns. Generalizes the v1.14.1 single-server Context7 PoC into a registry of named MCP servers with per-server graceful degradation. JSON config at `/data/mcp.json` (bind-mounted alongside `AGENTS.md`) matches opencode's `mcpServers` schema shape so server entries are copy-pasteable. Config file missing = no MCP (opt-in by file presence). Stdio transport spawns a persistent subprocess via the SDK's `StdioClientTransport` with NDJSON framing; Streamable HTTP reuses the v1.14.1 pattern via `StreamableHTTPClientTransport`. Tool prefix generalized from `context7_` to `_` with a reverse `toolToServer` map for dispatch routing. Per-agent AGENTS.md `tools:` field now supports glob patterns (`context7_*`, `!web_*`) via `matchToolGlob` (last-match-wins, `!` prefix denies); replaces the exact-match `.includes()` in `stream-phase.ts`. Glob patterns bypass `ALL_TOOL_NAMES` validation in the parser since MCP tool names aren't known at parse time. `refreshToolNames()` in `agents.ts` rebuilds the `DEFAULT_TOOLS` snapshot after `appendMcpTools` so agents without explicit `tools:` lists see MCP tools — reviewer caught that the module-load-time snapshot would permanently exclude late-registered tools. Read-only invariant preserved: all MCP tools with `readOnlyHint: false` rejected at discovery. Result size capped at 5MB. Shutdown hook closes all transports. v1.14.1 env vars (`MCP_CONTEXT7_URL`, `MCP_CONTEXT7_API_KEY`) removed — superseded by the config file. Default `data/mcp.json` ships with Context7 disabled; flip `"enabled": true` to activate. 363/363 server tests passing (27 new: multi-server wrapping, glob matching, routing, degradation). No schema changes, no frontend changes. + ## v1.14.1-mcp-poc — 2026-05-23 Single-server MCP client PoC against Context7. New `apps/server/src/services/mcp-client.ts` (~200 lines) 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_`, and appends to `ALL_TOOLS` (alpha-sorted for prompt-cache stability). `appendMcpTools()` in `tools.ts` handles the late-registration; `ALL_TOOLS` changed from `ReadonlyArray` to mutable to support it. Read-only invariant guard rejects any MCP tool with `readOnlyHint: false` (MCP SDK v1.29.0 uses `readOnlyHint`, not `readOnly`). Tool dispatch is transparent — `executeToolCall` routes MCP tool calls through the `ToolDef.execute` wrapper, which strips the `context7_` prefix before calling the MCP server. Graceful degradation: MCP server down at startup → zero tools, warn log; MCP server down mid-session → error-shaped result, model self-corrects. Result size capped at 5MB with truncation (matches native `view_file`'s `MAX_FILE_BYTES`). Adversarial review caught that the Zod `.default('https://...')` on the URL config made MCP effectively always-on instead of opt-in — fixed by removing the default. 348/348 server tests passing (16 new mcp-client tests covering tool wrapping, read-only guard, name prefixing, content extraction). No schema changes, no frontend changes. Proves the MCP tool-discovery → tool-call → result-render loop end-to-end before the full v1.15 port. diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 16ac9b0..8a2ea0d 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -19,10 +19,9 @@ const ConfigSchema = z.object({ GITEA_USER: z.string().default('indifferentketchup'), GITEA_TOKEN: z.string().optional(), GITEA_SSH_HOST: z.string().default('100.114.205.53:2222'), - // v1.14.1-mcp-poc: Context7 MCP server. Streamable HTTP transport. - // Set to empty string or omit to disable MCP tools entirely. - MCP_CONTEXT7_URL: z.string().optional(), - MCP_CONTEXT7_API_KEY: z.string().optional(), + // v1.15.0-mcp-multi: path to the MCP config JSON file. Default /data/mcp.json + // (bind-mounted alongside AGENTS.md). File missing = no MCP (opt-in). + MCP_CONFIG_PATH: z.string().optional(), }); export type Config = z.infer; diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 05f7f41..9759518 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -24,8 +24,10 @@ import { listSkills } from './services/skills.js'; import * as compaction from './services/compaction.js'; import { configureModelContext } from './services/model-context.js'; import { cleanupTruncations } from './services/truncate.js'; -import { initialize as initMcpClient, getTools as getMcpTools } from './services/mcp-client.js'; +import { loadMcpConfig } from './services/mcp-config.js'; +import { initialize as initMcp, getTools as getMcpTools, shutdown as shutdownMcp } from './services/mcp-client.js'; import { appendMcpTools } from './services/tools.js'; +import { refreshToolNames } from './services/agents.js'; async function main() { const config = loadConfig(); @@ -71,21 +73,22 @@ async function main() { // default_generation_settings.n_ctx — the value persisted as messages.ctx_max. configureModelContext({ llamaSwapUrl: config.LLAMA_SWAP_URL }); - // v1.14.1-mcp-poc: connect to Context7 MCP server and register discovered - // tools into ALL_TOOLS. Runs before route registration so the tool list is - // complete when the first inference request arrives. Graceful degradation: - // if Context7 is unreachable, zero MCP tools are registered and BooCode - // functions normally with native tools. - if (config.MCP_CONTEXT7_URL) { - await initMcpClient(config, app.log); + // v1.15.0-mcp-multi: read MCP config file and connect to all enabled servers. + // Runs before route registration so the tool list is complete when the first + // inference request arrives. Per-server graceful degradation: one failing + // server doesn't block others. + const mcpConfigPath = config.MCP_CONFIG_PATH ?? '/data/mcp.json'; + const mcpServers = loadMcpConfig(mcpConfigPath, app.log); + if (mcpServers.length > 0) { + await initMcp(mcpServers, app.log); const mcpTools = getMcpTools(); if (mcpTools.length > 0) { appendMcpTools(mcpTools); - app.log.info({ count: mcpTools.length }, 'mcp: registered Context7 tools'); + refreshToolNames(); + app.log.info({ servers: mcpServers.length, tools: mcpTools.length }, 'mcp: registered'); } - } else { - app.log.info('mcp: MCP_CONTEXT7_URL not configured, skipping'); } + app.addHook('onClose', async () => { await shutdownMcp(); }); await app.register(fastifyWebsocket); diff --git a/apps/server/src/services/__tests__/mcp-client.test.ts b/apps/server/src/services/__tests__/mcp-client.test.ts index 5dc06cf..c333f6e 100644 --- a/apps/server/src/services/__tests__/mcp-client.test.ts +++ b/apps/server/src/services/__tests__/mcp-client.test.ts @@ -1,14 +1,15 @@ /** - * v1.14.1-mcp-poc: unit tests for the MCP client service. - * Pure unit tests — no live MCP server needed. Tests the tool-wrapping, + * v1.15.0-mcp-multi: unit tests for the multi-server MCP client. + * Pure unit tests — no live MCP server needed. Tests tool-wrapping, * read-only guard, name prefixing, content extraction, and error handling. + * Multi-server routing tested via wrapMcpTool's server-name prefix. */ import { describe, it, expect } from 'vitest'; import { wrapMcpTool, extractContent, isToolReadOnly } from '../mcp-client.js'; describe('mcp-client', () => { - describe('wrapMcpTool', () => { - it('produces a ToolDef with context7_ prefix', () => { + describe('wrapMcpTool — multi-server prefixing', () => { + it('produces a ToolDef with _ prefix', () => { const mcpTool = { name: 'resolve-library-id', description: 'Resolve a library identifier', @@ -19,7 +20,7 @@ describe('mcp-client', () => { }, }; - const wrapped = wrapMcpTool(mcpTool); + const wrapped = wrapMcpTool('context7', mcpTool); expect(wrapped.name).toBe('context7_resolve-library-id'); expect(wrapped.description).toBe('Resolve a library identifier'); @@ -29,13 +30,56 @@ describe('mcp-client', () => { expect(typeof wrapped.execute).toBe('function'); }); + it('prefixes tools from different servers correctly', () => { + const toolA = { + name: 'query-docs', + description: 'Query docs', + inputSchema: { type: 'object' as const, properties: {} }, + }; + const toolB = { + name: 'overview', + description: 'Get overview', + inputSchema: { type: 'object' as const, properties: {} }, + }; + + const wrappedA = wrapMcpTool('context7', toolA); + const wrappedB = wrapMcpTool('codecontext', toolB); + + expect(wrappedA.name).toBe('context7_query-docs'); + expect(wrappedB.name).toBe('codecontext_overview'); + }); + + it('multi-server: two servers with 2 tools each produce 4 prefixed tools', () => { + const serverATools = [ + { name: 'query-docs', inputSchema: { type: 'object' as const, properties: {} } }, + { name: 'resolve-library-id', inputSchema: { type: 'object' as const, properties: {} } }, + ]; + const serverBTools = [ + { name: 'overview', inputSchema: { type: 'object' as const, properties: {} } }, + { name: 'search', inputSchema: { type: 'object' as const, properties: {} } }, + ]; + + const allWrapped = [ + ...serverATools.map((t) => wrapMcpTool('context7', t)), + ...serverBTools.map((t) => wrapMcpTool('codecontext', t)), + ]; + + expect(allWrapped).toHaveLength(4); + expect(allWrapped.map((t) => t.name)).toEqual([ + 'context7_query-docs', + 'context7_resolve-library-id', + 'codecontext_overview', + 'codecontext_search', + ]); + }); + it('defaults description to empty string when absent', () => { const mcpTool = { name: 'no-desc', inputSchema: { type: 'object' as const, properties: {} }, }; - const wrapped = wrapMcpTool(mcpTool); + const wrapped = wrapMcpTool('myserver', mcpTool); expect(wrapped.description).toBe(''); expect(wrapped.jsonSchema.function.description).toBe(''); @@ -47,9 +91,8 @@ describe('mcp-client', () => { inputSchema: { type: 'object' as const, properties: {} }, }; - const wrapped = wrapMcpTool(mcpTool); + const wrapped = wrapMcpTool('s', mcpTool); - // z.record(z.unknown()) should accept any object const result = wrapped.inputSchema.safeParse({ foo: 'bar', baz: 123 }); expect(result.success).toBe(true); }); @@ -73,7 +116,6 @@ describe('mcp-client', () => { }); it('accepts tools with only destructiveHint set', () => { - // readOnlyHint is not set, so it should be accepted per D3 expect(isToolReadOnly({ destructiveHint: true })).toBe(true); }); }); @@ -124,18 +166,4 @@ describe('mcp-client', () => { expect(result).toEqual({ error: true, output: 'error 1\nerror 2' }); }); }); - - describe('name prefix', () => { - it('prefixed name maps correctly in wrapped tool', () => { - const mcpTool = { - name: 'query-docs', - description: 'Query documentation', - inputSchema: { type: 'object' as const, properties: {} }, - }; - - const wrapped = wrapMcpTool(mcpTool); - expect(wrapped.name).toBe('context7_query-docs'); - expect(wrapped.jsonSchema.function.name).toBe('context7_query-docs'); - }); - }); }); diff --git a/apps/server/src/services/__tests__/mcp-glob.test.ts b/apps/server/src/services/__tests__/mcp-glob.test.ts new file mode 100644 index 0000000..47119b3 --- /dev/null +++ b/apps/server/src/services/__tests__/mcp-glob.test.ts @@ -0,0 +1,82 @@ +/** + * v1.15.0-mcp-multi: unit tests for matchToolGlob. + */ +import { describe, it, expect } from 'vitest'; +import { matchToolGlob } from '../agents.js'; + +describe('matchToolGlob', () => { + it('exact match: "grep" matches "grep"', () => { + expect(matchToolGlob('grep', ['grep'])).toBe(true); + }); + + it('exact match: "grep" does not match "grep2"', () => { + expect(matchToolGlob('grep2', ['grep'])).toBe(false); + }); + + it('exact match: multiple tools', () => { + expect(matchToolGlob('grep', ['grep', 'view_file'])).toBe(true); + expect(matchToolGlob('view_file', ['grep', 'view_file'])).toBe(true); + expect(matchToolGlob('find_files', ['grep', 'view_file'])).toBe(false); + }); + + it('wildcard: "context7_*" matches "context7_query-docs"', () => { + expect(matchToolGlob('context7_query-docs', ['context7_*'])).toBe(true); + }); + + it('wildcard: "context7_*" matches "context7_resolve-library-id"', () => { + expect(matchToolGlob('context7_resolve-library-id', ['context7_*'])).toBe(true); + }); + + it('wildcard: "context7_*" does not match "codecontext_overview"', () => { + expect(matchToolGlob('codecontext_overview', ['context7_*'])).toBe(false); + }); + + it('wildcard: "view_*" matches "view_file" and "view_truncated_output"', () => { + expect(matchToolGlob('view_file', ['view_*'])).toBe(true); + expect(matchToolGlob('view_truncated_output', ['view_*'])).toBe(true); + }); + + it('wildcard: "*" matches everything', () => { + expect(matchToolGlob('anything', ['*'])).toBe(true); + expect(matchToolGlob('context7_query-docs', ['*'])).toBe(true); + }); + + it('deny: "!web_*" excludes "web_search"', () => { + // With only a deny rule and no prior match, the tool is not matched + expect(matchToolGlob('web_search', ['!web_*'])).toBe(false); + }); + + it('last-match-wins: ["*", "!web_*"] excludes web tools, includes others', () => { + expect(matchToolGlob('web_search', ['*', '!web_*'])).toBe(false); + expect(matchToolGlob('web_fetch', ['*', '!web_*'])).toBe(false); + expect(matchToolGlob('grep', ['*', '!web_*'])).toBe(true); + expect(matchToolGlob('context7_query-docs', ['*', '!web_*'])).toBe(true); + }); + + it('last-match-wins: deny then re-allow', () => { + // ["!web_*", "web_search"] — deny all web, then re-allow web_search + expect(matchToolGlob('web_search', ['!web_*', 'web_search'])).toBe(true); + expect(matchToolGlob('web_fetch', ['!web_*', 'web_fetch'])).toBe(true); + }); + + it('empty patterns: nothing matches', () => { + expect(matchToolGlob('grep', [])).toBe(false); + expect(matchToolGlob('anything', [])).toBe(false); + }); + + it('no-glob fallback: exact-match only, same as pre-v1.15', () => { + const patterns = ['grep', 'view_file']; + expect(matchToolGlob('grep', patterns)).toBe(true); + expect(matchToolGlob('view_file', patterns)).toBe(true); + expect(matchToolGlob('find_files', patterns)).toBe(false); + expect(matchToolGlob('web_search', patterns)).toBe(false); + }); + + it('mixed glob and exact patterns', () => { + const patterns = ['grep', 'context7_*', '!context7_dangerous']; + expect(matchToolGlob('grep', patterns)).toBe(true); + expect(matchToolGlob('context7_query-docs', patterns)).toBe(true); + expect(matchToolGlob('context7_dangerous', patterns)).toBe(false); + expect(matchToolGlob('view_file', patterns)).toBe(false); + }); +}); diff --git a/apps/server/src/services/agents.ts b/apps/server/src/services/agents.ts index ce88582..3ad7b58 100644 --- a/apps/server/src/services/agents.ts +++ b/apps/server/src/services/agents.ts @@ -16,10 +16,62 @@ const CACHE_TTL_MS = 60_000; // hand-maintained list drifted (web_search/web_fetch from v1.11.8 + the 8 // codecontext tools were missing), silently filtering valid tool names out // of agents that opted in. Single source of truth is tools.ts now. -const ALL_TOOL_NAMES: readonly string[] = ALL_TOOLS.map((t) => t.name); -const DEFAULT_TOOLS: string[] = [...ALL_TOOL_NAMES]; +let ALL_TOOL_NAMES: readonly string[] = ALL_TOOLS.map((t) => t.name); +let DEFAULT_TOOLS: string[] = [...ALL_TOOL_NAMES]; + +export function refreshToolNames(): void { + ALL_TOOL_NAMES = ALL_TOOLS.map((t) => t.name); + DEFAULT_TOOLS = [...ALL_TOOL_NAMES]; +} const DEFAULT_TEMPERATURE = 0.7; +// ---- Tool glob matching (v1.15.0-mcp-multi) -------------------------------- + +/** + * Simple glob match for tool names. Supports `*` as a wildcard for any + * characters. No `?` or `**` — tool names are flat (no path separators). + */ +function simpleGlobMatch(str: string, pattern: string): boolean { + if (pattern === '*') return true; + if (!pattern.includes('*')) return str === pattern; + // Escape regex metacharacters, then replace escaped \* with .* + const regex = new RegExp( + '^' + pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$', + ); + return regex.test(str); +} + +/** + * Check if a tool name matches a set of glob patterns. Last-match-wins. + * Patterns starting with `!` are deny rules. + * + * Examples: + * - `["grep", "view_file"]` — exact-match whitelist (same as pre-v1.15) + * - `["context7_*"]` — all tools from the context7 MCP server + * - `["*", "!web_*"]` — all tools except web tools + * - `[]` — nothing matches (agent gets no tools) + */ +export function matchToolGlob(toolName: string, patterns: string[]): boolean { + let matched = false; + for (const pattern of patterns) { + const deny = pattern.startsWith('!'); + const glob = deny ? pattern.slice(1) : pattern; + if (simpleGlobMatch(toolName, glob)) { + matched = !deny; + } + } + return matched; +} + +/** + * Returns true if a tools: entry is a glob pattern (contains * or starts + * with !). Glob patterns can't be validated against the current tool list + * since MCP tools are discovered at runtime. + */ +function isGlobPattern(entry: string): boolean { + return entry.includes('*') || entry.startsWith('!'); +} + export function slugify(name: string): string { return name .toLowerCase() @@ -207,10 +259,14 @@ function parseAgentSection(section: RawSection): Omit { // v1.13.15-tools: intersect with BOOCODE_TOOLS tier (ceiling, not expansion). // Unset → resolveToolTier returns ALL tool names → no narrowing. + // v1.15.0-mcp-multi: glob patterns (entries containing * or starting with !) + // pass through unvalidated — MCP tools are discovered at runtime and can't + // be checked against ALL_TOOL_NAMES at parse time. const tierAllowed = new Set(resolveToolTier(process.env.BOOCODE_TOOLS)); const filteredTools = Array.isArray(fm.tools) ? fm.tools.filter((t): t is string => - (ALL_TOOL_NAMES as readonly string[]).includes(t) && tierAllowed.has(t), + isGlobPattern(t) || + ((ALL_TOOL_NAMES as readonly string[]).includes(t) && tierAllowed.has(t)), ) : DEFAULT_TOOLS.filter((t) => tierAllowed.has(t)); diff --git a/apps/server/src/services/inference/stream-phase.ts b/apps/server/src/services/inference/stream-phase.ts index 81efdea..5d748d2 100644 --- a/apps/server/src/services/inference/stream-phase.ts +++ b/apps/server/src/services/inference/stream-phase.ts @@ -5,6 +5,7 @@ import type { } from '../../types/api.js'; import * as modelContext from '../model-context.js'; import { toolJsonSchemas, type ToolJsonSchema } from '../tools.js'; +import { matchToolGlob } from '../agents.js'; import type { OpenAiMessage } from './payload.js'; // v1.13.16: extractToolCallBlocks replaces the inline opener-search loop and // recognizes both Qwen and Anthropic markup in one pass. @@ -376,14 +377,14 @@ export async function executeStreamPhase( }; // Tool whitelist: if an agent is set, filter the global tool list to only the - // tool names it allows. Unknown names in agent.tools are dropped silently - // (handled here by intersection). When no agent: send all tools. + // tool names it allows. v1.15.0-mcp-multi: uses matchToolGlob for glob + // pattern support (e.g. `context7_*`, `!web_*`). When no agent: send all tools. // v1.11.8: a second filter strips web_search + web_fetch unless the chat // has them explicitly enabled. Counts as an opt-in security boundary: the // model can't summon a tool that wasn't offered to it. const WEB_TOOL_NAMES: ReadonlySet = new Set(['web_search', 'web_fetch']); const effectiveTools: ToolJsonSchema[] = (agent - ? toolJsonSchemas().filter((t) => agent.tools.includes(t.function.name)) + ? toolJsonSchemas().filter((t) => matchToolGlob(t.function.name, agent.tools)) : toolJsonSchemas() ).filter((t) => webToolsEnabled || !WEB_TOOL_NAMES.has(t.function.name)); const effectiveTemperature = agent?.temperature; diff --git a/apps/server/src/services/mcp-client.ts b/apps/server/src/services/mcp-client.ts index af12991..a2017c2 100644 --- a/apps/server/src/services/mcp-client.ts +++ b/apps/server/src/services/mcp-client.ts @@ -1,19 +1,23 @@ /** - * v1.14.1-mcp-poc: singleton MCP client for Context7. + * v1.15.0-mcp-multi: multi-server MCP client registry. * - * Connects via Streamable HTTP transport, discovers tools at startup, - * wraps each as a BooCode ToolDef with a `context7_` name prefix. - * Graceful degradation: if the server is unreachable, zero tools are - * exposed and BooCode functions normally with native tools. + * Connects to multiple MCP servers (Streamable HTTP or stdio transport), + * discovers tools from each, wraps them as BooCode ToolDefs with a + * `_` name prefix, and routes callTool by prefix. + * + * Graceful degradation: one failing server doesn't block others. + * Read-only invariant: tools with readOnlyHint === false are rejected. */ import { Client } from '@modelcontextprotocol/sdk/client'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { z } from 'zod'; import type { FastifyBaseLogger } from 'fastify'; -import type { Config } from '../config.js'; +import type { McpServerEntry, McpServerConfig } from './mcp-config.js'; import type { ToolDef } from './tools.js'; -// ---- Types for the MCP tool shape returned by listTools ---- +// ---- Types ---- + interface McpToolAnnotations { readOnlyHint?: boolean; destructiveHint?: boolean; @@ -27,99 +31,86 @@ interface McpToolDef { annotations?: McpToolAnnotations; } +interface ServerState { + client: Client; + transport: StreamableHTTPClientTransport | StdioClientTransport; + tools: ToolDef>[]; + type: 'streamableHttp' | 'stdio'; +} + // ---- Module-level state ---- -let client: Client | null = null; -let tools: ToolDef>[] = []; -let initialized = false; + +const servers = new Map(); +// Reverse map: prefixed tool name → server name (built during discovery) +const toolToServer = new Map(); let log: FastifyBaseLogger | null = null; -const NAME_PREFIX = 'context7_'; const MAX_RESULT_BYTES = 5 * 1024 * 1024; // ---- Public API ---- /** - * Connect to the Context7 MCP server, discover tools, and wrap them - * as BooCode ToolDefs. On failure, logs a warning and exposes zero tools. + * Connect to all configured MCP servers, discover tools, and wrap them. + * Per-server graceful degradation: a failing server is logged and skipped. */ -export async function initialize(config: Config, logger: FastifyBaseLogger): Promise { +export async function initialize( + entries: McpServerEntry[], + logger: FastifyBaseLogger, +): Promise { log = logger; - if (!config.MCP_CONTEXT7_URL) { - log.info('mcp: MCP_CONTEXT7_URL not set, skipping Context7 initialization'); - initialized = true; - return; - } - try { - client = new Client({ name: 'boocode', version: '1.14.1' }); - - const requestInit: RequestInit = {}; - if (config.MCP_CONTEXT7_API_KEY) { - requestInit.headers = { Authorization: `Bearer ${config.MCP_CONTEXT7_API_KEY}` }; - } - - const transport = new StreamableHTTPClientTransport( - new URL(config.MCP_CONTEXT7_URL), - { requestInit }, - ); - - await client.connect(transport); - - const result = await client.listTools(); - const mcpTools = (result.tools ?? []) as McpToolDef[]; - - tools = []; - for (const t of mcpTools) { - // D3: read-only invariant guard. Reject tools that explicitly declare - // readOnlyHint: false (i.e. write tools). Accept readOnlyHint: true - // or absent annotations (fail-open — most MCP servers don't annotate). - if (t.annotations?.readOnlyHint === false) { - log.info({ tool: t.name }, 'mcp: skipping non-read-only tool'); - continue; + // Connect servers in parallel — each wrapped in try/catch for isolation + await Promise.all( + entries.map(async (entry) => { + try { + await connectServer(entry); + } catch (err) { + log!.warn( + { err, server: entry.name }, + `mcp: failed to initialize server "${entry.name}" — its tools will be unavailable`, + ); } - tools.push(wrapMcpTool(t)); - } + }), + ); + if (servers.size > 0) { + const totalTools = Array.from(servers.values()).reduce((n, s) => n + s.tools.length, 0); log.info( - { count: tools.length, names: tools.map((t) => t.name) }, - 'mcp: initialized Context7', + { servers: servers.size, tools: totalTools }, + 'mcp: multi-server initialization complete', ); - initialized = true; - } catch (err) { - log.warn({ err }, 'mcp: failed to initialize Context7 — MCP tools will be unavailable'); - client = null; - tools = []; - initialized = true; } } /** - * Call an MCP tool by its prefixed name. Strips the prefix before - * forwarding to the MCP server. Returns a string on success or an - * error-shaped object on failure. + * Call an MCP tool by its prefixed name. Routes to the correct server + * using the toolToServer reverse map. */ export async function callTool( prefixedName: string, args: Record, ): Promise { - if (!client) { - return { error: true, output: 'MCP client not initialized' }; + const serverName = toolToServer.get(prefixedName); + if (!serverName) { + return { error: true, output: `MCP tool "${prefixedName}" not found in any server` }; } - const originalName = prefixedName.startsWith(NAME_PREFIX) - ? prefixedName.slice(NAME_PREFIX.length) - : prefixedName; + const state = servers.get(serverName); + if (!state) { + return { error: true, output: `MCP server "${serverName}" not available` }; + } + + // Strip the "_" prefix to get the original tool name + const originalName = prefixedName.slice(serverName.length + 1); try { - const result = await client.callTool({ name: originalName, arguments: args }); + const result = await state.client.callTool({ name: originalName, arguments: args }); - // D8: extract content blocks const content = result.content as Array<{ type: string; text?: string; [key: string]: unknown }>; if (!content || content.length === 0) { return '(no output)'; } - // If MCP reports an error, return error shape if (result.isError) { const joined = content .map((block) => (block.type === 'text' ? block.text ?? '' : JSON.stringify(block))) @@ -133,12 +124,12 @@ export async function callTool( }); const joined = parts.join('\n'); if (joined.length > MAX_RESULT_BYTES) { - log?.warn({ tool: originalName, bytes: joined.length, cap: MAX_RESULT_BYTES }, 'mcp: result truncated'); + log?.warn({ tool: originalName, server: serverName, bytes: joined.length, cap: MAX_RESULT_BYTES }, 'mcp: result truncated'); return joined.slice(0, MAX_RESULT_BYTES) + '\n\n[truncated — MCP result exceeded size limit]'; } return joined; } catch (err) { - log?.warn({ err, tool: originalName }, 'mcp: callTool failed'); + log?.warn({ err, tool: originalName, server: serverName }, 'mcp: callTool failed'); return { error: true, output: err instanceof Error ? err.message : 'MCP server unreachable', @@ -146,21 +137,114 @@ export async function callTool( } } -/** Return the wrapped ToolDefs discovered at initialization. */ +/** Return all wrapped ToolDefs from all connected servers, flattened. */ export function getTools(): ToolDef>[] { - return tools; + const all: ToolDef>[] = []; + for (const state of servers.values()) { + all.push(...state.tools); + } + return all; } -/** Whether initialize() has been called (even if it failed). */ -export function isInitialized(): boolean { - return initialized; +/** Return status of each server (for debug/status endpoints). */ +export function getMcpServers(): Array<{ + name: string; + type: 'streamableHttp' | 'stdio'; + toolCount: number; + connected: boolean; +}> { + return Array.from(servers.entries()).map(([name, state]) => ({ + name, + type: state.type, + toolCount: state.tools.length, + connected: true, + })); +} + +/** + * Graceful shutdown. For stdio servers, the SDK's transport.close() handles + * SIGTERM + timeout. For HTTP servers, close the transport. + */ +export async function shutdown(): Promise { + const closePromises: Promise[] = []; + for (const [name, state] of servers) { + closePromises.push( + (async () => { + try { + await state.transport.close(); + log?.info({ server: name }, 'mcp: server transport closed'); + } catch (err) { + log?.warn({ err, server: name }, 'mcp: error closing server transport'); + } + })(), + ); + } + await Promise.all(closePromises); + servers.clear(); + toolToServer.clear(); } // ---- Internal helpers ---- -/** Exposed for unit tests. */ -export function wrapMcpTool(mcpTool: McpToolDef): ToolDef> { - const prefixedName = `${NAME_PREFIX}${mcpTool.name}`; +async function connectServer(entry: McpServerEntry): Promise { + const { name, config } = entry; + + const client = new Client({ name: 'boocode', version: '1.15.0' }); + let transport: StreamableHTTPClientTransport | StdioClientTransport; + + if (config.type === 'streamableHttp') { + transport = createHttpTransport(config); + } else { + transport = createStdioTransport(config); + } + + await client.connect(transport); + + const result = await client.listTools(); + const mcpTools = (result.tools ?? []) as McpToolDef[]; + + const tools: ToolDef>[] = []; + for (const t of mcpTools) { + if (t.annotations?.readOnlyHint === false) { + log!.info({ tool: t.name, server: name }, 'mcp: skipping non-read-only tool'); + continue; + } + const wrapped = wrapMcpTool(name, t); + tools.push(wrapped); + toolToServer.set(wrapped.name, name); + } + + servers.set(name, { client, transport, tools, type: config.type }); + + log!.info( + { server: name, type: config.type, count: tools.length, names: tools.map((t) => t.name) }, + 'mcp: server initialized', + ); +} + +function createHttpTransport(config: Extract): StreamableHTTPClientTransport { + const requestInit: RequestInit = {}; + if (config.headers && Object.keys(config.headers).length > 0) { + requestInit.headers = config.headers; + } + return new StreamableHTTPClientTransport(new URL(config.url), { requestInit }); +} + +function createStdioTransport(config: Extract): StdioClientTransport { + return new StdioClientTransport({ + command: config.command, + args: config.args, + env: config.env, + stderr: 'pipe', + }); +} + +/** Wrap an MCP tool as a BooCode ToolDef with a server-name prefix. */ +export function wrapMcpTool( + serverName: string, + mcpTool: McpToolDef, +): ToolDef> { + const prefixedName = `${serverName}_${mcpTool.name}`; return { name: prefixedName, description: mcpTool.description ?? '', @@ -200,6 +284,5 @@ export function extractContent( /** Exposed for unit tests — the read-only guard predicate. */ export function isToolReadOnly(annotations?: McpToolAnnotations): boolean { - // Reject explicitly non-read-only tools; accept everything else return annotations?.readOnlyHint !== false; } diff --git a/apps/server/src/services/mcp-config.ts b/apps/server/src/services/mcp-config.ts new file mode 100644 index 0000000..e483059 --- /dev/null +++ b/apps/server/src/services/mcp-config.ts @@ -0,0 +1,78 @@ +/** + * v1.15.0-mcp-multi: MCP config file schema + loader. + * + * Reads a JSON config file (default `/data/mcp.json`) that declares MCP + * servers — their transport type, connection parameters, and enabled state. + * Schema shape matches opencode's `mcpServers` key for copy-paste compat. + */ +import { readFileSync } from 'node:fs'; +import { z } from 'zod'; +import type { FastifyBaseLogger } from 'fastify'; + +// ---- Zod schema ---- + +const McpServerConfigSchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('streamableHttp'), + url: z.string().url(), + headers: z.record(z.string()).optional(), + enabled: z.boolean().default(true), + }), + z.object({ + type: z.literal('stdio'), + command: z.string().min(1), + args: z.array(z.string()).default([]), + env: z.record(z.string()).optional(), + enabled: z.boolean().default(true), + }), +]); + +const McpConfigSchema = z.object({ + mcpServers: z.record(z.string(), McpServerConfigSchema).default({}), +}); + +export type McpServerConfig = z.infer; + +export interface McpServerEntry { + name: string; + config: McpServerConfig; +} + +// ---- Loader ---- + +/** + * Read and validate the MCP config file. Returns enabled servers only. + * File missing → log info, return []. Parse/validation error → log warn, return []. + */ +export function loadMcpConfig(configPath: string, log: FastifyBaseLogger): McpServerEntry[] { + let raw: string; + try { + raw = readFileSync(configPath, 'utf8'); + } catch { + log.info(`mcp: config not found at ${configPath}, skipping`); + return []; + } + + let json: unknown; + try { + json = JSON.parse(raw); + } catch (err) { + log.warn({ err }, `mcp: failed to parse ${configPath} as JSON`); + return []; + } + + const result = McpConfigSchema.safeParse(json); + if (!result.success) { + log.warn({ errors: result.error.flatten().fieldErrors }, `mcp: invalid config at ${configPath}`); + return []; + } + + const entries: McpServerEntry[] = []; + for (const [name, config] of Object.entries(result.data.mcpServers)) { + if (config.enabled) { + entries.push({ name, config }); + } + } + + return entries; +} diff --git a/data/mcp.json b/data/mcp.json new file mode 100644 index 0000000..38cdd97 --- /dev/null +++ b/data/mcp.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "context7": { + "type": "streamableHttp", + "url": "https://mcp.context7.com/mcp", + "enabled": false + } + } +} diff --git a/openspec/changes/v1.15-mcp-multi/design.md b/openspec/changes/v1.15-mcp-multi/design.md new file mode 100644 index 0000000..eb73ecb --- /dev/null +++ b/openspec/changes/v1.15-mcp-multi/design.md @@ -0,0 +1,59 @@ +# v1.15.0-mcp-multi — design decisions + +## D1. Config file path + +`/data/mcp.json` (alongside `AGENTS.md` at `/data/AGENTS.md`). Both are bind-mounted from the host's `data/` directory. Override via `MCP_CONFIG_PATH` env var. + +File missing = no MCP (opt-in by file presence, not by env var). Simpler than the v1.14.1 approach of always-defaulting a URL. + +## D2. Config schema matches opencode's `mcpServers` shape + +opencode uses `~/.opencode/config.json` with a `mcpServers` key. BooCode uses `mcp.json` with the same `mcpServers` key so server entries are copy-pasteable. Property names match: `type`, `url`, `command`, `args`, `env`, `headers`. BooCode adds `enabled` (boolean toggle per server, default true) which opencode doesn't have — harmless extra key. + +## D3. Transport types: streamableHttp + stdio only + +- **streamableHttp**: For remote servers (Context7, future cloud MCP services). Uses `@modelcontextprotocol/sdk`'s `StreamableHTTPClientTransport`. +- **stdio**: For local subprocess servers (codecontext, future local tools). Uses `@modelcontextprotocol/sdk`'s `StdioClientTransport` (spawns child process, NDJSON framing over stdin/stdout). +- **SSE**: Skipped. Streamable HTTP supersedes SSE per the MCP spec (May 2025 protocol update). If a legacy server requires SSE, it can be added later. + +## D4. Tool name prefixing: `_` + +Generalizes v1.14.1's `context7_` pattern. Server name comes from the config key (e.g. `"context7"`, `"codecontext"`). Collisions between servers with the same name are impossible (config keys are unique). Collisions between an MCP tool and a native tool are possible if someone names a server entry the same as a native tool prefix — but that's a user-configuration error, not a code bug. + +## D5. Per-agent glob patterns: last-match-wins + +AGENTS.md `tools:` field already supports exact-match arrays. Globs extend the same field: + +```yaml +tools: [view_file, grep, context7_*] +``` + +Evaluation: for each tool in `ALL_TOOLS`, scan the pattern list left-to-right. A `!` prefix denies. Last matching pattern wins. This matches the roadmap's "wildcard rule matcher" language. + +Examples: +- `[*]` — all tools (same as omitting `tools:` entirely) +- `[*, !web_*]` — all tools except web +- `[view_file, grep, context7_*]` — only view_file, grep, and all Context7 tools +- `[*]` on Architect + `[view_file]` on Prompt Builder — each agent gets its intended scope + +Globs use a simple `minimatch`-style check: `*` matches any characters. No `?` or `**` — tool names are flat (no path separators). + +## D6. No DB tables in v1.15 + +The roadmap listed `permissions`, `agent_permissions`, `session_permissions`, `mcp_servers` tables. All deferred to v2.0: + +- **Permission tables**: Enterprise multi-user pattern. BooChat is single-user behind Authelia. The read-only invariant guard is the BooChat-era defense. Formal permission rulesets land when BooCoder adds write tools. +- **`mcp_servers` table**: In-memory registry is sufficient. No need to persist server state to DB when the config file is the source of truth and tools are re-discovered on every boot. + +## D7. Stdio child lifecycle + +- Spawn on `initialize()`. Persistent connection for server lifetime (not per-call). +- On child exit (unexpected): mark server unavailable, log error. Do NOT auto-restart. BooCode continues with remaining servers. +- On BooCode shutdown (`app.addHook('onClose')`): send SIGTERM to all stdio children. Wait up to 5s, then SIGKILL. +- On ENOENT (command not found): skip server with a warning. Matches the graceful-degradation pattern from v1.14.1. + +## D8. v1.14.1 env vars removed + +`MCP_CONTEXT7_URL` and `MCP_CONTEXT7_API_KEY` are deleted from `config.ts`. They're superseded by the JSON config file's `context7` entry. The PoC was explicitly designed as throwaway. + +Migration path for anyone who had the env vars set: add a `data/mcp.json` with the Context7 entry. The CHANGELOG entry will note this. diff --git a/openspec/changes/v1.15-mcp-multi/proposal.md b/openspec/changes/v1.15-mcp-multi/proposal.md new file mode 100644 index 0000000..7e8ebc6 --- /dev/null +++ b/openspec/changes/v1.15-mcp-multi/proposal.md @@ -0,0 +1,130 @@ +# v1.15.0-mcp-multi — multi-server MCP client + stdio transport + config file + +Generalize the v1.14.1 single-server Context7 PoC into a multi-server MCP client. Add stdio transport (for local subprocess MCP servers like codecontext). JSON config file matching opencode's schema shape. Per-agent tool glob patterns in AGENTS.md frontmatter. + +## Why + +v1.14.1 proved the MCP loop works end-to-end but is hardcoded to one server (Context7) via env vars. Real value comes from multiple servers: Context7 for docs, codecontext re-wired as a proper MCP server (stdio), future local tools. The config shape should match opencode's so Sam can copy `mcp` blocks between the two without translation. + +## Scope + +### S1. JSON config file for MCP servers + +New file at `/data/mcp.json` (bind-mounted like `AGENTS.md`). Env var `MCP_CONFIG_PATH` points to it (default `/data/mcp.json`). + +Schema (matching opencode's shape): +```json +{ + "mcpServers": { + "context7": { + "type": "streamableHttp", + "url": "https://mcp.context7.com/mcp", + "headers": { "X-API-Key": "optional-key" }, + "enabled": true + }, + "codecontext": { + "type": "stdio", + "command": "/usr/local/bin/codecontext", + "args": ["--mcp"], + "env": { "WORKSPACE": "/opt" }, + "enabled": false + } + } +} +``` + +Zod-validated at startup. Unknown keys silently ignored (forward-compat). Each server entry has: +- `type`: `"streamableHttp"` | `"stdio"` (SSE deferred — Streamable HTTP supersedes it per the MCP spec) +- `url` (HTTP) or `command` + `args` + `env` (stdio) +- `headers` (HTTP, optional) — for API keys +- `enabled` (boolean, default true) + +### S2. Multi-server MCP client + +Refactor `mcp-client.ts` from a singleton to a registry of named MCP clients. On startup: +1. Read `/data/mcp.json` (or path from `MCP_CONFIG_PATH`) +2. For each enabled server: create a Client + transport, connect, discover tools via `tools/list` +3. Wrap tools with `_` prefix (generalizes the `context7_` pattern) +4. Apply read-only invariant guard per-tool (reject `readOnlyHint: false`) +5. Append all MCP tools to `ALL_TOOLS` in a single `appendMcpTools()` call +6. Per-server graceful degradation: one server failing doesn't block others + +Expose: `getMcpServers(): McpServerStatus[]` for debug/status endpoint, `callTool(prefixedName, args)` routed to the correct server by prefix. + +### S3. Stdio transport + +For `type: "stdio"` servers: spawn a subprocess via `child_process.spawn(command, args, {env, stdio: 'pipe'})`. Use `@modelcontextprotocol/sdk`'s `StdioClientTransport` (or implement the NDJSON framing ourselves — the SDK should have it). The subprocess runs for the lifetime of the BooCode server (persistent connection, not per-call spawn). + +Child lifecycle: +- Spawn on initialize. If spawn fails, log warn, skip server (graceful degradation). +- On child exit: log error, mark server as unavailable. Do NOT restart automatically (v1.15 keeps it simple; auto-restart is a v2.0 concern). +- On BooCode shutdown (`app.addHook('onClose')`): kill child processes. + +### S4. Per-agent tool glob patterns in AGENTS.md + +Currently `tools:` in AGENTS.md frontmatter is an exact-match whitelist (array of tool names). Extend to support glob patterns via a lightweight matcher: +- `context7_*` — all tools from the context7 server +- `view_*` — all tools starting with `view_` +- `!web_*` — exclude web tools (deny pattern) +- Plain names (`grep`, `view_file`) work as before (exact match) + +Evaluation order: for each tool in `ALL_TOOLS`, check if it matches any pattern in the agent's `tools:` list. A `!` prefix means exclude. Last-match-wins. + +Parser change in `agents.ts`: when validating `tools:`, don't reject unknown names if they contain `*` (glob patterns can't be validated against the current tool list since MCP tools are discovered at runtime). Exact names are still validated. + +### S5. Remove v1.14.1 env-var config + +Delete `MCP_CONTEXT7_URL` and `MCP_CONTEXT7_API_KEY` from `config.ts`. They're superseded by the JSON config file. The v1.14.1 PoC is throwaway-by-design (proposal said "throwaway-if-needed"). + +### S6. Read-only invariant preserved + +BooChat's read-only guarantee stays: every MCP tool with `readOnlyHint: false` is rejected at discovery. This applies globally, not per-server. Config has no `allowWriteTools` flag — that's a v2.0 BooCoder concern. + +## Deferred to v2.0 + +- **Permission ruleset tables** (`permissions`, `agent_permissions`, `session_permissions`). Enterprise pattern that doesn't serve until BooCoder adds write tools. The read-only invariant guard is the BooChat-era defense-in-depth. +- **OAuth / Dynamic Client Registration.** Needs secret storage primitive first. +- **SSE transport.** Streamable HTTP supersedes it per the MCP spec. SSE is a legacy fallback. +- **Per-session MCP toggle.** No `session.mcp_enabled` column in v1.15. MCP servers are globally configured; agent tool globs are the scoping mechanism. +- **`mcp_servers` DB table.** In-memory registry is sufficient for single-user. DB tracking deferred to v2.0. +- **codecontext re-wiring to MCP.** Separate batch after v1.15 proves stdio transport works. + +## Non-goals + +- No frontend changes. MCP tools surface via the existing tool registry; results render as normal tool-result parts. +- No schema changes. No new DB tables or columns. +- No changes to the inference loop (v1.14.0 outer loop unchanged). +- No changes to `executeToolCall` dispatch (transparent via ToolDef.execute). + +## Hard rules + +- No git commit/push. Sam commits. +- Read-only invariant: reject any MCP tool with `readOnlyHint: false`. +- Graceful degradation: any server down → that server's tools unavailable, rest unaffected. +- Alpha-sort of ALL_TOOLS preserved. +- One new dep only: none (MCP SDK already installed from v1.14.1). +- 348+ existing tests still pass. + +## Files expected to touch + +- `apps/server/src/services/mcp-client.ts` — refactor from singleton to multi-server registry (~200→300 lines) +- `apps/server/src/services/tools.ts` — no changes expected (appendMcpTools already works for multiple tools) +- `apps/server/src/config.ts` — replace MCP env vars with `MCP_CONFIG_PATH` +- `apps/server/src/index.ts` — startup reads config file, iterates servers +- `apps/server/src/services/agents.ts` — glob pattern support in `tools:` whitelist +- `data/mcp.json` — NEW, example config with Context7 (disabled by default, enabled via edit) +- `apps/server/src/services/__tests__/mcp-client.test.ts` — update for multi-server, add stdio transport tests +- `apps/server/src/services/__tests__/agents-glob.test.ts` — NEW, glob pattern matching tests + +## Estimate + +~350 LoC. The MCP SDK handles both transports; BooCode's job is config parsing, multi-server lifecycle, and glob matching. + +## Smoke plan + +1. Create `/data/mcp.json` with Context7 enabled. Restart. Confirm tools discovered + logged. +2. Send a chat asking about library docs. Confirm `context7_*` tools called + results rendered. +3. Disable Context7 in config (`"enabled": false`). Restart. Confirm zero MCP tools. +4. Add a dummy stdio server entry pointing to `/bin/cat` (will fail). Confirm graceful degradation: Context7 works, dummy fails with a logged warning. +5. Add `tools: [context7_*]` to the Architect agent in AGENTS.md. Confirm Architect sees only Context7 tools (via AgentPicker or by chatting with Architect selected). +6. Stop boocode, confirm child processes are killed (no orphans). diff --git a/openspec/changes/v1.15-mcp-multi/tasks.md b/openspec/changes/v1.15-mcp-multi/tasks.md new file mode 100644 index 0000000..8f50079 --- /dev/null +++ b/openspec/changes/v1.15-mcp-multi/tasks.md @@ -0,0 +1,87 @@ +# v1.15.0-mcp-multi tasks + +## B1 — Backups + +- [ ] `mcp-client.ts`, `config.ts`, `index.ts`, `agents.ts`, `mcp-client.test.ts` + +## B2 — MCP config file schema + loader + +- [ ] NEW `apps/server/src/services/mcp-config.ts` (~50 lines) +- [ ] Zod schema for `mcp.json`: `McpServerConfig` with `type`, `url/command/args/env`, `headers`, `enabled` +- [ ] `loadMcpConfig(configPath: string, log): McpServerConfig[]` — reads JSON, validates, returns enabled servers +- [ ] Graceful: file missing → log info, return empty array (no MCP) +- [ ] Graceful: parse error → log warn with details, return empty array + +## B3 — Config.ts: replace MCP env vars + +- [ ] Remove `MCP_CONTEXT7_URL` and `MCP_CONTEXT7_API_KEY` from Zod schema +- [ ] Add `MCP_CONFIG_PATH: z.string().optional()` (no default — opt-in) + +## B4 — Refactor mcp-client.ts to multi-server registry + +- [ ] Replace module-level singleton with `Map` +- [ ] `initialize(servers: McpServerConfig[], log)` — iterate servers, connect each, discover tools, wrap with `_` prefix, apply read-only guard +- [ ] Streamable HTTP transport: reuse existing pattern from v1.14.1 +- [ ] Stdio transport: use `@modelcontextprotocol/sdk`'s `StdioClientTransport` (check SDK exports; fallback to `child_process.spawn` + NDJSON if SDK doesn't expose it) +- [ ] `callTool(prefixedName, args)` — extract server name from prefix, route to correct client +- [ ] `getTools()` — return all tools from all servers, flattened +- [ ] `getMcpServers()` — return status of each server (name, type, toolCount, connected) +- [ ] Per-server graceful degradation: catch per-server errors, log, skip; continue with others +- [ ] `shutdown()` — kill stdio child processes, close HTTP clients +- [ ] `app.addHook('onClose')` calls shutdown + +## B5 — Startup wiring (index.ts) + +- [ ] Read config: `const mcpConfigPath = config.MCP_CONFIG_PATH ?? '/data/mcp.json'` +- [ ] `const mcpServers = loadMcpConfig(mcpConfigPath, app.log)` +- [ ] `await mcpClient.initialize(mcpServers, app.log)` +- [ ] `appendMcpTools(mcpClient.getTools())` +- [ ] Log summary: "mcp: N servers connected, M tools registered" +- [ ] `app.addHook('onClose', () => mcpClient.shutdown())` + +## B6 — AGENTS.md glob patterns + +- [ ] `apps/server/src/services/agents.ts` — in tool whitelist validation, skip validation for entries containing `*` (can't validate against runtime-discovered tools) +- [ ] NEW helper `matchToolGlob(toolName: string, patterns: string[]): boolean` — supports `*` wildcard and `!` deny prefix, last-match-wins +- [ ] Wire into `executeStreamPhase` (stream-phase.ts) where agent tools are filtered: replace exact-match `.includes()` with `matchToolGlob()` +- [ ] Export `matchToolGlob` for test access + +## B7 — Example config file + +- [ ] NEW `data/mcp.json` with Context7 entry (enabled: true, with URL, no API key) +- [ ] Comment in the file noting it's bind-mounted at `/data/mcp.json` inside the container + +## B8 — Tests + +- [ ] Update `mcp-client.test.ts` for multi-server wrapping (tools from two servers, prefix routing) +- [ ] Test: server A fails, server B succeeds — only B's tools registered +- [ ] Test: callTool routes to correct server by prefix +- [ ] Test: shutdown kills stdio transports +- [ ] NEW `apps/server/src/services/__tests__/mcp-glob.test.ts` +- [ ] Test: exact match ("grep" matches "grep") +- [ ] Test: wildcard ("context7_*" matches "context7_query-docs") +- [ ] Test: deny ("!web_*" excludes "web_search") +- [ ] Test: last-match-wins ("*" then "!web_*" → web tools excluded) +- [ ] Test: empty pattern list → nothing matches (agent gets no tools — same as current behavior for explicit whitelists) + +## B9 — Verification + +- [ ] `npx tsc --noEmit -p apps/server` — 0 errors +- [ ] `pnpm -C apps/server test` — all passing +- [ ] `pnpm -C apps/web build` — green (no web changes) + +## B10 — Deploy + smoke + +- [ ] Create `/data/mcp.json` on the host with Context7 enabled +- [ ] Update docker-compose bind mount if needed (data/ already mounted) +- [ ] `docker compose up --build -d` +- [ ] Check logs for multi-server init +- [ ] Live-smoke: Context7 tool call from chat +- [ ] Disable Context7 in config, restart, confirm zero MCP tools + +## B11 — Docs + tag + +- [ ] `CHANGELOG.md` entry +- [ ] `boocode_roadmap.md` retrospective bullet on v1.15 section +- [ ] `CLAUDE.md` — update MCP references +- [ ] Commit, tag `v1.15.0-mcp-multi`, push, rebuild