MCP für WordPress, ohne einem Agenten die Schlüssel zu geben

Drei Wege, Claude Code oder Codex an WordPress anzubinden, plus ein eigener MCP-Endpunkt mit 152 Zeilen, schreibgeschützt, geschrieben und getestet gegen ein lokales WordPress 7.1.

MCP für WordPress, ohne einem Agenten die Schlüssel zu geben
Schnelle Antwort

Es gibt drei Wege, einen Agenten an WordPress anzubinden: die Abilities API des Kerns (nur drei Abilities), den offiziellen Adapter WordPress/mcp-adapter (v0.6.1 vom 13. August 2026) und einen eigenen Endpunkt. Der dritte ist der einzige, bei dem du alles selbst bestimmst: 152 Zeilen PHP, drei Lese-Werkzeuge, ein eigenes Token und ein SQL-Filter, der jede Schreibaktion in ein SELECT 1 verwandelt. Hier getestet mit curl und anschließend mit Codex, gegen ein lokales WordPress 7.1.

Einen Code-Agenten an WordPress anzubinden bedeutet, ihm Zugriff auf deine Artikel, deine Entwürfe und, sehr schnell, das Schreibrecht zu geben. Zwischen dem Administrator-Passwort und der schlichten Verweigerung gibt es eine haltbare Position: einen MCP-Endpunkt, den du selbst schreibst, der nur drei Lesevorgänge freigibt und der schlicht nicht in der Lage ist, in die Datenbank zu schreiben, selbst wenn das Modell danach fragt.

Dieser Artikel bleibt dicht an WordPress. Für das Protokoll selbst, JSON-RPC, Transporte, den initialize-Zyklus, lies einen MCP-Server in PHP bauen, hier geht es um wp-load.php, $wpdb und Anwendungspasswörter.

Was WordPress im September 2026 bietet

Drei Wege gibt es, und sie schließen sich nicht aus. Der Beitrag From Abilities to AI agents, veröffentlicht am 4. Februar 2026 auf developer.wordpress.org, legt die Doktrin fest. Hier ihr Stand am 7. September 2026, Repository für Repository geprüft.

Weg Status Was er verlangt
Abilities API des Kerns In WordPress seit 6.9, drei Abilities auf meinem WordPress 7.1 registriert Nichts zu installieren, aber allein wird nichts über MCP freigegeben
Offizieller MCP-Adapter (WordPress/mcp-adapter) Aktiv: v0.6.1 vom 13. August 2026, letzter Commit am 2. September 2026, 1.672 Sterne Ein Plugin oder ein composer require, dann ein Anwendungspasswort oder OAuth 2.1
Plugin Automattic/wordpress-mcp Archiviert: letzter Commit am 30. August 2025, schreibgeschütztes Repository seit dem 19. Januar 2026 Nichts mehr: Es verweist auf den offiziellen Adapter

Die Umstellung ist noch nicht überall angekommen: Das LWS-Tutorial, veröffentlicht am 2. September 2025, beschreibt weiterhin das Automattic-Plugin als Hauptweg, während WPFormation, aktualisiert am 13. August 2026, zum offiziellen Adapter gewechselt ist. Die GitHub-API entscheidet: archived: true auf der einen Seite, archived: false auf der anderen.

Was die Abilities API deines WordPress enthält

Die Abilities API ist das Fundament: ein Register deklarierter Fähigkeiten, jeweils mit einem Eingabeschema, einem Ausgabeschema, einer Kategorie und einer Berechtigungsprüfung. Der MCP-Adapter übersetzt dieses Register nur in MCP-Werkzeuge. Anders gesagt: ohne Ability gibt ein MCP-Adapter nichts frei.

Auf dem WordPress 7.1 meiner lokalen Umgebung registriert der Kern drei davon. Die erste Überraschung: Die REST-Route ist nicht offen:

bash
curl -s http://localhost:8090/wp-json/wp-abilities/v1/abilities
# {"code":"rest_forbidden","message":"Entschuldigung, das darfst du nicht tun.",
#  "data":{"status":401}}

Als Administrator, über WP-CLI und rest_do_request(), um nicht den kleinsten Zugangsdatensatz anlegen zu müssen, passt die Liste in drei Zeilen:

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

Zwei Details zählen für das Weitere. Jede Ability trägt zunächst Annotationen, und die sind verbindlich. core/get-site-info deklariert meta.annotations.readonly = true, und die Ausführungsroute lehnt dann den POST ab:

bash
# POST auf eine schreibgeschützte Ability
# HTTP 405 — {"code":"rest_ability_invalid_method",
#             "message":"Schreibgeschützte Abilities erfordern die GET-Methode."}

