import type { Environment } from "@berry/api"; import { BACKGROUND_CONTEXT, withAbortSignal } from "@earendil-works/chord/context"; export const DEFAULT_CONFIG = "~/.config/berry/mcp.json"; export type Server = & { enabled: boolean; timeout: number; cwd: string; } & ( | { command: string; args: string[]; env: Record } | { url: string; headers: Record } ); function record(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } /** The server entries from the selected file on the conversation's machine. */ export async function readConfig(env: Environment, path: string, signal: AbortSignal) { const result = await env.readTextFile(path, withAbortSignal(signal, BACKGROUND_CONTEXT)); if (!result.ok) { if (result.error.code === "not_found") return {}; throw result.error; } let parsed: unknown; try { parsed = JSON.parse(result.value); } catch { throw new Error(`Invalid JSON in MCP configuration: ${path}`); } if (!record(parsed) || !record(parsed.mcpServers)) { throw new Error(`MCP configuration ${path} must contain an mcpServers object`); } return parsed.mcpServers; } /** Validate a server and resolve variables and paths on its own machine. */ export async function serverConfig( env: Environment, raw: unknown, signal: AbortSignal, ): Promise { if (!record(raw)) throw new Error("Server configuration must be an object"); if (raw.enabled === false) throw new Error("MCP server is disabled"); const string = (value: unknown, field: string): string => { if (typeof value !== "string") throw new Error(`${field} must be a string`); return value.replace(/\$\{([^}]+)\}/g, (_, name: string) => { const found = env.variables[name]; if (found === undefined) { throw new Error(`Environment variable ${name} is not set on ${env.name}`); } return found; }); }; const strings = (value: unknown, field: string): Record => { if (value === undefined) return {}; if (!record(value)) throw new Error(`${field} must be an object of strings`); return Object.fromEntries( Object.entries(value).map(([key, value]) => [key, string(value, field)]), ); }; const home = (value: string) => value === "~" ? env.home : /^~[/\\]/.test(value) ? env.home + value.slice(1) : value; if (raw.enabled !== undefined && typeof raw.enabled !== "boolean") { throw new Error("enabled must be a boolean"); } const timeout = raw.timeout ?? 60; if (typeof timeout !== "number" || !Number.isFinite(timeout * 1000) || timeout <= 0) { throw new Error("timeout must be a positive number of seconds"); } if (timeout * 1000 > 0x7fffffff) { throw new Error("timeout exceeds the maximum supported timer duration"); } const cwd = await env.absolutePath( raw.cwd === undefined ? env.cwd : home(string(raw.cwd, "cwd")), withAbortSignal(signal, BACKGROUND_CONTEXT), ); if (!cwd.ok) throw cwd.error; const base = { enabled: raw.enabled !== false, timeout: timeout * 1000, cwd: cwd.value }; if (raw.url !== undefined) { if (raw.command !== undefined) throw new Error("Specify either command or url, not both"); if (raw.type !== undefined && raw.type !== "http" && raw.type !== "streamable-http") { throw new Error("Only Streamable HTTP is supported for URL servers"); } if (raw.oauth || raw.auth) { throw new Error("OAuth is not supported yet; use headers with an access token"); } const expanded = string(raw.url, "url"); let url: URL; try { url = new URL(expanded); } catch { throw new Error("url must be a valid absolute HTTP or HTTPS URL"); } if (url.protocol !== "https:" && url.protocol !== "http:") { throw new Error("MCP URLs must use HTTP or HTTPS"); } if (url.username || url.password) { throw new Error("MCP URL credentials are not supported; use headers instead"); } return { ...base, url: url.href, headers: strings(raw.headers, "headers"), }; } if (raw.type !== undefined && raw.type !== "stdio") { throw new Error("Only stdio command servers are supported"); } const command = home(string(raw.command, "command")); if (!command.trim()) throw new Error("command must not be empty"); if (raw.args !== undefined && !Array.isArray(raw.args)) { throw new Error("args must be an array of strings"); } return { ...base, command, args: (raw.args as unknown[] | undefined ?? []).map((arg) => home(string(arg, "args"))), env: strings(raw.env, "env"), }; }