cc-libs/tools/trap-sandbox/test/mcp.test.ts
2026-06-15 19:57:45 +02:00

164 lines
6.8 KiB
TypeScript

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<string, unknown> {
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<string, unknown> = {}): ComputerResponse {
return { event: "computer_response", traposSenderId: traposId, messageId, type, ok, payload };
}
// Run a tool call and resolve the single gateway_request it emits with `payload`.
async function callAndAnswer(
deps: McpDeps,
socket: FakeSocket,
traposId: string,
toolName: string,
args: Record<string, unknown>,
ok: boolean,
payload: Record<string, unknown>,
): Promise<string> {
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 trap-sandbox 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, "trap-sandbox");
});
test("tools/list exposes the three 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", "write-file"]);
const exec = tools.find((t) => t.name === "exec-lua");
assert.deepEqual(exec?.inputSchema.required, ["traposId", "code"]);
});
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("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"]);
});