# We Connected a WordPress Site to AI Agents Through MCP. Here’s What We Had to Fix

> Source: <https://pub.towardsai.net/we-connected-a-wordpress-site-to-ai-agents-through-mcp-heres-what-we-had-to-fix-c1bdca1918f9?source=rss----98111c9905da---4>
> Published: 2026-10-08 17:01:04+00:00

More than 40% of all websites run on WordPress, according to W3Techs [1]. Over the past year, those sites gained a standard way to work with AI agents. WordPress 6.9 introduced the Abilities API, WordPress 7.0 added an AI Client to core, and the official MCP Adapter plugin turns registered abilities into Model Context Protocol (MCP) tools that any MCP client can call [2], [3]. We’re AnpsThemes, a WordPress theme studio that has worked with construction, logistics and industrial businesses since 2011, so we wanted to see what this means for sites like the ones our customers run.

*Disclosure: This article and the test plugin in it were written with help from an AI assistant (Claude). All code was run on WordPress 7.1.2 with MCP Adapter 0.6.1, and every error message quoted below is real output from those tests.*

We built a small plugin for a fictional construction company, connected it over MCP and tried to break it. Here’s the code, three things that didn’t behave the way we expected, and what we changed.

We ran WordPress 7.1.2 with MCP Adapter 0.6.1 and created a “project” post type, like the portfolio in a construction theme, with five sample projects. Each project stores its service, town and duration. Then we wrote two abilities:

Instead of testing through a chat app, we spoke MCP directly. We started the server with wp mcp-adapter serve and sent it raw JSON-RPC messages, so we could see exactly what an agent receives.

Every ability belongs to a category, and the category has to be registered first, on its own hook. Here is the category and the first ability, exactly as we ran it:

```
add_action( 'wp_abilities_api_categories_init', function () {    wp_register_ability_category( 'contractor', array(        'label'       => 'Contractor',        'description' => 'Projects and services of a construction company.',    ) );} );add_action( 'wp_abilities_api_init', function () {    wp_register_ability( 'contractor/list-projects', array(        'label'       => 'List projects',        'description' => 'Lists published projects from the company portfolio. Filter by service and town. Use it to answer questions about past work.',        'category'    => 'contractor',        'input_schema' => array(            'type'                 => 'object',            'properties'           => array(                'service' => array(                    'type'        => 'string',                    'enum'        => array( 'kitchen', 'bathroom', 'roofing', 'basement' ),                    'description' => 'Only return projects of this service type.',                ),                'town'    => array(                    'type'        => 'string',                    'description' => 'Only return projects in this town.',                ),                'limit'   => array(                    'type'    => 'integer',                    'minimum' => 1,                    'maximum' => 20,                    'default' => 5,                ),            ),            'additionalProperties' => false,            'default'              => array(), // Lets agents call it with no arguments.        ),        'output_schema' => array(            'type'  => 'array',            'items' => array(                'type'       => 'object',                'properties' => array(                    'id'             => array( 'type' => 'integer' ),                    'title'          => array( 'type' => 'string' ),                    'service'        => array( 'type' => 'string' ),                    'town'           => array( 'type' => 'string' ),                    'duration_weeks' => array( 'type' => 'integer' ),                    'url'            => array( 'type' => 'string' ),                ),            ),        ),        'execute_callback' => function ( $input = array() ) {            $meta_query = array();            foreach ( array( 'service', 'town' ) as $key ) {                if ( ! empty( $input[ $key ] ) ) {                    $meta_query[] = array( 'key' => $key, 'value' => $input[ $key ] );                }            }            $posts = get_posts( array(                'post_type'      => 'project',                'post_status'    => 'publish',                'posts_per_page' => $input['limit'] ?? 5,                'meta_query'     => $meta_query,            ) );            return array_map( function ( WP_Post $post ) {                return array(                    'id'             => $post->ID,                    'title'          => $post->post_title,                    'service'        => (string) get_post_meta( $post->ID, 'service', true ),                    'town'           => (string) get_post_meta( $post->ID, 'town', true ),                    'duration_weeks' => (int) get_post_meta( $post->ID, 'duration_weeks', true ),                    'url'            => get_permalink( $post ),                );            }, $posts );        },        // Published projects are public anyway, but the agent still needs a logged-in user.        'permission_callback' => function () {            return current_user_can( 'read' );        },        'meta' => array(            'public'       => true, // WordPress 7.1+: exposes it to REST and MCP.            'show_in_rest' => true, // WordPress 7.0 needs this one for REST.            'annotations'  => array(                'readonly'   => true,                'idempotent' => true,            ),        ),    ) );} );
```

A few details matter here. The description is written for a model, because a model reads it when choosing a tool. The enum limits services to the four this company offers. additionalProperties: false rejects any field we didn't define. And the meta block makes the ability public. In WordPress 7.1 the public flag exposes it to both REST and MCP, while WordPress 7.0 still needs show_in_rest for REST, so we set both.

