From dc4162c0fb643ded6e9cb72e415f6871c56be0f5 Mon Sep 17 00:00:00 2001 From: Guillaume ARM Date: Thu, 11 Jun 2026 03:03:41 +0200 Subject: [PATCH] test(mcp): isolate bridge integration ports --- Justfile | 5 ++- docs/README.md | 1 + tools/mcp-bridge/test-integration/harness.ts | 36 +++++++++++------ .../lua/multi-echo-client.lua | 3 +- .../test-integration/lua/silent-client.lua | 5 ++- .../test-integration/probe-empty.test.ts | 2 +- .../test-integration/probe-happy.test.ts | 4 +- .../test-integration/probe-multi.test.ts | 4 +- .../probe-real-program.test.ts | 4 +- .../test-integration/probe-silent.test.ts | 4 +- .../probe-two-real-programs.test.ts | 40 +++++++++++++++++++ 11 files changed, 83 insertions(+), 25 deletions(-) create mode 100644 tools/mcp-bridge/test-integration/probe-two-real-programs.test.ts diff --git a/Justfile b/Justfile index 9cab1af..b7788bf 100644 --- a/Justfile +++ b/Justfile @@ -433,8 +433,11 @@ 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. +e2e: npm-test-integration + # Run integration and harness tests that are too broad for unit test targets. -test-integration: npm-test-integration test-timeout +test-integration: e2e test-timeout # Run CraftOS-PC headless integration tests. Pass `--pretty` for grouped output. [positional-arguments] diff --git a/docs/README.md b/docs/README.md index 9a07593..968be7f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,6 +9,7 @@ Start here when looking up ComputerCraft-related APIs, CraftOS-PC behavior, peri - [`advanced_peripherals_glossary.md`](advanced_peripherals_glossary.md) - Advanced Peripherals 0.7 guides, peripherals, turtles, integrations, and changelog pages. - [`create_cc_tweaked_glossary.md`](create_cc_tweaked_glossary.md) - Create CC:Tweaked integration pages. - [`opencode_server_guide.md`](opencode_server_guide.md) - Running `opencode serve` for the TrapOS `ai` client. +- [`ingame-trapos-ai-mcp-guide.md`](ingame-trapos-ai-mcp-guide.md) - Concise in-game checklist for installing TrapOS, connecting `ai`, and linking MCP. - [`opencode_api.md`](opencode_api.md) - Minimal opencode HTTP API reference used by TrapOS. - [`public-ports.md`](public-ports.md) - Public production TCP port allocation for TrapOS services. - [`adrs/`](adrs/) - Lightweight Architecture Decision Records for this repository. diff --git a/tools/mcp-bridge/test-integration/harness.ts b/tools/mcp-bridge/test-integration/harness.ts index d9b6da8..cf08617 100644 --- a/tools/mcp-bridge/test-integration/harness.ts +++ b/tools/mcp-bridge/test-integration/harness.ts @@ -12,22 +12,22 @@ const HERE = dirname(fileURLToPath(import.meta.url)); const LUA_DIR = join(HERE, "lua"); const REPO_ROOT = join(HERE, "../../.."); -export const MCP_PORT = 2000; -export const LINK_PORT = 2001; -export const MCP_URL = `http://127.0.0.1:${MCP_PORT}`; - export type Bridge = { registry: LinkRegistry; + mcpUrl: string; + linkUrl: string; close: () => Promise; }; export async function startBridge(probeTimeoutMs = 500): Promise { const registry = new LinkRegistry(); - const mcpServer = startMcpServer({ host: "127.0.0.1", port: MCP_PORT, probeTimeoutMs, registry }); - const linkServer = startLinkServer({ host: "127.0.0.1", port: LINK_PORT, registry }); + 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), }; } @@ -43,14 +43,14 @@ export async function waitForComputers(registry: LinkRegistry, count: number, ti throw new Error(`waitForComputers timed out (expected ${count}, got ${registry.count()})`); } -export async function callProbeComputers(): Promise { +export async function callProbeComputers(mcpUrl: string): Promise { const body = { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "probe-computers", arguments: {} }, }; - const response = await fetch(MCP_URL, { + const response = await fetch(mcpUrl, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), @@ -78,7 +78,7 @@ export type CraftosHandle = { export function startCraftos( luaName: string, - opts: { mountRepo?: boolean; shellArgs?: string[]; timeoutMs?: number } = {}, + opts: { computerId?: number; computerLabel?: string; mountRepo?: boolean; shellArgs?: string[]; timeoutMs?: number } = {}, ): CraftosHandle { const timeoutMs = opts.timeoutMs ?? 15_000; const controller = new AbortController(); @@ -88,6 +88,9 @@ export function startCraftos( 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) { args.push( "--mount-ro", `/trapos=${REPO_ROOT}`, @@ -98,7 +101,7 @@ export function startCraftos( if (process.platform === "darwin") { args.push("--rom", "/Applications/CraftOS-PC.app/Contents/Resources"); } - args.push("--exec", buildExecCode(luaName, opts.shellArgs ?? [])); + args.push("--exec", buildExecCode(luaName, opts.shellArgs ?? [], opts.computerLabel)); const chunks: Buffer[] = []; const child = spawn("craftos", args, { signal: controller.signal }); @@ -125,10 +128,11 @@ export function formatFailure(message: string, craftosOutput: string): string { return lines.join("\n"); } -function buildExecCode(luaName: string, shellArgs: string[]): string { +function buildExecCode(luaName: string, shellArgs: string[], computerLabel?: string): string { const programPath = luaName.startsWith("/") ? luaName : `/staging/${luaName}`; const parts = [luaQuote(programPath), ...shellArgs.map(luaQuote)]; - return `shell.run(${parts.join(", ")})`; + const setup = computerLabel === undefined ? "" : `os.setComputerLabel(${luaQuote(computerLabel)}); `; + return `${setup}shell.run(${parts.join(", ")})`; } function luaQuote(value: string): string { @@ -147,6 +151,14 @@ function waitForListening(server: Server | WebSocketServer): Promise { }); } +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 { for (const client of linkServer.clients) { client.terminate(); diff --git a/tools/mcp-bridge/test-integration/lua/multi-echo-client.lua b/tools/mcp-bridge/test-integration/lua/multi-echo-client.lua index fa1b7c7..8dc3124 100644 --- a/tools/mcp-bridge/test-integration/lua/multi-echo-client.lua +++ b/tools/mcp-bridge/test-integration/lua/multi-echo-client.lua @@ -1,6 +1,7 @@ -- Integration-test helper: open two websocket connections in parallel, each -- presenting itself as a distinct logical computer. Used by probe-multi. -local urlBase = "ws://127.0.0.1:2001"; +local args = {...}; +local urlBase = args[1] or "ws://127.0.0.1:2001"; local function connect(id, label, duration) local url = urlBase .. "/?id=" .. id; diff --git a/tools/mcp-bridge/test-integration/lua/silent-client.lua b/tools/mcp-bridge/test-integration/lua/silent-client.lua index 7b284e7..9f4f52f 100644 --- a/tools/mcp-bridge/test-integration/lua/silent-client.lua +++ b/tools/mcp-bridge/test-integration/lua/silent-client.lua @@ -5,9 +5,10 @@ local args = {...}; local id = tonumber(args[1]) or 99; local label = args[2] or "silent"; -local duration = tonumber(args[3]) or 5; +local urlBase = args[3] or "ws://127.0.0.1:2001"; +local duration = tonumber(args[4]) or 5; -local url = "ws://127.0.0.1:2001/?id=" .. id; +local url = urlBase .. "/?id=" .. id; local ws, err = http.websocket(url); if not ws then print("websocket failed: " .. tostring(err)); diff --git a/tools/mcp-bridge/test-integration/probe-empty.test.ts b/tools/mcp-bridge/test-integration/probe-empty.test.ts index 0020751..196cae2 100644 --- a/tools/mcp-bridge/test-integration/probe-empty.test.ts +++ b/tools/mcp-bridge/test-integration/probe-empty.test.ts @@ -5,7 +5,7 @@ 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(), "No computers connected."); + assert.equal(await callProbeComputers(bridge.mcpUrl), "No computers connected."); } finally { await bridge.close(); } diff --git a/tools/mcp-bridge/test-integration/probe-happy.test.ts b/tools/mcp-bridge/test-integration/probe-happy.test.ts index da9afbe..759ae88 100644 --- a/tools/mcp-bridge/test-integration/probe-happy.test.ts +++ b/tools/mcp-bridge/test-integration/probe-happy.test.ts @@ -4,10 +4,10 @@ import { callProbeComputers, formatFailure, startBridge, startCraftos, waitForCo test("probe-computers aggregates a single CraftOS echo computer", async () => { const bridge = await startBridge(); - const craftos = startCraftos("echo-client.lua", { shellArgs: ["1", "echo-1", "ws://127.0.0.1:2001", "8"] }); + 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(); + const text = await callProbeComputers(bridge.mcpUrl); assert.equal(text, "pong from 1 (Label: echo-1)"); } catch (error) { craftos.abort(); diff --git a/tools/mcp-bridge/test-integration/probe-multi.test.ts b/tools/mcp-bridge/test-integration/probe-multi.test.ts index e1d4045..e6d7c65 100644 --- a/tools/mcp-bridge/test-integration/probe-multi.test.ts +++ b/tools/mcp-bridge/test-integration/probe-multi.test.ts @@ -4,10 +4,10 @@ import { callProbeComputers, formatFailure, startBridge, startCraftos, waitForCo test("probe-computers aggregates two CraftOS computers from the same headless process", async () => { const bridge = await startBridge(); - const craftos = startCraftos("multi-echo-client.lua", { timeoutMs: 15_000 }); + 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(); + const text = await callProbeComputers(bridge.mcpUrl); const lines = text.split("\n").sort(); assert.deepEqual(lines, [ "pong from 1001 (Label: echo-A)", diff --git a/tools/mcp-bridge/test-integration/probe-real-program.test.ts b/tools/mcp-bridge/test-integration/probe-real-program.test.ts index 1175e2f..68f2253 100644 --- a/tools/mcp-bridge/test-integration/probe-real-program.test.ts +++ b/tools/mcp-bridge/test-integration/probe-real-program.test.ts @@ -6,12 +6,12 @@ test("probe-computers talks to the real TrapOS mcp-computer program", async () = const bridge = await startBridge(); const craftos = startCraftos("/programs/mcp-computer.lua", { mountRepo: true, - shellArgs: ["ws://127.0.0.1:2001"], + shellArgs: [bridge.linkUrl], timeoutMs: 15_000, }); try { await waitForComputers(bridge.registry, 1, 12_000); - const text = await callProbeComputers(); + const text = await callProbeComputers(bridge.mcpUrl); assert.equal(text, "pong from 0 (Label: null)"); } catch (error) { craftos.abort(); diff --git a/tools/mcp-bridge/test-integration/probe-silent.test.ts b/tools/mcp-bridge/test-integration/probe-silent.test.ts index 98ed395..76f643d 100644 --- a/tools/mcp-bridge/test-integration/probe-silent.test.ts +++ b/tools/mcp-bridge/test-integration/probe-silent.test.ts @@ -4,10 +4,10 @@ import { callProbeComputers, formatFailure, startBridge, startCraftos, waitForCo 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", "8"] }); + 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(); + const text = await callProbeComputers(bridge.mcpUrl); assert.equal(text, "timeout from 7 (Label: silent-7)"); } catch (error) { craftos.abort(); diff --git a/tools/mcp-bridge/test-integration/probe-two-real-programs.test.ts b/tools/mcp-bridge/test-integration/probe-two-real-programs.test.ts new file mode 100644 index 0000000..55f5c5d --- /dev/null +++ b/tools/mcp-bridge/test-integration/probe-two-real-programs.test.ts @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { callProbeComputers, formatFailure, startBridge, startCraftos, waitForComputers } from "./harness.js"; + +test("probe-computers aggregates two real TrapOS mcp-computer programs", async () => { + const bridge = await startBridge(); + const craftosA = startCraftos("/programs/mcp-computer.lua", { + computerId: 101, + computerLabel: "trap-A", + mountRepo: true, + shellArgs: [bridge.linkUrl], + timeoutMs: 15_000, + }); + const craftosB = startCraftos("/programs/mcp-computer.lua", { + computerId: 202, + computerLabel: "trap-B", + mountRepo: true, + shellArgs: [bridge.linkUrl], + timeoutMs: 15_000, + }); + + try { + await waitForComputers(bridge.registry, 2, 12_000); + const lines = (await callProbeComputers(bridge.mcpUrl)).split("\n"); + assert.equal(lines.length, 2); + assert(lines.some((line) => /^pong from \d+ \(Label: trap-A\)$/.test(line))); + assert(lines.some((line) => /^pong from \d+ \(Label: trap-B\)$/.test(line))); + } catch (error) { + craftosA.abort(); + craftosB.abort(); + const [resultA, resultB] = await Promise.all([craftosA.done, craftosB.done]); + const output = ["--- craftos A ---", resultA.output, "--- craftos B ---", resultB.output].join("\n"); + throw new Error(formatFailure(error instanceof Error ? error.message : String(error), output), { cause: error }); + } finally { + craftosA.abort(); + craftosB.abort(); + await Promise.all([craftosA.done, craftosB.done]); + await bridge.close(); + } +});