/** * 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. * * Secrets stay out of the config file via `{env:VAR}` substitution * (opencode-compatible). Any string value can reference an environment * variable, e.g. a header `"CONTEXT7_API_KEY": "{env:CONTEXT7_API_KEY}"` * resolves from `process.env` at load. This keeps real keys in `.env` * (`env_file` in docker-compose) rather than the gitignored config. */ 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; } // ---- Env-var substitution ---- const ENV_VAR_PATTERN = /\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g; /** * Recursively replace `{env:VAR}` references in string values with the * matching environment variable (opencode-compatible). Runs before Zod * validation so a resolved value (e.g. a `{env:...}` URL) still validates. * An unset var resolves to '' and logs a warning so a missing secret is * visible in the boot log rather than silently sending a literal placeholder. * Pass an optional `unsetVars` set to collect the names that resolved to ''; * the loader surfaces them on a validation failure (an empty value in a strict * url/command field invalidates the whole config — see loadMcpConfig). */ export function substituteEnvVars( value: unknown, log: FastifyBaseLogger, unsetVars?: Set, ): unknown { if (typeof value === 'string') { return value.replace(ENV_VAR_PATTERN, (_match, name: string) => { const resolved = process.env[name]; if (resolved === undefined) { unsetVars?.add(name); log.warn(`mcp: env var ${name} referenced in config is unset; substituting empty string`); return ''; } return resolved; }); } if (Array.isArray(value)) { return value.map((v) => substituteEnvVars(v, log, unsetVars)); } if (value && typeof value === 'object') { const out: Record = {}; for (const [k, v] of Object.entries(value as Record)) { out[k] = substituteEnvVars(v, log, unsetVars); } return out; } return value; } // ---- 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 unsetVars = new Set(); const result = McpConfigSchema.safeParse(substituteEnvVars(json, log, unsetVars)); if (!result.success) { // Connect the two otherwise-disconnected warnings: an unset {env:VAR} that // resolved to '' can invalidate a strict field (url/command) and drop the // whole config, so name the unset vars alongside the validation errors. const hint = unsetVars.size ? ` — ${unsetVars.size} referenced env var(s) unset & substituted with '' (${[...unsetVars].join(', ')}); an unset {env:VAR} in a url/command field invalidates the whole config` : ''; log.warn( { errors: result.error.flatten().fieldErrors, unsetEnvVars: [...unsetVars] }, `mcp: invalid config at ${configPath}${hint}`, ); return []; } const entries: McpServerEntry[] = []; for (const [name, config] of Object.entries(result.data.mcpServers)) { if (config.enabled) { entries.push({ name, config }); } } return entries; }