Skip to content
Draft
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
3 changes: 3 additions & 0 deletions docs/e2e/feature-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ CONTENT-010 note: `content-seo-meta.e2e.ts` authors and publishes a Posts entry
| AI-006 | P2 | partial | Audit | Review AI usage rollups by user/scope/model/day | Usage generated by chat | AI Audit tab/dashboard widget | Audit tab renders model, scope, token, and daily rollups from a real streamed chat turn | empty usage, deleted labels, bad timezone, dashboard widget, mobile table |
| AI-007 | P2 | partial | Drivers | Use provider REST drivers without SDK lock-in | Mocked or local provider | AI runtime/provider drivers | Direct drivers map messages/tools, stream events, usage, model catalogues, context windows, and prices without provider SDKs | malformed SSE, rate limits, unknown pricing |
| AI-010 | P2 | ✅ | Tool Bridge | Keep the workspace MCP bridge stream readable in a real browser | Logged in | Open the Site editor, observe `/admin/api/ai/editor-bridge` | The bridge responds 200 as `text/event-stream` with no-transform caching and the client holds the stream open without parse errors | proxy reframing, buffered body, client teardown loop |
| AI-011 | P2 | ✅ | Tool Bridge | Learn from `get_context` whether the open workspace will actually service browser tools | Fresh login, personal access token | Open the Site editor, call `get_context` over MCP, wedge the tab, call again | `siteConnected` is true only while the tab answers a probe; a wedged tab reads as `unresponsive: ['site']` and recovers once it answers again; an unsupported `<link>` insert errors cleanly without breaking later calls | stale registration reported as connected, probe latency, reconnect after a stale result, step-up rotating the session |

AI-001 note: Ollama base-URL credential create/list/delete and offline auto-default guarding are automated in `ai.e2e.ts`; live provider model tests, credential update/masking, default reassignment, and mobile layout remain lower-level or future browser coverage.

