import { existsSync, realpathSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { readConfigDocument } from "./file-editor.ts"; import { IMPORT_KINDS, type ConfigFormat, type DiscoveryOptions, type DiscoverySnapshot, type ImportKind, type SourceOccurrence } from "./types.ts"; interface ConfigSourceSpec { id: string; path: string; } interface LoadedConfigSource { spec: ConfigSourceSpec; serverKey: string; servers: Record; imports: ImportKind[]; hostConfigDiscovery?: "off" | "prompt" | "on"; } interface ImportedDocument { path: string; format: ConfigFormat; value: Record; } function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } function isImportKind(value: unknown): value is ImportKind { return typeof value === "string" && (IMPORT_KINDS as readonly string[]).includes(value); } function pathsEqual(left: string, right: string): boolean { const a = resolve(left); const b = resolve(right); return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b; } function canonicalExistingPath(filePath: string): string { try { return realpathSync.native(filePath); } catch { return resolve(filePath); } } export function resolveAgentDir(explicitAgentDir?: string): string { if (explicitAgentDir) return resolve(explicitAgentDir); 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); } function getConfigSources(options: DiscoveryOptions): ConfigSourceSpec[] { const home = options.homeDir ?? homedir(); const agentDir = options.agentDir ?? resolveAgentDir(); const piGlobal = options.overrideConfigPath ? resolve(options.overrideConfigPath) : join(agentDir, "mcp.json"); const genericGlobal = join(home, ".config", "mcp", "mcp.json"); const agentsGlobal = [ join(home, ".agents", "mcp.json"), join(home, ".agents", "mcp", "mcp.json"), ]; const project = resolve(options.cwd, ".mcp.json"); const piProject = resolve(options.cwd, ".pi", "mcp.json"); const sources: ConfigSourceSpec[] = []; if (!pathsEqual(genericGlobal, piGlobal)) sources.push({ id: "shared-global", path: genericGlobal }); for (const [index, filePath] of agentsGlobal.entries()) { if (pathsEqual(filePath, piGlobal) || pathsEqual(filePath, genericGlobal)) continue; sources.push({ id: index === 0 ? "agents-global" : "agents-nested-global", path: filePath }); } sources.push({ id: "pi-global", path: piGlobal }); if (!pathsEqual(project, piGlobal)) sources.push({ id: "shared-project", path: project }); if (!pathsEqual(piProject, piGlobal) && !pathsEqual(piProject, project)) { sources.push({ id: "pi-project", path: piProject }); } return sources; } function chooseServerContainer( root: Record, primary: string, fallback?: string, ): { key: string; servers: Record | null } { const primaryValue = root[primary]; const fallbackValue = fallback ? root[fallback] : undefined; const selected = primaryValue ?? fallbackValue ?? {}; const key = primaryValue !== undefined && primaryValue !== null ? primary : fallback && fallbackValue !== undefined && fallbackValue !== null ? fallback : primary; return { key, servers: isRecord(selected) ? selected : null }; } function loadConfigSources(options: DiscoveryOptions, diagnostics: string[]): LoadedConfigSource[] { const loaded: LoadedConfigSource[] = []; for (const spec of getConfigSources(options)) { try { const document = readConfigDocument(spec.path, "jsonc"); if (!document || !isRecord(document.value)) continue; const root = document.value; const selected = chooseServerContainer(root, "mcpServers", "mcp-servers"); // pi-mcp-adapter discards imports/settings when the server container is invalid. if (!selected.servers) continue; const settings = isRecord(root.settings) ? root.settings : undefined; const hostConfigDiscovery = settings?.hostConfigDiscovery; loaded.push({ spec, serverKey: selected.key, servers: selected.servers, imports: Array.isArray(root.imports) ? root.imports.filter(isImportKind) : [], ...(hostConfigDiscovery === "off" || hostConfigDiscovery === "prompt" || hostConfigDiscovery === "on" ? { hostConfigDiscovery } : {}), }); } catch (error) { diagnostics.push(error instanceof Error ? error.message : String(error)); } } return loaded; } function findGitRoot(cwd: string): string | undefined { let current = resolve(cwd); while (true) { if (existsSync(join(current, ".git"))) return current; const parent = dirname(current); if (parent === current) return undefined; current = parent; } } function resolveOpenCodeProjectPath(cwd: string): string { const start = resolve(cwd); const gitRoot = findGitRoot(start); if (!gitRoot) return join(start, "opencode.json"); let current = start; while (true) { const candidate = join(current, "opencode.json"); if (existsSync(candidate) || current === gitRoot) return candidate; current = dirname(current); } } export function resolveImportCandidates(kind: ImportKind, options: DiscoveryOptions): string[] { const home = options.homeDir ?? homedir(); switch (kind) { case "cursor": return [join(home, ".cursor", "mcp.json")]; case "claude-code": return [ join(home, ".claude", "mcp.json"), join(home, ".claude.json"), join(home, ".claude", "claude_desktop_config.json"), ]; case "claude-desktop": return [join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json")]; case "codex": return [join(home, ".codex", "config.toml"), join(home, ".codex", "config.json")]; case "opencode": return [join(home, ".config", "opencode", "opencode.json"), resolveOpenCodeProjectPath(options.cwd)]; case "windsurf": return [join(home, ".windsurf", "mcp.json")]; case "vscode": return [resolve(options.cwd, ".vscode", "mcp.json")]; } } function formatForPath(filePath: string): ConfigFormat { return filePath.toLowerCase().endsWith(".toml") ? "toml" : "jsonc"; } function loadFirstImportedDocument( kind: ImportKind, options: DiscoveryOptions, diagnostics: string[], ): ImportedDocument | null { for (const candidate of resolveImportCandidates(kind, options)) { if (!existsSync(candidate)) continue; const format = formatForPath(candidate); try { const document = readConfigDocument(candidate, format); if (!document) continue; return { path: canonicalExistingPath(candidate), format, value: isRecord(document.value) ? document.value : {}, }; } catch (error) { diagnostics.push(error instanceof Error ? error.message : String(error)); } } return null; } function mergeOpenCodeConfigs(base: Record, next: Record): Record { const baseMcp = base.mcp; const nextMcp = next.mcp; const mergedMcp: Record = Object.assign( Object.create(null) as Record, isRecord(baseMcp) ? baseMcp : {}, ); if (isRecord(nextMcp)) { for (const [name, nextEntry] of Object.entries(nextMcp)) { const baseEntry = mergedMcp[name]; if (isRecord(baseEntry) && isRecord(nextEntry)) { const safeBase = { ...baseEntry }; if (typeof nextEntry.type === "string" && nextEntry.type !== safeBase.type) { for (const field of ["command", "environment", "cwd", "url", "headers", "oauth"]) delete safeBase[field]; } if (typeof nextEntry.url === "string" && nextEntry.url !== safeBase.url) { delete safeBase.headers; delete safeBase.oauth; } if (Array.isArray(nextEntry.command)) { const baseCommand = safeBase.command; const changed = !Array.isArray(baseCommand) || nextEntry.command.length !== baseCommand.length || nextEntry.command.some((value, index) => value !== baseCommand[index]); if (changed) { delete safeBase.environment; delete safeBase.cwd; } } const mergedEntry: Record = { ...safeBase, ...nextEntry }; for (const field of ["environment", "headers", "oauth"]) { if (isRecord(safeBase[field]) && isRecord(nextEntry[field])) { mergedEntry[field] = { ...safeBase[field], ...nextEntry[field] }; } } mergedMcp[name] = mergedEntry; } else { mergedMcp[name] = nextEntry; } } } return { ...base, ...next, mcp: mergedMcp }; } function isActiveOpenCodeEntry(value: unknown): boolean { if (!isRecord(value) || value.enabled === false) return false; if (value.type === "local") { return Array.isArray(value.command) && value.command.length > 0 && value.command.every((part) => typeof part === "string"); } return value.type === "remote" && typeof value.url === "string"; } function loadOpenCodeDocuments(options: DiscoveryOptions, diagnostics: string[]): { documents: ImportedDocument[]; merged: Record; } { const documents: ImportedDocument[] = []; let merged: Record = {}; for (const candidate of resolveImportCandidates("opencode", options)) { if (!existsSync(candidate)) continue; try { const document = readConfigDocument(candidate, formatForPath(candidate)); if (!document || !isRecord(document.value)) continue; const imported = { path: canonicalExistingPath(candidate), format: formatForPath(candidate), value: document.value, } satisfies ImportedDocument; documents.push(imported); merged = mergeOpenCodeConfigs(merged, imported.value); } catch (error) { diagnostics.push(error instanceof Error ? error.message : String(error)); } } return { documents, merged }; } function addOccurrence(snapshot: DiscoverySnapshot, occurrence: SourceOccurrence): void { const entries = snapshot.occurrencesByName.get(occurrence.name) ?? []; entries.push(occurrence); snapshot.occurrencesByName.set(occurrence.name, entries); snapshot.activeNames.add(occurrence.name); } function addImportedOccurrences( snapshot: DiscoverySnapshot, kind: ImportKind, options: DiscoveryOptions, ): void { if (kind === "opencode") { const { documents, merged } = loadOpenCodeDocuments(options, snapshot.diagnostics); const finalMcp = isRecord(merged.mcp) ? merged.mcp : {}; const activeNames = new Set( Object.entries(finalMcp) .filter(([, value]) => isActiveOpenCodeEntry(value)) .map(([name]) => name), ); for (const document of documents) { const rawMcp = isRecord(document.value.mcp) ? document.value.mcp : {}; for (const [name, value] of Object.entries(rawMcp)) { if (!activeNames.has(name)) continue; addOccurrence(snapshot, { name, path: document.path, format: document.format, keyPath: ["mcp", name], value: structuredClone(value), source: "import", importKind: kind, }); } } return; } const document = loadFirstImportedDocument(kind, options, snapshot.diagnostics); if (!document) return; let selected: { key: string; servers: Record | null }; switch (kind) { case "codex": selected = chooseServerContainer(document.value, "mcp_servers", "mcpServers"); break; case "cursor": case "windsurf": case "vscode": selected = chooseServerContainer(document.value, "mcpServers", "mcp-servers"); break; case "claude-code": case "claude-desktop": selected = chooseServerContainer(document.value, "mcpServers"); break; default: return; } if (!selected.servers) return; for (const [name, value] of Object.entries(selected.servers)) { addOccurrence(snapshot, { name, path: document.path, format: document.format, keyPath: [selected.key, name], value: structuredClone(value), source: "import", importKind: kind, }); } } export function discoverMcpSources(options: DiscoveryOptions): DiscoverySnapshot { const snapshot: DiscoverySnapshot = { occurrencesByName: new Map(), activeNames: new Set(), activeImportKinds: new Set(), diagnostics: [], }; const sources = loadConfigSources(options, snapshot.diagnostics); let hostConfigDiscovery: "off" | "prompt" | "on" = "off"; for (const source of sources) { if (source.hostConfigDiscovery) hostConfigDiscovery = source.hostConfigDiscovery; for (const kind of source.imports) snapshot.activeImportKinds.add(kind); for (const [name, value] of Object.entries(source.servers)) { addOccurrence(snapshot, { name, path: canonicalExistingPath(source.spec.path), format: "jsonc", keyPath: [source.serverKey, name], value: structuredClone(value), source: "config", sourceId: source.spec.id, }); } } if (hostConfigDiscovery === "on") { for (const kind of IMPORT_KINDS) snapshot.activeImportKinds.add(kind); } for (const kind of snapshot.activeImportKinds) addImportedOccurrences(snapshot, kind, options); return snapshot; } export function getMcpConfigOverrideFromArgv(argv = process.argv): string | undefined { const index = argv.indexOf("--mcp-config"); return index >= 0 && index + 1 < argv.length ? argv[index + 1] : undefined; }