feat: mcp run-file tool available in tools/trap-sandbox

This commit is contained in:
Guillaume ARM 2026-06-15 23:07:09 +02:00
parent 4eaa985458
commit c55dbb3669
4 changed files with 93 additions and 5 deletions

View File

@ -9,7 +9,7 @@ import type { GatewayRegistry } from "../trapos-cloud-gateway/registry.js";
export type McpDeps = {
registry: GatewayRegistry;
accountId: string;
// Default deadline for exec-lua / write-file when the caller omits timeoutMs.
// Default deadline for exec-lua / run-file / write-file when the caller omits timeoutMs.
defaultTimeoutMs: number;
// Hard ceiling applied to any caller-supplied timeoutMs.
maxTimeoutMs: number;
@ -46,6 +46,20 @@ const TOOLS = [
additionalProperties: false,
},
},
{
name: "run-file",
description: "Run a Lua file on a connected TrapOS computer and capture its output.",
inputSchema: {
type: "object",
properties: {
traposId: { type: "string", description: 'Target traposId ("<id>:<label>", from probe-computers).' },
path: { type: "string", description: "Lua file path on the TrapOS computer to run." },
timeoutMs: { type: "number", description: "Optional host-side timeout in milliseconds, max 30000." },
},
required: ["traposId", "path"],
additionalProperties: false,
},
},
{
name: "write-file",
description: "Write file content on a connected TrapOS computer, overwriting any existing file.",
@ -109,6 +123,16 @@ async function handleSingle(request: JsonRpcRequest, deps: McpDeps): Promise<unk
return jsonRpcResult(id, { content: [{ type: "text", text }] });
}
if (params.name === "run-file") {
const args = isRecord(params.arguments) ? params.arguments : {};
const parsed = parseRunFileArgs(args, deps);
if (!parsed.ok) {
return jsonRpcError(id, -32602, parsed.error);
}
const text = await runFile(deps, parsed.traposId, parsed.path, parsed.timeoutMs);
return jsonRpcResult(id, { content: [{ type: "text", text }] });
}
if (params.name === "write-file") {
const args = isRecord(params.arguments) ? params.arguments : {};
const parsed = parseWriteFileArgs(args, deps);
@ -152,6 +176,10 @@ async function execLua(deps: McpDeps, traposId: string, code: string, timeoutMs:
return transportError(traposId, err);
}
return formatExecutionResult(traposId, payload);
}
function formatExecutionResult(traposId: string, payload: Record<string, unknown>): string {
const ok = payload.ok === true;
const output = typeof payload.output === "string" ? payload.output : "";
const lines = [`computer: ${traposId}`, `ok: ${ok ? "true" : "false"}`];
@ -167,6 +195,17 @@ async function execLua(deps: McpDeps, traposId: string, code: string, timeoutMs:
return lines.join("\n");
}
async function runFile(deps: McpDeps, traposId: string, path: string, timeoutMs: number): Promise<string> {
let payload: Record<string, unknown>;
try {
payload = await deps.registry.request(deps.accountId, traposId, "run-file", { path }, timeoutMs);
} catch (err) {
return transportError(traposId, err);
}
return formatExecutionResult(traposId, payload);
}
async function writeFile(
deps: McpDeps,
traposId: string,
@ -208,6 +247,22 @@ function parseExecLuaArgs(args: Record<string, unknown>, deps: McpDeps): ExecLua
return { ok: true, traposId: args.traposId, code: args.code, timeoutMs: timeout.value };
}
type RunFileArgs = { ok: true; traposId: string; path: string; timeoutMs: number } | { ok: false; error: string };
function parseRunFileArgs(args: Record<string, unknown>, deps: McpDeps): RunFileArgs {
if (typeof args.traposId !== "string" || args.traposId.trim() === "") {
return { ok: false, error: "traposId must be a non-empty string" };
}
if (typeof args.path !== "string" || args.path.trim() === "") {
return { ok: false, error: "path must be a non-empty string" };
}
const timeout = parseTimeout(args.timeoutMs, deps);
if (!timeout.ok) {
return { ok: false, error: timeout.error };
}
return { ok: true, traposId: args.traposId, path: args.path, timeoutMs: timeout.value };
}
type WriteFileArgs =
| { ok: true; traposId: string; path: string; content: string; timeoutMs: number }
| { ok: false; error: string };

View File

@ -1,5 +1,5 @@
-- Integration-test driver: the REAL reactive MCP computer server. Connects the real
-- libsandbox daemon to the gateway and serves exec-lua / write-file via sandbox.serve
-- libsandbox daemon to the gateway and serves exec-lua / run-file / write-file via sandbox.serve
-- (same wiring as servers/mcp-computer-server.lua), then stays registered so the TS
-- side can drive the MCP tools over the gateway.
--
@ -37,6 +37,16 @@ sandbox.serve('exec-lua', function(payload)
};
end, { eventloop = el });
sandbox.serve('run-file', function(payload)
local result = mcp.executeFile(payload and payload.path);
return true, {
ok = result.ok,
returns = result.returns or {},
output = result.output or '',
error = result.error,
};
end, { eventloop = el });
sandbox.serve('write-file', function(payload)
local result = mcp.writeFile(payload and payload.path, payload and payload.content);
return true, {

View File

@ -18,7 +18,7 @@ async function callTool(sandbox: Sandbox, name: string, args: Record<string, unk
return body.result?.content?.[0]?.text ?? "";
}
test("MCP exec-lua / write-file / probe round-trip over the gateway", async () => {
test("MCP exec-lua / run-file / write-file / probe round-trip over the gateway", async () => {
const sandbox = await startSandbox();
const craftos = startCraftos("mcp-server-client.lua", {
computerId: 7,
@ -43,6 +43,10 @@ test("MCP exec-lua / write-file / probe round-trip over the gateway", async () =
assert.match(write, /ok: true/);
assert.match(write, /bytes: 5/);
const run = await callTool(sandbox, "run-file", { traposId: "7:base", path: "/mcp-it.txt" });
assert.match(run, /^computer: 7:base\nok: false/);
assert.match(run, /\/mcp-it\.txt:1: .*syntax error/);
// Read it back through exec-lua to prove the write actually landed.
const verify = await callTool(sandbox, "exec-lua", {
traposId: "7:base",

View File

@ -48,12 +48,14 @@ test("initialize advertises the trap-sandbox server", async () => {
assert.equal(info.name, "trap-sandbox");
});
test("tools/list exposes the three tools addressed by traposId", async () => {
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", "write-file"]);
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 () => {
@ -123,6 +125,23 @@ test("exec-lua maps a transport failure to a coded error", async () => {
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" }, true, {
ok: true,
returns: [],
output: "secret\n",
});
assert.equal(a.lastFrame().type, "run-file");
assert.deepEqual((a.lastFrame().payload as { path: string }).path, "/hello.lua");
assert.equal(text, ["computer: 7:base", "ok: true", "returns: []", "output:", "secret"].join("\n"));
});
test("write-file reports the written path and byte count", async () => {
const reg = new GatewayRegistry(silentLog);
const deps = makeDeps(reg);