feat(mcp): add remote lua execution

This commit is contained in:
Guillaume ARM 2026-06-11 03:50:21 +02:00
parent e0439ac6c5
commit e30f2af2fd
13 changed files with 382 additions and 26 deletions

View File

@ -9,6 +9,48 @@ local function formatLabel(label)
return label;
end
local function appendOutput(output, value)
output[#output + 1] = tostring(value);
end
local function serializeReturn(value)
local valueType = type(value);
if valueType == 'nil' then
return { type = 'nil' };
end
if valueType == 'string' or valueType == 'number' or valueType == 'boolean' then
return { type = valueType, value = value };
end
local ok, serialized = pcall(textutils.serialize, value);
if ok then
return { type = valueType, repr = serialized };
end
return { type = valueType, repr = tostring(value) };
end
local function createExecEnv(output)
local env = setmetatable({}, { __index = _G });
env.write = function(value)
appendOutput(output, value);
end;
env.print = function(...)
local values = table.pack(...);
for i = 1, values.n do
if i > 1 then
appendOutput(output, '\t');
end
appendOutput(output, values[i]);
end
appendOutput(output, '\n');
end;
return env;
end
local function createMcpComputer()
local api = {};
@ -63,6 +105,29 @@ 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) };
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
function api.handleRequest(request, osLike)
if type(request) ~= 'table' or request.type ~= 'request' or type(request.id) ~= 'string' then
return nil;
@ -77,6 +142,21 @@ local function createMcpComputer()
};
end
if request.method == 'exec-lua' then
local params = request.params;
local result = api.executeLua(type(params) == 'table' and params.code or nil);
return {
type = 'response',
id = request.id,
ok = result.ok,
result = {
returns = result.returns or {},
output = result.output or '',
},
error = result.error,
};
end
return {
type = 'response',
id = request.id,

View File

@ -15,5 +15,6 @@ Future ADRs can reuse the shape of the existing files when it is useful.
- [`adr-0010-ccpm-package-manager.md`](adr-0010-ccpm-package-manager.md) — `ccpm` package manager (packages, registries, package-aware bootstrap).
- [`adr-0011-repo-conventions.md`](adr-0011-repo-conventions.md) — Git hooks own commit/push verification; markdown link syntax for cross-references.
- [`adr-0016-js-tool-verification.md`](adr-0016-js-tool-verification.md) — JavaScript/TypeScript tool build, check, test, CI, and future integration-test split.
- [`adr-0017-mcp-remote-lua-execution.md`](adr-0017-mcp-remote-lua-execution.md) — MCP `exec-lua` remote execution for linked ComputerCraft computers.
Gaps in numbering (0003, 0004, 0006, 0008, 0009, 0012, 0013, 0014, 0015) are records that were either superseded by later decisions or consolidated into the surviving ADRs above.

View File

@ -0,0 +1,51 @@
# ADR 0017: MCP Remote Lua Execution
## Status
Accepted
## Date
2026-06-11
## Context
`tools/mcp-bridge` links OpenCode MCP tools to ComputerCraft computers through the `mcp-computer` WebSocket program. The initial bridge only supported `probe-computers`, which proved that the host could reach linked computers but did not let the assistant inspect or modify in-game state directly.
For interactive TrapOS development, a remote Lua execution tool is useful: it can inspect files, peripherals, settings, labels, inventory-adjacent APIs, service state, and small runtime hypotheses without asking the player to type each command manually in-game.
This is also explicitly dangerous. A linked assistant can run arbitrary Lua with the same authority as the ComputerCraft computer. That includes file deletion, network traffic, peripheral operations, turtle movement, inventory changes, reboot/shutdown calls, and long-running or stuck programs.
## Decision
Add an MCP tool named `exec-lua` to `tools/mcp-bridge`.
The tool targets one linked computer by `computerId` and sends Lua source over the existing bridge request/response protocol:
```json
{ "type": "request", "id": "...", "method": "exec-lua", "params": { "code": "..." } }
```
The ComputerCraft side executes the code in `mcp-computer` and returns a normal bridge response with:
- `ok` for execution success or failure.
- `result.returns` as JSON-safe descriptors of returned values.
- `result.output` containing captured `print` and `write` output.
- `error` for syntax or runtime failure.
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.
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
- 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`.
- 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
- Add a separate opt-in flag or per-computer policy if the bridge is used outside local trusted development.
- Add cooperative cancellation for yielding chunks if long-running remote execution becomes common.
- Add additional tools on top of `exec-lua` for common safe operations once usage patterns are clear.

View File

@ -6,7 +6,7 @@
"trapos-net": "0.3.0",
"trapos-ui": "0.2.2",
"trapos-ai": "0.6.4",
"trapos-sandbox": "0.1.1",
"trapos-sandbox": "0.1.2",
"trapos": "0.8.5"
}
}

View File

@ -1,6 +1,6 @@
{
"name": "trapos-sandbox",
"version": "0.1.1",
"version": "0.1.2",
"description": "TrapOS sandbox programs for ccpm experiments and Lua learning",
"dependencies": ["trapos-core"],
"files": [

View File

@ -72,6 +72,45 @@ testlib.test('handleRequest responds to ping', function()
testlib.assertEquals(response.result, 'pong from 42 (Label: worker)');
end);
testlib.test('executeLua captures output and return values', function()
local mcpComputer = createMcpComputer();
local result = mcpComputer.executeLua("print('hello', 'world'); write('tail'); return 2 + 3, nil, 'ok'");
testlib.assertEquals(result.ok, true);
testlib.assertEquals(result.output, 'hello\tworld\ntail');
testlib.assertEquals(result.returns[1].type, 'number');
testlib.assertEquals(result.returns[1].value, 5);
testlib.assertEquals(result.returns[2].type, 'nil');
testlib.assertEquals(result.returns[3].type, 'string');
testlib.assertEquals(result.returns[3].value, 'ok');
end);
testlib.test('executeLua reports runtime errors with captured output', function()
local mcpComputer = createMcpComputer();
local result = mcpComputer.executeLua("print('before'); error('boom', 0)");
testlib.assertEquals(result.ok, false);
testlib.assertEquals(result.output, 'before\n');
testlib.assertTrue(string.find(result.error, 'boom', 1, true));
end);
testlib.test('handleRequest executes lua code', function()
local mcpComputer = createMcpComputer();
local response = mcpComputer.handleRequest({
type = 'request',
id = 'req-exec',
method = 'exec-lua',
params = { code = "print('ran'); return true" },
}, fakeOs(42, 'worker'));
testlib.assertEquals(response.type, 'response');
testlib.assertEquals(response.id, 'req-exec');
testlib.assertEquals(response.ok, true);
testlib.assertEquals(response.result.output, 'ran\n');
testlib.assertEquals(response.result.returns[1].type, 'boolean');
testlib.assertEquals(response.result.returns[1].value, true);
end);
testlib.test('handleRequest reports unknown methods', function()
local mcpComputer = createMcpComputer();
local response = mcpComputer.handleRequest({

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "mcp-bridge",
"version": "0.1.0",
"version": "0.2.0",
"private": true,
"type": "module",
"scripts": {

View File

@ -85,21 +85,18 @@ export class LinkRegistry {
return lines.join("\n");
}
async execLua(computerId: number, code: 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, "exec-lua", { code }, timeoutMs);
return formatExecLuaResult(computer, result);
}
private async probeComputer(computer: ComputerConnection, timeoutMs: number): Promise<string> {
const id = randomUUID();
const response = new Promise<ResponseMessage>((resolve) => {
this.pending.set(id, { computerId: computer.computerId, resolve });
computer.ws.send(JSON.stringify({ type: "request", id, method: "ping" }));
});
const timeout = new Promise<null>((resolve) => {
setTimeout(() => {
this.pending.delete(id);
resolve(null);
}, timeoutMs);
});
const result = await Promise.race([response, timeout]);
const result = await this.requestComputer(computer, "ping", undefined, timeoutMs);
if (!result) {
return `timeout from ${formatComputer(computer.computerId, computer.label)}`;
}
@ -110,6 +107,55 @@ export class LinkRegistry {
return `error from ${formatComputer(computer.computerId, computer.label)}: ${result.error ?? "unknown error"}`;
}
private async requestComputer(
computer: ComputerConnection,
method: string,
params: Record<string, unknown> | undefined,
timeoutMs: number,
): Promise<ResponseMessage | null> {
const id = randomUUID();
const response = new Promise<ResponseMessage>((resolve) => {
this.pending.set(id, { computerId: computer.computerId, resolve });
computer.ws.send(JSON.stringify(params ? { type: "request", id, method, params } : { type: "request", id, method }));
});
const timeout = new Promise<null>((resolve) => {
setTimeout(() => {
this.pending.delete(id);
resolve(null);
}, timeoutMs);
});
return Promise.race([response, timeout]);
}
}
function formatExecLuaResult(computer: ComputerConnection, response: ResponseMessage | null): string {
const computerText = formatComputer(computer.computerId, computer.label);
if (!response) {
return `computer: ${computerText}\nok: false\nerror: timeout`;
}
const payload = isRecord(response.result) ? response.result : {};
const output = typeof payload.output === "string" ? payload.output : "";
const lines = [`computer: ${computerText}`, `ok: ${response.ok ? "true" : "false"}`];
if (response.ok) {
lines.push(`returns: ${JSON.stringify(Array.isArray(payload.returns) ? payload.returns : [])}`);
} else {
lines.push(`error: ${response.error ?? "unknown error"}`);
}
lines.push("output:");
if (output !== "") {
lines.push(output.endsWith("\n") ? output.slice(0, -1) : output);
}
return lines.join("\n");
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function startLinkServer(options: { host: string; port: number; registry: LinkRegistry }): WebSocketServer {

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.1.0" },
serverInfo: { name: "mcp-bridge", version: "0.2.0" },
});
}
@ -84,23 +84,68 @@ async function handleSingleRequest(request: JsonRpcRequest, registry: LinkRegist
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,
},
},
],
});
}
if (request.method === "tools/call") {
const params = isRecord(request.params) ? request.params : {};
if (params.name !== "probe-computers") {
return jsonRpcError(id, -32602, "Unknown tool");
if (params.name === "probe-computers") {
const text = await registry.probeComputers(probeTimeoutMs);
return jsonRpcResult(id, { content: [{ type: "text", text }] });
}
const text = await registry.probeComputers(probeTimeoutMs);
return jsonRpcResult(id, { content: [{ type: "text", text }] });
if (params.name === "exec-lua") {
const args = isRecord(params.arguments) ? params.arguments : {};
const parsed = parseExecLuaArgs(args, probeTimeoutMs);
if (!parsed.ok) {
return jsonRpcError(id, -32602, parsed.error);
}
const text = await registry.execLua(parsed.computerId, parsed.code, parsed.timeoutMs);
return jsonRpcResult(id, { content: [{ type: "text", text }] });
}
return jsonRpcError(id, -32602, "Unknown tool");
}
return jsonRpcError(id, -32601, "Method not found");
}
function parseExecLuaArgs(
args: Record<string, unknown>,
defaultTimeoutMs: number,
): { ok: true; computerId: number; code: 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.code !== "string" || args.code.trim() === "") {
return { ok: false, error: "code 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, code: args.code, timeoutMs };
}
function jsonRpcResult(id: unknown, result: unknown): unknown {
return { jsonrpc: "2.0", id, result };
}

View File

@ -0,0 +1,27 @@
import assert from "node:assert/strict";
import test from "node:test";
import { callExecLua, formatFailure, startBridge, startCraftos, waitForComputers } from "./harness.js";
test("exec-lua runs code through the real TrapOS mcp-computer program", async () => {
const bridge = await startBridge();
const craftos = startCraftos("/programs/mcp-computer.lua", {
mountRepo: true,
shellArgs: [bridge.linkUrl],
timeoutMs: 15_000,
});
try {
await waitForComputers(bridge.registry, 1, 12_000);
const text = await callExecLua(bridge.mcpUrl, 0, "print('hello from exec'); return 2 + 3");
assert.match(text, /^computer: 0 \(Label: null\)\nok: true\nreturns: \[\{.*\}\]\noutput:\nhello from exec$/);
assert.match(text, /"type":"number"/);
assert.match(text, /"value":5/);
} catch (error) {
craftos.abort();
const result = await craftos.done;
throw new Error(formatFailure(error instanceof Error ? error.message : String(error), result.output), { cause: error });
} finally {
craftos.abort();
await craftos.done;
await bridge.close();
}
});

View File

@ -65,6 +65,28 @@ export async function callProbeComputers(mcpUrl: string): Promise<string> {
return text;
}
export async function callExecLua(mcpUrl: string, computerId: number, code: string, timeoutMs?: number): Promise<string> {
const body = {
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: { name: "exec-lua", arguments: { computerId, code, timeoutMs } },
};
const response = await fetch(mcpUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
const payload = (await response.json()) as {
result?: { content?: { type: string; text: string }[] };
};
const text = payload.result?.content?.[0]?.text;
if (typeof text !== "string") {
throw new Error(`Unexpected MCP response: ${JSON.stringify(payload)}`);
}
return text;
}
export type CraftosResult = {
status: number | null;
signal: NodeJS.Signals | null;

View File

@ -46,6 +46,47 @@ test("MCP tool call returns text content", async () => {
});
});
test("exec-lua sends code 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.execLua(12, "print('hi') return 2 + 3", 50);
assert.deepEqual(computer.lastRequest(), {
type: "request",
id: computer.lastRequest().id,
method: "exec-lua",
params: { code: "print('hi') return 2 + 3" },
});
computer.respond(registry, 12, { returns: [{ type: "number", value: 5 }], output: "hi\n" });
assert.equal(
await promise,
'computer: 12 (Label: base-turtle)\nok: true\nreturns: [{"type":"number","value":5}]\noutput:\nhi',
);
});
test("exec-lua reports unknown computer", async () => {
const registry = new LinkRegistry();
assert.equal(await registry.execLua(99, "return 1", 50), "No computer with id 99 connected.");
});
test("MCP exec-lua validates required arguments", async () => {
const registry = new LinkRegistry();
const response = await handleMcpRequest(
{ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "exec-lua", arguments: { computerId: 1 } } },
registry,
10,
);
assert.deepEqual(response, {
jsonrpc: "2.0",
id: 2,
error: { code: -32602, message: "code must be a non-empty string" },
});
});
class FakeSocket {
sent: unknown[] = [];
@ -57,10 +98,14 @@ class FakeSocket {
return;
}
respond(registry: LinkRegistry, computerId: number, result: string): void {
lastRequest(): { id: string; type: string; method: string; params?: unknown } {
const last = this.sent.at(-1);
assert.equal(typeof last, "string");
const request = JSON.parse(last as string) as { id: string };
return JSON.parse(last as string) as { id: string; type: string; method: string; params?: unknown };
}
respond(registry: LinkRegistry, computerId: number, result: unknown): void {
const request = this.lastRequest();
registry.handleFrame(this as unknown as WebSocket, JSON.stringify({ type: "response", id: request.id, ok: true, result }));
assert.equal(computerId > 0, true);