mirror of
https://github.com/aaronsb/obsidian-mcp-plugin.git
synced 2026-07-22 06:45:14 +00:00
Replace the JEST_WORKER_ID bootstrap guard with a bridge-owned MCP_BRIDGE_NO_AUTOSTART opt-out. JEST_WORKER_ID is a jest-worker artifact that isn't reliably set under --runInBand, so an in-band run could let bridge-self-heal's in-process require() fire main() and process.exit(1) — the same boot-trap class this fix exists to kill. The bridge now declares its own boot contract: autostart unless a caller explicitly opts out. bridge-self-heal sets the var before requiring; bridge-bootstrap strips it from spawned child env (in case a sibling test set it in the worker) so real launches still autostart. Test robustness (review nits): parse only a complete newline- terminated stdout line (a mid-line chunk split would throw on partial JSON and flake); route every terminal path through a `finish` helper that clears the timer and kills the child, so no failure mode orphans a process. Expand the guard comment to note the manifest launches `node server.js` (where require.main would be true) while Desktop's built-in-Node path is the one that breaks. Refs #243. Claude-Session: https://claude.ai/code/session_011yowKCMFCBp61DRBu5xQm4
107 lines
4.8 KiB
TypeScript
107 lines
4.8 KiB
TypeScript
/**
|
|
* Regression harness for the bridge bootstrap (0.11.34 boot bug).
|
|
*
|
|
* The .mcpb bridge (mcpb/server.js) is bundled as a single file and launched as
|
|
* a process. The unit tests (bridge-self-heal) `require()` it and drive
|
|
* `dispatch` directly — which by design skips `main()` — so they cannot catch a
|
|
* failure to *start*. That gap shipped: #243 gated startup on
|
|
* `require.main === module`, but Claude Desktop loads the bundle with its
|
|
* built-in Node via a loader that does NOT make server.js the main module, so
|
|
* main() never ran, stdin was never read, and `initialize` hung until Desktop's
|
|
* 60s timeout.
|
|
*
|
|
* This test actually spawns the bridge against a stub HTTP server and asserts it
|
|
* answers `initialize` — in BOTH launch shapes:
|
|
* 1. `node server.js` (require.main === module)
|
|
* 2. `node -e "require('server.js')"` (require.main !== module — Desktop-like)
|
|
* Mode 2 is the one that regressed.
|
|
*/
|
|
import { spawn } from 'node:child_process';
|
|
import http from 'node:http';
|
|
import path from 'node:path';
|
|
import type { AddressInfo } from 'node:net';
|
|
|
|
const SERVER = path.resolve(__dirname, '../mcpb/server.js');
|
|
const INIT = JSON.stringify({
|
|
jsonrpc: '2.0', id: 0, method: 'initialize',
|
|
params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 't', version: '0' } },
|
|
});
|
|
|
|
// Minimal MCP HTTP stub: answers initialize with a session id + result, drains
|
|
// the GET notify stream, and 200s everything else. Enough for the bridge to
|
|
// complete a handshake and emit the initialize result to stdout.
|
|
function startStub(): Promise<{ url: string; close: () => Promise<void> }> {
|
|
return new Promise((resolve) => {
|
|
const server = http.createServer((req, res) => {
|
|
if (req.method === 'GET' || req.method === 'DELETE') { res.writeHead(200).end(); return; }
|
|
let body = '';
|
|
req.on('data', (c) => { body += c; });
|
|
req.on('end', () => {
|
|
const msg = JSON.parse(body || '{}');
|
|
if (msg.method === 'initialize') {
|
|
res.writeHead(200, { 'Content-Type': 'application/json', 'mcp-session-id': 'stub-session' });
|
|
res.end(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: { serverInfo: { name: 'stub' } } }));
|
|
} else {
|
|
res.writeHead(202).end();
|
|
}
|
|
});
|
|
});
|
|
server.listen(0, '127.0.0.1', () => {
|
|
const port = (server.address() as AddressInfo).port;
|
|
resolve({
|
|
url: `http://127.0.0.1:${port}/mcp`,
|
|
close: () => new Promise((r) => server.close(() => r())),
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
// Spawn the bridge with the given node args, feed it `initialize`, and resolve
|
|
// with the first JSON object it writes to stdout (the initialize result).
|
|
function bootAndInitialize(nodeArgs: string[], mcpUrl: string): Promise<Record<string, unknown>> {
|
|
return new Promise((resolve, reject) => {
|
|
// The child IS a real bridge launch, so it must autostart. Strip the
|
|
// opt-out in case a sibling test set it in this worker's process.env.
|
|
const env: NodeJS.ProcessEnv = { ...process.env, MCP_URL: mcpUrl, MCP_API_KEY: 'k' };
|
|
delete env.MCP_BRIDGE_NO_AUTOSTART;
|
|
|
|
const child = spawn(process.execPath, nodeArgs, { env, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
let out = '';
|
|
let err = '';
|
|
const finish = (fn: () => void) => { clearTimeout(timer); child.kill(); fn(); };
|
|
const timer = setTimeout(() => finish(() => reject(new Error(`no initialize response (bridge hung). stderr: ${err}`))), 8000);
|
|
child.stderr.on('data', (c) => { err += c; });
|
|
child.stdout.on('data', (c) => {
|
|
out += c;
|
|
// Only parse a complete, newline-terminated line — a chunk split mid-line
|
|
// would otherwise throw on partial JSON and flake the test.
|
|
const nl = out.indexOf('\n');
|
|
if (nl === -1) return;
|
|
const line = out.slice(0, nl);
|
|
finish(() => {
|
|
try { resolve(JSON.parse(line)); } catch (e) { reject(e as Error); }
|
|
});
|
|
});
|
|
child.on('error', (e) => finish(() => reject(e)));
|
|
child.stdin.write(INIT + '\n');
|
|
});
|
|
}
|
|
|
|
describe('mcpb bridge bootstrap — starts under both launch shapes (0.11.34 boot bug)', () => {
|
|
let stub: { url: string; close: () => Promise<void> };
|
|
beforeAll(async () => { stub = await startStub(); });
|
|
afterAll(async () => { await stub.close(); });
|
|
|
|
test('launched as `node server.js` (require.main === module) answers initialize', async () => {
|
|
const res = await bootAndInitialize([SERVER], stub.url);
|
|
expect(res.id).toBe(0);
|
|
expect((res as { result?: unknown }).result).toBeDefined();
|
|
}, 12000);
|
|
|
|
test('launched as a non-main require (Desktop-style loader) answers initialize', async () => {
|
|
const requireExpr = `require(${JSON.stringify(SERVER)})`;
|
|
const res = await bootAndInitialize(['-e', requireExpr], stub.url);
|
|
expect(res.id).toBe(0);
|
|
expect((res as { result?: unknown }).result).toBeDefined();
|
|
}, 12000);
|
|
});
|