feat: migrate mcp-bridge to trap-sandbox

This commit is contained in:
Guillaume ARM 2026-06-15 19:57:45 +02:00
parent c9fe4ca50a
commit 5bca84648f
31 changed files with 1144 additions and 711 deletions

View File

@ -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()
return os;
end
@ -62,49 +69,6 @@ local function createMcpComputer()
.. ' (Label: ' .. formatLabel(osLike.getComputerLabel()) .. ')';
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)
local output = {};
if type(code) ~= 'string' or code == '' then
@ -160,150 +124,6 @@ local function createMcpComputer()
return { ok = true, path = path, bytes = string.len(content) };
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;
end

View File

@ -139,20 +139,29 @@ local function createSandbox()
end
if frame.event == 'gateway_request' then
-- Reverse direction: built-in `ping` only; everything else is unknown_type.
local response;
-- Reverse direction. `ping` stays built-in so it answers without any handler.
-- 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
response = api.buildComputerResponse(
if ctx.send then
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,
{ code = 'unknown_type', message = 'unknown type: ' .. tostring(frame.type) }
);
end
if ctx.send then
ctx.send(response);
));
end
return;
end
@ -205,6 +214,10 @@ local function createSandbox()
-- 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.
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)
if not activeWs then
@ -289,6 +302,10 @@ local function createSandbox()
send = sendFrame,
queueEvent = queueEvent,
onHello = onHello,
isServed = function(msgType) return servedTypes[msgType] == true; end,
queueRequest = function(messageId, msgType, payload)
queueEvent('trapos_sandbox_request', messageId, msgType, payload);
end,
});
end);
@ -341,6 +358,23 @@ local function createSandbox()
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();
return {
@ -383,6 +417,49 @@ local function createSandbox()
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;
end

View File

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

View File

@ -2,7 +2,9 @@
## 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

View 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.

View File

@ -62,49 +62,56 @@ ai "say hello from TrapOS"
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
cd tools/mcp-bridge
npm install
CC_LINK_PORT=4243 npm run dev
just sandbox-serve
```
Production ports:
This serves two listeners:
```text
MCP endpoint: http://127.0.0.1:3000
ComputerCraft link: ws://<public-host>:4243
Gateway (in-game computers connect here): ws://<public-host>:4444/gateway
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
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
linked as <id> (Label: <label>)
waiting for requests... Press Ctrl+T to stop.
```sh
sandbox health
```
## 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
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
print('captured in MCP output')
@ -118,16 +125,22 @@ term.setCursorPos(1, 1)
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
- `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.
- `mcp-computer` says WebSocket unavailable: enable ComputerCraft HTTP/WebSocket support.
- MCP sees no computers: keep `mcp-computer ws://<public-host>:4243` running in-game.
- `exec-lua` or `write-file` is missing after updating the bridge: restart OpenCode so it reloads the MCP tool list.
- `sandbox health` fails: enable ComputerCraft HTTP/WebSocket support and check `sandbox.url` (port `4444`).
- MCP sees no computers: confirm `sandbox.url` is set and the computer was rebooted so `servers/sandbox` is running.
- `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).

View File

@ -1,6 +1,6 @@
{
"name": "TrapOS",
"version": "0.9.4",
"version": "0.9.5",
"branch": "master",
"packages": [
"trapos"

View File

@ -6,8 +6,8 @@
"trapos-net": "0.3.0",
"trapos-ui": "0.2.2",
"trapos-ai": "0.7.0",
"trapos-sandbox": "0.2.6",
"trapos-sandbox-legacy": "0.2.2",
"trapos": "0.9.4"
"trapos-sandbox": "0.3.0",
"trapos-sandbox-legacy": "0.3.0",
"trapos": "0.9.5"
}
}

View File

@ -1,16 +1,13 @@
{
"name": "trapos-sandbox-legacy",
"version": "0.2.2",
"version": "0.3.0",
"description": "[legacy] TrapOS sandbox programs for ccpm experiments and Lua learning",
"dependencies": ["trapos-core"],
"files": [
"apis/libcarre.lua",
"apis/libmcpcomputer.lua",
"programs/carre.lua",
"programs/creeper.lua",
"programs/mouton.lua",
"programs/mcp-computer.lua",
"servers/mcp-computer-server.lua"
"programs/mouton.lua"
],
"autostart": ["servers/mcp-computer-server"]
"autostart": []
}

View File

@ -1,12 +1,14 @@
{
"name": "trapos-sandbox",
"version": "0.2.6",
"description": "TrapOS sandbox gateway client: WS daemon (servers/sandbox) + sandbox command",
"version": "0.3.0",
"description": "TrapOS sandbox gateway client: WS daemon (servers/sandbox) + sandbox command + MCP exec/write server",
"dependencies": ["trapos-core"],
"files": [
"apis/libsandbox.lua",
"apis/libmcpcomputer.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"]
}

View File

@ -1,6 +1,6 @@
{
"name": "trapos",
"version": "0.9.4",
"version": "0.9.5",
"description": "TrapOS full install meta-package",
"dependencies": [
"trapos-boot",

View File

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

View File

@ -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 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
print('mcp-computer-server: ' .. WS_URL_SETTING .. ' not set, daemon inactive.');
print('mcp-computer-server: ' .. URL_SETTING .. ' not set, exec/write handlers inactive.');
return;
end
if not http or not http.websocket then
print('mcp-computer-server: HTTP/WebSocket unavailable, daemon inactive.');
local el = _G.bootEventLoop;
if not el then
print('mcp-computer-server: no boot eventloop, handlers inactive.');
return;
end
createMcpComputer().startSession({
eventloop = _G.bootEventLoop,
url = url,
os = os,
});
local sandbox = createSandbox();
local mcp = createMcpComputer();
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).');

View File

@ -3,10 +3,6 @@ local createMcpComputer = require('/apis/libmcpcomputer');
local testlib = createLibTest({ ... });
local function packed(...)
return table.pack(...);
end
local function fakeOs(id, label)
return {
getComputerID = function() return id; end,
@ -14,12 +10,6 @@ local function fakeOs(id, label)
};
end
local function fakeSettings(values)
return {
get = function(key) return values[key]; end,
};
end
local function fakeFs()
local files = {};
return {
@ -44,28 +34,6 @@ local function fakeFs()
};
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()
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)');
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()
local mcpComputer = createMcpComputer();
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');
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()
local mcpComputer = createMcpComputer();
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));
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();

View File

@ -93,6 +93,41 @@ testlib.test('onMessage rejects unknown reverse type with unknown_type', functio
testlib.assertEquals(sent[1].error.code, 'unknown_type');
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()
local sandbox = createSandbox();
local calls = {};
@ -421,4 +456,142 @@ testlib.test('request times out when the timer fires first', function()
testlib.assertEquals(err.code, 'timeout');
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();

View 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`.

View File

@ -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();
}
});

