Files
sproutclaw-data/agent/extensions/pi-mcp-adapter/direct-tools.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

442 lines
17 KiB
TypeScript

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);
}
};
}