diff --git a/server/ai/mcp/editorBridge.ts b/server/ai/mcp/editorBridge.ts index d6aafbdfa..9083214c9 100644 --- a/server/ai/mcp/editorBridge.ts +++ b/server/ai/mcp/editorBridge.ts @@ -4,10 +4,11 @@ * Browser-execution tools (insert HTML, apply CSS, set tokens, manage pages, * content CRUD, …) have no server implementation — their logic runs in the * editor app against the live store. To let an external MCP client use them, - * the editor holds a long-lived NDJSON stream open while mounted; this module - * keeps one bridge per user and workspace (the newest open instance wins) - * and lets the MCP server relay a browser tool call to the correct workspace - * before awaiting its result. + * the editor holds a long-lived newline-delimited JSON stream open while + * mounted. Its HTTP response is advertised as an event stream so reverse + * proxies preserve incremental delivery. This module keeps one bridge per user + * and workspace (the newest open instance wins) and lets the MCP server relay a + * browser tool call to the correct workspace before awaiting its result. * * Reuses the chat bridge machinery wholesale: `createBridge` issues the * `AiBrowserBridge` (whose `callBrowser` resolves when the editor POSTs back to diff --git a/server/ai/mcp/handlers/editorBridge.ts b/server/ai/mcp/handlers/editorBridge.ts index fbce8b74f..62e94fb6e 100644 --- a/server/ai/mcp/handlers/editorBridge.ts +++ b/server/ai/mcp/handlers/editorBridge.ts @@ -1,11 +1,13 @@ /** * Workspace bridge stream — `GET /admin/api/ai/editor-bridge?scope=…`. * - * The Site editor and Content workspace each open their own NDJSON stream so - * MCP browser tools are relayed to the workspace that owns the tool (see - * `../editorBridge.ts`). Authenticated by the admin session; each bridge is - * registered under the session user + scope, so it can only serve that user's - * own MCP connectors. Results flow back through the existing + * The Site editor and Content workspace each open their own newline-delimited + * JSON stream so MCP browser tools are relayed to the workspace that owns the + * tool (see `../editorBridge.ts`). The response uses the event-stream media type + * so reverse proxies flush each record instead of buffering this long-lived + * connection. Authenticated by the admin session; each bridge is registered + * under the session user + scope, so it can only serve that user's own MCP + * connectors. Results flow back through the existing * `POST /admin/api/ai/tool-result` endpoint. */ import { Type, safeParseValue } from '@core/utils/typeboxHelpers' @@ -81,8 +83,11 @@ async function handle(req: Request, db: DbClient): Promise { return new Response(stream, { status: 200, headers: { - 'Content-Type': 'application/x-ndjson', - 'Cache-Control': 'no-cache', + // Kamal Proxy flushes event streams incrementally, while generic NDJSON + // responses may be buffered until this long-lived bridge closes. The + // browser deliberately parses the payload as newline-delimited JSON. + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', 'X-Accel-Buffering': 'no', }, }) diff --git a/src/__tests__/agent/contentBridge.test.ts b/src/__tests__/agent/contentBridge.test.ts index ab2652253..54beef6ba 100644 --- a/src/__tests__/agent/contentBridge.test.ts +++ b/src/__tests__/agent/contentBridge.test.ts @@ -190,6 +190,7 @@ describe('runMcpWorkspaceBridgeConnection', () => { globalThis.fetch = (async (_input, init) => { requestCount += 1 if (requestCount === 1) { + expect(init?.headers).toEqual({ Accept: 'text/event-stream' }) const signal = init?.signal as AbortSignal expect(signal.aborted).toBe(false) connectionSignals.push(signal) @@ -212,6 +213,7 @@ describe('runMcpWorkspaceBridgeConnection', () => { } const signal = init?.signal as AbortSignal + expect(init?.headers).toEqual({ Accept: 'text/event-stream' }) expect(signal.aborted).toBe(false) connectionSignals.push(signal) return new Response(null, { status: 401 }) diff --git a/src/__tests__/ai/mcpEditorBridgeHandler.test.ts b/src/__tests__/ai/mcpEditorBridgeHandler.test.ts index 13730aa3d..0943085fd 100644 --- a/src/__tests__/ai/mcpEditorBridgeHandler.test.ts +++ b/src/__tests__/ai/mcpEditorBridgeHandler.test.ts @@ -58,7 +58,9 @@ describe('MCP editor bridge handler', () => { signal: siteCtrl.signal, }) expect(siteAllowed.status).toBe(200) - expect(siteAllowed.headers.get('content-type')).toBe('application/x-ndjson') + expect(siteAllowed.headers.get('content-type')).toBe('text/event-stream') + expect(siteAllowed.headers.get('cache-control')).toBe('no-cache, no-transform') + expect(siteAllowed.headers.get('x-accel-buffering')).toBe('no') siteCtrl.abort() const contentCtrl = new AbortController() @@ -67,7 +69,9 @@ describe('MCP editor bridge handler', () => { signal: contentCtrl.signal, }) expect(contentAllowed.status).toBe(200) - expect(contentAllowed.headers.get('content-type')).toBe('application/x-ndjson') + expect(contentAllowed.headers.get('content-type')).toBe('text/event-stream') + expect(contentAllowed.headers.get('cache-control')).toBe('no-cache, no-transform') + expect(contentAllowed.headers.get('x-accel-buffering')).toBe('no') contentCtrl.abort() }) }) diff --git a/src/admin/ai/useMcpWorkspaceBridge.ts b/src/admin/ai/useMcpWorkspaceBridge.ts index ebed2c81e..2aba02170 100644 --- a/src/admin/ai/useMcpWorkspaceBridge.ts +++ b/src/admin/ai/useMcpWorkspaceBridge.ts @@ -78,7 +78,9 @@ export async function runMcpWorkspaceBridgeConnection( const res = await fetch(`${MCP_BRIDGE_PATH}?scope=${scope}`, { method: 'GET', credentials: 'same-origin', - headers: { Accept: 'application/x-ndjson' }, + // The bridge body stays newline-delimited JSON, but the event-stream + // media type prevents reverse proxies from buffering the open response. + headers: { Accept: 'text/event-stream' }, signal, }) if (res.status === 401 || res.status === 403) return 'auth'