refactor: remove old mcp-bridge

This commit is contained in:
Guillaume ARM 2026-06-17 00:26:04 +02:00
parent b7420d07d8
commit 2d13242f29
43 changed files with 214 additions and 4393 deletions

View File

@ -12,8 +12,8 @@ permission:
bash:
"*": ask
"just check": allow
computercraft-mcp-bridge_probe-computers: allow
computercraft-mcp-bridge_exec-lua: allow
trapos-cloud-gateway-mcp_probe-computers: allow
trapos-cloud-gateway-mcp_exec-lua: allow
---
You answer players using TrapOS `ai` from inside ATM10 / All The Mods 10.

View File

@ -0,0 +1,173 @@
# Plan: Remove `tools/mcp-bridge`
## Context
`tools/mcp-bridge` used to provide two separate host-side surfaces:
1. MCP tools and a dedicated ComputerCraft link transport.
2. An opencode HTTP-over-WebSocket proxy used by the in-game `ai` bridge mode.
The MCP tools have already moved to `tools/trap-sandbox` and `.mcp.json` already points at
the sandbox MCP endpoint (`http://127.0.0.1:4445`). The remaining live dependency is the old
opencode proxy path (`opencc.bridge_url` / `packages/trapos-ai/apis/libhttpws.lua`).
Post-pull path note: Lua source files now live under `packages/<pkg>/...`. The CraftOS-PC
test harness stages those files into flat runtime roots via `just/stage.just`, and package
descriptors still list installed flat paths such as `apis/libai.lua`.
Decision: remove `tools/mcp-bridge` completely and intentionally drop the old opencode proxy
implementation for now. If bridge/proxy behavior is needed again, re-implement it from
scratch later; the old implementation remains available in git history.
## Scope
- Delete `tools/mcp-bridge/` entirely.
- Remove `ai` WebSocket bridge-mode support.
- Keep direct HTTP/HTTPS opencode support through `packages/trapos-ai/apis/libhttp.lua`.
- Keep current MCP tooling in `tools/trap-sandbox` unchanged, except for docs/tooling cleanup.
## Runtime Changes
### Drop `libhttpws`
- Delete `packages/trapos-ai/apis/libhttpws.lua`.
- Remove `require('/apis/libhttpws')` from `packages/trapos-ai/apis/libai.lua`.
- Remove `opencc.bridge_url` handling from `packages/trapos-ai/apis/libai.lua`.
- Remove the fallback that treats `ws://` / `wss:// opencc.server_url` as bridge mode.
- Remove `opencc.request_timeout_seconds` usage if it only exists for the WebSocket transport.
### Update `ai` CLI Help
Update `packages/trapos-ai/programs/ai.lua` help text:
- Remove `opencc.bridge_url` from required settings.
- Remove `opencc.request_timeout_seconds` from optional settings.
- Make `opencc.server_url` clearly HTTP/HTTPS-only.
Expected behavior after removal: users configure `opencc.server_url` to an HTTP/HTTPS
`opencode serve` endpoint. There is no WebSocket bridge mode until a new implementation is
added later.
## Tests
### Lua Tests
Update `packages/trapos-ai/tests/ai.lua`:
- Remove bridge-specific tests under the `ask over the bridge ws transport` section.
- Remove fake WebSocket helper code if it becomes unused.
- Keep direct HTTP opencode behavior tests.
### Node / Integration Tests
- Delete all `tools/mcp-bridge/test/` and `tools/mcp-bridge/test-integration/` tests with the
directory.
- Do not port `opencode-proxy.test.ts` or `ai-cli.test.ts` to `tools/trap-sandbox`; the proxy
behavior is intentionally removed.
- Keep `tools/trap-sandbox/test/` and `tools/trap-sandbox/test-integration/` as the supported
MCP/gateway coverage.
## Tooling Changes
### `just/npm.just`
- Remove `npm-install-mcp-bridge`.
- Remove `npm-build-mcp-bridge`.
- Remove `npm-check-mcp-bridge`.
- Remove `npm-test-mcp-bridge`.
- Remove `npm-test-integration-mcp-bridge`.
- Make aggregate Node recipes target only `tools/trap-sandbox`:
- `npm-install`
- `npm-build`
- `npm-check`
- `npm-test`
- `npm-test-integration`
### `just/install.just`
- Change `clean` to clean `tools/trap-sandbox` caches instead of `tools/mcp-bridge`, or make it
a broader Node-tool clean if useful.
- Update the comment above `clean`.
### `just/test.just`
- Update comments that describe end-to-end tests as spanning the MCP bridge; they now span the
sandbox gateway / MCP endpoint.
### `just/stage.just`
- No behavior change expected. It already stages package-owned sources into `.stage/`; deleting
`packages/trapos-ai/apis/libhttpws.lua` and removing it from `packages/trapos-ai/ccpm.json`
is sufficient.
## Packaging
Update `packages/trapos-ai/ccpm.json`:
- Remove `apis/libhttpws.lua` from `files`.
- Bump the `trapos-ai` package version because runtime behavior/help changes.
Update `packages/index.json`:
- Mirror the new `trapos-ai` version.
No `trapos-sandbox` package bump is required unless its behavior changes during cleanup.
## Documentation Cleanup
### Required Docs
- `DEVELOPMENT.md`: update `just clean` description so it no longer mentions `mcp-bridge`.
- `docs/public-ports.md`: remove the old `4243` / `CC_LINK_PORT` ComputerCraft bridge entry,
unless a new service explicitly claims that port later.
- `docs/adrs/adr-0019-mcp-over-sandbox-gateway.md`: replace the statement that
`tools/mcp-bridge` is kept for the opencode proxy with a note that it has been removed and
the proxy was intentionally dropped pending a future rewrite.
- `docs/adrs/adr-0016-js-tool-verification.md`: replace `tools/mcp-bridge` as the JS tool
verification example with `tools/trap-sandbox`.
### Historical / Optional Cleanup
- `docs/adrs/adr-0017-mcp-remote-lua-execution.md`: this is historical; either leave it as-is
or add a short superseded note pointing to ADR-0019.
- `.opencode/agent/atm10-expert.md`: update old `computercraft-mcp-bridge_*` permission names
if they are still relevant.
- `.plans/archived/*`: leave old references as archived history unless doing a full text
cleanup pass.
## Files Expected To Change
- Delete: `tools/mcp-bridge/`
- Delete: `packages/trapos-ai/apis/libhttpws.lua`
- Modify: `packages/trapos-ai/apis/libai.lua`
- Modify: `packages/trapos-ai/programs/ai.lua`
- Modify: `packages/trapos-ai/tests/ai.lua`
- Modify: `just/npm.just`
- Modify: `just/install.just`
- Modify: `just/test.just`
- Modify: `packages/trapos-ai/ccpm.json`
- Modify: `packages/index.json`
- Modify: `DEVELOPMENT.md`
- Modify: `docs/public-ports.md`
- Modify: `docs/adrs/adr-0019-mcp-over-sandbox-gateway.md`
- Modify: `docs/adrs/adr-0016-js-tool-verification.md`
- Optional: `docs/adrs/adr-0017-mcp-remote-lua-execution.md`
- Optional: `.opencode/agent/atm10-expert.md`
## Verification
After implementation:
1. Run `just check`.
2. Run `just test`.
3. Run `just npm-test-integration` if not already covered by the selected test target.
4. Optionally run full `just ci` before merging.
Expected result:
- No recipe references `tools/mcp-bridge`.
- No package descriptor references `apis/libhttpws.lua`.
- No source file references `require('/apis/libhttpws')`, `opencc.bridge_url`, or
`opencc.request_timeout_seconds` except archived history or generated/local CraftOS state.
- `ai` works only through direct HTTP/HTTPS `opencc.server_url`.
- MCP tools continue to work through `tools/trap-sandbox`.

View File

@ -12,7 +12,7 @@ This installs local Git hooks, checks required tools, generates `.env`, and veri
## Cleaning
- `just clean` — clears the `mcp-bridge` build caches (`node_modules/.cache/tsc`, `node_modules/.cache/eslint`). Safe to run anytime; `just reinstall` chains it with `just install`.
- `just clean` — clears the `trap-sandbox` build caches (`node_modules/.cache/tsc`, `node_modules/.cache/eslint`). Safe to run anytime; `just reinstall` chains it with `just install`.
- `just clean-env` — deletes `.env`. Run only when you actually want to drop locally generated tokens.
See [`docs/README.md`](docs/README.md) for repository docs and [`docs/adrs/README.md`](docs/adrs/README.md) for architecture decisions.

View File

@ -10,9 +10,9 @@ Accepted
## Context
The repository now contains `tools/mcp-bridge`, a TypeScript Node.js tool that sits next to the ComputerCraft Lua code. It has a different build and test lifecycle than TrapOS packages, but it still participates in the same local developer workflow and Git hooks described by [ADR-0011](adr-0011-repo-conventions.md).
The repository contains `tools/trap-sandbox`, a TypeScript Node.js tool that sits next to the ComputerCraft Lua code. It has a different build and test lifecycle than TrapOS packages, but it still participates in the same local developer workflow and Git hooks described by [ADR-0011](adr-0011-repo-conventions.md). (This convention was first established for `tools/mcp-bridge`, which has since been removed; `trap-sandbox` is now the JS tool it governs.)
The bridge also needs future end-to-end coverage that spans both runtimes: a host Node process and a headless CraftOS-PC computer. That is broader and slower than normal unit tests, and it needs the same kind of timeout discipline as the Lua harness in [ADR-0007](adr-0007-test-framework.md).
The tool also needs end-to-end coverage that spans both runtimes: a host Node process and a headless CraftOS-PC computer. That is broader and slower than normal unit tests, and it needs the same kind of timeout discipline as the Lua harness in [ADR-0007](adr-0007-test-framework.md).
## Decision
@ -22,12 +22,12 @@ Keep the Node package scripts simple and one-purpose:
- `npm run test` runs the Node unit tests directly from TypeScript with `tsx --test test/*.test.ts`. No prior build is required.
- `npm run check` runs ESLint. TypeScript compilation is covered by `npm run build` and `npm run test:ci` to avoid duplicate compiler runs in repository CI.
- `npm run test:ci` runs `npm run check && npm run build && npm run test`, so type errors and lint failures both surface even though `test` itself no longer compiles.
- `npm run test-integration` runs the bridge-to-CraftOS integration suite with `tsx --test --test-concurrency=1 test-integration/*.test.ts`. Each case boots the bridge in-process on fixed loopback ports (`127.0.0.1:2000` for MCP HTTP, `127.0.0.1:2001` for the CraftOS link), spawns a CraftOS-PC headless computer that connects back, exercises `tools/call probe-computers`, and tears everything down. `--test-concurrency=1` keeps the fixed ports collision-free.
- These fixed integration-test ports are loopback-only and unrelated to the public production ports documented in [`../public-ports.md`](../public-ports.md).
- `npm run test:integration` runs the host-to-CraftOS integration suite with `tsx --test --test-concurrency=1 test-integration/*.test.ts`. Each case boots the gateway in-process on an ephemeral loopback port, spawns a CraftOS-PC headless computer that connects back over the gateway WebSocket, drives MCP tool calls through the Fastify app (`inject`, no separate listener), and tears everything down.
- These integration-test ports are loopback-only and unrelated to the public production ports documented in [`../public-ports.md`](../public-ports.md).
Expose matching repository recipes for the Node lifecycle:
- `just build` delegates to `npm run build` for the bridge.
- `just build` delegates to `npm run build` for the sandbox tool.
- `just test` depends on `just build`, runs `npm run test`, then runs the existing CraftOS-PC test suite.
- `just check` includes `npm run check` alongside Lua and Markdown checks.
- `just ci` uses `npm run test:ci`, then runs CraftOS-PC tests and the broader integration/harness target.
@ -39,7 +39,7 @@ Expose matching repository recipes for the Node lifecycle:
- TypeScript errors are no longer caught by `npm run test`; they are caught by `npm run build`, which stays wired into `npm run test:ci`, `just build`, `just test`, and `just ci`.
- `just ci` avoids duplicating Node unit tests by calling `npm run test:ci` directly and then invoking only the CraftOS-side test body.
- ESLint failures are part of `just check`, so they are covered by the same pre-commit and pre-push hooks as Lua and Markdown checks.
- Integration tests live in `tools/mcp-bridge/test-integration/`, with Lua client fixtures under `test-integration/lua/`. Slow end-to-end behavior stays out of the fast unit-test path.
- Integration tests live in `tools/trap-sandbox/test-integration/`, with Lua client fixtures under `test-integration/lua/`. Slow end-to-end behavior stays out of the fast unit-test path.
## Future Work

