UNPKG

@earendil-works/pi-coding-agent

Version:

Coding agent CLI with read, bash, edit, write tools and session management

192 lines • 8.52 kB
/** * MCP server configuration. * * Servers are read from `mcp.json` in the agent directory and, for trusted projects, from * `<project>/.pi/mcp.json`. Both use the `mcpServers` shape shared by other MCP clients, so * existing configurations can be copied over. Project entries replace global entries with the * same name. * * A project entry without `command`, `url`, or `type` overrides only `enabled`, `exposure`, and * `toolExposure` of the global server with the same name, for example to turn it off in one project: * `{ "mcpServers": { "internal-tools": { "enabled": false } } }`. The rest of the global entry is kept, * including credentials the project could not set itself. * * ```json * { * "mcpServers": { * "filesystem": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "."] }, * "docs": { "url": "https://example.com/mcp", "headers": { "Authorization": "Bearer ${DOCS_TOKEN}" } }, * "sentry": { "url": "https://mcp.sentry.dev/mcp" } * } * } * ``` * * HTTP servers without an `Authorization` header use OAuth when they answer 401 (sign in with `/mcp`). * `"auth": { "provider": "<provider>" }` sends the token of a `/login` provider instead. Project files * cannot use it, so a repository cannot pick where the credential goes. * * The top-level `autoEnableCodemode` (default true) activates the codemode tool when a server * with `codemode` exposure connects. A project value overrides the global one. */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { isMcpServerOverride, mcpNamespace } from "@earendil-works/pi-mcp"; import { CONFIG_DIR_NAME } from "../../config.js"; import { validateMcpServerConfig } from "../../core/mcp-servers.js"; import { stripBom } from "../../utils/text.js"; export { getMcpToolExposure } from "../../core/mcp-servers.js"; const OVERRIDE_KEYS = ["enabled", "exposure", "toolExposure"]; function isRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } function readConfigFile(path, scope, state) { const { servers, errors } = state; if (!existsSync(path)) return; let parsed; try { parsed = JSON.parse(stripBom(readFileSync(path, "utf8"))); } catch (error) { errors.push(`${path}: ${error instanceof Error ? error.message : String(error)}`); return; } if (!isRecord(parsed) || (parsed.mcpServers !== undefined && !isRecord(parsed.mcpServers))) { errors.push(`${path}: expected an object with an "mcpServers" object`); return; } if (typeof parsed.autoEnableCodemode === "boolean") state.autoEnableCodemode = parsed.autoEnableCodemode; else if (parsed.autoEnableCodemode !== undefined) errors.push(`${path}: autoEnableCodemode must be a boolean`); for (const [name, value] of Object.entries(parsed.mcpServers ?? {})) { if (scope === "project" && isRecord(value) && isMcpServerOverride(value)) { const base = servers.get(name); const extra = Object.keys(value).filter((key) => !OVERRIDE_KEYS.includes(key)); if (!base) { errors.push(`${path}: server "${name}" needs "command" or "url", or a global server to override`); } else if (extra.length > 0) { errors.push(`${path}: server "${name}": an override can only set ${OVERRIDE_KEYS.join(", ")}`); } else { const config = validateMcpServerConfig(name, { ...base.config, ...value }); if (typeof config === "string") errors.push(`${path}: ${config}`); else servers.set(name, { ...base, config, override: path }); } continue; } const config = validateMcpServerConfig(name, value); if (typeof config === "string") { errors.push(`${path}: ${config}`); continue; } // Names that differ only in `-` and `_` would share a namespace. const clash = [...servers.keys()].find((other) => other !== name && mcpNamespace(other) === mcpNamespace(name)); if (clash) { errors.push(`${path}: server "${name}" conflicts with "${clash}"`); continue; } if (scope === "project" && "url" in config && config.auth) { errors.push(`${path}: server "${name}": auth is only allowed in the global mcp.json`); continue; } servers.set(name, { name, config, source: path, scope }); } } /** * Load global and (when trusted) project MCP configuration. Disabled servers are included with * `enabled: false`, so they can be enabled again. */ export function loadMcpConfig(options) { const state = { servers: new Map(), errors: [] }; readConfigFile(join(options.agentDir, "mcp.json"), "global", state); const projectConfig = options.projectTrusted ? join(options.cwd, CONFIG_DIR_NAME, "mcp.json") : undefined; if (projectConfig) readConfigFile(projectConfig, "project", state); return { servers: [...state.servers.values()], ...(state.autoEnableCodemode === undefined ? {} : { autoEnableCodemode: state.autoEnableCodemode }), errors: state.errors, ...(projectConfig ? { projectConfig } : {}), }; } /** * Change one server's settings in the `mcp.json` that defines or overrides it. With `override`, a * missing entry is added as an override. Overrides keep default values, since they replace the global * server's. Other content is kept; the file is rewritten with its indentation. */ export function updateMcpServerConfig(path, name, patch, options = {}) { editMcpServers(path, (servers, parsed) => { let server = servers?.[name]; if (server === undefined && options.override) { server = {}; parsed.mcpServers = { ...servers, [name]: server }; } if (!isRecord(server)) throw new Error(`${path} does not define MCP server "${name}"`); const keepDefaults = isMcpServerOverride(server); if (patch.enabled !== undefined) { if (patch.enabled && !keepDefaults) delete server.enabled; else server.enabled = patch.enabled; } if (patch.exposure !== undefined) { if (patch.exposure === "codemode" && !keepDefaults) delete server.exposure; else server.exposure = patch.exposure; } return true; }); } /** * Add a server to an `mcp.json`, creating the file when missing. An existing entry with the same * name is replaced. Returns true when an entry was replaced. */ export function addMcpServerConfig(path, name, config) { let replaced = false; editMcpServers(path, (servers, parsed) => { const target = servers ?? {}; replaced = target[name] !== undefined; target[name] = config; parsed.mcpServers = target; return true; }); return replaced; } /** Remove a server from an `mcp.json`. Returns false when the file does not define it. */ export function removeMcpServerConfig(path, name) { if (!existsSync(path)) return false; let removed = false; editMcpServers(path, (servers) => { if (!servers || servers[name] === undefined) return false; delete servers[name]; removed = true; return true; }); return removed; } /** * Read an `mcp.json` (an empty config when missing), let `edit` change its `mcpServers`, and write * it back with its indentation when `edit` returns true. Other content is kept. */ function editMcpServers(path, edit) { const text = existsSync(path) ? stripBom(readFileSync(path, "utf8")) : undefined; const parsed = text === undefined ? {} : JSON.parse(text); if (!isRecord(parsed) || (parsed.mcpServers !== undefined && !isRecord(parsed.mcpServers))) { throw new Error(`${path}: expected an object with an "mcpServers" object`); } const servers = isRecord(parsed.mcpServers) ? parsed.mcpServers : undefined; if (!edit(servers, parsed)) return; const indent = (text && /^([ \t]+)\S/m.exec(text)?.[1]) || " "; mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify(parsed, null, indent)}\n`); } //# sourceMappingURL=config.js.map