Files
openclaw/src/mcp/plugin-tools-serve.test.ts
2026-05-03 19:41:03 -07:00

261 lines
8.4 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import type { AnyAgentTool } from "../agents/tools/common.js";
import {
initializeGlobalHookRunner,
resetGlobalHookRunner,
} from "../plugins/hook-runner-global.js";
import { createMockPluginRegistry } from "../plugins/hooks.test-helpers.js";
import { createPluginToolsMcpHandlers } from "./plugin-tools-handlers.js";
const callGatewayTool = vi.hoisted(() => vi.fn());
const connectToolsMcpServerToStdioMock = vi.hoisted(() => vi.fn());
const createToolsMcpServerMock = vi.hoisted(() => vi.fn(() => ({ close: vi.fn() })));
const getRuntimeConfigMock = vi.hoisted(() => vi.fn(() => ({ plugins: { enabled: true } })));
const ensureStandalonePluginToolRegistryLoadedMock = vi.hoisted(() => vi.fn());
const resolvePluginToolsMock = vi.hoisted(() => vi.fn<() => AnyAgentTool[]>(() => []));
const routeLogsToStderrMock = vi.hoisted(() => vi.fn());
vi.mock("../agents/tools/gateway.js", () => ({
callGatewayTool,
}));
vi.mock("../config/config.js", () => ({
getRuntimeConfig: getRuntimeConfigMock,
}));
vi.mock("../logging/console.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../logging/console.js")>();
return {
...actual,
routeLogsToStderr: routeLogsToStderrMock,
};
});
vi.mock("../plugins/tools.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../plugins/tools.js")>();
return {
...actual,
ensureStandalonePluginToolRegistryLoaded: ensureStandalonePluginToolRegistryLoadedMock,
resolvePluginTools: resolvePluginToolsMock,
};
});
vi.mock("./tools-stdio-server.js", () => ({
connectToolsMcpServerToStdio: connectToolsMcpServerToStdioMock,
createToolsMcpServer: createToolsMcpServerMock,
}));
afterEach(() => {
vi.restoreAllMocks();
callGatewayTool.mockReset();
connectToolsMcpServerToStdioMock.mockReset();
createToolsMcpServerMock.mockClear();
ensureStandalonePluginToolRegistryLoadedMock.mockReset();
getRuntimeConfigMock.mockClear();
resolvePluginToolsMock.mockReset();
resolvePluginToolsMock.mockReturnValue([]);
routeLogsToStderrMock.mockReset();
resetGlobalHookRunner();
});
describe("plugin tools MCP server", () => {
it("routes logs to stderr before resolving tools for stdio", async () => {
const { servePluginToolsMcp } = await import("./plugin-tools-serve.js");
resolvePluginToolsMock.mockReturnValue([
{
name: "memory_recall",
label: "Recall memory",
description: "Recall stored memory",
parameters: { type: "object", properties: {} },
execute: vi.fn(),
},
]);
await servePluginToolsMcp();
expect(routeLogsToStderrMock).toHaveBeenCalledTimes(1);
expect(ensureStandalonePluginToolRegistryLoadedMock).toHaveBeenCalledWith({
context: { config: { plugins: { enabled: true } } },
});
expect(resolvePluginToolsMock).toHaveBeenCalledTimes(1);
expect(ensureStandalonePluginToolRegistryLoadedMock.mock.invocationCallOrder[0]).toBeLessThan(
resolvePluginToolsMock.mock.invocationCallOrder[0] ?? 0,
);
expect(routeLogsToStderrMock.mock.invocationCallOrder[0]).toBeLessThan(
resolvePluginToolsMock.mock.invocationCallOrder[0] ?? 0,
);
expect(connectToolsMcpServerToStdioMock).toHaveBeenCalledOnce();
});
it("threads global plugin tool policy into plugin resolution", async () => {
getRuntimeConfigMock.mockReturnValueOnce({
plugins: { enabled: true },
tools: {
alsoAllow: ["memory_search"],
deny: ["memory_forget"],
},
} as never);
const { servePluginToolsMcp } = await import("./plugin-tools-serve.js");
await servePluginToolsMcp();
expect(ensureStandalonePluginToolRegistryLoadedMock).toHaveBeenCalledWith(
expect.objectContaining({
toolAllowlist: expect.arrayContaining(["memory_search"]),
toolDenylist: ["memory_forget"],
}),
);
expect(resolvePluginToolsMock).toHaveBeenCalledWith(
expect.objectContaining({
toolAllowlist: expect.arrayContaining(["memory_search"]),
toolDenylist: ["memory_forget"],
}),
);
});
it("lists registered plugin tools and serializes non-array tool content", async () => {
const execute = vi.fn().mockResolvedValue({
content: "Stored.",
});
const tool = {
name: "memory_recall",
description: "Recall stored memory",
parameters: {
type: "object",
properties: {
query: { type: "string" },
},
required: ["query"],
},
execute,
} as unknown as AnyAgentTool;
const handlers = createPluginToolsMcpHandlers([tool]);
const listed = await handlers.listTools();
expect(listed.tools).toEqual([
expect.objectContaining({
name: "memory_recall",
description: "Recall stored memory",
inputSchema: expect.objectContaining({
type: "object",
required: ["query"],
}),
}),
]);
const result = await handlers.callTool({
name: "memory_recall",
arguments: { query: "remember this" },
});
expect(execute).toHaveBeenCalledWith(
expect.stringMatching(/^mcp-\d+$/),
{
query: "remember this",
},
undefined,
undefined,
);
expect(result.content).toEqual([{ type: "text", text: "Stored." }]);
});
it("serializes plugin tool results that do not use the MCP content envelope", async () => {
const execute = vi.fn().mockResolvedValue({
provider: "kitchen-sink-search",
results: [{ title: "Kitchen Sink image fixture" }],
});
const tool = {
name: "kitchen_sink_search",
description: "Search Kitchen Sink fixture content",
parameters: {
type: "object",
properties: {
query: { type: "string" },
},
},
execute,
} as unknown as AnyAgentTool;
const handlers = createPluginToolsMcpHandlers([tool]);
const result = await handlers.callTool({
name: "kitchen_sink_search",
arguments: { query: "kitchen sink" },
});
expect(result.content).toEqual([
{
type: "text",
text: JSON.stringify({
provider: "kitchen-sink-search",
results: [{ title: "Kitchen Sink image fixture" }],
}),
},
]);
});
it("returns MCP errors for unknown tools and thrown tool errors", async () => {
const failingTool = {
name: "memory_forget",
description: "Forget memory",
parameters: { type: "object", properties: {} },
execute: vi.fn().mockRejectedValue(new Error("boom")),
} as unknown as AnyAgentTool;
const handlers = createPluginToolsMcpHandlers([failingTool]);
const unknown = await handlers.callTool({
name: "missing_tool",
arguments: {},
});
expect(unknown.isError).toBe(true);
expect(unknown.content).toEqual([{ type: "text", text: "Unknown tool: missing_tool" }]);
const failed = await handlers.callTool({
name: "memory_forget",
arguments: {},
});
expect(failed.isError).toBe(true);
expect(failed.content).toEqual([{ type: "text", text: "Tool error: boom" }]);
});
it("blocks tool execution when before_tool_call requires approval on the MCP bridge", async () => {
let hookCalls = 0;
const execute = vi.fn().mockResolvedValue({
content: "Stored.",
});
initializeGlobalHookRunner(
createMockPluginRegistry([
{
hookName: "before_tool_call",
handler: async () => {
hookCalls += 1;
return {
requireApproval: {
pluginId: "test-plugin",
title: "Approval required",
description: "Approval required",
},
};
},
},
]),
);
callGatewayTool.mockRejectedValueOnce(new Error("gateway unavailable"));
const tool = {
name: "memory_store",
description: "Store memory",
parameters: { type: "object", properties: {} },
execute,
} as unknown as AnyAgentTool;
const handlers = createPluginToolsMcpHandlers([tool]);
const result = await handlers.callTool({
name: "memory_store",
arguments: { text: "remember this" },
});
expect(hookCalls).toBe(1);
expect(execute).not.toHaveBeenCalled();
expect(result.isError).toBe(true);
expect(result.content).toEqual([
{ type: "text", text: "Tool error: Plugin approval required (gateway unavailable)" },
]);
});
});