test(mcp): cover exec lua edge cases

This commit is contained in:
Guillaume ARM 2026-06-11 04:20:13 +02:00
parent 563845077e
commit 8386c165b5
5 changed files with 164 additions and 0 deletions

View File

@ -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

View File

@ -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 <id>` 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://<public-host>: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).

View File

@ -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({

View File

@ -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;

View File

@ -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);
}
}