From 2b2a5bb86aa83d673a73a3b7eb82676b94de48e5 Mon Sep 17 00:00:00 2001 From: Guillaume ARM Date: Thu, 11 Jun 2026 16:14:00 +0200 Subject: [PATCH] feat(mcp): add write-file tool --- apis/libmcpcomputer.lua | 50 +++++++++++ .../adrs/adr-0017-mcp-remote-lua-execution.md | 2 +- docs/ingame-trapos-ai-mcp-guide.md | 4 +- packages/index.json | 2 +- packages/trapos-sandbox/ccpm.json | 2 +- tests/mcpcomputer.lua | 82 +++++++++++++++++ tools/mcp-bridge/package-lock.json | 4 +- tools/mcp-bridge/package.json | 2 +- tools/mcp-bridge/src/link-server.ts | 29 ++++++ tools/mcp-bridge/src/mcp-server.ts | 52 ++++++++++- tools/mcp-bridge/test-integration/harness.ts | 28 ++++++ .../write-file-real-program.test.ts | 43 +++++++++ tools/mcp-bridge/test/probe-computers.test.ts | 90 +++++++++++++++++++ 13 files changed, 382 insertions(+), 8 deletions(-) create mode 100644 tools/mcp-bridge/test-integration/write-file-real-program.test.ts diff --git a/apis/libmcpcomputer.lua b/apis/libmcpcomputer.lua index afd7f55..fbdb835 100644 --- a/apis/libmcpcomputer.lua +++ b/apis/libmcpcomputer.lua @@ -128,6 +128,38 @@ local function createMcpComputer() return { ok = true, returns = returns, output = table.concat(output) }; end + function api.writeFile(path, content, fsLike) + fsLike = fsLike or fs; + if type(path) ~= 'string' or path == '' then + return { ok = false, error = 'path must be a non-empty string' }; + end + + if type(content) ~= 'string' then + return { ok = false, error = 'content must be a string' }; + end + + local handle, openErr = fsLike.open(path, 'w'); + if not handle then + return { ok = false, error = tostring(openErr or 'failed to open file') }; + end + + local ok, writeErr = pcall(function() + handle.write(content); + end); + local closeOk, closeErr = pcall(function() + handle.close(); + end); + + if not ok then + return { ok = false, error = tostring(writeErr) }; + end + if not closeOk then + return { ok = false, error = tostring(closeErr) }; + end + + return { ok = true, path = path, bytes = string.len(content) }; + end + function api.handleRequest(request, osLike) if type(request) ~= 'table' or request.type ~= 'request' or type(request.id) ~= 'string' then return nil; @@ -157,6 +189,24 @@ local function createMcpComputer() }; end + if request.method == 'write-file' then + local params = request.params; + local result = api.writeFile( + type(params) == 'table' and params.path or nil, + type(params) == 'table' and params.content or nil + ); + return { + type = 'response', + id = request.id, + ok = result.ok, + result = { + path = result.path, + bytes = result.bytes, + }, + error = result.error, + }; + end + return { type = 'response', id = request.id, diff --git a/docs/adrs/adr-0017-mcp-remote-lua-execution.md b/docs/adrs/adr-0017-mcp-remote-lua-execution.md index e471ef1..05db81a 100644 --- a/docs/adrs/adr-0017-mcp-remote-lua-execution.md +++ b/docs/adrs/adr-0017-mcp-remote-lua-execution.md @@ -51,4 +51,4 @@ Timeouts are host-side request timeouts. A timed-out MCP call stops waiting for - 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. +- Add more convenience tools on top of the trusted bridge once usage patterns are clear. diff --git a/docs/ingame-trapos-ai-mcp-guide.md b/docs/ingame-trapos-ai-mcp-guide.md index 2e20403..4e6d68f 100644 --- a/docs/ingame-trapos-ai-mcp-guide.md +++ b/docs/ingame-trapos-ai-mcp-guide.md @@ -122,12 +122,14 @@ 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. +The bridge also exposes `write-file`, which writes content to a path on one linked computer by id and overwrites any existing file. It follows normal ComputerCraft filesystem behavior, so missing parent directories fail instead of being created automatically. + ## 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. +- `exec-lua` or `write-file` 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/packages/index.json b/packages/index.json index 04c237f..bb06394 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.6", - "trapos-sandbox": "0.2.0", + "trapos-sandbox": "0.2.1", "trapos": "0.8.8" } } diff --git a/packages/trapos-sandbox/ccpm.json b/packages/trapos-sandbox/ccpm.json index b3f6051..bdd7d4c 100644 --- a/packages/trapos-sandbox/ccpm.json +++ b/packages/trapos-sandbox/ccpm.json @@ -1,6 +1,6 @@ { "name": "trapos-sandbox", - "version": "0.2.0", + "version": "0.2.1", "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 400f03e..58425b1 100644 --- a/tests/mcpcomputer.lua +++ b/tests/mcpcomputer.lua @@ -20,6 +20,30 @@ local function fakeSettings(values) }; end +local function fakeFs() + local files = {}; + return { + files = files, + open = function(path, mode) + if mode ~= 'w' then + return nil, 'unsupported mode'; + end + if string.find(path, 'missing-parent', 1, true) then + return nil, 'No such file'; + end + local buffer = {}; + return { + write = function(value) + buffer[#buffer + 1] = value; + end, + close = function() + files[path] = table.concat(buffer); + end, + }; + end, + }; +end + testlib.test('parseArgs accepts positional URL', function() local mcpComputer = createMcpComputer(); local config = mcpComputer.parseArgs(packed('ws://127.0.0.1:3001')); @@ -223,6 +247,64 @@ testlib.test('handleRequest reports invalid exec-lua code', function() testlib.assertTrue(string.find(missing.error, 'non-empty string', 1, true)); end); +testlib.test('writeFile writes and overwrites file content', function() + local mcpComputer = createMcpComputer(); + local fsLike = fakeFs(); + + local first = mcpComputer.writeFile('/tmp/note.txt', 'hello', fsLike); + local second = mcpComputer.writeFile('/tmp/note.txt', 'goodbye', fsLike); + + testlib.assertEquals(first.ok, true); + testlib.assertEquals(first.path, '/tmp/note.txt'); + testlib.assertEquals(first.bytes, 5); + testlib.assertEquals(second.ok, true); + testlib.assertEquals(second.bytes, 7); + testlib.assertEquals(fsLike.files['/tmp/note.txt'], 'goodbye'); +end); + +testlib.test('writeFile allows empty content', function() + local mcpComputer = createMcpComputer(); + local fsLike = fakeFs(); + local result = mcpComputer.writeFile('/tmp/empty.txt', '', fsLike); + + testlib.assertEquals(result.ok, true); + testlib.assertEquals(result.bytes, 0); + testlib.assertEquals(fsLike.files['/tmp/empty.txt'], ''); +end); + +testlib.test('writeFile validates path and content', function() + local mcpComputer = createMcpComputer(); + local fsLike = fakeFs(); + + local missingPath = mcpComputer.writeFile('', 'hello', fsLike); + local invalidContent = mcpComputer.writeFile('/tmp/note.txt', 12, fsLike); + local missingParent = mcpComputer.writeFile('/missing-parent/note.txt', 'hello', fsLike); + + testlib.assertEquals(missingPath.ok, false); + testlib.assertTrue(string.find(missingPath.error, 'path', 1, true)); + testlib.assertEquals(invalidContent.ok, false); + testlib.assertTrue(string.find(invalidContent.error, 'content', 1, true)); + testlib.assertEquals(missingParent.ok, false); + testlib.assertTrue(string.find(missingParent.error, 'No such file', 1, true)); +end); + +testlib.test('handleRequest writes files', function() + local mcpComputer = createMcpComputer(); + local response = mcpComputer.handleRequest({ + type = 'request', + id = 'req-write', + method = 'write-file', + params = { path = 'mcp-write-test.txt', content = 'from mcp' }, + }, fakeOs(42, 'worker')); + + testlib.assertEquals(response.type, 'response'); + testlib.assertEquals(response.id, 'req-write'); + testlib.assertEquals(response.ok, true); + testlib.assertEquals(response.result.path, 'mcp-write-test.txt'); + testlib.assertEquals(response.result.bytes, 8); + fs.delete('mcp-write-test.txt'); +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 a3dc90d..3b3ad88 100644 --- a/tools/mcp-bridge/package-lock.json +++ b/tools/mcp-bridge/package-lock.json @@ -1,12 +1,12 @@ { "name": "mcp-bridge", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mcp-bridge", - "version": "0.2.0", + "version": "0.3.0", "dependencies": { "ws": "^8.17.1" }, diff --git a/tools/mcp-bridge/package.json b/tools/mcp-bridge/package.json index eb70838..32868a4 100644 --- a/tools/mcp-bridge/package.json +++ b/tools/mcp-bridge/package.json @@ -1,6 +1,6 @@ { "name": "mcp-bridge", - "version": "0.2.0", + "version": "0.3.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 1e56a7f..9047ca3 100644 --- a/tools/mcp-bridge/src/link-server.ts +++ b/tools/mcp-bridge/src/link-server.ts @@ -95,6 +95,16 @@ export class LinkRegistry { return formatExecLuaResult(computer, result); } + async writeFile(computerId: number, path: string, content: 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, "write-file", { path, content }, timeoutMs); + return formatWriteFileResult(computer, result); + } + private async probeComputer(computer: ComputerConnection, timeoutMs: number): Promise { const result = await this.requestComputer(computer, "ping", undefined, timeoutMs); if (!result) { @@ -154,6 +164,25 @@ function formatExecLuaResult(computer: ComputerConnection, response: ResponseMes return lines.join("\n"); } +function formatWriteFileResult(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 lines = [`computer: ${computerText}`, `ok: ${response.ok ? "true" : "false"}`]; + + if (response.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"}`); + } + + return lines.join("\n"); +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/tools/mcp-bridge/src/mcp-server.ts b/tools/mcp-bridge/src/mcp-server.ts index 2023cef..ddf86fc 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.2.0" }, + serverInfo: { name: "mcp-bridge", version: "0.3.0" }, }); } @@ -98,6 +98,21 @@ async function handleSingleRequest(request: JsonRpcRequest, registry: LinkRegist additionalProperties: false, }, }, + { + name: "write-file", + description: "Write file content on a linked ComputerCraft computer, overwriting any existing file.", + inputSchema: { + type: "object", + properties: { + computerId: { type: "number", description: "ComputerCraft computer id to write on." }, + path: { type: "string", description: "Target path on the ComputerCraft computer." }, + content: { type: "string", description: "File content to write. Empty strings are allowed." }, + timeoutMs: { type: "number", description: "Optional host-side timeout in milliseconds, max 30000." }, + }, + required: ["computerId", "path", "content"], + additionalProperties: false, + }, + }, ], }); } @@ -120,6 +135,17 @@ async function handleSingleRequest(request: JsonRpcRequest, registry: LinkRegist return jsonRpcResult(id, { content: [{ type: "text", text }] }); } + if (params.name === "write-file") { + const args = isRecord(params.arguments) ? params.arguments : {}; + const parsed = parseWriteFileArgs(args, probeTimeoutMs); + if (!parsed.ok) { + return jsonRpcError(id, -32602, parsed.error); + } + + const text = await registry.writeFile(parsed.computerId, parsed.path, parsed.content, parsed.timeoutMs); + return jsonRpcResult(id, { content: [{ type: "text", text }] }); + } + return jsonRpcError(id, -32602, "Unknown tool"); } @@ -146,6 +172,30 @@ function parseExecLuaArgs( return { ok: true, computerId: args.computerId, code: args.code, timeoutMs }; } +function parseWriteFileArgs( + args: Record, + defaultTimeoutMs: number, +): { ok: true; computerId: number; path: string; content: 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 (typeof args.content !== "string") { + return { ok: false, error: "content must be a 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, content: args.content, timeoutMs }; +} + function jsonRpcResult(id: unknown, result: unknown): unknown { return { jsonrpc: "2.0", id, result }; } diff --git a/tools/mcp-bridge/test-integration/harness.ts b/tools/mcp-bridge/test-integration/harness.ts index fa94044..d4ed622 100644 --- a/tools/mcp-bridge/test-integration/harness.ts +++ b/tools/mcp-bridge/test-integration/harness.ts @@ -87,6 +87,34 @@ export async function callExecLua(mcpUrl: string, computerId: number, code: stri return text; } +export async function callWriteFile( + mcpUrl: string, + computerId: number, + path: string, + content: string, + timeoutMs?: number, +): Promise { + const body = { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "write-file", arguments: { computerId, path, content, 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-integration/write-file-real-program.test.ts b/tools/mcp-bridge/test-integration/write-file-real-program.test.ts new file mode 100644 index 0000000..7317f29 --- /dev/null +++ b/tools/mcp-bridge/test-integration/write-file-real-program.test.ts @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { callExecLua, callWriteFile, formatFailure, startBridge, startCraftos, waitForComputers } from "./harness.js"; + +test("write-file writes 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 first = await callWriteFile(bridge.mcpUrl, 0, "mcp-write-test.txt", "hello\nworld"); + assert.equal(first, "computer: 0 (Label: null)\nok: true\npath: mcp-write-test.txt\nbytes: 11"); + + const readFirst = await callExecLua( + bridge.mcpUrl, + 0, + "local h = fs.open('mcp-write-test.txt', 'r'); local c = h.readAll(); h.close(); return c", + ); + assert.match(readFirst, /"value":"hello\\nworld"/); + + const second = await callWriteFile(bridge.mcpUrl, 0, "mcp-write-test.txt", "overwritten"); + assert.equal(second, "computer: 0 (Label: null)\nok: true\npath: mcp-write-test.txt\nbytes: 11"); + + const readSecond = await callExecLua( + bridge.mcpUrl, + 0, + "local h = fs.open('mcp-write-test.txt', 'r'); local c = h.readAll(); h.close(); fs.delete('mcp-write-test.txt'); return c", + ); + assert.match(readSecond, /"value":"overwritten"/); + } 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/probe-computers.test.ts b/tools/mcp-bridge/test/probe-computers.test.ts index d928eed..ff1697e 100644 --- a/tools/mcp-bridge/test/probe-computers.test.ts +++ b/tools/mcp-bridge/test/probe-computers.test.ts @@ -74,6 +74,21 @@ test("MCP tools/list includes exec-lua schema", async () => { additionalProperties: false, }, }, + { + name: "write-file", + description: "Write file content on a linked ComputerCraft computer, overwriting any existing file.", + inputSchema: { + type: "object", + properties: { + computerId: { type: "number", description: "ComputerCraft computer id to write on." }, + path: { type: "string", description: "Target path on the ComputerCraft computer." }, + content: { type: "string", description: "File content to write. Empty strings are allowed." }, + timeoutMs: { type: "number", description: "Optional host-side timeout in milliseconds, max 30000." }, + }, + required: ["computerId", "path", "content"], + additionalProperties: false, + }, + }, ], }, }); @@ -153,6 +168,81 @@ test("MCP exec-lua validates timeoutMs", async () => { }); }); +test("write-file sends path and content 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.writeFile(12, "notes/todo.txt", "hello\nworld", 50); + assert.deepEqual(computer.lastRequest(), { + type: "request", + id: computer.lastRequest().id, + method: "write-file", + params: { path: "notes/todo.txt", content: "hello\nworld" }, + }); + + computer.respond(registry, 12, { path: "notes/todo.txt", bytes: 11 }); + assert.equal(await promise, "computer: 12 (Label: base-turtle)\nok: true\npath: notes/todo.txt\nbytes: 11"); +}); + +test("write-file reports unknown computer", async () => { + const registry = new LinkRegistry(); + + assert.equal(await registry.writeFile(99, "note.txt", "hello", 50), "No computer with id 99 connected."); +}); + +test("write-file 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.writeFile(12, "missing/note.txt", "hello", 50); + computer.respondError(registry, 12, "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 }); + + assert.equal(await registry.writeFile(12, "note.txt", "hello", 5), "computer: 12 (Label: base-turtle)\nok: false\nerror: timeout"); +}); + +test("MCP write-file validates required arguments", async () => { + const registry = new LinkRegistry(); + const response = await handleMcpRequest( + { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "write-file", arguments: { computerId: 1, content: "hello" } } }, + registry, + 10, + ); + + assert.deepEqual(response, { + jsonrpc: "2.0", + id: 2, + error: { code: -32602, message: "path must be a non-empty string" }, + }); +}); + +test("MCP write-file allows empty 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: "write-file", arguments: { computerId: 12, path: "empty.txt", content: "" } } }, + registry, + 10, + ); + computer.respond(registry, 12, { path: "empty.txt", bytes: 0 }); + + assert.deepEqual(await responsePromise, { + jsonrpc: "2.0", + id: 2, + result: { content: [{ type: "text", text: "computer: 12 (Label: base-turtle)\nok: true\npath: empty.txt\nbytes: 0" }] }, + }); +}); + class FakeSocket { sent: unknown[] = [];