feat(mcp): add computer daemon
This commit is contained in:
parent
ea0eb24a1f
commit
bb585cb9ac
@ -165,6 +165,95 @@ local function createMcpComputer()
|
|||||||
};
|
};
|
||||||
end
|
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;
|
return api;
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@ -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.5",
|
"trapos-ai": "0.6.5",
|
||||||
"trapos-sandbox": "0.1.4",
|
"trapos-sandbox": "0.2.0",
|
||||||
"trapos": "0.8.7"
|
"trapos": "0.8.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trapos-sandbox",
|
"name": "trapos-sandbox",
|
||||||
"version": "0.1.4",
|
"version": "0.2.0",
|
||||||
"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": [
|
||||||
@ -8,7 +8,8 @@
|
|||||||
"apis/libmcpcomputer.lua",
|
"apis/libmcpcomputer.lua",
|
||||||
"programs/carre.lua",
|
"programs/carre.lua",
|
||||||
"programs/creeper.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('examples:');
|
||||||
print(' mcp-computer ws://192.168.1.20:3001');
|
print(' mcp-computer ws://192.168.1.20:3001');
|
||||||
print(' mcp-computer -url ws://mcp-bridge.local: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
|
end
|
||||||
|
|
||||||
local function fail(message)
|
local function fail(message)
|
||||||
@ -55,7 +57,7 @@ if command == '-version' or command == '--version' or command == 'version' then
|
|||||||
end
|
end
|
||||||
|
|
||||||
local mcpComputer = createMcpComputer();
|
local mcpComputer = createMcpComputer();
|
||||||
local config, err = mcpComputer.parseArgs(args);
|
local config, err = mcpComputer.resolveUrl(args, settings);
|
||||||
if not config then
|
if not config then
|
||||||
print(err);
|
print(err);
|
||||||
print('use: mcp-computer -help');
|
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
|
end
|
||||||
|
|
||||||
|
local function fakeSettings(values)
|
||||||
|
return {
|
||||||
|
get = function(key) return values[key]; end,
|
||||||
|
};
|
||||||
|
end
|
||||||
|
|
||||||
testlib.test('parseArgs accepts positional URL', function()
|
testlib.test('parseArgs accepts positional URL', function()
|
||||||
local mcpComputer = createMcpComputer();
|
local mcpComputer = createMcpComputer();
|
||||||
local config = mcpComputer.parseArgs(packed('ws://127.0.0.1:3001'));
|
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');
|
testlib.assertEquals(hello.computerLabel, 'agent');
|
||||||
end);
|
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()
|
testlib.test('handleRequest responds to ping', function()
|
||||||
local mcpComputer = createMcpComputer();
|
local mcpComputer = createMcpComputer();
|
||||||
local response = mcpComputer.handleRequest({
|
local response = mcpComputer.handleRequest({
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user