feat(mcp): pass args to run-file

This commit is contained in:
Guillaume ARM 2026-06-16 03:05:37 +02:00
parent d35cb1b2ec
commit abf28ba445
17 changed files with 248 additions and 49 deletions

View File

@ -30,6 +30,11 @@ Always go through the `just` recipes — they set repo-local state and the macOS
- For one-shot automated probes prefer `just trapos-exec '<lua>'` / `just craftos-exec '<lua>'`: they always shut down and have a host watchdog, so they cannot hang the terminal.
- `just test [--pretty]` runs the full CraftOS-PC test suite (`__TRAPOS_TEST_OK__` on success).
## Running Connected Computers
- For connected TrapOS computers, prefer the MCP `run-file` tool for existing ComputerCraft programs such as `/programs/ccpm.lua`; pass CLI-style arguments with its `args` array, for example `path = "/programs/ccpm.lua", args = { "install", "pkg" }`. Do not use raw MCP Lua execution as a substitute for launching a program, because raw snippets may not provide globals such as `shell` or `require`.
- The MCP `run-file` tool path is a file path, not a shell command. Do not append CLI arguments to `path`; `/programs/ccpm.lua install pkg` is treated as a single missing file path.
## Architecture
- `apis/eventloop.lua` is the single-threaded event loop around `os.pullEventRaw`; consider using it everywhere async behavior is needed. A handler that returns `api.STOP` auto-unregisters.

View File

@ -20,6 +20,56 @@ local function appendOutput(output, value)
output[#output + 1] = tostring(value);
end
local function normalizeArgs(rawArgs)
if rawArgs == nil then
return true, {};
end
if type(rawArgs) ~= 'table' then
return false, 'args must be a table of strings';
end
local args = {};
for i = 1, #rawArgs do
local arg = rawArgs[i];
if type(arg) ~= 'string' then
return false, 'args must be a table of strings';
end
args[i] = arg;
end
return true, args;
end
local function copyArgs(args)
local copy = {};
for i = 1, #args do
copy[i] = args[i];
end
return copy;
end
local function dirname(path)
local dir = string.match(path, '^(.*)/[^/]*$');
if dir == nil then
return '';
end
if dir == '' then
return '/';
end
return dir;
end
local function createShellProxy(shellLib, runningProgram)
if type(shellLib) ~= 'table' then
return nil;
end
return setmetatable({
getRunningProgram = function()
return runningProgram;
end,
}, { __index = shellLib });
end
local function serializeReturn(value)
local valueType = type(value);
if valueType == 'nil' then
@ -63,8 +113,10 @@ local function defaultRequireFactory()
end
end
local function createExecEnv(output, requireFactory, requireDir)
local function createExecEnv(output, requireFactory, requireDir, execOpts)
execOpts = execOpts or {};
local env = setmetatable({}, { __index = _G });
env.arg = copyArgs(execOpts.args or {});
env.write = function(value)
appendOutput(output, value);
@ -89,18 +141,25 @@ local function createExecEnv(output, requireFactory, requireDir)
end
end
local shellProxy = createShellProxy(execOpts.shell, execOpts.runningProgram);
if shellProxy then
env.shell = shellProxy;
end
return env;
end
local function executeSource(code, chunkName, deps)
local function executeSource(code, chunkName, deps, execOpts)
execOpts = execOpts or {};
local output = {};
local args = execOpts.args or {};
local fn, loadErr = load(code, chunkName, 't', createExecEnv(output, deps.requireFactory, deps.requireDir));
local fn, loadErr = load(code, chunkName, 't', createExecEnv(output, deps.requireFactory, deps.requireDir, execOpts));
if not fn then
return { ok = false, error = tostring(loadErr), output = table.concat(output) };
end
local values = table.pack(pcall(fn));
local values = table.pack(pcall(fn, table.unpack(args)));
if not values[1] then
return { ok = false, error = tostring(values[2]), output = table.concat(output) };
end
@ -124,6 +183,7 @@ local function createMcpComputer(deps)
local resolved = {
requireFactory = requireFactory or nil,
requireDir = deps.requireDir or '',
shell = deps.shell or shell,
};
local api = {};
@ -144,11 +204,15 @@ local function createMcpComputer(deps)
return executeSource(code, 'mcp-exec', resolved);
end
function api.executeFile(path, fsLike)
function api.executeFile(path, fsLike, rawArgs)
fsLike = fsLike or fs;
if type(path) ~= 'string' or path == '' then
return { ok = false, error = 'path must be a non-empty string', output = '' };
end
local argsOk, argsOrErr = normalizeArgs(rawArgs);
if not argsOk then
return { ok = false, error = argsOrErr, output = '' };
end
local handle, openErr = fsLike.open(path, 'r');
if not handle then
@ -169,7 +233,15 @@ local function createMcpComputer(deps)
return { ok = false, error = tostring(closeErr), output = '' };
end
return executeSource(contentOrErr, '@' .. path, resolved);
local fileDeps = {
requireFactory = resolved.requireFactory,
requireDir = dirname(path),
};
return executeSource(contentOrErr, '@' .. path, fileDeps, {
args = argsOrErr,
runningProgram = path,
shell = resolved.shell,
});
end
function api.writeFile(path, content, fsLike)

View File

@ -35,9 +35,11 @@ The ComputerCraft side executes the code in `mcp-computer` and returns a normal
- `result.output` containing captured `print` and `write` output.
- `error` for syntax or runtime failure.
For existing Lua files, `run-file` sends a target path instead of source code. The
ComputerCraft side reads that file and loads the source into the same captured execution
environment as `exec-lua`, using the file path as the chunk name for useful errors.
For existing Lua files, `run-file` sends a target path instead of source code, with an
optional `args` array for CLI-style program arguments. The ComputerCraft side reads that
file and loads the source into the same captured execution environment as `exec-lua`,
using the file path as the chunk name for useful errors. Arguments are passed as Lua
varargs and exposed through the `arg` table.
Execution is enabled by default for any computer running the updated `mcp-computer` program. We intentionally do not add an `--allow-exec` flag for this first version because the current workflow is a local, explicitly trusted development bridge and the user accepts the risk.

View File

@ -6,7 +6,7 @@
"trapos-net": "0.3.0",
"trapos-ui": "0.2.2",
"trapos-ai": "0.7.0",
"trapos-sandbox": "0.3.2",
"trapos-sandbox": "0.3.3",
"trapos-sandbox-legacy": "0.3.0",
"trapos": "0.11.2"
}

