import assert from "node:assert/strict"; import { test } from "node:test"; import { GatewayRegistry } from "../src/modules/trapos-cloud-gateway/registry.js"; import type { ComputerResponse } from "../src/modules/trapos-cloud-gateway/protocol.js"; import { handleMcpRequest, type McpDeps } from "../src/modules/mcp/tools.js"; import { FakeSocket, silentLog } from "./helpers.js"; const ACCOUNT = "local"; function makeDeps(registry: GatewayRegistry): McpDeps { return { registry, accountId: ACCOUNT, defaultTimeoutMs: 5_000, maxTimeoutMs: 30_000, probeTimeoutMs: 5_000 }; } function rpc(method: string, params?: unknown, id: unknown = 1): Record { return { jsonrpc: "2.0", id, method, params }; } function resultText(reply: unknown): string { const result = (reply as { result?: { content?: { text?: string }[] } }).result; return result?.content?.[0]?.text ?? ""; } function response(traposId: string, messageId: string, type: string, ok: boolean, payload: Record = {}): ComputerResponse { return { event: "computer_response", traposSenderId: traposId, messageId, type, ok, payload }; } // Run a tool call and resolve the single server_request it emits with `payload`. async function callAndAnswer( deps: McpDeps, socket: FakeSocket, traposId: string, toolName: string, args: Record, ok: boolean, payload: Record, ): Promise { const pending = handleMcpRequest(rpc("tools/call", { name: toolName, arguments: args }), deps); // registry.request sends synchronously; a tick lets the async chain reach the await. await Promise.resolve(); const frame = socket.lastFrame(); deps.registry.resolveResponse(socket, ACCOUNT, traposId, response(traposId, frame.messageId as string, frame.type as string, ok, payload)); return resultText(await pending); } test("initialize advertises the trapos-mcp server", async () => { const reply = await handleMcpRequest(rpc("initialize"), makeDeps(new GatewayRegistry(silentLog))); const info = (reply as { result: { serverInfo: { name: string } } }).result.serverInfo; assert.equal(info.name, "trapos-mcp"); }); test("tools/list exposes the tools addressed by traposId", async () => { const reply = await handleMcpRequest(rpc("tools/list"), makeDeps(new GatewayRegistry(silentLog))); const tools = (reply as { result: { tools: { name: string; inputSchema: { required?: string[] } }[] } }).result.tools; assert.deepEqual(tools.map((t) => t.name).sort(), ["exec-lua", "probe-computers", "run-file", "write-file"]); const exec = tools.find((t) => t.name === "exec-lua"); assert.deepEqual(exec?.inputSchema.required, ["traposId", "code"]); const run = tools.find((t) => t.name === "run-file"); assert.deepEqual(run?.inputSchema.required, ["traposId", "path"]); }); test("notifications/initialized is a no-op (null)", async () => { const reply = await handleMcpRequest(rpc("notifications/initialized", undefined, null), makeDeps(new GatewayRegistry(silentLog))); assert.equal(reply, null); }); test("probe-computers reports no computers when registry is empty", async () => { const reply = await handleMcpRequest(rpc("tools/call", { name: "probe-computers" }), makeDeps(new GatewayRegistry(silentLog))); assert.equal(resultText(reply), "No computers connected."); }); test("probe-computers pings each connected computer by traposId", async () => { const reg = new GatewayRegistry(silentLog); const deps = makeDeps(reg); const a = new FakeSocket(); reg.register(ACCOUNT, "7:base", a); const pending = handleMcpRequest(rpc("tools/call", { name: "probe-computers" }), deps); await Promise.resolve(); const frame = a.lastFrame(); assert.equal(frame.type, "ping"); reg.resolveResponse(a, ACCOUNT, "7:base", response("7:base", frame.messageId as string, "ping", true, { traposId: "7:base" })); assert.equal(resultText(await pending), "ok from 7:base"); }); test("exec-lua formats returns and trimmed output on success", async () => { const reg = new GatewayRegistry(silentLog); const deps = makeDeps(reg); const a = new FakeSocket(); reg.register(ACCOUNT, "7:base", a); const text = await callAndAnswer(deps, a, "7:base", "exec-lua", { traposId: "7:base", code: "print('hi')" }, true, { ok: true, returns: [{ type: "number", value: 42 }], output: "hi\n", }); assert.equal(a.lastFrame().type, "exec-lua"); assert.deepEqual((a.lastFrame().payload as { code: string }).code, "print('hi')"); assert.equal(text, ["computer: 7:base", "ok: true", `returns: ${JSON.stringify([{ type: "number", value: 42 }])}`, "output:", "hi"].join("\n")); }); test("exec-lua surfaces an ok:false result with its captured output", async () => { const reg = new GatewayRegistry(silentLog); const deps = makeDeps(reg); const a = new FakeSocket(); reg.register(ACCOUNT, "7:base", a); const text = await callAndAnswer(deps, a, "7:base", "exec-lua", { traposId: "7:base", code: "error('boom')" }, true, { ok: false, error: "boom", output: "partial", }); assert.equal(text, ["computer: 7:base", "ok: false", "error: boom", "output:", "partial"].join("\n")); }); test("exec-lua maps a transport failure to a coded error", async () => { const reg = new GatewayRegistry(silentLog); const deps = makeDeps(reg); // No computer registered for this traposId -> not_connected. const reply = await handleMcpRequest( rpc("tools/call", { name: "exec-lua", arguments: { traposId: "9:ghost", code: "x" } }), deps, ); assert.match(resultText(reply), /^computer: 9:ghost\nok: false\nerror: not_connected:/); }); test("run-file sends the path and formats execution output", async () => { const reg = new GatewayRegistry(silentLog); const deps = makeDeps(reg); const a = new FakeSocket(); reg.register(ACCOUNT, "7:base", a); const text = await callAndAnswer( deps, a, "7:base", "run-file", { traposId: "7:base", path: "/hello.lua", args: ["install", "pkg"] }, true, { ok: true, returns: [], output: "secret\n", }, ); assert.equal(a.lastFrame().type, "run-file"); assert.deepEqual(a.lastFrame().payload, { path: "/hello.lua", args: ["install", "pkg"] }); assert.equal(text, ["computer: 7:base", "ok: true", "returns: []", "output:", "secret"].join("\n")); }); test("run-file defaults args to an empty array", async () => { const reg = new GatewayRegistry(silentLog); const deps = makeDeps(reg); const a = new FakeSocket(); reg.register(ACCOUNT, "7:base", a); await callAndAnswer(deps, a, "7:base", "run-file", { traposId: "7:base", path: "/hello.lua" }, true, { ok: true, returns: [], output: "", }); assert.deepEqual(a.lastFrame().payload, { path: "/hello.lua", args: [] }); }); test("run-file rejects non-string args", async () => { const reply = await handleMcpRequest( rpc("tools/call", { name: "run-file", arguments: { traposId: "7:base", path: "/hello.lua", args: ["ok", 2] } }), makeDeps(new GatewayRegistry(silentLog)), ); const error = (reply as { error?: { code: number; message: string } }).error; assert.equal(error?.code, -32602); assert.equal(error?.message, "args must be an array of strings"); }); test("write-file reports the written path and byte count", async () => { const reg = new GatewayRegistry(silentLog); const deps = makeDeps(reg); const a = new FakeSocket(); reg.register(ACCOUNT, "7:base", a); const text = await callAndAnswer( deps, a, "7:base", "write-file", { traposId: "7:base", path: "/foo.txt", content: "hello" }, true, { ok: true, path: "/foo.txt", bytes: 5 }, ); assert.equal(a.lastFrame().type, "write-file"); assert.equal(text, ["computer: 7:base", "ok: true", "path: /foo.txt", "bytes: 5"].join("\n")); }); test("exec-lua rejects a missing traposId with an invalid-params error", async () => { const reply = await handleMcpRequest( rpc("tools/call", { name: "exec-lua", arguments: { code: "x" } }), makeDeps(new GatewayRegistry(silentLog)), ); const error = (reply as { error?: { code: number; message: string } }).error; assert.equal(error?.code, -32602); assert.match(error?.message ?? "", /traposId/); }); test("registry.list returns the traposIds registered for an account", () => { const reg = new GatewayRegistry(silentLog); reg.register(ACCOUNT, "7:base", new FakeSocket()); reg.register(ACCOUNT, "8:turtle", new FakeSocket()); reg.register("other", "9:base", new FakeSocket()); assert.deepEqual(reg.list(ACCOUNT).sort(), ["7:base", "8:turtle"]); assert.deepEqual(reg.list("other"), ["9:base"]); });