Migrating WordPress media via MCP

I’ve done a lot of WordPress site migrations over the years. Different agencies, different scales but none of them have been quick or painless.

For the content side we usually reach for WP All Import at Filter. It does a proper job of mapping fields, handling ACF, and shifting posts in bulk. It’s a great tool and we’re happy to recommend it.

The media side has always been less fun. WP-CLI scripts, search-and-replace plugins, a depressing amount of poking around in the database. And even once the images have made it onto the new site, the references inside the posts are still pointing to the old one. Block attributes pointing at attachment IDs that don’t exist on the new site. wp-image-273 classes baked into the markup pointing at nothing. shortcodes carrying ID lists that won’t resolve etcetc

I wondered if we could do it using MCP and the Abilities API, running it from Claude. So I’ve spent the last few evenings adding a migration workflow to our Filter-Abilities plugin.

It exposes WordPress functionality as Abilities API abilities, so an MCP-connected agent (Claude, in my case) can call them. The first releases covered content, taxonomies, SEO, redirects, forms, and bits of our own products at Filter (PersonalizeWP and Filter AI).

My recent changes are on the media side: getting attachments onto the new site, and rewriting the references in the migrated posts to point at the new IDs. With the existing content abilities alongside, that’s enough for Claude to run a full migration.

Using Abilities

There are three new abilities, and they work together.

filter/list-media, called on the old site, now gives you each attachment back with its caption, description, post_parent, and (the important bit) a size_urls map of every registered intermediate size. That size_urls map is what makes the rest of the workflow work. Those URLs are the keys you need to find and replace later. thumbnail, medium, large, and any custom sizes the theme registered all live in there.

filter/upload-media takes a list of URLs and sideloads the files onto the new site, up to fifty at a time. The images never go through MCP. The new site’s PHP fetches each file directly from the old site’s public URL over HTTP, which means that only URLs travel through the agent. It’s SSRF-guarded against loopback and private-network addresses, holds onto the metadata, and echoes an original_id field on every response so you can build the old-to-new ID map as you go. The new site’s own image-size settings are then honoured automatically, which matters because the source and destination almost never share the same ones.

filter/rewrite-content is the one I’m happiest with. Once the media is on the new site, it walks through each migrated post and rewrites every media reference:

  • Gutenberg block attributes for the seven core media blocks (core/image, core/gallery, core/cover, core/media-text, core/video, core/audio, core/file)
  • wp-image-{ID} classes
  • Raw image URLs with intermediate sizes included
  • shortcodes
  • Featured-image postmeta
  • ACF image, gallery, and file fields

How to use it

You can actually use a really short prompt such as:

Migrate everything from oldsite.example to newsite.example.

But equally, you might want to add more context to ensure it knows what abilities to use. When it runs, it does it in three phases.

First, it pulls the media list from the old site and sideloads everything onto the new one, building the ID map as it goes. Then it pulls the posts from the old site and recreates them on the new one, still containing references that point at the old site. Finally it runs filter/rewrite-content with the ID map and fixes those references in place.

It also has a dry-run pause so before anything actually changes, Claude shows me something like:

post_id: 88, "Welcome to our new site"
  block_attrs: 4
  image_classes: 6
  urls: 11
  gallery_shortcode: 1
  thumbnail: 1
  acf_fields: 2
  applied: false

If the counts look about right, I approve. If they’re all zero on a post I was expecting to be touched, something didn’t match (usually a URL-formatting mismatch), and I fix the inputs before applying.

For a small site (around 200 attachments and 100 posts), end-to-end is about five to fifteen minutes work. The slow part is the upload phase, because each attachment is downloaded, written, and has its sizes regenerated by media_handle_sideload.

Still to do

It’s obviously not perfect:

  • Post-to-post ID references (mostly ACF post_object and relationship fields) aren’t translated automatically. Claude can do them by hand before each create-post call, but it’s not built into rewrite-content yet.
  • Author mapping is manual. New posts default to whichever user the MCP adapter is authenticated as.
  • The migration state isn’t stored on the server, so the ID map only lives as long as the conversation does. The workaround is to ask Claude to save the map to a file after every batch, which it’ll do happily if you ask up front. On anything bigger than a small site, I always do.
  • There’s no dedupe on retry. Re-running a half-finished migration will create photo-1.jpg, photo-2.jpg, and so on unless you pre-check the new site. I’ve been doing that with a quick filter/list-media call against the destination before each batch, which works but is a conversational pattern rather than a feature


None of those have actually blocked the test migrations I’ve done with it though and I think it works fine with any of them in place. But I will look at seeing if we can add them.

Does it help?

For me, the hardest part of a migration is keeping all the IDs mapped. An attachment that was ID 47 on the old site is ID 152 on the new one. Multiplied by a thousand images. Then a few hundred posts referring to the old IDs in markup, shortcodes, postmeta, and ACF fields. All of that needs translating against the map and updating.

To be honest, that’s the kind of work that i want to hand off to an LLM and not have to worry about. A job that used to take an age now runs in fifteen minutes which is a massive step forward.

It’s on GitHub at filter-agency/filter-abilities if you want a look. You’ll need WordPress 6.9+ and the MCP Adapter. The full workflow, the extension hooks for custom blocks, and a worked example for a 500-image migration are all in docs/MIGRATION.md. If it’s useful to you, take a look.