Files
boocode/apps/server/src/services/mcp-config.ts
indifferentketchup d27a977d59 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>
2026-05-24 04:08:42 +00:00

79 lines
2.1 KiB
TypeScript

/**
* 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;
}