Le serveur MCP et son autorisation — OAuth de bout en bout

Huitieme notebook de la serie « AI Engine par son API ». Le quatrieme (piloter-wordpress-par-mcp) avait ouvert la face agent : WordPress devenu serveur MCP, joignable en JSON-RPC — derriere une frontiere mesuree a 401, franchie avec l’application password de l’administrateur. Mais un client tiers reel (Claude Desktop, un IDE, un agent embarque) n’a pas les cles de l’admin, et ne doit pas les avoir. Comment se connecte-t-il ?

La reponse du plugin est un serveur d’autorisation OAuth 2.0 complet, embarque dans les memes routes mcp/v1 : enregistrement dynamique des clients, consentement humain, code d’autorisation, jetons. Ce notebook traverse le flow de bout en bout — et surtout, il collecte les refus : chaque marche refusee nomme sa couche (PKCE, capacite, session), et l’escalier des messages d’erreur est la vraie documentation du contrat de securite.

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 le serveur MCP : handshake, catalogue d’outils, appels reels
brancher-plusieurs-providers-par-l-api environnements, matrice d’usages, multi-provider par l’API
parler-au-chatbot-en-visiteur-par-l-api la face visiteur : session anonyme, nonce, mwai-ui/v1
obtenir-des-donnees-structurees-par-l-api sorties JSON structurees, la case json de la matrice
autour-du-consent-oauth-du-serveur-mcp (ce notebook) OAuth : registration, consent, PKCE, token, appel delegue

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.

Prerequis

  • L’instance jetable « Maison Valmont » est demarree (dossier instance-jetable/, montage en 5 etapes) ;
  • le fichier instance-jetable/.env existe (il n’est jamais commite) ;
  • aucune donnee reelle n’est utilisee : le corpus est 100 % synthetique.

Ce notebook n’appelle aucun modele de langage : OAuth est un protocole pur, et tout ce qui est mesure ici est du comportement de WordPress et du plugin. Les deux comptes de test utilises (un editor, un administrateur) sont crees par le notebook lui-meme via l’API REST, avec des mots de passe poses en clair dans le code : ils ne protegent qu’une instance jetable locale, et sont recrees a chaque premiere execution.

# 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 hashlib
import json
import os
import re
import secrets
from pathlib import Path

import requests
from dotenv import load_dotenv

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", "")
EDITEUR_MDP = os.getenv("VALMONT_EDITOR_PASSWORD")  # fixture consent.editor, posee plus bas
print("Base URL :", BASE_URL)

creds = base64.b64encode(f"{ADMIN_USER}:{APP_PASSWORD}".encode()).decode()
ENTETES = {"Authorization": "Basic " + creds, "Content-Type": "application/json"}


def api(route, method="GET", payload=None):
    """Appel a l'API REST d'administration de AI Engine (mwai/v1)."""
    r = requests.request(method, BASE_URL + "/wp-json" + route,
                         headers=ENTETES, json=payload, timeout=120)
    r.raise_for_status()
    return r.json()


def message_erreur(html):
    """Extrait le message d'une page d'erreur d'autorisation du plugin.

    Ces pages sont du HTML style ; le message utile vit dans le corps,
    entre les balises — le sortir rend les refus lisibles et comparables.
    """
    corps = re.search(r"<body[^>]*>(.*)</body>", html, re.S)
    texte = re.sub(r"<[^>]+>", "|", corps.group(1) if corps else html)
    parties = [p.strip() for p in texte.split("|") if p.strip()]
    # la derniere partie non-CSS est le message
    return parties[-1] if parties else "(message introuvable)"


def login_wordpress(session, utilisateur, mot_de_passe):
    """Connexion par formulaire (wp-login.php) -> cookies de session.

    WordPress exige le cookie wordpress_test_cookie AVANT le POST —
    sans lui le formulaire re-rend en 200 silencieux (mesure du sondage).
    """
    session.cookies.set("wordpress_test_cookie", "Cookie check")
    r = session.post(BASE_URL + "/wp-login.php",
                     data={"log": utilisateur, "pwd": mot_de_passe,
                           "wp-submit": "Log In",
                           "redirect_to": BASE_URL + "/wp-admin/",
                           "testcookie": "1"},
                     timeout=60, allow_redirects=False)
    return r.status_code == 302


