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-net": "0.3.0",
|
||||||
"trapos-ui": "0.2.2",
|
"trapos-ui": "0.2.2",
|
||||||
"trapos-ai": "0.6.3",
|
"trapos-ai": "0.6.3",
|
||||||
"trapos-sandbox": "0.1.0",
|
"trapos-sandbox": "0.1.1",
|
||||||
"trapos": "0.8.4"
|
"trapos": "0.8.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "trapos-sandbox",
|
"name": "trapos-sandbox",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"description": "TrapOS sandbox programs for ccpm experiments and Lua learning",
|
"description": "TrapOS sandbox programs for ccpm experiments and Lua learning",
|
||||||
"dependencies": ["trapos-core"],
|
"dependencies": ["trapos-core"],
|
||||||
"files": [
|
"files": [
|
||||||
"apis/libcarre.lua",
|
"apis/libcarre.lua",
|
||||||
"programs/carre.lua"
|
"apis/libmcpcomputer.lua",
|
||||||
|
"programs/carre.lua",
|
||||||
|
"programs/mcp-computer.lua"
|
||||||
],
|
],
|
||||||
"autostart": []
|
"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 HERE = dirname(fileURLToPath(import.meta.url));
|
||||||
const LUA_DIR = join(HERE, "lua");
|
const LUA_DIR = join(HERE, "lua");
|
||||||
|
const REPO_ROOT = join(HERE, "../../..");
|
||||||
|
|
||||||
export const MCP_PORT = 2000;
|
export const MCP_PORT = 2000;
|
||||||
export const LINK_PORT = 2001;
|
export const LINK_PORT = 2001;
|
||||||
@ -77,7 +78,7 @@ export type CraftosHandle = {
|
|||||||
|
|
||||||
export function startCraftos(
|
export function startCraftos(
|
||||||
luaName: string,
|
luaName: string,
|
||||||
opts: { shellArgs?: string[]; timeoutMs?: number } = {},
|
opts: { mountRepo?: boolean; shellArgs?: string[]; timeoutMs?: number } = {},
|
||||||
): CraftosHandle {
|
): CraftosHandle {
|
||||||
const timeoutMs = opts.timeoutMs ?? 15_000;
|
const timeoutMs = opts.timeoutMs ?? 15_000;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@ -87,6 +88,13 @@ export function startCraftos(
|
|||||||
const watchdog = setTimeout(() => controller.abort(), timeoutMs);
|
const watchdog = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
try {
|
try {
|
||||||
const args: string[] = ["--directory", dataDir, "--headless", "--mount-ro", `/staging=${LUA_DIR}`];
|
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") {
|
if (process.platform === "darwin") {
|
||||||
args.push("--rom", "/Applications/CraftOS-PC.app/Contents/Resources");
|
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 {
|
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(", ")})`;
|
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