mcp: serve tools for one session profile

This commit is contained in:
shum committed 2026-10-24 13:37:32 +00:00
1 parent 6479a1859e
commit a9d5ba3be1
5 files changed
+145 -40

No files matched your search

+26 -29
View File
@@ -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<CallToolResult | undefined>
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<CallToolResult | undefined> {
try {
@@ -411,7 +412,7 @@ export class AgentTools {
}
private async connect(input: ConnectInput, call: ToolCall): Promise<object | undefined> {
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<object | undefined> {
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<object | undefined> {
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<T.AChat[]> {
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<T.AChat> {
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<O extends {[key: string]: unknown}>(fields: O): Partial<O> {
@@ -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<AgentTools, "knownChat" | "agentChats"> = {
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}
}
@@ -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<number, (message: any) => 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<any> {
const id = this.nextId++
const reply = new Promise<any>(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<any> {
const reply = await this.request("initialize", {protocolVersion: "2025-11-25", capabilities: {}, clientInfo: {name: "test", version: "1"}})
this.notify("notifications/initialized")
return reply.result
}
}
@@ -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([
@@ -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<any>): Promise<LineClient> {
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/)
})
})