Piloter WordPress par MCP — le serveur, pas l’API d’administration

Quatrieme notebook de la serie « AI Engine par son API ». Les grains precedents ont utilise l’API REST d’administration (mwai/v1) : lire les chatbots, ecrire des formulaires. Ce notebook ouvre l’autre face du plugin : WordPress devient un serveur MCP — un endpoint JSON-RPC (mcp/v1/http) que consomme non pas un administrateur, mais un agent : Claude, un IDE, ou le client HTTP de ce notebook.

Le protocole complet est demontre sur l’instance jetable : le handshake (initialize), le catalogue d’outils (tools/list), puis de vrais appels (tools/call) — jusqu’a creer et supprimer un article : un client externe qui agit sur le site. Et la frontiere de securite, mesuree : sans credentials, tout refuse.

Les sorties de ce notebook proviennent d’une execution reelle contre l’instance locale (voir « Provenance et limites », en fin de fichier).

La serie « AI Engine par son API »

Le projet Livres Agites a mis AI Engine au coeur d’une maison d’edition : bot d’accueil, agents d’ateliers, bibliothecaire documentee par RAG, formulaires dynamiques. Cette serie presente le plugin de maniere reproductible — sans jamais exposer de donnees client :

Notebook Contenu
presenter-ai-engine-par-son-api instance, API, catalogue des chatbots, premiere completion
configurer-chatbots-par-l-api lire, dupliquer, ecrire et interroger des chatbots (document JSON global)
administrer-les-formulaires-par-l-api le formulaire comme contenu : CRUD, publication, rendu public
piloter-wordpress-par-mcp (ce notebook) le serveur MCP : handshake, catalogue d’outils, appels reels
notebooks suivants RAG/embeddings, multi-provider, …

Trois niveaux de lecture, dans chaque notebook :

  1. Decouverte — ce que fait la fonctionnalite, vue par l’API ;
  2. Branchement — comment on l’a branchee dans le projet ;
  3. Exercice — reutiliser le pattern sur un cas voisin.
# Configuration et helpers. Aucune cle ni adresse de provider n'est stockee
# dans ce fichier : tout vient de instance-jetable/.env (README, etape 5).

import base64
import json
import os
import re
from pathlib import Path

import requests
from dotenv import load_dotenv

# Localisation du .env : a cote du notebook (instance-jetable/.env),
# sinon dans le repertoire courant.
charges = []
for candidat in (Path("instance-jetable/.env"), Path(".env")):
    if candidat.exists():
        load_dotenv(candidat)
        charges.append(str(candidat))
print("Fichiers .env charges :", charges or "(aucun)")

BASE_URL = os.getenv("VALMONT_BASE_URL", "http://localhost:8093").rstrip("/")
ADMIN_USER = os.getenv("VALMONT_ADMIN_USER", "")
APP_PASSWORD = os.getenv("VALMONT_APP_PASSWORD", "")
print("Base URL :", BASE_URL)


def entetes(auth=True):
    """Entetes JSON-RPC ; l'authentification est un application password."""
    h = {"Content-Type": "application/json",
         "Accept": "application/json, text/event-stream"}
    if auth and ADMIN_USER and APP_PASSWORD:
        creds = base64.b64encode(f"{ADMIN_USER}:{APP_PASSWORD}".encode()).decode()
        h["Authorization"] = "Basic " + creds
    return h


def mcp_rpc(methode, params=None, auth=True):
    """Un appel JSON-RPC au serveur MCP. Retourne (statut_http, dict|None).

    La reponse peut venir en JSON ou en flux SSE (text/event-stream) :
    dans le second cas on extrait le premier bloc data:.
    """
    corps = {"jsonrpc": "2.0", "id": 1, "method": methode}
    if params is not None:
        corps["params"] = params
    r = requests.post(BASE_URL + "/wp-json/mcp/v1/http", headers=entetes(auth),
                      json=corps, timeout=120)
    texte = r.text
    if "text/event-stream" in r.headers.get("content-type", ""):
        for ligne in texte.splitlines():
            if ligne.startswith("data:"):
                texte = ligne[len("data:"):].strip()
                break
    try:
        return r.status_code, json.loads(texte)
    except (ValueError, TypeError):
        return r.status_code, None


def texte_outil(reponse):
    """Extrait le texte du premier bloc de contenu d'un tools/call."""
    return reponse["result"]["content"][0]["text"]
Fichiers .env charges : ['instance-jetable\\.env']
Base URL : http://localhost:8093

Deux faces du plugin, deux contrats