View File

@ -3,8 +3,9 @@
## Status
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.
MCP tools now ride the sandbox gateway and address computers by `traposId`, and
`tools/mcp-bridge` (referenced below in the past tense) has since been removed. The
execution semantics and danger model below still apply.
## Date

View File

@ -51,9 +51,11 @@ Move the MCP features onto the sandbox gateway and retire the dedicated MCP tran
`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.
`tools/mcp-bridge` has since been **removed** entirely. Its MCP server and CC-link parts
were superseded here; its opencode HTTP-over-WS proxy (and the in-game `ai` WebSocket bridge
mode it backed, `apis/libhttpws`) was intentionally dropped rather than migrated. The `ai`
client now talks direct HTTP/HTTPS to `opencode serve` (`opencc.server_url`); if a proxy is
needed again it will be re-implemented from scratch. The old code remains in git history.
## Consequences
@ -68,5 +70,6 @@ parts are superseded here; the proxy migration is deferred.
## Future Work
- Migrate the opencode HTTP proxy off `mcp-bridge` so the tool can be fully retired.
- Re-implement an opencode proxy / WebSocket transport for the `ai` client if the
ComputerCraft ~60s HTTP cap becomes a problem again (the old `mcp-bridge` proxy was dropped).
- Per-computer exec policy if the gateway is ever shared beyond trusted development.

View File

@ -5,7 +5,7 @@ TrapOS public production services use the `4242-4244` TCP range. Keep local deve
| Port | Service | Notes |
|---|---|---|
| `4242` | opencode server HTTP API | Production/public port for `opencode serve`. Local/dev examples may still use opencode's default `4096`. |
| `4243` | ComputerCraft bridge WebSocket | Production/public equivalent of the local bridge link default `3001`. Use `CC_LINK_PORT=4243` for production bridge deployments. |
| `4243` | Reserved | Formerly the ComputerCraft bridge WebSocket (removed with `tools/mcp-bridge`). Free for a future service. |
| `4244` | Reserved | Free public production port reserved for a future service. Do not assign it casually in local tooling. |
| `4444` | trap-sandbox gateway | Fastify service (`tools/trap-sandbox`) exposing the `trapos-cloud-gateway` WebSocket at `/gateway`. Sits **outside** the `4242-4244` range on purpose (4244 stays reserved). Default `SANDBOX_PORT`; serve with `just sandbox-serve`. |
@ -13,7 +13,5 @@ TrapOS public production services use the `4242-4244` TCP range. Keep local deve
- Local/dev opencode: `http://127.0.0.1:4096`.
- Public/production opencode: `http://<public-host>:4242`.
- Local/dev ComputerCraft bridge link: `ws://<host>:3001`.
- Public/production ComputerCraft bridge link: `ws://<public-host>:4243`.
Production services exposed on public ports should use the normal deployment controls for the host: authentication where supported, firewall rules, and TLS or a reverse proxy when crossing untrusted networks.

View File

@ -1,9 +1,9 @@
# Install local development tooling.
install: install-git-hooks check-install npm-install generate-env
# Remove build caches (tsc / eslint) for the mcp-bridge tool.
# Remove build caches (tsc / eslint) for the trap-sandbox tool.
clean:
npm run clean --prefix tools/mcp-bridge
npm run clean --prefix tools/trap-sandbox
# Remove generated local environment files (e.g. .env with tokens).
clean-env:

View File

@ -1,46 +1,30 @@
# Install Node dependencies for repository tools.
npm-install: npm-install-mcp-bridge npm-install-trap-sandbox
# Install Node dependencies for the MCP bridge.
npm-install-mcp-bridge:
npm install --prefix tools/mcp-bridge
npm-install: npm-install-trap-sandbox
# Install Node dependencies for trap-sandbox.
npm-install-trap-sandbox:
npm install --prefix tools/trap-sandbox/
# Build Node-based repository tools.
npm-build: npm-build-mcp-bridge npm-build-trap-sandbox
npm-build-mcp-bridge:
npm run build --prefix tools/mcp-bridge
npm-build: npm-build-trap-sandbox
npm-build-trap-sandbox:
npm run build --prefix tools/trap-sandbox/
# Check Node-based repository tools.
npm-check: npm-check-mcp-bridge npm-check-trap-sandbox
npm-check-mcp-bridge:
npm run check --prefix tools/mcp-bridge
npm-check: npm-check-trap-sandbox
npm-check-trap-sandbox:
npm run check --prefix tools/trap-sandbox/
# Run Node-based tool tests.
npm-test: npm-test-mcp-bridge npm-test-trap-sandbox
npm-test-mcp-bridge:
npm test --prefix tools/mcp-bridge
npm-test: npm-test-trap-sandbox
npm-test-trap-sandbox:
npm test --prefix tools/trap-sandbox/
# Run Node-based tool integration tests.
npm-test-integration: stage npm-test-integration-mcp-bridge npm-test-integration-trap-sandbox
npm-test-integration-mcp-bridge:
npm run test:integration --prefix tools/mcp-bridge
npm-test-integration: stage npm-test-integration-trap-sandbox
npm-test-integration-trap-sandbox:
npm run test:integration --prefix tools/trap-sandbox/

View File

@ -8,7 +8,7 @@ ci *args: check-craftos check check-packages npm-build npm-test
test *args: build npm-test
@just _craftos-test {{args}}
# Run end-to-end tests that span the MCP bridge and headless CraftOS-PC.
# Run end-to-end tests that span the trap-sandbox gateway / MCP endpoint and headless CraftOS-PC.
e2e: npm-test-integration
# Run integration and harness tests that are too broad for unit test targets.

View File

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

View File

@ -5,9 +5,9 @@
"trapos-boot": "0.4.2",
"trapos-net": "0.3.2",
"trapos-ui": "0.2.4",
"trapos-ai": "0.7.2",
"trapos-ai": "0.8.0",
"trapos-sandbox": "0.3.5",
"trapos-sandbox-legacy": "0.3.2",
"trapos": "0.11.5"
"trapos": "0.11.6"
}
}

View File

@ -2,21 +2,13 @@ local PING_PROMPT = 'reply with exactly: pong';
local DEFAULT_TIMEOUT_SECONDS = 60;
local MAX_TIMEOUT_SECONDS = 60;
local DEFAULT_REQUEST_TIMEOUT_SECONDS = 600;
local MAX_REQUEST_TIMEOUT_SECONDS = 600;
local DEFAULT_LUA_EXEC_MAX_RETRIES = 2;
local DEFAULT_LUA_EXEC_TIMEOUT_SECONDS = 5;
local DEFAULT_SESSION_SETTING_KEY = 'opencc.session_id';
local DEFAULT_AGENT_SETTING_KEY = 'opencc.agent';
local DEFAULT_VARIANT_SETTING_KEY = 'opencc.variant';
local DEFAULT_BRIDGE_SETTING_KEY = 'opencc.bridge_url';
local createHttp = require('/apis/libhttp');
local createHttpWs = require('/apis/libhttpws');
local function isWsUrl(url)
return type(url) == 'string' and string.match(url, '^wss?://') ~= nil;
end
local function isBlank(s)
return type(s) ~= 'string' or string.match(s, '^%s*$') ~= nil;
@ -155,34 +147,7 @@ local function createAi(opts)
local settingsLib = opts.settings or settings;
local osLib = opts.os or os;
local function resolveRequestTimeout()
local raw = settingsLib.get('opencc.request_timeout_seconds');
local n = tonumber(raw);
if not n or n <= 0 then n = DEFAULT_REQUEST_TIMEOUT_SECONDS; end
if n > MAX_REQUEST_TIMEOUT_SECONDS then n = MAX_REQUEST_TIMEOUT_SECONDS; end
return n;
end
-- A ws:// bridge routes opencode calls through the mcp-bridge proxy, escaping
-- ComputerCraft's hard ~60s http timeout. Falls back to direct http otherwise.
local bridgeUrl = opts.bridgeUrl or settingsLib.get(DEFAULT_BRIDGE_SETTING_KEY);
if isBlank(bridgeUrl) and isWsUrl(settingsLib.get('opencc.server_url')) then
bridgeUrl = settingsLib.get('opencc.server_url');
end
local bridgeActive = not isBlank(bridgeUrl);
local httpClient = opts.httpClient;
if not httpClient then
if bridgeActive then
httpClient = createHttpWs({
http = httpLib,
bridgeUrl = bridgeUrl,
receiveTimeout = resolveRequestTimeout(),
});
else
httpClient = createHttp({ http = httpLib });
end
end
local httpClient = opts.httpClient or createHttp({ http = httpLib });
local api = {};
@ -233,13 +198,9 @@ local function createAi(opts)
local function resolveConfig(options)
local url = options.serverUrl or settingsLib.get('opencc.server_url');
-- In bridge mode the proxy holds the real opencode URL, so server_url is optional.
if isBlank(url) then
if not bridgeActive then
return nil, 'missing opencc.server_url; run: set opencc.server_url <url>';
end
url = '';
end
local username = options.username or settingsLib.get('opencc.username') or 'opencode';
local password = options.password or settingsLib.get('opencc.password') or '';
local directory = options.directory or settingsLib.get('opencc.directory');
@ -439,8 +400,7 @@ local function createAi(opts)
end
-- Synchronous /message only: opencode's prompt_async loops the model on some
-- builds and strands sessions as busy. The bridge ws transport removes the
-- ~60s http cap that previously made blocking impractical.
-- builds and strands sessions as busy.
return askBlocking(cfg, sessionId, promptWithContext, persist, sessionSettingKey, log);
end

View File

