diff --git a/packages/simplex-chat-mcp/src/tools.ts b/packages/simplex-chat-mcp/src/tools.ts index 9870ebd588..d110bbe66a 100644 --- a/packages/simplex-chat-mcp/src/tools.ts +++ b/packages/simplex-chat-mcp/src/tools.ts @@ -92,7 +92,6 @@ export interface Agent { user: T.User actions: Actions watch: ChatItemsWatch - controlChat: () => string standingApprover: (chatInfo: T.ChatInfo) => string | undefined } @@ -107,7 +106,7 @@ type Plan = {join: PlanSummary; method: JoinMethod} | {known: T.ChatInfo; summar type Schema = {[key: string]: unknown} -type ToolName = "connect" | "send" | "receive" | "list" | "leave" +export type ToolName = "connect" | "send" | "receive" | "list" | "leave" const maxChats = 1_000_000 const editWindow = 24 * 3_600_000 - 10 * 60_000 @@ -320,9 +319,9 @@ const leaveDescription = `Deletes a contact, or leaves a group, business chat or It is an action: in approval mode requests the owner approves it in the SimpleX app first, and the call waits for the decision. Result status: done, rejected, expired, failed (error). If the call ends without a result, repeat it with the same arguments: it attaches to the same action.` -export function instructions(displayName: string): string { - return `You act in SimpleX chats through these tools, as the agent ${displayName}. -connect joins a contact, group, channel or directory by a link or a SimpleX name, as your profile ${displayName} or incognito with a new random name for that chat only. +export function instructions(): string { + return `You act in SimpleX chats through these tools, as your agent profile. +connect joins a contact, group, channel or directory by a link or a SimpleX name, as your profile or incognito with a new random name for that chat only. leave deletes a contact, or leaves a group or channel, and deletes the chat. connect, send and leave are actions: in mode requests your owner approves each one in the SimpleX app, and the call waits for the decision. A send to a chat with approved: true in list is performed without waiting for approval. send returns every message received in its chat after your message, until no new one arrives for idle seconds or wait ends. receive returns messages that arrived later. A call returns once; call again for later messages. @@ -350,20 +349,22 @@ const definitions: ToolDefinition[] = [ const cancelled: CallToolResult = {content: [{type: "text", text: "Cancelled."}], isError: true} -export class AgentTools { - constructor(private readonly agent: Agent, private readonly mode: Mode) {} +export type ToolHandler = (name: ToolName, input: unknown, call: ToolCall) => Promise - register(server: McpServer): void { - for (const {name, description, inputSchema, outputSchema, action} of definitions) { - const config = { - description, - inputSchema: fromJsonSchema(inputSchema as JsonSchemaType), - outputSchema: fromJsonSchema(outputSchema as JsonSchemaType), - ...(action && this.mode === "host" ? {_meta: {"anthropic/requiresUserInteraction": true}} : {}), - } - server.registerTool(name, config, async (input, ctx) => (await this.call(name, input, toolCall(ctx))) ?? cancelled) +export function registerTools(server: McpServer, mode: Mode, handler: ToolHandler): void { + for (const {name, description, inputSchema, outputSchema, action} of definitions) { + const config = { + description, + inputSchema: fromJsonSchema(inputSchema as JsonSchemaType), + outputSchema: fromJsonSchema(outputSchema as JsonSchemaType), + ...(action && mode === "host" ? {_meta: {"anthropic/requiresUserInteraction": true}} : {}), } + server.registerTool(name, config, async (input, ctx) => (await handler(name, input, toolCall(ctx))) ?? cancelled) } +} + +export class AgentTools { + constructor(private readonly agent: Agent) {} async call(name: ToolName, input: unknown, call: ToolCall): Promise { try { @@ -411,7 +412,7 @@ export class AgentTools { } private async connect(input: ConnectInput, call: ToolCall): Promise { - const key = actionKey("connect", {target: input.target, incognito: input.incognito ?? false, message: input.message ?? null}) + const key = actionKey(this.agent.session, "connect", {target: input.target, incognito: input.incognito ?? false, message: input.message ?? null}) const attached = this.agent.actions.find(key) if (attached !== undefined) return this.connectResult(attached as ConnectAction, input, call) const plan = await this.plan(input) @@ -580,7 +581,7 @@ export class AgentTools { } const replyTo = input.reply_to === undefined ? undefined : await this.quotedItem(chat, input.reply_to) const mentions = input.mention === undefined ? [] : await this.mentionedMembers(chatInfo, chat, input.mention) - const key = actionKey("send", {chat, text: input.text, reply_to: input.reply_to ?? null, mention: input.mention ?? null}) + const key = actionKey(this.agent.session, "send", {chat, text: input.text, reply_to: input.reply_to ?? null, mention: input.mention ?? null}) const subject = chatSubject(chatInfo) const responses = new Collected() if (call.signal.aborted) return undefined @@ -631,7 +632,7 @@ export class AgentTools { private async leave(input: LeaveInput, call: ToolCall): Promise { const {chat} = input - const key = actionKey("leave", {chat}) + const key = actionKey(this.agent.session, "leave", {chat}) const attached = this.agent.actions.find(key) if (attached !== undefined) return this.leaveResult(attached as LeaveAction, call) const {chatInfo} = await this.knownChat(chat) @@ -653,7 +654,7 @@ export class AgentTools { private async receive(input: ReceiveInput, call: ToolCall): Promise { const {chat, wait = 0, limit = defaultLimit} = input - if (!isChatRef(chat) || chat === this.agent.controlChat()) throw unknownChat(chat) + if (!isChatRef(chat)) throw unknownChat(chat) const arrived = new Collected() const stop = this.agent.watch.collect(chat, arrived) try { @@ -693,22 +694,18 @@ export class AgentTools { const fields = await this.chatFields(chatInfo) chats.push({chat: fields.chat, type: fields.type, name: fields.name, simplex_name: fields.simplex_name, peer_type: fields.peer_type, incognito: isIncognito(chatInfo), known_as: fields.known_as, connected: fields.connected, unread: chatStats.unreadCount, ...(this.agent.standingApprover(chatInfo) === undefined ? {} : {approved: true})}) } - const actions = this.agent.actions.all().map(({n, tool, input, state, open}) => ({action: n, tool, input, state, open: open > 0})) + const actions = this.agent.actions.ofSession(this.agent.session).map(({n, tool, input, state, open}) => ({action: n, tool, input, state, open: open > 0})) return {chats, actions} } async agentChats(): Promise { const {userId} = this.agent.user - const control = this.agent.controlChat() const {chats} = await chatCommand<{chats: T.AChat[]}>(this.agent.chat, `/_get chats ${userId} pcc=off count=${maxChats}`, "apiChats") - return chats.filter(({chatInfo}) => { - const ref = chatRef(chatInfo) - return ref !== undefined && ref !== control - }) + return chats.filter(({chatInfo}) => chatRef(chatInfo) !== undefined) } async knownChat(chat: string): Promise { - if (!isChatRef(chat) || chat === this.agent.controlChat()) throw unknownChat(chat) + if (!isChatRef(chat)) throw unknownChat(chat) try { return await getChat(this.agent.chat, chat, "count=0") } catch (error) { @@ -987,8 +984,8 @@ function unknownChat(chat: string): ToolError { return new ToolError(`No chat ${chat}. list shows the agent's chats.`) } -function actionKey(tool: string, fields: object): string { - return JSON.stringify([tool, fields]) +function actionKey(session: number, tool: string, fields: object): string { + return JSON.stringify([session, tool, fields]) } function defined(fields: O): Partial { diff --git a/packages/simplex-chat-mcp/test/unit/commands.test.ts b/packages/simplex-chat-mcp/test/unit/commands.test.ts index c531048507..d356165b16 100644 --- a/packages/simplex-chat-mcp/test/unit/commands.test.ts +++ b/packages/simplex-chat-mcp/test/unit/commands.test.ts @@ -2,7 +2,7 @@ import {CallToolResult} from "@modelcontextprotocol/server" import {ChatEvent, T} from "@simplex-chat/types" import {Actions, Mode, NewAction, ToolError} from "../../src/actions" import {Control, OwnerEntry, cardText, parseCommand} from "../../src/control" -import {ChatItemsWatch, Collected, utcMinutes} from "../../src/messages" +import {ChatItemsWatch, Collected, chatRef, utcMinutes} from "../../src/messages" import {AgentTools} from "../../src/tools" import {FakeChat, actionsWith, contact, direct, event, fakeHooks, group, groupChat, item, member, settled, signal, user} from "./fixtures" @@ -36,9 +36,16 @@ function agentChats(): FakeChat { return chat } -async function controlWith(chat: FakeChat, actions: Actions, owners?: OwnerEntry[], mode: Mode = "requests"): Promise<{control: Control; tools: AgentTools}> { - const tools = new AgentTools({chat: chat.api(), session: 1, user, actions, watch: new ChatItemsWatch(), controlChat: () => "#10", standingApprover: chatInfo => control.standingApprover(chatInfo)}, mode) - const control: Control = new Control(chat.api(), group(10, "agent"), {displayName: "agent", owners, registerHint: "hint", qrCode: "small"}, actions, tools) +async function controlWith(chat: FakeChat, actions: Actions, owners?: OwnerEntry[]): Promise<{control: Control; tools: AgentTools}> { + const tools = new AgentTools({chat: chat.api(), session: 1, user, actions, watch: new ChatItemsWatch(), standingApprover: chatInfo => control.standingApprover(chatInfo)}) + const withoutControlChat: Pick = { + agentChats: async () => (await tools.agentChats()).filter(({chatInfo}) => chatRef(chatInfo) !== "#10"), + knownChat: async ref => { + if (ref === "#10") throw new ToolError(`No chat ${ref}. list shows the agent's chats.`) + return tools.knownChat(ref) + }, + } + const control: Control = new Control(chat.api(), group(10, "agent"), {displayName: "agent", owners, registerHint: "hint", qrCode: "small"}, actions, withoutControlChat) await control.load() return {control, tools} } @@ -329,7 +336,7 @@ describe("command menu", () => { const chat = agentChats() const actions = actionsWith("requests", fakeHooks()) const old = group(10, "agent", self, {groupProfile: {displayName: "agent", fullName: "", groupPreferences: {history: {enable: "on"}, commands: [{type: "command", keyword: "approve", label: "Approve", params: "N"}]}}}) - const tools = new AgentTools({chat: chat.api(), session: 1, user, actions, watch: new ChatItemsWatch(), controlChat: () => "#10", standingApprover: () => undefined}, "requests") + const tools = new AgentTools({chat: chat.api(), session: 1, user, actions, watch: new ChatItemsWatch(), standingApprover: () => undefined}) await new Control(chat.api(), old, {displayName: "agent", registerHint: "hint", qrCode: "small"}, actions, tools).load() expect(chat.profiles).toEqual([{groupId: 10, groupProfile: {displayName: "agent", fullName: "", groupPreferences: {history: {enable: "on"}, commands: menu}}}]) const current = group(10, "agent", self, {groupProfile: {displayName: "agent", fullName: "", groupPreferences: {history: {enable: "on"}, commands: menu}}}) @@ -351,7 +358,7 @@ describe("sends to a chat approved for all messages", () => { outcome: action => control.outcome(action), perform: (action, signal) => tools.perform(action, signal), }) - const {control, tools} = await controlWith(chat, actions, undefined, mode) + const {control, tools} = await controlWith(chat, actions) return {chat, actions, control, tools} } diff --git a/packages/simplex-chat-mcp/test/unit/fixtures.ts b/packages/simplex-chat-mcp/test/unit/fixtures.ts index 9f02d178f3..4e75c6ab1d 100644 --- a/packages/simplex-chat-mcp/test/unit/fixtures.ts +++ b/packages/simplex-chat-mcp/test/unit/fixtures.ts @@ -1,5 +1,6 @@ import {ChatResponse, T} from "@simplex-chat/types" import {api, core} from "simplex-chat" +import {Readable, Writable} from "stream" import {Actions, ActionHooks} from "../../src/actions" import {ToolCall} from "../../src/messages" @@ -243,3 +244,39 @@ function chatStats(items: T.ChatItem[]): T.ChatStats { function storeError(ref: string): Error { return new core.ChatAPIError("Chat command error", {type: "errorStore", storeError: {type: "contactNotFound", contactId: Number(ref.slice(1))}}) } + +export class LineClient { + private nextId = 1 + private buffer = "" + private readonly waiting = new Map void>() + readonly notifications: any[] = [] + + constructor(private readonly input: Writable, output: Readable) { + output.on("data", chunk => { + this.buffer += chunk + for (let i = this.buffer.indexOf("\n"); i >= 0; i = this.buffer.indexOf("\n")) { + const message = JSON.parse(this.buffer.slice(0, i)) + this.buffer = this.buffer.slice(i + 1) + if (message.id !== undefined && this.waiting.has(message.id)) this.waiting.get(message.id)!(message) + else this.notifications.push(message) + } + }) + } + + request(method: string, params: object = {}): Promise { + const id = this.nextId++ + const reply = new Promise(resolve => this.waiting.set(id, resolve)) + this.input.write(`${JSON.stringify({jsonrpc: "2.0", id, method, params})}\n`) + return reply + } + + notify(method: string, params: object = {}): void { + this.input.write(`${JSON.stringify({jsonrpc: "2.0", method, params})}\n`) + } + + async initialize(): Promise { + const reply = await this.request("initialize", {protocolVersion: "2025-11-25", capabilities: {}, clientInfo: {name: "test", version: "1"}}) + this.notify("notifications/initialized") + return reply.result + } +} diff --git a/packages/simplex-chat-mcp/test/unit/messages.test.ts b/packages/simplex-chat-mcp/test/unit/messages.test.ts index 42e714f0db..85e93d8c5f 100644 --- a/packages/simplex-chat-mcp/test/unit/messages.test.ts +++ b/packages/simplex-chat-mcp/test/unit/messages.test.ts @@ -144,9 +144,9 @@ function textOf(result: CallToolResult | undefined): string { return (result!.content[0] as {text: string}).text } -function agentTools(chat: FakeChat, actions: Actions = actionsWith("requests", fakeHooks()), standingApprover: (chatInfo: T.ChatInfo) => string | undefined = () => undefined): {tools: AgentTools; watch: ChatItemsWatch} { +function agentTools(chat: FakeChat, actions: Actions = actionsWith("requests", fakeHooks()), standingApprover: (chatInfo: T.ChatInfo) => string | undefined = () => undefined, session = 1): {tools: AgentTools; watch: ChatItemsWatch} { const watch = new ChatItemsWatch() - return {tools: new AgentTools({chat: chat.api(), session: 1, user, actions, watch, controlChat: () => "#10", standingApprover}, "requests"), watch} + return {tools: new AgentTools({chat: chat.api(), session, user, actions, watch, standingApprover}), watch} } describe("receive", () => { @@ -228,7 +228,7 @@ describe("receive", () => { expect(chat.commands.at(-1)).toBe("/_read chat items @4 30") }) - it("refuses the agent control chat and unknown chats", async () => { + it("refuses unknown chats", async () => { const {tools} = agentTools(new FakeChat()) expect(textOf(await tools.call("receive", {chat: "#10"}, signal().call))).toBe("No chat #10. list shows the agent's chats.") expect(textOf(await tools.call("receive", {chat: "@99"}, signal().call))).toBe("No chat @99. list shows the agent's chats.") @@ -406,6 +406,26 @@ describe("send", () => { expect((await sending)!.structuredContent).toMatchObject({status: "done", responses: [], more: 0}) }) + it("keeps actions of two sessions apart", async () => { + const chat = new FakeChat() + chat.chats.set("@4", {chatInfo: direct(contact(4, "Bob")), items: []}) + const actions = actionsWith("requests", fakeHooks()) + const first = agentTools(chat, actions, undefined, 1).tools + const second = agentTools(chat, actions, undefined, 2).tools + const calls = [signal(), signal()] + const sending = [first, second].map((tools, i) => tools.call("send", {chat: "@4", text: "hello"}, calls[i].call)) + await settled() + expect(actions.all().map(({session}) => session)).toEqual([1, 2]) + for (const tools of [first, second]) { + const {actions: listed} = (await tools.call("list", {}, signal().call))!.structuredContent as {actions: {action: number}[]} + expect(listed.length).toBe(1) + } + const {actions: listedFirst} = (await first.call("list", {}, signal().call))!.structuredContent as {actions: {action: number}[]} + expect(listedFirst.map(({action}) => action)).toEqual([actions.all()[0].n]) + calls.forEach(({controller}) => controller.abort()) + await Promise.all(sending) + }) + it("creates no action for a call aborted before it", async () => { const chat = new FakeChat() chat.chats.set("@4", {chatInfo: direct(contact(4, "Bob")), items: []}) @@ -827,7 +847,7 @@ describe("leave", () => { expect(textOf(await tools.call("leave", {chat: "@4"}, signal().call))).toBe("No chat @4. list shows the agent's chats.") }) - it("refuses the agent control chat and unknown chats, and returns a rejection", async () => { + it("refuses unknown chats, and returns a rejection", async () => { const chat = new FakeChat() chat.chats.set("@4", {chatInfo: direct(contact(4, "Bob")), items: []}) const {tools, actions} = leaveTools(chat) @@ -847,7 +867,6 @@ describe("list", () => { const chat = new FakeChat() chat.chats.set("@4", {chatInfo: direct(contact(4, "SimpleX Directory", {peerType: "bot"})), items: [item(20, "hi")]}) chat.chats.set("#2", {chatInfo: groupChat(group(2, "ep", member(1, "agent"))), items: []}) - chat.chats.set("#10", {chatInfo: groupChat(group(10, "agent")), items: []}) const {tools} = agentTools(chat, actionsWith("requests", fakeHooks()), chatInfo => (chatInfo.type === "direct" ? "Alice" : undefined)) const {chats} = (await tools.call("list", {}, signal().call))!.structuredContent as {chats: object[]} expect(chats).toEqual([ diff --git a/packages/simplex-chat-mcp/test/unit/tools.test.ts b/packages/simplex-chat-mcp/test/unit/tools.test.ts new file mode 100644 index 0000000000..52dc409531 --- /dev/null +++ b/packages/simplex-chat-mcp/test/unit/tools.test.ts @@ -0,0 +1,45 @@ +import {PassThrough} from "stream" +import {McpServer} from "@modelcontextprotocol/server" +import {StdioServerTransport} from "@modelcontextprotocol/server/stdio" +import {ToolCall} from "../../src/messages" +import {ToolName, instructions, registerTools} from "../../src/tools" +import {LineClient} from "./fixtures" + +async function connectedServer(mode: "requests" | "host", handler: (name: ToolName, input: unknown, call: ToolCall) => Promise): Promise { + const input = new PassThrough() + const output = new PassThrough() + const server = new McpServer({name: "simplex-mcp", version: "test"}, {instructions: instructions()}) + registerTools(server, mode, handler) + await server.connect(new StdioServerTransport(input, output)) + return new LineClient(input, output) +} + +describe("tool registration", () => { + it("lists the five tools, marking actions for the host in mode host", async () => { + for (const mode of ["requests", "host"] as const) { + const client = await connectedServer(mode, async () => undefined) + expect((await client.initialize()).instructions).toBe(instructions()) + const {result} = await client.request("tools/list") + expect(result.tools.map(({name}: {name: string}) => name)).toEqual(["connect", "send", "receive", "list", "leave"]) + const marked = result.tools.filter((tool: any) => tool._meta?.["anthropic/requiresUserInteraction"] === true).map(({name}: {name: string}) => name) + expect(marked).toEqual(mode === "host" ? ["connect", "send", "leave"] : []) + } + }) + + it("passes calls to the handler and returns Cancelled when it returns nothing", async () => { + const calls: [string, unknown][] = [] + const client = await connectedServer("requests", async (name, input) => { + calls.push([name, input]) + return name === "list" ? {content: [{type: "text", text: "{}"}], structuredContent: {chats: [], actions: []}} : undefined + }) + await client.initialize() + expect((await client.request("tools/call", {name: "list", arguments: {}})).result.structuredContent).toEqual({chats: [], actions: []}) + expect((await client.request("tools/call", {name: "receive", arguments: {chat: "@1"}})).result).toEqual({content: [{type: "text", text: "Cancelled."}], isError: true}) + expect(calls).toEqual([["list", {}], ["receive", {chat: "@1"}]]) + }) + + it("names no profile in the instructions", () => { + expect(instructions()).toContain("You act in SimpleX chats through these tools, as your agent profile.") + expect(instructions()).not.toMatch(/as the agent \S/) + }) +})