Skip to content

Commit d697742

Browse files
fix(mcp): serve site_list_documents headlessly over MCP (CoreBunch#186)
* fix(mcp): serve site_list_documents headlessly over MCP The site-scope `site_list_documents` handler reads `ctx.snapshot`, which is null over MCP (no chat turn, no editor snapshot). `asSnap` is an unchecked cast, so `asSnap(null).currentDocument` throws a TypeError — any MCP client calling the tool crashes instead of receiving the document catalog. Add a headless `documentMcpTools` set that assembles the catalog directly from the draft site document (`getDraftSiteDocument` + `describeAgentDocuments`) and order it ahead of `siteTools` in the MCP registry so it wins the name de-dup, mirroring how `styleMcpTools` already shadows `site_list_breakpoints`. No editor focus exists server-side, so nothing is marked current; `get_context` still reports the live editor. * fix(mcp): scope site_list_documents to site.read and use a valid current-doc sentinel Address review feedback on the headless site_list_documents: - `requiredCapabilities` matches ANY-OF (see `toolAllowedForCapabilities`), so the previous five-capability list let a caller holding only an edit capability (e.g. `pages.edit`) but not `site.read` list documents. Narrow it to `['site.read']`, matching the site-scope tool it shadows. - The "no current document" sentinel used an empty id, violating `AgentDocumentRefSchema` (`minLength: 1`). Use a clearly-nonexistent, non-empty id instead so it never matches a real document and stays schema-valid if surfaced. * fix(mcp): finalize headless document catalog --------- Co-authored-by: DavidBabinec <hello@davidbabinec.com>
1 parent fdfcb15 commit d697742

5 files changed

Lines changed: 144 additions & 20 deletions

File tree

docs/features/mcp-connectors.md

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ The server is implemented with the official `@modelcontextprotocol/sdk`. That pa
1212

1313
- **Instatic is an MCP server.** One Streamable-HTTP endpoint at `/_instatic/mcp` serves both local and remote clients (local is just `localhost`).
1414
- **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.
15+
- **Tool surface = the full catalog.** Server-resolved tools (content reads + `site_list_documents` + `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.
1616
- **Bearer-token auth, one secret per connector.** The token is shown once on creation and stored only as a SHA-256 hash. New tokens expire after 90 days by default; admins can choose a custom TTL or explicitly create a non-expiring token. Revocable.
1717
- **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.
1818
- **Privilege floor.** An admin can only grant capabilities they themselves hold.
@@ -36,9 +36,9 @@ server/ai/mcp/server.ts low-level SDK Server; tools filtered by ca
3636
3737
server/ai/mcp/registry.ts AiTool registry → MCP tools (TypeBox inputSchema sent verbatim as JSON Schema)
3838
39-
executeAiTool(...) / treeService in-process, ctx { db, userId, capabilities }
39+
executeAiTool(...) / live editor bridge
4040
41-
repositories (data_rows, media) + applyTreeOperation + saveDataRowDraft
41+
repositories (headless reads) / live editor store (browser tools)
4242
```
4343

4444
### Module layout — `server/ai/mcp/`
@@ -48,15 +48,14 @@ repositories (data_rows, media) + applyTreeOperation + saveDataRowDraft
4848
| `transports/http.ts` | Mounts the SDK's Web-standard Streamable-HTTP transport; stateless per request (`enableJsonResponse`). |
4949
| `auth.ts` | Bearer resolution → `{ connectorId, userId, capabilities }`; spec-correct 401 with an RFC 9728 `resource_metadata` pointer. |
5050
| `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`. |
51+
| `registry.ts` | Headless reads plus the browser-relayed site/content catalog, deduped by name and filtered by `toolAllowedForCapabilities`. |
52+
| `tools/documentTools.ts` | `site_list_documents` — pages, templates, and visual components, headless from the DB. |
5253
| `tools/styleTools.ts` | `site_read_styles` — the design system as a CSS stylesheet, headless from the DB. |
5354
| `editorBridge.ts` | Per-user live editor bridge registry + `createEditorBridgeStream`; `getEditorBridgeForUser` routes browser tools to the owner's open editor. |
5455
| `handlers/editorBridge.ts` | `GET /admin/api/ai/editor-bridge` — the NDJSON stream the editor holds open. |
5556
| `connectors/` | `types.ts` (server-only record), `token.ts` (generate + SHA-256 hash), `store.ts` (CRUD + `toConnectorView`). |
5657
| `handlers/connectors.ts` | `/admin/api/ai/mcp/connectors` CRUD, gated by `ai.providers.manage`. |
5758

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-
6059
---
6160

6261
## Tool surface
@@ -68,6 +67,7 @@ MCP exposes the **full tool catalog** (deduped by name), capability-filtered. To
6867
**Headless (server-resolved) — work with no editor open:**
6968
- Content reads — list/read collections, entries, data rows, media.
7069
- `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."
70+
- `site_list_documents` — editable pages, templates, and visual components with document references, root node ids, template metadata, and summaries. Nothing is marked active/current because headless calls have no editor focus.
7171
- `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`.
7272
- `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.
7373

@@ -155,7 +155,6 @@ An admin cannot grant a capability they do not hold (enforced in `handlers/conne
155155
## Tests
156156

157157
- `server/ai/mcp/connectors/{token,store}.test.ts` — token hashing, expiry, and store CRUD.
158-
- `server/ai/content/treeService.test.ts` — headless load/mutate/persist.
159-
- `server/ai/mcp/{registry,auth,server,transports/http}.test.ts` — capability filtering, bearer auth + 401, full MCP round-trip (list/read/mutate), HTTP handshake.
158+
- `server/ai/mcp/{registry,auth,server,transports/http}.test.ts` and `server/ai/mcp/tools/documentTools.test.ts` — capability filtering, headless document listing, bearer auth + 401, full MCP round-trip (list/read/mutate), HTTP handshake.
160159
- `src/__tests__/ai/mcpConnectorsHandler.test.ts` — CRUD, privilege floor, capability gating.
161160
- `src/__tests__/architecture/ai-mcp-connectors-never-leak.test.ts` — token never serialized.

server/ai/mcp/registry.ts

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
* filtered to the connector's granted capabilities.
44
*
55
* Two execution classes are exposed:
6-
* - server-resolved tools (content reads + `site_read_styles`) run in-process and
7-
* work with NO editor open;
6+
* - server-resolved tools (content reads + `site_list_documents` +
7+
* `site_read_styles`) run in-process and work with NO editor open;
88
* - browser tools (structure edits, HTML/CSS authoring, design tokens, page
99
* lifecycle, content CRUD, code assets, live-DOM reads) are relayed to the
1010
* connector owner's open editor via the live editor bridge
@@ -29,19 +29,24 @@ import { contentTools } from '../tools/content'
2929
import { siteTools } from '../tools/site'
3030
import { styleMcpTools } from './tools/styleTools'
3131
import { contextMcpTools } from './tools/contextTool'
32+
import { documentMcpTools } from './tools/documentTools'
3233

3334
// Server-resolved site read tools whose handlers read the browser-posted
34-
// `ctx.snapshot`, which is null over MCP — they'd silently return nothing.
35-
// `site_read_styles` (headless) replaces `site_list_tokens`. The snapshot-based
36-
// `site_list_breakpoints` is excluded too; a headless `site_list_breakpoints` is provided
37-
// by `styleMcpTools` (which is ordered first, so it wins the de-dup).
35+
// `ctx.snapshot`, which is null over MCP — they'd return nothing or throw.
36+
// Each is handled one of two ways:
37+
// - `site_list_tokens` → excluded; `site_read_styles` (headless) replaces it.
38+
// - `site_list_breakpoints` → shadowed by a headless version in `styleMcpTools`.
39+
// - `site_list_documents` → shadowed by a headless version in `documentMcpTools`
40+
// (the snapshot-based one throws on `null.currentDocument`).
41+
// The headless tool sets are ordered ahead of `siteTools` below, so they win
42+
// the de-dup for any shared name.
3843
const MCP_EXCLUDED_TOOLS = new Set<string>(['site_list_tokens'])
3944

4045
function allMcpTools(): AiTool[] {
41-
// De-dup by tool name. Order matters: the headless style + content tools win
42-
// over the site toolset for any shared name (e.g. `list_documents`), so the
43-
// version that works without an open editor is the one exposed.
44-
const ordered = [...contextMcpTools, ...styleMcpTools, ...contentTools, ...siteTools]
46+
// De-dup by tool name. Order matters: the headless MCP-specific + content
47+
// tools win over the site toolset for shared names, so the version that works
48+
// without an open editor is the one exposed.
49+
const ordered = [...contextMcpTools, ...styleMcpTools, ...documentMcpTools, ...contentTools, ...siteTools]
4550
const byName = new Map<string, AiTool>()
4651
for (const tool of ordered) {
4752
if (MCP_EXCLUDED_TOOLS.has(tool.name)) continue
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { describe, expect, it, beforeEach } from 'bun:test'
2+
import { createSqliteClient } from '../../../db/sqlite'
3+
import { sqliteMigrations } from '../../../db/migrations-sqlite'
4+
import { runMigrations } from '../../../db/runMigrations'
5+
import type { DbClient } from '../../../db/client'
6+
import type { ToolContext } from '../../runtime/types'
7+
import { mcpToolsForCapabilities } from '../registry'
8+
9+
const PAGE_TREE = {
10+
rootNodeId: 'root',
11+
nodes: {
12+
root: { id: 'root', moduleId: 'base.body', props: {}, breakpointOverrides: {}, classIds: [], children: [] },
13+
},
14+
}
15+
16+
async function freshDb(): Promise<DbClient> {
17+
const db = createSqliteClient(':memory:')
18+
await runMigrations(db, sqliteMigrations)
19+
// The default site shell row is created at first-run setup, not by migrations.
20+
await db`
21+
insert into site (id, name, settings_json)
22+
values ('default', 'Test', ${{ cmsSiteSchemaVersion: 1, site: {} }})
23+
`
24+
// Seed one page row into the (already-seeded) `pages` system table.
25+
const cells = JSON.stringify({ title: 'Home', slug: 'index', body: PAGE_TREE })
26+
await db`
27+
insert into data_rows (id, table_id, cells_json, slug, status)
28+
values ('home', 'pages', ${cells}, 'index', 'draft')
29+
`
30+
return db
31+
}
32+
33+
function headlessCtx(db: DbClient): ToolContext {
34+
return {
35+
db,
36+
userId: 'u1',
37+
capabilities: ['site.read'],
38+
scope: 'site',
39+
conversationId: 'mcp:test',
40+
snapshot: null, // MCP has no browser-posted snapshot — this is the crash case.
41+
signal: new AbortController().signal,
42+
}
43+
}
44+
45+
describe('mcp site_list_documents (headless)', () => {
46+
let db: DbClient
47+
beforeEach(async () => {
48+
db = await freshDb()
49+
})
50+
51+
it('the MCP registry exposes a server-resolved site_list_documents that does not need a snapshot', async () => {
52+
const tool = mcpToolsForCapabilities(['site.read']).find((t) => t.name === 'site_list_documents')
53+
if (!tool?.handler) throw new Error('Expected site_list_documents handler')
54+
expect(tool.execution).toBe('server')
55+
56+
// The site-scope (chat) version throws on `null.currentDocument`; the headless
57+
// one resolves the catalog from the DB. Over MCP this must not throw.
58+
const result = (await tool.handler({}, headlessCtx(db))) as {
59+
currentDocument: unknown
60+
documents: {
61+
document: { type: string; id: string }
62+
slug?: string
63+
active: boolean
64+
current: boolean
65+
}[]
66+
}
67+
68+
expect(Array.isArray(result.documents)).toBe(true)
69+
expect(result.documents.some((d) => d.slug === 'index')).toBe(true)
70+
// No open-editor focus server-side, so nothing is reported as current.
71+
expect(result.currentDocument).toBeNull()
72+
expect(result.documents.every((document) => !document.active && !document.current)).toBe(true)
73+
})
74+
75+
it('does not expose the catalog to an edit-only connector without site.read', () => {
76+
const tool = mcpToolsForCapabilities(['pages.edit']).find(
77+
(candidate) => candidate.name === 'site_list_documents',
78+
)
79+
expect(tool).toBeUndefined()
80+
})
81+
})
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* Headless site-document catalog for MCP.
3+
*
4+
* `site_list_documents` lists editable documents — pages, templates, and visual
5+
* components — so an MCP agent can orient itself before reading or editing
6+
* (the site system prompt tells it to call this first for chrome/template work).
7+
*
8+
* The site-scope `site_list_documents` in `../../tools/site/readTools.ts` resolves
9+
* from the browser-posted `ctx.snapshot`, which is null over MCP (no chat turn,
10+
* no editor snapshot) — calling it there throws on `snap.currentDocument`. This
11+
* headless version assembles the catalog straight from the DB
12+
* (`getDraftSiteDocument`) and is ordered ahead of the site toolset in the MCP
13+
* registry so it wins the de-dup. There is no open-editor focus server-side, so
14+
* no document is marked active/current; `get_context` reports the live editor.
15+
*/
16+
import { Type } from '@core/utils/typeboxHelpers'
17+
import { describeAgentDocuments } from '@core/ai'
18+
import type { AiTool, ToolContext } from '../../runtime/types'
19+
import { getDraftSiteDocument } from '../../../repositories/publish'
20+
21+
export const documentMcpTools: AiTool[] = [
22+
{
23+
name: 'site_list_documents',
24+
description:
25+
'List editable documents: pages, templates, and visual components. Use the returned document refs with site_read_document/site_open_document. Each item includes rootNodeId, template metadata, and a short summary. Headless — no editor needed.',
26+
scope: 'site',
27+
execution: 'server',
28+
inputSchema: Type.Object({}, { additionalProperties: false }),
29+
requiredCapabilities: ['site.read'],
30+
handler: async (_input, ctx: ToolContext) => {
31+
const site = await getDraftSiteDocument(ctx.db)
32+
if (!site) return { ok: false, error: 'No site found.' }
33+
return {
34+
currentDocument: null,
35+
documents: describeAgentDocuments(site, null, null),
36+
}
37+
},
38+
},
39+
]

src/core/ai/documentRefs.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ export function documentRefEquals(a: AgentDocumentRef | null | undefined, b: Age
2626

2727
export function describeAgentDocuments(
2828
site: SiteDocument,
29-
activePageId: string,
30-
currentDocument: AgentDocumentRef,
29+
activePageId: string | null,
30+
currentDocument: AgentDocumentRef | null,
3131
): AgentDocumentDescriptor[] {
3232
const descriptors: AgentDocumentDescriptor[] = []
3333

0 commit comments

Comments
 (0)