Expand All @@ -299,6 +300,8 @@ AI-007 note: direct driver coverage is automated in `src/__tests__/ai/*Mapping.t

AI-002 note: Data-scope default selection, save feedback, reload persistence, clear feedback, and post-clear credential deletion are automated in `ai.e2e.ts` using an offline Ollama credential and deterministic fallback model list. Site/content/plugin scope permutations, no-credential empty state, stale credential recovery, live model catalogues, permission variants, and mobile layout remain future browser or lower-level coverage.

AI-011 note: `mcp-bridge-liveness.e2e.ts` mints its own personal access token through the step-up and token endpoints from inside the page, drives `get_context`, `site_list_documents`, `site_insert_html`, and `site_get_node_html` over the real MCP endpoint, and wedges the tab with a 7 s busy loop to prove the probe times out and recovers. The 60 s per-tool client deadline is unit-covered in `mcpBridgeRequestLoop.test.ts`; hosted-proxy stream drops remain manual.

## Plugins

| ID | Priority | Auto | Area | User Goal | Setup | Path | Expected Outcome | Watch For |
Expand Down
1 change: 1 addition & 0 deletions docs/e2e/feature-validation.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,4 @@ CONTENT-010 Entry SEO title and description reach the published <head> As a cont
ADMIN-009 Settings Escape dismissal after focus loss As an admin user, I want Escape to close the Settings modal even after I click non-focusable chrome so the dialog never traps me. Escape closes the Settings modal after a click on a heading or dead space moves focus to body. Focus on body; focus inside nested controls; backdrop click still works. Escape handling is document-level, not a React onKeyDown scoped to the dialog subtree. src/admin/modals/Settings/SettingsModal.tsx; tests/e2e/settings-escape.e2e.ts Regression spec for the fix shipped in PR #272; unit coverage drives fireEvent at chosen elements, only a browser reproduces real focus state. Happy: open Settings from the Site editor toolbar, click the first heading, press Escape, dialog hides. Passing in the repaired suite run 2026-08-30 0 None Rescued spec recovered from a prior verification session and added as tests/e2e/settings-escape.e2e.ts. Repair details in PR #461. 2026-08-30
ADMIN-010 AI workspace section navigation entry As an owner, I want the AI workspace reachable from the admin section navigation so I do not have to type the URL. A capability-gated AI link renders in the section navigation with href /admin/ai and routes on click. Users without AI capabilities see no entry; landing routes still include /admin/ai. Navigation uses the in-house admin router; the entry is gated on ai.providers.manage or ai.audit.read. src/admin/shared/AdminSectionNavigation/AdminSectionNavigation.tsx; tests/e2e/ai-nav.e2e.ts Regression spec for the fix shipped in PR #346. Happy: from Dashboard, the AI link is visible with the right href and clicking it lands on /admin/ai. Passing in the repaired suite run 2026-08-30 0 None Rescued spec recovered from a prior verification session and added as tests/e2e/ai-nav.e2e.ts. Repair details in PR #461. 2026-08-30
AI-010 Workspace MCP bridge stream stays readable As a connector user, I want the editor bridge stream to survive reverse proxies so connected agents keep working. The bridge responds 200 with content-type text/event-stream, cache-control no-cache, no-transform, and x-accel-buffering no, and the browser client holds the stream open without parse or stream errors. Buffering proxies reframing the body; client tearing the stream down as unreadable. The body stays newline-delimited JSON while the media type advertises an event stream so intermediaries flush incrementally. server/ai/mcp/editorBridge.ts; src/admin/ai/useMcpWorkspaceBridge.ts; tests/e2e/mcp-editor-bridge.e2e.ts Regression spec for the fix shipped in PR #282; unit coverage stubs fetch, only a browser exercises the real stream reader. Happy: open the Site editor, capture the bridge response, assert the streaming headers, and verify no mcp-workspace-bridge console errors for three seconds. Passing in the repaired suite run 2026-08-30 0 None Rescued spec recovered from a prior verification session and added as tests/e2e/mcp-editor-bridge.e2e.ts. Repair details in PR #461. 2026-08-30
AI-011 get_context reports live workspace connectivity As a connector user, I want get_context to tell me whether the open workspace will actually service browser tools, so a stuck tab is diagnosed instead of retried. siteConnected and contentConnected are true only when that tab answered a liveness probe within 2.5 s; a registered workspace that does not answer is listed under unresponsive; the flag recovers once the tab answers again; an unsupported <link> insert returns a validation error and later calls keep working. Registration kept by the idle lease after the tab stopped answering; busy main thread; stale probe result forcing a reconnect; step-up rotating the session used to mint the token. The probe is a real round-trip of the MCP_BRIDGE_PING_TOOL pseudo-tool answered by the client request loop itself; each relayed tool is bounded by a 60 s client deadline under the 90 s relay timeout. server/ai/mcp/editorBridge.ts; server/ai/mcp/tools/contextTool.ts; src/admin/ai/useMcpWorkspaceBridge.ts; tests/e2e/mcp-bridge-liveness.e2e.ts Runs on a fresh login because minting a personal access token requires a step-up. Hosted reverse-proxy stream drops are not reproducible locally. Happy: login, mint a token via step-up + access-tokens, open the Site editor, assert get_context connected; insert a bare <link> and assert the validation error and a following read; wedge the tab with a 7 s busy loop, assert unresponsive: [site]; poll until connected again. Passing in the verification run 2026-09-05 0 None Added for issue #490 alongside the client per-tool deadline and the get_context liveness probe. 2026-09-05
4 changes: 3 additions & 1 deletion docs/features/mcp-connectors.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ executeAiTool(...) / live editor bridge
| `auth.ts` | Resolves OAuth access tokens or personal tokens to `{ connectorId, userId, capabilities }`; returns a discovery-aware 401 otherwise. |
| `transports/http.ts` | Authenticated, Origin-validated `createMcpHandler` entry for MCP 2026-07-28 plus the stateless 2025 fallback. |
| `server.ts` / `registry.ts` | Low-level SDK server, TypeBox input schemas, catalog deduplication, and capability filtering. |
| `editorBridge.ts` | Per-user, per-scope live workspace bridge. The stream carries an **idle lease** (120s, re-armed by every relayed tool request) so an active batch is never cut mid-flight; only quiet streams recycle. The workspace's reconnect loop (`useMcpWorkspaceBridge`) reopens a recycled healthy stream immediately off the stream-end network event — deliberately timer-free, because hidden webviews (backgrounded browser tabs) clamp timers to minutes while network events still fire — and a tab becoming visible short-circuits any pending retry delay. |
| `editorBridge.ts` | Per-user, per-scope live workspace bridge. The stream carries an **idle lease** (120s, re-armed by every relayed tool request) so an active batch is never cut mid-flight; only quiet streams recycle. The workspace's reconnect loop (`useMcpWorkspaceBridge`) reopens a recycled healthy stream immediately off the stream-end network event — deliberately timer-free, because hidden webviews (backgrounded browser tabs) clamp timers to minutes while network events still fire — and a tab becoming visible short-circuits any pending retry delay. `pingEditorBridge` is the **liveness probe**: a real round-trip of the `MCP_BRIDGE_PING_TOOL` pseudo-tool (`@core/ai`) through the stream, answered by the client loop itself within 2.5 s, or the workspace reads as `unresponsive`. Registration alone is not connectivity — a tab whose loop is stuck, or whose connection died behind a proxy, keeps its entry until the idle lease recycles it. |
| `tools/publishTool.ts` | Explicit canonical full-site publish with MCP audit metadata. |
| `tools/uploadMediaTool.ts` | Server-resolved image upload (`media_upload`) — inline base64 or SSRF-guarded `sourceUrl` download, through the shared media pipeline. |

Expand All @@ -155,6 +155,8 @@ Server-resolved tools work without an editor open. They include content reads, `

