diff --git a/AGENTS.md b/AGENTS.md index 72f620d..2e1dfc3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ''` / `just craftos-exec ''`: 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. diff --git a/apis/libmcpcomputer.lua b/apis/libmcpcomputer.lua index 18c66ea..7e4064e 100644 --- a/apis/libmcpcomputer.lua +++ b/apis/libmcpcomputer.lua @@ -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) diff --git a/docs/adrs/adr-0017-mcp-remote-lua-execution.md b/docs/adrs/adr-0017-mcp-remote-lua-execution.md index f803cb4..03710d1 100644 --- a/docs/adrs/adr-0017-mcp-remote-lua-execution.md +++ b/docs/adrs/adr-0017-mcp-remote-lua-execution.md @@ -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. diff --git a/packages/index.json b/packages/index.json index 146586e..d9cfc4b 100644 --- a/packages/index.json +++ b/packages/index.json @@ -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" } diff --git a/packages/trapos-sandbox/ccpm.json b/packages/trapos-sandbox/ccpm.json index 52ddf90..32a2835 100644 --- a/packages/trapos-sandbox/ccpm.json +++ b/packages/trapos-sandbox/ccpm.json @@ -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": [ diff --git a/servers/mcp-computer-server.lua b/servers/mcp-computer-server.lua index 5a14cc1..42043e6 100644 --- a/servers/mcp-computer-server.lua +++ b/servers/mcp-computer-server.lua @@ -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 {}, diff --git a/tests/mcpcomputer.lua b/tests/mcpcomputer.lua index ab7e30d..33a1358 100644 --- a/tests/mcpcomputer.lua +++ b/tests/mcpcomputer.lua @@ -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(); diff --git a/tools/mcp-bridge/package-lock.json b/tools/mcp-bridge/package-lock.json index 426cda6..1349cc0 100644 --- a/tools/mcp-bridge/package-lock.json +++ b/tools/mcp-bridge/package-lock.json @@ -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" }, diff --git a/tools/mcp-bridge/package.json b/tools/mcp-bridge/package.json index b94396f..1c03ffe 100644 --- a/tools/mcp-bridge/package.json +++ b/tools/mcp-bridge/package.json @@ -1,6 +1,6 @@ { "name": "mcp-bridge", - "version": "0.3.0", + "version": "0.3.1", "private": true, "type": "module", "engines": { diff --git a/tools/mcp-bridge/src/link-server.ts b/tools/mcp-bridge/src/link-server.ts index e3a0f87..2ad372a 100644 --- a/tools/mcp-bridge/src/link-server.ts +++ b/tools/mcp-bridge/src/link-server.ts @@ -95,13 +95,13 @@ export class LinkRegistry { return formatExecutionResult(computer, result); } - async runFile(computerId: number, path: string, timeoutMs: number): Promise { + async runFile(computerId: number, path: string, args: string[], timeoutMs: number): Promise { 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); } diff --git a/tools/mcp-bridge/src/mcp-server.ts b/tools/mcp-bridge/src/mcp-server.ts index a5b21bd..aebb69e 100644 --- a/tools/mcp-bridge/src/mcp-server.ts +++ b/tools/mcp-bridge/src/mcp-server.ts @@ -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, 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 { diff --git a/tools/mcp-bridge/test/probe-computers.test.ts b/tools/mcp-bridge/test/probe-computers.test.ts index c1d71b7..fad5fb7 100644 --- a/tools/mcp-bridge/test/probe-computers.test.ts +++ b/tools/mcp-bridge/test/probe-computers.test.ts @@ -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, diff --git a/tools/trap-sandbox/package-lock.json b/tools/trap-sandbox/package-lock.json index 6311f28..c149a6d 100644 --- a/tools/trap-sandbox/package-lock.json +++ b/tools/trap-sandbox/package-lock.json @@ -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", diff --git a/tools/trap-sandbox/package.json b/tools/trap-sandbox/package.json index 47f70bd..374e589 100644 --- a/tools/trap-sandbox/package.json +++ b/tools/trap-sandbox/package.json @@ -1,6 +1,6 @@ { "name": "trap-sandbox", - "version": "0.2.1", + "version": "0.2.2", "private": true, "type": "module", "engines": { diff --git a/tools/trap-sandbox/src/modules/mcp/tools.ts b/tools/trap-sandbox/src/modules/mcp/tools.ts index 96302fa..4068804 100644 --- a/tools/trap-sandbox/src/modules/mcp/tools.ts +++ b/tools/trap-sandbox/src/modules/mcp/tools.ts @@ -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 (":