Brancher un LLM sur WordPress : exposer son blog en MCP

Quand je refaisais le tour de Ghost et de WordPress, un des avantages de WordPress était son écosystème aussi actif que conséquent. Depuis la version 6.9, il existe l’Abilities API : un registre où un plugin déclare des capacités typées, que le plugin officiel MCP Adapter rend ensuite accessible en HTTP. Sur le papier, il suffirait presque de donner cette URL à Claude pour piloter son site depuis un tchat. Sauf que les deux ne parlent pas encore la même langue : l’adaptateur WordPress s’authentifie par une authentification classique, alors que Claude ou GPT attendent plutôt de l’OAuth 2.1. L’idée m’est donc venue de m’appuyer sur ces assistants pour écrire une passerelle entre les deux. Automattic propose bien un proxy local, @automattic/mcp-wordpress-remote, mais il est pensé pour les clients qui lancent un processus sur votre machine : un connecteur ajouté depuis le tchat attend une URL distante.

En résumé : une petite passerelle Express traduit l’OAuth 2.1 de Claude en Application Password pour WordPress, et ça fonctionne. Mais l’adaptateur n’expose qu’un seul outil universel : c’est la couche de permissions qu’il faut soigner avant de laisser un assistant écrire sur son site.

  Claude             connecteur personnalisé
        |  OAuth 2.1 
        v
  Passerelle
        |  Basic Auth
        v
  MCP Adapter        /wp-json/mcp/mcp-adapter-default-server
        |  appel PHP direct
        v
  Les possibilités   rechercher, lire, rédiger, publier

La passerelle ne connaît que le compte WordPress et ne donne aucun identifiant au LLM : elle traduit pour que les interactions se fassent.

Étape 1 — Installer le plugin MCP Adapter

Si l’API est disponible dans WordPress 6.9, il faut en revanche installer le plugin MCP Adapter, qui reste en version 0.x. Depuis la 0.7.0, début octobre, il est publié dans le répertoire officiel des extensions : l’écran Extensions → Ajouter, ou wp plugin install mcp-adapter --activate, suffit. Si vous préférez suivre le dépôt GitHub de WordPress, il faut l’ajouter dans son dossier, puis récupérer ses dépendances PHP et l’activer :

cd mcp-adapter && composer install --no-dev
wp plugin activate mcp-adapter

À l’activation, l’adaptateur crée automatiquement un serveur par défaut qui expose toutes les abilities enregistrées sur la route mcp-adapter-default-server. L’avantage est qu’il n’y a aucune configuration à faire pour démarrer.

Vérification

La route doit exister et refuser l’accès anonyme :

curl -s -o /dev/null -w "%{http_code}\n" \
  https://exemple.fr/wp-json/mcp/mcp-adapter-default-server

# 401 attendu — la route existe et elle est protegee

Un 404 signifie que le plugin n’est pas actif, ou que les permaliens sont en mode « simple » ; enregistrez à nouveau la structure des permaliens.

Étape 2 — Déclarer ce que l’IA a le droit de faire

Sans ability enregistrée, votre serveur MCP est vide. C’est ici que se joue l’essentiel : chaque ability est un contrat explicite, et rien de ce qui n’est pas déclaré n’est atteignable.

Un plugin maison suffit, avec Requires Plugins: mcp-adapter dans son en-tête pour que WordPress refuse de l’activer sans l’adaptateur. Deux hooks : un pour la catégorie, un pour les abilities.

wp-content/plugins/mon-mcp/mon-mcp.php

add_action( 'wp_abilities_api_categories_init', function () {
    wp_register_ability_category( 'monsite', array(
        'label'       => 'Mon site',
        'description' => 'Abilities editoriales.',
    ) );
} );

add_action( 'wp_abilities_api_init', array( __CLASS__, 'register_abilities' ) );

Une ability se déclare avec ses schémas JSON, son exécution et sa permission :

