diff --git a/apis/libmcpcomputer.lua b/apis/libmcpcomputer.lua index f4de707..bee37d6 100644 --- a/apis/libmcpcomputer.lua +++ b/apis/libmcpcomputer.lua @@ -58,6 +58,26 @@ local function createExecEnv(output) return env; end +local function executeSource(code, chunkName) + local output = {}; + + local fn, loadErr = load(code, chunkName, 't', createExecEnv(output)); + if not fn then + return { ok = false, error = tostring(loadErr), output = table.concat(output) }; + end + + local values = table.pack(pcall(fn)); + if not values[1] then + return { ok = false, error = tostring(values[2]), output = table.concat(output) }; + end + + local returns = {}; + for i = 2, values.n do + returns[#returns + 1] = serializeReturn(values[i]); + end + return { ok = true, returns = returns, output = table.concat(output) }; +end + local function createMcpComputer() local api = {}; @@ -70,26 +90,39 @@ local function createMcpComputer() end function api.executeLua(code) - local output = {}; if type(code) ~= 'string' or code == '' then return { ok = false, error = 'code must be a non-empty string', output = '' }; end - local fn, loadErr = load(code, 'mcp-exec', 't', createExecEnv(output)); - if not fn then - return { ok = false, error = tostring(loadErr), output = table.concat(output) }; + return executeSource(code, 'mcp-exec'); + end + + function api.executeFile(path, fsLike) + 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 values = table.pack(pcall(fn)); - if not values[1] then - return { ok = false, error = tostring(values[2]), output = table.concat(output) }; + local handle, openErr = fsLike.open(path, 'r'); + if not handle then + return { ok = false, error = tostring(openErr or 'failed to open file'), output = '' }; end - local returns = {}; - for i = 2, values.n do - returns[#returns + 1] = serializeReturn(values[i]); + local ok, contentOrErr = pcall(function() + return handle.readAll(); + end); + local closeOk, closeErr = pcall(function() + handle.close(); + end); + + if not ok then + return { ok = false, error = tostring(contentOrErr), output = '' }; end - return { ok = true, returns = returns, output = table.concat(output) }; + if not closeOk then + return { ok = false, error = tostring(closeErr), output = '' }; + end + + return executeSource(contentOrErr, '@' .. path); 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 9da3c4e..f803cb4 100644 --- a/docs/adrs/adr-0017-mcp-remote-lua-execution.md +++ b/docs/adrs/adr-0017-mcp-remote-lua-execution.md @@ -20,7 +20,7 @@ This is also explicitly dangerous. A linked assistant can run arbitrary Lua with ## Decision -Add an MCP tool named `exec-lua` to `tools/mcp-bridge`. +Add MCP tools named `exec-lua` and `run-file` to `tools/mcp-bridge`. The tool targets one linked computer by `computerId` and sends Lua source over the existing bridge request/response protocol: @@ -35,9 +35,13 @@ 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. + 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. -The execution environment overrides `print` and `write` so their text is captured in the MCP response instead of being emitted to the visible terminal. Code that intentionally wants to affect the ComputerCraft screen should call terminal APIs such as `term.clear`, `term.setCursorPos`, and `term.write` directly. +The execution environment overrides `print` and `write` so their text is captured in the MCP response instead of being emitted to the visible terminal. This applies to direct `exec-lua` chunks and to files run through `run-file`. Code that intentionally wants to affect the ComputerCraft screen should call terminal APIs such as `term.clear`, `term.setCursorPos`, and `term.write` directly. Timeouts are host-side request timeouts. A timed-out MCP call stops waiting for the response, but it does not preempt a running Lua chunk inside ComputerCraft. Avoid infinite loops and long blocking calls unless the in-game computer can be restarted. diff --git a/manifest.json b/manifest.json index 556cee3..3ad08d4 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { "name": "TrapOS", - "version": "0.11.0", + "version": "0.11.1", "branch": "master", "packages": [ "trapos" diff --git a/packages/index.json b/packages/index.json index 503acbb..8930721 100644 --- a/packages/index.json +++ b/packages/index.json @@ -6,8 +6,8 @@ "trapos-net": "0.3.0", "trapos-ui": "0.2.2", "trapos-ai": "0.7.0", - "trapos-sandbox": "0.3.0", + "trapos-sandbox": "0.3.1", "trapos-sandbox-legacy": "0.3.0", - "trapos": "0.11.0" + "trapos": "0.11.1" } } diff --git a/packages/trapos-sandbox/ccpm.json b/packages/trapos-sandbox/ccpm.json index 0cde0f9..525855d 100644 --- a/packages/trapos-sandbox/ccpm.json +++ b/packages/trapos-sandbox/ccpm.json @@ -1,6 +1,6 @@ { "name": "trapos-sandbox", - "version": "0.3.0", + "version": "0.3.1", "description": "TrapOS sandbox gateway client: WS daemon (servers/sandbox) + sandbox command + MCP exec/write server", "dependencies": ["trapos-core"], "files": [ diff --git a/packages/trapos/ccpm.json b/packages/trapos/ccpm.json index 14f23e2..c875c3e 100644 --- a/packages/trapos/ccpm.json +++ b/packages/trapos/ccpm.json @@ -1,6 +1,6 @@ { "name": "trapos", - "version": "0.11.0", + "version": "0.11.1", "description": "TrapOS full install meta-package", "dependencies": [ "trapos-boot", diff --git a/servers/mcp-computer-server.lua b/servers/mcp-computer-server.lua index aa720d7..5a14cc1 100644 --- a/servers/mcp-computer-server.lua +++ b/servers/mcp-computer-server.lua @@ -40,6 +40,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, { @@ -51,4 +61,4 @@ sandbox.serve('write-file', function(payload) end, { eventloop = el }); print('mcp-computer-server v' .. createVersion().forSelf() - .. ' started (serving exec-lua/write-file over sandbox).'); + .. ' started (serving exec-lua/run-file/write-file over sandbox).'); diff --git a/tests/mcpcomputer.lua b/tests/mcpcomputer.lua index c85f6a6..616557b 100644 --- a/tests/mcpcomputer.lua +++ b/tests/mcpcomputer.lua @@ -15,6 +15,18 @@ local function fakeFs() return { files = files, open = function(path, mode) + if mode == 'r' then + if files[path] == nil then + return nil, 'No such file'; + end + return { + readAll = function() + return files[path]; + end, + close = function() end, + }; + end + if mode ~= 'w' then return nil, 'unsupported mode'; end @@ -34,6 +46,14 @@ local function fakeFs() }; end +local function fsWithFiles(files) + local fsLike = fakeFs(); + for path, content in pairs(files) do + fsLike.files[path] = content; + end + return fsLike; +end + testlib.test('formatPong includes computer id and label', function() local mcpComputer = createMcpComputer(); @@ -102,6 +122,77 @@ testlib.test('executeLua leaves direct terminal writes out of captured output', testlib.assertEquals(result.returns[1].value, 'done'); end); +testlib.test('executeFile captures output and return values from file', function() + local mcpComputer = createMcpComputer(); + local fsLike = fsWithFiles({ + ['/helloworld.lua'] = "print('hello', 'file'); write('tail'); return 42, 'ok'", + }); + + local result = mcpComputer.executeFile('/helloworld.lua', fsLike); + + testlib.assertEquals(result.ok, true); + testlib.assertEquals(result.output, 'hello\tfile\ntail'); + testlib.assertEquals(result.returns[1].type, 'number'); + testlib.assertEquals(result.returns[1].value, 42); + testlib.assertEquals(result.returns[2].type, 'string'); + testlib.assertEquals(result.returns[2].value, 'ok'); +end); + +testlib.test('executeFile reports validation and missing file errors', function() + local mcpComputer = createMcpComputer(); + local fsLike = fakeFs(); + + local missingPath = mcpComputer.executeFile('', fsLike); + local missingFile = mcpComputer.executeFile('/missing.lua', fsLike); + + testlib.assertEquals(missingPath.ok, false); + testlib.assertTrue(string.find(missingPath.error, 'path', 1, true)); + testlib.assertEquals(missingPath.output, ''); + testlib.assertEquals(missingFile.ok, false); + testlib.assertTrue(string.find(missingFile.error, 'No such file', 1, true)); + testlib.assertEquals(missingFile.output, ''); +end); + +testlib.test('executeFile reports syntax errors with file chunk name', function() + local mcpComputer = createMcpComputer(); + local fsLike = fsWithFiles({ + ['/broken.lua'] = "print('unterminated'\nreturn 1", + }); + + local result = mcpComputer.executeFile('/broken.lua', fsLike); + + testlib.assertEquals(result.ok, false); + testlib.assertEquals(result.output, ''); + testlib.assertTrue(string.find(result.error, '/broken.lua', 1, true)); +end); + +testlib.test('executeFile reports runtime errors with captured output', function() + local mcpComputer = createMcpComputer(); + local fsLike = fsWithFiles({ + ['/boom.lua'] = "print('before'); error('boom', 0)", + }); + + local result = mcpComputer.executeFile('/boom.lua', fsLike); + + testlib.assertEquals(result.ok, false); + testlib.assertEquals(result.output, 'before\n'); + testlib.assertTrue(string.find(result.error, 'boom', 1, true)); +end); + +testlib.test('executeFile leaves direct terminal writes out of captured output', function() + local mcpComputer = createMcpComputer(); + local fsLike = fsWithFiles({ + ['/screen.lua'] = "term.write('visible'); return 'done'", + }); + + local result = mcpComputer.executeFile('/screen.lua', fsLike); + + testlib.assertEquals(result.ok, true); + testlib.assertEquals(result.output, ''); + testlib.assertEquals(result.returns[1].type, 'string'); + testlib.assertEquals(result.returns[1].value, 'done'); +end); + testlib.test('writeFile writes and overwrites file content', function() local mcpComputer = createMcpComputer(); local fsLike = fakeFs(); diff --git a/tools/mcp-bridge/src/link-server.ts b/tools/mcp-bridge/src/link-server.ts index 9047ca3..e3a0f87 100644 --- a/tools/mcp-bridge/src/link-server.ts +++ b/tools/mcp-bridge/src/link-server.ts @@ -92,7 +92,17 @@ export class LinkRegistry { } const result = await this.requestComputer(computer, "exec-lua", { code }, timeoutMs); - return formatExecLuaResult(computer, result); + return formatExecutionResult(computer, result); + } + + async runFile(computerId: number, path: 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); + return formatExecutionResult(computer, result); } async writeFile(computerId: number, path: string, content: string, timeoutMs: number): Promise { @@ -141,7 +151,7 @@ export class LinkRegistry { } } -function formatExecLuaResult(computer: ComputerConnection, response: ResponseMessage | null): string { +function formatExecutionResult(computer: ComputerConnection, response: ResponseMessage | null): string { const computerText = formatComputer(computer.computerId, computer.label); if (!response) { return `computer: ${computerText}\nok: false\nerror: timeout`; @@ -149,12 +159,13 @@ function formatExecLuaResult(computer: ComputerConnection, response: ResponseMes const payload = isRecord(response.result) ? response.result : {}; const output = typeof payload.output === "string" ? payload.output : ""; - const lines = [`computer: ${computerText}`, `ok: ${response.ok ? "true" : "false"}`]; + const ok = response.ok && payload.ok !== false; + const lines = [`computer: ${computerText}`, `ok: ${ok ? "true" : "false"}`]; - if (response.ok) { + if (ok) { lines.push(`returns: ${JSON.stringify(Array.isArray(payload.returns) ? payload.returns : [])}`); } else { - lines.push(`error: ${response.error ?? "unknown error"}`); + lines.push(`error: ${typeof payload.error === "string" ? payload.error : response.error ?? "unknown error"}`); } lines.push("output:"); @@ -171,13 +182,14 @@ function formatWriteFileResult(computer: ComputerConnection, response: ResponseM } const payload = isRecord(response.result) ? response.result : {}; - const lines = [`computer: ${computerText}`, `ok: ${response.ok ? "true" : "false"}`]; + const ok = response.ok && payload.ok !== false; + const lines = [`computer: ${computerText}`, `ok: ${ok ? "true" : "false"}`]; - if (response.ok) { + if (ok) { lines.push(`path: ${typeof payload.path === "string" ? payload.path : ""}`); lines.push(`bytes: ${typeof payload.bytes === "number" ? payload.bytes : 0}`); } else { - lines.push(`error: ${response.error ?? "unknown error"}`); + lines.push(`error: ${typeof payload.error === "string" ? payload.error : response.error ?? "unknown error"}`); } return lines.join("\n"); diff --git a/tools/mcp-bridge/src/mcp-server.ts b/tools/mcp-bridge/src/mcp-server.ts index ddf86fc..a5b21bd 100644 --- a/tools/mcp-bridge/src/mcp-server.ts +++ b/tools/mcp-bridge/src/mcp-server.ts @@ -113,6 +113,20 @@ async function handleSingleRequest(request: JsonRpcRequest, registry: LinkRegist additionalProperties: false, }, }, + { + name: "run-file", + description: "Run a Lua file on a linked ComputerCraft computer with captured print/write output.", + inputSchema: { + type: "object", + properties: { + computerId: { type: "number", description: "ComputerCraft computer id to run on." }, + path: { type: "string", description: "Lua file path on the ComputerCraft computer." }, + timeoutMs: { type: "number", description: "Optional host-side timeout in milliseconds, max 30000." }, + }, + required: ["computerId", "path"], + additionalProperties: false, + }, + }, ], }); } @@ -146,6 +160,17 @@ async function handleSingleRequest(request: JsonRpcRequest, registry: LinkRegist return jsonRpcResult(id, { content: [{ type: "text", text }] }); } + if (params.name === "run-file") { + const args = isRecord(params.arguments) ? params.arguments : {}; + const parsed = parseRunFileArgs(args, probeTimeoutMs); + if (!parsed.ok) { + return jsonRpcError(id, -32602, parsed.error); + } + + const text = await registry.runFile(parsed.computerId, parsed.path, parsed.timeoutMs); + return jsonRpcResult(id, { content: [{ type: "text", text }] }); + } + return jsonRpcError(id, -32602, "Unknown tool"); } @@ -196,6 +221,26 @@ function parseWriteFileArgs( return { ok: true, computerId: args.computerId, path: args.path, content: args.content, timeoutMs }; } +function parseRunFileArgs( + args: Record, + defaultTimeoutMs: number, +): { ok: true; computerId: number; path: 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" }; + } + + if (typeof args.path !== "string" || args.path.trim() === "") { + return { ok: false, error: "path must be a non-empty string" }; + } + + 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 }; +} + function jsonRpcResult(id: unknown, result: unknown): unknown { return { jsonrpc: "2.0", id, result }; } diff --git a/tools/mcp-bridge/test/probe-computers.test.ts b/tools/mcp-bridge/test/probe-computers.test.ts index ff1697e..c1d71b7 100644 --- a/tools/mcp-bridge/test/probe-computers.test.ts +++ b/tools/mcp-bridge/test/probe-computers.test.ts @@ -89,6 +89,20 @@ test("MCP tools/list includes exec-lua schema", async () => { additionalProperties: false, }, }, + { + name: "run-file", + description: "Run a Lua file on a linked ComputerCraft computer with captured print/write output.", + inputSchema: { + type: "object", + properties: { + computerId: { type: "number", description: "ComputerCraft computer id to run on." }, + path: { type: "string", description: "Lua file path on the ComputerCraft computer." }, + timeoutMs: { type: "number", description: "Optional host-side timeout in milliseconds, max 30000." }, + }, + required: ["computerId", "path"], + additionalProperties: false, + }, + }, ], }, }); @@ -131,6 +145,17 @@ test("exec-lua formats computer error responses", async () => { assert.equal(await promise, "computer: 12 (Label: base-turtle)\nok: false\nerror: boom\noutput:\nbefore"); }); +test("exec-lua formats ok false execution results", async () => { + const registry = new LinkRegistry(); + const computer = new FakeSocket(); + registry.register({ computerId: 12, label: "base-turtle", ws: computer as unknown as WebSocket, connectedAt: 1, lastSeenAt: 1 }); + + const promise = registry.execLua(12, "print('before'); error('boom')", 50); + computer.respond(registry, 12, { ok: false, error: "boom", output: "before\n" }); + + assert.equal(await promise, "computer: 12 (Label: base-turtle)\nok: false\nerror: boom\noutput:\nbefore"); +}); + test("exec-lua reports timeouts", async () => { const registry = new LinkRegistry(); registry.register({ computerId: 12, label: "base-turtle", ws: new FakeSocket() as unknown as WebSocket, connectedAt: 1, lastSeenAt: 1 }); @@ -138,6 +163,63 @@ test("exec-lua reports timeouts", async () => { assert.equal(await registry.execLua(12, "while true do end", 5), "computer: 12 (Label: base-turtle)\nok: false\nerror: timeout"); }); +test("run-file sends path to the selected computer", async () => { + const registry = new LinkRegistry(); + 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); + assert.deepEqual(computer.lastRequest(), { + type: "request", + id: computer.lastRequest().id, + method: "run-file", + params: { path: "/helloworld.lua" }, + }); + + computer.respond(registry, 12, { returns: [], output: "Blah!\n" }); + assert.equal(await promise, "computer: 12 (Label: base-turtle)\nok: true\nreturns: []\noutput:\nBlah!"); +}); + +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."); +}); + +test("MCP run-file validates required arguments", async () => { + const registry = new LinkRegistry(); + const response = await handleMcpRequest( + { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "run-file", arguments: { computerId: 1 } } }, + registry, + 10, + ); + + assert.deepEqual(response, { + jsonrpc: "2.0", + id: 2, + error: { code: -32602, message: "path must be a non-empty string" }, + }); +}); + +test("MCP run-file returns text content", async () => { + const registry = new LinkRegistry(); + const computer = new FakeSocket(); + 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" } } }, + registry, + 10, + ); + computer.respond(registry, 12, { returns: [], output: "Blah!\n" }); + + assert.deepEqual(await responsePromise, { + jsonrpc: "2.0", + id: 2, + result: { content: [{ type: "text", text: "computer: 12 (Label: base-turtle)\nok: true\nreturns: []\noutput:\nBlah!" }] }, + }); +}); + test("MCP exec-lua validates required arguments", async () => { const registry = new LinkRegistry(); const response = await handleMcpRequest( @@ -202,6 +284,17 @@ test("write-file formats computer error responses", async () => { assert.equal(await promise, "computer: 12 (Label: base-turtle)\nok: false\nerror: No such file"); }); +test("write-file formats ok false operation results", async () => { + const registry = new LinkRegistry(); + const computer = new FakeSocket(); + registry.register({ computerId: 12, label: "base-turtle", ws: computer as unknown as WebSocket, connectedAt: 1, lastSeenAt: 1 }); + + const promise = registry.writeFile(12, "missing/note.txt", "hello", 50); + computer.respond(registry, 12, { ok: false, error: "No such file" }); + + assert.equal(await promise, "computer: 12 (Label: base-turtle)\nok: false\nerror: No such file"); +}); + test("write-file reports timeouts", async () => { const registry = new LinkRegistry(); registry.register({ computerId: 12, label: "base-turtle", ws: new FakeSocket() as unknown as WebSocket, connectedAt: 1, lastSeenAt: 1 });