Files
simplex-chat/packages/simplex-chat-mcp/test/unit/options.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

175 lines
8.9 KiB
TypeScript

import {helpText, parseCommand, validAgentName} from "../../src/cli"
import {entryText} from "../../src/control"
import {ExitError} from "../../src/log"
import {validName} from "../../src/messages"
import {ServeOptions, registerHint} from "../../src/serve"
const home = "/home/alice"
function usage(args: string[]): string {
try {
parseCommand(args, home)
} catch (error) {
expect(error).toBeInstanceOf(ExitError)
expect((error as ExitError).code).toBe(2)
return (error as ExitError).message
}
throw new Error("arguments accepted")
}
describe("serve options", () => {
it("has defaults", () => {
expect(parseCommand(["serve", "agent"], home)).toEqual({
command: "serve",
options: {
name: "agent",
data: "/home/alice/.simplex-mcp/agent",
dataGiven: false,
approve: "requests",
expireHours: 23,
resetOwners: false,
newToken: false,
port: 5226,
qrCode: "small",
stopWait: 10,
},
})
})
it("reads every option", () => {
const {options} = parseCommand(
["serve", "agent-2", "--data", "/tmp/a", "--display-name", "Agent Two", "--approve", "host", "--expire", "1", "--stop-wait", "0", "--port", "5227", "--qr-code", "large", "--new-token", "--reset-owners", "--owners"],
home,
) as {options: object}
expect(options).toEqual({
name: "agent-2",
data: "/tmp/a",
dataGiven: true,
displayName: "Agent Two",
approve: "host",
expireHours: 1,
stopWait: 0,
port: 5227,
qrCode: "large",
newToken: true,
resetOwners: true,
owners: [],
})
})
it("takes the QR code size", () => {
expect(parseCommand(["serve", "agent", "--qr-code", "small"], home)).toMatchObject({options: {qrCode: "small"}})
expect(parseCommand(["serve", "agent", "--qr-code", "large"], home)).toMatchObject({options: {qrCode: "large"}})
expect(usage(["serve", "agent", "--qr-code", "huge"])).toBe("--qr-code: small or large expected, got huge")
expect(usage(["serve", "agent", "--qr-code"])).toBe("--qr-code: a value expected.")
})
it("takes --owners with and without a list", () => {
expect(parseCommand(["serve", "agent", "--owners", "--port", "5300"], home)).toMatchObject({options: {owners: [], port: 5300}})
expect(parseCommand(["serve", "agent", "--owners", "3:Alice Smith,7:Bob"], home)).toMatchObject({
options: {owners: [{memberId: 3, name: "Alice Smith"}, {memberId: 7, name: "Bob"}]},
})
})
it("splits --owners only at commas before an entry ID, so a name may have commas", () => {
expect(parseCommand(["serve", "agent", "--owners", "3:Smith, Alice,7:Bob,,Jr,12:Lu,8"], home)).toMatchObject({
options: {owners: [{memberId: 3, name: "Smith, Alice"}, {memberId: 7, name: "Bob,,Jr"}, {memberId: 12, name: "Lu,8"}]},
})
})
it("checks ranges", () => {
expect(usage(["serve", "agent", "--expire", "24"])).toBe("--expire: hours from 1 to 23 expected, got 24")
expect(usage(["serve", "agent", "--expire", "0"])).toBe("--expire: hours from 1 to 23 expected, got 0")
expect(parseCommand(["serve", "agent", "--stop-wait", "60"], home)).toMatchObject({options: {stopWait: 60}})
expect(usage(["serve", "agent", "--stop-wait", "61"])).toBe("--stop-wait: seconds from 0 to 60 expected, got 61")
expect(usage(["serve", "agent", "--stop-wait", "-1"])).toBe("--stop-wait: seconds from 0 to 60 expected, got -1")
expect(usage(["serve", "agent", "--stop-wait", "2.5"])).toBe("--stop-wait: seconds from 0 to 60 expected, got 2.5")
expect(usage(["serve", "agent", "--stop-wait"])).toBe("--stop-wait: a value expected.")
expect(usage(["serve", "agent", "--port", "65536"])).toBe("--port: a port from 1 to 65535 expected, got 65536")
expect(usage(["serve", "agent", "--port", "x"])).toBe("--port: a port from 1 to 65535 expected, got x")
expect(usage(["serve", "agent", "--approve", "all"])).toBe("--approve: requests or host expected, got all")
expect(usage(["serve", "agent", "--port"])).toBe("--port: a value expected.")
})
it("rejects arguments", () => {
expect(usage(["serve", "agent", "--reset-owners", "--owners", "3:Alice"])).toBe("--reset-owners takes --owners without a list: member IDs change in a new agent control chat.")
expect(usage(["serve", "agent", "--owners", "Alice"])).toBe("--owners: ID:NAME expected, got Alice")
expect(usage(["serve", "Agent"])).toBe("NAME: 1 to 32 lowercase letters, digits and single hyphens, starting with a letter or digit.")
expect(usage(["serve"])).toBe("NAME: 1 to 32 lowercase letters, digits and single hyphens, starting with a letter or digit.")
expect(usage(["serve", "agent", "--colour"])).toBe("Unknown option --colour. Run simplex-mcp --help.")
expect(usage(["start", "agent"])).toMatch(/^Usage: /)
})
it("checks the display name", () => {
expect(usage(["serve", "agent", "--display-name", "agent.bot"])).toBe("--display-name: not a valid SimpleX name; try agentbot")
expect(usage(["serve", "agent", "--display-name", " agent"])).toBe("--display-name: not a valid SimpleX name; try agent")
expect(parseCommand(["serve", "agent", "--display-name", "Ridgeline Scout"], home)).toMatchObject({options: {displayName: "Ridgeline Scout"}})
})
})
describe("NAME", () => {
it("accepts lowercase letters, digits and single hyphens", () => {
for (const name of ["agent", "a", "agent-2", "2agent", "a-b-c", "a-", "x".repeat(32)]) expect(validAgentName(name)).toBe(true)
for (const name of ["", "-a", "a--b", "A", "a_b", "a.b", "x".repeat(33)]) expect(validAgentName(name)).toBe(false)
})
})
describe("display names", () => {
it("follows mkValidName", () => {
expect(validName("Alice")).toBe("Alice")
expect(validName("Alice Smith")).toBe("Alice Smith")
expect(validName("Alice Smith ")).toBe("Alice Smith")
expect(validName("@alice")).toBe("alice")
expect(validName("alice_12")).toBe("alice-12")
expect(validName("a-b")).toBe("a-b")
expect(validName("x".repeat(60))).toBe("x".repeat(50))
expect(validName("$")).toBe("")
})
})
describe("register options", () => {
it("has defaults and reads options", () => {
expect(parseCommand(["register", "agent"], home)).toEqual({command: "register", options: {name: "agent", data: "/home/alice/.simplex-mcp/agent", scope: "user"}})
expect(parseCommand(["register", "agent", "--scope", "local", "--data", "/tmp/a"], home)).toEqual({command: "register", options: {name: "agent", data: "/tmp/a", scope: "local"}})
expect(usage(["register", "agent", "--scope", "project"])).toBe("--scope: user or local expected, got project")
})
})
describe("printed commands", () => {
const platform = Object.getOwnPropertyDescriptor(process, "platform")!
const onPlatform = (value: NodeJS.Platform): void => {
Object.defineProperty(process, "platform", {...platform, value})
}
const hint = (data?: string): string => registerHint({...(parseCommand(["serve", "agent"], home) as {options: ServeOptions}).options, ...(data === undefined ? {} : {data, dataGiven: true})})
afterEach(() => Object.defineProperty(process, "platform", platform))
it("quote owner entries and the data folder for a POSIX shell", () => {
onPlatform("linux")
expect(entryText({memberId: 3, name: "Alice"})).toBe("3:Alice")
expect(entryText({memberId: 3, name: "Ana-Lu_2"})).toBe("3:Ana-Lu_2")
expect(entryText({memberId: 3, name: "Alice Smith"})).toBe("'3:Alice Smith'")
expect(entryText({memberId: 3, name: "O'Neil"})).toBe("'3:O'\\''Neil'")
expect(hint()).toBe("Add the agent to Claude Code:\n npx -y @simplex-chat/mcp register agent")
expect(hint("/tmp/a-1/b_2.c")).toBe("Add the agent to Claude Code:\n npx -y @simplex-chat/mcp register agent --data /tmp/a-1/b_2.c")
expect(hint("/tmp/my agent's")).toBe("Add the agent to Claude Code:\n npx -y @simplex-chat/mcp register agent --data '/tmp/my agent'\\''s'")
})
it("quote with double quotes on Windows", () => {
onPlatform("win32")
expect(entryText({memberId: 3, name: "Ana-Lu_2"})).toBe("3:Ana-Lu_2")
expect(entryText({memberId: 3, name: "Alice Smith"})).toBe('"3:Alice Smith"')
expect(hint("C:\\Users\\alice\\agent")).toBe("Add the agent to Claude Code:\n npx -y @simplex-chat/mcp register agent --data C:\\Users\\alice\\agent")
expect(hint("C:\\Users\\Ana Lu\\agent")).toBe('Add the agent to Claude Code:\n npx -y @simplex-chat/mcp register agent --data "C:\\Users\\Ana Lu\\agent"')
})
})
describe("help", () => {
it("matches the manual", () => {
expect(parseCommand(["--help"], home)).toEqual({command: "help"})
expect(helpText).toContain(" --owners [ID:NAME,...] only these owners approve; without a list, the link\n")
expect(helpText).toContain(" --stop-wait SECONDS at stop, wait SECONDS for cancelled cards to be sent,\n 0 to 60 (default: 10)\n")
expect(helpText.endsWith("--reset-owners takes --owners without a list.\n")).toBe(true)
})
})