import { createHash } from "node:crypto"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { getAgentDir, getProjectConfigDir } from "../../shared/utils.ts"; const CACHE_VERSION = 1; const CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; const BUILTIN_TOOL_NAMES = new Set(["read", "bash", "edit", "write", "grep", "find", "ls", "mcp"]); const GENERIC_GLOBAL_CONFIG_PATH = path.join(os.homedir(), ".config", "mcp", "mcp.json"); const IMPORT_PATHS = { cursor: [path.join(os.homedir(), ".cursor", "mcp.json")], "claude-code": [ path.join(os.homedir(), ".claude", "mcp.json"), path.join(os.homedir(), ".claude.json"), path.join(os.homedir(), ".claude", "claude_desktop_config.json"), ], "claude-desktop": [path.join(os.homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json")], codex: [path.join(os.homedir(), ".codex", "config.json")], windsurf: [path.join(os.homedir(), ".windsurf", "mcp.json")], vscode: [".vscode/mcp.json"], } as const; type ToolPrefix = "server" | "none" | "short"; type ImportKind = keyof typeof IMPORT_PATHS; interface ServerEntry { command?: string; args?: string[]; env?: Record; cwd?: string; url?: string; headers?: Record; auth?: "oauth" | "bearer" | false; bearerToken?: string; bearerTokenEnv?: string; exposeResources?: boolean; excludeTools?: string[]; directTools?: boolean | string[]; } interface McpConfig { mcpServers: Record; imports?: ImportKind[]; settings?: { toolPrefix?: ToolPrefix; directTools?: boolean; }; } interface CachedTool { name?: string; } interface CachedResource { uri?: string; name?: string; } interface ServerCacheEntry { configHash?: string; tools?: CachedTool[]; resources?: CachedResource[]; cachedAt?: number; } interface MetadataCache { version: number; servers: Record; } export function resolveMcpDirectToolNames(mcpDirectTools: string[] | undefined, cwd = process.cwd()): string[] { if (!mcpDirectTools?.length) return []; try { const config = loadMcpConfig(cwd); const cache = loadMetadataCache(); if (!cache) return []; return resolveDirectToolNames(config, cache, getToolPrefix(config.settings?.toolPrefix), mcpDirectTools); } catch { return []; } } function loadMetadataCache(): MetadataCache | null { const cachePath = path.join(getAgentDir(), "mcp-cache.json"); let parsed: unknown; try { parsed = JSON.parse(fs.readFileSync(cachePath, "utf-8")); } catch { return null; } if (!parsed || typeof parsed !== "object") return null; const raw = parsed as Record; if (raw.version !== CACHE_VERSION || !raw.servers || typeof raw.servers !== "object" || Array.isArray(raw.servers)) { return null; } return raw as unknown as MetadataCache; } function loadMcpConfig(cwd: string): McpConfig { let config: McpConfig = { mcpServers: {} }; for (const sourcePath of getConfigPaths(cwd)) { const loaded = readConfig(sourcePath); if (!loaded) continue; config = mergeConfigs(config, expandImports(loaded, cwd)); } return config; } function getConfigPaths(cwd: string): string[] { const piGlobalPath = path.join(getAgentDir(), "mcp.json"); const projectPath = path.resolve(cwd, ".mcp.json"); const projectPiPath = path.resolve(getProjectConfigDir(cwd), "mcp.json"); const sources: string[] = []; if (GENERIC_GLOBAL_CONFIG_PATH !== piGlobalPath) sources.push(GENERIC_GLOBAL_CONFIG_PATH); sources.push(piGlobalPath); if (projectPath !== piGlobalPath) sources.push(projectPath); if (projectPiPath !== piGlobalPath && projectPiPath !== projectPath) sources.push(projectPiPath); return sources; } function readConfig(configPath: string): McpConfig | null { let parsed: unknown; try { parsed = JSON.parse(fs.readFileSync(configPath, "utf-8")); } catch { return null; } return validateConfig(parsed); } function validateConfig(raw: unknown): McpConfig { if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { mcpServers: {} }; const obj = raw as Record; const servers = obj.mcpServers ?? obj["mcp-servers"] ?? {}; return { mcpServers: servers && typeof servers === "object" && !Array.isArray(servers) ? servers as Record : {}, imports: Array.isArray(obj.imports) ? obj.imports.filter((value): value is ImportKind => isImportKind(value)) : undefined, settings: obj.settings && typeof obj.settings === "object" && !Array.isArray(obj.settings) ? obj.settings as McpConfig["settings"] : undefined, }; } function mergeConfigs(base: McpConfig, next: McpConfig): McpConfig { const imports = [...(base.imports ?? []), ...(next.imports ?? [])]; return { mcpServers: { ...base.mcpServers, ...next.mcpServers }, imports: imports.length ? [...new Set(imports)] : undefined, settings: next.settings ? { ...base.settings, ...next.settings } : base.settings, }; } function expandImports(config: McpConfig, cwd: string): McpConfig { if (!config.imports?.length) return config; const importedServers: Record = {}; for (const importKind of config.imports) { const importPath = resolveImportPath(importKind, cwd); if (!importPath) continue; let imported: unknown; try { imported = JSON.parse(fs.readFileSync(importPath, "utf-8")); } catch { continue; } for (const [name, definition] of Object.entries(extractServers(imported, importKind))) { if (!importedServers[name]) importedServers[name] = definition; } } return { imports: config.imports, settings: config.settings, mcpServers: { ...importedServers, ...config.mcpServers }, }; } function resolveImportPath(importKind: ImportKind, cwd: string): string | null { for (const candidate of IMPORT_PATHS[importKind]) { const fullPath = candidate.startsWith(".") ? path.resolve(cwd, candidate) : candidate; if (fs.existsSync(fullPath)) return fullPath; } return null; } function extractServers(config: unknown, kind: ImportKind): Record { if (!config || typeof config !== "object" || Array.isArray(config)) return {}; const obj = config as Record; const servers = kind === "cursor" || kind === "windsurf" || kind === "vscode" ? obj.mcpServers ?? obj["mcp-servers"] : obj.mcpServers; return servers && typeof servers === "object" && !Array.isArray(servers) ? servers as Record : {}; } function resolveDirectToolNames(config: McpConfig, cache: MetadataCache, prefix: ToolPrefix, envOverride: string[]): string[] { const names: string[] = []; const seenNames = new Set(); const { servers: selectedServers, tools: selectedTools } = parseSelections(envOverride); for (const [serverName, definition] of Object.entries(config.mcpServers)) { const serverCache = cache.servers[serverName]; if (!isServerCacheValid(serverCache, definition)) continue; const toolFilter = selectedServers.has(serverName) ? true : selectedTools.get(serverName); if (!toolFilter) continue; for (const tool of Array.isArray(serverCache.tools) ? serverCache.tools : []) { if (typeof tool?.name !== "string" || !tool.name) continue; if (toolFilter !== true && !toolFilter.has(tool.name)) continue; if (isToolExcluded(tool.name, serverName, prefix, definition.excludeTools)) continue; const prefixedName = formatToolName(tool.name, serverName, prefix); if (BUILTIN_TOOL_NAMES.has(prefixedName) || seenNames.has(prefixedName)) continue; seenNames.add(prefixedName); names.push(prefixedName); } if (definition.exposeResources === false) continue; for (const resource of Array.isArray(serverCache.resources) ? serverCache.resources : []) { if (typeof resource?.name !== "string" || !resource.name || typeof resource.uri !== "string" || !resource.uri) continue; const baseName = `get_${resourceNameToToolName(resource.name)}`; if (toolFilter !== true && !toolFilter.has(baseName)) continue; if (isToolExcluded(baseName, serverName, prefix, definition.excludeTools)) continue; const prefixedName = formatToolName(baseName, serverName, prefix); if (BUILTIN_TOOL_NAMES.has(prefixedName) || seenNames.has(prefixedName)) continue; seenNames.add(prefixedName); names.push(prefixedName); } } return names; } function parseSelections(selections: string[]): { servers: Set; tools: Map> } { const servers = new Set(); const tools = new Map>(); for (let item of selections) { item = item.replace(/\/+$/, ""); if (item.includes("/")) { const [server, tool] = item.split("/", 2); if (server && tool) { if (!tools.has(server)) tools.set(server, new Set()); tools.get(server)!.add(tool); } else if (server) { servers.add(server); } } else if (item) { servers.add(item); } } return { servers, tools }; } function isServerCacheValid(entry: ServerCacheEntry | undefined, definition: ServerEntry): entry is ServerCacheEntry { if (!entry || entry.configHash !== computeMcpServerHash(definition)) return false; if (!entry.cachedAt || typeof entry.cachedAt !== "number") return false; return Date.now() - entry.cachedAt <= CACHE_MAX_AGE_MS; } export function computeMcpServerHash(definition: ServerEntry): string { const identity: Record = { command: definition.command, args: definition.args, env: interpolateEnvRecord(definition.env), cwd: resolveConfigPath(definition.cwd), url: definition.url, headers: interpolateEnvRecord(definition.headers), auth: definition.auth, bearerToken: resolveBearerToken(definition), bearerTokenEnv: definition.bearerTokenEnv, exposeResources: definition.exposeResources, excludeTools: definition.excludeTools, }; return createHash("sha256").update(stableStringify(identity)).digest("hex"); } function getToolPrefix(value: unknown): ToolPrefix { return value === "none" || value === "short" || value === "server" ? value : "server"; } function isImportKind(value: unknown): value is ImportKind { return typeof value === "string" && Object.hasOwn(IMPORT_PATHS, value); } function getServerPrefix(serverName: string, mode: ToolPrefix): string { if (mode === "none") return ""; if (mode === "short") { const short = serverName.replace(/-?mcp$/i, "").replace(/-/g, "_"); return short || "mcp"; } return serverName.replace(/-/g, "_"); } function formatToolName(toolName: string, serverName: string, prefix: ToolPrefix): string { const serverPrefix = getServerPrefix(serverName, prefix); return serverPrefix ? `${serverPrefix}_${toolName}` : toolName; } function isToolExcluded(toolName: string, serverName: string, prefix: ToolPrefix, excludeTools: unknown): boolean { if (!Array.isArray(excludeTools) || excludeTools.length === 0) return false; const candidates = new Set([ normalizeToolName(toolName), normalizeToolName(formatToolName(toolName, serverName, prefix)), normalizeToolName(formatToolName(toolName, serverName, "server")), normalizeToolName(formatToolName(toolName, serverName, "short")), ]); return excludeTools.some((excluded) => typeof excluded === "string" && candidates.has(normalizeToolName(excluded))); } function normalizeToolName(value: string): string { return value.replace(/-/g, "_"); } function resourceNameToToolName(name: string): string { let result = name .replace(/[^a-zA-Z0-9]/g, "_") .replace(/_+/g, "_") .replace(/^_+/, "") .replace(/_+$/, "") .toLowerCase(); if (!result || /^\d/.test(result)) result = `resource${result ? `_${result}` : ""}`; return result; } function interpolateEnvRecord(values: Record | undefined): Record | undefined { if (!values || typeof values !== "object" || Array.isArray(values)) return undefined; const resolved: Record = {}; for (const [key, value] of Object.entries(values)) { if (typeof value === "string") resolved[key] = interpolateEnvVars(value); } return resolved; } function interpolateEnvVars(value: string): string { return value .replace(/\$\{(\w+)\}/g, (_, name: string) => process.env[name] ?? "") .replace(/\$env:(\w+)/g, (_, name: string) => process.env[name] ?? ""); } function resolveConfigPath(value: string | undefined): string | undefined { if (typeof value !== "string") return undefined; const resolved = interpolateEnvVars(value); if (resolved === "~") return os.homedir(); if (resolved.startsWith("~/") || resolved.startsWith("~\\")) return path.join(os.homedir(), resolved.slice(2)); return resolved; } function resolveBearerToken(definition: Pick): string | undefined { if (typeof definition.bearerToken === "string") return interpolateEnvVars(definition.bearerToken); return typeof definition.bearerTokenEnv === "string" ? process.env[definition.bearerTokenEnv] : undefined; } function stableStringify(value: unknown): string { if (value === null || value === undefined || typeof value !== "object") { const serialized = JSON.stringify(value); return serialized === undefined ? "undefined" : serialized; } if (Array.isArray(value)) return `[${value.map((entry) => stableStringify(entry)).join(",")}]`; const obj = value as Record; return `{${Object.keys(obj).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(obj[key])}`).join(",")}}`; }