MCP per WordPress, senza dare le chiavi del sito a un agente

Tre vie per collegare Claude Code o Codex a WordPress, e un punto d'ingresso MCP fatto in casa di 152 righe, in sola lettura, scritto ed eseguito contro un WordPress 7.1 locale.

MCP per WordPress, senza dare le chiavi del sito a un agente
Risposta rapida

Esistono tre vie per collegare un agente a WordPress: l'Abilities API del core (solo tre abilities), l'adattatore ufficiale WordPress/mcp-adapter (v0.6.1 del 13 agosto 2026), e un punto d'ingresso fatto in casa. La terza è l'unica dove decidi tutto tu: 152 righe di PHP, tre strumenti in lettura, un token dedicato e un filtro SQL che trasforma ogni scrittura in SELECT 1. Testata qui con curl e poi da Codex, contro un WordPress 7.1 locale.

Collegare un agente di codice a WordPress vuol dire dargli accesso ai tuoi articoli, alle tue bozze e, molto in fretta, al diritto di scrivere. Tra la password da amministratore e il rifiuto puro e semplice, esiste una posizione sostenibile: un punto d’ingresso MCP che scrivi tu stesso, che espone solo tre letture e che è incapace di scrivere nel database, anche se il modello lo chiede.

Questo articolo resta a livello di WordPress. Per il protocollo in sé, JSON-RPC, trasporti, ciclo initialize, vai a leggere creare un server MCP in PHP, qui si parla di wp-load.php, di $wpdb e di password d’applicazione.

Cosa propone WordPress a settembre 2026

Esistono tre vie, e non si escludono a vicenda. L’articolo From Abilities to AI agents, pubblicato il 4 febbraio 2026 su developer.wordpress.org, pone la dottrina. Ecco il loro stato al 7 settembre 2026, verificato repository per repository.

Via Stato Cosa richiede
Abilities API del core In WordPress dalla 6.9, tre abilities registrate sul mio WordPress 7.1 Niente da installare, ma niente viene esposto in MCP da solo
Adattatore MCP ufficiale (WordPress/mcp-adapter) Attivo: v0.6.1 del 13 agosto 2026, ultimo invio il 2 settembre 2026, 1.672 stelle Un plugin o un composer require, poi una password d’applicazione o OAuth 2.1
Plugin Automattic/wordpress-mcp Archiviato: ultimo invio il 30 agosto 2025, repository in sola lettura dal 19 gennaio 2026 Più nulla: rimanda all’adattatore ufficiale

Il passaggio non è ancora avvenuto ovunque: il tutorial di LWS, pubblicato il 2 settembre 2025, descrive ancora il plugin di Automattic come la via principale, mentre WPFormation, aggiornato il 13 agosto 2026, è passato all’adattatore ufficiale. L’API di GitHub è netta: archived: true da una parte, archived: false dall’altra.

Cosa contiene l’Abilities API del tuo WordPress

L’Abilities API è la base: un registro di funzionalità dichiarate, con uno schema di ingresso, uno schema di uscita, una categoria e un controllo dei permessi. L’adattatore MCP si limita a tradurre questo registro in strumenti MCP. In altre parole: senza ability, un adattatore MCP non espone nulla.

Sul WordPress 7.1 del mio ambiente locale, il core ne registra tre. La prima sorpresa è che la rotta REST non è aperta:

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

Da amministratore, tramite WP-CLI e rest_do_request() per evitare di dover creare il minimo identificativo, l’elenco sta in tre righe:

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

Due dettagli contano per il seguito. Ogni ability porta anzitutto annotazioni, e sono vincolanti. core/get-site-info dichiara meta.annotations.readonly = true, e la rotta di esecuzione rifiuta quindi il POST:

bash
# POST su una ability in sola lettura
# HTTP 405: {"code":"rest_ability_invalid_method",
#             "message":"Les « abilities » en lecture seule requièrent la méthode GET."}

