feat(mcp): add computer bridge program

This commit is contained in:
Guillaume ARM 2026-06-11 01:35:39 +02:00
parent 39b71a8c20
commit 8de3d24af2
7 changed files with 341 additions and 5 deletions

91
apis/libmcpcomputer.lua Normal file
View File

@ -0,0 +1,91 @@
local function defaultOs()
return os;
end
local function formatLabel(label)
if type(label) ~= 'string' or label == '' then
return 'null';
end
return label;
end
local function createMcpComputer()
local api = {};
api.formatLabel = formatLabel;
function api.formatPong(osLike)
osLike = osLike or defaultOs();
return 'pong from ' .. tostring(osLike.getComputerID())
.. ' (Label: ' .. formatLabel(osLike.getComputerLabel()) .. ')';
end
function api.parseArgs(args)
args = args or {};
local count = args.n or #args;
local url = nil;
if count == 0 then
return nil, 'missing websocket URL';
end
local i = 1;
while i <= count do
local arg = args[i];
if arg == '-url' then
if not args[i + 1] or args[i + 1] == '' then
return nil, 'missing value for -url';
end
url = args[i + 1];
i = i + 1;
elseif string.sub(tostring(arg), 1, 1) == '-' then
return nil, 'unknown option: ' .. tostring(arg);
elseif not url then
url = arg;
else
return nil, 'unexpected argument: ' .. tostring(arg);
end
i = i + 1;
end
if not url or url == '' then
return nil, 'missing websocket URL';
end
return { url = url };
end
function api.hello(osLike)
osLike = osLike or defaultOs();
return {
type = 'hello',
computerId = osLike.getComputerID(),
computerLabel = osLike.getComputerLabel(),
};
end
function api.handleRequest(request, osLike)
if type(request) ~= 'table' or request.type ~= 'request' or type(request.id) ~= 'string' then
return nil;
end
if request.method == 'ping' then
return {
type = 'response',
id = request.id,
ok = true,
result = api.formatPong(osLike),
};
end
return {
type = 'response',
id = request.id,
ok = false,
error = 'unknown method',
};
end
return api;
end
return createMcpComputer;

View File

@ -6,7 +6,7 @@
"trapos-net": "0.3.0",
"trapos-ui": "0.2.2",
"trapos-ai": "0.6.3",
"trapos-sandbox": "0.1.0",
"trapos-sandbox": "0.1.1",
"trapos": "0.8.4"
}
}

View File

@ -1,11 +1,13 @@
{
"name": "trapos-sandbox",
"version": "0.1.0",
"version": "0.1.1",
"description": "TrapOS sandbox programs for ccpm experiments and Lua learning",
"dependencies": ["trapos-core"],
"files": [
"apis/libcarre.lua",
"programs/carre.lua"
"apis/libmcpcomputer.lua",
"programs/carre.lua",
"programs/mcp-computer.lua"
],
"autostart": []
}

113
programs/mcp-computer.lua Normal file
View File