wp_register_ability(
    'monsite/search-posts',
    array(
        'label'       => 'Rechercher des articles',
        'description' => 'Recherche par texte, statut, categorie ou tag.',
        'category'    => 'monsite',

        'input_schema' => array(
            'type'       => 'object',
            'properties' => array(
                'search' => array(
                    'type'        => 'string',
                    'description' => 'Texte cherche dans le titre et le contenu.',
                ),
                'status' => array(
                    'type'    => 'string',
                    'enum'    => array( 'publish', 'draft', 'pending', 'any' ),
                    'default' => 'publish',
                ),
                'limit' => array(
                    'type'    => 'integer',
                    'minimum' => 1,
                    'maximum' => 50,
                    'default' => 10,
                ),
            ),
            'additionalProperties' => false,
        ),

        'execute_callback'    => array( __CLASS__, 'search_posts' ),
        'permission_callback' => function () {
            return current_user_can( 'read' );
        },

        'meta' => array(
            'annotations' => array(
                'readonly'    => true,
                'destructive' => false,
                'idempotent'  => true,
            ),
        ),
    )
);

Trois détails qui comptent :

  • additionalProperties => false fait rejeter tout paramètre inconnu, plutôt que de l’ignorer en silence.
  • Les description ne sont pas de la documentation : c’est ce que le modèle lit pour décider quel outil appeler. Une description vague produit un mauvais choix d’outil.
  • Les annotations signalent au client si l’appel est en lecture seule ou destructif. Elles informent, elles n’interdisent rien — la vraie barrière est permission_callback.

Une progression raisonnable pour un blog : lecture (search-posts, get-post, list-categories, list-tags), puis écriture en brouillon (create-draft, update-post), et la publication en dernier — si vous la voulez.

Étape 3 — Tester avant de construire quoi que ce soit

C’est l’étape que tout le monde saute et qui coûte le plus cher ensuite. Générez un Application Password dans Utilisateurs → Profil → Mots de passe d’application, puis parlez MCP directement, sans passerelle.

curl -i -u 'moi:xxxx xxxx xxxx xxxx xxxx xxxx' \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
       "protocolVersion":"2025-06-18","capabilities":{},
       "clientInfo":{"name":"curl","version":"1"}}}' \
  https://exemple.fr/wp-json/mcp/mcp-adapter-default-server

Vérification

Une réponse 200 avec un serverInfo, et un en-tête Mcp-Session-Id :

{"jsonrpc":"2.0","id":1,"result":{
  "protocolVersion":"2025-06-18",
  "capabilities":{"tools":{"listChanged":false}, ...},
  "serverInfo":{"name":"MCP Adapter Default Server","version":"v1.0.0"}}}

Reprenez ce Mcp-Session-Id en en-tête et demandez tools/list. Surprise probable : vous n’obtenez pas vos abilities, mais trois méta-outils.

mcp-adapter-discover-abilities   # lister ce qui existe
mcp-adapter-get-ability-info     # lire le schema de l'une d'elles
mcp-adapter-execute-ability      # executer n'importe laquelle

L’adaptateur n’expose pas une ability par outil : il en publie un seul, universel, auquel on passe un ability_name et des parameters. Retenez-le, on y revient à la fin — c’est la principale limite de sécurité de ce montage.

Si vous obtenez 401

Vérifiez d’abord la longueur du mot de passe : un Application Password fait 24 caractères, affichés en 6 groupes de 4. Si ce n’est pas la bonne valeur, WordPress répond rest_not_logged_in sans autre explication. Sinon, c’est votre serveur web qui supprime l’en-tête Authorization : sur Apache, ajoutez SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1.

Étape 4 — Traduire OAuth en Basic Auth

La passerelle est un petit serveur Express qui fait deux choses : il implémente le strict minimum d’un serveur d’autorisation OAuth 2.1, et il relaie tout le reste vers WordPress en y substituant l’Application Password.

Elle doit exposer six routes. Les deux premières sont des documents de découverte que le client lit avant toute chose.

Route Rôle
/.well-known/oauth-protected-resource déclare quel serveur d’autorisation protège la ressource
/.well-known/oauth-authorization-server publie les endpoints OAuth et les méthodes acceptées
POST /oauth/register enregistrement dynamique du client (le client_id est créé à la volée)
GET·POST /oauth/authorize page de consentement, puis émission du code d’autorisation
POST /oauth/token échange du code contre un jeton, après vérification PKCE
ALL /mcp le proxy vers WordPress, protégé par jeton Bearer

Le proxy, et le piège du streaming

Le transport Streamable HTTP peut répondre en text/event-stream : un flux qui ne se termine jamais. Un proxy qui accumule la réponse avant de la renvoyer bloque indéfiniment. Il faut la faire transiter telle quelle.

