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

276 lines
12 KiB
TypeScript

import {tmpdir} from "os"
import {join} from "path"
import {ChatResponse, T} from "@simplex-chat/types"
import {api, core} from "simplex-chat"
import {Actions, ActionHooks} from "../../src/actions"
import {ToolCall} from "../../src/messages"
export const user = {userId: 1, profile: {displayName: "agent", peerType: "agent", profileId: 1}} as unknown as T.User
export const data = join(tmpdir(), `simplex-mcp-unit-${process.pid}`)
export function contact(contactId: number, displayName: string, extra: object = {}): T.Contact {
return {
contactId,
profile: {displayName, fullName: "", profileId: 100 + contactId, localAlias: "", ...extra},
activeConn: {connId: 200 + contactId, connStatus: {type: "ready"}},
updatedAt: "2026-10-17T10:00:00Z",
} as unknown as T.Contact
}
export function member(groupMemberId: number, displayName: string, role: T.GroupMemberRole = T.GroupMemberRole.Member, status: T.GroupMemberStatus = T.GroupMemberStatus.Connected): T.GroupMember {
return {
groupMemberId,
memberRole: role,
memberStatus: status,
memberProfile: {displayName, fullName: "", profileId: 300 + groupMemberId, localAlias: ""},
memberContactProfileId: 300 + groupMemberId,
} as unknown as T.GroupMember
}
export function group(groupId: number, displayName: string, membership: T.GroupMember = member(1, "agent", T.GroupMemberRole.Owner, T.GroupMemberStatus.Creator), extra: object = {}): T.GroupInfo {
return {groupId, groupProfile: {displayName, fullName: ""}, membership, useRelays: false, updatedAt: "2026-10-17T10:00:00Z", ...extra} as unknown as T.GroupInfo
}
export function direct(c: T.Contact): T.ChatInfo {
return {type: "direct", contact: c}
}
export function groupChat(g: T.GroupInfo): T.ChatInfo {
return {type: "group", groupInfo: g}
}
export function item(itemId: number, text: string, chatDir: T.CIDirection = {type: "directRcv"}, extra: {status?: string; quoted?: number; mention?: boolean; ts?: string; live?: boolean} = {}): T.ChatItem {
const received = chatDir.type.endsWith("Rcv")
return {
chatDir,
meta: {
itemId,
itemTs: extra.ts ?? "2026-10-17T10:00:00Z",
itemText: text,
itemStatus: {type: extra.status ?? (received ? "rcvNew" : "sndSent")},
userMention: extra.mention ?? false,
...(extra.live === undefined ? {} : {itemLive: extra.live}),
},
content: {type: received ? "rcvMsgContent" : "sndMsgContent", msgContent: {type: "text", text}},
mentions: {},
reactions: [],
...(extra.quoted === undefined ? {} : {quotedItem: {itemId: extra.quoted, sentAt: "", content: {type: "text", text: ""}}}),
} as unknown as T.ChatItem
}
export function fileItem(itemId: number, msgContent: object, fileStatus: object, extra: {chatDir?: T.CIDirection; fileSource?: object; fileId?: number} = {}): T.ChatItem {
const chatItem = item(itemId, "", extra.chatDir)
const file = {fileId: extra.fileId ?? 7, fileName: "report.pdf", fileSize: 2400000, fileStatus, fileProtocol: "xftp", ...(extra.fileSource === undefined ? {} : {fileSource: extra.fileSource})}
return {...chatItem, content: {...chatItem.content, msgContent}, file} as unknown as T.ChatItem
}
export function event(chatInfo: T.ChatInfo, chatItem: T.ChatItem): T.AChatItem {
return {chatInfo, chatItem}
}
export function signal(): {controller: AbortController; call: ToolCall; progress: string[]} {
const controller = new AbortController()
const progress: string[] = []
return {controller, progress, call: {signal: controller.signal, progress: message => progress.push(message)}}
}
export interface FakeHooks {
calls: string[]
hooks: ActionHooks
performs: {resolve: () => void; reject: (error: Error) => void}[]
approvers: number
}
export function fakeHooks(): FakeHooks {
const fake: FakeHooks = {calls: [], performs: [], approvers: 1, hooks: undefined as unknown as ActionHooks}
fake.hooks = {
refusal: () => (fake.approvers > 0 ? undefined : "no approver"),
saveCounter: async next => {
fake.calls.push(`counter ${next}`)
},
postCard: async action => {
fake.calls.push(`card ${action.n}`)
return 1000 + action.n
},
perform: action =>
new Promise((resolve, reject) => {
fake.calls.push(`perform ${action.n}`)
fake.performs.push({resolve, reject})
}),
outcome: async action => {
fake.calls.push(`outcome ${action.n} ${action.state}${action.interrupted ? " interrupted" : ""}`)
},
}
return fake
}
export function actionsWith(mode: "requests" | "host", fake: FakeHooks, next = 1, expireHours = 23): Actions {
return new Actions(mode, expireHours, next, fake.hooks)
}
export async function settled(): Promise<void> {
for (let i = 0; i < 10; i++) await Promise.resolve()
}
interface FakeChatData {
chatInfo: T.ChatInfo
items: T.ChatItem[]
}
const library = api.ChatApi.prototype
export class FakeChat {
apiGetChat = library.apiGetChat
apiChatItemsRead = library.apiChatItemsRead
apiGetChats = library.apiGetChats
apiContactInfo = library.apiContactInfo
apiConnectPlan = library.apiConnectPlan
apiPrepareContact = library.apiPrepareContact
apiPrepareGroup = library.apiPrepareGroup
apiConnectPreparedContact = library.apiConnectPreparedContact
apiConnectPreparedGroup = library.apiConnectPreparedGroup
apiConnectContactViaAddress = library.apiConnectContactViaAddress
apiConnectPreparedLink = library.apiConnectPreparedLink
apiReceiveFile = library.apiReceiveFile
apiCancelFile = library.apiCancelFile
apiGetChatItemInfo = library.apiGetChatItemInfo
apiGetUserServers = library.apiGetUserServers
commands: string[] = []
sent: {chat: [T.ChatType, number]; messages: T.ComposedMessage[]}[] = []
updates: {chatId: number; itemId: number; text: string}[] = []
replies: string[] = []
deleted: {chatType: T.ChatType; chatId: number; mode?: T.ChatDeleteMode}[] = []
left: number[] = []
customData: {chat: string; customData?: object}[] = []
profiles: {groupId: number; groupProfile: T.GroupProfile}[] = []
members = new Map<number, T.GroupMember[]>()
chats = new Map<string, FakeChatData>()
responses = new Map<string, object>()
nextItemId = 500
async sendChatCmd(cmd: string): Promise<ChatResponse> {
this.commands.push(cmd)
const fixed = this.responses.get(cmd)
if (fixed !== undefined) return fixed as ChatResponse
const getChat = /^\/_get chat ([@#]\d+) (.+)$/.exec(cmd)
if (getChat) return this.getChat(getChat[1], getChat[2])
if (cmd.startsWith("/_get chats ")) {
const [, before, count] = /(?: before=(\S+))? count=(\d+) /.exec(cmd)!
const chats = [...this.chats.values()]
.filter(({chatInfo}) => before === undefined || chatTs(chatInfo) < before)
.slice(0, Number(count))
.map(({chatInfo, items}) => ({chatInfo, chatItems: [], chatStats: chatStats(items)}))
return structuredClone({type: "apiChats", user, chats}) as unknown as ChatResponse
}
if (cmd.startsWith("/_read chat items ")) {
const [, ref, ids] = /^\/_read chat items ([@#]\d+) (.+)$/.exec(cmd)!
const data = this.chats.get(ref)!
for (const id of ids.split(",").map(Number)) {
const found = data.items.find(({meta}) => meta.itemId === id)
if (found) found.meta.itemStatus = {type: "rcvRead"}
}
return {type: "itemsReadForChat"} as unknown as ChatResponse
}
return {type: "cmdOk"}
}
async apiSendMessages(chat: [T.ChatType, number], messages: T.ComposedMessage[]): Promise<T.AChatItem[]> {
this.sent.push({chat, messages})
const ref = `${chat[0] === T.ChatType.Direct ? "@" : "#"}${chat[1]}`
const chatInfo = this.chats.get(ref)?.chatInfo ?? groupChat(group(chat[1], "control"))
return messages.map(({msgContent}) => event(chatInfo, item(this.nextItemId++, msgContent.text, chatInfo.type === "direct" ? {type: "directSnd"} : {type: "groupSnd"})))
}
async apiUpdateChatItem(_type: T.ChatType, chatId: number, itemId: number, msgContent: T.MsgContent): Promise<T.ChatItem> {
this.updates.push({chatId, itemId, text: msgContent.text})
return item(itemId, msgContent.text, {type: "groupSnd"})
}
async apiSendTextReply(_ci: T.AChatItem, text: string): Promise<T.AChatItem[]> {
this.replies.push(text)
return []
}
async apiListMembers(groupId: number): Promise<T.GroupMember[]> {
return this.members.get(groupId) ?? []
}
async apiDeleteChat(chatType: T.ChatType, chatId: number, mode?: T.ChatDeleteMode): Promise<void> {
this.deleted.push({chatType, chatId, mode})
this.chats.delete(`${chatType === T.ChatType.Direct ? "@" : "#"}${chatId}`)
}
async apiLeaveGroup(groupId: number): Promise<T.GroupInfo> {
this.left.push(groupId)
const {groupInfo} = this.chats.get(`#${groupId}`)!.chatInfo as T.ChatInfo.Group
groupInfo.membership = {...groupInfo.membership, memberStatus: T.GroupMemberStatus.Left}
return groupInfo
}
async apiSetContactCustomData(contactId: number, customData?: object): Promise<void> {
this.setCustomData(`@${contactId}`, customData)
}
async apiSetGroupCustomData(groupId: number, customData?: object): Promise<void> {
this.setCustomData(`#${groupId}`, customData)
}
async apiDeleteGroupLink(): Promise<void> {}
async apiUpdateGroupProfile(groupId: number, groupProfile: T.GroupProfile): Promise<T.GroupInfo> {
this.profiles.push({groupId, groupProfile})
return {...group(groupId, groupProfile.displayName), groupProfile}
}
api(): api.ChatApi {
return this as unknown as api.ChatApi
}
addItem(ref: string, chatItem: T.ChatItem): T.AChatItem {
const data = this.chats.get(ref)!
data.items.push(chatItem)
return event(data.chatInfo, structuredClone(chatItem))
}
private setCustomData(ref: string, customData: object | undefined): void {
this.customData.push({chat: ref, customData})
const {chatInfo} = this.chats.get(ref)!
if (chatInfo.type === "direct") chatInfo.contact.customData = customData
else (chatInfo as T.ChatInfo.Group).groupInfo.customData = customData
}
private getChat(ref: string, pagination: string): ChatResponse {
const data = this.chats.get(ref)
if (data === undefined) throw storeError(ref)
const around = /^around=(\d+) count=0$/.exec(pagination)
const after = /^after=(\d+) count=(\d+)$/.exec(pagination)
const before = /^before=(\d+) count=(\d+)$/.exec(pagination)
const last = /^count=(\d+)$/.exec(pagination)
const chatItems = around
? data.items.filter(({meta}) => meta.itemId === Number(around[1]))
: after
? data.items.filter(({meta}) => meta.itemId > Number(after[1])).slice(0, Number(after[2]))
: before
? data.items.filter(({meta}) => meta.itemId < Number(before[1])).slice(-Number(before[2]))
: last && Number(last[1]) > 0
? data.items.slice(-Number(last[1]))
: []
if (around && chatItems.length === 0) throw storeError(ref)
return structuredClone({type: "apiChat", chat: {chatInfo: data.chatInfo, chatItems, chatStats: chatStats(data.items)}}) as unknown as ChatResponse
}
}
function chatTs(chatInfo: T.ChatInfo): string {
return chatInfo.type === "direct" ? chatInfo.contact.updatedAt : (chatInfo as T.ChatInfo.Group).groupInfo.updatedAt
}
function chatStats(items: T.ChatItem[]): T.ChatStats {
const unread = items.filter(({meta}) => meta.itemStatus.type === "rcvNew")
return {unreadCount: unread.length, minUnreadItemId: unread[0]?.meta.itemId ?? 0, unreadMentions: 0, reportsCount: 0, unreadChat: false}
}
function storeError(ref: string): Error {
return new core.ChatAPIError("Chat command error", {type: "errorStore", storeError: {type: "contactNotFound", contactId: Number(ref.slice(1))}})
}