diff --git a/packages/simplex-chat-mcp/test/e2e/daemon.test.ts b/packages/simplex-chat-mcp/test/e2e/daemon.test.ts new file mode 100644 index 0000000000..af3e479878 --- /dev/null +++ b/packages/simplex-chat-mcp/test/e2e/daemon.test.ts @@ -0,0 +1,191 @@ +import {mkdtempSync, rmSync} from "fs" +import {tmpdir} from "os" +import {join} from "path" +import {T} from "@simplex-chat/types" +import {api} from "simplex-chat" +import {DaemonClient} from "../../src/client" +import {defaultConfig} from "../../src/config" +import {Daemon, productionDeps} from "../../src/daemon" +import {SessionReply, StatusReply} from "../../src/protocol" +import {Bot, ItemCheck, callTool, captureLog, cardNumber, joinByLink, lines, person, prepareRoot, received, startBot, textStarts, until, updated} from "./helpers" +import {SmpServer, startSmpServer} from "./smpServer" +import {daemonPid, killDaemon, spawnSession} from "./stdio" + +const one = "11111111-1111-4111-8111-111111111111" +const two = "22222222-2222-4222-8222-222222222222" +const three = "33333333-3333-4333-8333-333333333333" +const daemonStopped = "The SimpleX daemon stopped; repeat the call." + +let smpServer: SmpServer +let dir: string +let logLines: string[] +let closing: (() => Promise)[] + +beforeAll(async () => { + smpServer = await startSmpServer() + dir = mkdtempSync(join(tmpdir(), "simplex-mcp-daemon-")) + logLines = captureLog() +}, 120_000) + +afterAll(async () => { + jest.restoreAllMocks() + await smpServer?.stop() + rmSync(dir, {recursive: true, force: true}) +}) + +beforeEach(() => { + closing = [] +}) + +afterEach(async () => { + for (const close of closing.reverse()) await close().catch(error => process.stderr.write(`cleanup: ${error}\n`)) +}) + +type Person = {chat: api.ChatApi; user: T.User} + +async function personIn(scenario: string, name: string): Promise { + const p = await person(smpServer.address, join(dir, scenario, name), name) + closing.push(() => p.chat.close()) + return p +} + +async function botIn(scenario: string, name: string): Promise { + const b = await startBot(smpServer.address, join(dir, scenario, name), name, {welcomeMessage: "Welcome!"}) + closing.push(() => b.chat.close()) + return b +} + +async function startDaemon(root: string): Promise { + const daemon = await Daemon.start({...productionDeps(root), onStopped: () => undefined}, () => defaultConfig) + closing.push(() => daemon.stop()) + return daemon +} + +function openSession(client: DaemonClient, id: string, name: string): Promise { + return client.request("POST", "/session", {id, name, project: "/p", config: defaultConfig}) +} + +const cardFrom = (session: number, name: string, request: string): ItemCheck => textStarts(`Session ${session} ยท SimpleX MCP - ${name}\n${request}`) + +test("two sessions in one Main group, then resume after a restart", async () => { + const root = join(dir, "sessions", "root") + await prepareRoot(smpServer.address, root, [ + {id: one, name: "one"}, + {id: two, name: "two"}, + ]) + const alice = await personIn("sessions", "Alice") + const directory = await botIn("sessions", "Directory") + let daemon = await startDaemon(root) + const client = DaemonClient.forRoot(root) + + expect(await openSession(client, one, "one")).toMatchObject({number: 1, displayName: "SimpleX MCP - one", owner: {owners: []}}) + expect(await openSession(client, two, "two")).toMatchObject({number: 2, displayName: "SimpleX MCP - two"}) + const {owner} = await client.request("GET", "/status") + expect(owner.link).toBeDefined() + + const greeting = received(alice.chat, textStarts("I'm your agents' Main group.")) + await joinByLink(alice, owner.link!) + const mainId = ((await greeting).chatInfo as T.ChatInfo.Group).groupInfo.groupId + await until(() => logLines.some(line => /^Alice joined \(member \d+\) and is now an owner\. The link is deleted\.$/.test(line))) + expect((await client.request("GET", "/status")).owner).toEqual({owners: ["Alice"]}) + const ownerSends = (text: string) => alice.chat.apiSendTextMessage([T.ChatType.Group, mainId], text) + + const connectDirectory = async (id: string, session: number, name: string): Promise<{n: number; chat: string}> => { + const card = received(alice.chat, cardFrom(session, name, "I want to connect to bot Directory, as myself.")) + const connecting = callTool(client, id, "connect", {target: directory.link, idle: 2}) + const n = cardNumber(await card) + await ownerSends(`/approve ${n}`) + const {result} = await connecting + expect(result).toMatchObject({action: n, status: "done", connected: true, type: "contact", name: "Directory"}) + return {n, chat: result.chat} + } + const first = await connectDirectory(one, 1, "one") + const second = await connectDirectory(two, 2, "two") + expect(second.n).not.toBe(first.n) + const target1 = `1:${first.chat}` + const target2 = `2:${second.chat}` + + const sendCard = (session: number, name: string, text: string): Promise => { + const isCard = cardFrom(session, name, "I want to send a message to bot Directory:") + return received(alice.chat, ci => isCard(ci) && lines(ci)[2] === `\`${text}\``) + } + const responseTexts = ({result}: {result: any}): string[] => result.responses.map(({text}: {text: string}) => text) + + const card1 = sendCard(1, "one", "from one") + const sending1 = callTool(client, one, "send", {chat: first.chat, text: "from one", idle: 2}) + const n1 = cardNumber(await card1) + const card2 = sendCard(2, "two", "from two") + const sending2 = callTool(client, two, "send", {chat: second.chat, text: "from two", idle: 2}) + const n2 = cardNumber(await card2) + const outcome = (card: T.AChatItem): Promise => updated(alice.chat, ci => ci.chatItem.meta.itemId === card.chatItem.meta.itemId) + const notFor = received(alice.chat, textStarts(`Action ${n2} is not for ${target1}.`)) + await ownerSends(`/approve ${target1} ${n2}`) + await notFor + const approved2 = outcome(await card2) + await ownerSends(`/approve ${target2} ${n2}`) + const sent2 = await sending2 + expect(sent2.result).toMatchObject({action: n2, status: "done", chat: second.chat, more: 0}) + expect(responseTexts(sent2)).toEqual(["result 1: from two", "result 2: from two"]) + expect(lines(await approved2).at(-1)).toBe("Approved by Alice.") + expect((await callTool(client, one, "list", {})).result.actions).toEqual([expect.objectContaining({action: n1, tool: "send", state: "pending"})]) + const rejected1 = outcome(await card1) + await ownerSends(`/reject ${target1} ${n1}`) + expect((await sending1).result).toEqual({action: n1, status: "rejected"}) + expect(lines(await rejected1).at(-1)).toBe("Rejected by Alice.") + + const approvedAll = received(alice.chat, textStarts("Approved for bot Directory")) + await ownerSends(`/approve ${target2} all`) + expect((await approvedAll).chatItem.meta.itemText).toBe(`Approved for bot Directory (${target2}) by Alice. /'revoke ${target2}'`) + const standingOutcome = received(alice.chat, cardFrom(2, "two", "I sent a message to bot Directory:\n`standing`")) + const standing = await callTool(client, two, "send", {chat: second.chat, text: "standing", idle: 2}) + expect(standing.result).toMatchObject({status: "done", chat: second.chat}) + expect(responseTexts(standing)).toEqual(["result 1: standing", "result 2: standing"]) + expect(lines(await standingOutcome).at(-1)).toBe(`Approved for this chat by Alice. /'revoke ${target2}'`) + + const revoked = received(alice.chat, textStarts(`Messages to bot Directory (${target2}) need approval again.`)) + await ownerSends(`/revoke ${target2}`) + await revoked + const card3 = sendCard(2, "two", "after revoke") + const sending3 = callTool(client, two, "send", {chat: second.chat, text: "after revoke", idle: 2}) + const n3 = cardNumber(await card3) + await ownerSends(`/approve ${target2} ${n3}`) + expect((await sending3).result).toMatchObject({action: n3, status: "done"}) + + await daemon.stop() + daemon = await startDaemon(root) + expect(await openSession(client, two, "two")).toMatchObject({number: 2, displayName: "SimpleX MCP - two", owner: {owners: ["Alice"]}}) + const listed = await callTool(client, two, "list", {}) + expect(listed.result.chats).toContainEqual(expect.objectContaining({chat: second.chat, type: "contact", name: "Directory", connected: true})) + expect(listed.result.actions).toEqual([]) +}, 600_000) + +test("stdio session spawns the daemon and recovers after the daemon is killed", async () => { + const root = join(dir, "stdio", "root") + await prepareRoot(smpServer.address, root, [{id: three, name: "three"}]) + closing.push(() => killDaemon(root)) + const session = spawnSession({...process.env, CLAUDE_CODE_SESSION_ID: three, SIMPLEX_MCP_HOME: root, CLAUDE_PROJECT_DIR: "/p/three"}) + closing.push(() => session.stop()) + + expect(await session.client.initialize()).toMatchObject({serverInfo: {name: "simplex-mcp"}}) + const {result} = await session.client.request("tools/list") + expect(result.tools.map(({name}: {name: string}) => name)).toEqual(["connect", "send", "receive", "list", "leave"]) + const listed = await session.call("list", {}) + expect(listed.isError).toBeUndefined() + expect(listed.structuredContent.actions).toEqual([]) + const status = await DaemonClient.forRoot(root).request("GET", "/status") + expect(status.sessions).toEqual([{number: 1, name: "SimpleX MCP - three", attached: 1}]) + const [chat] = listed.structuredContent.chats as {chat: string}[] + expect(chat).toBeDefined() + + const firstDaemon = daemonPid(root) + const progressToken = "receive" + const receiving = session.call("receive", {chat: chat.chat, wait: 60}, {progressToken}) + await until(() => session.client.notifications.some(({method, params}) => method === "notifications/progress" && params.progressToken === progressToken)) + await killDaemon(root) + expect(await receiving).toMatchObject({isError: true, content: [{type: "text", text: daemonStopped}]}) + + const relisted = await session.call("list", {}) + expect(relisted.isError).toBeUndefined() + expect(relisted.structuredContent.chats).toEqual(listed.structuredContent.chats) + expect(daemonPid(root)).not.toBe(firstDaemon) +}, 600_000) diff --git a/packages/simplex-chat-mcp/test/e2e/helpers.ts b/packages/simplex-chat-mcp/test/e2e/helpers.ts index 6f15cd9550..903174ed96 100644 --- a/packages/simplex-chat-mcp/test/e2e/helpers.ts +++ b/packages/simplex-chat-mcp/test/e2e/helpers.ts @@ -1,5 +1,9 @@ +import {join} from "path" import {T} from "@simplex-chat/types" import {api, bot} from "simplex-chat" +import {DaemonClient} from "../../src/client" +import {createDataFolder, hubFolder, sessionFolder} from "../../src/files" +import {CallLine} from "../../src/protocol" export const timeout = 60_000 @@ -21,6 +25,23 @@ export async function profileWithServer(smpAddress: string, filePrefix: string, await chat.close() } +export async function prepareRoot(smpAddress: string, root: string, sessions: {id: string; name: string}[]): Promise { + const folders = [{folder: hubFolder(root), displayName: "SimpleX MCP"}, ...sessions.map(({id, name}) => ({folder: sessionFolder(root, id), displayName: `SimpleX MCP - ${name}`}))] + for (const {folder, displayName} of folders) { + createDataFolder(folder) + await profileWithServer(smpAddress, join(folder, "simplex"), {displayName, fullName: "", peerType: T.ChatPeerType.Agent}) + } +} + +export async function callTool(client: DaemonClient, id: string, tool: string, input: object): Promise<{isError?: boolean; text: string; result: any}> { + let result: any + await client.stream("/call", {id, tool, input}, line => { + const parsed = line as CallLine + if ("result" in parsed) result = parsed.result + }) + return {isError: result?.isError, text: result?.content[0].text, result: result?.structuredContent} +} + export async function person(smpAddress: string, filePrefix: string, displayName: string): Promise<{chat: api.ChatApi; user: T.User}> { await profileWithServer(smpAddress, filePrefix, {displayName, fullName: ""}) const chat = await api.ChatApi.init({type: "sqlite", filePrefix}) @@ -54,10 +75,6 @@ export async function startBot(smpAddress: string, filePrefix: string, displayNa return {chat, user, link: address!.connLinkContact.connShortLink ?? address!.connLinkContact.connFullLink} } -export function lastLink(lines: string[]): string { - return lines.filter(line => /^(https:\/\/|simplex:)/.test(line)).at(-1)! -} - export async function joinByLink({chat, user}: {chat: api.ChatApi; user: T.User}, link: string): Promise { const joined = chat.wait("userJoinedGroup", timeout) const [, connLink] = await chat.apiConnectPlan(user.userId, link) @@ -94,17 +111,9 @@ export function updated(chat: api.ChatApi, check: ItemCheck): Promise ci => ci.chatItem.meta.itemText.startsWith(prefix) export function cardNumber(ci: T.AChatItem): number { - return Number(/\/'approve (?:[#@]\d+ )?(\d+)'/.exec(ci.chatItem.meta.itemText)![1]) + return Number(/\/'approve (?:\d+:[#@]\d+ )?(\d+)'/.exec(ci.chatItem.meta.itemText)![1]) } export function lines(ci: T.AChatItem): string[] { return ci.chatItem.meta.itemText.split("\n") } - -export function isActive({memberStatus}: T.GroupMember): boolean { - return memberStatus === T.GroupMemberStatus.Connected || memberStatus === T.GroupMemberStatus.Complete -} - -export async function controlGroupOf(chat: api.ChatApi, userId: number): Promise { - return (await chat.apiListGroups(userId)).find(({customData}) => (customData as {simplexMcp?: {control?: boolean}} | undefined)?.simplexMcp?.control === true)! -} diff --git a/packages/simplex-chat-mcp/test/e2e/stdio.ts b/packages/simplex-chat-mcp/test/e2e/stdio.ts new file mode 100644 index 0000000000..5283149e26 --- /dev/null +++ b/packages/simplex-chat-mcp/test/e2e/stdio.ts @@ -0,0 +1,43 @@ +import {spawn} from "child_process" +import {once} from "events" +import {existsSync, readFileSync} from "fs" +import {join} from "path" +import {LineClient} from "../unit/fixtures" +import {until} from "./helpers" + +const cli = join(__dirname, "../../dist/cli.js") + +export interface SessionProcess { + client: LineClient + call(tool: string, args: object, meta?: object): Promise + stop(): Promise +} + +export function spawnSession(env: NodeJS.ProcessEnv): SessionProcess { + const child = spawn(process.execPath, [cli, "session"], {env, stdio: ["pipe", "pipe", "inherit"]}) + child.stdout.setEncoding("utf8") + const client = new LineClient(child.stdin, child.stdout) + return { + client, + call: async (tool, args, meta) => (await client.request("tools/call", {name: tool, arguments: args, ...(meta === undefined ? {} : {_meta: meta})})).result, + stop: async () => { + if (child.exitCode !== null || child.signalCode !== null) return + const exited = once(child, "exit") + child.stdin.end() + child.kill() + await exited + }, + } +} + +export function daemonPid(root: string): number { + return Number(readFileSync(join(root, "lock"), "utf8").trim()) +} + +// SIGTERM runs the daemon's stop sequence, which removes the lock file +export async function killDaemon(root: string): Promise { + const lock = join(root, "lock") + if (!existsSync(lock)) return + process.kill(daemonPid(root)) + await until(() => !existsSync(lock)) +}