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 <noreply@anthropic.com>
This commit is contained in:
@@ -21,7 +21,7 @@ function postJsonRpc(
|
|||||||
const parsed = new URL(url);
|
const parsed = new URL(url);
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json, text/event-stream',
|
||||||
};
|
};
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
headers['mcp-session-id'] = 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.
|
* STDIO-to-Streamable-HTTP MCP bridge.
|
||||||
*
|
*
|
||||||
@@ -126,10 +145,16 @@ export async function runMcpBridge(opts: McpBridgeOptions): Promise<void> {
|
|||||||
|
|
||||||
if (result.status >= 400) {
|
if (result.status >= 400) {
|
||||||
stderr.write(`MCP bridge error: HTTP ${result.status}: ${result.body}\n`);
|
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) {
|
} catch (err) {
|
||||||
stderr.write(`MCP bridge error: ${err instanceof Error ? err.message : String(err)}\n`);
|
stderr.write(`MCP bridge error: ${err instanceof Error ? err.message : String(err)}\n`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,8 +94,14 @@ beforeAll(async () => {
|
|||||||
responseBody = JSON.stringify({ jsonrpc: '2.0', id: rpc.id, error: { code: -32601, message: 'Method not found' } });
|
responseBody = JSON.stringify({ jsonrpc: '2.0', id: rpc.id, error: { code: -32601, message: 'Method not found' } });
|
||||||
}
|
}
|
||||||
|
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
// Respond in SSE format for /projects/sse-project/mcp
|
||||||
res.end(responseBody);
|
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 {
|
} catch {
|
||||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
||||||
@@ -389,6 +395,30 @@ describe('MCP STDIO Bridge', () => {
|
|||||||
expect(lines).toHaveLength(1);
|
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 () => {
|
it('URL-encodes project name', async () => {
|
||||||
recorded.length = 0;
|
recorded.length = 0;
|
||||||
const stdin = new Readable({ read() {} });
|
const stdin = new Readable({ read() {} });
|
||||||
|
|||||||
Reference in New Issue
Block a user