feat(core): reference support in the MCP tools - #2515
Conversation
A reference field's selections are edges in _emdash_content_references, not a column on the collection's table. The registry skips column DDL for storage-less field types, the schema handlers own the backing relation's lifecycle (created with the field, destroyed with it, target collection immutable), and the previously unregistered relation and reference-edge routes are wired into injectCoreRoutes. A storage-less field never appears in `data` in either direction: it is excluded from the generated Zod shape (so a required reference field is satisfiable at all), rejected with a VALIDATION_ERROR when a caller sends one, and filtered out of reads so a column left behind by an older version cannot round-trip back into a save. That replaces the reference-target existence pass in validateContentData, which validated a column-backed value that no longer exists. Seeds apply a reference field's $ref: value as an edge, so seed files keep working unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reference selections ride in the content create/update body under a `references` key and are written in the same transaction as the entry, so a child that fails to resolve aborts the whole save rather than leaving an entry with taxonomies, bylines and SEO already committed. The editor GET opts into hydrating the first page of each reference field's children. Two paths that used to lose edges now carry them: duplicating an entry copies its outgoing references onto the copy, and purging a row clears the group's edges only once no sibling — trashed ones included, they are still restorable — is left to own them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A reference field's `multiple: false` and `required` settings were UI-only: the content body and the edge endpoint would both happily write several children to a single-reference field, or clear a required one. Both checks now live in `setReferenceChildren`, the one function every edge write passes through, so the content body, the standalone endpoint and the seed engine can't drift from each other. The create path additionally rejects a payload that omits a required reference field altogether, which no edge write would otherwise visit. Updates keep partial semantics: a field the payload doesn't mention is left alone. A relation with no reference field behind it stays unconstrained — the relations API can create one directly, and it carries no field config to enforce. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`getEmDashEntry` and `getEmDashCollection` now attach `entry.references`, keyed by field slug, with each referenced entry carrying its own `data` so a template can render a related entry without a follow-up query. Published targets only — an edge pointing at a draft resolves to nothing, exactly like one whose target was deleted, so a public read cannot leak an unpublished slug. Hydration is unconditional, so it has to be free when unused: the content query carries an uncorrelated existence probe on the edge table (the same shape as `_emdash_bylines_exist`), and an empty table stops hydration before it looks at the schema. Query counts are unchanged on every route in the snapshot; only the query text moves. A page of entries costs a constant number of queries — one batched edge read for the whole page, then one resolve per target collection. Generated types no longer describe a reference field as a string on `data`, and the `reference()` factory documents where the value actually lives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
MCP set a reference through `data[fieldSlug]`, which the storage-less field change now rejects, and `schema_create_field` went straight to the registry — minting a reference field with no relation behind it, which nothing could ever be written through. `schema_delete_field` left that relation behind. `content_create` and `content_update` take a `references` argument and `content_get` hydrates one back, both keyed by field slug: MCP exposes no relation tools, so a relation's translation group is reachable from nothing an agent can call, while `schema_get_collection` names every field. The seed engine already addresses references the same way. Writes resolve to the relation and go through `setReferenceChildren`, so `required` and `multiple` hold here too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Overlapping PRsThis PR modifies files that are also changed by other open PRs:
This may cause merge conflicts or duplicated work. A maintainer will coordinate. |
There was a problem hiding this comment.
Approach judgment
This is the right fix for the stated problem. Instead of teaching every MCP tool about relation translation groups, the PR keeps the internal write path keyed by relation group while translating from field slugs at the MCP boundary — matching the seed engine and public query. Centralizing all edge writes through setReferenceChildren means multiple/required only need one implementation, and routing schema_create_field/schema_delete_field through the schema handlers finally creates the relation a reference field needs. The public-query hydration and seed wiring are natural extensions of that model.
I read the whole diff (which includes the stacked reference-infrastructure commits), the changed handlers, mcp/server.ts, the registry/seed code, the loader/query paths, the relation repository, and the new tests. I did not run the test suite, linter, or query-count snapshot (no shell), so those claims in the PR description are unverified.
Headline conclusion
The change is solid, but there are three real blockers before merge:
- Storage-less reference fields can still be created/update'd with
indexed: true. The column is skipped, socreateFieldIndexbuilds SQL against a missing column and will fail. - FTS
getSearchableFieldsreturns storage-less reference fields. If a reference field is markedsearchable: true,populateFromContentemits SQL that references the missing column. - Public
getEmDashCollection/getEmDashEntrydo not strip storage-less columns fromentry.data.handleContentGetnow strips legacy reference columns, but the loader-based public query still includes them, so templates can see stale/invalidentry.data.<referenceField>values.
A smaller note: the reference-field create/update branches only call invalidateCollectionCache, while the non-reference branches use invalidateFieldCaches (collection snapshots + Zod schema cache). Since reference fields are omitted from Zod schemas this is mostly harmless, but aligning the invalidation would avoid future surprises and the new CacheNamespace.SCHEMA cache used by getReferenceFields/getCollectionInfo still isn’t invalidated anywhere.
Findings
Findings
-
[needs fixing]
packages/core/src/schema/registry.ts:981Storage-less reference fields skip
addColumn, but this block still callscreateFieldIndexwheninput.indexedis true. Because the column does not exist, the resultingCREATE INDEX ... ON ec_<collection>("<referenceField>")will fail. Storage-less field types should be excluded from index creation (and from the matching index-drop path inupdateField).if (input.indexed && !STORAGELESS_FIELD_TYPES.has(input.type)) { await this.createFieldIndex(collectionSlug, id, input.slug, trx); } -
[needs fixing]
packages/core/src/search/fts-manager.ts:423getSearchableFieldsreturns every_emdash_fieldsrow withsearchable = 1, including reference fields that no longer have a content-table column.rebuildIndex/populateFromContentthen emits SQL likeSELECT ... "ec_<collection>".">"<referenceField>", which fails because the column is gone.import { STORAGELESS_FIELD_TYPES } from "../schema/types.js"; const fields = await this.db .selectFrom("_emdash_fields") .select("slug") .where("collection_id", "=", collection.id) .where("searchable", "=", 1) .where("type", "not in", [...STORAGELESS_FIELD_TYPES]) .execute(); -
[needs fixing]
packages/core/src/query.ts:1205hydrateEntryReferencespopulatesentry.references, but nothing removes storage-less field columns fromentry.data. The loader issuesSELECT *andmapRowToDataincludes every non-system column, so legacy reference fields that pre-date storage-less references still leak their old column value to public templates.handleContentGetalready strips these viastripStoragelessFromItem; the public query path should do the same, e.g. by reusingstoragelessFieldsat the top of this helper.async function hydrateEntryReferences<D>( type: string, entries: ContentEntry<D>[], locale?: string, ): Promise<void> { if (entries.length === 0) return; try { const { getDb } = await import("./loader.js"); const db = await getDb(); const { storagelessFields } = await import("./schema/reference-fields.js"); for (const { slug } of await storagelessFields(db, type)) { for (const entry of entries) delete entry.data[slug]; } for (const entry of entries) entry.references = {}; // ... rest unchanged
Scope checkThis PR changes 4,770 lines across 45 files. Large PRs are harder to review and more likely to be closed without review. If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs. See CONTRIBUTING.md for contribution guidelines. |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/registry-verification
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
🦋 Changeset detectedLatest commit: 3a77770 The changes in this PR will be included in the next version bump. This PR includes changesets to release 17 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
What does this PR do?
Closes the second of the two P1 findings on #1928: MCP could not set a reference at all, and could create a reference field that nothing could ever write through.
Both were real holes in #1928 that fourteen review passes missed. MCP set references through
data[fieldSlug];stripStoragelessDataKeysvalidated the target and then silently discarded the value, and the test only asserted the call didn't error, so it passed while losing data. #2492 turned that silent drop into a rejection — this PR gives MCP the working path.Writes and reads, keyed by field slug
content_createandcontent_updatetake areferencesargument;content_gethydrates one back:Keyed by field slug, not relation group. The REST content body keys by relation translation group because the admin addresses fields by relation. MCP has no relation tools at all — a relation's translation group is reachable from nothing an agent can call, while
schema_get_collectionnames every field. The seed engine already addresses references by field slug, and #2509 chose it for the public read; this is the third surface to agree. A key that isn't a reference field is rejected by name, listing the ones that are.Children resolve by id or slug, the same as every other reference write path —
setReferenceChildrenalready didfindManyByIdOrSlug.Every write goes through
setReferenceChildren, sorequiredandmultiple(#2503) hold here without a second implementation.content_createhas two call sites — the publish path creates then publishes — andcontent_updatehas three; all of them carryreferences, and the two "did anything change?" guards on the status-transition paths now count it.Reads are unconditional, matching bylines and the public query. A caller without
content:read_draftsgets draft children skipped, not listed —handleContentGet'sreferenceOptions.includeDraftsis wired to the samecanReadDraftscheck that already hides draft entries from that caller.The schema half
schema_create_fieldwent straight toSchemaRegistry.createField, bypassinghandleSchemaFieldCreate— which is where a reference field's relation is created, in the same transaction as the field row. So a reference field added over MCP had no relation: not broken-looking, just permanently unusable, since every edge write resolves through one.schema_delete_fieldhad the mirror bug and orphaned the relation.Both now route through the handlers. The tool's response shape is unchanged (
jsonResult(item)/{ deleted, collection }), so this isn't a wire break.validationgainstargetCollectionandmultiple, which the REST field body already accepts.options.collectionis what this tool has always documented as a reference field's target, so it still works — it fills invalidation.targetCollectionwhen that's absent rather than failing an agent that followed the old description.Not in this PR
OpenAPI (
/relations*and the reference edge routes are still undocumented) and the admin UI, which are PRs 6 and 7 of this stack.Type of change
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change)pnpm formathas been runmessages.pochanges are included.emdash: minor)AI-generated code disclosure
Screenshots / test output
Tests
tests/integration/mcp/references.test.ts(new) drives real MCP tools against a real runtime and database — no mocks — covering selection order, resolving a child by slug, replace-and-clear on update, both single-call publish paths, an unknown field slug, a child that doesn't resolve (and that the entry is rolled back with it),multipleandrequired, a draft child hidden from a subscriber, and the schema-tool round trip including the relation being deleted with the field.tests/integration/mcp/validation.test.ts— the "bug #6" block previously asserted only that a reference indatais rejected. Its fixture built the field through the registry, which produced the relation-less field described above, so nothing there could have caught it. It now builds the field through the handler and asserts the value persists: written throughreferences, absent fromdataon read, present underreferences.Each new test was run against unmodified
src/before the implementation landed: 15 of the 16 in the new file fail, and so does the persistence test. The one that passes either way (requiredrejecting a create that omits the field) is asserting that MCP reaches a constraint the handler layer already enforced.