print("Helpers prets.")
Fichiers .env charges : ['instance-jetable\\.env']
Base URL : http://localhost:8093
Helpers prets.

1. La carte : deux documents de decouverte

Avant tout flow, un client OAuth decouvre son serveur. Le plugin publie deux documents standards, aux roles distincts :

  • /.well-known/oauth-protected-resource (RFC 9728) — dit « cette ressource est protegee, voici les serveurs d’autorisation accepts » ; c’est ce document qu’un client comme Claude.ai consulte en premier ;
  • /.well-known/oauth-authorization-server (RFC 8414) — la carte complete du serveur d’autorisation : endpoints, grants, methodes.

Ces routes sont publiques : la decouverte ne requiert aucune authentification.

# Les deux documents de decouverte.
pr = requests.get(BASE_URL + "/wp-json/mcp/v1/.well-known/oauth-protected-resource",
                  timeout=30).json()
print("== protected-resource (RFC 9728) ==")
print("  ressource protegee :", pr["resource"].split("/wp-json")[0] + "/wp-json/mcp/v1/http")
print("  serveurs d'autorisation :", [s.split("/wp-json")[0] + "/wp-json/mcp/v1" for s in pr["authorization_servers"]])
print("  bearer_methods :", pr["bearer_methods_supported"], "| scopes :", pr["scopes_supported"])
print()
AS_URL = pr["authorization_servers"][0]  # il n'y en a qu'un : le plugin lui-meme

az = requests.get(AS_URL + "/.well-known/oauth-authorization-server", timeout=30).json()
print("== authorization-server (RFC 8414) ==")
for cle in ("issuer", "authorization_endpoint", "token_endpoint",
            "registration_endpoint", "revocation_endpoint",
            "response_types_supported", "grant_types_supported"):
    val = az.get(cle)
    val = val.split("/wp-json")[0] + "/wp-json" + val.split("/wp-json")[1] if isinstance(val, str) and "/wp-json" in val else val
    print(f"  {cle:26s}: {val}")
== protected-resource (RFC 9728) ==
  ressource protegee : http://localhost:8093/wp-json/mcp/v1/http
  serveurs d'autorisation : ['http://localhost:8093/wp-json/mcp/v1']
  bearer_methods : ['header'] | scopes : ['mcp']

== authorization-server (RFC 8414) ==
  issuer                    : http://localhost:8093/wp-json/mcp/v1
  authorization_endpoint    : http://localhost:8093/wp-json/mcp/v1/oauth/authorize
  token_endpoint            : http://localhost:8093/wp-json/mcp/v1/oauth/token
  registration_endpoint     : http://localhost:8093/wp-json/mcp/v1/oauth/register
  revocation_endpoint       : http://localhost:8093/wp-json/mcp/v1/oauth/revoke
  response_types_supported  : ['code']
  grant_types_supported     : ['authorization_code', 'refresh_token']

Le plugin est son propre serveur d’autorisation : la ressource protegee (le endpoint MCP) et le serveur qui delivre les jetons vivent dans le meme namespace mcp/v1. Les grants declares — authorization_code et refresh_token — annoncent la couleur : un flow interactif d’abord, un renouvellement ensuite. Aucun client_credentials : tout passe par un utilisateur humain.

2. L’enregistrement dynamique — un client se declare lui-meme

La troisieme colonne de la carte est registration_endpoint. C’est la RFC 7591 : un client peut s’enregistrer tout seul, sans qu’un administrateur le pre-declare. Essayons — sans aucune authentification.

# Enregistrement dynamique d'un client (RFC 7591).
session_anonyme = requests.Session()
reg = session_anonyme.post(AS_URL + "/oauth/register",
                           json={"client_name": "Notebook CoursIA",
                                 "redirect_uris": ["http://localhost:8888/callback"]},
                           timeout=30).json()
CLIENT_ID = reg["client_id"]
print("client_id        :", CLIENT_ID[:14] + "...")
print("client_name      :", reg["client_name"])
print("redirect_uris    :", reg["redirect_uris"])
print("grant_types      :", reg["grant_types"])
print("auth_method      :", reg["token_endpoint_auth_method"], "(client PUBLIC : pas de secret)")
REDIRECT_URI = reg["redirect_uris"][0]
client_id        : e7eb3dcc18dc01...
client_name      : Notebook CoursIA
redirect_uris    : ['http://localhost:8888/callback']
grant_types      : ['authorization_code', 'refresh_token']
auth_method      : none (client PUBLIC : pas de secret)

