Files
simplex-chat/packages/simplex-chat-mcp/test/unit/cards.test.ts
T
Evgeny @ SimpleX Chat be668564df mcp server: files, /leave and library methods (#7962)
* bots, nodejs: document chat, connection, member contact and network commands, typed commands in library

* bots, nodejs: restore apiConnect, wrap domain once, test all connect responses

* nodejs: JSDoc for new methods

* docs: plan for bot API commands and typed Node library methods

* tests: channel prepared with a domain has a verified SimpleX name

* docs: apply review fixes to the plan for bot API commands

* docs: apply second review pass to the plan for bot API commands

* docs: refer the bot API plan to the rewritten test commit

* docs: record decisions in the plan for bot API commands

* docs: plan tests of the generated Python command strings

* python: test the generated strings of the documented commands

* docs: plan for files, /leave and library methods in the MCP server

* docs: wait at stop in the MCP plan

* docs: library methods and /leave mechanics in the MCP plan

* mcp: typed library methods for chat commands

* docs: plan the message information command for bots

* mcp: /leave CHAT owner command to leave a chat at once

* docs: file servers, GIF and video choices decided in the MCP plan

* docs: file mechanics in the MCP plan

* mcp: files of received messages

* bots, nodejs: message information with the file servers for bots

* docs: plan the relay approval option of receiving files in Node

* mcp: download of received files with owner approval

* docs: name the xftp-server test helper in the files plan

* nodejs: receive files without approving unknown relays

* docs: plan the user servers command for bots

* bots, nodejs: user servers and relays by operator for bots

* nodejs: document the message information method

* docs: plan the file upload progress event for bots

* mcp: send files with owner approval, refusing private keys

* bots: file upload progress event for bots

* mcp: /leave template in the agent control chat menu

* docs: sent copies are deleted for contacts too, at sndFileCompleteXFTP

* bots: drop the file upload progress event

* docs: drop the upload progress event plan

* nodejs: document user servers and relay approval of received files

* mcp: download cards name unknown file servers after the upload

* docs: record the wait for the file description in the files plan

* mcp: fixed wait at stop for cancelled cards, without /sql

* docs: record decision C4, a fixed wait at stop, in the files plan

* ci: test the MCP server package on pull requests

* ci: state when the MCP workflow can be dispatched

* ci: run the MCP server tests when its libraries change

* mcp: log the errors of image previews and resizing

* mcp: keep files in transfer in the data folder

* mcp: restrict sent files by the real paths of refused folders

* mcp: validate the file at the send by its inode and change time

* mcp: limit ffmpeg and ffprobe to local files and no input commands

* mcp: ask the owner for unknown file servers; read approvals at creation

* mcp: name downloaded files by their ID; return paths of downloads only

* mcp: end each download with a result for the agent and the card

* mcp: show files above their sender's size limit as failed

* mcp: refuse empty files, as without file and captions too long to send

* mcp: tell the owner about failed uploads and unposted received files

* mcp: delete resized copies left by the last run at start

* mcp: name downloads and leaving in the help and greeting, align docs

* mcp: compute the download path for received files only

* mcp: send a /receive file to the owner under its name

* mcp: keep files received without a download in the chat's folder

* mcp: keep a left chat's files and delete them with /delete CHAT files

* mcp: test that sent images are resized by the core

* mcp: wait 30 minutes for a download's progress before cancelling it

* mcp: end a download as deleted only when its message is not found

* mcp: delete the copy of a /receive file whose post fails

* mcp: count the quoted content in a message's length, as the core sends it

* mcp: name received bytes, unknown-server downloads and left chat files plainly

* mcp: test refused folders, file changes and download approvals

* docs: align MCP server docs with refused folders, leaves and download waits

* mcp: move received files only in full, delete partials only of downloads

* mcp: report a received file missing from its folder as failed

* mcp: move received files of a group's member chats into its folder

* mcp: read a video's preview and duration as an MP4 or QuickTime file

* mcp: 0.2.0 on simplex-chat 7.1.0-beta.10.1

* mcp: file folders by chat type, /delete cancels downloads, files reply

* docs: MCP server specs and plan for 0.2.0

* mcp: /receive gives the agent a file, /forward sends it to the owner

* docs: MCP server specs for /receive and /forward of download cards

* docs: MCP plan records the temp folder command as a library exception

* mcp: /delete cancels resumed downloads; /forward quote fits the limit

* docs: MCP specs and plans for /forward quotes and resumed downloads

* docs: MCP specs name the source commit they are checked against

* docs: MCP PLAN states the /forward quote and move rules

* mcp: a download moves its completed file whatever its size

* mcp: /receive N all covers its card and later files, not pending ones

* docs: MCP files approval scope, reply text and README server wording

* mcp: a download approves relays only for a card naming unknown servers

* mcp: files in member chats of a group are tracked as in the main chat

* docs: MCP relay approval of downloads and files in member chats

* mcp: a rejected file gets a card again under the files approval

* docs: MCP rejected files under the files approval, README scope

* mcp: an offered file without a description gets no card or action

* docs: MCP a file without a description is not downloaded

* mcp: 0.2.0 on simplex-chat 7.1.0-beta.10.2

* ci: test the MCP server as a scope of build-linux
2026-11-06 18:38:34 +00:00

286 lines
18 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as qrcode from "qrcode"
import {Action, Mode, NewAction, State} from "../../src/actions"
import {cardText, qrCodeText} from "../../src/control"
import {Changes, Collected} from "../../src/messages"
const expires = Date.UTC(2026, 9, 18, 9, 2)
function action(fields: NewAction, state: State, extra: Partial<Action> = {}): Action {
return {...fields, n: 1, key: "k", created: expires - 3_600_000, expires, changes: new Changes(), standing: false, state, open: 0, interrupted: false, ...extra} as Action
}
const directory = {kind: "bot", name: "SimpleX Directory", simplexName: "@directory.simplex", incognito: false}
function connect(extra: {incognito?: boolean; message?: string; description?: string; welcome?: string} = {}): NewAction {
return {
tool: "connect",
input: {target: "@directory.simplex", ...(extra.incognito ? {incognito: true} : {}), ...(extra.message ? {message: extra.message} : {})},
subject: {...directory, incognito: extra.incognito ?? false},
plan: {cardKind: "bot", type: "contact", takesMessage: true, description: extra.description, welcome: extra.welcome},
messages: new Collected(),
}
}
function send(extra: {incognito?: boolean; text?: string; replyTo?: {from: string; text: string}; mentions?: {name: string; member: number}[]} = {}): NewAction {
return {
tool: "send",
input: {chat: "@4", text: extra.text ?? "trail running shoes"},
subject: extra.incognito ? {kind: "business", name: "Ridgeline", incognito: true} : directory,
chat: "@4",
replyTo: extra.replyTo,
mentions: extra.mentions ?? [],
responses: new Collected(),
}
}
const mode: Mode = "requests" as Mode
describe("connect cards", () => {
it("asks as myself with the link texts", () => {
expect(cardText(action(connect({description: "Find groups, channels, businesses and bots."}), "pending"), mode)).toBe(
[
"I want to connect to bot SimpleX Directory (@directory.simplex), as myself.",
"`Find groups, channels, businesses and bots.`",
"/'approve 1' or /'reject 1'",
].join("\n"),
)
})
it("asks incognito with the message", () => {
expect(cardText(action(connect({incognito: true, message: "Do you have\ntrail shoes?", welcome: "Welcome!"}), "pending"), mode)).toBe(
[
"I want to connect to bot SimpleX Directory (@directory.simplex), incognito.",
"`Welcome!`",
"With the message:",
"`Do you have`",
"`trail shoes?`",
"/'approve 1' or /'reject 1'",
].join("\n"),
)
})
it("shows every outcome", () => {
const fields = connect({description: "Search."})
const request = ["I want to connect to bot SimpleX Directory (@directory.simplex), as myself.", "`Search.`"]
const lines = (a: Action, m: Mode = mode): string[] => cardText(a, m).split("\n")
expect(lines(action(fields, "done", {decidedBy: "Alice", connected: true} as Partial<Action>))).toEqual(["I connected to bot SimpleX Directory (@directory.simplex), as myself.", "`Search.`", "Approved by Alice."])
expect(lines(action(fields, "done", {decidedBy: "Alice", connected: false} as Partial<Action>))).toEqual(["I asked to connect to bot SimpleX Directory (@directory.simplex), as myself.", "`Search.`", "Approved by Alice."])
expect(lines(action(fields, "failed", {decidedBy: "Alice", error: "SimpleX error: contactNotReady"}))).toEqual([...request, "Approved by Alice; failed: `SimpleX error: contactNotReady`."])
expect(lines(action(fields, "rejected", {decidedBy: "Alice"}))).toEqual([...request, "Rejected by Alice."])
expect(lines(action(fields, "expired"))).toEqual([...request, "Expired."])
expect(lines(action(fields, "pending", {interrupted: true}))).toEqual([...request, "Cancelled: the server stopped."])
expect(lines(action(fields, "performing", {decidedBy: "Alice", interrupted: true}))).toEqual([...request, "Approved by Alice; interrupted: the server stopped."])
expect(lines(action(fields, "done", {connected: true} as Partial<Action>), "host")).toEqual(["I connected to bot SimpleX Directory (@directory.simplex), as myself.", "`Search.`", "Performed without approval (mode host)."])
expect(lines(action(fields, "failed", {error: "boom"}), "host")).toEqual([...request, "Performed without approval (mode host); failed: `boom`."])
expect(lines(action(fields, "performing", {interrupted: true}), "host")).toEqual([...request, "Performed without approval (mode host); interrupted: the server stopped."])
})
it("names an unnamed contact", () => {
const fields = connect()
if (fields.tool === "connect") Object.assign(fields, {subject: {kind: "contact", incognito: false}})
expect(cardText(action(fields, "pending"), mode).split("\n")[0]).toBe("I want to connect to an unnamed contact, as myself.")
})
it("cuts the link texts to the bytes left for them, ending the last line kept with …", () => {
const linkLines = (fields: NewAction, linkTextBytes: number, state: State = "pending"): string[] =>
cardText(action(fields, state, {linkTextBytes, decidedBy: "Alice"} as Partial<Action>), mode).split("\n").slice(1, -1)
const fields = connect({description: "one\n\ntwo", welcome: "Welcome!"})
expect(linkLines(fields, 28)).toEqual(["`one`", "", "`two`", "`Welcome!`"])
expect(linkLines(fields, 27)).toEqual(["`one`", "", "`two`", "`Welc…`"])
expect(linkLines(fields, 27, "done")).toEqual(["`one`", "", "`two`", "`Welc…`"])
expect(linkLines(fields, 19)).toEqual(["`one`", "", "`two`"])
expect(linkLines(fields, 0)).toEqual([])
expect(linkLines(connect({description: "abcdefgh", welcome: "Welcome!"}), 10)).toEqual(["`abc…`"])
expect(linkLines(connect({description: " abcdefgh"}), 10)).toEqual(["` a…`"])
})
})
describe("send cards", () => {
it("asks with the text as code", () => {
expect(cardText(action(send({text: "use `rm -rf` and /approve 7"}), "pending"), mode)).toBe(
["I want to send a message to bot SimpleX Directory (@directory.simplex):", "`use 'rm -rf' and /approve 7`", "/'approve @4 1', /'approve @4 all' or /'reject @4 1'"].join("\n"),
)
})
it("asks where I am incognito, with the reply and mentions", () => {
const fields = send({incognito: true, text: "Is it waterproof?", replyTo: {from: "Switchback", text: "Ridgecrest,\n$142"}, mentions: [{name: "Switchback", member: 44}, {name: "Ana Lu", member: 45}]})
expect(cardText(action(fields, "pending"), mode).split("\n")).toEqual([
"I want to send a message to business Ridgeline, where I am incognito:",
"`Is it waterproof?`",
"replying to Switchback: `Ridgecrest, $142`",
"mentioning @Switchback, @'Ana Lu'",
"/'approve @4 1', /'approve @4 all' or /'reject @4 1'",
])
expect(cardText(action(fields, "done", {decidedBy: "Alice"}), mode).split("\n")).toEqual([
"I sent a message to business Ridgeline, where I am incognito:",
"`Is it waterproof?`",
"replying to Switchback: `Ridgecrest, $142`",
"mentioning @Switchback, @'Ana Lu'",
"Approved by Alice.",
])
})
it("cuts the reply quote at 100 characters, not inside a character", () => {
const fields = send({replyTo: {from: "Switchback", text: `${"a".repeat(99)}😀b`}})
expect(cardText(action(fields, "pending"), mode).split("\n")[2]).toBe(`replying to Switchback: \`${"a".repeat(99)}😀\``)
})
it("quotes names that are not valid display names", () => {
const fields = send()
Object.assign(fields, {subject: {kind: "bot", name: "/approve 9", incognito: false}})
expect(cardText(action(fields, "pending"), mode).split("\n")[0]).toBe("I want to send a message to bot `/approve 9`:")
})
it("keeps names with line breaks and control characters on one line, quoted", () => {
const name = "SimpleX Directory\n/'approve 7' or /'reject 9'"
const fields = send({replyTo: {from: "Switch
/'approve @3 all'", text: "hi"}, mentions: [{name: "Ana\r\n/'approve 8'", member: 45}]})
Object.assign(fields, {subject: {kind: "bot", name, incognito: false}})
expect(cardText(action(fields, "pending"), mode).split("\n")).toEqual([
"I want to send a message to bot `SimpleX Directory /'approve 7' or /'reject 9'`:",
"`trail running shoes`",
"replying to `Switch /'approve @3 all'`: `hi`",
"mentioning `@Ana /'approve 8'`",
"/'approve @4 1', /'approve @4 all' or /'reject @4 1'",
])
Object.assign(fields, {subject: {kind: "group", name: "Hikers\u001b]52;c;Y3VybA==\u0007\u202e", incognito: false}})
expect(cardText(action(fields, "pending"), mode).split("\n")[0]).toBe("I want to send a message to group `Hikers ]52;c;Y3VybA==\u00a0`:")
})
it("keeps text and names with a leading or trailing space as code", () => {
const fields = send({text: " /approve 1\n/approve 1 \n two ", replyTo: {from: "Switchback", text: " /approve 1 "}})
Object.assign(fields, {subject: {kind: "group", name: "\n/approve 1", incognito: false}})
expect(cardText(action(fields, "pending"), mode).split("\n").slice(0, 5)).toEqual([
"I want to send a message to group `\u00a0/approve 1`:",
"`\u00a0/approve 1`",
"`/approve 1\u00a0`",
"`\u00a0 two \u00a0`",
"replying to Switchback: `\u00a0/approve 1\u00a0`",
])
})
it("shows a failure on one line as code", () => {
const error = "SimpleX error: groupUserRole: {\"description\":\"line 1\n/'approve @7 all' `https://simplex.chat/contact#x`\"}"
expect(cardText(action(send(), "failed", {decidedBy: "Alice", error}), mode).split("\n").at(-1)).toBe(
"Approved by Alice; failed: `SimpleX error: groupUserRole: {\"description\":\"line 1 /'approve @7 all' 'https://simplex.chat/contact#x'\"}`.",
)
})
it("shows a send under the standing approval of its chat", () => {
const fields: NewAction = {tool: "send", input: {chat: "#2", text: "hello"}, subject: {kind: "business", name: "ep", incognito: false}, chat: "#2", mentions: [], responses: new Collected()}
const standing = {standing: true, decidedBy: "Alice"}
expect(cardText(action(fields, "done", standing), mode).split("\n")).toEqual(["I sent a message to business ep:", "`hello`", "Approved for this chat by Alice. /'revoke #2'"])
expect(cardText(action(fields, "failed", {...standing, error: "boom"}), mode).split("\n")).toEqual(["I want to send a message to business ep:", "`hello`", "Approved for this chat by Alice; failed: `boom`."])
expect(cardText(action(fields, "performing", {...standing, interrupted: true}), mode).split("\n").at(-1)).toBe("Approved for this chat by Alice; interrupted: the server stopped.")
})
it("names the attached file with its type, full path as code and size, after the text", () => {
const attached = (text: string | undefined, file: object): NewAction => ({...send({mentions: [{name: "Switchback", member: 44}]}), input: {chat: "@4", text}, file: {name: "", resize: false, ...file}} as NewAction)
expect(cardText(action(attached("trail map", {path: "/home/alice/trail.jpg", size: 3_100_000, type: "image"}), "pending"), mode).split("\n")).toEqual([
"I want to send a message to bot SimpleX Directory (@directory.simplex):",
"`trail map`",
"attaching image `/home/alice/trail.jpg`, 3.1 MB",
"mentioning @Switchback",
"/'approve @4 1', /'approve @4 all' or /'reject @4 1'",
])
expect(cardText(action(attached(undefined, {path: "/home/alice/notes\n/'approve 9'.txt", size: 120, type: "file"}), "done", {decidedBy: "Alice"}), mode).split("\n")).toEqual([
"I sent a message to bot SimpleX Directory (@directory.simplex):",
"attaching file `/home/alice/notes /'approve 9'.txt`, 120 bytes",
"mentioning @Switchback",
"Approved by Alice.",
])
})
})
describe("leave cards", () => {
function leave(chat: string, subject: {kind: string; name?: string; simplexName?: string}): NewAction {
return {tool: "leave", input: {chat}, subject: {...subject, incognito: false}, chat}
}
it("asks to delete a contact with its kind and to leave a group", () => {
expect(cardText(action(leave("@3", directory), "pending"), mode).split("\n")).toEqual(["I want to delete bot SimpleX Directory (@3).", "/'approve @3 1' or /'reject @3 1'"])
expect(cardText(action(leave("@5", {kind: "contact", name: "Ana Lu"}), "pending"), mode).split("\n")[0]).toBe("I want to delete contact Ana Lu (@5).")
expect(cardText(action(leave("#2", {kind: "business", name: "ep"}), "pending"), mode).split("\n")).toEqual(["I want to leave business ep (#2).", "/'approve #2 1' or /'reject #2 1'"])
expect(cardText(action(leave("#7", {kind: "group", name: "/approve 9"}), "pending"), mode).split("\n")[0]).toBe("I want to leave group `/approve 9` (#7).")
})
it("shows every outcome", () => {
const contact = leave("@3", directory)
const group = leave("#2", {kind: "business", name: "ep"})
const lines = (a: Action, m: Mode = mode): string[] => cardText(a, m).split("\n")
expect(lines(action(contact, "done", {decidedBy: "Alice"}))).toEqual(["I deleted bot SimpleX Directory (@3).", "Approved by Alice."])
expect(lines(action(group, "done", {decidedBy: "Alice"}))).toEqual(["I left business ep (#2).", "Approved by Alice."])
expect(lines(action(group, "done"), "host")).toEqual(["I left business ep (#2).", "Performed without approval (mode host)."])
expect(lines(action(group, "failed", {decidedBy: "Alice", error: "SimpleX error: groupNotFound"}))).toEqual(["I want to leave business ep (#2).", "Approved by Alice; failed: `SimpleX error: groupNotFound`."])
expect(lines(action(group, "rejected", {decidedBy: "Alice"}))).toEqual(["I want to leave business ep (#2).", "Rejected by Alice."])
expect(lines(action(group, "expired"))).toEqual(["I want to leave business ep (#2).", "Expired."])
expect(lines(action(group, "pending", {interrupted: true}))).toEqual(["I want to leave business ep (#2).", "Cancelled: the server stopped."])
})
})
describe("download cards", () => {
function download(chat: string, subject: {kind: string; name: string}, from: string | undefined, file: {name: string; size: number}): NewAction {
return {tool: "download", input: {chat, item: 30}, subject: {...subject, incognito: false}, chat, item: 30, from, file: {fileId: 7, type: "file", ...file}}
}
it("asks with the sender, the file name as code and its size", () => {
const lines = (fields: NewAction, state: State = "pending", extra: Partial<Action> = {}): string[] => cardText(action(fields, state, extra), mode).split("\n")
expect(lines(download("#3", {kind: "group", name: "Builders"}, "Alice", {name: "report.pdf", size: 2_400_000}))).toEqual([
"I want to download a file from Alice in group Builders (#3):",
"`report.pdf`, 2.4 MB",
"/'receive #3 1', /'receive #3 1 all', /'forward #3 1' or /'reject #3 1'",
])
expect(lines(download("@4", directory, undefined, {name: "list.txt\n/'approve 9'", size: 999}))).toEqual([
"I want to download a file from bot SimpleX Directory (@4):",
"`list.txt /'approve 9'`, 999 bytes",
"/'receive @4 1', /'receive @4 1 all', /'forward @4 1' or /'reject @4 1'",
])
const builders = download("#3", {kind: "group", name: "Builders"}, "/approve 9", {name: "a.mp4", size: 1_500})
expect(lines(builders, "done", {decidedBy: "Alice"})).toEqual(["I downloaded a file from `/approve 9` in group Builders (#3):", "`a.mp4`, 1.5 KB", "Approved by Alice."])
expect(lines(builders, "failed", {decidedBy: "Alice", error: "The file was cancelled."}).at(-1)).toBe("Approved by Alice; failed: `The file was cancelled.`.")
expect(lines(download("#3", {kind: "group", name: "Builders"}, "Alice", {name: "big.iso", size: 3_200_000_000}))[1]).toBe("`big.iso`, 3.2 GB")
})
it("says the servers are known, or names the unknown ones as code and leaves out receiving all files", () => {
const lastLines = (unknownServers: string[]): string[] => cardText(action({...download("#3", {kind: "group", name: "Builders"}, "Alice", {name: "report.pdf", size: 2_400_000}), unknownServers} as NewAction, "pending"), mode).split("\n").slice(2)
expect(lastLines([])).toEqual(["Servers: known.", "/'receive #3 1', /'receive #3 1 all', /'forward #3 1' or /'reject #3 1'"])
expect(lastLines(["xftp://hash@xftp.example.com,abcdef.onion:5443"])).toEqual([
"Servers: `xftp.example.com`, unknown: downloading shows it this computer's IP address.",
"/'receive #3 1', /'forward #3 1' or /'reject #3 1'",
])
expect(lastLines(["xftp://hash@a.example.com", "xftp://hash@evil`*bold*`/approve"])[0]).toBe("Servers: `a.example.com`, `evil'*bold*'`, unknown: downloading shows them this computer's IP address.")
})
})
describe("QR codes", () => {
const link = "https://smp4.simplex.im/g#1ai9d8WS140_IxvPDYWLigJNZU-z7GsDUhp0JoJ7kbU"
const cells = (line: string): string[] => [...line.matchAll(/\x1b\[[0-9;]+m[^\x1b]+/g)].map(([cell]) => cell)
const visible = (line: string): string => line.replace(/\x1b\[[0-9;]*m/g, "")
it("draws each module as two cells in large", () => {
const lines = qrCodeText(link, "large").split("\n")
expect(lines).toHaveLength(37)
for (const line of lines) {
expect(visible(line)).toHaveLength(74)
expect(line.endsWith("\x1b[0m")).toBe(true)
}
for (const line of [...lines.slice(0, 2), ...lines.slice(-2)]) expect(cells(line)).toEqual(Array(37).fill("\x1b[47m "))
expect(cells(lines[2]).slice(0, 10)).toEqual([...Array(2).fill("\x1b[47m "), ...Array(7).fill("\x1b[40m "), "\x1b[47m "])
})
it("draws two module rows per line with lower half blocks in small", () => {
const lines = qrCodeText(link, "small").split("\n")
expect(lines).toHaveLength(19)
for (const line of lines) {
expect(visible(line)).toHaveLength(37)
expect(line.endsWith("\x1b[0m")).toBe(true)
expect([...cells(line).slice(0, 2), ...cells(line).slice(-2)]).toEqual(Array(4).fill("\x1b[47m "))
}
for (const line of [lines[0], lines.at(-1)!]) expect(cells(line)).toEqual(Array(37).fill("\x1b[47m "))
expect(cells(lines[1]).slice(2, 4)).toEqual(["\x1b[40m ", "\x1b[37;40m▄"])
const {modules} = qrcode.create(link, {errorCorrectionLevel: "L"})
const row = Array.from({length: 16}, (_, i) => 2 * i).find(r => Array.from({length: modules.size}, (_, c) => c).some(c => !modules.get(r, c) && modules.get(r + 1, c)))!
const col = Array.from({length: modules.size}, (_, c) => c).find(c => !modules.get(row, c) && modules.get(row + 1, c))!
expect(cells(lines[row / 2 + 1])[col + 2]).toBe("\x1b[30;47m▄")
})
})