v1.15.0-mcp-multi: multi-server MCP client + stdio transport + config file + tool globs

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_<name> to <serverName>_<toolName> 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) <noreply@anthropic.com>
This commit is contained in:
2026-05-24 04:08:42 +00:00
parent 5692e99a5d
commit d27a977d59
14 changed files with 741 additions and 121 deletions

1
.gitignore vendored
View File

@@ -10,3 +10,4 @@ secrets/
data/* data/*
!data/AGENTS.md !data/AGENTS.md
!data/skills/ !data/skills/
!data/mcp.json

View File

@@ -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. 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_<name>` to `<serverName>_<toolName>` 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 ## 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_<name>`, 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. 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_<name>`, 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.

View File

@@ -19,10 +19,9 @@ const ConfigSchema = z.object({
GITEA_USER: z.string().default('indifferentketchup'), GITEA_USER: z.string().default('indifferentketchup'),
GITEA_TOKEN: z.string().optional(), GITEA_TOKEN: z.string().optional(),
GITEA_SSH_HOST: z.string().default('100.114.205.53:2222'), GITEA_SSH_HOST: z.string().default('100.114.205.53:2222'),
// v1.14.1-mcp-poc: Context7 MCP server. Streamable HTTP transport. // v1.15.0-mcp-multi: path to the MCP config JSON file. Default /data/mcp.json
// Set to empty string or omit to disable MCP tools entirely. // (bind-mounted alongside AGENTS.md). File missing = no MCP (opt-in).
MCP_CONTEXT7_URL: z.string().optional(), MCP_CONFIG_PATH: z.string().optional(),
MCP_CONTEXT7_API_KEY: z.string().optional(),
}); });
export type Config = z.infer<typeof ConfigSchema>; export type Config = z.infer<typeof ConfigSchema>;

View File

@@ -24,8 +24,10 @@ import { listSkills } from './services/skills.js';
import * as compaction from './services/compaction.js'; import * as compaction from './services/compaction.js';
import { configureModelContext } from './services/model-context.js'; import { configureModelContext } from './services/model-context.js';
import { cleanupTruncations } from './services/truncate.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 { appendMcpTools } from './services/tools.js';
import { refreshToolNames } from './services/agents.js';
async function main() { async function main() {
const config = loadConfig(); const config = loadConfig();
@@ -71,21 +73,22 @@ async function main() {
// default_generation_settings.n_ctx — the value persisted as messages.ctx_max. // default_generation_settings.n_ctx — the value persisted as messages.ctx_max.
configureModelContext({ llamaSwapUrl: config.LLAMA_SWAP_URL }); configureModelContext({ llamaSwapUrl: config.LLAMA_SWAP_URL });
// v1.14.1-mcp-poc: connect to Context7 MCP server and register discovered // v1.15.0-mcp-multi: read MCP config file and connect to all enabled servers.
// tools into ALL_TOOLS. Runs before route registration so the tool list is // Runs before route registration so the tool list is complete when the first
// complete when the first inference request arrives. Graceful degradation: // inference request arrives. Per-server graceful degradation: one failing
// if Context7 is unreachable, zero MCP tools are registered and BooCode // server doesn't block others.
// functions normally with native tools. const mcpConfigPath = config.MCP_CONFIG_PATH ?? '/data/mcp.json';
if (config.MCP_CONTEXT7_URL) { const mcpServers = loadMcpConfig(mcpConfigPath, app.log);
await initMcpClient(config, app.log); if (mcpServers.length > 0) {
await initMcp(mcpServers, app.log);
const mcpTools = getMcpTools(); const mcpTools = getMcpTools();
if (mcpTools.length > 0) { if (mcpTools.length > 0) {
appendMcpTools(mcpTools); 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); await app.register(fastifyWebsocket);

View File

@@ -1,14 +1,15 @@
/** /**
* v1.14.1-mcp-poc: unit tests for the MCP client service. * v1.15.0-mcp-multi: unit tests for the multi-server MCP client.
* Pure unit tests — no live MCP server needed. Tests the tool-wrapping, * Pure unit tests — no live MCP server needed. Tests tool-wrapping,
* read-only guard, name prefixing, content extraction, and error handling. * 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 { describe, it, expect } from 'vitest';
import { wrapMcpTool, extractContent, isToolReadOnly } from '../mcp-client.js'; import { wrapMcpTool, extractContent, isToolReadOnly } from '../mcp-client.js';
describe('mcp-client', () => { describe('mcp-client', () => {
describe('wrapMcpTool', () => { describe('wrapMcpTool — multi-server prefixing', () => {
it('produces a ToolDef with context7_ prefix', () => { it('produces a ToolDef with <serverName>_ prefix', () => {
const mcpTool = { const mcpTool = {
name: 'resolve-library-id', name: 'resolve-library-id',
description: 'Resolve a library identifier', 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.name).toBe('context7_resolve-library-id');
expect(wrapped.description).toBe('Resolve a library identifier'); expect(wrapped.description).toBe('Resolve a library identifier');
@@ -29,13 +30,56 @@ describe('mcp-client', () => {
expect(typeof wrapped.execute).toBe('function'); 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', () => { it('defaults description to empty string when absent', () => {
const mcpTool = { const mcpTool = {
name: 'no-desc', name: 'no-desc',
inputSchema: { type: 'object' as const, properties: {} }, inputSchema: { type: 'object' as const, properties: {} },
}; };
const wrapped = wrapMcpTool(mcpTool); const wrapped = wrapMcpTool('myserver', mcpTool);
expect(wrapped.description).toBe(''); expect(wrapped.description).toBe('');
expect(wrapped.jsonSchema.function.description).toBe(''); expect(wrapped.jsonSchema.function.description).toBe('');
@@ -47,9 +91,8 @@ describe('mcp-client', () => {
inputSchema: { type: 'object' as const, properties: {} }, 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 }); const result = wrapped.inputSchema.safeParse({ foo: 'bar', baz: 123 });
expect(result.success).toBe(true); expect(result.success).toBe(true);
}); });
@@ -73,7 +116,6 @@ describe('mcp-client', () => {
}); });
it('accepts tools with only destructiveHint set', () => { it('accepts tools with only destructiveHint set', () => {
// readOnlyHint is not set, so it should be accepted per D3
expect(isToolReadOnly({ destructiveHint: true })).toBe(true); expect(isToolReadOnly({ destructiveHint: true })).toBe(true);
}); });
}); });
@@ -124,18 +166,4 @@ describe('mcp-client', () => {
expect(result).toEqual({ error: true, output: 'error 1\nerror 2' }); 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');
});
});
}); });

View File

@@ -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);
});
});

View File

@@ -16,10 +16,62 @@ const CACHE_TTL_MS = 60_000;
// hand-maintained list drifted (web_search/web_fetch from v1.11.8 + the 8 // hand-maintained list drifted (web_search/web_fetch from v1.11.8 + the 8
// codecontext tools were missing), silently filtering valid tool names out // codecontext tools were missing), silently filtering valid tool names out
// of agents that opted in. Single source of truth is tools.ts now. // 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); let ALL_TOOL_NAMES: readonly string[] = ALL_TOOLS.map((t) => t.name);
const DEFAULT_TOOLS: string[] = [...ALL_TOOL_NAMES]; 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; 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 { export function slugify(name: string): string {
return name return name
.toLowerCase() .toLowerCase()
@@ -207,10 +259,14 @@ function parseAgentSection(section: RawSection): Omit<Agent, 'source'> {
// v1.13.15-tools: intersect with BOOCODE_TOOLS tier (ceiling, not expansion). // v1.13.15-tools: intersect with BOOCODE_TOOLS tier (ceiling, not expansion).
// Unset → resolveToolTier returns ALL tool names → no narrowing. // 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 tierAllowed = new Set(resolveToolTier(process.env.BOOCODE_TOOLS));
const filteredTools = Array.isArray(fm.tools) const filteredTools = Array.isArray(fm.tools)
? fm.tools.filter((t): t is string => ? 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)); : DEFAULT_TOOLS.filter((t) => tierAllowed.has(t));

View File

@@ -5,6 +5,7 @@ import type {
} from '../../types/api.js'; } from '../../types/api.js';
import * as modelContext from '../model-context.js'; import * as modelContext from '../model-context.js';
import { toolJsonSchemas, type ToolJsonSchema } from '../tools.js'; import { toolJsonSchemas, type ToolJsonSchema } from '../tools.js';
import { matchToolGlob } from '../agents.js';
import type { OpenAiMessage } from './payload.js'; import type { OpenAiMessage } from './payload.js';
// v1.13.16: extractToolCallBlocks replaces the inline opener-search loop and // v1.13.16: extractToolCallBlocks replaces the inline opener-search loop and
// recognizes both Qwen <tool_call> and Anthropic <invoke> markup in one pass. // recognizes both Qwen <tool_call> and Anthropic <invoke> 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 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 // tool names it allows. v1.15.0-mcp-multi: uses matchToolGlob for glob
// (handled here by intersection). When no agent: send all tools. // 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 // 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 // has them explicitly enabled. Counts as an opt-in security boundary: the
// model can't summon a tool that wasn't offered to it. // model can't summon a tool that wasn't offered to it.
const WEB_TOOL_NAMES: ReadonlySet<string> = new Set(['web_search', 'web_fetch']); const WEB_TOOL_NAMES: ReadonlySet<string> = new Set(['web_search', 'web_fetch']);
const effectiveTools: ToolJsonSchema[] = (agent const effectiveTools: ToolJsonSchema[] = (agent
? toolJsonSchemas().filter((t) => agent.tools.includes(t.function.name)) ? toolJsonSchemas().filter((t) => matchToolGlob(t.function.name, agent.tools))
: toolJsonSchemas() : toolJsonSchemas()
).filter((t) => webToolsEnabled || !WEB_TOOL_NAMES.has(t.function.name)); ).filter((t) => webToolsEnabled || !WEB_TOOL_NAMES.has(t.function.name));
const effectiveTemperature = agent?.temperature; const effectiveTemperature = agent?.temperature;

View File

@@ -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, * Connects to multiple MCP servers (Streamable HTTP or stdio transport),
* wraps each as a BooCode ToolDef with a `context7_` name prefix. * discovers tools from each, wraps them as BooCode ToolDefs with a
* Graceful degradation: if the server is unreachable, zero tools are * `<serverName>_<toolName>` name prefix, and routes callTool by prefix.
* exposed and BooCode functions normally with native tools. *
* 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 { Client } from '@modelcontextprotocol/sdk/client';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { z } from 'zod'; import { z } from 'zod';
import type { FastifyBaseLogger } from 'fastify'; 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'; import type { ToolDef } from './tools.js';
// ---- Types for the MCP tool shape returned by listTools ---- // ---- Types ----
interface McpToolAnnotations { interface McpToolAnnotations {
readOnlyHint?: boolean; readOnlyHint?: boolean;
destructiveHint?: boolean; destructiveHint?: boolean;
@@ -27,99 +31,86 @@ interface McpToolDef {
annotations?: McpToolAnnotations; annotations?: McpToolAnnotations;
} }
interface ServerState {
client: Client;
transport: StreamableHTTPClientTransport | StdioClientTransport;
tools: ToolDef<Record<string, unknown>>[];
type: 'streamableHttp' | 'stdio';
}
// ---- Module-level state ---- // ---- Module-level state ----
let client: Client | null = null;
let tools: ToolDef<Record<string, unknown>>[] = []; const servers = new Map<string, ServerState>();
let initialized = false; // Reverse map: prefixed tool name → server name (built during discovery)
const toolToServer = new Map<string, string>();
let log: FastifyBaseLogger | null = null; let log: FastifyBaseLogger | null = null;
const NAME_PREFIX = 'context7_';
const MAX_RESULT_BYTES = 5 * 1024 * 1024; const MAX_RESULT_BYTES = 5 * 1024 * 1024;
// ---- Public API ---- // ---- Public API ----
/** /**
* Connect to the Context7 MCP server, discover tools, and wrap them * Connect to all configured MCP servers, discover tools, and wrap them.
* as BooCode ToolDefs. On failure, logs a warning and exposes zero tools. * Per-server graceful degradation: a failing server is logged and skipped.
*/ */
export async function initialize(config: Config, logger: FastifyBaseLogger): Promise<void> { export async function initialize(
entries: McpServerEntry[],
logger: FastifyBaseLogger,
): Promise<void> {
log = logger; log = logger;
if (!config.MCP_CONTEXT7_URL) {
log.info('mcp: MCP_CONTEXT7_URL not set, skipping Context7 initialization');
initialized = true;
return;
}
try { // Connect servers in parallel — each wrapped in try/catch for isolation
client = new Client({ name: 'boocode', version: '1.14.1' }); await Promise.all(
entries.map(async (entry) => {
const requestInit: RequestInit = {}; try {
if (config.MCP_CONTEXT7_API_KEY) { await connectServer(entry);
requestInit.headers = { Authorization: `Bearer ${config.MCP_CONTEXT7_API_KEY}` }; } catch (err) {
} log!.warn(
{ err, server: entry.name },
const transport = new StreamableHTTPClientTransport( `mcp: failed to initialize server "${entry.name}" — its tools will be unavailable`,
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;
} }
tools.push(wrapMcpTool(t)); }),
} );
if (servers.size > 0) {
const totalTools = Array.from(servers.values()).reduce((n, s) => n + s.tools.length, 0);
log.info( log.info(
{ count: tools.length, names: tools.map((t) => t.name) }, { servers: servers.size, tools: totalTools },
'mcp: initialized Context7', '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 * Call an MCP tool by its prefixed name. Routes to the correct server
* forwarding to the MCP server. Returns a string on success or an * using the toolToServer reverse map.
* error-shaped object on failure.
*/ */
export async function callTool( export async function callTool(
prefixedName: string, prefixedName: string,
args: Record<string, unknown>, args: Record<string, unknown>,
): Promise<unknown> { ): Promise<unknown> {
if (!client) { const serverName = toolToServer.get(prefixedName);
return { error: true, output: 'MCP client not initialized' }; if (!serverName) {
return { error: true, output: `MCP tool "${prefixedName}" not found in any server` };
} }
const originalName = prefixedName.startsWith(NAME_PREFIX) const state = servers.get(serverName);
? prefixedName.slice(NAME_PREFIX.length) if (!state) {
: prefixedName; return { error: true, output: `MCP server "${serverName}" not available` };
}
// Strip the "<serverName>_" prefix to get the original tool name
const originalName = prefixedName.slice(serverName.length + 1);
try { 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 }>; const content = result.content as Array<{ type: string; text?: string; [key: string]: unknown }>;
if (!content || content.length === 0) { if (!content || content.length === 0) {
return '(no output)'; return '(no output)';
} }
// If MCP reports an error, return error shape
if (result.isError) { if (result.isError) {
const joined = content const joined = content
.map((block) => (block.type === 'text' ? block.text ?? '' : JSON.stringify(block))) .map((block) => (block.type === 'text' ? block.text ?? '' : JSON.stringify(block)))
@@ -133,12 +124,12 @@ export async function callTool(
}); });
const joined = parts.join('\n'); const joined = parts.join('\n');
if (joined.length > MAX_RESULT_BYTES) { 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.slice(0, MAX_RESULT_BYTES) + '\n\n[truncated — MCP result exceeded size limit]';
} }
return joined; return joined;
} catch (err) { } catch (err) {
log?.warn({ err, tool: originalName }, 'mcp: callTool failed'); log?.warn({ err, tool: originalName, server: serverName }, 'mcp: callTool failed');
return { return {
error: true, error: true,
output: err instanceof Error ? err.message : 'MCP server unreachable', 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<Record<string, unknown>>[] { export function getTools(): ToolDef<Record<string, unknown>>[] {
return tools; const all: ToolDef<Record<string, unknown>>[] = [];
for (const state of servers.values()) {
all.push(...state.tools);
}
return all;
} }
/** Whether initialize() has been called (even if it failed). */ /** Return status of each server (for debug/status endpoints). */
export function isInitialized(): boolean { export function getMcpServers(): Array<{
return initialized; 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<void> {
const closePromises: Promise<void>[] = [];
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 ---- // ---- Internal helpers ----
/** Exposed for unit tests. */ async function connectServer(entry: McpServerEntry): Promise<void> {
export function wrapMcpTool(mcpTool: McpToolDef): ToolDef<Record<string, unknown>> { const { name, config } = entry;
const prefixedName = `${NAME_PREFIX}${mcpTool.name}`;
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<Record<string, unknown>>[] = [];
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<McpServerConfig, { type: 'streamableHttp' }>): 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<McpServerConfig, { type: 'stdio' }>): 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<Record<string, unknown>> {
const prefixedName = `${serverName}_${mcpTool.name}`;
return { return {
name: prefixedName, name: prefixedName,
description: mcpTool.description ?? '', description: mcpTool.description ?? '',
@@ -200,6 +284,5 @@ export function extractContent(
/** Exposed for unit tests — the read-only guard predicate. */ /** Exposed for unit tests — the read-only guard predicate. */
export function isToolReadOnly(annotations?: McpToolAnnotations): boolean { export function isToolReadOnly(annotations?: McpToolAnnotations): boolean {
// Reject explicitly non-read-only tools; accept everything else
return annotations?.readOnlyHint !== false; return annotations?.readOnlyHint !== false;
} }

View File

@@ -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<typeof McpServerConfigSchema>;
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;
}

9
data/mcp.json Normal file
View File

@@ -0,0 +1,9 @@
{
"mcpServers": {
"context7": {
"type": "streamableHttp",
"url": "https://mcp.context7.com/mcp",
"enabled": false
}
}
}

View File

@@ -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: `<serverName>_<toolName>`
Generalizes v1.14.1's `context7_<name>` 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.

View File

@@ -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 `<server-name>_<tool-name>` 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).

View File

@@ -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<serverName, {client, transport, tools}>`
- [ ] `initialize(servers: McpServerConfig[], log)` — iterate servers, connect each, discover tools, wrap with `<serverName>_<toolName>` 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