openclaw / src /mcp /plugin-tools-handlers.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
5c2a829 verified
Raw History Blame Contribute Delete
4.58 kB
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<string, unknown> {
const params = tool.parameters;
if (params && typeof params === "object" && "type" in params) {
return params as Record<string, unknown>;
}
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<string, { tool: AnyAgentTool; runId: string | undefined }>();
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);
}
},
};
}