,

How I Added AI-Powered Content Creation to My WordPress Blog with MCP

I just added a pretty cool feature to my blog: the ability to create posts by chatting with Claude AI. Here’s how I did it, explained in plain Englishβ€”including all the mistakes I made along the way.

πŸ“¦ Full source code available: github.com/laxmariappan/claude2blog

What is MCP?

MCP (Model Context Protocol) is a way for AI assistants like Claude to interact with applications. Think of it as a bridge that lets Claude actually do things instead of just suggesting them.

Instead of Claude telling me “here’s the content, now copy-paste it to WordPress,” Claude can now publish directly to my blog.

What You Need

  • WordPress 6.9 or newer – This includes the new Abilities API
  • MCP Adapter plugin – Available from the WordPress plugin directory
  • Node.js – To run the MCP bridge locally
  • Basic PHP knowledge – Just enough to create a simple WordPress plugin

The Big Picture

WordPress 6.9 introduced something called the “Abilities API.” It lets you register actions that your site can perform, like “create a post” or “get site info.”

The MCP Adapter plugin automatically discovers and exposes registered WordPress abilities to AI assistants through the MCP protocol.

The Journey: What Actually Happened

Attempt 1: Following the Docs (Sort Of)

I started by registering abilities using wp_register_ability(). My initial code looked like this:

add_action('abilities_init', 'claude_register_publish_ability');

function claude_register_publish_ability() {
    wp_register_ability('claude/publish-post', [
        'label' => 'Publish Blog Post',
        'description' => 'Publish a blog post',
        'callback' => 'claude_publish_post_handler',
        'capability' => 'publish_posts',
        'input_schema' => [
            'title' => ['type' => 'string', 'required' => true],
            'content' => ['type' => 'string', 'required' => true],
            // ...
        ],
    ]);
}
Code language: PHP (php)

Result: Abilities weren’t showing up in MCP discovery. Zero. Nada. Nothing.

Problem 1: Wrong Hook Name

The correct hook is wp_abilities_api_init, not abilities_init. Easy mistake to make when you’re working with new APIs.

// WRONG
add_action('abilities_init', 'claude_register_publish_ability');

// CORRECT
add_action('wp_abilities_api_init', 'claude_register_publish_ability');
Code language: JavaScript (javascript)

Problem 2: Wrong Namespace

I initially used claude/publish-post as the ability name. Bad ideaβ€””Claude” is Anthropic’s trademark. Changed all abilities to use claude2blog/ namespace instead:

// BEFORE: claude/publish-post
// AFTER: claude2blog/publish-post
Code language: JSON / JSON with Comments (json)

Problem 3: Wrong Parameter Names

The official Abilities API documentation clearly states you need execute_callback and permission_callback, not just callback:

// WRONG
wp_register_ability('claude2blog/publish-post', [
    'callback' => 'claude_publish_post_handler',
    'capability' => 'publish_posts',
]);

// CORRECT
wp_register_ability('claude2blog/publish-post', [
    'execute_callback' => 'claude_publish_post_handler',
    'permission_callback' => 'claude_publish_post_permission',
]);
Code language: PHP (php)

The permission callback is a separate function that checks capabilities:

function claude_publish_post_permission($params) {
    return current_user_can('publish_posts');
}
Code language: PHP (php)

Problem 4: The Smoking Gun – Missing Category Parameter

This was the hardest bug to find. Even with correct hook names and parameter names, abilities still weren’t discoverable. They would register, but wp_register_ability() silently returned null instead of a WP_Ability object.

After creating a minimal test plugin with extensive debug logging, I discovered the root cause:

error_log('[TEST-ABILITY] Registration returned NULL!');
error_log('[TEST-ABILITY] Available categories: site, user, mcp-adapter');
Code language: JavaScript (javascript)

I was missing the required category parameter! Looking at the WordPress core source (wp-includes/abilities-api/class-wp-ability.php lines 274-278), I found:

