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

190 lines
9.3 KiB
TypeScript

import {Server, request as httpRequest} from "http"
import {AddressInfo, connect} from "net"
import {McpServer} from "@modelcontextprotocol/server"
import {Actions, Mode} from "../../src/actions"
import {startHttp, stopHttp} from "../../src/http"
import {ChatItemsWatch} from "../../src/messages"
import {AgentTools} from "../../src/tools"
import {FakeChat, actionsWith, contact, data, direct, fakeHooks, user} from "./fixtures"
const token = "secret-token"
let server: Server
let url: string
let actions: Actions
let ready: boolean
async function start(mode: Mode = "requests"): Promise<void> {
ready = true
const chat = new FakeChat()
chat.chats.set("@4", {chatInfo: direct(contact(4, "SimpleX Directory")), items: []})
actions = actionsWith(mode, fakeHooks())
const tools = new AgentTools({chat: chat.api(), user, actions, watch: new ChatItemsWatch(), controlChat: () => "#10", standingApprover: () => undefined, refusedFile: () => undefined, data}, mode)
const factory = (): McpServer => {
const mcp = new McpServer({name: "simplex-mcp", version: "0.1.0"}, {instructions: "Use the tools."})
tools.register(mcp)
return mcp
}
server = await startHttp({port: 0, token, factory, ready: () => ready})
url = `http://127.0.0.1:${(server.address() as AddressInfo).port}`
}
beforeEach(() => {
jest.spyOn(process.stdout, "write").mockImplementation(() => true)
})
afterEach(async () => {
jest.restoreAllMocks()
await stopHttp(server)
})
const auth = {authorization: `Bearer ${token}`, "content-type": "application/json", accept: "application/json, text/event-stream"}
function post(body: object, headers: {[name: string]: string} = auth, path = "/mcp", signal?: AbortSignal): Promise<Response> {
return fetch(`${url}${path}`, {method: "POST", headers, body: JSON.stringify(body), signal})
}
function modern(method: string, params: object = {}, id = 1): object {
return {jsonrpc: "2.0", id, method, params: {...params, _meta: {"io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": {}, ...(params as {_meta?: object})._meta}}}
}
function modernHeaders(method: string, name?: string): {[name: string]: string} {
return {...auth, "mcp-protocol-version": "2026-07-28", "mcp-method": method, ...(name === undefined ? {} : {"mcp-name": name})}
}
function statusWithHost(host: string): Promise<number> {
return new Promise((resolve, reject) => {
const req = httpRequest(`${url}/mcp`, {method: "POST", headers: {...modernHeaders("tools/list"), host}}, res => {
res.resume()
resolve(res.statusCode ?? 0)
})
req.on("error", reject)
req.end(JSON.stringify(modern("tools/list")))
})
}
function rawStatusLine(target: string): Promise<string> {
return new Promise((resolve, reject) => {
const {port} = server.address() as AddressInfo
const socket = connect(port, "127.0.0.1", () => {
socket.write(`POST ${target} HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\nAuthorization: Bearer ${token}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n`)
})
let response = ""
socket.on("data", chunk => (response += chunk))
socket.on("end", () => resolve(response.split("\r\n")[0]))
socket.on("error", reject)
})
}
async function body(response: Response): Promise<any> {
const text = await response.text()
if (!response.headers.get("content-type")?.startsWith("text/event-stream")) return JSON.parse(text)
const data = text.split("\n").filter(line => line.startsWith("data: ")).map(line => JSON.parse(line.slice(6)))
return data.find(message => "result" in message || "error" in message)
}
describe("HTTP", () => {
it("refuses requests without the token", async () => {
await start()
expect((await post(modern("tools/list"), {...modernHeaders("tools/list"), authorization: ""})).status).toBe(401)
expect((await post(modern("tools/list"), {...modernHeaders("tools/list"), authorization: "Bearer other"})).status).toBe(401)
})
it("refuses another Origin and another Host", async () => {
await start()
expect((await post(modern("tools/list"), {...modernHeaders("tools/list"), origin: "https://example.com"})).status).toBe(403)
expect((await post(modern("tools/list"), {...modernHeaders("tools/list"), origin: "http://localhost:3000"})).status).toBe(200)
expect(await statusWithHost("example.com")).toBe(403)
})
it("serves only /mcp", async () => {
await start()
expect((await post(modern("tools/list"), modernHeaders("tools/list"), "/other")).status).toBe(404)
expect((await fetch(`${url}/mcp`, {headers: auth})).status).toBe(405)
})
it("answers a request target that is not a URL with 404, and keeps serving", async () => {
await start()
expect(await rawStatusLine("http://[")).toBe("HTTP/1.1 404 Not Found")
expect((await post(modern("tools/list"), modernHeaders("tools/list"))).status).toBe(200)
})
it("answers /mcp with 503 and Retry-After until the server is ready, after the token check", async () => {
await start()
ready = false
const starting = await post(modern("tools/list"), modernHeaders("tools/list"))
expect(starting.status).toBe(503)
expect(starting.headers.get("retry-after")).toBe("1")
expect((await post(modern("tools/list"), {...modernHeaders("tools/list"), authorization: ""})).status).toBe(401)
ready = true
expect((await post(modern("tools/list"), modernHeaders("tools/list"))).status).toBe(200)
})
})
describe("MCP", () => {
it("initializes a legacy client with the instructions", async () => {
await start()
const response = await post({jsonrpc: "2.0", id: 1, method: "initialize", params: {protocolVersion: "2025-06-18", capabilities: {}, clientInfo: {name: "c", version: "1"}}})
expect((await body(response)).result).toMatchObject({protocolVersion: "2025-06-18", serverInfo: {name: "simplex-mcp", version: "0.1.0"}, instructions: "Use the tools."})
})
it("lists the six tools with their schemas and caching fields", async () => {
await start()
const {result} = await body(await post(modern("tools/list"), modernHeaders("tools/list")))
expect(result).toMatchObject({ttlMs: 0, cacheScope: "private"})
expect(result.tools.map(({name}: {name: string}) => name)).toEqual(["connect", "send", "receive", "download", "list", "leave"])
const send = result.tools.find(({name}: {name: string}) => name === "send")
expect(send.inputSchema.properties.idle).toEqual({type: "number", minimum: 1, maximum: 300, description: "Seconds without a new response that end the wait once a response arrived. Default 30."})
expect(send.outputSchema.required).toEqual(["action", "status"])
expect(send._meta).toBeUndefined()
})
it("asks the host for each action in mode host", async () => {
await start("host")
const {result} = await body(await post(modern("tools/list"), modernHeaders("tools/list")))
expect(Object.fromEntries(result.tools.map(({name, _meta}: {name: string; _meta?: object}) => [name, _meta]))).toEqual({
connect: {"anthropic/requiresUserInteraction": true},
send: {"anthropic/requiresUserInteraction": true},
receive: undefined,
download: {"anthropic/requiresUserInteraction": true},
list: undefined,
leave: {"anthropic/requiresUserInteraction": true},
})
})
it("rejects input that fails the schema with isError", async () => {
await start()
const text = async (args: object): Promise<string> => {
const {result} = await body(await post(modern("tools/call", {name: "send", arguments: args}), modernHeaders("tools/call", "send")))
expect(result.isError).toBe(true)
return result.content[0].text
}
expect(await text({chat: "@4", text: ""})).toBe("Input validation error: Invalid arguments for tool send: data/text must NOT have fewer than 1 characters")
expect(await text({chat: "@4", text: "😀".repeat(12001)})).toMatch(/data\/text must NOT have more than 12000 characters$/)
expect(await text({chat: "@4", text: "x", mention: [44, 44]})).toMatch(/data\/mention must NOT have duplicate items/)
expect(await text({chat: "@4", text: "x", wait: -1})).toMatch(/data\/wait must be >= 0$/)
expect(await text({chat: "@4", text: "x", colour: "red"})).toMatch(/data must NOT have additional properties$/)
expect(await text({text: "x"})).toMatch(/data must have required property 'chat'$/)
expect(actions.all()).toHaveLength(0)
})
it("streams progress, and aborts the call when the client closes the stream", async () => {
await start()
const controller = new AbortController()
const response = await post(modern("tools/call", {name: "send", arguments: {chat: "@4", text: "hello"}, _meta: {progressToken: "p1"}}), modernHeaders("tools/call", "send"), "/mcp", controller.signal)
expect(response.headers.get("content-type")).toMatch(/^text\/event-stream/)
const reader = response.body!.getReader()
let text = ""
while (!text.includes("notifications/progress")) text += new TextDecoder().decode((await reader.read()).value)
expect(text).toMatch(/"progressToken":"p1".*"message":"Action 1 waits for an approver until /)
expect(actions.get(1)!.open).toBe(1)
controller.abort()
await until(() => actions.get(1)!.open === 0)
})
})
async function until(condition: () => boolean): Promise<void> {
for (let i = 0; i < 200 && !condition(); i++) await new Promise(resolve => setTimeout(resolve, 10))
expect(condition()).toBe(true)
}