mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
fix(mcp): skip unreadable plugin tools
This commit is contained in:
@@ -12,34 +12,95 @@ type CallPluginToolParams = {
|
||||
arguments?: unknown;
|
||||
};
|
||||
|
||||
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>;
|
||||
type PluginMcpToolEntry = {
|
||||
description: string;
|
||||
inputSchema: Record<string, unknown>;
|
||||
name: string;
|
||||
tool: AnyAgentTool;
|
||||
};
|
||||
|
||||
function readToolName(tool: AnyAgentTool): string | undefined {
|
||||
try {
|
||||
const value = tool.name;
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return { type: "object", properties: {} };
|
||||
}
|
||||
|
||||
function readToolDescription(tool: AnyAgentTool): string {
|
||||
try {
|
||||
return typeof tool.description === "string" ? tool.description : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function readToolParameters(tool: AnyAgentTool): { ok: true; value: unknown } | { ok: false } {
|
||||
try {
|
||||
return { ok: true, value: tool.parameters };
|
||||
} catch {
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
function resolveJsonSchemaForTool(
|
||||
tool: AnyAgentTool,
|
||||
): { ok: true; schema: Record<string, unknown> } | { ok: false } {
|
||||
const params = readToolParameters(tool);
|
||||
if (!params.ok) {
|
||||
return { ok: false };
|
||||
}
|
||||
try {
|
||||
if (params.value && typeof params.value === "object" && "type" in params.value) {
|
||||
return { ok: true, schema: params.value as Record<string, unknown> };
|
||||
}
|
||||
} catch {
|
||||
return { ok: false };
|
||||
}
|
||||
return { ok: true, schema: { type: "object", properties: {} } };
|
||||
}
|
||||
|
||||
export function createPluginToolsMcpHandlers(tools: AnyAgentTool[]) {
|
||||
const wrappedTools = tools.map((tool) => {
|
||||
if (isToolWrappedWithBeforeToolCallHook(tool)) {
|
||||
return rewrapToolWithBeforeToolCallHook(tool, undefined, { approvalMode: "report" });
|
||||
const toolEntries: PluginMcpToolEntry[] = [];
|
||||
for (const tool of tools) {
|
||||
const name = readToolName(tool);
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
// 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 inputSchema = resolveJsonSchemaForTool(tool);
|
||||
if (!inputSchema.ok) {
|
||||
continue;
|
||||
}
|
||||
const description = readToolDescription(tool);
|
||||
let wrappedTool: AnyAgentTool;
|
||||
try {
|
||||
// The ACPX MCP bridge should enforce the same pre-execution hook boundary
|
||||
// as the agent and HTTP tool execution paths.
|
||||
wrappedTool = isToolWrappedWithBeforeToolCallHook(tool)
|
||||
? rewrapToolWithBeforeToolCallHook(tool, undefined, { approvalMode: "report" })
|
||||
: wrapToolWithBeforeToolCallHook(tool, undefined, { approvalMode: "report" });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
toolEntries.push({
|
||||
description,
|
||||
inputSchema: inputSchema.schema,
|
||||
name,
|
||||
tool: wrappedTool,
|
||||
});
|
||||
}
|
||||
const toolMap = new Map<string, AnyAgentTool>();
|
||||
for (const tool of wrappedTools) {
|
||||
toolMap.set(tool.name, tool);
|
||||
for (const entry of toolEntries) {
|
||||
toolMap.set(entry.name, entry.tool);
|
||||
}
|
||||
|
||||
return {
|
||||
listTools: async () => ({
|
||||
tools: wrappedTools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description ?? "",
|
||||
inputSchema: resolveJsonSchemaForTool(tool),
|
||||
tools: toolEntries.map((entry) => ({
|
||||
name: entry.name,
|
||||
description: entry.description,
|
||||
inputSchema: entry.inputSchema,
|
||||
})),
|
||||
}),
|
||||
callTool: async (params: CallPluginToolParams, signal?: AbortSignal) => {
|
||||
|
||||
@@ -174,6 +174,77 @@ describe("plugin tools MCP server", () => {
|
||||
expect(result.content).toEqual([{ type: "text", text: "Stored." }]);
|
||||
});
|
||||
|
||||
it("skips unreadable plugin tool descriptors while keeping healthy MCP siblings", async () => {
|
||||
const healthyExecute = vi.fn().mockResolvedValue({ content: "ok" });
|
||||
const unreadableNameTool = Object.defineProperties(
|
||||
{},
|
||||
{
|
||||
name: {
|
||||
get() {
|
||||
throw new Error("name unavailable");
|
||||
},
|
||||
},
|
||||
description: { value: "Unreadable name" },
|
||||
parameters: { value: { type: "object", properties: {} } },
|
||||
execute: { value: vi.fn() },
|
||||
},
|
||||
);
|
||||
const unreadableParametersTool = Object.defineProperties(
|
||||
{},
|
||||
{
|
||||
name: { value: "broken_parameters" },
|
||||
description: { value: "Unreadable parameters" },
|
||||
parameters: {
|
||||
get() {
|
||||
throw new Error("parameters unavailable");
|
||||
},
|
||||
},
|
||||
execute: { value: vi.fn() },
|
||||
},
|
||||
);
|
||||
const revoked = Proxy.revocable({}, {});
|
||||
revoked.revoke();
|
||||
|
||||
const handlers = createPluginToolsMcpHandlers([
|
||||
unreadableNameTool,
|
||||
unreadableParametersTool,
|
||||
{
|
||||
name: "revoked_schema",
|
||||
description: "Revoked schema",
|
||||
parameters: revoked.proxy,
|
||||
execute: vi.fn(),
|
||||
},
|
||||
{
|
||||
name: "healthy_tool",
|
||||
description: "Healthy tool",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string" },
|
||||
},
|
||||
},
|
||||
execute: healthyExecute,
|
||||
},
|
||||
] as unknown as AnyAgentTool[]);
|
||||
|
||||
const listed = await handlers.listTools();
|
||||
expect(listed.tools.map((tool) => tool.name)).toEqual(["healthy_tool"]);
|
||||
expect(listed.tools[0]?.inputSchema).toMatchObject({
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string" },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await handlers.callTool({
|
||||
name: "healthy_tool",
|
||||
arguments: { query: "ok" },
|
||||
});
|
||||
|
||||
expect(result.content).toEqual([{ type: "text", text: "ok" }]);
|
||||
expect(healthyExecute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("serializes plugin tool results that do not use the MCP content envelope", async () => {
|
||||
const execute = vi.fn().mockResolvedValue({
|
||||
provider: "kitchen-sink-search",
|
||||
|
||||
Reference in New Issue
Block a user