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-mcp-portabletext-markdown.md
Original file line number Diff line number Diff line change
@@ -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.
45 changes: 38 additions & 7 deletions packages/core/src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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<string, unknown>,
): Promise<Record<string, unknown>> {
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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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')"),
Expand Down Expand Up @@ -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) {
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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);

Comment on lines 727 to 732
// Status transitions route through dedicated handlers for proper revision management
if (args.status === "published") {
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown> };
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<Database>;
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<ItemEnvelope>(created).item.id;
const got = await harness.client.callTool({
name: "content_get",
arguments: { collection: "post", id },
});
const content = getContent(extractJson<ItemEnvelope>(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<ItemEnvelope>(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<ItemEnvelope>(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<ItemEnvelope>(created).item.id;
const got = await harness.client.callTool({
name: "content_get",
arguments: { collection: "post", id },
});
const content = getContent(extractJson<ItemEnvelope>(got)) as Array<{ _key: string }>;
expect(content[0]?._key).toBe("abc123");
});
});
Loading