import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { afterEach, describe, expect, test, vi } from "vitest"; import { z } from "zod"; import { testState } from "../gateway/test-helpers.mocks.js"; import { createGatewaySuiteHarness, installGatewayTestHooks, writeSessionStore, } from "../gateway/test-helpers.server.js"; import { emitSessionTranscriptUpdate } from "../sessions/transcript-events.js"; import { createOpenClawChannelMcpServer, OpenClawChannelBridge } from "./channel-server.js"; installGatewayTestHooks(); const ClaudeChannelNotificationSchema = z.object({ method: z.literal("notifications/claude/channel"), params: z.object({ content: z.string(), meta: z.record(z.string(), z.string()), }), }); const ClaudePermissionNotificationSchema = z.object({ method: z.literal("notifications/claude/channel/permission"), params: z.object({ request_id: z.string(), behavior: z.enum(["allow", "deny"]), }), }); const cleanupDirs: string[] = []; afterEach(async () => { await Promise.all( cleanupDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })), ); }); async function createSessionStoreFile(): Promise { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mcp-channel-")); cleanupDirs.push(dir); const storePath = path.join(dir, "sessions.json"); testState.sessionStorePath = storePath; return storePath; } async function seedSession(params: { storePath: string; sessionKey: string; sessionId: string; route: { channel: string; to: string; accountId?: string; threadId?: string | number; }; entryOverrides?: Record; transcriptMessages: Array<{ id: string; message: Record }>; }) { const transcriptPath = path.join(path.dirname(params.storePath), `${params.sessionId}.jsonl`); await writeSessionStore({ entries: { [params.sessionKey.split(":").at(-1) ?? "main"]: { sessionId: params.sessionId, sessionFile: transcriptPath, updatedAt: Date.now(), lastChannel: params.route.channel, lastTo: params.route.to, lastAccountId: params.route.accountId, lastThreadId: params.route.threadId, ...params.entryOverrides, }, }, storePath: params.storePath, }); await fs.writeFile( transcriptPath, [ JSON.stringify({ type: "session", version: 1, id: params.sessionId }), ...params.transcriptMessages.map((entry) => JSON.stringify(entry)), ].join("\n"), "utf-8", ); return transcriptPath; } async function connectMcp(params: { gatewayUrl: string; gatewayToken: string; claudeChannelMode?: "auto" | "on" | "off"; }) { const serverHarness = await createOpenClawChannelMcpServer({ gatewayUrl: params.gatewayUrl, gatewayToken: params.gatewayToken, claudeChannelMode: params.claudeChannelMode ?? "auto", }); const client = new Client({ name: "mcp-test-client", version: "1.0.0" }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await serverHarness.server.connect(serverTransport); await client.connect(clientTransport); await serverHarness.start(); return { client, bridge: serverHarness.bridge, close: async () => { await client.close(); await serverHarness.close(); }, }; } describe("openclaw channel mcp server", () => { test("lists conversations, reads messages, and waits for events", async () => { const storePath = await createSessionStoreFile(); const sessionKey = "agent:main:main"; await seedSession({ storePath, sessionKey, sessionId: "sess-main", route: { channel: "telegram", to: "-100123", accountId: "acct-1", threadId: 42, }, transcriptMessages: [ { id: "msg-1", message: { role: "assistant", content: [{ type: "text", text: "hello from transcript" }], timestamp: Date.now(), }, }, { id: "msg-attachment", message: { role: "assistant", content: [ { type: "text", text: "attached image" }, { type: "image", source: { type: "base64", media_type: "image/png", data: "abc", }, }, ], timestamp: Date.now() + 1, }, }, ], }); const harness = await createGatewaySuiteHarness(); let mcp: Awaited> | null = null; try { mcp = await connectMcp({ gatewayUrl: `ws://127.0.0.1:${harness.port}`, gatewayToken: "test-gateway-token-1234567890", }); const listed = (await mcp.client.callTool({ name: "conversations_list", arguments: {}, })) as { structuredContent?: { conversations?: Array> }; }; expect(listed.structuredContent?.conversations).toEqual( expect.arrayContaining([ expect.objectContaining({ sessionKey, channel: "telegram", to: "-100123", accountId: "acct-1", threadId: 42, }), ]), ); const read = (await mcp.client.callTool({ name: "messages_read", arguments: { session_key: sessionKey, limit: 5 }, })) as { structuredContent?: { messages?: Array> }; }; expect(read.structuredContent?.messages?.[0]).toMatchObject({ role: "assistant", content: [{ type: "text", text: "hello from transcript" }], }); expect(read.structuredContent?.messages?.[1]).toMatchObject({ __openclaw: { id: "msg-attachment", }, }); const attachments = (await mcp.client.callTool({ name: "attachments_fetch", arguments: { session_key: sessionKey, message_id: "msg-attachment" }, })) as { structuredContent?: { attachments?: Array> }; isError?: boolean; }; expect(attachments.isError).not.toBe(true); expect(attachments.structuredContent?.attachments).toEqual( expect.arrayContaining([ expect.objectContaining({ type: "image", }), ]), ); const waitPromise = mcp.client.callTool({ name: "events_wait", arguments: { session_key: sessionKey, after_cursor: 0, timeout_ms: 5_000 }, }) as Promise<{ structuredContent?: { event?: Record }; }>; emitSessionTranscriptUpdate({ sessionFile: path.join(path.dirname(storePath), "sess-main.jsonl"), sessionKey, messageId: "msg-2", message: { role: "user", content: [{ type: "text", text: "inbound live message" }], timestamp: Date.now(), }, }); const waited = await waitPromise; expect(waited.structuredContent?.event).toMatchObject({ type: "message", sessionKey, messageId: "msg-2", role: "user", text: "inbound live message", }); } finally { await mcp?.close(); await harness.close(); } }); test("sendMessage normalizes route metadata for gateway send", async () => { const bridge = new OpenClawChannelBridge({} as never, { claudeChannelMode: "off", verbose: false, }); const gatewayRequest = vi.fn().mockResolvedValue({ ok: true, channel: "telegram" }); ( bridge as unknown as { gateway: { request: typeof gatewayRequest; stopAndWait: () => Promise }; readySettled: boolean; resolveReady: () => void; } ).gateway = { request: gatewayRequest, stopAndWait: async () => {}, }; ( bridge as unknown as { readySettled: boolean; resolveReady: () => void; } ).readySettled = true; ( bridge as unknown as { resolveReady: () => void; } ).resolveReady(); vi.spyOn(bridge, "getConversation").mockResolvedValue({ sessionKey: "agent:main:main", channel: "telegram", to: "-100123", accountId: "acct-1", threadId: 42, }); await bridge.sendMessage({ sessionKey: "agent:main:main", text: "reply from mcp", }); expect(gatewayRequest).toHaveBeenCalledWith( "send", expect.objectContaining({ to: "-100123", channel: "telegram", accountId: "acct-1", threadId: "42", sessionKey: "agent:main:main", message: "reply from mcp", }), ); }); test("lists routed sessions that only expose modern channel fields", async () => { const bridge = new OpenClawChannelBridge({} as never, { claudeChannelMode: "off", verbose: false, }); const gatewayRequest = vi.fn().mockResolvedValue({ sessions: [ { key: "agent:main:channel-field", channel: "telegram", deliveryContext: { to: "-100111", }, }, { key: "agent:main:origin-field", origin: { provider: "imessage", accountId: "imessage-default", threadId: "thread-7", }, deliveryContext: { to: "+15551230000", }, }, ], }); ( bridge as unknown as { gateway: { request: typeof gatewayRequest; stopAndWait: () => Promise }; readySettled: boolean; resolveReady: () => void; } ).gateway = { request: gatewayRequest, stopAndWait: async () => {}, }; ( bridge as unknown as { readySettled: boolean; resolveReady: () => void; } ).readySettled = true; ( bridge as unknown as { resolveReady: () => void; } ).resolveReady(); await expect(bridge.listConversations()).resolves.toEqual([ expect.objectContaining({ sessionKey: "agent:main:channel-field", channel: "telegram", to: "-100111", }), expect.objectContaining({ sessionKey: "agent:main:origin-field", channel: "imessage", to: "+15551230000", accountId: "imessage-default", threadId: "thread-7", }), ]); }); test("swallows notification send errors after channel replies are matched", async () => { const bridge = new OpenClawChannelBridge({} as never, { claudeChannelMode: "on", verbose: false, }); ( bridge as unknown as { pendingClaudePermissions: Map>; server: { server: { notification: ReturnType } }; } ).pendingClaudePermissions.set("abcde", { toolName: "Bash", description: "run npm test", inputPreview: '{"cmd":"npm test"}', }); ( bridge as unknown as { server: { server: { notification: ReturnType } }; } ).server = { server: { notification: vi.fn().mockRejectedValue(new Error("Not connected")), }, }; await expect( ( bridge as unknown as { handleSessionMessageEvent: (payload: Record) => Promise; } ).handleSessionMessageEvent({ sessionKey: "agent:main:main", message: { role: "user", content: [{ type: "text", text: "yes abcde" }], }, }), ).resolves.toBeUndefined(); }); test("emits Claude channel and permission notifications", async () => { const storePath = await createSessionStoreFile(); const sessionKey = "agent:main:main"; await seedSession({ storePath, sessionKey, sessionId: "sess-claude", route: { channel: "imessage", to: "+15551234567", }, transcriptMessages: [], }); const harness = await createGatewaySuiteHarness(); let mcp: Awaited> | null = null; try { const channelNotifications: Array<{ content: string; meta: Record }> = []; const permissionNotifications: Array<{ request_id: string; behavior: "allow" | "deny" }> = []; mcp = await connectMcp({ gatewayUrl: `ws://127.0.0.1:${harness.port}`, gatewayToken: "test-gateway-token-1234567890", claudeChannelMode: "on", }); mcp.client.setNotificationHandler(ClaudeChannelNotificationSchema, ({ params }) => { channelNotifications.push(params); }); mcp.client.setNotificationHandler(ClaudePermissionNotificationSchema, ({ params }) => { permissionNotifications.push(params); }); emitSessionTranscriptUpdate({ sessionFile: path.join(path.dirname(storePath), "sess-claude.jsonl"), sessionKey, messageId: "msg-user-1", message: { role: "user", content: [{ type: "text", text: "hello Claude" }], timestamp: Date.now(), }, }); await vi.waitFor(() => { expect(channelNotifications).toHaveLength(1); }); expect(channelNotifications[0]).toMatchObject({ content: "hello Claude", meta: expect.objectContaining({ session_key: sessionKey, channel: "imessage", to: "+15551234567", message_id: "msg-user-1", }), }); await mcp.client.notification({ method: "notifications/claude/channel/permission_request", params: { request_id: "abcde", tool_name: "Bash", description: "run npm test", input_preview: '{"cmd":"npm test"}', }, }); emitSessionTranscriptUpdate({ sessionFile: path.join(path.dirname(storePath), "sess-claude.jsonl"), sessionKey, messageId: "msg-user-2", message: { role: "user", content: [{ type: "text", text: "yes abcde" }], timestamp: Date.now(), }, }); await vi.waitFor(() => { expect(permissionNotifications).toHaveLength(1); }); expect(permissionNotifications[0]).toEqual({ request_id: "abcde", behavior: "allow", }); emitSessionTranscriptUpdate({ sessionFile: path.join(path.dirname(storePath), "sess-claude.jsonl"), sessionKey, messageId: "msg-user-3", message: { role: "user", content: "plain string user turn", timestamp: Date.now(), }, }); await vi.waitFor(() => { expect(channelNotifications).toHaveLength(2); }); expect(channelNotifications[1]).toMatchObject({ content: "plain string user turn", meta: expect.objectContaining({ session_key: sessionKey, message_id: "msg-user-3", }), }); } finally { await mcp?.close(); await harness.close(); } }); });