const response = await fetch(WP_MCP_URL, {
  method: req.method, headers, body: hasBody ? req.body : undefined,
  signal: controller.signal,
});

res.status(response.status);
response.headers.forEach((value, key) => {
  if (!SKIP_RESPONSE_HEADERS.has(key.toLowerCase())) res.setHeader(key, value);
});
res.flushHeaders();

// En flux, jamais accumule : une reponse SSE ne se termine pas.
Readable.fromWeb(response.body).pipe(res);

Dans SKIP_RESPONSE_HEADERS, pensez à content-encoding en plus des en-têtes hop-by-hop : fetch a déjà décompressé le corps, et recopier l’en-tête d’origine ferait tenter au client une décompression sur du texte clair.

La page de consentement doit avoir un mot de passe

Point le plus important de tout l’article, et celui qu’on oublie parce que le flux OAuth a l’air sécurisé. L’enregistrement dynamique de client est ouvert : n’importe qui peut obtenir un client_id par une simple requête. S’il ne reste ensuite qu’un bouton « Autoriser » à cliquer, votre page de consentement est l’accès administrateur à votre WordPress, à la portée de qui trouve l’URL.

if (!timingSafeEqualString(req.body.gateway_password, GATEWAY_PASSWORD)) {
  console.warn(`Consentement refuse depuis ${req.ip}`);
  return res.status(403).type("html")
    .send(consentPage({ ...result, error: "Mot de passe incorrect." }));
}

Un champ, une comparaison en temps constant, et un mot de passe que vous ne tapez qu’une fois par connexion. La comparaison naïve avec === fuit de l’information par son temps d’exécution ; passez les deux valeurs par un hachage puis crypto.timingSafeEqual.

Les autres vérifications non négociables

Le redirect_uri doit appartenir à la liste enregistrée par le client — sinon un attaquant se fait renvoyer le code d’autorisation. Le code d’autorisation est à usage unique et expire en une minute. Le code_verifier PKCE est vérifié en S256, jamais en plain. Et le jeton Bearer de Claude n’est jamais transmis à WordPress : la passerelle le consomme et fabrique son propre en-tête Basic.

Le fichier complet — src/server.js
import express from "express";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { Readable } from "node:stream";

const app = express();
app.set("trust proxy", true);

const PORT = Number(process.env.PORT || 3000);
const MCP_PATH = process.env.MCP_PATH || "/mcp";
const WP_MCP_URL = process.env.WP_MCP_URL;
const WP_USERNAME = process.env.WP_USERNAME;
const WP_APPLICATION_PASSWORD = process.env.WP_APPLICATION_PASSWORD;
const PUBLIC_URL = (process.env.PUBLIC_URL || "").replace(/\/$/, "");
const GATEWAY_PASSWORD = process.env.GATEWAY_PASSWORD;
const DATA_DIR = process.env.DATA_DIR || "/app/data";
const OAUTH_ISSUER = PUBLIC_URL;

for (const [name, value] of Object.entries({
  WP_MCP_URL,
  WP_USERNAME,
  WP_APPLICATION_PASSWORD,
  PUBLIC_URL,
  GATEWAY_PASSWORD,
})) {
  if (!value) {
    console.error(`Missing required environment variable: ${name}`);
    process.exit(1);
  }
}

/* -------------------------------------------------------------------------
 * Helpers
 * ---------------------------------------------------------------------- */

const randomString = (bytes = 32) => crypto.randomBytes(bytes).toString("base64url");

const sha256Base64Url = (value) =>
  crypto.createHash("sha256").update(value).digest("base64url");

function timingSafeEqualString(a, b) {
  if (typeof a !== "string" || typeof b !== "string" || !a || !b) return false;
  const aa = Buffer.from(a);
  const bb = Buffer.from(b);
  // Hash both sides so a length mismatch does not short-circuit and leak length.
  return crypto.timingSafeEqual(
    crypto.createHash("sha256").update(aa).digest(),
    crypto.createHash("sha256").update(bb).digest(),
  );
}

const escapeHtml = (value) =>
  String(value)
    .replaceAll("&", "&")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;");

/* -------------------------------------------------------------------------
 * Storage
 *
 * Clients and access tokens are persisted so a container restart does not
 * force reconfiguring the connector in Claude. Authorization codes are
 * short-lived and stay in memory only.
 * ---------------------------------------------------------------------- */

