Blog • Agent Security
Après qu'un client MCP a envoyé des identifiants OAuth au mauvais serveur d'autorisation, à quelle étape du journal JSON faut-il arrêter l'appel d'outil ?
Le 30 septembre 2026, le SDK TypeScript MCP a publié GHSA-6qxp-vccf-f47h, gravité High, CVSS 7.5. Dans les versions concernées, le client OAuth ne liait pas les identifiants enregistrés au serveur d'autorisation auquel ils appartiennent. Un serveur MCP dans la connexion pouvait en nommer un autre, et le client envoyait les identifiants sans action de l'utilisateur. Ce qu'il faut garder après l'avis est la ligne JSON qui arrête l'appel d'outil, pas une requête rejouable.
Qui porte les permissions MCP est dans l'article sur la sécurité MCP. La décision de permission après un schéma valide est dans l'article précédent. Celui-ci part du journal après l'avis : si credential_check ne passe pas, l'appel d'outil suivant ne doit pas être là.
- Comparez savedIssuer et le namedAuthorizationServer que le serveur MCP nomme.
- Si issuerMatch est false, mettez decision à deny et credentialsSent à false.
- Si cette étape est déjà deny, n'ajoutez pas de tool_call au journal.
- Après la mise à jour vers le SDK 1.31.0 ou le client 2.2.0, les providers fournis ont encore besoin d'expectedIssuer. Les identifiants enregistrés sans issuer reçoivent ce champ, ou vous les effacez et vous reconnectez.
La décision : si les issuer diffèrent, arrêtez-vous à credential_check. decision vaut deny, credentialsSent vaut false, et aucun appel d'outil ne suit.
Que contient la première ligne du journal après l'avis ?
Sont concernées les apps qui utilisent le SDK comme client OAuth sur HTTP, peuvent se connecter à un serveur MCP auquel elles ne font pas entièrement confiance, et détiennent des identifiants pour un serveur d'autorisation légitime. Les entrées sont un authProvider sur le transport, le middleware withOAuth, et les appels directs à auth ou fetchToken. Les serveurs MCP écrits avec ce SDK, et les clients stdio, sont hors avis. En 1.x, 1.12.0 à 1.30.1 sont corrigés en 1.31.0. En 2.x, le client 2.0.0 à 2.1.0 n'est concerné que pour les providers fournis sans expectedIssuer, les identifiants stockés sans issuer, les appels directs à fetchToken, ou les providers qui relisent le stockage via OAuthTokensSchema ou OAuthClientInformationSchema. Corrigé en 2.2.0. Si vous importez core directement, passez-le aussi en 2.2.0. L'avis est GHSA-6qxp-vccf-f47h.
{
"step": "credential_check",
"savedIssuer": "https://auth.example.com",
"namedAuthorizationServer": "https://other.example.net",
"issuerMatch": false,
"credentialsSent": false,
"decision": "deny",
"reason": "issuer_mismatch"
}
Cette ligne ne note que l'arrêt. savedIssuer est le serveur d'autorisation auquel les identifiants appartiennent. namedAuthorizationServer est celui que ce serveur MCP nomme. Si les deux chaînes diffèrent, issuerMatch vaut false. decision vaut deny et credentialsSent vaut false. Ne mettez ni jeton ni client secret dans les champs. Les listes de vulnérabilités notent le même problème comme CVE-2026-104850. Cet article suit les versions et le correctif de l'avis GitHub.
Quel schéma fixe la forme de cette ligne JSON ?
Utilisez JSON Schema pour fixer les énumérations et les champs requis. Il prouve qu'il s'agit d'un credential_check. decision ne peut être que allow, deny ou ask. reason ne peut être que issuer_mismatch, issuer_match ou awaiting_user. Il ne prouve pas que cette étape doit s'arrêter.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["step", "savedIssuer", "namedAuthorizationServer", "issuerMatch", "credentialsSent", "decision", "reason"],
"properties": {
"step": { "enum": ["credential_check"] },
"savedIssuer": { "type": "string" },
"namedAuthorizationServer": { "type": "string" },
"issuerMatch": { "type": "boolean" },
"credentialsSent": { "type": "boolean" },
"decision": { "enum": ["allow", "deny", "ask"] },
"reason": { "enum": ["issuer_mismatch", "issuer_match", "awaiting_user"] }
}
}
Passer le schéma veut seulement dire que les champs sont dans les énumérations. issuerMatch false, credentialsSent true et decision allow reste une forme valide. Ce qui arrête l'envoi est la règle d'application de la section suivante, pas ce contrôle de forme.
Quelles trois règles arrêtent encore l'étape quand la forme est valide ?
Le correctif de l'avis : le client enregistre le serveur d'autorisation comme issuer sur les identifiants et ne les envoie pas à un autre. L'utilisateur se reconnecte, ou l'appel échoue. La comparaison se fait hors du modèle. Ce que le modèle dit ensuite ne peut pas transformer une étape déjà notée deny en envoi.
| Ce que l'enregistrement montre | Schéma | Règle de l'app |
|---|---|---|
| issuerMatch est false, mais credentialsSent est true | Passe. true est un booléen. | Remettez-le à false. N'envoyez ni refresh_token, ni client_secret, ni assertion signée. |
| issuerMatch est false, mais decision est allow | Passe. allow est dans l'énumération. | Passez à deny et laissez reason à issuer_mismatch. |
| credential_check est déjà deny, et une ligne tool_call suit | Un schéma d'une seule ligne ne voit pas la suivante. Mettre step à tool_call fait échouer cet enregistrement lui-même. | Le journal s'arrête à credential_check. N'ajoutez pas d'appel d'outil. Un appel plus tard, dont les issuer correspondent déjà, reçoit sa propre décision de permission. |
Après la mise à jour de version, quels trois réglages écrivez-vous encore à la main ?
Passez 1.x à @modelcontextprotocol/sdk 1.31.0 ou plus. Passez 2.x à @modelcontextprotocol/client 2.2.0 ou plus. La mise à jour du paquet ne finit pas les trois lignes ci-dessous. La source reste le même avis.
| Ce qui reste | Ce que l'avis demande |
|---|---|
| Providers fournis | Passez expectedIssuer à ClientCredentialsProvider, PrivateKeyJwtProvider, StaticPrivateKeyJwtProvider et CrossAppAccessProvider, par exemple https://auth.example.com. Sans cela, ils utilisent encore le serveur d'autorisation que le serveur MCP nomme. |
| Identifiants stockés sans issuer | Les jetons et informations client enregistrés dans un fichier, un trousseau ou une base avant la mise à jour partent encore, au premier usage, vers le serveur d'autorisation alors nommé. Ajoutez issuer, ou effacez-les pour que l'utilisateur se reconnecte. Votre propre OAuthClientProvider enregistre exactement ce que saveTokens et saveClientInformation reçoivent, issuer compris. Les identifiants préenregistrés incluent issuer dans ce que clientInformation renvoie. |
| Une nouvelle connexion interactive | Ce correctif ne couvre pas une nouvelle connexion interactive. Elle va encore vers le serveur d'autorisation que le serveur MCP nomme. Ne la terminez que pour des serveurs de confiance. Si le client a pu se connecter à un serveur MCP non fiable, faites tourner le client secret ou la clé de signature et révoquez les jetons. Si vous ne pouvez pas encore mettre à jour, ne connectez les clients OAuth qu'à des serveurs MCP de confiance. 2.0.0 et 2.1.0 acceptent déjà expectedIssuer. |
Les appels directs à refreshAuthorization ou exchangeAuthorization sont hors de ce correctif. De même pour 2.x avec skipIssuerMetadataValidation à true. Ne laissez aucun des deux cas au modèle pour décider si les identifiants partent.
En quoi est-ce différent des deux articles déjà publiés ?
| Pourquoi un schéma est nécessaire | Sécurité MCP | Cet article | |
|---|---|---|---|
| Répond à | Pourquoi la sortie a besoin d'une forme | Qui doit tenir la limite de permission | Quand les issuer diffèrent, quelle étape du journal arrête l'appel d'outil |
| N'écrit pas | Pas l'arrêt de cet avis OAuth | Pas l'ordre du journal après l'avis | Pas de requête rejouable, et pas à nouveau si un schéma suffit |
Comment vérifier cet arrêt dans JSONNote ?
- 1Choisir la bonne opération
Ouvrez JSON Schema. Réglez Fonctionnement sur Vérifiez avec Schema. Collez l'arrêt ci-dessus dans le premier champ et le schéma dans le second. step ne peut être que credential_check. decision seulement allow, deny ou ask.
- 2Opposer quatre documents invalides
Mettez decision à yes, credentialsSent à la chaîne false, supprimez decision, ou mettez step à tool_call. Les quatre échouent. L'enregistrement deny ci-dessus passe. Passer ne veut dire que la forme est bonne.
- 3Comparer deny et l'allow de forme valide
Ouvrez JSON Comparer Diff. Collez l'arrêt deny dans le premier champ. Dans le second, changez seulement credentialsSent en true et decision en allow. reason reste issuer_mismatch. Les deux passent le schéma. La règle de l'app arrête l'envoi : si les issuer diffèrent, la décision doit être deny, et rien n'est envoyé.
Questions fréquentes
Un client stdio doit-il écrire cette ligne de journal ?
Non. L'avis dit que les clients stdio et les serveurs MCP construits avec ce SDK sont hors périmètre. La ligne credential_check concerne un client OAuth sur HTTP qui peut se connecter à un serveur MCP auquel vous ne faites pas entièrement confiance, tout en détenant des identifiants pour un serveur d'autorisation légitime.
Mettre à jour vers 1.31.0 ou 2.2.0 suffit-il ?
Non. Les providers fournis ont besoin d'expectedIssuer. Les identifiants sans issuer reçoivent ce champ ou sont effacés. Une nouvelle connexion interactive ne se termine encore que pour des serveurs de confiance. Les appels directs à refreshAuthorization ou exchangeAuthorization, et skipIssuerMetadataValidation à true, sont hors de ce correctif.
Une fois les issuer identiques, l'appel d'outil peut-il s'exécuter ?
Pas sur ce seul point. Des issuer identiques veulent seulement dire que les identifiants ne sont pas partis vers un autre serveur d'autorisation. L'appel d'outil a encore besoin de sa propre décision de permission : l'outil est-il sur la liste autorisée, et ce grant contient-il la capacité ? C'est l'article précédent. Celui-ci n'écrit pas cet appel après un deny.
Est-ce le même article que celui sur la sécurité MCP ?
Non. Celui-là dit qui doit tenir la limite de permission. Celui-ci dit quelle étape du journal JSON arrête l'appel d'outil après l'avis du 30 septembre 2026. Les deux mènent aux outils, mais la coupe est différente.
Le journal doit-il garder le refresh_token ?
Non. L'arrêt ne garde que les deux chaînes issuer, s'ils correspondent, si quelque chose a été envoyé, decision et reason. Les jetons et le client secret n'entrent pas dans ce JSON. Si le client a pu joindre un serveur non fiable, faites tourner la clé et révoquez les jetons au lieu de les copier dans le journal.
Conclusion
Les identifiants doivent d'abord être liés à leur issuer d'origine. Si le serveur MCP en nomme un autre, le journal s'arrête à credential_check : deny, ne pas envoyer, ne pas écrire d'appel d'outil. La mise à jour de version met cette comparaison dans le client. expectedIssuer et les anciens enregistrements restent à compléter par vous.
La décision : comparez d'abord les issuer ; s'ils diffèrent, deny ; laissez credentialsSent à false ; n'ajoutez pas d'appel d'outil ; écrivez expectedIssuer sur les providers fournis ; si le client a pu joindre un serveur non fiable, faites tourner la clé et révoquez les jetons.
Ensuite : fixez cet arrêt avec le schéma, puis utilisez Diff pour voir l'enregistrement deny face à l'enregistrement de forme valide dont decision est allow.