MCP pour WordPress, sans donner les clés du site à un agent

Trois voies pour brancher Claude Code ou Codex sur WordPress, et un point d'entrée MCP maison de 152 lignes, en lecture seule, écrit et exécuté contre un WordPress 7.1 local.

MCP pour WordPress, sans donner les clés du site à un agent
Réponse rapide

Trois voies existent pour brancher un agent sur WordPress : l'Abilities API du cœur (trois abilities seulement), l'adaptateur officiel WordPress/mcp-adapter (v0.6.1 du 13 août 2026), et un point d'entrée maison. La troisième est la seule où vous décidez de tout : 152 lignes de PHP, trois outils en lecture, un jeton dédié et un filtre SQL qui transforme toute écriture en SELECT 1. Testée ici au curl puis depuis Codex, contre un WordPress 7.1 local.

Brancher un agent de code sur WordPress, cela veut dire lui donner accès à vos articles, à vos
brouillons et, très vite, au droit d’écrire. Entre le mot de passe d’administrateur et le refus pur
et simple, il existe une position tenable : un point d’entrée MCP que vous écrivez vous-même, qui
n’expose que trois lectures et qui est incapable d’écrire en base, même si le modèle le demande.

Cet article reste au ras de WordPress. Pour le protocole lui-même, JSON-RPC, transports, cycle
initialize, allez lire
créer un serveur MCP en PHP,
ici, on parle de wp-load.php, de $wpdb et de mots de passe d’application.

Ce que propose WordPress en septembre 2026

Trois voies existent, et elles ne s’excluent pas. Le billet
From
Abilities to AI agents
, publié le 4 février 2026 sur developer.wordpress.org, pose la doctrine.
Voici leur état au 7 septembre 2026, vérifié dépôt par dépôt.

Voie État Ce qu’elle demande
Abilities API du cœur Dans WordPress depuis la 6.9, trois abilities enregistrées sur mon WordPress 7.1 Rien à installer, mais rien n’est exposé en MCP tout seul
Adaptateur MCP officiel (WordPress/mcp-adapter) Actif : v0.6.1 du 13 août 2026, dernier envoi le 2 septembre 2026, 1 672 étoiles Un greffon ou un composer require, puis un mot de passe d’application ou OAuth 2.1
Plugin Automattic/wordpress-mcp Archivé : dernier envoi le 30 août 2025, dépôt en lecture seule depuis le 19 janvier 2026 Plus rien : il renvoie vers l’adaptateur officiel

La bascule n’est pas encore passée partout : le
tutoriel de LWS, publié
le 2 septembre 2025, décrit toujours le greffon d’Automattic comme la voie principale, quand
WPFormation, mis à jour
le 13 août 2026, est passé à l’adaptateur officiel. L’API GitHub tranche :
archived: true d’un côté, archived: false de l’autre.

Ce que contient l’Abilities API de votre WordPress

L’Abilities API est le socle : un registre de capacités déclarées, avec un schéma d’entrée, un
schéma de sortie, une catégorie et un contrôle de permission. L’adaptateur MCP ne fait que traduire
ce registre en outils MCP. Autrement dit : sans ability, un adaptateur MCP n’expose rien.

Sur le WordPress 7.1 de mon environnement local, le cœur en enregistre trois. La première
surprise est que la route REST n’est pas ouverte :

bash
curl -s http://localhost:8090/wp-json/wp-abilities/v1/abilities
# {"code":"rest_forbidden","message":"Désolé, vous n'avez pas l'autorisation de faire cela.",
#  "data":{"status":401}}

En administrateur, via WP-CLI et rest_do_request() pour éviter d’avoir à créer le
moindre identifiant, la liste tient en trois lignes :

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

Deux détails comptent pour la suite. Chaque ability porte d’abord des annotations, et
elles sont contraignantes. core/get-site-info déclare
meta.annotations.readonly = true, et la route d’exécution refuse alors le POST :

bash
# POST sur une ability en lecture seule
# HTTP 405 — {"code":"rest_ability_invalid_method",
#             "message":"Les « abilities » en lecture seule requièrent la méthode GET."}

