Files
sproutclaw-data/agent/extensions/pi-mcp-adapter/init.ts
shumengya 50edff80f5 feat: 导出 SproutClaw .sproutclaw 配置
包含 extensions、skills、prompts、settings、auth、models、mcp 等配置。
排除 node_modules、npm 缓存、sessions 等运行时数据。
2026-06-26 15:48:56 +08:00

348 lines
12 KiB
TypeScript

import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import type { McpExtensionState } from "./state.ts";
import type { ToolMetadata } from "./types.ts";
import { existsSync } from "node:fs";
import { loadMcpConfig } from "./config.ts";
import { ConsentManager } from "./consent-manager.ts";
import { McpLifecycleManager } from "./lifecycle.ts";
import {
computeServerHash,
getMetadataCachePath,
isServerCacheValid,
loadMetadataCache,
reconstructToolMetadata,
saveMetadataCache,
serializeResources,
serializeTools,
type ServerCacheEntry,
} from "./metadata-cache.ts";
import { McpServerManager } from "./server-manager.ts";
import { buildToolMetadata, totalToolCount } from "./tool-metadata.ts";
import { UiResourceHandler } from "./ui-resource-handler.ts";
import { openUrl, parallelLimit } from "./utils.ts";
import { logger } from "./logger.ts";
import { getMissingConfiguredDirectToolServers } from "./direct-tools.ts";
const FAILURE_BACKOFF_MS = 60 * 1000;
export function isTuiMode(ctx: Pick<ExtensionContext, "hasUI" | "mode">): boolean {
return ctx.hasUI && ctx.mode === "tui";
}
export async function initializeMcp(
pi: ExtensionAPI,
ctx: ExtensionContext
): Promise<McpExtensionState> {
const configPath = pi.getFlag("mcp-config") as string | undefined;
const config = loadMcpConfig(configPath, ctx.cwd);
const manager = new McpServerManager();
const samplingAutoApprove = config.settings?.samplingAutoApprove === true;
if (config.settings?.sampling !== false && (ctx.hasUI || samplingAutoApprove)) {
manager.setSamplingConfig({
autoApprove: samplingAutoApprove,
ui: ctx.hasUI ? ctx.ui : undefined,
modelRegistry: ctx.modelRegistry,
getCurrentModel: () => ctx.model,
getSignal: () => ctx.signal,
});
}
const elicitationEnabled = config.settings?.elicitation !== false && ctx.hasUI;
if (elicitationEnabled) {
manager.setElicitationConfig({
ui: ctx.ui,
allowUrl: isTuiMode(ctx),
});
}
const lifecycle = new McpLifecycleManager(manager);
const toolMetadata = new Map<string, ToolMetadata[]>();
const failureTracker = new Map<string, number>();
const uiResourceHandler = new UiResourceHandler(manager);
const consentManager = new ConsentManager("once-per-server");
const ui = ctx.hasUI ? ctx.ui : undefined;
const state: McpExtensionState = {
manager,
lifecycle,
toolMetadata,
config,
failureTracker,
uiResourceHandler,
consentManager,
uiServer: null,
completedUiSessions: [],
openBrowser: (url: string) => openUrl(pi, url, process.env.BROWSER),
ui,
sendMessage: (message, options) => pi.sendMessage(message as unknown as Parameters<typeof pi.sendMessage>[0], options),
};
const serverEntries = Object.entries(config.mcpServers);
if (serverEntries.length === 0) {
return state;
}
const idleSetting = typeof config.settings?.idleTimeout === "number" ? config.settings.idleTimeout : 10;
lifecycle.setGlobalIdleTimeout(idleSetting);
const cachePath = getMetadataCachePath();
const cacheFileExists = existsSync(cachePath);
let cache = loadMetadataCache();
let bootstrapAll = false;
if (!cacheFileExists) {
bootstrapAll = true;
saveMetadataCache({ version: 1, servers: {} });
} else if (!cache) {
cache = { version: 1, servers: {} };
saveMetadataCache(cache);
}
const prefix = config.settings?.toolPrefix ?? "server";
for (const [name, definition] of serverEntries) {
const lifecycleMode = definition.lifecycle ?? "lazy";
const idleOverride = definition.idleTimeout ?? (lifecycleMode === "eager" ? 0 : undefined);
lifecycle.registerServer(
name,
definition,
idleOverride !== undefined ? { idleTimeout: idleOverride } : undefined
);
if (lifecycleMode === "keep-alive") {
lifecycle.markKeepAlive(name, definition);
}
if (cache?.servers?.[name] && isServerCacheValid(cache.servers[name], definition)) {
const metadata = reconstructToolMetadata(name, cache.servers[name], prefix, definition);
toolMetadata.set(name, metadata);
}
}
const startupServers = bootstrapAll
? serverEntries
: serverEntries.filter(([, definition]) => {
const mode = definition.lifecycle ?? "lazy";
return mode === "keep-alive" || mode === "eager";
});
if (ctx.hasUI && startupServers.length > 0) {
ctx.ui.setStatus("mcp", `MCP: connecting to ${startupServers.length} servers...`);
}
const results = await parallelLimit(startupServers, 10, async ([name, definition]) => {
try {
const connection = await manager.connect(name, definition);
if (connection.status === "needs-auth") {
return { name, definition, connection: null, error: `OAuth authentication required. Run /mcp-auth ${name}.` };
}
return { name, definition, connection, error: null };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { name, definition, connection: null, error: message };
}
});
for (const { name, definition, connection, error } of results) {
if (error || !connection) {
if (ctx.hasUI) {
ctx.ui.notify(`MCP: Failed to connect to ${name}: ${error}`, "error");
}
console.error(`MCP: Failed to connect to ${name}: ${error}`);
continue;
}
const { metadata, failedTools } = buildToolMetadata(connection.tools, connection.resources, definition, name, prefix);
toolMetadata.set(name, metadata);
updateMetadataCache(state, name);
if (failedTools.length > 0 && ctx.hasUI) {
ctx.ui.notify(
`MCP: ${name} - ${failedTools.length} tools skipped`,
"warning"
);
}
}
const connectedCount = results.filter(r => r.connection).length;
const failedCount = results.filter(r => r.error).length;
if (ctx.hasUI && connectedCount > 0) {
const totalTools = totalToolCount(state);
const msg = failedCount > 0
? `MCP: ${connectedCount}/${startupServers.length} servers connected (${totalTools} tools)`
: `MCP: ${connectedCount} servers connected (${totalTools} tools)`;
ctx.ui.notify(msg, "info");
}
const envDirect = process.env.MCP_DIRECT_TOOLS;
if (envDirect !== "__none__") {
const currentCache = loadMetadataCache();
const missingCacheServers = getMissingConfiguredDirectToolServers(config, currentCache);
if (missingCacheServers.length > 0) {
const bootstrapResults = await parallelLimit(
missingCacheServers.filter(name => !results.some(r => r.name === name && r.connection)),
10,
async (name) => {
const definition = config.mcpServers[name];
try {
const connection = await manager.connect(name, definition);
if (connection.status === "needs-auth") {
return { name, ok: false };
}
const { metadata } = buildToolMetadata(connection.tools, connection.resources, definition, name, prefix);
toolMetadata.set(name, metadata);
updateMetadataCache(state, name);
return { name, ok: true };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.debug(`MCP: direct-tools bootstrap failed for ${name}: ${message}`);
return { name, ok: false };
}
},
);
const bootstrapped = bootstrapResults.filter(r => r.ok).map(r => r.name);
if (bootstrapped.length > 0 && ctx.hasUI) {
ctx.ui.notify(`MCP: direct tools for ${bootstrapped.join(", ")} will be available after restart`, "info");
}
}
}
lifecycle.setReconnectCallback((serverName) => {
updateServerMetadata(state, serverName);
updateMetadataCache(state, serverName);
state.failureTracker.delete(serverName);
updateStatusBar(state);
});
lifecycle.setIdleShutdownCallback((serverName) => {
const idleMinutes = getEffectiveIdleTimeoutMinutes(state, serverName);
logger.debug(`${serverName} shut down (idle ${idleMinutes}m)`);
updateStatusBar(state);
});
lifecycle.startHealthChecks();
return state;
}
export function updateServerMetadata(state: McpExtensionState, serverName: string): void {
const connection = state.manager.getConnection(serverName);
if (!connection || connection.status !== "connected") return;
const definition = state.config.mcpServers[serverName];
if (!definition) return;
const prefix = state.config.settings?.toolPrefix ?? "server";
const { metadata } = buildToolMetadata(connection.tools, connection.resources, definition, serverName, prefix);
state.toolMetadata.set(serverName, metadata);
}
export function updateMetadataCache(state: McpExtensionState, serverName: string): void {
const connection = state.manager.getConnection(serverName);
if (!connection || connection.status !== "connected") return;
const definition = state.config.mcpServers[serverName];
if (!definition) return;
const configHash = computeServerHash(definition);
const existing = loadMetadataCache();
const existingEntry = existing?.servers?.[serverName];
const tools = serializeTools(connection.tools);
let resources = definition.exposeResources === false ? [] : serializeResources(connection.resources);
if (
definition.exposeResources !== false &&
resources.length === 0 &&
existingEntry?.resources?.length &&
existingEntry.configHash === configHash
) {
resources = existingEntry.resources;
}
const entry: ServerCacheEntry = {
configHash,
tools,
resources,
cachedAt: Date.now(),
};
saveMetadataCache({ version: 1, servers: { [serverName]: entry } });
}
export function flushMetadataCache(state: McpExtensionState): void {
for (const [name, connection] of state.manager.getAllConnections()) {
if (connection.status === "connected") {
updateMetadataCache(state, name);
}
}
}
export function updateStatusBar(state: McpExtensionState): void {
const ui = state.ui;
if (!ui) return;
const total = Object.keys(state.config.mcpServers).length;
if (total === 0) {
ui.setStatus("mcp", undefined);
return;
}
const connectedCount = state.manager.getAllConnections().size;
ui.setStatus("mcp", ui.theme.fg("accent", `MCP: ${connectedCount}/${total} servers`));
}
export function getFailureAgeSeconds(state: McpExtensionState, serverName: string): number | null {
const failedAt = state.failureTracker.get(serverName);
if (!failedAt) return null;
const ageMs = Date.now() - failedAt;
if (ageMs > FAILURE_BACKOFF_MS) return null;
return Math.round(ageMs / 1000);
}
export async function lazyConnect(state: McpExtensionState, serverName: string): Promise<boolean> {
const connection = state.manager.getConnection(serverName);
if (connection?.status === "needs-auth") {
return false;
}
if (connection?.status === "connected") {
updateServerMetadata(state, serverName);
return true;
}
const failedAgo = getFailureAgeSeconds(state, serverName);
if (failedAgo !== null) return false;
const definition = state.config.mcpServers[serverName];
if (!definition) return false;
try {
if (state.ui) {
state.ui.setStatus("mcp", `MCP: connecting to ${serverName}...`);
}
const newConnection = await state.manager.connect(serverName, definition);
if (newConnection.status === "needs-auth") {
return false;
}
state.failureTracker.delete(serverName);
updateServerMetadata(state, serverName);
updateMetadataCache(state, serverName);
updateStatusBar(state);
return true;
} catch (error) {
state.failureTracker.set(serverName, Date.now());
const message = error instanceof Error ? error.message : String(error);
logger.debug(`MCP: lazy connect failed for ${serverName}: ${message}`);
updateStatusBar(state);
return false;
}
}
function getEffectiveIdleTimeoutMinutes(state: McpExtensionState, serverName: string): number {
const definition = state.config.mcpServers[serverName];
if (!definition) {
return typeof state.config.settings?.idleTimeout === "number" ? state.config.settings.idleTimeout : 10;
}
if (typeof definition.idleTimeout === "number") return definition.idleTimeout;
const mode = definition.lifecycle ?? "lazy";
if (mode === "eager") return 0;
return typeof state.config.settings?.idleTimeout === "number" ? state.config.settings.idleTimeout : 10;
}