/** * E2E tests for mcp-server.ts — all 10 MCP tools + concurrent call handling. * * Uses InMemoryTransport (linked pair) for in-process testing without HTTP. * All external dependencies are mocked to prevent real API calls or filesystem access. * * Phase 72: MCP-01 (10 tool tests), MCP-02 (concurrent calls). * DO NOT CHANGE tool names or argument shapes — they must match mcp-server.ts exactly. */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; // ── Module mocks (BEFORE any import of mcp-server) ──────────────────────── vi.mock("./accounts.js", () => ({ listEnabledWahaAccounts: vi.fn().mockReturnValue([ { accountId: "default", session: "test_session", enabled: true, role: "bot", tenantId: "default" }, ]), resolveWahaAccount: vi.fn().mockReturnValue({ accountId: "default", session: "test_session", enabled: true, role: "bot", }), })); vi.mock("./health.js", () => ({ getHealthState: vi.fn().mockReturnValue({ status: "healthy", consecutiveFailures: 0, lastSuccessAt: Date.now(), lastCheckAt: Date.now(), }), })); vi.mock("./mimicry-gate.js", () => ({ getMimicryDb: vi.fn().mockReturnValue({ getFirstSendAt: vi.fn().mockReturnValue(null), countRecentSends: vi.fn().mockReturnValue(0), }), getCapStatus: vi.fn().mockReturnValue({ count: 0, limit: 10, remaining: 10, maturity: "new", windowStartMs: Date.now() - 3600000, }), resolveCapLimit: vi.fn().mockReturnValue(10), getMaturityPhase: vi.fn().mockReturnValue("new"), })); vi.mock("./directory.js", () => ({ getDirectoryDb: vi.fn().mockReturnValue({ getContacts: vi.fn().mockReturnValue([]), getContactCount: vi.fn().mockReturnValue(0), getGroupCount: vi.fn().mockReturnValue(0), getNewsletterCount: vi.fn().mockReturnValue(0), getDmCount: vi.fn().mockReturnValue(0), getContact: vi.fn().mockReturnValue(null), }), })); vi.mock("./proxy-send-handler.js", () => ({ handleProxySend: vi.fn().mockResolvedValue({ status: 200, body: { ok: true } }), })); vi.mock("./config-io.js", () => ({ getConfigPath: vi.fn().mockReturnValue("/mock/openclaw.json"), readConfig: vi.fn().mockResolvedValue({ channels: { waha: { session: "test_session" } } }), modifyConfig: vi.fn().mockResolvedValue(undefined), })); vi.mock("./send.js", () => ({ getWahaChatMessages: vi.fn().mockResolvedValue([]), })); vi.mock("./http-client.js", () => ({ callWahaApi: vi.fn().mockResolvedValue({ ok: true }), })); vi.mock("node:fs", async () => { const actual = await vi.importActual("node:fs"); return { ...actual, readFileSync: vi.fn().mockReturnValue("{}"), writeFileSync: vi.fn(), existsSync: vi.fn().mockReturnValue(false), }; }); // ── Import module under test AFTER all mocks ─────────────────────────────── import { createMcpServer } from "./mcp-server.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; // ── Helpers ──────────────────────────────────────────────────────────────── /** Track all created client/server pairs so afterEach can close them */ const activePairs: Array<{ client: Client; server: ReturnType }> = []; function makeMinimalConfig() { return { channels: { waha: { apiUrl: "http://127.0.0.1:3004", apiKey: "test-key", session: "test_session", baseUrl: "http://127.0.0.1:3004", dmFilter: { enabled: false }, groupFilter: { enabled: false }, allowFrom: [], groupAllowFrom: [], allowedGroups: [], }, }, }; } async function makeConnectedClient(cfg = makeMinimalConfig()) { const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); const server = createMcpServer(cfg as any); await server.connect(serverTransport); const client = new Client({ name: "test-client", version: "1.0.0" }); await client.connect(clientTransport); activePairs.push({ client, server }); return { client, server }; } afterEach(async () => { for (const pair of activePairs) { try { await pair.client.close(); } catch { /* already closed */ } try { await pair.server.close(); } catch { /* already closed */ } } activePairs.length = 0; }); // ── Tests ────────────────────────────────────────────────────────────────── describe("MCP tools (MCP-01)", () => { // DO NOT CHANGE tool names or argument shapes — they must match mcp-server.ts exactly. const toolCases = [ { name: "send_message", arguments: { chatId: "test@c.us", session: "test_session", text: "hello" } }, { name: "send_media", arguments: { chatId: "test@c.us", session: "test_session", url: "https://example.com/img.jpg", type: "image" } }, { name: "read_messages", arguments: { chatId: "test@c.us", session: "test_session" } }, { name: "search", arguments: { query: "test" } }, { name: "get_directory", arguments: {} }, { name: "manage_group", arguments: { action: "info", session: "test_session", groupId: "test@g.us" } }, { name: "get_status", arguments: {} }, { name: "update_settings", arguments: { path: "channels.waha.sendGate.startHour", value: 8 } }, { name: "send_poll", arguments: { chatId: "test@c.us", session: "test_session", name: "Test?", options: ["Yes", "No"] } }, { name: "send_reaction", arguments: { chatId: "test@c.us", session: "test_session", messageId: "true_test@c.us_ABC123", reaction: "\ud83d\udc4d" } }, ] as const; it.each(toolCases)("$name — succeeds and returns valid JSON content", async ({ name, arguments: args }) => { const { client } = await makeConnectedClient(); const result = await client.callTool({ name, arguments: { ...args } }); expect(result.isError).not.toBe(true); expect(result.content).toBeDefined(); // Verify content structure: must be an array with at least one text entry containing valid JSON expect(Array.isArray(result.content)).toBe(true); const firstEntry = (result.content as Array<{ type: string; text: string }>)[0]; expect(firstEntry).toHaveProperty("type", "text"); expect(firstEntry).toHaveProperty("text"); // Content text must be a non-empty string (some tools return JSON, others plain text) expect(typeof firstEntry.text).toBe("string"); expect(firstEntry.text.length).toBeGreaterThan(0); }); it("update_settings — rejects paths outside channels.waha", async () => { const { client } = await makeConnectedClient(); const result = await client.callTool({ name: "update_settings", arguments: { path: "agents.openai.model", value: "gpt-4" }, }); expect(result.isError).toBe(true); // Verify error message references the restricted path scope const errorText = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ""; expect(errorText).toMatch(/channels\.waha/i); }); }); describe("MCP concurrent calls (MCP-02)", () => { it("handles 6 concurrent tool calls without errors", async () => { const { client } = await makeConnectedClient(); const calls = [ client.callTool({ name: "get_status", arguments: {} }), client.callTool({ name: "get_directory", arguments: {} }), client.callTool({ name: "search", arguments: { query: "test" } }), client.callTool({ name: "get_status", arguments: {} }), client.callTool({ name: "get_directory", arguments: {} }), client.callTool({ name: "read_messages", arguments: { chatId: "test@c.us", session: "test_session" } }), ]; const results = await Promise.all(calls); results.forEach(r => { expect(r.isError).not.toBe(true); expect(r.content).toBeDefined(); }); }); });