fix(core): accept Markdown for portableText fields in MCP content tools#1563
Conversation
Agents emit the content_create arguments as one inline JSON blob. A rich
text field authored as a deeply nested Portable Text array makes that blob
large and easy to truncate or mis-escape, so the whole tool call fails to
parse before any handler runs ("JSON parsing failed").
content_create and content_update now convert a Markdown string to Portable
Text for portableText fields before calling the handlers, mirroring
EmDashClient. A Portable Text array is still accepted unchanged. content_get
and content_list gain an optional `markdown` flag (default false) that
returns rich text as Markdown instead of Portable Text arrays.
🦋 Changeset detectedLatest commit: 523bc08 The changes in this PR will be included in the next version bump. This PR includes changesets to release 16 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
docs | 523bc08 | Jun 21 2026, 07:02 AM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-playground | 523bc08 | Jun 21 2026, 07:03 AM |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-demo-cache | 523bc08 | Jun 21 2026, 07:03 AM |
There was a problem hiding this comment.
Approach
This is the right change, solving the right problem, done in a way that fits EmDash. Agents emit MCP content_create/content_update arguments as one inline JSON blob, and a deeply nested Portable Text array for a portableText field is exactly the shape models truncate/mis-escape — so the tool call fails to parse before any server code runs. Letting agents pass a Markdown scalar instead, and read it back as Markdown, collapses the failure-prone payload. Crucially, the PR doesn't reinvent conversion: it reuses convertDataForRead/convertDataForWrite from client/portable-text.ts, the same code path the EmDash JS client and CLI already use. The read-side markdown flag defaults to false, so the response shape is unchanged for existing consumers, and PT JSON arrays still pass through on writes (convertDataForWrite only touches string values on portableText fields). Backwards-compatible, additive, idiomatic.
(The author marks this a bug fix and skips the "approved Discussion" item. It's borderline feature/fix, but the change is additive, opt-in on reads, reuses existing infrastructure, and addresses a genuine usability failure of the existing tools — I'd accept the framing.)
What I checked
Traced every changed hunk in packages/core/src/mcp/server.ts and the conversion functions in client/portable-text.ts:
getCollectionFieldsreturnsnullon any failure →convertWriteData/applyReadMarkdownfall back to pass-through, documented as best-effort. On a write where the schema can't load, the Markdown string reaches the handler and surfaces as a field validation error — acceptable degradation, and aligns with the collection-not-found / DB-error cases where the handler errors anyway.- No aliasing/mutation hazard:
convertDataForWrite/convertDataForReadshallow-copy before reassigning field values, soargs.dataisn't mutated;applyReadMarkdownreassignsitem.dataon fresh handler results (handleContentGet/handleContentListbuild a newContentRepositoryper call and return fresh objects — no request cache to poison). content_get: the Markdown conversion runs after the subscriber not-found gate, so draft content is never leaked as Markdown to users withoutcontent:read_drafts.content_listforcesstatus: "published"for subscribers before the conversion runs.content_listnarrowspayload(object+"items" in+Array.isArray) before mutating;nextCursor/totalare left untouched.- Both
content_createbranches (published + default) and all threecontent_updatebranches (published/draft/default) use the converteddatavariable; theargs.data ||gates still key off the original input, which is correct. - No new SQL against content tables —
getCollectionWithFieldshits_emdash_collections/_emdash_fields(system tables), so nolocale-filter concern; the registry's queries are parameterized, no injection surface. - Authorization unchanged:
requireScope/requireRole/requireOwnershipall intact; themarkdownflag adds a schema read but exposes no data the caller couldn't already see (they already receive the field values). - Types:
FieldSchemais importedimport type(satisfiesverbatimModuleSyntax); theas Record<string, unknown>casts are guarded bytypeof === "object"+ truthiness checks.
Tests
markdown-portable-text.test.ts runs five real integration tests through the in-memory MCP harness against a migrated SQLite DB with a post collection (title: string, content: portableText) — no mocks. Covers: create Markdown→PT (asserts block structure + strong mark), create PT-array passthrough, get default-PT vs markdown:true round-trip, list with markdown:true, and update Markdown→PT. This satisfies the "reproducing test" requirement.
Changeset
.changeset/mcp-markdown-portable-text.md — emdash patch, accurate, present, describes the observable effect rather than internals.
Conclusion
Implementation is clean. No needs_fixing issues found. One non-blocking observation: the markdown read flag is added to content_get/content_list but not to content_compare or content_list_trashed, which also return portableText field data — an agent reading Markdown might want it there too, though returning raw PT from content_compare is arguably intentional for precise diffing. Worth a maintainer's glance, not a blocker. LGTM.
Shorten the convertWriteData/applyReadMarkdown doc comments to state what they do, and steer content_create/content_update callers to use Portable Text for complex content that Markdown can't express.
What does this PR do?
Agents drive the MCP
content_createtool by emitting the whole arguments object as one inline JSON blob. When a rich text (portableText) field is authored as a deeply nested Portable Text array, that blob gets large and the model frequently truncates or mis-escapes it — so the entire tool call fails to parse (Invalid input for tool content_create: JSON parsing failed: Text: {...}) before any server code runs. It's not a serialization bug in EmDash; it's that we were forcing the model down the one path it's worst at (hand-emitting a big nested JSON tree).This wires the same Markdown↔Portable Text conversion the EmDash client already uses into the MCP tools:
content_create/content_updatenow accept a Markdown string forportableTextfields and convert it to Portable Text before calling the handlers. A Portable Text JSON array is still accepted unchanged (conversion only touches string values onportableTextfields). Collapsing the nested array into one flat scalar is trivial for a model to emit correctly.content_get/content_listgain an optionalmarkdownflag (defaultfalse, so the response shape is unchanged for existing consumers) that returns rich text as Markdown instead of Portable Text arrays.Conversion is best-effort: if the collection schema can't be loaded, data passes through and the handler validates it as before.
Closes #
Type of change
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change)pnpm formathas been runAI-generated code disclosure
Screenshots / test output
(full
packages/coreMCP integration + unit suites, including the newmarkdown-portable-text.test.ts)