The schema does real work. We sent bad input on purpose, and WordPress rejected it before our code ran:

The most natural way to call a list tool is with no arguments, to see everything. Our first version failed with input is not of type object. The input was empty, so it wasn't an object, and validation stopped there.

The fix is the 'default' => array() line you can see at the end of the input schema above. When no input is given, WordPress uses the schema's top-level default [4]. After we added it, a call with no arguments returned the five most recent projects.

Letting an agent change a site is where most of the risk is, so we kept the write ability narrow on purpose. It creates a project page, but the post status is hard-coded to draft, and only a person can publish it. It’s registered in the same wp_abilities_api_init callback:

``` js
wp_register_ability( 'contractor/draft-project', array(    'label'       => 'Draft a project page',    'description' => 'Creates a DRAFT project page from job notes. It never publishes. A person reviews and publishes the draft in WordPress.',    'category'    => 'contractor',    'input_schema' => array(        'type'                 => 'object',        'properties'           => array(            'title'          => array( 'type' => 'string', 'minLength' => 5, 'maxLength' => 120 ),            'service'        => array( 'type' => 'string', 'enum' => array( 'kitchen', 'bathroom', 'roofing', 'basement' ) ),            'town'           => array( 'type' => 'string', 'minLength' => 2 ),            'duration_weeks' => array( 'type' => 'integer', 'minimum' => 1, 'maximum' => 104 ),            'summary'        => array( 'type' => 'string', 'minLength' => 40, 'description' => 'What was done, in plain words.' ),        ),        'required'             => array( 'title', 'service', 'town', 'summary' ),        'additionalProperties' => false,    ),    'output_schema' => array(        'type'       => 'object',        'properties' => array(            'id'       => array( 'type' => 'integer' ),            'status'   => array( 'type' => 'string' ),            'edit_url' => array( 'type' => 'string' ),        ),    ),    'execute_callback' => function ( $input ) {        $post_id = wp_insert_post( array(            'post_type'    => 'project',            'post_status'  => 'draft', // Hard-coded. The agent cannot publish.            'post_title'   => sanitize_text_field( $input['title'] ),            'post_content' => wp_kses_post( $input['summary'] ),            'meta_input'   => array(                'service'        => $input['service'],                'town'           => sanitize_text_field( $input['town'] ),                'duration_weeks' => (int) ( $input['duration_weeks'] ?? 0 ),            ),        ), true );        if ( is_wp_error( $post_id ) ) {            return $post_id;        }        return array(            'id'       => $post_id,            'status'   => get_post_status( $post_id ),            'edit_url' => admin_url( 'post.php?post=' . $post_id . '&action=edit' ),        );    },    'permission_callback' => function () {        if ( current_user_can( 'edit_posts' ) ) {            return true;        }        return new WP_Error(            'contractor_cannot_draft',            'This account cannot create drafts. Connect with a user who can edit posts.'        );    },    'meta' => array(        'public'       => true,        'show_in_rest' => true,        'annotations'  => array(            'readonly'    => false,            'destructive' => false,            'idempotent'  => false,        ),    ),) );
```

We tried to get around the draft rule by adding "status": "publish" to the input. Because of additionalProperties: false, WordPress refused with status is not a valid property of Object. Even without that line, nothing the agent sends ever reaches post_status. A valid call returned the new draft's ID, its status and an edit link for a person to review.

The annotations in the meta block do more than label an ability. Over REST, WordPress used them to choose the HTTP method. Calling the read-only ability with POST returned 405 Read-only abilities require GET method. Calling the draft ability with GET returned 405 Abilities that perform updates require POST method. The MCP Adapter also passes them on to clients as MCP hints such as readOnlyHint.

But they’re promises, not guarantees. If an ability is marked read-only and its callback deletes posts, nothing stops it. The MCP specification says the same about its hints: clients must treat annotations from untrusted servers as untrusted [6]. The real protection is the permission callback and what your code allows.

The MCP Adapter creates a default server automatically. When we asked it for its tools, it didn’t list our two abilities. It listed three generic tools: one to discover abilities, one to get an ability’s details and one to execute any ability by name [3].

That design keeps the tool list short on sites with many abilities. In our test it had two side effects. First, the agent needs three steps where one would do. Second, the per-ability hints don’t reach the tool list. The execute tool can run anything, so it’s marked destructiveHint: true, and our harmless project search goes through the same tool as any write. A client that asks for confirmation before destructive actions can't tell them apart.

The discovery tool also listed WordPress’s own public abilities next to ours, including one that returns PHP and database server details. WordPress only lets administrators run that one, which is another reason not to connect agents as an administrator.

The MCP Adapter lets you create your own server with only the abilities you choose, and each one becomes its own MCP tool:

