// metadata-cache.ts - Persistent MCP metadata cache import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs"; import { dirname } from "node:path"; import { getAgentPath } from "./agent-dir.ts"; import { createHash } from "node:crypto"; import { getToolUiResourceUri } from "./ext-apps-bridge.ts"; import type { McpTool, McpResource, ServerEntry, ToolMetadata } from "./types.ts"; import { formatToolName, isToolExcluded } from "./types.ts"; import { resourceNameToToolName } from "./resource-tools.ts"; import { extractToolUiStreamMode, interpolateEnvRecord, resolveBearerToken, resolveConfigPath } from "./utils.ts"; const CACHE_VERSION = 1; const CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; export interface CachedTool { name: string; description?: string; inputSchema?: unknown; uiResourceUri?: string; uiStreamMode?: "eager" | "stream-first"; } export interface CachedResource { uri: string; name: string; description?: string; } export interface ServerCacheEntry { configHash: string; tools: CachedTool[]; resources: CachedResource[]; cachedAt: number; } export interface MetadataCache { version: number; servers: Record; } export function getMetadataCachePath(): string { return getAgentPath("mcp-cache.json"); } export function loadMetadataCache(): MetadataCache | null { const cachePath = getMetadataCachePath(); if (!existsSync(cachePath)) return null; try { const raw = JSON.parse(readFileSync(cachePath, "utf-8")); if (!raw || typeof raw !== "object") return null; if (raw.version !== CACHE_VERSION) return null; if (!raw.servers || typeof raw.servers !== "object") return null; return raw as MetadataCache; } catch { return null; } } export function saveMetadataCache(cache: MetadataCache): void { const cachePath = getMetadataCachePath(); const dir = dirname(cachePath); mkdirSync(dir, { recursive: true }); let merged: MetadataCache = { version: CACHE_VERSION, servers: {} }; try { if (existsSync(cachePath)) { const existing = JSON.parse(readFileSync(cachePath, "utf-8")) as MetadataCache; if (existing && existing.version === CACHE_VERSION && existing.servers) { merged.servers = { ...existing.servers }; } } } catch { // Ignore parse errors and proceed with empty cache } merged.version = CACHE_VERSION; merged.servers = { ...merged.servers, ...cache.servers }; const tmpPath = `${cachePath}.${process.pid}.tmp`; writeFileSync(tmpPath, JSON.stringify(merged, null, 2), "utf-8"); renameSync(tmpPath, cachePath); } export function computeServerHash(definition: ServerEntry): string { // Hash only fields that affect server identity and tool/resource output. // Exclude lifecycle, idleTimeout, debug — those are runtime behavior settings // that don't change which tools a server exposes. 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, }; const normalized = stableStringify(identity); return createHash("sha256").update(normalized).digest("hex"); } export function isServerCacheValid( entry: ServerCacheEntry, definition: ServerEntry, maxAgeMs: number = CACHE_MAX_AGE_MS ): boolean { if (!entry || entry.configHash !== computeServerHash(definition)) return false; if (!entry.cachedAt || typeof entry.cachedAt !== "number") return false; if (maxAgeMs > 0 && Date.now() - entry.cachedAt > maxAgeMs) return false; return true; } export function reconstructToolMetadata( serverName: string, entry: ServerCacheEntry, prefix: "server" | "none" | "short", definition: Pick ): ToolMetadata[] { const metadata: ToolMetadata[] = []; for (const tool of entry.tools ?? []) { if (!tool?.name) continue; if (isToolExcluded(tool.name, serverName, prefix, definition.excludeTools)) { continue; } metadata.push({ name: formatToolName(tool.name, serverName, prefix), originalName: tool.name, description: tool.description ?? "", inputSchema: tool.inputSchema, uiResourceUri: tool.uiResourceUri, uiStreamMode: tool.uiStreamMode, }); } if (definition.exposeResources !== false) { for (const resource of entry.resources ?? []) { if (!resource?.name || !resource?.uri) continue; const baseName = `get_${resourceNameToToolName(resource.name)}`; if (isToolExcluded(baseName, serverName, prefix, definition.excludeTools)) { continue; } metadata.push({ name: formatToolName(baseName, serverName, prefix), originalName: baseName, description: resource.description ?? `Read resource: ${resource.uri}`, resourceUri: resource.uri, }); } } return metadata; } export function serializeTools(tools: McpTool[]): CachedTool[] { return tools .filter(t => t?.name) .map(t => ({ name: t.name, description: t.description, inputSchema: t.inputSchema, uiResourceUri: tryGetToolUiResourceUri(t), uiStreamMode: extractToolUiStreamMode(t._meta), })); } export function serializeResources(resources: McpResource[]): CachedResource[] { return resources .filter(r => r?.name && r?.uri) .map(r => ({ uri: r.uri, name: r.name, description: r.description, })); } 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(v => stableStringify(v)).join(",")}]`; } const obj = value as Record; const keys = Object.keys(obj).sort(); return `{${keys.map(k => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(",")}}`; } function tryGetToolUiResourceUri(tool: McpTool): string | undefined { try { return getToolUiResourceUri({ _meta: tool._meta }); } catch { return undefined; } }