AI Engine expose deux familles d’interfaces sur le meme WordPress :

API REST mwai/v1 (grains 1-3) Serveur MCP mcp/v1/http (ce notebook)
Style REST : une route par famille, GET/POST JSON-RPC 2.0 : une route, des methodes
Consommateur l’administrateur (scripts, tableaux de bord) un agent (Claude, un IDE, un client MCP)
Decouverte catalogue de routes (GET /mwai/v1) handshake puis tools/list
Contrat reponse JSON ad hoc par route reponse normalisee : content[], isError
Authentification application password application password (ou OAuth, pour les clients distants)

Meme site, memes donnees — mais le contrat MCP est fait pour etre decouvert puis utilise par une machine : le client annonce ses capacites, le serveur repond par les siennes, et tout appel d’outil passe par une signature declaree (JSON Schema), pas par une documentation a lire.

# 1. Le handshake : initialize
statut, reponse = mcp_rpc("initialize", {
    "protocolVersion": "2025-03-26",
    "capabilities": {},
    "clientInfo": {"name": "notebook-coursia", "version": "1.0"},
})
print("statut HTTP :", statut)
resultat = reponse["result"]
print("serveur     :", resultat["serverInfo"])
print("version du protocole :", resultat["protocolVersion"], "(nous avions propose 2025-03-26)")

session_id = None
probe = requests.post(BASE_URL + "/wp-json/mcp/v1/http", headers=entetes(),
                      json={"jsonrpc": "2.0", "id": 1, "method": "initialize",
                            "params": {"protocolVersion": "2025-03-26",
                                       "capabilities": {},
                                       "clientInfo": {"name": "probe", "version": "1"}}},
                      timeout=60)
session_id = probe.headers.get("mcp-session-id")
print("session-id renvoye par le serveur :", (session_id or "aucun")[:20] + "...")

# Notification d'initialisation : pas de reponse attendue (notification JSON-RPC)
requests.post(BASE_URL + "/wp-json/mcp/v1/http", headers=entetes(),
              json={"jsonrpc": "2.0", "method": "notifications/initialized"}, timeout=60)
print("notifications/initialized envoyee")
statut HTTP : 200
serveur     : {'name': 'AI Engine - Maison Valmont', 'version': '0.0.1'}
version du protocole : 2025-06-18 (nous avions propose 2025-03-26)
session-id renvoye par le serveur : 87bfc920-f561-4bef-9...
notifications/initialized envoyee

Ce que dit le handshake

Trois choses se jouent en un appel. D’abord l’identite : le serveur se nomme — AI Engine - Maison Valmont, le nom du site est embarque. Ensuite la negociation de version : le client propose 2025-03-26, le serveur repond 2025-06-18 — c’est la version qu’il appliquera ; un client rigoureux se raccroche a ce que le serveur renvoie, pas a ce qu’il a demande. Enfin la session : le serveur renvoie un identifiant de session — mais les appels suivants fonctionnent aussi sans le renvoyer (serveur tolerant aux clients etatless, ce que ce notebook verifie en practice).

Le catalogue : tools/list

Le client decouvre ensuite ce qu’il peut faire. tools/list renvoie la liste des outils avec, pour chacun : nom, description, et le JSON Schema de ses parametres — la signature complete, exploitable par un LLM pour decider quoi appeler et comment.

# 2. Le catalogue : tools/list
statut, reponse = mcp_rpc("tools/list")
outils = reponse["result"]["tools"]
print("statut HTTP :", statut, "| outils :", len(outils))

for outil in outils[:8]:
    params = sorted((outil.get("inputSchema") or {}).get("properties", {}))
    print(f"  {outil['name']:28s} params={params}")

# Recoupement avec le miroir REST (mwai/v1/mcp/functions, vu au grain 1)
creds = base64.b64encode(f"{ADMIN_USER}:{APP_PASSWORD}".encode()).decode()
r = requests.get(BASE_URL + "/wp-json/mwai/v1/mcp/functions",
                 headers={"Authorization": "Basic " + creds}, timeout=60).json()
