
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:
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:
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-infoTwo 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:
# 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.
$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.
$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.
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 = 0Zero 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.
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.
# 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.phpThe first test is the refusal.
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'
# 401Then the handshake, the way an MCP client would do it:
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"}}}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.
{"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.
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.
mcp: wp/site_stats started
mcp: wp/site_stats (failed)
MCP tool call requires approval, but approval policy is neverIn 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:
[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 = 20writes 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:
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.
'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.
claude mcp add --transport http wp http://127.0.0.1:8099/mcp \
--header "Authorization: Bearer $GK_MCP_TOKEN" --scope project{
"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-mcpplugin has been archived since 19 January 2026, the
official route isWordPress/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, andcore/get-site-infoalready 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
queryfilter pre-registered
beforewp-load.php: every write turns into a loggedSELECT 1. - In
codex exec, a tool with noreadOnlyHintgets refused: it’s
the annotation, and nothing else, that authorises the automatic call inwritesmode. - Under Apache and mod_php,
$_SERVER['HTTP_AUTHORIZATION']is empty: read
the header withgetallheaders(), or your server will answer 401 for no apparent reason.
Common errors
/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.mod_php, $_SERVER['HTTP_AUTHORIZATION'] is empty even though getallheaders() returns it. Read both, or your endpoint answers 401 with nothing to explain why.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.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.query filter pre-registered in $wp_filter before wp-load.php.

