MCP for WordPress without handing an agent the keys to your site

Three ways to wire Claude Code or Codex up to WordPress, plus a homemade 152-line, read-only MCP endpoint, written and run against a local WordPress 7.1.

MCP for WordPress without handing an agent the keys to your site
Quick answer

Three routes exist for wiring an agent up to WordPress: the core's Abilities API (only three abilities), the official WordPress/mcp-adapter (v0.6.1 from 13 August 2026), and a homemade endpoint. The third is the only one where you decide everything: 152 lines of PHP, three read-only tools, a dedicated token and a SQL filter that turns every write into a SELECT 1. Tested here with curl, then from Codex, against a local WordPress 7.1.

Wiring a coding agent up to WordPress means giving it access to your posts, your
drafts and, very quickly, write access. Between handing over the admin password and
flatly refusing, there’s a tenable middle ground: an MCP endpoint you write yourself, one that
exposes only three reads and is physically incapable of writing to the database, even if the model asks it to.

This article stays close to the ground on WordPress itself. For the protocol proper, JSON-RPC, transports, the
initialize cycle, go read
building an MCP server in PHP,
here, we’re talking about wp-load.php, $wpdb and application passwords.

What WordPress offers as of September 2026

Three routes exist, and they aren’t mutually exclusive. The
From
Abilities to AI agents
post, published on 4 February 2026 on developer.wordpress.org, lays out the doctrine.
Here’s where they stand as of 7 September 2026, checked repo by repo.

Route Status What it requires
Core Abilities API In WordPress since 6.9, three abilities registered on my WordPress 7.1 Nothing to install, but nothing gets exposed over MCP on its own
Official MCP adapter (WordPress/mcp-adapter) Active: v0.6.1 from 13 August 2026, last commit 2 September 2026, 1,672 stars A plugin or a composer require, then an application password or OAuth 2.1
Plugin Automattic/wordpress-mcp Archived: last commit 30 August 2025, read-only repo since 19 January 2026 Nothing left: it points to the official adapter

The switchover hasn’t reached everywhere yet: the
LWS tutorial, published
on 2 September 2025, still describes Automattic’s plugin as the main route, while
WPFormation, updated
on 13 August 2026, has moved on to the official adapter. The GitHub API settles it:
archived: true on one side, archived: false on the other.

What your WordPress’s Abilities API actually holds

The Abilities API is the foundation: a registry of declared capabilities, each with an input
schema, an output schema, a category and a permission check. The MCP adapter merely translates
that registry into MCP tools. In other words: with no ability registered, an MCP adapter exposes nothing.

On the WordPress 7.1 in my local environment, the core registers three. The first
surprise is that the REST route isn’t open:

bash
curl -s http://localhost:8090/wp-json/wp-abilities/v1/abilities
# {"code":"rest_forbidden","message":"Sorry, you are not allowed to do that.",
#  "data":{"status":401}}

As an administrator, via WP-CLI and rest_do_request() to avoid having to create the
slightest credential, the list fits in three lines:

bash
docker compose run --rm -T cli wp eval '
$u = get_users( array( "role" => "administrator", "number" => 1 ) );
wp_set_current_user( $u[0]->ID );
$r = rest_do_request( new WP_REST_Request( "GET", "/wp-abilities/v1/abilities" ) );
foreach ( $r->get_data() as $a ) { echo $a["name"], "\n"; }'

# core/get-site-info
# core/get-user-info
# core/get-environment-info

Two details matter for what follows. Every ability first carries annotations, and they’re enforced, not just informative. core/get-site-info declares meta.annotations.readonly = true, and the execution route then refuses the POST:

bash
# POST on a read-only ability
# HTTP 405: {"code":"rest_ability_invalid_method",
#             "message":"Read-only abilities require the GET method."}

# GET on the same route, as an administrator: HTTP 200
# {"name":"Gekkode","url":"http://localhost:8090","admin_email":"c***@gekkode.com",
#  "charset":"UTF-8","language":"fr-FR","version":"7.1"}

The second detail is read in that response itself, admin_email. The core’s most innocuous-looking ability, the one flagged “read-only, non-destructive, idempotent,” hands back the site’s administration email address. An agent that calls it puts that address in its context, and that context goes off to a model provider. This is exactly the kind of leak that never trips an alarm: nothing got modified, everything went exactly as planned.

The same call as an anonymous visitor returns 401 rest_ability_cannot_execute: WordPress’s permissions are properly respected. The permission model does its job. The problem comes from the account you hand the agent, cleared to read everything.

Why write your own endpoint?