# GET sur la même route, en administrateur : HTTP 200
# {"name":"Gekkode","url":"http://localhost:8090","admin_email":"c***@gekkode.com",
#  "charset":"UTF-8","language":"fr-FR","version":"7.1"}

Le second détail se lit dans la réponse elle-même, admin_email. L’ability la plus anodine
du cœur, celle qui est marquée « lecture seule, non destructive, idempotente », rend l’adresse
e-mail d’administration du site. Un agent qui l’appelle la met dans son contexte, et ce contexte
part chez un fournisseur de modèle. C’est exactement le genre de fuite qui ne déclenche aucune
alerte : aucune donnée n’a été modifiée, tout s’est passé comme prévu.

Le même appel en visiteur anonyme renvoie 401 rest_ability_cannot_execute : les
permissions WordPress sont bien respectées. Le modèle de permission fait son travail. Le
problème vient du compte que vous donnez à l’agent, autorisé à tout lire.

Pourquoi écrire son propre point d’entrée ?

Parce que la surface par défaut est immense. Sur ce site, l’index de l’API REST déclare
154 routes pour 296 774 octets de schéma. Un agent qui explore
cette surface la paie en tokens, et rien ne l’empêche de trouver la route qui écrit.

La comparaison la plus parlante porte sur une recherche identique :

Appel Taille de la réponse
GET /wp/v2/posts?search=docker&per_page=5 116 227 octets
Le même avec _fields=id,title,link,date 1 080 octets
search_posts("docker", 5), mon outil MCP 1 499 octets

Lisez bien la troisième ligne : mon outil est plus gros que l’API REST bien
paramétrée, parce qu’il compte les mots en plus. Le gain ne vient donc pas de MCP, mais du fait que
la forme de la réponse est décidée une fois, par vous, au lieu d’être laissée au modèle, qui n’a
aucune raison de penser à _fields. Le facteur 78 des appels bruts, c’est ce que vous
fixez en écrivant l’outil.

Écrire le point d’entrée en lecture seule

Le fichier fait 152 lignes de code effectives. Il charge wp-load.php, expose trois
outils et pose deux verrous. Le premier est un jeton porteur dédié, lu dans l’environnement : ce
n’est ni un mot de passe WordPress, ni un mot de passe d’application, et il ne donne accès à rien
d’autre qu’à ces trois lectures.

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

// Sous Apache/mod_php, $_SERVER['HTTP_AUTHORIZATION'] est vide : l'en-tête n'arrive
// que par getallheaders(). Mesuré sur 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;
}

Le second verrou est le cœur du dispositif. Restreindre le rôle WordPress ne suffit pas :
n’importe quelle extension chargée pendant le démarrage peut écrire. On coupe donc plus bas, au
niveau SQL. WordPress passe chaque requête par le filtre query
(wp-includes/class-wpdb.php), et il sait charger des filtres déclarés avant
lui : wp-includes/plugin.php appelle
WP_Hook::build_preinitialized_hooks( $wp_filter ) à la ligne 41. On s’installe là.

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 */';
}

// Lu par WP_Hook::build_preinitialized_hooks() au chargement de wp-includes/plugin.php.
$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';

Toute requête qui n’est pas une lecture est remplacée par un SELECT 1 et journalisée.
J’ai vérifié le verrou avec un DELETE volontaire, écrit pour ne correspondre à aucune
ligne : si le filtre tombait, rien ne serait détruit.

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

# Écritures tentées pendant le démarrage de WordPress : 0
# Après un DELETE volontaire, requête réellement envoyée : SELECT 1 /* écriture refusée */
# Écritures bloquées au total : 1
#   - DELETE FROM gk_options WHERE option_id = 0

Zéro écriture au démarrage : sur ce site, WordPress ne tente rien en base pendant un simple
chargement. C’est une bonne nouvelle, pas une garantie : ajoutez une extension et le compte
changera. Le verrou sert pour ce jour-là.

