Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ on:
- 'mcp-bridge/**'
- 'tests/**'
- 'src/**'
- '.github/workflows/e2e.yml'
workflow_dispatch:

permissions: read-all
Expand Down Expand Up @@ -163,6 +164,66 @@ jobs:
path: ffi/zig/zig-out/bench*
retention-days: 30

# ─── Bridge Tests: unit suite + boot smoke × {Node, Deno, Bun} ─────
# The README blesses three runtimes for the zero-dependency bridge:
# Deno (documented runtime), Node (the npx install path every client
# quickstart uses), and Bun (zero-install). Only Deno is exercised by
# development, so a runtime leak in the other two ships silently — a
# bare `Deno.env` reference once broke the entire npx path at import
# time (fixed in PR #211). This job runs the full unit suite AND an
# MCP initialize → tools/list boot smoke of main.js under each
# runtime. The smoke matters separately: the unit suite imports lib/
# but never boots main.js, whose stdio/exit paths are runtime-gated.
bridge-tests:
name: Bridge — ${{ matrix.runtime }} (unit + boot smoke)
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
include:
- runtime: node
unit: node --test mcp-bridge/tests/dispatch_test.js mcp-bridge/tests/http_transport_test.js mcp-bridge/tests/path_claims_test.js
boot: node mcp-bridge/main.js
- runtime: deno
unit: deno test --allow-read --allow-env --allow-run --allow-net mcp-bridge/tests/dispatch_test.js mcp-bridge/tests/http_transport_test.js mcp-bridge/tests/path_claims_test.js
# Scoped perms matching main.js's shebang, NOT -A: the boot smoke
# must exercise the exact permission set a real install uses, or it
# would mask a missing-grant bug that scoped users would hit.
boot: deno run --allow-net --allow-env --allow-read mcp-bridge/main.js
- runtime: bun
unit: bun test mcp-bridge/tests/dispatch_test.js mcp-bridge/tests/http_transport_test.js mcp-bridge/tests/path_claims_test.js
boot: bun mcp-bridge/main.js

steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Setup Node
# Node is needed on every leg: it is the subject on the node
# leg and the boot-smoke orchestrator on all three.
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: '22'

- name: Install Deno
if: matrix.runtime == 'deno'
uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4
with:
deno-version: v2.x

- name: Install Bun
if: matrix.runtime == 'bun'
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: '1.x'

- name: Unit suite (${{ matrix.runtime }})
run: ${{ matrix.unit }}

- name: Boot smoke (${{ matrix.runtime }})
run: node mcp-bridge/tests/boot_smoke.js ${{ matrix.boot }}

# ─── Bench Bridge: mcp-bridge JS perf (path-claims) ────────────────
# Separate job from `benchmarks` above because the bridge has no Zig
# toolchain dependency — keeps logs untangled and lets the JS bench
Expand Down
22 changes: 14 additions & 8 deletions docs/planning/v1-critical-chain-2026-06-11.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -325,14 +325,20 @@ BUILD-FROM-SOURCE/DEV. A newcomer should never see `asdf install idris2`
unless they are hacking the ABI.

E5. *Close the regression class found in this session.* The bridge's
own unit tests (`node --test mcp-bridge/tests/`) are not run by any CI
workflow — only a bench script is. Consequence: a bare `Deno.env`/
`Deno.pid` call in `nickel-validator.js` shipped and broke the *entire
npx/Node install path* at import time (fixed in this session's commit;
52/52 tests now pass under Node). Add a bridge-tests CI job with a
{Node, Deno} matrix so the primary install path cannot silently break
again. Same PR can pick up the two broken gates already catalogued in
#199 (Zig download URL, ABI-grep false positive).
own unit tests (`node --test mcp-bridge/tests/`) were not run by any CI
workflow — only a bench script was. Consequence: a bare `Deno.env`/
`Deno.pid` call in `nickel-validator.js` shipped and broke the npx/Node
install path *and* the Bun path at import time (Bun has no `Deno`
global either; only the dogfooded Deno runtime was clean). Fixed in
PR #211. *LANDED (follow-up PR):* a `bridge-tests` job in `e2e.yml`
runs the full unit suite *plus* an MCP initialize → tools/list boot
smoke of `main.js` under a {Node, Deno, Bun} matrix — all three
runtimes verified green locally before wiring (52/52 units each; boot
smoke answers with 68 tools and exits 0). Bun is in the matrix because
the README blesses it as an install path; the boot smoke exists because
the unit suite imports `lib/` but never boots `main.js`, whose
stdio/exit paths are runtime-gated. The two broken gates catalogued in
#199 (Zig download URL, ABI-grep false positive) stay tracked there.

