feat: mcp run-file tool available in tools/trap-sandbox
This commit is contained in:
parent
4eaa985458
commit
c55dbb3669
@ -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 };
|
||||
|
||||
@ -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, {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user