const STORE_FILE = path.join(DATA_DIR, "store.json");

const clients = new Map();
const accessTokens = new Map();
const authorizationCodes = new Map();

function loadStore() {
  try {
    const raw = JSON.parse(fs.readFileSync(STORE_FILE, "utf8"));
    for (const [k, v] of raw.clients || []) clients.set(k, v);
    for (const [k, v] of raw.accessTokens || []) {
      if (v.expires_at > Date.now()) accessTokens.set(k, v);
    }
    console.log(`Store loaded: ${clients.size} client(s), ${accessTokens.size} token(s)`);
  } catch (error) {
    if (error.code !== "ENOENT") console.error("Store load failed:", error.message);
  }
}

function saveStore() {
  try {
    fs.mkdirSync(DATA_DIR, { recursive: true });
    const tmp = `${STORE_FILE}.tmp`;
    fs.writeFileSync(
      tmp,
      JSON.stringify({
        clients: [...clients],
        accessTokens: [...accessTokens],
      }),
    );
    fs.renameSync(tmp, STORE_FILE);
  } catch (error) {
    console.error("Store save failed:", error.message);
  }
}

loadStore();

/* -------------------------------------------------------------------------
 * MCP proxy
 *
 * Mounted before the JSON body parsers so the request body is forwarded as
 * raw bytes, byte for byte, instead of being re-serialized.
 * ---------------------------------------------------------------------- */

function authenticateMcp(req, res, next) {
  const challenge = `Bearer realm="Mon site MCP", resource_metadata="${PUBLIC_URL}/.well-known/oauth-protected-resource${MCP_PATH}"`;
  const authorization = req.get("authorization");

  if (!authorization) {
    res.set("WWW-Authenticate", challenge);
    return res.status(401).json({ error: "unauthorized" });
  }

  const match = authorization.match(/^Bearer\s+(.+)$/i);
  if (!match) {
    res.set("WWW-Authenticate", challenge);
    return res.status(401).json({ error: "invalid_token" });
  }

  const session = accessTokens.get(match[1]);
  if (!session || session.expires_at < Date.now()) {
    if (session) {
      accessTokens.delete(match[1]);
      saveStore();
    }
    res.set("WWW-Authenticate", challenge);
    return res.status(401).json({ error: "invalid_token" });
  }

  req.oauthSession = session;
  next();
}

// Hop-by-hop headers, plus content-encoding: fetch already decoded the body,
// so forwarding the original encoding header would corrupt the response.
const SKIP_RESPONSE_HEADERS = new Set([
  "content-length",
  "content-encoding",
  "transfer-encoding",
  "connection",
  "keep-alive",
]);

const SKIP_REQUEST_HEADERS = new Set([
  "host",
  "authorization",
  "content-length",
  "connection",
  "keep-alive",
  "accept-encoding",
]);

app.all(
  MCP_PATH,
  express.raw({ type: () => true, limit: "8mb" }),
  authenticateMcp,
  async (req, res) => {
    const controller = new AbortController();
    res.on("close", () => controller.abort());

    try {
      const headers = new Headers();

      for (const [key, value] of Object.entries(req.headers)) {
        if (SKIP_REQUEST_HEADERS.has(key.toLowerCase())) continue;
        if (value === undefined) continue;
        headers.set(key, Array.isArray(value) ? value.join(", ") : value);
      }

      headers.set(
        "Authorization",
        `Basic ${Buffer.from(`${WP_USERNAME}:${WP_APPLICATION_PASSWORD}`).toString("base64")}`,
      );

      const hasBody =
        req.method !== "GET" &&
        req.method !== "HEAD" &&
        Buffer.isBuffer(req.body) &&
        req.body.length > 0;

      const response = await fetch(WP_MCP_URL, {
        method: req.method,
        headers,
        body: hasBody ? req.body : undefined,
        signal: controller.signal,
        redirect: "follow",
      });

      res.status(response.status);
      response.headers.forEach((value, key) => {
        if (!SKIP_RESPONSE_HEADERS.has(key.toLowerCase())) res.setHeader(key, value);
      });
      res.flushHeaders();

      if (!response.body) return res.end();

      // Streamed, not buffered: an SSE response never ends, so accumulating it
      // would hang the request forever.
      Readable.fromWeb(response.body).pipe(res);
    } catch (error) {
      if (controller.signal.aborted) return;
      console.error("MCP proxy error:", error);
      if (!res.headersSent) {
        res.status(502).json({
          error: "bad_gateway",
          message: "Unable to reach WordPress MCP server.",
        });
      } else {
        res.end();
      }
    }
  },
);