@ -1,140 +0,0 @@
-- WebSocket transport for the opencode bridge proxy.
--
-- Implements the same client surface as libhttp (getJson/postJson plus the
-- trimTrailingSlash/queryString helpers libai calls), but performs each call as
-- a blocking websocket round-trip to the mcp-bridge opencode proxy instead of a
-- direct http.get/post. ComputerCraft's http API caps requests at ~60s; a
-- websocket round-trip has no such cap, so the bridge can run a synchronous
-- opencode request server-side for as long as it needs.
local DEFAULT_RECEIVE_TIMEOUT_SECONDS = 600;
local function isBlank(s)
return type(s) ~= 'string' or string.match(s, '^%s*$') ~= nil;
end
local function trimTrailingSlash(s)
return (s:gsub('/+$', ''));
end
local function urlEncode(s)
return (tostring(s):gsub('[^%w%-_%.~]', function(c)
return string.format('%%%02X', string.byte(c));
end));
end
local function queryString(params)
local parts = {};
for _, item in ipairs(params) do
if not isBlank(item[2]) then
parts[#parts + 1] = urlEncode(item[1]) .. '=' .. urlEncode(item[2]);
end
end
if #parts == 0 then return ''; end
return '?' .. table.concat(parts, '&');
end
local function createHttpWs(opts)
opts = opts or {};
local httpLib = opts.http or http;
local textutilsLib = opts.textutils or textutils;
local bridgeUrl = opts.bridgeUrl;
local receiveTimeout = tonumber(opts.receiveTimeout) or DEFAULT_RECEIVE_TIMEOUT_SECONDS;
local api = {
trimTrailingSlash = trimTrailingSlash,
urlEncode = urlEncode,
queryString = queryString,
};
local activeWs = nil;
local function ensureSocket()
if activeWs then return activeWs, nil; end
if isBlank(bridgeUrl) then
return nil, 'missing opencc.bridge_url';
end
local ws, err = httpLib.websocket(bridgeUrl);
if not ws then
return nil, 'bridge unreachable: ' .. tostring(err);
end
activeWs = ws;
return ws, nil;
end
local function closeSocket()
if activeWs then
pcall(function() activeWs.close(); end);
activeWs = nil;
end
end
local idCounter = 0;
local function nextId()
idCounter = idCounter + 1;
local stamp = os.epoch and os.epoch('utc') or os.clock();
return 'req_' .. tostring(stamp) .. '_' .. tostring(idCounter) .. '_' .. tostring(math.random(100000, 999999));
end
local function trySend(ws, text)
return pcall(function() ws.send(text); end);
end
local function roundtrip(method, path, payload)
local ws, err = ensureSocket();
if not ws then return nil, err; end
local id = nextId();
local frame = { type = 'http', id = id, method = method, path = path };
if payload ~= nil then
frame.body = textutilsLib.serializeJSON(payload);
end
local text = textutilsLib.serializeJSON(frame);
if not trySend(ws, text) then
-- Socket went away; drop it and try one fresh reconnect.
closeSocket();
ws, err = ensureSocket();
if not ws then return nil, err; end
if not trySend(ws, text) then
closeSocket();
return nil, 'bridge send failed';
end
end
while true do
local ok, message = pcall(function() return ws.receive(receiveTimeout); end);
if not ok then
closeSocket();
return nil, 'bridge connection closed';
end
if message == nil then
return nil, 'timeout waiting for bridge response';
end
local decoded = textutilsLib.unserializeJSON(message);
if type(decoded) == 'table' and decoded.type == 'http-response' and decoded.id == id then
if decoded.status == nil or decoded.status == 0 then
return nil, decoded.error or 'bridge error';
end
return decoded.body, decoded.status;
end
-- Ignore frames that don't correlate (stale/other ids) and keep waiting.
end
end
function api.getJson(_cfg, path)
return roundtrip('GET', path, nil);
end
function api.postJson(_cfg, path, payload)
return roundtrip('POST', path, payload);
end
function api.close()
closeSocket();
end
return api;
end
return createHttpWs;

View File

@ -1,12 +1,11 @@
{
"name": "trapos-ai",
"version": "0.7.2",
"version": "0.8.0",
"description": "TrapOS AI client for opencode serve",
"dependencies": ["trapos-core"],
"files": [
"apis/libai.lua",
"apis/libhttp.lua",
"apis/libhttpws.lua",
"apis/libtrapgpt.lua",
"programs/ai.lua",
"programs/trapgpt.lua"

View File

@ -41,21 +41,19 @@ local function printUsage()
print(' ai --version');
print(' ai --help');
print();
print('settings required (one of):');
print(' opencc.server_url (direct http opencode URL)');
print(' opencc.bridge_url (ws:// mcp-bridge proxy; bypasses CC 60s http cap)');
print('settings required:');
print(' opencc.server_url (http/https opencode serve URL)');
print();
print('settings optional:');
print(' opencc.username (default: opencode; direct mode only)');
print(' opencc.password (Basic Auth password; direct mode only)');
print(' opencc.username (default: opencode)');
print(' opencc.password (Basic Auth password)');
print(' opencc.session_id (auto-managed)');
print(' opencc.directory (optional session list scope)');
print(' opencc.agent (e.g. atm10-expert)');
print(' opencc.variant (e.g. low)');
print(' opencc.provider_id (e.g. anthropic)');
print(' opencc.model_id (e.g. claude-opus-4-7)');
print(' opencc.timeout_seconds (per HTTP call, max 60; direct mode)');
print(' opencc.request_timeout_seconds (ws reply wait, default/max: 600)');
print(' opencc.timeout_seconds (per HTTP call, max 60)');
end
local function printAiLog(message)

View File

@ -67,59 +67,6 @@ local function fakeHttp(postResults, getResults)
};
end
-- Fake CC http library exposing websocket(), for the bridge ws transport.
-- wsResults: list of { status=, body=, error= } returned per round-trip in order.
-- A nil entry simulates ws.receive timing out. opts.connectFail makes
-- http.websocket return (nil, err).
local function fakeWsHttp(wsResults, opts)
wsResults = wsResults or {};
opts = opts or {};
local sent = {};
local receiveTimeouts = {};
local idx = 0;
local lastId = nil;
local closed = false;
local connectUrl = nil;
local ws = {
send = function(text)
local frame = textutils.unserializeJSON(text);
sent[#sent + 1] = frame;
lastId = frame and frame.id;
end,
receive = function(timeout)
receiveTimeouts[#receiveTimeouts + 1] = timeout;
idx = idx + 1;
local r = wsResults[idx];
if r == nil then return nil; end
return textutils.serializeJSON({
type = 'http-response',
id = lastId,
status = r.status,
body = r.body,
error = r.error,
});
end,
close = function() closed = true; end,
};
local httpLib = {
websocket = function(url)
connectUrl = url;
if opts.connectFail then return nil, 'refused'; end
return ws;
end,
};
return {
http = httpLib,
sent = sent,
isClosed = function() return closed; end,
connectUrl = function() return connectUrl; end,
lastReceiveTimeout = function() return receiveTimeouts[#receiveTimeouts]; end,
};
end
local function httpError(code, body)
return function()
return nil, 'HTTP response code ' .. tostring(code), response(code, body);
@ -137,21 +84,6 @@ local function messageResp(reply)
}));
end
local function wsResult(status, body)
return { status = status, body = body };
end
local function wsSessionResult(id)
return wsResult(200, textutils.serializeJSON({ id = id, title = 'cc-ai' }));
end
local function wsMessageResult(reply)
return wsResult(200, textutils.serializeJSON({
info = { time = { completed = 1 } },
parts = { { type = 'text', text = reply } },
}));
end
local function postedText(call)
local body = textutils.unserializeJSON(call.body);
return body.parts[1].text;
@ -937,152 +869,6 @@ testlib.test('ask omits Authorization header when no password', function()
testlib.assertEquals(httpStub.postCalls[1].headers['Authorization'], nil);
end);
-- ask over the bridge ws transport --
testlib.test('ask over bridge uses synchronous message endpoint', function()
local ws = fakeWsHttp({
wsSessionResult('ses_ws'),
wsMessageResult('reply'),
});
local settingsStub = fakeSettings({ ['opencc.bridge_url'] = 'ws://bridge' });
local ai = createAi({ http = ws.http, settings = settingsStub });
local ok, result = ai.ask('hello');
testlib.assertTrue(ok, tostring(result));
testlib.assertEquals(result.reply, 'reply');
testlib.assertEquals(result.sessionId, 'ses_ws');
testlib.assertEquals(ws.connectUrl(), 'ws://bridge');
testlib.assertEquals(#ws.sent, 2);
testlib.assertEquals(ws.sent[1].method, 'POST');
testlib.assertEquals(ws.sent[1].path, '/session');
testlib.assertEquals(ws.sent[2].method, 'POST');
testlib.assertEquals(ws.sent[2].path, '/session/ses_ws/message');
testlib.assertTrue(string.find(ws.sent[2].path, 'prompt_async', 1, true) == nil);
end);
testlib.test('ask uses bridge when server_url has ws scheme', function()
local ws = fakeWsHttp({
wsSessionResult('ses_ws'),
wsMessageResult('reply'),
});
local settingsStub = fakeSettings({ ['opencc.server_url'] = 'ws://bridgehost' });
local ai = createAi({ http = ws.http, settings = settingsStub });
local ok, result = ai.ask('hello');
testlib.assertTrue(ok, tostring(result));
testlib.assertEquals(result.reply, 'reply');
testlib.assertEquals(ws.connectUrl(), 'ws://bridgehost');
testlib.assertTrue(#ws.sent >= 1);
end);
testlib.test('ask over bridge sends body as serialized json', function()
local ws = fakeWsHttp({ wsMessageResult('reply') });
local settingsStub = fakeSettings({
['opencc.bridge_url'] = 'ws://bridge',
['opencc.session_id'] = 'ses_1',
['opencc.agent'] = 'atm10-expert',
});
local ai = createAi({ http = ws.http, settings = settingsStub, os = fakeOs(5, 'turtle') });
local ok = ai.ask('hello');
testlib.assertTrue(ok);
testlib.assertEquals(#ws.sent, 1);
testlib.assertEquals(ws.sent[1].path, '/session/ses_1/message');
local body = textutils.unserializeJSON(ws.sent[1].body);
testlib.assertEquals(body.agent, 'atm10-expert');
testlib.assertTrue(string.find(body.parts[1].text, 'computer id: 5', 1, true) ~= nil);
end);
testlib.test('ask over bridge surfaces a transport error frame', function()
local ws = fakeWsHttp({ { status = 0, error = 'fetch failed: ECONNREFUSED' } });
local settingsStub = fakeSettings({
['opencc.bridge_url'] = 'ws://bridge',
['opencc.session_id'] = 'ses_1',
});
local ai = createAi({ http = ws.http, settings = settingsStub });
local ok, err = ai.ask('hello');
testlib.assertTrue(not ok);
testlib.assertTrue(string.find(err, 'fetch failed', 1, true) ~= nil);
end);
testlib.test('ask over bridge times out when no reply', function()
local ws = fakeWsHttp({});
local settingsStub = fakeSettings({
['opencc.bridge_url'] = 'ws://bridge',
['opencc.session_id'] = 'ses_1',
});
local ai = createAi({ http = ws.http, settings = settingsStub });
local ok, err = ai.ask('hello');
testlib.assertTrue(not ok);
testlib.assertTrue(string.find(err, 'timeout', 1, true) ~= nil);
end);
testlib.test('ask over bridge maps a 404 frame to missing session', function()
local ws = fakeWsHttp({ wsResult(404, '{}') });
local settingsStub = fakeSettings({
['opencc.bridge_url'] = 'ws://bridge',
['opencc.session_id'] = 'ses_stale',
});
local ai = createAi({ http = ws.http, settings = settingsStub });
local ok, err = ai.ask('hello');
testlib.assertTrue(not ok);
testlib.assertTrue(string.find(err, 'session introuvable', 1, true) ~= nil);
testlib.assertEquals(settingsStub.values['opencc.session_id'], nil);
end);
testlib.test('ask over bridge fails when websocket connect fails', function()
local ws = fakeWsHttp({}, { connectFail = true });
local settingsStub = fakeSettings({
['opencc.bridge_url'] = 'ws://bridge',
['opencc.session_id'] = 'ses_1',
});
local ai = createAi({ http = ws.http, settings = settingsStub });
local ok, err = ai.ask('hello');
testlib.assertTrue(not ok);
testlib.assertTrue(string.find(err, 'bridge unreachable', 1, true) ~= nil);
end);
testlib.test('ask over bridge uses request_timeout_seconds for ws receive', function()
local ws = fakeWsHttp({ wsMessageResult('reply') });
local settingsStub = fakeSettings({
['opencc.bridge_url'] = 'ws://bridge',
['opencc.session_id'] = 'ses_1',
['opencc.request_timeout_seconds'] = 120,
});
local ai = createAi({ http = ws.http, settings = settingsStub });
local ok = ai.ask('hello');
testlib.assertTrue(ok);
testlib.assertEquals(ws.lastReceiveTimeout(), 120);
end);
testlib.test('ask over bridge caps request_timeout_seconds at ten minutes', function()
local ws = fakeWsHttp({ wsMessageResult('reply') });
local settingsStub = fakeSettings({
['opencc.bridge_url'] = 'ws://bridge',
['opencc.session_id'] = 'ses_1',
['opencc.request_timeout_seconds'] = 5000,
});
local ai = createAi({ http = ws.http, settings = settingsStub });
local ok = ai.ask('hello');
testlib.assertTrue(ok);
testlib.assertEquals(ws.lastReceiveTimeout(), 600);
end);
-- lua executor --
testlib.test('lua executor captures print write and returned values', function()

View File

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

View File

@ -1,13 +0,0 @@
# 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,26 +0,0 @@
import js from "@eslint/js";
import globals from "globals";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist/**"] },
js.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
{
languageOptions: {
globals: globals.node,
parserOptions: {
projectService: {
allowDefaultProject: ["eslint.config.js"],
},
tsconfigRootDir: import.meta.dirname,
},
},
},
{
files: ["test/**/*.ts", "test-integration/**/*.ts"],
rules: {
"@typescript-eslint/no-floating-promises": "off",
},
},
);

File diff suppressed because it is too large Load Diff

View File

@ -1,38 +0,0 @@
{
"name": "mcp-bridge",
"version": "0.3.1",
"private": true,
"type": "module",
"engines": {
"node": ">=18"
},
"scripts": {
"build": "tsc",
"eslint": "eslint . --cache --cache-location node_modules/.cache/eslint/",
"clean": "rm -rf node_modules/.cache/tsc node_modules/.cache/eslint",
"check": "npm run eslint",
"test:all": "npm run check && npm run build && npm run test",
"test:ci": "npm run test:all && npm run test:integration",
"dev": "tsx watch src/index.ts",
"start": "npm run build && node dist/src/index.js",
"test": "tsx --test test/*.test.ts",
"test:integration": "tsx --test --test-concurrency=1 test-integration/*.test.ts"
},
"dependencies": {
"ws": "^8.17.1"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^20.14.10",
"@types/ws": "^8.5.10",
"eslint": "^10.4.1",
"globals": "^17.6.0",
"tsx": "^4.16.2",
"typescript": "^5.5.3",
"typescript-eslint": "^8.61.0"
},
"allowScripts": {
"esbuild@0.28.0": true,
"fsevents@2.3.3": true
}
}

View File

@ -1,45 +0,0 @@
import { LinkRegistry, startLinkServer } from "./link-server.js";
import { startMcpServer } from "./mcp-server.js";
import { startOpencodeProxy } from "./opencode-proxy.js";
const config = {
mcpHost: process.env.MCP_HOST ?? "127.0.0.1",
mcpPort: readPort(process.env.MCP_PORT, 3000),
ccLinkHost: process.env.CC_LINK_HOST ?? "0.0.0.0",
ccLinkPort: readPort(process.env.CC_LINK_PORT, 3001),
probeTimeoutMs: readPort(process.env.CC_PROBE_TIMEOUT_MS, 2000),
opencodeProxyHost: process.env.OPENCODE_PROXY_HOST ?? "0.0.0.0",
opencodeProxyPort: readPort(process.env.OPENCODE_PROXY_PORT, 3002),
opencodeUrl: process.env.OPENCODE_URL,
opencodeUsername: process.env.OPENCODE_USERNAME,
opencodePassword: process.env.OPENCODE_PASSWORD,
};
const registry = new LinkRegistry();
startMcpServer({ host: config.mcpHost, port: config.mcpPort, probeTimeoutMs: config.probeTimeoutMs, registry });
startLinkServer({ host: config.ccLinkHost, port: config.ccLinkPort, registry });
console.log(`MCP bridge listening on http://${config.mcpHost}:${config.mcpPort}`);
console.log(`ComputerCraft link listening on ws://${config.ccLinkHost}:${config.ccLinkPort}`);
if (config.opencodeUrl) {
startOpencodeProxy({
host: config.opencodeProxyHost,
port: config.opencodeProxyPort,
opencodeUrl: config.opencodeUrl,
username: config.opencodeUsername,
password: config.opencodePassword,
});
console.log(`opencode proxy listening on ws://${config.opencodeProxyHost}:${config.opencodeProxyPort} -> ${config.opencodeUrl}`);
} else {
console.log("opencode proxy disabled (set OPENCODE_URL to enable)");
}
function readPort(value: string | undefined, fallback: number): number {
if (!value) {
return fallback;
}
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}

View File

@ -1,212 +0,0 @@
import { randomUUID } from "node:crypto";
import { WebSocketServer, type WebSocket } from "ws";
import { formatComputer, parseJsonFrame, parseLinkMessage, type ResponseMessage } from "./protocol.js";
export type ComputerConnection = {
computerId: number;
label: string | null;
ws: WebSocket;
connectedAt: number;
lastSeenAt: number;
};
type PendingRequest = {
computerId: number;
resolve: (message: ResponseMessage) => void;
};
export class LinkRegistry {
private readonly computers = new Map<number, ComputerConnection>();
private readonly pending = new Map<string, PendingRequest>();
register(connection: ComputerConnection): void {
const existing = this.computers.get(connection.computerId);
if (existing && existing.ws !== connection.ws) {
existing.ws.close();
}
this.computers.set(connection.computerId, connection);
}
unregister(ws: WebSocket): void {
for (const [computerId, connection] of this.computers) {
if (connection.ws === ws) {
this.computers.delete(computerId);
}
}
}
count(): number {
return this.computers.size;
}
snapshot(): ComputerConnection[] {
return [...this.computers.values()];
}
handleFrame(ws: WebSocket, data: unknown): void {
const message = parseLinkMessage(parseJsonFrame(data));
if (!message) {
return;
}
if (message.type === "hello") {
const now = Date.now();
this.register({
computerId: message.computerId,
label: message.computerLabel,
ws,
connectedAt: now,
lastSeenAt: now,
});
ws.send(JSON.stringify({ type: "hello-ok" }));
return;
}
const pending = this.pending.get(message.id);
if (!pending) {
return;
}
const connection = this.computers.get(pending.computerId);
if (connection) {
connection.lastSeenAt = Date.now();
}
this.pending.delete(message.id);
pending.resolve(message);
}
async probeComputers(timeoutMs: number): Promise<string> {
const computers = this.snapshot();
if (computers.length === 0) {
return "No computers connected.";
}
const lines = await Promise.all(computers.map((computer) => this.probeComputer(computer, timeoutMs)));
return lines.join("\n");
}
async execLua(computerId: number, code: string, timeoutMs: number): Promise<string> {
const computer = this.computers.get(computerId);
if (!computer) {
return `No computer with id ${computerId} connected.`;
}
const result = await this.requestComputer(computer, "exec-lua", { code }, timeoutMs);
return formatExecutionResult(computer, result);
}
async runFile(computerId: number, path: string, args: string[], timeoutMs: number): Promise<string> {
const computer = this.computers.get(computerId);
if (!computer) {
return `No computer with id ${computerId} connected.`;
}
const result = await this.requestComputer(computer, "run-file", { path, args }, timeoutMs);
return formatExecutionResult(computer, result);
}
async writeFile(computerId: number, path: string, content: string, timeoutMs: number): Promise<string> {
const computer = this.computers.get(computerId);
if (!computer) {
return `No computer with id ${computerId} connected.`;
}
const result = await this.requestComputer(computer, "write-file", { path, content }, timeoutMs);
return formatWriteFileResult(computer, result);
}
private async probeComputer(computer: ComputerConnection, timeoutMs: number): Promise<string> {
const result = await this.requestComputer(computer, "ping", undefined, timeoutMs);
if (!result) {
return `timeout from ${formatComputer(computer.computerId, computer.label)}`;
}
if (result.ok && typeof result.result === "string") {
return result.result;
}
return `error from ${formatComputer(computer.computerId, computer.label)}: ${result.error ?? "unknown error"}`;
}
private async requestComputer(
computer: ComputerConnection,
method: string,
params: Record<string, unknown> | undefined,
timeoutMs: number,
): Promise<ResponseMessage | null> {
const id = randomUUID();
const response = new Promise<ResponseMessage>((resolve) => {
this.pending.set(id, { computerId: computer.computerId, resolve });
computer.ws.send(JSON.stringify(params ? { type: "request", id, method, params } : { type: "request", id, method }));
});
const timeout = new Promise<null>((resolve) => {
setTimeout(() => {
this.pending.delete(id);
resolve(null);
}, timeoutMs);
});
return Promise.race([response, timeout]);
}
}
function formatExecutionResult(computer: ComputerConnection, response: ResponseMessage | null): string {
const computerText = formatComputer(computer.computerId, computer.label);
if (!response) {
return `computer: ${computerText}\nok: false\nerror: timeout`;
}
const payload = isRecord(response.result) ? response.result : {};
const output = typeof payload.output === "string" ? payload.output : "";
const ok = response.ok && payload.ok !== false;
const lines = [`computer: ${computerText}`, `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 : response.error ?? "unknown error"}`);
}
lines.push("output:");
if (output !== "") {
lines.push(output.endsWith("\n") ? output.slice(0, -1) : output);
}
return lines.join("\n");
}
function formatWriteFileResult(computer: ComputerConnection, response: ResponseMessage | null): string {
const computerText = formatComputer(computer.computerId, computer.label);
if (!response) {
return `computer: ${computerText}\nok: false\nerror: timeout`;
}
const payload = isRecord(response.result) ? response.result : {};
const ok = response.ok && payload.ok !== false;
const lines = [`computer: ${computerText}`, `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 : response.error ?? "unknown error"}`);
}
return lines.join("\n");
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function startLinkServer(options: { host: string; port: number; registry: LinkRegistry }): WebSocketServer {
const server = new WebSocketServer({ host: options.host, port: options.port });
server.on("connection", (ws) => {
ws.on("message", (data) => options.registry.handleFrame(ws, data));
ws.on("close", () => options.registry.unregister(ws));
ws.on("error", () => options.registry.unregister(ws));
});
return server;
}

View File

@ -1,276 +0,0 @@
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import type { LinkRegistry } from "./link-server.js";
type JsonRpcRequest = {
jsonrpc?: unknown;
id?: unknown;
method?: unknown;
params?: unknown;
};
export function startMcpServer(options: {
host: string;
port: number;
probeTimeoutMs: number;
registry: LinkRegistry;
}): Server {
const server = createServer((req, res) => {
void handleRequest(req, res, options.registry, options.probeTimeoutMs);
});
server.listen(options.port, options.host);
return server;
}
export async function handleMcpRequest(body: unknown, registry: LinkRegistry, probeTimeoutMs: number): Promise<unknown> {
if (Array.isArray(body)) {
return Promise.all(body.map((item) => handleSingleRequest(item as JsonRpcRequest, registry, probeTimeoutMs)));
}
return handleSingleRequest(body as JsonRpcRequest, registry, probeTimeoutMs);
}
async function handleRequest(
req: IncomingMessage,
res: ServerResponse,
registry: LinkRegistry,
probeTimeoutMs: number,
): Promise<void> {
if (req.method === "GET" && req.url === "/health") {
writeJson(res, 200, { ok: true, computers: registry.count() });
return;
}
if (req.method !== "POST") {
writeJson(res, 404, { error: "not found" });
return;
}
const raw = await readBody(req);
let body: unknown;
try {
body = JSON.parse(raw) as unknown;
} catch {
writeJson(res, 400, jsonRpcError(null, -32700, "Parse error"));
return;
}
writeJson(res, 200, await handleMcpRequest(body, registry, probeTimeoutMs));
}
async function handleSingleRequest(request: JsonRpcRequest, registry: LinkRegistry, probeTimeoutMs: number): 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: "mcp-bridge", version: "0.3.1" },
});
}
if (request.method === "notifications/initialized") {
return null;
}
if (request.method === "tools/list") {
return jsonRpcResult(id, {
tools: [
{
name: "probe-computers",
description: "Probe all linked ComputerCraft computers.",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
},
{
name: "exec-lua",
description: "Execute Lua code on a linked ComputerCraft computer.",
inputSchema: {
type: "object",
properties: {
computerId: { type: "number", description: "ComputerCraft computer id to execute on." },
code: { type: "string", description: "Lua source code to execute." },
timeoutMs: { type: "number", description: "Optional host-side timeout in milliseconds, max 30000." },
},
required: ["computerId", "code"],
additionalProperties: false,
},
},
{
name: "write-file",
description: "Write file content on a linked ComputerCraft computer, overwriting any existing file.",
inputSchema: {
type: "object",
properties: {
computerId: { type: "number", description: "ComputerCraft computer id to write on." },
path: { type: "string", description: "Target path on the ComputerCraft 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: ["computerId", "path", "content"],
additionalProperties: false,
},
},
{
name: "run-file",
description: "Run a Lua file on a linked ComputerCraft computer with captured print/write output.",
inputSchema: {
type: "object",
properties: {
computerId: { type: "number", description: "ComputerCraft computer id to run on." },
path: { type: "string", description: "Lua file path on the ComputerCraft computer." },
args: { type: "array", items: { type: "string" }, description: "Optional program arguments passed as Lua varargs." },
timeoutMs: { type: "number", description: "Optional host-side timeout in milliseconds, max 30000." },
},
required: ["computerId", "path"],
additionalProperties: false,
},
},
],
});
}
if (request.method === "tools/call") {
const params = isRecord(request.params) ? request.params : {};
if (params.name === "probe-computers") {
const text = await registry.probeComputers(probeTimeoutMs);
return jsonRpcResult(id, { content: [{ type: "text", text }] });
}
if (params.name === "exec-lua") {
const args = isRecord(params.arguments) ? params.arguments : {};
const parsed = parseExecLuaArgs(args, probeTimeoutMs);
if (!parsed.ok) {
return jsonRpcError(id, -32602, parsed.error);
}
const text = await registry.execLua(parsed.computerId, 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, probeTimeoutMs);
if (!parsed.ok) {
return jsonRpcError(id, -32602, parsed.error);
}
const text = await registry.writeFile(parsed.computerId, parsed.path, parsed.content, parsed.timeoutMs);
return jsonRpcResult(id, { content: [{ type: "text", text }] });
}
if (params.name === "run-file") {
const args = isRecord(params.arguments) ? params.arguments : {};
const parsed = parseRunFileArgs(args, probeTimeoutMs);
if (!parsed.ok) {
return jsonRpcError(id, -32602, parsed.error);
}
const text = await registry.runFile(parsed.computerId, parsed.path, parsed.args, parsed.timeoutMs);
return jsonRpcResult(id, { content: [{ type: "text", text }] });
}
return jsonRpcError(id, -32602, "Unknown tool");
}
return jsonRpcError(id, -32601, "Method not found");
}
function parseExecLuaArgs(
args: Record<string, unknown>,
defaultTimeoutMs: number,
): { ok: true; computerId: number; code: string; timeoutMs: number } | { ok: false; error: string } {
if (typeof args.computerId !== "number" || !Number.isFinite(args.computerId)) {
return { ok: false, error: "computerId must be a finite number" };
}
if (typeof args.code !== "string" || args.code.trim() === "") {
return { ok: false, error: "code must be a non-empty string" };
}
if (args.timeoutMs !== undefined && (typeof args.timeoutMs !== "number" || !Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
return { ok: false, error: "timeoutMs must be a positive finite number" };
}
const timeoutMs = Math.min(args.timeoutMs ?? defaultTimeoutMs, 30_000);
return { ok: true, computerId: args.computerId, code: args.code, timeoutMs };
}
function parseWriteFileArgs(
args: Record<string, unknown>,
defaultTimeoutMs: number,
): { ok: true; computerId: number; path: string; content: string; timeoutMs: number } | { ok: false; error: string } {
if (typeof args.computerId !== "number" || !Number.isFinite(args.computerId)) {
return { ok: false, error: "computerId must be a finite number" };
}
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" };
}
if (args.timeoutMs !== undefined && (typeof args.timeoutMs !== "number" || !Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
return { ok: false, error: "timeoutMs must be a positive finite number" };
}
const timeoutMs = Math.min(args.timeoutMs ?? defaultTimeoutMs, 30_000);
return { ok: true, computerId: args.computerId, path: args.path, content: args.content, timeoutMs };
}
function parseRunFileArgs(
args: Record<string, unknown>,
defaultTimeoutMs: number,
): { ok: true; computerId: number; path: string; args: string[]; timeoutMs: number } | { ok: false; error: string } {
if (typeof args.computerId !== "number" || !Number.isFinite(args.computerId)) {
return { ok: false, error: "computerId must be a finite number" };
}
if (typeof args.path !== "string" || args.path.trim() === "") {
return { ok: false, error: "path must be a non-empty string" };
}
if (args.args !== undefined && !isStringArray(args.args)) {
return { ok: false, error: "args must be an array of strings" };
}
if (args.timeoutMs !== undefined && (typeof args.timeoutMs !== "number" || !Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
return { ok: false, error: "timeoutMs must be a positive finite number" };
}
const timeoutMs = Math.min(args.timeoutMs ?? defaultTimeoutMs, 30_000);
return { ok: true, computerId: args.computerId, path: args.path, args: args.args ?? [], timeoutMs };
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item) => typeof item === "string");
}
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 writeJson(res: ServerResponse, statusCode: number, body: unknown): void {
res.writeHead(statusCode, { "content-type": "application/json" });
res.end(JSON.stringify(body));
}
async function readBody(req: IncomingMessage): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
}
return Buffer.concat(chunks).toString("utf8");
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View File

