From c890c0620a8cdd89b8c4ec603aabacf91fcccd85 Mon Sep 17 00:00:00 2001 From: borskyj Date: Fri, 4 Sep 2026 15:34:30 +0200 Subject: [PATCH 1/7] feat(mcp): add headless data table and row tools MCP could read post types and their rows, but reusable `data` tables were invisible to it and no tool could create a table, change its fields, or write rows anywhere. Schema setup was the one manual step in an otherwise automatable pipeline. Adds a `data` toolset that runs server-side in-process, so it needs no open editor workspace: - `data_list_tables`, `data_create_table`, `data_update_table`, `data_add_fields` - `data_create_rows` (bulk, transactional, max 200 per call), `data_update_row` - `data_set_rows_status`, `data_delete_rows` (per-row partial failure, since publishing bakes an artefact per row and is not transactional) The tools reuse the capability predicates in `server/handlers/cms/data/access.ts` rather than restating the rules, so the predicate signatures widen to accept a plain actor instead of a full `AuthUser`. On the content side, asking `content_set_active_collection` for a non-postType table now says which workspace owns it instead of "collection not found". Closes #433, closes #463. Verification: bun test server/ai/tools/data (42 pass), bun test server/ai/mcp/registry.test.ts, bun test src/__tests__/agent, bun run lint, bun run build. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 7 + docs/features/data-workspace.md | 13 + docs/features/mcp-connectors.md | 25 +- server/ai/mcp/registry.test.ts | 44 ++ server/ai/mcp/registry.ts | 11 +- server/ai/tools/content/readTools.ts | 11 +- server/ai/tools/content/systemPrompt.ts | 1 + server/ai/tools/content/writeTools.ts | 4 +- server/ai/tools/data/access.ts | 24 + server/ai/tools/data/index.ts | 28 ++ server/ai/tools/data/lifecycleTools.test.ts | 219 +++++++++ server/ai/tools/data/lifecycleTools.ts | 242 ++++++++++ server/ai/tools/data/rowTools.test.ts | 255 ++++++++++ server/ai/tools/data/rowTools.ts | 248 ++++++++++ server/ai/tools/data/runtime.ts | 18 + server/ai/tools/data/schemaTools.test.ts | 392 +++++++++++++++ server/ai/tools/data/schemaTools.ts | 456 ++++++++++++++++++ server/ai/tools/index.ts | 11 +- server/handlers/cms/data/access.ts | 21 +- src/__tests__/agent/contentBridge.test.ts | 20 +- .../agent/contentCollectionRefresh.test.tsx | 57 ++- .../architecture/ai-tool-input-object.test.ts | 3 +- .../pages/content/agent/contentBridge.ts | 8 +- .../content/agent/contentBridgeHandle.ts | 3 +- .../content/agent/useContentToolBridge.ts | 66 ++- .../content/hooks/useContentWorkspace.ts | 19 +- 26 files changed, 2135 insertions(+), 71 deletions(-) create mode 100644 server/ai/tools/data/access.ts create mode 100644 server/ai/tools/data/index.ts create mode 100644 server/ai/tools/data/lifecycleTools.test.ts create mode 100644 server/ai/tools/data/lifecycleTools.ts create mode 100644 server/ai/tools/data/rowTools.test.ts create mode 100644 server/ai/tools/data/rowTools.ts create mode 100644 server/ai/tools/data/runtime.ts create mode 100644 server/ai/tools/data/schemaTools.test.ts create mode 100644 server/ai/tools/data/schemaTools.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index dfc6abefe..924c6b98d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to Instatic will be documented here. This project is pre-1.0. Breaking changes may appear in minor or patch releases until a stable release line exists. +## Unreleased + +### AI and integrations + +- Added a `data_*` tool scope so reusable data tables can be built and filled headlessly, over MCP or from the in-app agent ([#433](https://github.com/CoreBunch/Instatic/issues/433), [#463](https://github.com/CoreBunch/Instatic/issues/463)). Schema setup was the one manual break in an otherwise automatable pipeline: `content_list_collections` listed post types only, `content_create_document` refused a `kind: 'data'` table id, and no tool could create a table or a field at all, so an agent asked to build a training catalogue had to stop and hand the operator a list of columns to type in. Eight tools now cover the whole surface — `data_list_tables`, `data_create_table`, `data_update_table`, `data_add_fields`, `data_create_rows`, `data_update_row`, `data_set_rows_status`, `data_delete_rows` — all server-resolved, so none of them needs a workspace tab open. Row creation is transactional in batches of up to 200, and the system tables still refuse any change to their identity or built-in fields. +- Fixed the Content workspace answering "Collection not found" for a reusable data table that exists. The id was right and the table was real; it is simply authored in the Data workspace, not in the Tiptap editor. Agents responded by creating a duplicate post type to stand in for it. The refusal now names the kind of table it is and the tool that can write it. + ## 0.0.20 - 2026-09-13 ### Features diff --git a/docs/features/data-workspace.md b/docs/features/data-workspace.md index 899d21769..f66db9402 100644 --- a/docs/features/data-workspace.md +++ b/docs/features/data-workspace.md @@ -209,6 +209,18 @@ Both actions are opened from `DataSidebar`. --- +## Agent and MCP access + +Everything this workspace does by hand is also reachable headlessly, through the `data` tool scope (`server/ai/tools/data/`): `data_list_tables`, `data_create_table`, `data_update_table`, `data_add_fields`, `data_create_rows`, `data_update_row`, `data_set_rows_status`, `data_delete_rows`. An MCP connection gets the same tools with a runtime attached, so its writes are attributed to the connection in the audit log. + +Those handlers reuse this workspace's server side rather than restating it — the same repository calls, the same `slugForTable` derivation, the same `content.entry.cells` plugin filter, and the same access predicates from `server/handlers/cms/data/access.ts`. Keep it that way: a second copy of a rule is a copy free to drift. When a rule changes here, it changes for the tools in the same edit. + +The tools are deliberately `execution: 'server'`, not browser-relayed like the `content_*` writes. A data row is a grid of typed cells, not a Tiptap document, so there is nothing an open tab could render that the server cannot do alone — and requiring one would make the toolset useless to a script or a remote agent. + +Full tool table and capability requirements: [docs/features/mcp-connectors.md](mcp-connectors.md) → "Reusable data tables". + +--- + ## Forbidden patterns | Pattern | Why | @@ -229,6 +241,7 @@ Both actions are opened from `DataSidebar`. ## Related - [docs/features/content-storage.md](content-storage.md) — `DataField` schema, field types, `data_tables` / `data_rows` structure +- [docs/features/mcp-connectors.md](mcp-connectors.md) — the `data_*` toolset that exposes this workspace headlessly - [docs/reference/ui-primitives.md](../reference/ui-primitives.md) — `Button`, `Input`, `Select`, `Switch` usage - [docs/reference/persistence-keys.md](../reference/persistence-keys.md) — `instatic-data-grid-primary-widths-v1` - Source-of-truth files: diff --git a/docs/features/mcp-connectors.md b/docs/features/mcp-connectors.md index 30febc736..382695bf5 100644 --- a/docs/features/mcp-connectors.md +++ b/docs/features/mcp-connectors.md @@ -144,15 +144,37 @@ executeAiTool(...) / live editor bridge | `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. | | `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. | +| `../tools/data/` | Server-resolved schema and row tools for reusable data tables (`data_*`). Shared with the in-app `data` chat scope; the MCP registry passes a runtime so writes are attributed to the connection. | ## Tool execution model MCP exposes the full deduplicated tool catalog, filtered by the connection's capabilities. -Server-resolved tools work without an editor open. They include content reads, `get_context`, `site_list_documents`, `site_read_styles`, `site_list_breakpoints`, `media_upload`, and explicit `site_publish`. Publishing requires `ai.tools.write` plus `pages.publish`, runs the canonical full-site pipeline, swaps the static slot atomically, and records the connection id in the publish audit event. +Server-resolved tools work without an editor open. They include content reads, the whole `data_*` toolset, `get_context`, `site_list_documents`, `site_read_styles`, `site_list_breakpoints`, `media_upload`, and explicit `site_publish`. Publishing requires `ai.tools.write` plus `pages.publish`, runs the canonical full-site pipeline, swaps the static slot atomically, and records the connection id in the publish audit event. `media_upload` is the one server-resolved write that mutates outside the live editor draft: it adds an image to the Media library through the same `acceptUploadedMedia` core the HTTP route uses (magic-byte sniffing, SVG sanitisation, storage dispatch, responsive variants). Bytes arrive inline (base64) or via an https `sourceUrl` the host downloads under the plugin network layer's SSRF blocklist — https-only, DNS-resolved, per-redirect-hop re-validation, size-capped. It requires `ai.tools.write` plus `media.write`. +### Reusable data tables + +`content_*` covers post types — documents with a Tiptap body the Content workspace renders. Reusable tables (`kind: 'data'`: a training catalogue, a team roster, anything a page loops over) are a different shape: a grid of typed cells with no body, and `content_list_collections` deliberately does not list them. + +They get their own headless toolset instead of being folded into `content_*`, because routing a cell write through an open browser tab would buy nothing and would make the toolset unusable from a script or a remote agent: + +| Tool | Does | Requires | +|---|---|---| +| `data_list_tables` | Lists data tables and post types with slug, kind, route base, row count, and primary field. Page, component, and layout tables stay hidden — those are Site-editor documents. | a table read/manage capability | +| `data_create_table` | Creates a table with its fields. `kind: 'data'` gets no route base, so its rows have no public URL; `kind: 'postType'` gets `/`. | `data.custom.tables.manage` | +| `data_update_table` | Changes identity or replaces the field array. | a table manage capability | +| `data_add_fields` | Appends fields, leaving stored values untouched — the safe way to evolve a schema. | a table manage capability | +| `data_create_rows` | Creates up to 200 rows in one transaction. Any rejection writes nothing. | `content.create` | +| `data_update_row` | Patches one row's cells (merge by default). | a content edit capability | +| `data_set_rows_status` | Publishes, unpublishes, or drafts rows in bulk, reporting per-row outcomes. | publish for `published`, edit otherwise | +| `data_delete_rows` | Soft-deletes rows in bulk. | a content edit capability | + +Publishing a row in a table with no route base is allowed and normal. No static artefact is baked because there is no route to bake it at, but the row becomes `published`, which is what an `` on some other page reads. + +The system tables (`pages`, `posts`, `components`, `layouts`) accept new custom fields but refuse any change to their identity or their built-in fields, enforced by the same `assertSystemTableUpdateAllowed` the HTTP route uses. + 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. 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. @@ -200,4 +222,5 @@ Create and manual revoke actions retain the existing `ai.mcp_connector.created` - `src/__tests__/ai/mcpOAuthAuthorizationHandler.test.ts` covers signed-in consent, capability selection, exact callback redirects, denial, and privilege floors. - `src/__tests__/ai/mcpConnectorsHandler.test.ts` covers connection listing, personal-token creation, step-up, revoke, and privilege floors. - `server/ai/mcp/e2e.test.ts`, `transports/http.test.ts`, and `publishTool.test.ts` cover the real MCP request flow and publish path. +- `server/ai/tools/data/*.test.ts` cover the data toolset against a migrated SQLite database: system-table refusals, transactional batches, slug-conflict naming, and publishing a row in a non-routable table. - `src/__tests__/architecture/ai-mcp-connectors-never-leak.test.ts` gates the token-free connection projection. diff --git a/server/ai/mcp/registry.test.ts b/server/ai/mcp/registry.test.ts index e6794025c..c13dc447a 100644 --- a/server/ai/mcp/registry.test.ts +++ b/server/ai/mcp/registry.test.ts @@ -13,7 +13,9 @@ const FULL: Parameters[0] = [ 'content.create', 'content.edit.any', 'data.custom.tables.read', + 'data.custom.tables.manage', 'data.system.tables.read', + 'content.publish.any', 'media.read', 'media.write', ] @@ -89,4 +91,46 @@ describe('mcp registry', () => { expect(mcpToolsForCapabilities(FULL.filter((c) => c !== 'ai.tools.write')).map((t) => t.name)) .not.toContain('site_publish') }) + it('exposes the headless data toolset that reusable data tables need', () => { + const tools = mcpToolsForCapabilities(FULL) + const byName = new Map(tools.map((t) => [t.name, t])) + + // Issue #463 / #433: without these, a `kind: 'data'` table could not be + // listed, created, or written over MCP at all. + for (const name of [ + 'data_list_tables', + 'data_create_table', + 'data_update_table', + 'data_add_fields', + 'data_create_rows', + 'data_update_row', + 'data_set_rows_status', + 'data_delete_rows', + ]) { + expect(byName.get(name)).toBeTruthy() + // A data row is a grid of cells, not a Tiptap document — there is no + // editor surface to relay these through, and requiring one would make + // the whole toolset unusable from a headless agent. + expect(byName.get(name)!.execution).toBe('server') + } + }) + + it('drops every data write when ai.tools.write is absent, keeping the read', () => { + const readOnly = mcpToolsForCapabilities(FULL.filter((c) => c !== 'ai.tools.write')) + .map((t) => t.name) + expect(readOnly).toContain('data_list_tables') + expect(readOnly).not.toContain('data_create_table') + expect(readOnly).not.toContain('data_create_rows') + expect(readOnly).not.toContain('data_delete_rows') + }) + + it('gates schema writes on a table-manage capability, separately from row writes', () => { + const noTableManage = mcpToolsForCapabilities( + FULL.filter((c) => c !== 'data.custom.tables.manage'), + ).map((t) => t.name) + expect(noTableManage).not.toContain('data_create_table') + expect(noTableManage).not.toContain('data_add_fields') + // Row writes ride content.* capabilities, so they survive. + expect(noTableManage).toContain('data_create_rows') + }) }) diff --git a/server/ai/mcp/registry.ts b/server/ai/mcp/registry.ts index 3f8a011d0..e18c3946b 100644 --- a/server/ai/mcp/registry.ts +++ b/server/ai/mcp/registry.ts @@ -3,8 +3,9 @@ * filtered to the connector's granted capabilities. * * Two execution classes are exposed: - * - server-resolved tools (content reads + `site_list_documents` + - * `site_read_styles`) run in-process and work with NO editor open; + * - server-resolved tools (content reads, the `data_*` table and row tools, + * `site_list_documents`, `site_read_styles`) run in-process and work with + * NO editor open; * - browser tools (structure edits, HTML/CSS authoring, design tokens, page * lifecycle, content CRUD, code assets, live-DOM reads) are relayed to the * connector owner's matching open Site or Content workspace via the live @@ -26,6 +27,7 @@ import type { CoreCapability } from '@core/capabilities' import type { AiTool } from '../runtime/types' import { toolAllowedForCapabilities } from '../tools/capabilityGate' import { contentTools } from '../tools/content' +import { dataTools } from '../tools/data' import { siteTools } from '../tools/site' import { styleMcpTools } from './tools/styleTools' import { contextMcpTools } from './tools/contextTool' @@ -54,6 +56,11 @@ function allMcpTools(runtime?: McpPublishRuntime): AiTool[] { ...documentMcpTools, createPublishMcpTool(runtime), uploadMediaMcpTool, + // Schema + row writes for reusable data tables. Server-resolved, so they + // work with no workspace open — the Content workspace bridge only edits + // routable post types, and a `kind: 'data'` table has no editor to relay + // to. `DataToolsRuntime` is the same shape as `McpPublishRuntime`. + ...dataTools(runtime), ...contentTools, ...siteTools, ] diff --git a/server/ai/tools/content/readTools.ts b/server/ai/tools/content/readTools.ts index 5253de60e..041c51632 100644 --- a/server/ai/tools/content/readTools.ts +++ b/server/ai/tools/content/readTools.ts @@ -63,6 +63,11 @@ const SCHEMA_READ_CAPS: readonly CoreCapability[] = [ * workspaces and cannot be activated by the Content browser bridge. Keeping * this catalog aligned with the actual writable surface avoids advertising a * collection that every subsequent focus/write tool must reject. + * + * The filter stays, but it is no longer a dead end: reusable `kind: 'data'` + * tables are listed and written by the headless `data_*` toolset + * (`server/ai/tools/data/`), and the descriptions here say so. Widening this + * set instead would advertise tables the Tiptap editor cannot render. */ const CONTENT_KIND_VISIBLE: ReadonlySet = new Set(['postType']) @@ -130,7 +135,7 @@ const listCollectionsTool: AiTool = { execution: 'server', requiredCapabilities: SCHEMA_READ_CAPS, description: - 'List every Content-workspace collection (routable post types only) with id, slug, label, kind, row count, and primary field id. Pages are edited through Site tools; reusable tables through Data tools.', + 'List every Content-workspace collection (routable post types only) with id, slug, label, kind, row count, and primary field id. Pages are edited through the site_* tools; reusable data tables through the data_* tools — call data_list_tables to see those, they are NOT listed here.', inputSchema: ListCollectionsInput, handler: async (_input, ctx) => { const tables = await listDataTablesWithCounts(ctx.db, ctx.branch) @@ -156,7 +161,7 @@ const getCollectionSchemaTool: AiTool = { execution: 'server', requiredCapabilities: SCHEMA_READ_CAPS, description: - "Return one collection's field schema: each field's id, label, type, required flag, builtIn flag, and per-type extras (select options, media kind, relation target). Call BEFORE content_set_document_field on an unfamiliar collection so you know the field's value shape.", + "Return one collection's field schema: each field's id, label, type, required flag, builtIn flag, and per-type extras (select options, media kind, relation target). Call BEFORE content_set_document_field on an unfamiliar collection so you know the field's value shape. Accepts any table id, including a reusable data table that content_list_collections does not list — its rows are written with data_create_rows / data_update_row.", inputSchema: GetCollectionSchemaInput, handler: async (input, ctx) => { const { tableId } = input as Static @@ -273,7 +278,7 @@ const searchDocumentsTool: AiTool = { execution: 'server', requiredCapabilities: DOCUMENT_READ_CAPS, description: - "Full-text search across document slugs (the slug is a URL-safe derivative of the title — reliable text proxy for free-text lookup). Returns light summaries (id, tableId, slug, status, updatedAt). `limit` default 25, max 100.", + "Full-text search across document slugs (the slug is a URL-safe derivative of the title — reliable text proxy for free-text lookup) in post-type collections only; rows of a reusable data table are not searched here. Returns light summaries (id, tableId, slug, status, updatedAt). `limit` default 25, max 100.", inputSchema: SearchDocumentsInput, handler: async (input, ctx) => { const { query, limit } = input as Static diff --git a/server/ai/tools/content/systemPrompt.ts b/server/ai/tools/content/systemPrompt.ts index d103bf1bd..a73db4d64 100644 --- a/server/ai/tools/content/systemPrompt.ts +++ b/server/ai/tools/content/systemPrompt.ts @@ -14,6 +14,7 @@ const STATIC_PROMPT_PREFIX = `You manage the user's website content (posts, page Scope: - Each collection is a typed table of documents (posts, pages, or custom). Documents have a fixed schema: built-in fields (title, slug, body, featuredMedia, seoTitle, seoDescription) plus any custom fields. +- Reusable data tables (kind 'data' — a training catalogue, a team roster, anything a page loops over) are NOT collections here and never appear in content_list_collections. They are edited in the Data workspace, and by the data_* tools where those are offered (data_list_tables, data_create_table, data_add_fields, data_create_rows, data_update_row, data_set_rows_status, data_delete_rows). If the request is about structured records rather than a written post and you have no data_* tool in this session, say so instead of creating a post type to stand in for the table. - The active document is the one currently open in the editor. Most edits target it; call content_set_active_document to switch the user's view before editing another doc. - Body content is exchanged as **markdown**. Use standard markdown (headings, paragraphs, lists, links, bold/italic, code, blockquotes) — the bridge converts to the editor's internal format on write. diff --git a/server/ai/tools/content/writeTools.ts b/server/ai/tools/content/writeTools.ts index c6e553aee..c0d40422e 100644 --- a/server/ai/tools/content/writeTools.ts +++ b/server/ai/tools/content/writeTools.ts @@ -67,7 +67,7 @@ const createDocumentTool: AiTool = { execution: 'browser', requiredCapabilities: ['content.create'], description: - "Create a new draft document in `tableId`. `fields` is a Record per the collection's schema; omit to create an empty draft. Success data includes the new id as `documentId`; the bridge auto-switches the user's editor to the new doc so they can see what you built. Use content_set_document_status separately to publish or schedule it.", + "Create a new draft document in a POST TYPE (`tableId` must be one content_list_collections returned). `fields` is a Record per the collection's schema; omit to create an empty draft. Success data includes the new id as `documentId`; the bridge auto-switches the user's editor to the new doc so they can see what you built. Use content_set_document_status separately to publish or schedule it. For a row in a reusable data table, use data_create_rows instead — this tool needs the Content editor open and cannot write one.", inputSchema: CreateDocumentInput, } @@ -197,7 +197,7 @@ const setActiveCollectionTool: AiTool = { scope: 'content', execution: 'browser', description: - 'Switch the workspace sidebar focus to this collection. Use when working across collection-level actions (browsing, bulk reviews).', + 'Switch the workspace sidebar focus to this collection. Use when working across collection-level actions (browsing, bulk reviews). Only post types can be focused; a reusable data table is refused with a message naming the data_* tool that can write it.', inputSchema: SetActiveCollectionInput, } diff --git a/server/ai/tools/data/access.ts b/server/ai/tools/data/access.ts new file mode 100644 index 000000000..2762cb42e --- /dev/null +++ b/server/ai/tools/data/access.ts @@ -0,0 +1,24 @@ +/** + * Capability adapter for the data toolset. + * + * The HTTP data routes decide per-table and per-row access with the predicates + * in `server/handlers/cms/data/access.ts`, which read an `AuthUser`. A tool + * handler never has one — it has a `ToolContext` carrying `userId` and the + * caller's capability set. `toolActor` projects that context into the shape + * those predicates read, so the MCP surface and the HTTP surface answer the + * same question the same way instead of growing a second copy of the rules + * that drifts. + * + * Two gates, both needed. A tool's `requiredCapabilities` is the coarse one: + * it decides whether the tool is offered to this caller at all. The predicates + * below are the fine one: which table or row this particular caller may touch. + */ +import type { AuthUser } from '../../../repositories/users' +import type { ToolContext } from '../../runtime/types' + +export type ToolActor = Pick + +export function toolActor(ctx: ToolContext): ToolActor { + // Copied, not aliased: `ctx.capabilities` is readonly and `AuthUser`'s is not. + return { id: ctx.userId, capabilities: [...ctx.capabilities] } +} diff --git a/server/ai/tools/data/index.ts b/server/ai/tools/data/index.ts new file mode 100644 index 000000000..008114f96 --- /dev/null +++ b/server/ai/tools/data/index.ts @@ -0,0 +1,28 @@ +/** + * Data-scope tool barrel. + * + * Everything here is `execution: 'server'` and works with no workspace tab + * open, which is the point: reusable data tables are edited in a grid, not in + * the Tiptap editor that forces the `content_*` writes through the browser + * bridge. See `schemaTools.ts` for the full reasoning and the step-up caveat. + * + * A factory, not a constant, because the row tools need per-connection context + * (`DataToolsRuntime`) the MCP server owns. Call it with no argument for the + * in-app agent path. + */ + +import type { AiTool } from '../types' +import { dataLifecycleTools } from './lifecycleTools' +import { dataRowTools } from './rowTools' +import { dataSchemaTools } from './schemaTools' +import type { DataToolsRuntime } from './runtime' + +export function dataTools(runtime?: DataToolsRuntime): AiTool[] { + return [ + ...dataSchemaTools(runtime), + ...dataRowTools(runtime), + ...dataLifecycleTools(runtime), + ] +} + +export type { DataToolsRuntime } from './runtime' diff --git a/server/ai/tools/data/lifecycleTools.test.ts b/server/ai/tools/data/lifecycleTools.test.ts new file mode 100644 index 000000000..ab7b1679a --- /dev/null +++ b/server/ai/tools/data/lifecycleTools.test.ts @@ -0,0 +1,219 @@ +/** + * Publish / retract / delete behaviour against a real migrated SQLite database. + * + * The case worth pinning is publishing a row in a table with no route base: + * that is the default shape of a reusable data table, and if the publisher + * refused it the whole point of issue #463 — feed a loop from an agent-written + * table — would not work. The rest guards the partial-failure contract, which + * differs from the all-or-nothing contract of `data_create_rows`. + */ +import { beforeEach, describe, expect, it } from 'bun:test' +import type { CoreCapability } from '@core/capabilities' +import type { DbClient } from '../../../db/client' +import { createSqliteClient } from '../../../db/sqlite' +import { sqliteMigrations } from '../../../db/migrations-sqlite' +import { runMigrations } from '../../../db/runMigrations' +import { listAuditEvents } from '../../../repositories/audit' +import { getDataRow, listDataRows } from '../../../repositories/data' +import type { AiTool, ToolContext } from '../../runtime/types' +import { dataTools } from './index' + +const FULL_CAPS: CoreCapability[] = [ + 'content.create', + 'content.manage', + 'content.publish.any', + 'data.custom.tables.read', + 'data.custom.tables.manage', +] + +/** May write and retract rows, but never publish one. */ +const NO_PUBLISH_CAPS: CoreCapability[] = [ + 'content.create', + 'content.edit.any', + 'data.custom.tables.read', +] + +async function freshDb(): Promise { + const db = createSqliteClient(':memory:') + await runMigrations(db, sqliteMigrations) + await db` + insert into users (id, email, email_normalized, display_name, password_hash, role_id) + values ('user-1', 'u1@example.com', 'u1@example.com', 'User One', 'x', 'owner') + ` + return db +} + +function toolByName(name: string): AiTool { + // No `uploadsDir`, so nothing tries to touch the disk: these assert database + // state, and the artefact writer is exercised by the publisher's own tests. + const tool = dataTools().find((candidate) => candidate.name === name) + if (!tool) throw new Error(`tool ${name} is not registered`) + return tool +} + +function run( + name: string, + input: Record, + db: DbClient, + capabilities: CoreCapability[] = FULL_CAPS, +): Promise { + const ctx: ToolContext = { + db, + userId: 'user-1', + capabilities, + scope: 'data', + conversationId: 'conversation-1', + snapshot: null, + signal: new AbortController().signal, + } + return toolByName(name).handler!(input, ctx) +} + +interface Seeded { + tableId: string + rowIds: string[] +} + +/** A `kind: 'data'` table — no route base, which is the case under test. */ +async function seedTrainings(db: DbClient, count = 2): Promise { + const created = await run('data_create_table', { + name: 'Trainings', + fields: [ + { id: 'name', label: 'Name', type: 'text' }, + { id: 'slug', label: 'Slug', type: 'text' }, + ], + primaryFieldId: 'name', + }, db) as { table: { id: string; routeBase: string } } + expect(created.table.routeBase).toBe('') + + const rows = await run('data_create_rows', { + tableId: created.table.id, + rows: Array.from({ length: count }, (_, i) => ({ + cells: { name: `Training ${i}`, slug: `training-${i}` }, + })), + }, db) as { rows: Array<{ id: string }> } + + return { tableId: created.table.id, rowIds: rows.rows.map((row) => row.id) } +} + +describe('data_set_rows_status', () => { + let db: DbClient + let seeded: Seeded + + beforeEach(async () => { + db = await freshDb() + seeded = await seedTrainings(db) + }) + + it('publishes rows in a table that has no public route base', async () => { + const result = await run('data_set_rows_status', { + rowIds: seeded.rowIds, + status: 'published', + }, db) as { updated: Array<{ id: string; status: string }>; failed: unknown[] } + + expect(result.failed).toHaveLength(0) + expect(result.updated.map((row) => row.status)).toEqual(['published', 'published']) + // What makes a loop on another page pick the row up. + const stored = await getDataRow(db, seeded.rowIds[0]) + expect(stored!.status).toBe('published') + }) + + it('retracts a published row back to draft', async () => { + await run('data_set_rows_status', { rowIds: seeded.rowIds, status: 'published' }, db) + + const result = await run('data_set_rows_status', { + rowIds: [seeded.rowIds[0]], + status: 'draft', + }, db) as { updated: Array<{ status: string }> } + + expect(result.updated[0].status).toBe('draft') + expect((await getDataRow(db, seeded.rowIds[1]))!.status).toBe('published') + }) + + it('reports the rows it could not touch and still applies the rest', async () => { + const result = await run('data_set_rows_status', { + rowIds: [seeded.rowIds[0], 'missing-row'], + status: 'published', + }, db) as { + updated: Array<{ id: string }> + failed: Array<{ rowId: string; error: string }> + } + + expect(result.updated.map((row) => row.id)).toEqual([seeded.rowIds[0]]) + expect(result.failed).toHaveLength(1) + expect(result.failed[0].rowId).toBe('missing-row') + expect(result.failed[0].error).toMatch(/not found/) + }) + + it('refuses to publish for a caller with edit but no publish capability', async () => { + const result = await run('data_set_rows_status', { + rowIds: seeded.rowIds, + status: 'published', + }, db, NO_PUBLISH_CAPS) as { updated: unknown[]; failed: unknown[] } + + expect(result.updated).toHaveLength(0) + expect(result.failed).toHaveLength(2) + expect((await getDataRow(db, seeded.rowIds[0]))!.status).toBe('draft') + }) + + it('audits a publish distinctly from a retraction', async () => { + await run('data_set_rows_status', { rowIds: [seeded.rowIds[0]], status: 'published' }, db) + await run('data_set_rows_status', { rowIds: [seeded.rowIds[0]], status: 'unpublished' }, db) + + const actions = (await listAuditEvents(db)).map((event) => event.action) + expect(actions).toContain('data.row.publish') + expect(actions).toContain('data.row.status') + }) +}) + +describe('data_delete_rows', () => { + let db: DbClient + let seeded: Seeded + + beforeEach(async () => { + db = await freshDb() + seeded = await seedTrainings(db, 3) + }) + + it('soft-deletes the rows it was given and leaves the others', async () => { + const result = await run('data_delete_rows', { + rowIds: seeded.rowIds.slice(0, 2), + }, db) as { deleted: Array<{ id: string }>; failed: unknown[] } + + expect(result.deleted).toHaveLength(2) + expect(result.failed).toHaveLength(0) + const remaining = await listDataRows(db, seeded.tableId) + expect(remaining.map((row) => row.id)).toEqual([seeded.rowIds[2]]) + // Soft, not hard — the row is gone from every listing but still stored. + expect(await getDataRow(db, seeded.rowIds[0])).toBeNull() + }) + + it('deletes a published row, which retracts its public route', async () => { + await run('data_set_rows_status', { rowIds: [seeded.rowIds[0]], status: 'published' }, db) + + const result = await run('data_delete_rows', { rowIds: [seeded.rowIds[0]] }, db) as { + deleted: unknown[] + } + expect(result.deleted).toHaveLength(1) + expect(await listDataRows(db, seeded.tableId)).toHaveLength(2) + }) + + it('reports unknown ids without blocking the deletable ones', async () => { + const result = await run('data_delete_rows', { + rowIds: [seeded.rowIds[0], 'missing-row'], + }, db) as { deleted: Array<{ id: string }>; failed: Array<{ rowId: string }> } + + expect(result.deleted.map((row) => row.id)).toEqual([seeded.rowIds[0]]) + expect(result.failed.map((row) => row.rowId)).toEqual(['missing-row']) + }) + + it('writes nothing when no row is deletable', async () => { + const result = await run('data_delete_rows', { rowIds: ['a', 'b'] }, db) as { + deleted: unknown[] + failed: unknown[] + } + expect(result.deleted).toHaveLength(0) + expect(result.failed).toHaveLength(2) + expect(await listDataRows(db, seeded.tableId)).toHaveLength(3) + }) +}) diff --git a/server/ai/tools/data/lifecycleTools.ts b/server/ai/tools/data/lifecycleTools.ts new file mode 100644 index 000000000..1457a705c --- /dev/null +++ b/server/ai/tools/data/lifecycleTools.ts @@ -0,0 +1,242 @@ +/** + * Publication state and removal for data rows. + * + * Both tools are bulk and both report per-row outcomes instead of failing the + * whole call, which is the opposite of `data_create_rows`. The reason is what + * each operation touches. A create is one transaction over rows that do not + * exist yet, so all-or-nothing costs nothing and spares the caller a partial + * table. Publishing is not transactional at all: each row takes the publish + * lock, bakes its own static artefact, and bumps the publish version, so by + * the time row 7 fails, rows 1–6 are already live on disk. Pretending that + * away with a single error would leave the caller with no idea what shipped. + * + * Publishing a row in a non-routable table (`routeBase === ''`, the default + * for `kind: 'data'`) is deliberately allowed. No artefact is baked because + * there is no route to bake it at, but the row still becomes `published`, + * which is exactly what an `` on some other page reads. That is + * the normal shape for a reusable table: the rows are content, the page that + * lists them owns the URL. + */ +import { Type, type Static } from '@core/utils/typeboxHelpers' +import type { CoreCapability } from '@core/capabilities' +import type { DataRow, DataRowStatus } from '@core/data/schemas' +import type { AiTool, ToolContext } from '../../runtime/types' +import { createAuditEvent, type AuditAction } from '../../../repositories/audit' +import { + getDataRow, + softDeleteDataRowMany, + updateDataRowStatus, +} from '../../../repositories/data' +import { publishDataRow, removeDataRowArtefact } from '../../../publish/publishRow' +import { bumpPublishVersionSerialized } from '../../../publish/publishState' +import { emitContentEntryDeleted, emitContentEntryUpdated } from '../../../publish/contentEvents' +import { canEditDataRow, canPublishDataRow } from '../../../handlers/cms/data/access' +import { toolActor } from './access' +import type { DataToolsRuntime } from './runtime' + +/** + * The union of the HTTP surface's two gates: publishing needs a publish cap, + * retracting needs an edit cap. Which one applies is decided per row, by the + * requested status, in the handler. + */ +const ROW_LIFECYCLE_CAPS: CoreCapability[] = [ + 'content.publish.own', + 'content.publish.any', + 'content.edit.own', + 'content.edit.any', + 'content.manage', +] + +const ROW_DELETE_CAPS: CoreCapability[] = ['content.edit.own', 'content.edit.any', 'content.manage'] + +const MAX_ROWS_PER_CALL = 200 + +const RowIds = Type.Array(Type.String({ minLength: 1 }), { + minItems: 1, + maxItems: MAX_ROWS_PER_CALL, +}) + +interface RowFailure { + rowId: string + error: string +} + +// --------------------------------------------------------------------------- +// data_set_rows_status +// --------------------------------------------------------------------------- + +const SetRowsStatusInput = Type.Object({ + rowIds: RowIds, + status: Type.Union([ + Type.Literal('published'), + Type.Literal('draft'), + Type.Literal('unpublished'), + ]), +}, { additionalProperties: false }) + +function setRowsStatusTool(runtime?: DataToolsRuntime): AiTool { + return { + name: 'data_set_rows_status', + scope: 'data', + execution: 'server', + mutates: true, + requiredCapabilities: ROW_LIFECYCLE_CAPS, + description: + `Publish, unpublish, or return to draft up to ${MAX_ROWS_PER_CALL} rows. Rows are processed one by one and the result lists what succeeded and what did not — a failure part-way through does not roll back the rows already published. Publishing a row in a table with no route base still makes it visible to loops on other pages; it just gets no public URL of its own.`, + inputSchema: SetRowsStatusInput, + handler: async (input, ctx: ToolContext) => { + const args = input as Static + const updated: Array<{ id: string; slug: string; status: DataRowStatus }> = [] + const failed: RowFailure[] = [] + + for (const rowId of args.rowIds) { + const current = await getDataRow(ctx.db, rowId) + // Publishing and retracting are separate permissions on the HTTP + // surface, so the per-row check has to follow the requested status + // rather than the tool's coarse capability gate. + const allowed = current && (args.status === 'published' + ? canPublishDataRow(toolActor(ctx), current) + : canEditDataRow(toolActor(ctx), current)) + if (!current || !allowed) { + failed.push({ rowId, error: `Row ${rowId} not found.` }) + continue + } + + try { + const row = args.status === 'published' + ? (await publishDataRow(ctx.db, rowId, ctx.userId, runtime?.uploadsDir)).row + : await retractRow(ctx, runtime, rowId, args.status) + if (!row) { + failed.push({ rowId, error: `Row ${rowId} not found.` }) + continue + } + await emitContentEntryUpdated(ctx.db, row.id, ['status'], { + kind: 'user', + userId: ctx.userId, + }) + await recordRowAudit( + ctx, + runtime, + args.status === 'published' ? 'data.row.publish' : 'data.row.status', + row, + { status: args.status }, + ) + updated.push({ id: row.id, slug: row.slug, status: row.status }) + } catch (err) { + failed.push({ rowId, error: err instanceof Error ? err.message : String(err) }) + } + } + + return { updated, failed } + }, + } +} + +/** + * Leave public visibility. Both `draft` and `unpublished` retract the row, so + * the baked artefact has to go with it — Layer A serves the disk slot with no + * database awareness and would keep answering for a retracted row. + */ +async function retractRow( + ctx: ToolContext, + runtime: DataToolsRuntime | undefined, + rowId: string, + status: 'draft' | 'unpublished', +): Promise { + const row = await updateDataRowStatus(ctx.db, rowId, status, ctx.userId) + if (!row) return null + if (runtime?.uploadsDir) { + await removeDataRowArtefact(ctx.db, runtime.uploadsDir, rowId, row.slug).catch((err) => { + console.error('[ai:data] failed to remove artefact for retracted row', rowId, err) + }) + } + return row +} + +// --------------------------------------------------------------------------- +// data_delete_rows +// --------------------------------------------------------------------------- + +const DeleteRowsInput = Type.Object({ + rowIds: RowIds, +}, { additionalProperties: false }) + +function deleteRowsTool(runtime?: DataToolsRuntime): AiTool { + return { + name: 'data_delete_rows', + scope: 'data', + execution: 'server', + mutates: true, + requiredCapabilities: ROW_DELETE_CAPS, + description: + `Delete up to ${MAX_ROWS_PER_CALL} rows. The delete is soft — the rows stop being served and stop appearing anywhere, but stay recoverable in the database. Rows the caller may not edit are reported in \`failed\` and the rest are still deleted.`, + inputSchema: DeleteRowsInput, + handler: async (input, ctx: ToolContext) => { + const args = input as Static + const deletable: DataRow[] = [] + const failed: RowFailure[] = [] + + for (const rowId of args.rowIds) { + const row = await getDataRow(ctx.db, rowId) + if (!row || !canEditDataRow(toolActor(ctx), row)) { + failed.push({ rowId, error: `Row ${rowId} not found.` }) + continue + } + deletable.push(row) + } + + if (deletable.length === 0) return { deleted: [], failed } + + const result = await softDeleteDataRowMany( + ctx.db, + deletable.map((row) => row.id), + ctx.userId, + ) + + // The artefact prune and the cache bump both run after the transaction + // commits: the bump serializes on the publish lock, which must never be + // taken from inside a write transaction. + for (const row of deletable) { + if (runtime?.uploadsDir) { + await removeDataRowArtefact(ctx.db, runtime.uploadsDir, row.id, row.slug).catch((err) => { + console.error('[ai:data] failed to remove artefact for deleted row', row.id, err) + }) + } + await emitContentEntryDeleted(ctx.db, row.id, { kind: 'user', userId: ctx.userId }) + await recordRowAudit(ctx, runtime, 'data.row.delete', row) + } + if (result.publishedDeleted > 0) await bumpPublishVersionSerialized() + + return { deleted: deletable.map((row) => ({ id: row.id, slug: row.slug })), failed } + }, + } +} + +// --------------------------------------------------------------------------- +// Shared +// --------------------------------------------------------------------------- + +async function recordRowAudit( + ctx: ToolContext, + runtime: DataToolsRuntime | undefined, + action: AuditAction, + row: Pick, + extra: Record = {}, +): Promise { + await createAuditEvent(ctx.db, { + actorUserId: ctx.userId, + action, + targetType: 'data_row', + targetId: row.id, + metadata: { + tableId: row.tableId, + slug: row.slug, + ...extra, + ...(runtime ? { source: 'mcp', connectorId: runtime.connectorId } : { source: 'agent' }), + }, + }) +} + +export function dataLifecycleTools(runtime?: DataToolsRuntime): AiTool[] { + return [setRowsStatusTool(runtime), deleteRowsTool(runtime)] +} diff --git a/server/ai/tools/data/rowTools.test.ts b/server/ai/tools/data/rowTools.test.ts new file mode 100644 index 000000000..a034f88ac --- /dev/null +++ b/server/ai/tools/data/rowTools.test.ts @@ -0,0 +1,255 @@ +/** + * Row-tool behaviour against a real migrated SQLite database. + * + * Row writes are the half of issue #463 that had no path at all over MCP, so + * these assert the parts that make the path usable rather than merely present: + * the batch is transactional, a slug conflict is named instead of surfacing as + * a driver error, and the merge default lets an agent set one cell without + * re-sending the row. + */ +import { beforeEach, describe, expect, it } from 'bun:test' +import type { CoreCapability } from '@core/capabilities' +import type { DbClient } from '../../../db/client' +import { createSqliteClient } from '../../../db/sqlite' +import { sqliteMigrations } from '../../../db/migrations-sqlite' +import { runMigrations } from '../../../db/runMigrations' +import { listAuditEvents } from '../../../repositories/audit' +import { getDataRow, listDataRows } from '../../../repositories/data' +import type { AiTool, ToolContext } from '../../runtime/types' +import { dataTools } from './index' + +const FULL_CAPS: CoreCapability[] = [ + 'ai.chat', + 'ai.tools.write', + 'content.create', + 'content.manage', + 'data.custom.tables.read', + 'data.custom.tables.manage', + 'data.system.tables.read', +] + +/** Can create a row but not edit one — the split `requireDataCreator` enforces. */ +const CREATE_ONLY_CAPS: CoreCapability[] = ['content.create', 'data.custom.tables.read'] + +async function freshDb(): Promise { + const db = createSqliteClient(':memory:') + await runMigrations(db, sqliteMigrations) + await db` + insert into users (id, email, email_normalized, display_name, password_hash, role_id) + values ('user-1', 'u1@example.com', 'u1@example.com', 'User One', 'x', 'owner') + ` + return db +} + +function toolByName(name: string): AiTool { + const tool = dataTools({ connectorId: 'connector-1', uploadsDir: '/tmp/uploads' }) + .find((candidate) => candidate.name === name) + if (!tool) throw new Error(`tool ${name} is not registered`) + return tool +} + +function run( + name: string, + input: Record, + db: DbClient, + capabilities: CoreCapability[] = FULL_CAPS, +): Promise { + const ctx: ToolContext = { + db, + userId: 'user-1', + capabilities, + scope: 'data', + conversationId: 'conversation-1', + snapshot: null, + signal: new AbortController().signal, + } + return toolByName(name).handler!(input, ctx) +} + +/** A table with a `slug` field, so slug derivation and its unique index apply. */ +async function trainingsTable(db: DbClient): Promise { + const created = await run('data_create_table', { + name: 'Trainings', + fields: [ + { id: 'name', label: 'Name', type: 'text' }, + { id: 'slug', label: 'Slug', type: 'text' }, + { id: 'price', label: 'Price', type: 'number' }, + ], + primaryFieldId: 'name', + }, db) as { table: { id: string } } + return created.table.id +} + +describe('data_create_rows', () => { + let db: DbClient + let tableId: string + + beforeEach(async () => { + db = await freshDb() + tableId = await trainingsTable(db) + }) + + it('writes a whole batch and returns the rows as stored', async () => { + const result = await run('data_create_rows', { + tableId, + rows: [ + { cells: { name: 'Basics', slug: 'basics', price: 100 } }, + { cells: { name: 'Advanced', slug: 'advanced', price: 200 } }, + { cells: { name: 'Expert', slug: 'expert', price: 300 } }, + ], + }, db) as { rows: Array<{ id: string; slug: string; status: string; cells: Record }> } + + expect(result.rows).toHaveLength(3) + expect(result.rows.map((row) => row.slug)).toEqual(['basics', 'advanced', 'expert']) + // Rows land as drafts; publishing is an explicit second call. + expect(result.rows.every((row) => row.status === 'draft')).toBe(true) + expect(result.rows[0].cells.price).toBe(100) + + const stored = await listDataRows(db, tableId) + expect(stored).toHaveLength(3) + }) + + it('aborts the entire batch when two rows in it share a slug', async () => { + const result = await run('data_create_rows', { + tableId, + rows: [ + { cells: { name: 'Basics', slug: 'basics' } }, + { cells: { name: 'Basics again', slug: 'basics' } }, + { cells: { name: 'Advanced', slug: 'advanced' } }, + ], + }, db) as { ok: boolean; error: string } + + expect(result.ok).toBe(false) + expect(result.error).toMatch(/used twice in this batch/) + // Not even the first, valid row was written. + expect(await listDataRows(db, tableId)).toHaveLength(0) + }) + + it('names a collision with a row already in the table', async () => { + await run('data_create_rows', { + tableId, + rows: [{ cells: { name: 'Basics', slug: 'basics' } }], + }, db) + + const result = await run('data_create_rows', { + tableId, + rows: [{ cells: { name: 'Basics reloaded', slug: 'basics' } }], + }, db) as { ok: boolean; error: string } + + expect(result.ok).toBe(false) + expect(result.error).toMatch(/already exists in this table/) + expect(await listDataRows(db, tableId)).toHaveLength(1) + }) + + it('refuses a cell that targets an editor-managed built-in field', async () => { + // `pages` stores its tree in a built-in the visual editor owns; a row + // created through the generic path must not be able to seed it. + const result = await run('data_create_rows', { + tableId: 'pages', + rows: [{ cells: { title: 'Home', tree: {} } }], + }, db) as { ok: boolean; error: string } + + expect(result.ok).toBe(false) + expect(result.error).toMatch(/managed by the editor/) + }) + + it('reports an unknown table as not found', async () => { + const result = await run('data_create_rows', { + tableId: 'nope', + rows: [{ cells: { name: 'X' } }], + }, db) as { ok: boolean; error: string } + expect(result.ok).toBe(false) + expect(result.error).toMatch(/not found/) + }) + + it('records one audit event per created row, carrying the connector id', async () => { + await run('data_create_rows', { + tableId, + rows: [ + { cells: { name: 'Basics', slug: 'basics' } }, + { cells: { name: 'Advanced', slug: 'advanced' } }, + ], + }, db) + + const created = (await listAuditEvents(db)).filter((e) => e.action === 'data.row.create') + expect(created).toHaveLength(2) + expect(created[0].metadata).toMatchObject({ tableId, source: 'mcp', connectorId: 'connector-1' }) + }) +}) + +describe('data_update_row', () => { + let db: DbClient + let tableId: string + let rowId: string + + beforeEach(async () => { + db = await freshDb() + tableId = await trainingsTable(db) + const created = await run('data_create_rows', { + tableId, + rows: [{ cells: { name: 'Basics', slug: 'basics', price: 100 } }], + }, db) as { rows: Array<{ id: string }> } + rowId = created.rows[0].id + }) + + it('merges by default, leaving untouched cells alone', async () => { + const result = await run('data_update_row', { + rowId, + cells: { price: 150 }, + }, db) as { row: { cells: Record; slug: string } } + + expect(result.row.cells.price).toBe(150) + expect(result.row.cells.name).toBe('Basics') + expect(result.row.slug).toBe('basics') + }) + + it('replaces the whole cell set when merge is false', async () => { + const result = await run('data_update_row', { + rowId, + cells: { name: 'Basics', slug: 'basics' }, + merge: false, + }, db) as { row: { cells: Record } } + + expect(result.row.cells.price).toBeUndefined() + }) + + it('re-derives the slug when the slug cell changes', async () => { + const result = await run('data_update_row', { + rowId, + cells: { slug: 'Basics Reloaded' }, + }, db) as { row: { slug: string } } + + expect(result.row.slug).toBe('basics-reloaded') + const stored = await getDataRow(db, rowId) + expect(stored!.slug).toBe('basics-reloaded') + }) + + it('names a slug collision with a sibling row', async () => { + await run('data_create_rows', { + tableId, + rows: [{ cells: { name: 'Advanced', slug: 'advanced' } }], + }, db) + + const result = await run('data_update_row', { + rowId, + cells: { slug: 'advanced' }, + }, db) as { ok: boolean; error: string } + + expect(result.ok).toBe(false) + expect(result.error).toMatch(/already exists in this table/) + const stored = await getDataRow(db, rowId) + expect(stored!.slug).toBe('basics') + }) + + it("reports a row the caller may not edit as not found", async () => { + const result = await run('data_update_row', { + rowId, + cells: { price: 999 }, + }, db, CREATE_ONLY_CAPS) as { ok: boolean; error: string } + + expect(result.ok).toBe(false) + expect(result.error).toMatch(/not found/) + const stored = await getDataRow(db, rowId) + expect(stored!.cells.price).toBe(100) + }) +}) diff --git a/server/ai/tools/data/rowTools.ts b/server/ai/tools/data/rowTools.ts new file mode 100644 index 000000000..7d1be6db7 --- /dev/null +++ b/server/ai/tools/data/rowTools.ts @@ -0,0 +1,248 @@ +/** + * Row read/write tools for reusable data tables. + * + * The `content_*` toolset writes a row by driving the open Content workspace + * through the editor bridge, because a post's body is a Tiptap document only + * the editor can render. A `kind: 'data'` row has no body — it is a bag of + * typed cells edited in a grid — so routing its writes through a browser tab + * would buy nothing and would make the whole toolset unusable from a headless + * agent. These run in-process instead, reusing the same repository calls, + * plugin filters, slug derivation, and access predicates the HTTP row + * endpoints use, so a write over MCP and a write from the Data workspace + * cannot diverge. + * + * Creates are bulk by default: `createDataRowMany` puts the whole batch in one + * transaction, so an agent seeding a table either gets every row or none, and + * never a half-filled table it has to reconcile by hand. + */ +import { Type, type Static } from '@core/utils/typeboxHelpers' +import type { CoreCapability } from '@core/capabilities' +import type { DataRow } from '@core/data/schemas' +import { slugForTable } from '@core/data/cells' +import { protectedBuiltInCreateCellKey } from '@core/data/systemTableGuard' +import type { AiTool, ToolContext } from '../../runtime/types' +import { createAuditEvent, type AuditAction } from '../../../repositories/audit' +import { + createDataRowMany, + getDataRow, + getDataRowBySlug, + getDataTable, + saveDataRowDraft, +} from '../../../repositories/data' +import { + applyContentEntryCellsFilter, + emitContentEntryCreated, + emitContentEntryUpdated, +} from '../../../publish/contentEvents' +import { canEditDataRow, canReadTable } from '../../../handlers/cms/data/access' +import { toolActor } from './access' +import type { DataToolsRuntime } from './runtime' + +/** Mirrors `requireDataCreator` — creating a row is one capability, not a family. */ +const ROW_CREATE_CAPS: CoreCapability[] = ['content.create'] + +/** Mirrors `DATA_EDIT_CAPABILITIES`; the per-row owner check runs in the handler. */ +const ROW_EDIT_CAPS: CoreCapability[] = ['content.edit.own', 'content.edit.any', 'content.manage'] + +/** + * One transaction's worth of rows. High enough that seeding a real catalogue + * is a single call, low enough that a runaway generation cannot hold the write + * lock (`serializeCollabAwareWrite`) for an unbounded stretch. + */ +const MAX_ROWS_PER_CALL = 200 + +const CellsSchema = Type.Record(Type.String(), Type.Unknown(), { + description: 'Cell values keyed by field id, as returned by data_list_tables.', +}) + +// --------------------------------------------------------------------------- +// data_create_rows +// --------------------------------------------------------------------------- + +const CreateRowsInput = Type.Object({ + tableId: Type.String({ minLength: 1 }), + rows: Type.Array(Type.Object({ cells: CellsSchema }, { additionalProperties: false }), { + minItems: 1, + maxItems: MAX_ROWS_PER_CALL, + }), +}, { additionalProperties: false }) + +function createRowsTool(runtime?: DataToolsRuntime): AiTool { + return { + name: 'data_create_rows', + scope: 'data', + execution: 'server', + mutates: true, + requiredCapabilities: ROW_CREATE_CAPS, + description: + `Create up to ${MAX_ROWS_PER_CALL} rows in one table, in a single transaction — if any row is rejected, none are written. Cells are keyed by field id (call data_list_tables for a table's fields). Rows land as drafts; publish them with data_set_rows_status. Headless — no editor needed.`, + inputSchema: CreateRowsInput, + handler: async (input, ctx: ToolContext) => { + const args = input as Static + const table = await getDataTable(ctx.db, args.tableId) + if (!table || !canReadTable(toolActor(ctx), table)) { + return { ok: false, error: `Table ${args.tableId} not found.` } + } + + const prepared: Array<{ cells: Record; slug: string }> = [] + // Slugs are checked against the batch as well as against the table: the + // unique index would otherwise abort the transaction mid-way with a + // driver error, leaving the caller no way to tell which row caused it. + const batchSlugs = new Set() + + for (const [index, row] of args.rows.entries()) { + const locked = protectedBuiltInCreateCellKey(table, row.cells) + if (locked) { + return { + ok: false, + error: `Row ${index}: the "${locked}" field is managed by the editor and can't be set here.`, + } + } + + const cells = await applyContentEntryCellsFilter(row.cells, { + tableSlug: table.slug, + entryId: 'new', + actor: { kind: 'user', userId: ctx.userId }, + }) + const slug = slugForTable(table, cells) + + if (slug) { + if (batchSlugs.has(slug)) { + return { ok: false, error: `Row ${index}: slug "${slug}" is used twice in this batch.` } + } + const clash = await getDataRowBySlug(ctx.db, table.id, slug) + if (clash) { + return { + ok: false, + error: `Row ${index}: a row with slug "${slug}" already exists in this table (id ${clash.id}).`, + } + } + batchSlugs.add(slug) + } + + prepared.push({ cells, slug }) + } + + const created = await createDataRowMany( + ctx.db, + prepared.map((row) => ({ tableId: table.id, cells: row.cells, slug: row.slug })), + ctx.userId, + ) + + for (const row of created) { + await emitContentEntryCreated(ctx.db, row.id, { kind: 'user', userId: ctx.userId }) + await recordRowAudit(ctx, runtime, 'data.row.create', row) + } + + return { rows: created.map(projectRow) } + }, + } +} + +// --------------------------------------------------------------------------- +// data_update_row +// --------------------------------------------------------------------------- + +const UpdateRowInput = Type.Object({ + rowId: Type.String({ minLength: 1 }), + cells: CellsSchema, + merge: Type.Optional(Type.Boolean({ + description: 'Default true: patch only the cells given. Set false to replace the whole cell set.', + })), +}, { additionalProperties: false }) + +function updateRowTool(runtime?: DataToolsRuntime): AiTool { + return { + name: 'data_update_row', + scope: 'data', + execution: 'server', + mutates: true, + requiredCapabilities: ROW_EDIT_CAPS, + description: + "Change one row's cells. By default the given cells are merged into what is already stored, so you can set a single field without re-sending the rest; pass merge: false to replace the whole cell set. Editing a published row writes its draft — call data_set_rows_status to publish the change. Headless — no editor needed.", + inputSchema: UpdateRowInput, + handler: async (input, ctx: ToolContext) => { + const args = input as Static + const current = await getDataRow(ctx.db, args.rowId) + if (!current || !canEditDataRow(toolActor(ctx), current)) { + return { ok: false, error: `Row ${args.rowId} not found.` } + } + + const table = await getDataTable(ctx.db, current.tableId) + if (!table) return { ok: false, error: `Row ${args.rowId} not found.` } + + const rawCells = args.merge === false ? args.cells : { ...current.cells, ...args.cells } + const cells = await applyContentEntryCellsFilter(rawCells, { + tableSlug: table.slug, + entryId: current.id, + actor: { kind: 'user', userId: ctx.userId }, + }) + const slug = slugForTable(table, cells) + + if (slug && slug !== current.slug) { + const clash = await getDataRowBySlug(ctx.db, table.id, slug) + if (clash && clash.id !== current.id) { + return { + ok: false, + error: `A row with slug "${slug}" already exists in this table (id ${clash.id}).`, + } + } + } + + const row = await saveDataRowDraft(ctx.db, current.id, { cells, slug }, ctx.userId) + if (!row) return { ok: false, error: `Row ${args.rowId} not found.` } + + // Plugins loop-guard on this list, so it must include the keys the + // filter itself rewrote, not only the keys the caller sent. + const changedIds = [...new Set([ + ...Object.keys(args.cells), + ...Object.keys(cells).filter((key) => cells[key] !== rawCells[key]), + ])] + await emitContentEntryUpdated(ctx.db, row.id, changedIds, { kind: 'user', userId: ctx.userId }) + await recordRowAudit(ctx, runtime, 'data.row.update', row) + + return { row: projectRow(row) } + }, + } +} + +// --------------------------------------------------------------------------- +// Shared +// --------------------------------------------------------------------------- + +/** + * What a caller needs back to keep working: the id to address the row with, + * its slug and status, and the cells as they were actually stored — a plugin + * filter may have normalized or auto-filled them. + */ +function projectRow(row: DataRow) { + return { + id: row.id, + tableId: row.tableId, + slug: row.slug, + status: row.status, + cells: row.cells, + updatedAt: row.updatedAt, + } +} + +async function recordRowAudit( + ctx: ToolContext, + runtime: DataToolsRuntime | undefined, + action: AuditAction, + row: Pick, +): Promise { + await createAuditEvent(ctx.db, { + actorUserId: ctx.userId, + action, + targetType: 'data_row', + targetId: row.id, + metadata: runtime + ? { tableId: row.tableId, slug: row.slug, source: 'mcp', connectorId: runtime.connectorId } + : { tableId: row.tableId, slug: row.slug, source: 'agent' }, + }) +} + +export function dataRowTools(runtime?: DataToolsRuntime): AiTool[] { + return [createRowsTool(runtime), updateRowTool(runtime)] +} diff --git a/server/ai/tools/data/runtime.ts b/server/ai/tools/data/runtime.ts new file mode 100644 index 000000000..7f56dfade --- /dev/null +++ b/server/ai/tools/data/runtime.ts @@ -0,0 +1,18 @@ +/** + * Per-connection context the data tools need but `ToolContext` does not carry. + * + * `uploadsDir` is required to bake a row's static artefact on publish, and + * `connectorId` is what makes an MCP write attributable in the audit log. + * Both are known only to the MCP server, which passes them in when it builds + * the catalog. The in-app agent path constructs the toolset without a runtime: + * its writes are attributed to the signed-in user directly, and it does not + * expose row publishing. + * + * Structurally identical to `McpPublishRuntime` and filled from the same + * object, declared here so `server/ai/tools/` does not import from + * `server/ai/mcp/`. + */ +export interface DataToolsRuntime { + connectorId: string + uploadsDir: string +} diff --git a/server/ai/tools/data/schemaTools.test.ts b/server/ai/tools/data/schemaTools.test.ts new file mode 100644 index 000000000..6f3204da5 --- /dev/null +++ b/server/ai/tools/data/schemaTools.test.ts @@ -0,0 +1,392 @@ +/** + * Schema-tool behaviour against a real migrated SQLite database. + * + * These run the handlers, not a mock of them: the point of the toolset is that + * it reuses the repository and the HTTP route's access predicates, and only a + * real schema proves the reuse holds (seeded system tables, the active-slug + * unique index, the field normalizer). + */ +import { beforeEach, describe, expect, it } from 'bun:test' +import type { CoreCapability } from '@core/capabilities' +import type { DataTable } from '@core/data/schemas' +import type { DbClient } from '../../../db/client' +import { createSqliteClient } from '../../../db/sqlite' +import { sqliteMigrations } from '../../../db/migrations-sqlite' +import { runMigrations } from '../../../db/runMigrations' +import { listAuditEvents } from '../../../repositories/audit' +import { getDataTable, getDataTableBySlug } from '../../../repositories/data' +import type { AiTool, ToolContext } from '../../runtime/types' +import { dataTools } from './index' + +const MANAGE_CAPS: CoreCapability[] = [ + 'ai.chat', + 'ai.tools.write', + 'data.custom.tables.read', + 'data.custom.tables.manage', + 'data.system.tables.read', +] + +/** A connector granted only the custom-table read cap — no system, no content. */ +const CUSTOM_ONLY_CAPS: CoreCapability[] = ['data.custom.tables.read'] + +/** Adds the system-table manage grant, which `canManageTable` requires for `posts`. */ +const SYSTEM_MANAGE_CAPS: CoreCapability[] = [...MANAGE_CAPS, 'data.system.tables.manage'] + +async function freshDb(): Promise { + const db = createSqliteClient(':memory:') + await runMigrations(db, sqliteMigrations) + // `created_by_user_id` carries a foreign key, so the actor has to exist. + await db` + insert into users (id, email, email_normalized, display_name, password_hash, role_id) + values ('user-1', 'u1@example.com', 'u1@example.com', 'User One', 'x', 'owner') + ` + return db +} + +function toolByName(name: string): AiTool { + const tool = dataTools({ connectorId: 'connector-1', uploadsDir: '/tmp/uploads' }) + .find((candidate) => candidate.name === name) + if (!tool) throw new Error(`tool ${name} is not registered`) + return tool +} + +function run( + name: string, + input: Record, + db: DbClient, + capabilities: CoreCapability[] = MANAGE_CAPS, +): Promise { + const ctx: ToolContext = { + db, + userId: 'user-1', + capabilities, + scope: 'data', + conversationId: 'conversation-1', + snapshot: null, + signal: new AbortController().signal, + } + return toolByName(name).handler!(input, ctx) +} + +/** The seeded `posts` system table, used for the frozen-surface assertions. */ +async function postsTable(db: DbClient): Promise { + const table = await getDataTableBySlug(db, 'posts') + if (!table) throw new Error('the posts system table was not seeded') + return table +} + +describe('data_list_tables', () => { + let db: DbClient + + beforeEach(async () => { + db = await freshDb() + }) + + it('lists reusable data tables, which content_list_collections excludes', async () => { + await run('data_create_table', { + name: 'Trainings', + kind: 'data', + fields: [{ id: 'name', label: 'Name', type: 'text' }], + }, db) + + const result = await run('data_list_tables', {}, db) as { + tables: Array<{ slug: string; kind: string; routable: boolean; rowCount: number }> + } + + const trainings = result.tables.find((t) => t.slug === 'trainings') + expect(trainings).toBeDefined() + expect(trainings!.kind).toBe('data') + expect(trainings!.rowCount).toBe(0) + // kind 'data' gets no route base, so no per-row public URLs. + expect(trainings!.routable).toBe(false) + }) + + it('never lists page, component, or layout tables', async () => { + const result = await run('data_list_tables', {}, db) as { tables: Array<{ slug: string }> } + const slugs = result.tables.map((t) => t.slug) + expect(slugs).not.toContain('pages') + expect(slugs).not.toContain('components') + expect(slugs).not.toContain('layouts') + // The seeded `posts` post type is authorable, so it stays. + expect(slugs).toContain('posts') + }) + + it('narrows to one kind when asked', async () => { + await run('data_create_table', { name: 'Trainings', kind: 'data' }, db) + + const dataOnly = await run('data_list_tables', { kind: 'data' }, db) as { + tables: Array<{ slug: string }> + } + expect(dataOnly.tables.map((t) => t.slug)).toEqual(['trainings']) + + const postTypesOnly = await run('data_list_tables', { kind: 'postType' }, db) as { + tables: Array<{ slug: string }> + } + expect(postTypesOnly.tables.map((t) => t.slug)).toEqual(['posts']) + }) + + it('hides system tables from a custom-only caller, as the HTTP list route does', async () => { + await run('data_create_table', { name: 'Trainings', kind: 'data' }, db) + + const result = await run('data_list_tables', {}, db, CUSTOM_ONLY_CAPS) as { + tables: Array<{ slug: string }> + } + // `posts` is seeded with system=true, so a custom-only grant cannot see it. + expect(result.tables.map((t) => t.slug)).toEqual(['trainings']) + }) +}) + +describe('data_create_table', () => { + let db: DbClient + + beforeEach(async () => { + db = await freshDb() + }) + + it('stores the fields it was given and returns them as stored', async () => { + const result = await run('data_create_table', { + name: 'Trainings', + fields: [ + { id: 'name', label: 'Name', type: 'text' }, + { id: 'price', label: 'Price', type: 'number' }, + { id: 'bookingurl', label: 'Booking URL', type: 'url' }, + ], + primaryFieldId: 'name', + }, db) as { table: { id: string; fields: Array<{ id: string; type: string }> } } + + expect(result.table.fields.map((f) => f.id)).toEqual(['name', 'price', 'bookingurl']) + + // The agent wires loops against what was persisted, not what it sent. + const stored = await getDataTable(db, result.table.id) + expect(stored!.fields.map((f) => f.type)).toEqual(['text', 'number', 'url']) + expect(stored!.primaryFieldId).toBe('name') + }) + + it('defaults kind to data, derives slug and labels from the name', async () => { + const result = await run('data_create_table', { name: 'Team Members' }, db) as { + table: { slug: string; kind: string; singularLabel: string; pluralLabel: string; routeBase: string } + } + expect(result.table.kind).toBe('data') + expect(result.table.slug).toBe('team-members') + expect(result.table.pluralLabel).toBe('Team Members') + expect(result.table.singularLabel).toBe('Team Member') + }) + + it('gives a post type a route base so its rows get public URLs', async () => { + const result = await run('data_create_table', { name: 'Guides', kind: 'postType' }, db) as { + table: { kind: string; routeBase: string } + } + expect(result.table.kind).toBe('postType') + expect(result.table.routeBase).toBe('/guides') + }) + + it('names a slug collision instead of letting the unique index throw', async () => { + await run('data_create_table', { name: 'Trainings' }, db) + const second = await run('data_create_table', { name: 'Trainings' }, db) as { + ok: boolean + error: string + } + expect(second.ok).toBe(false) + expect(second.error).toMatch(/already exists/) + expect(second.error).toMatch(/trainings/) + }) + + it('refuses field types reserved for the built-in page and component tables', async () => { + const result = await run('data_create_table', { + name: 'Trainings', + fields: [{ id: 'body', label: 'Body', type: 'pageTree' }], + }, db) as { ok: boolean; error: string } + + expect(result.ok).toBe(false) + expect(result.error).toMatch(/reserved/) + // Nothing was written. + const listed = await run('data_list_tables', { kind: 'data' }, db) as { tables: unknown[] } + expect(listed.tables).toHaveLength(0) + }) + + it('honours an explicit route base on a data table', async () => { + const result = await run('data_create_table', { + name: 'Guides', + kind: 'data', + routeBase: '/guides', + }, db) as { table: { routeBase: string } } + expect(result.table.routeBase).toBe('/guides') + }) + + it('records an audit event carrying the connector id', async () => { + await run('data_create_table', { name: 'Trainings' }, db) + + const events = await listAuditEvents(db) + const created = events.find((event) => event.action === 'data.table.create') + expect(created).toBeDefined() + expect(created!.actorUserId).toBe('user-1') + expect(created!.metadata).toMatchObject({ + slug: 'trainings', + source: 'mcp', + connectorId: 'connector-1', + }) + }) +}) + + +describe('data_update_table', () => { + let db: DbClient + let tableId: string + + beforeEach(async () => { + db = await freshDb() + const created = await run('data_create_table', { + name: 'Trainings', + fields: [ + { id: 'name', label: 'Name', type: 'text' }, + { id: 'price', label: 'Price', type: 'number' }, + ], + }, db) as { table: { id: string } } + tableId = created.table.id + }) + + it('replaces the whole field array, dropping what was omitted', async () => { + const result = await run('data_update_table', { + tableId, + fields: [{ id: 'name', label: 'Name', type: 'text' }], + }, db) as { table: { fields: Array<{ id: string }> } } + + expect(result.table.fields.map((f) => f.id)).toEqual(['name']) + const stored = await getDataTable(db, tableId) + expect(stored!.fields.map((f) => f.id)).toEqual(['name']) + }) + + it('renames the table and re-derives the slug from what it was given', async () => { + const result = await run('data_update_table', { + tableId, + name: 'Course Catalogue', + slug: 'Course Catalogue', + }, db) as { table: { name: string; slug: string } } + + expect(result.table.name).toBe('Course Catalogue') + expect(result.table.slug).toBe('course-catalogue') + }) + + it('refuses a call that asks for no change', async () => { + const result = await run('data_update_table', { tableId }, db) as { + ok: boolean + error: string + } + expect(result.ok).toBe(false) + expect(result.error).toMatch(/at least one property/) + }) + + it('names a slug collision instead of letting the unique index throw', async () => { + await run('data_create_table', { name: 'Guides' }, db) + + const result = await run('data_update_table', { tableId, slug: 'guides' }, db) as { + ok: boolean + error: string + } + expect(result.ok).toBe(false) + expect(result.error).toMatch(/already exists/) + // The rename was rejected before it was written. + const stored = await getDataTable(db, tableId) + expect(stored!.slug).toBe('trainings') + }) + + it('reports an unmanageable table as not found rather than forbidden', async () => { + const result = await run('data_update_table', { + tableId, + name: 'Renamed', + }, db, CUSTOM_ONLY_CAPS) as { ok: boolean; error: string } + expect(result.ok).toBe(false) + expect(result.error).toMatch(/not found/) + }) + + it("refuses to rename a system table even with the system manage grant", async () => { + const posts = await postsTable(db) + + const result = await run('data_update_table', { + tableId: posts.id, + name: 'Articles', + }, db, SYSTEM_MANAGE_CAPS) as { ok: boolean; error: string } + + expect(result.ok).toBe(false) + expect(result.error).toMatch(/System tables can't change their name/) + const stored = await getDataTable(db, posts.id) + expect(stored!.name).toBe(posts.name) + }) + + it('refuses to drop a built-in field off a system table', async () => { + const posts = await postsTable(db) + + const result = await run('data_update_table', { + tableId: posts.id, + fields: [{ id: 'summary', label: 'Summary', type: 'text' }], + }, db, SYSTEM_MANAGE_CAPS) as { ok: boolean; error: string } + + expect(result.ok).toBe(false) + expect(result.error).toMatch(/built-in field/) + }) +}) + +describe('data_add_fields', () => { + let db: DbClient + let tableId: string + + beforeEach(async () => { + db = await freshDb() + const created = await run('data_create_table', { + name: 'Trainings', + fields: [{ id: 'name', label: 'Name', type: 'text' }], + }, db) as { table: { id: string } } + tableId = created.table.id + }) + + it('appends without disturbing the fields already there', async () => { + const result = await run('data_add_fields', { + tableId, + fields: [ + { id: 'price', label: 'Price', type: 'number' }, + { id: 'bookingurl', label: 'Booking URL', type: 'url' }, + ], + }, db) as { table: { fields: Array<{ id: string }> } } + + expect(result.table.fields.map((f) => f.id)).toEqual(['name', 'price', 'bookingurl']) + }) + + it('refuses a field id the table already has instead of overwriting it', async () => { + const result = await run('data_add_fields', { + tableId, + fields: [{ id: 'name', label: 'Full name', type: 'text' }], + }, db) as { ok: boolean; error: string } + + expect(result.ok).toBe(false) + expect(result.error).toMatch(/already has a field with id "name"/) + // The original label survived. + const stored = await getDataTable(db, tableId) + expect(stored!.fields.find((f) => f.id === 'name')!.label).toBe('Name') + }) + + it('adds a custom field to a system table, which data_update_table cannot rename', async () => { + const posts = await postsTable(db) + + const result = await run('data_add_fields', { + tableId: posts.id, + fields: [{ id: 'readingtime', label: 'Reading time', type: 'number' }], + }, db, SYSTEM_MANAGE_CAPS) as { table: { fields: Array<{ id: string }> } } + + const ids = result.table.fields.map((f) => f.id) + expect(ids).toContain('readingtime') + // Every built-in that was there before is still there. + for (const field of posts.fields) expect(ids).toContain(field.id) + }) + + it('records an audit event so an MCP schema change is traceable', async () => { + await run('data_add_fields', { + tableId, + fields: [{ id: 'price', label: 'Price', type: 'number' }], + }, db) + + const events = await listAuditEvents(db) + const updated = events.find((event) => event.action === 'data.table.update') + expect(updated).toBeDefined() + expect(updated!.metadata).toMatchObject({ slug: 'trainings', connectorId: 'connector-1' }) + }) +}) diff --git a/server/ai/tools/data/schemaTools.ts b/server/ai/tools/data/schemaTools.ts new file mode 100644 index 000000000..158a88c72 --- /dev/null +++ b/server/ai/tools/data/schemaTools.ts @@ -0,0 +1,456 @@ +/** + * Data-scope schema tools — server-resolved, headless. + * + * These close the read/write asymmetry the MCP surface had around table + * schemas: `content_get_collection_schema` reads any table, but nothing could + * create one or add a field to one, so provisioning a site over MCP always + * stopped for a human to type tables into the Data workspace. + * + * Headless on purpose. A data table is edited in a grid, not in the Tiptap + * editor whose dirty in-memory state forces the `content_*` write tools + * through the browser bridge, so there is no live draft to keep consistent + * with. Same execution class as `media_upload`: a server-resolved write that + * works with no workspace tab open. + * + * Each tool mirrors the validation, guards, and audit emission of the HTTP + * route it shadows (`server/handlers/cms/data/tables.ts`). The one thing it + * cannot mirror is `requireStepUp` — there is no step-up challenge on an MCP + * connection. The compensating controls are the `data.*.tables.manage` + * capability gate (an approver can only grant capabilities they hold) and the + * audit trail below, which records the connector id on every write. + */ + +import { Type, type Static } from '@core/utils/typeboxHelpers' +import type { CoreCapability } from '@core/capabilities' +import { + DataFieldSchema, + type DataField, + type DataTable, + type DataTableListItem, + type UpdateDataTableInput, +} from '@core/data/schemas' +import { normalizeDataTableFields } from '@core/data/fields' +import { assertSystemTableUpdateAllowed } from '@core/data/systemTableGuard' +import { slugFromTitle } from '@core/utils/slug' +import type { AiTool, ToolContext } from '../../runtime/types' +import { createAuditEvent } from '../../../repositories/audit' +import { + createDataTable, + getDataTable, + getDataTableBySlug, + listDataTablesWithCounts, + updateDataTable, +} from '../../../repositories/data' +import { + canManageTable, + canReadTable, + hasContentRowAccess, +} from '../../../handlers/cms/data/access' +import { toolActor } from './access' +import type { DataToolsRuntime } from './runtime' + +// --------------------------------------------------------------------------- +// Capability requirements (ANY-OF) — each tool mirrors its HTTP-route gate in +// server/handlers/cms/data/access.ts. +// --------------------------------------------------------------------------- + +// Mirrors `requireDataTablesRead` (TABLE_READ_CAPABILITIES). Holding any table +// read/manage cap is enough to enumerate; `canReadTable` then filters per +// family so a custom-only connector never sees the system tables. +const TABLE_READ_CAPS: readonly CoreCapability[] = [ + 'data.custom.tables.read', + 'data.custom.tables.manage', + 'data.system.tables.read', + 'data.system.tables.manage', +] + +// Mirrors `requireCustomTablesManager`. Creation is always a custom table — +// the system tables are seeded at boot and never created through an API. +const TABLE_CREATE_CAPS: readonly CoreCapability[] = ['data.custom.tables.manage'] + +// Schema mutation on an EXISTING table, whose family is only known once the +// table has been read. Holding either manage cap is enough to be offered the +// tool; `canManageTable` then decides per table, exactly as `handleTableItem` +// does after resolving it. +const TABLE_MANAGE_CAPS: readonly CoreCapability[] = [ + 'data.custom.tables.manage', + 'data.system.tables.manage', +] + +// --------------------------------------------------------------------------- +// Shared shapes +// --------------------------------------------------------------------------- + +/** + * Field types reserved for the seeded system tables. `pageTree` stores a whole + * page-node tree (the `body` of a `page` / `component` row) and `fieldSchema` + * stores a `DataField[]` (a component's `params`). Both render as "open the + * editor" buttons that only mean something inside those tables; put one on a + * custom table and the grid shows a control that cannot be used and the value + * cannot be authored anywhere. `normalizeDataTableFields` accepts them because + * it also parses the system tables' own persisted schemas, so the refusal + * belongs here, at the authoring boundary. + */ +const RESERVED_FIELD_TYPES: ReadonlySet = new Set(['pageTree', 'fieldSchema']) + +/** + * The real field union, not `Type.Unknown()`. + * + * The HTTP route takes `fields` as unknown and lets `normalizeDataTableFields` + * be the source of truth, which is right for a browser client that already + * knows the shape. Over MCP the advertised schema is the ONLY spec an agent + * reads, so an opaque `unknown` here means the agent has to guess the field + * shape and finds out it guessed wrong from a table that silently dropped + * every field. `normalizeDataTableFields` still runs after validation as the + * coercion layer. + */ +const FieldArray = Type.Array(DataFieldSchema) + +function projectTable(table: DataTableListItem) { + return { + id: table.id, + slug: table.slug, + label: table.pluralLabel || table.name, + kind: table.kind, + // Empty route base is the persisted sentinel for "not publicly routable". + routable: table.routeBase !== '', + system: table.system, + rowCount: table.rowCount, + primaryFieldId: table.primaryFieldId, + } +} + +function reservedFieldTypeError(fields: readonly DataField[]): string | null { + const reserved = fields.find((field) => RESERVED_FIELD_TYPES.has(field.type)) + if (!reserved) return null + return `Field "${reserved.id}" uses type "${reserved.type}", which is reserved for the built-in page/component tables and cannot be authored on a custom table.` +} + +// --------------------------------------------------------------------------- +// data_list_tables +// --------------------------------------------------------------------------- + +const ListTablesInput = Type.Object({ + kind: Type.Optional(Type.Union([Type.Literal('data'), Type.Literal('postType')])), +}, { additionalProperties: false }) + +/** + * No `limit` and no cursor, deliberately. The row count of a table is content + * and grows without bound; the NUMBER OF TABLES is schema, set by whoever + * designed the site, and a workspace has tens of them at most. A limit-only + * cap would make anything past the cap permanently unreachable while saving + * nothing. The heavy half of a table — its `fields` array — is not in this + * projection at all; that stays on `content_get_collection_schema`, one table + * at a time. + */ +const listTablesTool: AiTool = { + name: 'data_list_tables', + scope: 'data', + execution: 'server', + requiredCapabilities: TABLE_READ_CAPS, + description: + "List every table in the workspace: reusable data tables (kind 'data') AND routable post types (kind 'postType'). Pass `kind` to narrow. Returns id, slug, label, kind, routable, system, rowCount, primaryFieldId per table — call content_get_collection_schema with an id for its fields. This is the discovery tool for reusable data tables, which content_list_collections deliberately excludes. Headless — no editor needed.", + inputSchema: ListTablesInput, + handler: async (input, ctx: ToolContext) => { + const { kind } = input as Static + const actor = toolActor(ctx) + const tables = await listDataTablesWithCounts(ctx.db) + + // Per-family visibility, exactly as `GET /admin/api/cms/data/tables` + // filters it: a custom-only caller never learns the system tables exist, + // while a caller with content-row access (the loop / template pickers) + // keeps the full list because choosing a loop source needs it. + const readable = hasContentRowAccess(actor) + ? tables + : tables.filter((table) => canReadTable(actor, table)) + + // `page`, `component`, and `layout` are Site-workspace internals with no + // authorable rows — never part of this catalog, with or without a filter. + return { + tables: readable + .filter((table) => (kind ? table.kind === kind : table.kind === 'data' || table.kind === 'postType')) + .map(projectTable), + } + }, +} + +// --------------------------------------------------------------------------- +// data_create_table +// --------------------------------------------------------------------------- + +const CreateTableInput = Type.Object({ + name: Type.String({ minLength: 1 }), + slug: Type.Optional(Type.String()), + kind: Type.Optional(Type.Union([Type.Literal('data'), Type.Literal('postType')])), + routeBase: Type.Optional(Type.String()), + singularLabel: Type.Optional(Type.String()), + pluralLabel: Type.Optional(Type.String()), + primaryFieldId: Type.Optional(Type.String()), + fields: Type.Optional(FieldArray), +}, { additionalProperties: false }) + +function createTableTool(runtime?: DataToolsRuntime): AiTool { + return { + name: 'data_create_table', + scope: 'data', + execution: 'server', + mutates: true, + requiredCapabilities: TABLE_CREATE_CAPS, + description: + "Create a custom table. `kind` defaults to 'data' (a reusable table with no public URLs — course dates, team members, pricing rows) — use 'postType' only for content that needs one public page per row. `slug` defaults to a slugified `pluralLabel`, `singularLabel`/`pluralLabel` default from `name`, `routeBase` defaults to `/` for a post type and to none for a data table (pass an explicit `routeBase` only to override that), `primaryFieldId` names the field used as the row label in grids and pickers (defaults to 'title'). Pass `fields` to define the schema up front. Returns the table AS STORED, including the actual field ids — read those back before wiring loops or writing rows, they are not always the ids you sent. Field types 'pageTree' and 'fieldSchema' are reserved for built-in tables. Headless — no editor needed.", + inputSchema: CreateTableInput, + handler: async (input, ctx: ToolContext) => { + const args = input as Static + + const name = args.name.trim() + if (!name) return { ok: false, error: 'Table name is required.' } + + const singularLabel = args.singularLabel?.trim() || name.replace(/s$/i, '') || name + const pluralLabel = args.pluralLabel?.trim() || name + const slug = slugFromTitle(args.slug?.trim() || pluralLabel) + const kind = args.kind === 'postType' ? 'postType' : 'data' + + // Same rule as the Data workspace's own New table dialog: a post type is + // routable at `/`, a reusable data table is not routable at all. + // Leaving this undefined is NOT equivalent — `createDataTable` then + // derives `/` for both kinds, which would give every row of a + // course-dates table its own public URL. + const routeBase = args.routeBase ?? (kind === 'postType' ? `/${slug}` : '') + + const fields = normalizeDataTableFields(args.fields ?? []) + const reserved = reservedFieldTypeError(fields) + if (reserved) return { ok: false, error: reserved } + + // The partial unique index on active slugs would otherwise surface as an + // opaque driver error, leaving the caller unable to tell a duplicate + // from a bug. Same reasoning as the row-slug pre-check in the HTTP route. + const clash = await getDataTableBySlug(ctx.db, slug) + if (clash) { + return { + ok: false, + error: `A table with slug "${slug}" already exists (id ${clash.id}). Pass a different \`slug\`, or write to the existing table.`, + } + } + + const table = await createDataTable(ctx.db, { + name, + slug, + kind, + routeBase, + singularLabel, + pluralLabel, + primaryFieldId: args.primaryFieldId?.trim() || undefined, + fields, + createdByUserId: ctx.userId, + updatedByUserId: ctx.userId, + }) + + await recordTableAudit(ctx, runtime, 'data.table.create', table.id, table.slug) + return { table } + }, + } +} + +// --------------------------------------------------------------------------- +// data_update_table +// --------------------------------------------------------------------------- + +const UpdateTableInput = Type.Object({ + tableId: Type.String({ minLength: 1 }), + name: Type.Optional(Type.String({ minLength: 1 })), + slug: Type.Optional(Type.String({ minLength: 1 })), + routeBase: Type.Optional(Type.String()), + singularLabel: Type.Optional(Type.String({ minLength: 1 })), + pluralLabel: Type.Optional(Type.String({ minLength: 1 })), + primaryFieldId: Type.Optional(Type.String({ minLength: 1 })), + fields: Type.Optional(FieldArray), +}, { additionalProperties: false }) + +function updateTableTool(runtime?: DataToolsRuntime): AiTool { + return { + name: 'data_update_table', + scope: 'data', + execution: 'server', + mutates: true, + requiredCapabilities: TABLE_MANAGE_CAPS, + description: + "Change an existing table's identity or schema. `fields` REPLACES the whole field array — send every field you want to keep, or use data_add_fields to append without touching the rest. Dropping a field orphans the values already stored under it on every row. Set `routeBase` to an empty string to make a table non-routable, or to a path to give each row a public URL. The seeded system tables (pages, posts, components, layouts) accept custom fields but refuse any change to their identity or their built-in fields. Headless — no editor needed.", + inputSchema: UpdateTableInput, + handler: async (input, ctx: ToolContext) => { + const args = input as Static + const resolved = await resolveManageableTable(ctx, args.tableId) + if ('error' in resolved) return resolved + const { table } = resolved + + const update: Parameters[2] = { updatedByUserId: ctx.userId } + if (args.name !== undefined) update.name = args.name.trim() + if (args.slug !== undefined) update.slug = slugFromTitle(args.slug.trim()) + if (args.routeBase !== undefined) update.routeBase = args.routeBase + if (args.singularLabel !== undefined) update.singularLabel = args.singularLabel.trim() + if (args.pluralLabel !== undefined) update.pluralLabel = args.pluralLabel.trim() + if (args.primaryFieldId !== undefined) update.primaryFieldId = args.primaryFieldId.trim() + if (args.fields !== undefined) update.fields = normalizeDataTableFields(args.fields) + + // `updatedByUserId` is always set, so one key means nothing was asked for. + if (Object.keys(update).length === 1) { + return { ok: false, error: 'Pass at least one property to change.' } + } + + // A rename must not collide with another active slug, for the same + // reason creation checks it: the unique index throws opaquely otherwise. + if (update.slug && update.slug !== table.slug) { + const clash = await getDataTableBySlug(ctx.db, update.slug) + if (clash) { + return { ok: false, error: `A table with slug "${update.slug}" already exists (id ${clash.id}).` } + } + } + + const rejection = validateTableUpdate(table, update) + if (rejection) return { ok: false, error: rejection } + + const updated = await updateDataTable(ctx.db, table.id, update) + if (!updated) return { ok: false, error: `Table ${args.tableId} not found.` } + + await recordTableAudit(ctx, runtime, 'data.table.update', updated.id, updated.slug) + return { table: updated } + }, + } +} + +// --------------------------------------------------------------------------- +// data_add_fields +// --------------------------------------------------------------------------- + +const AddFieldsInput = Type.Object({ + tableId: Type.String({ minLength: 1 }), + fields: Type.Array(DataFieldSchema, { minItems: 1 }), +}, { additionalProperties: false }) + +function addFieldsTool(runtime?: DataToolsRuntime): AiTool { + return { + name: 'data_add_fields', + scope: 'data', + execution: 'server', + mutates: true, + requiredCapabilities: TABLE_MANAGE_CAPS, + description: + "Append fields to an existing table, leaving every current field and every stored value untouched. This is the safe way to evolve a schema — data_update_table's `fields` replaces the array and drops whatever you omit. A field id the table already has is refused rather than overwritten; change an existing field through data_update_table. Returns the table as stored. Headless — no editor needed.", + inputSchema: AddFieldsInput, + handler: async (input, ctx: ToolContext) => { + const args = input as Static + const resolved = await resolveManageableTable(ctx, args.tableId) + if ('error' in resolved) return resolved + const { table } = resolved + + const additions = normalizeDataTableFields(args.fields) + if (additions.length === 0) { + return { ok: false, error: 'None of the supplied fields is a usable field definition.' } + } + + const existingIds = new Set(table.fields.map((field) => field.id)) + const duplicate = additions.find((field) => existingIds.has(field.id)) + if (duplicate) { + return { + ok: false, + error: `Table "${table.slug}" already has a field with id "${duplicate.id}". Use data_update_table to change an existing field.`, + } + } + + const fields = [...table.fields, ...additions] + const rejection = validateTableUpdate(table, { fields }) + if (rejection) return { ok: false, error: rejection } + + const updated = await updateDataTable(ctx.db, table.id, { + fields, + updatedByUserId: ctx.userId, + }) + if (!updated) return { ok: false, error: `Table ${args.tableId} not found.` } + + await recordTableAudit(ctx, runtime, 'data.table.update', updated.id, updated.slug) + return { table: updated } + }, + } +} + +// --------------------------------------------------------------------------- +// Shared resolution + field validation +// --------------------------------------------------------------------------- + +/** + * Resolve a table this caller may reshape. + * + * The coarse `requiredCapabilities` gate cannot tell custom from system — + * the family is a property of the row, not of the request — so the kind-aware + * check happens here, mirroring `handleTableItem`. An unmanageable table + * reports "not found" rather than "forbidden" for the same reason the HTTP + * read path does: a caller who may not touch a family should not learn what + * that family contains. + */ +async function resolveManageableTable( + ctx: ToolContext, + tableId: string, +): Promise<{ table: DataTable } | { ok: false; error: string }> { + const table = await getDataTable(ctx.db, tableId) + if (!table || !canManageTable(toolActor(ctx), table)) { + return { ok: false, error: `Table ${tableId} not found.` } + } + return { table } +} + +/** + * The reserved-type refusal plus the system table's frozen identity and + * built-ins. Takes the whole patch, not just its fields, because a system + * table freezes its name, slug, route base, and labels too — passing only + * `fields` would let a rename through. + * + * The reserved check is skipped for system tables because their own `pageTree` + * / `fieldSchema` built-ins are legitimately present and get re-sent on any + * field write; `assertSystemTableUpdateAllowed` is what stops those from being + * edited. + */ +function validateTableUpdate( + table: DataTable, + update: UpdateDataTableInput, +): string | null { + if (update.fields && !table.system) { + const reserved = reservedFieldTypeError(update.fields) + if (reserved) return reserved + } + return assertSystemTableUpdateAllowed(table, update) +} + +// --------------------------------------------------------------------------- +// Audit +// --------------------------------------------------------------------------- + +/** + * Record a schema write. `source` and `connectorId` are what make an MCP write + * distinguishable from an admin's own in the audit log — the HTTP routes get + * that for free from `requestAuditContext(req)`, which has no tool equivalent. + */ +async function recordTableAudit( + ctx: ToolContext, + runtime: DataToolsRuntime | undefined, + action: 'data.table.create' | 'data.table.update', + tableId: string, + slug: string, +): Promise { + await createAuditEvent(ctx.db, { + actorUserId: ctx.userId, + action, + targetType: 'data_table', + targetId: tableId, + metadata: runtime + ? { slug, source: 'mcp', connectorId: runtime.connectorId } + : { slug, source: 'agent' }, + }) +} + +export function dataSchemaTools(runtime?: DataToolsRuntime): AiTool[] { + return [ + listTablesTool, + createTableTool(runtime), + updateTableTool(runtime), + addFieldsTool(runtime), + ] +} diff --git a/server/ai/tools/index.ts b/server/ai/tools/index.ts index 1c00c6077..2d0760d18 100644 --- a/server/ai/tools/index.ts +++ b/server/ai/tools/index.ts @@ -1,8 +1,8 @@ /** * Tool registry root — selects the right toolset for a chat scope. * - * `site` and `content` scopes have tools registered. `data` and `plugin` - * are reserved scopes with no toolset yet. + * `site`, `content`, and `data` scopes have tools registered. `plugin` is a + * reserved scope with no toolset yet. * * Adding a new scope: * 1. Create `server/ai/tools//` with its tool files + index.ts. @@ -25,6 +25,7 @@ import { toolAllowedForCapabilities } from './capabilityGate' import type { AiTool, ToolScope } from './types' import { siteTools } from './site' import { contentTools } from './content' +import { dataTools } from './data' function scopeToolset(scope: ToolScope): AiTool[] { switch (scope) { @@ -33,8 +34,10 @@ function scopeToolset(scope: ToolScope): AiTool[] { case 'content': return contentTools case 'data': - // Reserved: no data-scope toolset yet. - return [] + // Schema + row tools, all server-resolved. The MCP registry builds its + // own copy with a runtime (see server/ai/mcp/registry.ts); the in-app + // agent gets the runtime-free subset. + return dataTools() case 'plugin': // Reserved: no plugin-scope toolset yet. return [] diff --git a/server/handlers/cms/data/access.ts b/server/handlers/cms/data/access.ts index 99a753db4..84ea2b591 100644 --- a/server/handlers/cms/data/access.ts +++ b/server/handlers/cms/data/access.ts @@ -77,6 +77,15 @@ const DATA_PUBLISH_CAPABILITIES = [ 'content.publish.any', ] satisfies CoreCapability[] +/** + * Who is asking. The predicates below take the caller's identity + capability + * set rather than a whole `AuthUser` so the AI tool handlers can reuse them: + * a tool has a `ToolContext` (userId + capabilities), never an `AuthUser`, and + * a second copy of these rules on that side would be free to drift from this + * one. See `server/ai/tools/data/access.ts`. + */ +type DataRowActor = Pick + interface OwnedDataRow { authorUserId: string | null createdByUserId: string | null @@ -121,7 +130,7 @@ export async function requireCustomTablesManager(req: Request, db: DbClient): Pr * cap; custom tables need a custom read cap. Used to filter the table list and * gate single-table reads at the boundary. */ -export function canReadTable(user: AuthUser, table: Pick): boolean { +export function canReadTable(user: Pick, table: Pick): boolean { return table.system ? userHasAnyCapability(user, ['data.system.tables.read', 'data.system.tables.manage']) : userHasAnyCapability(user, ['data.custom.tables.read', 'data.custom.tables.manage']) @@ -132,7 +141,7 @@ export function canReadTable(user: AuthUser, table: Pick): * only governs custom fields + primary-field selection — identity and built-in * fields are immutable for everyone (`assertSystemTableUpdateAllowed`). */ -export function canManageTable(user: AuthUser, table: Pick): boolean { +export function canManageTable(user: Pick, table: Pick): boolean { return userHasCapability(user, table.system ? 'data.system.tables.manage' : 'data.custom.tables.manage') } @@ -141,7 +150,7 @@ export function canManageTable(user: AuthUser, table: Pick) * caller sees the full table list even without data-table read caps, because * picking a loop source needs to know what tables exist. */ -export function hasContentRowAccess(user: AuthUser): boolean { +export function hasContentRowAccess(user: Pick): boolean { return userHasAnyCapability(user, DATA_ACCESS_CAPABILITIES) } @@ -175,7 +184,7 @@ export function canSeeAllDataRows(user: AuthUser): boolean { return userHasAnyCapability(user, DATA_ANY_VISIBILITY_CAPABILITIES) } -function ownsDataRow(user: AuthUser, row: OwnedDataRow): boolean { +function ownsDataRow(user: Pick, row: OwnedDataRow): boolean { return row.authorUserId === user.id || (!row.authorUserId && row.createdByUserId === user.id) } @@ -184,12 +193,12 @@ export function canReadDataRow(user: AuthUser, row: OwnedDataRow): boolean { (ownsDataRow(user, row) && userHasAnyCapability(user, DATA_OWN_READ_CAPABILITIES)) } -export function canEditDataRow(user: AuthUser, row: OwnedDataRow): boolean { +export function canEditDataRow(user: DataRowActor, row: OwnedDataRow): boolean { return userHasAnyCapability(user, ['content.edit.any', 'content.manage']) || (ownsDataRow(user, row) && userHasCapability(user, 'content.edit.own')) } -export function canPublishDataRow(user: AuthUser, row: OwnedDataRow): boolean { +export function canPublishDataRow(user: DataRowActor, row: OwnedDataRow): boolean { return userHasCapability(user, 'content.publish.any') || (ownsDataRow(user, row) && userHasCapability(user, 'content.publish.own')) } diff --git a/src/__tests__/agent/contentBridge.test.ts b/src/__tests__/agent/contentBridge.test.ts index 54beef6ba..6f6506429 100644 --- a/src/__tests__/agent/contentBridge.test.ts +++ b/src/__tests__/agent/contentBridge.test.ts @@ -30,7 +30,6 @@ function registerHandle(overrides: Partial = {}) { }, async selectCollection() { calls.push('selectCollection') - return true }, async createDocument() { calls.push('createDocument') @@ -245,3 +244,22 @@ describe('runMcpWorkspaceBridgeConnection', () => { } }) }) + +describe('content_set_active_collection refusals', () => { + it("passes the workspace's own reason through instead of a bare not-found", async () => { + registerHandle({ + async selectCollection(tableId) { + throw new Error( + `Table "trainings" is a data table, not a post type: it is edited in the Data workspace — write its rows with data_create_rows / data_update_row. (${tableId})`, + ) + }, + }) + + const result = await executeContentTool('content_set_active_collection', { tableId: 'trainings' }) + + expect(result.ok).toBe(false) + // Issue #463: an agent told "not found" re-creates the table it already + // has. The refusal has to name the toolset that can write it. + expect(result.error).toMatch(/data_create_rows/) + }) +}) diff --git a/src/__tests__/agent/contentCollectionRefresh.test.tsx b/src/__tests__/agent/contentCollectionRefresh.test.tsx index b3bc7b334..6b91f3704 100644 --- a/src/__tests__/agent/contentCollectionRefresh.test.tsx +++ b/src/__tests__/agent/contentCollectionRefresh.test.tsx @@ -15,29 +15,33 @@ import type { DataRow, DataTable } from '@core/data/schemas' import { useContentToolBridge } from '@admin/pages/content/agent/useContentToolBridge' import { getContentBridgeHandle } from '@admin/pages/content/agent/contentBridgeHandle' -function table(id: string): DataTable { +function table(id: string, kind: DataTable['kind'] = 'postType'): DataTable { return { id, name: id, slug: id, - kind: 'postType', - routeBase: `/${id}`, + kind, + routeBase: kind === 'postType' ? `/${id}` : '', fields: [], } as unknown as DataTable } -/** Workspace whose roster starts stale and only learns `recipes` on refresh. */ +/** + * Workspace whose roster starts stale and only learns `recipes` (a post type) + * and `trainings` (a reusable data table) on refresh. + */ function staleWorkspace() { - let collections = [table('posts')] - const refreshCollections = mock(async () => { - collections = [table('posts'), table('recipes')] - return collections + let tables = [table('posts')] + const refreshTables = mock(async () => { + tables = [table('posts'), table('recipes'), table('trainings', 'data')] + return tables }) const selectCollection = mock(() => {}) return { surface: { - get collections() { return collections }, - refreshCollections, + get collections() { return tables.filter((t) => t.kind === 'postType') }, + get tables() { return tables }, + refreshTables, entries: [] as DataRow[], selectedEntry: null, selectedCollectionId: 'posts', @@ -48,7 +52,7 @@ function staleWorkspace() { updateEntryAuthor: async (row: DataRow) => row, updateSelectedEntry: () => {}, }, - refreshCollections, + refreshTables, selectCollection, } } @@ -79,8 +83,8 @@ describe('content bridge collection resolution', () => { const workspace = staleWorkspace() const handle = mountBridge(workspace) - expect(await handle.selectCollection('recipes')).toBe(true) - expect(workspace.refreshCollections).toHaveBeenCalledTimes(1) + await handle.selectCollection('recipes') + expect(workspace.refreshTables).toHaveBeenCalledTimes(1) expect(workspace.selectCollection).toHaveBeenCalledTimes(1) }) @@ -88,16 +92,26 @@ describe('content bridge collection resolution', () => { const workspace = staleWorkspace() const handle = mountBridge(workspace) - expect(await handle.selectCollection('posts')).toBe(true) - expect(workspace.refreshCollections).not.toHaveBeenCalled() + await handle.selectCollection('posts') + expect(workspace.refreshTables).not.toHaveBeenCalled() }) it('still reports a genuinely unknown collection as missing', async () => { const workspace = staleWorkspace() const handle = mountBridge(workspace) - expect(await handle.selectCollection('nope')).toBe(false) - expect(workspace.refreshCollections).toHaveBeenCalledTimes(1) + await expect(handle.selectCollection('nope')).rejects.toThrow(/not found/) + expect(workspace.refreshTables).toHaveBeenCalledTimes(1) + }) + + it('tells the caller a reusable data table is real but authored elsewhere', async () => { + const workspace = staleWorkspace() + const handle = mountBridge(workspace) + + // Issue #463: "not found" made agents re-create a table that already + // existed. The refusal names the toolset that can actually write it. + await expect(handle.selectCollection('trainings')).rejects.toThrow(/data_create_rows/) + expect(workspace.selectCollection).not.toHaveBeenCalled() }) it('refuses to create in an unknown collection before issuing any row request', async () => { @@ -105,6 +119,13 @@ describe('content bridge collection resolution', () => { const handle = mountBridge(workspace) await expect(handle.createDocument({ tableId: 'nope' })).rejects.toThrow(/not found/) - expect(workspace.refreshCollections).toHaveBeenCalledTimes(1) + expect(workspace.refreshTables).toHaveBeenCalledTimes(1) + }) + + it('refuses to create a document in a data table, naming the row tool', async () => { + const workspace = staleWorkspace() + const handle = mountBridge(workspace) + + await expect(handle.createDocument({ tableId: 'trainings' })).rejects.toThrow(/data_create_rows/) }) }) diff --git a/src/__tests__/architecture/ai-tool-input-object.test.ts b/src/__tests__/architecture/ai-tool-input-object.test.ts index 0d7aa7cea..2c35721b1 100644 --- a/src/__tests__/architecture/ai-tool-input-object.test.ts +++ b/src/__tests__/architecture/ai-tool-input-object.test.ts @@ -9,11 +9,12 @@ import { describe, expect, it } from 'bun:test' import { contentTools } from '../../../server/ai/tools/content' +import { dataTools } from '../../../server/ai/tools/data' import { siteTools } from '../../../server/ai/tools/site' describe('AI tool input object gate', () => { it('every registered tool advertises an object-rooted input schema', () => { - for (const tool of [...siteTools, ...contentTools]) { + for (const tool of [...siteTools, ...contentTools, ...dataTools()]) { expect( tool.inputSchema.type, `${tool.name} must expose a top-level JSON Schema object`, diff --git a/src/admin/pages/content/agent/contentBridge.ts b/src/admin/pages/content/agent/contentBridge.ts index 2a7917e88..108cfcbb2 100644 --- a/src/admin/pages/content/agent/contentBridge.ts +++ b/src/admin/pages/content/agent/contentBridge.ts @@ -211,10 +211,10 @@ async function handleSetActiveCollection( rawInput: unknown, ): Promise { const input = parseInput(SetActiveCollectionSchema, rawInput) as Static - const ok = await handle.selectCollection(input.tableId) - if (!ok) { - return aiToolError(`Collection ${input.tableId} not found.`) - } + // A table this workspace cannot author throws with the reason; the dispatch + // catch above turns it into the tool error, so the agent learns which + // toolset owns the table instead of a bare "not found". + await handle.selectCollection(input.tableId) return aiToolOk() } diff --git a/src/admin/pages/content/agent/contentBridgeHandle.ts b/src/admin/pages/content/agent/contentBridgeHandle.ts index bfc2c600e..35201addf 100644 --- a/src/admin/pages/content/agent/contentBridgeHandle.ts +++ b/src/admin/pages/content/agent/contentBridgeHandle.ts @@ -94,7 +94,8 @@ export interface ContentBridgeHandle { */ selectDocument(documentId: string): Promise /** Switch the sidebar focus to a different collection. */ - selectCollection(tableId: string): Promise + /** Throws with the reason when the table is not authorable in this workspace. */ + selectCollection(tableId: string): Promise /** * Create a new draft row in `tableId`. When `fields` is provided, the diff --git a/src/admin/pages/content/agent/useContentToolBridge.ts b/src/admin/pages/content/agent/useContentToolBridge.ts index cc2b94fae..d3052d70a 100644 --- a/src/admin/pages/content/agent/useContentToolBridge.ts +++ b/src/admin/pages/content/agent/useContentToolBridge.ts @@ -31,10 +31,33 @@ import { // workspaces. Keep the bridge aligned with the Content workspace collection list. const CONTENT_KIND_VISIBLE: ReadonlySet = new Set(['postType']) +/** Where a table this workspace cannot author is actually edited. */ +const KIND_HOME: Record = { + data: 'the Data workspace — write its rows with data_create_rows / data_update_row', + page: 'the Site editor — use the site_* tools', + component: 'the Site editor — use the site_* tools', + layout: 'the Site editor — use the site_* tools', +} + +/** + * Why a table id the Content workspace was handed is not usable here. + * + * Reads the roster the bridge already has rather than asking the server, so a + * refusal stays a refusal and never turns into a network error of its own. + */ +function unusableCollectionMessage(tableId: string, table: DataTable | undefined): string { + const home = table && KIND_HOME[table.kind] + if (!table || !home) return `Collection ${tableId} not found.` + return `Table "${table.slug}" is a ${table.kind} table, not a post type: it is edited in ${home}.` +} + interface ContentToolWorkspaceSurface { + /** Post types only — what the sidebar lists and what this bridge can author. */ collections: DataTable[] - /** Re-reads the roster from the server and returns the fresh post types. */ - refreshCollections(): Promise + /** Every table, including the ones other workspaces own. Used to explain refusals. */ + tables: DataTable[] + /** Re-reads the roster from the server and returns every table. */ + refreshTables(): Promise entries: DataRow[] selectedEntry: DataRow | null selectedCollectionId: string | null @@ -83,25 +106,31 @@ export function useContentToolBridge({ useEffect(() => { /** * Find a Content-visible collection, refreshing the roster once if the id - * is unknown. + * is unknown, and throwing a message that says why when it stays unusable. * * The workspace caches its collections at mount, so a table created after * that — by an import, another admin, or an MCP connector building a site * — is invisible here and every write against it fails with "not found" * until someone reloads the page. One refresh distinguishes "created a * moment ago" from "does not exist". + * + * The refusal is kind-aware because "not found" was actively wrong for the + * commonest miss: a reusable `kind: 'data'` table exists, the caller has + * the right id, and it simply is not authored in this Tiptap editor. Agents + * responded by re-creating the table (issue #463). Name the mismatch and + * point at the toolset that can write it instead. */ - const resolveCollection = async (tableId: string): Promise => { - const visible = (table: DataTable | undefined): DataTable | null => - table && CONTENT_KIND_VISIBLE.has(table.kind) ? table : null - - const cached = visible( - workspaceRef.current.collections.find((candidate) => candidate.id === tableId), - ) + const resolveCollection = async (tableId: string): Promise => { + const cached = workspaceRef.current.collections.find((c) => c.id === tableId) if (cached) return cached - const refreshed = await workspaceRef.current.refreshCollections() - return visible(refreshed.find((candidate) => candidate.id === tableId)) + // The refresh returns every table, so a miss here can say WHICH kind of + // table this is without a second request. + const refreshed = await workspaceRef.current.refreshTables() + const found = refreshed.find((candidate) => candidate.id === tableId) + if (found && CONTENT_KIND_VISIBLE.has(found.kind)) return found + + throw new Error(unusableCollectionMessage(tableId, found)) } const handle: ContentBridgeHandle = { @@ -133,15 +162,11 @@ export function useContentToolBridge({ return opened }, async selectCollection(tableId) { - const table = await resolveCollection(tableId) - if (!table) return false + await resolveCollection(tableId) flushSync(() => workspaceRef.current.selectCollection(tableId)) - return true }, async createDocument({ tableId, fields }) { - if (!(await resolveCollection(tableId))) { - throw new Error(`Collection ${tableId} not found.`) - } + await resolveCollection(tableId) const cells = fields ? normalizeEditableFields(fields) : {} // Create directly in the requested collection. The manual // createUntitledEntry action is intentionally tied to the currently @@ -154,7 +179,10 @@ export function useContentToolBridge({ opened = latestWorkspace.openEntry(created) if (opened) draftRef.current.applySelectedEntry(created) }) - if (!opened) throw new Error(`Collection ${tableId} not found.`) + if (!opened) { + const known = workspaceRef.current.tables.find((t) => t.id === tableId) + throw new Error(unusableCollectionMessage(tableId, known)) + } return created.id }, async deleteDocument(documentId) { diff --git a/src/admin/pages/content/hooks/useContentWorkspace.ts b/src/admin/pages/content/hooks/useContentWorkspace.ts index 8dc0f8ccc..e52216791 100644 --- a/src/admin/pages/content/hooks/useContentWorkspace.ts +++ b/src/admin/pages/content/hooks/useContentWorkspace.ts @@ -144,23 +144,24 @@ export function useContentWorkspace({ } /** - * Re-read the collection roster from the server and return it. + * Re-read the table roster from the server and return it. * - * The mount-time load is a snapshot: a collection created after it — by an + * The mount-time load is a snapshot: a table created after it — by an * import, another admin, or an MCP connector — is invisible to this * workspace until a reload, and every write against it fails with * "Collection not found". Callers that hit an unknown id refresh through * here and retry rather than making the operator reload the page. * - * Returns the fresh post-type list directly, so a caller can act on it in - * the same tick instead of waiting for a re-render. + * Returns EVERY table, not just the post types the sidebar shows, so a + * caller can tell "no such table" from "that table is not authored here" + * in the same tick instead of waiting for a re-render or asking the server + * a second time. */ - const refreshCollections = useCallback(async (): Promise => { + const refreshTables = useCallback(async (): Promise => { const allTables = await listCmsDataTables() - const nextCollections = allTables.filter((table) => table.kind === 'postType') setTables(allTables) - setCollections(nextCollections) - return nextCollections + setCollections(allTables.filter((table) => table.kind === 'postType')) + return allTables }, []) useEffect(() => { @@ -538,7 +539,7 @@ export function useContentWorkspace({ return { tables, collections, - refreshCollections, + refreshTables, entries, authors, authorsLoading, From 51c76fc58417d27ce550d014ecbbf95456cb8cd3 Mon Sep 17 00:00:00 2001 From: borskyj Date: Sat, 19 Sep 2026 15:51:23 +0200 Subject: [PATCH 2/7] feat(mcp): advertise tool annotations and structured output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tools/list` now carries the four MCP behaviour hints per tool (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`) and an `outputSchema` for every tool in the catalog, and a successful `tools/call` returns the payload as `structuredContent` beside the JSON text block. A client can tell a read from a delete before it calls, and parse a typed result instead of re-deriving the shape from a text blob. The server also reports its real package version instead of a hardcoded `1.0.0`. Result shapes live in the new `src/core/ai/toolOutputSchemas.ts`: a browser tool's result is produced in the browser and advertised by the server, and neither side may import the other. Nothing validates against them at runtime — drift has to fail a test, not a live install. Also fixes `data_create_rows` pointing agents at `data_list_tables` for the field ids its cells are keyed by. That tool does not return them, so the first call guessed and failed. The descriptions now name `content_get_collection_schema` for field ids and `content_list_documents` for reading rows back, and the in-app `data` chat scope gained those same three content reads — it could create a table and fill it, then had no way to see what it wrote. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 + docs/features/mcp-connectors.md | 21 + server/ai/mcp/e2e.test.ts | 10 + server/ai/mcp/registry.test.ts | 10 + server/ai/mcp/server.test.ts | 60 +++ server/ai/mcp/server.ts | 100 ++++- server/ai/mcp/tools/contextTool.ts | 2 + server/ai/mcp/tools/documentTools.ts | 3 +- server/ai/mcp/tools/publishTool.ts | 2 + server/ai/mcp/tools/styleTools.ts | 3 + server/ai/mcp/tools/uploadMediaTool.ts | 2 + server/ai/mcp/transports/http.test.ts | 4 +- server/ai/runtime/types.ts | 15 + server/ai/tools/content/readTools.test.ts | 14 + server/ai/tools/content/readTools.ts | 36 +- server/ai/tools/content/writeTools.ts | 9 + server/ai/tools/data/lifecycleTools.ts | 3 + server/ai/tools/data/rowTools.test.ts | 21 + server/ai/tools/data/rowTools.ts | 7 +- server/ai/tools/data/schemaTools.test.ts | 19 + server/ai/tools/data/schemaTools.ts | 7 +- server/ai/tools/index.test.ts | 52 +++ server/ai/tools/index.ts | 12 +- server/ai/tools/site/readTools.ts | 16 +- server/ai/tools/site/writeTools.ts | 46 ++ src/__tests__/agent/executor.test.ts | 7 + src/core/ai/index.ts | 1 + src/core/ai/toolOutputSchemas.ts | 498 ++++++++++++++++++++++ tsconfig.node.json | 3 +- 29 files changed, 969 insertions(+), 16 deletions(-) create mode 100644 server/ai/tools/index.test.ts create mode 100644 src/core/ai/toolOutputSchemas.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 924c6b98d..0d2c5d924 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ This project is pre-1.0. Breaking changes may appear in minor or patch releases ### AI and integrations - Added a `data_*` tool scope so reusable data tables can be built and filled headlessly, over MCP or from the in-app agent ([#433](https://github.com/CoreBunch/Instatic/issues/433), [#463](https://github.com/CoreBunch/Instatic/issues/463)). Schema setup was the one manual break in an otherwise automatable pipeline: `content_list_collections` listed post types only, `content_create_document` refused a `kind: 'data'` table id, and no tool could create a table or a field at all, so an agent asked to build a training catalogue had to stop and hand the operator a list of columns to type in. Eight tools now cover the whole surface — `data_list_tables`, `data_create_table`, `data_update_table`, `data_add_fields`, `data_create_rows`, `data_update_row`, `data_set_rows_status`, `data_delete_rows` — all server-resolved, so none of them needs a workspace tab open. Row creation is transactional in batches of up to 200, and the system tables still refuse any change to their identity or built-in fields. +- Added tool annotations and structured output to the MCP surface. `tools/list` now carries the four MCP behaviour hints per tool (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`) and an `outputSchema` for every tool in the catalog, and a successful `tools/call` returns the payload as `structuredContent` alongside the JSON text block. A client can tell a read from a delete before it calls, and parse a typed result instead of re-deriving the shape from a text blob. The server also reports its real package version instead of a hardcoded `1.0.0`. +- Fixed `data_create_rows` sending agents to `data_list_tables` for the field ids its cells are keyed by — that tool does not return them, so the first call guessed and failed. The descriptions now name `content_get_collection_schema` for field ids and `content_list_documents` for reading rows back, and the in-app `data` chat scope gained those same three content reads: it could create a table and fill it, then had no way to see what it wrote. - Fixed the Content workspace answering "Collection not found" for a reusable data table that exists. The id was right and the table was real; it is simply authored in the Data workspace, not in the Tiptap editor. Agents responded by creating a duplicate post type to stand in for it. The refusal now names the kind of table it is and the tool that can write it. ## 0.0.20 - 2026-09-13 diff --git a/docs/features/mcp-connectors.md b/docs/features/mcp-connectors.md index 382695bf5..16928821a 100644 --- a/docs/features/mcp-connectors.md +++ b/docs/features/mcp-connectors.md @@ -146,6 +146,25 @@ executeAiTool(...) / live editor bridge | `tools/uploadMediaTool.ts` | Server-resolved image upload (`media_upload`) — inline base64 or SSRF-guarded `sourceUrl` download, through the shared media pipeline. | | `../tools/data/` | Server-resolved schema and row tools for reusable data tables (`data_*`). Shared with the in-app `data` chat scope; the MCP registry passes a runtime so writes are attributed to the connection. | +## What `tools/list` advertises + +Each tool ships three things beyond its name and description. + +`inputSchema` is the tool's TypeBox schema emitted verbatim as JSON Schema. The same schema re-validates the arguments inside `executeAiTool`, so the advertised contract and the enforced one cannot drift. + +`outputSchema` describes the result. The server sends the payload twice on success: as JSON text in `content`, for clients that read only that, and as `structuredContent`, a typed object matching the schema. Nothing validates a result against its schema at runtime — a shape drift must fail a test, never break a tool on a live install — so the catalog test in `registry.test.ts` requires every advertised tool to declare one and the per-tool tests assert the match. Schemas live in `src/core/ai/toolOutputSchemas.ts`, in core because browser tools produce their results in the browser and the server advertises them. Where a tool forwards a structure another engine owns (a page-node tree, a module's prop schema) the leaf stays `unknown` with a description rather than a second definition to drift from the first. + +`annotations` carry the four MCP behaviour hints, derived in `server.ts`: + +| Hint | How it is set | +|---|---| +| `readOnlyHint` | true unless the tool is tagged `mutates` | +| `destructiveHint` | true for the row/document/node/page deletes, `data_update_table` (dropping a field orphans its values), and `site_publish` (overwrites the live slot) | +| `idempotentHint` | true for reads and for writes that land in the same state when repeated — status changes, deletes, field and token setters | +| `openWorldHint` | always false; every tool reaches this install's own database and uploads, nothing else | + +They are hints for a client's confirmation UI, not a security boundary. Capabilities are the boundary. + ## Tool execution model MCP exposes the full deduplicated tool catalog, filtered by the connection's capabilities. @@ -171,6 +190,8 @@ They get their own headless toolset instead of being folded into `content_*`, be | `data_set_rows_status` | Publishes, unpublishes, or drafts rows in bulk, reporting per-row outcomes. | publish for `published`, edit otherwise | | `data_delete_rows` | Soft-deletes rows in bulk. | a content edit capability | +Reading rows back is `content_list_documents` and `content_get_document`: both accept a reusable data table's id exactly as they accept a post type's, and `content_get_collection_schema` returns the field ids that `data_create_rows` keys its cells by. There is deliberately no `data_list_rows` / `data_get_row` duplicating them; the three descriptions point at each other so an agent finds the path from either side. + Publishing a row in a table with no route base is allowed and normal. No static artefact is baked because there is no route to bake it at, but the row becomes `published`, which is what an `` on some other page reads. The system tables (`pages`, `posts`, `components`, `layouts`) accept new custom fields but refuse any change to their identity or their built-in fields, enforced by the same `assertSystemTableUpdateAllowed` the HTTP route uses. diff --git a/server/ai/mcp/e2e.test.ts b/server/ai/mcp/e2e.test.ts index b600fcd55..ff92e6509 100644 --- a/server/ai/mcp/e2e.test.ts +++ b/server/ai/mcp/e2e.test.ts @@ -91,11 +91,21 @@ describe('MCP end-to-end (2026-07-28 stateless requests, real handler)', () => { expect(names).toContain('site_read_styles') // headless design-system read expect(names).toContain('site_insert_html') // browser editing tool, relayed to the editor + // Every listed tool carries its behaviour hints and a result schema. + const listed = list.json.result?.tools ?? [] + const listTables = listed.find((t) => t.name === 'data_list_tables') + expect(listTables?.annotations).toMatchObject({ readOnlyHint: true, openWorldHint: false }) + expect(listTables?.outputSchema?.type).toBe('object') + expect(listed.every((t) => t.outputSchema?.type === 'object')).toBe(true) + const read = await rpc('tools/call', { name: 'content_list_collections', arguments: {} }) expect(read.json.result?.isError).toBeFalsy() const content = JSON.stringify(read.json.result?.content) expect(content).toContain('posts') expect(content).not.toContain('"id":"pages"') + // The same payload as typed data, so a client parses it instead of the blob. + const structured = read.json.result?.structuredContent as { collections: Array<{ id: string }> } + expect(structured.collections.map((c) => c.id)).toContain('posts') }) it('a read-only connector sees reads but no write tools', async () => { diff --git a/server/ai/mcp/registry.test.ts b/server/ai/mcp/registry.test.ts index c13dc447a..f2e6ac39b 100644 --- a/server/ai/mcp/registry.test.ts +++ b/server/ai/mcp/registry.test.ts @@ -133,4 +133,14 @@ describe('mcp registry', () => { // Row writes ride content.* capabilities, so they survive. expect(noTableManage).toContain('data_create_rows') }) + + it('advertises an outputSchema for every tool in the catalog', () => { + // The MCP surface promises structured output. A tool added without an + // `outputSchema` would silently ship `structuredContent` no client can + // interpret, so the catalog is the gate rather than each tool file. + const missing = mcpToolsForCapabilities(FULL) + .filter((t) => t.outputSchema === undefined) + .map((t) => t.name) + expect(missing).toEqual([]) + }) }) diff --git a/server/ai/mcp/server.test.ts b/server/ai/mcp/server.test.ts index 706c503fa..17b5763cf 100644 --- a/server/ai/mcp/server.test.ts +++ b/server/ai/mcp/server.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, beforeEach } from 'bun:test' +import { Value } from '@sinclair/typebox/value' import { InMemoryTransport } from '@modelcontextprotocol/server' import { Client } from '@modelcontextprotocol/client' import { createSqliteClient } from '../../db/sqlite' @@ -7,6 +8,7 @@ import { runMigrations } from '../../db/runMigrations' import type { DbClient } from '../../db/client' import { createDataRow } from '../../repositories/data' import { resolveBridgeToolResult } from '../runtime' +import { mcpToolsForCapabilities } from './registry' import { buildMcpServer } from './server' import { createEditorBridgeStream } from './editorBridge' import { MAIN_SCOPE } from '../../branches/scope' @@ -97,6 +99,64 @@ describe('mcp server', () => { await client.close() }) + it('annotates each tool with read-only, destructive and idempotent hints', async () => { + const client = await connectClient(db, ['ai.chat', 'ai.tools.write', 'site.read', 'content.manage', 'content.create', 'content.edit.any', 'data.custom.tables.manage', 'data.system.tables.read']) + const { tools } = await client.listTools() + const byName = new Map(tools.map((t) => [t.name, t])) + + // A read cannot change anything, so repeating it is safe by definition. + expect(byName.get('data_list_tables')?.annotations).toMatchObject({ + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }) + // A delete loses data, but deleting the same rows twice ends in one state. + expect(byName.get('data_delete_rows')?.annotations).toMatchObject({ + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + }) + // An insert appends: calling it twice inserts twice. + expect(byName.get('data_create_rows')?.annotations).toMatchObject({ + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }) + // Nothing here reaches outside this install's own database and uploads. + expect(tools.every((t) => t.annotations?.openWorldHint === false)).toBe(true) + + await client.close() + }) + + it('advertises an outputSchema with a JSON Schema object root for every tool', async () => { + const client = await connectClient(db, ['ai.chat', 'ai.tools.write', 'site.read', 'content.manage', 'data.custom.tables.manage', 'data.system.tables.read']) + const { tools } = await client.listTools() + expect(tools.length).toBeGreaterThan(0) + for (const tool of tools) { + expect(tool.outputSchema).toBeTruthy() + // The MCP wire has no place for an `allOf`/`anyOf` root — a client that + // reads `properties` would find none. + expect(tool.outputSchema?.type).toBe('object') + } + await client.close() + }) + + it('returns structuredContent that matches the advertised outputSchema', async () => { + const client = await connectClient(db, ['ai.chat', 'site.read', 'data.system.tables.read']) + const { tools } = await client.listTools() + expect(tools.find((t) => t.name === 'content_list_collections')?.outputSchema).toBeTruthy() + // Check against the TypeBox source, not the JSON Schema on the wire: the + // wire copy has had its TypeBox symbols stripped, so it is data, not a + // validator. + const schema = mcpToolsForCapabilities(['ai.chat', 'site.read', 'data.system.tables.read']) + .find((t) => t.name === 'content_list_collections')!.outputSchema! + + const result = await client.callTool({ name: 'content_list_collections', arguments: {} }) + expect(result.isError).toBeFalsy() + expect(Value.Check(schema, result.structuredContent)).toBe(true) + await client.close() + }) + it('advertises the open-workspace requirement on browser tools, not on headless ones', async () => { const client = await connectClient(db, ['ai.chat', 'ai.tools.write', 'site.structure.edit', 'content.manage']) const { tools } = await client.listTools() diff --git a/server/ai/mcp/server.ts b/server/ai/mcp/server.ts index 52dd309f3..e34a264d5 100644 --- a/server/ai/mcp/server.ts +++ b/server/ai/mcp/server.ts @@ -13,7 +13,9 @@ import { type CallToolResult, type JSONValue, type Tool, + type ToolAnnotations, } from '@modelcontextprotocol/server' +import type { TSchema } from '@core/utils/typeboxHelpers' import type { DbClient } from '../../db/client' import type { CoreCapability } from '@core/capabilities' import { getErrorMessage } from '@core/utils/errorMessage' @@ -24,6 +26,7 @@ import { authorizeMcpContentTool } from './contentAuthorization' import { getEditorBridgeBranch, getEditorBridgeForUser, type EditorBridgeScope } from './editorBridge' import { runPublishFlush } from '../../publish/publishFlush' import { MAIN_SCOPE } from '../../branches/scope' +import { version as INSTATIC_VERSION } from '../../../package.json' export interface McpServerContext { db: DbClient @@ -96,7 +99,7 @@ function plainJsonValue(value: unknown): JSONValue { return out } -function plainInputSchema(schema: AiTool['inputSchema']): Tool['inputSchema'] { +function plainObjectSchema(schema: TSchema, label: string): Tool['inputSchema'] { const value = plainJsonValue(schema) if ( value === null || @@ -104,14 +107,84 @@ function plainInputSchema(schema: AiTool['inputSchema']): Tool['inputSchema'] { typeof value !== 'object' || value.type !== 'object' ) { - throw new TypeError('MCP tool input schema must be a JSON Schema object') + throw new TypeError(`MCP tool ${label} schema must be a JSON Schema object`) } return value as Tool['inputSchema'] } +function isJsonObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +// --------------------------------------------------------------------------- +// Tool annotations +// --------------------------------------------------------------------------- + +/** + * Tools whose effect cannot be undone from the tool surface itself. + * + * `mutates` already separates reads from writes; this is the narrower + * question a client asks before prompting a human. Row deletes are soft and + * recoverable in the database, but not through any tool here, so they count. + * `data_update_table` is included because its `fields` array REPLACES the + * schema — an omitted field orphans every value stored under it. + */ +const DESTRUCTIVE_TOOLS: ReadonlySet = new Set([ + 'data_delete_rows', + 'data_update_table', + 'content_delete_document', + 'site_delete_node', + 'site_delete_page', + 'site_publish', +]) + +/** + * Tools where a repeat call with the same arguments lands on the same state. + * Setting a status or a token set is idempotent; inserting a node or creating + * a row is not — calling it twice produces two of the thing. + */ +const IDEMPOTENT_TOOLS: ReadonlySet = new Set([ + 'data_set_rows_status', + 'data_delete_rows', + 'data_update_row', + 'data_update_table', + 'content_set_document_status', + 'content_set_document_field', + 'content_set_document_fields', + 'content_set_document_author', + 'content_delete_document', + 'site_set_color_tokens', + 'site_set_font_tokens', + 'site_set_type_scale', + 'site_set_spacing_scale', + 'site_set_page_template', + 'site_clear_page_template', + 'site_delete_node', + 'site_delete_page', +]) + +/** + * Behavioural hints for the client, derived from what the registry already + * knows. They are hints, not a security boundary — `toolAllowedForCapabilities` + * and the per-tool capability re-check are what actually gate a call. + * + * `openWorldHint` is false throughout: every tool reads or writes this + * instance's own database, uploads directory, and open editor. None of them + * reaches an external service whose result could vary independently. + */ +function advertisedAnnotations(tool: AiTool): ToolAnnotations { + const readOnly = tool.mutates !== true + return { + readOnlyHint: readOnly, + destructiveHint: !readOnly && DESTRUCTIVE_TOOLS.has(tool.name), + idempotentHint: readOnly || IDEMPOTENT_TOOLS.has(tool.name), + openWorldHint: false, + } +} + export function buildMcpServer(ctx: McpServerContext): Server { const server = new Server( - { name: 'instatic', version: '1.0.0' }, + { name: 'instatic', version: INSTATIC_VERSION }, { capabilities: { tools: {} } }, ) @@ -130,15 +203,24 @@ export function buildMcpServer(ctx: McpServerContext): Server { // Every MCP tool schema is a Type.Object. Remove TypeBox's symbol-keyed // runtime annotations before handing the otherwise unchanged JSON Schema // to the v2 wire validator. - inputSchema: plainInputSchema(t.inputSchema), + inputSchema: plainObjectSchema(t.inputSchema, 'input'), + ...(t.outputSchema + ? { outputSchema: plainObjectSchema(t.outputSchema, 'output') } + : {}), + annotations: advertisedAnnotations(t), })), })) server.setRequestHandler('tools/call', async (request, requestContext): Promise => { const { name, arguments: args } = request.params const tool = byName.get(name) + // The advertised schema is what the SDK's era projection reconciles a + // result against, so hand it the same object `tools/list` published. + const advertisedOutput = tool?.outputSchema + ? plainObjectSchema(tool.outputSchema, 'output') + : undefined const project = (result: CallToolResult) => - server.projectCallToolResult(result, undefined) + server.projectCallToolResult(result, advertisedOutput) if (!tool) { return project({ isError: true, content: [{ type: 'text', text: `Unknown tool: ${name}` }] }) } @@ -226,13 +308,19 @@ export function buildMcpServer(ctx: McpServerContext): Server { // read as an unambiguous success — never the literal "null". const payload = output.data === undefined || output.data === null ? { ok: true } : output.data const content: CallToolResult['content'] = [{ type: 'text', text: JSON.stringify(payload) }] + // Ship the payload as `structuredContent` too, so a client parses the + // result instead of re-deriving its shape from the text block. Only when + // it is an object: the 2025 wire shape requires one, and a tool returning + // a bare array would otherwise be wrapped as `{ result: … }` and stop + // matching its own advertised `outputSchema`. + const structuredContent = isJsonObject(payload) ? payload : undefined // Forward image attachments (e.g. render_snapshot's PNG) as MCP image // content blocks so vision clients actually receive the screenshot — they // travel on `output.images`, never inlined into the text payload. for (const image of output.images ?? []) { content.push({ type: 'image', data: image.data, mimeType: image.mimeType }) } - return project({ content }) + return project(structuredContent ? { content, structuredContent } : { content }) }) return server diff --git a/server/ai/mcp/tools/contextTool.ts b/server/ai/mcp/tools/contextTool.ts index 632e556d5..9c44326b6 100644 --- a/server/ai/mcp/tools/contextTool.ts +++ b/server/ai/mcp/tools/contextTool.ts @@ -11,6 +11,7 @@ * come straight from the DB. No browser snapshot. */ import { Type } from '@core/utils/typeboxHelpers' +import { GetContextOutputSchema } from '@core/ai' import type { CoreCapability } from '@core/capabilities' import type { AiTool, ToolContext } from '../../runtime/types' import { getDraftSite } from '../../../repositories/site' @@ -55,6 +56,7 @@ export const contextMcpTools: AiTool[] = [ scope: 'site', execution: 'server', inputSchema: GetContextInput, + outputSchema: GetContextOutputSchema, requiredCapabilities: CONTEXT_READ_CAPS, handler: async (input, ctx: ToolContext) => { const { entryId } = input as { entryId?: string } diff --git a/server/ai/mcp/tools/documentTools.ts b/server/ai/mcp/tools/documentTools.ts index 2bf3af9e5..a17616d15 100644 --- a/server/ai/mcp/tools/documentTools.ts +++ b/server/ai/mcp/tools/documentTools.ts @@ -14,7 +14,7 @@ * no document is marked active/current; `get_context` reports the live editor. */ import { Type } from '@core/utils/typeboxHelpers' -import { describeAgentDocuments } from '@core/ai' +import { describeAgentDocuments, SiteListDocumentsOutputSchema } from '@core/ai' import type { AiTool, ToolContext } from '../../runtime/types' import { getDraftSiteDocument } from '../../../repositories/publish' import { MAIN_SCOPE } from '../../../branches/scope' @@ -27,6 +27,7 @@ export const documentMcpTools: AiTool[] = [ scope: 'site', execution: 'server', inputSchema: Type.Object({}, { additionalProperties: false }), + outputSchema: SiteListDocumentsOutputSchema, requiredCapabilities: ['site.read'], handler: async (_input, ctx: ToolContext) => { const site = await getDraftSiteDocument(ctx.db, MAIN_SCOPE) diff --git a/server/ai/mcp/tools/publishTool.ts b/server/ai/mcp/tools/publishTool.ts index cd145c748..bd8a1147f 100644 --- a/server/ai/mcp/tools/publishTool.ts +++ b/server/ai/mcp/tools/publishTool.ts @@ -9,6 +9,7 @@ * static slot, swaps it atomically, and bumps the in-memory publish version. */ import { Type } from '@core/utils/typeboxHelpers' +import { SitePublishOutputSchema } from '@core/ai' import type { AiTool, ToolContext } from '../../runtime/types' import { createAuditEvent } from '../../../repositories/audit' import { publishDraftSite } from '../../../publish/publishSite' @@ -28,6 +29,7 @@ export function createPublishMcpTool(runtime?: McpPublishRuntime): AiTool { mutates: true, requiredCapabilities: ['pages.publish'], inputSchema: Type.Object({}, { additionalProperties: false }), + outputSchema: SitePublishOutputSchema, handler: async (_input, ctx: ToolContext) => { if (!runtime) { throw new Error('MCP publish runtime uploads directory is not configured.') diff --git a/server/ai/mcp/tools/styleTools.ts b/server/ai/mcp/tools/styleTools.ts index 08bdcbb48..5349bb68c 100644 --- a/server/ai/mcp/tools/styleTools.ts +++ b/server/ai/mcp/tools/styleTools.ts @@ -17,6 +17,7 @@ import { Type } from '@core/utils/typeboxHelpers' import { isGeneratedClass, styleRuleSelector, type SiteDocument, type StyleRule } from '@core/page-tree' import { generateFontTokenVariablesCss } from '@core/fonts' import { generateClassCSS, generateFrameworkCss } from '@core/publisher' +import { SiteListBreakpointsOutputSchema, SiteReadStylesOutputSchema } from '@core/ai' import type { CoreCapability } from '@core/capabilities' import type { AiTool, ToolContext } from '../../runtime/types' import { getDraftSite } from '../../../repositories/site' @@ -60,6 +61,7 @@ export const styleMcpTools: AiTool[] = [ scope: 'site', execution: 'server', inputSchema: ReadStylesInput, + outputSchema: SiteReadStylesOutputSchema, requiredCapabilities: SITE_READ_CAPS, handler: async (input, ctx: ToolContext) => { const { format = 'full', className, includeTokens = true } = input as { @@ -121,6 +123,7 @@ export const styleMcpTools: AiTool[] = [ scope: 'site', execution: 'server', inputSchema: Type.Object({}, { additionalProperties: false }), + outputSchema: SiteListBreakpointsOutputSchema, requiredCapabilities: SITE_READ_CAPS, handler: async (_input, ctx: ToolContext) => { const site = await getDraftSite(ctx.db, MAIN_SCOPE) diff --git a/server/ai/mcp/tools/uploadMediaTool.ts b/server/ai/mcp/tools/uploadMediaTool.ts index af06cffa0..5a9cd154f 100644 --- a/server/ai/mcp/tools/uploadMediaTool.ts +++ b/server/ai/mcp/tools/uploadMediaTool.ts @@ -18,6 +18,7 @@ */ import { Type } from '@core/utils/typeboxHelpers' import type { Static } from '@core/utils/typeboxHelpers' +import { MediaUploadOutputSchema } from '@core/ai' import type { AiTool, ToolContext } from '../../runtime/types' import { IMAGE_MIMES, @@ -115,6 +116,7 @@ export const uploadMediaMcpTool: AiTool = { mutates: true, requiredCapabilities: ['media.write'], inputSchema: UploadMediaInput, + outputSchema: MediaUploadOutputSchema, handler: async (input, ctx: ToolContext) => { const args = input as UploadMediaArgs const hasData = typeof args.data === 'string' && args.data.length > 0 diff --git a/server/ai/mcp/transports/http.test.ts b/server/ai/mcp/transports/http.test.ts index bad978181..68ba628b0 100644 --- a/server/ai/mcp/transports/http.test.ts +++ b/server/ai/mcp/transports/http.test.ts @@ -5,6 +5,7 @@ import { runMigrations } from '../../../db/runMigrations' import type { DbClient } from '../../../db/client' import { createBearerConnection } from '../connectors/store' import { generatePersonalAccessToken, hashMcpSecret } from '../connectors/token' +import { version as INSTATIC_VERSION } from '../../../../package.json' import { handleMcpHttp } from './http' function initBody() { @@ -216,7 +217,8 @@ describe('mcp http transport', () => { expect(res?.headers.get('Mcp-Session-Id')).toBeNull() const rpc = parseRpcBody(await res!.text()) expect(rpc.result?.protocolVersion).toBe('2025-06-18') - expect(rpc.result?.serverInfo).toEqual({ name: 'instatic', version: '1.0.0' }) + // The install's real version, so a client's logs name a build that exists. + expect(rpc.result?.serverInfo).toEqual({ name: 'instatic', version: INSTATIC_VERSION }) }) it('rejects an oversized request before MCP protocol dispatch', async () => { diff --git a/server/ai/runtime/types.ts b/server/ai/runtime/types.ts index afdc8f51b..24ce41613 100644 --- a/server/ai/runtime/types.ts +++ b/server/ai/runtime/types.ts @@ -97,6 +97,21 @@ export interface AiTool { readonly scope: ToolScope | 'shared' readonly execution: ToolExecution readonly inputSchema: TSchema + /** + * Shape of a successful result's `data`, as JSON Schema. + * + * Only the MCP surface reads it: `tools/list` advertises it and + * `tools/call` ships the payload as `structuredContent`, so a client can + * parse a result instead of re-deriving its shape from the JSON text block. + * The in-app drivers ignore it — a provider tool definition carries no + * output schema. + * + * NOT validated at runtime, deliberately. A drift between this and what a + * handler returns must surface as a failing test, never as a tool that + * stops working in production. `server/ai/mcp/registry.test.ts` requires + * every advertised tool to declare one. + */ + readonly outputSchema?: TSchema /** * Does this tool mutate state? Read tools (snapshot, search, list) are * pure reads against the db / store; write tools (insertHtml, diff --git a/server/ai/tools/content/readTools.test.ts b/server/ai/tools/content/readTools.test.ts index 93c83dbed..af8ec9416 100644 --- a/server/ai/tools/content/readTools.test.ts +++ b/server/ai/tools/content/readTools.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { Value } from '@sinclair/typebox/value' import { createCapabilityTestHarness, type CapabilityTestHarness } from '../../../../src/__tests__/helpers/capabilityHarness' import { createDataTable } from '../../../repositories/data' import { contentReadTools } from './readTools' @@ -55,5 +56,18 @@ describe('content read tools', () => { 'projects', ]) expect(result.collections.every((collection) => collection.kind === 'postType')).toBe(true) + + const schema = tool.outputSchema + expect(schema).toBeTruthy() + expect(Value.Check(schema!, result)).toBe(true) + }) + + it('says the document reads accept a reusable data table too', () => { + // data_* can write rows but not read them back; these two are the read + // path, and an agent only finds that out from the description. + const list = contentReadTools.find((t) => t.name === 'content_list_documents') + const get = contentReadTools.find((t) => t.name === 'content_get_document') + expect(list?.description).toContain('reusable data table') + expect(get?.description).toContain('reusable data table') }) }) diff --git a/server/ai/tools/content/readTools.ts b/server/ai/tools/content/readTools.ts index 041c51632..565237268 100644 --- a/server/ai/tools/content/readTools.ts +++ b/server/ai/tools/content/readTools.ts @@ -14,6 +14,15 @@ import { Type, type Static } from '@core/utils/typeboxHelpers' import type { CoreCapability } from '@core/capabilities' import type { AiTool } from '../types' +import { + ContentGetCollectionSchemaOutputSchema, + ContentGetDocumentOutputSchema, + ContentListCollectionsOutputSchema, + ContentListDocumentsOutputSchema, + ContentListMediaOutputSchema, + ContentListUsersOutputSchema, + ContentSearchDocumentsOutputSchema, +} from '@core/ai' import { getDataRow, listDataAuthorOptions, @@ -137,6 +146,7 @@ const listCollectionsTool: AiTool = { description: 'List every Content-workspace collection (routable post types only) with id, slug, label, kind, row count, and primary field id. Pages are edited through the site_* tools; reusable data tables through the data_* tools — call data_list_tables to see those, they are NOT listed here.', inputSchema: ListCollectionsInput, + outputSchema: ContentListCollectionsOutputSchema, handler: async (_input, ctx) => { const tables = await listDataTablesWithCounts(ctx.db, ctx.branch) return { @@ -163,6 +173,7 @@ const getCollectionSchemaTool: AiTool = { description: "Return one collection's field schema: each field's id, label, type, required flag, builtIn flag, and per-type extras (select options, media kind, relation target). Call BEFORE content_set_document_field on an unfamiliar collection so you know the field's value shape. Accepts any table id, including a reusable data table that content_list_collections does not list — its rows are written with data_create_rows / data_update_row.", inputSchema: GetCollectionSchemaInput, + outputSchema: ContentGetCollectionSchemaOutputSchema, handler: async (input, ctx) => { const { tableId } = input as Static const tables = await listDataTablesWithCounts(ctx.db, ctx.branch) @@ -203,8 +214,9 @@ const listDocumentsTool: AiTool = { execution: 'server', requiredCapabilities: DOCUMENT_READ_CAPS, description: - 'List documents in one collection. Returns id, title, slug, status, authorUserId, updatedAt — light projection. Filter by status / authorUserId, paginate with limit (default 25, max 200) + offset.', + 'List documents in one collection. Accepts a reusable data table id (from data_list_tables) as well as a post type id. Returns id, title, slug, status, authorUserId, updatedAt — light projection. Filter by status / authorUserId, paginate with limit (default 25, max 200) + offset.', inputSchema: ListDocumentsInput, + outputSchema: ContentListDocumentsOutputSchema, handler: async (input, ctx) => { const args = input as Static const all = await listDataRows(ctx.db, ctx.branch, args.tableId) @@ -237,8 +249,9 @@ const getDocumentTool: AiTool = { execution: 'server', requiredCapabilities: DOCUMENT_READ_CAPS, description: - "Return one document's full state: every field value (body is a markdown string), status, author, slug, timestamps. Use for the doc the user wants to edit when it isn't the active doc, or to refresh state after another agent action.", + "Return one document's full state: every field value (body is a markdown string), status, author, slug, timestamps. Accepts a row of a reusable data table as well as a post. Use for the doc the user wants to edit when it isn't the active doc, or to refresh state after another agent action.", inputSchema: GetDocumentInput, + outputSchema: ContentGetDocumentOutputSchema, handler: async (input, ctx) => { const { documentId } = input as Static const row = await getDataRow(ctx.db, ctx.branch, documentId) @@ -280,6 +293,7 @@ const searchDocumentsTool: AiTool = { description: "Full-text search across document slugs (the slug is a URL-safe derivative of the title — reliable text proxy for free-text lookup) in post-type collections only; rows of a reusable data table are not searched here. Returns light summaries (id, tableId, slug, status, updatedAt). `limit` default 25, max 100.", inputSchema: SearchDocumentsInput, + outputSchema: ContentSearchDocumentsOutputSchema, handler: async (input, ctx) => { const { query, limit } = input as Static const results = await searchDataRows(ctx.db, ctx.branch, query, limit ?? 25) @@ -319,6 +333,7 @@ const listUsersTool: AiTool = { description: 'List active users available as document authors (id, email, displayName, roleSlug, roleName). Use to look up an author id before content_set_document_author.', inputSchema: ListUsersInput, + outputSchema: ContentListUsersOutputSchema, handler: async (_input, ctx) => { const users = await listDataAuthorOptions(ctx.db) return { users } @@ -343,6 +358,7 @@ const listMediaTool: AiTool = { description: "List existing media assets so you can pick one for a media-typed field. Returns id, filename, publicPath, mimeType, altText, width, height. Optional `query` substring-matches filename + altText (case-insensitive); `mimeType` substring-matches the mime (e.g. 'image' to filter to images). `limit` default 25, max 100. To add a new image, use media_upload.", inputSchema: ListMediaInput, + outputSchema: ContentListMediaOutputSchema, handler: async (input, ctx) => { const args = input as Static const all = await listMediaAssets(ctx.db) @@ -385,3 +401,19 @@ export const contentReadTools: AiTool[] = [ listUsersTool, listMediaTool, ] + +/** + * The three reads that are about a TABLE, not about the Content workspace. + * + * All three resolve any table id, reusable `kind: 'data'` tables included, so + * they are the row-read half of the `data_*` toolset — which writes rows but + * has no reader of its own. The `data` chat scope re-exports them + * (`server/ai/tools/index.ts`); the MCP registry gets them anyway from the + * whole content set. Exported as a named subset rather than through + * `./index`, because that barrel stamps `mutates` across the full toolset. + */ +export const tableScopedReadTools: AiTool[] = [ + getCollectionSchemaTool, + listDocumentsTool, + getDocumentTool, +] diff --git a/server/ai/tools/content/writeTools.ts b/server/ai/tools/content/writeTools.ts index c0d40422e..54d928dfb 100644 --- a/server/ai/tools/content/writeTools.ts +++ b/server/ai/tools/content/writeTools.ts @@ -15,6 +15,7 @@ import { Type } from '@core/utils/typeboxHelpers' import type { CoreCapability } from '@core/capabilities' import type { AiTool } from '../types' +import { AcknowledgementOutputSchema, ContentCreateDocumentOutputSchema } from '@core/ai' // `fields` is a free-form `Record`. Per-type validation // happens on the browser bridge (it knows the collection's field schema). @@ -69,6 +70,7 @@ const createDocumentTool: AiTool = { description: "Create a new draft document in a POST TYPE (`tableId` must be one content_list_collections returned). `fields` is a Record per the collection's schema; omit to create an empty draft. Success data includes the new id as `documentId`; the bridge auto-switches the user's editor to the new doc so they can see what you built. Use content_set_document_status separately to publish or schedule it. For a row in a reusable data table, use data_create_rows instead — this tool needs the Content editor open and cannot write one.", inputSchema: CreateDocumentInput, + outputSchema: ContentCreateDocumentOutputSchema, } // --------------------------------------------------------------------------- @@ -87,6 +89,7 @@ const deleteDocumentTool: AiTool = { description: 'Soft-delete a document. User can restore via the Trash UI.', inputSchema: DeleteDocumentInput, + outputSchema: AcknowledgementOutputSchema, } // --------------------------------------------------------------------------- @@ -107,6 +110,7 @@ const setDocumentStatusTool: AiTool = { description: "Set the document's lifecycle status. `status='scheduled'` requires `scheduledAt` (ISO datetime). Publishing requires the user to hold content.publish.own (own docs) or content.publish.any (any doc).", inputSchema: SetDocumentStatusInput, + outputSchema: AcknowledgementOutputSchema, } // --------------------------------------------------------------------------- @@ -127,6 +131,7 @@ const setDocumentFieldTool: AiTool = { description: "Write one field on a document. The document MUST be the active one — call content_set_active_document first, or the write is refused. (content_create_document leaves the new document active, so create-then-fill needs no extra call.) `value` shape depends on the field type (read content_get_collection_schema first if unsure): text/longText/richText/url/email → string; number → number; boolean → boolean; date/dateTime → ISO string; select → option id; multiSelect → option id[]; media → { id } or { id }[]; relation → { rowId } or { rowId }[]; body → markdown string. Bridge converts markdown ↔ Tiptap automatically for body.", inputSchema: SetDocumentFieldInput, + outputSchema: AcknowledgementOutputSchema, } // --------------------------------------------------------------------------- @@ -146,6 +151,7 @@ const setDocumentFieldsTool: AiTool = { description: 'Batch-write multiple fields on one document. The document MUST be the active one — call content_set_active_document first, or the write is refused. `fields` is Record; same per-type shapes as content_set_document_field. Prefer this when generating a whole post (title + slug + body + seo* in one call), and when filling several documents in sequence set each one active before writing to it.', inputSchema: SetDocumentFieldsInput, + outputSchema: AcknowledgementOutputSchema, } // --------------------------------------------------------------------------- @@ -165,6 +171,7 @@ const setDocumentAuthorTool: AiTool = { description: 'Reassign the document author to another user. Requires the caller to hold content.edit.any. Use content_list_users to find the right user id.', inputSchema: SetDocumentAuthorInput, + outputSchema: AcknowledgementOutputSchema, } // --------------------------------------------------------------------------- @@ -182,6 +189,7 @@ const setActiveDocumentTool: AiTool = { description: "Switch the user's editor to this document so they can watch you work. Call BEFORE editing a doc that isn't already open — the user only sees the active doc, so content_set_document_field on a non-active doc happens invisibly.", inputSchema: SetActiveDocumentInput, + outputSchema: AcknowledgementOutputSchema, } // --------------------------------------------------------------------------- @@ -199,6 +207,7 @@ const setActiveCollectionTool: AiTool = { description: 'Switch the workspace sidebar focus to this collection. Use when working across collection-level actions (browsing, bulk reviews). Only post types can be focused; a reusable data table is refused with a message naming the data_* tool that can write it.', inputSchema: SetActiveCollectionInput, + outputSchema: AcknowledgementOutputSchema, } // --------------------------------------------------------------------------- diff --git a/server/ai/tools/data/lifecycleTools.ts b/server/ai/tools/data/lifecycleTools.ts index 1457a705c..a3b34e2d6 100644 --- a/server/ai/tools/data/lifecycleTools.ts +++ b/server/ai/tools/data/lifecycleTools.ts @@ -20,6 +20,7 @@ import { Type, type Static } from '@core/utils/typeboxHelpers' import type { CoreCapability } from '@core/capabilities' import type { DataRow, DataRowStatus } from '@core/data/schemas' +import { DataDeleteRowsOutputSchema, DataSetRowsStatusOutputSchema } from '@core/ai' import type { AiTool, ToolContext } from '../../runtime/types' import { createAuditEvent, type AuditAction } from '../../../repositories/audit' import { @@ -84,6 +85,7 @@ function setRowsStatusTool(runtime?: DataToolsRuntime): AiTool { description: `Publish, unpublish, or return to draft up to ${MAX_ROWS_PER_CALL} rows. Rows are processed one by one and the result lists what succeeded and what did not — a failure part-way through does not roll back the rows already published. Publishing a row in a table with no route base still makes it visible to loops on other pages; it just gets no public URL of its own.`, inputSchema: SetRowsStatusInput, + outputSchema: DataSetRowsStatusOutputSchema, handler: async (input, ctx: ToolContext) => { const args = input as Static const updated: Array<{ id: string; slug: string; status: DataRowStatus }> = [] @@ -171,6 +173,7 @@ function deleteRowsTool(runtime?: DataToolsRuntime): AiTool { description: `Delete up to ${MAX_ROWS_PER_CALL} rows. The delete is soft — the rows stop being served and stop appearing anywhere, but stay recoverable in the database. Rows the caller may not edit are reported in \`failed\` and the rest are still deleted.`, inputSchema: DeleteRowsInput, + outputSchema: DataDeleteRowsOutputSchema, handler: async (input, ctx: ToolContext) => { const args = input as Static const deletable: DataRow[] = [] diff --git a/server/ai/tools/data/rowTools.test.ts b/server/ai/tools/data/rowTools.test.ts index a034f88ac..2d67e5e66 100644 --- a/server/ai/tools/data/rowTools.test.ts +++ b/server/ai/tools/data/rowTools.test.ts @@ -8,6 +8,7 @@ * re-sending the row. */ import { beforeEach, describe, expect, it } from 'bun:test' +import { Value } from '@sinclair/typebox/value' import type { CoreCapability } from '@core/capabilities' import type { DbClient } from '../../../db/client' import { createSqliteClient } from '../../../db/sqlite' @@ -252,4 +253,24 @@ describe('data_update_row', () => { const stored = await getDataRow(db, rowId) expect(stored!.cells.price).toBe(100) }) + + it('describes cells by field id and points at the schema and row reads', () => { + // The description used to send the agent to data_list_tables for field + // ids, which does not return them — every first call guessed wrong. + const description = toolByName('data_create_rows').description + expect(description).toContain('content_get_collection_schema') + expect(description).toContain('content_list_documents') + expect(description).not.toContain('data_list_tables for') + }) + + it('returns payloads matching the advertised outputSchemas', async () => { + const created = await run('data_create_rows', { + tableId, + rows: [{ cells: { name: 'Intro', slug: 'intro', price: 100 } }], + }, db) + expect(Value.Check(toolByName('data_create_rows').outputSchema!, created)).toBe(true) + + const updated = await run('data_update_row', { rowId, cells: { price: 120 } }, db) + expect(Value.Check(toolByName('data_update_row').outputSchema!, updated)).toBe(true) + }) }) diff --git a/server/ai/tools/data/rowTools.ts b/server/ai/tools/data/rowTools.ts index 7d1be6db7..5a66743b5 100644 --- a/server/ai/tools/data/rowTools.ts +++ b/server/ai/tools/data/rowTools.ts @@ -19,6 +19,7 @@ import { Type, type Static } from '@core/utils/typeboxHelpers' import type { CoreCapability } from '@core/capabilities' import type { DataRow } from '@core/data/schemas' import { slugForTable } from '@core/data/cells' +import { DataCreateRowsOutputSchema, DataUpdateRowOutputSchema } from '@core/ai' import { protectedBuiltInCreateCellKey } from '@core/data/systemTableGuard' import type { AiTool, ToolContext } from '../../runtime/types' import { createAuditEvent, type AuditAction } from '../../../repositories/audit' @@ -52,7 +53,7 @@ const ROW_EDIT_CAPS: CoreCapability[] = ['content.edit.own', 'content.edit.any', const MAX_ROWS_PER_CALL = 200 const CellsSchema = Type.Record(Type.String(), Type.Unknown(), { - description: 'Cell values keyed by field id, as returned by data_list_tables.', + description: 'Cell values keyed by field id. Field ids come from content_get_collection_schema.', }) // --------------------------------------------------------------------------- @@ -75,8 +76,9 @@ function createRowsTool(runtime?: DataToolsRuntime): AiTool { mutates: true, requiredCapabilities: ROW_CREATE_CAPS, description: - `Create up to ${MAX_ROWS_PER_CALL} rows in one table, in a single transaction — if any row is rejected, none are written. Cells are keyed by field id (call data_list_tables for a table's fields). Rows land as drafts; publish them with data_set_rows_status. Headless — no editor needed.`, + `Create up to ${MAX_ROWS_PER_CALL} rows in one table, in a single transaction — if any row is rejected, none are written. Cells are keyed by field id — call content_get_collection_schema with the table id for its field ids. Rows land as drafts; publish them with data_set_rows_status and read them back with content_list_documents. Headless — no editor needed.`, inputSchema: CreateRowsInput, + outputSchema: DataCreateRowsOutputSchema, handler: async (input, ctx: ToolContext) => { const args = input as Static const table = await getDataTable(ctx.db, args.tableId) @@ -161,6 +163,7 @@ function updateRowTool(runtime?: DataToolsRuntime): AiTool { description: "Change one row's cells. By default the given cells are merged into what is already stored, so you can set a single field without re-sending the rest; pass merge: false to replace the whole cell set. Editing a published row writes its draft — call data_set_rows_status to publish the change. Headless — no editor needed.", inputSchema: UpdateRowInput, + outputSchema: DataUpdateRowOutputSchema, handler: async (input, ctx: ToolContext) => { const args = input as Static const current = await getDataRow(ctx.db, args.rowId) diff --git a/server/ai/tools/data/schemaTools.test.ts b/server/ai/tools/data/schemaTools.test.ts index 6f3204da5..272695342 100644 --- a/server/ai/tools/data/schemaTools.test.ts +++ b/server/ai/tools/data/schemaTools.test.ts @@ -7,6 +7,7 @@ * unique index, the field normalizer). */ import { beforeEach, describe, expect, it } from 'bun:test' +import { Value } from '@sinclair/typebox/value' import type { CoreCapability } from '@core/capabilities' import type { DataTable } from '@core/data/schemas' import type { DbClient } from '../../../db/client' @@ -101,6 +102,24 @@ describe('data_list_tables', () => { expect(trainings!.routable).toBe(false) }) + it('points at the content reads for table fields and rows', () => { + // The only way to read a data table's rows and field ids; if the pointer + // goes stale an agent has no path from a table id to its contents. + const description = toolByName('data_list_tables').description + expect(description).toContain('content_get_collection_schema') + expect(description).toContain('content_list_documents') + }) + + it('returns a payload matching its advertised outputSchema', async () => { + await run('data_create_table', { + name: 'Trainings', + kind: 'data', + fields: [{ id: 'name', label: 'Name', type: 'text' }], + }, db) + const result = await run('data_list_tables', {}, db) + expect(Value.Check(toolByName('data_list_tables').outputSchema!, result)).toBe(true) + }) + it('never lists page, component, or layout tables', async () => { const result = await run('data_list_tables', {}, db) as { tables: Array<{ slug: string }> } const slugs = result.tables.map((t) => t.slug) diff --git a/server/ai/tools/data/schemaTools.ts b/server/ai/tools/data/schemaTools.ts index 158a88c72..807f5731e 100644 --- a/server/ai/tools/data/schemaTools.ts +++ b/server/ai/tools/data/schemaTools.ts @@ -32,6 +32,7 @@ import { import { normalizeDataTableFields } from '@core/data/fields' import { assertSystemTableUpdateAllowed } from '@core/data/systemTableGuard' import { slugFromTitle } from '@core/utils/slug' +import { DataListTablesOutputSchema, DataTableOutputSchema } from '@core/ai' import type { AiTool, ToolContext } from '../../runtime/types' import { createAuditEvent } from '../../../repositories/audit' import { @@ -149,8 +150,9 @@ const listTablesTool: AiTool = { execution: 'server', requiredCapabilities: TABLE_READ_CAPS, description: - "List every table in the workspace: reusable data tables (kind 'data') AND routable post types (kind 'postType'). Pass `kind` to narrow. Returns id, slug, label, kind, routable, system, rowCount, primaryFieldId per table — call content_get_collection_schema with an id for its fields. This is the discovery tool for reusable data tables, which content_list_collections deliberately excludes. Headless — no editor needed.", + "List every table in the workspace: reusable data tables (kind 'data') AND routable post types (kind 'postType'). Pass `kind` to narrow. Returns id, slug, label, kind, routable, system, rowCount, primaryFieldId per table — call content_get_collection_schema with an id for its fields, and content_list_documents / content_get_document with the same id to read its rows. This is the discovery tool for reusable data tables, which content_list_collections deliberately excludes. Headless — no editor needed.", inputSchema: ListTablesInput, + outputSchema: DataListTablesOutputSchema, handler: async (input, ctx: ToolContext) => { const { kind } = input as Static const actor = toolActor(ctx) @@ -199,6 +201,7 @@ function createTableTool(runtime?: DataToolsRuntime): AiTool { description: "Create a custom table. `kind` defaults to 'data' (a reusable table with no public URLs — course dates, team members, pricing rows) — use 'postType' only for content that needs one public page per row. `slug` defaults to a slugified `pluralLabel`, `singularLabel`/`pluralLabel` default from `name`, `routeBase` defaults to `/` for a post type and to none for a data table (pass an explicit `routeBase` only to override that), `primaryFieldId` names the field used as the row label in grids and pickers (defaults to 'title'). Pass `fields` to define the schema up front. Returns the table AS STORED, including the actual field ids — read those back before wiring loops or writing rows, they are not always the ids you sent. Field types 'pageTree' and 'fieldSchema' are reserved for built-in tables. Headless — no editor needed.", inputSchema: CreateTableInput, + outputSchema: DataTableOutputSchema, handler: async (input, ctx: ToolContext) => { const args = input as Static @@ -276,6 +279,7 @@ function updateTableTool(runtime?: DataToolsRuntime): AiTool { description: "Change an existing table's identity or schema. `fields` REPLACES the whole field array — send every field you want to keep, or use data_add_fields to append without touching the rest. Dropping a field orphans the values already stored under it on every row. Set `routeBase` to an empty string to make a table non-routable, or to a path to give each row a public URL. The seeded system tables (pages, posts, components, layouts) accept custom fields but refuse any change to their identity or their built-in fields. Headless — no editor needed.", inputSchema: UpdateTableInput, + outputSchema: DataTableOutputSchema, handler: async (input, ctx: ToolContext) => { const args = input as Static const resolved = await resolveManageableTable(ctx, args.tableId) @@ -336,6 +340,7 @@ function addFieldsTool(runtime?: DataToolsRuntime): AiTool { description: "Append fields to an existing table, leaving every current field and every stored value untouched. This is the safe way to evolve a schema — data_update_table's `fields` replaces the array and drops whatever you omit. A field id the table already has is refused rather than overwritten; change an existing field through data_update_table. Returns the table as stored. Headless — no editor needed.", inputSchema: AddFieldsInput, + outputSchema: DataTableOutputSchema, handler: async (input, ctx: ToolContext) => { const args = input as Static const resolved = await resolveManageableTable(ctx, args.tableId) diff --git a/server/ai/tools/index.test.ts b/server/ai/tools/index.test.ts new file mode 100644 index 000000000..81db24733 --- /dev/null +++ b/server/ai/tools/index.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'bun:test' +import type { CoreCapability } from '@core/capabilities' +import { selectToolsForScope } from './index' + +const FULL: CoreCapability[] = [ + 'ai.chat', + 'ai.tools.write', + 'content.create', + 'content.manage', + 'content.edit.any', + 'data.custom.tables.read', + 'data.custom.tables.manage', + 'data.system.tables.read', +] + +describe('selectToolsForScope', () => { + it('gives the data scope a way to read back what it wrote', () => { + const names = selectToolsForScope('data', FULL).map((t) => t.name) + + // All eight data tools. + expect(names.filter((n) => n.startsWith('data_')).sort()).toEqual([ + 'data_add_fields', + 'data_create_rows', + 'data_create_table', + 'data_delete_rows', + 'data_list_tables', + 'data_set_rows_status', + 'data_update_row', + 'data_update_table', + ]) + + // Plus the three content reads that resolve a table id — the data tools + // write rows but cannot read one back. + expect(names).toContain('content_get_collection_schema') + expect(names).toContain('content_list_documents') + expect(names).toContain('content_get_document') + }) + + it('keeps the borrowed content reads read-only', () => { + // They arrive from a barrel that stamps `mutates` across its whole set; a + // read tagged as a write would vanish for a caller without ai.tools.write. + const borrowed = selectToolsForScope('data', FULL) + .filter((t) => t.name.startsWith('content_')) + expect(borrowed).toHaveLength(3) + expect(borrowed.every((t) => t.mutates !== true)).toBe(true) + + const readOnly = selectToolsForScope('data', ['ai.chat', 'content.manage', 'data.custom.tables.read', 'data.system.tables.read']) + .map((t) => t.name) + expect(readOnly).toContain('content_list_documents') + expect(readOnly).not.toContain('data_create_rows') + }) +}) diff --git a/server/ai/tools/index.ts b/server/ai/tools/index.ts index 2d0760d18..15093cf6e 100644 --- a/server/ai/tools/index.ts +++ b/server/ai/tools/index.ts @@ -25,6 +25,7 @@ import { toolAllowedForCapabilities } from './capabilityGate' import type { AiTool, ToolScope } from './types' import { siteTools } from './site' import { contentTools } from './content' +import { tableScopedReadTools } from './content/readTools' import { dataTools } from './data' function scopeToolset(scope: ToolScope): AiTool[] { @@ -37,7 +38,16 @@ function scopeToolset(scope: ToolScope): AiTool[] { // Schema + row tools, all server-resolved. The MCP registry builds its // own copy with a runtime (see server/ai/mcp/registry.ts); the in-app // agent gets the runtime-free subset. - return dataTools() + // + // `dataTools()` writes rows but cannot read one back, so the three + // table-scoped content reads come along — they resolve a reusable data + // table's id just as they resolve a post type's. Without them an agent + // in the Data workspace could create a table and fill it, then have no + // way to see what it wrote. + return [ + ...dataTools(), + ...tableScopedReadTools.map((t) => ({ ...t, mutates: false })), + ] case 'plugin': // Reserved: no plugin-scope toolset yet. return [] diff --git a/server/ai/tools/site/readTools.ts b/server/ai/tools/site/readTools.ts index 0a5a91398..893832ccb 100644 --- a/server/ai/tools/site/readTools.ts +++ b/server/ai/tools/site/readTools.ts @@ -9,7 +9,15 @@ */ import { Type, type Static } from '@core/utils/typeboxHelpers' -import { describeAgentDocuments } from '@core/ai' +import { + describeAgentDocuments, + SiteListBreakpointsOutputSchema, + SiteListDocumentsOutputSchema, + SiteListLoopSourcesOutputSchema, + SiteListModulesOutputSchema, + SiteListPostTypesOutputSchema, + SiteListTokensOutputSchema, +} from '@core/ai' import '@core/loops/sources' import { buildDataMeta } from '@core/data/fields' import type { DataMetaField } from '@core/data/schemas' @@ -43,6 +51,7 @@ const listDocumentsTool: AiTool = { description: 'List editable documents: pages, templates, and visual components. Use the returned document refs with site_read_document/site_open_document. Each item includes rootNodeId, active/current flags, template metadata, and a short summary.', inputSchema: ListDocumentsInput, + outputSchema: SiteListDocumentsOutputSchema, handler: async (_input, ctx) => { const snap = asSnap(ctx.snapshot) return { @@ -68,6 +77,7 @@ const listModulesTool: AiTool = { description: 'List registered modules with id, name, category, props schema, and style targets. `category` filters case-insensitively.', inputSchema: ListModulesInput, + outputSchema: SiteListModulesOutputSchema, handler: async (input) => { const { category } = input as Static const normalized = category?.toLowerCase() @@ -102,6 +112,7 @@ const listTokensTool: AiTool = { description: "List the site's design tokens — color tokens (with shades/tints), typography & spacing scale steps, and font tokens — each with its CSS variable (use as `var(--name)` in a