/* -------------------------------------------------------------------------
 * Body parsers for the OAuth endpoints
 * ---------------------------------------------------------------------- */

app.use(express.urlencoded({ extended: false }));
app.use(express.json());

/* -------------------------------------------------------------------------
 * OAuth metadata
 *
 * Both the bare path and the resource-suffixed variant are served: MCP
 * clients look up /.well-known/<doc><resource-path> first (RFC 9728) and
 * only then fall back to the root.
 * ---------------------------------------------------------------------- */

app.get(
  ["/.well-known/oauth-authorization-server", `/.well-known/oauth-authorization-server${MCP_PATH}`],
  (req, res) => {
    res.json({
      issuer: OAUTH_ISSUER,
      authorization_endpoint: `${PUBLIC_URL}/oauth/authorize`,
      token_endpoint: `${PUBLIC_URL}/oauth/token`,
      registration_endpoint: `${PUBLIC_URL}/oauth/register`,
      response_types_supported: ["code"],
      grant_types_supported: ["authorization_code"],
      code_challenge_methods_supported: ["S256"],
      token_endpoint_auth_methods_supported: ["none"],
      scopes_supported: ["wordpress"],
    });
  },
);

app.get(
  ["/.well-known/oauth-protected-resource", `/.well-known/oauth-protected-resource${MCP_PATH}`],
  (req, res) => {
    res.json({
      resource: `${PUBLIC_URL}${MCP_PATH}`,
      authorization_servers: [OAUTH_ISSUER],
      scopes_supported: ["wordpress"],
      bearer_methods_supported: ["header"],
    });
  },
);

/* -------------------------------------------------------------------------
 * Dynamic client registration
 * ---------------------------------------------------------------------- */

app.post("/oauth/register", (req, res) => {
  const {
    client_name,
    redirect_uris,
    grant_types,
    response_types,
    token_endpoint_auth_method,
  } = req.body || {};

  if (!Array.isArray(redirect_uris) || redirect_uris.length === 0) {
    return res.status(400).json({
      error: "invalid_client_metadata",
      error_description: "redirect_uris is required",
    });
  }

  for (const uri of redirect_uris) {
    let parsed;
    try {
      parsed = new URL(uri);
    } catch {
      return res.status(400).json({ error: "invalid_redirect_uri" });
    }
    if (parsed.protocol !== "https:" && parsed.hostname !== "localhost") {
      return res.status(400).json({ error: "invalid_redirect_uri" });
    }
  }

  const client = {
    client_id: randomString(24),
    client_name: client_name || "MCP Client",
    redirect_uris,
    grant_types: grant_types || ["authorization_code"],
    response_types: response_types || ["code"],
    token_endpoint_auth_method: token_endpoint_auth_method || "none",
    created_at: Date.now(),
  };

  clients.set(client.client_id, client);
  saveStore();

  console.log(`Registered client ${client.client_id} (${client.client_name})`);

  res.status(201).json({
    client_id: client.client_id,
    client_name: client.client_name,
    redirect_uris: client.redirect_uris,
    grant_types: client.grant_types,
    response_types: client.response_types,
    token_endpoint_auth_method: client.token_endpoint_auth_method,
  });
});

/* -------------------------------------------------------------------------
 * Authorization endpoint
 * ---------------------------------------------------------------------- */

