From bb585cb9ac939371214c24be5cd56681aca8080c Mon Sep 17 00:00:00 2001 From: Guillaume ARM Date: Thu, 11 Jun 2026 06:07:53 +0200 Subject: [PATCH] feat(mcp): add computer daemon --- apis/libmcpcomputer.lua | 89 +++++++++++++++++++++++++++++++ packages/index.json | 2 +- packages/trapos-sandbox/ccpm.json | 7 +-- programs/mcp-computer.lua | 4 +- servers/mcp-computer-server.lua | 23 ++++++++ tests/mcpcomputer.lua | 53 ++++++++++++++++++ 6 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 servers/mcp-computer-server.lua diff --git a/apis/libmcpcomputer.lua b/apis/libmcpcomputer.lua index e96a8ea..afd7f55 100644 --- a/apis/libmcpcomputer.lua +++ b/apis/libmcpcomputer.lua @@ -165,6 +165,95 @@ local function createMcpComputer() }; end + -- Resolve the websocket URL from CLI args first, then fall back to the + -- 'mcp-computer.ws-url' setting when no argument is provided. + function api.resolveUrl(args, settingsLib) + args = args or {}; + local count = args.n or #args; + + if count > 0 then + return api.parseArgs(args); + end + + local url = settingsLib and settingsLib.get('mcp-computer.ws-url'); + if type(url) ~= 'string' or url == '' then + return nil, 'missing websocket URL (pass a URL or set mcp-computer.ws-url)'; + end + return { url = url }; + end + + -- Decode a raw websocket frame and dispatch it. Non-request frames (e.g. + -- 'hello-ok') and malformed payloads produce no response. + function api.onMessage(content, osLike, sendFn, decode) + decode = decode or textutils.unserializeJSON; + + local ok, frame = pcall(decode, content); + if not ok then + return; + end + + local response = api.handleRequest(frame, osLike); + if response and sendFn then + sendFn(response); + end + end + + -- Wire an event-driven MCP session onto an eventloop and connect. Registers + -- websocket handlers and returns immediately, so it never blocks the boot + -- sequence. Auto-reconnects on failure or close. + function api.startSession(opts) + opts = opts or {}; + local el = assert(opts.eventloop, 'startSession requires an eventloop'); + local url = assert(opts.url, 'startSession requires a url'); + local osLike = opts.os or defaultOs(); + local httpLike = opts.http or http; + local reconnectDelay = opts.reconnectDelay or 5; + local encode = opts.encode or textutils.serializeJSON; + local decode = opts.decode or textutils.unserializeJSON; + + local activeWs = nil; + + local function connect() + httpLike.websocketAsync(url); + end + + local function scheduleReconnect() + activeWs = nil; + el.setTimeout(connect, reconnectDelay); + end + + el.register('websocket_success', function(eventUrl, ws) + if eventUrl ~= url then return; end + activeWs = ws; + ws.send(encode(api.hello(osLike))); + end); + + el.register('websocket_message', function(eventUrl, content) + if eventUrl ~= url then return; end + api.onMessage(content, osLike, function(response) + if activeWs then + activeWs.send(encode(response)); + end + end, decode); + end); + + el.register('websocket_failure', function(eventUrl) + if eventUrl ~= url then return; end + scheduleReconnect(); + end); + + el.register('websocket_closed', function(eventUrl) + if eventUrl ~= url then return; end + scheduleReconnect(); + end); + + connect(); + + return { + isConnected = function() return activeWs ~= nil; end, + }; + end + return api; end diff --git a/packages/index.json b/packages/index.json index 80ab054..500c906 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.5", - "trapos-sandbox": "0.1.4", + "trapos-sandbox": "0.2.0", "trapos": "0.8.7" } } diff --git a/packages/trapos-sandbox/ccpm.json b/packages/trapos-sandbox/ccpm.json index 7c58e14..b3f6051 100644 --- a/packages/trapos-sandbox/ccpm.json +++ b/packages/trapos-sandbox/ccpm.json @@ -1,6 +1,6 @@ { "name": "trapos-sandbox", - "version": "0.1.4", + "version": "0.2.0", "description": "TrapOS sandbox programs for ccpm experiments and Lua learning", "dependencies": ["trapos-core"], "files": [ @@ -8,7 +8,8 @@ "apis/libmcpcomputer.lua", "programs/carre.lua", "programs/creeper.lua", - "programs/mcp-computer.lua" + "programs/mcp-computer.lua", + "servers/mcp-computer-server.lua" ], - "autostart": [] + "autostart": ["servers/mcp-computer-server"] } diff --git a/programs/mcp-computer.lua b/programs/mcp-computer.lua index 465a4fd..7b558c2 100644 --- a/programs/mcp-computer.lua +++ b/programs/mcp-computer.lua @@ -15,6 +15,8 @@ local function printUsage() print('examples:'); print(' mcp-computer ws://192.168.1.20:3001'); print(' mcp-computer -url ws://mcp-bridge.local:3001'); + print(); + print('with no URL, falls back to the mcp-computer.ws-url setting.'); end local function fail(message) @@ -55,7 +57,7 @@ if command == '-version' or command == '--version' or command == 'version' then end local mcpComputer = createMcpComputer(); -local config, err = mcpComputer.parseArgs(args); +local config, err = mcpComputer.resolveUrl(args, settings); if not config then print(err); print('use: mcp-computer -help'); diff --git a/servers/mcp-computer-server.lua b/servers/mcp-computer-server.lua new file mode 100644 index 0000000..66e1749 --- /dev/null +++ b/servers/mcp-computer-server.lua @@ -0,0 +1,23 @@ +local createMcpComputer = require('/apis/libmcpcomputer'); +local createVersion = require('/apis/libversion'); + +local WS_URL_SETTING = 'mcp-computer.ws-url'; + +local url = settings.get(WS_URL_SETTING); +if type(url) ~= 'string' or url == '' then + print('mcp-computer-server: ' .. WS_URL_SETTING .. ' not set, daemon inactive.'); + return; +end + +if not http or not http.websocket then + print('mcp-computer-server: HTTP/WebSocket unavailable, daemon inactive.'); + return; +end + +createMcpComputer().startSession({ + eventloop = _G.bootEventLoop, + url = url, + os = os, +}); + +print('mcp-computer-server v' .. createVersion().forSelf() .. ' started (' .. url .. ').'); diff --git a/tests/mcpcomputer.lua b/tests/mcpcomputer.lua index 4a6158d..400f03e 100644 --- a/tests/mcpcomputer.lua +++ b/tests/mcpcomputer.lua @@ -14,6 +14,12 @@ local function fakeOs(id, label) }; end +local function fakeSettings(values) + return { + get = function(key) return values[key]; end, + }; +end + testlib.test('parseArgs accepts positional URL', function() local mcpComputer = createMcpComputer(); local config = mcpComputer.parseArgs(packed('ws://127.0.0.1:3001')); @@ -58,6 +64,53 @@ testlib.test('hello identifies the current computer', function() testlib.assertEquals(hello.computerLabel, 'agent'); end); +testlib.test('resolveUrl prefers a positional argument over the setting', function() + local mcpComputer = createMcpComputer(); + local settingsLib = fakeSettings({ ['mcp-computer.ws-url'] = 'ws://from-setting:3001' }); + local config = mcpComputer.resolveUrl(packed('ws://from-arg:3001'), settingsLib); + + testlib.assertEquals(config.url, 'ws://from-arg:3001'); +end); + +testlib.test('resolveUrl falls back to the setting when no argument is given', function() + local mcpComputer = createMcpComputer(); + local settingsLib = fakeSettings({ ['mcp-computer.ws-url'] = 'ws://from-setting:3001' }); + local config = mcpComputer.resolveUrl(packed(), settingsLib); + + testlib.assertEquals(config.url, 'ws://from-setting:3001'); +end); + +testlib.test('resolveUrl errors when neither argument nor setting provides a URL', function() + local mcpComputer = createMcpComputer(); + local config, err = mcpComputer.resolveUrl(packed(), fakeSettings({})); + + testlib.assertEquals(config, nil); + testlib.assertTrue(string.find(err, 'mcp-computer.ws-url', 1, true)); +end); + +testlib.test('onMessage replies to a request frame through sendFn', function() + local mcpComputer = createMcpComputer(); + local sent = {}; + mcpComputer.onMessage('{"type":"request","id":"req-7","method":"ping"}', fakeOs(5, 'node'), function(response) + sent[#sent + 1] = response; + end); + + testlib.assertEquals(#sent, 1); + testlib.assertEquals(sent[1].id, 'req-7'); + testlib.assertEquals(sent[1].result, 'pong from 5 (Label: node)'); +end); + +testlib.test('onMessage stays silent on non-request and malformed frames', function() + local mcpComputer = createMcpComputer(); + local sent = {}; + local function capture(response) sent[#sent + 1] = response; end + + mcpComputer.onMessage('{"type":"hello-ok"}', fakeOs(5, 'node'), capture); + mcpComputer.onMessage('not json', fakeOs(5, 'node'), capture); + + testlib.assertEquals(#sent, 0); +end); + testlib.test('handleRequest responds to ping', function() local mcpComputer = createMcpComputer(); local response = mcpComputer.handleRequest({