feat(mcp): add computer bridge program
This commit is contained in:
parent
39b71a8c20
commit
8de3d24af2
91
apis/libmcpcomputer.lua
Normal file
91
apis/libmcpcomputer.lua
Normal 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;
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
113
programs/mcp-computer.lua
Normal 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
96
tests/mcpcomputer.lua
Normal 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();
|
||||
@ -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(", ")})`;
|
||||
}
|
||||
|
||||
|
||||
25
tools/mcp-bridge/test-integration/probe-real-program.test.ts
Normal file
25
tools/mcp-bridge/test-integration/probe-real-program.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user