Le serveur a emis un client_id sans demander qui nous etions — et la reponse porte token_endpoint_auth_method: none : c’est un client public, sans client_secret. C’est le pattern des applications incapables de garder un secret (une app desktop, un notebook) — et pour cette raison meme, la suite du flow ne leur fera pas confiance sans preuve. Cette preuve, c’est PKCE.

3. L’escalier des refus

Demandons une autorisation — GET /oauth/authorize — et laissons le serveur refuser, en ecoutant chaque message. Premier refus : le client public, sans preuve de possession.

# Refus 1 : demande d'autorisation SANS PKCE.
params_sans_pkce = {"response_type": "code", "client_id": CLIENT_ID,
                    "redirect_uri": REDIRECT_URI, "scope": "mcp", "state": "refus-1"}
r = session_anonyme.get(AS_URL + "/oauth/authorize", params=params_sans_pkce, timeout=30)
print("statut :", r.status_code)
print("message :", message_erreur(r.text))
statut : 400
message : PKCE is required: provide code_challenge and code_challenge_method=S256.

PKCE est exige (RFC 7636) : la demande doit embarquer un code_challenge, et l’echange de jeton devra presenter le code_verifier correspondant. Le mecanisme, en trois temps :

  1. le client genere un code_verifier aleatoire ;
  2. il n’envoie au serveur que son empreinte (code_challenge = SHA-256 du verifier, encode base64url) avec la methode S256 ;
  3. au moment de l’echange du code, il presente le verifier en clair ; le serveur verifie que l’empreinte correspond.

Un client public n’a pas de secret — mais il a une preuve de possession : celui qui recoit le code d’autorisation (potentiellement intercepte, via l’URL de redirection) ne peut PAS l’echanger sans le verifier, qui n’a jamais voyage dans un navigateur.

Deuxieme refus : l’autorisation n’est pas pour tout le monde. Pour le mesurer, il nous faut un utilisateur non administrateur — le notebook en cree un dedie (pattern idempotent : retrouve s’il existe deja). Au passage, un piege du routing WordPress : chercher consent.editor avec search= rend un 404 HTML (le point fait derailer la reecriture) — on cherche sans le point et on filtre cote client.

# Utilisateur de test non-admin (editor), cree via l'API REST — idempotent.
# Mots de passe en clair : instance jetable locale, comptes recrees par ce notebook.
users = api("/wp/v2/users?search=consent-editor", method="GET")
existant = [u for u in users if u.get("name") == "consent.editor"]
if existant:
    print("utilisateur existant :", existant[0]["id"])
else:
    r = requests.post(BASE_URL + "/wp-json/wp/v2/users", headers=ENTETES,
                      json={"username": "consent.editor", "password": EDITEUR_MDP,
                            "email": "consent.editor@example.test",
                            "name": "consent.editor", "roles": ["editor"]}, timeout=60)
    print("creation :", r.status_code)

session_editor = requests.Session()
ok = login_wordpress(session_editor, "consent.editor", EDITEUR_MDP)
print("login editor :", "OK (302)" if ok else "ECHEC")
utilisateur existant : 2
login editor : OK (302)

Pourquoi un editor, et pourquoi il est cree ici

La cellule rend utilisateur existant : 4 puis login editor : OK (302) : le compte existe deja (la cellule est idempotente, elle le reutilise au lieu d’en creer un second a chaque execution) et son authentification aboutit. Ce 302 est un succes, pas une erreur : WordPress repond a une connexion reussie par une redirection, et c’est precisement ce qui permet a la cellule suivante de presenter une session authentifiee au serveur d’autorisation.

Le choix du role n’est pas anodin. Un subscriber serait un temoin trop faible : son refus pourrait s’expliquer par a peu pres n’importe quel controle d’acces, et ne dirait rien de specifique au consentement MCP. Un editor, lui, peut publier, editer et supprimer le contenu d’autrui — c’est un compte privilegie au sens de WordPress. Son refus a l’etape suivante isole donc une regle precise : autoriser une application MCP n’est pas un privilege editorial, c’est un privilege d’administration du site.

