From fdafe87a77efdde18408396b3ff07ae2bea0f8d1 Mon Sep 17 00:00:00 2001 From: Michal Date: Tue, 24 Feb 2026 10:17:45 +0000 Subject: [PATCH] fix: handle SSE responses in MCP bridge and add Commander-level tests The bridge now parses SSE text/event-stream responses (extracting data: lines) in addition to plain JSON. Also sends correct Accept header per MCP streamable HTTP spec. Added tests for SSE handling and command option parsing (-p/--project). Co-Authored-By: Claude Opus 4.6 --- src/cli/src/commands/mcp.ts | 31 ++++++++++++++++++++++++--- src/cli/tests/commands/mcp.test.ts | 34 ++++++++++++++++++++++++++++-- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/src/cli/src/commands/mcp.ts b/src/cli/src/commands/mcp.ts index d3d21b0..0a883ee 100644 --- a/src/cli/src/commands/mcp.ts +++ b/src/cli/src/commands/mcp.ts @@ -21,7 +21,7 @@ function postJsonRpc( const parsed = new URL(url); const headers: Record = { 'Content-Type': 'application/json', - 'Accept': 'application/json', + 'Accept': 'application/json, text/event-stream', }; if (sessionId) { headers['mcp-session-id'] = sessionId; @@ -95,6 +95,25 @@ function sendDelete( }); } +/** + * Extract JSON-RPC messages from an HTTP response body. + * Handles both plain JSON and SSE (text/event-stream) formats. + */ +function extractJsonRpcMessages(contentType: string | undefined, body: string): string[] { + if (contentType?.includes('text/event-stream')) { + // Parse SSE: extract data: lines + const messages: string[] = []; + for (const line of body.split('\n')) { + if (line.startsWith('data: ')) { + messages.push(line.slice(6)); + } + } + return messages; + } + // Plain JSON response + return [body]; +} + /** * STDIO-to-Streamable-HTTP MCP bridge. * @@ -126,10 +145,16 @@ export async function runMcpBridge(opts: McpBridgeOptions): Promise { if (result.status >= 400) { stderr.write(`MCP bridge error: HTTP ${result.status}: ${result.body}\n`); - // Still forward the response body — it may contain a JSON-RPC error } - stdout.write(result.body + '\n'); + // Handle both plain JSON and SSE responses + const messages = extractJsonRpcMessages(result.headers['content-type'], result.body); + for (const msg of messages) { + const trimmedMsg = msg.trim(); + if (trimmedMsg) { + stdout.write(trimmedMsg + '\n'); + } + } } catch (err) { stderr.write(`MCP bridge error: ${err instanceof Error ? err.message : String(err)}\n`); } diff --git a/src/cli/tests/commands/mcp.test.ts b/src/cli/tests/commands/mcp.test.ts index b53f89b..3518845 100644 --- a/src/cli/tests/commands/mcp.test.ts +++ b/src/cli/tests/commands/mcp.test.ts @@ -94,8 +94,14 @@ beforeAll(async () => { 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); + // Respond in SSE format for /projects/sse-project/mcp + if (req.url?.includes('sse-project')) { + res.writeHead(200, { 'Content-Type': 'text/event-stream' }); + res.end(`event: message\ndata: ${responseBody}\n\n`); + } else { + 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' })); @@ -389,6 +395,30 @@ describe('MCP STDIO Bridge', () => { expect(lines).toHaveLength(1); }); + it('handles SSE (text/event-stream) responses', 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: 'sse-project', // triggers SSE response from mock server + mcplocalUrl: `http://localhost:${mockPort}`, + stdin, stdout, stderr: new Writable({ write(_, __, cb) { cb(); } }), + }); + + // Should extract JSON from SSE data: lines + const output = stdoutChunks.join('').trim(); + const parsed = JSON.parse(output); + expect(parsed.result.serverInfo.name).toBe('test-server'); + }); + it('URL-encodes project name', async () => { recorded.length = 0; const stdin = new Readable({ read() {} });