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 = {
|
export type McpDeps = {
|
||||||
registry: GatewayRegistry;
|
registry: GatewayRegistry;
|
||||||
accountId: string;
|
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;
|
defaultTimeoutMs: number;
|
||||||
// Hard ceiling applied to any caller-supplied timeoutMs.
|
// Hard ceiling applied to any caller-supplied timeoutMs.
|
||||||
maxTimeoutMs: number;
|
maxTimeoutMs: number;
|
||||||
@ -46,6 +46,20 @@ const TOOLS = [
|
|||||||
additionalProperties: false,
|
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",
|
name: "write-file",
|
||||||
description: "Write file content on a connected TrapOS computer, overwriting any existing 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 }] });
|
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") {
|
if (params.name === "write-file") {
|
||||||
const args = isRecord(params.arguments) ? params.arguments : {};
|
const args = isRecord(params.arguments) ? params.arguments : {};
|
||||||
const parsed = parseWriteFileArgs(args, deps);
|
const parsed = parseWriteFileArgs(args, deps);
|
||||||
@ -152,6 +176,10 @@ async function execLua(deps: McpDeps, traposId: string, code: string, timeoutMs:
|
|||||||
return transportError(traposId, err);
|
return transportError(traposId, err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return formatExecutionResult(traposId, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatExecutionResult(traposId: string, payload: Record<string, unknown>): string {
|
||||||
const ok = payload.ok === true;
|
const ok = payload.ok === true;
|
||||||
const output = typeof payload.output === "string" ? payload.output : "";
|
const output = typeof payload.output === "string" ? payload.output : "";
|
||||||
const lines = [`computer: ${traposId}`, `ok: ${ok ? "true" : "false"}`];
|
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");
|
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(
|
async function writeFile(
|
||||||
deps: McpDeps,
|
deps: McpDeps,
|
||||||
traposId: string,
|
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 };
|
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 =
|
type WriteFileArgs =
|
||||||
| { ok: true; traposId: string; path: string; content: string; timeoutMs: number }
|
| { ok: true; traposId: string; path: string; content: string; timeoutMs: number }
|
||||||
| { ok: false; error: string };
|
| { ok: false; error: string };
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
-- Integration-test driver: the REAL reactive MCP computer server. Connects the real
|
-- 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
|
-- (same wiring as servers/mcp-computer-server.lua), then stays registered so the TS
|
||||||
-- side can drive the MCP tools over the gateway.
|
-- side can drive the MCP tools over the gateway.
|
||||||
--
|
--
|
||||||
@ -37,6 +37,16 @@ sandbox.serve('exec-lua', function(payload)
|
|||||||
};
|
};
|
||||||
end, { eventloop = el });
|
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)
|
sandbox.serve('write-file', function(payload)
|
||||||
local result = mcp.writeFile(payload and payload.path, payload and payload.content);
|
local result = mcp.writeFile(payload and payload.path, payload and payload.content);
|
||||||
return true, {
|
return true, {
|
||||||
|
|||||||
@ -18,7 +18,7 @@ async function callTool(sandbox: Sandbox, name: string, args: Record<string, unk
|
|||||||
return body.result?.content?.[0]?.text ?? "";
|
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 sandbox = await startSandbox();
|
||||||
const craftos = startCraftos("mcp-server-client.lua", {
|
const craftos = startCraftos("mcp-server-client.lua", {
|
||||||
computerId: 7,
|
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, /ok: true/);
|
||||||
assert.match(write, /bytes: 5/);
|
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.
|
// Read it back through exec-lua to prove the write actually landed.
|
||||||
const verify = await callTool(sandbox, "exec-lua", {
|
const verify = await callTool(sandbox, "exec-lua", {
|
||||||
traposId: "7:base",
|
traposId: "7:base",
|
||||||
|
|||||||
@ -48,12 +48,14 @@ test("initialize advertises the trap-sandbox server", async () => {
|
|||||||
assert.equal(info.name, "trap-sandbox");
|
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 reply = await handleMcpRequest(rpc("tools/list"), makeDeps(new GatewayRegistry(silentLog)));
|
||||||
const tools = (reply as { result: { tools: { name: string; inputSchema: { required?: string[] } }[] } }).result.tools;
|
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");
|
const exec = tools.find((t) => t.name === "exec-lua");
|
||||||
assert.deepEqual(exec?.inputSchema.required, ["traposId", "code"]);
|
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 () => {
|
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:/);
|
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 () => {
|
test("write-file reports the written path and byte count", async () => {
|
||||||
const reg = new GatewayRegistry(silentLog);
|
const reg = new GatewayRegistry(silentLog);
|
||||||
const deps = makeDeps(reg);
|
const deps = makeDeps(reg);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user