Skip to content

feat(mcp): headless data table and row tools - #545

Open
borskyj-symph wants to merge 7 commits into
CoreBunch:mainfrom
borskyj-symph:feat/mcp-data-tools
Open

borskyj-symph wants to merge 7 commits into
CoreBunch:mainfrom
borskyj-symph:feat/mcp-data-tools

Conversation

@borskyj-symph

@borskyj-symph borskyj-symph commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

Closes #433
Closes #463

What changed

An external MCP client could read data tables but not create or shape one. Building a post type meant clicking through the Data workspace field by field, which made any bulk content setup, such as importing a spreadsheet into a new post type, impractical over MCP.

This adds the headless data tools and the surrounding plumbing:

  • server/ai/tools/data/ holds the schema tools (data_create_table, data_update_table, data_add_fields), the row tools (data_create_rows, data_update_row, data_delete_rows), and the lifecycle tool (data_set_rows_status), each with its own tests.
  • Tool annotations and structured output across the MCP server, plus output schemas in src/core/ai/toolOutputSchemas.ts, so clients see read-only and destructive hints and typed results instead of prose.
  • data.custom.tables.manage is offered on the connector consent screen. The capability and the tools already existed, but the screen never listed the capability, so it could not be granted and the tools were dead on every connector. data.system.tables.manage is deliberately not offered: the four seeded system tables are locked from rename and delete, and nothing an MCP client does should reshape them.

Why

Schema authority and row authority are separate concerns, so they are separate capabilities. A connector that fills rows does not need to be able to restructure the table it fills.

Branch scope

The data tools read and write through the request's branch, but the headless MCP path always runs on main. server/ai/mcp/server.ts pins MAIN_SCOPE for every call, so a connector sees the live site's rows and never a branch's. Only the in-app Data chat supplies a real branch.

Publishing is main-only on both paths. data_set_rows_status refuses published off main, the same refusal the HTTP route gives, because persistDataRowPublish writes main regardless of the scope the row was read at. That refusal ends the whole call before any row is touched, which is an exception to the tool's per-row outcome reporting, so the tool description says so: an agent reading it should not retry row by row. Without that gate the call authorized one row and published another. Draft and unpublished still work on a branch, and retracting or deleting there leaves main's baked artefact and render cache untouched, matching the HTTP delete handler. removeDataRowArtefact resolves the route by row id with no branch filter, so an unguarded branch retraction would unlink main's live page.

Artefacts from the in-app Data chat

The in-app path built the data toolset with no runtime, so every artefact write and removal was skipped. Retiring a published row updated the database, reported success, and left the baked HTML on disk, where Layer A kept serving it with no database awareness. uploadsDir is now threaded from the server runtime through the AI handlers into the toolset, which is why this PR touches server/router.ts and server/ai/handlers/. connectorId becomes optional on DataToolsRuntime, and its absence is what marks an in-app write, so those writes still audit as source: 'agent' while MCP writes keep their connector id.

Output schemas

Three schemas described payloads their handlers do not return, so a schema-validating client saw a mismatch on a successful call. The code-asset list and write schemas required id, but describeCodeAsset returns fileId, as the read schema already said. site_list_tokens was declared as an array while it returns the four-family object filterTokenFamily produces. site_list_breakpoints advertised width as an integer while BreakpointSchema types it as a number, so a fractional width would have failed on a successful call.

How to review this

This is large, and the commits are separated on purpose. Reading it commit by commit is far cheaper than reading the combined diff.

  1. c890c06 adds the data tools themselves. The core of the feature, and the only commit that introduces server/ai/tools/data/.
  2. 51c76fc is the one to read carefully, and it is not about data tables. It adds tool annotations and outputSchema to the whole MCP catalog, so it changes the advertised wire contract for site, content, publish, media, context and style tools as well. Every output-schema bug found so far lived here: site_list_code_assets and site_write_code_asset required id where the handler returns fileId, site_list_tokens was typed as an array where the handler returns an object, and site_list_breakpoints advertised an integer width against a number. All three are fixed, none of them is a data tool, and that is the reason to check this commit against the handlers rather than against the feature description.
  3. 6808b2b carries the branch scope through the data tools.
  4. 2e8aacd adds the capability to the connector consent screen. Without it the schema tools cannot be granted to anyone, so it is part of the feature rather than a separate fix.
  5. 8104d73d is the review follow-up: the main-scope guards, the uploads dir threading that reaches server/router.ts, and two of the schema corrections.
  6. 3f46e71 and 2430901a are one-line corrections to a tool description and a schema.

Two things deserve a second opinion rather than a skim.

The step-up control is deliberately absent. requireStepUp guards table create and update over HTTP (server/handlers/cms/data/tables.ts:175 and :267), and an MCP connection has no step-up challenge to offer. The compensating controls are the data.custom.tables.manage capability gate, which an approver can only grant if they hold it, and the audit trail that records the connector id on every write. That is a judgement call about weakening an existing control, and it should not be signed off by the author alone.

The end-to-end check was manual: a post type with 29 fields and 19 rows created over MCP against a self-hosted install, then rendered. CI does not reproduce it.

Impact

Connector owners can grant custom-table management and let an MCP client build a post type and fill it. Existing connectors are unaffected until the new capability is granted. No schema migration.

Verification

  • bun test server/ai/tools/ : 57 pass
  • bun test server/ai/tools/data/lifecycleTools.test.ts : 15 pass, including six new branch-scope cases built on a real forkBranch of main, since a forked row keeps main's logical id and slug and that is what makes the unguarded artefact removal dangerous
  • bun test server/ai/ : 162 pass, 1 fail. The failure is a beforeEach hook in server/ai/mcp/contentAuthorization.test.ts timing out at bun's 5s limit under the full-directory run; that file passes in isolation and this branch does not touch it.
  • bunx tsc -b
  • bunx eslint on the changed files
  • End to end against a self-hosted install: created a post type with 29 fields and 19 rows over MCP, then rendered the entries.

