import { randomUUID } from "node:crypto"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; // Plugin MCP tool handlers route plugin tool calls through the active runtime. import { consumeAdjustedParamsForToolCall, isToolWrappedWithBeforeToolCallHook, rewrapToolWithBeforeToolCallHook, wrapToolWithBeforeToolCallHook, } from "../agents/agent-tools.before-tool-call.js"; import { getBeforeToolCallHookContext } from "../agents/before-tool-call-metadata.js"; import { isToolResultError } from "../agents/tool-result-error.js"; import { isAutomationsToolName } from "../agents/tools/automations-tool-name.js"; import type { AnyAgentTool } from "../agents/tools/common.js"; import { formatErrorMessage } from "../infra/errors.js"; import { coerceChatContentText } from "../shared/chat-content.js"; type CallPluginToolParams = { name: string; arguments?: unknown; }; function toMcpContentBlock(block: unknown): unknown { if (!isRecord(block)) { return { type: "text", text: coerceChatContentText(block) }; } if (block.type !== "image") { return block; } if (typeof block.data === "string" && typeof block.mimeType === "string") { return block; } const source = block.source; if ( isRecord(source) && source.type === "base64" && typeof source.data === "string" && typeof source.media_type === "string" ) { return { type: "image", data: source.data, mimeType: source.media_type, }; } return { type: "text", text: coerceChatContentText(block) }; } function resolveJsonSchemaForTool(tool: AnyAgentTool): Record { const params = tool.parameters; if (params && typeof params === "object" && "type" in params) { return params as Record; } return { type: "object", properties: {} }; } export function createPluginToolsMcpHandlers(tools: AnyAgentTool[]) { const wrappedTools = tools.map((tool) => { if (isToolWrappedWithBeforeToolCallHook(tool)) { return rewrapToolWithBeforeToolCallHook(tool, undefined, { approvalMode: "report" }); } // The ACPX MCP bridge should enforce the same pre-execution hook boundary // as the agent and HTTP tool execution paths. return wrapToolWithBeforeToolCallHook(tool, undefined, { approvalMode: "report" }); }); const toolMap = new Map(); for (const tool of wrappedTools) { toolMap.set(tool.name, { tool, runId: getBeforeToolCallHookContext(tool)?.runId }); } // "cron" remains an inbound scheduler alias (owner decision, RFC 0026). // Capture the first advertised name without adding another listTools entry; // map lookup keeps the last tool for duplicate names. const automationsName = wrappedTools.find((tool) => isAutomationsToolName(tool.name))?.name; const automationsEntry = automationsName ? toolMap.get(automationsName) : undefined; return { listTools: async () => ({ tools: wrappedTools.map((tool) => ({ name: tool.name, description: tool.description ?? "", inputSchema: resolveJsonSchemaForTool(tool), })), }), callTool: async (params: CallPluginToolParams, signal?: AbortSignal) => { const entry = toolMap.get(params.name) ?? (isAutomationsToolName(params.name) ? automationsEntry : undefined); if (!entry) { return { content: [{ type: "text", text: `Unknown tool: ${params.name}` }], isError: true, }; } const toolCallId = `mcp-${randomUUID()}`; try { const result = await entry.tool.execute(toolCallId, params.arguments ?? {}, signal); const isError = isToolResultError(result); const rawContent = result && typeof result === "object" && "content" in result ? (result as { content?: unknown }).content : result; return { content: Array.isArray(rawContent) ? rawContent.map(toMcpContentBlock) : [{ type: "text", text: coerceChatContentText(rawContent) }], ...(isError ? { isError: true } : {}), }; } catch (err) { return { content: [{ type: "text", text: `Tool error: ${formatErrorMessage(err)}` }], isError: true, }; } finally { // Direct MCP calls have no agent event consumer, so release the cloned // hook arguments as soon as the tool reaches a terminal state. consumeAdjustedParamsForToolCall(toolCallId, entry.runId); } }, }; }