6 min

MCP

Connecting Claude to WordPress: the MCP Adapter Plugin and a mu-plugin

For Claude to read and edit a WordPress site, you need three pieces: the MCP Adapter plugin, which exposes WordPress “abilities” as MCP tools, a dedicated account with an application password, and a mu-plugin that declares what Claude is allowed to do. The mu-plugin is the piece that matters, because it sets the limits.

The safeguards (ID list, backup, dry run) are explained in an earlier note. This one shows how to wire everything up.

1. The MCP Adapter plugin

The official MCP Adapter plugin builds on WordPress’s Abilities API (WordPress 6.9 or later). Once installed, it publishes an MCP server at /wp-json/mcp/mcp-adapter-default-server. The abilities declared by the site become tools for the agent.

2. A dedicated account

Create a user reserved for the agent (the Editor role is enough) and an application password. Never put that password in a configuration file: use the WP_API_USERNAME and WP_API_PASSWORD environment variables, set on your machine.

On the Claude side, the server is declared like this:

claude mcp add wordpress --scope user \
  -e WP_API_URL=https://mysite.com/wp-json/mcp/mcp-adapter-default-server \
  -- npx -y @automattic/mcp-wordpress-remote

3. The mu-plugin

A mu-plugin is a PHP file dropped into wp-content/mu-plugins/ (the folder doesn’t always exist, create it). It loads on its own, without activation, and can’t be deactivated from the admin.

Here is a simplified skeleton: a read ability, and a write ability limited to drafts and an ID list, with a dry run and a backup. Test it on a staging site before production.

<?php
/**
 * Plugin Name: Claude abilities (example)
 * Requires at least: 6.9
 */
defined( 'ABSPATH' ) || exit;

const CLAUDE_USERS        = array( 'claude-mcp' );
const CLAUDE_WRITABLE_IDS = array(); // published IDs opened for writing
const CLAUDE_BACKUP_META  = '_claude_backup';

// Application passwords are only possible for the agent's account.
add_filter( 'wp_is_application_passwords_available_for_user', function ( $ok, $user ) {
    return $ok && in_array( $user->user_login, CLAUDE_USERS, true );
}, 10, 2 );

function claude_allowed_user() {
    $u = wp_get_current_user();
    return $u->exists() && in_array( $u->user_login, CLAUDE_USERS, true ) && current_user_can( 'edit_posts' );
}

function claude_writable_post( $id ) {
    $post = get_post( absint( $id ) );
    if ( ! $post || 'post' !== $post->post_type ) {
        return new WP_Error( 'not_found', 'Content not found.' );
    }
    $listed = in_array( $post->ID, CLAUDE_WRITABLE_IDS, true );
    $draft  = in_array( $post->post_status, array( 'draft', 'pending' ), true );
    if ( ! $listed && ! $draft ) {
        return new WP_Error( 'not_writable', 'Published and not on the allow-list.' );
    }
    return $post;
}

add_action( 'wp_abilities_api_categories_init', function () {
    wp_register_ability_category( 'claude-content', array(
        'label'       => 'Content',
        'description' => 'Read and bounded write access to posts.',
    ) );
} );

