/** * v1.15.0-mcp-multi: MCP config file schema + loader. * * Reads a JSON config file (default `/data/mcp.json`) that declares MCP * servers — their transport type, connection parameters, and enabled state. * Schema shape matches opencode's `mcpServers` key for copy-paste compat. */ import { readFileSync } from 'node:fs'; import { z } from 'zod'; import type { FastifyBaseLogger } from 'fastify'; // ---- Zod schema ---- const McpServerConfigSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('streamableHttp'), url: z.string().url(), headers: z.record(z.string()).optional(), enabled: z.boolean().default(true), }), z.object({ type: z.literal('stdio'), command: z.string().min(1), args: z.array(z.string()).default([]), env: z.record(z.string()).optional(), enabled: z.boolean().default(true), }), ]); const McpConfigSchema = z.object({ mcpServers: z.record(z.string(), McpServerConfigSchema).default({}), }); export type McpServerConfig = z.infer; export interface McpServerEntry { name: string; config: McpServerConfig; } // ---- Loader ---- /** * Read and validate the MCP config file. Returns enabled servers only. * File missing → log info, return []. Parse/validation error → log warn, return []. */ export function loadMcpConfig(configPath: string, log: FastifyBaseLogger): McpServerEntry[] { let raw: string; try { raw = readFileSync(configPath, 'utf8'); } catch { log.info(`mcp: config not found at ${configPath}, skipping`); return []; } let json: unknown; try { json = JSON.parse(raw); } catch (err) { log.warn({ err }, `mcp: failed to parse ${configPath} as JSON`); return []; } const result = McpConfigSchema.safeParse(json); if (!result.success) { log.warn({ errors: result.error.flatten().fieldErrors }, `mcp: invalid config at ${configPath}`); return []; } const entries: McpServerEntry[] = []; for (const [name, config] of Object.entries(result.data.mcpServers)) { if (config.enabled) { entries.push({ name, config }); } } return entries; }