# GET auf derselben Route, als Administrator: HTTP 200
# {"name":"Gekkode","url":"http://localhost:8090","admin_email":"c***@gekkode.com",
#  "charset":"UTF-8","language":"fr-FR","version":"7.1"}

Das zweite Detail steht in der Antwort selbst, admin_email. Die harmloseste Ability des Kerns, die als „schreibgeschützt, nicht destruktiv, idempotent“ markiert ist, gibt die Administrations-E-Mail-Adresse der Website zurück. Ein Agent, der sie aufruft, legt diese Adresse in seinen Kontext, und dieser Kontext wandert zu einem Modellanbieter. Das ist genau die Art von Leck, die keinen Alarm auslöst: Keine Daten wurden verändert, alles ist genau wie geplant abgelaufen.

Derselbe Aufruf als anonymer Besucher liefert 401 rest_ability_cannot_execute: Die WordPress-Berechtigungen werden korrekt eingehalten. Das Berechtigungsmodell tut seine Arbeit. Das Problem kommt von dem Konto, das du dem Agenten gibst, mit der Erlaubnis, alles zu lesen.

Warum einen eigenen Endpunkt schreiben?

Weil die Standardfläche riesig ist. Auf dieser Website deklariert der Index der REST-API 154 Routen für 296.774 Byte Schema. Ein Agent, der diese Fläche erkundet, bezahlt dafür in Tokens, und nichts hindert ihn daran, die Route zu finden, die schreibt.

Der aussagekräftigste Vergleich betrifft eine identische Suche:

Aufruf Größe der Antwort
GET /wp/v2/posts?search=docker&per_page=5 116.227 Byte
Derselbe mit _fields=id,title,link,date 1.080 Byte
search_posts("docker", 5), mein MCP-Werkzeug 1.499 Byte

Lies die dritte Zeile genau: Mein Werkzeug ist größer als die gut parametrierte REST-API, weil es zusätzlich die Wörter zählt. Der Gewinn kommt also nicht von MCP selbst, sondern davon, dass die Form der Antwort ein für alle Mal von dir entschieden wird, statt dem Modell überlassen zu werden, das keinen Grund hat, an _fields zu denken. Der Faktor 78 zwischen den rohen Aufrufen ist das, was du beim Schreiben des Werkzeugs festlegst.

Den schreibgeschützten Endpunkt schreiben

Die Datei hat 152 Zeilen tatsächlichen Code. Sie lädt wp-load.php, stellt drei Werkzeuge bereit und setzt zwei Sperren. Die erste ist ein eigenes Bearer-Token, aus der Umgebung gelesen: Das ist weder ein WordPress-Passwort noch ein Anwendungspasswort, und es gewährt keinen Zugriff auf irgendetwas außer diesen drei Lesevorgängen.

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

// Unter Apache/mod_php ist $_SERVER['HTTP_AUTHORIZATION'] leer: Der Header kommt nur
// über getallheaders() an. Gemessen mit 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;
}

Die zweite Sperre ist das Herzstück der Konstruktion. Die WordPress-Rolle einzuschränken reicht nicht: Jede während des Starts geladene Erweiterung kann trotzdem schreiben. Also schneiden wir tiefer, auf SQL-Ebene. WordPress leitet jede Anfrage durch den Filter query (wp-includes/class-wpdb.php), und es weiß, wie man Filter lädt, die vor ihm deklariert wurden: wp-includes/plugin.php ruft in Zeile 41 WP_Hook::build_preinitialized_hooks( $wp_filter ) auf. Genau dort setzen wir an.

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

// Gelesen von WP_Hook::build_preinitialized_hooks() beim Laden von 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';

Jede Anfrage, die keine Lesung ist, wird durch ein SELECT 1 ersetzt und protokolliert. Ich habe die Sperre mit einem gezielten DELETE geprüft, das so geschrieben war, dass es auf keine einzige Zeile passt: Fiele der Filter aus, würde nichts zerstört.

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

# Versuchte Schreibvorgänge beim Start von WordPress: 0
# Nach einem gezielten DELETE, tatsächlich gesendete Anfrage: SELECT 1 /* écriture refusée */
# Insgesamt blockierte Schreibvorgänge: 1
#   - DELETE FROM gk_options WHERE option_id = 0

Null Schreibvorgänge beim Start: Auf dieser Website versucht WordPress bei einem einfachen Laden nichts an der Datenbank. Das ist eine gute Nachricht, keine Garantie: Füge eine Erweiterung hinzu, und die Zahl ändert sich. Die Sperre ist für diesen Tag da.

Bleiben die drei Werkzeuge. Sie sind bewusst karg: search_posts sieht nur veröffentlichte Artikel, get_post lehnt alles ab, was kein veröffentlichter Artikel ist, und site_stats zählt nur. Jedes ist mit readOnlyHint annotiert, du wirst weiter unten sehen, dass das nicht dekorativ ist.