@ -1,73 +0,0 @@
import { WebSocketServer, type WebSocket } from "ws";
import { parseHttpRequestFrame, parseJsonFrame, type HttpResponseFrame } from "./protocol.js";
export type OpencodeProxyOptions = {
host: string;
port: number;
opencodeUrl: string;
username?: string;
password?: string;
maxRequestMs?: number;
};
const DEFAULT_MAX_REQUEST_MS = 600_000;
// WebSocket endpoint where the ComputerCraft client is the requester and the
// bridge is the responder: it performs the opencode http call server-side (no
// short timeout) and returns the raw response. This lets the in-game client use
// opencode's synchronous /message endpoint without hitting CC's ~60s http cap.
export function startOpencodeProxy(options: OpencodeProxyOptions): WebSocketServer {
const baseUrl = options.opencodeUrl.replace(/\/+$/, "");
const maxRequestMs = options.maxRequestMs ?? DEFAULT_MAX_REQUEST_MS;
const authHeader = options.password
? "Basic " + Buffer.from(`${options.username ?? "opencode"}:${options.password}`).toString("base64")
: undefined;
const server = new WebSocketServer({ host: options.host, port: options.port });
server.on("connection", (ws) => {
ws.on("message", (data) => {
void handleFrame(ws, data);
});
ws.on("error", () => {
/* connection-level errors just drop the socket; nothing to clean up */
});
});
async function handleFrame(ws: WebSocket, data: unknown): Promise<void> {
const frame = parseHttpRequestFrame(parseJsonFrame(data));
if (!frame) {
return;
}
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "application/json",
};
if (authHeader) {
headers.Authorization = authHeader;
}
try {
const res = await fetch(baseUrl + frame.path, {
method: frame.method,
headers,
body: frame.method === "POST" ? frame.body : undefined,
signal: AbortSignal.timeout(maxRequestMs),
});
const body = await res.text();
send(ws, { type: "http-response", id: frame.id, status: res.status, body });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
send(ws, { type: "http-response", id: frame.id, status: 0, error: message });
}
}
return server;
}
function send(ws: WebSocket, frame: HttpResponseFrame): void {
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify(frame));
}
}