function consentPage({ client, params, error }) {
  const fields = Object.entries(params)
    .map(
      ([name, value]) =>
        `<input type="hidden" name="${escapeHtml(name)}" value="${escapeHtml(value)}">`,
    )
    .join("\n");

  return `<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>Mon site MCP</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<style>
body{font-family:system-ui,sans-serif;max-width:520px;margin:80px auto;padding:20px;line-height:1.5}
.card{border:1px solid #ddd;border-radius:12px;padding:24px}
input[type=password]{width:100%;padding:10px;font-size:16px;border:1px solid #ccc;border-radius:8px;box-sizing:border-box}
button{background:#111;color:#fff;border:0;border-radius:8px;padding:12px 20px;font-size:16px;cursor:pointer;margin-top:16px}
.error{color:#b00020;font-weight:600}
</style>
</head>
<body>
<div class="card">
<h1>Mon site MCP</h1>
<p><strong>${escapeHtml(client.client_name)}</strong> demande l'accès à votre serveur WordPress.</p>
<p><strong>Compte WordPress :</strong> ${escapeHtml(WP_USERNAME)}</p>
<p><strong>Portée :</strong> ${escapeHtml(params.scope)}</p>
${error ? `<p class="error">${escapeHtml(error)}</p>` : ""}
<form method="POST" action="/oauth/authorize">
${fields}
<label for="gateway_password">Mot de passe du gateway</label>
<input id="gateway_password" type="password" name="gateway_password" autocomplete="current-password" required autofocus>
<button type="submit">Autoriser</button>
</form>
</div>
</body>
</html>`;
}

function validateAuthorizeParams(source) {
  const {
    client_id,
    redirect_uri,
    response_type,
    state,
    code_challenge,
    code_challenge_method,
    scope,
    resource,
  } = source;

  if (
    !client_id ||
    !redirect_uri ||
    response_type !== "code" ||
    !state ||
    !code_challenge ||
    code_challenge_method !== "S256"
  ) {
    return { error: "Requête OAuth invalide." };
  }

  const client = clients.get(client_id);
  if (!client) return { error: "Client OAuth inconnu." };
  if (!client.redirect_uris.includes(redirect_uri)) return { error: "Redirect URI invalide." };

  return {
    client,
    params: {
      client_id,
      redirect_uri,
      response_type,
      state,
      code_challenge,
      code_challenge_method,
      scope: scope || "wordpress",
      ...(resource ? { resource } : {}),
    },
  };
}

app.get("/oauth/authorize", (req, res) => {
  const result = validateAuthorizeParams(req.query);
  if (result.error) return res.status(400).send(result.error);
  res.type("html").send(consentPage(result));
});

app.post("/oauth/authorize", (req, res) => {
  const result = validateAuthorizeParams(req.body);
  if (result.error) return res.status(400).send(result.error);

  if (!timingSafeEqualString(req.body.gateway_password, GATEWAY_PASSWORD)) {
    console.warn(`Rejected consent attempt from ${req.ip}`);
    return res
      .status(403)
      .type("html")
      .send(consentPage({ ...result, error: "Mot de passe incorrect." }));
  }

  const code = randomString(32);
  authorizationCodes.set(code, {
    client_id: result.params.client_id,
    redirect_uri: result.params.redirect_uri,
    code_challenge: result.params.code_challenge,
    scope: result.params.scope,
    expires_at: Date.now() + 60_000,
  });

  const redirect = new URL(result.params.redirect_uri);
  redirect.searchParams.set("code", code);
  redirect.searchParams.set("state", result.params.state);

  console.log(`Authorization granted to ${result.params.client_id}`);
  res.redirect(redirect.toString());
});

/* -------------------------------------------------------------------------
 * Token endpoint
 * ---------------------------------------------------------------------- */

const TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60;

app.post("/oauth/token", (req, res) => {
  const { grant_type, code, redirect_uri, client_id, code_verifier } = req.body || {};

  if (grant_type !== "authorization_code") {
    return res.status(400).json({ error: "unsupported_grant_type" });
  }
  if (!code || !redirect_uri || !client_id || !code_verifier) {
    return res.status(400).json({ error: "invalid_request" });
  }

  const authorization = authorizationCodes.get(code);
  authorizationCodes.delete(code);

  if (
    !authorization ||
    authorization.expires_at < Date.now() ||
    authorization.client_id !== client_id ||
    authorization.redirect_uri !== redirect_uri
  ) {
    return res.status(400).json({ error: "invalid_grant" });
  }

  if (!timingSafeEqualString(sha256Base64Url(code_verifier), authorization.code_challenge)) {
    return res.status(400).json({ error: "invalid_grant", error_description: "Invalid PKCE verifier." });
  }

  const accessToken = randomString(48);
  accessTokens.set(accessToken, {
    client_id,
    scope: authorization.scope,
    expires_at: Date.now() + TOKEN_TTL_SECONDS * 1000,
  });
  saveStore();

  console.log(`Issued access token to ${client_id}`);

  res.json({
    access_token: accessToken,
    token_type: "Bearer",
    expires_in: TOKEN_TTL_SECONDS,
    scope: authorization.scope,
  });
});

/* -------------------------------------------------------------------------
 * Health
 * ---------------------------------------------------------------------- */

app.get("/health", (req, res) => {
  res.json({
    status: "ok",
    service: "mcp-gateway",
    clients: clients.size,
    tokens: accessTokens.size,
  });
});

setInterval(() => {
  const now = Date.now();
  let changed = false;
  for (const [code, value] of authorizationCodes) {
    if (value.expires_at < now) authorizationCodes.delete(code);
  }
  for (const [token, value] of accessTokens) {
    if (value.expires_at < now) {
      accessTokens.delete(token);
      changed = true;
    }
  }
  if (changed) saveStore();
}, 60_000).unref();

app.listen(PORT, "0.0.0.0", () => {
  console.log(`Mon site MCP Gateway listening on ${PORT}`);
  console.log(`Public URL:   ${PUBLIC_URL}${MCP_PATH}`);
  console.log(`WordPress MCP: ${WP_MCP_URL}`);
});

Étape 5 — Docker, Traefik, TLS

Trois fichiers. Le Dockerfile d’abord, avec un volume pour les jetons — sans lui, chaque redémarrage force à reconfigurer le connecteur côté Claude.

Dockerfile

FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY src ./src

# Possede par node, pour que le volume monte ici reste inscriptible.
RUN mkdir -p /app/data && chown -R node:node /app/data

USER node
EXPOSE 3000
CMD ["npm", "start"]

.env — chmod 600, jamais dans Git

PUBLIC_URL=https://mcp.exemple.fr
MCP_PATH=/mcp

WP_MCP_URL=https://exemple.fr/wp-json/mcp/mcp-adapter-default-server
WP_USERNAME=moi
WP_APPLICATION_PASSWORD=xxxx xxxx xxxx xxxx xxxx xxxx

GATEWAY_PASSWORD=# openssl rand -base64 24

Puis le service, à ajouter à votre stack existante plutôt que dans un projet isolé :

docker-compose.yml

  mcp-gateway:
    build: ./mcp-gateway
    container_name: mcp-gateway
    restart: unless-stopped
    env_file:
      - ./mcp-gateway/.env
    volumes:
      - mcp_gateway_data:/app/data
    networks:
      - frontend
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.mcp.rule=Host(`mcp.exemple.fr`)"
      - "traefik.http.routers.mcp.entrypoints=websecure"
      - "traefik.http.routers.mcp.tls.certresolver=letsencrypt"
      - "traefik.http.services.mcp.loadbalancer.server.port=3000"

Pas de middleware buffering

La tentation est d’ajouter buffering.maxRequestBodyBytes « pour les grosses requêtes ». Ce middleware met aussi les réponses en tampon et annule le travail fait à l’étape précédente. Laissez passer.

Sur un fichier compose qui pilote déjà d’autres services, validez avant d’appliquer et démarrez la cible seule — un up -d global recréerait les conteneurs dont l’image a bougé depuis leur démarrage :

docker compose config -q          # valide le YAML entier
docker compose up -d mcp-gateway  # ne touche que ce service

Vérification

Dans l’ordre, et on ne passe pas au suivant si l’un échoue :

curl https://mcp.exemple.fr/health
# {"status":"ok", ...}

curl https://mcp.exemple.fr/.well-known/oauth-protected-resource/mcp
# {"resource":"https://mcp.exemple.fr/mcp", ...}

curl -i https://mcp.exemple.fr/mcp
# 401 + WWW-Authenticate: Bearer realm=..., resource_metadata=...

Les deux variantes de .well-known

Quand la ressource est servie sur /mcp, le client cherche d’abord /.well-known/oauth-protected-resource/mcp — le chemin de la ressource inséré après le nom du document, comme le prévoit la RFC 9728 — avant de retomber sur la racine. Servez les deux, c’est une ligne :

app.get([
  "/.well-known/oauth-protected-resource",
  `/.well-known/oauth-protected-resource${MCP_PATH}`,
], (req, res) => { /* ... */ });

Derrière un CDN

