Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions server/ai/mcp/editorBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 12 additions & 7 deletions server/ai/mcp/handlers/editorBridge.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -81,8 +83,11 @@ async function handle(req: Request, db: DbClient): Promise<Response> {
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',
},
})
Expand Down
2 changes: 2 additions & 0 deletions src/__tests__/agent/contentBridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 })
Expand Down
8 changes: 6 additions & 2 deletions src/__tests__/ai/mcpEditorBridgeHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
})
})
4 changes: 3 additions & 1 deletion src/admin/ai/useMcpWorkspaceBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Loading