noms_rest = {f["name"] for f in r["functions"]}
noms_mcp = {o["name"] for o in outils}
print()
print("miroir REST mcp/functions :", r["count"], "outils")
print("dans tools/list mais pas dans le miroir :", sorted(noms_mcp - noms_rest))
print("dans le miroir mais pas dans tools/list :", sorted(noms_rest - noms_mcp) or "aucun")
statut HTTP : 200 | outils : 43
  mcp_ping                     params=[]
  wp_list_plugins              params=['search']
  wp_get_users                 params=['limit', 'offset', 'paged', 'role', 'search']
  wp_create_user               params=['display_name', 'role', 'user_email', 'user_login', 'user_pass']
  wp_update_user               params=['ID', 'fields']
  wp_get_comments              params=['author_email', 'limit', 'offset', 'paged', 'post_id', 'search', 'status', 'type', 'user_id']
  wp_create_comment            params=['comment_approved', 'comment_author', 'comment_author_email', 'comment_author_url', 'comment_content', 'post_id']
  wp_update_comment            params=['comment_ID', 'fields']

miroir REST mcp/functions : 42 outils
dans tools/list mais pas dans le miroir : ['mcp_ping']
dans le miroir mais pas dans tools/list : aucun

Ce que le catalogue revele de la surface

Quarante-trois outils, chacun avec ses parametres declares : le catalogue est un contrat, pas une documentation au kilometre. Les prefixes racontent la taxonomie avant meme qu’on lise les noms – wp_get_* pour lire, wp_create_* et wp_update_* pour ecrire, et des utilitaires de protocole comme mcp_ping qui ne touchent WordPress que de loin. Un auditeur de surface d’attaque lirait cette liste autrement : chaque outil d’ecriture (wp_create_user, wp_update_user, …) est une action que le porteur du header d’autorisation pourra demander telle quelle, sans interface, sans rate-limit visible, sans journal d’interface.

Pour la suite du notebook, ce contrat a une vertu pratique : les parametres annonces ici sont exactement ceux que tools/call acceptera – inutile de deviner la forme des arguments, le serveur l’a declaree.

Lire par MCP

tools/call execute un outil par son nom, avec ses arguments. Deux lectures innocentes pour commencer : compter les contenus, lister les articles. La reponse arrive dans content[0].text — du texte (souvent du JSON serialize), le format que consommera l’agent.

# 3. Lire par MCP : compter, lister
statut, reponse = mcp_rpc("tools/call", {"name": "wp_count_posts", "arguments": {}})
print("wp_count_posts ->", statut)
print(texte_outil(reponse))

statut, reponse = mcp_rpc("tools/call", {"name": "wp_get_posts", "arguments": {"limit": 3}})
print()
print("wp_get_posts ->", statut)
for post in json.loads(texte_outil(reponse)):
    print(f"  [{post['post_status']:8s}] #{post['ID']} {post['post_title']}")
wp_count_posts -> 200
{
    "publish": "1",
    "future": 0,
    "draft": 0,
    "pending": 0,
    "private": 0,
    "trash": 0,
    "auto-draft": 0,
    "inherit": 0,
    "request-pending": 0,
    "request-confirmed": 0,
    "request-failed": 0,
    "request-completed": 0
}

wp_get_posts -> 200
  [publish ] #1 Hello world!

Lire par MCP : la meme base, servie autrement

Les deux lectures reviennent en 200, et leurs contenus meritent un arret. wp_count_posts ne rend pas un nombre mais un dictionnaire d’etats : un article publish, zero partout ailleurs. C’est la photographie d’une instance fraiche – les zeros de draft, pending, trash disent qu’aucun brouillon n’a survecu a un precedent passage, et le compte final du palier d’ecriture ci-dessous devra retomber exactement sur ces zeros pour prouver le menage.

wp_get_posts confirme avec le detail : le seul article publie est #1 Hello world! – l’article d’installation de WordPress, signature d’une instance jetable qui n’a encore servi a rien d’autre. La valeur de ces lectures n’est pas dans leur contenu (minimal) mais dans leur forme : le meme JSON structure que l’API d’administration, obtenu par un autre transport.

Agir par MCP : le cycle de vie d’un article

La demonstration qui donne son sens au serveur MCP : un client externe cree un contenu. On fait vivre a un article ephemere le cycle complet par le protocole — creation (draft), lecture, puis suppression. L’identifiant rendu par la creation est du texte ("Post created ID 12") : le client le parse pour chainer l’appel suivant — exactement ce que ferait un agent.

# 4. Agir par MCP : creer, lire, supprimer un article epheme
statut, rep_create = mcp_rpc("tools/call", {"name": "wp_create_post", "arguments": {
    "post_title": "Article ephemere du notebook MCP",
    "post_content": "Cree par tools/call, supprime trois appels plus tard.",
    "post_status": "draft",
}})
message = texte_outil(rep_create)
print("creation  ->", statut, "|", message)
article_id = int(re.search(r"ID (\d+)", message).group(1))
print("identifiant parse :", article_id)

