Download src/node-host/invoke.mcp.test.ts from SaylorTwift/openclaw: direct link, hf CLI and curl.
- Browser
- Download file 8.3 kB
-
https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/node-host/invoke.mcp.test.ts
- Command line
-
hf download hf://SaylorTwift/openclaw/src/node-host/invoke.mcp.test.ts
-
curl -L -o invoke.mcp.test.ts https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/node-host/invoke.mcp.test.ts
8.3 kB
| /** Tests the built-in node-host MCP invocation command. */ | |
| import { describe, expect, it, vi } from "vitest"; | |
| import type { GatewayClient } from "../gateway/client.js"; | |
| import { handleInvoke } from "./invoke.js"; | |
| import { NodeHostMcpError, type NodeHostMcpManager } from "./mcp.js"; | |
| const MEBIBYTE = 1024 * 1024; | |
| async function invokeMcp(manager: NodeHostMcpManager, params: unknown) { | |
| const request = vi.fn<GatewayClient["request"]>().mockResolvedValue(null); | |
| await handleInvoke( | |
| { | |
| id: "invoke-mcp", | |
| nodeId: "node-1", | |
| command: "mcp.tools.call.v1", | |
| paramsJSON: JSON.stringify(params), | |
| timeoutMs: 321, | |
| }, | |
| { request } as unknown as GatewayClient, | |
| { current: async () => [] }, | |
| manager, | |
| ); | |
| return (request.mock.calls[0]?.[1] ?? {}) as { | |
| ok: boolean; | |
| payload?: unknown; | |
| payloadJSON?: string; | |
| error?: { code?: string; message?: string }; | |
| }; | |
| } | |
| function managerWith(callMcpTool: NodeHostMcpManager["callMcpTool"]): NodeHostMcpManager { | |
| return { | |
| descriptors: [], | |
| callMcpTool, | |
| close: async () => undefined, | |
| }; | |
| } | |
| describe("mcp.tools.call.v1", () => { | |
| it("dispatches validated params and preserves raw MCP content for one final projection", async () => { | |
| const callMcpTool = vi.fn<NodeHostMcpManager["callMcpTool"]>().mockResolvedValue({ | |
| content: [ | |
| { type: "text", text: "pong" }, | |
| { type: "image", data: "aW1hZ2U=", mimeType: "image/png" }, | |
| { | |
| type: "resource_link", | |
| uri: "https://example.com/report", | |
| name: "report", | |
| title: "Report", | |
| }, | |
| ], | |
| structuredContent: { ok: true }, | |
| }); | |
| const result = await invokeMcp(managerWith(callMcpTool), { | |
| server: "docs", | |
| tool: "search", | |
| arguments: { query: "x" }, | |
| }); | |
| expect(callMcpTool).toHaveBeenCalledWith({ | |
| server: "docs", | |
| tool: "search", | |
| arguments: { query: "x" }, | |
| timeoutMs: 321, | |
| }); | |
| expect(result.ok).toBe(true); | |
| expect(result.payload).toEqual({ | |
| content: [ | |
| { type: "text", text: "pong" }, | |
| { type: "image", data: "aW1hZ2U=", mimeType: "image/png" }, | |
| { | |
| type: "resource_link", | |
| uri: "https://example.com/report", | |
| name: "report", | |
| title: "Report", | |
| }, | |
| ], | |
| structuredContent: { ok: true }, | |
| }); | |
| }); | |
| it("returns MCP tool errors as results while thrown failures fail the invoke", async () => { | |
| const toolError = await invokeMcp( | |
| managerWith(async () => ({ | |
| isError: true, | |
| content: [{ type: "text", text: "bad query" }], | |
| structuredContent: { retryable: true }, | |
| })), | |
| { server: "docs", tool: "search" }, | |
| ); | |
| expect(toolError).toEqual({ | |
| id: "invoke-mcp", | |
| nodeId: "node-1", | |
| ok: true, | |
| payload: { | |
| content: [{ type: "text", text: "bad query" }], | |
| structuredContent: { retryable: true }, | |
| isError: true, | |
| }, | |
| }); | |
| const unavailable = await invokeMcp( | |
| managerWith(async () => { | |
| throw new NodeHostMcpError("MCP_SERVER_UNAVAILABLE", "server unavailable"); | |
| }), | |
| { server: "docs", tool: "search" }, | |
| ); | |
| expect(unavailable).toMatchObject({ | |
| ok: false, | |
| error: { code: "MCP_SERVER_UNAVAILABLE", message: "server unavailable" }, | |
| }); | |
| const unexpected = await invokeMcp( | |
| managerWith(async () => { | |
| throw new Error("x".repeat(2_000)); | |
| }), | |
| { server: "docs", tool: "search" }, | |
| ); | |
| expect(unexpected.error?.code).toBe("MCP_TOOL_ERROR"); | |
| expect(unexpected.error?.message).toHaveLength(1_024); | |
| }); | |
| it("does not publish an MCP result after its invocation is canceled", async () => { | |
| const controller = new AbortController(); | |
| let resolveTool: | |
| | ((result: { content: Array<{ type: "text"; text: string }> }) => void) | |
| | undefined; | |
| const callMcpTool = vi.fn<NodeHostMcpManager["callMcpTool"]>( | |
| () => | |
| new Promise((resolve) => { | |
| resolveTool = resolve; | |
| }), | |
| ); | |
| const request = vi.fn<GatewayClient["request"]>().mockResolvedValue(null); | |
| const invoking = handleInvoke( | |
| { | |
| id: "invoke-mcp-canceled", | |
| nodeId: "node-1", | |
| command: "mcp.tools.call.v1", | |
| paramsJSON: JSON.stringify({ server: "docs", tool: "search" }), | |
| timeoutMs: 321, | |
| }, | |
| { request } as unknown as GatewayClient, | |
| { current: async () => [] }, | |
| managerWith(callMcpTool), | |
| { signal: controller.signal }, | |
| ); | |
| await vi.waitFor(() => expect(callMcpTool).toHaveBeenCalledOnce()); | |
| expect(callMcpTool.mock.calls[0]?.[0].signal).toBe(controller.signal); | |
| controller.abort(); | |
| resolveTool?.({ content: [{ type: "text", text: "stale MCP result" }] }); | |
| await invoking; | |
| expect(request).not.toHaveBeenCalled(); | |
| }); | |
| it("caps aggregate MCP text content at one megabyte with a truncation note", async () => { | |
| const result = await invokeMcp( | |
| managerWith(async () => ({ | |
| content: [ | |
| { type: "text", text: "a".repeat(MEBIBYTE) }, | |
| { type: "text", text: "overflow" }, | |
| ], | |
| })), | |
| { server: "docs", tool: "large" }, | |
| ); | |
| const payload = result.payload as { | |
| content: Array<{ type: string; text: string }>; | |
| }; | |
| const text = payload.content.map((block) => block.text).join(""); | |
| expect(Buffer.byteLength(text)).toBeLessThanOrEqual(MEBIBYTE); | |
| expect(text).toContain("truncated: MCP text content exceeded 1 MB"); | |
| }); | |
| it("drops oversized images and structured content before node.invoke serialization", async () => { | |
| const oversized = "A".repeat(20 * MEBIBYTE); | |
| const result = await invokeMcp( | |
| managerWith(async () => ({ | |
| content: [{ type: "image", data: oversized, mimeType: "image/png" }], | |
| structuredContent: { oversized }, | |
| })), | |
| { server: "docs", tool: "large-image" }, | |
| ); | |
| const payload = result.payload as { | |
| content: Array<{ type: string; text?: string }>; | |
| structuredContent?: Record<string, unknown>; | |
| }; | |
| expect(Buffer.byteLength(JSON.stringify(result))).toBeLessThanOrEqual(20 * MEBIBYTE); | |
| expect(payload.content).toEqual([ | |
| { type: "text", text: "[truncated: MCP result exceeded 20 MB]" }, | |
| ]); | |
| expect(payload.structuredContent).toBeUndefined(); | |
| }); | |
| it("preserves structured content and recovery guidance when an exact JSON mirror is oversized", async () => { | |
| const structuredContent = { | |
| oversized: "S".repeat(10 * MEBIBYTE), | |
| }; | |
| const recovery = "authentication expired; run login"; | |
| const result = await invokeMcp( | |
| managerWith(async () => ({ | |
| content: [ | |
| { type: "text", text: JSON.stringify(structuredContent, null, 2) }, | |
| { | |
| type: "image", | |
| data: "I".repeat(10 * MEBIBYTE), | |
| mimeType: "image/png", | |
| }, | |
| { type: "text", text: recovery }, | |
| ], | |
| structuredContent, | |
| isError: true, | |
| })), | |
| { server: "docs", tool: "recover" }, | |
| ); | |
| const payload = result.payload as { | |
| content: Array<{ type: string; text?: string }>; | |
| structuredContent?: Record<string, unknown>; | |
| isError?: boolean; | |
| }; | |
| expect(payload.isError).toBe(true); | |
| expect(payload.structuredContent).toBeDefined(); | |
| expect(payload.structuredContent?.oversized).toHaveLength(structuredContent.oversized.length); | |
| expect(payload.content).toEqual([ | |
| { type: "text", text: recovery }, | |
| { type: "text", text: "[truncated: MCP result exceeded 20 MB]" }, | |
| ]); | |
| expect(Buffer.byteLength(JSON.stringify(result))).toBeLessThanOrEqual(20 * MEBIBYTE); | |
| }); | |
| it("sends MCP payloads as structured invoke data without double JSON escaping", async () => { | |
| const escaped = "\\".repeat(8 * 1024 * 1024); | |
| const result = await invokeMcp( | |
| managerWith(async () => ({ content: [], structuredContent: { escaped } })), | |
| { server: "docs", tool: "escaped" }, | |
| ); | |
| expect(result.payloadJSON).toBeUndefined(); | |
| expect( | |
| (result.payload as { structuredContent: { escaped: string } }).structuredContent.escaped, | |
| ).toBe(escaped); | |
| expect(Buffer.byteLength(JSON.stringify(result))).toBeLessThanOrEqual(20 * MEBIBYTE); | |
| }); | |
| }); | |