Skip to content

Commit e554b0e

Browse files
DavidBabinecclaude
andauthored
feat(ai): MCP connectors — expose CMS tools to external AI clients (CoreBunch#109)
* chore(ai): add MCP SDK + fetch-to-node, scope SDK isolation gate to drivers only Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(ai): MCP connector model, token hashing, store + migration 018 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(content): extract actor-agnostic page-tree service; plugin handler delegates Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(ai): page-tree MCP tools + capability-filtered MCP registry Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(ai): MCP bearer auth, capability-scoped server, Web-standard HTTP transport mounted at /_instatic/mcp Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(ai): MCP connector CRUD handlers (list/create/revoke) with privilege floor + audit Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(admin): MCP connectors tab — create/list/revoke with capability picker + client snippets Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(ai): document MCP connectors; scope SDK gate note; fix Button title gate Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(ai): deterministic end-to-end MCP flow (stateless initialize/list/call) over the real handler Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(ai): type the MCP e2e RPC responses (drop explicit any) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(admin): MCP connector dialog reuses the Role dialog capability picker The capabilities list collided with the AiPage two-column field grid. Switch the dialog to the SiteCreateDialog form layout + the UsersPage capability-picker styles + shared CAPABILITY_META, grouped Read/Write, for visual consistency with the role management dialog. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(admin): extract shared CapabilityPicker used by Role + MCP dialogs Move the role dialog's capability checklist into a shared, presentational `@admin/shared/CapabilityPicker` (+ co-located CAPABILITY_META). RoleDialog and the MCP connector dialog both consume it, so they stay visually consistent and the markup/CSS lives in one place. UsersPage.module.css keeps only the role identity grid; users/utils/capabilities.ts keeps only the role grouping. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(ai): MCP live editor bridge — expose the full tool catalog via an open editor Browser-execution editor tools (HTML/CSS authoring, design tokens, page lifecycle, content CRUD, code assets, live-DOM reads) have no server impl — they run in the editor app. This relays MCP calls for those tools to the connector owner's open editor and awaits the result, reusing the chat bridge machinery wholesale (createBridge / resolveBridgeToolResult / /tool-result). - server/ai/mcp/editorBridge.ts: per-user bridge registry + NDJSON stream. - GET /admin/api/ai/editor-bridge: the stream the editor holds open. - MCP registry now exposes the full deduped catalog; server.ts routes browser tools to the live bridge (clear 'open the editor' error when none connected). - src/admin/.../useEditorMcpBridge.ts: editor-side listener (mounted in SitePage) running executeAgentTool + postToolResult, with reconnect. - MCP picker expanded to all tool-backed editing capabilities. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(ai): headless read_styles tool + clearer MCP results + write-flush + sharper descriptions Addresses live-use friction: - read_styles: returns the design system as a CSS stylesheet (tokens + classes) read straight from the DB via the publisher emitters — headless, no editor, no snapshot. Replaces the snapshot-dependent list_tokens (excluded from MCP along with list_breakpoints, which silently returned nothing over MCP). - MCP success with no payload now reads as {ok:true}, not the literal 'null', so mutating tools (deleteNode) report unambiguous success. - The editor bridge flushes the draft save after a write tool, so a follow-up headless read sees the change instead of stale state (fixes the duplicatePage 'looked like it failed' race). - Sharpened read_page_tree/mutate_page_tree descriptions to steer the agent (headless-by-id vs editor HTML/CSS authoring; pointer to read_styles). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ai): forward MCP tool images (render_snapshot PNG was dropped) The MCP CallTool result emitted only text, discarding output.images — so render_snapshot's screenshot never reached the client. Forward images as MCP image content blocks. Adds an integration test relaying render_snapshot through the editor bridge and asserting the PNG arrives as an image block. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ai): delete headless read_page_tree/mutate_page_tree — one source of truth These MCP tools (added earlier this branch) edited the DB directly, creating a second copy of each page with identical node ids. Mixed with the open editor's browser tools they desynced, and the editor's autosave clobbered the headless write (data loss the agent reproduced). Superseded by the live editor bridge: ALL page editing now goes through the editor store (browser tools), persisted by the existing save-flush. Removed the two tools + their registry wiring; tests repointed to the headless content reads. treeService stays — the plugin RPC (cms.content.tree.*) still uses it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ai): editor MCP bridge self-heals — never permanently stops on auth blip The reconnect loop set stopped=true on a 401/403, so a transient auth failure during a dev-server restart silently killed the bridge for the whole session ('editor open but MCP can't see it'). Now it always retries (longer backoff for auth failures) and logs on connect, so the bridge survives restarts and brief session blips. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(ai): Tier 1 MCP tool improvements — list_breakpoints, read_styles summary, insertHtml created map - list_breakpoints: headless (reads the site shell) — fixes 'guessed desktop'; replaces the snapshot-based version that returned nothing over MCP. - read_styles: format:'summary' returns a compact class catalog (selector + referenced token vars, no declarations) for cheap scanning before editing. - insertHtml now returns 'created' — every inserted node { id, moduleId, classes } — so the agent can target nested nodes without re-dumping the tree. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(ai): Tier 3 — get_context tool (editor connected? which templates wrap pages?) Headless orientation tool: reports whether a live editor is connected (browser tools need it) and which everywhere/post-type templates wrap pages — the two things that silently confused live use. Reads bridge presence + template rows from the DB. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(ai): consistent snake_case + site_/content_ prefix for all agent/MCP tools Standardizes the whole agent toolset (shared by the built-in panel and MCP) on snake_case with a domain prefix, so the tool list self-groups and humans+agents can tell at a glance what's site vs content: insertHtml→site_insert_html, applyCss→site_apply_css, deleteNode→site_delete_node, addPage→site_add_page, render_snapshot→site_render_snapshot, read_styles→site_read_styles, create_document→content_create_document, list_collections→content_list_collections, … get_context stays unprefixed (cross-cutting). The old site/content list_documents name collision is resolved into distinct prefixed names. Scoped strictly to agent tool-name references (server defs, both executors, the panel row formatter, prompts, MCP registry/tools, SSOT gate, tests, docs) — never the identically-spelled TreeOperation kinds or editor-store methods. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ai): Tier 2 — scoped render_snapshot via iframe + importer no double-prop render_snapshot: the canvas renders each breakpoint inside an <iframe> (the data-breakpoint-id is on the host wrapper, the nodes live in the iframe's document). The capture searched the host frame, so scoped node lookups never matched and full captures grabbed the empty wrapper. Now it resolves the breakpoint's iframe and uses contentDocument as the node-lookup + screenshot root, and defaults to the active breakpoint (not the first DOM frame, which is mobile in a mobile-first layout). Scoped site_render_snapshot({nodeId}) and the right-viewport full capture now work. HTML importer: a node that recurses into child nodes no longer also keeps a flattened 'text' prop — children are the source of truth. Fixes a loop <a> wrapping spans/tokens that got BOTH a text prop and children (ambiguous, double-render risk). Mirrors the conditional heading/label rules. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ai): site_insert_html 'created' was always empty (stale pre-insert snapshot) runInsertHtml read activeDocumentNodes(store) from the snapshot captured BEFORE insertImportedNodes ran, so the just-created nodes (and auto-created classes) weren't in it — created came back []. Re-read fresh state after the insert. Now created returns the full inserted subtree { id, moduleId, classes } with resolved class names, so an agent can target nested nodes without re-reading the tree. Regression test added. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ai): remove unused MCP review symbols --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d32a262 commit e554b0e