statut, rep_get = mcp_rpc("tools/call", {"name": "wp_get_post", "arguments": {"ID": article_id}})
post = json.loads(texte_outil(rep_get))
print("lecture   ->", statut, "|", post["post_title"], "| statut :", post["post_status"])

statut, rep_del = mcp_rpc("tools/call", {"name": "wp_delete_post", "arguments": {"ID": article_id}})
print("suppression ->", statut, "|", texte_outil(rep_del))

# Etat final : le brouillon n'existe plus
statut, reponse = mcp_rpc("tools/call", {"name": "wp_count_posts", "arguments": {}})
comptes = json.loads(texte_outil(reponse))
print("comptage final (draft) :", comptes["draft"], "- l'article ephemere a bien disparu")
creation  -> 200 | Post created ID 14
identifiant parse : 14
lecture   -> 200 | Article ephemere du notebook MCP | statut : draft
suppression -> 200 | Post #14 deleted
comptage final (draft) : 0 - l'article ephemere a bien disparu

Le cycle de vie complet, dans un seul transport

Create, read, delete : trois appels, trois 200, et le compteur de brouillons retombe a zero – le cycle se referme proprement. Trois details font la valeur de la demonstration.

D’abord l’identifiant parse : le serveur repond par un texte (Post created ID 14), et c’est au client d’en extraire le 14 pour l’etape suivante. Le protocole ne rend pas de cle structuree ici : piloter par MCP, c’est aussi accepter de decouper des reponses en prose. Ensuite l’article nait en draft : le statut lu apres creation n’est pas « publie » mais brouillon – la creation par outil n’importe pas d’hypothese de publication, c’est au client de decider. Enfin la suppression verifyee par le comptage : le 200 du delete ne suffit pas, le retour a zero du compteur de brouillons en est la preuve reellement mesuree. Un test qui s’arrete au code de reponse sait ce que le serveur a dit ; un test qui recompte sait ce qui est arrive.

La frontiere de securite, mesuree

Sans le header d’autorisation, il ne reste rien : la mesure le dit — initialize, tools/list et tools/call refusent tous (401 rest_forbidden). La frontiere est devant la porte, pas derriere : meme se presenter au serveur exige des credentials. La granularite fine est declaree dans le catalogue (accessLevel : read / write / admin) et arbitree par WordPress selon le compte — l’app password admin ouvre tout ; un compte moindre verrait les niveaux filtres.

# 5. Sans authentification : la frontiere
statut, reponse = mcp_rpc("tools/list", auth=False)
print("tools/list sans auth  ->", statut, "|", (reponse or {}).get("code"))

statut, reponse = mcp_rpc("tools/call", {"name": "wp_count_posts", "arguments": {}}, auth=False)
print("tools/call sans auth  ->", statut, "|", (reponse or {}).get("code"))

statut, reponse = mcp_rpc("initialize", {"protocolVersion": "2025-03-26",
                                         "capabilities": {},
                                         "clientInfo": {"name": "anonyme", "version": "1"}},
                          auth=False)
print("initialize sans auth  ->", statut, "| reponse du serveur :",
      (reponse or {}).get("result", {}).get("serverInfo"))
tools/list sans auth  -> 401 | rest_forbidden
tools/call sans auth  -> 401 | rest_forbidden
initialize sans auth  -> 401 | reponse du serveur : None

La frontiere, lue dans les trois refus

Sans le header d’autorisation, les trois niveaux du protocole disent exactement la meme chose : non. tools/list et tools/call recoivent un 401 rest_forbidden – le refus standard de la REST API de WordPress, donc la frontiere est posee avant la couche MCP, au niveau du transport. Et initialize lui-meme echoue : reponse None du serveur, pas meme un handshake. C’est le detail le plus instructif de la mesure : l’authentification ne protege pas seulement les outils, elle protege le protocole entier. Un serveur qui accepterait le handshake sans autorisation exposerait deja son identite, sa version et son protocole a n’importe quel curieux du reseau.

La mesure vaut ce que vaut son instance : sur localhost:8093, derriere un tunnel reserve, le risque est nul. Transposee a une instance publique, la meme sortie serait la verification minimale que le serveur MCP n’est pas un canal d’administration ouvert.

Ce qu’on en a fait dans le projet Livres Agites