C’est la difference entre « qui peut agir sur le contenu » et « qui peut deleguer a un tiers le droit d’agir sur le contenu ». Un editor dispose du premier ; le second lui est refuse. La suite le mesure.

# Refus 2 : demande d'autorisation AVEC PKCE, par un editor connecte.
verifier = secrets.token_urlsafe(48)
challenge = base64.urlsafe_b64encode(
    hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
params_ok = {"response_type": "code", "client_id": CLIENT_ID,
             "redirect_uri": REDIRECT_URI, "scope": "mcp", "state": "refus-2",
             "code_challenge": challenge, "code_challenge_method": "S256"}
r = session_editor.get(AS_URL + "/oauth/authorize", params=params_ok, timeout=30)
print("statut :", r.status_code)
print("message :", message_erreur(r.text))
statut : 400
message : Only administrators can authorize MCP applications on this site.

Seuls les administrateurs peuvent autoriser. Le consentement MCP n’est pas deleguable : un editor du site ne peut pas, par lui-meme, donner a un client l’acces au serveur MCP. C’est un choix de securite fort du plugin — le serveur MCP expose des outils d’administration (creer/supprimer des articles, voire plus), et la porte ne s’ouvre que par le haut.

L’escalier resume ce que chaque couche a dit :

Demande Reponse La couche qui parle
sans session, sans PKCE 400 — PKCE is required le contrat du flow
editor connecte + PKCE 400 — Only administrators la capacite WordPress
admin connecte + PKCE 200 — formulaire de consentement l’humain

4. Le consentement — la marche humaine, mechanisee

Pour la suite, un administrateur de test dedie (meme pattern idempotent). Avec lui, authorize ne refuse plus : il presente le formulaire de consentement — la page qu’un humain verrait dans son navigateur.

# Administrateur de test dedie (idempotent) + session.
users = api("/wp/v2/users?search=consent-admin", method="GET")
existant = [u for u in users if u.get("name") == "consent.admin"]
if existant:
    print("utilisateur existant :", existant[0]["id"])
else:
    r = requests.post(BASE_URL + "/wp-json/wp/v2/users", headers=ENTETES,
                      json={"username": "consent.admin", "password": "Consent-Admin-2026!",
                            "email": "consent.admin@example.test",
                            "name": "consent.admin", "roles": ["administrator"]}, timeout=60)
    print("creation :", r.status_code)

session_admin = requests.Session()
ok = login_wordpress(session_admin, "consent.admin", "Consent-Admin-2026!")
print("login admin :", "OK (302)" if ok else "ECHEC")

r = session_admin.get(AS_URL + "/oauth/authorize", params=params_ok, timeout=30, allow_redirects=False)
print()
print("authorize (admin + PKCE) :", r.status_code)
form = re.search(r"<form[^>]*>(.*?)</form>", r.text, re.S)
champs_form = dict(re.findall(r'name="([^"]+)"[^>]*value="([^"]*)"', form.group(1))) if form else {}
print("formulaire de consentement :", bool(form))
print("champs declares :", sorted(champs_form))
print("boutons presents :", re.findall(r'<button[^>]*name="(action)"[^>]*value="([^"]*)"', form.group(1)) if form else [])
utilisateur existant : 3
login admin : OK (302)

authorize (admin + PKCE) : 200
formulaire de consentement : True
champs declares : ['_mwai_nonce', 'action', 'client_id', 'code_challenge', 'code_challenge_method', 'redirect_uri', 'scope', 'state']
boutons presents : [('action', 'approve'), ('action', 'deny')]

Le formulaire porte tous les parametres de la demande (client, redirect, scope, challenge) plus deux details dignes d’attention :

  • le nonce maison _mwai_nonce — pas le _wpnonce standard de WordPress : le plugin a son propre espace de nonce pour cette page ;
  • un champ cache action dont la valeur par defaut est… deny. Le consentement est refus par defaut : c’est le bouton Approve qui surcharge ce champ, et un POST sans decision vaut un non.

Approuvons : on renvoie le formulaire complet avec action=approve.

Ce que _mwai_nonce protege, precisement

Un nonce WordPress n’est ni un secret ni un jeton d’authentification : c’est une valeur derivee de l’utilisateur, de l’action et d’une fenetre de temps, qui n’a de sens que dans ce formulaire, pour ce compte, maintenant. Il ne dit pas qui vous etes — le cookie de session s’en charge — il dit que la requete vient bien de la page que le serveur a servie, et pas d’une soumission fabriquee ailleurs.

Sans lui, la page de consentement serait une cible CSRF de manuel : il suffirait d’amener un administrateur authentifie a soumettre, a son insu, un formulaire hostile pointant vers /oauth/authorize — et un client attaquant obtiendrait un code d’autorisation valide, sans qu’aucun mot de passe ne fuite et sans que l’administrateur ne voie jamais l’ecran d’approbation. Le nonce casse cette chaine, parce qu’un tiers ne peut pas le predire.

C’est aussi pourquoi le fait qu’il soit maison (_mwai_nonce et non le _wpnonce standard) merite d’etre note plutot que subi : le plugin gere son propre espace de nonce, donc un client de test doit le relire dans la page a chaque tentative. Le recopier d’une execution precedente echoue — et cet echec ressemblera a un bug de code alors que c’est la protection qui fonctionne.

Quant au champ action par defaut a deny : c’est la meme philosophie appliquee au consentement lui-meme. Une soumission incomplete, un script qui oublie de positionner le champ, un utilisateur qui valide au clavier sans lire — tous convergent vers le refus. La decision par defaut est celle qui n’accorde rien.

# Approbation : le formulaire complet, avec la decision.
donnees = dict(champs_form)
donnees["action"] = "approve"
r = session_admin.post(AS_URL + "/oauth/authorize", params=params_ok,
                       data=donnees, timeout=30, allow_redirects=False)
print("statut :", r.status_code)
location = r.headers.get("Location", "")
print("redirection : vers le callback (code d'autorisation dans l'URL, voir variable CODE)")
m = re.search(r"[?&]code=([^&]+)", location)
CODE = m.group(1) if m else None
print("code d'autorisation :", "reçu" if CODE else "(aucun)")
statut : 302
redirection : vers le callback (code d'autorisation dans l'URL, voir variable CODE)
code d'autorisation : reçu

Un 302 vers le redirect_uri du client, avec le code d’autorisation en parametre — exactement le chemin qu’emprunterait le navigateur d’un utilisateur reel. Le code est a usage unique, a vie courte, et — grace a PKCE — inutile a quiconque ne possede pas le verifier.

Deux proprietes de ce code meritent d’etre nommees, parce qu’elles expliquent pourquoi le flux ne s’arrete pas ici.

Il transite par l’URL, donc par des endroits qui gardent des traces : la barre d’adresse, l’historique du navigateur, les journaux du serveur, l’en-tete Referer d’une eventuelle page suivante. C’est assume par le protocole — et c’est exactement pour cela qu’il ne vaut rien seul, qu’il expire en quelques minutes et qu’il ne peut servir qu’une fois. Un code intercepte apres usage est un code mort.

Il n’est pas encore une autorisation, seulement une promesse. Rien n’a ete delegue tant que l’echange de l’etape suivante n’a pas eu lieu, et cet echange exigera le code_verifier — que seul le client qui a initie la demande possede. C’est la que la boucle PKCE ouverte trois cellules plus haut se referme : le code_challenge envoye au depart etait l’empreinte, le code_verifier est l’antecedent, et le serveur verifie que l’un derive bien de l’autre.

5. L’echange : le code contre un jeton

Derniere etape mecanique : POST /oauth/token, avec le code, le client, le redirect… et le code_verifier en clair. C’est ici que la preuve de possession se joue.

# Echange du code contre un jeton (le verifier voyage, en clair, une seule fois).
r = session_anonyme.post(AS_URL + "/oauth/token",
                         data={"grant_type": "authorization_code",
                               "code": CODE, "client_id": CLIENT_ID,
                               "redirect_uri": REDIRECT_URI,
                               "code_verifier": verifier},
                         timeout=30)
print("statut :", r.status_code)
jeton = r.json()
for cle, val in jeton.items():
    affiche = ("présent") if isinstance(val, str) and len(str(val)) > 24 else val
    print(f"  {cle:14s}: {affiche}")
statut : 200
  access_token  : présent
  token_type    : Bearer
  expires_in    : 3600
  refresh_token : présent
  scope         : mcp

Le serveur delivre le trio complet : access_token, refresh_token, expires_in — et le scope accorde. Le jeton d’acces a une duree de vie bornee (la valeur mesuree ci-dessus, en secondes) : c’est sa difference fondamentale avec l’application password, qui ne meurt jamais tant qu’on ne la revoque pas.

Cette borne n’est pas un detail d’implementation : elle change la conception du client. Un client qui detient une application password peut se contenter de la lire au demarrage et de ne plus jamais y penser. Un client OAuth ne le peut pas — a une heure de duree de vie, tout processus qui vit plus longtemps qu’une session de travail rencontrera l’expiration, et devra la traiter.

Trois consequences concretes, dans l’ordre ou on les rencontre :

  1. Le 401 devient un etat nominal, pas une erreur. Un client correct n’abandonne pas au premier refus : il tente un rafraichissement, puis rejoue la requete. Traiter le 401 comme une panne produit un outil qui marche parfaitement en demonstration et casse au bout d’une heure.
  2. Le refresh_token devient le secret a proteger, pas l’access_token. Le second expire tout seul ; le premier est ce qui permet d’en fabriquer d’autres. Les stocker au meme endroit avec le meme soin est un contresens frequent.
  3. Ne pas attendre l’expiration pour la constater. expires_in est un delai, pas une date : il se compte a partir de la reception de la reponse. Un client qui rafraichit un peu en avance evite la fenetre ou la requete part valide et arrive expiree.

C’est le prix de la delegation, et il est modeste compare a ce qu’elle achete : un droit borne dans le temps et dans sa portee, revocable a distance, qui n’a jamais exige que le client connaisse le moindre identifiant du site.

6. L’appel delegue : le serveur MCP repond

Le moment de verite : le meme endpoint MCP que le quatrieme notebook, cette fois au bearer OAuth — un jeton obtenu par delegation, pas par les cles de l’admin.

# initialize au bearer OAuth.
r = session_anonyme.post(BASE_URL + "/wp-json/mcp/v1/http",
                         headers={"Authorization": "Bearer " + jeton["access_token"],
                                  "Content-Type": "application/json",
                                  "Accept": "application/json, text/event-stream"},
                         json={"jsonrpc": "2.0", "id": 1, "method": "initialize",
                               "params": {"protocolVersion": "2025-06-18",
                                          "capabilities": {},
                                          "clientInfo": {"name": "notebook-coursia", "version": "1.0"}}},
                         timeout=60)
print("statut :", r.status_code)
resultat = r.json()
print("serverInfo :", resultat["result"]["serverInfo"])
print("capabilities :", list(resultat["result"]["capabilities"]))
statut : 200
serverInfo : {'name': 'AI Engine - Maison Valmont', 'version': '0.0.1'}
capabilities : ['tools']

Ce que initialize etablit — et ce qu’il ne fait pas

Premier appel MCP porte par le jeton delegue : statut : 200, le serveur se nomme (AI Engine - Maison Valmont, version 0.0.1) et annonce ses capacites — ici ['tools'] et rien d’autre.

Deux lectures a en tirer.

Le jeton est accepte comme identite du client. Aucun cookie, aucun mot de passe d’application : la session ne porte que l’en-tete Authorization, ce que le document de decouverte annoncait deja avec bearer_methods : ['header']. Le 200 confirme que le serveur de ressource et le serveur d’autorisation partagent bien le meme referentiel de jetons — ce qui, dans ce plugin, va de soi puisqu’ils sont le meme composant, mais qui serait un point d’integration a part entiere si l’AS etait externe.

capabilities est une negociation, pas une decoration. Le serveur declare tools : un client correct n’appellera donc ni resources/list ni prompts/list, non par politesse mais parce que rien ne garantit qu’ils existent. C’est le pendant cote protocole de ce que le scope fait cote autorisation : l’un borne ce que le serveur sait faire, l’autre ce que ce jeton a le droit de demander. Les deux bornes se cumulent, et une implementation qui n’en respecte qu’une se decouvrira a la premiere erreur.

# Et un vrai appel d'outil, au jeton delegue.
r = session_anonyme.post(BASE_URL + "/wp-json/mcp/v1/http",
                         headers={"Authorization": "Bearer " + jeton["access_token"],
                                  "Content-Type": "application/json",
                                  "Accept": "application/json, text/event-stream"},
                         json={"jsonrpc": "2.0", "id": 2, "method": "tools/call",
                               "params": {"name": "wp_count_posts",
                                          "arguments": {"post_type": "post"}}},
                         timeout=60)
print("statut :", r.status_code)
print(r.text[:200])
statut : 200
{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"{\n    \"publish\": \"1\",\n    \"future\": 0,\n    \"draft\": 0,\n    \"pending\": 0,\n    \"private\": 0,\n    \"trash\": 0,\n    

L’appel d’outil : la delegation devient concrete

statut : 200, et la reponse JSON-RPC porte un result.content de type text dont la charge utile est un decompte de contenus (publish, future, draft, pending, private, trash). C’est la premiere fois du notebook qu’une donnee metier du site traverse le canal MCP.

Le detail qui compte : cette reponse a ete produite pour un client qui n’a jamais connu de mot de passe. Il a presente un jeton obtenu par un flux ou un administrateur a explicitement approuve, dans un formulaire, une demande nommee. Si ce jeton fuit, ce qui fuit est un droit borne (scope: mcp) et temporaire (expires_in: 3600), revocable d’un appel — pas les identifiants d’un compte.

Noter aussi la forme de l’enveloppe : le resultat metier arrive comme du texte dans content, pas comme un objet JSON structure au niveau du protocole. C’est la convention MCP — le serveur decrit ses outils, le client les appelle, et l’interpretation du contenu rendu reste a la charge de l’appelant (ou du modele). Un client robuste ne suppose donc pas la forme de ce texte : il la valide.

7. Trois cles pour un meme serveur

La serie a desormais croise trois facons de s’authentifier aupres des surfaces du plugin — et elles ne disent pas la meme chose :

Mecanisme Vu au Delivre par Vie Portee
application password (admin) grain 4 un administrateur, dans son profil permanente (jusqu’a revocation) tout ce que l’admin peut faire
token OAuth (delegue) ce grain le flow consent + PKCE bornee (expires_in), renouvelable ce que l’admin a consenti, pour CE client
nonce WordPress (visiteur) grain 6 start_session, a quiconque charge le site 12-24 h anti-CSRF : ne prouve AUCUNE identite

Le tableau se lit comme une echelle de delegation : le password d’application est l’admin ; le token OAuth represente un admin qui a dit oui une fois ; le nonce n’est personne. Confondre les lignes — traiter un nonce comme une authentification, ou un jeton borne comme permanent — est exactement le type d’erreur que la mesure de expires_in ci-dessus rend visible.

8. Nettoyage

Le jeton delivre reste valide jusqu’a expiration. Le endpoint de revocation declare dans la carte permet de le tuer immediatement — faisons-le, pour laisser l’instance propre. Les deux comptes de test, eux, sont conserves : idempotents (retrouves s’ils existent), ils servent a chaque re-execution du notebook.

# Revocation du jeton d'acces.
r = session_anonyme.post(AS_URL + "/oauth/revoke",
                         data={"token": jeton["access_token"],
                               "client_id": CLIENT_ID},
                         timeout=30)
print("statut :", r.status_code, "| corps :", r.text[:80])

# Preuve que le jeton est mort : le meme appel MCP doit maintenant echouer.
r = session_anonyme.post(BASE_URL + "/wp-json/mcp/v1/http",
                         headers={"Authorization": "Bearer " + jeton["access_token"],
                                  "Content-Type": "application/json"},
                         json={"jsonrpc": "2.0", "id": 3, "method": "initialize",
                               "params": {"protocolVersion": "2025-06-18", "capabilities": {},
                                          "clientInfo": {"name": "notebook-coursia", "version": "1.0"}}},
                         timeout=60)
print("appel MCP apres revocation :", r.status_code)
statut : 200 | corps : 
appel MCP apres revocation : 401

La revocation est immediate, et c’est une propriete a verifier

Deux valeurs dans cet output, et la seconde est la seule qui prouve quelque chose : statut : 200 pour la revocation, puis appel MCP apres revocation : 401.

Le 200 seul serait insuffisant. RFC 7009 impose au serveur de repondre 200 a une revocation meme si le jeton etait deja invalide ou inconnu — un serveur conforme renvoie donc 200 sans qu’on puisse rien en conclure sur l’etat reel. La preuve est le 401 qui suit : le meme appel MCP qui rendait 200 deux cellules plus haut est desormais refuse.

Ce que ce 401 etablit concretement, c’est qu’il n’y a pas de cache de validation entre la revocation et le prochain appel. Sur un deploiement ou le serveur de ressource valide les jetons localement (JWT signes verifies sans aller-retour vers l’AS), ce meme test rendrait souvent 200 pendant encore plusieurs minutes — le temps que le jeton expire de lui-meme. La revocation y devient une intention plutot qu’un evenement. C’est exactement le genre de propriete qu’on ne peut pas deduire de la documentation d’un serveur : il faut la mesurer, et le couple revoke-puis-rappel est le test qui la mesure.

Bilan

  • Le plugin est son propre serveur d’autorisation. Les deux documents de decouverte (RFC 9728 puis 8414) menent du endpoint MCP protege a la carte complete — registration, authorize, token, revoke — dans le meme namespace mcp/v1.
  • Un client public, une preuve de possession. L’enregistrement dynamique (RFC 7591) emet des client_id sans secret ; en contrepartie, PKCE (RFC 7636) est exige — le refus est mesure, pas suppose.
  • Le consentement est un acte d’administrateur. Un editor ne peut pas ouvrir la porte du serveur MCP ; le formulaire de consentement est meme refus par defaut (champ cache action=deny), et le nonce y est maison (_mwai_nonce).
  • Trois cles, trois significations. Application password = identite pleine ; token OAuth = delegation bornee et consentie ; nonce = anti-CSRF sans identite. Le meme serveur MCP repond aux deux premieres — mais ce qu’elles autorisent n’a pas la meme duree ni le meme responsable.

Avec cette huitieme note, la serie a couvert les trois faces (admin, agent, visiteur), la regie des environnements, les donnees structurees et l’autorisation deleguee. Les familles restantes du catalogue (WooCommerce metier, statistiques, RAG par REST) restent fermees sur la version gratuite.

Exercices

Les trois exercices suivants sont a completer (remplacez pass). L’instance doit etre demarree et le .env charge (cellule de configuration). Les variables AS_URL, CLIENT_ID, REDIRECT_URI, verifier et jeton proviennent des cellules precedentes.

# Exercice 1 — le renouvellement.
# Le jeton d'acces est borne, mais la reponse de /oauth/token contenait
# un refresh_token, et la carte declare le grant refresh_token. Ecrire
# une fonction renouveler(refresh_token, client_id) qui obtient un
# NOUVEAU access_token par POST /oauth/token (grant_type=refresh_token),
# et verifier que le nouveau jeton passe un initialize.

def renouveler(refresh_token, client_id):
    pass
# Exercice 2 — l'echelle des refus, cote methode.
# PKCE exige S256 — que dit le serveur si on presente la methode
# historique 'plain' (meme challenge, methode differente) ? Ecrire une
# fonction qui tente l'autorisation avec code_challenge_method='plain'
# (session admin, formulaire approuve comme plus haut) et retourne le
# message d'erreur extrait par message_erreur(). Le contrat du serveur
# accepte-t-il 'plain', et que repond-il ?

def autoriser_plain(session_admin, client_id, redirect_uri):
    pass
# Exercice 3 — classer les trois cles.
# Ecrire une fonction classer_cles() qui retourne un dictionnaire
# {mecanisme: duree_de_vie_en_secondes} pour les trois authentifications
# croisees par la serie : le token OAuth (expires_in de la cellule
# 5), le nonce WordPress visiteur (12 a 24 h — prendre 18 h comme
# centre), et l'application password admin (permanente — utiliser
# float('inf')). Trier par duree croissante et commenter : quelle cle
# faudrait-il revoquer en premier si un notebook est compromis ?

def classer_cles(expires_in_mesure):
    pass
Retour au sommet