feat: 导出 SproutClaw .sproutclaw 配置
包含 extensions、skills、prompts、settings、auth、models、mcp 等配置。 排除 node_modules、npm 缓存、sessions 等运行时数据。
This commit is contained in:
201
agent/extensions/pi-mcp-adapter/metadata-cache.ts
Normal file
201
agent/extensions/pi-mcp-adapter/metadata-cache.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
// 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 "@modelcontextprotocol/ext-apps/app-bridge";
|
||||
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<string, ServerCacheEntry>;
|
||||
}
|
||||
|
||||
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<string, unknown> = {
|
||||
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<ServerEntry, "exposeResources" | "excludeTools">
|
||||
): 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<string, unknown>;
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user