feat(mcp): headless data table and row tools - #545
Open
borskyj-symph wants to merge 7 commits into
Open
borskyj-symph wants to merge 7 commits into
borskyj-symph wants to merge 7 commits into
Conversation
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
marked this pull request as ready for review
September 19, 2026 20:42
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.src/core/ai/toolOutputSchemas.ts, so clients see read-only and destructive hints and typed results instead of prose.data.custom.tables.manageis 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.manageis 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.tspinsMAIN_SCOPEfor 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_statusrefusespublishedoff main, the same refusal the HTTP route gives, becausepersistDataRowPublishwrites 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.removeDataRowArtefactresolves 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.
uploadsDiris now threaded from the server runtime through the AI handlers into the toolset, which is why this PR touchesserver/router.tsandserver/ai/handlers/.connectorIdbecomes optional onDataToolsRuntime, and its absence is what marks an in-app write, so those writes still audit assource: '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, butdescribeCodeAssetreturnsfileId, as the read schema already said.site_list_tokenswas declared as an array while it returns the four-family objectfilterTokenFamilyproduces.site_list_breakpointsadvertised width as an integer whileBreakpointSchematypes 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.
c890c06adds the data tools themselves. The core of the feature, and the only commit that introducesserver/ai/tools/data/.51c76fcis the one to read carefully, and it is not about data tables. It adds tool annotations andoutputSchemato 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_assetsandsite_write_code_assetrequiredidwhere the handler returnsfileId,site_list_tokenswas typed as an array where the handler returns an object, andsite_list_breakpointsadvertised 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.6808b2bcarries the branch scope through the data tools.2e8aacdadds 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.8104d73dis the review follow-up: the main-scope guards, the uploads dir threading that reachesserver/router.ts, and two of the schema corrections.3f46e71and2430901aare 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.
requireStepUpguards table create and update over HTTP (server/handlers/cms/data/tables.ts:175and:267), and an MCP connection has no step-up challenge to offer. The compensating controls are thedata.custom.tables.managecapability 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 passbun test server/ai/tools/data/lifecycleTools.test.ts: 15 pass, including six new branch-scope cases built on a realforkBranchof main, since a forked row keeps main's logical id and slug and that is what makes the unguarded artefact removal dangerousbun test server/ai/: 162 pass, 1 fail. The failure is abeforeEachhook inserver/ai/mcp/contentAuthorization.test.tstiming 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 -bbunx eslinton the changed files🤖 Generated with Claude Code