diff --git a/apis/libmcpcomputer.lua b/apis/libmcpcomputer.lua new file mode 100644 index 0000000..63ea20a --- /dev/null +++ b/apis/libmcpcomputer.lua @@ -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; diff --git a/packages/index.json b/packages/index.json index 29e5ac8..d7ae329 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.3", - "trapos-sandbox": "0.1.0", + "trapos-sandbox": "0.1.1", "trapos": "0.8.4" } } diff --git a/packages/trapos-sandbox/ccpm.json b/packages/trapos-sandbox/ccpm.json index 076d1c3..90bff76 100644 --- a/packages/trapos-sandbox/ccpm.json +++ b/packages/trapos-sandbox/ccpm.json @@ -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": [] } diff --git a/programs/mcp-computer.lua b/programs/mcp-computer.lua new file mode 100644 index 0000000..465a4fd --- /dev/null +++ b/programs/mcp-computer.lua @@ -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 '); + print(' mcp-computer -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 diff --git a/tests/mcpcomputer.lua b/tests/mcpcomputer.lua new file mode 100644 index 0000000..c5330b4 --- /dev/null +++ b/tests/mcpcomputer.lua @@ -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(); diff --git a/tools/mcp-bridge/test-integration/harness.ts b/tools/mcp-bridge/test-integration/harness.ts index 9657270..d9b6da8 100644 --- a/tools/mcp-bridge/test-integration/harness.ts +++ b/tools/mcp-bridge/test-integration/harness.ts @@ -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(", ")})`; } diff --git a/tools/mcp-bridge/test-integration/probe-real-program.test.ts b/tools/mcp-bridge/test-integration/probe-real-program.test.ts new file mode 100644 index 0000000..1175e2f --- /dev/null +++ b/tools/mcp-bridge/test-integration/probe-real-program.test.ts @@ -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(); + } +});