diff --git a/CHANGELOG.md b/CHANGELOG.md index dfc6abefe..7811b1052 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ 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. Publishing a row happens on `main` only, exactly as it does in the Content workspace — a branch reaches the live site by being merged — while drafting, unpublishing, and deleting work on a branch too. +- Fixed the data schema tools being impossible to grant. `data_create_table`, `data_update_table`, and `data_add_fields` require the "Manage custom tables" capability, which the connector consent screen never offered, so a client could reconnect as often as it liked and still not see them. The screen now has a Data tables section holding it. It stays off by default, like every other write. +- 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 ### Features diff --git a/docs/features/data-workspace.md b/docs/features/data-workspace.md index 899d21769..99b6532d3 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`. Both paths pass a runtime carrying the uploads directory, so publishing bakes a row's static artefact and retracting or deleting unlinks it again; an MCP connection also passes its connector id, which is what attributes its writes to the connection in the audit log instead of to the signed-in user. A headless call runs on `main` (the MCP context pins it); the in-app chat carries the workspace's branch, and publishing is refused off main. + +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..b6052ca18 100644 --- a/docs/features/mcp-connectors.md +++ b/docs/features/mcp-connectors.md @@ -144,15 +144,62 @@ 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. | + +## 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. -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 | + +Schema writes are granted separately from row writes: `data.custom.tables.manage` is its own **Data tables** section on the consent screen and in the access-token dialog, off by default. A connector that only fills rows never gets it, and an approver who does not hold it never sees the section. `data.system.tables.manage` is not offered at all — the four built-in tables refuse identity and built-in-field changes regardless, so the grant would read wider than it acts. + +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. + +Headless calls always run on `main`. The MCP request context pins `MAIN_SCOPE`, so a connector reads and writes the live site's rows and never a site branch's; only the in-app Data chat carries the workspace's branch through the same tools. `data_set_rows_status` refuses a publish off main for the same reason the HTTP route answers 409 — publishing writes main regardless of the branch the row was read from — while retracting and deleting work on a branch and leave main's baked artefact and render cache alone. + +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 +247,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/handlers/chat.ts b/server/ai/handlers/chat.ts index 286d0011a..17be9860c 100644 --- a/server/ai/handlers/chat.ts +++ b/server/ai/handlers/chat.ts @@ -60,7 +60,7 @@ import { canonicaliseAiUserContent, preflightAiUserContent, } from '../inputImages' -import { selectToolsForScope } from '../tools' +import { selectToolsForScope, type ToolsetOptions } from '../tools' import { buildSiteSystemPrompt, SiteAgentSnapshotSchema, @@ -95,17 +95,19 @@ export function tryHandleAiChat( req: Request, db: DbClient, pathname: string, + options: ToolsetOptions = {}, ): Promise | null { if (!pathname.startsWith('/admin/api/ai/chat/')) return null const scope = pathname.slice('/admin/api/ai/chat/'.length) if (!VALID_SCOPES.includes(scope as ToolScope)) return null - return handleAiChat(req, db, scope as ToolScope) + return handleAiChat(req, db, scope as ToolScope, options) } async function handleAiChat( req: Request, db: DbClient, scope: ToolScope, + options: ToolsetOptions, ): Promise { if (req.method !== 'POST') { return jsonResponse({ error: 'Method not allowed' }, { status: 405 }) @@ -191,7 +193,7 @@ async function handleAiChat( req.signal, ) if (modelCapabilities === REQUEST_ABORTED) return clientClosedRequest() - const tools = selectToolsForScope(scope, user.capabilities) + const tools = selectToolsForScope(scope, user.capabilities, options) if (requestedImage && !modelCapabilities.visionInput) { return jsonResponse( { error: 'The selected model does not support image input. Choose a vision-capable model.' }, diff --git a/server/ai/handlers/index.ts b/server/ai/handlers/index.ts index 51a28205a..b6b220a26 100644 --- a/server/ai/handlers/index.ts +++ b/server/ai/handlers/index.ts @@ -21,10 +21,20 @@ import { tryHandleAiMcpManagement } from '../mcp/handlers/management' import { tryHandleMcpOAuthAuthorization } from '../mcp/handlers/oauthAuthorization' import { tryHandleAiEditorBridge } from '../mcp/handlers/editorBridge' +export interface AiHandlerOptions { + /** + * Where baked artefacts live. The chat handler passes it to the data + * toolset so a row retracted or deleted from the Data workspace also loses + * its published HTML — the same reason the CMS handlers take one. + */ + uploadsDir?: string +} + export function tryHandleAi( req: Request, db: DbClient, url: URL, + options: AiHandlerOptions = {}, ): Promise | null { const pathname = url.pathname if (!pathname.startsWith('/admin/api/ai/')) return null @@ -44,7 +54,7 @@ export function tryHandleAi( tryHandleAiMcpManagement(req, db, pathname) ?? tryHandleAiEditorBridge(req, db, pathname) ?? tryHandleAiAudit(req, db, url, pathname) ?? - tryHandleAiChat(req, db, pathname) ?? + tryHandleAiChat(req, db, pathname, options) ?? tryHandleAiToolResult(req, db, pathname) ?? tryHandleAiCredentials(req, db, pathname) ?? tryHandleAiConversations(req, db, url, pathname) ?? 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 e6794025c..f2e6ac39b 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,56 @@ 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') + }) + + 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/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/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 5253de60e..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, @@ -63,6 +72,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,8 +144,9 @@ 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, + outputSchema: ContentListCollectionsOutputSchema, handler: async (_input, ctx) => { const tables = await listDataTablesWithCounts(ctx.db, ctx.branch) return { @@ -156,8 +171,9 @@ 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, + outputSchema: ContentGetCollectionSchemaOutputSchema, handler: async (input, ctx) => { const { tableId } = input as Static const tables = await listDataTablesWithCounts(ctx.db, ctx.branch) @@ -198,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) @@ -232,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) @@ -273,8 +291,9 @@ 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, + outputSchema: ContentSearchDocumentsOutputSchema, handler: async (input, ctx) => { const { query, limit } = input as Static const results = await searchDataRows(ctx.db, ctx.branch, query, limit ?? 25) @@ -314,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 } @@ -338,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) @@ -380,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/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..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). @@ -67,8 +68,9 @@ 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, + 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, } // --------------------------------------------------------------------------- @@ -197,8 +205,9 @@ 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, + outputSchema: AcknowledgementOutputSchema, } // --------------------------------------------------------------------------- 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..8407e2275 --- /dev/null +++ b/server/ai/tools/data/index.ts @@ -0,0 +1,30 @@ +/** + * 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 uploads directory both callers supply, plus the + * connector id only the MCP server has. Calling it with no argument leaves the + * artefact writes off, which is correct only where nothing is ever published — + * both live callers pass an uploads dir. + */ + +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..8f3199657 --- /dev/null +++ b/server/ai/tools/data/lifecycleTools.test.ts @@ -0,0 +1,383 @@ +/** + * 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 { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import type { CoreCapability } from '@core/capabilities' +import { physicalId } from '@core/branches' +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 { MAIN_SCOPE, type BranchScope } from '../../../branches/scope' +import { forkBranch } from '../../../branches/fork' +import { getPublishVersion } from '../../../publish/publishState' +import type { AiTool, ToolContext } from '../../runtime/types' +import { selectToolsForScope } from '../index' +import { dataTools } from './index' + +const FULL_CAPS: CoreCapability[] = [ + 'content.create', + 'content.manage', + 'content.publish.any', + 'data.custom.tables.read', + 'data.custom.tables.manage', +] + +/** What the in-app chat handler holds when it builds the data toolset. */ +const CHAT_CAPS: CoreCapability[] = ['ai.chat', 'ai.tools.write', ...FULL_CAPS] + +const BRANCH_SCOPE: BranchScope = { branchId: 'feature-1' } + +/** 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, + branch: BranchScope = MAIN_SCOPE, + tool: AiTool = toolByName(name), +): Promise { + const ctx: ToolContext = { + db, + branch, + userId: 'user-1', + capabilities, + scope: 'data', + conversationId: 'conversation-1', + snapshot: null, + signal: new AbortController().signal, + } + return tool.handler!(input, ctx) +} + +/** + * The toolset the in-app Data chat gets — built the way the chat handler + * builds it, so these cases fail if the uploads dir stops being threaded + * through and the artefact writes go silently dead again. + */ +function chatTool(name: string, uploadsDir: string): AiTool { + const tool = selectToolsForScope('data', CHAT_CAPS, { uploadsDir }) + .find((candidate) => candidate.name === name) + if (!tool) throw new Error(`tool ${name} is not offered to the in-app data chat`) + return tool +} + +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, + branch: BranchScope = MAIN_SCOPE, +): 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, FULL_CAPS, branch) 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, FULL_CAPS, branch) 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, MAIN_SCOPE, 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, MAIN_SCOPE, 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, MAIN_SCOPE, 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, MAIN_SCOPE, 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, MAIN_SCOPE, 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, MAIN_SCOPE, 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, MAIN_SCOPE, seeded.tableId)).toHaveLength(3) + }) +}) + +/** + * Publishing exists on main only: the tool reads and authorizes the row at + * `ctx.branch`, but `persistDataRowPublish` reads and writes `MAIN_SCOPE`, so + * an off-main publish would check one row and ship another. + * + * Retraction and deletion stay available on a branch — they write through + * `ctx.branch` — but must not touch main's baked artefact or its render + * cache. The branch here is a real fork, which is what makes that dangerous: + * every row keeps main's logical id and slug, and `removeDataRowArtefact` + * resolves the route by row id with NO branch filter, so an unguarded branch + * retraction unlinks main's live page. + */ +describe('branch scope', () => { + let db: DbClient + let seeded: Seeded + let uploadsDir: string + + /** Where a row in a table with no route base bakes: `/` -> `.html`. */ + function artefactPath(slug: string): string { + return join(uploadsDir, 'published', 'a', `${slug}.html`) + } + + async function seedArtefact(slug: string): Promise { + const path = artefactPath(slug) + await mkdir(join(uploadsDir, 'published', 'a'), { recursive: true }) + await writeFile(path, 'live', 'utf-8') + return path + } + + /** `updateDataRowStatus` only moves a row down, and a branch publish is refused. */ + async function forcePublished(rowId: string, branch: BranchScope): Promise { + await db` + update data_rows set status = 'published' + where id = ${physicalId(branch.branchId, rowId)} + ` + } + + beforeEach(async () => { + db = await freshDb() + seeded = await seedTrainings(db) + uploadsDir = await mkdtemp(join(tmpdir(), 'instatic-lifecycle-')) + await run('data_set_rows_status', { rowIds: [seeded.rowIds[0]], status: 'published' }, db) + await forkBranch(db, { + id: BRANCH_SCOPE.branchId, + name: 'Feature', + fromBranchId: MAIN_SCOPE.branchId, + createdByUserId: 'user-1', + }) + }) + + afterEach(async () => { + await rm(uploadsDir, { recursive: true, force: true }) + }) + + it('refuses a publish from a branch instead of publishing the row on main', async () => { + const result = await run('data_set_rows_status', { + rowIds: [seeded.rowIds[1]], + status: 'published', + }, db, FULL_CAPS, BRANCH_SCOPE) as { ok: boolean; error: string } + + expect(result.ok).toBe(false) + expect(result.error).toMatch(/only available on main/) + expect((await getDataRow(db, BRANCH_SCOPE, seeded.rowIds[1]))!.status).toBe('draft') + // The row the write would have landed on. + expect((await getDataRow(db, MAIN_SCOPE, seeded.rowIds[1]))!.status).toBe('draft') + }) + + it('still retracts on a branch, and only on the branch', async () => { + const result = await run('data_set_rows_status', { + rowIds: [seeded.rowIds[0]], + status: 'unpublished', + }, db, FULL_CAPS, BRANCH_SCOPE) as { updated: Array<{ status: string }>; failed: unknown[] } + + expect(result.failed).toHaveLength(0) + expect(result.updated[0].status).toBe('unpublished') + expect((await getDataRow(db, MAIN_SCOPE, seeded.rowIds[0]))!.status).toBe('published') + }) + + it('leaves the live artefact in place when the branch copy is retracted', async () => { + const path = await seedArtefact('training-0') + + await run('data_set_rows_status', { + rowIds: [seeded.rowIds[0]], + status: 'unpublished', + }, db, CHAT_CAPS, BRANCH_SCOPE, chatTool('data_set_rows_status', uploadsDir)) + + expect(existsSync(path)).toBe(true) + }) + + it('removes the artefact when the same retraction runs on main', async () => { + const path = await seedArtefact('training-0') + + await run('data_set_rows_status', { + rowIds: [seeded.rowIds[0]], + status: 'unpublished', + }, db, CHAT_CAPS, MAIN_SCOPE, chatTool('data_set_rows_status', uploadsDir)) + + expect(existsSync(path)).toBe(false) + }) + + it('deleting the branch copy touches neither the artefact nor the publish version', async () => { + const path = await seedArtefact('training-0') + await forcePublished(seeded.rowIds[0], BRANCH_SCOPE) + const versionBefore = getPublishVersion() + + const result = await run('data_delete_rows', { + rowIds: [seeded.rowIds[0]], + }, db, CHAT_CAPS, BRANCH_SCOPE, chatTool('data_delete_rows', uploadsDir)) as { + deleted: unknown[] + } + + expect(result.deleted).toHaveLength(1) + expect(existsSync(path)).toBe(true) + expect(getPublishVersion()).toBe(versionBefore) + // Main's row is untouched by the branch delete. + expect((await getDataRow(db, MAIN_SCOPE, seeded.rowIds[0]))!.status).toBe('published') + }) + + it('deleting the published row on main removes the artefact and bumps the version', async () => { + const path = await seedArtefact('training-0') + const versionBefore = getPublishVersion() + + await run('data_delete_rows', { + rowIds: [seeded.rowIds[0]], + }, db, CHAT_CAPS, MAIN_SCOPE, chatTool('data_delete_rows', uploadsDir)) + + expect(existsSync(path)).toBe(false) + expect(getPublishVersion()).toBeGreaterThan(versionBefore) + }) +}) diff --git a/server/ai/tools/data/lifecycleTools.ts b/server/ai/tools/data/lifecycleTools.ts new file mode 100644 index 000000000..66a33685c --- /dev/null +++ b/server/ai/tools/data/lifecycleTools.ts @@ -0,0 +1,272 @@ +/** + * 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 { DataDeleteRowsOutputSchema, DataSetRowsStatusOutputSchema } from '@core/ai' +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 { isMainScope } from '../../../branches/scope' +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 + +/** + * Mirrors `branchOnlyResponse` in `server/handlers/cms/data/rows.ts` (409): + * publishing exists on main only, and a branch reaches the live site by being + * merged. Off main the row is read at `ctx.branch` but `persistDataRowPublish` + * writes `MAIN_SCOPE`, so without this gate the call would authorize one row + * and publish a different one. + */ +const BRANCH_PUBLISH_ERROR = 'Publishing is only available on main. Merge this branch first.' + +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 requires the main branch: called with status 'published' from a branch the WHOLE call is refused before any row is touched, so do not retry it row by row. Unpublishing and returning to draft work on a branch. 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 + // Scope is a property of the request, not of a row, so an off-main + // publish refuses the whole call instead of failing every row. + // `draft` / `unpublished` stay available: they write through + // `ctx.branch` and touch nothing on main. + if (args.status === 'published' && !isMainScope(ctx.branch)) { + return { ok: false, error: BRANCH_PUBLISH_ERROR } + } + const updated: Array<{ id: string; slug: string; status: DataRowStatus }> = [] + const failed: RowFailure[] = [] + + for (const rowId of args.rowIds) { + const current = await getDataRow(ctx.db, ctx.branch, 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, ctx.branch, 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, ctx.branch, rowId, status, ctx.userId) + if (!row) return null + // Only main is served. `removeDataRowArtefact` resolves the route by row id + // with no branch filter, so retracting a branch row whose slug matches + // main's would unlink main's live page (same guard as the HTTP delete). + if (runtime?.uploadsDir && isMainScope(ctx.branch)) { + 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, + outputSchema: DataDeleteRowsOutputSchema, + 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, ctx.branch, 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, + ctx.branch, + 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. Both are main-only — a branch + // row never had an artefact or a cached route (same guards as the HTTP + // delete handler). + for (const row of deletable) { + if (runtime?.uploadsDir && isMainScope(ctx.branch)) { + 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, ctx.branch, row.id, { kind: 'user', userId: ctx.userId }) + await recordRowAudit(ctx, runtime, 'data.row.delete', row) + } + if (result.publishedDeleted > 0 && isMainScope(ctx.branch)) { + 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?.connectorId + ? { 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..8ca864121 --- /dev/null +++ b/server/ai/tools/data/rowTools.test.ts @@ -0,0 +1,278 @@ +/** + * 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 { Value } from '@sinclair/typebox/value' +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 { MAIN_SCOPE } from '../../../branches/scope' +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, + branch: MAIN_SCOPE, + 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, MAIN_SCOPE, 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, MAIN_SCOPE, 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, MAIN_SCOPE, 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, MAIN_SCOPE, 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, MAIN_SCOPE, 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, MAIN_SCOPE, 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 new file mode 100644 index 000000000..e735a3b84 --- /dev/null +++ b/server/ai/tools/data/rowTools.ts @@ -0,0 +1,252 @@ +/** + * 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 { 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' +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. Field ids come from content_get_collection_schema.', +}) + +// --------------------------------------------------------------------------- +// 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 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, ctx.branch, 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, ctx.branch, 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, + ctx.branch, + prepared.map((row) => ({ tableId: table.id, cells: row.cells, slug: row.slug })), + ctx.userId, + ) + + for (const row of created) { + await emitContentEntryCreated(ctx.db, ctx.branch, 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, + outputSchema: DataUpdateRowOutputSchema, + handler: async (input, ctx: ToolContext) => { + const args = input as Static + const current = await getDataRow(ctx.db, ctx.branch, args.rowId) + if (!current || !canEditDataRow(toolActor(ctx), current)) { + return { ok: false, error: `Row ${args.rowId} not found.` } + } + + const table = await getDataTable(ctx.db, ctx.branch, 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, ctx.branch, 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, ctx.branch, 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, ctx.branch, 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?.connectorId + ? { 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..83776ccf4 --- /dev/null +++ b/server/ai/tools/data/runtime.ts @@ -0,0 +1,22 @@ +/** + * 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 to + * unlink it again on retract or delete, so BOTH paths that expose these tools + * must supply it: the MCP server from its transport options, the in-app chat + * handler from the server runtime. Without it a retract would update the + * database and leave the baked page on disk, where Layer A keeps serving it. + * + * `connectorId` is what makes an MCP write attributable in the audit log, so + * it is set only on the MCP path — the in-app agent has no connector and its + * writes are attributed to the signed-in user as `source: 'agent'`. + * + * Structurally compatible with `McpPublishRuntime` and filled from the same + * object, declared here so `server/ai/tools/` does not import from + * `server/ai/mcp/`. + */ +export interface DataToolsRuntime { + /** Present on the MCP path only; its absence is what marks an in-app write. */ + 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..b45c85b50 --- /dev/null +++ b/server/ai/tools/data/schemaTools.test.ts @@ -0,0 +1,413 @@ +/** + * 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 { Value } from '@sinclair/typebox/value' +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 { MAIN_SCOPE } from '../../../branches/scope' +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, + branch: MAIN_SCOPE, + 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, MAIN_SCOPE, '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('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) + 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, MAIN_SCOPE, 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, MAIN_SCOPE, 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, MAIN_SCOPE, 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, MAIN_SCOPE, 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, MAIN_SCOPE, 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..7771114dc --- /dev/null +++ b/server/ai/tools/data/schemaTools.ts @@ -0,0 +1,461 @@ +/** + * 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 { DataListTablesOutputSchema, DataTableOutputSchema } from '@core/ai' +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, 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) + const tables = await listDataTablesWithCounts(ctx.db, ctx.branch) + + // 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, + outputSchema: DataTableOutputSchema, + 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, ctx.branch, 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, ctx.branch, { + 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, + outputSchema: DataTableOutputSchema, + 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[3] = { 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, ctx.branch, 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, ctx.branch, 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, + outputSchema: DataTableOutputSchema, + 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, ctx.branch, 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, ctx.branch, 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?.connectorId + ? { 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.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 1c00c6077..53fcc6095 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,16 +25,41 @@ 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[] { +/** + * Per-request context the toolset needs from the server runtime. `uploadsDir` + * is what lets a data row's baked artefact be written and unlinked — without + * it a retract or delete from the in-app Data chat would leave the published + * HTML on disk, still served by Layer A. + */ +export interface ToolsetOptions { + uploadsDir?: string +} + +function scopeToolset(scope: ToolScope, options: ToolsetOptions = {}): AiTool[] { switch (scope) { case 'site': return siteTools 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 connector-attributed runtime (see + // server/ai/mcp/registry.ts); the in-app agent gets the same tools with + // an uploads dir but no connector id, so its writes audit as + // `source: 'agent'`. + // + // `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(options.uploadsDir ? { uploadsDir: options.uploadsDir } : undefined), + ...tableScopedReadTools.map((t) => ({ ...t, mutates: false })), + ] case 'plugin': // Reserved: no plugin-scope toolset yet. return [] @@ -58,6 +83,7 @@ function scopeToolset(scope: ToolScope): AiTool[] { export function selectToolsForScope( scope: ToolScope, capabilities: readonly CoreCapability[], + options: ToolsetOptions = {}, ): AiTool[] { - return scopeToolset(scope).filter((t) => toolAllowedForCapabilities(t, capabilities)) + return scopeToolset(scope, options).filter((t) => toolAllowedForCapabilities(t, capabilities)) } 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