import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import http from 'node:http'; import { Readable, Writable } from 'node:stream'; import { runMcpBridge, createMcpCommand } from '../../src/commands/mcp.js'; // ---- Mock MCP server (simulates mcplocal project endpoint) ---- interface RecordedRequest { method: string; url: string; headers: http.IncomingHttpHeaders; body: string; } let mockServer: http.Server; let mockPort: number; const recorded: RecordedRequest[] = []; let sessionCounter = 0; function makeInitializeResponse(id: number | string) { return JSON.stringify({ jsonrpc: '2.0', id, result: { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'test-server', version: '1.0.0' }, }, }); } function makeToolsListResponse(id: number | string) { return JSON.stringify({ jsonrpc: '2.0', id, result: { tools: [ { name: 'grafana/query', description: 'Query Grafana', inputSchema: { type: 'object', properties: {} } }, ], }, }); } function makeToolCallResponse(id: number | string) { return JSON.stringify({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: 'tool result' }], }, }); } beforeAll(async () => { mockServer = http.createServer((req, res) => { const chunks: Buffer[] = []; req.on('data', (c: Buffer) => chunks.push(c)); req.on('end', () => { const body = Buffer.concat(chunks).toString('utf-8'); recorded.push({ method: req.method ?? '', url: req.url ?? '', headers: req.headers, body }); if (req.method === 'DELETE') { res.writeHead(200); res.end(); return; } if (req.method === 'POST' && req.url?.startsWith('/projects/')) { let sessionId = req.headers['mcp-session-id'] as string | undefined; // Assign session ID on first request if (!sessionId) { sessionCounter++; sessionId = `session-${sessionCounter}`; } res.setHeader('mcp-session-id', sessionId); // Parse JSON-RPC and respond based on method try { const rpc = JSON.parse(body) as { id: number | string; method: string }; let responseBody: string; switch (rpc.method) { case 'initialize': responseBody = makeInitializeResponse(rpc.id); break; case 'tools/list': responseBody = makeToolsListResponse(rpc.id); break; case 'tools/call': responseBody = makeToolCallResponse(rpc.id); break; default: responseBody = JSON.stringify({ jsonrpc: '2.0', id: rpc.id, error: { code: -32601, message: 'Method not found' } }); } res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(responseBody); } catch { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Invalid JSON' })); } return; } res.writeHead(404); res.end(); }); }); await new Promise((resolve) => { mockServer.listen(0, () => { const addr = mockServer.address(); if (addr && typeof addr === 'object') { mockPort = addr.port; } resolve(); }); }); }); afterAll(() => { mockServer.close(); }); // ---- Helper to run bridge with mock streams ---- function createMockStreams() { const stdoutChunks: string[] = []; const stderrChunks: string[] = []; const stdout = new Writable({ write(chunk: Buffer, _encoding, callback) { stdoutChunks.push(chunk.toString()); callback(); }, }); const stderr = new Writable({ write(chunk: Buffer, _encoding, callback) { stderrChunks.push(chunk.toString()); callback(); }, }); return { stdout, stderr, stdoutChunks, stderrChunks }; } function pushAndEnd(stdin: Readable, lines: string[]) { for (const line of lines) { stdin.push(line + '\n'); } stdin.push(null); // EOF } // ---- Tests ---- describe('MCP STDIO Bridge', () => { beforeAll(() => { recorded.length = 0; sessionCounter = 0; }); it('forwards initialize request and returns response', async () => { recorded.length = 0; const stdin = new Readable({ read() {} }); const { stdout, stdoutChunks } = createMockStreams(); const initMsg = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } }, }); pushAndEnd(stdin, [initMsg]); await runMcpBridge({ projectName: 'test-project', mcplocalUrl: `http://localhost:${mockPort}`, stdin, stdout, stderr: new Writable({ write(_, __, cb) { cb(); } }), }); // Verify request was made to correct URL expect(recorded.some((r) => r.url === '/projects/test-project/mcp' && r.method === 'POST')).toBe(true); // Verify response on stdout const output = stdoutChunks.join(''); const parsed = JSON.parse(output.trim()); expect(parsed.result.serverInfo.name).toBe('test-server'); expect(parsed.result.protocolVersion).toBe('2024-11-05'); }); it('sends session ID on subsequent requests', async () => { recorded.length = 0; const stdin = new Readable({ read() {} }); const { stdout, stdoutChunks } = createMockStreams(); const initMsg = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } }, }); const toolsListMsg = JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }); pushAndEnd(stdin, [initMsg, toolsListMsg]); await runMcpBridge({ projectName: 'test-project', mcplocalUrl: `http://localhost:${mockPort}`, stdin, stdout, stderr: new Writable({ write(_, __, cb) { cb(); } }), }); // First POST should NOT have mcp-session-id header const firstPost = recorded.find((r) => r.method === 'POST' && r.body.includes('initialize')); expect(firstPost).toBeDefined(); expect(firstPost!.headers['mcp-session-id']).toBeUndefined(); // Second POST SHOULD have mcp-session-id header const secondPost = recorded.find((r) => r.method === 'POST' && r.body.includes('tools/list')); expect(secondPost).toBeDefined(); expect(secondPost!.headers['mcp-session-id']).toMatch(/^session-/); // Verify tools/list response const lines = stdoutChunks.join('').trim().split('\n'); expect(lines.length).toBe(2); const toolsResponse = JSON.parse(lines[1]); expect(toolsResponse.result.tools[0].name).toBe('grafana/query'); }); it('forwards tools/call and returns result', async () => { recorded.length = 0; const stdin = new Readable({ read() {} }); const { stdout, stdoutChunks } = createMockStreams(); const initMsg = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } }, }); const callMsg = JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'grafana/query', arguments: { query: 'test' } }, }); pushAndEnd(stdin, [initMsg, callMsg]); await runMcpBridge({ projectName: 'test-project', mcplocalUrl: `http://localhost:${mockPort}`, stdin, stdout, stderr: new Writable({ write(_, __, cb) { cb(); } }), }); const lines = stdoutChunks.join('').trim().split('\n'); expect(lines.length).toBe(2); const callResponse = JSON.parse(lines[1]); expect(callResponse.result.content[0].text).toBe('tool result'); }); it('forwards Authorization header when token provided', async () => { recorded.length = 0; const stdin = new Readable({ read() {} }); const { stdout } = createMockStreams(); const initMsg = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } }, }); pushAndEnd(stdin, [initMsg]); await runMcpBridge({ projectName: 'test-project', mcplocalUrl: `http://localhost:${mockPort}`, token: 'my-secret-token', stdin, stdout, stderr: new Writable({ write(_, __, cb) { cb(); } }), }); const post = recorded.find((r) => r.method === 'POST'); expect(post).toBeDefined(); expect(post!.headers['authorization']).toBe('Bearer my-secret-token'); }); it('does not send Authorization header when no token', async () => { recorded.length = 0; const stdin = new Readable({ read() {} }); const { stdout } = createMockStreams(); const initMsg = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } }, }); pushAndEnd(stdin, [initMsg]); await runMcpBridge({ projectName: 'test-project', mcplocalUrl: `http://localhost:${mockPort}`, stdin, stdout, stderr: new Writable({ write(_, __, cb) { cb(); } }), }); const post = recorded.find((r) => r.method === 'POST'); expect(post).toBeDefined(); expect(post!.headers['authorization']).toBeUndefined(); }); it('sends DELETE to clean up session on stdin EOF', async () => { recorded.length = 0; const stdin = new Readable({ read() {} }); const { stdout } = createMockStreams(); const initMsg = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } }, }); pushAndEnd(stdin, [initMsg]); await runMcpBridge({ projectName: 'test-project', mcplocalUrl: `http://localhost:${mockPort}`, stdin, stdout, stderr: new Writable({ write(_, __, cb) { cb(); } }), }); // Should have a DELETE request for session cleanup const deleteReq = recorded.find((r) => r.method === 'DELETE'); expect(deleteReq).toBeDefined(); expect(deleteReq!.headers['mcp-session-id']).toMatch(/^session-/); }); it('does not send DELETE if no session was established', async () => { recorded.length = 0; const stdin = new Readable({ read() {} }); const { stdout } = createMockStreams(); // Push EOF immediately with no messages stdin.push(null); await runMcpBridge({ projectName: 'test-project', mcplocalUrl: `http://localhost:${mockPort}`, stdin, stdout, stderr: new Writable({ write(_, __, cb) { cb(); } }), }); expect(recorded.filter((r) => r.method === 'DELETE')).toHaveLength(0); }); it('writes errors to stderr, not stdout', async () => { recorded.length = 0; const stdin = new Readable({ read() {} }); const { stdout, stdoutChunks, stderr, stderrChunks } = createMockStreams(); // Send to a non-existent port to trigger connection error const badMsg = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }); pushAndEnd(stdin, [badMsg]); await runMcpBridge({ projectName: 'test-project', mcplocalUrl: 'http://localhost:1', // will fail to connect stdin, stdout, stderr, }); // Error should be on stderr expect(stderrChunks.join('')).toContain('MCP bridge error'); // stdout should be empty (no corrupted output) expect(stdoutChunks.join('')).toBe(''); }); it('skips blank lines in stdin', async () => { recorded.length = 0; const stdin = new Readable({ read() {} }); const { stdout, stdoutChunks } = createMockStreams(); const initMsg = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } }, }); pushAndEnd(stdin, ['', ' ', initMsg, '']); await runMcpBridge({ projectName: 'test-project', mcplocalUrl: `http://localhost:${mockPort}`, stdin, stdout, stderr: new Writable({ write(_, __, cb) { cb(); } }), }); // Only one POST (for the actual message) const posts = recorded.filter((r) => r.method === 'POST'); expect(posts).toHaveLength(1); // One response line const lines = stdoutChunks.join('').trim().split('\n'); expect(lines).toHaveLength(1); }); it('URL-encodes project name', async () => { recorded.length = 0; const stdin = new Readable({ read() {} }); const { stdout } = createMockStreams(); const { stderr } = createMockStreams(); const initMsg = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } }, }); pushAndEnd(stdin, [initMsg]); await runMcpBridge({ projectName: 'my project', mcplocalUrl: `http://localhost:${mockPort}`, stdin, stdout, stderr, }); const post = recorded.find((r) => r.method === 'POST'); expect(post?.url).toBe('/projects/my%20project/mcp'); }); }); describe('createMcpCommand', () => { it('accepts --project option directly', () => { const cmd = createMcpCommand({ getProject: () => undefined, configLoader: () => ({ mcplocalUrl: 'http://localhost:3200' }), credentialsLoader: () => null, }); const opt = cmd.options.find((o) => o.long === '--project'); expect(opt).toBeDefined(); expect(opt!.short).toBe('-p'); }); it('parses --project from command args', async () => { let capturedProject: string | undefined; const cmd = createMcpCommand({ getProject: () => undefined, configLoader: () => ({ mcplocalUrl: `http://localhost:${mockPort}` }), credentialsLoader: () => null, }); // Override the action to capture what project was parsed // We test by checking the option parsing works, not by running the full bridge const parsed = cmd.parse(['--project', 'test-proj'], { from: 'user' }); capturedProject = parsed.opts().project; expect(capturedProject).toBe('test-proj'); }); it('parses -p shorthand from command args', () => { const cmd = createMcpCommand({ getProject: () => undefined, configLoader: () => ({ mcplocalUrl: `http://localhost:${mockPort}` }), credentialsLoader: () => null, }); const parsed = cmd.parse(['-p', 'my-project'], { from: 'user' }); expect(parsed.opts().project).toBe('my-project'); }); });