# GET sulla stessa rotta, da amministratore: HTTP 200
# {"name":"Gekkode","url":"http://localhost:8090","admin_email":"c***@gekkode.com",
#  "charset":"UTF-8","language":"fr-FR","version":"7.1"}

Il secondo dettaglio si legge nella risposta stessa, admin_email. L’ability più innocua del core, quella segnata «sola lettura, non distruttiva, idempotente», restituisce l’indirizzo e-mail di amministrazione del sito. Un agente che la chiama la mette nel suo contesto, e quel contesto finisce presso un fornitore di modelli. È esattamente il tipo di fuga che non fa scattare nessun allarme: nessun dato è stato modificato, tutto è andato come previsto.

La stessa chiamata da visitatore anonimo restituisce 401 rest_ability_cannot_execute: i permessi di WordPress sono ben rispettati. Il modello di permessi fa il suo lavoro. Il problema viene dall’account che dai all’agente, autorizzato a leggere tutto.

Perché scrivere il proprio punto d’ingresso?

Perché la superficie predefinita è enorme. Su questo sito, l’indice dell’API REST dichiara 154 rotte per 296.774 byte di schema. Un agente che esplora questa superficie la paga in token, e nulla gli impedisce di trovare la rotta che scrive.

Il confronto più eloquente riguarda una ricerca identica:

Chiamata Dimensione della risposta
GET /wp/v2/posts?search=docker&per_page=5 116.227 byte
La stessa con _fields=id,title,link,date 1.080 byte
search_posts("docker", 5), il mio strumento MCP 1.499 byte

Leggi bene la terza riga: il mio strumento è più pesante dell’API REST ben parametrizzata, perché conta anche le parole. Il guadagno non viene quindi da MCP, ma dal fatto che la forma della risposta è decisa una volta per tutte, da te, invece di essere lasciata al modello, che non ha alcuna ragione di pensare a _fields. Il fattore 78 delle chiamate grezze è ciò che fissi tu scrivendo lo strumento.

Scrivere il punto d’ingresso in sola lettura

Il file conta 152 righe di codice effettivo. Carica wp-load.php, espone tre strumenti e pone due blocchi. Il primo è un token bearer dedicato, letto dall’ambiente: non è né una password WordPress, né una password d’applicazione, e non dà accesso a nient’altro che a queste tre letture.

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

// Con Apache/mod_php, $_SERVER['HTTP_AUTHORIZATION'] è vuoto: l'header non arriva
// che tramite getallheaders(). Misurato su 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;
}

Il secondo blocco è il cuore del dispositivo. Limitare il ruolo WordPress non basta: qualsiasi estensione caricata durante l’avvio può scrivere. Si taglia quindi più in basso, a livello SQL. WordPress fa passare ogni richiesta dal filtro query (wp-includes/class-wpdb.php), e sa caricare filtri dichiarati prima di lui: wp-includes/plugin.php chiama WP_Hook::build_preinitialized_hooks( $wp_filter ) alla riga 41. Ci si installa 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 */';
}

// Letto da WP_Hook::build_preinitialized_hooks() al caricamento di 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';

Ogni richiesta che non è una lettura viene sostituita da un SELECT 1 e registrata nel log. Ho verificato il blocco con un DELETE volontario, scritto per non corrispondere a nessuna riga: se il filtro cedesse, nulla verrebbe distrutto.

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

# Scritture tentate durante l'avvio di WordPress: 0
# Dopo un DELETE volontario, richiesta realmente inviata: SELECT 1 /* écriture refusée */
# Scritture bloccate in totale: 1
#   - DELETE FROM gk_options WHERE option_id = 0

Zero scritture all’avvio: su questo sito, WordPress non tenta nulla nel database durante un semplice caricamento. È una buona notizia, non una garanzia: aggiungi un’estensione e il conto cambierà. Il blocco vale per quel giorno.

Restano i tre strumenti. Sono volontariamente poveri: search_posts vede solo gli articoli pubblicati, get_post rifiuta tutto ciò che non è un articolo pubblicato, e site_stats conta. Ognuno è annotato readOnlyHint, vedrai più avanti che non è decorativo.

