mcp: add the SessionStart hook and link commands

This commit is contained in:
shum committed 2026-10-24 14:54:29 +00:00
1 parent afa82561f9
commit 75b2e1e04d
3 files changed
+155

No files matched your search

+48
View File
@@ -0,0 +1,48 @@
import {DaemonClient} from "./client"
import {readConfig} from "./config"
import {dataRoot} from "./files"
import {errorText} from "./log"
import {SessionReply} from "./protocol"
import {qrCodeText} from "./qr"
import {isNotRunning} from "./socket"
import {version} from "./version"
const minNodeMajor = 22
const daemonStopped = "The SimpleX daemon stopped; it starts again with the next tool call."
interface SessionStartInput {
session_id: string
session_title?: string
cwd?: string
}
export interface HookOutput {
systemMessage?: string
hookSpecificOutput?: {hookEventName: "SessionStart"; additionalContext: string}
}
export interface HookDeps {
client: Pick<DaemonClient, "ensureDaemon" | "request">
nodeVersion: string
}
export async function sessionStart(stdin: string, env: NodeJS.ProcessEnv = process.env, deps?: HookDeps): Promise<HookOutput> {
const nodeVersion = deps?.nodeVersion ?? process.versions.node
if (Number(nodeVersion.split(".")[0]) < minNodeMajor) return {systemMessage: `SimpleX MCP needs Node.js ${minNodeMajor} or later; found ${nodeVersion}.`}
try {
const input = JSON.parse(stdin) as SessionStartInput
const config = readConfig(env)
const client = deps?.client ?? DaemonClient.forRoot(dataRoot(env))
await client.ensureDaemon(version)
const reply = await client.request<SessionReply>("POST", "/session", {id: input.session_id, name: input.session_title, project: env.CLAUDE_PROJECT_DIR ?? input.cwd ?? process.cwd(), config})
const {link, owners} = reply.owner
if (owners.length > 0 || link === undefined) return {}
const {mainGroup} = config
return {
systemMessage: `SimpleX MCP: scan this QR code with the SimpleX app and tap Join to supervise your agents in "${mainGroup}":\n${qrCodeText(link, "plain")}\n${link}`,
hookSpecificOutput: {hookEventName: "SessionStart", additionalContext: `The SimpleX Main group "${mainGroup}" has no owner yet. Tell your user to open this link in the SimpleX app and join: ${link}`},
}
} catch (error) {
return {systemMessage: `SimpleX MCP: ${isNotRunning(error) ? daemonStopped : errorText(error)}`}
}
}
+30
View File
@@ -0,0 +1,30 @@
import {DaemonClient} from "./client"
import {dataRoot} from "./files"
import {ResetReply, StatusReply} from "./protocol"
import {qrCodeText} from "./qr"
import {version} from "./version"
export function linkText({mainGroup, owner}: StatusReply, tty: boolean): string {
if (owner.owners.length > 0) return `Owners of "${mainGroup}": ${owner.owners.join(", ")}. To replace them, run /simplex:reset-owners.`
if (owner.link === undefined) return "The Main group has no link. Run /simplex:reset-owners."
return [`Join "${mainGroup}" in the SimpleX app. The link works once:`, ...qrLines(owner.link, tty), owner.link].join("\n")
}
export function resetOwnersText(link: string, mainGroup: string, tty: boolean): string {
return [`"${mainGroup}" was replaced. Join the new one in the SimpleX app. The link works once:`, ...qrLines(link, tty), link].join("\n")
}
export async function showLink(env: NodeJS.ProcessEnv = process.env, tty = process.stdout.isTTY === true): Promise<string> {
return linkText(await DaemonClient.forRoot(dataRoot(env)).ensureDaemon(version), tty)
}
export async function resetOwners(env: NodeJS.ProcessEnv = process.env, tty = process.stdout.isTTY === true): Promise<string> {
const client = DaemonClient.forRoot(dataRoot(env))
const {mainGroup} = await client.ensureDaemon(version)
const {link} = await client.request<ResetReply>("POST", "/reset-owners")
return resetOwnersText(link, mainGroup, tty)
}
function qrLines(link: string, tty: boolean): string[] {
return tty ? [qrCodeText(link, "ansi")] : []
}
@@ -0,0 +1,77 @@
import {HookDeps, sessionStart} from "../../src/hook"
import {linkText, resetOwnersText} from "../../src/owner"
import {qrCodeText} from "../../src/qr"
const status = {version: "0.2.0", protocol: 1, mainGroup: "SimpleX MCP", sessions: []}
const input = JSON.stringify({session_id: "11111111-1111-4111-8111-111111111111", session_title: "auth", cwd: "/p/app", source: "startup"})
function fakeClient(owner: {owners: string[]; link?: string}, ensureError?: Error) {
const requests: unknown[] = []
const fake = {
ensureDaemon: async () => {
if (ensureError) throw ensureError
return {...status, owner}
},
request: async (_method: string, _route: string, body: unknown) => {
requests.push(body)
return {number: 1, displayName: "SimpleX MCP - auth", version: "0.2.0", protocol: 1, owner}
},
}
return {requests, client: fake as unknown as HookDeps["client"]}
}
describe("SessionStart hook", () => {
it("registers the session and stays quiet when the Main group has an owner", async () => {
const {client, requests} = fakeClient({owners: ["Alice"]})
expect(await sessionStart(input, {CLAUDE_PROJECT_DIR: "/p/app"}, {client, nodeVersion: "22.1.0"})).toEqual({})
expect(requests).toEqual([{id: "11111111-1111-4111-8111-111111111111", name: "auth", project: "/p/app", config: {mainGroup: "SimpleX MCP", approve: "requests", expireHours: 23}}])
})
it("shows the link with a QR code while the Main group has no owner", async () => {
const link = "https://simplex.chat/g#link"
const {client} = fakeClient({owners: [], link})
const output = await sessionStart(input, {}, {client, nodeVersion: "22.1.0"})
expect(output).toEqual({
systemMessage: `SimpleX MCP: scan this QR code with the SimpleX app and tap Join to supervise your agents in "SimpleX MCP":\n${qrCodeText(link, "plain")}\n${link}`,
hookSpecificOutput: {hookEventName: "SessionStart", additionalContext: `The SimpleX Main group "SimpleX MCP" has no owner yet. Tell your user to open this link in the SimpleX app and join: ${link}`},
})
})
it("needs Node.js 22", async () => {
const {client, requests} = fakeClient({owners: []})
expect(await sessionStart(input, {}, {client, nodeVersion: "20.11.0"})).toEqual({systemMessage: "SimpleX MCP needs Node.js 22 or later; found 20.11.0."})
expect(requests).toEqual([])
})
it("reports failures as a message and lets the session start", async () => {
const {client} = fakeClient({owners: []})
expect(await sessionStart("not json", {}, {client, nodeVersion: "22.1.0"})).toMatchObject({systemMessage: expect.stringMatching(/^SimpleX MCP: /)})
expect(await sessionStart(input, {CLAUDE_PLUGIN_OPTION_APPROVE: "x"}, {client, nodeVersion: "22.1.0"})).toEqual({
systemMessage: "SimpleX MCP: Plugin option approve: requests or host expected, got x. Change it in /plugin > Installed > simplex > Configure options.",
})
})
it("explains a stopped daemon instead of the raw connect error", async () => {
const error = Object.assign(new Error("connect ENOENT /root/daemon.sock"), {code: "ENOENT"})
const {client} = fakeClient({owners: []}, error)
expect(await sessionStart(input, {}, {client, nodeVersion: "22.1.0"})).toEqual({
systemMessage: "SimpleX MCP: The SimpleX daemon stopped; it starts again with the next tool call.",
})
})
it("reports other daemon errors with their text", async () => {
const {client} = fakeClient({owners: []}, new Error("spawn failed"))
expect(await sessionStart(input, {}, {client, nodeVersion: "22.1.0"})).toEqual({systemMessage: "SimpleX MCP: spawn failed"})
})
})
describe("owner commands", () => {
const link = "https://simplex.chat/g#link"
it("show the owners, or the link with a QR code in a terminal", () => {
expect(linkText({...status, owner: {owners: ["Alice", "Bob"]}}, false)).toBe('Owners of "SimpleX MCP": Alice, Bob. To replace them, run /simplex:reset-owners.')
expect(linkText({...status, owner: {owners: [], link}}, false)).toBe(`Join "SimpleX MCP" in the SimpleX app. The link works once:\n${link}`)
expect(linkText({...status, owner: {owners: [], link}}, true)).toBe(`Join "SimpleX MCP" in the SimpleX app. The link works once:\n${qrCodeText(link, "ansi")}\n${link}`)
expect(linkText({...status, owner: {owners: []}}, false)).toBe("The Main group has no link. Run /simplex:reset-owners.")
expect(resetOwnersText(link, "SimpleX MCP", false)).toBe(`"SimpleX MCP" was replaced. Join the new one in the SimpleX app. The link works once:\n${link}`)
})
})