feat(mcp): add write-file tool

This commit is contained in:
Guillaume ARM 2026-06-11 16:14:00 +02:00
parent 565fc98ce8
commit 2b2a5bb86a
13 changed files with 382 additions and 8 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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": [

View File

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

View File

@ -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"
},

View File

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

View File

@ -95,6 +95,16 @@ export class LinkRegistry {
return formatExecLuaResult(computer, result);
}
async writeFile(computerId: number, path: string, content: 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, "write-file", { path, content }, timeoutMs);
return formatWriteFileResult(computer, result);
}
private async probeComputer(computer: ComputerConnection, timeoutMs: number): Promise<string> {
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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

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.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<string, unknown>,
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 };
}

View File

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

View File

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

View File

@ -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[] = [];