99 files changed

Lines changed: 4445 additions & 1153 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ Read [`docs/architecture.md`](docs/architecture.md) for the system overview, [`d
4747
- **Plugins:** Zip packages with a `plugin.json` manifest, lifecycle hooks. Server entrypoints and canvas module packs run inside a **QuickJS-WASM sandbox** — no Node/Bun ambient access, network gated by `network.outbound` permission + `networkAllowedHosts`. The VM bootstrap (SDK factory + `__run*` dispatchers) is authored as typed TS in `server/plugins/quickjs/bootstrap/src/` and bundled to committed string artifacts in `bootstrap/generated/` — after editing the source run `bun run bootstrap:sync` (gated by `plugin-bootstrap-fresh.test.ts`). Permission enforcement everywhere (VM, host, editor) validates against `grantedPermissions`, never the declared `permissions` array. Feature doc: [`docs/features/plugin-system.md`](docs/features/plugin-system.md).
4848
- **Routing:** In-house router at `src/admin/lib/routing/`. Replaces `react-router-dom`. Use it for all internal admin navigation, including links rendered from the site editor. `react-router-dom` is banned, raw `<a href="/admin...">` hard navigations are banned in admin UI, and `src/core/` + `src/modules/` must not import the admin router. Gated by `admin-router-usage.test.ts`.
4949
- **Icons:** `pixel-art-icons/icons/<name>` — deep-imported, tree-shakeable. Vendored at `vendor/pixel-art-icons/`. No `lucide-react`, no inline SVG strings — gated by `no-third-party-icons.test.ts`, `direct-icon-imports.test.ts`. Add a new icon by importing it and running `bun run icons:sync`.
50-
- **AI providers:** No provider SDKs. Each driver in `server/ai/drivers/` talks directly to its provider's REST API over HTTP/SSE, sharing one multi-turn tool loop (`drivers/http/toolLoop.ts`). `@anthropic-ai/sdk`, `@anthropic-ai/claude-agent-sdk`, `@openai/agents`, `@openrouter/agent`, and `@modelcontextprotocol/sdk` are all banned repo-wide — gated by `ai-driver-isolation.test.ts`.
50+
- **AI providers:** No provider SDKs. Each driver in `server/ai/drivers/` talks directly to its provider's REST API over HTTP/SSE, sharing one multi-turn tool loop (`drivers/http/toolLoop.ts`). `@anthropic-ai/sdk`, `@anthropic-ai/claude-agent-sdk`, `@openai/agents`, and `@openrouter/agent` are banned repo-wide. `@modelcontextprotocol/sdk` is **scoped, not banned**: allowed only under `server/ai/mcp/` (Instatic's MCP *server* implements a real wire protocol), still banned in the drivers and the browser. Gated by `ai-driver-isolation.test.ts`.
51+
- **MCP server:** Instatic exposes its CMS tools to external MCP clients (Claude Code, Codex, remote agents) at `/_instatic/mcp`, authenticated by per-connector bearer tokens. Thin adapter over the existing tool engine: headless reads (content reads + `read_styles`) run in-process; ALL page editing (the full set of browser-execution editor tools) is relayed to the connector owner's **open editor** via the live editor bridge (`server/ai/mcp/editorBridge.ts` + `useEditorMcpBridge` in `SitePage`), reusing the chat bridge machinery — the live editor store is the single source of truth (no headless DB-mutating page-tree tool, which would desync). Feature doc: [`docs/features/mcp-connectors.md`](docs/features/mcp-connectors.md).
5152
- **Tree primitive:** Every tree-of-nodes — pages, Visual Components, slot fills — uses one shape: `NodeTree<TNode>` in `src/core/page-tree/treeSchema.ts`. Mutations are tree-agnostic. Reference: [`docs/reference/page-tree.md`](docs/reference/page-tree.md).
5253
- **Publishing:** Three-layer pipeline. **Layer A** bakes fully-static pages to `uploads/published/current/<route>.html` at publish time via a two-slot symlink swap (`server/publish/staticArtefact.ts`). **Layer B** is an in-memory LRU keyed by `(urlPath, queryString, publishVersion)` for dynamic routes (`server/publish/renderCache.ts`); `bumpPublishVersion()` evicts wholesale on every publish. **Layer C** emits `<instatic-hole>` placeholders for nodes auto-detected as request-dependent; a ~668 B `IntersectionObserver` runtime lazy-fetches each fragment from `/_instatic/hole/<nodeId>`. Auto-detection lives in `src/core/publisher/dynamicDetection.ts` — one walker, four rules. Single entry: `server/publish/publicRouter.ts:renderPublicResolution`. Full design: [`docs/features/publisher.md`](docs/features/publisher.md).
5354
- **Tests:** `bun test`. Architectural rules in `src/__tests__/architecture/*` — when *your* change drifts a structural rule, fix the rule's gate test in the same change.

bun.lock

Lines changed: 126 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ Three categories, three voices:
148148
| [features/dashboard.md](features/dashboard.md) | Dashboard workspace, widgets, grid, customize mode |
149149
| [features/spotlight.md](features/spotlight.md) | Cmd+K command palette |
150150
| [features/agent.md](features/agent.md) | AI agent integration and provider-agnostic runtime |
151+
| [features/mcp-connectors.md](features/mcp-connectors.md) | Instatic as an MCP server — external AI clients drive the CMS over MCP |
151152
| [features/templates.md](features/templates.md) | Entry templates + dynamic bindings + token interpolation |
152153
| [features/loops.md](features/loops.md) | `base.loop` + loop entity sources |
153154
| [features/cms-native-forms.md](features/cms-native-forms.md) | Visual form primitives, presets, secure public submissions |

docs/features/audit-log.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ Every event has a typed `action` string. The closed union is the source of truth
4343
| Data | `data.table.create`, `data.table.update`, `data.table.delete`, `data.row.create`, `data.row.update`, `data.row.delete`, `data.row.publish`, `data.row.schedule`, `data.row.schedule.cancel`, `data.row.status`, `data.row.move`, `data.author.assign` |
4444
| Publishing | `publish` |
4545
| Plugins | `plugin.install`, `plugin.update`, `plugin.enable`, `plugin.disable`, `plugin.delete`, `plugin.pack.install`, `plugin.settings.update` |
46-
| AI | `ai.credential.created`, `ai.credential.updated`, `ai.credential.deleted`, `ai.credential.tested`, `ai.default.updated`, `ai.default.cleared`, `ai.chat.started`, `ai.chat.completed`, `ai.chat.failed` |
46+
| AI | `ai.credential.created`, `ai.credential.updated`, `ai.credential.deleted`, `ai.credential.tested`, `ai.default.updated`, `ai.default.cleared`, `ai.chat.started`, `ai.chat.completed`, `ai.chat.failed`, `ai.mcp_connector.created`, `ai.mcp_connector.revoked` |
4747

4848
If you add a new action that fits an existing group, append to the union. New groups (e.g. media-related audit) extend the same union.
4949

docs/features/mcp-connectors.md

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
# MCP Connectors
2+
3+
MCP connectors let **external AI clients drive this Instatic instance** over the [Model Context Protocol](https://modelcontextprotocol.io). Instatic acts as an **MCP server**: a local client (Claude Code, Codex, Cursor) or a remote agent connects, lists the available tools, and operates the CMS — reading the site, editing page structure, and managing content — exactly the way the built-in AI panel does.
4+
5+
This is the mirror image of the **Providers** tab (`server/ai/credentials/`), which points Instatic's *own* agent outward at LLM providers. MCP connectors point inward: they let outside agents reach in.
6+
7+
The server is implemented with the official `@modelcontextprotocol/sdk`. That package is banned everywhere else in the tree (the AI drivers hand-roll provider REST); it is allowed **only under `server/ai/mcp/`**, scoped by `ai-driver-isolation.test.ts`.
8+
9+
---
10+
11+
## TL;DR
12+
13+
- **Instatic is an MCP server.** One Streamable-HTTP endpoint at `/_instatic/mcp` serves both local and remote clients (local is just `localhost`).
14+
- **Thin adapter over the existing tool engine.** No tool logic is duplicated. MCP is a new *caller* alongside the built-in agent and the plugin host; tool dispatch reuses `executeAiTool`.
15+
- **Tool surface = the full catalog.** Server-resolved tools (content reads + `site_read_styles`) run headless — no editor needed. Every browser-execution tool the agent panel has (structure edits, insert HTML, apply CSS, assign classes, set design tokens, manage pages, content CRUD, code assets, live-DOM reads) is exposed too, **relayed to an open editor via the live editor bridge** — the single source of truth for page editing. If the connector owner has no editor open, those tools return a clear "open the editor" error; the headless reads still work.
16+
- **Bearer-token auth, one secret per connector.** The token is shown once on creation and stored only as a SHA-256 hash. Revocable.
17+
- **Capability-gated.** A connector carries a granted capability subset; the same gate the built-in agent uses (`toolAllowedForCapabilities`) filters the toolset. An MCP caller can never invoke a tool the granting capabilities couldn't authorize over HTTP.
18+
- **Privilege floor.** An admin can only grant capabilities they themselves hold.
19+
- **Managed from the admin UI:** AI workspace → **MCP** tab.
20+
21+
---
22+
23+
## Architecture
24+
25+
```
26+
MCP client (Claude Code / Codex / remote agent)
27+
│ JSON-RPC over Streamable HTTP
28+
29+
server/router.ts → /_instatic/mcp (tryServeMcp)
30+
31+
server/ai/mcp/transports/http.ts WebStandardStreamableHTTPServerTransport (Web Request/Response)
32+
33+
server/ai/mcp/auth.ts Bearer token → connector → capability set (401 + WWW-Authenticate otherwise)
34+
35+
server/ai/mcp/server.ts low-level SDK Server; tools filtered by capabilities
36+
37+
server/ai/mcp/registry.ts AiTool registry → MCP tools (TypeBox inputSchema sent verbatim as JSON Schema)
38+
39+
executeAiTool(...) / treeService in-process, ctx { db, userId, capabilities }
40+
41+
repositories (data_rows, media) + applyTreeOperation + saveDataRowDraft
42+
```
43+
44+
### Module layout — `server/ai/mcp/`
45+
46+
| File | Responsibility |
47+
|---|---|
48+
| `transports/http.ts` | Mounts the SDK's Web-standard Streamable-HTTP transport; stateless per request (`enableJsonResponse`). |
49+
| `auth.ts` | Bearer resolution → `{ connectorId, userId, capabilities }`; spec-correct 401 with an RFC 9728 `resource_metadata` pointer. |
50+
| `server.ts` | Builds a capability-scoped low-level `Server` (`ListTools` / `CallTool` handlers). Uses the low-level `Server`, not `McpServer.registerTool`, because the latter needs Zod (banned) — this lets the TypeBox `inputSchema` pass through verbatim. |
51+
| `registry.ts` | The exposable toolset = full catalog (content + site + page-tree), deduped by name, filtered by `toolAllowedForCapabilities`. |
52+
| `tools/styleTools.ts` | `site_read_styles` — the design system as a CSS stylesheet, headless from the DB. |
53+
| `editorBridge.ts` | Per-user live editor bridge registry + `createEditorBridgeStream`; `getEditorBridgeForUser` routes browser tools to the owner's open editor. |
54+
| `handlers/editorBridge.ts` | `GET /admin/api/ai/editor-bridge` — the NDJSON stream the editor holds open. |
55+
| `connectors/` | `types.ts` (server-only record), `token.ts` (generate + SHA-256 hash), `store.ts` (CRUD + `toConnectorView`). |
56+
| `handlers/connectors.ts` | `/admin/api/ai/mcp/connectors` CRUD, gated by `ai.providers.manage`. |
57+
58+
The headless page-tree path (load → `applyTreeOperation` → persist) lives in `server/ai/content/treeService.ts` and is shared with the plugin RPC `cms.content.tree.mutate` — neither caller duplicates the engine. Gated by `plugin-content-tree-via-engine.test.ts`.
59+
60+
---
61+
62+
## Tool surface
63+
64+
MCP exposes the **full tool catalog** (deduped by name), capability-filtered. Tools fall in two execution classes:
65+
66+
**Single source of truth.** All page *editing* goes through the **live editor store** (browser tools, relayed to the open editor). There is deliberately **no** headless DB-mutating page-tree tool: an earlier `read_page_tree`/`mutate_page_tree` pair edited the DB directly, creating a second copy of each page with identical node ids that desynced from the open editor and got clobbered by its autosave (data loss). They were removed — structure editing uses the editor's browser tools, which the existing save-flush persists.
67+
68+
**Headless (server-resolved) — work with no editor open:**
69+
- Content reads — list/read collections, entries, data rows, media.
70+
- `get_context({ entryId? })` — orientation in one call: is a live editor connected (browser tools need it), which "everywhere"/post-type templates wrap pages, site name. Call it first if a browser tool returns "open the editor."
71+
- `site_read_styles({ format?, className?, includeTokens? })` — the design system as a **CSS stylesheet**: design tokens (CSS custom properties) + every class/ambient rule, read straight from the DB via the publisher's emitters. `format:"summary"` returns a compact class catalog (selector + referenced token vars, no declarations) to scan first. Symmetric with reading pages as HTML / writing CSS via `site_apply_css`. Replaces the old snapshot-dependent `list_tokens`.
72+
- `site_list_breakpoints` — configured viewport ids/labels/widths (the first is the base), so `site_render_snapshot` can target one deliberately. Headless version replaces the snapshot-dependent one.
73+
74+
**Browser-relayed (via the live editor bridge) — require an open editor:**
75+
- Structure editing — `site_insert_html`, `site_replace_node_html`, `site_delete_node`, `site_move_node`, `site_duplicate_node`, `site_rename_node`, `site_update_node_props`.
76+
- HTML/CSS authoring (`site_apply_css`, `site_assign_class`, `site_remove_class`), page lifecycle (`site_add_page`, …), design tokens (`site_set_color_tokens`, …), content CRUD (`content_create_document`, `content_set_document_field`, …), code assets, structure reads (`site_read_document`), and live-DOM reads (`site_render_snapshot`, `site_get_node_html`).
77+
- These have no server implementation — their logic runs in the editor app against the live store. The MCP server relays the call to the connector owner's open editor and awaits the result (see "Live editor bridge"); image attachments (e.g. `site_render_snapshot`'s PNG) come back as MCP image content blocks. No editor connected → a clear error asking the operator to open it.
78+
79+
## Live editor bridge
80+
81+
`server/ai/mcp/editorBridge.ts` keeps one bridge per user (newest open editor wins), keyed by `userId` so a connector can only reach **its own owner's** editor.
82+
83+
```
84+
MCP browser-tool call Editor (open in a browser)
85+
│ executeAiTool(browser) │ useEditorMcpBridge() holds the stream open
86+
▼ ▼
87+
buildMcpServer → getEditorBridgeForUser(userId)
88+
│ bridge.callBrowser(tool, input) → emits toolRequest ─────────────▶ executeAgentTool(tool, input)
89+
│ │ (live store)
90+
◀───────────── POST /admin/api/ai/tool-result ◀── postToolResult ◀───────┘
91+
```
92+
93+
- Editor side: `useEditorMcpBridge` (mounted in `SitePage`) opens `GET /admin/api/ai/editor-bridge` (NDJSON, admin-session auth), runs each `toolRequest` through the SAME `executeAgentTool` the agent panel uses, and POSTs the result back. Reconnects with backoff. After a tool that leaves unsaved changes, it **flushes the draft save** (`flushEditorSave`) so a follow-up headless read (`site_read_styles` / content reads) sees the change immediately instead of waiting for the 30 s autosave.
94+
- Server side: reuses the chat bridge machinery wholesale — `createBridge` issues the `AiBrowserBridge`, `resolveBridgeToolResult` settles it from the existing `/admin/api/ai/tool-result` endpoint.
95+
96+
This is why an open editor (yours, or one the agent opens) unlocks the full editing surface without reimplementing any tool.
97+
98+
---
99+
100+
## Authentication
101+
102+
**Phase 1 — bearer token (current).** Each connector has a long-lived secret (`imcp_…`). The client sends `Authorization: Bearer <token>`. The server hashes the presented token and looks up a non-revoked connector, yielding its capability set. Missing/invalid tokens get a `401` with `WWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource"`.
103+
104+
Works today with Claude Code, Cursor, Claude.ai custom connectors, and custom remote agents.
105+
106+
**Phase 2 — OAuth 2.1 (designed, not built).** ChatGPT and Gemini's *managed* connector UIs refuse static API keys and require an OAuth 2.1 flow conforming to the MCP authorization spec (RFC 9728 Protected Resource Metadata). The `auth_mode` column and the 401's `resource_metadata` pointer are already in place so this layers in without a migration rewrite.
107+
108+
---
109+
110+
## Connecting a client
111+
112+
Create a connector in **AI → MCP**, choose its type and capabilities, then copy the token (shown once).
113+
114+
**Local (Claude Code / Codex / Cursor):**
115+
116+
```sh
117+
claude mcp add instatic --transport http http://localhost:3000/_instatic/mcp \
118+
--header "Authorization: Bearer imcp_…"
119+
```
120+
121+
**Remote:** point the client at `https://<your-host>/_instatic/mcp` and send the token as an `Authorization: Bearer` header.
122+
123+
---
124+
125+
## Data model
126+
127+
`ai_mcp_connectors` (migration `018`, PG + SQLite parity):
128+
129+
| column | notes |
130+
|---|---|
131+
| `id`, `user_id`, `label` | owner + display name |
132+
| `type` | `local` \| `remote` |
133+
| `auth_mode` | `bearer` now; `oauth` reserved for phase 2 |
134+
| `token_hash` | SHA-256 of the secret; never the plaintext. Unique. |
135+
| `capabilities_json` | granted capability subset |
136+
| `created_at`, `last_used_at`, `revoked_at` | lifecycle; revoked tokens fail auth |
137+
138+
The wire-safe `McpConnectorView` (the only HTTP-returned shape) never includes the hash — gated by `ai-mcp-connectors-never-leak.test.ts`. Create and revoke are audited (`ai.mcp_connector.created` / `ai.mcp_connector.revoked`).
139+
140+
---
141+
142+
## Capabilities
143+
144+
Connector management is gated by `ai.providers.manage` (the AI-integrations admin surface). A connector's granted capabilities flow straight into the existing tool gate:
145+
146+
- mutating tools require `ai.tools.write`;
147+
- page-tree edits require any of `site.structure.edit` / `site.content.edit` / `site.style.edit` / `pages.edit`;
148+
- reads require any site/content read grant.
149+
150+
An admin cannot grant a capability they do not hold (enforced in `handlers/connectors.ts`).
151+
152+
---
153+
154+
## Tests
155+
156+
- `server/ai/mcp/connectors/{token,store}.test.ts` — token hashing + store CRUD.
157+
- `server/ai/content/treeService.test.ts` — headless load/mutate/persist.
158+
- `server/ai/mcp/{registry,auth,server,transports/http}.test.ts` — capability filtering, bearer auth + 401, full MCP round-trip (list/read/mutate), HTTP handshake.
159+
- `src/__tests__/ai/mcpConnectorsHandler.test.ts` — CRUD, privilege floor, capability gating.
160+
- `src/__tests__/architecture/ai-mcp-connectors-never-leak.test.ts` — token never serialized.

0 commit comments

Comments
 (0)