Restent les trois outils. Ils sont volontairement pauvres : search_posts ne voit
que les articles publiés, get_post refuse tout ce qui n’est pas un article publié, et
site_stats compte. Chacun est annoté readOnlyHint, vous verrez plus loin
que ce n’est pas décoratif.

php
case 'search_posts':
	$q = new WP_Query( array(
		'post_type'      => 'post',
		'post_status'    => 'publish',           // jamais les brouillons
		's'              => (string) ( $args['query'] ?? '' ),
		'posts_per_page' => min( 20, max( 1, (int) ( $args['per_page'] ?? 5 ) ) ),
		'no_found_rows'  => true,
	) );
	// puis, pour chaque article : ID, titre, permalien, date, nombre de mots.

Lancer le point d’entrée et le vérifier au curl

Le serveur HTTP intégré de PHP suffit, et il a un avantage : le point d’entrée écoute sur un
port distinct de celui du site, sur la boucle locale uniquement. Rien n’est exposé publiquement.
Chez moi, le tout tourne dans un conteneur jetable qui rejoint le réseau Docker du blog.

bash
# Le jeton n'est jamais écrit dans un fichier du dépôt.
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

Le premier test est celui du refus.

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

Puis la poignée de main, comme la ferait un client MCP :

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}

Les temps de réponse, mesurés sur ce miroir local de 1 198 articles : 0,11 s pour
tools/list, 0,02 s pour site_stats, 0,07 s pour
search_posts, 0,03 s pour get_post. L’essentiel est le démarrage de
WordPress, pas la requête.

Et le contrôle qui compte : demander un brouillon.

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

Le brancher sur Codex

Codex garde ses serveurs MCP dans ~/.codex/config.toml. L’option -c évite d’y toucher en passant toute la déclaration en ligne, ce que j’ai fait ici avec codex-cli 0.153.4 et 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."

Premier essai : échec instructif. Le serveur se connecte, les outils sont découverts, mais
les appels s’arrêtent net.

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

En session interactive, Codex vous demanderait d’approuver chaque appel. En
codex exec, personne ne peut répondre : la politique vaut never,
l’appel est refusé. Le réglage se pose par serveur :

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 est le bon compromis : seuls les outils annoncés comme non modifiants
passent sans demander. Avec ce réglage et les annotations en place, l’exécution aboutit en
vingt secondes pour 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 »

J’ai voulu savoir ce qui, exactement, déclenchait cette autorisation. J’ai donc resservi le même
fichier privé de ses annotations, sans rien changer d’autre : le même appel est redevenu
MCP tool call requires approval. Le déclencheur n’est donc ni le nom de l’outil ni sa
description, mais le drapeau que le serveur déclare lui-même.

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

Retenez le sens de la dépendance : c’est votre serveur qui affirme être inoffensif, et le
client qui le croit. Un serveur MCP tiers peut mentir sur cette ligne. Le raisonnement vaut donc
pour du code que vous avez écrit, pas pour un serveur récupéré sur une place de marché, sujet
traité dans cloisonner
Claude Code et Codex
.

Du côté de Claude Code

La déclaration tient en une commande. La portée
project écrit un .mcp.json à la racine du projet, partagé par
l’équipe, la portée local le garde pour vous.

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}" }
    }
  }
}

Deux précautions. La commande écrit le jeton en clair dans un fichier fait pour
être versionné : remplacez la valeur par ${GK_MCP_TOKEN} à la main, comme ci-dessus
(la documentation accepte aussi ${VAR:-defaut}). Ensuite, un serveur ajouté en portée
projet n’est pas actif tant que personne ne l’a approuvé : claude mcp list affiche
Pending approval jusqu’à la première session.

En entreprise, le réglage managedMcpServers apparu dans Claude Code 2.1.259 le
2 septembre 2026 permet de pousser des serveurs MCP HTTP à tous les postes, au même format que
.mcp.json : c’est là qu’un point d’entrée en lecture seule a sa place, plutôt que
dans le dépôt de chacun.

Quels usages, et où s’arrêter ?

