From 8386c165b54f8179c24b6cbb89e6b7a2367e3007 Mon Sep 17 00:00:00 2001 From: Guillaume ARM Date: Thu, 11 Jun 2026 04:20:13 +0200 Subject: [PATCH] test(mcp): cover exec lua edge cases --- .../adrs/adr-0017-mcp-remote-lua-execution.md | 3 + docs/ingame-trapos-ai-mcp-guide.md | 17 +++++ tests/mcpcomputer.lua | 59 +++++++++++++++ .../exec-lua-real-program.test.ts | 12 +++ tools/mcp-bridge/test/probe-computers.test.ts | 73 +++++++++++++++++++ 5 files changed, 164 insertions(+) diff --git a/docs/adrs/adr-0017-mcp-remote-lua-execution.md b/docs/adrs/adr-0017-mcp-remote-lua-execution.md index b391417..e471ef1 100644 --- a/docs/adrs/adr-0017-mcp-remote-lua-execution.md +++ b/docs/adrs/adr-0017-mcp-remote-lua-execution.md @@ -35,6 +35,8 @@ The ComputerCraft side executes the code in `mcp-computer` and returns a normal 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. + 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. ## Consequences @@ -42,6 +44,7 @@ Timeouts are host-side request timeouts. A timed-out MCP call stops waiting for - OpenCode can now inspect and operate linked ComputerCraft computers without manual in-game command entry. - The trust boundary moves to the act of running `mcp-computer` against a bridge. Only run it against bridges and assistants you trust. - The bridge remains protocol-compatible with older clients for `probe-computers`; older `mcp-computer` clients will report `unknown method` for `exec-lua`. +- Captured output is deterministic for assistant workflows; visible screen mutation remains explicit through `term.*` APIs. - Tests must cover both host-side MCP routing and the real CraftOS-PC `mcp-computer` path so regressions are caught across the Node/Lua boundary described in [ADR-0016](adr-0016-js-tool-verification.md). ## Future Work diff --git a/docs/ingame-trapos-ai-mcp-guide.md b/docs/ingame-trapos-ai-mcp-guide.md index f4f6f63..4bd16f8 100644 --- a/docs/ingame-trapos-ai-mcp-guide.md +++ b/docs/ingame-trapos-ai-mcp-guide.md @@ -100,11 +100,28 @@ http://127.0.0.1:3000 Then ask OpenCode to use the MCP tool `probe-computers`. A working link returns a `pong from ` line. +The bridge also exposes `exec-lua`, which runs Lua on one linked computer by id. For example, this returns captured output to OpenCode: + +```lua +print('captured in MCP output') +``` + +To write to the visible ComputerCraft screen, use terminal APIs directly: + +```lua +term.clear() +term.setCursorPos(1, 1) +term.write('visible on screen') +``` + +`exec-lua` is powerful and unsafe by design: it can do anything the linked computer can do, including file, peripheral, turtle, and reboot operations. Only run `mcp-computer` against a bridge you trust. + ## Quick Fixes - `ai` says missing `opencc.server_url`: run the `set opencc.server_url ...` command again. - `ai` cannot reach server: check `opencode serve`, public host, port `4242`, and ComputerCraft HTTP rules. - `mcp-computer` says WebSocket unavailable: enable ComputerCraft HTTP/WebSocket support. - MCP sees no computers: keep `mcp-computer ws://:4243` running in-game. +- `exec-lua` is missing after updating the bridge: restart OpenCode so it reloads the MCP tool list. More detail: [`opencode_server_guide.md`](opencode_server_guide.md), [`public-ports.md`](public-ports.md). diff --git a/tests/mcpcomputer.lua b/tests/mcpcomputer.lua index 0c0b8b8..4a6158d 100644 --- a/tests/mcpcomputer.lua +++ b/tests/mcpcomputer.lua @@ -94,6 +94,39 @@ testlib.test('executeLua reports runtime errors with captured output', function( testlib.assertTrue(string.find(result.error, 'boom', 1, true)); end); +testlib.test('executeLua reports syntax errors', function() + local mcpComputer = createMcpComputer(); + local result = mcpComputer.executeLua("print('unterminated'\nreturn 1"); + + testlib.assertEquals(result.ok, false); + testlib.assertEquals(result.output, ''); + testlib.assertTrue(string.find(result.error, 'expected', 1, true) + or string.find(result.error, 'near', 1, true)); +end); + +testlib.test('executeLua serializes non-json return values as descriptors', function() + local mcpComputer = createMcpComputer(); + local result = mcpComputer.executeLua('return { answer = 42 }, function() end, coroutine.create(function() end)'); + + testlib.assertEquals(result.ok, true); + testlib.assertEquals(result.returns[1].type, 'table'); + testlib.assertTrue(type(result.returns[1].repr) == 'string'); + testlib.assertEquals(result.returns[2].type, 'function'); + testlib.assertTrue(type(result.returns[2].repr) == 'string'); + testlib.assertEquals(result.returns[3].type, 'thread'); + testlib.assertTrue(type(result.returns[3].repr) == 'string'); +end); + +testlib.test('executeLua leaves direct terminal writes out of captured output', function() + local mcpComputer = createMcpComputer(); + local result = mcpComputer.executeLua("term.write('visible'); return 'done'"); + + 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('handleRequest executes lua code', function() local mcpComputer = createMcpComputer(); local response = mcpComputer.handleRequest({ @@ -111,6 +144,32 @@ testlib.test('handleRequest executes lua code', function() testlib.assertEquals(response.result.returns[1].value, true); end); +testlib.test('handleRequest reports invalid exec-lua code', function() + local mcpComputer = createMcpComputer(); + local response = mcpComputer.handleRequest({ + type = 'request', + id = 'req-empty-exec', + method = 'exec-lua', + params = { code = '' }, + }, fakeOs(42, 'worker')); + + testlib.assertEquals(response.type, 'response'); + testlib.assertEquals(response.id, 'req-empty-exec'); + testlib.assertEquals(response.ok, false); + testlib.assertEquals(response.result.output, ''); + testlib.assertTrue(string.find(response.error, 'non-empty string', 1, true)); + + local missing = mcpComputer.handleRequest({ + type = 'request', + id = 'req-missing-exec', + method = 'exec-lua', + params = {}, + }, fakeOs(42, 'worker')); + + testlib.assertEquals(missing.ok, false); + testlib.assertTrue(string.find(missing.error, 'non-empty string', 1, true)); +end); + testlib.test('handleRequest reports unknown methods', function() local mcpComputer = createMcpComputer(); local response = mcpComputer.handleRequest({ diff --git a/tools/mcp-bridge/test-integration/exec-lua-real-program.test.ts b/tools/mcp-bridge/test-integration/exec-lua-real-program.test.ts index 3cfbe66..3473c92 100644 --- a/tools/mcp-bridge/test-integration/exec-lua-real-program.test.ts +++ b/tools/mcp-bridge/test-integration/exec-lua-real-program.test.ts @@ -15,6 +15,18 @@ test("exec-lua runs code through the real TrapOS mcp-computer program", async () assert.match(text, /^computer: 0 \(Label: null\)\nok: true\nreturns: \[\{.*\}\]\noutput:\nhello from exec$/); assert.match(text, /"type":"number"/); assert.match(text, /"value":5/); + + const runtimeError = await callExecLua(bridge.mcpUrl, 0, "print('before runtime error'); error('boom', 0)"); + assert.equal(runtimeError, "computer: 0 (Label: null)\nok: false\nerror: boom\noutput:\nbefore runtime error"); + + const syntaxError = await callExecLua(bridge.mcpUrl, 0, "print('unterminated'\nreturn 1"); + assert.match(syntaxError, /^computer: 0 \(Label: null\)\nok: false\nerror: .+\noutput:$/); + assert.match(syntaxError, /near|expected|unexpected/); + + const visibleWrite = await callExecLua(bridge.mcpUrl, 0, "term.clear(); term.setCursorPos(1, 1); term.write('visible'); return 'done'"); + assert.match(visibleWrite, /^computer: 0 \(Label: null\)\nok: true\nreturns: \[\{.*\}\]\noutput:$/); + assert.match(visibleWrite, /"type":"string"/); + assert.match(visibleWrite, /"value":"done"/); } catch (error) { craftos.abort(); const result = await craftos.done; diff --git a/tools/mcp-bridge/test/probe-computers.test.ts b/tools/mcp-bridge/test/probe-computers.test.ts index d134804..d928eed 100644 --- a/tools/mcp-bridge/test/probe-computers.test.ts +++ b/tools/mcp-bridge/test/probe-computers.test.ts @@ -46,6 +46,39 @@ test("MCP tool call returns text content", async () => { }); }); +test("MCP tools/list includes exec-lua schema", async () => { + const registry = new LinkRegistry(); + const response = await handleMcpRequest({ jsonrpc: "2.0", id: 1, method: "tools/list" }, registry, 10); + + assert.deepEqual(response, { + jsonrpc: "2.0", + id: 1, + result: { + tools: [ + { + name: "probe-computers", + description: "Probe all linked ComputerCraft computers.", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + }, + { + name: "exec-lua", + description: "Execute Lua code on a linked ComputerCraft computer.", + inputSchema: { + type: "object", + properties: { + computerId: { type: "number", description: "ComputerCraft computer id to execute on." }, + code: { type: "string", description: "Lua source code to execute." }, + timeoutMs: { type: "number", description: "Optional host-side timeout in milliseconds, max 30000." }, + }, + required: ["computerId", "code"], + additionalProperties: false, + }, + }, + ], + }, + }); +}); + test("exec-lua sends code to the selected computer", async () => { const registry = new LinkRegistry(); const computer = new FakeSocket(); @@ -72,6 +105,24 @@ test("exec-lua reports unknown computer", async () => { assert.equal(await registry.execLua(99, "return 1", 50), "No computer with id 99 connected."); }); +test("exec-lua formats computer error responses", 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.respondError(registry, 12, "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 }); + + assert.equal(await registry.execLua(12, "while true do end", 5), "computer: 12 (Label: base-turtle)\nok: false\nerror: timeout"); +}); + test("MCP exec-lua validates required arguments", async () => { const registry = new LinkRegistry(); const response = await handleMcpRequest( @@ -87,6 +138,21 @@ test("MCP exec-lua validates required arguments", async () => { }); }); +test("MCP exec-lua validates timeoutMs", async () => { + const registry = new LinkRegistry(); + const response = await handleMcpRequest( + { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "exec-lua", arguments: { computerId: 1, code: "return 1", timeoutMs: 0 } } }, + registry, + 10, + ); + + assert.deepEqual(response, { + jsonrpc: "2.0", + id: 2, + error: { code: -32602, message: "timeoutMs must be a positive finite number" }, + }); +}); + class FakeSocket { sent: unknown[] = []; @@ -110,4 +176,11 @@ class FakeSocket { assert.equal(computerId > 0, true); } + + respondError(registry: LinkRegistry, computerId: number, error: string, result: unknown): void { + const request = this.lastRequest(); + registry.handleFrame(this as unknown as WebSocket, JSON.stringify({ type: "response", id: request.id, ok: false, error, result })); + + assert.equal(computerId > 0, true); + } }