diff --git a/.changeset/fix-mcp-portabletext-markdown.md b/.changeset/fix-mcp-portabletext-markdown.md new file mode 100644 index 000000000..b333c67c0 --- /dev/null +++ b/.changeset/fix-mcp-portabletext-markdown.md @@ -0,0 +1,5 @@ +--- +"emdash": patch +--- + +Fixes the MCP `content_create` and `content_update` tools rejecting a markdown string in a `portableText` field with `expected array, received string`. Markdown strings in rich-text fields are now converted to Portable Text before validation, matching the `emdash` CLI client — so MCP callers can write rich text as markdown instead of hand-assembling Portable Text JSON (`_key`s, `markDefs`, block shapes), which LLM clients do unreliably. Existing Portable Text arrays are passed through unchanged. diff --git a/packages/core/src/mcp/server.ts b/packages/core/src/mcp/server.ts index e099a9867..3ce312bbb 100644 --- a/packages/core/src/mcp/server.ts +++ b/packages/core/src/mcp/server.ts @@ -18,6 +18,7 @@ import { contentBylineInputSchema, contentSeoInput } from "#api/schemas.js"; import type { EmDashHandlers } from "../astro/types.js"; import { hasScope } from "../auth/api-tokens.js"; +import { convertDataForWrite } from "../client/portable-text.js"; const COLLECTION_SLUG_PATTERN = /^[a-z][a-z0-9_]*$/; /** http(s) scheme matcher used by `settings_update` URL validation. */ @@ -381,6 +382,30 @@ function extractContentId(data: unknown): string | undefined { return typeof item?.id === "string" ? item.id : undefined; } +/** + * Convert markdown strings in `portableText` fields to Portable Text arrays + * before content data reaches the write handlers. + * + * MCP callers (LLMs) reliably produce markdown but not valid Portable Text + * JSON — unique `_key`s, resolved `markDefs`, correct block shapes — so a + * field declared as `portableText` would otherwise fail validation with + * "expected array, received string". This mirrors what the `emdash` CLI + * client does in `convertDataForWrite` before POSTing. When the collection + * has no schema the data is returned unchanged so the handler still surfaces + * the real "collection not found" error. See #1005. + */ +async function convertContentDataForWrite( + emdash: EmDashHandlers, + collection: string, + data: Record, +): Promise> { + const { SchemaRegistry } = await import("../schema/index.js"); + const registry = new SchemaRegistry(emdash.db); + const def = await registry.getCollectionWithFields(collection); + if (!def) return data; + return convertDataForWrite(data, def.fields); +} + // --------------------------------------------------------------------------- // Server factory // --------------------------------------------------------------------------- @@ -538,8 +563,9 @@ export function createMcpServer(): McpServer { description: "Create a new content item in a collection. The 'data' object should " + "contain field values matching the collection's schema (use " + - "schema_get_collection to check). Rich text fields accept Portable Text " + - "JSON arrays. A slug is auto-generated if not provided. Items are created " + + "schema_get_collection to check). Rich text (portableText) fields accept " + + "either a Portable Text JSON array or a markdown string, which is converted " + + "automatically. A slug is auto-generated if not provided. Items are created " + "as 'draft' by default — use content_publish to make them live.", inputSchema: z.object({ collection: z.string().describe("Collection slug (e.g. 'posts', 'pages')"), @@ -568,6 +594,7 @@ export function createMcpServer(): McpServer { requireScope(extra, "content:write"); requireRole(extra, Role.CONTRIBUTOR); const { emdash, userId } = getExtra(extra); + const data = await convertContentDataForWrite(emdash, args.collection, args.data); // Creating a translation requires edit permission on the source item if (args.translationOf) { @@ -591,7 +618,7 @@ export function createMcpServer(): McpServer { ); } const result = await emdash.handleContentCreate(args.collection, { - data: args.data, + data, slug: args.slug, authorId: userId, locale: args.locale, @@ -607,7 +634,7 @@ export function createMcpServer(): McpServer { return unwrap( await emdash.handleContentCreate(args.collection, { - data: args.data, + data, slug: args.slug, authorId: userId, locale: args.locale, @@ -698,6 +725,10 @@ export function createMcpServer(): McpServer { } const resolvedId = extractContentId(existing.data) ?? args.id; + const data = + args.data === undefined + ? undefined + : await convertContentDataForWrite(emdash, args.collection, args.data); // Status transitions route through dedicated handlers for proper revision management if (args.status === "published") { @@ -710,7 +741,7 @@ export function createMcpServer(): McpServer { args.publishedAt !== undefined ) { const updateResult = await emdash.handleContentUpdate(args.collection, resolvedId, { - data: args.data, + data, slug: args.slug, authorId: userId, seo: args.seo, @@ -733,7 +764,7 @@ export function createMcpServer(): McpServer { args.publishedAt !== undefined ) { const updateResult = await emdash.handleContentUpdate(args.collection, resolvedId, { - data: args.data, + data, slug: args.slug, authorId: userId, seo: args.seo, @@ -748,7 +779,7 @@ export function createMcpServer(): McpServer { return unwrap( await emdash.handleContentUpdate(args.collection, resolvedId, { - data: args.data, + data, slug: args.slug, authorId: userId, seo: args.seo, diff --git a/packages/core/tests/integration/mcp/content-portabletext-markdown.test.ts b/packages/core/tests/integration/mcp/content-portabletext-markdown.test.ts new file mode 100644 index 000000000..2d648a4e3 --- /dev/null +++ b/packages/core/tests/integration/mcp/content-portabletext-markdown.test.ts @@ -0,0 +1,122 @@ +/** + * MCP content_create / content_update accept markdown strings for + * portableText fields and convert them to Portable Text, matching the + * `emdash` CLI client (`convertDataForWrite`). LLM callers reliably produce + * markdown but not valid Portable Text JSON (unique `_key`s, `markDefs`, + * block shapes), so without this the only way to write rich text over MCP is + * to hand-assemble PT JSON — which fails validation when it's malformed. + * + * Regression test for #1005 (MCP rejected markdown strings in portableText + * fields with "expected array, received string"). + */ + +import { Role } from "@emdash-cms/auth"; +import type { Kysely } from "kysely"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +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"; + +type Item = { id: string; data: Record }; +type ItemEnvelope = { item: Item }; +type PtBlock = { _type: string; style?: string }; + +function getContent(envelope: ItemEnvelope): unknown { + return envelope.item.data.content; +} + +describe("MCP content markdown -> portableText (#1005)", () => { + let db: Kysely; + let harness: McpHarness; + + beforeEach(async () => { + db = await setupTestDatabaseWithCollections(); + harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN }); + }); + + afterEach(async () => { + if (harness) await harness.cleanup(); + await teardownTestDatabase(db); + }); + + it("content_create converts a markdown string in a portableText field", async () => { + const created = await harness.client.callTool({ + name: "content_create", + arguments: { + collection: "post", + slug: "md-create", + data: { title: "Test", content: "## Heading\n\nSome **bold** text." }, + }, + }); + expect(isErrorResult(created), extractText(created)).toBe(false); + + const id = extractJson(created).item.id; + const got = await harness.client.callTool({ + name: "content_get", + arguments: { collection: "post", id }, + }); + const content = getContent(extractJson(got)); + expect(Array.isArray(content)).toBe(true); + const blocks = content as PtBlock[]; + expect(blocks[0]?._type).toBe("block"); + expect(blocks[0]?.style).toBe("h2"); + }); + + it("content_update converts a markdown string in a portableText field", async () => { + const created = await harness.client.callTool({ + name: "content_create", + arguments: { collection: "post", slug: "md-update", data: { title: "Test" } }, + }); + const id = extractJson(created).item.id; + + const updated = await harness.client.callTool({ + name: "content_update", + arguments: { collection: "post", id, data: { content: "Just a **paragraph**." } }, + }); + expect(isErrorResult(updated), extractText(updated)).toBe(false); + + const got = await harness.client.callTool({ + name: "content_get", + arguments: { collection: "post", id }, + }); + const content = getContent(extractJson(got)); + expect(Array.isArray(content)).toBe(true); + expect((content as PtBlock[])[0]?._type).toBe("block"); + }); + + it("content_create still accepts a Portable Text array unchanged", async () => { + const block = { + _type: "block", + _key: "abc123", + style: "normal", + markDefs: [], + children: [{ _type: "span", _key: "s1", text: "Already PT", marks: [] }], + }; + const created = await harness.client.callTool({ + name: "content_create", + arguments: { + collection: "post", + slug: "pt-array", + data: { title: "Test", content: [block] }, + }, + }); + expect(isErrorResult(created), extractText(created)).toBe(false); + + const id = extractJson(created).item.id; + const got = await harness.client.callTool({ + name: "content_get", + arguments: { collection: "post", id }, + }); + const content = getContent(extractJson(got)) as Array<{ _key: string }>; + expect(content[0]?._key).toBe("abc123"); + }); +});