BlogIngénierie
Serveur MCP distant et OAuth : leçons de la mise en production
Notes d’un serveur MCP en production qui utilise Supabase Auth comme serveur d’autorisation : ce que demande la spécification, nos erreurs, et ce que nous n’avons pas encore testé.
- Écrit par
- Haiyi Mei
- Publié
- Mis à jour
- Longueur
- 12 min de lecture
La réponse courte
Un serveur MCP distant est une ressource protégée OAuth. Sans jeton, il répond 401 et renvoie vers ses métadonnées RFC 9728 ; le client les suit jusqu’au serveur d’autorisation, s’enregistre, connecte la personne avec PKCE et le paramètre resource, puis revient avec un jeton bearer émis pour l’URL du serveur. Nous faisons tourner cela en production avec Supabase Auth. Trois détails ont demandé du travail : un 401 avec une indication de scope et sans code d’erreur, un hook de jeton qui ajoute notre audience et notre scope aux jetons de Supabase, et l’enregistrement dynamique de client, puisque notre serveur d’autorisation n’offre pas de documents de métadonnées d’identifiant client.
Qui fait quoi quand un serveur MCP utilise OAuth ?
Le serveur MCP a la tâche la plus légère : accepter ou rejeter des jetons bearer. La spécification d’autorisation MCP (version 2025-11-25) en fait un serveur de ressources OAuth 2.1 et de l’assistant un client OAuth, et permet que le serveur d’autorisation, qui connecte la personne et émet les jetons, soit un service distinct.
Le nôtre est distinct. Le serveur OAuth 2.1 de Supabase Auth émet chaque jeton, notre site fournit la page de connexion et de consentement, et le point de terminaison https://mcp.scorestarling.com/mcp se contente de vérifier les jetons. Il fonctionne en Streamable HTTP sans sessions MCP et vérifie le jeton à chaque requête. ScoreStarling transforme des enregistrements en partitions modifiables, et ce point de terminaison est la porte par laquelle les assistants y accèdent ; le guide de connexion montre les étapes à suivre.
Que se passe-t-il quand un client MCP se connecte ?
Le client part de la seule URL. Deux réponses de notre serveur, montrées dans leur état du 3 octobre 2026, lui indiquent où se connecter ; six étapes de plus lui procurent un jeton. D’abord, une requête sans jeton reçoit un 401 dont l’en-tête WWW-Authenticate nomme les métadonnées et les scopes à demander :
$ curl -si -X POST https://mcp.scorestarling.com/mcp \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' -d '{}'
HTTP/2 401
content-type: application/json
www-authenticate: Bearer resource_metadata="https://mcp.scorestarling.com/.well-known/oauth-protected-resource/mcp", scope="openid email profile"
{"error_description": "Authentication required"}
Ensuite, le client récupère ces métadonnées. La RFC 9728 en forme l’adresse en insérant /.well-known/oauth-protected-resource entre l’hôte et le chemin, et exige que resource soit identique à l’URL que le client utilise, faute de quoi il doit s’arrêter. La référence de notre projet Supabase est remplacée ici :
{
"resource": "https://mcp.scorestarling.com/mcp",
"authorization_servers": ["https://<project-ref>.supabase.co/auth/v1"],
"scopes_supported": ["openid", "email", "profile"],
"bearer_methods_supported": ["header"]
}
Les six autres étapes :
Le client lit les métadonnées du serveur d’autorisation. L’émetteur de Supabase a un chemin, donc la RFC 8414 place le segment well-known avant lui :
https://<project-ref>.supabase.co/.well-known/oauth-authorization-server/auth/v1. Le client vérifie queissuercorrespond exactement et quecode_challenge_methods_supportedcontientS256; la spécification MCP dit de s’arrêter si ce champ est absent.Il envoie ses propres métadonnées par POST au
registration_endpointannoncé (enregistrement dynamique de client, RFC 7591) et obtient unclient_id. Notre client de test s’enregistre comme client public sans secret ; la documentation d’Anthropic dit que Claude fait de même.Il ouvre le navigateur sur le point de terminaison d’autorisation avec un challenge PKCE S256, le scope du 401 et
resource=https://mcp.scorestarling.com/mcp(RFC 8707). Supabase confie le navigateur à notre page de consentement, qui connecte la personne si besoin et lui demande d’approuver ou de refuser le client.Il échange le code avec son vérificateur PKCE et le même
resource, et obtient un jeton d’accès valable une heure ainsi qu’un jeton d’actualisation.Il envoie
Authorization : Bearer …à chaque requête MCP. Nous vérifions la signature avec les clés publiées de l’émetteur, puis l’émetteur, l’audience, l’expiration et le scope d’écriture, et enfin que le compte n’a pas été désactivé.Quand le jeton expire, ou quand nous répondons 401 avec
invalid_token, il l’actualise, toujours avecresource, et obtient un nouveau jeton d’accès et un nouveau jeton d’actualisation.
Les clients doivent envoyer resource dans les requêtes d’autorisation et de jeton même si le serveur d’autorisation l’ignore. La documentation du flux OAuth de Supabase ne mentionne pas ce paramètre (vérifié le 3 octobre 2026). Supabase l’a accepté dans toutes nos exécutions en conditions réelles, et l’audience de nos jetons vient d’un hook.
Que doit contenir l’en-tête WWW-Authenticate du 401 ?
Quand aucun jeton n’a été envoyé : où se trouvent les métadonnées, quels scopes demander, et aucun code d’erreur. La RFC 6750 §3.1 indique qu’une requête sans aucune information d’authentification ne devrait pas recevoir de code d’erreur. invalid_token est réservé à un jeton envoyé qui a échoué, et signale au client qu’il peut en obtenir un nouveau et réessayer. La spécification MCP ajoute que le 401 devrait porter une indication scope, que les clients doivent traiter comme les scopes à demander.
Nous avons constaté que notre propre serveur se trompait sur ce point en rédigeant notre guide de connexion. Dans le SDK Python MCP 1.30.0, RequireAuthMiddleware répond à toute requête non authentifiée avec error="invalid_token" et sans scope : un client qui n’avait jamais envoyé de jeton se voyait donc dire que son jeton était invalide. Un petit middleware ASGI placé autour de la route MCP ne réécrit désormais que les 401 du SDK ; le SDK décide toujours qui entre, et sa réponse 403 insufficient_scope n’est pas touchée :
# No token, before the fix (the SDK's default; wrapped for reading)
WWW-Authenticate: Bearer error="invalid_token",
error_description="Authentication required",
resource_metadata="…/.well-known/oauth-protected-resource/mcp"
# No token, now
WWW-Authenticate: Bearer
resource_metadata="…/.well-known/oauth-protected-resource/mcp",
scope="openid email profile"
# Rejected token: expired, wrong audience, malformed or account turned off
WWW-Authenticate: Bearer error="invalid_token",
error_description="Invalid or expired access token",
resource_metadata="…/.well-known/oauth-protected-resource/mcp",
scope="openid email profile"
L’indication cite openid email profile, les scopes que Supabase peut accorder, en accord avec scopes_supported. Notre contrôle de déploiement échoue désormais si un challenge sans jeton comporte un code d’erreur. Le correctif est arrivé en production le 2 octobre 2026, moins de quatre minutes après la fusion. La documentation des connecteurs d’Anthropic ajoute deux règles pour Claude : la connexion ne démarre qu’à partir d’un 401 (il ignore WWW-Authenticate sur un 200), et il n’utilise que la première entrée de authorization_servers.
Comment faire entrer une audience et un scope MCP dans les jetons de Supabase ?
Avec un hook de jeton d’accès personnalisé. La spécification MCP dit qu’un serveur ne doit accepter que les jetons émis pour lui, ce qui revient en pratique à vérifier aud, et nous voulions un scope d’écriture à nous ; la documentation de Supabase dit que son serveur OAuth ne prend pas en charge les scopes personnalisés (vérifié le 3 octobre 2026). Le hook est une fonction Postgres que Supabase Auth exécute avant d’émettre un jeton, et elle peut réécrire les revendications. Voici le nôtre, légèrement simplifié d’après notre migration :
create or replace function scorestarling.access_token_hook(event jsonb)
returns jsonb language plpgsql stable security invoker set search_path = '' as $$
declare claims jsonb := event->'claims';
begin
-- Tokens issued to an OAuth client carry its client_id; website sessions don't.
if coalesce(claims->>'client_id', '') <> '' then
claims := jsonb_set(claims, '{aud}', '"https://mcp.scorestarling.com/mcp"');
claims := jsonb_set(claims, '{scope}', '"openid email profile scorestarling:write"');
end if;
return jsonb_build_object('claims', claims);
end;
$$;
grant usage on schema scorestarling to supabase_auth_admin;
grant execute on function scorestarling.access_token_hook(jsonb) to supabase_auth_admin;
revoke all on function scorestarling.access_token_hook(jsonb) from public, anon, authenticated;
Côté MCP, ces revendications sont vérifiées avec PyJWT, puis deux règles qui nous sont propres s’appliquent :
claims = jwt.decode(token, key, algorithms=["RS256", "ES256"],
issuer=ISSUER, audience=ACCEPTED_AUDIENCES,
options={"require": ["exp", "iat", "iss", "aud", "sub"]})
if "scorestarling:write" not in claims.get("scope", "").split():
return None # the client gets 401 invalid_token
# then: has this account been turned off?
ACCEPTED_AUDIENCES est l’URL MCP, plus l’ancienne le temps d’un changement d’adresse. Le test sur le client ID tient à l’écart la connexion propre au site : un jeton de session de navigateur du même projet Supabase a le même émetteur et les mêmes clés mais pas de client_id, et n’obtient donc jamais l’audience MCP.
Le hook a deux coûts. Il appose une audience fixe au lieu de suivre le resource du client, ce qui convient tant qu’un serveur d’autorisation dessert un seul serveur MCP. Et notre page de consentement ne peut afficher que les scopes que le client a demandés : approuver le client revient donc à accorder le scope d’écriture.
Un signalement ouvert, supabase/auth#2820 (ouvert le 20 septembre 2026 ; sans réponse au 3 octobre), indique que la requête de détails d’autorisation de Supabase renvoie 400 pour les clients publics, offline_access ou resource. Aucun de ces cas ne s’est reproduit chez nous ; dans le code source de Supabase Auth que nous avons examiné, ce 400 n’apparaît que pour une autorisation qui n’est plus en attente, par exemple une autorisation récupérée deux fois.
Enregistrement dynamique de client ou documents de métadonnées d’identifiant client ?
Utilisez ce que votre serveur d’autorisation annonce ; avec Supabase aujourd’hui, c’est l’enregistrement dynamique de client (DCR). La spécification MCP 2025-11-25 recommande les documents de métadonnées d’identifiant client (CIMD) quand client et serveur ne se sont jamais rencontrés, et garde le DCR pour la rétrocompatibilité. Les clients devraient essayer d’abord un identifiant client fourni à l’avance, puis le CIMD, puis le DCR, et seulement ensuite demander à la personne.
| Approche | Comment le client s’identifie | Signal dans les métadonnées du serveur | Option dans le formulaire de Claude | Notre serveur, 3 octobre 2026 |
|---|---|---|---|---|
| Préenregistrement | Un identifiant client créé à l’avance, saisi à la main ou intégré | Aucun nécessaire | « Use your own OAuth client » [Utiliser votre propre client OAuth] | Non proposé |
| Document de métadonnées d’identifiant client | Son client_id est une URL HTTPS vers un fichier JSON que le client héberge et que le serveur récupère | client_id_metadata_document_supported | « Use Claude’s published identity (recommended) » [Utiliser l’identité publiée de Claude (recommandé)] | Non annoncé |
| Enregistrement dynamique de client | Il envoie ses métadonnées au point de terminaison d’enregistrement et obtient un client_id | registration_endpoint | « Register automatically » [Enregistrer automatiquement] | Annoncé |
Les métadonnées de Supabase, relues le 3 octobre 2026, listent un point de terminaison d’enregistrement, S256 et la méthode none du point de terminaison de jeton, et aucun indicateur CIMD ; son guide MCP vous fait activer l’enregistrement dynamique dans le tableau de bord.
Cela décide de ce que les gens choisissent dans le formulaire de connecteur personnalisé de Claude. La documentation d’Anthropic décrit l’option recommandée, l’identité publiée de Claude, comme un CIMD qu’Anthropic héberge et que le serveur doit prendre en charge. Notre guide demande donc « Sign in now », puisqu’aucun de nos outils ne fonctionne sans compte, et « Register automatically ». La documentation pour développeurs d’Anthropic dit que Claude se rabat sur le DCR quand le CIMD n’est pas annoncé (le CIMD exige aussi none dans token_endpoint_auth_methods_supported) ; nous n’avons pas testé le formulaire en laissant l’option par défaut, c’est pourquoi nous demandons le choix explicite. La documentation des clients que nous avons lue le 2 octobre 2026 décrit le DCR pour le mode développeur de ChatGPT, Cursor, VS Code et Gemini CLI.
Le DCR a un coût : Anthropic note que Claude enregistre un nouveau client à chaque nouvelle connexion, et Supabase prévient que l’enregistrement dynamique permet à n’importe quel client MCP de s’enregistrer auprès de votre projet. S’enregistrer n’accorde toutefois rien en soi. Une personne doit encore se connecter et approuver le client, et le vérificateur contrôle le compte à chaque requête. Comme n’importe quel client peut s’enregistrer sous n’importe quel nom, notre page de consentement indique depuis le 6 octobre 2026 où mène l’approbation, et avertit quand une application se dit ChatGPT ou Claude sans revenir à l’adresse de cet assistant.
Les clients MCP restent-ils connectés après l’expiration du jeton d’accès ?
Oui. Les jetons d’accès durent une heure, et les clients les renouvellent avec des jetons d’actualisation. Avant de nous y fier, nous avons lu le code source de Supabase Auth (master au 22 septembre 2026) :
- chaque échange de code renvoie un jeton d’actualisation, avec ou sans
offline_access; - une actualisation doit venir du client propre à la session, effectue la rotation du jeton d’actualisation et conserve la revendication
client_id; - le hook s’exécute aussi lors des actualisations (
authentication_methodvauttoken_refresh), si bien que notre audience et notre scope reviennent à chaque fois.
Une exécution en conditions réelles le 2 octobre 2026 a confirmé cela : le jeton d’actualisation a effectué sa rotation, et le jeton d’accès renouvelé a fonctionné. La documentation d’Anthropic dit que Claude actualise après un 401 et jusqu’à cinq minutes avant l’expiration, et ajoute offline_access quand le serveur d’autorisation le liste dans scopes_supported. Les métadonnées de Supabase le listent, et notre vérification qui demandait aussi offline_access a réussi.
Les sessions se terminent tout de même quand :
- le compte est désactivé, ou l’utilisateur est supprimé ou banni (dans notre essai en conditions réelles, la désactivation du compte a fait rejeter la session du navigateur et les deux jetons MCP) ;
- un ancien jeton d’actualisation est réutilisé après l’intervalle de réutilisation de Supabase, 10 secondes par défaut, ce qui révoque toute la session ;
- une limite de durée de session (« time-box »), un délai d’inactivité ou l’option « single session per user » [une seule session par utilisateur] est activé. Gardez ce dernier réglage désactivé, sinon une connexion au site met fin à la session de l’assistant ;
- la personne se déconnecte du site avec le
logoutpar défaut de Supabase, qui met fin à toutes les sessions du compte, y compris celle de l’assistant. C’était le cas chez nous jusqu’au 6 octobre 2026, ce qui explique probablement qu’une connexion ChatGPT n’ait plus pu renouveler son jeton ; la commande Se déconnecter envoie désormaisscope=localet ne termine que la session de ce navigateur.
Supabase n’annonce aucun point de terminaison de révocation (vérifié le 3 octobre 2026) : désactiver le compte est donc notre moyen de couper l’accès.
Pourquoi une barre oblique finale dans l’émetteur compte-t-elle ?
Parce que les émetteurs sont comparés caractère par caractère. La RFC 8414 exige que l’issuer des métadonnées soit identique à celui que le client a utilisé pour les trouver, et la RFC 9728 demande la même chose pour resource. Pour un client strict, https://auth.example.com et https://auth.example.com/ sont deux émetteurs différents.
Le SDK Python MCP stocke l’émetteur sous forme de AnyHttpUrl de pydantic, et pydantic 2.13.5, la version de notre lockfile, transforme https://auth.example.com en https://auth.example.com/. Des métadonnées construites à partir de cela nomment un émetteur que le serveur d’autorisation n’a jamais publié : notre serveur écrit donc ses propres métadonnées de ressource protégée avec l’émetteur exactement tel que configuré. L’émetteur de Supabase se termine par un chemin, que pydantic laisse intact, donc cela ne nous a jamais touchés en production ; ce serait le cas avec un émetteur réduit à l’origine, sans chemin. Notre contrôle de déploiement échoue si l’issuer du serveur d’autorisation ne correspond pas exactement au nôtre.
Ce que nous avons vérifié, et ce que nous n’avons pas vérifié
Notre première preuve est venue d’un client scripté qui suit la spécification MCP. Le 2 octobre 2026, une exécution contre la production a couvert le 401 sans jeton, l’enregistrement dynamique d’un client public, le consentement et l’échange de code avec resource, une actualisation avec rotation, tools/list avec les deux jetons, et la désactivation du compte, qui a fait rejeter les deux jetons et la session du navigateur. Une seconde exécution a aussi demandé offline_access, et les deux ont réussi. Cette exécution utilisait notre ancienne adresse ; le passage à mcp.scorestarling.com a son propre article.
Depuis, de vrais clients se sont connectés. L’application en mode développeur de ChatGPT s’est connectée via mcp.scorestarling.com et a appelé un outil le 3 octobre 2026, et Claude s’est connecté et a exécuté des outils le 6 octobre. Pas encore vérifié :
- une actualisation après l’expiration réelle d’un jeton : l’actualisation scriptée a eu lieu juste après la connexion, et nous n’en avons pas tracé une venant d’un vrai client ;
- les réglages de session hébergés de Supabase, que nous n’avons pas relus.
Sources
- Authorization, MCP specification version 2025-11-25, Model Context Protocol
- RFC 9728 : OAuth 2.0 Protected Resource Metadata, IETF
- RFC 6750 : The OAuth 2.0 Authorization Framework : Bearer Token Usage, §3.1, IETF
- RFC 8707 : Resource Indicators for OAuth 2.0, IETF
- RFC 7591 : OAuth 2.0 Dynamic Client Registration Protocol, IETF
- RFC 8414 : OAuth 2.0 Authorization Server Metadata, IETF
- OAuth Client ID Metadata Document (Internet-Draft), groupe de travail OAuth de l’IETF
- Authentication for connectors, Anthropic
- Add a connector that isn’t in the directory, Anthropic
- OAuth 2.1 Flows, Supabase
- Custom Access Token Hook, Supabase
- Model Context Protocol (MCP) Authentication, Supabase
- User sessions, Supabase
- supabase/auth issue #2820, GitHub
Questions et réponses
Un serveur MCP distant a-t-il besoin de son propre serveur d’autorisation ?
Non. La spécification d’autorisation MCP permet que le serveur d’autorisation soit un service distinct. Le serveur MCP publie des métadonnées RFC 9728 qui le désignent, puis valide les jetons qu’il émet, audience comprise. Le nôtre renvoie vers Supabase Auth et ne fait que vérifier les jetons.
Le 401 d’un serveur MCP doit-il contenir l’erreur invalid_token quand aucun jeton n’a été envoyé ?
Non. La RFC 6750 §3.1 indique qu’une requête sans identifiants ne devrait recevoir aucun code d’erreur. Envoyez plutôt resource_metadata et une indication scope, et réservez invalid_token à un jeton envoyé puis rejeté, cas où il indique au client d’actualiser son jeton.
Supabase Auth peut-il émettre des scopes personnalisés pour un serveur MCP ?
Pas au 3 octobre 2026 : la documentation OAuth 2.1 de Supabase indique que les scopes personnalisés ne sont pas pris en charge. Nous demandons openid email profile et ajoutons notre propre scope et l’audience MCP dans un hook de jeton d’accès personnalisé, qui s’exécute aussi à chaque actualisation.
Que faut-il choisir dans le formulaire de connecteur personnalisé de Claude si le serveur ne prend pas en charge CIMD ?
Choisissez « Register automatically » [Enregistrer automatiquement] sous « OAuth client » [Client OAuth], ce qui utilise l’enregistrement dynamique de client, et « Sign in now » [Se connecter maintenant] sous « Authentication » [Authentification] si chaque outil exige un compte. L’identité publiée recommandée par Claude est un document de métadonnées d’identifiant client, que le serveur doit prendre en charge. Nos étapes pour Claude montrent les deux choix.
Ai-je besoin du scope offline_access pour obtenir des jetons d’actualisation de Supabase ?
Pas dans nos tests. Supabase a renvoyé un jeton d’actualisation à chaque échange de code sans ce scope, d’après son code source et lors d’exécutions en conditions réelles le 2 octobre 2026. Certains clients le demandent quand même ; la documentation d’Anthropic dit que Claude le fait quand le serveur d’autorisation le liste, et notre vérification avec ce scope a aussi réussi.