if ( empty( $args['category'] ) || ! is_string( $args['category'] ) ) {
    throw new InvalidArgumentException(
        __( 'The ability properties must contain a `category` string.' )
    )
}
Code language: PHP (php)

The category parameter is REQUIRED and must reference a registered category. Available categories are:

  • site – Site-level operations
  • user – User-level operations
  • mcp-adapter – MCP-specific abilities

Adding this one parameter fixed everything:

wp_register_ability('claude2blog/publish-post', [
    'label' => __('Publish Blog Post', 'claude-publisher'),
    'description' => __('Publish a blog post with title, content, categories, tags, and more', 'claude-publisher'),
    'category' => 'mcp-adapter',  // THIS WAS THE MISSING PIECE!
    'input_schema' => [/* ... */],
    'execute_callback' => 'claude_publish_post_handler',
    'permission_callback' => 'claude_publish_post_permission',
    'meta' => [
        'mcp' => ['public' => true],
        'show_in_rest' => true,
    ],
]);
Code language: PHP (php)

Problem 5: MCP Metadata Requirements

For abilities to be discoverable via MCP, they need specific metadata. I discovered this by reading the MCP Adapter source code (wp-content/plugins/mcp-adapter/includes/Abilities/McpAbilityHelperTrait.php):

$is_public_mcp = $meta['mcp']['public'] ?? false;

if ( ! ( $meta['mcp']['public'] ?? false ) ) {
    continue;  // Skip if not publicly exposed
}
Code language: PHP (php)

So abilities need 'mcp' => ['public' => true] in their meta array to be exposed via MCP. They also need 'show_in_rest' => true to appear in the REST API.

The Working Code

After all the debugging, here’s what actually works:

add_action('wp_abilities_api_init', 'claude_register_publish_ability');

function claude_register_publish_ability() {
    // Check if Abilities API is available
    if (!function_exists('wp_register_ability')) {
        return;
    }

    // Register the publish post ability
    wp_register_ability('claude2blog/publish-post', [
        'label' => __('Publish Blog Post', 'claude-publisher'),
        'description' => __('Publish a blog post with title, content, categories, tags, and more', 'claude-publisher'),
        'category' => 'mcp-adapter',
        'input_schema' => [
            'title' => [
                'type' => 'string',
                'required' => true,
                'description' => 'Post title',
            ],
            'content' => [
                'type' => 'string',
                'required' => true,
                'description' => 'Post content (HTML allowed)',
            ],
            'status' => [
                'type' => 'string',
                'enum' => ['draft', 'publish', 'pending', 'private'],
                'default' => 'draft',
                'description' => 'Post status',
            ],
            // ... more fields
        ],
        'execute_callback' => 'claude_publish_post_handler',
        'permission_callback' => 'claude_publish_post_permission',
        'meta' => [
            'mcp' => ['public' => true],
            'show_in_rest' => true,
        ],
    ]);
}

function claude_publish_post_permission($params) {
    return current_user_can('publish_posts');
}

function claude_publish_post_handler($params) {
    // Validate required parameters
    if (empty($params['title']) || empty($params['content'])) {
        return new WP_Error(
            'missing_required_fields',
            __('Title and content are required', 'claude-publisher')
        );
    }
    
    // Prepare post data
    $post_data = [
        'post_title' => sanitize_text_field($params['title']),
        'post_content' => wp_kses_post($params['content']),
        'post_status' => isset($params['status']) ? sanitize_text_field($params['status']) : 'draft',
        'post_author' => get_current_user_id(),
    ];
    
    // Handle categories
    if (!empty($params['categories']) && is_array($params['categories'])) {
        $post_data['post_category'] = array_map('absint', $params['categories']);
    }
    
    // Insert the post
    $post_id = wp_insert_post($post_data, true);
    
    if (is_wp_error($post_id)) {
        return $post_id;
    }
    
    // Handle tags
    if (!empty($params['tags']) && is_array($params['tags'])) {
        wp_set_post_tags($post_id, array_map('absint', $params['tags']));
    }
    
    return [
        'success' => true,
        'post_id' => $post_id,
        'post_url' => get_permalink($post_id),
        'edit_url' => get_edit_post_link($post_id, 'raw'),
        'message' => sprintf(
            __('Post "%s" published successfully', 'claude-publisher'),
            get_the_title($post_id)
        ),
    ];
}
Code language: PHP (php)

