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
123
i18n/de/pages/client/caching.md
Normal file
123
i18n/de/pages/client/caching.md
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
---
|
||||
translation:
|
||||
sections: [9e7b9a1710e5aeba, b74ca4c1d2ddddee, fa8714e61bf90c5a, 04db67a886b7271c, 857690fb8f876800]
|
||||
tool: 1
|
||||
---
|
||||
# Caching-Hinweise {#caching-hints}
|
||||
|
||||
Jedes Ergebnis, das ein Server für `tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read` und `server/discover` zurückgibt, trägt im Protokoll 2026-07-28 zwei Felder: `ttlMs`, wie viele Millisekunden ein Client das Ergebnis als frisch behandeln darf, und `cacheScope`, ob ein gecachtes Ergebnis personenübergreifend geteilt werden darf (`"public"`) oder zu genau einem Autorisierungskontext gehört (`"private"`).
|
||||
|
||||
Der Server cacht selbst nichts. Die Felder sind eine *Erklärung*: „Diese Tool-Liste ist für alle gleich und ändert sich eine Minute lang nicht.“ Ein Client (oder ein Gateway vor deinem Server) kann sich dann den Roundtrip sparen. Ob er die Hinweise beachtet, entscheidet der Client; sie auszugeben ist Aufgabe des Servers, und das übernimmt das SDK für dich.
|
||||
|
||||
Ohne weitere Konfiguration sagt jedes Ergebnis `ttlMs: 0, cacheScope: "private"`: sofort abgelaufen, nie geteilt. Das ist immer sicher und immer protokollkonform. Wenn deine Listen tatsächlich stabil und für alle Aufrufer identisch sind, gib das bei der Konstruktion an:
|
||||
|
||||
```python title="server.py" hl_lines="5-8"
|
||||
--8<-- "docs_src/caching/tutorial001.py"
|
||||
```
|
||||
|
||||
* Die Map ist nach **Methodennamen** geschlüsselt, und die sechs cachefähigen Methoden sind die einzigen zulässigen Schlüssel. Der Parameter ist als `Mapping[CacheableMethod, CacheHint]` typisiert, sodass dein Editor die Schlüssel automatisch vervollständigt und einen Tippfehler markiert, bevor du den Code ausführst; was am Typprüfer vorbeirutscht, löst bei der Konstruktion eine Exception aus.
|
||||
* Eine Methode, die du nicht erwähnst, behält die Standardwerte. Die Map ist eine Sammlung von Überschreibungen, kein Manifest.
|
||||
* `CacheHint(ttl_ms=5_000)` lässt `scope` ungesetzt, also bleibt es `"private"`: fünf Sekunden Frische, pro Aufrufer. Scope und TTL sind unabhängige Entscheidungen.
|
||||
* `"server/discover"` ist ebenfalls ein zulässiger Schlüssel, denn das Discovery-Ergebnis ist cachefähig wie jede Liste.
|
||||
|
||||
!!! warning
|
||||
`cacheScope: "public"` heißt: Deine gecachte Response darf an *alle* ausgeliefert werden. Ein
|
||||
gemeinsam genutztes Gateway reicht das Ergebnis einer Person ohne Zögern an eine andere weiter,
|
||||
selbst wenn der Request authentifiziert war. Markiere ein Ergebnis nur dann als `"public"`, wenn
|
||||
es für alle Aufrufer identisch ist, und verwende `cacheScope` nie als Zugriffskontrolle: Es ist
|
||||
ein Etikett, kein Schloss.
|
||||
|
||||
## Überschreiben pro Handler {#per-handler-override}
|
||||
|
||||
Auf dem Low-Level-`Server` bauen Handler ihre Ergebnisse von Hand, und `ttl_ms` / `cache_scope` sind einfach Felder der Ergebnismodelle. Ein Handler, der sie explizit setzt, gewinnt immer gegen die Map aus dem Konstruktor, Feld für Feld:
|
||||
|
||||
```python title="server.py" hl_lines="10 16"
|
||||
--8<-- "docs_src/caching/tutorial002.py"
|
||||
```
|
||||
|
||||
Der Handler hat `ttl_ms=1_000` gesetzt und nichts zum Scope gesagt. Auf der Leitung: `ttlMs: 1000` (vom Handler, nicht die `60_000` der Map) und `cacheScope: "public"` (aus der Map, weil der Handler es ungesetzt ließ). Explizit schlägt konfiguriert, und konfiguriert schlägt Standardwert. Das gilt pro Feld, ein Handler kann also ein Feld festlegen und das andere der serverweiten Richtlinie überlassen.
|
||||
|
||||
Das ist auch der Notausgang für Dynamik, die der Konstruktor nicht kennen kann: Ein Handler, der `resources/read` pro Person filtert, kann auf einem ansonsten öffentlichen Server für einen URI `cache_scope="private"` zurückgeben.
|
||||
|
||||
Ein Vorbehalt bei paginierten Listen: Das Protokoll verlangt **denselben `cacheScope` auf jeder Seite** einer Liste. Die Map aus dem Konstruktor erfüllt das von selbst, weil sie nach Methode geschlüsselt ist, nicht nach Seite. Ein Handler, der den Scope selbst überschreibt, ist aber auch selbst für diese Konsistenz verantwortlich: Überschreibe ihn auf *jeder* Seite, nie nur dann, wenn ein Cursor vorhanden ist, sonst widersprechen sich Seite eins und Seite zwei.
|
||||
|
||||
## Was der Client sieht {#what-the-client-sees}
|
||||
|
||||
In einer 2026-07-28-Session beachtet `Client` die Hinweise für dich: Er hat einen eingebauten Response-Cache, der standardmäßig aktiv ist. Ein Ergebnis, das mit einem `ttlMs` ankommt, wird gespeichert, und ein identischer Aufruf innerhalb dieser TTL wird ohne Roundtrip aus dem Cache bedient. Ein Ergebnis, das *keinen* Hinweis trägt, wird nicht gecacht: Ergebnisse ohne Hinweis bekommen `CacheConfig.default_ttl_ms`, dessen Standardwert `0` ist (sofort abgelaufen), sodass ein Server, der nichts deklariert, Aufruf für Aufruf genau denselben Verkehr sieht wie schon immer.
|
||||
|
||||
```python title="client.py" hl_lines="33 35 38"
|
||||
--8<-- "docs_src/caching/tutorial003.py"
|
||||
```
|
||||
|
||||
Vier Aufrufe, drei Abrufe. Der zweite Aufruf fand einen frischen Eintrag und erreichte den Server nie; die (injizierte) Uhr über die TTL hinaus vorzustellen ließ den dritten wieder abrufen; der vierte gab `cache_mode="refresh"` an. Dieses Keyword-Argument gibt es auf den fünf cachenden Verben (`list_tools`, `list_prompts`, `list_resources`, `list_resource_templates`, `read_resource`):
|
||||
|
||||
* `"use"` (der Standardwert) liefert einen frischen Eintrag, wenn es einen gibt, und speichert andernfalls den Abruf.
|
||||
* `"refresh"` liefert nie aus dem Cache: Es ruft ab und speichert das Ergebnis, wobei es ersetzt, was auch immer gecacht war.
|
||||
* `"bypass"` macht den Roundtrip, ohne den Cache überhaupt anzufassen: kein Lesen, kein Schreiben.
|
||||
|
||||
Eine Regel steht über `"use"`: **Aufrufe mit `meta` erreichen immer den Server.** Ein Request mit gesetztem `meta` (ein Progress-Token, Tracing-Felder) erwartet einen Request auf der Leitung, deshalb wird er unter `cache_mode="use"` wie `"refresh"` behandelt: Das Lesen aus dem Cache entfällt, und das abgerufene Ergebnis ersetzt trotzdem den gecachten Eintrag. `"bypass"` und ein explizites `"refresh"` verhalten sich wie immer.
|
||||
|
||||
Um das Caching ganz abzuschalten, konstruiere mit `Client(server, cache=None)`: Jeder Aufruf ist wieder ein Roundtrip, und `cache_mode` wird zwar weiter akzeptiert, bewirkt aber nichts.
|
||||
|
||||
Auch der Scope wird automatisch beachtet: `"private"`-Einträge sind an die *Partition* des Caches gebunden (siehe unten), während `"public"`-Einträge sich für breiteres Teilen entscheiden können. Und **Benachrichtigungen schlagen die TTL** für genau die Einträge, die sie benennen: Eine `list_changed`-Benachrichtigung verdrängt die passende gecachte Liste, und `resources/updated` verdrängt den gecachten Lesevorgang, der unter exakt ihrem URI gespeichert ist – egal, wie frisch sie waren. Auf einer 2026-07-28-Verbindung kommen diese Benachrichtigungen auf einem `subscriptions/listen`-Stream an, den du mit `client.listen(...)` öffnest, und die Verdrängung ist abgeschlossen, bevor dein Watcher das Ereignis sieht; alles dazu steht in **[Abonnements](subscriptions.md)**.
|
||||
|
||||
Ein Vorbehalt bei `resources/updated`: Verdrängt wird nur bei exakt gleichem URI. Der Store-Vertrag kennt keine Operation zum Aufzählen oder Scannen (wie auch die TypeScript-Referenzimplementierung), daher verdrängt eine Benachrichtigung mit dem URI einer *Unter*-Ressource keinen gecachten Lesevorgang ihrer übergeordneten Ressource. Wenn dein Server Unter-Ressourcen so signalisiert, rufe die übergeordnete Ressource mit `cache_mode="refresh"` erneut ab.
|
||||
|
||||
### Konfiguration: `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`: wo die Einträge liegen. Standardmäßig ist das ein frischer In-Memory-Store pro Client; übergib deine eigene `ResponseCacheStore`-Implementierung (etwa mit Redis dahinter), um einen Cache über Clients oder Prozesse hinweg zu teilen. Die Vertragstypen (`ResponseCacheStore`, `CacheKey`, `CacheEntry` und der Standard-`InMemoryResponseCacheStore`) lassen sich aus `mcp.client` importieren. Ein Lookup kann bis zu zwei aufeinanderfolgende `get`s am Store auslösen (erst den privaten Zweig, dann den öffentlichen), plane die Latenzerwartungen an einen entfernten Store also entsprechend. Ein eigener Store **erfordert** eine explizite `partition`.
|
||||
* `partition`: das Label für den Autorisierungskontext, das verhindert, dass die `"private"`-Einträge eines Principals in einem gemeinsam genutzten Store an einen anderen ausgeliefert werden.
|
||||
* `target_id`: explizite Server-Identität, für eigene Transporte und In-Process-Server (siehe unten).
|
||||
* `default_ttl_ms`: TTL für Ergebnisse, die keinen `ttlMs`-Hinweis tragen. Der Standardwert `0` lässt Ergebnisse ohne Hinweis ungecacht.
|
||||
* `share_public`: vom Server als `"public"` deklarierte Einträge über Partitionen hinweg ausliefern (siehe unten). Standardmäßig aus.
|
||||
* `clock`: die Quelle für die Uhrzeit, in Epoch-Sekunden. Injiziere eine, wie es das Beispiel oben tut, und Ablauftests kommen ohne Schlafen aus.
|
||||
|
||||
!!! warning "Partition = verifizierter Principal"
|
||||
Leite `partition` aus einem **verifizierten Credential** ab, etwa dem Subject eines validierten Tokens. Leite sie nie aus Daten ab, die der Request mitliefert, und nie aus der Server-URL (die Server-Identität ist eine eigene Schlüsselachse). Das SDK ist eine Bibliothek ohne eigene Authentifizierung: Der Vertrauensanker ist, wer auch immer die `CacheConfig` konstruiert – also das Deployment, nicht der Mandant. Ein mandantenfähiges Gateway erzeugt eine `CacheConfig` pro authentifiziertem Principal.
|
||||
|
||||
Die Partition steht außerdem für die Lebensdauer des `Client` fest. Ändert sich der Autorisierungskontext der Verbindung mitten in der Session (etwa durch erneute Authentifizierung als anderer Principal), folgt der Cache nicht; konstruiere einen neuen `Client` für den neuen Principal.
|
||||
|
||||
Cache-Schlüssel tragen außerdem die **Identität des Servers**: den URL-String, den du angewählt hast, ohne etwaige `user:pass@`-Userinfo und ansonsten bytegenau. Keine Normalisierung der Groß-/Kleinschreibung, keine Umsortierung der Query, kein Bereinigen abschließender Schrägstriche. Zu wenig Normalisierung kostet nur Teilbarkeit, zu viel könnte zwei Mandanten zusammenlegen (`?tenant=a` gegenüber `?tenant=b`), deshalb teilen oberflächlich verschiedene URLs einfach keine Einträge. Gibt es keine URL (ein In-Process-Server oder eine `Transport`-Instanz), bekommt der Client stattdessen eine zufällige Identität pro Instanz; setze `CacheConfig.target_id`, um den Server zu benennen (bei einem eigenen Store ist das Pflicht, und die Konstruktion sagt dir das). Die Identität wird mit sha256 gehasht, bevor sie ins Schlüsselmaterial eingeht, sodass eine URL mit Geheimnissen im Query-String nie in Store-Schlüsseln auftaucht. Logge die Form vor dem Hashing auch selbst nicht.
|
||||
|
||||
!!! warning "`share_public` vertraut dem Server, flottenweit"
|
||||
Standardmäßig bleiben selbst `"public"`-Einträge in ihrer Partition. `share_public=True` liefert Einträge, die der Server mit `cacheScope: "public"` markiert hat, an **jede** Partition aus, die den Store nutzt, und vertraut dabei im Namen aller auf die Einstufung des Servers. Ein Server, der mandantenspezifische Daten als `"public"` stempelt (aus Versehen oder in böser Absicht), lässt dann die Response eines Mandanten zu den anderen durchsickern. Das Flag gibt es bewusst nur auf Konstruktorebene: Das `cache_mode` pro Aufruf kann das Caching einschränken, aber nichts pro Aufruf kann das Teilen ausweiten.
|
||||
|
||||
### Was der Cache nie tut {#what-the-cache-never-does}
|
||||
|
||||
* **Aufrufe auf Session-Ebene umgehen ihn.** `client.session.list_tools()` und Konsorten machen immer den Roundtrip; der Cache sitzt auf den `Client`-Verben.
|
||||
* **`server/discover` bleibt außen vor.** Das Discover-Ergebnis wird einmal geliefert, beim Verbinden, und gelangt nie in den Response-Cache, selbst wenn es ein `ttlMs` trägt. Wenn du selbst eines persistierst, um die Probe beim Wiederverbinden zu überspringen ([`prior_discover`](../protocol-versions.md#reconnecting-with-prior_discover)), ist seine Frische deine eigene Buchführung: `DiscoverResult` trägt `ttl_ms` und `cache_scope`, bereits geparst, genau zu diesem Zweck.
|
||||
* **Folgeseiten werden nie gecacht.** Nur Aufrufe ohne Cursor nehmen teil. Eine Folgeseite, die wegen eines abgelaufenen Cursors abgelehnt wird, *verdrängt* allerdings die gecachte Liste, weil sich die Liste darunter geändert hat.
|
||||
* **Multi-Roundtrip-Lesevorgänge (multi-round-trip reads) werden nie gecacht.** Ein `read_resource`, das mit `input_responses`/`request_state` gestartet wird oder das über Eingaberunden aufgelöst wird, gelangt nie in den Cache (ein MUST der Spezifikation).
|
||||
* **Verdrängung per Benachrichtigung braucht Benachrichtigungen.** Die Verdrängung ist nur so gut wie die Zustellung durch den Transport, und der moderne In-Process-Pfad (`Client(server)` mit dem Standardwert `mode="auto"`) stellt heute keine eigenständigen Benachrichtigungen zu.
|
||||
* **Verdrängung geschieht letztendlich, nicht augenblicklich.** Benachrichtigungen vom Leitungspfad werden aus eigens gestarteten Tasks verteilt, sodass ein Aufruf, der mit dem Eintreffen einer Benachrichtigung um die Wette läuft, noch einmal den Eintrag von vor der Verdrängung bekommen kann; das Fenster ist durch die Dispatch-Latenz begrenzt, und die Verdrängung kommt trotzdem an.
|
||||
* **Kein Stale-if-error.** Ein abgelaufener Eintrag wird nie deshalb ausgeliefert, weil der erneute Abruf fehlschlug; der Fehler wird weitergereicht.
|
||||
* **Kein vorzeitiger Neuabruf.** Ein gespeicherter Eintrag wird ausgeliefert, bis seine TTL abläuft, und der nächste Aufruf danach bezahlt den Roundtrip; nichts wird im Hintergrund aktualisiert.
|
||||
* **Kein Zusammenfassen.** Zwei gleichzeitige identische Aufrufe sind zwei Abrufe.
|
||||
* **Keine TTL über 24 Stunden.** Ein größeres `ttlMs`, ob vom Server gesendet oder konfiguriert, wird beim Speichern gekappt (`mcp.client.caching.MAX_TTL_MS`); das begrenzt, wie lange irgendein Eintrag ausgeliefert werden kann, egal wie großzügig der Hinweis war.
|
||||
* Auf einem **gemeinsam genutzten Store** laufen Clients gegeneinander um die Wette. Jeder Client verwirft seinen eigenen Schreibvorgang, wenn eine Verdrängung den laufenden Abruf überholt hat, aber ein Client eines *Mit-Mandanten* kann trotzdem einen Eintrag zurückschreiben, den eine Verdrängung entfernt hatte, die er nie gesehen hat; und diese Race-Buchführung ist selbst begrenzt: Jenseits von 4096 verfolgten Schlüsseln wird zuerst der Schutz des ältesten Schlüssels verworfen. Beide Fenster sind akzeptiert und werden durch die TTL-Obergrenze oben geschlossen.
|
||||
* **Kein Ausliefern über Protokollgenerationen hinweg.** Einträge sind auf die ausgehandelte Protokollversion beschränkt: Auf einem gemeinsam genutzten persistenten Store liefert eine Session nie einen Eintrag aus, der unter einer anderen ausgehandelten Version geschrieben wurde (dieselbe Liste unterscheidet sich tatsächlich je nach Generation, weil das SDK die 2026er-Felder für ältere Sessions entfernt). Verdrängung berührt ebenso nur die Einträge der aktuellen Generation; Einträge einer anderen Generation laufen einfach per TTL ab.
|
||||
|
||||
### Die Hinweise selbst lesen {#reading-the-hints-yourself}
|
||||
|
||||
Die Hinweise sind außerdem ganz normale Felder auf jedem cachefähigen Ergebnis (`result.ttl_ms` und `result.cache_scope`, bereits geparst), falls du eine eigene Buchführung über den eingebauten Cache legen willst (oder an seiner Stelle).
|
||||
|
||||
Gegenüber einem **älteren Server** (Protokoll vor 2026) fehlen die Felder auf der Leitung einfach, und die Modelle zeigen ihre konservativen Standardwerte: `ttl_ms == 0` und `cache_scope == "private"`, abgelaufen und ungeteilt – die richtige Annahme für einen Server, der nichts deklariert hat. Der Cache behandelt eine Legacy-Session genauso: Hinweise werden dort nie herangezogen (egal welche Schlüssel auf der Leitung auftauchen), es gilt nur `default_ttl_ms`, und dessen Standardwert `0` cacht nichts, sodass sich eine Verbindung vor 2026 genau so verhält wie vor der Existenz des Caches. Musst du „der Server hat 0 gesagt“ von „der Server hat nichts gesagt“ unterscheiden, prüfe `"ttl_ms" in result.model_fields_set`: Das ist nur gesetzt, wenn das Feld tatsächlich angekommen ist.
|
||||
|
||||
## Ältere Clients {#older-clients}
|
||||
|
||||
Clients mit Protokollversionen vor 2026 sehen keines der beiden Felder; das SDK entfernt sie für diese Verbindungen bei der Serialisierung. Konfiguriere deine Hinweise einmal; es gibt nichts Versionsspezifisches zu schreiben.
|
||||
|
||||
## Zusammenfassung {#recap}
|
||||
|
||||
* Sechs Methoden tragen `ttlMs`/`cacheScope`; das SDK setzt sie standardmäßig auf `0`/`"private"` – abgelaufen und ungeteilt, immer sicher.
|
||||
* `cache_hints={method: CacheHint(...)}` bei der Konstruktion (sowohl `MCPServer` als auch `Server`) setzt serverweite Werte pro Methode.
|
||||
* Ein Handler, der die Felder auf seinem Ergebnis setzt, überschreibt die Map, pro Feld.
|
||||
* `"public"` ist ein Versprechen, dass das Ergebnis für alle Aufrufer identisch ist. Es ist keine Zugriffskontrolle.
|
||||
* `Client` beachtet die Hinweise automatisch: Sein Response-Cache ist standardmäßig aktiv, liefert frische Einträge statt neu abzurufen und cacht nichts für Server (oder Sessions), die keine Hinweise liefern.
|
||||
* Pro Aufruf ruft `cache_mode="refresh"` neu ab und `"bypass"` umgeht den Cache; `cache=None` bei der Konstruktion schaltet ihn ganz ab.
|
||||
154
i18n/de/pages/client/callbacks.md
Normal file
154
i18n/de/pages/client/callbacks.md
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
---
|
||||
translation:
|
||||
sections: [adf3c545b5be46b6, 916cd3ab1c03f461, e9be7a8d0eb0a456, 565890a636288ecf, 6af7e49db9129ec3, 06b0238c174186af, 90c6043be435fcb0]
|
||||
tool: 1
|
||||
---
|
||||
# Client-Callbacks {#client-callbacks}
|
||||
|
||||
Fast jeder Request in MCP läuft in eine Richtung: vom Client zum Server.
|
||||
|
||||
Ein Server kann aber auch den **Client** um etwas bitten: der Person am Host eine Frage zu stellen, ihr Modell per Sampling zu nutzen, ihre Arbeitsverzeichnisse aufzulisten. Diese Requests beantwortest du, indem du `Client(...)` **Callbacks** übergibst.
|
||||
|
||||
## Ein Server, der fragt {#a-server-that-asks}
|
||||
|
||||
Hier ist ein Server, dessen Tool allein nicht fertig werden kann:
|
||||
|
||||
```python title="server.py" hl_lines="16"
|
||||
--8<-- "docs_src/client_callbacks/tutorial001.py"
|
||||
```
|
||||
|
||||
* `ctx.elicit(...)` sendet einen `elicitation/create`-Request **an den Client** und wartet.
|
||||
* Das Tool kehrt erst zurück, wenn jemand (eine Person in einem Formular oder dein Code) einen `name` liefert.
|
||||
|
||||
Das ist die Server-Hälfte, und die gehört der Seite **[Elicitation](../handlers/elicitation.md)** (Elicitation: Rückfrage bei der Person am Host). Diese Seite hier ist das andere Ende der Leitung.
|
||||
|
||||
## Der Elicitation-Callback {#the-elicitation-callback}
|
||||
|
||||
```python title="client.py" hl_lines="6-10 16-17"
|
||||
--8<-- "docs_src/client_callbacks/tutorial002.py"
|
||||
```
|
||||
|
||||
* Ein Elicitation-Callback ist `async (context, params) -> ElicitResult`.
|
||||
* `params.message` ist die Frage. `params.requested_schema` ist das JSON-Schema der Antwort, die der Server haben will. Ein echter Client rendert daraus ein Formular; dieser hier füllt es automatisch aus.
|
||||
* Du gibst `ElicitResult(action="accept", content={...})` zurück, oder `action="decline"`, oder `action="cancel"`. Die einzige andere Möglichkeit ist `ErrorData(...)`: Das weist den Request zurück und lässt den gesamten Aufruf fehlschlagen.
|
||||
* `context` ist ein `ClientRequestContext`: die laufende `session`, die `request_id` des Servers und alles, was er an `meta` angehängt hat.
|
||||
|
||||
!!! tip
|
||||
`params` ist eine Union der beiden Elicitation-Modi. Hier ist `params.mode` gleich `"form"`; ein `"url"`-Request
|
||||
trägt `params.url` statt eines Schemas. Ein Callback behandelt beide; verzweige anhand von `params.mode`.
|
||||
**[Elicitation](../handlers/elicitation.md)** zeigt das vollständige Muster.
|
||||
|
||||
### Ausprobieren {#try-it}
|
||||
|
||||
Rufe `issue_card` auf und beobachte beide Enden.
|
||||
|
||||
Dein Callback erhält die Frage des Servers, bereits geparst:
|
||||
|
||||
```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'}
|
||||
```
|
||||
|
||||
Er antwortet, `ctx.elicit(...)` läuft im Tool weiter, und das Tool wird fertig:
|
||||
|
||||
```python
|
||||
result.content # [TextContent(type='text', text='Card issued to Ada Lovelace.')]
|
||||
```
|
||||
|
||||
Ein `tools/call` von dir, ein `elicitation/create` zurück vom Server, beantwortet von deiner Funktion – alles innerhalb eines einzigen Tool-Aufrufs.
|
||||
|
||||
!!! info
|
||||
`mode="legacy"` im `Client(...)`-Aufruf leistet echte Arbeit. Standardmäßig handelt `Client(...)` den modernen
|
||||
Protokollpfad aus, und dieser Pfad hat keinen Rückkanal (back-channel) für Requests vom Server an den Client: `ctx.elicit`
|
||||
schlägt fehl, bevor dein Callback überhaupt läuft. Das entscheidet nicht der Transport, sondern das ausgehandelte
|
||||
Protokoll – in-memory genauso wie über eine URL. Setze `mode="legacy"` fest, wann immer dein Client
|
||||
einen solchen Request beantworten muss; jeder Test hinter dieser Seite tut das. Alles Weitere steht in **[Protokollversionen](../protocol-versions.md)**.
|
||||
|
||||
In einer 2026-07-28-Session ist der Callback nicht tot, er wird nur anders gespeist: Gibt ein Tool ein
|
||||
`InputRequiredResult` zurück, das einen `ElicitRequest` trägt, leitet `Client` diesen Eintrag an denselben
|
||||
`elicitation_callback` weiter und wiederholt den Aufruf für dich. Dieser Ablauf heißt **[Multi-Roundtrip-Requests](../handlers/multi-round-trip.md)** (multi-round-trip requests).
|
||||
|
||||
## Ein Callback ist eine Capability {#a-callback-is-a-capability}
|
||||
|
||||
Du hast dem Server nie gesagt, dass dein Client Elicitation-Requests beantworten kann. Das SDK hat es getan.
|
||||
|
||||
Wenn sich ein Client verbindet, deklariert er seine `capabilities`, das Spiegelbild derer des Servers. Dieses Objekt schreibst du nicht. **Einen Callback zu registrieren ist die Deklaration.**
|
||||
|
||||
| du übergibst | der Client deklariert |
|
||||
| --- | --- |
|
||||
| `elicitation_callback=` | `"elicitation": {"form": {}, "url": {}}` |
|
||||
| `sampling_callback=` | `"sampling": {}` |
|
||||
| `list_roots_callback=` | `"roots": {"listChanged": true}` |
|
||||
| keinen davon | `{}` |
|
||||
|
||||
Die Sampling-Sub-Capabilities sind die eine Verfeinerung: Übergib `sampling_capabilities=SamplingCapability(tools=SamplingToolsCapability())` zusammen mit `sampling_callback`, wenn dein Sampler die Parameter `tools` / `tool_choice` verarbeitet. Server müssen `sampling.tools` deklariert sehen, bevor sie diese senden dürfen.
|
||||
|
||||
`logging_callback` und `message_handler` stehen nicht in der Tabelle. Sie verarbeiten Benachrichtigungen, und Benachrichtigungen brauchen keine Capability.
|
||||
|
||||
Der Server liest die Deklaration mit `ctx.session.check_client_capability(...)` zurück. Füge ein Tool hinzu, das genau das tut:
|
||||
|
||||
```python title="server.py" hl_lines="23-31"
|
||||
--8<-- "docs_src/client_callbacks/tutorial003.py"
|
||||
```
|
||||
|
||||
Verbinde dich nur mit `elicitation_callback` und rufe es auf:
|
||||
|
||||
```python
|
||||
result.structured_content # {'result': ['elicitation']}
|
||||
```
|
||||
|
||||
Übergibst du alle drei Callbacks, bekommst du `['elicitation', 'sampling', 'roots']`. Übergibst du keinen, bekommst du `[]`.
|
||||
|
||||
!!! check
|
||||
Jetzt mach es absichtlich falsch: Verbinde dich **ohne** `elicitation_callback` und rufe `issue_card` trotzdem auf.
|
||||
|
||||
Der `elicitation/create`-Request des Servers erreicht deinen Client trotzdem, und das SDK beantwortet ihn für
|
||||
dich – mit einem Fehler, weil du nie gesagt hast, dass du ihn verarbeiten kannst. Dieser Fehler lässt den gesamten Aufruf scheitern.
|
||||
`call_tool` gibt kein `is_error`-Ergebnis zurück; es wirft eine Exception:
|
||||
|
||||
```text
|
||||
MCPError: Elicitation not supported
|
||||
```
|
||||
|
||||
Das ist ein Protokollfehler (`-32600`, *invalid request*), kein Tool-Fehler: Es gibt nichts, was
|
||||
das Modell lesen und erneut versuchen könnte. Deshalb lohnt sich `client_features`: Ein Server,
|
||||
der sich gut benimmt, prüft, bevor er fragt.
|
||||
|
||||
## Das veraltete Paar {#the-deprecated-pair}
|
||||
|
||||
`sampling_callback` beantwortet `sampling/createMessage`: Der Server bittet *dein* Modell um eine Completion. `list_roots_callback` beantwortet `roots/list`: Der Server fragt, in welchen Verzeichnissen er arbeiten darf.
|
||||
|
||||
Beide funktionieren. Beide folgen der Regel oben. Und beide bedienen RPCs, die die **Spezifikation 2026-07-28 entfernt**: Ein moderner Server ruft nicht mitten im Request in deinen Client zurück, sondern reicht dir den Request als Teil des Tool-Ergebnisses zurück (**[Multi-Roundtrip-Requests](../handlers/multi-round-trip.md)**). Die Callbacks selbst sind nicht tot. Trägt ein `InputRequiredResult` einen `CreateMessageRequest` oder einen `ListRootsRequest`, leitet die Auto-Schleife von `Client` ihn an denselben `sampling_callback` oder `list_roots_callback` weiter, den du hier registriert hast. Die vollständige Liste steht in **[Veraltete Features](../deprecated.md)**.
|
||||
|
||||
Du brauchst die Callbacks weiterhin, um mit Servern zu sprechen, die noch nicht umgestiegen sind. Die Signaturen:
|
||||
|
||||
```python title="client.py"
|
||||
--8<-- "docs_src/client_callbacks/tutorial004.py"
|
||||
```
|
||||
|
||||
* Ein Sampling-Callback erhält die vollständigen `CreateMessageRequestParams` (`messages`, `model_preferences`, `max_tokens`) und gibt ein `CreateMessageResult` zurück. *Du* betreibst das Modell, ganz wie du willst; das SDK transportiert nur den Request.
|
||||
* Ein Roots-Callback nimmt überhaupt keine Parameter entgegen und gibt ein `ListRootsResult` zurück.
|
||||
* Beide dürfen stattdessen `ErrorData(...)` zurückgeben, um abzulehnen.
|
||||
|
||||
Übergib sie an `Client(...)` genau wie `elicitation_callback`.
|
||||
|
||||
## Die Benachrichtigungs-Callbacks {#the-notification-callbacks}
|
||||
|
||||
Zwei weitere. Keiner deklariert etwas.
|
||||
|
||||
`logging_callback` erhält die `notifications/message`, die ein Server sendet, als `LoggingMessageNotificationParams` (`level`, `logger`, `data`). Das Protokoll-Logging selbst ist mit der Spezifikation 2026-07-28 veraltet (was du stattdessen tust, steht in **[Logging](../handlers/logging.md)**), dieser Callback existiert also für die Server, die es noch ausgeben. Auf einer Verbindung der 2026er-Generation bringt dir der Callback allein nichts, denn 2026er-Server senden Log-Nachrichten nur an Requests, die sich dafür anmelden: Übergib `log_level="info"` (oder ein anderes Level) an `Client(...)`, um dieses Opt-in jedem Request aufzuprägen und dieses Level und alles darüber zu empfangen. Server vor 2026 ignorieren das und behalten ihr `logging/setLevel`-Verhalten.
|
||||
|
||||
`message_handler` ist das Sammelbecken: Jede Server-Benachrichtigung, die die Session nach oben reicht, landet dort (zusätzlich zu ihrem spezifischen Callback), und auf einem Stream-gestützten Transport auch jede `Exception` auf Transportebene. Zwei kommen nie an: `notifications/cancelled` wendet das SDK an, statt sie nach oben zu reichen, und eine Abonnement-Bestätigung für einen laufenden `listen()`-Stream verbraucht dieser Stream selbst. Annotiere den Parameter mit `IncomingMessage` (`ServerNotification | Exception`, exportiert aus `mcp.client`). Das eine Muster, das du kennen solltest, ist `if isinstance(message, Exception): raise message`, damit eine unterbrochene Verbindung laut fehlschlägt, statt still zu verschwinden.
|
||||
|
||||
## Zusammenfassung {#recap}
|
||||
|
||||
* Ein Server kann Requests an den Client senden. Du beantwortest sie mit Callbacks, die du `Client(...)` übergibst.
|
||||
* Der Elicitation-Callback ist der aktuelle: `async (context, params) -> ElicitResult`, eine Funktion für Formular- und URL-Modus.
|
||||
* **Einen Callback zu registrieren heißt, die Capability zu deklarieren.** Ohne ihn weist das SDK den Request des Servers in deinem Namen zurück, und der gesamte Aufruf schlägt mit `MCPError` fehl.
|
||||
* Ein Server findet das vor dem Fragen mit `ctx.session.check_client_capability(...)` heraus.
|
||||
* `sampling_callback` und `list_roots_callback` funktionieren genauso, bedienen aber veraltete Features; moderne Server verwenden stattdessen Multi-Roundtrip-Requests.
|
||||
* `logging_callback` und `message_handler` empfangen Benachrichtigungen. Sie deklarieren nichts.
|
||||
|
||||
Das erste Argument von `Client(...)` ist ein Transport-Objekt. **[Client-Transporte](transports.md)** behandelt jede Art davon.
|
||||
155
i18n/de/pages/client/identity-assertion.md
Normal file
155
i18n/de/pages/client/identity-assertion.md
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
---
|
||||
translation:
|
||||
sections: [a91322c46111d16d, 8e6fd6d6f59bb568, e7828fd2729b2c9d, a03ec26bfc678b65, 1034c653c0bcf1b0]
|
||||
tool: 1
|
||||
---
|
||||
# Identity Assertion {#identity-assertion}
|
||||
|
||||
Ein gewöhnlicher OAuth-Provider (**[OAuth-Clients](oauth-clients.md)**) stellt dem MCP-Server zuerst eine Frage: *Welchem Autorisierungsserver vertraust du?* Er folgt der Antwort, wohin sie auch zeigt, und dann meldet sich entweder eine Person an oder ein vorab geteiltes Secret tritt an ihre Stelle.
|
||||
|
||||
Ein Unternehmen will weder das eine noch das andere pro Server entschieden haben. Es betreibt längst einen Identity Provider (Okta, Microsoft Entra ID, einen eigenen); die Person hat sich dort heute Morgen schon angemeldet; und es ist der eine Ort, an dem das Security-Team entscheiden will, wer was erreichen darf. [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990), die Erweiterung **Enterprise-Managed Authorization**, verlegt die Entscheidung dorthin. Der IdP signiert ein kurzlebiges JWT, einen **Identity Assertion JWT Authorization Grant**, den **ID-JAG**: die Aussage, dass *diese Person* über *diesen Client* *diesen MCP-Server* erreichen darf. Der Client tauscht ihn gegen ein gewöhnliches Access Token. Kein Browser, kein Zustimmungsdialog, keine dynamische Registrierung.
|
||||
|
||||
Diese Seite zeigt beide Seiten dieses Tauschs. Der MCP-Server selbst ändert sich nie: Er ist nach wie vor der Ressourcenserver aus **[Autorisierung](../run/authorization.md)** und prüft jedes Token, das ankommt.
|
||||
|
||||
## Zwei Token-Requests {#two-token-requests}
|
||||
|
||||
Zwei verschiedene Instanzen sind im Spiel, und sie auseinanderzuhalten ist schon fast das ganze Verständnis dieser Seite. Der **Unternehmens-IdP** ist der Identity Provider deiner Organisation: Er kennt die Identität der Beschäftigten, bei ihm liegen die Richtlinien, und er stellt den ID-JAG aus. Das SDK spricht nie mit ihm. Der **MCP-Autorisierungsserver** ist dieselbe Partei wie in **[Autorisierung](../run/authorization.md)**: der Issuer, den die Metadaten des MCP-Servers nennen, die Stelle, die die Tokens ausstellt, die dieser MCP-Server akzeptiert. In einem gewöhnlichen OAuth-Flow sind diese beiden Rollen meist ein und dasselbe System. Hier sind es zwei, und der ganze Grant besteht darin, dass der zweite zustimmt, dem ersten zu vertrauen.
|
||||
|
||||
Der Client stellt an jeden der beiden genau einen Token-Request.
|
||||
|
||||
1. **An den Unternehmens-IdP.** Der Client tauscht die Anmeldung der Person (ihr OpenID-Connect-ID-Token) gegen den ID-JAG. Das ist ein Token Exchange nach [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693), er läuft vollständig über die API deines IdP, und **das SDK führt ihn nicht aus**. Das machst du, in einem einzigen asynchronen Callback. Hier fällt auch die Richtlinienentscheidung: Ein IdP, der Nein sagt, stellt den ID-JAG gar nicht erst aus, und es gibt nichts vorzulegen.
|
||||
2. **An den MCP-Autorisierungsserver.** Der Client legt den ID-JAG im `jwt-bearer`-Grant nach [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) vor (`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`, der ID-JAG als `assertion`) und erhält das Access Token. **Diesen Request stellt das SDK**, und ihn anzunehmen ist das Einzige, was diese Seite einem Autorisierungsserver hinzufügt.
|
||||
|
||||
Alles Weitere dreht sich um den zweiten Request: den Client, der ihn sendet, und den Autorisierungsserver, der ihn beantwortet.
|
||||
|
||||
## Der Client {#the-client}
|
||||
|
||||
**`IdentityAssertionOAuthProvider`** liegt in `mcp.client.auth.extensions.identity_assertion`. Wie jeder Provider in **[OAuth-Clients](oauth-clients.md)** ist er ein `httpx2.Auth`: Erzeuge einen, setze ihn auf `auth=` und übergib den `httpx2.AsyncClient` an den Transport.
|
||||
|
||||
```python title="client.py" hl_lines="49-50 53-61"
|
||||
--8<-- "docs_src/identity_assertion/tutorial001.py"
|
||||
```
|
||||
|
||||
Lies die Datei von unten nach oben.
|
||||
|
||||
* `main()` ist das übliche `main()` eines OAuth-Clients (**[OAuth-Clients](oauth-clients.md)**), Zeile für Zeile unverändert. Genau darum geht es: Sobald der Provider existiert, weiß nichts dahinter, welcher Grant das Token erzeugt hat.
|
||||
* Der Provider nimmt entgegen, was die anderen Provider nicht per Discovery herausfinden können: eine `client_id` und ein `client_secret`, die jemand beim Autorisierungsserver **vorab registriert** hat, den `issuer` dieses Autorisierungsservers und `assertion_provider`, einen asynchronen Callback, der auf Anforderung einen frischen ID-JAG liefert.
|
||||
* `storage` ist dasselbe `TokenStorage`-Protokoll. Aufgerufen werden nur die beiden Token-Methoden; dynamische Registrierung gibt es hier nicht, also auch kein `client_info`, das man sich merken müsste.
|
||||
|
||||
### Der Assertion-Provider {#the-assertion-provider}
|
||||
|
||||
`fetch_id_jag(audience, resource)` ist der einzige Code, den du schreibst. Er wird einmal pro Token-Austausch aufgerufen, nie beim Konstruieren, und erst *nachdem* die Metadaten des Autorisierungsservers abgerufen und validiert wurden – so gibt ein falsch konfigurierter Issuer nie eine Assertion preis. Seine beiden Argumente sind zwei der Claims, mit denen der ID-JAG ausgestellt werden muss: `audience` ist der Issuer des Autorisierungsservers (das `aud` des ID-JAG) und `resource` der kanonische Bezeichner des MCP-Servers (das `resource` des ID-JAG). Den dritten hast du bereits: Der `client_id`-Claim des ID-JAG muss die `client_id` nennen, die du dem Provider gegeben hast, sonst verweigert der Autorisierungsserver den Austausch.
|
||||
|
||||
`idp_issue_id_jag` darüber ist **nicht dein Code**. Die Funktion steht stellvertretend für den Identity Provider und signiert die Assertion im selben Prozess, damit die Datei vollständig ist und du jeden Claim lesen kannst, den ein ID-JAG trägt. Ein echtes `fetch_id_jag` stellt stattdessen den ersten Token-Request aus dem vorigen Abschnitt: einen Token Exchange nach [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) gegen deinen IdP, definiert im Draft zum Identity Assertion JWT Authorization Grant, den [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) profiliert. Das ID-Token der angemeldeten Person geht als `subject_token` hinein, der `requested_token_type` ist der eigene URN des ID-JAG (`urn:ietf:params:oauth:token-type:id-jag`), `audience` und `resource` werden unverändert durchgereicht, und die Response enthält den ID-JAG. Nach genau diesem Austausch unter genau diesen Namen suchst du in der Dokumentation deines IdP.
|
||||
|
||||
!!! tip
|
||||
Für jeden Austausch wird ein frischer ID-JAG angefordert, und genau das ist der Sinn: Er ist ein
|
||||
Grant zur einmaligen Verwendung, der nur Minuten lebt, und der Autorisierungsserver auf dieser Seite
|
||||
nimmt denselben kein zweites Mal an. Cache ihn nicht. Wiederverwendet wird das Access Token, das du
|
||||
dafür bekommst.
|
||||
|
||||
### Der Issuer ist Konfiguration {#the-issuer-is-configuration}
|
||||
|
||||
Hier liegt die Umkehrung. `OAuthClientProvider` fragt den Ressourcenserver, welchen Autorisierungsserver er verwenden soll, und folgt der Antwort, wohin sie auch zeigt. Dieser Provider weigert sich: `issuer` ist erforderlich, die Metadaten nach [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) werden vom eigenen Well-known-Pfad dieses Issuers abgerufen, der Token-Endpunkt muss auf dem Origin dieses Issuers liegen, und der Ressourcenserver wird nie irgendetwas gefragt.
|
||||
|
||||
Die Erweiterung verlangt das nicht; es ist eine bewusst strengere Entscheidung. Dieser Client trägt zwei Dinge mit sich, die sich zu stehlen lohnen – ein vorab registriertes Secret und eine an eine Audience gebundene Assertion –, und ein Client, der sich von einem kompromittierten MCP-Server zu einem von Angreifenden kontrollierten Autorisierungsserver lenken ließe, würde beides dorthin posten. Den Issuer beim Konstruieren festzulegen, streicht dieses Gespräch komplett.
|
||||
|
||||
!!! warning
|
||||
Der konfigurierte `issuer` wird mit dem Feld `issuer` des Metadatendokuments per einfachem
|
||||
String-Vergleich nach RFC 8414 §3.3 verglichen: Zeichen für Zeichen, abschließender Schrägstrich
|
||||
inklusive, ohne Normalisierung. Rate ihn nicht. Rufe `/.well-known/oauth-authorization-server` von
|
||||
deinem Autorisierungsserver ab und kopiere den `issuer`-Wert, den er zurückgibt. Für den
|
||||
Autorisierungsserver auf dieser Seite ist das `https://auth.example.com/`, mit dem Schrägstrich, weil
|
||||
sein Issuer aus einem Pydantic-URL-Objekt gebaut wurde. Eine Abweichung stoppt den Flow bei
|
||||
`OAuthFlowError: Authorization server metadata issuer
|
||||
mismatch`, bevor auch nur ein einziges Credential oder eine Assertion gesendet wird.
|
||||
|
||||
### Ein vertraulicher Client {#a-confidential-client}
|
||||
|
||||
`client_secret` ist erforderlich; ohne löst der Konstruktor einen `ValueError` aus. Das IETF-Profil unter [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) reserviert diesen Grant für vertrauliche Clients, SEP-990 verlangt, dass sich der Client authentifiziert, und dieses SDK setzt beides durch, indem es auf einem geteilten Secret besteht. `token_endpoint_auth_method` legt fest, wo es mitreist: `client_secret_post` (der Standardwert, im Formular-Body) oder `client_secret_basic` (ein HTTP-Basic-Header). Das Profil erlaubt außerdem `private_key_jwt`; dieser Provider unterstützt es nicht.
|
||||
|
||||
!!! tip
|
||||
Lies `client_secret` aus der Umgebung oder einem Secret-Manager, nie aus der Versionsverwaltung.
|
||||
|
||||
### Was der Provider für dich erledigt {#what-the-provider-does-for-you}
|
||||
|
||||
Der erste Request geht unauthentifiziert raus, und das `401` des Servers startet den Flow.
|
||||
|
||||
1. **Discovery.** Er ruft die Metadaten des Autorisierungsservers vom Well-known-Pfad nach [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) des konfigurierten Issuers ab, prüft, dass der `issuer` des Dokuments übereinstimmt, und prüft, dass der Token-Endpunkt auf dem Origin des Issuers liegt.
|
||||
2. **Die Assertion.** Er ruft deinen `assertion_provider` auf und wartet auf das Ergebnis.
|
||||
3. **Austausch.** Er sendet den `jwt-bearer`-Grant per POST an den Token-Endpunkt, speichert das `OAuthToken` und wiederholt deinen ursprünglichen Request mit `Authorization: Bearer ...`.
|
||||
|
||||
Ein `403`, dessen `WWW-Authenticate` `insufficient_scope` nennt, führt die Schritte 2 und 3 erneut aus, mit der Vereinigung aus deinem `scope` und dem in der Challenge geforderten. (`scope` ist immer nur eine Bitte; der Autorisierungsserver dieser Seite gewährt, was der ID-JAG sagt, und nichts sonst.) Ein Refresh Token gibt es hier nirgends: Läuft das Access Token ab, lässt das nächste `401` einen frischen ID-JAG ausstellen und tauscht erneut, und *das* ist der Hebel, den der IdP in der Hand hält. Fehler sind dieselben zwei Exceptions wie überall in **[OAuth-Clients](oauth-clients.md)**: `OAuthFlowError` für Discovery und Validierung, ihre Unterklasse `OAuthTokenError`, wenn der Token-Endpunkt Nein sagt.
|
||||
|
||||
## Der Autorisierungsserver {#the-authorization-server}
|
||||
|
||||
Meistens hörst du hier auf. Der MCP-Autorisierungsserver ist das Produkt von jemand anderem, ID-JAGs anzunehmen ist eine Einstellung in dessen Konfiguration, die du einschaltest, und die SDK-Hälfte von [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) ist der Client oben.
|
||||
|
||||
Das SDK kann aber auch selbst der Autorisierungsserver *sein*: `create_auth_routes` gibt die Routen des Autorisierungsservers als Liste zurück, die jede Starlette-App mounten kann – so betreibt `examples/servers/simple-auth/` im Repository einen. SEP-990 fügt dieser Oberfläche ein Flag und eine Methode hinzu:
|
||||
|
||||
```python title="auth_server.py" hl_lines="48-50 105-107"
|
||||
--8<-- "docs_src/identity_assertion/tutorial002.py"
|
||||
```
|
||||
|
||||
* `identity_assertion_enabled=True` schaltet alles frei. Ausgeschaltet – das ist der Standardwert – beantwortet `/token` diesen Grant mit `unsupported_grant_type`, selbst wenn du den Hook implementiert hast, und die Metadaten erwähnen ihn nicht. Eingeschaltet erhalten die Metadaten den Grant-Typ `jwt-bearer` und listen `urn:ietf:params:oauth:grant-profile:id-jag` in `authorization_grant_profiles_supported`, dem Feld, mit dem die Erweiterung Unterstützung bekannt gibt. (Der Client dieses SDK liest es nie: Er ist für genau einen Issuer eingerichtet und fragt einfach.)
|
||||
* **`exchange_identity_assertion`** ist der Hook. Bevor er läuft, hat das SDK den Client authentifiziert, öffentliche Clients abgewiesen und Clients abgewiesen, deren Registrierung den Grant nicht aufführt. Du bekommst ein `IdentityAssertionParams` (die rohe `assertion`, die angeforderten `scopes` und `resource`) und gibst ein schlichtes `OAuthToken` zurück.
|
||||
* Die dynamische Client-Registrierung lehnt diesen Grant ausnahmslos ab, deshalb bedient `get_client` hier einen von Hand eingerichteten Client. Ein ID-JAG-Client kann sich nicht selbst ins Leben registrieren.
|
||||
* Die halbe Klasse besteht aus Ablehnungen. `OAuthAuthorizationServerProvider` ist der *ganze* Autorisierungsserver, also verlangt er auch den Authorization-Code-Flow; ein Server, der Personen zusätzlich anmeldet, implementiert diese Methoden wirklich, und dieser hier hat genau eine Tür.
|
||||
|
||||
!!! warning
|
||||
Das SDK dekodiert die Assertion nie: Nur dein Deployment weiß, welchem IdP es vertraut und welche
|
||||
Schlüssel dieser IdP veröffentlicht, deshalb ist alles innerhalb von `exchange_identity_assertion`
|
||||
tragend. Prüfe die Signatur gegen die veröffentlichten Schlüssel des IdP (sein JWKS; das geteilte
|
||||
Secret hier gehört zur Demo) sowie `iss` und `exp`, gemäß [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) §3. Verlange, dass `typ`
|
||||
im JWT-Header `oauth-id-jag+jwt` ist – der Schutz des Profils dagegen, dass irgendein anderes JWT
|
||||
als Grant wiedereingespielt wird. Verlange, dass `aud` dein eigener Issuer ist. Verlange, dass der
|
||||
`client_id`-Claim des ID-JAG dem Client entspricht, den der Handler authentifiziert hat, und dass
|
||||
sein `resource`-Claim eine Ressource nennt, die du tatsächlich bedienst. Merke dir `jti` bis zum
|
||||
`exp` der Assertion, damit sie nur einmal akzeptiert wird. Und entnimm die gewährten Scopes und vor
|
||||
allem das `resource` des ausgestellten Tokens dem validierten ID-JAG, nie dem Request:
|
||||
`params.resource` ist, was immer der Client eingetippt hat. Die vollständigen Verarbeitungsregeln
|
||||
stehen in der [Spezifikation zu Enterprise-Managed Authorization](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization).
|
||||
|
||||
Eine fehlerhafte Assertion weist du mit `TokenError("invalid_grant", ...)` ab. Der andere Fehlercode in diesem Flow ist `invalid_target`: Ein ID-JAG, der eine Ressource nennt, die du nicht bedienst, wird damit abgelehnt – das verhindert, dass dieser Server Tokens für die Ressource von jemand anderem ausstellt. Und die gewährten Scopes stammen aus dem `scope`-Claim des ID-JAG (eine Assertion ohne ihn wird ebenfalls abgelehnt); deiner könnte stattdessen die Gruppen der Person abbilden.
|
||||
|
||||
Und beachte, was das zurückgegebene `OAuthToken` nicht enthält: ein Refresh Token. Der IdP entscheidet, wie lange diese Person Zugang behält, indem er entscheidet, ob er den nächsten ID-JAG ausstellt. Ein hier ausgestelltes Refresh Token gäbe diese Entscheidung stillschweigend wieder ab.
|
||||
|
||||
!!! info
|
||||
Ein Server, der seinen Autorisierungsserver noch mit `auth_server_provider=` einbettet, erreicht
|
||||
denselben Code über `AuthSettings(identity_assertion_enabled=True)`. **[Autorisierung](../run/authorization.md)** erklärt,
|
||||
warum neue Server nicht dort anfangen sollten.
|
||||
|
||||
!!! check
|
||||
Verbinde die beiden Dateien dieser Seite miteinander, und der ganze Grant ist ein einziges `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"}
|
||||
```
|
||||
|
||||
Kein `/authorize`, kein `/register`, kein Abruf der Protected-Resource-Metadaten. Die einzigen
|
||||
Requests auf der Leitung sind der, der das `401` ausgelöst hat, der Well-known-Abruf, dieser
|
||||
Austausch und danach gewöhnlicher MCP-Verkehr mit angehängtem Bearer-Token. Und das `sub`, das dein
|
||||
Validator aus dem ID-JAG gelesen hat, ist genau das, was `get_access_token().subject` innerhalb
|
||||
eines Tools meldet.
|
||||
|
||||
### Ausprobieren {#try-it}
|
||||
|
||||
`examples/stories/identity_assertion/` im SDK-Repository ist diese Seite in echt: derselbe `exchange_identity_assertion`-Validator, ein MCP-Server, der durch dessen Tokens abgesichert ist, ein Stellvertreter-IdP und der Client, in einem einzigen Programm, das sich selbst prüft. `uv run python -m stories.identity_assertion.client --http` führt den ganzen Austausch aus und prüft, dass die Person, die der IdP benannt hat, dieselbe ist, die das Tool sieht.
|
||||
|
||||
## Zusammenfassung {#recap}
|
||||
|
||||
* [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) lässt den Identity Provider des Unternehmens – nicht die Person am Host – entscheiden, welche MCP-Server ein Client erreichen darf. Der IdP signiert diese Entscheidung in einen **ID-JAG**.
|
||||
* Den ID-JAG zu beschaffen ist ein Token Exchange nach [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) gegen *deinen IdP*, und das SDK führt ihn nicht aus. Ihn dem MCP-Autorisierungsserver vorzulegen ist der `jwt-bearer`-Grant nach [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523), und davon übernimmt das SDK beide Seiten.
|
||||
* `IdentityAssertionOAuthProvider` ist ein weiteres `httpx2.Auth`: ein vorab registrierter vertraulicher Client, ein festgelegter `issuer` und ein einziger Callback `assertion_provider(audience, resource)`. Kein Browser, keine Registrierung, kein Refresh Token.
|
||||
* Der Autorisierungsserver wird nie über den Ressourcenserver entdeckt. Setze `issuer` auf genau den String, den sein Metadatendokument ausliefert; verglichen wird Zeichen für Zeichen.
|
||||
* Serverseitig: `identity_assertion_enabled=True` plus `exchange_identity_assertion`. Das SDK authentifiziert den Client und schaltet den Grant frei; den ID-JAG zu validieren ist ganz deine Sache, und das ausgestellte Token ist an das `resource` des ID-JAG gebunden, nicht an das des Requests.
|
||||
|
||||
Die eine Partei, die diese Seite nie angefasst hat, ist der MCP-Server. Was er mit dem Token macht, das du gerade ausgestellt hast, hat er schon in **[Autorisierung](../run/authorization.md)** getan.
|
||||
217
i18n/de/pages/client/index.md
Normal file
217
i18n/de/pages/client/index.md
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
---
|
||||
translation:
|
||||
sections: [ebef1e7a0df854f4, a4c687d3d627d516, 8e79141fc2985342, b345dd05b9c3c7ab, 80ce41579825a6fa, 5f0fa90494de8f65, 83d10514eaa62fa5, 9190555aa39a5d28, 84a4c9d8bf14dddb, 927d71cf40b58c30]
|
||||
tool: 1
|
||||
---
|
||||
# Der Client {#the-client}
|
||||
|
||||
Ein **`Client`** ist der Weg, auf dem ein Python-Programm mit einem MCP-Server spricht.
|
||||
|
||||
Er ist ein einziges Objekt mit einem einzigen Lebenszyklus: erzeugen, `async with` betreten, Methoden aufrufen. Jedes Verb des Protokolls (die Tools auflisten, eines aufrufen, eine Ressource lesen, einen Prompt rendern) ist eine `async`-Methode darauf, die ein typisiertes Ergebnis zurückgibt.
|
||||
|
||||
## Der erste Client {#your-first-client}
|
||||
|
||||
```python title="client.py" hl_lines="14-18"
|
||||
--8<-- "docs_src/client/tutorial001.py"
|
||||
```
|
||||
|
||||
Der Server oben ist nur da, damit du etwas hast, womit du dich verbinden kannst. Der Client sind die fünf hervorgehobenen Zeilen.
|
||||
|
||||
* `Client(mcp)` bekommt das **Server-Objekt selbst**. Das ist der In-Memory-Transport: kein Subprozess, kein Port, kein HTTP. So verbindet sich jedes Beispiel auf dieser Seite und jeder Test, den du schreibst.
|
||||
* `async with` ist der **Lebenszyklus**. Beim Betreten wird verbunden und ausgehandelt, beim Verlassen getrennt. Es gibt kein `connect()`/`close()`-Paar, und ein `Client` lässt sich nach dem Ende des Blocks nicht wiederverwenden.
|
||||
* Innerhalb des Blocks liegen die Fakten zur Verbindung bereits als einfache Properties vor.
|
||||
|
||||
### Was sich an `Client` übergeben lässt {#what-you-can-pass-to-client}
|
||||
|
||||
`Client` nimmt ein positionelles Argument und leitet den Transport aus dessen Typ ab:
|
||||
|
||||
* Eine Instanz von `MCPServer` (oder des Low-Level-`Server`): Verbindung **im selben Prozess**.
|
||||
* Ein URL-String (`Client("http://localhost:8000/mcp")`): Streamable HTTP, der Weg für die Produktion.
|
||||
* Ein **Transport**: alles, was sich mit `async with ... as (read, write)` verwenden lässt, etwa `stdio_client(...)` um einen Subprozess herum.
|
||||
|
||||
Alles Übrige auf dieser Seite ist in allen drei Fällen identisch. Header, Subprozesse, Timeouts und das `Transport`-Protokoll haben ihre eigene Seite: **[Client-Transporte](transports.md)**.
|
||||
|
||||
### Was ein verbundener Client mitbringt {#whats-on-a-connected-client}
|
||||
|
||||
Vier schreibgeschützte Properties, die gefüllt sind, sobald du den Block betrittst:
|
||||
|
||||
* `client.server_info`: die Identität des Servers oder `None` bei einem Server der 2026er-Generation, der keine meldet (python-sdk-Server tun das standardmäßig). `server_info.name` ist hier `"Bookshop"`, `server_info.version` ist das, was der Server meldet.
|
||||
* `client.server_capabilities`: was der Server kann (`tools`, `resources`, `prompts`, `completions`, ...). Eine Capability, die der Server nicht hat, ist `None`.
|
||||
* `client.protocol_version`: die Protokollversion, auf die sich beide Seiten geeinigt haben. Hier ist sie `"2026-07-28"`.
|
||||
* `client.instructions`: der `instructions=`-String des Servers oder `None`, wenn er keinen gesetzt hat.
|
||||
|
||||
Eine Protokollversion hast du nie ausgewählt. Standardmäßig sondiert der `Client` den Server und fällt bei älteren auf den klassischen Handshake zurück, sodass ein einziger Client mit Servern jeder Generation funktioniert. Wenn du das steuern musst: Alles Weitere steht in **[Protokollversionen](../protocol-versions.md)**.
|
||||
|
||||
!!! tip
|
||||
`client.session` ist die darunterliegende `ClientSession`, der Low-Level-Notausgang.
|
||||
Für nichts auf dieser Seite wirst du sie brauchen.
|
||||
|
||||
## Tools auflisten {#listing-tools}
|
||||
|
||||
```python title="client.py" hl_lines="15-20"
|
||||
--8<-- "docs_src/client/tutorial002.py"
|
||||
```
|
||||
|
||||
`list_tools()` gibt ein `ListToolsResult` zurück; die Tools stehen in `.tools`. Jedes davon ist die vollständige Definition, die ein Host einem Modell übergeben würde:
|
||||
|
||||
```python
|
||||
tool.name # 'search_books'
|
||||
tool.title # 'Search the catalog'
|
||||
tool.description # 'Search the catalog by title or author.'
|
||||
```
|
||||
|
||||
und `tool.input_schema` ist das JSON-Schema, das der Server aus den Type Hints der Funktion abgeleitet hat:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"title": "Query", "type": "string"},
|
||||
"limit": {"default": 10, "title": "Limit", "type": "integer"}
|
||||
},
|
||||
"required": ["query"],
|
||||
"title": "search_booksArguments"
|
||||
}
|
||||
```
|
||||
|
||||
Dieses Schema ist alles, was eine UI braucht, um ein Argumentformular zu rendern, und alles, was ein Modell braucht, um gültige Argumente zu erzeugen.
|
||||
|
||||
!!! tip
|
||||
`title` ist optional, also muss sich eine UI, die einem Menschen Tools anzeigt, entscheiden: den `title`, wenn es einen gibt,
|
||||
sonst den `name`. `from mcp.shared.metadata_utils import get_display_name` macht genau das –
|
||||
für Tools, Ressourcen, Ressourcen-Templates und Prompts.
|
||||
|
||||
## Ein Tool aufrufen {#calling-a-tool}
|
||||
|
||||
`call_tool(name, arguments)` führt das Tool aus und gibt dir ein `CallToolResult` zurück.
|
||||
|
||||
```python title="client.py" hl_lines="26-33"
|
||||
--8<-- "docs_src/client/tutorial003.py"
|
||||
```
|
||||
|
||||
`lookup_book` auf dem Server gibt ein Pydantic-`Book` zurück. Das sieht der 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
|
||||
```
|
||||
|
||||
Ein Rückgabewert, drei Dinge zu lesen. Jedes hat einen anderen Abnehmer.
|
||||
|
||||
### `content`: was das Modell liest {#content-what-the-model-reads}
|
||||
|
||||
`content` ist eine `list` von **Content-Blöcken**, und ein Content-Block ist eine Union: `TextContent`, `ImageContent`, `AudioContent`, `ResourceLink` oder `EmbeddedResource`. Ein Tool kann mehrere zurückgeben, auch verschiedener Art.
|
||||
|
||||
Deshalb grenzt `main` mit `isinstance(block, TextContent)` ein, bevor es `block.text` anfasst. Beachte, dass es kein `.text` außerhalb des `isinstance` gibt: Der Typprüfer lässt das nicht zu, denn `ImageContent` hat `.data`, nicht `.text`. Die Union ist ehrlich darüber, was ein Tool dir schicken darf; dein Code sollte es auch sein.
|
||||
|
||||
### `structured_content`: was deine Anwendung liest {#structured_content-what-your-application-reads}
|
||||
|
||||
`structured_content` ist der Rückgabewert des Tools als JSON, passend zum deklarierten `output_schema` des Tools. Kein String-Parsing, kein Raten.
|
||||
|
||||
Wenn beide vorhanden sind, sagen sie absichtlich zweimal dasselbe: `content` ist für ein Modell, `structured_content` ist für Code. Woher die strukturierte Hälfte kommt und wie du sie steuerst, steht auf der Seite **[Strukturierte Ausgabe](../servers/structured-output.md)**.
|
||||
|
||||
### `is_error`: ob das Tool fehlgeschlagen ist {#is_error-whether-the-tool-failed}
|
||||
|
||||
Ein Tool, das eine Exception auslöst, löst in deinem Client **keine** aus. Es kommt als gewöhnliches Ergebnis mit `is_error=True` zurück.
|
||||
|
||||
!!! check
|
||||
Frag `lookup_book` nach `"Solaris"` (einem Titel, der nicht im Katalog steht), und die Funktion löst
|
||||
`ValueError` aus. Der Aufruf kehrt trotzdem normal zurück:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
Die Meldung der Exception ist in `content` gelandet, wo das **Modell** sie lesen und es erneut versuchen kann. Das
|
||||
ist Absicht: Ein Tool-Fehler ist Teil des Gesprächs, kein Absturz. Sieh dir immer `is_error` an,
|
||||
bevor du `structured_content` vertraust.
|
||||
|
||||
!!! warning
|
||||
`is_error=True` deckt mehr ab als dein eigenes `raise`. Frag nach einem Tool, das der Server gar nicht hat
|
||||
(`call_tool("does_not_exist", {})`), und nichts wird ausgelöst. Du bekommst dieselbe Form zurück,
|
||||
`is_error=True` mit `Unknown tool: does_not_exist` in `content`. Eine `Client`-Methode löst
|
||||
`MCPError` nur aus, wenn der Server mit einem JSON-RPC-**Fehler** statt eines Ergebnisses antwortet, und
|
||||
**[Fehler behandeln](../servers/handling-errors.md)** erklärt, wann ein Server welches davon erzeugt.
|
||||
|
||||
## Ressourcen {#resources}
|
||||
|
||||
Die Ressourcen-Verben kommen paarweise: zwei Wege zum Auflisten, einer zum Lesen.
|
||||
|
||||
```python title="client.py" hl_lines="22-31"
|
||||
--8<-- "docs_src/client/tutorial004.py"
|
||||
```
|
||||
|
||||
* `list_resources()` gibt die **konkreten** Ressourcen zurück, die mit festem URI. Hier: `['catalog://genres']`.
|
||||
* `list_resource_templates()` gibt die **parametrisierten** zurück. Hier: `['catalog://genres/{genre}']`. Es sind zwei verschiedene Listen, weil ein Template erst lesbar ist, wenn du es ausfüllst.
|
||||
* `read_resource(uri)` nimmt einen URI als einfachen `str` und funktioniert mit beiden: Übergib `"catalog://genres/poetry"`, und der Server ordnet ihn dem Template zu.
|
||||
|
||||
`read_resource` gibt `contents` zurück, eine Liste aus `TextResourceContents` oder `BlobResourceContents`. Dieselbe Idee wie beim Tool-Content: mit `isinstance` eingrenzen, dann `.text` (oder `.blob`) lesen.
|
||||
|
||||
Ein Client kann sich auch mitteilen lassen, wann sich eine Ressource ändert. Auf Verbindungen der 2025er-Generation geschieht das über `subscribe_resource(uri)` / `unsubscribe_resource(uri)` – ein Methodenpaar, das `MCPServer` nicht implementiert, sodass der Request auf der 2026-07-28-Leitung (wo es diese Verben nicht mehr gibt) mit `-32601`, *Method not found*, beantwortet wird. Der Ersatz in 2026 ist ein `subscriptions/listen`-Stream, den `MCPServer` *sehr wohl* bedient – `server_capabilities.resources.subscribe` ist dort `True` –, und wie du ihn mit `client.listen(...)` konsumierst, steht auf der Seite **[Abonnements](subscriptions.md)** in diesem Abschnitt.
|
||||
|
||||
## Prompts {#prompts}
|
||||
|
||||
```python title="client.py" hl_lines="15-20"
|
||||
--8<-- "docs_src/client/tutorial005.py"
|
||||
```
|
||||
|
||||
`list_prompts()` sagt dir, was der Server anbietet und was jeder Prompt braucht:
|
||||
|
||||
```python
|
||||
prompt.name # 'recommend'
|
||||
prompt.title # 'Recommend a book'
|
||||
prompt.arguments # [PromptArgument(name='genre', required=True)]
|
||||
```
|
||||
|
||||
`get_prompt(name, arguments)` rendert ihn. Das Argument-Dict ist `str -> str`: Prompt-Argumente sind immer Strings. Das Ergebnis ist `messages`, eine Liste von `PromptMessage`, jeweils mit einer `role` und einem `content`-Block:
|
||||
|
||||
```python
|
||||
message.role # 'user'
|
||||
message.content # TextContent(type='text', text='Recommend one poetry book from the catalog and say why.')
|
||||
```
|
||||
|
||||
Ein Host reicht diese Nachrichten direkt an das Modell weiter. Das ist das ganze Feature.
|
||||
|
||||
## Vervollständigungen {#completions}
|
||||
|
||||
Ein Server mit einem Handler für Vervollständigungen kann Argumente von Prompts und Ressourcen-Templates automatisch vervollständigen, während die Person tippt.
|
||||
|
||||
```python title="client.py" hl_lines="27-31"
|
||||
--8<-- "docs_src/client/tutorial006.py"
|
||||
```
|
||||
|
||||
* `ref` sagt, *welchen* Prompt oder welches Template du ausfüllst: eine `PromptReference` oder eine `ResourceTemplateReference`.
|
||||
* `argument` ist `{"name": ..., "value": ...}`: das Argument und das, was die Person bisher getippt hat.
|
||||
|
||||
Die Antwort steht in `result.completion.values`. Tippe `"p"`, und der Server liefert `['poetry']`. Die Serverseite, und wie ein Handler die *anderen*, bereits ausgefüllten Argumente nutzt, um seine Vorschläge einzugrenzen, steht auf der Seite **[Vervollständigungen](../servers/completions.md)**.
|
||||
|
||||
## Paginierung {#pagination}
|
||||
|
||||
Jede `list_*`-Methode nimmt ein Keyword-Argument `cursor=`, und jedes Ergebnis trägt einen `next_cursor`. Wenn `next_cursor` `None` ist, hast du alles.
|
||||
|
||||
```python title="client.py" hl_lines="22-30"
|
||||
--8<-- "docs_src/client/tutorial007.py"
|
||||
```
|
||||
|
||||
Diese Schleife ist gegenüber jedem Server korrekt. `MCPServer` gibt alles auf einer Seite zurück, also ist `next_cursor` `None` und die Schleife läuft einmal – deshalb schreibt der meiste Code sie nie. Server, die wirklich paginieren, und die Regeln, denen Cursor gehorchen, stehen in **[Paginierung](../advanced/pagination.md)**.
|
||||
|
||||
## In Tests {#in-tests}
|
||||
|
||||
`Client(mcp)` ohne Prozess und ohne Port ist bereits ein Test-Harness für deinen Server.
|
||||
|
||||
Dafür gibt es ein eigenes Konstruktor-Flag: `Client(mcp, raise_exceptions=True)`. Es wirkt nur auf In-Memory-Verbindungen, und **[Testen](../get-started/testing.md)** ist die Seite, die es erklärt und das ganze Muster darum herum aufbaut.
|
||||
|
||||
## Zusammenfassung {#recap}
|
||||
|
||||
* `Client(x)` verbindet sich in-memory mit einem Server-Objekt, über Streamable HTTP mit einem URL-String und über alles andere per Transport.
|
||||
* `async with` ist der ganze Lebenszyklus. Darin sind `server_capabilities` und `protocol_version` bereits gefüllt; `server_info` und `instructions` ebenfalls, wenn der Server sie liefert.
|
||||
* `list_tools()` gibt dir für jedes Tool `name`, `title`, `description` und `input_schema`.
|
||||
* `call_tool()` gibt `content` für das Modell, `structured_content` für deinen Code und `is_error` zurück. Ein Tool, das eine Exception auslöst, ist ein Ergebnis, keine Exception.
|
||||
* `content` ist eine Union von Blocktypen; grenze mit `isinstance` ein, bevor du liest.
|
||||
* `list_resources` / `list_resource_templates` / `read_resource`, `list_prompts` / `get_prompt` und `complete` runden die Verben ab.
|
||||
* Jede `list_*`-Methode nimmt `cursor=`; iteriere, bis `next_cursor` `None` ist.
|
||||
|
||||
Was ein Server vom *Client* anfordern kann und wie du darauf antwortest, steht in **[Client-Callbacks](callbacks.md)**.
|
||||
152
i18n/de/pages/client/oauth-clients.md
Normal file
152
i18n/de/pages/client/oauth-clients.md
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
---
|
||||
translation:
|
||||
sections: [c6899d3892bd9fa0, 79372cff3cc48a88, 63878d29e87c3e73, 13175843d3588af4, e7e2b9fd516f77de, 758f06399b513c1f, a05d7278487d610b]
|
||||
tool: 1
|
||||
---
|
||||
# OAuth-Clients {#oauth-clients}
|
||||
|
||||
Manche MCP-Server sind geschützt. Schickst du ihnen einen Request ohne Token, antworten sie mit `401 Unauthorized`.
|
||||
|
||||
Mit **`OAuthClientProvider`** bekommst du das Token. Das ist überhaupt kein MCP-Objekt. Es ist ein `httpx2.Auth`, der Standard-Hook von httpx2 für „tu etwas mit jedem Request“. Du hängst ihn an einen `httpx2.AsyncClient`, übergibst diesen Client dem Streamable-HTTP-Transport und denkst nicht mehr darüber nach.
|
||||
|
||||
Diese Seite ist die Client-Seite. Wie dein eigener Server ein Token verlangt, steht in **[Autorisierung](../run/authorization.md)**.
|
||||
|
||||
## Der Provider {#the-provider}
|
||||
|
||||
```python title="client.py" hl_lines="44-54"
|
||||
--8<-- "docs_src/oauth_clients/tutorial001.py"
|
||||
```
|
||||
|
||||
Du gibst ihm vier Dinge:
|
||||
|
||||
* `server_url`: der MCP-Endpunkt, mit dem du dich verbindest. Alles Weitere findet der Provider von dort aus selbst heraus.
|
||||
* `client_metadata`: das, was du in das Formular „Anwendung registrieren“ eines Autorisierungsservers eintragen würdest.
|
||||
* `storage`: wo Tokens zwischen den Läufen liegen.
|
||||
* `redirect_handler` und `callback_handler`: die beiden Momente, in denen ein Mensch beteiligt ist.
|
||||
|
||||
Sonst erwähnt nichts in der Datei OAuth. `main()` sieht nie ein Token.
|
||||
|
||||
### Client-Metadaten {#client-metadata}
|
||||
|
||||
`OAuthClientMetadata` ist das echte Registrierungsdokument aus [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591), als Pydantic-Modell.
|
||||
|
||||
Du setzt drei Felder. Die Standardwerte füllen den Rest: `grant_types` ist bereits `["authorization_code", "refresh_token"]` und `response_types` ist bereits `["code"]` – genau der Flow, den dieser Provider ausführt.
|
||||
|
||||
!!! check
|
||||
Weil es ein Pydantic-Modell ist, validiert es, **bevor ein einziges Byte über das Netzwerk geht**.
|
||||
Lass `redirect_uris` weg, und die Konstruktion scheitert sofort mit einem `ValidationError`, der
|
||||
das Feld benennt:
|
||||
|
||||
```text
|
||||
redirect_uris
|
||||
Field required [type=missing, input_value={'client_name': 'Bookshop Agent'}, input_type=dict]
|
||||
```
|
||||
|
||||
Kein Browser geöffnet, keine halbfertige Registrierung auf dem Autorisierungsserver zurückgelassen.
|
||||
|
||||
### Token-Speicherung {#token-storage}
|
||||
|
||||
**`TokenStorage`** ist ein `Protocol` mit vier async-Methoden. Du erbst von nichts; schreib die Methoden, und jede beliebige Klasse ist ein Token-Speicher:
|
||||
|
||||
* `get_tokens` / `set_tokens` halten das `OAuthToken`: Access-Token, Refresh-Token, Ablaufzeit, Scope.
|
||||
* `get_client_info` / `set_client_info` halten die `OAuthClientInformationFull`, die der Autorisierungsserver ausgestellt hat, als der Provider dich registrierte – einschließlich deiner `client_id`.
|
||||
|
||||
Die In-Memory-Variante oben funktioniert. Sie vergisst aber auch alles, wenn der Prozess endet, sodass der nächste Lauf das ganze Prozedere noch einmal durchläuft. Speichere sie in einer Datei oder im Schlüsselbund deiner Plattform, und der nächste Lauf bleibt stumm.
|
||||
|
||||
!!! tip
|
||||
Speichere `client_info`, nicht nur die Tokens. Der Provider registriert sich dynamisch, wenn er
|
||||
beim ersten Mal keine gespeicherte `client_info` findet. Wirfst du sie weg, erzeugst du bei jedem Lauf eine neue Registrierung.
|
||||
|
||||
### Die zwei Handler {#the-two-handlers}
|
||||
|
||||
Der Authorization-Code-Flow braucht genau einmal einen Menschen: Jemand muss sich anmelden und auf „Zulassen“ klicken.
|
||||
|
||||
* **`redirect_handler`** wird mit der fertig gebauten Autorisierungs-URL awaited. `client_id`, `redirect_uri`, `state` und die PKCE-Challenge stecken bereits darin. Deine einzige Aufgabe ist, einen Browser dorthin zu bringen. Eine Desktop-App ruft `webbrowser.open` auf; diese Datei gibt sie aus.
|
||||
* **`callback_handler`** wird als Nächstes awaited. Er wartet, bis die Person wieder auf deiner `redirect_uri` landet, und gibt die Query-Parameter dieses Redirects als `AuthorizationCodeResult` zurück.
|
||||
|
||||
Ein echter Client betreibt auf der Redirect-URI einen kleinen lokalen HTTP-Server, statt `input()` aufzurufen. Die Form ist identisch: weitergeleitet werden, `code`, `state` und `iss` zurückgeben.
|
||||
|
||||
!!! warning
|
||||
Reiche `state` und `iss` genau so durch, wie sie angekommen sind. Der Provider vergleicht `state` mit dem Wert,
|
||||
den er generiert hat, und `iss` mit dem Issuer, den er ermittelt hat, und lehnt eine Abweichung ab. Sie sind die
|
||||
Schutzmaßnahmen gegen CSRF und Server-Verwechslung.
|
||||
|
||||
### In den `Client` {#into-the-client}
|
||||
|
||||
Sieh dir `main()` an. Der Provider kommt an den **httpx2-Client**, der httpx2-Client kommt in `streamable_http_client(url, http_client=...)`, und dieser Transport kommt in `Client`.
|
||||
|
||||
`streamable_http_client` hat kein Keyword `auth=`. Alles auf HTTP-Ebene (Auth, Header, Timeouts, Proxys) gehört auf den `httpx2.AsyncClient`, den du mitbringst. Diese Schichtung steht in **[Client-Transporte](transports.md)**.
|
||||
|
||||
## Was der Provider für dich tut {#what-the-provider-does-for-you}
|
||||
|
||||
Wenn `Client` zum ersten Mal einen Request schickt, antwortet der Server mit `401`. Der Provider übernimmt:
|
||||
|
||||
1. **Discovery.** Er liest den `WWW-Authenticate`-Header, holt die Protected Resource Metadata des Servers von `/.well-known/oauth-protected-resource`, erfährt, welcher Autorisierungsserver diese Ressource schützt, und holt die Metadaten *dieses* Servers.
|
||||
2. **Registrierung.** Nichts im Speicher? Er registriert dich dynamisch mit deiner `OAuthClientMetadata` und speichert das Ergebnis.
|
||||
3. **Autorisierung.** Er generiert das PKCE-Paar und einen `state`, baut die Autorisierungs-URL, awaited deinen `redirect_handler` und awaited dann deinen `callback_handler` für den Code.
|
||||
4. **Austausch.** Er tauscht den Code gegen ein `OAuthToken`, speichert es und wiederholt deinen ursprünglichen Request mit `Authorization: Bearer ...`.
|
||||
|
||||
Danach ist Ruhe. Tokens kommen aus dem Speicher, ein abgelaufenes Access-Token wird mit dem Refresh-Token erneuert, und erst wenn nichts davon klappt, führt er den Flow erneut aus.
|
||||
|
||||
Nichts davon hast du geschrieben. Zwei Keyword-Argumente bleiben übrig (`client_metadata_url` und `validate_resource_url`), und diese Datei braucht keines davon. `client_metadata_url` ist dasjenige, das man kennen sollte; es bekommt unten einen eigenen Abschnitt.
|
||||
|
||||
### Ausprobieren {#try-it}
|
||||
|
||||
Die meisten Beispiele in dieser Dokumentation kannst du mit einem In-Memory-`Client(server)` prüfen. Dieses nicht: Der ganze Sinn des Flows ist ein HTTP-`401`, und zwischen einem In-Memory-Client und seinem Server gibt es kein HTTP.
|
||||
|
||||
Das Repository liefert die Live-Variante mit. `examples/servers/simple-auth/` betreibt einen eigenständigen Autorisierungsserver und einen geschützten MCP-Server; `examples/clients/simple-auth-client/` ist der Client dieser Seite, ausgebaut zu einem kleinen CLI. Sein README enthält die beiden Befehle: Starte die Server, lass den Client gegen sie laufen, und du siehst die vier Schritte vorbeiziehen.
|
||||
|
||||
## Client ID Metadata Documents {#client-id-metadata-documents}
|
||||
|
||||
Die Revision 2026-07-28 der Spezifikation erklärt die dynamische Client-Registrierung für veraltet, zugunsten von **Client ID Metadata Documents** (CIMD). Statt jedem Autorisierungsserver, dem er begegnet, per POST eine frische Registrierung zu schicken, veröffentlicht dein Client ein einziges JSON-Dokument über sich selbst unter einer stabilen HTTPS-URL, und diese URL *ist* seine `client_id`. Der Autorisierungsserver holt das Dokument; der Provider fasst es nie an.
|
||||
|
||||
Das SDK spricht es bereits: Übergib die URL als `client_metadata_url=`, wenn du den Provider erzeugst. Wenn die Metadaten des Autorisierungsservers `client_id_metadata_document_supported: true` ankündigen, überspringt der Provider den `/register`-Request komplett: Die URL geht als `client_id` in den Flow, und es gibt kein `client_secret`. Wenn der Server es nicht ankündigt (die meisten tun das noch nicht) oder du nie eine URL übergibst, fällt der Provider **stillschweigend** auf die dynamische Registrierung zurück, und alles oben funktioniert genau wie beschrieben. Eine gespeicherte `client_info` hat weiterhin Vorrang vor beidem.
|
||||
|
||||
Die URL muss HTTPS sein und einen Pfad haben, der nicht das Wurzelverzeichnis ist; alles andere ist ein `ValueError` bei der Konstruktion, bevor irgendein Netzwerkverkehr stattfindet. Das mitgelieferte `examples/clients/simple-auth-client/` nimmt sie als Umgebungsvariable `MCP_CLIENT_METADATA_URL` entgegen.
|
||||
|
||||
## Maschine zu Maschine {#machine-to-machine}
|
||||
|
||||
Ein nächtlicher Job, ein CI-Schritt, ein anderer Dienst. Es gibt keinen Browser und niemanden, der auf „Zulassen“ klickt. Das ist der **Client-Credentials**-Grant: Du besitzt bereits eine `client_id` und ein `client_secret`, und der Token-Endpunkt ist der ganze Flow.
|
||||
|
||||
`ClientCredentialsOAuthProvider` ist dasselbe `httpx2.Auth`, ohne den Menschen:
|
||||
|
||||
```python title="client.py" hl_lines="4 27-33"
|
||||
--8<-- "docs_src/oauth_clients/tutorial002.py"
|
||||
```
|
||||
|
||||
Was sich geändert hat:
|
||||
|
||||
* Keine `OAuthClientMetadata`, keine Handler. Du übergibst `client_id` und `client_secret`; der Provider baut eine minimale `client_credentials`-Registrierung darum herum und überspringt die dynamische Registrierung komplett.
|
||||
* `scope` ist ein durch Leerzeichen getrennter String, das OAuth-Format auf der Leitung.
|
||||
* Alles danach ist identisch: dasselbe `TokenStorage`, derselbe `httpx2.AsyncClient(auth=...)`, derselbe `streamable_http_client`.
|
||||
|
||||
Standardmäßig reist das Secret als HTTP Basic Auth im Token-Request (`client_secret_basic`). Übergib `token_endpoint_auth_method="client_secret_post"`, um es stattdessen in den Formular-Body zu legen. Manche Autorisierungsserver akzeptieren nur eine der beiden Varianten.
|
||||
|
||||
!!! tip
|
||||
Lies `client_secret` aus der Umgebung oder einem Secret-Manager, nie aus der Versionsverwaltung.
|
||||
|
||||
!!! info
|
||||
Ein weiterer Provider liegt in `mcp.client.auth.extensions.client_credentials`:
|
||||
**`PrivateKeyJWTOAuthProvider`**, für Clients, die sich mit einem JWT statt einem
|
||||
gemeinsamen Secret authentifizieren (`private_key_jwt`, die Variante mit Schlüsselpaar und Workload-Identität). Er folgt
|
||||
demselben Muster: einen erzeugen, auf `auth=` setzen. Dasselbe Modul liefert
|
||||
`SignedJWTParameters` und `static_assertion_provider`, zwei Helfer, die seine Assertion bauen.
|
||||
|
||||
Es gibt noch eine Situation ohne Menschen: Der Client gehört zu einem Unternehmen, dessen Identity Provider – nicht die Person am Host – entscheidet, welche MCP-Server er erreichen darf. Das ist ein anderer Grant mit eigenem Vertrauensmodell und eigener Seite: **[Identity Assertion](identity-assertion.md)**.
|
||||
|
||||
## Wenn es fehlschlägt {#when-it-fails}
|
||||
|
||||
Wenn der OAuth-Flow schiefgeht, löst der Provider einen `OAuthFlowError` aus `mcp.client.auth` aus. Er hat zwei Unterklassen. `OAuthRegistrationError` bedeutet, dass die Registrierung keinen Client ergeben hat, den du verwenden kannst: Der Autorisierungsserver hat die Registrierung abgelehnt, oder er hat dich zwar registriert, aber mit Zugangsdaten, die dieser Flow nicht verwenden kann (zum Beispiel eine Authentifizierungsmethode, die er nicht implementiert). `OAuthTokenError` bedeutet, dass kein Token beschafft werden konnte: Der Token-Endpunkt hat abgelehnt, oder ein gespeicherter Client-Eintrag trägt eine Authentifizierungsmethode, die dieser Client nicht anwenden kann – das wird beim Bauen des Token-Requests gemeldet statt gesendet. Ein einziges `except OAuthFlowError:` deckt Discovery, Registrierung, Autorisierung und Austausch ab.
|
||||
|
||||
Nicht alles ist ein Flow-Fehler. Das Netzwerk kann weiterhin ausfallen; das sind gewöhnliche `httpx2`-Exceptions, und sie werden unverändert durchgereicht.
|
||||
|
||||
## Zusammenfassung {#recap}
|
||||
|
||||
* `OAuthClientProvider` ist ein `httpx2.Auth`. Setze ihn auf einen `httpx2.AsyncClient`, übergib diesen an `streamable_http_client(url, http_client=...)`, und `Client` erfährt nie, dass OAuth stattgefunden hat.
|
||||
* Du lieferst vier Dinge: die Server-URL, eine `OAuthClientMetadata`, ein `TokenStorage` und das Paar aus Redirect- und Callback-Handler.
|
||||
* `TokenStorage` ist ein `Protocol`: vier async-Methoden, keine Basisklasse. Speichere `client_info` ebenso dauerhaft wie die Tokens.
|
||||
* Discovery, Registrierung (dynamisch oder über ein **Client ID Metadata Document**), PKCE, die Prüfungen von `state` und `iss` sowie die Token-Erneuerung sind Aufgabe des Providers, nicht deine.
|
||||
* `ClientCredentialsOAuthProvider` ist die Variante ohne Menschen: `client_id` + `client_secret`, keine Handler, kein Browser.
|
||||
* Jeder OAuth-Fehlschlag ist ein `OAuthFlowError`; `OAuthRegistrationError` und `OAuthTokenError` sind seine Unterklassen.
|
||||
|
||||
Die andere Hälfte dieses Handshakes – wie dein *Server* das Token verlangt – steht in **[Autorisierung](../run/authorization.md)**.
|
||||
87
i18n/de/pages/client/session-groups.md
Normal file
87
i18n/de/pages/client/session-groups.md
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
---
|
||||
translation:
|
||||
sections: [09c857a25a9dc37a, 43bc6a76a243a50e, 0a716022a88768df, 4b7f78042bfcfff7, c112662e61b03315, 58974ba1f489a8b4, d18adbdbb835ea73]
|
||||
tool: 1
|
||||
---
|
||||
# Session-Gruppen {#session-groups}
|
||||
|
||||
Ein `Client` verbindet sich mit einem Server. Echte Anwendungen brauchen oft mehrere (einen Suchserver, einen Datenbankserver, eine interne API) und jonglieren am Ende für jeden davon mit einer Verbindung und einer Tool-Liste.
|
||||
|
||||
**`ClientSessionGroup`** ist ein einziges Objekt, das viele Verbindungen hält und alles, was sie bereitstellen, zu einer einzigen Sicht zusammenführt.
|
||||
|
||||
## Zwei Server {#two-servers}
|
||||
|
||||
Beginne mit zwei gewöhnlichen Servern. Sie haben nichts miteinander zu tun, also haben beide ihr Tool ganz selbstverständlich `search` genannt:
|
||||
|
||||
```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"
|
||||
```
|
||||
|
||||
## Eine Gruppe {#one-group}
|
||||
|
||||
Erzeuge eine `ClientSessionGroup` und rufe **`connect_to_server`** einmal pro Server auf:
|
||||
|
||||
```python title="client.py" hl_lines="10-12"
|
||||
--8<-- "docs_src/session_groups/tutorial003.py"
|
||||
```
|
||||
|
||||
* `connect_to_server` nimmt Transport-Parameter entgegen, kein Server-Objekt: `StdioServerParameters` (aus `mcp`), um einen Subprozess zu starten, oder `StreamableHttpParameters` / `SseServerParameters` (aus `mcp.client.session_group`) für einen Server, der bereits unter einer URL lauscht.
|
||||
* `group.tools` ist ein `dict[str, Tool]` mit den Tools aller verbundenen Server. `group.resources` und `group.prompts` haben dieselbe Form.
|
||||
* `group.call_tool(name, arguments)` schlägt den Namen nach, findet die Session, der er gehört, und leitet den Aufruf weiter. Du gibst nie an, welcher Server gemeint ist.
|
||||
|
||||
!!! check
|
||||
Lege `client.py` neben die beiden Server und führe es aus. Das zweite `connect_to_server` verweigert sich:
|
||||
|
||||
```text
|
||||
mcp.shared.exceptions.MCPError: {'search'} already exist in group tools.
|
||||
```
|
||||
|
||||
Das ist ein `MCPError`, ausgelöst, bevor irgendetwas vom zweiten Server registriert ist. Ein Name muss
|
||||
in der **gesamten** Gruppe eindeutig sein, und zwei Server, die du nicht kontrollierst, kollidieren früher oder später.
|
||||
|
||||
## `component_name_hook` {#component_name_hook}
|
||||
|
||||
Du behebst das in der Gruppe, nicht in den Servern. Übergib eine Funktion von `(name, server_info)`, und die Gruppe wendet sie auf jeden Namen an, den sie registriert:
|
||||
|
||||
```python title="client.py" hl_lines="7-8 15"
|
||||
--8<-- "docs_src/session_groups/tutorial004.py"
|
||||
```
|
||||
|
||||
Führe es erneut aus. `print(sorted(group.tools))` zeigt jetzt beide:
|
||||
|
||||
```text
|
||||
['Library.search', 'Web.search']
|
||||
```
|
||||
|
||||
* Der **Schlüssel** gehört dir. `by_server` hat ihn aus `server_info.name` gebaut, dem Namen, mit dem jeder `MCPServer(...)` erzeugt wurde.
|
||||
* Das `Tool` darin bleibt unverändert: `group.tools["Web.search"].name` ist weiterhin `"search"`, und das ist der Name, den `call_tool` auf die Leitung legt. Das Präfix verlässt deinen Prozess nie.
|
||||
* Es betrifft nicht nur Tools. Die Ressource `hours` der Bibliothek wird als `Library.hours` registriert.
|
||||
|
||||
!!! tip
|
||||
Der Hook läuft auf **jedem** Namen von **jedem** Server, nicht nur bei Konflikten: Es gibt keinen
|
||||
Modus „Präfix nur bei Kollision“. Wähle ein Schema und lass es überall gelten.
|
||||
|
||||
## Server hinzufügen und entfernen {#adding-and-removing-servers}
|
||||
|
||||
`connect_to_server` gibt die `ClientSession` zurück, die es geöffnet hat. Behalte sie, falls du diesen Server jemals wieder loswerden willst: `await group.disconnect_from_server(session)` entfernt seine Tools, Ressourcen und Prompts aus der Gruppe.
|
||||
|
||||
Hältst du bereits eine verbundene `ClientSession` (`Client.session` ist eine), übergib sie an `await group.connect_with_session(server_info, session)`, statt einen neuen Transport zu öffnen. Sie wird genauso zusammengeführt. Die Gruppe schließt nie eine Session, die sie nicht selbst geöffnet hat. `server_info` benennt den Server für die Komponenten-Präfixe; auf einer Verbindung der 2026er-Generation kann `client.server_info` `None` sein (die Identität ist optional), übergib in diesem Fall also deine eigene `Implementation(name=..., version=...)`.
|
||||
|
||||
## Der klassische Handshake {#the-classic-handshake}
|
||||
|
||||
`ClientSessionGroup` baut auf `ClientSession` auf, nicht auf `Client`. Jedes `connect_to_server` führt den klassischen `initialize`-Handshake aus. Es sendet nie die `server/discover`-Probe, die in **[Protokollversionen](../protocol-versions.md)** beschrieben ist. Jeder MCP-Server versteht diesen Handshake, das kostet dich also keinerlei Kompatibilität; es bedeutet nur, dass eine Gruppe den älteren, langsameren Weg zu einem Server nimmt, der es besser könnte.
|
||||
|
||||
## Zusammenfassung {#recap}
|
||||
|
||||
* `ClientSessionGroup` hält viele Server-Verbindungen und führt deren Tools, Ressourcen und Prompts in je ein `dict` zusammen.
|
||||
* `connect_to_server(params)` pro Server. Es nimmt Transport-Parameter entgegen, nie das Server-Objekt oder die URL, die ein `Client` entgegennimmt.
|
||||
* `group.call_tool(name, arguments)` leitet den Aufruf für dich an den zuständigen Server weiter.
|
||||
* Namen müssen in der gesamten Gruppe eindeutig sein; zwei Server mit einem `search`-Tool können nicht ohne Weiteres nebeneinander bestehen.
|
||||
* `component_name_hook=` schreibt jeden registrierten Namen um. Der Dict-Schlüssel ändert sich, der Name auf der Leitung nicht.
|
||||
* `connect_with_session` fügt eine Session hinzu, die du bereits hältst; `disconnect_from_server` entfernt eine.
|
||||
|
||||
Der Handshake, den eine Gruppe spricht (und der schnellere, den ein `Client` bevorzugt), ist Thema von **[Protokollversionen](../protocol-versions.md)**.
|
||||
91
i18n/de/pages/client/subscriptions.md
Normal file
91
i18n/de/pages/client/subscriptions.md
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
---
|
||||
translation:
|
||||
sections: [8f9558e57f29eee1, a88c587739e0465c, 46ebfd5b325ed041, 4d10b00b57ce4bd9, 2cdb0edd1f59b3e2]
|
||||
tool: 1
|
||||
---
|
||||
# Abonnements {#subscriptions}
|
||||
|
||||
Der Katalog eines Servers steht nicht fest. Tools tauchen zur Laufzeit auf, und der Inhalt hinter einem Ressourcen-URI ändert sich. Ein Client erfährt davon über `client.listen(...)`: ein einziger `subscriptions/listen`-Request, dessen Response der Stream *ist*. Er bleibt offen und trägt die Änderungsbenachrichtigungen, die der Client angefordert hat.
|
||||
|
||||
Diese Seite beschreibt das Client-Ende: den Stream öffnen, ihn neben dem Hauptablauf beobachten und mit seinem Ende umgehen. Änderungen veröffentlichen, filtern und die Methode bedienen sind die Server-Seite der Geschichte, erzählt in **[Abonnements](../handlers/subscriptions.md)** unter *Im Handler*. Die Beispiele hier sprechen mit dem dort gebauten Sprint-Board-Server.
|
||||
|
||||
## Den Stream beobachten {#watching-the-stream}
|
||||
|
||||
Ein Abonnement ist ein einziger Kontextmanager. Beim Betreten wird der Request gesendet – mit deinen Keyword-Argumenten als Abonnementfilter – und auf die Bestätigung des Servers gewartet, sodass der Stream bereits live ist, wenn der Block beginnt.
|
||||
|
||||
```python title="client.py" hl_lines="15 18 28"
|
||||
--8<-- "docs_src/subscriptions/tutorial003.py"
|
||||
```
|
||||
|
||||
Die Iteration liefert vier typisierte Events: `ToolsListChanged`, `PromptsListChanged`, `ResourcesListChanged` und `ResourceUpdated(uri=...)`.
|
||||
|
||||
Ein Event sagt, *was* sich geändert hat, nie *wie*. Deshalb ruft `follow_board` `read_resource` und `list_tools` auf: Das Event ist das Stichwort zum erneuten Abrufen. Lies `event.uri`, statt anzunehmen, welche Ressource sich bewegt hat: Ein Filter kann mehrere URIs nennen, und ein Server kann eine Änderung an einer Unterressource einer davon melden.
|
||||
|
||||
Doppelte Events, die auf ihre Verarbeitung warten, fallen zu einem zusammen, und das erneute Abrufen liefert dir trotzdem den aktuellen Stand. Nur identische Events fallen zusammen: Zwei `ResourceUpdated` für verschiedene URIs sind zwei Events.
|
||||
|
||||
Zwei weitere Eigenschaften des Handles:
|
||||
|
||||
* `sub.honored` ist der Filter, den der Server bestätigt hat: ein `SubscriptionFilter` mit den Feldern, die du übergeben hast, lesbar als Attribute (`sub.honored.prompts_list_changed`). `MCPServer` erfüllt jede Art, die du anforderst, und gibt deinen Request daher unverändert zurück. Ein Server, der weniger Arten unterstützt, bestätigt weniger, und eine bestätigte Art kann trotzdem nie ausgelöst werden. Ein Server kann auch den ganzen Request ablehnen, statt ihn zu bestätigen (siehe [Entscheiden, wer beobachten darf](../handlers/subscriptions.md#deciding-who-may-watch) auf der Server-Seite), was als Fehler des Requests ankommt.
|
||||
* `sub.subscription_id` ist die ID des listen-Requests, die auf jeden Frame dieses Streams gestempelt ist. Mehrere Abonnements können gleichzeitig offen sein, jedes anhand seiner eigenen ID demultiplext.
|
||||
|
||||
## Beobachten, ohne zu blockieren {#watching-without-blocking}
|
||||
|
||||
`follow_board` läuft, bis der Server den Stream schließt – was vielleicht nie passiert –, und nimmt allein also dein ganzes Programm in Beschlag. Echte Clients wollen den Beobachter *neben* dem Hauptablauf: Ein Agent ruft Tools auf, während ein Beobachter einen Cache oder eine UI aktuell hält.
|
||||
|
||||
Öffne zuerst das Abonnement, starte dann den Beobachter und mach mit deiner Arbeit weiter.
|
||||
|
||||
=== "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` importiert `BOARD` und `read_board` aus dem ersten Beispiel, das dieses Repo als
|
||||
`tutorial003.py` speichert. Wenn du die gerenderten Dateien nebeneinander als `client.py` und `app.py`
|
||||
ablegst, schreibe stattdessen `from client import BOARD, read_board`. Das Beispiel `watch.py` weiter unten
|
||||
importiert `read_board` auf dieselbe Weise.
|
||||
|
||||
Auf die Reihenfolge kommt es an. Nichts wird erneut abgespielt, ein Event, das veröffentlicht wurde, bevor dein Stream existierte, geht also verloren. Das Betreten von `client.listen(...)` wartet auf die Bestätigung, sodass jede Änderung ab diesem Moment deinen Beobachter erreicht und der Snapshot, den du im Block aufnimmst, keine verpassen kann.
|
||||
|
||||
Requests laufen ungehindert neben einem offenen Stream, aus dem Beobachter-Task oder jedem anderen, auf demselben Client. Weil *doppelte* unverarbeitete Events zusammenfallen, kann ein beschäftigter Hauptablauf ein einziges erneutes Abrufen auslösen statt drei. Unterschiedliche Events fallen nicht zusammen: Ein Filter, der viele URIs nennt, reiht pro URI ein ausstehendes Event ein.
|
||||
|
||||
Um das Beobachten zu beenden, verlässt du den Block: Einen `unsubscribe`-Aufruf gibt es nicht. Das Abbrechen des Tasks, dem der Block gehört, erledigt das für dich, und das SDK bricht den listen-Request so ab, wie der Transport es erwartet: über Streamable HTTP durch Schließen des Streams dieses Requests. Ein Beobachter, der für die Lebensdauer deiner App läuft, kehrt nie von selbst zurück, brich ihn also beim Herunterfahren ab – oder den Scope seiner Task-Gruppe.
|
||||
|
||||
## Streams enden {#streams-end}
|
||||
|
||||
Ein Stream endet auf eine von zwei Arten, beide sind gewöhnlicher Kontrollfluss. Ein geordnetes Schließen durch den Server beendet das `async for`; ein abrupter Abbruch löst `SubscriptionLost` aus.
|
||||
|
||||
Der Unterschied ist diagnostisch, kein Unterschied darin, was als Nächstes zu tun ist: Der Stream ist weg, nichts wurde erneut abgespielt, und ein Beobachter, dem es noch wichtig ist, lauscht erneut und ruft erneut ab.
|
||||
|
||||
```python title="watch.py" hl_lines="16 20"
|
||||
--8<-- "docs_src/subscriptions/tutorial005.py"
|
||||
```
|
||||
|
||||
Server schließen Streams aus eigenen Gründen geordnet, etwa um einen Abonnenten loszuwerden, dessen Rückstand zu groß geworden ist. Ein sauberes Ende ist also kein Signal, mit dem Beobachten aufzuhören. Warte ab (Backoff), bevor du erneut lauschst.
|
||||
|
||||
`SubscriptionLost` hat auch eine lokale Ursache. Der Client hält höchstens 1024 unverarbeitete Events, und ein Verbraucher, der so weit zurückfällt, verliert das Abonnement, statt unbegrenzt zu wachsen. Halte den Rumpf des `async for` kurz und erledige langsame Arbeit anderswo.
|
||||
|
||||
`keep_following` fängt nur `SubscriptionLost` ab. Das Betreten von `listen()` kann außerdem `MCPError` auslösen (die Verbindung ist fehlgeschlagen, oder der Server bedient die Methode nicht), `TimeoutError` (keine Bestätigung kam an) und `ListenNotSupportedError` (eine Verbindung von vor 2026). Entscheide, bei welchen davon dein Beobachter es erneut versuchen sollte: Der letzte heilt nie.
|
||||
|
||||
## Zusammenfassung {#recap}
|
||||
|
||||
* Betritt `async with client.listen(...)`; das Betreten wartet auf die Bestätigung, sodass nichts verpasst wird, was danach veröffentlicht wird.
|
||||
* Iteriere mit `async for event in sub`. Events sind Stichworte zum erneuten Abrufen, nie Payloads.
|
||||
* Öffne das Abonnement, führe dann den Beobachter als Task aus, und Tool-Aufrufe fließen daneben weiter.
|
||||
* Ein sauberes Ende stoppt die Schleife; ein Abbruch löst `SubscriptionLost` aus. So oder so: erneut lauschen, erneut abrufen, vorher abwarten.
|
||||
* Das Verlassen des Blocks ist das Abbestellen.
|
||||
|
||||
Diese Events veröffentlichen, den Filter eingrenzen und über einen Prozess hinaus skalieren sind die Geschichte des Servers: **[Abonnements](../handlers/subscriptions.md)**. Dieselben Events halten auch einen clientseitigen Cache ehrlich, und **[Caching](caching.md)** ist die nächste Seite.
|
||||
128
i18n/de/pages/client/transports.md
Normal file
128
i18n/de/pages/client/transports.md
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
---
|
||||
translation:
|
||||
sections: [9cac816674181eb0, 0700f337babcd4dd, 2bde0dd58cdf00f5, ff7401df479af877, 3d0832f39b0d7059, d4bf7e4479637768, 05e20c0a798860e7]
|
||||
tool: 1
|
||||
---
|
||||
# Client-Transporte {#client-transports}
|
||||
|
||||
Jeder `Client` spricht mit seinem Server über einen **Transport**: das, was die Nachrichten tatsächlich befördert.
|
||||
|
||||
Du konfigurierst nie einen separat. `Client` nimmt ein einziges positionales Argument und leitet den Transport aus dessen Typ ab.
|
||||
|
||||
Die *Server*-Seite jedes Transports (was `mcp.run()` tut und was du bereitstellst) steht in **[Den Server betreiben](../run/index.md)**.
|
||||
|
||||
## Im Speicher {#in-memory}
|
||||
|
||||
Übergib das Server-Objekt selbst:
|
||||
|
||||
```python title="client.py" hl_lines="14"
|
||||
--8<-- "docs_src/client_transports/tutorial001.py"
|
||||
```
|
||||
|
||||
Kein Subprozess, kein Port, keine Bytes auf einer Leitung. Client und Server sind zwei Objekte im selben Prozess, und der Aufruf läuft trotzdem durch die echte Protokollschicht: `search_books` wird genau so aufgelistet, validiert und aufgerufen, wie es über HTTP geschähe.
|
||||
|
||||
Damit ist es zwei Dinge zugleich:
|
||||
|
||||
* **Eine Testumgebung.** Jedes Beispiel in dieser Dokumentation wird so ausgeführt, und die Seite **[Testen](../get-started/testing.md)** baut das ganze Muster darauf auf.
|
||||
* **Eine Embedding-API.** Eine Anwendung, die den Server selbst erzeugt, braucht keinen Netzwerk-Hop, um dessen Tools aufzurufen.
|
||||
|
||||
## Streamable HTTP {#streamable-http}
|
||||
|
||||
Übergib einen URL-String und du bekommst **Streamable HTTP**, den Transport, hinter dem du bereitstellst:
|
||||
|
||||
```python title="client.py" hl_lines="5"
|
||||
--8<-- "docs_src/client_transports/tutorial002.py"
|
||||
```
|
||||
|
||||
Das ist der ganze Produktions-Client. `Client` packt die URL für dich in `streamable_http_client(...)`, auf Basis eines `httpx2.AsyncClient`, der so konfiguriert ist, wie MCP es braucht: `follow_redirects=True`, ein Timeout von 30 Sekunden für connect/write/pool und ein Read-Timeout von 300 Sekunden, weil der Server einen Response-Stream offen halten kann.
|
||||
|
||||
!!! check
|
||||
Ein `Client`, den du erzeugt hast, ist **nicht** verbunden. Das Erzeugen wählt nur den Transport;
|
||||
erst `async with` öffnet ihn. Greifst du vor dem Eintreten auf die Verbindung zu, sagt dir das SDK das:
|
||||
|
||||
```text
|
||||
RuntimeError: Client must be used within an async context manager
|
||||
```
|
||||
|
||||
Nichts wurde aufgelöst, abgerufen oder gestartet, als du `Client("http://...")` geschrieben hast. Diese Zeile kostet nichts.
|
||||
|
||||
### Einen eigenen `httpx2.AsyncClient` mitbringen {#bring-your-own-httpx2asyncclient}
|
||||
|
||||
Sobald du einen `Authorization`-Header, ein Cookie, einen Proxy, mTLS oder ein anderes Timeout brauchst, baust du den `httpx2.AsyncClient` selbst und übergibst ihn an `streamable_http_client`:
|
||||
|
||||
```python title="client.py" hl_lines="8-14"
|
||||
--8<-- "docs_src/client_transports/tutorial003.py"
|
||||
```
|
||||
|
||||
Zwei Dinge fallen auf:
|
||||
|
||||
* Der `httpx2.AsyncClient` gehört dir, also betrittst und verlässt **du** ihn. Das SDK schließt nie einen Client, den es nicht selbst erzeugt hat.
|
||||
* `streamable_http_client(url, http_client=...)` gibt einen Transport zurück, und `Client(transport)` nimmt ihn an wie alles andere auch.
|
||||
|
||||
Eine Anmerkung zu TLS: `httpx2` prüft Zertifikate gegen den Trust Store des Betriebssystems (über
|
||||
[`truststore`](https://pypi.org/project/truststore/)), nicht gegen eine mitgelieferte CA-Liste. In einer Umgebung ohne
|
||||
nutzbaren System-CA-Store (manche minimalen Container) setzt du die Standard-Umgebungsvariablen `SSL_CERT_FILE`/`SSL_CERT_DIR`
|
||||
oder übergibst deinem `httpx2.AsyncClient` ein explizites `verify=ssl_context`
|
||||
(Hintergrund in
|
||||
[`httpx` und `httpx-sse` durch `httpx2` ersetzt](../migration.md#httpx-and-httpx-sse-replaced-by-httpx2)).
|
||||
|
||||
!!! warning
|
||||
`streamable_http_client` nahm früher `headers=` und `timeout=` direkt entgegen. Das tut er nicht mehr:
|
||||
seine einzigen Parameter sind `url`, `http_client` und `terminate_on_close`. Greifst du aus
|
||||
Gewohnheit zu `headers=`, bekommst du:
|
||||
|
||||
```text
|
||||
TypeError: streamable_http_client() got an unexpected keyword argument 'headers'
|
||||
```
|
||||
|
||||
Alles, was mit HTTP zu tun hat, lebt jetzt auf dem einen `httpx2.AsyncClient`, den du übergibst.
|
||||
|
||||
!!! info
|
||||
`httpx2` behält die vertraute `httpx`-API bei. Wenn du `httpx` kennst, weißt du hier also bereits, wie Auth,
|
||||
Proxys, Event-Hooks, Retries und Verbindungslimits gehen. Das SDK fügt nichts hinzu und nimmt
|
||||
nichts weg. Hier dockt auch OAuth an:
|
||||
`httpx2.AsyncClient(auth=OAuthClientProvider(...))`. Der ganze Ablauf steht in **[OAuth-Clients](oauth-clients.md)**.
|
||||
|
||||
## stdio {#stdio}
|
||||
|
||||
Ein **stdio**-Server ist ein Subprozess. Der Client startet ihn, schreibt JSON-RPC in seine stdin und liest JSON-RPC aus seiner stdout. So betreibt ein Desktop-Host einen Server auf deinem Rechner: Ein Host *ist* dieser Code plus eine UI, und **[Mit einem echten Host verbinden](../get-started/real-host.md)** zeigt dieselbe Beziehung von der Seite des Hosts, als Konfigurationsdatei.
|
||||
|
||||
Beschreibe den Prozess mit `StdioServerParameters`, mach daraus mit `stdio_client` einen Transport und übergib *den* an `Client`:
|
||||
|
||||
```python title="client.py" hl_lines="4-8 12"
|
||||
--8<-- "docs_src/client_transports/tutorial004.py"
|
||||
```
|
||||
|
||||
`Client` akzeptiert das Parameter-Objekt allein nicht. `StdioServerParameters` ist Konfiguration; `stdio_client(server)` ist der Transport, der weiß, wie er daraus einen Prozess startet. Immer einpacken.
|
||||
|
||||
Beim Verlassen des `async with`-Blocks wird auch der Subprozess beendet: stdin schließen, warten, abschießen, falls er hängen bleibt. Du räumst ihn nie selbst auf.
|
||||
|
||||
!!! warning
|
||||
Der Kindprozess erbt **nicht** deine Umgebung. Er bekommt eine minimale Allow-List (`HOME`, `LOGNAME`,
|
||||
`PATH`, `SHELL`, `TERM` und `USER` auf POSIX), damit nichts Sensibles in einen Prozess durchsickert, den du
|
||||
vielleicht nicht selbst geschrieben hast.
|
||||
|
||||
Ein Server, der einen API-Key braucht, findet ihn dort nicht. Übergib ihn explizit mit `env=`; diese
|
||||
Variablen werden über die Allow-List gelegt. Genau das tut `BOOKSHOP_API_KEY` oben.
|
||||
|
||||
## SSE {#sse}
|
||||
|
||||
`sse_client(url)` aus `mcp.client.sse` ist der HTTP-Transport, den Streamable HTTP abgelöst hat. Pack ihn genauso ein, `Client(sse_client("http://localhost:8000/sse"))`, um mit einem Server zu sprechen, der ihn noch verwendet – und bau nichts Neues darauf.
|
||||
|
||||
## Das `Transport`-Protokoll {#the-transport-protocol}
|
||||
|
||||
Für `Client` ist alles oben Genannte dasselbe.
|
||||
|
||||
Ein **Transport** ist ein beliebiger asynchroner Kontextmanager, der ein `(read, write)`-Paar von Nachrichten-Streams liefert: formal das `Transport`-Protokoll in `mcp.client`. `Client` löst sein Argument nach Typ auf: Ein Server-Objekt verbindet im Prozess, ein `str` wird zu `streamable_http_client(url)`, und alles andere wird direkt als Transport betreten. Diese letzte Regel ist der Grund, warum `stdio_client(...)`, `streamable_http_client(...)` und `sse_client(...)` alle in denselben Platz passen – und warum du deinen eigenen schreiben kannst.
|
||||
|
||||
## Zusammenfassung {#recap}
|
||||
|
||||
* `Client(mcp)` (das Server-Objekt) verbindet im Speicher. Nutze es für Tests und zum Einbetten.
|
||||
* `Client("http://.../mcp")` (eine URL) verbindet über Streamable HTTP, den Produktions-Transport.
|
||||
* Header, Auth, Proxys und Timeouts gehören auf einen `httpx2.AsyncClient`, den du an `streamable_http_client(url, http_client=...)` übergibst. Es gibt kein Keyword `headers=`.
|
||||
* stdio ist `Client(stdio_client(StdioServerParameters(...)))`, nie das Parameter-Objekt allein.
|
||||
* Der Subprozess bekommt eine Umgebung per Allow-List, nicht deine; `env=` ergänzt sie.
|
||||
* Ein Transport ist alles, womit du `async with x as (read, write)` schreiben kannst. Alles, was weder Server-Objekt noch URL ist, reicht `Client` direkt an dieses Protokoll weiter.
|
||||
* Das Erzeugen eines `Client` wählt den Transport. `async with` öffnet ihn.
|
||||
|
||||
Sobald der Transport offen ist, müssen sich beide Seiten auf eine Protokollversion einigen. Normalerweise denkst du nie darüber nach; wenn doch, ist **[Protokollversionen](../protocol-versions.md)** die richtige Seite.
|
||||
Loading…
Reference in a new issue