``` php
add_action( 'mcp_adapter_init', function ( $adapter ) {    $adapter->create_server(        'contractor-server',                                // Server ID.        'mcp',                                              // REST namespace.        'contractor-server',                                // REST route.        'Contractor Site',                                  // Name.        'Read the project portfolio and draft new project pages.',        '1.0.0',        array( \WP\MCP\Transport\HttpTransport::class ),        \WP\MCP\Infrastructure\ErrorHandling\ErrorLogMcpErrorHandler::class,        null,                                               // Observability handler.        array( 'contractor/list-projects', 'contractor/draft-project' )    );} );
```

With this server, the tool list showed contractor-list-projects with readOnlyHint: true and contractor-draft-project with destructiveHint: false, each with its full input schema. MCP tool names can't contain slashes, so the adapter replaced them with hyphens.

We sent the draft ability a call with three problems: a four-letter title, a service that isn’t on the list and a one-word summary. The response named only the first: input[title] must be at least 5 characters long. An agent that fixes one problem per attempt needs several round trips to get the call right.

The schema the agent sees already contains every rule, so property descriptions that state the limits plainly should help it get the call right the first time. We haven’t measured how much.

Permission errors had the opposite problem. Our first permission callback simply returned false, and over MCP the agent received only Permission denied. When we returned a WP_Error with a message instead, the agent got This account cannot create drafts. Connect with a user who can edit posts. That's something an agent can pass on to the person it's working for.

For local work, an MCP client can start the server through WP-CLI. The WordPress developer blog documents this configuration format for Claude Desktop, Cursor and Claude Code [2]:

```
{  "mcpServers": {    "contractor-site": {      "command": "wp",      "args": [        "--path=/path/to/wordpress",        "mcp-adapter",        "serve",        "--server=contractor-server",        "--user=ai-agent"      ]    }  }}
```

For a live site, the same post describes an HTTP setup through the @automattic/mcp-wordpress-remote package with an application password [2]. We tested the STDIO server with raw JSON-RPC, not with each of these clients.

Pay attention to the --user argument. Every ability runs as that WordPress user, so it decides what the agent can do. We'd create a dedicated account with the lowest role that does the job, rather than connecting as an administrator.

For theme and plugin developers, this is a new kind of interface. Until now we designed for two audiences: site owners in the admin and visitors on the front end. Abilities add a third, and it reads your descriptions literally.

**What would you let an AI agent do on your WordPress site, and what would you never allow? Tell us in the comments.**

References

[1] W3Techs, “Comparison of the usage statistics of WordPress for websites,” Sep. 30, 2026. [Online]. Available: [https://w3techs.com/technologies/comparison/cm-wordpress](https://w3techs.com/technologies/comparison/cm-wordpress)

[2] WordPress Developer Blog, “From abilities to AI agents: Introducing the WordPress MCP Adapter,” Feb. 2026. [Online]. Available: [https://developer.wordpress.org/news/2026/02/from-abilities-to-ai-agents-introducing-the-wordpress-mcp-adapter/](https://developer.wordpress.org/news/2026/02/from-abilities-to-ai-agents-introducing-the-wordpress-mcp-adapter/)

[3] WordPress, “Default MCP server,” MCP Adapter documentation, GitHub. [Online]. Available: [https://github.com/WordPress/mcp-adapter/blob/trunk/docs/guides/default-server.md](https://github.com/WordPress/mcp-adapter/blob/trunk/docs/guides/default-server.md)

[4] WordPress, “abilities-api.php,” WordPress 7.1.2 source code, GitHub. [Online]. Available: [https://github.com/WordPress/WordPress/blob/7.1.2/wp-includes/abilities-api.php](https://github.com/WordPress/WordPress/blob/7.1.2/wp-includes/abilities-api.php)

[5] InfoQ, “WordPress 7.0 ships with AI foundations in core, a modernized admin, and new design tools,” Jul. 2026. [Online]. Available: [https://www.infoq.com/news/2026/07/wordpress-7-ai/](https://www.infoq.com/news/2026/07/wordpress-7-ai/)

[6] O. Hungerford, S. Morrow, and L. Chang, “Tool annotations as risk vocabulary: What hints can and can’t do,” Model Context Protocol Blog, Mar. 16, 2026. [Online]. Available: [https://blog.modelcontextprotocol.io/posts/2026-03-16-tool-annotations/](https://blog.modelcontextprotocol.io/posts/2026-03-16-tool-annotations/)

[We Connected a WordPress Site to AI Agents Through MCP. Here’s What We Had to Fix](https://pub.towardsai.net/we-connected-a-wordpress-site-to-ai-agents-through-mcp-heres-what-we-had-to-fix-c1bdca1918f9) was originally published in [Towards AI](https://pub.towardsai.net) on Medium, where people are continuing the conversation by highlighting and responding to this story.
