feat: migrate mcp-bridge to trap-sandbox
This commit is contained in:
parent
c9fe4ca50a
commit
5bca84648f
@ -1,3 +1,10 @@
|
|||||||
|
-- Computer-side MCP execution primitives.
|
||||||
|
--
|
||||||
|
-- Protocol-agnostic building blocks for the MCP `exec-lua` / `write-file` features.
|
||||||
|
-- The transport lives elsewhere: servers/mcp-computer-server.lua reacts to sandbox
|
||||||
|
-- gateway_request events and replies through the trap-sandbox gateway (see
|
||||||
|
-- apis/libsandbox.lua). This module only knows how to run code and write files.
|
||||||
|
|
||||||
local function defaultOs()
|
local function defaultOs()
|
||||||
return os;
|
return os;
|
||||||
end
|
end
|
||||||
@ -62,49 +69,6 @@ local function createMcpComputer()
|
|||||||
.. ' (Label: ' .. formatLabel(osLike.getComputerLabel()) .. ')';
|
.. ' (Label: ' .. formatLabel(osLike.getComputerLabel()) .. ')';
|
||||||
end
|
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.executeLua(code)
|
function api.executeLua(code)
|
||||||
local output = {};
|
local output = {};
|
||||||
if type(code) ~= 'string' or code == '' then
|
if type(code) ~= 'string' or code == '' then
|
||||||
@ -160,150 +124,6 @@ local function createMcpComputer()
|
|||||||
return { ok = true, path = path, bytes = string.len(content) };
|
return { ok = true, path = path, bytes = string.len(content) };
|
||||||
end
|
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
|
|
||||||
|
|
||||||
if request.method == 'exec-lua' then
|
|
||||||
local params = request.params;
|
|
||||||
local result = api.executeLua(type(params) == 'table' and params.code or nil);
|
|
||||||
return {
|
|
||||||
type = 'response',
|
|
||||||
id = request.id,
|
|
||||||
ok = result.ok,
|
|
||||||
result = {
|
|
||||||
returns = result.returns or {},
|
|
||||||
output = result.output or '',
|
|
||||||
},
|
|
||||||
error = result.error,
|
|
||||||
};
|
|
||||||
end
|
|
||||||
|
|
||||||
if request.method == 'write-file' then
|
|
||||||
local params = request.params;
|
|
||||||
local result = api.writeFile(
|
|
||||||
type(params) == 'table' and params.path or nil,
|
|
||||||
type(params) == 'table' and params.content or nil
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
type = 'response',
|
|
||||||
id = request.id,
|
|
||||||
ok = result.ok,
|
|
||||||
result = {
|
|
||||||
path = result.path,
|
|
||||||
bytes = result.bytes,
|
|
||||||
},
|
|
||||||
error = result.error,
|
|
||||||
};
|
|
||||||
end
|
|
||||||
|
|
||||||
return {
|
|
||||||
type = 'response',
|
|
||||||
id = request.id,
|
|
||||||
ok = false,
|
|
||||||
error = 'unknown method',
|
|
||||||
};
|
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
@ -139,20 +139,29 @@ local function createSandbox()
|
|||||||
end
|
end
|
||||||
|
|
||||||
if frame.event == 'gateway_request' then
|
if frame.event == 'gateway_request' then
|
||||||
-- Reverse direction: built-in `ping` only; everything else is unknown_type.
|
-- Reverse direction. `ping` stays built-in so it answers without any handler.
|
||||||
local response;
|
-- Other types are delegated to in-OS servers (re-emitted via ctx.queueRequest)
|
||||||
|
-- when a handler has advertised the type (ctx.isServed); an unadvertised type
|
||||||
|
-- still gets an immediate unknown_type reply.
|
||||||
if frame.type == 'ping' then
|
if frame.type == 'ping' then
|
||||||
response = api.buildComputerResponse(
|
if ctx.send then
|
||||||
ctx.traposId, frame.messageId, 'ping', true, { traposId = ctx.traposId }, nil
|
ctx.send(api.buildComputerResponse(
|
||||||
);
|
ctx.traposId, frame.messageId, 'ping', true, { traposId = ctx.traposId }, nil
|
||||||
else
|
));
|
||||||
response = api.buildComputerResponse(
|
end
|
||||||
|
return;
|
||||||
|
end
|
||||||
|
|
||||||
|
if ctx.isServed and ctx.isServed(frame.type) and ctx.queueRequest then
|
||||||
|
ctx.queueRequest(frame.messageId, frame.type, coercePayload(frame.payload));
|
||||||
|
return;
|
||||||
|
end
|
||||||
|
|
||||||
|
if ctx.send then
|
||||||
|
ctx.send(api.buildComputerResponse(
|
||||||
ctx.traposId, frame.messageId, frame.type, false, nil,
|
ctx.traposId, frame.messageId, frame.type, false, nil,
|
||||||
{ code = 'unknown_type', message = 'unknown type: ' .. tostring(frame.type) }
|
{ code = 'unknown_type', message = 'unknown type: ' .. tostring(frame.type) }
|
||||||
);
|
));
|
||||||
end
|
|
||||||
if ctx.send then
|
|
||||||
ctx.send(response);
|
|
||||||
end
|
end
|
||||||
return;
|
return;
|
||||||
end
|
end
|
||||||
@ -205,6 +214,10 @@ local function createSandbox()
|
|||||||
-- open / connection failed / connection closed) so a long outage logs once on entry
|
-- open / connection failed / connection closed) so a long outage logs once on entry
|
||||||
-- and once on recovery instead of flooding the file every reconnectDelay seconds.
|
-- and once on recovery instead of flooding the file every reconnectDelay seconds.
|
||||||
local reconnecting = false;
|
local reconnecting = false;
|
||||||
|
-- Gateway_request types advertised by in-OS servers (via trapos_sandbox_serve).
|
||||||
|
-- Inbound frames of an advertised type are re-emitted as trapos_sandbox_request
|
||||||
|
-- events; unadvertised (non-ping) types get an immediate unknown_type reply.
|
||||||
|
local servedTypes = {};
|
||||||
|
|
||||||
local function sendFrame(frame)
|
local function sendFrame(frame)
|
||||||
if not activeWs then
|
if not activeWs then
|
||||||
@ -289,6 +302,10 @@ local function createSandbox()
|
|||||||
send = sendFrame,
|
send = sendFrame,
|
||||||
queueEvent = queueEvent,
|
queueEvent = queueEvent,
|
||||||
onHello = onHello,
|
onHello = onHello,
|
||||||
|
isServed = function(msgType) return servedTypes[msgType] == true; end,
|
||||||
|
queueRequest = function(messageId, msgType, payload)
|
||||||
|
queueEvent('trapos_sandbox_request', messageId, msgType, payload);
|
||||||
|
end,
|
||||||
});
|
});
|
||||||
end);
|
end);
|
||||||
|
|
||||||
@ -341,6 +358,23 @@ local function createSandbox()
|
|||||||
end
|
end
|
||||||
end);
|
end);
|
||||||
|
|
||||||
|
-- In-OS servers advertise the gateway_request types they handle. Recorded so
|
||||||
|
-- onMessage routes those (and only those) as trapos_sandbox_request events.
|
||||||
|
el.register('trapos_sandbox_serve', function(msgType)
|
||||||
|
if type(msgType) == 'string' and msgType ~= '' then
|
||||||
|
servedTypes[msgType] = true;
|
||||||
|
end
|
||||||
|
end);
|
||||||
|
|
||||||
|
-- Bridge a server reply back onto the wire as a computer_response. Dropped when
|
||||||
|
-- not connected: the gateway-side request then times out, which is the reply.
|
||||||
|
el.register('trapos_sandbox_respond', function(messageId, msgType, ok, payload, err)
|
||||||
|
if state ~= 'ready' then
|
||||||
|
return;
|
||||||
|
end
|
||||||
|
sendFrame(api.buildComputerResponse(traposId, messageId, msgType, ok == true, payload, err));
|
||||||
|
end);
|
||||||
|
|
||||||
connect();
|
connect();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -383,6 +417,49 @@ local function createSandbox()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Server-side reply: queue a computer_response for an inbound gateway_request that
|
||||||
|
-- the daemon delivered as a trapos_sandbox_request event. The daemon owns the wire.
|
||||||
|
function api.respond(messageId, msgType, ok, payload, err, opts)
|
||||||
|
opts = opts or {};
|
||||||
|
local queueEvent = opts.queueEvent or os.queueEvent;
|
||||||
|
queueEvent('trapos_sandbox_respond', messageId, msgType, ok == true, payload, err);
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Register an in-OS handler for an inbound gateway_request type. Advertises the
|
||||||
|
-- type to the daemon (so it routes instead of replying unknown_type) and reacts to
|
||||||
|
-- matching trapos_sandbox_request events. handler(payload, messageId) returns
|
||||||
|
-- (true, resultTable) on success or (false, { code, message }) on failure; a thrown
|
||||||
|
-- error is reported as { code = 'internal', message = <error> }.
|
||||||
|
function api.serve(msgType, handler, opts)
|
||||||
|
opts = opts or {};
|
||||||
|
local el = assert(opts.eventloop, 'serve requires an eventloop');
|
||||||
|
assert(type(msgType) == 'string' and msgType ~= '', 'serve requires a type');
|
||||||
|
assert(type(handler) == 'function', 'serve requires a handler');
|
||||||
|
local queueEvent = opts.queueEvent or os.queueEvent;
|
||||||
|
|
||||||
|
queueEvent('trapos_sandbox_serve', msgType);
|
||||||
|
|
||||||
|
el.register('trapos_sandbox_request', function(messageId, requestType, payload)
|
||||||
|
if requestType ~= msgType then
|
||||||
|
return;
|
||||||
|
end
|
||||||
|
local ok, result, errBody = pcall(handler, coercePayload(payload), messageId);
|
||||||
|
if not ok then
|
||||||
|
api.respond(messageId, msgType, false, nil,
|
||||||
|
{ code = 'internal', message = tostring(result) }, { queueEvent = queueEvent });
|
||||||
|
return;
|
||||||
|
end
|
||||||
|
if result == true then
|
||||||
|
api.respond(messageId, msgType, true, errBody, nil, { queueEvent = queueEvent });
|
||||||
|
else
|
||||||
|
if type(errBody) ~= 'table' then
|
||||||
|
errBody = { code = 'internal', message = tostring(errBody or 'request failed') };
|
||||||
|
end
|
||||||
|
api.respond(messageId, msgType, false, nil, errBody, { queueEvent = queueEvent });
|
||||||
|
end
|
||||||
|
end);
|
||||||
|
end
|
||||||
|
|
||||||
return api;
|
return api;
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@ -17,5 +17,6 @@ Future ADRs can reuse the shape of the existing files when it is useful.
|
|||||||
- [`adr-0016-js-tool-verification.md`](adr-0016-js-tool-verification.md) — JavaScript/TypeScript tool build, check, test, CI, and future integration-test split.
|
- [`adr-0016-js-tool-verification.md`](adr-0016-js-tool-verification.md) — JavaScript/TypeScript tool build, check, test, CI, and future integration-test split.
|
||||||
- [`adr-0017-mcp-remote-lua-execution.md`](adr-0017-mcp-remote-lua-execution.md) — MCP `exec-lua` remote execution for linked ComputerCraft computers.
|
- [`adr-0017-mcp-remote-lua-execution.md`](adr-0017-mcp-remote-lua-execution.md) — MCP `exec-lua` remote execution for linked ComputerCraft computers.
|
||||||
- [`adr-0018-justfile-organization.md`](adr-0018-justfile-organization.md) — Root Justfile split with imports while preserving flat recipe names.
|
- [`adr-0018-justfile-organization.md`](adr-0018-justfile-organization.md) — Root Justfile split with imports while preserving flat recipe names.
|
||||||
|
- [`adr-0019-mcp-over-sandbox-gateway.md`](adr-0019-mcp-over-sandbox-gateway.md) — MCP tools migrated onto the trap-sandbox gateway; reactive `libsandbox.serve`; `traposId` addressing.
|
||||||
|
|
||||||
Gaps in numbering (0003, 0004, 0006, 0008, 0009, 0012, 0013, 0014, 0015) are records that were either superseded by later decisions or consolidated into the surviving ADRs above.
|
Gaps in numbering (0003, 0004, 0006, 0008, 0009, 0012, 0013, 0014, 0015) are records that were either superseded by later decisions or consolidated into the surviving ADRs above.
|
||||||
|
|||||||
@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Accepted
|
Accepted. Transport superseded by [ADR-0019](adr-0019-mcp-over-sandbox-gateway.md) — the
|
||||||
|
MCP tools now ride the sandbox gateway and address computers by `traposId`. The execution
|
||||||
|
semantics and danger model below still apply.
|
||||||
|
|
||||||
## Date
|
## Date
|
||||||
|
|
||||||
|
|||||||
72
docs/adrs/adr-0019-mcp-over-sandbox-gateway.md
Normal file
72
docs/adrs/adr-0019-mcp-over-sandbox-gateway.md
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
# ADR 0019: MCP Over The Sandbox Gateway
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted (supersedes the transport of [ADR-0017](adr-0017-mcp-remote-lua-execution.md))
|
||||||
|
|
||||||
|
## Date
|
||||||
|
|
||||||
|
2026-06-15
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The MCP tools (`probe-computers`, `exec-lua`, `write-file`) shipped in `tools/mcp-bridge`
|
||||||
|
([ADR-0017](adr-0017-mcp-remote-lua-execution.md)) over a dedicated CC-link WebSocket and
|
||||||
|
the `mcp-computer` / `mcp-computer-server` Lua programs.
|
||||||
|
|
||||||
|
Meanwhile `tools/trap-sandbox` introduced a second, richer host↔computer connection: a
|
||||||
|
single persistent gateway WebSocket per computer (`servers/sandbox` + `apis/libsandbox`),
|
||||||
|
a hello handshake with a shared secret, a coded error model, and a bidirectional
|
||||||
|
request/reply (`GatewayRegistry.request` → `gateway_request` → `computer_response`).
|
||||||
|
|
||||||
|
Running both stacks meant two sockets, two protocols, and a second autostart daemon per
|
||||||
|
computer. The sandbox gateway already had everything MCP needs.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Move the MCP features onto the sandbox gateway and retire the dedicated MCP transport.
|
||||||
|
|
||||||
|
- **Host (`tools/trap-sandbox`):** a second Fastify listener (`src/modules/mcp`) bound to
|
||||||
|
`127.0.0.1` (default `MCP_PORT=4445`) speaks the same custom JSON-RPC 2.0 as the old
|
||||||
|
bridge. Each tool call is a `GatewayRegistry.request(accountId, traposId, type, payload)`
|
||||||
|
reusing the existing gateway socket. Computers are addressed by **`traposId`**
|
||||||
|
(`"<id>:<label>"`, the registry's key) rather than a numeric `computerId`;
|
||||||
|
`probe-computers` lists the connected traposIds.
|
||||||
|
- **Computer side:** `apis/libsandbox` gains a reactive serving path symmetric to its
|
||||||
|
request path. In-OS servers call `sandbox.serve(type, handler, { eventloop })`, which
|
||||||
|
advertises the type to the daemon (`trapos_sandbox_serve`) and reacts to inbound
|
||||||
|
`trapos_sandbox_request` events, replying via `trapos_sandbox_respond`
|
||||||
|
(`computer_response`). Unadvertised non-`ping` types still get an immediate
|
||||||
|
`unknown_type` reply (the daemon tracks advertised types, so there is no timer race).
|
||||||
|
`ping` stays built-in, so `probe-computers` works against any computer running just the
|
||||||
|
sandbox daemon.
|
||||||
|
- **`servers/mcp-computer-server`** is rewritten to be reactive: it owns no socket, serves
|
||||||
|
`exec-lua` / `write-file` over the gateway via `sandbox.serve`, and is gated on
|
||||||
|
`sandbox.url` being set. `apis/libmcpcomputer` is slimmed to the protocol-agnostic
|
||||||
|
execution primitives (`executeLua`, `writeFile`); both move into the `trapos-sandbox`
|
||||||
|
package (autostart `servers/mcp-computer-server` after `servers/sandbox`). The standalone
|
||||||
|
`programs/mcp-computer` client is deleted and the MCP entries are removed from
|
||||||
|
`trapos-sandbox-legacy`.
|
||||||
|
- A code error or a failed write is reported as a *successful* round-trip carrying an
|
||||||
|
`ok:false` result payload (preserving captured `output`), matching the old bridge's
|
||||||
|
semantics; only a dropped connection or timeout is a transport-level failure.
|
||||||
|
|
||||||
|
`tools/mcp-bridge` is **not** deleted: its opencode HTTP-over-WS proxy is still used by the
|
||||||
|
in-game `ai` bridge mode (`apis/libhttpws`, `apis/libai`). Only the MCP server and CC-link
|
||||||
|
parts are superseded here; the proxy migration is deferred.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- One connection and one protocol per computer; no `mcp-computer.ws-url` setting.
|
||||||
|
- The exec trust boundary is now the authenticated gateway connection (`sandbox.url` +
|
||||||
|
`SANDBOX_PASSWORD`) plus the local-only MCP bind, instead of "trust the bridge you run
|
||||||
|
`mcp-computer` against."
|
||||||
|
- `apis/libsandbox.serve` / `respond` are reusable for any future computer-side service
|
||||||
|
invoked from the host, not just MCP.
|
||||||
|
- Tests cover host-side MCP routing (unit) and the real CraftOS gateway round-trip
|
||||||
|
(integration), per [ADR-0016](adr-0016-js-tool-verification.md).
|
||||||
|
|
||||||
|
## Future Work
|
||||||
|
|
||||||
|
- Migrate the opencode HTTP proxy off `mcp-bridge` so the tool can be fully retired.
|
||||||
|
- Per-computer exec policy if the gateway is ever shared beyond trusted development.
|
||||||
@ -62,49 +62,56 @@ ai "say hello from TrapOS"
|
|||||||
|
|
||||||
Expected ping: `pong`.
|
Expected ping: `pong`.
|
||||||
|
|
||||||
## 4. Start MCP Bridge On Host
|
## 4. Start The Sandbox Gateway On Host
|
||||||
|
|
||||||
From this repository on your real machine:
|
The MCP tools now ride on the `trap-sandbox` gateway (the same WS connection used for
|
||||||
|
sandbox health). From this repository on your real machine:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd tools/mcp-bridge
|
just sandbox-serve
|
||||||
npm install
|
|
||||||
CC_LINK_PORT=4243 npm run dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Production ports:
|
This serves two listeners:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
MCP endpoint: http://127.0.0.1:3000
|
Gateway (in-game computers connect here): ws://<public-host>:4444/gateway
|
||||||
ComputerCraft link: ws://<public-host>:4243
|
MCP endpoint (OpenCode connects here): http://127.0.0.1:4445
|
||||||
```
|
```
|
||||||
|
|
||||||
## 5. Link The Computer To MCP
|
The MCP endpoint stays bound to `127.0.0.1` on purpose: `exec-lua` / `write-file` are
|
||||||
|
as powerful as the linked computer, so only local processes (your OpenCode) reach them.
|
||||||
|
Set `SANDBOX_PASSWORD` in `.env` to require a shared secret on the gateway connection.
|
||||||
|
|
||||||
On the ComputerCraft computer:
|
## 5. Link The Computer To The Gateway
|
||||||
|
|
||||||
|
On the ComputerCraft computer, point it at the gateway and reboot:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
mcp-computer ws://<public-host>:4243
|
set sandbox.url ws://<public-host>:4444
|
||||||
|
set sandbox.password secret # only if SANDBOX_PASSWORD is set on the host
|
||||||
|
reboot
|
||||||
```
|
```
|
||||||
|
|
||||||
Leave it running. You should see:
|
The `servers/sandbox` daemon connects automatically at boot, and `servers/mcp-computer-server`
|
||||||
|
starts serving `exec-lua` / `write-file` over that one connection. Check the link with:
|
||||||
|
|
||||||
```text
|
```sh
|
||||||
linked as <id> (Label: <label>)
|
sandbox health
|
||||||
waiting for requests... Press Ctrl+T to stop.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 6. Connect OpenCode To MCP
|
## 6. Connect OpenCode To MCP
|
||||||
|
|
||||||
Add the bridge as an MCP HTTP server in your OpenCode MCP config, pointing at:
|
Add the gateway's MCP endpoint as an MCP HTTP server in your OpenCode MCP config:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
http://127.0.0.1:3000
|
http://127.0.0.1:4445
|
||||||
```
|
```
|
||||||
|
|
||||||
Then ask OpenCode to use the MCP tool `probe-computers`. A working link returns a `pong from <id>` line.
|
Then ask OpenCode to use the MCP tool `probe-computers`. It lists each connected
|
||||||
|
computer's `traposId` (the `"<id>:<label>"` string), for example `ok from 7:base`.
|
||||||
|
|
||||||
The bridge also exposes `exec-lua`, which runs Lua on one linked computer by id. For example, this returns captured output to OpenCode:
|
`exec-lua` and `write-file` address a computer by that `traposId`. `exec-lua` runs Lua
|
||||||
|
on the target and returns captured output to OpenCode:
|
||||||
|
|
||||||
```lua
|
```lua
|
||||||
print('captured in MCP output')
|
print('captured in MCP output')
|
||||||
@ -118,16 +125,22 @@ term.setCursorPos(1, 1)
|
|||||||
term.write('visible on screen')
|
term.write('visible on screen')
|
||||||
```
|
```
|
||||||
|
|
||||||
`exec-lua` is powerful and unsafe by design: it can do anything the linked computer can do, including file, peripheral, turtle, and reboot operations. Only run `mcp-computer` against a bridge you trust.
|
`exec-lua` is powerful and unsafe by design: it can do anything the linked computer can
|
||||||
|
do, including file, peripheral, turtle, and reboot operations. The trust boundary is the
|
||||||
|
authenticated gateway connection (`sandbox.url` + `SANDBOX_PASSWORD`) plus the local-only
|
||||||
|
MCP bind — only connect a computer to a gateway you control.
|
||||||
|
|
||||||
The bridge also exposes `write-file`, which writes content to a path on one linked computer by id and overwrites any existing file. It follows normal ComputerCraft filesystem behavior, so missing parent directories fail instead of being created automatically.
|
`write-file` writes content to a path on the target computer and overwrites any existing
|
||||||
|
file. It follows normal ComputerCraft filesystem behavior, so missing parent directories
|
||||||
|
fail instead of being created automatically.
|
||||||
|
|
||||||
## Quick Fixes
|
## Quick Fixes
|
||||||
|
|
||||||
- `ai` says missing `opencc.server_url`: run the `set opencc.server_url ...` command again.
|
- `ai` says missing `opencc.server_url`: run the `set opencc.server_url ...` command again.
|
||||||
- `ai` cannot reach server: check `opencode serve`, public host, port `4242`, and ComputerCraft HTTP rules.
|
- `ai` cannot reach server: check `opencode serve`, public host, port `4242`, and ComputerCraft HTTP rules.
|
||||||
- `mcp-computer` says WebSocket unavailable: enable ComputerCraft HTTP/WebSocket support.
|
- `sandbox health` fails: enable ComputerCraft HTTP/WebSocket support and check `sandbox.url` (port `4444`).
|
||||||
- MCP sees no computers: keep `mcp-computer ws://<public-host>:4243` running in-game.
|
- MCP sees no computers: confirm `sandbox.url` is set and the computer was rebooted so `servers/sandbox` is running.
|
||||||
- `exec-lua` or `write-file` is missing after updating the bridge: restart OpenCode so it reloads the MCP tool list.
|
- `exec-lua` / `write-file` return `unknown_type`: that computer is not running `servers/mcp-computer-server` (set `sandbox.url` and reboot).
|
||||||
|
- `exec-lua` or `write-file` is missing after updating: restart OpenCode so it reloads the MCP tool list.
|
||||||
|
|
||||||
More detail: [`opencode_server_guide.md`](opencode_server_guide.md), [`public-ports.md`](public-ports.md).
|
More detail: [`opencode_server_guide.md`](opencode_server_guide.md), [`public-ports.md`](public-ports.md).
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "TrapOS",
|
"name": "TrapOS",
|
||||||
"version": "0.9.4",
|
"version": "0.9.5",
|
||||||
"branch": "master",
|
"branch": "master",
|
||||||
"packages": [
|
"packages": [
|
||||||
"trapos"
|
"trapos"
|
||||||
|
|||||||
@ -6,8 +6,8 @@
|
|||||||
"trapos-net": "0.3.0",
|
"trapos-net": "0.3.0",
|
||||||
"trapos-ui": "0.2.2",
|
"trapos-ui": "0.2.2",
|
||||||
"trapos-ai": "0.7.0",
|
"trapos-ai": "0.7.0",
|
||||||
"trapos-sandbox": "0.2.6",
|
"trapos-sandbox": "0.3.0",
|
||||||
"trapos-sandbox-legacy": "0.2.2",
|
"trapos-sandbox-legacy": "0.3.0",
|
||||||
"trapos": "0.9.4"
|
"trapos": "0.9.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,16 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "trapos-sandbox-legacy",
|
"name": "trapos-sandbox-legacy",
|
||||||
"version": "0.2.2",
|
"version": "0.3.0",
|
||||||
"description": "[legacy] TrapOS sandbox programs for ccpm experiments and Lua learning",
|
"description": "[legacy] TrapOS sandbox programs for ccpm experiments and Lua learning",
|
||||||
"dependencies": ["trapos-core"],
|
"dependencies": ["trapos-core"],
|
||||||
"files": [
|
"files": [
|
||||||
"apis/libcarre.lua",
|
"apis/libcarre.lua",
|
||||||
"apis/libmcpcomputer.lua",
|
|
||||||
"programs/carre.lua",
|
"programs/carre.lua",
|
||||||
"programs/creeper.lua",
|
"programs/creeper.lua",
|
||||||
"programs/mouton.lua",
|
"programs/mouton.lua"
|
||||||
"programs/mcp-computer.lua",
|
|
||||||
"servers/mcp-computer-server.lua"
|
|
||||||
],
|
],
|
||||||
"autostart": ["servers/mcp-computer-server"]
|
"autostart": []
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "trapos-sandbox",
|
"name": "trapos-sandbox",
|
||||||
"version": "0.2.6",
|
"version": "0.3.0",
|
||||||
"description": "TrapOS sandbox gateway client: WS daemon (servers/sandbox) + sandbox command",
|
"description": "TrapOS sandbox gateway client: WS daemon (servers/sandbox) + sandbox command + MCP exec/write server",
|
||||||
"dependencies": ["trapos-core"],
|
"dependencies": ["trapos-core"],
|
||||||
"files": [
|
"files": [
|
||||||
"apis/libsandbox.lua",
|
"apis/libsandbox.lua",
|
||||||
|
"apis/libmcpcomputer.lua",
|
||||||
"programs/sandbox.lua",
|
"programs/sandbox.lua",
|
||||||
"servers/sandbox.lua"
|
"servers/sandbox.lua",
|
||||||
|
"servers/mcp-computer-server.lua"
|
||||||
],
|
],
|
||||||
"autostart": ["servers/sandbox"]
|
"autostart": ["servers/sandbox", "servers/mcp-computer-server"]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trapos",
|
"name": "trapos",
|
||||||
"version": "0.9.4",
|
"version": "0.9.5",
|
||||||
"description": "TrapOS full install meta-package",
|
"description": "TrapOS full install meta-package",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"trapos-boot",
|
"trapos-boot",
|
||||||
|
|||||||
@ -1,115 +0,0 @@
|
|||||||
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 <ws-url>');
|
|
||||||
print(' mcp-computer -url <ws-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');
|
|
||||||
print();
|
|
||||||
print('with no URL, falls back to the mcp-computer.ws-url setting.');
|
|
||||||
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.resolveUrl(args, settings);
|
|
||||||
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
|
|
||||||
@ -1,23 +1,54 @@
|
|||||||
|
-- MCP computer server: reacts to sandbox gateway requests.
|
||||||
|
--
|
||||||
|
-- No longer owns a websocket. It registers `exec-lua` / `write-file` handlers on the
|
||||||
|
-- sandbox daemon's event bus (apis/libsandbox.serve), so requests arrive over the one
|
||||||
|
-- gateway connection owned by servers/sandbox.lua and replies are sent back through it.
|
||||||
|
-- It only handles requests while a sandbox is configured, so exec stays gated behind
|
||||||
|
-- the same trusted, authenticated connection as the rest of the gateway.
|
||||||
|
|
||||||
|
local createSandbox = require('/apis/libsandbox');
|
||||||
local createMcpComputer = require('/apis/libmcpcomputer');
|
local createMcpComputer = require('/apis/libmcpcomputer');
|
||||||
local createVersion = require('/apis/libversion');
|
local createVersion = require('/apis/libversion');
|
||||||
|
|
||||||
local WS_URL_SETTING = 'mcp-computer.ws-url';
|
local URL_SETTING = 'sandbox.url';
|
||||||
|
|
||||||
local url = settings.get(WS_URL_SETTING);
|
local url = settings.get(URL_SETTING);
|
||||||
if type(url) ~= 'string' or url == '' then
|
if type(url) ~= 'string' or url == '' then
|
||||||
print('mcp-computer-server: ' .. WS_URL_SETTING .. ' not set, daemon inactive.');
|
print('mcp-computer-server: ' .. URL_SETTING .. ' not set, exec/write handlers inactive.');
|
||||||
return;
|
return;
|
||||||
end
|
end
|
||||||
|
|
||||||
if not http or not http.websocket then
|
local el = _G.bootEventLoop;
|
||||||
print('mcp-computer-server: HTTP/WebSocket unavailable, daemon inactive.');
|
if not el then
|
||||||
|
print('mcp-computer-server: no boot eventloop, handlers inactive.');
|
||||||
return;
|
return;
|
||||||
end
|
end
|
||||||
|
|
||||||
createMcpComputer().startSession({
|
local sandbox = createSandbox();
|
||||||
eventloop = _G.bootEventLoop,
|
local mcp = createMcpComputer();
|
||||||
url = url,
|
|
||||||
os = os,
|
|
||||||
});
|
|
||||||
|
|
||||||
print('mcp-computer-server v' .. createVersion().forSelf() .. ' started (' .. url .. ').');
|
-- A code error or a failed file write is a *successful* round-trip that carries an
|
||||||
|
-- ok:false result (so captured output survives the reply); only a thrown handler or
|
||||||
|
-- a dropped connection becomes a transport-level failure.
|
||||||
|
sandbox.serve('exec-lua', function(payload)
|
||||||
|
local result = mcp.executeLua(payload and payload.code);
|
||||||
|
return true, {
|
||||||
|
ok = result.ok,
|
||||||
|
returns = result.returns or {},
|
||||||
|
output = result.output or '',
|
||||||
|
error = result.error,
|
||||||
|
};
|
||||||
|
end, { eventloop = el });
|
||||||
|
|
||||||
|
sandbox.serve('write-file', function(payload)
|
||||||
|
local result = mcp.writeFile(payload and payload.path, payload and payload.content);
|
||||||
|
return true, {
|
||||||
|
ok = result.ok,
|
||||||
|
path = result.path,
|
||||||
|
bytes = result.bytes,
|
||||||
|
error = result.error,
|
||||||
|
};
|
||||||
|
end, { eventloop = el });
|
||||||
|
|
||||||
|
print('mcp-computer-server v' .. createVersion().forSelf()
|
||||||
|
.. ' started (serving exec-lua/write-file over sandbox).');
|
||||||
|
|||||||
@ -3,10 +3,6 @@ local createMcpComputer = require('/apis/libmcpcomputer');
|
|||||||
|
|
||||||
local testlib = createLibTest({ ... });
|
local testlib = createLibTest({ ... });
|
||||||
|
|
||||||
local function packed(...)
|
|
||||||
return table.pack(...);
|
|
||||||
end
|
|
||||||
|
|
||||||
local function fakeOs(id, label)
|
local function fakeOs(id, label)
|
||||||
return {
|
return {
|
||||||
getComputerID = function() return id; end,
|
getComputerID = function() return id; end,
|
||||||
@ -14,12 +10,6 @@ local function fakeOs(id, label)
|
|||||||
};
|
};
|
||||||
end
|
end
|
||||||
|
|
||||||
local function fakeSettings(values)
|
|
||||||
return {
|
|
||||||
get = function(key) return values[key]; end,
|
|
||||||
};
|
|
||||||
end
|
|
||||||
|
|
||||||
local function fakeFs()
|
local function fakeFs()
|
||||||
local files = {};
|
local files = {};
|
||||||
return {
|
return {
|
||||||
@ -44,28 +34,6 @@ local function fakeFs()
|
|||||||
};
|
};
|
||||||
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()
|
testlib.test('formatPong includes computer id and label', function()
|
||||||
local mcpComputer = createMcpComputer();
|
local mcpComputer = createMcpComputer();
|
||||||
|
|
||||||
@ -79,76 +47,6 @@ testlib.test('formatPong renders missing or empty labels as null', function()
|
|||||||
testlib.assertEquals(mcpComputer.formatPong(fakeOs(8, '')), 'pong from 8 (Label: null)');
|
testlib.assertEquals(mcpComputer.formatPong(fakeOs(8, '')), 'pong from 8 (Label: null)');
|
||||||
end);
|
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('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({
|
|
||||||
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('executeLua captures output and return values', function()
|
testlib.test('executeLua captures output and return values', function()
|
||||||
local mcpComputer = createMcpComputer();
|
local mcpComputer = createMcpComputer();
|
||||||
local result = mcpComputer.executeLua("print('hello', 'world'); write('tail'); return 2 + 3, nil, 'ok'");
|
local result = mcpComputer.executeLua("print('hello', 'world'); write('tail'); return 2 + 3, nil, 'ok'");
|
||||||
@ -204,49 +102,6 @@ testlib.test('executeLua leaves direct terminal writes out of captured output',
|
|||||||
testlib.assertEquals(result.returns[1].value, 'done');
|
testlib.assertEquals(result.returns[1].value, 'done');
|
||||||
end);
|
end);
|
||||||
|
|
||||||
testlib.test('handleRequest executes lua code', function()
|
|
||||||
local mcpComputer = createMcpComputer();
|
|
||||||
local response = mcpComputer.handleRequest({
|
|
||||||
type = 'request',
|
|
||||||
id = 'req-exec',
|
|
||||||
method = 'exec-lua',
|
|
||||||
params = { code = "print('ran'); return true" },
|
|
||||||
}, fakeOs(42, 'worker'));
|
|
||||||
|
|
||||||
testlib.assertEquals(response.type, 'response');
|
|
||||||
testlib.assertEquals(response.id, 'req-exec');
|
|
||||||
testlib.assertEquals(response.ok, true);
|
|
||||||
testlib.assertEquals(response.result.output, 'ran\n');
|
|
||||||
testlib.assertEquals(response.result.returns[1].type, 'boolean');
|
|
||||||
testlib.assertEquals(response.result.returns[1].value, true);
|
|
||||||
end);
|
|
||||||
|
|
||||||
testlib.test('handleRequest reports invalid exec-lua code', function()
|
|
||||||
local mcpComputer = createMcpComputer();
|
|
||||||
local response = mcpComputer.handleRequest({
|
|
||||||
type = 'request',
|
|
||||||
id = 'req-empty-exec',
|
|
||||||
method = 'exec-lua',
|
|
||||||
params = { code = '' },
|
|
||||||
}, fakeOs(42, 'worker'));
|
|
||||||
|
|
||||||
testlib.assertEquals(response.type, 'response');
|
|
||||||
testlib.assertEquals(response.id, 'req-empty-exec');
|
|
||||||
testlib.assertEquals(response.ok, false);
|
|
||||||
testlib.assertEquals(response.result.output, '');
|
|
||||||
testlib.assertTrue(string.find(response.error, 'non-empty string', 1, true));
|
|
||||||
|
|
||||||
local missing = mcpComputer.handleRequest({
|
|
||||||
type = 'request',
|
|
||||||
id = 'req-missing-exec',
|
|
||||||
method = 'exec-lua',
|
|
||||||
params = {},
|
|
||||||
}, fakeOs(42, 'worker'));
|
|
||||||
|
|
||||||
testlib.assertEquals(missing.ok, false);
|
|
||||||
testlib.assertTrue(string.find(missing.error, 'non-empty string', 1, true));
|
|
||||||
end);
|
|
||||||
|
|
||||||
testlib.test('writeFile writes and overwrites file content', function()
|
testlib.test('writeFile writes and overwrites file content', function()
|
||||||
local mcpComputer = createMcpComputer();
|
local mcpComputer = createMcpComputer();
|
||||||
local fsLike = fakeFs();
|
local fsLike = fakeFs();
|
||||||
@ -288,42 +143,4 @@ testlib.test('writeFile validates path and content', function()
|
|||||||
testlib.assertTrue(string.find(missingParent.error, 'No such file', 1, true));
|
testlib.assertTrue(string.find(missingParent.error, 'No such file', 1, true));
|
||||||
end);
|
end);
|
||||||
|
|
||||||
testlib.test('handleRequest writes files', function()
|
|
||||||
local mcpComputer = createMcpComputer();
|
|
||||||
local response = mcpComputer.handleRequest({
|
|
||||||
type = 'request',
|
|
||||||
id = 'req-write',
|
|
||||||
method = 'write-file',
|
|
||||||
params = { path = 'mcp-write-test.txt', content = 'from mcp' },
|
|
||||||
}, fakeOs(42, 'worker'));
|
|
||||||
|
|
||||||
testlib.assertEquals(response.type, 'response');
|
|
||||||
testlib.assertEquals(response.id, 'req-write');
|
|
||||||
testlib.assertEquals(response.ok, true);
|
|
||||||
testlib.assertEquals(response.result.path, 'mcp-write-test.txt');
|
|
||||||
testlib.assertEquals(response.result.bytes, 8);
|
|
||||||
fs.delete('mcp-write-test.txt');
|
|
||||||
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();
|
testlib.run();
|
||||||
|
|||||||
@ -93,6 +93,41 @@ testlib.test('onMessage rejects unknown reverse type with unknown_type', functio
|
|||||||
testlib.assertEquals(sent[1].error.code, 'unknown_type');
|
testlib.assertEquals(sent[1].error.code, 'unknown_type');
|
||||||
end);
|
end);
|
||||||
|
|
||||||
|
testlib.test('onMessage routes a served reverse type to trapos_sandbox_request', function()
|
||||||
|
local sandbox = createSandbox();
|
||||||
|
local requested = {};
|
||||||
|
sandbox.onMessage(encode({
|
||||||
|
event = 'gateway_request', type = 'exec-lua', messageId = 'g-3', payload = { code = 'x' },
|
||||||
|
}), {
|
||||||
|
traposId = '7:base',
|
||||||
|
send = function() error('served type must not reply directly', 0); end,
|
||||||
|
isServed = function(t) return t == 'exec-lua'; end,
|
||||||
|
queueRequest = function(messageId, msgType, payload)
|
||||||
|
requested[#requested + 1] = { messageId = messageId, msgType = msgType, payload = payload };
|
||||||
|
end,
|
||||||
|
});
|
||||||
|
|
||||||
|
testlib.assertEquals(#requested, 1);
|
||||||
|
testlib.assertEquals(requested[1].messageId, 'g-3');
|
||||||
|
testlib.assertEquals(requested[1].msgType, 'exec-lua');
|
||||||
|
testlib.assertEquals(requested[1].payload.code, 'x');
|
||||||
|
end);
|
||||||
|
|
||||||
|
testlib.test('onMessage replies unknown_type for a non-served reverse type', function()
|
||||||
|
local sandbox = createSandbox();
|
||||||
|
local sent = {};
|
||||||
|
sandbox.onMessage(encode({ event = 'gateway_request', type = 'exec-lua', messageId = 'g-4' }), {
|
||||||
|
traposId = '7:base',
|
||||||
|
send = function(frame) sent[#sent + 1] = frame; end,
|
||||||
|
isServed = function() return false; end,
|
||||||
|
queueRequest = function() error('non-served type must not route', 0); end,
|
||||||
|
});
|
||||||
|
|
||||||
|
testlib.assertEquals(#sent, 1);
|
||||||
|
testlib.assertEquals(sent[1].ok, false);
|
||||||
|
testlib.assertEquals(sent[1].error.code, 'unknown_type');
|
||||||
|
end);
|
||||||
|
|
||||||
testlib.test('onMessage drives hello via onHello', function()
|
testlib.test('onMessage drives hello via onHello', function()
|
||||||
local sandbox = createSandbox();
|
local sandbox = createSandbox();
|
||||||
local calls = {};
|
local calls = {};
|
||||||
@ -421,4 +456,142 @@ testlib.test('request times out when the timer fires first', function()
|
|||||||
testlib.assertEquals(err.code, 'timeout');
|
testlib.assertEquals(err.code, 'timeout');
|
||||||
end);
|
end);
|
||||||
|
|
||||||
|
-- daemon-side serve/respond wiring ----------------------------------------
|
||||||
|
|
||||||
|
local function readySession()
|
||||||
|
local ctx = startSession();
|
||||||
|
local ws = fakes.fakeWs();
|
||||||
|
ctx.el.fire('websocket_success', ctx.url, ws);
|
||||||
|
ctx.el.fire('websocket_message', ctx.url, encode({ event = 'gateway_response', type = 'hello', ok = true, payload = {} }));
|
||||||
|
ctx.ws = ws;
|
||||||
|
return ctx;
|
||||||
|
end
|
||||||
|
|
||||||
|
testlib.test('startSession routes an advertised reverse type to trapos_sandbox_request', function()
|
||||||
|
local ctx = readySession();
|
||||||
|
ctx.el.fire('trapos_sandbox_serve', 'exec-lua');
|
||||||
|
local sentBefore = #ctx.ws.sent;
|
||||||
|
|
||||||
|
ctx.el.fire('websocket_message', ctx.url, encode({
|
||||||
|
event = 'gateway_request', type = 'exec-lua', messageId = 'g-9', payload = { code = 'x' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
-- No immediate reply on the wire; a request event is queued for the server instead.
|
||||||
|
testlib.assertEquals(#ctx.ws.sent, sentBefore);
|
||||||
|
local req = ctx.queued[#ctx.queued];
|
||||||
|
testlib.assertEquals(req[1], 'trapos_sandbox_request');
|
||||||
|
testlib.assertEquals(req[2], 'g-9');
|
||||||
|
testlib.assertEquals(req[3], 'exec-lua');
|
||||||
|
testlib.assertEquals(req[4].code, 'x');
|
||||||
|
end);
|
||||||
|
|
||||||
|
testlib.test('startSession replies unknown_type for an unadvertised reverse type', function()
|
||||||
|
local ctx = readySession();
|
||||||
|
ctx.el.fire('websocket_message', ctx.url, encode({
|
||||||
|
event = 'gateway_request', type = 'exec-lua', messageId = 'g-10',
|
||||||
|
}));
|
||||||
|
|
||||||
|
local frame = textutils.unserializeJSON(ctx.ws.sent[#ctx.ws.sent]);
|
||||||
|
testlib.assertEquals(frame.event, 'computer_response');
|
||||||
|
testlib.assertEquals(frame.ok, false);
|
||||||
|
testlib.assertEquals(frame.error.code, 'unknown_type');
|
||||||
|
end);
|
||||||
|
|
||||||
|
testlib.test('respond handler sends a computer_response when ready', function()
|
||||||
|
local ctx = readySession();
|
||||||
|
local sentBefore = #ctx.ws.sent;
|
||||||
|
|
||||||
|
ctx.el.fire('trapos_sandbox_respond', 'g-11', 'exec-lua', true, { ok = true, output = 'hi' }, nil);
|
||||||
|
|
||||||
|
testlib.assertEquals(#ctx.ws.sent, sentBefore + 1);
|
||||||
|
local frame = textutils.unserializeJSON(ctx.ws.sent[#ctx.ws.sent]);
|
||||||
|
testlib.assertEquals(frame.event, 'computer_response');
|
||||||
|
testlib.assertEquals(frame.type, 'exec-lua');
|
||||||
|
testlib.assertEquals(frame.messageId, 'g-11');
|
||||||
|
testlib.assertEquals(frame.ok, true);
|
||||||
|
testlib.assertEquals(frame.payload.output, 'hi');
|
||||||
|
end);
|
||||||
|
|
||||||
|
testlib.test('respond handler drops the reply when not connected', function()
|
||||||
|
local ctx = startSession(); -- never reaches 'ready'
|
||||||
|
-- Must not raise even though there is no live socket to send on.
|
||||||
|
ctx.el.fire('trapos_sandbox_respond', 'g-12', 'exec-lua', true, { ok = true }, nil);
|
||||||
|
testlib.assertEquals(ctx.session.isReady(), false);
|
||||||
|
end);
|
||||||
|
|
||||||
|
-- serve() / respond() helpers ---------------------------------------------
|
||||||
|
|
||||||
|
testlib.test('respond queues a trapos_sandbox_respond event', function()
|
||||||
|
local sandbox = createSandbox();
|
||||||
|
local queued = {};
|
||||||
|
sandbox.respond('mid-1', 'exec-lua', true, { ok = true }, nil, {
|
||||||
|
queueEvent = function(...) queued[#queued + 1] = table.pack(...); end,
|
||||||
|
});
|
||||||
|
|
||||||
|
testlib.assertEquals(#queued, 1);
|
||||||
|
testlib.assertEquals(queued[1][1], 'trapos_sandbox_respond');
|
||||||
|
testlib.assertEquals(queued[1][2], 'mid-1');
|
||||||
|
testlib.assertEquals(queued[1][3], 'exec-lua');
|
||||||
|
testlib.assertEquals(queued[1][4], true);
|
||||||
|
testlib.assertEquals(queued[1][5].ok, true);
|
||||||
|
end);
|
||||||
|
|
||||||
|
testlib.test('serve advertises the type and auto-responds on success', function()
|
||||||
|
local sandbox = createSandbox();
|
||||||
|
local el = fakes.fakeEventloop();
|
||||||
|
local queued = {};
|
||||||
|
sandbox.serve('exec-lua', function(payload)
|
||||||
|
return true, { echoed = payload.code };
|
||||||
|
end, { eventloop = el, queueEvent = function(...) queued[#queued + 1] = table.pack(...); end });
|
||||||
|
|
||||||
|
testlib.assertEquals(queued[1][1], 'trapos_sandbox_serve');
|
||||||
|
testlib.assertEquals(queued[1][2], 'exec-lua');
|
||||||
|
|
||||||
|
el.fire('trapos_sandbox_request', 'mid-7', 'exec-lua', { code = 'print(1)' });
|
||||||
|
local respond = queued[#queued];
|
||||||
|
testlib.assertEquals(respond[1], 'trapos_sandbox_respond');
|
||||||
|
testlib.assertEquals(respond[2], 'mid-7');
|
||||||
|
testlib.assertEquals(respond[4], true);
|
||||||
|
testlib.assertEquals(respond[5].echoed, 'print(1)');
|
||||||
|
end);
|
||||||
|
|
||||||
|
testlib.test('serve ignores requests for other types', function()
|
||||||
|
local sandbox = createSandbox();
|
||||||
|
local el = fakes.fakeEventloop();
|
||||||
|
local queued = {};
|
||||||
|
sandbox.serve('exec-lua', function() return true, {}; end, {
|
||||||
|
eventloop = el, queueEvent = function(...) queued[#queued + 1] = table.pack(...); end,
|
||||||
|
});
|
||||||
|
local before = #queued;
|
||||||
|
el.fire('trapos_sandbox_request', 'mid', 'write-file', {});
|
||||||
|
testlib.assertEquals(#queued, before); -- no respond queued
|
||||||
|
end);
|
||||||
|
|
||||||
|
testlib.test('serve responds with the error table on a failure result', function()
|
||||||
|
local sandbox = createSandbox();
|
||||||
|
local el = fakes.fakeEventloop();
|
||||||
|
local queued = {};
|
||||||
|
sandbox.serve('exec-lua', function() return false, { code = 'internal', message = 'nope' }; end, {
|
||||||
|
eventloop = el, queueEvent = function(...) queued[#queued + 1] = table.pack(...); end,
|
||||||
|
});
|
||||||
|
el.fire('trapos_sandbox_request', 'mid', 'exec-lua', {});
|
||||||
|
local respond = queued[#queued];
|
||||||
|
testlib.assertEquals(respond[4], false);
|
||||||
|
testlib.assertEquals(respond[6].code, 'internal');
|
||||||
|
end);
|
||||||
|
|
||||||
|
testlib.test('serve reports a thrown handler as an internal error', function()
|
||||||
|
local sandbox = createSandbox();
|
||||||
|
local el = fakes.fakeEventloop();
|
||||||
|
local queued = {};
|
||||||
|
sandbox.serve('exec-lua', function() error('boom', 0); end, {
|
||||||
|
eventloop = el, queueEvent = function(...) queued[#queued + 1] = table.pack(...); end,
|
||||||
|
});
|
||||||
|
el.fire('trapos_sandbox_request', 'mid', 'exec-lua', {});
|
||||||
|
local respond = queued[#queued];
|
||||||
|
testlib.assertEquals(respond[4], false);
|
||||||
|
testlib.assertEquals(respond[6].code, 'internal');
|
||||||
|
testlib.assertTrue(string.find(respond[6].message, 'boom', 1, true) ~= nil);
|
||||||
|
end);
|
||||||
|
|
||||||
testlib.run();
|
testlib.run();
|
||||||
|
|||||||
13
tools/mcp-bridge/README.md
Normal file
13
tools/mcp-bridge/README.md
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
# mcp-bridge (deprecated, except the opencode proxy)
|
||||||
|
|
||||||
|
> **Deprecated.** The MCP server (`probe-computers` / `exec-lua` / `write-file`) and the
|
||||||
|
> CC-link WebSocket server have been migrated to `tools/trap-sandbox`, which serves the
|
||||||
|
> same tools over the sandbox gateway and addresses computers by `traposId`. See
|
||||||
|
> [ADR-0019](../../docs/adrs/adr-0019-mcp-over-sandbox-gateway.md).
|
||||||
|
|
||||||
|
This tool is kept alive only for its **opencode HTTP-over-WS proxy**, still used by the
|
||||||
|
in-game `ai` bridge mode (`apis/libhttpws.lua`, `apis/libai.lua`, `programs/ai.lua`) to
|
||||||
|
bypass ComputerCraft's ~60s HTTP cap. Once that proxy is migrated onto the gateway, this
|
||||||
|
tool can be removed entirely.
|
||||||
|
|
||||||
|
Do not add new MCP features here — add them to `tools/trap-sandbox/src/modules/mcp`.
|
||||||
@ -1,39 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
import { callExecLua, formatFailure, startBridge, startCraftos, waitForComputers } from "./harness.js";
|
|
||||||
|
|
||||||
test("exec-lua runs code through the real TrapOS mcp-computer program", async () => {
|
|
||||||
const bridge = await startBridge();
|
|
||||||
const craftos = startCraftos("/programs/mcp-computer.lua", {
|
|
||||||
mountRepo: true,
|
|
||||||
shellArgs: [bridge.linkUrl],
|
|
||||||
timeoutMs: 15_000,
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
await waitForComputers(bridge.registry, 1, 12_000);
|
|
||||||
const text = await callExecLua(bridge.mcpUrl, 0, "print('hello from exec'); return 2 + 3");
|
|
||||||
assert.match(text, /^computer: 0 \(Label: null\)\nok: true\nreturns: \[\{.*\}\]\noutput:\nhello from exec$/);
|
|
||||||
assert.match(text, /"type":"number"/);
|
|
||||||
assert.match(text, /"value":5/);
|
|
||||||
|
|
||||||
const runtimeError = await callExecLua(bridge.mcpUrl, 0, "print('before runtime error'); error('boom', 0)");
|
|
||||||
assert.equal(runtimeError, "computer: 0 (Label: null)\nok: false\nerror: boom\noutput:\nbefore runtime error");
|
|
||||||
|
|
||||||
const syntaxError = await callExecLua(bridge.mcpUrl, 0, "print('unterminated'\nreturn 1");
|
|
||||||
assert.match(syntaxError, /^computer: 0 \(Label: null\)\nok: false\nerror: .+\noutput:$/);
|
|
||||||
assert.match(syntaxError, /near|expected|unexpected/);
|
|
||||||
|
|
||||||
const visibleWrite = await callExecLua(bridge.mcpUrl, 0, "term.clear(); term.setCursorPos(1, 1); term.write('visible'); return 'done'");
|
|
||||||
assert.match(visibleWrite, /^computer: 0 \(Label: null\)\nok: true\nreturns: \[\{.*\}\]\noutput:$/);
|
|
||||||
assert.match(visibleWrite, /"type":"string"/);
|
|
||||||
assert.match(visibleWrite, /"value":"done"/);
|
|
||||||
} 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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
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: [bridge.linkUrl],
|
|
||||||
timeoutMs: 15_000,
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
await waitForComputers(bridge.registry, 1, 12_000);
|
|
||||||
const text = await callProbeComputers(bridge.mcpUrl);
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
import { callProbeComputers, formatFailure, startBridge, startCraftos, waitForComputers } from "./harness.js";
|
|
||||||
|
|
||||||
test("probe-computers aggregates two real TrapOS mcp-computer programs", async () => {
|
|
||||||
const bridge = await startBridge();
|
|
||||||
const craftosA = startCraftos("/programs/mcp-computer.lua", {
|
|
||||||
computerId: 101,
|
|
||||||
computerLabel: "trap-A",
|
|
||||||
mountRepo: true,
|
|
||||||
shellArgs: [bridge.linkUrl],
|
|
||||||
timeoutMs: 15_000,
|
|
||||||
});
|
|
||||||
const craftosB = startCraftos("/programs/mcp-computer.lua", {
|
|
||||||
computerId: 202,
|
|
||||||
computerLabel: "trap-B",
|
|
||||||
mountRepo: true,
|
|
||||||
shellArgs: [bridge.linkUrl],
|
|
||||||
timeoutMs: 15_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await waitForComputers(bridge.registry, 2, 12_000);
|
|
||||||
const lines = (await callProbeComputers(bridge.mcpUrl)).split("\n");
|
|
||||||
assert.equal(lines.length, 2);
|
|
||||||
assert(lines.some((line) => /^pong from \d+ \(Label: trap-A\)$/.test(line)));
|
|
||||||
assert(lines.some((line) => /^pong from \d+ \(Label: trap-B\)$/.test(line)));
|
|
||||||
} catch (error) {
|
|
||||||
craftosA.abort();
|
|
||||||
craftosB.abort();
|
|
||||||
const [resultA, resultB] = await Promise.all([craftosA.done, craftosB.done]);
|
|
||||||
const output = ["--- craftos A ---", resultA.output, "--- craftos B ---", resultB.output].join("\n");
|
|
||||||
throw new Error(formatFailure(error instanceof Error ? error.message : String(error), output), { cause: error });
|
|
||||||
} finally {
|
|
||||||
craftosA.abort();
|
|
||||||
craftosB.abort();
|
|
||||||
await Promise.all([craftosA.done, craftosB.done]);
|
|
||||||
await bridge.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
import { callExecLua, callWriteFile, formatFailure, startBridge, startCraftos, waitForComputers } from "./harness.js";
|
|
||||||
|
|
||||||
test("write-file writes through the real TrapOS mcp-computer program", async () => {
|
|
||||||
const bridge = await startBridge();
|
|
||||||
const craftos = startCraftos("/programs/mcp-computer.lua", {
|
|
||||||
mountRepo: true,
|
|
||||||
shellArgs: [bridge.linkUrl],
|
|
||||||
timeoutMs: 15_000,
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
await waitForComputers(bridge.registry, 1, 12_000);
|
|
||||||
|
|
||||||
const first = await callWriteFile(bridge.mcpUrl, 0, "mcp-write-test.txt", "hello\nworld");
|
|
||||||
assert.equal(first, "computer: 0 (Label: null)\nok: true\npath: mcp-write-test.txt\nbytes: 11");
|
|
||||||
|
|
||||||
const readFirst = await callExecLua(
|
|
||||||
bridge.mcpUrl,
|
|
||||||
0,
|
|
||||||
"local h = fs.open('mcp-write-test.txt', 'r'); local c = h.readAll(); h.close(); return c",
|
|
||||||
);
|
|
||||||
assert.match(readFirst, /"value":"hello\\nworld"/);
|
|
||||||
|
|
||||||
const second = await callWriteFile(bridge.mcpUrl, 0, "mcp-write-test.txt", "overwritten");
|
|
||||||
assert.equal(second, "computer: 0 (Label: null)\nok: true\npath: mcp-write-test.txt\nbytes: 11");
|
|
||||||
|
|
||||||
const readSecond = await callExecLua(
|
|
||||||
bridge.mcpUrl,
|
|
||||||
0,
|
|
||||||
"local h = fs.open('mcp-write-test.txt', 'r'); local c = h.readAll(); h.close(); fs.delete('mcp-write-test.txt'); return c",
|
|
||||||
);
|
|
||||||
assert.match(readSecond, /"value":"overwritten"/);
|
|
||||||
} 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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trap-sandbox",
|
"name": "trap-sandbox",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import Fastify, { type FastifyInstance, type FastifyServerOptions } from "fastify";
|
import Fastify, { type FastifyInstance, type FastifyServerOptions } from "fastify";
|
||||||
import websocket from "@fastify/websocket";
|
import websocket from "@fastify/websocket";
|
||||||
import { resolveAccount } from "./auth.js";
|
import { resolveAccount, LOCAL_ACCOUNT_ID } from "./auth.js";
|
||||||
import { createOpencodeProbe } from "./opencode.js";
|
import { createOpencodeProbe } from "./opencode.js";
|
||||||
import gatewayModule from "./modules/trapos-cloud-gateway/index.js";
|
import gatewayModule from "./modules/trapos-cloud-gateway/index.js";
|
||||||
import { GatewayRegistry } from "./modules/trapos-cloud-gateway/registry.js";
|
import { GatewayRegistry } from "./modules/trapos-cloud-gateway/registry.js";
|
||||||
|
import mcpModule from "./modules/mcp/index.js";
|
||||||
|
|
||||||
export type Config = {
|
export type Config = {
|
||||||
host: string;
|
host: string;
|
||||||
@ -13,17 +14,27 @@ export type Config = {
|
|||||||
opencodeUrl?: string;
|
opencodeUrl?: string;
|
||||||
opencodeUsername: string;
|
opencodeUsername: string;
|
||||||
opencodeServerPassword?: string;
|
opencodeServerPassword?: string;
|
||||||
|
// MCP HTTP endpoint (exec-lua / write-file / probe-computers). Bound to mcpHost,
|
||||||
|
// which defaults to 127.0.0.1 so the powerful tools stay local-only.
|
||||||
|
mcpEnabled: boolean;
|
||||||
|
mcpHost: string;
|
||||||
|
mcpPort: number;
|
||||||
|
mcpDefaultTimeoutMs: number;
|
||||||
|
mcpMaxTimeoutMs: number;
|
||||||
|
mcpProbeTimeoutMs: number;
|
||||||
logger: FastifyServerOptions["logger"];
|
logger: FastifyServerOptions["logger"];
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAX_PAYLOAD_BYTES = 1024 * 1024; // ~1MB
|
const MAX_PAYLOAD_BYTES = 1024 * 1024; // ~1MB
|
||||||
|
|
||||||
// Composes the service: registry + opencode probe + auth seam, wired into the
|
// Composes the service: registry + opencode probe + auth seam, wired into the
|
||||||
// trapos-cloud-gateway module. Never reads process.env (that is index.ts's job),
|
// trapos-cloud-gateway module, plus an optional MCP app sharing the same registry.
|
||||||
// so it is fully constructable from tests.
|
// Never reads process.env (that is index.ts's job), so it is fully constructable
|
||||||
|
// from tests. The MCP app is a separate Fastify instance (its own host/port) so it
|
||||||
|
// can bind to localhost independently of the public gateway listener.
|
||||||
export async function createApp(
|
export async function createApp(
|
||||||
config: Config,
|
config: Config,
|
||||||
): Promise<{ app: FastifyInstance; registry: GatewayRegistry }> {
|
): Promise<{ app: FastifyInstance; mcpApp: FastifyInstance | null; registry: GatewayRegistry }> {
|
||||||
const app = Fastify({ logger: config.logger, disableRequestLogging: true });
|
const app = Fastify({ logger: config.logger, disableRequestLogging: true });
|
||||||
|
|
||||||
const registry = new GatewayRegistry(app.log, config.requestTimeoutMs);
|
const registry = new GatewayRegistry(app.log, config.requestTimeoutMs);
|
||||||
@ -40,5 +51,19 @@ export async function createApp(
|
|||||||
resolveAccount: (secret) => resolveAccount(secret, config.sandboxPassword),
|
resolveAccount: (secret) => resolveAccount(secret, config.sandboxPassword),
|
||||||
});
|
});
|
||||||
|
|
||||||
return { app, registry };
|
const mcpApp = config.mcpEnabled ? await createMcpApp(config, registry) : null;
|
||||||
|
|
||||||
|
return { app, mcpApp, registry };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createMcpApp(config: Config, registry: GatewayRegistry): Promise<FastifyInstance> {
|
||||||
|
const mcpApp = Fastify({ logger: config.logger, disableRequestLogging: true });
|
||||||
|
await mcpApp.register(mcpModule, {
|
||||||
|
registry,
|
||||||
|
accountId: LOCAL_ACCOUNT_ID,
|
||||||
|
defaultTimeoutMs: config.mcpDefaultTimeoutMs,
|
||||||
|
maxTimeoutMs: config.mcpMaxTimeoutMs,
|
||||||
|
probeTimeoutMs: config.mcpProbeTimeoutMs,
|
||||||
|
});
|
||||||
|
return mcpApp;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
export type AccountId = "local";
|
export type AccountId = "local";
|
||||||
|
|
||||||
|
// The single account today. Centralized so the gateway and the MCP server agree on
|
||||||
|
// the registry key (the future multi-account swap touches only resolveAccount).
|
||||||
|
export const LOCAL_ACCOUNT_ID: AccountId = "local";
|
||||||
|
|
||||||
// The single future swap-point: credential -> account home. Today it also owns the
|
// The single future swap-point: credential -> account home. Today it also owns the
|
||||||
// password gate. Returns the account id for a valid secret, or null = unauthorized.
|
// password gate. Returns the account id for a valid secret, or null = unauthorized.
|
||||||
// - password unset/empty => auth disabled, any secret (or none) maps to "local"
|
// - password unset/empty => auth disabled, any secret (or none) maps to "local"
|
||||||
|
|||||||
@ -8,25 +8,37 @@ const config: Config = {
|
|||||||
opencodeUrl: process.env.OPENCODE_URL ?? "http://127.0.0.1:4242",
|
opencodeUrl: process.env.OPENCODE_URL ?? "http://127.0.0.1:4242",
|
||||||
opencodeUsername: process.env.OPENCODE_USERNAME ?? "opencode",
|
opencodeUsername: process.env.OPENCODE_USERNAME ?? "opencode",
|
||||||
opencodeServerPassword: process.env.OPENCODE_SERVER_PASSWORD,
|
opencodeServerPassword: process.env.OPENCODE_SERVER_PASSWORD,
|
||||||
|
// MCP endpoint: on by default, bound to localhost. exec-lua / write-file are as
|
||||||
|
// powerful as the linked computer, so the listener stays off the public gateway port.
|
||||||
|
mcpEnabled: process.env.MCP_ENABLED !== "0",
|
||||||
|
mcpHost: process.env.MCP_HOST ?? "127.0.0.1",
|
||||||
|
mcpPort: readPort(process.env.MCP_PORT, 4445),
|
||||||
|
mcpDefaultTimeoutMs: readPositiveInt(process.env.MCP_DEFAULT_TIMEOUT_MS, 30_000),
|
||||||
|
mcpMaxTimeoutMs: readPositiveInt(process.env.MCP_MAX_TIMEOUT_MS, 30_000),
|
||||||
|
mcpProbeTimeoutMs: readPositiveInt(process.env.MCP_PROBE_TIMEOUT_MS, 5_000),
|
||||||
logger: buildLogger(),
|
logger: buildLogger(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const { app } = await createApp(config);
|
const { app, mcpApp } = await createApp(config);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await app.listen({ host: config.host, port: config.port });
|
await app.listen({ host: config.host, port: config.port });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isErrnoException(err) && err.code === "EADDRINUSE") {
|
reportListenError(err, config.host, config.port, "SANDBOX_PORT");
|
||||||
process.stderr.write(
|
|
||||||
`\n[trap-sandbox] Port ${config.port} is already in use on ${config.host}.\n` +
|
|
||||||
`Another instance may be running, or set SANDBOX_PORT to a free port.\n\n`,
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
app.log.error(err);
|
app.log.error(err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mcpApp) {
|
||||||
|
try {
|
||||||
|
await mcpApp.listen({ host: config.mcpHost, port: config.mcpPort });
|
||||||
|
} catch (err) {
|
||||||
|
reportListenError(err, config.mcpHost, config.mcpPort, "MCP_PORT");
|
||||||
|
mcpApp.log.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function buildLogger(): Config["logger"] {
|
function buildLogger(): Config["logger"] {
|
||||||
const level = process.env.LOG_LEVEL ?? "info";
|
const level = process.env.LOG_LEVEL ?? "info";
|
||||||
if (process.env.LOG_PRETTY) {
|
if (process.env.LOG_PRETTY) {
|
||||||
@ -56,3 +68,13 @@ function readPositiveInt(value: string | undefined, fallback: number): number {
|
|||||||
function isErrnoException(err: unknown): err is NodeJS.ErrnoException {
|
function isErrnoException(err: unknown): err is NodeJS.ErrnoException {
|
||||||
return err instanceof Error && "code" in err;
|
return err instanceof Error && "code" in err;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reportListenError(err: unknown, host: string, port: number, portEnv: string): void {
|
||||||
|
if (isErrnoException(err) && err.code === "EADDRINUSE") {
|
||||||
|
process.stderr.write(
|
||||||
|
`\n[trap-sandbox] Port ${port} is already in use on ${host}.\n` +
|
||||||
|
`Another instance may be running, or set ${portEnv} to a free port.\n\n`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
36
tools/trap-sandbox/src/modules/mcp/index.ts
Normal file
36
tools/trap-sandbox/src/modules/mcp/index.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import type { FastifyError, FastifyPluginAsync } from "fastify";
|
||||||
|
import { handleMcpRequest, type McpDeps } from "./tools.js";
|
||||||
|
|
||||||
|
export type McpModuleOptions = McpDeps;
|
||||||
|
|
||||||
|
// HTTP JSON-RPC 2.0 MCP endpoint. Mounted on its own Fastify instance bound to
|
||||||
|
// 127.0.0.1 (see app.ts) so exec-lua / write-file stay off the public gateway port —
|
||||||
|
// the trust boundary is "local process + authenticated gateway connection".
|
||||||
|
const mcpModule: FastifyPluginAsync<McpModuleOptions> = (fastify, opts) => {
|
||||||
|
const deps: McpDeps = opts;
|
||||||
|
|
||||||
|
fastify.get("/health", () => ({ ok: true, computers: deps.registry.count() }));
|
||||||
|
|
||||||
|
fastify.post("/", async (request, reply) => {
|
||||||
|
const result = await handleMcpRequest(request.body, deps);
|
||||||
|
// A JSON-RPC notification (e.g. notifications/initialized) yields null -> 204.
|
||||||
|
if (result === null) {
|
||||||
|
return reply.code(204).send();
|
||||||
|
}
|
||||||
|
return reply.send(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Malformed JSON never reaches the handler (Fastify rejects it first); surface it
|
||||||
|
// as a JSON-RPC parse error rather than Fastify's default validation envelope.
|
||||||
|
fastify.setErrorHandler((error: FastifyError, _request, reply) => {
|
||||||
|
if (error.statusCode === 400) {
|
||||||
|
return reply.code(400).send({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } });
|
||||||
|
}
|
||||||
|
fastify.log.error(error);
|
||||||
|
return reply.code(500).send({ jsonrpc: "2.0", id: null, error: { code: -32603, message: "Internal error" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
export default mcpModule;
|
||||||
268
tools/trap-sandbox/src/modules/mcp/tools.ts
Normal file
268
tools/trap-sandbox/src/modules/mcp/tools.ts
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
import { GatewayError } from "../trapos-cloud-gateway/protocol.js";
|
||||||
|
import type { GatewayRegistry } from "../trapos-cloud-gateway/registry.js";
|
||||||
|
|
||||||
|
// MCP server surface, migrated from tools/mcp-bridge. It rides on the existing
|
||||||
|
// gateway request/reply (GatewayRegistry.request) instead of a dedicated CC-link
|
||||||
|
// socket, and addresses computers by traposId ("<id>:<label>") rather than a numeric
|
||||||
|
// computerId. Custom JSON-RPC 2.0 (no SDK), matching the old bridge's wire shape.
|
||||||
|
|
||||||
|
export type McpDeps = {
|
||||||
|
registry: GatewayRegistry;
|
||||||
|
accountId: string;
|
||||||
|
// Default deadline for exec-lua / write-file when the caller omits timeoutMs.
|
||||||
|
defaultTimeoutMs: number;
|
||||||
|
// Hard ceiling applied to any caller-supplied timeoutMs.
|
||||||
|
maxTimeoutMs: number;
|
||||||
|
// Short deadline for the per-computer ping in probe-computers.
|
||||||
|
probeTimeoutMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type JsonRpcRequest = {
|
||||||
|
jsonrpc?: unknown;
|
||||||
|
id?: unknown;
|
||||||
|
method?: unknown;
|
||||||
|
params?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SERVER_VERSION = "0.2.0";
|
||||||
|
|
||||||
|
const TOOLS = [
|
||||||
|
{
|
||||||
|
name: "probe-computers",
|
||||||
|
description: "Probe all connected TrapOS computers; lists each computer's traposId.",
|
||||||
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exec-lua",
|
||||||
|
description: "Execute Lua code on a connected TrapOS computer.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
traposId: { type: "string", description: 'Target traposId ("<id>:<label>", from probe-computers).' },
|
||||||
|
code: { type: "string", description: "Lua source code to execute." },
|
||||||
|
timeoutMs: { type: "number", description: "Optional host-side timeout in milliseconds, max 30000." },
|
||||||
|
},
|
||||||
|
required: ["traposId", "code"],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "write-file",
|
||||||
|
description: "Write file content on a connected TrapOS computer, overwriting any existing file.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
traposId: { type: "string", description: 'Target traposId ("<id>:<label>", from probe-computers).' },
|
||||||
|
path: { type: "string", description: "Target path on the TrapOS computer." },
|
||||||
|
content: { type: "string", description: "File content to write. Empty strings are allowed." },
|
||||||
|
timeoutMs: { type: "number", description: "Optional host-side timeout in milliseconds, max 30000." },
|
||||||
|
},
|
||||||
|
required: ["traposId", "path", "content"],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function handleMcpRequest(body: unknown, deps: McpDeps): Promise<unknown> {
|
||||||
|
if (Array.isArray(body)) {
|
||||||
|
return Promise.all(body.map((item) => handleSingle(item as JsonRpcRequest, deps)));
|
||||||
|
}
|
||||||
|
return handleSingle(body as JsonRpcRequest, deps);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSingle(request: JsonRpcRequest, deps: McpDeps): Promise<unknown> {
|
||||||
|
const id = request && "id" in request ? request.id : null;
|
||||||
|
if (!request || request.jsonrpc !== "2.0" || typeof request.method !== "string") {
|
||||||
|
return jsonRpcError(id, -32600, "Invalid Request");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === "initialize") {
|
||||||
|
return jsonRpcResult(id, {
|
||||||
|
protocolVersion: "2024-11-05",
|
||||||
|
capabilities: { tools: {} },
|
||||||
|
serverInfo: { name: "trap-sandbox", version: SERVER_VERSION },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === "notifications/initialized") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === "tools/list") {
|
||||||
|
return jsonRpcResult(id, { tools: TOOLS });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === "tools/call") {
|
||||||
|
const params = isRecord(request.params) ? request.params : {};
|
||||||
|
|
||||||
|
if (params.name === "probe-computers") {
|
||||||
|
return jsonRpcResult(id, { content: [{ type: "text", text: await probeComputers(deps) }] });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.name === "exec-lua") {
|
||||||
|
const args = isRecord(params.arguments) ? params.arguments : {};
|
||||||
|
const parsed = parseExecLuaArgs(args, deps);
|
||||||
|
if (!parsed.ok) {
|
||||||
|
return jsonRpcError(id, -32602, parsed.error);
|
||||||
|
}
|
||||||
|
const text = await execLua(deps, parsed.traposId, parsed.code, parsed.timeoutMs);
|
||||||
|
return jsonRpcResult(id, { content: [{ type: "text", text }] });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.name === "write-file") {
|
||||||
|
const args = isRecord(params.arguments) ? params.arguments : {};
|
||||||
|
const parsed = parseWriteFileArgs(args, deps);
|
||||||
|
if (!parsed.ok) {
|
||||||
|
return jsonRpcError(id, -32602, parsed.error);
|
||||||
|
}
|
||||||
|
const text = await writeFile(deps, parsed.traposId, parsed.path, parsed.content, parsed.timeoutMs);
|
||||||
|
return jsonRpcResult(id, { content: [{ type: "text", text }] });
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonRpcError(id, -32602, "Unknown tool");
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonRpcError(id, -32601, "Method not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeComputers(deps: McpDeps): Promise<string> {
|
||||||
|
const traposIds = deps.registry.list(deps.accountId);
|
||||||
|
if (traposIds.length === 0) {
|
||||||
|
return "No computers connected.";
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = await Promise.all(
|
||||||
|
traposIds.map(async (traposId) => {
|
||||||
|
try {
|
||||||
|
await deps.registry.request(deps.accountId, traposId, "ping", {}, deps.probeTimeoutMs);
|
||||||
|
return `ok from ${traposId}`;
|
||||||
|
} catch (err) {
|
||||||
|
return `error from ${traposId}: ${errorText(err)}`;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function execLua(deps: McpDeps, traposId: string, code: string, timeoutMs: number): Promise<string> {
|
||||||
|
let payload: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
payload = await deps.registry.request(deps.accountId, traposId, "exec-lua", { code }, timeoutMs);
|
||||||
|
} catch (err) {
|
||||||
|
return transportError(traposId, err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = payload.ok === true;
|
||||||
|
const output = typeof payload.output === "string" ? payload.output : "";
|
||||||
|
const lines = [`computer: ${traposId}`, `ok: ${ok ? "true" : "false"}`];
|
||||||
|
if (ok) {
|
||||||
|
lines.push(`returns: ${JSON.stringify(Array.isArray(payload.returns) ? payload.returns : [])}`);
|
||||||
|
} else {
|
||||||
|
lines.push(`error: ${typeof payload.error === "string" ? payload.error : "unknown error"}`);
|
||||||
|
}
|
||||||
|
lines.push("output:");
|
||||||
|
if (output !== "") {
|
||||||
|
lines.push(output.endsWith("\n") ? output.slice(0, -1) : output);
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeFile(
|
||||||
|
deps: McpDeps,
|
||||||
|
traposId: string,
|
||||||
|
path: string,
|
||||||
|
content: string,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<string> {
|
||||||
|
let payload: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
payload = await deps.registry.request(deps.accountId, traposId, "write-file", { path, content }, timeoutMs);
|
||||||
|
} catch (err) {
|
||||||
|
return transportError(traposId, err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = payload.ok === true;
|
||||||
|
const lines = [`computer: ${traposId}`, `ok: ${ok ? "true" : "false"}`];
|
||||||
|
if (ok) {
|
||||||
|
lines.push(`path: ${typeof payload.path === "string" ? payload.path : ""}`);
|
||||||
|
lines.push(`bytes: ${typeof payload.bytes === "number" ? payload.bytes : 0}`);
|
||||||
|
} else {
|
||||||
|
lines.push(`error: ${typeof payload.error === "string" ? payload.error : "unknown error"}`);
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExecLuaArgs = { ok: true; traposId: string; code: string; timeoutMs: number } | { ok: false; error: string };
|
||||||
|
|
||||||
|
function parseExecLuaArgs(args: Record<string, unknown>, deps: McpDeps): ExecLuaArgs {
|
||||||
|
if (typeof args.traposId !== "string" || args.traposId.trim() === "") {
|
||||||
|
return { ok: false, error: "traposId must be a non-empty string" };
|
||||||
|
}
|
||||||
|
if (typeof args.code !== "string" || args.code.trim() === "") {
|
||||||
|
return { ok: false, error: "code must be a non-empty string" };
|
||||||
|
}
|
||||||
|
const timeout = parseTimeout(args.timeoutMs, deps);
|
||||||
|
if (!timeout.ok) {
|
||||||
|
return { ok: false, error: timeout.error };
|
||||||
|
}
|
||||||
|
return { ok: true, traposId: args.traposId, code: args.code, timeoutMs: timeout.value };
|
||||||
|
}
|
||||||
|
|
||||||
|
type WriteFileArgs =
|
||||||
|
| { ok: true; traposId: string; path: string; content: string; timeoutMs: number }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
function parseWriteFileArgs(args: Record<string, unknown>, deps: McpDeps): WriteFileArgs {
|
||||||
|
if (typeof args.traposId !== "string" || args.traposId.trim() === "") {
|
||||||
|
return { ok: false, error: "traposId must be a non-empty string" };
|
||||||
|
}
|
||||||
|
if (typeof args.path !== "string" || args.path.trim() === "") {
|
||||||
|
return { ok: false, error: "path must be a non-empty string" };
|
||||||
|
}
|
||||||
|
if (typeof args.content !== "string") {
|
||||||
|
return { ok: false, error: "content must be a string" };
|
||||||
|
}
|
||||||
|
const timeout = parseTimeout(args.timeoutMs, deps);
|
||||||
|
if (!timeout.ok) {
|
||||||
|
return { ok: false, error: timeout.error };
|
||||||
|
}
|
||||||
|
return { ok: true, traposId: args.traposId, path: args.path, content: args.content, timeoutMs: timeout.value };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTimeout(
|
||||||
|
value: unknown,
|
||||||
|
deps: McpDeps,
|
||||||
|
): { ok: true; value: number } | { ok: false; error: string } {
|
||||||
|
if (value === undefined) {
|
||||||
|
return { ok: true, value: Math.min(deps.defaultTimeoutMs, deps.maxTimeoutMs) };
|
||||||
|
}
|
||||||
|
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
||||||
|
return { ok: false, error: "timeoutMs must be a positive finite number" };
|
||||||
|
}
|
||||||
|
return { ok: true, value: Math.min(value, deps.maxTimeoutMs) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// not_connected / timeout / unknown_type / internal — the request never produced a
|
||||||
|
// computer reply. Rendered in the same shape as an operational failure for symmetry.
|
||||||
|
function transportError(traposId: string, err: unknown): string {
|
||||||
|
return `computer: ${traposId}\nok: false\nerror: ${errorText(err)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorText(err: unknown): string {
|
||||||
|
if (err instanceof GatewayError) {
|
||||||
|
return `${err.code}: ${err.message}`;
|
||||||
|
}
|
||||||
|
return err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonRpcResult(id: unknown, result: unknown): unknown {
|
||||||
|
return { jsonrpc: "2.0", id, result };
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonRpcError(id: unknown, code: number, message: string): unknown {
|
||||||
|
return { jsonrpc: "2.0", id, error: { code, message } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
@ -89,6 +89,19 @@ export class GatewayRegistry {
|
|||||||
return this.entries.size;
|
return this.entries.size;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Registered traposIds for an account (newest-wins, so at most one per traposId).
|
||||||
|
// Used by the MCP `probe-computers` tool to enumerate addressable computers.
|
||||||
|
list(accountId: string): string[] {
|
||||||
|
const prefix = this.key(accountId, "");
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const key of this.entries.keys()) {
|
||||||
|
if (key.startsWith(prefix)) {
|
||||||
|
out.push(key.slice(prefix.length));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// Gateway -> computer request/reply. Rejects with a coded GatewayError on
|
// Gateway -> computer request/reply. Rejects with a coded GatewayError on
|
||||||
// not_connected / timeout / response ok:false; resolves with the response payload.
|
// not_connected / timeout / response ok:false; resolves with the response payload.
|
||||||
// The deadline is the caller's concern; defaultTimeoutMs is only a safety ceiling.
|
// The deadline is the caller's concern; defaultTimeoutMs is only a safety ceiling.
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { tmpdir } from "node:os";
|
|||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import type { AddressInfo } from "node:net";
|
import type { AddressInfo } from "node:net";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
import { createApp, type Config } from "../src/app.js";
|
import { createApp, type Config } from "../src/app.js";
|
||||||
import type { GatewayRegistry } from "../src/modules/trapos-cloud-gateway/registry.js";
|
import type { GatewayRegistry } from "../src/modules/trapos-cloud-gateway/registry.js";
|
||||||
|
|
||||||
@ -14,6 +15,9 @@ const REPO_ROOT = join(HERE, "../../..");
|
|||||||
|
|
||||||
export type Sandbox = {
|
export type Sandbox = {
|
||||||
registry: GatewayRegistry;
|
registry: GatewayRegistry;
|
||||||
|
// The MCP Fastify app, sharing the gateway registry. Drive it in-process with
|
||||||
|
// `mcpApp.inject({ method: "POST", url: "/", payload })` — no real listener needed.
|
||||||
|
mcpApp: FastifyInstance;
|
||||||
// Gateway origin (no path). This is what `sandbox.url` expects: libsandbox appends
|
// Gateway origin (no path). This is what `sandbox.url` expects: libsandbox appends
|
||||||
// `/gateway` itself. Pass this to the Lua scripts.
|
// `/gateway` itself. Pass this to the Lua scripts.
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
@ -42,28 +46,44 @@ export async function startSandbox(opts: SandboxOptions = {}): Promise<Sandbox>
|
|||||||
? { level: "info", stream: { write: (line: string) => void logLines.push(line) } }
|
? { level: "info", stream: { write: (line: string) => void logLines.push(line) } }
|
||||||
: { level: "silent" };
|
: { level: "silent" };
|
||||||
|
|
||||||
|
const timeoutMs = opts.requestTimeoutMs ?? 5_000;
|
||||||
const config: Config = {
|
const config: Config = {
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
port: 0,
|
port: 0,
|
||||||
sandboxPassword: opts.sandboxPassword,
|
sandboxPassword: opts.sandboxPassword,
|
||||||
requestTimeoutMs: opts.requestTimeoutMs ?? 5_000,
|
requestTimeoutMs: timeoutMs,
|
||||||
opencodeUrl: opts.opencodeUrl,
|
opencodeUrl: opts.opencodeUrl,
|
||||||
opencodeUsername: "opencode",
|
opencodeUsername: "opencode",
|
||||||
opencodeServerPassword: undefined,
|
opencodeServerPassword: undefined,
|
||||||
|
mcpEnabled: true,
|
||||||
|
mcpHost: "127.0.0.1",
|
||||||
|
mcpPort: 0,
|
||||||
|
mcpDefaultTimeoutMs: timeoutMs,
|
||||||
|
mcpMaxTimeoutMs: 30_000,
|
||||||
|
mcpProbeTimeoutMs: timeoutMs,
|
||||||
logger,
|
logger,
|
||||||
};
|
};
|
||||||
|
|
||||||
const { app, registry } = await createApp(config);
|
const { app, mcpApp, registry } = await createApp(config);
|
||||||
await app.listen({ host: config.host, port: config.port });
|
await app.listen({ host: config.host, port: config.port });
|
||||||
|
// Driven in-process via inject(); ready() loads the plugin without a socket.
|
||||||
|
if (mcpApp === null) {
|
||||||
|
throw new Error("expected mcpApp to be created (mcpEnabled is true)");
|
||||||
|
}
|
||||||
|
await mcpApp.ready();
|
||||||
const { port } = app.server.address() as AddressInfo;
|
const { port } = app.server.address() as AddressInfo;
|
||||||
|
|
||||||
const baseUrl = `ws://127.0.0.1:${port}`;
|
const baseUrl = `ws://127.0.0.1:${port}`;
|
||||||
return {
|
return {
|
||||||
registry,
|
registry,
|
||||||
|
mcpApp,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
socketUrl: `${baseUrl}/gateway`,
|
socketUrl: `${baseUrl}/gateway`,
|
||||||
countLog: (substring) => logLines.filter((line) => line.includes(substring)).length,
|
countLog: (substring) => logLines.filter((line) => line.includes(substring)).length,
|
||||||
close: () => app.close(),
|
close: async () => {
|
||||||
|
await mcpApp.close();
|
||||||
|
await app.close();
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,61 @@
|
|||||||
|
-- Integration-test driver: the REAL reactive MCP computer server. Connects the real
|
||||||
|
-- libsandbox daemon to the gateway and serves exec-lua / write-file via sandbox.serve
|
||||||
|
-- (same wiring as servers/mcp-computer-server.lua), then stays registered so the TS
|
||||||
|
-- side can drive the MCP tools over the gateway.
|
||||||
|
--
|
||||||
|
-- Args (via shell.run): url, secret.
|
||||||
|
-- url gateway origin, e.g. ws://127.0.0.1:PORT (daemon appends /gateway)
|
||||||
|
-- secret shared secret ('' / '-' => none)
|
||||||
|
local args = { ... };
|
||||||
|
local url = args[1];
|
||||||
|
local secret = args[2];
|
||||||
|
if secret == '' or secret == '-' then secret = nil; end
|
||||||
|
|
||||||
|
local createSandbox = require('/apis/libsandbox');
|
||||||
|
local createMcpComputer = require('/apis/libmcpcomputer');
|
||||||
|
local createEventLoop = require('/apis/eventloop');
|
||||||
|
|
||||||
|
_G.bootEventLoop = createEventLoop();
|
||||||
|
local el = _G.bootEventLoop;
|
||||||
|
local sandbox = createSandbox();
|
||||||
|
local mcp = createMcpComputer();
|
||||||
|
|
||||||
|
sandbox.startSession({
|
||||||
|
eventloop = el,
|
||||||
|
url = url,
|
||||||
|
secret = secret,
|
||||||
|
os = os,
|
||||||
|
});
|
||||||
|
|
||||||
|
sandbox.serve('exec-lua', function(payload)
|
||||||
|
local result = mcp.executeLua(payload and payload.code);
|
||||||
|
return true, {
|
||||||
|
ok = result.ok,
|
||||||
|
returns = result.returns or {},
|
||||||
|
output = result.output or '',
|
||||||
|
error = result.error,
|
||||||
|
};
|
||||||
|
end, { eventloop = el });
|
||||||
|
|
||||||
|
sandbox.serve('write-file', function(payload)
|
||||||
|
local result = mcp.writeFile(payload and payload.path, payload and payload.content);
|
||||||
|
return true, {
|
||||||
|
ok = result.ok,
|
||||||
|
path = result.path,
|
||||||
|
bytes = result.bytes,
|
||||||
|
error = result.error,
|
||||||
|
};
|
||||||
|
end, { eventloop = el });
|
||||||
|
|
||||||
|
local function runReady()
|
||||||
|
os.pullEvent('trapos_sandbox_connected');
|
||||||
|
print('__SANDBOX_READY__ ' .. sandbox.traposId(os));
|
||||||
|
os.sleep(12);
|
||||||
|
end
|
||||||
|
|
||||||
|
parallel.waitForAny(
|
||||||
|
function() el.runLoop(true); end,
|
||||||
|
runReady
|
||||||
|
);
|
||||||
|
|
||||||
|
os.shutdown();
|
||||||
65
tools/trap-sandbox/test-integration/mcp.test.ts
Normal file
65
tools/trap-sandbox/test-integration/mcp.test.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { formatFailure, startCraftos, startSandbox, waitFor, type Sandbox } from "./harness.js";
|
||||||
|
|
||||||
|
// Drives the MCP tools end to end: a real reactive mcp-computer server (libsandbox +
|
||||||
|
// libmcpcomputer) on a real CraftOS-PC client, reached through the real gateway. The
|
||||||
|
// MCP app is driven in-process via inject() (no extra listener).
|
||||||
|
async function callTool(sandbox: Sandbox, name: string, args: Record<string, unknown> = {}): Promise<string> {
|
||||||
|
const res = await sandbox.mcpApp.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/",
|
||||||
|
payload: { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } },
|
||||||
|
});
|
||||||
|
const body = res.json<{ result?: { content?: { text?: string }[] }; error?: { message?: string } }>();
|
||||||
|
if (body.error) {
|
||||||
|
throw new Error(`tool ${name} returned a JSON-RPC error: ${body.error.message}`);
|
||||||
|
}
|
||||||
|
return body.result?.content?.[0]?.text ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
test("MCP exec-lua / write-file / probe round-trip over the gateway", async () => {
|
||||||
|
const sandbox = await startSandbox();
|
||||||
|
const craftos = startCraftos("mcp-server-client.lua", {
|
||||||
|
computerId: 7,
|
||||||
|
computerLabel: "base",
|
||||||
|
shellArgs: [sandbox.baseUrl, "-"],
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await waitFor(() => sandbox.registry.has("local", "7:base"), 12_000, "computer registered");
|
||||||
|
|
||||||
|
assert.equal(await callTool(sandbox, "probe-computers"), "ok from 7:base");
|
||||||
|
|
||||||
|
const exec = await callTool(sandbox, "exec-lua", { traposId: "7:base", code: "print('hi'); return 1 + 1" });
|
||||||
|
assert.match(exec, /^computer: 7:base\nok: true/);
|
||||||
|
assert.match(exec, /"value":2/); // return value serialized
|
||||||
|
assert.match(exec, /\nhi$/); // captured print output
|
||||||
|
|
||||||
|
const write = await callTool(sandbox, "write-file", {
|
||||||
|
traposId: "7:base",
|
||||||
|
path: "/mcp-it.txt",
|
||||||
|
content: "hello",
|
||||||
|
});
|
||||||
|
assert.match(write, /ok: true/);
|
||||||
|
assert.match(write, /bytes: 5/);
|
||||||
|
|
||||||
|
// Read it back through exec-lua to prove the write actually landed.
|
||||||
|
const verify = await callTool(sandbox, "exec-lua", {
|
||||||
|
traposId: "7:base",
|
||||||
|
code: "local h = fs.open('/mcp-it.txt', 'r'); local c = h.readAll(); h.close(); return c",
|
||||||
|
});
|
||||||
|
assert.match(verify, /"value":"hello"/);
|
||||||
|
|
||||||
|
// Unknown traposId is a transport (not_connected) failure, not a crash.
|
||||||
|
const ghost = await callTool(sandbox, "exec-lua", { traposId: "9:ghost", code: "return 1" });
|
||||||
|
assert.match(ghost, /^computer: 9:ghost\nok: false\nerror: not_connected:/);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(formatFailure(error instanceof Error ? error.message : String(error), craftos.snapshot()), {
|
||||||
|
cause: error,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
craftos.abort();
|
||||||
|
await craftos.done.catch(() => undefined);
|
||||||
|
await sandbox.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
163
tools/trap-sandbox/test/mcp.test.ts
Normal file
163
tools/trap-sandbox/test/mcp.test.ts
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { GatewayRegistry } from "../src/modules/trapos-cloud-gateway/registry.js";
|
||||||
|
import type { ComputerResponse } from "../src/modules/trapos-cloud-gateway/protocol.js";
|
||||||
|
import { handleMcpRequest, type McpDeps } from "../src/modules/mcp/tools.js";
|
||||||
|
import { FakeSocket, silentLog } from "./helpers.js";
|
||||||
|
|
||||||
|
const ACCOUNT = "local";
|
||||||
|
|
||||||
|
function makeDeps(registry: GatewayRegistry): McpDeps {
|
||||||
|
return { registry, accountId: ACCOUNT, defaultTimeoutMs: 5_000, maxTimeoutMs: 30_000, probeTimeoutMs: 5_000 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function rpc(method: string, params?: unknown, id: unknown = 1): Record<string, unknown> {
|
||||||
|
return { jsonrpc: "2.0", id, method, params };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultText(reply: unknown): string {
|
||||||
|
const result = (reply as { result?: { content?: { text?: string }[] } }).result;
|
||||||
|
return result?.content?.[0]?.text ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function response(traposId: string, messageId: string, type: string, ok: boolean, payload: Record<string, unknown> = {}): ComputerResponse {
|
||||||
|
return { event: "computer_response", traposSenderId: traposId, messageId, type, ok, payload };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run a tool call and resolve the single gateway_request it emits with `payload`.
|
||||||
|
async function callAndAnswer(
|
||||||
|
deps: McpDeps,
|
||||||
|
socket: FakeSocket,
|
||||||
|
traposId: string,
|
||||||
|
toolName: string,
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
ok: boolean,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): Promise<string> {
|
||||||
|
const pending = handleMcpRequest(rpc("tools/call", { name: toolName, arguments: args }), deps);
|
||||||
|
// registry.request sends synchronously; a tick lets the async chain reach the await.
|
||||||
|
await Promise.resolve();
|
||||||
|
const frame = socket.lastFrame();
|
||||||
|
deps.registry.resolveResponse(socket, ACCOUNT, traposId, response(traposId, frame.messageId as string, frame.type as string, ok, payload));
|
||||||
|
return resultText(await pending);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("initialize advertises the trap-sandbox server", async () => {
|
||||||
|
const reply = await handleMcpRequest(rpc("initialize"), makeDeps(new GatewayRegistry(silentLog)));
|
||||||
|
const info = (reply as { result: { serverInfo: { name: string } } }).result.serverInfo;
|
||||||
|
assert.equal(info.name, "trap-sandbox");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tools/list exposes the three tools addressed by traposId", async () => {
|
||||||
|
const reply = await handleMcpRequest(rpc("tools/list"), makeDeps(new GatewayRegistry(silentLog)));
|
||||||
|
const tools = (reply as { result: { tools: { name: string; inputSchema: { required?: string[] } }[] } }).result.tools;
|
||||||
|
assert.deepEqual(tools.map((t) => t.name).sort(), ["exec-lua", "probe-computers", "write-file"]);
|
||||||
|
const exec = tools.find((t) => t.name === "exec-lua");
|
||||||
|
assert.deepEqual(exec?.inputSchema.required, ["traposId", "code"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("notifications/initialized is a no-op (null)", async () => {
|
||||||
|
const reply = await handleMcpRequest(rpc("notifications/initialized", undefined, null), makeDeps(new GatewayRegistry(silentLog)));
|
||||||
|
assert.equal(reply, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("probe-computers reports no computers when registry is empty", async () => {
|
||||||
|
const reply = await handleMcpRequest(rpc("tools/call", { name: "probe-computers" }), makeDeps(new GatewayRegistry(silentLog)));
|
||||||
|
assert.equal(resultText(reply), "No computers connected.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("probe-computers pings each connected computer by traposId", async () => {
|
||||||
|
const reg = new GatewayRegistry(silentLog);
|
||||||
|
const deps = makeDeps(reg);
|
||||||
|
const a = new FakeSocket();
|
||||||
|
reg.register(ACCOUNT, "7:base", a);
|
||||||
|
|
||||||
|
const pending = handleMcpRequest(rpc("tools/call", { name: "probe-computers" }), deps);
|
||||||
|
await Promise.resolve();
|
||||||
|
const frame = a.lastFrame();
|
||||||
|
assert.equal(frame.type, "ping");
|
||||||
|
reg.resolveResponse(a, ACCOUNT, "7:base", response("7:base", frame.messageId as string, "ping", true, { traposId: "7:base" }));
|
||||||
|
assert.equal(resultText(await pending), "ok from 7:base");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("exec-lua formats returns and trimmed output on success", async () => {
|
||||||
|
const reg = new GatewayRegistry(silentLog);
|
||||||
|
const deps = makeDeps(reg);
|
||||||
|
const a = new FakeSocket();
|
||||||
|
reg.register(ACCOUNT, "7:base", a);
|
||||||
|
|
||||||
|
const text = await callAndAnswer(deps, a, "7:base", "exec-lua", { traposId: "7:base", code: "print('hi')" }, true, {
|
||||||
|
ok: true,
|
||||||
|
returns: [{ type: "number", value: 42 }],
|
||||||
|
output: "hi\n",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(a.lastFrame().type, "exec-lua");
|
||||||
|
assert.deepEqual((a.lastFrame().payload as { code: string }).code, "print('hi')");
|
||||||
|
assert.equal(text, ["computer: 7:base", "ok: true", `returns: ${JSON.stringify([{ type: "number", value: 42 }])}`, "output:", "hi"].join("\n"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("exec-lua surfaces an ok:false result with its captured output", async () => {
|
||||||
|
const reg = new GatewayRegistry(silentLog);
|
||||||
|
const deps = makeDeps(reg);
|
||||||
|
const a = new FakeSocket();
|
||||||
|
reg.register(ACCOUNT, "7:base", a);
|
||||||
|
|
||||||
|
const text = await callAndAnswer(deps, a, "7:base", "exec-lua", { traposId: "7:base", code: "error('boom')" }, true, {
|
||||||
|
ok: false,
|
||||||
|
error: "boom",
|
||||||
|
output: "partial",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(text, ["computer: 7:base", "ok: false", "error: boom", "output:", "partial"].join("\n"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("exec-lua maps a transport failure to a coded error", async () => {
|
||||||
|
const reg = new GatewayRegistry(silentLog);
|
||||||
|
const deps = makeDeps(reg);
|
||||||
|
// No computer registered for this traposId -> not_connected.
|
||||||
|
const reply = await handleMcpRequest(
|
||||||
|
rpc("tools/call", { name: "exec-lua", arguments: { traposId: "9:ghost", code: "x" } }),
|
||||||
|
deps,
|
||||||
|
);
|
||||||
|
assert.match(resultText(reply), /^computer: 9:ghost\nok: false\nerror: not_connected:/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("write-file reports the written path and byte count", async () => {
|
||||||
|
const reg = new GatewayRegistry(silentLog);
|
||||||
|
const deps = makeDeps(reg);
|
||||||
|
const a = new FakeSocket();
|
||||||
|
reg.register(ACCOUNT, "7:base", a);
|
||||||
|
|
||||||
|
const text = await callAndAnswer(
|
||||||
|
deps,
|
||||||
|
a,
|
||||||
|
"7:base",
|
||||||
|
"write-file",
|
||||||
|
{ traposId: "7:base", path: "/foo.txt", content: "hello" },
|
||||||
|
true,
|
||||||
|
{ ok: true, path: "/foo.txt", bytes: 5 },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(a.lastFrame().type, "write-file");
|
||||||
|
assert.equal(text, ["computer: 7:base", "ok: true", "path: /foo.txt", "bytes: 5"].join("\n"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("exec-lua rejects a missing traposId with an invalid-params error", async () => {
|
||||||
|
const reply = await handleMcpRequest(
|
||||||
|
rpc("tools/call", { name: "exec-lua", arguments: { code: "x" } }),
|
||||||
|
makeDeps(new GatewayRegistry(silentLog)),
|
||||||
|
);
|
||||||
|
const error = (reply as { error?: { code: number; message: string } }).error;
|
||||||
|
assert.equal(error?.code, -32602);
|
||||||
|
assert.match(error?.message ?? "", /traposId/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("registry.list returns the traposIds registered for an account", () => {
|
||||||
|
const reg = new GatewayRegistry(silentLog);
|
||||||
|
reg.register(ACCOUNT, "7:base", new FakeSocket());
|
||||||
|
reg.register(ACCOUNT, "8:turtle", new FakeSocket());
|
||||||
|
reg.register("other", "9:base", new FakeSocket());
|
||||||
|
assert.deepEqual(reg.list(ACCOUNT).sort(), ["7:base", "8:turtle"]);
|
||||||
|
assert.deepEqual(reg.list("other"), ["9:base"]);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user