Trois lectures suffisent à beaucoup de travail éditorial. La relecture SEO d’abord : l’agent
enchaîne search_posts et get_post, et travaille sur le texte réel plutôt
que sur son souvenir du site. La chasse aux contenus orphelins ensuite : get_post
renvoie un champ liens_internes. Sur un article de 2 064 mots publié en janvier 2023,
Codex m’a répondu « trois liens internes », la vérification en SQL en donne bien trois.

La préparation de brouillons, enfin, est le cas où il faut résister. La tentation est d’ajouter
un quatrième outil create_draft. Ne le faites pas dans le même point d’entrée : un
serveur qui écrit est un serveur dont chaque appel doit être approuvé, journalisé et réversible. Si
vous y tenez, faites-en un second serveur, sur un autre port, avec son propre jeton, et laissez
default_tools_approval_mode sur prompt. La même règle vaut pour
PrestaShop, où la surface Webservice est encore plus large : voyez
MCP pour PrestaShop.

Pour un site en production, la voie officielle reste la bonne réponse : installez
WordPress/mcp-adapter, n’exposez que vos propres abilities avec
'meta' => array( 'mcp' => array( 'public' => true ) ), et donnez à l’agent un
compte dédié, pas le vôtre, avec un mot de passe d’application révocable. Les deux approches se
complètent : l’adaptateur pour ce que WordPress sait déjà faire, un point d’entrée maison pour ce
que vous voulez borner au millimètre.

Ce qu’il faut retenir

  • Le greffon Automattic/wordpress-mcp est archivé depuis le 19 janvier 2026, la
    voie officielle est WordPress/mcp-adapter, v0.6.1 du 13 août 2026.
  • Sans ability déclarée, un adaptateur MCP n’expose rien : le cœur de WordPress 7.1 n’en
    enregistre que trois, et core/get-site-info rend déjà l’adresse e-mail
    d’administration.
  • Un point d’entrée maison de 152 lignes ramène 154 routes REST et 296 774 octets de schéma à
    trois outils et 1 081 octets.
  • Le vrai verrou n’est pas le rôle WordPress mais le filtre query pré-enregistré
    avant wp-load.php : toute écriture devient un SELECT 1 journalisé.
  • En codex exec, un outil sans readOnlyHint est refusé : c’est
    l’annotation, et rien d’autre, qui autorise l’appel automatique en mode writes.
  • Sous Apache et mod_php, $_SERVER['HTTP_AUTHORIZATION'] est vide : lisez
    l’en-tête avec getallheaders(), sinon votre serveur répondra 401 sans raison
    apparente.

Erreurs fréquentes

Croire que l'Abilities API est ouverte La route /wp-json/wp-abilities/v1/abilities renvoie 401 en anonyme, et une ability marquée readonly refuse le POST sur sa route /run avec un 405. Passez par GET et par un compte authentifié.
Laisser Apache avaler l'en-tête Authorization Sous mod_php, $_SERVER['HTTP_AUTHORIZATION'] est vide alors que getallheaders() le renvoie. Lisez les deux, sinon votre point d'entrée répond 401 sans que rien ne l'explique.
Coller le jeton en clair dans .mcp.json claude mcp add --header écrit la valeur telle quelle dans un fichier fait pour être versionné. Remplacez-la par ${GK_MCP_TOKEN} et gardez le secret dans l'environnement.
Oublier les annotations d'outil Sans readOnlyHint, Codex refuse l'appel en codex exec même avec default_tools_approval_mode = "writes". Vérifié par contrôle négatif : c'est l'annotation qui débloque, rien d'autre.
Compter sur le rôle WordPress pour interdire l'écriture Un rôle limite l'utilisateur, pas le code chargé pendant le démarrage. Le verrou fiable est le filtre query pré-enregistré dans $wp_filter avant wp-load.php.

Claude CodeCodexMCPPHPSécuritéWordPress

Damien Flandrin Développeur web depuis 2010, créateur de Gekkode et d’Email Impact. Chaque article est testé sur un projet réel avant publication. Contact
Newsletter

Les nouveaux tests, tutoriels et projets, par e-mail.

Tests reproductibles, code versionné, résultats datés. Jamais de spam.