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

View File

@ -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.send(api.buildComputerResponse(
ctx.traposId, frame.messageId, 'ping', true, { traposId = ctx.traposId }, nil ctx.traposId, frame.messageId, 'ping', true, { traposId = ctx.traposId }, nil
); ));
else end
response = api.buildComputerResponse( 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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

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", "name": "trap-sandbox",
"version": "0.1.0", "version": "0.2.0",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {

View File

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

View File

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

View File

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

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

View File

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

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