From fff19088e8c6f161105f9fd6452e3e58daf3916c Mon Sep 17 00:00:00 2001 From: khoinguyenpham04 <137921741+khoinguyenpham04@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:10:56 +0100 Subject: [PATCH 1/3] fix(mcp): preserve omitted blocks fields on writes --- .changeset/fix-omitted-blocks-mcp.md | 5 + packages/core/src/client/portable-text.ts | 2 +- .../integration/mcp/blocks-content.test.ts | 119 ++++++++++++++++++ .../tests/unit/client/portable-text.test.ts | 4 + 4 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-omitted-blocks-mcp.md create mode 100644 packages/core/tests/integration/mcp/blocks-content.test.ts diff --git a/.changeset/fix-omitted-blocks-mcp.md b/.changeset/fix-omitted-blocks-mcp.md new file mode 100644 index 0000000000..c0a87c6a9a --- /dev/null +++ b/.changeset/fix-omitted-blocks-mcp.md @@ -0,0 +1,5 @@ +--- +"emdash": patch +--- + +Fixes MCP `content_create` and `content_update` rejecting writes that omit a blocks field with "must be an array". Omitted blocks fields now use an empty array on create and retain their existing value on update; supplied non-array values are still rejected. diff --git a/packages/core/src/client/portable-text.ts b/packages/core/src/client/portable-text.ts index 7aca4b6186..2c4a6ceede 100644 --- a/packages/core/src/client/portable-text.ts +++ b/packages/core/src/client/portable-text.ts @@ -446,7 +446,7 @@ export function convertDataForWrite( for (const field of fields) { if (field.type === "portableText" && typeof result[field.slug] === "string") { result[field.slug] = markdownToPortableText(result[field.slug] as string); - } else if (field.type === "blocks") { + } else if (field.type === "blocks" && Array.isArray(result[field.slug])) { result[field.slug] = convertNestedBlockPortableText(result[field.slug], field, "write"); } } diff --git a/packages/core/tests/integration/mcp/blocks-content.test.ts b/packages/core/tests/integration/mcp/blocks-content.test.ts new file mode 100644 index 0000000000..52361c5754 --- /dev/null +++ b/packages/core/tests/integration/mcp/blocks-content.test.ts @@ -0,0 +1,119 @@ +import { Role } from "@emdash-cms/auth"; +import { afterEach, beforeEach, expect, it } from "vitest"; + +import { BlockTypeRegistry } from "../../../src/schema/block-type-registry.js"; +import { SchemaRegistry } from "../../../src/schema/registry.js"; +import { + connectMcpHarness, + currentRev, + extractJson, + extractText, + type McpHarness, +} from "../../utils/mcp-runtime.js"; +import { + describeEachDialect, + setupForDialect, + teardownForDialect, + type DialectTestContext, +} from "../../utils/test-db.js"; + +interface ContentEnvelope { + item: { id: string; data: Record }; +} + +describeEachDialect("MCP blocks content writes", (dialect) => { + let ctx: DialectTestContext; + let harness: McpHarness; + + beforeEach(async () => { + ctx = await setupForDialect(dialect); + await new BlockTypeRegistry(ctx.db).createBlockType({ + slug: "hero", + label: "Hero", + fields: [ + { slug: "heading", label: "Heading", type: "string", required: true }, + { slug: "body", label: "Body", type: "portableText" }, + ], + }); + const schema = new SchemaRegistry(ctx.db); + await schema.createCollection({ + slug: "pages", + label: "Pages", + supports: ["drafts", "revisions"], + }); + await schema.createField("pages", { slug: "title", label: "Title", type: "string" }); + await schema.createField("pages", { + slug: "layout", + label: "Layout", + type: "blocks", + validation: { allowedTypes: ["hero"] }, + }); + harness = await connectMcpHarness({ db: ctx.db, userId: "admin", userRole: Role.ADMIN }); + }); + + afterEach(async () => { + await harness?.cleanup(); + await teardownForDialect(ctx); + }); + + it("creates content with an empty blocks field when the field is omitted", async () => { + const result = await harness.client.callTool({ + name: "content_create", + arguments: { collection: "pages", data: { title: "No layout" } }, + }); + expect(result.isError, extractText(result)).toBeFalsy(); + const created = extractJson(result); + const read = await harness.client.callTool({ + name: "content_get", + arguments: { collection: "pages", id: created.item.id }, + }); + expect(read.isError, extractText(read)).toBeFalsy(); + expect(extractJson(read).item.data).toMatchObject({ + title: "No layout", + layout: [], + }); + }); + + it("preserves stored blocks when an update changes only another field", async () => { + const result = await harness.client.callTool({ + name: "content_create", + arguments: { + collection: "pages", + data: { title: "Before", layout: [{ _type: "hero", heading: "Keep me" }] }, + }, + }); + expect(result.isError, extractText(result)).toBeFalsy(); + const created = extractJson(result); + const updated = await harness.client.callTool({ + name: "content_update", + arguments: { + collection: "pages", + id: created.item.id, + data: { title: "After" }, + _rev: await currentRev(harness.client, "pages", created.item.id), + }, + }); + expect(updated.isError, extractText(updated)).toBeFalsy(); + const read = await harness.client.callTool({ + name: "content_get", + arguments: { collection: "pages", id: created.item.id }, + }); + expect(read.isError, extractText(read)).toBeFalsy(); + expect(extractJson(read).item.data).toEqual({ + title: "After", + layout: created.item.data.layout, + }); + }); + + it.each([null, "not an array", {}])( + "rejects a supplied non-array blocks value: %j", + async (layout) => { + const result = await harness.client.callTool({ + name: "content_create", + arguments: { collection: "pages", data: { layout } }, + }); + expect(result.isError).toBe(true); + expect(extractText(result)).toContain("[VALIDATION_ERROR] layout: must be an array"); + }, + ); +}); diff --git a/packages/core/tests/unit/client/portable-text.test.ts b/packages/core/tests/unit/client/portable-text.test.ts index 5c1288ae72..938aab2584 100644 --- a/packages/core/tests/unit/client/portable-text.test.ts +++ b/packages/core/tests/unit/client/portable-text.test.ts @@ -599,6 +599,10 @@ describe("blocks Portable Text conversion", () => { }, ]; + it("does not add an omitted blocks field to a partial write", () => { + expect(convertDataForWrite({ title: "Updated" }, fields)).toStrictEqual({ title: "Updated" }); + }); + it("uses the active version for a new keyless block write", () => { const result = convertDataForWrite( { layout: [{ _type: "hero", introduction: "Hello **world**" }] }, From 019859df33a0bc003c198875ca013a27358979e9 Mon Sep 17 00:00:00 2001 From: khoinguyenpham04 <137921741+khoinguyenpham04@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:18:11 +0100 Subject: [PATCH 2/3] fix(client): preserve omitted blocks fields on read --- .changeset/fix-omitted-blocks-mcp.md | 2 ++ packages/core/src/client/portable-text.ts | 12 +++++++----- .../core/tests/unit/client/portable-text.test.ts | 4 ++++ 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/.changeset/fix-omitted-blocks-mcp.md b/.changeset/fix-omitted-blocks-mcp.md index c0a87c6a9a..35ae16fafc 100644 --- a/.changeset/fix-omitted-blocks-mcp.md +++ b/.changeset/fix-omitted-blocks-mcp.md @@ -3,3 +3,5 @@ --- Fixes MCP `content_create` and `content_update` rejecting writes that omit a blocks field with "must be an array". Omitted blocks fields now use an empty array on create and retain their existing value on update; supplied non-array values are still rejected. + +JS client reads also preserve omitted blocks fields instead of adding them with an `undefined` value. diff --git a/packages/core/src/client/portable-text.ts b/packages/core/src/client/portable-text.ts index 2c4a6ceede..784ae396ff 100644 --- a/packages/core/src/client/portable-text.ts +++ b/packages/core/src/client/portable-text.ts @@ -413,8 +413,9 @@ function convertNestedBlockPortableText( } /** - * Convert content data for reading: PT fields -> markdown strings. - * Only converts fields with type "portableText" that contain arrays. + * Convert content data for reading: Portable Text arrays -> Markdown strings. + * Converts top-level `portableText` arrays and nested `portableText` arrays + * inside `blocks` arrays. */ export function convertDataForRead( data: Record, @@ -427,7 +428,7 @@ export function convertDataForRead( for (const field of fields) { if (field.type === "portableText" && Array.isArray(result[field.slug])) { result[field.slug] = portableTextToMarkdown(result[field.slug] as PortableTextBlock[]); - } else if (field.type === "blocks") { + } else if (field.type === "blocks" && Array.isArray(result[field.slug])) { result[field.slug] = convertNestedBlockPortableText(result[field.slug], field, "read"); } } @@ -435,8 +436,9 @@ export function convertDataForRead( } /** - * Convert content data for writing: markdown strings -> PT arrays. - * Only converts fields with type "portableText" that contain strings. + * Convert content data for writing: Markdown strings -> Portable Text arrays. + * Converts top-level `portableText` strings and nested Markdown strings + * in `portableText` fields inside `blocks` arrays. */ export function convertDataForWrite( data: Record, diff --git a/packages/core/tests/unit/client/portable-text.test.ts b/packages/core/tests/unit/client/portable-text.test.ts index 938aab2584..369e9b0733 100644 --- a/packages/core/tests/unit/client/portable-text.test.ts +++ b/packages/core/tests/unit/client/portable-text.test.ts @@ -599,6 +599,10 @@ describe("blocks Portable Text conversion", () => { }, ]; + it("does not add an omitted blocks field on read", () => { + expect(convertDataForRead({ title: "Example" }, fields)).toStrictEqual({ title: "Example" }); + }); + it("does not add an omitted blocks field to a partial write", () => { expect(convertDataForWrite({ title: "Updated" }, fields)).toStrictEqual({ title: "Updated" }); }); From f2c3b81c47672bf04da3733d67b4babb10c8eaf2 Mon Sep 17 00:00:00 2001 From: khoinguyenpham04 <137921741+khoinguyenpham04@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:24:15 +0100 Subject: [PATCH 3/3] docs(changeset): describe omitted blocks fields on create accurately An omitted field on create is treated as empty, so minItems still applies. Drop the read-side note: an undefined-valued key never reaches JSON consumers. --- .changeset/fix-omitted-blocks-mcp.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.changeset/fix-omitted-blocks-mcp.md b/.changeset/fix-omitted-blocks-mcp.md index 35ae16fafc..989b84510c 100644 --- a/.changeset/fix-omitted-blocks-mcp.md +++ b/.changeset/fix-omitted-blocks-mcp.md @@ -2,6 +2,4 @@ "emdash": patch --- -Fixes MCP `content_create` and `content_update` rejecting writes that omit a blocks field with "must be an array". Omitted blocks fields now use an empty array on create and retain their existing value on update; supplied non-array values are still rejected. - -JS client reads also preserve omitted blocks fields instead of adding them with an `undefined` value. +Fixes MCP `content_create` and `content_update` rejecting writes that omit a blocks field with "must be an array". A create that omits a blocks field treats it as empty, and an update keeps the stored blocks. Supplied non-array values are still rejected.