php
case 'search_posts':
	$q = new WP_Query( array(
		'post_type'      => 'post',
		'post_status'    => 'publish',           // mai le bozze
		's'              => (string) ( $args['query'] ?? '' ),
		'posts_per_page' => min( 20, max( 1, (int) ( $args['per_page'] ?? 5 ) ) ),
		'no_found_rows'  => true,
	) );
	// poi, per ogni articolo: ID, titolo, permalink, data, numero di parole.

Avviare il punto d’ingresso e verificarlo con curl

Il server HTTP integrato di PHP basta, e ha un vantaggio: il punto d’ingresso ascolta su una porta distinta da quella del sito, solo sul loopback locale. Nulla è esposto pubblicamente. Da me, il tutto gira in un container usa e getta che si unisce alla rete Docker del blog.

bash
# Il token non viene mai scritto in un file del repository.
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

Il primo test è quello del rifiuto.

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

Poi la stretta di mano, come farebbe 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}

I tempi di risposta, misurati su questo mirror locale di 1.198 articoli: 0,11 s per tools/list, 0,02 s per site_stats, 0,07 s per search_posts, 0,03 s per get_post. L’essenziale è l’avvio di WordPress, non la richiesta.

E il controllo che conta: chiedere una bozza.

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

Collegarlo a Codex

Codex tiene i suoi server MCP in ~/.codex/config.toml. L’opzione -c evita di toccare quel file passando tutta la dichiarazione in linea di comando, ed è quello che ho fatto qui, con codex-cli 0.153.4 e 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."

Primo tentativo: fallimento istruttivo. Il server si collega, gli strumenti vengono scoperti, ma le chiamate si fermano di colpo.

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

In sessione interattiva, Codex ti chiederebbe di approvare ogni chiamata. In codex exec, nessuno può rispondere: la politica vale never, la chiamata viene rifiutata. L’impostazione si pone 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 è il compromesso giusto: solo gli strumenti dichiarati come non modificanti passano senza chiedere. Con questa impostazione e le annotazioni al loro posto, l’esecuzione riesce in venti secondi per 9.550 token:

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 »

Ho voluto sapere cosa, esattamente, sbloccava questa autorizzazione. Ho quindi riutilizzato lo stesso file privo delle sue annotazioni, senza cambiare nient’altro: la stessa chiamata è tornata MCP tool call requires approval. L’elemento scatenante non è quindi né il nome dello strumento né la sua descrizione, ma il flag che il server dichiara da solo.

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

Ricorda il senso della dipendenza: è il tuo server ad affermare di essere innocuo, ed è il client a crederci. Un server MCP di terze parti può mentire su questa riga. Il ragionamento vale quindi per codice che hai scritto tu, non per un server recuperato da un marketplace, argomento trattato in isolare Claude Code e Codex.

Dal lato di Claude Code

La dichiarazione sta in un comando. L’ambito project scrive un .mcp.json alla radice del progetto, condiviso dal team, l’ambito local lo tiene per te.

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

Due precauzioni. Il comando scrive il token in chiaro in un file fatto per essere versionato: sostituisci il valore con ${GK_MCP_TOKEN} a mano, come sopra (la documentazione accetta anche ${VAR:-predefinito}). Poi, un server aggiunto in ambito progetto non è attivo finché nessuno lo ha approvato: claude mcp list mostra Pending approval fino alla prima sessione.

In azienda, l’impostazione managedMcpServers comparsa in Claude Code 2.1.259 il 2 settembre 2026 permette di spingere server MCP HTTP a tutte le postazioni, nello stesso formato di .mcp.json: è lì che un punto d’ingresso in sola lettura ha il suo posto, piuttosto che nel repository di ciascuno.

Quali usi, e dove fermarsi?

