/** * MCP server configuration in Claude Code's format (pure). * * Sources, lowest to highest precedence: * ~/.claude.json (user, global) * /.mcp.json (project, checked in — walked up to the repo root) * /.claude/settings.local.json (project, personal) * * Every file holds an `mcpServers` object. A stdio server has `command` (plus * optional `args`, `env`); a remote one has `url` and optional `type`/`headers`. */ import { existsSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { claudeJsonPath } from "../lib/paths.ts"; export interface StdioServer { kind: "stdio"; name: string; command: string; args: string[]; env?: Record; source: string; /** Config referenced these environment variables and they are not set. */ missingEnv?: string[]; /** Every env var the config references (set or not), for the consent dialog — values are expanded away by parse time. */ referencedEnv?: string[]; } export interface HttpServer { kind: "http"; name: string; url: string; headers?: Record; source: string; /** Config referenced these environment variables and they are not set. */ missingEnv?: string[]; /** Every env var the config references (set or not), for the consent dialog — values are expanded away by parse time. */ referencedEnv?: string[]; } export type McpServer = StdioServer | HttpServer; interface RawServer { command?: unknown; args?: unknown; env?: unknown; url?: unknown; type?: unknown; headers?: unknown; disabled?: unknown; } function asStringRecord(value: unknown): Record | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const out: Record = {}; for (const [key, item] of Object.entries(value as Record)) { if (typeof item === "string") out[key] = item; } return Object.keys(out).length > 0 ? out : undefined; } /** `$VAR` and `${VAR}` references. A string source (not a shared object) so each use gets a fresh, unshared lastIndex. */ const ENV_VAR_SOURCE = "\\$\\{([A-Za-z_][A-Za-z0-9_]*)\\}|\\$([A-Za-z_][A-Za-z0-9_]*)"; /** Every env var name a value references, in order (with duplicates). The one scanner the others build on. */ function envVarNames(value: string): string[] { const names: string[] = []; const pattern = new RegExp(ENV_VAR_SOURCE, "g"); let match = pattern.exec(value); while (match) { names.push(match[1] ?? match[2]); match = pattern.exec(value); } return names; } /** Expands $VAR and ${VAR} in a config string, as Claude Code does. */ export function expandEnv(value: string, env: Record): string { return value.replace(new RegExp(ENV_VAR_SOURCE, "g"), (_match, braced, bare) => env[braced ?? bare] ?? ""); } /** * Variables a value references that are not set. Expanding them to "" produces * configuration that looks valid and fails confusingly at the server — a real * example being `Authorization: "Bearer ${GITHUB_PERSONAL_ACCESS_TOKEN}"` * becoming `"Bearer "`, which the endpoint rejects as a badly formatted header. */ export function missingEnvVars(value: string, env: Record): string[] { return [...new Set(envVarNames(value).filter((name) => !env[name]))]; } /** Every env var a value references, set or not — for the consent dialog (values are gone after expandEnv). */ export function referencedEnvVars(...values: string[]): string[] { return [...new Set(values.flatMap(envVarNames))]; } export function parseServer( name: string, raw: RawServer, source: string, env: Record, ): McpServer | undefined { if (raw.disabled === true) return undefined; if (typeof raw.url === "string" && raw.url.trim()) { const headers = asStringRecord(raw.headers); const missing = [ ...missingEnvVars(raw.url, env), ...Object.values(headers ?? {}).flatMap((value) => missingEnvVars(value, env)), ]; const referenced = referencedEnvVars(raw.url, ...Object.values(headers ?? {})); return { kind: "http", name, url: expandEnv(raw.url, env), headers: headers ? Object.fromEntries(Object.entries(headers).map(([k, v]) => [k, expandEnv(v, env)])) : undefined, source, missingEnv: missing.length > 0 ? [...new Set(missing)] : undefined, referencedEnv: referenced.length > 0 ? referenced : undefined, }; } if (typeof raw.command === "string" && raw.command.trim()) { const args = Array.isArray(raw.args) ? raw.args.filter((a): a is string => typeof a === "string").map((a) => expandEnv(a, env)) : []; const rawEnv = asStringRecord(raw.env); const missing = [ ...missingEnvVars(raw.command, env), ...(Array.isArray(raw.args) ? raw.args : []) .filter((a): a is string => typeof a === "string") .flatMap((a) => missingEnvVars(a, env)), ...Object.values(rawEnv ?? {}).flatMap((value) => missingEnvVars(value, env)), ]; const referenced = referencedEnvVars( raw.command, ...(Array.isArray(raw.args) ? raw.args.filter((a): a is string => typeof a === "string") : []), ...Object.values(rawEnv ?? {}), ); return { kind: "stdio", name, command: expandEnv(raw.command, env), args, env: rawEnv ? Object.fromEntries(Object.entries(rawEnv).map(([k, v]) => [k, expandEnv(v, env)])) : undefined, source, missingEnv: missing.length > 0 ? [...new Set(missing)] : undefined, referencedEnv: referenced.length > 0 ? referenced : undefined, }; } return undefined; } /** `.mcp.json` from cwd upward, so a repo-root config applies in subdirectories. */ export function findProjectConfigs(cwd: string): string[] { const found: string[] = []; let dir = cwd; while (true) { const candidate = join(dir, ".mcp.json"); if (existsSync(candidate)) found.push(candidate); if (existsSync(join(dir, ".git"))) break; const parent = dirname(dir); if (parent === dir) break; dir = parent; } // Nearest last, so it overrides ancestors. return found.reverse(); } export function configPaths(cwd: string, home: string): string[] { return [claudeJsonPath(home), ...findProjectConfigs(cwd), join(cwd, ".claude", "settings.local.json")]; } /** * A plugin's `.mcp.json` is a **bare** server map with no `mcpServers` wrapper, * unlike a project's. Accept either shape. */ function serverMapOf(file: Record | undefined): Record | undefined { if (!file || typeof file !== "object") return undefined; const wrapped = (file as { mcpServers?: unknown }).mcpServers; if (wrapped && typeof wrapped === "object" && !Array.isArray(wrapped)) { return wrapped as Record; } const looksLikeServerMap = Object.values(file).every( (value) => value && typeof value === "object" && !Array.isArray(value), ); return looksLikeServerMap ? (file as Record) : undefined; } export interface LoadServersOptions { /** Plugin `.mcp.json` paths → plugin name; their servers are named `plugin::` (CC's shape). */ pluginNames?: ReadonlyMap; /** Called for a config file that exists but is not valid JSON (review M11: a silent drop read as "no servers configured"). */ onError?: (path: string, message: string) => void; } export function loadServers( cwd: string, home: string, env: Record = process.env, extraPaths: string[] = [], options: LoadServersOptions = {}, ): McpServer[] { const byName = new Map(); // Plugin configs come first so project and user files can override them. for (const path of [...extraPaths, ...configPaths(cwd, home)]) { if (!existsSync(path)) continue; let file: Record | undefined; try { file = JSON.parse(readFileSync(path, "utf-8")) as Record; } catch (error) { options.onError?.(path, (error as Error).message); continue; } const servers = serverMapOf(file); if (!servers) continue; const plugin = options.pluginNames?.get(path); for (const [rawName, raw] of Object.entries(servers)) { const name = plugin ? `plugin:${plugin}:${rawName}` : rawName; const server = parseServer(name, raw ?? {}, path, env); if (server) byName.set(name, server); else byName.delete(name); // an explicit `disabled` entry removes an inherited one } } return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)); }