mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-14 11:17:44 +00:00
* 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
190 lines
9.3 KiB
TypeScript
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)
|
|
}
|