View File

@ -1,135 +0,0 @@
export type HelloMessage = {
type: "hello";
computerId: number;
computerLabel: string | null;
};
export type ResponseMessage = {
type: "response";
id: string;
ok: boolean;
result?: unknown;
error?: string;
};
export type LinkMessage = HelloMessage | ResponseMessage;
export type HttpRequestFrame = {
type: "http";
id: string;
method: "GET" | "POST";
path: string;
body?: string;
};
export type HttpResponseFrame = {
type: "http-response";
id: string;
status: number;
body?: string;
error?: string;
};
export function parseHttpRequestFrame(value: unknown): HttpRequestFrame | null {
if (!isRecord(value) || value.type !== "http") {
return null;
}
if (typeof value.id !== "string" || value.id === "") {
return null;
}
if (value.method !== "GET" && value.method !== "POST") {
return null;
}
if (typeof value.path !== "string" || !value.path.startsWith("/")) {
return null;
}
if (value.body !== undefined && typeof value.body !== "string") {
return null;
}
return {
type: "http",
id: value.id,
method: value.method,
path: value.path,
body: typeof value.body === "string" ? value.body : undefined,
};
}
export function parseJsonFrame(data: unknown): unknown {
if (typeof data === "string") {
return parseJson(data);
}
if (Buffer.isBuffer(data)) {
return parseJson(data.toString("utf8"));
}
if (data instanceof ArrayBuffer) {
return parseJson(Buffer.from(data).toString("utf8"));
}
if (Array.isArray(data)) {
return parseJson(Buffer.concat(data).toString("utf8"));
}
return null;
}
export function parseLinkMessage(value: unknown): LinkMessage | null {
if (!isRecord(value) || typeof value.type !== "string") {
return null;
}
if (value.type === "hello") {
if (typeof value.computerId !== "number" || !Number.isFinite(value.computerId)) {
return null;
}
const label = typeof value.computerLabel === "string" && value.computerLabel.trim() !== ""
? value.computerLabel
: null;
return {
type: "hello",
computerId: value.computerId,
computerLabel: label,
};
}
if (value.type === "response") {
if (typeof value.id !== "string" || typeof value.ok !== "boolean") {
return null;
}
return {
type: "response",
id: value.id,
ok: value.ok,
result: value.result,
error: typeof value.error === "string" ? value.error : undefined,
};
}
return null;
}
export function formatComputer(computerId: number, label: string | null): string {
return `${computerId} (Label: ${label ?? "null"})`;
}
function parseJson(text: string): unknown {
try {
return JSON.parse(text) as unknown;
} catch {
return null;
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View File

@ -1,181 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import type { WebSocketServer } from "ws";
import { formatFailure, startCraftos } from "./harness.js";
import { startOpencodeProxy } from "../src/opencode-proxy.js";
type RequestLog = {
method: string;
path: string;
body?: unknown;
};
test("ai CLI commands run through the opencode bridge proxy", async () => {
const requests: RequestLog[] = [];
let nextSession = 1;
const opencode = createServer((req, res) => {
void handleOpencodeRequest(req, res, requests, () => {
const id = `ses_${nextSession}`;
nextSession += 1;
return id;
});
});
await new Promise<void>((resolve) => opencode.listen(0, "127.0.0.1", resolve));
const opencodeUrl = `http://127.0.0.1:${serverPort(opencode)}`;
const proxy = startOpencodeProxy({ host: "127.0.0.1", port: 0, opencodeUrl });
await waitForListening(proxy);
const proxyUrl = `ws://127.0.0.1:${wssPort(proxy)}`;
const craftos = startCraftos("ai-cli-check.lua", {
mountRepo: true,
shellArgs: [proxyUrl],
timeoutMs: 20_000,
});
try {
const result = await craftos.done;
const output = result.output;
assert.equal(result.status, 0, formatFailure("expected craftos to exit cleanly", output));
assert.match(output, /ses_existing {2}Existing title/, formatFailure("expected sessions output", output));
assert.match(output, /\bpong\b/, formatFailure("expected ping reply", output));
assert.match(output, /new reply/, formatFailure("expected ai new reply", output));
assert.match(output, /plain reply/, formatFailure("expected plain ai reply", output));
assert.match(output, /SESSION_AFTER_PING=ses_1/, formatFailure("expected ping session to persist", output));
assert.match(output, /SESSION_AFTER_NEW=ses_2/, formatFailure("expected ai new to replace session", output));
assert.match(output, /SESSION_AFTER_ASK=ses_2/, formatFailure("expected plain ai to reuse new session", output));
assert.deepEqual(requests.map((r) => `${r.method} ${r.path}`), [
"GET /session",
"POST /session",
"POST /session/ses_1/message",
"POST /session",
"POST /session/ses_2/message",
"POST /session/ses_2/message",
]);
assert.match(promptText(requests[2].body), /reply with exactly: pong/);
assert.match(promptText(requests[4].body), /User prompt:\nfresh start/);
assert.match(promptText(requests[5].body), /User prompt:\ncontinue please/);
} finally {
craftos.abort();
await craftos.done.catch(() => undefined);
await closeWss(proxy);
await new Promise<void>((resolve) => opencode.close(() => resolve()));
}
});
async function handleOpencodeRequest(
req: IncomingMessage,
res: ServerResponse,
requests: RequestLog[],
createSessionId: () => string,
): Promise<void> {
const bodyText = await readBody(req);
const body: unknown = bodyText === "" ? undefined : JSON.parse(bodyText) as unknown;
const url = new URL(req.url ?? "/", "http://127.0.0.1");
requests.push({ method: req.method ?? "GET", path: url.pathname + url.search, body });
if (req.method === "GET" && url.pathname === "/session") {
respondJson(res, [
{ id: "ses_existing", title: "Existing title", time: { updated: 10 } },
]);
return;
}
if (req.method === "POST" && url.pathname === "/session") {
const id = createSessionId();
respondJson(res, { id, title: "cc-ai" });
return;
}
const messageMatch = url.pathname.match(/^\/session\/([^/]+)\/message$/);
if (req.method === "POST" && messageMatch) {
respondJson(res, {
info: { id: `msg_${requests.length}`, time: { completed: 1 } },
parts: [{ type: "text", text: replyForPrompt(promptText(body)) }],
});
return;
}
respondJson(res, { error: "not found" }, 404);
}
function replyForPrompt(prompt: string): string {
if (prompt.includes("reply with exactly: pong")) {
return "pong";
}
if (prompt.includes("fresh start")) {
return "new reply";
}
if (prompt.includes("continue please")) {
return "plain reply";
}
return `unhandled prompt: ${prompt}`;
}
function promptText(body: unknown): string {
if (!isObject(body) || !Array.isArray(body.parts)) {
return "";
}
const parts = body.parts as unknown[];
const first = parts[0];
if (!isObject(first) || typeof first.text !== "string") {
return "";
}
return first.text;
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function readBody(req: IncomingMessage): Promise<string> {
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => chunks.push(chunk));
return new Promise((resolve, reject) => {
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
req.on("error", reject);
});
}
function respondJson(res: ServerResponse, body: unknown, status = 200): void {
res.writeHead(status, { "content-type": "application/json" });
res.end(JSON.stringify(body));
}
function serverPort(server: Server): number {
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("server has no TCP address");
}
return address.port;
}
function wssPort(proxy: WebSocketServer): number {
const address = proxy.address();
if (!address || typeof address === "string") {
throw new Error("proxy has no TCP address");
}
return address.port;
}
function waitForListening(proxy: WebSocketServer): Promise<void> {
return new Promise((resolve, reject) => {
if (proxy.address()) {
resolve();
return;
}
proxy.once("listening", () => resolve());
proxy.once("error", reject);
});
}
function closeWss(proxy: WebSocketServer): Promise<void> {
for (const client of proxy.clients) {
client.terminate();
}
return new Promise<void>((resolve) => proxy.close(() => resolve()));
}

View File

@ -1,228 +0,0 @@
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import type { Server } from "node:http";
import type { WebSocketServer } from "ws";
import { LinkRegistry, startLinkServer } from "../src/link-server.js";
import { startMcpServer } from "../src/mcp-server.js";
const HERE = dirname(fileURLToPath(import.meta.url));
const LUA_DIR = join(HERE, "lua");
const REPO_ROOT = join(HERE, "../../..");
export type Bridge = {
registry: LinkRegistry;
mcpUrl: string;
linkUrl: string;
close: () => Promise<void>;
};
export async function startBridge(probeTimeoutMs = 500): Promise<Bridge> {
const registry = new LinkRegistry();
const mcpServer = startMcpServer({ host: "127.0.0.1", port: 0, probeTimeoutMs, registry });
const linkServer = startLinkServer({ host: "127.0.0.1", port: 0, registry });
await Promise.all([waitForListening(mcpServer), waitForListening(linkServer)]);
return {
registry,
mcpUrl: `http://127.0.0.1:${serverPort(mcpServer)}`,
linkUrl: `ws://127.0.0.1:${serverPort(linkServer)}`,
close: () => closeBridge(mcpServer, linkServer),
};
}
export async function waitForComputers(registry: LinkRegistry, count: number, timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (registry.count() >= count) {
return;
}
await sleep(50);
}
throw new Error(`waitForComputers timed out (expected ${count}, got ${registry.count()})`);
}
export async function callProbeComputers(mcpUrl: string): Promise<string> {
const body = {
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: { name: "probe-computers", arguments: {} },
};
const response = await fetch(mcpUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
const payload = (await response.json()) as {
result?: { content?: { type: string; text: string }[] };
};
const text = payload.result?.content?.[0]?.text;
if (typeof text !== "string") {
throw new Error(`Unexpected MCP response: ${JSON.stringify(payload)}`);
}
return text;
}
export async function callExecLua(mcpUrl: string, computerId: number, code: string, timeoutMs?: number): Promise<string> {
const body = {
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: { name: "exec-lua", arguments: { computerId, code, timeoutMs } },
};
const response = await fetch(mcpUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
const payload = (await response.json()) as {
result?: { content?: { type: string; text: string }[] };
};
const text = payload.result?.content?.[0]?.text;
if (typeof text !== "string") {
throw new Error(`Unexpected MCP response: ${JSON.stringify(payload)}`);
}
return text;
}
export async function callWriteFile(
mcpUrl: string,
computerId: number,
path: string,
content: string,
timeoutMs?: number,
): Promise<string> {
const body = {
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: { name: "write-file", arguments: { computerId, path, content, timeoutMs } },
};
const response = await fetch(mcpUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
const payload = (await response.json()) as {
result?: { content?: { type: string; text: string }[] };
};
const text = payload.result?.content?.[0]?.text;
if (typeof text !== "string") {
throw new Error(`Unexpected MCP response: ${JSON.stringify(payload)}`);
}
return text;
}
export type CraftosResult = {
status: number | null;
signal: NodeJS.Signals | null;
output: string;
};
export type CraftosHandle = {
done: Promise<CraftosResult>;
abort: () => void;
};
export function startCraftos(
luaName: string,
opts: { computerId?: number; computerLabel?: string; mountRepo?: boolean; shellArgs?: string[]; timeoutMs?: number } = {},
): CraftosHandle {
const timeoutMs = opts.timeoutMs ?? 15_000;
const controller = new AbortController();
const done = (async (): Promise<CraftosResult> => {
const dataDir = await mkdtemp(join(tmpdir(), "mcp-bridge-it-"));
const watchdog = setTimeout(() => controller.abort(), timeoutMs);
try {
const args: string[] = ["--directory", dataDir, "--headless", "--mount-ro", `/staging=${LUA_DIR}`];
if (opts.computerId !== undefined) {
args.push("--id", String(opts.computerId));
}
if (opts.mountRepo) {
if (!existsSync(join(REPO_ROOT, ".stage/apis"))) {
throw new Error(
".stage/ is missing — run `just stage` (or use `just test-integration`) before the integration tests.",
);
}
args.push(
"--mount-ro", `/trapos=${REPO_ROOT}`,
"--mount-ro", `/apis=${join(REPO_ROOT, ".stage/apis")}`,
"--mount-ro", `/programs=${join(REPO_ROOT, ".stage/programs")}`,
);
}
if (process.platform === "darwin") {
args.push("--rom", "/Applications/CraftOS-PC.app/Contents/Resources");
}
args.push("--exec", buildExecCode(luaName, opts.shellArgs ?? [], opts.computerLabel));
const chunks: Buffer[] = [];
const child = spawn("craftos", args, { signal: controller.signal });
child.stdout.on("data", (d: Buffer) => chunks.push(d));
child.stderr.on("data", (d: Buffer) => chunks.push(d));
const result = await new Promise<{ status: number | null; signal: NodeJS.Signals | null }>((resolve) => {
child.once("close", (code, signal) => resolve({ status: code, signal }));
child.once("error", () => resolve({ status: null, signal: null }));
});
return { status: result.status, signal: result.signal, output: Buffer.concat(chunks).toString("utf8") };
} finally {
clearTimeout(watchdog);
await rm(dataDir, { recursive: true, force: true });
}
})();
return { done, abort: () => controller.abort() };
}
export function formatFailure(message: string, craftosOutput: string): string {
const lines = ["\x1b[31mFAIL\x1b[0m " + message, "--- craftos output ---", craftosOutput.trimEnd(), "----------------------"];
return lines.join("\n");
}
function buildExecCode(luaName: string, shellArgs: string[], computerLabel?: string): string {
const programPath = luaName.startsWith("/") ? luaName : `/staging/${luaName}`;
const parts = [luaQuote(programPath), ...shellArgs.map(luaQuote)];
const setup = computerLabel === undefined ? "" : `os.setComputerLabel(${luaQuote(computerLabel)}); `;
return `${setup}shell.run(${parts.join(", ")})`;
}
function luaQuote(value: string): string {
return `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`;
}
function waitForListening(server: Server | WebSocketServer): Promise<void> {
return new Promise((resolve, reject) => {
const addr = "address" in server ? server.address() : null;
if (addr) {
resolve();
return;
}
server.once("listening", () => resolve());
server.once("error", reject);
});
}
function serverPort(server: Server | WebSocketServer): number {
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("server does not have a TCP address");
}
return address.port;
}
async function closeBridge(mcpServer: Server, linkServer: WebSocketServer): Promise<void> {
for (const client of linkServer.clients) {
client.terminate();
}
await new Promise<void>((resolve) => linkServer.close(() => resolve()));
await new Promise<void>((resolve) => mcpServer.close(() => resolve()));
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@ -1,27 +0,0 @@
-- Runs the real /programs/ai.lua CLI against a bridge proxy URL.
-- Usage: ai-cli-check <ws-proxy-url>
local args = { ... };
local url = args[1];
settings.unset('opencc.server_url');
settings.unset('opencc.session_id');
settings.set('opencc.bridge_url', url);
settings.set('opencc.request_timeout_seconds', 10);
settings.save();
print('--- sessions ---');
shell.run('/programs/ai.lua', 'sessions');
print('--- ping ---');
shell.run('/programs/ai.lua', 'ping');
print('SESSION_AFTER_PING=' .. tostring(settings.get('opencc.session_id')));
print('--- new ---');
shell.run('/programs/ai.lua', 'new', 'fresh', 'start');
print('SESSION_AFTER_NEW=' .. tostring(settings.get('opencc.session_id')));
print('--- ask ---');
shell.run('/programs/ai.lua', 'continue', 'please');
print('SESSION_AFTER_ASK=' .. tostring(settings.get('opencc.session_id')));
os.shutdown();

View File

@ -1,42 +0,0 @@
-- Integration-test helper: connect to the mcp-bridge link server, send a hello
-- frame, then reply to every `request` with a `pong` response until a deadline
-- expires. Args (via shell.run): id, label, urlBase, durationSeconds.
local args = {...};
local id = tonumber(args[1]) or 1;
local label = args[2] or "echo";
local urlBase = args[3] or "ws://127.0.0.1:2001";
local duration = tonumber(args[4]) or 5;
local url = urlBase .. "/?id=" .. id;
local ws, err = http.websocket(url);
if not ws then
print("websocket failed: " .. tostring(err));
os.shutdown();
return;
end
ws.send(textutils.serializeJSON({
type = "hello",
computerId = id,
computerLabel = label,
}));
local deadline = os.epoch("utc") + duration * 1000;
while os.epoch("utc") < deadline do
local msg = ws.receive(0.5);
if msg then
local frame = textutils.unserializeJSON(msg);
if frame and frame.type == "request" then
ws.send(textutils.serializeJSON({
type = "response",
id = frame.id,
ok = true,
result = "pong from " .. id .. " (Label: " .. label .. ")",
}));
end
end
end
pcall(function() ws.close(); end);
os.shutdown();

View File

@ -1,44 +0,0 @@
-- Integration-test helper: open two websocket connections in parallel, each
-- presenting itself as a distinct logical computer. Used by probe-multi.
local args = {...};
local urlBase = args[1] or "ws://127.0.0.1:2001";
local function connect(id, label, duration)
local url = urlBase .. "/?id=" .. id;
local ws, err = http.websocket(url);
if not ws then
print("websocket failed (" .. id .. "): " .. tostring(err));
return;
end
ws.send(textutils.serializeJSON({
type = "hello",
computerId = id,
computerLabel = label,
}));
local deadline = os.epoch("utc") + duration * 1000;
while os.epoch("utc") < deadline do
local msg = ws.receive(0.3);
if msg then
local frame = textutils.unserializeJSON(msg);
if frame and frame.type == "request" then
ws.send(textutils.serializeJSON({
type = "response",
id = frame.id,
ok = true,
result = "pong from " .. id .. " (Label: " .. label .. ")",
}));
end
end
end
pcall(function() ws.close(); end);
end
parallel.waitForAll(
function() connect(1001, "echo-A", 5); end,
function() connect(1002, "echo-B", 5); end
);
os.shutdown();

View File

@ -1,15 +0,0 @@
-- Drives the real libhttpws transport against the bridge opencode proxy.
-- Usage: opencode-proxy-check <ws-proxy-url>
local createHttpWs = require('/apis/libhttpws');
local args = { ... };
local url = args[1];
local client = createHttpWs({ bridgeUrl = url, receiveTimeout = 10 });
local body, code = client.postJson({ url = '' }, '/session/ses_x/message', {
parts = { { type = 'text', text = 'ping' } },
});
print('STATUS=' .. tostring(code));
print('BODY=' .. tostring(body));
client.close();
os.shutdown();

View File

@ -1,28 +0,0 @@
-- Integration-test helper: connect, send a hello frame, then ignore every
-- request until the deadline expires. Used to prove the bridge's per-computer
-- probe timeout path.
local args = {...};
local id = tonumber(args[1]) or 99;
local label = args[2] or "silent";
local urlBase = args[3] or "ws://127.0.0.1:2001";
local duration = tonumber(args[4]) or 5;
local url = urlBase .. "/?id=" .. id;
local ws, err = http.websocket(url);
if not ws then
print("websocket failed: " .. tostring(err));
os.shutdown();
return;
end
ws.send(textutils.serializeJSON({
type = "hello",
computerId = id,
computerLabel = label,
}));
sleep(duration);
pcall(function() ws.close(); end);
os.shutdown();

View File

@ -1,70 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createServer, type Server } from "node:http";
import type { WebSocketServer } from "ws";
import { formatFailure, startCraftos } from "./harness.js";
import { startOpencodeProxy } from "../src/opencode-proxy.js";
test("libhttpws drives a synchronous opencode call through the bridge proxy", async () => {
const opencode = createServer((_req, res) => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ info: { finish: "stop" }, parts: [{ type: "text", text: "pong" }] }));
});
await new Promise<void>((resolve) => opencode.listen(0, "127.0.0.1", resolve));
const opencodeUrl = `http://127.0.0.1:${serverPort(opencode)}`;
const proxy = startOpencodeProxy({ host: "127.0.0.1", port: 0, opencodeUrl });
await waitForListening(proxy);
const proxyUrl = `ws://127.0.0.1:${wssPort(proxy)}`;
const craftos = startCraftos("opencode-proxy-check.lua", {
mountRepo: true,
shellArgs: [proxyUrl],
timeoutMs: 20_000,
});
try {
const result = await craftos.done;
assert.match(result.output, /STATUS=200/, formatFailure("expected STATUS=200", result.output));
assert.match(result.output, /pong/, formatFailure("expected reply body to contain pong", result.output));
} finally {
craftos.abort();
await craftos.done.catch(() => undefined);
await closeWss(proxy);
await new Promise<void>((resolve) => opencode.close(() => resolve()));
}
});
function serverPort(server: Server): number {
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("server has no TCP address");
}
return address.port;
}
function wssPort(proxy: WebSocketServer): number {
const address = proxy.address();
if (!address || typeof address === "string") {
throw new Error("proxy has no TCP address");
}
return address.port;
}
function waitForListening(proxy: WebSocketServer): Promise<void> {
return new Promise((resolve, reject) => {
if (proxy.address()) {
resolve();
return;
}
proxy.once("listening", () => resolve());
proxy.once("error", reject);
});
}
function closeWss(proxy: WebSocketServer): Promise<void> {
for (const client of proxy.clients) {
client.terminate();
}
return new Promise<void>((resolve) => proxy.close(() => resolve()));
}

