Skip to content

feat(core): reference support in the MCP tools - #2515

Closed
MA2153 wants to merge 6 commits into
emdash-cms:mainfrom
MA2153:feat/reference-mcp-tools
Closed

feat(core): reference support in the MCP tools#2515
MA2153 wants to merge 6 commits into
emdash-cms:mainfrom
MA2153:feat/reference-mcp-tools

Conversation

@MA2153

@MA2153 MA2153 commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Stacked on #2509. Base branches can't live on a fork, so this PR targets main and its diff currently includes the four commits below it. Review 7107f0c1 on its own — that's the whole of this change.

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]; stripStoragelessDataKeys validated 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_create and content_update take a references argument; content_get hydrates one back:

// content_create
{ "collection": "post", "data": { "title": "" },
  "references": { "related_posts": ["01JB…", "some-slug"] } }

// content_get
{ "item": { "data": {  },
    "references": { "related_posts": { "children": [{ "id": "01JB…", "slug": "", "title": "", "locale": "en" }] } } } }

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_collection names 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 — setReferenceChildren already did findManyByIdOrSlug.

Every write goes through setReferenceChildren, so required and multiple (#2503) hold here without a second implementation. content_create has two call sites — the publish path creates then publishes — and content_update has three; all of them carry references, 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_drafts gets draft children skipped, not listed — handleContentGet's referenceOptions.includeDrafts is wired to the same canReadDrafts check that already hides draft entries from that caller.

The schema half

schema_create_field went straight to SchemaRegistry.createField, bypassing handleSchemaFieldCreate — 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_field had 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.

validation gains targetCollection and multiple, which the REST field body already accepts. options.collection is what this tool has always documented as a reference field's target, so it still works — it fills in validation.targetCollection when 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

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 5 (Claude Code)

Screenshots / test output

$ pnpm exec vitest run          # packages/core
 Test Files  469 passed | 2 skipped (471)
      Tests  5825 passed | 9 skipped (5834)

$ pnpm typecheck                # all packages — clean
$ pnpm lint:json | jq '.diagnostics | length'
0

$ pnpm --filter emdash build && pnpm query-counts
OK: query counts match scripts/query-counts.snapshot.sqlite.json
OK: query text matches scripts/query-counts.queries.sqlite.json

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), multiple and required, 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 in data is 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 through references, absent from data on read, present under references.

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 (required rejecting a create that omits the field) is asserting that MCP reaches a constraint the handler layer already enforced.

MA2153 and others added 5 commits August 16, 2026 16:42
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>
@github-actions

Copy link
Copy Markdown
Contributor

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Storage-less reference fields can still be created/update'd with indexed: true. The column is skipped, so createFieldIndex builds SQL against a missing column and will fail.
  2. FTS getSearchableFields returns storage-less reference fields. If a reference field is marked searchable: true, populateFromContent emits SQL that references the missing column.
  3. Public getEmDashCollection/getEmDashEntry do not strip storage-less columns from entry.data. handleContentGet now strips legacy reference columns, but the loader-based public query still includes them, so templates can see stale/invalid entry.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:981

    Storage-less reference fields skip addColumn, but this block still calls createFieldIndex when input.indexed is true. Because the column does not exist, the resulting CREATE INDEX ... ON ec_<collection>("<referenceField>") will fail. Storage-less field types should be excluded from index creation (and from the matching index-drop path in updateField).

    				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:423

    getSearchableFields returns every _emdash_fields row with searchable = 1, including reference fields that no longer have a content-table column. rebuildIndex/populateFromContent then emits SQL like SELECT ... "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:1205

    hydrateEntryReferences populates entry.references, but nothing removes storage-less field columns from entry.data. The loader issues SELECT * and mapRowToData includes every non-system column, so legacy reference fields that pre-date storage-less references still leak their old column value to public templates. handleContentGet already strips these via stripStoragelessFromItem; the public query path should do the same, e.g. by reusing storagelessFields at 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
    

@github-actions

Copy link
Copy Markdown
Contributor

Scope check

This 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.

@pkg-pr-new

pkg-pr-new Bot commented Aug 16, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@2515

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@2515

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@2515

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@2515

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@2515

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@2515

emdash

npm i https://pkg.pr.new/emdash@2515

create-emdash

npm i https://pkg.pr.new/create-emdash@2515

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@2515

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@2515

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@2515

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@2515

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@2515

@emdash-cms/registry-verification

npm i https://pkg.pr.new/@emdash-cms/registry-verification@2515

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@2515

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@2515

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@2515

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@2515

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@2515

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@2515

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@2515

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@2515

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@2515

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@2515

commit: 3a77770

@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-review No maintainer or bot review yet labels Aug 16, 2026
@changeset-bot

changeset-bot Bot commented Aug 16, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3a77770

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 17 packages
Name Type
emdash Minor
@emdash-cms/cloudflare Minor
@emdash-cms/sandbox-workerd Patch
@emdash-cms/plugin-mcp-smoke Major
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/admin Minor
@emdash-cms/auth Minor
@emdash-cms/blocks Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
create-emdash Minor
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

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

@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/awaiting-author Reviewed; waiting on the author to respond labels Aug 16, 2026
@MA2153 MA2153 closed this Aug 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/core overlap review/needs-rereview Author pushed changes since the last review size/XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant