CodeGym /Cours /ChatGPT Apps /Configuration d’un serveur MCP en ressource protégée ...

Configuration d’un serveur MCP en ressource protégée : .well-known, Bearer, audience/scope

ChatGPT Apps
Niveau 10 , Leçon 3
Disponible

1. Serveur MCP en tant que Resource Server : ce que nous configurons précisément

Dans le cours précédent, nous avons configuré l’Auth Server — le composant qui émet les jetons. À présent, occupons‑nous de l’autre côté du couple : le serveur MCP en tant que Resource Server, qui reçoit et vérifie ces jetons.

Du point de vue d’OAuth 2.1, votre serveur MCP est un Resource Server. Il stocke des « ressources » (outils MCP, données utilisateur) et reçoit des requêtes avec un access token dans l’en‑tête Authorization: Bearer .... Avant d’exécuter un tool, il doit vérifier que le jeton est authentique, non expiré, émis par un serveur d’autorisation de confiance (Auth Server) et destiné à ce serveur MCP, avec les droits requis (scope).

Il est important de distinguer deux niveaux :

  1. Le niveau transport — ici on gère les en‑têtes HTTP et les jetons. Vous y :
    • acceptez/analysez Authorization: Bearer,
    • rendez 401 Unauthorized avec WWW-Authenticate: Bearer ... en cas d’absence/erreur de jeton,
    • formez le contexte utilisateur en cas de jeton valide.
  2. Le niveau SDK MCP, qui n’a pas besoin de connaître JWT. Il reçoit simplement un appel « déjà authentifié » et, à l’intérieur du handler, peut utiliser ctx.userId, ctx.scopes, etc.

Analogiquement : le SDK MCP est le cuisinier, et le middleware OAuth est l’agent de sécurité à l’entrée. Le cuisinier ne contrôle pas les passeports, il prépare les commandes.

Comme exemple pédagogique, poursuivons avec GiftGenius : serveur MCP sur http://localhost:3000 avec l’outil list_my_gifts, et Auth Server (par exemple, Keycloak ou un mini AS maison) sur http://localhost:4000.

2. .well-known/oauth-protected-resource : la carte de visite de votre ressource MCP

Pourquoi .well-known pour la ressource

Lorsque ChatGPT (ou MCP Jam) contacte votre serveur MCP pour la première fois et reçoit un 401, il doit comprendre deux choses :

  • où aller chercher le jeton ;
  • quels droits (scopes) cette ressource prend en charge.

Pour éviter de tout « coder en dur » côté clients, on utilise un endpoint de découverte :

GET /.well-known/oauth-protected-resource

Cet endpoint renvoie un JSON de métadonnées de ressource protégée (Protected Resource Metadata) conforme à la RFC 9728.

Exemple pour GiftGenius :

{
  "resource": "http://localhost:3000",
  "authorization_servers": ["http://localhost:4000"],
  "scopes_supported": ["gifts:read", "gifts:write"],
  "bearer_methods_supported": ["header"]
}

OpenAI montre dans ses guides un exemple très proche, simplement en HTTPS et avec des domaines réels.

Le client (ChatGPT/Jam) lit ce document et :

  • comprend que le jeton doit avoir pour audience http://localhost:3000,
  • comprend avec quels authorization_servers travailler (URL de l’issuer),
  • voit la liste des scopes pris en charge (plus simple pour construire l’écran de consentement et les suggestions).

Analyse des champs de métadonnées

Résumé des principaux champs :

Champ Rôle
resource
Identifiant canonique HTTPS/HTTP du serveur MCP. Doit ensuite correspondre au aud du jeton.
authorization_servers
Liste des URL de vos serveurs d’autorisation (Auth Server/issuer). Le client ira y chercher les métadonnées OAuth/OIDC.
scopes_supported
Tableau des scopes pris en charge ; utile au client pour un bon UX et une demande de jeton correcte.
bearer_methods_supported
Méthodes de transmission du jeton : généralement ["header"], donc Authorization: Bearer ....