Cloudflare et consorts fonctionnent, mais commencez en direct : le proxy désactivé, vous voyez les vraies erreurs de votre serveur. Une fois la chaîne validée, remettez le proxy si vous le souhaitez. Et si le certificat ne s’émet pas, regardez les logs du reverse proxy avant de soupçonner la passerelle — ce genre de panne est presque toujours antérieure au service qu’on vient d’ajouter.

Étape 6 — Le connecteur côté Claude

Réglages → Connecteurs → Ajouter un connecteur personnalisé.

  • URL : https://mcp.exemple.fr/mcp
  • Transport : HTTP streamable
  • Authentification : toujours requise
  • Client OAuth : aucun identifiant, en enregistrer un automatiquement

L’erreur la plus fréquente

Laisser l’authentification sur « non requise ». Claude appelle alors /mcp sans jeton, reçoit le 401, et affiche que le serveur demande une connexion alors que le connecteur est configuré sans. Le serveur a raison, c’est le réglage qui est faux.

Gardez les logs du conteneur ouverts pendant la connexion. La séquence attendue est sans ambiguïté :

Registered client K7f2xQm9… (Claude)
Authorization granted to K7f2xQm9…
Issued access token to K7f2xQm9…

Si la première ligne manque, Claude n’a pas fait la découverte : problème de DNS, de TLS, ou de réglage d’authentification. Si les deux premières passent mais pas la troisième, c’est PKCE ou le redirect_uri.

Trois tests dans la conversation, dans cet ordre : lister les capacités disponibles, chercher les derniers articles, en lire un. S’ils passent, c’est fini.

Ce qui reste ouvert

Le montage fonctionne, mais deux choses méritent d’être regardées en face avant de le laisser tourner.

Un seul outil, tous les droits

Souvenez-vous de mcp-adapter-execute-ability. C’est une clé universelle : elle atteint toutes vos abilities sans distinction, avec les droits du compte configuré dans la passerelle. Si ce compte est administrateur et que vous avez déclaré publish-post, le modèle peut publier dès la première conversation.

Deux corrections, cumulables. Côté WordPress, resserrez chaque permission_callback et utilisez un compte dédié au rôle minimal plutôt que votre compte admin. Côté passerelle, filtrez au passage — le proxy voit le corps JSON-RPC et peut refuser ce qui n’est pas sur la liste :

const ALLOWED = new Set([
  "core/get-site-info", "monsite/search-posts", "monsite/get-post",
  "monsite/list-categories", "monsite/list-tags",
  "monsite/create-draft", "monsite/update-post",
]);
// publish-post absente : la passerelle la refuse, WordPress n'est pas sollicite.

La deuxième couche est celle qui tient : elle ne dépend pas du modèle, ni de sa bonne compréhension d’une annotation destructive.

C’est la direction que j’ai prise avec mon propre plugin. Depuis la version 1.1.0, il distingue la lecture seule, l’écriture, puis deux niveaux qui restent éteints tant que je ne les active pas : la publication et l’administration. Sans le premier, l’assistant dépose des brouillons et ne publie rien. Sans le second, il ne touche ni aux options, ni aux extensions, ni aux routes REST sensibles. Chaque écriture crée une révision avant de s’exécuter, refuse par défaut de supprimer une iframe, un script ou un shortcode, et laisse une ligne dans un journal. Les remplacements en masse passent par une simulation : on n’applique que si le total annoncé est celui qu’on attend.

Le stockage des sessions

Ranger clients et jetons dans un fichier JSON suffit largement pour un site personnel : un client, un jeton, quelques octets. Ça résiste au redémarrage, ce qui est l’essentiel. Ce n’est en revanche pas une implémentation OAuth multi-utilisateurs — pas de rotation, pas de révocation depuis une interface, pas de refresh token. Pour un blog, c’est un choix assumé ; pour un usage partagé, il faut reprendre cette partie.

Pour la suite, et pour savoir ce que je laisse (ou non) à l’IA sur ce blog : Le contenu IA et Écrire plus pour écrire mieux ?.


Écrit et testé sur WordPress 6.9 avec MCP Adapter 0.6.1, Node 22 et Traefik v3. La même chaîne tourne aujourd’hui sur WordPress 7.1 avec MCP Adapter 0.7.1. Les sorties de commandes reproduites ici viennent d’une installation réelle.

By tech