51 lines
2.9 KiB
TypeScript
51 lines
2.9 KiB
TypeScript
// The MCP server as a client starts it: a spawned process, an `initialize` on stdin, a JSON-RPC answer as the very
|
|
// first stdout line within 10 s (a log line on stdout closes the client's connection: CONNECTION_CLOSED).
|
|
import { describe, expect, test } from 'bun:test';
|
|
import { spawn } from 'node:child_process';
|
|
import { readFileSync, statSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
|
|
const ROOT = join(import.meta.dir, '..');
|
|
const INIT = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'test', version: '1' } } });
|
|
|
|
export function handshake(cmd: string, args: string[], env: Record<string, string> = {}, ms = 10_000): Promise<{ first: string; stderr: string }> {
|
|
return new Promise((res, rej) => {
|
|
const p = spawn(cmd, args, { cwd: ROOT, env: { ...process.env, STOREBOUND_NO_NETWORK: '1', ...env }, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
let out = '', err = '';
|
|
const t = setTimeout(() => { p.kill(); rej(new Error(`no initialize answer in ${ms} ms; stdout: ${out.slice(0, 300)} stderr: ${err.slice(0, 300)}`)); }, ms);
|
|
p.stderr.on('data', d => { err += d; });
|
|
p.stdout.on('data', d => {
|
|
out += d;
|
|
const nl = out.indexOf('\n');
|
|
if (nl >= 0) { clearTimeout(t); p.kill(); res({ first: out.slice(0, nl), stderr: err }); }
|
|
});
|
|
p.on('error', e => { clearTimeout(t); rej(e); });
|
|
p.stdin.write(INIT + '\n');
|
|
});
|
|
}
|
|
|
|
describe('mcp start-up (spawned)', () => {
|
|
test('bun cli/index.ts mcp answers initialize first on stdout', async () => {
|
|
const { first } = await handshake(process.execPath, [join(ROOT, 'cli/index.ts'), 'mcp']);
|
|
const r = JSON.parse(first);
|
|
expect(r.id).toBe(1);
|
|
expect(r.result.serverInfo.name).toBe('storebound');
|
|
}, 15_000);
|
|
|
|
test('the plugin launcher (bin/storebound) is executable, POSIX sh, and completes the handshake', async () => {
|
|
const bin = join(ROOT, 'bin/storebound');
|
|
expect(readFileSync(bin, 'utf8').startsWith('#!/bin/sh\n')).toBe(true);
|
|
expect(statSync(bin).mode & 0o111).not.toBe(0);
|
|
const { first } = await handshake(bin, ['mcp']);
|
|
expect(JSON.parse(first).result.serverInfo.name).toBe('storebound');
|
|
}, 15_000);
|
|
|
|
test('.mcp.json never spawns a path that may not exist (falls back when the plugin root is missing)', async () => {
|
|
const m = JSON.parse(readFileSync(join(ROOT, '.mcp.json'), 'utf8')).mcpServers.storebound as { command: string; args: string[] };
|
|
expect(m.command).toBe('sh');
|
|
// a plugin root that is gone (an old cache folder): the script must still start a server, here from the repo's bin
|
|
const args = m.args.map(a => a.replaceAll('${CLAUDE_PLUGIN_ROOT:-}', '/nonexistent-plugin-root'));
|
|
const { first } = await handshake(m.command, args, { PATH: `${join(ROOT, 'bin')}:${process.env.PATH}` });
|
|
expect(JSON.parse(first).result.serverInfo.name).toBe('storebound');
|
|
}, 15_000);
|
|
});
|