/** * Safe mcp.json + extension-config I/O. * * - mcp.json path: `/mcp.json` (PI_CODING_AGENT_DIR aware), the * exact file pi-mcp-adapter reads (`getPiGlobalConfigPath`). * - Atomic writes via temp file + rename. * - Corrupt JSON is backed up as `mcp.json.backup-.json` and the * error surfaces to the caller; we never silently overwrite user data. * - Every mutation re-reads the file first so concurrent edits are preserved. */ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { createHash } from "node:crypto"; import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; import type { McpConfig, RegistriesConfig, RegistrySpec } from "./types.ts"; import { ti } from "./i18n.ts"; /** Resolve the pi agent directory, honoring PI_CODING_AGENT_DIR (same rules as pi-mcp-adapter's agent-dir.ts). */ export function getAgentDir(): string { const configured = process.env.PI_CODING_AGENT_DIR?.trim(); if (!configured) return join(homedir(), ".pi", "agent"); if (configured === "~") return homedir(); if (configured.startsWith("~/")) return resolve(homedir(), configured.slice(2)); return resolve(configured); } export function getAgentPath(...segments: string[]): string { return join(getAgentDir(), ...segments); } /** The global mcp.json path — the same file pi-mcp-adapter reads. */ export function getMcpConfigPath(): string { return getAgentPath("mcp.json"); } /** Directory for this extension's own config/cache (never touches pi's settings.json). */ export function getExtensionDir(): string { return getAgentPath("pi-mcp-market"); } export function getRegistriesPath(): string { return join(getExtensionDir(), "registries.json"); } export function getCacheDir(): string { return join(getExtensionDir(), "cache"); } export function getCachePath(registryUrl: string): string { // sha256 of the URL — stable, avoids illegal filename characters const hash = sha256Hex(registryUrl); return join(getCacheDir(), `${hash}.json`); } function sha256Hex(input: string): string { return createHash("sha256").update(input).digest("hex"); } export class ConfigError extends Error { constructor( message: string, public readonly backupPath?: string, ) { super(message); this.name = "ConfigError"; } } /** Read raw JSON from a file, returning null when missing or unparseable. */ function readJsonFile(path: string): unknown | null { if (!existsSync(path)) return null; const raw = readFileSync(path, "utf8"); try { return JSON.parse(raw); } catch { return null; } } /** Read mcp.json. Missing file → fresh config. Corrupt file → backed up, error thrown. */ export function loadMcpConfig(): McpConfig { const path = getMcpConfigPath(); if (!existsSync(path)) { return { mcpServers: {} }; } const raw = readFileSync(path, "utf8"); try { const parsed = JSON.parse(raw) as Partial; return { mcpServers: parsed.mcpServers ?? {}, settings: parsed.settings, imports: parsed.imports, }; } catch { const backupPath = backupCorruptFile(path); throw new ConfigError( ti("config.mcpCorrupted", { backup: backupPath }), backupPath, ); } } /** Backup a corrupt file as `.backup-.json`. */ export function backupCorruptFile(path: string): string { const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const backupPath = `${path}.backup-${timestamp}.json`; try { writeFileSync(backupPath, readFileSync(path, "utf8"), "utf8"); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new ConfigError(ti("config.backupFailed", { msg: message })); } return backupPath; } function ensureParent(path: string): void { mkdirSync(dirname(path), { recursive: true }); } /** Atomically write JSON to a path (temp file + rename). */ export function writeJsonAtomic(path: string, value: unknown): void { ensureParent(path); const tmpPath = `${path}.${process.pid}.tmp`; writeFileSync(tmpPath, JSON.stringify(value, null, 2) + "\n", "utf8"); renameSync(tmpPath, path); } /** * Install a server into mcp.json. Re-reads the file first so concurrent * external edits are preserved; only touches the target key. */ export function installMcpServer(name: string, entry: McpConfig["mcpServers"][string]): void { const config = loadMcpConfig(); config.mcpServers[name] = entry; writeJsonAtomic(getMcpConfigPath(), config); } /** Uninstall a server — removes only the target key, leaves everything else untouched. */ export function uninstallMcpServer(name: string): void { const config = loadMcpConfig(); if (!(name in config.mcpServers)) return; delete config.mcpServers[name]; writeJsonAtomic(getMcpConfigPath(), config); } /** * Built-in default market sources, used when registries.json is missing or * corrupt so the market works out of the box. Only sources that actually * produce installable entries are included (Glama is metadata-only and would * render as all-skipped). Users can override via registries.json. * * deepnlp is the largest source by volume (~11k servers). It is loaded in * parallel with the other sources so the panel shows something quickly while * deepnlp completes. Items without a derivable transport URL are skipped. */ export const DEFAULT_REGISTRIES: RegistrySpec[] = [ { id: "official", name: "官方 Registry", url: "https://registry.modelcontextprotocol.io/v0/servers", format: "official", }, { id: "smithery", name: "Smithery", url: "https://registry.smithery.ai/servers", format: "smithery", }, { id: "deepnlp", name: "DeepNLP", url: "https://www.deepnlp.org/api/mcp_marketplace/v1?page_id=0&count_per_page=500", format: "deepnlp", }, ]; /** Read the extension's registries.json (missing → default sources; corrupt → backup + default). */ export function loadRegistries(): RegistriesConfig { const path = getRegistriesPath(); if (!existsSync(path)) return { registries: [...DEFAULT_REGISTRIES] }; const parsed = readJsonFile(path); if (parsed === null) { try { backupCorruptFile(path); } catch { // backup failure must not brick the market; fall through to defaults } return { registries: [...DEFAULT_REGISTRIES] }; } if (typeof parsed !== "object" || !Array.isArray((parsed as RegistriesConfig).registries)) { return { registries: [...DEFAULT_REGISTRIES] }; } // Explicit empty list = user disabled every source on purpose; honor it. return { registries: (parsed as RegistriesConfig).registries }; } export function saveRegistries(config: RegistriesConfig): void { writeJsonAtomic(getRegistriesPath(), config); } /** Read a cached remote registry payload, or null. */ export function readRegistryCache(registryUrl: string): unknown | null { return readJsonFile(getCachePath(registryUrl)); } export function writeRegistryCache(registryUrl: string, payload: unknown): void { try { writeJsonAtomic(getCachePath(registryUrl), payload); } catch { // cache write failure is non-fatal } }