View File

@ -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();
}
});

View File

@ -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();
}
});

View File

@ -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();
}
});

View File

@ -1,6 +1,6 @@
{
"name": "trap-sandbox",
"version": "0.1.0",
"version": "0.2.0",
"private": true,
"type": "module",
"engines": {

View File

@ -1,9 +1,10 @@
import Fastify, { type FastifyInstance, type FastifyServerOptions } from "fastify";
import websocket from "@fastify/websocket";
import { resolveAccount } from "./auth.js";
import { resolveAccount, LOCAL_ACCOUNT_ID } from "./auth.js";
import { createOpencodeProbe } from "./opencode.js";
import gatewayModule from "./modules/trapos-cloud-gateway/index.js";
import { GatewayRegistry } from "./modules/trapos-cloud-gateway/registry.js";
import mcpModule from "./modules/mcp/index.js";
export type Config = {
host: string;
@ -13,17 +14,27 @@ export type Config = {
opencodeUrl?: string;
opencodeUsername: 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"];
};
const MAX_PAYLOAD_BYTES = 1024 * 1024; // ~1MB
// 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),
// so it is fully constructable from tests.
// trapos-cloud-gateway module, plus an optional MCP app sharing the same registry.
// 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(
config: Config,
): Promise<{ app: FastifyInstance; registry: GatewayRegistry }> {
): Promise<{ app: FastifyInstance; mcpApp: FastifyInstance | null; registry: GatewayRegistry }> {
const app = Fastify({ logger: config.logger, disableRequestLogging: true });
const registry = new GatewayRegistry(app.log, config.requestTimeoutMs);
@ -40,5 +51,19 @@ export async function createApp(
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;
}

View File

@ -1,5 +1,9 @@
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
// 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"

View File

@ -8,25 +8,37 @@ const config: Config = {
opencodeUrl: process.env.OPENCODE_URL ?? "http://127.0.0.1:4242",
opencodeUsername: process.env.OPENCODE_USERNAME ?? "opencode",
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(),
};
const { app } = await createApp(config);
const { app, mcpApp } = await createApp(config);
try {
await app.listen({ host: config.host, port: config.port });
} catch (err) {
if (isErrnoException(err) && err.code === "EADDRINUSE") {
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);
}
reportListenError(err, config.host, config.port, "SANDBOX_PORT");
app.log.error(err);
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"] {
const level = process.env.LOG_LEVEL ?? "info";
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 {
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);
}
}

View 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;

View 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);
}

View File

@ -89,6 +89,19 @@ export class GatewayRegistry {
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
// not_connected / timeout / response ok:false; resolves with the response payload.
// The deadline is the caller's concern; defaultTimeoutMs is only a safety ceiling.

View File

@ -5,6 +5,7 @@ import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import type { AddressInfo } from "node:net";
import type { FastifyInstance } from "fastify";
import { createApp, type Config } from "../src/app.js";
import type { GatewayRegistry } from "../src/modules/trapos-cloud-gateway/registry.js";
@ -14,6 +15,9 @@ const REPO_ROOT = join(HERE, "../../..");
export type Sandbox = {
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` itself. Pass this to the Lua scripts.
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: "silent" };
const timeoutMs = opts.requestTimeoutMs ?? 5_000;
const config: Config = {
host: "127.0.0.1",
port: 0,
sandboxPassword: opts.sandboxPassword,
requestTimeoutMs: opts.requestTimeoutMs ?? 5_000,
requestTimeoutMs: timeoutMs,
opencodeUrl: opts.opencodeUrl,
opencodeUsername: "opencode",
opencodeServerPassword: undefined,
mcpEnabled: true,
mcpHost: "127.0.0.1",
mcpPort: 0,
mcpDefaultTimeoutMs: timeoutMs,
mcpMaxTimeoutMs: 30_000,
mcpProbeTimeoutMs: timeoutMs,
logger,
};
const { app, registry } = await createApp(config);
const { app, mcpApp, registry } = await createApp(config);
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 baseUrl = `ws://127.0.0.1:${port}`;
return {
registry,
mcpApp,
baseUrl,
socketUrl: `${baseUrl}/gateway`,
countLog: (substring) => logLines.filter((line) => line.includes(substring)).length,
close: () => app.close(),
close: async () => {
await mcpApp.close();
await app.close();
},
};
}

View File

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

View 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();
}
});

View 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"]);
});