@ -0,0 +1,113 @@
local createMcpComputer = require('/apis/libmcpcomputer');
local createVersion = require('/apis/libversion');
local args = table.pack(...);
local command = args[1];
local function printUsage()
print('mcp-computer usage:');
print();
print(' mcp-computer <ws-url>');
print(' mcp-computer -url <ws-url>');
print(' mcp-computer --version');
print(' mcp-computer --help');
print();
print('examples:');
print(' mcp-computer ws://192.168.1.20:3001');
print(' mcp-computer -url ws://mcp-bridge.local:3001');
end
local function fail(message)
print(message);
error('mcp-computer failed', 0);
end
local function decodeJson(text)
return textutils.unserializeJSON(text);
end
local function sendJson(ws, value)
ws.send(textutils.serializeJSON(value));
end
local function waitForHelloOk(ws)
local message = ws.receive(5);
if not message then
return false, 'timed out waiting for hello-ok';
end
local frame = decodeJson(message);
if type(frame) ~= 'table' or frame.type ~= 'hello-ok' then
return false, 'unexpected hello response';
end
return true;
end
if command == '-help' or command == '--help' or command == 'help' then
printUsage();
return;
end
if command == '-version' or command == '--version' or command == 'version' then
print('v' .. createVersion().forSelf());
return;
end
local mcpComputer = createMcpComputer();
local config, err = mcpComputer.parseArgs(args);
if not config then
print(err);
print('use: mcp-computer -help');
return;
end
if not http then
fail('CC:Tweaked HTTP/WebSocket is unavailable: enable the http API.');
end
if not http.websocket then
fail('CC:Tweaked WebSocket is unavailable: enable HTTP WebSocket support.');
end
local version = createVersion().forSelf();
print('mcp-computer v' .. version .. ' connecting to ' .. config.url);
local ws, connectErr = http.websocket(config.url);
if not ws then
fail('websocket failed: ' .. tostring(connectErr));
end
local ok, runtimeErr = pcall(function()
sendJson(ws, mcpComputer.hello(os));
local helloOk, helloErr = waitForHelloOk(ws);
if not helloOk then
error(helloErr, 0);
end
print('linked as ' .. tostring(os.getComputerID())
.. ' (Label: ' .. mcpComputer.formatLabel(os.getComputerLabel()) .. ')');
print('waiting for requests... Press Ctrl+T to stop.');
while true do
local message = ws.receive();
if not message then return; end
local frame = decodeJson(message);
local response = mcpComputer.handleRequest(frame, os);
if response then
sendJson(ws, response);
end
end
end);
pcall(function() ws.close(); end);
if not ok then
if tostring(runtimeErr) == 'Terminated' then
print('stopped.');
return;
end
fail(tostring(runtimeErr));
end

96
tests/mcpcomputer.lua Normal file
View File

@ -0,0 +1,96 @@
local createLibTest = require('/apis/libtest');
local createMcpComputer = require('/apis/libmcpcomputer');
local testlib = createLibTest({ ... });
local function packed(...)
return table.pack(...);
end
local function fakeOs(id, label)
return {
getComputerID = function() return id; end,
getComputerLabel = function() return label; end,
};
end
testlib.test('parseArgs accepts positional URL', function()
local mcpComputer = createMcpComputer();
local config = mcpComputer.parseArgs(packed('ws://127.0.0.1:3001'));
testlib.assertEquals(config.url, 'ws://127.0.0.1:3001');
end);
testlib.test('parseArgs accepts -url option', function()
local mcpComputer = createMcpComputer();
local config = mcpComputer.parseArgs(packed('-url', 'ws://bridge:3001'));
testlib.assertEquals(config.url, 'ws://bridge:3001');
end);
testlib.test('parseArgs rejects missing URL', function()
local mcpComputer = createMcpComputer();
local config, err = mcpComputer.parseArgs(packed());
testlib.assertEquals(config, nil);
testlib.assertTrue(string.find(err, 'missing websocket URL', 1, true));
end);
testlib.test('formatPong includes computer id and label', function()
local mcpComputer = createMcpComputer();
testlib.assertEquals(mcpComputer.formatPong(fakeOs(12, 'base-turtle')), 'pong from 12 (Label: base-turtle)');
end);
testlib.test('formatPong renders missing or empty labels as null', function()
local mcpComputer = createMcpComputer();
testlib.assertEquals(mcpComputer.formatPong(fakeOs(7, nil)), 'pong from 7 (Label: null)');
testlib.assertEquals(mcpComputer.formatPong(fakeOs(8, '')), 'pong from 8 (Label: null)');
end);
testlib.test('hello identifies the current computer', function()
local mcpComputer = createMcpComputer();
local hello = mcpComputer.hello(fakeOs(3, 'agent'));
testlib.assertEquals(hello.type, 'hello');
testlib.assertEquals(hello.computerId, 3);
testlib.assertEquals(hello.computerLabel, 'agent');
end);
testlib.test('handleRequest responds to ping', function()
local mcpComputer = createMcpComputer();
local response = mcpComputer.handleRequest({
type = 'request',
id = 'req-1',
method = 'ping',
}, fakeOs(42, 'worker'));
testlib.assertEquals(response.type, 'response');
testlib.assertEquals(response.id, 'req-1');
testlib.assertEquals(response.ok, true);
testlib.assertEquals(response.result, 'pong from 42 (Label: worker)');
end);
testlib.test('handleRequest reports unknown methods', function()
local mcpComputer = createMcpComputer();
local response = mcpComputer.handleRequest({
type = 'request',
id = 'req-2',
method = 'dance',
}, fakeOs(42, 'worker'));
testlib.assertEquals(response.type, 'response');
testlib.assertEquals(response.id, 'req-2');
testlib.assertEquals(response.ok, false);
testlib.assertEquals(response.error, 'unknown method');
end);
testlib.test('handleRequest ignores malformed frames', function()
local mcpComputer = createMcpComputer();
testlib.assertEquals(mcpComputer.handleRequest({ type = 'request', method = 'ping' }, fakeOs(1, nil)), nil);
testlib.assertEquals(mcpComputer.handleRequest({ type = 'hello' }, fakeOs(1, nil)), nil);
end);
testlib.run();

