Skip to content

Commit 0d2d3b3

Browse files
Vallhalenmarcusbellamyshaw-cell
authored andcommitted
feat(mcp): support taxonomies field on content_create/content_update (emdash-cms#953) (emdash-cms#1874)
* feat(mcp): support taxonomies field on content_create/content_update (emdash-cms#953) Extends MCP content_create and content_update (and the matching REST POST/PUT bodies) with a `taxonomies` field of shape `{ [taxonomyName]: [termSlug, ...] }`. Term slugs are resolved in the entry's locale via `TaxonomyRepository.findBySlug` and persisted through `setTermsForEntry` in the same transaction as the content write, so crash recovery leaves either both changes or neither. - Unknown slug or wrong-shape input surfaces a VALIDATION_ERROR from the handler; the entire content write rolls back. - Update semantics match `setTermsForEntry`: only the taxonomies named in the payload are touched, and an empty array clears one taxonomy. - Reuses the exact code path the `/terms/{taxonomy}` REST route uses so the two entry points can't drift, including the `invalidateTermCache` side-effect for the hasAny hydration cache. Prior behaviour: `taxonomies` was silently accepted and ignored, forcing agents to make N follow-up REST calls per taxonomy after every create. * fix(mcp): address review feedback on taxonomies field - Point content_create's taxonomies description at taxonomy_list_terms (the real tool) instead of the non-existent term_list, so agents can self-serve slug lookups. - Rewrite the stale hasAnyTermAssignments cache comment on the helper's invalidateTermCache call to name what actually gets bumped (the taxonomy object cache).
1 parent 3bd74a9 commit 0d2d3b3

7 files changed

Lines changed: 351 additions & 1 deletion

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"emdash": minor
3+
---
4+
5+
MCP `content_create` and `content_update` now accept a `taxonomies` field
6+
(`{ [taxonomyName]: [termSlug, ...] }`) that assigns taxonomy terms in the
7+
same transaction as the content write. Term slugs are resolved in the entry's
8+
locale via the same code path as the `/terms/{taxonomy}` REST route, so the
9+
two entry points can't drift. Also exposed on the REST `POST` and `PUT`
10+
content endpoints for parity. Fixes #953.

packages/core/src/api/handlers/content.ts

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { ContentRepository } from "../../database/repositories/content.js";
1212
import { RedirectRepository } from "../../database/repositories/redirect.js";
1313
import { RevisionRepository } from "../../database/repositories/revision.js";
1414
import { SeoRepository } from "../../database/repositories/seo.js";
15+
import { TaxonomyRepository } from "../../database/repositories/taxonomy.js";
1516
import {
1617
EmDashValidationError,
1718
ScheduledNotDueError,
@@ -30,6 +31,7 @@ import type { Database } from "../../database/types.js";
3031
import { validateIdentifier } from "../../database/validate.js";
3132
import { getI18nConfig, isI18nEnabled } from "../../i18n/config.js";
3233
import { invalidateRedirectCache } from "../../redirects/cache.js";
34+
import { invalidateTermCache } from "../../taxonomies/index.js";
3335
import { isMissingTableError } from "../../utils/db-errors.js";
3436
import { encodeRev, validateRev } from "../rev.js";
3537
import type { ApiResult, ContentListResponse, ContentResponse } from "../types.js";
@@ -638,6 +640,7 @@ export async function handleContentCreate(
638640
locale?: string;
639641
translationOf?: string;
640642
seo?: ContentSeoInput;
643+
taxonomies?: Record<string, string[]>;
641644
createdAt?: string | null;
642645
publishedAt?: string | null;
643646
},
@@ -712,7 +715,6 @@ export async function handleContentCreate(
712715
// when the target already has credits, but the cleaner guard
713716
// is to skip the call entirely.
714717
if (body.translationOf) {
715-
const { TaxonomyRepository } = await import("../../database/repositories/taxonomy.js");
716718
const taxRepo = new TaxonomyRepository(trx);
717719
await taxRepo.copyEntryTerms(collection, body.translationOf, created.id);
718720

@@ -737,6 +739,18 @@ export async function handleContentCreate(
737739
created.seo = { ...SEO_DEFAULTS };
738740
}
739741

742+
// Attach taxonomy terms in the same transaction. The MCP tool
743+
// (and the REST create body) previously accepted a `taxonomies`
744+
// field on `content_create` without doing anything with it, so
745+
// agents publishing a categorized/tagged entry had to make N
746+
// follow-up REST calls per taxonomy. This resolves each slug in
747+
// the entry's locale and pipes it through the same
748+
// `setTermsForEntry` path the `.../terms/{taxonomy}` REST route
749+
// uses, so the two entry points can't drift.
750+
if (body.taxonomies) {
751+
await assignTaxonomies(trx, collection, created.id, effectiveLocale, body.taxonomies);
752+
}
753+
740754
return created;
741755
});
742756

@@ -816,6 +830,7 @@ export async function handleContentUpdate(
816830
locale?: string;
817831
_rev?: string;
818832
seo?: ContentSeoInput;
833+
taxonomies?: Record<string, string[]>;
819834
publishedAt?: string | null;
820835
},
821836
): Promise<ApiResult<ContentResponse>> {
@@ -922,6 +937,20 @@ export async function handleContentUpdate(
922937

923938
await hydrateBylines(trx, collection, updated);
924939

940+
// Replace taxonomy assignments in the same transaction. Uses the
941+
// entry's own locale (post-update) to resolve slugs so an update
942+
// that also changes locale still lands on the correct term
943+
// variants. See handleContentCreate for rationale.
944+
if (body.taxonomies) {
945+
await assignTaxonomies(
946+
trx,
947+
collection,
948+
resolvedId,
949+
updated.locale ?? body.locale,
950+
body.taxonomies,
951+
);
952+
}
953+
925954
return updated;
926955
});
927956

@@ -1769,3 +1798,57 @@ async function syncNonTranslatableFields(
17691798
AND id != ${updatedItemId}
17701799
`.execute(trx);
17711800
}
1801+
1802+
/**
1803+
* Resolve a `{ taxonomyName: [slug, ...] }` map to term IDs and replace the
1804+
* entry's assignments for each named taxonomy.
1805+
*
1806+
* Shared by handleContentCreate and handleContentUpdate so both MCP entry
1807+
* points behave identically. Slug resolution is scoped to `locale`; passing
1808+
* `undefined` lets `findBySlug` fall back to its default (lowest locale code)
1809+
* so callers on single-locale sites don't need to know the site's default.
1810+
*
1811+
* Throws EmDashValidationError on unknown slug or wrong shape; the calling
1812+
* handler translates that into a VALIDATION_ERROR response.
1813+
*/
1814+
async function assignTaxonomies(
1815+
trx: Kysely<Database>,
1816+
collection: string,
1817+
entryId: string,
1818+
locale: string | undefined,
1819+
taxonomies: Record<string, string[]>,
1820+
): Promise<void> {
1821+
const taxRepo = new TaxonomyRepository(trx);
1822+
let anyChange = false;
1823+
1824+
for (const [taxonomyName, slugs] of Object.entries(taxonomies)) {
1825+
if (!Array.isArray(slugs)) {
1826+
throw new EmDashValidationError(`taxonomies.${taxonomyName} must be an array of term slugs`);
1827+
}
1828+
1829+
const termIds: string[] = [];
1830+
for (const slug of slugs) {
1831+
if (typeof slug !== "string" || slug.length === 0) {
1832+
throw new EmDashValidationError(
1833+
`taxonomies.${taxonomyName} contains a non-string or empty slug`,
1834+
);
1835+
}
1836+
const term = await taxRepo.findBySlug(taxonomyName, slug, locale);
1837+
if (!term) {
1838+
throw new EmDashValidationError(
1839+
`Unknown taxonomy term: ${taxonomyName}='${slug}'${
1840+
locale ? ` (locale '${locale}')` : ""
1841+
}`,
1842+
);
1843+
}
1844+
termIds.push(term.id);
1845+
}
1846+
1847+
await taxRepo.setTermsForEntry(collection, entryId, taxonomyName, termIds);
1848+
anyChange = true;
1849+
}
1850+
1851+
// Match the REST route's behaviour: taxonomy term assignments changed,
1852+
// so invalidate the taxonomy object cache used during hydration.
1853+
if (anyChange) invalidateTermCache();
1854+
}

packages/core/src/api/schemas/content.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ export const contentCreateBody = z
5959
locale: localeCode.optional(),
6060
translationOf: z.string().optional(),
6161
seo: contentSeoInput.optional(),
62+
taxonomies: z.record(z.string(), z.array(z.string())).optional().meta({
63+
description:
64+
"Taxonomy term assignments as { taxonomyName: [termSlug, ...] }, resolved in the entry's locale.",
65+
}),
6266
publishedAt: contentDateOverride,
6367
createdAt: contentDateOverride,
6468
})
@@ -77,6 +81,10 @@ export const contentUpdateBody = z
7781
.meta({ description: "Opaque revision token for optimistic concurrency" }),
7882
skipRevision: z.boolean().optional(),
7983
seo: contentSeoInput.optional(),
84+
taxonomies: z.record(z.string(), z.array(z.string())).optional().meta({
85+
description:
86+
"Replace taxonomy assignments as { taxonomyName: [termSlug, ...] }. Only named taxonomies are touched; pass an empty array to clear a taxonomy.",
87+
}),
8088
publishedAt: contentDateOverride,
8189
})
8290
.meta({ id: "ContentUpdateBody" });

packages/core/src/astro/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,7 @@ export interface EmDashHandlers {
268268
bylines?: Array<{ bylineId: string; roleLabel?: string | null }>;
269269
locale?: string;
270270
translationOf?: string;
271+
taxonomies?: Record<string, string[]>;
271272
createdAt?: string | null;
272273
publishedAt?: string | null;
273274
},
@@ -290,6 +291,7 @@ export interface EmDashHandlers {
290291
canonical?: string | null;
291292
noIndex?: boolean;
292293
};
294+
taxonomies?: Record<string, string[]>;
293295
publishedAt?: string | null;
294296
_rev?: string;
295297
},

packages/core/src/emdash-runtime.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2606,6 +2606,7 @@ export class EmDashRuntime {
26062606
bylines?: Array<{ bylineId: string; roleLabel?: string | null }>;
26072607
locale?: string;
26082608
translationOf?: string;
2609+
taxonomies?: Record<string, string[]>;
26092610
},
26102611
) {
26112612
// Run beforeSave hooks (trusted plugins)
@@ -2670,6 +2671,7 @@ export class EmDashRuntime {
26702671
canonical?: string | null;
26712672
noIndex?: boolean;
26722673
};
2674+
taxonomies?: Record<string, string[]>;
26732675
publishedAt?: string | null;
26742676
locale?: string;
26752677
/** Skip revision creation (used by autosave) */

packages/core/src/mcp/server.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -660,6 +660,12 @@ export function createMcpServer(): McpServer {
660660
.describe(
661661
"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.",
662662
),
663+
taxonomies: z
664+
.record(z.string(), z.array(z.string()))
665+
.optional()
666+
.describe(
667+
"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.",
668+
),
663669
}),
664670
annotations: { destructiveHint: false },
665671
},
@@ -698,6 +704,7 @@ export function createMcpServer(): McpServer {
698704
locale: args.locale,
699705
translationOf: args.translationOf,
700706
bylines: args.bylines,
707+
taxonomies: args.taxonomies,
701708
});
702709
if (!result.success) return unwrap(result);
703710
const itemId = extractContentId(result.data);
@@ -715,6 +722,7 @@ export function createMcpServer(): McpServer {
715722
locale: args.locale,
716723
translationOf: args.translationOf,
717724
bylines: args.bylines,
725+
taxonomies: args.taxonomies,
718726
}),
719727
);
720728
},
@@ -771,6 +779,12 @@ export function createMcpServer(): McpServer {
771779
.describe(
772780
"Replace the byline list for this item. The first entry becomes the primary byline. Pass an empty array to clear all bylines.",
773781
),
782+
taxonomies: z
783+
.record(z.string(), z.array(z.string()))
784+
.optional()
785+
.describe(
786+
"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.",
787+
),
774788
publishedAt: z.iso
775789
.datetime({ offset: true, message: "must be an ISO 8601 datetime" })
776790
.nullish()
@@ -823,6 +837,7 @@ export function createMcpServer(): McpServer {
823837
args.slug ||
824838
args.seo !== undefined ||
825839
args.bylines !== undefined ||
840+
args.taxonomies !== undefined ||
826841
args.publishedAt !== undefined
827842
) {
828843
const updateResult = await emdash.handleContentUpdate(args.collection, resolvedId, {
@@ -832,6 +847,7 @@ export function createMcpServer(): McpServer {
832847
locale: args.locale,
833848
seo: args.seo,
834849
bylines: args.bylines,
850+
taxonomies: args.taxonomies,
835851
publishedAt: args.publishedAt,
836852
_rev: args._rev,
837853
});
@@ -847,6 +863,7 @@ export function createMcpServer(): McpServer {
847863
args.slug ||
848864
args.seo !== undefined ||
849865
args.bylines !== undefined ||
866+
args.taxonomies !== undefined ||
850867
args.publishedAt !== undefined
851868
) {
852869
const updateResult = await emdash.handleContentUpdate(args.collection, resolvedId, {
@@ -856,6 +873,7 @@ export function createMcpServer(): McpServer {
856873
locale: args.locale,
857874
seo: args.seo,
858875
bylines: args.bylines,
876+
taxonomies: args.taxonomies,
859877
publishedAt: args.publishedAt,
860878
_rev: args._rev,
861879
});
@@ -872,6 +890,7 @@ export function createMcpServer(): McpServer {
872890
locale: args.locale,
873891
seo: args.seo,
874892
bylines: args.bylines,
893+
taxonomies: args.taxonomies,
875894
publishedAt: args.publishedAt,
876895
_rev: args._rev,
877896
}),

0 commit comments

Comments
 (0)