Hello.

article

Moving markdown-style Storyblok content to richtext through the MCP

2026-11-06 · Storyblok, Astro and Cloudflare Workers

TL;DR Convert offline, push drafts with publish: false, diff each draft mechanically against the converter output, then publish in a separate step. Turn off markdown-it's typographer, and do not fall back to first_published_at for article dates.

This is part 3 of 4. Part 1 is the overview and part 2 the Astro SSR setup. The content was already in Storyblok, but not as richtext, and this part is about moving it, with the Storyblok MCP doing the pushes.

What you need

Credentials stay out of the MCP session. I never pasted a token into a prompt, and the scripts read theirs from the environment and print counts and statuses only.

What had to move

The old site was a Nuxt 2 static export from June 2023, with URLs of the form /a/<slug>/. The space held 16 articles and 4 pages (home, about, license, legal notice). Two groups of articles are series: Protect Secrets has 3 parts and GitHub Actions has 7.

The old Article stored its text in a body field made of nested bloks: ArticleText held markdown, ArticleCode a code string with a type, ArticleImage an image with a caption, and ArticleQuote a quote with a link. The new Article has one richtext field, content, that allows only Figure, Callout, CodeTabs, Steps, Terminal, EdgeProbe and RequestFlow. Richtext keeps the whole article in one editable document, and the blok whitelist is what makes the interactive parts of this series possible.

Converting markdown to richtext

The converter ran offline in the container, from a backup of each story's original JSON. Storyblok publishes @storyblok/richtext with markdownToStoryblokRichtext and htmlToStoryblokRichtext, so I did not write a markdown parser. One option inside it had to go.

const md = new MarkdownIt({ html: false, linkify: true, typographer: false, breaks: true });
const markdownToStoryblokRichtext = (src) => htmlToStoryblokRichtext(md.render(src));

As far as I know, the package's own markdown parser needs a DOM, which Workers lack, so the site's legacy fallback path uses marked instead.

The other mappings:

The check compares text, not layout: extract plain text from the old side (with marked) and from the new richtext, strip all whitespace, compare. 20 of 20 passed. That proves text and order, not nesting or heading levels, so look at the pages too. In caching-in-ga the site's heading normalization (smallest heading becomes h2, jumps clamped to one level) turned sibling sections into parent and child, because the article opens with a deeper heading than it later uses.

Pushing through the MCP

The MCP has no file input, so the converted stories (about 277 KB together) had to be typed into the calls, and the model retypes every payload. Moving the 20 stories produced three typing slips in two of them: a duplicated, truncated first paragraph in license, a starred_url that lost {/repo}, and a url that came out as api.github.com/repos/… instead of github.com/…. The defence is a fixed order:

Step 1 of 5

Back up the originals

Save every story as JSON before any write. Use a field subset, or strip preview_token from full responses.

The mechanical diff caught two of the three typos; the duplicated paragraph in license was caught during the push, before the diff ran. Only versions with zero differences were published.

What else the MCP taught me

The date trap

Storyblok gives a story three date candidates: my own date field, created_at and first_published_at. The first version of the new site fell back to first_published_at when date was missing. That was wrong for 7 of the 16 articles, and production showed those dates for a while after the cutover.

It showed up as a preview and a production render of the same article one day apart, which looked like a timezone bug. Timezones do matter a little: in this space datetime fields come back in UTC, and the site renders the calendar day in Europe/Vienna. Migrated dates are stored as YYYY-MM-DD 00:00 UTC, which is 01:00 or 02:00 in Vienna, the same day.

URL compatibility

The old URLs were /a/<slug>/ with a trailing slash, and /a/<slug> answered with a 301 to the slash form. The new site keeps both, with a middleware rather than Astro's trailingSlash: 'always' (part 2 explains why). On the first deployment, /a/adb-tricks/ returned 200 and /a/adb-tricks returned 301.

Two things changed on purpose. Series have their own pages, /s/<series>/, and series parts are listed under the series, not individually on the index. I did not check external links to old pages beyond the site's own 24 URLs.

Cleaning up the schema

With the content moved, the old body field and its components could go. Deleting components is destructive, so it ran in two stages, the second only after an explicit go from me. First, without touching the schema:

Then the schema change:

A read-only listing confirms the result:

listManagementComponents after the cleanup
⏺ execute_readonly operation="listManagementComponents" parameters={"space_id":93903} fields=["components.name"] region="eu"
{"components":[{"name":"Article"},{"name":"Callout"},{"name":"CodeTab"},{"name":"CodeTabs"},{"name":"EdgeProbe"},{"name":"Figure"},{"name":"FlowNode"},{"name":"page"},{"name":"Page"},{"name":"RequestFlow"},{"name":"Series"},{"name":"Step"},{"name":"Steps"},{"name":"Terminal"},{"name":"TermLine"}]}

Eight components are gone compared with before the cleanup: ArticleCode, ArticleImage, ArticleQuote, ArticleReferences, ArticleText, intro, Newsletter and TextSection.

What I would do again

The order: backup, drafts, diff, publish, diff again, then delete. Each failure here was caught by comparing against something independent: the drafts against the converter output, the text against a second parser, the dates against the old site. Part 4 puts the finished site behind an edge cache.