E6. *Quickstart trace test.* Once per release, someone (or a scripted
container) executes USER.adoc literally, from clean, and the elapsed
Expand Down
107 changes: 107 additions & 0 deletions mcp-bridge/tests/boot_smoke.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// SPDX-License-Identifier: MPL-2.0
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
//
// BoJ Server — bridge boot smoke (runtime portability gate)
//
// Spawns the MCP bridge (`main.js`) under a given runtime command,
// performs a real JSON-RPC handshake over stdio (initialize →
// notifications/initialized → tools/list), and asserts the bridge
// answers with serverInfo and a non-empty tool list, then exits 0
// when stdin closes.
//
// The unit suite (dispatch_test.js etc.) imports the lib/ modules but
// never boots main.js itself — so a runtime-specific leak in the boot
// path (e.g. a bare `Deno.*` reference under Node/Bun, the PR #211
// bug class) would pass units and still break `npx`/`bun main.js`.
// This smoke closes that gap. No REST backend is required: initialize
// and tools/list are bridge-local (offline manifest).
//
// Usage (orchestrator always runs under Node; subject runtime varies).
// The deno leg uses main.js's own scoped grant, not -A, so the smoke
// exercises the exact permission set a real install uses:
// node mcp-bridge/tests/boot_smoke.js node mcp-bridge/main.js
// node mcp-bridge/tests/boot_smoke.js deno run --allow-net --allow-env --allow-read mcp-bridge/main.js
// node mcp-bridge/tests/boot_smoke.js bun mcp-bridge/main.js

import { spawn } from "node:child_process";
import process from "node:process";

const TIMEOUT_MS = 30_000;

const argv = process.argv.slice(2);
if (argv.length === 0) {
console.error("usage: node boot_smoke.js <runtime command...>");
process.exit(2);
}

const requests = [
{
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "boot-smoke", version: "0.0.0" },
},
},
{ jsonrpc: "2.0", method: "notifications/initialized" },
{ jsonrpc: "2.0", id: 2, method: "tools/list" },
];

const child = spawn(argv[0], argv.slice(1), {
stdio: ["pipe", "pipe", "pipe"],
env: { ...process.env, BOJ_TRANSPORT: "stdio" },
});

let stdout = "";
let stderr = "";
child.stdout.on("data", (d) => (stdout += d));
child.stderr.on("data", (d) => (stderr += d));

const killTimer = setTimeout(() => {
console.error(`FAIL: bridge did not exit within ${TIMEOUT_MS}ms`);
child.kill("SIGKILL");
}, TIMEOUT_MS);

child.stdin.write(requests.map((r) => JSON.stringify(r)).join("\n") + "\n");
child.stdin.end();

child.on("close", (code) => {
clearTimeout(killTimer);
const fail = (msg) => {
console.error(`FAIL: ${msg}`);
console.error(`--- subject: ${argv.join(" ")}`);
console.error(`--- exit code: ${code}`);
console.error(`--- stdout ---\n${stdout}`);
console.error(`--- stderr ---\n${stderr}`);
process.exit(1);
};

if (code !== 0) return fail(`expected exit 0, got ${code}`);

const responses = [];
for (const line of stdout.split("\n")) {
const s = line.trim();
if (!s.startsWith("{")) continue;
try {
responses.push(JSON.parse(s));
} catch {
return fail(`non-JSON line on stdout: ${s.slice(0, 200)}`);
}
}

const init = responses.find((r) => r.id === 1);
if (!init?.result?.serverInfo?.name) {
return fail("no initialize response with result.serverInfo.name");
}
const tools = responses.find((r) => r.id === 2);
if (!Array.isArray(tools?.result?.tools) || tools.result.tools.length === 0) {
return fail("no tools/list response with a non-empty result.tools");
}

console.log(
`OK: ${argv[0]} boot smoke — serverInfo.name=${init.result.serverInfo.name}, ` +
`tools=${tools.result.tools.length}, exit=0`,
);
});
Loading