Browser tools run against the connection owner's live workspace. Site structure, HTML/CSS, page lifecycle, design-token, content mutation, code-asset, and live-DOM tools route to the matching open Site or Content workspace. If that workspace is not open, the tool returns a scope-specific error while headless tools remain available. `tools/list` states that requirement in each browser tool's description, so a client learns the precondition when it picks the tool rather than from a failed call.

The workspace services relayed tools one at a time, in order. Each tool (plus its persistence step) is bounded by a 60 s client-side deadline (`useMcpWorkspaceBridge`), under the relay's 90 s per-call timeout (`server/ai/runtime/transport.ts`): a tool whose promise never settles returns an error naming it and the loop moves on, instead of blocking every later request until the tab reloads. `get_context` reports `siteConnected` / `contentConnected` from the liveness probe above — true only when that tab answered just now — and lists workspaces that opened a bridge but did not answer under `unresponsive`, which means "reload that tab", not "retry".

There is intentionally no headless page-tree mutation path. The open editor store is the single source of truth for draft edits; a second DB mutation path would desynchronize node state and overwrite the live document. Relayed edits need no post-tool save step: store mutations stream to the collab relay the moment they land, and every headless read (plus `site_publish`) flushes the relay server-side before it touches the DB — so a following read or publish always observes the edit. There is no client-side save flush, and no window in which the MCP caller can see stale data.

Writes remain drafts. Clients should finish and verify an edit sequence, then call `site_publish` once only when deployment was requested.
Expand Down
43 changes: 43 additions & 0 deletions server/ai/mcp/editorBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
* connector can only ever reach the open workspace of its OWN owner and a
* content tool can never be dispatched to the site editor (or vice versa).
*/
import { MCP_BRIDGE_PING_TOOL } from '@core/ai'
import type { AiBrowserBridge, AiStreamEvent } from '../runtime/types'
import { createBridge, encodeStreamEvent } from '../runtime'

Expand All @@ -29,6 +30,23 @@ interface EditorBridgeEntry {
}

export type EditorBridgeScope = 'site' | 'content'

/**
* What a liveness probe learned about a workspace bridge:
* - `live` — the tab answered just now; browser tools will be serviced.
* - `unresponsive` — a stream is registered but nothing answered in time.
* The tab's request loop is stuck or its connection died without the
* server noticing. Relayed tools will time out until that tab reloads.
* - `closed` — no stream is registered for this scope.
*/
export type EditorBridgeLiveness = 'live' | 'unresponsive' | 'closed'

/**
* How long a workspace gets to answer a probe. A healthy loop answers in
* milliseconds; a stuck one never does, and a caller orienting itself should
* not wait the full per-tool timeout to learn that.
*/
const BRIDGE_PING_TIMEOUT_MS = 2_500
/**
* How long a stream may sit with NO tool traffic before the server drops it.
* This is an IDLE lease: every relayed tool request re-arms it, so an active
Expand All @@ -54,6 +72,31 @@ export function hasEditorBridge(userId: string, scope: EditorBridgeScope): boole
return byUser.get(userId)?.has(scope) ?? false
}

/**
* Prove the registered workspace still answers. Registration only says a tab
* opened a stream: a tab whose request loop is stuck behind a tool that never
* settled, or whose connection died behind a proxy, keeps its entry until the
* idle lease recycles it, so `hasEditorBridge` alone reported "connected"
* while every relayed call timed out (#490). The probe is a real round-trip
* through the same stream and the same client loop every tool uses.
*/
export async function pingEditorBridge(
userId: string,
scope: EditorBridgeScope,
timeoutMs: number = BRIDGE_PING_TIMEOUT_MS,
): Promise<EditorBridgeLiveness> {
const bridge = getEditorBridgeForUser(userId, scope)
if (!bridge) return 'closed'
try {
const answer = await bridge.callBrowser(MCP_BRIDGE_PING_TOOL, {}, { timeoutMs })
return answer.ok ? 'live' : 'unresponsive'
} catch (_err) {
// A rejected probe (timed out, or the stream was torn down mid-probe) IS
// the finding; the registry says whether anything is left to talk to.
return hasEditorBridge(userId, scope) ? 'unresponsive' : 'closed'
}
}