php
case 'search_posts':
	$q = new WP_Query( array(
		'post_type'      => 'post',
		'post_status'    => 'publish',           // nie Entwürfe
		's'              => (string) ( $args['query'] ?? '' ),
		'posts_per_page' => min( 20, max( 1, (int) ( $args['per_page'] ?? 5 ) ) ),
		'no_found_rows'  => true,
	) );
	// dann für jeden Artikel: ID, Titel, Permalink, Datum, Wortzahl.

Den Einstiegspunkt starten und mit curl prüfen

Der in PHP integrierte HTTP-Server reicht aus, und er hat einen Vorteil: Der Endpunkt lauscht auf einem eigenen Port, getrennt von dem der Website, nur auf der lokalen Loopback-Schnittstelle. Nichts wird öffentlich freigegeben. Bei mir läuft das Ganze in einem Wegwerf-Container, der sich dem Docker-Netzwerk des Blogs anschließt.

bash
# Das Token wird nie in eine Datei des Repositorys geschrieben.
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

Der erste Test ist der der Ablehnung.

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

Dann der Handshake, so wie ihn ein MCP-Client machen würde:

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}

Die Antwortzeiten, gemessen auf diesem lokalen Spiegel mit 1.198 Artikeln: 0,11 s für tools/list, 0,02 s für site_stats, 0,07 s für search_posts, 0,03 s für get_post. Das meiste davon ist der Start von WordPress, nicht die Abfrage selbst.

Und die Prüfung, die zählt: nach einem Entwurf fragen.

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

Ihn an Codex anbinden

Codex hält seine MCP-Server in ~/.codex/config.toml. Die Option -c erspart es, diese Datei anzufassen, indem sie die ganze Deklaration auf der Kommandozeile übergibt, genau das habe ich hier gemacht, mit codex-cli 0.153.4 und 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."

Erster Versuch: ein lehrreicher Fehlschlag. Der Server verbindet sich, die Werkzeuge werden entdeckt, aber die Aufrufe bleiben abrupt stehen.

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

In einer interaktiven Sitzung würde Codex dich bitten, jeden Aufruf freizugeben. In codex exec ist niemand da, um zu antworten: Die Richtlinie steht auf never, der Aufruf wird abgelehnt. Die Einstellung wird pro Server gesetzt:

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 ist der richtige Kompromiss: Nur die als nicht verändernd angekündigten Werkzeuge kommen ohne Nachfrage durch. Mit dieser Einstellung und den Annotationen an ihrem Platz gelingt die Ausführung in zwanzig Sekunden für 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 »

Ich wollte wissen, was genau diese Freigabe auslöste. Also habe ich dieselbe Datei ohne ihre Annotationen erneut verwendet, ohne sonst etwas zu ändern: Derselbe Aufruf wurde wieder zu MCP tool call requires approval. Der Auslöser ist also weder der Name des Werkzeugs noch seine Beschreibung, sondern das Flag, das der Server selbst deklariert.

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

Merk dir die Richtung dieses Vertrauens: Dein Server behauptet, harmlos zu sein, und der Client glaubt es. Ein MCP-Server von Dritten kann bei genau dieser Zeile lügen. Die Überlegung gilt also für Code, den du selbst geschrieben hast, nicht für einen Server, den du dir von einem Marktplatz geholt hast, ein Thema, das in Claude Code und Codex absichern behandelt wird.

Auf der Seite von Claude Code

Die Deklaration passt in einen Befehl. Der Geltungsbereich project schreibt eine .mcp.json im Wurzelverzeichnis des Projekts, geteilt mit dem Team, der Geltungsbereich local behält sie für dich.

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

Zwei Vorsichtsmaßnahmen. Der Befehl schreibt das Token im Klartext in eine Datei, die versioniert werden soll: Ersetze den Wert von Hand durch ${GK_MCP_TOKEN}, wie oben gezeigt (die Dokumentation akzeptiert auch ${VAR:-Standard}). Zweitens ist ein im Projekt-Geltungsbereich hinzugefügter Server nicht aktiv, solange ihn niemand freigegeben hat: claude mcp list zeigt bis zur ersten Sitzung Pending approval an.

Im Unternehmen erlaubt die Einstellung managedMcpServers, die am 2. September 2026 mit Claude Code 2.1.259 kam, HTTP-MCP-Server im selben Format wie .mcp.json auf alle Arbeitsplätze zu verteilen: Dorthin gehört ein schreibgeschützter Endpunkt, statt in das Repository jedes Einzelnen.

Welche Anwendungen, und wo aufhören?

