feat: 导出 SproutClaw .sproutclaw 配置
包含 extensions、skills、prompts、settings、auth、models、mcp 等配置。 排除 node_modules、npm 缓存、sessions 等运行时数据。
This commit is contained in:
441
agent/extensions/pi-mcp-adapter/direct-tools.ts
Normal file
441
agent/extensions/pi-mcp-adapter/direct-tools.ts
Normal file
@@ -0,0 +1,441 @@
|
||||
import type { AgentToolResult, AgentToolUpdateCallback, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { UrlElicitationRequiredError } from "@modelcontextprotocol/sdk/types.js";
|
||||
import type { McpExtensionState } from "./state.ts";
|
||||
import type { DirectToolSpec, McpConfig, McpContent } from "./types.ts";
|
||||
import type { MetadataCache } from "./metadata-cache.ts";
|
||||
import { lazyConnect, getFailureAgeSeconds } from "./init.ts";
|
||||
import { isServerCacheValid } from "./metadata-cache.ts";
|
||||
import { formatSchema } from "./tool-metadata.ts";
|
||||
import { transformMcpContent } from "./tool-registrar.ts";
|
||||
import { maybeStartUiSession, type UiSessionRuntime } from "./ui-session.ts";
|
||||
import { formatToolName, isToolExcluded } from "./types.ts";
|
||||
import { resourceNameToToolName } from "./resource-tools.ts";
|
||||
import { authenticate, supportsOAuth } from "./mcp-auth-flow.ts";
|
||||
import { formatAuthRequiredMessage } from "./utils.ts";
|
||||
|
||||
const BUILTIN_NAMES = new Set(["read", "bash", "edit", "write", "grep", "find", "ls", "mcp"]);
|
||||
|
||||
type DirectAutoAuthResult =
|
||||
| { status: "skipped" }
|
||||
| { status: "success" }
|
||||
| { status: "failed"; message: string };
|
||||
|
||||
function getDirectAuthRequiredMessage(
|
||||
state: McpExtensionState,
|
||||
serverName: string,
|
||||
defaultMessage = `MCP server "${serverName}" requires OAuth authentication. Run mcp({ action: "auth-start", server: "${serverName}" }) to get a browser URL, or /mcp-auth ${serverName} in an interactive local session.`,
|
||||
): string {
|
||||
return formatAuthRequiredMessage(state.config, serverName, defaultMessage);
|
||||
}
|
||||
|
||||
function getDirectAuthFailedMessage(state: McpExtensionState, serverName: string, message: string): string {
|
||||
const customGuidance = state.config.settings?.authRequiredMessage;
|
||||
if (customGuidance) {
|
||||
return `OAuth authentication failed for "${serverName}": ${message}. ${getDirectAuthRequiredMessage(state, serverName)}`;
|
||||
}
|
||||
return `OAuth authentication failed for "${serverName}": ${message}. Run mcp({ action: "auth-start", server: "${serverName}" }) to get a browser URL, or /mcp-auth ${serverName} in an interactive local session.`;
|
||||
}
|
||||
|
||||
async function attemptDirectAutoAuth(
|
||||
state: McpExtensionState,
|
||||
serverName: string,
|
||||
): Promise<DirectAutoAuthResult> {
|
||||
if (state.config.settings?.autoAuth !== true) {
|
||||
return { status: "skipped" };
|
||||
}
|
||||
|
||||
const definition = state.config.mcpServers[serverName];
|
||||
if (!definition || !supportsOAuth(definition) || !definition.url) {
|
||||
return { status: "skipped" };
|
||||
}
|
||||
|
||||
const grantType = definition.oauth ? definition.oauth.grantType ?? "authorization_code" : "authorization_code";
|
||||
if (!state.ui && grantType !== "client_credentials") {
|
||||
return {
|
||||
status: "failed",
|
||||
message: getDirectAuthRequiredMessage(
|
||||
state,
|
||||
serverName,
|
||||
`MCP server "${serverName}" requires OAuth authentication. Run mcp({ action: "auth-start", server: "${serverName}" }) to get a browser URL, or /mcp-auth ${serverName} in an interactive local session.`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await authenticate(serverName, definition.url, definition);
|
||||
return { status: "success" };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
status: "failed",
|
||||
message: getDirectAuthFailedMessage(state, serverName, message),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveDirectTools(
|
||||
config: McpConfig,
|
||||
cache: MetadataCache | null,
|
||||
prefix: "server" | "none" | "short",
|
||||
envOverride?: string[],
|
||||
): DirectToolSpec[] {
|
||||
const specs: DirectToolSpec[] = [];
|
||||
if (!cache) return specs;
|
||||
|
||||
const seenNames = new Set<string>();
|
||||
|
||||
const envServers = new Set<string>();
|
||||
const envTools = new Map<string, Set<string>>();
|
||||
if (envOverride) {
|
||||
for (let item of envOverride) {
|
||||
item = item.replace(/\/+$/, "");
|
||||
if (item.includes("/")) {
|
||||
const [server, tool] = item.split("/", 2);
|
||||
if (server && tool) {
|
||||
if (!envTools.has(server)) envTools.set(server, new Set());
|
||||
envTools.get(server)!.add(tool);
|
||||
} else if (server) {
|
||||
envServers.add(server);
|
||||
}
|
||||
} else if (item) {
|
||||
envServers.add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const globalDirect = config.settings?.directTools;
|
||||
|
||||
for (const [serverName, definition] of Object.entries(config.mcpServers)) {
|
||||
const serverCache = cache.servers[serverName];
|
||||
if (!serverCache || !isServerCacheValid(serverCache, definition)) continue;
|
||||
|
||||
let toolFilter: true | string[] | false = false;
|
||||
|
||||
if (envOverride) {
|
||||
if (envServers.has(serverName)) {
|
||||
toolFilter = true;
|
||||
} else if (envTools.has(serverName)) {
|
||||
toolFilter = [...envTools.get(serverName)!];
|
||||
}
|
||||
} else {
|
||||
if (definition.directTools !== undefined) {
|
||||
toolFilter = definition.directTools;
|
||||
} else if (globalDirect) {
|
||||
toolFilter = globalDirect;
|
||||
}
|
||||
}
|
||||
|
||||
if (!toolFilter) continue;
|
||||
|
||||
for (const tool of serverCache.tools ?? []) {
|
||||
if (toolFilter !== true && !toolFilter.includes(tool.name)) continue;
|
||||
if (isToolExcluded(tool.name, serverName, prefix, definition.excludeTools)) continue;
|
||||
const prefixedName = formatToolName(tool.name, serverName, prefix);
|
||||
if (BUILTIN_NAMES.has(prefixedName)) {
|
||||
console.warn(`MCP: skipping direct tool "${prefixedName}" (collides with builtin)`);
|
||||
continue;
|
||||
}
|
||||
if (seenNames.has(prefixedName)) {
|
||||
console.warn(`MCP: skipping duplicate direct tool "${prefixedName}" from "${serverName}"`);
|
||||
continue;
|
||||
}
|
||||
seenNames.add(prefixedName);
|
||||
specs.push({
|
||||
serverName,
|
||||
originalName: tool.name,
|
||||
prefixedName,
|
||||
description: tool.description ?? "",
|
||||
inputSchema: tool.inputSchema,
|
||||
uiResourceUri: tool.uiResourceUri,
|
||||
uiStreamMode: tool.uiStreamMode,
|
||||
});
|
||||
}
|
||||
|
||||
if (definition.exposeResources !== false) {
|
||||
for (const resource of serverCache.resources ?? []) {
|
||||
const baseName = `get_${resourceNameToToolName(resource.name)}`;
|
||||
if (toolFilter !== true && !toolFilter.includes(baseName)) continue;
|
||||
if (isToolExcluded(baseName, serverName, prefix, definition.excludeTools)) continue;
|
||||
const prefixedName = formatToolName(baseName, serverName, prefix);
|
||||
if (BUILTIN_NAMES.has(prefixedName)) {
|
||||
console.warn(`MCP: skipping direct resource tool "${prefixedName}" (collides with builtin)`);
|
||||
continue;
|
||||
}
|
||||
if (seenNames.has(prefixedName)) {
|
||||
console.warn(`MCP: skipping duplicate direct resource tool "${prefixedName}" from "${serverName}"`);
|
||||
continue;
|
||||
}
|
||||
seenNames.add(prefixedName);
|
||||
specs.push({
|
||||
serverName,
|
||||
originalName: baseName,
|
||||
prefixedName,
|
||||
description: resource.description ?? `Read resource: ${resource.uri}`,
|
||||
resourceUri: resource.uri,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return specs;
|
||||
}
|
||||
|
||||
export function getMissingConfiguredDirectToolServers(
|
||||
config: McpConfig,
|
||||
cache: MetadataCache | null,
|
||||
): string[] {
|
||||
const missing: string[] = [];
|
||||
const globalDirect = config.settings?.directTools;
|
||||
|
||||
for (const [serverName, definition] of Object.entries(config.mcpServers)) {
|
||||
const hasDirectTools = definition.directTools !== undefined
|
||||
? !!definition.directTools
|
||||
: !!globalDirect;
|
||||
|
||||
if (!hasDirectTools) continue;
|
||||
|
||||
const serverCache = cache?.servers?.[serverName];
|
||||
if (!serverCache || !isServerCacheValid(serverCache, definition)) {
|
||||
missing.push(serverName);
|
||||
}
|
||||
}
|
||||
|
||||
return missing;
|
||||
}
|
||||
|
||||
export function buildProxyDescription(
|
||||
config: McpConfig,
|
||||
cache: MetadataCache | null,
|
||||
directSpecs: DirectToolSpec[],
|
||||
): string {
|
||||
const prefix = config.settings?.toolPrefix ?? "server";
|
||||
let desc = `MCP gateway - connect to MCP servers and call their tools. Non-MCP Pi tools should be called directly, not through mcp.\n`;
|
||||
|
||||
const directByServer = new Map<string, number>();
|
||||
for (const spec of directSpecs) {
|
||||
directByServer.set(spec.serverName, (directByServer.get(spec.serverName) ?? 0) + 1);
|
||||
}
|
||||
if (directByServer.size > 0) {
|
||||
const parts = [...directByServer.entries()].map(
|
||||
([server, count]) => `${server} (${count})`,
|
||||
);
|
||||
desc += `\nDirect tools available (call as normal tools): ${parts.join(", ")}\n`;
|
||||
}
|
||||
|
||||
const serverSummaries: string[] = [];
|
||||
for (const serverName of Object.keys(config.mcpServers)) {
|
||||
const entry = cache?.servers?.[serverName];
|
||||
const definition = config.mcpServers[serverName];
|
||||
const toolCount = (entry?.tools ?? []).filter(
|
||||
(tool) => !isToolExcluded(tool.name, serverName, prefix, definition.excludeTools),
|
||||
).length;
|
||||
const resourceCount = definition?.exposeResources !== false
|
||||
? (entry?.resources ?? []).filter((resource) => {
|
||||
const baseName = `get_${resourceNameToToolName(resource.name)}`;
|
||||
return !isToolExcluded(baseName, serverName, prefix, definition.excludeTools);
|
||||
}).length
|
||||
: 0;
|
||||
const totalItems = toolCount + resourceCount;
|
||||
if (totalItems === 0) continue;
|
||||
const directCount = directByServer.get(serverName) ?? 0;
|
||||
const proxyCount = totalItems - directCount;
|
||||
if (proxyCount > 0) {
|
||||
serverSummaries.push(`${serverName} (${proxyCount} tools)`);
|
||||
}
|
||||
}
|
||||
|
||||
if (serverSummaries.length > 0) {
|
||||
desc += `\nServers: ${serverSummaries.join(", ")}\n`;
|
||||
}
|
||||
|
||||
desc += `\nUsage:\n`;
|
||||
desc += ` mcp({ }) → Show server status\n`;
|
||||
desc += ` mcp({ server: "name" }) → List tools from server\n`;
|
||||
desc += ` mcp({ search: "query" }) → Search MCP tools by name/description\n`;
|
||||
desc += ` mcp({ describe: "tool_name" }) → Show tool details and parameters\n`;
|
||||
desc += ` mcp({ connect: "server-name" }) → Connect to a server and refresh metadata\n`;
|
||||
desc += ` mcp({ tool: "name", args: '{"key": "value"}' }) → Call a tool (args is JSON string)\n`;
|
||||
desc += ` mcp({ action: "ui-messages" }) → Retrieve accumulated messages from completed UI sessions\n`;
|
||||
desc += ` mcp({ action: "auth-start", server: "name" }) → Start manual OAuth and get a browser URL\n`;
|
||||
desc += ` mcp({ action: "auth-complete", server: "name", args: '{"redirectUrl":"..."}' }) → Complete manual OAuth\n`;
|
||||
desc += `\nMode: action > tool (call) > connect > describe > search > server (list) > nothing (status)`;
|
||||
|
||||
return desc;
|
||||
}
|
||||
|
||||
type DirectToolExecute = (
|
||||
toolCallId: string,
|
||||
params: Record<string, unknown>,
|
||||
signal: AbortSignal | undefined,
|
||||
onUpdate: AgentToolUpdateCallback<Record<string, unknown>> | undefined,
|
||||
ctx: ExtensionContext,
|
||||
) => Promise<AgentToolResult<Record<string, unknown>>>;
|
||||
|
||||
export function createDirectToolExecutor(
|
||||
getState: () => McpExtensionState | null,
|
||||
getInitPromise: () => Promise<McpExtensionState> | null,
|
||||
spec: DirectToolSpec
|
||||
): DirectToolExecute {
|
||||
return async function execute(_toolCallId, params) {
|
||||
let state = getState();
|
||||
const initPromise = getInitPromise();
|
||||
|
||||
if (!state && initPromise) {
|
||||
try {
|
||||
state = await initPromise;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `MCP initialization failed: ${message}` }],
|
||||
details: { error: "init_failed", message },
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!state) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "MCP not initialized" }],
|
||||
details: { error: "not_initialized" },
|
||||
};
|
||||
}
|
||||
|
||||
let connected = await lazyConnect(state, spec.serverName);
|
||||
let autoAuthAttempted = false;
|
||||
|
||||
if (!connected && state.manager.getConnection(spec.serverName)?.status === "needs-auth") {
|
||||
autoAuthAttempted = true;
|
||||
const autoAuth = await attemptDirectAutoAuth(state, spec.serverName);
|
||||
if (autoAuth.status === "failed") {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: autoAuth.message }],
|
||||
details: { error: "auth_required", server: spec.serverName, message: autoAuth.message },
|
||||
};
|
||||
}
|
||||
if (autoAuth.status === "success") {
|
||||
await state.manager.close(spec.serverName);
|
||||
state.failureTracker.delete(spec.serverName);
|
||||
connected = await lazyConnect(state, spec.serverName);
|
||||
}
|
||||
}
|
||||
|
||||
if (!connected) {
|
||||
const authConnection = state.manager.getConnection(spec.serverName);
|
||||
if (authConnection?.status === "needs-auth") {
|
||||
const message = getDirectAuthRequiredMessage(state, spec.serverName);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: message }],
|
||||
details: { error: "auth_required", server: spec.serverName, message, autoAuthAttempted },
|
||||
};
|
||||
}
|
||||
const failedAgo = getFailureAgeSeconds(state, spec.serverName);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `MCP server "${spec.serverName}" not available${failedAgo !== null ? ` (failed ${failedAgo}s ago)` : ""}` }],
|
||||
details: { error: "server_unavailable", server: spec.serverName },
|
||||
};
|
||||
}
|
||||
|
||||
const connection = state.manager.getConnection(spec.serverName);
|
||||
if (!connection || connection.status !== "connected") {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `MCP server "${spec.serverName}" not connected` }],
|
||||
details: { error: "not_connected", server: spec.serverName },
|
||||
};
|
||||
}
|
||||
|
||||
let uiSession: UiSessionRuntime | null = null;
|
||||
|
||||
try {
|
||||
state.manager.touch(spec.serverName);
|
||||
state.manager.incrementInFlight(spec.serverName);
|
||||
|
||||
if (spec.resourceUri) {
|
||||
const result = await connection.client.readResource({ uri: spec.resourceUri });
|
||||
const content = (result.contents ?? []).map(c => ({
|
||||
type: "text" as const,
|
||||
text: "text" in c ? c.text : ("blob" in c ? `[Binary data: ${(c as { mimeType?: string }).mimeType ?? "unknown"}]` : JSON.stringify(c)),
|
||||
}));
|
||||
return {
|
||||
content: content.length > 0 ? content : [{ type: "text" as const, text: "(empty resource)" }],
|
||||
details: { server: spec.serverName, resourceUri: spec.resourceUri },
|
||||
};
|
||||
}
|
||||
|
||||
const hasUi = !!spec.uiResourceUri;
|
||||
uiSession = hasUi
|
||||
? await maybeStartUiSession(state, {
|
||||
serverName: spec.serverName,
|
||||
toolName: spec.originalName,
|
||||
toolArgs: params ?? {},
|
||||
uiResourceUri: spec.uiResourceUri!,
|
||||
streamMode: spec.uiStreamMode,
|
||||
})
|
||||
: null;
|
||||
|
||||
const resultPromise = connection.client.callTool({
|
||||
name: spec.originalName,
|
||||
arguments: params ?? {},
|
||||
_meta: uiSession?.requestMeta,
|
||||
});
|
||||
|
||||
const result = await resultPromise;
|
||||
uiSession?.sendToolResult(result as unknown as import("@modelcontextprotocol/sdk/types.js").CallToolResult);
|
||||
|
||||
const mcpContent = (result.content ?? []) as McpContent[];
|
||||
const content = transformMcpContent(mcpContent);
|
||||
|
||||
if (result.isError) {
|
||||
let errorText = content.filter(c => c.type === "text").map(c => (c as { text: string }).text).join("\n") || "Tool execution failed";
|
||||
if (spec.inputSchema) {
|
||||
errorText += `\n\nExpected parameters:\n${formatSchema(spec.inputSchema)}`;
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Error: ${errorText}` }],
|
||||
details: { error: "tool_error", server: spec.serverName },
|
||||
};
|
||||
}
|
||||
|
||||
const resultText = content.filter(c => c.type === "text").map(c => (c as { text: string }).text).join("\n") || "(empty result)";
|
||||
if (hasUi) {
|
||||
const uiMessage = uiSession?.reused
|
||||
? "Updated the open UI."
|
||||
: "📺 Interactive UI is now open in your browser. I'll respond to your prompts and intents as you interact with it.";
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `${resultText}\n\n${uiMessage}` }],
|
||||
details: { server: spec.serverName, tool: spec.originalName, uiOpen: true },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: content.length > 0 ? content : [{ type: "text" as const, text: "(empty result)" }],
|
||||
details: { server: spec.serverName, tool: spec.originalName },
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof UrlElicitationRequiredError) {
|
||||
const action = await state.manager.handleUrlElicitationRequired(spec.serverName, error);
|
||||
const message = action === "accept"
|
||||
? "The original MCP tool did not run. Complete the opened browser interaction, then retry the tool."
|
||||
: `The URL interaction was ${action === "decline" ? "declined" : "cancelled"}.`;
|
||||
uiSession?.sendToolCancelled(message);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: message }],
|
||||
details: { error: "url_elicitation_required", server: spec.serverName, action },
|
||||
};
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
uiSession?.sendToolCancelled(message);
|
||||
let errorText = `Failed to call tool: ${message}`;
|
||||
if (spec.inputSchema) {
|
||||
errorText += `\n\nExpected parameters:\n${formatSchema(spec.inputSchema)}`;
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text" as const, text: errorText }],
|
||||
details: { error: "call_failed", server: spec.serverName },
|
||||
};
|
||||
} finally {
|
||||
if (uiSession?.reused) {
|
||||
uiSession.close();
|
||||
}
|
||||
state.manager.decrementInFlight(spec.serverName);
|
||||
state.manager.touch(spec.serverName);
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user