Tre letture bastano per molto lavoro editoriale. La revisione SEO per prima: l’agente incatena search_posts e get_post, e lavora sul testo reale invece che sul suo ricordo del sito. La caccia ai contenuti orfani poi: get_post restituisce un campo liens_internes. Su un articolo di 2.064 parole pubblicato a gennaio 2023, Codex mi ha risposto «tre link interni», la verifica in SQL ne dà proprio tre.

La preparazione di bozze, infine, è il caso in cui bisogna resistere. La tentazione è aggiungere un quarto strumento create_draft. Non farlo nello stesso punto d’ingresso: un server che scrive è un server in cui ogni chiamata deve essere approvata, registrata e reversibile. Se ci tieni, fanne un secondo server, su un’altra porta, con un token proprio, e lascia default_tools_approval_mode su prompt. La stessa regola vale per PrestaShop, dove la superficie Webservice è ancora più ampia: vedi MCP per PrestaShop.

Per un sito in produzione, la via ufficiale resta la risposta giusta: installa WordPress/mcp-adapter, esponi solo le tue abilities con 'meta' => array( 'mcp' => array( 'public' => true ) ), e dai all’agente un account dedicato, non il tuo, con una password d’applicazione revocabile. I due approcci si completano: l’adattatore per ciò che WordPress sa già fare, un punto d’ingresso fatto in casa per ciò che vuoi delimitare al millimetro.

Cosa ricordare

  • Il plugin Automattic/wordpress-mcp è archiviato dal 19 gennaio 2026, la via ufficiale è WordPress/mcp-adapter, v0.6.1 del 13 agosto 2026.
  • Senza ability dichiarata, un adattatore MCP non espone nulla: il core di WordPress 7.1 ne registra solo tre, e core/get-site-info restituisce già l’indirizzo e-mail di amministrazione.
  • Un punto d’ingresso fatto in casa di 152 righe riduce 154 rotte REST e 296.774 byte di schema a tre strumenti e 1.081 byte.
  • Il vero blocco non è il ruolo WordPress ma il filtro query preregistrato prima di wp-load.php: ogni scrittura diventa un SELECT 1 registrato nel log.
  • In codex exec, uno strumento senza readOnlyHint viene rifiutato: è l’annotazione, e nient’altro, ad autorizzare la chiamata automatica in modalità writes.
  • Sotto Apache e mod_php, $_SERVER['HTTP_AUTHORIZATION'] è vuoto: leggi l’header con getallheaders(), altrimenti il tuo server risponderà 401 senza motivo apparente.

Errori frequenti

Credere che l'Abilities API sia aperta La rotta /wp-json/wp-abilities/v1/abilities restituisce 401 da anonimo, e una ability segnata readonly rifiuta il POST sulla sua rotta /run con un 405. Passa da GET e da un account autenticato.
Lasciare che Apache inghiotta l'header Authorization Con mod_php, $_SERVER['HTTP_AUTHORIZATION'] è vuoto mentre getallheaders() lo restituisce. Leggi entrambi, altrimenti il tuo punto d'ingresso risponde 401 senza che nulla lo spieghi.
Incollare il token in chiaro in .mcp.json claude mcp add --header scrive il valore così com'è in un file fatto per essere versionato. Sostituiscilo con ${GK_MCP_TOKEN} e tieni il segreto nell'ambiente.
Dimenticare le annotazioni dello strumento Senza readOnlyHint, Codex rifiuta la chiamata in codex exec anche con default_tools_approval_mode = "writes". Verificato con un controllo negativo: è l'annotazione a sbloccare, nient'altro.
Contare sul ruolo WordPress per vietare la scrittura Un ruolo limita l'utente, non il codice caricato durante l'avvio. Il blocco affidabile è il filtro query preregistrato in $wp_filter prima di wp-load.php.

Claude CodeCodexMCPPHPSécuritéWordPress

Damien Flandrin Sviluppatore web dal 2010, creatore di Gekkode e di Email Impact. Ogni articolo è testato su un progetto reale prima della pubblicazione. Contatti
Newsletter

I nuovi test, tutorial e progetti, via e-mail.

Test riproducibili, codice versionato, risultati datati. Mai spam.