Because the default surface is enormous. On this site, the REST API index declares
154 routes for 296,774 bytes of schema. An agent exploring
this surface pays for it in tokens, and nothing stops it from finding the route that writes.

The most telling comparison is on an identical search:

Call Response size
GET /wp/v2/posts?search=docker&per_page=5 116,227 bytes
The same with _fields=id,title,link,date 1,080 bytes
search_posts("docker", 5), my MCP tool 1,499 bytes

Read the third line carefully: my tool is bigger than the properly parameterised
REST API, because it also counts words. So the gain doesn’t come from MCP itself, it comes from
the response shape being decided once, by you, instead of being left to the model, which has
no reason to think of _fields. That factor of 78 between the raw calls is what you
lock in by writing the tool.

Writing the read-only endpoint

The file runs 152 lines of actual code. It loads wp-load.php, exposes three
tools and sets up two locks. The first is a dedicated bearer token, read from the environment: it’s
neither a WordPress password nor an application password, and it grants access to nothing
beyond these three reads.

php
$attendu = (string) getenv( 'GK_MCP_TOKEN' );

// Under Apache/mod_php, $_SERVER['HTTP_AUTHORIZATION'] is empty: the header only arrives
// via getallheaders(). Measured on wordpress:7.1-php8.5-apache.
$entetes = function_exists( 'getallheaders' ) ? array_change_key_case( getallheaders() ) : array();
$recu    = (string) ( $_SERVER['HTTP_AUTHORIZATION']
	?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION']
	?? $entetes['authorization']
	?? '' );
$recu    = preg_replace( '/^Bearer\s+/i', '', trim( $recu ) );

if ( '' === $attendu || ! hash_equals( $attendu, $recu ) ) {
	header( 'Content-Type: application/json', true, 401 );
	header( 'WWW-Authenticate: Bearer realm="mcp"' );
	echo json_encode( array( 'error' => 'jeton absent ou invalide' ) );
	exit;
}

The second lock is the heart of the whole setup. Restricting the WordPress role isn’t enough:
any plugin loaded during startup can still write. So we cut lower, at the SQL level. WordPress
runs every query through the query filter (wp-includes/class-wpdb.php),
and it knows how to load filters declared before it: wp-includes/plugin.php
calls WP_Hook::build_preinitialized_hooks( $wp_filter ) at line 41. That’s where we hook in.

php
$GLOBALS['gk_bloquees'] = array();

function gk_lecture_seule( $sql ) {
	if ( preg_match( '/^\s*(SELECT|SHOW|DESCRIBE|DESC|EXPLAIN|SET|USE)\b/i', (string) $sql ) ) {
		return $sql;
	}
	$GLOBALS['gk_bloquees'][] = substr( preg_replace( '/\s+/', ' ', (string) $sql ), 0, 120 );
	return 'SELECT 1 /* écriture refusée par le point d\'entrée MCP */';
}

// Read by WP_Hook::build_preinitialized_hooks() when wp-includes/plugin.php loads.
$wp_filter = array(
	'query' => array( 0 => array( array( 'function' => 'gk_lecture_seule', 'accepted_args' => 1 ) ) ),
);

define( 'DISABLE_WP_CRON', true );
require_once '/var/www/html/wp-load.php';

Any query that isn’t a read gets swapped out for a SELECT 1 and logged.
I checked the lock with a deliberate DELETE, written to match no row at all: if the
filter ever failed, nothing would be destroyed.

bash
docker exec gekkode-mcp-lab php .../test-verrou.php

# Writes attempted during WordPress startup: 0
# After a deliberate DELETE, the request actually sent: SELECT 1 /* écriture refusée */
# Writes blocked in total: 1
#   - DELETE FROM gk_options WHERE option_id = 0

Zero writes on startup: on this site, WordPress doesn’t attempt anything against the
database during a plain load. That’s good news, not a guarantee: add a plugin and the
count will change. The lock is there for that day.

That leaves the three tools. They’re deliberately bare: search_posts only
sees published posts, get_post refuses anything that isn’t a published post, and
site_stats just counts. Each one is annotated readOnlyHint, you’ll see
further down that this isn’t decorative.

php
case 'search_posts':
	$q = new WP_Query( array(
		'post_type'      => 'post',
		'post_status'    => 'publish',           // never drafts
		's'              => (string) ( $args['query'] ?? '' ),
		'posts_per_page' => min( 20, max( 1, (int) ( $args['per_page'] ?? 5 ) ) ),
		'no_found_rows'  => true,
	) );
	// then, for each post: ID, title, permalink, date, word count.

Launching the endpoint and checking it with curl

