// config.ts - Config loading with import support import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs'; import { homedir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { parse as parseToml } from 'smol-toml'; import stripJsonComments from 'strip-json-comments'; import { getAgentPath } from './_agent-dir.ts'; import { isServerDisabled, type HostConfigDiscovery, type McpConfig, type ServerEntry, type McpSettings, type ImportKind, type ServerProvenance, } from './_types.ts'; import { toStringRecord } from './_utils.ts'; const GENERIC_GLOBAL_CONFIG_PATH = join(homedir(), '.config', 'mcp', 'mcp.json'); const AGENTS_GLOBAL_CONFIG_PATHS = [ join(homedir(), '.agents', 'mcp.json'), join(homedir(), '.agents', 'mcp', 'mcp.json'), ] as const; const PROJECT_CONFIG_NAME = '.mcp.json'; const PROJECT_PI_CONFIG_NAME = '.pi/mcp.json'; const REPOPROMPT_BINARY_CANDIDATES = [ join(homedir(), 'RepoPrompt', 'repoprompt_cli'), '/Applications/Repo Prompt.app/Contents/MacOS/repoprompt-mcp', ]; const IMPORT_PATHS: Record = { cursor: [join(homedir(), '.cursor', 'mcp.json')], 'claude-code': [ join(homedir(), '.claude', 'mcp.json'), join(homedir(), '.claude.json'), join(homedir(), '.claude', 'claude_desktop_config.json'), ], 'claude-desktop': [join(homedir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json')], codex: [join(homedir(), '.codex', 'config.toml'), join(homedir(), '.codex', 'config.json')], opencode: [join(homedir(), '.config', 'opencode', 'opencode.json'), './opencode.json'], windsurf: [join(homedir(), '.windsurf', 'mcp.json')], vscode: ['.vscode/mcp.json'], }; interface ConfigSourceSpec { id: 'shared-global' | 'agents-global' | 'agents-nested-global' | 'pi-global' | 'shared-project' | 'pi-project'; label: string; readPath: string; writePath: string; kind: 'user' | 'project' | 'import'; importKind?: string; shared: boolean; scope: 'global' | 'project'; } export interface ConfigDiscoveryPath { label: string; path: string; exists: boolean; } export interface DiscoveredImportConfig { kind: ImportKind; path: string; } export interface ConfigDiscoverySource extends ConfigDiscoveryPath { id: ConfigSourceSpec['id']; scope: ConfigSourceSpec['scope']; kind: 'shared' | 'pi'; serverCount: number; } export interface ImportConfigSummary extends DiscoveredImportConfig { serverCount: number; } export interface HostConfigSummary extends ImportConfigSummary { active: boolean; } export interface ImportConfigIssue { kind: ImportKind; path: string; message: string; } export interface McpConfigConflict { serverName: string; sources: Array<{ kind: 'shared' | 'pi' | 'host'; path: string }>; winner: { kind: 'shared' | 'pi' | 'host'; path: string }; } export interface RepoPromptDiscovery { configured: boolean; configuredPath?: string; executablePath?: string; targetPath?: string; serverName?: string; entry?: ServerEntry; } export interface McpDiscoverySummary { sources: ConfigDiscoverySource[]; imports: ImportConfigSummary[]; importIssues: ImportConfigIssue[]; hostConfigs: HostConfigSummary[]; hostConfigDiscovery: HostConfigDiscovery; conflicts: McpConfigConflict[]; hasAnyConfig: boolean; hasAnyDetectedPaths: boolean; hasSharedServers: boolean; hasPiOwnedServers: boolean; totalServerCount: number; fingerprint: string; repoPrompt: RepoPromptDiscovery; } export interface ConfigWritePreview { path: string; existed: boolean; changed: boolean; beforeText: string; afterText: string; diffText: string; } export function getPiGlobalConfigPath(overridePath?: string): string { return overridePath ? resolve(overridePath) : getAgentPath('mcp.json'); } export function getGenericGlobalConfigPath(): string { return GENERIC_GLOBAL_CONFIG_PATH; } export function getProjectConfigPath(cwd = process.cwd()): string { return resolve(cwd, PROJECT_CONFIG_NAME); } export function getProjectPiConfigPath(cwd = process.cwd()): string { return resolve(cwd, PROJECT_PI_CONFIG_NAME); } export function getConfigDiscoveryPaths(overridePath?: string, cwd = process.cwd()): ConfigDiscoveryPath[] { return getConfigSources(overridePath, cwd).map((source) => ({ label: source.label, path: source.readPath, exists: existsSync(source.readPath), })); } export function findAvailableImportConfigs(cwd = process.cwd()): DiscoveredImportConfig[] { const discovered: DiscoveredImportConfig[] = []; for (const importKind of Object.keys(IMPORT_PATHS) as ImportKind[]) { const importPath = resolveImportPath(importKind, cwd); if (importPath) { discovered.push({ kind: importKind, path: importPath }); } } return discovered; } export function getMcpDiscoverySummary(overridePath?: string, cwd = process.cwd()): McpDiscoverySummary { const sourceSpecs = getConfigSources(overridePath, cwd); const sources = sourceSpecs.map((source) => { const loaded = readValidatedConfig(source.readPath, `MCP config from ${source.readPath}`); return { id: source.id, label: source.label, path: source.readPath, exists: existsSync(source.readPath), scope: source.scope, kind: source.shared ? 'shared' : 'pi', serverCount: loaded ? Object.keys(loaded.mcpServers).length : 0, } satisfies ConfigDiscoverySource; }); const importIssues: ImportConfigIssue[] = []; const recordImportIssue = (issue: ImportConfigIssue): void => { if ( !importIssues.some( (entry) => entry.kind === issue.kind && entry.path === issue.path && entry.message === issue.message ) ) { importIssues.push(issue); } }; const imports = (Object.keys(IMPORT_PATHS) as ImportKind[]) .map((kind) => { const imported = loadImportedConfig( kind, cwd, `Failed to inspect imported MCP config from ${kind}:`, recordImportIssue ); if (!imported) { return null; } return { kind, path: imported.path, serverCount: Object.keys(extractServers(imported.value, kind)).length, } satisfies ImportConfigSummary; }) .filter((value): value is ImportConfigSummary => value !== null); const hostConfigDiscovery = getConfiguredHostConfigDiscovery(overridePath, cwd); const hostConfigs = imports.map((entry) => ({ ...entry, active: hostConfigDiscovery === 'on' })); const totalServerCount = sources.reduce((sum, source) => sum + source.serverCount, 0); const hasSharedServers = sources.some((source) => source.kind === 'shared' && source.serverCount > 0); const hasPiOwnedServers = sources.some((source) => source.kind === 'pi' && source.serverCount > 0); const hasAnyDetectedPaths = sources.some((source) => source.exists) || imports.length > 0; const hasAnyConfig = totalServerCount > 0 || imports.some((entry) => entry.serverCount > 0) || hasAnyDetectedPaths; const summaryWithoutRepoPrompt = { sources, imports, importIssues, hostConfigs, hostConfigDiscovery, conflicts: getConfigConflicts(sourceSpecs, imports, cwd, recordImportIssue), hasAnyConfig, hasAnyDetectedPaths, hasSharedServers, hasPiOwnedServers, totalServerCount, }; const fingerprint = JSON.stringify({ sources: sources.map((source) => [source.id, source.exists, source.serverCount]), imports: imports.map((entry) => [entry.kind, entry.path, entry.serverCount]), importIssues: importIssues.map((entry) => [entry.kind, entry.path, entry.message]), hostConfigDiscovery, conflicts: summaryWithoutRepoPrompt.conflicts, }); return { ...summaryWithoutRepoPrompt, fingerprint, repoPrompt: detectRepoPrompt(summaryWithoutRepoPrompt, cwd), }; } export function cloneMcpConfig(config: McpConfig): McpConfig { return structuredClone(config); } export function loadMcpConfig(overridePath?: string, cwd = process.cwd()): McpConfig { const sourceSpecs = getConfigSources(overridePath, cwd); const hostConfigDiscovery = getConfiguredHostConfigDiscovery(overridePath, cwd); // Host files are a lower-precedence fallback. This ordering means an opt-in // discovery cannot override a shared or Pi-owned definition, and all normal // URL-bound credential stripping remains in mergeServerMaps. let config: McpConfig = hostConfigDiscovery === 'on' ? loadDiscoveredHostConfigs(cwd) : { mcpServers: {} }; for (const source of sourceSpecs) { const loaded = readValidatedConfig(source.readPath, `MCP config from ${source.readPath}`); if (!loaded) { continue; } config = mergeConfigs(config, expandImports(loaded, cwd)); } return config; } function getConfiguredHostConfigDiscovery(overridePath?: string, cwd = process.cwd()): HostConfigDiscovery { let configured: HostConfigDiscovery = 'off'; for (const source of getConfigSources(overridePath, cwd)) { const loaded = readValidatedConfig(source.readPath, `MCP config from ${source.readPath}`); const value = loaded?.settings?.hostConfigDiscovery; if (value === 'off' || value === 'prompt' || value === 'on') { configured = value; } } return configured; } function loadDiscoveredHostConfigs(cwd: string): McpConfig { let config: McpConfig = { mcpServers: {} }; for (const importKind of Object.keys(IMPORT_PATHS) as ImportKind[]) { const imported = loadImportedConfig(importKind, cwd, `Failed to discover imported MCP config from ${importKind}:`); if (!imported) { continue; } config = mergeConfigs(config, { mcpServers: extractServers(imported.value, importKind), }); } return config; } function getConfigConflicts( sourceSpecs: ConfigSourceSpec[], imports: ImportConfigSummary[], cwd: string, onImportIssue: (issue: ImportConfigIssue) => void ): McpConfigConflict[] { const seen = new Map>(); const record = (name: string, source: { kind: 'shared' | 'pi' | 'host'; path: string }): void => { const entries = seen.get(name) ?? []; if (!entries.some((entry) => entry.kind === source.kind && entry.path === source.path)) { entries.push(source); } seen.set(name, entries); }; // Host candidates are listed first because, when enabled, they are the // lowest-precedence fallback. The fixed IMPORT_PATHS order is deterministic. for (const entry of imports) { const imported = loadImportedConfig( entry.kind, cwd, `Failed to inspect imported MCP config from ${entry.kind}:`, onImportIssue ); if (!imported) { continue; } for (const name of Object.keys(extractServers(imported.value, entry.kind))) { record(name, { kind: 'host', path: imported.path }); } } for (const source of sourceSpecs) { const loaded = readValidatedConfig(source.readPath, `MCP config from ${source.readPath}`); if (!loaded) { continue; } if (loaded.imports?.length) { for (const importKind of loaded.imports) { const imported = loadImportedConfig( importKind, cwd, `Failed to inspect imported MCP config from ${importKind}:`, onImportIssue ); if (!imported) { continue; } for (const name of Object.keys(extractServers(imported.value, importKind))) { record(name, { kind: 'host', path: imported.path }); } } } for (const name of Object.keys(loaded.mcpServers)) { record(name, { kind: source.shared ? 'shared' : 'pi', path: source.readPath, }); } } return [...seen.entries()] .filter(([, sources]) => sources.length > 1) .map(([serverName, sources]) => ({ serverName, sources, winner: sources[sources.length - 1]! })) .sort((left, right) => left.serverName.localeCompare(right.serverName)); } function getConfigSources(overridePath?: string, cwd = process.cwd()): ConfigSourceSpec[] { const userPath = getPiGlobalConfigPath(overridePath); const projectPath = getProjectConfigPath(cwd); const projectPiPath = getProjectPiConfigPath(cwd); const sources: ConfigSourceSpec[] = []; if (GENERIC_GLOBAL_CONFIG_PATH !== userPath) { sources.push({ id: 'shared-global', label: 'user-global standard MCP', readPath: GENERIC_GLOBAL_CONFIG_PATH, writePath: userPath, kind: 'import', importKind: 'global MCP config', shared: true, scope: 'global', }); } for (const [index, agentsPath] of AGENTS_GLOBAL_CONFIG_PATHS.entries()) { if (agentsPath === userPath || agentsPath === GENERIC_GLOBAL_CONFIG_PATH) { continue; } sources.push({ id: index === 0 ? 'agents-global' : 'agents-nested-global', label: index === 0 ? 'user-global .agents MCP' : 'user-global .agents nested MCP', readPath: agentsPath, writePath: userPath, kind: 'import', importKind: index === 0 ? '.agents MCP config' : '.agents/mcp MCP config', shared: true, scope: 'global', }); } sources.push({ id: 'pi-global', label: 'Pi global override', readPath: userPath, writePath: userPath, kind: 'user', shared: false, scope: 'global', }); if (projectPath !== userPath) { sources.push({ id: 'shared-project', label: 'project standard MCP', readPath: projectPath, writePath: projectPath, kind: 'project', shared: true, scope: 'project', }); } if (projectPiPath !== userPath && projectPiPath !== projectPath) { sources.push({ id: 'pi-project', label: 'project Pi override', readPath: projectPiPath, writePath: projectPiPath, kind: 'project', shared: false, scope: 'project', }); } return sources; } function mergeConfigs(base: McpConfig, next: McpConfig): McpConfig { return { mcpServers: mergeServerMaps(base.mcpServers, next.mcpServers), imports: mergeImports(base.imports, next.imports), settings: next.settings ? { ...base.settings, ...next.settings } : base.settings, }; } // Credential-bearing fields whose value is bound to a specific server `url`. // When a higher-precedence config source repoints an existing server at a // different url, these MUST NOT be inherited from the lower-precedence entry - // otherwise the original endpoint's credentials would be shipped to the new // url. See the SECURITY note in mergeServerMaps. const URL_BOUND_AUTH_FIELDS = ['headers', 'bearerToken', 'bearerTokenEnv'] as const; function mergeServerMaps( base: Record, next: Record ): Record { const merged = { ...base }; for (const [name, definition] of Object.entries(next)) { const existing = merged[name]; // SECURITY (credential/url binding): the merge is per-field, so a // higher-precedence source that supplies only a new `url` for an existing // server would otherwise retain the lower-precedence entry's auth material // (Authorization header, bearer token, OAuth config) and send it to the new // url - a credential-exfiltration vector when the higher-precedence source // is less trusted than the one that first defined the server. Bind auth to // the url that supplied it: when the url changes, drop inherited auth // material before merging. Auth explicitly re-supplied by `definition` still // applies (it is spread last). Behavior is unchanged when the url is // identical or the override omits `url` (partial overrides still inherit). let baseEntry: ServerEntry = existing ?? {}; if (existing && typeof definition.socket === 'string') { baseEntry = { ...existing }; for (const field of [ 'command', 'args', 'env', 'cwd', 'url', 'headers', 'auth', 'bearerToken', 'bearerTokenEnv', 'oauth', ] as const) { delete baseEntry[field]; } } else if (existing?.socket && (typeof definition.command === 'string' || typeof definition.url === 'string')) { baseEntry = { ...existing }; delete baseEntry.socket; } if (existing && typeof definition.url === 'string' && definition.url !== existing.url) { if (baseEntry === existing) { baseEntry = { ...existing }; } for (const field of URL_BOUND_AUTH_FIELDS) { delete baseEntry[field]; } if (baseEntry.oauth !== false) { delete baseEntry.oauth; } } merged[name] = { ...baseEntry, ...definition }; } return merged; } function mergeImports(left: ImportKind[] | undefined, right: ImportKind[] | undefined): ImportKind[] | undefined { const merged = [...(left ?? []), ...(right ?? [])]; if (merged.length === 0) { return undefined; } return [...new Set(merged)]; } function expandImports(config: McpConfig, cwd = process.cwd()): McpConfig { if (!config.imports?.length) { return config; } const importedServers: Record = {}; for (const importKind of config.imports) { const imported = loadImportedConfig(importKind, cwd, `Failed to import MCP config from ${importKind}:`); if (!imported) { continue; } const servers = extractServers(imported.value, importKind); for (const [name, definition] of Object.entries(servers)) { if (!importedServers[name]) { importedServers[name] = definition; } } } return { imports: config.imports, settings: config.settings, mcpServers: mergeServerMaps(importedServers, config.mcpServers), }; } function resolveImportCandidates(importKind: ImportKind, cwd: string): string[] { return (IMPORT_PATHS[importKind] ?? []).map((candidate) => { if (importKind === 'opencode' && candidate === './opencode.json') { const start = resolve(cwd); let gitRoot: string | undefined; let current = start; while (true) { if (existsSync(join(current, '.git'))) { gitRoot = current; break; } const parent = dirname(current); if (parent === current) { break; } current = parent; } if (!gitRoot) { return join(start, 'opencode.json'); } current = start; while (true) { const projectConfig = join(current, 'opencode.json'); if (existsSync(projectConfig) || current === gitRoot) { return projectConfig; } current = dirname(current); } } return candidate.startsWith('.') ? resolve(cwd, candidate) : candidate; }); } function parseJsonConfig(raw: string): unknown { return JSON.parse(stripJsonComments(raw, { trailingCommas: true })); } function readImportedConfig(path: string): unknown { const raw = readFileSync(path, 'utf-8'); return path.endsWith('.toml') ? parseToml(raw) : parseJsonConfig(raw); } function summarizeImportError(error: unknown): string { const message = error instanceof Error ? error.message : String(error); return message.split(/\r?\n/, 1)[0]?.trim() || 'Unknown config error'; } function reportImportIssue( issue: ImportConfigIssue, warningPrefix: string, onIssue?: (issue: ImportConfigIssue) => void ): void { if (onIssue) { onIssue(issue); return; } console.warn(`${warningPrefix} ${issue.message} (${issue.path})`); } function loadImportedConfig( importKind: ImportKind, cwd: string, warningPrefix: string, onIssue?: (issue: ImportConfigIssue) => void ): { path: string; value: unknown } | null { if (importKind === 'opencode') { let merged: Record = {}; let highestPrecedencePath: string | undefined; for (const path of resolveImportCandidates(importKind, cwd)) { if (!existsSync(path)) { continue; } try { const value = readImportedConfig(path); if (value && typeof value === 'object' && !Array.isArray(value)) { merged = mergeOpenCodeConfigs(merged, value as Record); highestPrecedencePath = path; } } catch (error) { reportImportIssue({ kind: importKind, path, message: summarizeImportError(error) }, warningPrefix, onIssue); } } return highestPrecedencePath ? { path: highestPrecedencePath, value: merged } : null; } for (const path of resolveImportCandidates(importKind, cwd)) { if (!existsSync(path)) { continue; } try { return { path, value: readImportedConfig(path) }; } catch (error) { reportImportIssue({ kind: importKind, path, message: summarizeImportError(error) }, warningPrefix, onIssue); } } return null; } function resolveImportPath(importKind: ImportKind, cwd = process.cwd()): string | null { return ( loadImportedConfig(importKind, cwd, `Failed to discover imported MCP config from ${importKind}:`)?.path ?? null ); } function readValidatedConfig(path: string, label: string): McpConfig | null { if (!existsSync(path)) { return null; } try { return validateConfig(parseJsonConfig(readFileSync(path, 'utf-8'))); } catch (error) { console.warn(`Failed to load ${label}:`, error); return null; } } function validateConfig(raw: unknown): McpConfig { if (!raw || typeof raw !== 'object') { return { mcpServers: {} }; } const obj = raw as Record; const servers = obj.mcpServers ?? obj['mcp-servers'] ?? {}; if (typeof servers !== 'object' || servers === null || Array.isArray(servers)) { return { mcpServers: {} }; } return { mcpServers: servers as Record, imports: Array.isArray(obj.imports) ? (obj.imports as ImportKind[]) : undefined, settings: obj.settings as McpSettings | undefined, }; } function mergeOpenCodeConfigs(base: Record, next: Record): Record { const baseMcp = base.mcp; const nextMcp = next.mcp; const mergedMcp: Record = { ...(baseMcp && typeof baseMcp === 'object' && !Array.isArray(baseMcp) ? baseMcp : {}), }; if (nextMcp && typeof nextMcp === 'object' && !Array.isArray(nextMcp)) { for (const [name, nextEntry] of Object.entries(nextMcp)) { const baseEntry = mergedMcp[name]; if ( baseEntry && typeof baseEntry === 'object' && !Array.isArray(baseEntry) && nextEntry && typeof nextEntry === 'object' && !Array.isArray(nextEntry) ) { const safeBase = { ...(baseEntry as Record) }; const override = nextEntry as Record; if (typeof override.type === 'string' && override.type !== safeBase.type) { for (const field of ['command', 'environment', 'cwd', 'url', 'headers', 'oauth']) { delete safeBase[field]; } } if (typeof override.url === 'string' && override.url !== safeBase.url) { delete safeBase.headers; delete safeBase.oauth; } if (Array.isArray(override.command)) { const baseCommand = safeBase.command; const commandChanged = !Array.isArray(baseCommand) || override.command.length !== baseCommand.length || override.command.some((value, index) => value !== baseCommand[index]); if (commandChanged) { delete safeBase.environment; delete safeBase.cwd; } } const mergedEntry = { ...safeBase, ...override }; for (const field of ['environment', 'headers', 'oauth']) { const baseField = safeBase[field]; const nextField = override[field]; if ( baseField && typeof baseField === 'object' && !Array.isArray(baseField) && nextField && typeof nextField === 'object' && !Array.isArray(nextField) ) { mergedEntry[field] = { ...(baseField as Record), ...(nextField as Record), }; } } mergedMcp[name] = mergedEntry; } else { mergedMcp[name] = nextEntry; } } } return { ...base, ...next, mcp: mergedMcp }; } function extractServers(config: unknown, kind: ImportKind): Record { if (!config || typeof config !== 'object') { return {}; } const obj = config as Record; let servers: unknown; switch (kind) { case 'claude-desktop': case 'claude-code': servers = obj.mcpServers; break; case 'codex': servers = obj.mcp_servers ?? obj.mcpServers; break; case 'cursor': case 'windsurf': case 'vscode': servers = obj.mcpServers ?? obj['mcp-servers']; break; case 'opencode': servers = obj.mcp; break; default: return {}; } if (!servers || typeof servers !== 'object' || Array.isArray(servers)) { return {}; } const mappedServers: Record = {}; for (const [name, entry] of Object.entries(servers)) { if (kind === 'opencode') { if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { continue; } const raw = entry as Record; if (raw.enabled === false) { continue; } if ( raw.type === 'local' && Array.isArray(raw.command) && raw.command.length > 0 && raw.command.every((value): value is string => typeof value === 'string') ) { const env = toStringRecord(raw.environment); const mapped: ServerEntry = { command: raw.command[0], args: raw.command.slice(1), ...(env ? { env } : {}), ...(typeof raw.cwd === 'string' ? { cwd: raw.cwd } : {}), }; mappedServers[name] = mapped; continue; } if (raw.type === 'remote' && typeof raw.url === 'string') { const headers = toStringRecord(raw.headers); const mapped: ServerEntry = { url: raw.url, ...(headers ? { headers } : {}), }; if (raw.oauth === false) { mapped.oauth = false; } else if (raw.oauth && typeof raw.oauth === 'object' && !Array.isArray(raw.oauth)) { const oauth = raw.oauth as Record; mapped.auth = 'oauth'; mapped.oauth = { ...(typeof oauth.clientId === 'string' ? { clientId: oauth.clientId } : {}), ...(typeof oauth.clientSecret === 'string' ? { clientSecret: oauth.clientSecret } : {}), ...(typeof oauth.scope === 'string' ? { scope: oauth.scope } : {}), }; } mappedServers[name] = mapped; } continue; } if (kind !== 'codex' || !entry || typeof entry !== 'object' || Array.isArray(entry)) { mappedServers[name] = entry as ServerEntry; continue; } const mapped = { ...(entry as Record) }; const bearerTokenEnv = mapped.bearer_token_env_var; const httpHeaders = mapped.http_headers; const envHttpHeaders = mapped.env_http_headers; if (typeof bearerTokenEnv === 'string') { mapped.bearerTokenEnv = bearerTokenEnv; if (mapped.auth === undefined) { mapped.auth = 'bearer'; } } if (httpHeaders && typeof httpHeaders === 'object' && !Array.isArray(httpHeaders)) { mapped.headers = { ...(mapped.headers as Record | undefined), ...(httpHeaders as Record), }; } if (envHttpHeaders && typeof envHttpHeaders === 'object' && !Array.isArray(envHttpHeaders)) { const headers = { ...(mapped.headers as Record | undefined) }; for (const [header, envVar] of Object.entries(envHttpHeaders)) { if (typeof envVar === 'string' && headers[header] === undefined) { headers[header] = `$env:${envVar}`; } } mapped.headers = headers; } delete mapped.bearer_token_env_var; delete mapped.http_headers; delete mapped.env_http_headers; mappedServers[name] = mapped as ServerEntry; } return mappedServers; } function serializeRawConfig(raw: Record): string { return `${JSON.stringify(raw, null, 2)}\n`; } function buildUnifiedDiff(beforeText: string, afterText: string): string { if (beforeText === afterText) { return '(no changes)'; } const before = beforeText.split('\n'); const after = afterText.split('\n'); const rows = before.length; const cols = after.length; const lcs = Array.from({ length: rows + 1 }, () => new Array(cols + 1).fill(0)); for (let i = rows - 1; i >= 0; i--) { for (let j = cols - 1; j >= 0; j--) { lcs[i][j] = before[i] === after[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]); } } const lines: string[] = ['--- before', '+++ after']; let i = 0; let j = 0; while (i < rows || j < cols) { if (i < rows && j < cols && before[i] === after[j]) { lines.push(` ${before[i]}`); i++; j++; continue; } if (j < cols && (i === rows || lcs[i][j + 1] >= lcs[i + 1][j])) { lines.push(`+ ${after[j]}`); j++; continue; } if (i < rows) { lines.push(`- ${before[i]}`); i++; } } return lines.join('\n'); } function buildConfigWritePreview(filePath: string, nextRaw: Record): ConfigWritePreview { const existed = existsSync(filePath); const beforeRaw = readRawConfigObject(filePath); const beforeText = existed ? serializeRawConfig(beforeRaw) : ''; const afterText = serializeRawConfig(nextRaw); return { path: filePath, existed, changed: beforeText !== afterText, beforeText, afterText, diffText: buildUnifiedDiff(beforeText, afterText), }; } function readRawConfigObject(filePath: string): Record { if (!existsSync(filePath)) { return {}; } try { const raw = parseJsonConfig(readFileSync(filePath, 'utf-8')); return raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record) : {}; } catch { return {}; } } function writeRawConfigObject(filePath: string, raw: Record): void { mkdirSync(dirname(filePath), { recursive: true }); const tmpPath = `${filePath}.${process.pid}.tmp`; writeFileSync(tmpPath, `${JSON.stringify(raw, null, 2)}\n`, 'utf-8'); renameSync(tmpPath, filePath); } function getServersObject(raw: Record): Record { const existing = raw.mcpServers ?? raw['mcp-servers'] ?? {}; if (!existing || typeof existing !== 'object' || Array.isArray(existing)) { return {}; } return existing as Record; } function setServersObject(raw: Record, servers: Record): void { delete raw['mcp-servers']; raw.mcpServers = servers; } export interface ServerDisabledOverrideResult { path: string; changed: boolean; } /** * Persist only the disabled field in the project Pi layer. Enabling writes an * explicit false only when a lower-precedence source is itself disabled; this * writer never copies a server definition or its credentials into the file. */ export function writeProjectServerDisabledOverride( overridePath: string | undefined, cwd: string, serverName: string, disabled: boolean ): ServerDisabledOverrideResult { const filePath = getProjectPiConfigPath(cwd); let raw: Record = {}; if (existsSync(filePath)) { try { const parsed = parseJsonConfig(readFileSync(filePath, 'utf-8')); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { throw new Error('root value must be an object'); } raw = parsed as Record; } catch (error) { throw new Error( `Failed to read project MCP override at ${filePath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error } ); } } const serverKey = raw.mcpServers !== undefined ? 'mcpServers' : raw['mcp-servers'] !== undefined ? 'mcp-servers' : 'mcpServers'; const rawServers = raw[serverKey]; if (rawServers !== undefined && (!rawServers || typeof rawServers !== 'object' || Array.isArray(rawServers))) { throw new Error(`Failed to update project MCP override at ${filePath}: ${serverKey} must be an object`); } const servers = (rawServers ?? {}) as Record; const previous = servers[serverName]; if (previous !== undefined && (!previous || typeof previous !== 'object' || Array.isArray(previous))) { throw new Error(`Failed to update project MCP override at ${filePath}: server "${serverName}" must be an object`); } const existing = previous as Record | undefined; let next: Record; if (disabled) { next = { ...existing, disabled: true }; } else { next = Object.fromEntries(Object.entries(existing ?? {}).filter(([key]) => key !== 'disabled')); let lowerConfig: McpConfig = { mcpServers: {} }; for (const source of getConfigSources(overridePath, cwd)) { if (source.readPath === filePath) { continue; } const loaded = readValidatedConfig(source.readPath, `MCP config from ${source.readPath}`); if (loaded) { lowerConfig = mergeConfigs(lowerConfig, expandImports(loaded, cwd)); } } if (raw.imports !== undefined) { if ( !Array.isArray(raw.imports) || raw.imports.some((kind) => typeof kind !== 'string' || !Object.hasOwn(IMPORT_PATHS, kind)) ) { throw new Error( `Failed to update project MCP override at ${filePath}: imports contains an unsupported config kind` ); } lowerConfig = mergeConfigs( lowerConfig, expandImports({ mcpServers: {}, imports: raw.imports as ImportKind[] }, cwd) ); } if (isServerDisabled(lowerConfig.mcpServers[serverName])) { next.disabled = false; } } if ((!existing && Object.keys(next).length === 0) || JSON.stringify(existing) === JSON.stringify(next)) { return { path: filePath, changed: false }; } if (Object.keys(next).length === 0) { delete servers[serverName]; } else { servers[serverName] = next; } raw[serverKey] = servers; writeRawConfigObject(filePath, raw); return { path: filePath, changed: true }; } function isRepoPromptServer(name: string, entry: ServerEntry): boolean { const normalizedName = name.toLowerCase(); if (normalizedName.includes('repoprompt') || normalizedName === 'rp') { return true; } const command = entry.command?.toLowerCase() ?? ''; if (command.includes('repoprompt') || command.includes('rp-mcp') || command.endsWith('repoprompt_cli')) { return true; } return (entry.args ?? []).some((arg) => typeof arg === 'string' && arg.toLowerCase().includes('repoprompt')); } function findProjectRoot(cwd = process.cwd()): string | null { let current = resolve(cwd); while (true) { if ( existsSync(join(current, '.git')) || existsSync(join(current, 'package.json')) || existsSync(join(current, PROJECT_CONFIG_NAME)) || existsSync(join(current, '.pi')) ) { return current; } const parent = dirname(current); if (parent === current) { return null; } current = parent; } } function buildRepoPromptEntry(executablePath: string): ServerEntry { return { command: executablePath, args: [], lifecycle: 'lazy', }; } function detectRepoPrompt( summary: Omit, cwd = process.cwd() ): RepoPromptDiscovery { for (const source of summary.sources) { if (source.kind !== 'shared' || source.serverCount === 0) { continue; } const config = readValidatedConfig(source.path, `MCP config from ${source.path}`); if (!config) { continue; } for (const [name, entry] of Object.entries(config.mcpServers)) { if (isRepoPromptServer(name, entry)) { return { configured: true, configuredPath: source.path }; } } } const executablePath = REPOPROMPT_BINARY_CANDIDATES.find((candidate) => existsSync(candidate)); if (!executablePath) { return { configured: false }; } const projectRoot = findProjectRoot(cwd); const targetPath = projectRoot ? join(projectRoot, PROJECT_CONFIG_NAME) : GENERIC_GLOBAL_CONFIG_PATH; return { configured: false, executablePath, targetPath, serverName: 'repoprompt', entry: buildRepoPromptEntry(executablePath), }; } export function previewCompatibilityImports(importKinds: ImportKind[], overridePath?: string): ConfigWritePreview { const targetPath = getPiGlobalConfigPath(overridePath); const raw = readRawConfigObject(targetPath); const currentImports = Array.isArray(raw.imports) ? raw.imports.filter((value): value is ImportKind => typeof value === 'string') : []; const merged = [...new Set([...currentImports, ...importKinds])]; const nextRaw = { ...raw, imports: merged }; setServersObject(nextRaw, getServersObject(nextRaw)); return buildConfigWritePreview(targetPath, nextRaw); } export function ensureCompatibilityImports( importKinds: ImportKind[], overridePath?: string ): { path: string; added: ImportKind[] } { const targetPath = getPiGlobalConfigPath(overridePath); const raw = readRawConfigObject(targetPath); const currentImports = Array.isArray(raw.imports) ? raw.imports.filter((value): value is ImportKind => typeof value === 'string') : []; const merged = [...new Set([...currentImports, ...importKinds])]; const added = merged.filter((kind) => !currentImports.includes(kind)); if (added.length === 0) { return { path: targetPath, added: [] }; } raw.imports = merged; const servers = getServersObject(raw); setServersObject(raw, servers); writeRawConfigObject(targetPath, raw); return { path: targetPath, added }; } export function buildStarterProjectConfig(): McpConfig { return { mcpServers: {}, }; } export function previewStarterProjectConfig(cwd = process.cwd()): ConfigWritePreview { const targetPath = getProjectConfigPath(cwd); const nextRaw = { mcpServers: buildStarterProjectConfig().mcpServers }; return buildConfigWritePreview(targetPath, nextRaw); } export function writeStarterProjectConfig(cwd = process.cwd()): string { const targetPath = getProjectConfigPath(cwd); const raw = { mcpServers: buildStarterProjectConfig().mcpServers }; writeRawConfigObject(targetPath, raw); return targetPath; } export function previewSharedServerEntry(filePath: string, serverName: string, entry: ServerEntry): ConfigWritePreview { const raw = readRawConfigObject(filePath); const nextRaw = { ...raw }; const servers = getServersObject(nextRaw); servers[serverName] = entry; setServersObject(nextRaw, servers); return buildConfigWritePreview(filePath, nextRaw); } export function writeSharedServerEntry(filePath: string, serverName: string, entry: ServerEntry): string { const raw = readRawConfigObject(filePath); const servers = getServersObject(raw); servers[serverName] = entry; setServersObject(raw, servers); writeRawConfigObject(filePath, raw); return filePath; } export function getServerProvenance(overridePath?: string, cwd = process.cwd()): Map { const provenance = new Map(); const userPath = getPiGlobalConfigPath(overridePath); if (getConfiguredHostConfigDiscovery(overridePath, cwd) === 'on') { for (const importKind of Object.keys(IMPORT_PATHS) as ImportKind[]) { const imported = loadImportedConfig(importKind, cwd, `Failed to inspect imported MCP config from ${importKind}:`); if (!imported) { continue; } for (const name of Object.keys(extractServers(imported.value, importKind))) { // Keep writes inside Pi-owned storage even though the source is external. // Later import kinds win in the same deterministic order as loadDiscoveredHostConfigs. provenance.set(name, { path: userPath, kind: 'import', importKind }); } } } for (const source of getConfigSources(overridePath, cwd)) { const loaded = readValidatedConfig(source.readPath, `MCP config from ${source.readPath}`); if (!loaded) { continue; } if (loaded.imports?.length) { for (const importKind of loaded.imports) { const imported = loadImportedConfig( importKind, cwd, `Failed to inspect imported MCP config from ${importKind}:` ); if (!imported) { continue; } const servers = extractServers(imported.value, importKind); for (const name of Object.keys(servers)) { if (!provenance.has(name)) { provenance.set(name, { path: userPath, kind: 'import', importKind }); } } } } for (const name of Object.keys(loaded.mcpServers)) { provenance.set(name, { path: source.writePath, kind: source.kind, importKind: source.importKind, }); } } return provenance; } export function writeDirectToolsConfig( changes: Map, provenance: Map, fullConfig: McpConfig ): void { const byPath = new Map(); for (const [serverName, value] of changes) { const prov = provenance.get(serverName); if (!prov) { continue; } const targetPath = prov.path; if (!byPath.has(targetPath)) { byPath.set(targetPath, []); } byPath.get(targetPath)!.push({ name: serverName, value, prov }); } for (const [filePath, entries] of byPath) { const raw = readRawConfigObject(filePath); const servers = getServersObject(raw); for (const { name, value, prov } of entries) { if (prov.kind === 'import') { const fullDef = fullConfig.mcpServers[name]; if (fullDef) { servers[name] = { ...fullDef, directTools: value }; } } else if (servers[name]) { servers[name] = { ...servers[name], directTools: value }; } } setServersObject(raw, servers); writeRawConfigObject(filePath, raw); } } export function resolveConfiguredOAuthDir(raw: unknown, cwd = process.cwd()): string | undefined { if (raw === undefined || raw === null) { return undefined; } if (typeof raw !== 'string') { throw new Error('settings.oauthDir must be a string'); } const trimmed = raw.trim(); if (!trimmed) { return undefined; } return resolve(cwd, trimmed); }