/**
* Open the long-lived stream the editor consumes. The server pushes
* `toolRequest` events down it whenever an MCP browser tool is invoked for this
Expand Down
4 changes: 2 additions & 2 deletions server/ai/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ export function buildMcpServer(ctx: McpServerContext): Server {
}
bridge = browserScope === 'content'
? {
callBrowser: async (toolName, input) => {
callBrowser: async (toolName, input, options) => {
await authorizeMcpContentTool(
ctx.db,
ctx.userId,
Expand All @@ -177,7 +177,7 @@ export function buildMcpServer(ctx: McpServerContext): Server {
)
const current = getEditorBridgeForUser(ctx.userId, browserScope)
if (!current) throw new Error(NO_WORKSPACE_MESSAGE[browserScope])
return current.callBrowser(toolName, input)
return current.callBrowser(toolName, input, options)
},
}
: live
Expand Down
26 changes: 20 additions & 6 deletions server/ai/mcp/tools/contextTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@
* 2. which "everywhere" / post-type templates wrap pages (so the agent isn't
* surprised by a nav/footer it didn't author).
*
* Headless: editor presence comes from the bridge registry; templates + author
* come straight from the DB. No browser snapshot.
* Headless: editor presence is a live probe through each registered bridge
* (`pingEditorBridge`); templates + author come straight from the DB. No
* browser snapshot.
*/
import { Type } from '@core/utils/typeboxHelpers'
import type { CoreCapability } from '@core/capabilities'
import type { AiTool, ToolContext } from '../../runtime/types'
import { getDraftSite } from '../../../repositories/site'
import { hasEditorBridge } from '../editorBridge'
import { pingEditorBridge, type EditorBridgeScope } from '../editorBridge'

const CONTEXT_READ_CAPS: readonly CoreCapability[] = [
'site.read',
Expand Down Expand Up @@ -50,7 +51,7 @@ export const contextMcpTools: AiTool[] = [
{
name: 'get_context',
description:
'Orient yourself before editing: reports whether the Site editor and Content workspace are connected (browser tools require their matching workspace), and which templates wrap pages — an "everywhere" template applies a nav/footer/etc. to every page, so anything you author is in addition to it. Pass entryId to also learn whether a template wraps that specific page. Headless — no editor needed. Call this first if a browser tool returns an "open the workspace" error.',
'Orient yourself before editing: reports whether the Site editor and Content workspace are connected (browser tools require their matching workspace), and which templates wrap pages — an "everywhere" template applies a nav/footer/etc. to every page, so anything you author is in addition to it. `siteConnected` / `contentConnected` are live: true only when that open tab answered a probe just now. `unresponsive` lists workspaces whose tab opened a bridge but did not answer — that tab is stuck or its connection died, relayed tools to it will time out, and the fix is to reload that tab, not to retry. Pass entryId to also learn whether a template wraps that specific page. Headless — no editor needed. Call this first if a browser tool returns an "open the workspace" error or times out.',
scope: 'site',
execution: 'server',
inputSchema: GetContextInput,
Expand All @@ -75,11 +76,24 @@ export const contextMcpTools: AiTool[] = [
}))
.sort((a, b) => a.priority - b.priority)

// Probe both workspaces for real rather than reading the registry: an
// entry whose tab stopped answering read as "connected" for as long as
// the idle lease kept it, which sent callers chasing their own inputs
// while every relayed tool timed out (#490).
const [siteLiveness, contentLiveness] = await Promise.all([
pingEditorBridge(ctx.userId, 'site'),
pingEditorBridge(ctx.userId, 'content'),
])
const unresponsive: EditorBridgeScope[] = []
if (siteLiveness === 'unresponsive') unresponsive.push('site')
if (contentLiveness === 'unresponsive') unresponsive.push('content')

const result: Record<string, unknown> = {
site: site ? { name: site.name } : null,
editor: {
siteConnected: hasEditorBridge(ctx.userId, 'site'),
contentConnected: hasEditorBridge(ctx.userId, 'content'),
siteConnected: siteLiveness === 'live',
contentConnected: contentLiveness === 'live',
unresponsive,
},
templates,
}
Expand Down
4 changes: 2 additions & 2 deletions server/ai/runtime/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export function createBridge(
let destroyed = false

const bridge: AiBrowserBridge = {
callBrowser(toolName, input) {
callBrowser(toolName, input, options) {
if (destroyed) {
return Promise.reject(new Error('AI chat stream ended before tool result arrived.'))
}
Expand All @@ -108,7 +108,7 @@ export function createBridge(
}
const timer = setTimeout(
() => settle(new Error(`Browser tool "${toolName}" result timed out.`)),
timeoutMs,
options?.timeoutMs ?? timeoutMs,
)
const onAbort = () => settle(new Error('AI chat stream aborted before tool result arrived.'))
const cleanup = () => {
Expand Down
Loading
Loading