En complément, on publie parfois resource_documentation, jwks_uri, introspection_endpoint, etc., mais pour le scénario de base, les quatre premiers suffisent.

Point critique : resource doit correspondre à ce que l’Auth Server place dans le aud du jeton. S’ils ne correspondent pas, le client MCP (et vous) rejettera le jeton.

Implémentation de .well-known dans Next.js 16

Supposons que notre serveur MCP vive dans une application Next.js (Apps SDK backend, port 3000). Le plus simple est d’ajouter un route handler dans app/.well-known/oauth-protected-resource/route.ts :


// app/.well-known/oauth-protected-resource/route.ts
import { NextResponse } from "next/server";

export async function GET() {
  const body = {
    resource: "http://localhost:3000",
    authorization_servers: ["http://localhost:4000"],
    scopes_supported: ["gifts:read", "gifts:write"],
    bearer_methods_supported: ["header"],
  };

  return NextResponse.json(body);
}

En production, resource doit être l’URL HTTPS de l’environnement de prod de votre serveur MCP (par exemple, https://mcp.giftgenius.com), et il doit correspondre au aud dans les jetons de l’IdP.

3. WWW-Authenticate et 401 : comment le MCP indique « un jeton est requis »

Nous avons préparé la « carte de visite » de la ressource dans .well-known/oauth-protected-resource. Voyons maintenant comment le serveur MCP signale au client qu’il doit aller la consulter — via un 401 et l’en‑tête WWW-Authenticate.

Scénario de base : arrivée sans jeton

Imaginons que ChatGPT appelle pour la première fois l’outil list_my_gifts. La requête réseau ressemble à ceci :

GET /mcp/tools/list_my_gifts HTTP/1.1
Host: localhost:3000

Pas de jeton. Le serveur MCP ne doit pas renvoyer silencieusement un 403 ni une page HTML quelconque. Le comportement correct d’une ressource protégée dans l’univers OAuth est de renvoyer 401 Unauthorized et, via l’en‑tête WWW-Authenticate, expliquer au client comment s’autoriser.

Exemple de bonne réponse :

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="http://localhost:3000/.well-known/oauth-protected-resource", scope="gifts:read"
Content-Type: application/json

{"error":"unauthorized","error_description":"Missing or invalid access token"}

Détails importants :

  • le schéma Bearer indique que nous attendons un jeton OAuth Bearer ;
  • le paramètre resource_metadata pointe vers l’URL .well-known/oauth-protected-resource ;
  • le paramètre scope suggère la portée minimale requise (par exemple gifts:read).

MCP Jam et ChatGPT savent lire cet en‑tête. En le voyant, ils :

  1. appellent .well-known/oauth-protected-resource ;
  2. trouvent, via authorization_servers, l’Auth Server et ses métadonnées OpenID/OAuth ;
  3. lancent le flux Authorization Code + PKCE, ouvrent la page de connexion et obtiennent un jeton.

Autrement dit, WWW-Authenticate est le déclencheur : sans lui, le client ne devinerait même pas qu’OAuth est disponible ici.

Middleware pour les réponses 401 (Next.js)

Écrivons un petit utilitaire à utiliser sur tous les endpoints protégés. D’abord, une fonction qui forme la réponse :

// lib/authResponses.ts
import { NextResponse } from "next/server";

export function unauthorized(scope?: string) {
  const wwwAuth = [
    `Bearer resource_metadata="http://localhost:3000/.well-known/oauth-protected-resource"`,
    scope ? `scope="${scope}"` : null,
  ]
    .filter(Boolean)
    .join(", ");

  return new NextResponse(
    JSON.stringify({
      error: "unauthorized",
      error_description: "Missing or invalid access token",
    }),
    {
      status: 401,
      headers: {
        "WWW-Authenticate": wwwAuth,
        "Content-Type": "application/json",
      },
    }
  );
}

Désormais, n’importe quelle route (par exemple notre endpoint MCP) peut simplement faire return unauthorized("gifts:read"), et le client recevra un challenge correct. La fonction unauthorized() renvoie un objet NextResponse (compatible avec Response standard). Dans les exemples suivants, nous lancerons parfois cet objet comme une exception et, dans les route handlers, nous intercepterons précisément Response pour éviter de dupliquer la construction de la réponse 401 dans chaque route.

4. Réception et validation du jeton Bearer

Passons au plus intéressant : comment recevoir et vérifier un jeton Bearer.

Où effectuer la validation

Votre transport MCP est probablement implémenté soit :

  • dans un route handler Next.js (app/mcp/route.ts) qui accepte un POST et délègue ensuite au SDK MCP ;
  • dans un serveur Express/Fastify qui écoute /mcp et transmet le JSON au handler MCP.

Dans tous les cas, la couche HTTP doit :

  1. récupérer Authorization dans l’en‑tête ;
  2. en cas d’absence/erreur, renvoyer un 401 via notre unauthorized ;
  3. en cas de succès — construire l’objet de contexte (userId, scopes, roles) et le passer au SDK MCP (via les arguments du handler/contexte).

Le SDK MCP (par exemple @modelcontextprotocol/sdk) peut ignorer totalement ce qu’est un JWT. C’est votre responsabilité.

Stratégies de validation : JWT vs introspection

Il existe deux approches principales :

  1. Vérifier localement la signature et les claims du JWT en utilisant les clés JWK de l’Auth Server.
  2. Appeler /introspect du serveur d’autorisation pour demander : « Ce jeton est‑il encore valide ? Quels scopes a‑t‑il ? ».

Dans ce cours, nous supposerons que l’Auth Server émet des JWT et publie un jwks_uri, et que le serveur MCP vérifie localement la signature et les claims (plus rapide et autonome).

Utilitaire verifyAccessToken en TypeScript

Utilisons la bibliothèque populaire jose (ESM‑friendly). Il nous faut un helper de ce type :

// lib/verifyAccessToken.ts
import { jwtVerify, createRemoteJWKSet } from "jose";

const JWKS = createRemoteJWKSet(
  new URL("http://localhost:4000/.well-known/jwks.json")
);
const EXPECTED_ISS = "http://localhost:4000";
const EXPECTED_AUD = "http://localhost:3000";

export async function verifyAccessToken(token: string) {
  const { payload } = await jwtVerify(token, JWKS, {
    issuer: EXPECTED_ISS,
    audience: EXPECTED_AUD,
  });

  return {
    sub: String(payload.sub),
    scopes: String(payload.scope || "").split(" ").filter(Boolean),
    raw: payload,
  };
}

Dans ce helper, nous :

  • téléchargeons les clés JWK de l’Auth Server via jwks_uri ;
  • vérifions la signature et les claims standards (iss, aud) ;
  • extraitons sub (user id) et scope (chaîne séparée par des espaces, donc split(" ")).

audience doit correspondre à resource de notre .well-known/oauth-protected-resource, ce qui garantit que ce jeton est émis pour notre serveur MCP.

Validation simple de l’en‑tête Authorization

Créons maintenant un petit helper qui récupère le jeton de l’en‑tête et le passe dans verifyAccessToken :

// lib/getUserFromRequest.ts
import type { NextRequest } from "next/server";
import { unauthorized } from "./authResponses";
import { verifyAccessToken } from "./verifyAccessToken";

export async function getUserFromRequest(req: NextRequest) {
  const auth = req.headers.get("authorization") || "";
  const [, token] = auth.split(" ");

  if (!token) throw unauthorized("gifts:read");

  try {
    return await verifyAccessToken(token);
  } catch {
    throw unauthorized("gifts:read");
  }
}

Notez qu’ici nous levons unauthorized(...) (c’est‑à‑dire un objet Response) comme une exception, afin que le route handler puisse l’intercepter proprement et le retourner en réponse.

5. audience et scope : lier le jeton à la ressource et aux actions

Audience (aud) : « pour qui » le jeton est émis

Le claim aud répond à la question : ce jeton est‑il destiné à cette ressource ? Dans notre cas :

  • aud dans le jeton est défini par l’Auth Server à http://localhost:3000 ;
  • notre .well-known/oauth-protected-resource publie resource: "http://localhost:3000" ;
  • verifyAccessToken vérifie que c’est bien le cas.

Si le jeton est destiné à une autre ressource (par exemple https://api.other-app.com), votre serveur MCP doit le rejeter comme « pas pour moi ».

Erreur typique : oublier de synchroniser resource et aud, ce qui fait que, malgré une configuration correcte en apparence, ChatGPT obtient sans cesse des 401. Nous y reviendrons dans la section « Erreurs courantes ».

Scopes : « ce que » l’on peut faire

Le claim scope dans le jeton est la liste des droits que l’utilisateur a accordés au client. Dans notre exemple :

  • gifts:read — le droit de lire ses cadeaux ;
  • gifts:write — le droit de créer/mettre à jour des cadeaux.

Dans .well-known/oauth-protected-resource, ces valeurs apparaissent comme scopes_supported, afin que le client sache à l’avance quoi demander.

Le serveur d’autorisation, dans son document de découverte (.well-known/openid-configuration), publie aussi scopes_supported, mais il s’agit de la liste globale des scopes de l’IdP (à ne pas confondre avec les scopes du resource server .well-known/oauth-protected-resource).

Il ne faut pas confondre ces deux listes : scopes_supported de la ressource décrit les droits nécessaires à votre serveur MCP, tandis que scopes_supported de l’IdP est le « catalogue » global des scopes du fournisseur. Le client prend généralement l’intersection des deux.

Au niveau du serveur MCP, vous devez :

  • décider des scopes requis pour chaque outil ;
  • à chaque appel d’outil, vérifier que le jeton contient ces scopes.

Écrivons un helper :

// lib/requireScope.ts
import { unauthorized } from "./authResponses";

export function requireScope(
  user: { scopes: string[] },
  needed: string[]
) {
  const hasAll = needed.every((s) => user.scopes.includes(s));
  if (!hasAll) throw unauthorized(needed.join(" "));
}

Vous pouvez maintenant appeler requireScope(user, ["gifts:read"]) avant d’exécuter l’outil.

6. Intégration avec les outils MCP : du jeton jusqu’à list_my_gifts

Route MCP dans Next.js

Supposons que nous ayons un serveur MCP basé sur un SDK capable de traiter des requêtes HTTP. Du point de vue de Next.js, cela peut ressembler à ceci :

// app/api/mcp/route.ts
import { NextRequest } from "next/server";
import { unauthorized } from "@/lib/authResponses";
import { getUserFromRequest } from "@/lib/getUserFromRequest";
import { mcpServer } from "@/lib/mcpServer";

export async function POST(req: NextRequest) {
  try {
    const user = await getUserFromRequest(req);

    const body = await req.json();
    const result = await mcpServer.handle(body, { user });

    return Response.json(result);
  } catch (err) {
    if (err instanceof Response) return err; // unauthorized(...)
    console.error(err);
    return unauthorized();
  }
}

Points importants :

  • nous extrayons l’utilisateur et les scopes du jeton (getUserFromRequest) ;
  • nous les passons au serveur MCP via le contexte { user } ;
  • en cas d’absence/erreur de jeton, nous renvoyons notre 401 avec WWW-Authenticate.

L’API précise du SDK MCP peut différer, mais l’idée est partout la même : envelopper l’appel MCP dans un middleware qui sait déjà « qui » appelle.

Outil list_my_gifts avec vérification du scope

Regardons maintenant l’implémentation de l’outil lui‑même. Supposons que nous utilisions le SDK TypeScript pour MCP, et que nous ayons quelque chose comme :

// lib/mcpServer.ts (extrait)
import { createMcpServer } from "@modelcontextprotocol/sdk";
import { requireScope } from "./requireScope";

export const mcpServer = createMcpServer<{ user: any }>();

mcpServer.registerTool(
  "list_my_gifts",
  {
    title: "List my gifts",
    description: "Shows your saved gift ideas.",
    inputSchema: { type: "object", properties: {}, additionalProperties: false },
  },
  async (_input, ctx) => {
    requireScope(ctx.user, ["gifts:read"]);

    const gifts = await loadGiftsForUser(ctx.user.sub);
    return {
      content: [{ type: "text", text: `Found ${gifts.length} gifts` }],
      structuredContent: { gifts },
    };
  }
);

Nous effectuons trois étapes clés :

  • nous exigeons gifts:read avant d’exécuter le code principal ;
  • nous utilisons ctx.user.sub comme identifiant utilisateur (issu du jeton) ;
  • nous renvoyons les données de cet utilisateur uniquement.

Ainsi, votre outil cesse d’être une « API générique » et devient personnalisé — lié à l’identité issue de l’Auth Server.

7. Résumé du flux : du 401 à l’appel réussi

Pour tout fixer, rassemblons un mini schéma du flux désormais géré par votre serveur MCP protégé.

sequenceDiagram
    participant ChatGPT
    participant MCP as MCP Server (3000)
    participant AS as Auth Server (4000)

    ChatGPT->>MCP: POST /api/mcp (no Authorization)
    MCP-->>ChatGPT: 401 + WWW-Authenticate: Bearer resource_metadata=...

    ChatGPT->>MCP: GET /.well-known/oauth-protected-resource
    MCP-->>ChatGPT: { resource, authorization_servers, scopes_supported }

    ChatGPT->>AS: GET /authorize?scope=gifts:read&resource=...
    AS-->>ChatGPT: redirect with ?code=XYZ

    ChatGPT->>AS: POST /token (code + code_verifier)
    AS-->>ChatGPT: { access_token, scope, ... }

    ChatGPT->>MCP: POST /api/mcp Authorization: Bearer token
    MCP->>MCP: verify JWT (iss, aud, exp, scope)
    MCP-->>ChatGPT: tool result for this user

Notez le paramètre resource dans les requêtes à l’Auth Server : il est recopié dans le aud du jeton et doit correspondre à resource dans .well-known/oauth-protected-resource.

8. Petite vérification pratique avec curl

Pour se rassurer, on peut faire deux requêtes à la main.

Première — tentative d’appeler le MCP sans jeton :

curl -i http://localhost:3000/api/mcp \
  -H "Content-Type: application/json" \
  -d '{"method":"tools/call","params":{"name":"list_my_gifts","arguments":{}}}'

On s’attend à voir le statut 401 et notre WWW-Authenticate avec resource_metadata et scope="gifts:read".

Deuxième — avec un jeton valide (obtenu auprès de l’Auth Server) :

curl -i http://localhost:3000/api/mcp \
  -H "Authorization: Bearer abc123" \
  -H "Content-Type: application/json" \
  -d '{"method":"tools/call","params":{"name":"list_my_gifts","arguments":{}}}'

Désormais, si abc123 est un JWT valide avec iss, aud="http://localhost:3000" corrects et un scope incluant gifts:read, vous obtiendrez la réponse JSON de l’outil, et structuredContent.gifts contiendra les cadeaux de l’utilisateur courant.

9. Erreurs courantes lors de la configuration du serveur MCP en ressource protégée

Ci‑dessous, une série de pièges fréquents précisément lors de l’implémentation du code que nous venons d’écrire : .well-known, WWW-Authenticate, vérification du jeton et contrôle des scopes.

Erreur n°1 : resource et audience non synchronisés.
On indique souvent une valeur de resource dans .well-known/oauth-protected-resource et, côté Auth Server, on émet une autre valeur de aud dans les jetons. Au final, jwtVerify rejette le jeton, même si la signature et la durée de vie sont correctes. C’est particulièrement facile à casser lorsque vous changez le domaine/port du serveur MCP et oubliez de mettre à jour soit .well-known, soit la configuration de l’Auth Server. Dans notre exemple, c’est la même chaîne http://localhost:3000 dans le champ resource de .well-known et dans EXPECTED_AUD dans verifyAccessToken. Définissez une constante unique RESOURCE_ID et utilisez‑la aux deux endroits pour éviter les divergences.

Erreur n°2 : absence de WWW-Authenticate avec 401.
Des développeurs renvoient parfois simplement un 401 ou 403 sans l’en‑tête WWW-Authenticate. Côté navigateur, ça peut passer, mais ChatGPT et MCP Jam ne sauront pas où obtenir le jeton ni quels scopes sont requis. Ils considéreront alors votre serveur MCP comme « cassé » et n’afficheront pas l’UI de liaison à l’utilisateur. Le minimum requis : WWW-Authenticate: Bearer resource_metadata=".../.well-known/oauth-protected-resource". Ajoutez idéalement aussi scope="..." pour rendre le flux plus clair. Notre helper unauthorized() garantit justement la présence de cet en‑tête avec tout 401.

Erreur n°3 : faire confiance au jeton sans vérifier la signature et iss.
Parfois, surtout au début, la tentation est grande : « C’est un jeton de mon Auth Server, faisons juste JSON.parse(atob(..)) et basta ». À ne surtout pas faire : vous accepteriez alors n’importe quel jeton au bon format, même falsifié. La bonne approche est de charger les clés via jwks_uri et de vérifier la signature et iss/aud avec une bibliothèque (jose, jsonwebtoken, etc.). Ce n’est qu’après cela qu’on peut se fier aux claims.

Erreur n°4 : mélanger la validation du jeton et la logique métier.
Parfois, la vérification du jeton se disperse dans le code des outils : l’un vérifie le scope, l’autre pas, on oublie de vérifier le aud quelque part, et ailleurs on accepte un id utilisateur passé en argument du tool. Cela mène à des bugs étranges et à des vulnérabilités potentielles. Gardez une séparation nette : le middleware au niveau HTTP s’occupe du jeton (signature, iss, aud, expiration), et dans l’outil, vous vous appuyez sur ctx.user comme « source de vérité » et n’ajoutez que des contrôles métier (par exemple rôle/tenant).

Erreur n°5 : incohérence entre scopes_supported et les scopes réellement utilisés.
Autre cas fréquent : vous publiez un ensemble de scopes dans .well-known/oauth-protected-resource, un autre côté Auth Server, et dans les outils vous en vérifiez un troisième. ChatGPT/MCP Jam construisent la demande d’autorisation à partir des scopes_supported publiés, puis votre serveur se plaint que le scope requis n’y est pas. Réduisez le nombre de scopes et gérez‑les comme une « vérité unique » — par exemple via un enum TypeScript utilisé à la fois pour générer .well-known et pour configurer les clients dans l’Auth Server.

Erreur n°6 : se reposer uniquement sur securitySchemes de l’Apps SDK et oublier la vérification côté serveur.
L’Apps SDK permet de décrire des securitySchemes pour les outils (noauth, oauth2, scopes), et ChatGPT affichera l’UX adéquat. Mais ces annotations ne rendent pas votre serveur automatiquement sécurisé. Même si l’outil est déclaré comme nécessitant un jeton OAuth, votre serveur MCP doit quand même vérifier jeton, issuer, audience et scopes à chaque requête. Sinon, on peut contourner les contrôles en appelant directement l’URL du MCP.

Erreur n°7 : oublier la courte durée de vie des jetons et la gestion de l’expiration.
Si les access tokens vivent trop longtemps, vous réduisez la sécurité ; s’ils vivent trop peu mais que le serveur ne gère pas correctement l’expiration, l’utilisateur rencontrera souvent des erreurs. Le bon modèle : un access token de courte durée et la capacité du serveur MCP à renvoyer 401 avec WWW-Authenticate lorsque exp est passé. Le client (ChatGPT) relancera alors le flux OAuth et renouvellera le jeton.

Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION