Skip to content
Open
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/fix-omitted-blocks-mcp.md
Original file line number Diff line number Diff line change
@@ -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". 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.
14 changes: 8 additions & 6 deletions packages/core/src/client/portable-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>,
Expand All @@ -427,16 +428,17 @@ 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");
}
}
return result;
}

/**
* 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<string, unknown>,
Expand All @@ -446,7 +448,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");
}
}
Expand Down
119 changes: 119 additions & 0 deletions packages/core/tests/integration/mcp/blocks-content.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> };
}

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<ContentEnvelope>(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<ContentEnvelope>(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<ContentEnvelope>(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<ContentEnvelope>(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");
},
);
});
8 changes: 8 additions & 0 deletions packages/core/tests/unit/client/portable-text.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,14 @@ 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" });
});

it("uses the active version for a new keyless block write", () => {
const result = convertDataForWrite(
{ layout: [{ _type: "hero", introduction: "Hello **world**" }] },
Expand Down
Loading