In the previous article we migrated a wp_ajax handler to the WordPress Abilities API. Registering the ability was the easy part. The next question is the one that matters: how do you let an AI client like Claude actually call it – without handing over the keys to your whole site ?
Because that is what most setup guides do. They end with “create an application password for your admin account and paste it into Claude Desktop”. Think about what that means. An MCP client acts as a logged-in WordPress user. With an administrator’s application password, the AI is an administrator. It can install plugins, delete users, publish anything. The AI does not need to be malicious – one misunderstood instruction is enough.
So in this article we do it properly: a dedicated role that can do exactly two things, abilities gated by a purpose-made capability, and a scoped MCP server that exposes only what we choose. At the end, we prove the lock works. As with the last article, everything here was built and tested on WordPress 7.0.2 with MCP Adapter 0.5.0 – all responses are real output.
Table of content
- The master key problem
- Step 1: A role with a purpose-made capability
- Step 2: Abilities that ask for that capability
- Step 3: A scoped MCP server
- Step 4: The user and its key
- Step 5: Connect Claude
- Prove the lock
- Closing thoughts
The master key problem
The MCP Adapter is the official bridge between the Abilities API and AI clients. Install it, and abilities can be exposed as MCP tools. It gives you two routes to do that:
- The default server – abilities marked with
meta.mcp.public => trueshow up there automatically. - A custom server – you list exactly which abilities it exposes. Nothing else exists on it.
We will use a custom server. And there is a second, less obvious thing to lock: the transport itself. By default, the adapter’s endpoint answers to any logged-in user – the check is literally is_user_logged_in(). On a shop or membership site, that means every customer account can knock on your MCP door and list your tools. We will close that too.
Install the adapter from the GitHub release (it is not on wordpress.org):
wp plugin install https://github.com/WordPress/mcp-adapter/releases/latest/download/mcp-adapter.zip --activate
Step 1: A role with a purpose-made capability
In the last article, our ability’s permission callback checked edit_posts. That works, but it is broader than what the AI needs – anyone with edit_posts can also do plenty of other things. The cleaner approach: mint a capability that exists only for this purpose, and give it to a role that has nothing else.
add_action( 'init', 'w4dev_register_ai_agent_role' );
function w4dev_register_ai_agent_role() {
if ( ! get_role( 'ai_agent' ) ) {
add_role(
'ai_agent',
'AI Agent',
array(
'read' => true,
'use_w4dev_ai' => true,
)
);
}
}
use_w4dev_ai is not a WordPress capability – it is ours. No other role has it, and the ai_agent role has nothing else. In a real plugin you would run add_role() on the activation hook instead of init (roles are stored in the database, registering on every load is wasteful, but for a snippet the get_role() check keeps it harmless).
Step 2: Abilities that ask for that capability
We reuse the w4dev/post-stats ability from the previous article with one line changed – the permission callback now checks our dedicated capability:
'permission_callback' => function () {
return current_user_can( 'use_w4dev_ai' );
},
And we add a second ability so the AI can do something useful: create a blog post draft. Note the two guardrails – the post status is hardcoded to draft inside the callback, and the annotations declare it non-destructive. The AI can propose content all day long, it can never publish anything.
add_action( 'wp_abilities_api_init', 'w4dev_register_draft_ability' );
function w4dev_register_draft_ability() {
wp_register_ability(
'w4dev/create-draft',
array(
'label' => 'Create Draft Post',
'description' => 'Creates a new blog post as a draft. The post is never published automatically.',
'category' => 'w4dev',
'input_schema' => array(
'type' => 'object',
'properties' => array(
'title' => array(
'type' => 'string',
'description' => 'Post title.',
),
'content' => array(
'type' => 'string',
'description' => 'Post content, plain text or basic html.',
),
),
'required' => array( 'title' ),
'additionalProperties' => false,
),
'output_schema' => array(
'type' => 'object',
'properties' => array(
'id' => array( 'type' => 'integer' ),
'status' => array( 'type' => 'string' ),
),
),
'execute_callback' => function ( $input ) {
$post_id = wp_insert_post(
array(
'post_title' => sanitize_text_field( $input['title'] ),
'post_content' => wp_kses_post( isset( $input['content'] ) ? $input['content'] : '' ),
'post_status' => 'draft', // always a draft, no exception.
'post_type' => 'post',
),
true
);
if ( is_wp_error( $post_id ) ) {
return $post_id;
}
return array(
'id' => $post_id,
'status' => 'draft',
);
},
'permission_callback' => function () {
return current_user_can( 'use_w4dev_ai' );
},
'meta' => array(
'show_in_rest' => true,
'annotations' => array(
'readonly' => false,
'destructive' => false,
'idempotent' => false,
),
),
)
);
}
Step 3: A scoped MCP server
Now the part no setup guide shows. Instead of marking abilities public for the default server, we create our own server that exposes exactly two abilities – and we replace the transport-level permission check with our capability:
add_action( 'mcp_adapter_init', 'w4dev_create_mcp_server' );
function w4dev_create_mcp_server( $adapter ) {
$adapter->create_server(
'w4dev-ai', // server id
'w4dev-ai', // rest namespace
'mcp', // rest route -> /wp-json/w4dev-ai/mcp
'W4dev AI',
'Scoped MCP server exposing selected w4dev abilities only.',
'v0.1.0',
array( \WP\MCP\Transport\HttpTransport::class ),
\WP\MCP\Infrastructure\ErrorHandling\ErrorLogMcpErrorHandler::class,
null, // observability handler, null handler is used
array( 'w4dev/post-stats', 'w4dev/create-draft' ),
array(), // resources
array(), // prompts
function () { // transport gate. default is only is_user_logged_in() !
return current_user_can( 'use_w4dev_ai' );
}
);
}
Three things worth pointing out:
- The last parameter is the transport permission callback. It is barely documented, but it is the difference between “any logged-in customer can list my AI tools” and “only the AI user can even open the door”.
- Abilities listed here do not need
meta.mcp.public. That flag is only for the default server, which lives at/wp-json/mcp/mcp-adapter-default-serverand only carries the adapter’s discovery tools plus whatever you explicitly mark public. I verified our two abilities do not leak onto it. - Each ability’s own
permission_callbackstill runs on every call. The transport gate is a second lock, not a replacement.
Step 4: The user and its key
Create the user and cut its key – an application password. Two WP-CLI commands:
wp user create ai-agent [email protected] --role=ai_agent
wp user application-password create ai-agent claude --porcelain
The second command prints the application password once – save it, it is what goes into the Claude configuration. A nice side effect of the dedicated user: every draft the AI creates is authored by ai-agent, so your posts list doubles as an audit trail. And when you want to shut the AI out, you revoke one application password or delete one user. Nothing else on the site is touched.
Step 5: Connect Claude
MCP clients talk STDIO locally, so Automattic ships a small proxy, mcp-wordpress-remote, that translates to authenticated HTTP calls. For Claude Desktop, add this to claude_desktop_config.json:
{
"mcpServers": {
"my-wordpress": {
"command": "npx",
"args": [ "-y", "@automattic/mcp-wordpress-remote@latest" ],
"env": {
"WP_API_URL": "https://example.com/wp-json/w4dev-ai/mcp",
"WP_API_USERNAME": "ai-agent",
"WP_API_PASSWORD": "xxxx xxxx xxxx xxxx xxxx xxxx"
}
}
}
}
Notice the url points at our server route, and the credentials belong to the ai-agent user – not to you. Claude Code users can do the same from the terminal with claude mcp add.
Prove the lock
Do not take the setup’s word for it – test it. You can speak the MCP protocol with plain curl. Two details: the endpoint requires the Accept: application/json, text/event-stream header, and after initialize you must echo back the mcp-session-id response header on every following request.
curl -s -D- -X POST "https://example.com/wp-json/w4dev-ai/mcp" \
--user "ai-agent:xxxx xxxx xxxx xxxx xxxx xxxx" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}'
Grab the session id from the response headers, then list the tools:
curl -s -X POST "https://example.com/wp-json/w4dev-ai/mcp" \
--user "ai-agent:xxxx xxxx xxxx xxxx xxxx xxxx" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "Mcp-Session-Id: PASTE-SESSION-ID-HERE" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
The AI user sees exactly two tools – w4dev-post-stats and w4dev-create-draft (the slash in an ability name becomes a dash in the MCP tool name), each carrying the annotations we declared. Now the part that matters, everything it can not do. These are real responses from my test run:
- No credentials at all:
401 rest_forbidden. - A logged-in subscriber tries to initialize:
403 rest_forbidden– this is the transport gate. With the defaultis_user_logged_in()check, this request would have succeeded. - Calling a tool that is not on this server (
core-get-site-info):404. It simply does not exist here. - The ai-agent tries the normal REST API instead – publish a post:
403 rest_cannot_create. Delete a post:403 rest_cannot_delete. The key opens two doors and nothing else.
That last test is the whole point. Even if the AI goes completely off-script and starts firing raw REST requests, it is still just a user with read and one custom capability.
Closing thoughts
None of this is complicated – a role, a capability, two abilities and one create_server call. But it changes the security model completely: instead of trusting the AI with your site and hoping it behaves, you decide up front what it can touch. Add abilities one at a time, as you need them, each with its own guardrails baked into the callback.
This small-tools-with-their-own-permissions pattern is the same one we use to build AI features into our own products, like WP Pro Admin. It scales from a two-tool demo to a full assistant without ever needing that master key.

Leave a Reply