From 0d88137780d073b686b994b2f4bd5ef181197881 Mon Sep 17 00:00:00 2001 From: Asish Kumar Date: Mon, 25 May 2026 07:45:52 +0530 Subject: [PATCH 1/2] fix(core): accept markdown strings in portableText fields over MCP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP content_create and content_update tools passed `args.data` straight to the write handlers, so a markdown string in a `portableText` field failed schema validation with 'expected array, received string'. The emdash CLI client already converts markdown to Portable Text via convertDataForWrite before sending; the MCP server skipped that step. LLM callers reliably produce markdown but not valid Portable Text JSON (unique _key values, markDefs, block shapes), so this effectively blocked writing rich text over MCP. Convert markdown to Portable Text in the MCP server before the data reaches the handlers, resolving the collection's field types via SchemaRegistry — the same conversion the CLI performs. Existing Portable Text arrays pass through unchanged. Closes #1005 --- .changeset/fix-mcp-portabletext-markdown.md | 5 + packages/core/src/mcp/server.ts | 45 +++++-- .../mcp/content-portabletext-markdown.test.ts | 118 ++++++++++++++++++ 3 files changed, 161 insertions(+), 7 deletions(-) create mode 100644 .changeset/fix-mcp-portabletext-markdown.md create mode 100644 packages/core/tests/integration/mcp/content-portabletext-markdown.test.ts 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..df1db0a9c --- /dev/null +++ b/packages/core/tests/integration/mcp/content-portabletext-markdown.test.ts @@ -0,0 +1,118 @@ +/** + * 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"); + }); +}); From 1676290c1ca8da0a1ea933f58c7c8903ee50d74c Mon Sep 17 00:00:00 2001 From: "emdashbot[bot]" Date: Mon, 25 May 2026 02:16:39 +0000 Subject: [PATCH 2/2] style: format --- .../integration/mcp/content-portabletext-markdown.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/core/tests/integration/mcp/content-portabletext-markdown.test.ts b/packages/core/tests/integration/mcp/content-portabletext-markdown.test.ts index df1db0a9c..2d648a4e3 100644 --- a/packages/core/tests/integration/mcp/content-portabletext-markdown.test.ts +++ b/packages/core/tests/integration/mcp/content-portabletext-markdown.test.ts @@ -103,7 +103,11 @@ describe("MCP content markdown -> portableText (#1005)", () => { }; const created = await harness.client.callTool({ name: "content_create", - arguments: { collection: "post", slug: "pt-array", data: { title: "Test", content: [block] } }, + arguments: { + collection: "post", + slug: "pt-array", + data: { title: "Test", content: [block] }, + }, }); expect(isErrorResult(created), extractText(created)).toBe(false);