🤖 Generated with Claude Code

borskyj and others added 7 commits September 19, 2026 15:53
MCP could read post types and their rows, but reusable `data` tables were
invisible to it and no tool could create a table, change its fields, or write
rows anywhere. Schema setup was the one manual step in an otherwise automatable
pipeline.

Adds a `data` toolset that runs server-side in-process, so it needs no open
editor workspace:

- `data_list_tables`, `data_create_table`, `data_update_table`, `data_add_fields`
- `data_create_rows` (bulk, transactional, max 200 per call), `data_update_row`
- `data_set_rows_status`, `data_delete_rows` (per-row partial failure, since
  publishing bakes an artefact per row and is not transactional)

The tools reuse the capability predicates in `server/handlers/cms/data/access.ts`
rather than restating the rules, so the predicate signatures widen to accept a
plain actor instead of a full `AuthUser`.

On the content side, asking `content_set_active_collection` for a non-postType
table now says which workspace owns it instead of "collection not found".

Closes CoreBunch#433, closes CoreBunch#463.

Verification: bun test server/ai/tools/data (42 pass), bun test
server/ai/mcp/registry.test.ts, bun test src/__tests__/agent, bun run lint,
bun run build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`tools/list` now carries the four MCP behaviour hints per tool
(`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`)
and an `outputSchema` for every tool in the catalog, and a successful
`tools/call` returns the payload as `structuredContent` beside the JSON
text block. A client can tell a read from a delete before it calls, and
parse a typed result instead of re-deriving the shape from a text blob.
The server also reports its real package version instead of a hardcoded
`1.0.0`.

Result shapes live in the new `src/core/ai/toolOutputSchemas.ts`: a
browser tool's result is produced in the browser and advertised by the
server, and neither side may import the other. Nothing validates against
them at runtime — drift has to fail a test, not a live install.

Also fixes `data_create_rows` pointing agents at `data_list_tables` for
the field ids its cells are keyed by. That tool does not return them, so
the first call guessed and failed. The descriptions now name
`content_get_collection_schema` for field ids and
`content_list_documents` for reading rows back, and the in-app `data`
chat scope gained those same three content reads — it could create a
table and fill it, then had no way to see what it wrote.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adapts the `data_*` toolset to the site-branches work that landed in
0.0.20. Every data-table and data-row repository call, and the content
entry events, now take a `BranchScope` as their second argument, and
`ToolContext` carries the caller's as `ctx.branch` — the same shape the
content tools already thread through. Without it the tools read and
wrote whatever `scope.branchId` happened to be undefined against, which
typechecked before the rebase and threw at runtime after it.

`updateDataTable` gained a parameter ahead of its input object, so the
`Parameters<typeof updateDataTable>` index in `data_update_table` moved
from 2 to 3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`data_create_table`, `data_update_table`, and `data_add_fields` gate on
`data.custom.tables.manage`, but the MCP capability picker never listed it.
The consent screen is the only place a connection's capability set is chosen,
so those tools shipped ungrantable: reconnecting the client changed nothing.

Adds a Data tables group holding that one capability. It is not part of the
read-only default selection, and `availableMcpCapabilityGroups` still hides it
from an approver who does not hold it. `data.system.tables.manage` stays
unoffered — `assertSystemTableUpdateAllowed` refuses identity and built-in
field changes on the four system tables anyway.

Verified: bun test src/admin/pages/ai, bunx tsc -b, eslint on the changed files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review follow-ups on the headless data toolset.

`data_set_rows_status` refuses to publish off main. The row was read and
authorized at `ctx.branch`, but `persistDataRowPublish` reads and writes
`MAIN_SCOPE`, so the call authorized one row and published another. The
HTTP route already refuses this; the tool now gives the same answer.
Draft and unpublished still work on a branch, since they write through
`ctx.branch`.

Artefact removal and the publish-version bump are main-only, matching
the HTTP delete handler. `removeDataRowArtefact` resolves the route by
row id with no branch filter, so retracting or deleting a branch row
whose slug matches main's would unlink main's live page.

The in-app Data chat now gets an uploads dir. It built the toolset with
no runtime, so every artefact write and removal was skipped: retiring a
published row updated the database, reported success, and left the baked
HTML on disk where Layer A kept serving it. `uploadsDir` is threaded
from the server runtime through the AI handlers to the toolset.
`connectorId` becomes optional on `DataToolsRuntime` and its absence is
what marks an in-app write, so those writes still audit as
`source: 'agent'` while MCP writes keep their connector id.

Two output schemas described payloads no handler returns. The code-asset
list and write schemas required `id`, but `describeCodeAsset` returns
`fileId`, as the read schema already said. `site_list_tokens` was
declared as an array while it returns the four-family object
`filterTokenFamily` produces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`data_set_rows_status` reports per-row outcomes, but an off-main publish
refuses the whole call before any row is touched. The description
promised only the per-row contract, so an agent reading it would retry
the call one row at a time and collect the same refusal each time. The
description is the only spec an MCP client sees, so the exception
belongs in it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`site_list_breakpoints` returns `site.breakpoints` verbatim, and
`BreakpointSchema` types width as a number, so a fractional width is
storable. The advertised output schema said integer, which would reject
one on a successful call. Same class as the code-asset and token schema
corrections: the advertised shape has to describe what the handler
actually returns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@borskyj-symph
borskyj-symph marked this pull request as ready for review September 19, 2026 20:42

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants