docs: publish translated docs in twelve languages and the tool that maintains them (#3280)
This commit is contained in:
parent
14aa889992
commit
52ad0a8876
8 changed files with 89737 additions and 54 deletions
122
i18n/fr/pages/client/caching.md
Normal file
122
i18n/fr/pages/client/caching.md
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
---
|
||||
translation:
|
||||
sections: [9e7b9a1710e5aeba, b74ca4c1d2ddddee, fa8714e61bf90c5a, 04db67a886b7271c, 857690fb8f876800]
|
||||
tool: 1
|
||||
---
|
||||
# Indications de mise en cache {#caching-hints}
|
||||
|
||||
Sur le protocole 2026-07-28, chaque résultat qu’un serveur renvoie pour `tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read` et `server/discover` porte deux champs : `ttlMs`, le nombre de millisecondes pendant lesquelles un client peut considérer le résultat comme frais, et `cacheScope`, qui indique si un résultat mis en cache peut être partagé entre utilisateurs (`"public"`) ou appartient à un seul contexte d’autorisation (`"private"`).
|
||||
|
||||
Le serveur ne met rien en cache. Ces champs sont une *déclaration* : « cette liste d’outils est la même pour tout le monde et ne changera pas pendant une minute ». Un client (ou une passerelle placée devant vous) peut alors s’épargner l’aller-retour. Respecter ces indications relève du choix du client ; les émettre est le travail du serveur, et le SDK le fait pour vous.
|
||||
|
||||
Par défaut, chaque résultat indique `ttlMs: 0, cacheScope: "private"` : périmé immédiatement, jamais partagé. C’est toujours sûr et toujours conforme. Si vos listes sont réellement stables et identiques pour tous les appelants, dites-le à la construction :
|
||||
|
||||
```python title="server.py" hl_lines="5-8"
|
||||
--8<-- "docs_src/caching/tutorial001.py"
|
||||
```
|
||||
|
||||
* Le dictionnaire est indexé par **nom de méthode**, et les six méthodes pouvant être mises en cache sont les seules clés autorisées. Le paramètre est typé `Mapping[CacheableMethod, CacheHint]` : votre éditeur complète donc les clés automatiquement et signale une faute de frappe avant l’exécution ; tout ce qui échappe au vérificateur de types lève une exception à la construction.
|
||||
* Une méthode que vous ne mentionnez pas garde les valeurs par défaut. Le dictionnaire est un ensemble de surcharges, pas un manifeste.
|
||||
* `CacheHint(ttl_ms=5_000)` n’a pas défini `scope`, qui reste donc `"private"` : cinq secondes de fraîcheur, par appelant. La portée et le TTL sont deux décisions indépendantes.
|
||||
* `"server/discover"` est aussi une clé autorisée, puisque le résultat de découverte peut être mis en cache comme n’importe quelle liste.
|
||||
|
||||
!!! warning
|
||||
`cacheScope: "public"` signifie que *n’importe qui* peut recevoir votre réponse mise en cache. Une passerelle
|
||||
partagée transmettra sans hésiter le résultat d’un utilisateur à un autre, même lorsque la requête était
|
||||
authentifiée. Ne marquez un résultat `"public"` que s’il est identique pour chaque appelant, et
|
||||
n’utilisez jamais `cacheScope` comme contrôle d’accès : c’est une étiquette, pas un verrou.
|
||||
|
||||
## Surcharge par gestionnaire {#per-handler-override}
|
||||
|
||||
Sur le `Server` bas niveau, les gestionnaires (handlers) construisent leurs résultats à la main, et `ttl_ms` / `cache_scope` sont de simples champs des modèles de résultat. Un gestionnaire qui les définit explicitement l’emporte toujours sur le dictionnaire du constructeur, champ par champ :
|
||||
|
||||
```python title="server.py" hl_lines="10 16"
|
||||
--8<-- "docs_src/caching/tutorial002.py"
|
||||
```
|
||||
|
||||
Le gestionnaire a indiqué `ttl_ms=1_000` et rien sur la portée. Sur la liaison : `ttlMs: 1000` (la valeur du gestionnaire, pas le `60_000` du dictionnaire) et `cacheScope: "public"` (la valeur du dictionnaire, puisque le gestionnaire ne l’a pas définie). L’explicite l’emporte sur le configuré, et le configuré sur la valeur par défaut. Cela vaut champ par champ : un gestionnaire peut donc fixer un champ et laisser l’autre à la politique du serveur.
|
||||
|
||||
C’est aussi l’échappatoire pour les comportements dynamiques que le constructeur ne peut pas connaître : un gestionnaire qui filtre `resources/read` par utilisateur peut renvoyer `cache_scope="private"` pour un URI donné sur un serveur par ailleurs public.
|
||||
|
||||
Une réserve sur les listes paginées : le protocole exige **le même `cacheScope` sur chaque page** d’une même liste. Le dictionnaire du constructeur y satisfait par construction, puisqu’il est indexé par méthode et non par page. Mais un gestionnaire qui surcharge lui-même la portée devient responsable de cette cohérence : surchargez-la sur *chaque* page, jamais uniquement lorsqu’un curseur est présent, sinon la page un et la page deux se contrediront.
|
||||
|
||||
## Ce que voit le client {#what-the-client-sees}
|
||||
|
||||
Sur une session 2026-07-28, `Client` respecte les indications pour vous : il embarque un cache de réponses, activé par défaut. Un résultat qui arrive avec un `ttlMs` est stocké, et un appel identique effectué dans ce TTL est servi depuis le cache, sans aller-retour. Un résultat qui ne porte *aucune* indication n’est pas mis en cache : les résultats sans indication reçoivent `CacheConfig.default_ttl_ms`, dont la valeur par défaut est `0` (périmé immédiatement), si bien qu’un serveur qui ne déclare rien voit exactement le même trafic, appel pour appel, qu’auparavant.
|
||||
|
||||
```python title="client.py" hl_lines="33 35 38"
|
||||
--8<-- "docs_src/caching/tutorial003.py"
|
||||
```
|
||||
|
||||
Quatre appels, trois récupérations. Le deuxième appel a trouvé une entrée fraîche et n’a jamais atteint le serveur ; avancer l’horloge (injectée) au-delà du TTL a fait que le troisième récupère à nouveau ; le quatrième a indiqué `cache_mode="refresh"`. Cet argument nommé existe sur les cinq verbes avec cache (`list_tools`, `list_prompts`, `list_resources`, `list_resource_templates`, `read_resource`) :
|
||||
|
||||
* `"use"` (la valeur par défaut) sert une entrée fraîche s’il y en a une, et stocke le résultat récupéré sinon.
|
||||
* `"refresh"` ne sert jamais depuis le cache : il récupère et stocke le résultat, en remplaçant ce qui était en cache.
|
||||
* `"bypass"` effectue l’aller-retour sans toucher du tout au cache : ni lecture, ni écriture.
|
||||
|
||||
Une règle prime sur `"use"` : **les appels portant `meta` atteignent toujours le serveur.** Une requête avec `meta` défini (un jeton de progression, des champs de traçage) attend une requête sur la liaison ; sous `cache_mode="use"`, elle est donc traitée comme `"refresh"` : la lecture du cache est sautée, et le résultat récupéré remplace quand même l’entrée en cache. `"bypass"` et un `"refresh"` explicite se comportent comme d’habitude.
|
||||
|
||||
Pour désactiver entièrement la mise en cache, construisez avec `Client(server, cache=None)` : chaque appel redevient un aller-retour, et `cache_mode`, bien que toujours accepté, n’a aucun effet.
|
||||
|
||||
La portée est elle aussi respectée automatiquement : les entrées `"private"` sont indexées sur la *partition* du cache (ci-dessous), tandis que les entrées `"public"` peuvent opter pour un partage plus large. Et **les notifications priment sur le TTL** pour les entrées exactes qu’elles désignent : une notification `list_changed` évince la liste correspondante en cache, et `resources/updated` évince la lecture en cache stockée exactement sous son URI, aussi fraîches soient-elles. Sur une connexion 2026-07-28, ces notifications arrivent sur un flux `subscriptions/listen` que vous ouvrez avec `client.listen(...)`, et l’éviction se termine avant que votre observateur ne voie l’événement ; tous les détails sont dans **[Abonnements](subscriptions.md)**.
|
||||
|
||||
Une réserve sur `resources/updated` : l’éviction ne porte que sur l’URI exact. Le contrat du magasin n’a ni opération d’énumération ni de parcours (comme l’implémentation TypeScript de référence), donc une notification portant l’URI d’une *sous*-ressource n’évince pas la lecture en cache de son parent. Si votre serveur signale ainsi des sous-ressources, récupérez à nouveau le parent avec `cache_mode="refresh"`.
|
||||
|
||||
### Configurer le cache : `CacheConfig` {#configuring-it-cacheconfig}
|
||||
|
||||
```python
|
||||
from mcp.client import CacheConfig
|
||||
|
||||
client = Client("https://api.example.com/mcp", cache=CacheConfig(default_ttl_ms=5_000))
|
||||
```
|
||||
|
||||
* `store` : l’endroit où vivent les entrées. Par défaut, un nouveau magasin en mémoire par client ; passez votre propre implémentation de `ResponseCacheStore` (adossée à Redis, par exemple) pour partager un cache entre clients ou processus. Les types du contrat (`ResponseCacheStore`, `CacheKey`, `CacheEntry` et le `InMemoryResponseCacheStore` par défaut) sont importables depuis `mcp.client`. Une recherche peut émettre jusqu’à deux `get` séquentiels sur le magasin (la branche privée, puis la publique) ; dimensionnez donc en conséquence vos attentes de latence pour un magasin distant. Un magasin personnalisé **exige** une `partition` explicite.
|
||||
* `partition` : l’étiquette de contexte d’autorisation qui empêche les entrées `"private"` d’un principal d’être servies à un autre au sein d’un magasin partagé.
|
||||
* `target_id` : identité explicite du serveur, pour les transports personnalisés et les serveurs en processus (ci-dessous).
|
||||
* `default_ttl_ms` : TTL appliqué aux résultats qui ne portent aucune indication `ttlMs`. La valeur par défaut `0` laisse les résultats sans indication hors du cache.
|
||||
* `share_public` : servir entre partitions les entrées que le serveur affirme `"public"` (ci-dessous). Désactivé par défaut.
|
||||
* `clock` : la source d’horloge murale, en secondes depuis l’epoch. Injectez-en une, comme le fait l’exemple ci-dessus, et les tests d’expiration n’ont pas besoin d’attendre.
|
||||
|
||||
!!! warning "Partition = principal vérifié"
|
||||
Dérivez `partition` d’**informations d’identification vérifiées**, comme le sujet d’un jeton validé. Ne la dérivez jamais de données fournies par la requête, ni de l’URL du serveur (l’identité du serveur est un axe de clé distinct). Le SDK est une bibliothèque sans authentification propre : l’ancre de confiance est celui qui construit le `CacheConfig`, c’est-à-dire le déploiement, pas le locataire. Une passerelle multi-locataire crée un `CacheConfig` par principal authentifié.
|
||||
|
||||
La partition est aussi figée pour toute la durée de vie du `Client`. Si le contexte d’autorisation de la connexion change en cours de session (une réauthentification sous un autre principal, par exemple), le cache ne suit pas ; construisez un nouveau `Client` pour le nouveau principal.
|
||||
|
||||
Les clés du cache portent aussi **l’identité du serveur** : la chaîne d’URL que vous avez appelée, débarrassée de toute partie userinfo `user:pass@` et sinon conservée à l’octet près. Pas de normalisation de la casse, pas de réordonnancement des paramètres de requête, pas de nettoyage de la barre oblique finale. Sous-normaliser ne coûte que du partage, alors que sur-normaliser pourrait fusionner deux locataires (`?tenant=a` et `?tenant=b`) : des URL superficiellement différentes ne partagent tout simplement pas d’entrées. Lorsqu’il n’y a pas d’URL (un serveur en processus, ou une instance de `Transport`), le client reçoit à la place une identité aléatoire par instance ; définissez `CacheConfig.target_id` pour nommer le serveur (avec un magasin personnalisé, c’est obligatoire, et la construction vous le dit). L’identité est hachée en sha256 avant d’entrer dans le matériau de clé, si bien qu’une URL portant des secrets dans sa chaîne de requête n’apparaît jamais dans les clés du magasin. Ne journalisez pas non plus vous-même la forme avant hachage.
|
||||
|
||||
!!! warning "`share_public` fait confiance au serveur, pour tout le parc"
|
||||
Par défaut, même les entrées `"public"` restent dans leur partition. `share_public=True` sert les entrées que le serveur a marquées `cacheScope: "public"` à **toutes** les partitions qui utilisent le magasin, en faisant confiance à la classification du serveur au nom de chacune d’elles. Un serveur qui appose `"public"` sur des données propres à un locataire (par bogue ou par malveillance) fait alors fuiter la réponse d’un locataire vers les autres. L’option est délibérément limitée au constructeur : le `cache_mode` par appel peut restreindre la mise en cache, mais rien au niveau de l’appel ne peut élargir le partage.
|
||||
|
||||
### Ce que le cache ne fait jamais {#what-the-cache-never-does}
|
||||
|
||||
* **Les appels au niveau session le contournent.** `client.session.list_tools()` et consorts font toujours l’aller-retour ; le cache vit sur les verbes de `Client`.
|
||||
* **`server/discover` reste en dehors.** Le résultat de découverte est livré une fois, à la connexion, et n’entre jamais dans le cache de réponses, même lorsqu’il porte un `ttlMs`. Si vous en persistez un vous-même pour éviter la sonde de reconnexion ([`prior_discover`](../protocol-versions.md#reconnecting-with-prior_discover)), sa fraîcheur relève de votre propre suivi : `DiscoverResult` porte `ttl_ms` et `cache_scope`, déjà analysés, précisément à cette fin.
|
||||
* **Les pages de continuation ne sont jamais mises en cache.** Seuls les appels sans curseur participent. Une page de continuation rejetée pour curseur expiré *évince* bien la liste en cache, car la liste a changé entre-temps.
|
||||
* **Les lectures à plusieurs allers-retours (multi-round-trip) ne sont jamais mises en cache.** Un `read_resource` amorcé avec `input_responses`/`request_state`, ou qui se résout au fil de tours de saisie, n’entre jamais dans le cache (un MUST de la spécification).
|
||||
* **L’éviction par notification a besoin de notifications.** L’éviction ne vaut que ce que vaut la livraison du transport, et le chemin moderne en processus (`Client(server)` avec le `mode="auto"` par défaut) ne livre pas aujourd’hui les notifications autonomes.
|
||||
* **L’éviction se produit à terme, pas instantanément.** Les notifications qui arrivent par la liaison sont distribuées depuis des tâches lancées à part ; un appel en concurrence avec l’arrivée d’une notification peut donc se voir servir une fois de plus l’entrée d’avant l’éviction ; la fenêtre est bornée par la latence de distribution, et l’éviction a tout de même lieu.
|
||||
* **Pas de stale-if-error.** Une entrée expirée n’est jamais servie parce que la nouvelle récupération a échoué ; l’erreur se propage.
|
||||
* **Pas de récupération anticipée.** Une entrée stockée est servie jusqu’à expiration de son TTL, et l’appel suivant paie l’aller-retour ; rien ne se rafraîchit en arrière-plan.
|
||||
* **Pas de regroupement.** Deux appels identiques concurrents font deux récupérations.
|
||||
* **Pas de TTL au-delà de 24 heures.** Un `ttlMs` supérieur, qu’il vienne du serveur ou de la configuration, est ramené à ce plafond au stockage (`mcp.client.caching.MAX_TTL_MS`), ce qui borne la durée pendant laquelle une entrée, si généreuse soit son indication, peut être servie.
|
||||
* Sur un **magasin partagé**, les clients sont en concurrence. Chaque client abandonne sa propre écriture lorsqu’une éviction a doublé la récupération en cours, mais un client *colocataire* peut toujours réécrire une entrée qu’une éviction qu’il n’a jamais vue avait supprimée ; et ce suivi des concurrences est lui-même borné : au-delà de 4 096 clés suivies, la garde de la clé la plus ancienne est abandonnée en premier. Les deux fenêtres sont acceptées, et refermées par le plafond de TTL ci-dessus.
|
||||
* **Pas de service d’une génération de protocole à l’autre.** Les entrées sont rattachées à la version de protocole négociée : sur un magasin persistant partagé, une session ne sert jamais une entrée écrite sous une autre version négociée (la même liste diffère réellement selon la génération, puisque le SDK retire les champs 2026 pour les sessions plus anciennes). L’éviction, de même, ne touche que les entrées de la génération courante ; les entrées d’une autre génération expirent simplement avec leur TTL.
|
||||
|
||||
### Lire les indications vous-même {#reading-the-hints-yourself}
|
||||
|
||||
Les indications sont aussi de simples champs sur chaque résultat pouvant être mis en cache (`result.ttl_ms` et `result.cache_scope`, déjà analysés), au cas où vous voudriez superposer votre propre suivi au cache intégré (ou le remplacer).
|
||||
|
||||
Face à un **serveur plus ancien** (protocole antérieur à 2026), les champs sont tout simplement absents de la liaison, et les modèles affichent leurs valeurs par défaut prudentes : `ttl_ms == 0` et `cache_scope == "private"`, périmé et non partagé, la bonne hypothèse pour un serveur qui n’a rien déclaré. Le cache traite une session historique de la même façon : les indications n’y sont jamais consultées (quelles que soient les clés présentes sur la liaison), seul `default_ttl_ms` s’applique, et sa valeur par défaut de `0` ne met rien en cache, de sorte qu’une connexion antérieure à 2026 se comporte exactement comme avant l’existence du cache. Si vous devez distinguer « le serveur a dit 0 » de « le serveur n’a rien dit », testez `"ttl_ms" in result.model_fields_set` : il n’est défini que lorsque le champ est réellement arrivé.
|
||||
|
||||
## Clients plus anciens {#older-clients}
|
||||
|
||||
Les clients sur des versions de protocole antérieures à 2026 ne voient jamais ni l’un ni l’autre de ces champs ; le SDK les retire à la sérialisation pour ces connexions. Configurez vos indications une fois pour toutes ; il n’y a rien de propre à une version à écrire.
|
||||
|
||||
## Récapitulatif {#recap}
|
||||
|
||||
* Six méthodes portent `ttlMs`/`cacheScope` ; le SDK leur donne par défaut `0`/`"private"`, périmé et non partagé, toujours sûr.
|
||||
* `cache_hints={method: CacheHint(...)}` à la construction (`MCPServer` comme `Server`) fixe des valeurs par méthode pour tout le serveur.
|
||||
* Un gestionnaire qui définit les champs sur son résultat surcharge le dictionnaire, champ par champ.
|
||||
* `"public"` est la promesse que le résultat est identique pour chaque appelant. Ce n’est pas un contrôle d’accès.
|
||||
* `Client` respecte les indications automatiquement : son cache de réponses est activé par défaut, sert les entrées fraîches au lieu de les récupérer à nouveau, et ne met rien en cache pour les serveurs (ou les sessions) qui ne fournissent aucune indication.
|
||||
* Par appel, `cache_mode="refresh"` récupère à nouveau et `"bypass"` saute le cache ; `cache=None` à la construction le désactive entièrement.
|
||||
154
i18n/fr/pages/client/callbacks.md
Normal file
154
i18n/fr/pages/client/callbacks.md
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
---
|
||||
translation:
|
||||
sections: [adf3c545b5be46b6, 916cd3ab1c03f461, e9be7a8d0eb0a456, 565890a636288ecf, 6af7e49db9129ec3, 06b0238c174186af, 90c6043be435fcb0]
|
||||
tool: 1
|
||||
---
|
||||
# Fonctions de rappel du client {#client-callbacks}
|
||||
|
||||
Presque toutes les requêtes dans MCP vont dans un seul sens : du client vers le serveur.
|
||||
|
||||
Un serveur peut aussi demander des choses au **client** : poser une question à l’utilisateur, échantillonner le modèle de l’utilisateur, lister les dossiers de son espace de travail. Vous répondez à ces requêtes en passant des **fonctions de rappel** (callbacks) à `Client(...)`.
|
||||
|
||||
## Un serveur qui demande {#a-server-that-asks}
|
||||
|
||||
Voici un serveur dont l’outil ne peut pas terminer tout seul :
|
||||
|
||||
```python title="server.py" hl_lines="16"
|
||||
--8<-- "docs_src/client_callbacks/tutorial001.py"
|
||||
```
|
||||
|
||||
* `ctx.elicit(...)` envoie une requête `elicitation/create` **au client** et attend.
|
||||
* L’outil ne renvoie rien tant que quelqu’un (une personne devant un formulaire, ou votre code) n’a pas fourni un `name`.
|
||||
|
||||
C’est la moitié serveur, et la page **[Élicitation](../handlers/elicitation.md)** la couvre en détail. Cette page-ci se tient à l’autre bout de la liaison.
|
||||
|
||||
## La fonction de rappel d’élicitation {#the-elicitation-callback}
|
||||
|
||||
```python title="client.py" hl_lines="6-10 16-17"
|
||||
--8<-- "docs_src/client_callbacks/tutorial002.py"
|
||||
```
|
||||
|
||||
* Une fonction de rappel d’élicitation (elicitation) a pour signature `async (context, params) -> ElicitResult`.
|
||||
* `params.message` est la question. `params.requested_schema` est le JSON Schema de la réponse que le serveur attend. Un vrai client en tire un formulaire ; celui-ci le remplit automatiquement.
|
||||
* Vous renvoyez `ElicitResult(action="accept", content={...})`, ou `action="decline"`, ou `action="cancel"`. La seule autre option est `ErrorData(...)`, qui refuse la requête et fait échouer l’appel entier.
|
||||
* `context` est un `ClientRequestContext` : la `session` active, le `request_id` du serveur et les éventuelles `meta` qu’il a jointes.
|
||||
|
||||
!!! tip
|
||||
`params` est une union des deux modes d’élicitation. Ici `params.mode` vaut `"form"` ; une requête `"url"`
|
||||
porte `params.url` au lieu d’un schéma. Une seule fonction de rappel gère les deux ; branchez sur `params.mode`.
|
||||
**[Élicitation](../handlers/elicitation.md)** montre le motif complet.
|
||||
|
||||
### Essayer {#try-it}
|
||||
|
||||
Appelez `issue_card` et observez les deux extrémités.
|
||||
|
||||
Votre fonction de rappel reçoit la question du serveur, déjà analysée :
|
||||
|
||||
```python
|
||||
params.mode # 'form'
|
||||
params.message # 'What name should go on the card?'
|
||||
params.requested_schema # {'properties': {'name': {'title': 'Name', 'type': 'string'}},
|
||||
# 'required': ['name'], 'title': 'CardHolder', 'type': 'object'}
|
||||
```
|
||||
|
||||
Elle répond, `ctx.elicit(...)` reprend à l’intérieur de l’outil, et l’outil termine :
|
||||
|
||||
```python
|
||||
result.content # [TextContent(type='text', text='Card issued to Ada Lovelace.')]
|
||||
```
|
||||
|
||||
Un `tools/call` de votre part, un `elicitation/create` en retour du serveur, auquel votre fonction répond, le tout à l’intérieur d’un seul appel d’outil.
|
||||
|
||||
!!! info
|
||||
`mode="legacy"` dans l’appel `Client(...)` fait un vrai travail. Par défaut, `Client(...)` négocie le chemin
|
||||
moderne du protocole, et ce chemin n’a pas de canal de retour (back-channel) pour les requêtes du serveur vers le client : `ctx.elicit`
|
||||
échoue avant même que votre fonction de rappel ne s’exécute. Ce n’est pas le transport qui en décide ; c’est le
|
||||
protocole négocié, en mémoire comme via une URL. Fixez `mode="legacy"` dès que votre client doit
|
||||
répondre à l’une d’elles ; tous les tests derrière cette page le font. Tous les détails sont dans **[Versions du protocole](../protocol-versions.md)**.
|
||||
|
||||
Sur une session 2026-07-28, la fonction de rappel n’est pas morte, elle est alimentée autrement : quand un outil renvoie un
|
||||
`InputRequiredResult` portant une `ElicitRequest`, `Client` transmet cette entrée à la même
|
||||
`elicitation_callback` et relance l’appel pour vous. Ce flux est décrit dans **[Requêtes à plusieurs allers-retours](../handlers/multi-round-trip.md)** (multi-round-trip).
|
||||
|
||||
## Une fonction de rappel est une capacité {#a-callback-is-a-capability}
|
||||
|
||||
Vous n’avez jamais dit au serveur que votre client sait répondre aux requêtes d’élicitation. Le SDK l’a fait.
|
||||
|
||||
Quand un client se connecte, il déclare ses `capabilities`, l’image miroir de celles du serveur. Vous n’écrivez pas cet objet. **Enregistrer une fonction de rappel vaut déclaration.**
|
||||
|
||||
| vous passez | le client déclare |
|
||||
| --- | --- |
|
||||
| `elicitation_callback=` | `"elicitation": {"form": {}, "url": {}}` |
|
||||
| `sampling_callback=` | `"sampling": {}` |
|
||||
| `list_roots_callback=` | `"roots": {"listChanged": true}` |
|
||||
| aucune d’elles | `{}` |
|
||||
|
||||
Les sous-capacités d’échantillonnage (sampling) sont le seul raffinement : passez `sampling_capabilities=SamplingCapability(tools=SamplingToolsCapability())` en plus de `sampling_callback` lorsque votre échantillonneur gère les paramètres `tools` / `tool_choice`. Les serveurs doivent voir `sampling.tools` déclaré avant de pouvoir les envoyer.
|
||||
|
||||
`logging_callback` et `message_handler` ne figurent pas dans le tableau. Ils traitent des notifications, et les notifications n’exigent aucune capacité.
|
||||
|
||||
Le serveur relit la déclaration avec `ctx.session.check_client_capability(...)`. Ajoutez un outil qui le fait :
|
||||
|
||||
```python title="server.py" hl_lines="23-31"
|
||||
--8<-- "docs_src/client_callbacks/tutorial003.py"
|
||||
```
|
||||
|
||||
Connectez-vous avec seulement `elicitation_callback` et appelez-le :
|
||||
|
||||
```python
|
||||
result.structured_content # {'result': ['elicitation']}
|
||||
```
|
||||
|
||||
Passez les trois fonctions de rappel et vous obtenez `['elicitation', 'sampling', 'roots']`. N’en passez aucune et vous obtenez `[]`.
|
||||
|
||||
!!! check
|
||||
Faites maintenant ce qu’il ne faut pas : connectez-vous **sans** `elicitation_callback` et appelez `issue_card` quand même.
|
||||
|
||||
La requête `elicitation/create` du serveur atteint toujours votre client, et le SDK y répond à votre
|
||||
place, par une erreur, puisque vous n’avez jamais dit pouvoir la traiter. Cette erreur coule l’appel entier.
|
||||
`call_tool` ne renvoie pas un résultat `is_error` ; il lève une exception :
|
||||
|
||||
```text
|
||||
MCPError: Elicitation not supported
|
||||
```
|
||||
|
||||
C’est une erreur de protocole (`-32600`, *requête invalide*), pas une erreur d’outil : le modèle n’a rien
|
||||
à lire ni à retenter. C’est pourquoi `client_features` vaut la peine : un serveur bien élevé
|
||||
vérifie avant de demander.
|
||||
|
||||
## La paire obsolète {#the-deprecated-pair}
|
||||
|
||||
`sampling_callback` répond à `sampling/createMessage` : le serveur demande à *votre* modèle de compléter quelque chose. `list_roots_callback` répond à `roots/list` : le serveur demande dans quels répertoires il peut travailler.
|
||||
|
||||
Les deux fonctionnent. Les deux suivent la règle ci-dessus. Et les deux servent des RPC que la **spécification 2026-07-28 supprime** : un serveur moderne ne rappelle pas votre client en pleine requête, il vous rend la requête dans le résultat de l’outil (**[Requêtes à plusieurs allers-retours](../handlers/multi-round-trip.md)**). Les fonctions de rappel elles-mêmes ne sont pas mortes. Quand un `InputRequiredResult` porte une `CreateMessageRequest` ou une `ListRootsRequest`, la boucle automatique de `Client` la transmet à la même `sampling_callback` ou `list_roots_callback` que vous avez enregistrée ici. La liste complète est dans **[Fonctionnalités obsolètes](../deprecated.md)**.
|
||||
|
||||
Vous avez encore besoin de ces fonctions de rappel pour parler aux serveurs qui n’ont pas migré. Les signatures :
|
||||
|
||||
```python title="client.py"
|
||||
--8<-- "docs_src/client_callbacks/tutorial004.py"
|
||||
```
|
||||
|
||||
* Une fonction de rappel d’échantillonnage reçoit le `CreateMessageRequestParams` complet (`messages`, `model_preferences`, `max_tokens`) et renvoie un `CreateMessageResult`. C’est *vous* qui exécutez le modèle, comme bon vous semble ; le SDK ne fait que transporter la requête.
|
||||
* Une fonction de rappel de racines (roots) ne prend aucun paramètre et renvoie un `ListRootsResult`.
|
||||
* L’une comme l’autre peut renvoyer `ErrorData(...)` à la place, pour refuser.
|
||||
|
||||
Passez-les à `Client(...)` exactement comme `elicitation_callback`.
|
||||
|
||||
## Les fonctions de rappel de notification {#the-notification-callbacks}
|
||||
|
||||
Deux de plus. Aucune ne déclare quoi que ce soit.
|
||||
|
||||
`logging_callback` reçoit les `notifications/message` qu’un serveur envoie, sous forme de `LoggingMessageNotificationParams` (`level`, `logger`, `data`). La journalisation par le protocole est elle-même rendue obsolète par la spécification 2026-07-28 (**[Journalisation](../handlers/logging.md)** explique quoi faire à la place), donc cette fonction de rappel existe pour les serveurs qui l’émettent encore. Sur une connexion de génération 2026, la fonction de rappel seule ne vous apporte rien, car les serveurs 2026 n’envoient des messages de journal qu’aux requêtes qui en font la demande : passez `log_level="info"` (ou un autre niveau) à `Client(...)` pour apposer cette demande sur chaque requête et recevoir ce niveau et les niveaux supérieurs. Les serveurs antérieurs à 2026 l’ignorent et conservent leur comportement `logging/setLevel`.
|
||||
|
||||
`message_handler` est le fourre-tout : chaque notification serveur que la session remonte lui parvient (en plus de sa fonction de rappel spécifique), et sur un transport adossé à un flux, chaque `Exception` de niveau transport aussi. Deux n’y parviennent jamais : `notifications/cancelled` est appliquée par le SDK plutôt que remontée, et l’accusé de réception d’abonnement d’un flux `listen()` actif est consommé par ce flux. Annotez le paramètre avec `IncomingMessage` (`ServerNotification | Exception`, exporté depuis `mcp.client`). Le seul motif à connaître est `if isinstance(message, Exception): raise message`, pour qu’une connexion rompue échoue bruyamment au lieu de disparaître en silence.
|
||||
|
||||
## Récapitulatif {#recap}
|
||||
|
||||
* Un serveur peut envoyer des requêtes au client. Vous y répondez avec des fonctions de rappel passées à `Client(...)`.
|
||||
* La fonction de rappel d’élicitation est celle d’actualité : `async (context, params) -> ElicitResult`, une seule fonction pour les modes formulaire et URL.
|
||||
* **Enregistrer une fonction de rappel, c’est déclarer la capacité.** Sans elle, le SDK refuse la requête du serveur à votre place et l’appel entier échoue avec `MCPError`.
|
||||
* Un serveur le sait avant de demander grâce à `ctx.session.check_client_capability(...)`.
|
||||
* `sampling_callback` et `list_roots_callback` fonctionnent de la même manière mais servent des fonctionnalités obsolètes ; les serveurs modernes utilisent à la place les requêtes à plusieurs allers-retours.
|
||||
* `logging_callback` et `message_handler` reçoivent des notifications. Ils ne déclarent rien.
|
||||
|
||||
Le premier argument de `Client(...)` est un objet transport. **[Transports client](transports.md)** couvre tous les types.
|
||||
155
i18n/fr/pages/client/identity-assertion.md
Normal file
155
i18n/fr/pages/client/identity-assertion.md
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
---
|
||||
translation:
|
||||
sections: [a91322c46111d16d, 8e6fd6d6f59bb568, e7828fd2729b2c9d, a03ec26bfc678b65, 1034c653c0bcf1b0]
|
||||
tool: 1
|
||||
---
|
||||
# Assertion d’identité {#identity-assertion}
|
||||
|
||||
Un fournisseur OAuth ordinaire (**[Clients OAuth](oauth-clients.md)**) commence par poser une question au serveur MCP : *à quel serveur d’autorisation faites-vous confiance ?* Il suit la réponse où qu’elle mène, puis soit une personne se connecte, soit un secret pré-partagé en tient lieu.
|
||||
|
||||
Une entreprise ne veut voir ni l’un ni l’autre décidé serveur par serveur. Elle exploite déjà un fournisseur d’identité (Okta, Microsoft Entra ID, le vôtre) ; l’utilisateur s’y est déjà connecté ce matin ; et c’est l’unique endroit où l’équipe sécurité veut décider qui peut accéder à quoi. La [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990), l’extension **Enterprise-Managed Authorization**, y déplace la décision. L’IdP signe un JWT de courte durée, un **Identity Assertion JWT Authorization Grant**, l’**ID-JAG** : une déclaration selon laquelle *cet utilisateur*, via *ce client*, peut accéder à *ce serveur MCP*. Le client l’échange contre un jeton d’accès ordinaire. Pas de navigateur, pas d’écran de consentement, pas d’enregistrement dynamique.
|
||||
|
||||
Cette page couvre les deux extrémités de cet échange. Le serveur MCP lui-même ne change jamais : il reste le serveur de ressources de **[Autorisation](../run/authorization.md)**, qui vérifie le jeton qui se présente, quel qu’il soit.
|
||||
|
||||
## Deux requêtes de jeton {#two-token-requests}
|
||||
|
||||
Deux autorités différentes sont en jeu, et bien les distinguer, c’est l’essentiel pour comprendre cette page. L’**IdP d’entreprise** est le fournisseur d’identité de votre organisation : il sait qui est l’employé, c’est là que réside la politique d’accès, et il émet l’ID-JAG. Le SDK ne lui parle jamais. Le **serveur d’autorisation MCP** est le même acteur que dans **[Autorisation](../run/authorization.md)** : l’émetteur nommé dans les métadonnées du serveur MCP, celui qui émet les jetons que ce serveur MCP accepte. Dans un flux OAuth ordinaire, ces deux rôles tiennent généralement dans une seule boîte. Ici ils sont deux, et tout le grant consiste en ce que le second accepte de faire confiance au premier.
|
||||
|
||||
Le client adresse une requête de jeton à chacun.
|
||||
|
||||
1. **Vers l’IdP d’entreprise.** Le client échange la connexion de l’utilisateur (son jeton d’identité OpenID Connect) contre l’ID-JAG. C’est un échange de jetons [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693), c’est entièrement l’API de votre IdP, et **le SDK ne l’effectue pas**. C’est vous qui le faites, dans une seule fonction de rappel (callback) asynchrone. C’est aussi là que se prend la décision de politique : un IdP qui dit non n’émet jamais l’ID-JAG, et il n’y a rien à présenter.
|
||||
2. **Vers le serveur d’autorisation MCP.** Le client présente l’ID-JAG sous le grant `jwt-bearer` de la [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) (`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`, l’ID-JAG comme `assertion`) et reçoit le jeton d’accès. **C’est la requête que le SDK effectue**, et l’accepter est la seule chose que cette page ajoute à un serveur d’autorisation.
|
||||
|
||||
Tout ce qui suit concerne la seconde requête : le client qui l’envoie et le serveur d’autorisation qui y répond.
|
||||
|
||||
## Le client {#the-client}
|
||||
|
||||
**`IdentityAssertionOAuthProvider`** se trouve dans `mcp.client.auth.extensions.identity_assertion`. Comme tous les fournisseurs de **[Clients OAuth](oauth-clients.md)**, c’est un `httpx2.Auth` : construisez-en un, placez-le sur `auth=`, passez le `httpx2.AsyncClient` au transport.
|
||||
|
||||
```python title="client.py" hl_lines="49-50 53-61"
|
||||
--8<-- "docs_src/identity_assertion/tutorial001.py"
|
||||
```
|
||||
|
||||
Lisez-le en partant du bas.
|
||||
|
||||
* `main()` est le `main()` standard d’un client OAuth (**[Clients OAuth](oauth-clients.md)**), inchangé ligne pour ligne. C’est tout l’intérêt : une fois le fournisseur en place, rien en aval ne sait quel grant a produit le jeton.
|
||||
* Le fournisseur prend ce que les autres fournisseurs ne peuvent pas découvrir : un `client_id` et un `client_secret` que quelqu’un a **pré-enregistrés** auprès du serveur d’autorisation, la valeur `issuer` de ce serveur d’autorisation, et `assertion_provider`, une fonction de rappel asynchrone qui renvoie un ID-JAG tout neuf à la demande.
|
||||
* `storage` est le même protocole `TokenStorage`. Seules les deux méthodes de jeton sont appelées ; il n’y a pas d’enregistrement dynamique ici, donc pas de `client_info` à mémoriser.
|
||||
|
||||
### Le fournisseur d’assertion {#the-assertion-provider}
|
||||
|
||||
`fetch_id_jag(audience, resource)` est le seul code que vous écrivez. Il est attendu (await) une fois par échange de jeton, jamais à la construction, et seulement *après* que les métadonnées du serveur d’autorisation ont été récupérées et validées, si bien qu’un émetteur mal configuré ne laisse jamais fuiter une assertion. Ses deux arguments sont deux des claims avec lesquels l’ID-JAG doit être émis : `audience` est l’émetteur du serveur d’autorisation (le `aud` de l’ID-JAG) et `resource` est l’identifiant canonique du serveur MCP (le `resource` de l’ID-JAG). Le troisième, vous le détenez déjà : le claim `client_id` de l’ID-JAG doit nommer le `client_id` que vous avez donné au fournisseur, faute de quoi le serveur d’autorisation refuse l’échange.
|
||||
|
||||
`idp_issue_id_jag`, juste au-dessus, n’est **pas votre code**. Il tient lieu de fournisseur d’identité et signe l’assertion dans le processus même, pour que le fichier soit complet et que vous puissiez lire chaque claim que porte un ID-JAG. Un vrai `fetch_id_jag` effectue à la place la première requête de jeton de la section précédente : un échange de jetons [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) auprès de votre IdP, défini par le draft Identity Assertion JWT Authorization Grant dont la [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) définit un profil. Le jeton d’identité de l’utilisateur connecté y entre comme `subject_token`, le `requested_token_type` est l’URN propre à l’ID-JAG (`urn:ietf:params:oauth:token-type:id-jag`), `audience` et `resource` sont transmis tels quels, et la réponse porte l’ID-JAG. Cet échange, sous ces noms-là, est ce qu’il faut chercher dans la documentation de votre IdP.
|
||||
|
||||
!!! tip
|
||||
Un nouvel ID-JAG est demandé à chaque échange, et c’est voulu : c’est un grant à usage unique,
|
||||
valable quelques minutes, et le serveur d’autorisation de cette page refuse d’accepter deux fois
|
||||
le même. Ne le mettez pas en cache. C’est le jeton d’accès qu’il vous procure qui est réutilisé.
|
||||
|
||||
### L’émetteur relève de la configuration {#the-issuer-is-configuration}
|
||||
|
||||
Voici l’inversion. `OAuthClientProvider` demande au serveur de ressources quel serveur d’autorisation utiliser et suit la réponse où qu’elle mène. Ce fournisseur-ci s’y refuse : `issuer` est obligatoire, les métadonnées [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) sont récupérées depuis le chemin well-known de cet émetteur même, le point de terminaison de jeton doit se trouver sur l’origine de cet émetteur, et rien n’est jamais demandé au serveur de ressources.
|
||||
|
||||
L’extension ne l’exige pas ; c’est un choix délibérément plus strict. Ce client transporte deux choses qui valent d’être volées, un secret pré-enregistré et une assertion liée à une audience, et un client qui laisserait un serveur MCP compromis l’aiguiller vers le serveur d’autorisation d’un attaquant y posterait les deux. Épingler l’émetteur à la construction supprime purement et simplement cette conversation.
|
||||
|
||||
!!! warning
|
||||
La valeur `issuer` configurée est comparée au champ `issuer` du document de métadonnées par la
|
||||
comparaison de chaînes simple de la RFC 8414 §3.3 : caractère par caractère, barre oblique finale
|
||||
comprise, sans normalisation. Ne la devinez pas. Récupérez `/.well-known/oauth-authorization-server`
|
||||
auprès de votre serveur d’autorisation et copiez la valeur `issuer` qu’il renvoie. Pour le serveur
|
||||
d’autorisation de cette page, c’est `https://auth.example.com/`, avec la barre oblique, parce que
|
||||
son émetteur a été construit à partir d’un objet URL pydantic. Une discordance arrête le flux
|
||||
sur `OAuthFlowError: Authorization server metadata issuer
|
||||
mismatch` avant qu’un seul identifiant ou une seule assertion ne soit envoyé.
|
||||
|
||||
### Un client confidentiel {#a-confidential-client}
|
||||
|
||||
`client_secret` est obligatoire ; sans lui, le constructeur lève `ValueError`. Le profil IETF sous-jacent à la [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) réserve ce grant aux clients confidentiels, la SEP-990 exige que le client s’authentifie, et ce SDK fait respecter les deux en imposant un secret partagé. `token_endpoint_auth_method` choisit par où il transite : `client_secret_post` (la valeur par défaut, dans le corps du formulaire) ou `client_secret_basic` (un en-tête HTTP Basic). Le profil autorise aussi `private_key_jwt` ; ce fournisseur ne le prend pas en charge.
|
||||
|
||||
!!! tip
|
||||
Lisez `client_secret` depuis l’environnement ou un gestionnaire de secrets, jamais depuis le dépôt de code.
|
||||
|
||||
### Ce que le fournisseur fait pour vous {#what-the-provider-does-for-you}
|
||||
|
||||
La première requête part sans authentification, et le `401` du serveur démarre le flux.
|
||||
|
||||
1. **Découverte.** Il récupère les métadonnées du serveur d’autorisation depuis le chemin well-known [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) de l’émetteur configuré, vérifie que la valeur `issuer` du document correspond, et vérifie que le point de terminaison de jeton se trouve sur l’origine de l’émetteur.
|
||||
2. **L’assertion.** Il attend (await) votre `assertion_provider`.
|
||||
3. **Échange.** Il envoie en POST le grant `jwt-bearer` au point de terminaison de jeton, stocke le `OAuthToken`, et rejoue votre requête d’origine avec `Authorization: Bearer ...`.
|
||||
|
||||
Un `403` dont le `WWW-Authenticate` nomme `insufficient_scope` relance les étapes 2 et 3 avec l’union de votre `scope` et de celui du défi. (`scope` n’est jamais qu’une demande ; le serveur d’autorisation de cette page accorde ce que dit l’ID-JAG et rien d’autre.) Il n’y a de jeton d’actualisation nulle part ici : quand le jeton d’accès expire, le `401` suivant fait émettre un nouvel ID-JAG et relance l’échange, et c’est *là* le levier que détient l’IdP. Les échecs sont les deux mêmes exceptions que dans le reste de **[Clients OAuth](oauth-clients.md)** : `OAuthFlowError` pour la découverte et la validation, sa sous-classe `OAuthTokenError` quand le point de terminaison de jeton dit non.
|
||||
|
||||
## Le serveur d’autorisation {#the-authorization-server}
|
||||
|
||||
La plupart du temps, vous vous arrêtez ici. Le serveur d’autorisation MCP est le produit de quelqu’un d’autre, accepter les ID-JAG est une option de sa configuration à activer, et la moitié de la [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) qui revient au SDK est le client ci-dessus.
|
||||
|
||||
Le SDK peut aussi *être* le serveur d’autorisation : `create_auth_routes` renvoie les routes du serveur d’autorisation sous forme d’une liste que n’importe quelle application Starlette peut monter, et c’est ainsi que `examples/servers/simple-auth/` dans le dépôt en fait tourner un. La SEP-990 ajoute un drapeau et une méthode à cette surface :
|
||||
|
||||
```python title="auth_server.py" hl_lines="48-50 105-107"
|
||||
--8<-- "docs_src/identity_assertion/tutorial002.py"
|
||||
```
|
||||
|
||||
* `identity_assertion_enabled=True` conditionne tout. Désactivé, ce qui est la valeur par défaut, `/token` répond à ce grant par `unsupported_grant_type` même si vous avez implémenté le hook, et les métadonnées n’en font pas mention. Activé, les métadonnées gagnent le type de grant `jwt-bearer` et listent `urn:ietf:params:oauth:grant-profile:id-jag` dans `authorization_grant_profiles_supported`, le champ par lequel l’extension annonce sa prise en charge. (Le client de ce SDK ne le lit jamais : il est provisionné pour un seul émetteur et demande, tout simplement.)
|
||||
* **`exchange_identity_assertion`** est le hook. Avant qu’il ne s’exécute, le SDK a authentifié le client, refusé les clients publics, et refusé les clients dont l’enregistrement ne liste pas le grant. Vous recevez un `IdentityAssertionParams` (la valeur `assertion` brute, les `scopes` et `resource` demandés) et renvoyez un simple `OAuthToken`.
|
||||
* L’enregistrement dynamique des clients refuse ce grant sans condition, si bien que `get_client` sert ici un client provisionné à la main. Un client ID-JAG ne peut pas se faire exister en s’enregistrant lui-même.
|
||||
* La moitié de la classe est faite de refus. `OAuthAuthorizationServerProvider` est le serveur d’autorisation *tout entier*, il réclame donc aussi le flux authorization code ; un serveur qui connecte aussi des utilisateurs implémente ces méthodes pour de bon, et celui-ci n’a qu’une seule porte.
|
||||
|
||||
!!! warning
|
||||
Le SDK ne décode jamais l’assertion : seul votre déploiement sait à quel IdP il fait confiance et
|
||||
quelles clés cet IdP publie, donc tout ce qui se trouve dans `exchange_identity_assertion` est
|
||||
déterminant. Vérifiez la signature par rapport aux clés publiées de l’IdP (son JWKS ; le secret
|
||||
partagé ici est celui de la démo), ainsi que `iss` et `exp`, selon la [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) §3. Exigez
|
||||
que le `typ` de l’en-tête JWT soit `oauth-id-jag+jwt`, le garde-fou du profil contre le rejeu
|
||||
d’un autre JWT comme grant. Exigez que `aud` soit votre propre émetteur. Exigez que le claim
|
||||
`client_id` de l’ID-JAG soit égal au client que le gestionnaire (handler) a authentifié, et que
|
||||
son claim `resource` nomme une ressource que vous servez réellement. Suivez `jti` jusqu’à la
|
||||
valeur `exp` de l’assertion pour qu’elle ne soit acceptée qu’une fois. Et tirez les scopes
|
||||
accordés et, surtout, le `resource` du jeton émis de l’ID-JAG validé, jamais de la requête :
|
||||
`params.resource` est ce que le client a tapé, quoi que ce soit. Les règles de traitement
|
||||
complètes sont dans la
|
||||
[spécification Enterprise-Managed Authorization](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization).
|
||||
|
||||
Rejetez une mauvaise assertion avec `TokenError("invalid_grant", ...)`. L’autre code d’erreur de ce flux est `invalid_target` : un ID-JAG qui nomme une ressource que vous ne servez pas est refusé avec lui, et c’est ce qui empêche ce serveur d’émettre des jetons pour celle de quelqu’un d’autre. Et les scopes accordés viennent du claim `scope` de l’ID-JAG (une assertion qui n’en a pas est refusée elle aussi) ; le vôtre pourrait plutôt faire correspondre les groupes de l’utilisateur.
|
||||
|
||||
Et remarquez ce que le `OAuthToken` renvoyé ne porte pas : un jeton d’actualisation. L’IdP décide combien de temps cet utilisateur garde l’accès en décidant d’émettre ou non le prochain ID-JAG. Un jeton d’actualisation émis ici reprendrait en douce cette décision à l’IdP.
|
||||
|
||||
!!! info
|
||||
Un serveur qui embarque encore son serveur d’autorisation avec `auth_server_provider=` atteint le
|
||||
même code via `AuthSettings(identity_assertion_enabled=True)`. **[Autorisation](../run/authorization.md)** explique pourquoi
|
||||
les nouveaux serveurs ne devraient pas commencer par là.
|
||||
|
||||
!!! check
|
||||
Reliez les deux fichiers de cette page et tout le grant tient en un seul `POST /token` :
|
||||
|
||||
```text
|
||||
grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
|
||||
assertion=eyJhbGciOiJIUzI1NiIsInR5cCI6Im9hdXRoLWlkLWphZytqd3QifQ...
|
||||
client_id=finance-agent
|
||||
resource=http://localhost:8001/mcp
|
||||
scope=notes:read
|
||||
client_secret=finance-agent-secret
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
{"access_token": "mcp_...", "token_type": "Bearer", "expires_in": 300, "scope": "notes:read"}
|
||||
```
|
||||
|
||||
Pas de `/authorize`, pas de `/register`, pas de récupération des métadonnées de ressource
|
||||
protégée. Les seules requêtes sur la liaison sont celle qui a provoqué le `401`, la récupération
|
||||
well-known, cet échange, puis le trafic MCP ordinaire avec le jeton porteur attaché. Et le `sub`
|
||||
que votre validateur a lu dans l’ID-JAG est exactement ce que `get_access_token().subject`
|
||||
rapporte à l’intérieur d’un outil.
|
||||
|
||||
### Essayer {#try-it}
|
||||
|
||||
`examples/stories/identity_assertion/` dans le dépôt du SDK, c’est cette page exécutée pour de bon : le même validateur `exchange_identity_assertion`, un serveur MCP protégé par ses jetons, un IdP de substitution et le client, dans un seul programme qui se vérifie lui-même. `uv run python -m stories.identity_assertion.client --http` exécute tout l’échange et vérifie par assertion que l’utilisateur nommé par l’IdP est bien celui que voit l’outil.
|
||||
|
||||
## Récapitulatif {#recap}
|
||||
|
||||
* La [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) laisse le fournisseur d’identité de l’entreprise, et non l’utilisateur final, décider quels serveurs MCP un client peut atteindre. L’IdP signe cette décision dans un **ID-JAG**.
|
||||
* Obtenir l’ID-JAG est un échange de jetons [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) auprès de *votre IdP*, et le SDK ne l’effectue pas. Le présenter au serveur d’autorisation MCP relève du grant `jwt-bearer` de la [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523), et le SDK en assure les deux côtés.
|
||||
* `IdentityAssertionOAuthProvider` est un `httpx2.Auth` de plus : un client confidentiel pré-enregistré, un `issuer` épinglé, et une fonction de rappel `assertion_provider(audience, resource)`. Pas de navigateur, pas d’enregistrement, pas de jeton d’actualisation.
|
||||
* Le serveur d’autorisation n’est jamais découvert à partir du serveur de ressources. Configurez `issuer` avec exactement la chaîne que sert son document de métadonnées ; la comparaison se fait caractère par caractère.
|
||||
* Côté serveur, `identity_assertion_enabled=True` plus `exchange_identity_assertion`. Le SDK authentifie le client et conditionne le grant ; valider l’ID-JAG vous revient entièrement, et le jeton émis est lié au `resource` de l’ID-JAG, pas à celui de la requête.
|
||||
|
||||
Le seul acteur auquel cette page n’a jamais touché est le serveur MCP. Ce qu’il fait du jeton que vous venez d’émettre, il le faisait déjà dans **[Autorisation](../run/authorization.md)**.
|
||||
217
i18n/fr/pages/client/index.md
Normal file
217
i18n/fr/pages/client/index.md
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
---
|
||||
translation:
|
||||
sections: [ebef1e7a0df854f4, a4c687d3d627d516, 8e79141fc2985342, b345dd05b9c3c7ab, 80ce41579825a6fa, 5f0fa90494de8f65, 83d10514eaa62fa5, 9190555aa39a5d28, 84a4c9d8bf14dddb, 927d71cf40b58c30]
|
||||
tool: 1
|
||||
---
|
||||
# Le client {#the-client}
|
||||
|
||||
Un **`Client`** est le moyen par lequel un programme Python dialogue avec un serveur MCP.
|
||||
|
||||
C’est un seul objet avec un seul cycle de vie : vous le construisez, vous entrez dans `async with`, vous appelez des méthodes. Chaque verbe du protocole (lister les outils, en appeler un, lire une ressource, rendre un prompt) est une méthode `async` de cet objet qui renvoie un résultat typé.
|
||||
|
||||
## Votre premier client {#your-first-client}
|
||||
|
||||
```python title="client.py" hl_lines="14-18"
|
||||
--8<-- "docs_src/client/tutorial001.py"
|
||||
```
|
||||
|
||||
Le serveur en haut n’est là que pour vous donner quelque chose à quoi vous connecter. Le client, ce sont les cinq lignes surlignées.
|
||||
|
||||
* `Client(mcp)` reçoit **l’objet serveur lui-même**. C’est le transport en mémoire : pas de sous-processus, pas de port, pas de HTTP. C’est ainsi que se connectent tous les exemples de cette page, et tous les tests que vous écrivez.
|
||||
* `async with` est le **cycle de vie**. Y entrer connecte et négocie ; en sortir déconnecte. Il n’y a pas de paire `connect()` / `close()`, et un `Client` ne peut pas être réutilisé une fois le bloc terminé.
|
||||
* À l’intérieur du bloc, les informations de connexion sont déjà là, sous forme de simples propriétés.
|
||||
|
||||
### Ce que vous pouvez passer à `Client` {#what-you-can-pass-to-client}
|
||||
|
||||
`Client` prend un seul argument positionnel et déduit le transport de son type :
|
||||
|
||||
* Une instance de `MCPServer` (ou du `Server` bas niveau) : connexion **dans le processus**.
|
||||
* Une chaîne d’URL (`Client("http://localhost:8000/mcp")`) : Streamable HTTP, la voie de production.
|
||||
* Un **transport** : tout ce sur quoi vous pouvez faire `async with ... as (read, write)`, comme `stdio_client(...)` qui enveloppe un sous-processus.
|
||||
|
||||
Tout le reste de cette page est identique pour les trois. Les en-têtes, les sous-processus, les délais d’expiration et le protocole `Transport` ont leur propre page : **[Transports côté client](transports.md)**.
|
||||
|
||||
### Ce que porte un client connecté {#whats-on-a-connected-client}
|
||||
|
||||
Quatre propriétés en lecture seule, renseignées dès que vous entrez dans le bloc :
|
||||
|
||||
* `client.server_info` : l’identité du serveur, ou `None` pour un serveur de génération 2026 qui n’en déclare pas (les serveurs python-sdk le font par défaut). Ici, `server_info.name` vaut `"Bookshop"` et `server_info.version` est ce que le serveur déclare.
|
||||
* `client.server_capabilities` : ce que le serveur sait faire (`tools`, `resources`, `prompts`, `completions`, ...). Une capacité que le serveur n’a pas vaut `None`.
|
||||
* `client.protocol_version` : la version du protocole sur laquelle les deux côtés se sont mis d’accord. Ici, c’est `"2026-07-28"`.
|
||||
* `client.instructions` : la chaîne `instructions=` du serveur, ou `None` s’il n’en a pas défini.
|
||||
|
||||
Vous n’avez jamais choisi de version du protocole. Par défaut, le `Client` sonde le serveur et se rabat sur la poignée de main (handshake) classique avec les plus anciens, si bien qu’un seul client fonctionne avec un serveur de n’importe quelle génération. Lorsque vous avez besoin de contrôler cela, tous les détails sont dans **[Versions du protocole](../protocol-versions.md)**.
|
||||
|
||||
!!! tip
|
||||
`client.session` est la `ClientSession` sous-jacente, l’échappatoire bas niveau.
|
||||
Vous n’en aurez besoin pour rien sur cette page.
|
||||
|
||||
## Lister les outils {#listing-tools}
|
||||
|
||||
```python title="client.py" hl_lines="15-20"
|
||||
--8<-- "docs_src/client/tutorial002.py"
|
||||
```
|
||||
|
||||
`list_tools()` renvoie un `ListToolsResult` ; les outils sont dans `.tools`. Chacun est la définition complète qu’un hôte transmettrait à un modèle :
|
||||
|
||||
```python
|
||||
tool.name # 'search_books'
|
||||
tool.title # 'Search the catalog'
|
||||
tool.description # 'Search the catalog by title or author.'
|
||||
```
|
||||
|
||||
et `tool.input_schema` est le JSON Schema que le serveur a dérivé des annotations de type de la fonction :
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"title": "Query", "type": "string"},
|
||||
"limit": {"default": 10, "title": "Limit", "type": "integer"}
|
||||
},
|
||||
"required": ["query"],
|
||||
"title": "search_booksArguments"
|
||||
}
|
||||
```
|
||||
|
||||
Ce schéma est tout ce dont une interface a besoin pour afficher un formulaire d’arguments, et tout ce dont un modèle a besoin pour produire des arguments valides.
|
||||
|
||||
!!! tip
|
||||
`title` est facultatif, donc une interface qui présente des outils à un humain doit choisir : le `title` s’il existe,
|
||||
le `name` sinon. `from mcp.shared.metadata_utils import get_display_name` fait exactement cela,
|
||||
pour les outils, les ressources, les modèles de ressource et les prompts.
|
||||
|
||||
## Appeler un outil {#calling-a-tool}
|
||||
|
||||
`call_tool(name, arguments)` exécute l’outil et vous renvoie un `CallToolResult`.
|
||||
|
||||
```python title="client.py" hl_lines="26-33"
|
||||
--8<-- "docs_src/client/tutorial003.py"
|
||||
```
|
||||
|
||||
Le `lookup_book` du serveur renvoie un `Book` Pydantic. Voici ce que voit le client :
|
||||
|
||||
```python
|
||||
result.content # [TextContent(type='text', text='{\n "title": "Dune",\n "author": "Frank Herbert",\n "year": 1965\n}')]
|
||||
result.structured_content # {'title': 'Dune', 'author': 'Frank Herbert', 'year': 1965}
|
||||
result.is_error # False
|
||||
```
|
||||
|
||||
Une valeur de retour, trois choses à lire. Chacune a un consommateur différent.
|
||||
|
||||
### `content` : ce que lit le modèle {#content-what-the-model-reads}
|
||||
|
||||
`content` est une `list` de **blocs de contenu**, et un bloc de contenu est une union : `TextContent`, `ImageContent`, `AudioContent`, `ResourceLink` ou `EmbeddedResource`. Un outil peut en renvoyer plusieurs, de natures différentes.
|
||||
|
||||
C’est pourquoi `main` restreint le type avec `isinstance(block, TextContent)` avant de toucher à `block.text`. Remarquez qu’il n’y a pas de `.text` en dehors du `isinstance` : le vérificateur de types ne le permettrait pas, car `ImageContent` a `.data`, pas `.text`. L’union est honnête sur ce qu’un outil a le droit de vous envoyer ; votre code devrait l’être aussi.
|
||||
|
||||
### `structured_content` : ce que lit votre application {#structured_content-what-your-application-reads}
|
||||
|
||||
`structured_content` est la valeur de retour de l’outil au format JSON, conforme au `output_schema` déclaré par l’outil. Pas d’analyse de chaînes, pas de devinettes.
|
||||
|
||||
Quand les deux sont présents, ils disent volontairement deux fois la même chose : `content` est pour un modèle, `structured_content` pour du code. D’où vient la moitié structurée, et comment la contrôler, c’est le sujet de la page **[Sortie structurée](../servers/structured-output.md)**.
|
||||
|
||||
### `is_error` : si l’outil a échoué {#is_error-whether-the-tool-failed}
|
||||
|
||||
Un outil qui lève une exception ne lève **rien** dans votre client. Il revient sous la forme d’un résultat ordinaire avec `is_error=True`.
|
||||
|
||||
!!! check
|
||||
Demandez `"Solaris"` à `lookup_book` (un titre qui n’est pas au catalogue) et la fonction lève
|
||||
`ValueError`. L’appel revient pourtant normalement :
|
||||
|
||||
```python
|
||||
result.is_error # True
|
||||
result.content # [TextContent(type='text', text="Error executing tool lookup_book: No book titled 'Solaris' in the catalog.")]
|
||||
result.structured_content # None
|
||||
```
|
||||
|
||||
Le message de l’exception a atterri dans `content`, où le **modèle** peut le lire et réessayer. C’est
|
||||
délibéré : une erreur d’outil fait partie de la conversation, ce n’est pas un plantage. Regardez toujours `is_error`
|
||||
avant de faire confiance à `structured_content`.
|
||||
|
||||
!!! warning
|
||||
`is_error=True` couvre plus que vos propres `raise`. Demandez un outil que le serveur n’a même pas
|
||||
(`call_tool("does_not_exist", {})`) et rien n’est levé. Vous obtenez la même forme en retour :
|
||||
`is_error=True` avec `Unknown tool: does_not_exist` dans `content`. Une méthode de `Client` ne lève
|
||||
`MCPError` que lorsque le serveur répond par une **erreur** JSON-RPC au lieu d’un résultat, et
|
||||
**[Gérer les erreurs](../servers/handling-errors.md)** explique quand un serveur produit l’une ou l’autre.
|
||||
|
||||
## Ressources {#resources}
|
||||
|
||||
Les verbes des ressources vont par paires : deux façons de lister, une façon de lire.
|
||||
|
||||
```python title="client.py" hl_lines="22-31"
|
||||
--8<-- "docs_src/client/tutorial004.py"
|
||||
```
|
||||
|
||||
* `list_resources()` renvoie les ressources **concrètes**, celles qui ont un URI fixe. Ici : `['catalog://genres']`.
|
||||
* `list_resource_templates()` renvoie les ressources **paramétrées**. Ici : `['catalog://genres/{genre}']`. Ce sont deux listes distinctes parce qu’un modèle n’est pas lisible tant que vous ne l’avez pas rempli.
|
||||
* `read_resource(uri)` prend un URI sous forme de simple `str` et fonctionne sur les deux : passez `"catalog://genres/poetry"` et le serveur le fait correspondre au modèle.
|
||||
|
||||
`read_resource` renvoie `contents`, une liste de `TextResourceContents` ou de `BlobResourceContents`. Même idée que pour le contenu des outils : restreignez le type avec `isinstance`, puis lisez `.text` (ou `.blob`).
|
||||
|
||||
Un client peut aussi être prévenu quand une ressource change. Sur les connexions de génération 2025, c’est `subscribe_resource(uri)` / `unsubscribe_resource(uri)` — une paire de méthodes que `MCPServer` n’implémente pas, si bien que sur la liaison en version 2026-07-28 (où ces verbes n’existent plus) la requête reçoit en réponse `-32601`, *Method not found*. Le remplaçant en version 2026 est un flux `subscriptions/listen`, que `MCPServer` sert *bel et bien* — `server_capabilities.resources.subscribe` y vaut `True` — et sa consommation avec `client.listen(...)` fait l’objet de la page **[Abonnements](subscriptions.md)** de cette section.
|
||||
|
||||
## Prompts {#prompts}
|
||||
|
||||
```python title="client.py" hl_lines="15-20"
|
||||
--8<-- "docs_src/client/tutorial005.py"
|
||||
```
|
||||
|
||||
`list_prompts()` vous dit ce que le serveur propose et ce dont chaque prompt a besoin :
|
||||
|
||||
```python
|
||||
prompt.name # 'recommend'
|
||||
prompt.title # 'Recommend a book'
|
||||
prompt.arguments # [PromptArgument(name='genre', required=True)]
|
||||
```
|
||||
|
||||
`get_prompt(name, arguments)` le rend. Le dictionnaire d’arguments est `str -> str` : les arguments de prompt sont toujours des chaînes. Le résultat est `messages`, une liste de `PromptMessage`, chacun avec un `role` et un bloc `content` :
|
||||
|
||||
```python
|
||||
message.role # 'user'
|
||||
message.content # TextContent(type='text', text='Recommend one poetry book from the catalog and say why.')
|
||||
```
|
||||
|
||||
Un hôte transmet ces messages tels quels au modèle. C’est toute la fonctionnalité.
|
||||
|
||||
## Complétions {#completions}
|
||||
|
||||
Un serveur doté d’un gestionnaire (handler) de complétion peut compléter automatiquement les arguments des prompts et des modèles de ressource au fil de la saisie de l’utilisateur.
|
||||
|
||||
```python title="client.py" hl_lines="27-31"
|
||||
--8<-- "docs_src/client/tutorial006.py"
|
||||
```
|
||||
|
||||
* `ref` indique *quel* prompt ou modèle vous remplissez : un `PromptReference` ou un `ResourceTemplateReference`.
|
||||
* `argument` vaut `{"name": ..., "value": ...}` : l’argument et ce que l’utilisateur a saisi jusqu’ici.
|
||||
|
||||
La réponse se trouve dans `result.completion.values`. Tapez `"p"` et le serveur revient avec `['poetry']`. Le côté serveur, et la façon dont un gestionnaire utilise les *autres* arguments déjà remplis pour affiner ses suggestions, c’est la page **[Complétions](../servers/completions.md)**.
|
||||
|
||||
## Pagination {#pagination}
|
||||
|
||||
Chaque méthode `list_*` accepte un argument nommé `cursor=` et chaque résultat porte un `next_cursor`. Quand `next_cursor` vaut `None`, vous avez tout.
|
||||
|
||||
```python title="client.py" hl_lines="22-30"
|
||||
--8<-- "docs_src/client/tutorial007.py"
|
||||
```
|
||||
|
||||
Cette boucle est correcte face à n’importe quel serveur. `MCPServer` renvoie tout en une seule page, donc `next_cursor` vaut `None` et la boucle s’exécute une fois, ce qui explique que la plupart du code ne l’écrive jamais. Les serveurs qui paginent réellement, et les règles auxquelles obéissent les curseurs, sont dans **[Pagination](../advanced/pagination.md)**.
|
||||
|
||||
## Dans les tests {#in-tests}
|
||||
|
||||
`Client(mcp)`, sans processus ni port, est déjà un banc de test pour votre serveur.
|
||||
|
||||
Il existe un drapeau du constructeur conçu pour cela : `Client(mcp, raise_exceptions=True)`. Il n’a d’effet que sur les connexions en mémoire, et **[Tests](../get-started/testing.md)** est la page qui l’explique et construit tout le modèle autour de lui.
|
||||
|
||||
## Récapitulatif {#recap}
|
||||
|
||||
* `Client(x)` se connecte en mémoire à un objet serveur, en Streamable HTTP à une chaîne d’URL, et à tout le reste via un transport.
|
||||
* `async with` est tout le cycle de vie. À l’intérieur, `server_capabilities` et `protocol_version` sont déjà renseignés ; `server_info` et `instructions` le sont aussi lorsque le serveur les fournit.
|
||||
* `list_tools()` vous donne le `name`, le `title`, la `description` et le `input_schema` de chaque outil.
|
||||
* `call_tool()` renvoie `content` pour le modèle, `structured_content` pour votre code, et `is_error`. Un outil qui lève une exception est un résultat, pas une exception.
|
||||
* `content` est une union de types de blocs ; restreignez le type avec `isinstance` avant de lire.
|
||||
* `list_resources` / `list_resource_templates` / `read_resource`, `list_prompts` / `get_prompt` et `complete` complètent la liste des verbes.
|
||||
* Chaque `list_*` accepte `cursor=` ; bouclez jusqu’à ce que `next_cursor` vaille `None`.
|
||||
|
||||
Ce qu’un serveur peut demander au *client*, et la façon d’y répondre, c’est **[Fonctions de rappel du client](callbacks.md)**.
|
||||
152
i18n/fr/pages/client/oauth-clients.md
Normal file
152
i18n/fr/pages/client/oauth-clients.md
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
---
|
||||
translation:
|
||||
sections: [c6899d3892bd9fa0, 79372cff3cc48a88, 63878d29e87c3e73, 13175843d3588af4, e7e2b9fd516f77de, 758f06399b513c1f, a05d7278487d610b]
|
||||
tool: 1
|
||||
---
|
||||
# Clients OAuth {#oauth-clients}
|
||||
|
||||
Certains serveurs MCP sont protégés. Envoyez-leur une requête sans jeton et ils répondent `401 Unauthorized`.
|
||||
|
||||
**`OAuthClientProvider`** est le moyen d’obtenir ce jeton. Ce n’est pas du tout un objet MCP. C’est un `httpx2.Auth`, le hook standard de httpx2 pour « faire quelque chose à chaque requête ». Vous l’attachez à un `httpx2.AsyncClient`, vous confiez ce client au transport Streamable HTTP, et vous n’y pensez plus.
|
||||
|
||||
Cette page couvre le côté client. Pour que votre propre serveur exige un jeton, voyez **[Autorisation](../run/authorization.md)**.
|
||||
|
||||
## Le fournisseur {#the-provider}
|
||||
|
||||
```python title="client.py" hl_lines="44-54"
|
||||
--8<-- "docs_src/oauth_clients/tutorial001.py"
|
||||
```
|
||||
|
||||
Vous lui donnez quatre choses :
|
||||
|
||||
* `server_url` : le point de terminaison MCP auquel vous vous connectez. Le fournisseur découvre tout le reste à partir de lui.
|
||||
* `client_metadata` : ce que vous saisiriez dans le formulaire « enregistrer une application » d’un serveur d’autorisation.
|
||||
* `storage` : là où les jetons vivent entre deux exécutions.
|
||||
* `redirect_handler` et `callback_handler` : les deux moments où un humain intervient.
|
||||
|
||||
Rien d’autre dans le fichier ne mentionne OAuth. `main()` ne voit jamais un jeton.
|
||||
|
||||
### Métadonnées du client {#client-metadata}
|
||||
|
||||
`OAuthClientMetadata` est le véritable document d’enregistrement de la [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591), sous forme de modèle Pydantic.
|
||||
|
||||
Vous définissez trois champs. Les valeurs par défaut remplissent le reste : `grant_types` vaut déjà `["authorization_code", "refresh_token"]` et `response_types` vaut déjà `["code"]`, ce qui correspond exactement au flux qu’exécute ce fournisseur.
|
||||
|
||||
!!! check
|
||||
Comme c’est un modèle Pydantic, il valide **avant qu’un seul octet ne parte sur le réseau**.
|
||||
Omettez `redirect_uris` et la construction échoue immédiatement avec une `ValidationError` qui
|
||||
nomme le champ :
|
||||
|
||||
```text
|
||||
redirect_uris
|
||||
Field required [type=missing, input_value={'client_name': 'Bookshop Agent'}, input_type=dict]
|
||||
```
|
||||
|
||||
Aucun navigateur ouvert, aucun enregistrement à moitié terminé laissé derrière sur le serveur d’autorisation.
|
||||
|
||||
### Stockage des jetons {#token-storage}
|
||||
|
||||
**`TokenStorage`** est un `Protocol` avec quatre méthodes asynchrones. Vous n’héritez de rien ; écrivez les méthodes et n’importe quelle classe devient un magasin de jetons :
|
||||
|
||||
* `get_tokens` / `set_tokens` conservent l’objet `OAuthToken` : jeton d’accès, jeton d’actualisation, expiration, portée.
|
||||
* `get_client_info` / `set_client_info` conservent l’objet `OAuthClientInformationFull` que le serveur d’autorisation a émis lorsque le fournisseur vous a enregistré, y compris votre `client_id`.
|
||||
|
||||
La version en mémoire ci-dessus fonctionne. Elle oublie aussi tout quand le processus se termine, si bien que l’exécution suivante refait toute la procédure. Persistez-la dans un fichier ou dans le trousseau de votre plateforme et l’exécution suivante est silencieuse.
|
||||
|
||||
!!! tip
|
||||
Stockez `client_info`, pas seulement les jetons. Le fournisseur s’enregistre dynamiquement la première fois qu’il
|
||||
ne trouve aucun `client_info` stocké. Jetez-le et vous créez un nouvel enregistrement à chaque exécution.
|
||||
|
||||
### Les deux gestionnaires {#the-two-handlers}
|
||||
|
||||
Le flux du code d’autorisation a besoin d’un humain exactement une fois : quelqu’un doit se connecter et cliquer sur « autoriser ».
|
||||
|
||||
* **`redirect_handler`** est attendu (await) avec l’URL d’autorisation entièrement construite. Le `client_id`, le `redirect_uri`, le `state` et le défi PKCE y figurent déjà. Votre seul travail est d’y amener un navigateur. Une application de bureau appelle `webbrowser.open` ; ce fichier l’affiche.
|
||||
* **`callback_handler`** est attendu ensuite. Il patiente jusqu’à ce que l’utilisateur revienne sur votre `redirect_uri` et renvoie les paramètres de requête de cette redirection sous la forme d’un `AuthorizationCodeResult`.
|
||||
|
||||
Un vrai client fait tourner un petit serveur HTTP local sur l’URI de redirection au lieu d’appeler `input()`. La forme est identique : recevoir la redirection, rendre `code`, `state` et `iss`.
|
||||
|
||||
!!! warning
|
||||
Transmettez `state` et `iss` exactement tels qu’ils sont arrivés. Le fournisseur compare `state` à celui
|
||||
qu’il a généré et `iss` à l’émetteur qu’il a découvert, et refuse toute divergence. Ce sont les défenses
|
||||
contre le CSRF et contre la confusion de serveurs (mix-up).
|
||||
|
||||
### Dans le `Client` {#into-the-client}
|
||||
|
||||
Regardez `main()`. Le fournisseur va sur le **client httpx2**, le client httpx2 va dans `streamable_http_client(url, http_client=...)`, et ce transport va dans `Client`.
|
||||
|
||||
`streamable_http_client` n’a pas de mot-clé `auth=`. Tout ce qui relève du niveau HTTP (authentification, en-têtes, délais d’expiration, proxys) appartient au `httpx2.AsyncClient` que vous apportez. Cette superposition de couches est décrite dans **[Transports client](transports.md)**.
|
||||
|
||||
## Ce que le fournisseur fait pour vous {#what-the-provider-does-for-you}
|
||||
|
||||
La première fois que `Client` envoie une requête, le serveur répond `401`. Le fournisseur prend le relais :
|
||||
|
||||
1. **Découverte.** Il lit l’en-tête `WWW-Authenticate`, récupère les Protected Resource Metadata du serveur depuis `/.well-known/oauth-protected-resource`, apprend quel serveur d’autorisation protège cette ressource, et récupère les métadonnées de *ce* serveur-là.
|
||||
2. **Enregistrement.** Rien dans le stockage ? Il vous enregistre dynamiquement avec votre `OAuthClientMetadata` et stocke le résultat.
|
||||
3. **Autorisation.** Il génère la paire PKCE et un `state`, construit l’URL d’autorisation, attend votre `redirect_handler`, puis attend votre `callback_handler` pour obtenir le code.
|
||||
4. **Échange.** Il échange le code contre un `OAuthToken`, le stocke, et rejoue votre requête d’origine avec `Authorization: Bearer ...`.
|
||||
|
||||
Après cela, il se fait discret. Les jetons sortent du stockage, un jeton d’accès expiré est actualisé avec le jeton d’actualisation, et ce n’est que lorsque rien de tout cela ne fonctionne qu’il relance le flux.
|
||||
|
||||
Vous n’avez rien écrit de tout cela. Il reste deux arguments nommés (`client_metadata_url` et `validate_resource_url`), et ce fichier n’a besoin d’aucun des deux. `client_metadata_url` est celui qui mérite d’être connu ; il a sa propre section plus bas.
|
||||
|
||||
### Essayer {#try-it}
|
||||
|
||||
La plupart des exemples de cette documentation se vérifient avec un `Client(server)` en mémoire. Pas celui-ci : tout l’intérêt du flux est un `401` HTTP, et il n’y a pas de HTTP entre un client en mémoire et son serveur.
|
||||
|
||||
Le dépôt fournit la version réelle. `examples/servers/simple-auth/` exécute un serveur d’autorisation autonome et un serveur MCP protégé ; `examples/clients/simple-auth-client/` est le client de cette page devenu une petite CLI. Son README donne les deux commandes : démarrez les serveurs, lancez le client contre eux, et vous voyez défiler les quatre étapes.
|
||||
|
||||
## Client ID Metadata Documents {#client-id-metadata-documents}
|
||||
|
||||
La révision 2026-07-28 de la spécification rend obsolète l’enregistrement dynamique des clients au profit des **Client ID Metadata Documents** (CIMD). Au lieu d’envoyer par POST un nouvel enregistrement à chaque serveur d’autorisation qu’il rencontre, votre client publie un unique document JSON le décrivant à une URL HTTPS stable, et cette URL *est* son `client_id`. Le serveur d’autorisation récupère le document ; le fournisseur n’y touche jamais.
|
||||
|
||||
Le SDK le parle déjà : passez l’URL dans `client_metadata_url=` quand vous construisez le fournisseur. Lorsque les métadonnées du serveur d’autorisation annoncent `client_id_metadata_document_supported: true`, le fournisseur saute entièrement la requête `/register` : l’URL entre dans le flux en tant que `client_id`, et il n’y a pas de `client_secret`. Lorsque le serveur ne l’annonce pas (la plupart ne le font pas encore), ou que vous ne passez jamais d’URL, le fournisseur se rabat **silencieusement** sur l’enregistrement dynamique, et tout ce qui précède fonctionne exactement comme décrit. Un `client_info` stocké l’emporte toujours sur les deux.
|
||||
|
||||
L’URL doit être en HTTPS avec un chemin autre que la racine ; tout le reste lève une `ValueError` à la construction, avant le moindre échange réseau. L’exemple fourni `examples/clients/simple-auth-client/` la reçoit via la variable d’environnement `MCP_CLIENT_METADATA_URL`.
|
||||
|
||||
## De machine à machine {#machine-to-machine}
|
||||
|
||||
Une tâche nocturne, une étape de CI, un autre service. Il n’y a pas de navigateur et personne pour cliquer sur « autoriser ». C’est le type d’octroi **client credentials** : vous détenez déjà un `client_id` et un `client_secret`, et le point de terminaison de jeton constitue tout le flux.
|
||||
|
||||
`ClientCredentialsOAuthProvider` est le même `httpx2.Auth`, l’humain en moins :
|
||||
|
||||
```python title="client.py" hl_lines="4 27-33"
|
||||
--8<-- "docs_src/oauth_clients/tutorial002.py"
|
||||
```
|
||||
|
||||
Ce qui a changé :
|
||||
|
||||
* Aucun `OAuthClientMetadata`, aucun gestionnaire. Vous passez `client_id` et `client_secret` ; le fournisseur construit autour d’eux un enregistrement `client_credentials` minimal et saute entièrement l’enregistrement dynamique.
|
||||
* `scope` est une chaîne séparée par des espaces, le format qu’OAuth utilise sur la liaison.
|
||||
* Tout ce qui se trouve en aval est identique : le même `TokenStorage`, le même `httpx2.AsyncClient(auth=...)`, le même `streamable_http_client`.
|
||||
|
||||
Par défaut, le secret voyage en authentification HTTP Basic sur la requête de jeton (`client_secret_basic`). Passez `token_endpoint_auth_method="client_secret_post"` pour le placer plutôt dans le corps du formulaire. Certains serveurs d’autorisation n’acceptent que l’une des deux méthodes.
|
||||
|
||||
!!! tip
|
||||
Lisez `client_secret` depuis l’environnement ou un gestionnaire de secrets, jamais depuis le contrôle de version.
|
||||
|
||||
!!! info
|
||||
Un fournisseur de plus se trouve dans `mcp.client.auth.extensions.client_credentials` :
|
||||
**`PrivateKeyJWTOAuthProvider`**, pour les clients qui s’authentifient avec un JWT plutôt qu’avec un
|
||||
secret partagé (`private_key_jwt`, la variante à paire de clés et identité de charge de travail). Il suit
|
||||
le même schéma : construisez-en un, placez-le sur `auth=`. Le même module fournit
|
||||
`SignedJWTParameters` et `static_assertion_provider`, deux utilitaires qui construisent son assertion.
|
||||
|
||||
Il existe une autre situation sans humain : le client appartient à une entreprise dont le fournisseur d’identité, et non l’utilisateur, décide quels serveurs MCP il peut atteindre. C’est un type d’octroi différent, avec son propre modèle de confiance et sa propre page, **[Assertion d’identité](identity-assertion.md)**.
|
||||
|
||||
## En cas d’échec {#when-it-fails}
|
||||
|
||||
Quand le flux OAuth tourne mal, le fournisseur lève une `OAuthFlowError` depuis `mcp.client.auth`. Elle a deux sous-classes. `OAuthRegistrationError` signifie que l’enregistrement n’a pas produit un client utilisable : le serveur d’autorisation a refusé de vous enregistrer, ou il vous a bien enregistré mais avec des identifiants que ce flux ne peut pas utiliser (par exemple une méthode d’authentification qu’il n’implémente pas). `OAuthTokenError` signifie qu’un jeton n’a pas pu être obtenu : le point de terminaison de jeton a dit non, ou une fiche client stockée porte une méthode d’authentification que ce client ne peut pas appliquer, ce qui est signalé pendant la construction de la requête de jeton plutôt qu’envoyé. Un seul `except OAuthFlowError:` couvre la découverte, l’enregistrement, l’autorisation et l’échange.
|
||||
|
||||
Tout n’est pas une erreur de flux. Le réseau peut toujours échouer ; ce sont des exceptions `httpx2` ordinaires et elles passent sans être modifiées.
|
||||
|
||||
## Récapitulatif {#recap}
|
||||
|
||||
* `OAuthClientProvider` est un `httpx2.Auth`. Placez-le sur un `httpx2.AsyncClient`, passez celui-ci à `streamable_http_client(url, http_client=...)`, et `Client` ne sait jamais qu’OAuth a eu lieu.
|
||||
* Vous fournissez quatre choses : l’URL du serveur, un `OAuthClientMetadata`, un `TokenStorage` et la paire de gestionnaires redirect/callback.
|
||||
* `TokenStorage` est un `Protocol` : quatre méthodes asynchrones, pas de classe de base. Persistez `client_info` en plus des jetons.
|
||||
* La découverte, l’enregistrement (dynamique, ou via un **Client ID Metadata Document**), PKCE, les vérifications de `state` et `iss`, et l’actualisation des jetons sont l’affaire du fournisseur, pas la vôtre.
|
||||
* `ClientCredentialsOAuthProvider` est la version sans humain : `client_id` + `client_secret`, pas de gestionnaires, pas de navigateur.
|
||||
* Tout échec OAuth est une `OAuthFlowError` ; `OAuthRegistrationError` et `OAuthTokenError` en sont les sous-classes.
|
||||
|
||||
L’autre moitié de cette poignée de main, faire en sorte que votre *serveur* exige le jeton, se trouve dans **[Autorisation](../run/authorization.md)**.
|
||||
87
i18n/fr/pages/client/session-groups.md
Normal file
87
i18n/fr/pages/client/session-groups.md
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
---
|
||||
translation:
|
||||
sections: [09c857a25a9dc37a, 43bc6a76a243a50e, 0a716022a88768df, 4b7f78042bfcfff7, c112662e61b03315, 58974ba1f489a8b4, d18adbdbb835ea73]
|
||||
tool: 1
|
||||
---
|
||||
# Groupes de sessions {#session-groups}
|
||||
|
||||
Un `Client` se connecte à un seul serveur. Les applications réelles en veulent souvent plusieurs (un serveur de recherche, un serveur de base de données, une API interne) et finissent par jongler avec une connexion et une liste d’outils pour chacun.
|
||||
|
||||
**`ClientSessionGroup`** est un objet unique qui détient de nombreuses connexions et fusionne tout ce qu’elles exposent en une seule vue.
|
||||
|
||||
## Deux serveurs {#two-servers}
|
||||
|
||||
Commencez par deux serveurs ordinaires. Ils n’ont rien à voir l’un avec l’autre, si bien que tous deux ont naturellement appelé leur outil `search` :
|
||||
|
||||
```python title="library_server.py" hl_lines="7"
|
||||
--8<-- "docs_src/session_groups/tutorial001.py"
|
||||
```
|
||||
|
||||
```python title="web_server.py" hl_lines="7"
|
||||
--8<-- "docs_src/session_groups/tutorial002.py"
|
||||
```
|
||||
|
||||
## Un groupe {#one-group}
|
||||
|
||||
Créez un `ClientSessionGroup` et appelez **`connect_to_server`** une fois par serveur :
|
||||
|
||||
```python title="client.py" hl_lines="10-12"
|
||||
--8<-- "docs_src/session_groups/tutorial003.py"
|
||||
```
|
||||
|
||||
* `connect_to_server` prend des paramètres de transport, pas un objet serveur : `StdioServerParameters` (depuis `mcp`) pour lancer un sous-processus, ou `StreamableHttpParameters` / `SseServerParameters` (depuis `mcp.client.session_group`) pour un serveur qui écoute déjà sur une URL.
|
||||
* `group.tools` est un `dict[str, Tool]` regroupant les outils de tous les serveurs connectés. `group.resources` et `group.prompts` ont la même forme.
|
||||
* `group.call_tool(name, arguments)` recherche le nom, trouve la session qui le possède et lui transmet l’appel. Vous n’indiquez jamais quel serveur.
|
||||
|
||||
!!! check
|
||||
Placez `client.py` à côté des deux serveurs et exécutez-le. Le second `connect_to_server` refuse :
|
||||
|
||||
```text
|
||||
mcp.shared.exceptions.MCPError: {'search'} already exist in group tools.
|
||||
```
|
||||
|
||||
C’est une `MCPError`, levée avant que quoi que ce soit du second serveur ne soit enregistré. Un nom doit
|
||||
être unique dans **tout** le groupe, et deux serveurs que vous ne contrôlez pas finiront tôt ou tard par entrer en collision.
|
||||
|
||||
## `component_name_hook` {#component_name_hook}
|
||||
|
||||
Vous corrigez cela au niveau du groupe, pas des serveurs. Passez une fonction de `(name, server_info)` et le groupe l’exécute sur chaque nom qu’il enregistre :
|
||||
|
||||
```python title="client.py" hl_lines="7-8 15"
|
||||
--8<-- "docs_src/session_groups/tutorial004.py"
|
||||
```
|
||||
|
||||
Relancez-le. `print(sorted(group.tools))` affiche maintenant les deux :
|
||||
|
||||
```text
|
||||
['Library.search', 'Web.search']
|
||||
```
|
||||
|
||||
* La **clé** est à vous. `by_server` l’a construite à partir de `server_info.name`, le nom avec lequel chaque `MCPServer(...)` a été construit.
|
||||
* Le `Tool` à l’intérieur est intact : `group.tools["Web.search"].name` vaut toujours `"search"`, et c’est ce nom que `call_tool` envoie sur la liaison. Le préfixe ne quitte jamais votre processus.
|
||||
* Cela ne concerne pas que les outils. La ressource `hours` de la bibliothèque est enregistrée sous le nom `Library.hours`.
|
||||
|
||||
!!! tip
|
||||
Le hook s’exécute sur **chaque** nom de **chaque** serveur, pas seulement en cas de conflit : il n’existe pas de
|
||||
mode « préfixe en cas de collision ». Choisissez un schéma et laissez-le s’appliquer partout.
|
||||
|
||||
## Ajouter et retirer des serveurs {#adding-and-removing-servers}
|
||||
|
||||
`connect_to_server` renvoie la `ClientSession` qu’il a ouverte. Conservez-la si vous voulez un jour vous séparer de ce serveur : `await group.disconnect_from_server(session)` retire ses outils, ressources et prompts du groupe.
|
||||
|
||||
Si vous détenez déjà une `ClientSession` connectée (`Client.session` en est une), passez-la à `await group.connect_with_session(server_info, session)` au lieu d’ouvrir un nouveau transport. Elle est agrégée de la même façon. Le groupe ne ferme jamais une session qu’il n’a pas ouverte. `server_info` nomme le serveur pour les préfixes de composants ; sur une connexion de génération 2026, `client.server_info` peut valoir `None` (l’identité est facultative), passez donc votre propre `Implementation(name=..., version=...)` dans ce cas.
|
||||
|
||||
## La poignée de main classique {#the-classic-handshake}
|
||||
|
||||
`ClientSessionGroup` est construit sur `ClientSession`, pas sur `Client`. Chaque `connect_to_server` exécute la poignée de main (handshake) `initialize` classique. Il n’envoie jamais la sonde `server/discover` décrite dans **[Versions du protocole](../protocol-versions.md)**. Tous les serveurs MCP comprennent cette poignée de main, donc cela ne vous coûte aucune compatibilité ; cela signifie seulement qu’un groupe emprunte le chemin plus ancien et plus lent vers un serveur qui pourrait faire mieux.
|
||||
|
||||
## Récapitulatif {#recap}
|
||||
|
||||
* `ClientSessionGroup` détient de nombreuses connexions serveur et fusionne leurs outils, ressources et prompts en un `dict` chacun.
|
||||
* `connect_to_server(params)` par serveur. Il prend des paramètres de transport, jamais l’objet serveur ni l’URL que prend un `Client`.
|
||||
* `group.call_tool(name, arguments)` achemine l’appel vers le serveur propriétaire à votre place.
|
||||
* Les noms doivent être uniques dans tout le groupe ; deux serveurs dotés d’un outil `search` ne peuvent pas coexister tels quels.
|
||||
* `component_name_hook=` réécrit chaque nom enregistré. La clé du dict change, pas le nom sur la liaison.
|
||||
* `connect_with_session` ajoute une session que vous détenez déjà ; `disconnect_from_server` en retire une.
|
||||
|
||||
La poignée de main que parle un groupe (et celle, plus rapide, que préfère un `Client`) fait l’objet de **[Versions du protocole](../protocol-versions.md)**.
|
||||
91
i18n/fr/pages/client/subscriptions.md
Normal file
91
i18n/fr/pages/client/subscriptions.md
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
---
|
||||
translation:
|
||||
sections: [8f9558e57f29eee1, a88c587739e0465c, 46ebfd5b325ed041, 4d10b00b57ce4bd9, 2cdb0edd1f59b3e2]
|
||||
tool: 1
|
||||
---
|
||||
# Abonnements {#subscriptions}
|
||||
|
||||
Le catalogue d’un serveur n’est pas figé. Des outils apparaissent à l’exécution, et le contenu derrière l’URI d’une ressource change. Un client l’apprend grâce à `client.listen(...)` : une seule requête `subscriptions/listen` dont la réponse *est* le flux. Elle reste ouverte et transporte les notifications de changement que le client a demandées.
|
||||
|
||||
Cette page couvre le côté client : ouvrir le flux, le surveiller en parallèle de votre traitement principal, et gérer la façon dont il se termine. Publier les changements, filtrer et servir la méthode relèvent du serveur ; c’est raconté dans **[Abonnements](../handlers/subscriptions.md)**, sous *Dans votre gestionnaire*. Les exemples de cette page dialoguent avec le serveur de tableau de sprint construit là-bas.
|
||||
|
||||
## Surveiller le flux {#watching-the-stream}
|
||||
|
||||
Un abonnement est un gestionnaire de contexte, un seul. Y entrer envoie la requête, avec vos arguments nommés comme filtre d’abonnement, puis attend la confirmation du serveur : le flux est donc actif au moment où le bloc commence.
|
||||
|
||||
```python title="client.py" hl_lines="15 18 28"
|
||||
--8<-- "docs_src/subscriptions/tutorial003.py"
|
||||
```
|
||||
|
||||
L’itération produit quatre événements typés : `ToolsListChanged`, `PromptsListChanged`, `ResourcesListChanged` et `ResourceUpdated(uri=...)`.
|
||||
|
||||
Un événement dit *ce qui* a changé, jamais *comment*. C’est pourquoi `follow_board` appelle `read_resource` et `list_tools` : l’événement est un signal pour récupérer à nouveau les données. Lisez `event.uri` plutôt que de supposer quelle ressource a bougé : un filtre peut nommer plusieurs URI, et un serveur peut signaler un changement sur une sous-ressource de l’un d’eux.
|
||||
|
||||
Les événements en double qui attendent d’être consommés se fondent en un seul, et une nouvelle récupération vous donne tout de même l’état courant. Seuls les événements identiques se fondent : deux `ResourceUpdated` pour des URI différents sont deux événements.
|
||||
|
||||
Deux autres propriétés de l’objet d’abonnement :
|
||||
|
||||
* `sub.honored` est le filtre que le serveur a confirmé : un `SubscriptionFilter` avec les champs que vous avez passés, lisibles comme attributs (`sub.honored.prompts_list_changed`). `MCPServer` honore tous les types d’événements que vous demandez, il vous renvoie donc votre requête telle quelle. Un serveur qui prend en charge moins de types en confirme moins, et un type confirmé peut malgré tout ne jamais se déclencher. Un serveur peut aussi refuser la requête entière plutôt que de la confirmer (voir [Décider qui peut surveiller](../handlers/subscriptions.md#deciding-who-may-watch) sur la page serveur), ce qui se manifeste comme l’erreur de la requête.
|
||||
* `sub.subscription_id` est l’identifiant de la requête listen, celui qui est apposé sur chaque trame de ce flux. Plusieurs abonnements peuvent être ouverts en même temps, chacun démultiplexé par son propre identifiant.
|
||||
|
||||
## Surveiller sans bloquer {#watching-without-blocking}
|
||||
|
||||
`follow_board` tourne jusqu’à ce que le serveur ferme le flux, ce qui peut ne jamais arriver ; seule, elle monopolise donc votre programme. Les vrais clients veulent l’observateur *à côté* du traitement principal : un agent appelle des outils pendant qu’un observateur maintient à jour un cache ou une interface.
|
||||
|
||||
Ouvrez d’abord l’abonnement, puis démarrez l’observateur et poursuivez votre travail.
|
||||
|
||||
=== "asyncio"
|
||||
|
||||
```python title="app.py" hl_lines="18 20"
|
||||
--8<-- "docs_src/subscriptions/tutorial004_asyncio.py"
|
||||
```
|
||||
|
||||
=== "trio"
|
||||
|
||||
```python title="app.py" hl_lines="18 21"
|
||||
--8<-- "docs_src/subscriptions/tutorial004_trio.py"
|
||||
```
|
||||
|
||||
=== "anyio"
|
||||
|
||||
```python title="app.py" hl_lines="18 21"
|
||||
--8<-- "docs_src/subscriptions/tutorial004_anyio.py"
|
||||
```
|
||||
|
||||
!!! note
|
||||
`app.py` importe `BOARD` et `read_board` depuis le premier exemple, que ce dépôt stocke sous
|
||||
le nom `tutorial003.py`. Si vous enregistrez les fichiers affichés côte à côte sous les noms
|
||||
`client.py` et `app.py`, écrivez plutôt `from client import BOARD, read_board`. L’exemple
|
||||
`watch.py`, plus bas, importe `read_board` de la même façon.
|
||||
|
||||
L’ordre est tout l’enjeu. Rien n’est rejoué : un événement publié avant que votre flux n’existe est perdu. Entrer dans `client.listen(...)` attend la confirmation, si bien que chaque changement à partir de cet instant atteint votre observateur, et l’instantané que vous prenez dans le bloc ne peut en manquer aucun.
|
||||
|
||||
Les requêtes s’exécutent librement à côté d’un flux ouvert, depuis la tâche de l’observateur ou n’importe quelle autre, sur le même client. Comme les événements non consommés *en double* fusionnent, un traitement principal chargé peut produire une seule récupération plutôt que trois. Les événements qui diffèrent ne fusionnent pas : un filtre qui nomme de nombreux URI met en file un événement en attente par URI.
|
||||
|
||||
Pour arrêter de surveiller, sortez du bloc : il n’y a pas d’appel `unsubscribe`. Annuler la tâche qui possède le bloc le fait pour vous, et le SDK annule la requête listen comme le transport l’attend : en Streamable HTTP, en fermant le flux de cette requête. Un observateur qui tourne pendant toute la durée de vie de votre application ne revient jamais de lui-même ; annulez-le donc, ou la portée de son groupe de tâches, à l’arrêt.
|
||||
|
||||
## Les flux se terminent {#streams-end}
|
||||
|
||||
Un flux se termine de l’une de deux façons, toutes deux relevant du flot de contrôle ordinaire. Une fermeture propre côté serveur met fin à la boucle `async for` ; une coupure brutale lève `SubscriptionLost`.
|
||||
|
||||
La différence sert au diagnostic, elle ne change pas ce qu’il faut faire ensuite : le flux a disparu, rien n’a été rejoué, et un observateur toujours intéressé réécoute et récupère à nouveau.
|
||||
|
||||
```python title="watch.py" hl_lines="16 20"
|
||||
--8<-- "docs_src/subscriptions/tutorial005.py"
|
||||
```
|
||||
|
||||
Les serveurs ferment proprement les flux pour des raisons qui leur sont propres, notamment pour se délester d’un abonné dont l’arriéré a trop grossi ; une fin propre n’est donc pas un signal pour cesser de surveiller. Temporisez avant de réécouter.
|
||||
|
||||
`SubscriptionLost` a aussi une cause locale. Le client conserve au plus 1 024 événements non consommés, et un consommateur qui prend autant de retard perd l’abonnement plutôt que de grossir sans limite. Gardez le corps de la boucle `async for` court et faites le travail lent ailleurs.
|
||||
|
||||
`keep_following` n’intercepte que `SubscriptionLost`. Entrer dans `listen()` peut aussi lever `MCPError` (la connexion a échoué, ou le serveur ne sert pas la méthode), `TimeoutError` (aucune confirmation n’est arrivée) et `ListenNotSupportedError` (une connexion antérieure à 2026). Décidez lesquelles votre observateur devrait retenter : la dernière ne se résorbe jamais.
|
||||
|
||||
## Récapitulatif {#recap}
|
||||
|
||||
* Entrez dans `async with client.listen(...)` ; l’entrée attend la confirmation, donc rien de ce qui est publié ensuite n’est manqué.
|
||||
* Itérez avec `async for event in sub`. Les événements sont des signaux pour récupérer à nouveau, jamais des charges utiles.
|
||||
* Ouvrez l’abonnement, puis lancez l’observateur comme tâche, et les appels d’outils continuent de circuler à côté.
|
||||
* Une fin propre arrête la boucle ; une coupure lève `SubscriptionLost`. Dans les deux cas : réécoutez, récupérez à nouveau, en temporisant d’abord.
|
||||
* Sortir du bloc, c’est se désabonner.
|
||||
|
||||
Publier ces événements, restreindre le filtre et passer à l’échelle au-delà d’un seul processus relèvent du serveur : **[Abonnements](../handlers/subscriptions.md)**. Ces mêmes événements maintiennent aussi à jour un cache côté client, et **[Mise en cache](caching.md)** est la page suivante.
|
||||
128
i18n/fr/pages/client/transports.md
Normal file
128
i18n/fr/pages/client/transports.md
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
---
|
||||
translation:
|
||||
sections: [9cac816674181eb0, 0700f337babcd4dd, 2bde0dd58cdf00f5, ff7401df479af877, 3d0832f39b0d7059, d4bf7e4479637768, 05e20c0a798860e7]
|
||||
tool: 1
|
||||
---
|
||||
# Transports côté client {#client-transports}
|
||||
|
||||
Chaque `Client` dialogue avec son serveur via un **transport** : ce qui achemine réellement les messages.
|
||||
|
||||
Vous n’en configurez jamais un séparément. `Client` prend un seul argument positionnel et déduit le transport de son type.
|
||||
|
||||
Le côté *serveur* de chacun (ce que fait `mcp.run()` et ce que vous déployez) est traité dans **[Exécuter votre serveur](../run/index.md)**.
|
||||
|
||||
## En mémoire {#in-memory}
|
||||
|
||||
Passez l’objet serveur lui-même :
|
||||
|
||||
```python title="client.py" hl_lines="14"
|
||||
--8<-- "docs_src/client_transports/tutorial001.py"
|
||||
```
|
||||
|
||||
Pas de sous-processus, pas de port, aucun octet sur une liaison. Le client et le serveur sont deux objets dans le même processus, et l’appel passe tout de même par la véritable couche protocolaire : `search_books` est listé, validé et invoqué exactement comme il le serait via HTTP.
|
||||
|
||||
Cela en fait deux choses à la fois :
|
||||
|
||||
* **Un banc de test.** Chaque exemple de cette documentation est exécuté de cette façon, et la page **[Tests](../get-started/testing.md)** construit tout son modèle autour de lui.
|
||||
* **Une API d’intégration.** Une application qui construit le serveur n’a pas besoin d’un saut réseau pour appeler ses outils.
|
||||
|
||||
## Streamable HTTP {#streamable-http}
|
||||
|
||||
Passez une URL sous forme de chaîne et vous obtenez **Streamable HTTP**, le transport derrière lequel vous déployez :
|
||||
|
||||
```python title="client.py" hl_lines="5"
|
||||
--8<-- "docs_src/client_transports/tutorial002.py"
|
||||
```
|
||||
|
||||
C’est tout le client de production. `Client` enveloppe l’URL dans `streamable_http_client(...)` pour vous, par-dessus un `httpx2.AsyncClient` configuré comme MCP l’exige : `follow_redirects=True`, un délai d’expiration de 30 secondes pour connect/write/pool, et un délai de lecture de 300 secondes parce que le serveur peut garder un flux de réponse ouvert.
|
||||
|
||||
!!! check
|
||||
Un `Client` que vous venez de construire n’est **pas** connecté. La construction ne fait que choisir le transport ;
|
||||
c’est `async with` qui l’ouvre. Tentez d’accéder à la connexion avant d’y entrer et le SDK vous le signale :
|
||||
|
||||
```text
|
||||
RuntimeError: Client must be used within an async context manager
|
||||
```
|
||||
|
||||
Rien n’a été résolu, récupéré ni lancé quand vous avez écrit `Client("http://...")`. Cette ligne ne coûte rien.
|
||||
|
||||
### Fournir votre propre `httpx2.AsyncClient` {#bring-your-own-httpx2asyncclient}
|
||||
|
||||
Dès que vous avez besoin d’un en-tête `Authorization`, d’un cookie, d’un proxy, de mTLS ou d’un délai d’expiration différent, construisez le `httpx2.AsyncClient` vous-même et passez-le à `streamable_http_client` :
|
||||
|
||||
```python title="client.py" hl_lines="8-14"
|
||||
--8<-- "docs_src/client_transports/tutorial003.py"
|
||||
```
|
||||
|
||||
Deux points à remarquer :
|
||||
|
||||
* Le `httpx2.AsyncClient` vous appartient, donc c’est **vous** qui y entrez et en sortez. Le SDK ne ferme jamais un client qu’il n’a pas créé.
|
||||
* `streamable_http_client(url, http_client=...)` renvoie un transport, et `Client(transport)` l’accepte comme n’importe quoi d’autre.
|
||||
|
||||
Une remarque sur TLS : `httpx2` vérifie les certificats par rapport au magasin de confiance du système d’exploitation (via
|
||||
[`truststore`](https://pypi.org/project/truststore/)), et non par rapport à une liste d’autorités de certification embarquée. Dans un environnement sans
|
||||
magasin d’autorités de certification système utilisable (certains conteneurs minimaux), définissez les variables d’environnement standard `SSL_CERT_FILE`/`SSL_CERT_DIR`
|
||||
ou passez un `verify=ssl_context` explicite à votre `httpx2.AsyncClient`
|
||||
(le contexte se trouve dans
|
||||
[`httpx` et `httpx-sse` remplacés par `httpx2`](../migration.md#httpx-and-httpx-sse-replaced-by-httpx2)).
|
||||
|
||||
!!! warning
|
||||
`streamable_http_client` acceptait autrefois `headers=` et `timeout=` directement. Ce n’est plus le cas :
|
||||
ses seuls paramètres sont `url`, `http_client` et `terminate_on_close`. Utilisez `headers=` par
|
||||
habitude et vous obtenez :
|
||||
|
||||
```text
|
||||
TypeError: streamable_http_client() got an unexpected keyword argument 'headers'
|
||||
```
|
||||
|
||||
Tout ce qui relève de HTTP se trouve désormais sur l’unique `httpx2.AsyncClient` que vous passez.
|
||||
|
||||
!!! info
|
||||
`httpx2` conserve l’API familière de `httpx` ; si vous connaissez `httpx`, vous savez déjà comment gérer ici l’authentification,
|
||||
les proxys, les hooks d’événements, les nouvelles tentatives et les limites de connexions. Le SDK n’ajoute rien par-dessus et ne retire
|
||||
rien. C’est aussi là qu’OAuth se branche :
|
||||
`httpx2.AsyncClient(auth=OAuthClientProvider(...))`. Tout ce flux est décrit dans **[Clients OAuth](oauth-clients.md)**.
|
||||
|
||||
## stdio {#stdio}
|
||||
|
||||
Un serveur **stdio** est un sous-processus. Le client le lance, écrit du JSON-RPC sur son stdin et lit du JSON-RPC depuis son stdout. C’est ainsi qu’un hôte de bureau exécute un serveur sur votre machine : un hôte *est* ce code plus une interface utilisateur, et **[Se connecter à un véritable hôte](../get-started/real-host.md)** montre la même relation vue du côté de l’hôte, sous forme de fichier de configuration.
|
||||
|
||||
Décrivez le processus avec `StdioServerParameters`, transformez-le en transport avec `stdio_client`, et passez *cela* à `Client` :
|
||||
|
||||
```python title="client.py" hl_lines="4-8 12"
|
||||
--8<-- "docs_src/client_transports/tutorial004.py"
|
||||
```
|
||||
|
||||
`Client` n’accepte pas l’objet de paramètres seul. `StdioServerParameters` est de la configuration ; `stdio_client(server)` est le transport qui sait lancer un processus à partir de celle-ci. Enveloppez toujours.
|
||||
|
||||
Quitter le bloc `async with` arrête aussi le sous-processus : fermeture de stdin, attente, arrêt forcé s’il traîne. Vous ne le nettoyez jamais vous-même.
|
||||
|
||||
!!! warning
|
||||
Le processus enfant n’hérite **pas** de votre environnement. Il reçoit une liste d’autorisation minimale (`HOME`, `LOGNAME`,
|
||||
`PATH`, `SHELL`, `TERM` et `USER` sous POSIX), de sorte que rien de sensible ne fuite vers un processus que vous n’avez peut-être
|
||||
pas écrit.
|
||||
|
||||
Un serveur qui a besoin d’une clé d’API ne l’y trouvera pas. Passez-la explicitement avec `env=` ; ces
|
||||
variables sont fusionnées par-dessus la liste d’autorisation. C’est ce que fait `BOOKSHOP_API_KEY` ci-dessus.
|
||||
|
||||
## SSE {#sse}
|
||||
|
||||
`sse_client(url)`, du module `mcp.client.sse`, est le transport HTTP que Streamable HTTP a remplacé. Enveloppez-le de la même manière, `Client(sse_client("http://localhost:8000/sse"))`, pour dialoguer avec un serveur qui le parle encore, et ne construisez rien de nouveau dessus.
|
||||
|
||||
## Le protocole `Transport` {#the-transport-protocol}
|
||||
|
||||
Pour `Client`, tout ce qui précède est une seule et même chose.
|
||||
|
||||
Un **transport** est n’importe quel gestionnaire de contexte asynchrone qui produit une paire `(read, write)` de flux de messages : formellement, le protocole `Transport` de `mcp.client`. `Client` résout son argument selon son type : un objet serveur se connecte dans le processus, une `str` devient `streamable_http_client(url)`, et tout le reste est ouvert directement comme transport. C’est cette dernière règle qui explique pourquoi `stdio_client(...)`, `streamable_http_client(...)` et `sse_client(...)` s’insèrent tous au même emplacement, et pourquoi vous pouvez écrire le vôtre.
|
||||
|
||||
## Récapitulatif {#recap}
|
||||
|
||||
* `Client(mcp)` (l’objet serveur) se connecte en mémoire. Utilisez-le pour les tests et pour l’intégration.
|
||||
* `Client("http://.../mcp")` (une URL) se connecte via Streamable HTTP, le transport de production.
|
||||
* Les en-têtes, l’authentification, les proxys et les délais d’expiration vont sur un `httpx2.AsyncClient` que vous passez à `streamable_http_client(url, http_client=...)`. Il n’y a pas de mot-clé `headers=`.
|
||||
* stdio s’écrit `Client(stdio_client(StdioServerParameters(...)))`, jamais l’objet de paramètres seul.
|
||||
* Le sous-processus reçoit un environnement sous liste d’autorisation, pas le vôtre ; `env=` s’y ajoute.
|
||||
* Un transport est tout ce sur quoi vous pouvez faire `async with x as (read, write)`. `Client` transmet directement à ce protocole tout ce qui n’est ni un objet serveur ni une URL.
|
||||
* Construire un `Client` choisit le transport. `async with` l’ouvre.
|
||||
|
||||
Une fois le transport ouvert, les deux côtés doivent s’accorder sur une version du protocole. En temps normal, vous n’y pensez jamais ; le jour où vous devez y penser, la page à consulter est **[Versions du protocole](../protocol-versions.md)**.
|
||||
Loading…
Reference in a new issue