diff --git a/.changeset/mcp-content-taxonomies-field.md b/.changeset/mcp-content-taxonomies-field.md new file mode 100644 index 0000000000..173d3aa7cf --- /dev/null +++ b/.changeset/mcp-content-taxonomies-field.md @@ -0,0 +1,10 @@ +--- +"emdash": minor +--- + +MCP `content_create` and `content_update` now accept a `taxonomies` field +(`{ [taxonomyName]: [termSlug, ...] }`) that assigns taxonomy terms in the +same transaction as the content write. Term slugs are resolved in the entry's +locale via the same code path as the `/terms/{taxonomy}` REST route, so the +two entry points can't drift. Also exposed on the REST `POST` and `PUT` +content endpoints for parity. Fixes #953. diff --git a/packages/core/src/api/handlers/content.ts b/packages/core/src/api/handlers/content.ts index 295a79d10d..34972cf63e 100644 --- a/packages/core/src/api/handlers/content.ts +++ b/packages/core/src/api/handlers/content.ts @@ -12,6 +12,7 @@ import { ContentRepository } from "../../database/repositories/content.js"; import { RedirectRepository } from "../../database/repositories/redirect.js"; import { RevisionRepository } from "../../database/repositories/revision.js"; import { SeoRepository } from "../../database/repositories/seo.js"; +import { TaxonomyRepository } from "../../database/repositories/taxonomy.js"; import { EmDashValidationError, ScheduledNotDueError, @@ -30,6 +31,7 @@ import type { Database } from "../../database/types.js"; import { validateIdentifier } from "../../database/validate.js"; import { getI18nConfig, isI18nEnabled } from "../../i18n/config.js"; import { invalidateRedirectCache } from "../../redirects/cache.js"; +import { invalidateTermCache } from "../../taxonomies/index.js"; import { isMissingTableError } from "../../utils/db-errors.js"; import { encodeRev, validateRev } from "../rev.js"; import type { ApiResult, ContentListResponse, ContentResponse } from "../types.js"; @@ -638,6 +640,7 @@ export async function handleContentCreate( locale?: string; translationOf?: string; seo?: ContentSeoInput; + taxonomies?: Record; createdAt?: string | null; publishedAt?: string | null; }, @@ -712,7 +715,6 @@ export async function handleContentCreate( // when the target already has credits, but the cleaner guard // is to skip the call entirely. if (body.translationOf) { - const { TaxonomyRepository } = await import("../../database/repositories/taxonomy.js"); const taxRepo = new TaxonomyRepository(trx); await taxRepo.copyEntryTerms(collection, body.translationOf, created.id); @@ -737,6 +739,18 @@ export async function handleContentCreate( created.seo = { ...SEO_DEFAULTS }; } + // Attach taxonomy terms in the same transaction. The MCP tool + // (and the REST create body) previously accepted a `taxonomies` + // field on `content_create` without doing anything with it, so + // agents publishing a categorized/tagged entry had to make N + // follow-up REST calls per taxonomy. This resolves each slug in + // the entry's locale and pipes it through the same + // `setTermsForEntry` path the `.../terms/{taxonomy}` REST route + // uses, so the two entry points can't drift. + if (body.taxonomies) { + await assignTaxonomies(trx, collection, created.id, effectiveLocale, body.taxonomies); + } + return created; }); @@ -816,6 +830,7 @@ export async function handleContentUpdate( locale?: string; _rev?: string; seo?: ContentSeoInput; + taxonomies?: Record; publishedAt?: string | null; }, ): Promise> { @@ -922,6 +937,20 @@ export async function handleContentUpdate( await hydrateBylines(trx, collection, updated); + // Replace taxonomy assignments in the same transaction. Uses the + // entry's own locale (post-update) to resolve slugs so an update + // that also changes locale still lands on the correct term + // variants. See handleContentCreate for rationale. + if (body.taxonomies) { + await assignTaxonomies( + trx, + collection, + resolvedId, + updated.locale ?? body.locale, + body.taxonomies, + ); + } + return updated; }); @@ -1769,3 +1798,57 @@ async function syncNonTranslatableFields( AND id != ${updatedItemId} `.execute(trx); } + +/** + * Resolve a `{ taxonomyName: [slug, ...] }` map to term IDs and replace the + * entry's assignments for each named taxonomy. + * + * Shared by handleContentCreate and handleContentUpdate so both MCP entry + * points behave identically. Slug resolution is scoped to `locale`; passing + * `undefined` lets `findBySlug` fall back to its default (lowest locale code) + * so callers on single-locale sites don't need to know the site's default. + * + * Throws EmDashValidationError on unknown slug or wrong shape; the calling + * handler translates that into a VALIDATION_ERROR response. + */ +async function assignTaxonomies( + trx: Kysely, + collection: string, + entryId: string, + locale: string | undefined, + taxonomies: Record, +): Promise { + const taxRepo = new TaxonomyRepository(trx); + let anyChange = false; + + for (const [taxonomyName, slugs] of Object.entries(taxonomies)) { + if (!Array.isArray(slugs)) { + throw new EmDashValidationError(`taxonomies.${taxonomyName} must be an array of term slugs`); + } + + const termIds: string[] = []; + for (const slug of slugs) { + if (typeof slug !== "string" || slug.length === 0) { + throw new EmDashValidationError( + `taxonomies.${taxonomyName} contains a non-string or empty slug`, + ); + } + const term = await taxRepo.findBySlug(taxonomyName, slug, locale); + if (!term) { + throw new EmDashValidationError( + `Unknown taxonomy term: ${taxonomyName}='${slug}'${ + locale ? ` (locale '${locale}')` : "" + }`, + ); + } + termIds.push(term.id); + } + + await taxRepo.setTermsForEntry(collection, entryId, taxonomyName, termIds); + anyChange = true; + } + + // Match the REST route's behaviour: taxonomy term assignments changed, + // so invalidate the taxonomy object cache used during hydration. + if (anyChange) invalidateTermCache(); +} diff --git a/packages/core/src/api/schemas/content.ts b/packages/core/src/api/schemas/content.ts index 4907f84633..fafab339b2 100644 --- a/packages/core/src/api/schemas/content.ts +++ b/packages/core/src/api/schemas/content.ts @@ -59,6 +59,10 @@ export const contentCreateBody = z locale: localeCode.optional(), translationOf: z.string().optional(), seo: contentSeoInput.optional(), + taxonomies: z.record(z.string(), z.array(z.string())).optional().meta({ + description: + "Taxonomy term assignments as { taxonomyName: [termSlug, ...] }, resolved in the entry's locale.", + }), publishedAt: contentDateOverride, createdAt: contentDateOverride, }) @@ -77,6 +81,10 @@ export const contentUpdateBody = z .meta({ description: "Opaque revision token for optimistic concurrency" }), skipRevision: z.boolean().optional(), seo: contentSeoInput.optional(), + taxonomies: z.record(z.string(), z.array(z.string())).optional().meta({ + description: + "Replace taxonomy assignments as { taxonomyName: [termSlug, ...] }. Only named taxonomies are touched; pass an empty array to clear a taxonomy.", + }), publishedAt: contentDateOverride, }) .meta({ id: "ContentUpdateBody" }); diff --git a/packages/core/src/astro/types.ts b/packages/core/src/astro/types.ts index 02fd914b4b..f3cd045a5e 100644 --- a/packages/core/src/astro/types.ts +++ b/packages/core/src/astro/types.ts @@ -268,6 +268,7 @@ export interface EmDashHandlers { bylines?: Array<{ bylineId: string; roleLabel?: string | null }>; locale?: string; translationOf?: string; + taxonomies?: Record; createdAt?: string | null; publishedAt?: string | null; }, @@ -290,6 +291,7 @@ export interface EmDashHandlers { canonical?: string | null; noIndex?: boolean; }; + taxonomies?: Record; publishedAt?: string | null; _rev?: string; }, diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index 757cf4e188..f7418a3bfc 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -2540,6 +2540,7 @@ export class EmDashRuntime { bylines?: Array<{ bylineId: string; roleLabel?: string | null }>; locale?: string; translationOf?: string; + taxonomies?: Record; }, ) { // Run beforeSave hooks (trusted plugins) @@ -2604,6 +2605,7 @@ export class EmDashRuntime { canonical?: string | null; noIndex?: boolean; }; + taxonomies?: Record; publishedAt?: string | null; locale?: string; /** Skip revision creation (used by autosave) */ diff --git a/packages/core/src/mcp/server.ts b/packages/core/src/mcp/server.ts index 7cf91e4fe4..4432132e34 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 as { taxonomyName: [termSlug, ...] }. Term slugs are resolved in the entry's locale. Call taxonomy_list to see available taxonomies and taxonomy_list_terms to look up slugs. Missing keys leave that taxonomy unchanged.", + ), }), 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( + "Replace taxonomy term assignments as { taxonomyName: [termSlug, ...] }. Term slugs are resolved in the entry's locale. Only named taxonomies are touched; other taxonomies 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/content/content-taxonomies.test.ts b/packages/core/tests/integration/content/content-taxonomies.test.ts new file mode 100644 index 0000000000..7591def67c --- /dev/null +++ b/packages/core/tests/integration/content/content-taxonomies.test.ts @@ -0,0 +1,226 @@ +/** + * MCP + REST content create/update accept a `taxonomies` field that assigns + * taxonomy terms in the same transaction (issue #953). + * + * Prior behaviour: `content_create` silently ignored the field and callers had + * to make N follow-up REST calls to attach categories/tags. The regression + * this suite guards is that the field now resolves term slugs in the entry's + * locale, persists the assignments via `setTermsForEntry` (same path as the + * `/terms/{taxonomy}` REST route), and reports validation errors rather than + * silently dropping unknown terms. + */ + +import type { Kysely } from "kysely"; +import { afterEach, beforeEach, expect, it } from "vitest"; + +import { handleContentCreate, handleContentUpdate } from "../../../src/api/handlers/content.js"; +import { TaxonomyRepository } from "../../../src/database/repositories/taxonomy.js"; +import type { Database } from "../../../src/database/types.js"; +import { setI18nConfig } from "../../../src/i18n/config.js"; +import { + describeEachDialect, + setupForDialectWithCollections, + teardownForDialect, + type DialectTestContext, +} from "../../utils/test-db.js"; + +async function seedTerms(db: Kysely) { + const taxRepo = new TaxonomyRepository(db); + const cat = await taxRepo.create({ + name: "category", + slug: "porady", + label: "Porady", + locale: "en", + }); + const tagAi = await taxRepo.create({ + name: "tag", + slug: "ai", + label: "AI", + locale: "en", + }); + const tagSeo = await taxRepo.create({ + name: "tag", + slug: "seo", + label: "SEO", + locale: "en", + }); + return { cat, tagAi, tagSeo }; +} + +describeEachDialect("Content taxonomies field - create/update", (dialect) => { + let ctx: DialectTestContext; + + beforeEach(async () => { + ctx = await setupForDialectWithCollections(dialect); + }); + + afterEach(async () => { + setI18nConfig(null); + await teardownForDialect(ctx); + }); + + it("attaches taxonomy terms on create by slug", async () => { + await seedTerms(ctx.db); + + const result = await handleContentCreate(ctx.db, "post", { + data: { title: "Hello" }, + taxonomies: { category: ["porady"], tag: ["ai", "seo"] }, + }); + expect(result.success).toBe(true); + + const taxRepo = new TaxonomyRepository(ctx.db); + const entryId = result.data!.item.id; + const cats = await taxRepo.getTermsForEntry("post", entryId, "category"); + const tags = await taxRepo.getTermsForEntry("post", entryId, "tag"); + expect(cats.map((t) => t.slug).toSorted()).toEqual(["porady"]); + expect(tags.map((t) => t.slug).toSorted()).toEqual(["ai", "seo"]); + }); + + it("returns VALIDATION_ERROR and rolls back the whole write when a slug is unknown", async () => { + await seedTerms(ctx.db); + + const result = await handleContentCreate(ctx.db, "post", { + data: { title: "Rolled back" }, + slug: "rolled-back", + taxonomies: { tag: ["ai", "does-not-exist"] }, + }); + expect(result.success).toBe(false); + expect(result.error?.code).toBe("VALIDATION_ERROR"); + expect(result.error?.message).toContain("does-not-exist"); + + // The transaction must have rolled back. No orphan content row and no + // partial term assignment for the tag that did exist. + const list = await ctx.db + .selectFrom("ec_post") + .selectAll() + .where("slug", "=", "rolled-back") + .execute(); + expect(list).toHaveLength(0); + }); + + it("rejects taxonomies with a non-array value", async () => { + await seedTerms(ctx.db); + + const result = await handleContentCreate(ctx.db, "post", { + data: { title: "Bad shape" }, + // eslint-disable-next-line typescript/no-explicit-any -- deliberately malformed + taxonomies: { tag: "ai" as any }, + }); + expect(result.success).toBe(false); + expect(result.error?.code).toBe("VALIDATION_ERROR"); + }); + + it("replaces existing assignments on update (setTermsForEntry semantics)", async () => { + const { tagAi, tagSeo } = await seedTerms(ctx.db); + const taxRepo = new TaxonomyRepository(ctx.db); + + const created = await handleContentCreate(ctx.db, "post", { + data: { title: "Replace me" }, + taxonomies: { tag: ["ai", "seo"] }, + }); + expect(created.success).toBe(true); + const entryId = created.data!.item.id; + + const updated = await handleContentUpdate(ctx.db, "post", entryId, { + taxonomies: { tag: ["seo"] }, + }); + expect(updated.success).toBe(true); + + const tags = await taxRepo.getTermsForEntry("post", entryId, "tag"); + expect(tags.map((t) => t.slug)).toEqual(["seo"]); + // Sanity: we didn't just wipe the taxonomies table. + expect((await taxRepo.findById(tagAi.id))?.slug).toBe("ai"); + expect((await taxRepo.findById(tagSeo.id))?.slug).toBe("seo"); + }); + + it("clears a taxonomy when passed an empty array", async () => { + await seedTerms(ctx.db); + const taxRepo = new TaxonomyRepository(ctx.db); + + const created = await handleContentCreate(ctx.db, "post", { + data: { title: "Clear me" }, + taxonomies: { tag: ["ai"] }, + }); + const entryId = created.data!.item.id; + + const updated = await handleContentUpdate(ctx.db, "post", entryId, { + taxonomies: { tag: [] }, + }); + expect(updated.success).toBe(true); + + const tags = await taxRepo.getTermsForEntry("post", entryId, "tag"); + expect(tags).toEqual([]); + }); + + it("leaves untouched taxonomies alone (only rewrites named ones)", async () => { + await seedTerms(ctx.db); + const taxRepo = new TaxonomyRepository(ctx.db); + + const created = await handleContentCreate(ctx.db, "post", { + data: { title: "Selective update" }, + taxonomies: { category: ["porady"], tag: ["ai"] }, + }); + const entryId = created.data!.item.id; + + // Update only `tag`; `category` must survive. + const updated = await handleContentUpdate(ctx.db, "post", entryId, { + taxonomies: { tag: ["seo"] }, + }); + expect(updated.success).toBe(true); + + const cats = await taxRepo.getTermsForEntry("post", entryId, "category"); + const tags = await taxRepo.getTermsForEntry("post", entryId, "tag"); + expect(cats.map((t) => t.slug)).toEqual(["porady"]); + expect(tags.map((t) => t.slug)).toEqual(["seo"]); + }); + + it("resolves slugs in the entry's locale for i18n sites", async () => { + setI18nConfig({ + defaultLocale: "en", + locales: [ + { code: "en", label: "English" }, + { code: "fr", label: "Français" }, + ], + }); + + const taxRepo = new TaxonomyRepository(ctx.db); + const enTag = await taxRepo.create({ + name: "tag", + slug: "news", + label: "News", + locale: "en", + }); + await taxRepo.create({ + name: "tag", + slug: "actualites", + label: "Actualités", + locale: "fr", + translationOf: enTag.id, + }); + + // EN entry references the EN slug. + const enResult = await handleContentCreate(ctx.db, "post", { + data: { title: "Hello" }, + locale: "en", + taxonomies: { tag: ["news"] }, + }); + expect(enResult.success).toBe(true); + + // FR entry references the FR slug. The EN slug must NOT resolve here. + const frResult = await handleContentCreate(ctx.db, "post", { + data: { title: "Bonjour" }, + locale: "fr", + taxonomies: { tag: ["actualites"] }, + }); + expect(frResult.success).toBe(true); + + const frFailure = await handleContentCreate(ctx.db, "post", { + data: { title: "Bonjour 2" }, + slug: "bonjour-2", + locale: "fr", + taxonomies: { tag: ["news"] }, + }); + expect(frFailure.success).toBe(false); + expect(frFailure.error?.code).toBe("VALIDATION_ERROR"); + }); +});