The MCP Bridge (The Other Tricky Part)

WordPress MCP Adapter requires session management to work properly. This was another debugging adventure.

The Session Challenge

WordPress MCP Adapter uses a custom session management system (not part of the standard MCP protocol). Here’s how it works:

  1. When Claude first connects, it sends an initialize request
  2. WordPress creates a session and returns a Mcp-Session-Id in the response header
  3. The bridge must capture this session ID and reuse it for ALL subsequent requests
  4. Sessions expire after 24 hours of inactivity

If you generate a new session ID for every request (which I initially did), you’ll get “Invalid or expired session” errors.

The Solution: Session Persistence

Here’s the key code in the Node.js bridge that makes it work:

// Session management
let currentSessionId = null;

function makeRequest(method, body, timeout = 5000) {
  return new Promise((resolve, reject) => {
    const headers = {
      'Content-Type': 'application/json',
      'Authorization': authHeader
    };

    // Only add Mcp-Session-Id if we have one (not needed for initialize)
    if (currentSessionId) {
      headers['Mcp-Session-Id'] = currentSessionId;
    }

    const req = httpModule.request(options, (res) => {
      // Capture session ID from response headers
      const responseSessionId = res.headers['mcp-session-id'];
      if (responseSessionId) {
        currentSessionId = responseSessionId;  // Store it!
      }
      // ... handle response ...
    });
  });
}
Code language: JavaScript (javascript)

How It Actually Works

The complete flow looks like this:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Claude Code   β”‚
β”‚ Claude Desktop β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
         β”‚ STDIO (JSON-RPC 2.0, MCP 2025-06-18)
         β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  MCP Bridge    β”‚  ← Session ID persistence
β”‚  (Node.js)     β”‚  ← Protocol translation
β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
         β”‚ HTTP + Auth + Mcp-Session-Id header
         β”‚ (MCP 2024-11-05)
         β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  WordPress MCP     β”‚
β”‚  Endpoint          β”‚  ← Session validation
β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         β”‚ Abilities API
         β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Your Plugin       β”‚
β”‚  wp_register_      β”‚
β”‚  ability()         β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         β”‚ WordPress Core
         β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  wp_insert_post()  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Key Lessons Learned

  1. Read the error logs carefully – The “Registration returned NULL” message was the key clue
  2. The category parameter is REQUIRED – Not mentioned prominently in quick-start guides but enforced in core
  3. MCP metadata matters – You need 'mcp' => ['public' => true] for MCP exposure
  4. Session management is crucial – You must capture and reuse the session ID
  5. Use correct parameter namesexecute_callback and permission_callback, not just callback
  6. Hook names matter – It’s wp_abilities_api_init, not abilities_init
  7. Namespaces are important – Don’t use trademarked names like “claude”

Full Code Available

I’ve open-sourced the complete working implementation:

Repository: github.com/laxmariappan/claude2blog

Includes:

  • WordPress plugin with correct ability registration (v1.7.0)
  • Node.js MCP bridge with session persistence
  • Complete setup guide
  • Example configuration files
  • Troubleshooting documentation based on real debugging experience

Try It Yourself

Check out the repository for step-by-step setup instructions. The debugging took a couple of days (mostly figuring out the missing category parameter and session management), but now it works flawlessly.

Being able to just chat with Claude and have posts appear on my blog feels like magic. No more copy-pasting. No more context switching. Just conversation.

This post itself was updated via MCP using the claude2blog/update-post ability!

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *