
Existen tres vías para conectar un agente a WordPress: la Abilities API del núcleo (solo tres abilities), el adaptador oficial WordPress/mcp-adapter (v0.6.1 del 13 de agosto de 2026), y un endpoint casero. La tercera es la única en la que decides tú todo: 152 líneas de PHP, tres herramientas de lectura, un token dedicado y un filtro SQL que convierte toda escritura en SELECT 1. Probada aquí con curl y luego desde Codex, contra un WordPress 7.1 local.
Conectar un agente de código a WordPress significa darle acceso a tus artículos, a tus
borradores y, muy pronto, al derecho a escribir. Entre la contraseña de administrador y la
negativa pura y simple, existe una posición sostenible: un endpoint MCP que escribes tú mismo, que
solo expone tres lecturas y que es incapaz de escribir en la base de datos, aunque el modelo lo
pida.
Este artículo se queda pegado a WordPress. Para el protocolo en sí mismo, JSON-RPC, transportes, ciclo
initialize, ve a leer
crear un servidor MCP en PHP,
aquí se habla de wp-load.php, de $wpdb y de contraseñas de aplicación.
Lo que ofrece WordPress en septiembre de 2026
Existen tres vías, y no se excluyen entre sí. La entrada
From
Abilities to AI agents, publicada el 4 de febrero de 2026 en developer.wordpress.org, sienta la
doctrina. Este es su estado a 7 de septiembre de 2026, comprobado repositorio por repositorio.
| Vía | Estado | Qué pide |
|---|---|---|
| Abilities API del núcleo | En WordPress desde la 6.9, tres abilities registradas en mi WordPress 7.1 | Nada que instalar, pero no expone nada en MCP por sí sola |
Adaptador MCP oficial (WordPress/mcp-adapter) | Activo: v0.6.1 del 13 de agosto de 2026, último envío el 2 de septiembre de 2026, 1.672 estrellas | Un plugin o un composer require, y luego una contraseña de aplicación u OAuth 2.1 |
Plugin Automattic/wordpress-mcp | Archivado: último envío el 30 de agosto de 2025, repositorio en solo lectura desde el 19 de enero de 2026 | Ya nada: remite al adaptador oficial |
El cambio todavía no ha llegado a todas partes: el
tutorial de LWS,
publicado el 2 de septiembre de 2025, sigue describiendo el plugin de Automattic como la vía
principal, mientras que WPFormation,
actualizado el 13 de agosto de 2026, ya ha pasado al adaptador oficial. La API de GitHub zanja la
cuestión: archived: true por un lado, archived: false por el otro.
Lo que contiene la Abilities API de tu WordPress
La Abilities API es la base: un registro de capacidades declaradas, con un esquema de entrada,
un esquema de salida, una categoría y un control de permisos. El adaptador MCP se limita a
traducir ese registro en herramientas MCP. Dicho de otro modo: sin ability, un adaptador MCP no
expone nada.
En el WordPress 7.1 de mi entorno local, el núcleo registra tres. La primera sorpresa es que la
ruta REST no está abierta:
curl -s http://localhost:8090/wp-json/wp-abilities/v1/abilities
# POST sobre una ability de solo lectura
# HTTP 405, {"code":"rest_ability_invalid_method",
# "message":"Les « abilities » en lecture seule requièrent la méthode GET."}
# GET en la misma ruta, como administrador: HTTP 200
# {"name":"Gekkode","url":"http://localhost:8090","admin_email":"c***@gekkode.com",
# "charset":"UTF-8","language":"fr-FR","version":"7.1"}
# nunca los borradores
's' => (string) ( $args['query'] ?? '' ),
'posts_per_page' => min( 20, max( 1, (int) ( $args['per_page'] ?? 5 ) ) ),
'no_found_rows' => true,
) );
// luego, para cada artículo: ID, título, enlace permanente, fecha, número de palabras.Como administrador, vía WP-CLI y rest_do_request() para evitar tener que crear
ninguna credencial, la lista cabe en tres líneas:
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"; }'
# El token nunca se escribe en un archivo del repositorio.
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
# auto | prompt | writes | approve
startup_timeout_sec = 20
# core/get-environment-infoDos detalles importan para lo que sigue. Cada ability lleva de entrada anotaciones, y son vinculantes. core/get-site-info declara meta.annotations.readonly = true, y la ruta de ejecución rechaza entonces el POST:
# 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"}El segundo detalle se lee en la propia respuesta, admin_email. La ability más inofensiva del núcleo, la que está marcada «solo lectura, no destructiva, idempotente», devuelve la dirección de correo de administración del sitio. Un agente que la llama la mete en su contexto, y ese contexto se va a parar a un proveedor de modelos. Es exactamente el tipo de fuga que no dispara ninguna alerta: no se ha modificado ningún dato, todo ha ocurrido tal como estaba previsto.
La misma llamada como visitante anónimo devuelve 401 rest_ability_cannot_execute: los permisos de WordPress sí se respetan. El modelo de permisos hace su trabajo. El problema viene de la cuenta que le das al agente, con derecho a leerlo todo.
¿Por qué escribir tu propio endpoint?
Porque la superficie por defecto es inmensa. En este sitio, el índice de la API REST declara
154 rutas para 296.774 bytes de esquema. Un agente que explora
esa superficie la paga en tokens, y nada le impide encontrar la ruta que escribe.
La comparación más elocuente es sobre una búsqueda idéntica:
| Llamada | Tamaño de la respuesta |
|---|---|
GET /wp/v2/posts?search=docker&per_page=5 | 116.227 bytes |
Lo mismo con _fields=id,title,link,date | 1.080 bytes |
search_posts("docker", 5), mi herramienta MCP | 1.499 bytes |
Fíjate bien en la tercera línea: mi herramienta es más grande que la API REST bien
parametrizada, porque además cuenta las palabras. La ganancia no viene entonces de MCP, sino del
hecho de que la forma de la respuesta se decide una vez, por ti, en lugar de dejarse en manos del
modelo, que no tiene ningún motivo para pensar en _fields. El factor 78 de las
llamadas en bruto es lo que tú fijas al escribir la herramienta.
Escribir el endpoint de solo lectura
El archivo tiene 152 líneas de código efectivas. Carga wp-load.php, expone tres
herramientas y pone dos candados. El primero es un token bearer dedicado, leído del entorno: no es
ni una contraseña de WordPress, ni una contraseña de aplicación, y no da acceso a nada más que a
esas tres lecturas.
$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;
}El segundo candado es el corazón del dispositivo. Restringir el rol de WordPress no basta:
cualquier extensión cargada durante el arranque puede escribir. Así que se corta más abajo, a
nivel SQL. WordPress pasa cada consulta por el filtro query
(wp-includes/class-wpdb.php), y sabe cargar filtros declarados antes que él:
wp-includes/plugin.php llama a
WP_Hook::build_preinitialized_hooks( $wp_filter ) en la línea 41. Ahí es donde nos
instalamos.
$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';Toda consulta que no sea una lectura se sustituye por un SELECT 1 y se registra.
Comprobé el candado con un DELETE voluntario, escrito para no corresponder a ninguna
fila: si el filtro fallaba, no se destruiría nada.
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 = 0Cero escrituras en el arranque: en este sitio, WordPress no intenta nada en la base de datos
durante una simple carga. Es una buena noticia, no una garantía: añade una extensión y la cuenta
cambiará. El candado sirve para ese día.
Quedan las tres herramientas. Son deliberadamente pobres: search_posts solo ve los
artículos publicados, get_post rechaza todo lo que no sea un artículo publicado, y
site_stats cuenta. Cada una está anotada con readOnlyHint, más adelante
verás que no es decorativo.
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.Lanzar el punto de entrada y comprobarlo con curl
El servidor HTTP integrado de PHP basta, y tiene una ventaja: el endpoint escucha en un puerto
distinto del sitio, solo en el bucle local. Nada queda expuesto públicamente. En mi caso, todo
corre en un contenedor desechable que se une a la red Docker del blog.
# 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.phpLa primera prueba es la del rechazo.
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'
# 401Luego el handshake, como lo haría un cliente MCP:
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}Los tiempos de respuesta, medidos en este espejo local de 1.198 artículos: 0,11 s para
tools/list, 0,02 s para site_stats, 0,07 s para
search_posts, 0,03 s para get_post. Lo esencial es el arranque de
WordPress, no la consulta.
Y la comprobación que importa: pedir un borrador.
{"jsonrpc":"2.0","id":6,"result":{"isError":true,
"content":[{"type":"text","text":"article introuvable ou non publié"}]}}Conectarlo a Codex
Codex guarda sus servidores MCP en ~/.codex/config.toml. La opción -c evita tocar ese archivo pasando toda la declaración en línea, que es lo que hice aquí, con codex-cli 0.153.4 y 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."Primer intento: fallo instructivo. El servidor se conecta, las herramientas se descubren, pero
las llamadas se paran en seco.
mcp: wp/site_stats started
mcp: wp/site_stats (failed)
MCP tool call requires approval, but approval policy is neverEn sesión interactiva, Codex te pediría aprobar cada llamada. En codex exec, nadie
puede responder: la política vale never, la llamada se rechaza. El ajuste se pone por
servidor:
[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 es el compromiso adecuado: solo pasan sin preguntar las herramientas
anunciadas como no modificantes. Con este ajuste y las anotaciones en su sitio, la ejecución llega
a buen puerto en veinte segundos por 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 »Quise saber qué era, exactamente, lo que activaba esa autorización. Así que volví a servir el mismo archivo despojado de sus anotaciones, sin cambiar nada más: la misma llamada volvió a dar MCP tool call requires approval. El disparador no es entonces ni el nombre de la herramienta ni su descripción, sino el indicador que el propio servidor declara.
'annotations' => array(
'readOnlyHint' => true,
'destructiveHint' => false,
'idempotentHint' => true,
'openWorldHint' => false,
),Fíjate en el sentido de la dependencia: es tu servidor el que afirma ser inofensivo, y el
cliente el que le cree. Un servidor MCP de terceros puede mentir en esa línea. El razonamiento
vale entonces para código que has escrito tú, no para un servidor sacado de una marketplace, tema
tratado en aislar
Claude Code y Codex.
Del lado de Claude Code
La declaración cabe en un comando. El ámbito
project escribe un .mcp.json en la raíz del proyecto, compartido por el
equipo, el ámbito local lo guarda solo para ti.
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}" }
}
}
}Dos precauciones. El comando escribe el token en claro en un archivo hecho
para ser versionado: sustituye el valor por ${GK_MCP_TOKEN} a mano, como arriba (la
documentación también acepta ${VAR:-defecto}). Además, un servidor añadido en ámbito
de proyecto no está activo mientras nadie lo haya aprobado: claude mcp list muestra
Pending approval hasta la primera sesión.
En empresa, el ajuste managedMcpServers, aparecido en Claude Code 2.1.259 el 2 de
septiembre de 2026, permite empujar servidores MCP HTTP a todos los puestos, con el mismo formato
que .mcp.json: ahí es donde tiene su sitio un endpoint de solo lectura, más que en el
repositorio de cada uno.
¿Qué usos, y dónde parar?
Tres lecturas bastan para buena parte del trabajo editorial. La revisión SEO, primero: el
agente encadena search_posts y get_post, y trabaja sobre el texto real en
lugar de sobre su recuerdo del sitio. La caza de contenidos huérfanos, después:
get_post devuelve un campo liens_internes. En un artículo de 2.064
palabras publicado en enero de 2023, Codex me respondió «tres enlaces internos», la comprobación
en SQL da efectivamente tres.
La preparación de borradores, por último, es el caso en el que hay que resistirse. La tentación
es añadir una cuarta herramienta create_draft. No lo hagas en el mismo endpoint: un
servidor que escribe es un servidor en el que cada llamada debe aprobarse, registrarse y ser
reversible. Si de verdad lo quieres, haz un segundo servidor, en otro puerto, con su propio token,
y deja default_tools_approval_mode en prompt. La misma regla vale para
PrestaShop, donde la superficie del Webservice es todavía más amplia: mira
MCP para PrestaShop.
Para un sitio en producción, la vía oficial sigue siendo la respuesta correcta: instala
WordPress/mcp-adapter, expón solo tus propias abilities con
'meta' => array( 'mcp' => array( 'public' => true ) ), y dale al agente una
cuenta dedicada, no la tuya, con una contraseña de aplicación revocable. Los dos enfoques se
complementan: el adaptador para lo que WordPress ya sabe hacer, un endpoint casero para lo que
quieras acotar al milímetro.
Lo que hay que recordar
- El plugin
Automattic/wordpress-mcpestá archivado desde el 19 de enero de 2026,
la vía oficial esWordPress/mcp-adapter, v0.6.1 del 13 de agosto de 2026. - Sin ability declarada, un adaptador MCP no expone nada: el núcleo de WordPress 7.1 solo
registra tres, ycore/get-site-infoya devuelve la dirección de correo de
administración. - Un endpoint casero de 152 líneas reduce 154 rutas REST y 296.774 bytes de esquema a tres
herramientas y 1.081 bytes. - El verdadero candado no es el rol de WordPress sino el filtro
querypreregistrado
antes dewp-load.php: toda escritura se convierte en unSELECT 1
registrado. - En
codex exec, una herramienta sinreadOnlyHintse rechaza: es la
anotación, y nada más, lo que autoriza la llamada automática en modowrites. - En Apache y mod_php,
$_SERVER['HTTP_AUTHORIZATION']está vacío: lee la cabecera
congetallheaders(), o tu servidor responderá 401 sin razón aparente.
Errores frecuentes
/wp-json/wp-abilities/v1/abilities devuelve 401 en anónimo, y una ability marcada como readonly rechaza el POST en su ruta /run con un 405. Pasa por GET y por una cuenta autenticada.mod_php, $_SERVER['HTTP_AUTHORIZATION'] está vacío mientras que getallheaders() sí la devuelve. Lee las dos, o tu endpoint responderá 401 sin que nada lo explique.claude mcp add --header escribe el valor tal cual en un archivo hecho para ser versionado. Sustitúyelo por ${GK_MCP_TOKEN} y guarda el secreto en el entorno.readOnlyHint, Codex rechaza la llamada en codex exec incluso con default_tools_approval_mode = "writes". Comprobado mediante control negativo: es la anotación la que desbloquea, nada más.query preregistrado en $wp_filter antes de wp-load.php.

