Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/mcp-content-taxonomies-field.md
Original file line number Diff line number Diff line change
@@ -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.
85 changes: 84 additions & 1 deletion packages/core/src/api/handlers/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -638,6 +640,7 @@ export async function handleContentCreate(
locale?: string;
translationOf?: string;
seo?: ContentSeoInput;
taxonomies?: Record<string, string[]>;
createdAt?: string | null;
publishedAt?: string | null;
},
Expand Down Expand Up @@ -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);

Expand All @@ -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;
});

Expand Down Expand Up @@ -816,6 +830,7 @@ export async function handleContentUpdate(
locale?: string;
_rev?: string;
seo?: ContentSeoInput;
taxonomies?: Record<string, string[]>;
publishedAt?: string | null;
},
): Promise<ApiResult<ContentResponse>> {
Expand Down Expand Up @@ -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;
});

Expand Down Expand Up @@ -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<Database>,
collection: string,
entryId: string,
locale: string | undefined,
taxonomies: Record<string, string[]>,
): Promise<void> {
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();
}
8 changes: 8 additions & 0 deletions packages/core/src/api/schemas/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand All @@ -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" });
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/astro/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ export interface EmDashHandlers {
bylines?: Array<{ bylineId: string; roleLabel?: string | null }>;
locale?: string;
translationOf?: string;
taxonomies?: Record<string, string[]>;
createdAt?: string | null;
publishedAt?: string | null;
},
Expand All @@ -290,6 +291,7 @@ export interface EmDashHandlers {
canonical?: string | null;
noIndex?: boolean;
};
taxonomies?: Record<string, string[]>;
publishedAt?: string | null;
_rev?: string;
},
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/emdash-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2540,6 +2540,7 @@ export class EmDashRuntime {
bylines?: Array<{ bylineId: string; roleLabel?: string | null }>;
locale?: string;
translationOf?: string;
taxonomies?: Record<string, string[]>;
},
) {
// Run beforeSave hooks (trusted plugins)
Expand Down Expand Up @@ -2604,6 +2605,7 @@ export class EmDashRuntime {
canonical?: string | null;
noIndex?: boolean;
};
taxonomies?: Record<string, string[]>;
publishedAt?: string | null;
locale?: string;
/** Skip revision creation (used by autosave) */
Expand Down
19 changes: 19 additions & 0 deletions packages/core/src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
},
Expand Down Expand Up @@ -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);
Expand All @@ -715,6 +722,7 @@ export function createMcpServer(): McpServer {
locale: args.locale,
translationOf: args.translationOf,
bylines: args.bylines,
taxonomies: args.taxonomies,
}),
);
},
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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, {
Expand All @@ -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,
});
Expand All @@ -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, {
Expand All @@ -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,
});
Expand All @@ -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,
}),
Expand Down
Loading
Loading