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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,3 +10,4 @@ secrets/
|
||||
data/*
|
||||
!data/AGENTS.md
|
||||
!data/skills/
|
||||
!data/mcp.json
|
||||
|
||||
@@ -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_<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
|
||||
|
||||
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.
|
||||
|
||||
@@ -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<typeof ConfigSchema>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 <serverName>_ 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
82
apps/server/src/services/__tests__/mcp-glob.test.ts
Normal file
82
apps/server/src/services/__tests__/mcp-glob.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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<Agent, 'source'> {
|
||||
|
||||
// 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));
|
||||
|
||||
|
||||
@@ -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 <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 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<string> = 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;
|
||||
|
||||
@@ -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
|
||||
* `<serverName>_<toolName>` 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<Record<string, unknown>>[];
|
||||
type: 'streamableHttp' | 'stdio';
|
||||
}
|
||||
|
||||
// ---- Module-level state ----
|
||||
let client: Client | null = null;
|
||||
let tools: ToolDef<Record<string, unknown>>[] = [];
|
||||
let initialized = false;
|
||||
|
||||
const servers = new Map<string, ServerState>();
|
||||
// Reverse map: prefixed tool name → server name (built during discovery)
|
||||
const toolToServer = new Map<string, string>();
|
||||
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<void> {
|
||||
export async function initialize(
|
||||
entries: McpServerEntry[],
|
||||
logger: FastifyBaseLogger,
|
||||
): Promise<void> {
|
||||
log = logger;
|
||||
if (!config.MCP_CONTEXT7_URL) {
|
||||
log.info('mcp: MCP_CONTEXT7_URL not set, skipping Context7 initialization');
|
||||
initialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Connect servers in parallel — each wrapped in try/catch for isolation
|
||||
await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
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;
|
||||
}
|
||||
tools.push(wrapMcpTool(t));
|
||||
}
|
||||
|
||||
log.info(
|
||||
{ count: tools.length, names: tools.map((t) => t.name) },
|
||||
'mcp: initialized Context7',
|
||||
);
|
||||
initialized = true;
|
||||
await connectServer(entry);
|
||||
} catch (err) {
|
||||
log.warn({ err }, 'mcp: failed to initialize Context7 — MCP tools will be unavailable');
|
||||
client = null;
|
||||
tools = [];
|
||||
initialized = true;
|
||||
log!.warn(
|
||||
{ err, server: entry.name },
|
||||
`mcp: failed to initialize server "${entry.name}" — its tools will be unavailable`,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if (servers.size > 0) {
|
||||
const totalTools = Array.from(servers.values()).reduce((n, s) => n + s.tools.length, 0);
|
||||
log.info(
|
||||
{ servers: servers.size, tools: totalTools },
|
||||
'mcp: multi-server initialization complete',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
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 "<serverName>_" 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<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). */
|
||||
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<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 ----
|
||||
|
||||
/** Exposed for unit tests. */
|
||||
export function wrapMcpTool(mcpTool: McpToolDef): ToolDef<Record<string, unknown>> {
|
||||
const prefixedName = `${NAME_PREFIX}${mcpTool.name}`;
|
||||
async function connectServer(entry: McpServerEntry): Promise<void> {
|
||||
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<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 {
|
||||
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;
|
||||
}
|
||||
|
||||
78
apps/server/src/services/mcp-config.ts
Normal file
78
apps/server/src/services/mcp-config.ts
Normal 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
9
data/mcp.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"context7": {
|
||||
"type": "streamableHttp",
|
||||
"url": "https://mcp.context7.com/mcp",
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
}
|
||||
59
openspec/changes/v1.15-mcp-multi/design.md
Normal file
59
openspec/changes/v1.15-mcp-multi/design.md
Normal 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.
|
||||
130
openspec/changes/v1.15-mcp-multi/proposal.md
Normal file
130
openspec/changes/v1.15-mcp-multi/proposal.md
Normal 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).
|
||||
87
openspec/changes/v1.15-mcp-multi/tasks.md
Normal file
87
openspec/changes/v1.15-mcp-multi/tasks.md
Normal 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
|
||||
Reference in New Issue
Block a user