Tutoriel
Comment ajouter OAuth 2.1 à un serveur MCP : de l’Authorization Server au jeton d’accès pour l’API MCP distante
Le MCP sans état règle la session, pas qui a le droit d’appeler vos outils. Une fois le MCP distant sur l’internet public, l’authentification va de la découverte de l’Authorization Server jusqu’à l’Access Token — pas un secret partagé fourré dans le JSON-RPC.
MCP 2026-07-28 marque l’autorisation HTTP comme facultative ; dès que vous protégez une ressource, il faut le sous-ensemble OAuth 2.1. Cursor, Claude Desktop, le MCP distant de OpenAI Responses et les hôtes d’agents maison commencent par une requête sans jeton. Le serveur répond 401 et pointe vers les Protected Resource Metadata dans WWW-Authenticate. Le client découvre ensuite l’Authorization Server, s’enregistre, enchaîne code d’autorisation + PKCE avec le paramètre resource, reçoit un Access Token et pose Authorization: Bearer sur chaque saut HTTP. Voici ce chemin d’API MCP distante en pratique : qui fait quoi, à quoi ressemblent les metadata, comment lier l’audience, comment monter en portée. Spécification : MCP Authorization. Nous avons déjà ce qu’est MCP, MCP sans état et inputSchema et tool calling — cet article n’ajoute que « qui a le droit d’appeler ». N’appliquez pas cette danse navigateur au STDIO ; prenez les identifiants dans l’environnement.
Trois rôles : le serveur MCP n’émet pas de jeton
La première erreur est de traiter le serveur MCP comme l’émetteur de jetons. Dans la spec, un serveur MCP protégé est un serveur de ressources OAuth 2.1 : il accepte, valide et consomme des Access Tokens liés à son audience. Le client MCP est un client OAuth 2.1 : il obtient un jeton pour le propriétaire de la ressource et appelle les outils avec. L’Authorization Server (AS) gère connexion, consentement et émission. L’AS peut cohabiter avec le serveur de ressources ou être un IdP existant (Okta, Keycloak, Auth0, OIDC maison). La spec ne dit pas comment construire un AS ; elle dit comment le serveur MCP l’annonce — voir Authorization Server Discovery.
L’autorisation est facultative pour MCP. Les transports HTTP qui protègent des ressources DEVRAIENT suivre cette spec. Le STDIO NE DOIT PAS. Glisser une clé API partagée dans les params de tools/call, ou un jeton dans le _meta JSON-RPC, n’est pas l’auth MCP distante 2026-07-28. L’auth vit sur le transport HTTP, pas dans l’enveloppe de méthode.
Face à nos autres couches : MCP est découverte et appel ; le sans-état a retiré la session, pas l’auth ; inputSchema forme les arguments, et un Schema valide ne veut pas dire que l’appelant est autorisé. OAuth répond « ce Bearer a-t-il été émis pour cette ressource, et la portée suffit-elle ? » La boucle agent : qu’est-ce qu’un agent IA.
| Rôle | Casquette OAuth 2.1 | Ce que vous livrez |
|---|---|---|
| Client / hôte MCP | Client OAuth | Découverte, PKCE, stockage des jetons, Bearer à chaque saut |
| Serveur MCP | Serveur de ressources | 401 + PRM, valider les jetons, lier l’audience |
| Authorization Server | Émetteur / IdP | Connexion, consentement, code, Access Token |
Trouver l’Authorization Server : 401 et RFC 9728
Le labo commence par un appel raté. Le client frappe https://mcp.example.com/mcp avec tools/list ou n’importe quel JSON-RPC, sans Authorization. Le serveur DOIT renvoyer HTTP 401 avec Bearer et resource_metadata sur WWW-Authenticate. Ajoutez scope pour le minimum à demander. Les clients DOIVENT parser cet en-tête : utiliser resource_metadata s’il est là ; sinon sonder les URI well-known selon RFC 9728.
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="mcp",
resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
scope="invoices:read"
Les serveurs MCP DOIVENT implémenter OAuth 2.0 Protected Resource Metadata (RFC 9728). Le document DOIT lister au moins un émetteur AS dans authorization_servers. Vous pouvez aussi poser resource (URI canonique de ce serveur), scopes_supported et bearer_methods_supported. Le client récupère ensuite les metadata AS : sans chemin, essayer /.well-known/oauth-authorization-server puis OpenID openid-configuration ; avec un chemin tenant, l’insérer dans l’ordre de priorité de la spec. Le issuer du document DOIT être une égalité de chaîne exacte avec l’émetteur utilisé pour construire l’URL. Sinon, traitez-le comme une attaque et jetez le document.
{
"resource": "https://mcp.example.com/mcp",
"authorization_servers": [
"https://auth.example.com"
],
"bearer_methods_supported": ["header"],
"scopes_supported": [
"invoices:read",
"invoices:write"
],
"resource_documentation": "https://mcp.example.com/docs"
}
Ci-dessus : le 401 et un Protected Resource Metadata minimal. resource ne doit ni porter de fragment ni omettre le schéma. Soyez cohérents sur la barre finale ; la spec préfère l’absence sauf si elle a un sens. Si vous listez plusieurs AS, chacun est un émetteur indépendant — les clients DOIVENT séparer l’état d’enregistrement.
Enregistrer le client : CIMD, pré-enregistrement, DCR
AS trouvé, le client DOIT avoir un client_id avant d’autoriser. Trois mécanismes, dans l’ordre de la spec : ① Client ID Metadata Documents (CIMD) — le client_id est une URL HTTPS ; l’AS récupère ce JSON et vérifie redirect_uris ; ② pré-enregistrement console (confidentiel ou public) ; ③ Dynamic Client Registration (RFC 7591) POST /register. DCR est déprécié et ne reste que pour les AS qui ne font pas CIMD. Un projet neuf privilégie CIMD ou le pré-enregistrement.
Quand authorization_servers en liste plusieurs, chacun est un AS distinct. Stockez client_id, secrets et jetons par AS. N’envoyez jamais les identifiants de A vers le point de jeton de B. C’est une porte classique de mix-up / confused deputy.
Les clients publics (hôtes bureau, extensions) DOIVENT faire du PKCE sous OAuth 2.1. Les clients confidentiels aussi. N’inventez pas « mettre client_secret dans l’env du serveur MCP et relayer l’enregistrement vers l’AS ». Le serveur MCP est un serveur de ressources, pas un client.
Code d’autorisation + PKCE + resource : obtenir l’Access Token
Ensuite seulement le flux code. Avant d’ouvrir le navigateur, le client DOIT : générer code_verifier / code_challenge PKCE ; mettre resource (URI canonique du serveur MCP, RFC 8707) sur les requêtes d’autorisation et de jeton ; choisir les portées (préférer le scope du 401, sinon scopes_supported) ; enregistrer l’issuer AS validé sur le même enregistrement que le vérificateur. Après consentement, comparer le iss du callback à cette valeur par une comparaison de chaînes simple (RFC 9207) — pas de pliage de casse, pas de suppression de barre.
La requête de jeton porte code, code_verifier et resource. Le succès est du JSON : access_token, token_type Bearer, expires_in, parfois refresh_token et scope. Ne supposez pas un refresh token. Ne mettez pas offline_access dans scopes_supported du serveur MCP ni dans le scope du 401 — c’est une demande client vers l’AS, pas un besoin de ressource. Les champs AS metadata clés suivent ; si authorization_response_iss_parameter_supported vaut true, un callback sans iss DOIT être rejeté.
{
"issuer": "https://auth.example.com",
"authorization_endpoint": "https://auth.example.com/oauth/authorize",
"token_endpoint": "https://auth.example.com/oauth/token",
"jwks_uri": "https://auth.example.com/.well-known/jwks.json",
"registration_endpoint": "https://auth.example.com/oauth/register",
"code_challenge_methods_supported": ["S256"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"response_types_supported": ["code"],
"token_endpoint_auth_methods_supported": [
"none",
"client_secret_basic"
],
"authorization_response_iss_parameter_supported": true,
"scopes_supported": [
"invoices:read",
"invoices:write",
"offline_access"
]
}
Pour le client, un Access Token est souvent opaque ; au debug, c’est souvent un JWT. Lisez iss, aud (ou resource), scope, exp, client_id. aud DOIT lier ce serveur MCP. Un jeton émis pour l’API paiements ne doit pas exécuter tools/call. Le payload ci-dessous a le bon aud ; pointez-le vers l’URI d’une autre API et le serveur de ressources DOIT répondre 401.
{
"iss": "https://auth.example.com",
"sub": "user_1842",
"aud": "https://mcp.example.com/mcp",
"client_id": "https://host.example.com/oauth/client.json",
"scope": "invoices:read",
"exp": 1790000000,
"iat": 1789996400
}
Appeler l’API MCP distante en Bearer et vérifier l’audience
Après le jeton, chaque saut HTTP Client → serveur MCP DOIT porter Authorization: Bearer : découverte, tools/list, tools/call, resources/read. Les jetons NE DOIVENT PAS apparaître dans la query. Noms de méthodes JSON-RPC et arguments restent dans le corps — l’en-tête d’auth et l’enveloppe sont deux couches. Le MCP sans état n’a pas de session « connecté, on saute le contrôle » ; chaque requête s’authentifie. Voir MCP sans état.
POST /mcp HTTP/1.1
Host: mcp.example.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json
Accept: application/json, text/event-stream
{
"jsonrpc": "2.0",
"id": 42,
"method": "tools/call",
"params": {
"name": "searchInvoices",
"arguments": {
"startDate": "2026-01-01",
"endDate": "2026-01-31",
"status": "paid"
}
}
}
Le serveur valide selon OAuth 2.1 §5.2 : signature ou introspection, exp, iss, audience RFC 8707. Les échecs sont des 401. Après l’audience, confirmez encore que cet AS a émis le jeton. Les serveurs NE DOIVENT NI accepter NI transiter des jetons destinés à autrui. Les clients NE DOIVENT PAS envoyer le jeton d’une autre ressource à ce serveur MCP. C’est la règle du confused deputy.
Un jeton valide ne rend pas les arguments valides. Bearer répond « qui, quelle ressource, quelles portées ». Dates et énums de searchInvoices exigent encore parse + Schema + règles métier. Ne faites pas de « jeton OK » l’unique portail de tools/call. Contrat de forme : MCP et JSON Schema.
Portées, 403 et mise au point dans JSONVue
Quand le jeton est bon mais la portée non, le serveur DEVRAIT renvoyer 403 avec error="insufficient_scope", le scope nécessaire à cette opération, et le même resource_metadata sur WWW-Authenticate. Mettez toutes les portées de cette opération dans un seul défi — ne les versez pas une par une. Au step-up, le client unit les anciennes portées au défi pour ne pas perdre les droits déjà obtenus ailleurs.
| HTTP | Sens | Ensuite |
|---|---|---|
| 401 | Non authentifié, ou jeton invalide / expiré / mauvaise audience | Lire le PRM ; réautoriser ou rafraîchir |
| 403 + insufficient_scope | Jeton valide, permission insuffisante | Step-up : fusionner les portées et réautoriser |
| 400 | Requête d’autorisation mal formée | Corriger la requête client ; ne pas relancer l’outil |
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope",
scope="invoices:write",
resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
error_description="Write scope required to create a credit note"
Un labo produit au moins quatre JSON : Protected Resource Metadata, metadata AS, réponse jeton, et (si JWT) le payload — plus params.arguments de tools/call. Ils n’ont pas le même métier ; ne les fusionnez pas en un Schema « universel ». Gardez un fixture d’échec chacun : PRM sans authorization_servers, metadata AS au issuer faux, JWT au mauvais aud, arguments sans un champ.
Vous pouvez le faire dans le navigateur : formateur JSON pour voir si les metadata parsent ; Décodeur JWT pour aud / scope / exp ; Validateur JSON Schema contre le contrat écrit pour le PRM ; JSON Diff pour comparer les payloads avant/après refresh, ou arguments modèle vs params MCP. Rien ne quitte la machine.
Pour aller plus loin : Qu’est-ce que MCP, MCP sans état, MCP et JSON Schema, A2A vs MCP, Qu’est-ce qu’un agent IA.
FAQ
Un MCP STDIO local a-t-il besoin d’OAuth 2.1 ?
Non. La spec dit de ne pas lancer ce flux navigateur en STDIO ; prenez les identifiants dans l’environnement. OAuth 2.1 est pour le MCP distant HTTP. Les mêmes outils peuvent exposer STDIO et Streamable HTTP ; seule la face HTTP a besoin de 401, PRM et Bearer.
Puis-je mettre l’Access Token dans les params JSON-RPC ou _meta ?
Pas en implémentation conforme. L’Access Token DOIT voyager dans l’en-tête HTTP Authorization et NE DOIT PAS apparaître dans la query. Le _meta d’enveloppe sert à la version de protocole, pas à la connexion. Un jeton dans le corps finira dans les journaux, les proxies et le contexte du modèle.
Si j’émets moi-même des JWT et que le serveur vérifie un secret partagé, ai-je implémenté MCP OAuth ?
Vérifier la signature ne suffit pas. Le client découvre un Authorization Server, pas « une clé cuite dans le serveur ». Il faut encore les metadata RFC 9728, authorization_servers, resource sur autoriser et jeton, la liaison d’audience, et les défis 401 / 403. Un AS maison peut émettre des JWT ; la découverte et le paramètre resource ne sont pas facultatifs.
Notre passerelle API vérifie déjà les JWT. Faut-il encore ça ?
La passerelle peut vérifier signature et expiration. Les hôtes MCP doivent encore découvrir l’AS, envoyer resource et parser WWW-Authenticate. Vérifier « n’importe quel JWT valide » sans lier l’audience laisse entrer des jetons émis pour d’autres API. Traitez MCP comme du REST banal et les hôtes n’achèvent souvent pas la poignée de main.
Résumé et suite
OAuth 2.1 sur MCP distant n’est pas « ajouter une page de connexion ». C’est un contrat de transport : découverte 401, metadata AS, enregistrement client, PKCE, resource, Access Token, Bearer à chaque saut. Le serveur MCP est le serveur de ressources. L’Authorization Server émet les jetons.
Livrez dans cet ordre : rendre 401 + Protected Resource Metadata parsables par un client MCP existant, puis brancher l’AS, puis écrire la logique métier de tools/call. Gardez metadata et fixtures JWT dans JSONVue, séparés de la validation Schema. Entrée protocole : ce qu’est MCP. Transport sans état : MCP sans état.