diff --git a/apis/libmcpcomputer.lua b/apis/libmcpcomputer.lua index fbdb835..f4de707 100644 --- a/apis/libmcpcomputer.lua +++ b/apis/libmcpcomputer.lua @@ -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 diff --git a/apis/libsandbox.lua b/apis/libsandbox.lua index 0e8d3e1..3499b1f 100644 --- a/apis/libsandbox.lua +++ b/apis/libsandbox.lua @@ -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( - ctx.traposId, frame.messageId, 'ping', true, { traposId = ctx.traposId }, nil - ); - else - response = api.buildComputerResponse( + if ctx.send then + ctx.send(api.buildComputerResponse( + ctx.traposId, frame.messageId, 'ping', true, { traposId = ctx.traposId }, nil + )); + 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 = }. + 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 diff --git a/docs/adrs/README.md b/docs/adrs/README.md index ae61282..5be50de 100644 --- a/docs/adrs/README.md +++ b/docs/adrs/README.md @@ -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. diff --git a/docs/adrs/adr-0017-mcp-remote-lua-execution.md b/docs/adrs/adr-0017-mcp-remote-lua-execution.md index 05db81a..9da3c4e 100644 --- a/docs/adrs/adr-0017-mcp-remote-lua-execution.md +++ b/docs/adrs/adr-0017-mcp-remote-lua-execution.md @@ -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 diff --git a/docs/adrs/adr-0019-mcp-over-sandbox-gateway.md b/docs/adrs/adr-0019-mcp-over-sandbox-gateway.md new file mode 100644 index 0000000..3e142a8 --- /dev/null +++ b/docs/adrs/adr-0019-mcp-over-sandbox-gateway.md @@ -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`** + (`":