From e30f2af2fd0c3e48e0e919ecfbad7f29fba3c6a1 Mon Sep 17 00:00:00 2001 From: Guillaume ARM Date: Thu, 11 Jun 2026 03:50:21 +0200 Subject: [PATCH] feat(mcp): add remote lua execution --- apis/libmcpcomputer.lua | 80 +++++++++++++++++++ docs/adrs/README.md | 1 + .../adrs/adr-0017-mcp-remote-lua-execution.md | 51 ++++++++++++ packages/index.json | 2 +- packages/trapos-sandbox/ccpm.json | 2 +- tests/mcpcomputer.lua | 39 +++++++++ tools/mcp-bridge/package-lock.json | 4 +- tools/mcp-bridge/package.json | 2 +- tools/mcp-bridge/src/link-server.ts | 74 +++++++++++++---- tools/mcp-bridge/src/mcp-server.ts | 55 +++++++++++-- .../exec-lua-real-program.test.ts | 27 +++++++ tools/mcp-bridge/test-integration/harness.ts | 22 +++++ tools/mcp-bridge/test/probe-computers.test.ts | 49 +++++++++++- 13 files changed, 382 insertions(+), 26 deletions(-) create mode 100644 docs/adrs/adr-0017-mcp-remote-lua-execution.md create mode 100644 tools/mcp-bridge/test-integration/exec-lua-real-program.test.ts diff --git a/apis/libmcpcomputer.lua b/apis/libmcpcomputer.lua index 63ea20a..e96a8ea 100644 --- a/apis/libmcpcomputer.lua +++ b/apis/libmcpcomputer.lua @@ -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, diff --git a/docs/adrs/README.md b/docs/adrs/README.md index 13ea272..18fdda5 100644 --- a/docs/adrs/README.md +++ b/docs/adrs/README.md @@ -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. diff --git a/docs/adrs/adr-0017-mcp-remote-lua-execution.md b/docs/adrs/adr-0017-mcp-remote-lua-execution.md new file mode 100644 index 0000000..b391417 --- /dev/null +++ b/docs/adrs/adr-0017-mcp-remote-lua-execution.md @@ -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. diff --git a/packages/index.json b/packages/index.json index b4ce4d1..46f2451 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.6.4", - "trapos-sandbox": "0.1.1", + "trapos-sandbox": "0.1.2", "trapos": "0.8.5" } } diff --git a/packages/trapos-sandbox/ccpm.json b/packages/trapos-sandbox/ccpm.json index 90bff76..9a7030d 100644 --- a/packages/trapos-sandbox/ccpm.json +++ b/packages/trapos-sandbox/ccpm.json @@ -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": [ diff --git a/tests/mcpcomputer.lua b/tests/mcpcomputer.lua index c5330b4..0c0b8b8 100644 --- a/tests/mcpcomputer.lua +++ b/tests/mcpcomputer.lua @@ -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({ diff --git a/tools/mcp-bridge/package-lock.json b/tools/mcp-bridge/package-lock.json index baec297..a3dc90d 100644 --- a/tools/mcp-bridge/package-lock.json +++ b/tools/mcp-bridge/package-lock.json @@ -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" }, diff --git a/tools/mcp-bridge/package.json b/tools/mcp-bridge/package.json index aaae791..fd77963 100644 --- a/tools/mcp-bridge/package.json +++ b/tools/mcp-bridge/package.json @@ -1,6 +1,6 @@ { "name": "mcp-bridge", - "version": "0.1.0", + "version": "0.2.0", "private": true, "type": "module", "scripts": { diff --git a/tools/mcp-bridge/src/link-server.ts b/tools/mcp-bridge/src/link-server.ts index 3af8233..1e56a7f 100644 --- a/tools/mcp-bridge/src/link-server.ts +++ b/tools/mcp-bridge/src/link-server.ts @@ -85,21 +85,18 @@ export class LinkRegistry { return lines.join("\n"); } + async execLua(computerId: number, code: 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, "exec-lua", { code }, timeoutMs); + return formatExecLuaResult(computer, result); + } + private async probeComputer(computer: ComputerConnection, timeoutMs: number): Promise { - const id = randomUUID(); - const response = new Promise((resolve) => { - this.pending.set(id, { computerId: computer.computerId, resolve }); - computer.ws.send(JSON.stringify({ type: "request", id, method: "ping" })); - }); - - const timeout = new Promise((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 | undefined, + timeoutMs: number, + ): Promise { + const id = randomUUID(); + const response = new Promise((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((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 { + return typeof value === "object" && value !== null && !Array.isArray(value); } export function startLinkServer(options: { host: string; port: number; registry: LinkRegistry }): WebSocketServer { diff --git a/tools/mcp-bridge/src/mcp-server.ts b/tools/mcp-bridge/src/mcp-server.ts index 1f5de95..2023cef 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.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, + 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 }; } 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 new file mode 100644 index 0000000..3cfbe66 --- /dev/null +++ b/tools/mcp-bridge/test-integration/exec-lua-real-program.test.ts @@ -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(); + } +}); diff --git a/tools/mcp-bridge/test-integration/harness.ts b/tools/mcp-bridge/test-integration/harness.ts index cf08617..fa94044 100644 --- a/tools/mcp-bridge/test-integration/harness.ts +++ b/tools/mcp-bridge/test-integration/harness.ts @@ -65,6 +65,28 @@ export async function callProbeComputers(mcpUrl: string): Promise { return text; } +export async function callExecLua(mcpUrl: string, computerId: number, code: string, timeoutMs?: number): Promise { + 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; diff --git a/tools/mcp-bridge/test/probe-computers.test.ts b/tools/mcp-bridge/test/probe-computers.test.ts index 334c644..d134804 100644 --- a/tools/mcp-bridge/test/probe-computers.test.ts +++ b/tools/mcp-bridge/test/probe-computers.test.ts @@ -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);