docs: publish translated docs in twelve languages and the tool that maintains them (#3280)

This commit is contained in:
Max 2026-08-14 17:07:34 +01:00 • committed by GitHub
commit 52ad0a8876
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 89737 additions and 54 deletions

145
i18n/de/pages/run/asgi.md Normal file
View file

@ -0,0 +1,145 @@
---
translation:
sections: [1062ef792791488a, 4be2b831547184a9, 374b049e770385f2, b72f6947089e6de0, b172c9db7831bb31, 70b9ece244ca1b0c, cba78e052898c3f6, f06bdb541cb0b469, fb82d526320b7cc3]
tool: 1
---
# In eine bestehende App einbinden {#add-to-an-existing-app}
`mcp.run("streamable-http")` startet einen Webserver für dich. Manchmal willst du das nicht: Dein MCP-Server ist ein Teil einer größeren Webanwendung, oder du hast bereits ein ASGI-Deployment.
Dafür gibt `mcp.streamable_http_app()` eine **Starlette-Anwendung** zurück.
Eine Starlette-App ist eine ASGI-App. Alles, was ASGI hosten kann (uvicorn, Hypercorn, ein anderes Starlette, FastAPI), kann also auch deinen MCP-Server hosten.
## Die App {#the-app}
```python title="server.py" hl_lines="12"
--8<-- "docs_src/asgi/tutorial001.py"
```
`app` ist eine ganz normale ASGI-Anwendung. Übergib sie einem beliebigen ASGI-Server:
```console
uvicorn server:app
```
Der MCP-Endpunkt liegt unter `/mcp`, ein Client verbindet sich also mit `http://127.0.0.1:8000/mcp`.
Die App bringt bereits zwei Dinge mit:
* Eine Route, `/mcp`: den Streamable-HTTP-Endpunkt.
* Einen **Lifespan** (Start- und Stopp-Phase des Servers), der `mcp.session_manager` startet – das Objekt, dem die Hintergrundarbeit jeder aktiven Session gehört.
Betreibst du die App für sich allein (`uvicorn server:app`), musst du über keines von beiden nachdenken.
!!! tip
`streamable_http_app()` nimmt dieselben Keyword-Argumente wie `mcp.run("streamable-http", ...)`,
abzüglich `port`: Der Port gehört dem, was die App ausliefert. `host` wird weiterhin akzeptiert,
bindet hier aber nichts; **[Bereitstellen und skalieren](deploy.md)** erklärt, was es tatsächlich steuert.
**[Den Server betreiben](index.md)** behandelt die Optionen selbst.
`mcp.sse_app()` macht dasselbe für den abgelösten SSE-Transport.
## Nur localhost, bis du etwas anderes sagst {#localhost-only-until-you-say-otherwise}
Ohne weitere Konfiguration beantwortet die App **nur** Requests an localhost. `streamable_http_app()`
kann nicht wissen, hinter welchem Hostnamen sie ausgeliefert wird, also aktiviert sie den Schutz vor DNS-Rebinding mit der
sichersten möglichen Allowlist; auf deinem Rechner ist das genau richtig. Hinter einem echten Hostnamen bereitgestellt
heißt das: **Jeder Request wird mit `421 Misdirected Request` abgelehnt**, bis du
`transport_security=` eine Allowlist dessen übergibst, was du tatsächlich auslieferst. Nichts von dem, was du gebaut hast, wird
vorher überhaupt gefragt. Diese Allowlist – und alles andere zwischen einer funktionierenden App und einem echten Hostnamen –
steht in **[Bereitstellen und skalieren](deploy.md)**.
## Die App mounten {#mounting-it}
Sobald der MCP-Server *Teil* einer größeren Anwendung ist, steckst du die App in einen `Mount`. Und sobald du das tust, wird der Lifespan zu deinem Problem:
```python title="server.py" hl_lines="18-21 25-26"
--8<-- "docs_src/asgi/tutorial002.py"
```
* `Mount("/", ...)` plus der Standardpfad `/mcp` lässt den Endpunkt unter `/mcp`. Starlette probiert die Routen der Reihe nach durch, und `Mount("/")` passt auf **jeden** Pfad, deshalb stehen deine eigenen Routen in der Liste *davor*. Alles dahinter ist unerreichbar.
* Die Funktion `lifespan` betritt `mcp.session_manager.run()` für die Lebensdauer der **Host**-App. Das ist die Zeile, die alle vergessen.
* `mcp.session_manager` existiert erst, *nachdem* `streamable_http_app()` aufgerufen wurde. Deshalb werden die Routen auf Modulebene gebaut und der Manager wird erst im Lifespan angefasst.
Starlettes `Host`-Route funktioniert genauso: Ersetze `Mount("/", ...)` durch `Host("mcp.example.com", ...)`, um nach Hostname statt nach Pfad zu routen. Die Lifespan-Regel ändert sich nicht, und die zur Transport-Security auch nicht. Eine `Host("mcp.example.com", ...)`-Route empfängt nur Requests an genau diesen Hostnamen, aber die eigene Host-Allowlist des Transports (**[Bereitstellen und skalieren](deploy.md)**) läuft trotzdem zuerst. Ohne `"mcp.example.com"` darin beantwortet diese Route jeden einzelnen davon mit einem `421`.
!!! warning "Der Lifespan gehört der Host-App"
`streamable_http_app()` hängt `session_manager.run()` in den Lifespan des Starlette ein, das es
zurückgibt, aber **der Lifespan einer gemounteten Unteranwendung läuft nie**. Mounte die App, und dieser
eingebaute Lifespan ist toter Code. Welche App auch immer ganz oben in deinem ASGI-Stack sitzt, muss
`mcp.session_manager.run()` in ihrem eigenen Lifespan betreten.
!!! check
Lösche die Zeile `lifespan=lifespan` und starte den Server. Er startet. Die Route wird aufgelöst.
Dann schlägt der erste Request an `/mcp` fehl mit:
```text
RuntimeError: Task group is not initialized. Make sure to use run().
```
Nichts startet den Session-Manager außer seinem `run()`.
## Zwei Server, eine App {#two-servers-one-app}
Jeder `MCPServer` ist eine eigene App mit eigenem Session-Manager. Mounte so viele, wie du willst; betritt jeden Manager aus dem einen Host-Lifespan heraus:
```python title="server.py" hl_lines="27-30 35-36"
--8<-- "docs_src/asgi/tutorial003.py"
```
* `AsyncExitStack` betritt beide Manager; sie starten gemeinsam und fahren in umgekehrter Reihenfolge herunter.
* Die Endpunkte sind `/notes/mcp` und `/tasks/mcp`: das Mount-Präfix plus der Standardpfad.
## Den Pfad ändern {#changing-the-path}
Das abschließende `/mcp` ist `streamable_http_path`. Setze es auf `"/"`, und das Mount-Präfix wird zum gesamten öffentlichen Pfad:
```python title="server.py" hl_lines="25"
--8<-- "docs_src/asgi/tutorial004.py"
```
Jetzt verbinden sich Clients mit `/notes`, nicht mit `/notes/mcp`.
## CORS für Browser-Clients {#cors-for-browser-clients}
Ein browserbasierter Client braucht zwei Erlaubnisse von dir: seine MCP-Request-Header zu **senden** und den einen zu **lesen**, den MCP zurückschickt. Beides ist CORS-Konfiguration in der Host-App, und die Transport-Security-Allowlist von oben muss damit übereinstimmen:
```python title="server.py" hl_lines="27-30 33 35-49"
--8<-- "docs_src/asgi/tutorial005.py"
```
* `allow_headers` ist die Hälfte, die alle vergessen. Ein Browser schickt für jeden MCP-Request einen **Preflight**, weil `Content-Type: application/json` und die `Mcp-*`-Request-Header nicht auf der CORS-Safelist stehen, und ein Header, den der Preflight nicht gewährt, ist ein Request, den der Browser nie sendet. (`allow_headers=["*"]` funktioniert auch: Starlette beantwortet einen Preflight mit allem, wonach er gefragt hat.)
* `expose_headers=["Mcp-Session-Id"]` ist die Lese-Hälfte. Streamable HTTP gibt die Session-ID in diesem Response-Header zurück, und Browser verbergen Response-Header vor JavaScript, solange CORS sie nicht namentlich freigibt. Ohne das kann der Client seinen zweiten Request nie stellen.
* `allow_origins` ist deine Entscheidung, nicht die von MCP. Sei präzise und spiegle es oben in `allowed_origins=`: Der Browser setzt CORS durch, aber der Server prüft `Origin` selbst, und ein Origin, dem der Transport nicht vertraut, bekommt auch nach einem sauberen Preflight ein `403`.
* `allow_methods` listet die drei Methoden auf, die Streamable HTTP verwendet: `POST` zum Senden von Nachrichten, `GET` zum Öffnen des Streams vom Server zum Client, `DELETE` zum Beenden der Session.
## Eigene Routen {#custom-routes}
`@mcp.custom_route()` registriert einen einfachen HTTP-Endpunkt auf derselben App – für die Dinge, die jeder bereitgestellte Dienst braucht und die nichts mit MCP zu tun haben: einen Health-Check, einen OAuth-Callback.
```python title="server.py" hl_lines="15-17"
--8<-- "docs_src/asgi/tutorial006.py"
```
* Der Handler ist reines Starlette: eine `async`-Funktion von `Request` nach `Response`.
* `streamable_http_app()` sammelt jede eigene Route ein. `app.routes` ist jetzt `/mcp` und `/health`.
* `GET /health` antwortet mit `{"status": "ok"}`, weit und breit kein MCP.
!!! warning
Eigene Routen sind **nie authentifiziert**, selbst wenn der Rest des Servers es ist. Das ist
Absicht: Health-Checks und OAuth-Callbacks müssen erreichbar sein, bevor irgendein Token existiert.
Lege nichts Vertrauliches dahinter.
## Zusammenfassung {#recap}
* `mcp.streamable_http_app()` gibt eine Starlette-App mit einer Route zurück, `/mcp`. Jeder ASGI-Server kann sie betreiben.
* Ohne weitere Konfiguration beantwortet die App nur Requests an localhost, und hinter einem echten Hostnamen lehnt sie alles mit einem `421` ab, bis du `transport_security=` eine Allowlist übergibst. Das gehört zu **[Bereitstellen und skalieren](deploy.md)**, ebenso wie der Rest des Wegs in die Produktion.
* `Mount` (oder `Host`) steckt sie in eine größere Starlette- oder FastAPI-App.
* **Mounten deaktiviert den eingebauten Lifespan.** Der Lifespan der Host-App muss `mcp.session_manager.run()` betreten, sonst schlägt der erste Request fehl.
* Mehrere Server in einer App heißt mehrere Mounts und ein Lifespan, der jeden Session-Manager betritt.
* `streamable_http_path="/"` verschiebt den Endpunkt auf das Mount-Präfix selbst.
* Browser-Clients brauchen CORS: `allow_headers` für die `Mcp-*`-Request-Header, `expose_headers=["Mcp-Session-Id"]` für die Response.
* `@mcp.custom_route()` fügt einfache, nicht authentifizierte HTTP-Endpunkte neben `/mcp` hinzu.
Sobald der Server unter einer echten URL erreichbar ist, verbindet sich **[Der Client](../client/index.md)** über diese URL mit ihm statt über ein Server-Objekt.

View file

@ -0,0 +1,130 @@
---
translation:
sections: [d62c13457fc4a534, 80e73abaca6e0652, d1dc4c54cd00ec9c, 14ad3bc7904036bb, 5225f127bc1b9c77, fe1626fdd5aad1da, 4556cb7ea1a04a31]
tool: 1
---
# Autorisierung {#authorization}
Über Streamable HTTP ist dein MCP-Server ein ganz gewöhnlicher Webdienst, und du schützt ihn so, wie du jeden Webdienst schützt: mit OAuth-2.1-Bearer-Tokens.
In der Sprache von OAuth ist dein Server ein **Resource Server**. Er meldet nie jemanden an und stellt nie ein Token aus. Er tut genau eine Sache: Er sieht sich bei jedem Request den `Authorization`-Header an und entscheidet, ob das Token darin gültig ist.
Diese Seite behandelt die Server-Seite. Ein Client, der deinen Authorization Server findet und das Token holt, steht unter **[OAuth-Clients](../client/oauth-clients.md)**.
## Die drei Beteiligten {#the-three-parties}
* Der **Authorization Server** meldet Personen an und stellt Access Tokens aus. Den schreibst du nicht. Das ist dein Identity Provider (Auth0, Keycloak, Entra, dein eigener).
* Der **Resource Server** ist dein MCP-Server. Er prüft das Token bei jedem Request.
* Der **Client** findet heraus, welchem Authorization Server du vertraust, holt sich dort ein Token und schickt es dir als `Authorization: Bearer <token>` zurück.
Das ist das ganze Dreieck. Alles auf dieser Seite betrifft den mittleren Punkt.
## Ein Token-Verifier {#a-token-verifier}
Das SDK hat keine Meinung dazu, wie ein gültiges Token aussieht. Das sagst du ihm, indem du **`TokenVerifier`** implementierst:
```python title="server.py" hl_lines="12-14 19-24"
--8<-- "docs_src/authorization/tutorial001.py"
```
* `TokenVerifier` ist ein Protokoll mit einer einzigen asynchronen Methode. `verify_token` bekommt das rohe Token aus dem `Authorization`-Header und gibt ein **`AccessToken`** zurück, wenn es gültig ist, und `None`, wenn nicht. Mehr gibt es nicht zu implementieren.
* Dieser hier schlägt das Token in einer Tabelle nach. Ein echter prüft eine JWT-Signatur oder ruft den Token-Introspection-Endpunkt des Authorization Servers auf. Dieser Code gehört dir; das SDK ruft ihn nur auf.
* `token_verifier=` und `auth=` treten immer gemeinsam auf. Übergibst du das eine ohne das andere, löst `MCPServer(...)` einen `ValueError` aus, bevor auch nur ein Request bedient wird.
`AuthSettings` ist das öffentliche Gesicht deines Resource Servers:
* `issuer_url`: der Authorization Server, der deine Tokens ausstellt.
* `resource_server_url`: die öffentliche URL dieses MCP-Endpunkts. Sie benennt, für *welche* Ressource ein Token gilt, und unter ihr liegt das Discovery-Dokument.
* `required_scopes`: jedes Token muss alle davon tragen.
!!! tip
`examples/servers/simple-auth/` im SDK-Repository enthält einen `IntrospectionTokenVerifier`, der den
[RFC-7662](https://datatracker.ietf.org/doc/html/rfc7662)-Endpunkt eines echten Authorization Servers aufruft. Diese Form haben die meisten Verifier in Produktion.
## Was du über HTTP bekommst {#what-you-get-over-http}
Autorisierung lebt in HTTP-Headern, es gibt sie also nur auf den HTTP-Transporten. Betreibe sie auf dem, den du bereitstellst: `mcp.run(transport="streamable-http")` legt sie auf `http://127.0.0.1:8000/mcp`, und alles Weitere steht in **[Den Server betreiben](index.md)**. Die App hat jetzt zwei Routen:
```text
/mcp
/.well-known/oauth-protected-resource/mcp
```
Du hast ein Tool registriert. Die zweite Route stammt vom SDK.
### Discovery {#discovery}
Schick ein `GET` an diesen Well-Known-Pfad, und du bekommst **Protected Resource Metadata nach [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728)**, direkt aus deinen `AuthSettings` gebaut:
```json
{
"resource": "http://127.0.0.1:8000/mcp",
"authorization_servers": ["https://auth.example.com/"],
"scopes_supported": ["notes:read"],
"bearer_methods_supported": ["header"]
}
```
Über dieses Dokument findet ein Client, der noch nie von deinem Server gehört hat, den Weg hinein: Er liest `authorization_servers` und holt sich dort ein Token. Nichts davon hast du geschrieben.
!!! check
Ruf `/mcp` ohne Token auf (oder mit einem, für das dein Verifier `None` zurückgegeben hat), und der Request wird
an der Tür abgewiesen:
```text
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="Authentication required", resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp"
{"error": "invalid_token", "error_description": "Authentication required"}
```
Nichts wurde geparst, kein Tool ist gelaufen. Und der `resource_metadata`-Verweis in `WWW-Authenticate`
macht Discovery automatisch: 401 -> Metadaten-Dokument -> Authorization Server -> Token -> erneuter Versuch.
!!! warning
Nichts davon schützt `stdio`. Eine Pipe hat keinen `Authorization`-Header, also wird `token_verifier` dort nie
befragt. Die Sicherheitsgrenze eines `stdio`-Servers ist der Prozess, der ihn gestartet hat. Dasselbe
gilt für den In-Memory-`Client(mcp)`, den du in Tests verwendest: Er verbindet sich direkt mit dem Server-Objekt
und überspringt die HTTP-Schicht, Autorisierung eingeschlossen.
## Die Identität des Aufrufers {#the-callers-identity}
In jedem Handler ist **`get_access_token()`** das `AccessToken`, das dein Verifier für den aktuellen Request zurückgegeben hat:
```python title="server.py" hl_lines="4 32-35"
--8<-- "docs_src/authorization/tutorial002.py"
```
* Es funktioniert in Tools, Ressourcen und Prompts, und du musst nichts herumreichen: Die Auth-Middleware speichert es pro Request in einer Context-Variablen.
* Du bekommst **dasselbe Objekt zurück, das dein Verifier gebaut hat**: `client_id`, `scopes`, `subject`, `expires_at` und alle zusätzlichen `claims`, die du angehängt hast. Das ist der Ansatzpunkt für Regeln pro Tool: Lies die Scopes und lehne ab.
* Außerhalb eines authentifizierten HTTP-Requests gibt es `None` zurück. In-Memory und über `stdio` ist es immer `None`.
Ruf `whoami` mit `Authorization: Bearer alice-token` auf, und das Modell liest:
```text
alice (scopes: notes:read)
```
## Die Hälfte, die das SDK nicht übernimmt {#the-half-the-sdk-doesnt-do}
Das SDK gibt dir die Resource-Server-Hälfte: prüfen, bekanntmachen, ablehnen. Es gibt dir keine Login-Seite, keinen Consent-Screen und kein Token.
Um alle drei Beteiligten in Bewegung zu sehen, starte `examples/servers/simple-auth/` aus dem SDK-Repository (ein kleiner Authorization Server und ein Resource Server, genau wie auf dieser Seite eingerichtet) und richte dann `examples/clients/simple-auth-client/` darauf, um den kompletten Ablauf aus Discovery und Token-Abruf zu sehen.
!!! info
Es gibt ein zweites Konstruktor-Argument, `auth_server_provider=`, das einen vollständigen Authorization
Server in deinen MCP-Server einbettet. Es stammt aus der Zeit vor der AS/RS-Trennung, um die herum die
MCP-Autorisierungsspezifikation gebaut ist. Neue Server sollten nicht danach greifen.
Ein Authorization Server kann statt einer Person, die sich durch einen Consent-Screen klickt, auch die signierte Assertion eines Unternehmens-Identity-Providers akzeptieren, und das SDK unterstützt beide Seiten dieses Austauschs. Der Grant und der Client, der ihn vorlegt, stehen unter **[Identity Assertion](../client/identity-assertion.md)**.
## Zusammenfassung {#recap}
* Über Streamable HTTP ist dein Server ein OAuth-2.1-**Resource-Server**: Er prüft Tokens, er stellt nie welche aus.
* `TokenVerifier` ist die gesamte Integrationsfläche: eine asynchrone Methode, Token rein, `AccessToken | None` raus.
* `token_verifier=` und `auth=AuthSettings(issuer_url=..., resource_server_url=..., required_scopes=[...])` treten immer gemeinsam auf.
* Das SDK veröffentlicht Protected Resource Metadata nach [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) unter `/.well-known/oauth-protected-resource/...` und beantwortet nicht authentifizierte Requests mit einer 401, deren `WWW-Authenticate`-Header darauf zeigt. Das ist die ganze Discovery-Geschichte.
* `get_access_token()` in jedem Handler sagt dir, wer aufruft.
* Autorisierung ist eine HTTP-Angelegenheit. `stdio` und der In-Memory-Client bekommen sie nie zu sehen.
Die Client-Hälfte (deinen Authorization Server finden und das Token für dich holen) steht unter **[OAuth-Clients](../client/oauth-clients.md)**. Und ein Client, der eine Identität *behauptet*, statt eine Person danach zu fragen, steht unter **[Identity Assertion](../client/identity-assertion.md)**.

179
i18n/de/pages/run/deploy.md Normal file
View file

@ -0,0 +1,179 @@
---
translation:
sections: [28221886b198784f, f88ea1f1614f3a1d, ce926d686730b6d0, 3be24f8ad8bb5ab9, 3fad24032b2224ff, f25a7f860e579ecb, e758745df6fb7b0a]
tool: 1
---
# Bereitstellen und skalieren {#deploy-scale}
Dein Server läuft. Jetzt braucht er einen echten Hostnamen und mehr als einen Worker dahinter.
Fast nichts davon ist Sache von MCP. Du bringst den ASGI-Server, den Prozessmanager und den Load Balancer mit. Diese Seite enthält die kurze Liste der Dinge, die tatsächlich Sache von MCP *sind*: eine Einstellung, an der jedes Deployment hängt, und die zwei Stellen, an denen „mehr als ein Worker“ ändert, was das SDK tut.
## Vor allem anderen: die Host-Allowlist {#before-anything-else-the-host-allowlist}
`streamable_http_app()` kann nicht wissen, hinter welchem Hostnamen es einmal ausgeliefert wird, und nimmt deshalb die sicherste Antwort an: localhost. Ohne `transport_security=` schaltet die App den **DNS-Rebinding-Schutz** ein und akzeptiert einen Request nur, wenn sein `Host`-Header `127.0.0.1:<port>`, `localhost:<port>` oder `[::1]:<port>` lautet. Der `Origin`-Header muss, wenn es einen gibt, die `http://`-Form desselben sein. Auf deinem Rechner ist das genau richtig: Es verhindert, dass eine bösartige Webseite deinen lokalen Server über einen DNS-Namen steuert, den sie auf `127.0.0.1` umgebogen hat.
Hinter einem echten Hostnamen bereitgestellt, weist genau dieser Standard **jeden Request** ab, bis du etwas anderes festlegst. Die Prüfung läuft, bevor irgendetwas MCP-Förmiges an die Reihe kommt – nichts von dem, was du gebaut hast, wird überhaupt gefragt:
```text
421 Misdirected Request Invalid Host header the Host is not in the allowlist
403 Forbidden Invalid Origin header the Origin is not in the allowlist
```
`transport_security=` ist die Lösung. Setze auf die Allowlist, was du tatsächlich auslieferst:
```python title="server.py" hl_lines="2 13-17"
--8<-- "docs_src/deploy/tutorial001.py"
```
* Einträge in `allowed_hosts` sind exakte Strings: `"mcp.example.com"` passt auf einen `Host`-Header ohne Port und `"mcp.example.com:*"` auf jeden Port. Führe beide auf.
* `allowed_origins` spielt nur für Browser eine Rolle, weil sonst nichts `Origin` sendet. Es ist das serverseitige Gegenstück zur CORS-Konfiguration in **[In eine bestehende App einbinden](asgi.md)**.
* Hinter einem Reverse Proxy, der den `Host`-Header ohnehin kontrolliert, ist es die ehrliche Konfiguration, die Prüfung abzuschalten: `TransportSecuritySettings(enable_dns_rebinding_protection=False)`.
* Ein `host=`, das nicht localhost ist (zum Beispiel `host="mcp.example.com"`), setzt diesen Hostnamen **nicht** auf die Allowlist. Es verhindert nur, dass der localhost-Standard den Schutz scharf schaltet – damit wird jeder Host und jeder Origin akzeptiert. Sag stattdessen mit `transport_security=` ausdrücklich, was du meinst.
!!! check
Lösche das Argument `transport_security=security` und stelle die App trotzdem bereit. Sie startet, `/mcp`
wird geroutet, und jeder Request (auch von einem schlichten `curl`) kommt so zurück:
```text
HTTP/1.1 421 Misdirected Request
Invalid Host header
```
Diese Worte findest du auf der Client-Seite nicht. Ein `421` ist eine HTTP-Response im Klartext, kein
JSON-RPC-Fehler; der MCP-Client löst deshalb einen generischen Transportfehler aus. Der Hostname, der
ihm nicht gefiel, taucht nur im Log des **Servers** auf, als einzelne Warnung. Ein frisch
bereitgestellter Server, der jede Verbindung ablehnt, ist bis zum Beweis des Gegenteils eine Host-Allowlist.
Auch **[Fehlerbehebung](../troubleshooting.md)** fängt hier an.
## Worker – und wer sticky sein muss {#workers-and-who-has-to-be-sticky}
Sobald der Hostname antwortet, stellst du mehr als einen Worker dahinter. Dafür gibt es keinen Schalter im SDK; du skalierst eine Starlette-App wie jede andere ASGI-App, indem du das Objekt an etwas übergibst, das forken kann:
```console
uvicorn server:app --workers 4
```
Vier Prozesse, ein Socket. Und jetzt die Frage, die jedes Deployment beantworten muss: **Muss ein Request bei dem Worker landen, der den vorigen gesehen hat?**
Für einen Client, der das Protokoll **2026-07-28** spricht: nein. Ein moderner Request ist ein einziger, in sich geschlossener POST: kein `initialize`-Handshake davor, keine `Mcp-Session-Id` auf der Response, nichts, *zu dem* ein zweiter Request zurückkommen könnte. Leite ihn an einen beliebigen Worker.
Das ist kein Modus, den du einschaltest. `stateless_http=True` sieht so aus, als wäre es einer, aber der Transport routet nach dem Request-Header `MCP-Protocol-Version`, übergibt einen modernen Request an den modernen Handler und **kehrt zurück**. Die Zeile, die `stateless_http` liest, kommt *nach* diesem Return. Das Flag wird auf dem 2026-07-28-Pfad nicht etwa ignoriert – es wird gar nicht erst erreicht. `stateless_http` ist ein Schalter nur für den **Legacy**-Zweig, und der moderne Pfad ist schon von seiner Konstruktion her ohne Session.
Für einen Legacy-Client mit Spezifikationsversion 2025-11-25 oder älter hängt die Antwort von diesem Flag ab:
| Protokollversion des Clients | Session | Was der Load Balancer tun muss |
| --- | --- | --- |
| **2026-07-28** | Keine. `Mcp-Session-Id` wird nie gesetzt. | Nichts. Jeder Worker bedient jeden Request. |
| **2025-11-25 und älter** (der Standard) | `Mcp-Session-Id`, im Speicher eines einzigen Workers gehalten. | **Sticky Sessions.** Ein Folge-Request, der bei einem anderen Worker landet, bekommt ein `404` *„Session not found“*. |
| **2025-11-25 und älter**, mit `stateless_http=True` | Keine. | Nichts. Der Preis sind der Rückkanal (back-channel) vom Server zum Client – Sampling, Push-Elicitation (Rückfrage bei der Person am Host), `roots/list` – und die Wiederaufnehmbarkeit. |
Sticky Sessions und was der Legacy-Zweig kostet, haben ihre eigene Seite: **[Legacy-Clients unterstützen](legacy-clients.md)**; die beiden Generationen selbst stehen in **[Protokollversionen](../protocol-versions.md)**. Hier zählt die Form der Antwort: *Auf 2026-07-28 bist du schon zustandslos, und es gibt nichts zu konfigurieren.*
Der Rest dieser Seite behandelt die zwei Dinge, die dir Zustandslosigkeit **nicht** abnimmt.
## `requestState` über Worker hinweg {#requeststate-across-workers}
Ein **[Multi-Roundtrip-Tool](../handlers/multi-round-trip.md)** (multi-round-trip tool) braucht etwas, das der Client erst besorgen muss (eine Bestätigung, eine Auswahl, eine Zugangsberechtigung). Deshalb gibt es statt einer Antwort eine Frage zurück und wird beim Retry fertig. Zwischen den beiden Runden hält der Client ein undurchsichtiges `request_state`-Token, das der Server ausgestellt hat. Beim Retry muss der Server dieses Token wieder öffnen.
*Unter welchem Schlüssel versiegelt?* Standardmäßig unter einem, den der Server beim Konstruieren mit `os.urandom(32)` erzeugt hat. Unter `--workers 4` sind das vier Konstruktionen in vier Prozessen: vier verschiedene Schlüssel, nirgends gespeichert, nie geteilt, beim Neustart weg.
Hier ein Tool, das fragt, bevor es handelt, auf einem Server, der nichts konfiguriert:
```python title="server.py" hl_lines="14 20"
--8<-- "docs_src/deploy/tutorial002.py"
```
Die erste Runde landet bei Worker A. Worker A versiegelt `refund:120` unter **seinem** Schlüssel und gibt das Token zurück. Der Client legt die Frage einer Person vor, bekommt ein Ja und versucht es erneut. Der Retry ist ein nagelneuer HTTP-Request.
!!! check
Lass diesen Retry bei Worker B landen. B versucht, ein Token zu entsiegeln, das er nicht ausgestellt hat,
scheitert und lehnt die ganze Runde ab. `refund` wird nie aufgerufen; der Client bekommt einen JSON-RPC-Fehler:
```json
{
"code": -32602,
"message": "Invalid or expired requestState",
"data": {"reason": "invalid_request_state"}
}
```
Diese Meldung ist **festgeschrieben**. Abgelaufen, manipuliert, gegen andere Argumente erneut eingespielt oder
(in einem echten Deployment mit Abstand die häufigste Ursache) von einem Geschwister-Worker versiegelt: Der
Client bekommt jedes Mal dasselbe gesagt, sodass die Leitung nie verrät, welche Prüfung fehlgeschlagen ist.
Der wahre Grund ist ein einzelnes `WARNING` im Log des Servers:
```text
requestState rejected on tools/call: unknown key
```
Ein Multi-Roundtrip-Tool, das mit einem Worker funktioniert hat und bei zweien anfing, *manchmal* zu
scheitern, ist genau das. Beide Runden müssen weiterhin denselben Prozess erreichen, also scheitert es genau
so oft, wie dein Load Balancer sie trennt.
Die beiden Runden sind zwei unabhängige HTTP-Requests, und mehrere ganz gewöhnliche Dinge trennen sie: ein Proxy, der pro Request verteilt, eine Verbindung, die dazwischen abgebrochen ist, ein Deployment oder ein Neustart, ein Client, der `request_state` gespeichert hat und aus einem ganz anderen Prozess weitermacht (**[Die Schleife selbst steuern](../handlers/multi-round-trip.md#driving-the-loop-yourself)**). Jedes davon ist „ein anderer Worker“.
Die Lösung ist ein einziges Argument. Es hat **zwei** Hälften.
```python title="server.py" hl_lines="1 12 14"
--8<-- "docs_src/deploy/tutorial003.py"
```
* **`keys=[...]`** ist die Hälfte, die alle finden. Gib jeder Instanz dasselbe Secret (mindestens 32 Bytes davon), und jede Instanz kann entsiegeln, was irgendein Geschwister ausgestellt hat. `keys[0]` versiegelt, und jeder Schlüssel in der Liste entsiegelt – das ist der Rotationsring; **[Schlüssel rotieren](../handlers/multi-round-trip.md#rotating-keys)** zeigt, wie du ihn ohne Downtime drehst.
* **Der Name des Servers** ist die Hälfte, die fast niemand findet, und der Grund, warum instanzübergreifende Retries auch dann noch scheitern, nachdem du den Schlüssel geteilt hast. Jedes versiegelte Token trägt den `name` des Servers als **Audience-Claim**, der auf dem Rückweg strikt geprüft wird. Zwei Instanzen aus demselben Code haben denselben Namen und merken nie etwas davon. Benenne sie unterschiedlich (`MCPServer(f"billing-{POD}")` liest sich wie gute Observability-Hygiene), und jeder instanzübergreifende Retry wird genau wie oben abgelehnt, geteilter Schlüssel hin oder her. Im Log steht `audience` statt `unknown key`; der Client kann den Unterschied nicht erkennen.
Erzeuge das Secret einmal und gib jeder Instanz denselben Wert. Das ist der Befehl, den dir die eigene Fehlermeldung des SDK nennt, wenn du weniger als 32 Bytes übergibst:
```console
python -c "import secrets; print(secrets.token_hex(32))"
```
!!! warning "Dieselben Schlüssel *und* derselbe Name"
Ein Deployment mit mehreren Instanzen muss beides teilen. Wenn Namen pro Instanz für dich tragend sind,
gib der ganzen Flotte stattdessen eine explizite Audience: `RequestStateSecurity(keys=[...], audience="billing")`.
Jede Instanz stellt dann unter `"billing"` aus und akzeptiert darunter, egal wie sie heißt.
Alles Weitere zum Siegel steht in **[`requestState` schützen](../handlers/multi-round-trip.md#protecting-requeststate)**: was es bindet, die `ttl` pro Runde (standardmäßig 600 Sekunden), wie du einen eigenen Codec mitbringst und warum der unkonfigurierte Standard auf `stdio` genau richtig ist. Der ganze Beitrag dieser Seite ist eine Checkliste mit zwei Punkten: *dieselben Schlüssel, derselbe Name.*
!!! info
Du bist auf diesem Pfad, auch wenn du nie `InputRequiredResult` getippt hast. Ein Tool, dessen Parameter
`Resolve(...)` verwenden (**[Abhängigkeiten](../handlers/dependencies.md)**), ist ein Multi-Roundtrip-Tool,
und das SDK stellt sein `request_state` für es aus und versiegelt es. Derselbe Standardschlüssel, derselbe
Fehler über Worker hinweg, dieselbe Lösung.
## Änderungsbenachrichtigungen über Replikate hinweg {#change-notifications-across-replicas}
Der `subscriptions/listen`-Stream eines Clients ist eine einzige langlebige Response und hängt deshalb sein ganzes Leben lang an einem Replikat. Ein `ctx.notify_resource_updated(...)`, das auf einem **anderen** Replikat veröffentlicht wird, muss ihn erreichen.
Die Nahtstelle zwischen beiden ist der `SubscriptionBus`. Welchen Bus du einem Server auch gibst – in ihn geht jedes Publish, und auf ihm lauscht jeder offene Stream. Gib also jedem Replikat denselben Bus:
```python title="server.py" hl_lines="2 7 9"
--8<-- "docs_src/deploy/tutorial004.py"
```
Dem Fan-out ist es egal, an welchem Server-Objekt ein Stream hängt. Zwei Server, die sich einen `InMemorySubscriptionBus` teilen, verhalten sich schon so: Öffne einen Listen-Stream auf dem einen, rufe `edit_note` auf dem anderen auf, und der Stream erfährt davon. Dieser In-Memory-Bus reicht nur über Server-Objekte innerhalb eines Prozesses, was ihn zum Modell macht, nicht zum Deployment:
* Über echte Prozesse hinweg **liefert das SDK keinen Bus mit, der dir helfen kann.** `SubscriptionBus` ist ein `Protocol` mit zwei Methoden (`publish` und `subscribe`), das du über deinem eigenen Pub/Sub-Backend implementierst (Redis, NATS, was auch immer du schon betreibst) und als `MCPServer(subscriptions=...)` übergibst. Die Skizze und den Vertrag findest du in **[Abonnements](../handlers/subscriptions.md#scaling-past-one-process)**.
* Der Bus transportiert vier kleine typisierte Events, nie JSON-RPC. Bestätigung, Filterung und Stream-Lebenszyklus bleiben im SDK, sodass dein Bus das Protokoll nicht kaputt machen kann; er kann nur Events zwischen Prozessen bewegen.
* Streams sind **nicht** wiederaufnehmbar, und Events werden **nicht** erneut abgespielt. Fällt ein Replikat weg, fallen seine Streams weg; die Clients lauschen erneut und holen die Daten erneut ab. Es gibt keinen Event Store zu teilen und sonst nichts zu konfigurieren. Das ist die eine Stelle, an der horizontales Skalieren wirklich nur mehr vom Gleichen ist.
## Was das SDK dir nicht gibt {#what-the-sdk-does-not-give-you}
Ein `MCPServer` ist eine Protokollimplementierung, kein Anwendungsserver. Die Deployment-Schalter, nach denen du als Nächstes suchst, fehlen absichtlich:
* **Kein `workers=`.** `mcp.run("streamable-http")` startet genau einen uvicorn-Prozess, und mehr wird es nie starten. Mehrere Prozesse heißt: `streamable_http_app()` an das übergeben, womit du ASGI ohnehin bereitstellst – `uvicorn --workers`, gunicorn, der Prozessmanager deiner Plattform. Diese Seite ist absichtlich kein Tutorial für irgendeines davon; deren Dokumentation ist besser, als es eine Kopie davon hier wäre.
* **Keine Health-Check-Route.** `@mcp.custom_route("/health", methods=["GET"])` ist die ganze Antwort, und sie wird nie authentifiziert, selbst wenn der Rest des Servers es ist. Für eine Liveness-Probe ist das richtig, für alles Private falsch. **[In eine bestehende App einbinden](asgi.md#custom-routes)** zeigt eine.
* **Kein Objekt für Produktionseinstellungen.** Auf `MCPServer` gibt es keinen Ort, um Timeouts, TLS, geordnetes Herunterfahren oder Verbindungslimits festzuhalten, weil nichts davon seine Aufgabe ist. Sie gehören zu deinem ASGI-Server, und dort konfigurierst du sie. **[Den Server betreiben](index.md)** behandelt die paar Einstellungen, die der Konstruktor *tatsächlich* entgegennimmt.
* **Kein mitgelieferter `EventStore` – und auf 2026-07-28 auch keine Verwendung dafür.** Wiederaufnehmbarkeit ist ein Feature des zustandsbehafteten Legacy-Zweigs; ein moderner Austausch ist ein POST, eine Response und nichts, was wiederaufzunehmen wäre.
## Zusammenfassung {#recap}
* Ohne weitere Konfiguration beantwortet die App nur Requests an localhost. `transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])` ist die Schranke zum Livegang: Bis du es übergibst, ist jeder Request hinter einem echten Hostnamen ein `421`, und der Grund steht nur im Log des Servers.
* Auf 2026-07-28 gibt es keine Session und nichts, woran ein Load Balancer sticky sein könnte. `stateless_http=True` ist ein reiner Legacy-Schalter, weil ein moderner Request geroutet und beantwortet ist, bevor dieses Flag überhaupt gelesen wird.
* Der Standardschlüssel für `requestState` ist `os.urandom(32)`, pro Prozess erzeugt. Ein Multi-Roundtrip-Retry, der bei einem anderen Worker landet, scheitert mit `-32602` *„Invalid or expired requestState“*.
* Die Lösung ist `RequestStateSecurity(keys=[...])` **und** derselbe Servername auf jeder Instanz. Der Name ist der Standard-Audience-Claim des Tokens. Dieselben Schlüssel, derselbe Name.
* Änderungsbenachrichtigungen überqueren Replikate über einen gemeinsamen `SubscriptionBus`. Die einzige Implementierung des SDK läuft innerhalb eines Prozesses; das `Protocol` mit zwei Methoden über deinem eigenen Pub/Sub schreibst du selbst.
* Es gibt kein `workers=`, keine Health-Route, kein Objekt für Produktionseinstellungen. Bring deinen eigenen ASGI-Server mit.
Das andere, was ein echter Hostname vor sich braucht, ist ein Token: **[Autorisierung](authorization.md)**.

156
i18n/de/pages/run/index.md Normal file
View file

@ -0,0 +1,156 @@
---
translation:
sections: [fea8d769ff9edeba, ce8e2ad42f29ef71, 0d705efb19cf99c2, 7a53ead3e704a7f0, 9adc400e8c88e854, 318893ad8e2e9924, 6b63ab96b34476c0]
tool: 1
---
# Den Server betreiben {#running-your-server}
`mcp.run()` startet den Server.
Die einzige Entscheidung, die du triffst, ist der **Transport**: wie sich die Bytes zwischen deinem Server und seinem Client tatsächlich bewegen.
## Einen Transport wählen {#pick-a-transport}
| Transport | Was es ist | Wann |
|---|---|---|
| `stdio` | Der Host startet deine Datei als Subprozess und spricht über deren stdin und stdout. | Lokale Server. Der Standard. |
| `streamable-http` | Ein echter HTTP-Server, der auf einem Port lauscht. | Alles, was du bereitstellst. |
| `sse` | Der ältere HTTP-Transport. | Gar nicht. |
!!! warning
SSE wurde in der Protokollrevision 2025-03-26 durch Streamable HTTP abgelöst.
`mcp.run(transport="sse")` funktioniert weiterhin, mit eigenen Optionen `sse_path=` und `message_path=`,
existiert aber nur für Clients, die noch nicht umgestiegen sind. Bau nichts Neues darauf.
## `mcp.run()` {#mcprun}
```python title="server.py" hl_lines="12-13"
--8<-- "docs_src/run/tutorial001.py"
```
* `run()` ist synchron. Es blockiert, solange der Server lebt.
* Ohne Argument ist der Transport `stdio`.
* Es steht unter `if __name__ == "__main__":`, weil alles, was deinen Server lädt (`mcp dev`, `mcp run`, `mcp install`, deine Tests), diese Datei **importiert**. Der Guard verhindert, dass aus einem Import ein laufender Server wird.
### stdio {#stdio}
Es gibt nichts zu konfigurieren. Der Host startet deine Datei als Kindprozess, schreibt Requests in deren stdin und liest Responses von deren stdout.
Starte sie selbst, und du siehst die Konsequenz:
```console
python server.py
```
Nichts wird ausgegeben, und es kehrt nicht zurück. Der Prozess wartet auf stdin darauf, dass ein Host zuerst spricht.
Das heißt auch: stdout **ist die Leitung**. Während der Server läuft, verlegt das SDK die Leitung auf einen privaten Deskriptor und leitet Ausgaben, die nach stdout *geflusht* werden (ein Subprozess, der in sein geerbtes stdout schreibt, ein geflushtes `print()`), nach stderr um, wo sie den Stream nicht beschädigen können. Ausgaben, die *vor* dem Start des Servers nach stdout geflusht werden (ein Wrapper-Skript mit echo, ein ungepuffertes print zur Importzeit), landen trotzdem auf der Leitung – genauso ein `print()`, das gepuffert bleibt, bis der Interpreter den Puffer beim Beenden leert. Für Ausgaben, die du wirklich haben willst, ist das Modul `logging` das richtige Tool: Sein Handler flusht jeden Eintrag sofort nach stderr. Alles Weitere steht in **[Logging](../handlers/logging.md)**.
### Ausprobieren {#try-it}
```console
uv run mcp dev server.py
```
Der Inspector macht genau das, was ein echter Host macht: Er startet `server.py` als Subprozess und verbindet sich über stdio damit.
Du hast ihm nie einen Port gegeben. Es gibt keinen.
## Streamable HTTP {#streamable-http}
Um denselben Server stattdessen auf einen Port zu legen, nennst du den Transport (und seine Optionen) in `run()`:
```python title="server.py" hl_lines="13"
--8<-- "docs_src/run/tutorial002.py"
```
Diese eine Zeile baut eine Starlette-App und liefert sie mit uvicorn aus. Clients verbinden sich mit `http://127.0.0.1:3001/mcp`.
Jeder Transport hat eigene Keyword-Argumente, alle an `run()`:
* `host` / `port`: wo gelauscht wird. Standardwerte `127.0.0.1` und `8000`.
* `streamable_http_path`: wo der MCP-Endpunkt liegt. Standardwert `/mcp`.
* `json_response=True`: jeden POST mit einem einzelnen JSON-Body statt eines SSE-Streams beantworten. Dieser Body hat Platz für die Response und sonst nichts. Ein Tool, das mitten im Request in den Client zurückruft (`ctx.elicit()`, Sampling), löst auf dieser Strecke daher `NoBackChannelError` aus, und Benachrichtigungen, die an den laufenden Aufruf gebunden sind (Fortschritt aus `ctx.report_progress()`, Log-Nachrichten pro Aufruf), werden verworfen; der eigenständige `GET`-Stream trägt davon unabhängige weiterhin.
* `stateless_http=True`: ein frischer Transport pro Request, kein Session-Tracking.
* `max_request_body_size`: größter akzeptierter POST-Body in Bytes. Standardwert 4 MiB; größere Requests
erhalten HTTP 413, bevor geparst oder eine Session angelegt wird. Erhöhe ihn nur, wenn legitime MCP-Nachrichten
diese Größe überschreiten.
* `event_store`, `retry_interval`, `transport_security`: Wiederaufnahme und Schutz vor DNS-Rebinding. Sie können warten, bis du anderswo als auf localhost bereitstellst; **[Bereitstellen und skalieren](deploy.md)** behandelt `transport_security`.
!!! warning
Transport-Optionen gehen an `run()`, **nicht** an `MCPServer(...)`. Der Konstruktor beschreibt, was
dein Server *ist*: Name, Version, Instruktionen. `run()` beschreibt, wie er ausgeliefert wird. Vertauschst du
das, antwortet Python, bevor MCP überhaupt beteiligt ist:
```text
TypeError: MCPServer.__init__() got an unexpected keyword argument 'port'
```
`run()` ist der kurze Weg. Sobald du mehr brauchst (deinen Server in eine bestehende App eingehängt, zwei Server in einem Prozess, CORS für Browser-Clients), baust du die ASGI-App selbst und übergibst sie einem beliebigen ASGI-Host. Das ist **[Zu einer bestehenden App hinzufügen](asgi.md)**.
## Server-Einstellungen {#server-settings}
Ein paar Dinge rund ums Betreiben haben nichts mit dem Transport zu tun. Sie sind Konstruktor-Argumente:
```python title="server.py" hl_lines="3"
--8<-- "docs_src/run/tutorial003.py"
```
* `log_level`: wird an `logging.basicConfig()` übergeben, sobald `MCPServer(...)` konstruiert wird. Das konfiguriert den **Root**-Logger und setzt damit das Level auch für deine eigenen Logger, nicht nur für die des SDK. Standardwert `"INFO"`.
* `debug`: wird an die Starlette-App weitergereicht, die die HTTP-Transporte bauen. Standardwert `False`.
Beide landen auf `mcp.settings`, das du zur Laufzeit zurücklesen kannst.
## Der Befehl `mcp` {#the-mcp-command}
Das Extra `[cli]` installiert ein kleines Kommandozeilen-Tool rund um all das.
`mcp dev` betreibt deinen Server unter dem **MCP Inspector**:
```console
uv run mcp dev server.py
uv run mcp dev server.py --with pandas --with numpy
uv run mcp dev server.py --with-editable .
```
`--with` fügt der Umgebung, die es baut, Pakete hinzu; `--with-editable` installiert dein eigenes Paket hinein. Es braucht `npx` auf deinem `PATH`: Der Inspector ist eine Node.js-App.
`mcp run` importiert die Datei, findet das Server-Objekt (ein `mcp`, `server` oder `app` auf Modulebene) und ruft `run()` darauf auf:
```console
uv run mcp run server.py
uv run mcp run server.py:bookshop
```
Das Suffix mit `:` benennt das Objekt, wenn es nicht `mcp`, `server` oder `app` heißt.
Dein Block `if __name__ == "__main__":` wird hier nie ausgeführt: `mcp run` ruft `run()` selbst auf, und die einzige Option, die es weiterreicht, ist `--transport`.
`mcp install` registriert den Server bei **Claude Desktop**, sodass die App ihn für dich startet:
```console
uv run mcp install server.py --name "Bookshop"
uv run mcp install server.py -v API_KEY=abc123 -f .env
```
`-v KEY=VALUE` und `-f .env` halten Umgebungsvariablen in diesem Eintrag fest. Claude Desktop startet deinen Server in einem eigenen Prozess. Die Umgebung deiner Shell ist dort nicht vorhanden.
Claude Desktop ist der einzige Host, den `mcp install` kennt. Jeder andere Host (Claude Code, Cursor, VS Code) nimmt denselben Startbefehl in seiner eigenen Konfigurationsdatei entgegen, und **[Mit einem echten Host verbinden](../get-started/real-host.md)** hat jeden einzelnen.
`mcp version` gibt die installierte SDK-Version aus.
!!! tip
`mcp dev` und `mcp run` verstehen nur `MCPServer`. Wenn du mit dem Low-Level-`Server` baust,
betreibst du ihn selbst. Siehe **[Der Low-Level-Server](../advanced/low-level-server.md)**.
## Zusammenfassung {#recap}
* Ein **Transport** ist der Weg, auf dem Bytes deinen Server erreichen: `stdio` für einen lokalen Subprozess, `streamable-http` für einen Port. SSE ist abgelöst.
* `mcp.run()` wählt den Transport. Ohne Argument ist es `stdio`, und es blockiert.
* Jede Transport-Option (`host`, `port`, `streamable_http_path`, ...) ist ein Argument für `run()`, nie für `MCPServer(...)`.
* Lass `run()` unter `if __name__ == "__main__":`. Alles, was deinen Server lädt, importiert zuerst die Datei.
* `log_level=` und `debug=` sind Konstruktor-Argumente; sie landen auf `mcp.settings`.
* `mcp dev` für den Inspector, `mcp run` zum Ausführen einer Datei, `mcp install` für Claude Desktop, `mcp version` für die Version.
* Der Transport ändert nie, was dein Server *ist*: Alle drei Dateien auf dieser Seite stellen dasselbe Tool bereit.
Wenn `run()` selbst die Grenze ist (dein Server in einer App, die es schon gibt), geht es mit **[Zu einer bestehenden App hinzufügen](asgi.md)** weiter. Ein echter Hostname und mehr als ein Worker sind **[Bereitstellen und skalieren](deploy.md)**. Und wenn manche deiner Clients noch auf Spezifikationsversion 2025-11-25 oder älter sind, ist **[Legacy-Clients unterstützen](legacy-clients.md)** die gute Nachricht.

View file

@ -0,0 +1,140 @@
---
translation:
sections: [3d1663c18edc824c, d4fd37009a13f03d, af9f398a5a8b679a, 470c2dd144294d69, 8e45827e6d24e8c8, 91dfd0ce98ebb03c]
tool: 1
---
# Legacy-Clients unterstützen {#serving-legacy-clients}
MCP kennt zwei Protokollgenerationen: die Generation des `initialize`-Handshakes, bis zur Spezifikationsversion `2025-11-25`, und die moderne Generation, `2026-07-28`. **[Protokollversionen](../protocol-versions.md)** ist die Seite über diese Trennung selbst.
Diese Seite behandelt die Serverseite dieser Trennung, und die Antwort passt in einen Satz: **Die `streamable_http_app()`, die du ohnehin bereitstellst, bedient beide.**
Das SDK routet jeden Request anhand seines `MCP-Protocol-Version`-Headers. Ein Request, der `2026-07-28` nennt, geht an den modernen Handler. Ein Request, der eine Version der Handshake-Generation nennt oder gar keinen Header trägt (so kommt das `initialize` eines Clients von vor 2026 an), geht an den Transport, den diese Clients erwarten: `initialize`-Handshake, Sessions und alles, was dazugehört. Das passiert pro Request, vor deinem Code, in der einen App.
Ein Legacy-Client ist also nichts, *wofür* du etwas baust. Er ist etwas, das sich *mit* dem Server verbindet, den du schon geschrieben hast. Du konfigurierst nichts.
!!! note
Nichts, wortwörtlich. Es gibt keine Option `legacy=`, keine Allowlist für Versionen, keine
Möglichkeit, eine Generation abzulehnen oder abzuschalten: nicht an `streamable_http_app()`,
nicht an `run()`, nicht am Session-Manager. Beide Generationen sind immer aktiv. Was in dieser
Signatur einem Schalter pro Generation am nächsten kommt, ist `stateless_http` – und darum geht
es auf dem Großteil dieser Seite.
## Ein Handler, beide Generationen {#one-handler-both-eras}
Hier ist ein Tool, das die Person am Host etwas fragen muss, und Clients beider Generationen, die es aufrufen:
```python title="server.py" hl_lines="24 37-38"
--8<-- "docs_src/legacy_clients/tutorial001.py"
```
`reserve` braucht eine Sache, die das Modell nicht geliefert hat: wie viele Exemplare. Mit `Annotated[..., Resolve(ask_quantity)]` deklariert ein Tool genau das (alles Weitere steht in **[Abhängigkeiten](../handlers/dependencies.md)**). Nichts in `reserve` nennt eine Version, prüft eine Capability oder verzweigt.
Die beiden Clients sind **gleichzeitig** offen, am selben `mcp`-Objekt. `mode="legacy"` führt den `initialize`-Handshake aus: genau die Verbindung, die ein Client von vor 2026 öffnet. Der andere nimmt den Standardwert und landet bei `2026-07-28`.
```text
2025-11-25 {'result': "Reserved 2 of 'Dune'."}
2026-07-28 {'result': "Reserved 2 of 'Dune'."}
```
Derselbe Server, derselbe Handler, dieselbe Antwort. Das ist das ganze Feature.
Es lohnt sich, beim *Wie* kurz innezuhalten, denn den beiden Clients wurde dieselbe Frage über zwei völlig verschiedene Leitungen gestellt. Die `2026-07-28`-Verbindung hat keinen Kanal, auf dem der Server einen Request senden könnte, also gab `Resolve` die Frage im Tool-Ergebnis zurück, und der Client wiederholte den Aufruf mit der Antwort (**[Multi-Roundtrip-Requests (multi-round-trip requests)](../handlers/multi-round-trip.md)**). Die `2025-11-25`-Verbindung hat so etwas nicht; dort schickte `Resolve` mitten im Aufruf einen echten `elicitation/create`-Request und wartete. Geschrieben hast du keins von beidem. `Resolve` liest die ausgehandelte Version der Verbindung und wählt; dein Tool-Body sieht so oder so eine `AcceptedElicitation`.
!!! tip
Genau diese Portabilität über Generationen hinweg ist der Grund, *warum* `Resolve` die API ist,
auf die du bauen solltest. Sein älterer Verwandter `ctx.elicit()`
(**[Elicitation](../handlers/elicitation.md)**, die Rückfrage bei der Person am Host) sendet
immer nur `elicitation/create` und funktioniert deshalb immer nur auf einer Legacy-Verbindung.
Auf einer `2026-07-28`-Verbindung schlägt der Aufruf fehl. Wenn ein Tool es noch verwendet, ist
die Lösung die, die du oben siehst, und kein Versionscheck.
## Was eine Legacy-Session dich kostet {#what-a-legacy-session-costs-you}
Das Routing ist kostenlos. Die Session nicht.
Eine `2026-07-28`-Verbindung ist **sessionlos**: Jeder Request steht für sich, und der moderne Handler vergibt nie eine `Mcp-Session-Id`. Eine Legacy-Verbindung ist das Gegenteil. Sobald ein Client von vor 2026 `initialize` sendet, erzeugt das SDK eine `Mcp-Session-Id`, gibt sie in einem Response-Header zurück und hält dahinter einen lebenden Eintrag vor, den die späteren Requests des Clients finden: die ausgehandelte Version, die offenen Streams, einen Hintergrund-Task, der die Session antreibt.
Dieser Eintrag ist ein **einfaches `dict` im Prozess**. Es gibt keinen verteilten Session-Store und keine Möglichkeit, einen anzuschließen.
Auf einem Worker ist das unsichtbar. Auf zweien ist es das ganze Problem: Ein Request, der eine `Mcp-Session-Id` trägt und auf einem Worker landet, der sie nicht erzeugt hat, findet in diesem Dict nichts, und die Antwort ist ein `404` (`Session not found`), nicht das Tool-Ergebnis. Sobald du also mehr als einen Worker betreibst, **brauchen Legacy-Clients Sticky Routing**: Jeder Request einer Session muss den Prozess erreichen, der sie gestartet hat. Moderne Clients brauchen das nie; sie haben keine Session, an die sie gebunden sein müssten. **[Bereitstellen und skalieren](deploy.md)** behandelt Stickiness und alles andere rund um den Betrieb von mehr als einer Instanz.
!!! warning
`event_store=` sieht wie die Lösung aus und ist es nicht. Es ist **Wiederaufnahme** (das
Nachliefern verpasster SSE-Events an einen Client, der sich mit *derselben* Session neu
verbindet), kein Session-Store. Es macht eine Session nie von einem anderen Prozess aus
erreichbar.
## Die eine Stellschraube: `stateless_http` {#the-one-knob-stateless_http}
Wenn Stickiness ein Preis ist, den du nicht zahlen willst, gibt es genau eine Sache, die du ändern kannst.
```python title="server.py" hl_lines="28"
--8<-- "docs_src/legacy_clients/tutorial002.py"
```
Das ist der Server vom Anfang der Seite plus ein Schlüsselwort. Mit `stateless_http=True` baut der Legacy-Zweig stattdessen pro Request eine Wegwerf-Session: Es wird keine `Mcp-Session-Id` vergeben und nichts zwischen Requests behalten, also kann jeder Worker jeden Request bedienen und der Load Balancer kann tun, was er will.
Zwei Dinge daran sind wichtiger als das, was es tut.
**Es betrifft nur den Legacy-Zweig.** Requests werden anhand des Versions-Headers geroutet, *bevor* `stateless_http` gelesen wird, also sieht der moderne Pfad es nie. Eine `2026-07-28`-Verbindung ist ohnehin sessionlos und verhält sich unter beiden Werten exakt gleich.
**Es kostet auf diesem Zweig beide Kanäle vom Server zum Client.** Eine Session, die nur einen `POST` lang lebt, hat keinen Stream, über den der Server einen Request schicken könnte, und keinen eigenständigen Stream, über den er Benachrichtigungen schicken könnte. Jeder vom Server initiierte Request löst `NoBackChannelError` aus: `ctx.elicit()`, die ausgemusterten Sampling- und Roots-Aufrufe (**[Veraltete Features](../deprecated.md)**) und, ja, auch `Resolve`, wenn es einem *Legacy*-Client seine Frage stellt. Benachrichtigungen bekommen nicht einmal einen Fehler; sie werden stillschweigend verworfen.
!!! note
`json_response=True` ist nicht diese Stellschraube, verursacht aber auf *jeder* Legacy-Session
die Hälfte derselben Kosten: Ein `POST`, der mit einem einzigen JSON-Body beantwortet wird, hat
keinen Stream für den Request-gebundenen Kanal, also löst ein `ctx.elicit()` mitten im Request
denselben `NoBackChannelError` aus, und an den Request gebundene Benachrichtigungen werden
verworfen. Der eigenständige Stream der Session bleibt unberührt: Benachrichtigungen ohne Bezug
zum Request kommen weiterhin an.
!!! check
Mach es absichtlich falsch. `reserve` ist genau das Tool, das eben beide Clients bedient hat.
Stelle es mit `stateless_http=True` bereit, verbinde dieselben zwei Clients über HTTP und rufe
es von jedem aus auf.
Der moderne Client bekommt weiterhin `Reserved 2 of 'Dune'.` Der moderne Zweig hat sich nicht
verändert.
Der Aufruf des Legacy-Clients kommt nicht als `is_error`-Ergebnis zurück, das das Modell lesen
könnte. Der ganze Request schlägt fehl, als Protokollfehler auf oberster Ebene:
```text
mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests.
```
`Resolve` hat dich nicht gerettet. Auf einer `2025-11-25`-Verbindung *muss* es
`elicitation/create` senden, und der Kanal, den es dafür braucht, ist genau das, was
`stateless_http=True` hergegeben hat. Code, der über Generationen portabel ist, ist nicht
automatisch Code, der ohne Rückkanal (back-channel) auskommt.
Es ist also eine echte Abwägung, und es gibt sie nur auf dem Legacy-Zweig: **mit Session und sticky, oder zustandslos und nur in eine Richtung.** Wenn deine Tools nie in den Client zurückrufen, ist `stateless_http=True` kostenlos und du solltest es nehmen. Wenn doch, behalte die Sessions und halte das Routing sticky.
## Wo sich dein Code tatsächlich verzweigt {#where-your-code-actually-forks}
Fast nirgends.
Tools, Ressourcen, Prompts, strukturierte Ausgabe, Fortschritt, Fehler: Keines davon kümmert sich darum, welche Generation aufgerufen hat. Der `initialize`-Handshake, die `Mcp-Session-Id`, der eigenständige Stream, das `DELETE`, das eine Session beendet: All das gehört dem SDK, und ein Handler sieht nichts davon. Interaktive Eingabe ist *die* Stelle, an der sich die Generationen auf der Leitung wirklich unterscheiden, und `Resolve` gibt es, damit das nicht dein Problem ist: Du hast gerade zugesehen, wie ein Tool beide bedient.
Genau eine Sache bleibt übrig, und das sind **Änderungsbenachrichtigungen**, weil die beiden Generationen auf verschiedenen Kanälen lauschen:
* Ein `2026-07-28`-Client öffnet einen `subscriptions/listen`-Stream und liest den Abonnement-Bus. `ctx.notify_resource_updated()` (sowie `notify_tools_changed()`, `notify_prompts_changed()`, `notify_resources_changed()`) veröffentlichen dort, und *nur* dort. Alles Weitere steht in **[Abonnements](../handlers/subscriptions.md)**.
* Ein Legacy-Client liest den eigenständigen Stream, den seine Session offen hält. `ctx.session.send_resource_updated()` (sowie `send_tool_list_changed()` und Verwandte) schreiben auf die *Verbindung*, die den Request getragen hat: Bei einer Legacy-Session ist das ihr eigenständiger Stream. Eine moderne Verbindung hat dafür keinen Platz: Über HTTP gibt es keinen solchen Kanal, und über stdio laufen die vier Arten von Änderungsbenachrichtigungen ausschließlich über `subscriptions/listen`-Streams, also wird die Benachrichtigung auf einer modernen Verbindung stillschweigend verworfen.
Über HTTP erreicht keiner der beiden Aufrufe die Clients der jeweils anderen Generation. Um alle zu informieren, rufe beide auf:
```python title="server.py" hl_lines="19-20"
--8<-- "docs_src/legacy_clients/tutorial003.py"
```
Zwei Zeilen, kein `if`, kein Versionscheck, und du bist fertig. Das ist die vollständige Liste der Dinge, die ein Handler anders macht, weil es Legacy-Clients gibt.
## Zusammenfassung {#recap}
* Eine `streamable_http_app()` bedient beide Protokollgenerationen. Das SDK routet jeden Request anhand seines `MCP-Protocol-Version`-Headers; es gibt nichts zu konfigurieren und keine Stellschraube pro Generation, nach der du suchen müsstest.
* Ein Legacy-Client kostet dich eine Session: einen `Mcp-Session-Id`-Eintrag im Prozess ohne verteilten Store dahinter. Mehr als ein Worker bedeutet **Sticky Routing**, sonst antwortet der falsche Worker mit `404 Session not found`. Alles zum Betrieb mit mehreren Workern steht in **[Bereitstellen und skalieren](deploy.md)**.
* `stateless_http=True` ist die eine Stellschraube, und sie wirkt **nur auf den Legacy-Zweig**. Sie erkauft freies Load Balancing für Legacy-Clients um den Preis beider Kanäle vom Server zum Client auf diesem Zweig: Vom Server initiierte Requests lösen `NoBackChannelError` aus (beim Client ein Fehler auf oberster Ebene, kein `is_error`-Ergebnis), und Benachrichtigungen werden verworfen.
* Eine `2026-07-28`-Verbindung ist so oder so sessionlos. `stateless_http` berührt sie nie.
* Dein Handler-Code verzweigt nach Generation an genau einer Stelle: Änderungsbenachrichtigungen. `ctx.notify_*` erreicht `subscriptions/listen`-Clients; `ctx.session.send_*` erreicht Legacy-Sessions. Rufe beide auf.
* Alles andere (einschließlich der Rückfrage bei der Person am Host über `Resolve`) ist schon per Konstruktion über Generationen portabel. Schreib die moderne Variante einmal.

View file

@ -0,0 +1,85 @@
---
translation:
sections: [bc0227014724fa49, 15738c2f7fd67d86, a2c17bbe3f707e2f, d0d853376f162c06, b6368643fcc1c8d8, 902e33e17564a607]
tool: 1
---
# OpenTelemetry {#opentelemetry}
Dein Server wird bereits getract. Du musst nichts hinzufügen.
Jeder Server, den du erzeugst, gibt für jede Nachricht, die er verarbeitet, einen [OpenTelemetry](https://opentelemetry.io/)-Span aus. Das hast du nicht geschrieben, und du importierst es auch nicht. Es ist da, sobald du `MCPServer(...)` aufrufst.
```python title="server.py"
--8<-- "docs_src/opentelemetry/tutorial001.py"
```
Das ist ein vollständiger Server mit Tracing. Ruf `search_books` auf, und dafür entsteht ein Span. Dasselbe gilt für den Low-Level-`Server`: Das Tracing lebt auf beiden.
## Was du bekommst {#what-you-get}
Jede eingehende Nachricht wird zu einem `SERVER`-Span, benannt nach der Methode und ihrem Ziel. Ein `tools/call` für `search_books` ist also der Span `tools/call search_books`, und ein bloßes `tools/list` ist einfach `tools/list`.
Jeder Span trägt ein paar Attribute:
* `mcp.method.name` und `mcp.protocol.version`, auf jedem Span.
* `jsonrpc.request.id`, auf einem Request (eine Benachrichtigung hat keine).
* Ein Handler, der eine Exception auslöst, setzt den Span-Status auf Fehler. Ein Tool-Ergebnis mit `is_error=True` tut das ebenfalls.
Und weil das Tracing eines Tool-Aufrufs so oft gewünscht ist, sprechen `tools/call`-Spans die [GenAI Semantic Conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/) von OpenTelemetry:
* `gen_ai.operation.name`, gesetzt auf `"execute_tool"`.
* `gen_ai.tool.name`, gesetzt auf das aufgerufene Tool.
Ein `prompts/get`-Span bekommt im selben Sinne `gen_ai.prompt.name`. Die List-Methoden tragen keine `gen_ai.*`-Schlüssel, weil es dort nichts zu benennen gibt.
!!! tip
Diese GenAI-Attribute sind der Grund, warum eine Tracing-Oberfläche deine Tool-Aufrufe so gruppiert, wie sie die jedes anderen Agenten gruppiert. Diese Gruppierung bekommst du umsonst, ohne zusätzlichen Code.
## Kostenlos, bis du es brauchst {#it-costs-nothing-until-you-want-it}
Hier kommt der Teil, der „standardmäßig an“ zu einem angenehmen Standard macht.
Das SDK hängt nur von `opentelemetry-api` ab, der leichtgewichtigen Hälfte von OpenTelemetry. Ohne installiertes SDK und ohne Exporter ist das Erzeugen eines Spans ein No-op. Die Spans, die dein Server gerade ausgibt, kosten dich also fast nichts, und niemand sammelt sie ein.
An dem Tag, an dem du sie *sehen* willst, installierst du die andere Hälfte und richtest sie auf ein Ziel:
```console
uv add opentelemetry-sdk opentelemetry-exporter-otlp
```
Konfiguriere einen Exporter auf die übliche OpenTelemetry-Weise, und jeder Span, den das SDK bisher still erzeugt hat, leuchtet auf. Dein Server-Code ändert sich nicht. Keine einzige Zeile.
!!! info
[Pydantic Logfire](https://logfire.pydantic.dev/) ist ein solches Backend, und es übernimmt die Konfiguration für dich: `pip install logfire`, `logfire.configure()`, und deine MCP-Spans erscheinen in der Live-Ansicht. Es baut auf OpenTelemetry auf, deshalb gilt alles Folgende auch dafür.
## Traces, die über die Leitung gehen {#traces-that-cross-the-wire}
Ein Trace ist am nützlichsten, wenn er einem Request vom Client in den Server folgt, in einem zusammenhängenden Bild.
Wenn Client und Server beide das SDK einsetzen, entsteht diese Verbindung automatisch. Der Client fügt den [W3C Trace Context](https://www.w3.org/TR/trace-context/) in den Request ein, und der Server liest ihn wieder aus, sodass der Server-Span im selben Trace unter dem Client-Span eingeordnet wird. Das ist [SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414), und du bekommst es, ohne danach zu fragen.
Trägt die eingehende Nachricht keinen Trace Context – zum Beispiel ein Request von einem Client, der nicht das SDK ist –, hängt sich der Server-Span einfach an den Span, der auf dem Server gerade aktuell ist, statt einen brandneuen, verwaisten Trace zu beginnen.
## Abschalten {#turning-it-off}
Das Tracing ist eine Middleware, die erste in der Liste deines Servers. Wenn du wirklich einen Server willst, der keine Spans ausgibt, nimm sie heraus:
```python
from mcp.server._otel import OpenTelemetryMiddleware
mcp._lowlevel_server.middleware[:] = [
m for m in mcp._lowlevel_server.middleware if not isinstance(m, OpenTelemetryMiddleware)
]
```
!!! warning
Dieser Import hat einen führenden Unterstrich, und das ist Absicht. Die Klasse ist vorläufig, so wie [`Server.middleware`](../advanced/middleware.md) vorläufig ist, deshalb solltest du damit rechnen, dass sich der Importpfad ändert. Du brauchst das fast nie: Ohne installierten Exporter sind die Spans kostenlos, die übliche Antwort ist also, sie eingeschaltet zu lassen und keinen Exporter zu installieren.
## Zusammenfassung {#recap}
* Jeder `MCPServer` und jeder Low-Level-`Server` gibt ohne weitere Konfiguration pro eingehender Nachricht einen `SERVER`-Span aus. Du schreibst nichts.
* Spans tragen `mcp.method.name` und `mcp.protocol.version`; `tools/call` und `prompts/get` tragen zusätzlich GenAI-Attribute, sodass deine Tool-Aufrufe gruppiert werden wie die jedes anderen Agenten.
* Es kostet nichts, bis du ein OpenTelemetry-SDK und einen Exporter installierst, und dann leuchtet es auf, ohne dass sich dein Server ändert.
* Der Trace Context vom Client zum Server wird automatisch weitergegeben, wenn beide Seiten das SDK einsetzen.
Was entscheidet, ob ein Request überhaupt läuft, ist die **[Autorisierung](authorization.md)**.