Skip to content

Commit 10448bc

Browse files
authored
fix(mcp): publish drafts through scoped workspace bridges (CoreBunch#205)
1 parent d697742 commit 10448bc

52 files changed

Lines changed: 1942 additions & 764 deletions

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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ Read [`docs/architecture.md`](docs/architecture.md) for the system overview, [`d
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`.
5050
- **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).
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 and explicit `site_publish` run in-process; browser tools route by scope to the connector owner's **open Site or Content workspace** through the `(userId, scope)` live bridge (`server/ai/mcp/editorBridge.ts` + `useMcpWorkspaceBridge`). The live workspace is the single source of truth for edits (no headless DB-mutating page-tree tool, which would desync), and writes remain drafts until the explicitly capability-gated publish call. Feature doc: [`docs/features/mcp-connectors.md`](docs/features/mcp-connectors.md).
5252
- **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).
5353
- **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).
5454
- **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.

docs/features/agent.md

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,12 +79,17 @@ server/ai/
7979
├── types.ts — canonical AiStreamEvent / AiMessage / AiTool / ToolContext
8080
└── transport.ts — createBridge() / resolveBridgeToolResult()
8181
82+
src/admin/ai/
83+
├── ndjsonStream.ts — shared validated NDJSON reader
84+
├── toolResultApi.ts — shared browser-tool result POST
85+
└── useMcpWorkspaceBridge.ts— scope-aware external MCP stream + browser dispatcher
86+
8287
src/admin/pages/site/agent/
8388
├── index.ts — public barrel (all external imports go through here)
8489
├── agentSlice.ts — scope-agnostic Zustand slice factory (createAgentSlice(config))
8590
├── agentSliceConfig.site.ts— site-editor config: scope, snapshot builder, executor wiring
86-
├── agentConfig.ts — API path constants (AGENT_TOOL_RESULT_PATH, AI_CONVERSATIONS_PATH, …)
87-
├── agentApi.ts — HTTP layer: tool-result POST, conversation bootstrap, message rehydration
91+
├── agentConfig.ts — conversation/default API path constants
92+
├── agentApi.ts — conversation bootstrap and message rehydration
8893
├── streamEvents.ts — NDJSON schema (ServerStreamEventSchema) + processStreamEvent reducer
8994
├── siteAgentSnapshot.ts — `SiteAgentSnapshotSchema` (TypeBox) + derived `SiteAgentSnapshot` type + `buildSiteAgentSnapshot` serializer
9095
├── pageContext.ts — editor adapter: reads active page + store scalars, calls `buildSiteAgentSnapshot`
@@ -98,7 +103,9 @@ src/admin/pages/site/agent/
98103
src/admin/pages/content/agent/
99104
├── agentSliceConfig.content.ts — content-workspace config: scope, snapshot builder, executor wiring
100105
├── contentAgentStore.ts — standalone per-mount Zustand store (AgentSlice only)
101-
└── contentBridge.ts — content workspace write-tool executor
106+
├── contentBridge.ts — content workspace write-tool executor
107+
├── contentBridgeHandle.ts — live ContentPage operation handle
108+
└── useContentToolBridge.ts — always-mounted handle + content-scope MCP relay
102109
103110
src/admin/pages/site/panels/AgentPanel/
104111
├── AgentPanel.tsx — main panel; resolves active model's contextWindow from the models endpoint
@@ -464,7 +471,7 @@ When a node-targeting write tool (`site_insert_html`, `site_get_node_html`, `sit
464471

465472
Content-scope tools are registered under `server/ai/tools/content/`. They use the same `POST /admin/api/ai/chat/content` stream and `POST /admin/api/ai/tool-result` bridge as the Site editor, but the snapshot and browser executor are content-specific:
466473

467-
- `ContentAgentMount` builds a `ContentSnapshot` from the live Content workspace: visible `postType` / `page` collections, active collection id, active document fields/schema, and current user identity.
474+
- `ContentAgentMount` builds a `ContentSnapshot` from the live Content workspace: visible `postType` collections, active collection id, active document fields/schema, and current user identity.
468475
- `contentAgentStore.ts` mounts a standalone `AgentSlice` instance per `ContentPage` mount. The Content workspace is hook-based rather than a global Zustand store, so the bridge is exposed through `contentBridgeHandle.ts`.
469476
- Server read tools hit the data, media, and user repositories through `ctx.db`; write tools are browser-bridged so unsaved draft state in `useContentEntryDraft` and the Tiptap body editor stay authoritative.
470477

@@ -490,7 +497,7 @@ Content-scope tools are registered under `server/ai/tools/content/`. They use th
490497
| `content_set_document_field` | Writes one field; body values are markdown and are converted by the browser bridge |
491498
| `content_set_document_fields` | Batch-writes multiple fields in one save |
492499
| `content_set_document_author` | Reassigns author; gated by the same author-management capability path as the HTTP UI |
493-
| `content_set_active_document` | Opens a document in the content editor before editing it visibly |
500+
| `content_set_active_document` | Loads a document by id across post-type collections and commits the live editor focus before subsequent writes |
494501
| `content_set_active_collection` | Switches the sidebar focus to a collection |
495502

496503
The content system prompt is markdown-native: it tells the model to exchange body content as standard markdown, to read schemas before writing unfamiliar fields, and to prefer `content_set_document_fields` for whole-post generation. The prompt is built with the same `[staticPrefix, SYSTEM_PROMPT_DYNAMIC_BOUNDARY, dynamicSuffix]` shape as the Site prompt so provider prompt caching works the same way.

docs/features/content-workspace.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ The Content workspace renders a three-pane shell — explorer sidebar, document
1414
- **Canvas modes:** `write` (bare editor surface) and `live` (entry rendered inside its template with real site styles).
1515
- **Settings panel:** `ContentSettingsPanel` — entry-specific; hidden when no entry is selected. Reopened via the top-right notch when collapsed.
1616
- **Hooks:** `useContentWorkspace` (CRUD + selection), `useContentEntryDraft` (field state + save/publish), `useContentMediaPicker` (media modal + featured media).
17-
- **AI assistant:** `ContentAgentMount` docks the shared Agent Panel in the content rail. It exposes the live workspace through a content-specific bridge so agent writes mutate the open draft/editor state, not stale database rows.
17+
- **AI assistant:** `ContentAgentMount` docks the shared Agent Panel in the content rail. `useContentToolBridge`, mounted by `ContentPage`, exposes the live workspace to both the built-in agent and scoped MCP relay even when the panel is closed, so writes mutate the open draft/editor state rather than stale database rows.
1818

1919
---
2020

@@ -149,7 +149,7 @@ The mode switch is client-only. The markdown body is the source of truth in both
149149

150150
The Content workspace has its own `content` chat scope, mounted as the `agent` panel in `ContentSidebar` when the current user has `ai.chat`.
151151

152-
`ContentAgentMount` creates a fresh per-page `AgentSlice` store (`contentAgentStore.ts`) and registers a `ContentBridgeHandle` for the mounted `ContentPage`. The handle reads the current collections, selected entry, draft fields, schema, and current user via refs so the agent sees the same state the user sees. Tool writes go through that handle and then through `useContentWorkspace` / `useContentEntryDraft`, which keeps unsaved body/title/SEO/media changes and sidebar selection in sync.
152+
`ContentAgentMount` creates a fresh per-page `AgentSlice` store (`contentAgentStore.ts`) for the visible chat panel. Independently, `ContentPage` always mounts `useContentToolBridge`, which registers a `ContentBridgeHandle` and the `content` MCP stream for as long as the workspace is open. The handle reads the current collections, selected entry, draft fields, schema, and current user via refs so either caller sees the same state the user sees. Tool writes go through that handle and then through `useContentWorkspace` / `useContentEntryDraft`, which keeps unsaved body/title/SEO/media changes and sidebar selection in sync. `content_set_active_document` loads an uncached row by id, switches across post-type collections without waiting for the target sidebar list, and commits the workspace and draft focus before it returns so an immediately following write targets the selected document.
153153

154154
The server registers 15 content-scope tools:
155155

@@ -160,6 +160,8 @@ The server registers 15 content-scope tools:
160160

161161
Body content is exchanged with the model as markdown. The browser bridge converts it to/from the Tiptap document when applying field writes, so the persisted `body` cell remains the same markdown source of truth used by the manual editor.
162162

163+
`content_create_document` always creates a draft. Publishing or scheduling is deliberately a follow-up `content_set_document_status` call so the dedicated `content.publish.own` / `content.publish.any` capability gate cannot be bypassed through creation.
164+
163165
---
164166

165167
## Forbidden patterns
@@ -184,7 +186,8 @@ Body content is exchanged with the model as markdown. The browser bridge convert
184186
- [docs/features/agent.md](agent.md) — shared AI runtime, content-scope tools, and browser bridge
185187
- Source-of-truth files:
186188
- `src/admin/pages/content/ContentPage.tsx` — workspace mount point
187-
- `src/admin/pages/content/agent/ContentAgentMount.tsx` — content Agent Panel mount and bridge registration
189+
- `src/admin/pages/content/agent/ContentAgentMount.tsx` — content Agent Panel mount
190+
- `src/admin/pages/content/agent/useContentToolBridge.ts` — always-mounted live handle + scoped MCP bridge registration
188191
- `src/admin/pages/content/agent/contentBridge.ts` — content agent browser-tool dispatcher
189192
- `src/admin/pages/content/agent/contentBridgeHandle.ts` — live content workspace bridge handle
190193
- `src/admin/pages/content/TiptapBodyEditor.tsx` — body editor

0 commit comments

Comments
 (0)