PHP’s built-in HTTP server is enough, and it has one advantage: the endpoint listens on a
port separate from the site’s, on the local loopback only. Nothing is exposed publicly.
On my end, the whole thing runs in a disposable container that joins the blog’s Docker network.

bash
# The token is never written to a file in the repo.
export GK_MCP_TOKEN=$(openssl rand -hex 16)

docker run -d --name gekkode-mcp-lab --network gekkode-blog_default \
  -p 127.0.0.1:8099:8099 -e GK_MCP_TOKEN="$GK_MCP_TOKEN" \
  -e WORDPRESS_DB_HOST=db -e WORDPRESS_DB_NAME=gekkode \
  -e WORDPRESS_DB_USER=gekkode -e WORDPRESS_DB_PASSWORD=gekkode \
  -v "$PWD":/var/www/html wordpress:7.1-php8.5-apache \
  php -S 0.0.0.0:8099 /var/www/html/chemin/vers/mcp-wp.php

The first test is the refusal.

bash
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:8099/mcp
# 401
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:8099/mcp \
  -H 'Authorization: Bearer faux'
# 401

Then the handshake, the way an MCP client would do it:

bash
curl -s -X POST http://127.0.0.1:8099/mcp \
  -H "Authorization: Bearer $GK_MCP_TOKEN" -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize",
       "params":{"protocolVersion":"2025-06-18","capabilities":{}}}'

# {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18",
#  "capabilities":{"tools":{}},"serverInfo":{"name":"gekkode-wp-lecture","version":"1.0.0"}}}
bash
curl -s -X POST http://127.0.0.1:8099/mcp \
  -H "Authorization: Bearer $GK_MCP_TOKEN" -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":3,"method":"tools/call",
       "params":{"name":"site_stats","arguments":{}}}'

# {"wordpress":"7.1","site":"http://localhost:8090",
#  "articles":{"publies":1198,"brouillons":12,"futurs":0},
#  "pages":56,"categories":11,"etiquettes":64}

Response times, measured on this local mirror of 1,198 posts: 0.11 s for
tools/list, 0.02 s for site_stats, 0.07 s for
search_posts, 0.03 s for get_post. Most of it is WordPress starting up,
not the query itself.

And the check that actually matters: asking for a draft.

json
{"jsonrpc":"2.0","id":6,"result":{"isError":true,
 "content":[{"type":"text","text":"article introuvable ou non publié"}]}}

Connecting it to Codex

Codex keeps its MCP servers in ~/.codex/config.toml. The -c option avoids touching that file by passing the whole declaration on the command line, which is what I did here, with
codex-cli 0.153.4 and GPT-6 Astra.

bash
export GK_MCP_TOKEN=…

codex exec \
  -c 'mcp_servers.wp.url="http://127.0.0.1:8099/mcp"' \
  -c 'mcp_servers.wp.bearer_token_env_var="GK_MCP_TOKEN"' \
  -m gpt-6-astra \
  "Utilise UNIQUEMENT les outils MCP du serveur wp. Appelle site_stats, puis search_posts
   avec la requête « websocket » et per_page 2."

First attempt: an instructive failure. The server connects, the tools get discovered, but
the calls stop dead.

bash
mcp: wp/site_stats started
mcp: wp/site_stats (failed)
MCP tool call requires approval, but approval policy is never

In an interactive session, Codex would ask you to approve each call. In
codex exec, nobody’s there to answer: the policy is never,
the call gets refused. The setting is applied per server:

toml
[mcp_servers.wp]
url = "http://127.0.0.1:8099/mcp"
bearer_token_env_var = "GK_MCP_TOKEN"
default_tools_approval_mode = "writes"   # auto | prompt | writes | approve
startup_timeout_sec = 20

writes is the right compromise: only the tools announced as non-modifying
go through without asking. With that setting and the annotations in place, the run succeeds in
twenty seconds for 9,550 tokens:

bash
mcp: wp/site_stats (completed)
mcp: wp/search_posts (completed)

Articles publiés : 1 198
Version de WordPress : 7.1
Titres trouvés : « Créer un serveur WebSocket en PHP » ; « Comment faire une requête cURL en PHP »

I wanted to know exactly what was triggering this authorisation. So I re-ran the same file stripped of its annotations, changing nothing else: the same call went back to MCP tool call requires approval. So the trigger is neither the tool’s name nor its description, but the flag the server declares about itself.

php
'annotations' => array(
	'readOnlyHint'    => true,
	'destructiveHint' => false,
	'idempotentHint'  => true,
	'openWorldHint'   => false,
),

Note the direction of that trust: it’s your server that claims to be harmless, and the
client that believes it. A third-party MCP server can lie on that exact line. So this reasoning holds
for code you wrote yourself, not for a server pulled off a marketplace, a subject
treated in sandboxing
Claude Code and Codex
.

