feat(mcp): add computer daemon

This commit is contained in:
Guillaume ARM 2026-06-11 06:07:53 +02:00
parent ea0eb24a1f
commit bb585cb9ac
6 changed files with 173 additions and 5 deletions

View File

@ -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

View File

@ -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"
}
}

View File

@ -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"]
}

View File

@ -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');

View File

@ -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 .. ').');

View File

@ -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({