Dans le projet d’origine (AI Engine Pro + plugin custom), le serveur MCP est la colonne vertebrale de l’agentique : 88 outils exposes — 64 generiques (WordPress, WooCommerce, SQL, SEO) et 24 outils metier ecrits sur mesure (manuscrits, comite de lecture, pipeline editorial). La lecon d’architecture du Parcours 3 (04-Cas-Usage-livresagites/livresagites-parcours.md) : un serveur MCP utile expose les verbes du metier, pas les tables de la base. Ce notebook la montre en negatif : sur la version gratuite, les 43 outils sont tous generiques (wp_*) — la maison d’edition n’y apparait nulle part. Les verbes metier, eux, s’ajoutent par le code du plugin, et deviennent aussitot consommables par le meme protocole.

Deux compagnons stdlib prolongent ce notebook : auditer-un-serveur-mcp.ipynb (classer CRUD generique vs verbes metier d’un catalogue) et consommer-vs-exposer-le-mcp.ipynb (les deux sens du fil MCP et le risque de double-ecrit).

A vous : trois instruments autour du contrat

Les exercices qui suivent reprennent le contrat declare au catalogue. Le premier ecrit le wrapper generique qui manquait au notebook : une seule fonction pour n’importe quel tools/call, avec le decoupage de reponse en prose que le cycle de vie a revele. Le deuxieme relit le catalogue d’un oeil d’auditeur – l’histogramme des niveaux d’acces, c’est la question « combien d’outils peuvent ecrire ? » posee directement aux donnees. Le troisieme rejoue le cycle create-read-delete en autonomie completement deleguee a vos fonctions. Les squelettes sont silencieux, les meme instances que le parcours restent disponibles.

Exercices

Trois exercices, du plus simple au plus integre. Les fonctions sont a completer ; mcp_rpc() et texte_outil() sont disponibles. Chaque exercice se verifie d’une ligne de test.

Exercice 1 — un appel d’outil generique

Completez appeler_outil(nom, arguments) : elle execute tools/call et retourne le texte du resultat (sans lever d’exception si l’appel echoue — retourner None).

def appeler_outil(nom, arguments=None):
    """Execute tools/call ; retourne le texte du resultat, None si echec."""
    # A COMPLETER : mcp_rpc + texte_outil, avec garde d'echec
    return None

Exercice 2 — l’histogramme des niveaux d’acces

Completez compter_par_acces() : elle retourne un dict {niveau: nombre} des accessLevel des outils. Indice : les annotations MCP (tools/list) portent readOnlyHint, pas le niveau d’acces — celui-ci vit dans le miroir REST mwai/v1/mcp/functions (vu plus haut). L’exercice croise donc les deux faces du plugin.

def compter_par_acces():
    """Retourne {niveau_acces: nombre_d_outils} depuis tools/list."""
    # A COMPLETER : tools/list par MCP, extraire accessLevel par outil, compter
    return {}

Exercice 3 — un cycle de vie complet

Completez cycle_vie_article(titre) : elle cree un brouillon par MCP, verifie sa lecture, le supprime, et retourne True seulement si les trois etapes ont reussi et le comptage final des drafts n’a pas bouge.

def cycle_vie_article(titre):
    """Create-read-delete d'un article par MCP ; True si le cycle est propre."""
    # A COMPLETER : wp_count_posts avant, wp_create_post, wp_get_post, wp_delete_post,
    # wp_count_posts apres (draft inchange)
    return None

Provenance et limites

  • Instance testee : http://localhost:8093, montee via instance-jetable/docker-compose.jetable.example.yml, AI Engine 3.7.0 (version gratuite, wordpress.org), corpus synthetique « Maison Valmont ».
  • Determinisme : aucune completion LLM — tous les appels sont au serveur MCP (lecture, ecriture d’un article ephemere cree puis supprime ; l’etat final est verifie). Les identifiants d’articles varies d’une execution a l’autre, le deroule est stable. Note : wp_delete_post envoie l’article a la corbeille (comportement WordPress par defaut), pas en suppression definitive — le comptage final porte sur les drafts, la corbeille reste a vider par l’administrateur.
  • Endpoints verifies ici (firsthand) : POST /wp-json/mcp/v1/http (initialize, notifications/initialized, tools/list, tools/call x6), GET /wp-json/mwai/v1/mcp/functions.
  • Version du protocole : le serveur a repondu 2025-06-18 a une proposition 2025-03-26 (negociation) ; le format de reponse est reste JSON (pas de flux SSE sur cette instance).
  • Frontieres : pas de donnees client, pas de secret, pas d’IP de provider — regles du chantier CoursIA.
Retour au sommet