feat(mcp): add computer daemon
This commit is contained in:
parent
ea0eb24a1f
commit
bb585cb9ac
@ -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
|
||||
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"]
|
||||
}
|
||||
|
||||
@ -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');
|
||||
|
||||
23
servers/mcp-computer-server.lua
Normal file
23
servers/mcp-computer-server.lua
Normal 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 .. ').');
|
||||
@ -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({
|
||||
|
||||
Loading…
Reference in New Issue
Block a user