mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-12 02:57:56 +00:00
mcp: serve tools for one session profile
This commit is contained in:
1 parent
6479a1859e
commit
a9d5ba3be1
5 files changed
+145
-40
No files matched your search
@@ -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/)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user