add_action( 'wp_abilities_api_init', function () {
    wp_register_ability( 'claude/list-content', array(
        'label'               => 'List posts',
        'description'         => 'ID, title, URL and status of posts. Read only.',
        'category'            => 'claude-content',
        'input_schema'        => array(
            'type'                 => 'object',
            'properties'           => array( 'search' => array( 'type' => 'string' ) ),
            'additionalProperties' => false,
        ),
        'output_schema'       => array( 'type' => 'object' ),
        'permission_callback' => 'claude_allowed_user',
        'execute_callback'    => function ( $input = array() ) {
            $input = (array) $input;
            $q     = new WP_Query( array(
                'post_type'      => 'post',
                'post_status'    => 'any',
                'posts_per_page' => 50,
                's'              => sanitize_text_field( $input['search'] ?? '' ),
            ) );
            $items = array();
            foreach ( $q->posts as $p ) {
                $items[] = array( 'id' => $p->ID, 'title' => $p->post_title, 'url' => get_permalink( $p ), 'status' => $p->post_status );
            }
            return array( 'items' => $items );
        },
        'meta'                => array( 'public' => true, 'annotations' => array( 'readonly' => true, 'destructive' => false, 'idempotent' => true ) ),
    ) );

    wp_register_ability( 'claude/update-content', array(
        'label'               => 'Edit a post',
        'description'         => 'Title and excerpt. Drafts or allowed IDs, automatic backup, dry_run available.',
        'category'            => 'claude-content',
        'input_schema'        => array(
            'type'                 => 'object',
            'properties'           => array(
                'id'      => array( 'type' => 'integer' ),
                'title'   => array( 'type' => 'string' ),
                'excerpt' => array( 'type' => 'string' ),
                'dry_run' => array( 'type' => 'boolean' ),
            ),
            'required'             => array( 'id' ),
            'additionalProperties' => false,
        ),
        'output_schema'       => array( 'type' => 'object' ),
        'permission_callback' => 'claude_allowed_user',
        'execute_callback'    => function ( $input = array() ) {
            $input = (array) $input;
            $post  = claude_writable_post( $input['id'] ?? 0 );
            if ( is_wp_error( $post ) ) {
                return $post;
            }
            $data = array( 'ID' => $post->ID );
            if ( isset( $input['title'] ) ) {
                $data['post_title'] = sanitize_text_field( $input['title'] );
            }
            if ( isset( $input['excerpt'] ) ) {
                $data['post_excerpt'] = sanitize_textarea_field( $input['excerpt'] );
            }
            if ( ! empty( $input['dry_run'] ) ) {
                return array( 'dry_run' => true, 'would_write' => $data );
            }
            $backups   = get_post_meta( $post->ID, CLAUDE_BACKUP_META, true );
            $backups   = is_array( $backups ) ? $backups : array();
            $backup_id = uniqid();
            $backups[] = array( 'id' => $backup_id, 'title' => $post->post_title, 'excerpt' => $post->post_excerpt );
            update_post_meta( $post->ID, CLAUDE_BACKUP_META, wp_slash( array_slice( $backups, -10 ) ) );
            $r = wp_update_post( wp_slash( $data ), true );
            return is_wp_error( $r ) ? $r : array( 'id' => $post->ID, 'backup_id' => $backup_id );
        },
        'meta'                => array( 'public' => true, 'annotations' => array( 'readonly' => false, 'destructive' => false, 'idempotent' => true ) ),
    ) );
} );

This skeleton never touches a post’s status: no publishing or deleting by the agent. To open a published page, add its ID to CLAUDE_WRITABLE_IDS, nothing else.

The pitfalls I ran into

  • The page body isn’t always in post_content. With a theme that stores everything in ACF blocks, the REST API doesn’t expose them. The ability has to read them itself, using ACF’s functions.
  • The MCP session expires. One day the agent could no longer find the abilities (“Ability not found”) even though the mu-plugin was answering. Fallback: the Abilities REST API, with POST /wp-json/wp-abilities/v1/abilities/<name>/run and {"input": …}, and GET ?input[id]=… for read-only abilities.
  • A false error on write. update_field returns false when the value is identical to the existing one, even though the write succeeded. Always read the content back after a write.
  • UTF-8 on Windows. Sending JSON with curl from a Windows terminal breaks accents (rest_invalid_json error). Use a Python client, and the MCP-Protocol-Version header for direct HTTP calls.
  • The Editor role can’t read everything. Yoast scores return a 403 error with that role: read the metadata inside the ability, with an allow-list of keys.
  • A mu-plugin can vanish without notice. Someone removed it from the server and the abilities stopped answering. Dropping the file back is enough, but tell your team it exists.
  • Don’t crawl the site with the agent. Go through the ability rather than bursts of requests: that is the mistake that got me blocked by my own firewall.

Key takeaway

The agent can only do what the mu-plugin declares. Start with reading, add a write limited to drafts, then open published pages one by one with an automatic backup. Read back after every write.

Read next

34The Rules My AI Agent Learned From Its Mistakes1 URLSkills, Not PromptsSitemapImprove Internal Linking With Your Sitemap and Claude