Drei Lesevorgänge reichen für viel redaktionelle Arbeit. Zuerst die SEO-Durchsicht: Der Agent reiht search_posts und get_post aneinander und arbeitet am echten Text statt an seiner Erinnerung an die Website. Dann die Jagd auf verwaiste Inhalte: get_post liefert ein Feld liens_internes. Bei einem im Januar 2023 veröffentlichten Artikel mit 2.064 Wörtern antwortete mir Codex mit „drei interne Links“, die Überprüfung per SQL ergibt tatsächlich drei.

Das Vorbereiten von Entwürfen ist schließlich der Fall, bei dem du widerstehen musst. Die Versuchung ist, ein viertes Werkzeug create_draft hinzuzufügen. Tu das nicht im selben Endpunkt: Ein Server, der schreibt, ist ein Server, bei dem jeder Aufruf freigegeben, protokolliert und umkehrbar sein muss. Wenn du wirklich willst, mach daraus einen zweiten Server, auf einem anderen Port, mit eigenem Token, und lass default_tools_approval_mode auf prompt. Dieselbe Regel gilt für PrestaShop, wo die Webservice-Fläche noch größer ist: siehe MCP für PrestaShop.

Für eine Website in Produktion bleibt der offizielle Weg die richtige Antwort: Installiere WordPress/mcp-adapter, gib nur deine eigenen Abilities frei mit 'meta' => array( 'mcp' => array( 'public' => true ) ), und gib dem Agenten ein eigenes Konto, nicht dein eigenes, mit einem widerrufbaren Anwendungspasswort. Die beiden Ansätze ergänzen sich: der Adapter für das, was WordPress bereits kann, ein eigener Endpunkt für das, was du millimetergenau begrenzen willst.

Was du dir merken solltest

  • Das Plugin Automattic/wordpress-mcp ist seit dem 19. Januar 2026 archiviert, der offizielle Weg ist WordPress/mcp-adapter, v0.6.1 vom 13. August 2026.
  • Ohne deklarierte Ability gibt ein MCP-Adapter nichts frei: Der Kern von WordPress 7.1 registriert nur drei, und core/get-site-info gibt schon die Administrations-E-Mail-Adresse zurück.
  • Ein eigener Endpunkt mit 152 Zeilen bringt 154 REST-Routen und 296.774 Byte Schema auf drei Werkzeuge und 1.081 Byte herunter.
  • Die eigentliche Sperre ist nicht die WordPress-Rolle, sondern der vor wp-load.php vorregistrierte Filter query: Jeder Schreibvorgang wird zu einem protokollierten SELECT 1.
  • In codex exec wird ein Werkzeug ohne readOnlyHint abgelehnt: Es ist die Annotation, und nichts sonst, die den automatischen Aufruf im Modus writes erlaubt.
  • Unter Apache und mod_php ist $_SERVER['HTTP_AUTHORIZATION'] leer: Lies den Header mit getallheaders(), sonst antwortet dein Server ohne erkennbaren Grund mit 401.

Häufige Fehler

Glauben, die Abilities API sei offen Die Route /wp-json/wp-abilities/v1/abilities liefert anonym 401, und eine als readonly markierte Ability lehnt POST auf ihrer Route /run mit 405 ab. Nutze GET und ein authentifiziertes Konto.
Apache den Authorization-Header schlucken lassen Unter mod_php ist $_SERVER['HTTP_AUTHORIZATION'] leer, während getallheaders() ihn liefert. Lies beide, sonst antwortet dein Endpunkt mit 401, ohne dass etwas das erklärt.
Das Token im Klartext in .mcp.json einfügen claude mcp add --header schreibt den Wert unverändert in eine Datei, die versioniert werden soll. Ersetze ihn durch ${GK_MCP_TOKEN} und behalte das Geheimnis in der Umgebung.
Die Werkzeug-Annotationen vergessen Ohne readOnlyHint lehnt Codex den Aufruf in codex exec ab, selbst mit default_tools_approval_mode = "writes". Per Negativkontrolle geprüft: Es ist die Annotation, die freischaltet, nichts sonst.
Sich auf die WordPress-Rolle verlassen, um Schreiben zu verhindern Eine Rolle begrenzt den Benutzer, nicht den während des Starts geladenen Code. Die verlässliche Sperre ist der in $wp_filter vor wp-load.php vorregistrierte Filter query.

Claude CodeCodexMCPPHPSécuritéWordPress

Damien Flandrin Webentwickler seit 2010, Gründer von Gekkode und Email Impact. Jeder Artikel wird vor der Veröffentlichung an einem echten Projekt getestet. Kontakt
Newsletter

Neue Tests, Tutorials und Projekte, per E-Mail.

Reproduzierbare Tests, versionierter Code, datierte Ergebnisse. Niemals Spam.