View File

@ -1,6 +1,6 @@
{
"name": "trapos-sandbox",
"version": "0.3.2",
"version": "0.3.3",
"description": "TrapOS sandbox gateway client: WS daemon (servers/sandbox) + sandbox command + MCP exec/write server",
"dependencies": ["trapos-core"],
"files": [

View File

@ -25,7 +25,7 @@ if not el then
end
local sandbox = createSandbox();
local mcp = createMcpComputer();
local mcp = createMcpComputer({ shell = shell });
-- A code error or a failed file write is a *successful* round-trip that carries an
-- ok:false result (so captured output survives the reply); only a thrown handler or
@ -41,7 +41,7 @@ sandbox.serve('exec-lua', function(payload)
end, { eventloop = el });
sandbox.serve('run-file', function(payload)
local result = mcp.executeFile(payload and payload.path);
local result = mcp.executeFile(payload and payload.path, nil, payload and payload.args);
return true, {
ok = result.ok,
returns = result.returns or {},

View File

@ -184,6 +184,24 @@ testlib.test('executeFile injects require into the file environment', function()
testlib.assertEquals(result.returns[1].value, 'FROM_FILE');
end);
testlib.test('executeFile passes the file directory to require factory', function()
local seenDir;
local mcpComputer = createMcpComputer({
requireFactory = function(_, dir)
seenDir = dir;
return function() return 'ok'; end, {};
end,
});
local fsLike = fsWithFiles({
['/programs/tool.lua'] = "return require('dep')",
});
local result = mcpComputer.executeFile('/programs/tool.lua', fsLike);
testlib.assertEquals(result.ok, true);
testlib.assertEquals(seenDir, '/programs');
end);
testlib.test('executeFile captures output and return values from file', function()
local mcpComputer = createMcpComputer();
local fsLike = fsWithFiles({
@ -200,6 +218,46 @@ testlib.test('executeFile captures output and return values from file', function
testlib.assertEquals(result.returns[2].value, 'ok');
end);
testlib.test('executeFile passes args as varargs and arg table', function()
local mcpComputer = createMcpComputer();
local fsLike = fsWithFiles({
['/argv.lua'] = "local a, b = ...; return a, b, arg[1], arg[2]",
});
local result = mcpComputer.executeFile('/argv.lua', fsLike, { 'install', 'pkg' });
testlib.assertEquals(result.ok, true);
testlib.assertEquals(result.returns[1].value, 'install');
testlib.assertEquals(result.returns[2].value, 'pkg');
testlib.assertEquals(result.returns[3].value, 'install');
testlib.assertEquals(result.returns[4].value, 'pkg');
end);
testlib.test('executeFile validates args', function()
local mcpComputer = createMcpComputer();
local fsLike = fsWithFiles({
['/argv.lua'] = 'return true',
});
local result = mcpComputer.executeFile('/argv.lua', fsLike, { 'ok', 2 });
testlib.assertEquals(result.ok, false);
testlib.assertTrue(string.find(result.error, 'args', 1, true));
end);
testlib.test('executeFile provides shell getRunningProgram for the file', function()
local mcpComputer = createMcpComputer({ shell = { path = function() return '/rom/programs'; end } });
local fsLike = fsWithFiles({
['/programs/tool.lua'] = "return shell.getRunningProgram(), shell.path()",
});
local result = mcpComputer.executeFile('/programs/tool.lua', fsLike);
testlib.assertEquals(result.ok, true);
testlib.assertEquals(result.returns[1].value, '/programs/tool.lua');
testlib.assertEquals(result.returns[2].value, '/rom/programs');
end);
testlib.test('executeFile reports validation and missing file errors', function()
local mcpComputer = createMcpComputer();
local fsLike = fakeFs();

View File

@ -1,12 +1,12 @@
{
"name": "mcp-bridge",
"version": "0.3.0",
"version": "0.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mcp-bridge",
"version": "0.3.0",
"version": "0.3.1",
"dependencies": {
"ws": "^8.17.1"
},

View File

@ -1,6 +1,6 @@
{
"name": "mcp-bridge",
"version": "0.3.0",
"version": "0.3.1",
"private": true,
"type": "module",
"engines": {

View File

@ -95,13 +95,13 @@ export class LinkRegistry {
return formatExecutionResult(computer, result);
}
async runFile(computerId: number, path: string, timeoutMs: number): Promise<string> {
async runFile(computerId: number, path: string, args: string[], timeoutMs: number): Promise<string> {
const computer = this.computers.get(computerId);
if (!computer) {
return `No computer with id ${computerId} connected.`;
}
const result = await this.requestComputer(computer, "run-file", { path }, timeoutMs);
const result = await this.requestComputer(computer, "run-file", { path, args }, timeoutMs);
return formatExecutionResult(computer, result);
}

View File

@ -68,7 +68,7 @@ async function handleSingleRequest(request: JsonRpcRequest, registry: LinkRegist
return jsonRpcResult(id, {
protocolVersion: "2024-11-05",
capabilities: { tools: {} },
serverInfo: { name: "mcp-bridge", version: "0.3.0" },
serverInfo: { name: "mcp-bridge", version: "0.3.1" },
});
}
@ -121,6 +121,7 @@ async function handleSingleRequest(request: JsonRpcRequest, registry: LinkRegist
properties: {
computerId: { type: "number", description: "ComputerCraft computer id to run on." },
path: { type: "string", description: "Lua file path on the ComputerCraft computer." },
args: { type: "array", items: { type: "string" }, description: "Optional program arguments passed as Lua varargs." },
timeoutMs: { type: "number", description: "Optional host-side timeout in milliseconds, max 30000." },
},
required: ["computerId", "path"],
@ -167,7 +168,7 @@ async function handleSingleRequest(request: JsonRpcRequest, registry: LinkRegist
return jsonRpcError(id, -32602, parsed.error);
}
const text = await registry.runFile(parsed.computerId, parsed.path, parsed.timeoutMs);
const text = await registry.runFile(parsed.computerId, parsed.path, parsed.args, parsed.timeoutMs);
return jsonRpcResult(id, { content: [{ type: "text", text }] });
}
@ -224,7 +225,7 @@ function parseWriteFileArgs(
function parseRunFileArgs(
args: Record<string, unknown>,
defaultTimeoutMs: number,
): { ok: true; computerId: number; path: string; timeoutMs: number } | { ok: false; error: string } {
): { ok: true; computerId: number; path: string; args: string[]; timeoutMs: number } | { ok: false; error: string } {
if (typeof args.computerId !== "number" || !Number.isFinite(args.computerId)) {
return { ok: false, error: "computerId must be a finite number" };
}
@ -233,12 +234,20 @@ function parseRunFileArgs(
return { ok: false, error: "path must be a non-empty string" };
}
if (args.args !== undefined && !isStringArray(args.args)) {
return { ok: false, error: "args must be an array of strings" };
}
if (args.timeoutMs !== undefined && (typeof args.timeoutMs !== "number" || !Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
return { ok: false, error: "timeoutMs must be a positive finite number" };
}
const timeoutMs = Math.min(args.timeoutMs ?? defaultTimeoutMs, 30_000);
return { ok: true, computerId: args.computerId, path: args.path, timeoutMs };
return { ok: true, computerId: args.computerId, path: args.path, args: args.args ?? [], timeoutMs };
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item) => typeof item === "string");
}
function jsonRpcResult(id: unknown, result: unknown): unknown {

View File

@ -97,6 +97,7 @@ test("MCP tools/list includes exec-lua schema", async () => {
properties: {
computerId: { type: "number", description: "ComputerCraft computer id to run on." },
path: { type: "string", description: "Lua file path on the ComputerCraft computer." },
args: { type: "array", items: { type: "string" }, description: "Optional program arguments passed as Lua varargs." },
timeoutMs: { type: "number", description: "Optional host-side timeout in milliseconds, max 30000." },
},
required: ["computerId", "path"],
@ -168,12 +169,12 @@ test("run-file sends path to the selected computer", async () => {
const computer = new FakeSocket();
registry.register({ computerId: 12, label: "base-turtle", ws: computer as unknown as WebSocket, connectedAt: 1, lastSeenAt: 1 });
const promise = registry.runFile(12, "/helloworld.lua", 50);
const promise = registry.runFile(12, "/helloworld.lua", ["install", "pkg"], 50);
assert.deepEqual(computer.lastRequest(), {
type: "request",
id: computer.lastRequest().id,
method: "run-file",
params: { path: "/helloworld.lua" },
params: { path: "/helloworld.lua", args: ["install", "pkg"] },
});
computer.respond(registry, 12, { returns: [], output: "Blah!\n" });
@ -183,7 +184,7 @@ test("run-file sends path to the selected computer", async () => {
test("run-file reports unknown computer", async () => {
const registry = new LinkRegistry();
assert.equal(await registry.runFile(99, "/helloworld.lua", 50), "No computer with id 99 connected.");
assert.equal(await registry.runFile(99, "/helloworld.lua", [], 50), "No computer with id 99 connected.");
});
test("MCP run-file validates required arguments", async () => {
@ -207,12 +208,19 @@ test("MCP run-file returns text content", async () => {
registry.register({ computerId: 12, label: "base-turtle", ws: computer as unknown as WebSocket, connectedAt: 1, lastSeenAt: 1 });
const responsePromise = handleMcpRequest(
{ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "run-file", arguments: { computerId: 12, path: "/helloworld.lua" } } },
{
jsonrpc: "2.0",
id: 2,
method: "tools/call",
params: { name: "run-file", arguments: { computerId: 12, path: "/helloworld.lua", args: ["a", "b"] } },
},
registry,
10,
);
computer.respond(registry, 12, { returns: [], output: "Blah!\n" });
assert.deepEqual(computer.lastRequest().params, { path: "/helloworld.lua", args: ["a", "b"] });
assert.deepEqual(await responsePromise, {
jsonrpc: "2.0",
id: 2,

View File

@ -1,12 +1,12 @@
{
"name": "trap-sandbox",
"version": "0.2.1",
"version": "0.2.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "trap-sandbox",
"version": "0.2.1",
"version": "0.2.2",
"dependencies": {
"@fastify/websocket": "^11",
"@opencode-ai/sdk": "^1.17.4",

View File

@ -1,6 +1,6 @@
{
"name": "trap-sandbox",
"version": "0.2.1",
"version": "0.2.2",
"private": true,
"type": "module",
"engines": {

View File

@ -24,7 +24,7 @@ type JsonRpcRequest = {
params?: unknown;
};
const SERVER_VERSION = "0.2.1";
const SERVER_VERSION = "0.2.2";
const TOOLS = [
{
@ -56,6 +56,7 @@ const TOOLS = [
properties: {
traposId: { type: "string", description: 'Target traposId ("<id>:<label>", from probe-computers).' },
path: { type: "string", description: "Absolute Lua file path on the TrapOS computer to run, for example /programs/ccpm.lua." },
args: { type: "array", items: { type: "string" }, description: "Optional program arguments passed as Lua varargs." },
timeoutMs: { type: "number", description: "Optional host-side timeout in milliseconds, max 30000." },
},
required: ["traposId", "path"],
@ -131,7 +132,7 @@ async function handleSingle(request: JsonRpcRequest, deps: McpDeps): Promise<unk
if (!parsed.ok) {
return jsonRpcError(id, -32602, parsed.error);
}
const text = await runFile(deps, parsed.traposId, parsed.path, parsed.timeoutMs);
const text = await runFile(deps, parsed.traposId, parsed.path, parsed.args, parsed.timeoutMs);
return jsonRpcResult(id, { content: [{ type: "text", text }] });
}
@ -197,10 +198,10 @@ function formatExecutionResult(traposId: string, payload: Record<string, unknown
return lines.join("\n");
}
async function runFile(deps: McpDeps, traposId: string, path: string, timeoutMs: number): Promise<string> {
async function runFile(deps: McpDeps, traposId: string, path: string, args: string[], timeoutMs: number): Promise<string> {
let payload: Record<string, unknown>;
try {
payload = await deps.registry.request(deps.accountId, traposId, "run-file", { path }, timeoutMs);
payload = await deps.registry.request(deps.accountId, traposId, "run-file", { path, args }, timeoutMs);
} catch (err) {
return transportError(traposId, err);
}
@ -249,7 +250,7 @@ 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 };
type RunFileArgs = { ok: true; traposId: string; path: string; args: string[]; timeoutMs: number } | { ok: false; error: string };
function parseRunFileArgs(args: Record<string, unknown>, deps: McpDeps): RunFileArgs {
if (typeof args.traposId !== "string" || args.traposId.trim() === "") {
@ -258,11 +259,18 @@ function parseRunFileArgs(args: Record<string, unknown>, deps: McpDeps): RunFile
if (typeof args.path !== "string" || args.path.trim() === "") {
return { ok: false, error: "path must be a non-empty string" };
}
if (args.args !== undefined && !isStringArray(args.args)) {
return { ok: false, error: "args must be an array of strings" };
}
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 };
return { ok: true, traposId: args.traposId, path: args.path, args: args.args ?? [], timeoutMs: timeout.value };
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item) => typeof item === "string");
}
type WriteFileArgs =

View File

@ -35,24 +35,28 @@ test("MCP exec-lua / run-file / write-file / probe round-trip over the gateway",
assert.match(exec, /"value":2/); // return value serialized
assert.match(exec, /\nhi$/); // captured print output
const program = "local a, b = ...; print(a .. ':' .. b); return arg[1], arg[2], shell.getRunningProgram()";
const write = await callTool(sandbox, "write-file", {
traposId: "7:base",
path: "/mcp-it.txt",
content: "hello",
path: "/mcp-it.lua",
content: program,
});
assert.match(write, /ok: true/);
assert.match(write, /bytes: 5/);
assert.match(write, new RegExp(`bytes: ${program.length}`));
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/);
const run = await callTool(sandbox, "run-file", { traposId: "7:base", path: "/mcp-it.lua", args: ["install", "pkg"] });
assert.match(run, /^computer: 7:base\nok: true/);
assert.match(run, /"value":"install"/);
assert.match(run, /"value":"pkg"/);
assert.match(run, /"value":"\/mcp-it\.lua"/);
assert.match(run, /\ninstall:pkg$/);
// Read it back through exec-lua to prove the write actually landed.
const verify = await callTool(sandbox, "exec-lua", {
traposId: "7:base",
code: "local h = fs.open('/mcp-it.txt', 'r'); local c = h.readAll(); h.close(); return c",
code: "local h = fs.open('/mcp-it.lua', 'r'); local c = h.readAll(); h.close(); return c",
});
assert.match(verify, /"value":"hello"/);
assert.match(verify, /"shell.getRunningProgram/);
// Unknown traposId is a transport (not_connected) failure, not a crash.
const ghost = await callTool(sandbox, "exec-lua", { traposId: "9:ghost", code: "return 1" });

View File

@ -131,17 +131,50 @@ test("run-file sends the path and formats execution output", async () => {
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, {
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 as { path: string }).path, "/hello.lua");
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);