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 operationsuser– User-level operationsmcp-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:
- When Claude first connects, it sends an
initializerequest - WordPress creates a session and returns a
Mcp-Session-Idin the response header - The bridge must capture this session ID and reuse it for ALL subsequent requests
- 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
- Read the error logs carefully – The “Registration returned NULL” message was the key clue
- The
categoryparameter is REQUIRED – Not mentioned prominently in quick-start guides but enforced in core - MCP metadata matters – You need
'mcp' => ['public' => true]for MCP exposure - Session management is crucial – You must capture and reuse the session ID
- Use correct parameter names –
execute_callbackandpermission_callback, not justcallback - Hook names matter – It’s
wp_abilities_api_init, notabilities_init - 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!

Leave a Reply