On the Claude Code side

Declaring it fits in one command. The scope
project writes a .mcp.json at the root of the project, shared by
the team, the local scope keeps it to yourself.

bash
claude mcp add --transport http wp http://127.0.0.1:8099/mcp \
  --header "Authorization: Bearer $GK_MCP_TOKEN" --scope project
json
{
  "mcpServers": {
    "wp": {
      "type": "http",
      "url": "http://127.0.0.1:8099/mcp",
      "headers": { "Authorization": "Bearer ${GK_MCP_TOKEN}" }
    }
  }
}

Two precautions. The command writes the token in plaintext into a file made to
be versioned: replace the value with ${GK_MCP_TOKEN} by hand, as shown above
(the documentation also accepts ${VAR:-default}). Second, a server added at project scope
isn’t active until someone has approved it: claude mcp list shows
Pending approval until the first session.

In an enterprise setting, the managedMcpServers setting that arrived in Claude Code 2.1.259 on
2 September 2026 lets you push HTTP MCP servers to every machine, in the same format as
.mcp.json: that’s where a read-only endpoint belongs, rather than in
everyone’s own repo.

What’s it good for, and where should you stop?

Three reads are enough for a lot of editorial work. SEO review first: the agent
chains search_posts and get_post, and works from the real text rather
than its memory of the site. Hunting down orphaned content next: get_post
returns a liens_internes field. On a 2,064-word post published in January 2023,
Codex answered “three internal links,” checking it in SQL confirms exactly three.

Drafting new posts, finally, is the case where you need to resist. The temptation is to bolt on
a fourth tool, create_draft. Don’t do it in the same endpoint: a
server that writes is a server where every call has to be approved, logged and reversible. If
you really want it, make it a second server, on a different port, with its own token, and leave
default_tools_approval_mode on prompt. The same rule applies to
PrestaShop, where the Webservice surface is even wider: see
MCP for PrestaShop.

For a production site, the official route remains the right answer: install
WordPress/mcp-adapter, expose only your own abilities with
'meta' => array( 'mcp' => array( 'public' => true ) ), and give the agent a
dedicated account, not your own, with a revocable application password. The two approaches
complement each other: the adapter for what WordPress already knows how to do, a homemade
endpoint for whatever you want to bound down to the millimetre.

What to remember

  • The Automattic/wordpress-mcp plugin has been archived since 19 January 2026, the
    official route is WordPress/mcp-adapter, v0.6.1 from 13 August 2026.
  • With no ability declared, an MCP adapter exposes nothing: WordPress 7.1’s core
    registers only three, and core/get-site-info already hands back the administration
    email address.
  • A homemade 152-line endpoint brings 154 REST routes and 296,774 bytes of schema down to
    three tools and 1,081 bytes.
  • The real lock isn’t the WordPress role but the query filter pre-registered
    before wp-load.php: every write turns into a logged SELECT 1.
  • In codex exec, a tool with no readOnlyHint gets refused: it’s
    the annotation, and nothing else, that authorises the automatic call in writes mode.
  • Under Apache and mod_php, $_SERVER['HTTP_AUTHORIZATION'] is empty: read
    the header with getallheaders(), or your server will answer 401 for no apparent reason.

Common errors

Assuming the Abilities API is open The /wp-json/wp-abilities/v1/abilities route returns 401 anonymously, and an ability flagged readonly refuses a POST on its /run route with a 405. Go through GET, with an authenticated account.
Letting Apache swallow the Authorization header Under mod_php, $_SERVER['HTTP_AUTHORIZATION'] is empty even though getallheaders() returns it. Read both, or your endpoint answers 401 with nothing to explain why.
Pasting the token in plaintext into .mcp.json claude mcp add --header writes the value as-is into a file meant to be versioned. Replace it with ${GK_MCP_TOKEN} and keep the secret in the environment.
Forgetting the tool annotations With no readOnlyHint, Codex refuses the call in codex exec even with default_tools_approval_mode = "writes". Verified by negative control: it's the annotation that unlocks it, nothing else.
Relying on the WordPress role to forbid writes A role limits the user, not the code loaded during startup. The reliable lock is the query filter pre-registered in $wp_filter before wp-load.php.

Claude CodeCodexMCPPHPSécuritéWordPress

Damien Flandrin Web developer since 2010, creator of Gekkode and Email Impact. Every article is tested on a real project before publication. Contact
Newsletter

New tests, tutorials and projects, by e-mail.

Reproducible tests, versioned code, dated results. Never any spam.