Pour que Claude lise et modifie un site WordPress, il faut trois briques : le plugin MCP Adapter, qui expose des « abilities » WordPress comme des outils MCP, un compte dédié avec un mot de passe d’application, et un mu-plugin qui déclare ce que Claude a le droit de faire. Le mu-plugin est la brique qui compte, c’est lui qui pose les limites.
Les garde-fous (liste d’ID, sauvegarde, test à blanc) sont expliqués dans une note précédente. Celle-ci montre comment brancher le tout.
1. Le plugin MCP Adapter
Le plugin officiel MCP Adapter s’appuie sur l’API Abilities de WordPress (WordPress 6.9 ou plus). Une fois installé, il publie un serveur MCP à l’adresse /wp-json/mcp/mcp-adapter-default-server. Les abilities déclarées par le site deviennent des outils pour l’agent.
2. Un compte dédié
Créez un utilisateur réservé à l’agent (rôle Éditeur suffit) et un mot de passe d’application. Ne mettez jamais ce mot de passe dans un fichier de configuration : variables d’environnement WP_API_USERNAME et WP_API_PASSWORD, définies sur votre poste.
Côté Claude, le serveur se déclare ainsi :
claude mcp add wordpress --scope user \
-e WP_API_URL=https://monsite.com/wp-json/mcp/mcp-adapter-default-server \
-- npx -y @automattic/mcp-wordpress-remote
3. Le mu-plugin
Un mu-plugin est un fichier PHP déposé dans wp-content/mu-plugins/ (le dossier n’existe pas toujours, créez-le). Il se charge seul, sans activation, et ne peut pas être désactivé depuis l’administration.
Voici un squelette simplifié : une ability de lecture, une ability d’écriture limitée aux brouillons et à une liste d’ID, avec test à blanc et sauvegarde. À tester sur une préproduction avant la production.
<?php
/**
* Plugin Name: Abilities Claude (exemple)
* Requires at least: 6.9
*/
defined( 'ABSPATH' ) || exit;
const CLAUDE_USERS = array( 'claude-mcp' );
const CLAUDE_WRITABLE_IDS = array(); // IDs publiés ouverts à l'écriture
const CLAUDE_BACKUP_META = '_claude_backup';
// Les mots de passe d'application ne sont possibles que pour le compte de l'agent.
add_filter( 'wp_is_application_passwords_available_for_user', function ( $ok, $user ) {
return $ok && in_array( $user->user_login, CLAUDE_USERS, true );
}, 10, 2 );
function claude_allowed_user() {
$u = wp_get_current_user();
return $u->exists() && in_array( $u->user_login, CLAUDE_USERS, true ) && current_user_can( 'edit_posts' );
}
function claude_writable_post( $id ) {
$post = get_post( absint( $id ) );
if ( ! $post || 'post' !== $post->post_type ) {
return new WP_Error( 'not_found', 'Contenu introuvable.' );
}
$listed = in_array( $post->ID, CLAUDE_WRITABLE_IDS, true );
$draft = in_array( $post->post_status, array( 'draft', 'pending' ), true );
if ( ! $listed && ! $draft ) {
return new WP_Error( 'not_writable', 'Publié et hors de la liste autorisée.' );
}
return $post;
}
add_action( 'wp_abilities_api_categories_init', function () {
wp_register_ability_category( 'claude-content', array(
'label' => 'Contenus',
'description' => 'Lecture et écriture bornée des articles.',
) );
} );
add_action( 'wp_abilities_api_init', function () {
wp_register_ability( 'claude/list-content', array(
'label' => 'Lister les articles',
'description' => 'ID, titre, URL et statut des articles. Lecture seule.',
'category' => 'claude-content',
'input_schema' => array(
'type' => 'object',
'properties' => array( 'search' => array( 'type' => 'string' ) ),
'additionalProperties' => false,
),
'output_schema' => array( 'type' => 'object' ),
'permission_callback' => 'claude_allowed_user',
'execute_callback' => function ( $input = array() ) {
$input = (array) $input;
$q = new WP_Query( array(
'post_type' => 'post',
'post_status' => 'any',
'posts_per_page' => 50,
's' => sanitize_text_field( $input['search'] ?? '' ),
) );
$items = array();
foreach ( $q->posts as $p ) {
$items[] = array( 'id' => $p->ID, 'title' => $p->post_title, 'url' => get_permalink( $p ), 'status' => $p->post_status );
}
return array( 'items' => $items );
},
'meta' => array( 'public' => true, 'annotations' => array( 'readonly' => true, 'destructive' => false, 'idempotent' => true ) ),
) );
wp_register_ability( 'claude/update-content', array(
'label' => 'Modifier un article',
'description' => 'Titre et extrait. Brouillons ou IDs autorisés, sauvegarde automatique, dry_run possible.',
'category' => 'claude-content',
'input_schema' => array(
'type' => 'object',
'properties' => array(
'id' => array( 'type' => 'integer' ),
'title' => array( 'type' => 'string' ),
'excerpt' => array( 'type' => 'string' ),
'dry_run' => array( 'type' => 'boolean' ),
),
'required' => array( 'id' ),
'additionalProperties' => false,
),
'output_schema' => array( 'type' => 'object' ),
'permission_callback' => 'claude_allowed_user',
'execute_callback' => function ( $input = array() ) {
$input = (array) $input;
$post = claude_writable_post( $input['id'] ?? 0 );
if ( is_wp_error( $post ) ) {
return $post;
}
$data = array( 'ID' => $post->ID );
if ( isset( $input['title'] ) ) {
$data['post_title'] = sanitize_text_field( $input['title'] );
}
if ( isset( $input['excerpt'] ) ) {
$data['post_excerpt'] = sanitize_textarea_field( $input['excerpt'] );
}
if ( ! empty( $input['dry_run'] ) ) {
return array( 'dry_run' => true, 'would_write' => $data );
}
$backups = get_post_meta( $post->ID, CLAUDE_BACKUP_META, true );
$backups = is_array( $backups ) ? $backups : array();
$backup_id = uniqid();
$backups[] = array( 'id' => $backup_id, 'title' => $post->post_title, 'excerpt' => $post->post_excerpt );
update_post_meta( $post->ID, CLAUDE_BACKUP_META, wp_slash( array_slice( $backups, -10 ) ) );
$r = wp_update_post( wp_slash( $data ), true );
return is_wp_error( $r ) ? $r : array( 'id' => $post->ID, 'backup_id' => $backup_id );
},
'meta' => array( 'public' => true, 'annotations' => array( 'readonly' => false, 'destructive' => false, 'idempotent' => true ) ),
) );
} );
Ce squelette ne touche jamais au statut d’un contenu : pas de publication ni de suppression par l’agent. Pour ouvrir une page publiée, ajoutez son ID à CLAUDE_WRITABLE_IDS, rien d’autre.
Les pièges rencontrés
- Le corps de la page n’est pas toujours dans
post_content. Avec un thème qui range tout dans des blocs ACF, l’API REST ne les expose pas. L’ability doit les lire elle-même, avec les fonctions d’ACF. - La session MCP expire. Un jour, l’agent ne trouvait plus les abilities (« Ability not found ») alors que le mu-plugin répondait. Repli : l’API REST des Abilities, en
POST /wp-json/wp-abilities/v1/abilities/<nom>/runavec{"input": …}, et enGET ?input[id]=…pour les abilities en lecture seule. - Une fausse erreur à l’écriture.
update_fieldrenvoiefalsequand la valeur est identique à l’existante, alors que l’écriture a réussi. Relisez toujours le contenu après une écriture. - L’UTF-8 sous Windows. Envoyer du JSON avec
curldepuis un terminal Windows casse les accents (erreurrest_invalid_json). Utilisez un client Python, et l’en-têteMCP-Protocol-Versionpour les appels HTTP directs. - Le rôle Éditeur ne lit pas tout. Les scores Yoast renvoient une erreur 403 avec ce rôle : lisez les métadonnées dans l’ability, avec une liste blanche de clés.
- Un mu-plugin peut disparaître sans prévenir. Quelqu’un l’a retiré du serveur et les abilities ont cessé de répondre. Il suffit de redéposer le fichier, mais prévenez votre équipe qu’il existe.
- Ne crawlez pas le site avec l’agent. Passez par l’ability plutôt que par des requêtes en rafale : c’est l’erreur qui m’a fait bloquer par mon propre pare-feu.
À retenir
L’agent ne peut faire que ce que le mu-plugin déclare. Commencez par la lecture, ajoutez une écriture limitée aux brouillons, puis ouvrez des pages publiées une par une avec une sauvegarde automatique. Relisez après chaque écriture.