From c68b834a742c77929954ee1cf845369c32b74fae Mon Sep 17 00:00:00 2001 From: "Marcus (bug-testing)" Date: Thu, 2 Jul 2026 22:47:06 -0500 Subject: [PATCH] fix(core): persist taxonomies field on content_create/content_update MCP tools (#953) Both MCP tools accepted a taxonomies field without error but never persisted it -- the input schemas didn't declare it and nothing in the handler chain forwarded it to the taxonomy junction table. #621 added SEO and byline support the same way; taxonomies was the one field left out. Adds TaxonomyRepository.setTaxonomiesForEntry(), keyed by taxonomy name with each value a list of term refs. Refs may be a term id or a slug (resolveTermRef falls back to a slug lookup scoped to the taxonomy name, since resolveTranslationGroup only matches id/group). Threaded through handleContentCreate/handleContentUpdate in the same transaction as the content write, mirroring the existing bylines wiring. On create, explicit taxonomies win over whatever copyEntryTerms inherited from a translationOf source. Co-Authored-By: Claude Opus 4.8 ultracode --- .changeset/slow-donuts-hide.md | 5 + packages/core/src/api/handlers/content.ts | 16 +++ packages/core/src/astro/types.ts | 2 + .../src/database/repositories/taxonomy.ts | 35 ++++++ packages/core/src/emdash-runtime.ts | 2 + packages/core/src/mcp/server.ts | 19 +++ .../mcp/content-taxonomies.test.ts | 112 ++++++++++++++++++ 7 files changed, 191 insertions(+) create mode 100644 .changeset/slow-donuts-hide.md create mode 100644 packages/core/tests/integration/mcp/content-taxonomies.test.ts diff --git a/.changeset/slow-donuts-hide.md b/.changeset/slow-donuts-hide.md new file mode 100644 index 0000000000..f1383dee7f --- /dev/null +++ b/.changeset/slow-donuts-hide.md @@ -0,0 +1,5 @@ +--- +"emdash": patch +--- + +Fixes `content_create` and `content_update` MCP tools silently ignoring the `taxonomies` field. Both tools now accept a `taxonomies` object keyed by taxonomy name (e.g. `{ category: ["porady"], tag: ["ai", "seo"] }`), with each value a list of term slugs or ids, and persist the assignments. diff --git a/packages/core/src/api/handlers/content.ts b/packages/core/src/api/handlers/content.ts index 295a79d10d..5a327d51e1 100644 --- a/packages/core/src/api/handlers/content.ts +++ b/packages/core/src/api/handlers/content.ts @@ -635,6 +635,7 @@ export async function handleContentCreate( status?: string; authorId?: string; bylines?: ContentBylineInput[]; + taxonomies?: Record; locale?: string; translationOf?: string; seo?: ContentSeoInput; @@ -726,6 +727,14 @@ export async function handleContentCreate( } } + // Explicit body.taxonomies wins per taxonomy name over whatever + // copyEntryTerms just inherited from the translation source. + if (body.taxonomies !== undefined) { + const { TaxonomyRepository } = await import("../../database/repositories/taxonomy.js"); + const taxRepo = new TaxonomyRepository(trx); + await taxRepo.setTaxonomiesForEntry(collection, created.id, body.taxonomies); + } + await hydrateBylines(trx, collection, created); // Side-write SEO data if provided @@ -813,6 +822,7 @@ export async function handleContentUpdate( status?: string; authorId?: string | null; bylines?: ContentBylineInput[]; + taxonomies?: Record; locale?: string; _rev?: string; seo?: ContentSeoInput; @@ -893,6 +903,12 @@ export async function handleContentUpdate( updated.primaryBylineId = credits[0]?.byline.translationGroup ?? null; } + if (body.taxonomies !== undefined) { + const { TaxonomyRepository } = await import("../../database/repositories/taxonomy.js"); + const taxRepo = new TaxonomyRepository(trx); + await taxRepo.setTaxonomiesForEntry(collection, resolvedId, body.taxonomies); + } + // Create auto-redirect when slug changes if (oldSlug && body.slug) { await createSlugChangeRedirect(trx, collection, oldSlug, body.slug, resolvedId); diff --git a/packages/core/src/astro/types.ts b/packages/core/src/astro/types.ts index 02fd914b4b..4bff701f6e 100644 --- a/packages/core/src/astro/types.ts +++ b/packages/core/src/astro/types.ts @@ -266,6 +266,7 @@ export interface EmDashHandlers { status?: string; authorId?: string; bylines?: Array<{ bylineId: string; roleLabel?: string | null }>; + taxonomies?: Record; locale?: string; translationOf?: string; createdAt?: string | null; @@ -282,6 +283,7 @@ export interface EmDashHandlers { status?: string; authorId?: string | null; bylines?: Array<{ bylineId: string; roleLabel?: string | null }>; + taxonomies?: Record; locale?: string; seo?: { title?: string | null; diff --git a/packages/core/src/database/repositories/taxonomy.ts b/packages/core/src/database/repositories/taxonomy.ts index de6edaa5d2..88772eadf1 100644 --- a/packages/core/src/database/repositories/taxonomy.ts +++ b/packages/core/src/database/repositories/taxonomy.ts @@ -358,6 +358,41 @@ export class TaxonomyRepository { if (toRemove.length > 0 || toAdd.length > 0) invalidateTaxonomyObjectCache(); } + /** + * Resolve a term reference within a taxonomy to its translation_group. + * Accepts either a term id/translation_group (matched globally, same as + * `resolveTranslationGroup`) or a slug (matched scoped to `taxonomyName`, + * since slugs are only unique per-taxonomy). Returns null if neither matches. + */ + async resolveTermRef(taxonomyName: string, idOrSlug: string): Promise { + const group = await this.resolveTranslationGroup(idOrSlug); + if (group) return group; + const bySlug = await this.findBySlug(taxonomyName, idOrSlug); + return bySlug ? (bySlug.translationGroup ?? bySlug.id) : null; + } + + /** + * Replace term assignments across multiple taxonomies for one content + * entry, keyed by taxonomy name (e.g. `{ category: ["porady"], tag: ["ai", "seo"] }`). + * Each ref may be a term id or a slug. Taxonomy names/refs that don't + * resolve are silently skipped, matching `setTermsForEntry`'s existing + * lenient handling of unresolvable ids. + */ + async setTaxonomiesForEntry( + collection: string, + entryId: string, + taxonomies: Record, + ): Promise { + for (const [taxonomyName, refs] of Object.entries(taxonomies)) { + const resolved: string[] = []; + for (const ref of refs) { + const group = await this.resolveTermRef(taxonomyName, ref); + if (group) resolved.push(group); + } + await this.setTermsForEntry(collection, entryId, taxonomyName, resolved); + } + } + async clearEntryTerms(collection: string, entryId: string): Promise { const result = await this.db .deleteFrom("content_taxonomies") diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index 757cf4e188..2c358c80e3 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -2538,6 +2538,7 @@ export class EmDashRuntime { status?: string; authorId?: string; bylines?: Array<{ bylineId: string; roleLabel?: string | null }>; + taxonomies?: Record; locale?: string; translationOf?: string; }, @@ -2597,6 +2598,7 @@ export class EmDashRuntime { status?: string; authorId?: string | null; bylines?: Array<{ bylineId: string; roleLabel?: string | null }>; + taxonomies?: Record; seo?: { title?: string | null; description?: string | null; diff --git a/packages/core/src/mcp/server.ts b/packages/core/src/mcp/server.ts index 7cf91e4fe4..b894da8e74 100644 --- a/packages/core/src/mcp/server.ts +++ b/packages/core/src/mcp/server.ts @@ -660,6 +660,12 @@ export function createMcpServer(): McpServer { .describe( "Bylines to credit. Each entry references an existing byline by id (see byline_list / byline_create) with an optional roleLabel. The first entry becomes the primary byline.", ), + taxonomies: z + .record(z.string(), z.array(z.string())) + .optional() + .describe( + "Taxonomy term assignments, keyed by taxonomy name (e.g. 'category', 'tag'). Each value is an array of term slugs or ids (see taxonomy_list_terms). Replaces any assignments for the named taxonomies.", + ), }), annotations: { destructiveHint: false }, }, @@ -698,6 +704,7 @@ export function createMcpServer(): McpServer { locale: args.locale, translationOf: args.translationOf, bylines: args.bylines, + taxonomies: args.taxonomies, }); if (!result.success) return unwrap(result); const itemId = extractContentId(result.data); @@ -715,6 +722,7 @@ export function createMcpServer(): McpServer { locale: args.locale, translationOf: args.translationOf, bylines: args.bylines, + taxonomies: args.taxonomies, }), ); }, @@ -771,6 +779,12 @@ export function createMcpServer(): McpServer { .describe( "Replace the byline list for this item. The first entry becomes the primary byline. Pass an empty array to clear all bylines.", ), + taxonomies: z + .record(z.string(), z.array(z.string())) + .optional() + .describe( + "Taxonomy term assignments to replace, keyed by taxonomy name (e.g. 'category', 'tag'). Each value is an array of term slugs or ids (see taxonomy_list_terms). Taxonomy names not included are left unchanged; pass an empty array to clear a taxonomy.", + ), publishedAt: z.iso .datetime({ offset: true, message: "must be an ISO 8601 datetime" }) .nullish() @@ -823,6 +837,7 @@ export function createMcpServer(): McpServer { args.slug || args.seo !== undefined || args.bylines !== undefined || + args.taxonomies !== undefined || args.publishedAt !== undefined ) { const updateResult = await emdash.handleContentUpdate(args.collection, resolvedId, { @@ -832,6 +847,7 @@ export function createMcpServer(): McpServer { locale: args.locale, seo: args.seo, bylines: args.bylines, + taxonomies: args.taxonomies, publishedAt: args.publishedAt, _rev: args._rev, }); @@ -847,6 +863,7 @@ export function createMcpServer(): McpServer { args.slug || args.seo !== undefined || args.bylines !== undefined || + args.taxonomies !== undefined || args.publishedAt !== undefined ) { const updateResult = await emdash.handleContentUpdate(args.collection, resolvedId, { @@ -856,6 +873,7 @@ export function createMcpServer(): McpServer { locale: args.locale, seo: args.seo, bylines: args.bylines, + taxonomies: args.taxonomies, publishedAt: args.publishedAt, _rev: args._rev, }); @@ -872,6 +890,7 @@ export function createMcpServer(): McpServer { locale: args.locale, seo: args.seo, bylines: args.bylines, + taxonomies: args.taxonomies, publishedAt: args.publishedAt, _rev: args._rev, }), diff --git a/packages/core/tests/integration/mcp/content-taxonomies.test.ts b/packages/core/tests/integration/mcp/content-taxonomies.test.ts new file mode 100644 index 0000000000..6217d15e8d --- /dev/null +++ b/packages/core/tests/integration/mcp/content-taxonomies.test.ts @@ -0,0 +1,112 @@ +/** + * MCP content_create / content_update taxonomy assignment. + * + * Regression for #953: `taxonomies` was silently ignored by both tools — + * accepted without error but never persisted. Mirrors the `bylines` argument + * added alongside content_create/content_update (see bylines.test.ts). + */ + +import { Role } from "@emdash-cms/auth"; +import type { Kysely } from "kysely"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { TaxonomyRepository } from "../../../src/database/repositories/taxonomy.js"; +import type { Database } from "../../../src/database/types.js"; +import { + connectMcpHarness, + extractJson, + extractText, + isErrorResult, + type McpHarness, +} from "../../utils/mcp-runtime.js"; +import { setupTestDatabaseWithCollections, teardownTestDatabase } from "../../utils/test-db.js"; + +const ADMIN_ID = "user_admin"; + +describe("MCP content_create / content_update taxonomies", () => { + let db: Kysely; + let harness: McpHarness; + let taxRepo: TaxonomyRepository; + + beforeEach(async () => { + db = await setupTestDatabaseWithCollections(); + taxRepo = new TaxonomyRepository(db); + harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN }); + }); + + afterEach(async () => { + if (harness) await harness.cleanup(); + await teardownTestDatabase(db); + }); + + it("content_create persists taxonomy assignments by slug", async () => { + const category = await taxRepo.create({ name: "category", slug: "porady", label: "Porady" }); + const ai = await taxRepo.create({ name: "tag", slug: "ai", label: "AI" }); + const seo = await taxRepo.create({ name: "tag", slug: "seo", label: "SEO" }); + + const created = await harness.client.callTool({ + name: "content_create", + arguments: { + collection: "post", + data: { title: "Tagged Post" }, + taxonomies: { category: ["porady"], tag: ["ai", "seo"] }, + }, + }); + expect(created.isError, extractText(created)).toBeFalsy(); + const id = extractJson<{ item: { id: string } }>(created).item.id; + + const categories = await taxRepo.getTermsForEntry("post", id, "category"); + expect(categories.map((t) => t.id)).toEqual([category.id]); + + const tags = await taxRepo.getTermsForEntry("post", id, "tag"); + expect(tags.map((t) => t.id).toSorted()).toEqual([ai.id, seo.id].toSorted()); + }); + + it("content_update persists taxonomy assignments by id and replaces prior ones", async () => { + const category = await taxRepo.create({ name: "category", slug: "news", label: "News" }); + const oldTag = await taxRepo.create({ name: "tag", slug: "old", label: "Old" }); + const newTag = await taxRepo.create({ name: "tag", slug: "new", label: "New" }); + + const created = await harness.client.callTool({ + name: "content_create", + arguments: { + collection: "post", + data: { title: "Retag Post" }, + taxonomies: { category: [category.id], tag: [oldTag.id] }, + }, + }); + expect(created.isError, extractText(created)).toBeFalsy(); + const id = extractJson<{ item: { id: string } }>(created).item.id; + + const updated = await harness.client.callTool({ + name: "content_update", + arguments: { + collection: "post", + id, + taxonomies: { tag: [newTag.id] }, + }, + }); + expect(updated.isError, extractText(updated)).toBeFalsy(); + + // `tag` was replaced; `category` was omitted from the update, so it's unchanged. + const tags = await taxRepo.getTermsForEntry("post", id, "tag"); + expect(tags.map((t) => t.id)).toEqual([newTag.id]); + const categories = await taxRepo.getTermsForEntry("post", id, "category"); + expect(categories.map((t) => t.id)).toEqual([category.id]); + }); + + it("unresolvable term refs are silently skipped, not an error", async () => { + const result = await harness.client.callTool({ + name: "content_create", + arguments: { + collection: "post", + data: { title: "Bad Ref Post" }, + taxonomies: { category: ["does-not-exist"] }, + }, + }); + expect(isErrorResult(result)).toBe(false); + const id = extractJson<{ item: { id: string } }>(result).item.id; + const categories = await taxRepo.getTermsForEntry("post", id, "category"); + expect(categories).toEqual([]); + }); +});