View File

@ -10,6 +10,7 @@ import { startMcpServer } from "../src/mcp-server.js";
const HERE = dirname(fileURLToPath(import.meta.url));
const LUA_DIR = join(HERE, "lua");
const REPO_ROOT = join(HERE, "../../..");
export const MCP_PORT = 2000;
export const LINK_PORT = 2001;
@ -77,7 +78,7 @@ export type CraftosHandle = {
export function startCraftos(
luaName: string,
opts: { shellArgs?: string[]; timeoutMs?: number } = {},
opts: { mountRepo?: boolean; shellArgs?: string[]; timeoutMs?: number } = {},
): CraftosHandle {
const timeoutMs = opts.timeoutMs ?? 15_000;
const controller = new AbortController();
@ -87,6 +88,13 @@ export function startCraftos(
const watchdog = setTimeout(() => controller.abort(), timeoutMs);
try {
const args: string[] = ["--directory", dataDir, "--headless", "--mount-ro", `/staging=${LUA_DIR}`];
if (opts.mountRepo) {
args.push(
"--mount-ro", `/trapos=${REPO_ROOT}`,
"--mount-ro", `/apis=${join(REPO_ROOT, "apis")}`,
"--mount-ro", `/programs=${join(REPO_ROOT, "programs")}`,
);
}
if (process.platform === "darwin") {
args.push("--rom", "/Applications/CraftOS-PC.app/Contents/Resources");
}
@ -118,7 +126,8 @@ export function formatFailure(message: string, craftosOutput: string): string {
}
function buildExecCode(luaName: string, shellArgs: string[]): string {
const parts = [`'/staging/${luaName}'`, ...shellArgs.map(luaQuote)];
const programPath = luaName.startsWith("/") ? luaName : `/staging/${luaName}`;
const parts = [luaQuote(programPath), ...shellArgs.map(luaQuote)];
return `shell.run(${parts.join(", ")})`;
}

View File

@ -0,0 +1,25 @@
import assert from "node:assert/strict";
import test from "node:test";
import { callProbeComputers, formatFailure, startBridge, startCraftos, waitForComputers } from "./harness.js";
test("probe-computers talks to the real TrapOS mcp-computer program", async () => {
const bridge = await startBridge();
const craftos = startCraftos("/programs/mcp-computer.lua", {
mountRepo: true,
shellArgs: ["ws://127.0.0.1:2001"],
timeoutMs: 15_000,
});
try {
await waitForComputers(bridge.registry, 1, 12_000);
const text = await callProbeComputers();
assert.equal(text, "pong from 0 (Label: null)");
} 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();
}
});