Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/slow-donuts-hide.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 16 additions & 0 deletions packages/core/src/api/handlers/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,7 @@ export async function handleContentCreate(
status?: string;
authorId?: string;
bylines?: ContentBylineInput[];
taxonomies?: Record<string, string[]>;
locale?: string;
translationOf?: string;
seo?: ContentSeoInput;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -813,6 +822,7 @@ export async function handleContentUpdate(
status?: string;
authorId?: string | null;
bylines?: ContentBylineInput[];
taxonomies?: Record<string, string[]>;
locale?: string;
_rev?: string;
seo?: ContentSeoInput;
Expand Down Expand Up @@ -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);
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 @@ -266,6 +266,7 @@ export interface EmDashHandlers {
status?: string;
authorId?: string;
bylines?: Array<{ bylineId: string; roleLabel?: string | null }>;
taxonomies?: Record<string, string[]>;
locale?: string;
translationOf?: string;
createdAt?: string | null;
Expand All @@ -282,6 +283,7 @@ export interface EmDashHandlers {
status?: string;
authorId?: string | null;
bylines?: Array<{ bylineId: string; roleLabel?: string | null }>;
taxonomies?: Record<string, string[]>;
locale?: string;
seo?: {
title?: string | null;
Expand Down
35 changes: 35 additions & 0 deletions packages/core/src/database/repositories/taxonomy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null> {
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<string, string[]>,
): Promise<void> {
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<number> {
const result = await this.db
.deleteFrom("content_taxonomies")
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 @@ -2538,6 +2538,7 @@ export class EmDashRuntime {
status?: string;
authorId?: string;
bylines?: Array<{ bylineId: string; roleLabel?: string | null }>;
taxonomies?: Record<string, string[]>;
locale?: string;
translationOf?: string;
},
Expand Down Expand Up @@ -2597,6 +2598,7 @@ export class EmDashRuntime {
status?: string;
authorId?: string | null;
bylines?: Array<{ bylineId: string; roleLabel?: string | null }>;
taxonomies?: Record<string, string[]>;
seo?: {
title?: string | null;
description?: string | null;
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, 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 },
},
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(
"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()
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
112 changes: 112 additions & 0 deletions packages/core/tests/integration/mcp/content-taxonomies.test.ts
Original file line number Diff line number Diff line change
@@ -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<Database>;
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([]);
});
});
Loading