From 44233419e4ae1181b17ba0df919f944ca1ebb5ff Mon Sep 17 00:00:00 2001 From: Eric Date: Thu, 28 May 2026 01:24:40 -0700 Subject: [PATCH] [eric] ci: add verify-mcp-bundles that spawns each vendored bundle (notion, reddit-buddy, softeria-ms365) under the bundled node and asserts an mcp initialize round-trip so a corrupted esbuild output cant ship green --- scripts/ci/verify-mcp-bundles.js | 131 +++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 scripts/ci/verify-mcp-bundles.js diff --git a/scripts/ci/verify-mcp-bundles.js b/scripts/ci/verify-mcp-bundles.js new file mode 100644 index 00000000..e3d8e6f0 --- /dev/null +++ b/scripts/ci/verify-mcp-bundles.js @@ -0,0 +1,131 @@ +#!/usr/bin/env node +// Asserts each vendored MCP bundle in the packaged artifact actually starts and answers an MCP initialize handshake. A corrupted esbuild output or a node-version mismatch leaves MCP silently broken while every boot/health check stays green; this gate fails the build red instead. + +'use strict'; +const fs = require('fs'); +const path = require('path'); +const { spawn } = require('child_process'); +const h = require('./lib/app-harness'); + +function parseArgs(argv) { + const out = { app: null }; + for (let i = 0; i < argv.length; i++) if (argv[i] === '--app') out.app = argv[++i]; + return out; +} + +function bundlesRoot(appExe) { + if (process.platform === 'darwin') { + const i = appExe.indexOf('.app'); + const appRoot = i === -1 ? appExe : appExe.slice(0, i + 4); + return path.join(appRoot, 'Contents', 'Resources', 'backend', 'mcp-bundles'); + } + return path.join(path.dirname(appExe), 'resources', 'backend', 'mcp-bundles'); +} + +function bundledNode(appExe) { + if (process.platform === 'win32') return path.join(path.dirname(appExe), 'resources', 'node', 'x64', 'node.exe'); + if (process.platform === 'darwin') { + const i = appExe.indexOf('.app'); + const appRoot = i === -1 ? appExe : appExe.slice(0, i + 4); + return path.join(appRoot, 'Contents', 'Resources', 'node', process.arch, 'node'); + } + return path.join(path.dirname(appExe), 'resources', 'node', process.arch, 'node'); +} + +// Locate the JS entry for each bundle. Single-file bundles ship as .js; +// directory bundles (multi-file) have package.json + dist/index.js. +function discoverEntries(root) { + const out = []; + let entries = []; + try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { return out; } + for (const e of entries) { + const full = path.join(root, e.name); + if (e.isFile() && e.name.endsWith('.js')) out.push({ name: e.name.replace(/\.js$/, ''), entry: full }); + else if (e.isDirectory()) { + const candidates = [ + path.join(full, 'dist', 'index.js'), + path.join(full, 'index.js'), + path.join(full, 'build', 'index.js'), + ]; + const hit = candidates.find((c) => { try { return fs.statSync(c).isFile(); } catch { return false; } }); + if (hit) out.push({ name: e.name, entry: hit }); + } + } + return out; +} + +// Drive one MCP initialize handshake over the bundle's stdio. A working server +// responds to {jsonrpc:"2.0",id:1,method:"initialize",...} with a result that +// includes a protocolVersion. We don't care about the version string, only +// that we got a non-error JSON-RPC response back. +function probeBundle(nodePath, entryPath, timeoutMs = 20000) { + return new Promise((resolve) => { + const child = spawn(nodePath, [entryPath], { stdio: ['pipe', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + let done = false; + const finish = (result) => { if (done) return; done = true; try { child.kill('SIGKILL'); } catch {} resolve(result); }; + child.stdout.on('data', (b) => { + stdout += b.toString('utf8'); + // MCP frames are newline-delimited JSON; look for the first parseable response with id=1. + for (const line of stdout.split(/\r?\n/)) { + if (!line.trim()) continue; + try { + const msg = JSON.parse(line); + if (msg.id === 1 && (msg.result || msg.error)) { + finish({ ok: !!msg.result, response: msg, stderr }); + return; + } + } catch { /* partial frame */ } + } + }); + child.stderr.on('data', (b) => { stderr += b.toString('utf8'); }); + child.on('error', (e) => finish({ ok: false, error: String(e), stderr })); + child.on('exit', (code) => { if (!done) finish({ ok: false, exitCode: code, stderr }); }); + setTimeout(() => finish({ ok: false, error: 'timeout', stderr }), timeoutMs); + // Send the initialize request. clientInfo is required by the spec. + const req = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'verify-mcp-bundles', version: '0.1' } } }); + try { child.stdin.write(req + '\n'); } catch (e) { finish({ ok: false, error: String(e), stderr }); } + }); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const appExe = h.packagedAppPath(args.app); + const root = bundlesRoot(appExe); + const node = bundledNode(appExe); + + process.stdout.write(`MCP bundles root: ${root}\n`); + process.stdout.write(`Bundled node: ${node}\n`); + if (!fs.existsSync(root)) { process.stderr.write(`\nMCP-BUNDLES FAIL: bundles dir missing at ${root}\n`); process.exit(1); } + if (!fs.existsSync(node)) { process.stderr.write(`\nMCP-BUNDLES FAIL: bundled node not at ${node}\n`); process.exit(1); } + + const entries = discoverEntries(root); + if (entries.length === 0) { process.stderr.write(`\nMCP-BUNDLES FAIL: no MCP bundle entries found under ${root}\n`); process.exit(1); } + process.stdout.write(`Discovered ${entries.length} bundle(s): ${entries.map((e) => e.name).join(', ')}\n\n`); + + const failures = []; + for (const e of entries) { + process.stdout.write(` probing ${e.name} ... `); + const r = await probeBundle(node, e.entry); + if (r.ok) process.stdout.write('ok\n'); + else { + process.stdout.write('FAIL\n'); + failures.push({ name: e.name, ...r }); + } + } + + if (failures.length) { + process.stderr.write(`\nMCP-BUNDLES FAIL: ${failures.length}/${entries.length} bundle(s) did not respond to initialize:\n`); + for (const f of failures) { + process.stderr.write(` - ${f.name}: ${f.error || `exit ${f.exitCode}`}\n`); + if (f.stderr) process.stderr.write(` stderr: ${f.stderr.trim().slice(0, 400)}\n`); + } + process.exit(1); + } + + process.stdout.write('\nMCP-BUNDLES PASS: every shipped bundle starts and answers initialize.\n'); + process.exit(0); +} + +main().catch((e) => { process.stderr.write(`\nMCP-BUNDLES FAIL: ${e && e.message || e}\n`); process.exit(1); });