View File

@ -1,12 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { callProbeComputers, startBridge } from "./harness.js";
test("probe-computers returns the no-computers message when nothing is connected", async () => {
const bridge = await startBridge();
try {
assert.equal(await callProbeComputers(bridge.mcpUrl), "No computers connected.");
} finally {
await bridge.close();
}
});

View File

@ -1,21 +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 a single CraftOS echo computer", async () => {
const bridge = await startBridge();
const craftos = startCraftos("echo-client.lua", { shellArgs: ["1", "echo-1", bridge.linkUrl, "8"] });
try {
await waitForComputers(bridge.registry, 1, 12_000);
const text = await callProbeComputers(bridge.mcpUrl);
assert.equal(text, "pong from 1 (Label: echo-1)");
} 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 aggregates two CraftOS computers from the same headless process", async () => {
const bridge = await startBridge();
const craftos = startCraftos("multi-echo-client.lua", { shellArgs: [bridge.linkUrl], timeoutMs: 15_000 });
try {
await waitForComputers(bridge.registry, 2, 12_000);
const text = await callProbeComputers(bridge.mcpUrl);
const lines = text.split("\n").sort();
assert.deepEqual(lines, [
"pong from 1001 (Label: echo-A)",
"pong from 1002 (Label: echo-B)",
]);
} 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,21 +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 reports timeout for a connected computer that never replies", async () => {
const bridge = await startBridge(300);
const craftos = startCraftos("silent-client.lua", { shellArgs: ["7", "silent-7", bridge.linkUrl, "8"] });
try {
await waitForComputers(bridge.registry, 1, 12_000);
const text = await callProbeComputers(bridge.mcpUrl);
assert.equal(text, "timeout from 7 (Label: silent-7)");
} 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,163 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";
import { WebSocket, type RawData, type WebSocketServer } from "ws";
import { parseHttpRequestFrame, parseJsonFrame } from "../src/protocol.js";
import { startOpencodeProxy } from "../src/opencode-proxy.js";
test("parseHttpRequestFrame accepts a valid frame", () => {
const frame = parseJsonFrame(
JSON.stringify({ type: "http", id: "req_1", method: "POST", path: "/session/x/message", body: "{}" }),
);
assert.deepEqual(parseHttpRequestFrame(frame), {
type: "http",
id: "req_1",
method: "POST",
path: "/session/x/message",
body: "{}",
});
});
test("parseHttpRequestFrame omits absent body", () => {
const frame = parseHttpRequestFrame({ type: "http", id: "req_1", method: "GET", path: "/session" });
assert.deepEqual(frame, { type: "http", id: "req_1", method: "GET", path: "/session", body: undefined });
});
test("parseHttpRequestFrame rejects invalid frames", () => {
assert.equal(parseHttpRequestFrame({ type: "http", id: "", method: "GET", path: "/x" }), null);
assert.equal(parseHttpRequestFrame({ type: "http", id: "a", method: "DELETE", path: "/x" }), null);
assert.equal(parseHttpRequestFrame({ type: "http", id: "a", method: "GET", path: "no-slash" }), null);
assert.equal(parseHttpRequestFrame({ type: "http", id: "a", method: "GET", path: "/x", body: 5 }), null);
assert.equal(parseHttpRequestFrame({ type: "other", id: "a", method: "GET", path: "/x" }), null);
});
test("proxy forwards a request to opencode and returns the raw response", async () => {
const seen: { method?: string; url?: string; auth?: string; body?: string } = {};
const opencode = await startFakeOpencode((req, res, body) => {
seen.method = req.method;
seen.url = req.url;
seen.auth = req.headers.authorization;
seen.body = body;
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ info: { finish: "stop" }, parts: [{ type: "text", text: "pong" }] }));
});
const proxy = startOpencodeProxy({
host: "127.0.0.1",
port: 0,
opencodeUrl: opencode.url,
username: "opencode",
password: "secret",
});
await waitForListening(proxy);
try {
const response = await roundtrip(proxyUrl(proxy), {
type: "http",
id: "req_42",
method: "POST",
path: "/session/ses_1/message",
body: JSON.stringify({ parts: [{ type: "text", text: "ping" }] }),
});
assert.equal(response.type, "http-response");
assert.equal(response.id, "req_42");
assert.equal(response.status, 200);
const parsed = JSON.parse(response.body as string) as { parts: { text: string }[] };
assert.equal(parsed.parts[0].text, "pong");
assert.equal(seen.method, "POST");
assert.equal(seen.url, "/session/ses_1/message");
assert.equal(seen.auth, "Basic " + Buffer.from("opencode:secret").toString("base64"));
assert.equal(seen.body, JSON.stringify({ parts: [{ type: "text", text: "ping" }] }));
} finally {
await closeWss(proxy);
await opencode.close();
}
});
test("proxy maps a fetch failure to status 0", async () => {
// Point at a port with nothing listening so fetch rejects.
const proxy = startOpencodeProxy({ host: "127.0.0.1", port: 0, opencodeUrl: "http://127.0.0.1:1" });
await waitForListening(proxy);
try {
const response = await roundtrip(proxyUrl(proxy), {
type: "http",
id: "req_err",
method: "GET",
path: "/session",
});
assert.equal(response.id, "req_err");
assert.equal(response.status, 0);
assert.equal(typeof response.error, "string");
} finally {
await closeWss(proxy);
}
});
type HttpResponse = { type: string; id: string; status: number; body?: string; error?: string };
function rawToString(data: RawData): string {
if (Array.isArray(data)) {
return Buffer.concat(data).toString("utf8");
}
if (Buffer.isBuffer(data)) {
return data.toString("utf8");
}
return Buffer.from(data).toString("utf8");
}
function roundtrip(url: string, frame: unknown): Promise<HttpResponse> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url);
ws.on("open", () => ws.send(JSON.stringify(frame)));
ws.on("message", (data: RawData) => {
ws.close();
resolve(JSON.parse(rawToString(data)) as HttpResponse);
});
ws.on("error", reject);
});
}
async function startFakeOpencode(
handler: (req: IncomingMessage, res: ServerResponse, body: string) => void,
): Promise<{ url: string; close: () => Promise<void> }> {
const server = createServer((req, res) => {
const chunks: Buffer[] = [];
req.on("data", (c: Buffer) => chunks.push(c));
req.on("end", () => handler(req, res, Buffer.concat(chunks).toString("utf8")));
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const port = (server.address() as AddressInfo).port;
return {
url: `http://127.0.0.1:${port}`,
close: () => new Promise<void>((resolve) => server.close(() => resolve())),
};
}
function proxyUrl(proxy: WebSocketServer): string {
const address = proxy.address();
if (!address || typeof address === "string") {
throw new Error("proxy has no TCP address");
}
return `ws://127.0.0.1:${address.port}`;
}
function waitForListening(server: WebSocketServer | Server): Promise<void> {
return new Promise((resolve, reject) => {
if (server.address()) {
resolve();
return;
}
server.once("listening", () => resolve());
server.once("error", reject);
});
}
function closeWss(proxy: WebSocketServer): Promise<void> {
for (const client of proxy.clients) {
client.terminate();
}
return new Promise<void>((resolve) => proxy.close(() => resolve()));
}

View File

@ -1,377 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { WebSocket } from "ws";
import { LinkRegistry } from "../src/link-server.js";
import { handleMcpRequest } from "../src/mcp-server.js";
test("probe-computers returns no computers message when registry is empty", async () => {
const registry = new LinkRegistry();
assert.equal(await registry.probeComputers(10), "No computers connected.");
});
test("probe-computers aggregates multiple successful responses", async () => {
const registry = new LinkRegistry();
const computer1 = new FakeSocket();
const computer2 = new FakeSocket();
registry.register({ computerId: 12, label: "base-turtle", ws: computer1 as unknown as WebSocket, connectedAt: 1, lastSeenAt: 1 });
registry.register({ computerId: 13, label: "miner-1", ws: computer2 as unknown as WebSocket, connectedAt: 1, lastSeenAt: 1 });
const promise = registry.probeComputers(50);
computer1.respond(registry, 12, "pong from 12 (Label: base-turtle)");
computer2.respond(registry, 13, "pong from 13 (Label: miner-1)");
assert.equal(await promise, "pong from 12 (Label: base-turtle)\npong from 13 (Label: miner-1)");
});
test("probe-computers reports timeout for a connected computer that does not answer", async () => {
const registry = new LinkRegistry();
registry.register({ computerId: 14, label: "farm-turtle", ws: new FakeSocket() as unknown as WebSocket, connectedAt: 1, lastSeenAt: 1 });
assert.equal(await registry.probeComputers(5), "timeout from 14 (Label: farm-turtle)");
});
test("MCP tool call returns text content", async () => {
const registry = new LinkRegistry();
const response = await handleMcpRequest(
{ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "probe-computers", arguments: {} } },
registry,
10,
);
assert.deepEqual(response, {
jsonrpc: "2.0",
id: 1,
result: { content: [{ type: "text", text: "No computers connected." }] },
});
});
test("MCP tools/list includes exec-lua schema", async () => {
const registry = new LinkRegistry();
const response = await handleMcpRequest({ jsonrpc: "2.0", id: 1, method: "tools/list" }, registry, 10);
assert.deepEqual(response, {
jsonrpc: "2.0",
id: 1,
result: {
tools: [
{
name: "probe-computers",
description: "Probe all linked ComputerCraft computers.",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
},
{
name: "exec-lua",
description: "Execute Lua code on a linked ComputerCraft computer.",
inputSchema: {
type: "object",
properties: {
computerId: { type: "number", description: "ComputerCraft computer id to execute on." },
code: { type: "string", description: "Lua source code to execute." },
timeoutMs: { type: "number", description: "Optional host-side timeout in milliseconds, max 30000." },
},
required: ["computerId", "code"],
additionalProperties: false,
},
},
{
name: "write-file",
description: "Write file content on a linked ComputerCraft computer, overwriting any existing file.",
inputSchema: {
type: "object",
properties: {
computerId: { type: "number", description: "ComputerCraft computer id to write on." },
path: { type: "string", description: "Target path on the ComputerCraft 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: ["computerId", "path", "content"],
additionalProperties: false,
},
},
{
name: "run-file",
description: "Run a Lua file on a linked ComputerCraft computer with captured print/write output.",
inputSchema: {
type: "object",
properties: {
computerId: { type: "number", description: "ComputerCraft computer id to run on." },
path: { type: "string", description: "Lua file path on the ComputerCraft computer." },
args: { type: "array", items: { type: "string" }, description: "Optional program arguments passed as Lua varargs." },
timeoutMs: { type: "number", description: "Optional host-side timeout in milliseconds, max 30000." },
},
required: ["computerId", "path"],
additionalProperties: false,
},
},
],
},
});
});
test("exec-lua sends code to the selected computer", async () => {
const registry = new LinkRegistry();
const computer = new FakeSocket();
registry.register({ computerId: 12, label: "base-turtle", ws: computer as unknown as WebSocket, connectedAt: 1, lastSeenAt: 1 });
const promise = registry.execLua(12, "print('hi') return 2 + 3", 50);
assert.deepEqual(computer.lastRequest(), {
type: "request",
id: computer.lastRequest().id,
method: "exec-lua",
params: { code: "print('hi') return 2 + 3" },
});
computer.respond(registry, 12, { returns: [{ type: "number", value: 5 }], output: "hi\n" });
assert.equal(
await promise,
'computer: 12 (Label: base-turtle)\nok: true\nreturns: [{"type":"number","value":5}]\noutput:\nhi',
);
});
test("exec-lua reports unknown computer", async () => {
const registry = new LinkRegistry();
assert.equal(await registry.execLua(99, "return 1", 50), "No computer with id 99 connected.");
});
test("exec-lua formats computer error responses", async () => {
const registry = new LinkRegistry();
const computer = new FakeSocket();
registry.register({ computerId: 12, label: "base-turtle", ws: computer as unknown as WebSocket, connectedAt: 1, lastSeenAt: 1 });
const promise = registry.execLua(12, "print('before'); error('boom')", 50);
computer.respondError(registry, 12, "boom", { output: "before\n" });
assert.equal(await promise, "computer: 12 (Label: base-turtle)\nok: false\nerror: boom\noutput:\nbefore");
});
test("exec-lua formats ok false execution results", async () => {
const registry = new LinkRegistry();
const computer = new FakeSocket();
registry.register({ computerId: 12, label: "base-turtle", ws: computer as unknown as WebSocket, connectedAt: 1, lastSeenAt: 1 });
const promise = registry.execLua(12, "print('before'); error('boom')", 50);
computer.respond(registry, 12, { ok: false, error: "boom", output: "before\n" });
assert.equal(await promise, "computer: 12 (Label: base-turtle)\nok: false\nerror: boom\noutput:\nbefore");
});
test("exec-lua reports timeouts", async () => {
const registry = new LinkRegistry();
registry.register({ computerId: 12, label: "base-turtle", ws: new FakeSocket() as unknown as WebSocket, connectedAt: 1, lastSeenAt: 1 });
assert.equal(await registry.execLua(12, "while true do end", 5), "computer: 12 (Label: base-turtle)\nok: false\nerror: timeout");
});
test("run-file sends path to the selected computer", async () => {
const registry = new LinkRegistry();
const computer = new FakeSocket();
registry.register({ computerId: 12, label: "base-turtle", ws: computer as unknown as WebSocket, connectedAt: 1, lastSeenAt: 1 });
const promise = registry.runFile(12, "/helloworld.lua", ["install", "pkg"], 50);
assert.deepEqual(computer.lastRequest(), {
type: "request",
id: computer.lastRequest().id,
method: "run-file",
params: { path: "/helloworld.lua", args: ["install", "pkg"] },
});
computer.respond(registry, 12, { returns: [], output: "Blah!\n" });
assert.equal(await promise, "computer: 12 (Label: base-turtle)\nok: true\nreturns: []\noutput:\nBlah!");
});
test("run-file reports unknown computer", async () => {
const registry = new LinkRegistry();
assert.equal(await registry.runFile(99, "/helloworld.lua", [], 50), "No computer with id 99 connected.");
});
test("MCP run-file validates required arguments", async () => {
const registry = new LinkRegistry();
const response = await handleMcpRequest(
{ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "run-file", arguments: { computerId: 1 } } },
registry,
10,
);
assert.deepEqual(response, {
jsonrpc: "2.0",
id: 2,
error: { code: -32602, message: "path must be a non-empty string" },
});
});
test("MCP run-file returns text content", async () => {
const registry = new LinkRegistry();
const computer = new FakeSocket();
registry.register({ computerId: 12, label: "base-turtle", ws: computer as unknown as WebSocket, connectedAt: 1, lastSeenAt: 1 });
const responsePromise = handleMcpRequest(
{
jsonrpc: "2.0",
id: 2,
method: "tools/call",
params: { name: "run-file", arguments: { computerId: 12, path: "/helloworld.lua", args: ["a", "b"] } },
},
registry,
10,
);
computer.respond(registry, 12, { returns: [], output: "Blah!\n" });
assert.deepEqual(computer.lastRequest().params, { path: "/helloworld.lua", args: ["a", "b"] });
assert.deepEqual(await responsePromise, {
jsonrpc: "2.0",
id: 2,
result: { content: [{ type: "text", text: "computer: 12 (Label: base-turtle)\nok: true\nreturns: []\noutput:\nBlah!" }] },
});
});
test("MCP exec-lua validates required arguments", async () => {
const registry = new LinkRegistry();
const response = await handleMcpRequest(
{ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "exec-lua", arguments: { computerId: 1 } } },
registry,
10,
);
assert.deepEqual(response, {
jsonrpc: "2.0",
id: 2,
error: { code: -32602, message: "code must be a non-empty string" },
});
});
test("MCP exec-lua validates timeoutMs", async () => {
const registry = new LinkRegistry();
const response = await handleMcpRequest(
{ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "exec-lua", arguments: { computerId: 1, code: "return 1", timeoutMs: 0 } } },
registry,
10,
);
assert.deepEqual(response, {
jsonrpc: "2.0",
id: 2,
error: { code: -32602, message: "timeoutMs must be a positive finite number" },
});
});
test("write-file sends path and content to the selected computer", async () => {
const registry = new LinkRegistry();
const computer = new FakeSocket();
registry.register({ computerId: 12, label: "base-turtle", ws: computer as unknown as WebSocket, connectedAt: 1, lastSeenAt: 1 });
const promise = registry.writeFile(12, "notes/todo.txt", "hello\nworld", 50);
assert.deepEqual(computer.lastRequest(), {
type: "request",
id: computer.lastRequest().id,
method: "write-file",
params: { path: "notes/todo.txt", content: "hello\nworld" },
});
computer.respond(registry, 12, { path: "notes/todo.txt", bytes: 11 });
assert.equal(await promise, "computer: 12 (Label: base-turtle)\nok: true\npath: notes/todo.txt\nbytes: 11");
});
test("write-file reports unknown computer", async () => {
const registry = new LinkRegistry();
assert.equal(await registry.writeFile(99, "note.txt", "hello", 50), "No computer with id 99 connected.");
});
test("write-file formats computer error responses", async () => {
const registry = new LinkRegistry();
const computer = new FakeSocket();
registry.register({ computerId: 12, label: "base-turtle", ws: computer as unknown as WebSocket, connectedAt: 1, lastSeenAt: 1 });
const promise = registry.writeFile(12, "missing/note.txt", "hello", 50);
computer.respondError(registry, 12, "No such file", {});
assert.equal(await promise, "computer: 12 (Label: base-turtle)\nok: false\nerror: No such file");
});
test("write-file formats ok false operation results", async () => {
const registry = new LinkRegistry();
const computer = new FakeSocket();
registry.register({ computerId: 12, label: "base-turtle", ws: computer as unknown as WebSocket, connectedAt: 1, lastSeenAt: 1 });
const promise = registry.writeFile(12, "missing/note.txt", "hello", 50);
computer.respond(registry, 12, { ok: false, error: "No such file" });
assert.equal(await promise, "computer: 12 (Label: base-turtle)\nok: false\nerror: No such file");
});
test("write-file reports timeouts", async () => {
const registry = new LinkRegistry();
registry.register({ computerId: 12, label: "base-turtle", ws: new FakeSocket() as unknown as WebSocket, connectedAt: 1, lastSeenAt: 1 });
assert.equal(await registry.writeFile(12, "note.txt", "hello", 5), "computer: 12 (Label: base-turtle)\nok: false\nerror: timeout");
});
test("MCP write-file validates required arguments", async () => {
const registry = new LinkRegistry();
const response = await handleMcpRequest(
{ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "write-file", arguments: { computerId: 1, content: "hello" } } },
registry,
10,
);
assert.deepEqual(response, {
jsonrpc: "2.0",
id: 2,
error: { code: -32602, message: "path must be a non-empty string" },
});
});
test("MCP write-file allows empty content", async () => {
const registry = new LinkRegistry();
const computer = new FakeSocket();
registry.register({ computerId: 12, label: "base-turtle", ws: computer as unknown as WebSocket, connectedAt: 1, lastSeenAt: 1 });
const responsePromise = handleMcpRequest(
{ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "write-file", arguments: { computerId: 12, path: "empty.txt", content: "" } } },
registry,
10,
);
computer.respond(registry, 12, { path: "empty.txt", bytes: 0 });
assert.deepEqual(await responsePromise, {
jsonrpc: "2.0",
id: 2,
result: { content: [{ type: "text", text: "computer: 12 (Label: base-turtle)\nok: true\npath: empty.txt\nbytes: 0" }] },
});
});
class FakeSocket {
sent: unknown[] = [];
send(data: unknown): void {
this.sent.push(data);
}
close(): void {
return;
}
lastRequest(): { id: string; type: string; method: string; params?: unknown } {
const last = this.sent.at(-1);
assert.equal(typeof last, "string");
return JSON.parse(last as string) as { id: string; type: string; method: string; params?: unknown };
}
respond(registry: LinkRegistry, computerId: number, result: unknown): void {
const request = this.lastRequest();
registry.handleFrame(this as unknown as WebSocket, JSON.stringify({ type: "response", id: request.id, ok: true, result }));
assert.equal(computerId > 0, true);
}
respondError(registry: LinkRegistry, computerId: number, error: string, result: unknown): void {
const request = this.lastRequest();
registry.handleFrame(this as unknown as WebSocket, JSON.stringify({ type: "response", id: request.id, ok: false, error, result }));
assert.equal(computerId > 0, true);
}
}

View File

@ -1,19 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { parseJsonFrame, parseLinkMessage } from "../src/protocol.js";
test("protocol validation accepts valid hello", () => {
const frame = parseJsonFrame(JSON.stringify({ type: "hello", computerId: 12, computerLabel: "base-turtle" }));
assert.deepEqual(parseLinkMessage(frame), { type: "hello", computerId: 12, computerLabel: "base-turtle" });
});
test("protocol validation normalizes empty label", () => {
const frame = parseJsonFrame(JSON.stringify({ type: "hello", computerId: 12, computerLabel: "" }));
assert.deepEqual(parseLinkMessage(frame), { type: "hello", computerId: 12, computerLabel: null });
});
test("protocol validation rejects invalid frames", () => {
assert.equal(parseJsonFrame("not json"), null);
assert.equal(parseLinkMessage({ type: "hello", computerId: "12" }), null);
assert.equal(parseLinkMessage({ type: "response", id: 1, ok: true }), null);
});

View File

@ -1,17 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": ".",
"declaration": true,
"incremental": true,
"tsBuildInfoFile": "node_modules/.cache/tsc/tsconfig.tsbuildinfo"
},
"include": ["src/**/*.ts", "test/**/*.ts", "test-integration/**/*.ts"]
}