diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 334ba818c8..28ae351feb 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -12,6 +12,8 @@ on: # docs pages include their code blocks from these files via `--8<--`, so a # change here changes the rendered site even when no .md file moves. - docs_src/** + # translated pages and the language registry feed the site// sites + - i18n/** - mkdocs.yml - src/mcp/** - src/mcp-types/** diff --git a/.github/workflows/docs-preview.yml b/.github/workflows/docs-preview.yml index 6f9ec2cc34..278aa4423d 100644 --- a/.github/workflows/docs-preview.yml +++ b/.github/workflows/docs-preview.yml @@ -21,6 +21,7 @@ on: paths: - docs/** - docs_src/** + - i18n/** - mkdocs.yml - scripts/docs/** - pyproject.toml diff --git a/.gitignore b/.gitignore index 2e788e71d8..a63ec932ca 100644 --- a/.gitignore +++ b/.gitignore @@ -144,10 +144,13 @@ venv.bak/ # documentation /site /.worktrees/ -# Generated at build time by scripts/docs/ (the API reference tree and the -# concrete Zensical config spliced from mkdocs.yml). +# Generated at build time by scripts/docs/ (the API reference tree, the +# concrete Zensical configs spliced from mkdocs.yml, and the staged docs tree +# of each translated site). /docs/api/ /mkdocs.gen.yml +/mkdocs.*.gen.yml +/.build/ # mypy .mypy_cache/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b0fb9fa57b..1624e4c0a3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -126,6 +126,10 @@ pre-commit run --all-files - Add type hints to all functions - Include docstrings for public APIs +## Documentation and Translations + +Documentation contributions are English only: the pages under `docs/` are the source of truth, and the translated documentation sites are generated from them, guided by the per-language style guides and glossaries under `i18n//`. Never edit the generated pages under `i18n//pages/`—the tool can't tell a hand edit from its own output, so the edit persists unchecked, is carried forward into future runs, and hides the real fix. To fix a translation, change that language's `instructions.md` or `glossary.json` (or the English page, if that's where the problem is) and re-run `translate --pages` for the affected pages; the fix then carries into every future run. See [`i18n/README.md`](i18n/README.md) for the details. + ## Pull Requests By the time you open a PR, the "what" and "why" should already be settled in an issue. This keeps reviews focused on implementation. diff --git a/docs/js/language-switch.js b/docs/js/language-switch.js new file mode 100644 index 0000000000..b3fc9ca2b2 --- /dev/null +++ b/docs/js/language-switch.js @@ -0,0 +1,31 @@ +// The theme links each language-switcher entry to that language's home page. +// Point the entries at the current page on each language's site instead: every +// prose page exists at the same path on all of them. The API reference is +// English-only, so from there the entries keep pointing at the site roots. +// Instant navigation swaps the page but keeps the header, so re-run on every +// page the theme loads (`document$`) rather than once. +const base = JSON.parse(document.getElementById("__config").textContent).base; +// The site root as a directory path; `base` lacks the trailing slash on 404 pages. +const site = new URL(base.replace(/\/?$/, "/"), location).pathname; +const entries = ".md-select__link[hreflang]"; + +function samePage(entry) { + const page = location.pathname.slice(site.length); + return entry.dataset.site + (page.startsWith("api/") ? "" : page); +} + +document$.subscribe(() => { + for (const entry of document.querySelectorAll(entries)) { + entry.dataset.site ??= entry.getAttribute("href"); // the language root the theme rendered + entry.href = samePage(entry); + } +}); + +// Headings carry the same ids on every site, so the reader's place carries over +// too: query and fragment as they are when the switch happens, not at page load. +function aim(event) { + const entry = event.target instanceof Element ? event.target.closest(entries) : null; + if (entry?.dataset.site && (event.type !== "keydown" || event.key === "Enter")) + entry.href = samePage(entry) + location.search + location.hash; +} +for (const type of ["click", "auxclick", "keydown"]) document.addEventListener(type, aim, true); diff --git a/docs/translations.md b/docs/translations.md new file mode 100644 index 0000000000..13004de9a1 --- /dev/null +++ b/docs/translations.md @@ -0,0 +1,25 @@ +# Translations + +This documentation is written in English. To make it useful to more people, we also publish machine-translated editions of it, and this page explains what that means for you and how to help improve them. + +## What's available + +Translated documentation is currently a **preview** in twelve languages: Deutsch, español, français, हिन्दी, 日本語, 한국어, português (Brasil), русский язык, Türkçe, українська мова, 简体中文 and 繁體中文. Pick one from the language switcher at the top of any page. More languages may follow once these have proved themselves. + +The API reference is not translated: the translated site links to the single English one. + +## English is the source of truth + +If a translated page and its English original disagree, the English page is correct. Every page of a translated site opens with one of three notes saying where it stands: + +- **Machine translation** — the page was translated automatically and links to its English original. +- **Translation behind the English page** — the English original changed after the page was translated, so parts of it may be out of date until the translation catches up. +- **Shown in English** — there is no current translation of the page, so you are reading the English text. + +## How the translations are made + +Translated pages are machine-generated by a tool in this repository from the English pages under `docs/`, guided by two human-written inputs per language: a style guide (register, tone, typography, how to handle jokes and idioms) and a glossary (which terms stay in English, and the required and forbidden renderings for the rest). The generated text is never edited by hand. Every improvement goes into those inputs instead, so it survives the next time the pages are regenerated. + +## Reporting a translation problem + +Found a wrong term, an awkward sentence, or a translation that says something the English doesn't? [Open an issue](https://github.com/modelcontextprotocol/python-sdk/issues) with the language, the page and the passage; reports from native speakers are especially valuable. If you know the fix, propose it directly as a pull request against that language's style guide (`instructions.md`) or glossary (`glossary.json`) under [`i18n/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/i18n) — the correction then reaches every affected page the next time the translations are regenerated. Problems with the English text itself are fixed in the pages under `docs/`, like any other documentation change. diff --git a/i18n/README.md b/i18n/README.md new file mode 100644 index 0000000000..8dfb589d45 --- /dev/null +++ b/i18n/README.md @@ -0,0 +1,20 @@ +# Documentation translations + +The English pages under `docs/` are the source. This directory holds what steers their machine translation and the generated result; [`docs/translations.md`](../docs/translations.md) is the reader-facing explanation. + +- `languages.yml` — the registry: one entry per translated site (served at `//`), the model id, and the nav pages that stay in English. +- `general-prompt.md` — translation rules shared by every language. `notices.md` — English source of the three notes staged onto the pages of a translated site. +- `/instructions.md` (register, voice, typography, terminology) and `/glossary.json` (`keep`: terms that stay in English; `terms`: required renderings, each with an optional `note` and banned `avoid` renderings, which are checked) — human-authored, sent with every request. +- `/pages/**` and `/notices.md` — **generated**, never edited by hand: a correction goes into that language's `instructions.md` or `glossary.json` (or the English page), and the affected pages are re-run. + +## The tool + +```text +uv run --frozen python scripts/docs/translations.py status [--lang CODE] +uv run --frozen --group translate python scripts/docs/translations.py translate --lang CODE [--pages PATH ...] +uv run --frozen python scripts/docs/translations.py stage [--lang CODE] +``` + +`status` is offline: per language it lists missing, outdated (with the sections that changed), current and removable pages (translations whose English page is gone — `git rm` them). `translate` calls the Claude API (`ANTHROPIC_API_KEY` in the environment; the registry's model, or `DOCS_TRANSLATE_MODEL` to trial another) for the missing and outdated pages, retranslating only the English sections that changed and keeping the rest byte for byte; `--pages` instead re-translates exactly the named pages from scratch, which is also how a glossary or instructions change reaches existing pages (each generated page records the English section hashes it reflects, so editing those inputs invalidates nothing). `stage` assembles the tree each language site is built from (every language's, or one with `--lang`); `scripts/docs/build.sh` runs it before building them. Commit the generated pages in an ordinary pull request. + +To add a language, add an entry to `languages.yml`, write `/instructions.md` (the sections the `pt` file has) and `/glossary.json`, then run `translate --lang `. diff --git a/i18n/de/glossary.json b/i18n/de/glossary.json new file mode 100644 index 0000000000..d116e4a4c7 --- /dev/null +++ b/i18n/de/glossary.json @@ -0,0 +1,296 @@ +{ + "keep": [ + "MCP", + "Model Context Protocol", + "MCPServer", + "FastMCP", + "ClientSession", + "Context", + "ctx", + "stdio", + "Streamable HTTP", + "SSE", + "JSON-RPC", + "JSON", + "OAuth", + "PKCE", + "JWT", + "CIMD", + "HTTP", + "HTTPS", + "TLS", + "CORS", + "URI", + "URL", + "ASGI", + "WebSocket", + "API", + "SDK", + "CLI", + "IDE", + "LLM", + "SEP", + "RFC", + "Python", + "TypeScript", + "Node.js", + "PyPI", + "Pydantic", + "Starlette", + "FastAPI", + "uvicorn", + "httpx", + "anyio", + "asyncio", + "trio", + "pytest", + "OpenTelemetry", + "Inspector", + "Claude", + "GitHub", + "VS Code", + "Windows", + "macOS", + "Linux", + "llms.txt", + "2026-07-28", + "2025-11-25", + "2025-06-18", + "2025-03-26" + ], + "terms": [ + { + "source": "tool", + "target": "Tool", + "note": "MCP protocol noun (a server exposes tools) and the everyday word for a developer utility alike; kept as the loanword German developers use: das Tool / die Tools, compounds hyphenated (der Tool-Aufruf, die Tool-Beschreibung). Not Werkzeug. Wire identifiers such as `tools/call` and the `@mcp.tool()` decorator are code and stay untouched. Provisional pending native review." + }, + { + "source": "resource", + "target": "Ressource", + "note": "MCP protocol noun (data a server exposes for reading) and the general noun (a pool acquired in a lifespan is still a Ressource). German spelling with double s: die Ressource / die Ressourcen; never the English spelling Resource in German prose — except where it is a UI label the code emits, such as the Inspector's **Resources** tab. `resources/read` and `@mcp.resource()` are code." + }, + { + "source": "prompt", + "target": "Prompt", + "note": "The MCP feature (a reusable prompt template a server exposes) and the general AI sense; kept in English as German AI writing does. Masculine: der Prompt / die Prompts. Never Eingabeaufforderung, which is the command-line prompt and the wrong sense. `prompts/get` and `@mcp.prompt()` are code." + }, + { + "source": "sampling", + "target": "Sampling", + "note": "The (deprecated) client feature that lets a server borrow the client's model for a completion. Kept in English: das Sampling. Not Stichprobe / Stichprobenverfahren, which is statistical sampling and the wrong sense. The `sampling` capability key and `sampling/createMessage` stay Latin in code font. Provisional pending native review." + }, + { + "source": "roots", + "target": "Roots", + "note": "The (deprecated) client feature listing the workspace directories a client exposes; kept in English because readers meet it as the identifier `roots/list`. Plural: die Roots. May take the gloss \"Roots (freigegebene Arbeitsverzeichnisse)\" on first mention. Not Wurzeln (the botanical/mathematical sense); a root directory elsewhere is das Wurzelverzeichnis or Stammverzeichnis. A `Root` object in code font stays Latin. Provisional pending native review." + }, + { + "source": "elicitation", + "target": "Elicitation", + "note": "OPEN QUESTION for native review: there is no established German term for the server asking the person at the host a question mid-request. Provisionally kept in English — die Elicitation — glossed on its first appearance per page as \"Elicitation (Rückfrage bei der Person am Host)\", or, when that first appearance already sits inside parentheses, with a spaced en dash instead — \"Elicitation – Rückfrage bei der Person am Host\" — never a nested parenthesis; in running prose the act itself may be described with Rückfrage / zurückfragen. Do not coin Erhebung or Abfrage for it. `elicitation/create`, `ctx.elicit()` and the `Elicit` class stay Latin." + }, + { + "source": "capability", + "target": "Capability", + "note": "A negotiated protocol capability (what a client or server declared it supports during initialization). Provisional pending native review: kept as the term of art — die Capability / die Capabilities, \"capability negotiation\" → das Aushandeln der Capabilities — because it names the `capabilities` field; Fähigkeit is the open alternative and Funktion (a feature) is a different thing. Keys such as `sampling.tools` stay Latin." + }, + { + "source": "transport", + "target": "Transport", + "note": "The connection mechanism: der Transport / die Transporte (\"every standard transport\" → jeder Standard-Transport). The transport names stdio, Streamable HTTP and SSE are on the keep list and stay in English (der stdio-Transport, der Streamable-HTTP-Transport). Provisional pending native review." + }, + { + "source": "session", + "target": "Session", + "note": "An MCP session (the negotiated connection state): die Session / die Sessions, die Session-ID. Sitzung is understood too, but pin Session throughout rather than alternating; it matches the `session` objects and the `Mcp-Session-Id` header, which are code. Provisional pending native review." + }, + { + "source": "handler", + "target": "Handler", + "note": "The tool, resource or prompt function you register (nav section \"Inside your handler\" → Im Handler). Kept as everyday German developer usage: der Handler / die Handler (no plural -s). The verb is behandeln or verarbeiten, never handlen. Provisional pending native review." + }, + { + "source": "dependency", + "target": "Abhängigkeit", + "note": "Both package dependencies and the SDK's parameter-injection feature (the \"Dependencies\" page → Abhängigkeiten): die Abhängigkeit / die Abhängigkeiten. The pattern name stays English and open — Dependency Injection — as German developers write it. The `Resolve` marker class stays Latin. Provisional pending native review." + }, + { + "source": "resolver", + "target": "Resolver", + "note": "The plain function attached to a parameter with `Resolve(...)` that computes or asks for its value before the tool runs: der Resolver / die Resolver. Not Auflöser. The `Resolve` class stays Latin. Provisional pending native review." + }, + { + "source": "client", + "target": "Client", + "note": "An MCP client, and the client side of a connection: der Client / die Clients. Not Kunde (a customer). The `Client` class and the `mcp.client` module are code and stay untouched." + }, + { + "source": "server", + "target": "Server", + "note": "An MCP server (the program you build): der Server / die Server (no plural -s); ein MCP-Server with a hyphen. The `MCPServer`, `Server` and `ServerSession` classes are code and stay untouched." + }, + { + "source": "host", + "target": "Host", + "note": "The MCP host — the application the person talks to, which embeds the client and drives the model (Claude Desktop, an IDE) — and also a network host: der Host / die Hosts in both senses. Never Gastgeber.", + "avoid": ["Gastgeber"] + }, + { + "source": "context", + "target": "Kontext", + "note": "The generic lower-case word (\"provide context to LLMs\" → LLMs Kontext bereitstellen): der Kontext. The capitalised `Context` is the SDK object injected as `ctx`; it is on the keep list, stays Latin in prose and is masculine by analogy (\"The Context\" → Der Context, das `Context`-Objekt)." + }, + { + "source": "request", + "target": "Request", + "note": "A JSON-RPC or HTTP request (\"the initialize request\" → der initialize-Request, \"request body\" → der Request-Body): der Request / die Requests. Anfrage is correct German too, but pin Request throughout rather than alternating. The verb \"to request\" is anfordern. `Request` types in code font stay Latin. Provisional pending native review." + }, + { + "source": "response", + "target": "Response", + "note": "A JSON-RPC or HTTP response message: die Response / die Responses. Antwort remains the word for an answer a person, model or tool gives (\"the user's answer\" → die Antwort), so both may appear on one page in their own senses. `Response` types in code font stay Latin. Provisional pending native review." + }, + { + "source": "notification", + "target": "Benachrichtigung", + "note": "A JSON-RPC notification (a message that expects no response) and change notifications alike: die Benachrichtigung / die Benachrichtigungen, \"change notification\" → Änderungsbenachrichtigung. Method strings such as `notifications/tools/list_changed` are code. Provisional pending native review; Notification is the open alternative." + }, + { + "source": "callback", + "target": "Callback", + "note": "Client callbacks (`sampling_callback`, `elicitation_callback`) and OAuth redirect callbacks alike: der Callback / die Callbacks, die Callback-URL. Not Rückruf (a return phone call); the dated Rückruffunktion is not used either. Parameter names stay Latin. Provisional pending native review." + }, + { + "source": "decorator", + "target": "Dekorator", + "note": "The Python decorators the SDK is built on, in the German spelling Python literature uses: der Dekorator / die Dekoratoren. `@mcp.tool()` and its siblings are code and stay untouched. Provisional pending native review; the English spelling Decorator is the open alternative." + }, + { + "source": "type hint", + "target": "Type Hint", + "note": "Python type hints (\"from your type hints\" → aus deinen Type Hints): der Type Hint / die Type Hints, kept as German Python developers say it; \"type annotation\" is die Typannotation. Provisional pending native review; Typ-Hinweis is the open alternative." + }, + { + "source": "round trip", + "target": "Roundtrip", + "note": "One request/response exchange: der Roundtrip / die Roundtrips (\"zero negotiation round trips\" → kein einziger Roundtrip für die Aushandlung). Written closed as German developers do, not Round Trip and not Rundreise (a tour). Provisional pending native review." + }, + { + "source": "multi-round-trip", + "target": "Multi-Roundtrip", + "note": "The 2026-07-28 request pattern (\"Multi-round-trip requests\" → Multi-Roundtrip-Requests, hyphenated through). Provisional coinage pending native review: gloss the English on first use per page — Multi-Roundtrip-Requests (multi-round-trip requests). The abbreviation MRTR stays Latin." + }, + { + "source": "lifespan", + "target": "Lifespan", + "note": "The server's startup/shutdown scope (the \"Lifespan\" page, as in the ASGI lifespan); kept in English so the prose matches the `lifespan=` parameter: der Lifespan. May take the gloss \"Lifespan (Start- und Stopp-Phase des Servers)\" on first mention. Lebensdauer is the word for the neighbouring \"lifetime\" (\"for the lifetime of the app\" → für die Lebensdauer der App) and is not banned; Lebenserwartung is always wrong.", + "avoid": ["Lebenserwartung"] + }, + { + "source": "back-channel", + "target": "Rückkanal", + "note": "This documentation's term for the server calling back into the client during a request, which exists only on legacy connections: der Rückkanal. First mention on a page reads \"Rückkanal (back-channel)\" so the reader can connect it to the `NoBackChannelError` exception, which is code. Provisional pending native review." + }, + { + "source": "deprecated", + "target": "veraltet", + "note": "Advisory status: still works, scheduled for removal later — veraltet (\"Deprecated features\" → Veraltete Features, \"is deprecated\" → ist veraltet / gilt als veraltet); \"removed\" is entfernt, a different state. \"Deprecation warning\" → Deprecation-Warnung, tied to the `MCPDeprecationWarning` class, which stays Latin. Not the coinage deprecatet. Provisional pending native review." + }, + { + "source": "legacy", + "target": "Legacy-", + "note": "\"A legacy connection / client / session\" = one negotiated at spec version 2025-11-25 or earlier → die Legacy-Verbindung, der Legacy-Client, die Legacy-Session (prefix compound with hyphen); \"Serving legacy clients\" → Legacy-Clients unterstützen. Keep it distinct from veraltet, which renders \"deprecated\". Provisional pending native review; Alt- (Alt-Client) is the open alternative." + }, + { + "source": "era", + "target": "Generation", + "note": "\"Protocol era\" (\"a 2025-era client\", \"whatever era the client speaks\", \"any era of server\") → die Protokollgeneration, ein Client der 2025er-Generation, Server jeder Generation. Provisional pending native review; not Ära or Zeitalter, which read grandiose." + }, + { + "source": "wire", + "target": "Leitung", + "note": "The corpus's light metaphor for the byte stream between client and server, rendered with the idiom German networking prose uses: \"on the wire\" → auf der Leitung, \"what goes over the wire\" → was über die Leitung geht, \"stdout is the wire\" → stdout ist die Leitung, \"the JSON on the wire\" → das JSON auf der Leitung. Not Draht or Kabel. Provisional pending native review." + }, + { + "source": "handshake", + "target": "Handshake", + "note": "The initialization handshake (\"the classic handshake\" → der klassische Handshake): der Handshake / die Handshakes, as German protocol descriptions say. Provisional pending native review; not Handschlag in this corpus." + }, + { + "source": "escape hatch", + "target": "Notausgang", + "note": "The API-design metaphor for the lower-level mechanism you drop to when the convenience layer is in the way (`client.session`, `add_request_handler()`, the low-level `Server`): der Notausgang / die Notausgänge. Pinned so every page uses one rendering; Hintertür (a backdoor, with its security connotation) is wrong here. Provisional pending native review." + }, + { + "source": "library", + "target": "Bibliothek", + "note": "A software library: die Bibliothek / die Bibliotheken (Library is heard in speech but pin the German word). Bücherei is a lending library and never right here.", + "avoid": ["Bücherei"] + }, + { + "source": "deploy", + "target": "bereitstellen", + "note": "The verb: bereitstellen / in Produktion bringen (\"deploy it the way you deploy any ASGI app\" → stelle es bereit wie jede andere ASGI-App); the noun is das Deployment; the nav title \"Deploy & scale\" → Bereitstellen und skalieren. Never the coinage deployen / deployt (see instructions §5). Provisional pending native review." + }, + { + "source": "default value", + "target": "Standardwert", + "note": "A parameter's default: der Standardwert; \"by default\" → standardmäßig; Default- is fine inside an established compound (die Default-Konfiguration) but do not alternate Standardwert and Defaultwert. Provisional pending native review." + }, + { + "source": "exception", + "target": "Exception", + "note": "A raised Python exception: die Exception / die Exceptions, \"raises an exception\" → löst eine Exception aus / wirft eine Exception. Ausnahme is correct German too; pin Exception so prose matches the class names, which stay Latin, and never alternate. An \"error\" is der Fehler. Provisional pending native review." + }, + { + "source": "return value", + "target": "Rückgabewert", + "note": "A function's return value: der Rückgabewert; \"returns X\" → gibt X zurück / liefert X. The `return` keyword and annotations are code." + }, + { + "source": "subscription", + "target": "Abonnement", + "note": "Resource and list-change subscriptions (the two \"Subscriptions\" pages → Abonnements): das Abonnement / die Abonnements, \"subscribe\" → abonnieren, \"subscriber\" → der Abonnent (a piece of software here, so no gendering question arises). `subscriptions/listen` and `resources/subscribe` are code. Provisional pending native review." + }, + { + "source": "completion", + "target": "Vervollständigung", + "note": "Two senses. The MCP feature that autocompletes prompt and resource-template arguments (the \"Completions\" page, `completion/complete`) → die Vervollständigung / Vervollständigungen. The text a model produces in the sampling pages (\"ask the client for an LLM completion\") is a different thing → die Antwort des Modells or die Completion; never Vervollständigung there. Provisional pending native review." + }, + { + "source": "structured output", + "target": "strukturierte Ausgabe", + "note": "The tools feature and its page title (\"Structured Output\" → Strukturierte Ausgabe): die strukturierte Ausgabe. `structured_output` and `outputSchema` are code. Provisional pending native review." + }, + { + "source": "troubleshooting", + "target": "Fehlerbehebung", + "note": "The page title and the activity: die Fehlerbehebung. Not Problembehandlung and not the loan Troubleshooting in a heading. Provisional pending native review." + }, + { + "source": "authorization", + "target": "Autorisierung", + "note": "The OAuth sense and the page title (\"Authorization\" → Autorisierung); \"authentication\" is Authentifizierung — keep the two apart as the English does. The `Authorization` header is code. Provisional pending native review." + }, + { + "source": "Get started", + "target": "Einstieg", + "note": "The nav section that opens the guide, and the title of its index page. \"First steps\" is a separate page inside that section (Erste Schritte), so the two need distinct renderings or the sidebar shows the same title twice — never Erste Schritte for this one. Provisional pending native review; Loslegen is the open alternative." + }, + { + "source": "First steps", + "target": "Erste Schritte", + "note": "The tutorial page inside the \"Get started\" section; never reuse this rendering for the section itself (see that entry). Provisional pending native review." + }, + { + "source": "Recap", + "target": "Zusammenfassung", + "note": "Recurring section heading that closes most pages; one rendering everywhere, not Fazit on some pages and Zusammenfassung on others. Provisional pending native review." + }, + { + "source": "Try it", + "target": "Ausprobieren", + "note": "Recurring section heading above a runnable example; one rendering everywhere (the infinitive, per the heading rule), not Probier es aus on some pages. Provisional pending native review." + } + ] +} diff --git a/i18n/de/instructions.md b/i18n/de/instructions.md new file mode 100644 index 0000000000..59fe2dbef5 --- /dev/null +++ b/i18n/de/instructions.md @@ -0,0 +1,170 @@ +# German (de) — translation instructions + +Target language: German in Germany's standard orthography (Deutsch, de-DE), +directory and URL code `de`, page language tag `de`. This file is sent verbatim +with every translation request for this language, on top of the shared rules +in `../general-prompt.md`. The termbase in `glossary.json` is sent alongside it +and wins any terminology conflict with this file. + +## 1. Register + +Address the reader as **du**, consistently — the register of modern open-source +and developer-tool documentation; Sie would read like vendor docs. + +- du, dich, dir, dein are lower-case mid-sentence. Never address the reader as + Sie / Ihnen / Ihr, never capitalised Du / Dein, never a mix — a page that + drifts between du and Sie, or between direct imperatives and impersonal + officialese, is wrong even when each sentence is acceptable on its own. + (Third-person sie and a sentence-initial Sie are ordinary German and fine.) +- Steps are bare du imperatives: "Install the SDK, then run the server" → + Installiere das SDK und starte dann den Server — not Installieren Sie …, not + the infinitive SDK installieren in running prose, not Du solltest … (needless + modal), no bitte per step. Impersonal man only for truly general statements. + Where English says "your", German often uses the article: öffne das Terminal. +- Headings, table headers, tab labels and admonition titles are noun phrases or + infinitive constructions, never imperatives: "Declare a tool" → Ein Tool + deklarieren, "Handling errors" → Fehler behandeln, "Running your server" → + Den Server betreiben, "The Context" → Der Context. A question heading may + stay a question (Wohin damit?). No full stop after a heading. +- Requirement strength stays exact: must → muss, should → sollte, may / can → + kann or darf, must not → darf nicht (muss nicht means "need not"). +- Gender-neutral wording by phrasing, never by typography. Sentences about the + reader (du) or about software (der Client, der Server) need nothing. For + people use plurals and neutral nouns — alle, die den Host bedienen; wer das + SDK einsetzt; das Team — and for the single human in front of the host ("the + user") die Person, or die Person am Host where the role needs naming, then + sie. Never Nutzer*innen, Nutzer:innen, NutzerInnen or Nutzer/-innen, and no + bare generic masculine (der Nutzer, der Entwickler) either. Provisional; + apply it uniformly. + +## 2. Voice + +The English source is warm, direct and confident: short sentences, the +occasional one-line payoff. Carry that — sachlich, direkt, freundlich. + +- Keep the payoff sentences short: "That's the whole API." → Das ist die ganze + API. — not a formal summary sentence. Split long English sentences: two main + clauses read better than one nested period with the verb parked at the end. + Never merge, drop or reorder the technical claims themselves. +- Verbs, not Nominalstil: die Durchführung der Installation erfolgt → + installiere; eine Überprüfung vornehmen → prüfen. Active where German allows + it: "The tool is called by the model" → Das Modell ruft das Tool auf. +- No officialese (seitens, mittels, im Rahmen von, es ist darauf zu achten, + dass, erfolgt as an all-purpose verb), no hype or softeners (leistungsstark, + nahtlos, im Handumdrehen; du könntest eventuell → du kannst), no + English-shaped German (Sinn machen → sinnvoll sein, Python's → Pythons, ist + am Laufen → läuft). Nor the over-correction: no buddy tone (mega, easy). +- Example — "You don't construct it and you don't configure it. You ask for + it." → Du erzeugst ihn nicht selbst und konfigurierst ihn auch nicht. Du + forderst ihn einfach an. (ihn: der Context.) Not the Nominalstil Eine + Instanziierung sowie Konfiguration ist nicht erforderlich; es genügt eine + Anforderung. — nor the slangy calque Du baust es nicht … fragst danach, easy! + +## 3. Humour and idioms + +- The English is friendly and dry rather than jokey; the warmth carries over + into the du register unchanged, the idioms do not. Never translate a pun, + idiom or aside literally: say what it means as a short, natural German + sentence in the same register; a German idiom at home in technical prose is + welcome (unter der Haube for "under the hood"). An aside with no information + may go — a technical caveat phrased lightly never does. +- Recurring English tags get fixed renderings: "**[X](…)** has the whole story" + / "The whole story is in **[X](…)**" → Alles Weitere steht in **[X](…)**; + "That's the whole API." / "That's the whole protocol." → Das ist die ganze + API. / Das ist das ganze Protokoll.; "That's it. It's just Python." → Das ist + alles. Ganz normales Python. (not Das ist es. Es ist nur Python!); "You get + `3` back. ✨" → Du bekommst `3` zurück. ✨ (not Du erhältst 3 zurück! ✨ — + lost code span, added exclamation mark). +- Idioms take the plain meaning, not the picture: "Out of the box the app + answers **only** requests addressed to localhost." → Ohne weitere + Konfiguration beantwortet die App **nur** Requests an localhost — not aus der + Box heraus. "it stops being required" → er ist nicht mehr erforderlich, not + er stoppt, required zu sein. +- Exclamation marks: keep one only where the English carries genuine emphasis; + never add, never double, never in a heading. Emoji: keep the source's rare, + deliberately placed emoji exactly where they are; never add new ones. + +## 4. Typography + +- Quotation marks in prose are German „…“ (U+201E, U+201C), with ‚…‘ for a + quote inside a quote. Straight "…" and English “…” in the source prose become + „…“, scare quotes and example utterances included. Quotes inside code spans + and code blocks stay exactly as they are, and a code span is never wrapped in + quotation marks. +- Dashes: an English em-dash aside becomes a Gedankenstrich — an en dash with a + space on each side (Text – Einschub – Text) — or commas, parentheses or a + second sentence; never an em dash (—) in German text. Ranges: 3.10 bis 3.14, + or 3.10–3.14 with an en dash and no spaces. +- Compounds are closed or hyphenated, never spaced. A compound with an English, + abbreviated or code-font part is hyphenated through every joint: der + MCP-Server, das JSON-RPC-Format, der Streamable-HTTP-Transport, das + `Context`-Objekt, die `PATH`-Umgebungsvariable. Never MCP Server with a space + (and `MCPServer` is a class, not ein MCP-Server). A multi-word English term + standing alone stays open: Streamable HTTP, Dependency Injection. +- Every noun is capitalised, borrowed ones included (der Request, das Tool); + borrowed adjectives and verbs are not (optional, gecacht). Orthography is + de-DE: dass, muss, schließen, außerdem — never Swiss ss. +- Digits stay ASCII. Protocol revision strings such as `2026-07-28`, version + numbers, ports, status and error codes, RFC and SEP numbers are identifiers, + copied byte for byte — never 28.07.2026, never 28. Juli 2026. Prose + quantities take the decimal comma only when nothing but the separator changes + (2,5 Sekunden), never inside code; a space before units and % (30 s, 100 %). +- Abbreviations: e.g. → z. B., i.e. → d. h., etc. → usw. (inner space kept); + vs → oder / gegenüber; & in prose → und. Commas follow German grammar, not + the source (before dass, weil, wenn, ob and relative clauses). +- Bold and italics land on the words that carry the source's emphasis; a bolded + negation ("**not**" → **nicht** / **kein**) stays bold. English words kept in + German text are set in normal type. + +## 5. Terminology pointer + +The termbase is `glossary.json` next to this file. It is injected into the +prompt separately and its renderings override anything written here. This +section only fixes the conventions the glossary assumes: + +- Terms in the glossary's `keep` list are copied exactly — same spelling and + casing, not translated, italicised or quoted. They take an article by gender + (das SDK, die API, das JSON, die URL, der URI, das CLI, das LLM, der SEP, der + RFC) and the English plural where the source is plural (die SDKs). +- Everything in code font — class, function, parameter and module names, + protocol method strings (`tools/call`), header names, error text, config keys + — stays byte-identical. Name the kind of thing in front where it helps (die + Klasse `Context`, der Parameter `lifespan=`); compounds take a hyphen outside + the backticks (der `Resolve`-Marker). A glossary term used as a code-font + identifier stays English: "the `sampling` capability" → die Capability + `sampling`. +- Text quoted from what the example code prints or displays — an output line, a + log message, an error string, a UI label such as the Inspector's **Tools** + and **Resources** tabs — stays exactly as the code emits it (usually + English), in or out of code font. The quotation marks around it may become + „…“; the text inside does not change. +- Nouns are borrowed, verbs are not. German developers keep many English nouns + — capitalised, with a fixed gender, declined: der Request, die Response, der + Client, der Server, der Host, der Handler, der Callback, das Tool, der + Prompt, das Token, der String, der Header, die Payload, der Stream, das + Schema, die Middleware, die Session, der Commit, der Build, das Deployment. + Plurals take -s (die Requests, die Tools) except nouns in -er, which stay + unchanged (die Server, die Handler, die Parameter). Verbs are German wherever + a plain German verb exists: bereitstellen (not deployen), einen Commit + anlegen (not committen), zusammenführen (not mergen), aktualisieren (not + updaten). Fully naturalised verbs are fine: debuggen, parsen, loggen, cachen. +- Translate where German developers use the German word themselves — a forced + purism is as wrong as needless English: Ressource, Abhängigkeit, Fehler, + Rückgabewert, Standardwert, Umgebungsvariable, Bibliothek, Verzeichnis, + Verbindung, Benachrichtigung; but never Zeichenkette for String. +- First-use gloss: a rendering the reader may need to map back to the English + specification carries the English in parentheses on its first occurrence on + a page — der Rückkanal (back-channel) — where the glossary note says so. +- One rendering per term per page: the glossary target, every time. Where an + entry's note marks the choice as open or provisional, still use the listed + target consistently — never Request in one paragraph and Anfrage in the next. + +## 6. Provisional note + +The register, voice and terminology decisions above, and every entry in +`glossary.json`, are provisional pending review by native German-speaking +readers — in particular the du address, the gender-neutral phrasing convention +and the keep-versus-translate line for individual nouns. To propose a change, +edit this file or `glossary.json` in a pull request, ideally with a short +good/bad example; never edit the generated `pages/` or `notices.md` next to +this file, which the next translation run overwrites. diff --git a/i18n/de/notices.md b/i18n/de/notices.md new file mode 100644 index 0000000000..f805cbe4fc --- /dev/null +++ b/i18n/de/notices.md @@ -0,0 +1,20 @@ +--- +translation: + sections: [aff1b3e872b7876a, 4d80558ad052d586, 0bb81f1e62062d26, d5c35dcec50156bc] + tool: 1 +--- +# Übersetzungshinweise {#translation-notices} + +Einer dieser Hinweise steht oben auf jeder Seite einer übersetzten Dokumentationsseite. + +## Maschinelle Übersetzung {#translated} + +Diese Seite wurde automatisch aus der englischen Dokumentation übersetzt, und die [englische Seite](ENGLISH_PAGE) ist die maßgebliche Fassung. Wenn sich etwas falsch liest, erklärt [Übersetzungen](TRANSLATIONS_PAGE), wie du es melden kannst. + +## Übersetzung hinter der englischen Seite zurück {#outdated} + +Die englische Seite hat sich geändert, nachdem diese Übersetzung entstanden ist, daher können Teile davon veraltet sein. Lies im Zweifel die [englische Seite](ENGLISH_PAGE); [Übersetzungen](TRANSLATIONS_PAGE) erklärt, wie die übersetzte Dokumentation funktioniert. + +## Auf Englisch angezeigt {#english} + +Für diese Seite gibt es keine aktuelle Übersetzung, deshalb liest du sie auf Englisch. [Übersetzungen](TRANSLATIONS_PAGE) erklärt, wie die übersetzte Dokumentation funktioniert. diff --git a/i18n/de/pages/advanced/apps.md b/i18n/de/pages/advanced/apps.md new file mode 100644 index 0000000000..2df9303cec --- /dev/null +++ b/i18n/de/pages/advanced/apps.md @@ -0,0 +1,123 @@ +--- +translation: + sections: [0355618e5f4d5fe4, 1821eaf50f2d0b64, 82e0b28ebd3abf5a, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487] + tool: 1 +--- +# MCP Apps {#mcp-apps} + +Eine **MCP App** ist ein Tool mit Gesicht: Neben seinen Daten verweist das Tool auf ein HTML-Dokument, das der Host als interaktive Oberfläche rendert. + +Zwei Teile, immer zwei Teile: + +1. **Ein Tool**, das die Arbeit macht und Daten zurückgibt, wie jedes andere Tool auch. +2. **Eine `ui://`-Ressource** mit dem HTML, das der Host dafür anzeigt. + +Das Tool trägt eine `_meta.ui.resourceUri`-Referenz auf die Ressource. Der Host holt sie mit `resources/read`, rendert sie in einem **Sandbox-iframe** und schiebt das Ergebnis des Tools per `postMessage` in diesen iframe. Dein Server sendet oder empfängt niemals `ui/*`-Nachrichten: Dieser Verkehr läuft zwischen Host und iframe. Du lieferst ein Tool und ein HTML-Dokument; das Theater übernimmt der Host. + +Das SDK liefert das als eingebaute Extension `Apps` (`io.modelcontextprotocol/ui`) mit. Falls [Extensions](extensions.md) neu für dich sind, überfliege zuerst jene Seite. Eine Minute, dann komm zurück. + +## Eine Uhr mit Gesicht {#a-clock-with-a-face} + +```python title="server.py" hl_lines="19 22 30 32" +--8<-- "docs_src/apps/tutorial001.py" +``` + +Vier Schritte: + +* `Apps()`: Eine Instanz hält deine UI-gebundenen Tools und ihre Ressourcen. +* `@apps.tool(resource_uri="ui://clock/app.html")`: ein normales Tool plus der `_meta.ui.resourceUri`-Stempel. Alles, was `@mcp.tool()` akzeptiert (name, title, description, ...), wird durchgereicht. +* `apps.add_html_resource("ui://clock/app.html", CLOCK_HTML)`: die passende Ressource, ausgeliefert als `text/html;profile=mcp-app`. Genau dieser MIME-Typ sagt einem Host „das ist eine App, rendere sie“. +* `MCPServer("clock", extensions=[apps])`: die Anmeldung. Der Server bewirbt jetzt `io.modelcontextprotocol/ui` unter `capabilities.extensions`. + +Das HTML selbst lauscht auf das `postMessage` des Hosts und zeigt das Ergebnis an. Für echte Apps verwende das offizielle Browser-SDK [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps) in deinem HTML. Es gibt dir `ontoolresult`, `callServerTool`, `getHostContext` und `onhostcontextchanged` statt roher Message-Events. + +## Graceful Degradation {#graceful-degradation} + +Nicht jeder Client rendert Apps. Die Spezifikation sagt unverblümt, was das für dich bedeutet: + +> Tools **MÜSSEN** ein sinnvolles `content`-Array zurückgeben, auch wenn eine UI verfügbar ist. + +Das Modell liest `content`; der iframe ist für Menschen. Ein UI-fähiger Host füttert das Modell trotzdem mit dem Textergebnis, und ein reiner Text-Client bekommt *nur* das. Das kanonische Muster ist also: ein Tool, zwei Antworten. Sieh dir `get_time` noch einmal an: + +```python title="server.py" hl_lines="23-27" +--8<-- "docs_src/apps/tutorial001.py" +``` + +`client_supports_apps(ctx)` ist nur dann `True`, wenn der Client die Extension `io.modelcontextprotocol/ui` deklariert **und** `text/html;profile=mcp-app` in seinen `mimeTypes`-Einstellungen aufgeführt hat. Das Feld ist Pflicht, ein Client, der es weglässt, zählt also nicht. Genau das deklariert `main()` in derselben Datei: die Client-Hälfte der Aushandlung – und die reichhaltige Antwort kommt zurück. + +!!! warning + Gib niemals einen Platzhalter wie `"[Rendered UI]"` als einzigen Inhalt zurück. Wenn der Fallback-Text nutzlos ist, ist das Tool für jeden reinen Text-Client und für das Modell selbst nutzlos. Schreib den Satz. + +## Den iframe abriegeln {#locking-the-iframe-down} + +Die Ressourcenseite trägt die Sicherheitsmetadaten: was der iframe laden darf, welche Browser-Berechtigungen er möchte, wie er eingebettet werden will: + +```python title="server.py" hl_lines="9 19-22" +--8<-- "docs_src/apps/tutorial002.py" +``` + +`csp` und `permissions` sind **Anfragen an den Host**, kein Serververhalten. Der Host baut daraus die Content-Security-Policy und die Permissions-Policy des iframes, und er darf ablehnen. Prüfe in deinem JS per Feature Detection, statt eine Zusage vorauszusetzen. + +`ResourceCsp`, Feld für Feld (Python-Name, Schlüssel auf der Leitung, was der Host damit macht): + +| Python | Leitung (`_meta.ui.csp`) | Steuert | +|---|---|---| +| `connect_domains` | `connectDomains` | `connect-src`: wohin `fetch`/XHR gehen darf | +| `resource_domains` | `resourceDomains` | `img-src`, `style-src`, ...: statische Assets | +| `frame_domains` | `frameDomains` | `frame-src`: verschachtelte iframes | +| `base_uri_domains` | `baseUriDomains` | `base-uri`: worauf `` zeigen darf | + +`ResourcePermissions`: Jedes Feld fordert eine Browser-Berechtigung für den iframe an. + +| Python | Leitung (`_meta.ui.permissions`) | +|---|---| +| `camera` | `camera` | +| `microphone` | `microphone` | +| `geolocation` | `geolocation` | +| `clipboard_write` | `clipboardWrite` | + +!!! note + CSP und Berechtigungen liegen auf der **Ressource**, nie auf dem Tool. Die Tool-Metadaten der Spezifikation haben keinen Platz dafür, und Hosts ignorieren sie dort. Das SDK macht den Fehler unmöglich: `@apps.tool()` hat schlicht keinen Parameter `csp`. + +### Sichtbarkeit {#visibility} + +`visibility=["app"]` an einem Tool sagt „das existiert für den iframe, nicht für das Modell“: + +* `"model"`: Das Modell darf es aufrufen. +* `"app"`: Der iframe darf es aufrufen (über `callServerTool`). +* Weggelassen: beide, das ist der Standardwert. + +Filtern ist Aufgabe des **Hosts**. Dein Server listet reine App-Tools in `tools/list` wie alle anderen; der Host verbirgt sie vor dem Modell. Filtere nicht serverseitig. + +## Die Regeln, die das SDK durchsetzt {#the-rules-the-sdk-enforces} + +All das schlägt beim Start fehl, nicht in Produktion: + +* Ein `resource_uri` oder ein Ressourcen-URI, der nicht `ui://...` ist, ist ein `ValueError` zum Zeitpunkt der Dekoration bzw. Registrierung. +* Ein Tool, das an einen URI **ohne passende registrierte Ressource** gebunden ist, ist ein `ValueError`, wenn `MCPServer(extensions=[apps])` die Extension übernimmt. Ein Tool, das HTML bewirbt, das bei `resources/read` mit 404 antwortet, ist eine Fehlkonfiguration, also verweigert der Server die Konstruktion. +* `meta={"ui": ...}` an `@apps.tool()` ist ein `ValueError`. `_meta["ui"]` gehört dem Dekorator; sag es mit `resource_uri=` und `visibility=`. Andere `meta=`-Schlüssel werden daneben problemlos zusammengeführt. + +Weder das TypeScript-ext-apps-SDK noch FastMCP fängt heute irgendetwas davon ab; uns ist lieber, du erfährst es, bevor ein Host es tut. + +## Über Inline-HTML hinaus {#beyond-inline-html} + +`add_html_resource` deckt den häufigen Fall ab: einen String mit HTML. Für alles andere, HTML auf der Platte oder generierte Inhalte, baust du die Ressource selbst und reichst sie weiter: + +```python title="server.py" hl_lines="12 18" +--8<-- "docs_src/apps/tutorial003.py" +``` + +`add_resource` ergänzt den MIME-Typ `text/html;profile=mcp-app`, wenn die Ressource keinen explizit setzt, und weist einen expliziten Widerspruch zurück: Eine `ui://`-Ressource unter einem anderen MIME-Typ rendert kein Host. + +!!! tip + Du zielst auf einen Pre-GA-Host, der noch den veralteten flachen Schlüssel `_meta["ui/resourceUri"]` liest? Führe ihn selbst zusammen: + `@apps.tool(resource_uri="ui://x", meta={"ui/resourceUri": "ui://x"})`. + Das verschachtelte `ui`-Objekt ist die Form der Spezifikation; der flache Schlüssel ist auf dem Weg nach draußen. + +## Laufen sehen {#see-it-run} + +Die Story `apps` in `examples/stories/` ist diese Seite als lauffähiges Paar: ein Server mit einem UI-gebundenen Uhr-Tool und ein Client, der Apps aushandelt, die `_meta.ui.resourceUri` des Tools liest, das HTML holt und das Tool aufruft. + +```bash +uv run python -m stories.apps.client +``` diff --git a/i18n/de/pages/advanced/extensions.md b/i18n/de/pages/advanced/extensions.md new file mode 100644 index 0000000000..79745141de --- /dev/null +++ b/i18n/de/pages/advanced/extensions.md @@ -0,0 +1,172 @@ +--- +translation: + sections: [05891e7cc1938a13, b3c01a6af28c51ee, 7ffc91f5e38bdfe0, 717d3f235a8333a7, f471a13b2fe5d737, ed6af2df4b656dff] + tool: 1 +--- +# Extensions {#extensions} + +Eine **Extension** ist ein optionales Bündel von MCP-Verhalten hinter einem einzigen Identifier. + +Auf einem Server kann sie Tools, Ressourcen und neue Request-Methoden beisteuern, und sie kann `tools/call` umhüllen. Auf einem Client kann sie zusätzliche Ergebnisformen von `tools/call` für sich beanspruchen und Vendor-Benachrichtigungen beobachten. Jede Seite kündigt sie unter ihrem eigenen `capabilities.extensions` an, und für alle, die nicht darum gebeten haben, ändert sich nichts. Das ist der Vertrag ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)), und er hat eine goldene Regel: **Extensions sind standardmäßig aus**. + +## Eine Extension verwenden {#using-an-extension} + +Übergib Instanzen bei der Konstruktion: + +```python title="server.py" +--8<-- "docs_src/extensions/tutorial001.py" +``` + +Fertig. Der Server kündigt jetzt `io.modelcontextprotocol/ui` unter `capabilities.extensions` an und bedient alles, was die Extension beisteuert. + +`Apps` ist die eingebaute Referenz-Extension und bekommt eine eigene Seite: **[MCP Apps](apps.md)**. + +!!! note + Extensions stehen bei der Konstruktion fest. Es gibt kein `add_extension`, das du später aufrufen könntest: Die Capability-Map eines Servers sollte sich nicht ändern, während Clients mit ihm verbunden sind. + +Die Capability-Map reist mit `server/discover`, und das ist ein Pfad von **2026-07-28**. Ein Legacy-`initialize`-Handshake hat keinen Platz dafür, also sieht ein Legacy-Client die Extension schlicht nicht. Plane das ein: Eine Extension *ergänzt* einen Server, sie darf nicht der einzige Weg sein, auf dem der Server nutzbar ist. + +## Eine eigene Extension schreiben {#writing-your-own} + +Leite von `Extension` ab und überschreibe nur, was du brauchst. Jede Methode hat eine Standardimplementierung. + +### Der Identifier {#the-identifier} + +```python +--8<-- "docs_src/extensions/tutorial002.py" +``` + +Der Identifier ist ein `vendor-prefix/name`-String nach der `_meta`-Schlüsselgrammatik der Spezifikation: durch Punkte getrennte Labels (jedes beginnt mit einem Buchstaben und endet mit einem Buchstaben oder einer Ziffer), ein Schrägstrich, dann der Name. Er wird **bei der Definition der Klasse** validiert, ein Tippfehler wartet also nicht darauf, dass ein Server startet: + +```text +TypeError: Stamps.identifier must be a `vendor-prefix/name` string +(reverse-DNS prefix required), got 'stamps' +``` + +Nimm als Präfix eine Domain, die du kontrollierst. `io.modelcontextprotocol/*` ist Extensions vorbehalten, die das MCP-Projekt selbst spezifiziert. + +### Tools beisteuern {#contributing-tools} + +Die kleinste nützliche Extension besteht aus einem Tool und einer Settings-Map: + +```python title="server.py" hl_lines="17 19-20 22-23 26" +--8<-- "docs_src/extensions/tutorial003.py" +``` + +* `tools()` gibt `ToolBinding`s zurück. Der Server registriert jedes einzelne genau so, als hättest du selbst `mcp.add_tool(...)` aufgerufen: dieselbe Schema-Generierung, dieselbe `Context`-Injection, alles gleich. +* `settings()` ist der Wert, der unter `capabilities.extensions["com.example/stamps"]` angekündigt wird. Gib `{}` zurück (den Standardwert), um die Extension ohne Settings anzukündigen. +* Die Extension bekommt den Server nie in die Hand. Sie deklariert ihre Beiträge als Daten; `MCPServer` verarbeitet sie. Es gibt kein `self.server`, das sie verändern könnte. + +Und `main()` ist der Beweis, ein In-Memory-Client direkt gegen `mcp`: + +```python title="server.py" hl_lines="29-34" +--8<-- "docs_src/extensions/tutorial003.py" +``` + +### Eigene Methoden bedienen {#serving-your-own-methods} + +Eine Extension kann **neue Request-Methoden** registrieren: eigene Verben, bedient neben denen der Spezifikation: + +```python title="server.py" hl_lines="16-22 31 40-48" +--8<-- "docs_src/extensions/tutorial004.py" +``` + +* `SearchParams` leitet von `RequestParams` ab, sodass der `_meta`-Umschlag von 2026 einheitlich geparst wird und dein Handler validierte Parameter bekommt, nie ein rohes Dict. Begrenze, was der Client kontrolliert: `Field(ge=1, le=100)` weist ein absurdes `limit` zurück, bevor dein Code irgendetwas dafür alloziert. +* `require_client_extension(ctx, EXTENSION_ID)` ist die Schranke: Ein Client, der die Extension nicht deklariert hat, bekommt den Fehler `-32021` (missing required client capability), samt der maschinenlesbaren `requiredCapabilities`-Payload, die die Spezifikation verlangt. +* `protocol_versions=frozenset({"2026-07-28"})` heftet die Methode an genau eine Protokollversion auf der Leitung. Bei jeder anderen Version bekommt der Client `METHOD_NOT_FOUND`, genau so, als gäbe es die Methode dort nicht. Für diesen Client gibt es sie auch nicht. + +Methoden sind **strikt additiv**. Das SDK erzwingt das bei der Konstruktion, nicht zur Laufzeit: + +* Ein `MethodBinding` für eine in der Spezifikation definierte Methode (`tools/list`, `completion/complete`, ...) löst `ValueError` aus, wenn das Binding konstruiert wird. Kernverben gehören dem Server. +* Zwei Extensions, die dieselbe Methode binden, lösen eine Exception aus, sobald sich die zweite registriert. Last-write-wins ist genau der Weg, auf dem Plugins einander beschädigen; das machen wir nicht. +* Ein leeres `protocol_versions`-Set löst ebenfalls eine Exception aus: Eine Methode, die nie bedient werden kann, ist ein Bug, keine Konfiguration. + +### Die Client-Seite {#the-client-side} + +Das `main()` derselben Datei ist die ganze Client-Geschichte, beide Hälften davon: + +```python title="server.py" hl_lines="54-58" +--8<-- "docs_src/extensions/tutorial004.py" +``` + +* `Client(..., extensions=[advertise(EXTENSION_ID)])` deklariert die Extension. Die Deklarationen werden zu `ClientCapabilities.extensions`: Auf einer 2026-07-28-Verbindung reist die Map im `_meta`-Umschlag jedes einzelnen Requests, der Server sieht sie also bei **jedem** Request; auf einer Legacy-Verbindung reist sie mit dem `initialize`-Handshake. Dem Server-Code ist das egal: `require_client_extension(ctx, ...)` und `ctx.session.check_client_capability(...)` lesen auf beiden Pfaden die richtige Quelle. +* Vendor-Methoden steigen eine Schicht tiefer zu `client.session.send_request(...)` hinab; `Client` bekommt nur für Verben der Spezifikation eigene Methoden. `send_request` akzeptiert jede `Request`-Unterklasse, der Vendor-Request geht also unverändert durch. + +### `tools/call` abfangen {#intercepting-toolscall} + +Der eine eingreifende Hook. Überschreibe `intercept_tool_call`, um einen Tool-Aufruf zu beobachten, kurzzuschließen oder zu verhindern: + +```python title="server.py" hl_lines="17-24" +--8<-- "docs_src/extensions/tutorial005.py" +``` + +* `params` sind die validierten `CallToolRequestParams`: Du bekommst `params.name` und `params.arguments`, ohne rohes JSON anzufassen. Sie entscheiden auch, welcher Tool-Aufruf läuft: Reichst du über `call_next` einen umgeschriebenen Kontext weiter, ändert das, was der Handler auf `ctx` sieht, nicht den Tool-Aufruf selbst. Das Umschreiben von Requests auf Leitungsebene gehört in die [Middleware](middleware.md). +* `call_next(ctx)` führt den Rest der Kette aus und gibt das Ergebnis des Handlers zurück. Gib es unverändert zurück (beobachten), gib etwas anderes zurück (ersetzen) oder löse einen `MCPError` aus (ablehnen). Was immer du zurückgibst, wird wie jedes Handler-Ergebnis serialisiert, einschließlich des `serverInfo`-Identitätsstempels der 2026er-Generation, ein kurzschließender Interceptor erzeugt also nie eine anonyme oder vom Schema abweichende Response. +* Bei mehreren Extensions schachteln sich die Interceptors in Registrierungsreihenfolge: Die erste Extension in `extensions=[...]` liegt ganz außen. +* Die Standardimplementierung reicht einfach durch, und ein Server, dessen Extensions diesen Hook nie überschreiben, behält den nackten `tools/call`-Handler unangetastet. Du zahlst nicht für das, was du nicht nutzt. + +Der Hook umhüllt `tools/call` und sonst nichts. Für alles, was jede Nachricht betrifft, nimm [Middleware](middleware.md). Dafür ist sie da. + +## Eine Client-Extension verwenden {#using-a-client-extension} + +Eine **Client-Extension** ist derselbe Vertrag von der konsumierenden Seite: ein Bündel clientseitigen Verhaltens hinter einem einzigen Identifier. Übergib Instanzen an `Client(extensions=[...])` und rufe Tools ganz normal auf: + +```python title="client.py" hl_lines="66-68" +--8<-- "docs_src/extensions/tutorial006.py" +``` + +`call_tool("buy", ...)` gibt ein gewöhnliches `CallToolResult` zurück, wie jeder andere Aufruf. Was die Extension geändert hat: Der Server darf `buy` jetzt mit einer `receipt`-**Ergebnisform** statt mit einem endgültigen Ergebnis beantworten, und `Receipts` bringt sie zu Ende (hier, indem sie den Beleg mit einem Folgeaufruf einlöst), bevor `call_tool` zurückkehrt. An der Aufrufstelle bewegt sich nichts. + +Lass die Extension weg, und nichts davon existiert: Die Schranke des Servers weist einen Client ab, der sie nicht deklariert hat (Fehler -32021), und eine beanspruchte Form von einem Server, der die Schranke überspringt, fällt durch die Validierung, genau wie die Spezifikation es für einen unbekannten `resultType` verlangt. Standardmäßig aus, an beiden Enden der Leitung. + +Um einen Identifier **ohne** clientseitiges Verhalten anzukündigen (der Server prüft die Capability, der Client tut nichts, wie beim Search-Client oben), nimm `advertise()`: + +```python +from mcp.client import advertise + +client = Client(mcp, extensions=[advertise("com.example/search")]) +``` + +## Eine Client-Extension schreiben {#writing-a-client-extension} + +Leite von `ClientExtension` ab und überschreibe nur, was du brauchst. Drei Arten von Beiträgen, jede mit einer Standardimplementierung: `settings()`, `claims()` und `notifications()`. + +```python title="client.py" hl_lines="17-18 43-44 46-47" +--8<-- "docs_src/extensions/tutorial006.py" +``` + +* Der Identifier folgt derselben Grammatik wie auf dem Server und wird validiert, wenn die Klasse definiert wird. +* `claims()` gibt `ResultClaim`s zurück: ein Tag auf der Leitung, das Model, das es parst, und der Resolver, der es zu Ende bringt. Das Model muss das Tag mit `result_type: Literal["receipt"]` festlegen und darf nicht von den Kern-Ergebnistypen des Verbs ableiten; beides wird erzwungen, wenn der Claim konstruiert wird. Vendor-Felder wie `receipt_token` gehen unverändert über die Leitung: Eine ersetzte Form erreicht den Client wortwörtlich. +* Der Resolver erhält das geparste Model und einen `ClaimContext`; `ctx.session` ist derselbe öffentliche Griff wie `client.session`, Folgeaufrufe sind also gewöhnliche Session-Aufrufe. Er gibt das normale `CallToolResult` des Verbs zurück. +* `settings()` ist der Wert, der unter `ClientCapabilities.extensions[identifier]` angekündigt wird, einmal bei der Konstruktion von `Client` gelesen. + +`notifications()` deklariert Vendor-Benachrichtigungen des Servers, die beobachtet werden sollen: + +```python +def notifications(self) -> Sequence[NotificationBinding[Any]]: + return [NotificationBinding(method="notifications/receipts", params_type=ReceiptEvent, handler=self.on_receipt)] +``` + +Der Handler erhält validierte Parameter, eine Benachrichtigung nach der anderen, in Dispatch-Reihenfolge. Er beobachtet; ein Veto einlegen oder antworten kann er nicht. + +Zwei stille Regeln. Claims sind nur auf 2026-07-28-Verbindungen aktiv, und die Capability-Ankündigung folgt ihnen: Auf einer Legacy-Verbindung lösen sich die Claims auf, und der Identifier fällt mit ihnen aus der Ankündigung heraus, der Client kündigt also nie eine Extension an, deren Formen er zurückweisen würde. Und wenn du die beanspruchte Form selbst statt des Resolvers haben willst, rufe `client.session.call_tool(..., allow_claimed=True)` auf; ohne dieses Flag löst eine beanspruchte Form, die bei einem Aufrufer auf Session-Ebene ankommt, `UnexpectedClaimedResult` aus. + +### Extension-Verben {#extension-verbs} + +Die eigenen Request-Methoden einer Extension brauchen keine clientseitige Registrierung. Ein Vendor-Request-Typ leitet von `mcp.types.Request` ab und geht durch `client.session.send_request`, wie in [Eigene Methoden bedienen](#serving-your-own-methods). Eine Ergänzung: Wenn ein Params-Schlüssel im `Mcp-Name`-Header mitreisen muss (Extension-Spezifikationen wie Tasks verlangen das für ihre Verben), deklariert der Request-Typ `name_param`: + +```python title="client.py" hl_lines="22-25 46-47" +--8<-- "docs_src/extensions/tutorial007.py" +``` + +Die Session spiegelt `params["jobId"]` auf jedem Sendepfad in `Mcp-Name`, und ein fehlender Wert scheitert laut, statt einen erforderlichen Header stillschweigend wegzulassen. + +## Was eine Extension nicht kann {#what-an-extension-cannot-do} + +Die Fläche für Beiträge ist absichtlich **geschlossen**. Auf dem Server: Settings, Tools, Ressourcen, Methoden, ein `tools/call`-Interceptor. Auf dem Client: Settings, Result-Claims, Notification-Bindings. Eine Extension kann nicht: + +* **In den Host hineingreifen.** Sie deklariert Daten; sie hält keine Referenz auf Server oder Client. +* **Kernverhalten ersetzen.** Methoden der Spezifikation und Kern-Ergebnistags werden bei der Konstruktion abgewiesen (`initialize` reserviert der Runner von vornherein für sich); ein Notification-Binding, das vom Kernvokabular überdeckt wird, verstummt stattdessen mit einer Warnung. +* **Sich nachträglich registrieren.** Sobald `MCPServer(...)` oder `Client(...)` zurückgekehrt ist, ist die Menge der Extensions, wie sie ist. + +Wenn du gegen diese Wände ankämpfst, schreibst du keine Extension. Du schreibst einen Fork. Die Wände sind das Feature: Wer `extensions=[Apps(), Stamps()]` liest, weiß *alles*, was diese beiden angefasst haben können. diff --git a/i18n/de/pages/advanced/index.md b/i18n/de/pages/advanced/index.md new file mode 100644 index 0000000000..ed5e2b1d44 --- /dev/null +++ b/i18n/de/pages/advanced/index.md @@ -0,0 +1,34 @@ +--- +translation: + sections: [ca6988b7503cd2d3] + tool: 1 +--- +# Für Fortgeschrittene {#advanced} + +Alles, was ein gewöhnlicher Server oder Client braucht, hat in den Abschnitten oben sein thematisches Zuhause. +Dieser Abschnitt versammelt die Notausgänge, zu denen du greifst, wenn die Komfortschicht +von `MCPServer` im Weg ist: + +* **[Der Low-Level-Server](low-level-server.md)**: die Klasse, auf der `MCPServer` aufbaut. + Handgeschriebene Schemas, `on_*`-Handler, keine Prüfungen, die dir abgenommen werden, und + eigene JSON-RPC-Methoden. +* **[Paginierung](pagination.md)** und **[Middleware](middleware.md)**: zwei Dinge, die + *nur* auf dem Low-Level-`Server` gehen. +* **[Erweiterungen](extensions.md)** und **[MCP Apps](apps.md)**: die + Erweiterungsfläche des Protokolls. Kombiniere Erweiterungspakete zu einem Server oder schreibe deine eigenen. + +Einiges, was du mit gutem Grund hier suchen könntest, steht stattdessen dort, wo du es +tatsächlich einsetzt: + +* **Autorisierung** steht unter **[Den Server betreiben](../run/index.md)**, weil du + einen Server dort schützt, wo du ihn bereitstellst. +* **OAuth**, **Identity Assertion**, die Verbindung zu **mehreren Servern** und der + Response-**Cache** stehen alle unter **[Clients](../client/index.md)**. +* **Multi-Roundtrip-Requests** (multi-round-trip requests) und **Abonnements** stehen unter + **[Im Handler](../handlers/index.md)**, weil beides etwas ist, das ein + Handler *tut*. +* **URI-Templates** steht unter **[Server](../servers/index.md)**, neben den Ressourcen. +* **[Protokollversionen](../protocol-versions.md)** und + **[Veraltete Features](../deprecated.md)** haben jeweils eine eigene Seite auf oberster Ebene. + +Wenn du nicht sicher bist, ob du diesen Abschnitt brauchst, brauchst du ihn nicht. diff --git a/i18n/de/pages/advanced/low-level-server.md b/i18n/de/pages/advanced/low-level-server.md new file mode 100644 index 0000000000..55f07bdffd --- /dev/null +++ b/i18n/de/pages/advanced/low-level-server.md @@ -0,0 +1,207 @@ +--- +translation: + sections: [2c79b6338e09b7ac, 7edc43b3fae11314, 1086e77ce561cd7f, a3f71823df5efc31, 9fc7109f72201cae, 7bf25983df655b66, 6330e1f4c6029683, 2f1749c8c133fa1c, b3530fcf4d11fd56, ebc33704fbd74262, cd0e9c933350390e] + tool: 1 +--- +# Der Low-Level-Server {#the-low-level-server} + +`@mcp.tool()` ist eine Schicht. Darunter liegt eine zweite Server-Klasse, `Server`, die rohes MCP spricht: Du gibst ihr die Protokollobjekte, und sie legt sie unverändert auf die Leitung. + +`MCPServer` ist darauf aufgebaut. Du steigst hinab, wenn die Komfortschicht im Weg ist: + +* Du musst ein **exaktes** Schema ausgeben (aus einer Datei geladen, aus einer Datenbank generiert), nicht eines, das aus einer Python-Signatur abgeleitet ist. +* Du brauchst die volle Kontrolle über das Ergebnis: `_meta`, `is_error`, jeden Schlüssel von `structured_content`. +* Du musst eine Methode behandeln, die MCP nicht definiert. + +Für alles andere bleib bei `MCPServer`. + +## Dasselbe Tool, von Hand {#the-same-tool-by-hand} + +Das ist das Tool `search_books`, das **[Tools](../servers/tools.md)** in neun Zeilen `@mcp.tool()` schreibt, ohne den Zucker: + +```python title="server.py" hl_lines="22 26 32" +--8<-- "docs_src/lowlevel/tutorial001.py" +``` + +Drei Dinge haben sich geändert, und sie sind die ganze Low-Level-API: + +* **Handler sind Konstruktorparameter.** `on_list_tools=` und `on_call_tool=` wandern in `Server(...)`. Hier unten gibt es keine Dekoratoren, und jeder Handler hat dieselbe Form: `async (ctx, params) -> result`. +* **Du schreibst das Input-Schema.** `Tool.input_schema` ist ein schlichtes JSON-Schema-`dict`. Niemand leitet es aus Type Hints ab, denn es gibt keine Type Hints, aus denen man es ableiten könnte. +* **Du baust das Ergebnis.** `CallToolResult(content=[TextContent(...)])`, von Hand. Nichts wird verpackt, konvertiert oder aus einer Rückgabeannotation abgeleitet. + +`params` ist der geparste Request: `CallToolRequestParams` gibt dir `.name` und `.arguments`. `ctx` ist ein `ServerRequestContext`: `ctx.session` zum Zurücksprechen an den Client, `ctx.lifespan_context`, `ctx.request_id` und `ctx.meta`, das eingehende `_meta` des Requests. + +!!! info + Wenn du FastAPI kennst, kennst du diese Beziehung bereits. `MCPServer` ist die Schicht aus Dekoratoren und Type Hints; `Server` ist das Starlette darunter. Sie sind keine Rivalen: `MCPServer` erzeugt einen `Server` und registriert darauf genau solche Handler wie diese. + +### Ausprobieren {#try-it} + +Hierfür gibt es keinen Inspector: `mcp dev` und `mcp run` akzeptieren nur einen `MCPServer`. Dem In-Memory-`Client` ist das egal; er nimmt einen Low-Level-`Server` genauso wie einen `MCPServer`: + +```python title="main.py" +import asyncio + +from mcp import Client + +from server import server + + +async def main() -> None: + async with Client(server) as client: + result = await client.call_tool("search_books", {"query": "dune", "limit": 5}) + print(result.content) + + +asyncio.run(main()) +``` + +```text +[TextContent(type='text', text="Found 3 books matching 'dune' (showing up to 5).", annotations=None, meta=None)] +``` + +Derselbe Text, den die `@mcp.tool()`-Version erzeugt hat. Zwei ehrliche Unterschiede: + +* `result.structured_content` ist `None`. Der High-Level-Server verpackt ein `-> str` für dich in `{"result": ...}`; hier baut niemand, was du nicht gebaut hast. +* `list_tools` gibt das Schema zurück, das **du** getippt hast, Zeichen für Zeichen. Die High-Level-Version hatte `"title": "Query"` auf jeder Property und ein `"title": "search_booksArguments"` an der Wurzel: Pydantic-Artefakte. Hier unten gilt: Was auf der Leitung ist, hast du dort hingelegt. + +## Nichts wird für dich geprüft {#nothing-is-checked-for-you} + +`MCPServer` weist ein fehlerhaftes Argument ab, bevor deine Funktion überhaupt läuft, indem er den Aufruf gegen das generierte Schema validiert (**[Tools](../servers/tools.md)**). + +`Server` tut das nicht. Dein `input_schema` wird dem Client *angekündigt*; es wird nie auf `params.arguments` *angewendet*. + +!!! check + Ruf `search_books` ohne `limit` auf, und dein `args["limit"]` löst einen `KeyError` aus. Der Client sieht: + + ```text + MCPError: Internal server error + ``` + + Ein JSON-RPC-Fehler, Code `-32603`, mit einer bewusst generischen Meldung: Das SDK gibt deinen Traceback nicht an einen entfernten Aufrufer preis. Das Modell erfährt nie, was es falsch gemacht hat, kann es also nicht erneut versuchen. (In einem Test bringt `raise_exceptions=True` stattdessen die echte Exception zum Vorschein; siehe **[Testen](../get-started/testing.md)**.) + +Das lässt sich verallgemeinern. Eine Exception, die ein Low-Level-Handler auslöst, ist **immer** ein Protokollfehler, nie ein Tool-Ergebnis mit `is_error=True`. Wenn das Modell den Fehlschlag lesen und sich erholen soll, validiere `params.arguments` selbst und gib `CallToolResult(content=[TextContent(...)], is_error=True)` zurück. Die beiden Arten von Fehlschlägen sind das Thema von **[Fehler behandeln](../servers/handling-errors.md)**. + +## Zwei Tools, ein Handler {#two-tools-one-handler} + +`on_call_tool` ist der einzige Einstiegspunkt für jedes Tool auf dem Server. Du verzweigst über `params.name`: + +```python title="server.py" hl_lines="38-43" +--8<-- "docs_src/lowlevel/tutorial002.py" +``` + +* `list_tools` kündigt beide an. `call_tool` verteilt nach dem Namen. +* Der `else`-Zweig ist wichtig: `Server` leitet ein `tools/call` für einen Namen, den du nie gelistet hast, bereitwillig direkt in deinen Handler weiter. Löst du dort eine Exception aus, wird aus dem Aufruf dasselbe `-32603` wie oben. + +## Strukturierte Ausgabe, von Hand {#structured-output-by-hand} + +Deklariere `output_schema` auf dem `Tool` und setze `structured_content` auf das Ergebnis. Beides liegt bei dir: + +```python title="server.py" hl_lines="19-23 36" +--8<-- "docs_src/lowlevel/tutorial003.py" +``` + +Ruf es auf, und das Ergebnis trägt beide Darstellungen: + +```json +{ + "content": [{"type": "text", "text": "Found 3 books matching 'dune'."}], + "structuredContent": {"matches": 3, "query": "dune"}, + "isError": false, + "resultType": "complete", + "_meta": {"io.modelcontextprotocol/serverInfo": {"name": "Bookshop", "version": "2.0.0"}} +} +``` + +Der `_meta`-Block ist der Identitätsstempel des Servers: Das SDK fügt ihn jedem Ergebnis der 2026er-Generation hinzu, mit der `version` aus dem Konstruktor (ein Server, der keine setzt, meldet einen leeren String). Ein Server, der sich nicht zu erkennen geben darf, kann den Schlüssel mit einer Middleware entfernen, der die Ergebnisse gehören, die sie zurückgibt. + +Der Server vergleicht die beiden Felder nie. Der `Client` dieses SDK schon: Gibst du `structured_content` zurück, das das von dir deklarierte `output_schema` nicht erfüllt, löst `call_tool` einen `RuntimeError` aus, der mit `Invalid structured content returned by tool search_books` beginnt und dann den `jsonschema`-Fehler zitiert. Ein Schema zu versprechen ist billig; es einzuhalten liegt bei dir. Die ganze Stufenleiter der Rückgabetypen und Schemas steht in **[Strukturierte Ausgabe](../servers/structured-output.md)**. + +## `_meta`: für die Anwendung, nicht für das Modell {#\_meta-for-the-application-not-the-model} + +`content` ist der Teil der Antwort, den das Modell liest. `structured_content` ist dieselbe Antwort als typisierte Daten. `_meta` ist der dritte Kanal: Daten, die mit dem Ergebnis für die **Client-Anwendung** mitreisen, ohne überhaupt Teil der Antwort zu sein. + +Nutze es für Datensatz-IDs, Trace-IDs, alles, was deine UI braucht und dein Prompt nicht: + +```python title="server.py" hl_lines="37" +--8<-- "docs_src/lowlevel/tutorial004.py" +``` + +* Du erzeugst es als `_meta=`, dem Namen auf der Leitung. Der Client liest es als `result.meta` zurück. +* Versieh deine Schlüssel mit einem Namensraum (`bookshop/record_ids`). Die Schlüssel `io.modelcontextprotocol/*` sind vom Protokoll reserviert. + +!!! warning + `_meta` ist eine Konvention zwischen dir und der Client-Anwendung, keine Garantie darüber, was + das Modell erreicht. Der Host entscheidet, was er darstellt. Lege niemals ein Geheimnis in irgendeinen Teil eines Tool-Ergebnisses. + +## Capabilities folgen deinen Handlern {#capabilities-follow-your-handlers} + +Ein `Server` kündigt genau die Methodenfamilien an, für die du ihm Handler gegeben hast. Der `Bookshop` oben übergibt `on_list_tools` und `on_call_tool` und sonst nichts, also sieht ein Client, der sich mit ihm verbindet: + +```json +{"tools": {"listChanged": false}} +``` + +Kein `resources`, kein `prompts`: Es gibt nichts, was dahinter stünde. Übergib `on_list_prompts`, und `prompts` erscheint; übergib `on_completion`, und `completions` erscheint. + +`MCPServer` kündigt Tools, Ressourcen und Prompts immer an, ob du welche registriert hast oder nicht, weil seine Manager immer existieren. Hier unten *ist* die Deklaration der Konstruktoraufruf. + +## Der Lifespan-Generic {#the-lifespan-generic} + +`Server` ist generisch im Typ, den sein Lifespan liefert. Annotiere ihn einmal, und das Objekt ist überall typisiert, wo es auftaucht: + +```python title="server.py" hl_lines="24-26 44-45 50" +--8<-- "docs_src/lowlevel/tutorial005.py" +``` + +* Der Lifespan ist ein `Callable[[Server[Catalog]], AbstractAsyncContextManager[Catalog]]`; `@asynccontextmanager` auf einem `async`-Generator gibt dir genau das. +* Was immer er per `yield` liefert, wird zu `ctx.lifespan_context`, und weil die Handler mit `ServerRequestContext[Catalog]` annotiert sind, funktionieren Autovervollständigung und Typprüfung für `.search(...)`. +* Er wird einmal betreten, wenn der Server startet, und einmal verlassen, wenn er stoppt. Start, Abbau und die Variante derselben Idee in `MCPServer` stehen in **[Lifespan](../handlers/lifespan.md)**. + +Ohne ein `lifespan=` ist `ctx.lifespan_context` ein leeres `dict`. + +## Eine eigene Methode {#a-method-of-your-own} + +Der Konstruktor deckt die Methoden ab, die MCP definiert. `add_request_handler` deckt alles andere ab: + +```python title="server.py" hl_lines="35-36 39-40 43-44 48" +--8<-- "docs_src/lowlevel/tutorial006.py" +``` + +* Das erste Argument ist der Methoden-String. Benachrichtigungen haben ein Gegenstück, `add_notification_handler`. +* `params_type` ist das Modell, gegen das die eingehenden `params` validiert werden, **bevor** dein Handler läuft – eigene Methoden bekommen also *doch* die Validierung, die Tools nicht bekommen. Leite von `RequestParams` ab, damit das Feld `_meta` so geparst wird wie bei jeder anderen Methode. +* Der Handler gibt ein `BaseModel`, ein `dict` oder `None` zurück. Das SDK serialisiert es in das JSON-RPC-Ergebnis. + +Ein ehrlicher Vorbehalt: Der High-Level-`Client` hat nur Verben für die Methoden, die MCP definiert, es gibt also kein `client.reindex()`. Eine Vendor-Methode ist für eine Gegenstelle gedacht, die bereits weiß, dass es sie gibt: ein Client, den du ebenfalls auslieferst, oder ein anderer deiner Dienste, der JSON-RPC spricht. + +Eine Methode, die du nicht beanspruchen kannst: + +```text +ValueError: 'initialize' is handled by the server runner and cannot be overridden; +use Server.middleware to observe or wrap initialization +``` + +Der Handshake gehört dem Runner. `server/discover`, `ping` und jeden anderen Built-in darfst du ersetzen. + +!!! tip + `Server.middleware`, in dieser Fehlermeldung erwähnt, umhüllt **jede** eingehende Nachricht, `initialize` eingeschlossen. Wenn du Verkehr beobachten oder umschreiben willst, statt eine neue Methode zu beantworten, fang bei **[Middleware](middleware.md)** an. + +## Die übrigen Handler {#the-other-handlers} + +Jeder davon ist eine Idee, für die du jetzt das Vokabular hast; jeder hat seine eigene Seite. + +* `on_call_tool`, `on_get_prompt` und `on_read_resource` dürfen statt ihres normalen Ergebnisses ein `InputRequiredResult` zurückgeben, um den Aufruf anzuhalten und den Client um Eingaben zu bitten; siehe **[Multi-Roundtrip-Requests](../handlers/multi-round-trip.md)** (multi-round-trip requests). Getreu dieser Ebene wird nichts für dich installiert: Wo `MCPServer` `requestState` standardmäßig versiegelt, geht hier der `request_state`, den du setzt, genau so über die Leitung, wie du ihn geschrieben hast, bis du dich mit `server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[...]), default_audience=server.name))` dafür entscheidest: eine Zeile (beide Namen lassen sich aus `mcp.server.request_state` importieren) für genau die Versiegelung und Verifizierung, die `MCPServer` vornimmt (**[`requestState` schützen](../handlers/multi-round-trip.md#protecting-requeststate)**). +* `on_list_resources`, `on_read_resource`, `on_list_prompts`, `on_get_prompt`, `on_completion` haben dieselbe Form `(ctx, params) -> result` für die anderen Primitive. +* `on_subscriptions_listen` bedient den Stream `subscriptions/listen` aus 2026-07-28. Übergib einen `ListenHandler`, der auf einem `SubscriptionBus` aufgebaut ist, und veröffentliche Ereignisse aus deinen anderen Handlern auf dem Bus; die vollständige Zusammensetzung steht in **[Abonnements](../handlers/subscriptions.md)**. +* `server.streamable_http_app()` gibt dieselbe Starlette-App zurück wie die von `MCPServer`; stelle sie bereit, wie **[Den Server betreiben](../run/index.md)** jede andere ASGI-App bereitstellt. Hier unten gibt es kein `server.run(transport=...)`: `server.run(read_stream, write_stream, server.create_initialization_options())` treibt eine Verbindung über ein Paar Streams, und diese eine Zeile ist alles. + +## Zusammenfassung {#recap} + +* Der Low-Level-`Server` nimmt seine Handler als `on_*`-**Konstruktorparameter**; jeder Handler ist `async (ctx, params) -> result`. +* Du schreibst das `input_schema`-dict und du baust das `CallToolResult`. Nichts wird für dich abgeleitet, verpackt oder validiert. +* Eine Exception in einem Handler ist ein `-32603`-Protokollfehler. Ein Tool-Fehler, den das Modell lesen kann, ist ein `CallToolResult` mit `is_error=True`, das **du** zurückgibst. +* `_meta` auf dem Ergebnis richtet sich an die Client-Anwendung, nicht an das Modell. +* `Server[T]` ist generisch in dem, was sein Lifespan liefert; `ctx.lifespan_context` ist ein typisiertes `T`. +* `add_request_handler(method, params_type, handler)` bedient jede Methode. `initialize` ist reserviert. +* Die Capabilities, die ein `Server` ankündigt, leiten sich davon ab, welche Handler du registriert hast. + +`Client(server)` hat beide Server identisch behandelt, weil sie dasselbe Protokoll *sind* – und genau darum geht es. Die nächste Schicht darunter ist gar keine Klasse: Es ist **[Middleware](middleware.md)**. diff --git a/i18n/de/pages/advanced/middleware.md b/i18n/de/pages/advanced/middleware.md new file mode 100644 index 0000000000..b4879126c4 --- /dev/null +++ b/i18n/de/pages/advanced/middleware.md @@ -0,0 +1,127 @@ +--- +translation: + sections: [6048b4f308edbb8c, 068bda0f21ee9c1b, c3e565b61acd75c5, c62422b159c6ed09, 47204fab253cc45c] + tool: 1 +--- +# Middleware {#middleware} + +Eine **Middleware** ist eine einzelne async-Funktion, die jede Nachricht umschließt, die dein Server empfängt. + +Du schreibst sie als `async (ctx, call_next)` und hängst sie an `server.middleware` an. Das ist die ganze API. + +!!! warning + Die Middleware-Liste ist im Quellcode als **provisorisch** markiert: Signatur und Semantik können + sich in einem 2.x-Minor-Release ändern. Nutze sie zum *Beobachten* (Timing, Logging, Tracing) und + zum *Ablehnen* von Nachrichten; mache sie nicht zum Fundament, auf dem dein Server steht. + +`MCPServer` nimmt die Liste bei der Konstruktion entgegen (`MCPServer(name, middleware=[...])`) und stellt +sie als `mcp.middleware` bereit; der Low-Level-`Server` stellt dieselbe Liste als `server.middleware` +bereit. Das Beispiel unten verwendet den Low-Level-`Server`; wenn `Server(name, on_call_tool=...)` neu +für dich ist, lies zuerst **[Der Low-Level-Server](low-level-server.md)**. + +## Eine Timing-Middleware {#a-timing-middleware} + +Ein Server, ein Tool, eine Middleware, die loggt, wie lange jede Nachricht gedauert hat: + +```python title="server.py" hl_lines="39-45 49" +--8<-- "docs_src/middleware/tutorial001.py" +``` + +* `ctx` ist derselbe `ServerRequestContext`, den deine Handler erhalten. `ctx.method` ist der rohe + Methoden-String; `ctx.params` sind die rohen Params, **vor** jeder Validierung. +* `call_next(ctx)` führt den Rest der Kette aus: Validierung, die Handler-Suche, deinen Handler. + Gib zurück, was es zurückgegeben hat, und die Response bleibt unverändert. +* Das `try`/`finally` ist Absicht: Ein Handler, der eine Exception auslöst, wird trotzdem gemessen, + denn der Fehlschlag erreicht deine Middleware als Exception aus `call_next`. +* `server.middleware.append(...)` registriert sie. Die Liste läuft von außen nach innen, also ist + `middleware[0]` diejenige, die am nächsten an der Leitung sitzt. + +### Ausprobieren {#try-it} + +Verbinde einen Client, liste die Tools auf, rufe eines auf. Dein Log hat **drei** Zeilen: + +```text +server/discover took 18.3 ms +tools/list took 0.1 ms +tools/call took 0.1 ms +``` + +Du hast zwei Aufrufe gemacht und drei Zeilen bekommen. Die erste ist `server/discover`: der Request, +den der Client zum Aufbau der Verbindung geschickt hat, bevor du irgendetwas angefordert hast. + +Genau darum geht es. Middleware umschließt **jede** eingehende Nachricht: + +* Den Verbindungsaufbau: `server/discover`, oder `initialize` und `notifications/initialized` + in einer Legacy-Session. +* Jeden Request und jede Benachrichtigung. Bei einer Benachrichtigung gilt `ctx.request_id is None`, + `call_next(ctx)` gibt `None` zurück, und was immer du zurückgibst, wird verworfen. +* Sogar eine Methode, für die der Server keinen Handler hat: `call_next` wirft den + `MCPError(-32601, "Method not found")` *durch* deine Middleware hindurch auf dem Weg zum Client. + +## Was du in einer Middleware tun kannst {#what-you-can-do-inside-one} + +In aufsteigender Reihenfolge danach, wie sehr du zögern solltest: + +* **Beobachten.** Miss es, zähle es, logge es. Das Beispiel oben. +* **Ablehnen.** Wirf einen `MCPError` *statt* `call_next(ctx)` aufzurufen, und diese eine Nachricht + wird mit einem JSON-RPC-Fehler beantwortet. Die Verbindung bleibt bestehen; die nächste Nachricht + geht durch. So beschränkt ein Server `subscriptions/listen` pro Aufrufer: + **[Entscheiden, wer zusehen darf](../handlers/subscriptions.md#deciding-who-may-watch)** auf der + Seite Abonnements führt es Schritt für Schritt vor. +* **Umschreiben.** `ctx` ist eine Dataclass: `await call_next(dataclasses.replace(ctx, params=...))` + reicht dem Rest der Kette andere Params weiter, als der Client geschickt hat. Tu das nie mit + `initialize`: Das Ergebnis, das der Client zurückbekommt, wird aus deinen umgeschriebenen Params + gebaut, aber der Server legt seinen Verbindungszustand anhand der ursprünglichen Params von der + Leitung fest. Beide Seiten können den Handshake beenden und sich dabei uneinig sein, was sie + ausgehandelt haben. +* **Antworten.** Gib ein Ergebnis zurück, ohne `call_next(ctx)` aufzurufen, und es geht als deine + Response an den Client. `call_next` reicht dir die fertige Form für die Leitung, und die Pipeline + bessert nie nach, was du zurückgibst – der ganze Umschlag gehört also dir: Auf einer Verbindung der + 2026er-Generation gehört dazu der `serverInfo`-Stempel in `_meta`, den das SDK an Handler-Ergebnisse + anfügt, an deine aber nicht. + +!!! check + `initialize` gehört zu dem, was Middleware umschließt, und es ist der *einzige* Hook, den du + dafür bekommst. Versuchst du, es mit `add_request_handler` zu übernehmen, weigert sich das SDK: + + ```text + ValueError: 'initialize' is handled by the server runner and cannot be overridden; + use Server.middleware to observe or wrap initialization + ``` + +!!! warning + `initialize` wird inline behandelt: Der Server liest keine weiteren eingehenden Nachrichten, bis + deine Middleware-Kette zurückkehrt. Auf einen Server-zu-Client-Request zu warten + (`ctx.session.send_request(...)`, eine Elicitation – Rückfrage bei der Person am Host), während + `initialize` behandelt wird, **blockiert die Verbindung** daher **dauerhaft** (Deadlock): Die + Response, auf die du wartest, kann nie gelesen werden. Benachrichtigungen nach dem + Fire-and-forget-Prinzip sind in Ordnung. + +## Die eine Middleware, die standardmäßig aktiv ist {#the-one-middleware-that-ships-on-by-default} + +Das SDK liefert genau eine Middleware mit, und sie steht bereits auf der Liste deines Servers: die, +die für jede Nachricht einen OpenTelemetry-Span ausgibt. Du hängst sie nicht an, und meistens denkst +du gar nicht an sie. Sie tut nichts, bis du einen Exporter installierst, und sie hat ihre eigene Seite: +**[OpenTelemetry](../run/opentelemetry.md)**. + +!!! info + Wenn du schon ASGI-Middleware geschrieben hast, kennst du diese Form bereits. Aus Starlettes + `(scope, receive, send)` wurde `(ctx, call_next)`, und sie läuft *nach* dem Transport, auf der + dekodierten Nachricht statt auf dem rohen HTTP-Request. Beide lassen sich kombinieren: + Starlette-Middleware auf `streamable_http_app()` sieht HTTP; diese hier sieht MCP. + +## Zusammenfassung {#recap} + +* Eine Middleware ist `async (ctx, call_next) -> result`, übergeben als `MCPServer(middleware=[...])` + (oder an `mcp.middleware` angehängt) und beim Low-Level-`Server` an `server.middleware` angehängt. +* Sie umschließt **jede** eingehende Nachricht (`server/discover`, `initialize`, Requests, + Benachrichtigungen, unbekannte Methoden) und läuft von außen nach innen. +* An `ctx.request_id is None` unterscheidest du eine Benachrichtigung von einem Request. +* Wirf eine Exception, statt `call_next` aufzurufen, um eine einzelne Nachricht abzulehnen; die + Verbindung überlebt. +* Das OpenTelemetry-Tracing des SDK ist ebenfalls eine Middleware und steht schon auf der Liste. Siehe + **[OpenTelemetry](../run/opentelemetry.md)**. +* Die ganze Oberfläche ist provisorisch. Beobachte damit; baue nicht darauf. + +Das ist alles, was einen Request umschließt. **[Autorisierung](../run/authorization.md)** entscheidet, +ob der Request überhaupt laufen darf. diff --git a/i18n/de/pages/advanced/pagination.md b/i18n/de/pages/advanced/pagination.md new file mode 100644 index 0000000000..a082b294a0 --- /dev/null +++ b/i18n/de/pages/advanced/pagination.md @@ -0,0 +1,85 @@ +--- +translation: + sections: [a9aba7a026c7bd85, ed32bda7ba9ae33a, 7e64cc5646abb91f, 22a0129ee78b3c63, d875373c06d8d2f9] + tool: 1 +--- +# Paginierung {#pagination} + +Die meisten Server brauchen das nie. + +`MCPServer` beantwortet jeden `list_*`-Request mit allem, was er hat, auf einer Seite, `next_cursor=None`. Bei ein paar Dutzend Tools, Ressourcen oder Prompts ist das die richtige Antwort, und es gibt nichts zu konfigurieren. + +Paginierung ist für den Server gedacht, dessen Ressourcenliste in Wahrheit eine Datenbank ist: Tausende Zeilen, die er nicht in einer einzigen Response serialisieren will. Die Antwort des Protokolls darauf ist ein **Cursor**: Der Server gibt eine Seite plus ein opakes Token zurück, und der Client schickt dieses Token zurück, um die nächste Seite zu bekommen. + +`@mcp.resource()` hat dafür keinen Einstiegspunkt. Um seitenweise auszuliefern, schreibst du den List-Handler selbst, auf dem **[Low-Level-Server](low-level-server.md)**. + +## Ein Server, der paginiert {#a-server-that-pages} + +```python title="server.py" hl_lines="12 15-16" +--8<-- "docs_src/pagination/tutorial001.py" +``` + +* Auf einem Low-Level-`Server` sind Handler Konstruktorargumente, keine Dekoratoren. `on_list_resources` beantwortet jeden `resources/list`-Request; mehr Verkabelung gibt es nicht. +* Jeder paginierte Handler ist als `params: PaginatedRequestParams | None` typisiert, und das Beispiel akzeptiert beides. Über eine Verbindung übergibt dir das SDK jedoch nie `None` (ein Request ohne `params`-Member erreicht den Handler als Modell mit seinen Standardwerten). Das Signal, auf das es ankommt, ist daher `params.cursor is None`: **von vorne beginnen**. +* Du entscheidest, was ein Cursor *ist*. Hier ist es ein Offset, als String dargestellt. Ein Zeitstempel, ein Primärschlüssel, ein Base64-Blob: alles, was du beim Herausgeben erzeugen und beim Zurückkommen wiedererkennen kannst. +* Mit `next_cursor=None` sagst du „das war die letzte Seite“. Es gibt keine Anzahl, keine Gesamtsumme, kein `has_more`. `None` ist das ganze Signal. + +!!! tip + Eine `PAGE_SIZE` von 10 macht das Beispiel lesbar. Wähle deine pro Endpunkt: Eine Liste + einzeiliger Ressourcen verträgt eine Seite mit 500 Einträgen; eine Liste fetter Prompt-Templates nicht. + Der Client hat dabei nichts mitzureden, und das ist Absicht. + +### Ausprobieren {#try-it} + +`Client(server)` verbindet sich im Speicher mit einem Low-Level-`Server` genau so, wie er sich mit einem `MCPServer` verbindet. + +Rufe `list_resources()` ohne Argumente auf. Du bekommst zehn Ressourcen, `book-1` bis `book-10`, und `next_cursor` ist der String `"10"`. + +Gib ihn mit `list_resources(cursor="10")` zurück, und die erste Ressource ist `book-11`, der neue `next_cursor` ist `"20"`. + +Die zehnte Seite kommt mit `next_cursor` auf `None` zurück. Fertig. + +## Die Client-Schleife {#the-client-loop} + +Jede `list_*`-Methode auf `Client` (`list_tools`, `list_resources`, `list_resource_templates`, `list_prompts`) nimmt ein Keyword-Argument `cursor=`. Eine paginierte Liste leerzulesen ist ein einziges `while True`: + +```python title="client.py" hl_lines="26-32" +--8<-- "docs_src/pagination/tutorial002.py" +``` + +* `cursor` beginnt als `None`, der erste Request trägt also keinen Cursor. +* Erweitere die Liste, **bevor** du auf `next_cursor` schaust: Auch die letzte Seite enthält Ressourcen. +* `next_cursor is None` ist der Ausstieg. Alles andere geht unverändert direkt zurück in `cursor=`. + +Führe sein `main()` aus, und es gibt `100 resources` aus: zehn Seiten zu je zehn, zusammengefügt von einer Schleife, die nie wusste, dass es zehn Seiten waren. + +Das ist dieselbe Schleife, die **[Der Client](../client/index.md)** für jedes `list_*`-Verb zeigt, und sie kostet nichts gegenüber einem Server, der nicht paginiert: `next_cursor` ist schon in der ersten Response `None`, und die Schleife läuft genau einmal. + +## Die drei Regeln {#the-three-rules} + +**Cursor sind opak.** Ein Client darf einen Cursor nie parsen, bauen oder erraten. Die einzige zulässige Quelle eines Cursors ist der `next_cursor` der vorherigen Seite, wortwörtlich. + +**Der Server bestimmt die Seitengröße.** Es gibt kein `limit=` im Protokoll. Wenn du eine andere Seitengröße brauchst, änderst du den Server. + +**Ein Client, der Paginierung ignoriert, funktioniert trotzdem.** Er ruft `list_resources()` einmal auf, bekommt die ersten zehn und bemerkt den `next_cursor`, den er weggeworfen hat, nie. Nichts geht kaputt; er sieht nur weniger. + +!!! check + Opak heißt opak. Erfinde einen Cursor (`list_resources(cursor="page-2")`), und das + Protokoll kann nichts für dich tun. Dieser Server versucht `int("page-2")`, der Handler löst eine Exception aus, + und beim Client kommt an: + + ```text + MCPError(-32603, 'Internal server error', None) + ``` + + Ein Cursor, den du nicht vom Server bekommen hast, ist ein Bug, kein Feature-Wunsch. + +## Zusammenfassung {#recap} + +* `MCPServer` gibt alles auf einer Seite zurück. Paginierung ist optional, und du aktivierst sie auf dem Low-Level-`Server`. +* `on_list_resources` (und `on_list_tools`, `on_list_prompts`, `on_list_resource_templates`) erhält `PaginatedRequestParams | None`; `params.cursor` ist bei der ersten Seite `None`. +* Du gibst eine Seite plus `next_cursor` zurück: einen beliebigen String, den du später wiedererkennst, oder `None`, wenn nichts mehr übrig ist. +* Die Client-Schleife: `cursor=` übergeben, sammeln, wiederholen, bis `next_cursor is None`. +* Cursor sind opak, die Seitengröße gehört dem Server, und ein Client ohne Paginierung bekommt trotzdem Seite eins. + +Der Rest der handgeschriebenen `Server`-API (`on_call_tool`, `input_schema`-Dicts, `_meta`) steht in **[Der Low-Level-Server](low-level-server.md)**. diff --git a/i18n/de/pages/client/caching.md b/i18n/de/pages/client/caching.md new file mode 100644 index 0000000000..8860243502 --- /dev/null +++ b/i18n/de/pages/client/caching.md @@ -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. diff --git a/i18n/de/pages/client/callbacks.md b/i18n/de/pages/client/callbacks.md new file mode 100644 index 0000000000..ba7103a335 --- /dev/null +++ b/i18n/de/pages/client/callbacks.md @@ -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. diff --git a/i18n/de/pages/client/identity-assertion.md b/i18n/de/pages/client/identity-assertion.md new file mode 100644 index 0000000000..bdd38318d8 --- /dev/null +++ b/i18n/de/pages/client/identity-assertion.md @@ -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. diff --git a/i18n/de/pages/client/index.md b/i18n/de/pages/client/index.md new file mode 100644 index 0000000000..84de50aa17 --- /dev/null +++ b/i18n/de/pages/client/index.md @@ -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)**. diff --git a/i18n/de/pages/client/oauth-clients.md b/i18n/de/pages/client/oauth-clients.md new file mode 100644 index 0000000000..bc2a86dc23 --- /dev/null +++ b/i18n/de/pages/client/oauth-clients.md @@ -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)**. diff --git a/i18n/de/pages/client/session-groups.md b/i18n/de/pages/client/session-groups.md new file mode 100644 index 0000000000..e94089b2f0 --- /dev/null +++ b/i18n/de/pages/client/session-groups.md @@ -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)**. diff --git a/i18n/de/pages/client/subscriptions.md b/i18n/de/pages/client/subscriptions.md new file mode 100644 index 0000000000..e8de33af9f --- /dev/null +++ b/i18n/de/pages/client/subscriptions.md @@ -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. diff --git a/i18n/de/pages/client/transports.md b/i18n/de/pages/client/transports.md new file mode 100644 index 0000000000..b7870b5883 --- /dev/null +++ b/i18n/de/pages/client/transports.md @@ -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. diff --git a/i18n/de/pages/deprecated.md b/i18n/de/pages/deprecated.md new file mode 100644 index 0000000000..6cffeac039 --- /dev/null +++ b/i18n/de/pages/deprecated.md @@ -0,0 +1,98 @@ +--- +translation: + sections: [20541a40dbdd5980, 01262a123ad9501d, 429db5b574a2ac08, 56b2d49da412cb28, 6a1717123fe4513c] + tool: 1 +--- +# Veraltete Features {#deprecated-features} + +Die Spec 2026-07-28 mustert fünf Dinge aus. Das SDK implementiert jedes davon weiterhin, und jedes davon trägt jetzt eine **Deprecation-Warnung**. + +Die Tabelle unten nennt jedes veraltete Feature, den Grund, warum es verschwindet, und den Ersatz, auf dem du aufbauen solltest. + +## Was veraltet ist {#what-is-deprecated} + +| Veraltet | Warum | Was du stattdessen tust | +|---|---|---| +| **Roots**: `ctx.session.list_roots()`, `client.send_roots_list_changed()`, der `list_roots_callback=`, den du an `Client(...)` übergibst | [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) mustert die Capability aus. | Nimm die Pfade als gewöhnliche Tool-Argumente oder Ressourcen-URIs entgegen, oder bette einen `ListRootsRequest` in ein `InputRequiredResult` ein (siehe **[Multi-Roundtrip-Requests](handlers/multi-round-trip.md)**). | +| **Serverseitig initiiertes Sampling**: `ctx.session.create_message()`, der `sampling_callback=`, den du an `Client(...)` übergibst | SEP-2577 mustert die Capability aus. | Gib `InputRequiredResult` zurück und lass den Client den Aufruf wiederholen (siehe **[Multi-Roundtrip-Requests](handlers/multi-round-trip.md)**). | +| **Protokoll-Logging**: `ctx.log()`, `ctx.debug()`, `ctx.info()`, `ctx.warning()`, `ctx.error()`, `ctx.session.send_log_message()`, `client.set_logging_level()` | SEP-2577 mustert die Capability aus. Innerhalb des Protokolls ersetzt sie nichts. | Gewöhnliches `import logging` nach stderr (siehe **[Logging](handlers/logging.md)**). | +| **`ping`**: `client.send_ping()` | Aus dem Protokoll **entfernt**, nicht bloß veraltet. In 2026-07-28 gibt es keine Methode `ping`. | Nichts. Es funktioniert nur gegen eine `mode="legacy"`-Verbindung. | +| **Progress vom Client zum Server**: `client.send_progress_notification()` | 2026-07-28 erlaubt Progress nur noch vom Server zum Client. | Es gibt nichts zu senden. Dein *Server* meldet Fortschritt mit `ctx.report_progress()` (siehe **[Progress](handlers/progress.md)**). | + +Drei Dinge ergeben sich aus dieser Tabelle: + +* Roots, Sampling und Logging gehören zusammen. Ein einziger Vorschlag, **SEP-2577**, erklärt alle drei Capabilities auf einmal für veraltet. +* Sampling und Roots teilen ein tieferes Problem: Es sind Stellen, an denen ein **Server** einen **Request** an den **Client** sendet. Genau diese Richtung ersetzt 2026-07-28 durch **[Multi-Roundtrip-Requests](handlers/multi-round-trip.md)** (multi-round-trip requests). Verschwunden sind die eigenständigen RPC-Methoden (`sampling/createMessage`, `roots/list` und das Push-artige `elicitation/create`); die Payload-Typen `CreateMessageRequest` / `ListRootsRequest` / `ElicitRequest` bleiben erhalten, eingebettet in `InputRequiredResult.input_requests`, und auf dem Client landen sie bei denselben Callbacks. +* `ping` fällt aus der Reihe. Das Protokoll erklärt es nicht für veraltet, es entfernt es. Die SDK-Methode warnt trotzdem (ihre Meldung sagt *removed*, nicht *deprecated*), und ein Aufruf auf einer modernen Verbindung wird mit *„Method not found“* beantwortet. + +## Veraltet ist ein Hinweis, kein Verbot {#deprecated-is-advisory} + +Heute geht nichts kaputt. + +Jede der oben genannten Methoden funktioniert weiterhin gegen jede Session, die **2025-11-25 oder früher** ausgehandelt hat. Pinne `mode="legacy"` auf dem Client, und du bekommst exakt das Verhalten von vor 2026. Auf der Leitung ändert sich nichts, und das Aushandeln der Capabilities bleibt unverändert. + +Was sich ändert: Du bekommst eine sichtbare Warnung, wenn eine davon zum ersten Mal läuft: + +```text +MCPDeprecationWarning: The logging capability is deprecated as of 2026-07-28 (SEP-2577). +``` + +`MCPDeprecationWarning` ist eine Unterklasse von `UserWarning`, **nicht** von `DeprecationWarning`. Das ist Absicht: Pythons Standardfilter zeigt `DeprecationWarning` nur in Code, der direkt als `__main__` läuft – so erklären Bibliotheken Dinge für veraltet, und zwei Jahre lang merkt es niemand. Diese hier erscheint überall, ganz ohne `-W`-Flag. + +!!! warning + Der Hinweischarakter endet an der Leitung. Sampling und Roots sind *Requests* vom Server + an den Client, und eine 2026-07-28-Session hat keinen Kanal, der einen solchen transportiert. + Rufst du `ctx.session.create_message()` in einem Tool auf einer modernen Verbindung auf, + wird die Warnung trotzdem ausgelöst, und danach schlägt das Senden mit einem Fehler fehl: + + ```text + Cannot send 'sampling/createMessage': this transport context has no back-channel + for server-initiated requests. + ``` + + Zwei Signale, in dieser Reihenfolge. Die `MCPDeprecationWarning` wird in dem Moment + ausgelöst, in dem du die Methode aufrufst, auf jeder Verbindung. Der Fehler ist das, was + zurückkommt, wenn das SDK anschließend zu senden versucht. Beide funktionieren nur auf einer + `mode="legacy"`-Verbindung von Anfang bis Ende, deren Client den passenden Callback + registriert hat. + +## Die Warnung unterdrücken {#silencing-the-warning} + +Tu es nicht, in neuem Code. + +Ein Server, den du pflegst und der tatsächlich Clients von vor 2026 bedient, hat aber jedes Recht auf ein ruhiges Log. Filtere die Kategorie, bevor der erste veraltete Aufruf läuft: + +```python +import warnings + +from mcp import MCPDeprecationWarning + +warnings.filterwarnings("ignore", category=MCPDeprecationWarning) +``` + +Das ist die ganze API. Es gibt keinen Schalter pro Methode, und du willst auch keinen: Der Sinn einer einzigen Kategorie ist, dass eine Zeile sie zum Schweigen bringt und eine Zeile sie zurückholt. + +!!! check + Dreh den Filter um, und du bekommst einen Regressionstest geschenkt. Füge + `"error::mcp.MCPDeprecationWarning"` zur Einstellung `filterwarnings` in deiner + pytest-Konfiguration hinzu, und der veraltete Aufruf **wirft eine Exception**, statt zu + warnen. Ein Tool namens `old_log`, das noch `ctx.info()` aufruft, besteht nicht mehr und + meldet stattdessen: + + ```text + Error executing tool old_log: The logging capability is deprecated as of 2026-07-28 (SEP-2577). + ``` + + Eine Zeile pytest-Konfiguration, und ein veralteter Aufruf kann sich nie wieder in deine + Codebasis schleichen, ohne einen Test fehlschlagen zu lassen. + +## Zusammenfassung {#recap} + +* Die Spec 2026-07-28 erklärt **Roots**, serverseitig initiiertes **Sampling** und Protokoll-**Logging** für veraltet (alle [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), beschränkt **Progress** auf die Richtung vom Server zum Client und entfernt **`ping`**. +* Die Ersatzspalte weist dir den Weg: **[Multi-Roundtrip-Requests](handlers/multi-round-trip.md)** für Sampling und Roots, **[Logging](handlers/logging.md)** für Logging, **[Progress](handlers/progress.md)** für Progress. `ping` braucht gar nichts. +* Veraltet ist ein Hinweis: keine Änderungen auf der Leitung, alles funktioniert weiterhin gegen Sessions von vor 2026, und du bekommst eine sichtbare `MCPDeprecationWarning` (eine `UserWarning`, also standardmäßig aktiv). +* Sampling und Roots brauchen zusätzlich einen Rückkanal (back-channel), den eine 2026-07-28-Session nicht hat. Auf einer modernen Verbindung warnen sie und werfen dann eine Exception. +* `warnings.filterwarnings("ignore", category=MCPDeprecationWarning)` bringt die ganze Kategorie zum Schweigen; `"error::mcp.MCPDeprecationWarning"` in pytest macht daraus einen fehlschlagenden Test. +* Neuer Code sollte auf nichts davon aufbauen. + +Jede andere Seite dieser Dokumentation vermittelt die aktuelle API. diff --git a/i18n/de/pages/get-started/first-steps.md b/i18n/de/pages/get-started/first-steps.md new file mode 100644 index 0000000000..e841e02b2a --- /dev/null +++ b/i18n/de/pages/get-started/first-steps.md @@ -0,0 +1,144 @@ +--- +translation: + sections: [0d6c05bcbf836bf3, 59a7b14eeefc68c1, 7114d8d6daba203f, e8bbb56a98ba7bc9, 5138010f6159901c, f78da7c7c363d4c6, 220a939cab348686] + tool: 1 +--- +# Erste Schritte {#first-steps} + +Die **[Startseite](../index.md)** legt ein hohes Tempo vor: einen Server schreiben, ihn starten, ein Tool aufrufen. + +Diese Seite geht es langsam an – mit allen drei Dingen, die ein Server bereitstellen kann, und einem Namen für alles, was unterwegs auftaucht. + +## Host, Client und Server {#host-client-and-server} + +Drei Wörter, die dir ab hier auf jeder Seite begegnen: + +* Ein **Host** ist die LLM-Anwendung: Claude, eine IDE, eine Agent-Laufzeitumgebung. Mit ihm spricht die Person. +* Ein **Client** lebt im Host und spricht MCP. Der Host betreibt einen Client pro Server, mit dem er verbunden ist. +* Ein **Server** ist das, was du mit diesem SDK baust. Er stellt Clients Dinge bereit. Mit dem Modell spricht er nie direkt. + +Du schreibst den Server. Hosts sind das Produkt anderer. Das SDK gibt dir außerdem einen `Client`. Mit ihm testest du deine Server, und er taucht weiter unten auf dieser Seite auf. + +## Die drei Primitive {#the-three-primitives} + +Ein Server stellt genau drei Arten von Dingen bereit. Was sie unterscheidet, ist, **wer über ihren Einsatz entscheidet**: + +| Primitiv | Gesteuert von | Was es ist | Beispiel | +|----------------|--------------------|--------------------------------------------------------------------|-----------------------------------------| +| **Tools** | Dem Modell | Eine Funktion, die das Modell aufruft, um etwas zu tun | Ein API-Aufruf, ein Datenbank-Schreibzugriff | +| **Ressourcen** | Der Anwendung | Daten, die der Host in den Kontext des Modells lädt | Der Inhalt einer Datei, eine API-Response | +| **Prompts** | Der Person am Host | Eine wiederverwendbare Nachrichtenvorlage, die die Person über ihren Namen aufruft | Ein Slash-Befehl, ein Menüeintrag | + +„Gesteuert von“ ist der ganze Sinn dieser Aufteilung. Ein Tool läuft, weil das **Modell** entschieden hat, es aufzurufen. Eine Ressource wird angehängt, weil die **Anwendung** entschieden hat, dass das Modell sie braucht. Ein Prompt läuft, weil die **Person** ihn ausgewählt hat. + +!!! info + Wenn du schon einmal eine Web-API gebaut hast, hast du das meiste Gespür bereits: Eine + **Ressource** ist ein `GET` (sie lädt Daten und ändert nichts) und ein **Tool** ist ein `POST` + (es erledigt Arbeit und kann Seiteneffekte haben). Ein **Prompt** hat keine HTTP-Entsprechung; + er ähnelt eher einer gespeicherten Abfrage, die die Person über ihren Namen ausführt. + +## Ein Server, alle drei {#one-server-all-three} + +```python title="server.py" hl_lines="6 12 18" +--8<-- "docs_src/first_steps/tutorial001.py" +``` + +Drei gewöhnliche Funktionen, drei Dekoratoren. Jeder Dekorator ist die gesamte Registrierung: + +* `@mcp.tool()` macht `add` zu einem **Tool**. +* `@mcp.resource("greeting://{name}")` macht `greeting` zu einem **Ressourcen-Template**: Das `{name}` im URI ist der Parameter der Funktion. +* `@mcp.prompt()` macht `summarize` zu einem **Prompt**. Der String, den die Funktion zurückgibt, wird zu einer User-Nachricht. + +Alles andere (den Namen, die Beschreibung, das Argument-Schema) liest das SDK aus der Funktion selbst: ihrem Namen, ihrem Docstring, ihren Type Hints. Du hast nichts davon separat deklariert. + +!!! tip + Die beiden Hälften des SDK haben zwei Importpfade: `from mcp import Client` und + `from mcp.server import MCPServer`. Ein `from mcp import MCPServer` gibt es nicht. + +### Ausprobieren {#try-it} + +Starte ihn mit dem MCP Inspector: + +```console +uv run mcp dev server.py +``` + +Öffne die URL, die er ausgibt. Der Inspector hat einen Tab pro Primitiv; geh sie der Reihe nach durch. + +**Tools.** Ein Eintrag: `add`, beschrieben als *Add two numbers.* Das Formular hat ein erforderliches Ganzzahlfeld für `a` und ein weiteres für `b`. Füll sie aus, ruf das Tool auf, und das Ergebnis ist `3`. Der Inspector hat dieses Formular aus `a: int, b: int` gebaut. Jeder andere Client macht es genauso. + +**Resources.** Die Liste *Resources* ist leer. `greeting` steht unter **Resource Templates**, weil `greeting://{name}` einen Parameter hat: Es gibt keine einzelne Ressource aufzulisten, bis jemand einen `name` liefert. Gib ihm `World` und lies sie: + +```text +Hello, World! +``` + +**Prompts.** Ein Eintrag: `summarize`, mit einem einzigen erforderlichen Argument `text`. Ruf ihn mit etwas Text ab, und du erhältst eine Nachricht mit `role: user` und deinem gerenderten String als Inhalt. Mehr ist ein Prompt nicht: eine Funktion, die Nachrichten baut. + +Der Inspector hat deinen Server über **stdio** betrieben, einen der Transporte, die ein MCP-Server sprechen kann. Du wählst noch keinen aus; dafür gibt es die Seite **[Den Server betreiben](../run/index.md)**. + +## Capabilities {#capabilities} + +Du hast im Inspector drei Tabs gesehen. Woher wusste er, dass es drei sind? + +Wenn sich ein Client verbindet, deklariert der Server seine **Capabilities**: welche Familien von Requests er beantwortet. Der Client entscheidet anhand dieser Deklaration, wonach er überhaupt fragt. Du hast sie nie geschrieben; `MCPServer` deklariert sie für dich. + +Sieh es dir selbst an. Der `Client` des SDK nimmt das Server-Objekt direkt entgegen und verbindet sich **im Speicher** damit (kein Subprozess, kein Port): + +```python +import asyncio + +from mcp import Client + +from server import mcp + + +async def main() -> None: + async with Client(mcp) as client: + print(client.server_capabilities.model_dump(exclude_none=True)) + + +asyncio.run(main()) +``` + +```text +{'prompts': {'list_changed': True}, 'resources': {'subscribe': True, 'list_changed': True}, 'tools': {'list_changed': True}} +``` + +Dieses Dictionary sind die deklarierten **Capabilities** deines Servers. Es ist das Erste, was jeder Client beim Verbinden erfährt: + +| Capability | Der Client darf jetzt aufrufen | +|-------------|-------------------------------------------------------------| +| `tools` | `tools/list`, `tools/call` | +| `resources` | `resources/list`, `resources/templates/list`, `resources/read` | +| `prompts` | `prompts/list`, `prompts/get` | + +`MCPServer` bedient alle drei Primitive, also werden immer alle drei deklariert. + +Achte darauf, was fehlt. `completions` (die automatische Vervollständigung von Argumenten für Ressourcen-Templates und Prompts) braucht einen Handler, den du schreibst. Dieser Server hat keinen, also fehlt die Capability, und ein wohlerzogener Client fragt gar nicht erst. Das ist die Regel für alles Optionale: Registriere das Ding, und die Capability erscheint; **[Vervollständigungen](../servers/completions.md)** zeigt es. + +!!! info + `Client(mcp)` ist derselbe In-Memory-Client, mit dem jedes Beispiel in dieser Dokumentation + getestet wird, und so testest du auch deine. Er bekommt eine ganze Seite: **[Testen](testing.md)**. + +## Was du nicht geschrieben hast {#what-you-did-not-write} + +Blick auf diese Seite zurück. Du hast drei kleine Python-Funktionen geschrieben. **Nicht** geschrieben hast du: + +* Ein JSON-Schema. `a: int, b: int` *ist* das Schema für `add`. +* Einen Request-Handler. `tools/list`, `resources/read`, `prompts/get`: alles für dich bedient. +* Eine Capability-Deklaration. `MCPServer` hat sie für dich erstellt. +* Eine Zeile Protokoll. Die Versionsaushandlung, das JSON-RPC-Framing, der Austausch der Capabilities: Das alles passierte in `mcp dev` und `Client(mcp)`, und du hast es nie gesehen. + +Dieses Verhältnis ist der ganze Sinn des SDK. + +## Zusammenfassung {#recap} + +* Ein **Host** ist die LLM-App, ein **Client** ist ihre MCP-sprechende Hälfte, ein **Server** ist das, was du baust. +* Tools steuert das **Modell**, Ressourcen steuert die **Anwendung**, Prompts steuert die **Person**. +* Ein Dekorator pro Primitiv: `@mcp.tool()`, `@mcp.resource(uri)`, `@mcp.prompt()`. Name, Beschreibung und Schema kommen aus der Funktion. +* Ein URI mit einem `{param}` ergibt ein Ressourcen-**Template**, das getrennt von konkreten Ressourcen aufgelistet wird. +* Die **Capabilities** des Servers werden für dich deklariert, und ein Client fragt nur nach dem, was ein Server deklariert. +* `Client(mcp)` verbindet sich im Speicher mit dem Server-Objekt: deine Testumgebung vom ersten Tag an. + +Als Nächstes kommt **[Mit einem echten Host verbinden](real-host.md)**: dieser Server in Claude Desktop oder einer IDE, in echt. Danach **[Testen](testing.md)**: eine Seite, ein In-Memory-Client, und du musst nie raten, ob es funktioniert. Danach bekommt jedes Primitiv seine eigene Seite, angefangen mit dem, das das Modell steuert: **[Tools](../servers/tools.md)**. diff --git a/i18n/de/pages/get-started/index.md b/i18n/de/pages/get-started/index.md new file mode 100644 index 0000000000..4cdc309e50 --- /dev/null +++ b/i18n/de/pages/get-started/index.md @@ -0,0 +1,57 @@ +--- +translation: + sections: [ed4a756b4c53c585, 97e2fb315b7fe398, 4d04f1c6f4bf6c1d, 577d73078fc62baf] + tool: 1 +--- +# Einstieg {#get-started} + +Neu bei MCP oder neu bei diesem SDK? Fang hier an. Diese Seiten bringen dich von null zu einem +funktionierenden, getesteten Server: [das SDK installieren](installation.md), den +[ersten Server](first-steps.md) bauen, [ihn mit einem echten Host verbinden](real-host.md) und +[ihn testen](testing.md) – mit einem In-Memory-Client. + +## Den Code ausführen {#run-the-code} + +Alle Codeblöcke lassen sich direkt kopieren und verwenden: Es sind vollständige, lauffähige Dateien. + +Um mitzumachen, füge einen Block in eine `server.py` ein und öffne sie im MCP Inspector: + +```console +uv run mcp dev server.py +``` + +Es wird **DRINGEND empfohlen**, den Code selbst zu schreiben (oder zu kopieren), ihn zu bearbeiten und lokal auszuführen. Erst im eigenen Editor zeigt sich, worum es geht: wie wenig du schreibst, die Autovervollständigung, die Typprüfungen, die Fehler abfangen, bevor du überhaupt etwas ausführst. + +## Kein Rätselraten {#you-will-not-be-guessing} + +Jedes Beispiel in dieser Dokumentation ist eine vollständige Datei unter [`docs_src/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/docs_src) im Repository des SDK selbst, und jedes einzelne wird von der Testsuite des SDK über einen **In-Memory-Client** ausgeführt: + +```python +import pytest +from mcp import Client + +from server import mcp + + +@pytest.mark.anyio +async def test_add() -> None: + async with Client(mcp) as client: + result = await client.call_tool("add", {"a": 1, "b": 2}) + assert result.structured_content == {"result": 3} +``` + +Kein Subprozess, kein Port, kein Transport. `Client(mcp)` verbindet sich direkt mit dem Server-Objekt. + +Wenn eine Änderung am SDK ein Beispiel auf einer dieser Seiten kaputt macht, wird die CI rot, bevor es die Seite tut. Der Code, den du hier liest, ist der Code, der läuft. + +Das wirst du in [Testen](testing.md) selbst verwenden; so testest du auch deine eigenen Server. + +## Wie es weitergeht {#where-to-go-next} + +Sobald ein Server läuft, ist der Rest dieser Dokumentation ein Nachschlagewerk, kein Kurs. +Jede Seite steht für sich, spring also direkt zu dem, was du brauchst: + +* Was ein Server bereitstellt (Tools, Ressourcen, Prompts), steht in **[Server](../servers/index.md)**. +* Was innerhalb der Funktionen, die du registrierst, verfügbar ist, steht in **[Im Handler](../handlers/index.md)**. +* Wie du ihn vor Clients bringst (stdio, HTTP, deine bestehende FastAPI-App), steht in **[Den Server betreiben](../run/index.md)**. +* Wie du die andere Seite baust, eine Anwendung, die MCP-Server *nutzt*, steht in **[Clients](../client/index.md)**. diff --git a/i18n/de/pages/get-started/installation.md b/i18n/de/pages/get-started/installation.md new file mode 100644 index 0000000000..28f67185e3 --- /dev/null +++ b/i18n/de/pages/get-started/installation.md @@ -0,0 +1,48 @@ +--- +translation: + sections: [6e2f9bab94d5ed36, 8cf653388f69e28b, 6fd9ea2f65de0df6] + tool: 1 +--- +# Installation {#installation} + +Das Python-SDK liegt auf PyPI als [`mcp`](https://pypi.org/project/mcp/). Es setzt **Python 3.10+** voraus. + +Diese Dokumentation beschreibt **v2**, die aktuelle stabile Release-Linie: + +=== "uv" + + ```bash + uv add "mcp[cli]" + ``` + +=== "pip" + + ```bash + pip install "mcp[cli]" + ``` + +!!! note "Umstieg von v1?" + v2 ist eine Hauptversion mit inkompatiblen Änderungen; der **[Migrationsleitfaden](../migration.md)** + behandelt jede einzelne davon. Wenn dein *Paket* von `mcp` abhängt und noch nicht bereit für die + Migration ist, behalte eine Obergrenze `<2` bei (zum Beispiel `mcp>=1.28,<2`), damit eine nicht + gepinnte Auflösung auf der 1.x-Linie bleibt. + +## Was installiert wird {#what-gets-installed} + +Nichts davon musst du wissen, um das SDK zu nutzen. Falls du dich aber fragst, wozu die einzelnen Abhängigkeiten da sind: + +* `mcp-types`: jeder Protokolltyp (Requests, Ergebnisse, Content-Blöcke) als eigenes Paket, im Gleichschritt mit dem SDK versioniert. Code, der von `mcp` abhängt, importiert es über den Alias `mcp.types` (jedes `from mcp.types import ...` in dieser Dokumentation); importiere `mcp_types` nur in einem Projekt direkt, das `mcp-types` ohne das SDK installiert. +* [`anyio`](https://anyio.readthedocs.io/): die asynchrone Laufzeit. Das gesamte SDK ist gegen anyio geschrieben und läuft daher sowohl auf `asyncio` als auch auf `trio`. +* [`pydantic`](https://docs.pydantic.dev/): die Grundlage jedes `mcp.types`-Modells, dazu die gesamte Schema-Generierung und Validierung. +* [`httpx2`](https://pypi.org/project/httpx2/): der HTTP-Client hinter den *Client*-Transporten für Streamable HTTP und SSE, mit eingebauter Unterstützung für Server-Sent Events. +* [`starlette`](https://www.starlette.io/), [`uvicorn`](https://www.uvicorn.org/), [`sse-starlette`](https://pypi.org/project/sse-starlette/) und [`python-multipart`](https://pypi.org/project/python-multipart/): die *Server*-Transporte für HTTP. +* [`jsonschema`](https://pypi.org/project/jsonschema/): validiert die strukturierte Ausgabe eines Tools gegen das deklarierte Output-Schema. +* [`pyjwt[crypto]`](https://pyjwt.readthedocs.io/): Verarbeitung von OAuth-Tokens für die Autorisierung. +* [`opentelemetry-api`](https://opentelemetry-python.readthedocs.io/): nur die schlanke API. Die Tracing-Middleware des SDK kostet also nichts, solange du nicht selbst ein OpenTelemetry-SDK samt Exporter installierst. +* [`typing-extensions`](https://typing-extensions.readthedocs.io/) und [`typing-inspection`](https://pypi.org/project/typing-inspection/): moderne Typing-Features auf Python 3.10. +* [`pywin32`](https://pypi.org/project/pywin32/): nur unter Windows, für die Verwaltung von `stdio`-Subprozessen. + +## Optionale Extras {#optional-extras} + +* `mcp[cli]` ergänzt [`typer`](https://typer.tiangolo.com/) und [`python-dotenv`](https://pypi.org/project/python-dotenv/) für das Kommandozeilen-Tool `mcp` (`mcp dev`, `mcp run`, `mcp install`). Während der Entwicklung wirst du das haben wollen; in einem bereitgestellten Server brauchst du es womöglich nicht. +* `mcp[rich]` ergänzt [`rich`](https://rich.readthedocs.io/) für schönere Server-Logs. diff --git a/i18n/de/pages/get-started/real-host.md b/i18n/de/pages/get-started/real-host.md new file mode 100644 index 0000000000..7501860170 --- /dev/null +++ b/i18n/de/pages/get-started/real-host.md @@ -0,0 +1,186 @@ +--- +translation: + sections: [3c4f2f06b4e978b6, 22520eecae3d1961, f4e1709db18d635a, 2eb57992049671d9, 1ba83e9af37cc1b4, 4822586344b08d9e, 1c93afef72478992, b6b448f9eddd51dc, fe55370fd931815b] + tool: 1 +--- +# Mit einem echten Host verbinden {#connect-to-a-real-host} + +Ein **Host** ist die Anwendung, in der dein Server am Ende läuft: Claude Desktop, Claude Code, eine IDE. Der Host ist das, womit die Person spricht. In ihm startet ein MCP-**Client** deinen Server als Kindprozess und spricht mit ihm über stdin und stdout dieses Prozesses. + +Das heißt: Sich mit einem Host zu verbinden ist eine einzige Handlung. Du nennst ihm **den Befehl, der deinen Server startet**. Alles auf dieser Seite (zwei CLI-Befehle, drei JSON-Dateien) ist nur ein anderer Ort für genau diesen Befehl. + +## Ein Server, jeder Host {#one-server-every-host} + +```python title="server.py" hl_lines="3 33-34" +--8<-- "docs_src/real_host/tutorial001.py" +``` + +Zwei Tools und eine Ressource, eine Datei. Drei Dinge an dieser Datei sind für jeden der folgenden Hosts wichtig: + +* `mcp.run()` ohne Argumente startet einen **stdio**-Server: Er blockiert, liest Protokollnachrichten von stdin und schreibt sie auf stdout. Das ist der Transport, den jeder Host auf dieser Seite spricht. Der Host startet deine Datei als Kindprozess und besitzt diese beiden Pipes – deshalb bedeutet Verbinden immer nur „hier ist der Befehl“. Du wählst nie einen Port, und nichts lauscht auf einem. +* `run()` steht unter `if __name__ == "__main__":`. Alles Folgende **importiert** diese Datei, statt sie auszuführen. Ein ungeschütztes `run()` würde also einen Server starten, sobald irgendetwas das Modul lädt. +* Das Server-Objekt ist eine globale Variable auf Modulebene namens `mcp`. Nach diesem Namen sucht `mcp run` (`server` und `app` funktionieren auch). Nennst du es anders, gibst du den Namen explizit an: `mcp run server.py:bookshop`. + +Das war die letzte Zeile Python auf dieser Seite. Ab hier geht es nur noch um Host-Konfiguration. + +## Der Startbefehl {#the-launch-command} + +Jeder der folgenden Hosts bekommt denselben Befehl: + +```bash +uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py +``` + +Ein Befehl für alle, weil `uv run --with` das SDK an Ort und Stelle in eine frische Umgebung auflöst: Er funktioniert aus jedem Verzeichnis und braucht weder ein Projekt noch eine virtuelle Umgebung, die du aktivieren müsstest. Das zählt hier mehr als irgendwo sonst, denn ein Host startet deinen Server aus *seinem* Arbeitsverzeichnis mit einer fast leeren Umgebung, nicht aus deiner Shell. + +Es ist außerdem der Befehl, den `mcp install` für dich in die Konfiguration von Claude Desktop schreibt (siehe unten). Was du von Hand tippst und was das Tool erzeugt, stimmt also überein – bis auf die exakte Versionsangabe, die das Tool ergänzt. + +!!! tip "Wenn ein Host `uv` nicht findet" + Ein Host startet deinen Server mit einem minimalen `PATH`, und `uv` liegt womöglich nicht + darauf. Ersetze das bloße `uv` durch den absoluten Pfad aus `which uv` (macOS/Linux) oder + `where uv` (Windows). Genau das schreibt auch `mcp install`. + +!!! note "Diese Seite beschreibt den lokalen Fall" + Alles hier betreibt deinen Server auf der Maschine, auf der auch der Host läuft: Der Host + startet deine Datei, über stdio. Für ein persönliches Tool oder eines für einen einzelnen + Rechner ist das genau richtig. Um einen Server an Leute zu geben, die deine Datei *nicht* + haben, verteilst du eine **URL**, keinen Befehl: dasselbe `mcp`-Objekt, ausgeliefert über + Streamable HTTP. **[Den Server betreiben](../run/index.md)** fasst diese Entscheidung in + einer Tabelle zusammen, und **[Bereitstellen und skalieren](../run/deploy.md)** ist der Weg + von dort zu einem echten Hostnamen. + + Und ein Host ist nichts weiter als eine Anwendung mit einem MCP-Client darin. Dein eigenes + Python kann also die Rolle des Hosts übernehmen: **[Client-Transporte](../client/transports.md)** + startet genau diese Datei als Subprozess mit `stdio_client(...)`, und **[Testen](testing.md)** + verbindet sich im Speicher mit ihr, ganz ohne Prozess. + +## Claude Desktop {#claude-desktop} + +Der eine Host, den das SDK für dich konfigurieren kann: + +```bash +uv run mcp install server.py +``` + +Das ist alles. `mcp install` importiert die Datei, um den Namen des Servers zu lesen, findet die Konfigurationsdatei von Claude Desktop und schreibt den Startbefehl hinein. Nebenbei wandelt es deinen Pfad in einen absoluten um, damit du es nicht tun musst. + +Daran ist nichts Geheimnisvolles. Das ist der Eintrag, den es schreibt: + +```json +{ + "mcpServers": { + "Bookshop": { + "command": "/absolute/path/to/uv", + "args": [ + "run", + "--frozen", + "--with", + "mcp[cli]==2.0.0", + "mcp", + "run", + "/absolute/path/to/server.py" + ] + } + } +} +``` + +Das ist der Startbefehl aus dem Abschnitt oben mit drei Ergänzungen: dem absoluten Pfad zu `uv`, `--frozen`, damit `uv` nie ein Lockfile umschreibt, das zufällig in der Nähe liegt, und einer exakten Festlegung auf die `mcp`-Version, die du installiert hast. Er landet in `claude_desktop_config.json`, und die liegt hier: + +* **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` +* **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` + +Du kannst diese Datei von Hand schreiben. `mcp install` gibt es, damit dir dabei nicht der klassische Fehler (ein relativer Pfad) unterläuft. + +Beende Claude Desktop vollständig (nicht nur das Fenster) und öffne es erneut. + +!!! warning + `mcp install` schlägt mit `Claude app not found` fehl, wenn das *Konfigurationsverzeichnis* + von Claude Desktop noch nicht existiert. Installiere Claude Desktop und starte es einmal: + Dabei wird das Verzeichnis angelegt. + +!!! tip + Claude Desktop startet deinen Server in einem eigenen Prozess, die Umgebungsvariablen deiner + Shell sind dort also nicht vorhanden. `uv run mcp install server.py -v API_KEY=abc123` (oder + `-f .env`) trägt sie in das Feld `env` des Eintrags ein. `--name` überschreibt den Namen des + Eintrags; standardmäßig ist es der `name` des Servers. + +## Claude Code {#claude-code} + +Es gibt keine Datei zu bearbeiten. Registriere den Server mit dem `claude`-CLI; alles nach `--` ist der Startbefehl. + +```bash +claude mcp add bookshop -- uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py +``` + +Führe `/mcp` in einer Claude-Code-Session aus, um zu prüfen, dass `bookshop` verbunden ist und seine Tools aufgelistet werden. + +## Cursor {#cursor} + +Lege `.cursor/mcp.json` im Wurzelverzeichnis deines Projekts an. + +```json +{ + "mcpServers": { + "bookshop": { + "command": "uv", + "args": ["run", "--with", "mcp[cli]", "mcp", "run", "/absolute/path/to/server.py"] + } + } +} +``` + +Dasselbe `command` plus `args`, unter demselben Schlüssel `mcpServers`, den auch Claude Desktop verwendet. Der Server erscheint in den MCP-Einstellungen von Cursor mit beiden Tools. + +## VS Code {#vs-code} + +Lege `.vscode/mcp.json` im Wurzelverzeichnis deines Projekts an. + +```json +{ + "servers": { + "bookshop": { + "type": "stdio", + "command": "uv", + "args": ["run", "--with", "mcp[cli]", "mcp", "run", "/absolute/path/to/server.py"] + } + } +} +``` + +Zwei Unterschiede zur Datei von Cursor, und es sind die einzigen zwei: Der umschließende Schlüssel heißt `servers`, nicht `mcpServers`, und jeder Eintrag deklariert seinen `type`. Bestätige die Vertrauensabfrage, dann zeigt **MCP: List Servers** in der Befehlspalette `bookshop` als laufend an. + +!!! note + Du brauchst VS Code 1.99 oder neuer mit angemeldeter **GitHub Copilot**-Erweiterung (Copilot + Free genügt), und Copilot Chat muss im Modus **Agent** sein, denn kein anderer Modus ruft + Tools auf. + +## Der Server erscheint nicht {#it-doesnt-show-up} + +Bevor du irgendeine Host-Konfiguration anfasst, führe den Startbefehl selbst aus: + +```bash +uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py +``` + +Es wird nichts ausgegeben, und der Befehl kehrt nicht zurück. Diese Stille ist richtig: Ein stdio-Server wartet darauf, dass ein Host zuerst auf stdin spricht (`Ctrl-C` beendet ihn). Ein Traceback oder ein sofortiges Beenden ist der eigentliche Fehler – und jetzt kannst du ihn lesen, statt ihn durch einen Host hindurch zu erraten. + +Sobald der Befehl dasteht und wartet, bleibt fast immer eine von drei Ursachen: + +* **Ein relativer Pfad.** Der Host startet deinen Server aus *seinem* Arbeitsverzeichnis, nicht aus dem, in dem du ihn registriert hast. `server.py`, wo `/absolute/path/to/server.py` nötig wäre, ist der mit Abstand häufigste Fehler. Findet der Host auch `uv` nicht, muss dieser Pfad ebenfalls absolut sein. +* **Der Host läuft noch mit seiner alten Konfiguration.** Hosts lesen ihre Konfiguration beim Start. Gerade Claude Desktop musst du *vollständig beenden* (nicht nur das Fenster schließen) und neu öffnen, bevor eine Änderung an `claude_desktop_config.json` wirkt. +* **Etwas hat stdout außerhalb des umgeleiteten Zeitfensters erreicht.** Bei stdio *ist* stdout das Protokoll. Das SDK leitet während des Betriebs geflushte Streuausgaben nach stderr um. Aber Ausgaben, die vorher auf stdout geflusht werden (ein Wrapper-Skript mit echo, ein `print()` zur Importzeit in einem ungepufferten Prozess), oder ein gepuffertes `print()`, das beim Beenden des Interpreters geleert wird, übergeben dem Host eine kaputte Nachricht, und er trennt die Verbindung. Logge mit der Standardkonfiguration von `logging`, deren stderr-Handler jeden Eintrag sofort flusht; eigene Handler müssen stdout ebenfalls meiden. Alles Weitere steht in **[Logging](../handlers/logging.md)**. + +Claude Desktop führt pro Server ein Log: `mcp-server-.log` ist das stderr deines Servers, neben `mcp.log` für Verbindungen, unter `~/Library/Logs/Claude` auf macOS und `%APPDATA%\Claude\logs` auf Windows. + +Für alles jenseits dieser drei ist **[Fehlerbehebung](../troubleshooting.md)** die richtige Seite. + +## Zusammenfassung {#recap} + +* Ein **Host** (Claude Desktop, eine IDE) betreibt einen MCP-Client, der deinen Server als Kindprozess über stdio startet. Verbinden heißt, ihm einen einzigen Startbefehl zu geben. +* Dieser Befehl lautet `uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py`: kein venv zu aktivieren, funktioniert aus jedem Verzeichnis. +* **Claude Desktop** ist der eine Host, den `mcp install` für dich konfiguriert. Es schreibt genau diesen Befehl (plus den absoluten Pfad zu `uv`, `--frozen` und eine exakte Festlegung auf die installierte Version) in `claude_desktop_config.json`, damit du es nie selbst tun musst. +* **Claude Code** ist `claude mcp add bookshop -- `. **Cursor** ist `.cursor/mcp.json` unter `mcpServers`. **VS Code** ist `.vscode/mcp.json` unter `servers`, jeder Eintrag mit einem `type`. +* Überall absolute Pfade, den Host nach jeder Änderung an seiner Konfiguration neu starten, und nie etwas anderes als das SDK auf stdout schreiben lassen. + +Jeder Host auf dieser Seite hat sich mit derselben Datei verbunden, mit demselben Befehl. Was diese Datei *bereitstellen* kann, ist der Rest dieser Dokumentation: **[Tools](../servers/tools.md)**, **[Ressourcen](../servers/resources.md)** und jeder Transport außer stdio in **[Den Server betreiben](../run/index.md)**. diff --git a/i18n/de/pages/get-started/testing.md b/i18n/de/pages/get-started/testing.md new file mode 100644 index 0000000000..f18e36953a --- /dev/null +++ b/i18n/de/pages/get-started/testing.md @@ -0,0 +1,115 @@ +--- +translation: + sections: ['4926721070127497', c52a1de2b6b32f40, 2e410b412c25f314, 627195f7159e24ef] + tool: 1 +--- +# Testen {#testing} + +Das Python SDK bringt eine Klasse `Client` mit einem **In-Memory-Transport** mit: Übergib ihr dein Server-Objekt, und sie verbindet sich direkt damit. + +Kein Subprozess. Kein Port. Überhaupt kein Transport. Die Idee ist dieselbe wie bei FastAPIs `TestClient`. + +## Grundlegende Verwendung {#basic-usage} + +Nehmen wir an, du hast einen einfachen Server mit einem einzigen Tool: + +```python title="server.py" +--8<-- "docs_src/testing/tutorial001.py" +``` + +Um den Test unten auszuführen, brauchst du zwei zusätzliche (Entwicklungs-)Abhängigkeiten: + +=== "uv" + + ```bash + uv add --dev pytest inline-snapshot + ``` + +=== "pip" + + ```bash + pip install pytest inline-snapshot + ``` + +!!! info + Diese Dokumentation geht davon aus, dass du [`pytest`](https://docs.pytest.org/en/stable/) bereits kennst. + + [`inline-snapshot`](https://15r10nk.github.io/inline-snapshot/latest/) nutzt der Test unten, + um in einer Zeile auf das gesamte Ergebnisobjekt zu prüfen. Es zeichnet die Ausgabe eines Tests + als das `snapshot(...)`-Literal auf, das du siehst. Wenn du es lieber nicht verwenden möchtest, + lass den Import weg und prüfe die Felder, die dich interessieren (`result.content[0].text == "3"`), + wie in jedem anderen Test. + +Jetzt der Test: + +```python title="test_server.py" +import pytest +from inline_snapshot import snapshot +from mcp import Client +from mcp.types import CallToolResult, TextContent + +from server import mcp + + +@pytest.fixture +def anyio_backend(): # (1)! + return "asyncio" + + +@pytest.fixture +async def client(): # (2)! + async with Client(mcp, raise_exceptions=True) as c: + yield c + + +@pytest.mark.anyio +async def test_call_add_tool(client: Client): + result = await client.call_tool("add", {"a": 1, "b": 2}) + # Drop the server identity stamp in `_meta`; it is not what this test is about. + result.meta = None + assert result == snapshot( + CallToolResult( + content=[TextContent(type="text", text="3")], + structured_content={"result": 3}, + ) + ) +``` + +1. Wenn du `trio` verwendest, gib stattdessen `"trio"` zurück. Die Details stehen in der [anyio-Dokumentation](https://anyio.readthedocs.io/en/stable/testing.html#specifying-the-backends-to-run-on). +2. Das Fixture liefert einen verbundenen Client. Jeder Test, der `client` entgegennimmt, bekommt eine frische In-Memory-Verbindung zum selben Server. + +Das war's. Jetzt kannst du deine Tests um weitere Szenarien erweitern. + +## Warum `raise_exceptions=True`? {#why-raise_exceptionstrue} + +Zwei verschiedene Dinge können schiefgehen, und dieses Flag betrifft nur eines davon. + +Eine Exception in einem **deiner Tools** ist kein Protokollfehler. Sie wird zu einem normalen Ergebnis mit +`is_error=True`, und das Modell liest die Meldung. `raise_exceptions` ändert daran nichts: Mit oder +ohne das Flag gibt `call_tool` dasselbe Ergebnis mit `is_error=True` zurück. Dazu gibt es eine ganze Seite: +**[Fehler behandeln](../servers/handling-errors.md)**. + +Ein Fehler **außerhalb** eines Tool-Bodys ist etwas anderes. Auf der Verbindung, die dir `Client(mcp)` gibt, +bereinigt der Server ihn zu einem allgemeinen `"Internal server error"`, bevor der Client ihn sieht. Du solltest +die Details eines unerwarteten Absturzes niemals an einen entfernten Aufrufer durchsickern lassen. In einem Test ist das +genau das, was du *nicht* willst, und genau das ändert `raise_exceptions=True`: Dein Test sieht die echte Meldung +statt der bereinigten. + +Lass es in Tests eingeschaltet. In Produktionscode hat es keine Bedeutung. + +## Standardmäßig im selben Prozess {#in-process-by-default} + +!!! note + `Client(mcp)` verbindet sich im selben Prozess und ist standardmäßig **generationsneutral** (era-neutral): Er prüft den Server und + wählt den passenden Protokollpfad. Lege `mode="legacy"` fest, wenn dein Test Legacy-spezifische + Semantik prüft (Sampling- oder Elicitation-Push – Elicitation ist die Rückfrage bei der Person am Host –, `message_handler`), und lass `raise_exceptions=True` + dort weg: Eine Legacy-Verbindung bereinigt von vornherein nie, und das Flag löst den + Fehler erneut in der Server-Task aus statt in deinem Test. + +Diese eine Zeile ist auch der Grund, warum diese Dokumentation dir versprechen kann, dass ihre Beispiele funktionieren: Jede +Beispieldatei wird von der Test-Suite des SDK selbst ausgeführt, fast alle über genau diesen +Client. Du verwendest dasselbe Tool, das das SDK auf sich selbst anwendet. + +Du hast einen funktionierenden, getesteten Server. Wie du ihn in eine echte Anwendung (Claude Desktop, eine +IDE) einbindest, steht in **[Mit einem echten Host verbinden](real-host.md)**; jede andere Art, ihn zu betreiben, in +**[Den Server betreiben](../run/index.md)**. diff --git a/i18n/de/pages/handlers/context.md b/i18n/de/pages/handlers/context.md new file mode 100644 index 0000000000..52ce4844ad --- /dev/null +++ b/i18n/de/pages/handlers/context.md @@ -0,0 +1,134 @@ +--- +translation: + sections: [b50152f05c81e786, b302059b22fb7cb4, 85682a1bf561243a, 53fc48838eb6837a, b24190e0842786ec, 85f93e150fc9b240] + tool: 1 +--- +# Der Context {#the-context} + +Die Argumente eines Tools kommen vom Modell. Alles andere (der Request, den du gerade bearbeitest, der Server, in dem du lebst, ein Weg zurück zum Client) kommt aus einem einzigen Objekt: dem **`Context`**. + +Du erzeugst ihn nicht selbst und konfigurierst ihn auch nicht. Du forderst ihn einfach an. + +## Anfordern {#ask-for-it} + +Füge einem beliebigen Tool einen Parameter hinzu, der mit `Context` annotiert ist: + +```python title="server.py" hl_lines="2 8" +--8<-- "docs_src/context/tutorial001.py" +``` + +* Das SDK baut für jeden Request einen frischen `Context` und übergibt ihn. +* Der **Name des Parameters spielt keine Rolle**. `ctx`, `context`, `c`: Das SDK findet ihn über seine Annotation. +* Ressourcen und Prompts können ebenfalls einen deklarieren, auf dieselbe Weise. +* `ctx.request_id` ist die ID des Requests, den deine Funktion gerade bearbeitet. + +!!! info + Wenn du FastAPI kennst, kennst du diesen Kniff: Deklariere einen Parameter mit dem + frameworkeigenen Typ (`Request` dort, `Context` hier), und das Framework liefert ihn. Nichts zu + registrieren, nichts zu konfigurieren: Die Typannotation ist der ganze Mechanismus. + +### Für das Modell unsichtbar {#invisible-to-the-model} + +Das ist der Teil, den du verinnerlichen solltest. Hier ist das Eingabeschema, das `tools/list` für `search_books` meldet: + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"} + }, + "required": ["query"], + "title": "search_booksArguments" +} +``` + +Eine Eigenschaft. `ctx` ist kein Argument: Es taucht nie im Schema auf, das Modell erfährt nie davon, und kein Client kann es ausfüllen. Es ist ein Vertrag zwischen dir und dem SDK, unsichtbar auf der Leitung. + +### Ausprobieren {#try-it} + +Starte den Server mit dem MCP Inspector: + +```console +uv run mcp dev server.py +``` + +Das Formular für `search_books` hat ein einziges Feld `query`. Rufe es mit `dune` auf: + +```text +[request 3] Found 3 books matching 'dune'. +``` + +Die Zahl ist die Nummer des Requests, der es zufällig war. Rufe das Tool noch einmal auf, und sie ändert sich: Jeder Request bekommt seinen eigenen `Context`. + +## Was er dir bietet {#what-it-gives-you} + +Das injizierte Objekt ist klein. Neben `request_id`: + +* `await ctx.read_resource(uri)`: eine der **eigenen** Ressourcen des Servers aus einem Tool heraus lesen. Der nächste Abschnitt. +* `await ctx.report_progress(progress, total, message)`: während eines langen Aufrufs Fortschritt an den Aufrufer zurückstreamen. Alles Weitere steht in **[Fortschritt](progress.md)**. +* `await ctx.elicit(message, schema)` und `await ctx.elicit_url(...)`: das Tool anhalten und der Person am Host eine Frage stellen. Das ist **[Elicitation](elicitation.md)** (Rückfrage bei der Person am Host). +* `ctx.session`: die Server-Seite des Gesprächs mit diesem Client. Benachrichtigungen, die du an den Client schickst, leben hier; der letzte Abschnitt nutzt sie. +* `ctx.headers`: die Request-Header, die der Transport mitgebracht hat, oder `None` bei stdio. Einen eigenen Header liest du mit `(ctx.headers or {}).get("x-...")`. Header sind vom Client gelieferte Eingaben – in Ordnung für eine Locale oder ein Feature-Flag, nie für eine Identität. +* `ctx.request_context`: der rohe Datensatz pro Request. Das Feld, nach dem du greifen wirst, ist `lifespan_context`, das Objekt, das dein Startcode per yield geliefert hat (siehe **[Lifespan](lifespan.md)**). + +Logging steht bewusst nicht auf dieser Liste. Ein Server loggt mit Pythons Modul `logging`, wie jedes andere Python-Programm. **[Logging](logging.md)** ist die kurze Seite, die erklärt, warum. + +!!! tip + Injiziert wird nur in die Funktion, die du registriert hast. Eine Hilfsfunktion, die dein Tool + aufruft, bekommt keinen eigenen `Context`; reiche `ctx` als gewöhnliches Argument weiter. Es gibt + keinen umgebenden „aktuellen Kontext“, den du von woanders holen könntest. + +## Eigene Ressourcen lesen {#read-your-own-resources} + +Die Ressourcen eines Servers sind nicht nur für Clients da. Auch ein Tool kann sie lesen: + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/context/tutorial002.py" +``` + +`ctx.read_resource` löst den URI über dieselbe Registry auf, die auch `resources/read` bedient. Ein Tool bekommt also, was ein Client bekäme: ein Iterable von `ReadResourceContents`, eines pro Content-Block. Für diesen URI gibt es einen: + +```python +contents.content # 'fiction, non-fiction, poetry' +contents.mime_type # 'text/plain' +``` + +* `content` ist genau das, was `genres()` zurückgegeben hat. Eine einzige Quelle der Wahrheit: Der Client durchstöbert die Ressource, deine Tools konsumieren sie, niemand kopiert den String. +* Der einzige Parameter von `describe_catalog` ist der `Context`, daher hat sein Eingabeschema **überhaupt keine Eigenschaften**. Das Modell ruft es mit `{}` auf. + +## Dem Client mitteilen, dass sich die Liste geändert hat {#tell-the-client-the-list-changed} + +Was ein Server anbietet, steht nicht zur Importzeit fest. Registriere ein Tool zur Laufzeit und teile es dann dem Client mit: + +```python title="server.py" hl_lines="15-16" +--8<-- "docs_src/context/tutorial003.py" +``` + +* `mcp.add_tool(recommend_book)` registriert eine gewöhnliche Funktion als Tool: Name, Beschreibung und Schema werden genau so abgeleitet, wie `@mcp.tool()` es getan hätte. +* `await ctx.session.send_tool_list_changed()` sendet `notifications/tools/list_changed`. Ein Client, der das empfängt, ruft `tools/list` erneut auf und sieht `recommend_book`. + +Die Geschwister sind `send_resource_list_changed()`, `send_prompt_list_changed()` und `send_resource_updated(uri)` für eine Änderung an einer bestimmten Ressource. + +Auf einer Verbindung mit 2026-07-28 empfangen Clients Änderungsbenachrichtigungen nur auf einem `subscriptions/listen`-Stream, den sie selbst geöffnet haben. Die `send_*`-Methoden oben erreichen diese Streams daher nicht. Die Publish-Methoden des `Context` liefern an alle abonnierten Streams gleichzeitig aus: `await ctx.notify_tools_changed()`, `await ctx.notify_prompts_changed()`, `await ctx.notify_resources_changed()` und `await ctx.notify_resource_updated(uri)`. Alles Weitere, einschließlich der horizontalen Skalierung über Replikate, steht in **[Abonnements](subscriptions.md)**. + +!!! check + Bevor jemand `enable_recommendations` ausführt, existiert das Tool, das du versprichst, nicht. + Rufst du es trotzdem auf, ist das Ergebnis ein Fehler, den das Modell lesen kann: + + ```text + Unknown tool: recommend_book + ``` + + Führe `enable_recommendations` aus, und genau derselbe Aufruf gelingt. Die Tool-Liste ist + wirklich dynamisch: `tools/list` spiegelt wider, was *gerade jetzt* registriert ist. + +## Zusammenfassung {#recap} + +* Annotiere einen Parameter mit `Context` (in einem Tool, einer Ressource oder einem Prompt), und das SDK injiziert ihn. Der Name gehört dir. +* Er ist für das Modell unsichtbar: Das Eingabeschema enthält immer nur deine echten Argumente. +* `ctx.request_id` identifiziert den Request; `ctx.request_context.lifespan_context` ist das, was dein Startcode per yield geliefert hat. +* Mit `await ctx.read_resource(uri)` liest ein Tool die eigenen Ressourcen des Servers. +* `ctx.session` ist der Kanal zurück zum Client: `send_tool_list_changed()` und seine Geschwister sagen ihm, dass er eine Liste, die du geändert hast, erneut abrufen soll. +* Auch Fortschrittsmeldungen und Elicitation beginnen beim `Context`; beide haben ihre eigene Seite. + +Parameter, die das Modell nie sieht und die deine eigenen Funktionen füllen, sind **[Abhängigkeiten](dependencies.md)**. diff --git a/i18n/de/pages/handlers/dependencies.md b/i18n/de/pages/handlers/dependencies.md new file mode 100644 index 0000000000..fb0d242477 --- /dev/null +++ b/i18n/de/pages/handlers/dependencies.md @@ -0,0 +1,167 @@ +--- +translation: + sections: [b0389403e98d25ad, e2cf58b43b285e86, a363e1a38e1a5971, 6cfac078feb18013, b4535bd61df337e6, e97ed44207f929fd] + tool: 1 +--- +# Abhängigkeiten {#dependencies} + +Die Argumente eines Tools kommen vom Modell. Manche Werte sollten das nie: ein Preis, den du in deinen eigenen Datensätzen nachschlägst, eine Bestätigung, die nur ein Mensch geben kann, alles, bei dem das Modell danebenliegen könnte, wenn es den Wert erfindet. + +**Abhängigkeiten** sind Parameter, die deine eigenen Funktionen füllen. Du annotierst den Parameter, nennst die Funktion, und das SDK ruft sie auf, bevor dein Tool läuft. + +## Eine Abhängigkeit deklarieren {#declare-one} + +Umschließe den Typ des Parameters mit `Annotated[...]` und füge `Resolve(fn)` hinzu: + +```python title="server.py" hl_lines="18-19 23" +--8<-- "docs_src/dependencies/tutorial001.py" +``` + +* `check_stock` ist ein **Resolver**: eine gewöhnliche Funktion, die das SDK vor `reserve_book` ausführt und deren Rückgabewert zum Argument `stock` wird. +* Sein Parameter `title` ist das `title`-Argument des Tools selbst, zugeordnet **über den Namen**. Der Resolver sieht genau den validierten Wert, den auch der Tool-Rumpf sehen wird. +* Der Tool-Rumpf beginnt mit einem `Stock`, der bereits existiert. Kein Nachschlage-Code im Tool, keine „Was, wenn er fehlt“-Vorrede. + +!!! info + Wenn du FastAPI kennst: Das ist `Depends`. Derselbe Kniff, derselbe Grund: Die Funktion + deklariert, was sie braucht, das Framework liefert es, und die Verdrahtung steckt in der Typannotation. + +### Für das Modell unsichtbar {#invisible-to-the-model} + +Das ist das Eingabeschema, das `tools/list` für `reserve_book` meldet: + +```json +{ + "type": "object", + "properties": { + "title": {"title": "Title", "type": "string"} + }, + "required": ["title"], + "title": "reserve_bookArguments" +} +``` + +Eine einzige Property. Wie der `Context` in **[Der Context](context.md)** ist ein aufgelöster Parameter ein Vertrag zwischen dir und dem SDK: `stock` steht nicht im Schema, das Modell erfährt nie davon, und ein Client, der trotzdem einen `stock`-Wert schickt, wird ignoriert. Der Wert des Resolvers ist der einzige, den dein Tool empfangen kann. + +Dieser letzte Teil ist der Kern. Ein Parameter, den das Modell nicht liefern kann, ist ein Parameter, bei dem das Modell nichts falsch machen kann. + +### Ausprobieren {#try-it} + +Starte den Server mit dem MCP Inspector: + +```console +uv run mcp dev server.py +``` + +Das Formular für `reserve_book` hat ein einziges Feld `title`. `stock` taucht nirgends auf. Rufe es mit `Dune` auf: + +```text +Reserved 'Dune' (6 copies left). +``` + +Der Tool-Rumpf hat nichts nachgeschlagen: `check_stock` lief zuerst, und der zurückgegebene `Stock` kam als Argument an. Probiere `Neuromancer`, und derselbe Resolver reicht dem Tool eine Null. + +!!! tip + Du kannst `check_stock(title)` auch einfach im Tool-Rumpf aufrufen. Deklariere es als Abhängigkeit, + wenn der Wert mehr verdient als einen Hilfsaufruf: Jedes Tool, das den Bestand braucht, deklariert + denselben Parameter, und das SDK führt den Resolver höchstens einmal pro Aufruf aus, egal wie viele + ihn deklarieren. Die nächsten Abschnitte liefern den Rest: Resolver, die voneinander abhängen, und + Resolver, die die Person am Host fragen. + +## Abhängigkeiten von Abhängigkeiten {#dependencies-of-dependencies} + +Ein Resolver kann eigene Abhängigkeiten deklarieren, mit derselben Annotation: + +```python title="server.py" hl_lines="22 29-30" +--8<-- "docs_src/dependencies/tutorial002.py" +``` + +* `estimate_delivery` hängt von `check_stock` ab. Das SDK führt den Graphen der Reihe nach aus: erst der Bestand, dann die Schätzung, dann das Tool. +* Sowohl `stock` als auch `delivery` brauchen letztlich `check_stock`, aber es läuft **einmal pro Aufruf**. Eine Bestandsabfrage, zwei Konsumenten. +* Es gibt nichts zu registrieren. Die Annotationen *sind* der Graph. + +!!! check + Glaube das „einmal pro Aufruf“ nicht einfach. Setze ein `print` in `check_stock` und rufe + `order_book` aus dem Inspector auf: eine Zeile pro Aufruf. Zwei Konsumenten, eine Abfrage. + +Das SDK analysiert den Graphen, wenn das Tool registriert wird, nicht wenn es aufgerufen wird. Ein Parameter, den es nicht einordnen kann – kein `Context`, kein `Resolve(...)`, nicht der Name eines Tool-Arguments –, und ein Zyklus von Resolvern lösen beide beim Start `InvalidSignature` aus. Dein Server scheitert, bevor sich je ein Client verbindet, und der Fehler nennt den betreffenden Parameter oder Resolver. + +Die Parameter eines Resolvers werden genau wie die eines Tools aufgelöst: ein weiteres `Resolve(...)`, die eigenen Argumente des Tools über den Namen oder der `Context` – `ctx.headers`, das Lifespan-Objekt, alles davon. + +!!! warning + Auf HTTP-Transporten enthält der `Context` auch `ctx.headers`. Header sind **vom Client gelieferte + Eingaben**, wie jedes Tool-Argument: in Ordnung für eine Locale oder ein Feature-Flag, nie für eine + Identität. Wer aufruft, bestimmt deine Autorisierungsschicht (**[Autorisierung](../run/authorization.md)**), + nicht ein Header, der sich beliebig setzen lässt. + +!!! tip + *Einmal pro Aufruf* heißt genau das: Der nächste `tools/call` führt `check_stock` erneut aus. Eine + Ressource, die einen Request überdauern soll – ein Datenbank-Pool, ein HTTP-Client –, gehört in den + **[Lifespan](lifespan.md)**, und ein Resolver erreicht sie über `ctx.request_context.lifespan_context`. + +## Fragen, wenn es sein muss {#ask-when-you-must} + +Ein Resolver muss die Antwort nicht kennen. Er kann `Elicit(message, Model)` zurückgeben, und das SDK fragt die Person am Host – die Maschinerie der **[Elicitation](elicitation.md)** (Rückfrage bei der Person am Host), für dich ausgeführt: + +```python title="server.py" hl_lines="26-32 39" +--8<-- "docs_src/dependencies/tutorial003.py" +``` + +* Auf Lager: `confirm_backorder` gibt direkt ein `Backorder` zurück. **Keine Frage, kein Roundtrip.** Die Person wird nur unterbrochen, wenn ihre Antwort zählt. +* Nicht auf Lager: Das SDK sendet die Elicitation, validiert die Antwort gegen `Backorder` und injiziert sie. Dein Resolver berührt das Protokoll nie. +* Das Tool liest `backorder.confirm` wie jedes andere Argument. **Nein** zu antworten ist trotzdem eine Antwort: Die Elicitation wird mit `confirm=False` akzeptiert, das Tool läuft, und es wird keine Bestellung aufgegeben. Das Fragen ist zur Vorbedingung geworden, nicht zu Hilfscode im Tool-Rumpf. + +Und wenn die Person gar nicht antwortet – die Frage ablehnt oder abbricht? + +!!! check + Führe `order_book` für `Neuromancer` aus und lehne die Frage ab. Mit der Annotation + `Annotated[Backorder, Resolve(...)]` läuft der Tool-Rumpf nie; der Aufruf scheitert mit einem + Fehlerergebnis, das das Modell lesen kann: + + ```text + Error executing tool order_book: Resolver for parameter 'backorder' could not resolve: elicitation was decline + ``` + +Das ist der richtige Standardwert für eine Vorbedingung: keine Antwort, keine Bestellung. Wenn Ablehnen ein Ergebnis ist, das dein Tool behandeln will – die Nachbestellung überspringen, aber trotzdem einen anderen Titel vorschlagen –, annotiere stattdessen `ElicitationResult[Backorder]`, und das Tool erhält das vollständige Ergebnis aus accept/decline/cancel, nach dem es verzweigen kann. **[Elicitation](elicitation.md)** zeigt diese Form und alles Weitere zum Fragen: die Schema-Regeln, die drei Antworten, die Client-Seite des Gesprächs. + +!!! info + Das Framework wählt den Transport der Frage anhand der ausgehandelten Protokollversion; der Code + oben ist in beiden Fällen identisch. Ab **2026-07-28** reist die Frage innerhalb eines + Multi-Roundtrip-`tools/call` (multi-round-trip) – der Server gibt sie zurück, der + `elicitation_callback` des Clients beantwortet sie, und der `Client` wiederholt den Aufruf für dich + (**[Multi-Roundtrip-Requests](multi-round-trip.md)**). Bei **2025-11-25** und früher ist es ein + synchroner Elicitation-Request mitten im Aufruf. Jede Frage wird genau einmal pro Aufruf gestellt – + eine Garantie über die Frage, nicht über den Resolver. In der Multi-Roundtrip-Form kann jeder + Resolver erneut laufen, sobald der Aufruf nach einer Frage fortgesetzt wird; Code vor einem + `return Elicit(...)` läuft also in jeder dieser Runden. Die aufgezeichnete Antwort erfüllt dann die + wiederholte Frage, ohne die Person erneut zu fragen. Eine aufgezeichnete Antwort wird überhaupt nur + herangezogen, wenn der Resolver fragt; ein Resolver, der antwortet, *ohne* zu fragen, wie + `check_stock`, liefert immer seinen selbst berechneten Wert. Weil jede Antwort ihrer Frage + zugeordnet wird, muss ein fragender Resolver seine Frage deterministisch aus den Argumenten des + Tools und früheren Antworten ableiten. Ein pro Aufruf erzeugter Wert (eine ID aus + `default_factory`, ein Zeitstempel) wird in jeder Runde neu abgeleitet und darf nicht in einer + Frage vorkommen, an die sich die Antwort binden soll. Eine Frage aus solch flüchtigen Daten lässt + jede aufgezeichnete Antwort veraltet aussehen, sodass der Server sie in jeder Runde erneut stellt, + bis das Rundenlimit des Clients den Aufruf beendet. + +## Den Client fragen, nicht die Person {#ask-the-client-not-the-user} + +Elicitation ist eine von drei Fragen, die ein Resolver stellen kann, und der Multi-Roundtrip-Ablauf lässt keine weiteren zu. Die beiden anderen gehen an den **Client** statt an die Person: Gib `Sample(...)` zurück, um einen LLM-Aufruf über den Client auszuführen (ein `sampling/createMessage`-Request), oder `ListRoots()`, um die aktuellen Roots (freigegebene Arbeitsverzeichnisse) des Clients abzurufen. Keine von beiden hat ein Ergebnis aus accept/decline; der Konsument annotiert direkt den Ergebnistyp, `CreateMessageResult` (`CreateMessageResultWithTools`, wenn der Request `tools` oder `tool_choice` trägt) oder `ListRootsResult`: + +```python title="server.py" hl_lines="10-15 21" +--8<-- "docs_src/dependencies/tutorial004.py" +``` + +* Das Framework leitet sie genau wie `Elicit`: innerhalb des Multi-Roundtrip-`tools/call` bei **2026-07-28**, über den eigenständigen Server-zu-Client-Request bei **2025-11-25**. Eine nicht deklarierte Capability verweigert den Aufruf mit einem Protokollfehler `-32021` (`sampling`, `roots`, `elicitation` im Formularmodus; `sampling.tools`, wenn der Request `tools` oder `tool_choice` trägt). +* Alles, was der Info-Kasten oben über Fragen sagt, gilt unverändert: Ein `Sample`-Request wird seinem aufgezeichneten Ergebnis über seine exakte Darstellung zugeordnet, baue ihn also deterministisch aus den Argumenten des Tools und früheren Antworten; der Client zahlt dann für den LLM-Aufruf einmal pro Tool-Aufruf, nicht einmal pro Runde. Das aufgezeichnete Ergebnis reist für den Rest des Aufrufs in `request_state` mit, sodass eine sehr große Completion jeden verbleibenden Roundtrip schwerer macht. +* Die eigenständigen *Features* Sampling und Roots sind ab 2026-07-28 veraltet (SEP-2577). Neue Server, die das Modell des Clients brauchen, fragen über diesen Träger; Server, die es nicht brauchen, sollten direkt einen LLM-Anbieter anbinden. Andere `include_context`-Werte als `"none"` sind selbst veraltet; vermeide sie. + +## Zusammenfassung {#recap} + +* `Annotated[T, Resolve(fn)]` an einem Tool-Parameter: Das SDK führt `fn` aus und injiziert den Rückgabewert. +* Ein aufgelöster Parameter ist für das Modell unsichtbar, und ein Client kann ihn nicht liefern. Werte, die das Modell nicht erfinden darf – Preise, Identitäten, Berechtigungen –, gehören hierher. +* Die Parameter eines Resolvers werden genauso aufgelöst: der `Context`, ein weiteres `Resolve(...)` oder ein Tool-Argument über den Namen. Der Graph führt jeden Resolver höchstens einmal pro Runde aus, egal wie viele Konsumenten er hat; jede Frage wird genau einmal gestellt, und jeder Resolver kann erneut laufen, wenn ein Aufruf nach einer Frage fortgesetzt wird. +* Fehlerhafte Graphen scheitern bei der Registrierung mit `InvalidSignature`, nicht mitten im Aufruf. +* Gib `Elicit(message, Model)` zurück, um die Person zu fragen – nur, wenn es sein muss. Unverpackte Annotationen brechen bei Ablehnung ab; mit `ElicitationResult[T]` kann das Tool verzweigen. +* Gib `Sample(...)` oder `ListRoots()` zurück, um den Client nach einer Antwort des Modells oder der Liste der Roots zu fragen; das reine Ergebnis wird injiziert. + +Den Zustand, den dein Server einmal beim Start aufbaut, und wie ein Handler ihn erreicht, behandelt die Seite **[Lifespan](lifespan.md)**. diff --git a/i18n/de/pages/handlers/elicitation.md b/i18n/de/pages/handlers/elicitation.md new file mode 100644 index 0000000000..69c0599d8a --- /dev/null +++ b/i18n/de/pages/handlers/elicitation.md @@ -0,0 +1,190 @@ +--- +translation: + sections: [335ca2a0b266f003, d1ad562d3fe87bc0, 0bb1396c86daeba4, d1cb1235bb9ee267, 833179c09d239c83, e5d6dec2d2e655e8] + tool: 1 +--- +# Elicitation {#elicitation} + +Ein Tool, das mitten in seiner Arbeit steckt und dem eine Antwort fehlt, muss nicht scheitern. + +Mit **Elicitation** (Rückfrage bei der Person am Host) kann es fragen. Mitten in einem Tool-Aufruf bekommt die Person eine Frage gestellt, und ihre Antwort landet wieder im selben Funktionsaufruf. + +Es gibt zwei Modi: + +* **Formular-Modus**: Du brauchst einen Wert (eine Bestätigung, ein Datum, eine Menge). Du beschreibst die Felder, der Client rendert das Formular. +* **URL-Modus**: Die Person muss woanders hin (ein OAuth-Zustimmungsbildschirm, eine Bezahlseite). Nichts von dem, was sie dort tut, läuft über das Protokoll. + +Und es gibt zwei Wege zu fragen. Der Weg der Wahl ist ein **Resolver**: Du hängst die Frage an einen Parameter, und das SDK fragt – auf jeder Verbindung, egal welche Protokollgeneration der Client spricht. Der direkte Weg, `await ctx.elicit(...)`, ist ein Request vom *Server* an den *Client*, ein Kanal, den es nur für einen Client auf einer Legacy-Verbindung gibt (Spec-Version 2025-11-25 oder älter). Beide stehen auf dieser Seite; fang mit dem Resolver an. + +## Mit einem Resolver fragen {#ask-with-a-resolver} + +Eine Frage, die das ganze Tool blockiert – *bist du sicher? welches der drei passenden Konten?* – lässt sich aus dem Tool-Body in einen **Resolver** herausziehen, und das Framework stellt sie für dich. + +Ein Parameter mit der Annotation `Annotated[T, Resolve(fn)]` wird befüllt, indem `fn` vor dem Tool-Body läuft. Der Resolver gibt den Wert direkt zurück, wenn er ihn schon kennt, oder gibt `Elicit(...)` zurück, damit das Framework fragt: + +```python title="server.py" hl_lines="24-30 35-36" +--8<-- "docs_src/elicitation/tutorial004.py" +``` + +* `confirm_delete` liest das eigene Argument `path` des Tools über den Namen aus, listet den Ordner auf und **fragt nur, wenn es sein muss** – ein leerer Ordner wird zu `Confirm(ok=True)` aufgelöst, ohne Roundtrip zum Client. +* `delete_folder` annotiert `ElicitationResult[Confirm]`, also injiziert das Framework das ganze Ergebnis, und das Tool behandelt per `match` jeden Fall: annehmen und bestätigen, annehmen, aber behalten (`ok=False`), ablehnen, abbrechen. +* Der Parameter `confirm` taucht nie im Input-Schema des Tools auf – der Client liefert `path`, der Resolver liefert `confirm`. + +Annotiere stattdessen das unverpackte Modell (`Annotated[Confirm, Resolve(confirm_delete)]`), wenn das Tool nicht verzweigen muss: Beim Annehmen bekommt es das Modell, bei Ablehnen oder Abbrechen bricht der Aufruf mit einem Fehler ab. + +Ein Resolver funktioniert auf **jeder** Verbindung. Einem Client auf einer Legacy-Verbindung schickt das SDK die Frage direkt; auf einer **2026-07-28**-Verbindung *gibt* das SDK die Frage aus dem Aufruf *zurück*, und der nächste Versuch des Clients trägt die Antwort. Dein Resolver merkt den Unterschied nie; was unter der Haube passiert, steht in **[Multi-Roundtrip-Requests](multi-round-trip.md)** (multi-round-trip requests). + +Fragen ist nur eines von dem, was ein Resolver kann. Der allgemeine Mechanismus – Abhängigkeiten, die rechnen, ohne zu fragen, Abhängigkeiten von Abhängigkeiten, was das Modell liefern kann und was nicht – ist die Seite **[Abhängigkeiten](dependencies.md)**. + +## Aus dem Tool heraus fragen {#ask-from-inside-the-tool} + +Ein Tool kann auch mitten in seinem eigenen Body anhalten und fragen. + +!!! warning + `ctx.elicit()` und `ctx.elicit_url()` sind Requests vom *Server* an den *Client* – ein + Kanal, den es nur für einen Client auf einer Legacy-Verbindung gibt (Spec-Version **2025-11-25** + oder älter). Auf einer **2026-07-28**-Verbindung gibt es keine vom Server initiierten Requests, + also schlagen diese Aufrufe fehl. Ein Resolver funktioniert auf beiden. Alles Weitere steht in + **[Protokollversionen](../protocol-versions.md)**. + +`await ctx.elicit()` nimmt eine Nachricht und ein Pydantic-Modell entgegen: + +```python title="server.py" hl_lines="9-11 20-23 25" +--8<-- "docs_src/elicitation/tutorial001.py" +``` + +* Der **`Context`**-Parameter gibt dir `ctx.elicit`; jedes Tool kann einen entgegennehmen. Dieses Objekt hat seine eigene Seite: **[Der Context](context.md)**. +* `AlternativeDate` ist das **Schema** der Antwort, die du haben willst. +* Das Tool ist `async def`. Das muss es sein: Es hält mittendrin an und wartet auf einen Menschen. +* An jedem anderen Datum gibt das Tool sofort zurück. Es fragt nur, wenn es muss. +* Das Datum, das die Person annimmt, läuft wieder durch `book_table` selbst. Eine Antwort ist Eingabe wie jede andere: Ist die Alternative ebenfalls ausgebucht, wird erneut gefragt statt blind bestätigt. + +### Was der Client erhält {#what-the-client-receives} + +Der Client bekommt deine Nachricht und daneben ein JSON Schema, das aus dem Modell generiert wird: + +```json +{ + "properties": { + "accept_alternative": { + "description": "Try another date?", + "title": "Accept Alternative", + "type": "boolean" + }, + "date": { + "default": "2025-12-26", + "description": "Alternative date (YYYY-MM-DD)", + "title": "Date", + "type": "string" + } + }, + "required": ["accept_alternative"], + "title": "AlternativeDate", + "type": "object" +} +``` + +Dieses Schema ist das Formular. `Field(description=...)` ist die Beschriftung; ein Standardwert füllt das Eingabefeld vor und macht das Feld optional. Es ist dieselbe Pydantic-zu-JSON-Schema-Maschinerie, die **[Tools](../servers/tools.md)** für die Argumente eines Tools beschreibt. + +!!! warning + Ein Elicitation-Schema ist nicht so ausdrucksstark wie das Input-Schema eines Tools. Nur flache, + primitive Felder: `str`, `int`, `float`, `bool` oder ein `Literal` aus Strings (daraus wird ein `enum`). + Steckst du ein Modell in das Modell, löst `ctx.elicit` eine Exception aus, bevor irgendetwas an den Client geht: + + ```text + TypeError: Elicitation schema field 'address' rendered as {'$ref': '#/$defs/Address'}, which is not a valid PrimitiveSchemaDefinition + ``` + + Du unterbrichst einen Menschen mitten in einer Aufgabe. Wenn die Antwort Verschachtelung braucht, + hätte sie ein Argument des Tools sein sollen. + +### Die drei Antworten {#the-three-answers} + +`result.action` sagt dir, was die Person getan hat, und es gibt genau drei Möglichkeiten: + +* `"accept"`: Sie hat das Formular abgeschickt. `result.data` ist eine `AlternativeDate`-Instanz, bereits validiert. +* `"decline"`: Sie hat Nein gesagt. +* `"cancel"`: Sie hat die Frage weggeklickt, ohne sich zu entscheiden. + +`result.data` existiert nur bei `"accept"`, deshalb prüft das Beispiel zuerst `result.action`. Dein Type Checker erzwingt die Reihenfolge: Nach `result.action == "accept"` ist `result.data` ein `AlternativeDate`; davor gibt es gar kein `.data`. + +Eine Absage ist kein Fehler. Das Tool entscheidet, was Ablehnen bedeutet (hier: keine Buchung), und antwortet dem Modell ganz normal. + +!!! tip + Die Antwort wird gegen dein Modell validiert, bevor dein Code sie sieht. Ein Client, der + `"maybe"` für ein `bool` schickt, bringt deine Buchung nicht durcheinander: Der Aufruf schlägt mit einem + Schema-Mismatch-Fehler fehl, dein `if` läuft nie. + +## Die Person zu einer URL schicken {#send-the-user-to-a-url} + +Manche Dinge dürfen nicht durch das Modell oder den Client laufen: Zugangsdaten, Kartennummern, OAuth-Zustimmung. Dafür fragst du nicht nach Daten; du bittest die Person, irgendwohin zu gehen: + +```python title="server.py" hl_lines="10-14 23" +--8<-- "docs_src/elicitation/tutorial002.py" +``` + +* `ctx.elicit_url()` nimmt die Nachricht, die zu besuchende **URL** und eine `elicitation_id` deiner Wahl entgegen: einen beliebigen String, der diese Elicitation innerhalb deines Servers identifiziert. +* Das Ergebnis hat eine Action und sonst nichts. `"accept"` heißt, die Person hat zugestimmt, die URL zu öffnen, **nicht**, dass sie das, was auf der anderen Seite wartet, abgeschlossen hat. +* Die Zahlung läuft außerhalb des Protokolls, zwischen dem Browser der Person und deinem Zahlungsanbieter. Über MCP kommt nie irgendein Inhalt zurück. + +Sieh dir das zweite Tool an. Wenn dein Server erfährt, dass der externe Ablauf abgeschlossen ist (ein Webhook, ein Poll; hier als zweites Tool modelliert), sendet `ctx.session.send_elicit_complete(...)` die Benachrichtigung `notifications/elicitation/complete` mit derselben `elicitation_id`. So weiß der Client, dass er *„waiting for payment...“* nicht mehr anzeigen muss. Ohne sie kann der Client nur raten. + +## Die Client-Seite {#the-client-side} + +Server fragen. Clients antworten, indem sie `Client(...)` einen **`elicitation_callback`** übergeben: + +```python title="client.py" hl_lines="6-7 18" +--8<-- "docs_src/elicitation/tutorial003.py" +``` + +* Ein Callback behandelt beide Modi. `params` ist eine Union aus `ElicitRequestFormParams` und `ElicitRequestURLParams`; `isinstance` ist die Verzweigung. +* Bei einer URL zeigst du der Person `params.url` und gibst die Action zurück, die sie gewählt hat. Niemals irgendein `content`. +* Bei einem Formular rendert eine echte Anwendung `params.requested_schema` und gibt die Eingabe der Person als `content` zurück. Dieser hier sagt immer Ja mit einer vorgefertigten Antwort – genau der Callback, den du in einem Test willst. +* Den Callback zu übergeben ist zugleich die **Capability-Deklaration**: So erfährt der Server, dass dieser Client gefragt werden kann. Was ein Client sonst noch für einen Server beantworten kann, steht in **[Client-Callbacks](../client/callbacks.md)**. + +!!! info + Elicitation ist ein Request vom *Server* an den *Client*, und solche gibt es nur auf einer + Session mit klassischem Handshake, deshalb übergibt dieser Client `mode="legacy"`. + Auf einer **2026-07-28**-Verbindung fragt ein Tool stattdessen, indem es die Frage aus dem Aufruf + *zurückgibt*; dieser Ablauf steht in **[Multi-Roundtrip-Requests](multi-round-trip.md)**. + +### Ausprobieren {#try-it} + +Starte die `server.py` des `ctx.elicit`-Formular-Modus (die mit `book_table`) über Streamable HTTP (den Einzeiler dafür hat **[Den Server betreiben](../run/index.md)**), führe dann `main()` des Clients aus und frage `book_table` nach dem ersten Weihnachtstag. + +Der Callback gibt die Frage aus, die er bekommen hat: + +```text +No tables for 2 on 2025-12-25. Would you like to try another date? +``` + +Er antwortet mit `{"accept_alternative": True, "date": "2025-12-27"}`, und das Tool, das die ganze Zeit in `await ctx.elicit(...)` gewartet hat, schließt die Buchung ab: + +```text +Booked a table for 2 on 2025-12-27. +``` + +Tausche nun die `server.py` des URL-Modus ein und richte dasselbe `main()` auf `pay_deposit`: Derselbe Callback nimmt den anderen Zweig, gibt den Bezahllink aus, und das Tool kommt mit *„Complete the payment in your browser.“* zurück. Ein Roundtrip, mitten im Aufruf, in beide Richtungen. + +!!! check + Entferne nun `elicitation_callback=` aus dem `Client` und rufe `book_table` noch einmal für den ersten + Weihnachtstag auf. Der ganze Aufruf schlägt mit einem Protokollfehler fehl: + + ```text + Elicitation not supported + ``` + + Ein Client, der keinen Callback registriert hat, hat die Capability `elicitation` nie deklariert, also gibt es + niemanden zum Fragen. Dein Tool hat kein `"decline"` bekommen; es hat eine Exception bekommen. Plane dafür: Jede + Elicitation braucht eine sinnvolle Antwort auf „Was, wenn ich nicht fragen kann?“. + +## Zusammenfassung {#recap} + +* Ein Parameter mit der Annotation `Annotated[T, Resolve(fn)]` wird von einem Resolver befüllt, der `Elicit(...)` zurückgibt, wenn er fragen muss. Das funktioniert auf jeder Verbindung. +* Das Schema ist ein flaches Pydantic-Modell: nur primitive Felder, auf dem Rückweg validiert. +* `result.action` ist `"accept"`, `"decline"` oder `"cancel"`; `result.data` existiert nur bei Accept. +* `await ctx.elicit(message, schema=Model)` fragt aus dem Tool-Body heraus, und `await ctx.elicit_url(message, url, elicitation_id)` ist für alles, was nicht durch das Modell laufen darf (`ctx.session.send_elicit_complete(elicitation_id)` meldet, dass der externe Teil erledigt ist). Beide sind Server-zu-Client-Requests: Sie brauchen den Client auf einer Legacy-Verbindung. +* Der Client antwortet mit einem einzigen `elicitation_callback`, der nach dem Typ der Params verzweigt; ihn zu registrieren deklariert die Capability. +* Auf einer 2026-07-28-Verbindung gibt der Server die Frage zurück, statt sie zu pushen; derselbe Callback wird von **[Multi-Roundtrip-Requests](multi-round-trip.md)** gespeist. + +Alles unterhalb dieser Rückgabe (die Retry-Schleife, der Schutz von `requestState`, es selbst zu steuern) steht in **[Multi-Roundtrip-Requests](multi-round-trip.md)**. diff --git a/i18n/de/pages/handlers/index.md b/i18n/de/pages/handlers/index.md new file mode 100644 index 0000000000..4d0996ecc9 --- /dev/null +++ b/i18n/de/pages/handlers/index.md @@ -0,0 +1,24 @@ +--- +translation: + sections: [424930166c4bc6f3] + tool: 1 +--- +# Im Handler {#inside-your-handler} + +Die Argumente eines Handlers kommen vom Client. Alles *andere*, was er lesen kann, und alles, was er tun kann, während er läuft, steht hier. + +Was er lesen kann: + +* **[Der Context](context.md)** ist der eine zusätzliche Parameter, den jeder Handler anfordern kann: der laufende Request, seine Header, seine Session sowie die Verben für Fortschritt und Änderungsbenachrichtigungen. +* **[Abhängigkeiten](dependencies.md)** sind Parameter, die das Modell nie sieht – deine eigenen Funktionen füllen sie mit `Resolve`. +* **[Lifespan](lifespan.md)** behandelt Zustand, den dein Server einmal beim Start aufbaut, und wie ein Handler ihn über den `Context` erreicht. + +Was er tun kann, während er läuft: + +* Die Person am Host um weitere Eingaben bitten – mit **[Elicitation](elicitation.md)** (Rückfrage bei der Person am Host) und **[Multi-Roundtrip-Requests](multi-round-trip.md)** (multi-round-trip requests), dem Muster aus 2026-07-28, das sie transportiert. +* Den Client um die Antwort eines LLM oder um seine Arbeitsverzeichnisse bitten – mit **[Sampling und Roots](sampling-and-roots.md)**, veraltet, aber weiterhin bedient. +* **[Fortschritt](progress.md)** bei etwas Langsamem melden. +* Logs schreiben (auf die Standardfehlerausgabe, für alle, die den Server betreiben) – mit **[Logging](logging.md)**. +* Abonnierten Clients mitteilen, dass sich etwas geändert hat – mit **[Abonnements](subscriptions.md)**. + +Wenn du noch keinen Handler registriert hast, beginne mit **[Tools](../servers/tools.md)**. Jede Seite hier setzt voraus, dass du einen hast. diff --git a/i18n/de/pages/handlers/lifespan.md b/i18n/de/pages/handlers/lifespan.md new file mode 100644 index 0000000000..af15b1251e --- /dev/null +++ b/i18n/de/pages/handlers/lifespan.md @@ -0,0 +1,107 @@ +--- +translation: + sections: [f3ca8ac5f90f2dfa, 85a1ef3588ba0736, 563346d4d5804933, 9e3528340d0bab53] + tool: 1 +--- +# Lifespan {#lifespan} + +Die meisten echten Server halten etwas für ihre gesamte Lebensdauer: einen Datenbank-Pool, einen HTTP-Client, ein geladenes Modell. + +Du willst das nicht bei jedem Aufruf neu aufbauen, und du willst es sauber schließen. Genau dafür gibt es den **Lifespan** (Start- und Stopp-Phase des Servers). + +## Ein typisierter Lifespan {#a-typed-lifespan} + +Ein Lifespan ist ein `@asynccontextmanager`, der den Server erhält und per `yield` **ein Objekt** liefert. Was immer du dabei lieferst, steht jedem Handler zur Verfügung, solange der Server läuft. + +```python title="server.py" hl_lines="25-31 34 38 40" +--8<-- "docs_src/lifespan/tutorial001.py" +``` + +Lies es von unten nach oben: + +* `app_lifespan` verbindet die `Database` **vor** dem `yield` und trennt sie **danach**, in einem `finally`. Das sind Start und Stopp. +* Es liefert einen `AppContext`, eine schlichte Dataclass, die die eingerichteten Dinge hält. Heute ein Feld, morgen zehn. +* `MCPServer("Bookshop", lifespan=app_lifespan)` ist die ganze Verdrahtung. +* Im Tool ist das gelieferte Objekt `ctx.request_context.lifespan_context`. + +Der Lifespan läuft **einmal**. Er wird betreten, wenn der Server startet (vor dem ersten Request), und verlassen, wenn der Server stoppt. Alle Requests dazwischen teilen sich denselben `AppContext`. + +!!! info + Wenn du schon einmal einen FastAPI-`lifespan` geschrieben hast, kennst du das bereits. Derselbe Dekorator, dasselbe `yield`, dasselbe `finally`. + +### Was das Modell sieht {#what-the-model-sees} + +Nichts Neues. `ctx` ist ein **Context**-Parameter, also injiziert das SDK ihn, und er landet nie im Eingabeschema: + +```json +{ + "type": "object", + "properties": { + "genre": {"title": "Genre", "type": "string"} + }, + "required": ["genre"], + "title": "count_booksArguments" +} +``` + +`genre` ist das einzige Argument, das das Modell übergeben kann. Der Lifespan ist Sache deines Servers. + +Auch `@mcp.resource()`- und `@mcp.prompt()`-Funktionen können einen `ctx`-Parameter annehmen, geschrieben als bloßer `Context` – aus einem Grund, zu dem der nächste Abschnitt kommt. Alles, was `ctx` mitbringt, steht in **[Der Context](context.md)**. + +### Es ist wirklich typisiert {#it-really-is-typed} + +Sieh dir die Annotation noch einmal an: `ctx: Context[AppContext]`. + +Dieser eine Typparameter ist der Grund, warum `ctx.request_context.lifespan_context` für deinen Type Checker ein `AppContext` **ist**. `.db` wird automatisch vervollständigt; `.dbb` ist ein Fehler, bevor du den Server überhaupt startest. + +Schreibst du stattdessen einen bloßen `Context`, ist `lifespan_context` als `dict[str, Any]` typisiert: Der Type Checker kann nicht wissen, was dein Lifespan geliefert hat. Das Objekt ist zur Laufzeit immer noch da; du hast nur die Hilfe verloren. + +!!! warning + `Context[AppContext]` ist eine Schreibweise **nur für Tools**. Setzt du sie auf eine `@mcp.resource()`- oder + `@mcp.prompt()`-Funktion, schlägt jeder Aufruf dieses Handlers fehl. Der Client bekommt einen Fehler zurück, + und das Server-Log zeigt, warum: + + ```text + Context is not available outside of a request + ``` + + In Ressourcen und Prompts schreibst du das bloße `ctx: Context`. Das Objekt, das dein Lifespan geliefert hat, ist + zur Laufzeit immer noch `ctx.request_context.lifespan_context`; du gibst den Typparameter auf, nicht + das Objekt. + +!!! tip + Es gibt immer einen Lifespan. Übergibst du keinen, liefert der Standard des SDK ein leeres `dict`, + also ist `ctx.request_context.lifespan_context` `{}`, nie `None`. Dieser Standard ist auch der Grund, warum ein + bloßer `Context` es als `dict[str, Any]` typisiert. + +## Zusehen, wie es passiert {#watch-it-happen} + +„Der Start läuft vor dem ersten Request“ ist die Art von Satz, die du nicht einfach glauben müssen solltest. + +Reduziere den Server auf den Lebenszyklus: Gib `Database` ein `connected`-Flag, schalte es in `connect()` und `disconnect()` um und füge ein Tool hinzu, das es meldet. + +```python title="server.py" hl_lines="11 14 17 25 44" +--8<-- "docs_src/lifespan/tutorial002.py" +``` + +`database` lebt aus einem Grund auf Modulebene: damit du es von *außerhalb* des Servers betrachten kannst. + +!!! check + Drei Momente, drei Werte: + + * Bevor der Server startet, ist `database.connected` `False`. Der Import des Moduls hat nichts verbunden. + * Während er läuft, rufe `database_status` auf, und das Ergebnis ist `"connected"`. + * Stoppe den Server, und der `finally`-Block läuft: `database.connected` ist wieder `False`. + + Die Arbeit geschah genau dort, wo du sie hingelegt hast: rund um das `yield`, nicht beim Import und nicht pro Request. + +## Zusammenfassung {#recap} + +* `lifespan=` nimmt einen `@asynccontextmanager`, der den Server erhält und per `yield` ein Objekt liefert. +* Code vor dem `yield` ist der Start. Das `finally` danach ist der Stopp. +* Er läuft einmal, rund um die gesamte Lebensdauer des Servers, nicht pro Request. +* Was immer du per `yield` lieferst, ist `ctx.request_context.lifespan_context` in jedem Tool, jeder Ressource und jedem Prompt. +* `ctx: Context[AppContext]` macht diesen Zugriff in Tools vollständig typisiert. Ressourcen und Prompts nehmen den bloßen `Context`. +* Kein `lifespan=` bedeutet ein leeres `dict`, nie `None`. + +Ein Handler, der mitten im Aufruf anhält, um die Person am Host nach etwas zu fragen, das nur sie weiß, ist **[Elicitation](elicitation.md)** (Rückfrage bei der Person am Host). diff --git a/i18n/de/pages/handlers/logging.md b/i18n/de/pages/handlers/logging.md new file mode 100644 index 0000000000..4c56f0fa3e --- /dev/null +++ b/i18n/de/pages/handlers/logging.md @@ -0,0 +1,87 @@ +--- +translation: + sections: [c93a3e1aefd77955, 7851abd5ec54393b, f49d1ca2f330f9cd, c03764bd9dfeef7b, 4a0391691a674ae4, 2df5cd279eabf9f5] + tool: 1 +--- +# Logging {#logging} + +Logge aus einem Tool genauso wie aus jeder anderen Python-Funktion: mit der Standardbibliothek. + +MCP hat auf Protokollebene eine **Capability für Logging**: Ein Server konnte seine Log-Meldungen über Methoden des `Context`-Objekts als Benachrichtigungen an den Client schicken. Die Revision 2026-07-28 der Spezifikation **erklärt diese Capability für veraltet und ersetzt sie nicht**, deshalb vermitteln diese Docs sie nicht. Die vollständige Liste dessen, was veraltet ist und was du stattdessen tust, steht in **[Veraltete Features](../deprecated.md)**. + +Stattdessen tust du, was du in jedem anderen Python-Programm tust: Du nimmst die Standardbibliothek. + +## Ein Tool, das loggt {#a-tool-that-logs} + +```python title="server.py" hl_lines="1 5 13" +--8<-- "docs_src/logging/tutorial001.py" +``` + +* `logging.getLogger(__name__)` liefert dir einen Logger, der nach deinem Modul benannt ist. Leg ihn einmal an, ganz oben. +* Im Tool rufst du `logger.info(...)` auf wie in jeder anderen Funktion. Nichts zu injizieren, nichts mit `await`, nichts MCP-Spezifisches. + +!!! check + Ruf das Tool auf und sieh dir das ganze Ergebnis an: + + ```python + result.content # [TextContent(text="Found 3 books matching 'dune'.")] + result.structured_content # {'result': "Found 3 books matching 'dune'."} + ``` + + Die Log-Zeile taucht darin nirgends auf. Logging ist für **dich**, die Person, die den Server betreibt. + Das Modell sieht es nie. Wenn das Modell etwas lesen soll, gib es mit `return` zurück. + +## Wohin die Ausgabe geht {#where-it-goes} + +Bei einem **stdio**-Server ist diese Frage wichtiger als sonst. Der Host hat deinen Server als Subprozess gestartet und liest MCP-Nachrichten von dessen **stdout**. Standard Error gehört dir. + +Die Standardbibliothek macht bereits das Richtige: Log-Ausgaben gehen standardmäßig nach `sys.stderr`. Deine `logger.info(...)`-Zeilen landen im Terminal (oder wo auch immer der Host das stderr des Subprozesses einsammelt), und der Protokoll-Stream bleibt sauber. + +!!! tip + Verwende kein `print()` in einem stdio-Server. `print` schreibt nach **stdout**, und stdout gehört dem Protokoll. + Während der Server läuft, leitet das SDK stdout, das tatsächlich *geflusht* wird, nach stderr um, sodass es die + Leitung nicht beschädigen kann. Ein `print()` in einem blockgepufferten Prozess bleibt aber meist ungeflusht im + Puffer von `sys.stdout` liegen, bis der Interpreter ihn beim Beenden leert – direkt auf den Protokoll-Stream. + Selbst wenn die Zeile umgeleitet wird, landet sie roh zwischen den Log-Ausgaben, ohne Level, ohne Logger-Namen + und ohne Möglichkeit, sie zu filtern. + + `logger.debug("got here")` ist dieselbe eine Zeile Aufwand und geht an die richtige Stelle. + +## Das Level {#the-level} + +Du musst `logging.basicConfig()` nicht selbst aufrufen. Das Erzeugen eines `MCPServer` hat das bereits getan, mit einem Handler, der auf Standard Error zeigt, auf dem Level, das du als `log_level=` übergibst. `MCPServer("Bookshop", log_level="DEBUG")` genügt also, um deine `logger.debug(...)`-Zeilen zu sehen. + +Der Standardwert ist `"INFO"`. + +`logging.basicConfig()` ersetzt nie Handler, die bereits existieren. Wenn du das Logging selbst konfigurierst, bevor du den Server erzeugst, gewinnt deine Konfiguration. + +## Ausprobieren {#try-it} + +Starte den Server mit dem MCP Inspector: + +```console +uv run mcp dev server.py +``` + +Ruf `search_books` im Tab **Tools** auf. Der Inspector zeigt dir das Ergebnis: nur den Rückgabewert. Die Zeile + +```text +Searching for 'dune' +``` + +ging nach Standard Error: ins Terminal, nicht auf die Leitung. + +!!! info + Wenn du eigentlich *Tracing* willst (jeden Request, wie lange er gedauert hat, ob er fehlgeschlagen ist), + willst du keine Log-Zeilen, sondern Spans. Dein Server sendet sie bereits: Das SDK zeichnet ohne weitere + Konfiguration jede Nachricht mit OpenTelemetry auf. Siehe **[OpenTelemetry](../run/opentelemetry.md)**. + +## Zusammenfassung {#recap} + +* Die Logging-Capability des MCP-Protokolls ist mit der Spezifikation 2026-07-28 veraltet und wird nicht ersetzt. Bau nicht darauf auf. +* `logger = logging.getLogger(__name__)` auf Modulebene, `logger.info(...)` im Tool. Das ist das ganze Muster. +* Log-Ausgaben erreichen das Modell nie. Nur der Wert, den du mit `return` zurückgibst. +* Standard Error gehört dir; stdout gehört dem Protokoll. Das SDK leitet geflushtes, verirrtes stdout während des Betriebs nach stderr um, aber ein ungeflushtes `print()` kann beim Beenden trotzdem auf die Leitung gelangen, und umgeleitete Zeilen kommen ohne Kennzeichnung an. Nimm `logging`, dessen Handler jeden Eintrag flusht. +* `MCPServer(..., log_level="DEBUG")` setzt das Level, und eine Logging-Konfiguration, die du vorher angelegt hast, bleibt unangetastet. + +Wie du verbundenen Clients mitteilst, dass sich auf deinem Server etwas geändert hat (die Tool-Liste, eine Ressource), steht in **[Abonnements](subscriptions.md)**. diff --git a/i18n/de/pages/handlers/multi-round-trip.md b/i18n/de/pages/handlers/multi-round-trip.md new file mode 100644 index 0000000000..37737b09e3 --- /dev/null +++ b/i18n/de/pages/handlers/multi-round-trip.md @@ -0,0 +1,191 @@ +--- +translation: + sections: [74011e683045eea9, 9b64cc175c18b6a9, 4b41be4824030397, e3b1502da786ec33, 71e41161f143c6a9, 9ec2c1eeb8c36378, 8dd027377d46448b, f81491125dcbfe8b] + tool: 1 +--- +# Multi-Roundtrip-Requests {#multi-round-trip-requests} + +Manchmal kann ein Tool nicht in einem einzigen Roundtrip fertig werden. Es braucht etwas, das nur die Person am Host hat: eine Auswahl, eine Bestätigung, Zugangsdaten. + +Vor 2026-07-28 holte der Server sich das, indem er **zurückrief**: Mitten in der Bearbeitung des ursprünglichen Requests öffnete er einen eigenen Request an den Client (eine Elicitation – also eine Rückfrage bei der Person am Host – oder einen Sampling-Aufruf). Die Spec 2026-07-28 schafft diesen Rückkanal (back-channel) ab. + +Stattdessen **gibt** der Server etwas **zurück**. + +## Zurückgeben statt zurückrufen {#return-dont-call-back} + +Der Server beantwortet `tools/call` mit einem **`InputRequiredResult`** statt mit einem `CallToolResult`. Zwei seiner Felder erledigen die Arbeit: + +* **`input_requests`**: was der Server noch braucht, als Dict mit Schlüsseln, die der Server selbst gewählt hat. Jeder Wert ist ein `ElicitRequest`, ein `CreateMessageRequest` oder ein `ListRootsRequest`. +* **`request_state`**: ein opakes Token. Der Client schickt es beim Retry unverändert zurück. Dein Server ist der Einzige, der es liest. + +Der Client erfüllt jeden Request und ruft dann **dasselbe Tool noch einmal** auf, mit seinen Antworten in `input_responses` und dem Token in `request_state`. Der Server hat jetzt, was ihm fehlte, und gibt ein normales `CallToolResult` zurück. + +Das ist das ganze Protokoll. Jede Etappe ist ein gewöhnlicher Request vom Client an den Server. Nie fließt etwas in die andere Richtung. + +## Die Serverseite {#the-server-side} + +Auf `@mcp.tool()` baust du das selten von Hand: Deklariere eine Abhängigkeit, die bei der Person am Host zurückfragt (`Elicit`), das LLM des Clients per Sampling nutzt (`Sample`) oder seine Roots auflistet (`ListRoots`), und das SDK gibt das `InputRequiredResult` für dich zurück; diese Form beschreibt die Seite **[Abhängigkeiten](dependencies.md)**. Die beiden Formen lassen sich nicht mischen: Ein Aufruf hat genau einen `input_responses`/`request_state`-Kanal, deshalb kann ein Tool, das `Resolve(...)`-Parameter verwendet, nicht zusätzlich ein `InputRequiredResult` aus seinem Rumpf zurückgeben. Ein deklarierter `InputRequiredResult`-Rückgabetyp wird bei der Registrierung abgelehnt (`InvalidSignature`), ein nicht deklarierter lässt den Aufruf zur Laufzeit fehlschlagen. Die manuelle Form ist der **Low-Level**-`Server`, dessen Handler `on_call_tool` beide Ergebnistypen zurückgeben darf: + +```python title="server.py" hl_lines="43-46" +--8<-- "docs_src/mrtr/tutorial001.py" +``` + +* `on_call_tool` ist typisiert als `-> CallToolResult | InputRequiredResult`. Das zweite zurückzugeben ist die gesamte serverseitige API. +* Beim ersten Aufruf ist `params.input_responses` `None`, also greift die Guard-Bedingung, und der Handler fragt, statt zu antworten. +* Beim Retry liegt das `ElicitResult`, das der Client geschickt hat, unter **demselben Schlüssel** (`"region"`), den der Server in `input_requests` verwendet hat. + +Alles andere in dieser Datei (das explizite `input_schema`, das von Hand gebaute `CallToolResult`) ist der gewöhnliche Low-Level-`Server`, behandelt in **[Der Low-Level-Server](../advanced/low-level-server.md)**. Diese Seite fügt nur den zweiten Rückgabetyp hinzu. + +## Über Tools hinaus {#beyond-tools} + +`tools/call` ist nichts Besonderes: Unter 2026-07-28 darf ein Server `prompts/get` und `resources/read` genauso beantworten. Auf `MCPServer` gibt eine `@mcp.prompt()`-Funktion – oder eine `@mcp.resource()`-**Template**-Funktion – das `InputRequiredResult` selbst zurück und liest die Antworten des Retrys aus dem Context: + +```python title="server.py" hl_lines="20 22 24" +--8<-- "docs_src/mrtr/tutorial004.py" +``` + +* Die erste Runde gibt das `InputRequiredResult` zurück. Beim Retry hält `ctx.input_responses` die Antworten unter denselben Schlüsseln bereit, und die Funktion gibt ihr gewöhnliches Ergebnis zurück – hier Prompt-Nachrichten, bei einer Template-Ressource Ressourceninhalt. +* Ein `request_state`, den du setzt, wird versiegelt, bevor er über die Leitung geht, und beim Echo verifiziert, wie alles andere auf dem Server; **[`requestState` schützen](#protecting-requeststate)** weiter unten beschreibt, was dir das Siegel bringt und wann du Schlüssel konfigurieren musst. +* Eine `@mcp.tool()`-Funktion kann das Ergebnis genauso direkt zurückgeben, wenn die Abhängigkeitsform nicht passt. +* Statische `@mcp.resource()`-Funktionen nehmen nicht teil: Sie bekommen keinen `Context` und könnten den Retry deshalb nie lesen. Nur Template-Ressourcen können fragen. +* Die Regeln zur Protokollgeneration weiter unten gelten unverändert: Ein `InputRequiredResult` auf einer Session vor 2026 zurückzugeben, ergibt denselben `-32603`, den die Warnung beschreibt. + +## Die Clientseite {#the-client-side} + +`Client` führt die Schleife für dich aus. + +Registriere die Callbacks, nach denen der Server fragen könnte (`elicitation_callback`, `sampling_callback`, `list_roots_callback`), und rufe das Tool auf. Kommt ein `InputRequiredResult` an, verteilt `Client` jeden Eintrag in `input_requests` an den passenden Callback, wiederholt den Aufruf mit den Antworten und dem zurückgeschickten `request_state` und macht weiter, bis ein `CallToolResult` zurückkommt: + +```python title="client.py" hl_lines="11 12" +--8<-- "docs_src/mrtr/tutorial003.py" +``` + +* Dieser `elicitation_callback` ist derselbe, den das `elicitation/create` eines Servers vor 2026 über den Rückkanal getroffen hätte. Dasselbe gilt für `sampling_callback` bei `sampling/createMessage` und für `list_roots_callback` bei `roots/list`: Unter 2026-07-28 sind die eigenständigen Server->Client-RPCs verschwunden, aber die identischen Payloads `ElicitRequest` / `CreateMessageRequest` / `ListRootsRequest` reisen in `input_requests` mit und landen bei denselben drei Callbacks. Ein Satz Callbacks bedient beide Generationen. +* `call_tool` gibt ein schlichtes `CallToolResult` zurück. Die Zwischenrunden sind für den aufrufenden Code unsichtbar. +* `get_prompt` und `read_resource` treiben dieselbe Schleife. + +!!! check + Lässt du den Callback weg, scheitert die Schleife in der ersten Runde: Der Ersatz-Callback des SDK + beantwortet jede Elicitation mit einem Fehler, und `call_tool` löst `MCPError` mit der Meldung + *„Elicitation not supported“* aus. + +Die Schleife ist begrenzt. `Client(..., input_required_max_rounds=10)` ist die Standardobergrenze; ein Server, der darüber hinaus weiter `InputRequiredResult` zurückgibt, lässt `call_tool` eine Exception auslösen. Trägt eine Runde nur `request_state` und keine `input_requests`, schläft `Client` kurz (50 ms, verdoppelt bis zu einer Obergrenze von 250 ms), bevor er es erneut versucht. So wird ein Server, der nur *„noch nicht fertig“* sagt, nicht in einer Dauerschleife abgefragt. + +### Die Schleife selbst steuern {#driving-the-loop-yourself} + +Die automatische Schleife genügt für einen Client in einem einzigen Prozess. Übernimm die Schleife stattdessen selbst, wenn: + +* dein Client **verteilt** ist: Der Prozess, der der Person die Frage anzeigt, ist nicht der Prozess, der `call_tool` aufgerufen hat, also setzt ein anderer Worker den Retry ab. `request_state` ist das persistierbare Token, das du über diese Grenze trägst – durch deinen eigenen Speicher –, und `input_responses` ist das, was die andere Seite damit zurückschickt. +* du jede Runde **inspizieren** willst: jeden `input_requests`-Eintrag loggen oder auditieren, bestimmte Request-Arten ablehnen oder zwischen den Etappen ein eigenes Backoff anwenden. +* du eine Grenze nach **Uhrzeit** statt nach Rundenzahl willst: Umschließe deine eigene Schleife mit `anyio.fail_after(...)`, statt dich auf `input_required_max_rounds` zu verlassen. + +Geh auf die darunterliegende Session hinunter, wo `allow_input_required=True` dir die Union direkt aushändigt: + +```python title="client.py" hl_lines="12 13 19" +--8<-- "docs_src/mrtr/tutorial002.py" +``` + +* `client.session.call_tool(..., allow_input_required=True)` erweitert den Rückgabetyp auf `CallToolResult | InputRequiredResult`. Das `isinstance` engt ihn wieder ein. +* `request_state` liegt jetzt in deiner Hand. Schreib ihn zwischen den Etappen weg, und das Gespräch kann aus einem frischen Prozess fortgesetzt werden. +* Für jeden Eintrag in `input_requests` legst du eine `InputResponse` unter **demselben Schlüssel** in `input_responses` ab. `fulfil` ist die Stelle für deine UI; diese hier kodiert die Antwort fest. +* Derselbe Tool-Name, dieselben `arguments`, in jeder Etappe. Der Retry ist der ursprüngliche Aufruf, noch einmal ausgeführt, keine neue Methode. + +## `requestState` schützen {#protecting-requeststate} + +Alles oben behandelt `request_state` als Echo, und auf der Leitung ist er auch nichts anderes. Aber der Client hält ihn zwischen den Etappen (ihn über Prozesse hinweg wegzuschreiben ist genau das, was der vorige Abschnitt abgesegnet hat), also ist das, was zurückkommt, **vom Client gelieferte Eingabe**: Sie kann verändert, abgelaufen oder aus einem ganz anderen Aufruf entnommen sein. Die Spec verlangt von Servern, die Integrität dieses Zustands zu schützen und die Runde abzulehnen, wenn die Verifikation fehlschlägt – immer dann, wenn der Zustand Autorisierung, Ressourcenzugriff oder Geschäftslogik beeinflussen kann. + +`MCPServer` schützt ihn standardmäßig. Jeder Server versiegelt ausgehenden `requestState` und verifiziert jedes Echo – Resolver-Zustand und von Hand gebauten Zustand gleichermaßen – unter einem Schlüssel, der beim Prozessstart erzeugt wird. Du konfigurierst nichts, schreibst Klartext und liest Klartext; über die Leitung geht immer nur ein opakes, verschlüsseltes Token. + +Der Standardschlüssel lebt und stirbt mit dem Prozess – das ist das Eine, was du wissen musst, bevor du über einen einzelnen Prozess hinaus bereitstellst: + +```python +from mcp.server.mcpserver import MCPServer, RequestStateSecurity + +# Multi-instance or restart-surviving: one or more shared secret keys (>= 32 bytes each). +mcp = MCPServer("fleet", request_state_security=RequestStateSecurity(keys=[key])) +``` + +* **Der Standard (keine Konfiguration)** passt für einen einzelnen Prozess: stdio oder genau ein HTTP-Worker. Ein Retry, der bei einem anderen Worker, einer anderen Instanz hinter einem Load Balancer oder demselben Server nach einem Neustart landet, ist unter einem Schlüssel versiegelt, den dieser Prozess nicht hat – der Client bekommt die unten beschriebene feste Ablehnung und muss den Ablauf von vorn beginnen. +* **`keys=[...]`** ist erforderlich, sobald ein Retry eine **andere Instanz** erreichen kann (`uvicorn` mit mehreren Workern, HTTP hinter Lastverteilung) oder Neustarts überleben muss: Jede Instanz verifiziert, was irgendeine Schwesterinstanz ausgestellt hat. Dieselbe Maschinerie, dein Geheimnis statt eines erzeugten. +* Für eigene Kryptografie, etwa ein KMS oder einen vorhandenen Token-Dienst, übergib `RequestStateSecurity(codec=...)` statt `keys`; **[Eigene Kryptografie mitbringen](#bring-your-own-crypto)** weiter unten beschreibt den Vertrag. + +### Was das Siegel trägt {#what-the-seal-carries} + +Ob Standard oder konfiguriert: `requestState` auf der Leitung ist ein verschlüsseltes, authentifiziertes Token. Dein Code sieht es nie: Handler und Resolver schreiben Klartext und lesen Klartext (`ctx.request_state`); das SDK versiegelt auf dem Weg hinaus und verifiziert auf dem Weg hinein. Über die Integrität hinaus ist jedes Token gebunden an: + +* **Ein Zeitfenster.** Jede Runde versiegelt neu mit frischem Ablaufzeitpunkt, deshalb begrenzt `RequestStateSecurity(ttl=...)` (Standardwert 600 Sekunden) die Bedenkzeit pro Runde, nicht den ganzen Ablauf. +* **Den authentifizierten Principal.** Trägt der Request ein OAuth-Access-Token, das das SDK validiert hat, wird der Zustand an Client, Issuer und Subject des Tokens gebunden: Zustand, der für eine Person ausgestellt wurde, scheitert unter einer anderen, selbst wenn beide denselben OAuth-Client teilen. Ein Verifier, der kein Subject liefert, schwächt die Bindung auf die Client-Identität allein ab, die bei URL-basierten Client-IDs alle teilen, die diese Client-Software verwenden. Wird die Authentifizierung außerhalb des SDK terminiert (ein vorgeschalteter Proxy) oder ist der Transport nicht authentifiziert, gibt es keinen Principal zum Binden, und diese Prüfung bleibt wirkungslos – es sei denn, `RequestStateSecurity(bind_principal=...)` liefert einen aus deinem eigenen Identitätssignal. Welche Bestandteile dein Token-Verifier auch liefert, er muss sie konsistent liefern: Ein Verifier, der das Subject bei manchen Requests einschließt und bei anderen weglässt, ändert den Principal mitten im Ablauf, und laufende Runden werden abgelehnt. +* **Den auslösenden Request.** Die Methode, den Tool- oder Prompt-Namen (oder den Ressourcen-URI) und einen Digest der Argumente. Ein Token, das gegen ein anderes Tool, andere Argumente oder eine andere Methode wieder eingespielt wird, scheitert. +* **Die genaue gestellte Frage.** Jede Resolver-Antwort ist an die gerenderte Frage geheftet, die dem Client gezeigt wurde, sowohl in der Runde, in der sie zuerst eintrifft, als auch wenn eine aufgezeichnete Antwort später wiederverwendet wird. Stellst du mit umformulierter Nachricht oder geändertem Schema neu bereit, fragt der Server erneut, statt eine veraltete Antwort zu verbrauchen. Dieselbe Bindung wirkt auch andersherum: Leite Nachrichten aus den Argumenten des Tools ab, nicht aus Daten pro Aufruf. Eine Nachricht, die aus einem Zeitstempel oder einem Live-Kurs gebaut ist, rendert in jeder Runde anders, sodass jede aufgezeichnete Antwort veraltet aussieht und der Server erneut fragt, bis das Rundenlimit des Clients den Aufruf beendet. + +All das ist Aufgabe des SDK, nicht deine – und nicht die des Codecs, falls du deinen eigenen mitbringst. + +### Schlüssel rotieren {#rotating-keys} + +`keys[0]` versiegelt neuen Zustand; jeder Schlüssel in der Liste verifiziert. Eine Rotation ohne Ausfallzeit besteht aus drei Phasen, jede vollständig ausgerollt, bevor die nächste beginnt: + +```python +RequestStateSecurity(keys=[OLD, NEW]) # 1: every instance learns to verify NEW; OLD still mints +RequestStateSecurity(keys=[NEW, OLD]) # 2: NEW mints; in-flight OLD state keeps verifying +RequestStateSecurity(keys=[NEW]) # 3: one ttl after phase 2 is fully out, retire OLD +``` + +Befördere niemals zuerst den ausstellenden Schlüssel: Unter einem Schlüssel auszustellen, den manche Instanz noch nicht verifizieren kann, lässt laufende Runden mitten im Rollout fallen. + +Schlüssel gelten für genau einen Dienst. Der versiegelte Umschlag trägt außerdem den Namen des Servers als Audience-Claim, sodass ein Token, das ein anderer Dienst ausgestellt hat, der zufällig ein Geheimnis teilt, trotzdem abgelehnt wird. Der Claim ist nur so unterscheidungskräftig wie der Name, deshalb muss ein Server mit expliziter Policy einen echten Namen haben oder `RequestStateSecurity(audience=...)` setzen – ein unbenannter löst bei der Konstruktion eine Exception aus. `audience=` dient auch bewussten Multi-Service-Topologien, in denen ein Dienst Zustand akzeptieren muss, den ein anderer ausgestellt hat. (Der konfigurationsfreie Standard ist ausgenommen: Sein Schlüssel verlässt den Prozess nie, also hat der Audience-Claim nichts hinzuzufügen.) + +### Eigene Kryptografie mitbringen {#bring-your-own-crypto} + +`RequestStateSecurity(codec=...)` nimmt alles mit `seal(bytes) -> str` und `unseal(str) -> bytes`, das für jedes Token, das es nicht selbst ausgestellt hat, `InvalidRequestState` auslöst. Die klassische Form ist Envelope Encryption gegen ein KMS, bei der du beim Start einmal einen Datenschlüssel entpackst und die Kryptografie pro Token lokal hältst: + +```python title="server.py" hl_lines="12 26-27 34-35 38" +--8<-- "docs_src/mrtr/tutorial005.py" +``` + +TTL, Principal-Bindung und Request-Bindung sind **nicht** Sache des Codecs: Das SDK stempelt sie vor `seal` in die Payload und verifiziert sie nach `unseal` erneut, für jeden Codec. Die einzigen Pflichten eines Codecs sind Integrität (manipuliert heißt: Exception auslösen) und idealerweise Vertraulichkeit. + +### Wenn die Verifikation fehlschlägt {#when-verification-fails} + +Jeder eingehende Fehlschlag – ob manipuliert, abgelaufen, gegen einen anderen Request oder Principal wieder eingespielt oder unter einem Schlüssel versiegelt, den dieser Server nicht kennt – bekommt dieselbe Antwort: + +```json +{"code": -32602, "message": "Invalid or expired requestState"} +``` + +Eine feste Meldung für jede Ursache, damit die Leitung nie verrät, welche Prüfung fehlschlug; der wahre Grund geht ins Server-Log. Jeder eingehende `requestState` auf `tools/call`, `prompts/get` und `resources/read` wird geprüft, auch einer, der für einen Handler eintrifft, der nie Zustand ausstellt. Die in der Praxis häufigste Ablehnung ist kein Angriff – es ist der prozesslokale Standardschlüssel, der auf einen Retry von vor einem Neustart oder von einer anderen Instanz trifft; der Client startet den Ablauf neu, und `keys=[...]` ist die Lösung, wenn das ins Gewicht fällt. + +### Von Hand gebauter Zustand {#hand-built-state} + +Ein `request_state`, den du selbst setzt (indem du `InputRequiredResult` aus einer Tool-, Prompt- oder Ressourcen-Template-Funktion zurückgibst), wird von derselben Maschinerie versiegelt und verifiziert wie Resolver-Zustand, ganz ohne Codeänderungen: Klartext schreiben, Klartext lesen, und jede Bindung oben gilt. + +Das Eine, was das SDK dir nicht festheften kann, selbst wenn konfiguriert, ist die Identität der Frage: Es weiß nicht, zu welcher *deiner* Fragen eine Antwort in deinem Zustand gehört. Speicherst du Antworten nach Fragen geschlüsselt, nimm deine eigene Fragekennung in den Zustand auf und prüfe sie beim Retry. + +Der Low-Level-`Server` ist die Stufe ohne Extras: Anders als bei `MCPServer` wird nichts versiegelt, bis du die Grenze selbst anhängst, und bis dahin geht dein `request_state` genau so über die Leitung, wie du ihn geschrieben hast. Das einzeilige Opt-in zeigt **[Der Low-Level-Server](../advanced/low-level-server.md#the-other-handlers)**. + +## Ein Ergebnis für 2026-07-28 {#a-2026-07-28-result} + +`InputRequiredResult` gibt es nur bei Protokollversion **2026-07-28**. Der In-Memory-`Client(server)` handelt sie für dich aus; über die Leitung entdeckt `mode="auto"` sie. Nach dem Verbinden sagt dir `client.protocol_version`, was du bekommen hast. + +!!! warning + Eine Session vor 2026 hat keinen Platz für ein `InputRequiredResult`. Gibst du eines aus deinem Handler auf einer + `mode="legacy"`-Verbindung zurück, kann der Runner es nicht in die ausgehandelte Version serialisieren; der + Client bekommt einen `-32603`-Fehler *„Handler returned an invalid result“* zurück. Ein Server, der + beide Generationen bedient, muss `ctx.protocol_version` prüfen, bevor er danach greift. + +!!! info + **Elicitation im URL-Modus** nutzt auf einer 2026er-Verbindung genau diesen Mechanismus. Der Eintrag in + `input_requests` ist ein `ElicitRequest`, dessen Params `ElicitRequestURLParams` sind; die Person + schließt den Out-of-band-Ablauf ab, und dein Client wiederholt den Aufruf. Dieselbe Schleife, keine neue API. Die + Hälfte für den High-Level-Server steht in **[Elicitation](elicitation.md)**. + +## Zusammenfassung {#recap} + +* Unter 2026-07-28 **gibt** ein Server, der mitten im Aufruf Eingaben braucht, ein `InputRequiredResult` **zurück**. Er öffnet nie einen Request an den Client. +* `input_requests` ist, was er braucht. `request_state` ist ein opakes Wiederaufnahme-Token, das nur der Server liest. +* `Client` führt die Retry-Schleife für dich aus: Registriere `elicitation_callback` / `sampling_callback` / `list_roots_callback`, und `call_tool` gibt ein schlichtes `CallToolResult` zurück. `input_required_max_rounds` (Standardwert 10) begrenzt sie. +* Um Runden zu inspizieren oder zu persistieren, verwende `client.session.call_tool(..., allow_input_required=True)` und übernimm die Schleife `while isinstance(result, InputRequiredResult)` selbst. +* Auf `@mcp.tool()` erzeugt eine Abhängigkeit, die bei der Person am Host zurückfragt, dieses Ergebnis für dich (**[Abhängigkeiten](dependencies.md)**); der **Low-Level**-`Server` ist die manuelle Form. +* Prompts und Ressourcen nehmen ebenfalls teil: Eine `@mcp.prompt()`- oder Template-`@mcp.resource()`-Funktion gibt das `InputRequiredResult` selbst zurück und liest beim Retry `ctx.input_responses`. +* `requestState` kommt als vom Client gelieferte Eingabe zurück, deshalb versiegelt `MCPServer` ihn standardmäßig – Resolver-Zustand und von Hand gebauten Zustand gleichermaßen – unter einem prozesslokalen Schlüssel; Deployments mit mehreren Instanzen übergeben `RequestStateSecurity(keys=[...])` (oder einen eigenen Codec), damit jede Instanz verifizieren kann, was eine Schwesterinstanz ausgestellt hat. Das Siegel bindet jedes Token an ein Zeitfenster, den auslösenden Request und den authentifizierten Principal, wenn der Request eine vom SDK validierte Authentifizierung trägt oder `bind_principal=` dein eigenes Identitätssignal liefert (**[`requestState` schützen](#protecting-requeststate)**). + +Das ist der Mechanismus, der serverinitiiertes Sampling und den Rest des Push-artigen Rückkanals ersetzt; siehe **[Veraltete Features](../deprecated.md)**. diff --git a/i18n/de/pages/handlers/progress.md b/i18n/de/pages/handlers/progress.md new file mode 100644 index 0000000000..cdefdfabc9 --- /dev/null +++ b/i18n/de/pages/handlers/progress.md @@ -0,0 +1,122 @@ +--- +translation: + sections: [5315262fe26b33e1, 9d8e98840f1b78f0, 0284b215e85366c4, 8534d8dbb4053a70, 2966fac6fe697007] + tool: 1 +--- +# Fortschritt {#progress} + +Ein Tool, das dreißig Sekunden braucht und dreißig Sekunden lang schweigt, wirkt kaputt. + +**Fortschrittsbenachrichtigungen** beheben das. Das Tool meldet, wie weit es ist; der Client entscheidet, was er daraus zeichnet: einen Balken, einen Spinner, eine Log-Zeile. + +## Aus dem Tool melden {#report-it-from-the-tool} + +Nimm einen **`Context`**-Parameter entgegen und rufe `report_progress` auf: + +```python title="server.py" hl_lines="8 11" +--8<-- "docs_src/progress/tutorial001.py" +``` + +Drei Argumente, und du bestimmst, was sie bedeuten: + +* `progress`: wie weit du bist. Die Spezifikation verlangt, dass der Wert mit jeder Meldung **steigt**; wiederhole nie einen Wert und geh nie rückwärts. +* `total`: wie viel es insgesamt ist, falls du es weißt. Optional. +* `message`: eine menschenlesbare Zeile über *diesen* Schritt. Optional. + +`ctx` wird wegen seines Type Hints injiziert, und das Modell sieht ihn nie: Das Eingabeschema von `import_catalog` hat eine einzige Property, `urls`. Die Seite **[Der Context](context.md)** dreht sich ganz um dieses Objekt; Fortschritt ist eines der Dinge, die es dir bietet. + +## Im Client darauf lauschen {#listen-for-it-from-the-client} + +Der Client meldet sich **pro Aufruf** an, indem er `progress_callback=` an `call_tool` übergibt: + +```python title="client.py" hl_lines="7 16" +import anyio +from mcp import Client + +from server import mcp + + +async def show(progress: float, total: float | None, message: str | None) -> None: + print(f"{message} ({progress}/{total})") + + +async def main() -> None: + async with Client(mcp) as client: + result = await client.call_tool( + "import_catalog", + {"urls": ["https://example.com/a.json", "https://example.com/b.json"]}, + progress_callback=show, + ) + print(result.structured_content) + + +anyio.run(main) +``` + +Der Callback ist eine `async`-Funktion, die genau das entgegennimmt, was der Server gemeldet hat: `progress`, `total`, `message`. + +!!! info + `Client(mcp)` verbindet sich direkt mit dem Server-Objekt, im Speicher – derselbe Client, auf dem die Seite + **[Testen](../get-started/testing.md)** aufbaut. `progress_callback` ist derselbe Parameter, egal welchen + Transport der `Client` nutzt; das *Timing*, das du gleich siehst, ist das der In-Memory-Verbindung. Sie führt + deinen Callback inline aus, sodass jede Meldung eintrifft, bevor `call_tool` zurückkehrt. Über einen echten + Transport liefern sich die Benachrichtigungen ein Rennen mit dem Ergebnis, und ein langsamer Callback kann noch + laufen, nachdem `call_tool` bereits zurückgekehrt ist. + +### Ausprobieren {#try-it} + +Lege `client.py` neben `server.py` und starte es: + +```console +python client.py +``` + +```text +Imported https://example.com/a.json (1/2) +Imported https://example.com/b.json (2/2) +{'result': 'Imported 2 records.'} +``` + +Jedes `await ctx.report_progress(...)` auf dem Server wurde zu einem Aufruf von `show` auf dem Client, in derselben Reihenfolge, und beide Zeilen wurden ausgegeben, **bevor** `call_tool` zurückkehrte. Fortschritt wird nicht ins Ergebnis gepackt; er streamt, während das Tool noch arbeitet. + +!!! warning + `progress_callback` gehört zum **Aufruf**, nicht zum `Client`. Es gibt kein Konstruktorargument dafür, + weil verschiedene Aufrufe verschiedene Callbacks wollen: Einer treibt einen Download-Balken an, der nächste + eine Log-Zeile. + +!!! check + Lösche jetzt `progress_callback=show` und starte es erneut: + + ```text + {'result': 'Imported 2 records.'} + ``` + + Kein Fehler, keine Warnung, dasselbe Ergebnis. `report_progress` ist ein **No-op, wenn der Aufrufer keinen + Fortschritt angefordert hat**. Du meldest also bedingungslos und musst dich nie fragen, ob überhaupt jemand + zuhört. + +## Wenn du die Gesamtmenge nicht kennst {#when-you-dont-know-the-total} + +`total` ist für den Fall, dass du den Nenner kennst. Oft kennst du ihn nicht: Du leerst einen Feed, läufst einen Cursor ab, lädst etwas ohne Längen-Header herunter. + +Lass es weg: + +```python title="server.py" hl_lines="20" +--8<-- "docs_src/progress/tutorial002.py" +``` + +Der Callback erhält `total=None`. Ein Client kann weiterhin *Aktivität* anzeigen („3 imported so far...“), aber keinen Prozentwert. Erfinde keine Gesamtmenge, nur um einen hübscheren Balken zu bekommen. + +!!! tip + `progress` muss nichts Bestimmtes zählen. Bytes, Zeilen, Seiten: Wähle die Einheit, die die Person am Host + wiedererkennt, und versprich nur ein `total`, das du halten kannst. + +## Zusammenfassung {#recap} + +* `await ctx.report_progress(progress, total=None, message=None)` aus jedem Tool, das einen `Context` entgegennimmt. +* Der Client übergibt `progress_callback=` an `call_tool`: pro Aufruf, nie am `Client`. +* Der Callback ist `async (progress, total, message) -> None` und feuert, während das Tool noch läuft. +* Kein Callback am Aufruf heißt: `report_progress` tut nichts. Melde bedingungslos. +* Lass `total` weg, wenn du es nicht kennst; der Callback bekommt `None`. + +Fortschritt ist das, was ein laufendes Tool der *Person am Host* zeigt. Die Zeilen, die es für *dich* loggt – für dich, weil du den Server betreibst –, sind ein anderer Kanal: **[Logging](logging.md)**. diff --git a/i18n/de/pages/handlers/sampling-and-roots.md b/i18n/de/pages/handlers/sampling-and-roots.md new file mode 100644 index 0000000000..80080b8797 --- /dev/null +++ b/i18n/de/pages/handlers/sampling-and-roots.md @@ -0,0 +1,51 @@ +--- +translation: + sections: [5c82b20cbd65ded0, 9dc22632be79a533, 1fb8f452e990c456, 42666ab914ff0cb1, c4e0cb3667fd5ff9] + tool: 1 +--- +# Sampling und Roots {#sampling-and-roots} + +Ein Handler kann den verbundenen Client um zwei weitere Dinge bitten: eine Completion vom eigenen Modell des Clients (**Sampling**) und die Arbeitsverzeichnisse des Clients (**Roots**, freigegebene Arbeitsverzeichnisse). + +Beides funktioniert weiterhin, auf jeder Protokollversion, die das SDK spricht. Lies aber die Warnung, bevor du dein Design darauf aufbaust: + +!!! warning "Veraltet seit der Spezifikation 2026-07-28" + Sampling und Roots gelten seit `2026-07-28` als veraltet ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2577)). Sie bleiben voll funktionsfähig und stehen noch mindestens zwölf Monate in der Spezifikation, bevor sie entfernt werden dürfen, aber neue Implementierungen sollten nicht mehr darauf aufbauen. Die empfohlenen Migrationen: Binde statt Sampling direkt die API deines LLM-Anbieters an, und übergib Verzeichnisse statt über Roots per Tool-Parameter, Ressourcen-URI oder Serverkonfiguration. Die SDK-weite Liste steht in **[Veraltete Features](../deprecated.md)**. + +## Sampling: das Modell des Clients ausleihen {#sampling-borrow-the-clients-model} + +Ein Resolver gibt `Sample(...)` zurück, und das Tool erhält die Completion – über denselben Abhängigkeitsmechanismus, der in **[Abhängigkeiten](dependencies.md)** `Elicit` ausführt: + +```python title="server.py" hl_lines="10-15 19" +--8<-- "docs_src/sampling_and_roots/tutorial001.py" +``` + +* `Sample(messages, max_tokens=...)` spiegelt die Parameter von `sampling/createMessage` wider. Der injizierte Wert ist das `CreateMessageResult` des Clients; übergibst du `tools` oder `tool_choice`, wird daraus stattdessen ein `CreateMessageResultWithTools`. +* Der Client muss die Capability `sampling` deklariert haben (`sampling.tools`, wenn du `tools` oder `tool_choice` übergibst). Hat er das nicht, schlägt der Aufruf mit einem Protokollfehler `-32021` fehl, statt einen Request zu senden, den der Client nicht verarbeiten kann. Eine Session aus der Zeit vor 2026 ohne Rückkanal (back-channel) schlägt mit ihrem üblichen No-Back-Channel-Fehler fehl, weil es nichts gibt, worüber gesendet werden könnte. +* Bei `2026-07-28` wird der Request innerhalb des Multi-Roundtrip-Ablaufs zugestellt (**[Multi-Roundtrip-Requests (multi-round-trip requests)](multi-round-trip.md)**); bei `2025-11-25` ist er ein eigenständiger Request an den Client. Der Code ist in beiden Fällen derselbe, beachte aber die Multi-Roundtrip-Regel: Der Request muss in jeder Wiederholungsrunde identisch aussehen. Baue ihn deshalb nur aus den Argumenten des Tools und anderen stabilen Daten. +* Lass `include_context` unangetastet: Andere Werte als `"none"` sind selbst veraltet (SEP-2596) und brauchen eine Capability, die fast kein Client deklariert. + +## Roots: Wohin damit? {#roots-where-should-this-go} + +Roots sind die Verzeichnisse, auf denen der Server laut Client arbeiten darf. Sie sind ein informativer Hinweis, kein Mechanismus zur Zugriffskontrolle. Ein Resolver gibt `ListRoots()` zurück: + +```python title="server.py" hl_lines="10-11 15" +--8<-- "docs_src/sampling_and_roots/tutorial002.py" +``` + +* Das injizierte `ListRootsResult` enthält eine Liste von `Root`-Objekten: jeweils einen `file://`-URI und einen optionalen Anzeigenamen. +* Die Hürde ist dieselbe wie beim Sampling: Ohne deklarierte Capability `roots` schlägt der Aufruf mit `-32021` fehl, statt den Request zu senden. + +Auf der anderen Seite der Leitung beantwortet der Client beide Requests mit den Callbacks, die er ohnehin schon hat: `sampling_callback` und `list_roots_callback`, beschrieben in **[Client-Callbacks](../client/callbacks.md)**. + +## Auf Verbindungen der 2025er-Generation {#on-2025-era-connections} + +`ctx.session.create_message(...)` und `ctx.session.list_roots()` gibt es weiterhin für Code, der die Session direkt ansteuert. Sie funktionieren nur dort, wo ein Rückkanal existiert (nicht zustandslose Verbindungen der 2025er-Generation), und ihr Aufruf löst eine Deprecation-Warnung aus. Die Resolver-Marker oben sind die unterstützte Form: Sie wählen die Zustellung anhand der ausgehandelten Version und warnen nicht. + +## Zusammenfassung {#recap} + +* Gib `Sample(...)` oder `ListRoots()` aus einem Resolver zurück; das Tool erhält das `CreateMessageResult` oder `ListRootsResult` wie jede andere Abhängigkeit. +* Der Client muss die passende Capability deklarieren, sonst schlägt der Aufruf mit `-32021` fehl, statt dass ein Request gesendet wird. +* Beide Features sind bei `2026-07-28` veraltet: vorerst voll funktionsfähig, aber falsch für neue Designs. Bevorzuge Anbieter-APIs gegenüber Sampling und explizite Parameter gegenüber Roots. + +Wie ein langsames Tool seinen Fortschritt meldet: **[Fortschritt](progress.md)**. diff --git a/i18n/de/pages/handlers/subscriptions.md b/i18n/de/pages/handlers/subscriptions.md new file mode 100644 index 0000000000..809f3aca69 --- /dev/null +++ b/i18n/de/pages/handlers/subscriptions.md @@ -0,0 +1,152 @@ +--- +translation: + sections: [60a9de8a0bdaa531, 317bbe7e4355cdcc, a61d660c8029e04a, 8f7e82fcb88df8a9, b165db51249ff8ed, 266f56fb798068a4, 7c0e57030b622139, df18d7c2417a9883] + tool: 1 +--- +# Abonnements {#subscriptions} + +Der Katalog eines Servers ist nicht fest. Tools tauchen zur Laufzeit auf, und der Inhalt hinter einem Ressourcen-URI ändert sich. + +Über **Abonnements** erfährt ein Client davon. Der Client sendet einen einzigen `subscriptions/listen`-Request, und die Response auf diesen Request *ist* der Stream: Er bleibt offen und trägt die Änderungsbenachrichtigungen, die der Client angefordert hat. + +## Aus dem Tool heraus veröffentlichen {#publish-it-from-the-tool} + +Dein Anteil daran ist eine Zeile: Veröffentliche die Änderung. + +```python title="server.py" hl_lines="20 32" +--8<-- "docs_src/subscriptions/tutorial001.py" +``` + +* `await ctx.notify_resource_updated("board://sprint")` erreicht jeden offenen Stream, der diesen URI abonniert hat. Sonst niemanden. +* `await ctx.notify_tools_changed()` erreicht jeden Stream, der Änderungen an der Tool-Liste angefordert hat. Ein Client, der das empfängt, ruft `tools/list` erneut auf und sieht jetzt `sprint_report`. +* Die Geschwister heißen `notify_prompts_changed()` und `notify_resources_changed()`. +* Keine Abonnenten, keine Arbeit. Auf einem untätigen Server zu veröffentlichen ist ein No-op, deshalb prüfst du nie, ob jemand zuhört. Du gibst an, was sich geändert hat. + +`MCPServer` bedient `subscriptions/listen` für dich. Die Pflichten auf der Leitung (die Bestätigung als erster Frame, das Filtern pro Stream, die Abonnement-ID auf jedem Frame) sind Sache des SDK. + +!!! check + Auf der Leitung sieht ein Stream, dessen Filter `board://sprint` nannte, so aus, nachdem `complete_task` gelaufen ist: + + ```json + {"method": "notifications/subscriptions/acknowledged", + "params": {"notifications": {"resourceSubscriptions": ["board://sprint"]}, "_meta": {"io.modelcontextprotocol/subscriptionId": "listen-1"}}} + + {"method": "notifications/resources/updated", + "params": {"uri": "board://sprint", "_meta": {"io.modelcontextprotocol/subscriptionId": "listen-1"}}} + ``` + + Beachte, was das Update *nicht* trägt: das Board. Jeder Frame trägt die JSON-RPC-ID des listen-Requests unter `_meta`, und diese ID ist die Abonnement-ID. Der Client vergibt sie: Der Python-`Client` verwendet Strings wie `"listen-1"`; andere Clients verwenden vielleicht Ganzzahlen. + +## Nur das, was angefordert wurde {#only-what-was-asked-for} + +Der Filter ist ein Vertrag. Ein Stream, der Änderungen an der Tool-Liste und einen Ressourcen-URI angefordert hat, empfängt diese beiden Arten und nichts anderes. Veröffentlichst du eine Prompt-Änderung, bleibt dieser Stream still. + +`MCPServer` vergleicht Ressourcen-URIs als exakte Strings, deshalb hört ein Stream, der `board://sprint` nannte, nichts über `board://sprint/tasks/1`. Die Spezifikation erlaubt einem Server, eine Änderung an einer Unterressource eines abonnierten URI zu melden; `MCPServer` tut das nie, aber Clients sind darauf ausgelegt, damit zu rechnen. + +Zwei Dinge, die der Stream *nicht* ist: + +* **Er ist kein Wiederholungsprotokoll.** Ein abgebrochener Stream ist weg, und Ereignisse, die veröffentlicht wurden, während niemand verbunden war, werden nicht zwischengespeichert. Clients horchen erneut und laden neu. +* **Er ist nicht der Pfad von 2025.** Clients, die `resources/subscribe` aufgerufen haben, werden über `ctx.session.send_resource_updated(uri)` bedient. Die `notify_*`-Methoden erreichen nur `subscriptions/listen`-Streams. + +## Entscheiden, wer zusehen darf {#deciding-who-may-watch} + +Standardmäßig wird jede angeforderte Art und jeder URI akzeptiert: Jeder Aufrufer darf jeden URI beobachten, den du veröffentlichst. Nichts befragt deinen Lese-Handler, weil niemand liest – ein Aufrufer, den dein `files://{name}`-Handler abweisen würde, kann trotzdem einen Stream auf `files://payroll.csv` öffnen und erfahren, dass und wann sich die Datei geändert hat. Er erfährt nie Inhalte, und er kann nicht ertasten, was existiert, denn ein unbekannter URI wird ebenfalls akzeptiert und feuert schlicht nie. Schmal, aber real – sichere es also ab, bevor du personenbezogene URIs von einem mandantenfähigen Server veröffentlichst. + +Die Absicherung ist eine Middleware. Sie sieht den `subscriptions/listen`-Request, bevor das SDK ihn bestätigt, und lehnt ab, wenn der Aufrufer etwas anfordert, das er nicht lesen darf: + +```python title="server.py" hl_lines="19-26 29" +--8<-- "docs_src/subscriptions/tutorial006.py" +``` + +* `ctx.params` ist der rohe Request, deshalb validiert die Middleware ihn selbst zu `SubscriptionsListenRequestParams` und liest den Filter, den der Client angefordert hat. +* Eine Ablehnung ist ein ausgelöster `MCPError` vor `call_next(ctx)`: Der Client bekommt diesen Fehler und keinen Stream, und die Verbindung läuft weiter. Halte die Meldung einheitlich und nenne keinen URI, damit eine Ablehnung nie bestätigt, welche URIs geschützt sind. +* Ein einziges `can_access(user, uri)` beantwortet beide Fragen. Der Ressourcen-Handler fragt es bei `resources/read`; die Middleware fragt es bei `subscriptions/listen`. Tausche die Tabelle gegen eine Datenbank oder dein RBAC-System aus, und beide bleiben im Gleichschritt. +* Die Entscheidung gilt für die Lebensdauer des Streams. Es gibt keine erneute Prüfung pro Ereignis. Kann der Zugriff eines Aufrufers also mitten im Stream erlöschen (ein ablaufendes Token), beende die Verbindung dieses Aufrufers, sobald das geschieht. + +Der vollständige Middleware-Vertrag, einschließlich dessen, was sie sonst noch umschließt und warum sie als vorläufig markiert ist, steht auf **[Middleware](../advanced/middleware.md)**. + +## Die Client-Seite {#the-client-end} + +Hier ist ein Client auf der anderen Seite dieses Streams, der dem Board folgt: + +```python title="client.py" hl_lines="15" +--8<-- "docs_src/subscriptions/tutorial003.py" +``` + +Beim Betreten von `client.listen(...)` wird der Request gesendet und auf deine Bestätigung gewartet, sodass der Stream aktiv ist, wenn der Block beginnt, und jedes typisierte Ereignis ist ein Signal zum Neuladen, nie eine Payload. Das ist der ganze Vertrag auf einem Bildschirm. Alles andere zur Client-Seite steht auf einer eigenen Seite: neben einem Hauptablauf beobachten, Stream-Enden und erneutes Horchen. Siehe **[Abonnements](../client/subscriptions.md)** unter *Clients*. + +## Über einen Prozess hinaus skalieren {#scaling-past-one-process} + +Veröffentlichungen wandern von deinem Handler über einen `SubscriptionBus` zu den offenen Streams. Der Standard arbeitet im Speicher: ein Prozess, jeder Stream darin. Das ist die richtige Antwort, bis du Replikate hinter einem Load Balancer betreibst, denn dann ist der Stream eines Clients an ein Replikat gebunden, und eine Veröffentlichung auf einem anderen Replikat muss ihn erreichen. + +Diese Nahtstelle implementierst du selbst: zwei Methoden über deinem Pub/Sub-Backend. + +```python +from collections.abc import Callable + +from redis.asyncio import Redis + +from mcp.server.mcpserver import MCPServer +from mcp.server.subscriptions import ServerEvent # SubscriptionBus is a Protocol: no base class + + +class RedisSubscriptionBus: + def __init__(self, redis: Redis) -> None: + self._redis = redis + self._listeners: dict[object, Callable[[ServerEvent], None]] = {} + + async def publish(self, event: ServerEvent) -> None: + await self._redis.publish("mcp-events", encode(event)) # to every replica + + def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]: + token = object() + self._listeners[token] = listener + + def unsubscribe() -> None: + self._listeners.pop(token, None) + + return unsubscribe + + +mcp = MCPServer("Sprint Board", subscriptions=RedisSubscriptionBus(redis)) +``` + +`encode` gehört dir, ebenso der Lese-Task auf jedem Replikat, der eintreffende Nachrichten dekodiert und jeden registrierten Listener aufruft. Listener sind synchron, dürfen keine Exception auslösen und laufen auf der Event-Loop des Servers. + +Der Bus trägt typisierte `ServerEvent`-Werte, vier kleine Dataclasses, nie JSON-RPC. Stempeln, Filtern und Stream-Lebenszyklen bleiben im SDK, sodass eine Bus-Implementierung das Protokoll nicht brechen kann. Sie kann nur Ereignisse zwischen Prozessen bewegen. + +Um außerhalb eines Requests zu veröffentlichen, erzeuge den Bus selbst, damit du die Referenz hältst. `MCPServer` baut intern einen, wenn du nichts übergibst, und legt ihn nicht offen. + +```python +from mcp.server.subscriptions import InMemorySubscriptionBus, ToolsListChanged + +bus = InMemorySubscriptionBus() +mcp = MCPServer("Sprint Board", subscriptions=bus) + + +async def tools_reloaded() -> None: + await bus.publish(ToolsListChanged()) # from a lifespan task, a webhook, anywhere +``` + +## Die Low-Level-Komposition {#the-low-level-composition} + +Unten auf dem Low-Level-`Server` ist nichts vorverdrahtet, und dieselben Teile setzen sich in drei Zeilen zusammen: + +```python title="server.py" hl_lines="8-9 47" +--8<-- "docs_src/subscriptions/tutorial002.py" +``` + +* Der Bus gehört dir, also veröffentlichst du direkt darauf: `await bus.publish(ResourceUpdated(uri=...))`. Lege ihn dorthin, wo deine Handler ihn erreichen: hier auf Modulebene, in einer größeren App im Lifespan. +* `ListenHandler(bus)` ist derselbe Handler, den `MCPServer` registriert, und `on_subscriptions_listen=` ist ein gewöhnlicher Handler-Slot. Setze dein eigenes Callable in diesen Slot für eine andere Semantik, und die Pflichten aus der Spezifikation gehen auf dich über: zuerst bestätigen, jeden Frame mit der Abonnement-ID stempeln, nichts außerhalb des Filters ausliefern. +* `ListenHandler.close()` beendet jeden offenen Stream geordnet. Jeder empfängt das Ergebnis des listen-Requests als letzten Frame – so sagt die Spezifikation, dass der Server das Abonnement absichtlich beendet hat. Die Methode kehrt zurück, bevor diese Streams fertig geleert sind, gib ihnen also einen Moment, bevor du den Transport abbaust. Ohne sie enden Streams, wenn der Client die Verbindung trennt. + +## Zusammenfassung {#recap} + +* Ein Client steigt mit einem einzigen `subscriptions/listen`-Request ein, und die Response ist der Stream. Ihn zu bedienen ist eingebaut. +* Du veröffentlichst mit `ctx.notify_*`, und das SDK übernimmt Stempeln, Filtern und die Lebenszyklus-Arbeit. +* Ereignisse sind Signale, keine Payloads. Beide Seiten laden neu. +* Die Client-Seite ist `async with client.listen(...)`: Alles Weitere steht in **[Abonnements](../client/subscriptions.md)** unter *Clients*. +* Auf dem Low-Level-`Server` setzt du dieselben Teile selbst zusammen: einen Bus, `ListenHandler(bus)`, den Slot `on_subscriptions_listen`. +* Horizontal skalieren heißt, `SubscriptionBus` zu implementieren, zwei Methoden, und ihn als `MCPServer(subscriptions=...)` zu übergeben. + +Den Server zu betreiben, der all das bedient, hinter einem Replikat oder zwanzig, ist **[Bereitstellen und skalieren](../run/deploy.md)**. diff --git a/i18n/de/pages/index.md b/i18n/de/pages/index.md new file mode 100644 index 0000000000..744c99c40e --- /dev/null +++ b/i18n/de/pages/index.md @@ -0,0 +1,102 @@ +--- +translation: + sections: [154c4309937b9f85, 3ad8fc6caa76a9b0, a07f3f5b151ab746, bf6e476b712930c0, cf0b1f13978c6623] + tool: 1 +--- +# MCP Python SDK {#mcp-python-sdk} + +!!! info "Diese Dokumentation beschreibt v2, die aktuelle stabile Release-Linie" + Neu bei v2 oder kommst du von v1? **[Neu in v2](whats-new.md)** ist die Fünf-Minuten-Tour durch alle Änderungen, und der **[Migrationsleitfaden](migration.md)** behandelt jeden Breaking Change. + Noch auf v1.x? Die Dokumentation dazu findest du in den [v1.x-Docs](https://py.sdk.modelcontextprotocol.io/v1/). + Etwas hakt oder ist unklar? [Sag uns Bescheid](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml). + +Mit dem **Model Context Protocol (MCP)** können Anwendungen LLMs auf standardisierte Weise Kontext bereitstellen. Dabei wird das *Bereitstellen* von Kontext von der eigentlichen Interaktion mit dem LLM getrennt. + +Dies ist das offizielle Python SDK dafür. Damit kannst du: + +* **MCP-Server bauen**, die jedem MCP-Host Tools, Ressourcen und Prompts anbieten. +* **MCP-Clients bauen**, die sich mit jedem MCP-Server verbinden. +* Jeden Standard-Transport sprechen: stdio, Streamable HTTP und SSE. + +## Voraussetzungen {#requirements} + +Python 3.10+. + +## Installation {#installation} + +=== "uv" + + ```bash + uv add "mcp[cli]" + ``` + +=== "pip" + + ```bash + pip install "mcp[cli]" + ``` + +Das Extra `[cli]` bringt den Befehl `mcp` mit; den brauchst du für die Entwicklung. +Wofür die einzelnen Abhängigkeiten da sind, steht unter [Installation](get-started/installation.md). + +## Beispiel {#example} + +### Erstellen {#create-it} + +Lege eine Datei `server.py` an: + +```python title="server.py" +--8<-- "docs_src/index/tutorial001.py" +``` + +Das ist ein vollständiger MCP-Server. + +Er bietet ein **Tool** an, `add`, und eine **Ressource** mit Template, `greeting://{name}`. + +### Starten {#run-it} + +```console +uv run mcp dev server.py +``` + +Das startet deinen Server und öffnet den [MCP Inspector](https://github.com/modelcontextprotocol/inspector), eine interaktive Oberfläche, mit der du ihn erkunden kannst. Öffne die URL, die er ausgibt. + +!!! note + Der Inspector ist eine Node.js-App, deshalb braucht `mcp dev` `npx` auf deinem `PATH`. + +### Ausprobieren {#try-it} + +Gehe im Inspector zu **Tools** und rufe `add` mit `a=1`, `b=2` auf. + +Du bekommst `3` zurück. ✨ + +Dieses Formular (ein Pflichtfeld vom Typ Integer für `a`, ein weiteres für `b`) hat der Inspector aus deinen Type Hints gebaut. Claude macht das genauso, und jeder andere MCP-Host auch. + +Gehe jetzt zu **Resources** und lies `greeting://World`: + +```text +Hello, World! +``` + +### Zusammenfassung {#recap} + +Sieh dir noch einmal an, was du **nicht** geschrieben hast: + +* Kein JSON Schema. `a: int, b: int` *ist* das Schema. +* Kein Parsen von Requests, keine Serialisierung, kein Validierungscode. +* Keinerlei Protokollbehandlung. + +Du hast zwei Python-Funktionen mit Type Hints und einem Docstring geschrieben. Den Rest erledigt das SDK. + +## Wie es weitergeht {#where-to-go-next} + +* **[Einstieg](get-started/index.md)** führt dich von der Installation zu einem funktionierenden, getesteten Server. +* Du baust eine Anwendung, die MCP-Server *nutzt*? Beginne mit **[Clients](client/index.md)**. +* Du hast schon eine FastAPI- oder Starlette-App? **[In eine bestehende App einbinden](run/asgi.md)** hängt einen MCP-Server darin ein. +* Du suchst eine bestimmte Fehlermeldung? **[Fehlerbehebung](troubleshooting.md)** ist nach dem wörtlichen Text geordnet. +* Du fragst dich, was sich in v2 geändert hat? **[Neu in v2](whats-new.md)** ist die Fünf-Minuten-Tour. +* Du migrierst von v1? Beginne mit dem **[Migrationsleitfaden](migration.md)**. +* Du suchst eine genaue Signatur? Die **[API-Referenz](api/mcp/index.md)** wird aus dem Quellcode generiert. +* Du liest mit einem LLM? Diese Dokumentation wird auch im Format [llms.txt](https://llmstxt.org/) veröffentlicht: + [llms.txt](https://py.sdk.modelcontextprotocol.io/llms.txt) ist ein Index der Seiten, und + [llms-full.txt](https://py.sdk.modelcontextprotocol.io/llms-full.txt) enthält alle Seiten in einer einzigen Datei. diff --git a/i18n/de/pages/protocol-versions.md b/i18n/de/pages/protocol-versions.md new file mode 100644 index 0000000000..887ed4abba --- /dev/null +++ b/i18n/de/pages/protocol-versions.md @@ -0,0 +1,132 @@ +--- +translation: + sections: [478fd619e5f90ef8, aef094a00e44e248, bab8cbf3449fa7e9, df1809b15a58335b, 5f9d8c2336ed0239, f54974398e43ddef, b24443dd78584870] + tool: 1 +--- +# Protokollversionen {#protocol-versions} + +MCP hat zwei Generationen. + +Server, die vor 2026-07-28 veröffentlicht wurden, eröffnen jede Verbindung mit dem **`initialize`-Handshake**: Der Client schlägt eine Version vor, der Server hält dagegen, der Client bestätigt – alles vor dem ersten nützlichen Request. Server auf Stand **2026-07-28** lassen den Handshake weg. Der Client sendet eine einzige **`server/discover`**-Sondierung, und der Server beantwortet sie mit allem in einem einzigen Ergebnis. + +Darum musst du dich fast nie kümmern, denn `Client` handelt das für dich aus. Diese Seite behandelt das eine Konstruktorargument, das es steuert, `mode=`, und die drei Fälle, in denen du es änderst. + +## `mode="auto"` {#modeauto} + +```python title="client.py" hl_lines="14-15" +--8<-- "docs_src/protocol_versions/tutorial001.py" +``` + +Du hast `mode` nicht übergeben, also bekommst du den Standardwert: `"auto"`. Beim Eintritt in `async with` geht eine einzige `server/discover`-Sondierung in der neuesten Version raus, die dieses SDK spricht. Dann: + +* Ein **moderner Server** beantwortet sie. Der Client übernimmt das Ergebnis. Ein Roundtrip, fertig. +* Ein **älterer Server** hat noch nie von `server/discover` gehört und gibt einen Fehler zurück. Der Client fällt auf den klassischen `initialize`-Handshake zurück und nimmt, was dieser aushandelt. + +So oder so bist du am Ende verbunden, und `client.protocol_version` sagt dir, welcher Weg es war: + +```text +2026-07-28 +``` + +Das ist das ganze Feature. Ein `Client`, Server jeder Generation, keine Verzweigung in deinem Code. + +!!! info + `MCPServer` beantwortet `server/discover` auf jedem Transport – In-Memory, stdio, Streamable + HTTP –, sodass `auto` gegen deinen eigenen Server immer bei `2026-07-28` landet. Der Fallback + greift nur gegen einen echten Server von vor 2026, und genau dann willst du ihn auch. + +## `mode="legacy"` {#modelegacy} + +```python title="client.py" hl_lines="14" +--8<-- "docs_src/protocol_versions/tutorial002.py" +``` + +`mode="legacy"` sondiert nie. Es führt den `initialize`-Handshake aus – dieselbe Verbindung, die ein Client von vor 2026 öffnet. + +```text +2025-11-25 +``` + +Derselbe Server. Er spricht `2026-07-28` einwandfrei; du hast dem Client gesagt, nicht danach zu fragen. + +Das willst du für die **Push-Features**. + +Ein vom Server initiierter Request bedeutet, dass der Server *dich* aufruft: `ctx.elicit(...)` legt der Person am Host ein Formular vor, Sampling bittet dein Modell mitten in einem Tool-Aufruf um eine Completion. Diesen Kanal gibt es nur in einer Session der Handshake-Generation. + +Bei 2026-07-28 ist er weg. Der Server *gibt* seine Fragen *zurück*, und du wiederholst den Aufruf mit den Antworten – siehe **[Multi-Roundtrip-Requests](handlers/multi-round-trip.md)** (multi-round-trip requests). + +`mode="auto"` gibt dir nur dann einen Handshake, wenn der Server für alles andere zu alt ist. `mode="legacy"` garantiert einen. Greif dazu, wann immer du `Client(...)` einen `sampling_callback`, einen `elicitation_callback`, der als Request ausgelöst werden soll, oder einen `message_handler` übergibst. **[Client-Callbacks](client/callbacks.md)** geht jeden davon durch. + +## Eine Version festschreiben {#pinning-a-version} + +`mode` akzeptiert auch den String einer modernen Protokollversion. Heute ist diese Menge genau `["2026-07-28"]`. + +```python title="client.py" hl_lines="14" +--8<-- "docs_src/protocol_versions/tutorial003.py" +``` + +Eine festgeschriebene Version sendet **nichts**. Keine Sondierung, kein Handshake. Der Client übernimmt `2026-07-28` lokal, und die Verbindung steht in dem Moment, in dem `async with` zurückkehrt. + +Eine festgeschriebene Version ist ein Versprechen, das *du* gibst: Du weißt bereits, dass der Server diese Version spricht. Der Client prüft das nicht. + +!!! check + Festschreiben ist keine Erkennung. Gib `client.server_info` aus, und der Preis steht direkt da: + + ```text + None + ``` + + Der Client hat den Server nie gefragt, wer er ist, also ist `server_info` `None`. Bei `client.server_capabilities` + dasselbe: Jede Capability ist `None`. Tool-Aufrufe funktionieren weiterhin (das Protokoll braucht nichts davon); + Code, der `server_capabilities` liest, um zu entscheiden, was er anbietet, funktioniert nicht. + + Der nächste Abschnitt ist die Lösung. + +Nur moderne Versionen lassen sich festschreiben. Ein String der Handshake-Generation wird schon beim Konstruieren abgelehnt, vor jedem I/O, und der Fehler sagt dir, was du stattdessen schreiben sollst: + +```text +ValueError: mode must be 'legacy', 'auto', or one of ['2026-07-28']; got '2025-06-18' ('2025-06-18' is a handshake-era version; use mode='legacy') +``` + +## Mit `prior_discover` neu verbinden {#reconnecting-with-prior_discover} + +Die Sondierung ist billig, aber sie bleibt ein Roundtrip, den du bei jedem Neuverbinden bezahlst, und die Antwort ändert sich fast nie. + +Also heb sie auf. Nach einer `auto`-Verbindung enthält `client.session.discover_result` genau das `DiscoverResult`, das der Server gesendet hat: seine `supported_versions`, seine `capabilities`, seine `instructions` und die Identität, die der Server in das `_meta` des Ergebnisses gestempelt hat. Gib es beim nächsten Mal als `prior_discover=` zurück: + +```python title="client.py" hl_lines="15 17" +--8<-- "docs_src/protocol_versions/tutorial004.py" +``` + +```text +2026-07-28 +Bookshop +``` + +Die zweite Verbindung hat **keinen einzigen** Roundtrip für die Aushandlung gebraucht und weiß trotzdem genau, mit wem sie spricht. Das ist der festgeschriebene Modus, richtig gemacht: `mode=` nennt die Version, `prior_discover=` liefert die Identität. ✨ + +`DiscoverResult` ist ein Pydantic-Modell. `saved.model_dump_json()` wandert in eine Datei oder einen Cache; `DiscoverResult.model_validate_json(...)` holt es im nächsten Prozess zurück. + +!!! tip + `prior_discover=` bewirkt nur dann etwas, wenn `mode` eine festgeschriebene Version ist. Unter `"auto"` + sondiert der Client den Server ohnehin, und unter `"legacy"` wird es ignoriert. + +## Die vier Modi {#the-four-modes} + +| Du schreibst | Traffic für die Aushandlung | Du bekommst | +| --- | --- | --- | +| `Client(target)` | eine `server/discover`-Sondierung; der `initialize`-Handshake, falls sie fehlschlägt | die neueste Version, die beide Seiten sprechen, egal welcher Generation | +| `Client(target, mode="legacy")` | der `initialize`-Handshake | eine Version der Handshake-Generation; vom Server initiierte Requests funktionieren | +| `Client(target, mode="2026-07-28")` | keiner | diese Version, festgeschrieben, mit `server_info` als `None` | +| `Client(target, mode="2026-07-28", prior_discover=saved)` | keiner | diese Version, festgeschrieben, *und* die Identität, die du letztes Mal gespeichert hast | + +## Zusammenfassung {#recap} + +* MCP hat eine Handshake-Generation (bis `2025-11-25`, der `initialize`-Handshake) und eine moderne Generation (`2026-07-28`, `server/discover`). `Client` überbrückt beide. +* `mode="auto"` ist der Standardwert: sondieren, zurückfallen. Lass es in Ruhe, es sei denn, eine der anderen drei Zeilen beschreibt dich. +* `client.protocol_version` ist immer die Antwort auf „Was habe ich bekommen?“. +* `mode="legacy"` erzwingt den Handshake. Das brauchst du für vom Server initiierte Requests: Sampling, Push-Elicitation (Rückfrage bei der Person am Host), `message_handler`. +* Eine festgeschriebene Version (`mode="2026-07-28"`) sendet überhaupt keinen Traffic für die Aushandlung – um den Preis, dass `client.server_info` `None` ist. +* `prior_discover=` gleicht diesen Preis wieder aus: Speichere `client.session.discover_result`, verbinde dich damit neu, bekomm beides. + +Eine moderne Verbindung hat keinen Push-Kanal – wie also stellt dir ein 2026er-Server mitten im Aufruf eine Frage? Er gibt sie zurück: **[Multi-Roundtrip-Requests](handlers/multi-round-trip.md)**. diff --git a/i18n/de/pages/run/asgi.md b/i18n/de/pages/run/asgi.md new file mode 100644 index 0000000000..23750a5c7d --- /dev/null +++ b/i18n/de/pages/run/asgi.md @@ -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. diff --git a/i18n/de/pages/run/authorization.md b/i18n/de/pages/run/authorization.md new file mode 100644 index 0000000000..3cd5d35a4c --- /dev/null +++ b/i18n/de/pages/run/authorization.md @@ -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 ` 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)**. diff --git a/i18n/de/pages/run/deploy.md b/i18n/de/pages/run/deploy.md new file mode 100644 index 0000000000..ffb06c0447 --- /dev/null +++ b/i18n/de/pages/run/deploy.md @@ -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:`, `localhost:` oder `[::1]:` 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)**. diff --git a/i18n/de/pages/run/index.md b/i18n/de/pages/run/index.md new file mode 100644 index 0000000000..53969cf207 --- /dev/null +++ b/i18n/de/pages/run/index.md @@ -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. diff --git a/i18n/de/pages/run/legacy-clients.md b/i18n/de/pages/run/legacy-clients.md new file mode 100644 index 0000000000..63fdbca6b4 --- /dev/null +++ b/i18n/de/pages/run/legacy-clients.md @@ -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. diff --git a/i18n/de/pages/run/opentelemetry.md b/i18n/de/pages/run/opentelemetry.md new file mode 100644 index 0000000000..56e3f4b95b --- /dev/null +++ b/i18n/de/pages/run/opentelemetry.md @@ -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)**. diff --git a/i18n/de/pages/servers/completions.md b/i18n/de/pages/servers/completions.md new file mode 100644 index 0000000000..0d04bfdbb5 --- /dev/null +++ b/i18n/de/pages/servers/completions.md @@ -0,0 +1,130 @@ +--- +translation: + sections: [72f9c964769076dd, 9a2c14e10935b515, 235299eb78ab12d7, 8aee1e78c8237fb8, 9bd86acd4112138f, 55343cb7f250dc7b] + tool: 1 +--- +# Vervollständigungen {#completions} + +Ein Client, der eine UI auf deinem Server aufbaut, möchte Argumentwerte automatisch vervollständigen, während die Person tippt: Sprachnamen, Repository-Namen, Dateipfade. + +Mit **Vervollständigungen** liefert dein Server diese Vorschläge. + +## Etwas zum Vervollständigen {#something-worth-completing} + +Vervollständigungen gibt es für genau zwei Dinge: die Argumente eines **Prompts** und die Parameter eines **Ressourcen-Templates**. Beginne also mit einem Server, der von beidem eines hat: + +```python title="server.py" hl_lines="6 12" +--8<-- "docs_src/completions/tutorial001.py" +``` + +Noch hat hier nichts mit Vervollständigungen zu tun. + +* `review_code` nimmt eine `language` entgegen. Niemand sollte raten müssen, welche Schreibweisen du akzeptierst. +* `github_repo` nimmt einen `owner` und ein `repo` entgegen. Freitextfelder für beide ergeben ein schlechtes Formular. + +## Der Vervollständigungs-Handler {#the-completion-handler} + +Füge **eine** mit `@mcp.completion()` dekorierte Funktion hinzu: + +```python title="server.py" hl_lines="21-29" +--8<-- "docs_src/completions/tutorial002.py" +``` + +* Es gibt einen Handler pro Server. Jeder Vervollständigungs-Request landet hier, und du verzweigst danach, was gerade vervollständigt wird. +* Er muss mit `async def` definiert sein: Das SDK wartet per await auf ihn. +* Er erhält drei Argumente: + * `ref`: um *welchen* Prompt oder welches Ressourcen-Template es geht, als `PromptReference` oder `ResourceTemplateReference`. Mit `isinstance` unterscheidest du die beiden. + * `argument`: `argument.name` ist das Argument, das vervollständigt wird, `argument.value` das, was die Person bisher getippt hat. + * `context`: die bereits aufgelösten Argumente. Ignoriere es vorerst. +* Du gibst eine `Completion(values=[...])` zurück, oder `None`, wenn du nichts anzubieten hast. + +!!! tip + `argument.value` ist das Präfix, das die Person getippt hat. Das SDK filtert **nicht** für dich: Was + immer du in `values` packst, zeigt die UI an. Das `startswith` schreibst du selbst. + +### Ausprobieren {#try-it} + +Steuere ihn mit dem In-Memory-`Client` aus **[Testen](../get-started/testing.md)** an. Rufe +`client.complete()` mit `ref=PromptReference(name="review_code")` und +`argument={"name": "language", "value": "py"}` auf: + +```python +result.completion.values # ['python'] +``` + +* `ref` ist derselbe Referenztyp, den dein Handler erhält. +* `argument` ist ein einfaches dict mit genau zwei Schlüsseln, `name` und `value`. + +Schickst du ein leeres `value`, bekommst du die ganze Liste zurück. `lang.startswith("")` ist für jede Sprache wahr: + +```python +result.completion.values # ['go', 'javascript', 'python', 'rust', 'typescript'] +``` + +Fragst du nach `code` (einem Argument, das dein Handler nicht kennt), gibt er `None` zurück, was das SDK in eine leere Liste verwandelt: + +```python +result.completion.values # [] +``` + +`None` bedeutet *„keine Vorschläge“*, nie einen Fehler. Eine UI fällt auf ein einfaches Textfeld zurück. + +## Eine Capability, die du nie deklariert hast {#a-capability-you-never-declared} + +Den Handler zu registrieren ist die Deklaration. Verbinde einen Client und sieh nach: + +```python +client.server_capabilities.completions # CompletionsCapability() +``` + +Du hast `completions` nirgends aufgeführt. Das SDK hat den Handler gesehen und die Capability für dich deklariert. Jede *optionale* Capability funktioniert so: Der Handler ist die Deklaration. (Die drei Primitive sind nicht optional: `MCPServer` deklariert sie immer, mit oder ohne Handler.) + +!!! check + Geh zurück zur ersten `server.py` (der ohne Handler) und frage trotzdem. Der Aufruf schlägt + mit einem JSON-RPC-Fehler fehl: + + ```text + Method not found + ``` + + Und `client.server_capabilities.completions` ist `None`. Genau dafür ist die Capability da: Ein + Client, der sich korrekt verhält, prüft sie und schickt den Request, den du nicht beantworten kannst, gar nicht erst. + +## Abhängige Argumente {#dependent-arguments} + +`github://repos/{owner}/{repo}` hat zwei Parameter, und die sinnvollen Werte für `repo` hängen davon ab, welcher `owner` zuerst gewählt wurde. + +Dafür ist `context` da. Es trägt die Argumente, die die Person **bereits aufgelöst** hat: + +```python title="server.py" hl_lines="8-11 34-38" +--8<-- "docs_src/completions/tutorial003.py" +``` + +* Der neue Zweig greift beim Parameter `repo` des Templates. +* `context.arguments` ist ein `dict[str, str] | None` mit den bisher gewählten Werten (hier `owner`). +* Noch kein `owner` bedeutet keine sinnvollen Vorschläge, also gibt der Handler `None` zurück. + +Der Client schickt diese aufgelösten Werte mit `context_arguments=`. Diesmal ist `ref` eine +`ResourceTemplateReference(uri="github://repos/{owner}/{repo}")`. Frage mit leerem +`value` nach `repo` und übergib `context_arguments={"owner": "modelcontextprotocol"}`: + +```python +result.completion.values # ['python-sdk', 'typescript-sdk', 'inspector'] +``` + +Lässt du `context_arguments=` weg, gibt derselbe Aufruf `[]` zurück. Der Handler kann nicht wissen, welche Repos er anbieten soll, solange er den Owner nicht kennt. + +!!! info + `Completion` nimmt außerdem `total=` und `has_more=` entgegen. Setze sie, wenn `values` ein Ausschnitt einer + längeren Liste ist, damit eine UI *„und 200 weitere“* anzeigen kann. Die meisten Handler brauchen sie nie. + +## Zusammenfassung {#recap} + +* Vervollständigungen sind Vorschläge für **Prompt-Argumente** und **Parameter von Ressourcen-Templates**. Sonst nichts. +* `@mcp.completion()` registriert den einen Handler. Er ist `async def (ref, argument, context) -> Completion | None`. +* Verzweige nach `isinstance(ref, ...)` und nach `argument.name`. Filtere selbst nach `argument.value`. +* `None` wird zu einer leeren Liste. Es ist nie ein Fehler. +* `context.arguments` enthält die bereits aufgelösten Werte; der Client liefert sie als `context_arguments=`. +* Die Capability `completions` erscheint, sobald du den Handler registrierst. Ohne ihn endet der Request mit `Method not found`. + +Vorschläge helfen, solange die Person einen Prompt oder ein Template noch *ausfüllt*; um ihr *mitten* in einem Tool-Aufruf eine Frage zu stellen, brauchst du **[Elicitation](../handlers/elicitation.md)** (Rückfrage bei der Person am Host). Alles, was ein Tool außer Text zurückgeben kann, steht in **[Bilder, Audio und Icons](media.md)**. diff --git a/i18n/de/pages/servers/handling-errors.md b/i18n/de/pages/servers/handling-errors.md new file mode 100644 index 0000000000..31e1c988da --- /dev/null +++ b/i18n/de/pages/servers/handling-errors.md @@ -0,0 +1,139 @@ +--- +translation: + sections: [e33d441f12d50535, 7099694c603e0f5f, c1df4cf9673433e6, c9cd294541422e6e, 6cec073617bfd037, efa92b8f99e908c8, 6a22a29e27fb4601] + tool: 1 +--- +# Fehler behandeln {#handling-errors} + +Ein Tool kann auf zwei Arten scheitern, und das SDK behandelt sie sehr unterschiedlich. + +Löse eine gewöhnliche Exception aus, und das **Modell** sieht sie. Löse `MCPError` aus, und das **Protokoll** sieht sie. + +Auf dieser Seite geht es um die Wahl zwischen beiden. + +## Ein Fehler, den das Modell beheben kann {#an-error-the-model-can-fix} + +Nimm ein Tool, das etwas nachschlägt, und lass das Nachschlagen ins Leere laufen: + +```python title="server.py" hl_lines="11-12" +--8<-- "docs_src/handling_errors/tutorial001.py" +``` + +An diesen zwei Zeilen ist nichts MCP-Spezifisches. `get_author` löst einen schlichten `ValueError` aus, so wie es jede Python-Funktion täte. + +Ruf es mit einem Titel auf, der nicht im Katalog steht, und sieh dir das Ergebnis an: + +```python +result.is_error # True +result.content # [TextContent(text="Error executing tool get_author: No book titled 'Nothing' in the catalog.")] +result.structured_content # None +``` + +* Der Request war **erfolgreich**. Es gibt ein Ergebnis; beim Aufrufer wurde nichts ausgelöst. +* `is_error` ist `True`, und die Meldung deiner Exception (mit dem Tool-Namen als Präfix) steht in `content` – genau dort, wo das Modell liest. +* `structured_content` ist `None`. Ein fehlgeschlagener Aufruf hat keinen Rückgabewert, den man strukturieren könnte. + +Das ist ein **Tool-Fehler**, und er ist der Standard für *jede* Exception, die dein Tool auslöst. Fast immer ist es auch genau das, was du willst. + +Das Modell ist es, das dein Tool aufruft. Es hat die Argumente gewählt. Ein Tool-Fehler ist also ein Zug im Gespräch: Das Modell liest *„No book titled 'Nothing' in the catalog.“*, merkt, dass es den Titel falsch geraten hat, und ruft erneut mit einem besseren auf. Du hast ein einziges `raise` geschrieben und einen sich selbst korrigierenden Agenten bekommen. + +!!! tip + Gib aus einem Tool nie eine Fehlermeldung per `return` zurück. Ein zurückgegebener String hat `is_error=False`; + für das Modell (und für jede Client-UI) sieht es also aus, als hätte das Tool funktioniert und dieser String + wäre die Antwort. `raise`. Das Flag ist das Signal. + +## Ein Fehler, den das Modell nicht beheben kann {#an-error-the-model-cannot-fix} + +Tausche jetzt `ValueError` gegen `MCPError`. + +```python title="server.py" hl_lines="1 3 14" +--8<-- "docs_src/handling_errors/tutorial002.py" +``` + +`MCPError` ist der **Protokollfehler** des SDK. Es ist die eine Exception, die der Tool-Wrapper *nicht* abfängt: Sie wird weitergereicht, und der ganze `tools/call`-Request schlägt mit einem JSON-RPC-Fehler fehl statt mit einem Ergebnis zu enden. + +```json +{ + "code": -32602, + "message": "No book titled 'Nothing' in the catalog." +} +``` + +* Es gibt **kein Ergebnis**. Kein `content`, kein `is_error`: nichts, was das Modell lesen könnte. +* Stattdessen bekommt die **Host**-Anwendung den Fehler – genauso, als gäbe es das Tool gar nicht. +* `code`, `message` und `data` kommen unverändert an. `INVALID_PARAMS` ist `-32602`; `mcp.types` exportiert ihn und die anderen JSON-RPC-Fehlercodes (`INVALID_REQUEST`, `INTERNAL_ERROR`, ...) als Konstanten, sodass du nie eine magische Zahl tippen musst. + +!!! check + Dasselbe Nachschlagen, derselbe Fehlschlag, aber jetzt *löst* der Aufruf auf der Client-Seite eine Exception *aus*, statt zurückzukehren: + + ```text + mcp.shared.exceptions.MCPError: No book titled 'Nothing' in the catalog. + ``` + + Die erste Version gab dem Modell einen Satz, auf den es reagieren konnte. Diese hier gibt ihm nichts. + Für `get_author` ist das eindeutig schlechter – und genau darum geht es im nächsten Abschnitt. + +## Welche der beiden auslösen {#which-one-to-raise} + +Die beiden Wege beantworten zwei verschiedene Fragen. + +* **Löse irgendeine Exception aus** bei einem Fehlschlag der *Ausführung*: Das, was dein Tool versucht hat, hat nicht geklappt. Das Modell hat den Aufruf gewählt, also sollte das Modell die Folge sehen und die Chance bekommen, sich zu fangen. Ein falsch geschriebener Titel, eine vorgelagerte API mit Timeout, eine Zeile, die es nicht gibt: alles Tool-Fehler. +* **Löse `MCPError` aus**, wenn der *Request selbst* abgelehnt werden soll: Dem Client fehlt eine Capability, auf die dein Tool angewiesen ist, der Server ist nicht in einem Zustand, irgendwen zu bedienen, der Aufrufer hat einen erforderlichen Schritt übersprungen. Kein erneuter Versuch des Modells behebt irgendetwas davon, also bringt es nichts, ihm die Meldung zu geben. + +Eine Frage entscheidet: **Hätte ein klügeres Modell das vermeiden können?** Ja -> gewöhnliche Exception. Nein -> `MCPError`. + +Nach diesem Test hat die zweite Version von `get_author` die falsche Wahl getroffen: Ein besserer Titel behebt das Problem, also hätte das Modell die Meldung sehen sollen. Sie soll dir den Mechanismus zeigen, nicht ihn empfehlen. + +!!! info + `MCPError` findest du unter `from mcp import MCPError`; sie nimmt `code`, `message` und eine optionale + `data`-Payload entgegen. Was immer du hineinlegst, bekommt der Client: Das SDK leitet eine ausgelöste + `MCPError` wortwörtlich weiter, statt sie zu bereinigen. + +## Eine Ressource, die es nicht gibt {#a-resource-that-doesnt-exist} + +Ressourcen ziehen dieselbe Grenze und bringen für den häufigen Fall eine benannte Exception mit. + +```python title="server.py" hl_lines="2 13" +--8<-- "docs_src/handling_errors/tutorial003.py" +``` + +`books://{title}` ist ein **Template**. Es passt auf *jeden* Titel, also sind „der URI ist wohlgeformt“ und „das Buch existiert“ zwei verschiedene Fragen, und nur deine Funktion kann die zweite beantworten. + +Wenn sie das nicht kann, löse `ResourceNotFoundError` aus. Das SDK macht daraus den Protokollfehler, den die Spezifikation einer fehlenden Ressource zuordnet: `-32602` mit dem angeforderten URI in `data`, damit der Client weiß, *welcher* Lesevorgang fehlgeschlagen ist. + +```json +{ + "code": -32602, + "message": "No book titled 'Nothing' in the catalog.", + "data": {"uri": "books://Nothing"} +} +``` + +Beachte, dass es hier kein halbes Ergebnis mit `is_error=True` gibt. Das Lesen einer Ressource liefert entweder Inhalte oder schlägt fehl: Ressourcen haben nur den Protokollweg. Templates und alles Weitere zu Ressourcen stehen in **[Ressourcen](resources.md)**. + +## Fehler, die du nie auslöst {#errors-you-never-raise} + +Ein ungültiges Argument erreicht deine Funktion nie. + +Schick `get_author` einen `title`, der kein String ist, und das SDK weist ihn anhand des Eingabeschemas ab, **bevor** es dich aufruft – als dieselbe Art Tool-Fehler mit `is_error=True`, den das Modell lesen und korrigieren kann. **[Tools](tools.md)** zeigt dieselbe Ablehnung mit einer `Field(le=50)`-Einschränkung. + +Das bedeutet eine ganze Klasse von `raise`-Anweisungen, die du nicht schreibst: Validiere deine eigenen Type Hints nicht noch einmal. + +!!! info + Alles auf dieser Seite ist das, was ein **Client** sieht, und der In-Memory-`Client`, mit dem du + Tests schreibst, sieht exakt dasselbe. Selbst `raise_exceptions=True` macht aus einem Tool-Fehler + keinen Traceback mehr: Bis dieses Flag greifen könnte, ist deine Exception längst das + Ergebnis mit `is_error=True`. Prüfe das Ergebnis mit Assertions. **[Testen](../get-started/testing.md)** beschreibt das Muster. + +## Zusammenfassung {#recap} + +* Löse **irgendeine Exception** in einem Tool aus -> der Aufruf gibt `is_error=True` mit deiner Meldung in `content` zurück. Das Modell liest sie und kann es erneut versuchen. Das ist der Standard. +* Löse **`MCPError`** aus -> der Aufruf selbst schlägt mit einem JSON-RPC-Fehler fehl. Das Modell sieht nichts; der Host kümmert sich darum. `code`, `message` und `data` kommen unverändert durch. +* Die entscheidende Frage: *Hätte ein klügeres Modell das vermeiden können?* Ja -> Exception. Nein -> `MCPError`. +* `ResourceNotFoundError` aus einem Ressourcen-Handler -> das `-32602` des Protokolls, mit dem URI in `data`. +* Ungültige Argumente werden anhand des Schemas abgewiesen, bevor deine Funktion läuft; dafür schreibst du kein `raise`. +* `from mcp import MCPError`; die Fehlercode-Konstanten kommen aus `mcp.types`. + +Fehler behandelt. Das ist alles, was ein Server *nach außen anbietet*. Was jeder Handler lesen und während der Ausführung zurück an den Client tun kann, ist der nächste Abschnitt: **[Im Handler](../handlers/index.md)**. + +Den genauen Wortlaut der SDK-Fehler, denen du am ehesten begegnest, was jeder bedeutet und wie du ihn jeweils mit einem Handgriff behebst, findest du unter **[Fehlerbehebung](../troubleshooting.md)**. diff --git a/i18n/de/pages/servers/index.md b/i18n/de/pages/servers/index.md new file mode 100644 index 0000000000..45be37e5dc --- /dev/null +++ b/i18n/de/pages/servers/index.md @@ -0,0 +1,39 @@ +--- +translation: + sections: [09defc170a0da89d] + tool: 1 +--- +# Server {#servers} + +Ein `MCPServer` stellt einem verbundenen Client drei Primitive bereit. Sie +unterscheiden sich darin, wer über ihren Einsatz entscheidet: + +* Ein **[Tool](tools.md)** ist eine Aktion, die das *Modell* auswählt und + aufruft. Diese Seite wollen die meisten zuerst lesen, und + **[Strukturierte Ausgabe](structured-output.md)** ist die zugehörige + Referenz: alles über die Form dessen, was ein Tool zurückgibt. +* Eine **[Ressource](resources.md)** sind schreibgeschützte Daten, die die + *Anwendung* zu lesen beschließt. **[URI-Templates](uri-templates.md)** ist + die zugehörige Referenz: die vollständige Adressierungssyntax und die Regeln + zur Pfadsicherheit. +* Ein **[Prompt](prompts.md)** ist eine Nachrichtenvorlage, die eine *Person* + beim Namen aufruft – über ein Menü oder einen Slash-Befehl. + +Rund um die drei Primitive liegt der Rest dessen, was ein Server deklariert: + +* **[Vervollständigungen](completions.md)** ist serverseitige + Autovervollständigung für Argumente von Prompts und Ressourcen-Templates. +* **[Bilder, Audio und Icons](media.md)** behandelt alles, was ein Tool + außer Text zurückgeben kann, sowie die Icons, die ein Client neben deinem + Server anzeigt. +* **[Fehler behandeln](handling-errors.md)** erklärt den Unterschied zwischen + einem Fehler, von dem sich das Modell erholen kann, und einem, den es nie zu + sehen bekommen darf. + +Jede Seite hier steht für sich; spring direkt zu der, die du brauchst. Hast du +noch keinen Server gebaut, beginne stattdessen mit +**[Erste Schritte](../get-started/first-steps.md)**. + +Was *innerhalb* der Funktionen passiert, die du registrierst (der `Context`, +Dependency Injection, die Person mitten im Aufruf um weitere Eingaben bitten), +ist Thema des nächsten Abschnitts, **[Im Handler](../handlers/index.md)**. diff --git a/i18n/de/pages/servers/media.md b/i18n/de/pages/servers/media.md new file mode 100644 index 0000000000..4b0780b32e --- /dev/null +++ b/i18n/de/pages/servers/media.md @@ -0,0 +1,122 @@ +--- +translation: + sections: [496394d24d221bf1, 4ceb4591180dc6c3, 0fd63e4682d02e0c, 969ede0bd3686a16, 043f526230dd243d, 6ee3e9bcfd24047a] + tool: 1 +--- +# Medien {#media} + +Text ist nicht das Einzige, was ein Tool zurückgeben kann. + +Das SDK bringt zwei Helfer für binäre Ergebnisse mit (**`Image`** und **`Audio`**) sowie einen Typ **`Icon`**, mit dem dein Server, deine Tools, Ressourcen und Prompts im UI des Clients ein Gesicht bekommen. + +## Ein Bild zurückgeben {#returning-an-image} + +Annotiere den Rückgabetyp als `Image`, zeige auf eine Datei und gib sie zurück: + +```python title="server.py" hl_lines="8 12 14" +--8<-- "docs_src/media/tutorial001.py" +``` + +* `Image` nimmt genau eines von `path` (eine Datei, die gelesen wird) oder `data` (rohe Bytes). +* Den MIME-Typ, den der Client sieht, errät das SDK aus der Dateiendung: `logo.png` wird als `image/png` angekündigt. +* Nichts hiervon ist speziell für Logos. Jedes PNG neben `server.py` funktioniert: ein Diagramm, das dein Code gerendert hat, eine Skizze, ein Foto. + +`Image` ist eine Bequemlichkeit des SDK, kein Protokolltyp. Auf der Leitung wird dein Rückgabewert zu einem **`ImageContent`**-Block (die Bytes der Datei base64-kodiert, dazu der MIME-Typ): + +```python +result.content # [ImageContent(type="image", data="iVBORw0KGgoAAAANSUhEUg...", mime_type="image/png")] +result.structured_content # None +``` + +Zwei Dinge fallen auf: + +* `data` ist base64. Du hast die Bytes nie angefasst; das SDK hat die Datei gelesen und kodiert. +* `structured_content` ist `None`. Ein `Image` ist Inhalt, den das Modell anschaut, keine Daten, die die Anwendung parst: Es gibt kein Output-Schema. (Vergleiche **[Strukturierte Ausgabe](structured-output.md)**, wo die Rückgabeannotation das Schema *ist*.) + +!!! info + `ImageContent` und `AudioContent` liegen in `mcp.types`, direkt neben dem `TextContent`, + zu dem ein einfaches `str`-Ergebnis wird (**[Tools](tools.md)**). Ein Tool-Ergebnis ist eine Liste von Content-Blöcken; `Image` und `Audio` sind + der kürzeste Weg, die beiden binären Arten zu erzeugen. + +### Ausprobieren {#try-it} + +Lege ein beliebiges PNG neben `server.py`, nenne es `logo.png` und starte: + +```console +uv run mcp dev server.py +``` + +Öffne den Tab **Tools** und rufe `logo` auf. Das Ergebnis ist kein String: Es ist ein Content-Block vom Typ `image`, und der Inspector rendert dein Bild. Alles zwischen der Datei auf der Platte und den Pixeln auf dem Bildschirm hat das SDK erledigt. + +## Audio zurückgeben {#returning-audio} + +`Audio` hat dieselbe Form. Lass `logo.png`, wo es war, und lege eine beliebige WAV-Datei als `chime.wav` daneben: + +```python title="server.py" hl_lines="18-21" +--8<-- "docs_src/media/tutorial002.py" +``` + +Das Ergebnis ist ein **`AudioContent`**-Block: + +```python +result.content # [AudioContent(type="audio", data="UklGR...", mime_type="audio/wav")] +result.structured_content # None +``` + +Dasselbe Prinzip: eine Datei auf der Platte hinein, base64 und ein MIME-Typ heraus, kein Output-Schema. + +## Bytes oder eine Datei {#bytes-or-a-file} + +Beide Helfer akzeptieren auch `data=` (rohe Bytes) statt `path=`. Das ist der Modus für Bytes, die nie aus einer eigenen Datei kamen – eine Datenbankspalte, eine HTTP-Response, etwas, das Pillow gerade gezeichnet hat: + +```python title="server.py" hl_lines="14 15" +--8<-- "docs_src/media/tutorial003.py" +``` + +Mit `path=` gibt es nichts zu deklarieren: Die Datei wird gelesen, wenn das Ergebnis gebaut wird, und der MIME-Typ wird aus der Endung erraten: + +* `Image`: `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`. +* `Audio`: `.wav`, `.mp3`, `.ogg`, `.flac`, `.aac`, `.m4a`. + +Eine Endung, die nicht erkannt wird, fällt auf `application/octet-stream` zurück. + +!!! check + Mit `data=` gibt es keinen Dateinamen, also nichts, woraus sich etwas erraten ließe. Vergisst du `format=`, + fällt das SDK auf einen Standardwert zurück: `image/png` für Bilder, `audio/wav` für Audio. Baust du so ein + `Audio` aus MP3-Bytes, bekommt der Client `mime_type="audio/wav"` mitgeteilt und scheitert dann + folgerichtig am Dekodieren. Wenn du `data=` übergibst, übergib auch `format=`. + +## Icons {#icons} + +Ein `Icon` ist Metadaten, kein Inhalt. Es trägt das Bild nicht; es zeigt per URI auf eines, und ein Client kann es abrufen und neben dem Namen deines Servers, einem Tool, einer Ressource oder einem Prompt anzeigen. + +```python title="server.py" hl_lines="4-5 7 10 16" +--8<-- "docs_src/media/tutorial004.py" +``` + +* `src` ist ein URI, den der Client auflösen kann: `https:` oder ein `data:`-URI, wenn du das Icon ohne zusätzlichen Abruf einbetten willst. +* Mit `mime_type` und `sizes` (`"48x48"` oder `"any"` für ein skalierbares Format) kann der Client das passende auswählen, wenn du mehrere anbietest. +* `theme="light"` oder `theme="dark"` markiert ein Icon für ein Farbschema. + +Dasselbe Keyword `icons=[...]` akzeptieren `MCPServer(...)`, `@mcp.tool()`, `@mcp.resource()` und `@mcp.prompt()`. + +### Wo ein Client sie sieht {#where-a-client-sees-them} + +Icons reisen mit dem, was sie schmücken. Die des Servers kommen an, wenn sich der Client verbindet, auf `client.server_info` (auf Verbindungen der 2026er-Generation optional, also grenze es zuerst ein): + +```python +assert client.server_info is not None # python-sdk servers identify themselves by default +client.server_info.icons # [Icon(src="https://example.com/brand-kit.png", mime_type="image/png", sizes=["48x48"])] +``` + +Die Icons eines Tools liegen auf dem `Tool`-Objekt aus `tools/list`, die einer Ressource auf der `Resource` aus `resources/list`, die eines Prompts auf dem `Prompt` aus `prompts/list`. Das Feld heißt immer `icons`. + +## Zusammenfassung {#recap} + +* Gib ein `Image` oder `Audio` aus einem Tool zurück, und der Client empfängt einen `ImageContent`- bzw. `AudioContent`-Block: deine Bytes base64-kodiert, mit einem MIME-Typ. +* Baue eines aus einem `path=` und lass die Endung den MIME-Typ bestimmen, oder aus `data=` im Speicher plus einem expliziten `format=`. +* Medien-Ergebnisse tragen kein `structured_content` und kein Output-Schema. +* Ein `Icon` ist ein Zeiger: ein `src`-URI plus optional `mime_type`, `sizes` und `theme`. +* `icons=[...]` funktioniert auf dem Server, auf Tools, auf Ressourcen und auf Prompts, und Clients finden sie auf den passenden Objekten. + +Das ist alles, was ein Tool *in* ein Ergebnis packen kann. Was passiert, wenn ein Tool *fehlschlägt* (und wer davon erfahren sollte), steht in **[Fehler behandeln](handling-errors.md)**. diff --git a/i18n/de/pages/servers/prompts.md b/i18n/de/pages/servers/prompts.md new file mode 100644 index 0000000000..4a8e6e3e7a --- /dev/null +++ b/i18n/de/pages/servers/prompts.md @@ -0,0 +1,156 @@ +--- +translation: + sections: [d65c098f37f5b6c3, dd0c2724d6f2877e, 6835bb3570c6714c, ffe823cb0fedd488, f33651add1b59094] + tool: 1 +--- +# Prompts {#prompts} + +Ein **Prompt** ist eine Nachrichtenvorlage, die die Person am Host auswählt. + +Tools sind für das Modell gedacht. Ein Prompt ist das Gegenteil: Die Person wählt einen aus einem Menü in ihrem Client (ein Slash-Command, ein Button), füllt die Argumente aus, und die gerenderten Nachrichten landen in der Unterhaltung, als hätte sie sie selbst getippt. + +Du deklarierst einen, indem du `@mcp.prompt()` auf eine Funktion setzt, die den Text zurückgibt. + +## Dein erster Prompt {#your-first-prompt} + +```python title="server.py" hl_lines="6-9" +--8<-- "docs_src/prompts/tutorial001.py" +``` + +Das SDK liest dieselben drei Dinge wie bei einem Tool: + +* Der **Name** ist der Funktionsname: `review_code`. +* Die **Beschreibung**, die der Client anzeigt, ist der Docstring: `Review a piece of code.` +* Die **Argumente** stammen aus den Parametern. `code` hat keinen Standardwert, also ist es erforderlich. + +Das bekommt ein Client von `prompts/list` zurück: + +```json +{ + "name": "review_code", + "description": "Review a piece of code.", + "arguments": [ + {"name": "code", "required": true} + ] +} +``` + +Hier gibt es kein JSON Schema. Prompt-Argumente sind eine flache Liste **benannter String-Werte**: ein Formular, das eine Person ausfüllt, keine Payload, die ein Modell zusammenbaut. + +### Rendern {#rendering-it} + +Der Client rendert die Vorlage mit `prompts/get` und übergibt dabei die Argumente. Deine Funktion läuft, und der `str`, den du zurückgibst, wird zu **einer einzigen User-Nachricht**: + +```json +{ + "description": "Review a piece of code.", + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "Please review this code:\n\ndef add(a, b): return a + b" + } + } + ], + "resultType": "complete" +} +``` + +Das ist der ganze Lebenslauf eines Prompts: unter seinem Namen aufgelistet, bei Bedarf gerendert, in den Chat eingefügt. + +!!! check + `required` wird durchgesetzt, bevor deine Funktion läuft. Renderst du `review_code` ohne `code`, + schlägt der Request selbst mit einem JSON-RPC-Fehler (Code `-32603`) fehl: + + ```text + mcp.shared.exceptions.MCPError: Internal server error + ``` + + Es gibt kein Fehlerergebnis im Stil eines Tools, das man einem Modell zurückgeben könnte, denn es ist + kein Modell beteiligt: Der Aufruf löst eine Exception aus. Der Grund (`Missing required arguments: {'code'}`) + landet im Log deines Servers. + +### Ausprobieren {#try-it} + +Starte den Server mit dem MCP Inspector: + +```console +uv run mcp dev server.py +``` + +Öffne den Tab **Prompts** und wähle `review_code`. Der Inspector zeichnet ein Formular mit einem erforderlichen Feld `code`. Fülle es aus, rendere es, und du bekommst genau die User-Nachricht von oben zurück. + +## Mehr als eine Nachricht {#more-than-one-message} + +Ein Code-Review ist eine Nachricht. Eine Debugging-Sitzung ist eine Unterhaltung, und ein Prompt kann sie komplett anstoßen. + +Gib eine Liste von Nachrichten statt eines `str` zurück: + +```python title="server.py" hl_lines="2 13-20" +--8<-- "docs_src/prompts/tutorial002.py" +``` + +* `UserMessage` und `AssistantMessage` kommen aus `mcp.server.mcpserver.prompts.base`. Übergib ihnen einen `str`, und sie verpacken ihn für dich in `TextContent`. Die Rolle ist der Klassenname. +* `Message` ist ihre gemeinsame Basisklasse. Verwende sie als Rückgabeannotation. + +Das Rendern von `debug_error` erzeugt jetzt drei Nachrichten, in dieser Reihenfolge: + +```json +{ + "description": "Start a debugging conversation.", + "messages": [ + {"role": "user", "content": {"type": "text", "text": "I'm seeing this error:"}}, + {"role": "user", "content": {"type": "text", "text": "TypeError: 'int' object is not iterable"}}, + { + "role": "assistant", + "content": {"type": "text", "text": "I'll help debug that. What have you tried so far?"} + } + ], + "resultType": "complete" +} +``` + +Beachte die letzte. Einen `assistant`-Beitrag vorzubelegen ist der Weg, die *nächste* Antwort des Modells zu lenken, ohne dass die Person die Lenkung selbst tippen muss. + +## Titel und Argumentbeschreibungen {#titles-and-argument-descriptions} + +`review_code` ist ein Funktionsname, keine Beschriftung. Gib dem Client etwas Besseres für den Button und beschreibe jedes Argument, damit sich das Formular von selbst erklärt: + +```python title="server.py" hl_lines="10-13" +--8<-- "docs_src/prompts/tutorial003.py" +``` + +* `title="Code review"` ist der menschenlesbare Name, genau wie das `title` eines Tools. +* `Annotated[str, Field(description=...)]` ist dasselbe Muster, mit dem **[Tools](tools.md)** die Parameter eines Tools beschreibt. Hier landet die Beschreibung am Argument statt in einem Schema. +* `language` hat einen Standardwert und ist damit nicht mehr erforderlich. + +Der `prompts/list`-Eintrag enthält jetzt alles, was ein Client braucht, um ein gutes Formular zu zeichnen: + +```json +{ + "name": "review_code", + "title": "Code review", + "description": "Review a piece of code.", + "arguments": [ + {"name": "code", "description": "The code to review.", "required": true}, + {"name": "language", "description": "The language the code is written in.", "required": false} + ] +} +``` + +!!! info + Wenn du **[Tools](tools.md)** gelesen hast, kennst du schon alles auf dieser Seite. Derselbe Dekorator, derselbe + Docstring als Beschreibung, dasselbe `Annotated`/`Field`. Das Einzige, was sich ändert: wer + ihn auslöst (die Person) und wohin das Ergebnis geht (in die Unterhaltung). + +## Zusammenfassung {#recap} + +* `@mcp.prompt()` auf einer Funktion macht sie zu einem Prompt. Der Name kommt von der Funktion, die Beschreibung vom Docstring. +* Prompts sind **von der Person gesteuert**: Der Client listet sie auf, die Person wählt einen und füllt die Argumente aus. +* Argumente sind eine flache Liste benannter Strings (kein Schema). Ein Parameter mit Standardwert ist optional. +* Gibst du einen `str` zurück, wird daraus eine User-Nachricht. Gib eine Liste von `UserMessage` / `AssistantMessage` zurück, um eine mehrteilige Unterhaltung anzustoßen. +* `title=` und `Field(description=...)` sind das, was ein Client in seiner Oberfläche anzeigt. +* Ein fehlendes erforderliches Argument lässt den ganzen Request fehlschlagen. Es gibt kein Fehlerergebnis pro Prompt. + +Serverseitige Autovervollständigung für die Argumente eines Prompts (oder eines Ressourcen-Templates) ist **[Vervollständigungen](completions.md)**. diff --git a/i18n/de/pages/servers/resources.md b/i18n/de/pages/servers/resources.md new file mode 100644 index 0000000000..5fa9cbe49f --- /dev/null +++ b/i18n/de/pages/servers/resources.md @@ -0,0 +1,146 @@ +--- +translation: + sections: [09df998c2a799f78, 0cf131146d16d4f9, 4e6b91e3f8025346, 8fe4eef576db17ed, 0d0d1ed43e3d0a53] + tool: 1 +--- +# Ressourcen {#resources} + +Eine **Ressource** sind Daten, die du bereitstellst, damit die Anwendung sie lesen kann. + +Das ist die Trennlinie. Ein Tool ist etwas, das das **Modell** aufzurufen beschließt. Eine Ressource ist etwas, das die **Anwendung** zu laden beschließt (eine Konfigurationsdatei, einen Datensatz, ein Dokument) und dem Modell als Kontext vorlegt. + +Du deklarierst eine, indem du `@mcp.resource(uri)` auf eine ganz normale Python-Funktion setzt. + +## Deine erste Ressource {#your-first-resource} + +```python title="server.py" hl_lines="6-8" +--8<-- "docs_src/resources/tutorial001.py" +``` + +Sie hat dieselbe Form wie ein Tool, plus eine Sache: den **URI**. Ressourcen werden adressiert, nicht benannt. Ein Client fragt nach `config://app`, nie nach `get_config`. + +Den Rest liest das SDK weiterhin aus der Funktion: + +* Der **Name** ist der Funktionsname: `get_config`. +* Die **Beschreibung**, die der Client sieht, ist der Docstring. +* Der **Inhalt** ist das, was du zurückgibst. + +Bei `resources/list` bekommt der Client das hier: + +```json +{ + "name": "get_config", + "uri": "config://app", + "description": "The active shop configuration.", + "mimeType": "text/plain" +} +``` + +Und wenn er `config://app` liest, läuft deine Funktion, und der Rückgabewert kommt als Text zurück: + +```python +result.contents # [TextResourceContents(uri="config://app", mime_type="text/plain", text="theme=dark\nlanguage=en")] +``` + +!!! tip + Auflisten ist billig. Deine Funktion wird bei `resources/list` **nicht** aufgerufen, nur bei + `resources/read`, und nur für den URI, nach dem gefragt wurde. Stelle tausend Ressourcen + bereit, und du zahlst nur für die, die jemand öffnet. + +### Ausprobieren {#try-it} + +Starte den Server mit dem MCP Inspector: + +```console +uv run mcp dev server.py +``` + +Öffne die URL, die er ausgibt, und wechsle zum Tab **Resources**. `config://app` steht mit seiner Beschreibung in der Liste. Klicke darauf, und der Inspector liest es: Da sind deine zwei Zeilen Konfiguration. + +## Ressourcen-Templates {#resource-templates} + +Ein URI pro Datensatz skaliert nicht. Setze einen **Platzhalter** in den URI und einen passenden Parameter auf die Funktion: + +```python title="server.py" hl_lines="12-13" +--8<-- "docs_src/resources/tutorial002.py" +``` + +`{user_id}` im URI, `user_id: str` an der Funktion. Das ist der ganze Vertrag. + +Das ist jetzt ein **Ressourcen-Template**, und es zieht um: Es verlässt `resources/list` und taucht stattdessen in `resources/templates/list` auf – als Muster statt als Adresse: + +```json +{ + "name": "get_user_profile", + "uriTemplate": "users://{user_id}/profile", + "description": "A customer's profile.", + "mimeType": "text/plain" +} +``` + +Der Client füllt den Platzhalter aus und liest einen konkreten URI: `users://42/profile`, `users://ada/profile`. Eine einzige Funktion beantwortet sie alle, wobei der erkannte Wert als `user_id` übergeben wird: + +```python +result.contents # [TextResourceContents(uri="users://42/profile", text="User 42: 12 orders since 2021.")] +``` + +Beachte den `uri` im Ergebnis. Es ist der **konkrete** URI, nach dem der Client gefragt hat, nicht das Template. + +!!! check + Platzhalter und Parameter müssen übereinstimmen. Benenne den Funktionsparameter in + `user` um, während im URI noch `{user_id}` steht, und der Dekorator verweigert sich **beim Import**, + bevor irgendein Client in die Nähe kommt: + + ```text + ValueError: Mismatch between URI parameters {'user_id'} and function parameters {'user'} + ``` + + Eine Abweichung kann immer nur ein Bug sein, also macht das SDK es unmöglich, den Server damit zu starten. + +Die Platzhalter-Syntax ist [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570): `{+path}` für Werte über mehrere Segmente, `{?q,lang}` für optionale Query-Parameter und mehr. Außerdem wendet das SDK standardmäßig Pfadsicherheitsprüfungen auf die extrahierten Werte an. Die vollständige Referenz steht in **[URI-Templates und Pfadsicherheit](uri-templates.md)**. + +`get_user_profile` kann auch einen Parameter mit der Annotation `Context` entgegennehmen. Das SDK injiziert ihn, ohne ihn je als URI-Parameter zu behandeln, und die Seite **[Der Context](../handlers/context.md)** beschreibt, was er dir bietet. + +## Was du zurückgibst {#what-you-return} + +Du bist nicht auf `str` beschränkt. Gib jeder Ressource einen `mime_type` und gib zurück, was passt: + +```python title="server.py" hl_lines="8-9 14-15 20-21" +--8<-- "docs_src/resources/tutorial003.py" +``` + +* `readme` gibt einen `str` zurück, also wird er unverändert gesendet. Das ist der Normalfall. +* `catalog_stats` gibt ein `dict` zurück, also serialisiert das SDK es für dich zu **JSON-Text**: + + ```json + { + "books": 1204, + "authors": 391 + } + ``` + +* `placeholder_cover` gibt `bytes` zurück, also bekommt der Client ein `BlobResourceContents` statt eines `TextResourceContents`, mit deinen Bytes base64-kodiert im Feld `blob`. + +Dieselbe Regel gilt für alles andere, was JSON-serialisierbar ist: eine Liste, ein Pydantic-Modell, eine Dataclass. Ist es kein `str` und kein `bytes`, wird es zu JSON. + +`mime_type` deklarierst du selbst, und der Standardwert ist `text/plain`. Das SDK untersucht nie, was du zurückgibst, um ihn zu erraten – eine `dict`-Ressource, die du nicht kennzeichnest, wird also weiterhin als Plain Text angekündigt. + +!!! tip + `@mcp.resource()` akzeptiert auch `name=`, `title=` und `description=`, wenn du sie nicht + aus der Funktion ableiten willst. Und wenn es gar keine Funktion zu schreiben gibt, + hält `mcp.server.mcpserver.resources` fertige `Resource`-Klassen bereit (`TextResource`, + `BinaryResource`, `FileResource`, `HttpResource`, `DirectoryResource`), die du + mit `mcp.add_resource(...)` registrierst. + +Ein Client kann eine Ressource außerdem **abonnieren** und benachrichtigt werden, wenn sie sich ändert; das ist die Client-Hälfte der Geschichte, und sie steht in **[Der Client](../client/index.md)**. + +## Zusammenfassung {#recap} + +* `@mcp.resource(uri)` auf einer Funktion macht sie zur Ressource. Der URI ist die Adresse, der Rückgabewert ist der Inhalt, der Docstring ist die Beschreibung. +* Ein `{placeholder}` im URI macht sie zum **Template**: Es wird unter `resources/templates/list` aufgeführt, und eine einzige Funktion bedient jeden URI, der passt. +* Die Platzhalternamen müssen den Parameternamen der Funktion entsprechen. Machst du es falsch, erfährst du es beim Import, nicht in Produktion. +* Deine Funktion läuft, wenn die Ressource **gelesen** wird, nicht wenn sie aufgelistet wird. +* `str` wird zu Text, `bytes` zu einem base64-Blob, alles andere zu JSON-Text. Mit `mime_type=` kennzeichnest du es. +* Tools sind dafür da, dass das Modell handelt. Ressourcen sind dafür da, dass die Anwendung liest. + +Das dritte Primitiv – das, das eine Person aus einem Menü auswählt – sind **[Prompts](prompts.md)**. diff --git a/i18n/de/pages/servers/structured-output.md b/i18n/de/pages/servers/structured-output.md new file mode 100644 index 0000000000..f35159545c --- /dev/null +++ b/i18n/de/pages/servers/structured-output.md @@ -0,0 +1,250 @@ +--- +translation: + sections: [a838d57f003aed44, 857d03886a0137ed, 42d9efcb9f542867, 2290ff08435b5573, e866c192e11d1c14, 6cdbad079f7b47f0, d4b607372fb28b51, 18dbf726ac45e0b7, c6f7d2a148aa49f4, c851964bb3301907, d715db6f8dccc9cc, ef86634aa70498a7] + tool: 1 +--- +# Strukturierte Ausgabe {#structured-output} + +Ein Tool, das einen einfachen `str` zurückgibt, liefert das Ergebnis doppelt: als Text in `content` und als `{"result": "..."}` in `structured_content`. + +Auf dieser Seite geht es um diesen zweiten Kanal: woher er kommt, welche Formen er annehmen kann und wie das SDK dafür sorgt, dass er hält, was er verspricht. + +Die Kurzfassung: **Die Annotation des Rückgabetyps ist das Ausgabeschema**. Du hast sie schon geschrieben. + +## Das Ausgabeschema {#the-output-schema} + +```python title="server.py" hl_lines="9" +--8<-- "docs_src/structured_output/tutorial001.py" +``` + +Die entscheidende Zeile ist die Signatur: `-> int`. + +Ihretwegen trägt das Tool, das das SDK bei `tools/list` sendet, ein `output_schema` neben dem Eingabeschema, das es aus deinen Parametern baut (darum kümmert sich **[Tools](tools.md)**): + +```json +{ + "properties": { + "result": {"title": "Result", "type": "integer"} + }, + "required": ["result"], + "title": "get_temperatureOutput", + "type": "object" +} +``` + +Ein nackter `int` ist kein JSON-Objekt, also **verpackt** das SDK ihn in `{"result": ...}`. Ruf das Tool auf, und beide Kanäle sind gefüllt: + +```python +result.content # [TextContent(text="17")] +result.structured_content # {"result": 17} +``` + +Jeder skalare Wert bekommt dieselbe Hülle: `str`, `int`, `float`, `bool`, `bytes`, `None`. + +## Zwei Kanäle {#two-channels} + +Warum denselben Wert zweimal senden? + +* `content` ist für das **Modell**. Ein Sprachmodell liest Text; das ist der einzige Teil des Ergebnisses, den es sieht. +* `structured_content` ist für die **Anwendung**, in der das Modell läuft: Code, der `17` will und keinen Satz, in dem „17“ vorkommt. +* `output_schema` ist der Vertrag zwischen beiden, veröffentlicht, bevor das Tool überhaupt aufgerufen wird. + +Du gibst einen einzigen Python-Wert zurück. Das SDK füllt alle drei. + +## Ein Modell zurückgeben {#return-a-model} + +Deklariere die Form als Pydantic-`BaseModel` und gib eine Instanz zurück: + +```python title="server.py" hl_lines="8-11 15" +--8<-- "docs_src/structured_output/tutorial002.py" +``` + +`WeatherData` **ist** jetzt das Schema. Keine Hülle, kein `result`-Schlüssel: + +```json +{ + "properties": { + "temperature": {"description": "Degrees Celsius.", "title": "Temperature", "type": "number"}, + "humidity": {"description": "Relative humidity, 0 to 1.", "title": "Humidity", "type": "number"}, + "conditions": {"title": "Conditions", "type": "string"} + }, + "required": ["temperature", "humidity", "conditions"], + "title": "WeatherData", + "type": "object" +} +``` + +`structured_content` ist das Objekt, Feld für Feld: + +```python +result.structured_content # {"temperature": 16.2, "humidity": 0.83, "conditions": "Overcast"} +``` + +Und das Modell geht nicht leer aus. Das SDK serialisiert dasselbe Objekt für `content` zu JSON-Text: + +```json +{ + "temperature": 16.2, + "humidity": 0.83, + "conditions": "Overcast" +} +``` + +Beachte, dass das `Field(description=...)` an `temperature` und `humidity` im Schema gelandet ist. Dasselbe `Field`, das deine **Eingaben** beschrieben hat, beschreibt auch deine Ausgaben. + +!!! info + Wenn du FastAPIs `response_model` kennst, kennst du das hier schon: ein Pydantic-Modell als deklarierte + Response, für dich serialisiert und dokumentiert. Der einzige Unterschied: Hier ist die Annotation des + Rückgabetyps die ganze Deklaration. + +## Ein `TypedDict` {#a-typeddict} + +Nicht jede Form verdient eine Klasse. Ein `TypedDict` erzeugt dasselbe Schema: + +```python title="server.py" hl_lines="8" +--8<-- "docs_src/structured_output/tutorial003.py" +``` + +Ein `TypedDict` ist zur Laufzeit ein einfaches `dict`, also baust du genau das und gibst es zurück. Das Schema, die Validierung und `structured_content` sind identisch mit der `BaseModel`-Variante (abgesehen von den Beschreibungen, für die ein `TypedDict` keinen Platz hat). + +## Eine Dataclass {#a-dataclass} + +Dataclasses funktionieren auch, genauso wie jede gewöhnliche Klasse, deren Attribute Type Hints tragen. Das SDK baut unter der Haube aus den Annotationen ein Pydantic-Modell. + +```python title="server.py" hl_lines="8-9" +--8<-- "docs_src/structured_output/tutorial004.py" +``` + +Drei Schreibweisen, ein Schema. Nimm die, die deine Codebasis ohnehin schon verwendet. + +## Listen {#lists} + +Eine `list[...]` ist ebenfalls kein JSON-Objekt, also bekommt sie die `{"result": ...}`-Hülle, mit deinem Elementtyp als `$defs`-Referenz darin: + +```python title="server.py" hl_lines="15" +--8<-- "docs_src/structured_output/tutorial005.py" +``` + +```json +{ + "$defs": { + "WeatherData": { + "properties": { + "temperature": {"title": "Temperature", "type": "number"}, + "humidity": {"title": "Humidity", "type": "number"}, + "conditions": {"title": "Conditions", "type": "string"} + }, + "required": ["temperature", "humidity", "conditions"], + "title": "WeatherData", + "type": "object" + } + }, + "properties": { + "result": {"items": {"$ref": "#/$defs/WeatherData"}, "title": "Result", "type": "array"} + }, + "required": ["result"], + "title": "get_forecastOutput", + "type": "object" +} +``` + +Fordere eine Zwei-Tage-Vorhersage an, und `structured_content` ist `{"result": [{...}, {...}]}`. `content` wird zu **zwei** `TextContent`-Blöcken, einer pro Element: Eine Liste wird für das Modell aufgefächert, statt als ein einziger String ausgegeben zu werden. + +`tuple[...]`, Unions und `Optional[...]` werden genauso verpackt. + +## Dictionaries {#dictionaries} + +`dict[str, ...]` ist der eine generische Typ, der bereits ein JSON-Objekt *ist*, und wird deshalb nicht verpackt: + +```python title="server.py" hl_lines="9" +--8<-- "docs_src/structured_output/tutorial006.py" +``` + +```json +{ + "additionalProperties": {"type": "number"}, + "title": "get_temperaturesDictOutput", + "type": "object" +} +``` + +```python +result.structured_content # {"London": 16.2, "Reykjavik": 4.4} +``` + +Die Schlüssel müssen `str` sein. Ein `dict[int, float]` kann kein JSON-Objekt sein und fällt deshalb auf die `{"result": ...}`-Hülle zurück. + +## Validierung {#validation} + +`output_schema` ist keine Dokumentation. Was auch immer deine Funktion zurückgibt, wird **dagegen validiert**, bevor es den Server verlässt. + +Solange du den Wert von Hand baust, merkst du davon nichts: Pydantic hat schon sichergestellt, dass dein `WeatherData` ein `WeatherData` ist. Du merkst es an dem Tag, an dem die Daten von irgendwo kommen, das du nicht kontrollierst: + +```python title="server.py" hl_lines="9 21" +--8<-- "docs_src/structured_output/tutorial007.py" +``` + +Die Annotation verspricht `WeatherData`. Die Upstream-Response liefert `humidity` nicht mehr mit. + +!!! check + Ruf `get_weather` auf, und es reicht dem Client nicht stillschweigend ein halb leeres Objekt weiter. Der Aufruf schlägt fehl, + und die ersten Zeilen des Fehlers nennen das Feld: + + ```text + Error executing tool get_weather: 1 validation error for WeatherData + humidity + Field required [type=missing, input_value={'temperature': 16.2, 'conditions': 'Overcast'}, input_type=dict] + ``` + + Dieser Text kommt als Tool-Ergebnis mit `is_error=True` zurück. So weiß das Modell, dass der Aufruf fehlgeschlagen ist, + statt selbstbewusst Wetterdaten abzulesen, die gar nicht da sind. + +Ein einfaches `dict` aus einem `-> WeatherData`-Tool zurückzugeben ist übrigens in Ordnung. Genau das hat `json.loads` erzeugt. Validiert wird der Wert, nicht der Python-Typ. + +## Abschalten {#opting-out} + +Manchmal ist die Annotation des Rückgabetyps für den Type Checker da, nicht für das Protokoll. Übergib `structured_output=False`, und das Tool liefert nur Text: + +```python title="server.py" hl_lines="6" +--8<-- "docs_src/structured_output/tutorial008.py" +``` + +Kein `output_schema`, keine Hülle, keine Validierung. `structured_content` ist `None`, und `content` ist der String, den du zurückgegeben hast. + +Das Gegenteil, `structured_output=True`, macht aus der automatischen Erkennung eine Anforderung: Ein Tool, dessen Rückgabetyp kein Schema erzeugen kann, löst beim Import eine Exception aus, statt auf Text zurückzufallen. + +## Eine Klasse ohne Type Hints {#a-class-without-type-hints} + +Es gibt einen Weg, unstrukturiert zu enden, ohne es gewollt zu haben: eine Klasse zurückzugeben, die **keine Annotationen im Klassenrumpf** hat. + +```python title="server.py" hl_lines="6-9" +--8<-- "docs_src/structured_output/tutorial009.py" +``` + +`Station` setzt `name` und `online` in `__init__`, aber die *Klasse* deklariert nichts. Das SDK liest die Klassenannotationen, findet keine und gibt auf. + +!!! warning + Es gibt **stillschweigend** auf. `output_schema` ist `None`, `structured_content` ist `None`, und der Text, + den das Modell liest, ist das `repr` des Objekts: + + ```text + "" + ``` + + Kein Fehler, keine Warnung, ein nutzloses Tool. Verschiebe die Annotationen in den Klassenrumpf oder übergib + `structured_output=True`. Das macht daraus einen harten Fehler, sobald das Modul importiert wird: + `Function get_station: return type is not serializable for structured output`. + +!!! tip + Brauchst du die volle Kontrolle (das `CallToolResult` selbst bauen oder `_meta` anhängen, das die + Anwendung sieht, das Modell aber nicht)? Das ist **[Der Low-Level-Server](../advanced/low-level-server.md)**. + +## Zusammenfassung {#recap} + +* Die **Annotation des Rückgabetyps** ist das Ausgabeschema. Sie wird in `tools/list` als `output_schema` veröffentlicht. +* Skalare, Listen, Tupel und Unions werden in `{"result": ...}` verpackt. Modelle, `TypedDict`s, Dataclasses, annotierte Klassen und `dict[str, ...]` sind schon Objekte und bleiben, wie sie sind. +* Jedes Ergebnis trägt `content` (Text, für das Modell) **und** `structured_content` (Daten, für die Anwendung). +* Was du zurückgibst, wird gegen das Schema validiert. Eine Abweichung ist ein Tool-Fehler, kein kaputtes Ergebnis. +* `structured_output=False` nimmt ein Tool davon aus. Eine Klasse ohne Type Hints nimmt sich stillschweigend aus; achte darauf. + +Damit hast du alles in der Hand, was ein Tool zurückmelden kann. Als Nächstes das zweite Primitiv: **[Ressourcen](resources.md)**. diff --git a/i18n/de/pages/servers/tools.md b/i18n/de/pages/servers/tools.md new file mode 100644 index 0000000000..70def651ca --- /dev/null +++ b/i18n/de/pages/servers/tools.md @@ -0,0 +1,177 @@ +--- +translation: + sections: [e4cc390d56573409, 8566e2b68594e9ad, 2c97b9f888398951, 048e5471dfa71aea, 3076b1e16ad95950, edbedf2a16e71311, 3d8ef8da89fa87c1, f6c0e02e6ea5a363] + tool: 1 +--- +# Tools {#tools} + +Ein **Tool** ist eine Funktion, die das Modell aufrufen kann. + +Du deklarierst eines, indem du `@mcp.tool()` auf eine ganz normale Python-Funktion setzt. Das ist die ganze API. + +## Dein erstes Tool {#your-first-tool} + +```python title="server.py" hl_lines="6-8" +--8<-- "docs_src/tools/tutorial001.py" +``` + +Sieh dir an, was du geschrieben hast. Keine Schemas, kein JSON, kein Protokoll, nur eine Funktion. Das SDK liest drei Dinge daraus: + +* Der **Name** des Tools ist der Name der Funktion: `search_books`. +* Die **Beschreibung**, die das Modell sieht, ist der Docstring: `Search the catalog by title or author.` +* Die **Argumente**, die das Modell übergeben darf, ergeben sich aus den Type Hints: `query: str` und `limit: int`. + +### Das Eingabeschema {#the-input-schema} + +Aus diesen Type Hints erzeugt das SDK ein JSON Schema und sendet es während `tools/list` an den Client: + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"title": "Limit", "type": "integer"} + }, + "required": ["query", "limit"], + "title": "search_booksArguments" +} +``` + +Beide Argumente stehen in `required`, weil keines einen Standardwert hat. Das änderst du gleich. (Die `title`-Schlüssel sind Pydantic-Artefakte; die Properties, ihre Typen und `required` sind der Vertrag.) + +!!! tip + Type Hints sind hier keine Dokumentation. Sie sind **der Vertrag**. Sendet ein Client `"limit": "ten"`, + weist das SDK das zurück, bevor deine Funktion überhaupt läuft. + +### Was das Modell zurückbekommt {#what-the-model-gets-back} + +Ruf das Tool mit `{"query": "dune", "limit": 5}` auf, und das Ergebnis hat zwei Teile: + +```python +result.content # [TextContent(text="Found 3 books matching 'dune' (showing up to 5).")] +result.structured_content # {'result': "Found 3 books matching 'dune' (showing up to 5)."} +``` + +`content` ist der Text, den das **Modell** liest. `structured_content` sind typisierte Daten für die **Client-Anwendung**. Es ist da, weil du den Rückgabetyp als `-> str` deklariert hast. + +Kümmere dich noch nicht um `structured_content`. Gib aus deinen Tools echte Python-Objekte zurück, und es passiert das Richtige; die Seite **[Strukturierte Ausgabe](structured-output.md)** dreht sich genau darum. + +### Ausprobieren {#try-it} + +Starte den Server mit dem MCP Inspector: + +```console +uv run mcp dev server.py +``` + +Öffne die URL, die er ausgibt, geh zum Tab **Tools** und ruf `search_books` auf. + +Der Inspector zeigt ein Formular mit einem erforderlichen Textfeld `query` und einem erforderlichen Zahlenfeld `limit`. Dieses Formular hat er aus deinen Type Hints gebaut. Das macht jeder andere MCP-Client genauso. + +## Optionale Argumente {#optional-arguments} + +Gib einem Parameter einen Standardwert, und er ist nicht mehr erforderlich. Das ist alles. Ganz normales Python. + +```python title="server.py" hl_lines="7" +--8<-- "docs_src/tools/tutorial002.py" +``` + +Das Schema zieht mit: + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"default": 10, "title": "Limit", "type": "integer"} + }, + "required": ["query"], + "title": "search_booksArguments" +} +``` + +`limit` ist aus `required` verschwunden und hat `"default": 10` bekommen. Ein Client, der es weglässt, bekommt `10` – genau wie in Python. + +## Reichere Schemas mit `Field` {#richer-schemas-with-field} + +Type Hints bringen dich weit, aber manchmal willst du ein Argument *beschreiben* oder einschränken. + +Verpacke den Typ in `Annotated` und füge ein Pydantic-`Field` hinzu: + +```python title="server.py" hl_lines="12-14" +--8<-- "docs_src/tools/tutorial003.py" +``` + +Drei neue Dinge, alle an den Parametern: + +* `Field(description=...)`: eine Beschreibung pro Argument, die das Modell zusätzlich zum Docstring liest. +* `Field(ge=1, le=50)`: numerische Grenzen. Sie landen im Schema als `"minimum": 1, "maximum": 50`. +* `Literal["fiction", "non-fiction", "poetry"]`: ein Enum. Das Modell kann nur einen dieser Werte wählen. + +!!! check + Constraints sind keine Dekoration. Ruf das Tool mit `limit=999` auf, und das SDK antwortet mit einem + Tool-Fehler, **bevor deine Funktion läuft**: + + ```text + Input should be less than or equal to 50 + ``` + + Dieser Fehler geht als Tool-Ergebnis zurück an das Modell, das Modell liest ihn und versucht es mit + einem gültigen Wert erneut. Du hast einmal `le=50` geschrieben und bekommst selbstkorrigierende Agenten umsonst dazu. + +!!! info + Wenn du FastAPI oder Pydantic schon benutzt hast, kennst du das alles bereits. Es ist dasselbe `Field`, + dasselbe `Annotated`, dieselbe Validierung. Es gibt hier nichts MCP-Spezifisches zu lernen. + +## Ein Modell als Parameter {#a-model-as-a-parameter} + +Nimmt ein Tool mehr als ein paar Argumente entgegen, fasse sie in einem Pydantic-Modell zusammen: + +```python title="server.py" hl_lines="8-11 15" +--8<-- "docs_src/tools/tutorial004.py" +``` + +Das `Book`-Schema wird in das Eingabeschema des Tools eingebettet (als `$defs`-Referenz), das Modell füllt es als JSON-Objekt aus, und deine Funktion erhält eine **echte `Book`-Instanz**, bereits validiert, mit den Attributen `.title`, `.author` und `.year`. + +Du kannst frei kombinieren: einfache Parameter neben Modell-Parametern, verschachtelte Modelle, Listen von Modellen. Es ist Pydantic bis ganz nach unten. + +## `async def` {#async-def} + +Macht ein Tool I/O (ruft eine API auf, liest eine Datei, fragt eine Datenbank ab), deklariere es als `async def` und verwende `await` darin. Das SDK wartet darauf. + +Ein Tool mit einfachem `def` funktioniert auch: Das SDK führt es in einem Thread aus, damit es den Server nie blockiert. + +Mehr gibt es nicht zu konfigurieren. + +## Namen, Titel und Annotationen {#names-titles-and-annotations} + +Alles, was das SDK ableitet, kannst du im Dekorator überschreiben: + +```python title="server.py" hl_lines="7-10" +--8<-- "docs_src/tools/tutorial005.py" +``` + +* `title` ist ein menschenlesbarer Name für UIs. Clients zeigen *„Search the catalog“* statt `search_books`. +* `annotations` sind **Hinweise** zum Verhalten für den Client: + * `read_only_hint=True`: Dieses Tool ändert nichts. + * `open_world_hint=False`: Es arbeitet auf einer geschlossenen Menge von Dingen (diesem Katalog), nicht im offenen Web. + * Die beiden anderen, `destructive_hint` und `idempotent_hint`, beschreiben ein Tool, das *schreibt*: Darf es + etwas löschen, und ist zweimal aufrufen dasselbe wie einmal aufrufen? Die Spezifikation definiert beide + nur für Tools, die nicht read-only sind, deshalb würden sie bei `search_books` nichts aussagen. + +Ein gut erzogener Client nutzt sie, um Dinge zu entscheiden wie *„Muss ich die Person fragen, bevor ich das ausführe?“*. Es sind Hinweise, keine Sicherheit. Verlass dich nie darauf, dass ein Client sie beachtet. + +!!! tip + `@mcp.tool()` akzeptiert auch `name=` und `description=`, falls du sie nicht aus dem Funktionsnamen + und dem Docstring ableiten lassen willst. Meistens willst du das aber. + +## Zusammenfassung {#recap} + +* `@mcp.tool()` auf einer Funktion macht sie zum Tool. Name aus der Funktion, Beschreibung aus dem Docstring. +* Type Hints **sind** das Eingabeschema. Standardwerte machen Argumente optional. +* `Annotated[..., Field(...)]` fügt Beschreibungen und Constraints hinzu; `Literal` fügt Enums hinzu. +* Über einen Pydantic-Modell-Parameter nimmst du einen strukturierten „Body“ entgegen. +* Ungültige Argumente werden für dich abgewiesen, mit einem Fehler, den das Modell lesen und aus dem es sich erholen kann. +* `async def` für I/O, einfaches `def` für alles andere. + +**[Strukturierte Ausgabe](structured-output.md)** beschreibt, was mit dem Wert passiert, den du mit `return` zurückgibst. diff --git a/i18n/de/pages/servers/uri-templates.md b/i18n/de/pages/servers/uri-templates.md new file mode 100644 index 0000000000..5160a6e07e --- /dev/null +++ b/i18n/de/pages/servers/uri-templates.md @@ -0,0 +1,290 @@ +--- +translation: + sections: [4a7033e1ed8ad602, 55dcbfff0c6271bf, 101ef9d14bf4ec46, 4b6c4a845438abc7, f98b46bafbee4acd] + tool: 1 +--- +# URI-Templates und Pfadsicherheit {#uri-templates-and-path-safety} + +Dies ist die Referenz für die URI-Template-Syntax, die +[`@mcp.resource`](resources.md) akzeptiert, und für die +Pfadsicherheitsrichtlinie, die das SDK auf extrahierte Werte anwendet. Eine +Einführung, was Ressourcen sind und wann du sie einsetzt, findest du in +**[Ressourcen](resources.md)**; diese Seite setzt voraus, dass du bereits +sicher im Deklarieren einer Ressource bist und den vollständigen +Operatorsatz, die Sicherheitseinstellungen oder die Low-Level-Verdrahtung +suchst. + +Die Template-Syntax ist [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570). +Das SDK unterstützt eine Teilmenge, die für das Matching eingehender +`resources/read`-URIs ausgewählt wurde, plus eine Sicherheitsschicht, die +Werte ablehnt, die außerhalb des Verzeichnisses landen würden, das du +bereitstellen willst. Die Details auf Protokollebene (Nachrichtenformate, +Lebenszyklus, Paginierung) stehen in der +[MCP-Ressourcen-Spezifikation](https://modelcontextprotocol.io/specification/latest/server/resources). + +## Der vollständige Operatorsatz {#the-full-operator-set} + +Der einfache Platzhalter `{user_id}` ist der, den **[Ressourcen](resources.md)** einführt. Es gibt vier weitere +Operatorformen; hier stehen sie alle auf einem Server, damit du sie +nebeneinander siehst: + +```python title="server.py" hl_lines="16-17 22-23 28-29 34-35 40-41" +--8<-- "docs_src/uri_templates/tutorial001.py" +``` + +Jeder hervorgehobene Dekorator zerlegt den URI auf eine andere Weise. +Die folgenden Abschnitte gehen sie von oben nach unten durch. + +### Einfache Expansion: `{name}` {#simple-expansion-name} + +`books://{isbn}` ist die schlichte Alltagsform. Der Platzhalter wird auf +den Parameter `isbn` abgebildet, sodass ein Client, der +`books://978-0441172719` liest, `get_book("978-0441172719")` aufruft. + +Ein einfaches `{name}` endet am ersten `/`. `books://978/extra` passt +nicht, weil der Schrägstrich nach `978` die Erfassung beendet und `/extra` +übrig bleibt. + +### Typkonvertierung {#type-conversion} + +Extrahierte Werte kommen als Strings an, aber du kannst einen genaueren +Typ deklarieren, und das SDK konvertiert. `orders://{order_id}` landet in +einer Funktion, deren Parameter `order_id: int` ist, sodass das Lesen von +`orders://12345` `get_order(12345)` aufruft, nicht `get_order("12345")`. Der +Handler rechnet damit (`order_id + 1`), ohne zu casten. + +### Mehrteilige Pfade: `{+name}` {#multi-segment-paths-name} + +Um einen Wert zu erfassen, der Schrägstriche enthält, verwende `{+name}`. Mit +`manuals://{+path}`: + +* `manuals://returns.md` ergibt `path = "returns.md"` +* `manuals://printing/setup.md` ergibt `path = "printing/setup.md"` + +Greif zu `{+name}`, wann immer der Wert hierarchisch ist: Dateisystempfade, +verschachtelte Objektschlüssel, URL-Pfade, die du als Proxy weiterreichst. + +### Query-Parameter: `{?a,b,c}` {#query-parameters-abc} + +`reviews://{isbn}{?limit,sort}` setzt `limit` und `sort` hinter das `?`. +Der Pfad bestimmt, *welches* Buch; die Query steuert, *wie* du es liest. + +Query-Parameter werden nachsichtig abgeglichen: Die Reihenfolge spielt keine +Rolle, zusätzliche werden ignoriert, und weggelassene fallen auf die +Standardwerte deiner Funktion zurück. `reviews://978-0441172719` verwendet +also `limit=10, sort="newest"`, und +`reviews://978-0441172719?sort=top` überschreibt nur `sort`. + +### Pfadsegmente als Liste: `{/name*}` {#path-segments-as-a-list-name} + +Wenn du jedes Pfadsegment als eigenes Listenelement haben willst statt als +einen String mit Schrägstrichen, verwende `{/name*}`. Mit +`shelves://browse{/path*}` ruft ein Client, der +`shelves://browse/fiction/sci-fi` liest, +`browse_shelf(["fiction", "sci-fi"])` auf. + +### Template-Referenz {#template-reference} + +Die häufigsten Muster: + +| Muster | Beispieleingabe | Du bekommst | +|--------------|-----------------------|-------------------------| +| `{name}` | `alice` | `"alice"` | +| `{name}` | `docs/intro.md` | *kein Treffer* (endet am `/`) | +| `{+path}` | `docs/intro.md` | `"docs/intro.md"` | +| `{.ext}` | `.json` | `"json"` | +| `{/segment}` | `/v2` | `"v2"` | +| `{?key}` | `?key=value` | `"value"` | +| `{?a,b}` | `?a=1&b=2` | `"1"`, `"2"` | +| `{/path*}` | `/a/b/c` | `["a", "b", "c"]` | + +### Was der Parser ablehnt {#what-the-parser-rejects} + +Einige Template-Formen werden vorab abgefangen, statt beim ersten Request +zu scheitern. `@mcp.resource` parst das Template, wenn der Dekorator läuft, +sodass keine davon je einen laufenden Server erreicht. + +`UriTemplate.parse()` löst `InvalidUriTemplate` aus bei: + +* **Zwei Variablen ohne etwas dazwischen.** `manuals://{+path}{ext}` + wird abgelehnt: Das Matching kann nicht erkennen, wo `path` endet und `ext` + beginnt. Setze ein Literal dazwischen (`manuals://{+path}/{ext}`) oder + verwende einen Operator, der seinen eigenen Trenner mitbringt. + `manuals://{+path}{.ext}` wird akzeptiert, weil `{.ext}` den `.` selbst + beisteuert. +* **Mehr als eine mehrteilige Variable.** Höchstens eines von `{+var}`, + `{#var}` oder einer explodierten Variable (`{/var*}`, `{.var*}`, `{;var*}`) + pro Template. Zwei sind grundsätzlich mehrdeutig: Es gibt keinen + begründbaren Weg zu entscheiden, welche ein zusätzliches Segment aufnimmt. +* **Den üblichen Syntaxfehlern**: eine nicht geschlossene geschweifte + Klammer, ein doppelt verwendeter Variablenname oder ein RFC-6570-Feature, + das das SDK nicht unterstützt, etwa der Präfix-Modifikator `{var:3}` oder + die Query-Explosion `{?vars*}`. + +Darüber hinaus löst `@mcp.resource` einen `ValueError` aus, wenn ein +Handler-Parameter an eine Query-Variable im abschließenden +`{?...}`/`{&...}`-Lauf des Templates gebunden ist, aber keinen +Python-Standardwert hat. Diese Variablen werden nachsichtig abgeglichen +(ein Client darf jede davon weglassen), sodass ein Parameter ohne +Standardwert erst beim ersten Request, der ihn weglässt, als +undurchsichtiger interner Fehler auftauchen würde. +`reviews://{isbn}{?limit,sort}` im Server oben ist die wohlgeformte +Variante: `limit` und `sort` tragen beide Standardwerte. + +## Sicherheit {#security} + +Template-Parameter kommen vom Client. Fließen sie ungeprüft in +Dateisystem- oder Datenbankoperationen, können Werte wie +`../../etc/passwd` außerhalb des Verzeichnisses landen, das du +bereitstellen wolltest. + +### Was das SDK standardmäßig prüft {#what-the-sdk-checks-by-default} + +Bevor dein Handler läuft, lehnt das SDK jeden Parameter ab, der: + +* sein Ausgangsverzeichnis über `..`-Komponenten verlassen würde +* wie ein absoluter Pfad aussieht (`/etc/passwd`, `C:\Windows`) oder wie + ein laufwerksrelativer Windows-Pfad (`C:foo`). Ein laufwerksrelativer + Wert und ein Bezeichner mit Namensraum wie `x:y` sind als Strings nicht + zu unterscheiden, daher wird standardmäßig jeder Wert aus einem einzelnen + Buchstaben plus Doppelpunkt abgelehnt; nimm den Parameter aus, wenn er + solche Werte legitim erhält +* ein Nullbyte (`\x00`) enthält + +Die `..`-Prüfung arbeitet komponentenbasiert, nicht als Teilstringsuche. +Werte wie `v1.0..v2.0` oder `HEAD~3..HEAD` kommen durch, weil `..` dort +kein eigenständiges Pfadsegment ist. + +Diese Prüfungen gelten für den dekodierten Wert, sie fangen Traversal also +unabhängig davon ab, wie es im URI kodiert war (`../etc`, `..%2Fetc`, +`%2E%2E/etc`, `..%5Cetc`, `%00` werden alle abgefangen). + +!!! check + Lies `manuals://../etc/passwd` vom Server oben, und der Request wird + rundweg abgelehnt: Das Template-Matching stoppt beim ersten Fehlschlag, + sodass kein späteres (womöglich großzügigeres) Template als Fallback + probiert wird. Der Client sieht denselben `-32602`-Fehler „Unknown + resource“ wie bei einem URI, der auf gar kein Template passt, und + `read_manual` läuft nie. + +### Dateisystem-Handler: safe_join verwenden {#filesystem-handlers-use-safe_join} + +Die eingebauten Prüfungen stoppen die häufigen Fälle, können aber deine +Sandbox-Grenze nicht kennen. Für Dateisystemzugriffe verwende `safe_join`, +um den Pfad aufzulösen und zu verifizieren, dass er innerhalb deines +Basisverzeichnisses bleibt: + +```python title="server.py" hl_lines="4 14" +--8<-- "docs_src/uri_templates/tutorial002.py" +``` + +`safe_join` fängt Symlink-Ausbrüche, `..`-Sequenzen und Tricks mit +absoluten Pfaden ab, die eine einfache Stringprüfung übersehen würde. +Verlässt der aufgelöste Pfad `DOCS_ROOT`, löst es `PathEscapeError` aus, +der beim Client als `ResourceError` ankommt. + +### Wenn die Standardwerte im Weg stehen {#when-the-defaults-get-in-the-way} + +Manchmal blockieren die Prüfungen legitime Werte. Ein Tool für den +Katalogimport könnte absichtlich einen absoluten Pfad erhalten, oder ein +Parameter könnte eine relative Referenz wie `../sibling` sein, die dein +Handler sicher interpretiert, ohne das Dateisystem anzufassen. Nimm diesen +Parameter aus oder lockere die Richtlinie für den ganzen Server: + +```python title="server.py" hl_lines="9 16-19" +--8<-- "docs_src/uri_templates/tutorial003.py" +``` + +* `security=ResourceSecurity(exempt_params={"source"})` am Dekorator + überspringt die Prüfungen für diesen einen Parameter auf dieser einen + Ressource. Der Rest des Servers behält die Standardrichtlinie. +* `resource_security=` am `MCPServer`-Konstruktor setzt den Standard + für jede Ressource. Hier schaltet `relaxed` die `..`-Prüfung ganz ab. + +Die konfigurierbaren Prüfungen: + +| Einstellung | Standardwert | Was sie tut | +|-------------------------|---------|-------------------------------------| +| `reject_path_traversal` | `True` | Lehnt `..`-Sequenzen ab, die das Ausgangsverzeichnis verlassen | +| `reject_absolute_paths` | `True` | Lehnt `/foo`, `C:\foo`, UNC-Pfade und laufwerksrelatives `C:foo` ab (fängt auch `x:y` ab) | +| `reject_null_bytes` | `True` | Lehnt Werte ab, die `\x00` enthalten | +| `exempt_params` | leer | Parameternamen, für die Prüfungen übersprungen werden | + +Diese Prüfungen sind ein heuristischer Vorfilter; für Dateisystemzugriffe +bleibt `safe_join` die Eindämmungsgrenze. + +!!! tip + Kann dein Handler den Request nicht erfüllen (die Datei existiert nicht, + die ID ist unbekannt), löse eine Exception aus. Das SDK macht daraus eine + Fehler-Response. Den Unterschied zwischen einem Protokollfehler und einem + Tool-Fehler erklärt **[Fehler behandeln](handling-errors.md)**. + +## Ressourcen auf dem Low-Level-Server {#resources-on-the-low-level-server} + +Wenn du auf dem Low-Level-`Server` aufbaust (siehe **[Der +Low-Level-Server](../advanced/low-level-server.md)**), registrierst du Handler für die +Protokollmethoden `resources/list` und `resources/read` direkt. Es gibt +keinen Dekorator; du gibst die Protokolltypen selbst zurück. + +### Statische Ressourcen {#static-resources} + +Für feste URIs führe eine Registry und verteile anhand exakter +Übereinstimmung: + +```python title="server.py" hl_lines="17 21 27" +--8<-- "docs_src/uri_templates/tutorial004.py" +``` + +Der List-Handler teilt Clients mit, was verfügbar ist; der Read-Handler +liefert den Inhalt. Prüfe zuerst deine Registry, falle auf Templates +(unten) zurück, falls du welche hast, und löse für alles andere eine +Exception aus. + +### Templates {#templates} + +Die Template-Engine, die `MCPServer` verwendet, liegt in +`mcp.shared.uri_template` und funktioniert eigenständig. Du bekommst +dasselbe Parsing und Matching; Routing und Sicherheitsrichtlinie +verdrahtest du selbst. + +```python title="server.py" hl_lines="13-16 22-25 29 33 45" +--8<-- "docs_src/uri_templates/tutorial005.py" +``` + +In den hervorgehobenen Zeilen passieren drei Dinge: + +* **Einmal parsen, pro Request matchen.** `UriTemplate.parse()` baut das + Template; `template.match(uri)` gibt die extrahierten Variablen als + `dict` zurück, oder `None`, wenn der URI nicht passt. Die URL-Dekodierung + geschieht innerhalb von `match()`; die dekodierten Werte werden unverändert + zurückgegeben, ohne Pfadsicherheitsprüfung. Die Werte kommen als Strings + heraus: Konvertiere sie selbst + (`int(matched["id"])`, `Path(matched["path"])`). +* **Die Sicherheitsprüfungen selbst anwenden.** Die `..`- und + Absolutpfad-Prüfungen, die `MCPServer` standardmäßig ausführt, liegen in + `mcp.shared.path_security`. `read_manual_safely` ruft sie auf, bevor es + `MANUALS` anfasst. Ist ein Parameter kein Dateisystempfad (eine ISBN, eine + Suchanfrage), überspring die Prüfungen für diesen Wert: Du steuerst die + Richtlinie pro Handler statt über ein Konfigurationsobjekt. +* **Die Templates aus derselben Quelle auflisten.** Clients entdecken + Templates über `resources/templates/list`. `str(template)` gibt den + ursprünglichen Template-String zurück, sodass Auflistung und Matcher + eine einzige Quelle der Wahrheit teilen. + +## Zusammenfassung {#recap} + +* `{name}` passt auf ein Segment; `{+name}` behält die Schrägstriche; + `{?a,b}` zieht aus dem Query-String; `{/name*}` teilt Segmente in eine + Liste auf. +* Zwei Variablen ohne etwas dazwischen oder eine zweite mehrteilige + Variable werden beim Parsen abgelehnt. Ein Parameter, der an eine + abschließende `{?...}`/`{&...}`-Query-Variable gebunden ist, muss einen + Python-Standardwert deklarieren. +* Annotiere den Parameter (`order_id: int`), und das SDK konvertiert. +* Die Standard-Sicherheitsrichtlinie lehnt `..`, absolute Pfade und + Nullbytes ab, bevor dein Handler läuft; überschreibe sie pro Ressource + mit `security=ResourceSecurity(...)` oder serverweit mit + `resource_security=`. +* Für Dateisystemzugriffe ist `safe_join` die Eindämmungsgrenze. +* Auf dem Low-Level-`Server` parst du mit `UriTemplate.parse()`, matchst + mit `.match()` und wendest `mcp.shared.path_security` selbst an. diff --git a/i18n/de/pages/translations.md b/i18n/de/pages/translations.md new file mode 100644 index 0000000000..fa02355173 --- /dev/null +++ b/i18n/de/pages/translations.md @@ -0,0 +1,30 @@ +--- +translation: + sections: [f671b445b16e4f99, 3983a560eb2cece7, 004b3ee918529d8c, c6e2debf1da06eb7, 81d412ed5f399f94] + tool: 1 +--- +# Übersetzungen {#translations} + +Diese Dokumentation ist auf Englisch verfasst. Damit sie mehr Menschen nützt, veröffentlichen wir zusätzlich maschinell übersetzte Ausgaben davon. Diese Seite erklärt, was das für dich bedeutet und wie du helfen kannst, sie zu verbessern. + +## Was verfügbar ist {#whats-available} + +Die übersetzte Dokumentation ist derzeit eine **Vorschau** in zwölf Sprachen: Deutsch, español, français, हिन्दी, 日本語, 한국어, português (Brasil), русский язык, Türkçe, українська мова, 简体中文 und 繁體中文. Wähle eine über die Sprachauswahl oben auf jeder Seite. Weitere Sprachen können folgen, sobald sich diese bewährt haben. + +Die API-Referenz wird nicht übersetzt: Die übersetzte Website verlinkt auf die eine englische Fassung. + +## Maßgeblich ist die englische Fassung {#english-is-the-source-of-truth} + +Wenn eine übersetzte Seite und ihr englisches Original voneinander abweichen, gilt die englische Seite. Jede Seite einer übersetzten Website beginnt mit einem von drei Hinweisen, der ihren Stand angibt: + +- **Maschinelle Übersetzung** – die Seite wurde automatisch übersetzt und verlinkt auf ihr englisches Original. +- **Übersetzung hinter der englischen Seite zurück** – das englische Original hat sich geändert, nachdem die Seite übersetzt wurde; Teile davon können also veraltet sein, bis die Übersetzung nachzieht. +- **Auf Englisch angezeigt** – es gibt keine aktuelle Übersetzung der Seite, deshalb liest du den englischen Text. + +## Wie die Übersetzungen entstehen {#how-the-translations-are-made} + +Übersetzte Seiten erzeugt ein Tool in diesem Repository maschinell aus den englischen Seiten unter `docs/`, gesteuert von zwei von Menschen geschriebenen Vorgaben pro Sprache: einem Styleguide (Anrede, Tonfall, Typografie, Umgang mit Witzen und Redewendungen) und einem Glossar (welche Begriffe auf Englisch bleiben sowie die vorgeschriebenen und verbotenen Wiedergaben für den Rest). Der erzeugte Text wird nie von Hand bearbeitet. Jede Verbesserung fließt stattdessen in diese Vorgaben ein, damit sie die nächste Neuerzeugung der Seiten übersteht. + +## Ein Übersetzungsproblem melden {#reporting-a-translation-problem} + +Einen falschen Begriff, einen holprigen Satz oder eine Übersetzung gefunden, die etwas anderes sagt als das Englische? [Eröffne ein Issue](https://github.com/modelcontextprotocol/python-sdk/issues) mit der Sprache, der Seite und der Textstelle; Meldungen von Menschen mit der jeweiligen Muttersprache sind besonders wertvoll. Wenn du die Korrektur kennst, schlage sie direkt als Pull Request gegen den Styleguide (`instructions.md`) oder das Glossar (`glossary.json`) der jeweiligen Sprache unter [`i18n/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/i18n) vor – die Korrektur erreicht dann jede betroffene Seite, sobald die Übersetzungen das nächste Mal neu erzeugt werden. Probleme mit dem englischen Text selbst werden in den Seiten unter `docs/` behoben, wie jede andere Änderung an der Dokumentation. diff --git a/i18n/de/pages/troubleshooting.md b/i18n/de/pages/troubleshooting.md new file mode 100644 index 0000000000..3ed6eb24e5 --- /dev/null +++ b/i18n/de/pages/troubleshooting.md @@ -0,0 +1,422 @@ +--- +translation: + sections: [2efaecdef109a5c5, fcacd3e66b8635a4, 25323d737dcf0261, 4835ed1772f1d113, 137454d469c867f5, 6392596bd6df54f0, 41126fa9c4fe432f, 480b6d7897e30ab4, d83bb682e708dde0, ebbed3449c499db4, 323ef84f6b4bebde, 30fd31be74169d9a, 656943c6cb567218, c2dc3b1007d2e987, 7cf5386b997d04e9, 0b59feed8384456e, 0cba47bae78d04eb, 954dc21efdb532a3] + tool: 1 +--- +# Fehlerbehebung {#troubleshooting} + +Jede Überschrift auf dieser Seite ist der exakte Text eines Fehlers, den das SDK erzeugt, gefolgt davon, was er bedeutet, und der Lösung in einem Schritt. Suche die letzte Zeile deines Tracebacks (oder deines Server-Logs) hier mit der Seitensuche des Browsers und lies nur diesen Eintrag. + +Mehrere Einträge laufen gegen diesen einen Server: ein Tool und eine Ressource mit Template, die beide bei einer Stadt, die sie nicht kennen, eine Exception auslösen: + +```python title="server.py" +--8<-- "docs_src/troubleshooting/tutorial001.py" +``` + +Die Fehler, die diese Seite zitiert, sind echt: Die Testsuite des SDK selbst reproduziert jeden einzelnen. + +## `ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)` {#exceptiongroup-unhandled-errors-in-a-taskgroup-1-sub-exception} + +Das ist kein MCP-Fehler. Es ist Rauschen von anyio, und dein eigentlicher Fehler steht in der **letzten Zeile** der Ausgabe. + +`Client.__aenter__` startet eine Task-Group. anyio verpackt alles, was eine Task-Group verlässt, in eine `ExceptionGroup`. Deshalb kommt *jede* Exception, die einen `async with Client(...)`-Block verlässt – egal welche –, in einer solchen an: + +```python +async def main() -> None: + async with Client(mcp) as client: + await client.read_resource("weather://Atlantis") +``` + +```text + + Exception Group Traceback (most recent call last): + | ... + | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception) + +-+---------------- 1 ---------------- + | Exception Group Traceback (most recent call last): + | ... + | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception) + +-+---------------- 1 ---------------- + | Traceback (most recent call last): + | ... + | mcp.shared.exceptions.MCPError: No forecast for 'Atlantis'. + +------------------------------------ +``` + +Damit machst du zwei Dinge: + +1. **Unten lesen.** `MCPError: No forecast for 'Atlantis'.` ist der Fehler; suche *dessen* Text auf dieser Seite. +2. **Im Block abfangen.** Die `ExceptionGroup` erscheint nur, wenn die Exception das `async with` *verlässt*. Fängst du sie innerhalb ab, ist derselbe Fehler die schlichte `MCPError`, ganz ohne Gruppe: + +```python +async def main() -> None: + async with Client(mcp) as client: + try: + await client.read_resource("weather://Atlantis") + except MCPError as e: + print(e) # No forecast for 'Atlantis'. +``` + +!!! tip + Ein Fehler beim *Verbindungsaufbau* (eine falsche URL, ein Server, der nicht läuft, der `421` + weiter unten auf dieser Seite) entweicht aus dem `async with` selbst, es gibt also kein + „Innen“, in dem du ihn abfangen könntest. Lies in diesen Fällen das Ende der Gruppe. + +## `RuntimeError: Client must be used within an async context manager` {#runtimeerror-client-must-be-used-within-an-async-context-manager} + +`Client(...)` baut nur das Objekt. Verbunden wird erst mit `async with`, deshalb verweigert jede Methode den Dienst: + +```python +async def main() -> None: + client = Client(mcp) + tools = await client.list_tools() # RuntimeError +``` + +Betritt den Kontextmanager. `__aenter__` ist die Verbindung: + +```python +async def main() -> None: + async with Client(mcp) as client: + tools = await client.list_tools() +``` + +`__aexit__` ist die Trennung – deshalb gibt es kein `client.close()`, das du vergessen könntest. **[Testen](get-started/testing.md)** baut genau auf diesem Muster auf. + +## `Error executing tool : ` und `Unknown tool: ` {#error-executing-tool-name-message-and-unknown-tool-name} + +Du liest ein **Ergebnis**, keine Exception. `call_tool` hat nichts ausgelöst und wird das bei einem fehlschlagenden Tool auch nie tun. + +Rufe `forecast` für eine Stadt auf, die der Server nicht kennt, und die Exception, die es auslöst, kommt zurück, während der Request als *erfolgreich* markiert ist: + +```python +result.is_error # True +result.content # [TextContent(text="Error executing tool forecast: No forecast for 'Atlantis'.")] +result.structured_content # None +``` + +`Unknown tool: get_forecast` hat dieselbe Form bei einem Namen, den der Server nie registriert hat, und ein ungültiges Argument wird genauso abgewiesen – anhand des Eingabeschemas des Tools, bevor deine Funktion überhaupt läuft. + +Die Lösung liegt in deinem Client: **Prüfe `result.is_error`.** Ein `try/except` um `call_tool` fängt nichts davon ab, weil es nichts abzufangen gibt. Das ist Absicht, und es ist das Nützlichste auf dieser Seite, das du verinnerlichen solltest: Das *Modell* hat den Aufruf gewählt, also bekommt das Modell die Meldung und eine Chance, es erneut zu versuchen. Alles Weitere steht in **[Fehler behandeln](servers/handling-errors.md)**, einschließlich des `MCPError`-Pfads, der *tatsächlich* eine Exception auslöst. + +## `TypeError: The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool` {#typeerror-the-tool-decorator-was-used-incorrectly-did-you-forget-to-call-it-use-tool-instead-of-tool} + +Du hast `@mcp.tool` statt `@mcp.tool()` geschrieben. `tool()` ist eine Dekorator-*Fabrik*: Ohne die Klammern übergibt Python deine Funktion an deren Parameter `name=`. + +```python +@mcp.tool # <- missing () +def forecast(city: str) -> str: + """Today's forecast for one city.""" + return f"{city}: Rain." +``` + +```text +TypeError: The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool +``` + +Füge die Klammern hinzu. `@mcp.resource(...)` und `@mcp.prompt()` melden dasselbe beim selben Ausrutscher. + +!!! note + Das wird beim **Import** des Moduls ausgelöst, bevor sich ein Client verbindet. Ein Host, der + deinen Server als *Start fehlgeschlagen* (oder *getrennt*) anzeigt statt als verbunden mit null + Tools, hat also diese Form: Führe `python server.py` selbst aus und lies den Traceback. Ein + Type-Checker fängt es ebenfalls ab: Eine Funktion ist kein gültiges `name=`. + +## `Tool already exists: ` {#tool-already-exists-name} + +Zwei Registrierungen haben denselben Tool-Namen verwendet. Die **erste** gewinnt, die zweite wird stillschweigend verworfen, und diese Warnung im *Server-Log* ist das einzige Signal: + +```python title="server.py" hl_lines="6 12" +--8<-- "docs_src/troubleshooting/tutorial002.py" +``` + +```text +WARNING mcp.server.mcpserver.tools.tool_manager: Tool already exists: forecast +``` + +`tools/list` meldet ein `forecast`, und zwar `forecast_today`. Benenne eines der beiden um. `MCPServer(..., warn_on_duplicate_tools=False)` unterdrückt die Warnung, ohne das Ergebnis zu ändern, lass sie also eingeschaltet. Für Ressourcen und Prompts gilt dieselbe Regel mit derselben Log-Zeile (`Resource already exists:`, `Prompt already exists:`). + +## Mein Host listet keine Tools auf {#my-host-lists-zero-tools} + +Dafür gibt es keinen Fehlertext, und genau deshalb ist es schwer zu suchen. Das SDK entfernt nie ein registriertes Tool aus `tools/list`, arbeite dich also von innen nach außen vor: + +* **Ist der Server überhaupt gestartet?** `@mcp.tool` ohne Klammern löst beim Import aus, und ein abgestürzter Server sieht in manchen Hosts einem leeren sehr ähnlich. Führe `python server.py` selbst aus. +* **Hängt das Tool an dem `mcp`, das der Host ausführt?** Ein zweites `MCPServer(...)` in einem anderen Modul ist ein anderer, leerer Server. Prüfe, welches Objekt der Befehl des Hosts tatsächlich importiert. +* **Teilen sich zwei Tools einen Namen?** Dann ist eines davon weg. Suche im Server-Log nach `Tool already exists:`. +* **Ist die Liste des Hosts nicht mehr aktuell?** Ein Tool, das nach dem Start hinzugefügt wird, erreicht nur Clients, die `notifications/tools/list_changed` verarbeiten. Den Host neu zu starten ist die grobe Lösung. +* **Hat etwas außerhalb des umgeleiteten Fensters nach `stdout` geschrieben?** Während des Betriebs leitet das SDK *geflushte* verirrte stdout-Ausgaben nach stderr um (nach bestem Bemühen: Eine Umgebung, die die Standard-Streams ersetzt, wird unverändert bedient). Ausgaben, die früher nach stdout geflusht wurden (ein Wrapper-Skript mit echo, ein `print()` beim Import in einem ungepufferten Prozess), oder ein gepuffertes `print()`, das beim Beenden des Interpreters geleert wird, landen aber auf dem Protokoll-Stream, und eine einzige Müllzeile kann den Host dazu bringen, die Verbindung zu schließen – was manche Hosts als Server ohne Inhalt darstellen. Logge stattdessen mit dem Modul `logging`. Der Rest der Checkliste auf Host-Seite steht auf **[Mit einem echten Host verbinden](get-started/real-host.md)**. + +Ein „ungültiger“ Tool-Name steht *nicht* auf dieser Liste: Ein nicht konformer Name loggt eine Warnung, aber das Tool wird trotzdem registriert und aufgelistet. + +## `MCPError: Server returned an error response` {#mcperror-server-returned-an-error-response} + +Der Server hat den HTTP-Request rundheraus abgelehnt, mit einem Body, der kein JSON-RPC ist, sodass der Python-`Client` dir nichts Besseres zeigen kann als diesen Platzhalter. + +Die mit Abstand häufigste Ursache ist ein frisch bereitgestellter Streamable-HTTP-Server. `streamable_http_app()` (und `mcp.run("streamable-http")`) ohne `transport_security=` verwendet standardmäßig den **DNS-Rebinding-Schutz**: Es werden nur Requests akzeptiert, deren `Host`-Header localhost ist. Das ist der richtige Standardwert auf deinem Laptop und der falsche hinter einem echten Hostnamen: + +```python title="server.py" hl_lines="12" +--8<-- "docs_src/troubleshooting/tutorial003.py" +``` + +Stelle das bereit, richte einen Client darauf, und die Verbindung scheitert beim Handshake: + +```python +async with Client("https://mcp.example.com/mcp") as client: + ... +``` + +```text +mcp.shared.exceptions.MCPError: Server returned an error response +``` + +Die Wörter, die der Server tatsächlich gesendet hat, `421` und `Invalid Host header`, erreichen dich nie: Der 421-Body hat kein `Content-Type: application/json`, also kann der Client ihn nicht parsen. Sie stehen im **Log des Servers**, und dort schaust du als Nächstes nach: + +```text +WARNING mcp.server.transport_security: Invalid Host header: mcp.example.com +``` + +Die Lösung ist `transport_security=`. Setze den Hostnamen, den du tatsächlich bedienst, auf die Allowlist: + +```python title="server.py" hl_lines="14-17" +--8<-- "docs_src/troubleshooting/tutorial004.py" +``` + +!!! check + Das ist die ganze Änderung. Derselbe Client verbindet sich jetzt, handelt `2026-07-28` aus + und ruft `forecast` auf. + +**[Bereitstellen und skalieren](run/deploy.md)** erklärt, was jedes Feld bedeutet, den Fall mit Reverse-Proxy und alles andere, was sich beim Bereitstellen ändert. Und `421 Misdirected Request` / `Invalid Host header` direkt darunter ist derselbe Fehler, von der anderen Seite gesehen. + +## `421 Misdirected Request` / `Invalid Host header` {#421-misdirected-request-invalid-host-header} + +Das ist `Server returned an error response`, gesehen von allem, was *nicht* der Python-`Client` ist: curl, der Netzwerk-Tab eines Browsers, das Access-Log eines Reverse-Proxys oder ein anderes SDK. + +```bash +curl -i https://mcp.example.com/mcp \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}' +``` + +```text +HTTP/1.1 421 Misdirected Request + +Invalid Host header +``` + +`421 Misdirected Request` ist HTTPs eigene Reason-Phrase für den Status; `Invalid Host header` ist der Response-Body des SDK; und der Python-`Client` stellt dasselbe Ereignis als `Server returned an error response` dar. Alle drei sind eine einzige Ablehnung. Die Prüfung läuft gegen den **`Host`-Header, den der Request trägt**, nicht gegen die Adresse, an die der Server gebunden ist. Ein Reverse-Proxy, der den öffentlichen Hostnamen weiterleitet, löst sie also genauso aus wie ein direkter Client. + +Die Lösung ist dasselbe `transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])` wie unter `Server returned an error response`. Zwei Randfälle sind erwähnenswert: + +* Ein `allowed_hosts`-Eintrag ist ein exakter String. `"mcp.example.com"` passt auf einen `Host`-Header ohne Port und `"mcp.example.com:*"` auf jeden expliziten Port. Führe beide auf. +* Ein `403` mit dem Body `Invalid Origin header` ist die verwandte Prüfung des `Origin`-Headers. Sie greift nur bei Browsern (nichts anderes sendet `Origin`), und `allowed_origins=` ist ihre Allowlist. + +Alles Weitere steht in **[Bereitstellen und skalieren](run/deploy.md)**, auch dazu, wann das Abschalten der Prüfung die ehrliche Konfiguration ist. + +## `RuntimeError: Task group is not initialized. Make sure to use run().` {#runtimeerror-task-group-is-not-initialized-make-sure-to-use-run} + +Deine MCP-App ist in eine andere ASGI-App eingehängt, und nichts hat ihren **Session-Manager** gestartet. + +`mcp.streamable_http_app()` gibt eine Starlette-App zurück, deren eigener Lifespan (Start- und Stopp-Phase) den Manager startet, und `uvicorn server:app` führt diesen Lifespan für dich aus. Aber Starlette **führt nie den Lifespan einer eingehängten Sub-App aus**. Sobald die App also in einem `Mount` steckt, startet der Manager nie, und der erste Request fliegt dir um die Ohren: + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/troubleshooting/tutorial005.py" +``` + +Der Server startet. Die Route wird aufgelöst. Dann gibt `uvicorn` bei jedem Request Folgendes aus: + +```text +ERROR: Exception in ASGI application +Traceback (most recent call last): + ... +RuntimeError: Task group is not initialized. Make sure to use run(). +``` + +Der Client sieht einen 500. Die Lösung ist ein Lifespan auf der **Host**-App, der `mcp.session_manager.run()` betritt: + +```python +@asynccontextmanager +async def lifespan(app: Starlette) -> AsyncIterator[None]: + async with mcp.session_manager.run(): + yield + + +app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lifespan) +``` + +**[In eine bestehende App einbinden](run/asgi.md)** ist die Seite dazu, einschließlich mehrerer Server in einer App und FastAPI. Zwei benachbarte Strings aus derselben Klasse: + +* `StreamableHTTPSessionManager .run() can only be called once per instance. Create a new instance if you need to run again.` Der Manager ist zum einmaligen Gebrauch; wer den Lifespan derselben App zweimal betritt, trifft darauf. +* `mcp.session_manager` existiert erst, **nachdem** `streamable_http_app()` aufgerufen wurde. Baue also zuerst die Routen und fasse den Manager nur innerhalb des Lifespans an. + +## `MCPError: Session not found` {#mcperror-session-not-found} + +Der Server erkennt die `Mcp-Session-Id`, die dein Client gesendet hat, nicht – fast immer, weil der Server **neu gestartet** wurde (oder du zu einer anderen Instanz geroutet wurdest). Sessions leben im Speicher dieses einen Prozesses. + +Es gibt keinen Server-Bug zu finden. Die HTTP-Response ist ein `404`, dessen Body JSON-RPC *ist*, deshalb zeigt dir der Python-`Client` – anders als beim `421` oben – diese hier wörtlich: + +```json +{"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "Session not found"}} +``` + +Die Lösung ist, dich neu zu verbinden: Verlasse den `async with Client(...)`-Block und betritt einen neuen, der eine frische Session aushandelt. Für einen langlebigen Client heißt das, `MCPError` um deine Aufrufe herum abzufangen und bei dieser Meldung neu zu verbinden, statt es in einer toten Session erneut zu versuchen. + +Passiert es *ohne* Neustart, betreibst du mehr als einen Worker ohne Sticky Sessions: Jeder Worker hält seine eigene Session-Tabelle, sodass ein Request, der zum falschen geroutet wird, hier landet. **[Bereitstellen und skalieren](run/deploy.md)** und **[Legacy-Clients unterstützen](run/legacy-clients.md)** behandeln dieses Thema und seine beiden Lösungen (Sticky Routing oder `stateless_http=True`). + +Für alle, die den Server betreiben, lautet die passende Log-Zeile `Rejected request with unknown or expired session ID: `. Sie wird auf `INFO` geloggt, ist also bei der üblichen `WARNING`-Schwelle unsichtbar. Sie direkt nach einem Deployment stoßweise zu sehen ist normal; jeder verbundene Client verbindet sich neu. + +## `MCPError: Method not found` {#mcperror-method-not-found} + +Eine Seite hat einen JSON-RPC-Request gesendet, für den die andere keinen Handler hat, und `e.error.data` nennt die Methode. Die übliche Ursache sind **unterschiedliche Protokollgenerationen**: eine Methode, die in einer Protokollrevision existiert und in der anderen nicht, gesendet an ein Gegenüber auf der falschen – etwa ein `resources/subscribe` der `2025`er-Generation, das auf einer `2026-07-28`-Verbindung ankommt, oder ein nur in `2026` vorhandenes `subscriptions/listen`, gesendet von einem Client, der auf `mode="legacy"` festgelegt ist. **[Protokollversionen](protocol-versions.md)** ist die Karte, welche Seite was spricht, und die andere ehrliche Ursache (eine optionale Capability, für die du nie einen Handler registriert hast) steht auf **[Vervollständigungen](servers/completions.md)**. + +Eines erzeugt diesen Fehler **nicht**, obwohl es ein Request ist, den das moderne Protokoll entfernt hat: ein Tool, das `ctx.elicit()` auf einer `2026-07-28`-Verbindung aufruft. Der Server weigert sich, diesen Request überhaupt zu *senden*, sodass du stattdessen `Cannot send 'elicitation/create': ...` bekommst, weiter unten auf dieser Seite. + +## `MCPError: Client did not declare the form elicitation capability required by resolver ''` {#mcperror-client-did-not-declare-the-form-elicitation-capability-required-by-resolver-name} + +Dein Server möchte die Person am Host etwas fragen, und dieser Client hat nie gesagt, dass man ihn fragen kann. + +Ein Resolver für Elicitation (Rückfrage bei der Person am Host) lehnt von vornherein ab, wenn der verbundene Client keine Form-Elicitation deklariert hat, und `e.error.data` nennt genau, was fehlt: + +```json +{ + "code": -32021, + "message": "Client did not declare the form elicitation capability required by resolver 'server:ask_to_confirm'", + "data": {"requiredCapabilities": {"elicitation": {"form": {}}}} +} +``` + +Übergib `elicitation_callback=` an `Client(...)`. Den Callback zu registrieren *ist* die Deklaration der Capability; einen zweiten Schalter gibt es nicht: + +```python +async def main() -> None: + async with Client(mcp, elicitation_callback=handle_elicitation) as client: + result = await client.call_tool("book_table", {"date": "Friday"}) +``` + +**[Client-Callbacks](client/callbacks.md)** listet die anderen auf (`sampling_callback`, `list_roots_callback`), von denen jeder auf dieselbe Weise eine Deklaration ist. + +!!! info + `-32021` ist `MISSING_REQUIRED_CLIENT_CAPABILITY`, einer von drei Fehlercodes, die die + Spezifikation 2026-07-28 hinzufügt. Keiner davon ist eine Exception-Klasse: Alle kommen als + `MCPError` an, und `e.error.code` ist die Stelle zum Nachsehen. `mcp.types` exportiert die + Konstanten. Die beiden anderen sind `-32020` `HEADER_MISMATCH` (ein HTTP-Header widerspricht + dem Request-Body, den er begleitet) und `-32022` `UNSUPPORTED_PROTOCOL_VERSION` (der Request + nannte eine Version, die dieser Server nicht spricht). Ein konformer SDK-Client kann keinen von + beiden erzeugen; siehst du einen, schau dir an, was zwischen deinem Client und deinem Server + Requests umschreibt. + +## `MCPError: Elicitation not supported` {#mcperror-elicitation-not-supported} + +Dieselbe Lücke wie bei `Client did not declare the form elicitation capability ...`, formuliert von den Pfaden, die nicht vorab prüfen: Der Server brauchte eine Antwort auf eine Elicitation, und der verbundene Client hat keinen `elicitation_callback` registriert. + +Du siehst diese bei `ctx.elicit()` auf einer Legacy-Verbindung, und auf jeder beliebigen Verbindung bei einer zurückgegebenen Multi-Roundtrip-Frage (multi-round-trip, **[Multi-Roundtrip-Requests](handlers/multi-round-trip.md)**), die einen Client ohne Callback zum Beantworten erreicht. Die Lösung ist identisch: Übergib `elicitation_callback=` an `Client(...)`. Es gibt keine Variante von „die Person wurde nicht gefragt“, die dein Tool als `decline` erhält; ein Client, der nicht gefragt werden kann, ist ein fehlgeschlagener Aufruf, also entwirf deine Tools entsprechend. + +## `MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests.` {#mcperror-cannot-send-elicitationcreate-this-transport-context-has-no-back-channel-for-server-initiated-requests} + +Dein Handler hat versucht, den Client mitten im Request zu erreichen, auf einer Verbindung, deren Aufruf keinen Rückkanal (back-channel) hat, der einen Request vom Server tragen kann. Drei Server-Konfigurationen bringen einen Aufruf in diese Lage. + +**Eine `2026-07-28`-Verbindung: jeder Transport, immer.** Das moderne Protokoll kennt überhaupt keine vom Server initiierten Requests, deshalb weigert sich der Server, bevor irgendetwas gesendet wird. `ctx.elicit()` innerhalb eines Tools ist der klassische Weg, dem zu begegnen (schon beim allerersten In-Memory-Test, denn `Client(server)` handelt ungefragt `2026-07-28` aus), und `elicitation_callback=` zu übergeben ändert nichts, weil nie ein Request beim Client ankommt, den er beantworten könnte: + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/troubleshooting/tutorial006.py" +``` + +```python +async def main() -> None: + async with Client(mcp) as client: + await client.call_tool("book_table", {"date": "Friday"}) +``` + +```text +mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests. +``` + +**Eine Legacy-Verbindung auf einem Server mit `stateless_http=True`.** Zustandslosigkeit heißt, jeder Request ist seine eigene Welt: keine Session, kein Stream vom Server zum Client und damit nichts, wohin ein `elicitation/create` (oder `sampling/createMessage` oder `roots/list`) gesendet werden könnte – selbst in der Generation, die sie kennt: + +```python title="server.py" hl_lines="16 23" +--8<-- "docs_src/troubleshooting/tutorial008.py" +``` + +**Eine Legacy-Verbindung auf einem Server mit `json_response=True`.** Das `POST` wird mit einem einzigen JSON-Body beantwortet, und ein einziger Body trägt nur die Response, sodass der Request-gebundene Stream, den ein `ctx.elicit()` mitten im Request braucht, auch hier nicht existiert. Die Session, ihre `Mcp-Session-Id` und ihr eigenständiger Stream sind alle noch da; nur der Request-gebundene Kanal fehlt. + +Die Meldung nennt die Methode, die sie nicht senden konnte. `NoBackChannelError` ist die Klasse, die der Server auslöst, aber über die Leitung geht nur die Basisklasse `MCPError`, sodass der Satz oben die letzte Zeile deines Tracebacks ist, nicht der Klassenname. + +Für einen `2026-07-28`-Client ist die Lösung in allen drei Fällen dieselbe: Greife nicht mitten im Aufruf zurück. Verschiebe die Frage in einen **Resolver** (oder gib selbst ein `InputRequiredResult` zurück), und sie wird Teil der *Response*, die jede Verbindung tragen kann: + +```python title="server.py" hl_lines="15-17 21" +--8<-- "docs_src/troubleshooting/tutorial007.py" +``` + +Dieselbe Frage, derselbe `elicitation_callback` auf dem Client. Der Unterschied liegt unter der Haube: Mit einem Resolver kann der Server die Frage aus dem Aufruf *zurückgeben*, statt sie zu pushen, sodass nie etwas vom Server zum Client fließt. Das rettet jeden `2026-07-28`-Client, in welcher der drei Konfigurationen sich der Server auch befindet. Ein *Legacy*-Client wird durch die Umschreibung allein nicht gerettet: `2025-11-25` hat keine Möglichkeit, eine Frage zurückzugeben, also sendet der Resolver auf einer Legacy-Verbindung weiterhin `elicitation/create` über den Request-gebundenen Kanal und braucht weiterhin einen Server, der ihn behält – weder `stateless_http=True` noch `json_response=True`. **[Elicitation](handlers/elicitation.md)** behandelt Resolver; **[Multi-Roundtrip-Requests](handlers/multi-round-trip.md)** behandelt, was auf der Leitung passiert. + +!!! check + Das Tool mit `ctx.elicit()` ist nicht falsch, es ist *vor 2026*. Verbinde dich mit + `mode="legacy"` (der klassische `initialize`-Handshake, Spezifikation `2025-11-25` und früher) + mit einem Server, der weder `stateless_http=True` noch `json_response=True` ist, und es + funktioniert, weil der Kanal vom Server zum Client dort existiert. + **[Protokollversionen](protocol-versions.md)** ist die Seite dazu, was jede Version hat. + +## `MCPError: Invalid or expired requestState` {#mcperror-invalid-or-expired-requeststate} + +Der Server konnte das `requestState`-Token, das dein Client zurückgespielt hat, nicht verifizieren und hat die Runde deshalb abgelehnt. + +`requestState` ist das opake Resume-Token, das ein **[Multi-Roundtrip](handlers/multi-round-trip.md)**-Aufruf zwischen den Etappen trägt. `MCPServer` versiegelt es auf dem Weg nach draußen und verifiziert jedes Echo, und er verifiziert *jedes* eingehende `request_state` bei `tools/call`, `prompts/get` und `resources/read`, selbst für einen Handler, der nie eines ausstellt. Ein Token, das dieser Prozess nicht versiegelt hat, wird also abgelehnt, wo immer es landet: + +```python +async def main() -> None: + async with Client(mcp) as client: + await client.call_tool("forecast", {"city": "London"}, request_state="round-1-from-worker-a") +``` + +```text +mcp.shared.exceptions.MCPError: Invalid or expired requestState +``` + +Die Meldung ist absichtlich festgeschrieben: Die Leitung verrät nie, welche Prüfung fehlgeschlagen ist. Der Grund geht ins **Server-Log**, und ihn zu lesen ist die ganze Diagnose: + +```text +WARNING mcp.server.request_state: requestState rejected on tools/call: malformed +``` + +Die Gründe, die du tatsächlich sehen wirst: + +* **`unknown key`** ist der, auf den es ankommt. Der Standardschlüssel zum Versiegeln wird beim Prozessstart erzeugt, also wurde ein Retry, der auf einem **anderen Worker**, einer anderen Instanz hinter einem Load Balancer oder demselben Server **nach einem Neustart** landet, unter einem Schlüssel versiegelt, den dieser Prozess nie hatte. Das ist kein Angreifer; das ist der Standardwert, der auf mehr als einen Prozess trifft. +* **`audience`**: Das Token wurde von einer Instanz mit einem *anderen Servernamen* versiegelt. Der Name ist der standardmäßige Audience-Claim des Siegels, also muss eine Flotte neben den Schlüsseln auch den Namen teilen (oder ein explizites `RequestStateSecurity(audience=...)` setzen). +* **`expired`**: Die Runde hat länger gedauert als die `ttl` des Siegels, die 600 Sekunden beträgt und pro Runde gilt, nicht pro Aufruf. +* **`malformed`** / **`codec error`**: Das Token wurde unterwegs verändert oder war nie ein versiegeltes Token. +* **`request binding`**: Das Token kam mit einem anderen Tool, anderen Argumenten oder einer anderen Methode zurück. + +Die Lösung für mehrere Prozesse ist ein Argument (die*selben* `keys` auf jeder Instanz) plus etwas, das gar kein Argument ist: derselbe Server*name* (oder ein explizites gemeinsames `audience=`). + +```python +mcp = MCPServer("Weather", request_state_security=RequestStateSecurity(keys=[key])) +``` + +`keys[0]` versiegelt; jeder Schlüssel in der Liste verifiziert, und genau das ermöglicht Rotation ohne Ausfallzeit. **[Multi-Roundtrip-Requests](handlers/multi-round-trip.md#protecting-requeststate)** erklärt, was das Siegel schützt, und die Rotationsabfolge, und **[Bereitstellen und skalieren](run/deploy.md)** geht den ganzen Fehlerfall mit zwei Workern und seine zweiteilige Lösung durch. + +!!! tip + `keys=[...]` lehnt einen schwachen Schlüssel sofort ab, mit einer ungewöhnlich hilfreichen + Meldung: + + ```text + ValueError: request-state keys must be at least 32 bytes of secret randomness; keys[0] is 7 bytes. Generate one with: python -c "import secrets; print(secrets.token_hex(32))" + ``` + + Tu, was sie sagt. + +## Kommst du nicht weiter? {#still-stuck} + +* Steht eine Meldung, die das SDK erzeugt hat, nicht auf dieser Seite, ist das ein Dokumentationsfehler, der für sich genommen eine Meldung wert ist. +* Durchsuche den [Issue-Tracker](https://github.com/modelcontextprotocol/python-sdk/issues); die meisten Fehlertexte, die dort auftauchen, hat schon jemand aufgeschrieben. +* Nichts gefunden? [Eröffne ein Issue](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml) mit dem vollständigen Traceback oder frage in [#python-sdk-dev auf dem MCP-Contributors-Discord](https://discord.gg/6CSzBmMkjX). + +## Zusammenfassung {#recap} + +* `ExceptionGroup: unhandled errors in a TaskGroup` ist nie der Fehler. Lies die **letzte Zeile**; fängst du `MCPError` *innerhalb* des `async with Client(...)`-Blocks ab, entfällt die Verpackung komplett. +* `call_tool` löst bei einem fehlschlagenden Tool keine Exception aus. `Error executing tool ...` und `Unknown tool: ...` sind Ergebnisse: Prüfe `result.is_error`. +* `Client must be used within an async context manager` -> verwende `async with`. `Use @tool() instead of @tool` -> füge die Klammern hinzu. +* `Tool already exists:` im Server-Log ist das einzige Zeichen, dass zwei gleichnamige Tools zu einem zusammengefallen sind. +* Ein 421, drei Schreibweisen: `Server returned an error response` (der Python-`Client`), `421 Misdirected Request` / `Invalid Host header` (alles andere), `Invalid Host header: ` (das Server-Log). Lösung: `transport_security=TransportSecuritySettings(allowed_hosts=[...])`. +* `Task group is not initialized` -> eine eingehängte App, deren Host-Lifespan nie `mcp.session_manager.run()` betreten hat. +* `Session not found` -> der Server wurde neu gestartet; verbinde dich neu. +* `Cannot send 'elicitation/create': ... no back-channel ...` -> `ctx.elicit()` braucht einen Kanal vom Server zum Client: Eine `2026-07-28`-Verbindung hat nie einen, `stateless_http=True` nimmt den Legacy-Kanal weg, und `json_response=True` nimmt den Request-gebundenen weg. Verwende einen Resolver (ein Legacy-Client braucht außerdem einen Server, der den Kanal behält). Sein Nachbar `Method not found` ist ein Request für eine Methode, die die Protokollrevision der anderen Seite nicht hat. +* `Client did not declare the form elicitation capability ...` und `Elicitation not supported` -> dem Client fehlt `elicitation_callback=`. +* `Invalid or expired requestState` sagt auf der Leitung nie, warum. Das Server-Log schon; `unknown key` heißt: Teile `RequestStateSecurity(keys=[...])` über alle Worker hinweg. diff --git a/i18n/de/pages/whats-new.md b/i18n/de/pages/whats-new.md new file mode 100644 index 0000000000..460cd4bc71 --- /dev/null +++ b/i18n/de/pages/whats-new.md @@ -0,0 +1,214 @@ +--- +translation: + sections: [cfe01c0c5863dfa2, 11d93f1fa09eadf5, a7392996acf1ad8f, 875eb2889263424e] + tool: 1 +--- +# Was ist neu in v2 {#whats-new-in-v2} + +In v2 ist zweierlei auf einmal passiert. Das **SDK wurde neu gebaut**: eine neue Engine unter Client und Server, ein vollwertiger `Client` und eine Reihe von Umbenennungen, auf die eine v1-Codebasis beim ersten Import stößt. Und das **Protokoll hat sich bewegt**: v2 spricht die Revision 2026-07-28 von MCP, die den Verbindungs-Handshake, die Session und jeden vom Server ausgehenden Request entfernt, ohne die Clients im Stich zu lassen, die du bereits hast. + +Diese Seite ist der Rundgang durch beide Hälften: ein Abschnitt pro Schlagzeile, jeder endet bei der Seite, zu der das Thema gehört. Sie ist nicht die Portierungsanleitung. Das ist der **[Migrationsleitfaden](migration.md)**: jede inkompatible Änderung, mit Code vorher und nachher. + +!!! note "v2 ist die stabile Linie" + `pip install mcp` installiert 2.x, und **[Installation](get-started/installation.md)** hat die + Installationszeile zum Kopieren. Wenn irgendetwas in v2 kaputtgeht, dich überrascht oder ausbremst, + [sag uns Bescheid](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml). + +## Das SDK: von v1 zu v2 {#the-sdk-v1-to-v2} + +### `FastMCP` heißt jetzt `MCPServer` {#fastmcp-is-now-mcpserver} + +Die High-Level-Serverklasse wurde umbenannt, ihr Modul gleich mit. Das ist das Erste, worauf jeder v1-Server stößt, denn der alte Importpfad ist entfernt, nicht bloß veraltet: + +```python +from mcp.server import MCPServer # v1: from mcp.server.fastmcp import FastMCP + +mcp = MCPServer("Demo") # v1: FastMCP("Demo") +``` + +Für einen mit Dekoratoren gebauten Server ist das auch schon der größte Teil der Portierung. `@mcp.tool()`, `@mcp.resource()` und `@mcp.prompt()` akzeptieren, was sie in v1 akzeptiert haben (`@mcp.resource()` bekommt ein optionales Schlüsselwort `security=` dazu), und das Eingabeschema kommt weiterhin aus deinen Type Hints. An den Rändern: Alles unter `mcp.server.fastmcp.*` liegt jetzt unter `mcp.server.mcpserver.*`, `ctx.fastmcp` heißt `ctx.mcp_server`, `get_context()` ist entfernt (deklariere stattdessen einen Parameter `ctx: Context`), und die Exception-Basisklasse `FastMCPError` heißt `MCPServerError`. Die Importtabelle steht im **[Migrationsleitfaden](migration.md#fastmcp-renamed-to-mcpserver)**. + +### `Resolve`: der neue Weg, die Person am Host nach Eingaben zu fragen {#resolve-the-new-way-to-ask-the-user-for-input} + +Nicht alles, was ein Tool braucht, sollte vom Modell kommen. Neu in v2: Ein Tool-Parameter, der mit `Resolve(fn)` annotiert ist, wird stattdessen von einer Funktion gefüllt, die du schreibst – unsichtbar für das Modell –, und diese Funktion kann `Elicit(...)` zurückgeben, um der Person am Host eine Frage zu stellen. Das ist der bevorzugte Weg, mitten im Aufruf irgendetwas vom Client zu bekommen: Das SDK transportiert die Frage über den Mechanismus, den die Verbindung jeweils unterstützt – bei einem Legacy-Client ein Live-Request per Elicitation (Rückfrage bei der Person am Host), bei 2026-07-28 ein Multi-Roundtrip (multi-round-trip) –, sodass ein einziger Tool-Body beide Generationen bedient. Die Seite dazu ist **[Abhängigkeiten](handlers/dependencies.md)**. + +!!! note + Die beiden anderen Formen bleiben, wenn du sie brauchst: `ctx.elicit()` funktioniert weiterhin für + Clients auf Legacy-Verbindungen (**[Elicitation](handlers/elicitation.md)**), und ein Handler kann + selbst ein `InputRequiredResult` zurückgeben und die Runden von Hand steuern – so reisen bei + 2026-07-28 auch Sampling- und Roots-Requests (**[Multi-Roundtrip-Requests](handlers/multi-round-trip.md)**). + +### Ein vollwertiger `Client` {#a-first-class-client} + +v1 gab dir drei verschachtelte Schichten: einen Transport-Kontextmanager, der rohe Streams liefert, eine darum gewickelte `ClientSession` und ein von Hand aufgerufenes `await session.initialize()`. v2 hat ein einziges Objekt: + +```python title="client.py" hl_lines="14-18" +--8<-- "docs_src/client/tutorial001.py" +``` + +`Client` nimmt ein Server-Objekt (im Speicher, ohne Transport: das ist der Testansatz), eine URL (Streamable HTTP) oder einen beliebigen Transport-Kontextmanager wie `stdio_client(...)`. Das Betreten von `async with` verbindet und handelt die Protokollversion aus, welche Generation der Server auch spricht; `client.server_capabilities` und `client.protocol_version` sind danach einfach da, ebenso `client.server_info`, wenn der Server sich zu erkennen gibt (das ist jetzt `Implementation | None`, weil die Identität in der 2026er-Generation optional ist). Die Sampling- und Elicitation-Callbacks, die du in v1 registriert hast, funktionieren weiter (ihre Bodies sehen dieselbe Umbenennung der Attribute auf snake_case wie alles andere auf dieser Seite), sie beantworten jetzt außerdem die Requests-in-Results im 2026er-Stil (unten), und sie laufen nebenläufig statt nacheinander. `ClientSession` liegt für alle, die die Low-Level-Oberfläche wollen, weiterhin darunter, und `client.session` reicht sie dir; auch sie hat sich bewegt (sie läuft auf der neuen Dispatcher-Engine, und einige ihrer eigenen Signaturen haben sich geändert), lies also den **[Migrationsleitfaden](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)**, bevor du hinabsteigst. + +**[Der Client](client/index.md)** stellt ihn vor, **[Client-Transporte](client/transports.md)** behandelt die drei Verbindungsformen, **[Client-Callbacks](client/callbacks.md)** die Callbacks selbst, und **[Testen](get-started/testing.md)** zeigt das In-Memory-Muster, das den Helfer `create_connected_server_and_client_session()` aus v1 ersetzt. + +### Der Low-Level-`Server` wurde neu gebaut, nicht umbenannt {#the-low-level-server-was-rebuilt-not-renamed} + +Wenn du auf der JSON-RPC-Ebene arbeitest, ist das der Teil von v2, bei dem „alles anders ist“. Hier ist derselbe Server mit einem Tool in beiden Varianten; klicke auf die Marker, um zu sehen, was sich verschoben hat. + + + +```python title="v1" +from typing import Any + +import mcp.types as types +from mcp.server.lowlevel import Server + +server = Server("Bookshop") + + +@server.list_tools() # (1)! +async def list_tools() -> list[types.Tool]: + return [ # (2)! + types.Tool( + name="search_books", + description="Search the catalog by title or author.", + inputSchema={ # (3)! + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + }, + ) + ] + + +@server.call_tool() +async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentBlock]: # (4)! + if name != "search_books": + raise ValueError(f"Unknown tool: {name}") # (5)! + ctx = server.request_context # (6)! + return [types.TextContent(type="text", text=f"Found 3 books matching {arguments['query']!r}.")] # (7)! +``` + +1. Handler werden mit Dekoratoren registriert (aufgerufen, mit Klammern), jederzeit, nachdem der Server existiert. +2. Du gibst eine bloße `list[Tool]` zurück, und das SDK verpackt sie in ein `ListToolsResult`. +3. Felder sind in Python camelCase, und das Schema wird **durchgesetzt**: Das SDK validiert die `call_tool`-Argumente per jsonschema dagegen, bevor deine Funktion läuft – deshalb ist `arguments["query"]` unten sicher. +4. Ein einziger `call_tool`-Handler bedient jedes Tool, und er erhält den Tool-Namen und die bereits validierten Argumente, ausgepackt und nie `None`. +5. Durch das Auslösen einer Exception signalisiert ein v1-Tool einen Fehlschlag: Jede Exception wird abgefangen und als `CallToolResult(isError=True)` mit `str(e)` als Text zurückgegeben, sodass das aufrufende Modell diese Meldung liest und es erneut versuchen kann. +6. Der Kontext kommt aus einer umgebenden ContextVar, die mitten im Request über das Server-Objekt erreicht wird. +7. Bloße Content-Blöcke werden für dich in ein `CallToolResult` verpackt. + +```python title="v2" +--8<-- "docs_src/whats_new/tutorial001.py" +``` + +1. Felder sind jetzt snake_case, und das Schema wird **bekannt gegeben, aber nie angewendet**: Nichts prüft die Argumente, bevor dein Handler läuft. +2. Jeder Handler hat dieselbe Form: `async (ctx, params) -> result`. Der Kontext ist das erste Argument (`ctx.session`, `ctx.request_id`, `ctx.protocol_version` liegen darauf); dorthin ist `server.request_context` gewandert. +3. Du baust das vollständige `ListToolsResult` selbst. Eine bloße Liste zurückzugeben ist jetzt ein serverseitiger `TypeError`, nichts, was das SDK verpackt. +4. Typisierte Params hinein (`params.name`, `params.arguments`), ein vollständiges Result hinaus. Nichts wird für dich ausgepackt, verpackt oder umgewandelt. +5. Dieselbe Prüfung, anderes Verb. Ein `ValueError` käme hier beim Modell als undurchsichtiges `-32603` an (siehe unten), daher wird ein absichtlicher Fehler auf der Leitung als `MCPError` ausgelöst: Er geht mit Code und Meldung unverändert durch, und `-32602` mit diesem Text ist die eigene Antwort der Spezifikation für ein unbekanntes Tool. +6. `params.arguments` kann `None` sein; v1 setzte es auf `{}`, bevor dein Code es je zu sehen bekam. Ohne Validierung vor dem Handler ist diese Zeile tragend. +7. Eine hier ausgelöste unerwartete Exception wird zu einem **bereinigten** Protokollfehler, `-32603` `"Internal server error"`: Das Modell sieht die Meldung nie. Für einen Fehlschlag, den das Modell lesen und auf den es reagieren soll, gib `CallToolResult(is_error=True, ...)` zurück. +8. Handler sind Konstruktorargumente, die Oberfläche des Servers ist also in dem Moment vollständig, in dem er existiert; `add_request_handler()` ist der Notausgang nach der Konstruktion und die Tür zu eigenen Methoden. + +Das Beispiel ist das Muster. Allgemeiner: Jeder Handler hat dieselbe Form, mit typisierten Params hinein und einem vollständigen Result-Typ hinaus; die alte jsonschema-Prüfung der Tool-Argumente ist entfernt; eine Exception ist ein Protokollfehler, nie ein Tool-Result mit `is_error=True`; und die umgebende ContextVar `server.request_context` ist entfernt. Eigene Methoden mit Vendor-Namespace sind über `add_request_handler(method, params_type, handler)` vollwertig unterstützt; das validiert eingehende Params gegen dein Modell, bevor dein Handler läuft. Und eine `middleware`-Liste (bewusst als vorläufig markiert) umhüllt jede eingehende Nachricht und ersetzt die privaten `_handle_*`-Methoden, die früher überschrieben wurden. + +Unter der Haube wurde die `BaseSession`-Empfangsschleife aus v1 durch eine Dispatcher-Engine ersetzt, die Client und Server sich jetzt teilen, und sie macht mehrere Dinge auf dieser Seite gleichzeitig wahr: Ein einziges `Server`-Objekt bedient beide Protokollgenerationen, `Client(server)` dispatcht im Prozess ohne JSON-RPC-Framing, und ein Client-Request, der in den Timeout läuft, bricht jetzt tatsächlich den serverseitigen Handler ab. + +Die Seite dazu ist **[Der Low-Level-Server](advanced/low-level-server.md)**; der **[Migrationsleitfaden](migration.md#lowlevel-server-decorator-based-handlers-replaced-with-constructor-on_-params)** geht jeden entfernten Hook durch. Wenn du nie unter `MCPServer` hinabgestiegen bist, betrifft dich nichts davon. + +### Die Typen auf der Leitung sind nach `mcp-types` umgezogen, und jedes Feld ist snake_case {#the-wire-types-moved-to-mcp-types-and-every-field-is-snake_case} + +Die Protokolltypen leben jetzt in einer eigenen Distribution, `mcp-types`. Sie hängt von nichts außer pydantic und typing-extensions ab, sodass ein Gateway, ein Proxy oder ein Codegenerator die Formen, die MCP über die Leitung schickt, verwenden kann, ohne einen HTTP-Stack zu installieren: Ein solches Projekt installiert `mcp-types` und importiert `mcp_types`. `mcp` selbst hängt von diesem Paket in einer exakten Version ab und reicht es weiter, sodass Code, der vom SDK abhängt, weiterhin `import mcp.types as types` und `from mcp.types import Tool` schreibt (ein dauerhafter Alias, jeder Name dasselbe Objekt) und nur seine eine echte Abhängigkeit deklariert, `mcp`. Die Faustregel: Importiere über das Paket, von dem du tatsächlich abhängst. + +Auf diesen Typen ist jedes Python-Attribut jetzt snake_case: `result.is_error`, `tool.input_schema`, `listing.next_cursor`. Das JSON auf der Leitung ist camelCase, genau wie zuvor; nur die Schreibweise der Attribute hat sich geändert. Zwei strengere Standardwerte kommen mit: Unbekannte Felder werden ignoriert statt durchgereicht (lege Zusätzliches in `_meta` ab), und beide Seiten validieren den Verkehr gegen die Protokollversion, die sie ausgehandelt haben. Die Umbenennungstabelle steht im **[Migrationsleitfaden](migration.md#field-names-changed-from-camelcase-to-snake_case)**. + +### Die Transportkonfiguration ist nach `run()` umgezogen {#transport-configuration-moved-to-run} + +Bei `MCPServer(...)` geht es darum, was dein Server *ist*: sein Name, seine Instruktionen, sein Lifespan, seine Auth. Wie er *ausgeliefert* wird, gehört jetzt zu `run()` und den App-Buildern; dorthin sind `host`, `port`, `stateless_http`, `json_response`, die Endpunktpfade und `transport_security` gewandert (`MCPServer("x", port=9000)` ist ein `TypeError`). Die Overloads sind pro Transport typisiert, dein Editor sagt dir also, welche Optionen `stdio` nimmt und welche `streamable-http`. Eine Entfernung, die du kennen solltest: `mount_path` ist weg; die ASGI-App zu mounten ist der unterstützte Weg, unter einem Präfix auszuliefern. + +**[Den Server betreiben](run/index.md)** behandelt die Optionen; **[In eine bestehende App einbinden](run/asgi.md)** das Mounten. + +### Verhalten, das sich ohne Importfehler ändert {#behavior-that-changes-without-an-import-error} + +Die Umbenennungen machen sich selbst bemerkbar. Diese Änderungen nicht: + +* **Synchrone Funktionen laufen auf einem Worker-Thread.** Ein `def`-Tool (oder eine Ressource, ein Prompt oder ein Resolver) blockiert die Event-Loop nicht mehr; der Preis dafür ist, dass sein Body nicht mehr *auf* dem Event-Loop-Thread läuft, was für threadgebundenen Code eine Rolle spielt. `async def`-Handler sind nicht betroffen. **[Migrationsleitfaden](migration.md#sync-handler-functions-now-run-on-a-worker-thread)**. +* **Ein in einem Tool ausgelöster `MCPError` (in v1 `McpError`) ist jetzt ein Protokollfehler.** Das Modell sieht ihn nie. Jede andere Exception wird weiterhin zu einem Result mit `is_error=True`, das das Modell lesen und auf das es reagieren kann. Die Aufteilung steht in **[Fehler behandeln](servers/handling-errors.md)**. +* **Results werden validiert, bevor sie hinausgehen.** Ein von Hand gebautes `Tool`, dessen `input_schema` `{}` ist, lässt jetzt `tools/list` fehlschlagen (die Spezifikation verlangt `"type": "object"`). Server, die auf `@mcp.tool()` aufbauen, sehen das nie; das SDK schreibt ihre Schemas. +* **Dein Client validiert, was er empfängt.** `list_tools()` und `call_tool()` prüfen die Antwort des Servers gegen die ausgehandelte Protokollversion, sodass ein nicht ganz valider Server, den das nachsichtige Parsen von v1 tolerierte, jetzt `pydantic.ValidationError` auslöst. Wenn du dich mit Servern verbindest, die du nicht kontrollierst, rechne damit, dass du sie findest; die Details stehen im **[Migrationsleitfaden](migration.md#client-validates-inbound-traffic-against-the-protocol-schema)**. +* **URI-Templates sind jetzt echtes RFC 6570.** `{+path}`, `{?query}` und Verwandte funktionieren, der Abgleich ist exakt statt Regex-locker, und Path Traversal in extrahierten Werten wird standardmäßig abgelehnt. Strengere Templates schlagen beim Dekorieren fehl, nicht beim ersten Request. **[URI-Templates](servers/uri-templates.md)**. +* **Der Lifespan bei Streamable HTTP läuft einmal**, beim Start, und sein Zustand wird von jeder Session und jedem Request geteilt. In v1 lief er einmal pro Session und unter `stateless_http=True` einmal pro Request. Pools und Caches, die in einem Lifespan gebaut werden, werden drastisch billiger; alles, was dort eine Ressource pro Verbindung beschafft hat, gehört jetzt in den Handler-Body. **[Lifespan](handlers/lifespan.md)**. +* **`mcp dev` und `mcp install` pinnen die Umgebung, die sie starten,** auf deine installierte SDK-Version. Beide Befehle führen deinen Server in einer frischen `uv run --with ...`-Umgebung aus, die `mcp` früher auf das neueste stabile Release auflöste statt auf die Version, gegen die du entwickelst. **[Migrationsleitfaden](migration.md#mcp-dev-and-mcp-install-pin-the-spawned-environment-to-your-sdk-version)**. +* **Der HTTP-Client ist jetzt `httpx2`, nicht `httpx`.** Der Abhängigkeitswechsel ändert, was dein Code abfängt und übergibt (`httpx2.AsyncClient`, `httpx2.ConnectError`), und er ändert, wie TLS-Zertifikate geprüft werden: `httpx2` validiert über `truststore` gegen den Trust Store des Betriebssystems statt gegen die gebündelte CA-Liste von certifi. Die meisten Umgebungen merken davon nichts; ein minimaler Container ohne System-CA-Store oder eine private CA, die nur das Bundle von certifi kannte, scheitert nun beim TLS-Handshake. Setze `SSL_CERT_FILE`/`SSL_CERT_DIR` oder übergib deinem Client `verify=ssl_context`. **[Migrationsleitfaden](migration.md#httpx-and-httpx-sse-replaced-by-httpx2)**. + +### Komplett entfernt {#removed-outright} + +Jeder dieser Punkte ist ein Abschnitt im **[Migrationsleitfaden](migration.md)**: + +* Der **WebSocket-Transport**, beide Seiten, und das Extra `mcp[ws]`. Er war nie Teil der MCP-Spezifikation. +* Die **experimentelle Tasks**-API (`mcp.*.experimental`). 2026-07-28 verlagert Tasks aus dem Kernprotokoll in eine offizielle Erweiterung ([SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663)), die dieses SDK noch nicht implementiert. +* `mcp.shared.version`, `mcp.shared.progress` und `mcp.shared.session` (mit dem `RequestResponder`-Stub, den `message_handler`-Annotationen in v1 importierten) als Importpfade. (`mcp.types` ist *nicht* entfernt: Es bleibt als dauerhafter Alias für das eigenständige Paket `mcp_types`.) +* Die veraltete Schreibweise `streamablehttp_client` und der Callback `get_session_id` aus `streamable_http_client` (das jetzt genau zwei Streams liefert). +* `McpError`, umbenannt in **`MCPError`** mit einem direkten Konstruktor `(code, message, data)`. +* `MCPServer.get_context()`, `mount_path=` sowie die Dekorator-Methoden, die ContextVar und die Handler-Dicts des Low-Level-`Server`. + +## Das Protokoll: von 2025-11-25 zu 2026-07-28 {#the-protocol-2025-11-25-to-2026-07-28} + +v2 implementiert die Revision 2026-07-28, und es bedient **beide** Revisionen zugleich: Dieselbe `streamable_http_app()` (und derselbe stdio-Server) beantwortet das `initialize` eines Clients der 2025er-Generation und die Requests eines Clients der 2026er-Generation, ohne dass du etwas konfigurieren, ein Flag umlegen oder ein getrenntes Deployment aufsetzen musst. Die neue Revision zu bedienen lässt keinen Client auf der alten im Stich. Was folgt, ist das, was die neue Revision selbst ändert. + +### Kein Handshake, keine Session {#no-handshake-no-session} + +Ein 2026-07-28-Client öffnet nicht erst eine Verbindung, handelt aus und redet dann. Jeder Request trägt seine Protokollversion, die Client-Info und die Client-Capabilities in `_meta`, und der eine Discovery-Aufruf, `server/discover`, ist ein gewöhnlicher Request wie jeder andere. `Client` tut standardmäßig das Richtige: Er probiert `server/discover` einmal und fällt auf den `initialize`-Handshake zurück, wenn der Server älter ist. + +Über Streamable HTTP gibt es auf dem 2026er-Pfad keine `Mcp-Session-Id`, und das ist die Schlagzeile für den Betrieb: **Nichts bindet einen modernen Request an einen Worker**, also kann jede Replik hinter einem schlichten Round-Robin-Load-Balancer ihn beantworten. Zwei ehrliche Einschränkungen. Deine Clients der 2025er-Generation (heute sind das die meisten Clients) öffnen weiterhin Sessions und brauchen weiterhin die Stickiness, die sie auf v1 brauchten; für sie ändert sich nichts. Und das Einzige, was der erneute Versuch eines *Multi-Roundtrips* über Worker hinweg mitnehmen muss, ist sein versiegelter `request_state`, dessen Standardschlüssel pro Prozess erzeugt wird, daher übergibt ein horizontal skaliertes Deployment `RequestStateSecurity(keys=[...])`. (`stateless_http=True` hat damit nichts zu tun: Es beeinflusst nur, wie Clients der 2025er-Generation bedient werden, und 2026er-Verkehr liest es nie; wenn du es in v1 bereits gesetzt hast, ändert sich nichts.) + +**[Protokollversionen](protocol-versions.md)** ist die Client-Seite davon, **[Bereitstellen und skalieren](run/deploy.md)** die Checkliste für den Betrieb (die Host-Allowlist, der `request_state`-Schlüssel, Benachrichtigungen über Repliken hinweg), und **[Legacy-Clients unterstützen](run/legacy-clients.md)** erzählt, wie beide Generationen zugleich bedient werden. + +### Der Server kann den Client nicht aufrufen: Multi-Roundtrip-Requests {#the-server-cannot-call-the-client-multi-round-trip-requests} + +Jeder vom Server ausgehende Request ist bei 2026-07-28 entfernt: Push-Elicitation, Sampling, `roots/list`. Auf einer 2026er-Verbindung gibt es keinen Rückkanal (back-channel) dafür, also schlagen `ctx.elicit()` und `ctx.session.create_message()` dort mit `NoBackChannelError` fehl (für Legacy-Clients funktionieren sie weiterhin). + +Der Ersatz dreht den Aufruf um. Ein Tool, das etwas von der Person am Host braucht, *gibt* die Frage *zurück* (`InputRequiredResult`), der Client beantwortet sie mit denselben Callbacks, die er schon immer hatte, und der Aufruf wird mit angehängten Antworten erneut versucht. `Client` treibt diese Schleife für dich. Auf dem Server baust du das Result selten selbst, weil eine **[Abhängigkeit](handlers/dependencies.md)** das übernimmt: Annotiere einen Parameter mit `Resolve(ask_quantity)`, wobei `ask_quantity` eine gewöhnliche Funktion ist, die du schreibst, und das SDK fragt über den Mechanismus, den die Verbindung unterstützt – ein Live-Elicitation-Request auf einer Legacy-Session oder ein Multi-Roundtrip bei 2026. Ein Tool-Body, beide Generationen: + +```python title="dual_era.py" hl_lines="24 37-38" +--8<-- "docs_src/legacy_clients/tutorial001.py" +``` + +Diese Datei ist das ganze Versprechen an einem Ort: ein Server, ein Tool mit `Resolve` dahinter, und ein Legacy-Client plus ein moderner Client, die beide ihre Antwort bekommen, im Speicher. **[Multi-Roundtrip-Requests](handlers/multi-round-trip.md)** erklärt den Mechanismus (einschließlich `request_state`, den das SDK für dich versiegelt und verifiziert); **[Elicitation](handlers/elicitation.md)** behandelt das Fragen. + +!!! warning "Das ist die eine Stelle, an der ein portierter v1-Server sein Verhalten ändert" + Deine eigenen Tests treffen es zuerst: `Client(mcp)` handelt gegen deinen v2-Server standardmäßig + 2026-07-28 aus, also schlägt ein Tool, das `ctx.elicit()` aufruft, in einem Test fehl, der auf v1 bestand. + Verschiebe die Frage in einen `Resolve(...)`-Parameter (über Generationen portabel), oder pinne den + Test-Client auf `mode="legacy"`, wenn du das Push-Verhalten wirklich willst. + +### Roots, Sampling und Protokoll-Logging sind veraltet; `ping` ist entfernt {#roots-sampling-and-protocol-logging-are-deprecated-ping-is-removed} + +[SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) erklärt drei ganze *Capabilities* für veraltet, auf jeder Protokollversion: Roots, Sampling und Logging auf MCP-Ebene (`ctx.info()` und Verwandte). Das ist eine andere Achse als der fehlende Rückkanal oben; veraltet ist ein Hinweis, alles funktioniert gegen Sessions der 2025er-Generation weiter, und auf der Leitung ändert sich nichts. Was du bemerkst, ist `MCPDeprecationWarning`, eine `UserWarning`, die deshalb standardmäßig ausgegeben wird; rechne damit, dass dein erstes `ctx.info(...)` nach dem Upgrade das meldet. + +Bei `ping` ist es strenger: aus dem Protokoll entfernt, nicht veraltet. Zwei eigenständige Methoden der veralteten Features sind bei 2026-07-28 auf dieselbe Weise entfernt, `logging/setLevel` und das `notifications/roots/list_changed` des Clients, und Fortschrittsbenachrichtigungen gehen jetzt nur noch vom Server zum Client. + +**[Veraltete Features](deprecated.md)** hat die vollständige Tabelle, den Ersatz für jedes einzelne und den einzeiligen Filter, falls du ein ruhiges Log brauchst, während du Legacy-Clients bedienst. + +### Änderungsbenachrichtigungen werden zu einem einzigen Stream {#change-notifications-become-one-stream} + +Bei 2026-07-28 werden der eigenständige HTTP-GET-Stream und `resources/subscribe` durch `subscriptions/listen` ersetzt: Der Client öffnet einen langlebigen Stream und benennt die Arten von Benachrichtigungen, die er haben will. `MCPServer` bedient ihn ohne weitere Konfiguration; du veröffentlichst mit `await ctx.notify_resource_updated(uri)` (und `notify_tools_changed()` und so weiter), eine Middleware kann einen Listen-Request pro Aufrufer ablehnen, und Deployments mit mehreren Repliken binden einen gemeinsamen `SubscriptionBus` ein. Auf dem Client öffnet `async with client.listen(...)` den Stream: Der Filter geht als Schlüsselwortargumente hinein, typisierte Änderungsereignisse kommen zurück, und `sub.honored` ist die Teilmenge, die der Server zu liefern zugesagt hat. + +**[Abonnements](handlers/subscriptions.md)** behandelt das Veröffentlichen und Bedienen, **[das Gegenstück unter Clients](client/subscriptions.md)** die beobachtende Seite und **[Bereitstellen und skalieren](run/deploy.md)** den Bus. + +### Der Rest, in Kürze {#the-rest-quickly} + +* **Identität ist optionale Metadaten pro Nachricht.** Der `_meta`-Schlüssel `clientInfo` auf der Request-Seite ist optional (das Pflichtpaar ist `protocolVersion` + `clientCapabilities`), und `serverInfo` ist aus dem Result-Body von `server/discover` ausgezogen: Server stempeln es stattdessen in das `_meta` jedes Results der 2026er-Generation ([Spec #3002](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3002)). Das SDK stempelt immer; `client.server_info` ist `None`, wenn ein Server sich nicht zu erkennen gibt (zum Beispiel, weil eine Middleware den Schlüssel entfernt hat). **[Der Low-Level-Server](advanced/low-level-server.md)** zeigt den Stempel auf der Leitung. +* **Requests lassen sich routen, ohne Bodies zu parsen.** Moderne HTTP-Requests tragen `Mcp-Method` (und für die drei Tool-artigen Aufrufe `Mcp-Name`); eine Eigenschaft im Eingabeschema eines Tools, die mit `x-mcp-header` annotiert ist, wird in einen `Mcp-Param-*`-Header gespiegelt und vom Server gegengeprüft ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243)). Gateways und Rate-Limiter können allein anhand der Header routen; die Regeln stehen im **[Migrationsleitfaden](migration.md#servers-validate-mcp-param-headers-against-the-request-body-sep-2243)**. +* **Results tragen Cache-Hinweise.** List- und Read-Results deklarieren `ttlMs` und `cacheScope` ([SEP-2549](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549)); du setzt sie pro Methode mit `cache_hints=`, und `Client` beachtet sie mit einem eingebauten Response-Cache. Ein Server, der keine Hinweise sendet (jeder Server vor 2026), sieht identischen, ungecachten Verkehr. **[Caching-Hinweise](client/caching.md)**. +* **Erweiterungen sind vollwertig.** Server und Clients deklarieren optionale Capability-Bündel unter Reverse-DNS-Bezeichnern ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)); die eingebaute Erweiterung `Apps` (MCP Apps) ist die Referenz. **[Erweiterungen](advanced/extensions.md)** und **[MCP Apps](advanced/apps.md)**. +* **Fehlercodes wurden standardisiert.** Eine fehlende Ressource ist `-32602` mit dem URI in `error.data`, und die neuen von der Spezifikation reservierten Codes erscheinen als `-32020` (Header-Abweichung), `-32021` (fehlende erforderliche Capability) und `-32022` (nicht unterstützte Protokollversion). **[Fehlerbehebung](troubleshooting.md)** ist nach den exakten Meldungen geordnet. +* **Autorisierung lässt sich schwerer falsch benutzen.** Der Client validiert das `iss`, das mit dem Autorisierungscode zurückkommt ([RFC 9207](https://datatracker.ietf.org/doc/html/rfc9207); dein `callback_handler` gibt jetzt ein `AuthorizationCodeResult` zurück), sendet `application_type` bei der Registrierung und spielt Zugangsdaten nie gegen einen anderen Autorisierungsserver erneut ab. Neu in der Enterprise-Ecke: der Identity-Assertion-Flow aus [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990). Der **[Migrationsleitfaden](migration.md)** listet jede OAuth-Änderung auf; die Seiten dazu sind **[OAuth für Clients](client/oauth-clients.md)** und **[Identity Assertion](client/identity-assertion.md)**. +* **Jeder Server ist nachverfolgbar.** OpenTelemetry ist als Middleware standardmäßig eingeschaltet: Jeder Request bekommt einen Server-Span, ohne Kosten, bis der Prozess einen Exporter konfiguriert. Wenn auf beiden Seiten das SDK läuft, propagiert der Client außerdem den W3C-Trace-Kontext in `_meta`, sodass die Traces zusammenfinden. **[OpenTelemetry](run/opentelemetry.md)**. + +## Upgrade von v1? {#upgrading-from-v1} + +* Der **[Migrationsleitfaden](migration.md)** ist die vollständige, exakte Liste dessen, was zu ändern ist; diese Seite war das Warum. +* **v1.x verschwindet nicht.** Es geht in die Wartung über, bekommt weiter kritische Fixes und Sicherheitspatches, und nichts an der Veröffentlichung der Spezifikation 2026-07-28 macht es kaputt; seine Doku liegt unter [/v1/](https://py.sdk.modelcontextprotocol.io/v1/). Wenn du eine Bibliothek veröffentlichst, die von `mcp` abhängt, und noch nicht zur Migration bereit bist, setze eine Obergrenze (zum Beispiel `mcp>=1.28,<2`), damit eine ungepinnte Auflösung auf 1.x bleibt. +* Etwas holprig, verwirrend oder kaputt? **[Gib v2-Feedback](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml)**; alles wird gelesen. diff --git a/i18n/es/glossary.json b/i18n/es/glossary.json new file mode 100644 index 0000000000..437794f3b8 --- /dev/null +++ b/i18n/es/glossary.json @@ -0,0 +1,307 @@ +{ + "keep": [ + "MCP", + "Model Context Protocol", + "MCPServer", + "FastMCP", + "ClientSession", + "Context", + "ctx", + "stdio", + "Streamable HTTP", + "SSE", + "JSON-RPC", + "JSON", + "OAuth", + "PKCE", + "JWT", + "CIMD", + "HTTP", + "HTTPS", + "TLS", + "CORS", + "URI", + "URL", + "ASGI", + "WebSocket", + "API", + "SDK", + "CLI", + "IDE", + "LLM", + "SEP", + "RFC", + "Python", + "TypeScript", + "Node.js", + "PyPI", + "Pydantic", + "Starlette", + "FastAPI", + "uvicorn", + "httpx", + "anyio", + "asyncio", + "trio", + "pytest", + "OpenTelemetry", + "Inspector", + "Claude", + "GitHub", + "VS Code", + "Windows", + "macOS", + "Linux", + "llms.txt", + "2026-07-28", + "2025-11-25", + "2025-06-18", + "2025-03-26" + ], + "terms": [ + { + "source": "tool", + "target": "herramienta", + "note": "MCP protocol noun (a server exposes tools), feminine: la herramienta / las herramientas. Standard rendering. Wire identifiers such as `tools/call` and the `@mcp.tool()` decorator are code and stay untouched; the Inspector's **Tools** tab is a UI label and stays English." + }, + { + "source": "resource", + "target": "recurso", + "note": "MCP protocol noun (data a server exposes for reading) and the general noun alike, masculine: el recurso / los recursos. Standard rendering. `resources/read` and `@mcp.resource()` are code." + }, + { + "source": "prompt", + "target": "prompt", + "note": "The MCP feature (a reusable prompt template a server exposes) and the everyday AI sense; kept in English as Spanish AI writing does, masculine: el prompt / los prompts. Provisional pending native review: indicación / instrucción are not used for the MCP noun. `prompts/get` and `@mcp.prompt()` are code." + }, + { + "source": "sampling", + "target": "muestreo", + "note": "The (deprecated) client feature that lets a server borrow the client's model for a completion, masculine: el muestreo. Provisional pending native review; first mention in a page body reads \"muestreo (sampling)\" so the reader can map it to `sampling/createMessage`, which is code. Keeping the English word is the open alternative." + }, + { + "source": "roots", + "target": "roots", + "note": "The (deprecated) client feature listing the workspace directories a client exposes; kept in English because the reader meets it as `roots/list`, masculine plural: los roots. Provisional pending native review; may take the gloss \"roots (directorios raíz)\" on first mention. raíces is the open alternative; a `Root` object in code font stays Latin." + }, + { + "source": "elicitation", + "target": "elicitación", + "note": "OPEN QUESTION for native review: the server asking the user a question mid-request through the client. Provisionally the software-engineering loan elicitación (as in elicitación de requisitos), feminine, glossed on first mention per page — elicitación (elicitation). Alternatives a reviewer may prefer: consulta al usuario, petición de datos al usuario. `elicitation/create` and `ctx.elicit()` are code." + }, + { + "source": "capability", + "target": "capacidad", + "note": "What client and server declare during initialization (\"capability negotiation\" → negociación de capacidades), feminine. Not habilidad; and funcionalidad / característica mean a feature, which is a different thing. The `capabilities` field and keys such as `sampling.tools` are code." + }, + { + "source": "transport", + "target": "transporte", + "note": "The connection mechanism (\"every standard transport\" → todos los transportes estándar), masculine. The transport names stdio, Streamable HTTP and SSE are on the keep list: el transporte stdio." + }, + { + "source": "session", + "target": "sesión", + "note": "An MCP session (the negotiated connection state), feminine: la sesión / las sesiones, ID de sesión. `session` objects, `ClientSession` and `ServerSession` are code." + }, + { + "source": "handler", + "target": "handler", + "note": "The tool, resource or prompt function you register; kept in English as Spanish-speaking developers say it, masculine: el handler / los handlers (nav section \"Inside your handler\" → Dentro de tu handler). Provisional pending native review: manejador is the open alternative; controlador is not used because it also means controller and driver." + }, + { + "source": "dependency", + "target": "dependencia", + "note": "Both package dependencies and the SDK's parameter-injection feature (the \"Dependencies\" page → Dependencias; \"dependency injection\" → inyección de dependencias), feminine. The `Resolve` marker class is code." + }, + { + "source": "resolver", + "target": "resolutor", + "note": "The plain function attached with `Resolve(...)` that computes or asks for a parameter's value before the tool runs. Rendered as the noun resolutor (masculine) because resolver is a Spanish verb and \"el resolver\" misreads. OPEN QUESTION for native review: resolvedor and función de resolución are the alternatives. The `Resolve` class stays Latin." + }, + { + "source": "client", + "target": "cliente", + "note": "An MCP client and the client side of a connection, masculine: el cliente. The `Client` class and the `mcp.client` module are code and stay in English." + }, + { + "source": "server", + "target": "servidor", + "note": "An MCP server (the program you build), masculine: el servidor. The `MCPServer`, `Server` and `ServerSession` classes are code." + }, + { + "source": "host", + "target": "host", + "note": "The MCP host — the application the user talks to (Claude Desktop, an IDE, an agent runtime) — and a network host alike; kept in English as Spanish networking usage does, masculine: el host / los hosts. Provisional pending native review: anfitrión is not used for the MCP role." + }, + { + "source": "context", + "target": "contexto", + "note": "The generic lower-case word (\"provide context to LLMs\" → proporcionar contexto a los LLM), masculine. The capitalised `Context` is the SDK object injected as `ctx`; it is on the keep list and stays Latin (\"The Context\" → El Context, la clase `Context`)." + }, + { + "source": "request", + "target": "solicitud", + "note": "A JSON-RPC or HTTP request (\"the initialize request\" → la solicitud de inicialización), feminine. Provisional pending native review: petición is equally understood but use solicitud throughout rather than alternating; the raw English \"request\" is developer speech, not used in prose here. `Request` types in code font stay Latin." + }, + { + "source": "response", + "target": "respuesta", + "note": "A JSON-RPC or HTTP response, feminine: la respuesta. `Response` types in code font stay Latin." + }, + { + "source": "callback", + "target": "callback", + "note": "Client callbacks and OAuth redirect callbacks alike; kept in English, masculine: el callback / los callbacks. Provisional pending native review: retrollamada (the Python documentation's term) and devolución de llamada (vendor style) are not used. Parameter names such as `sampling_callback` are code." + }, + { + "source": "decorator", + "target": "decorador", + "note": "The Python decorators the SDK is built on, masculine: el decorador. Standard rendering in the Spanish Python documentation. `@mcp.tool()` and its siblings are code and stay untouched." + }, + { + "source": "type hint", + "target": "anotación de tipo", + "note": "Python type hints (\"from your type hints\" → a partir de tus anotaciones de tipo), feminine; the plural goes on anotación, tipo stays singular. Provisional pending native review: the Spanish Python documentation also uses indicador de tipo; pin anotación de tipo here and do not leave \"type hints\" in English prose. `type hints` inside code font is code." + }, + { + "source": "notification", + "target": "notificación", + "note": "A JSON-RPC notification (a message with no response) and the change notifications a server publishes, feminine: la notificación / las notificaciones. `notifications/...` method strings are code." + }, + { + "source": "round trip", + "target": "ida y vuelta", + "note": "One request-and-response exchange (\"zero negotiation round trips\" → cero idas y vueltas de negociación; \"round-trip time\" → tiempo de ida y vuelta), feminine. Provisional pending native review." + }, + { + "source": "multi-round-trip", + "target": "de varias idas y vueltas", + "note": "The 2026-07-28 request pattern (\"Multi-round-trip requests\" → Solicitudes de varias idas y vueltas). Provisional coinage pending native review: gloss the English on first mention per page — solicitudes de varias idas y vueltas (multi-round-trip). The abbreviation MRTR stays Latin." + }, + { + "source": "lifespan", + "target": "lifespan", + "note": "The server's startup/shutdown scope and its `lifespan=` parameter; kept in English so the prose matches the parameter name, masculine: el lifespan. Provisional pending native review; may take the gloss \"lifespan (ciclo de vida del servidor)\" on first mention. Not vida útil; and esperanza de vida (life expectancy) is never the sense. The neighbouring word \"lifetime\" (\"for the lifetime of the app\") may render as durante toda la vida de la app.", + "avoid": ["esperanza de vida"] + }, + { + "source": "back-channel", + "target": "canal de retorno", + "note": "This documentation's term for the server calling back into the client during a request, which exists only on legacy connections; masculine. Provisional pending native review: gloss the English on first mention per page — canal de retorno (back-channel) — so the reader can connect it to `NoBackChannelError`, which is code." + }, + { + "source": "deprecated", + "target": "obsoleto", + "note": "Advisory status: still works, scheduled for removal later — obsoleto / obsoleta (agreeing), as the Spanish Python documentation says (\"Deprecated features\" → Funcionalidades obsoletas; \"deprecation warning\" → aviso de obsolescencia); \"removed\" is eliminado. Provisional pending native review: the calque deprecado is widespread in speech but not used here; en desuso is understood but pin obsoleto. `MCPDeprecationWarning` is code." + }, + { + "source": "legacy", + "target": "heredado", + "note": "\"A legacy connection / client\" = one negotiated at spec version 2025-11-25 or earlier → una conexión heredada, un cliente heredado (\"Serving legacy clients\" → Atender clientes heredados). Standard vendor rendering, understood everywhere; do not leave \"legacy\" in English prose. Provisional pending native review." + }, + { + "source": "era", + "target": "generación", + "note": "\"Protocol era\" (\"a 2025-era client\", \"whatever era the client speaks\") → la generación del protocolo, un cliente de la generación 2025. Provisional pending native review; not the literal era or época." + }, + { + "source": "handshake", + "target": "handshake", + "note": "The initialization handshake (\"the classic handshake\" → el handshake clásico), kept in English as Spanish networking usage mostly does, masculine. Provisional pending native review: negociación inicial is the descriptive alternative; the literal apretón de manos appears in some vendor texts but is not used here." + }, + { + "source": "wire", + "target": "canal", + "note": "The corpus's light metaphor for the byte stream between client and server. Render the meaning, not the picture: \"stdout is the wire\" → stdout es el canal; \"invisible on the wire\" → no aparece en lo que se transmite; \"the JSON on the wire\" → el JSON que realmente se transmite. Provisional pending native review. Never the literal alambre; cable only if a reviewer prefers it.", + "avoid": ["alambre"] + }, + { + "source": "token", + "target": "token", + "note": "Kept in English for OAuth tokens (token de acceso, token de actualización) and LLM tokens alike, masculine: el token / los tokens. Standard." + }, + { + "source": "schema", + "target": "esquema", + "note": "A JSON schema describing tool input or output (\"the input schema\" → el esquema de entrada), masculine. The proper name JSON Schema stays English; `inputSchema` / `outputSchema` are code." + }, + { + "source": "default", + "target": "por defecto", + "note": "\"by default\" → por defecto; \"the default value\" → el valor por defecto; \"defaults to X\" → es X por defecto. Provisional pending native review: predeterminado is equally correct but pin one rendering; never leave \"default\" in English prose. `default=` in code font is code." + }, + { + "source": "deploy", + "target": "desplegar", + "note": "Verb desplegar, noun despliegue (masculine): \"Deploy & scale\" → Desplegar y escalar; \"after deployment\" → tras el despliegue. Standard pan-Hispanic rendering; never the verbified deployar." + }, + { + "source": "subscription", + "target": "suscripción", + "note": "Resource and list-change subscriptions (the \"Subscriptions\" pages → Suscripciones; \"subscribe\" → suscribirse), feminine. Spelled without the b (suscripción, not subscripción). `subscriptions/listen` and `resources/subscribe` are code." + }, + { + "source": "completion", + "target": "autocompletado", + "note": "The MCP feature that suggests values for prompt and resource-template arguments (the \"Completions\" page → Autocompletado), masculine. It is argument autocompletion, not LLM text completion; when a page means the model's generated text (sampling), say la respuesta del modelo. `completion/complete` is code. Provisional pending native review." + }, + { + "source": "library", + "target": "biblioteca", + "note": "A code library, feminine: la biblioteca (\"the standard library\" → la biblioteca estándar). The false friend librería means a bookshop and is never right for this sense. It is not on the banned list only because the corpus's running example server is a bookshop (\"Bookshop\"), for which librería would be the correct word if the English prose ever names it." + }, + { + "source": "file", + "target": "archivo", + "note": "Masculine: el archivo. fichero is Spain-only usage and this single global variant never uses it; archivo is understood everywhere, Spain included.", + "avoid": ["fichero"] + }, + { + "source": "computer", + "target": "computadora", + "note": "Rare in this corpus, which mostly says \"machine\" (→ la máquina: \"on your machine\" → en tu máquina) or can say el equipo. Where \"computer\" itself must be rendered, computadora is the pan-American form and is understood in Spain; ordenador is Spain-only and this global variant never uses it. Provisional pending native review.", + "avoid": ["ordenador"] + }, + { + "source": "string", + "target": "cadena", + "note": "A text string (\"returns a string\" → devuelve una cadena), feminine, as the Spanish Python documentation writes it; cadena de texto where cadena alone could be ambiguous. `str` is code. Provisional pending native review: the raw \"string\" is common in speech but not used in prose here." + }, + { + "source": "return", + "target": "devolver", + "note": "What a function or tool gives back (\"returns `3`\" → devuelve `3`; \"the return value\" → el valor devuelto / el valor de retorno). Provisional pending native review: retornar is understood everywhere and the Python documentation uses it too, but pin devolver so pages do not alternate. The `return` keyword is code." + }, + { + "source": "raise", + "target": "lanzar", + "note": "Raising an exception (\"raises `ToolError`\" → lanza `ToolError`; \"an exception is raised\" → se lanza una excepción), as the Spanish Python documentation says. generar una excepción is understood but pin lanzar. The `raise` keyword is code." + }, + { + "source": "async", + "target": "asíncrono", + "note": "The prose adjective (\"the async runtime\" → el entorno de ejecución asíncrono; \"an async callback\" → un callback asíncrono). Provisional pending native review: asincrónico is equally valid and more common in parts of the Americas; pin asíncrono for consistency. The `async` and `await` keywords in code font stay Latin." + }, + { + "source": "Get started", + "target": "Empieza aquí", + "note": "The nav section that opens the guide, and the title of its index page. \"First steps\" is a separate page inside that section (Primeros pasos), so the two need distinct renderings or the sidebar shows the same title twice. Provisional pending native review; Cómo empezar and Introducción are the alternatives for the section." + }, + { + "source": "First steps", + "target": "Primeros pasos", + "note": "The tutorial page inside the \"Get started\" section; never reuse this rendering for the section itself (see that entry). Provisional pending native review." + }, + { + "source": "Recap", + "target": "Resumen", + "note": "Recurring section heading that closes most pages; one rendering everywhere, not Resumen on some pages and Recapitulación or En resumen on others. Provisional pending native review." + }, + { + "source": "Try it", + "target": "Pruébalo", + "note": "Recurring section heading above a runnable example; one rendering everywhere (tú imperative with the clitic, accent kept). Provisional pending native review." + } + ] +} diff --git a/i18n/es/instructions.md b/i18n/es/instructions.md new file mode 100644 index 0000000000..f46d82f3c3 --- /dev/null +++ b/i18n/es/instructions.md @@ -0,0 +1,170 @@ +# Spanish (es) — translation instructions + +Target language: Spanish (español), one global variant for Latin America +and Spain alike, directory and URL code `es`, page language tag `es`. This +file is sent verbatim with every translation request for this language, on +top of the shared rules in `../general-prompt.md`. The termbase in +`glossary.json` is sent alongside it and wins any terminology conflict. + +## 1. Register + +Write the relaxed, direct register of modern open-source documentation in +Spanish: professional, plain-spoken, addressed to a colleague. + +- Address the reader as **tú**, always, with matching verb forms and + pronouns (puedes, tu servidor, te devuelve). Never usted (ejecute, su, le), + never vosotros or its forms (ejecutáis, vuestro, os), never voseo (podés, + tenés). Where the English "you" is plainly plural ("you and your team"), + the plural is **ustedes**, never vosotros. +- Steps are bare tú imperatives: "Install the SDK, then run the server" → + Instala el SDK y luego ejecuta el servidor — not Instale (usted), not Debes + instalar, no por favor before every step; prohibitions are no + subjunctive + (no llames, no uses). The authorial "we" is nosotros. +- Spanish drops the subject pronoun. Let the verb carry the person; write tú + only for contrast — two or three explicit tú on a page is a lot. "Your + server" is usually el servidor; tu servidor when ownership is the point. + Impersonal se constructions (se instala con uv) are welcome for describing + behaviour and keep a page from becoming a wall of imperatives, but a + third-person verb aimed at the reader is an usted form and is wrong. +- One page, one register: a page that drifts between tú and usted, or slips + in one vosotros or vos form, is wrong even when each sentence is fine. +- One global Spanish. Where regions differ, use the form understood + everywhere and pinned in the glossary (archivo, not fichero; computadora, + not ordenador); avoid words that are everyday in one region and odd in + another (vale, coger; accesar, checar). ejecutar, not correr, a program; + "enter a value" → escribe or indica (ingresar and introducir each read as + regional); "click **Tools**" → haz clic en **Tools**. + +## 2. Voice + +The English is warm, direct and confident: short sentences, second person, +the occasional one-line payoff ("That's the whole API."). Developer Spanish +carries that voice naturally; keep it, and keep the payoff lines short — Esa +es toda la API. + +- Follow Spanish rhythm: split a long English sentence in two rather than + chaining clauses with commas; use plain connectives (así que, es decir, + por eso) where they help. Prefer concrete verbs to noun stacks (realizar la + ejecución de → ejecutar) and the active voice or pasiva refleja to a + calqued passive: "The tool is called by the model" → el modelo llama a la + herramienta, not la herramienta es llamada por el modelo. +- Keep the directness ("don't" is no uses, not quizá convenga evitar), skip + the hype, and avoid officialese: el presente documento, dicho / dicha + everywhere, el mismo as a pronoun, a nivel de, en base a, cabe destacar. +- Verbs are Spanish even when the noun is borrowed: never deployar, setear, + loguear, debuggear, testear, parsear, pushear — write desplegar, configurar, + registrar, depurar, probar, analizar, enviar; hacer un commit, hacer push. +- False friends and calques: librería for a code library (→ biblioteca), + eventualmente for "eventually" (→ con el tiempo), actual for "actual" + (→ real), soportar for "support" (→ admite / es compatible con), remover + (→ quitar / eliminar), asumir (→ suponer), the gerundio de posterioridad + ("…, generando un error"), and "under the hood" → internamente, not bajo + el capó. retornar is understood everywhere, but this corpus pins devolver. + +Example — English: "You don't construct it and you don't configure it. You +ask for it." + +- Not this (usted, pronoun in every clause): Usted no lo construye y usted + no lo configura. Usted lo solicita. +- Not this either (voseo, slang, added emphasis): No lo construís ni lo + configurás. Lo pedís y listo, ¡facilísimo! +- This: No lo construyes ni lo configuras. Lo pides. + +## 3. Humour and idioms + +- The English is friendly and dry rather than jokey, and conversational + Spanish absorbs that easily; what needs work is idiom. Never translate a + pun, idiom or aside word for word: say what it means in a short natural + sentence in the same register. A widely understood turn of phrase (y + listo, sin más) is fine, a regional one is not; culture-bound references + take the plain meaning. An aside that carries no information may go — + never a technical caveat that happens to be phrased lightly. +- Recurring English tags get fixed renderings: "**[X](…)** has the whole + story" → **[X](…)** tiene todos los detalles; "The whole story is in + **[X](…)**" → Todos los detalles están en **[X](…)**; "That's the whole + API." / "That is the whole API." → Esa es toda la API.; "That's the whole + protocol." → Ese es todo el protocolo.; "That's it. It's just Python." → + Eso es todo. Es simplemente Python.; "You get `3` back. ✨" → Te devuelve + `3`. ✨; "Out of the box the app answers **only** requests addressed to + localhost." → Por defecto, la app responde **solo** a las solicitudes + dirigidas a localhost. — never recién sacada de la caja. +- Exclamation marks: English exports far more than Spanish prose wants. Keep + one only where the source is genuinely emphatic, always paired ¡…!, never + doubled, never in a heading, never after a warning or error description. +- Emoji: keep the source's rare, deliberately placed emoji exactly where they + are (two payoff lines end in ✨); never add one, never in a heading. "Give + a parameter a default value and it stops being required. That's it. It's + just Python." → Dale un valor por defecto a un parámetro y deja de ser + obligatorio. Eso es todo. Es simplemente Python. — not ¡Eso es todo, es + solo Python! ✨ (merged sentences, added exclamation and emoji). + +## 4. Typography + +- Questions and exclamations always open with the inverted mark — ¿Dónde va + esto?, ¡Listo! — placed where the question starts: Si falla, ¿qué ves? +- Sentence case for headings, admonition titles, tab labels and table + headers: first word and proper nouns only (Configurar el transporte). An + English -ing heading becomes an infinitive or a noun phrase, never a + gerund: "Handling errors" → Manejo de errores, not Manejando errores. + Language names and months are lower-case (en inglés, en julio); capitals + keep their accents; solo and este / ese / aquel never take one. +- Punctuation characters stay as the source has them: straight double quotes + "…" (not «…», not curly), ASCII apostrophes, parentheses and colons. No + space before : ; ! ? and lower case after a colon unless a proper noun or + code follows. An English em-dash aside becomes commas, parentheses, a + colon or a second sentence; if a dash pair truly reads best, use the raya + pegada —así—, never an English " — " floating between spaces. +- Digits stay ASCII and identifiers are copied byte for byte: protocol + revisions such as `2026-07-28`, versions, ports, status and error codes, + RFC and SEP numbers — never 28/07/2026 or 28 de julio de 2026. Ordinary + quantities keep the source's form, decimal point included (2.5 segundos), + since decimal conventions differ across the Spanish-speaking world. A + space between number and unit (100 MB, 30 s); % as in the source (100%). +- e.g. → por ejemplo, i.e. → es decir, vs → frente a, etc. stays; & → y, + and y → e / o → u also before English words (clientes e IDE). +- Loanwords kept in English are plain type — no italics, no quotes — with a + Spanish article: el token, la API. Emphasis lands where the source puts + it; a bolded negation stays bold ("does **not** raise" → **no** lanza). + +## 5. Terminology pointer + +The termbase `glossary.json` is injected separately and overrides anything +written here. This section fixes the conventions its renderings assume: + +- Terms in the glossary's `keep` list and every identifier — class, function, + method, parameter, module and package names, protocol method strings + (`tools/call`, `notifications/...`), header names, environment variables, + anything in code font — are copied byte for byte. Acronyms and product + names take no plural s in Spanish: "the SDKs" → los SDK, "both APIs" → + ambas API; the article carries the number. You may name the kind of thing + in front: la clase `Context`, el parámetro `lifespan=`. A glossary term + used as a code-font identifier stays English although its prose noun is + translated: "the `sampling` capability" → la capacidad `sampling`. +- Text quoted from what the example code prints or displays — an output + line, a log message, an error string, a UI label such as the Inspector's + **Tools** and **Resources** tabs — stays exactly as the code emits it + (usually English), with no Spanish reading added in brackets. +- English nouns kept in English keep their spelling, take a fixed gender and + pluralise with -s (los tokens, los callbacks). Masculine by default — el + token, el handler, el callback, el host, el prompt, el endpoint, el log, + el middleware, el payload, el timeout; feminine where settled — la API, la + URL, la URI, la CLI, la web, la caché, la terminal (el terminal is Spain + usage). A gender given in a glossary note wins. +- First-use gloss: a translated MCP concept the reader may need to map back + to the English specification carries the English in parentheses on its + first occurrence in the page body — muestreo (sampling) — and appears alone + after that, never glossed in a heading. Each glossary note says which + terms take the gloss. +- Python vocabulary follows the established Spanish of the Python + documentation: devolver un valor, lanzar una excepción, argumento + nombrado, decorador, cadena, entorno virtual, tiempo de ejecución, hilo, + de terceros, asíncrono. One rendering per term per page: the glossary + target, every time — also where a note marks the choice as provisional. + +## 6. Provisional note + +Every decision above, and every entry in `glossary.json`, is provisional +pending review by native Spanish-speaking readers from more than one region. +To propose a change — a rendering that reads as regional, a rule that yields +stiff Spanish, a term to pin — edit this file or `glossary.json` in a pull +request; the generated pages under `pages/` are never edited by hand. diff --git a/i18n/es/notices.md b/i18n/es/notices.md new file mode 100644 index 0000000000..3bf5486f80 --- /dev/null +++ b/i18n/es/notices.md @@ -0,0 +1,20 @@ +--- +translation: + sections: [aff1b3e872b7876a, 4d80558ad052d586, 0bb81f1e62062d26, d5c35dcec50156bc] + tool: 1 +--- +# Avisos de traducción {#translation-notices} + +Una de estas notas aparece al principio de cada página de un sitio de documentación traducido. + +## Traducción automática {#translated} + +Esta página se tradujo automáticamente a partir de la documentación en inglés, y la [página en inglés](ENGLISH_PAGE) es la versión de referencia. Si algo no se lee bien, [Traducciones](TRANSLATIONS_PAGE) explica cómo avisarnos. + +## Traducción desactualizada respecto a la página en inglés {#outdated} + +La página en inglés cambió después de que se hizo esta traducción, así que algunas partes pueden estar desactualizadas. En caso de duda, lee la [página en inglés](ENGLISH_PAGE); [Traducciones](TRANSLATIONS_PAGE) explica cómo funciona la documentación traducida. + +## Se muestra en inglés {#english} + +No hay una traducción vigente de esta página, así que la estás leyendo en inglés. [Traducciones](TRANSLATIONS_PAGE) explica cómo funciona la documentación traducida. diff --git a/i18n/es/pages/advanced/apps.md b/i18n/es/pages/advanced/apps.md new file mode 100644 index 0000000000..682404a1b2 --- /dev/null +++ b/i18n/es/pages/advanced/apps.md @@ -0,0 +1,165 @@ +--- +translation: + sections: [0355618e5f4d5fe4, 1821eaf50f2d0b64, 82e0b28ebd3abf5a, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487] + tool: 1 +--- +# MCP Apps {#mcp-apps} + +Una **MCP App** es una herramienta con cara visible: además de sus datos, la herramienta apunta a un +documento HTML que el host muestra como una superficie interactiva. + +Dos partes, siempre dos partes: + +1. **Una herramienta** que hace el trabajo y devuelve datos, como cualquier otra herramienta. +2. **Un recurso `ui://`** que contiene el HTML que el host muestra para ella. + +La herramienta lleva una referencia `_meta.ui.resourceUri` al recurso. El host lo obtiene +con `resources/read`, lo muestra en un **iframe aislado (sandboxed)** y envía el resultado de la +herramienta a ese iframe mediante `postMessage`. El servidor nunca envía ni recibe +mensajes `ui/*`: ese tráfico ocurre entre el host y el iframe. Tú sirves una herramienta +y un documento HTML; el host monta el espectáculo. + +El SDK incluye esto como la extensión integrada `Apps` (`io.modelcontextprotocol/ui`). +Si las [extensiones](extensions.md) son nuevas para ti, échale un vistazo primero a esa página. Un minuto, +y luego vuelve. + +## Un reloj con cara visible {#a-clock-with-a-face} + +```python title="server.py" hl_lines="19 22 30 32" +--8<-- "docs_src/apps/tutorial001.py" +``` + +Cuatro pasos: + +* `Apps()`: una sola instancia contiene tus herramientas vinculadas a una UI y sus recursos. +* `@apps.tool(resource_uri="ui://clock/app.html")`: una herramienta normal, más la + marca `_meta.ui.resourceUri`. Todo lo que acepta `@mcp.tool()` (name, title, + description, ...) se pasa tal cual. +* `apps.add_html_resource("ui://clock/app.html", CLOCK_HTML)`: el recurso + correspondiente, servido como `text/html;profile=mcp-app`. Ese tipo MIME exacto es lo que + le dice a un host "esto es una app, muéstrala". +* `MCPServer("clock", extensions=[apps])`: la activación. El servidor ahora anuncia + `io.modelcontextprotocol/ui` bajo `capabilities.extensions`. + +El HTML en sí escucha el `postMessage` del host y muestra el resultado. Para apps +reales, usa el SDK oficial de navegador [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps) +dentro de tu HTML. Te da `ontoolresult`, `callServerTool`, +`getHostContext` y `onhostcontextchanged` en lugar de eventos de mensaje sin procesar. + +## Degradación elegante {#graceful-degradation} + +No todos los clientes muestran apps. La especificación es tajante sobre lo que eso significa para ti: + +> Tools **MUST** return a meaningful `content` array even when UI is available. + +El modelo lee `content`; el iframe es para humanos. Un host capaz de mostrar UI sigue entregando +el resultado en texto al modelo, y un cliente solo de texto recibe *solo* eso. Así que el +patrón canónico es una herramienta, dos respuestas. Mira `get_time` de nuevo: + +```python title="server.py" hl_lines="23-27" +--8<-- "docs_src/apps/tutorial001.py" +``` + +`client_supports_apps(ctx)` es `True` solo cuando el cliente declaró la +extensión `io.modelcontextprotocol/ui` **y** incluyó `text/html;profile=mcp-app` +en su configuración `mimeTypes`. El campo es obligatorio, así que un cliente que lo omite +no cuenta. Eso es exactamente lo que declara `main()` en el mismo archivo: la +mitad cliente de la negociación, y vuelve la respuesta enriquecida. + +!!! warning + Nunca devuelvas un marcador de posición como `"[Rendered UI]"` como único contenido. Si el + texto alternativo es inútil, la herramienta es inútil para todos los clientes solo de texto y para + el propio modelo. Escribe la frase. + +## Blindar el iframe {#locking-the-iframe-down} + +El lado del recurso lleva los metadatos de seguridad: qué puede cargar el iframe, qué +permisos del navegador quiere, cómo le gustaría que lo enmarcaran: + +```python title="server.py" hl_lines="9 19-22" +--8<-- "docs_src/apps/tutorial002.py" +``` + +`csp` y `permissions` son **solicitudes al host**, no comportamiento del servidor. El host +construye las políticas Content-Security-Policy y Permissions-Policy del iframe a partir de ellas, y +puede negarse. Detecta las funcionalidades en tu JS en lugar de suponer que se concedieron. + +`ResourceCsp`, campo por campo (nombre en Python, clave en el canal, qué hace el host con ella): + +| Python | Canal (`_meta.ui.csp`) | Controla | +|---|---|---| +| `connect_domains` | `connectDomains` | `connect-src`: adónde pueden ir `fetch`/XHR | +| `resource_domains` | `resourceDomains` | `img-src`, `style-src`, ...: recursos estáticos | +| `frame_domains` | `frameDomains` | `frame-src`: iframes anidados | +| `base_uri_domains` | `baseUriDomains` | `base-uri`: a qué puede apuntar `` | + +`ResourcePermissions`: cada campo solicita un permiso del navegador para el iframe. + +| Python | Canal (`_meta.ui.permissions`) | +|---|---| +| `camera` | `camera` | +| `microphone` | `microphone` | +| `geolocation` | `geolocation` | +| `clipboard_write` | `clipboardWrite` | + +!!! note + La CSP y los permisos viven en el **recurso**, nunca en la herramienta. Los metadatos de herramienta + de la especificación no tienen hueco para ellos, y los hosts los ignoran ahí. El SDK hace que el + error sea imposible de representar: `@apps.tool()` simplemente no tiene parámetro `csp`. + +### Visibilidad {#visibility} + +`visibility=["app"]` en una herramienta dice "esto existe para el iframe, no para el modelo": + +* `"model"`: el modelo puede llamarla. +* `"app"`: el iframe puede llamarla (mediante `callServerTool`). +* Omitido: ambos, que es el valor por defecto. + +Filtrar es tarea del **host**. El servidor lista las herramientas exclusivas de app en `tools/list` +como cualquier otra; el host las oculta al modelo. No filtres en el servidor. + +## Las reglas que el SDK hace cumplir {#the-rules-the-sdk-enforces} + +Todas estas fallan al arrancar, no en producción: + +* Un `resource_uri` o una URI de recurso que no sea `ui://...` es un `ValueError` en el + momento de decorar o registrar. +* Una herramienta vinculada a una URI **sin un recurso registrado que corresponda** es un `ValueError` + cuando `MCPServer(extensions=[apps])` consume la extensión. Una herramienta que anuncia + un HTML que responde 404 en `resources/read` es un error de configuración, así que se niega a + construirse. +* `meta={"ui": ...}` en `@apps.tool()` es un `ValueError`. El decorador es dueño de + `_meta["ui"]`; exprésalo con `resource_uri=` y `visibility=`. Otras claves de `meta=` + se combinan sin problema al lado. + +Ni el SDK ext-apps de TypeScript ni FastMCP detectan hoy ninguno de estos casos; preferimos +que te enteres antes de que lo haga un host. + +## Más allá del HTML en línea {#beyond-inline-html} + +`add_html_resource` cubre el caso común: una cadena de HTML. Para cualquier otra cosa, +HTML en disco o contenido generado, construye el recurso tú mismo y entrégalo: + +```python title="server.py" hl_lines="12 18" +--8<-- "docs_src/apps/tutorial003.py" +``` + +`add_resource` rellena el tipo MIME `text/html;profile=mcp-app` cuando el recurso +no fija uno explícitamente, y rechaza una discrepancia explícita: un recurso `ui://` +con cualquier otro tipo MIME es uno que ningún host va a mostrar. + +!!! tip + ¿Apuntas a un host previo a la disponibilidad general que todavía lee la clave plana + obsoleta `_meta["ui/resourceUri"]`? Combínala tú mismo: + `@apps.tool(resource_uri="ui://x", meta={"ui/resourceUri": "ui://x"})`. + El objeto `ui` anidado es la forma de la especificación; la clave plana está de salida. + +## Verlo en marcha {#see-it-run} + +La historia `apps` en `examples/stories/` es esta página en forma de pareja ejecutable: un servidor +con una herramienta de reloj vinculada a una UI y un cliente que negocia Apps, lee el +`_meta.ui.resourceUri` de la herramienta, obtiene el HTML y llama a la herramienta. + +```bash +uv run python -m stories.apps.client +``` diff --git a/i18n/es/pages/advanced/extensions.md b/i18n/es/pages/advanced/extensions.md new file mode 100644 index 0000000000..74a2163033 --- /dev/null +++ b/i18n/es/pages/advanced/extensions.md @@ -0,0 +1,260 @@ +--- +translation: + sections: [05891e7cc1938a13, b3c01a6af28c51ee, 7ffc91f5e38bdfe0, 717d3f235a8333a7, f471a13b2fe5d737, ed6af2df4b656dff] + tool: 1 +--- +# Extensiones {#extensions} + +Una **extensión** es un paquete opcional de comportamiento MCP detrás de un único identificador. + +En un servidor puede aportar herramientas, recursos y nuevos métodos de solicitud, y puede envolver +`tools/call`. En un cliente puede reclamar formas de resultado adicionales de `tools/call` y observar +notificaciones de proveedor. Cada lado se anuncia bajo su propio `capabilities.extensions`, y nada +cambia para quien no lo haya pedido. Ese es el contrato ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)), y +tiene una regla de oro: **las extensiones están desactivadas por defecto**. + +## Usar una extensión {#using-an-extension} + +Pasa las instancias al construir: + +```python title="server.py" +--8<-- "docs_src/extensions/tutorial001.py" +``` + +Listo. El servidor ahora anuncia `io.modelcontextprotocol/ui` bajo +`capabilities.extensions` y sirve todo lo que aporta la extensión. + +`Apps` es la extensión de referencia incorporada y tiene su propia página: **[MCP Apps](apps.md)**. + +!!! note + Las extensiones se fijan al construir. No hay un `add_extension` que llamar después: + el mapa de capacidades de un servidor no debería cambiar mientras haya clientes conectados a él. + +El mapa de capacidades viaja en `server/discover`, que es una ruta de **2026-07-28**. Un +handshake `initialize` heredado no tiene dónde ponerlo, así que un cliente heredado simplemente +no ve la extensión. Diseña pensando en eso: una extensión *amplía* un servidor, no debe ser la +única forma de usarlo. + +## Escribir la tuya {#writing-your-own} + +Crea una subclase de `Extension` y sobrescribe solo lo que necesites. Cada método tiene un valor por defecto. + +### El identificador {#the-identifier} + +```python +--8<-- "docs_src/extensions/tutorial002.py" +``` + +El identificador es una cadena `vendor-prefix/name` que sigue la gramática de claves `_meta` +de la especificación: etiquetas separadas por puntos (cada una empieza con una letra y termina +con una letra o un dígito), una barra y luego el nombre. Se valida **cuando se define la clase**, +así que un error tipográfico no espera a que arranque un servidor: + +```text +TypeError: Stamps.identifier must be a `vendor-prefix/name` string +(reverse-DNS prefix required), got 'stamps' +``` + +Usa como prefijo un dominio que controles. `io.modelcontextprotocol/*` es para extensiones +especificadas por el propio proyecto MCP. + +### Aportar herramientas {#contributing-tools} + +La extensión útil más pequeña es una herramienta y un mapa de ajustes: + +```python title="server.py" hl_lines="17 19-20 22-23 26" +--8<-- "docs_src/extensions/tutorial003.py" +``` + +* `tools()` devuelve objetos `ToolBinding`. El servidor registra cada uno exactamente como si + hubieras llamado tú a `mcp.add_tool(...)`: la misma generación de esquema, la misma inyección + de `Context`, todo igual. +* `settings()` es el valor anunciado en `capabilities.extensions["com.example/stamps"]`. + Devuelve `{}` (el valor por defecto) para anunciar la extensión sin ajustes. +* La extensión nunca recibe el servidor. Declara sus aportaciones como datos; + `MCPServer` las consume. No hay un `self.server` que mutar. + +Y `main()` es la prueba, un cliente en memoria directamente contra `mcp`: + +```python title="server.py" hl_lines="29-34" +--8<-- "docs_src/extensions/tutorial003.py" +``` + +### Servir tus propios métodos {#serving-your-own-methods} + +Una extensión puede registrar **nuevos métodos de solicitud**: sus propios verbos, servidos junto a los +de la especificación: + +```python title="server.py" hl_lines="16-22 31 40-48" +--8<-- "docs_src/extensions/tutorial004.py" +``` + +* `SearchParams` es una subclase de `RequestParams`, así que el sobre `_meta` de 2026 se analiza + de forma uniforme y tu handler recibe parámetros validados, nunca un diccionario en bruto. Acota lo + que controla el cliente: `Field(ge=1, le=100)` rechaza un `limit` absurdo antes de que + tu código reserve nada para él. +* `require_client_extension(ctx, EXTENSION_ID)` es el filtro: un cliente que no declaró + la extensión recibe el error `-32021` (falta una capacidad de cliente requerida), + con el payload legible por máquina `requiredCapabilities` que pide la especificación. +* `protocol_versions=frozenset({"2026-07-28"})` fija el método a una única versión del protocolo. + En cualquier otra versión el cliente recibe `METHOD_NOT_FOUND`, exactamente como si el método + no existiera ahí. Para ese cliente, no existe. + +Los métodos son **estrictamente aditivos**. El SDK lo hace cumplir al construir, no en +tiempo de ejecución: + +* Un `MethodBinding` para un método definido por la especificación (`tools/list`, `completion/complete`, ...) + lanza `ValueError` cuando se construye el binding. Los verbos principales pertenecen al servidor. +* Dos extensiones que vinculan el mismo método lanzan una excepción cuando se registra la segunda. + Que gane la última escritura es como los plugins se corrompen entre sí; aquí no hacemos eso. +* Un conjunto `protocol_versions` vacío también lanza una excepción: un método que nunca puede + servirse es un bug, no una configuración. + +### El lado del cliente {#the-client-side} + +El `main()` del mismo archivo es toda la historia del cliente, sus dos mitades: + +```python title="server.py" hl_lines="54-58" +--8<-- "docs_src/extensions/tutorial004.py" +``` + +* `Client(..., extensions=[advertise(EXTENSION_ID)])` declara la extensión. Las + declaraciones se convierten en `ClientCapabilities.extensions`: en una conexión 2026-07-28 + el mapa viaja en el sobre `_meta` de cada solicitud, así que el servidor lo ve en + **cada** solicitud; en una conexión heredada viaja en el handshake `initialize`. + Al código del servidor le da igual cuál: `require_client_extension(ctx, ...)` y + `ctx.session.check_client_capability(...)` leen la fuente correcta en ambas rutas. +* Los métodos de proveedor bajan una capa hasta `client.session.send_request(...)`; `Client` + solo incorpora métodos de primera clase para los verbos de la especificación. `send_request` + acepta cualquier subclase de `Request`, así que la solicitud de proveedor pasa tal cual. + +### Interceptar `tools/call` {#intercepting-toolscall} + +El único hook que intercepta. Sobrescribe `intercept_tool_call` para observar, cortocircuitar +o vetar una llamada a herramienta: + +```python title="server.py" hl_lines="17-24" +--8<-- "docs_src/extensions/tutorial005.py" +``` + +* `params` es el `CallToolRequestParams` validado: obtienes `params.name` y + `params.arguments` sin tocar JSON en bruto. También es lo que decide qué llamada a + herramienta se ejecuta: pasar un contexto reescrito a través de `call_next` cambia lo que + el handler observa en `ctx`, no la invocación de la herramienta. Reescribir solicitudes a + nivel del canal es cosa de [Middleware](middleware.md). +* `call_next(ctx)` ejecuta el resto de la cadena y devuelve el resultado del handler. + Devuélvelo sin cambios (observar), devuelve otra cosa (reemplazar) o lanza un + `MCPError` (rechazar). Lo que devuelvas se serializa como cualquier resultado de + handler, incluido el sello de identidad `serverInfo` de la generación 2026, así que un + interceptor que cortocircuita nunca produce una respuesta anónima o fuera de esquema. +* Con varias extensiones, los interceptores se anidan en orden de registro: la primera + extensión en `extensions=[...]` es la más externa. +* La implementación por defecto deja pasar todo, y un servidor cuyas extensiones nunca + sobrescriben este hook mantiene intacto el handler `tools/call` sin más. No + pagas por lo que no usas. + +El hook envuelve `tools/call` y nada más. Para lo que afecta a cada mensaje, usa +[Middleware](middleware.md). Para eso está. + +## Usar una extensión de cliente {#using-a-client-extension} + +Una **extensión de cliente** es el mismo contrato desde el lado que consume: un paquete de +comportamiento del lado del cliente detrás de un único identificador. Pasa las instancias a +`Client(extensions=[...])` y llama a las herramientas con normalidad: + +```python title="client.py" hl_lines="66-68" +--8<-- "docs_src/extensions/tutorial006.py" +``` + +`call_tool("buy", ...)` devuelve un `CallToolResult` normal, como cualquier otra llamada. Lo que +cambió la extensión: el servidor ahora puede responder a `buy` con una **forma de resultado** +`receipt` en lugar de un resultado final, y `Receipts` la termina (aquí canjeando el +recibo con una llamada de seguimiento) antes de que `call_tool` devuelva. Nada del punto +de llamada se mueve. + +Quita la extensión y nada de esto existe: el filtro del servidor rechaza a un cliente +que no la declaró (error -32021), y una forma reclamada procedente de un servidor que +se salta el filtro falla la validación, exactamente como exige la especificación para un +`resultType` no reconocido. Desactivada por defecto, en ambos extremos del canal. + +Para anunciar un identificador **sin** comportamiento del lado del cliente (el servidor filtra +según la capacidad, el cliente no hace nada, como en el cliente de búsqueda de arriba), usa +`advertise()`: + +```python +from mcp.client import advertise + +client = Client(mcp, extensions=[advertise("com.example/search")]) +``` + +## Escribir una extensión de cliente {#writing-a-client-extension} + +Crea una subclase de `ClientExtension` y sobrescribe solo lo que necesites. Tres tipos de +aportación, cada uno con un valor por defecto: `settings()`, `claims()` y `notifications()`. + +```python title="client.py" hl_lines="17-18 43-44 46-47" +--8<-- "docs_src/extensions/tutorial006.py" +``` + +* El identificador sigue la misma gramática que el del servidor y se valida cuando se + define la clase. +* `claims()` devuelve objetos `ResultClaim`: una etiqueta del canal, el modelo que la analiza y el + resolutor que la termina. El modelo debe fijar la etiqueta con + `result_type: Literal["receipt"]` y no debe ser subclase de los tipos de resultado principales + del verbo; ambas cosas se hacen cumplir cuando se construye el claim. Los campos de proveedor como + `receipt_token` viajan por el canal tal cual: una forma sustituida llega al cliente + literalmente. +* El resolutor recibe el modelo analizado y un `ClaimContext`; `ctx.session` es el + mismo identificador público que `client.session`, así que los seguimientos son llamadas de + sesión normales. Devuelve el `CallToolResult` normal del verbo. +* `settings()` es el valor anunciado en `ClientCapabilities.extensions[identifier]`, + leído una vez al construir el `Client`. + +`notifications()` declara las notificaciones de servidor de proveedor que se van a observar: + +```python +def notifications(self) -> Sequence[NotificationBinding[Any]]: + return [NotificationBinding(method="notifications/receipts", params_type=ReceiptEvent, handler=self.on_receipt)] +``` + +El handler recibe los parámetros validados de uno en uno, en orden de despacho. Observa; no puede vetar +ni responder. + +Dos reglas discretas. Los claims solo están activos en conexiones 2026-07-28, y el anuncio de +capacidades los sigue: en una conexión heredada los claims se disuelven y el identificador sale +del anuncio con ellos, así que el cliente nunca anuncia una extensión cuyas formas +rechazaría. Y cuando quieras la forma reclamada tú mismo en lugar del resolutor, +llama a `client.session.call_tool(..., allow_claimed=True)`; sin esa bandera, una +forma reclamada que llega a un llamador del nivel de sesión lanza `UnexpectedClaimedResult`. + +### Verbos de extensión {#extension-verbs} + +Los métodos de solicitud propios de una extensión no necesitan registro del lado del cliente. Un tipo +de solicitud de proveedor es una subclase de `mcp.types.Request` y pasa por `client.session.send_request`, +como en [Servir tus propios métodos](#serving-your-own-methods). Un añadido: cuando una +clave de los parámetros debe viajar en el header `Mcp-Name` (las especificaciones de extensión, como +tasks, lo exigen para sus verbos), el tipo de solicitud declara `name_param`: + +```python title="client.py" hl_lines="22-25 46-47" +--8<-- "docs_src/extensions/tutorial007.py" +``` + +La sesión replica `params["jobId"]` en `Mcp-Name` en cada ruta de envío, y un +valor ausente falla de forma visible en lugar de omitir en silencio un header obligatorio. + +## Lo que una extensión no puede hacer {#what-an-extension-cannot-do} + +La superficie de aportación es **cerrada** a propósito. En el servidor: ajustes, herramientas, +recursos, métodos y un interceptor de `tools/call`. En el cliente: ajustes, claims de +resultado y bindings de notificación. Una extensión no puede: + +* **Meterse en el host.** Declara datos; no guarda ninguna referencia al servidor ni al cliente. +* **Reemplazar el comportamiento principal.** Los métodos de la especificación y las etiquetas de + resultado principales se rechazan al construir (el runner reserva `initialize` directamente); un + binding de notificación eclipsado por el vocabulario principal se silencia con un aviso en su lugar. +* **Registrarse tarde.** Una vez que `MCPServer(...)` o `Client(...)` devuelven, el conjunto + de extensiones es el que es. + +Si estás peleando contra estos muros, no estás escribiendo una extensión. Estás escribiendo +un fork. Los muros son la funcionalidad: quien lee `extensions=[Apps(), Stamps()]` +sabe *todo* lo que esas dos pueden haber tocado. diff --git a/i18n/es/pages/advanced/index.md b/i18n/es/pages/advanced/index.md new file mode 100644 index 0000000000..3bf3e1a79c --- /dev/null +++ b/i18n/es/pages/advanced/index.md @@ -0,0 +1,34 @@ +--- +translation: + sections: [ca6988b7503cd2d3] + tool: 1 +--- +# Avanzado {#advanced} + +Todo lo que necesita un servidor o un cliente normal tiene su sitio temático en las secciones anteriores. +Esta sección reúne las vías de escape a las que recurres cuando la capa de conveniencia de `MCPServer` +te estorba: + +* **[El Server de bajo nivel](low-level-server.md)**: la clase sobre la que está construido `MCPServer`. + Esquemas escritos a mano, handlers `on_*`, nada se comprueba por ti, y métodos JSON-RPC + personalizados propios. +* **[Paginación](pagination.md)** y **[Middleware](middleware.md)**: dos cosas que + *solo* puedes hacer en el `Server` de bajo nivel. +* **[Extensiones](extensions.md)** y **[MCP Apps](apps.md)**: la superficie de + extensión del protocolo. Compón paquetes de extensión en un servidor o escribe los tuyos. + +Algunas cosas que sería razonable buscar aquí viven, en cambio, donde realmente las +usarías: + +* **Autorización** está en **[Ejecutar tu servidor](../run/index.md)**, porque un + servidor se protege donde se despliega. +* **OAuth**, la **aserción de identidad**, la conexión a **varios servidores** y la + **caché** de respuestas están en **[Clientes](../client/index.md)**. +* Las **solicitudes de varias idas y vueltas (multi-round-trip)** y las **suscripciones** están en + **[Dentro de tu handler](../handlers/index.md)**, porque ambas son cosas que un + handler *hace*. +* Las **plantillas de URI** están en **[Servidores](../servers/index.md)**, junto a Recursos. +* **[Versiones del protocolo](../protocol-versions.md)** y + **[Funcionalidades obsoletas](../deprecated.md)** tienen cada una su propia página de nivel superior. + +Si no tienes claro si necesitas esta sección, no la necesitas. diff --git a/i18n/es/pages/advanced/low-level-server.md b/i18n/es/pages/advanced/low-level-server.md new file mode 100644 index 0000000000..1068b8956f --- /dev/null +++ b/i18n/es/pages/advanced/low-level-server.md @@ -0,0 +1,207 @@ +--- +translation: + sections: [2c79b6338e09b7ac, 7edc43b3fae11314, 1086e77ce561cd7f, a3f71823df5efc31, 9fc7109f72201cae, 7bf25983df655b66, 6330e1f4c6029683, 2f1749c8c133fa1c, b3530fcf4d11fd56, ebc33704fbd74262, cd0e9c933350390e] + tool: 1 +--- +# El Server de bajo nivel {#the-low-level-server} + +`@mcp.tool()` es una capa. Debajo hay una segunda clase de servidor, `Server`, que habla MCP en crudo: le pasas los objetos del protocolo y los transmite tal cual, sin tocarlos. + +`MCPServer` está construido encima. Bajas de nivel cuando la capa de conveniencia estorba: + +* Necesitas emitir un esquema **exacto** (cargado de un archivo, generado a partir de una base de datos), no uno derivado de una firma de Python. +* Necesitas control total del resultado: `_meta`, `is_error`, cada clave de `structured_content`. +* Necesitas atender un método que MCP no define. + +Para todo lo demás, quédate en `MCPServer`. + +## La misma herramienta, a mano {#the-same-tool-by-hand} + +Esta es la herramienta `search_books` que **[Herramientas](../servers/tools.md)** escribe en nueve líneas de `@mcp.tool()`, sin el azúcar sintáctico: + +```python title="server.py" hl_lines="22 26 32" +--8<-- "docs_src/lowlevel/tutorial001.py" +``` + +Cambiaron tres cosas, y son toda la API de bajo nivel: + +* **Los handlers son parámetros del constructor.** `on_list_tools=` y `on_call_tool=` van en `Server(...)`. Aquí abajo no hay decoradores, y todos los handlers tienen la misma forma: `async (ctx, params) -> result`. +* **Tú escribes el esquema de entrada.** `Tool.input_schema` es un simple `dict` de JSON Schema. Nadie lo deriva de las anotaciones de tipo, porque no hay anotaciones de tipo de las que derivarlo. +* **Tú construyes el resultado.** `CallToolResult(content=[TextContent(...)])`, a mano. Nada se envuelve, se convierte ni se infiere de una anotación de retorno. + +`params` es la solicitud ya analizada: `CallToolRequestParams` te da `.name` y `.arguments`. `ctx` es un `ServerRequestContext`: `ctx.session` para responder al cliente, `ctx.lifespan_context`, `ctx.request_id` y `ctx.meta`, el `_meta` entrante de la solicitud. + +!!! info + Si has usado FastAPI, ya conoces esta relación. `MCPServer` es la capa de decoradores y anotaciones de tipo; `Server` es el Starlette de debajo. No son rivales: `MCPServer` construye un `Server` y registra en él handlers exactamente como estos. + +### Pruébalo {#try-it} + +Aquí no hay Inspector: `mcp dev` y `mcp run` solo aceptan un `MCPServer`. Al `Client` en memoria le da igual; acepta un `Server` de bajo nivel exactamente igual que acepta un `MCPServer`: + +```python title="main.py" +import asyncio + +from mcp import Client + +from server import server + + +async def main() -> None: + async with Client(server) as client: + result = await client.call_tool("search_books", {"query": "dune", "limit": 5}) + print(result.content) + + +asyncio.run(main()) +``` + +```text +[TextContent(type='text', text="Found 3 books matching 'dune' (showing up to 5).", annotations=None, meta=None)] +``` + +El mismo texto que produjo la versión con `@mcp.tool()`. Dos diferencias honestas: + +* `result.structured_content` es `None`. El servidor de alto nivel envuelve un `-> str` en `{"result": ...}` por ti; aquí nadie construye lo que no construiste. +* `list_tools` devuelve el esquema que escribiste **tú**, carácter por carácter. La versión de alto nivel tenía `"title": "Query"` en cada propiedad y un `"title": "search_booksArguments"` en la raíz: artefactos de Pydantic. Aquí abajo, si se transmite, es porque lo pusiste ahí. + +## Nada se comprueba por ti {#nothing-is-checked-for-you} + +`MCPServer` rechaza un argumento incorrecto antes de que tu función llegue a ejecutarse, validando la llamada contra el esquema que generó (**[Herramientas](../servers/tools.md)**). + +`Server` no hace eso. Tu `input_schema` se *anuncia* al cliente; nunca se *aplica* a `params.arguments`. + +!!! check + Llama a `search_books` sin `limit` y tu `args["limit"]` lanza `KeyError`. El cliente ve: + + ```text + MCPError: Internal server error + ``` + + Un error de JSON-RPC, código `-32603`, con un mensaje deliberadamente genérico: el SDK no filtra tu traceback a un llamador remoto. El modelo nunca se entera de qué hizo mal, así que no puede reintentar. (En una prueba, `raise_exceptions=True` muestra la excepción real en su lugar; consulta **[Pruebas](../get-started/testing.md)**.) + +Eso se generaliza. Una excepción lanzada desde un handler de bajo nivel es **siempre** un error de protocolo, nunca un resultado de herramienta con `is_error=True`. Si quieres que el modelo lea el fallo y se recupere, valida `params.arguments` por tu cuenta y devuelve `CallToolResult(content=[TextContent(...)], is_error=True)`. Los dos tipos de fallo son el tema de **[Manejo de errores](../servers/handling-errors.md)**. + +## Dos herramientas, un handler {#two-tools-one-handler} + +`on_call_tool` es el único punto de entrada para todas las herramientas del servidor. Enrutas según `params.name`: + +```python title="server.py" hl_lines="38-43" +--8<-- "docs_src/lowlevel/tutorial002.py" +``` + +* `list_tools` anuncia las dos. `call_tool` despacha según el nombre. +* La rama `else` importa: `Server` reenvía sin problema un `tools/call` para un nombre que nunca listaste directamente a tu handler. Lanzar una excepción ahí convierte la llamada en el mismo `-32603` de antes. + +## Salida estructurada, a mano {#structured-output-by-hand} + +Declara `output_schema` en el `Tool` y pon `structured_content` en el resultado. Ambos son cosa tuya: + +```python title="server.py" hl_lines="19-23 36" +--8<-- "docs_src/lowlevel/tutorial003.py" +``` + +Llámala y el resultado lleva ambas representaciones: + +```json +{ + "content": [{"type": "text", "text": "Found 3 books matching 'dune'."}], + "structuredContent": {"matches": 3, "query": "dune"}, + "isError": false, + "resultType": "complete", + "_meta": {"io.modelcontextprotocol/serverInfo": {"name": "Bookshop", "version": "2.0.0"}} +} +``` + +El bloque `_meta` es el sello de identidad del servidor: el SDK lo añade a cada resultado de la generación 2026, con la `version` del constructor (un servidor que no establece ninguna informa una cadena vacía). Un servidor que no deba identificarse puede quitar la clave con un middleware, que es dueño de los resultados que devuelve. + +El servidor nunca compara los dos campos. El `Client` de este SDK sí: devuelve un `structured_content` que no cumpla el `output_schema` que declaraste y `call_tool` lanza un `RuntimeError` que empieza por `Invalid structured content returned by tool search_books` y sigue citando el fallo de `jsonschema`. Prometer un esquema es barato; cumplirlo depende de ti. Toda la escalera de tipos de retorno y esquemas está en **[Salida estructurada](../servers/structured-output.md)**. + +## `_meta`: para la aplicación, no para el modelo {#\_meta-for-the-application-not-the-model} + +`content` es la parte de la respuesta que lee el modelo. `structured_content` es la misma respuesta como datos tipados. `_meta` es el tercer canal: datos que viajan con el resultado para la **aplicación cliente**, sin formar parte de la respuesta en absoluto. + +Úsalo para ID de registros, ID de trazas, cualquier cosa que tu interfaz necesite y tu prompt no: + +```python title="server.py" hl_lines="37" +--8<-- "docs_src/lowlevel/tutorial004.py" +``` + +* Lo construyes como `_meta=`, el nombre que se transmite. El cliente lo lee de vuelta como `result.meta`. +* Pon tus claves en un espacio de nombres (`bookshop/record_ids`). Las claves `io.modelcontextprotocol/*` están reservadas por el protocolo. + +!!! warning + `_meta` es una convención entre tú y la aplicación cliente, no una garantía sobre lo que llega + al modelo. El host decide qué muestra. Nunca pongas un secreto en ninguna parte de un resultado de herramienta. + +## Las capacidades siguen a tus handlers {#capabilities-follow-your-handlers} + +Un `Server` anuncia exactamente las familias de métodos para las que le diste handlers. El `Bookshop` de arriba pasa `on_list_tools` y `on_call_tool` y nada más, así que un cliente que se conecta a él ve: + +```json +{"tools": {"listChanged": false}} +``` + +Ni `resources` ni `prompts`: no hay nada que los respalde. Pasa `on_list_prompts` y aparece `prompts`; pasa `on_completion` y aparece `completions`. + +`MCPServer` siempre anuncia herramientas, recursos y prompts, hayas registrado alguno o no, porque sus gestores siempre existen. Aquí abajo la declaración *es* la llamada al constructor. + +## El genérico del lifespan {#the-lifespan-generic} + +`Server` es genérico en el tipo que produce su lifespan. Anótalo una vez y el objeto queda tipado en todos los sitios donde aparece: + +```python title="server.py" hl_lines="24-26 44-45 50" +--8<-- "docs_src/lowlevel/tutorial005.py" +``` + +* El lifespan es un `Callable[[Server[Catalog]], AbstractAsyncContextManager[Catalog]]`; `@asynccontextmanager` sobre un generador `async` te da exactamente eso. +* Lo que produzca con `yield` se convierte en `ctx.lifespan_context`, y como los handlers están anotados como `ServerRequestContext[Catalog]`, `.search(...)` se autocompleta y pasa la comprobación de tipos. +* Se entra en él una vez cuando el servidor arranca y se sale una vez cuando se detiene. El arranque, la finalización y la versión de `MCPServer` de la misma idea están en **[Lifespan](../handlers/lifespan.md)**. + +Sin un `lifespan=`, `ctx.lifespan_context` es un `dict` vacío. + +## Un método propio {#a-method-of-your-own} + +El constructor cubre los métodos que MCP define. `add_request_handler` cubre todo lo demás: + +```python title="server.py" hl_lines="35-36 39-40 43-44 48" +--8<-- "docs_src/lowlevel/tutorial006.py" +``` + +* El primer argumento es la cadena del método. Las notificaciones tienen un gemelo, `add_notification_handler`. +* `params_type` es el modelo contra el que se validan los `params` entrantes **antes** de que se ejecute tu handler, así que los métodos personalizados *sí* reciben la validación que las herramientas no. Hereda de `RequestParams` para que el campo `_meta` se analice como el de cualquier otro método. +* El handler devuelve un `BaseModel`, un `dict` o `None`. El SDK lo serializa en el resultado JSON-RPC. + +Una advertencia honesta: el `Client` de alto nivel solo tiene verbos para los métodos que MCP define, así que no hay `client.reindex()`. Un método de proveedor es para un par que ya sabe que existe: un cliente que también distribuyes, u otro servicio tuyo que hable JSON-RPC. + +Un método que no puedes reclamar: + +```text +ValueError: 'initialize' is handled by the server runner and cannot be overridden; +use Server.middleware to observe or wrap initialization +``` + +El handshake pertenece al runner. `server/discover`, `ping` y todos los demás métodos integrados son tuyos para reemplazarlos. + +!!! tip + `Server.middleware`, mencionado en ese error, envuelve **todos** los mensajes entrantes, incluido `initialize`. Si lo que quieres es observar o reescribir el tráfico en vez de responder a un método nuevo, empieza en **[Middleware](middleware.md)**. + +## Los otros handlers {#the-other-handlers} + +Cada uno de estos es una idea para la que ya tienes el vocabulario; cada uno tiene su propia página. + +* `on_call_tool`, `on_get_prompt` y `on_read_resource` pueden devolver un `InputRequiredResult` en lugar de su resultado normal para pausar la llamada y pedir datos al cliente; consulta **[Solicitudes de varias idas y vueltas (multi-round-trip)](../handlers/multi-round-trip.md)**. Fiel a este nivel, nada se instala por ti: donde `MCPServer` sella `requestState` por defecto, aquí el `request_state` que estableces se transmite exactamente como lo escribiste hasta que optas por `server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[...]), default_audience=server.name))`: una línea (ambos nombres se importan de `mcp.server.request_state`) para el mismo sellado y verificación que realiza `MCPServer` (**[Proteger `requestState`](../handlers/multi-round-trip.md#protecting-requeststate)**). +* `on_list_resources`, `on_read_resource`, `on_list_prompts`, `on_get_prompt` y `on_completion` tienen la misma forma `(ctx, params) -> result` para las demás primitivas. +* `on_subscriptions_listen` sirve el stream `subscriptions/listen` de 2026-07-28. Pasa un `ListenHandler` construido sobre un `SubscriptionBus` y publica eventos en el bus desde tus otros handlers; consulta **[Suscripciones](../handlers/subscriptions.md)** para la composición completa. +* `server.streamable_http_app()` devuelve la misma app de Starlette que la de `MCPServer`; despliégala como **[Ejecutar tu servidor](../run/index.md)** despliega cualquier otra app ASGI. Aquí abajo no hay `server.run(transport=...)`: `server.run(read_stream, write_stream, server.create_initialization_options())` conduce una conexión sobre un par de streams, y esa única línea es todo lo que hay. + +## Resumen {#recap} + +* El `Server` de bajo nivel recibe sus handlers como **parámetros del constructor** `on_*`; cada handler es `async (ctx, params) -> result`. +* Tú escribes el dict `input_schema` y tú construyes el `CallToolResult`. Nada se deriva, se envuelve ni se valida por ti. +* Una excepción en un handler es un error de protocolo `-32603`. Un error de herramienta que el modelo pueda leer es un `CallToolResult` con `is_error=True` que devuelves **tú**. +* El `_meta` del resultado va dirigido a la aplicación cliente, no al modelo. +* `Server[T]` es genérico en lo que produce su lifespan; `ctx.lifespan_context` es un `T` tipado. +* `add_request_handler(method, params_type, handler)` sirve cualquier método. `initialize` está reservado. +* Las capacidades que anuncia un `Server` se derivan de los handlers que registraste. + +`Client(server)` trató a ambos servidores de forma idéntica porque *son* el mismo protocolo, que es justamente la idea. La siguiente capa hacia abajo no es una clase: es **[Middleware](middleware.md)**. diff --git a/i18n/es/pages/advanced/middleware.md b/i18n/es/pages/advanced/middleware.md new file mode 100644 index 0000000000..7ad8f1b7c8 --- /dev/null +++ b/i18n/es/pages/advanced/middleware.md @@ -0,0 +1,126 @@ +--- +translation: + sections: [6048b4f308edbb8c, 068bda0f21ee9c1b, c3e565b61acd75c5, c62422b159c6ed09, 47204fab253cc45c] + tool: 1 +--- +# Middleware {#middleware} + +Un **middleware** es una función asíncrona que envuelve cada mensaje que recibe el servidor. + +Lo escribes como `async (ctx, call_next)` y lo añades a `server.middleware`. Esa es toda la API. + +!!! warning + La lista de middleware está marcada como **provisional** en el código fuente: su firma y su + semántica pueden cambiar en una versión menor 2.x. Úsala para *observar* (medir tiempos, + registrar, trazar) y para *rechazar* mensajes; no la conviertas en los cimientos del servidor. + +`MCPServer` recibe la lista en el constructor (`MCPServer(name, middleware=[...])`) y la expone como +`mcp.middleware`; el `Server` de bajo nivel expone la misma lista como `server.middleware`. El ejemplo +de abajo usa el `Server` de bajo nivel; si `Server(name, on_call_tool=...)` es nuevo para ti, lee +primero **[El Server de bajo nivel](low-level-server.md)**. + +## Un middleware que mide tiempos {#a-timing-middleware} + +Un servidor, una herramienta y un middleware que registra cuánto tardó cada mensaje: + +```python title="server.py" hl_lines="39-45 49" +--8<-- "docs_src/middleware/tutorial001.py" +``` + +* `ctx` es el mismo `ServerRequestContext` que reciben tus handlers. `ctx.method` es la cadena + del método sin procesar; `ctx.params` son los parámetros sin procesar, **antes** de cualquier + validación. +* `call_next(ctx)` ejecuta el resto de la cadena: la validación, la búsqueda del handler y tu + handler. Devuelve lo que devolvió y la respuesta queda intacta. +* El `try`/`finally` es deliberado: un handler que lanza una excepción también se cronometra, + porque el fallo llega a tu middleware como la excepción que sale de `call_next`. +* `server.middleware.append(...)` lo registra. La lista se ejecuta de fuera hacia dentro, así que + `middleware[0]` es el más cercano al canal. + +### Pruébalo {#try-it} + +Conecta un cliente, lista las herramientas, llama a una. El log tiene **tres** líneas: + +```text +server/discover took 18.3 ms +tools/list took 0.1 ms +tools/call took 0.1 ms +``` + +Hiciste dos llamadas y obtuviste tres líneas. La primera es `server/discover`: la solicitud que +envió el cliente para establecer la conexión, antes de que pidieras nada. + +Ese es el punto. El middleware envuelve **cada** mensaje entrante: + +* El establecimiento de la conexión: `server/discover`, o `initialize` y `notifications/initialized` + en una sesión heredada. +* Cada solicitud y cada notificación. Para una notificación, `ctx.request_id is None`, + `call_next(ctx)` devuelve `None` y lo que devuelvas se descarta. +* Incluso un método para el que el servidor no tiene handler: `call_next` lanza el + `MCPError(-32601, "Method not found")` *a través de* tu middleware de camino al cliente. + +## Qué puedes hacer dentro de uno {#what-you-can-do-inside-one} + +En orden creciente de cuánto deberías dudar: + +* **Observar.** Cronométralo, cuéntalo, regístralo. El ejemplo de arriba. +* **Rechazar.** Lanza un `MCPError` *en lugar de* llamar a `call_next(ctx)` y ese único mensaje se + responde con un error JSON-RPC. La conexión sigue activa; el siguiente mensaje pasa. Así es + como un servidor restringe `subscriptions/listen` por llamante: + **[Decidir quién puede observar](../handlers/subscriptions.md#deciding-who-may-watch)** en la + página de Suscripciones lo recorre paso a paso. +* **Reescribir.** `ctx` es una dataclass: `await call_next(dataclasses.replace(ctx, params=...))` + entrega al resto de la cadena unos parámetros distintos de los que envió el cliente. Nunca hagas + esto con `initialize`: el resultado que recibe el cliente se construye a partir de tus parámetros + reescritos, pero el servidor fija el estado de la conexión a partir de los parámetros originales + que llegaron por el canal. Los dos lados pueden terminar el handshake en desacuerdo sobre lo que + negociaron. +* **Responder.** Devuelve un resultado sin llamar a `call_next(ctx)` y llega al cliente como tu + respuesta. `call_next` te entrega la forma final que se transmite, y la canalización nunca + retoca lo que devuelves, así que todo el sobre es tuyo: en una conexión de la generación 2026 + eso incluye la marca `_meta` de `serverInfo`, que el SDK añade a los resultados de los handlers + pero no a los tuyos. + +!!! check + `initialize` es una de las cosas que el middleware envuelve, y es el *único* punto de enganche + que tienes para ello. Intenta apropiártelo con `add_request_handler` y el SDK se niega: + + ```text + ValueError: 'initialize' is handled by the server runner and cannot be overridden; + use Server.middleware to observe or wrap initialization + ``` + +!!! warning + `initialize` se maneja en línea: el servidor no lee más mensajes entrantes hasta que tu cadena + de middleware devuelve. Esperar con await una solicitud del servidor al cliente + (`ctx.session.send_request(...)`, una elicitación) mientras se maneja `initialize` **bloquea + la conexión por completo**: la respuesta que esperas nunca se podrá leer. Las notificaciones + que se envían sin esperar respuesta no dan problemas. + +## El único middleware que viene activado por defecto {#the-one-middleware-that-ships-on-by-default} + +El SDK incluye exactamente un middleware, y ya está en la lista del servidor: el que emite un +span de OpenTelemetry por cada mensaje. No lo añades y, la mayor parte del tiempo, ni piensas en +él. No hace nada hasta que instalas un exportador, y tiene su propia página: +**[OpenTelemetry](../run/opentelemetry.md)**. + +!!! info + Si has escrito middleware ASGI, ya conoces esta forma. El `(scope, receive, send)` de + Starlette se convirtió en `(ctx, call_next)`, y se ejecuta *después* del transporte, sobre el + mensaje ya decodificado en lugar de la solicitud HTTP sin procesar. Los dos se combinan: el + middleware de Starlette sobre `streamable_http_app()` ve HTTP; este ve MCP. + +## Resumen {#recap} + +* Un middleware es `async (ctx, call_next) -> result`, se pasa como `MCPServer(middleware=[...])` (o + se añade a `mcp.middleware`), y se añade a `server.middleware` en el `Server` de bajo nivel. +* Envuelve **cada** mensaje entrante (`server/discover`, `initialize`, solicitudes, notificaciones, + métodos desconocidos) y se ejecuta de fuera hacia dentro. +* `ctx.request_id is None` es la forma de distinguir una notificación de una solicitud. +* Lanza una excepción en lugar de llamar a `call_next` para rechazar un mensaje; la conexión sobrevive. +* El trazado con OpenTelemetry del propio SDK también es un middleware, ya incluido en la lista. Consulta + **[OpenTelemetry](../run/opentelemetry.md)**. +* Toda la superficie es provisional. Observa con ella; no construyas sobre ella. + +Eso es todo lo que envuelve una solicitud. **[Autorización](../run/authorization.md)** es lo que decide si la solicitud +llega a ejecutarse siquiera. diff --git a/i18n/es/pages/advanced/pagination.md b/i18n/es/pages/advanced/pagination.md new file mode 100644 index 0000000000..d1abdfc04f --- /dev/null +++ b/i18n/es/pages/advanced/pagination.md @@ -0,0 +1,85 @@ +--- +translation: + sections: [a9aba7a026c7bd85, ed32bda7ba9ae33a, 7e64cc5646abb91f, 22a0129ee78b3c63, d875373c06d8d2f9] + tool: 1 +--- +# Paginación {#pagination} + +La mayoría de los servidores nunca necesitan esto. + +`MCPServer` responde a cada solicitud `list_*` con todo lo que tiene, en una sola página, `next_cursor=None`. Para unas cuantas docenas de herramientas, recursos o prompts esa es la respuesta correcta y no hay nada que configurar. + +La paginación es para el servidor cuya lista de recursos es en realidad una base de datos: miles de filas que se niega a serializar en una sola respuesta. La respuesta del protocolo es un **cursor**: el servidor devuelve una página más un token opaco, y el cliente envía ese token de vuelta para obtener la siguiente página. + +`@mcp.resource()` no tiene ningún punto de extensión para nada de eso. Para paginar, escribes el handler de listado tú mismo, sobre el **[Server de bajo nivel](low-level-server.md)**. + +## Un servidor que pagina {#a-server-that-pages} + +```python title="server.py" hl_lines="12 15-16" +--8<-- "docs_src/pagination/tutorial001.py" +``` + +* En un `Server` de bajo nivel, los handlers son argumentos del constructor, no decoradores. `on_list_resources` responde a cada solicitud `resources/list`; esa es toda la conexión necesaria. +* Todo handler paginado lleva el tipo `params: PaginatedRequestParams | None`, y el ejemplo acepta ambos. Sin embargo, a través de una conexión el SDK nunca te entrega `None` (una solicitud sin miembro `params` llega al handler como el modelo con sus valores por defecto), así que la señal que importa es `params.cursor is None`: **empieza desde el principio**. +* Tú decides qué *es* un cursor. Aquí es un desplazamiento representado como cadena. Una marca de tiempo, una clave primaria, un blob en base64: cualquier cosa que puedas generar de salida y reconocer cuando vuelva. +* `next_cursor=None` es la forma de decir "esa fue la última página". No hay recuento, ni total, ni `has_more`. `None` es toda la señal. + +!!! tip + Un `PAGE_SIZE` de 10 hace legible el ejemplo. Elige el tuyo por endpoint: una lista de + recursos de una línea se puede permitir una página de 500; una lista de plantillas de prompt voluminosas, no. + El cliente no tiene voz en ello, y así está diseñado. + +### Pruébalo {#try-it} + +`Client(server)` se conecta a un `Server` de bajo nivel en memoria exactamente igual que se conecta a un `MCPServer`. + +Llama a `list_resources()` sin argumentos. Obtienes diez recursos, de `book-1` a `book-10`, y `next_cursor` es la cadena `"10"`. + +Devuélvelo con `list_resources(cursor="10")` y el primer recurso es `book-11`; el nuevo `next_cursor` es `"20"`. + +La décima página vuelve con `next_cursor` en `None`. Listo. + +## El bucle del cliente {#the-client-loop} + +Cada método `list_*` de `Client` (`list_tools`, `list_resources`, `list_resource_templates`, `list_prompts`) acepta el argumento nombrado `cursor=`. Vaciar una lista paginada es un solo `while True`: + +```python title="client.py" hl_lines="26-32" +--8<-- "docs_src/pagination/tutorial002.py" +``` + +* `cursor` empieza como `None`, así que la primera solicitud no lleva cursor. +* Extiende **antes** de mirar `next_cursor`: la última página también tiene recursos. +* `next_cursor is None` es la salida. Cualquier otra cosa vuelve directamente a `cursor=`, sin tocarla. + +Ejecuta su `main()` e imprime `100 resources`: diez páginas de diez, unidas por un bucle que nunca supo que había diez páginas. + +Es el mismo bucle que **[El cliente](../client/index.md)** muestra para cada verbo `list_*`, y no cuesta nada frente a un servidor que no pagina: `next_cursor` es `None` en la primera respuesta y el bucle se ejecuta una vez. + +## Las tres reglas {#the-three-rules} + +**Los cursores son opacos.** Un cliente nunca debe analizar, construir ni adivinar uno. La única fuente legítima de un cursor es el `next_cursor` de la página anterior, tal cual. + +**El servidor elige el tamaño de página.** No hay `limit=` en el protocolo. Si necesitas un tamaño de página distinto, cambias el servidor. + +**Un cliente que ignora la paginación sigue funcionando.** Llama a `list_resources()` una vez, obtiene los diez primeros y nunca se entera del `next_cursor` que descartó. Nada se rompe; simplemente ve menos. + +!!! check + Opaco significa opaco. Inventa un cursor (`list_resources(cursor="page-2")`) y no hay + nada que el protocolo pueda hacer por ti. Este servidor intenta `int("page-2")`, el handler lanza una excepción, + y lo que le vuelve al cliente es: + + ```text + MCPError(-32603, 'Internal server error', None) + ``` + + Un cursor que no obtuviste del servidor es un bug, no una petición de funcionalidad. + +## Resumen {#recap} + +* `MCPServer` devuelve todo en una página. La paginación es opcional, y la activas en el `Server` de bajo nivel. +* `on_list_resources` (y `on_list_tools`, `on_list_prompts`, `on_list_resource_templates`) recibe `PaginatedRequestParams | None`; `params.cursor` es `None` para la primera página. +* Devuelves una página más `next_cursor`: cualquier cadena que reconozcas después, o `None` cuando no queda nada. +* El bucle del cliente: pasa `cursor=`, acumula, repite hasta que `next_cursor is None`. +* Los cursores son opacos, el servidor es dueño del tamaño de página y un cliente que no pagina sigue recibiendo la primera página. + +El resto de la API del `Server` escrito a mano (`on_call_tool`, diccionarios `input_schema`, `_meta`) está en **[El Server de bajo nivel](low-level-server.md)**. diff --git a/i18n/es/pages/client/caching.md b/i18n/es/pages/client/caching.md new file mode 100644 index 0000000000..e8d1613c1a --- /dev/null +++ b/i18n/es/pages/client/caching.md @@ -0,0 +1,123 @@ +--- +translation: + sections: [9e7b9a1710e5aeba, b74ca4c1d2ddddee, fa8714e61bf90c5a, 04db67a886b7271c, 857690fb8f876800] + tool: 1 +--- +# Sugerencias de caché {#caching-hints} + +En el protocolo 2026-07-28, cada resultado que un servidor devuelve para `tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read` y `server/discover` lleva dos campos: `ttlMs`, cuántos milisegundos puede un cliente tratar el resultado como vigente, y `cacheScope`, si un resultado en caché puede compartirse entre usuarios (`"public"`) o pertenece a un único contexto de autorización (`"private"`). + +El servidor no guarda nada en caché. Los campos son una *declaración*: "esta lista de herramientas es la misma para todos y no cambiará durante un minuto". Un cliente (o un gateway delante de ti) puede entonces saltarse la ida y vuelta. Respetar las sugerencias es decisión del cliente; emitirlas es trabajo del servidor, y el SDK lo hace por ti. + +Por defecto, cada resultado dice `ttlMs: 0, cacheScope: "private"`: caducado de inmediato, nunca compartido. Eso siempre es seguro y siempre conforme. Si tus listas realmente son estables e idénticas para todos los que llaman, dilo en la construcción: + +```python title="server.py" hl_lines="5-8" +--8<-- "docs_src/caching/tutorial001.py" +``` + +* El mapa usa como clave el **nombre del método**, y los seis métodos que admiten caché son las únicas claves válidas. El parámetro tiene el tipo `Mapping[CacheableMethod, CacheHint]`, así que tu editor autocompleta las claves y marca un error tipográfico antes de ejecutar; lo que se le escape al verificador de tipos lanza una excepción en la construcción. +* Un método que no mencionas conserva los valores por defecto. El mapa es un conjunto de sobrescrituras, no un manifiesto. +* `CacheHint(ttl_ms=5_000)` dejó `scope` sin definir, así que sigue siendo `"private"`: cinco segundos de vigencia, por cada llamador. El alcance y el TTL son decisiones independientes. +* `"server/discover"` también es una clave válida, ya que el resultado de descubrimiento admite caché como cualquier lista. + +!!! warning + `cacheScope: "public"` significa que *cualquiera* puede recibir tu respuesta en caché. Un + gateway compartido entregará sin problema el resultado de un usuario a otro, incluso cuando + la solicitud estaba autenticada. Marca un resultado como `"public"` solo cuando sea idéntico + para todos los llamadores, y nunca uses `cacheScope` como control de acceso: es una etiqueta, + no un candado. + +## Sobrescritura por handler {#per-handler-override} + +En el `Server` de bajo nivel, los handlers construyen sus resultados a mano, y `ttl_ms` / `cache_scope` son simplemente campos de los modelos de resultado. Un handler que los define explícitamente siempre gana al mapa del constructor, campo por campo: + +```python title="server.py" hl_lines="10 16" +--8<-- "docs_src/caching/tutorial002.py" +``` + +El handler dijo `ttl_ms=1_000` y nada sobre el alcance. En lo que se transmite: `ttlMs: 1000` (el del handler, no el `60_000` del mapa) y `cacheScope: "public"` (el del mapa, porque el handler lo dejó sin definir). Lo explícito gana a lo configurado, y lo configurado gana a lo por defecto. Esto vale por campo, así que un handler puede fijar un campo y dejar el otro a la política de todo el servidor. + +Esta es también la vía de escape para dinámicas que el constructor no puede conocer: un handler que filtra `resources/read` por usuario puede devolver `cache_scope="private"` para una URI desde un servidor por lo demás público. + +Una salvedad sobre las listas paginadas: el protocolo exige el **mismo `cacheScope` en cada página** de una misma lista. El mapa del constructor lo cumple por construcción, ya que usa como clave el método, no la página. Pero un handler que sobrescribe el alcance se hace responsable de esa coherencia: sobrescríbelo en *todas* las páginas, nunca solo cuando hay un cursor, o la página uno y la página dos no coincidirán. + +## Lo que ve el cliente {#what-the-client-sees} + +En una sesión 2026-07-28, `Client` respeta las sugerencias por ti: tiene una caché de respuestas integrada, activada por defecto. Un resultado que llega con un `ttlMs` se almacena, y una llamada idéntica dentro de ese TTL se sirve desde la caché sin ida y vuelta. Un resultado que llega *sin* sugerencia no se guarda en caché: los resultados sin sugerencia reciben `CacheConfig.default_ttl_ms`, que es `0` por defecto (caducado de inmediato), así que un servidor que no declara nada ve exactamente el mismo tráfico llamada por llamada de siempre. + +```python title="client.py" hl_lines="33 35 38" +--8<-- "docs_src/caching/tutorial003.py" +``` + +Cuatro llamadas, tres consultas al servidor. La segunda llamada encontró una entrada vigente y nunca llegó al servidor; adelantar el reloj (inyectado) más allá del TTL hizo que la tercera volviera a consultar; la cuarta dijo `cache_mode="refresh"`. Ese argumento nombrado existe en los cinco verbos con caché (`list_tools`, `list_prompts`, `list_resources`, `list_resource_templates`, `read_resource`): + +* `"use"` (el valor por defecto) sirve una entrada vigente si la hay, y almacena lo consultado si no. +* `"refresh"` nunca sirve desde la caché: consulta al servidor y almacena el resultado, reemplazando lo que hubiera en caché. +* `"bypass"` hace la ida y vuelta sin tocar la caché en absoluto: ni lectura ni escritura. + +Hay una regla por encima de `"use"`: **las llamadas que llevan `meta` siempre llegan al servidor.** Una solicitud con `meta` definido (un token de progreso, campos de trazado) espera una solicitud real por el canal, así que con `cache_mode="use"` se trata como `"refresh"`: se omite la lectura de la caché, y el resultado obtenido sigue reemplazando la entrada en caché. `"bypass"` y un `"refresh"` explícito se comportan como siempre. + +Para desactivar la caché por completo, construye con `Client(server, cache=None)`: cada llamada vuelve a ser una ida y vuelta, y `cache_mode`, aunque se sigue aceptando, no hace nada. + +El alcance también se respeta automáticamente: las entradas `"private"` se asocian a la *partición* de la caché (más abajo), mientras que las `"public"` pueden optar por compartirse más ampliamente. Y **las notificaciones ganan al TTL** para las entradas exactas que nombran: una notificación `list_changed` desaloja el listado en caché correspondiente, y `resources/updated` desaloja la lectura en caché almacenada exactamente bajo su URI, por muy vigentes que estuvieran. En una conexión 2026-07-28 esas notificaciones llegan por un stream `subscriptions/listen` que abres con `client.listen(...)`, y el desalojo se completa antes de que tu observador vea el evento; **[Suscripciones](subscriptions.md)** es esa página. + +Una salvedad sobre `resources/updated`: el desalojo es solo por URI exacta. El contrato del almacén no tiene operación de enumerar ni de recorrer (igual que la implementación de referencia en TypeScript), así que una notificación que lleva la URI de un *sub*recurso no desaloja una lectura en caché de su padre. Si tu servidor señala los subrecursos de esta forma, vuelve a consultar el padre con `cache_mode="refresh"`. + +### Configurarla: `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`: dónde viven las entradas. Por defecto es un almacén en memoria nuevo por cliente; pasa tu propia implementación de `ResponseCacheStore` (respaldada por Redis, por ejemplo) para compartir una caché entre clientes o procesos. Los tipos del contrato (`ResponseCacheStore`, `CacheKey`, `CacheEntry` y el `InMemoryResponseCacheStore` por defecto) se pueden importar desde `mcp.client`. Una búsqueda puede emitir hasta dos `get` secuenciales al almacén (la rama privada, luego la pública), así que ajusta en consecuencia las expectativas de latencia de un almacén remoto. Un almacén personalizado **exige** una `partition` explícita. +* `partition`: la etiqueta de contexto de autorización que evita que las entradas `"private"` de un principal se sirvan a otro dentro de un almacén compartido. +* `target_id`: identidad explícita del servidor, para transportes personalizados y servidores en proceso (más abajo). +* `default_ttl_ms`: TTL aplicado a los resultados que no llevan sugerencia `ttlMs`. El `0` por defecto deja sin caché los resultados sin sugerencia. +* `share_public`: sirve entre particiones las entradas que el servidor afirma como `"public"` (más abajo). Desactivado por defecto. +* `clock`: la fuente de hora de reloj, en segundos desde la época Unix. Inyecta una, como hace el ejemplo de arriba, y las pruebas de caducidad no necesitan dormir. + +!!! warning "Partición = principal verificado" + Deriva `partition` de una **credencial verificada**, como el subject de un token validado. Nunca la derives de datos proporcionados por la solicitud, y nunca de la URL del servidor (la identidad del servidor es un eje de clave aparte). El SDK es una biblioteca sin autenticación propia: el ancla de confianza es quien construye el `CacheConfig`, que es el despliegue, no el inquilino. Un gateway multiinquilino crea un `CacheConfig` por cada principal autenticado. + + La partición también queda fija durante toda la vida del `Client`. Si el contexto de autorización de la conexión cambia a mitad de sesión (una reautenticación como un principal distinto, por ejemplo), la caché no lo sigue; construye un nuevo `Client` para el nuevo principal. + +Las claves de caché también llevan la **identidad del servidor**: la cadena de URL a la que te conectaste, sin el userinfo `user:pass@` y, por lo demás, exacta byte por byte. Sin normalizar mayúsculas, sin reordenar la query, sin limpiar la barra final. Normalizar de menos solo cuesta compartición, mientras que normalizar de más podría fusionar dos inquilinos (`?tenant=a` frente a `?tenant=b`), así que las URL superficialmente distintas simplemente no comparten entradas. Cuando no hay URL (un servidor en proceso, o una instancia de `Transport`), el cliente recibe en su lugar una identidad aleatoria por instancia; define `CacheConfig.target_id` para nombrar el servidor (con un almacén personalizado es obligatorio, y la construcción lo dice). La identidad se pasa por un hash sha256 antes de entrar en el material de la clave, así que una URL con secretos en su cadena de consulta nunca aparece en las claves del almacén. Tampoco registres tú la forma previa al hash. + +!!! warning "`share_public` confía en el servidor, para toda la flota" + Por defecto, incluso las entradas `"public"` permanecen dentro de su partición. `share_public=True` sirve las entradas que el servidor marcó `cacheScope: "public"` a **todas** las particiones que usan el almacén, confiando en la clasificación del servidor en nombre de todas ellas. Un servidor que pone `"public"` a datos por inquilino (por error o por malicia) filtra entonces la respuesta de un inquilino a los demás. La opción es deliberadamente solo de nivel constructor: el `cache_mode` por llamada puede restringir la caché, pero nada por llamada puede ampliar la compartición. + +### Lo que la caché nunca hace {#what-the-cache-never-does} + +* **Las llamadas del nivel de sesión la omiten.** `client.session.list_tools()` y compañía siempre hacen la ida y vuelta; la caché vive en los verbos de `Client`. +* **`server/discover` queda fuera.** El resultado de discover se entrega una vez, al conectar, y nunca entra en la caché de respuestas, incluso cuando lleva un `ttlMs`. Si persistes uno tú mismo para saltarte el sondeo de reconexión ([`prior_discover`](../protocol-versions.md#reconnecting-with-prior_discover)), su vigencia es tu responsabilidad: `DiscoverResult` lleva `ttl_ms` y `cache_scope`, ya analizados, exactamente para eso. +* **Las páginas de continuación nunca se guardan en caché.** Solo participan las llamadas sin cursor. Una página de continuación rechazada por un cursor caducado sí *desaloja* el listado en caché, porque el listado cambió por debajo. +* **Las lecturas de varias idas y vueltas (multi-round-trip) nunca se guardan en caché.** Un `read_resource` iniciado con `input_responses`/`request_state`, o uno que se resuelve a través de rondas de entrada, nunca entra en la caché (un MUST de la especificación). +* **El desalojo por notificación necesita notificaciones.** El desalojo es tan bueno como la entrega del transporte, y la ruta moderna en proceso (`Client(server)` con el `mode="auto"` por defecto) hoy no entrega notificaciones independientes. +* **El desalojo es diferido, no instantáneo.** Las notificaciones de la ruta de red se despachan desde tareas lanzadas aparte, así que una llamada que compite con la llegada de una notificación puede recibir una vez más la entrada previa al desalojo; la ventana está acotada por la latencia de despacho, y el desalojo igualmente se produce. +* **Sin stale-if-error.** Una entrada caducada nunca se sirve porque la nueva consulta falló; el error se propaga. +* **Sin reconsulta anticipada.** Una entrada almacenada se sirve hasta que caduca su TTL y la siguiente llamada después de eso paga la ida y vuelta; nada se refresca en segundo plano. +* **Sin coalescencia.** Dos llamadas idénticas concurrentes son dos consultas. +* **Ningún TTL de más de 24 horas.** Un `ttlMs` mayor, ya sea enviado por el servidor o configurado, se recorta al almacenar (`mcp.client.caching.MAX_TTL_MS`), lo que acota cuánto tiempo puede servirse cualquier entrada, por generosa que sea su sugerencia. +* En un **almacén compartido**, los clientes compiten entre sí. Cada cliente descarta su propia escritura cuando un desalojo adelantó a la consulta en curso, pero un cliente *coinquilino* aún puede volver a escribir una entrada que un desalojo que nunca vio había eliminado; y esa contabilidad de carreras está acotada a su vez: pasadas 4096 claves rastreadas, primero se descarta la guarda de la clave más antigua. Ambas ventanas se aceptan, y las cierra el límite de TTL de arriba. +* **Nada se sirve entre generaciones del protocolo.** Las entradas están acotadas a la versión de protocolo negociada: en un almacén persistente compartido, una sesión nunca sirve una entrada escrita bajo otra versión negociada (el mismo listado difiere de verdad según la generación, ya que el SDK quita los campos 2026 para las sesiones más antiguas). El desalojo, igualmente, solo toca las entradas de la generación actual; las entradas de otra generación simplemente caducan por TTL. + +### Leer las sugerencias por tu cuenta {#reading-the-hints-yourself} + +Las sugerencias también son campos normales en cada resultado que admite caché (`result.ttl_ms` y `result.cache_scope`, ya analizados), por si quieres añadir tu propia contabilidad encima de la caché integrada (o en lugar de ella). + +Contra un **servidor más antiguo** (protocolo anterior a 2026), los campos simplemente no aparecen en lo que se transmite, y los modelos muestran sus valores por defecto conservadores: `ttl_ms == 0` y `cache_scope == "private"`, caducado y sin compartir, la suposición correcta para un servidor que no declaró nada. La caché trata una sesión heredada de la misma forma: allí las sugerencias nunca se consultan (sean cuales sean las claves que aparezcan en lo que se transmite), solo se aplica `default_ttl_ms`, y su valor por defecto de `0` no guarda nada en caché, así que una conexión anterior a 2026 se comporta exactamente como antes de que existiera la caché. Si necesitas distinguir "el servidor dijo 0" de "el servidor no dijo nada", comprueba `"ttl_ms" in result.model_fields_set`: solo está definido cuando el campo llegó de verdad. + +## Clientes más antiguos {#older-clients} + +Los clientes con versiones del protocolo anteriores a 2026 nunca ven ninguno de los dos campos; el SDK los quita en la serialización para esas conexiones. Configura tus sugerencias una vez; no hay nada específico de versión que escribir. + +## Resumen {#recap} + +* Seis métodos llevan `ttlMs`/`cacheScope`; el SDK los deja por defecto en `0`/`"private"`, caducado y sin compartir, siempre seguro. +* `cache_hints={method: CacheHint(...)}` en la construcción (tanto en `MCPServer` como en `Server`) fija valores para todo el servidor por método. +* Un handler que define los campos en su resultado sobrescribe el mapa, campo por campo. +* `"public"` es una promesa de que el resultado es idéntico para todos los llamadores. No es control de acceso. +* `Client` respeta las sugerencias automáticamente: su caché de respuestas está activada por defecto, sirve entradas vigentes en lugar de volver a consultar, y no guarda nada en caché para servidores (o sesiones) que no proporcionan sugerencias. +* Por llamada, `cache_mode="refresh"` vuelve a consultar y `"bypass"` se salta la caché; `cache=None` en la construcción la desactiva por completo. diff --git a/i18n/es/pages/client/callbacks.md b/i18n/es/pages/client/callbacks.md new file mode 100644 index 0000000000..2369552e08 --- /dev/null +++ b/i18n/es/pages/client/callbacks.md @@ -0,0 +1,154 @@ +--- +translation: + sections: [adf3c545b5be46b6, 916cd3ab1c03f461, e9be7a8d0eb0a456, 565890a636288ecf, 6af7e49db9129ec3, 06b0238c174186af, 90c6043be435fcb0] + tool: 1 +--- +# Callbacks del cliente {#client-callbacks} + +Casi todas las solicitudes en MCP van en una sola dirección: del cliente al servidor. + +Un servidor también puede pedirle cosas al **cliente**: hacerle una pregunta al usuario, muestrear el modelo del usuario, listar las carpetas del espacio de trabajo del usuario. Respondes a esas solicitudes pasando **callbacks** a `Client(...)`. + +## Un servidor que pregunta {#a-server-that-asks} + +Aquí tienes un servidor cuya herramienta no puede terminar por sí sola: + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/client_callbacks/tutorial001.py" +``` + +* `ctx.elicit(...)` envía una solicitud `elicitation/create` **al cliente** y espera. +* La herramienta no devuelve nada hasta que alguien (una persona en un formulario, o tu código) proporciona un `name`. + +Esa es la mitad del servidor, y la página **[Elicitación](../handlers/elicitation.md)** se ocupa de ella. Esta página es el otro extremo de la conexión. + +## El callback de elicitación {#the-elicitation-callback} + +```python title="client.py" hl_lines="6-10 16-17" +--8<-- "docs_src/client_callbacks/tutorial002.py" +``` + +* Un callback de elicitación (elicitation) es `async (context, params) -> ElicitResult`. +* `params.message` es la pregunta. `params.requested_schema` es el JSON Schema de la respuesta que quiere el servidor. Un cliente real genera un formulario a partir de él; este lo rellena automáticamente. +* Devuelves `ElicitResult(action="accept", content={...})`, o `action="decline"`, o `action="cancel"`. La única otra opción es `ErrorData(...)`, que rechaza la solicitud y hace fallar toda la llamada. +* `context` es un `ClientRequestContext`: la `session` activa, el `request_id` del servidor y cualquier `meta` que haya adjuntado. + +!!! tip + `params` es una unión de los dos modos de elicitación. Aquí `params.mode` es `"form"`; una solicitud + `"url"` lleva `params.url` en lugar de un esquema. Un solo callback maneja ambos; bifurca según `params.mode`. + **[Elicitación](../handlers/elicitation.md)** muestra el patrón completo. + +### Pruébalo {#try-it} + +Llama a `issue_card` y observa ambos extremos. + +Tu callback recibe la pregunta del servidor, ya analizada: + +```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'} +``` + +Responde, `ctx.elicit(...)` se reanuda dentro de la herramienta y la herramienta termina: + +```python +result.content # [TextContent(type='text', text='Card issued to Ada Lovelace.')] +``` + +Un `tools/call` tuyo, un `elicitation/create` de vuelta desde el servidor, respondido por tu función, todo dentro de una sola llamada a herramienta. + +!!! info + `mode="legacy"` en la llamada a `Client(...)` hace trabajo real. Por defecto, `Client(...)` negocia la ruta + moderna del protocolo, y esa ruta no tiene canal de retorno (back-channel) para las solicitudes del servidor al cliente: `ctx.elicit` + falla antes de que tu callback llegue a ejecutarse. No lo decide el transporte; lo decide el protocolo + negociado, tanto en memoria como a través de una URL. Fija `mode="legacy"` siempre que tu cliente tenga + que responder a una; todas las pruebas detrás de esta página lo hacen. **[Versiones del protocolo](../protocol-versions.md)** tiene todos los detalles. + + En una sesión 2026-07-28 el callback no está muerto, se alimenta de otra forma: cuando una herramienta devuelve un + `InputRequiredResult` que lleva un `ElicitRequest`, `Client` despacha esa entrada al mismo + `elicitation_callback` y reintenta la llamada por ti. Ese flujo está en **[Solicitudes de varias idas y vueltas](../handlers/multi-round-trip.md)**. + +## Un callback es una capacidad {#a-callback-is-a-capability} + +Nunca le dijiste al servidor que tu cliente puede responder solicitudes de elicitación. Lo hizo el SDK. + +Cuando un cliente se conecta declara sus `capabilities`, la imagen especular de las del servidor. No escribes ese objeto. **Registrar un callback es la declaración.** + +| lo que pasas | lo que declara el cliente | +| --- | --- | +| `elicitation_callback=` | `"elicitation": {"form": {}, "url": {}}` | +| `sampling_callback=` | `"sampling": {}` | +| `list_roots_callback=` | `"roots": {"listChanged": true}` | +| ninguno de ellos | `{}` | + +Las subcapacidades de muestreo (sampling) son el único refinamiento: pasa `sampling_capabilities=SamplingCapability(tools=SamplingToolsCapability())` junto con `sampling_callback` cuando tu muestreador maneja los parámetros `tools` / `tool_choice`. Los servidores deben ver `sampling.tools` declarado antes de poder enviarlos. + +`logging_callback` y `message_handler` no están en la tabla. Manejan notificaciones, y las notificaciones no necesitan ninguna capacidad. + +El servidor lee la declaración con `ctx.session.check_client_capability(...)`. Añade una herramienta que lo haga: + +```python title="server.py" hl_lines="23-31" +--8<-- "docs_src/client_callbacks/tutorial003.py" +``` + +Conéctate solo con `elicitation_callback` y llámala: + +```python +result.structured_content # {'result': ['elicitation']} +``` + +Pasa los tres callbacks y obtienes `['elicitation', 'sampling', 'roots']`. No pases ninguno y obtienes `[]`. + +!!! check + Ahora haz lo incorrecto: conéctate **sin** `elicitation_callback` y llama a `issue_card` de todos modos. + + La solicitud `elicitation/create` del servidor sigue llegando a tu cliente, y el SDK la responde por + ti, con un error, porque nunca dijiste que pudieras manejarla. Ese error hunde toda la llamada. + `call_tool` no devuelve un resultado `is_error`; lanza: + + ```text + MCPError: Elicitation not supported + ``` + + Eso es un error de protocolo (`-32600`, *invalid request*), no un error de herramienta: no hay nada que + el modelo pueda leer y reintentar. Por eso vale la pena tener `client_features`: un servidor bien educado + comprueba antes de preguntar. + +## El par obsoleto {#the-deprecated-pair} + +`sampling_callback` responde a `sampling/createMessage`: el servidor pidiéndole a *tu* modelo que complete algo. `list_roots_callback` responde a `roots/list`: el servidor preguntando en qué directorios puede trabajar. + +Ambos funcionan. Ambos siguen la regla anterior. Y ambos atienden RPC que **la especificación 2026-07-28 elimina**: un servidor moderno no llama de vuelta a tu cliente a mitad de una solicitud, te devuelve la solicitud como parte del resultado de la herramienta (**[Solicitudes de varias idas y vueltas](../handlers/multi-round-trip.md)**). Los callbacks en sí no están muertos. Cuando un `InputRequiredResult` lleva un `CreateMessageRequest` o un `ListRootsRequest`, el bucle automático de `Client` lo despacha al mismo `sampling_callback` o `list_roots_callback` que registraste aquí. La lista completa está en **[Funcionalidades obsoletas](../deprecated.md)**. + +Sigues necesitando los callbacks para hablar con servidores que no han migrado. Las firmas: + +```python title="client.py" +--8<-- "docs_src/client_callbacks/tutorial004.py" +``` + +* Un callback de muestreo recibe los `CreateMessageRequestParams` completos (`messages`, `model_preferences`, `max_tokens`) y devuelve un `CreateMessageResult`. *Tú* ejecutas el modelo, como prefieras; el SDK solo transporta la solicitud. +* Un callback de roots no recibe ningún parámetro y devuelve un `ListRootsResult`. +* Cualquiera de los dos puede devolver `ErrorData(...)` en su lugar, para rechazar. + +Pásalos a `Client(...)` exactamente igual que `elicitation_callback`. + +## Los callbacks de notificaciones {#the-notification-callbacks} + +Dos más. Ninguno declara nada. + +`logging_callback` recibe el `notifications/message` que envía un servidor, como `LoggingMessageNotificationParams` (`level`, `logger`, `data`). El logging del protocolo está a su vez obsoleto según la especificación 2026-07-28 (**[Logging](../handlers/logging.md)** explica qué hacer en su lugar), así que este callback existe para los servidores que todavía lo emiten. En una conexión de la generación 2026 el callback por sí solo no te da nada, porque los servidores 2026 envían mensajes de log solo a las solicitudes que lo piden: pasa `log_level="info"` (u otro nivel) a `Client(...)` para marcar esa preferencia en cada solicitud y recibir ese nivel y los superiores. Los servidores anteriores a 2026 lo ignoran y mantienen su comportamiento de `logging/setLevel`. + +`message_handler` es el comodín: toda notificación del servidor que la sesión expone le llega (además de a su callback específico), y en un transporte basado en flujos también toda `Exception` a nivel de transporte. Dos nunca llegan: `notifications/cancelled` la aplica el SDK en lugar de exponerla, y la confirmación de suscripción de un flujo `listen()` activo la consume ese flujo. Anota el parámetro con `IncomingMessage` (`ServerNotification | Exception`, exportado desde `mcp.client`). El único patrón que vale la pena conocer es `if isinstance(message, Exception): raise message`, para que una conexión rota falle de forma visible en lugar de desvanecerse. + +## Resumen {#recap} + +* Un servidor puede enviar solicitudes al cliente. Las respondes con callbacks pasados a `Client(...)`. +* El callback de elicitación es el vigente: `async (context, params) -> ElicitResult`, una sola función para los modos formulario y URL. +* **Registrar un callback es declarar la capacidad.** Sin él, el SDK rechaza la solicitud del servidor en tu nombre y toda la llamada falla con `MCPError`. +* Un servidor lo averigua antes de preguntar con `ctx.session.check_client_capability(...)`. +* `sampling_callback` y `list_roots_callback` funcionan igual pero atienden funcionalidades obsoletas; los servidores modernos usan solicitudes de varias idas y vueltas en su lugar. +* `logging_callback` y `message_handler` reciben notificaciones. No declaran nada. + +El primer argumento de `Client(...)` es un objeto de transporte. **[Transportes del cliente](transports.md)** cubre todos los tipos. diff --git a/i18n/es/pages/client/identity-assertion.md b/i18n/es/pages/client/identity-assertion.md new file mode 100644 index 0000000000..9566936cab --- /dev/null +++ b/i18n/es/pages/client/identity-assertion.md @@ -0,0 +1,153 @@ +--- +translation: + sections: [a91322c46111d16d, 8e6fd6d6f59bb568, e7828fd2729b2c9d, a03ec26bfc678b65, 1034c653c0bcf1b0] + tool: 1 +--- +# Aserción de identidad {#identity-assertion} + +Un proveedor OAuth ordinario (**[Clientes OAuth](oauth-clients.md)**) empieza por hacerle una pregunta al servidor MCP: *¿en qué servidor de autorización confías?* Sigue la respuesta adonde apunte y, a partir de ahí, o bien una persona inicia sesión o bien un secreto compartido de antemano ocupa su lugar. + +Una empresa no quiere que ninguna de las dos cosas se decida servidor por servidor. Ya tiene un proveedor de identidad en marcha (Okta, Microsoft Entra ID, el tuyo propio); el usuario ya inició sesión en él esta mañana; y es el único lugar donde el equipo de seguridad quiere decidir quién puede acceder a qué. [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990), la extensión **Enterprise-Managed Authorization**, traslada la decisión allí. El IdP firma un JWT de corta duración, un **Identity Assertion JWT Authorization Grant**, el **ID-JAG**: una declaración de que *este usuario*, a través de *este cliente*, puede acceder a *este servidor MCP*. El cliente lo intercambia por un token de acceso ordinario. Sin navegador, sin pantalla de consentimiento, sin registro dinámico. + +Esta página cubre los dos extremos de ese intercambio. El servidor MCP en sí nunca cambia: sigue siendo el servidor de recursos de **[Autorización](../run/authorization.md)**, que comprueba cualquier token que le llegue. + +## Dos solicitudes de token {#two-token-requests} + +Hay dos autoridades distintas en juego, y saber distinguirlas por su nombre es casi todo lo que hace falta para entender esta página. El **IdP de la empresa** es el proveedor de identidad de tu organización: sabe quién es el empleado, es donde vive la política y es quien emite el ID-JAG. El SDK nunca habla con él. El **servidor de autorización MCP** es la misma parte que era en **[Autorización](../run/authorization.md)**: el emisor nombrado en los metadatos del servidor MCP, lo que acuña los tokens que ese servidor MCP acepta. En un flujo OAuth ordinario, esos dos roles suelen ser una sola caja. Aquí son dos, y toda la concesión consiste en que el segundo acepte confiar en el primero. + +El cliente hace una solicitud de token a cada uno. + +1. **Al IdP de la empresa.** El cliente intercambia el inicio de sesión del usuario (su token de ID de OpenID Connect) por el ID-JAG. Es un intercambio de tokens de [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693), es por completo la API de tu IdP y **el SDK no lo hace**. Lo haces tú, dentro de un callback asíncrono. Es también donde ocurre la decisión de política: un IdP que dice que no nunca emite el ID-JAG, y no hay nada que presentar. +2. **Al servidor de autorización MCP.** El cliente presenta el ID-JAG bajo la concesión `jwt-bearer` de [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) (`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`, con el ID-JAG como `assertion`) y recibe el token de acceso. **Esta es la solicitud que hace el SDK**, y aceptarla es lo único que esta página añade a un servidor de autorización. + +Todo lo que sigue es la segunda solicitud: el cliente que la envía y el servidor de autorización que la responde. + +## El cliente {#the-client} + +**`IdentityAssertionOAuthProvider`** vive en `mcp.client.auth.extensions.identity_assertion`. Como todos los proveedores de **[Clientes OAuth](oauth-clients.md)**, es un `httpx2.Auth`: construyes uno, lo pones en `auth=` y le pasas el `httpx2.AsyncClient` al transporte. + +```python title="client.py" hl_lines="49-50 53-61" +--8<-- "docs_src/identity_assertion/tutorial001.py" +``` + +Léelo desde abajo. + +* `main()` es el `main()` estándar de un cliente OAuth (**[Clientes OAuth](oauth-clients.md)**), sin cambiar una sola línea. Esa es la idea: una vez que existe el proveedor, nada de lo que viene después sabe qué concesión produjo el token. +* El proveedor recibe lo que los demás proveedores no pueden descubrir: un `client_id` y un `client_secret` que alguien **registró de antemano** en el servidor de autorización, el `issuer` de ese servidor de autorización y `assertion_provider`, un callback asíncrono que devuelve un ID-JAG nuevo cuando se le pide. +* `storage` es el mismo protocolo `TokenStorage`. Solo se llama a los dos métodos de tokens; aquí no hay registro dinámico, así que no hay ningún `client_info` que recordar. + +### El proveedor de aserciones {#the-assertion-provider} + +`fetch_id_jag(audience, resource)` es el único código que escribes. Se espera una vez por intercambio de tokens, nunca en la construcción, y solo *después* de que los metadatos del servidor de autorización se hayan obtenido y validado, de modo que un emisor mal configurado nunca filtra una aserción. Sus dos argumentos son dos de los claims con los que debe acuñarse el ID-JAG: `audience` es el emisor del servidor de autorización (el `aud` del ID-JAG) y `resource` es el identificador canónico del servidor MCP (el `resource` del ID-JAG). El tercero ya lo tienes: el claim `client_id` del ID-JAG debe nombrar el `client_id` que le diste al proveedor, o el servidor de autorización rechaza el intercambio. + +`idp_issue_id_jag`, justo encima, **no es tu código**. Hace las veces del proveedor de identidad y firma la aserción dentro del mismo proceso para que el archivo esté completo y puedas leer cada claim que lleva un ID-JAG. Un `fetch_id_jag` real hace, en cambio, la primera solicitud de token de la sección anterior: un intercambio de tokens de [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) contra tu IdP, definido por el borrador Identity Assertion JWT Authorization Grant que [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) perfila. El token de ID del usuario que inició sesión entra como `subject_token`, el `requested_token_type` es el URN propio del ID-JAG (`urn:ietf:params:oauth:token-type:id-jag`), `audience` y `resource` pasan tal cual, y la respuesta trae el ID-JAG. Ese intercambio, con esos nombres, es lo que debes buscar en la documentación de tu IdP. + +!!! tip + Se solicita un ID-JAG nuevo en cada intercambio, y esa es la idea: es una concesión de un + solo uso que vive minutos, y el servidor de autorización de esta página se niega a aceptar el + mismo dos veces. No lo guardes en caché. Lo que se reutiliza es el token de acceso que te compra. + +### El emisor es configuración {#the-issuer-is-configuration} + +Aquí está la inversión. `OAuthClientProvider` le pregunta al servidor de recursos qué servidor de autorización usar y sigue la respuesta adonde apunte. Este proveedor se niega a hacerlo: `issuer` es obligatorio, los metadatos de [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) se obtienen de la ruta well-known propia de ese emisor, el endpoint de token debe estar en el origen de ese emisor y al servidor de recursos nunca se le pregunta nada. + +La extensión no exige esto; es una elección deliberadamente más estricta. Este cliente lleva dos cosas que vale la pena robar, un secreto registrado de antemano y una aserción vinculada a una audiencia, y un cliente que dejara que un servidor MCP comprometido lo dirigiera al servidor de autorización de un atacante le enviaría ambas. Fijar el emisor en la construcción elimina esa conversación. + +!!! warning + El `issuer` configurado se compara con el campo `issuer` del documento de metadatos mediante la + comparación simple de cadenas de RFC 8414 §3.3: carácter por carácter, barra final incluida, + sin normalización. No lo adivines. Obtén `/.well-known/oauth-authorization-server` de tu + servidor de autorización y copia el valor `issuer` que devuelve. Para el servidor de + autorización de esta página es `https://auth.example.com/`, con la barra, porque su emisor se + construyó a partir de un objeto URL de pydantic. Una discrepancia detiene el flujo en + `OAuthFlowError: Authorization server metadata issuer + mismatch` antes de que se envíe una sola credencial o aserción. + +### Un cliente confidencial {#a-confidential-client} + +`client_secret` es obligatorio; el constructor lanza `ValueError` si falta. El perfil del IETF que hay debajo de [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) reserva esta concesión para clientes confidenciales, SEP-990 exige que el cliente se autentique, y este SDK hace cumplir ambas cosas insistiendo en un secreto compartido. `token_endpoint_auth_method` elige por dónde viaja: `client_secret_post` (el valor por defecto, en el cuerpo del formulario) o `client_secret_basic` (una cabecera HTTP Basic). El perfil también permite `private_key_jwt`; este proveedor no lo admite. + +!!! tip + Lee `client_secret` del entorno o de un gestor de secretos, nunca del control de versiones. + +### Lo que el proveedor hace por ti {#what-the-provider-does-for-you} + +La primera solicitud sale sin autenticar, y el `401` del servidor inicia el flujo. + +1. **Descubrimiento.** Obtiene los metadatos del servidor de autorización de la ruta well-known de [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) del emisor configurado, comprueba que el `issuer` del documento coincide y comprueba que el endpoint de token está en el origen del emisor. +2. **La aserción.** Espera tu `assertion_provider`. +3. **Intercambio.** Envía con POST la concesión `jwt-bearer` al endpoint de token, guarda el `OAuthToken` y repite tu solicitud original con `Authorization: Bearer ...`. + +Un `403` cuyo `WWW-Authenticate` indica `insufficient_scope` ejecuta los pasos 2 y 3 de nuevo con la unión de tu `scope` y el reclamado. (`scope` nunca es más que una petición; el servidor de autorización de esta página concede lo que dice el ID-JAG y nada más.) No hay token de actualización en ninguna parte de esto: cuando el token de acceso caduca, el siguiente `401` acuña un ID-JAG nuevo y vuelve a intercambiar, y *esa* es la palanca que tiene el IdP. Los fallos son las mismas dos excepciones que en el resto de **[Clientes OAuth](oauth-clients.md)**: `OAuthFlowError` para el descubrimiento y la validación, y su subclase `OAuthTokenError` cuando el endpoint de token dice que no. + +## El servidor de autorización {#the-authorization-server} + +La mayoría de las veces te detienes aquí. El servidor de autorización MCP es el producto de otra persona, aceptar ID-JAG es una configuración suya que hay que activar, y la mitad de [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) que le toca al SDK es el cliente de arriba. + +El SDK también puede *ser* el servidor de autorización: `create_auth_routes` devuelve las rutas del servidor de autorización como una lista que cualquier app de Starlette puede montar, que es como `examples/servers/simple-auth/` en el repositorio ejecuta uno. SEP-990 añade una bandera y un método a esa superficie: + +```python title="auth_server.py" hl_lines="48-50 105-107" +--8<-- "docs_src/identity_assertion/tutorial002.py" +``` + +* `identity_assertion_enabled=True` lo controla todo. Desactivado, que es el valor por defecto, `/token` responde a esta concesión con `unsupported_grant_type` aunque hayas implementado el hook, y los metadatos no la mencionan. Activado, los metadatos ganan el tipo de concesión `jwt-bearer` y listan `urn:ietf:params:oauth:grant-profile:id-jag` en `authorization_grant_profiles_supported`, el campo que la extensión usa para anunciar la compatibilidad. (El cliente de este SDK nunca lo lee: está aprovisionado para un solo emisor y simplemente pregunta.) +* **`exchange_identity_assertion`** es el hook. Antes de que se ejecute, el SDK ha autenticado al cliente, ha rechazado los clientes públicos y ha rechazado los clientes cuyo registro no lista la concesión. Recibes un `IdentityAssertionParams` (la `assertion` sin procesar, los `scopes` solicitados y el `resource`) y devuelves un `OAuthToken` simple. +* El registro dinámico de clientes rechaza esta concesión sin excepciones, así que `get_client` aquí sirve un cliente aprovisionado a mano. Un cliente ID-JAG no puede registrarse a sí mismo para existir. +* La mitad de la clase son rechazos. `OAuthAuthorizationServerProvider` es el servidor de autorización *completo*, así que también pide el flujo de código de autorización; un servidor que además inicia la sesión de los usuarios implementa esos de verdad, y este tiene exactamente una puerta. + +!!! warning + El SDK nunca decodifica la aserción: solo tu despliegue sabe en qué IdP confía y qué claves + publica ese IdP, así que todo lo que hay dentro de `exchange_identity_assertion` es esencial. + Verifica la firma contra las claves publicadas del IdP (su JWKS; el secreto compartido de aquí + es el de la demo), así como `iss` y `exp`, según [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) §3. Exige que el `typ` de la cabecera + del JWT sea `oauth-id-jag+jwt`, la protección del perfil contra que algún otro JWT se reenvíe + como concesión. Exige que `aud` sea tu propio emisor. Exige que el claim `client_id` del ID-JAG + sea igual al cliente que el handler autenticó, y que su claim `resource` nombre un recurso que + realmente sirves. Lleva registro de `jti` hasta el `exp` de la aserción para que se acepte una + sola vez. Y toma los scopes concedidos y, sobre todo, el `resource` del token emitido del ID-JAG + validado, nunca de la solicitud: `params.resource` es lo que sea que el cliente escribió. Las + reglas de procesamiento completas están en la + [especificación Enterprise-Managed Authorization](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization). + +Rechaza una aserción inválida con `TokenError("invalid_grant", ...)`. El otro código de error de este flujo es `invalid_target`: un ID-JAG que nombra un recurso que no sirves se rechaza con él, que es lo que impide que este servidor acuñe tokens para el de otra persona. Y los scopes concedidos salen del claim `scope` del ID-JAG (una aserción sin él también se rechaza); el tuyo podría mapear los grupos del usuario en su lugar. + +Y fíjate en lo que el `OAuthToken` devuelto no lleva: un token de actualización. El IdP decide cuánto tiempo conserva el acceso este usuario decidiendo si emite el siguiente ID-JAG. Un token de actualización acuñado aquí le devolvería en silencio esa decisión. + +!!! info + Un servidor que todavía incrusta su servidor de autorización con `auth_server_provider=` llega al + mismo código a través de `AuthSettings(identity_assertion_enabled=True)`. **[Autorización](../run/authorization.md)** explica + por qué los servidores nuevos no deberían empezar por ahí. + +!!! check + Conecta los dos archivos de esta página entre sí y toda la concesión es un solo `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"} + ``` + + Sin `/authorize`, sin `/register`, sin obtener los metadatos del recurso protegido. Las únicas + solicitudes que se transmiten son la que provocó el `401`, la obtención del well-known, este + intercambio y luego tráfico MCP ordinario con el bearer adjunto. Y el `sub` que tu validador + leyó del ID-JAG es exactamente lo que `get_access_token().subject` informa dentro de una herramienta. + +### Pruébalo {#try-it} + +`examples/stories/identity_assertion/` en el repositorio del SDK es esta página funcionando de verdad: el mismo validador `exchange_identity_assertion`, un servidor MCP protegido por sus tokens, un IdP sustituto y el cliente, en un solo programa que se verifica a sí mismo. `uv run python -m stories.identity_assertion.client --http` ejecuta todo el intercambio y comprueba que el usuario que nombró el IdP es el usuario que ve la herramienta. + +## Resumen {#recap} + +* [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) permite que el proveedor de identidad de la empresa, y no el usuario final, decida a qué servidores MCP puede acceder un cliente. El IdP firma esa decisión en un **ID-JAG**. +* Obtener el ID-JAG es un intercambio de tokens de [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) contra *tu IdP*, y el SDK no lo hace. Presentarlo al servidor de autorización MCP es la concesión `jwt-bearer` de [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523), y el SDK cubre los dos lados de eso. +* `IdentityAssertionOAuthProvider` es otro `httpx2.Auth`: un cliente confidencial registrado de antemano, un `issuer` fijado y un callback `assertion_provider(audience, resource)`. Sin navegador, sin registro, sin token de actualización. +* El servidor de autorización nunca se descubre desde el servidor de recursos. Configura `issuer` con exactamente la cadena que sirve su documento de metadatos; la comparación es carácter por carácter. +* Del lado del servidor, `identity_assertion_enabled=True` más `exchange_identity_assertion`. El SDK autentica al cliente y controla la concesión; validar el ID-JAG es enteramente cosa tuya, y el token emitido queda vinculado al `resource` del ID-JAG, no al de la solicitud. + +La única parte que esta página nunca tocó es el servidor MCP. Lo que hace con el token que acabas de acuñar ya lo hacía en **[Autorización](../run/authorization.md)**. diff --git a/i18n/es/pages/client/index.md b/i18n/es/pages/client/index.md new file mode 100644 index 0000000000..4df0e27323 --- /dev/null +++ b/i18n/es/pages/client/index.md @@ -0,0 +1,217 @@ +--- +translation: + sections: [ebef1e7a0df854f4, a4c687d3d627d516, 8e79141fc2985342, b345dd05b9c3c7ab, 80ce41579825a6fa, 5f0fa90494de8f65, 83d10514eaa62fa5, 9190555aa39a5d28, 84a4c9d8bf14dddb, 927d71cf40b58c30] + tool: 1 +--- +# El cliente {#the-client} + +Un **`Client`** es la forma en que un programa de Python se comunica con un servidor MCP. + +Es un solo objeto con un solo ciclo de vida: lo construyes, entras en `async with`, llamas a sus métodos. Cada verbo del protocolo (listar las herramientas, llamar a una, leer un recurso, renderizar un prompt) es un método `async` del objeto que devuelve un resultado tipado. + +## Tu primer cliente {#your-first-client} + +```python title="client.py" hl_lines="14-18" +--8<-- "docs_src/client/tutorial001.py" +``` + +El servidor del principio solo está ahí para que tengas algo a lo que conectarte. El cliente son las cinco líneas resaltadas. + +* `Client(mcp)` recibe **el propio objeto servidor**. Ese es el transporte en memoria: sin subproceso, sin puerto, sin HTTP. Así se conectan todos los ejemplos de esta página y todas las pruebas que escribas. +* `async with` es el **ciclo de vida**. Al entrar se conecta y negocia; al salir se desconecta. No hay un par `connect()` / `close()`, y un `Client` no se puede reutilizar una vez que termina el bloque. +* Dentro del bloque, los datos de la conexión ya están ahí como propiedades simples. + +### Qué puedes pasarle a `Client` {#what-you-can-pass-to-client} + +`Client` recibe un solo argumento posicional y resuelve el transporte a partir de su tipo: + +* Una instancia de `MCPServer` (o del `Server` de bajo nivel): se conecta **en el mismo proceso**. +* Una cadena con una URL (`Client("http://localhost:8000/mcp")`): Streamable HTTP, el camino de producción. +* Un **transporte**: cualquier cosa que puedas usar con `async with ... as (read, write)`, como `stdio_client(...)` envolviendo un subproceso. + +Todo lo demás en esta página es idéntico en los tres casos. Los encabezados, los subprocesos, los timeouts y el protocolo `Transport` tienen su propia página: **[Transportes del cliente](transports.md)**. + +### Qué hay en un cliente conectado {#whats-on-a-connected-client} + +Cuatro propiedades de solo lectura, que se rellenan en cuanto entras en el bloque: + +* `client.server_info`: la identidad del servidor, o `None` para un servidor de la generación 2026 que no la informa (los servidores de python-sdk lo hacen por defecto). Aquí `server_info.name` es `"Bookshop"` y `server_info.version` es lo que el servidor informe. +* `client.server_capabilities`: lo que el servidor puede hacer (`tools`, `resources`, `prompts`, `completions`, ...). Una capacidad que el servidor no tiene es `None`. +* `client.protocol_version`: la versión del protocolo que acordaron las dos partes. Aquí es `"2026-07-28"`. +* `client.instructions`: la cadena `instructions=` del servidor, o `None` si no definió una. + +Nunca elegiste una versión del protocolo. Por defecto, el `Client` sondea el servidor y recurre al handshake clásico con los más antiguos, así que un mismo cliente funciona contra servidores de cualquier generación. Cuando necesites controlar eso, **[Versiones del protocolo](../protocol-versions.md)** tiene todos los detalles. + +!!! tip + `client.session` es la `ClientSession` subyacente, la vía de escape de bajo nivel. + No la necesitarás para nada de esta página. + +## Listar herramientas {#listing-tools} + +```python title="client.py" hl_lines="15-20" +--8<-- "docs_src/client/tutorial002.py" +``` + +`list_tools()` devuelve un `ListToolsResult`; las herramientas están en `.tools`. Cada una es la definición completa que un host le entregaría a un modelo: + +```python +tool.name # 'search_books' +tool.title # 'Search the catalog' +tool.description # 'Search the catalog by title or author.' +``` + +y `tool.input_schema` es el JSON Schema que el servidor derivó de las anotaciones de tipo de la función: + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"default": 10, "title": "Limit", "type": "integer"} + }, + "required": ["query"], + "title": "search_booksArguments" +} +``` + +Ese esquema es todo lo que una UI necesita para renderizar un formulario de argumentos, y todo lo que un modelo necesita para producir argumentos válidos. + +!!! tip + `title` es opcional, así que una UI que muestra herramientas a una persona tiene que elegir: el `title` si lo hay, + el `name` si no. `from mcp.shared.metadata_utils import get_display_name` hace exactamente eso, + para herramientas, recursos, plantillas de recursos y prompts. + +## Llamar a una herramienta {#calling-a-tool} + +`call_tool(name, arguments)` ejecuta la herramienta y te devuelve un `CallToolResult`. + +```python title="client.py" hl_lines="26-33" +--8<-- "docs_src/client/tutorial003.py" +``` + +El `lookup_book` del servidor devuelve un `Book` de Pydantic. Esto es lo que ve el cliente: + +```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 +``` + +Un solo valor de retorno, tres cosas que leer. Cada una tiene un consumidor distinto. + +### `content`: lo que lee el modelo {#content-what-the-model-reads} + +`content` es una `list` de **bloques de contenido**, y un bloque de contenido es una unión: `TextContent`, `ImageContent`, `AudioContent`, `ResourceLink` o `EmbeddedResource`. Una herramienta puede devolver varios, de distintos tipos. + +Por eso `main` acota el tipo con `isinstance(block, TextContent)` antes de tocar `block.text`. Fíjate en que no hay ningún `.text` fuera del `isinstance`: el verificador de tipos no lo permite, porque `ImageContent` tiene `.data`, no `.text`. La unión es honesta sobre lo que una herramienta puede enviarte; tu código también debería serlo. + +### `structured_content`: lo que lee tu aplicación {#structured_content-what-your-application-reads} + +`structured_content` es el valor de retorno de la herramienta en JSON, conforme al `output_schema` que declara la herramienta. Sin analizar cadenas, sin adivinar. + +Cuando ambos están presentes dicen lo mismo dos veces a propósito: `content` es para un modelo, `structured_content` es para el código. De dónde sale la mitad estructurada, y cómo controlarla, está en la página **[Salida estructurada](../servers/structured-output.md)**. + +### `is_error`: si la herramienta falló {#is_error-whether-the-tool-failed} + +Una herramienta que lanza una excepción **no** la lanza en tu cliente. Vuelve como un resultado normal con `is_error=True`. + +!!! check + Pídele `"Solaris"` a `lookup_book` (un título que no está en el catálogo) y la función lanza + `ValueError`. Aun así, la llamada devuelve un resultado normal: + + ```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 + ``` + + El mensaje de la excepción acabó en `content`, donde el **modelo** puede leerlo y volver a intentarlo. Es + deliberado: un error de herramienta es parte de la conversación, no un fallo fatal. Mira siempre `is_error` + antes de confiar en `structured_content`. + +!!! warning + `is_error=True` cubre más que tu propio `raise`. Pide una herramienta que el servidor ni siquiera tiene + (`call_tool("does_not_exist", {})`) y no se lanza nada. Recibes la misma forma de vuelta, + `is_error=True` con `Unknown tool: does_not_exist` en `content`. Un método de `Client` lanza + `MCPError` solo cuando el servidor responde con un **error** JSON-RPC en lugar de un resultado, y + **[Manejo de errores](../servers/handling-errors.md)** explica cuándo un servidor produce cada cosa. + +## Recursos {#resources} + +Los verbos de recursos vienen en pares: dos formas de listar, una de leer. + +```python title="client.py" hl_lines="22-31" +--8<-- "docs_src/client/tutorial004.py" +``` + +* `list_resources()` devuelve los recursos **concretos**, los que tienen una URI fija. Aquí: `['catalog://genres']`. +* `list_resource_templates()` devuelve los **parametrizados**. Aquí: `['catalog://genres/{genre}']`. Son dos listas distintas porque una plantilla no se puede leer hasta que la rellenas. +* `read_resource(uri)` recibe una URI como `str` simple y funciona con ambos: pasa `"catalog://genres/poetry"` y el servidor la hace coincidir con la plantilla. + +`read_resource` devuelve `contents`, una lista de `TextResourceContents` o `BlobResourceContents`. La misma idea que con el contenido de las herramientas: acota con `isinstance` y luego lee `.text` (o `.blob`). + +A un cliente también se le puede avisar cuando cambia un recurso. En conexiones de la generación 2025 eso es `subscribe_resource(uri)` / `unsubscribe_resource(uri)`, un par de métodos que `MCPServer` no implementa, así que con el protocolo 2026-07-28 (donde esos verbos ya no existen) la solicitud responde `-32601`, *Method not found*. El reemplazo de 2026 es un stream `subscriptions/listen`, que `MCPServer` *sí* sirve (allí `server_capabilities.resources.subscribe` es `True`), y cómo consumirlo con `client.listen(...)` es la página **[Suscripciones](subscriptions.md)** de esta sección. + +## Prompts {#prompts} + +```python title="client.py" hl_lines="15-20" +--8<-- "docs_src/client/tutorial005.py" +``` + +`list_prompts()` te dice qué ofrece el servidor y qué necesita cada prompt: + +```python +prompt.name # 'recommend' +prompt.title # 'Recommend a book' +prompt.arguments # [PromptArgument(name='genre', required=True)] +``` + +`get_prompt(name, arguments)` lo renderiza. El diccionario de argumentos es `str -> str`: los argumentos de un prompt siempre son cadenas. El resultado es `messages`, una lista de `PromptMessage`, cada uno con un `role` y un bloque `content`: + +```python +message.role # 'user' +message.content # TextContent(type='text', text='Recommend one poetry book from the catalog and say why.') +``` + +Un host le entrega esos mensajes directamente al modelo. Esa es toda la funcionalidad. + +## Autocompletado {#completions} + +Un servidor con un handler de autocompletado puede autocompletar argumentos de prompts y de plantillas de recursos mientras el usuario escribe. + +```python title="client.py" hl_lines="27-31" +--8<-- "docs_src/client/tutorial006.py" +``` + +* `ref` dice *qué* prompt o plantilla estás rellenando: un `PromptReference` o un `ResourceTemplateReference`. +* `argument` es `{"name": ..., "value": ...}`: el argumento y lo que el usuario ha escrito hasta ahora. + +La respuesta está en `result.completion.values`. Escribe `"p"` y el servidor devuelve `['poetry']`. El lado del servidor, y cómo un handler usa los *otros* argumentos ya rellenados para acotar sus sugerencias, es la página **[Autocompletado](../servers/completions.md)**. + +## Paginación {#pagination} + +Cada método `list_*` acepta un argumento nombrado `cursor=` y cada resultado trae un `next_cursor`. Cuando `next_cursor` es `None`, ya lo tienes todo. + +```python title="client.py" hl_lines="22-30" +--8<-- "docs_src/client/tutorial007.py" +``` + +Este bucle es correcto contra cualquier servidor. `MCPServer` devuelve todo en una sola página, así que `next_cursor` es `None` y el bucle se ejecuta una vez; por eso la mayoría del código nunca lo escribe. Los servidores que realmente paginan, y las reglas que siguen los cursores, están en **[Paginación](../advanced/pagination.md)**. + +## En las pruebas {#in-tests} + +`Client(mcp)`, sin proceso y sin puerto, ya es un banco de pruebas para tu servidor. + +Hay una opción del constructor pensada para eso: `Client(mcp, raise_exceptions=True)`. Solo tiene efecto en conexiones en memoria, y **[Pruebas](../get-started/testing.md)** es la página que la explica y construye todo el patrón a su alrededor. + +## Resumen {#recap} + +* `Client(x)` se conecta en memoria a un objeto servidor, por Streamable HTTP a una cadena con una URL, y por cualquier otra cosa mediante un transporte. +* `async with` es todo el ciclo de vida. Dentro, `server_capabilities` y `protocol_version` ya están rellenas; `server_info` e `instructions` también, cuando el servidor las proporciona. +* `list_tools()` te da el `name`, `title`, `description` e `input_schema` de cada herramienta. +* `call_tool()` devuelve `content` para el modelo, `structured_content` para tu código, e `is_error`. Una herramienta que lanza una excepción es un resultado, no una excepción. +* `content` es una unión de tipos de bloque; acota con `isinstance` antes de leer. +* `list_resources` / `list_resource_templates` / `read_resource`, `list_prompts` / `get_prompt` y `complete` completan los verbos. +* Cada `list_*` acepta `cursor=`; itera hasta que `next_cursor` sea `None`. + +Lo que un servidor puede pedirle al *cliente*, y cómo le respondes, está en **[Callbacks del cliente](callbacks.md)**. diff --git a/i18n/es/pages/client/oauth-clients.md b/i18n/es/pages/client/oauth-clients.md new file mode 100644 index 0000000000..9bccb9f8ae --- /dev/null +++ b/i18n/es/pages/client/oauth-clients.md @@ -0,0 +1,152 @@ +--- +translation: + sections: [c6899d3892bd9fa0, 79372cff3cc48a88, 63878d29e87c3e73, 13175843d3588af4, e7e2b9fd516f77de, 758f06399b513c1f, a05d7278487d610b] + tool: 1 +--- +# Clientes OAuth {#oauth-clients} + +Algunos servidores MCP están protegidos. Envíales una solicitud sin token y responden `401 Unauthorized`. + +**`OAuthClientProvider`** es la forma de conseguir el token. No es un objeto de MCP en absoluto. Es un `httpx2.Auth`, el hook estándar de httpx2 para "hacer algo con cada solicitud". Lo asocias a un `httpx2.AsyncClient`, le pasas ese cliente al transporte Streamable HTTP y dejas de pensar en ello. + +Esta página es el lado del cliente. Hacer que tu propio servidor exija un token es **[Autorización](../run/authorization.md)**. + +## El proveedor {#the-provider} + +```python title="client.py" hl_lines="44-54" +--8<-- "docs_src/oauth_clients/tutorial001.py" +``` + +Le das cuatro cosas: + +* `server_url`: el endpoint MCP al que te conectas. El proveedor descubre todo lo demás a partir de él. +* `client_metadata`: lo que escribirías en el formulario de "registrar una aplicación" de un servidor de autorización. +* `storage`: dónde viven los tokens entre ejecuciones. +* `redirect_handler` y `callback_handler`: los dos momentos en los que interviene un humano. + +Nada más en el archivo menciona OAuth. `main()` nunca ve un token. + +### Metadatos del cliente {#client-metadata} + +`OAuthClientMetadata` es el documento de registro real de [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591), como modelo de Pydantic. + +Defines tres campos. Los valores por defecto completan el resto: `grant_types` ya es `["authorization_code", "refresh_token"]` y `response_types` ya es `["code"]`, que es exactamente el flujo que ejecuta este proveedor. + +!!! check + Al ser un modelo de Pydantic, valida **antes de que un solo byte salga a la red**. + Omite `redirect_uris` y la construcción falla en el acto con un `ValidationError` que + nombra el campo: + + ```text + redirect_uris + Field required [type=missing, input_value={'client_name': 'Bookshop Agent'}, input_type=dict] + ``` + + No se abre ningún navegador ni queda un registro a medias en el servidor de autorización. + +### Almacenamiento de tokens {#token-storage} + +**`TokenStorage`** es un `Protocol` con cuatro métodos asíncronos. No heredas de nada; escribe los métodos y cualquier clase es un almacén de tokens: + +* `get_tokens` / `set_tokens` guardan el `OAuthToken`: token de acceso, token de actualización, caducidad, scope. +* `get_client_info` / `set_client_info` guardan el `OAuthClientInformationFull` que el servidor de autorización emitió cuando el proveedor te registró, incluido tu `client_id`. + +La versión en memoria de arriba funciona. También olvida todo cuando el proceso termina, así que la siguiente ejecución repite todo el proceso. Persístelo en un archivo o en el llavero de tu plataforma y la siguiente ejecución transcurre en silencio. + +!!! tip + Guarda `client_info`, no solo los tokens. El proveedor se registra dinámicamente la primera vez que + no encuentra un `client_info` almacenado. Si lo descartas, generas un registro nuevo en cada ejecución. + +### Los dos handlers {#the-two-handlers} + +El flujo de código de autorización necesita un humano exactamente una vez: alguien tiene que iniciar sesión y hacer clic en "permitir". + +* **`redirect_handler`** se espera con la URL de autorización ya construida por completo. El `client_id`, el `redirect_uri`, el `state` y el desafío PKCE ya están en ella. Tu único trabajo es llevar un navegador hasta allí. Una app de escritorio llama a `webbrowser.open`; este archivo la imprime. +* **`callback_handler`** se espera a continuación. Aguarda hasta que el usuario vuelve a tu `redirect_uri` y devuelve los parámetros de consulta de esa redirección como un `AuthorizationCodeResult`. + +Un cliente real ejecuta un pequeño servidor HTTP local en el URI de redirección en lugar de llamar a `input()`. La forma es idéntica: recibe la redirección y devuelve `code`, `state` e `iss`. + +!!! warning + Pasa `state` e `iss` exactamente como llegaron. El proveedor compara `state` con el que + generó e `iss` con el emisor que descubrió, y rechaza cualquier discrepancia. Son las defensas + contra CSRF y contra la confusión de servidores. + +### Dentro del `Client` {#into-the-client} + +Mira `main()`. El proveedor va en el **cliente httpx2**, el cliente httpx2 va en `streamable_http_client(url, http_client=...)` y ese transporte va en `Client`. + +`streamable_http_client` no tiene argumento nombrado `auth=`. Todo lo que es de nivel HTTP (autenticación, cabeceras, timeouts, proxies) pertenece al `httpx2.AsyncClient` que traes. Esa organización en capas está en **[Transportes del cliente](transports.md)**. + +## Lo que el proveedor hace por ti {#what-the-provider-does-for-you} + +La primera vez que `Client` envía una solicitud, el servidor responde `401`. El proveedor toma el control: + +1. **Descubrimiento.** Lee la cabecera `WWW-Authenticate`, obtiene los Protected Resource Metadata del servidor desde `/.well-known/oauth-protected-resource`, averigua qué servidor de autorización protege este recurso y obtiene los metadatos de *ese* servidor. +2. **Registro.** ¿No hay nada en el almacenamiento? Te registra dinámicamente con tu `OAuthClientMetadata` y guarda el resultado. +3. **Autorización.** Genera el par PKCE y un `state`, construye la URL de autorización, espera tu `redirect_handler` y luego espera tu `callback_handler` para obtener el código. +4. **Intercambio.** Cambia el código por un `OAuthToken`, lo guarda y repite tu solicitud original con `Authorization: Bearer ...`. + +Después de eso, se queda callado. Los tokens salen del almacenamiento, un token de acceso caducado se renueva con el token de actualización y solo cuando nada de eso funciona vuelve a ejecutar el flujo. + +No escribiste nada de eso. Quedan dos argumentos nombrados (`client_metadata_url` y `validate_resource_url`), y este archivo no necesita ninguno. `client_metadata_url` es el que vale la pena conocer; tiene su propia sección más abajo. + +### Pruébalo {#try-it} + +La mayoría de los ejemplos de esta documentación puedes comprobarlos con un `Client(server)` en memoria. Este no: todo el sentido del flujo es un `401` HTTP, y no hay HTTP entre un cliente en memoria y su servidor. + +El repositorio incluye la versión real. `examples/servers/simple-auth/` ejecuta un servidor de autorización independiente y un servidor MCP protegido; `examples/clients/simple-auth-client/` es el cliente de esta página convertido en una pequeña CLI. Su README tiene los dos comandos: inicia los servidores, ejecuta el cliente contra ellos y verás pasar los cuatro pasos. + +## Client ID Metadata Documents {#client-id-metadata-documents} + +La revisión 2026-07-28 de la especificación declara obsoleto el registro dinámico de clientes en favor de los **Client ID Metadata Documents** (CIMD). En lugar de enviar un POST con un registro nuevo a cada servidor de autorización que encuentra, tu cliente publica un único documento JSON sobre sí mismo en una URL HTTPS estable, y esa URL *es* su `client_id`. El servidor de autorización obtiene el documento; el proveedor nunca lo toca. + +El SDK ya lo admite: pasa la URL como `client_metadata_url=` al construir el proveedor. Cuando los metadatos del servidor de autorización anuncian `client_id_metadata_document_supported: true`, el proveedor se salta por completo la solicitud a `/register`: la URL entra en el flujo como `client_id` y no hay `client_secret`. Cuando el servidor no lo anuncia (la mayoría aún no lo hace), o nunca pasas una URL, el proveedor recurre al registro dinámico **en silencio**, y todo lo anterior funciona exactamente como se describe. Un `client_info` almacenado sigue teniendo prioridad sobre ambos. + +La URL debe ser HTTPS con una ruta que no sea la raíz; cualquier otra cosa es un `ValueError` en la construcción, antes de que ocurra nada en la red. El ejemplo incluido en `examples/clients/simple-auth-client/` la toma de la variable de entorno `MCP_CLIENT_METADATA_URL`. + +## De máquina a máquina {#machine-to-machine} + +Un trabajo nocturno, un paso de CI, otro servicio. No hay navegador ni nadie que haga clic en "permitir". Ese es el grant **client credentials**: ya tienes un `client_id` y un `client_secret`, y el endpoint de token es todo el flujo. + +`ClientCredentialsOAuthProvider` es el mismo `httpx2.Auth`, sin el humano: + +```python title="client.py" hl_lines="4 27-33" +--8<-- "docs_src/oauth_clients/tutorial002.py" +``` + +Qué cambió: + +* Sin `OAuthClientMetadata`, sin handlers. Pasas `client_id` y `client_secret`; el proveedor construye un registro `client_credentials` mínimo en torno a ellos y se salta el registro dinámico por completo. +* `scope` es una cadena separada por espacios, el formato que OAuth usa en lo que se transmite. +* Todo lo que viene después es idéntico: el mismo `TokenStorage`, el mismo `httpx2.AsyncClient(auth=...)`, el mismo `streamable_http_client`. + +Por defecto, el secreto viaja como autenticación HTTP Basic en la solicitud de token (`client_secret_basic`). Pasa `token_endpoint_auth_method="client_secret_post"` para ponerlo en el cuerpo del formulario en su lugar. Algunos servidores de autorización solo aceptan uno de los dos. + +!!! tip + Lee `client_secret` del entorno o de un gestor de secretos, nunca del control de versiones. + +!!! info + Hay un proveedor más en `mcp.client.auth.extensions.client_credentials`: + **`PrivateKeyJWTOAuthProvider`**, para clientes que se autentican con un JWT en lugar de un + secreto compartido (`private_key_jwt`, la variante de par de claves e identidad de carga de trabajo). Sigue + el mismo patrón: construye uno y ponlo en `auth=`. El mismo módulo incluye + `SignedJWTParameters` y `static_assertion_provider`, dos utilidades que construyen su aserción. + +Hay una situación más sin humanos: el cliente pertenece a una empresa cuyo proveedor de identidad, y no el usuario, decide a qué servidores MCP puede acceder. Ese es un grant distinto, con su propio modelo de confianza y su propia página, **[Aserción de identidad](identity-assertion.md)**. + +## Cuando falla {#when-it-fails} + +Cuando el flujo OAuth sale mal, el proveedor lanza un `OAuthFlowError` de `mcp.client.auth`. Tiene dos subclases. `OAuthRegistrationError` significa que el registro no produjo un cliente que puedas usar: el servidor de autorización se negó a registrarte, o sí te registró pero con credenciales que este flujo no puede usar (por ejemplo, un método de autenticación que no implementa). `OAuthTokenError` significa que no se pudo obtener un token: el endpoint de token dijo que no, o un registro de cliente almacenado lleva un método de autenticación que este cliente no puede aplicar, lo cual se informa al construir la solicitud de token en lugar de enviarse. Un solo `except OAuthFlowError:` cubre descubrimiento, registro, autorización e intercambio. + +No todo es un error de flujo. La red todavía puede fallar; esas son excepciones ordinarias de `httpx2` y pasan sin modificar. + +## Resumen {#recap} + +* `OAuthClientProvider` es un `httpx2.Auth`. Ponlo en un `httpx2.AsyncClient`, pásaselo a `streamable_http_client(url, http_client=...)` y `Client` nunca se entera de que hubo OAuth. +* Aportas cuatro cosas: la URL del servidor, un `OAuthClientMetadata`, un `TokenStorage` y el par de handlers de redirección y callback. +* `TokenStorage` es un `Protocol`: cuatro métodos asíncronos, sin clase base. Persiste `client_info` además de los tokens. +* El descubrimiento, el registro (dinámico o mediante un **Client ID Metadata Document**), PKCE, las comprobaciones de `state` e `iss` y la renovación de tokens son trabajo del proveedor, no tuyo. +* `ClientCredentialsOAuthProvider` es la versión sin humanos: `client_id` + `client_secret`, sin handlers, sin navegador. +* Todo fallo de OAuth es un `OAuthFlowError`; `OAuthRegistrationError` y `OAuthTokenError` son sus subclases. + +La otra mitad de este handshake, hacer que tu *servidor* exija el token, es **[Autorización](../run/authorization.md)**. diff --git a/i18n/es/pages/client/session-groups.md b/i18n/es/pages/client/session-groups.md new file mode 100644 index 0000000000..438e68c14b --- /dev/null +++ b/i18n/es/pages/client/session-groups.md @@ -0,0 +1,87 @@ +--- +translation: + sections: [09c857a25a9dc37a, 43bc6a76a243a50e, 0a716022a88768df, 4b7f78042bfcfff7, c112662e61b03315, 58974ba1f489a8b4, d18adbdbb835ea73] + tool: 1 +--- +# Grupos de sesiones {#session-groups} + +Un `Client` se conecta a un servidor. Las aplicaciones reales suelen querer varios (un servidor de búsqueda, un servidor de base de datos, una API interna) y terminan haciendo malabares con una conexión y una lista de herramientas para cada uno. + +**`ClientSessionGroup`** es un único objeto que mantiene muchas conexiones y reúne todo lo que exponen en una sola vista. + +## Dos servidores {#two-servers} + +Empieza con dos servidores normales. No tienen nada que ver entre sí, así que, como es natural, ambos llamaron `search` a su herramienta: + +```python title="library_server.py" hl_lines="7" +--8<-- "docs_src/session_groups/tutorial001.py" +``` + +```python title="web_server.py" hl_lines="7" +--8<-- "docs_src/session_groups/tutorial002.py" +``` + +## Un grupo {#one-group} + +Crea un `ClientSessionGroup` y llama a **`connect_to_server`** una vez por servidor: + +```python title="client.py" hl_lines="10-12" +--8<-- "docs_src/session_groups/tutorial003.py" +``` + +* `connect_to_server` recibe parámetros de transporte, no un objeto servidor: `StdioServerParameters` (de `mcp`) para lanzar un subproceso, o `StreamableHttpParameters` / `SseServerParameters` (de `mcp.client.session_group`) para un servidor que ya está escuchando en una URL. +* `group.tools` es un `dict[str, Tool]` con las herramientas de todos los servidores conectados. `group.resources` y `group.prompts` tienen la misma forma. +* `group.call_tool(name, arguments)` busca el nombre, encuentra la sesión a la que pertenece y le reenvía la llamada. Nunca indicas qué servidor. + +!!! check + Pon `client.py` junto a los dos servidores y ejecútalo. El segundo `connect_to_server` se niega: + + ```text + mcp.shared.exceptions.MCPError: {'search'} already exist in group tools. + ``` + + Es un `MCPError`, lanzado antes de que se registre nada del segundo servidor. Un nombre debe + ser único en **todo** el grupo, y dos servidores que no controlas acabarán chocando tarde o temprano. + +## `component_name_hook` {#component_name_hook} + +Esto se arregla en el grupo, no en los servidores. Pasa una función de `(name, server_info)` y el grupo la ejecuta sobre cada nombre que registra: + +```python title="client.py" hl_lines="7-8 15" +--8<-- "docs_src/session_groups/tutorial004.py" +``` + +Ejecútalo de nuevo. `print(sorted(group.tools))` ahora muestra ambos: + +```text +['Library.search', 'Web.search'] +``` + +* La **clave** es tuya. `by_server` la construyó a partir de `server_info.name`, el nombre con el que se creó cada `MCPServer(...)`. +* El `Tool` que contiene queda intacto: `group.tools["Web.search"].name` sigue siendo `"search"`, y ese es el nombre que `call_tool` transmite por el canal. El prefijo nunca sale de tu proceso. +* No son solo las herramientas. El recurso `hours` de la biblioteca se registra como `Library.hours`. + +!!! tip + El hook se ejecuta sobre **cada** nombre de **cada** servidor, no solo en los conflictos: no hay un + modo de prefijo solo en caso de colisión. Elige un esquema y deja que se aplique en todas partes. + +## Añadir y quitar servidores {#adding-and-removing-servers} + +`connect_to_server` devuelve la `ClientSession` que abrió. Guárdala si alguna vez quieres deshacerte de ese servidor: `await group.disconnect_from_server(session)` quita del grupo sus herramientas, recursos y prompts. + +Si ya tienes una `ClientSession` conectada (`Client.session` lo es), pásala a `await group.connect_with_session(server_info, session)` en lugar de abrir un transporte nuevo. La agrega de la misma manera. El grupo nunca cierra una sesión que no abrió. `server_info` da nombre al servidor para los prefijos de los componentes; en una conexión de la generación 2026, `client.server_info` puede ser `None` (la identidad es opcional), así que en ese caso pasa tu propio `Implementation(name=..., version=...)`. + +## El handshake clásico {#the-classic-handshake} + +`ClientSessionGroup` está construido sobre `ClientSession`, no sobre `Client`. Cada `connect_to_server` ejecuta el handshake clásico de `initialize`. Nunca envía el sondeo `server/discover` descrito en **[Versiones del protocolo](../protocol-versions.md)**. Todos los servidores MCP entienden ese handshake, así que esto no te cuesta compatibilidad con nada; solo significa que un grupo toma el camino más antiguo y lento hacia un servidor que podría hacerlo mejor. + +## Resumen {#recap} + +* `ClientSessionGroup` mantiene muchas conexiones a servidores y reúne sus herramientas, recursos y prompts en un `dict` para cada tipo. +* `connect_to_server(params)` por servidor. Recibe parámetros de transporte, nunca el objeto servidor ni la URL que recibe un `Client`. +* `group.call_tool(name, arguments)` enruta por ti al servidor al que pertenece. +* Los nombres deben ser únicos en todo el grupo; dos servidores con una herramienta `search` no pueden coexistir por sí solos. +* `component_name_hook=` reescribe cada nombre registrado. La clave del dict cambia; el nombre que se transmite por el canal, no. +* `connect_with_session` añade una sesión que ya tienes; `disconnect_from_server` quita una. + +El handshake que habla un grupo (y el más rápido que prefiere un `Client`) es el tema de **[Versiones del protocolo](../protocol-versions.md)**. diff --git a/i18n/es/pages/client/subscriptions.md b/i18n/es/pages/client/subscriptions.md new file mode 100644 index 0000000000..4ef2d1e57c --- /dev/null +++ b/i18n/es/pages/client/subscriptions.md @@ -0,0 +1,91 @@ +--- +translation: + sections: [8f9558e57f29eee1, a88c587739e0465c, 46ebfd5b325ed041, 4d10b00b57ce4bd9, 2cdb0edd1f59b3e2] + tool: 1 +--- +# Suscripciones {#subscriptions} + +El catálogo de un servidor no es fijo. Las herramientas aparecen en tiempo de ejecución y el contenido detrás de la URI de un recurso cambia. Un cliente se entera a través de `client.listen(...)`: una sola solicitud `subscriptions/listen` cuya respuesta *es* el flujo. Permanece abierta y transporta las notificaciones de cambio que el cliente pidió. + +Esta página es el extremo del cliente: abrir el flujo, observarlo junto a tu flujo principal y manejar sus finales. Publicar cambios, filtrar y atender el método son la parte del servidor, que se cuenta en **[Suscripciones](../handlers/subscriptions.md)**, dentro de *Dentro de tu handler*. Los ejemplos de aquí hablan con el servidor del tablero de sprint que se construye allí. + +## Observar el flujo {#watching-the-stream} + +Una suscripción es un gestor de contexto. Al entrar se envía la solicitud, con tus argumentos nombrados como filtro de la suscripción, y se espera la confirmación del servidor, así que el flujo ya está activo cuando empieza el bloque. + +```python title="client.py" hl_lines="15 18 28" +--8<-- "docs_src/subscriptions/tutorial003.py" +``` + +La iteración produce cuatro eventos tipados: `ToolsListChanged`, `PromptsListChanged`, `ResourcesListChanged` y `ResourceUpdated(uri=...)`. + +Un evento dice *qué* cambió, nunca *cómo*. Por eso `follow_board` llama a `read_resource` y a `list_tools`: el evento es una señal para volver a pedir los datos. Lee `event.uri` en lugar de suponer qué recurso se movió: un filtro puede nombrar varias URI, y un servidor puede informar de un cambio en un subrecurso de una de ellas. + +Los eventos duplicados que esperan a ser consumidos se funden en uno, y al volver a pedir los datos obtienes igualmente el estado actual. Solo se funden los eventos idénticos: dos `ResourceUpdated` para URI distintas son dos eventos. + +Dos propiedades más del objeto devuelto: + +* `sub.honored` es el filtro que el servidor confirmó: un `SubscriptionFilter` con los campos que pasaste, que se leen como atributos (`sub.honored.prompts_list_changed`). `MCPServer` acepta todos los tipos que pides, así que te devuelve tu solicitud tal cual. Un servidor que admite menos tipos confirma menos, y un tipo aceptado puede no dispararse nunca. Un servidor también puede rechazar la solicitud entera en lugar de confirmarla (consulta [Decidir quién puede observar](../handlers/subscriptions.md#deciding-who-may-watch) en la página del servidor), lo que aparece como el error de la solicitud. +* `sub.subscription_id` es el id de la solicitud de escucha, el que va estampado en cada trama de este flujo. Puede haber varias suscripciones abiertas a la vez, cada una demultiplexada por su propio id. + +## Observar sin bloquear {#watching-without-blocking} + +`follow_board` se ejecuta hasta que el servidor cierra el flujo, lo que puede no ocurrir nunca, así que por sí solo se adueña de tu programa. Los clientes reales quieren el observador *junto* al flujo principal: un agente llama a herramientas mientras un observador mantiene al día una caché o una interfaz. + +Abre primero la suscripción, luego inicia el observador y sigue con tu trabajo. + +=== "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` importa `BOARD` y `read_board` del primer ejemplo, que este repositorio guarda como + `tutorial003.py`. Si guardas los archivos renderizados uno junto al otro como `client.py` y `app.py`, + escribe `from client import BOARD, read_board` en su lugar. El ejemplo `watch.py` de más abajo + importa `read_board` de la misma manera. + +El orden es la clave. No se reenvía nada, así que un evento publicado antes de que existiera tu flujo se pierde. Entrar en `client.listen(...)` espera la confirmación, así que cada cambio a partir de ese momento llega a tu observador, y la instantánea que tomas dentro del bloque no puede perderse ninguno. + +Las solicitudes se ejecutan libremente junto a un flujo abierto, desde la tarea del observador o desde cualquier otra, en el mismo cliente. Como los eventos *duplicados* sin consumir se funden, un flujo principal ocupado puede producir una sola recarga en lugar de tres. Los eventos distintos no se funden: un filtro que nombra muchas URI encola un evento pendiente por URI. + +Para dejar de observar, sal del bloque: no hay ninguna llamada `unsubscribe`. Cancelar la tarea propietaria del bloque lo hace por ti, y el SDK cancela la solicitud de escucha como espera el transporte: en Streamable HTTP, cerrando el flujo de esa solicitud. Un observador que se ejecuta durante toda la vida de tu app nunca vuelve por sí solo, así que cancélalo, o cancela el alcance de su grupo de tareas, al apagar. + +## Los flujos terminan {#streams-end} + +Un flujo termina de una de dos maneras, y ambas son flujo de control normal. Un cierre ordenado del servidor termina el `async for`; una caída abrupta lanza `SubscriptionLost`. + +La diferencia es de diagnóstico, no de qué hacer después: el flujo ya no está, no se reenvió nada, y un observador al que todavía le importa vuelve a escuchar y vuelve a pedir los datos. + +```python title="watch.py" hl_lines="16 20" +--8<-- "docs_src/subscriptions/tutorial005.py" +``` + +Los servidores cierran flujos de forma ordenada por sus propias razones, entre ellas deshacerse de un suscriptor cuyo atraso creció demasiado, así que un final limpio no es una señal para dejar de observar. Espera un poco antes de volver a escuchar. + +`SubscriptionLost` también tiene una causa local. El cliente retiene como máximo 1024 eventos sin consumir, y un consumidor que se atrasa tanto pierde la suscripción en lugar de crecer sin límite. Mantén corto el cuerpo del `async for` y haz el trabajo lento en otro sitio. + +`keep_following` captura solo `SubscriptionLost`. Entrar en `listen()` también puede lanzar `MCPError` (la conexión falló o el servidor no atiende el método), `TimeoutError` (no llegó ninguna confirmación) y `ListenNotSupportedError` (una conexión anterior a 2026). Decide cuáles de ellas debe reintentar tu observador: la última nunca se recupera. + +## Resumen {#recap} + +* Entra con `async with client.listen(...)`; al entrar se espera la confirmación, así que no se pierde nada publicado después. +* Itera con `async for event in sub`. Los eventos son señales para volver a pedir los datos, nunca cargas de datos. +* Abre la suscripción, luego ejecuta el observador como tarea, y las llamadas a herramientas siguen fluyendo junto a él. +* Un final limpio detiene el bucle; una caída lanza `SubscriptionLost`. En ambos casos: vuelve a escuchar, vuelve a pedir los datos y, antes, espera un poco. +* Salir del bloque es darse de baja. + +Publicar estos eventos, acotar el filtro y escalar más allá de un proceso son la parte del servidor: **[Suscripciones](../handlers/subscriptions.md)**. Estos mismos eventos también mantienen fiable una caché del lado del cliente, y **[Caché](caching.md)** es la página siguiente. diff --git a/i18n/es/pages/client/transports.md b/i18n/es/pages/client/transports.md new file mode 100644 index 0000000000..4ab1e720dc --- /dev/null +++ b/i18n/es/pages/client/transports.md @@ -0,0 +1,128 @@ +--- +translation: + sections: [9cac816674181eb0, 0700f337babcd4dd, 2bde0dd58cdf00f5, ff7401df479af877, 3d0832f39b0d7059, d4bf7e4479637768, 05e20c0a798860e7] + tool: 1 +--- +# Transportes del cliente {#client-transports} + +Cada `Client` habla con su servidor a través de un **transporte**: lo que realmente lleva los mensajes. + +Nunca configuras uno por separado. `Client` recibe un único argumento posicional y deduce el transporte a partir de su tipo. + +El lado del *servidor* de cada uno (lo que hace `mcp.run()` y lo que despliegas) está en **[Ejecutar el servidor](../run/index.md)**. + +## En memoria {#in-memory} + +Pasa el propio objeto del servidor: + +```python title="client.py" hl_lines="14" +--8<-- "docs_src/client_transports/tutorial001.py" +``` + +Sin subproceso, sin puerto, sin bytes por ningún canal. El cliente y el servidor son dos objetos en el mismo proceso, y aun así la llamada pasa por la capa real del protocolo: `search_books` se lista, se valida y se invoca exactamente igual que por HTTP. + +Eso lo convierte en dos cosas a la vez: + +* **Un arnés de pruebas.** Todos los ejemplos de esta documentación se ejercitan así, y la página **[Pruebas](../get-started/testing.md)** construye todo el patrón en torno a ello. +* **Una API de integración.** Una aplicación que construye el servidor no necesita un salto de red para llamar a sus herramientas. + +## Streamable HTTP {#streamable-http} + +Pasa una cadena con una URL y obtienes **Streamable HTTP**, el transporte con el que despliegas: + +```python title="client.py" hl_lines="5" +--8<-- "docs_src/client_transports/tutorial002.py" +``` + +Ese es todo el cliente de producción. `Client` envuelve la URL en `streamable_http_client(...)` por ti, encima de un `httpx2.AsyncClient` configurado como MCP necesita: `follow_redirects=True`, un timeout de 30 segundos para connect/write/pool y un timeout de lectura de 300 segundos, porque el servidor puede mantener abierto un flujo de respuesta. + +!!! check + Un `Client` que has construido **no** está conectado. La construcción solo elige el transporte; + `async with` es lo que lo abre. Intenta usar la conexión antes de entrar y el SDK te lo dice: + + ```text + RuntimeError: Client must be used within an async context manager + ``` + + No se resolvió, se obtuvo ni se lanzó nada cuando escribiste `Client("http://...")`. Esa línea es gratis. + +### Trae tu propio `httpx2.AsyncClient` {#bring-your-own-httpx2asyncclient} + +En cuanto necesites un encabezado `Authorization`, una cookie, un proxy, mTLS o un timeout distinto, construye tú mismo el `httpx2.AsyncClient` y entrégaselo a `streamable_http_client`: + +```python title="client.py" hl_lines="8-14" +--8<-- "docs_src/client_transports/tutorial003.py" +``` + +Dos cosas que notar: + +* El `httpx2.AsyncClient` es tuyo, así que **tú** entras y sales de él. El SDK nunca cierra un cliente que no creó. +* `streamable_http_client(url, http_client=...)` devuelve un transporte, y `Client(transport)` lo acepta como cualquier otra cosa. + +Una nota sobre TLS: `httpx2` verifica los certificados contra el almacén de confianza del sistema operativo (mediante +[`truststore`](https://pypi.org/project/truststore/)), no contra una lista de CA incluida. En un entorno sin +un almacén de CA del sistema utilizable (algunos contenedores mínimos), configura las variables de entorno +estándar `SSL_CERT_FILE`/`SSL_CERT_DIR` o pasa un `verify=ssl_context` explícito a tu `httpx2.AsyncClient` +(el contexto está en +[`httpx` y `httpx-sse` sustituidos por `httpx2`](../migration.md#httpx-and-httpx-sse-replaced-by-httpx2)). + +!!! warning + `streamable_http_client` antes aceptaba `headers=` y `timeout=` directamente. Ya no: + sus únicos parámetros son `url`, `http_client` y `terminate_on_close`. Usa `headers=` por + costumbre y obtienes: + + ```text + TypeError: streamable_http_client() got an unexpected keyword argument 'headers' + ``` + + Todo lo que tiene forma de HTTP vive ahora en el único `httpx2.AsyncClient` que pasas. + +!!! info + `httpx2` conserva la API conocida de `httpx`, así que si conoces `httpx` ya sabes cómo hacer la autenticación, + los proxies, los event hooks, los reintentos y los límites de conexión aquí. El SDK no añade nada encima ni quita + nada. También es donde se conecta OAuth: + `httpx2.AsyncClient(auth=OAuthClientProvider(...))`. Todo ese flujo está en **[Clientes OAuth](oauth-clients.md)**. + +## stdio {#stdio} + +Un servidor **stdio** es un subproceso. El cliente lo lanza, escribe JSON-RPC en su stdin y lee JSON-RPC de su stdout. Así es como un host de escritorio ejecuta un servidor en tu máquina: un host *es* este código más una interfaz de usuario, y **[Conectar a un host real](../get-started/real-host.md)** es la misma relación vista desde el lado del host, como archivo de configuración. + +Describe el proceso con `StdioServerParameters`, conviértelo en un transporte con `stdio_client` y entrega *eso* a `Client`: + +```python title="client.py" hl_lines="4-8 12" +--8<-- "docs_src/client_transports/tutorial004.py" +``` + +`Client` no acepta el objeto de parámetros por sí solo. `StdioServerParameters` es configuración; `stdio_client(server)` es el transporte que sabe lanzar un proceso a partir de ella. Envuélvelo siempre. + +Salir del bloque `async with` también cierra el subproceso: cierra stdin, espera y lo mata si se queda. Nunca lo limpias tú. + +!!! warning + El proceso hijo **no** hereda tu entorno. Recibe una lista de permitidos mínima (`HOME`, `LOGNAME`, + `PATH`, `SHELL`, `TERM` y `USER` en POSIX) para que nada sensible se filtre a un proceso que quizá + no hayas escrito tú. + + Un servidor que necesita una clave de API no la encontrará ahí. Pásala explícitamente con `env=`; esas + variables se fusionan encima de la lista de permitidos. Eso es lo que hace `BOOKSHOP_API_KEY` arriba. + +## SSE {#sse} + +`sse_client(url)`, de `mcp.client.sse`, es el transporte HTTP al que reemplazó Streamable HTTP. Envuélvelo igual, `Client(sse_client("http://localhost:8000/sse"))`, para hablar con un servidor que todavía lo usa, y no construyas nada nuevo sobre él. + +## El protocolo `Transport` {#the-transport-protocol} + +Para `Client`, todo lo anterior es lo mismo. + +Un **transporte** es cualquier gestor de contexto asíncrono que produce un par `(read, write)` de flujos de mensajes: formalmente, el protocolo `Transport` de `mcp.client`. `Client` resuelve su argumento por tipo: un objeto de servidor se conecta dentro del proceso, un `str` se convierte en `streamable_http_client(url)` y cualquier otra cosa se entra directamente como transporte. Esa última regla es la razón por la que `stdio_client(...)`, `streamable_http_client(...)` y `sse_client(...)` encajan todos en el mismo hueco, y por la que puedes escribir el tuyo. + +## Resumen {#recap} + +* `Client(mcp)` (el objeto del servidor) se conecta en memoria. Úsalo para pruebas y para integración. +* `Client("http://.../mcp")` (una URL) se conecta por Streamable HTTP, el transporte de producción. +* Los encabezados, la autenticación, los proxies y los timeouts van en un `httpx2.AsyncClient` que pasas a `streamable_http_client(url, http_client=...)`. No existe el argumento nombrado `headers=`. +* stdio es `Client(stdio_client(StdioServerParameters(...)))`, nunca el objeto de parámetros solo. +* El subproceso recibe un entorno con lista de permitidos, no el tuyo; `env=` se añade a él. +* Un transporte es cualquier cosa con la que puedas hacer `async with x as (read, write)`. `Client` entrega directamente a ese protocolo todo lo que no sea un objeto de servidor ni una URL. +* Construir un `Client` elige el transporte. `async with` lo abre. + +Una vez abierto el transporte, los dos lados tienen que acordar una versión del protocolo. Normalmente nunca piensas en ello; cuando lo hagas, **[Versiones del protocolo](../protocol-versions.md)** es la página. diff --git a/i18n/es/pages/deprecated.md b/i18n/es/pages/deprecated.md new file mode 100644 index 0000000000..1df3d683df --- /dev/null +++ b/i18n/es/pages/deprecated.md @@ -0,0 +1,96 @@ +--- +translation: + sections: [20541a40dbdd5980, 01262a123ad9501d, 429db5b574a2ac08, 56b2d49da412cb28, 6a1717123fe4513c] + tool: 1 +--- +# Funcionalidades obsoletas {#deprecated-features} + +La especificación 2026-07-28 retira cinco cosas. El SDK sigue implementando todas y cada una, y todas llevan ahora un **aviso de obsolescencia**. + +La tabla siguiente nombra cada funcionalidad obsoleta, explica por qué desaparece e indica el reemplazo sobre el que construir. + +## Qué queda obsoleto {#what-is-deprecated} + +| Obsoleto | Por qué | Qué hacer en su lugar | +|---|---|---| +| **Roots** (directorios raíz): `ctx.session.list_roots()`, `client.send_roots_list_changed()`, el `list_roots_callback=` que pasas a `Client(...)` | [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) retira la capacidad. | Recibe las rutas como argumentos de herramienta normales o URI de recurso, o incrusta una `ListRootsRequest` en un `InputRequiredResult` (consulta **[Solicitudes de varias idas y vueltas (multi-round-trip)](handlers/multi-round-trip.md)**). | +| **Muestreo (sampling) iniciado por el servidor**: `ctx.session.create_message()`, el `sampling_callback=` que pasas a `Client(...)` | SEP-2577 retira la capacidad. | Devuelve `InputRequiredResult` y deja que el cliente reintente la llamada (consulta **[Solicitudes de varias idas y vueltas](handlers/multi-round-trip.md)**). | +| **Registro de logs del protocolo**: `ctx.log()`, `ctx.debug()`, `ctx.info()`, `ctx.warning()`, `ctx.error()`, `ctx.session.send_log_message()`, `client.set_logging_level()` | SEP-2577 retira la capacidad. Nada dentro del protocolo la reemplaza. | El `import logging` de siempre hacia stderr (consulta **[Registro de logs](handlers/logging.md)**). | +| **`ping`**: `client.send_ping()` | **Eliminado** del protocolo, no solo obsoleto. No hay método `ping` en 2026-07-28. | Nada. Solo funciona contra una conexión `mode="legacy"`. | +| **Progreso de cliente a servidor**: `client.send_progress_notification()` | 2026-07-28 hace que el progreso sea solo de servidor a cliente. | Nada que enviar. Tu *servidor* informa del progreso con `ctx.report_progress()` (consulta **[Progreso](handlers/progress.md)**). | + +De esa tabla se desprenden tres cosas: + +* Roots, muestreo y registro de logs van juntos. Una sola propuesta, **SEP-2577**, deja obsoletas las tres capacidades a la vez. +* El muestreo y los roots comparten un problema más profundo: son lugares donde un **servidor** envía una **solicitud** al **cliente**. Esa dirección entera es lo que 2026-07-28 reemplaza con las **[Solicitudes de varias idas y vueltas](handlers/multi-round-trip.md)**. Lo que desaparece son los métodos RPC independientes (`sampling/createMessage`, `roots/list` y el `elicitation/create` de estilo push); los tipos de payload `CreateMessageRequest` / `ListRootsRequest` / `ElicitRequest` sobreviven, incrustados en `InputRequiredResult.input_requests`, y en el cliente llegan a los mismos callbacks. +* `ping` es la excepción. El protocolo no lo deja obsoleto: lo elimina. El método del SDK sigue avisando (su mensaje dice *removed*, no *deprecated*) y llamarlo en una conexión moderna responde con *"Method not found"*. + +## Obsoleto es solo un aviso {#deprecated-is-advisory} + +Hoy no se rompe nada. + +Todos los métodos anteriores siguen funcionando contra cualquier sesión que haya negociado **2025-11-25 o anterior**. Fija `mode="legacy"` en el cliente y obtienes exactamente el comportamiento anterior a 2026. No hay cambios en lo que se transmite y la negociación de capacidades no cambia. + +Lo que cambia es que recibes un aviso visible la primera vez que se ejecuta cada uno: + +```text +MCPDeprecationWarning: The logging capability is deprecated as of 2026-07-28 (SEP-2577). +``` + +`MCPDeprecationWarning` hereda de `UserWarning`, **no** de `DeprecationWarning`. Es deliberado: el filtro por defecto de Python solo muestra `DeprecationWarning` en código que se ejecuta directamente como `__main__`, y así es como las bibliotecas dejan cosas obsoletas sin que nadie se entere durante dos años. Este aparece en todas partes, sin ninguna opción `-W`. + +!!! warning + "Solo un aviso" deja de ser cierto en el canal. El muestreo y los roots son *solicitudes* + de servidor a cliente, y una sesión 2026-07-28 no tiene ningún canal que las transporte. + Llama a `ctx.session.create_message()` dentro de una herramienta en una conexión moderna y + el aviso se dispara igual, y después el envío falla con un error: + + ```text + Cannot send 'sampling/createMessage': this transport context has no back-channel + for server-initiated requests. + ``` + + Dos señales, en ese orden. El `MCPDeprecationWarning` se dispara en el momento en que llamas + al método, en cualquier conexión. El error es lo que vuelve cuando a continuación el SDK + intenta enviar. Estas dos funcionalidades solo funcionan de extremo a extremo en una conexión + `mode="legacy"` cuyo cliente registró el callback correspondiente. + +## Silenciar el aviso {#silencing-the-warning} + +No lo hagas en código nuevo. + +Pero un servidor que mantienes y que de verdad atiende a clientes anteriores a 2026 tiene todo el derecho a un log tranquilo. Filtra la categoría antes de que se ejecute la primera llamada obsoleta: + +```python +import warnings + +from mcp import MCPDeprecationWarning + +warnings.filterwarnings("ignore", category=MCPDeprecationWarning) +``` + +Esa es toda la API. No hay un interruptor por método, y tampoco lo quieres: la gracia de tener una sola categoría es que una línea la silencia y una línea la trae de vuelta. + +!!! check + Aplica el filtro al revés y obtienes una prueba de regresión gratis. Añade + `"error::mcp.MCPDeprecationWarning"` al ajuste `filterwarnings` de tu configuración de + pytest y la llamada obsoleta **lanza una excepción** en lugar de avisar. Una herramienta + llamada `old_log` que todavía llama a `ctx.info()` deja de pasar y empieza a informar: + + ```text + Error executing tool old_log: The logging capability is deprecated as of 2026-07-28 (SEP-2577). + ``` + + Una línea de configuración de pytest, y una llamada obsoleta nunca podrá volver a colarse + en tu código sin que falle una prueba. + +## Resumen {#recap} + +* La especificación 2026-07-28 deja obsoletos los **roots**, el **muestreo** iniciado por el servidor y el **registro de logs** del protocolo (todo en [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), restringe el **progreso** a la dirección servidor a cliente y elimina **`ping`**. +* La columna de reemplazos te indica el camino: **[Solicitudes de varias idas y vueltas](handlers/multi-round-trip.md)** para el muestreo y los roots, **[Registro de logs](handlers/logging.md)** para los logs, **[Progreso](handlers/progress.md)** para el progreso. `ping` no necesita nada en absoluto. +* Obsoleto es solo un aviso: no hay cambios en lo que se transmite, todo sigue funcionando contra sesiones anteriores a 2026 y recibes un `MCPDeprecationWarning` visible (un `UserWarning`, así que está activo por defecto). +* El muestreo y los roots necesitan además un canal de retorno (back-channel) que una sesión 2026-07-28 no tiene. En una conexión moderna avisan y después lanzan una excepción. +* `warnings.filterwarnings("ignore", category=MCPDeprecationWarning)` silencia toda la categoría; `"error::mcp.MCPDeprecationWarning"` en pytest la convierte en un fallo de prueba. +* El código nuevo no debería construirse sobre nada de esto. + +Todas las demás páginas de esta documentación enseñan la API actual. diff --git a/i18n/es/pages/get-started/first-steps.md b/i18n/es/pages/get-started/first-steps.md new file mode 100644 index 0000000000..86fce40692 --- /dev/null +++ b/i18n/es/pages/get-started/first-steps.md @@ -0,0 +1,144 @@ +--- +translation: + sections: [0d6c05bcbf836bf3, 59a7b14eeefc68c1, 7114d8d6daba203f, e8bbb56a98ba7bc9, 5138010f6159901c, f78da7c7c363d4c6, 220a939cab348686] + tool: 1 +--- +# Primeros pasos {#first-steps} + +La **[página de inicio](../index.md)** va rápido: escribes un servidor, lo ejecutas, llamas a una herramienta. + +Esta página va despacio, con las tres cosas que un servidor puede exponer y un nombre para cada pieza por el camino. + +## Host, cliente y servidor {#host-client-and-server} + +Tres palabras que verás en cada página a partir de aquí: + +* Un **host** es la aplicación LLM: Claude, un IDE, un entorno de ejecución de agentes. Es aquello con lo que habla el usuario. +* Un **cliente** vive dentro del host y habla MCP. El host ejecuta un cliente por cada servidor al que está conectado. +* Un **servidor** es lo que construyes con este SDK. Expone cosas a los clientes. Nunca habla directamente con el modelo. + +Tú escribes el servidor. Los hosts son el producto de otra persona. El SDK también te da un `Client`. Lo usarás para probar tus servidores, y aparece más adelante en esta página. + +## Las tres primitivas {#the-three-primitives} + +Un servidor expone exactamente tres tipos de cosas. Lo que las distingue es **quién decide usarlas**: + +| Primitiva | Quién la controla | Qué es | Ejemplo | +|------------------|-------------------|------------------------------------------------------------------------|-------------------------------------------------| +| **Herramientas** | El modelo | Una función que el modelo llama para realizar una acción | Una llamada a una API, una escritura en base de datos | +| **Recursos** | La aplicación | Datos que el host carga en el contexto del modelo | El contenido de un archivo, una respuesta de una API | +| **Prompts** | El usuario | Una plantilla de mensajes reutilizable que el usuario invoca por nombre | Un comando de barra, una entrada de menú | + +"Quién la controla" es precisamente la razón de la división. Una herramienta se ejecuta porque el **modelo** decidió llamarla. Un recurso se adjunta porque la **aplicación** decidió que el modelo lo necesitaba. Un prompt se ejecuta porque el **usuario** lo eligió. + +!!! info + Si has construido una API web ya tienes casi toda la intuición: un **recurso** es un `GET` + (carga datos y no cambia nada) y una **herramienta** es un `POST` (hace trabajo y puede tener + efectos secundarios). Un **prompt** no tiene equivalente HTTP; se parece más a una consulta + guardada que el usuario ejecuta por nombre. + +## Un servidor, las tres {#one-server-all-three} + +```python title="server.py" hl_lines="6 12 18" +--8<-- "docs_src/first_steps/tutorial001.py" +``` + +Tres funciones normales, tres decoradores. Cada decorador es el registro completo: + +* `@mcp.tool()` convierte `add` en una **herramienta**. +* `@mcp.resource("greeting://{name}")` convierte `greeting` en una **plantilla de recurso**: el `{name}` de la URI es el parámetro de la función. +* `@mcp.prompt()` convierte `summarize` en un **prompt**. La cadena que devuelve se convierte en un mensaje de usuario. + +Todo lo demás (el nombre, la descripción, el esquema de argumentos) el SDK lo lee de la propia función: su nombre, su docstring, sus anotaciones de tipo. Nunca declaraste nada de eso por separado. + +!!! tip + Las dos mitades del SDK tienen dos rutas de importación: `from mcp import Client` y + `from mcp.server import MCPServer`. No existe `from mcp import MCPServer`. + +### Pruébalo {#try-it} + +Ejecútalo con el MCP Inspector: + +```console +uv run mcp dev server.py +``` + +Abre la URL que imprime. El Inspector tiene una pestaña por primitiva; recórrelas en orden. + +**Tools.** Una entrada: `add`, descrita como *Add two numbers.* El formulario tiene un campo entero obligatorio para `a` y otro para `b`. Rellénalos, llámala, y el resultado es `3`. El Inspector construyó ese formulario a partir de `a: int, b: int`. Lo mismo hace cualquier otro cliente. + +**Resources.** La lista *Resources* está vacía. `greeting` está en **Resource Templates**, porque `greeting://{name}` tiene un parámetro: no hay un recurso concreto que listar hasta que alguien indique un `name`. Dale `World` y léelo: + +```text +Hello, World! +``` + +**Prompts.** Una entrada: `summarize`, con un único argumento obligatorio `text`. Obtenlo con algo de texto y recibes un mensaje con `role: user` y tu cadena ya generada como contenido. Eso es todo lo que es un prompt: una función que construye mensajes. + +El Inspector ejecutó tu servidor sobre **stdio**, uno de los transportes que puede hablar un servidor MCP. Todavía no eliges uno; **[Ejecutar tu servidor](../run/index.md)** es la página para eso. + +## Capacidades {#capabilities} + +Viste tres pestañas en el Inspector. ¿Cómo supo que había tres? + +Cuando un cliente se conecta, el servidor declara sus **capacidades**: qué familias de solicitudes va a responder. El cliente usa esa declaración para decidir qué vale la pena pedir siquiera. Nunca la escribiste; `MCPServer` la declara por ti. + +Míralo tú mismo. El `Client` del SDK acepta el objeto servidor directamente y se conecta a él **en memoria** (sin subproceso, sin puerto): + +```python +import asyncio + +from mcp import Client + +from server import mcp + + +async def main() -> None: + async with Client(mcp) as client: + print(client.server_capabilities.model_dump(exclude_none=True)) + + +asyncio.run(main()) +``` + +```text +{'prompts': {'list_changed': True}, 'resources': {'subscribe': True, 'list_changed': True}, 'tools': {'list_changed': True}} +``` + +Ese diccionario son las **capacidades** declaradas de tu servidor. Es lo primero que aprende cada cliente que se conecta: + +| Capacidad | El cliente ya puede llamar a | +|-------------|----------------------------------------------------------------| +| `tools` | `tools/list`, `tools/call` | +| `resources` | `resources/list`, `resources/templates/list`, `resources/read` | +| `prompts` | `prompts/list`, `prompts/get` | + +`MCPServer` sirve las tres primitivas, así que las tres se declaran siempre. + +Fíjate en lo que no está. `completions` (autocompletado de argumentos para plantillas de recurso y prompts) necesita un handler que escribes tú, este servidor no tiene uno, así que la capacidad está ausente y un cliente bien hecho no la pedirá. Esa es la regla para todo lo opcional: registra la cosa y la capacidad aparece; **[Autocompletado](../servers/completions.md)** lo demuestra. + +!!! info + `Client(mcp)` es el mismo cliente en memoria con el que se prueba cada ejemplo de esta + documentación, y es como probarás los tuyos. Tiene una página entera: **[Pruebas](testing.md)**. + +## Lo que no escribiste {#what-you-did-not-write} + +Repasa esta página. Escribiste tres pequeñas funciones de Python. **No** escribiste: + +* Un JSON Schema. `a: int, b: int` *es* el esquema de `add`. +* Un handler de solicitudes. `tools/list`, `resources/read`, `prompts/get`: todos servidos por ti. +* Una declaración de capacidades. `MCPServer` la hizo por ti. +* Una línea de protocolo. La negociación de versión, el encuadre JSON-RPC, el intercambio de capacidades: todo ocurrió dentro de `mcp dev` y `Client(mcp)`, y nunca lo viste. + +Esa proporción es la razón de ser del SDK. + +## Resumen {#recap} + +* Un **host** es la app LLM, un **cliente** es su mitad que habla MCP, un **servidor** es lo que construyes. +* Las herramientas las controla el **modelo**, los recursos los controla la **aplicación**, los prompts los controla el **usuario**. +* Un decorador por primitiva: `@mcp.tool()`, `@mcp.resource(uri)`, `@mcp.prompt()`. Nombre, descripción y esquema salen de la función. +* Una URI con un `{param}` crea una **plantilla** de recurso, que se lista aparte de los recursos concretos. +* Las **capacidades** del servidor se declaran por ti, y un cliente solo pide lo que un servidor declara. +* `Client(mcp)` se conecta al objeto servidor en memoria: tu entorno de pruebas desde el primer día. + +Lo siguiente es **[Conectar a un host real](real-host.md)**: este servidor dentro de Claude Desktop o un IDE, de verdad. Después, **[Pruebas](testing.md)**: una página, un cliente en memoria, y nunca más adivinas si funciona. Tras eso, cada primitiva tiene su propia página, empezando por la que maneja el modelo: **[Herramientas](../servers/tools.md)**. diff --git a/i18n/es/pages/get-started/index.md b/i18n/es/pages/get-started/index.md new file mode 100644 index 0000000000..150b2ccef8 --- /dev/null +++ b/i18n/es/pages/get-started/index.md @@ -0,0 +1,53 @@ +--- +translation: + sections: [ed4a756b4c53c585, 97e2fb315b7fe398, 4d04f1c6f4bf6c1d, 577d73078fc62baf] + tool: 1 +--- +# Empieza aquí {#get-started} + +¿Eres nuevo en MCP o nuevo en este SDK? Empieza aquí. Estas páginas te llevan desde cero hasta un servidor funcional y probado: [instala el SDK](installation.md), construye tu [primer servidor](first-steps.md), [conéctalo a un host real](real-host.md) y [pruébalo](testing.md) con un cliente en memoria. + +## Ejecuta el código {#run-the-code} + +Todos los bloques de código se pueden copiar y usar directamente: son archivos completos que funcionan. + +Para seguir los pasos, pega un bloque en un `server.py` y ábrelo en el MCP Inspector: + +```console +uv run mcp dev server.py +``` + +Se **RECOMIENDA ENCARECIDAMENTE** que escribas (o copies) el código, lo edites y lo ejecutes localmente. Usarlo en tu propio editor es lo que de verdad te muestra la idea: lo poco que escribes, el autocompletado, las comprobaciones de tipos que detectan errores antes de ejecutar nada. + +## No vas a adivinar {#you-will-not-be-guessing} + +Cada ejemplo de esta documentación es un archivo completo dentro de [`docs_src/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/docs_src) en el propio repositorio del SDK, y cada uno de ellos lo ejercita la suite de pruebas del SDK mediante un **cliente en memoria**: + +```python +import pytest +from mcp import Client + +from server import mcp + + +@pytest.mark.anyio +async def test_add() -> None: + async with Client(mcp) as client: + result = await client.call_tool("add", {"a": 1, "b": 2}) + assert result.structured_content == {"result": 3} +``` + +Sin subproceso, sin puerto, sin transporte. `Client(mcp)` se conecta directamente al objeto del servidor. + +Si un cambio en el SDK rompe un ejemplo de una de estas páginas, la CI se pone en rojo antes que la página. El código que lees aquí es el código que se ejecuta. + +Lo usarás tú mismo en [Pruebas](testing.md); así es también como pruebas tus propios servidores. + +## Adónde ir después {#where-to-go-next} + +Una vez que tengas un servidor en marcha, el resto de esta documentación es una referencia, no un curso. Cada página es independiente, así que ve directo a lo que necesitas: + +* Lo que expone un servidor (herramientas, recursos, prompts) está en **[Servidores](../servers/index.md)**. +* Lo que tienes disponible dentro de las funciones que registras está en **[Dentro de tu handler](../handlers/index.md)**. +* Ponerlo delante de los clientes (stdio, HTTP, tu app FastAPI existente) está en **[Ejecutar el servidor](../run/index.md)**. +* Construir el otro lado, una aplicación que *usa* servidores MCP, está en **[Clientes](../client/index.md)**. diff --git a/i18n/es/pages/get-started/installation.md b/i18n/es/pages/get-started/installation.md new file mode 100644 index 0000000000..0e3ca1b1b1 --- /dev/null +++ b/i18n/es/pages/get-started/installation.md @@ -0,0 +1,47 @@ +--- +translation: + sections: [6e2f9bab94d5ed36, 8cf653388f69e28b, 6fd9ea2f65de0df6] + tool: 1 +--- +# Instalación {#installation} + +El SDK de Python está en PyPI como [`mcp`](https://pypi.org/project/mcp/). Requiere **Python 3.10+**. + +Esta documentación describe **v2**, la línea de versiones estable actual: + +=== "uv" + + ```bash + uv add "mcp[cli]" + ``` + +=== "pip" + + ```bash + pip install "mcp[cli]" + ``` + +!!! note "¿Vienes de v1?" + v2 es una versión mayor con cambios incompatibles; la **[Guía de migración](../migration.md)** + los cubre todos. Si tu *paquete* depende de `mcp` y aún no está listo para migrar, mantén un + límite superior `<2` (por ejemplo `mcp>=1.28,<2`) para que una resolución sin versión fijada se quede en la línea 1.x. + +## Qué se instala {#what-gets-installed} + +No necesitas saber nada de esto para usar el SDK, pero si te preguntas para qué sirve cada dependencia: + +* `mcp-types`: todos los tipos del protocolo (solicitudes, resultados, bloques de contenido) como paquete propio, versionado a la par del SDK. El código que depende de `mcp` lo importa a través del alias `mcp.types` (cada `from mcp.types import ...` de esta documentación); importa `mcp_types` directamente solo en un proyecto que instale `mcp-types` sin el SDK. +* [`anyio`](https://anyio.readthedocs.io/): el entorno de ejecución asíncrono. Todo el SDK está escrito sobre anyio, así que funciona tanto con `asyncio` como con `trio`. +* [`pydantic`](https://docs.pydantic.dev/): la base de todos los modelos de `mcp.types`, además de toda la generación y validación de esquemas. +* [`httpx2`](https://pypi.org/project/httpx2/): el cliente HTTP detrás de los transportes de *cliente* Streamable HTTP y SSE, con compatibilidad integrada con server-sent events. +* [`starlette`](https://www.starlette.io/), [`uvicorn`](https://www.uvicorn.org/), [`sse-starlette`](https://pypi.org/project/sse-starlette/) y [`python-multipart`](https://pypi.org/project/python-multipart/): los transportes HTTP de *servidor*. +* [`jsonschema`](https://pypi.org/project/jsonschema/): valida la salida estructurada de una herramienta contra su esquema de salida declarado. +* [`pyjwt[crypto]`](https://pyjwt.readthedocs.io/): manejo de tokens OAuth para la autorización. +* [`opentelemetry-api`](https://opentelemetry-python.readthedocs.io/): solo la API ligera, de modo que el middleware de trazas del SDK no cuesta nada a menos que instales por tu cuenta un SDK y un exportador de OpenTelemetry. +* [`typing-extensions`](https://typing-extensions.readthedocs.io/) y [`typing-inspection`](https://pypi.org/project/typing-inspection/): funcionalidades modernas de tipado en Python 3.10. +* [`pywin32`](https://pypi.org/project/pywin32/): solo en Windows, se usa para la gestión de subprocesos `stdio`. + +## Extras opcionales {#optional-extras} + +* `mcp[cli]` añade [`typer`](https://typer.tiangolo.com/) y [`python-dotenv`](https://pypi.org/project/python-dotenv/) para la herramienta de línea de comandos `mcp` (`mcp dev`, `mcp run`, `mcp install`). La querrás durante el desarrollo; puede que no la necesites en un servidor desplegado. +* `mcp[rich]` añade [`rich`](https://rich.readthedocs.io/) para unos logs del servidor más legibles. diff --git a/i18n/es/pages/get-started/real-host.md b/i18n/es/pages/get-started/real-host.md new file mode 100644 index 0000000000..1e4492dda7 --- /dev/null +++ b/i18n/es/pages/get-started/real-host.md @@ -0,0 +1,182 @@ +--- +translation: + sections: [3c4f2f06b4e978b6, 22520eecae3d1961, f4e1709db18d635a, 2eb57992049671d9, 1ba83e9af37cc1b4, 4822586344b08d9e, 1c93afef72478992, b6b448f9eddd51dc, fe55370fd931815b] + tool: 1 +--- +# Conectarse a un host real {#connect-to-a-real-host} + +Un **host** es la aplicación dentro de la que acaba tu servidor: Claude Desktop, Claude Code, un IDE. El host es con lo que habla el usuario. Dentro de él, un **cliente** MCP lanza tu servidor como un proceso hijo y se comunica con él a través del stdin y el stdout de ese proceso. + +Esto significa que conectarse a un host es un único acto: le indicas **el comando que arranca tu servidor**. Todo lo que hay en esta página (dos comandos de CLI, tres archivos JSON) es un lugar distinto donde poner ese mismo comando. + +## Un servidor, todos los hosts {#one-server-every-host} + +```python title="server.py" hl_lines="3 33-34" +--8<-- "docs_src/real_host/tutorial001.py" +``` + +Dos herramientas y un recurso, un solo archivo. Tres cosas de ese archivo importan para todos los hosts de abajo: + +* `mcp.run()` sin argumentos arranca un servidor **stdio**: se bloquea, lee los mensajes del protocolo por stdin y los escribe por stdout. Ese es el transporte que hablan todos los hosts de esta página. El host arranca tu archivo como proceso hijo y es dueño de esas dos tuberías, y por eso conectarse siempre se reduce a "aquí tienes el comando". Nunca eliges un puerto, y nada escucha en ninguno. +* `run()` está bajo `if __name__ == "__main__":`. Todo lo de abajo **importa** este archivo en lugar de ejecutarlo, así que un `run()` sin protección arrancaría un servidor en cuanto cualquier cosa cargara el módulo. +* El objeto servidor es una variable global del módulo llamada `mcp`. Ese es el nombre que busca `mcp run` (`server` y `app` también funcionan). Si lo llamas de otra forma, lo nombras explícitamente: `mcp run server.py:bookshop`. + +Esa es la última línea de Python de esta página. De aquí en adelante todo es configuración del host. + +## El comando de arranque {#the-launch-command} + +Todos los hosts de abajo reciben el mismo comando: + +```bash +uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py +``` + +Un solo comando para todos porque `uv run --with` resuelve el SDK en un entorno nuevo al momento: funciona desde cualquier directorio y no necesita ni proyecto ni entorno virtual que activar. Aquí eso importa más que en ningún otro sitio, porque un host lanza tu servidor desde *su* directorio de trabajo con un entorno casi vacío, no desde tu shell. + +También es el comando que `mcp install` escribe por ti en la configuración de Claude Desktop (abajo), así que lo que escribes a mano y lo que genera la herramienta coinciden, salvo por la versión exacta que fija la herramienta. + +!!! tip "Si un host no encuentra `uv`" + Un host lanza tu servidor con un `PATH` mínimo, y puede que `uv` no esté en él. Sustituye el + `uv` a secas por la ruta absoluta que da `which uv` (macOS/Linux) o `where uv` (Windows). Eso es + exactamente lo que escribe `mcp install`. + +!!! note "Esta página es la historia local" + Todo lo de aquí ejecuta tu servidor en la máquina donde está el host: el host lanza tu + archivo, por stdio. Eso es justo lo correcto para una herramienta personal o de una sola + máquina. Para dar un servidor a personas que *no* tienen tu archivo, repartes una **URL**, no un + comando: el mismo objeto `mcp` servido por Streamable HTTP. **[Ejecutar tu servidor](../run/index.md)** + es esa decisión en una sola tabla, y **[Desplegar y escalar](../run/deploy.md)** es el camino desde + ahí hasta un nombre de host real. + + Y un host no es más que una aplicación con un cliente MCP dentro, así que tu propio código + Python puede hacer el papel del host: **[Transportes del cliente](../client/transports.md)** lanza + este mismo archivo como subproceso con `stdio_client(...)`, y **[Pruebas](testing.md)** + se conecta a él en memoria, sin ningún proceso. + +## Claude Desktop {#claude-desktop} + +El único host que el SDK puede configurar por ti: + +```bash +uv run mcp install server.py +``` + +Eso es todo. `mcp install` importa el archivo para leer el nombre del servidor, encuentra el archivo de configuración de Claude Desktop y escribe en él el comando de arranque. De paso convierte tu ruta en absoluta, así que no tienes que hacerlo. + +No hay nada misterioso. Esta es la entrada que escribe: + +```json +{ + "mcpServers": { + "Bookshop": { + "command": "/absolute/path/to/uv", + "args": [ + "run", + "--frozen", + "--with", + "mcp[cli]==2.0.0", + "mcp", + "run", + "/absolute/path/to/server.py" + ] + } + } +} +``` + +Es el comando de arranque de la sección anterior con tres añadidos: la ruta absoluta a `uv`, `--frozen` para que `uv` nunca reescriba un archivo de bloqueo que tenga cerca por casualidad, y una versión fijada exactamente a la de `mcp` que tienes instalada. Va a parar a `claude_desktop_config.json`, que vive en: + +* **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` +* **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` + +Puedes escribir ese archivo a mano. `mcp install` existe para que no cometas el error clásico (una ruta relativa) al hacerlo. + +Cierra Claude Desktop por completo (no solo su ventana) y vuelve a abrirlo. + +!!! warning + `mcp install` falla con `Claude app not found` si el *directorio* de configuración de Claude Desktop + todavía no existe. Instala Claude Desktop y ejecútalo una vez: eso es lo que crea el directorio. + +!!! tip + Claude Desktop arranca tu servidor en su propio proceso, así que las variables de entorno de tu + shell no están ahí. `uv run mcp install server.py -v API_KEY=abc123` (o `-f .env`) las registra en el + campo `env` de la entrada. `--name` sobrescribe el nombre de la entrada; por defecto es el `name` del servidor. + +## Claude Code {#claude-code} + +No hay ningún archivo que editar. Registra el servidor con la CLI `claude`; todo lo que va después de `--` es el comando de arranque. + +```bash +claude mcp add bookshop -- uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py +``` + +Ejecuta `/mcp` dentro de una sesión de Claude Code para confirmar que `bookshop` está conectado y sus herramientas aparecen listadas. + +## Cursor {#cursor} + +Crea `.cursor/mcp.json` en la raíz de tu proyecto. + +```json +{ + "mcpServers": { + "bookshop": { + "command": "uv", + "args": ["run", "--with", "mcp[cli]", "mcp", "run", "/absolute/path/to/server.py"] + } + } +} +``` + +El mismo `command` más `args`, bajo la misma clave `mcpServers` que usa Claude Desktop. El servidor aparece en los ajustes de MCP de Cursor con ambas herramientas listadas. + +## VS Code {#vs-code} + +Crea `.vscode/mcp.json` en la raíz de tu proyecto. + +```json +{ + "servers": { + "bookshop": { + "type": "stdio", + "command": "uv", + "args": ["run", "--with", "mcp[cli]", "mcp", "run", "/absolute/path/to/server.py"] + } + } +} +``` + +Dos diferencias con el archivo de Cursor, y son las únicas dos: la clave contenedora es `servers`, no `mcpServers`, y cada entrada declara su `type`. Confirma el aviso de confianza y luego **MCP: List Servers** en la paleta de comandos muestra `bookshop` en ejecución. + +!!! note + Necesitas VS Code 1.99 o posterior con la extensión **GitHub Copilot** con sesión iniciada (basta con + Copilot Free), y Copilot Chat debe estar en modo **Agent**, porque ningún otro modo llama a herramientas. + +## No aparece {#it-doesnt-show-up} + +Antes de tocar ninguna configuración de host, ejecuta tú mismo el comando de arranque: + +```bash +uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py +``` + +No imprime nada y no termina. Ese silencio es correcto: un servidor stdio está esperando a que un host hable primero por stdin (`Ctrl-C` para detenerlo). Un traceback o una salida inmediata es el error real, y ahora puedes leerlo en vez de adivinarlo a través de un host. + +Una vez que ese comando se queda esperando, lo que queda es casi siempre una de estas tres cosas: + +* **Una ruta relativa.** El host lanza tu servidor desde *su* directorio de trabajo, no desde aquel en el que lo registraste. `server.py` donde hace falta `/absolute/path/to/server.py` es el fallo más común de todos. Si el host tampoco encuentra `uv`, esa ruta también tiene que ser absoluta. +* **El host sigue ejecutando su configuración anterior.** Los hosts leen su configuración al arrancar. Claude Desktop en particular hay que *cerrarlo por completo* (no solo cerrar su ventana) y volver a abrirlo para que un cambio en `claude_desktop_config.json` surta efecto. +* **Algo llegó a stdout fuera de la ventana desviada.** En stdio, stdout *es* el protocolo. El SDK desvía a stderr la salida extraviada que se vacía mientras sirve, pero la salida vaciada a stdout antes de eso (un script contenedor que hace echo, un `print()` en tiempo de importación en un proceso sin búfer), o un `print()` en búfer que se drena al salir el intérprete, le entrega al host un mensaje corrupto y este corta la conexión. Registra con la configuración por defecto de `logging`, cuyo handler de stderr vacía cada registro; los handlers personalizados también deben evitar stdout. **[Registro](../handlers/logging.md)** tiene todos los detalles. + +Claude Desktop guarda un log por servidor: `mcp-server-.log` es el stderr de tu servidor, junto a `mcp.log` para las conexiones, bajo `~/Library/Logs/Claude` en macOS y `%APPDATA%\Claude\logs` en Windows. + +Para cualquier cosa más allá de esas tres, **[Solución de problemas](../troubleshooting.md)** es la página. + +## Resumen {#recap} + +* Un **host** (Claude Desktop, un IDE) ejecuta un cliente MCP que lanza tu servidor como proceso hijo por stdio. Conectarse significa darle un comando de arranque. +* Ese comando es `uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py`: ningún entorno virtual que activar, funciona desde cualquier directorio. +* **Claude Desktop** es el único host que `mcp install` configura por ti. Escribe ese mismo comando (más la ruta absoluta a `uv`, `--frozen` y una versión fijada exactamente a la que tienes instalada) en `claude_desktop_config.json`, así que nunca tienes que hacerlo tú. +* **Claude Code** es `claude mcp add bookshop -- `. **Cursor** es `.cursor/mcp.json` bajo `mcpServers`. **VS Code** es `.vscode/mcp.json` bajo `servers`, cada entrada con un `type`. +* Rutas absolutas en todas partes, reinicia el host tras editar su configuración y nunca dejes que nada salvo el SDK escriba en stdout. + +Todos los hosts de esta página se conectaron al mismo archivo, con el mismo comando. Lo que ese archivo puede *exponer* es el resto de esta documentación: **[Herramientas](../servers/tools.md)**, **[Recursos](../servers/resources.md)** y todos los transportes aparte de stdio en **[Ejecutar tu servidor](../run/index.md)**. diff --git a/i18n/es/pages/get-started/testing.md b/i18n/es/pages/get-started/testing.md new file mode 100644 index 0000000000..c3d2727792 --- /dev/null +++ b/i18n/es/pages/get-started/testing.md @@ -0,0 +1,115 @@ +--- +translation: + sections: ['4926721070127497', c52a1de2b6b32f40, 2e410b412c25f314, 627195f7159e24ef] + tool: 1 +--- +# Pruebas {#testing} + +El SDK de Python incluye una clase `Client` con un **transporte en memoria**: le pasas tu objeto servidor y se conecta a él directamente. + +Sin subproceso. Sin puerto. Sin transporte alguno. Es la misma idea que el `TestClient` de FastAPI. + +## Uso básico {#basic-usage} + +Supongamos que tienes un servidor sencillo con una sola herramienta: + +```python title="server.py" +--8<-- "docs_src/testing/tutorial001.py" +``` + +Para ejecutar la prueba de abajo necesitarás dos dependencias adicionales (de desarrollo): + +=== "uv" + + ```bash + uv add --dev pytest inline-snapshot + ``` + +=== "pip" + + ```bash + pip install pytest inline-snapshot + ``` + +!!! info + Esta documentación supone que ya conoces [`pytest`](https://docs.pytest.org/en/stable/). + + [`inline-snapshot`](https://15r10nk.github.io/inline-snapshot/latest/) es lo que usa la prueba + de abajo para comprobar el objeto de resultado completo en una sola línea. Registra la salida de + una prueba como el literal `snapshot(...)` que ves. Si prefieres no usarlo, quita la importación + y comprueba los campos que te interesan (`result.content[0].text == "3"`) como en cualquier otra prueba. + +Ahora la prueba: + +```python title="test_server.py" +import pytest +from inline_snapshot import snapshot +from mcp import Client +from mcp.types import CallToolResult, TextContent + +from server import mcp + + +@pytest.fixture +def anyio_backend(): # (1)! + return "asyncio" + + +@pytest.fixture +async def client(): # (2)! + async with Client(mcp, raise_exceptions=True) as c: + yield c + + +@pytest.mark.anyio +async def test_call_add_tool(client: Client): + result = await client.call_tool("add", {"a": 1, "b": 2}) + # Drop the server identity stamp in `_meta`; it is not what this test is about. + result.meta = None + assert result == snapshot( + CallToolResult( + content=[TextContent(type="text", text="3")], + structured_content={"result": 3}, + ) + ) +``` + +1. Si usas `trio`, devuelve `"trio"` en su lugar. Consulta la [documentación de anyio](https://anyio.readthedocs.io/en/stable/testing.html#specifying-the-backends-to-run-on) para los detalles. +2. El fixture entrega un cliente conectado. Cada prueba que recibe `client` obtiene una conexión en memoria nueva al mismo servidor. + +¡Listo! Ahora puedes ampliar tus pruebas para cubrir más escenarios. + +## ¿Por qué `raise_exceptions=True`? {#why-raise_exceptionstrue} + +Pueden fallar dos cosas distintas, y este indicador solo afecta a una de ellas. + +Una excepción dentro de una de **tus herramientas** no es un fallo del protocolo. Se convierte en un +resultado normal con `is_error=True`, y el modelo lee el mensaje. `raise_exceptions` no cambia eso: +con o sin él, `call_tool` devuelve el mismo resultado con `is_error=True`. Hay una página entera +dedicada a esto: **[Manejo de errores](../servers/handling-errors.md)**. + +Un fallo **fuera** del cuerpo de una herramienta es otra cosa. En la conexión que te da +`Client(mcp)`, el servidor lo depura y lo convierte en un genérico `"Internal server error"` antes de +que el cliente lo vea. Nunca deberías filtrar los detalles de un fallo inesperado a un llamador +remoto. En una prueba eso es exactamente lo que *no* quieres, y es lo que cambia +`raise_exceptions=True`: tu prueba ve el mensaje real en lugar del depurado. + +Déjalo activado en las pruebas. No tiene ningún sentido en código de producción. + +## En proceso por defecto {#in-process-by-default} + +!!! note + `Client(mcp)` se conecta en proceso y es **neutral respecto a la generación** por defecto: sondea + el servidor y elige la ruta de protocolo adecuada. Fija `mode="legacy"` si tu prueba ejercita + comportamientos específicos de las conexiones heredadas (envío de muestreo (sampling) o + elicitación (elicitation), `message_handler`), y quita `raise_exceptions=True` en ese caso: una + conexión heredada nunca depura los errores en primer lugar, y el indicador relanza el fallo + dentro de la tarea del servidor en lugar de en tu prueba. + +Esa única línea es también la razón por la que esta documentación puede prometerte que sus ejemplos +funcionan: cada archivo de ejemplo lo ejercita la propia suite de pruebas del SDK, casi todos a +través de exactamente este cliente. Estás usando la misma herramienta que el SDK usa consigo mismo. + +Tienes un servidor que funciona y está probado. Ponerlo dentro de una aplicación real (Claude +Desktop, un IDE) es **[Conectar a un host real](real-host.md)**; todas las demás formas de servirlo +están en **[Ejecutar tu servidor](../run/index.md)**. diff --git a/i18n/es/pages/handlers/context.md b/i18n/es/pages/handlers/context.md new file mode 100644 index 0000000000..489f44861f --- /dev/null +++ b/i18n/es/pages/handlers/context.md @@ -0,0 +1,134 @@ +--- +translation: + sections: [b50152f05c81e786, b302059b22fb7cb4, 85682a1bf561243a, 53fc48838eb6837a, b24190e0842786ec, 85f93e150fc9b240] + tool: 1 +--- +# El Context {#the-context} + +Los argumentos de una herramienta vienen del modelo. Todo lo demás (la solicitud que estás atendiendo, el servidor en el que vives, una forma de responderle al cliente) viene de un solo objeto: el **`Context`**. + +No lo construyes ni lo configuras. Lo pides. + +## Pídelo {#ask-for-it} + +Añade un parámetro anotado con `Context` a cualquier herramienta: + +```python title="server.py" hl_lines="2 8" +--8<-- "docs_src/context/tutorial001.py" +``` + +* El SDK construye un `Context` nuevo para cada solicitud y lo pasa. +* El **nombre del parámetro no importa**. `ctx`, `context`, `c`: el SDK lo encuentra por su anotación. +* Los recursos y los prompts también pueden declarar uno, de la misma forma. +* `ctx.request_id` es el id de la solicitud que tu función está atendiendo en este momento. + +!!! info + Si has usado FastAPI, ya has visto esta jugada: declaras un parámetro con el tipo propio del + framework (`Request` allí, `Context` aquí) y el framework lo proporciona. Nada que registrar, + nada que configurar: la anotación de tipo es todo el mecanismo. + +### Invisible para el modelo {#invisible-to-the-model} + +Esta es la parte que hay que interiorizar. Este es el esquema de entrada que `tools/list` informa para `search_books`: + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"} + }, + "required": ["query"], + "title": "search_booksArguments" +} +``` + +Una sola propiedad. `ctx` no es un argumento: nunca aparece en el esquema, al modelo nunca se le habla de él y ningún cliente puede rellenarlo. Es un contrato entre tú y el SDK, que no aparece en lo que se transmite. + +### Pruébalo {#try-it} + +Ejecuta el servidor con el MCP Inspector: + +```console +uv run mcp dev server.py +``` + +El formulario de `search_books` tiene un único campo `query`. Llámalo con `dune`: + +```text +[request 3] Found 3 books matching 'dune'. +``` + +El número es el de la solicitud que haya tocado. Vuelve a llamar a la herramienta y cambia: cada solicitud recibe su propio `Context`. + +## Qué te da {#what-it-gives-you} + +El objeto inyectado es pequeño. Además de `request_id`: + +* `await ctx.read_resource(uri)`: lee uno de los recursos **propios** del servidor desde dentro de una herramienta. La siguiente sección. +* `await ctx.report_progress(progress, total, message)`: envía el progreso al llamador durante una llamada larga. Todos los detalles están en **[Progreso](progress.md)**. +* `await ctx.elicit(message, schema)` y `await ctx.elicit_url(...)`: pausan la herramienta y le hacen una pregunta al usuario. De eso trata **[Elicitación](elicitation.md)** (elicitation). +* `ctx.session`: el lado del servidor de la conversación con este cliente. Aquí viven las notificaciones que envías al cliente; la última sección lo usa. +* `ctx.headers`: los encabezados de la solicitud que transportó el transporte, o `None` en stdio. Lee un encabezado personalizado con `(ctx.headers or {}).get("x-...")`. Los encabezados son entrada proporcionada por el cliente: valen para una configuración regional o un feature flag, nunca para una identidad. +* `ctx.request_context`: el registro bruto por solicitud. El campo que vas a buscar es `lifespan_context`, el objeto que tu código de arranque entregó con yield (consulta **[Lifespan](lifespan.md)**). + +El logging queda fuera de esa lista a propósito. Un servidor registra logs con el módulo `logging` de Python, como cualquier otro programa de Python. **[Logging](logging.md)** es la página breve que explica por qué. + +!!! tip + La inyección solo ocurre en la función que registraste. Una función auxiliar a la que llama tu + herramienta no recibe su propio `Context`; pásale `ctx` como un argumento normal. No hay un + "contexto actual" ambiental que obtener desde otro sitio. + +## Lee tus propios recursos {#read-your-own-resources} + +Los recursos de un servidor no son solo para los clientes. Una herramienta también puede leerlos: + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/context/tutorial002.py" +``` + +`ctx.read_resource` resuelve la URI a través del mismo registro que atiende `resources/read`, así que una herramienta obtiene lo que obtendría un cliente: un iterable de `ReadResourceContents`, uno por bloque de contenido. Para esta URI hay uno: + +```python +contents.content # 'fiction, non-fiction, poetry' +contents.mime_type # 'text/plain' +``` + +* `content` es exactamente lo que devolvió `genres()`. Una única fuente de verdad: el cliente explora el recurso, tus herramientas lo consumen, nadie copia la cadena. +* El único parámetro de `describe_catalog` es el `Context`, así que su esquema de entrada **no tiene ninguna propiedad**. El modelo la llama con `{}`. + +## Dile al cliente que la lista cambió {#tell-the-client-the-list-changed} + +Lo que ofrece un servidor no queda fijo al importar. Registra una herramienta en tiempo de ejecución y luego díselo al cliente: + +```python title="server.py" hl_lines="15-16" +--8<-- "docs_src/context/tutorial003.py" +``` + +* `mcp.add_tool(recommend_book)` registra una función normal como herramienta: nombre, descripción y esquema se derivan exactamente como lo habría hecho `@mcp.tool()`. +* `await ctx.session.send_tool_list_changed()` envía `notifications/tools/list_changed`. Un cliente que la recibe vuelve a llamar a `tools/list` y ve `recommend_book`. + +Los hermanos son `send_resource_list_changed()`, `send_prompt_list_changed()` y `send_resource_updated(uri)` para un cambio en un recurso concreto. + +En una conexión 2026-07-28, los clientes reciben notificaciones de cambio solo en un stream `subscriptions/listen` que hayan abierto, así que los métodos `send_*` de arriba no llegan a esos streams. Los métodos de publicación del `Context` entregan a todos los streams suscritos a la vez: `await ctx.notify_tools_changed()`, `await ctx.notify_prompts_changed()`, `await ctx.notify_resources_changed()` y `await ctx.notify_resource_updated(uri)`. Todos los detalles, incluido cómo escalar horizontalmente entre réplicas, están en **[Suscripciones](subscriptions.md)**. + +!!! check + Antes de que alguien ejecute `enable_recommendations`, la herramienta que prometes no existe. + Llámala de todos modos y el resultado es un error que el modelo puede leer: + + ```text + Unknown tool: recommend_book + ``` + + Ejecuta `enable_recommendations` y esa misma llamada funciona. La lista de herramientas es + realmente dinámica: `tools/list` refleja lo que esté registrado *en este momento*. + +## Resumen {#recap} + +* Anota un parámetro con `Context` (en una herramienta, un recurso o un prompt) y el SDK lo inyecta. El nombre lo eliges tú. +* Es invisible para el modelo: el esquema de entrada solo contiene tus argumentos reales. +* `ctx.request_id` identifica la solicitud; `ctx.request_context.lifespan_context` es lo que entregó tu arranque con yield. +* `await ctx.read_resource(uri)` permite que una herramienta lea los recursos propios del servidor. +* `ctx.session` es el canal de vuelta al cliente: `send_tool_list_changed()` y sus hermanos le indican que vuelva a obtener una lista que cambiaste. +* Los informes de progreso y la elicitación también empiezan en el `Context`; cada uno tiene su propia página. + +Los parámetros que el modelo nunca ve, rellenados por tus propias funciones, son las **[Dependencias](dependencies.md)**. diff --git a/i18n/es/pages/handlers/dependencies.md b/i18n/es/pages/handlers/dependencies.md new file mode 100644 index 0000000000..abe343c734 --- /dev/null +++ b/i18n/es/pages/handlers/dependencies.md @@ -0,0 +1,168 @@ +--- +translation: + sections: [b0389403e98d25ad, e2cf58b43b285e86, a363e1a38e1a5971, 6cfac078feb18013, b4535bd61df337e6, e97ed44207f929fd] + tool: 1 +--- +# Dependencias {#dependencies} + +Los argumentos de una herramienta vienen del modelo. Algunos valores nunca deberían: un precio consultado en tus registros, una confirmación que solo una persona puede dar, cualquier cosa que el modelo podría estropear inventándosela. + +Las **dependencias** son parámetros que rellenan tus propias funciones. Anotas el parámetro, nombras la función, y el SDK la llama antes de que se ejecute tu herramienta. + +## Declara una {#declare-one} + +Envuelve el tipo del parámetro en `Annotated[...]` y añade `Resolve(fn)`: + +```python title="server.py" hl_lines="18-19 23" +--8<-- "docs_src/dependencies/tutorial001.py" +``` + +* `check_stock` es un **resolutor**: una función normal que el SDK ejecuta antes de `reserve_book`, y cuyo valor devuelto se convierte en el argumento `stock`. +* Su parámetro `title` es el propio argumento `title` de la herramienta, emparejado **por nombre**. El resolutor ve exactamente el valor validado que verá el cuerpo de la herramienta. +* El cuerpo de la herramienta parte de un `Stock` que ya existe. Nada de código de consulta en la herramienta, nada de preámbulo del tipo "y si falta". + +!!! info + Si has usado FastAPI, esto es `Depends`. El mismo mecanismo, por la misma razón: la función declara lo + que necesita, el framework lo proporciona, y el cableado vive en la anotación de tipo. + +### Invisible para el modelo {#invisible-to-the-model} + +Este es el esquema de entrada que `tools/list` reporta para `reserve_book`: + +```json +{ + "type": "object", + "properties": { + "title": {"title": "Title", "type": "string"} + }, + "required": ["title"], + "title": "reserve_bookArguments" +} +``` + +Una sola propiedad. Igual que el `Context` en **[El Context](context.md)**, un parámetro resuelto es un contrato entre tú y el SDK: `stock` no está en el esquema, al modelo nunca se le habla de él, y a un cliente que envíe un valor `stock` de todos modos se le ignora. El valor del resolutor es el único que puede recibir tu herramienta. + +Esa última parte es la clave. Un parámetro que el modelo no puede proporcionar es un parámetro que el modelo no puede estropear. + +### Pruébalo {#try-it} + +Ejecuta el servidor con el MCP Inspector: + +```console +uv run mcp dev server.py +``` + +El formulario de `reserve_book` tiene un único campo `title`. `stock` no aparece por ningún lado. Llámala con `Dune`: + +```text +Reserved 'Dune' (6 copies left). +``` + +El cuerpo de la herramienta nunca consultó nada: `check_stock` se ejecutó primero, y el `Stock` que devolvió llegó como argumento. Prueba con `Neuromancer` y el mismo resolutor le entrega un cero a la herramienta. + +!!! tip + Podrías simplemente llamar a `check_stock(title)` en el cuerpo de la herramienta. Decláralo como + dependencia cuando el valor merezca más que una llamada a una función auxiliar: todas las herramientas + que necesitan el stock declaran el mismo parámetro, y el SDK ejecuta el resolutor como mucho una vez por + llamada, sin importar cuántas lo declaren. Las siguientes secciones añaden el resto: resolutores que + dependen unos de otros, y resolutores que preguntan al usuario. + +## Dependencias de dependencias {#dependencies-of-dependencies} + +Un resolutor puede declarar sus propias dependencias, con la misma anotación: + +```python title="server.py" hl_lines="22 29-30" +--8<-- "docs_src/dependencies/tutorial002.py" +``` + +* `estimate_delivery` depende de `check_stock`. El SDK ejecuta el grafo en orden: primero el stock, luego la estimación, luego la herramienta. +* Tanto `stock` como `delivery` necesitan `check_stock` en última instancia, pero se ejecuta **una vez por llamada**. Una consulta de inventario, dos consumidores. +* No hay nada que registrar. El grafo *son* las anotaciones. + +!!! check + No te creas lo de una vez por llamada sin comprobarlo. Pon un `print` en `check_stock` y llama a + `order_book` desde el Inspector: una línea por llamada. Dos consumidores, una consulta. + +El SDK analiza el grafo cuando se registra la herramienta, no cuando se llama. Un parámetro que no puede clasificar (ni un `Context`, ni un `Resolve(...)`, ni el nombre de un argumento de la herramienta) y un ciclo de resolutores lanzan ambos `InvalidSignature` al arrancar. El servidor falla antes de que ningún cliente se conecte, con el parámetro o resolutor problemático nombrado en el error. + +Los parámetros de un resolutor se resuelven exactamente igual que los de una herramienta: otro `Resolve(...)`, los argumentos de la propia herramienta por nombre, o el `Context`: `ctx.headers`, el objeto del lifespan, todo. + +!!! warning + En los transportes HTTP el `Context` incluye `ctx.headers`. Las cabeceras son **entrada proporcionada + por el cliente**, como cualquier argumento de herramienta: bien para una configuración regional o un + feature flag, nunca para una identidad. Quién es el que llama viene de tu capa de autorización + (**[Autorización](../run/authorization.md)**), no de una cabecera que cualquiera puede establecer. + +!!! tip + *Una vez por llamada* significa exactamente eso: el siguiente `tools/call` ejecuta `check_stock` otra + vez. Un recurso que debe sobrevivir a una solicitud (un pool de base de datos, un cliente HTTP) + pertenece al **[Lifespan](lifespan.md)**, y un resolutor puede llegar a él a través de + `ctx.request_context.lifespan_context`. + +## Pregunta cuando debas {#ask-when-you-must} + +Un resolutor no tiene por qué saber la respuesta. Puede devolver `Elicit(message, Model)` y el SDK pregunta al usuario: la maquinaria de **[Elicitación](elicitation.md)** (elicitation), ejecutada por ti: + +```python title="server.py" hl_lines="26-32 39" +--8<-- "docs_src/dependencies/tutorial003.py" +``` + +* Con stock: `confirm_backorder` devuelve un `Backorder` directamente. **Sin pregunta, sin ida y vuelta.** Solo se interrumpe al usuario cuando su respuesta importa. +* Sin stock: el SDK envía la elicitación, valida la respuesta contra `Backorder` y la inyecta. Tu resolutor nunca toca el protocolo. +* La herramienta lee `backorder.confirm` como cualquier otro argumento. Responder **no** sigue siendo una respuesta: la elicitación se acepta con `confirm=False`, la herramienta se ejecuta y no se hace ningún pedido. Preguntar se convirtió en una precondición, no en fontanería dentro del cuerpo de la herramienta. + +¿Y si el usuario no responde en absoluto, si rechaza la pregunta o la cancela? + +!!! check + Ejecuta `order_book` para `Neuromancer` y rechaza la pregunta. Con la anotación escrita como + `Annotated[Backorder, Resolve(...)]` el cuerpo de la herramienta nunca se ejecuta; la llamada falla con + un resultado de error que el modelo puede leer: + + ```text + Error executing tool order_book: Resolver for parameter 'backorder' could not resolve: elicitation was decline + ``` + +Ese es el valor por defecto correcto para una precondición: sin respuesta, no hay pedido. Cuando rechazar es un resultado que tu herramienta quiere manejar (omitir el pedido pendiente pero aun así sugerir otro título), anota `ElicitationResult[Backorder]` en su lugar y la herramienta recibe el resultado completo de aceptar/rechazar/cancelar para bifurcar según él. **[Elicitación](elicitation.md)** muestra esa forma, y todo lo demás sobre preguntar: las reglas del esquema, las tres respuestas, el lado del cliente en la conversación. + +!!! info + El framework elige el transporte de la pregunta a partir de la versión del protocolo negociada; el + código de arriba es idéntico en ambas. En **2026-07-28** y posteriores la pregunta viaja dentro de un + `tools/call` de varias idas y vueltas (multi-round-trip): el servidor la devuelve, el + `elicitation_callback` del cliente la responde, y el `Client` reintenta la llamada por ti + (**[Solicitudes de varias idas y vueltas](multi-round-trip.md)**). En **2025-11-25** y anteriores es + una solicitud de elicitación síncrona a mitad de llamada. Cada pregunta se hace exactamente una vez por + llamada: una garantía sobre la pregunta, no sobre el resolutor. En la forma de varias idas y vueltas + cualquier resolutor puede volver a ejecutarse cada vez que la llamada se reanuda tras una pregunta, así + que el código anterior a un `return Elicit(...)` se ejecuta en cada una de esas rondas; la respuesta + registrada satisface entonces la pregunta repetida sin volver a preguntar al usuario. Una respuesta + registrada solo se consulta cuando el resolutor pregunta; un resolutor que responde *sin* preguntar, + como `check_stock`, siempre proporciona su propio valor calculado. Como cada respuesta se empareja con + su pregunta, un resolutor que elicita debe derivar su pregunta de forma determinista a partir de los + argumentos de la herramienta y las respuestas anteriores. Un valor generado por llamada (un id de + `default_factory`, una marca de tiempo) se vuelve a derivar en cada ronda y no debe aparecer en una + pregunta a la que la respuesta deba quedar vinculada. Una pregunta construida con datos tan volátiles + hace que toda respuesta registrada parezca obsoleta, así que el servidor la vuelve a hacer en cada + ronda hasta que el límite de rondas del cliente termina la llamada. + +## Pregunta al cliente, no al usuario {#ask-the-client-not-the-user} + +La elicitación es una de las tres preguntas que puede hacer un resolutor, y el flujo de varias idas y vueltas no permite otras. Las otras dos van al **cliente** en lugar de al usuario: devuelve `Sample(...)` para ejecutar una llamada a un LLM a través del cliente (una solicitud `sampling/createMessage`), o `ListRoots()` para obtener los roots actuales del cliente. Ninguna tiene un resultado de aceptar/rechazar; el consumidor anota el tipo de resultado directamente, `CreateMessageResult` (`CreateMessageResultWithTools` cuando la solicitud lleva `tools` o `tool_choice`) o `ListRootsResult`: + +```python title="server.py" hl_lines="10-15 21" +--8<-- "docs_src/dependencies/tutorial004.py" +``` + +* El framework las enruta exactamente igual que `Elicit`: dentro del `tools/call` de varias idas y vueltas en **2026-07-28**, sobre la solicitud independiente servidor->cliente en **2025-11-25**. Una capacidad no declarada rechaza la llamada con un error de protocolo `-32021` (`sampling`, `roots`, `elicitation` en modo formulario; `sampling.tools` cuando la solicitud lleva `tools` o `tool_choice`). +* Todo lo que dice el recuadro informativo de arriba sobre las preguntas se aplica sin cambios: una solicitud `Sample` se empareja con su resultado registrado por su representación exacta, así que constrúyela de forma determinista a partir de los argumentos de la herramienta y las respuestas anteriores; el cliente paga entonces la llamada al LLM una vez por llamada a la herramienta, no una vez por ronda. El resultado registrado viaja en `request_state` durante el resto de la llamada, así que una respuesta del modelo muy grande hace más pesada cada ida y vuelta restante. +* Las *funcionalidades* independientes de muestreo (sampling) y roots quedan obsoletas en 2026-07-28 (SEP-2577). Los servidores nuevos que necesitan el modelo del cliente preguntan a través de este mecanismo; los que no, deberían integrarse directamente con un proveedor de LLM. Los valores de `include_context` distintos de `"none"` están ellos mismos obsoletos; evítalos. + +## Resumen {#recap} + +* `Annotated[T, Resolve(fn)]` en un parámetro de herramienta: el SDK ejecuta `fn` e inyecta su valor devuelto. +* Un parámetro resuelto es invisible para el modelo y un cliente no puede proporcionarlo. Los valores que el modelo no debe inventar (precios, identidades, permisos) van aquí. +* Los parámetros de un resolutor se resuelven del mismo modo: el `Context`, otro `Resolve(...)`, o un argumento de la herramienta por nombre. El grafo ejecuta cada resolutor como mucho una vez por ronda, tenga los consumidores que tenga; cada pregunta se hace exactamente una vez, y cualquier resolutor puede volver a ejecutarse cuando una llamada se reanuda tras una pregunta. +* Los grafos incorrectos fallan en el registro con `InvalidSignature`, no a mitad de llamada. +* Devuelve `Elicit(message, Model)` para preguntar al usuario, solo cuando tengas que hacerlo. Las anotaciones sin envolver abortan al rechazar; `ElicitationResult[T]` permite a la herramienta bifurcar. +* Devuelve `Sample(...)` o `ListRoots()` para pedir al cliente una respuesta del modelo o la lista de roots; se inyecta el resultado sin más. + +El estado que tu servidor construye una vez al arrancar, y cómo llega a él un handler, es la página de **[Lifespan](lifespan.md)**. diff --git a/i18n/es/pages/handlers/elicitation.md b/i18n/es/pages/handlers/elicitation.md new file mode 100644 index 0000000000..927c080823 --- /dev/null +++ b/i18n/es/pages/handlers/elicitation.md @@ -0,0 +1,192 @@ +--- +translation: + sections: [335ca2a0b266f003, d1ad562d3fe87bc0, 0bb1396c86daeba4, d1cb1235bb9ee267, 833179c09d239c83, e5d6dec2d2e655e8] + tool: 1 +--- +# Elicitación {#elicitation} + +Una herramienta que va por la mitad de su trabajo y a la que le falta una respuesta no tiene por qué fallar. + +La **elicitación** (elicitation) le permite preguntar. En medio de una llamada a la herramienta, el usuario recibe una pregunta y su respuesta vuelve a la misma llamada de función. + +Hay dos modos: + +* **Modo formulario**: necesitas un valor (una confirmación, una fecha, una cantidad). Describes los campos y el cliente muestra el formulario. +* **Modo URL**: necesitas que el usuario vaya a otro sitio (una pantalla de consentimiento OAuth, una página de pago). Nada de lo que haga allí pasa por el protocolo. + +Y hay dos formas de preguntar. La que conviene usar es un **resolutor**: cuelgas la pregunta de un parámetro y el SDK pregunta, en cualquier conexión, sea cual sea la generación del protocolo que hable el cliente. La forma directa, `await ctx.elicit(...)`, es una solicitud del *servidor* al *cliente*, un canal que solo existe para un cliente en una conexión heredada (versión de la especificación 2025-11-25 o anterior). Ambas están en esta página; empieza por el resolutor. + +## Preguntar con un resolutor {#ask-with-a-resolver} + +Una pregunta que condiciona toda la herramienta (*¿estás seguro?, ¿cuál de las tres cuentas que coinciden?*) puede sacarse del cuerpo de la herramienta a un **resolutor**, y el framework la hace por ti. + +Un parámetro anotado como `Annotated[T, Resolve(fn)]` se rellena ejecutando `fn` antes del cuerpo de la herramienta. El resolutor devuelve el valor directamente cuando ya lo conoce, o devuelve `Elicit(...)` para que el framework pregunte: + +```python title="server.py" hl_lines="24-30 35-36" +--8<-- "docs_src/elicitation/tutorial004.py" +``` + +* `confirm_delete` lee por nombre el propio argumento `path` de la herramienta, lista la carpeta y **solo pregunta cuando debe**: una carpeta vacía se resuelve a `Confirm(ok=True)` sin ninguna ida y vuelta al cliente. +* `delete_folder` anota `ElicitationResult[Confirm]`, así que el framework inyecta el resultado completo y la herramienta usa `match` para cubrir cada caso: aceptar y confirmar, aceptar pero conservar (`ok=False`), rechazar, cancelar. +* El parámetro `confirm` nunca aparece en el esquema de entrada de la herramienta: el cliente aporta `path`, el resolutor aporta `confirm`. + +Anota en su lugar el modelo sin envolver (`Annotated[Confirm, Resolve(confirm_delete)]`) cuando la herramienta no necesita bifurcar: recibe el modelo si el usuario acepta y la llamada se interrumpe con un error si rechaza o cancela. + +Un resolutor funciona en **todas** las conexiones. A un cliente en una conexión heredada, el SDK le envía la pregunta directamente; en una conexión **2026-07-28**, el SDK *devuelve* la pregunta desde la llamada y el siguiente intento del cliente lleva la respuesta. Tu resolutor nunca nota la diferencia; lo que ocurre por debajo está en **[Solicitudes de varias idas y vueltas](multi-round-trip.md)** (multi-round-trip). + +Preguntar es solo una de las cosas que puede hacer un resolutor. El mecanismo general (dependencias que calculan sin preguntar, dependencias de dependencias, qué puede aportar el modelo y qué no) está en la página **[Dependencias](dependencies.md)**. + +## Preguntar desde dentro de la herramienta {#ask-from-inside-the-tool} + +Una herramienta también puede detenerse en medio de su propio cuerpo y preguntar. + +!!! warning + `ctx.elicit()` y `ctx.elicit_url()` son solicitudes del *servidor* al *cliente*: un + canal que solo existe para un cliente en una conexión heredada (versión de la especificación + **2025-11-25** o anterior). En una conexión **2026-07-28** no hay solicitudes iniciadas por el + servidor, así que estas llamadas fallan. Un resolutor funciona en ambas. + **[Versiones del protocolo](../protocol-versions.md)** tiene todos los detalles. + +`await ctx.elicit()` recibe un mensaje y un modelo de Pydantic: + +```python title="server.py" hl_lines="9-11 20-23 25" +--8<-- "docs_src/elicitation/tutorial001.py" +``` + +* El parámetro **`Context`** es lo que te da `ctx.elicit`; cualquier herramienta puede recibir uno. Ese objeto tiene su propia página: **[El Context](context.md)**. +* `AlternativeDate` es el **esquema** de la respuesta que quieres. +* La herramienta es `async def`. Tiene que serlo: se detiene a mitad y espera a una persona. +* En cualquier otra fecha la herramienta devuelve enseguida. Solo pregunta cuando tiene que hacerlo. +* La fecha que acepta el usuario vuelve a pasar por el propio `book_table`. Una respuesta es una entrada como cualquier otra: una alternativa que también está completa provoca una nueva pregunta, no se confirma a ciegas. + +### Qué recibe el cliente {#what-the-client-receives} + +El cliente recibe tu mensaje y, junto a él, un JSON Schema generado a partir del modelo: + +```json +{ + "properties": { + "accept_alternative": { + "description": "Try another date?", + "title": "Accept Alternative", + "type": "boolean" + }, + "date": { + "default": "2025-12-26", + "description": "Alternative date (YYYY-MM-DD)", + "title": "Date", + "type": "string" + } + }, + "required": ["accept_alternative"], + "title": "AlternativeDate", + "type": "object" +} +``` + +Ese esquema es el formulario. `Field(description=...)` es la etiqueta; un valor por defecto rellena el campo de antemano y lo hace opcional. Es la misma maquinaria de Pydantic a JSON Schema que **[Herramientas](../servers/tools.md)** describe para los argumentos de una herramienta. + +!!! warning + Un esquema de elicitación no es tan expresivo como el esquema de entrada de una herramienta. + Solo campos planos y primitivos: `str`, `int`, `float`, `bool` o un `Literal` de cadenas (se + convierte en un `enum`). Pon un modelo dentro del modelo y `ctx.elicit` lanza una excepción + antes de que se envíe nada al cliente: + + ```text + TypeError: Elicitation schema field 'address' rendered as {'$ref': '#/$defs/Address'}, which is not a valid PrimitiveSchemaDefinition + ``` + + Estás interrumpiendo a una persona en plena tarea. Si la respuesta necesita anidamiento, + debería haber sido un argumento de la herramienta. + +### Las tres respuestas {#the-three-answers} + +`result.action` te dice qué hizo el usuario, y hay exactamente tres posibilidades: + +* `"accept"`: envió el formulario. `result.data` es una instancia de `AlternativeDate`, ya validada. +* `"decline"`: dijo que no. +* `"cancel"`: descartó la pregunta sin elegir. + +`result.data` solo existe con `"accept"`, y por eso el ejemplo comprueba primero `result.action`. Tu verificador de tipos impone el orden: después de `result.action == "accept"`, `result.data` es un `AlternativeDate`; antes, no hay ningún `.data`. + +Una negativa no es un error. La herramienta decide qué significa rechazar (aquí, no hay reserva) y responde al modelo con normalidad. + +!!! tip + La respuesta se valida contra tu modelo antes de que tu código la vea. Un cliente que envía + `"maybe"` para un `bool` no corrompe tu reserva: la llamada falla con un error de + discrepancia de esquema y tu `if` nunca se ejecuta. + +## Enviar al usuario a una URL {#send-the-user-to-a-url} + +Algunas cosas no deben pasar por el modelo ni por el cliente: credenciales, números de tarjeta, consentimiento OAuth. Para esas no pides datos; pides al usuario que vaya a algún sitio: + +```python title="server.py" hl_lines="10-14 23" +--8<-- "docs_src/elicitation/tutorial002.py" +``` + +* `ctx.elicit_url()` recibe el mensaje, la **URL** que hay que visitar y un `elicitation_id` que eliges tú: cualquier cadena que identifique esta elicitación dentro de tu servidor. +* El resultado tiene una acción y nada más. `"accept"` significa que el usuario aceptó abrir la URL, **no** que haya terminado lo que hay al otro lado. +* El pago ocurre fuera de banda, entre el navegador del usuario y tu proveedor de pagos. Ningún contenido vuelve nunca a través de MCP. + +Fíjate en la segunda herramienta. Cuando el servidor se entera de que el flujo fuera de banda terminó (un webhook, un sondeo; aquí se modela como una segunda herramienta), `ctx.session.send_elicit_complete(...)` envía `notifications/elicitation/complete` con el mismo `elicitation_id`. Así es como el cliente sabe que puede dejar de mostrar *"waiting for payment..."*. Sin eso, el cliente solo puede adivinar. + +## El lado del cliente {#the-client-side} + +Los servidores preguntan. Los clientes responden pasando un **`elicitation_callback`** a `Client(...)`: + +```python title="client.py" hl_lines="6-7 18" +--8<-- "docs_src/elicitation/tutorial003.py" +``` + +* Un solo callback maneja ambos modos. `params` es una unión de `ElicitRequestFormParams` y `ElicitRequestURLParams`; `isinstance` es la bifurcación. +* Para una URL, muestras `params.url` al usuario y devuelves la acción que eligió. Nunca ningún `content`. +* Para un formulario, una aplicación real muestra `params.requested_schema` y devuelve la entrada del usuario como `content`. Este siempre dice que sí con una respuesta predefinida, que es justo el callback que quieres en una prueba. +* Pasar el callback es también la **declaración de capacidad**: es como el servidor se entera de que a este cliente se le puede preguntar. Las demás cosas que un cliente puede responder a un servidor están en **[Callbacks del cliente](../client/callbacks.md)**. + +!!! info + La elicitación es una solicitud del *servidor* al *cliente*, y esas solo existen en una + sesión con handshake clásico, por eso este cliente pasa `mode="legacy"`. + En una conexión **2026-07-28**, una herramienta pregunta *devolviendo* la pregunta desde la + llamada; ese flujo está en **[Solicitudes de varias idas y vueltas](multi-round-trip.md)**. + +### Pruébalo {#try-it} + +Arranca el `server.py` del modo formulario con `ctx.elicit` (el de `book_table`) sobre Streamable HTTP (**[Ejecutar tu servidor](../run/index.md)** tiene el comando de una línea), luego ejecuta el `main()` del cliente y pide a `book_table` el día de Navidad. + +El callback imprime la pregunta que recibió: + +```text +No tables for 2 on 2025-12-25. Would you like to try another date? +``` + +Responde con `{"accept_alternative": True, "date": "2025-12-27"}`, y la herramienta, que ha estado esperando dentro de `await ctx.elicit(...)` todo este tiempo, termina la reserva: + +```text +Booked a table for 2 on 2025-12-27. +``` + +Ahora cambia al `server.py` del modo URL y apunta el mismo `main()` a `pay_deposit`: el mismo callback toma la otra rama, imprime el enlace de pago y la herramienta vuelve con *"Complete the payment in your browser."* Una ida y vuelta, en mitad de la llamada, en ambos sentidos. + +!!! check + Ahora quita `elicitation_callback=` del `Client` y vuelve a llamar a `book_table` para el día + de Navidad. Toda la llamada falla con un error de protocolo: + + ```text + Elicitation not supported + ``` + + Un cliente que no registró ningún callback nunca declaró la capacidad `elicitation`, así que + no hay nadie a quien preguntar. Tu herramienta no recibió un `"decline"`; recibió una + excepción. Diseña para ello: toda elicitación necesita una respuesta sensata a "¿y si no + puedo preguntar?". + +## Resumen {#recap} + +* Un parámetro anotado como `Annotated[T, Resolve(fn)]` lo rellena un resolutor, que devuelve `Elicit(...)` cuando tiene que preguntar. Funciona en todas las conexiones. +* El esquema es un modelo plano de Pydantic: solo campos primitivos, validados al volver. +* `result.action` es `"accept"`, `"decline"` o `"cancel"`; `result.data` solo existe en accept. +* `await ctx.elicit(message, schema=Model)` pregunta desde dentro del cuerpo de la herramienta, y `await ctx.elicit_url(message, url, elicitation_id)` es para todo lo que no debe pasar por el modelo (`ctx.session.send_elicit_complete(elicitation_id)` indica que la parte fuera de banda terminó). Ambas son solicitudes del servidor al cliente: necesitan al cliente en una conexión heredada. +* El cliente responde con un solo `elicitation_callback`, bifurcando según el tipo de params; registrarlo es lo que declara la capacidad. +* En una conexión 2026-07-28 el servidor devuelve la pregunta en lugar de enviarla; el mismo callback se alimenta desde **[Solicitudes de varias idas y vueltas](multi-round-trip.md)**. + +Todo lo que hay debajo de ese retorno (el bucle de reintentos, proteger `requestState`, manejarlo tú mismo) está en **[Solicitudes de varias idas y vueltas](multi-round-trip.md)**. diff --git a/i18n/es/pages/handlers/index.md b/i18n/es/pages/handlers/index.md new file mode 100644 index 0000000000..8190892631 --- /dev/null +++ b/i18n/es/pages/handlers/index.md @@ -0,0 +1,24 @@ +--- +translation: + sections: [424930166c4bc6f3] + tool: 1 +--- +# Dentro de tu handler {#inside-your-handler} + +Los argumentos de un handler vienen del cliente. Todo lo *demás* que puede leer, y todo lo que puede hacer mientras se ejecuta, está aquí. + +Lo que puede leer: + +* **[El Context](context.md)** es el único parámetro extra que cualquier handler puede pedir: la solicitud en curso, sus cabeceras, su sesión y los verbos de progreso y de notificación de cambios. +* **[Dependencias](dependencies.md)** son parámetros que el modelo nunca ve, rellenados por tus propias funciones con `Resolve`. +* **[Lifespan](lifespan.md)** cubre el estado que el servidor construye una sola vez al arrancar, y cómo un handler llega a él a través del `Context`. + +Lo que puede hacer mientras se ejecuta: + +* Pedir más datos al usuario con **[Elicitación](elicitation.md)**, y con **[Solicitudes de varias idas y vueltas](multi-round-trip.md)**, el patrón de 2026-07-28 que la transporta. +* Pedir al cliente una respuesta de su LLM o sus carpetas de trabajo con **[Muestreo y roots](sampling-and-roots.md)**, obsoletos pero todavía atendidos. +* Informar del **[Progreso](progress.md)** de algo lento. +* Escribir logs (en el error estándar, para quien opere el servidor) con **[Logging](logging.md)**. +* Avisar a los clientes suscritos de que algo cambió con **[Suscripciones](subscriptions.md)**. + +Si todavía no has registrado ningún handler, empieza por **[Herramientas](../servers/tools.md)**. Todas las páginas de esta sección suponen que ya tienes uno. diff --git a/i18n/es/pages/handlers/lifespan.md b/i18n/es/pages/handlers/lifespan.md new file mode 100644 index 0000000000..3c4fa61362 --- /dev/null +++ b/i18n/es/pages/handlers/lifespan.md @@ -0,0 +1,107 @@ +--- +translation: + sections: [f3ca8ac5f90f2dfa, 85a1ef3588ba0736, 563346d4d5804933, 9e3528340d0bab53] + tool: 1 +--- +# Lifespan {#lifespan} + +La mayoría de los servidores reales mantienen algo durante toda su vida: un pool de conexiones a la base de datos, un cliente HTTP, un modelo cargado. + +No quieres construirlo en cada llamada, y sí quieres cerrarlo limpiamente. Para eso está el **lifespan** (ciclo de vida del servidor). + +## Un lifespan tipado {#a-typed-lifespan} + +Un lifespan es un `@asynccontextmanager` que recibe el servidor y hace `yield` de **un solo objeto**. Lo que sea que entregues queda disponible para todos los handlers mientras el servidor esté en ejecución. + +```python title="server.py" hl_lines="25-31 34 38 40" +--8<-- "docs_src/lifespan/tutorial001.py" +``` + +Léelo de abajo hacia arriba: + +* `app_lifespan` conecta la `Database` **antes** del `yield` y la desconecta **después**, en un `finally`. Eso es el arranque y el apagado. +* Entrega un `AppContext`, una dataclass simple que contiene las cosas que configuraste. Un campo hoy, diez mañana. +* `MCPServer("Bookshop", lifespan=app_lifespan)` es todo el cableado necesario. +* Dentro de la herramienta, el objeto entregado es `ctx.request_context.lifespan_context`. + +El lifespan se ejecuta **una sola vez**. Se entra en él cuando el servidor arranca (antes de la primera solicitud) y se sale cuando el servidor se detiene. Todas las solicitudes intermedias comparten el mismo `AppContext`. + +!!! info + Si has escrito un `lifespan` de FastAPI, ya conoces esto. Mismo decorador, mismo `yield`, mismo `finally`. + +### Lo que ve el modelo {#what-the-model-sees} + +Nada nuevo. `ctx` es un parámetro **Context**, así que el SDK lo inyecta y nunca llega al esquema de entrada: + +```json +{ + "type": "object", + "properties": { + "genre": {"title": "Genre", "type": "string"} + }, + "required": ["genre"], + "title": "count_booksArguments" +} +``` + +`genre` es el único argumento que el modelo puede pasar. El lifespan es asunto de tu servidor. + +Las funciones `@mcp.resource()` y `@mcp.prompt()` también pueden recibir un parámetro `ctx`, escrito como un `Context` a secas por una razón que se explica en la siguiente sección. Todo lo que lleva `ctx` está en **[El Context](context.md)**. + +### De verdad está tipado {#it-really-is-typed} + +Mira de nuevo la anotación: `ctx: Context[AppContext]`. + +Ese único parámetro de tipo es la razón por la que `ctx.request_context.lifespan_context` **es** un `AppContext` para tu verificador de tipos. `.db` se autocompleta; `.dbb` es un error antes de que llegues a ejecutar el servidor. + +Si escribes un `Context` a secas, `lifespan_context` queda tipado como `dict[str, Any]`: el verificador de tipos no tiene forma de saber qué entregó tu lifespan. El objeto sigue ahí en tiempo de ejecución; lo que pierdes es la ayuda. + +!!! warning + `Context[AppContext]` es una forma de escribirlo **exclusiva de las herramientas**. Ponla en una + función `@mcp.resource()` o `@mcp.prompt()` y todas las llamadas a ese handler fallan. El cliente + recibe un error, y el log del servidor muestra por qué: + + ```text + Context is not available outside of a request + ``` + + En recursos y prompts, escribe `ctx: Context` a secas. El objeto que entregó tu lifespan + sigue siendo `ctx.request_context.lifespan_context` en tiempo de ejecución; renuncias al + parámetro de tipo, no al objeto. + +!!! tip + Siempre hay un lifespan. Si no pasas uno, el lifespan por defecto del SDK entrega un `dict` vacío, + así que `ctx.request_context.lifespan_context` es `{}`, nunca `None`. Ese valor por defecto es también + la razón por la que un `Context` a secas lo tipa como `dict[str, Any]`. + +## Míralo en acción {#watch-it-happen} + +"El arranque se ejecuta antes de la primera solicitud" es el tipo de frase que no deberías tener que creerte sin más. + +Reduce el servidor al ciclo de vida: dale a `Database` un indicador `connected`, cámbialo en `connect()` y `disconnect()`, y añade una herramienta que informe de su valor. + +```python title="server.py" hl_lines="11 14 17 25 44" +--8<-- "docs_src/lifespan/tutorial002.py" +``` + +`database` vive a nivel de módulo por una sola razón: para que puedas observarla desde *fuera* del servidor. + +!!! check + Tres momentos, tres valores: + + * Antes de que el servidor arranque, `database.connected` es `False`. Importar el módulo no conectó nada. + * Mientras está en ejecución, llama a `database_status` y el resultado es `"connected"`. + * Detén el servidor y se ejecuta el bloque `finally`: `database.connected` es `False` de nuevo. + + El trabajo ocurrió exactamente donde lo pusiste: alrededor del `yield`, no al importar ni en cada solicitud. + +## Resumen {#recap} + +* `lifespan=` recibe un `@asynccontextmanager` que recibe el servidor y hace `yield` de un solo objeto. +* El código anterior al `yield` es el arranque. El `finally` posterior es el apagado. +* Se ejecuta una sola vez, alrededor de toda la vida del servidor, no en cada solicitud. +* Lo que sea que entregues con `yield` es `ctx.request_context.lifespan_context` en cada herramienta, recurso y prompt. +* `ctx: Context[AppContext]` hace que ese acceso esté completamente tipado en las herramientas. Los recursos y prompts reciben el `Context` a secas. +* Sin `lifespan=`, obtienes un `dict` vacío, nunca `None`. + +Un handler que se detiene a mitad de una llamada para preguntarle al usuario algo que solo él sabe es **[Elicitación](elicitation.md)**. diff --git a/i18n/es/pages/handlers/logging.md b/i18n/es/pages/handlers/logging.md new file mode 100644 index 0000000000..ad35f6a991 --- /dev/null +++ b/i18n/es/pages/handlers/logging.md @@ -0,0 +1,87 @@ +--- +translation: + sections: [c93a3e1aefd77955, 7851abd5ec54393b, f49d1ca2f330f9cd, c03764bd9dfeef7b, 4a0391691a674ae4, 2df5cd279eabf9f5] + tool: 1 +--- +# Registro de logs {#logging} + +Registra logs desde una herramienta igual que desde cualquier otra función de Python: con la biblioteca estándar. + +MCP tiene una **capacidad de logging** a nivel de protocolo: un servidor podía enviar sus mensajes de log al cliente como notificaciones, mediante métodos del objeto `Context`. La revisión 2026-07-28 de la especificación **declara obsoleta esa capacidad y no la sustituye**, así que esta documentación no la enseña. La lista completa de lo que está obsoleto y qué hacer en su lugar está en **[Funcionalidades obsoletas](../deprecated.md)**. + +Lo que haces en su lugar es lo que haces en cualquier otro programa de Python: usar la biblioteca estándar. + +## Una herramienta que registra logs {#a-tool-that-logs} + +```python title="server.py" hl_lines="1 5 13" +--8<-- "docs_src/logging/tutorial001.py" +``` + +* `logging.getLogger(__name__)` te da un logger con el nombre de tu módulo. Créalo una vez, al principio. +* Dentro de la herramienta llamas a `logger.info(...)` como en cualquier otra función. Nada que inyectar, nada que esperar con `await`, nada específico de MCP. + +!!! check + Llama a la herramienta y mira el resultado completo: + + ```python + result.content # [TextContent(text="Found 3 books matching 'dune'.")] + result.structured_content # {'result': "Found 3 books matching 'dune'."} + ``` + + La línea de log no aparece por ningún lado. El logging es para **ti**, la persona que opera el + servidor. El modelo nunca lo ve. Si el modelo debe leer algo, devuélvelo con `return`. + +## A dónde va {#where-it-goes} + +Para un servidor **stdio**, esta pregunta importa más de lo habitual. El host lanzó el servidor como subproceso y lee los mensajes MCP desde su **stdout**. La salida de error estándar es tuya. + +La biblioteca estándar ya hace lo correcto: la salida de log va a `sys.stderr` por defecto. Tus líneas `logger.info(...)` acaban en la terminal (o donde sea que el host recoja el stderr del subproceso), y el flujo del protocolo se mantiene limpio. + +!!! tip + No uses `print()` en un servidor stdio. `print` escribe en **stdout**, y stdout pertenece al + protocolo. Mientras atiende solicitudes, el SDK desvía a stderr el stdout que realmente se *vacía* + (flush), así que no puede corromper el canal. Pero un `print()` en un proceso con búfer por bloques + suele quedarse sin vaciar en el búfer de `sys.stdout` hasta que el intérprete lo drena al salir, + directamente sobre el flujo del protocolo. Incluso cuando se desvía, la línea llega en bruto entre la + salida de log, sin nivel, sin nombre de logger y sin forma de filtrarla. + + `logger.debug("got here")` cuesta la misma línea de esfuerzo y va al lugar correcto. + +## El nivel {#the-level} + +No tienes que llamar a `logging.basicConfig()` tú mismo. Construir un `MCPServer` ya lo hizo, con un handler apuntado a la salida de error estándar, al nivel que pasas como `log_level=`, así que `MCPServer("Bookshop", log_level="DEBUG")` es todo lo que hace falta para ver tus líneas `logger.debug(...)`. + +El valor por defecto es `"INFO"`. + +`logging.basicConfig()` nunca reemplaza handlers que ya existen. Si configuras el logging tú mismo antes de crear el servidor, tu configuración gana. + +## Pruébalo {#try-it} + +Ejecuta el servidor con el MCP Inspector: + +```console +uv run mcp dev server.py +``` + +Llama a `search_books` desde la pestaña **Tools**. El Inspector te muestra el resultado: solo el valor devuelto. La línea + +```text +Searching for 'dune' +``` + +fue a la salida de error estándar: a la terminal, no al canal. + +!!! info + Si lo que realmente quieres es *trazabilidad* (cada solicitud, cuánto tardó, si falló), no quieres + líneas de log, quieres spans. El servidor ya los emite: el SDK traza cada mensaje con OpenTelemetry + por defecto. Consulta **[OpenTelemetry](../run/opentelemetry.md)**. + +## Resumen {#recap} + +* La capacidad de logging del protocolo MCP queda obsoleta en la especificación 2026-07-28 y no se sustituye. No construyas sobre ella. +* `logger = logging.getLogger(__name__)` a nivel de módulo, `logger.info(...)` en la herramienta. Ese es todo el patrón. +* La salida de log nunca llega al modelo. Solo lo hace el valor que devuelves con `return`. +* La salida de error estándar es tuya; stdout pertenece al protocolo. El SDK desvía a stderr el stdout extraviado que se vacía mientras atiende solicitudes, pero un `print()` sin vaciar todavía puede drenarse sobre el canal al salir, y las líneas desviadas llegan sin etiquetar; usa `logging`, cuyo handler vacía cada registro. +* `MCPServer(..., log_level="DEBUG")` fija el nivel, y una configuración de logging que hayas hecho antes se respeta. + +Avisar a los clientes conectados de que algo cambió en el servidor (la lista de herramientas, un recurso) es cosa de **[Suscripciones](subscriptions.md)**. diff --git a/i18n/es/pages/handlers/multi-round-trip.md b/i18n/es/pages/handlers/multi-round-trip.md new file mode 100644 index 0000000000..75555cb49d --- /dev/null +++ b/i18n/es/pages/handlers/multi-round-trip.md @@ -0,0 +1,191 @@ +--- +translation: + sections: [74011e683045eea9, 9b64cc175c18b6a9, 4b41be4824030397, e3b1502da786ec33, 71e41161f143c6a9, 9ec2c1eeb8c36378, 8dd027377d46448b, f81491125dcbfe8b] + tool: 1 +--- +# Solicitudes de varias idas y vueltas {#multi-round-trip-requests} + +A veces una herramienta no puede terminar en una sola ida y vuelta. Necesita algo que solo tiene el usuario: una elección, una confirmación, una credencial. + +Antes de 2026-07-28 el servidor lo conseguía con una llamada **de vuelta**: abría su propia solicitud al cliente (una elicitación (elicitation), una llamada de muestreo (sampling)) mientras atendía la original. La especificación 2026-07-28 retira ese canal de retorno (back-channel). + +En su lugar, el servidor **devuelve** un resultado. + +## Devuelve, no llames de vuelta {#return-dont-call-back} + +El servidor responde a `tools/call` con un **`InputRequiredResult`** en lugar de un `CallToolResult`. Dos de sus campos hacen el trabajo: + +* **`input_requests`**: lo que el servidor aún necesita, como un dict cuyas claves son nombres que eligió el servidor. Cada valor es un `ElicitRequest`, un `CreateMessageRequest` o un `ListRootsRequest`. +* **`request_state`**: un token opaco. El cliente lo devuelve tal cual en el reintento. Tu servidor es lo único que lo lee. + +El cliente satisface cada solicitud y luego llama a la **misma herramienta otra vez**, con sus respuestas en `input_responses` y el token en `request_state`. El servidor ya tiene lo que le faltaba y devuelve un `CallToolResult` normal. + +Ese es todo el protocolo. Cada tramo es una solicitud ordinaria del cliente al servidor. Nunca fluye nada en sentido contrario. + +## El lado del servidor {#the-server-side} + +En `@mcp.tool()` rara vez construyes esto a mano: declara una dependencia que pregunta al usuario (`Elicit`), muestrea el LLM del cliente (`Sample`) o lista sus roots (directorios raíz) (`ListRoots`), y el SDK devuelve el `InputRequiredResult` por ti; esa forma es la página **[Dependencias](dependencies.md)**. Las dos formas no se mezclan: una llamada tiene un solo canal `input_responses`/`request_state`, así que una herramienta que usa parámetros `Resolve(...)` no puede además devolver `InputRequiredResult` desde su cuerpo. Un retorno `InputRequiredResult` declarado se rechaza al registrar (`InvalidSignature`), y uno no declarado hace fallar la llamada en tiempo de ejecución. La forma manual es el `Server` de **bajo nivel**, cuyo handler `on_call_tool` puede devolver cualquiera de los dos tipos de resultado: + +```python title="server.py" hl_lines="43-46" +--8<-- "docs_src/mrtr/tutorial001.py" +``` + +* `on_call_tool` tiene el tipo `-> CallToolResult | InputRequiredResult`. Devolver el segundo es toda la API del lado del servidor. +* En la primera llamada `params.input_responses` es `None`, así que la guarda se activa y el handler pregunta en lugar de responder. +* En el reintento, el `ElicitResult` que envió el cliente está bajo la **misma clave** (`"region"`) que el servidor usó en `input_requests`. + +Todo lo demás en ese archivo (el `input_schema` explícito, el `CallToolResult` construido a mano) es el `Server` de bajo nivel ordinario, que se explica en **[El Server de bajo nivel](../advanced/low-level-server.md)**. Esta página solo añade el segundo tipo de retorno. + +## Más allá de las herramientas {#beyond-tools} + +`tools/call` no es especial: en 2026-07-28 un servidor puede responder a `prompts/get` y `resources/read` de la misma manera. En `MCPServer`, una función `@mcp.prompt()` (o una función de **plantilla** `@mcp.resource()`) devuelve ella misma el `InputRequiredResult` y lee las respuestas del reintento desde el contexto: + +```python title="server.py" hl_lines="20 22 24" +--8<-- "docs_src/mrtr/tutorial004.py" +``` + +* La primera ronda devuelve el `InputRequiredResult`. En el reintento, `ctx.input_responses` contiene las respuestas bajo las mismas claves y la función devuelve su resultado ordinario: mensajes de prompt aquí, contenido de recurso para un recurso de plantilla. +* Un `request_state` que fijes se sella antes de transmitirse y se verifica cuando vuelve, como todo lo demás en el servidor; **[Proteger `requestState`](#protecting-requeststate)** más abajo explica qué te da el sello y cuándo necesitas configurar claves. +* Una función `@mcp.tool()` puede devolver el resultado directamente de la misma manera, cuando la forma con dependencias no encaja. +* Las funciones `@mcp.resource()` estáticas no participan: no reciben `Context`, así que nunca podrían leer el reintento. Solo los recursos de plantilla pueden preguntar. +* Las reglas sobre la generación del protocolo de más abajo se aplican sin cambios: devolver un `InputRequiredResult` en una sesión anterior a 2026 es el mismo `-32603` que describe la advertencia. + +## El lado del cliente {#the-client-side} + +`Client` ejecuta el bucle por ti. + +Registra los callbacks que el servidor podría pedir (`elicitation_callback`, `sampling_callback`, `list_roots_callback`) y llama a la herramienta. Cuando llega un `InputRequiredResult`, `Client` despacha cada entrada de `input_requests` al callback correspondiente, reintenta con las respuestas y el `request_state` devuelto, y sigue hasta que vuelve un `CallToolResult`: + +```python title="client.py" hl_lines="11 12" +--8<-- "docs_src/mrtr/tutorial003.py" +``` + +* Ese `elicitation_callback` es el mismo al que habría llegado el `elicitation/create` por canal de retorno de un servidor anterior a 2026. Lo mismo vale para `sampling_callback` con `sampling/createMessage` y `list_roots_callback` con `roots/list`: en 2026-07-28 las RPC independientes de servidor a cliente desaparecen, pero los mismos payloads `ElicitRequest` / `CreateMessageRequest` / `ListRootsRequest` viajan dentro de `input_requests` y se despachan a los mismos tres callbacks. Un solo conjunto de callbacks sirve a ambas generaciones. +* `call_tool` devuelve un `CallToolResult` sin más. Las rondas intermedias son invisibles para quien llama. +* `get_prompt` y `read_resource` ejecutan el mismo bucle. + +!!! check + Si omites el callback, el bucle falla en la primera ronda: el callback sustituto del SDK + responde a toda elicitación con un error, y `call_tool` lanza `MCPError` con el mensaje + *"Elicitation not supported"*. + +El bucle está acotado. `Client(..., input_required_max_rounds=10)` es el tope por defecto; un servidor que sigue devolviendo `InputRequiredResult` más allá de él hace que `call_tool` lance una excepción. Si una ronda trae solo `request_state` y ningún `input_requests`, `Client` duerme brevemente (50 ms que se duplican hasta un techo de 250 ms) antes de reintentar, así que a un servidor que solo está diciendo *"todavía no he terminado"* no se le sondea sin parar. + +### Controlar el bucle tú mismo {#driving-the-loop-yourself} + +El bucle automático basta para un cliente de un solo proceso. Hazte cargo del bucle, en cambio, cuando: + +* Tu cliente es **distribuido**: el proceso que muestra la pregunta al usuario no es el proceso que llamó a `call_tool`, así que un worker distinto emite el reintento. `request_state` es el token persistible que llevas a través de esa frontera, mediante tu propio almacenamiento, e `input_responses` es lo que el otro lado envía de vuelta con él. +* Quieres **inspeccionar** cada ronda: registrar o auditar cada entrada de `input_requests`, rechazar ciertos tipos de solicitud o aplicar tu propio backoff entre tramos. +* Quieres un límite de **tiempo real** en lugar de un límite por número de rondas: envuelve tu propio bucle en `anyio.fail_after(...)` en lugar de depender de `input_required_max_rounds`. + +Baja a la sesión subyacente, donde `allow_input_required=True` te entrega la unión directamente: + +```python title="client.py" hl_lines="12 13 19" +--8<-- "docs_src/mrtr/tutorial002.py" +``` + +* `client.session.call_tool(..., allow_input_required=True)` amplía el tipo de retorno a `CallToolResult | InputRequiredResult`. El `isinstance` es lo que lo vuelve a estrechar. +* `request_state` está ahora en tus manos. Guárdalo entre tramos y la conversación puede reanudarse desde un proceso nuevo. +* Por cada entrada de `input_requests` pones un `InputResponse` bajo la **misma clave** en `input_responses`. `fulfil` es donde va tu UI; esta fija la respuesta en el código. +* Mismo nombre de herramienta, mismos `arguments`, en cada tramo. El reintento es la llamada original realizada de nuevo, no un método nuevo. + +## Proteger `requestState` {#protecting-requeststate} + +Todo lo anterior trata `request_state` como un eco, y en lo que se transmite no es más que eso. Pero el cliente lo conserva entre tramos (guardarlo entre procesos es justo lo que aprobó la sección anterior), así que lo que vuelve es **entrada proporcionada por el cliente**: puede estar modificada, caducada o tomada de otra llamada completamente distinta. La especificación exige que los servidores protejan la integridad de este estado y rechacen la ronda cuando la verificación falla, siempre que el estado pueda influir en la autorización, el acceso a recursos o la lógica de negocio. + +`MCPServer` lo protege por defecto. Todo servidor sella el `requestState` saliente y verifica cada eco (tanto el estado de los resolutores como el construido a mano) con una clave generada al arrancar el proceso. No configuras nada, escribes texto plano y lees texto plano; lo único que se transmite es un token cifrado opaco. + +La clave por defecto vive y muere con el proceso, que es lo único que debes saber antes de desplegar más allá de un solo proceso: + +```python +from mcp.server.mcpserver import MCPServer, RequestStateSecurity + +# Multi-instance or restart-surviving: one or more shared secret keys (>= 32 bytes each). +mcp = MCPServer("fleet", request_state_security=RequestStateSecurity(keys=[key])) +``` + +* **El valor por defecto (sin configuración)** sirve para un solo proceso: stdio, o exactamente un worker HTTP. Un reintento que cae en otro worker, en otra instancia detrás de un balanceador de carga o en el mismo servidor tras un reinicio está sellado con una clave que ese proceso no tiene: el cliente recibe el rechazo fijo de más abajo y debe empezar el flujo de nuevo. +* **`keys=[...]`** es obligatorio siempre que un reintento pueda llegar a una **instancia distinta** (`uvicorn` con varios workers, HTTP con balanceo de carga) o deba sobrevivir a reinicios: cada instancia verifica lo que emitió cualquier instancia hermana. La misma maquinaria, tu secreto en lugar de uno generado. +* Para tu propia criptografía, como un KMS o un servicio de tokens existente, pasa `RequestStateSecurity(codec=...)` en lugar de `keys`; **[Trae tu propia criptografía](#bring-your-own-crypto)** más abajo explica el contrato. + +### Qué lleva el sello {#what-the-seal-carries} + +Por defecto o configurado, el `requestState` que se transmite es un token cifrado y autenticado. Tu código nunca lo ve: los handlers y los resolutores escriben texto plano y leen texto plano (`ctx.request_state`); el SDK sella a la salida y verifica a la entrada. Más allá de la integridad, cada token está vinculado a: + +* **Una ventana de tiempo.** Cada ronda vuelve a sellar con una caducidad nueva, así que `RequestStateSecurity(ttl=...)` (600 segundos por defecto) acota el tiempo de reflexión por ronda, no el flujo completo. +* **El principal autenticado.** Cuando la solicitud trae un token de acceso OAuth que el SDK validó, el estado queda vinculado al cliente, el emisor y el sujeto del token: el estado emitido para un usuario falla con otro, incluso cuando ambos usuarios comparten un mismo cliente OAuth. Un verificador que no aporta sujeto degrada el vínculo a la sola identidad del cliente, que con ID de cliente basados en URL comparten todos los usuarios de ese software cliente. Cuando la autenticación termina fuera del SDK (un proxy delante), o el transporte no está autenticado, no hay principal al que vincular y esta comprobación queda inerte, salvo que `RequestStateSecurity(bind_principal=...)` aporte uno a partir de tu propia señal de identidad. Sean cuales sean los componentes que aporta tu verificador de tokens, debe aportarlos de forma coherente: un verificador que incluye el sujeto en algunas solicitudes y lo omite en otras cambia el principal a mitad del flujo, y las rondas en curso se rechazan. +* **La solicitud de origen.** El método, el nombre de la herramienta o del prompt (o la URI del recurso) y un resumen criptográfico de los argumentos. Un token reproducido contra otra herramienta, otros argumentos u otro método falla. +* **La pregunta exacta que se hizo.** Cada respuesta de un resolutor queda fijada a la pregunta tal como se mostró al cliente, tanto en la ronda en que llega por primera vez como cuando una respuesta registrada se reutiliza más tarde. Vuelve a desplegar con un mensaje reformulado o un esquema cambiado y el servidor vuelve a preguntar en lugar de consumir una respuesta obsoleta. La misma fijación funciona en el otro sentido: deriva los mensajes de los argumentos de la herramienta, no de datos propios de cada llamada. Un mensaje construido a partir de una marca de tiempo o de una cotización en vivo se muestra distinto en cada ronda, así que toda respuesta registrada parece obsoleta y el servidor vuelve a preguntar hasta que el límite de rondas del cliente termina la llamada. + +Todo eso es trabajo del SDK, no tuyo, ni del códec si traes el tuyo. + +### Rotación de claves {#rotating-keys} + +`keys[0]` sella el estado nuevo; todas las claves de la lista verifican. La rotación sin tiempo de inactividad tiene tres fases, cada una desplegada por completo antes de la siguiente: + +```python +RequestStateSecurity(keys=[OLD, NEW]) # 1: every instance learns to verify NEW; OLD still mints +RequestStateSecurity(keys=[NEW, OLD]) # 2: NEW mints; in-flight OLD state keeps verifying +RequestStateSecurity(keys=[NEW]) # 3: one ttl after phase 2 is fully out, retire OLD +``` + +Nunca promuevas primero la clave emisora: emitir con una clave que alguna instancia aún no puede verificar pierde rondas en curso a mitad del despliegue. + +Las claves tienen como ámbito un solo servicio. El sobre sellado lleva también el nombre del servidor como claim de audiencia, así que un token emitido por otro servicio que casualmente comparte un secreto se rechaza de todos modos. El claim es tan distintivo como lo sea el nombre, así que un servidor con una política explícita debe tener un nombre real o fijar `RequestStateSecurity(audience=...)`: uno sin nombre lanza una excepción al construirse. `audience=` también sirve para topologías deliberadas de varios servicios donde un servicio debe aceptar estado que emitió otro. (El valor por defecto sin configuración queda exento: su clave nunca sale del proceso, así que el claim de audiencia no tiene nada que añadir.) + +### Trae tu propia criptografía {#bring-your-own-crypto} + +`RequestStateSecurity(codec=...)` acepta cualquier cosa con `seal(bytes) -> str` y `unseal(str) -> bytes` que lance `InvalidRequestState` para cualquier token que no haya emitido. La forma clásica es el cifrado de sobre contra un KMS, donde desenvuelves una clave de datos una vez al arrancar y mantienes local la criptografía por token: + +```python title="server.py" hl_lines="12 26-27 34-35 38" +--8<-- "docs_src/mrtr/tutorial005.py" +``` + +El TTL, el vínculo con el principal y el vínculo con la solicitud **no** son trabajo del códec: el SDK los graba en el payload antes de `seal` y los vuelve a verificar después de `unseal`, para todo códec. Las únicas obligaciones de un códec son la integridad (manipulado significa lanzar una excepción) y, a ser posible, la confidencialidad. + +### Cuando falla la verificación {#when-verification-fails} + +Todo fallo entrante, ya sea por manipulación, caducidad, reproducción contra otra solicitud u otro principal, o por estar sellado con una clave que este servidor no conoce, recibe la misma respuesta: + +```json +{"code": -32602, "message": "Invalid or expired requestState"} +``` + +Un único mensaje fijo para todas las causas, de modo que lo que se transmite nunca revela qué comprobación falló; el motivo real va al log del servidor. Se comprueba todo `requestState` entrante en `tools/call`, `prompts/get` y `resources/read`, incluido uno que llegue para un handler que nunca emite estado. El rechazo más común en la práctica no es un atacante: es la clave por defecto, local al proceso, que se topa con un reintento anterior a un reinicio o procedente de otra instancia; el cliente reinicia el flujo, y `keys=[...]` es la solución cuando eso importa. + +### Estado construido a mano {#hand-built-state} + +Un `request_state` que fijas tú mismo (al devolver `InputRequiredResult` desde una función de herramienta, de prompt o de plantilla de recurso) lo sella y verifica la misma maquinaria que el estado de los resolutores, sin ningún cambio de código: escribe texto plano, lee texto plano, y se aplican todos los vínculos anteriores. + +Lo único que el SDK no puede fijar por ti, incluso configurado, es la identidad de la pregunta: no sabe a cuál de *tus* preguntas pertenece una respuesta de tu estado. Si guardas respuestas con la pregunta como clave, incluye tu propio identificador de pregunta en el estado y compruébalo en el reintento. + +El `Server` de bajo nivel es el nivel sin pilas incluidas: a diferencia de `MCPServer`, no se sella nada hasta que añades tú mismo esa frontera, y tu `request_state` se transmite exactamente como lo escribiste hasta que lo haces. La activación de una sola línea se muestra en **[El Server de bajo nivel](../advanced/low-level-server.md#the-other-handlers)**. + +## Un resultado de 2026-07-28 {#a-2026-07-28-result} + +`InputRequiredResult` solo existe en la versión del protocolo **2026-07-28**. El `Client(server)` en memoria la negocia por ti; a través del canal, `mode="auto"` la descubre. Tras conectar, `client.protocol_version` te dice qué obtuviste. + +!!! warning + Una sesión anterior a 2026 no tiene dónde poner un `InputRequiredResult`. Devuelve uno desde tu + handler en una conexión `mode="legacy"` y el ejecutor no puede serializarlo a la versión negociada; + el cliente recibe un error `-32603` *"Handler returned an invalid result"*. Un servidor que atiende + ambas generaciones debe comprobar `ctx.protocol_version` antes de recurrir a él. + +!!! info + **La elicitación en modo URL** usa exactamente este mecanismo en una conexión 2026. La entrada en + `input_requests` es un `ElicitRequest` cuyos params son `ElicitRequestURLParams`; el usuario + termina el flujo fuera de banda y tu cliente reintenta la llamada. El mismo bucle, ninguna API + nueva. La mitad del servidor de alto nivel está en **[Elicitación](elicitation.md)**. + +## Resumen {#recap} + +* En 2026-07-28 un servidor que necesita datos a mitad de una llamada **devuelve** un `InputRequiredResult`. Nunca abre una solicitud al cliente. +* `input_requests` es lo que necesita. `request_state` es un token opaco de reanudación que solo lee el servidor. +* `Client` ejecuta el bucle de reintentos por ti: registra `elicitation_callback` / `sampling_callback` / `list_roots_callback` y `call_tool` devuelve un `CallToolResult` sin más. `input_required_max_rounds` (10 por defecto) lo acota. +* Para inspeccionar o persistir rondas, usa `client.session.call_tool(..., allow_input_required=True)` y hazte cargo tú mismo del bucle `while isinstance(result, InputRequiredResult)`. +* En `@mcp.tool()`, una dependencia que pregunta al usuario produce este resultado por ti (**[Dependencias](dependencies.md)**); el `Server` de **bajo nivel** es la forma manual. +* Los prompts y los recursos también participan: una función `@mcp.prompt()` o `@mcp.resource()` de plantilla devuelve ella misma el `InputRequiredResult` y lee `ctx.input_responses` en el reintento. +* `requestState` vuelve como entrada proporcionada por el cliente, así que `MCPServer` lo sella por defecto (tanto el estado de los resolutores como el construido a mano) con una clave local al proceso; los despliegues de varias instancias pasan `RequestStateSecurity(keys=[...])` (o un códec propio) para que cada instancia pueda verificar lo que emitió una instancia hermana. El sello vincula cada token a una ventana de tiempo, a la solicitud de origen y al principal autenticado cuando la solicitud trae autenticación que el SDK validó o `bind_principal=` aporta tu propia señal de identidad (**[Proteger `requestState`](#protecting-requeststate)**). + +Este es el mecanismo que sustituye al muestreo iniciado por el servidor y al resto del canal de retorno de tipo push; consulta **[Funcionalidades obsoletas](../deprecated.md)**. diff --git a/i18n/es/pages/handlers/progress.md b/i18n/es/pages/handlers/progress.md new file mode 100644 index 0000000000..8f454dedc9 --- /dev/null +++ b/i18n/es/pages/handlers/progress.md @@ -0,0 +1,122 @@ +--- +translation: + sections: [5315262fe26b33e1, 9d8e98840f1b78f0, 0284b215e85366c4, 8534d8dbb4053a70, 2966fac6fe697007] + tool: 1 +--- +# Progreso {#progress} + +Una herramienta que tarda treinta segundos y no dice nada durante treinta segundos parece rota. + +Las **notificaciones de progreso** lo solucionan. La herramienta informa de cuánto lleva avanzado; el cliente decide qué dibujar con eso: una barra, un indicador giratorio, una línea de log. + +## Repórtalo desde la herramienta {#report-it-from-the-tool} + +Acepta un parámetro **`Context`** y llama a `report_progress`: + +```python title="server.py" hl_lines="8 11" +--8<-- "docs_src/progress/tutorial001.py" +``` + +Tres argumentos, y tú decides qué significan: + +* `progress`: cuánto llevas avanzado. La especificación exige que **aumente** con cada reporte; nunca repitas un valor ni retrocedas. +* `total`: cuánto hay en total, si lo sabes. Opcional. +* `message`: una línea legible para humanos sobre *este* paso. Opcional. + +`ctx` se inyecta por su anotación de tipo y el modelo nunca lo ve: el esquema de entrada de `import_catalog` tiene una sola propiedad, `urls`. La página **[El Context](context.md)** trata por completo de ese objeto; el progreso es una de las cosas que te da. + +## Escúchalo desde el cliente {#listen-for-it-from-the-client} + +El cliente lo activa **por llamada**, pasando `progress_callback=` a `call_tool`: + +```python title="client.py" hl_lines="7 16" +import anyio +from mcp import Client + +from server import mcp + + +async def show(progress: float, total: float | None, message: str | None) -> None: + print(f"{message} ({progress}/{total})") + + +async def main() -> None: + async with Client(mcp) as client: + result = await client.call_tool( + "import_catalog", + {"urls": ["https://example.com/a.json", "https://example.com/b.json"]}, + progress_callback=show, + ) + print(result.structured_content) + + +anyio.run(main) +``` + +El callback es una función `async` que recibe exactamente lo que reportó el servidor: `progress`, `total`, `message`. + +!!! info + `Client(mcp)` se conecta directamente al objeto servidor, en memoria; es el mismo cliente sobre el que se construye la página **[Pruebas](../get-started/testing.md)**. + `progress_callback` es el mismo parámetro sea cual sea el transporte que use el `Client`; + los *tiempos* que vas a ver son los de la conexión en memoria. Ejecuta tu callback + de forma directa, así que cada reporte llega antes de que `call_tool` devuelva. Con un transporte real, + las notificaciones compiten con el resultado, y un callback lento puede seguir ejecutándose después de que `call_tool` + haya devuelto. + +### Pruébalo {#try-it} + +Pon `client.py` junto a `server.py` y ejecútalo: + +```console +python client.py +``` + +```text +Imported https://example.com/a.json (1/2) +Imported https://example.com/b.json (2/2) +{'result': 'Imported 2 records.'} +``` + +Cada `await ctx.report_progress(...)` en el servidor se convirtió en una llamada a `show` en el cliente, en orden, y ambas líneas se imprimieron **antes** de que `call_tool` devolviera. El progreso no va empaquetado en el resultado; se transmite mientras la herramienta sigue trabajando. + +!!! warning + `progress_callback` pertenece a la **llamada**, no al `Client`. No hay un argumento del constructor + para él, porque llamadas distintas quieren callbacks distintos: una maneja una barra de descarga, la siguiente + una línea de log. + +!!! check + Ahora borra `progress_callback=show` y ejecútalo de nuevo: + + ```text + {'result': 'Imported 2 records.'} + ``` + + Ningún error, ningún aviso, el mismo resultado. `report_progress` **no hace nada cuando quien llama no pidió + progreso**, así que reportas sin condiciones y nunca tienes que preguntarte si alguien está + escuchando. + +## Cuando no conoces el total {#when-you-dont-know-the-total} + +`total` es para cuando conoces el denominador. A menudo no es así: estás vaciando un feed, recorriendo un cursor, descargando algo sin cabecera de longitud. + +Omítelo: + +```python title="server.py" hl_lines="20" +--8<-- "docs_src/progress/tutorial002.py" +``` + +El callback recibe `total=None`. Un cliente todavía puede mostrar *actividad* ("3 imported so far...") pero no puede mostrar un porcentaje. No te inventes un total para conseguir una barra más bonita. + +!!! tip + `progress` no tiene por qué contar nada en particular. Bytes, filas, páginas: elige la unidad que el + usuario reconocería, y promete solo un `total` que puedas cumplir. + +## Resumen {#recap} + +* `await ctx.report_progress(progress, total=None, message=None)` desde cualquier herramienta que reciba un `Context`. +* El cliente pasa `progress_callback=` a `call_tool`: por llamada, nunca en el `Client`. +* El callback es `async (progress, total, message) -> None` y se dispara mientras la herramienta sigue ejecutándose. +* Si la llamada no lleva callback, `report_progress` no hace nada. Reporta sin condiciones. +* Omite `total` cuando no lo conozcas; el callback recibe `None`. + +El progreso es lo que una herramienta en ejecución le muestra al *usuario*. Las líneas que registra para *ti*, la persona que opera el servidor, van por otro canal: **[Logging](logging.md)**. diff --git a/i18n/es/pages/handlers/sampling-and-roots.md b/i18n/es/pages/handlers/sampling-and-roots.md new file mode 100644 index 0000000000..d9a79ff74a --- /dev/null +++ b/i18n/es/pages/handlers/sampling-and-roots.md @@ -0,0 +1,51 @@ +--- +translation: + sections: [5c82b20cbd65ded0, 9dc22632be79a533, 1fb8f452e990c456, 42666ab914ff0cb1, c4e0cb3667fd5ff9] + tool: 1 +--- +# Muestreo y roots {#sampling-and-roots} + +Un handler puede pedirle al cliente conectado dos cosas más: una respuesta del propio modelo del cliente, el **muestreo** (sampling), y las carpetas del espacio de trabajo del cliente, los **roots** (directorios raíz). + +Ambos siguen funcionando, en todas las versiones del protocolo que habla el SDK. Pero lee la advertencia antes de diseñar en torno a ellos: + +!!! warning "Obsoletos según la especificación 2026-07-28" + El muestreo y los roots están obsoletos a partir de `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2577)). Siguen siendo plenamente funcionales y permanecen en la especificación al menos doce meses antes de poder ser eliminados, pero las implementaciones nuevas no deberían basarse en ellos. Las migraciones sugeridas: integra directamente la API de tu proveedor de LLM en lugar del muestreo, y pasa los directorios mediante parámetros de herramientas, URI de recursos o la configuración del servidor en lugar de los roots. La lista completa del SDK está en **[Funcionalidades obsoletas](../deprecated.md)**. + +## Muestreo: toma prestado el modelo del cliente {#sampling-borrow-the-clients-model} + +Un resolutor devuelve `Sample(...)` y la herramienta recibe la respuesta del modelo, a través del mismo mecanismo de dependencias que ejecuta `Elicit` en **[Dependencias](dependencies.md)**: + +```python title="server.py" hl_lines="10-15 19" +--8<-- "docs_src/sampling_and_roots/tutorial001.py" +``` + +* `Sample(messages, max_tokens=...)` refleja los parámetros de `sampling/createMessage`. El valor inyectado es el `CreateMessageResult` del cliente; pasa `tools` o `tool_choice` y en su lugar será un `CreateMessageResultWithTools`. +* El cliente debe haber declarado la capacidad `sampling` (`sampling.tools` si pasas `tools` o `tool_choice`). Si no lo hizo, la llamada falla con un error de protocolo `-32021` en lugar de enviar una solicitud que el cliente no puede manejar. Una sesión anterior a 2026 sin canal de retorno (back-channel) falla con su error habitual de falta de canal de retorno, ya que no hay nada por donde enviarla. +* En `2026-07-28` la solicitud se entrega dentro del flujo de varias idas y vueltas (**[Solicitudes de varias idas y vueltas](multi-round-trip.md)**, multi-round-trip); en `2025-11-25` es una solicitud independiente al cliente. El código es el mismo en ambos casos, pero ten en cuenta la regla de las varias idas y vueltas: la solicitud debe generarse idéntica en cada ronda de reintento, así que constrúyela solo a partir de los argumentos de la herramienta y otros datos estables. +* Deja `include_context` tal cual: los valores distintos de `"none"` están a su vez obsoletos (SEP-2596) y necesitan una capacidad que casi ningún cliente declara. + +## Roots: ¿dónde va esto? {#roots-where-should-this-go} + +Los roots son las carpetas sobre las que, según el cliente, el servidor puede operar. Son una orientación informativa, no un mecanismo de control de acceso. Un resolutor devuelve `ListRoots()`: + +```python title="server.py" hl_lines="10-11 15" +--8<-- "docs_src/sampling_and_roots/tutorial002.py" +``` + +* El `ListRootsResult` inyectado lleva una lista de objetos `Root`: un URI `file://` y un nombre para mostrar opcional. +* La condición es la misma que para el muestreo: sin una capacidad `roots` declarada, la llamada falla con `-32021` en lugar de enviar la solicitud. + +Al otro lado del canal, el cliente responde ambas solicitudes con los callbacks que ya tiene: `sampling_callback` y `list_roots_callback`, que se tratan en **[Callbacks del cliente](../client/callbacks.md)**. + +## En conexiones de la generación 2025 {#on-2025-era-connections} + +`ctx.session.create_message(...)` y `ctx.session.list_roots()` siguen existiendo para el código que maneja la sesión directamente. Solo funcionan donde existe un canal de retorno (conexiones de la generación 2025 que no sean sin estado), y llamarlos lanza un aviso de obsolescencia. Los marcadores de resolutor de arriba son la forma admitida: eligen la entrega según la versión negociada y no emiten ningún aviso. + +## Resumen {#recap} + +* Devuelve `Sample(...)` o `ListRoots()` desde un resolutor; la herramienta recibe el `CreateMessageResult` o el `ListRootsResult` como cualquier otra dependencia. +* El cliente debe declarar la capacidad correspondiente o la llamada falla con `-32021` en lugar de enviarse una solicitud. +* Ambas funcionalidades están obsoletas en `2026-07-28`: plenamente funcionales por ahora, equivocadas para diseños nuevos. Prefiere las API del proveedor frente al muestreo y los parámetros explícitos frente a los roots. + +Informar cuánto lleva avanzado una herramienta lenta: **[Progreso](progress.md)**. diff --git a/i18n/es/pages/handlers/subscriptions.md b/i18n/es/pages/handlers/subscriptions.md new file mode 100644 index 0000000000..153516bc1b --- /dev/null +++ b/i18n/es/pages/handlers/subscriptions.md @@ -0,0 +1,152 @@ +--- +translation: + sections: [60a9de8a0bdaa531, 317bbe7e4355cdcc, a61d660c8029e04a, 8f7e82fcb88df8a9, b165db51249ff8ed, 266f56fb798068a4, 7c0e57030b622139, df18d7c2417a9883] + tool: 1 +--- +# Suscripciones {#subscriptions} + +El catálogo de un servidor no es fijo. Las herramientas aparecen en tiempo de ejecución, y el contenido detrás del URI de un recurso cambia. + +Las **suscripciones** son la forma en que un cliente se entera. El cliente envía una solicitud `subscriptions/listen`, y la respuesta a esa solicitud *es* el flujo: queda abierto y transporta las notificaciones de cambio que el cliente pidió. + +## Publícalo desde la herramienta {#publish-it-from-the-tool} + +Tu parte es una sola línea: publicar el cambio. + +```python title="server.py" hl_lines="20 32" +--8<-- "docs_src/subscriptions/tutorial001.py" +``` + +* `await ctx.notify_resource_updated("board://sprint")` llega a cada flujo abierto que se suscribió a ese URI. A nadie más. +* `await ctx.notify_tools_changed()` llega a cada flujo que pidió los cambios en la lista de herramientas. Un cliente que lo recibe vuelve a llamar a `tools/list`, y ahora ve `sprint_report`. +* Los métodos hermanos son `notify_prompts_changed()` y `notify_resources_changed()`. +* Sin suscriptores, sin trabajo. Publicar en un servidor inactivo no hace nada, así que nunca compruebas si alguien está escuchando. Declaras qué cambió. + +`MCPServer` atiende `subscriptions/listen` por ti. Las obligaciones del canal (el acuse de recibo como primera trama, el filtrado por flujo, el id de suscripción en cada trama) son trabajo del SDK. + +!!! check + En el canal, un flujo cuyo filtro nombró `board://sprint` se ve así después de que se ejecuta `complete_task`: + + ```json + {"method": "notifications/subscriptions/acknowledged", + "params": {"notifications": {"resourceSubscriptions": ["board://sprint"]}, "_meta": {"io.modelcontextprotocol/subscriptionId": "listen-1"}}} + + {"method": "notifications/resources/updated", + "params": {"uri": "board://sprint", "_meta": {"io.modelcontextprotocol/subscriptionId": "listen-1"}}} + ``` + + Fíjate en lo que la actualización *no* lleva: el tablero. Cada trama lleva el id JSON-RPC de la solicitud listen bajo `_meta`, y ese id es el id de suscripción. Lo genera el cliente: el `Client` de Python usa cadenas como `"listen-1"`; otros clientes pueden usar enteros. + +## Solo lo que se pidió {#only-what-was-asked-for} + +El filtro es un contrato. Un flujo que solicitó los cambios en la lista de herramientas y un URI de recurso recibe esos dos tipos y nada más. Publica un cambio de prompt y ese flujo se queda en silencio. + +`MCPServer` compara los URI de recurso como cadenas exactas, así que un flujo que nombró `board://sprint` no se entera de nada sobre `board://sprint/tasks/1`. La especificación permite que un servidor informe de un cambio en un subrecurso de un URI suscrito; `MCPServer` nunca lo hace, pero los clientes están construidos para esperarlo. + +Dos cosas que el flujo *no* es: + +* **No es un registro de repetición.** Un flujo caído se pierde, y los eventos publicados mientras nadie estaba conectado no se encolan. Los clientes vuelven a escuchar y vuelven a consultar. +* **No es la vía de 2025.** A los clientes que llamaron a `resources/subscribe` los atiende `ctx.session.send_resource_updated(uri)`. Los métodos `notify_*` llegan solo a los flujos de `subscriptions/listen`. + +## Decidir quién puede observar {#deciding-who-may-watch} + +Por defecto se acepta cada tipo y URI solicitado: cualquier llamador puede observar cualquier URI que publiques. Nada consulta tu handler de lectura, porque nadie está leyendo: un llamador al que tu handler `files://{name}` rechazaría puede igualmente abrir un flujo sobre `files://payroll.csv` y enterarse de que cambió, y cuándo. Nunca conoce el contenido, y no puede sondear qué existe, porque un URI desconocido también se acepta y simplemente nunca se dispara. Acotado pero real, así que contrólalo antes de publicar URI por usuario desde un servidor multiinquilino. + +El control es un middleware. Ve la solicitud `subscriptions/listen` antes de que el SDK la acuse y la rechaza cuando el llamador pide algo que no puede leer: + +```python title="server.py" hl_lines="19-26 29" +--8<-- "docs_src/subscriptions/tutorial006.py" +``` + +* `ctx.params` es la solicitud en bruto, así que el propio middleware la valida como `SubscriptionsListenRequestParams` y lee el filtro que pidió el cliente. +* El rechazo es un `MCPError` lanzado antes de `call_next(ctx)`: el cliente recibe ese error y ningún flujo, y la conexión sigue. Mantén el mensaje uniforme, sin nombrar ningún URI, para que un rechazo nunca confirme qué URI están protegidos. +* Un único `can_access(user, uri)` responde ambas preguntas. El handler del recurso lo consulta en `resources/read`; el middleware lo consulta en `subscriptions/listen`. Cambia la tabla por una base de datos o por tu sistema RBAC y ambos siguen coordinados. +* La decisión vale durante toda la vida del flujo. No hay nueva comprobación por evento, así que si el acceso de un llamador puede caducar a mitad del flujo (un token que expira), termina la conexión de ese llamador cuando ocurra. + +El contrato completo del middleware, incluido qué más envuelve y por qué está marcado como provisional, está en **[Middleware](../advanced/middleware.md)**. + +## El lado del cliente {#the-client-end} + +Aquí tienes un cliente al otro lado de ese flujo, siguiendo el tablero: + +```python title="client.py" hl_lines="15" +--8<-- "docs_src/subscriptions/tutorial003.py" +``` + +Entrar en `client.listen(...)` envía la solicitud y espera tu acuse de recibo, así que el flujo está activo cuando empieza el bloque, y cada evento tipado es una señal para volver a consultar, nunca un payload. Ese es todo el contrato en una pantalla. Todo lo demás sobre el lado del cliente vive en su propia página: observar junto a un flujo principal, finales de flujo y volver a escuchar. Consulta **[Suscripciones](../client/subscriptions.md)** en *Clientes*. + +## Escalar más allá de un proceso {#scaling-past-one-process} + +Las publicaciones viajan desde tu handler hasta los flujos abiertos a través de un `SubscriptionBus`. El valor por defecto es en memoria: un proceso, con todos los flujos dentro. Esa es la respuesta correcta hasta que ejecutas réplicas detrás de un balanceador de carga, porque entonces el flujo de un cliente queda fijado a una réplica, y una publicación en otra réplica tiene que llegar hasta él. + +Esa pieza te toca implementarla a ti: dos métodos sobre tu backend de pub/sub. + +```python +from collections.abc import Callable + +from redis.asyncio import Redis + +from mcp.server.mcpserver import MCPServer +from mcp.server.subscriptions import ServerEvent # SubscriptionBus is a Protocol: no base class + + +class RedisSubscriptionBus: + def __init__(self, redis: Redis) -> None: + self._redis = redis + self._listeners: dict[object, Callable[[ServerEvent], None]] = {} + + async def publish(self, event: ServerEvent) -> None: + await self._redis.publish("mcp-events", encode(event)) # to every replica + + def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]: + token = object() + self._listeners[token] = listener + + def unsubscribe() -> None: + self._listeners.pop(token, None) + + return unsubscribe + + +mcp = MCPServer("Sprint Board", subscriptions=RedisSubscriptionBus(redis)) +``` + +`encode` es tuyo, y también lo es la tarea lectora de cada réplica que decodifica los mensajes que llegan y llama a cada listener registrado. Los listeners son síncronos, no deben lanzar excepciones y se ejecutan en el bucle de eventos del servidor. + +El bus transporta valores `ServerEvent` tipados, cuatro dataclasses pequeñas, nunca JSON-RPC. El marcado, el filtrado y los ciclos de vida de los flujos se quedan en el SDK, así que una implementación del bus no puede romper el protocolo. Solo puede mover eventos entre procesos. + +Para publicar desde fuera de una solicitud, construye el bus tú mismo para conservar la referencia. `MCPServer` crea uno internamente cuando no pasas nada, y no lo expone. + +```python +from mcp.server.subscriptions import InMemorySubscriptionBus, ToolsListChanged + +bus = InMemorySubscriptionBus() +mcp = MCPServer("Sprint Board", subscriptions=bus) + + +async def tools_reloaded() -> None: + await bus.publish(ToolsListChanged()) # from a lifespan task, a webhook, anywhere +``` + +## La composición de bajo nivel {#the-low-level-composition} + +Abajo, en el `Server` de bajo nivel, no hay nada preconectado, y las mismas piezas se ensamblan en tres líneas: + +```python title="server.py" hl_lines="8-9 47" +--8<-- "docs_src/subscriptions/tutorial002.py" +``` + +* El bus es tuyo, así que publicas en él directamente: `await bus.publish(ResourceUpdated(uri=...))`. Ponlo donde tus handlers puedan alcanzarlo: el ámbito del módulo aquí, el lifespan en una app más grande. +* `ListenHandler(bus)` es el mismo handler que registra `MCPServer`, y `on_subscriptions_listen=` es una ranura de handler común y corriente. Pon tu propio callable en esa ranura para otra semántica, y las obligaciones de la especificación pasan a ti: acusar recibo primero, marcar cada trama con el id de suscripción, no entregar nada fuera del filtro. +* `ListenHandler.close()` termina cada flujo abierto de forma ordenada. Cada uno recibe el resultado de la solicitud listen como trama final, que es la forma que tiene la especificación de decir que el servidor terminó la suscripción a propósito. Devuelve antes de que esos flujos acaben de vaciarse, así que dales un momento antes de desmontar el transporte. Sin él, los flujos terminan cuando el cliente se desconecta. + +## Resumen {#recap} + +* Un cliente se apunta con una solicitud `subscriptions/listen`, y la respuesta es el flujo. Atenderla viene integrado. +* Publicas con `ctx.notify_*`, y el SDK hace el trabajo de marcado, filtrado y ciclo de vida. +* Los eventos son señales, no payloads. Ambos extremos vuelven a consultar. +* El lado del cliente es `async with client.listen(...)`: **[Suscripciones](../client/subscriptions.md)** en *Clientes* tiene todos los detalles. +* En el `Server` de bajo nivel ensamblas tú mismo las mismas piezas: un bus, `ListenHandler(bus)`, la ranura `on_subscriptions_listen`. +* Escalar horizontalmente significa implementar `SubscriptionBus`, dos métodos, y pasarlo como `MCPServer(subscriptions=...)`. + +Ejecutar el servidor que atiende todo esto, detrás de una réplica o de veinte, es **[Desplegar y escalar](../run/deploy.md)**. diff --git a/i18n/es/pages/index.md b/i18n/es/pages/index.md new file mode 100644 index 0000000000..145c89d421 --- /dev/null +++ b/i18n/es/pages/index.md @@ -0,0 +1,102 @@ +--- +translation: + sections: [154c4309937b9f85, 3ad8fc6caa76a9b0, a07f3f5b151ab746, bf6e476b712930c0, cf0b1f13978c6623] + tool: 1 +--- +# MCP Python SDK {#mcp-python-sdk} + +!!! info "Esta documentación describe v2, la línea de versiones estable actual" + ¿Eres nuevo en v2 o vienes de v1? **[Novedades de v2](whats-new.md)** es el recorrido de cinco minutos por lo que cambió, y la **[Guía de migración](migration.md)** cubre cada cambio incompatible. + ¿Sigues en v1.x? Su documentación está en la [documentación de v1.x](https://py.sdk.modelcontextprotocol.io/v1/). + ¿Algo quedó tosco o confuso? [Cuéntanos](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml). + +El **Model Context Protocol (MCP)** permite que las aplicaciones proporcionen contexto a los LLM de forma estandarizada, separando la tarea de *proporcionar* contexto de la interacción con el LLM en sí. + +Este es su SDK oficial para Python. Con él puedes: + +* **Crear servidores MCP** que exponen herramientas, recursos y prompts a cualquier host MCP. +* **Crear clientes MCP** que se conectan a cualquier servidor MCP. +* Hablar todos los transportes estándar: stdio, Streamable HTTP y SSE. + +## Requisitos {#requirements} + +Python 3.10+. + +## Instalación {#installation} + +=== "uv" + + ```bash + uv add "mcp[cli]" + ``` + +=== "pip" + + ```bash + pip install "mcp[cli]" + ``` + +El extra `[cli]` te da el comando `mcp`; lo vas a necesitar para desarrollar. +Consulta [Instalación](get-started/installation.md) para saber para qué sirve cada dependencia. + +## Ejemplo {#example} + +### Créalo {#create-it} + +Crea un archivo `server.py`: + +```python title="server.py" +--8<-- "docs_src/index/tutorial001.py" +``` + +Eso es un servidor MCP completo. + +Expone una **herramienta**, `add`, y un **recurso** con plantilla, `greeting://{name}`. + +### Ejecútalo {#run-it} + +```console +uv run mcp dev server.py +``` + +Esto inicia el servidor y abre el [MCP Inspector](https://github.com/modelcontextprotocol/inspector), una interfaz interactiva para explorarlo. Abre la URL que imprime. + +!!! note + El Inspector es una app de Node.js, así que `mcp dev` necesita `npx` en tu `PATH`. + +### Pruébalo {#try-it} + +En el Inspector, ve a **Tools** y llama a `add` con `a=1`, `b=2`. + +Te devuelve `3`. ✨ + +El Inspector construyó ese formulario (un campo entero obligatorio para `a` y otro para `b`) a partir de tus anotaciones de tipo. Lo mismo hará Claude, y cualquier otro host MCP. + +Ahora ve a **Resources** y lee `greeting://World`: + +```text +Hello, World! +``` + +### Resumen {#recap} + +Fíjate de nuevo en lo que **no** escribiste: + +* Ningún JSON Schema. `a: int, b: int` *es* el esquema. +* Nada de analizar solicitudes, ni de serialización, ni código de validación. +* Ningún manejo del protocolo. + +Escribiste dos funciones de Python con anotaciones de tipo y un docstring. El SDK hace el resto. + +## Dónde seguir {#where-to-go-next} + +* **[Empieza aquí](get-started/index.md)** te lleva de la instalación a un servidor funcional y probado. +* ¿Estás creando una aplicación que *usa* servidores MCP? Empieza por **[Clientes](client/index.md)**. +* ¿Ya tienes una app de FastAPI o Starlette? **[Añadir a una app existente](run/asgi.md)** monta un servidor MCP dentro de ella. +* ¿Buscas un mensaje de error exacto? **[Solución de problemas](troubleshooting.md)** está organizada por el texto literal. +* ¿Te preguntas qué cambió en v2? **[Novedades de v2](whats-new.md)** es el recorrido de cinco minutos. +* ¿Migras desde v1? Empieza por la **[Guía de migración](migration.md)**. +* ¿Buscas una firma exacta? La **[Referencia de la API](api/mcp/index.md)** se genera a partir del código fuente. +* ¿Lees con un LLM? Esta documentación también se publica en el formato [llms.txt](https://llmstxt.org/): + [llms.txt](https://py.sdk.modelcontextprotocol.io/llms.txt) es un índice de las páginas, y + [llms-full.txt](https://py.sdk.modelcontextprotocol.io/llms-full.txt) contiene todas las páginas en un solo archivo. diff --git a/i18n/es/pages/protocol-versions.md b/i18n/es/pages/protocol-versions.md new file mode 100644 index 0000000000..9d7211f1eb --- /dev/null +++ b/i18n/es/pages/protocol-versions.md @@ -0,0 +1,132 @@ +--- +translation: + sections: [478fd619e5f90ef8, aef094a00e44e248, bab8cbf3449fa7e9, df1809b15a58335b, 5f9d8c2336ed0239, f54974398e43ddef, b24443dd78584870] + tool: 1 +--- +# Versiones del protocolo {#protocol-versions} + +MCP tiene dos generaciones. + +Los servidores publicados antes de 2026-07-28 abren cada conexión con el **handshake `initialize`**: el cliente propone una versión, el servidor responde con la suya, el cliente confirma, todo antes de la primera solicitud útil. Los servidores en **2026-07-28** eliminan el handshake. El cliente envía un único sondeo **`server/discover`** y el servidor le responde con todo en un solo resultado. + +Casi nunca tienes que preocuparte por esto, porque `Client` negocia por ti. Esta página trata del único argumento del constructor que lo controla, `mode=`, y de las tres ocasiones en que lo cambias. + +## `mode="auto"` {#modeauto} + +```python title="client.py" hl_lines="14-15" +--8<-- "docs_src/protocol_versions/tutorial001.py" +``` + +No pasaste `mode`, así que obtuviste el valor por defecto: `"auto"`. Al entrar en `async with` se envía un único sondeo `server/discover` con la versión más reciente que habla este SDK. Después: + +* Un **servidor moderno** lo responde. El cliente adopta el resultado. Una ida y vuelta, y listo. +* Un **servidor más antiguo** nunca ha oído hablar de `server/discover` y devuelve un error. El cliente recurre al handshake clásico `initialize` y se queda con lo que este negocie. + +En cualquier caso terminas conectado, y `client.protocol_version` te dice cuál fue: + +```text +2026-07-28 +``` + +Esa es toda la funcionalidad. Un solo `Client`, servidores de cualquier generación, sin ramificaciones en tu código. + +!!! info + `MCPServer` responde a `server/discover` en todos los transportes (en memoria, stdio, Streamable + HTTP), así que contra tu propio servidor `auto` siempre llega a `2026-07-28`. El mecanismo de + respaldo solo se activa contra un servidor real anterior a 2026, que es exactamente cuando quieres que lo haga. + +## `mode="legacy"` {#modelegacy} + +```python title="client.py" hl_lines="14" +--8<-- "docs_src/protocol_versions/tutorial002.py" +``` + +`mode="legacy"` nunca sondea. Ejecuta el handshake `initialize`, la misma conexión que abre un cliente anterior a 2026. + +```text +2025-11-25 +``` + +El mismo servidor. Habla `2026-07-28` sin ningún problema; le dijiste al cliente que no preguntara. + +Esto lo quieres para las funcionalidades de tipo **push**. + +Una solicitud iniciada por el servidor es el servidor llamándote *a ti*: `ctx.elicit(...)` poniendo un formulario delante de tu usuario, el muestreo (sampling) pidiéndole a tu modelo una respuesta en mitad de una llamada a una herramienta. Ese canal solo existe en una sesión de la generación del handshake. + +En 2026-07-28 ya no existe. El servidor *devuelve* sus preguntas y tú repites la llamada con las respuestas (**[Solicitudes de varias idas y vueltas (multi-round-trip)](handlers/multi-round-trip.md)**). + +`mode="auto"` solo te da un handshake cuando el servidor es demasiado antiguo para cualquier otra cosa. `mode="legacy"` lo garantiza. Úsalo siempre que le pases a `Client(...)` un `sampling_callback`, un `elicitation_callback` que quieras que se ejecute como solicitud, o un `message_handler`. **[Callbacks del cliente](client/callbacks.md)** los repasa uno por uno. + +## Fijar una versión {#pinning-a-version} + +`mode` también acepta una cadena con una versión moderna del protocolo. Hoy ese conjunto es exactamente `["2026-07-28"]`. + +```python title="client.py" hl_lines="14" +--8<-- "docs_src/protocol_versions/tutorial003.py" +``` + +Una versión fijada no envía **nada**. Ni sondeo ni handshake. El cliente adopta `2026-07-28` localmente y la conexión está activa en el instante en que `async with` devuelve el control. + +Fijar una versión es una promesa que haces *tú*: ya sabes que el servidor habla esa versión. El cliente no lo comprueba. + +!!! check + Fijar una versión no es un descubrimiento. Imprime `client.server_info` y el precio salta a la vista: + + ```text + None + ``` + + El cliente nunca le preguntó al servidor quién es, así que `server_info` es `None`. Con `client.server_capabilities` + pasa lo mismo: todas las capacidades son `None`. Las llamadas a herramientas siguen funcionando (el protocolo no necesita nada de eso); + el código que lee `server_capabilities` para decidir qué ofrecer, no. + + La siguiente sección es la solución. + +Solo se pueden fijar versiones modernas. Una cadena de la generación del handshake se rechaza al construir el cliente, antes de cualquier E/S, y el error te dice qué escribir en su lugar: + +```text +ValueError: mode must be 'legacy', 'auto', or one of ['2026-07-28']; got '2025-06-18' ('2025-06-18' is a handshake-era version; use mode='legacy') +``` + +## Reconectar con `prior_discover` {#reconnecting-with-prior_discover} + +El sondeo es barato, pero sigue siendo una ida y vuelta que pagas en cada reconexión, y la respuesta casi nunca cambia. + +Así que guárdala. Tras una conexión `auto`, `client.session.discover_result` contiene el `DiscoverResult` exacto que envió el servidor: sus `supported_versions`, sus `capabilities`, sus `instructions` y la identidad que el servidor grabó en el `_meta` del resultado. Devuélveselo como `prior_discover=` la próxima vez: + +```python title="client.py" hl_lines="15 17" +--8<-- "docs_src/protocol_versions/tutorial004.py" +``` + +```text +2026-07-28 +Bookshop +``` + +La segunda conexión hizo **cero** idas y vueltas de negociación y aun así sabe exactamente con quién está hablando. Ese es el modo fijado bien hecho: `mode=` nombra la versión, `prior_discover=` aporta la identidad. ✨ + +`DiscoverResult` es un modelo de Pydantic. `saved.model_dump_json()` va a un archivo o a una caché; `DiscoverResult.model_validate_json(...)` lo recupera en el siguiente proceso. + +!!! tip + `prior_discover=` solo tiene efecto cuando `mode` es una versión fijada. Con `"auto"` el cliente + sondea el servidor de todos modos, y con `"legacy"` se ignora. + +## Los cuatro modos {#the-four-modes} + +| Escribes | Tráfico de negociación | Obtienes | +| --- | --- | --- | +| `Client(target)` | un sondeo `server/discover`; el handshake `initialize` si falla | la versión más reciente que hablan ambos lados, sea cual sea la generación | +| `Client(target, mode="legacy")` | el handshake `initialize` | una versión de la generación del handshake; las solicitudes iniciadas por el servidor funcionan | +| `Client(target, mode="2026-07-28")` | ninguno | esa versión, fijada, con `server_info` como `None` | +| `Client(target, mode="2026-07-28", prior_discover=saved)` | ninguno | esa versión, fijada, *y* la identidad que guardaste la última vez | + +## Resumen {#recap} + +* MCP tiene una generación del handshake (hasta `2025-11-25`, el handshake `initialize`) y una generación moderna (`2026-07-28`, `server/discover`). `Client` hace de puente entre ambas. +* `mode="auto"` es el valor por defecto: sondea y, si falla, recurre al handshake. Déjalo como está a menos que una de las otras tres filas te describa. +* `client.protocol_version` es siempre la respuesta a "¿qué obtuve?". +* `mode="legacy"` fuerza el handshake. Es lo que necesitas para las solicitudes iniciadas por el servidor: muestreo, elicitación (elicitation) de tipo push, `message_handler`. +* Fijar una versión (`mode="2026-07-28"`) no envía ningún tráfico de negociación, a costa de que `client.server_info` sea `None`. +* `prior_discover=` compensa ese precio: guarda `client.session.discover_result`, reconecta con él y obtén ambas cosas. + +Una conexión moderna no tiene canal push, así que ¿cómo te hace una pregunta un servidor de 2026 en mitad de una llamada? La devuelve: **[Solicitudes de varias idas y vueltas](handlers/multi-round-trip.md)**. diff --git a/i18n/es/pages/run/asgi.md b/i18n/es/pages/run/asgi.md new file mode 100644 index 0000000000..5d9363860a --- /dev/null +++ b/i18n/es/pages/run/asgi.md @@ -0,0 +1,146 @@ +--- +translation: + sections: [1062ef792791488a, 4be2b831547184a9, 374b049e770385f2, b72f6947089e6de0, b172c9db7831bb31, 70b9ece244ca1b0c, cba78e052898c3f6, f06bdb541cb0b469, fb82d526320b7cc3] + tool: 1 +--- +# Añadir a una app existente {#add-to-an-existing-app} + +`mcp.run("streamable-http")` arranca un servidor web por ti. A veces no es lo que quieres: el servidor MCP es una pieza de una aplicación web más grande, o ya tienes un despliegue ASGI. + +Para eso, `mcp.streamable_http_app()` devuelve una **aplicación Starlette**. + +Una app Starlette es una app ASGI, así que cualquier cosa que aloje ASGI (uvicorn, Hypercorn, otra Starlette, FastAPI) puede alojar el servidor MCP. + +## La app {#the-app} + +```python title="server.py" hl_lines="12" +--8<-- "docs_src/asgi/tutorial001.py" +``` + +`app` es una aplicación ASGI corriente. Pásala a cualquier servidor ASGI: + +```console +uvicorn server:app +``` + +El endpoint MCP está en `/mcp`, así que un cliente se conecta a `http://127.0.0.1:8000/mcp`. + +La app ya trae dos cosas: + +* Una ruta, `/mcp`: el endpoint Streamable HTTP. +* Un **lifespan** (ciclo de vida del servidor) que arranca `mcp.session_manager`, el objeto que se encarga del trabajo en segundo plano de cada sesión activa. + +Ejecuta la app por sí sola (`uvicorn server:app`) y nunca tendrás que pensar en ninguna de las dos. + +!!! tip + `streamable_http_app()` acepta los mismos argumentos nombrados que `mcp.run("streamable-http", ...)`, + menos `port`: el puerto es cosa de lo que sirva la app. `host` se sigue aceptando, pero aquí + no enlaza nada; **[Desplegar y escalar](deploy.md)** explica qué controla realmente. + **[Ejecutar el servidor](index.md)** cubre las opciones en sí. + +`mcp.sse_app()` hace lo mismo para el transporte SSE, ya reemplazado. + +## Solo localhost, hasta que digas lo contrario {#localhost-only-until-you-say-otherwise} + +Por defecto, la app responde **solo** a las solicitudes dirigidas a localhost. `streamable_http_app()` +no puede saber detrás de qué nombre de host se va a servir, así que activa la protección contra DNS +rebinding con la lista de permitidos más segura posible; en tu máquina eso es justo lo correcto. +Desplegada detrás de un nombre de host real, significa que **toda solicitud se rechaza con +`421 Misdirected Request`** hasta que le pases a `transport_security=` una lista de permitidos con +lo que realmente sirves. Nada de lo que construiste llega siquiera a consultarse antes. Esa lista +de permitidos, y todo lo demás que hay entre una app que funciona y un nombre de host real, está en +**[Desplegar y escalar](deploy.md)**. + +## Montarla {#mounting-it} + +En cuanto el servidor MCP es *parte* de una aplicación más grande, metes la app dentro de un `Mount`. Y en cuanto haces eso, el lifespan pasa a ser tu problema: + +```python title="server.py" hl_lines="18-21 25-26" +--8<-- "docs_src/asgi/tutorial002.py" +``` + +* `Mount("/", ...)` junto con el path por defecto `/mcp` mantiene el endpoint en `/mcp`. Starlette prueba las rutas en orden y `Mount("/")` coincide con **cualquier** path, así que tus propias rutas van *antes* que él en la lista. Todo lo que quede después es inalcanzable. +* La función `lifespan` entra en `mcp.session_manager.run()` durante toda la vida de la app **anfitriona**. Esta es la línea que todo el mundo olvida. +* `mcp.session_manager` solo existe *después* de llamar a `streamable_http_app()`. Por eso las rutas se construyen en el ámbito del módulo y el gestor solo se toca dentro del lifespan. + +La ruta `Host` de Starlette funciona igual: cambia `Mount("/", ...)` por `Host("mcp.example.com", ...)` para enrutar por nombre de host en lugar de por path. La regla del lifespan no cambia, y la de la seguridad del transporte tampoco. Una ruta `Host("mcp.example.com", ...)` solo recibe solicitudes dirigidas a ese nombre de host, pero la lista de permitidos de Host del propio transporte (**[Desplegar y escalar](deploy.md)**) sigue ejecutándose primero. Sin `"mcp.example.com"` en ella, esa ruta responde a todas con un `421`. + +!!! warning "La app anfitriona es dueña del lifespan" + `streamable_http_app()` conecta `session_manager.run()` al lifespan de la Starlette que + devuelve, pero **el lifespan de una subaplicación montada nunca se ejecuta**. Monta la app y + ese lifespan integrado es código muerto. La app que esté en la cima de tu pila ASGI, sea cual + sea, debe entrar en `mcp.session_manager.run()` en su propio lifespan. + +!!! check + Borra la línea `lifespan=lifespan` y arranca el servidor. Arranca. La ruta se resuelve. + Luego la primera solicitud a `/mcp` falla con: + + ```text + RuntimeError: Task group is not initialized. Make sure to use run(). + ``` + + Nada arranca el gestor de sesiones salvo su `run()`. + +## Dos servidores, una app {#two-servers-one-app} + +Cada `MCPServer` es su propia app con su propio gestor de sesiones. Monta tantos como quieras; entra en todos los gestores desde el lifespan de la app anfitriona, que es uno solo: + +```python title="server.py" hl_lines="27-30 35-36" +--8<-- "docs_src/asgi/tutorial003.py" +``` + +* `AsyncExitStack` entra en ambos gestores; arrancan juntos y se cierran en orden inverso. +* Los endpoints son `/notes/mcp` y `/tasks/mcp`: el prefijo de montaje más el path por defecto. + +## Cambiar el path {#changing-the-path} + +Ese `/mcp` final es `streamable_http_path`. Ponlo en `"/"` y el prefijo de montaje pasa a ser el path público completo: + +```python title="server.py" hl_lines="25" +--8<-- "docs_src/asgi/tutorial004.py" +``` + +Ahora los clientes se conectan a `/notes`, no a `/notes/mcp`. + +## CORS para clientes de navegador {#cors-for-browser-clients} + +Un cliente basado en navegador necesita dos permisos de tu parte: **enviar** sus encabezados de solicitud MCP y **leer** el que MCP devuelve. Ambos son configuración CORS de la app anfitriona, y la lista de permitidos de seguridad del transporte de arriba tiene que concordar con ella: + +```python title="server.py" hl_lines="27-30 33 35-49" +--8<-- "docs_src/asgi/tutorial005.py" +``` + +* `allow_headers` es la mitad que todo el mundo olvida. Un navegador hace un **preflight** de cada solicitud MCP, porque `Content-Type: application/json` y los encabezados de solicitud `Mcp-*` no están en la lista segura de CORS, y un encabezado que el preflight no concede es una solicitud que el navegador nunca envía. (`allow_headers=["*"]` también funciona: Starlette responde a un preflight con lo que sea que haya pedido.) +* `expose_headers=["Mcp-Session-Id"]` es la mitad de lectura. Streamable HTTP devuelve el ID de sesión en ese encabezado de respuesta, y los navegadores ocultan los encabezados de respuesta a JavaScript salvo que CORS los exponga por nombre. Sin él, el cliente nunca puede hacer su segunda solicitud. +* `allow_origins` es decisión tuya, no de MCP. Sé preciso y refléjalo en `allowed_origins=` arriba: el navegador hace cumplir CORS, pero el servidor comprueba `Origin` por su cuenta, y un origen en el que el transporte no confía recibe un `403` incluso tras un preflight limpio. +* `allow_methods` enumera los tres métodos que usa Streamable HTTP: `POST` para enviar mensajes, `GET` para abrir el flujo de servidor a cliente, `DELETE` para terminar la sesión. + +## Rutas personalizadas {#custom-routes} + +`@mcp.custom_route()` registra un endpoint HTTP simple en la misma app, para las cosas que todo servicio desplegado necesita y que no tienen nada que ver con MCP: una comprobación de estado, un callback de OAuth. + +```python title="server.py" hl_lines="15-17" +--8<-- "docs_src/asgi/tutorial006.py" +``` + +* El handler es Starlette puro: una función `async` de `Request` a `Response`. +* `streamable_http_app()` recoge todas las rutas personalizadas. `app.routes` es ahora `/mcp` y `/health`. +* `GET /health` responde `{"status": "ok"}` sin rastro de MCP. + +!!! warning + Las rutas personalizadas **nunca se autentican**, aunque el resto del servidor sí. Es + deliberado: las comprobaciones de estado y los callbacks de OAuth tienen que ser accesibles + antes de que exista ningún token. No pongas nada privado detrás de una. + +## Resumen {#recap} + +* `mcp.streamable_http_app()` devuelve una app Starlette con una ruta, `/mcp`. Cualquier servidor ASGI puede ejecutarla. +* Por defecto, la app responde solo a las solicitudes dirigidas a localhost, y detrás de un nombre de host real lo rechaza todo con un `421` hasta que le pases a `transport_security=` una lista de permitidos. **[Desplegar y escalar](deploy.md)** se ocupa de eso y del resto del camino a producción. +* `Mount` (o `Host`) la mete dentro de una app Starlette o FastAPI más grande. +* **Montar desactiva el lifespan integrado.** El lifespan de la app anfitriona debe entrar en `mcp.session_manager.run()`, o la primera solicitud falla. +* Varios servidores en una app significa varios montajes y un solo lifespan que entra en todos los gestores de sesiones. +* `streamable_http_path="/"` mueve el endpoint al propio prefijo de montaje. +* Los clientes de navegador necesitan CORS: `allow_headers` para los encabezados de solicitud `Mcp-*`, `expose_headers=["Mcp-Session-Id"]` para la respuesta. +* `@mcp.custom_route()` añade endpoints HTTP simples, sin autenticación, junto a `/mcp`. + +Una vez que el servidor es accesible en una URL real, **[El cliente](../client/index.md)** se conecta a él con esa URL en lugar de con un objeto servidor. diff --git a/i18n/es/pages/run/authorization.md b/i18n/es/pages/run/authorization.md new file mode 100644 index 0000000000..c39fcdcec8 --- /dev/null +++ b/i18n/es/pages/run/authorization.md @@ -0,0 +1,130 @@ +--- +translation: + sections: [d62c13457fc4a534, 80e73abaca6e0652, d1dc4c54cd00ec9c, 14ad3bc7904036bb, 5225f127bc1b9c77, fe1626fdd5aad1da, 4556cb7ea1a04a31] + tool: 1 +--- +# Autorización {#authorization} + +Sobre Streamable HTTP, tu servidor MCP es un servicio web común y corriente, y lo proteges igual que proteges cualquier servicio web: con tokens bearer de OAuth 2.1. + +En términos de OAuth, el servidor es un **servidor de recursos**. Nunca inicia la sesión de nadie y nunca emite un token. Hace una sola cosa: mirar el header `Authorization` de cada solicitud y decidir si el token que trae es válido. + +Esta página es el lado del servidor. Un cliente que descubre tu servidor de autorización y obtiene el token está en **[Clientes OAuth](../client/oauth-clients.md)**. + +## Las tres partes {#the-three-parties} + +* El **servidor de autorización** inicia la sesión de las personas y emite tokens de acceso. Esto no lo escribes tú. Es tu proveedor de identidad (Auth0, Keycloak, Entra, el tuyo propio). +* El **servidor de recursos** es tu servidor MCP. Verifica el token en cada solicitud. +* El **cliente** descubre en qué servidor de autorización confías, obtiene de él un token y te lo envía de vuelta como `Authorization: Bearer `. + +Ese es todo el triángulo. Todo lo que hay en esta página es el punto del medio. + +## Un verificador de tokens {#a-token-verifier} + +El SDK no opina sobre cómo debe ser un token válido. Se lo dices tú, implementando **`TokenVerifier`**: + +```python title="server.py" hl_lines="12-14 19-24" +--8<-- "docs_src/authorization/tutorial001.py" +``` + +* `TokenVerifier` es un protocolo con un solo método asíncrono. `verify_token` recibe el token en bruto del header `Authorization` y devuelve un **`AccessToken`** si es válido, `None` si no lo es. No hay nada más que implementar. +* Este busca el token en una tabla. Uno real verifica la firma de un JWT o llama al endpoint de introspección de tokens del servidor de autorización. Ese código es tuyo; el SDK solo lo llama. +* `token_verifier=` y `auth=` siempre van juntos. Pasa uno sin el otro y `MCPServer(...)` lanza un `ValueError` antes de atender ninguna solicitud. + +`AuthSettings` es la cara pública de tu servidor de recursos: + +* `issuer_url`: el servidor de autorización que emite tus tokens. +* `resource_server_url`: la URL pública de este endpoint MCP. Indica *para qué* recurso es un token y es donde vive el documento de descubrimiento. +* `required_scopes`: todo token debe traerlos todos. + +!!! tip + `examples/servers/simple-auth/` en el repositorio del SDK tiene un `IntrospectionTokenVerifier` que llama + al endpoint [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662) de un servidor de autorización real. Es la forma que toman la mayoría de los verificadores en producción. + +## Lo que obtienes sobre HTTP {#what-you-get-over-http} + +La autorización vive en los headers HTTP, así que solo existe en los transportes HTTP. Ejecútala en el que despliegues: `mcp.run(transport="streamable-http")` la pone en `http://127.0.0.1:8000/mcp`, y **[Ejecutar el servidor](index.md)** tiene el resto. La app ahora tiene dos rutas: + +```text +/mcp +/.well-known/oauth-protected-resource/mcp +``` + +Registraste una herramienta. La segunda ruta es del SDK. + +### Descubrimiento {#discovery} + +Haz un `GET` a esa ruta well-known y obtienes los **Protected Resource Metadata de [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728)**, construidos directamente a partir de tu `AuthSettings`: + +```json +{ + "resource": "http://127.0.0.1:8000/mcp", + "authorization_servers": ["https://auth.example.com/"], + "scopes_supported": ["notes:read"], + "bearer_methods_supported": ["header"] +} +``` + +Este documento es la forma en que un cliente que nunca ha oído hablar de tu servidor encuentra la entrada: lee `authorization_servers` y va ahí a buscar un token. No escribiste nada de él. + +!!! check + Llama a `/mcp` sin token (o con uno para el que tu verificador devolvió `None`) y la solicitud + se detiene en la puerta: + + ```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"} + ``` + + No se analizó nada ni se ejecutó ninguna herramienta. Y ese puntero `resource_metadata` en `WWW-Authenticate` es + lo que hace automático el descubrimiento: 401 -> documento de metadatos -> servidor de autorización -> token -> reintento. + +!!! warning + Nada de esto protege a `stdio`. Una tubería no tiene header `Authorization`, así que ahí nunca se + consulta `token_verifier`. La frontera de seguridad de un servidor `stdio` es el proceso que lo lanzó. Lo mismo + vale para el `Client(mcp)` en memoria que usas en las pruebas: se conecta directamente al objeto servidor + y se salta la capa HTTP, autorización incluida. + +## La identidad de quien llama {#the-callers-identity} + +Dentro de cualquier handler, **`get_access_token()`** es el `AccessToken` que tu verificador devolvió para la solicitud actual: + +```python title="server.py" hl_lines="4 32-35" +--8<-- "docs_src/authorization/tutorial002.py" +``` + +* Funciona en herramientas, recursos y prompts, y no hay nada que pasar de un lado a otro: el middleware de autenticación lo guarda en una variable de contexto por solicitud. +* Recibes el **mismo objeto que construyó tu verificador**: `client_id`, `scopes`, `subject`, `expires_at` y cualquier `claims` extra que hayas añadido. Ese es el punto de enganche para reglas por herramienta: lee los scopes y rechaza. +* Fuera de una solicitud HTTP autenticada devuelve `None`. En memoria y sobre `stdio` siempre es `None`. + +Llama a `whoami` con `Authorization: Bearer alice-token` y el modelo lee: + +```text +alice (scopes: notes:read) +``` + +## La mitad que el SDK no hace {#the-half-the-sdk-doesnt-do} + +El SDK te da la mitad del servidor de recursos: verificar, anunciar, rechazar. No te da una página de inicio de sesión, una pantalla de consentimiento ni un token. + +Para ver a las tres partes en movimiento, ejecuta `examples/servers/simple-auth/` del repositorio del SDK (un pequeño servidor de autorización y un servidor de recursos configurado exactamente como en esta página) y luego apunta `examples/clients/simple-auth-client/` hacia él para ver el recorrido completo de descubrimiento y token. + +!!! info + Hay un segundo argumento del constructor, `auth_server_provider=`, que incrusta un servidor de autorización + completo dentro de tu servidor MCP. Es anterior a la separación AS/RS sobre la que se construye la especificación + de autorización de MCP. Los servidores nuevos no deberían recurrir a él. + +Un servidor de autorización también puede aceptar la aserción firmada de un proveedor de identidad empresarial en lugar de que un usuario haga clic en una pantalla de consentimiento, y el SDK admite los dos lados de ese intercambio. El grant, y el cliente que lo presenta, están en **[Aserción de identidad](../client/identity-assertion.md)**. + +## Resumen {#recap} + +* Sobre Streamable HTTP tu servidor es un **servidor de recursos** de OAuth 2.1: verifica tokens, nunca los emite. +* `TokenVerifier` es toda la superficie de integración: un método asíncrono, entra un token, sale `AccessToken | None`. +* `token_verifier=` y `auth=AuthSettings(issuer_url=..., resource_server_url=..., required_scopes=[...])` siempre van juntos. +* El SDK publica los Protected Resource Metadata de [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) en `/.well-known/oauth-protected-resource/...` y responde a las solicitudes no autenticadas con un 401 cuyo header `WWW-Authenticate` apunta a ellos. Ese es todo el mecanismo de descubrimiento. +* `get_access_token()` en cualquier handler te dice quién llama. +* La autorización es un asunto de HTTP. `stdio` y el cliente en memoria nunca la ven. + +La mitad del cliente (descubrir tu servidor de autorización y obtener el token por ti) está en **[Clientes OAuth](../client/oauth-clients.md)**. Y un cliente que *afirma* una identidad en lugar de pedírsela a un usuario está en **[Aserción de identidad](../client/identity-assertion.md)**. diff --git a/i18n/es/pages/run/deploy.md b/i18n/es/pages/run/deploy.md new file mode 100644 index 0000000000..3ff88dc1ae --- /dev/null +++ b/i18n/es/pages/run/deploy.md @@ -0,0 +1,179 @@ +--- +translation: + sections: [28221886b198784f, f88ea1f1614f3a1d, ce926d686730b6d0, 3be24f8ad8bb5ab9, 3fad24032b2224ff, f25a7f860e579ecb, e758745df6fb7b0a] + tool: 1 +--- +# Desplegar y escalar {#deploy-scale} + +El servidor funciona. Ahora necesita un nombre de host real y más de un worker detrás. + +Casi nada de eso es asunto de MCP. Tú pones el servidor ASGI, el gestor de procesos y el balanceador de carga. Lo que tiene esta página es la lista corta de cosas que *sí* son asunto de MCP: un ajuste que condiciona cualquier despliegue y los dos puntos donde "más de un worker" cambia lo que hace el SDK. + +## Antes que nada: la lista de hosts permitidos {#before-anything-else-the-host-allowlist} + +`streamable_http_app()` no puede saber detrás de qué nombre de host se va a servir, así que supone la respuesta más segura: localhost. Sin `transport_security=`, la app activa la **protección contra DNS rebinding** y acepta una solicitud solo si su encabezado `Host` es `127.0.0.1:`, `localhost:` o `[::1]:`. El encabezado `Origin`, cuando lo hay, tiene que ser la forma `http://` del mismo. En tu máquina eso es exactamente lo correcto: impide que una página web maliciosa maneje tu servidor local a través de un nombre DNS que volvió a apuntar a `127.0.0.1`. + +Desplegada detrás de un nombre de host real, esa misma configuración por defecto rechaza **todas las solicitudes** hasta que indiques lo contrario. La comprobación se ejecuta antes que cualquier cosa con forma de MCP, así que nada de lo que construiste llega siquiera a consultarse: + +```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=` es la solución. Permite lo que realmente sirves: + +```python title="server.py" hl_lines="2 13-17" +--8<-- "docs_src/deploy/tutorial001.py" +``` + +* Las entradas de `allowed_hosts` son cadenas exactas: `"mcp.example.com"` coincide con un encabezado `Host` sin puerto y `"mcp.example.com:*"` coincide con cualquier puerto. Incluye ambas. +* `allowed_origins` solo importa para los navegadores, porque nada más envía `Origin`. Es el gemelo del lado del servidor de la configuración CORS de **[Añadir a una app existente](asgi.md)**. +* Detrás de un proxy inverso que ya controla el encabezado `Host`, desactivar la comprobación es la configuración honesta: `TransportSecuritySettings(enable_dns_rebinding_protection=False)`. +* Pasar un `host=` que no sea localhost (por ejemplo `host="mcp.example.com"`) **no** añade ese nombre de host a la lista permitida. Solo evita que el valor por defecto de localhost arme la protección, lo que deja cualquier Host y Origin aceptados. Di lo que quieres decir con `transport_security=`. + +!!! check + Borra el argumento `transport_security=security` y despliega la app de todos modos. Arranca, `/mcp` + enruta, y cada solicitud (incluso desde un simple `curl`) vuelve así: + + ```text + HTTP/1.1 421 Misdirected Request + + Invalid Host header + ``` + + No encontrarás esas palabras del lado del cliente. Un `421` es una respuesta HTTP en texto plano, no un + error JSON-RPC, así que el cliente MCP lanza un error de transporte genérico; el nombre de host que + no le gustó aparece solo en el log del **servidor**, como una única advertencia. Un servidor recién + desplegado que rechaza todas las conexiones es una lista de hosts permitidos hasta que se demuestre lo contrario. + **[Solución de problemas](../troubleshooting.md)** también empieza por aquí. + +## Workers, y quién tiene que ser sticky {#workers-and-who-has-to-be-sticky} + +Una vez que el nombre de host responde, pon más de un worker detrás. No hay ningún ajuste del SDK para eso; una app Starlette se escala como cualquier app ASGI, entregando el objeto a algo que sepa hacer fork: + +```console +uvicorn server:app --workers 4 +``` + +Cuatro procesos, un socket. Y ahora la pregunta que todo despliegue tiene que responder: **¿una solicitud tiene que llegar al worker que vio la anterior?** + +Para un cliente que habla el protocolo **2026-07-28**, no. Una solicitud moderna es un único POST autocontenido: sin handshake `initialize` antes, sin `Mcp-Session-Id` en la respuesta, nada *a lo que* una segunda solicitud pueda volver. Enrútala a cualquier worker. + +Eso no es un modo que activas. `stateless_http=True` parece que debería serlo, pero el transporte enruta según el encabezado de solicitud `MCP-Protocol-Version`, entrega una solicitud moderna al handler moderno y **devuelve**. La línea que lee `stateless_http` viene *después* de ese retorno. No es que el flag se ignore en la ruta 2026-07-28; es que nunca se alcanza. `stateless_http` es un ajuste solo para el tramo **heredado**, y la ruta moderna no tiene sesión por construcción. + +Para un cliente heredado en la versión de especificación 2025-11-25 o anterior, la respuesta depende de ese flag: + +| Versión de protocolo del cliente | Sesión | Lo que debe hacer el balanceador de carga | +| --- | --- | --- | +| **2026-07-28** | Ninguna. `Mcp-Session-Id` nunca se establece. | Nada. Cualquier worker atiende cualquier solicitud. | +| **2025-11-25 y anteriores** (por defecto) | `Mcp-Session-Id`, guardado en la memoria de un worker. | **Sesiones sticky.** Una solicitud siguiente que llega a otro worker recibe un `404` *"Session not found"*. | +| **2025-11-25 y anteriores**, con `stateless_http=True` | Ninguna. | Nada. El costo es el canal de retorno (back-channel) del servidor al cliente (muestreo (sampling), elicitación (elicitation) por push, `roots/list`) y la capacidad de reanudar. | + +Las sesiones sticky y lo que cuesta el tramo heredado tienen su propia página, **[Atender clientes heredados](legacy-clients.md)**; las dos generaciones en sí están en **[Versiones del protocolo](../protocol-versions.md)**. Lo que importa aquí es la forma de la respuesta: *en 2026-07-28 ya eres stateless, sin nada que configurar.* + +El resto de esta página son las dos cosas que ser stateless **no** te resuelve. + +## `requestState` entre workers {#requeststate-across-workers} + +Una herramienta **[de varias idas y vueltas (multi-round-trip)](../handlers/multi-round-trip.md)** necesita algo que el cliente tiene que ir a buscar (una confirmación, una elección, una credencial), así que devuelve una pregunta en lugar de una respuesta y termina en el reintento. Entre las dos rondas el cliente guarda un token opaco `request_state` que acuñó el servidor. En el reintento el servidor tiene que volver a abrir ese token. + +*¿Sellado con qué clave?* Por defecto, una que el servidor generó con `os.urandom(32)` al construirse. Con `--workers 4` eso son cuatro construcciones, en cuatro procesos: cuatro claves distintas, nunca escritas en ningún lado, nunca compartidas, perdidas al reiniciar. + +Aquí tienes una herramienta que pregunta antes de actuar, en un servidor que no configura nada: + +```python title="server.py" hl_lines="14 20" +--8<-- "docs_src/deploy/tutorial002.py" +``` + +La primera ronda llega al worker A. El worker A sella `refund:120` con **su** clave y devuelve el token. El cliente le pone la pregunta delante a una persona, recibe un sí y reintenta. El reintento es una solicitud HTTP completamente nueva. + +!!! check + Deja que ese reintento llegue al worker B. B intenta abrir un token que no acuñó, no puede, y rechaza la + ronda entera. Nunca se llama a `refund`; el cliente recibe un error JSON-RPC: + + ```json + { + "code": -32602, + "message": "Invalid or expired requestState", + "data": {"reason": "invalid_request_state"} + } + ``` + + Ese mensaje está **congelado**. Expirado, manipulado, reutilizado contra otros argumentos o (la causa + más común con diferencia en un despliegue real) sellado por un worker hermano: al cliente se le dice + lo mismo cada vez, así que lo que se transmite nunca revela qué comprobación falló. El motivo real es un + `WARNING` en el log del servidor: + + ```text + requestState rejected on tools/call: unknown key + ``` + + Una herramienta de varias idas y vueltas que funcionaba con un worker y empezó a fallar *a veces* con + dos es esto. Las dos rondas siguen teniendo que llegar al mismo proceso, así que falla exactamente tan + a menudo como tu balanceador de carga las separa. + +Las dos rondas son dos solicitudes HTTP independientes, y varias cosas ordinarias las separan: un proxy que balancea por solicitud, una conexión que se cayó entre medias, un despliegue o un reinicio, un cliente que persistió `request_state` y está reanudando desde un proceso completamente distinto (**[Dirigir el bucle tú mismo](../handlers/multi-round-trip.md#driving-the-loop-yourself)**). Cualquiera de ellas es "un worker distinto". + +La solución es un argumento. Tiene **dos** mitades. + +```python title="server.py" hl_lines="1 12 14" +--8<-- "docs_src/deploy/tutorial003.py" +``` + +* **`keys=[...]`** es la mitad que todo el mundo encuentra. Dale a cada instancia el mismo secreto (al menos 32 bytes) y cada instancia puede abrir lo que acuñó cualquier hermana. `keys[0]` sella y todas las claves de la lista abren, que es el anillo de rotación; **[Rotar claves](../handlers/multi-round-trip.md#rotating-keys)** explica cómo girarlo sin tiempo de inactividad. +* **El nombre del servidor** es la mitad que casi nadie encuentra, y la razón por la que los reintentos entre instancias siguen fallando después de compartir la clave. Cada token sellado lleva el `name` del servidor como **claim de audiencia**, comprobado de forma estricta al volver a entrar. Dos instancias construidas a partir del mismo código tienen el mismo nombre y nunca lo notan. Nómbralas distinto (`MCPServer(f"billing-{POD}")` parece buena higiene de observabilidad) y cada reintento entre instancias se rechaza exactamente como arriba, con clave compartida o sin ella. El log dice `audience` en lugar de `unknown key`; el cliente no puede notar la diferencia. + +Acuña el secreto una vez y entrega el mismo valor a cada instancia. Este es el comando que el propio mensaje de error del SDK te dice que ejecutes si le pasas menos de 32 bytes: + +```console +python -c "import secrets; print(secrets.token_hex(32))" +``` + +!!! warning "Las mismas claves, *y* el mismo nombre" + Un despliegue de varias instancias debe compartir ambos. Si los nombres por instancia son imprescindibles + para ti, dale a la flota una única audiencia explícita: `RequestStateSecurity(keys=[...], audience="billing")`. + Cada instancia acuña y acepta entonces bajo `"billing"` sin importar cómo se llame. + +Todo lo demás sobre el sello está en **[Proteger `requestState`](../handlers/multi-round-trip.md#protecting-requeststate)**: qué vincula, el `ttl` por ronda (600 segundos por defecto), traer tu propio códec, por qué el valor por defecto sin configurar es exactamente lo correcto en `stdio`. Toda la aportación de esta página es una lista de comprobación de dos puntos: *las mismas claves, el mismo nombre.* + +!!! info + Estás en esta ruta aunque nunca hayas escrito `InputRequiredResult`. Una herramienta cuyos parámetros + usan `Resolve(...)` (**[Dependencias](../handlers/dependencies.md)**) es una herramienta de varias idas y vueltas, + y el SDK acuña y sella su `request_state` por ella. La misma clave por defecto, el mismo fallo entre + workers, la misma solución. + +## Notificaciones de cambio entre réplicas {#change-notifications-across-replicas} + +El stream `subscriptions/listen` de un cliente es una única respuesta de larga duración, así que queda fijado a una réplica durante toda su vida. Un `ctx.notify_resource_updated(...)` publicado en una réplica **distinta** tiene que llegarle. + +La unión entre las dos es el `SubscriptionBus`. El bus que le des a un servidor es al que va cada publicación y el que escucha cada stream abierto, así que entrega el mismo bus a cada réplica: + +```python title="server.py" hl_lines="2 7 9" +--8<-- "docs_src/deploy/tutorial004.py" +``` + +A nada del fan-out le importa a qué objeto servidor está conectado un stream. Dos servidores que comparten un `InMemorySubscriptionBus` ya se comportan así: abre un stream de escucha en uno, ejecuta `edit_note` en el otro, y el stream se entera. Ese bus en memoria solo abarca objetos servidor dentro de un mismo proceso, lo que lo convierte en el modelo, no en el despliegue: + +* Entre procesos reales, **el SDK no trae ningún bus que pueda ayudarte.** `SubscriptionBus` es un `Protocol` de dos métodos (`publish` y `subscribe`) que implementas sobre tu propio backend pub/sub (Redis, NATS, lo que ya ejecutes) y pasas como `MCPServer(subscriptions=...)`. **[Suscripciones](../handlers/subscriptions.md#scaling-past-one-process)** tiene el esbozo y el contrato. +* El bus transporta cuatro eventos tipados pequeños, nunca JSON-RPC. El acuse de recibo, el filtrado y el ciclo de vida de los streams se quedan en el SDK, así que tu bus no puede romper el protocolo; solo puede mover eventos entre procesos. +* Los streams **no** se pueden reanudar y los eventos **no** se vuelven a reproducir. Perder una réplica descarta sus streams; los clientes vuelven a escuchar y vuelven a obtener los datos. No hay almacén de eventos que compartir ni nada más que configurar. Este es el único lugar donde escalar horizontalmente es de verdad solo más de lo mismo. + +## Lo que el SDK no te da {#what-the-sdk-does-not-give-you} + +Un `MCPServer` es una implementación del protocolo, no un servidor de aplicaciones. Los ajustes de despliegue que vas a buscar a continuación faltan a propósito: + +* **Sin `workers=`.** `mcp.run("streamable-http")` arranca exactamente un proceso uvicorn, y eso es todo lo que arrancará jamás. Multiproceso es `streamable_http_app()` entregado a lo que ya uses para desplegar ASGI: `uvicorn --workers`, gunicorn, el gestor de procesos de tu plataforma. Esta página deliberadamente no es un tutorial de ninguno de ellos; su documentación es mejor de lo que sería una copia aquí. +* **Sin ruta de health check.** `@mcp.custom_route("/health", methods=["GET"])` es toda la respuesta, y nunca se autentica aunque el resto del servidor sí. Eso es lo correcto para una sonda de vida, incorrecto para cualquier cosa privada. **[Añadir a una app existente](asgi.md#custom-routes)** muestra una. +* **Sin objeto de configuración de producción.** No hay ningún lugar en `MCPServer` donde anotar timeouts, TLS, apagado ordenado o límites de conexión, porque ninguno de esos es su trabajo. Pertenecen a tu servidor ASGI, y los configuras allí. **[Ejecutar tu servidor](index.md)** cubre el puñado de ajustes que el constructor *sí* acepta. +* **Sin `EventStore` incluido, y en 2026-07-28 sin uso para uno.** La capacidad de reanudar es una funcionalidad del tramo heredado con estado; un intercambio moderno es un POST, una respuesta y nada que reanudar. + +## Resumen {#recap} + +* Por defecto, la app responde solo a las solicitudes dirigidas a localhost. `transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])` es la puerta de salida a producción: hasta que lo pases, cada solicitud detrás de un nombre de host real es un `421` y el motivo solo está en el log del servidor. +* En 2026-07-28 no hay sesión ni nada sobre lo que un balanceador de carga pueda ser sticky. `stateless_http=True` es un ajuste solo para lo heredado porque una solicitud moderna se enruta y se responde antes de que ese flag llegue a leerse. +* La clave por defecto de `requestState` es `os.urandom(32)`, acuñada por proceso. Un reintento de varias idas y vueltas que llega a otro worker falla con `-32602` *"Invalid or expired requestState"*. +* La solución es `RequestStateSecurity(keys=[...])` **y** el mismo nombre de servidor en cada instancia. El nombre es el claim de audiencia por defecto del token. Las mismas claves, el mismo nombre. +* Las notificaciones de cambio cruzan réplicas a través de un `SubscriptionBus` compartido. La única implementación del SDK es dentro del proceso; el `Protocol` de dos métodos sobre tu propio pub/sub te toca escribirlo a ti. +* No hay `workers=`, ni ruta de health check, ni objeto de configuración de producción. Trae tu propio servidor ASGI. + +Lo otro que un nombre de host real necesita delante es un token: **[Autorización](authorization.md)**. diff --git a/i18n/es/pages/run/index.md b/i18n/es/pages/run/index.md new file mode 100644 index 0000000000..7ad6328cf4 --- /dev/null +++ b/i18n/es/pages/run/index.md @@ -0,0 +1,156 @@ +--- +translation: + sections: [fea8d769ff9edeba, ce8e2ad42f29ef71, 0d705efb19cf99c2, 7a53ead3e704a7f0, 9adc400e8c88e854, 318893ad8e2e9924, 6b63ab96b34476c0] + tool: 1 +--- +# Ejecutar el servidor {#running-your-server} + +`mcp.run()` inicia el servidor. + +La única decisión que tomas es el **transporte**: cómo se mueven realmente los bytes entre el servidor y su cliente. + +## Elige un transporte {#pick-a-transport} + +| Transporte | Qué es | Cuándo | +|---|---|---| +| `stdio` | El host lanza tu archivo como subproceso y se comunica a través de su stdin y stdout. | Servidores locales. El valor por defecto. | +| `streamable-http` | Un servidor HTTP real que escucha en un puerto. | Cualquier cosa que despliegues. | +| `sse` | El transporte HTTP antiguo. | No lo uses. | + +!!! warning + SSE quedó reemplazado por Streamable HTTP en la revisión del protocolo 2025-03-26. + `mcp.run(transport="sse")` sigue funcionando, con sus propias opciones `sse_path=` y `message_path=`, + pero existe para los clientes que no han migrado. No construyas nada nuevo sobre él. + +## `mcp.run()` {#mcprun} + +```python title="server.py" hl_lines="12-13" +--8<-- "docs_src/run/tutorial001.py" +``` + +* `run()` es síncrono. Bloquea durante toda la vida del servidor. +* Sin argumentos, el transporte es `stdio`. +* Está bajo `if __name__ == "__main__":` porque todo lo que carga el servidor (`mcp dev`, `mcp run`, `mcp install`, tus pruebas) **importa** este archivo. La guarda evita que una importación se convierta en un servidor en ejecución. + +### stdio {#stdio} + +No hay nada que configurar. El host inicia tu archivo como proceso hijo, escribe las solicitudes en su stdin y lee las respuestas de su stdout. + +Ejecútalo tú mismo y verás la consecuencia: + +```console +python server.py +``` + +No imprime nada y no termina. Está esperando en stdin a que un host hable primero. + +Eso también significa que stdout **es el canal**. Mientras sirve, el SDK mueve el canal a un descriptor privado y desvía a stderr la salida que se *vacía* (flush) hacia stdout (un subproceso que escribe en el stdout heredado, un `print()` con flush), donde no puede corromper el flujo. La salida que se vacía hacia stdout *antes* de que empiece a servir (un script envoltorio que hace echo, un print sin búfer al importar) sigue llegando al canal, igual que un `print()` que queda en el búfer hasta que el intérprete lo vacía al salir. Para la salida que de verdad quieres, el módulo `logging` es la herramienta adecuada: su handler vacía cada registro a stderr en cuanto ocurre. Todos los detalles están en **[Logging](../handlers/logging.md)**. + +### Pruébalo {#try-it} + +```console +uv run mcp dev server.py +``` + +El Inspector hace exactamente lo que hace un host real: lanza `server.py` como subproceso y se conecta a él por stdio. + +Nunca le diste un puerto. No hay ninguno. + +## Streamable HTTP {#streamable-http} + +Para poner el mismo servidor en un puerto, nombra el transporte (y sus opciones) en `run()`: + +```python title="server.py" hl_lines="13" +--8<-- "docs_src/run/tutorial002.py" +``` + +Esa única línea construye una app de Starlette y la sirve con uvicorn. Los clientes se conectan a `http://127.0.0.1:3001/mcp`. + +Cada transporte tiene sus propios argumentos nombrados, todos en `run()`: + +* `host` / `port`: dónde escuchar. Por defecto `127.0.0.1` y `8000`. +* `streamable_http_path`: dónde vive el endpoint MCP. Por defecto `/mcp`. +* `json_response=True`: responde a cada POST con un único cuerpo JSON en lugar de un flujo SSE. Ese cuerpo tiene sitio para la respuesta y nada más, así que una herramienta que llama de vuelta al cliente a mitad de solicitud (`ctx.elicit()`, muestreo (sampling)) lanza `NoBackChannelError` en este tramo, y las notificaciones ligadas a la llamada en curso (el progreso de `ctx.report_progress()`, los mensajes de log por llamada) se descartan; el flujo `GET` independiente sigue llevando las que no están relacionadas. +* `stateless_http=True`: un transporte nuevo por solicitud, sin seguimiento de sesión. +* `max_request_body_size`: el cuerpo POST más grande que se acepta, en bytes. Es 4 MiB por defecto; las solicitudes mayores + reciben HTTP 413 antes del análisis o de la creación de la sesión. Súbelo solo cuando los mensajes MCP legítimos + superen ese tamaño. +* `event_store`, `retry_interval`, `transport_security`: reanudabilidad y protección contra DNS rebinding. Pueden esperar hasta que despliegues en algún lugar que no sea localhost; **[Desplegar y escalar](deploy.md)** cubre `transport_security`. + +!!! warning + Las opciones de transporte van a `run()`, **no** a `MCPServer(...)`. El constructor describe lo que + el servidor *es*: nombre, versión, instrucciones. `run()` describe cómo se sirve. Si lo haces + al revés, Python responde antes de que MCP entre siquiera en juego: + + ```text + TypeError: MCPServer.__init__() got an unexpected keyword argument 'port' + ``` + +`run()` es el camino corto. En cuanto necesitas más (el servidor montado dentro de una app existente, dos servidores en un proceso, CORS para clientes de navegador), construyes la app ASGI tú mismo y se la pasas a cualquier host ASGI. Eso es **[Añadir a una app existente](asgi.md)**. + +## Ajustes del servidor {#server-settings} + +Un par de cosas sobre la ejecución no tienen que ver con el transporte. Son argumentos del constructor: + +```python title="server.py" hl_lines="3" +--8<-- "docs_src/run/tutorial003.py" +``` + +* `log_level`: se pasa a `logging.basicConfig()` en el momento en que se construye `MCPServer(...)`. Eso configura el logger **raíz**, así que fija el nivel también para tus propios loggers, no solo para los del SDK. Por defecto `"INFO"`. +* `debug`: se reenvía a la app de Starlette que construyen los transportes HTTP. Por defecto `False`. + +Ambos acaban en `mcp.settings`, que puedes leer en tiempo de ejecución. + +## El comando `mcp` {#the-mcp-command} + +El extra `[cli]` instala una pequeña herramienta de línea de comandos alrededor de todo esto. + +`mcp dev` ejecuta el servidor bajo el **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` añade paquetes al entorno que construye; `--with-editable` instala tu propio paquete en él. Necesita `npx` en tu `PATH`: el Inspector es una app de Node.js. + +`mcp run` importa el archivo, encuentra el objeto servidor (un `mcp`, `server` o `app` a nivel de módulo) y llama a `run()` sobre él: + +```console +uv run mcp run server.py +uv run mcp run server.py:bookshop +``` + +El sufijo `:` nombra el objeto cuando no se llama `mcp`, `server` ni `app`. + +Tu bloque `if __name__ == "__main__":` nunca se ejecuta aquí: `mcp run` llama a `run()` por su cuenta, y la única opción que reenvía es `--transport`. + +`mcp install` registra el servidor en **Claude Desktop**, de modo que la app lo lanza por ti: + +```console +uv run mcp install server.py --name "Bookshop" +uv run mcp install server.py -v API_KEY=abc123 -f .env +``` + +`-v KEY=VALUE` y `-f .env` guardan variables de entorno en esa entrada. Claude Desktop inicia el servidor en su propio proceso. El entorno de tu shell no está ahí. + +Claude Desktop es el único host que `mcp install` conoce. Todos los demás hosts (Claude Code, Cursor, VS Code) aceptan el mismo comando de lanzamiento en su propio archivo de configuración, y **[Conectar con un host real](../get-started/real-host.md)** tiene cada uno. + +`mcp version` imprime la versión del SDK instalada. + +!!! tip + `mcp dev` y `mcp run` solo entienden `MCPServer`. Si construyes con el `Server` de bajo nivel, + lo ejecutas tú mismo. Consulta **[El Server de bajo nivel](../advanced/low-level-server.md)**. + +## Resumen {#recap} + +* Un **transporte** es cómo llegan los bytes al servidor: `stdio` para un subproceso local, `streamable-http` para un puerto. SSE está reemplazado. +* `mcp.run()` elige el transporte. Sin argumentos es `stdio`, y bloquea. +* Cada opción de transporte (`host`, `port`, `streamable_http_path`, ...) es un argumento de `run()`, nunca de `MCPServer(...)`. +* Mantén `run()` bajo `if __name__ == "__main__":`. Todo lo que carga el servidor importa primero el archivo. +* `log_level=` y `debug=` son argumentos del constructor; acaban en `mcp.settings`. +* `mcp dev` para el Inspector, `mcp run` para ejecutar un archivo, `mcp install` para Claude Desktop, `mcp version` para la versión. +* El transporte nunca cambia lo que el servidor *es*: los tres archivos de esta página exponen la misma herramienta. + +Cuando el límite es `run()` mismo (el servidor dentro de una app que ya existe), es **[Añadir a una app existente](asgi.md)**. Un nombre de host real y más de un worker es **[Desplegar y escalar](deploy.md)**. Y si algunos de tus clientes siguen en la versión de la especificación 2025-11-25 o anterior, **[Atender clientes heredados](legacy-clients.md)** es la buena noticia. diff --git a/i18n/es/pages/run/legacy-clients.md b/i18n/es/pages/run/legacy-clients.md new file mode 100644 index 0000000000..8b1e2bf28c --- /dev/null +++ b/i18n/es/pages/run/legacy-clients.md @@ -0,0 +1,134 @@ +--- +translation: + sections: [3d1663c18edc824c, d4fd37009a13f03d, af9f398a5a8b679a, 470c2dd144294d69, 8e45827e6d24e8c8, 91dfd0ce98ebb03c] + tool: 1 +--- +# Atender clientes heredados {#serving-legacy-clients} + +MCP tiene dos generaciones de protocolo: la generación del handshake `initialize`, hasta la versión de la especificación `2025-11-25`, y la generación moderna, `2026-07-28`. **[Versiones del protocolo](../protocol-versions.md)** es la página dedicada a esa división. + +Esta página trata del lado del servidor de esa división, y la respuesta cabe en una frase: **el `streamable_http_app()` que ya despliegas atiende a ambas.** + +El SDK enruta cada solicitud según su encabezado `MCP-Protocol-Version`. Una solicitud que indica `2026-07-28` va al handler moderno. Una solicitud que indica una versión de la generación del handshake, o que no trae ningún encabezado (que es como llega el `initialize` de un cliente anterior a 2026), va al transporte que esos clientes esperan: handshake `initialize`, sesiones y todo lo demás. Ocurre por solicitud, antes de tu código, en una sola app. + +Así que un cliente heredado (legacy) no es algo *para* lo que construyes. Es algo que se conecta *al* servidor que ya escribiste. No configuras nada. + +!!! note + Nada, literalmente. No hay una opción `legacy=`, ni una lista de versiones permitidas, ni forma + de rechazar o desactivar una generación: ni en `streamable_http_app()`, ni en `run()`, ni en el + gestor de sesiones. Ambas generaciones están siempre activas. Lo más parecido a un interruptor + por generación en esa firma es `stateless_http`, y ocupa la mayor parte de esta página. + +## Un handler, ambas generaciones {#one-handler-both-eras} + +Aquí tienes una herramienta que necesita preguntarle algo al usuario, y clientes de ambas generaciones que la llaman: + +```python title="server.py" hl_lines="24 37-38" +--8<-- "docs_src/legacy_clients/tutorial001.py" +``` + +`reserve` necesita una cosa que el modelo no proporcionó: cuántos ejemplares. `Annotated[..., Resolve(ask_quantity)]` es la forma en que una herramienta lo declara (**[Dependencias](../handlers/dependencies.md)** tiene todos los detalles). Nada en `reserve` nombra una versión, comprueba una capacidad ni se bifurca. + +Los dos clientes están abiertos **al mismo tiempo**, sobre el mismo objeto `mcp`. `mode="legacy"` ejecuta el handshake `initialize`: exactamente la conexión que abre un cliente anterior a 2026. El otro toma el valor por defecto y queda en `2026-07-28`. + +```text +2025-11-25 {'result': "Reserved 2 of 'Dune'."} +2026-07-28 {'result': "Reserved 2 of 'Dune'."} +``` + +Mismo servidor, mismo handler, misma respuesta. Esa es toda la funcionalidad. + +Vale la pena detenerse en el *cómo*, porque a los dos clientes se les hizo la misma pregunta por dos canales completamente distintos. La conexión `2026-07-28` no tiene un canal por el que el servidor pueda enviar una solicitud, así que `Resolve` devolvió la pregunta dentro del resultado de la herramienta y el cliente reintentó la llamada con la respuesta (**[Solicitudes de varias idas y vueltas](../handlers/multi-round-trip.md)**). La conexión `2025-11-25` no tiene nada de eso; ahí, `Resolve` envió una solicitud `elicitation/create` en vivo a mitad de la llamada y esperó. No escribiste ninguna de las dos cosas. `Resolve` lee la versión negociada de la conexión y elige; el cuerpo de tu herramienta ve un `AcceptedElicitation` en ambos casos. + +!!! tip + Esa portabilidad entre generaciones es la *razón* por la que `Resolve` es la API sobre la que + conviene construir. Su hermano mayor, `ctx.elicit()` (**[Elicitación](../handlers/elicitation.md)**), + solo envía `elicitation/create`, así que solo funciona en una conexión heredada. En una + `2026-07-28` la llamada falla. Si una herramienta todavía lo usa, la solución es la que ves + arriba, no una comprobación de versión. + +## Lo que te cuesta una sesión heredada {#what-a-legacy-session-costs-you} + +El enrutamiento es gratis. La sesión no. + +Una conexión `2026-07-28` **no tiene sesión**: cada solicitud es independiente, y el handler moderno nunca emite un `Mcp-Session-Id`. Una conexión heredada es lo contrario. En el momento en que un cliente anterior a 2026 envía `initialize`, el SDK genera un `Mcp-Session-Id`, lo devuelve en un encabezado de respuesta y mantiene detrás de él un registro vivo que las solicitudes posteriores del cliente deben encontrar: la versión negociada, los streams abiertos, una tarea en segundo plano que mueve la sesión. + +Ese registro es **un simple `dict` dentro del proceso**. No hay un almacén de sesiones distribuido ni forma de conectar uno. + +Con un solo worker eso no se nota. Con dos, es todo el problema: una solicitud que trae un `Mcp-Session-Id` y cae en un worker que no lo generó no encuentra nada en ese dict, y la respuesta es un `404` (`Session not found`), no el resultado de la herramienta. Así que en cuanto ejecutas más de un worker, **los clientes heredados necesitan enrutamiento sticky (afinidad de sesión)**: cada solicitud de una sesión tiene que llegar al proceso que la inició. Los clientes modernos nunca lo necesitan; no tienen una sesión a la que mantenerse pegados. **[Desplegar y escalar](deploy.md)** cubre la afinidad y todo lo demás sobre ejecutar más de uno de estos. + +!!! warning + `event_store=` parece la solución y no lo es. Es **reanudabilidad** (reenviar los eventos SSE + perdidos a un cliente que se reconecta a la *misma* sesión), no un almacén de sesiones. Nunca + hace que una sesión sea alcanzable desde otro proceso. + +## El único ajuste: `stateless_http` {#the-one-knob-stateless_http} + +Si la afinidad es un costo que te niegas a pagar, hay exactamente una cosa que puedes cambiar. + +```python title="server.py" hl_lines="28" +--8<-- "docs_src/legacy_clients/tutorial002.py" +``` + +Es el servidor del principio de la página más un argumento nombrado. `stateless_http=True` hace que el tramo heredado construya en su lugar una sesión desechable, por solicitud: no se emite ningún `Mcp-Session-Id`, no se recuerda nada entre solicitudes, así que cualquier worker puede atender cualquier solicitud y el balanceador de carga puede hacer lo que quiera. + +Dos cosas sobre él importan más que lo que hace. + +**Solo afecta al tramo heredado.** Las solicitudes se enrutan según el encabezado de versión *antes* de que se lea `stateless_http`, así que la ruta moderna nunca lo ve. Una conexión `2026-07-28` ya no tiene sesión y es exactamente igual con cualquiera de los dos valores. + +**Cuesta los dos canales de servidor a cliente en ese tramo.** Una sesión que vive lo que dura un `POST` no tiene un stream por el que el servidor pueda enviar una solicitud ni un stream independiente por el que enviar notificaciones. Cada solicitud iniciada por el servidor lanza `NoBackChannelError`: `ctx.elicit()`, las llamadas retiradas de muestreo (sampling) y roots (**[Funcionalidades obsoletas](../deprecated.md)**) y, sí, `Resolve` cuando le hace su pregunta a un cliente *heredado*. Las notificaciones ni siquiera reciben un error; se descartan en silencio. + +!!! note + `json_response=True` no es ese ajuste, pero asume la mitad del mismo costo en *cada* sesión + heredada: un `POST` respondido con un único cuerpo JSON no tiene stream para el canal ligado a + la solicitud, así que un `ctx.elicit()` a mitad de solicitud lanza el mismo `NoBackChannelError` + y las notificaciones ligadas a la solicitud se descartan. El stream independiente de la sesión no + se toca: las notificaciones no relacionadas siguen llegando. + +!!! check + Haz lo incorrecto. `reserve` es exactamente la herramienta que acaba de atender a ambos clientes. + Despliégala con `stateless_http=True`, conecta los mismos dos clientes por HTTP y llámala desde + cada uno. + + El cliente moderno sigue recibiendo `Reserved 2 of 'Dune'.` El tramo moderno no cambió. + + La llamada del cliente heredado no vuelve como un resultado `is_error` que el modelo pueda leer. + La solicitud entera falla, como un error de protocolo de nivel superior: + + ```text + mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests. + ``` + + `Resolve` no te salvó. En una conexión `2025-11-25` *tiene* que enviar `elicitation/create`, + y el canal que necesita es justo lo que `stateless_http=True` entregó. El código portable entre + generaciones no es código libre de canal de retorno (back-channel). + +Así que es una concesión real, y solo existe en el tramo heredado: **con sesión y afinidad, o sin estado y en una sola dirección.** Si tus herramientas nunca llaman de vuelta al cliente, `stateless_http=True` es gratis y deberías usarlo. Si lo hacen, conserva las sesiones y mantén el enrutamiento con afinidad. + +## Dónde se bifurca realmente tu código {#where-your-code-actually-forks} + +Casi en ningún sitio. + +Herramientas, recursos, prompts, salida estructurada, progreso, errores: a ninguno le importa qué generación hizo la llamada. El handshake `initialize`, el `Mcp-Session-Id`, el stream independiente, el `DELETE` que termina una sesión: el SDK se encarga de todo, y un handler nunca ve nada de eso. La entrada interactiva es *el* lugar donde las generaciones difieren de verdad en lo que se transmite, y `Resolve` existe para que no sea tu problema: acabas de ver a una sola herramienta atender a ambas. + +Queda exactamente una cosa, y son las **notificaciones de cambio**, porque las dos generaciones escuchan por conductos distintos: + +* Un cliente `2026-07-28` abre un stream `subscriptions/listen` y lee el bus de suscripciones. `ctx.notify_resource_updated()` (y `notify_tools_changed()`, `notify_prompts_changed()`, `notify_resources_changed()`) publican ahí, y *solo* ahí. **[Suscripciones](../handlers/subscriptions.md)** es esa página. +* Un cliente heredado lee el stream independiente que su sesión mantiene abierto. `ctx.session.send_resource_updated()` (y `send_tool_list_changed()` y compañía) escriben en la *conexión* que trajo la solicitud: para una sesión heredada, ese es su stream independiente. Una conexión moderna no tiene dónde ponerlo: por HTTP no existe ese canal, y por stdio los cuatro tipos de notificación de cambio viajan solo por streams `subscriptions/listen`, así que en una conexión moderna la notificación se descarta en silencio. + +Por HTTP, ninguna de las dos llamadas alcanza a los clientes de la otra generación. Para avisar a todos, llama a ambas: + +```python title="server.py" hl_lines="19-20" +--8<-- "docs_src/legacy_clients/tutorial003.py" +``` + +Dos líneas, sin `if`, sin comprobación de versión, y listo. Esa es la lista completa de cosas que un handler hace distinto porque existe un cliente heredado. + +## Resumen {#recap} + +* Un solo `streamable_http_app()` atiende a ambas generaciones del protocolo. El SDK enruta cada solicitud según su encabezado `MCP-Protocol-Version`; no hay nada que configurar ni ningún ajuste de generación que buscar. +* Un cliente heredado te cuesta una sesión: un registro `Mcp-Session-Id` dentro del proceso sin ningún almacén distribuido detrás. Más de un worker significa **enrutamiento sticky**, o el worker equivocado responde `404 Session not found`. **[Desplegar y escalar](deploy.md)** tiene todos los detalles sobre varios workers. +* `stateless_http=True` es el único ajuste, y es **solo para el tramo heredado**. Compra balanceo de carga gratis para los clientes heredados al precio de los dos canales de servidor a cliente en ese tramo: las solicitudes iniciadas por el servidor lanzan `NoBackChannelError` (un error de nivel superior en el cliente, no un resultado `is_error`), y las notificaciones se descartan. +* Una conexión `2026-07-28` no tiene sesión en ningún caso. `stateless_http` nunca la toca. +* El código de tu handler se bifurca por generación en exactamente un lugar: las notificaciones de cambio. `ctx.notify_*` llega a los clientes de `subscriptions/listen`; `ctx.session.send_*` llega a las sesiones heredadas. Llama a ambas. +* Todo lo demás (incluido pedirle datos al usuario, mediante `Resolve`) es portable entre generaciones por construcción. Escribe la versión moderna una sola vez. diff --git a/i18n/es/pages/run/opentelemetry.md b/i18n/es/pages/run/opentelemetry.md new file mode 100644 index 0000000000..97bb90d241 --- /dev/null +++ b/i18n/es/pages/run/opentelemetry.md @@ -0,0 +1,113 @@ +--- +translation: + sections: [bc0227014724fa49, 15738c2f7fd67d86, a2c17bbe3f707e2f, d0d853376f162c06, b6368643fcc1c8d8, 902e33e17564a607] + tool: 1 +--- +# OpenTelemetry {#opentelemetry} + +Tu servidor ya está trazado. No tienes que añadir nada. + +Cada servidor que creas emite un span de [OpenTelemetry](https://opentelemetry.io/) por cada +mensaje que maneja. No lo escribiste ni lo importas. Está ahí desde el momento en que +llamas a `MCPServer(...)`. + +```python title="server.py" +--8<-- "docs_src/opentelemetry/tutorial001.py" +``` + +Ese es un servidor completo y trazado. Llama a `search_books` y se crea un span para esa llamada. +Lo mismo vale para el `Server` de bajo nivel: el trazado vive en ambos. + +## Qué obtienes {#what-you-get} + +Cada mensaje entrante se convierte en un span `SERVER` con el nombre del método y su destino. Así, +un `tools/call` para `search_books` es el span `tools/call search_books`, y un `tools/list` a secas +es simplemente `tools/list`. + +Cada span lleva unos cuantos atributos: + +* `mcp.method.name` y `mcp.protocol.version`, en todos los spans. +* `jsonrpc.request.id`, en una solicitud (una notificación no tiene). +* Un handler que lanza una excepción marca el estado del span como error. Lo mismo hace un resultado de herramienta con `is_error=True`. + +Y como trazar una llamada a herramienta es algo que se quiere muy a menudo, los spans de `tools/call` +siguen las [convenciones semánticas GenAI](https://opentelemetry.io/docs/specs/semconv/gen-ai/) de OpenTelemetry: + +* `gen_ai.operation.name`, con el valor `"execute_tool"`. +* `gen_ai.tool.name`, con el nombre de la herramienta que se llama. + +Un span de `prompts/get` recibe `gen_ai.prompt.name` con la misma idea. Los métodos de listado no +llevan claves `gen_ai.*`, porque no hay nada que nombrar. + +!!! tip + Esos atributos GenAI son la razón por la que una interfaz de trazas agrupa tus llamadas a + herramientas igual que agrupa las de cualquier otro agente. Obtienes esa agrupación gratis, + sin código extra. + +## No cuesta nada hasta que lo quieras {#it-costs-nothing-until-you-want-it} + +Esta es la parte que hace que "activado por defecto" sea un valor por defecto cómodo. + +El SDK depende solo de `opentelemetry-api`, la mitad ligera de OpenTelemetry. Sin un SDK ni un +exportador instalados, crear un span es una operación nula. Así que los spans que tu servidor está +emitiendo ahora mismo no te cuestan casi nada, y nadie los está recolectando. + +El día que quieras *verlos*, instalas la otra mitad y la apuntas a algún sitio: + +```console +uv add opentelemetry-sdk opentelemetry-exporter-otlp +``` + +Configura un exportador como se hace normalmente en OpenTelemetry, y cada span que el SDK ha estado +creando en silencio se enciende. El código de tu servidor no cambia. Ni una línea. + +!!! info + [Pydantic Logfire](https://logfire.pydantic.dev/) es uno de esos backends, y hace la + configuración por ti: `pip install logfire`, `logfire.configure()`, y tus spans de MCP aparecen + en la vista en vivo. Está construido sobre OpenTelemetry, así que todo lo que sigue también se aplica a él. + +## Trazas que cruzan el canal {#traces-that-cross-the-wire} + +Una traza es más útil cuando sigue una solicitud desde el cliente hasta el servidor, en una sola +imagen conectada. + +Cuando el cliente y el servidor ejecutan ambos el SDK, esa conexión es automática. El cliente inyecta +el [contexto de traza W3C](https://www.w3.org/TR/trace-context/) en la solicitud, y el servidor lo +lee de vuelta, de modo que el span del servidor queda anidado bajo el span del cliente en la misma +traza. Esto es [SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414), y lo +obtienes sin pedirlo. + +Si el mensaje entrante no lleva contexto de traza, por ejemplo una solicitud de un cliente que no es +el SDK, el span del servidor simplemente toma como padre el span que ya esté activo en el servidor, +en lugar de iniciar una traza huérfana nueva. + +## Desactivarlo {#turning-it-off} + +El trazado es un middleware, el primero de la lista de tu servidor. Si de verdad quieres un servidor +que no emita spans, quítalo: + +```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 + Ese import lleva un guion bajo inicial, y es a propósito. La clase es provisional, igual que + [`Server.middleware`](../advanced/middleware.md) es provisional, así que debes contar con que la + ruta de importación cambie. Casi nunca necesitas esto: sin un exportador instalado los spans son + gratis, así que la respuesta habitual es dejarlos activados y no instalar un exportador. + +## Resumen {#recap} + +* Cada `MCPServer` y cada `Server` de bajo nivel emite un span `SERVER` por mensaje entrante, por + defecto. No escribes nada. +* Los spans llevan `mcp.method.name` y `mcp.protocol.version`; `tools/call` y `prompts/get` también + llevan atributos GenAI para que tus llamadas a herramientas se agrupen como las de cualquier otro agente. +* No cuesta nada hasta que instalas un SDK de OpenTelemetry y un exportador, y entonces se enciende + sin ningún cambio en tu servidor. +* El contexto de traza de cliente a servidor se propaga automáticamente cuando ambos lados ejecutan el SDK. + +Lo que decide si una solicitud se ejecuta o no es la **[Autorización](authorization.md)**. diff --git a/i18n/es/pages/servers/completions.md b/i18n/es/pages/servers/completions.md new file mode 100644 index 0000000000..2ed313ff23 --- /dev/null +++ b/i18n/es/pages/servers/completions.md @@ -0,0 +1,131 @@ +--- +translation: + sections: [72f9c964769076dd, 9a2c14e10935b515, 235299eb78ab12d7, 8aee1e78c8237fb8, 9bd86acd4112138f, 55343cb7f250dc7b] + tool: 1 +--- +# Autocompletado {#completions} + +Un cliente que construye una interfaz sobre tu servidor quiere autocompletar los valores de los argumentos mientras el usuario escribe: nombres de lenguajes, nombres de repositorios, rutas de archivos. + +El **autocompletado** (completions) es la forma en que tu servidor proporciona esas sugerencias. + +## Algo que valga la pena completar {#something-worth-completing} + +El autocompletado se aplica exactamente a dos cosas: los argumentos de un **prompt** y los parámetros de una **plantilla de recurso**. Así que empieza con un servidor que tenga uno de cada: + +```python title="server.py" hl_lines="6 12" +--8<-- "docs_src/completions/tutorial001.py" +``` + +Aquí todavía no hay nada de autocompletado. + +* `review_code` recibe un `language`. Un usuario no debería tener que adivinar qué formas de escribirlo aceptas. +* `github_repo` recibe un `owner` y un `repo`. Dos campos de texto libre hacen un mal formulario. + +## El handler de autocompletado {#the-completion-handler} + +Añade **una** función decorada con `@mcp.completion()`: + +```python title="server.py" hl_lines="21-29" +--8<-- "docs_src/completions/tutorial002.py" +``` + +* Hay un handler por servidor. Todas las solicitudes de autocompletado llegan aquí, y tú decides qué hacer según lo que se esté completando. +* Debe ser `async def`: el SDK lo espera con await. +* Recibe tres argumentos: + * `ref`: *qué* prompt o plantilla de recurso, como `PromptReference` o `ResourceTemplateReference`. Con `isinstance` los distingues. + * `argument`: `argument.name` es el argumento que se está completando, `argument.value` es lo que el usuario ha escrito hasta ahora. + * `context`: los argumentos ya resueltos. Ignóralo por ahora. +* Devuelves un `Completion(values=[...])`, o `None` cuando no tienes nada que ofrecer. + +!!! tip + `argument.value` es el prefijo que el usuario ha escrito. El SDK **no** filtra por ti: lo que + pongas en `values` es lo que muestra la interfaz. El `startswith` lo escribes tú. + +### Pruébalo {#try-it} + +Manéjalo con el `Client` en memoria de **[Pruebas](../get-started/testing.md)**. Llama a +`client.complete()` con `ref=PromptReference(name="review_code")` y +`argument={"name": "language", "value": "py"}`: + +```python +result.completion.values # ['python'] +``` + +* `ref` es el mismo tipo de referencia que recibe tu handler. +* `argument` es un dict normal con exactamente dos claves, `name` y `value`. + +Envía un `value` vacío y te devuelve la lista completa. `lang.startswith("")` es verdadero para todos los lenguajes: + +```python +result.completion.values # ['go', 'javascript', 'python', 'rust', 'typescript'] +``` + +Pregunta por `code` (un argumento que tu handler no reconoce) y devuelve `None`, que el SDK convierte en una lista vacía: + +```python +result.completion.values # [] +``` + +`None` significa *"sin sugerencias"*, nunca un error. La interfaz recurre a un campo de texto normal. + +## Una capacidad que nunca declaraste {#a-capability-you-never-declared} + +Registrar el handler es la declaración. Conecta un cliente y mira: + +```python +client.server_capabilities.completions # CompletionsCapability() +``` + +No escribiste `completions` en ninguna parte. El SDK vio el handler y declaró la capacidad por ti. Todas las capacidades *opcionales* funcionan así: el handler es la declaración. (Las tres primitivas no son opcionales: `MCPServer` siempre las declara, haya handlers o no.) + +!!! check + Vuelve al primer `server.py` (el que no tiene handler) y pregúntale de todos modos. La llamada + falla con un error JSON-RPC: + + ```text + Method not found + ``` + + Y `client.server_capabilities.completions` es `None`. Ese es el sentido de la capacidad: un + cliente bien hecho la comprueba y nunca envía la solicitud que no puedes responder. + +## Argumentos dependientes {#dependent-arguments} + +`github://repos/{owner}/{repo}` tiene dos parámetros, y los valores útiles para `repo` dependen de qué `owner` se eligió primero. + +Para eso sirve `context`. Lleva los argumentos que el usuario **ya ha resuelto**: + +```python title="server.py" hl_lines="8-11 34-38" +--8<-- "docs_src/completions/tutorial003.py" +``` + +* La nueva rama se activa para el parámetro `repo` de la plantilla. +* `context.arguments` es un `dict[str, str] | None` con los valores elegidos hasta ahora (aquí, `owner`). +* Si todavía no hay `owner`, no hay sugerencias sensatas, así que el handler devuelve `None`. + +El cliente envía esos valores resueltos con `context_arguments=`. Esta vez `ref` es un +`ResourceTemplateReference(uri="github://repos/{owner}/{repo}")`. Pide `repo` con un +`value` vacío y pasa `context_arguments={"owner": "modelcontextprotocol"}`: + +```python +result.completion.values # ['python-sdk', 'typescript-sdk', 'inspector'] +``` + +Quita `context_arguments=` y la misma llamada devuelve `[]`. El handler no puede saber qué repositorios ofrecer hasta que conoce el propietario. + +!!! info + `Completion` también acepta `total=` y `has_more=`. Úsalos cuando `values` sea un fragmento de + una lista más larga, para que la interfaz pueda mostrar *"y 200 más"*. La mayoría de los + handlers nunca los necesitan. + +## Resumen {#recap} + +* El autocompletado son sugerencias para **argumentos de prompts** y **parámetros de plantillas de recurso**. Nada más. +* `@mcp.completion()` registra el único handler. Es `async def (ref, argument, context) -> Completion | None`. +* Decide según `isinstance(ref, ...)` y `argument.name`. Filtra por `argument.value` tú mismo. +* `None` se convierte en una lista vacía. Nunca es un error. +* `context.arguments` contiene los valores ya resueltos; el cliente los proporciona como `context_arguments=`. +* La capacidad `completions` aparece en cuanto registras el handler. Sin él, la solicitud da `Method not found`. + +Las sugerencias ayudan mientras el usuario todavía está *rellenando* un prompt o una plantilla; para hacerle una pregunta en *mitad* de una llamada a una herramienta, lo que quieres es **[Elicitación](../handlers/elicitation.md)**. Todo lo que una herramienta puede devolver además de texto está en **[Imágenes, audio e iconos](media.md)**. diff --git a/i18n/es/pages/servers/handling-errors.md b/i18n/es/pages/servers/handling-errors.md new file mode 100644 index 0000000000..f417a5c06c --- /dev/null +++ b/i18n/es/pages/servers/handling-errors.md @@ -0,0 +1,140 @@ +--- +translation: + sections: [e33d441f12d50535, 7099694c603e0f5f, c1df4cf9673433e6, c9cd294541422e6e, 6cec073617bfd037, efa92b8f99e908c8, 6a22a29e27fb4601] + tool: 1 +--- +# Manejo de errores {#handling-errors} + +Una herramienta puede fallar de dos maneras, y el SDK las trata de forma muy distinta. + +Lanza una excepción ordinaria y la ve el **modelo**. Lanza `MCPError` y la ve el **protocolo**. + +Esta página trata de cómo elegir. + +## Un error que el modelo puede corregir {#an-error-the-model-can-fix} + +Toma una herramienta que busca algo, y deja que la búsqueda falle: + +```python title="server.py" hl_lines="11-12" +--8<-- "docs_src/handling_errors/tutorial001.py" +``` + +No hay nada de MCP en esas dos líneas. `get_author` lanza un `ValueError` común y corriente, como lo haría cualquier función de Python. + +Llámala con un título que no esté en el catálogo y observa el resultado: + +```python +result.is_error # True +result.content # [TextContent(text="Error executing tool get_author: No book titled 'Nothing' in the catalog.")] +result.structured_content # None +``` + +* La solicitud **tuvo éxito**. Hay un resultado; no se lanzó nada del lado de quien llama. +* `is_error` es `True`, y el mensaje de tu excepción (con el nombre de la herramienta como prefijo) está en `content`, justo donde lee el modelo. +* `structured_content` es `None`. Una llamada fallida no tiene valor devuelto que estructurar. + +Esto es un **error de herramienta**, y es el comportamiento por defecto para *cualquier* excepción que lance tu herramienta. Además, casi siempre es lo que quieres. + +El modelo es quien llama a tu herramienta. Él eligió los argumentos. Así que un error de herramienta es un turno de la conversación: el modelo lee *"No book titled 'Nothing' in the catalog."*, se da cuenta de que adivinó mal el título y vuelve a llamar con uno mejor. Escribiste un `raise` y obtuviste un agente que se corrige solo. + +!!! tip + Nunca devuelvas con `return` un mensaje de error desde una herramienta. Una cadena devuelta + tiene `is_error=False`, así que para el modelo (y para toda interfaz de cliente) parece que la + herramienta funcionó y que esa cadena era la respuesta. Usa `raise`. El indicador es la señal. + +## Un error que el modelo no puede corregir {#an-error-the-model-cannot-fix} + +Ahora cambia `ValueError` por `MCPError`. + +```python title="server.py" hl_lines="1 3 14" +--8<-- "docs_src/handling_errors/tutorial002.py" +``` + +`MCPError` es el **error de protocolo** del SDK. Es la única excepción que el envoltorio de la herramienta *no* captura: se propaga, y toda la solicitud `tools/call` falla con un error JSON-RPC en lugar de un resultado. + +```json +{ + "code": -32602, + "message": "No book titled 'Nothing' in the catalog." +} +``` + +* **No hay resultado**. No hay `content` ni `is_error`: nada que el modelo pueda leer. +* En su lugar, el error lo recibe la aplicación **host**, igual que si la herramienta no existiera. +* `code`, `message` y `data` llegan intactos. `INVALID_PARAMS` es `-32602`; `mcp.types` lo exporta, junto con los demás códigos de error JSON-RPC (`INVALID_REQUEST`, `INTERNAL_ERROR`, ...), como constantes para que nunca escribas un número mágico. + +!!! check + La misma búsqueda, el mismo fallo, pero ahora la llamada *lanza* una excepción del lado del cliente en lugar de devolver un resultado: + + ```text + mcp.shared.exceptions.MCPError: No book titled 'Nothing' in the catalog. + ``` + + La primera versión le entregaba al modelo una frase a la que podía reaccionar. Esta no le + entrega nada. Para `get_author` eso es estrictamente peor, y de eso trata la siguiente sección. + +## Cuál lanzar {#which-one-to-raise} + +Los dos caminos responden a dos preguntas distintas. + +* **Lanza cualquier excepción** ante un fallo de *ejecución*: lo que tu herramienta intentó hacer no funcionó. El modelo eligió la llamada, así que el modelo debería ver la consecuencia y tener la oportunidad de recuperarse. Un título mal escrito, una API externa que agotó el tiempo de espera, una fila que no existe: todos son errores de herramienta. +* **Lanza `MCPError`** cuando debe rechazarse la *solicitud misma*: al cliente le falta una capacidad de la que depende tu herramienta, el servidor no está en condiciones de atender a nadie, quien llama se saltó un paso obligatorio. Ningún reintento del modelo arregla nada de eso, así que no se gana nada entregándole el mensaje. + +Una sola pregunta lo decide: **¿podría haberlo evitado un modelo más inteligente?** Sí -> excepción ordinaria. No -> `MCPError`. + +Según ese criterio, la segunda versión de `get_author` eligió mal: un título mejor lo arregla, así que el modelo merecía ver el mensaje. Está ahí para mostrarte el mecanismo, no para recomendarlo. + +!!! info + `MCPError` se importa con `from mcp import MCPError` y recibe `code`, `message` y un payload + opcional `data`. Lo que pongas en ellos es lo que recibe el cliente: el SDK reenvía un + `MCPError` lanzado tal cual, en lugar de sanearlo. + +## Un recurso que no existe {#a-resource-that-doesnt-exist} + +Los recursos trazan la misma línea, e incluyen una excepción con nombre propio para el caso común. + +```python title="server.py" hl_lines="2 13" +--8<-- "docs_src/handling_errors/tutorial003.py" +``` + +`books://{title}` es una **plantilla**. Coincide con *cualquier* título, así que "la URI está bien formada" y "el libro existe" son dos preguntas distintas, y solo tu función puede responder la segunda. + +Cuando no pueda, lanza `ResourceNotFoundError`. El SDK lo convierte en el error de protocolo que la especificación asigna a un recurso que falta: `-32602` con la URI solicitada en `data`, para que el cliente sepa *cuál* lectura falló. + +```json +{ + "code": -32602, + "message": "No book titled 'Nothing' in the catalog.", + "data": {"uri": "books://Nothing"} +} +``` + +Fíjate en que aquí no hay un resultado a medias con `is_error=True`. La lectura de un recurso devuelve contenido o falla: los recursos solo tienen el camino del protocolo. Las plantillas y todo lo demás sobre recursos están en **[Recursos](resources.md)**. + +## Errores que nunca lanzas {#errors-you-never-raise} + +Un argumento incorrecto nunca llega a tu función. + +Envíale a `get_author` un `title` que no sea una cadena y el SDK lo rechaza contra el esquema de entrada **antes** de llamarte, como el mismo tipo de error de herramienta con `is_error=True` que el modelo puede leer y corregir. **[Herramientas](tools.md)** muestra el mismo rechazo con una restricción `Field(le=50)`. + +Eso significa toda una clase de sentencias `raise` que no escribes: no vuelvas a validar tus propias anotaciones de tipo. + +!!! info + Todo lo de esta página es lo que ve un **cliente**, y el `Client` en memoria con el que + escribirás pruebas ve exactamente lo mismo. Ni siquiera `raise_exceptions=True` convierte un + error de herramienta de nuevo en un traceback: para cuando ese indicador podría actuar, tu + excepción ya es el resultado con `is_error=True`. Haz las aserciones sobre el resultado. + **[Pruebas](../get-started/testing.md)** cubre el patrón. + +## Resumen {#recap} + +* Lanza **cualquier excepción** en una herramienta -> la llamada devuelve `is_error=True` con tu mensaje en `content`. El modelo lo lee y puede reintentar. Este es el comportamiento por defecto. +* Lanza **`MCPError`** -> la llamada misma falla con un error JSON-RPC. El modelo no ve nada; el host se encarga. `code`, `message` y `data` sobreviven intactos. +* La pregunta decisiva: *¿podría haberlo evitado un modelo más inteligente?* Sí -> excepción. No -> `MCPError`. +* `ResourceNotFoundError` desde un handler de recurso -> el `-32602` del protocolo, con la URI en `data`. +* Los argumentos incorrectos se rechazan contra el esquema antes de que se ejecute tu función; para esos no usas `raise`. +* `from mcp import MCPError`; las constantes de códigos de error vienen de `mcp.types`. + +Errores resueltos. Eso es todo lo que un servidor *expone*. Lo que cada handler puede leer, y hacer de vuelta hacia el cliente mientras se ejecuta, es la siguiente sección: **[Dentro de tu handler](../handlers/index.md)**. + +El texto exacto de los errores del SDK que es más probable que encuentres, qué significa cada uno y la solución de un solo paso para cada uno están en **[Solución de problemas](../troubleshooting.md)**. diff --git a/i18n/es/pages/servers/index.md b/i18n/es/pages/servers/index.md new file mode 100644 index 0000000000..e021f74248 --- /dev/null +++ b/i18n/es/pages/servers/index.md @@ -0,0 +1,35 @@ +--- +translation: + sections: [09defc170a0da89d] + tool: 1 +--- +# Servidores {#servers} + +Un `MCPServer` expone tres primitivas a un cliente conectado. Se diferencian en quién +decide usarlas: + +* Una **[herramienta](tools.md)** es una acción que el *modelo* elige y llama. Esta es + la página que la mayoría quiere leer primero, y + **[Salida estructurada](structured-output.md)** es su referencia complementaria: + todo sobre la forma de lo que devuelve una herramienta. +* Un **[recurso](resources.md)** son datos de solo lectura que la *aplicación* + decide leer. **[Plantillas de URI](uri-templates.md)** es su referencia + complementaria: la sintaxis de direccionamiento completa y las reglas de seguridad de rutas. +* Un **[prompt](prompts.md)** es una plantilla de mensaje que una *persona* invoca por + nombre, desde un menú o un comando de barra. + +En torno a las tres primitivas, el resto de lo que declara un servidor: + +* **[Autocompletado](completions.md)** es el autocompletado del lado del servidor para los + argumentos de prompts y de plantillas de recursos. +* **[Imágenes, audio e iconos](media.md)** cubre todo lo que una herramienta puede + devolver además de texto, y los iconos que un cliente muestra junto al servidor. +* **[Manejo de errores](handling-errors.md)** explica la diferencia entre un + error del que el modelo puede recuperarse y uno que nunca debe ver. + +Cada página de esta sección se sostiene por sí sola; ve directamente a la que necesites. Si aún no +has creado un servidor, empieza por **[Primeros pasos](../get-started/first-steps.md)**. + +Lo que ocurre *dentro* de las funciones que registras (el `Context`, la inyección de dependencias, +pedirle al usuario más información a mitad de la llamada) es la siguiente sección, +**[Dentro de tu handler](../handlers/index.md)**. diff --git a/i18n/es/pages/servers/media.md b/i18n/es/pages/servers/media.md new file mode 100644 index 0000000000..a19451c00e --- /dev/null +++ b/i18n/es/pages/servers/media.md @@ -0,0 +1,122 @@ +--- +translation: + sections: [496394d24d221bf1, 4ceb4591180dc6c3, 0fd63e4682d02e0c, 969ede0bd3686a16, 043f526230dd243d, 6ee3e9bcfd24047a] + tool: 1 +--- +# Multimedia {#media} + +El texto no es lo único que puede devolver una herramienta. + +El SDK incluye dos utilidades para resultados binarios (**`Image`** y **`Audio`**) y un tipo **`Icon`** para darles a tu servidor, herramientas, recursos y prompts una cara visible en la interfaz del cliente. + +## Devolver una imagen {#returning-an-image} + +Anota el tipo de retorno como `Image`, apúntalo a un archivo y devuélvelo: + +```python title="server.py" hl_lines="8 12 14" +--8<-- "docs_src/media/tutorial001.py" +``` + +* `Image` acepta exactamente uno de los dos: `path` (un archivo que leer) o `data` (bytes en bruto). +* El tipo MIME que ve el cliente se deduce del sufijo: `logo.png` se anuncia como `image/png`. +* No hay nada especial en que sea un logo. Cualquier PNG junto a `server.py` sirve: una gráfica que generó tu código, un diagrama, una foto. + +`Image` es una comodidad del SDK, no un tipo del protocolo. En lo que se transmite, el valor devuelto se convierte en un bloque **`ImageContent`** (los bytes del archivo codificados en base64, más el tipo MIME): + +```python +result.content # [ImageContent(type="image", data="iVBORw0KGgoAAAANSUhEUg...", mime_type="image/png")] +result.structured_content # None +``` + +Dos cosas que notar: + +* `data` está en base64. Nunca tocaste los bytes; el SDK leyó el archivo e hizo la codificación. +* `structured_content` es `None`. Un `Image` es contenido para que lo mire el modelo, no datos para que los analice la aplicación: no hay esquema de salida. (Compara con **[Salida estructurada](structured-output.md)**, donde la anotación de retorno *es* el esquema.) + +!!! info + `ImageContent` y `AudioContent` viven en `mcp.types`, justo al lado del `TextContent` + en el que se convierte un resultado `str` simple (**[Herramientas](tools.md)**). El resultado de una herramienta es una lista de bloques de contenido; `Image` y `Audio` son + la forma más corta de producir los dos tipos binarios. + +### Pruébalo {#try-it} + +Coloca cualquier PNG junto a `server.py`, llámalo `logo.png` y ejecuta: + +```console +uv run mcp dev server.py +``` + +Abre la pestaña **Tools** y llama a `logo`. El resultado no es una cadena: es un bloque de contenido `image`, y el Inspector muestra tu imagen. Todo lo que hay entre el archivo en disco y los píxeles en pantalla lo hizo el SDK. + +## Devolver audio {#returning-audio} + +`Audio` tiene la misma forma. Deja `logo.png` donde estaba y pon cualquier WAV a su lado como `chime.wav`: + +```python title="server.py" hl_lines="18-21" +--8<-- "docs_src/media/tutorial002.py" +``` + +El resultado es un bloque **`AudioContent`**: + +```python +result.content # [AudioContent(type="audio", data="UklGR...", mime_type="audio/wav")] +result.structured_content # None +``` + +Lo mismo: entra un archivo en disco, salen base64 y un tipo MIME, sin esquema de salida. + +## Bytes o un archivo {#bytes-or-a-file} + +Ambas utilidades aceptan también `data=` (bytes en bruto) en lugar de `path=`. Ese es el modo para los bytes que nunca vinieron de un archivo propio: una columna de base de datos, una respuesta HTTP, algo que Pillow acaba de dibujar: + +```python title="server.py" hl_lines="14 15" +--8<-- "docs_src/media/tutorial003.py" +``` + +Con `path=` no hay nada que declarar: el archivo se lee cuando se construye el resultado y el tipo MIME se deduce del sufijo: + +* `Image`: `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`. +* `Audio`: `.wav`, `.mp3`, `.ogg`, `.flac`, `.aac`, `.m4a`. + +Un sufijo que no reconoce recurre a `application/octet-stream`. + +!!! check + Con `data=` no hay nombre de archivo, así que no hay nada de lo que deducir. Olvida `format=` y + el SDK recurre a un valor por defecto: `image/png` para imágenes, `audio/wav` para audio. Construye un + `Audio` así a partir de bytes MP3 y al cliente se le dice `mime_type="audio/wav"`, y entonces + falla fielmente al decodificarlo. Cuando pases `data=`, pasa `format=`. + +## Iconos {#icons} + +Un `Icon` es metadatos, no contenido. No lleva la imagen; apunta a una con una URI, y el cliente puede descargarla y mostrarla junto al nombre de tu servidor, una herramienta, un recurso o un prompt. + +```python title="server.py" hl_lines="4-5 7 10 16" +--8<-- "docs_src/media/tutorial004.py" +``` + +* `src` es una URI que el cliente puede resolver: `https:`, o una URI `data:` si quieres el icono incrustado sin una descarga extra. +* `mime_type` y `sizes` (`"48x48"`, o `"any"` para un formato escalable) permiten al cliente elegir el adecuado cuando ofreces varios. +* `theme="light"` o `theme="dark"` marca un icono para un esquema de color. + +El mismo argumento nombrado `icons=[...]` lo aceptan `MCPServer(...)`, `@mcp.tool()`, `@mcp.resource()` y `@mcp.prompt()`. + +### Dónde los ve un cliente {#where-a-client-sees-them} + +Los iconos viajan con lo que decoran. Los del servidor llegan cuando el cliente se conecta, en `client.server_info` (opcional en conexiones de la generación 2026, así que acota el tipo primero): + +```python +assert client.server_info is not None # python-sdk servers identify themselves by default +client.server_info.icons # [Icon(src="https://example.com/brand-kit.png", mime_type="image/png", sizes=["48x48"])] +``` + +Los iconos de una herramienta están en el objeto `Tool` de `tools/list`, los de un recurso en el `Resource` de `resources/list`, los de un prompt en el `Prompt` de `prompts/list`. El campo siempre se llama `icons`. + +## Resumen {#recap} + +* Devuelve un `Image` o un `Audio` desde una herramienta y el cliente recibe un bloque `ImageContent` / `AudioContent`: tus bytes codificados en base64, con un tipo MIME. +* Constrúyelo a partir de un `path=` y deja que el sufijo decida el tipo MIME, o a partir de `data=` en memoria más un `format=` explícito. +* Los resultados multimedia no llevan `structured_content` ni esquema de salida. +* Un `Icon` es un puntero: una URI `src` más `mime_type`, `sizes` y `theme` opcionales. +* `icons=[...]` funciona en el servidor, en herramientas, en recursos y en prompts, y los clientes los encuentran en los objetos correspondientes. + +Eso es todo lo que una herramienta puede poner *dentro* de un resultado. Lo que ocurre cuando una herramienta *falla* (y quién debería enterarse) está en **[Manejo de errores](handling-errors.md)**. diff --git a/i18n/es/pages/servers/prompts.md b/i18n/es/pages/servers/prompts.md new file mode 100644 index 0000000000..5004a91874 --- /dev/null +++ b/i18n/es/pages/servers/prompts.md @@ -0,0 +1,156 @@ +--- +translation: + sections: [d65c098f37f5b6c3, dd0c2724d6f2877e, 6835bb3570c6714c, ffe823cb0fedd488, f33651add1b59094] + tool: 1 +--- +# Prompts {#prompts} + +Un **prompt** es una plantilla de mensajes que elige el usuario. + +Las herramientas son para el modelo. Un prompt es lo contrario: el usuario elige uno en un menú de su cliente (un comando de barra, un botón), completa sus argumentos y los mensajes renderizados entran en la conversación como si los hubiera escrito él mismo. + +Para declarar uno, pon `@mcp.prompt()` en una función que devuelva el texto. + +## Tu primer prompt {#your-first-prompt} + +```python title="server.py" hl_lines="6-9" +--8<-- "docs_src/prompts/tutorial001.py" +``` + +El SDK lee las mismas tres cosas que lee de una herramienta: + +* El **nombre** es el nombre de la función: `review_code`. +* La **descripción** que muestra el cliente es el docstring: `Review a piece of code.` +* Los **argumentos** salen de los parámetros. `code` no tiene valor por defecto, así que es obligatorio. + +Esto es lo que recibe un cliente de `prompts/list`: + +```json +{ + "name": "review_code", + "description": "Review a piece of code.", + "arguments": [ + {"name": "code", "required": true} + ] +} +``` + +Aquí no hay JSON Schema. Los argumentos de un prompt son una lista plana de **valores de cadena con nombre**: un formulario que rellena una persona, no un payload que construye un modelo. + +### Renderizarlo {#rendering-it} + +El cliente renderiza la plantilla con `prompts/get`, pasando los argumentos. Tu función se ejecuta y el `str` que devuelves se convierte en **un único mensaje de usuario**: + +```json +{ + "description": "Review a piece of code.", + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "Please review this code:\n\ndef add(a, b): return a + b" + } + } + ], + "resultType": "complete" +} +``` + +Esa es toda la vida de un prompt: se lista por nombre, se renderiza a demanda y se coloca en el chat. + +!!! check + `required` se comprueba antes de que se ejecute tu función. Renderiza `review_code` sin `code` y la + propia solicitud falla con un error JSON-RPC (código `-32603`): + + ```text + mcp.shared.exceptions.MCPError: Internal server error + ``` + + No hay un resultado de error al estilo de las herramientas que devolver a un modelo, porque no hay + ningún modelo en el circuito: la llamada lanza una excepción. El motivo + (`Missing required arguments: {'code'}`) queda en el log del servidor. + +### Pruébalo {#try-it} + +Ejecuta el servidor con el MCP Inspector: + +```console +uv run mcp dev server.py +``` + +Abre la pestaña **Prompts** y selecciona `review_code`. El Inspector dibuja un formulario con un campo obligatorio `code`. Rellénalo, renderízalo y te devuelve exactamente el mensaje de usuario de arriba. + +## Más de un mensaje {#more-than-one-message} + +Una revisión de código es un mensaje. Una sesión de depuración es una conversación, y un prompt puede sembrarla entera. + +Devuelve una lista de mensajes en lugar de un `str`: + +```python title="server.py" hl_lines="2 13-20" +--8<-- "docs_src/prompts/tutorial002.py" +``` + +* `UserMessage` y `AssistantMessage` vienen de `mcp.server.mcpserver.prompts.base`. Dales un `str` y lo envuelven en `TextContent` por ti. El rol es el nombre de la clase. +* `Message` es su base común. Úsala como anotación de retorno. + +Renderizar `debug_error` ahora produce tres mensajes, en orden: + +```json +{ + "description": "Start a debugging conversation.", + "messages": [ + {"role": "user", "content": {"type": "text", "text": "I'm seeing this error:"}}, + {"role": "user", "content": {"type": "text", "text": "TypeError: 'int' object is not iterable"}}, + { + "role": "assistant", + "content": {"type": "text", "text": "I'll help debug that. What have you tried so far?"} + } + ], + "resultType": "complete" +} +``` + +Fíjate en el último. Rellenar de antemano un turno `assistant` es la forma de orientar la *siguiente* respuesta del modelo sin que el usuario tenga que escribir esa orientación. + +## Títulos y descripciones de argumentos {#titles-and-argument-descriptions} + +`review_code` es un nombre de función, no una etiqueta. Dale al cliente algo mejor que poner en el botón y describe cada argumento para que el formulario se explique solo: + +```python title="server.py" hl_lines="10-13" +--8<-- "docs_src/prompts/tutorial003.py" +``` + +* `title="Code review"` es el nombre legible para personas, exactamente igual que el `title` de una herramienta. +* `Annotated[str, Field(description=...)]` es el mismo patrón que usa **[Herramientas](tools.md)** para describir los parámetros de una herramienta. Aquí la descripción va al argumento en lugar de a un esquema. +* `language` tiene valor por defecto, así que deja de ser obligatorio. + +La entrada de `prompts/list` ahora lleva todo lo que un cliente necesita para dibujar un buen formulario: + +```json +{ + "name": "review_code", + "title": "Code review", + "description": "Review a piece of code.", + "arguments": [ + {"name": "code", "description": "The code to review.", "required": true}, + {"name": "language", "description": "The language the code is written in.", "required": false} + ] +} +``` + +!!! info + Si has leído **[Herramientas](tools.md)**, ya sabes todo lo de esta página. El mismo decorador, el mismo + docstring como descripción, el mismo `Annotated`/`Field`. Lo único que cambia es quién + lo dispara (el usuario) y adónde va el resultado (a la conversación). + +## Resumen {#recap} + +* `@mcp.prompt()` en una función la convierte en un prompt. El nombre sale de la función y la descripción del docstring. +* Los prompts están **controlados por el usuario**: el cliente los lista, el usuario elige uno y completa los argumentos. +* Los argumentos son una lista plana de cadenas con nombre (sin esquema). Un parámetro con valor por defecto es opcional. +* Devuelve un `str` y se convierte en un mensaje de usuario. Devuelve una lista de `UserMessage` / `AssistantMessage` para sembrar una conversación de varios turnos. +* `title=` y `Field(description=...)` son lo que un cliente pone en su interfaz. +* Un argumento obligatorio que falta hace fallar toda la solicitud. No hay un resultado de error por prompt. + +El autocompletado en el servidor de los argumentos de un prompt (o de una plantilla de recurso) está en **[Autocompletado](completions.md)**. diff --git a/i18n/es/pages/servers/resources.md b/i18n/es/pages/servers/resources.md new file mode 100644 index 0000000000..189c748d91 --- /dev/null +++ b/i18n/es/pages/servers/resources.md @@ -0,0 +1,146 @@ +--- +translation: + sections: [09df998c2a799f78, 0cf131146d16d4f9, 4e6b91e3f8025346, 8fe4eef576db17ed, 0d0d1ed43e3d0a53] + tool: 1 +--- +# Recursos {#resources} + +Un **recurso** es un dato que expones para que la aplicación lo lea. + +Esa es la diferencia. Una herramienta es algo que el **modelo** decide llamar. Un recurso es algo que la **aplicación** decide cargar (un archivo de configuración, un registro, un documento) y poner delante del modelo como contexto. + +Declaras uno poniendo `@mcp.resource(uri)` sobre una función normal de Python. + +## Tu primer recurso {#your-first-resource} + +```python title="server.py" hl_lines="6-8" +--8<-- "docs_src/resources/tutorial001.py" +``` + +Tiene la misma forma que una herramienta, con un añadido: el **URI**. A los recursos se accede por dirección, no por nombre. Un cliente pide `config://app`, nunca `get_config`. + +El SDK sigue leyendo el resto de la función: + +* El **nombre** es el nombre de la función: `get_config`. +* La **descripción** que ve el cliente es el docstring. +* El **contenido** es lo que devuelvas. + +Durante `resources/list` el cliente recibe esto: + +```json +{ + "name": "get_config", + "uri": "config://app", + "description": "The active shop configuration.", + "mimeType": "text/plain" +} +``` + +Y cuando lee `config://app`, tu función se ejecuta y el valor devuelto regresa como texto: + +```python +result.contents # [TextResourceContents(uri="config://app", mime_type="text/plain", text="theme=dark\nlanguage=en")] +``` + +!!! tip + Listar es barato. Tu función **no** se llama durante `resources/list`, solo durante + `resources/read`, y solo para el URI que se pidió. Expón mil recursos + y pagas por los que alguien abre. + +### Pruébalo {#try-it} + +Ejecuta el servidor con el MCP Inspector: + +```console +uv run mcp dev server.py +``` + +Abre la URL que imprime y ve a la pestaña **Resources**. `config://app` está en la lista con su descripción. Haz clic en él y el Inspector lo lee: ahí están tus dos líneas de configuración. + +## Plantillas de recurso {#resource-templates} + +Un URI por registro no escala. Pon un **marcador de posición** en el URI y un parámetro correspondiente en la función: + +```python title="server.py" hl_lines="12-13" +--8<-- "docs_src/resources/tutorial002.py" +``` + +`{user_id}` en el URI, `user_id: str` en la función. Ese es todo el contrato. + +Ahora es una **plantilla de recurso**, y se muda: sale de `resources/list` y aparece en `resources/templates/list`, como un patrón en lugar de una dirección: + +```json +{ + "name": "get_user_profile", + "uriTemplate": "users://{user_id}/profile", + "description": "A customer's profile.", + "mimeType": "text/plain" +} +``` + +El cliente rellena el marcador de posición y lee un URI concreto: `users://42/profile`, `users://ada/profile`. Una sola función responde a todos, con el valor coincidente pasado como `user_id`: + +```python +result.contents # [TextResourceContents(uri="users://42/profile", text="User 42: 12 orders since 2021.")] +``` + +Fíjate en el `uri` del resultado. Es el URI **concreto** que pidió el cliente, no la plantilla. + +!!! check + Los marcadores de posición y los parámetros tienen que coincidir. Renombra el parámetro de la función a + `user` mientras el URI sigue diciendo `{user_id}` y el decorador lo rechaza **en tiempo de importación**, + antes de que ningún cliente se acerque: + + ```text + ValueError: Mismatch between URI parameters {'user_id'} and function parameters {'user'} + ``` + + Una discrepancia así solo puede ser un bug, así que el SDK hace imposible arrancar el servidor con una. + +La sintaxis de los marcadores de posición es [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570): `{+path}` para valores de varios segmentos, `{?q,lang}` para parámetros de consulta opcionales, y más. Por defecto, el SDK también aplica comprobaciones de seguridad de rutas a los valores extraídos. Consulta **[Plantillas de URI y seguridad de rutas](uri-templates.md)** para la referencia completa. + +`get_user_profile` también puede recibir un parámetro anotado como `Context`. El SDK lo inyecta sin tratarlo nunca como un parámetro del URI, y la página **[El Context](../handlers/context.md)** explica lo que te ofrece. + +## Lo que devuelves {#what-you-return} + +No estás limitado a `str`. Dale a cada recurso un `mime_type` y devuelve lo que encaje: + +```python title="server.py" hl_lines="8-9 14-15 20-21" +--8<-- "docs_src/resources/tutorial003.py" +``` + +* `readme` devuelve un `str`, así que se envía tal cual. Es el caso habitual. +* `catalog_stats` devuelve un `dict`, así que el SDK lo serializa a **texto JSON** por ti: + + ```json + { + "books": 1204, + "authors": 391 + } + ``` + +* `placeholder_cover` devuelve `bytes`, así que el cliente recibe un `BlobResourceContents` en lugar de un `TextResourceContents`, con tus bytes codificados en base64 en su campo `blob`. + +La misma regla vale para cualquier otra cosa serializable a JSON: una lista, un modelo de Pydantic, una dataclass. Si no es `str` ni `bytes`, se convierte en JSON. + +El `mime_type` lo declaras tú, y es `text/plain` por defecto. El SDK nunca inspecciona lo que devuelves para adivinarlo, así que un recurso `dict` sin etiquetar se sigue anunciando como texto plano. + +!!! tip + `@mcp.resource()` también acepta `name=`, `title=` y `description=` cuando no quieres + derivarlos de la función. Y cuando no hay ninguna función que escribir, + `mcp.server.mcpserver.resources` tiene clases `Resource` listas para usar (`TextResource`, + `BinaryResource`, `FileResource`, `HttpResource`, `DirectoryResource`) que registras + con `mcp.add_resource(...)`. + +Un cliente también puede **suscribirse** a un recurso y recibir una notificación cuando cambie; esa es la mitad de la historia que le toca al cliente y vive en **[El cliente](../client/index.md)**. + +## Resumen {#recap} + +* `@mcp.resource(uri)` sobre una función la convierte en un recurso. El URI es la dirección, el valor devuelto es el contenido, el docstring es la descripción. +* Un `{placeholder}` en el URI la convierte en una **plantilla**: se lista en `resources/templates/list` y una sola función sirve todos los URI que coinciden. +* Los nombres de los marcadores de posición deben ser iguales a los nombres de los parámetros de la función. Equivócate y lo descubres en tiempo de importación, no en producción. +* Tu función se ejecuta cuando el recurso se **lee**, no cuando se lista. +* `str` se convierte en texto, `bytes` en un blob en base64, cualquier otra cosa en texto JSON. Con `mime_type=` lo etiquetas. +* Las herramientas son para que el modelo actúe. Los recursos son para que la aplicación lea. + +La tercera primitiva, la que una persona elige de un menú, son los **[Prompts](prompts.md)**. diff --git a/i18n/es/pages/servers/structured-output.md b/i18n/es/pages/servers/structured-output.md new file mode 100644 index 0000000000..ca64aa68f0 --- /dev/null +++ b/i18n/es/pages/servers/structured-output.md @@ -0,0 +1,250 @@ +--- +translation: + sections: [a838d57f003aed44, 857d03886a0137ed, 42d9efcb9f542867, 2290ff08435b5573, e866c192e11d1c14, 6cdbad079f7b47f0, d4b607372fb28b51, 18dbf726ac45e0b7, c6f7d2a148aa49f4, c851964bb3301907, d715db6f8dccc9cc, ef86634aa70498a7] + tool: 1 +--- +# Salida estructurada {#structured-output} + +Una herramienta que devuelve un simple `str` produce el resultado dos veces: como texto en `content` y como `{"result": "..."}` en `structured_content`. + +Esta página trata de ese segundo canal: de dónde sale, todas las formas que puede tomar y cómo el SDK garantiza que sea fiel. + +La versión corta: **la anotación del tipo de retorno es el esquema de salida**. Ya la escribiste. + +## El esquema de salida {#the-output-schema} + +```python title="server.py" hl_lines="9" +--8<-- "docs_src/structured_output/tutorial001.py" +``` + +La línea que importa es la firma: `-> int`. + +Gracias a ella, la herramienta que el SDK envía durante `tools/list` lleva un `output_schema` junto al esquema de entrada que construye a partir de tus parámetros (de ese se ocupa **[Herramientas](tools.md)**): + +```json +{ + "properties": { + "result": {"title": "Result", "type": "integer"} + }, + "required": ["result"], + "title": "get_temperatureOutput", + "type": "object" +} +``` + +Un `int` suelto no es un objeto JSON, así que el SDK lo **envuelve** en `{"result": ...}`. Llama a la herramienta y se llenan los dos canales: + +```python +result.content # [TextContent(text="17")] +result.structured_content # {"result": 17} +``` + +Todos los escalares reciben el mismo envoltorio: `str`, `int`, `float`, `bool`, `bytes`, `None`. + +## Dos canales {#two-channels} + +¿Por qué enviar el mismo valor dos veces? + +* `content` es para el **modelo**. Un modelo de lenguaje lee texto; es la única parte del resultado que ve. +* `structured_content` es para la **aplicación** dentro de la que se ejecuta el modelo: código que quiere `17`, no una frase que contenga "17". +* `output_schema` es el contrato entre ambos, publicado antes de que la herramienta se llame por primera vez. + +Devuelves un único valor de Python. El SDK rellena los tres. + +## Devolver un modelo {#return-a-model} + +Declara la forma como un `BaseModel` de Pydantic y devuelve una instancia: + +```python title="server.py" hl_lines="8-11 15" +--8<-- "docs_src/structured_output/tutorial002.py" +``` + +Ahora `WeatherData` **es** el esquema. Sin envoltorio, sin clave `result`: + +```json +{ + "properties": { + "temperature": {"description": "Degrees Celsius.", "title": "Temperature", "type": "number"}, + "humidity": {"description": "Relative humidity, 0 to 1.", "title": "Humidity", "type": "number"}, + "conditions": {"title": "Conditions", "type": "string"} + }, + "required": ["temperature", "humidity", "conditions"], + "title": "WeatherData", + "type": "object" +} +``` + +`structured_content` es el objeto, campo por campo: + +```python +result.structured_content # {"temperature": 16.2, "humidity": 0.83, "conditions": "Overcast"} +``` + +Y el modelo no se queda fuera. El SDK serializa el mismo objeto como texto JSON para `content`: + +```json +{ + "temperature": 16.2, + "humidity": 0.83, + "conditions": "Overcast" +} +``` + +Fíjate en que el `Field(description=...)` de `temperature` y `humidity` acabó en el esquema. El mismo `Field` que describía tus **entradas** describe tus salidas. + +!!! info + Si has usado el `response_model` de FastAPI, esto ya lo conoces: un modelo de Pydantic como respuesta + declarada, serializado y documentado por ti. La única diferencia es que aquí la anotación de retorno + es toda la declaración. + +## Un `TypedDict` {#a-typeddict} + +No todas las formas merecen una clase. Un `TypedDict` produce el mismo esquema: + +```python title="server.py" hl_lines="8" +--8<-- "docs_src/structured_output/tutorial003.py" +``` + +Un `TypedDict` es un `dict` normal en tiempo de ejecución, así que eso es lo que construyes y devuelves. El esquema, la validación y `structured_content` son idénticos a los de la versión con `BaseModel` (salvo las descripciones, para las que `TypedDict` no tiene sitio). + +## Una dataclass {#a-dataclass} + +Las dataclasses también funcionan, igual que cualquier clase normal cuyos atributos tengan anotaciones de tipo. El SDK construye internamente un modelo de Pydantic a partir de las anotaciones. + +```python title="server.py" hl_lines="8-9" +--8<-- "docs_src/structured_output/tutorial004.py" +``` + +Tres formas de escribirlo, un solo esquema. Usa la que ya tenga tu código. + +## Listas {#lists} + +Un `list[...]` tampoco es un objeto JSON, así que recibe el envoltorio `{"result": ...}`, con tu tipo de elemento dentro como referencia en `$defs`: + +```python title="server.py" hl_lines="15" +--8<-- "docs_src/structured_output/tutorial005.py" +``` + +```json +{ + "$defs": { + "WeatherData": { + "properties": { + "temperature": {"title": "Temperature", "type": "number"}, + "humidity": {"title": "Humidity", "type": "number"}, + "conditions": {"title": "Conditions", "type": "string"} + }, + "required": ["temperature", "humidity", "conditions"], + "title": "WeatherData", + "type": "object" + } + }, + "properties": { + "result": {"items": {"$ref": "#/$defs/WeatherData"}, "title": "Result", "type": "array"} + }, + "required": ["result"], + "title": "get_forecastOutput", + "type": "object" +} +``` + +Pide un pronóstico de dos días y `structured_content` es `{"result": [{...}, {...}]}`. `content` se convierte en **dos** bloques `TextContent`, uno por elemento: una lista se aplana para el modelo en lugar de volcarse como una sola cadena. + +`tuple[...]`, las uniones y `Optional[...]` se envuelven de la misma manera. + +## Diccionarios {#dictionaries} + +`dict[str, ...]` es el único genérico que ya *es* un objeto JSON, así que no se envuelve: + +```python title="server.py" hl_lines="9" +--8<-- "docs_src/structured_output/tutorial006.py" +``` + +```json +{ + "additionalProperties": {"type": "number"}, + "title": "get_temperaturesDictOutput", + "type": "object" +} +``` + +```python +result.structured_content # {"London": 16.2, "Reykjavik": 4.4} +``` + +Las claves deben ser `str`. Un `dict[int, float]` no puede ser un objeto JSON, así que recurre al envoltorio `{"result": ...}`. + +## Validación {#validation} + +`output_schema` no es documentación. Lo que devuelva tu función **se valida contra él** antes de salir del servidor. + +No lo notas mientras construyes el valor a mano: Pydantic ya se aseguró de que tu `WeatherData` fuera un `WeatherData`. Lo notas el día que los datos vienen de algún sitio que no controlas: + +```python title="server.py" hl_lines="9 21" +--8<-- "docs_src/structured_output/tutorial007.py" +``` + +La anotación promete `WeatherData`. La respuesta del servicio externo dejó de enviar `humidity`. + +!!! check + Llama a `get_weather` y no le entrega al cliente en silencio un objeto medio vacío. La llamada falla, + y las primeras líneas del error nombran el campo: + + ```text + Error executing tool get_weather: 1 validation error for WeatherData + humidity + Field required [type=missing, input_value={'temperature': 16.2, 'conditions': 'Overcast'}, input_type=dict] + ``` + + Ese texto vuelve como resultado de la herramienta con `is_error=True`, así que el modelo sabe que la + llamada falló en lugar de leer con toda confianza un tiempo que no existe. + +Por cierto, devolver un `dict` normal desde una herramienta `-> WeatherData` está bien. Es exactamente lo que produjo `json.loads`. La validación se aplica al valor, no al tipo de Python. + +## Desactivarlo {#opting-out} + +A veces la anotación de retorno es para tu verificador de tipos, no para el protocolo. Pasa `structured_output=False` y la herramienta es solo texto: + +```python title="server.py" hl_lines="6" +--8<-- "docs_src/structured_output/tutorial008.py" +``` + +Sin `output_schema`, sin envoltorio, sin validación. `structured_content` es `None` y `content` es la cadena que devolviste. + +Lo contrario, `structured_output=True`, convierte la detección automática en un requisito: una herramienta cuyo tipo de retorno no pueda producir un esquema lanza una excepción al importar el módulo en lugar de recurrir al texto. + +## Una clase sin anotaciones de tipo {#a-class-without-type-hints} + +Hay una forma de acabar sin salida estructurada sin haberlo pedido: devolver una clase que **no tiene anotaciones en su cuerpo**. + +```python title="server.py" hl_lines="6-9" +--8<-- "docs_src/structured_output/tutorial009.py" +``` + +`Station` asigna `name` y `online` dentro de `__init__`, pero la *clase* no declara nada. El SDK lee las anotaciones de la clase, no encuentra ninguna y desiste. + +!!! warning + Desiste **en silencio**. `output_schema` es `None`, `structured_content` es `None` y el texto + que lee el modelo es el `repr` del objeto: + + ```text + "" + ``` + + Ni error, ni aviso: una herramienta inútil. Mueve las anotaciones al cuerpo de la clase o pasa + `structured_output=True`, que convierte esto en un error inmediato en cuanto se importa el módulo: + `Function get_station: return type is not serializable for structured output`. + +!!! tip + ¿Necesitas control total (construir el `CallToolResult` tú mismo o adjuntar un `_meta` que la + aplicación pueda ver pero el modelo no)? Eso es **[El Server de bajo nivel](../advanced/low-level-server.md)**. + +## Resumen {#recap} + +* La **anotación del tipo de retorno** es el esquema de salida. Se publica en `tools/list` como `output_schema`. +* Los escalares, las listas, las tuplas y las uniones se envuelven en `{"result": ...}`. Los modelos, los `TypedDict`, las dataclasses, las clases con anotaciones y `dict[str, ...]` ya son objetos y se quedan como están. +* Cada resultado lleva `content` (texto, para el modelo) **y** `structured_content` (datos, para la aplicación). +* Lo que devuelves se valida contra el esquema. Una discrepancia es un error de herramienta, no un resultado corrupto. +* `structured_output=False` excluye una herramienta. Una clase sin anotaciones de tipo queda excluida en silencio; vigílalo. + +Ahora dominas todo lo que una herramienta puede responder. A continuación, la segunda primitiva: **[Recursos](resources.md)**. diff --git a/i18n/es/pages/servers/tools.md b/i18n/es/pages/servers/tools.md new file mode 100644 index 0000000000..d475e95b57 --- /dev/null +++ b/i18n/es/pages/servers/tools.md @@ -0,0 +1,177 @@ +--- +translation: + sections: [e4cc390d56573409, 8566e2b68594e9ad, 2c97b9f888398951, 048e5471dfa71aea, 3076b1e16ad95950, edbedf2a16e71311, 3d8ef8da89fa87c1, f6c0e02e6ea5a363] + tool: 1 +--- +# Herramientas {#tools} + +Una **herramienta** es una función a la que el modelo puede llamar. + +Declaras una poniendo `@mcp.tool()` sobre una función de Python normal. Esa es toda la API. + +## Tu primera herramienta {#your-first-tool} + +```python title="server.py" hl_lines="6-8" +--8<-- "docs_src/tools/tutorial001.py" +``` + +Mira lo que escribiste. No hay esquemas, ni JSON, ni protocolo: solo una función. El SDK lee tres cosas de ella: + +* El **nombre** de la herramienta es el nombre de la función: `search_books`. +* La **descripción** que ve el modelo es el docstring: `Search the catalog by title or author.` +* Los **argumentos** que el modelo puede pasar salen de las anotaciones de tipo: `query: str` y `limit: int`. + +### El esquema de entrada {#the-input-schema} + +A partir de esas anotaciones de tipo, el SDK genera un JSON Schema y lo envía al cliente durante `tools/list`: + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"title": "Limit", "type": "integer"} + }, + "required": ["query", "limit"], + "title": "search_booksArguments" +} +``` + +Ambos argumentos están en `required` porque ninguno tiene valor por defecto. Lo arreglarás en un momento. (Las claves `title` son artefactos de Pydantic; las propiedades, sus tipos y `required` son el contrato.) + +!!! tip + Aquí las anotaciones de tipo no son documentación. Son **el contrato**. Si un cliente envía `"limit": "ten"`, + el SDK lo rechaza antes de que tu función llegue a ejecutarse. + +### Lo que recibe el modelo {#what-the-model-gets-back} + +Llama a la herramienta con `{"query": "dune", "limit": 5}` y el resultado tiene dos partes: + +```python +result.content # [TextContent(text="Found 3 books matching 'dune' (showing up to 5).")] +result.structured_content # {'result': "Found 3 books matching 'dune' (showing up to 5)."} +``` + +`content` es el texto que lee el **modelo**. `structured_content` son datos tipados para la **aplicación cliente**. Está ahí porque declaraste el tipo de retorno como `-> str`. + +No te preocupes todavía por `structured_content`. Devuelve objetos reales de Python desde tus herramientas y ocurre lo correcto; la página **[Salida estructurada](structured-output.md)** trata justamente de eso. + +### Pruébalo {#try-it} + +Ejecuta el servidor con el MCP Inspector: + +```console +uv run mcp dev server.py +``` + +Abre la URL que imprime, ve a la pestaña **Tools** y llama a `search_books`. + +El Inspector muestra un formulario con un campo de texto obligatorio `query` y un campo numérico obligatorio `limit`. Construyó ese formulario a partir de tus anotaciones de tipo. Lo mismo hará cualquier otro cliente MCP. + +## Argumentos opcionales {#optional-arguments} + +Dale un valor por defecto a un parámetro y deja de ser obligatorio. Eso es todo. Es simplemente Python. + +```python title="server.py" hl_lines="7" +--8<-- "docs_src/tools/tutorial002.py" +``` + +El esquema lo refleja: + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"default": 10, "title": "Limit", "type": "integer"} + }, + "required": ["query"], + "title": "search_booksArguments" +} +``` + +`limit` salió de `required` y ganó `"default": 10`. Un cliente que lo omite recibe `10`, exactamente como haría Python. + +## Esquemas más ricos con `Field` {#richer-schemas-with-field} + +Las anotaciones de tipo te llevan lejos, pero a veces quieres *describir* un argumento, o restringirlo. + +Envuelve el tipo en `Annotated` y añade un `Field` de Pydantic: + +```python title="server.py" hl_lines="12-14" +--8<-- "docs_src/tools/tutorial003.py" +``` + +Tres cosas nuevas, todas en los parámetros: + +* `Field(description=...)`: una descripción por argumento que el modelo lee junto con el docstring. +* `Field(ge=1, le=50)`: límites numéricos. Llegan al esquema como `"minimum": 1, "maximum": 50`. +* `Literal["fiction", "non-fiction", "poetry"]`: una enumeración. El modelo solo puede elegir uno de esos valores. + +!!! check + Las restricciones no son decoración. Llama a la herramienta con `limit=999` y el SDK responde con un + error de herramienta **antes de que tu función se ejecute**: + + ```text + Input should be less than or equal to 50 + ``` + + Ese error vuelve al modelo como resultado de la herramienta, y el modelo lo lee y reintenta con + un valor válido. Escribiste `le=50` una vez y obtuviste agentes que se corrigen solos, gratis. + +!!! info + Si has usado FastAPI o Pydantic, ya sabes todo esto. Es el mismo `Field`, + el mismo `Annotated`, la misma validación. No hay nada específico de MCP que aprender aquí. + +## Un modelo como parámetro {#a-model-as-a-parameter} + +Cuando una herramienta recibe más de un par de argumentos, agrúpalos en un modelo de Pydantic: + +```python title="server.py" hl_lines="8-11 15" +--8<-- "docs_src/tools/tutorial004.py" +``` + +El esquema de `Book` queda anidado dentro del esquema de entrada de la herramienta (como referencia en `$defs`), el modelo lo rellena como un objeto JSON y tu función recibe una **instancia real de `Book`**, ya validada, con los atributos `.title`, `.author` y `.year`. + +Puedes combinar a tu gusto: parámetros simples junto a parámetros de modelo, modelos anidados, listas de modelos. Es Pydantic hasta el fondo. + +## `async def` {#async-def} + +Si una herramienta hace E/S (llama a una API, lee un archivo, consulta una base de datos), declárala como `async def` y usa `await` dentro. El SDK se encarga de esperarla. + +Una herramienta con `def` normal también funciona: el SDK la ejecuta en un hilo para que nunca bloquee el servidor. + +No hay nada más que configurar. + +## Nombres, títulos y anotaciones {#names-titles-and-annotations} + +Todo lo que el SDK infiere, puedes sobrescribirlo en el decorador: + +```python title="server.py" hl_lines="7-10" +--8<-- "docs_src/tools/tutorial005.py" +``` + +* `title` es un nombre legible para las interfaces de usuario. Los clientes muestran *"Search the catalog"* en lugar de `search_books`. +* `annotations` son **pistas** de comportamiento para el cliente: + * `read_only_hint=True`: esta herramienta no cambia nada. + * `open_world_hint=False`: trabaja sobre un conjunto cerrado de cosas (este catálogo), no sobre la web abierta. + * Las otras dos, `destructive_hint` e `idempotent_hint`, describen una herramienta que *escribe*: ¿puede + borrar algo?, ¿y llamarla dos veces equivale a llamarla una? La especificación define ambas + solo para herramientas que no son de solo lectura, así que en `search_books` no dirían nada. + +Un cliente bien hecho las usa para decidir cosas como *"¿tengo que preguntarle al usuario antes de ejecutar esto?"*. Son pistas, no seguridad. Nunca des por hecho que un cliente las respetará. + +!!! tip + `@mcp.tool()` también acepta `name=` y `description=` si no quieres derivarlos + del nombre de la función y del docstring. La mayoría de las veces sí quieres. + +## Resumen {#recap} + +* `@mcp.tool()` sobre una función la convierte en herramienta. El nombre sale de la función, la descripción del docstring. +* Las anotaciones de tipo **son** el esquema de entrada. Los valores por defecto hacen opcionales los argumentos. +* `Annotated[..., Field(...)]` añade descripciones y restricciones; `Literal` añade enumeraciones. +* Un parámetro que es un modelo de Pydantic es la forma de recibir un "cuerpo" estructurado. +* Los argumentos incorrectos se rechazan por ti, con un error que el modelo puede leer y del que puede recuperarse. +* `async def` para E/S, `def` normal para todo lo demás. + +**[Salida estructurada](structured-output.md)** es lo que le ocurre al valor que devuelves con `return`. diff --git a/i18n/es/pages/servers/uri-templates.md b/i18n/es/pages/servers/uri-templates.md new file mode 100644 index 0000000000..c3f0f8d221 --- /dev/null +++ b/i18n/es/pages/servers/uri-templates.md @@ -0,0 +1,274 @@ +--- +translation: + sections: [4a7033e1ed8ad602, 55dcbfff0c6271bf, 101ef9d14bf4ec46, 4b6c4a845438abc7, f98b46bafbee4acd] + tool: 1 +--- +# Plantillas de URI y seguridad de rutas {#uri-templates-and-path-safety} + +Esta es la referencia de la sintaxis de plantillas de URI que acepta +[`@mcp.resource`](resources.md) y de la +política de seguridad de rutas que el SDK aplica a los valores extraídos. Para una +introducción a qué son los recursos y cuándo usarlos, empieza por +**[Recursos](resources.md)**; esta página supone que ya te sientes cómodo declarando un +recurso y quieres el conjunto completo de operadores, los ajustes de seguridad o la +conexión con la capa de bajo nivel. + +La sintaxis de plantillas es [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570). +El SDK admite un subconjunto elegido para hacer coincidir las URI entrantes de +`resources/read`, más una capa de seguridad que rechaza los valores que se resolverían +fuera del directorio que pretendes servir. Para los detalles a nivel de protocolo +(formatos de mensaje, ciclo de vida, paginación) consulta la +[especificación de recursos de MCP](https://modelcontextprotocol.io/specification/latest/server/resources). + +## El conjunto completo de operadores {#the-full-operator-set} + +El marcador simple, `{user_id}`, es el que presenta **[Recursos](resources.md)**. Hay cuatro +formas de operador más; aquí están en un solo servidor para que puedas verlas una junto a +otra: + +```python title="server.py" hl_lines="16-17 22-23 28-29 34-35 40-41" +--8<-- "docs_src/uri_templates/tutorial001.py" +``` + +Cada decorador resaltado es una forma distinta de dividir la URI. +Las secciones siguientes los recorren de arriba abajo. + +### Expansión simple: `{name}` {#simple-expansion-name} + +`books://{isbn}` es la forma simple, la de todos los días. El marcador se asigna al +parámetro `isbn`, así que un cliente que lee `books://978-0441172719` llama a +`get_book("978-0441172719")`. + +Un `{name}` simple se detiene en la primera `/`. `books://978/extra` no +coincide porque la barra después de `978` termina la captura y `/extra` +sobra. + +### Conversión de tipos {#type-conversion} + +Los valores extraídos llegan como cadenas, pero puedes declarar un tipo más específico +y el SDK los convierte. `orders://{order_id}` llega a una función +cuyo parámetro es `order_id: int`, así que leer `orders://12345` llama a +`get_order(12345)`, no a `get_order("12345")`. El handler hace +aritmética con él (`order_id + 1`) sin conversión explícita. + +### Rutas de varios segmentos: `{+name}` {#multi-segment-paths-name} + +Para capturar un valor que contiene barras, usa `{+name}`. Con +`manuals://{+path}`: + +* `manuals://returns.md` da `path = "returns.md"` +* `manuals://printing/setup.md` da `path = "printing/setup.md"` + +Recurre a `{+name}` siempre que el valor sea jerárquico: rutas del sistema de +archivos, claves de objetos anidados, rutas de URL que estés redirigiendo como proxy. + +### Parámetros de consulta: `{?a,b,c}` {#query-parameters-abc} + +`reviews://{isbn}{?limit,sort}` pone `limit` y `sort` después del `?`. +La ruta identifica *qué* libro; la consulta ajusta *cómo* lo lees. + +Los parámetros de consulta se comparan con flexibilidad: el orden no importa, los +sobrantes se ignoran y los omitidos caen en los valores por defecto de tu función. Así que +`reviews://978-0441172719` usa `limit=10, sort="newest"`, y +`reviews://978-0441172719?sort=top` sobrescribe solo `sort`. + +### Segmentos de ruta como lista: `{/name*}` {#path-segments-as-a-list-name} + +Si quieres cada segmento de ruta como un elemento separado de una lista en lugar de una +sola cadena con barras, usa `{/name*}`. Con `shelves://browse{/path*}`, un +cliente que lee `shelves://browse/fiction/sci-fi` llama a +`browse_shelf(["fiction", "sci-fi"])`. + +### Referencia de plantillas {#template-reference} + +Los patrones más comunes: + +| Patrón | Entrada de ejemplo | Obtienes | +|--------------|-----------------------|-------------------------| +| `{name}` | `alice` | `"alice"` | +| `{name}` | `docs/intro.md` | *no coincide* (se detiene en `/`) | +| `{+path}` | `docs/intro.md` | `"docs/intro.md"` | +| `{.ext}` | `.json` | `"json"` | +| `{/segment}` | `/v2` | `"v2"` | +| `{?key}` | `?key=value` | `"value"` | +| `{?a,b}` | `?a=1&b=2` | `"1"`, `"2"` | +| `{/path*}` | `/a/b/c` | `["a", "b", "c"]` | + +### Lo que rechaza el analizador {#what-the-parser-rejects} + +Algunas formas de plantilla se detectan desde el principio en lugar de fallar en la +primera solicitud. `@mcp.resource` analiza la plantilla cuando se ejecuta el +decorador, así que ninguna de estas llega nunca a un servidor en ejecución. + +`UriTemplate.parse()` lanza `InvalidUriTemplate` en estos casos: + +* **Dos variables sin nada entre ellas.** `manuals://{+path}{ext}` + se rechaza: la comparación no puede saber dónde termina `path` y dónde empieza `ext`. + Pon un literal entre ellas (`manuals://{+path}/{ext}`) o usa un + operador que aporte su propio delimitador. `manuals://{+path}{.ext}` + se acepta porque `{.ext}` aporta el `.` por sí mismo. +* **Más de una variable de varios segmentos.** Como máximo una entre `{+var}`, + `{#var}` o una variable expandida (`{/var*}`, `{.var*}`, `{;var*}`) + por plantilla. Dos son intrínsecamente ambiguas: no hay una forma fundamentada + de decidir cuál de ellas absorbe un segmento adicional. +* **Los errores de sintaxis habituales**: una llave sin cerrar, un nombre de variable usado + dos veces o una característica de RFC 6570 que el SDK no admite, como el + modificador de prefijo `{var:3}` o la expansión de consulta `{?vars*}`. + +Además de eso, `@mcp.resource` lanza `ValueError` cuando un parámetro del +handler está vinculado a una variable de consulta en el tramo final +`{?...}`/`{&...}` de la plantilla pero no tiene valor por defecto en Python. Esas variables se +comparan con flexibilidad (un cliente puede omitir cualquiera de ellas), así que un parámetro +sin valor por defecto solo aparecería como un error interno opaco en la +primera solicitud que lo omita. `reviews://{isbn}{?limit,sort}` en el +servidor de arriba es la versión bien formada: tanto `limit` como `sort` tienen +valores por defecto. + +## Seguridad {#security} + +Los parámetros de plantilla vienen del cliente. Si llegan a operaciones del sistema de +archivos o de base de datos sin comprobar, valores como `../../etc/passwd` pueden +resolverse fuera del directorio que pretendías servir. + +### Lo que el SDK comprueba por defecto {#what-the-sdk-checks-by-default} + +Antes de que se ejecute tu handler, el SDK rechaza cualquier parámetro que: + +* escaparía de su directorio de partida mediante componentes `..` +* parezca una ruta absoluta (`/etc/passwd`, `C:\Windows`) o una + ruta relativa a unidad de Windows (`C:foo`). Un valor relativo a unidad y un + identificador con espacio de nombres como `x:y` son indistinguibles como cadenas, + así que cualquier valor de una sola letra seguida de dos puntos se rechaza por defecto; + exime el parámetro si recibe legítimamente ese tipo de valores +* contenga un byte nulo (`\x00`) + +La comprobación de `..` se basa en componentes, no en buscar subcadenas. Valores como +`v1.0..v2.0` o `HEAD~3..HEAD` pasan porque ahí `..` no es un segmento de ruta +independiente. + +Estas comprobaciones se aplican al valor decodificado, así que detectan el recorrido de +directorios sin importar cómo se codificó en la URI (`../etc`, `..%2Fetc`, +`%2E%2E/etc`, `..%5Cetc`, `%00`: todos se detectan). + +!!! check + Lee `manuals://../etc/passwd` en el servidor de arriba y la solicitud + se rechaza sin más: la comparación de plantillas se detiene en el primer fallo, + así que no se prueba ninguna plantilla posterior (potencialmente más permisiva) como + alternativa. El cliente ve el mismo error `-32602` "Unknown resource" + que vería con una URI que no coincide con ninguna plantilla, y + `read_manual` nunca se ejecuta. + +### Handlers del sistema de archivos: usa safe_join {#filesystem-handlers-use-safe_join} + +Las comprobaciones integradas detienen los casos comunes, pero no pueden conocer el límite +de tu entorno aislado. Para acceder al sistema de archivos, usa `safe_join` para resolver la +ruta y verificar que se mantiene dentro de tu directorio base: + +```python title="server.py" hl_lines="4 14" +--8<-- "docs_src/uri_templates/tutorial002.py" +``` + +`safe_join` detecta escapes mediante enlaces simbólicos, secuencias `..` y trucos con rutas +absolutas que una simple comprobación de cadenas pasaría por alto. Si la ruta resuelta +escapa de `DOCS_ROOT`, lanza `PathEscapeError`, que le llega al +cliente como un `ResourceError`. + +### Cuando los valores por defecto estorban {#when-the-defaults-get-in-the-way} + +A veces las comprobaciones bloquean valores legítimos. Una herramienta de importación de +catálogos podría recibir intencionadamente una ruta absoluta, o un parámetro podría ser una +referencia relativa como `../sibling` que tu handler interpreta con +seguridad sin tocar el sistema de archivos. Exime ese parámetro o relaja +la política para todo el servidor: + +```python title="server.py" hl_lines="9 16-19" +--8<-- "docs_src/uri_templates/tutorial003.py" +``` + +* `security=ResourceSecurity(exempt_params={"source"})` en el decorador + omite las comprobaciones para ese único parámetro en ese único recurso. El + resto del servidor mantiene la política por defecto. +* `resource_security=` en el constructor de `MCPServer` fija el valor por defecto + para todos los recursos. Aquí `relaxed` desactiva por completo la comprobación de `..`. + +Las comprobaciones configurables: + +| Ajuste | Por defecto | Qué hace | +|-------------------------|---------|-------------------------------------| +| `reject_path_traversal` | `True` | Rechaza secuencias `..` que escapan del directorio de partida | +| `reject_absolute_paths` | `True` | Rechaza `/foo`, `C:\foo`, rutas UNC y la ruta relativa a unidad `C:foo` (también detecta `x:y`) | +| `reject_null_bytes` | `True` | Rechaza valores que contienen `\x00` | +| `exempt_params` | vacío | Nombres de parámetros para los que se omiten las comprobaciones | + +Estas comprobaciones son un prefiltro heurístico; para el acceso al sistema de archivos, +`safe_join` sigue siendo el límite de contención. + +!!! tip + Si tu handler no puede satisfacer la solicitud (el archivo no existe, + el id es desconocido), lanza una excepción. El SDK la convierte en una + respuesta de error. Consulta **[Manejo de errores](handling-errors.md)** para ver la diferencia entre un + error de protocolo y un error de herramienta. + +## Recursos en el Server de bajo nivel {#resources-on-the-low-level-server} + +Si construyes sobre el `Server` de bajo nivel (consulta **[El Server de bajo +nivel](../advanced/low-level-server.md)**), registras directamente los handlers para los métodos de protocolo +`resources/list` y `resources/read`. No hay decorador; devuelves +tú mismo los tipos del protocolo. + +### Recursos estáticos {#static-resources} + +Para URI fijas, mantén un registro y despacha por coincidencia exacta: + +```python title="server.py" hl_lines="17 21 27" +--8<-- "docs_src/uri_templates/tutorial004.py" +``` + +El handler de listado les dice a los clientes qué hay disponible; el handler de lectura +sirve el contenido. Comprueba primero tu registro, pasa a las +plantillas (más abajo) si tienes alguna y luego lanza una excepción para cualquier otra cosa. + +### Plantillas {#templates} + +El motor de plantillas que usa `MCPServer` vive en `mcp.shared.uri_template` +y funciona por sí solo. Obtienes el mismo análisis y la misma comparación; el +enrutamiento y la política de seguridad los conectas tú mismo. + +```python title="server.py" hl_lines="13-16 22-25 29 33 45" +--8<-- "docs_src/uri_templates/tutorial005.py" +``` + +En las líneas resaltadas ocurren tres cosas: + +* **Analiza una vez, compara en cada solicitud.** `UriTemplate.parse()` construye la + plantilla; `template.match(uri)` devuelve las variables extraídas como un + `dict`, o `None` si la URI no encaja. La decodificación de URL ocurre dentro de + `match()`; los valores decodificados se devuelven tal cual, sin validación de + seguridad de rutas. Los valores salen como cadenas: conviértelos tú mismo + (`int(matched["id"])`, `Path(matched["path"])`). +* **Aplica tú mismo las comprobaciones de seguridad.** Las comprobaciones de `..` y de rutas + absolutas que `MCPServer` ejecuta por defecto viven en `mcp.shared.path_security`. + `read_manual_safely` las llama antes de tocar `MANUALS`. Si un + parámetro no es una ruta del sistema de archivos (un ISBN, una consulta de búsqueda), omite las + comprobaciones para ese valor: controlas la política por handler en lugar de + hacerlo mediante un objeto de configuración. +* **Lista las plantillas desde la misma fuente.** Los clientes descubren + las plantillas mediante `resources/templates/list`. `str(template)` devuelve + la cadena original de la plantilla, así que el listado y el comparador + comparten una única fuente de verdad. + +## Resumen {#recap} + +* `{name}` coincide con un segmento; `{+name}` conserva las barras; `{?a,b}` + toma de la cadena de consulta; `{/name*}` divide los segmentos en una lista. +* Dos variables sin nada entre ellas, o una segunda variable de varios + segmentos, se rechazan al analizar. Un parámetro vinculado a una variable de consulta + final `{?...}`/`{&...}` debe declarar un valor por defecto en Python. +* Anota el parámetro (`order_id: int`) y el SDK convierte. +* La política de seguridad por defecto rechaza `..`, rutas absolutas y bytes + nulos antes de que se ejecute tu handler; sobrescríbela por recurso con + `security=ResourceSecurity(...)` o para todo el servidor con + `resource_security=`. +* Para el acceso al sistema de archivos, `safe_join` es el límite de contención. +* En el `Server` de bajo nivel, analiza con `UriTemplate.parse()`, compara + con `.match()` y aplica `mcp.shared.path_security` tú mismo. diff --git a/i18n/es/pages/translations.md b/i18n/es/pages/translations.md new file mode 100644 index 0000000000..61f88fc785 --- /dev/null +++ b/i18n/es/pages/translations.md @@ -0,0 +1,30 @@ +--- +translation: + sections: [f671b445b16e4f99, 3983a560eb2cece7, 004b3ee918529d8c, c6e2debf1da06eb7, 81d412ed5f399f94] + tool: 1 +--- +# Traducciones {#translations} + +Esta documentación está escrita en inglés. Para que resulte útil a más personas, también publicamos ediciones traducidas automáticamente, y esta página explica qué significa eso para ti y cómo ayudar a mejorarlas. + +## Qué hay disponible {#whats-available} + +La documentación traducida es por ahora una **vista previa** en doce idiomas: Deutsch, español, français, हिन्दी, 日本語, 한국어, português (Brasil), русский язык, Türkçe, українська мова, 简体中文 y 繁體中文. Elige uno en el selector de idioma de la parte superior de cualquier página. Puede que se añadan más idiomas una vez que estos hayan demostrado su valor. + +La referencia de la API no está traducida: el sitio traducido enlaza a la única versión, en inglés. + +## El inglés es la fuente de verdad {#english-is-the-source-of-truth} + +Si una página traducida y su original en inglés no coinciden, la página en inglés es la correcta. Cada página de un sitio traducido se abre con una de estas tres notas, que indica en qué estado se encuentra: + +- **Traducción automática**: la página se tradujo automáticamente y enlaza a su original en inglés. +- **Traducción desactualizada respecto a la página en inglés**: el original en inglés cambió después de traducir la página, así que algunas partes pueden estar desactualizadas hasta que la traducción se ponga al día. +- **Mostrada en inglés**: no hay una traducción vigente de la página, así que estás leyendo el texto en inglés. + +## Cómo se hacen las traducciones {#how-the-translations-are-made} + +Las páginas traducidas las genera automáticamente una herramienta de este repositorio a partir de las páginas en inglés bajo `docs/`, guiada por dos insumos escritos por personas para cada idioma: una guía de estilo (registro, tono, tipografía, cómo tratar los chistes y los modismos) y un glosario (qué términos se quedan en inglés, y las traducciones obligatorias y prohibidas del resto). El texto generado nunca se edita a mano. Todas las mejoras van a esos insumos, de modo que sobreviven la próxima vez que se regeneren las páginas. + +## Informar de un problema de traducción {#reporting-a-translation-problem} + +¿Encontraste un término incorrecto, una frase forzada o una traducción que dice algo que el inglés no dice? [Abre un issue](https://github.com/modelcontextprotocol/python-sdk/issues) indicando el idioma, la página y el pasaje; los informes de hablantes nativos son especialmente valiosos. Si conoces la solución, proponla directamente como pull request contra la guía de estilo (`instructions.md`) o el glosario (`glossary.json`) de ese idioma bajo [`i18n/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/i18n): la corrección llegará a todas las páginas afectadas la próxima vez que se regeneren las traducciones. Los problemas del propio texto en inglés se corrigen en las páginas bajo `docs/`, como cualquier otro cambio en la documentación. diff --git a/i18n/es/pages/troubleshooting.md b/i18n/es/pages/troubleshooting.md new file mode 100644 index 0000000000..15dd877a30 --- /dev/null +++ b/i18n/es/pages/troubleshooting.md @@ -0,0 +1,420 @@ +--- +translation: + sections: [2efaecdef109a5c5, fcacd3e66b8635a4, 25323d737dcf0261, 4835ed1772f1d113, 137454d469c867f5, 6392596bd6df54f0, 41126fa9c4fe432f, 480b6d7897e30ab4, d83bb682e708dde0, ebbed3449c499db4, 323ef84f6b4bebde, 30fd31be74169d9a, 656943c6cb567218, c2dc3b1007d2e987, 7cf5386b997d04e9, 0b59feed8384456e, 0cba47bae78d04eb, 954dc21efdb532a3] + tool: 1 +--- +# Solución de problemas {#troubleshooting} + +Cada encabezado de esta página es el texto exacto de un error que produce el SDK, seguido de lo que significa y de la solución en un solo paso. Busca aquí la última línea de tu traceback (o del log del servidor) con la búsqueda en página del navegador y lee solo esa entrada. + +Varias entradas se ejecutan contra este mismo servidor. Una herramienta y un recurso con plantilla, cada uno de los cuales lanza una excepción para una ciudad que no conoce: + +```python title="server.py" +--8<-- "docs_src/troubleshooting/tutorial001.py" +``` + +Los errores que cita esta página son reales: la propia suite de pruebas del SDK reproduce cada uno de ellos. + +## `ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)` {#exceptiongroup-unhandled-errors-in-a-taskgroup-1-sub-exception} + +Esto no es un error de MCP. Es ruido de anyio, y tu error real es la **última línea** de lo que pegaste. + +`Client.__aenter__` inicia un grupo de tareas. anyio envuelve en un `ExceptionGroup` todo lo que sale de un grupo de tareas, así que *cualquier* excepción que escape de un bloque `async with Client(...)`, sea la que sea, llega dentro de uno: + +```python +async def main() -> None: + async with Client(mcp) as client: + await client.read_resource("weather://Atlantis") +``` + +```text + + Exception Group Traceback (most recent call last): + | ... + | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception) + +-+---------------- 1 ---------------- + | Exception Group Traceback (most recent call last): + | ... + | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception) + +-+---------------- 1 ---------------- + | Traceback (most recent call last): + | ... + | mcp.shared.exceptions.MCPError: No forecast for 'Atlantis'. + +------------------------------------ +``` + +Dos cosas que hacer con eso: + +1. **Lee el final.** `MCPError: No forecast for 'Atlantis'.` es el fallo; busca *su* texto en esta página. +2. **Captura dentro del bloque.** El `ExceptionGroup` solo aparece cuando la excepción *sale* del `async with`. Capturado dentro, el mismo fallo es el `MCPError` sin más, sin ningún grupo: + +```python +async def main() -> None: + async with Client(mcp) as client: + try: + await client.read_resource("weather://Atlantis") + except MCPError as e: + print(e) # No forecast for 'Atlantis'. +``` + +!!! tip + Un fallo durante la *conexión* (una URL equivocada, un servidor que no está en ejecución, el + `421` de más abajo en esta página) escapa del propio `async with`, así que no hay un "dentro" + donde capturarlo. Para esos casos, lee el final del grupo. + +## `RuntimeError: Client must be used within an async context manager` {#runtimeerror-client-must-be-used-within-an-async-context-manager} + +`Client(...)` solo construye el objeto. Nada se conecta hasta el `async with`, así que todos los métodos se niegan: + +```python +async def main() -> None: + client = Client(mcp) + tools = await client.list_tools() # RuntimeError +``` + +Entra en él. `__aenter__` es la conexión: + +```python +async def main() -> None: + async with Client(mcp) as client: + tools = await client.list_tools() +``` + +`__aexit__` es la desconexión, y por eso no hay ningún `client.close()` que olvidar. **[Pruebas](get-started/testing.md)** se basa exactamente en este patrón. + +## `Error executing tool : ` y `Unknown tool: ` {#error-executing-tool-name-message-and-unknown-tool-name} + +Estás leyendo un **resultado**, no una excepción. `call_tool` no lanzó nada, y nunca lo hará para una herramienta que falla. + +Llama a `forecast` con una ciudad que el servidor no conoce y la excepción que lanza vuelve con la solicitud marcada como *correcta*: + +```python +result.is_error # True +result.content # [TextContent(text="Error executing tool forecast: No forecast for 'Atlantis'.")] +result.structured_content # None +``` + +`Unknown tool: get_forecast` tiene la misma forma para un nombre que el servidor nunca registró, y un argumento incorrecto se rechaza igual, contra el esquema de entrada de la herramienta, antes de que tu función llegue a ejecutarse. + +La solución está en tu cliente: **comprueba `result.is_error`**. Un `try/except` alrededor de `call_tool` no captura ninguno de estos casos, porque no hay nada que capturar. Es deliberado, y es lo más útil de esta página que puedes interiorizar: el *modelo* eligió la llamada, así que el modelo recibe el mensaje y la oportunidad de intentarlo de nuevo. **[Manejo de errores](servers/handling-errors.md)** tiene todos los detalles, incluida la vía de `MCPError` que *sí* lanza. + +## `TypeError: The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool` {#typeerror-the-tool-decorator-was-used-incorrectly-did-you-forget-to-call-it-use-tool-instead-of-tool} + +Escribiste `@mcp.tool` en lugar de `@mcp.tool()`. `tool()` es una *fábrica* de decoradores: sin los paréntesis, Python le pasa tu función a su parámetro `name=`. + +```python +@mcp.tool # <- missing () +def forecast(city: str) -> str: + """Today's forecast for one city.""" + return f"{city}: Rain." +``` + +```text +TypeError: The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool +``` + +Añade los paréntesis. `@mcp.resource(...)` y `@mcp.prompt()` dicen lo mismo ante el mismo descuido. + +!!! note + Esto se lanza al **importar** el módulo, antes de que se conecte ningún cliente. Así que un + host que muestra tu servidor como *no se pudo iniciar* (o *desconectado*), en lugar de + conectado con cero herramientas, tiene esta forma: ejecuta `python server.py` tú mismo y lee + el traceback. Un verificador de tipos también lo detecta: una función no es un `name=` válido. + +## `Tool already exists: ` {#tool-already-exists-name} + +Dos registros usaron el mismo nombre de herramienta. Gana el **primero**, el segundo se descarta en silencio, y este aviso en el *log del servidor* es la única señal: + +```python title="server.py" hl_lines="6 12" +--8<-- "docs_src/troubleshooting/tutorial002.py" +``` + +```text +WARNING mcp.server.mcpserver.tools.tool_manager: Tool already exists: forecast +``` + +`tools/list` informa de un solo `forecast`, y es `forecast_today`. Cambia el nombre de uno de ellos. `MCPServer(..., warn_on_duplicate_tools=False)` silencia el aviso sin cambiar el resultado, así que déjalo activado. Los recursos y los prompts tienen la misma regla y la misma línea de log (`Resource already exists:`, `Prompt already exists:`). + +## Mi host muestra cero herramientas {#my-host-lists-zero-tools} + +No hay ninguna cadena de error para esto, y precisamente por eso es difícil de buscar. El SDK nunca quita una herramienta registrada de `tools/list`, así que ve descartando de dentro hacia fuera: + +* **¿Llegó a arrancar el servidor?** `@mcp.tool` sin paréntesis lanza una excepción al importar, y en algunos hosts un servidor caído se parece mucho a uno vacío. Ejecuta `python server.py` tú mismo. +* **¿Está la herramienta en el `mcp` que ejecuta el host?** Un segundo `MCPServer(...)` en otro módulo es un servidor distinto y vacío. Comprueba qué objeto importa realmente el comando del host. +* **¿Dos herramientas compartían nombre?** Entonces una de ellas desapareció. Busca `Tool already exists:` en el log del servidor. +* **¿Está desactualizada la lista del host?** Añadir una herramienta después del arranque solo llega a los clientes que manejan `notifications/tools/list_changed`. Reiniciar el host es la solución expeditiva. +* **¿Algo escribió en `stdout` fuera de la ventana desviada?** Mientras atiende, el SDK desvía a stderr la salida suelta de stdout que se *vacía* (en la medida de lo posible: un entorno que reemplaza los flujos estándar se atiende tal cual), pero la salida vaciada a stdout antes (un script envoltorio que hace eco, un `print()` en tiempo de importación en un proceso sin búfer) o un `print()` en búfer que se drena al salir el intérprete acaba en el flujo del protocolo, y una sola línea de basura puede hacer que el host corte la conexión, lo que algunos hosts muestran como un servidor sin nada dentro. Registra con el módulo `logging` en su lugar. El resto de la lista de comprobaciones del lado del host está en **[Conectar con un host real](get-started/real-host.md)**. + +Un nombre de herramienta "inválido" *no* está en esa lista: un nombre no conforme registra un aviso, pero la herramienta se registra y se lista igualmente. + +## `MCPError: Server returned an error response` {#mcperror-server-returned-an-error-response} + +El servidor rechazó de plano la solicitud HTTP, con un cuerpo que no es JSON-RPC, así que el `Client` de python no tiene nada mejor que mostrarte que este mensaje genérico. + +La causa más común, con diferencia, es un servidor Streamable HTTP recién desplegado. `streamable_http_app()` (y `mcp.run("streamable-http")`) sin `transport_security=` activa por defecto la **protección contra DNS rebinding**: solo acepta solicitudes cuya cabecera `Host` sea localhost. Es el valor por defecto correcto en tu portátil y el incorrecto detrás de un nombre de host real: + +```python title="server.py" hl_lines="12" +--8<-- "docs_src/troubleshooting/tutorial003.py" +``` + +Despliega eso, apunta un cliente hacia él y la conexión falla en el handshake: + +```python +async with Client("https://mcp.example.com/mcp") as client: + ... +``` + +```text +mcp.shared.exceptions.MCPError: Server returned an error response +``` + +Las palabras que el servidor envió realmente, `421` e `Invalid Host header`, nunca te llegan: el cuerpo del 421 no tiene `Content-Type: application/json`, así que el cliente no puede analizarlo. Están en el **log del servidor**, que es donde mirar a continuación: + +```text +WARNING mcp.server.transport_security: Invalid Host header: mcp.example.com +``` + +La solución es `transport_security=`. Añade a la lista de permitidos el nombre de host que sirves realmente: + +```python title="server.py" hl_lines="14-17" +--8<-- "docs_src/troubleshooting/tutorial004.py" +``` + +!!! check + Ese es todo el cambio. El mismo cliente ahora se conecta, negocia `2026-07-28` y llama a + `forecast`. + +**[Desplegar y escalar](run/deploy.md)** cubre lo que significa cada campo, el caso del proxy inverso y todo lo demás que cambia al desplegar. Y `421 Misdirected Request` / `Invalid Host header`, justo debajo, es el mismo fallo visto desde el otro lado. + +## `421 Misdirected Request` / `Invalid Host header` {#421-misdirected-request-invalid-host-header} + +Esto es `Server returned an error response`, visto desde cualquier cosa que *no* sea el `Client` de python: curl, la pestaña de red de un navegador, el log de acceso de un proxy inverso u otro SDK. + +```bash +curl -i https://mcp.example.com/mcp \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}' +``` + +```text +HTTP/1.1 421 Misdirected Request + +Invalid Host header +``` + +`421 Misdirected Request` es la propia frase de motivo de HTTP para ese estado; `Invalid Host header` es el cuerpo de respuesta del SDK; y el `Client` de python muestra el mismo evento como `Server returned an error response`. Las tres son un único rechazo. La comprobación se hace contra la **cabecera `Host` que lleva la solicitud**, no contra la dirección a la que se enlazó el servidor, así que un proxy inverso que reenvía el nombre de host público la dispara exactamente igual que un cliente directo. + +La solución es el mismo `transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])` que se muestra en `Server returned an error response`. Dos de sus detalles merecen mención: + +* Una entrada de `allowed_hosts` es una cadena exacta. `"mcp.example.com"` coincide con una cabecera `Host` sin puerto y `"mcp.example.com:*"` coincide con cualquier puerto explícito. Incluye las dos. +* Un `403` con el cuerpo `Invalid Origin header` es la comprobación hermana sobre la cabecera `Origin`. Solo salta con navegadores (nada más envía `Origin`), y `allowed_origins=` es su lista de permitidos. + +**[Desplegar y escalar](run/deploy.md)** lo trata a fondo, incluido cuándo desactivar la comprobación es la configuración honesta. + +## `RuntimeError: Task group is not initialized. Make sure to use run().` {#runtimeerror-task-group-is-not-initialized-make-sure-to-use-run} + +Tu app MCP está montada dentro de otra app ASGI, y nada inició su **gestor de sesiones**. + +`mcp.streamable_http_app()` devuelve una app Starlette cuyo propio lifespan (ciclo de vida del servidor) inicia el gestor, y `uvicorn server:app` ejecuta ese lifespan por ti. Pero Starlette **nunca ejecuta el lifespan de una subaplicación montada**, así que en cuanto la app va dentro de un `Mount`, el gestor nunca arranca y la primera solicitud explota: + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/troubleshooting/tutorial005.py" +``` + +El servidor arranca. La ruta se resuelve. Luego `uvicorn` imprime esto en cada solicitud: + +```text +ERROR: Exception in ASGI application +Traceback (most recent call last): + ... +RuntimeError: Task group is not initialized. Make sure to use run(). +``` + +El cliente ve un 500. La solución es un lifespan en la app **host** que entre en `mcp.session_manager.run()`: + +```python +@asynccontextmanager +async def lifespan(app: Starlette) -> AsyncIterator[None]: + async with mcp.session_manager.run(): + yield + + +app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lifespan) +``` + +**[Añadir a una app existente](run/asgi.md)** es la página para esto, incluidos varios servidores en una sola app y FastAPI. Dos cadenas vecinas de la misma clase: + +* `StreamableHTTPSessionManager .run() can only be called once per instance. Create a new instance if you need to run again.` El gestor es de un solo uso; entrar dos veces en el lifespan de la misma app lo provoca. +* `mcp.session_manager` solo existe **después** de llamar a `streamable_http_app()`, así que construye primero las rutas y toca el gestor solo dentro del lifespan. + +## `MCPError: Session not found` {#mcperror-session-not-found} + +El servidor no reconoce el `Mcp-Session-Id` que envió tu cliente, casi siempre porque el servidor **se reinició** (o te enrutaron a otra instancia). Las sesiones viven en la memoria de ese único proceso. + +No hay ningún bug del servidor que encontrar. La respuesta HTTP es un `404` cuyo cuerpo *sí* es JSON-RPC, así que, a diferencia del `421` de arriba, el `Client` de python te muestra este tal cual: + +```json +{"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "Session not found"}} +``` + +La solución es reconectar: sal del bloque `async with Client(...)` y entra en uno nuevo, que negocia una sesión nueva. Para un cliente de larga duración, eso significa capturar `MCPError` alrededor de tus llamadas y reconectar ante este mensaje en lugar de reintentar dentro de una sesión muerta. + +Si ocurre *sin* un reinicio, estás ejecutando más de un worker sin sticky sessions: cada worker mantiene su propia tabla de sesiones, así que una solicitud enrutada al equivocado acaba aquí. **[Desplegar y escalar](run/deploy.md)** y **[Atender clientes heredados](run/legacy-clients.md)** tienen todos los detalles y las dos soluciones (enrutamiento sticky o `stateless_http=True`). + +Para quien opera el servidor, la línea de log correspondiente es `Rejected request with unknown or expired session ID: `. Se registra con nivel `INFO`, así que es invisible con el umbral habitual de `WARNING`. Verla en ráfagas justo después de un despliegue es normal; todos los clientes conectados están reconectando. + +## `MCPError: Method not found` {#mcperror-method-not-found} + +Un lado envió una solicitud JSON-RPC para la que el otro no tiene handler, y `e.error.data` nombra el método. La causa habitual es un **desajuste de generación**: un método que existe en una revisión del protocolo y no en la otra, enviado a un par que habla la equivocada, como un `resources/subscribe` de la generación `2025` que llega a una conexión `2026-07-28`, o un `subscriptions/listen` exclusivo de `2026` enviado por un cliente fijado en `mode="legacy"`. **[Versiones del protocolo](protocol-versions.md)** es el mapa de qué habla cada lado, y la otra causa legítima (una capacidad opcional para la que nunca registraste un handler) está en **[Autocompletado](servers/completions.md)**. + +Hay una cosa que **no** produce este error, aunque es una solicitud que el protocolo moderno eliminó: una herramienta que llama a `ctx.elicit()` en una conexión `2026-07-28`. El servidor se niega siquiera a *enviar* esa solicitud, así que lo que obtienes en su lugar es `Cannot send 'elicitation/create': ...`, más abajo en esta página. + +## `MCPError: Client did not declare the form elicitation capability required by resolver ''` {#mcperror-client-did-not-declare-the-form-elicitation-capability-required-by-resolver-name} + +Tu servidor quiere preguntarle algo al usuario, y este cliente nunca dijo que se le pudiera preguntar. + +Un resolutor de elicitación (elicitation) se niega de entrada cuando el cliente conectado no declaró la elicitación por formulario, y `e.error.data` nombra exactamente lo que falta: + +```json +{ + "code": -32021, + "message": "Client did not declare the form elicitation capability required by resolver 'server:ask_to_confirm'", + "data": {"requiredCapabilities": {"elicitation": {"form": {}}}} +} +``` + +Pasa `elicitation_callback=` a `Client(...)`. Registrar el callback *es* la declaración de la capacidad; no hay un segundo interruptor: + +```python +async def main() -> None: + async with Client(mcp, elicitation_callback=handle_elicitation) as client: + result = await client.call_tool("book_table", {"date": "Friday"}) +``` + +**[Callbacks del cliente](client/callbacks.md)** enumera los demás (`sampling_callback`, `list_roots_callback`), cada uno de los cuales es una declaración del mismo modo. + +!!! info + `-32021` es `MISSING_REQUIRED_CLIENT_CAPABILITY`, uno de los tres códigos de error que añade + la especificación 2026-07-28. Ninguno de ellos es una clase de excepción: todos llegan como + `MCPError`, y `e.error.code` es donde mirar. `mcp.types` exporta las constantes. Los otros dos + son `-32020` `HEADER_MISMATCH` (una cabecera HTTP discrepa del cuerpo de la solicitud a la que + acompaña) y `-32022` `UNSUPPORTED_PROTOCOL_VERSION` (la solicitud nombraba una versión que + este servidor no habla). Un cliente SDK conforme no puede producir ninguno de los dos, así que + si ves uno, mira lo que sea que esté reescribiendo solicitudes entre tu cliente y tu servidor. + +## `MCPError: Elicitation not supported` {#mcperror-elicitation-not-supported} + +La misma carencia que `Client did not declare the form elicitation capability ...`, expresada por las vías que no comprueban de entrada: el servidor necesitaba que se respondiera una elicitación, y el cliente conectado no registró ningún `elicitation_callback`. + +Este lo ves desde `ctx.elicit()` en una conexión heredada, y en cualquier conexión desde una pregunta de varias idas y vueltas (multi-round-trip) devuelta (**[Solicitudes de varias idas y vueltas](handlers/multi-round-trip.md)**) que llega a un cliente sin callback para responderla. La solución es idéntica: pasa `elicitation_callback=` a `Client(...)`. No hay ninguna versión de "al usuario no se le preguntó" que tu herramienta reciba como un `decline`; un cliente al que no se le puede preguntar es una llamada fallida, así que diseña tus herramientas contando con ello. + +## `MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests.` {#mcperror-cannot-send-elicitationcreate-this-transport-context-has-no-back-channel-for-server-initiated-requests} + +Tu handler intentó contactar con el cliente a mitad de solicitud, en una conexión cuya llamada no tiene ningún canal capaz de llevar una solicitud desde el servidor. Hay tres configuraciones de servidor que ponen una llamada en esa situación. + +**Una conexión `2026-07-28`: cualquier transporte, siempre.** El protocolo moderno no tiene solicitudes iniciadas por el servidor en absoluto, así que el servidor se niega antes de enviar nada. `ctx.elicit()` dentro de una herramienta es la forma clásica de toparse con esto (en la primera prueba en memoria, ya que `Client(server)` negocia `2026-07-28` sin que se lo pidas), y pasar `elicitation_callback=` no cambia nada, porque ninguna solicitud llega nunca al cliente para que la responda: + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/troubleshooting/tutorial006.py" +``` + +```python +async def main() -> None: + async with Client(mcp) as client: + await client.call_tool("book_table", {"date": "Friday"}) +``` + +```text +mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests. +``` + +**Una conexión heredada en un servidor con `stateless_http=True`.** Sin estado significa que cada solicitud es su propio mundo: sin sesión, sin flujo de servidor a cliente y, por tanto, sin ningún lugar al que enviar un `elicitation/create` (o `sampling/createMessage`, o `roots/list`), ni siquiera en la generación que los tiene: + +```python title="server.py" hl_lines="16 23" +--8<-- "docs_src/troubleshooting/tutorial008.py" +``` + +**Una conexión heredada en un servidor con `json_response=True`.** El `POST` se responde con un único cuerpo JSON, y un único cuerpo solo lleva la respuesta, así que el flujo ligado a la solicitud que necesita un `ctx.elicit()` a mitad de solicitud tampoco existe aquí. La sesión, su `Mcp-Session-Id` y su flujo independiente siguen ahí; solo ha desaparecido el canal ligado a la solicitud. + +El mensaje nombra el método que no pudo enviar. `NoBackChannelError` es la clase que lanza el servidor, pero lo que se transmite lleva solo el `MCPError` base, así que la frase de arriba es la última línea de tu traceback, no el nombre de la clase. + +Para un cliente `2026-07-28` la solución es la misma en las tres: no vuelvas al cliente a mitad de llamada. Mueve la pregunta a un **resolutor** (o devuelve tú mismo un `InputRequiredResult`) y pasa a formar parte de la *respuesta*, que todas las conexiones pueden llevar: + +```python title="server.py" hl_lines="15-17 21" +--8<-- "docs_src/troubleshooting/tutorial007.py" +``` + +La misma pregunta, el mismo `elicitation_callback` en el cliente. La diferencia es interna: un resolutor permite al servidor *devolver* la pregunta desde la llamada en lugar de empujarla, así que nunca fluye nada de servidor a cliente. Eso rescata a todos los clientes `2026-07-28`, sea cual sea la configuración de las tres en que esté el servidor. A un cliente *heredado* no lo rescata la reescritura por sí sola: `2025-11-25` no tiene forma de devolver una pregunta, así que en una conexión heredada el resolutor sigue enviando `elicitation/create` por el canal ligado a la solicitud, y sigue necesitando un servidor que lo conserve: ni `stateless_http=True` ni `json_response=True`. **[Elicitación](handlers/elicitation.md)** cubre los resolutores; **[Solicitudes de varias idas y vueltas](handlers/multi-round-trip.md)** cubre lo que ocurre en lo que se transmite. + +!!! check + La herramienta con `ctx.elicit()` no está mal, es *anterior a 2026*. Conéctate con + `mode="legacy"` (el handshake clásico de `initialize`, especificación `2025-11-25` y anteriores) + a un servidor que no tenga ni `stateless_http=True` ni `json_response=True`, y funciona, porque + ahí el canal de servidor a cliente existe. + **[Versiones del protocolo](protocol-versions.md)** es la página sobre qué tiene cada versión. + +## `MCPError: Invalid or expired requestState` {#mcperror-invalid-or-expired-requeststate} + +El servidor no pudo verificar el token `requestState` que tu cliente devolvió como eco, así que rechazó la ronda. + +`requestState` es el token opaco de reanudación que una llamada **[de varias idas y vueltas](handlers/multi-round-trip.md)** lleva entre tramos. `MCPServer` lo sella al salir y verifica cada eco, y verifica *cada* `request_state` entrante en `tools/call`, `prompts/get` y `resources/read`, incluso para un handler que nunca emite uno. Así que un token que este proceso no selló se rechaza dondequiera que llegue: + +```python +async def main() -> None: + async with Client(mcp) as client: + await client.call_tool("forecast", {"city": "London"}, request_state="round-1-from-worker-a") +``` + +```text +mcp.shared.exceptions.MCPError: Invalid or expired requestState +``` + +El mensaje está congelado a propósito: lo que se transmite nunca revela qué comprobación falló. El motivo va al **log del servidor**, y leerlo es todo el diagnóstico: + +```text +WARNING mcp.server.request_state: requestState rejected on tools/call: malformed +``` + +Los motivos que verás realmente: + +* **`unknown key`** es el que importa. La clave de sellado por defecto se genera al arrancar el proceso, así que un reintento que cae en un **worker distinto**, en otra instancia detrás de un balanceador de carga o en el mismo servidor **después de un reinicio** se selló con una clave que este proceso nunca tuvo. No es un atacante; es el valor por defecto encontrándose con más de un proceso. +* **`audience`**: el token lo selló una instancia con un *nombre de servidor distinto*. El nombre es el claim de audiencia por defecto del sello, así que una flota debe compartir el nombre (o fijar un `RequestStateSecurity(audience=...)` explícito) además de las claves. +* **`expired`**: la ronda tardó más que el `ttl` del sello, que es de 600 segundos y por ronda, no por llamada. +* **`malformed`** / **`codec error`**: el token se alteró en tránsito, o nunca fue un token sellado. +* **`request binding`**: el token volvió con otra herramienta, otros argumentos u otro método. + +La solución multiproceso es un argumento (las *mismas* `keys` en todas las instancias) más una cosa que no es un argumento en absoluto: el mismo *nombre* de servidor (o un `audience=` compartido explícito). + +```python +mcp = MCPServer("Weather", request_state_security=RequestStateSecurity(keys=[key])) +``` + +`keys[0]` sella; todas las claves de la lista verifican, que es lo que hace posible la rotación sin tiempo de inactividad. **[Solicitudes de varias idas y vueltas](handlers/multi-round-trip.md#protecting-requeststate)** explica qué protege el sello y la secuencia de rotación, y **[Desplegar y escalar](run/deploy.md)** recorre todo el fallo de dos workers y su solución en dos partes. + +!!! tip + `keys=[...]` rechaza una clave débil de inmediato, con un mensaje inusualmente útil: + + ```text + ValueError: request-state keys must be at least 32 bytes of secret randomness; keys[0] is 7 bytes. Generate one with: python -c "import secrets; print(secrets.token_hex(32))" + ``` + + Haz lo que dice. + +## ¿Sigues sin resolverlo? {#still-stuck} + +* Si un mensaje que produjo el SDK no está en esta página, eso es un bug de documentación que vale la pena reportar por sí solo. +* Busca en el [gestor de incidencias](https://github.com/modelcontextprotocol/python-sdk/issues); la mayoría de las cadenas de error que aparecen ahí ya son el informe de alguien. +* ¿No encontraste nada? [Abre una incidencia](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml) con el traceback completo, o pregunta en [#python-sdk-dev en el Discord de MCP Contributors](https://discord.gg/6CSzBmMkjX). + +## Resumen {#recap} + +* `ExceptionGroup: unhandled errors in a TaskGroup` nunca es el error. Lee la **última línea**; capturar `MCPError` *dentro* del bloque `async with Client(...)` evita el envoltorio por completo. +* `call_tool` no lanza nada para una herramienta que falla. `Error executing tool ...` y `Unknown tool: ...` son resultados: comprueba `result.is_error`. +* `Client must be used within an async context manager` -> usa `async with`. `Use @tool() instead of @tool` -> añade los paréntesis. +* `Tool already exists:` en el log del servidor es la única señal de que dos herramientas con el mismo nombre se fundieron en una. +* Un 421, tres formas de escribirlo: `Server returned an error response` (el `Client` de python), `421 Misdirected Request` / `Invalid Host header` (todo lo demás), `Invalid Host header: ` (el log del servidor). Solución: `transport_security=TransportSecuritySettings(allowed_hosts=[...])`. +* `Task group is not initialized` -> una app montada cuyo lifespan de la app host nunca entró en `mcp.session_manager.run()`. +* `Session not found` -> el servidor se reinició; reconecta. +* `Cannot send 'elicitation/create': ... no back-channel ...` -> `ctx.elicit()` necesita un canal de servidor a cliente: una conexión `2026-07-28` nunca lo tiene, `stateless_http=True` quita el heredado y `json_response=True` quita el ligado a la solicitud. Usa un resolutor (un cliente heredado necesita además un servidor que conserve el canal). Su vecino `Method not found` es una solicitud de un método que la revisión del protocolo del otro lado no tiene. +* `Client did not declare the form elicitation capability ...` y `Elicitation not supported` -> al cliente le falta `elicitation_callback=`. +* `Invalid or expired requestState` nunca dice por qué en lo que se transmite. El log del servidor sí; `unknown key` significa compartir `RequestStateSecurity(keys=[...])` entre los workers. diff --git a/i18n/es/pages/whats-new.md b/i18n/es/pages/whats-new.md new file mode 100644 index 0000000000..407a4af802 --- /dev/null +++ b/i18n/es/pages/whats-new.md @@ -0,0 +1,216 @@ +--- +translation: + sections: [cfe01c0c5863dfa2, 11d93f1fa09eadf5, a7392996acf1ad8f, 875eb2889263424e] + tool: 1 +--- +# Novedades de la v2 {#whats-new-in-v2} + +En la v2 pasaron dos cosas a la vez. Se **reconstruyó el SDK**: un motor nuevo bajo el cliente y el servidor, un `Client` de primera clase y una serie de renombramientos con los que un código v1 se topa en su primer import. Y **el protocolo avanzó**: la v2 habla la revisión 2026-07-28 de MCP, que elimina el handshake de conexión, la sesión y toda solicitud iniciada por el servidor, sin dejar varados a los clientes que ya tienes. + +Esta página es el recorrido por ambas mitades, una sección por titular, cada una terminando en la página que se ocupa del tema. No es el manual de portado. Ese es la **[Guía de migración](migration.md)**: cada cambio incompatible, con el código de antes y después. + +!!! note "La v2 es la línea estable" + `pip install mcp` instala la 2.x, e **[Instalación](get-started/installation.md)** tiene la + línea de instalación para copiar y pegar. Si algo en la v2 se rompe, te sorprende o te frena, + [cuéntanoslo](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml). + +## El SDK: de la v1 a la v2 {#the-sdk-v1-to-v2} + +### `FastMCP` ahora es `MCPServer` {#fastmcp-is-now-mcpserver} + +La clase de servidor de alto nivel cambió de nombre, y su módulo con ella. Es lo primero con lo que se topa todo servidor v1, porque la ruta de import antigua desapareció en lugar de quedar obsoleta: + +```python +from mcp.server import MCPServer # v1: from mcp.server.fastmcp import FastMCP + +mcp = MCPServer("Demo") # v1: FastMCP("Demo") +``` + +Para un servidor construido con decoradores, eso es además casi todo el portado. `@mcp.tool()`, `@mcp.resource()` y `@mcp.prompt()` aceptan lo mismo que aceptaban en la v1 (`@mcp.resource()` añade un argumento nombrado opcional `security=`), y el esquema de entrada sigue saliendo de tus anotaciones de tipo. En los bordes: todo lo que estaba bajo `mcp.server.fastmcp.*` vive ahora bajo `mcp.server.mcpserver.*`, `ctx.fastmcp` es `ctx.mcp_server`, `get_context()` desapareció (declara un parámetro `ctx: Context` en su lugar) y la excepción base `FastMCPError` es `MCPServerError`. La **[Guía de migración](migration.md#fastmcp-renamed-to-mcpserver)** tiene la tabla de imports. + +### `Resolve`: la nueva forma de pedir datos al usuario {#resolve-the-new-way-to-ask-the-user-for-input} + +No todo lo que una herramienta necesita debería venir del modelo. Novedad de la v2: un parámetro de herramienta anotado con `Resolve(fn)` lo rellena una función que escribes tú, sin que el modelo lo vea, y esa función puede devolver `Elicit(...)` para poner una pregunta delante del usuario. Es la forma preferida de obtener cualquier cosa del cliente en mitad de una llamada: el SDK transporta la pregunta por el mecanismo que la conexión admita (una solicitud de elicitación (elicitation) en vivo para un cliente heredado, una solicitud de varias idas y vueltas (multi-round-trip) en 2026-07-28), así que un solo cuerpo de herramienta sirve para ambas generaciones. **[Dependencias](handlers/dependencies.md)** es la página. + +!!! note + Las otras dos formas siguen ahí para cuando las necesites: `ctx.elicit()` sigue funcionando + para clientes en conexiones heredadas (**[Elicitación](handlers/elicitation.md)**), y un handler + puede devolver él mismo un `InputRequiredResult` y dirigir las rondas a mano, que es también + como viajan las solicitudes de muestreo (sampling) y de roots en 2026-07-28 + (**[Solicitudes de varias idas y vueltas](handlers/multi-round-trip.md)**). + +### Un `Client` de primera clase {#a-first-class-client} + +La v1 te entregaba tres capas anidadas: un gestor de contexto de transporte que producía flujos en crudo, una `ClientSession` envolviéndolos y un `await session.initialize()` llamado a mano. La v2 tiene un solo objeto: + +```python title="client.py" hl_lines="14-18" +--8<-- "docs_src/client/tutorial001.py" +``` + +`Client` acepta un objeto servidor (en memoria, sin transporte: la historia de las pruebas), una URL (Streamable HTTP) o cualquier gestor de contexto de transporte como `stdio_client(...)`. Entrar en `async with` conecta y negocia la versión del protocolo, sea cual sea la generación que hable el servidor; `client.server_capabilities` y `client.protocol_version` simplemente están ahí después, y `client.server_info` también cuando el servidor se identifica (ahora es `Implementation | None`, porque la identidad en la generación 2026 es opcional). Los callbacks de muestreo y elicitación que registraste en la v1 siguen funcionando (sus cuerpos ven el mismo renombramiento de atributos a snake_case que todo lo demás en esta página), ahora también responden a las solicitudes dentro de resultados al estilo 2026 (más abajo) y se ejecutan concurrentemente en lugar de una a una. `ClientSession` sigue debajo para quien quiera la superficie de bajo nivel, y `client.session` te la entrega; también cambió (corre sobre el nuevo motor de despacho, y algunas de sus propias firmas cambiaron), así que lee la **[Guía de migración](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)** antes de bajar a ese nivel. + +**[El Client](client/index.md)** lo presenta, **[Transportes del cliente](client/transports.md)** cubre las tres formas de conexión, **[Callbacks del cliente](client/callbacks.md)** cubre los callbacks en sí y **[Pruebas](get-started/testing.md)** muestra el patrón en memoria que sustituye al helper `create_connected_server_and_client_session()` de la v1. + +### El `Server` de bajo nivel se reconstruyó, no se renombró {#the-low-level-server-was-rebuilt-not-renamed} + +Si trabajas en la capa JSON-RPC, esta es la parte de la v2 donde "todo es distinto". Aquí está el mismo servidor de una sola herramienta de las dos maneras; haz clic en los marcadores para ver qué se movió. + + + +```python title="v1" +from typing import Any + +import mcp.types as types +from mcp.server.lowlevel import Server + +server = Server("Bookshop") + + +@server.list_tools() # (1)! +async def list_tools() -> list[types.Tool]: + return [ # (2)! + types.Tool( + name="search_books", + description="Search the catalog by title or author.", + inputSchema={ # (3)! + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + }, + ) + ] + + +@server.call_tool() +async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentBlock]: # (4)! + if name != "search_books": + raise ValueError(f"Unknown tool: {name}") # (5)! + ctx = server.request_context # (6)! + return [types.TextContent(type="text", text=f"Found 3 books matching {arguments['query']!r}.")] # (7)! +``` + +1. Los handlers se registran con decoradores (llamados, con paréntesis), en cualquier momento después de que exista el servidor. +2. Devuelves una `list[Tool]` sin más y el SDK la envuelve en un `ListToolsResult`. +3. Los campos son camelCase en Python, y el esquema **se aplica**: el SDK valida con jsonschema los argumentos de `call_tool` contra él antes de que se ejecute tu función, por eso `arguments["query"]` más abajo es seguro. +4. Un solo handler `call_tool` atiende todas las herramientas, y recibe el nombre de la herramienta y los argumentos ya validados, desempaquetados y nunca `None`. +5. Lanzar una excepción es como una herramienta v1 señala un fallo: cualquier excepción se captura y se devuelve como `CallToolResult(isError=True)` con `str(e)` como texto, así que el modelo que llama lee este mensaje y puede reintentar. +6. El contexto viene de una ContextVar ambiental, a la que se llega a través del objeto servidor en mitad de la solicitud. +7. Los bloques de contenido sueltos se envuelven en un `CallToolResult` por ti. + +```python title="v2" +--8<-- "docs_src/whats_new/tutorial001.py" +``` + +1. Ahora los campos son snake_case, y el esquema **se anuncia pero nunca se aplica**: nada comprueba los argumentos antes de que se ejecute tu handler. +2. Todos los handlers tienen la misma forma: `async (ctx, params) -> result`. El contexto es el primer argumento (`ctx.session`, `ctx.request_id` y `ctx.protocol_version` viven en él); aquí es adonde fue a parar `server.request_context`. +3. Construyes tú el `ListToolsResult` completo. Devolver una lista suelta es ahora un `TypeError` del lado del servidor, no algo que el SDK envuelva. +4. Entran params tipados (`params.name`, `params.arguments`), sale un resultado completo. Nada se desempaqueta, envuelve ni convierte por ti. +5. La misma comprobación, distinto verbo. Un `ValueError` aquí llegaría al modelo como un `-32603` opaco (ver más abajo), así que un error de protocolo deliberado se lanza como `MCPError`: pasa con su código y su mensaje intactos, y `-32602` con este texto es la propia respuesta de la especificación para una herramienta desconocida. +6. `params.arguments` puede ser `None`; la v1 lo dejaba en `{}` por defecto antes de que tu código lo viera. Sin validación delante del handler, esta línea es imprescindible. +7. Una excepción inesperada lanzada aquí se convierte en un error de protocolo **saneado**, `-32603` `"Internal server error"`: el modelo nunca ve el mensaje. Para un fallo que el modelo deba leer y al que deba reaccionar, devuelve `CallToolResult(is_error=True, ...)`. +8. Los handlers son argumentos del constructor, así que la superficie del servidor está completa en el momento en que existe; `add_request_handler()` es la vía de escape tras la construcción, y la puerta a los métodos personalizados. + +El ejemplo es el patrón. De forma más general: todos los handlers tienen la misma forma, con params tipados de entrada y un tipo de resultado completo de salida; la antigua comprobación con jsonschema de los argumentos de las herramientas desapareció; una excepción es un error de protocolo, nunca un resultado de herramienta con `is_error=True`; y la ContextVar ambiental `server.request_context` desapareció. Los métodos personalizados con espacio de nombres de proveedor son de primera clase mediante `add_request_handler(method, params_type, handler)`, que valida los params entrantes contra tu modelo antes de que se ejecute tu handler. Y una lista `middleware` (marcada deliberadamente como provisional) envuelve cada mensaje entrante, sustituyendo a los métodos privados `_handle_*` que la gente solía sobrescribir. + +Por debajo, el bucle de recepción `BaseSession` de la v1 se reemplazó por un motor de despacho que ahora comparten el cliente y el servidor, y es lo que hace ciertas varias cosas de esta página a la vez: un solo objeto `Server` sirve ambas generaciones del protocolo, `Client(server)` despacha en proceso sin enmarcado JSON-RPC, y una solicitud de cliente que agota su tiempo de espera ahora cancela de verdad el handler del lado del servidor. + +**[El Server de bajo nivel](advanced/low-level-server.md)** es la página; la **[Guía de migración](migration.md#lowlevel-server-decorator-based-handlers-replaced-with-constructor-on_-params)** recorre cada gancho eliminado. Si nunca bajaste por debajo de `MCPServer`, nada de esto te afecta. + +### Los tipos del protocolo se movieron a `mcp-types`, y todos los campos son snake_case {#the-wire-types-moved-to-mcp-types-and-every-field-is-snake_case} + +Los tipos del protocolo viven ahora en su propia distribución, `mcp-types`. No depende de nada más que pydantic y typing-extensions, así que una pasarela, un proxy o un generador de código pueden consumir las formas que MCP transmite sin instalar una pila HTTP: un proyecto así instala `mcp-types` e importa `mcp_types`. El propio `mcp` depende de ese paquete en una versión exacta y lo reexpone, así que el código que depende del SDK sigue escribiendo `import mcp.types as types` y `from mcp.types import Tool` (un alias permanente, cada nombre es el mismo objeto) y declara solo su única dependencia real, `mcp`. La regla práctica: importa a través del paquete del que realmente dependas. + +En esos tipos, cada atributo de Python es ahora snake_case: `result.is_error`, `tool.input_schema`, `listing.next_cursor`. El JSON que realmente se transmite es camelCase, exactamente como antes; solo cambió la grafía de los atributos. Dos valores por defecto más estrictos lo acompañan: los campos desconocidos se ignoran en lugar de reenviarse de vuelta (pon los extras en `_meta`), y ambos lados validan el tráfico contra la versión del protocolo que negociaron. Consulta la **[Guía de migración](migration.md#field-names-changed-from-camelcase-to-snake_case)** para ver la tabla de renombramientos. + +### La configuración del transporte se movió a `run()` {#transport-configuration-moved-to-run} + +`MCPServer(...)` trata de lo que tu servidor *es*: su nombre, sus instrucciones, su lifespan (ciclo de vida del servidor), su autenticación. Cómo se *sirve* pertenece ahora a `run()` y a los constructores de la app, que es adonde fueron `host`, `port`, `stateless_http`, `json_response`, las rutas de los endpoints y `transport_security` (`MCPServer("x", port=9000)` es un `TypeError`). Las sobrecargas están tipadas por transporte, así que tu editor te dice qué opciones acepta `stdio` y cuáles `streamable-http`. Una eliminación que conviene conocer: `mount_path` desapareció; montar la app ASGI es la forma admitida de servir bajo un prefijo. + +**[Ejecutar tu servidor](run/index.md)** cubre las opciones; **[Añadir a una app existente](run/asgi.md)** cubre el montaje. + +### Comportamiento que cambia sin un error de import {#behavior-that-changes-without-an-import-error} + +Los renombramientos se anuncian solos. Estos no: + +* **Las funciones síncronas se ejecutan en un hilo de trabajo.** Una herramienta `def` (o recurso, prompt o resolutor) ya no bloquea el bucle de eventos; la contrapartida es que su cuerpo ya no se ejecuta *en* el hilo del bucle de eventos, lo que importa para código afín a un hilo. Los handlers `async def` no cambian. **[Guía de migración](migration.md#sync-handler-functions-now-run-on-a-worker-thread)**. +* **`MCPError` (el `McpError` de la v1) lanzado dentro de una herramienta es ahora un error de protocolo.** El modelo nunca lo ve. Cualquier otra excepción sigue convirtiéndose en un resultado `is_error=True` que el modelo puede leer y al que puede reaccionar. **[Manejo de errores](servers/handling-errors.md)** explica la división. +* **Los resultados se validan antes de salir.** Un `Tool` construido a mano cuyo `input_schema` sea `{}` ahora falla en `tools/list` (la especificación exige `"type": "object"`). Los servidores construidos sobre `@mcp.tool()` nunca ven esto; el SDK escribe sus esquemas. +* **Tu cliente valida lo que recibe.** `list_tools()` y `call_tool()` comprueban la respuesta del servidor contra la versión del protocolo negociada, así que un servidor no del todo válido que el análisis permisivo de la v1 toleraba ahora lanza `pydantic.ValidationError`. Si te conectas a servidores que no controlas, cuenta con ser tú quien los descubra; la **[Guía de migración](migration.md#client-validates-inbound-traffic-against-the-protocol-schema)** tiene los detalles. +* **Las plantillas de URI son ahora RFC 6570 de verdad.** `{+path}`, `{?query}` y compañía funcionan, la coincidencia es exacta en lugar de laxa por regex, y el path traversal en los valores extraídos se rechaza por defecto. Las plantillas más estrictas fallan al decorar, no en la primera solicitud. **[Plantillas de URI](servers/uri-templates.md)**. +* **El lifespan de Streamable HTTP se ejecuta una vez**, al arrancar, y su estado lo comparten todas las sesiones y solicitudes. En la v1 se ejecutaba una vez por sesión, y una vez por solicitud con `stateless_http=True`. Los pools y cachés construidos en un lifespan se vuelven drásticamente más baratos; cualquier cosa que adquiriera ahí un recurso por conexión pertenece ahora al cuerpo del handler. **[Lifespan](handlers/lifespan.md)**. +* **`mcp dev` y `mcp install` fijan el entorno que lanzan** a la versión del SDK que tienes instalada. Ambos comandos ejecutan tu servidor en un entorno nuevo `uv run --with ...`, que antes resolvía `mcp` a la versión estable más reciente en lugar de la versión contra la que estás desarrollando. **[Guía de migración](migration.md#mcp-dev-and-mcp-install-pin-the-spawned-environment-to-your-sdk-version)**. +* **El cliente HTTP es ahora `httpx2`, no `httpx`.** El cambio de dependencia altera lo que tu código captura y pasa (`httpx2.AsyncClient`, `httpx2.ConnectError`), y cambia cómo se verifican los certificados TLS: `httpx2` valida mediante `truststore` contra el almacén de confianza del sistema operativo en lugar de la lista de CA incluida en certifi. La mayoría de los entornos ni se enteran; un contenedor mínimo sin almacén de CA del sistema, o una CA privada que solo conocía el paquete de certifi, empieza a fallar en el handshake TLS. Define `SSL_CERT_FILE`/`SSL_CERT_DIR` o pasa `verify=ssl_context` a tu cliente. **[Guía de migración](migration.md#httpx-and-httpx-sse-replaced-by-httpx2)**. + +### Eliminado sin más {#removed-outright} + +Cada uno de estos es una sección de la **[Guía de migración](migration.md)**: + +* El **transporte WebSocket**, en ambos lados, y el extra `mcp[ws]`. Nunca formó parte de la especificación de MCP. +* La API **experimental Tasks** (`mcp.*.experimental`). 2026-07-28 saca las tareas del protocolo principal y las lleva a una extensión oficial ([SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663)), que este SDK todavía no implementa. +* `mcp.shared.version`, `mcp.shared.progress` y `mcp.shared.session` (con el stub `RequestResponder` que importaban las anotaciones de `message_handler` de la v1) como rutas de import. (`mcp.types` *no* se elimina: permanece como alias permanente del paquete independiente `mcp_types`.) +* La grafía obsoleta `streamablehttp_client`, y el callback `get_session_id` de `streamable_http_client` (que ahora produce exactamente dos flujos). +* `McpError`, renombrado **`MCPError`** con un constructor directo `(code, message, data)`. +* `MCPServer.get_context()`, `mount_path=` y, en el `Server` de bajo nivel, los métodos decoradores, la ContextVar y los diccionarios de handlers. + +## El protocolo: de 2025-11-25 a 2026-07-28 {#the-protocol-2025-11-25-to-2026-07-28} + +La v2 implementa la revisión 2026-07-28, y sirve **ambas** revisiones a la vez: la misma `streamable_http_app()` (y el mismo servidor stdio) responde al `initialize` de un cliente de la generación 2025 y a las solicitudes de un cliente de la generación 2026 sin nada que configurar, ninguna bandera que activar ni un despliegue aparte. Servir la revisión nueva no deja varado a un cliente en la antigua. Lo que sigue es lo que cambia la revisión nueva en sí. + +### Sin handshake, sin sesión {#no-handshake-no-session} + +Un cliente 2026-07-28 no abre una conexión, negocia y luego habla. Cada solicitud lleva su versión de protocolo, la información del cliente y las capacidades del cliente en `_meta`, y la única llamada de descubrimiento, `server/discover`, es una solicitud normal como cualquier otra. `Client` hace lo correcto por defecto: sondea `server/discover` una vez y recurre al handshake `initialize` si el servidor es más antiguo. + +Sobre Streamable HTTP no hay `Mcp-Session-Id` en el camino 2026, y ese es el titular operativo: **nada ata una solicitud moderna a un worker**, así que cualquier réplica detrás de un balanceador de carga round-robin normal puede responderla. Dos matices honestos. Tus clientes de la generación 2025 (hoy, eso es la mayoría) siguen abriendo sesiones y siguen necesitando la afinidad que necesitaran en la v1; para ellos no cambia nada. Y lo único que un reintento de *varias idas y vueltas* tiene que llevar entre workers es su `request_state` sellado, cuya clave por defecto se genera por proceso, así que un despliegue escalado horizontalmente pasa `RequestStateSecurity(keys=[...])`. (`stateless_http=True` no tiene relación: solo afecta a cómo se sirve a los clientes de la generación 2025, y el tráfico 2026 nunca lo lee; si ya lo tenías activado en la v1, no cambia nada.) + +**[Versiones del protocolo](protocol-versions.md)** es el lado del cliente de esto, **[Desplegar y escalar](run/deploy.md)** es la lista de comprobación del operador (la lista de hosts permitidos, la clave de `request_state`, las notificaciones entre réplicas) y **[Atender clientes heredados](run/legacy-clients.md)** es la historia de ambas generaciones a la vez. + +### El servidor no puede llamar al cliente: solicitudes de varias idas y vueltas {#the-server-cannot-call-the-client-multi-round-trip-requests} + +Toda solicitud iniciada por el servidor desaparece en 2026-07-28: elicitación por push, muestreo, `roots/list`. En una conexión 2026 no hay canal para ellas, así que `ctx.elicit()` y `ctx.session.create_message()` fallan ahí con `NoBackChannelError`, porque no hay canal de retorno (back-channel) (siguen funcionando para clientes heredados). + +El reemplazo le da la vuelta a la llamada. Una herramienta que necesita algo del usuario *devuelve* la pregunta (`InputRequiredResult`), el cliente la responde con los mismos callbacks que siempre tuvo, y la llamada se reintenta con las respuestas adjuntas. `Client` dirige ese bucle por ti. En el servidor rara vez construyes tú el resultado, porque lo hace una **[dependencia](handlers/dependencies.md)**: anota un parámetro con `Resolve(ask_quantity)`, donde `ask_quantity` es una función ordinaria que escribes tú, y el SDK pregunta por el mecanismo que la conexión admita, una solicitud de elicitación en vivo en una sesión heredada o una solicitud de varias idas y vueltas en 2026. Un solo cuerpo de herramienta, ambas generaciones: + +```python title="dual_era.py" hl_lines="24 37-38" +--8<-- "docs_src/legacy_clients/tutorial001.py" +``` + +Ese archivo es la propuesta en un solo lugar: un servidor, una herramienta respaldada por `Resolve`, y un cliente heredado más un cliente moderno recibiendo ambos su respuesta, en memoria. **[Solicitudes de varias idas y vueltas](handlers/multi-round-trip.md)** explica el mecanismo (incluido `request_state`, que el SDK sella y verifica por ti); **[Elicitación](handlers/elicitation.md)** cubre cómo preguntar. + +!!! warning "Este es el único lugar donde un servidor v1 portado cambia de comportamiento" + Tus propias pruebas se lo encuentran primero: `Client(mcp)` negocia 2026-07-28 contra tu + servidor v2 por defecto, así que una herramienta que llama a `ctx.elicit()` falla en una prueba + que pasaba en la v1. Mueve la pregunta a un parámetro `Resolve(...)` (portátil entre + generaciones), o fija el cliente de pruebas a `mode="legacy"` si de verdad quieres el + comportamiento push. + +### Roots, muestreo y logging del protocolo quedan obsoletos; `ping` se elimina {#roots-sampling-and-protocol-logging-are-deprecated-ping-is-removed} + +[SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) declara obsoletas tres *capacidades* enteras, en todas las versiones del protocolo: roots, muestreo y logging a nivel MCP (`ctx.info()` y compañía). Es un eje distinto del canal de retorno ausente de arriba; obsoleto es solo un aviso, todo sigue funcionando contra sesiones de la generación 2025 y nada cambia en lo que se transmite. Lo que notas es `MCPDeprecationWarning`, que es un `UserWarning`, así que se imprime por defecto; cuenta con que tu primer `ctx.info(...)` tras la actualización lo diga. + +`ping` es más estricto: eliminado del protocolo, no obsoleto. Dos de los métodos independientes de las funcionalidades obsoletas se eliminan en 2026-07-28 del mismo modo, `logging/setLevel` y el `notifications/roots/list_changed` del cliente, y las notificaciones de progreso son ahora solo de servidor a cliente. + +**[Funcionalidades obsoletas](deprecated.md)** tiene la tabla completa, el reemplazo de cada una y el filtro de una línea si necesitas un log silencioso mientras atiendes clientes heredados. + +### Las notificaciones de cambio se convierten en un solo flujo {#change-notifications-become-one-stream} + +En 2026-07-28 el flujo HTTP GET independiente y `resources/subscribe` se sustituyen por `subscriptions/listen`: el cliente abre un único flujo de larga duración y nombra los tipos de notificación que quiere. `MCPServer` lo sirve por defecto; publicas con `await ctx.notify_resource_updated(uri)` (y `notify_tools_changed()`, etc.), un middleware puede rechazar una solicitud de escucha por llamante, y los despliegues con varias réplicas conectan un `SubscriptionBus` compartido. En el cliente, `async with client.listen(...)` abre el flujo: el filtro entra como argumentos nombrados, vuelven eventos de cambio tipados, y `sub.honored` es el subconjunto que el servidor aceptó entregar. + +**[Suscripciones](handlers/subscriptions.md)** cubre la publicación y el servicio, **[su gemela en Clientes](client/subscriptions.md)** el extremo que observa, y **[Desplegar y escalar](run/deploy.md)** el bus. + +### El resto, rápido {#the-rest-quickly} + +* **La identidad es opcional, metadatos por mensaje.** La clave `clientInfo` de `_meta` del lado de la solicitud es opcional (el par obligatorio es `protocolVersion` + `clientCapabilities`), y `serverInfo` salió del cuerpo del resultado de `server/discover`: los servidores la estampan en el `_meta` de cada resultado de la generación 2026 en su lugar ([spec #3002](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3002)). El SDK siempre la estampa; `client.server_info` es `None` cuando un servidor no se identifica (por ejemplo, un middleware quitó la clave). **[El Server de bajo nivel](advanced/low-level-server.md)** muestra la marca en lo que se transmite. +* **Las solicitudes se pueden enrutar sin analizar cuerpos.** Las solicitudes HTTP modernas llevan `Mcp-Method` (y, para las tres llamadas de tipo herramienta, `Mcp-Name`); una propiedad del esquema de entrada de una herramienta anotada con `x-mcp-header` se refleja en una cabecera `Mcp-Param-*` y el servidor la contrasta ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243)). Las pasarelas y los limitadores de tasa pueden enrutar solo con cabeceras; la **[Guía de migración](migration.md#servers-validate-mcp-param-headers-against-the-request-body-sep-2243)** tiene las reglas. +* **Los resultados llevan indicaciones de caché.** Los resultados de listado y lectura declaran `ttlMs` y `cacheScope` ([SEP-2549](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549)); los fijas por método con `cache_hints=`, y `Client` los respeta con una caché de respuestas integrada. Un servidor que no envía indicaciones (todo servidor anterior a 2026) ve un tráfico idéntico, sin caché. **[Indicaciones de caché](client/caching.md)**. +* **Las extensiones son de primera clase.** Servidores y clientes declaran paquetes de capacidades opcionales bajo identificadores DNS inversos ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)); la extensión integrada `Apps` (MCP Apps) es la referencia. **[Extensiones](advanced/extensions.md)** y **[MCP Apps](advanced/apps.md)**. +* **Los códigos de error se estandarizaron.** Un recurso inexistente es `-32602` con la URI en `error.data`, y los nuevos códigos reservados por la especificación aparecen como `-32020` (cabecera no coincidente), `-32021` (falta una capacidad obligatoria) y `-32022` (versión de protocolo no admitida). **[Solución de problemas](troubleshooting.md)** está indexada por los mensajes exactos. +* **La autorización se volvió más difícil de usar mal.** El cliente valida el `iss` devuelto con el código de autorización ([RFC 9207](https://datatracker.ietf.org/doc/html/rfc9207); tu `callback_handler` devuelve ahora un `AuthorizationCodeResult`), envía `application_type` cuando se registra y nunca reutiliza credenciales contra un servidor de autorización distinto. Novedad en el rincón empresarial: el flujo de aserción de identidad de [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990). La **[Guía de migración](migration.md)** enumera cada cambio de OAuth; **[OAuth para clientes](client/oauth-clients.md)** y **[Aserción de identidad](client/identity-assertion.md)** son las páginas. +* **Todo servidor es trazable.** OpenTelemetry viene activado por defecto como middleware: cada solicitud obtiene un span de servidor, sin coste hasta que el proceso configura un exportador. Cuando ambos extremos ejecutan el SDK, el cliente también propaga el contexto de traza W3C en `_meta`, así que las trazas se unen. **[OpenTelemetry](run/opentelemetry.md)**. + +## ¿Actualizas desde la v1? {#upgrading-from-v1} + +* La **[Guía de migración](migration.md)** es la lista completa y exacta de lo que hay que cambiar; esta página era el porqué. +* **La v1.x no se va a ningún lado.** Pasa a mantenimiento, sigue recibiendo correcciones críticas y parches de seguridad, y nada de la publicación de la especificación 2026-07-28 la rompe; su documentación vive en [/v1/](https://py.sdk.modelcontextprotocol.io/v1/). Si publicas una biblioteca que depende de `mcp` y no estás listo para migrar, mantén un límite superior (por ejemplo `mcp>=1.28,<2`) para que una resolución sin fijar se quede en la 1.x. +* ¿Algo tosco, confuso o roto? **[Envía comentarios sobre la v2](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml)**; se lee todo. diff --git a/i18n/fr/glossary.json b/i18n/fr/glossary.json new file mode 100644 index 0000000000..4ae0de4da5 --- /dev/null +++ b/i18n/fr/glossary.json @@ -0,0 +1,307 @@ +{ + "keep": [ + "MCP", + "Model Context Protocol", + "MCPServer", + "FastMCP", + "ClientSession", + "Context", + "ctx", + "stdio", + "Streamable HTTP", + "SSE", + "JSON-RPC", + "JSON", + "OAuth", + "PKCE", + "JWT", + "CIMD", + "HTTP", + "HTTPS", + "TLS", + "CORS", + "URI", + "URL", + "ASGI", + "WebSocket", + "API", + "SDK", + "CLI", + "IDE", + "LLM", + "SEP", + "RFC", + "Python", + "TypeScript", + "Node.js", + "PyPI", + "Pydantic", + "Starlette", + "FastAPI", + "uvicorn", + "httpx", + "anyio", + "asyncio", + "trio", + "pytest", + "OpenTelemetry", + "Inspector", + "Claude", + "GitHub", + "VS Code", + "Windows", + "macOS", + "Linux", + "llms.txt", + "2026-07-28", + "2025-11-25", + "2025-06-18", + "2025-03-26" + ], + "terms": [ + { + "source": "tool", + "target": "outil", + "note": "MCP protocol noun (a server exposes tools), masculine: un outil / les outils, l’appel d’outil. First mention on a page may read \"outil (tool)\". Wire identifiers such as `tools/call` and the `@mcp.tool()` decorator are code and stay untouched; the Inspector's **Tools** tab is a UI label and stays English. Provisional pending native review." + }, + { + "source": "resource", + "target": "ressource", + "note": "MCP protocol noun (data a server exposes for reading) and the general noun alike, feminine, French spelling with double s: la ressource / les ressources. `resources/read` and `@mcp.resource()` are code; the Inspector's **Resources** tab is a UI label and stays English." + }, + { + "source": "prompt", + "target": "prompt", + "note": "The MCP feature (a reusable prompt template a server exposes) and the general AI sense; kept in English as French AI writing does. Masculine: le prompt / les prompts. Not invite, which is the command-line prompt (invite de commandes) and the wrong sense. `prompts/get` and `@mcp.prompt()` are code. Provisional pending native review." + }, + { + "source": "sampling", + "target": "échantillonnage", + "note": "The (deprecated) client feature that lets a server borrow the client's model for a completion. Masculine: l’échantillonnage. First mention on a page reads \"échantillonnage (sampling)\" so the reader can map it to `sampling/createMessage`, which is code. Provisional pending native review; keeping the English word is the open alternative." + }, + { + "source": "roots", + "target": "racines", + "note": "The (deprecated) client feature listing the workspace directories a client exposes. Feminine plural: les racines (\"the client's roots\" → les racines du client, i.e. ses répertoires racine). First mention on a page reads \"racines (roots)\" so the reader can map it to `roots/list`, which is code; a `Root` object in code font stays Latin. Provisional pending native review; keeping roots in English is the open alternative." + }, + { + "source": "elicitation", + "target": "élicitation", + "note": "The MCP feature where the server asks the user a question through the client mid-request. The French noun exists (élicitation des exigences) and is exact; feminine: l’élicitation. First mention on a page reads \"élicitation (elicitation)\". `elicitation/create`, `ctx.elicit()` and the `Elicit` class are code. Provisional pending native review." + }, + { + "source": "capability", + "target": "capacité", + "note": "What client and server declare during initialization (\"capability negotiation\" → la négociation des capacités): la capacité / les capacités. Not fonctionnalité, which is a feature and a different thing, and not aptitude. The `capabilities` field and keys such as `sampling.tools` stay Latin. Provisional pending native review." + }, + { + "source": "transport", + "target": "transport", + "note": "The connection layer, masculine: le transport / les transports. The individual transports — stdio, Streamable HTTP, SSE — are names on the keep list and stay in English (le transport stdio, le transport Streamable HTTP)." + }, + { + "source": "session", + "target": "session", + "note": "An MCP session (the negotiated connection state), feminine: la session / les sessions, l’identifiant de session. The `session` object, `ClientSession` and `ServerSession` are code and stay untouched." + }, + { + "source": "handler", + "target": "gestionnaire", + "note": "The tool, resource or prompt function you register (nav section \"Inside your handler\" → Dans votre gestionnaire): le gestionnaire / les gestionnaires. First mention on a page may read \"gestionnaire (handler)\". Provisional pending native review; keeping le handler is the open alternative, but never alternate the two on one page." + }, + { + "source": "dependency", + "target": "dépendance", + "note": "Both package dependencies and the SDK's parameter-injection feature — parameters declared with `Resolve(...)` (\"dependency injection\" → l’injection de dépendances; the \"Dependencies\" page → Dépendances). Feminine: la dépendance. The `Resolve` marker class stays Latin." + }, + { + "source": "resolver", + "target": "résolveur", + "note": "The plain function attached to a parameter with `Resolve(...)` that computes or asks for its value before the tool runs: le résolveur / les résolveurs (the word French already uses for a DNS resolver). The `Resolve` class stays Latin. Provisional pending native review." + }, + { + "source": "client", + "target": "client", + "note": "An MCP client, and the client side of a connection: le client / les clients. The `Client` class and the `mcp.client` module are code and stay untouched." + }, + { + "source": "server", + "target": "serveur", + "note": "An MCP server (the program you build): le serveur / les serveurs, un serveur MCP. The `MCPServer`, `Server` and `ServerSession` classes are code and stay untouched." + }, + { + "source": "host", + "target": "hôte", + "note": "The MCP host — the application the user talks to, which embeds the client and drives the model (Claude Desktop, an IDE) — and also a network host: l’hôte (masculine) / les hôtes in both senses. Hébergeur is a hosting provider and a different thing. Provisional pending native review." + }, + { + "source": "context", + "target": "contexte", + "note": "The generic lower-case word (\"provide context to LLMs\" → fournir du contexte aux LLM): le contexte. The capitalised `Context` is the SDK object injected as `ctx`; it is on the keep list and stays Latin in prose (\"The Context\" → L’objet Context, la classe `Context`)." + }, + { + "source": "request", + "target": "requête", + "note": "A JSON-RPC or HTTP request (\"the initialize request\" → la requête initialize, \"request body\" → le corps de la requête): la requête / les requêtes. Demande stays the everyday word for a request a person makes; never alternate the two for protocol messages. `Request` types in code font stay Latin." + }, + { + "source": "response", + "target": "réponse", + "note": "A JSON-RPC or HTTP response, and equally the answer a person, model or tool gives: la réponse / les réponses. `Response` types in code font stay Latin." + }, + { + "source": "notification", + "target": "notification", + "note": "A JSON-RPC notification (a message that expects no response) and change notifications alike: la notification / les notifications, \"change notification\" → notification de changement. Method strings such as `notifications/tools/list_changed` are code." + }, + { + "source": "callback", + "target": "fonction de rappel", + "note": "Client callbacks such as `sampling_callback` and `elicitation_callback` (the \"Callbacks\" page → Fonctions de rappel): la fonction de rappel / les fonctions de rappel; first mention on a page may read \"fonction de rappel (callback)\". An OAuth redirect callback is l’URL de rappel / le rappel OAuth. Parameter names stay Latin. Provisional pending native review; le callback is the open alternative." + }, + { + "source": "decorator", + "target": "décorateur", + "note": "The Python decorators the SDK is built on: le décorateur / les décorateurs. `@mcp.tool()` and its siblings are code and stay untouched." + }, + { + "source": "type hint", + "target": "annotation de type", + "note": "Python type hints (\"from your type hints\" → à partir de vos annotations de type): l’annotation de type / les annotations de type — the plural goes on annotation, type stays singular. Translated on every page rather than left as type hints in the prose; `type hints` inside code font is code." + }, + { + "source": "round trip", + "target": "aller-retour", + "note": "One request/response exchange: un aller-retour / des allers-retours (\"zero negotiation round trips\" → aucun aller-retour de négociation). Provisional pending native review." + }, + { + "source": "multi-round-trip", + "target": "à plusieurs allers-retours", + "note": "The 2026-07-28 request pattern (\"Multi-round-trip requests\" → Requêtes à plusieurs allers-retours). Provisional pending native review: gloss the English on first use per page — requêtes à plusieurs allers-retours (multi-round-trip). The abbreviation MRTR stays Latin." + }, + { + "source": "lifespan", + "target": "cycle de vie", + "note": "The server's startup/shutdown scope (the \"Lifespan\" page, as in the ASGI lifespan): le cycle de vie (du serveur). First mention on a page reads \"cycle de vie (lifespan)\" so the reader can connect it to the `lifespan=` parameter, which is code. Durée de vie is the neighbouring \"lifetime\" (\"for the lifetime of the app\" → pendant toute la durée de vie de l’application) and is not banned; espérance de vie is always wrong. Provisional pending native review.", + "avoid": ["espérance de vie"] + }, + { + "source": "back-channel", + "target": "canal de retour", + "note": "This documentation's term for the server calling back into the client during a request, which exists only on legacy connections: le canal de retour. First mention on a page reads \"canal de retour (back-channel)\" so the reader can connect it to the `NoBackChannelError` exception, which is code. Provisional pending native review." + }, + { + "source": "deprecated", + "target": "obsolète", + "note": "Advisory status: still works, scheduled for removal later — obsolète, as French Python and web documentation render \"deprecated\" (\"Deprecated features\" → Fonctionnalités obsolètes); \"deprecation\" → l’obsolescence (avertissement d’obsolescence, tied to the `MCPDeprecationWarning` class, which stays Latin); \"removed\" is supprimé, a different state. Provisional pending native review; the anglicism déprécié is widespread and is the open alternative, but never alternate the two." + }, + { + "source": "legacy", + "target": "historique", + "note": "\"A legacy connection / client / session\" = one negotiated at spec version 2025-11-25 or earlier → une connexion historique, un client historique, une session historique (\"Serving legacy clients\" → Prendre en charge les clients historiques). Keep it distinct from obsolète, which renders \"deprecated\", and from ancien, which before the noun means \"former\". Provisional pending native review; hérité and d’ancienne génération are the open alternatives." + }, + { + "source": "era", + "target": "génération", + "note": "\"Protocol era\" (\"a 2025-era client\", \"whatever era the client speaks\", \"any era of server\") → la génération du protocole, un client de génération 2025, un serveur de n’importe quelle génération. Provisional pending native review; not ère or époque, which read grandiose." + }, + { + "source": "wire", + "target": "liaison", + "note": "The corpus's light metaphor for the byte stream between client and server: \"on the wire\" → sur la liaison, \"stdout is the wire\" → stdout est la liaison elle-même, \"invisible on the wire\" → invisible sur la liaison, \"the JSON on the wire\" → le JSON qui circule sur la liaison. Not the literal fil or câble. Provisional pending native review." + }, + { + "source": "handshake", + "target": "poignée de main", + "note": "The initialization handshake (\"the classic handshake\" → la poignée de main classique), the term French protocol documentation uses for TCP and TLS alike: la poignée de main. First mention on a page may read \"poignée de main (handshake)\". Provisional pending native review; keeping le handshake is the open alternative." + }, + { + "source": "escape hatch", + "target": "échappatoire", + "note": "The API-design metaphor for the lower-level mechanism you drop to when the convenience layer is in the way (`client.session`, `add_request_handler()`, the low-level `Server`): une échappatoire / des échappatoires. Pinned so every page uses one rendering; not trappe de secours or porte dérobée (a backdoor). Provisional pending native review." + }, + { + "source": "library", + "target": "bibliothèque", + "note": "A software library: la bibliothèque / les bibliothèques. The false friend librairie means a bookshop and is never right here.", + "avoid": ["librairie"] + }, + { + "source": "framework", + "target": "framework", + "note": "Kept in English as French developers say it: le framework / les frameworks (\"the framework supplies it\" → le framework le fournit). The purist coinage cadriciel is never used.", + "avoid": ["cadriciel"] + }, + { + "source": "middleware", + "target": "middleware", + "note": "Kept in English as French developers say it, and matching `server.middleware`: le middleware / les middlewares (the \"Middleware\" page → Middleware). The purist coinage intergiciel is never used.", + "avoid": ["intergiciel"] + }, + { + "source": "token", + "target": "jeton", + "note": "OAuth and bearer tokens, as French security documentation writes them: le jeton / les jetons, jeton d’accès (access token), jeton d’actualisation (refresh token), jeton porteur (bearer token). Provisional pending native review: for LLM tokens (a model's context or output length) le token is what French AI writing uses and is acceptable in that sense only." + }, + { + "source": "logging", + "target": "journalisation", + "note": "The activity and the page title (\"Logging\" → Journalisation); \"a log message\" → un message de journal, \"the server's log\" → le journal du serveur, \"to log\" → journaliser or consigner, never logger / loguer. The `logging` module and `ctx.log()` are code. Provisional pending native review; les logs is common in speech and is the open alternative for the noun." + }, + { + "source": "subscription", + "target": "abonnement", + "note": "Resource and list-change subscriptions (the two \"Subscriptions\" pages → Abonnements): l’abonnement / les abonnements, \"subscribe\" → s’abonner, \"subscriber\" → l’abonné. `subscriptions/listen` and `resources/subscribe` are code." + }, + { + "source": "completion", + "target": "complétion", + "note": "Two senses, one word. The MCP feature that autocompletes prompt and resource-template arguments (the \"Completions\" page, `completion/complete`) → la complétion / les complétions (des arguments). The text a model produces in the sampling pages (\"an LLM completion\") may also read la complétion, or more plainly la réponse du modèle. Provisional pending native review." + }, + { + "source": "structured output", + "target": "sortie structurée", + "note": "The tools feature and its page title (\"Structured Output\" → Sortie structurée): la sortie structurée. `structured_output` and `outputSchema` are code." + }, + { + "source": "troubleshooting", + "target": "dépannage", + "note": "The page title and the activity: le dépannage. Not résolution des problèmes on some pages and dépannage on others. Provisional pending native review." + }, + { + "source": "authorization", + "target": "autorisation", + "note": "The OAuth sense and the page title (\"Authorization\" → Autorisation); \"authentication\" is l’authentification — keep the two apart as the English does. The `Authorization` header is code." + }, + { + "source": "return value", + "target": "valeur de retour", + "note": "A function's return value: la valeur de retour; \"returns X\" → renvoie X, never retourne X. The `return` keyword and annotations are code." + }, + { + "source": "default value", + "target": "valeur par défaut", + "note": "A parameter's default: la valeur par défaut; \"by default\" → par défaut; \"required\" (of a parameter) → obligatoire, not requis." + }, + { + "source": "Get started", + "target": "Prise en main", + "note": "The nav section that opens the guide, and the title of its index page. \"First steps\" is a separate page inside that section (Premiers pas), so the two need distinct renderings or the sidebar shows the same title twice — never Premiers pas for this one. Provisional pending native review; Démarrer is the open alternative." + }, + { + "source": "First steps", + "target": "Premiers pas", + "note": "The tutorial page inside the \"Get started\" section; never reuse this rendering for the section itself (see that entry). Provisional pending native review." + }, + { + "source": "Recap", + "target": "Récapitulatif", + "note": "Recurring section heading that closes most pages; one rendering everywhere, not En résumé on some pages and Récapitulatif on others. Provisional pending native review." + }, + { + "source": "Try it", + "target": "Essayer", + "note": "Recurring section heading above a runnable example; one rendering everywhere (the infinitive, per the heading rule), not Essayez on some pages and À vous de jouer on others. Provisional pending native review." + } + ] +} diff --git a/i18n/fr/instructions.md b/i18n/fr/instructions.md new file mode 100644 index 0000000000..13f982008a --- /dev/null +++ b/i18n/fr/instructions.md @@ -0,0 +1,170 @@ +# French (fr) — translation instructions + +Target language: French as written in France (français, fr-FR conventions), +directory and URL code `fr`, page language tag `fr`. This file is sent verbatim +with every translation request for this language, on top of the shared rules +in `../general-prompt.md`. The termbase in `glossary.json` is sent alongside it +and wins any terminology conflict with this file. + +## 1. Register + +Address the reader as **vous**, always — verb forms, votre / vos and object +pronouns to match. French developer documentation does not use tu. + +- Never tu / toi / ton, never a mix. A page that drifts between vous and tu, or + between direct instructions and an impersonal administrative voice, is wrong + even when each sentence is acceptable on its own. +- Steps are imperatives in the second person plural: "Install the SDK, then + run the server" → Installez le SDK, puis lancez le serveur — not Veuillez + installer … before every step, not the infinitive Installer le SDK in + running prose. Obligations take the present: vous devez, not vous devrez, + unless the English is explicitly about the future. +- Headings, table headers, tab labels and admonition titles are infinitives or + noun phrases, never conjugated imperatives: "Declare a tool" → Déclarer un + outil, "Handling errors" → Gérer les erreurs, "Running your server" → + Exécuter votre serveur, "The Context" → L’objet Context. A question heading + may stay a question (Où placer ce code ?). No full stop after a heading. +- Requirement strength stays exact: must → devez / il faut, should → devriez / + il est recommandé de, may / can → pouvez, must not → ne devez pas. +- The authorial "we" is nous (Nous recommandons). The impersonal on is fine + for a genuinely general statement (on obtient alors un schéma), never as a + substitute for addressing the reader, never mixed with nous for one referent. +- "The user" — the human in front of the host — is l’utilisateur, the generic + form French documentation uses; no typographic inclusive forms + (utilisateur·rice). Where a sentence is really about the reader, say vous. + +## 2. Voice + +The English source is warm, direct and confident: short sentences, the +occasional one-line payoff. Aim for an experienced French engineer explaining a +library to a colleague — professional, warm, plain-spoken; not stiff, not chatty. + +- Keep the payoff sentences short: "That's the whole API." → C’est toute + l’API. — not a formal summary sentence. Split a long English sentence rather + than mirroring its clause chain; never merge, drop or reorder the technical + claims themselves. +- Verbs, not nominal chains: procéder à l’installation de → installer; + effectuer la configuration → configurer. Active voice: "The tool is called by + the model" → Le modèle appelle l’outil. +- No administrative French (il convient de, il est à noter que, dans le cadre + de, afin de pouvoir, ledit, ce dernier as an all-purpose pronoun) and no hype + (puissant, en toute simplicité, révolutionnaire). +- No English-shaped French: supporter for "support" (→ prendre en charge), + retourner une valeur (→ renvoyer), consistant for "consistent" (→ cohérent), + adresser un problème (→ traiter), faire sens (→ avoir du sens), définitivement + for "definitely", and bare en 2026-07-28 (→ en version 2026-07-28). +- Body prose uses cela rather than the spoken ça; ça is tolerable only in a + deliberately conversational payoff line, never in reference material. +- Example — "You don't construct it and you don't configure it. You ask for + it." → Vous ne le construisez pas, vous ne le configurez pas. Vous le + demandez. Not the administrative Il n’est pas nécessaire de procéder à son + instanciation ni à sa configuration ; il suffit d’en effectuer la demande. — + nor the calque Tu ne le construis pas … Tu le demandes, c’est tout ! + +## 3. Humour and idioms + +- The English is friendly and dry rather than jokey; French technical prose + tolerates warmth but less wit than English. Never translate a pun, idiom or + aside literally: say what it means as a short, natural French sentence in + the same register; a French idiom at home in technical prose is welcome + (sous le capot for "under the hood"). An aside with no information may go — + a technical caveat phrased lightly never does. +- Recurring English tags get fixed renderings: "**[X](…)** has the whole story" + / "The whole story is in **[X](…)**" → Tous les détails sont dans + **[X](…)**; "That's the whole API." / "That's the whole protocol." → C’est + toute l’API. / C’est tout le protocole.; "That's it. It's just Python." → + C’est tout. C’est du Python, tout simplement. (not C’est ça. C’est juste du + Python !); "You get `3` back. ✨" → Vous obtenez `3` en retour. ✨ (not Vous + récupérez 3 en retour ! ✨ — lost code span, added exclamation mark). +- Idioms take the plain meaning, not the picture: "Out of the box the app + answers **only** requests addressed to localhost." → Par défaut, + l’application répond **uniquement** aux requêtes adressées à localhost — not + a calqued sortie de la boîte. "it stops being required" → il cesse d’être + obligatoire, not il arrête d’être requis. +- Exclamation marks are rare in French documentation: keep one only where the + English carries genuine emphasis, with its espace insécable (§4); never add, + never double, never in a heading. Emoji: keep the source's rare, deliberately + placed emoji exactly where they are; never add new ones. + +## 4. Typography + +- Espace insécable: put a no-break space (the character U+00A0 itself, never + ` ` and never an ordinary space) before ; : ! ? and %, and inside + guillemets — after « and before ». So: Où placer ce code ? / le schéma + suit : / « bonjour » / 100 %. Never inside code spans, code blocks, URLs, + link targets or `{#id}` attributes; never after the `!!!` / `???` admonition + markers or inside the `![` of an image; and no space at all before , or . +- Quotation marks are guillemets « … » for quotations, scare quotes and + example utterances; English "…" and “…” in the source prose become « … », + with “…” for a quote inside a quote. Quotes inside code stay exactly as they + are, and a code span is never wrapped in guillemets. +- Apostrophe: the typographic ’ (U+2019) throughout the prose — l’outil, + jusqu’à, C’est — and the straight ' only inside code. Do not elide onto a + code span: la fonction `add`, le paramètre `a`, not l’`add`. +- Accented capitals are mandatory (À partir de, État, Ça, Échantillonnage); + the ligature is œ (cœur, nœud); ordinals are 1er, 2e, 3e (not 2ème). +- Sentence case everywhere; French has no title case (Gérer les erreurs, not + Gérer Les Erreurs). Language names, weekdays and months are lower-case (en + anglais, en juillet); proper nouns keep their capitals (Python, GitHub). +- Digits stay ASCII. Protocol revision strings such as `2026-07-28`, version + numbers, ports, status and error codes, RFC and SEP numbers are identifiers, + copied byte for byte — never 28/07/2026, never 28 juillet 2026. Prose + quantities take the decimal comma only when nothing but the separator changes + (2,5 secondes), never inside code. Thousands and units take a no-break space + (10 000, 30 s, 100 Mo — byte units are o, ko, Mo, Go in prose, unchanged + inside code or quoted output). +- Dashes: keep the source's em-dash incise with a space on each side (texte — + incise — texte) or recast it with commas or parentheses. Ranges read de 3.10 + à 3.14, never a hyphen. The ellipsis is the single character … in prose. +- Abbreviations: e.g. → par exemple, i.e. → c’est-à-dire, etc. → etc., vs → + ou / par rapport à; & in prose → et. No comma before et / ou closing a list. +- Bold and italics land on the words that carry the source's emphasis; a bolded + negation ("**not**" → **pas** / **aucun**) stays bold. English words kept in + French text are set in normal type — no italics, no guillemets. + +## 5. Terminology pointer + +The termbase is `glossary.json` next to this file. It is injected into the +prompt separately and its renderings override anything written here. This +section only fixes the conventions the glossary assumes: + +- Terms in the glossary's `keep` list are copied exactly — same spelling and + casing, not translated, italicised or quoted — and invariable in French (les + SDK, les API, no plural s). They take an article by gender: le SDK, l’API + (f.), le JSON, l’URL (f.), l’URI (m.), la CLI, le LLM, la SEP, la RFC. +- Everything in code font — class, function, parameter and module names, + protocol method strings (`tools/call`), header names, error text, config keys + — stays byte-identical. Name the kind of thing in front where it helps: la + classe `Context`, le paramètre `lifespan=`. A glossary term used as a + code-font identifier stays English although its prose noun is translated: + "the `sampling` capability" → la capacité `sampling`. +- Text quoted from what the example code prints or displays — an output line, a + log message, an error string, a UI label such as the Inspector's **Tools** + and **Resources** tabs — stays exactly as the code emits it (usually + English), in or out of code font. The guillemets around it are French; the + text inside does not change. +- France, not Québec, and natural French before anglicism: prefer the French + word wherever developers in France use it — outil, requête, réponse, + gestionnaire, dépendance, bibliothèque, dépôt, fichier, flux, en-tête, + jeton, journal, déploiement, e-mail — and keep the English noun where they + do, masculine, plural in -s: le prompt, le framework, le middleware, le + build, le commit, le hook. Never the purist or Québec coinages cadriciel, + intergiciel, courriel, téléverser. Verbs are French: déployer, fusionner + (not merger), récupérer (not fetcher), analyser (not parser), journaliser + (not logger), mettre en cache, déboguer; créer un commit, never commiter. +- First-use gloss: a translated MCP concept the reader may need to map back to + the English specification carries the English in parentheses on its first + occurrence on a page — l’échantillonnage (sampling) — where the note says so. +- One rendering per term per page: the glossary target, every time. Where an + entry's note marks the choice as open or provisional, still use the listed + target consistently — never requête in one paragraph and demande in the next. + +## 6. Provisional note + +The register, voice and terminology decisions above, and every entry in +`glossary.json`, are provisional pending review by native French-speaking +readers — in particular the translate-versus-keep line for individual nouns +and the typographic apostrophe. To propose a change, edit this file or +`glossary.json` in a pull request, ideally with a short good/bad example; +never edit the generated `pages/` or `notices.md` next to this file, which the +next translation run overwrites. diff --git a/i18n/fr/notices.md b/i18n/fr/notices.md new file mode 100644 index 0000000000..91b59a1a9f --- /dev/null +++ b/i18n/fr/notices.md @@ -0,0 +1,20 @@ +--- +translation: + sections: [aff1b3e872b7876a, 4d80558ad052d586, 0bb81f1e62062d26, d5c35dcec50156bc] + tool: 1 +--- +# Avis de traduction {#translation-notices} + +L’une de ces notes apparaît en haut de chaque page d’un site de documentation traduit. + +## Traduction automatique {#translated} + +Cette page a été traduite automatiquement à partir de la documentation en anglais, et la [page en anglais](ENGLISH_PAGE) fait foi. Si quelque chose vous semble incorrect, la page [Traductions](TRANSLATIONS_PAGE) explique comment le signaler. + +## Traduction en retard sur la page en anglais {#outdated} + +La page en anglais a changé depuis cette traduction, si bien que certaines parties peuvent être obsolètes. En cas de doute, consultez la [page en anglais](ENGLISH_PAGE) ; la page [Traductions](TRANSLATIONS_PAGE) explique comment fonctionne la documentation traduite. + +## Affichée en anglais {#english} + +Il n’existe pas de traduction à jour de cette page, vous la lisez donc en anglais. La page [Traductions](TRANSLATIONS_PAGE) explique comment fonctionne la documentation traduite. diff --git a/i18n/fr/pages/advanced/apps.md b/i18n/fr/pages/advanced/apps.md new file mode 100644 index 0000000000..8e19e4db5e --- /dev/null +++ b/i18n/fr/pages/advanced/apps.md @@ -0,0 +1,121 @@ +--- +translation: + sections: [0355618e5f4d5fe4, 1821eaf50f2d0b64, 82e0b28ebd3abf5a, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487] + tool: 1 +--- +# MCP Apps {#mcp-apps} + +Une **MCP App** est un outil doté d’une interface : en plus de ses données, l’outil désigne un document HTML que l’hôte affiche comme surface interactive. + +Deux parties, toujours deux parties : + +1. **Un outil** qui fait le travail et renvoie des données, comme n’importe quel autre outil. +2. **Une ressource `ui://`** contenant le HTML que l’hôte affiche pour lui. + +L’outil porte une référence `_meta.ui.resourceUri` vers la ressource. L’hôte la récupère avec `resources/read`, l’affiche dans une **iframe isolée (sandbox)** et pousse le résultat de l’outil dans cette iframe via `postMessage`. Votre serveur n’envoie ni ne reçoit jamais de messages `ui/*` : ce trafic circule entre l’hôte et l’iframe. Vous servez un outil et un document HTML ; l’hôte se charge de la mise en scène. + +Le SDK fournit cela sous la forme de l’extension intégrée `Apps` (`io.modelcontextprotocol/ui`). Si les [extensions](extensions.md) sont nouvelles pour vous, parcourez d’abord cette page. Une minute, puis revenez. + +## Une horloge avec un cadran {#a-clock-with-a-face} + +```python title="server.py" hl_lines="19 22 30 32" +--8<-- "docs_src/apps/tutorial001.py" +``` + +Quatre étapes : + +* `Apps()` : une seule instance contient vos outils liés à une interface et leurs ressources. +* `@apps.tool(resource_uri="ui://clock/app.html")` : un outil ordinaire, plus le marquage `_meta.ui.resourceUri`. Tout ce que `@mcp.tool()` accepte (name, title, description, …) est transmis tel quel. +* `apps.add_html_resource("ui://clock/app.html", CLOCK_HTML)` : la ressource correspondante, servie en `text/html;profile=mcp-app`. C’est ce type MIME exact qui indique à un hôte « ceci est une app, affichez-la ». +* `MCPServer("clock", extensions=[apps])` : vous activez l’extension. Le serveur annonce désormais `io.modelcontextprotocol/ui` sous `capabilities.extensions`. + +Le HTML lui-même écoute le `postMessage` de l’hôte et affiche le résultat. Pour de vraies applications, utilisez dans votre HTML le SDK navigateur officiel [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps). Il vous donne `ontoolresult`, `callServerTool`, `getHostContext` et `onhostcontextchanged` au lieu d’événements de message bruts. + +## Dégradation gracieuse {#graceful-degradation} + +Tous les clients n’affichent pas les apps. La spécification dit sans détour ce que cela implique pour vous : + +> Les outils **DOIVENT** renvoyer un tableau `content` significatif même lorsqu’une interface est disponible. + +Le modèle lit `content` ; l’iframe est pour les humains. Un hôte capable d’afficher une interface transmet quand même le résultat textuel au modèle, et un client purement textuel ne reçoit *que* cela. Le schéma canonique est donc : un outil, deux réponses. Regardez à nouveau `get_time` : + +```python title="server.py" hl_lines="23-27" +--8<-- "docs_src/apps/tutorial001.py" +``` + +`client_supports_apps(ctx)` ne vaut `True` que lorsque le client a déclaré l’extension `io.modelcontextprotocol/ui` **et** listé `text/html;profile=mcp-app` dans ses paramètres `mimeTypes`. Le champ est obligatoire, donc un client qui l’omet ne compte pas. C’est exactement ce que déclare `main()` dans le même fichier : la moitié client de la négociation, et la réponse riche revient. + +!!! warning + Ne renvoyez jamais un texte de substitution comme `"[Rendered UI]"` pour seul contenu. Si le texte de repli est inutile, l’outil est inutile pour tout client purement textuel et pour le modèle lui-même. Écrivez la phrase. + +## Verrouiller l’iframe {#locking-the-iframe-down} + +C’est le côté ressource qui porte les métadonnées de sécurité : ce que l’iframe peut charger, les permissions du navigateur qu’elle souhaite, la façon dont elle aimerait être encadrée : + +```python title="server.py" hl_lines="9 19-22" +--8<-- "docs_src/apps/tutorial002.py" +``` + +`csp` et `permissions` sont des **demandes adressées à l’hôte**, pas un comportement du serveur. L’hôte construit à partir d’elles la Content-Security-Policy et la Permissions-Policy de l’iframe, et il peut refuser. Faites de la détection de fonctionnalités dans votre JS plutôt que de supposer l’accord acquis. + +`ResourceCsp`, champ par champ (nom Python, clé sur la liaison, ce que l’hôte en fait) : + +| Python | Liaison (`_meta.ui.csp`) | Contrôle | +|---|---|---| +| `connect_domains` | `connectDomains` | `connect-src` : où `fetch`/XHR peuvent aller | +| `resource_domains` | `resourceDomains` | `img-src`, `style-src`, … : fichiers statiques | +| `frame_domains` | `frameDomains` | `frame-src` : iframes imbriquées | +| `base_uri_domains` | `baseUriDomains` | `base-uri` : ce vers quoi `` peut pointer | + +`ResourcePermissions` : chaque champ demande une permission du navigateur pour l’iframe. + +| Python | Liaison (`_meta.ui.permissions`) | +|---|---| +| `camera` | `camera` | +| `microphone` | `microphone` | +| `geolocation` | `geolocation` | +| `clipboard_write` | `clipboardWrite` | + +!!! note + La CSP et les permissions vivent sur la **ressource**, jamais sur l’outil. Les métadonnées d’outil de la spécification n’ont pas d’emplacement pour elles, et les hôtes les ignorent à cet endroit. Le SDK rend l’erreur impossible à exprimer : `@apps.tool()` n’a tout simplement pas de paramètre `csp`. + +### Visibilité {#visibility} + +`visibility=["app"]` sur un outil dit « ceci existe pour l’iframe, pas pour le modèle » : + +* `"model"` : le modèle peut l’appeler. +* `"app"` : l’iframe peut l’appeler (via `callServerTool`). +* Omis : les deux, ce qui est la valeur par défaut. + +Le filtrage est le travail de **l’hôte**. Votre serveur liste les outils réservés à l’app dans `tools/list` comme les autres ; l’hôte les cache au modèle. Ne filtrez pas côté serveur. + +## Les règles que le SDK fait respecter {#the-rules-the-sdk-enforces} + +Toutes échouent au démarrage, pas en production : + +* Un `resource_uri` ou un URI de ressource qui n’est pas `ui://...` lève une `ValueError` au moment de la décoration ou de l’enregistrement. +* Un outil lié à un URI **sans ressource enregistrée correspondante** lève une `ValueError` lorsque `MCPServer(extensions=[apps])` consomme l’extension. Un outil qui annonce du HTML répondant 404 sur `resources/read` est une erreur de configuration, donc le serveur refuse de se construire. +* `meta={"ui": ...}` sur `@apps.tool()` lève une `ValueError`. Le décorateur est propriétaire de `_meta["ui"]` ; exprimez-le avec `resource_uri=` et `visibility=`. Les autres clés `meta=` se fusionnent sans problème à côté. + +Ni le SDK TypeScript ext-apps ni FastMCP ne détectent ces cas aujourd’hui ; nous préférons que vous le découvriez avant qu’un hôte ne le fasse. + +## Au-delà du HTML inline {#beyond-inline-html} + +`add_html_resource` couvre le cas courant : une chaîne de HTML. Pour tout le reste, HTML sur disque ou contenu généré, construisez la ressource vous-même et transmettez-la : + +```python title="server.py" hl_lines="12 18" +--8<-- "docs_src/apps/tutorial003.py" +``` + +`add_resource` renseigne le type MIME `text/html;profile=mcp-app` quand la ressource n’en définit pas explicitement, et rejette une incohérence explicite : une ressource `ui://` sous tout autre type MIME est une ressource qu’aucun hôte n’affichera. + +!!! tip + Vous ciblez un hôte d’avant la disponibilité générale qui lit encore la clé plate obsolète `_meta["ui/resourceUri"]` ? Fusionnez-la vous-même : `@apps.tool(resource_uri="ui://x", meta={"ui/resourceUri": "ui://x"})`. L’objet `ui` imbriqué est la forme prévue par la spécification ; la clé plate est en voie de disparition. + +## Le voir en action {#see-it-run} + +Le scénario `apps` dans `examples/stories/`, c’est cette page sous forme de paire exécutable : un serveur avec un outil horloge lié à une interface et un client qui négocie Apps, lit le `_meta.ui.resourceUri` de l’outil, récupère le HTML et appelle l’outil. + +```bash +uv run python -m stories.apps.client +``` diff --git a/i18n/fr/pages/advanced/extensions.md b/i18n/fr/pages/advanced/extensions.md new file mode 100644 index 0000000000..2ecdc7b295 --- /dev/null +++ b/i18n/fr/pages/advanced/extensions.md @@ -0,0 +1,172 @@ +--- +translation: + sections: [05891e7cc1938a13, b3c01a6af28c51ee, 7ffc91f5e38bdfe0, 717d3f235a8333a7, f471a13b2fe5d737, ed6af2df4b656dff] + tool: 1 +--- +# Extensions {#extensions} + +Une **extension** est un ensemble de comportements MCP, activable sur demande, regroupé derrière un seul identifiant. + +Côté serveur, elle peut apporter des outils (tools), des ressources et de nouvelles méthodes de requête, et elle peut envelopper `tools/call`. Côté client, elle peut revendiquer des formes de résultat `tools/call` supplémentaires et observer des notifications propres à un éditeur. Chaque côté s’annonce sous son propre `capabilities.extensions`, et rien ne change pour quiconque ne l’a pas demandé. C’est le contrat ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)), et il a une règle d’or : **les extensions sont désactivées par défaut**. + +## Utiliser une extension {#using-an-extension} + +Passez des instances à la construction : + +```python title="server.py" +--8<-- "docs_src/extensions/tutorial001.py" +``` + +C’est fait. Le serveur annonce désormais `io.modelcontextprotocol/ui` sous `capabilities.extensions` et sert tout ce que l’extension apporte. + +`Apps` est l’extension de référence intégrée, et elle a sa propre page : **[MCP Apps](apps.md)**. + +!!! note + Les extensions sont figées à la construction. Il n’existe pas de `add_extension` à appeler plus tard : la table des capacités d’un serveur ne devrait pas changer pendant que des clients y sont connectés. + +La table des capacités transite par `server/discover`, qui est un chemin **2026-07-28**. Une poignée de main (handshake) `initialize` historique n’a aucun endroit où la placer, donc un client historique ne voit tout simplement pas l’extension. Concevez en conséquence : une extension *enrichit* un serveur, elle ne doit pas être la seule manière de le rendre utilisable. + +## Écrire la vôtre {#writing-your-own} + +Dérivez `Extension` et ne redéfinissez que ce dont vous avez besoin. Chaque méthode a une valeur par défaut. + +### L’identifiant {#the-identifier} + +```python +--8<-- "docs_src/extensions/tutorial002.py" +``` + +L’identifiant est une chaîne `vendor-prefix/name` qui suit la grammaire des clés `_meta` de la spécification : des libellés séparés par des points (chacun commence par une lettre et se termine par une lettre ou un chiffre), une barre oblique, puis le nom. Il est validé **au moment où la classe est définie**, de sorte qu’une faute de frappe n’attend pas le démarrage d’un serveur : + +```text +TypeError: Stamps.identifier must be a `vendor-prefix/name` string +(reverse-DNS prefix required), got 'stamps' +``` + +Utilisez comme préfixe un domaine que vous contrôlez. `io.modelcontextprotocol/*` est réservé aux extensions spécifiées par le projet MCP lui-même. + +### Apporter des outils {#contributing-tools} + +La plus petite extension utile, c’est un outil et une table de paramètres : + +```python title="server.py" hl_lines="17 19-20 22-23 26" +--8<-- "docs_src/extensions/tutorial003.py" +``` + +* `tools()` renvoie des `ToolBinding`. Le serveur enregistre chacun exactement comme si vous aviez appelé `mcp.add_tool(...)` vous-même : même génération de schéma, même injection de `Context`, tout à l’identique. +* `settings()` est la valeur annoncée sous `capabilities.extensions["com.example/stamps"]`. Renvoyez `{}` (la valeur par défaut) pour annoncer l’extension sans paramètres. +* L’extension ne reçoit jamais le serveur. Elle déclare ses contributions sous forme de données ; `MCPServer` les consomme. Il n’y a pas de `self.server` à modifier. + +Et `main()` en est la preuve, un client en mémoire branché directement sur `mcp` : + +```python title="server.py" hl_lines="29-34" +--8<-- "docs_src/extensions/tutorial003.py" +``` + +### Servir vos propres méthodes {#serving-your-own-methods} + +Une extension peut enregistrer de **nouvelles méthodes de requête** : ses propres verbes, servis à côté de ceux de la spécification : + +```python title="server.py" hl_lines="16-22 31 40-48" +--8<-- "docs_src/extensions/tutorial004.py" +``` + +* `SearchParams` dérive de `RequestParams`, si bien que l’enveloppe `_meta` de 2026 est analysée de façon uniforme et que votre gestionnaire (handler) reçoit des paramètres validés, jamais un dict brut. Bornez ce que le client contrôle : `Field(ge=1, le=100)` rejette un `limit` absurde avant que votre code n’alloue quoi que ce soit pour lui. +* `require_client_extension(ctx, EXTENSION_ID)` est le garde-fou : un client qui n’a pas déclaré l’extension reçoit l’erreur `-32021` (capacité client obligatoire manquante), avec la charge utile `requiredCapabilities` lisible par machine que la spécification demande. +* `protocol_versions=frozenset({"2026-07-28"})` épingle la méthode à une seule version de la liaison. Dans toute autre version, le client reçoit `METHOD_NOT_FOUND`, exactement comme si la méthode n’y existait pas. Pour ce client, elle n’existe pas. + +Les méthodes sont **strictement additives**. Le SDK le fait respecter à la construction, pas à l’exécution : + +* Un `MethodBinding` pour une méthode définie par la spécification (`tools/list`, `completion/complete`…) lève une `ValueError` lors de la construction du binding. Les verbes de base appartiennent au serveur. +* Deux extensions qui lient la même méthode lèvent une exception quand la seconde s’enregistre. Laisser la dernière écriture l’emporter, c’est ainsi que des plugins se corrompent mutuellement ; nous ne faisons pas cela. +* Un ensemble `protocol_versions` vide lève aussi une exception : une méthode qui ne peut jamais être servie est un bogue, pas une configuration. + +### Le côté client {#the-client-side} + +Le `main()` du même fichier raconte toute l’histoire côté client, ses deux moitiés : + +```python title="server.py" hl_lines="54-58" +--8<-- "docs_src/extensions/tutorial004.py" +``` + +* `Client(..., extensions=[advertise(EXTENSION_ID)])` déclare l’extension. Les déclarations deviennent `ClientCapabilities.extensions` : sur une connexion 2026-07-28, la table voyage dans l’enveloppe `_meta` de chaque requête, donc le serveur la voit sur **chaque** requête ; sur une connexion historique, elle transite par la poignée de main `initialize`. Le code serveur ne s’en soucie pas : `require_client_extension(ctx, ...)` et `ctx.session.check_client_capability(...)` lisent la bonne source dans les deux cas. +* Les méthodes propres à un éditeur descendent d’un niveau, vers `client.session.send_request(...)` ; `Client` n’acquiert de méthodes de premier rang que pour les verbes de la spécification. `send_request` accepte n’importe quelle sous-classe de `Request`, donc la requête de l’éditeur passe telle quelle. + +### Intercepter `tools/call` {#intercepting-toolscall} + +Le seul hook d’interception. Redéfinissez `intercept_tool_call` pour observer, court-circuiter ou opposer un veto à un appel d’outil : + +```python title="server.py" hl_lines="17-24" +--8<-- "docs_src/extensions/tutorial005.py" +``` + +* `params` est le `CallToolRequestParams` validé : vous obtenez `params.name` et `params.arguments` sans toucher au JSON brut. C’est aussi lui qui décide quel appel d’outil s’exécute : passer un contexte réécrit à `call_next` change ce que le gestionnaire observe sur `ctx`, pas l’invocation de l’outil. La réécriture de requêtes au niveau de la liaison relève du [Middleware](middleware.md). +* `call_next(ctx)` exécute le reste de la chaîne et renvoie le résultat du gestionnaire. Renvoyez-le tel quel (observer), renvoyez autre chose (remplacer) ou levez une `MCPError` (refuser). Ce que vous renvoyez est sérialisé comme n’importe quel résultat de gestionnaire, y compris l’estampille d’identité `serverInfo` de la génération 2026, si bien qu’un intercepteur qui court-circuite ne produit jamais de réponse anonyme ou hors schéma. +* Avec plusieurs extensions, les intercepteurs s’imbriquent dans l’ordre d’enregistrement : la première extension de `extensions=[...]` est la plus externe. +* L’implémentation par défaut laisse passer sans rien faire, et un serveur dont les extensions ne redéfinissent jamais ce hook conserve le gestionnaire `tools/call` nu, intact. Vous ne payez pas pour ce que vous n’utilisez pas. + +Le hook enveloppe `tools/call` et rien d’autre. Pour ce qui concerne chaque message, utilisez le [Middleware](middleware.md). Il est fait pour cela. + +## Utiliser une extension client {#using-a-client-extension} + +Une **extension client**, c’est le même contrat vu du côté consommateur : un ensemble de comportements côté client derrière un seul identifiant. Passez des instances à `Client(extensions=[...])` et appelez les outils normalement : + +```python title="client.py" hl_lines="66-68" +--8<-- "docs_src/extensions/tutorial006.py" +``` + +`call_tool("buy", ...)` renvoie un simple `CallToolResult`, comme tout autre appel. Ce que l’extension a changé : le serveur peut désormais répondre à `buy` par une **forme de résultat** `receipt` au lieu d’un résultat final, et `Receipts` la termine (ici en échangeant le reçu via un appel de suivi) avant que `call_tool` ne renvoie. Rien ne bouge au point d’appel. + +Retirez l’extension et rien de tout cela n’existe : le garde-fou du serveur refuse un client qui ne l’a pas déclarée (erreur -32021), et une forme revendiquée venant d’un serveur qui saute le garde-fou échoue à la validation, exactement comme la spécification l’exige pour un `resultType` non reconnu. Désactivé par défaut, aux deux bouts de la liaison. + +Pour annoncer un identifiant **sans aucun** comportement côté client (le serveur filtre sur la capacité, le client ne fait rien, comme dans le client de recherche ci-dessus), utilisez `advertise()` : + +```python +from mcp.client import advertise + +client = Client(mcp, extensions=[advertise("com.example/search")]) +``` + +## Écrire une extension client {#writing-a-client-extension} + +Dérivez `ClientExtension` et ne redéfinissez que ce dont vous avez besoin. Trois types de contributions, chacun avec une valeur par défaut : `settings()`, `claims()` et `notifications()`. + +```python title="client.py" hl_lines="17-18 43-44 46-47" +--8<-- "docs_src/extensions/tutorial006.py" +``` + +* L’identifiant suit la même grammaire que celui du serveur, validé au moment où la classe est définie. +* `claims()` renvoie des `ResultClaim` : une étiquette de liaison, le modèle qui l’analyse et le résolveur qui la termine. Le modèle doit épingler l’étiquette avec `result_type: Literal["receipt"]` et ne doit pas dériver des types de résultat de base du verbe ; les deux sont vérifiés à la construction du claim. Les champs d’éditeur comme `receipt_token` voyagent tels quels sur la liaison : une forme substituée parvient au client à l’identique. +* Le résolveur reçoit le modèle analysé et un `ClaimContext` ; `ctx.session` est le même point d’accès public que `client.session`, donc les appels de suivi sont des appels de session ordinaires. Il renvoie le `CallToolResult` normal du verbe. +* `settings()` est la valeur annoncée sous `ClientCapabilities.extensions[identifier]`, lue une fois à la construction de `Client`. + +`notifications()` déclare les notifications serveur d’éditeur à observer : + +```python +def notifications(self) -> Sequence[NotificationBinding[Any]]: + return [NotificationBinding(method="notifications/receipts", params_type=ReceiptEvent, handler=self.on_receipt)] +``` + +Le gestionnaire reçoit des paramètres validés un par un, dans l’ordre de distribution. Il observe ; il ne peut ni opposer de veto ni répondre. + +Deux règles discrètes. Les claims ne sont actifs que sur les connexions 2026-07-28, et l’annonce des capacités les suit : sur une connexion historique, les claims se dissolvent et l’identifiant disparaît de l’annonce avec eux, si bien que le client n’annonce jamais une extension dont il rejetterait les formes. Et lorsque vous voulez la forme revendiquée elle-même plutôt que le résolveur, appelez `client.session.call_tool(..., allow_claimed=True)` ; sans ce drapeau, une forme revendiquée qui atteint un appelant au niveau session lève `UnexpectedClaimedResult`. + +### Verbes d’extension {#extension-verbs} + +Les méthodes de requête propres à une extension n’ont besoin d’aucun enregistrement côté client. Un type de requête d’éditeur dérive de `mcp.types.Request` et passe par `client.session.send_request`, comme dans [Servir vos propres méthodes](#serving-your-own-methods). Un ajout : lorsqu’une clé des paramètres doit transiter par l’en-tête `Mcp-Name` (des spécifications d’extension comme tasks l’exigent pour leurs verbes), le type de requête déclare `name_param` : + +```python title="client.py" hl_lines="22-25 46-47" +--8<-- "docs_src/extensions/tutorial007.py" +``` + +La session reflète `params["jobId"]` dans `Mcp-Name` sur chaque chemin d’envoi, et une valeur manquante échoue bruyamment au lieu d’omettre silencieusement un en-tête obligatoire. + +## Ce qu’une extension ne peut pas faire {#what-an-extension-cannot-do} + +La surface de contribution est **fermée** à dessein. Côté serveur : paramètres, outils, ressources, méthodes, un intercepteur `tools/call`. Côté client : paramètres, claims de résultat, bindings de notification. Une extension ne peut pas : + +* **Atteindre l’hôte.** Elle déclare des données ; elle ne détient aucune référence au serveur ni au client. +* **Remplacer le comportement de base.** Les méthodes de la spécification et les étiquettes de résultat de base sont rejetées à la construction (`initialize` est purement et simplement réservé par le runner) ; un binding de notification masqué par le vocabulaire de base se tait avec un avertissement à la place. +* **S’enregistrer tardivement.** Une fois que `MCPServer(...)` ou `Client(...)` a renvoyé, l’ensemble des extensions est ce qu’il est. + +Si vous vous battez contre ces murs, vous n’écrivez pas une extension. Vous écrivez un fork. Les murs sont la fonctionnalité : un utilisateur qui lit `extensions=[Apps(), Stamps()]` sait *tout* ce que ces deux-là ont pu toucher. diff --git a/i18n/fr/pages/advanced/index.md b/i18n/fr/pages/advanced/index.md new file mode 100644 index 0000000000..ddf9629c8c --- /dev/null +++ b/i18n/fr/pages/advanced/index.md @@ -0,0 +1,34 @@ +--- +translation: + sections: [ca6988b7503cd2d3] + tool: 1 +--- +# Avancé {#advanced} + +Tout ce dont un serveur ou un client ordinaire a besoin a sa place, par thème, dans les sections ci-dessus. +Cette section regroupe les échappatoires vers lesquelles vous vous tournez quand la couche de commodité +de `MCPServer` vous gêne : + +* **[Le Server de bas niveau](low-level-server.md)** : la classe sur laquelle `MCPServer` est construit. + Des schémas écrits à la main, des gestionnaires `on_*`, rien de vérifié à votre place, et vos propres + méthodes JSON-RPC personnalisées. +* **[Pagination](pagination.md)** et **[Middleware](middleware.md)** : deux choses que vous + ne pouvez faire *que* sur le `Server` de bas niveau. +* **[Extensions](extensions.md)** et **[MCP Apps](apps.md)** : la surface d’extension + du protocole. Combinez des paquets d’extension dans un serveur, ou écrivez les vôtres. + +Quelques éléments que vous pourriez légitimement chercher ici se trouvent plutôt là où vous +les utilisez réellement : + +* **L’autorisation** se trouve sous **[Exécuter votre serveur](../run/index.md)**, parce que vous + protégez un serveur là où vous le déployez. +* **OAuth**, **l’assertion d’identité**, la connexion à **plusieurs serveurs** et le + **cache** de réponses se trouvent tous sous **[Clients](../client/index.md)**. +* **Les requêtes à plusieurs allers-retours** (multi-round-trip) et **les abonnements** se trouvent sous + **[Dans votre gestionnaire](../handlers/index.md)**, parce que ce sont deux choses qu’un + gestionnaire *fait*. +* **Les modèles d’URI** se trouvent sous **[Serveurs](../servers/index.md)**, à côté des ressources. +* **[Versions du protocole](../protocol-versions.md)** et + **[Fonctionnalités obsolètes](../deprecated.md)** ont chacune leur propre page de premier niveau. + +Si vous n’êtes pas sûr d’avoir besoin de cette section, c’est que vous n’en avez pas besoin. diff --git a/i18n/fr/pages/advanced/low-level-server.md b/i18n/fr/pages/advanced/low-level-server.md new file mode 100644 index 0000000000..c8ce7837c7 --- /dev/null +++ b/i18n/fr/pages/advanced/low-level-server.md @@ -0,0 +1,207 @@ +--- +translation: + sections: [2c79b6338e09b7ac, 7edc43b3fae11314, 1086e77ce561cd7f, a3f71823df5efc31, 9fc7109f72201cae, 7bf25983df655b66, 6330e1f4c6029683, 2f1749c8c133fa1c, b3530fcf4d11fd56, ebc33704fbd74262, cd0e9c933350390e] + tool: 1 +--- +# Le Server de bas niveau {#the-low-level-server} + +`@mcp.tool()` est une couche. En dessous se trouve une seconde classe de serveur, `Server`, qui parle le MCP brut : vous lui donnez les objets du protocole et elle les place sur la liaison, tels quels. + +`MCPServer` est construit par-dessus. Vous descendez d’un niveau lorsque la couche de confort vous gêne : + +* Vous devez émettre un schéma **exact** (chargé depuis un fichier, généré à partir d’une base de données), et non un schéma dérivé d’une signature Python. +* Vous avez besoin d’un contrôle total sur le résultat : `_meta`, `is_error`, chaque clé de `structured_content`. +* Vous devez traiter une méthode que MCP ne définit pas. + +Pour tout le reste, restez sur `MCPServer`. + +## Le même outil, à la main {#the-same-tool-by-hand} + +Voici l’outil `search_books` que **[Outils](../servers/tools.md)** écrit en neuf lignes de `@mcp.tool()`, sans le sucre syntaxique : + +```python title="server.py" hl_lines="22 26 32" +--8<-- "docs_src/lowlevel/tutorial001.py" +``` + +Trois choses ont changé, et elles constituent toute l’API de bas niveau : + +* **Les gestionnaires (handlers) sont des paramètres du constructeur.** `on_list_tools=` et `on_call_tool=` vont dans `Server(...)`. Il n’y a pas de décorateurs à ce niveau, et chaque gestionnaire a la même forme : `async (ctx, params) -> result`. +* **Vous écrivez le schéma d’entrée.** `Tool.input_schema` est un simple `dict` JSON Schema. Personne ne le dérive d’annotations de type, car il n’y a aucune annotation de type dont le dériver. +* **Vous construisez le résultat.** `CallToolResult(content=[TextContent(...)])`, à la main. Rien n’est enveloppé, converti ni déduit d’une annotation de retour. + +`params` est la requête analysée : `CallToolRequestParams` vous donne `.name` et `.arguments`. `ctx` est un `ServerRequestContext` : `ctx.session` pour répondre au client, `ctx.lifespan_context`, `ctx.request_id` et `ctx.meta`, le `_meta` entrant de la requête. + +!!! info + Si vous avez utilisé FastAPI, vous connaissez déjà cette relation. `MCPServer` est la couche des décorateurs et des annotations de type ; `Server` est le Starlette qui se trouve en dessous. Ils ne sont pas rivaux : `MCPServer` construit un `Server` et y enregistre des gestionnaires exactement comme ceux-ci. + +### Essayer {#try-it} + +Pas d’Inspector pour celui-ci : `mcp dev` et `mcp run` n’acceptent qu’un `MCPServer`. Le `Client` en mémoire s’en moque ; il accepte un `Server` de bas niveau exactement comme il accepte un `MCPServer` : + +```python title="main.py" +import asyncio + +from mcp import Client + +from server import server + + +async def main() -> None: + async with Client(server) as client: + result = await client.call_tool("search_books", {"query": "dune", "limit": 5}) + print(result.content) + + +asyncio.run(main()) +``` + +```text +[TextContent(type='text', text="Found 3 books matching 'dune' (showing up to 5).", annotations=None, meta=None)] +``` + +Le même texte que celui produit par la version `@mcp.tool()`. Deux différences, en toute honnêteté : + +* `result.structured_content` vaut `None`. Le serveur de haut niveau enveloppe pour vous un `-> str` dans `{"result": ...}` ; ici, personne ne construit ce que vous n’avez pas construit. +* `list_tools` renvoie le schéma que **vous** avez saisi, caractère pour caractère. La version de haut niveau avait `"title": "Query"` sur chaque propriété et un `"title": "search_booksArguments"` à la racine : des artefacts de Pydantic. À ce niveau, si quelque chose est sur la liaison, c’est vous qui l’y avez mis. + +## Rien n’est vérifié pour vous {#nothing-is-checked-for-you} + +`MCPServer` rejette un mauvais argument avant même que votre fonction s’exécute, en validant l’appel par rapport au schéma qu’il a généré (**[Outils](../servers/tools.md)**). + +`Server` ne fait pas cela. Votre `input_schema` est *annoncé* au client ; il n’est jamais *appliqué* à `params.arguments`. + +!!! check + Appelez `search_books` sans `limit` et votre `args["limit"]` lève `KeyError`. Le client voit : + + ```text + MCPError: Internal server error + ``` + + Une erreur JSON-RPC, code `-32603`, avec un message volontairement générique : le SDK ne divulgue pas votre traceback à un appelant distant. Le modèle ne découvre jamais ce qu’il a mal fait, il ne peut donc pas réessayer. (Dans un test, `raise_exceptions=True` fait remonter la véritable exception à la place ; voir **[Tests](../get-started/testing.md)**.) + +Cela se généralise. Une exception levée depuis un gestionnaire de bas niveau est **toujours** une erreur de protocole, jamais un résultat d’outil avec `is_error=True`. Si vous voulez que le modèle lise l’échec et se rattrape, validez vous-même `params.arguments` et renvoyez `CallToolResult(content=[TextContent(...)], is_error=True)`. Les deux types d’échec sont le sujet de **[Gérer les erreurs](../servers/handling-errors.md)**. + +## Deux outils, un gestionnaire {#two-tools-one-handler} + +`on_call_tool` est l’unique point d’entrée pour tous les outils du serveur. Vous aiguillez selon `params.name` : + +```python title="server.py" hl_lines="38-43" +--8<-- "docs_src/lowlevel/tutorial002.py" +``` + +* `list_tools` annonce les deux. `call_tool` répartit selon le nom. +* La branche `else` compte : `Server` transmettra sans hésiter à votre gestionnaire un `tools/call` pour un nom que vous n’avez jamais listé. Lever une exception à cet endroit transforme l’appel en le même `-32603` que ci-dessus. + +## Sortie structurée, à la main {#structured-output-by-hand} + +Déclarez `output_schema` sur le `Tool` et placez `structured_content` sur le résultat. Les deux vous appartiennent : + +```python title="server.py" hl_lines="19-23 36" +--8<-- "docs_src/lowlevel/tutorial003.py" +``` + +Appelez-le et le résultat porte les deux représentations : + +```json +{ + "content": [{"type": "text", "text": "Found 3 books matching 'dune'."}], + "structuredContent": {"matches": 3, "query": "dune"}, + "isError": false, + "resultType": "complete", + "_meta": {"io.modelcontextprotocol/serverInfo": {"name": "Bookshop", "version": "2.0.0"}} +} +``` + +Le bloc `_meta` est la marque d’identité du serveur : le SDK l’ajoute à chaque résultat de génération 2026, avec la `version` issue du constructeur (un serveur qui n’en définit aucune renvoie une chaîne vide). Un serveur qui ne doit pas s’identifier peut retirer la clé avec un middleware, lequel est maître des résultats qu’il renvoie. + +Le serveur ne compare jamais les deux champs. Le `Client` de ce SDK, si : renvoyez un `structured_content` qui ne satisfait pas le `output_schema` que vous avez déclaré et `call_tool` lève une `RuntimeError` qui commence par `Invalid structured content returned by tool search_books` puis cite l’échec de `jsonschema`. Promettre un schéma ne coûte rien ; le tenir vous incombe. Toute l’échelle des types de retour et des schémas est dans **[Sortie structurée](../servers/structured-output.md)**. + +## `_meta` : pour l’application, pas pour le modèle {#\_meta-for-the-application-not-the-model} + +`content` est la partie de la réponse que lit le modèle. `structured_content` est la même réponse sous forme de données typées. `_meta` est le troisième canal : des données qui voyagent avec le résultat à destination de l’**application cliente**, sans faire partie de la réponse du tout. + +Utilisez-le pour des identifiants d’enregistrement, des identifiants de trace, tout ce dont votre interface a besoin mais pas votre prompt : + +```python title="server.py" hl_lines="37" +--8<-- "docs_src/lowlevel/tutorial004.py" +``` + +* Vous le construisez sous le nom `_meta=`, le nom sur la liaison. Le client le relit sous la forme `result.meta`. +* Préfixez vos clés d’un espace de noms (`bookshop/record_ids`). Les clés `io.modelcontextprotocol/*` sont réservées par le protocole. + +!!! warning + `_meta` est une convention entre vous et l’application cliente, pas une garantie sur ce qui parvient + au modèle. L’hôte décide de ce qu’il affiche. Ne mettez jamais de secret dans quelque partie que ce soit d’un résultat d’outil. + +## Les capacités suivent vos gestionnaires {#capabilities-follow-your-handlers} + +Un `Server` annonce exactement les familles de méthodes pour lesquelles vous lui avez fourni des gestionnaires. Le `Bookshop` ci-dessus passe `on_list_tools` et `on_call_tool` et rien d’autre, donc un client qui s’y connecte voit : + +```json +{"tools": {"listChanged": false}} +``` + +Pas de `resources`, pas de `prompts` : rien ne les soutient. Passez `on_list_prompts` et `prompts` apparaît ; passez `on_completion` et `completions` apparaît. + +`MCPServer` annonce toujours les outils, les ressources et les prompts, que vous en ayez enregistré ou non, car ses managers existent toujours. À ce niveau, la déclaration *est* l’appel au constructeur. + +## Le type générique du cycle de vie {#the-lifespan-generic} + +`Server` est générique sur le type que produit son cycle de vie (lifespan). Annotez-le une fois et l’objet est typé partout où il apparaît : + +```python title="server.py" hl_lines="24-26 44-45 50" +--8<-- "docs_src/lowlevel/tutorial005.py" +``` + +* Le cycle de vie est un `Callable[[Server[Catalog]], AbstractAsyncContextManager[Catalog]]` ; `@asynccontextmanager` sur un générateur `async` vous donne exactement cela. +* Ce qu’il produit via `yield` devient `ctx.lifespan_context`, et comme les gestionnaires sont annotés `ServerRequestContext[Catalog]`, `.search(...)` bénéficie de l’autocomplétion et de la vérification de types. +* On y entre une fois au démarrage du serveur et on en sort une fois à son arrêt. Le démarrage, l’arrêt et la version `MCPServer` de la même idée sont dans **[Cycle de vie](../handlers/lifespan.md)**. + +Sans `lifespan=`, `ctx.lifespan_context` est un `dict` vide. + +## Une méthode à vous {#a-method-of-your-own} + +Le constructeur couvre les méthodes que MCP définit. `add_request_handler` couvre tout le reste : + +```python title="server.py" hl_lines="35-36 39-40 43-44 48" +--8<-- "docs_src/lowlevel/tutorial006.py" +``` + +* Le premier argument est la chaîne de la méthode. Les notifications ont un jumeau, `add_notification_handler`. +* `params_type` est le modèle par rapport auquel les `params` entrants sont validés **avant** l’exécution de votre gestionnaire ; les méthodes personnalisées *ont* donc droit à la validation dont les outils sont privés. Dérivez de `RequestParams` pour que le champ `_meta` s’analyse comme celui de toute autre méthode. +* Le gestionnaire renvoie un `BaseModel`, un `dict` ou `None`. Le SDK le sérialise dans le résultat JSON-RPC. + +Une réserve, en toute honnêteté : le `Client` de haut niveau n’a de verbes que pour les méthodes que MCP définit, il n’y a donc pas de `client.reindex()`. Une méthode propriétaire s’adresse à un pair qui sait déjà qu’elle existe : un client que vous livrez aussi, ou un autre de vos services parlant JSON-RPC. + +Une méthode que vous ne pouvez pas vous approprier : + +```text +ValueError: 'initialize' is handled by the server runner and cannot be overridden; +use Server.middleware to observe or wrap initialization +``` + +La poignée de main (handshake) appartient à l’exécuteur (runner). Vous êtes libre de remplacer `server/discover`, `ping` et toutes les autres méthodes intégrées. + +!!! tip + `Server.middleware`, mentionné dans cette erreur, enveloppe **chaque** message entrant, `initialize` compris. Si ce que vous voulez est observer ou réécrire le trafic plutôt que répondre à une nouvelle méthode, commencez par **[Middleware](middleware.md)**. + +## Les autres gestionnaires {#the-other-handlers} + +Chacun d’eux correspond à une idée pour laquelle vous avez désormais le vocabulaire ; chacun a sa propre page. + +* `on_call_tool`, `on_get_prompt` et `on_read_resource` peuvent renvoyer un `InputRequiredResult` au lieu de leur résultat normal pour mettre l’appel en pause et demander une saisie au client ; voir **[Requêtes à plusieurs allers-retours (multi-round-trip)](../handlers/multi-round-trip.md)**. Fidèle à ce niveau, rien n’est installé pour vous : là où `MCPServer` scelle `requestState` par défaut, ici le `request_state` que vous définissez traverse la liaison exactement tel qu’écrit, jusqu’à ce que vous optiez pour `server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[...]), default_audience=server.name))` : une seule ligne (les deux noms s’importent depuis `mcp.server.request_state`) pour un scellement et une vérification identiques à ceux qu’effectue `MCPServer` (**[Protéger `requestState`](../handlers/multi-round-trip.md#protecting-requeststate)**). +* `on_list_resources`, `on_read_resource`, `on_list_prompts`, `on_get_prompt`, `on_completion` ont la même forme `(ctx, params) -> result` pour les autres primitives. +* `on_subscriptions_listen` sert le flux `subscriptions/listen` de la version 2026-07-28. Passez un `ListenHandler` construit sur un `SubscriptionBus` et publiez des événements sur le bus depuis vos autres gestionnaires ; voir **[Abonnements](../handlers/subscriptions.md)** pour la composition complète. +* `server.streamable_http_app()` renvoie la même application Starlette que celle de `MCPServer` ; déployez-la comme **[Exécuter votre serveur](../run/index.md)** déploie n’importe quelle autre application ASGI. Il n’y a pas de `server.run(transport=...)` à ce niveau : `server.run(read_stream, write_stream, server.create_initialization_options())` pilote une connexion sur une paire de flux, et cette seule ligne dit tout. + +## Récapitulatif {#recap} + +* Le `Server` de bas niveau reçoit ses gestionnaires sous forme de **paramètres de constructeur** `on_*` ; chaque gestionnaire est `async (ctx, params) -> result`. +* Vous écrivez le dict `input_schema` et vous construisez le `CallToolResult`. Rien n’est dérivé, enveloppé ni validé pour vous. +* Une exception dans un gestionnaire est une erreur de protocole `-32603`. Une erreur d’outil que le modèle peut lire est un `CallToolResult` avec `is_error=True` que **vous** renvoyez. +* Le `_meta` du résultat s’adresse à l’application cliente, pas au modèle. +* `Server[T]` est générique sur ce que produit son cycle de vie ; `ctx.lifespan_context` est un `T` typé. +* `add_request_handler(method, params_type, handler)` sert n’importe quelle méthode. `initialize` est réservée. +* Les capacités qu’annonce un `Server` découlent des gestionnaires que vous avez enregistrés. + +`Client(server)` a traité les deux serveurs de façon identique parce qu’ils *sont* le même protocole, et c’est tout l’intérêt. La couche suivante vers le bas n’est pas une classe du tout : c’est le **[Middleware](middleware.md)**. diff --git a/i18n/fr/pages/advanced/middleware.md b/i18n/fr/pages/advanced/middleware.md new file mode 100644 index 0000000000..5268c1c96b --- /dev/null +++ b/i18n/fr/pages/advanced/middleware.md @@ -0,0 +1,132 @@ +--- +translation: + sections: [6048b4f308edbb8c, 068bda0f21ee9c1b, c3e565b61acd75c5, c62422b159c6ed09, 47204fab253cc45c] + tool: 1 +--- +# Middleware {#middleware} + +Un **middleware** est une fonction asynchrone qui enveloppe chaque message que votre serveur reçoit. + +Vous l’écrivez sous la forme `async (ctx, call_next)` et vous l’ajoutez à `server.middleware`. C’est toute l’API. + +!!! warning + La liste de middlewares est marquée **provisoire** dans le code source : sa signature et sa + sémantique peuvent changer dans une version mineure 2.x. Utilisez-la pour *observer* + (chronométrage, journalisation, traçage) et pour *refuser* des messages ; n’en faites pas la + fondation sur laquelle repose votre serveur. + +`MCPServer` reçoit la liste à la construction (`MCPServer(name, middleware=[...])`) et l’expose sous +`mcp.middleware` ; le `Server` bas niveau expose la même liste sous `server.middleware`. L’exemple +ci-dessous utilise le `Server` bas niveau ; si `Server(name, on_call_tool=...)` est nouveau pour +vous, lisez d’abord **[Le Server bas niveau](low-level-server.md)**. + +## Un middleware de chronométrage {#a-timing-middleware} + +Un serveur, un outil, un middleware qui journalise le temps pris par chaque message : + +```python title="server.py" hl_lines="39-45 49" +--8<-- "docs_src/middleware/tutorial001.py" +``` + +* `ctx` est le même `ServerRequestContext` que celui que reçoivent vos gestionnaires (handlers). + `ctx.method` est la chaîne de méthode brute ; `ctx.params` contient les paramètres bruts, + **avant** toute validation. +* `call_next(ctx)` exécute le reste de la chaîne : la validation, la recherche du gestionnaire, + votre gestionnaire. Renvoyez ce qu’il a renvoyé et la réponse reste intacte. +* Le `try`/`finally` est délibéré : un gestionnaire qui lève une exception est tout de même + chronométré, car l’échec atteint votre middleware sous la forme de l’exception qui sort de + `call_next`. +* `server.middleware.append(...)` l’enregistre. La liste s’exécute de l’extérieur vers + l’intérieur, donc `middleware[0]` est celui qui est le plus proche de la liaison. + +### Essayer {#try-it} + +Connectez un client, listez les outils, appelez-en un. Votre journal contient **trois** lignes : + +```text +server/discover took 18.3 ms +tools/list took 0.1 ms +tools/call took 0.1 ms +``` + +Vous avez fait deux appels et obtenu trois lignes. La première est `server/discover` : la requête +que le client a envoyée pour établir la connexion, avant que vous ne demandiez quoi que ce soit. + +C’est tout l’intérêt. Le middleware enveloppe **chaque** message entrant : + +* La mise en place de la connexion : `server/discover`, ou `initialize` et + `notifications/initialized` sur une session historique. +* Chaque requête et chaque notification. Pour une notification, `ctx.request_id is None`, + `call_next(ctx)` renvoie `None`, et tout ce que vous renvoyez est ignoré. +* Même une méthode pour laquelle le serveur n’a pas de gestionnaire : `call_next` lève + `MCPError(-32601, "Method not found")` *à travers* votre middleware en route vers le client. + +## Ce que vous pouvez y faire {#what-you-can-do-inside-one} + +Du geste le plus anodin à celui devant lequel vous devriez le plus hésiter : + +* **Observer.** Chronométrer, compter, journaliser. C’est l’exemple ci-dessus. +* **Refuser.** Levez une `MCPError` *au lieu* d’appeler `call_next(ctx)` et ce message-là + reçoit pour réponse une erreur JSON-RPC. La connexion reste ouverte ; le message suivant passe. + C’est ainsi qu’un serveur contrôle l’accès à `subscriptions/listen` appelant par appelant : la + section **[Décider qui peut observer](../handlers/subscriptions.md#deciding-who-may-watch)** de + la page Abonnements détaille la démarche. +* **Réécrire.** `ctx` est une dataclass : `await call_next(dataclasses.replace(ctx, params=...))` + transmet au reste de la chaîne d’autres paramètres que ceux envoyés par le client. Ne faites + jamais cela pour `initialize` : le résultat que le client reçoit en retour est construit à + partir de vos paramètres réécrits, mais le serveur fixe l’état de sa connexion à partir des + paramètres d’origine reçus sur la liaison. Les deux côtés peuvent terminer la poignée de main + (handshake) en désaccord sur ce qu’ils ont négocié. +* **Répondre.** Renvoyez un résultat sans appeler `call_next(ctx)` et il part au client comme + votre réponse. `call_next` vous remet la forme finale telle qu’elle circule sur la liaison, et + le pipeline ne retouche jamais ce que vous renvoyez ; toute l’enveloppe est donc à votre + charge : sur une connexion de génération 2026, cela inclut l’estampille `_meta` `serverInfo`, + que le SDK ajoute aux résultats des gestionnaires mais pas aux vôtres. + +!!! check + `initialize` fait partie de ce que le middleware enveloppe, et c’est le *seul* hook dont + vous disposez pour lui. Essayez d’en prendre le contrôle avec `add_request_handler` et le + SDK refuse : + + ```text + ValueError: 'initialize' is handled by the server runner and cannot be overridden; + use Server.middleware to observe or wrap initialization + ``` + +!!! warning + `initialize` est traité en ligne : le serveur ne lit aucun autre message entrant tant que + votre chaîne de middlewares n’est pas revenue. Attendre une requête du serveur vers le client + (`ctx.session.send_request(...)`, une élicitation (elicitation)) pendant le traitement de + `initialize` **provoque donc l’interblocage de la connexion** : la réponse que vous attendez + ne pourra jamais être lue. Les notifications envoyées sans attente de réponse ne posent pas + de problème. + +## Le seul middleware activé par défaut {#the-one-middleware-that-ships-on-by-default} + +Le SDK fournit exactement un middleware, et il figure déjà dans la liste de votre serveur : celui +qui émet un span OpenTelemetry pour chaque message. Vous ne l’ajoutez pas et, la plupart du temps, +vous n’y pensez pas. Il ne fait rien tant que vous n’installez pas d’exporteur, et il a sa propre +page : **[OpenTelemetry](../run/opentelemetry.md)**. + +!!! info + Si vous avez déjà écrit un middleware ASGI, vous connaissez cette forme. Le + `(scope, receive, send)` de Starlette est devenu `(ctx, call_next)`, et il s’exécute *après* + le transport, sur le message décodé plutôt que sur la requête HTTP brute. Les deux se + composent : un middleware Starlette sur `streamable_http_app()` voit du HTTP ; celui-ci voit + du MCP. + +## Récapitulatif {#recap} + +* Un middleware est `async (ctx, call_next) -> result`, passé via `MCPServer(middleware=[...])` + (ou ajouté à `mcp.middleware`), et ajouté à `server.middleware` sur le `Server` bas niveau. +* Il enveloppe **chaque** message entrant (`server/discover`, `initialize`, requêtes, + notifications, méthodes inconnues) et s’exécute de l’extérieur vers l’intérieur. +* `ctx.request_id is None` est ce qui distingue une notification d’une requête. +* Levez une exception au lieu d’appeler `call_next` pour refuser un message ; la connexion + survit. +* Le traçage OpenTelemetry du SDK est lui aussi un middleware, déjà dans la liste. Voir + **[OpenTelemetry](../run/opentelemetry.md)**. +* Toute cette surface est provisoire. Servez-vous-en pour observer ; ne construisez pas dessus. + +C’est tout ce qui enveloppe une requête. Quant à savoir si la requête a seulement le droit de +s’exécuter, c’est l’**[Autorisation](../run/authorization.md)** qui en décide. diff --git a/i18n/fr/pages/advanced/pagination.md b/i18n/fr/pages/advanced/pagination.md new file mode 100644 index 0000000000..4e0a9eff1f --- /dev/null +++ b/i18n/fr/pages/advanced/pagination.md @@ -0,0 +1,85 @@ +--- +translation: + sections: [a9aba7a026c7bd85, ed32bda7ba9ae33a, 7e64cc5646abb91f, 22a0129ee78b3c63, d875373c06d8d2f9] + tool: 1 +--- +# Pagination {#pagination} + +La plupart des serveurs n’en ont jamais besoin. + +`MCPServer` répond à chaque requête `list_*` avec tout ce qu’il a, en une seule page, `next_cursor=None`. Pour quelques dizaines d’outils, de ressources ou de prompts, c’est la bonne réponse et il n’y a rien à configurer. + +La pagination sert au serveur dont la liste de ressources est en réalité une base de données : des milliers de lignes qu’il refuse de sérialiser en une seule réponse. La réponse du protocole est un **curseur** : le serveur renvoie une page accompagnée d’un jeton opaque, et le client renvoie ce jeton pour obtenir la page suivante. + +`@mcp.resource()` n’offre aucun point d’accroche pour cela. Pour paginer, vous écrivez vous-même le gestionnaire (handler) de liste, sur le **[Server de bas niveau](low-level-server.md)**. + +## Un serveur qui pagine {#a-server-that-pages} + +```python title="server.py" hl_lines="12 15-16" +--8<-- "docs_src/pagination/tutorial001.py" +``` + +* Sur un `Server` de bas niveau, les gestionnaires sont des arguments du constructeur, pas des décorateurs. `on_list_resources` répond à chaque requête `resources/list` ; c’est tout le branchement nécessaire. +* Chaque gestionnaire paginé est typé `params: PaginatedRequestParams | None`, et l’exemple accepte les deux. Sur une connexion, cependant, le SDK ne vous passe jamais `None` (une requête sans membre `params` arrive au gestionnaire sous la forme du modèle avec ses valeurs par défaut), donc le signal qui compte est `params.cursor is None` : **commencer par le début**. +* C’est vous qui décidez ce qu’*est* un curseur. Ici, c’est un décalage (offset) rendu sous forme de chaîne. Un horodatage, une clé primaire, un blob base64 : tout ce que vous pouvez émettre à l’aller et reconnaître au retour. +* `next_cursor=None` est votre façon de dire « c’était la dernière page ». Il n’y a ni décompte, ni total, ni `has_more`. `None` est le signal à lui seul. + +!!! tip + Une valeur de `PAGE_SIZE` de 10 rend l’exemple lisible. Choisissez la vôtre par point de terminaison : une liste de + ressources d’une ligne peut se permettre une page de 500 ; une liste de gros modèles de prompts, non. + Le client n’a pas son mot à dire, et c’est voulu. + +### Essayer {#try-it} + +`Client(server)` se connecte à un `Server` de bas niveau en mémoire exactement comme il se connecte à un `MCPServer`. + +Appelez `list_resources()` sans argument. Vous obtenez dix ressources, de `book-1` à `book-10`, et `next_cursor` vaut la chaîne `"10"`. + +Renvoyez-la avec `list_resources(cursor="10")` : la première ressource est `book-11`, le nouveau `next_cursor` vaut `"20"`. + +La dixième page revient avec `next_cursor` à `None`. Terminé. + +## La boucle côté client {#the-client-loop} + +Chaque méthode `list_*` de `Client` (`list_tools`, `list_resources`, `list_resource_templates`, `list_prompts`) accepte un argument nommé `cursor=`. Vider une liste paginée tient en un `while True` : + +```python title="client.py" hl_lines="26-32" +--8<-- "docs_src/pagination/tutorial002.py" +``` + +* `cursor` démarre à `None`, donc la première requête ne porte aucun curseur. +* Étendez la liste **avant** de regarder `next_cursor` : la dernière page contient elle aussi des ressources. +* `next_cursor is None` est la sortie. Toute autre valeur repart directement dans `cursor=`, telle quelle. + +Lancez son `main()` et il affiche `100 resources` : dix pages de dix, assemblées par une boucle qui n’a jamais su qu’il y avait dix pages. + +C’est la même boucle que montre **[Le client](../client/index.md)** pour chaque verbe `list_*`, et elle ne coûte rien face à un serveur qui ne pagine pas : `next_cursor` vaut `None` dès la première réponse et la boucle s’exécute une seule fois. + +## Les trois règles {#the-three-rules} + +**Les curseurs sont opaques.** Un client ne doit jamais en analyser, en construire ni en deviner un. La seule source légitime d’un curseur est le `next_cursor` de la page précédente, tel quel. + +**Le serveur choisit la taille de page.** Il n’y a pas de `limit=` dans le protocole. S’il vous faut une autre taille de page, vous modifiez le serveur. + +**Un client qui ignore la pagination fonctionne quand même.** Il appelle `list_resources()` une fois, obtient les dix premières, et ne remarque jamais le `next_cursor` qu’il a jeté. Rien ne casse ; il en voit moins. + +!!! check + Opaque veut dire opaque. Inventez un curseur (`list_resources(cursor="page-2")`) et le + protocole ne peut rien pour vous. Ce serveur tente `int("page-2")`, le gestionnaire lève une exception, + et ce qui revient au client est : + + ```text + MCPError(-32603, 'Internal server error', None) + ``` + + Un curseur que vous n’avez pas obtenu du serveur est un bogue, pas une demande de fonctionnalité. + +## Récapitulatif {#recap} + +* `MCPServer` renvoie tout en une seule page. La pagination est facultative, et vous l’activez sur le `Server` de bas niveau. +* `on_list_resources` (ainsi que `on_list_tools`, `on_list_prompts`, `on_list_resource_templates`) reçoit `PaginatedRequestParams | None` ; `params.cursor` vaut `None` pour la première page. +* Vous renvoyez une page plus un `next_cursor` : n’importe quelle chaîne que vous reconnaîtrez plus tard, ou `None` quand il ne reste rien. +* La boucle côté client : passez `cursor=`, accumulez, répétez jusqu’à ce que `next_cursor is None`. +* Les curseurs sont opaques, la taille de page appartient au serveur, et un client qui ne pagine pas obtient quand même la première page. + +Le reste de l’API `Server` écrite à la main (`on_call_tool`, les dicts `input_schema`, `_meta`) se trouve dans **[Le Server de bas niveau](low-level-server.md)**. diff --git a/i18n/fr/pages/client/caching.md b/i18n/fr/pages/client/caching.md new file mode 100644 index 0000000000..a319b78471 --- /dev/null +++ b/i18n/fr/pages/client/caching.md @@ -0,0 +1,122 @@ +--- +translation: + sections: [9e7b9a1710e5aeba, b74ca4c1d2ddddee, fa8714e61bf90c5a, 04db67a886b7271c, 857690fb8f876800] + tool: 1 +--- +# Indications de mise en cache {#caching-hints} + +Sur le protocole 2026-07-28, chaque résultat qu’un serveur renvoie pour `tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read` et `server/discover` porte deux champs : `ttlMs`, le nombre de millisecondes pendant lesquelles un client peut considérer le résultat comme frais, et `cacheScope`, qui indique si un résultat mis en cache peut être partagé entre utilisateurs (`"public"`) ou appartient à un seul contexte d’autorisation (`"private"`). + +Le serveur ne met rien en cache. Ces champs sont une *déclaration* : « cette liste d’outils est la même pour tout le monde et ne changera pas pendant une minute ». Un client (ou une passerelle placée devant vous) peut alors s’épargner l’aller-retour. Respecter ces indications relève du choix du client ; les émettre est le travail du serveur, et le SDK le fait pour vous. + +Par défaut, chaque résultat indique `ttlMs: 0, cacheScope: "private"` : périmé immédiatement, jamais partagé. C’est toujours sûr et toujours conforme. Si vos listes sont réellement stables et identiques pour tous les appelants, dites-le à la construction : + +```python title="server.py" hl_lines="5-8" +--8<-- "docs_src/caching/tutorial001.py" +``` + +* Le dictionnaire est indexé par **nom de méthode**, et les six méthodes pouvant être mises en cache sont les seules clés autorisées. Le paramètre est typé `Mapping[CacheableMethod, CacheHint]` : votre éditeur complète donc les clés automatiquement et signale une faute de frappe avant l’exécution ; tout ce qui échappe au vérificateur de types lève une exception à la construction. +* Une méthode que vous ne mentionnez pas garde les valeurs par défaut. Le dictionnaire est un ensemble de surcharges, pas un manifeste. +* `CacheHint(ttl_ms=5_000)` n’a pas défini `scope`, qui reste donc `"private"` : cinq secondes de fraîcheur, par appelant. La portée et le TTL sont deux décisions indépendantes. +* `"server/discover"` est aussi une clé autorisée, puisque le résultat de découverte peut être mis en cache comme n’importe quelle liste. + +!!! warning + `cacheScope: "public"` signifie que *n’importe qui* peut recevoir votre réponse mise en cache. Une passerelle + partagée transmettra sans hésiter le résultat d’un utilisateur à un autre, même lorsque la requête était + authentifiée. Ne marquez un résultat `"public"` que s’il est identique pour chaque appelant, et + n’utilisez jamais `cacheScope` comme contrôle d’accès : c’est une étiquette, pas un verrou. + +## Surcharge par gestionnaire {#per-handler-override} + +Sur le `Server` bas niveau, les gestionnaires (handlers) construisent leurs résultats à la main, et `ttl_ms` / `cache_scope` sont de simples champs des modèles de résultat. Un gestionnaire qui les définit explicitement l’emporte toujours sur le dictionnaire du constructeur, champ par champ : + +```python title="server.py" hl_lines="10 16" +--8<-- "docs_src/caching/tutorial002.py" +``` + +Le gestionnaire a indiqué `ttl_ms=1_000` et rien sur la portée. Sur la liaison : `ttlMs: 1000` (la valeur du gestionnaire, pas le `60_000` du dictionnaire) et `cacheScope: "public"` (la valeur du dictionnaire, puisque le gestionnaire ne l’a pas définie). L’explicite l’emporte sur le configuré, et le configuré sur la valeur par défaut. Cela vaut champ par champ : un gestionnaire peut donc fixer un champ et laisser l’autre à la politique du serveur. + +C’est aussi l’échappatoire pour les comportements dynamiques que le constructeur ne peut pas connaître : un gestionnaire qui filtre `resources/read` par utilisateur peut renvoyer `cache_scope="private"` pour un URI donné sur un serveur par ailleurs public. + +Une réserve sur les listes paginées : le protocole exige **le même `cacheScope` sur chaque page** d’une même liste. Le dictionnaire du constructeur y satisfait par construction, puisqu’il est indexé par méthode et non par page. Mais un gestionnaire qui surcharge lui-même la portée devient responsable de cette cohérence : surchargez-la sur *chaque* page, jamais uniquement lorsqu’un curseur est présent, sinon la page un et la page deux se contrediront. + +## Ce que voit le client {#what-the-client-sees} + +Sur une session 2026-07-28, `Client` respecte les indications pour vous : il embarque un cache de réponses, activé par défaut. Un résultat qui arrive avec un `ttlMs` est stocké, et un appel identique effectué dans ce TTL est servi depuis le cache, sans aller-retour. Un résultat qui ne porte *aucune* indication n’est pas mis en cache : les résultats sans indication reçoivent `CacheConfig.default_ttl_ms`, dont la valeur par défaut est `0` (périmé immédiatement), si bien qu’un serveur qui ne déclare rien voit exactement le même trafic, appel pour appel, qu’auparavant. + +```python title="client.py" hl_lines="33 35 38" +--8<-- "docs_src/caching/tutorial003.py" +``` + +Quatre appels, trois récupérations. Le deuxième appel a trouvé une entrée fraîche et n’a jamais atteint le serveur ; avancer l’horloge (injectée) au-delà du TTL a fait que le troisième récupère à nouveau ; le quatrième a indiqué `cache_mode="refresh"`. Cet argument nommé existe sur les cinq verbes avec cache (`list_tools`, `list_prompts`, `list_resources`, `list_resource_templates`, `read_resource`) : + +* `"use"` (la valeur par défaut) sert une entrée fraîche s’il y en a une, et stocke le résultat récupéré sinon. +* `"refresh"` ne sert jamais depuis le cache : il récupère et stocke le résultat, en remplaçant ce qui était en cache. +* `"bypass"` effectue l’aller-retour sans toucher du tout au cache : ni lecture, ni écriture. + +Une règle prime sur `"use"` : **les appels portant `meta` atteignent toujours le serveur.** Une requête avec `meta` défini (un jeton de progression, des champs de traçage) attend une requête sur la liaison ; sous `cache_mode="use"`, elle est donc traitée comme `"refresh"` : la lecture du cache est sautée, et le résultat récupéré remplace quand même l’entrée en cache. `"bypass"` et un `"refresh"` explicite se comportent comme d’habitude. + +Pour désactiver entièrement la mise en cache, construisez avec `Client(server, cache=None)` : chaque appel redevient un aller-retour, et `cache_mode`, bien que toujours accepté, n’a aucun effet. + +La portée est elle aussi respectée automatiquement : les entrées `"private"` sont indexées sur la *partition* du cache (ci-dessous), tandis que les entrées `"public"` peuvent opter pour un partage plus large. Et **les notifications priment sur le TTL** pour les entrées exactes qu’elles désignent : une notification `list_changed` évince la liste correspondante en cache, et `resources/updated` évince la lecture en cache stockée exactement sous son URI, aussi fraîches soient-elles. Sur une connexion 2026-07-28, ces notifications arrivent sur un flux `subscriptions/listen` que vous ouvrez avec `client.listen(...)`, et l’éviction se termine avant que votre observateur ne voie l’événement ; tous les détails sont dans **[Abonnements](subscriptions.md)**. + +Une réserve sur `resources/updated` : l’éviction ne porte que sur l’URI exact. Le contrat du magasin n’a ni opération d’énumération ni de parcours (comme l’implémentation TypeScript de référence), donc une notification portant l’URI d’une *sous*-ressource n’évince pas la lecture en cache de son parent. Si votre serveur signale ainsi des sous-ressources, récupérez à nouveau le parent avec `cache_mode="refresh"`. + +### Configurer le cache : `CacheConfig` {#configuring-it-cacheconfig} + +```python +from mcp.client import CacheConfig + +client = Client("https://api.example.com/mcp", cache=CacheConfig(default_ttl_ms=5_000)) +``` + +* `store` : l’endroit où vivent les entrées. Par défaut, un nouveau magasin en mémoire par client ; passez votre propre implémentation de `ResponseCacheStore` (adossée à Redis, par exemple) pour partager un cache entre clients ou processus. Les types du contrat (`ResponseCacheStore`, `CacheKey`, `CacheEntry` et le `InMemoryResponseCacheStore` par défaut) sont importables depuis `mcp.client`. Une recherche peut émettre jusqu’à deux `get` séquentiels sur le magasin (la branche privée, puis la publique) ; dimensionnez donc en conséquence vos attentes de latence pour un magasin distant. Un magasin personnalisé **exige** une `partition` explicite. +* `partition` : l’étiquette de contexte d’autorisation qui empêche les entrées `"private"` d’un principal d’être servies à un autre au sein d’un magasin partagé. +* `target_id` : identité explicite du serveur, pour les transports personnalisés et les serveurs en processus (ci-dessous). +* `default_ttl_ms` : TTL appliqué aux résultats qui ne portent aucune indication `ttlMs`. La valeur par défaut `0` laisse les résultats sans indication hors du cache. +* `share_public` : servir entre partitions les entrées que le serveur affirme `"public"` (ci-dessous). Désactivé par défaut. +* `clock` : la source d’horloge murale, en secondes depuis l’epoch. Injectez-en une, comme le fait l’exemple ci-dessus, et les tests d’expiration n’ont pas besoin d’attendre. + +!!! warning "Partition = principal vérifié" + Dérivez `partition` d’**informations d’identification vérifiées**, comme le sujet d’un jeton validé. Ne la dérivez jamais de données fournies par la requête, ni de l’URL du serveur (l’identité du serveur est un axe de clé distinct). Le SDK est une bibliothèque sans authentification propre : l’ancre de confiance est celui qui construit le `CacheConfig`, c’est-à-dire le déploiement, pas le locataire. Une passerelle multi-locataire crée un `CacheConfig` par principal authentifié. + + La partition est aussi figée pour toute la durée de vie du `Client`. Si le contexte d’autorisation de la connexion change en cours de session (une réauthentification sous un autre principal, par exemple), le cache ne suit pas ; construisez un nouveau `Client` pour le nouveau principal. + +Les clés du cache portent aussi **l’identité du serveur** : la chaîne d’URL que vous avez appelée, débarrassée de toute partie userinfo `user:pass@` et sinon conservée à l’octet près. Pas de normalisation de la casse, pas de réordonnancement des paramètres de requête, pas de nettoyage de la barre oblique finale. Sous-normaliser ne coûte que du partage, alors que sur-normaliser pourrait fusionner deux locataires (`?tenant=a` et `?tenant=b`) : des URL superficiellement différentes ne partagent tout simplement pas d’entrées. Lorsqu’il n’y a pas d’URL (un serveur en processus, ou une instance de `Transport`), le client reçoit à la place une identité aléatoire par instance ; définissez `CacheConfig.target_id` pour nommer le serveur (avec un magasin personnalisé, c’est obligatoire, et la construction vous le dit). L’identité est hachée en sha256 avant d’entrer dans le matériau de clé, si bien qu’une URL portant des secrets dans sa chaîne de requête n’apparaît jamais dans les clés du magasin. Ne journalisez pas non plus vous-même la forme avant hachage. + +!!! warning "`share_public` fait confiance au serveur, pour tout le parc" + Par défaut, même les entrées `"public"` restent dans leur partition. `share_public=True` sert les entrées que le serveur a marquées `cacheScope: "public"` à **toutes** les partitions qui utilisent le magasin, en faisant confiance à la classification du serveur au nom de chacune d’elles. Un serveur qui appose `"public"` sur des données propres à un locataire (par bogue ou par malveillance) fait alors fuiter la réponse d’un locataire vers les autres. L’option est délibérément limitée au constructeur : le `cache_mode` par appel peut restreindre la mise en cache, mais rien au niveau de l’appel ne peut élargir le partage. + +### Ce que le cache ne fait jamais {#what-the-cache-never-does} + +* **Les appels au niveau session le contournent.** `client.session.list_tools()` et consorts font toujours l’aller-retour ; le cache vit sur les verbes de `Client`. +* **`server/discover` reste en dehors.** Le résultat de découverte est livré une fois, à la connexion, et n’entre jamais dans le cache de réponses, même lorsqu’il porte un `ttlMs`. Si vous en persistez un vous-même pour éviter la sonde de reconnexion ([`prior_discover`](../protocol-versions.md#reconnecting-with-prior_discover)), sa fraîcheur relève de votre propre suivi : `DiscoverResult` porte `ttl_ms` et `cache_scope`, déjà analysés, précisément à cette fin. +* **Les pages de continuation ne sont jamais mises en cache.** Seuls les appels sans curseur participent. Une page de continuation rejetée pour curseur expiré *évince* bien la liste en cache, car la liste a changé entre-temps. +* **Les lectures à plusieurs allers-retours (multi-round-trip) ne sont jamais mises en cache.** Un `read_resource` amorcé avec `input_responses`/`request_state`, ou qui se résout au fil de tours de saisie, n’entre jamais dans le cache (un MUST de la spécification). +* **L’éviction par notification a besoin de notifications.** L’éviction ne vaut que ce que vaut la livraison du transport, et le chemin moderne en processus (`Client(server)` avec le `mode="auto"` par défaut) ne livre pas aujourd’hui les notifications autonomes. +* **L’éviction se produit à terme, pas instantanément.** Les notifications qui arrivent par la liaison sont distribuées depuis des tâches lancées à part ; un appel en concurrence avec l’arrivée d’une notification peut donc se voir servir une fois de plus l’entrée d’avant l’éviction ; la fenêtre est bornée par la latence de distribution, et l’éviction a tout de même lieu. +* **Pas de stale-if-error.** Une entrée expirée n’est jamais servie parce que la nouvelle récupération a échoué ; l’erreur se propage. +* **Pas de récupération anticipée.** Une entrée stockée est servie jusqu’à expiration de son TTL, et l’appel suivant paie l’aller-retour ; rien ne se rafraîchit en arrière-plan. +* **Pas de regroupement.** Deux appels identiques concurrents font deux récupérations. +* **Pas de TTL au-delà de 24 heures.** Un `ttlMs` supérieur, qu’il vienne du serveur ou de la configuration, est ramené à ce plafond au stockage (`mcp.client.caching.MAX_TTL_MS`), ce qui borne la durée pendant laquelle une entrée, si généreuse soit son indication, peut être servie. +* Sur un **magasin partagé**, les clients sont en concurrence. Chaque client abandonne sa propre écriture lorsqu’une éviction a doublé la récupération en cours, mais un client *colocataire* peut toujours réécrire une entrée qu’une éviction qu’il n’a jamais vue avait supprimée ; et ce suivi des concurrences est lui-même borné : au-delà de 4 096 clés suivies, la garde de la clé la plus ancienne est abandonnée en premier. Les deux fenêtres sont acceptées, et refermées par le plafond de TTL ci-dessus. +* **Pas de service d’une génération de protocole à l’autre.** Les entrées sont rattachées à la version de protocole négociée : sur un magasin persistant partagé, une session ne sert jamais une entrée écrite sous une autre version négociée (la même liste diffère réellement selon la génération, puisque le SDK retire les champs 2026 pour les sessions plus anciennes). L’éviction, de même, ne touche que les entrées de la génération courante ; les entrées d’une autre génération expirent simplement avec leur TTL. + +### Lire les indications vous-même {#reading-the-hints-yourself} + +Les indications sont aussi de simples champs sur chaque résultat pouvant être mis en cache (`result.ttl_ms` et `result.cache_scope`, déjà analysés), au cas où vous voudriez superposer votre propre suivi au cache intégré (ou le remplacer). + +Face à un **serveur plus ancien** (protocole antérieur à 2026), les champs sont tout simplement absents de la liaison, et les modèles affichent leurs valeurs par défaut prudentes : `ttl_ms == 0` et `cache_scope == "private"`, périmé et non partagé, la bonne hypothèse pour un serveur qui n’a rien déclaré. Le cache traite une session historique de la même façon : les indications n’y sont jamais consultées (quelles que soient les clés présentes sur la liaison), seul `default_ttl_ms` s’applique, et sa valeur par défaut de `0` ne met rien en cache, de sorte qu’une connexion antérieure à 2026 se comporte exactement comme avant l’existence du cache. Si vous devez distinguer « le serveur a dit 0 » de « le serveur n’a rien dit », testez `"ttl_ms" in result.model_fields_set` : il n’est défini que lorsque le champ est réellement arrivé. + +## Clients plus anciens {#older-clients} + +Les clients sur des versions de protocole antérieures à 2026 ne voient jamais ni l’un ni l’autre de ces champs ; le SDK les retire à la sérialisation pour ces connexions. Configurez vos indications une fois pour toutes ; il n’y a rien de propre à une version à écrire. + +## Récapitulatif {#recap} + +* Six méthodes portent `ttlMs`/`cacheScope` ; le SDK leur donne par défaut `0`/`"private"`, périmé et non partagé, toujours sûr. +* `cache_hints={method: CacheHint(...)}` à la construction (`MCPServer` comme `Server`) fixe des valeurs par méthode pour tout le serveur. +* Un gestionnaire qui définit les champs sur son résultat surcharge le dictionnaire, champ par champ. +* `"public"` est la promesse que le résultat est identique pour chaque appelant. Ce n’est pas un contrôle d’accès. +* `Client` respecte les indications automatiquement : son cache de réponses est activé par défaut, sert les entrées fraîches au lieu de les récupérer à nouveau, et ne met rien en cache pour les serveurs (ou les sessions) qui ne fournissent aucune indication. +* Par appel, `cache_mode="refresh"` récupère à nouveau et `"bypass"` saute le cache ; `cache=None` à la construction le désactive entièrement. diff --git a/i18n/fr/pages/client/callbacks.md b/i18n/fr/pages/client/callbacks.md new file mode 100644 index 0000000000..551453f2fa --- /dev/null +++ b/i18n/fr/pages/client/callbacks.md @@ -0,0 +1,154 @@ +--- +translation: + sections: [adf3c545b5be46b6, 916cd3ab1c03f461, e9be7a8d0eb0a456, 565890a636288ecf, 6af7e49db9129ec3, 06b0238c174186af, 90c6043be435fcb0] + tool: 1 +--- +# Fonctions de rappel du client {#client-callbacks} + +Presque toutes les requêtes dans MCP vont dans un seul sens : du client vers le serveur. + +Un serveur peut aussi demander des choses au **client** : poser une question à l’utilisateur, échantillonner le modèle de l’utilisateur, lister les dossiers de son espace de travail. Vous répondez à ces requêtes en passant des **fonctions de rappel** (callbacks) à `Client(...)`. + +## Un serveur qui demande {#a-server-that-asks} + +Voici un serveur dont l’outil ne peut pas terminer tout seul : + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/client_callbacks/tutorial001.py" +``` + +* `ctx.elicit(...)` envoie une requête `elicitation/create` **au client** et attend. +* L’outil ne renvoie rien tant que quelqu’un (une personne devant un formulaire, ou votre code) n’a pas fourni un `name`. + +C’est la moitié serveur, et la page **[Élicitation](../handlers/elicitation.md)** la couvre en détail. Cette page-ci se tient à l’autre bout de la liaison. + +## La fonction de rappel d’élicitation {#the-elicitation-callback} + +```python title="client.py" hl_lines="6-10 16-17" +--8<-- "docs_src/client_callbacks/tutorial002.py" +``` + +* Une fonction de rappel d’élicitation (elicitation) a pour signature `async (context, params) -> ElicitResult`. +* `params.message` est la question. `params.requested_schema` est le JSON Schema de la réponse que le serveur attend. Un vrai client en tire un formulaire ; celui-ci le remplit automatiquement. +* Vous renvoyez `ElicitResult(action="accept", content={...})`, ou `action="decline"`, ou `action="cancel"`. La seule autre option est `ErrorData(...)`, qui refuse la requête et fait échouer l’appel entier. +* `context` est un `ClientRequestContext` : la `session` active, le `request_id` du serveur et les éventuelles `meta` qu’il a jointes. + +!!! tip + `params` est une union des deux modes d’élicitation. Ici `params.mode` vaut `"form"` ; une requête `"url"` + porte `params.url` au lieu d’un schéma. Une seule fonction de rappel gère les deux ; branchez sur `params.mode`. + **[Élicitation](../handlers/elicitation.md)** montre le motif complet. + +### Essayer {#try-it} + +Appelez `issue_card` et observez les deux extrémités. + +Votre fonction de rappel reçoit la question du serveur, déjà analysée : + +```python +params.mode # 'form' +params.message # 'What name should go on the card?' +params.requested_schema # {'properties': {'name': {'title': 'Name', 'type': 'string'}}, + # 'required': ['name'], 'title': 'CardHolder', 'type': 'object'} +``` + +Elle répond, `ctx.elicit(...)` reprend à l’intérieur de l’outil, et l’outil termine : + +```python +result.content # [TextContent(type='text', text='Card issued to Ada Lovelace.')] +``` + +Un `tools/call` de votre part, un `elicitation/create` en retour du serveur, auquel votre fonction répond, le tout à l’intérieur d’un seul appel d’outil. + +!!! info + `mode="legacy"` dans l’appel `Client(...)` fait un vrai travail. Par défaut, `Client(...)` négocie le chemin + moderne du protocole, et ce chemin n’a pas de canal de retour (back-channel) pour les requêtes du serveur vers le client : `ctx.elicit` + échoue avant même que votre fonction de rappel ne s’exécute. Ce n’est pas le transport qui en décide ; c’est le + protocole négocié, en mémoire comme via une URL. Fixez `mode="legacy"` dès que votre client doit + répondre à l’une d’elles ; tous les tests derrière cette page le font. Tous les détails sont dans **[Versions du protocole](../protocol-versions.md)**. + + Sur une session 2026-07-28, la fonction de rappel n’est pas morte, elle est alimentée autrement : quand un outil renvoie un + `InputRequiredResult` portant une `ElicitRequest`, `Client` transmet cette entrée à la même + `elicitation_callback` et relance l’appel pour vous. Ce flux est décrit dans **[Requêtes à plusieurs allers-retours](../handlers/multi-round-trip.md)** (multi-round-trip). + +## Une fonction de rappel est une capacité {#a-callback-is-a-capability} + +Vous n’avez jamais dit au serveur que votre client sait répondre aux requêtes d’élicitation. Le SDK l’a fait. + +Quand un client se connecte, il déclare ses `capabilities`, l’image miroir de celles du serveur. Vous n’écrivez pas cet objet. **Enregistrer une fonction de rappel vaut déclaration.** + +| vous passez | le client déclare | +| --- | --- | +| `elicitation_callback=` | `"elicitation": {"form": {}, "url": {}}` | +| `sampling_callback=` | `"sampling": {}` | +| `list_roots_callback=` | `"roots": {"listChanged": true}` | +| aucune d’elles | `{}` | + +Les sous-capacités d’échantillonnage (sampling) sont le seul raffinement : passez `sampling_capabilities=SamplingCapability(tools=SamplingToolsCapability())` en plus de `sampling_callback` lorsque votre échantillonneur gère les paramètres `tools` / `tool_choice`. Les serveurs doivent voir `sampling.tools` déclaré avant de pouvoir les envoyer. + +`logging_callback` et `message_handler` ne figurent pas dans le tableau. Ils traitent des notifications, et les notifications n’exigent aucune capacité. + +Le serveur relit la déclaration avec `ctx.session.check_client_capability(...)`. Ajoutez un outil qui le fait : + +```python title="server.py" hl_lines="23-31" +--8<-- "docs_src/client_callbacks/tutorial003.py" +``` + +Connectez-vous avec seulement `elicitation_callback` et appelez-le : + +```python +result.structured_content # {'result': ['elicitation']} +``` + +Passez les trois fonctions de rappel et vous obtenez `['elicitation', 'sampling', 'roots']`. N’en passez aucune et vous obtenez `[]`. + +!!! check + Faites maintenant ce qu’il ne faut pas : connectez-vous **sans** `elicitation_callback` et appelez `issue_card` quand même. + + La requête `elicitation/create` du serveur atteint toujours votre client, et le SDK y répond à votre + place, par une erreur, puisque vous n’avez jamais dit pouvoir la traiter. Cette erreur coule l’appel entier. + `call_tool` ne renvoie pas un résultat `is_error` ; il lève une exception : + + ```text + MCPError: Elicitation not supported + ``` + + C’est une erreur de protocole (`-32600`, *requête invalide*), pas une erreur d’outil : le modèle n’a rien + à lire ni à retenter. C’est pourquoi `client_features` vaut la peine : un serveur bien élevé + vérifie avant de demander. + +## La paire obsolète {#the-deprecated-pair} + +`sampling_callback` répond à `sampling/createMessage` : le serveur demande à *votre* modèle de compléter quelque chose. `list_roots_callback` répond à `roots/list` : le serveur demande dans quels répertoires il peut travailler. + +Les deux fonctionnent. Les deux suivent la règle ci-dessus. Et les deux servent des RPC que la **spécification 2026-07-28 supprime** : un serveur moderne ne rappelle pas votre client en pleine requête, il vous rend la requête dans le résultat de l’outil (**[Requêtes à plusieurs allers-retours](../handlers/multi-round-trip.md)**). Les fonctions de rappel elles-mêmes ne sont pas mortes. Quand un `InputRequiredResult` porte une `CreateMessageRequest` ou une `ListRootsRequest`, la boucle automatique de `Client` la transmet à la même `sampling_callback` ou `list_roots_callback` que vous avez enregistrée ici. La liste complète est dans **[Fonctionnalités obsolètes](../deprecated.md)**. + +Vous avez encore besoin de ces fonctions de rappel pour parler aux serveurs qui n’ont pas migré. Les signatures : + +```python title="client.py" +--8<-- "docs_src/client_callbacks/tutorial004.py" +``` + +* Une fonction de rappel d’échantillonnage reçoit le `CreateMessageRequestParams` complet (`messages`, `model_preferences`, `max_tokens`) et renvoie un `CreateMessageResult`. C’est *vous* qui exécutez le modèle, comme bon vous semble ; le SDK ne fait que transporter la requête. +* Une fonction de rappel de racines (roots) ne prend aucun paramètre et renvoie un `ListRootsResult`. +* L’une comme l’autre peut renvoyer `ErrorData(...)` à la place, pour refuser. + +Passez-les à `Client(...)` exactement comme `elicitation_callback`. + +## Les fonctions de rappel de notification {#the-notification-callbacks} + +Deux de plus. Aucune ne déclare quoi que ce soit. + +`logging_callback` reçoit les `notifications/message` qu’un serveur envoie, sous forme de `LoggingMessageNotificationParams` (`level`, `logger`, `data`). La journalisation par le protocole est elle-même rendue obsolète par la spécification 2026-07-28 (**[Journalisation](../handlers/logging.md)** explique quoi faire à la place), donc cette fonction de rappel existe pour les serveurs qui l’émettent encore. Sur une connexion de génération 2026, la fonction de rappel seule ne vous apporte rien, car les serveurs 2026 n’envoient des messages de journal qu’aux requêtes qui en font la demande : passez `log_level="info"` (ou un autre niveau) à `Client(...)` pour apposer cette demande sur chaque requête et recevoir ce niveau et les niveaux supérieurs. Les serveurs antérieurs à 2026 l’ignorent et conservent leur comportement `logging/setLevel`. + +`message_handler` est le fourre-tout : chaque notification serveur que la session remonte lui parvient (en plus de sa fonction de rappel spécifique), et sur un transport adossé à un flux, chaque `Exception` de niveau transport aussi. Deux n’y parviennent jamais : `notifications/cancelled` est appliquée par le SDK plutôt que remontée, et l’accusé de réception d’abonnement d’un flux `listen()` actif est consommé par ce flux. Annotez le paramètre avec `IncomingMessage` (`ServerNotification | Exception`, exporté depuis `mcp.client`). Le seul motif à connaître est `if isinstance(message, Exception): raise message`, pour qu’une connexion rompue échoue bruyamment au lieu de disparaître en silence. + +## Récapitulatif {#recap} + +* Un serveur peut envoyer des requêtes au client. Vous y répondez avec des fonctions de rappel passées à `Client(...)`. +* La fonction de rappel d’élicitation est celle d’actualité : `async (context, params) -> ElicitResult`, une seule fonction pour les modes formulaire et URL. +* **Enregistrer une fonction de rappel, c’est déclarer la capacité.** Sans elle, le SDK refuse la requête du serveur à votre place et l’appel entier échoue avec `MCPError`. +* Un serveur le sait avant de demander grâce à `ctx.session.check_client_capability(...)`. +* `sampling_callback` et `list_roots_callback` fonctionnent de la même manière mais servent des fonctionnalités obsolètes ; les serveurs modernes utilisent à la place les requêtes à plusieurs allers-retours. +* `logging_callback` et `message_handler` reçoivent des notifications. Ils ne déclarent rien. + +Le premier argument de `Client(...)` est un objet transport. **[Transports client](transports.md)** couvre tous les types. diff --git a/i18n/fr/pages/client/identity-assertion.md b/i18n/fr/pages/client/identity-assertion.md new file mode 100644 index 0000000000..805976269e --- /dev/null +++ b/i18n/fr/pages/client/identity-assertion.md @@ -0,0 +1,155 @@ +--- +translation: + sections: [a91322c46111d16d, 8e6fd6d6f59bb568, e7828fd2729b2c9d, a03ec26bfc678b65, 1034c653c0bcf1b0] + tool: 1 +--- +# Assertion d’identité {#identity-assertion} + +Un fournisseur OAuth ordinaire (**[Clients OAuth](oauth-clients.md)**) commence par poser une question au serveur MCP : *à quel serveur d’autorisation faites-vous confiance ?* Il suit la réponse où qu’elle mène, puis soit une personne se connecte, soit un secret pré-partagé en tient lieu. + +Une entreprise ne veut voir ni l’un ni l’autre décidé serveur par serveur. Elle exploite déjà un fournisseur d’identité (Okta, Microsoft Entra ID, le vôtre) ; l’utilisateur s’y est déjà connecté ce matin ; et c’est l’unique endroit où l’équipe sécurité veut décider qui peut accéder à quoi. La [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990), l’extension **Enterprise-Managed Authorization**, y déplace la décision. L’IdP signe un JWT de courte durée, un **Identity Assertion JWT Authorization Grant**, l’**ID-JAG** : une déclaration selon laquelle *cet utilisateur*, via *ce client*, peut accéder à *ce serveur MCP*. Le client l’échange contre un jeton d’accès ordinaire. Pas de navigateur, pas d’écran de consentement, pas d’enregistrement dynamique. + +Cette page couvre les deux extrémités de cet échange. Le serveur MCP lui-même ne change jamais : il reste le serveur de ressources de **[Autorisation](../run/authorization.md)**, qui vérifie le jeton qui se présente, quel qu’il soit. + +## Deux requêtes de jeton {#two-token-requests} + +Deux autorités différentes sont en jeu, et bien les distinguer, c’est l’essentiel pour comprendre cette page. L’**IdP d’entreprise** est le fournisseur d’identité de votre organisation : il sait qui est l’employé, c’est là que réside la politique d’accès, et il émet l’ID-JAG. Le SDK ne lui parle jamais. Le **serveur d’autorisation MCP** est le même acteur que dans **[Autorisation](../run/authorization.md)** : l’émetteur nommé dans les métadonnées du serveur MCP, celui qui émet les jetons que ce serveur MCP accepte. Dans un flux OAuth ordinaire, ces deux rôles tiennent généralement dans une seule boîte. Ici ils sont deux, et tout le grant consiste en ce que le second accepte de faire confiance au premier. + +Le client adresse une requête de jeton à chacun. + +1. **Vers l’IdP d’entreprise.** Le client échange la connexion de l’utilisateur (son jeton d’identité OpenID Connect) contre l’ID-JAG. C’est un échange de jetons [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693), c’est entièrement l’API de votre IdP, et **le SDK ne l’effectue pas**. C’est vous qui le faites, dans une seule fonction de rappel (callback) asynchrone. C’est aussi là que se prend la décision de politique : un IdP qui dit non n’émet jamais l’ID-JAG, et il n’y a rien à présenter. +2. **Vers le serveur d’autorisation MCP.** Le client présente l’ID-JAG sous le grant `jwt-bearer` de la [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) (`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`, l’ID-JAG comme `assertion`) et reçoit le jeton d’accès. **C’est la requête que le SDK effectue**, et l’accepter est la seule chose que cette page ajoute à un serveur d’autorisation. + +Tout ce qui suit concerne la seconde requête : le client qui l’envoie et le serveur d’autorisation qui y répond. + +## Le client {#the-client} + +**`IdentityAssertionOAuthProvider`** se trouve dans `mcp.client.auth.extensions.identity_assertion`. Comme tous les fournisseurs de **[Clients OAuth](oauth-clients.md)**, c’est un `httpx2.Auth` : construisez-en un, placez-le sur `auth=`, passez le `httpx2.AsyncClient` au transport. + +```python title="client.py" hl_lines="49-50 53-61" +--8<-- "docs_src/identity_assertion/tutorial001.py" +``` + +Lisez-le en partant du bas. + +* `main()` est le `main()` standard d’un client OAuth (**[Clients OAuth](oauth-clients.md)**), inchangé ligne pour ligne. C’est tout l’intérêt : une fois le fournisseur en place, rien en aval ne sait quel grant a produit le jeton. +* Le fournisseur prend ce que les autres fournisseurs ne peuvent pas découvrir : un `client_id` et un `client_secret` que quelqu’un a **pré-enregistrés** auprès du serveur d’autorisation, la valeur `issuer` de ce serveur d’autorisation, et `assertion_provider`, une fonction de rappel asynchrone qui renvoie un ID-JAG tout neuf à la demande. +* `storage` est le même protocole `TokenStorage`. Seules les deux méthodes de jeton sont appelées ; il n’y a pas d’enregistrement dynamique ici, donc pas de `client_info` à mémoriser. + +### Le fournisseur d’assertion {#the-assertion-provider} + +`fetch_id_jag(audience, resource)` est le seul code que vous écrivez. Il est attendu (await) une fois par échange de jeton, jamais à la construction, et seulement *après* que les métadonnées du serveur d’autorisation ont été récupérées et validées, si bien qu’un émetteur mal configuré ne laisse jamais fuiter une assertion. Ses deux arguments sont deux des claims avec lesquels l’ID-JAG doit être émis : `audience` est l’émetteur du serveur d’autorisation (le `aud` de l’ID-JAG) et `resource` est l’identifiant canonique du serveur MCP (le `resource` de l’ID-JAG). Le troisième, vous le détenez déjà : le claim `client_id` de l’ID-JAG doit nommer le `client_id` que vous avez donné au fournisseur, faute de quoi le serveur d’autorisation refuse l’échange. + +`idp_issue_id_jag`, juste au-dessus, n’est **pas votre code**. Il tient lieu de fournisseur d’identité et signe l’assertion dans le processus même, pour que le fichier soit complet et que vous puissiez lire chaque claim que porte un ID-JAG. Un vrai `fetch_id_jag` effectue à la place la première requête de jeton de la section précédente : un échange de jetons [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) auprès de votre IdP, défini par le draft Identity Assertion JWT Authorization Grant dont la [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) définit un profil. Le jeton d’identité de l’utilisateur connecté y entre comme `subject_token`, le `requested_token_type` est l’URN propre à l’ID-JAG (`urn:ietf:params:oauth:token-type:id-jag`), `audience` et `resource` sont transmis tels quels, et la réponse porte l’ID-JAG. Cet échange, sous ces noms-là, est ce qu’il faut chercher dans la documentation de votre IdP. + +!!! tip + Un nouvel ID-JAG est demandé à chaque échange, et c’est voulu : c’est un grant à usage unique, + valable quelques minutes, et le serveur d’autorisation de cette page refuse d’accepter deux fois + le même. Ne le mettez pas en cache. C’est le jeton d’accès qu’il vous procure qui est réutilisé. + +### L’émetteur relève de la configuration {#the-issuer-is-configuration} + +Voici l’inversion. `OAuthClientProvider` demande au serveur de ressources quel serveur d’autorisation utiliser et suit la réponse où qu’elle mène. Ce fournisseur-ci s’y refuse : `issuer` est obligatoire, les métadonnées [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) sont récupérées depuis le chemin well-known de cet émetteur même, le point de terminaison de jeton doit se trouver sur l’origine de cet émetteur, et rien n’est jamais demandé au serveur de ressources. + +L’extension ne l’exige pas ; c’est un choix délibérément plus strict. Ce client transporte deux choses qui valent d’être volées, un secret pré-enregistré et une assertion liée à une audience, et un client qui laisserait un serveur MCP compromis l’aiguiller vers le serveur d’autorisation d’un attaquant y posterait les deux. Épingler l’émetteur à la construction supprime purement et simplement cette conversation. + +!!! warning + La valeur `issuer` configurée est comparée au champ `issuer` du document de métadonnées par la + comparaison de chaînes simple de la RFC 8414 §3.3 : caractère par caractère, barre oblique finale + comprise, sans normalisation. Ne la devinez pas. Récupérez `/.well-known/oauth-authorization-server` + auprès de votre serveur d’autorisation et copiez la valeur `issuer` qu’il renvoie. Pour le serveur + d’autorisation de cette page, c’est `https://auth.example.com/`, avec la barre oblique, parce que + son émetteur a été construit à partir d’un objet URL pydantic. Une discordance arrête le flux + sur `OAuthFlowError: Authorization server metadata issuer + mismatch` avant qu’un seul identifiant ou une seule assertion ne soit envoyé. + +### Un client confidentiel {#a-confidential-client} + +`client_secret` est obligatoire ; sans lui, le constructeur lève `ValueError`. Le profil IETF sous-jacent à la [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) réserve ce grant aux clients confidentiels, la SEP-990 exige que le client s’authentifie, et ce SDK fait respecter les deux en imposant un secret partagé. `token_endpoint_auth_method` choisit par où il transite : `client_secret_post` (la valeur par défaut, dans le corps du formulaire) ou `client_secret_basic` (un en-tête HTTP Basic). Le profil autorise aussi `private_key_jwt` ; ce fournisseur ne le prend pas en charge. + +!!! tip + Lisez `client_secret` depuis l’environnement ou un gestionnaire de secrets, jamais depuis le dépôt de code. + +### Ce que le fournisseur fait pour vous {#what-the-provider-does-for-you} + +La première requête part sans authentification, et le `401` du serveur démarre le flux. + +1. **Découverte.** Il récupère les métadonnées du serveur d’autorisation depuis le chemin well-known [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) de l’émetteur configuré, vérifie que la valeur `issuer` du document correspond, et vérifie que le point de terminaison de jeton se trouve sur l’origine de l’émetteur. +2. **L’assertion.** Il attend (await) votre `assertion_provider`. +3. **Échange.** Il envoie en POST le grant `jwt-bearer` au point de terminaison de jeton, stocke le `OAuthToken`, et rejoue votre requête d’origine avec `Authorization: Bearer ...`. + +Un `403` dont le `WWW-Authenticate` nomme `insufficient_scope` relance les étapes 2 et 3 avec l’union de votre `scope` et de celui du défi. (`scope` n’est jamais qu’une demande ; le serveur d’autorisation de cette page accorde ce que dit l’ID-JAG et rien d’autre.) Il n’y a de jeton d’actualisation nulle part ici : quand le jeton d’accès expire, le `401` suivant fait émettre un nouvel ID-JAG et relance l’échange, et c’est *là* le levier que détient l’IdP. Les échecs sont les deux mêmes exceptions que dans le reste de **[Clients OAuth](oauth-clients.md)** : `OAuthFlowError` pour la découverte et la validation, sa sous-classe `OAuthTokenError` quand le point de terminaison de jeton dit non. + +## Le serveur d’autorisation {#the-authorization-server} + +La plupart du temps, vous vous arrêtez ici. Le serveur d’autorisation MCP est le produit de quelqu’un d’autre, accepter les ID-JAG est une option de sa configuration à activer, et la moitié de la [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) qui revient au SDK est le client ci-dessus. + +Le SDK peut aussi *être* le serveur d’autorisation : `create_auth_routes` renvoie les routes du serveur d’autorisation sous forme d’une liste que n’importe quelle application Starlette peut monter, et c’est ainsi que `examples/servers/simple-auth/` dans le dépôt en fait tourner un. La SEP-990 ajoute un drapeau et une méthode à cette surface : + +```python title="auth_server.py" hl_lines="48-50 105-107" +--8<-- "docs_src/identity_assertion/tutorial002.py" +``` + +* `identity_assertion_enabled=True` conditionne tout. Désactivé, ce qui est la valeur par défaut, `/token` répond à ce grant par `unsupported_grant_type` même si vous avez implémenté le hook, et les métadonnées n’en font pas mention. Activé, les métadonnées gagnent le type de grant `jwt-bearer` et listent `urn:ietf:params:oauth:grant-profile:id-jag` dans `authorization_grant_profiles_supported`, le champ par lequel l’extension annonce sa prise en charge. (Le client de ce SDK ne le lit jamais : il est provisionné pour un seul émetteur et demande, tout simplement.) +* **`exchange_identity_assertion`** est le hook. Avant qu’il ne s’exécute, le SDK a authentifié le client, refusé les clients publics, et refusé les clients dont l’enregistrement ne liste pas le grant. Vous recevez un `IdentityAssertionParams` (la valeur `assertion` brute, les `scopes` et `resource` demandés) et renvoyez un simple `OAuthToken`. +* L’enregistrement dynamique des clients refuse ce grant sans condition, si bien que `get_client` sert ici un client provisionné à la main. Un client ID-JAG ne peut pas se faire exister en s’enregistrant lui-même. +* La moitié de la classe est faite de refus. `OAuthAuthorizationServerProvider` est le serveur d’autorisation *tout entier*, il réclame donc aussi le flux authorization code ; un serveur qui connecte aussi des utilisateurs implémente ces méthodes pour de bon, et celui-ci n’a qu’une seule porte. + +!!! warning + Le SDK ne décode jamais l’assertion : seul votre déploiement sait à quel IdP il fait confiance et + quelles clés cet IdP publie, donc tout ce qui se trouve dans `exchange_identity_assertion` est + déterminant. Vérifiez la signature par rapport aux clés publiées de l’IdP (son JWKS ; le secret + partagé ici est celui de la démo), ainsi que `iss` et `exp`, selon la [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) §3. Exigez + que le `typ` de l’en-tête JWT soit `oauth-id-jag+jwt`, le garde-fou du profil contre le rejeu + d’un autre JWT comme grant. Exigez que `aud` soit votre propre émetteur. Exigez que le claim + `client_id` de l’ID-JAG soit égal au client que le gestionnaire (handler) a authentifié, et que + son claim `resource` nomme une ressource que vous servez réellement. Suivez `jti` jusqu’à la + valeur `exp` de l’assertion pour qu’elle ne soit acceptée qu’une fois. Et tirez les scopes + accordés et, surtout, le `resource` du jeton émis de l’ID-JAG validé, jamais de la requête : + `params.resource` est ce que le client a tapé, quoi que ce soit. Les règles de traitement + complètes sont dans la + [spécification Enterprise-Managed Authorization](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization). + +Rejetez une mauvaise assertion avec `TokenError("invalid_grant", ...)`. L’autre code d’erreur de ce flux est `invalid_target` : un ID-JAG qui nomme une ressource que vous ne servez pas est refusé avec lui, et c’est ce qui empêche ce serveur d’émettre des jetons pour celle de quelqu’un d’autre. Et les scopes accordés viennent du claim `scope` de l’ID-JAG (une assertion qui n’en a pas est refusée elle aussi) ; le vôtre pourrait plutôt faire correspondre les groupes de l’utilisateur. + +Et remarquez ce que le `OAuthToken` renvoyé ne porte pas : un jeton d’actualisation. L’IdP décide combien de temps cet utilisateur garde l’accès en décidant d’émettre ou non le prochain ID-JAG. Un jeton d’actualisation émis ici reprendrait en douce cette décision à l’IdP. + +!!! info + Un serveur qui embarque encore son serveur d’autorisation avec `auth_server_provider=` atteint le + même code via `AuthSettings(identity_assertion_enabled=True)`. **[Autorisation](../run/authorization.md)** explique pourquoi + les nouveaux serveurs ne devraient pas commencer par là. + +!!! check + Reliez les deux fichiers de cette page et tout le grant tient en un seul `POST /token` : + + ```text + grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer + assertion=eyJhbGciOiJIUzI1NiIsInR5cCI6Im9hdXRoLWlkLWphZytqd3QifQ... + client_id=finance-agent + resource=http://localhost:8001/mcp + scope=notes:read + client_secret=finance-agent-secret + + HTTP/1.1 200 OK + {"access_token": "mcp_...", "token_type": "Bearer", "expires_in": 300, "scope": "notes:read"} + ``` + + Pas de `/authorize`, pas de `/register`, pas de récupération des métadonnées de ressource + protégée. Les seules requêtes sur la liaison sont celle qui a provoqué le `401`, la récupération + well-known, cet échange, puis le trafic MCP ordinaire avec le jeton porteur attaché. Et le `sub` + que votre validateur a lu dans l’ID-JAG est exactement ce que `get_access_token().subject` + rapporte à l’intérieur d’un outil. + +### Essayer {#try-it} + +`examples/stories/identity_assertion/` dans le dépôt du SDK, c’est cette page exécutée pour de bon : le même validateur `exchange_identity_assertion`, un serveur MCP protégé par ses jetons, un IdP de substitution et le client, dans un seul programme qui se vérifie lui-même. `uv run python -m stories.identity_assertion.client --http` exécute tout l’échange et vérifie par assertion que l’utilisateur nommé par l’IdP est bien celui que voit l’outil. + +## Récapitulatif {#recap} + +* La [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) laisse le fournisseur d’identité de l’entreprise, et non l’utilisateur final, décider quels serveurs MCP un client peut atteindre. L’IdP signe cette décision dans un **ID-JAG**. +* Obtenir l’ID-JAG est un échange de jetons [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) auprès de *votre IdP*, et le SDK ne l’effectue pas. Le présenter au serveur d’autorisation MCP relève du grant `jwt-bearer` de la [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523), et le SDK en assure les deux côtés. +* `IdentityAssertionOAuthProvider` est un `httpx2.Auth` de plus : un client confidentiel pré-enregistré, un `issuer` épinglé, et une fonction de rappel `assertion_provider(audience, resource)`. Pas de navigateur, pas d’enregistrement, pas de jeton d’actualisation. +* Le serveur d’autorisation n’est jamais découvert à partir du serveur de ressources. Configurez `issuer` avec exactement la chaîne que sert son document de métadonnées ; la comparaison se fait caractère par caractère. +* Côté serveur, `identity_assertion_enabled=True` plus `exchange_identity_assertion`. Le SDK authentifie le client et conditionne le grant ; valider l’ID-JAG vous revient entièrement, et le jeton émis est lié au `resource` de l’ID-JAG, pas à celui de la requête. + +Le seul acteur auquel cette page n’a jamais touché est le serveur MCP. Ce qu’il fait du jeton que vous venez d’émettre, il le faisait déjà dans **[Autorisation](../run/authorization.md)**. diff --git a/i18n/fr/pages/client/index.md b/i18n/fr/pages/client/index.md new file mode 100644 index 0000000000..ea89751ea7 --- /dev/null +++ b/i18n/fr/pages/client/index.md @@ -0,0 +1,217 @@ +--- +translation: + sections: [ebef1e7a0df854f4, a4c687d3d627d516, 8e79141fc2985342, b345dd05b9c3c7ab, 80ce41579825a6fa, 5f0fa90494de8f65, 83d10514eaa62fa5, 9190555aa39a5d28, 84a4c9d8bf14dddb, 927d71cf40b58c30] + tool: 1 +--- +# Le client {#the-client} + +Un **`Client`** est le moyen par lequel un programme Python dialogue avec un serveur MCP. + +C’est un seul objet avec un seul cycle de vie : vous le construisez, vous entrez dans `async with`, vous appelez des méthodes. Chaque verbe du protocole (lister les outils, en appeler un, lire une ressource, rendre un prompt) est une méthode `async` de cet objet qui renvoie un résultat typé. + +## Votre premier client {#your-first-client} + +```python title="client.py" hl_lines="14-18" +--8<-- "docs_src/client/tutorial001.py" +``` + +Le serveur en haut n’est là que pour vous donner quelque chose à quoi vous connecter. Le client, ce sont les cinq lignes surlignées. + +* `Client(mcp)` reçoit **l’objet serveur lui-même**. C’est le transport en mémoire : pas de sous-processus, pas de port, pas de HTTP. C’est ainsi que se connectent tous les exemples de cette page, et tous les tests que vous écrivez. +* `async with` est le **cycle de vie**. Y entrer connecte et négocie ; en sortir déconnecte. Il n’y a pas de paire `connect()` / `close()`, et un `Client` ne peut pas être réutilisé une fois le bloc terminé. +* À l’intérieur du bloc, les informations de connexion sont déjà là, sous forme de simples propriétés. + +### Ce que vous pouvez passer à `Client` {#what-you-can-pass-to-client} + +`Client` prend un seul argument positionnel et déduit le transport de son type : + +* Une instance de `MCPServer` (ou du `Server` bas niveau) : connexion **dans le processus**. +* Une chaîne d’URL (`Client("http://localhost:8000/mcp")`) : Streamable HTTP, la voie de production. +* Un **transport** : tout ce sur quoi vous pouvez faire `async with ... as (read, write)`, comme `stdio_client(...)` qui enveloppe un sous-processus. + +Tout le reste de cette page est identique pour les trois. Les en-têtes, les sous-processus, les délais d’expiration et le protocole `Transport` ont leur propre page : **[Transports côté client](transports.md)**. + +### Ce que porte un client connecté {#whats-on-a-connected-client} + +Quatre propriétés en lecture seule, renseignées dès que vous entrez dans le bloc : + +* `client.server_info` : l’identité du serveur, ou `None` pour un serveur de génération 2026 qui n’en déclare pas (les serveurs python-sdk le font par défaut). Ici, `server_info.name` vaut `"Bookshop"` et `server_info.version` est ce que le serveur déclare. +* `client.server_capabilities` : ce que le serveur sait faire (`tools`, `resources`, `prompts`, `completions`, ...). Une capacité que le serveur n’a pas vaut `None`. +* `client.protocol_version` : la version du protocole sur laquelle les deux côtés se sont mis d’accord. Ici, c’est `"2026-07-28"`. +* `client.instructions` : la chaîne `instructions=` du serveur, ou `None` s’il n’en a pas défini. + +Vous n’avez jamais choisi de version du protocole. Par défaut, le `Client` sonde le serveur et se rabat sur la poignée de main (handshake) classique avec les plus anciens, si bien qu’un seul client fonctionne avec un serveur de n’importe quelle génération. Lorsque vous avez besoin de contrôler cela, tous les détails sont dans **[Versions du protocole](../protocol-versions.md)**. + +!!! tip + `client.session` est la `ClientSession` sous-jacente, l’échappatoire bas niveau. + Vous n’en aurez besoin pour rien sur cette page. + +## Lister les outils {#listing-tools} + +```python title="client.py" hl_lines="15-20" +--8<-- "docs_src/client/tutorial002.py" +``` + +`list_tools()` renvoie un `ListToolsResult` ; les outils sont dans `.tools`. Chacun est la définition complète qu’un hôte transmettrait à un modèle : + +```python +tool.name # 'search_books' +tool.title # 'Search the catalog' +tool.description # 'Search the catalog by title or author.' +``` + +et `tool.input_schema` est le JSON Schema que le serveur a dérivé des annotations de type de la fonction : + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"default": 10, "title": "Limit", "type": "integer"} + }, + "required": ["query"], + "title": "search_booksArguments" +} +``` + +Ce schéma est tout ce dont une interface a besoin pour afficher un formulaire d’arguments, et tout ce dont un modèle a besoin pour produire des arguments valides. + +!!! tip + `title` est facultatif, donc une interface qui présente des outils à un humain doit choisir : le `title` s’il existe, + le `name` sinon. `from mcp.shared.metadata_utils import get_display_name` fait exactement cela, + pour les outils, les ressources, les modèles de ressource et les prompts. + +## Appeler un outil {#calling-a-tool} + +`call_tool(name, arguments)` exécute l’outil et vous renvoie un `CallToolResult`. + +```python title="client.py" hl_lines="26-33" +--8<-- "docs_src/client/tutorial003.py" +``` + +Le `lookup_book` du serveur renvoie un `Book` Pydantic. Voici ce que voit le client : + +```python +result.content # [TextContent(type='text', text='{\n "title": "Dune",\n "author": "Frank Herbert",\n "year": 1965\n}')] +result.structured_content # {'title': 'Dune', 'author': 'Frank Herbert', 'year': 1965} +result.is_error # False +``` + +Une valeur de retour, trois choses à lire. Chacune a un consommateur différent. + +### `content` : ce que lit le modèle {#content-what-the-model-reads} + +`content` est une `list` de **blocs de contenu**, et un bloc de contenu est une union : `TextContent`, `ImageContent`, `AudioContent`, `ResourceLink` ou `EmbeddedResource`. Un outil peut en renvoyer plusieurs, de natures différentes. + +C’est pourquoi `main` restreint le type avec `isinstance(block, TextContent)` avant de toucher à `block.text`. Remarquez qu’il n’y a pas de `.text` en dehors du `isinstance` : le vérificateur de types ne le permettrait pas, car `ImageContent` a `.data`, pas `.text`. L’union est honnête sur ce qu’un outil a le droit de vous envoyer ; votre code devrait l’être aussi. + +### `structured_content` : ce que lit votre application {#structured_content-what-your-application-reads} + +`structured_content` est la valeur de retour de l’outil au format JSON, conforme au `output_schema` déclaré par l’outil. Pas d’analyse de chaînes, pas de devinettes. + +Quand les deux sont présents, ils disent volontairement deux fois la même chose : `content` est pour un modèle, `structured_content` pour du code. D’où vient la moitié structurée, et comment la contrôler, c’est le sujet de la page **[Sortie structurée](../servers/structured-output.md)**. + +### `is_error` : si l’outil a échoué {#is_error-whether-the-tool-failed} + +Un outil qui lève une exception ne lève **rien** dans votre client. Il revient sous la forme d’un résultat ordinaire avec `is_error=True`. + +!!! check + Demandez `"Solaris"` à `lookup_book` (un titre qui n’est pas au catalogue) et la fonction lève + `ValueError`. L’appel revient pourtant normalement : + + ```python + result.is_error # True + result.content # [TextContent(type='text', text="Error executing tool lookup_book: No book titled 'Solaris' in the catalog.")] + result.structured_content # None + ``` + + Le message de l’exception a atterri dans `content`, où le **modèle** peut le lire et réessayer. C’est + délibéré : une erreur d’outil fait partie de la conversation, ce n’est pas un plantage. Regardez toujours `is_error` + avant de faire confiance à `structured_content`. + +!!! warning + `is_error=True` couvre plus que vos propres `raise`. Demandez un outil que le serveur n’a même pas + (`call_tool("does_not_exist", {})`) et rien n’est levé. Vous obtenez la même forme en retour : + `is_error=True` avec `Unknown tool: does_not_exist` dans `content`. Une méthode de `Client` ne lève + `MCPError` que lorsque le serveur répond par une **erreur** JSON-RPC au lieu d’un résultat, et + **[Gérer les erreurs](../servers/handling-errors.md)** explique quand un serveur produit l’une ou l’autre. + +## Ressources {#resources} + +Les verbes des ressources vont par paires : deux façons de lister, une façon de lire. + +```python title="client.py" hl_lines="22-31" +--8<-- "docs_src/client/tutorial004.py" +``` + +* `list_resources()` renvoie les ressources **concrètes**, celles qui ont un URI fixe. Ici : `['catalog://genres']`. +* `list_resource_templates()` renvoie les ressources **paramétrées**. Ici : `['catalog://genres/{genre}']`. Ce sont deux listes distinctes parce qu’un modèle n’est pas lisible tant que vous ne l’avez pas rempli. +* `read_resource(uri)` prend un URI sous forme de simple `str` et fonctionne sur les deux : passez `"catalog://genres/poetry"` et le serveur le fait correspondre au modèle. + +`read_resource` renvoie `contents`, une liste de `TextResourceContents` ou de `BlobResourceContents`. Même idée que pour le contenu des outils : restreignez le type avec `isinstance`, puis lisez `.text` (ou `.blob`). + +Un client peut aussi être prévenu quand une ressource change. Sur les connexions de génération 2025, c’est `subscribe_resource(uri)` / `unsubscribe_resource(uri)` — une paire de méthodes que `MCPServer` n’implémente pas, si bien que sur la liaison en version 2026-07-28 (où ces verbes n’existent plus) la requête reçoit en réponse `-32601`, *Method not found*. Le remplaçant en version 2026 est un flux `subscriptions/listen`, que `MCPServer` sert *bel et bien* — `server_capabilities.resources.subscribe` y vaut `True` — et sa consommation avec `client.listen(...)` fait l’objet de la page **[Abonnements](subscriptions.md)** de cette section. + +## Prompts {#prompts} + +```python title="client.py" hl_lines="15-20" +--8<-- "docs_src/client/tutorial005.py" +``` + +`list_prompts()` vous dit ce que le serveur propose et ce dont chaque prompt a besoin : + +```python +prompt.name # 'recommend' +prompt.title # 'Recommend a book' +prompt.arguments # [PromptArgument(name='genre', required=True)] +``` + +`get_prompt(name, arguments)` le rend. Le dictionnaire d’arguments est `str -> str` : les arguments de prompt sont toujours des chaînes. Le résultat est `messages`, une liste de `PromptMessage`, chacun avec un `role` et un bloc `content` : + +```python +message.role # 'user' +message.content # TextContent(type='text', text='Recommend one poetry book from the catalog and say why.') +``` + +Un hôte transmet ces messages tels quels au modèle. C’est toute la fonctionnalité. + +## Complétions {#completions} + +Un serveur doté d’un gestionnaire (handler) de complétion peut compléter automatiquement les arguments des prompts et des modèles de ressource au fil de la saisie de l’utilisateur. + +```python title="client.py" hl_lines="27-31" +--8<-- "docs_src/client/tutorial006.py" +``` + +* `ref` indique *quel* prompt ou modèle vous remplissez : un `PromptReference` ou un `ResourceTemplateReference`. +* `argument` vaut `{"name": ..., "value": ...}` : l’argument et ce que l’utilisateur a saisi jusqu’ici. + +La réponse se trouve dans `result.completion.values`. Tapez `"p"` et le serveur revient avec `['poetry']`. Le côté serveur, et la façon dont un gestionnaire utilise les *autres* arguments déjà remplis pour affiner ses suggestions, c’est la page **[Complétions](../servers/completions.md)**. + +## Pagination {#pagination} + +Chaque méthode `list_*` accepte un argument nommé `cursor=` et chaque résultat porte un `next_cursor`. Quand `next_cursor` vaut `None`, vous avez tout. + +```python title="client.py" hl_lines="22-30" +--8<-- "docs_src/client/tutorial007.py" +``` + +Cette boucle est correcte face à n’importe quel serveur. `MCPServer` renvoie tout en une seule page, donc `next_cursor` vaut `None` et la boucle s’exécute une fois, ce qui explique que la plupart du code ne l’écrive jamais. Les serveurs qui paginent réellement, et les règles auxquelles obéissent les curseurs, sont dans **[Pagination](../advanced/pagination.md)**. + +## Dans les tests {#in-tests} + +`Client(mcp)`, sans processus ni port, est déjà un banc de test pour votre serveur. + +Il existe un drapeau du constructeur conçu pour cela : `Client(mcp, raise_exceptions=True)`. Il n’a d’effet que sur les connexions en mémoire, et **[Tests](../get-started/testing.md)** est la page qui l’explique et construit tout le modèle autour de lui. + +## Récapitulatif {#recap} + +* `Client(x)` se connecte en mémoire à un objet serveur, en Streamable HTTP à une chaîne d’URL, et à tout le reste via un transport. +* `async with` est tout le cycle de vie. À l’intérieur, `server_capabilities` et `protocol_version` sont déjà renseignés ; `server_info` et `instructions` le sont aussi lorsque le serveur les fournit. +* `list_tools()` vous donne le `name`, le `title`, la `description` et le `input_schema` de chaque outil. +* `call_tool()` renvoie `content` pour le modèle, `structured_content` pour votre code, et `is_error`. Un outil qui lève une exception est un résultat, pas une exception. +* `content` est une union de types de blocs ; restreignez le type avec `isinstance` avant de lire. +* `list_resources` / `list_resource_templates` / `read_resource`, `list_prompts` / `get_prompt` et `complete` complètent la liste des verbes. +* Chaque `list_*` accepte `cursor=` ; bouclez jusqu’à ce que `next_cursor` vaille `None`. + +Ce qu’un serveur peut demander au *client*, et la façon d’y répondre, c’est **[Fonctions de rappel du client](callbacks.md)**. diff --git a/i18n/fr/pages/client/oauth-clients.md b/i18n/fr/pages/client/oauth-clients.md new file mode 100644 index 0000000000..346d2f49da --- /dev/null +++ b/i18n/fr/pages/client/oauth-clients.md @@ -0,0 +1,152 @@ +--- +translation: + sections: [c6899d3892bd9fa0, 79372cff3cc48a88, 63878d29e87c3e73, 13175843d3588af4, e7e2b9fd516f77de, 758f06399b513c1f, a05d7278487d610b] + tool: 1 +--- +# Clients OAuth {#oauth-clients} + +Certains serveurs MCP sont protégés. Envoyez-leur une requête sans jeton et ils répondent `401 Unauthorized`. + +**`OAuthClientProvider`** est le moyen d’obtenir ce jeton. Ce n’est pas du tout un objet MCP. C’est un `httpx2.Auth`, le hook standard de httpx2 pour « faire quelque chose à chaque requête ». Vous l’attachez à un `httpx2.AsyncClient`, vous confiez ce client au transport Streamable HTTP, et vous n’y pensez plus. + +Cette page couvre le côté client. Pour que votre propre serveur exige un jeton, voyez **[Autorisation](../run/authorization.md)**. + +## Le fournisseur {#the-provider} + +```python title="client.py" hl_lines="44-54" +--8<-- "docs_src/oauth_clients/tutorial001.py" +``` + +Vous lui donnez quatre choses : + +* `server_url` : le point de terminaison MCP auquel vous vous connectez. Le fournisseur découvre tout le reste à partir de lui. +* `client_metadata` : ce que vous saisiriez dans le formulaire « enregistrer une application » d’un serveur d’autorisation. +* `storage` : là où les jetons vivent entre deux exécutions. +* `redirect_handler` et `callback_handler` : les deux moments où un humain intervient. + +Rien d’autre dans le fichier ne mentionne OAuth. `main()` ne voit jamais un jeton. + +### Métadonnées du client {#client-metadata} + +`OAuthClientMetadata` est le véritable document d’enregistrement de la [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591), sous forme de modèle Pydantic. + +Vous définissez trois champs. Les valeurs par défaut remplissent le reste : `grant_types` vaut déjà `["authorization_code", "refresh_token"]` et `response_types` vaut déjà `["code"]`, ce qui correspond exactement au flux qu’exécute ce fournisseur. + +!!! check + Comme c’est un modèle Pydantic, il valide **avant qu’un seul octet ne parte sur le réseau**. + Omettez `redirect_uris` et la construction échoue immédiatement avec une `ValidationError` qui + nomme le champ : + + ```text + redirect_uris + Field required [type=missing, input_value={'client_name': 'Bookshop Agent'}, input_type=dict] + ``` + + Aucun navigateur ouvert, aucun enregistrement à moitié terminé laissé derrière sur le serveur d’autorisation. + +### Stockage des jetons {#token-storage} + +**`TokenStorage`** est un `Protocol` avec quatre méthodes asynchrones. Vous n’héritez de rien ; écrivez les méthodes et n’importe quelle classe devient un magasin de jetons : + +* `get_tokens` / `set_tokens` conservent l’objet `OAuthToken` : jeton d’accès, jeton d’actualisation, expiration, portée. +* `get_client_info` / `set_client_info` conservent l’objet `OAuthClientInformationFull` que le serveur d’autorisation a émis lorsque le fournisseur vous a enregistré, y compris votre `client_id`. + +La version en mémoire ci-dessus fonctionne. Elle oublie aussi tout quand le processus se termine, si bien que l’exécution suivante refait toute la procédure. Persistez-la dans un fichier ou dans le trousseau de votre plateforme et l’exécution suivante est silencieuse. + +!!! tip + Stockez `client_info`, pas seulement les jetons. Le fournisseur s’enregistre dynamiquement la première fois qu’il + ne trouve aucun `client_info` stocké. Jetez-le et vous créez un nouvel enregistrement à chaque exécution. + +### Les deux gestionnaires {#the-two-handlers} + +Le flux du code d’autorisation a besoin d’un humain exactement une fois : quelqu’un doit se connecter et cliquer sur « autoriser ». + +* **`redirect_handler`** est attendu (await) avec l’URL d’autorisation entièrement construite. Le `client_id`, le `redirect_uri`, le `state` et le défi PKCE y figurent déjà. Votre seul travail est d’y amener un navigateur. Une application de bureau appelle `webbrowser.open` ; ce fichier l’affiche. +* **`callback_handler`** est attendu ensuite. Il patiente jusqu’à ce que l’utilisateur revienne sur votre `redirect_uri` et renvoie les paramètres de requête de cette redirection sous la forme d’un `AuthorizationCodeResult`. + +Un vrai client fait tourner un petit serveur HTTP local sur l’URI de redirection au lieu d’appeler `input()`. La forme est identique : recevoir la redirection, rendre `code`, `state` et `iss`. + +!!! warning + Transmettez `state` et `iss` exactement tels qu’ils sont arrivés. Le fournisseur compare `state` à celui + qu’il a généré et `iss` à l’émetteur qu’il a découvert, et refuse toute divergence. Ce sont les défenses + contre le CSRF et contre la confusion de serveurs (mix-up). + +### Dans le `Client` {#into-the-client} + +Regardez `main()`. Le fournisseur va sur le **client httpx2**, le client httpx2 va dans `streamable_http_client(url, http_client=...)`, et ce transport va dans `Client`. + +`streamable_http_client` n’a pas de mot-clé `auth=`. Tout ce qui relève du niveau HTTP (authentification, en-têtes, délais d’expiration, proxys) appartient au `httpx2.AsyncClient` que vous apportez. Cette superposition de couches est décrite dans **[Transports client](transports.md)**. + +## Ce que le fournisseur fait pour vous {#what-the-provider-does-for-you} + +La première fois que `Client` envoie une requête, le serveur répond `401`. Le fournisseur prend le relais : + +1. **Découverte.** Il lit l’en-tête `WWW-Authenticate`, récupère les Protected Resource Metadata du serveur depuis `/.well-known/oauth-protected-resource`, apprend quel serveur d’autorisation protège cette ressource, et récupère les métadonnées de *ce* serveur-là. +2. **Enregistrement.** Rien dans le stockage ? Il vous enregistre dynamiquement avec votre `OAuthClientMetadata` et stocke le résultat. +3. **Autorisation.** Il génère la paire PKCE et un `state`, construit l’URL d’autorisation, attend votre `redirect_handler`, puis attend votre `callback_handler` pour obtenir le code. +4. **Échange.** Il échange le code contre un `OAuthToken`, le stocke, et rejoue votre requête d’origine avec `Authorization: Bearer ...`. + +Après cela, il se fait discret. Les jetons sortent du stockage, un jeton d’accès expiré est actualisé avec le jeton d’actualisation, et ce n’est que lorsque rien de tout cela ne fonctionne qu’il relance le flux. + +Vous n’avez rien écrit de tout cela. Il reste deux arguments nommés (`client_metadata_url` et `validate_resource_url`), et ce fichier n’a besoin d’aucun des deux. `client_metadata_url` est celui qui mérite d’être connu ; il a sa propre section plus bas. + +### Essayer {#try-it} + +La plupart des exemples de cette documentation se vérifient avec un `Client(server)` en mémoire. Pas celui-ci : tout l’intérêt du flux est un `401` HTTP, et il n’y a pas de HTTP entre un client en mémoire et son serveur. + +Le dépôt fournit la version réelle. `examples/servers/simple-auth/` exécute un serveur d’autorisation autonome et un serveur MCP protégé ; `examples/clients/simple-auth-client/` est le client de cette page devenu une petite CLI. Son README donne les deux commandes : démarrez les serveurs, lancez le client contre eux, et vous voyez défiler les quatre étapes. + +## Client ID Metadata Documents {#client-id-metadata-documents} + +La révision 2026-07-28 de la spécification rend obsolète l’enregistrement dynamique des clients au profit des **Client ID Metadata Documents** (CIMD). Au lieu d’envoyer par POST un nouvel enregistrement à chaque serveur d’autorisation qu’il rencontre, votre client publie un unique document JSON le décrivant à une URL HTTPS stable, et cette URL *est* son `client_id`. Le serveur d’autorisation récupère le document ; le fournisseur n’y touche jamais. + +Le SDK le parle déjà : passez l’URL dans `client_metadata_url=` quand vous construisez le fournisseur. Lorsque les métadonnées du serveur d’autorisation annoncent `client_id_metadata_document_supported: true`, le fournisseur saute entièrement la requête `/register` : l’URL entre dans le flux en tant que `client_id`, et il n’y a pas de `client_secret`. Lorsque le serveur ne l’annonce pas (la plupart ne le font pas encore), ou que vous ne passez jamais d’URL, le fournisseur se rabat **silencieusement** sur l’enregistrement dynamique, et tout ce qui précède fonctionne exactement comme décrit. Un `client_info` stocké l’emporte toujours sur les deux. + +L’URL doit être en HTTPS avec un chemin autre que la racine ; tout le reste lève une `ValueError` à la construction, avant le moindre échange réseau. L’exemple fourni `examples/clients/simple-auth-client/` la reçoit via la variable d’environnement `MCP_CLIENT_METADATA_URL`. + +## De machine à machine {#machine-to-machine} + +Une tâche nocturne, une étape de CI, un autre service. Il n’y a pas de navigateur et personne pour cliquer sur « autoriser ». C’est le type d’octroi **client credentials** : vous détenez déjà un `client_id` et un `client_secret`, et le point de terminaison de jeton constitue tout le flux. + +`ClientCredentialsOAuthProvider` est le même `httpx2.Auth`, l’humain en moins : + +```python title="client.py" hl_lines="4 27-33" +--8<-- "docs_src/oauth_clients/tutorial002.py" +``` + +Ce qui a changé : + +* Aucun `OAuthClientMetadata`, aucun gestionnaire. Vous passez `client_id` et `client_secret` ; le fournisseur construit autour d’eux un enregistrement `client_credentials` minimal et saute entièrement l’enregistrement dynamique. +* `scope` est une chaîne séparée par des espaces, le format qu’OAuth utilise sur la liaison. +* Tout ce qui se trouve en aval est identique : le même `TokenStorage`, le même `httpx2.AsyncClient(auth=...)`, le même `streamable_http_client`. + +Par défaut, le secret voyage en authentification HTTP Basic sur la requête de jeton (`client_secret_basic`). Passez `token_endpoint_auth_method="client_secret_post"` pour le placer plutôt dans le corps du formulaire. Certains serveurs d’autorisation n’acceptent que l’une des deux méthodes. + +!!! tip + Lisez `client_secret` depuis l’environnement ou un gestionnaire de secrets, jamais depuis le contrôle de version. + +!!! info + Un fournisseur de plus se trouve dans `mcp.client.auth.extensions.client_credentials` : + **`PrivateKeyJWTOAuthProvider`**, pour les clients qui s’authentifient avec un JWT plutôt qu’avec un + secret partagé (`private_key_jwt`, la variante à paire de clés et identité de charge de travail). Il suit + le même schéma : construisez-en un, placez-le sur `auth=`. Le même module fournit + `SignedJWTParameters` et `static_assertion_provider`, deux utilitaires qui construisent son assertion. + +Il existe une autre situation sans humain : le client appartient à une entreprise dont le fournisseur d’identité, et non l’utilisateur, décide quels serveurs MCP il peut atteindre. C’est un type d’octroi différent, avec son propre modèle de confiance et sa propre page, **[Assertion d’identité](identity-assertion.md)**. + +## En cas d’échec {#when-it-fails} + +Quand le flux OAuth tourne mal, le fournisseur lève une `OAuthFlowError` depuis `mcp.client.auth`. Elle a deux sous-classes. `OAuthRegistrationError` signifie que l’enregistrement n’a pas produit un client utilisable : le serveur d’autorisation a refusé de vous enregistrer, ou il vous a bien enregistré mais avec des identifiants que ce flux ne peut pas utiliser (par exemple une méthode d’authentification qu’il n’implémente pas). `OAuthTokenError` signifie qu’un jeton n’a pas pu être obtenu : le point de terminaison de jeton a dit non, ou une fiche client stockée porte une méthode d’authentification que ce client ne peut pas appliquer, ce qui est signalé pendant la construction de la requête de jeton plutôt qu’envoyé. Un seul `except OAuthFlowError:` couvre la découverte, l’enregistrement, l’autorisation et l’échange. + +Tout n’est pas une erreur de flux. Le réseau peut toujours échouer ; ce sont des exceptions `httpx2` ordinaires et elles passent sans être modifiées. + +## Récapitulatif {#recap} + +* `OAuthClientProvider` est un `httpx2.Auth`. Placez-le sur un `httpx2.AsyncClient`, passez celui-ci à `streamable_http_client(url, http_client=...)`, et `Client` ne sait jamais qu’OAuth a eu lieu. +* Vous fournissez quatre choses : l’URL du serveur, un `OAuthClientMetadata`, un `TokenStorage` et la paire de gestionnaires redirect/callback. +* `TokenStorage` est un `Protocol` : quatre méthodes asynchrones, pas de classe de base. Persistez `client_info` en plus des jetons. +* La découverte, l’enregistrement (dynamique, ou via un **Client ID Metadata Document**), PKCE, les vérifications de `state` et `iss`, et l’actualisation des jetons sont l’affaire du fournisseur, pas la vôtre. +* `ClientCredentialsOAuthProvider` est la version sans humain : `client_id` + `client_secret`, pas de gestionnaires, pas de navigateur. +* Tout échec OAuth est une `OAuthFlowError` ; `OAuthRegistrationError` et `OAuthTokenError` en sont les sous-classes. + +L’autre moitié de cette poignée de main, faire en sorte que votre *serveur* exige le jeton, se trouve dans **[Autorisation](../run/authorization.md)**. diff --git a/i18n/fr/pages/client/session-groups.md b/i18n/fr/pages/client/session-groups.md new file mode 100644 index 0000000000..6ab8e19f6b --- /dev/null +++ b/i18n/fr/pages/client/session-groups.md @@ -0,0 +1,87 @@ +--- +translation: + sections: [09c857a25a9dc37a, 43bc6a76a243a50e, 0a716022a88768df, 4b7f78042bfcfff7, c112662e61b03315, 58974ba1f489a8b4, d18adbdbb835ea73] + tool: 1 +--- +# Groupes de sessions {#session-groups} + +Un `Client` se connecte à un seul serveur. Les applications réelles en veulent souvent plusieurs (un serveur de recherche, un serveur de base de données, une API interne) et finissent par jongler avec une connexion et une liste d’outils pour chacun. + +**`ClientSessionGroup`** est un objet unique qui détient de nombreuses connexions et fusionne tout ce qu’elles exposent en une seule vue. + +## Deux serveurs {#two-servers} + +Commencez par deux serveurs ordinaires. Ils n’ont rien à voir l’un avec l’autre, si bien que tous deux ont naturellement appelé leur outil `search` : + +```python title="library_server.py" hl_lines="7" +--8<-- "docs_src/session_groups/tutorial001.py" +``` + +```python title="web_server.py" hl_lines="7" +--8<-- "docs_src/session_groups/tutorial002.py" +``` + +## Un groupe {#one-group} + +Créez un `ClientSessionGroup` et appelez **`connect_to_server`** une fois par serveur : + +```python title="client.py" hl_lines="10-12" +--8<-- "docs_src/session_groups/tutorial003.py" +``` + +* `connect_to_server` prend des paramètres de transport, pas un objet serveur : `StdioServerParameters` (depuis `mcp`) pour lancer un sous-processus, ou `StreamableHttpParameters` / `SseServerParameters` (depuis `mcp.client.session_group`) pour un serveur qui écoute déjà sur une URL. +* `group.tools` est un `dict[str, Tool]` regroupant les outils de tous les serveurs connectés. `group.resources` et `group.prompts` ont la même forme. +* `group.call_tool(name, arguments)` recherche le nom, trouve la session qui le possède et lui transmet l’appel. Vous n’indiquez jamais quel serveur. + +!!! check + Placez `client.py` à côté des deux serveurs et exécutez-le. Le second `connect_to_server` refuse : + + ```text + mcp.shared.exceptions.MCPError: {'search'} already exist in group tools. + ``` + + C’est une `MCPError`, levée avant que quoi que ce soit du second serveur ne soit enregistré. Un nom doit + être unique dans **tout** le groupe, et deux serveurs que vous ne contrôlez pas finiront tôt ou tard par entrer en collision. + +## `component_name_hook` {#component_name_hook} + +Vous corrigez cela au niveau du groupe, pas des serveurs. Passez une fonction de `(name, server_info)` et le groupe l’exécute sur chaque nom qu’il enregistre : + +```python title="client.py" hl_lines="7-8 15" +--8<-- "docs_src/session_groups/tutorial004.py" +``` + +Relancez-le. `print(sorted(group.tools))` affiche maintenant les deux : + +```text +['Library.search', 'Web.search'] +``` + +* La **clé** est à vous. `by_server` l’a construite à partir de `server_info.name`, le nom avec lequel chaque `MCPServer(...)` a été construit. +* Le `Tool` à l’intérieur est intact : `group.tools["Web.search"].name` vaut toujours `"search"`, et c’est ce nom que `call_tool` envoie sur la liaison. Le préfixe ne quitte jamais votre processus. +* Cela ne concerne pas que les outils. La ressource `hours` de la bibliothèque est enregistrée sous le nom `Library.hours`. + +!!! tip + Le hook s’exécute sur **chaque** nom de **chaque** serveur, pas seulement en cas de conflit : il n’existe pas de + mode « préfixe en cas de collision ». Choisissez un schéma et laissez-le s’appliquer partout. + +## Ajouter et retirer des serveurs {#adding-and-removing-servers} + +`connect_to_server` renvoie la `ClientSession` qu’il a ouverte. Conservez-la si vous voulez un jour vous séparer de ce serveur : `await group.disconnect_from_server(session)` retire ses outils, ressources et prompts du groupe. + +Si vous détenez déjà une `ClientSession` connectée (`Client.session` en est une), passez-la à `await group.connect_with_session(server_info, session)` au lieu d’ouvrir un nouveau transport. Elle est agrégée de la même façon. Le groupe ne ferme jamais une session qu’il n’a pas ouverte. `server_info` nomme le serveur pour les préfixes de composants ; sur une connexion de génération 2026, `client.server_info` peut valoir `None` (l’identité est facultative), passez donc votre propre `Implementation(name=..., version=...)` dans ce cas. + +## La poignée de main classique {#the-classic-handshake} + +`ClientSessionGroup` est construit sur `ClientSession`, pas sur `Client`. Chaque `connect_to_server` exécute la poignée de main (handshake) `initialize` classique. Il n’envoie jamais la sonde `server/discover` décrite dans **[Versions du protocole](../protocol-versions.md)**. Tous les serveurs MCP comprennent cette poignée de main, donc cela ne vous coûte aucune compatibilité ; cela signifie seulement qu’un groupe emprunte le chemin plus ancien et plus lent vers un serveur qui pourrait faire mieux. + +## Récapitulatif {#recap} + +* `ClientSessionGroup` détient de nombreuses connexions serveur et fusionne leurs outils, ressources et prompts en un `dict` chacun. +* `connect_to_server(params)` par serveur. Il prend des paramètres de transport, jamais l’objet serveur ni l’URL que prend un `Client`. +* `group.call_tool(name, arguments)` achemine l’appel vers le serveur propriétaire à votre place. +* Les noms doivent être uniques dans tout le groupe ; deux serveurs dotés d’un outil `search` ne peuvent pas coexister tels quels. +* `component_name_hook=` réécrit chaque nom enregistré. La clé du dict change, pas le nom sur la liaison. +* `connect_with_session` ajoute une session que vous détenez déjà ; `disconnect_from_server` en retire une. + +La poignée de main que parle un groupe (et celle, plus rapide, que préfère un `Client`) fait l’objet de **[Versions du protocole](../protocol-versions.md)**. diff --git a/i18n/fr/pages/client/subscriptions.md b/i18n/fr/pages/client/subscriptions.md new file mode 100644 index 0000000000..40cc4da439 --- /dev/null +++ b/i18n/fr/pages/client/subscriptions.md @@ -0,0 +1,91 @@ +--- +translation: + sections: [8f9558e57f29eee1, a88c587739e0465c, 46ebfd5b325ed041, 4d10b00b57ce4bd9, 2cdb0edd1f59b3e2] + tool: 1 +--- +# Abonnements {#subscriptions} + +Le catalogue d’un serveur n’est pas figé. Des outils apparaissent à l’exécution, et le contenu derrière l’URI d’une ressource change. Un client l’apprend grâce à `client.listen(...)` : une seule requête `subscriptions/listen` dont la réponse *est* le flux. Elle reste ouverte et transporte les notifications de changement que le client a demandées. + +Cette page couvre le côté client : ouvrir le flux, le surveiller en parallèle de votre traitement principal, et gérer la façon dont il se termine. Publier les changements, filtrer et servir la méthode relèvent du serveur ; c’est raconté dans **[Abonnements](../handlers/subscriptions.md)**, sous *Dans votre gestionnaire*. Les exemples de cette page dialoguent avec le serveur de tableau de sprint construit là-bas. + +## Surveiller le flux {#watching-the-stream} + +Un abonnement est un gestionnaire de contexte, un seul. Y entrer envoie la requête, avec vos arguments nommés comme filtre d’abonnement, puis attend la confirmation du serveur : le flux est donc actif au moment où le bloc commence. + +```python title="client.py" hl_lines="15 18 28" +--8<-- "docs_src/subscriptions/tutorial003.py" +``` + +L’itération produit quatre événements typés : `ToolsListChanged`, `PromptsListChanged`, `ResourcesListChanged` et `ResourceUpdated(uri=...)`. + +Un événement dit *ce qui* a changé, jamais *comment*. C’est pourquoi `follow_board` appelle `read_resource` et `list_tools` : l’événement est un signal pour récupérer à nouveau les données. Lisez `event.uri` plutôt que de supposer quelle ressource a bougé : un filtre peut nommer plusieurs URI, et un serveur peut signaler un changement sur une sous-ressource de l’un d’eux. + +Les événements en double qui attendent d’être consommés se fondent en un seul, et une nouvelle récupération vous donne tout de même l’état courant. Seuls les événements identiques se fondent : deux `ResourceUpdated` pour des URI différents sont deux événements. + +Deux autres propriétés de l’objet d’abonnement : + +* `sub.honored` est le filtre que le serveur a confirmé : un `SubscriptionFilter` avec les champs que vous avez passés, lisibles comme attributs (`sub.honored.prompts_list_changed`). `MCPServer` honore tous les types d’événements que vous demandez, il vous renvoie donc votre requête telle quelle. Un serveur qui prend en charge moins de types en confirme moins, et un type confirmé peut malgré tout ne jamais se déclencher. Un serveur peut aussi refuser la requête entière plutôt que de la confirmer (voir [Décider qui peut surveiller](../handlers/subscriptions.md#deciding-who-may-watch) sur la page serveur), ce qui se manifeste comme l’erreur de la requête. +* `sub.subscription_id` est l’identifiant de la requête listen, celui qui est apposé sur chaque trame de ce flux. Plusieurs abonnements peuvent être ouverts en même temps, chacun démultiplexé par son propre identifiant. + +## Surveiller sans bloquer {#watching-without-blocking} + +`follow_board` tourne jusqu’à ce que le serveur ferme le flux, ce qui peut ne jamais arriver ; seule, elle monopolise donc votre programme. Les vrais clients veulent l’observateur *à côté* du traitement principal : un agent appelle des outils pendant qu’un observateur maintient à jour un cache ou une interface. + +Ouvrez d’abord l’abonnement, puis démarrez l’observateur et poursuivez votre travail. + +=== "asyncio" + + ```python title="app.py" hl_lines="18 20" + --8<-- "docs_src/subscriptions/tutorial004_asyncio.py" + ``` + +=== "trio" + + ```python title="app.py" hl_lines="18 21" + --8<-- "docs_src/subscriptions/tutorial004_trio.py" + ``` + +=== "anyio" + + ```python title="app.py" hl_lines="18 21" + --8<-- "docs_src/subscriptions/tutorial004_anyio.py" + ``` + +!!! note + `app.py` importe `BOARD` et `read_board` depuis le premier exemple, que ce dépôt stocke sous + le nom `tutorial003.py`. Si vous enregistrez les fichiers affichés côte à côte sous les noms + `client.py` et `app.py`, écrivez plutôt `from client import BOARD, read_board`. L’exemple + `watch.py`, plus bas, importe `read_board` de la même façon. + +L’ordre est tout l’enjeu. Rien n’est rejoué : un événement publié avant que votre flux n’existe est perdu. Entrer dans `client.listen(...)` attend la confirmation, si bien que chaque changement à partir de cet instant atteint votre observateur, et l’instantané que vous prenez dans le bloc ne peut en manquer aucun. + +Les requêtes s’exécutent librement à côté d’un flux ouvert, depuis la tâche de l’observateur ou n’importe quelle autre, sur le même client. Comme les événements non consommés *en double* fusionnent, un traitement principal chargé peut produire une seule récupération plutôt que trois. Les événements qui diffèrent ne fusionnent pas : un filtre qui nomme de nombreux URI met en file un événement en attente par URI. + +Pour arrêter de surveiller, sortez du bloc : il n’y a pas d’appel `unsubscribe`. Annuler la tâche qui possède le bloc le fait pour vous, et le SDK annule la requête listen comme le transport l’attend : en Streamable HTTP, en fermant le flux de cette requête. Un observateur qui tourne pendant toute la durée de vie de votre application ne revient jamais de lui-même ; annulez-le donc, ou la portée de son groupe de tâches, à l’arrêt. + +## Les flux se terminent {#streams-end} + +Un flux se termine de l’une de deux façons, toutes deux relevant du flot de contrôle ordinaire. Une fermeture propre côté serveur met fin à la boucle `async for` ; une coupure brutale lève `SubscriptionLost`. + +La différence sert au diagnostic, elle ne change pas ce qu’il faut faire ensuite : le flux a disparu, rien n’a été rejoué, et un observateur toujours intéressé réécoute et récupère à nouveau. + +```python title="watch.py" hl_lines="16 20" +--8<-- "docs_src/subscriptions/tutorial005.py" +``` + +Les serveurs ferment proprement les flux pour des raisons qui leur sont propres, notamment pour se délester d’un abonné dont l’arriéré a trop grossi ; une fin propre n’est donc pas un signal pour cesser de surveiller. Temporisez avant de réécouter. + +`SubscriptionLost` a aussi une cause locale. Le client conserve au plus 1 024 événements non consommés, et un consommateur qui prend autant de retard perd l’abonnement plutôt que de grossir sans limite. Gardez le corps de la boucle `async for` court et faites le travail lent ailleurs. + +`keep_following` n’intercepte que `SubscriptionLost`. Entrer dans `listen()` peut aussi lever `MCPError` (la connexion a échoué, ou le serveur ne sert pas la méthode), `TimeoutError` (aucune confirmation n’est arrivée) et `ListenNotSupportedError` (une connexion antérieure à 2026). Décidez lesquelles votre observateur devrait retenter : la dernière ne se résorbe jamais. + +## Récapitulatif {#recap} + +* Entrez dans `async with client.listen(...)` ; l’entrée attend la confirmation, donc rien de ce qui est publié ensuite n’est manqué. +* Itérez avec `async for event in sub`. Les événements sont des signaux pour récupérer à nouveau, jamais des charges utiles. +* Ouvrez l’abonnement, puis lancez l’observateur comme tâche, et les appels d’outils continuent de circuler à côté. +* Une fin propre arrête la boucle ; une coupure lève `SubscriptionLost`. Dans les deux cas : réécoutez, récupérez à nouveau, en temporisant d’abord. +* Sortir du bloc, c’est se désabonner. + +Publier ces événements, restreindre le filtre et passer à l’échelle au-delà d’un seul processus relèvent du serveur : **[Abonnements](../handlers/subscriptions.md)**. Ces mêmes événements maintiennent aussi à jour un cache côté client, et **[Mise en cache](caching.md)** est la page suivante. diff --git a/i18n/fr/pages/client/transports.md b/i18n/fr/pages/client/transports.md new file mode 100644 index 0000000000..da2801b705 --- /dev/null +++ b/i18n/fr/pages/client/transports.md @@ -0,0 +1,128 @@ +--- +translation: + sections: [9cac816674181eb0, 0700f337babcd4dd, 2bde0dd58cdf00f5, ff7401df479af877, 3d0832f39b0d7059, d4bf7e4479637768, 05e20c0a798860e7] + tool: 1 +--- +# Transports côté client {#client-transports} + +Chaque `Client` dialogue avec son serveur via un **transport** : ce qui achemine réellement les messages. + +Vous n’en configurez jamais un séparément. `Client` prend un seul argument positionnel et déduit le transport de son type. + +Le côté *serveur* de chacun (ce que fait `mcp.run()` et ce que vous déployez) est traité dans **[Exécuter votre serveur](../run/index.md)**. + +## En mémoire {#in-memory} + +Passez l’objet serveur lui-même : + +```python title="client.py" hl_lines="14" +--8<-- "docs_src/client_transports/tutorial001.py" +``` + +Pas de sous-processus, pas de port, aucun octet sur une liaison. Le client et le serveur sont deux objets dans le même processus, et l’appel passe tout de même par la véritable couche protocolaire : `search_books` est listé, validé et invoqué exactement comme il le serait via HTTP. + +Cela en fait deux choses à la fois : + +* **Un banc de test.** Chaque exemple de cette documentation est exécuté de cette façon, et la page **[Tests](../get-started/testing.md)** construit tout son modèle autour de lui. +* **Une API d’intégration.** Une application qui construit le serveur n’a pas besoin d’un saut réseau pour appeler ses outils. + +## Streamable HTTP {#streamable-http} + +Passez une URL sous forme de chaîne et vous obtenez **Streamable HTTP**, le transport derrière lequel vous déployez : + +```python title="client.py" hl_lines="5" +--8<-- "docs_src/client_transports/tutorial002.py" +``` + +C’est tout le client de production. `Client` enveloppe l’URL dans `streamable_http_client(...)` pour vous, par-dessus un `httpx2.AsyncClient` configuré comme MCP l’exige : `follow_redirects=True`, un délai d’expiration de 30 secondes pour connect/write/pool, et un délai de lecture de 300 secondes parce que le serveur peut garder un flux de réponse ouvert. + +!!! check + Un `Client` que vous venez de construire n’est **pas** connecté. La construction ne fait que choisir le transport ; + c’est `async with` qui l’ouvre. Tentez d’accéder à la connexion avant d’y entrer et le SDK vous le signale : + + ```text + RuntimeError: Client must be used within an async context manager + ``` + + Rien n’a été résolu, récupéré ni lancé quand vous avez écrit `Client("http://...")`. Cette ligne ne coûte rien. + +### Fournir votre propre `httpx2.AsyncClient` {#bring-your-own-httpx2asyncclient} + +Dès que vous avez besoin d’un en-tête `Authorization`, d’un cookie, d’un proxy, de mTLS ou d’un délai d’expiration différent, construisez le `httpx2.AsyncClient` vous-même et passez-le à `streamable_http_client` : + +```python title="client.py" hl_lines="8-14" +--8<-- "docs_src/client_transports/tutorial003.py" +``` + +Deux points à remarquer : + +* Le `httpx2.AsyncClient` vous appartient, donc c’est **vous** qui y entrez et en sortez. Le SDK ne ferme jamais un client qu’il n’a pas créé. +* `streamable_http_client(url, http_client=...)` renvoie un transport, et `Client(transport)` l’accepte comme n’importe quoi d’autre. + +Une remarque sur TLS : `httpx2` vérifie les certificats par rapport au magasin de confiance du système d’exploitation (via +[`truststore`](https://pypi.org/project/truststore/)), et non par rapport à une liste d’autorités de certification embarquée. Dans un environnement sans +magasin d’autorités de certification système utilisable (certains conteneurs minimaux), définissez les variables d’environnement standard `SSL_CERT_FILE`/`SSL_CERT_DIR` +ou passez un `verify=ssl_context` explicite à votre `httpx2.AsyncClient` +(le contexte se trouve dans +[`httpx` et `httpx-sse` remplacés par `httpx2`](../migration.md#httpx-and-httpx-sse-replaced-by-httpx2)). + +!!! warning + `streamable_http_client` acceptait autrefois `headers=` et `timeout=` directement. Ce n’est plus le cas : + ses seuls paramètres sont `url`, `http_client` et `terminate_on_close`. Utilisez `headers=` par + habitude et vous obtenez : + + ```text + TypeError: streamable_http_client() got an unexpected keyword argument 'headers' + ``` + + Tout ce qui relève de HTTP se trouve désormais sur l’unique `httpx2.AsyncClient` que vous passez. + +!!! info + `httpx2` conserve l’API familière de `httpx` ; si vous connaissez `httpx`, vous savez déjà comment gérer ici l’authentification, + les proxys, les hooks d’événements, les nouvelles tentatives et les limites de connexions. Le SDK n’ajoute rien par-dessus et ne retire + rien. C’est aussi là qu’OAuth se branche : + `httpx2.AsyncClient(auth=OAuthClientProvider(...))`. Tout ce flux est décrit dans **[Clients OAuth](oauth-clients.md)**. + +## stdio {#stdio} + +Un serveur **stdio** est un sous-processus. Le client le lance, écrit du JSON-RPC sur son stdin et lit du JSON-RPC depuis son stdout. C’est ainsi qu’un hôte de bureau exécute un serveur sur votre machine : un hôte *est* ce code plus une interface utilisateur, et **[Se connecter à un véritable hôte](../get-started/real-host.md)** montre la même relation vue du côté de l’hôte, sous forme de fichier de configuration. + +Décrivez le processus avec `StdioServerParameters`, transformez-le en transport avec `stdio_client`, et passez *cela* à `Client` : + +```python title="client.py" hl_lines="4-8 12" +--8<-- "docs_src/client_transports/tutorial004.py" +``` + +`Client` n’accepte pas l’objet de paramètres seul. `StdioServerParameters` est de la configuration ; `stdio_client(server)` est le transport qui sait lancer un processus à partir de celle-ci. Enveloppez toujours. + +Quitter le bloc `async with` arrête aussi le sous-processus : fermeture de stdin, attente, arrêt forcé s’il traîne. Vous ne le nettoyez jamais vous-même. + +!!! warning + Le processus enfant n’hérite **pas** de votre environnement. Il reçoit une liste d’autorisation minimale (`HOME`, `LOGNAME`, + `PATH`, `SHELL`, `TERM` et `USER` sous POSIX), de sorte que rien de sensible ne fuite vers un processus que vous n’avez peut-être + pas écrit. + + Un serveur qui a besoin d’une clé d’API ne l’y trouvera pas. Passez-la explicitement avec `env=` ; ces + variables sont fusionnées par-dessus la liste d’autorisation. C’est ce que fait `BOOKSHOP_API_KEY` ci-dessus. + +## SSE {#sse} + +`sse_client(url)`, du module `mcp.client.sse`, est le transport HTTP que Streamable HTTP a remplacé. Enveloppez-le de la même manière, `Client(sse_client("http://localhost:8000/sse"))`, pour dialoguer avec un serveur qui le parle encore, et ne construisez rien de nouveau dessus. + +## Le protocole `Transport` {#the-transport-protocol} + +Pour `Client`, tout ce qui précède est une seule et même chose. + +Un **transport** est n’importe quel gestionnaire de contexte asynchrone qui produit une paire `(read, write)` de flux de messages : formellement, le protocole `Transport` de `mcp.client`. `Client` résout son argument selon son type : un objet serveur se connecte dans le processus, une `str` devient `streamable_http_client(url)`, et tout le reste est ouvert directement comme transport. C’est cette dernière règle qui explique pourquoi `stdio_client(...)`, `streamable_http_client(...)` et `sse_client(...)` s’insèrent tous au même emplacement, et pourquoi vous pouvez écrire le vôtre. + +## Récapitulatif {#recap} + +* `Client(mcp)` (l’objet serveur) se connecte en mémoire. Utilisez-le pour les tests et pour l’intégration. +* `Client("http://.../mcp")` (une URL) se connecte via Streamable HTTP, le transport de production. +* Les en-têtes, l’authentification, les proxys et les délais d’expiration vont sur un `httpx2.AsyncClient` que vous passez à `streamable_http_client(url, http_client=...)`. Il n’y a pas de mot-clé `headers=`. +* stdio s’écrit `Client(stdio_client(StdioServerParameters(...)))`, jamais l’objet de paramètres seul. +* Le sous-processus reçoit un environnement sous liste d’autorisation, pas le vôtre ; `env=` s’y ajoute. +* Un transport est tout ce sur quoi vous pouvez faire `async with x as (read, write)`. `Client` transmet directement à ce protocole tout ce qui n’est ni un objet serveur ni une URL. +* Construire un `Client` choisit le transport. `async with` l’ouvre. + +Une fois le transport ouvert, les deux côtés doivent s’accorder sur une version du protocole. En temps normal, vous n’y pensez jamais ; le jour où vous devez y penser, la page à consulter est **[Versions du protocole](../protocol-versions.md)**. diff --git a/i18n/fr/pages/deprecated.md b/i18n/fr/pages/deprecated.md new file mode 100644 index 0000000000..6172f7baf7 --- /dev/null +++ b/i18n/fr/pages/deprecated.md @@ -0,0 +1,97 @@ +--- +translation: + sections: [20541a40dbdd5980, 01262a123ad9501d, 429db5b574a2ac08, 56b2d49da412cb28, 6a1717123fe4513c] + tool: 1 +--- +# Fonctionnalités obsolètes {#deprecated-features} + +La spécification 2026-07-28 retire cinq éléments. Le SDK les implémente toujours tous, et chacun d’eux porte désormais un **avertissement d’obsolescence**. + +Le tableau ci-dessous nomme chaque fonctionnalité obsolète, la raison de sa disparition et le remplacement sur lequel vous appuyer. + +## Ce qui est obsolète {#what-is-deprecated} + +| Obsolète | Pourquoi | Ce que vous faites à la place | +|---|---|---| +| **Racines (roots)** : `ctx.session.list_roots()`, `client.send_roots_list_changed()`, le `list_roots_callback=` que vous passez à `Client(...)` | La [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) retire la capacité. | Prenez les chemins comme arguments d’outil ordinaires ou comme URI de ressource, ou intégrez une `ListRootsRequest` dans un `InputRequiredResult` (voir **[Requêtes à plusieurs allers-retours (multi-round-trip)](handlers/multi-round-trip.md)**). | +| **Échantillonnage (sampling) à l’initiative du serveur** : `ctx.session.create_message()`, le `sampling_callback=` que vous passez à `Client(...)` | La SEP-2577 retire la capacité. | Renvoyez `InputRequiredResult` et laissez le client réessayer l’appel (voir **[Requêtes à plusieurs allers-retours](handlers/multi-round-trip.md)**). | +| **Journalisation par le protocole** : `ctx.log()`, `ctx.debug()`, `ctx.info()`, `ctx.warning()`, `ctx.error()`, `ctx.session.send_log_message()`, `client.set_logging_level()` | La SEP-2577 retire la capacité. Rien dans le protocole ne la remplace. | Un `import logging` ordinaire vers stderr (voir **[Journalisation](handlers/logging.md)**). | +| **`ping`** : `client.send_ping()` | **Supprimé** du protocole, pas simplement obsolète. Il n’y a pas de méthode `ping` en version 2026-07-28. | Rien. Cela ne fonctionne que sur une connexion `mode="legacy"`. | +| **Progression client->serveur** : `client.send_progress_notification()` | La version 2026-07-28 réserve la progression au sens serveur->client. | Rien à envoyer. Votre *serveur* signale sa progression avec `ctx.report_progress()` (voir **[Progression](handlers/progress.md)**). | + +Trois choses ressortent de ce tableau : + +* Les racines, l’échantillonnage et la journalisation vont ensemble. Une seule proposition, la **SEP-2577**, rend les trois capacités obsolètes d’un coup. +* L’échantillonnage et les racines partagent un problème plus profond : ce sont des endroits où un **serveur** envoie une **requête** au **client**. C’est toute cette direction que la version 2026-07-28 remplace par les **[Requêtes à plusieurs allers-retours](handlers/multi-round-trip.md)**. Ce sont les méthodes RPC autonomes (`sampling/createMessage`, `roots/list` et `elicitation/create` en mode push) qui disparaissent ; les types de charge utile `CreateMessageRequest` / `ListRootsRequest` / `ElicitRequest` survivent, intégrés dans `InputRequiredResult.input_requests`, et côté client ils aboutissent aux mêmes fonctions de rappel (callbacks). +* `ping` est l’exception. Le protocole ne le rend pas obsolète, il le supprime. La méthode du SDK avertit quand même (son message dit *removed*, pas *deprecated*) et l’appeler sur une connexion moderne répond par *« Method not found »*. + +## L’obsolescence est indicative {#deprecated-is-advisory} + +Rien ne casse aujourd’hui. + +Chaque méthode ci-dessus continue de fonctionner sur toute session qui a négocié la version **2025-11-25 ou antérieure**. Fixez `mode="legacy"` sur le client et vous obtenez exactement le comportement d’avant 2026. Il n’y a aucun changement sur la liaison et la négociation des capacités est inchangée. + +Ce qui change, c’est que vous obtenez un avertissement visible la première fois que chacune s’exécute : + +```text +MCPDeprecationWarning: The logging capability is deprecated as of 2026-07-28 (SEP-2577). +``` + +`MCPDeprecationWarning` hérite de `UserWarning`, **pas** de `DeprecationWarning`. C’est délibéré : le filtre par défaut de Python n’affiche `DeprecationWarning` que dans le code exécuté directement en tant que `__main__`, ce qui explique que les bibliothèques rendent des choses obsolètes sans que personne ne le remarque pendant deux ans. Celui-ci apparaît partout, sans option `-W`. + +!!! warning + « Indicatif » s’arrête à la liaison. L’échantillonnage et les racines sont des *requêtes* + du serveur vers le client, et une session 2026-07-28 n’a aucun canal pour en transporter + une. Appelez `ctx.session.create_message()` dans un outil sur une connexion moderne : + l’avertissement se déclenche quand même, puis l’envoi échoue avec une erreur : + + ```text + Cannot send 'sampling/createMessage': this transport context has no back-channel + for server-initiated requests. + ``` + + Deux signaux, dans cet ordre. Le `MCPDeprecationWarning` se déclenche dès que vous + appelez la méthode, sur n’importe quelle connexion. L’erreur est ce qui revient quand le + SDK tente ensuite l’envoi. Ces deux fonctionnalités ne marchent de bout en bout que sur + une connexion `mode="legacy"` dont le client a enregistré la fonction de rappel + correspondante. + +## Faire taire l’avertissement {#silencing-the-warning} + +Dans du nouveau code, ne le faites pas. + +Mais un serveur que vous maintenez et qui sert réellement des clients d’avant 2026 a parfaitement droit à un journal silencieux. Filtrez la catégorie avant l’exécution du premier appel obsolète : + +```python +import warnings + +from mcp import MCPDeprecationWarning + +warnings.filterwarnings("ignore", category=MCPDeprecationWarning) +``` + +C’est toute l’API. Il n’y a pas d’interrupteur par méthode, et vous n’en voulez pas : l’intérêt d’une catégorie unique, c’est qu’une ligne la fait taire et qu’une ligne la rétablit. + +!!! check + Inversez le filtre et vous obtenez gratuitement un test de non-régression. Ajoutez + `"error::mcp.MCPDeprecationWarning"` au réglage `filterwarnings` de votre configuration + pytest et l’appel obsolète **lève une exception** au lieu d’avertir. Un outil nommé + `old_log` qui appelle encore `ctx.info()` cesse de passer et se met à signaler : + + ```text + Error executing tool old_log: The logging capability is deprecated as of 2026-07-28 (SEP-2577). + ``` + + Une ligne de configuration pytest, et un appel obsolète ne peut plus jamais se glisser + de nouveau dans votre base de code sans faire échouer un test. + +## Récapitulatif {#recap} + +* La spécification 2026-07-28 rend obsolètes les **racines**, l’**échantillonnage** à l’initiative du serveur et la **journalisation** par le protocole (toutes via la [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), restreint la **progression** au sens serveur vers client et supprime **`ping`**. +* La colonne des remplacements vous oriente : **[Requêtes à plusieurs allers-retours](handlers/multi-round-trip.md)** pour l’échantillonnage et les racines, **[Journalisation](handlers/logging.md)** pour la journalisation, **[Progression](handlers/progress.md)** pour la progression. `ping` n’a besoin de rien du tout. +* L’obsolescence est indicative : aucun changement sur la liaison, tout continue de fonctionner sur les sessions d’avant 2026, et vous obtenez un `MCPDeprecationWarning` visible (un `UserWarning`, donc actif par défaut). +* L’échantillonnage et les racines ont en plus besoin d’un canal de retour (back-channel) qu’une session 2026-07-28 n’a pas. Sur une connexion moderne, ils avertissent puis lèvent une exception. +* `warnings.filterwarnings("ignore", category=MCPDeprecationWarning)` fait taire toute la catégorie ; `"error::mcp.MCPDeprecationWarning"` dans pytest la transforme en échec de test. +* Aucun nouveau code ne devrait s’appuyer sur l’une de ces fonctionnalités. + +Toutes les autres pages de cette documentation enseignent l’API actuelle. diff --git a/i18n/fr/pages/get-started/first-steps.md b/i18n/fr/pages/get-started/first-steps.md new file mode 100644 index 0000000000..ac30ba9ad4 --- /dev/null +++ b/i18n/fr/pages/get-started/first-steps.md @@ -0,0 +1,144 @@ +--- +translation: + sections: [0d6c05bcbf836bf3, 59a7b14eeefc68c1, 7114d8d6daba203f, e8bbb56a98ba7bc9, 5138010f6159901c, f78da7c7c363d4c6, 220a939cab348686] + tool: 1 +--- +# Premiers pas {#first-steps} + +La **[page d’accueil](../index.md)** va vite : écrire un serveur, l’exécuter, appeler un outil. + +Cette page prend son temps, avec les trois choses qu’un serveur peut exposer, et un nom pour chaque notion rencontrée en chemin. + +## Hôte, client et serveur {#host-client-and-server} + +Trois mots que vous verrez sur chaque page à partir d’ici : + +* Un **hôte** est l’application LLM : Claude, un IDE, un environnement d’exécution d’agents. C’est ce à quoi l’utilisateur parle. +* Un **client** vit à l’intérieur de l’hôte et parle MCP. L’hôte exécute un client par serveur auquel il est connecté. +* Un **serveur** est ce que vous construisez avec ce SDK. Il expose des choses aux clients. Il ne parle jamais directement au modèle. + +Vous écrivez le serveur. Les hôtes sont le produit de quelqu’un d’autre. Le SDK vous fournit aussi un `Client`. Vous l’utiliserez pour tester vos serveurs, et il apparaît plus loin sur cette page. + +## Les trois primitives {#the-three-primitives} + +Un serveur expose exactement trois sortes de choses. Ce qui les distingue, c’est **qui décide de les utiliser** : + +| Primitive | Contrôlée par | Ce que c’est | Exemple | +|----------------|-----------------|----------------------------------------------------------------------|------------------------------------------------| +| **Outils** | Le modèle | Une fonction que le modèle appelle pour agir | Un appel d’API, une écriture en base de données | +| **Ressources** | L’application | Des données que l’hôte charge dans le contexte du modèle | Le contenu d’un fichier, une réponse d’API | +| **Prompts** | L’utilisateur | Un modèle de message réutilisable que l’utilisateur invoque par son nom | Une commande slash, une entrée de menu | + +« Contrôlée par » est tout l’intérêt de la distinction. Un outil s’exécute parce que le **modèle** a décidé de l’appeler. Une ressource est jointe parce que l’**application** a décidé que le modèle en avait besoin. Un prompt s’exécute parce que l’**utilisateur** l’a choisi. + +!!! info + Si vous avez déjà construit une API web, vous avez l’essentiel de l’intuition : une **ressource** est un `GET` + (elle charge des données et ne modifie rien) et un **outil** est un `POST` (il effectue un travail et peut avoir + des effets de bord). Un **prompt** n’a pas d’équivalent HTTP ; il se rapproche d’une requête enregistrée que + l’utilisateur exécute par son nom. + +## Un serveur, les trois à la fois {#one-server-all-three} + +```python title="server.py" hl_lines="6 12 18" +--8<-- "docs_src/first_steps/tutorial001.py" +``` + +Trois fonctions ordinaires, trois décorateurs. Chaque décorateur constitue à lui seul tout l’enregistrement : + +* `@mcp.tool()` fait de `add` un **outil**. +* `@mcp.resource("greeting://{name}")` fait de `greeting` un **modèle de ressource** (resource template) : le `{name}` dans l’URI est le paramètre de la fonction. +* `@mcp.prompt()` fait de `summarize` un **prompt**. La chaîne qu’il renvoie devient un message utilisateur. + +Tout le reste (le nom, la description, le schéma des arguments), le SDK le lit dans la fonction elle-même : son nom, sa docstring, ses annotations de type. Vous n’avez rien déclaré de tout cela séparément. + +!!! tip + Les deux moitiés du SDK ont deux chemins d’import : `from mcp import Client` et + `from mcp.server import MCPServer`. Il n’existe pas de `from mcp import MCPServer`. + +### Essayer {#try-it} + +Lancez-le avec le MCP Inspector : + +```console +uv run mcp dev server.py +``` + +Ouvrez l’URL qu’il affiche. L’Inspector a un onglet par primitive ; parcourez-les dans l’ordre. + +**Tools.** Une entrée : `add`, décrite comme *Add two numbers.* Le formulaire comporte un champ entier obligatoire pour `a` et un autre pour `b`. Remplissez-les, lancez l’appel, et le résultat est `3`. L’Inspector a construit ce formulaire à partir de `a: int, b: int`. Tous les autres clients font de même. + +**Resources.** La liste *Resources* est vide. `greeting` se trouve sous **Resource Templates**, parce que `greeting://{name}` a un paramètre : il n’y a aucune ressource unique à lister tant que personne n’a fourni de `name`. Donnez-lui `World` et lisez-la : + +```text +Hello, World! +``` + +**Prompts.** Une entrée : `summarize`, avec un seul argument obligatoire, `text`. Récupérez-le avec un peu de texte et vous recevez un message avec `role: user` et votre chaîne rendue comme contenu. Un prompt n’est rien d’autre que cela : une fonction qui construit des messages. + +L’Inspector a exécuté votre serveur via **stdio**, l’un des transports qu’un serveur MCP peut parler. Vous n’en choisissez pas encore un ; **[Exécuter votre serveur](../run/index.md)** est la page consacrée à ce sujet. + +## Capacités {#capabilities} + +Vous avez vu trois onglets dans l’Inspector. Comment savait-il qu’il y en avait trois ? + +Lorsqu’un client se connecte, le serveur déclare ses **capacités** (capabilities) : les familles de requêtes auxquelles il répondra. Le client utilise cette déclaration pour décider de ce qu’il peut même demander. Vous ne l’avez jamais écrite ; `MCPServer` la déclare pour vous. + +Regardez par vous-même. Le `Client` du SDK accepte directement l’objet serveur et s’y connecte **en mémoire** (ni sous-processus, ni port) : + +```python +import asyncio + +from mcp import Client + +from server import mcp + + +async def main() -> None: + async with Client(mcp) as client: + print(client.server_capabilities.model_dump(exclude_none=True)) + + +asyncio.run(main()) +``` + +```text +{'prompts': {'list_changed': True}, 'resources': {'subscribe': True, 'list_changed': True}, 'tools': {'list_changed': True}} +``` + +Ce dictionnaire, ce sont les **capacités** déclarées de votre serveur. C’est la première chose qu’apprend chaque client qui se connecte : + +| Capacité | Le client peut désormais appeler | +|-------------|---------------------------------------------------------------| +| `tools` | `tools/list`, `tools/call` | +| `resources` | `resources/list`, `resources/templates/list`, `resources/read` | +| `prompts` | `prompts/list`, `prompts/get` | + +`MCPServer` sert les trois primitives, donc les trois sont toujours déclarées. + +Remarquez ce qui n’y figure pas. `completions` (la complétion automatique des arguments pour les modèles de ressources et les prompts) nécessite un gestionnaire que vous écrivez ; ce serveur n’en a pas, donc la capacité est absente et un client bien élevé ne demandera rien. C’est la règle pour tout ce qui est facultatif : enregistrez la chose et la capacité apparaît ; **[Complétions](../servers/completions.md)** le prouve. + +!!! info + `Client(mcp)` est le même client en mémoire avec lequel chaque exemple de cette documentation est testé, et + c’est ainsi que vous testerez les vôtres. Il a droit à une page entière : **[Tester](testing.md)**. + +## Ce que vous n’avez pas écrit {#what-you-did-not-write} + +Reprenez cette page depuis le début. Vous avez écrit trois petites fonctions Python. Vous n’avez **pas** écrit : + +* De JSON Schema. `a: int, b: int` *est* le schéma de `add`. +* De gestionnaire de requêtes. `tools/list`, `resources/read`, `prompts/get` : tous servis pour vous. +* De déclaration de capacités. `MCPServer` l’a faite pour vous. +* Une seule ligne de protocole. La négociation de version, l’encapsulation JSON-RPC, l’échange de capacités : tout cela s’est passé à l’intérieur de `mcp dev` et de `Client(mcp)`, et vous n’en avez rien vu. + +Ce rapport est tout l’intérêt du SDK. + +## Récapitulatif {#recap} + +* Un **hôte** est l’application LLM, un **client** est sa moitié qui parle MCP, un **serveur** est ce que vous construisez. +* Les outils sont contrôlés par le **modèle**, les ressources par l’**application**, les prompts par l’**utilisateur**. +* Un décorateur par primitive : `@mcp.tool()`, `@mcp.resource(uri)`, `@mcp.prompt()`. Le nom, la description et le schéma viennent de la fonction. +* Un URI avec un `{param}` crée un **modèle** de ressource, listé séparément des ressources concrètes. +* Les **capacités** du serveur sont déclarées pour vous, et un client ne demande que ce qu’un serveur déclare. +* `Client(mcp)` se connecte à l’objet serveur en mémoire : votre banc d’essai dès le premier jour. + +La suite, c’est **[Se connecter à un vrai hôte](real-host.md)** : ce serveur dans Claude Desktop ou un IDE, pour de vrai. Puis **[Tester](testing.md)** : une page, un client en mémoire, et vous n’aurez plus jamais à deviner si cela fonctionne. Ensuite, chaque primitive a droit à sa propre page, en commençant par celle que pilote le modèle : **[Outils](../servers/tools.md)**. diff --git a/i18n/fr/pages/get-started/index.md b/i18n/fr/pages/get-started/index.md new file mode 100644 index 0000000000..1374233b24 --- /dev/null +++ b/i18n/fr/pages/get-started/index.md @@ -0,0 +1,57 @@ +--- +translation: + sections: [ed4a756b4c53c585, 97e2fb315b7fe398, 4d04f1c6f4bf6c1d, 577d73078fc62baf] + tool: 1 +--- +# Prise en main {#get-started} + +Vous débutez avec MCP, ou avec ce SDK ? Commencez ici. Ces pages vous mènent de zéro à un +serveur fonctionnel et testé : [installez le SDK](installation.md), construisez votre +[premier serveur](first-steps.md), [connectez-le à un hôte réel](real-host.md) et +[testez-le](testing.md) avec un client en mémoire. + +## Exécuter le code {#run-the-code} + +Tous les blocs de code peuvent être copiés et utilisés tels quels : ce sont des fichiers complets et fonctionnels. + +Pour suivre, collez un bloc dans un fichier `server.py` et ouvrez-le dans le MCP Inspector : + +```console +uv run mcp dev server.py +``` + +Il est **FORTEMENT recommandé** d’écrire (ou de copier) le code, de le modifier et de l’exécuter localement. C’est en l’utilisant dans votre propre éditeur que vous en saisirez vraiment l’intérêt : le peu de code à écrire, l’autocomplétion, les vérifications de type qui détectent les erreurs avant même que vous n’exécutiez quoi que ce soit. + +## Vous n’aurez pas à deviner {#you-will-not-be-guessing} + +Chaque exemple de cette documentation est un fichier complet sous [`docs_src/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/docs_src) dans le dépôt du SDK lui-même, et chacun d’eux est exécuté par la suite de tests du SDK via un **client en mémoire** : + +```python +import pytest +from mcp import Client + +from server import mcp + + +@pytest.mark.anyio +async def test_add() -> None: + async with Client(mcp) as client: + result = await client.call_tool("add", {"a": 1, "b": 2}) + assert result.structured_content == {"result": 3} +``` + +Aucun sous-processus, aucun port, aucun transport. `Client(mcp)` se connecte directement à l’objet serveur. + +Si une modification du SDK casse un exemple de l’une de ces pages, la CI passe au rouge avant la page. Le code que vous lisez ici est le code qui s’exécute. + +Vous l’utiliserez vous-même dans [Tester](testing.md) ; c’est aussi ainsi que vous testez vos propres serveurs. + +## Où aller ensuite {#where-to-go-next} + +Une fois qu’un serveur tourne, le reste de cette documentation est une référence, pas un cours. +Chaque page se suffit à elle-même, alors allez directement à ce dont vous avez besoin : + +* Ce qu’un serveur expose (outils, ressources, prompts), c’est **[Serveurs](../servers/index.md)**. +* Ce qui est disponible dans les fonctions que vous enregistrez, c’est **[Dans votre gestionnaire](../handlers/index.md)**. +* Le mettre à disposition des clients (stdio, HTTP, votre application FastAPI existante), c’est **[Exécuter votre serveur](../run/index.md)**. +* Construire l’autre côté, une application qui *utilise* des serveurs MCP, c’est **[Clients](../client/index.md)**. diff --git a/i18n/fr/pages/get-started/installation.md b/i18n/fr/pages/get-started/installation.md new file mode 100644 index 0000000000..610c6253a5 --- /dev/null +++ b/i18n/fr/pages/get-started/installation.md @@ -0,0 +1,47 @@ +--- +translation: + sections: [6e2f9bab94d5ed36, 8cf653388f69e28b, 6fd9ea2f65de0df6] + tool: 1 +--- +# Installation {#installation} + +Le SDK Python est disponible sur PyPI sous le nom [`mcp`](https://pypi.org/project/mcp/). Il nécessite **Python 3.10+**. + +Cette documentation décrit la **v2**, la ligne de versions stable actuelle : + +=== "uv" + + ```bash + uv add "mcp[cli]" + ``` + +=== "pip" + + ```bash + pip install "mcp[cli]" + ``` + +!!! note "Vous venez de la v1 ?" + La v2 est une version majeure avec des changements incompatibles ; le **[Guide de migration](../migration.md)** + les couvre tous. Si votre *paquet* dépend de `mcp` et n’est pas prêt à migrer, conservez une + borne supérieure `<2` (par exemple `mcp>=1.28,<2`) pour qu’une résolution sans version épinglée reste sur la ligne 1.x. + +## Ce qui est installé {#what-gets-installed} + +Vous n’avez pas besoin de connaître tout cela pour utiliser le SDK, mais si vous vous demandez à quoi sert chaque dépendance : + +* `mcp-types` : tous les types du protocole (requêtes, résultats, blocs de contenu) dans un paquet à part, versionné au même rythme que le SDK. Le code qui dépend de `mcp` l’importe via l’alias `mcp.types` (tous les `from mcp.types import ...` de cette documentation) ; n’importez `mcp_types` directement que dans un projet qui installe `mcp-types` sans le SDK. +* [`anyio`](https://anyio.readthedocs.io/) : le runtime asynchrone. Tout le SDK est écrit au-dessus d’anyio, il fonctionne donc aussi bien avec `asyncio` qu’avec `trio`. +* [`pydantic`](https://docs.pydantic.dev/) : la base de tous les modèles `mcp.types`, ainsi que toute la génération et la validation de schémas. +* [`httpx2`](https://pypi.org/project/httpx2/) : le client HTTP derrière les transports *client* Streamable HTTP et SSE, avec prise en charge intégrée des server-sent events. +* [`starlette`](https://www.starlette.io/), [`uvicorn`](https://www.uvicorn.org/), [`sse-starlette`](https://pypi.org/project/sse-starlette/) et [`python-multipart`](https://pypi.org/project/python-multipart/) : les transports HTTP *serveur*. +* [`jsonschema`](https://pypi.org/project/jsonschema/) : valide la sortie structurée d’un outil par rapport au schéma de sortie qu’il déclare. +* [`pyjwt[crypto]`](https://pyjwt.readthedocs.io/) : gestion des jetons OAuth pour l’autorisation. +* [`opentelemetry-api`](https://opentelemetry-python.readthedocs.io/) : l’API légère uniquement, de sorte que le middleware de traçage du SDK ne coûte rien tant que vous n’installez pas vous-même un SDK OpenTelemetry et un exporteur. +* [`typing-extensions`](https://typing-extensions.readthedocs.io/) et [`typing-inspection`](https://pypi.org/project/typing-inspection/) : les fonctionnalités de typage modernes sous Python 3.10. +* [`pywin32`](https://pypi.org/project/pywin32/) : Windows uniquement, utilisé pour la gestion des sous-processus `stdio`. + +## Extras optionnels {#optional-extras} + +* `mcp[cli]` ajoute [`typer`](https://typer.tiangolo.com/) et [`python-dotenv`](https://pypi.org/project/python-dotenv/) pour l’outil en ligne de commande `mcp` (`mcp dev`, `mcp run`, `mcp install`). Vous en aurez besoin pendant le développement ; vous pouvez vous en passer sur un serveur déployé. +* `mcp[rich]` ajoute [`rich`](https://rich.readthedocs.io/) pour des journaux de serveur plus lisibles. diff --git a/i18n/fr/pages/get-started/real-host.md b/i18n/fr/pages/get-started/real-host.md new file mode 100644 index 0000000000..f74124fba0 --- /dev/null +++ b/i18n/fr/pages/get-started/real-host.md @@ -0,0 +1,186 @@ +--- +translation: + sections: [3c4f2f06b4e978b6, 22520eecae3d1961, f4e1709db18d635a, 2eb57992049671d9, 1ba83e9af37cc1b4, 4822586344b08d9e, 1c93afef72478992, b6b448f9eddd51dc, fe55370fd931815b] + tool: 1 +--- +# Se connecter à un véritable hôte {#connect-to-a-real-host} + +Un **hôte** est l’application dans laquelle votre serveur finit par vivre : Claude Desktop, Claude Code, un IDE. L’hôte est ce à quoi l’utilisateur parle. À l’intérieur, un **client** MCP lance votre serveur comme processus enfant et lui parle via le stdin et le stdout de ce processus. + +Se connecter à un hôte se résume donc à un seul geste : vous lui indiquez **la commande qui démarre votre serveur**. Tout ce qui figure sur cette page (deux commandes CLI, trois fichiers JSON) n’est qu’un endroit différent où placer cette même commande. + +## Un serveur, tous les hôtes {#one-server-every-host} + +```python title="server.py" hl_lines="3 33-34" +--8<-- "docs_src/real_host/tutorial001.py" +``` + +Deux outils et une ressource, un seul fichier. Trois points concernant ce fichier comptent pour chaque hôte ci-dessous : + +* `mcp.run()` sans argument démarre un serveur **stdio** : il bloque, lit les messages du protocole sur stdin et les écrit sur stdout. C’est le transport que parlent tous les hôtes de cette page. L’hôte démarre votre fichier comme processus enfant et possède ces deux tubes, c’est pourquoi se connecter ne revient jamais qu’à « voici la commande ». Vous ne choisissez jamais de port, et rien n’écoute sur un port. +* `run()` est placé sous `if __name__ == "__main__":`. Tout ce qui suit **importe** ce fichier au lieu de l’exécuter ; un `run()` non protégé démarrerait donc un serveur dès que quoi que ce soit chargerait le module. +* L’objet serveur est une variable globale de niveau module nommée `mcp`. C’est le nom que `mcp run` recherche (`server` et `app` fonctionnent aussi). Si vous l’appelez autrement, vous le nommez explicitement : `mcp run server.py:bookshop`. + +C’est la dernière ligne de Python de cette page. À partir d’ici, tout n’est que configuration d’hôte. + +## La commande de lancement {#the-launch-command} + +Chaque hôte ci-dessous reçoit la même commande : + +```bash +uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py +``` + +Une seule commande pour tous, parce que `uv run --with` résout le SDK dans un environnement neuf, à la volée : elle fonctionne depuis n’importe quel répertoire et n’a besoin d’aucun projet ni d’aucun environnement virtuel à activer. Cela compte ici plus qu’ailleurs, car un hôte lance votre serveur depuis *son* répertoire de travail avec un environnement presque vide, et non depuis votre shell. + +C’est aussi la commande que `mcp install` écrit pour vous dans la configuration de Claude Desktop (ci-dessous) : ce que vous tapez à la main et ce que l’outil génère concordent, à l’exception de l’épinglage de version exact que l’outil ajoute. + +!!! tip "Si un hôte ne trouve pas `uv`" + Un hôte lance votre serveur avec un `PATH` minimal, et `uv` n’y figure peut-être pas. + Remplacez le `uv` seul par le chemin absolu donné par `which uv` (macOS/Linux) ou `where uv` + (Windows). C’est exactement ce qu’écrit `mcp install`. + +!!! note "Cette page traite du cas local" + Tout ici exécute votre serveur sur la machine où se trouve l’hôte : l’hôte lance votre + fichier, via stdio. C’est exactement ce qu’il faut pour un outil personnel ou limité à une + seule machine. Pour mettre un serveur à disposition de personnes qui n’ont *pas* votre + fichier, vous distribuez une **URL**, pas une commande : le même objet `mcp`, servi via + Streamable HTTP. **[Exécuter votre serveur](../run/index.md)** résume cette décision en un + tableau, et **[Déployer et passer à l’échelle](../run/deploy.md)** est le chemin qui mène de + là à un véritable nom d’hôte. + + Et un hôte n’est rien de plus qu’une application contenant un client MCP ; votre propre + code Python peut donc jouer le rôle de l’hôte : **[Transports du client](../client/transports.md)** + lance ce même fichier comme sous-processus avec `stdio_client(...)`, et **[Tests](testing.md)** + s’y connecte en mémoire, sans aucun processus. + +## Claude Desktop {#claude-desktop} + +Le seul hôte que le SDK peut configurer pour vous : + +```bash +uv run mcp install server.py +``` + +C’est tout. `mcp install` importe le fichier pour lire le nom du serveur, trouve le fichier de configuration de Claude Desktop et y écrit la commande de lancement. Au passage, il convertit votre chemin en chemin absolu, pour que vous n’ayez pas à le faire. + +Il n’y a là rien de mystérieux. Voici l’entrée qu’il écrit : + +```json +{ + "mcpServers": { + "Bookshop": { + "command": "/absolute/path/to/uv", + "args": [ + "run", + "--frozen", + "--with", + "mcp[cli]==2.0.0", + "mcp", + "run", + "/absolute/path/to/server.py" + ] + } + } +} +``` + +C’est la commande de lancement de la section précédente avec trois ajouts : le chemin absolu vers `uv`, `--frozen` pour que `uv` ne réécrive jamais un fichier de verrouillage qui se trouverait à proximité, et un épinglage exact de la version de `mcp` que vous avez installée. Elle atterrit dans `claude_desktop_config.json`, qui se trouve ici : + +* **macOS** : `~/Library/Application Support/Claude/claude_desktop_config.json` +* **Windows** : `%APPDATA%\Claude\claude_desktop_config.json` + +Vous pouvez écrire ce fichier à la main. `mcp install` existe pour vous éviter l’erreur classique (un chemin relatif) en le faisant. + +Quittez complètement Claude Desktop (pas seulement sa fenêtre), puis rouvrez-le. + +!!! warning + `mcp install` échoue avec `Claude app not found` si le *répertoire* de configuration de + Claude Desktop n’existe pas encore. Installez Claude Desktop et lancez-le une fois : c’est + ce qui crée le répertoire. + +!!! tip + Claude Desktop démarre votre serveur dans son propre processus ; les variables + d’environnement de votre shell n’y sont donc pas. `uv run mcp install server.py -v API_KEY=abc123` + (ou `-f .env`) les enregistre dans le champ `env` de l’entrée. `--name` remplace le nom de + l’entrée ; par défaut, c’est le `name` du serveur. + +## Claude Code {#claude-code} + +Il n’y a aucun fichier à modifier. Enregistrez le serveur avec la CLI `claude` ; tout ce qui suit `--` est la commande de lancement. + +```bash +claude mcp add bookshop -- uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py +``` + +Exécutez `/mcp` dans une session Claude Code pour confirmer que `bookshop` est connecté et que ses outils sont listés. + +## Cursor {#cursor} + +Créez `.cursor/mcp.json` à la racine de votre projet. + +```json +{ + "mcpServers": { + "bookshop": { + "command": "uv", + "args": ["run", "--with", "mcp[cli]", "mcp", "run", "/absolute/path/to/server.py"] + } + } +} +``` + +Les mêmes `command` et `args`, sous la même clé `mcpServers` que celle qu’utilise Claude Desktop. Le serveur apparaît dans les paramètres MCP de Cursor avec les deux outils listés. + +## VS Code {#vs-code} + +Créez `.vscode/mcp.json` à la racine de votre projet. + +```json +{ + "servers": { + "bookshop": { + "type": "stdio", + "command": "uv", + "args": ["run", "--with", "mcp[cli]", "mcp", "run", "/absolute/path/to/server.py"] + } + } +} +``` + +Deux différences avec le fichier de Cursor, et ce sont les deux seules : la clé englobante est `servers`, et non `mcpServers`, et chaque entrée déclare son `type`. Acceptez la demande de confiance, puis **MCP: List Servers** dans la palette de commandes affiche `bookshop` en cours d’exécution. + +!!! note + Il vous faut VS Code 1.99 ou ultérieur avec l’extension **GitHub Copilot** connectée + (Copilot Free suffit), et Copilot Chat doit être en mode **Agent**, car aucun autre mode + n’appelle d’outils. + +## Le serveur n’apparaît pas {#it-doesnt-show-up} + +Avant de toucher à la moindre configuration d’hôte, exécutez vous-même la commande de lancement : + +```bash +uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py +``` + +Rien ne s’affiche, et la commande ne rend pas la main. Ce silence est normal : un serveur stdio attend qu’un hôte parle en premier sur stdin (`Ctrl-C` pour l’arrêter). Une trace d’erreur ou une sortie immédiate, voilà le vrai bogue, et vous pouvez désormais le lire au lieu de le deviner à travers un hôte. + +Une fois que cette commande se contente d’attendre, ce qui reste est presque toujours l’une de ces trois causes : + +* **Un chemin relatif.** L’hôte lance votre serveur depuis *son* répertoire de travail, pas depuis celui d’où vous l’avez enregistré. `server.py` là où il faut `/absolute/path/to/server.py` est, de loin, l’échec le plus fréquent. Si l’hôte ne trouve pas `uv` non plus, ce chemin doit lui aussi être absolu. +* **L’hôte utilise encore son ancienne configuration.** Les hôtes lisent leur configuration au démarrage. Claude Desktop, en particulier, doit être *complètement quitté* (pas seulement sa fenêtre fermée) puis rouvert avant qu’une modification de `claude_desktop_config.json` prenne effet. +* **Quelque chose a atteint stdout en dehors de la fenêtre de redirection.** En stdio, stdout *est* le protocole. Le SDK redirige vers stderr la sortie parasite vidée pendant qu’il sert, mais une sortie vidée sur stdout avant cela (un script d’enrobage qui fait un echo, un `print()` à l’import dans un processus sans tampon), ou un `print()` mis en tampon et vidé à la sortie de l’interpréteur, remet à l’hôte un message corrompu et celui-ci coupe la connexion. Journalisez avec la configuration `logging` par défaut, dont le gestionnaire stderr vide chaque enregistrement ; les gestionnaires personnalisés doivent eux aussi éviter stdout. Tous les détails sont dans **[Journalisation](../handlers/logging.md)**. + +Claude Desktop tient un journal par serveur : `mcp-server-.log` est le stderr de votre serveur, à côté de `mcp.log` pour les connexions, sous `~/Library/Logs/Claude` sur macOS et `%APPDATA%\Claude\logs` sur Windows. + +Pour tout ce qui dépasse ces trois cas, la page à consulter est **[Dépannage](../troubleshooting.md)**. + +## Récapitulatif {#recap} + +* Un **hôte** (Claude Desktop, un IDE) exécute un client MCP qui lance votre serveur comme processus enfant via stdio. Se connecter, c’est lui donner une commande de lancement. +* Cette commande est `uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py` : aucun venv à activer, elle fonctionne depuis n’importe quel répertoire. +* **Claude Desktop** est le seul hôte que `mcp install` configure pour vous. Il écrit cette même commande (plus le chemin absolu vers `uv`, `--frozen` et un épinglage exact de la version que vous avez installée) dans `claude_desktop_config.json`, pour que vous n’ayez jamais à le faire. +* **Claude Code**, c’est `claude mcp add bookshop -- `. **Cursor**, c’est `.cursor/mcp.json` sous `mcpServers`. **VS Code**, c’est `.vscode/mcp.json` sous `servers`, chaque entrée avec un `type`. +* Des chemins absolus partout, redémarrez l’hôte après avoir modifié sa configuration, et ne laissez jamais rien d’autre que le SDK écrire sur stdout. + +Tous les hôtes de cette page se sont connectés au même fichier, avec la même commande. Ce que ce fichier peut *exposer*, c’est le reste de cette documentation : **[Outils](../servers/tools.md)**, **[Ressources](../servers/resources.md)**, et tous les transports autres que stdio dans **[Exécuter votre serveur](../run/index.md)**. diff --git a/i18n/fr/pages/get-started/testing.md b/i18n/fr/pages/get-started/testing.md new file mode 100644 index 0000000000..e7b187985f --- /dev/null +++ b/i18n/fr/pages/get-started/testing.md @@ -0,0 +1,117 @@ +--- +translation: + sections: ['4926721070127497', c52a1de2b6b32f40, 2e410b412c25f314, 627195f7159e24ef] + tool: 1 +--- +# Tests {#testing} + +Le SDK Python fournit une classe `Client` dotée d’un **transport en mémoire** : passez-lui votre objet serveur et il s’y connecte directement. + +Pas de sous-processus. Pas de port. Pas de transport du tout. C’est la même idée que le `TestClient` de FastAPI. + +## Utilisation de base {#basic-usage} + +Supposons que vous ayez un serveur simple avec un seul outil (tool) : + +```python title="server.py" +--8<-- "docs_src/testing/tutorial001.py" +``` + +Pour exécuter le test ci-dessous, vous aurez besoin de deux dépendances (de développement) supplémentaires : + +=== "uv" + + ```bash + uv add --dev pytest inline-snapshot + ``` + +=== "pip" + + ```bash + pip install pytest inline-snapshot + ``` + +!!! info + Cette documentation suppose que vous connaissez déjà [`pytest`](https://docs.pytest.org/en/stable/). + + [`inline-snapshot`](https://15r10nk.github.io/inline-snapshot/latest/) est ce que le test + ci-dessous utilise pour vérifier l’objet résultat entier en une seule ligne. Il enregistre la + sortie d’un test sous la forme du littéral `snapshot(...)` que vous voyez. Si vous préférez vous + en passer, supprimez l’import et vérifiez les champs qui vous intéressent + (`result.content[0].text == "3"`) comme dans n’importe quel autre test. + +Voici maintenant le test : + +```python title="test_server.py" +import pytest +from inline_snapshot import snapshot +from mcp import Client +from mcp.types import CallToolResult, TextContent + +from server import mcp + + +@pytest.fixture +def anyio_backend(): # (1)! + return "asyncio" + + +@pytest.fixture +async def client(): # (2)! + async with Client(mcp, raise_exceptions=True) as c: + yield c + + +@pytest.mark.anyio +async def test_call_add_tool(client: Client): + result = await client.call_tool("add", {"a": 1, "b": 2}) + # Drop the server identity stamp in `_meta`; it is not what this test is about. + result.meta = None + assert result == snapshot( + CallToolResult( + content=[TextContent(type="text", text="3")], + structured_content={"result": 3}, + ) + ) +``` + +1. Si vous utilisez `trio`, renvoyez `"trio"` à la place. Consultez la [documentation d’anyio](https://anyio.readthedocs.io/en/stable/testing.html#specifying-the-backends-to-run-on) pour les détails. +2. La fixture produit un client connecté. Chaque test qui prend `client` en paramètre obtient une nouvelle connexion en mémoire vers le même serveur. + +Et voilà. Vous pouvez maintenant étendre vos tests pour couvrir davantage de scénarios. + +## Pourquoi `raise_exceptions=True` ? {#why-raise_exceptionstrue} + +Deux choses différentes peuvent mal tourner, et cet indicateur n’en concerne qu’une seule. + +Une exception dans l’un de **vos outils** n’est pas un échec du protocole. Elle devient un résultat +normal avec `is_error=True`, et le modèle lit le message. `raise_exceptions` n’y change rien : avec +ou sans lui, `call_tool` renvoie le même résultat `is_error=True`. Une page entière y est +consacrée : **[Gérer les erreurs](../servers/handling-errors.md)**. + +Un échec **en dehors** du corps d’un outil est différent. Sur la connexion que vous donne +`Client(mcp)`, le serveur le neutralise en un `"Internal server error"` générique avant que le +client ne le voie. Vous ne devriez jamais divulguer les détails d’un plantage inattendu à un +appelant distant. Dans un test, c’est exactement ce que vous ne voulez *pas*, et c’est ce que +change `raise_exceptions=True` : votre test voit le vrai message au lieu de la version neutralisée. + +Laissez-le activé dans les tests. Il n’a aucun sens dans du code de production. + +## Dans le processus par défaut {#in-process-by-default} + +!!! note + `Client(mcp)` se connecte dans le processus et est **neutre vis-à-vis de la génération du + protocole** par défaut : il sonde le serveur et choisit le chemin de protocole approprié. Fixez + `mode="legacy"` si votre test exerce une sémantique propre aux connexions historiques (push + d’échantillonnage (sampling) ou d’élicitation (elicitation), `message_handler`), et retirez alors + `raise_exceptions=True` : une connexion historique ne neutralise jamais rien, et l’indicateur + relève l’échec dans la tâche du serveur plutôt que dans votre test. + +Cette unique ligne est aussi la raison pour laquelle cette documentation peut vous promettre que +ses exemples fonctionnent : chaque fichier d’exemple est exercé par la propre suite de tests du +SDK, presque tous via ce client précisément. Vous utilisez le même outil que le SDK utilise sur +lui-même. + +Vous avez un serveur qui fonctionne et qui est testé. L’intégrer dans une véritable application +(Claude Desktop, un IDE), c’est **[Se connecter à un hôte réel](real-host.md)** ; toutes les autres +manières de le servir sont dans **[Exécuter votre serveur](../run/index.md)**. diff --git a/i18n/fr/pages/handlers/context.md b/i18n/fr/pages/handlers/context.md new file mode 100644 index 0000000000..683c770e46 --- /dev/null +++ b/i18n/fr/pages/handlers/context.md @@ -0,0 +1,134 @@ +--- +translation: + sections: [b50152f05c81e786, b302059b22fb7cb4, 85682a1bf561243a, 53fc48838eb6837a, b24190e0842786ec, 85f93e150fc9b240] + tool: 1 +--- +# L’objet Context {#the-context} + +Les arguments d’un outil viennent du modèle. Tout le reste (la requête que vous servez, le serveur dans lequel vous vivez, un moyen de répondre au client) vient d’un seul objet : le **`Context`**. + +Vous ne le construisez pas, vous ne le configurez pas. Vous le demandez. + +## Le demander {#ask-for-it} + +Ajoutez un paramètre annoté avec `Context` à n’importe quel outil : + +```python title="server.py" hl_lines="2 8" +--8<-- "docs_src/context/tutorial001.py" +``` + +* Le SDK construit un `Context` neuf pour chaque requête et vous le passe. +* Le **nom du paramètre n’a aucune importance**. `ctx`, `context`, `c` : le SDK le trouve grâce à son annotation. +* Les ressources et les prompts peuvent en déclarer un aussi, de la même façon. +* `ctx.request_id` est l’identifiant de la requête que votre fonction est en train de servir. + +!!! info + Si vous avez utilisé FastAPI, vous connaissez le procédé : vous déclarez un paramètre avec le type propre au framework + (`Request` là-bas, `Context` ici) et le framework le fournit. Rien à enregistrer, rien à + configurer : l’annotation de type est tout le mécanisme. + +### Invisible pour le modèle {#invisible-to-the-model} + +C’est le point à bien intégrer. Voici le schéma d’entrée que `tools/list` renvoie pour `search_books` : + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"} + }, + "required": ["query"], + "title": "search_booksArguments" +} +``` + +Une seule propriété. `ctx` n’est pas un argument : il n’apparaît jamais dans le schéma, le modèle n’en entend jamais parler et aucun client ne peut le remplir. C’est un contrat entre vous et le SDK, invisible sur la liaison. + +### Essayer {#try-it} + +Lancez le serveur avec le MCP Inspector : + +```console +uv run mcp dev server.py +``` + +Le formulaire de `search_books` n’a qu’un seul champ, `query`. Appelez-le avec `dune` : + +```text +[request 3] Found 3 books matching 'dune'. +``` + +Le numéro est celui de la requête en question, quelle qu’elle soit. Appelez de nouveau l’outil et il change : chaque requête reçoit son propre `Context`. + +## Ce qu’il vous apporte {#what-it-gives-you} + +L’objet injecté est petit. En plus de `request_id` : + +* `await ctx.read_resource(uri)` : lire l’une des **propres** ressources du serveur depuis un outil. C’est la section suivante. +* `await ctx.report_progress(progress, total, message)` : remonter la progression à l’appelant pendant un appel long. Tous les détails sont dans **[Progression](progress.md)**. +* `await ctx.elicit(message, schema)` et `await ctx.elicit_url(...)` : mettre l’outil en pause et poser une question à l’utilisateur. C’est **[l’élicitation (elicitation)](elicitation.md)**. +* `ctx.session` : le côté serveur de la conversation avec ce client. Les notifications que vous envoyez au client passent par là ; la dernière section s’en sert. +* `ctx.headers` : les en-têtes de requête acheminés par le transport, ou `None` en stdio. Lisez un en-tête personnalisé avec `(ctx.headers or {}).get("x-...")`. Les en-têtes sont des données fournies par le client — très bien pour une langue ou un feature flag, jamais pour une identité. +* `ctx.request_context` : l’enregistrement brut propre à la requête. Le champ que vous irez chercher est `lifespan_context`, l’objet que votre code de démarrage a produit avec yield (voir **[Cycle de vie (lifespan)](lifespan.md)**). + +La journalisation est volontairement absente de cette liste. Un serveur journalise avec le module `logging` de Python, comme n’importe quel autre programme Python. **[Journalisation](logging.md)** est la courte page qui explique pourquoi. + +!!! tip + L’injection n’a lieu que pour la fonction que vous avez enregistrée. Une fonction auxiliaire appelée par votre outil ne reçoit pas + son propre `Context` ; passez-lui `ctx` comme un argument ordinaire. Il n’existe aucun + « contexte courant » ambiant à récupérer ailleurs. + +## Lire vos propres ressources {#read-your-own-resources} + +Les ressources d’un serveur ne sont pas réservées aux clients. Un outil peut les lire aussi : + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/context/tutorial002.py" +``` + +`ctx.read_resource` résout l’URI via le même registre que celui qui sert `resources/read`, si bien qu’un outil obtient ce qu’un client obtiendrait : un itérable de `ReadResourceContents`, un par bloc de contenu. Pour cet URI, il y en a un : + +```python +contents.content # 'fiction, non-fiction, poetry' +contents.mime_type # 'text/plain' +``` + +* `content` est exactement ce que `genres()` a renvoyé. Une seule source de vérité : le client parcourt la ressource, vos outils la consomment, personne ne copie la chaîne. +* Le seul paramètre de `describe_catalog` est le `Context`, donc son schéma d’entrée n’a **aucune propriété**. Le modèle l’appelle avec `{}`. + +## Signaler au client que la liste a changé {#tell-the-client-the-list-changed} + +Ce qu’un serveur propose n’est pas figé au moment de l’import. Enregistrez un outil à l’exécution, puis prévenez le client : + +```python title="server.py" hl_lines="15-16" +--8<-- "docs_src/context/tutorial003.py" +``` + +* `mcp.add_tool(recommend_book)` enregistre une simple fonction comme outil : nom, description et schéma dérivés exactement comme `@mcp.tool()` l’aurait fait. +* `await ctx.session.send_tool_list_changed()` envoie `notifications/tools/list_changed`. Un client qui la reçoit appelle de nouveau `tools/list` et voit `recommend_book`. + +Les méthodes sœurs sont `send_resource_list_changed()`, `send_prompt_list_changed()` et `send_resource_updated(uri)` pour un changement sur une ressource précise. + +Sur une connexion 2026-07-28, les clients ne reçoivent les notifications de changement que sur un flux `subscriptions/listen` qu’ils ont ouvert ; les méthodes `send_*` ci-dessus n’atteignent donc pas ces flux. Les méthodes de publication du `Context` diffusent vers tous les flux abonnés d’un coup : `await ctx.notify_tools_changed()`, `await ctx.notify_prompts_changed()`, `await ctx.notify_resources_changed()` et `await ctx.notify_resource_updated(uri)`. Tous les détails, y compris la montée en charge sur plusieurs réplicas, sont dans **[Abonnements](subscriptions.md)**. + +!!! check + Avant que quelqu’un n’exécute `enable_recommendations`, l’outil que vous promettez n’existe pas. Appelez-le + quand même et le résultat est une erreur que le modèle peut lire : + + ```text + Unknown tool: recommend_book + ``` + + Exécutez `enable_recommendations`, et le même appel réussit. La liste d’outils est réellement + dynamique : `tools/list` reflète ce qui est enregistré *à l’instant même*. + +## Récapitulatif {#recap} + +* Annotez un paramètre avec `Context` (dans un outil, une ressource ou un prompt) et le SDK l’injecte. Le nom vous appartient. +* Il est invisible pour le modèle : le schéma d’entrée ne contient jamais que vos vrais arguments. +* `ctx.request_id` identifie la requête ; `ctx.request_context.lifespan_context` est ce que votre démarrage a produit avec yield. +* `await ctx.read_resource(uri)` permet à un outil de lire les propres ressources du serveur. +* `ctx.session` est le canal de retour vers le client : `send_tool_list_changed()` et ses sœurs lui demandent de récupérer à nouveau une liste que vous avez modifiée. +* Le rapport de progression et l’élicitation partent eux aussi du `Context` ; chacun a sa propre page. + +Les paramètres que le modèle ne voit jamais, remplis par vos propres fonctions, sont les **[Dépendances](dependencies.md)**. diff --git a/i18n/fr/pages/handlers/dependencies.md b/i18n/fr/pages/handlers/dependencies.md new file mode 100644 index 0000000000..15d154ae76 --- /dev/null +++ b/i18n/fr/pages/handlers/dependencies.md @@ -0,0 +1,173 @@ +--- +translation: + sections: [b0389403e98d25ad, e2cf58b43b285e86, a363e1a38e1a5971, 6cfac078feb18013, b4535bd61df337e6, e97ed44207f929fd] + tool: 1 +--- +# Dépendances {#dependencies} + +Les arguments d’un outil (tool) viennent du modèle. Certaines valeurs ne devraient jamais en venir : un prix tiré de vos registres, une confirmation que seule une personne peut donner, tout ce que le modèle pourrait fausser en l’inventant. + +Les **dépendances** sont des paramètres remplis par vos propres fonctions. Vous annotez le paramètre, vous nommez la fonction, et le SDK l’appelle avant l’exécution de votre outil. + +## En déclarer une {#declare-one} + +Enveloppez le type du paramètre dans `Annotated[...]` et ajoutez `Resolve(fn)` : + +```python title="server.py" hl_lines="18-19 23" +--8<-- "docs_src/dependencies/tutorial001.py" +``` + +* `check_stock` est un **résolveur** : une simple fonction que le SDK exécute avant `reserve_book`, et dont la valeur de retour devient l’argument `stock`. +* Son paramètre `title` est l’argument `title` de l’outil lui-même, apparié **par nom**. Le résolveur voit exactement la valeur validée que verra le corps de l’outil. +* Le corps de l’outil part d’un `Stock` qui existe déjà. Pas de code de recherche dans l’outil, pas de préambule « et s’il manquait ? ». + +!!! info + Si vous avez utilisé FastAPI, c’est `Depends`. Même geste, même raison : la fonction déclare + ce dont elle a besoin, le framework le fournit, et le câblage vit dans l’annotation de type. + +### Invisible pour le modèle {#invisible-to-the-model} + +Voici le schéma d’entrée que `tools/list` rapporte pour `reserve_book` : + +```json +{ + "type": "object", + "properties": { + "title": {"title": "Title", "type": "string"} + }, + "required": ["title"], + "title": "reserve_bookArguments" +} +``` + +Une seule propriété. Comme le `Context` dans **[L’objet Context](context.md)**, un paramètre résolu est un contrat entre vous et le SDK : `stock` n’est pas dans le schéma, le modèle n’en entend jamais parler, et un client qui envoie quand même une valeur `stock` est ignoré. La valeur du résolveur est la seule que votre outil puisse recevoir. + +Ce dernier point est l’essentiel. Un paramètre que le modèle ne peut pas fournir est un paramètre sur lequel le modèle ne peut pas se tromper. + +### Essayer {#try-it} + +Lancez le serveur avec le MCP Inspector : + +```console +uv run mcp dev server.py +``` + +Le formulaire de `reserve_book` comporte un seul champ `title`. `stock` n’y figure nulle part. Appelez-le avec `Dune` : + +```text +Reserved 'Dune' (6 copies left). +``` + +Le corps de l’outil n’a rien recherché : `check_stock` s’est exécuté d’abord, et le `Stock` qu’il a renvoyé est arrivé en argument. Essayez `Neuromancer` et le même résolveur remet un zéro à l’outil. + +!!! tip + Vous pourriez simplement appeler `check_stock(title)` dans le corps de l’outil. Déclarez-le + comme dépendance quand la valeur mérite mieux qu’un appel de fonction utilitaire : chaque + outil qui a besoin du stock déclare le même paramètre, et le SDK exécute le résolveur au plus + une fois par appel, quel que soit le nombre d’outils qui le déclarent. Les sections suivantes + ajoutent le reste : des résolveurs qui dépendent les uns des autres, et des résolveurs qui + interrogent l’utilisateur. + +## Dépendances de dépendances {#dependencies-of-dependencies} + +Un résolveur peut déclarer ses propres dépendances, avec la même annotation : + +```python title="server.py" hl_lines="22 29-30" +--8<-- "docs_src/dependencies/tutorial002.py" +``` + +* `estimate_delivery` dépend de `check_stock`. Le SDK exécute le graphe dans l’ordre : le stock d’abord, puis l’estimation, puis l’outil. +* `stock` comme `delivery` ont en fin de compte besoin de `check_stock`, mais celui-ci s’exécute **une fois par appel**. Une seule consultation de l’inventaire, deux consommateurs. +* Il n’y a rien à enregistrer. Le graphe, *ce sont* les annotations. + +!!! check + Ne croyez pas le « une fois par appel » sur parole. Placez un `print` dans `check_stock` et + appelez `order_book` depuis l’Inspector : une ligne par appel. Deux consommateurs, une seule + consultation. + +Le SDK analyse le graphe à l’enregistrement de l’outil, pas à son appel. Un paramètre qu’il ne sait pas classer — ni un `Context`, ni un `Resolve(...)`, ni le nom d’un argument de l’outil — et un cycle de résolveurs lèvent tous deux `InvalidSignature` au démarrage. Votre serveur échoue avant même qu’un client se connecte, avec le paramètre ou le résolveur fautif nommé dans l’erreur. + +Les paramètres d’un résolveur se résolvent exactement comme ceux d’un outil : un autre `Resolve(...)`, les arguments de l’outil lui-même par nom, ou le `Context` — `ctx.headers`, l’objet du cycle de vie (lifespan), tout. + +!!! warning + Sur les transports HTTP, le `Context` inclut `ctx.headers`. Les en-têtes sont des **entrées + fournies par le client**, comme n’importe quel argument d’outil : très bien pour une locale ou + un feature flag, jamais pour une identité. L’identité de l’appelant vient de votre couche + d’autorisation (**[Autorisation](../run/authorization.md)**), pas d’un en-tête que n’importe qui peut définir. + +!!! tip + *Une fois par appel* veut dire exactement cela : le `tools/call` suivant exécute de nouveau + `check_stock`. Une ressource qui doit survivre à une requête — un pool de connexions à la base + de données, un client HTTP — a sa place dans **[Cycle de vie](lifespan.md)**, et un résolveur + peut l’atteindre via `ctx.request_context.lifespan_context`. + +## Demander quand il le faut {#ask-when-you-must} + +Un résolveur n’est pas obligé de connaître la réponse. Il peut renvoyer `Elicit(message, Model)` et le SDK interroge l’utilisateur — c’est la mécanique de l’**[Élicitation](elicitation.md)** (elicitation), pilotée pour vous : + +```python title="server.py" hl_lines="26-32 39" +--8<-- "docs_src/dependencies/tutorial003.py" +``` + +* En stock : `confirm_backorder` renvoie directement un `Backorder`. **Pas de question, pas d’aller-retour.** L’utilisateur n’est interrompu que lorsque sa réponse compte. +* En rupture : le SDK envoie l’élicitation, valide la réponse par rapport à `Backorder`, et l’injecte. Votre résolveur ne touche jamais au protocole. +* L’outil lit `backorder.confirm` comme n’importe quel autre argument. Répondre **non** reste une réponse : l’élicitation est acceptée avec `confirm=False`, l’outil s’exécute, et aucune commande n’est passée. Poser la question est devenu une précondition, pas de la tuyauterie dans le corps de l’outil. + +Et si l’utilisateur ne répond pas du tout — s’il décline la question, ou l’annule ? + +!!! check + Lancez `order_book` pour `Neuromancer` et déclinez la question. Avec l’annotation écrite sous + la forme `Annotated[Backorder, Resolve(...)]`, le corps de l’outil ne s’exécute jamais ; + l’appel échoue avec un résultat d’erreur que le modèle peut lire : + + ```text + Error executing tool order_book: Resolver for parameter 'backorder' could not resolve: elicitation was decline + ``` + +C’est le bon comportement par défaut pour une précondition : pas de réponse, pas de commande. Quand le refus est une issue que votre outil veut gérer — renoncer à la commande en attente mais suggérer tout de même un autre titre —, annotez plutôt `ElicitationResult[Backorder]` et l’outil reçoit l’issue complète accept/decline/cancel pour décider de la suite. **[Élicitation](elicitation.md)** montre cette forme, et tout le reste sur la manière de poser une question : les règles de schéma, les trois réponses, le côté client de la conversation. + +!!! info + Le framework choisit le transport de la question d’après la version du protocole négociée ; + le code ci-dessus est identique dans les deux cas. En version **2026-07-28** et ultérieures, + la question voyage à l’intérieur d’un `tools/call` à plusieurs allers-retours + (multi-round-trip) — le serveur la renvoie, la fonction de rappel (callback) + `elicitation_callback` du client y répond, et le `Client` relance l’appel pour vous + (**[Requêtes à plusieurs allers-retours](multi-round-trip.md)**). En version **2025-11-25** + et antérieures, c’est une requête d’élicitation synchrone en cours d’appel. Chaque question + est posée exactement une fois par appel — une garantie qui porte sur la question, pas sur le + résolveur. Dans la forme à plusieurs allers-retours, n’importe quel résolveur peut s’exécuter + de nouveau chaque fois que l’appel reprend après une question ; le code placé avant un + `return Elicit(...)` s’exécute donc à chacun de ces tours, et la réponse enregistrée satisfait + alors la question répétée sans solliciter de nouveau l’utilisateur. Une réponse enregistrée + n’est consultée que lorsque le résolveur pose la question ; un résolveur qui répond *sans* + poser de question, comme `check_stock`, fournit toujours sa propre valeur calculée. Comme + chaque réponse est rattachée à sa question, un résolveur qui élicite doit dériver sa question + de façon déterministe à partir des arguments de l’outil et des réponses précédentes. Une + valeur générée à chaque appel (un identifiant issu d’un `default_factory`, un horodatage) est + recalculée à chaque tour et ne doit pas figurer dans une question à laquelle la réponse est + censée se lier. Une question construite à partir de données aussi volatiles fait paraître + périmée chaque réponse enregistrée ; le serveur la repose donc à chaque tour jusqu’à ce que + la limite de tours du client mette fin à l’appel. + +## Interroger le client, pas l’utilisateur {#ask-the-client-not-the-user} + +L’élicitation est l’une des trois questions qu’un résolveur peut poser, et le flux à plusieurs allers-retours n’en autorise aucune autre. Les deux autres s’adressent au **client** plutôt qu’à l’utilisateur : renvoyez `Sample(...)` pour faire exécuter un appel de LLM par le client (une requête `sampling/createMessage`), ou `ListRoots()` pour récupérer les racines (roots) actuelles du client. Aucune des deux n’a d’issue accept/decline ; le consommateur annote directement le type du résultat, `CreateMessageResult` (`CreateMessageResultWithTools` lorsque la requête porte `tools` ou `tool_choice`) ou `ListRootsResult` : + +```python title="server.py" hl_lines="10-15 21" +--8<-- "docs_src/dependencies/tutorial004.py" +``` + +* Le framework les achemine exactement comme `Elicit` : à l’intérieur du `tools/call` à plusieurs allers-retours en version **2026-07-28**, via la requête autonome serveur->client en version **2025-11-25**. Une capacité non déclarée fait refuser l’appel avec une erreur de protocole `-32021` (`sampling`, `roots`, `elicitation` en mode formulaire ; `sampling.tools` lorsque la requête porte `tools` ou `tool_choice`). +* Tout ce que l’encadré d’information ci-dessus dit des questions s’applique tel quel : une requête `Sample` est rattachée à son résultat enregistré par son rendu exact ; construisez-la donc de façon déterministe à partir des arguments de l’outil et des réponses précédentes. Le client paie alors l’appel de LLM une fois par appel d’outil, pas une fois par tour. Le résultat enregistré voyage dans `request_state` pour le reste de l’appel, si bien qu’une complétion très volumineuse alourdit chaque aller-retour restant. +* Les *fonctionnalités* autonomes d’échantillonnage (sampling) et de racines sont obsolètes en version 2026-07-28 (SEP-2577). Les nouveaux serveurs qui ont besoin du modèle du client posent leur question via ce vecteur ; ceux qui n’en ont pas besoin devraient s’intégrer directement à un fournisseur de LLM. Les valeurs de `include_context` autres que `"none"` sont elles-mêmes obsolètes ; évitez-les. + +## Récapitulatif {#recap} + +* `Annotated[T, Resolve(fn)]` sur un paramètre d’outil : le SDK exécute `fn` et injecte sa valeur de retour. +* Un paramètre résolu est invisible pour le modèle et ne peut pas être fourni par un client. Les valeurs que le modèle ne doit pas inventer — prix, identités, permissions — ont leur place ici. +* Les paramètres d’un résolveur se résolvent de la même façon : le `Context`, un autre `Resolve(...)`, ou un argument de l’outil par nom. Le graphe exécute chaque résolveur au plus une fois par tour, quel que soit le nombre de ses consommateurs ; chaque question est posée exactement une fois, et n’importe quel résolveur peut s’exécuter de nouveau lorsqu’un appel reprend après une question. +* Les graphes incorrects échouent à l’enregistrement avec `InvalidSignature`, pas en cours d’appel. +* Renvoyez `Elicit(message, Model)` pour interroger l’utilisateur, seulement quand il le faut. Les annotations non enveloppées interrompent l’appel en cas de refus ; `ElicitationResult[T]` laisse l’outil décider de la suite. +* Renvoyez `Sample(...)` ou `ListRoots()` pour demander au client une complétion de LLM ou la liste des racines ; le résultat brut est injecté. + +L’état que votre serveur construit une seule fois au démarrage, et la manière dont un gestionnaire (handler) y accède, c’est la page **[Cycle de vie](lifespan.md)**. diff --git a/i18n/fr/pages/handlers/elicitation.md b/i18n/fr/pages/handlers/elicitation.md new file mode 100644 index 0000000000..58faf19400 --- /dev/null +++ b/i18n/fr/pages/handlers/elicitation.md @@ -0,0 +1,190 @@ +--- +translation: + sections: [335ca2a0b266f003, d1ad562d3fe87bc0, 0bb1396c86daeba4, d1cb1235bb9ee267, 833179c09d239c83, e5d6dec2d2e655e8] + tool: 1 +--- +# Élicitation {#elicitation} + +Un outil arrivé à mi-parcours de sa tâche et à qui il manque une seule réponse n’est pas obligé d’échouer. + +L’**élicitation** (elicitation) lui permet de la demander. En plein appel d’outil, l’utilisateur reçoit une question, et sa réponse revient dans le même appel de fonction. + +Il existe deux modes : + +* **Mode formulaire** : vous avez besoin d’une valeur (une confirmation, une date, une quantité). Vous décrivez les champs, le client affiche le formulaire. +* **Mode URL** : vous avez besoin que l’utilisateur aille ailleurs (un écran de consentement OAuth, une page de paiement). Rien de ce qu’il y fait ne passe par le protocole. + +Et il existe deux façons de demander. Celle à privilégier est un **résolveur** : vous accrochez la question à un paramètre, et le SDK la pose — sur n’importe quelle connexion, quelle que soit la génération de protocole que parle le client. La façon directe, `await ctx.elicit(...)`, est une requête du *serveur* vers le *client*, un canal qui n’existe que pour un client sur une connexion historique (version de spécification 2025-11-25 ou antérieure). Les deux figurent sur cette page ; commencez par le résolveur. + +## Demander avec un résolveur {#ask-with-a-resolver} + +Une question dont dépend tout l’outil — *êtes-vous sûr ? lequel des trois comptes correspondants ?* — peut être sortie du corps de l’outil et placée dans un **résolveur**, et le framework la pose pour vous. + +Un paramètre annoté `Annotated[T, Resolve(fn)]` est rempli en exécutant `fn` avant le corps de l’outil. Le résolveur renvoie directement la valeur quand il la connaît déjà, ou renvoie `Elicit(...)` pour que le framework pose la question : + +```python title="server.py" hl_lines="24-30 35-36" +--8<-- "docs_src/elicitation/tutorial004.py" +``` + +* `confirm_delete` lit par son nom l’argument `path` de l’outil lui-même, liste le dossier et **n’élicite que lorsqu’il le doit** — un dossier vide se résout en `Confirm(ok=True)` sans aucun aller-retour avec le client. +* `delete_folder` annote `ElicitationResult[Confirm]` : le framework injecte donc le résultat complet et l’outil traite chaque cas avec `match` : accepter et confirmer, accepter mais conserver (`ok=False`), décliner, annuler. +* Le paramètre `confirm` n’apparaît jamais dans le schéma d’entrée de l’outil — le client fournit `path`, le résolveur fournit `confirm`. + +Annotez plutôt le modèle non enveloppé (`Annotated[Confirm, Resolve(confirm_delete)]`) quand l’outil n’a pas besoin de bifurquer : il reçoit le modèle en cas d’acceptation, et l’appel s’interrompt avec une erreur en cas de refus ou d’annulation. + +Un résolveur fonctionne sur **toutes** les connexions. Pour un client sur une connexion historique, le SDK lui envoie directement la question ; sur une connexion **2026-07-28**, le SDK *renvoie* la question depuis l’appel, et la tentative suivante du client transporte la réponse. Votre résolveur ne voit jamais la différence ; ce qui se passe sous le capot, ce sont les **[Requêtes à plusieurs allers-retours](multi-round-trip.md)** (multi-round-trip). + +Demander n’est qu’une des choses qu’un résolveur peut faire. Le mécanisme général — des dépendances qui calculent sans demander, des dépendances de dépendances, ce que le modèle peut et ne peut pas fournir — est décrit sur la page **[Dépendances](dependencies.md)**. + +## Demander depuis l’intérieur de l’outil {#ask-from-inside-the-tool} + +Un outil peut aussi s’arrêter au milieu de son propre corps et poser une question. + +!!! warning + `ctx.elicit()` et `ctx.elicit_url()` sont des requêtes du *serveur* vers le *client* — un + canal qui n’existe que pour un client sur une connexion historique (version de spécification **2025-11-25** + ou antérieure). Sur une connexion **2026-07-28**, il n’y a pas de requêtes à l’initiative du serveur, donc + ces appels échouent. Un résolveur fonctionne sur les deux. Tous les détails sont dans + **[Versions du protocole](../protocol-versions.md)**. + +`await ctx.elicit()` prend un message et un modèle Pydantic : + +```python title="server.py" hl_lines="9-11 20-23 25" +--8<-- "docs_src/elicitation/tutorial001.py" +``` + +* Le paramètre **`Context`** est ce qui vous donne `ctx.elicit` ; n’importe quel outil peut en prendre un. Cet objet a sa propre page : **[L’objet Context](context.md)**. +* `AlternativeDate` est le **schéma** de la réponse que vous voulez. +* L’outil est `async def`. Il doit l’être : il s’arrête au milieu et attend une personne. +* Pour toute autre date, l’outil renvoie immédiatement. Il ne demande que lorsqu’il le doit. +* La date que l’utilisateur accepte repasse par `book_table` lui-même. Une réponse est une entrée comme une autre : une date de remplacement elle aussi complète fait l’objet d’une nouvelle question, au lieu d’être confirmée à l’aveugle. + +### Ce que reçoit le client {#what-the-client-receives} + +Le client reçoit votre message et, à côté, un JSON Schema généré à partir du modèle : + +```json +{ + "properties": { + "accept_alternative": { + "description": "Try another date?", + "title": "Accept Alternative", + "type": "boolean" + }, + "date": { + "default": "2025-12-26", + "description": "Alternative date (YYYY-MM-DD)", + "title": "Date", + "type": "string" + } + }, + "required": ["accept_alternative"], + "title": "AlternativeDate", + "type": "object" +} +``` + +Ce schéma, c’est le formulaire. `Field(description=...)` est le libellé ; une valeur par défaut préremplit le champ et le rend facultatif. C’est la même mécanique Pydantic vers JSON Schema que **[Outils](../servers/tools.md)** décrit pour les arguments d’un outil. + +!!! warning + Un schéma d’élicitation n’est pas aussi expressif que le schéma d’entrée d’un outil. Des champs plats et primitifs + uniquement : `str`, `int`, `float`, `bool`, ou un `Literal` de chaînes (il devient un `enum`). + Mettez un modèle dans le modèle et `ctx.elicit` lève une exception avant que quoi que ce soit ne soit envoyé au client : + + ```text + TypeError: Elicitation schema field 'address' rendered as {'$ref': '#/$defs/Address'}, which is not a valid PrimitiveSchemaDefinition + ``` + + Vous interrompez une personne en pleine tâche. Si la réponse a besoin d’imbrication, elle aurait dû être un + argument de l’outil. + +### Les trois réponses {#the-three-answers} + +`result.action` vous indique ce qu’a fait l’utilisateur, et il y a exactement trois possibilités : + +* `"accept"` : il a soumis le formulaire. `result.data` est une instance de `AlternativeDate`, déjà validée. +* `"decline"` : il a dit non. +* `"cancel"` : il a écarté la question sans choisir. + +`result.data` n’existe que sur `"accept"`, c’est pourquoi l’exemple vérifie `result.action` d’abord. Votre vérificateur de types impose cet ordre : après `result.action == "accept"`, `result.data` est un `AlternativeDate` ; avant, il n’y a pas de `.data` du tout. + +Un refus n’est pas une erreur. L’outil décide de ce que signifie décliner (ici, pas de réservation) et répond normalement au modèle. + +!!! tip + La réponse est validée par rapport à votre modèle avant que votre code ne la voie. Un client qui envoie + `"maybe"` pour un `bool` ne corrompt pas votre réservation : l’appel échoue avec une + erreur de non-conformité au schéma, votre `if` ne s’exécute jamais. + +## Envoyer l’utilisateur vers une URL {#send-the-user-to-a-url} + +Certaines choses ne doivent passer ni par le modèle ni par le client : identifiants, numéros de carte, consentement OAuth. Pour celles-là, vous ne demandez pas de données ; vous demandez à l’utilisateur d’aller quelque part : + +```python title="server.py" hl_lines="10-14 23" +--8<-- "docs_src/elicitation/tutorial002.py" +``` + +* `ctx.elicit_url()` prend le message, l’**URL** à visiter et un `elicitation_id` que vous choisissez : n’importe quelle chaîne qui identifie cette élicitation au sein de votre serveur. +* Le résultat contient une action et rien d’autre. `"accept"` signifie que l’utilisateur a accepté d’ouvrir l’URL, **pas** qu’il a terminé ce qui se trouve de l’autre côté. +* Le paiement a lieu hors bande, entre le navigateur de l’utilisateur et votre prestataire de paiement. Aucun contenu ne revient jamais par MCP. + +Regardez le second outil. Quand votre serveur apprend que le flux hors bande est terminé (un webhook, une interrogation périodique ; ici, c’est modélisé par un second outil), `ctx.session.send_elicit_complete(...)` envoie `notifications/elicitation/complete` avec le même `elicitation_id`. C’est ainsi que le client sait qu’il peut cesser d’afficher *« en attente du paiement… »*. Sans cela, le client ne peut que deviner. + +## Côté client {#the-client-side} + +Les serveurs demandent. Les clients répondent en passant une fonction de rappel (callback) **`elicitation_callback`** à `Client(...)` : + +```python title="client.py" hl_lines="6-7 18" +--8<-- "docs_src/elicitation/tutorial003.py" +``` + +* Une seule fonction de rappel gère les deux modes. `params` est une union de `ElicitRequestFormParams` et `ElicitRequestURLParams` ; `isinstance` fait le branchement. +* Pour une URL, vous montrez `params.url` à l’utilisateur et renvoyez l’action qu’il a choisie. Jamais de `content`. +* Pour un formulaire, une vraie application affiche `params.requested_schema` et renvoie la saisie de l’utilisateur comme `content`. Celle-ci dit toujours oui avec une réponse toute faite, ce qui est exactement la fonction de rappel que vous voulez dans un test. +* Passer la fonction de rappel constitue aussi la **déclaration de capacité** : c’est ainsi que le serveur apprend que ce client peut être interrogé. Les autres choses auxquelles un client peut répondre pour un serveur se trouvent dans **[Fonctions de rappel du client](../client/callbacks.md)**. + +!!! info + L’élicitation est une requête du *serveur* vers le *client*, et celles-ci n’existent que sur une + session à poignée de main (handshake) classique, c’est pourquoi ce client passe `mode="legacy"`. + Sur une connexion **2026-07-28**, un outil demande plutôt en *renvoyant* la question depuis l’appel ; + ce flux, ce sont les **[Requêtes à plusieurs allers-retours](multi-round-trip.md)**. + +### Essayer {#try-it} + +Démarrez le `server.py` en mode formulaire avec `ctx.elicit` (celui de `book_table`) sur Streamable HTTP (**[Exécuter votre serveur](../run/index.md)** donne la commande en une ligne), puis exécutez le `main()` du client et demandez à `book_table` le jour de Noël. + +La fonction de rappel affiche la question qui lui a été envoyée : + +```text +No tables for 2 on 2025-12-25. Would you like to try another date? +``` + +Elle répond avec `{"accept_alternative": True, "date": "2025-12-27"}`, et l’outil, qui attendait dans `await ctx.elicit(...)` pendant tout ce temps, termine la réservation : + +```text +Booked a table for 2 on 2025-12-27. +``` + +Remplacez-le maintenant par le `server.py` en mode URL et pointez le même `main()` vers `pay_deposit` : la même fonction de rappel prend l’autre branche, affiche le lien de paiement, et l’outil revient avec *« Complete the payment in your browser. »* Un aller-retour, en plein appel, dans les deux sens. + +!!! check + Retirez maintenant `elicitation_callback=` du `Client` et appelez de nouveau `book_table` pour le jour de Noël. + L’appel entier échoue avec une erreur de protocole : + + ```text + Elicitation not supported + ``` + + Un client qui n’a enregistré aucune fonction de rappel n’a jamais déclaré la capacité `elicitation`, il n’y a donc + personne à qui demander. Votre outil n’a pas reçu de `"decline"` ; il a reçu une exception. Concevez en conséquence : chaque + élicitation a besoin d’une réponse sensée à la question « et si je ne peux pas demander ? ». + +## Récapitulatif {#recap} + +* Un paramètre annoté `Annotated[T, Resolve(fn)]` est rempli par un résolveur, qui renvoie `Elicit(...)` quand il doit demander. Cela fonctionne sur toutes les connexions. +* Le schéma est un modèle Pydantic plat : des champs primitifs uniquement, validés au retour. +* `result.action` vaut `"accept"`, `"decline"` ou `"cancel"` ; `result.data` n’existe qu’en cas d’acceptation. +* `await ctx.elicit(message, schema=Model)` demande depuis l’intérieur du corps de l’outil, et `await ctx.elicit_url(message, url, elicitation_id)` sert à tout ce qui ne doit pas passer par le modèle (`ctx.session.send_elicit_complete(elicitation_id)` indique que la partie hors bande est terminée). Les deux sont des requêtes du serveur vers le client : elles nécessitent que le client soit sur une connexion historique. +* Le client répond avec une seule `elicitation_callback`, en branchant sur le type des params ; l’enregistrer, c’est ce qui déclare la capacité. +* Sur une connexion 2026-07-28, le serveur renvoie la question au lieu de la pousser ; la même fonction de rappel est alimentée par les **[Requêtes à plusieurs allers-retours](multi-round-trip.md)**. + +Tout ce qui se trouve sous ce retour (la boucle de réessai, la protection de `requestState`, le pilotage à la main) est dans **[Requêtes à plusieurs allers-retours](multi-round-trip.md)**. diff --git a/i18n/fr/pages/handlers/index.md b/i18n/fr/pages/handlers/index.md new file mode 100644 index 0000000000..59595ee542 --- /dev/null +++ b/i18n/fr/pages/handlers/index.md @@ -0,0 +1,24 @@ +--- +translation: + sections: [424930166c4bc6f3] + tool: 1 +--- +# Dans votre gestionnaire {#inside-your-handler} + +Les arguments d’un gestionnaire (handler) viennent du client. Tout ce qu’il peut lire *d’autre*, et tout ce qu’il peut faire pendant son exécution, se trouve ici. + +Ce qu’il peut lire : + +* **[L’objet Context](context.md)** est le seul paramètre supplémentaire que n’importe quel gestionnaire peut demander : la requête en cours, ses en-têtes, sa session, ainsi que les verbes de progression et de notification de changement. +* **[Les dépendances](dependencies.md)** sont des paramètres que le modèle ne voit jamais, renseignés par vos propres fonctions avec `Resolve`. +* **[Le cycle de vie](lifespan.md)** (lifespan) couvre l’état que votre serveur construit une seule fois au démarrage, et la façon dont un gestionnaire y accède via l’objet `Context`. + +Ce qu’il peut faire pendant son exécution : + +* Demander davantage d’informations à l’utilisateur avec **[l’élicitation](elicitation.md)** (elicitation), et les **[requêtes à plusieurs allers-retours](multi-round-trip.md)** (multi-round-trip), le mécanisme de la version 2026-07-28 qui la véhicule. +* Demander au client une complétion de LLM ou les dossiers de son espace de travail avec **[l’échantillonnage et les racines](sampling-and-roots.md)** (sampling et roots), obsolètes mais toujours pris en charge. +* Signaler la **[progression](progress.md)** d’une opération lente. +* Écrire des journaux (sur la sortie d’erreur standard, pour quiconque exploite le serveur) avec la **[journalisation](logging.md)**. +* Prévenir les clients abonnés que quelque chose a changé avec les **[abonnements](subscriptions.md)**. + +Si vous n’avez pas encore enregistré de gestionnaire, commencez par **[Outils](../servers/tools.md)**. Chaque page de cette section suppose que vous en avez un. diff --git a/i18n/fr/pages/handlers/lifespan.md b/i18n/fr/pages/handlers/lifespan.md new file mode 100644 index 0000000000..d583cb9eb3 --- /dev/null +++ b/i18n/fr/pages/handlers/lifespan.md @@ -0,0 +1,107 @@ +--- +translation: + sections: [f3ca8ac5f90f2dfa, 85a1ef3588ba0736, 563346d4d5804933, 9e3528340d0bab53] + tool: 1 +--- +# Cycle de vie {#lifespan} + +La plupart des vrais serveurs conservent quelque chose pendant toute leur durée de vie : un pool de connexions à la base de données, un client HTTP, un modèle chargé en mémoire. + +Vous ne voulez pas le reconstruire à chaque appel, et vous voulez le fermer proprement. C’est à cela que sert le **cycle de vie** (lifespan). + +## Un cycle de vie typé {#a-typed-lifespan} + +Un cycle de vie est un `@asynccontextmanager` qui reçoit le serveur et produit avec `yield` **un seul objet**. Ce que vous produisez ainsi reste accessible à chaque gestionnaire (handler) aussi longtemps que le serveur tourne. + +```python title="server.py" hl_lines="25-31 34 38 40" +--8<-- "docs_src/lifespan/tutorial001.py" +``` + +Lisez-le de bas en haut : + +* `app_lifespan` connecte la `Database` **avant** le `yield` et la déconnecte **après**, dans un `finally`. C’est le démarrage et l’arrêt. +* Il produit un `AppContext`, une simple dataclass qui contient ce que vous avez initialisé. Un champ aujourd’hui, dix demain. +* `MCPServer("Bookshop", lifespan=app_lifespan)` est tout le câblage nécessaire. +* Dans l’outil, l’objet produit est `ctx.request_context.lifespan_context`. + +Le cycle de vie s’exécute **une seule fois**. On y entre au démarrage du serveur (avant la première requête) et on en sort à l’arrêt du serveur. Toutes les requêtes entre les deux partagent le même `AppContext`. + +!!! info + Si vous avez déjà écrit un `lifespan` FastAPI, vous connaissez déjà tout cela. Même décorateur, même `yield`, même `finally`. + +### Ce que voit le modèle {#what-the-model-sees} + +Rien de nouveau. `ctx` est un paramètre **Context** : le SDK l’injecte et il n’atteint jamais le schéma d’entrée : + +```json +{ + "type": "object", + "properties": { + "genre": {"title": "Genre", "type": "string"} + }, + "required": ["genre"], + "title": "count_booksArguments" +} +``` + +`genre` est le seul argument que le modèle peut passer. Le cycle de vie, c’est l’affaire de votre serveur. + +Les fonctions `@mcp.resource()` et `@mcp.prompt()` peuvent elles aussi prendre un paramètre `ctx`, annoté d’un simple `Context` pour une raison que la section suivante explique. Tout ce que transporte `ctx` est décrit dans **[L’objet Context](context.md)**. + +### C’est réellement typé {#it-really-is-typed} + +Regardez de nouveau l’annotation : `ctx: Context[AppContext]`. + +Ce seul paramètre de type est la raison pour laquelle `ctx.request_context.lifespan_context` **est** un `AppContext` pour votre vérificateur de types. `.db` s’autocomplète ; `.dbb` est une erreur avant même que vous n’ayez lancé le serveur. + +Écrivez un simple `Context` à la place et `lifespan_context` est typé `dict[str, Any]` : le vérificateur de types n’a aucun moyen de savoir ce que votre cycle de vie a produit. L’objet est toujours là à l’exécution ; vous avez perdu l’assistance. + +!!! warning + `Context[AppContext]` est une écriture **réservée aux outils**. Mettez-la sur une fonction + `@mcp.resource()` ou `@mcp.prompt()` et chaque appel à ce gestionnaire échoue. Le client + reçoit une erreur en retour, et le journal du serveur montre pourquoi : + + ```text + Context is not available outside of a request + ``` + + Dans les ressources et les prompts, écrivez simplement `ctx: Context`. L’objet produit par + votre cycle de vie reste `ctx.request_context.lifespan_context` à l’exécution ; vous renoncez + au paramètre de type, pas à l’objet. + +!!! tip + Il y a toujours un cycle de vie. Si vous n’en passez pas, celui par défaut du SDK produit un + `dict` vide, si bien que `ctx.request_context.lifespan_context` vaut `{}`, jamais `None`. + Cette valeur par défaut explique aussi pourquoi un simple `Context` le type `dict[str, Any]`. + +## Le voir se produire {#watch-it-happen} + +« Le démarrage s’exécute avant la première requête » est le genre de phrase que vous ne devriez pas avoir à croire sur parole. + +Réduisez le serveur à son cycle de vie : donnez à `Database` un indicateur `connected`, basculez-le dans `connect()` et `disconnect()`, et ajoutez un outil qui en rend compte. + +```python title="server.py" hl_lines="11 14 17 25 44" +--8<-- "docs_src/lifespan/tutorial002.py" +``` + +`database` est défini au niveau du module pour une seule raison : pouvoir l’observer depuis *l’extérieur* du serveur. + +!!! check + Trois moments, trois valeurs : + + * Avant le démarrage du serveur, `database.connected` vaut `False`. Importer le module n’a rien connecté. + * Pendant qu’il tourne, appelez `database_status` et le résultat est `"connected"`. + * Arrêtez le serveur et le bloc `finally` s’exécute : `database.connected` vaut de nouveau `False`. + + Le travail s’est fait exactement là où vous l’avez placé : autour du `yield`, pas à l’import et pas à chaque requête. + +## Récapitulatif {#recap} + +* `lifespan=` prend un `@asynccontextmanager` qui reçoit le serveur et produit avec `yield` un seul objet. +* Le code avant le `yield` est le démarrage. Le `finally` qui suit est l’arrêt. +* Il s’exécute une seule fois, autour de toute la vie du serveur, pas à chaque requête. +* Ce que vous produisez avec `yield` est `ctx.request_context.lifespan_context` dans chaque outil, ressource et prompt. +* `ctx: Context[AppContext]` rend cet accès entièrement typé dans les outils. Les ressources et les prompts prennent le simple `Context`. +* Pas de `lifespan=` signifie un `dict` vide, jamais `None`. + +Un gestionnaire qui s’interrompt en plein appel pour demander à l’utilisateur quelque chose que lui seul connaît, c’est l’**[Élicitation](elicitation.md)**. diff --git a/i18n/fr/pages/handlers/logging.md b/i18n/fr/pages/handlers/logging.md new file mode 100644 index 0000000000..0030d4c3fa --- /dev/null +++ b/i18n/fr/pages/handlers/logging.md @@ -0,0 +1,86 @@ +--- +translation: + sections: [c93a3e1aefd77955, 7851abd5ec54393b, f49d1ca2f330f9cd, c03764bd9dfeef7b, 4a0391691a674ae4, 2df5cd279eabf9f5] + tool: 1 +--- +# Journalisation {#logging} + +Journalisez depuis un outil comme vous le feriez depuis n’importe quelle autre fonction Python : avec la bibliothèque standard. + +MCP possède une **capacité de journalisation** au niveau du protocole : un serveur pouvait envoyer ses messages de journal au client sous forme de notifications, via des méthodes de l’objet `Context`. La révision 2026-07-28 de la spécification **rend cette capacité obsolète sans la remplacer**, si bien que cette documentation ne l’enseigne pas. La liste complète de ce qui est obsolète, et de ce qu’il faut faire à la place, se trouve dans **[Fonctionnalités obsolètes](../deprecated.md)**. + +Ce que vous faites à la place, c’est ce que vous faites dans tout autre programme Python : utiliser la bibliothèque standard. + +## Un outil qui journalise {#a-tool-that-logs} + +```python title="server.py" hl_lines="1 5 13" +--8<-- "docs_src/logging/tutorial001.py" +``` + +* `logging.getLogger(__name__)` vous donne un logger nommé d’après votre module. Créez-le une seule fois, en haut du fichier. +* Dans l’outil, vous appelez `logger.info(...)` comme dans n’importe quelle autre fonction. Rien à injecter, rien à `await`, rien de spécifique à MCP. + +!!! check + Appelez l’outil et regardez le résultat complet : + + ```python + result.content # [TextContent(text="Found 3 books matching 'dune'.")] + result.structured_content # {'result': "Found 3 books matching 'dune'."} + ``` + + La ligne de journal n’y figure nulle part. La journalisation est faite pour **vous**, la personne qui exploite le serveur. Le modèle + ne la voit jamais. Si le modèle doit lire quelque chose, renvoyez-le avec `return`. + +## Où cela va {#where-it-goes} + +Pour un serveur **stdio**, cette question compte plus que d’habitude. L’hôte a lancé votre serveur comme sous-processus et lit les messages MCP depuis son **stdout**. La sortie d’erreur standard est à vous. + +La bibliothèque standard fait déjà ce qu’il faut : la sortie des journaux va vers `sys.stderr` par défaut. Vos lignes `logger.info(...)` arrivent dans le terminal (ou là où l’hôte collecte le stderr du sous-processus), et le flux du protocole reste propre. + +!!! tip + N’utilisez pas `print()` dans un serveur stdio. `print` écrit sur **stdout**, et stdout appartient au protocole. + Pendant qu’il sert, le SDK redirige vers stderr ce qui est effectivement *vidé* (flush) sur stdout, de sorte que cela ne peut pas corrompre + la liaison ; mais dans un processus à tampon par blocs, un `print()` reste généralement non vidé dans le tampon de `sys.stdout` + jusqu’à ce que l’interpréteur le purge à la sortie, directement sur le flux du protocole. Même lorsqu’elle est redirigée, + la ligne arrive brute au milieu de la sortie des journaux, sans niveau, sans nom de logger et sans aucun moyen de la filtrer. + + `logger.debug("got here")` demande le même effort d’une ligne et va au bon endroit. + +## Le niveau {#the-level} + +Vous n’avez pas à appeler `logging.basicConfig()` vous-même. La construction d’un `MCPServer` l’a déjà fait, avec un gestionnaire de journalisation pointé vers la sortie d’erreur standard, au niveau que vous passez via `log_level=` ; `MCPServer("Bookshop", log_level="DEBUG")` suffit donc pour voir vos lignes `logger.debug(...)`. + +La valeur par défaut est `"INFO"`. + +`logging.basicConfig()` ne remplace jamais des gestionnaires de journalisation qui existent déjà. Si vous configurez la journalisation vous-même avant de créer le serveur, votre configuration l’emporte. + +## Essayer {#try-it} + +Lancez le serveur avec le MCP Inspector : + +```console +uv run mcp dev server.py +``` + +Appelez `search_books` depuis l’onglet **Tools**. L’Inspector vous montre le résultat : uniquement la valeur de retour. La ligne + +```text +Searching for 'dune' +``` + +est partie vers la sortie d’erreur standard : le terminal, pas la liaison. + +!!! info + Si ce que vous voulez vraiment, c’est du *traçage* (chaque requête, sa durée, son éventuel échec), vous + ne voulez pas des lignes de journal, vous voulez des spans. Votre serveur en émet déjà : le SDK trace chaque + message avec OpenTelemetry par défaut. Voir **[OpenTelemetry](../run/opentelemetry.md)**. + +## Récapitulatif {#recap} + +* La capacité de journalisation du protocole MCP est rendue obsolète par la spécification 2026-07-28 et n’est pas remplacée. Ne construisez rien dessus. +* `logger = logging.getLogger(__name__)` au niveau du module, `logger.info(...)` dans l’outil. C’est tout le modèle à suivre. +* La sortie des journaux n’atteint jamais le modèle. Seule la valeur que vous renvoyez avec `return` y parvient. +* La sortie d’erreur standard est à vous ; stdout appartient au protocole. Pendant qu’il sert, le SDK redirige vers stderr ce qui s’égare sur stdout et est vidé, mais un `print()` non vidé peut encore se déverser sur la liaison à la sortie, et les lignes redirigées arrivent sans étiquette ; utilisez `logging`, dont le gestionnaire vide chaque enregistrement. +* `MCPServer(..., log_level="DEBUG")` fixe le niveau, et une configuration de journalisation que vous avez faite au préalable est laissée telle quelle. + +Prévenir les clients connectés que quelque chose a changé sur votre serveur (la liste des outils, une ressource), c’est l’affaire des **[Abonnements](subscriptions.md)**. diff --git a/i18n/fr/pages/handlers/multi-round-trip.md b/i18n/fr/pages/handlers/multi-round-trip.md new file mode 100644 index 0000000000..661c27856a --- /dev/null +++ b/i18n/fr/pages/handlers/multi-round-trip.md @@ -0,0 +1,192 @@ +--- +translation: + sections: [74011e683045eea9, 9b64cc175c18b6a9, 4b41be4824030397, e3b1502da786ec33, 71e41161f143c6a9, 9ec2c1eeb8c36378, 8dd027377d46448b, f81491125dcbfe8b] + tool: 1 +--- +# Requêtes à plusieurs allers-retours (multi-round-trip) {#multi-round-trip-requests} + +Parfois, un outil ne peut pas terminer en un seul aller-retour. Il lui faut quelque chose que seul l’utilisateur détient : un choix, une confirmation, un identifiant d’accès. + +Avant la version 2026-07-28, le serveur l’obtenait en **rappelant** le client : il ouvrait sa propre requête vers le client — une élicitation (elicitation), un appel d’échantillonnage (sampling) — au beau milieu du traitement de la requête d’origine. La spécification 2026-07-28 retire ce canal de retour (back-channel). + +À la place, le serveur **renvoie un résultat**. + +## Renvoyer, ne pas rappeler {#return-dont-call-back} + +Le serveur répond à `tools/call` par un **`InputRequiredResult`** au lieu d’un `CallToolResult`. Deux de ses champs font le travail : + +* **`input_requests`** : ce qu’il manque encore au serveur, sous la forme d’un dictionnaire dont les clés sont des noms choisis par le serveur. Chaque valeur est une `ElicitRequest`, une `CreateMessageRequest` ou une `ListRootsRequest`. +* **`request_state`** : un jeton opaque. Le client le renvoie tel quel lors de la nouvelle tentative. Votre serveur est le seul à le lire. + +Le client satisfait chaque requête, puis appelle **à nouveau le même outil**, en transportant ses réponses dans `input_responses` et le jeton dans `request_state`. Le serveur dispose désormais de ce qui lui manquait et renvoie un `CallToolResult` normal. + +C’est tout le protocole. Chaque étape est une requête ordinaire du client vers le serveur. Rien ne circule jamais dans l’autre sens. + +## Côté serveur {#the-server-side} + +Avec `@mcp.tool()`, vous construisez rarement cela à la main : déclarez une dépendance qui interroge l’utilisateur (`Elicit`), échantillonne le LLM du client (`Sample`) ou liste ses racines (roots) (`ListRoots`), et le SDK renvoie l’objet `InputRequiredResult` à votre place ; cette forme fait l’objet de la page **[Dépendances](dependencies.md)**. Les deux formes ne se mélangent pas : un appel ne dispose que d’un seul canal `input_responses`/`request_state`, si bien qu’un outil qui utilise des paramètres `Resolve(...)` ne peut pas en plus renvoyer un `InputRequiredResult` depuis son corps. Un retour `InputRequiredResult` déclaré est refusé à l’enregistrement (`InvalidSignature`), et un retour non déclaré fait échouer l’appel à l’exécution. La forme manuelle, c’est le `Server` **bas niveau**, dont le gestionnaire (handler) `on_call_tool` a le droit de renvoyer l’un ou l’autre type de résultat : + +```python title="server.py" hl_lines="43-46" +--8<-- "docs_src/mrtr/tutorial001.py" +``` + +* `on_call_tool` est typé `-> CallToolResult | InputRequiredResult`. Renvoyer le second constitue toute l’API côté serveur. +* Au premier appel, `params.input_responses` vaut `None` : la garde se déclenche et le gestionnaire pose la question au lieu de répondre. +* Lors de la nouvelle tentative, le résultat `ElicitResult` envoyé par le client se trouve sous la **même clé** (`"region"`) que celle utilisée par le serveur dans `input_requests`. + +Tout le reste de ce fichier (le `input_schema` explicite, le `CallToolResult` construit à la main) relève du `Server` bas niveau ordinaire, traité dans **[Le Server bas niveau](../advanced/low-level-server.md)**. Cette page n’ajoute que le second type de retour. + +## Au-delà des outils {#beyond-tools} + +`tools/call` n’a rien de particulier : en version 2026-07-28, un serveur peut répondre de la même façon à `prompts/get` et à `resources/read`. Sur `MCPServer`, une fonction `@mcp.prompt()` — ou une fonction `@mcp.resource()` **modèle** (template) — renvoie elle-même l’objet `InputRequiredResult` et lit les réponses de la nouvelle tentative dans le contexte : + +```python title="server.py" hl_lines="20 22 24" +--8<-- "docs_src/mrtr/tutorial004.py" +``` + +* Le premier tour renvoie l’objet `InputRequiredResult`. Lors de la nouvelle tentative, `ctx.input_responses` contient les réponses sous les mêmes clés et la fonction renvoie son résultat ordinaire — ici des messages de prompt, du contenu de ressource pour une ressource modèle. +* Un `request_state` que vous définissez est scellé avant de franchir la liaison et vérifié à son retour en écho, comme tout le reste côté serveur ; **[Protéger `requestState`](#protecting-requeststate)** ci-dessous explique ce que le sceau vous apporte et quand vous devez configurer des clés. +* Une fonction `@mcp.tool()` peut renvoyer le résultat directement de la même façon, quand la forme par dépendance ne convient pas. +* Les fonctions `@mcp.resource()` statiques ne participent pas : elles ne prennent pas de `Context`, elles ne pourraient donc jamais lire la nouvelle tentative. Seules les ressources modèles peuvent poser une question. +* Les règles de génération ci-dessous s’appliquent telles quelles : renvoyer un `InputRequiredResult` sur une session antérieure à 2026 donne le même `-32603` que celui décrit par l’avertissement. + +## Côté client {#the-client-side} + +`Client` exécute la boucle pour vous. + +Enregistrez les fonctions de rappel (callbacks) que le serveur pourrait solliciter (`elicitation_callback`, `sampling_callback`, `list_roots_callback`) et appelez l’outil. Quand un `InputRequiredResult` arrive, `Client` répartit chaque entrée de `input_requests` vers la fonction de rappel correspondante, relance l’appel avec les réponses et le `request_state` renvoyé en écho, et continue jusqu’à ce qu’un `CallToolResult` revienne : + +```python title="client.py" hl_lines="11 12" +--8<-- "docs_src/mrtr/tutorial003.py" +``` + +* Cette `elicitation_callback` est celle-là même qu’aurait atteinte le `elicitation/create` du canal de retour d’un serveur antérieur à 2026. Il en va de même de `sampling_callback` pour `sampling/createMessage` et de `list_roots_callback` pour `roots/list` : en version 2026-07-28, les RPC autonomes du serveur vers le client ont disparu, mais les charges utiles `ElicitRequest` / `CreateMessageRequest` / `ListRootsRequest`, identiques, voyagent à l’intérieur de `input_requests` et sont distribuées aux trois mêmes fonctions de rappel. Un seul jeu de fonctions de rappel sert les deux générations. +* `call_tool` renvoie un simple `CallToolResult`. Les tours intermédiaires sont invisibles pour l’appelant. +* `get_prompt` et `read_resource` pilotent la même boucle. + +!!! check + Omettez la fonction de rappel et la boucle échoue dès le premier tour : la fonction de rappel + de substitution du SDK répond à chaque élicitation par une erreur, et `call_tool` lève une + `MCPError` avec le message *« Elicitation not supported »*. + +La boucle est bornée. `Client(..., input_required_max_rounds=10)` est le plafond par défaut ; un serveur qui continue de renvoyer des `InputRequiredResult` au-delà fait lever une exception à `call_tool`. Si un tour ne transporte que `request_state` sans `input_requests`, `Client` marque une courte pause (50 ms, doublés jusqu’à un plafond de 250 ms) avant de réessayer, de sorte qu’un serveur qui se contente de dire *« pas encore terminé »* ne soit pas sollicité en boucle. + +### Piloter la boucle vous-même {#driving-the-loop-yourself} + +La boucle automatique suffit pour un client à processus unique. Prenez plutôt la boucle en main quand : + +* Votre client est **distribué** : le processus qui affiche la question à l’utilisateur n’est pas celui qui a appelé `call_tool`, c’est donc un autre worker qui émet la nouvelle tentative. `request_state` est le jeton persistant que vous transportez à travers cette frontière, via votre propre stockage, et `input_responses` est ce que l’autre côté renvoie avec lui. +* Vous voulez **inspecter** chaque tour : journaliser ou auditer chaque entrée de `input_requests`, refuser certains types de requêtes, ou appliquer votre propre temporisation entre les étapes. +* Vous voulez une borne en **temps réel** plutôt qu’en nombre de tours : enveloppez votre propre boucle dans `anyio.fail_after(...)` au lieu de compter sur `input_required_max_rounds`. + +Descendez à la session sous-jacente, où `allow_input_required=True` vous remet directement l’union : + +```python title="client.py" hl_lines="12 13 19" +--8<-- "docs_src/mrtr/tutorial002.py" +``` + +* `client.session.call_tool(..., allow_input_required=True)` élargit le type de retour à `CallToolResult | InputRequiredResult`. C’est le `isinstance` qui le resserre à nouveau. +* `request_state` est désormais entre vos mains. Notez-le entre deux étapes et la conversation peut reprendre depuis un processus tout neuf. +* Pour chaque entrée de `input_requests`, vous placez une `InputResponse` sous la **même clé** dans `input_responses`. `fulfil` est l’endroit où va votre interface utilisateur ; celle-ci code la réponse en dur. +* Même nom d’outil, mêmes `arguments`, à chaque étape. La nouvelle tentative, c’est l’appel d’origine exécuté de nouveau, pas une nouvelle méthode. + +## Protéger `requestState` {#protecting-requeststate} + +Tout ce qui précède traite `request_state` comme un écho, et sur la liaison ce n’est rien d’autre. Mais le client le conserve entre deux étapes (le noter pour le passer d’un processus à l’autre est précisément ce que la section précédente a approuvé), si bien que ce qui revient est une **entrée fournie par le client** : elle peut avoir été modifiée, avoir expiré, ou avoir été prélevée sur un tout autre appel. La spécification impose aux serveurs de protéger l’intégrité de cet état et de rejeter le tour quand la vérification échoue, dès lors que l’état peut influencer l’autorisation, l’accès aux ressources ou la logique métier. + +`MCPServer` le protège par défaut. Chaque serveur scelle le `requestState` sortant et vérifie chaque écho — l’état des résolveurs comme l’état construit à la main — sous une clé générée au démarrage du processus. Vous ne configurez rien, vous écrivez du texte en clair et vous lisez du texte en clair ; la liaison ne transporte jamais qu’un jeton chiffré opaque. + +La clé par défaut vit et meurt avec le processus ; c’est la seule chose que vous devez savoir avant de déployer au-delà d’un processus unique : + +```python +from mcp.server.mcpserver import MCPServer, RequestStateSecurity + +# Multi-instance or restart-surviving: one or more shared secret keys (>= 32 bytes each). +mcp = MCPServer("fleet", request_state_security=RequestStateSecurity(keys=[key])) +``` + +* **La valeur par défaut (aucune configuration)** convient à un processus unique : stdio, ou exactement un worker HTTP. Une nouvelle tentative qui atterrit sur un autre worker, une autre instance derrière un répartiteur de charge, ou le même serveur après un redémarrage, est scellée sous une clé que ce processus ne possède pas — le client reçoit le rejet figé ci-dessous et doit recommencer le flux depuis le début. +* **`keys=[...]`** est obligatoire dès qu’une nouvelle tentative peut atteindre une **autre instance** (`uvicorn` à plusieurs workers, HTTP derrière répartiteur de charge) ou doit survivre aux redémarrages : chaque instance vérifie ce que n’importe quelle instance sœur a émis. Même mécanique, votre secret à la place d’un secret généré. +* Pour votre propre cryptographie, par exemple un KMS ou un service de jetons existant, passez `RequestStateSecurity(codec=...)` au lieu de `keys` ; **[Apporter votre propre cryptographie](#bring-your-own-crypto)** ci-dessous décrit le contrat. + +### Ce que porte le sceau {#what-the-seal-carries} + +Par défaut ou configuré, `requestState` sur la liaison est un jeton chiffré et authentifié. Votre code ne le voit jamais : gestionnaires et résolveurs écrivent du texte en clair et lisent du texte en clair (`ctx.request_state`) ; le SDK scelle à la sortie et vérifie à l’entrée. Au-delà de l’intégrité, chaque jeton est rattaché à : + +* **Une fenêtre temporelle.** Chaque tour scelle de nouveau avec une échéance fraîche, si bien que `RequestStateSecurity(ttl=...)` (600 secondes par défaut) borne le temps de réflexion par tour, pas le flux entier. +* **Le principal authentifié.** Quand la requête porte un jeton d’accès OAuth validé par le SDK, l’état est rattaché au client, à l’émetteur et au sujet du jeton : un état émis pour un utilisateur échoue pour un autre, même quand les deux utilisateurs partagent un même client OAuth. Un vérificateur qui ne fournit aucun sujet réduit le rattachement à la seule identité du client, laquelle, avec des identifiants de client fondés sur une URL, est partagée par tous les utilisateurs de ce logiciel client. Quand l’authentification se termine en dehors du SDK (un proxy frontal), ou que le transport n’est pas authentifié, il n’y a aucun principal auquel se rattacher et cette vérification est inerte, sauf si `RequestStateSecurity(bind_principal=...)` en fournit un à partir de votre propre signal d’identité. Quels que soient les composants que votre vérificateur de jetons fournit, il doit les fournir de façon cohérente : un vérificateur qui inclut le sujet sur certaines requêtes et l’omet sur d’autres change de principal en plein flux, et les tours en cours sont rejetés. +* **La requête d’origine.** La méthode, le nom de l’outil ou du prompt (ou l’URI de la ressource), et une empreinte des arguments. Un jeton rejoué contre un autre outil, d’autres arguments ou une autre méthode échoue. +* **La question exacte posée.** Chaque réponse de résolveur est épinglée à la question rendue qui a été montrée au client, aussi bien au tour où elle arrive pour la première fois que lorsqu’une réponse enregistrée est réutilisée plus tard. Redéployez avec un message reformulé ou un schéma modifié et le serveur repose la question au lieu de consommer une réponse périmée. Le même épinglage joue aussi dans l’autre sens : dérivez les messages des arguments de l’outil, pas de données propres à chaque appel. Un message construit à partir d’un horodatage ou d’un taux en direct se rend différemment à chaque tour, si bien que chaque réponse enregistrée paraît périmée et que le serveur repose la question jusqu’à ce que la limite de tours du client mette fin à l’appel. + +Tout cela est le travail du SDK, pas le vôtre, ni celui du codec si vous apportez le vôtre. + +### Rotation des clés {#rotating-keys} + +`keys[0]` scelle le nouvel état ; chaque clé de la liste vérifie. Une rotation sans interruption se fait en trois phases, chacune entièrement déployée avant la suivante : + +```python +RequestStateSecurity(keys=[OLD, NEW]) # 1: every instance learns to verify NEW; OLD still mints +RequestStateSecurity(keys=[NEW, OLD]) # 2: NEW mints; in-flight OLD state keeps verifying +RequestStateSecurity(keys=[NEW]) # 3: one ttl after phase 2 is fully out, retire OLD +``` + +Ne promouvez jamais la clé d’émission en premier : émettre sous une clé qu’une instance ne sait pas encore vérifier fait tomber des tours en cours au milieu du déploiement. + +Les clés sont limitées à un seul service. L’enveloppe scellée porte aussi le nom du serveur comme revendication d’audience, si bien qu’un jeton émis par un autre service qui se trouverait partager un secret est rejeté de toute façon. La revendication n’est distinctive que dans la mesure où le nom l’est : un serveur doté d’une politique explicite doit donc avoir un vrai nom ou définir `RequestStateSecurity(audience=...)` — un serveur sans nom lève une exception à la construction. `audience=` sert aussi aux topologies multi-services délibérées où un service doit accepter un état émis par un autre. (La valeur par défaut sans configuration est exemptée : sa clé ne quitte jamais le processus, la revendication d’audience n’a donc rien à ajouter.) + +### Apporter votre propre cryptographie {#bring-your-own-crypto} + +`RequestStateSecurity(codec=...)` accepte tout objet doté de `seal(bytes) -> str` et `unseal(str) -> bytes` qui lève `InvalidRequestState` pour tout jeton qu’il n’a pas émis. La forme classique est le chiffrement d’enveloppe adossé à un KMS : vous déchiffrez une clé de données une seule fois au démarrage et gardez la cryptographie par jeton en local : + +```python title="server.py" hl_lines="12 26-27 34-35 38" +--8<-- "docs_src/mrtr/tutorial005.py" +``` + +Le TTL, le rattachement au principal et le rattachement à la requête ne sont **pas** l’affaire du codec : le SDK les inscrit dans la charge utile avant `seal` et les revérifie après `unseal`, pour chaque codec. Les seules obligations d’un codec sont l’intégrité (altéré signifie lever une exception) et, idéalement, la confidentialité. + +### Quand la vérification échoue {#when-verification-fails} + +Chaque échec entrant, qu’il s’agisse d’un jeton altéré, expiré, rejoué contre une autre requête ou un autre principal, ou scellé sous une clé que ce serveur ne connaît pas, reçoit la même réponse : + +```json +{"code": -32602, "message": "Invalid or expired requestState"} +``` + +Un seul message figé pour toutes les causes, afin que la liaison ne révèle jamais quelle vérification a échoué ; la vraie raison va dans le journal du serveur. Chaque `requestState` entrant sur `tools/call`, `prompts/get` et `resources/read` est vérifié, y compris celui qui arrive pour un gestionnaire qui n’émet jamais d’état. Le rejet le plus courant en pratique n’est pas un attaquant — c’est la clé par défaut, locale au processus, qui rencontre une nouvelle tentative antérieure à un redémarrage ou venue d’une autre instance ; le client relance le flux, et `keys=[...]` est le correctif quand cela compte. + +### État construit à la main {#hand-built-state} + +Un `request_state` que vous définissez vous-même (en renvoyant `InputRequiredResult` depuis une fonction d’outil, de prompt ou de modèle de ressource) est scellé et vérifié par la même mécanique que l’état des résolveurs, sans aucune modification de code : écrivez du texte en clair, lisez du texte en clair, et chaque rattachement ci-dessus s’applique. + +La seule chose que le SDK ne peut pas épingler pour vous, même configuré, c’est l’identité de la question : il ne sait pas à laquelle de *vos* questions appartient une réponse présente dans votre état. Si vous stockez des réponses indexées par question, incluez votre propre identifiant de question dans l’état et vérifiez-le lors de la nouvelle tentative. + +Le `Server` bas niveau est le niveau sans rien de fourni d’office : contrairement à `MCPServer`, rien n’est scellé tant que vous n’ajoutez pas vous-même la frontière, et votre `request_state` franchit la liaison exactement tel qu’écrit jusqu’à ce que vous le fassiez. L’activation en une ligne est montrée dans **[Le Server bas niveau](../advanced/low-level-server.md#the-other-handlers)**. + +## Un résultat de la version 2026-07-28 {#a-2026-07-28-result} + +`InputRequiredResult` n’existe qu’en version de protocole **2026-07-28**. Le `Client(server)` en mémoire la négocie pour vous ; sur la liaison, `mode="auto"` la découvre. Une fois connecté, `client.protocol_version` vous dit ce que vous avez obtenu. + +!!! warning + Une session antérieure à 2026 n’a nulle part où mettre un `InputRequiredResult`. Renvoyez-en + un depuis votre gestionnaire sur une connexion `mode="legacy"` et l’exécuteur ne peut pas le + sérialiser dans la version négociée ; le client reçoit en retour une erreur `-32603` + *« Handler returned an invalid result »*. Un serveur qui sert les deux générations doit vérifier + `ctx.protocol_version` avant d’y recourir. + +!!! info + L’**élicitation en mode URL** emprunte exactement ce mécanisme sur une connexion 2026. L’entrée + dans `input_requests` est une `ElicitRequest` dont les params sont `ElicitRequestURLParams` ; + l’utilisateur termine le flux hors bande et votre client relance l’appel. Même boucle, aucune + nouvelle API. La moitié serveur haut niveau se trouve dans **[Élicitation](elicitation.md)**. + +## Récapitulatif {#recap} + +* En version 2026-07-28, un serveur qui a besoin d’une entrée en cours d’appel **renvoie** un `InputRequiredResult`. Il n’ouvre jamais de requête vers le client. +* `input_requests` est ce dont il a besoin. `request_state` est un jeton de reprise opaque que seul le serveur lit. +* `Client` exécute la boucle de nouvelles tentatives pour vous : enregistrez `elicitation_callback` / `sampling_callback` / `list_roots_callback` et `call_tool` renvoie un simple `CallToolResult`. `input_required_max_rounds` (10 par défaut) la borne. +* Pour inspecter ou persister les tours, utilisez `client.session.call_tool(..., allow_input_required=True)` et prenez vous-même en main la boucle `while isinstance(result, InputRequiredResult)`. +* Avec `@mcp.tool()`, une dépendance qui interroge l’utilisateur produit ce résultat pour vous (**[Dépendances](dependencies.md)**) ; le `Server` **bas niveau** est la forme manuelle. +* Les prompts et les ressources participent aussi : une fonction `@mcp.prompt()` ou une fonction `@mcp.resource()` modèle renvoie elle-même l’objet `InputRequiredResult` et lit `ctx.input_responses` lors de la nouvelle tentative. +* `requestState` revient sous forme d’entrée fournie par le client, donc `MCPServer` le scelle par défaut — l’état des résolveurs comme l’état construit à la main — sous une clé locale au processus ; les déploiements multi-instances passent `RequestStateSecurity(keys=[...])` (ou un codec personnalisé) pour que chaque instance puisse vérifier ce qu’une instance sœur a émis. Le sceau rattache chaque jeton à une fenêtre temporelle, à la requête d’origine et au principal authentifié lorsque la requête porte une authentification validée par le SDK ou que `bind_principal=` fournit votre propre signal d’identité (**[Protéger `requestState`](#protecting-requeststate)**). + +C’est le mécanisme qui remplace l’échantillonnage à l’initiative du serveur et le reste du canal de retour de type push ; voir **[Fonctionnalités obsolètes](../deprecated.md)**. diff --git a/i18n/fr/pages/handlers/progress.md b/i18n/fr/pages/handlers/progress.md new file mode 100644 index 0000000000..29182ac1a8 --- /dev/null +++ b/i18n/fr/pages/handlers/progress.md @@ -0,0 +1,112 @@ +--- +translation: + sections: [5315262fe26b33e1, 9d8e98840f1b78f0, 0284b215e85366c4, 8534d8dbb4053a70, 2966fac6fe697007] + tool: 1 +--- +# Progression {#progress} + +Un outil qui met trente secondes et ne dit rien pendant trente secondes a l’air cassé. + +Les **notifications de progression** règlent cela. L’outil indique où il en est ; le client décide quoi en afficher : une barre, une roue qui tourne, une ligne de journal. + +## La signaler depuis l’outil {#report-it-from-the-tool} + +Prenez un paramètre **`Context`** et appelez `report_progress` : + +```python title="server.py" hl_lines="8 11" +--8<-- "docs_src/progress/tutorial001.py" +``` + +Trois arguments, et c’est vous qui décidez de leur sens : + +* `progress` : où vous en êtes. La spécification exige qu’il **augmente** à chaque signalement ; ne répétez jamais une valeur et ne revenez jamais en arrière. +* `total` : la quantité totale, si vous la connaissez. Optionnel. +* `message` : une ligne lisible par un humain à propos de *cette* étape. Optionnel. + +`ctx` est injecté grâce à son annotation de type et le modèle ne le voit jamais : le schéma d’entrée de `import_catalog` a une seule propriété, `urls`. La page **[L’objet Context](context.md)** est entièrement consacrée à cet objet ; la progression est l’une des choses qu’il vous apporte. + +## L’écouter depuis le client {#listen-for-it-from-the-client} + +Le client active la fonctionnalité **appel par appel**, en passant `progress_callback=` à `call_tool` : + +```python title="client.py" hl_lines="7 16" +import anyio +from mcp import Client + +from server import mcp + + +async def show(progress: float, total: float | None, message: str | None) -> None: + print(f"{message} ({progress}/{total})") + + +async def main() -> None: + async with Client(mcp) as client: + result = await client.call_tool( + "import_catalog", + {"urls": ["https://example.com/a.json", "https://example.com/b.json"]}, + progress_callback=show, + ) + print(result.structured_content) + + +anyio.run(main) +``` + +La fonction de rappel (callback) est une fonction `async` qui prend exactement ce que le serveur a signalé : `progress`, `total`, `message`. + +!!! info + `Client(mcp)` se connecte directement à l’objet serveur, en mémoire : c’est le même client que celui sur lequel repose la page **[Tests](../get-started/testing.md)**. `progress_callback` est le même paramètre quel que soit le transport qu’utilise le `Client` ; le *timing* que vous allez observer est celui de la connexion en mémoire. Elle exécute votre fonction de rappel de façon synchrone, si bien que chaque signalement arrive avant que `call_tool` ne renvoie. Sur un vrai transport, les notifications font la course avec le résultat, et une fonction de rappel lente peut encore être en cours d’exécution après le retour de `call_tool`. + +### Essayer {#try-it} + +Placez `client.py` à côté de `server.py` et lancez-le : + +```console +python client.py +``` + +```text +Imported https://example.com/a.json (1/2) +Imported https://example.com/b.json (2/2) +{'result': 'Imported 2 records.'} +``` + +Chaque `await ctx.report_progress(...)` côté serveur est devenu un appel à `show` côté client, dans l’ordre, et les deux lignes se sont affichées **avant** que `call_tool` ne renvoie. La progression n’est pas empaquetée dans le résultat ; elle est diffusée pendant que l’outil travaille encore. + +!!! warning + `progress_callback` appartient à l’**appel**, pas au `Client`. Il n’existe aucun argument de constructeur pour cela, parce que des appels différents veulent des fonctions de rappel différentes : l’un pilote une barre de téléchargement, le suivant une ligne de journal. + +!!! check + Maintenant, supprimez `progress_callback=show` et relancez : + + ```text + {'result': 'Imported 2 records.'} + ``` + + Aucune erreur, aucun avertissement, même résultat. `report_progress` **ne fait rien quand l’appelant n’a pas demandé la progression** : vous signalez donc sans condition et n’avez jamais à vous demander si quelqu’un écoute. + +## Quand vous ne connaissez pas le total {#when-you-dont-know-the-total} + +`total` sert quand vous connaissez le dénominateur. Souvent, ce n’est pas le cas : vous videz un flux, parcourez un curseur, téléchargez quelque chose sans en-tête de longueur. + +Omettez-le : + +```python title="server.py" hl_lines="20" +--8<-- "docs_src/progress/tutorial002.py" +``` + +La fonction de rappel reçoit `total=None`. Un client peut toujours montrer une *activité* (« 3 importés jusqu’ici… ») mais il ne peut pas afficher de pourcentage. N’inventez pas un total pour obtenir une plus jolie barre. + +!!! tip + `progress` n’a pas à compter quelque chose de précis. Octets, lignes, pages : choisissez l’unité que l’utilisateur reconnaîtrait, et ne promettez qu’un `total` que vous pouvez tenir. + +## Récapitulatif {#recap} + +* `await ctx.report_progress(progress, total=None, message=None)` depuis n’importe quel outil qui prend un `Context`. +* Le client passe `progress_callback=` à `call_tool` : appel par appel, jamais sur le `Client`. +* La fonction de rappel est `async (progress, total, message) -> None` et se déclenche pendant que l’outil s’exécute encore. +* Sans fonction de rappel sur l’appel, `report_progress` ne fait rien. Signalez sans condition. +* Omettez `total` quand vous ne le connaissez pas ; la fonction de rappel reçoit `None`. + +La progression est ce qu’un outil en cours d’exécution montre à l’*utilisateur*. Les lignes qu’il journalise pour *vous*, la personne qui exploite le serveur, passent par un autre canal : la **[journalisation](logging.md)**. diff --git a/i18n/fr/pages/handlers/sampling-and-roots.md b/i18n/fr/pages/handlers/sampling-and-roots.md new file mode 100644 index 0000000000..f5632d0603 --- /dev/null +++ b/i18n/fr/pages/handlers/sampling-and-roots.md @@ -0,0 +1,51 @@ +--- +translation: + sections: [5c82b20cbd65ded0, 9dc22632be79a533, 1fb8f452e990c456, 42666ab914ff0cb1, c4e0cb3667fd5ff9] + tool: 1 +--- +# Échantillonnage et racines {#sampling-and-roots} + +Un gestionnaire (handler) peut demander deux choses de plus au client connecté : une complétion produite par le modèle du client lui-même — l’**échantillonnage** (sampling) — et les dossiers de l’espace de travail du client — les **racines** (roots). + +Les deux fonctionnent toujours, sur toutes les versions du protocole que le SDK parle. Mais lisez l’avertissement avant de concevoir quoi que ce soit autour d’elles : + +!!! warning "Rendus obsolètes par la spécification 2026-07-28" + L’échantillonnage et les racines sont obsolètes depuis la version `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2577)). Ils restent pleinement fonctionnels et demeurent dans la spécification pendant au moins douze mois avant de pouvoir être supprimés, mais les nouvelles implémentations ne devraient pas s’appuyer dessus. Les migrations suggérées : intégrez-vous directement à l’API de votre fournisseur de LLM au lieu de l’échantillonnage, et transmettez les répertoires via des paramètres d’outil, des URI de ressource ou la configuration du serveur au lieu des racines. La liste complète pour le SDK se trouve dans **[Fonctionnalités obsolètes](../deprecated.md)**. + +## Échantillonnage : emprunter le modèle du client {#sampling-borrow-the-clients-model} + +Un résolveur renvoie `Sample(...)` et l’outil reçoit la complétion, par le même mécanisme de dépendances qui exécute `Elicit` dans **[Dépendances](dependencies.md)** : + +```python title="server.py" hl_lines="10-15 19" +--8<-- "docs_src/sampling_and_roots/tutorial001.py" +``` + +* `Sample(messages, max_tokens=...)` reprend les paramètres de `sampling/createMessage`. La valeur injectée est le `CreateMessageResult` du client ; passez `tools` ou `tool_choice` et elle devient un `CreateMessageResultWithTools`. +* Le client doit avoir déclaré la capacité `sampling` (`sampling.tools` si vous passez `tools` ou `tool_choice`). S’il ne l’a pas fait, l’appel échoue avec une erreur de protocole `-32021` au lieu d’envoyer une requête que le client ne peut pas traiter. Une session antérieure à 2026 sans canal de retour (back-channel) échoue avec son erreur habituelle d’absence de canal de retour, puisqu’il n’y a rien sur quoi envoyer. +* En version `2026-07-28`, la requête est acheminée dans le flux à plusieurs allers-retours (multi-round-trip) (**[Requêtes à plusieurs allers-retours](multi-round-trip.md)**) ; en version `2025-11-25`, c’est une requête autonome adressée au client. Le code est le même dans les deux cas, mais gardez à l’esprit la règle des requêtes à plusieurs allers-retours : la requête doit être rendue à l’identique d’une tentative à l’autre, construisez-la donc uniquement à partir des arguments de l’outil et d’autres données stables. +* Ne touchez pas à `include_context` : les valeurs autres que `"none"` sont elles-mêmes obsolètes (SEP-2596) et exigent une capacité que presque aucun client ne déclare. + +## Racines : où cela doit-il aller ? {#roots-where-should-this-go} + +Les racines sont les dossiers sur lesquels le client indique que le serveur peut opérer. Ce sont des indications à titre informatif, pas un mécanisme de contrôle d’accès. Un résolveur renvoie `ListRoots()` : + +```python title="server.py" hl_lines="10-11 15" +--8<-- "docs_src/sampling_and_roots/tutorial002.py" +``` + +* Le `ListRootsResult` injecté contient une liste de `Root` : un URI `file://` et un nom d’affichage facultatif. +* Le garde-fou est le même que pour l’échantillonnage : sans capacité `roots` déclarée, l’appel échoue avec `-32021` au lieu d’envoyer la requête. + +De l’autre côté de la liaison, le client répond aux deux requêtes avec les fonctions de rappel (callbacks) dont il dispose déjà : `sampling_callback` et `list_roots_callback`, décrites dans **[Fonctions de rappel du client](../client/callbacks.md)**. + +## Sur les connexions de génération 2025 {#on-2025-era-connections} + +`ctx.session.create_message(...)` et `ctx.session.list_roots()` existent toujours pour le code qui pilote la session directement. Elles ne fonctionnent que là où un canal de retour existe (connexions de génération 2025 qui ne sont pas sans état), et les appeler déclenche un avertissement d’obsolescence. Les marqueurs de résolveur ci-dessus sont la forme prise en charge : ils choisissent le mode d’acheminement d’après la version négociée et n’émettent pas d’avertissement. + +## Récapitulatif {#recap} + +* Renvoyez `Sample(...)` ou `ListRoots()` depuis un résolveur ; l’outil reçoit le `CreateMessageResult` ou le `ListRootsResult` comme n’importe quelle autre dépendance. +* Le client doit déclarer la capacité correspondante, sinon l’appel échoue avec `-32021` au lieu qu’une requête soit envoyée. +* Les deux fonctionnalités sont obsolètes en version `2026-07-28` : pleinement fonctionnelles pour l’instant, inadaptées aux nouvelles conceptions. Préférez les API des fournisseurs à l’échantillonnage et les paramètres explicites aux racines. + +Indiquer l’avancement d’un outil lent : **[Progression](progress.md)**. diff --git a/i18n/fr/pages/handlers/subscriptions.md b/i18n/fr/pages/handlers/subscriptions.md new file mode 100644 index 0000000000..17aa6eace4 --- /dev/null +++ b/i18n/fr/pages/handlers/subscriptions.md @@ -0,0 +1,152 @@ +--- +translation: + sections: [60a9de8a0bdaa531, 317bbe7e4355cdcc, a61d660c8029e04a, 8f7e82fcb88df8a9, b165db51249ff8ed, 266f56fb798068a4, 7c0e57030b622139, df18d7c2417a9883] + tool: 1 +--- +# Abonnements {#subscriptions} + +Le catalogue d’un serveur n’est pas figé. Des outils apparaissent à l’exécution, et le contenu derrière l’URI d’une ressource change. + +**Les abonnements** sont le moyen par lequel un client en est informé. Le client envoie une seule requête `subscriptions/listen`, et la réponse à cette requête *est* le flux : elle reste ouverte et transporte les notifications de changement que le client a demandées. + +## Publier depuis l’outil {#publish-it-from-the-tool} + +Votre part se résume à une ligne : publier le changement. + +```python title="server.py" hl_lines="20 32" +--8<-- "docs_src/subscriptions/tutorial001.py" +``` + +* `await ctx.notify_resource_updated("board://sprint")` atteint chaque flux ouvert abonné à cet URI. Personne d’autre. +* `await ctx.notify_tools_changed()` atteint chaque flux qui a demandé les changements de la liste d’outils. Un client qui la reçoit appelle de nouveau `tools/list`, et voit désormais `sprint_report`. +* Les méthodes sœurs sont `notify_prompts_changed()` et `notify_resources_changed()`. +* Pas d’abonnés, pas de travail. Publier sur un serveur inactif est sans effet, vous ne vérifiez donc jamais si quelqu’un écoute. Vous indiquez ce qui a changé. + +`MCPServer` sert `subscriptions/listen` pour vous. Les obligations sur la liaison (l’accusé de réception comme première trame, le filtrage par flux, l’identifiant d’abonnement sur chaque trame) sont l’affaire du SDK. + +!!! check + Sur la liaison, un flux dont le filtre nommait `board://sprint` ressemble à ceci après l’exécution de `complete_task` : + + ```json + {"method": "notifications/subscriptions/acknowledged", + "params": {"notifications": {"resourceSubscriptions": ["board://sprint"]}, "_meta": {"io.modelcontextprotocol/subscriptionId": "listen-1"}}} + + {"method": "notifications/resources/updated", + "params": {"uri": "board://sprint", "_meta": {"io.modelcontextprotocol/subscriptionId": "listen-1"}}} + ``` + + Notez ce que la mise à jour ne transporte *pas* : le tableau. Chaque trame porte l’identifiant JSON-RPC de la requête listen sous `_meta`, et cet identifiant est l’identifiant d’abonnement. C’est le client qui le crée : le `Client` Python utilise des chaînes comme `"listen-1"` ; d’autres clients peuvent utiliser des entiers. + +## Seulement ce qui a été demandé {#only-what-was-asked-for} + +Le filtre est un contrat. Un flux qui a demandé les changements de la liste d’outils et un URI de ressource reçoit ces deux types et rien d’autre. Publiez un changement de prompt et ce flux reste silencieux. + +`MCPServer` compare les URI de ressource comme des chaînes exactes, si bien qu’un flux qui a nommé `board://sprint` n’entend rien à propos de `board://sprint/tasks/1`. La spécification permet à un serveur de signaler un changement sur une sous-ressource d’un URI abonné ; `MCPServer` ne le fait jamais, mais les clients sont conçus pour s’y attendre. + +Deux choses que le flux n’est *pas* : + +* **Ce n’est pas un journal de relecture.** Un flux interrompu est perdu, et les événements publiés pendant que personne n’était connecté ne sont pas mis en file d’attente. Les clients rouvrent l’écoute et récupèrent de nouveau les données. +* **Ce n’est pas le chemin 2025.** Les clients qui ont appelé `resources/subscribe` sont servis par `ctx.session.send_resource_updated(uri)`. Les méthodes `notify_*` n’atteignent que les flux `subscriptions/listen`. + +## Décider qui peut observer {#deciding-who-may-watch} + +Par défaut, chaque type et chaque URI demandés sont honorés : n’importe quel appelant peut observer n’importe quel URI que vous publiez. Rien ne consulte votre gestionnaire (handler) de lecture, car personne ne lit — un appelant que votre gestionnaire `files://{name}` refuserait peut tout de même ouvrir un flux sur `files://payroll.csv` et apprendre qu’il a changé, et quand. Il n’apprend jamais le contenu, et il ne peut pas sonder ce qui existe, car un URI inconnu est honoré lui aussi et ne se déclenche tout simplement jamais. La faille est étroite mais réelle : mettez donc un contrôle en place avant de publier des URI propres à chaque utilisateur depuis un serveur multi-locataire. + +Le contrôle est un middleware. Il voit la requête `subscriptions/listen` avant que le SDK n’en accuse réception et refuse lorsque l’appelant demande quoi que ce soit qu’il n’a pas le droit de lire : + +```python title="server.py" hl_lines="19-26 29" +--8<-- "docs_src/subscriptions/tutorial006.py" +``` + +* `ctx.params` est la requête brute ; le middleware la valide donc lui-même en `SubscriptionsListenRequestParams` et lit le filtre demandé par le client. +* Le refus est une `MCPError` levée avant `call_next(ctx)` : le client reçoit cette erreur et aucun flux, et la connexion continue. Gardez le message uniforme, sans nommer d’URI, afin qu’un refus ne confirme jamais quels URI sont protégés. +* Une seule fonction `can_access(user, uri)` répond aux deux questions. Le gestionnaire de ressource la pose sur `resources/read` ; le middleware la pose sur `subscriptions/listen`. Remplacez la table par une base de données ou votre système RBAC et les deux restent synchronisés. +* La décision vaut pour toute la durée de vie du flux. Il n’y a pas de nouvelle vérification par événement ; si l’accès d’un appelant peut expirer en cours de flux (un jeton qui expire), mettez donc fin à la connexion de cet appelant à ce moment-là. + +Le contrat complet du middleware, y compris ce qu’il enveloppe d’autre et pourquoi il est marqué comme provisoire, se trouve sur **[Middleware](../advanced/middleware.md)**. + +## Côté client {#the-client-end} + +Voici un client de l’autre côté de ce flux, qui suit le tableau : + +```python title="client.py" hl_lines="15" +--8<-- "docs_src/subscriptions/tutorial003.py" +``` + +Entrer dans `client.listen(...)` envoie la requête et attend votre accusé de réception : le flux est donc actif quand le bloc commence, et chaque événement typé est un signal pour récupérer de nouveau les données, jamais une charge utile. C’est tout le contrat, en un seul écran. Tout le reste concernant le côté client a sa propre page : observer à côté d’un traitement principal, fins de flux et réouverture de l’écoute. Voir **[Abonnements](../client/subscriptions.md)** sous *Clients*. + +## Passer à l’échelle au-delà d’un seul processus {#scaling-past-one-process} + +Les publications voyagent de votre gestionnaire vers les flux ouverts via un `SubscriptionBus`. Le bus par défaut est en mémoire : un processus, et tous les flux qu’il contient. C’est la bonne réponse jusqu’au jour où vous exécutez des réplicas derrière un répartiteur de charge, car le flux d’un client est alors épinglé à un réplica, et une publication sur un autre réplica doit l’atteindre. + +Cette jointure est à vous d’implémenter : deux méthodes au-dessus de votre backend pub/sub. + +```python +from collections.abc import Callable + +from redis.asyncio import Redis + +from mcp.server.mcpserver import MCPServer +from mcp.server.subscriptions import ServerEvent # SubscriptionBus is a Protocol: no base class + + +class RedisSubscriptionBus: + def __init__(self, redis: Redis) -> None: + self._redis = redis + self._listeners: dict[object, Callable[[ServerEvent], None]] = {} + + async def publish(self, event: ServerEvent) -> None: + await self._redis.publish("mcp-events", encode(event)) # to every replica + + def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]: + token = object() + self._listeners[token] = listener + + def unsubscribe() -> None: + self._listeners.pop(token, None) + + return unsubscribe + + +mcp = MCPServer("Sprint Board", subscriptions=RedisSubscriptionBus(redis)) +``` + +`encode` est à vous, tout comme la tâche de lecture sur chaque réplica qui décode les messages entrants et appelle chaque écouteur enregistré. Les écouteurs sont synchrones, ne doivent pas lever d’exception et s’exécutent sur la boucle d’événements du serveur. + +Le bus transporte des valeurs `ServerEvent` typées, quatre petites dataclasses, jamais du JSON-RPC. Le marquage, le filtrage et le cycle de vie des flux restent dans le SDK, si bien qu’une implémentation de bus ne peut pas casser le protocole. Elle ne peut que déplacer des événements entre processus. + +Pour publier en dehors d’une requête, construisez le bus vous-même afin d’en garder la référence. `MCPServer` en construit un en interne lorsque vous ne passez rien, et ne l’expose pas. + +```python +from mcp.server.subscriptions import InMemorySubscriptionBus, ToolsListChanged + +bus = InMemorySubscriptionBus() +mcp = MCPServer("Sprint Board", subscriptions=bus) + + +async def tools_reloaded() -> None: + await bus.publish(ToolsListChanged()) # from a lifespan task, a webhook, anywhere +``` + +## La composition bas niveau {#the-low-level-composition} + +Sur le `Server` bas niveau, rien n’est précâblé, et les mêmes pièces s’assemblent en trois lignes : + +```python title="server.py" hl_lines="8-9 47" +--8<-- "docs_src/subscriptions/tutorial002.py" +``` + +* Le bus vous appartient, vous y publiez donc directement : `await bus.publish(ResourceUpdated(uri=...))`. Placez-le là où vos gestionnaires peuvent l’atteindre : la portée du module ici, le cycle de vie (lifespan) dans une application plus grande. +* `ListenHandler(bus)` est le même gestionnaire que celui qu’enregistre `MCPServer`, et `on_subscriptions_listen=` est un emplacement de gestionnaire ordinaire. Mettez votre propre callable dans cet emplacement pour une sémantique différente, et les obligations de la spécification vous reviennent : accuser réception d’abord, marquer chaque trame avec l’identifiant d’abonnement, ne rien livrer en dehors du filtre. +* `ListenHandler.close()` termine proprement chaque flux ouvert. Chacun reçoit le résultat de la requête listen comme dernière trame, ce qui est la manière pour la spécification de dire que le serveur a mis fin à l’abonnement délibérément. Elle rend la main avant que ces flux n’aient fini de se vider : laissez-leur donc un instant avant de démonter le transport. Sans cela, les flux se terminent lorsque le client se déconnecte. + +## Récapitulatif {#recap} + +* Un client s’inscrit avec une seule requête `subscriptions/listen`, et la réponse est le flux. La prise en charge est intégrée. +* Vous publiez avec `ctx.notify_*`, et le SDK se charge du marquage, du filtrage et du cycle de vie. +* Les événements sont des signaux, pas des charges utiles. Les deux extrémités récupèrent de nouveau les données. +* Le côté client, c’est `async with client.listen(...)` : tous les détails sont dans **[Abonnements](../client/subscriptions.md)** sous *Clients*. +* Sur le `Server` bas niveau, vous assemblez vous-même les mêmes pièces : un bus, `ListenHandler(bus)`, l’emplacement `on_subscriptions_listen`. +* Passer à l’échelle horizontalement signifie implémenter `SubscriptionBus`, deux méthodes, et le passer via `MCPServer(subscriptions=...)`. + +Exécuter le serveur qui sert tout cela, derrière un réplica ou vingt, c’est **[Déployer et passer à l’échelle](../run/deploy.md)**. diff --git a/i18n/fr/pages/index.md b/i18n/fr/pages/index.md new file mode 100644 index 0000000000..99a0257166 --- /dev/null +++ b/i18n/fr/pages/index.md @@ -0,0 +1,102 @@ +--- +translation: + sections: [154c4309937b9f85, 3ad8fc6caa76a9b0, a07f3f5b151ab746, bf6e476b712930c0, cf0b1f13978c6623] + tool: 1 +--- +# MCP Python SDK {#mcp-python-sdk} + +!!! info "Cette documentation décrit la v2, la branche stable actuelle" + Vous découvrez la v2, ou vous venez de la v1 ? **[Nouveautés de la v2](whats-new.md)** fait le tour des changements en cinq minutes, et le **[Guide de migration](migration.md)** couvre chaque changement incompatible. + Encore en v1.x ? Sa documentation se trouve dans la [documentation v1.x](https://py.sdk.modelcontextprotocol.io/v1/). + Quelque chose vous semble maladroit ou confus ? [Dites-le-nous](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml). + +Le **Model Context Protocol (MCP)** permet aux applications de fournir du contexte aux LLM de façon standardisée, en séparant la *fourniture* du contexte de l’interaction avec le LLM proprement dite. + +Voici son SDK Python officiel. Il vous permet de : + +* **Construire des serveurs MCP** qui exposent des outils (tools), des ressources et des prompts à n’importe quel hôte MCP. +* **Construire des clients MCP** qui se connectent à n’importe quel serveur MCP. +* Parler tous les transports standard : stdio, Streamable HTTP et SSE. + +## Prérequis {#requirements} + +Python 3.10+. + +## Installation {#installation} + +=== "uv" + + ```bash + uv add "mcp[cli]" + ``` + +=== "pip" + + ```bash + pip install "mcp[cli]" + ``` + +L’extra `[cli]` vous fournit la commande `mcp` ; vous en aurez besoin pour le développement. +Consultez [Installation](get-started/installation.md) pour savoir à quoi sert chaque dépendance. + +## Exemple {#example} + +### Le créer {#create-it} + +Créez un fichier `server.py` : + +```python title="server.py" +--8<-- "docs_src/index/tutorial001.py" +``` + +C’est un serveur MCP complet. + +Il expose un **outil**, `add`, et une **ressource** paramétrée, `greeting://{name}`. + +### L’exécuter {#run-it} + +```console +uv run mcp dev server.py +``` + +Cette commande démarre votre serveur et ouvre le [MCP Inspector](https://github.com/modelcontextprotocol/inspector), une interface interactive pour l’explorer. Ouvrez l’URL qu’elle affiche. + +!!! note + L’Inspector est une application Node.js : `mcp dev` a donc besoin de `npx` dans votre `PATH`. + +### Essayer {#try-it} + +Dans l’Inspector, allez dans **Tools** et appelez `add` avec `a=1`, `b=2`. + +Vous obtenez `3` en retour. ✨ + +L’Inspector a construit ce formulaire (un champ entier obligatoire pour `a`, un autre pour `b`) à partir de vos annotations de type. Claude fera de même, ainsi que tous les autres hôtes MCP. + +Allez maintenant dans **Resources** et lisez `greeting://World` : + +```text +Hello, World! +``` + +### Récapitulatif {#recap} + +Regardez à nouveau ce que vous n’avez **pas** écrit : + +* Aucun JSON Schema. `a: int, b: int` *est* le schéma. +* Aucune analyse de requête, aucune sérialisation, aucun code de validation. +* Aucune gestion du protocole. + +Vous avez écrit deux fonctions Python avec des annotations de type et une docstring. Le SDK fait le reste. + +## Et ensuite {#where-to-go-next} + +* **[Prise en main](get-started/index.md)** vous mène de l’installation à un serveur fonctionnel et testé. +* Vous construisez une application qui *utilise* des serveurs MCP ? Commencez par **[Clients](client/index.md)**. +* Vous avez déjà une application FastAPI ou Starlette ? **[Ajouter à une application existante](run/asgi.md)** y monte un serveur MCP. +* Vous cherchez un message d’erreur précis ? **[Dépannage](troubleshooting.md)** est indexé par le texte exact. +* Vous vous demandez ce qui a changé dans la v2 ? **[Nouveautés de la v2](whats-new.md)** en fait le tour en cinq minutes. +* Vous migrez depuis la v1 ? Commencez par le **[Guide de migration](migration.md)**. +* Vous cherchez une signature exacte ? La **[Référence de l’API](api/mcp/index.md)** est générée à partir du code source. +* Vous lisez avec un LLM ? Cette documentation est aussi publiée au format [llms.txt](https://llmstxt.org/) : + [llms.txt](https://py.sdk.modelcontextprotocol.io/llms.txt) est un index des pages, et + [llms-full.txt](https://py.sdk.modelcontextprotocol.io/llms-full.txt) contient toutes les pages dans un seul fichier. diff --git a/i18n/fr/pages/protocol-versions.md b/i18n/fr/pages/protocol-versions.md new file mode 100644 index 0000000000..a655ec8d77 --- /dev/null +++ b/i18n/fr/pages/protocol-versions.md @@ -0,0 +1,132 @@ +--- +translation: + sections: [478fd619e5f90ef8, aef094a00e44e248, bab8cbf3449fa7e9, df1809b15a58335b, 5f9d8c2336ed0239, f54974398e43ddef, b24443dd78584870] + tool: 1 +--- +# Versions du protocole {#protocol-versions} + +MCP compte deux générations. + +Les serveurs publiés avant la version 2026-07-28 ouvrent chaque connexion par la **poignée de main (handshake) `initialize`** : le client propose une version, le serveur fait une contre-proposition, le client accuse réception, le tout avant la première requête utile. Les serveurs en version **2026-07-28** abandonnent la poignée de main. Le client envoie une seule sonde **`server/discover`** et le serveur y répond avec tout ce qu’il faut en un seul résultat. + +Vous n’avez presque jamais à vous en soucier, car `Client` négocie pour vous. Cette page porte sur le seul argument du constructeur qui contrôle cela, `mode=`, et sur les trois cas où vous le changez. + +## `mode="auto"` {#modeauto} + +```python title="client.py" hl_lines="14-15" +--8<-- "docs_src/protocol_versions/tutorial001.py" +``` + +Vous n’avez pas passé `mode`, vous avez donc la valeur par défaut : `"auto"`. L’entrée dans `async with` envoie une seule sonde `server/discover` à la version la plus récente que parle ce SDK. Ensuite : + +* Un **serveur moderne** y répond. Le client adopte le résultat. Un aller-retour, terminé. +* Un **serveur plus ancien** n’a jamais entendu parler de `server/discover` et renvoie une erreur. Le client se rabat sur la poignée de main classique `initialize` et prend ce qu’elle négocie. + +Dans les deux cas, vous ressortez connecté, et `client.protocol_version` vous indique lequel c’était : + +```text +2026-07-28 +``` + +C’est toute la fonctionnalité. Un seul `Client`, un serveur de n’importe quelle génération, aucun branchement dans votre code. + +!!! info + `MCPServer` répond à `server/discover` sur tous les transports — en mémoire, stdio, Streamable + HTTP — donc face à votre propre serveur, `auto` aboutit toujours à `2026-07-28`. Le repli ne + se déclenche que face à un vrai serveur antérieur à 2026, c’est-à-dire exactement quand vous le souhaitez. + +## `mode="legacy"` {#modelegacy} + +```python title="client.py" hl_lines="14" +--8<-- "docs_src/protocol_versions/tutorial002.py" +``` + +`mode="legacy"` ne sonde jamais. Il exécute la poignée de main `initialize`, la même connexion qu’ouvre un client antérieur à 2026. + +```text +2025-11-25 +``` + +Même serveur. Il parle parfaitement `2026-07-28` ; vous avez dit au client de ne pas demander. + +Vous en avez besoin pour les fonctionnalités **de type push**. + +Une requête à l’initiative du serveur, c’est le serveur qui *vous* appelle : `ctx.elicit(...)` qui place un formulaire devant votre utilisateur, l’échantillonnage (sampling) qui demande une complétion à votre modèle en plein appel d’outil. Ce canal n’existe que sur une session de la génération à poignée de main. + +En version 2026-07-28, il a disparu. Le serveur *renvoie* ses questions et vous relancez l’appel avec les réponses (**[Requêtes à plusieurs allers-retours (multi-round-trip)](handlers/multi-round-trip.md)**). + +`mode="auto"` ne vous donne une poignée de main que lorsque le serveur est trop ancien pour autre chose. `mode="legacy"` en garantit une. Utilisez-le dès que vous passez à `Client(...)` un `sampling_callback`, un `elicitation_callback` que vous voulez piloté comme une requête, ou un `message_handler`. **[Fonctions de rappel du client](client/callbacks.md)** les passe chacun en revue. + +## Épingler une version {#pinning-a-version} + +`mode` accepte aussi une chaîne de version moderne du protocole. Aujourd’hui, cet ensemble est exactement `["2026-07-28"]`. + +```python title="client.py" hl_lines="14" +--8<-- "docs_src/protocol_versions/tutorial003.py" +``` + +Un épinglage n’envoie **rien**. Ni sonde, ni poignée de main. Le client adopte `2026-07-28` localement et la connexion est active dès l’instant où `async with` rend la main. + +Un épinglage est une promesse que *vous* faites : vous savez déjà que le serveur parle cette version. Le client ne vérifie pas. + +!!! check + Un épinglage n’est pas une découverte. Affichez `client.server_info` et le prix à payer saute aux yeux : + + ```text + None + ``` + + Le client n’a jamais demandé au serveur qui il est, donc `server_info` vaut `None`. Même chose pour + `client.server_capabilities` : chaque capacité vaut `None`. Les appels d’outils fonctionnent toujours (le protocole n’a besoin de rien de tout cela) ; + le code qui lit `server_capabilities` pour décider quoi proposer, non. + + La section suivante apporte la solution. + +Seules les versions modernes peuvent être épinglées. Une chaîne de la génération à poignée de main est rejetée à la construction, avant toute entrée-sortie, et l’erreur vous indique quoi écrire à la place : + +```text +ValueError: mode must be 'legacy', 'auto', or one of ['2026-07-28']; got '2025-06-18' ('2025-06-18' is a handshake-era version; use mode='legacy') +``` + +## Se reconnecter avec `prior_discover` {#reconnecting-with-prior_discover} + +La sonde est peu coûteuse, mais cela reste un aller-retour que vous payez à chaque reconnexion, et la réponse ne change presque jamais. + +Alors conservez-la. Après une connexion `auto`, `client.session.discover_result` contient le `DiscoverResult` exact que le serveur a envoyé : ses `supported_versions`, ses `capabilities`, ses `instructions` et l’identité que le serveur a inscrite dans le `_meta` du résultat. Repassez-le via `prior_discover=` la fois suivante : + +```python title="client.py" hl_lines="15 17" +--8<-- "docs_src/protocol_versions/tutorial004.py" +``` + +```text +2026-07-28 +Bookshop +``` + +La seconde connexion n’a fait **aucun** aller-retour de négociation et sait pourtant exactement à qui elle parle. C’est le mode épinglé bien fait : `mode=` nomme la version, `prior_discover=` fournit l’identité. ✨ + +`DiscoverResult` est un modèle Pydantic. `saved.model_dump_json()` va dans un fichier ou un cache ; `DiscoverResult.model_validate_json(...)` le restitue dans le processus suivant. + +!!! tip + `prior_discover=` n’a d’effet que lorsque `mode` est un épinglage de version. En `"auto"`, le client + sonde le serveur de toute façon, et en `"legacy"`, il est ignoré. + +## Les quatre modes {#the-four-modes} + +| Vous écrivez | Trafic de négociation | Vous obtenez | +| --- | --- | --- | +| `Client(target)` | une sonde `server/discover` ; la poignée de main `initialize` si elle échoue | la version la plus récente que parlent les deux côtés, quelle que soit la génération | +| `Client(target, mode="legacy")` | la poignée de main `initialize` | une version de la génération à poignée de main ; les requêtes à l’initiative du serveur fonctionnent | +| `Client(target, mode="2026-07-28")` | aucun | cette version, épinglée, avec `server_info` à `None` | +| `Client(target, mode="2026-07-28", prior_discover=saved)` | aucun | cette version, épinglée, *et* l’identité que vous avez enregistrée la dernière fois | + +## Récapitulatif {#recap} + +* MCP a une génération à poignée de main (jusqu’à `2025-11-25`, la poignée de main `initialize`) et une génération moderne (`2026-07-28`, `server/discover`). `Client` fait le pont entre les deux. +* `mode="auto"` est la valeur par défaut : sonder, se replier. N’y touchez pas sauf si l’une des trois autres lignes vous correspond. +* `client.protocol_version` est toujours la réponse à « qu’est-ce que j’ai obtenu ? ». +* `mode="legacy"` force la poignée de main. C’est ce qu’il vous faut pour les requêtes à l’initiative du serveur : échantillonnage, élicitation (elicitation) en push, `message_handler`. +* Un épinglage de version (`mode="2026-07-28"`) n’envoie aucun trafic de négociation, au prix d’un `client.server_info` à `None`. +* `prior_discover=` rembourse ce coût : enregistrez `client.session.discover_result`, reconnectez-vous avec, et obtenez les deux. + +Une connexion moderne n’a pas de canal push, alors comment un serveur 2026 vous pose-t-il une question en plein appel ? Il la renvoie : **[Requêtes à plusieurs allers-retours](handlers/multi-round-trip.md)**. diff --git a/i18n/fr/pages/run/asgi.md b/i18n/fr/pages/run/asgi.md new file mode 100644 index 0000000000..288262c6ed --- /dev/null +++ b/i18n/fr/pages/run/asgi.md @@ -0,0 +1,145 @@ +--- +translation: + sections: [1062ef792791488a, 4be2b831547184a9, 374b049e770385f2, b72f6947089e6de0, b172c9db7831bb31, 70b9ece244ca1b0c, cba78e052898c3f6, f06bdb541cb0b469, fb82d526320b7cc3] + tool: 1 +--- +# Ajouter à une application existante {#add-to-an-existing-app} + +`mcp.run("streamable-http")` démarre un serveur web pour vous. Parfois, ce n’est pas ce que vous voulez : votre serveur MCP n’est qu’une pièce d’une application web plus vaste, ou vous avez déjà un déploiement ASGI. + +Pour cela, `mcp.streamable_http_app()` renvoie une **application Starlette**. + +Une application Starlette est une application ASGI, donc tout ce qui héberge de l’ASGI (uvicorn, Hypercorn, une autre application Starlette, FastAPI) peut héberger votre serveur MCP. + +## L’application {#the-app} + +```python title="server.py" hl_lines="12" +--8<-- "docs_src/asgi/tutorial001.py" +``` + +`app` est une application ASGI ordinaire. Passez-la à n’importe quel serveur ASGI : + +```console +uvicorn server:app +``` + +Le point de terminaison MCP se trouve à `/mcp`, un client se connecte donc à `http://127.0.0.1:8000/mcp`. + +L’application embarque déjà deux choses : + +* Une route, `/mcp` : le point de terminaison Streamable HTTP. +* Un **cycle de vie** (lifespan) qui démarre `mcp.session_manager`, l’objet responsable du travail d’arrière-plan de chaque session active. + +Exécutez l’application seule (`uvicorn server:app`) et vous n’aurez jamais à penser ni à l’un ni à l’autre. + +!!! tip + `streamable_http_app()` accepte les mêmes arguments nommés que `mcp.run("streamable-http", ...)`, + à l’exception de `port` : le port appartient à ce qui sert l’application. `host` est toujours accepté mais ne lie + rien ici ; **[Déployer et passer à l’échelle](deploy.md)** explique ce qu’il contrôle réellement. + **[Exécuter votre serveur](index.md)** détaille les options elles-mêmes. + +`mcp.sse_app()` fait la même chose pour le transport SSE, désormais remplacé. + +## Localhost uniquement, jusqu’à ce que vous en décidiez autrement {#localhost-only-until-you-say-otherwise} + +Par défaut, l’application répond **uniquement** aux requêtes adressées à localhost. `streamable_http_app()` +ne peut pas savoir derrière quel nom d’hôte elle sera servie ; elle active donc la protection contre le DNS rebinding avec la +liste d’autorisation la plus sûre possible ; sur votre machine, c’est exactement ce qu’il faut. Déployée derrière un vrai nom d’hôte, +cela signifie que **chaque requête est rejetée avec `421 Misdirected Request`** tant que vous n’avez pas passé à +`transport_security=` une liste d’autorisation de ce que vous servez réellement. Rien de ce que vous avez construit n’est même +consulté avant. Cette liste d’autorisation, et tout ce qui sépare une application fonctionnelle d’un vrai nom d’hôte, +c’est **[Déployer et passer à l’échelle](deploy.md)**. + +## Le monter {#mounting-it} + +Dès que le serveur MCP fait *partie* d’une application plus grande, vous placez l’application dans un `Mount`. Et dès que vous faites cela, le cycle de vie devient votre problème : + +```python title="server.py" hl_lines="18-21 25-26" +--8<-- "docs_src/asgi/tutorial002.py" +``` + +* `Mount("/", ...)` combiné au chemin par défaut `/mcp` garde le point de terminaison à `/mcp`. Starlette essaie les routes dans l’ordre et `Mount("/")` correspond à **tous** les chemins ; vos propres routes vont donc *avant* lui dans la liste. Tout ce qui vient après est inaccessible. +* La fonction `lifespan` entre dans `mcp.session_manager.run()` pour toute la durée de vie de l’application **hôte**. C’est la ligne que tout le monde oublie. +* `mcp.session_manager` n’existe qu’*après* l’appel à `streamable_http_app()`. C’est pourquoi les routes sont construites au niveau du module et que le gestionnaire de sessions n’est manipulé qu’à l’intérieur du cycle de vie. + +La route `Host` de Starlette fonctionne de la même façon : remplacez `Mount("/", ...)` par `Host("mcp.example.com", ...)` pour router par nom d’hôte plutôt que par chemin. La règle du cycle de vie ne change pas, et celle de la sécurité du transport non plus. Une route `Host("mcp.example.com", ...)` ne reçoit jamais que les requêtes adressées à ce nom d’hôte, mais la propre liste d’autorisation Host du transport (**[Déployer et passer à l’échelle](deploy.md)**) s’exécute tout de même en premier. Sans `"mcp.example.com"` dedans, cette route répond à chacune d’elles par un `421`. + +!!! warning "L’application hôte possède le cycle de vie" + `streamable_http_app()` branche `session_manager.run()` sur le cycle de vie de l’application Starlette qu’elle + renvoie, mais **le cycle de vie d’une sous-application montée ne s’exécute jamais**. Montez l’application et ce + cycle de vie intégré devient du code mort. L’application située au sommet de votre pile ASGI, quelle qu’elle soit, doit entrer dans + `mcp.session_manager.run()` dans son propre cycle de vie. + +!!! check + Supprimez la ligne `lifespan=lifespan` et démarrez le serveur. Il démarre. La route se résout. + Puis la première requête vers `/mcp` échoue avec : + + ```text + RuntimeError: Task group is not initialized. Make sure to use run(). + ``` + + Rien ne démarre le gestionnaire de sessions, si ce n’est sa méthode `run()`. + +## Deux serveurs, une application {#two-servers-one-app} + +Chaque `MCPServer` est sa propre application avec son propre gestionnaire de sessions. Montez-en autant que vous voulez ; entrez dans chaque gestionnaire depuis l’unique cycle de vie de l’hôte : + +```python title="server.py" hl_lines="27-30 35-36" +--8<-- "docs_src/asgi/tutorial003.py" +``` + +* `AsyncExitStack` entre dans les deux gestionnaires ; ils démarrent ensemble et s’arrêtent dans l’ordre inverse. +* Les points de terminaison sont `/notes/mcp` et `/tasks/mcp` : le préfixe de montage suivi du chemin par défaut. + +## Changer le chemin {#changing-the-path} + +Ce `/mcp` final, c’est `streamable_http_path`. Définissez-le à `"/"` et le préfixe de montage devient le chemin public complet : + +```python title="server.py" hl_lines="25" +--8<-- "docs_src/asgi/tutorial004.py" +``` + +Les clients se connectent désormais à `/notes`, et non à `/notes/mcp`. + +## CORS pour les clients navigateur {#cors-for-browser-clients} + +Un client qui s’exécute dans un navigateur a besoin de deux permissions de votre part : **envoyer** ses en-têtes de requête MCP, et **lire** celui que MCP renvoie. Les deux relèvent de la configuration CORS de l’application hôte, et la liste d’autorisation de la sécurité du transport ci-dessus doit concorder avec elle : + +```python title="server.py" hl_lines="27-30 33 35-49" +--8<-- "docs_src/asgi/tutorial005.py" +``` + +* `allow_headers` est la moitié que tout le monde oublie. Un navigateur envoie une **requête préliminaire** (preflight) avant chaque requête MCP, parce que `Content-Type: application/json` et les en-têtes de requête `Mcp-*` ne figurent pas dans la liste sûre de CORS, et un en-tête que la requête préliminaire n’accorde pas, c’est une requête que le navigateur n’envoie jamais. (`allow_headers=["*"]` fonctionne aussi : Starlette répond à une requête préliminaire avec ce qu’elle a demandé.) +* `expose_headers=["Mcp-Session-Id"]` est la moitié lecture. Streamable HTTP renvoie l’identifiant de session dans cet en-tête de réponse, et les navigateurs masquent les en-têtes de réponse au JavaScript sauf si CORS les expose nommément. Sans lui, le client ne peut jamais faire sa deuxième requête. +* `allow_origins` est votre décision, pas celle de MCP. Soyez précis, et reproduisez-le dans `allowed_origins=` ci-dessus : le navigateur applique CORS, mais le serveur vérifie lui-même l’en-tête `Origin`, et une origine à laquelle le transport ne fait pas confiance reçoit un `403` même après une requête préliminaire réussie. +* `allow_methods` liste les trois méthodes qu’utilise Streamable HTTP : `POST` pour envoyer des messages, `GET` pour ouvrir le flux serveur vers client, `DELETE` pour terminer la session. + +## Routes personnalisées {#custom-routes} + +`@mcp.custom_route()` enregistre un point de terminaison HTTP ordinaire sur la même application, pour ce dont tout service déployé a besoin et qui n’a rien à voir avec MCP : une vérification d’état, un rappel OAuth. + +```python title="server.py" hl_lines="15-17" +--8<-- "docs_src/asgi/tutorial006.py" +``` + +* Le gestionnaire est du Starlette ordinaire : une fonction `async` de `Request` vers `Response`. +* `streamable_http_app()` récupère chaque route personnalisée. `app.routes` contient maintenant `/mcp` et `/health`. +* `GET /health` répond `{"status": "ok"}` sans la moindre trace de MCP. + +!!! warning + Les routes personnalisées ne sont **jamais authentifiées**, même lorsque le reste du serveur l’est. C’est + volontaire : les vérifications d’état et les rappels OAuth doivent être joignables avant qu’un quelconque jeton n’existe. + Ne mettez rien de privé derrière l’une d’elles. + +## Récapitulatif {#recap} + +* `mcp.streamable_http_app()` renvoie une application Starlette avec une route, `/mcp`. N’importe quel serveur ASGI peut l’exécuter. +* Par défaut, l’application répond uniquement aux requêtes adressées à localhost, et derrière un vrai nom d’hôte elle rejette tout avec un `421` tant que vous n’avez pas passé à `transport_security=` une liste d’autorisation. **[Déployer et passer à l’échelle](deploy.md)** s’occupe de cela, et du reste du chemin vers la production. +* `Mount` (ou `Host`) la place dans une application Starlette ou FastAPI plus grande. +* **Le montage désactive le cycle de vie intégré.** Le cycle de vie de l’application hôte doit entrer dans `mcp.session_manager.run()`, sinon la première requête échoue. +* Plusieurs serveurs dans une même application, c’est plusieurs montages et un seul cycle de vie qui entre dans chaque gestionnaire de sessions. +* `streamable_http_path="/"` déplace le point de terminaison sur le préfixe de montage lui-même. +* Les clients navigateur ont besoin de CORS : `allow_headers` pour les en-têtes de requête `Mcp-*`, `expose_headers=["Mcp-Session-Id"]` pour la réponse. +* `@mcp.custom_route()` ajoute des points de terminaison HTTP ordinaires, non authentifiés, à côté de `/mcp`. + +Une fois le serveur joignable à une vraie URL, **[Le client](../client/index.md)** s’y connecte avec cette URL plutôt qu’avec un objet serveur. diff --git a/i18n/fr/pages/run/authorization.md b/i18n/fr/pages/run/authorization.md new file mode 100644 index 0000000000..57e515a54b --- /dev/null +++ b/i18n/fr/pages/run/authorization.md @@ -0,0 +1,130 @@ +--- +translation: + sections: [d62c13457fc4a534, 80e73abaca6e0652, d1dc4c54cd00ec9c, 14ad3bc7904036bb, 5225f127bc1b9c77, fe1626fdd5aad1da, 4556cb7ea1a04a31] + tool: 1 +--- +# Autorisation {#authorization} + +Sur Streamable HTTP, votre serveur MCP est un service web ordinaire, et vous le protégez comme n’importe quel service web : avec des jetons porteurs OAuth 2.1. + +En termes OAuth, votre serveur est un **serveur de ressources**. Il ne connecte jamais personne et n’émet jamais de jeton. Il fait une seule chose : examiner l’en-tête `Authorization` de chaque requête et décider si le jeton qu’il contient est valable. + +Cette page traite du côté serveur. Un client qui découvre votre serveur d’autorisation et récupère le jeton, c’est **[Clients OAuth](../client/oauth-clients.md)**. + +## Les trois parties {#the-three-parties} + +* Le **serveur d’autorisation** connecte les utilisateurs et émet les jetons d’accès. Vous ne l’écrivez pas. C’est votre fournisseur d’identité (Auth0, Keycloak, Entra, le vôtre). +* Le **serveur de ressources**, c’est votre serveur MCP. Il vérifie le jeton à chaque requête. +* Le **client** découvre à quel serveur d’autorisation vous faites confiance, en obtient un jeton et vous le renvoie sous la forme `Authorization: Bearer `. + +C’est tout le triangle. Toute cette page porte sur le point du milieu. + +## Un vérificateur de jetons {#a-token-verifier} + +Le SDK n’a aucun avis sur ce à quoi ressemble un jeton valide. C’est vous qui le lui dites, en implémentant **`TokenVerifier`** : + +```python title="server.py" hl_lines="12-14 19-24" +--8<-- "docs_src/authorization/tutorial001.py" +``` + +* `TokenVerifier` est un protocole avec une seule méthode asynchrone. `verify_token` reçoit le jeton brut de l’en-tête `Authorization` et renvoie un **`AccessToken`** s’il est valide, `None` sinon. Il n’y a rien d’autre à implémenter. +* Celui-ci cherche le jeton dans une table. Un vérificateur réel vérifie la signature d’un JWT ou appelle le point de terminaison d’introspection de jetons du serveur d’autorisation. Ce code est le vôtre ; le SDK ne fait que l’appeler. +* `token_verifier=` et `auth=` vont toujours de pair. Passez l’un sans l’autre et `MCPServer(...)` lève une `ValueError` avant même de servir la moindre requête. + +`AuthSettings` est la face publique de votre serveur de ressources : + +* `issuer_url` : le serveur d’autorisation qui émet vos jetons. +* `resource_server_url` : l’URL publique de ce point de terminaison MCP. Elle désigne *quelle* ressource un jeton vise, et c’est là que réside le document de découverte. +* `required_scopes` : chaque jeton doit tous les porter. + +!!! tip + `examples/servers/simple-auth/` dans le dépôt du SDK contient un `IntrospectionTokenVerifier` qui appelle + le point de terminaison [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662) d’un véritable serveur d’autorisation. C’est la forme que prennent la plupart des vérificateurs en production. + +## Ce que vous obtenez sur HTTP {#what-you-get-over-http} + +L’autorisation vit dans les en-têtes HTTP, elle n’existe donc que sur les transports HTTP. Lancez-la sur celui que vous déployez : `mcp.run(transport="streamable-http")` la place sur `http://127.0.0.1:8000/mcp`, et le reste est dans **[Exécuter votre serveur](index.md)**. L’application possède désormais deux routes : + +```text +/mcp +/.well-known/oauth-protected-resource/mcp +``` + +Vous avez enregistré un seul outil. La seconde route est celle du SDK. + +### Découverte {#discovery} + +Faites un `GET` sur ce chemin well-known et vous obtenez les **métadonnées de ressource protégée de la [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728)** (Protected Resource Metadata), construites directement à partir de vos `AuthSettings` : + +```json +{ + "resource": "http://127.0.0.1:8000/mcp", + "authorization_servers": ["https://auth.example.com/"], + "scopes_supported": ["notes:read"], + "bearer_methods_supported": ["header"] +} +``` + +C’est grâce à ce document qu’un client qui n’a jamais entendu parler de votre serveur trouve son chemin : il lit `authorization_servers` et s’y rend pour obtenir un jeton. Vous n’en avez rien écrit. + +!!! check + Appelez `/mcp` sans jeton (ou avec un jeton pour lequel votre vérificateur a renvoyé `None`) et la requête + est arrêtée à la porte : + + ```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"} + ``` + + Rien n’a été analysé et aucun outil n’a été exécuté. Et ce pointeur `resource_metadata` dans `WWW-Authenticate` est + ce qui rend la découverte automatique : 401 -> document de métadonnées -> serveur d’autorisation -> jeton -> nouvelle tentative. + +!!! warning + Rien de tout cela ne protège `stdio`. Un tube n’a pas d’en-tête `Authorization`, donc `token_verifier` n’y est + jamais consulté. La frontière de sécurité d’un serveur `stdio` est le processus qui l’a lancé. Il en va de + même pour le `Client(mcp)` en mémoire que vous utilisez dans les tests : il se connecte directement à l’objet serveur + et saute la couche HTTP, autorisation comprise. + +## L’identité de l’appelant {#the-callers-identity} + +Dans n’importe quel gestionnaire (handler), **`get_access_token()`** est l’objet `AccessToken` que votre vérificateur a renvoyé pour la requête en cours : + +```python title="server.py" hl_lines="4 32-35" +--8<-- "docs_src/authorization/tutorial002.py" +``` + +* Cela fonctionne dans les outils, les ressources et les prompts, et il n’y a rien à transmettre : le middleware d’authentification le stocke dans une variable de contexte par requête. +* Vous récupérez le **même objet que celui construit par votre vérificateur** : `client_id`, `scopes`, `subject`, `expires_at` et tous les `claims` supplémentaires que vous y avez attachés. C’est le point d’accroche pour des règles par outil : lisez les scopes et refusez. +* En dehors d’une requête HTTP authentifiée, elle renvoie `None`. En mémoire et sur `stdio`, c’est toujours `None`. + +Appelez `whoami` avec `Authorization: Bearer alice-token` et le modèle lit : + +```text +alice (scopes: notes:read) +``` + +## La moitié que le SDK ne fait pas {#the-half-the-sdk-doesnt-do} + +Le SDK vous donne la moitié serveur de ressources : vérifier, annoncer, refuser. Il ne vous donne ni page de connexion, ni écran de consentement, ni jeton. + +Pour voir les trois parties en action, lancez `examples/servers/simple-auth/` depuis le dépôt du SDK (un petit serveur d’autorisation et un serveur de ressources configuré exactement comme sur cette page), puis pointez `examples/clients/simple-auth-client/` dessus pour la chorégraphie complète découverte-puis-jeton. + +!!! info + Il existe un second argument de constructeur, `auth_server_provider=`, qui embarque un serveur d’autorisation + complet dans votre serveur MCP. Il est antérieur à la séparation AS/RS autour de laquelle la spécification + d’autorisation MCP est construite. Les nouveaux serveurs ne devraient pas y recourir. + +Un serveur d’autorisation peut aussi accepter l’assertion signée d’un fournisseur d’identité d’entreprise à la place d’un utilisateur qui valide un écran de consentement, et le SDK prend en charge les deux côtés de cet échange. Ce mode d’octroi (grant), et le client qui le présente, c’est **[Assertion d’identité](../client/identity-assertion.md)**. + +## Récapitulatif {#recap} + +* Sur Streamable HTTP, votre serveur est un **serveur de ressources** OAuth 2.1 : il vérifie les jetons, il n’en émet jamais. +* `TokenVerifier` est toute la surface d’intégration : une méthode asynchrone, un jeton en entrée, `AccessToken | None` en sortie. +* `token_verifier=` et `auth=AuthSettings(issuer_url=..., resource_server_url=..., required_scopes=[...])` vont toujours de pair. +* Le SDK publie les métadonnées de ressource protégée (Protected Resource Metadata) de la [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) sur `/.well-known/oauth-protected-resource/...` et répond aux requêtes non authentifiées par un 401 dont l’en-tête `WWW-Authenticate` pointe vers elles. C’est tout le mécanisme de découverte. +* `get_access_token()` dans n’importe quel gestionnaire indique qui appelle. +* L’autorisation est une affaire de HTTP. `stdio` et le client en mémoire ne la voient jamais. + +La moitié client (découvrir votre serveur d’autorisation et récupérer le jeton pour vous), c’est **[Clients OAuth](../client/oauth-clients.md)**. Et un client qui *affirme* une identité au lieu d’en demander une à un utilisateur, c’est **[Assertion d’identité](../client/identity-assertion.md)**. diff --git a/i18n/fr/pages/run/deploy.md b/i18n/fr/pages/run/deploy.md new file mode 100644 index 0000000000..14df623049 --- /dev/null +++ b/i18n/fr/pages/run/deploy.md @@ -0,0 +1,182 @@ +--- +translation: + sections: [28221886b198784f, f88ea1f1614f3a1d, ce926d686730b6d0, 3be24f8ad8bb5ab9, 3fad24032b2224ff, f25a7f860e579ecb, e758745df6fb7b0a] + tool: 1 +--- +# Déployer et passer à l’échelle {#deploy-scale} + +Votre serveur fonctionne. Il lui faut maintenant un vrai nom d’hôte, et plus d’un worker derrière lui. + +Presque rien de tout cela ne regarde MCP. Vous apportez le serveur ASGI, le gestionnaire de processus, le répartiteur de charge. Ce que contient cette page, c’est la courte liste de ce qui *regarde* bel et bien MCP : un réglage qui conditionne tout déploiement, et les deux endroits où « plus d’un worker » change ce que fait le SDK. + +## Avant toute chose : la liste des hôtes autorisés {#before-anything-else-the-host-allowlist} + +`streamable_http_app()` ne peut pas savoir derrière quel nom d’hôte il sera servi, il retient donc la réponse la plus sûre : localhost. Sans `transport_security=`, l’application active la **protection contre le DNS rebinding** et n’accepte une requête que si son en-tête `Host` vaut `127.0.0.1:`, `localhost:` ou `[::1]:`. L’en-tête `Origin`, quand il y en a un, doit être la forme `http://` du même hôte. Sur votre machine, c’est exactement ce qu’il faut : cela empêche une page web malveillante de piloter votre serveur local via un nom DNS qu’elle a fait pointer vers `127.0.0.1`. + +Déployée derrière un vrai nom d’hôte, cette même valeur par défaut rejette **toutes les requêtes** tant que vous ne dites pas le contraire. La vérification s’exécute avant tout ce qui ressemble à du MCP, si bien que rien de ce que vous avez construit n’est même consulté : + +```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=` est le correctif. Autorisez ce que vous servez réellement : + +```python title="server.py" hl_lines="2 13-17" +--8<-- "docs_src/deploy/tutorial001.py" +``` + +* Les entrées de `allowed_hosts` sont des chaînes exactes : `"mcp.example.com"` correspond à un en-tête `Host` sans port et `"mcp.example.com:*"` correspond à n’importe quel port. Listez les deux. +* `allowed_origins` ne compte que pour les navigateurs, car rien d’autre n’envoie `Origin`. C’est le pendant côté serveur de la configuration CORS décrite dans **[Ajouter à une application existante](asgi.md)**. +* Derrière un proxy inverse qui contrôle déjà l’en-tête `Host`, désactiver la vérification est la configuration honnête : `TransportSecuritySettings(enable_dns_rebinding_protection=False)`. +* Passer un `host=` autre que localhost (par exemple `host="mcp.example.com"`) n’autorise **pas** ce nom d’hôte. Cela empêche seulement la valeur par défaut localhost d’armer la protection, ce qui laisse passer tous les Host et tous les Origin. Dites plutôt ce que vous voulez avec `transport_security=`. + +!!! check + Supprimez l’argument `transport_security=security` et déployez quand même l’application. Elle + démarre, `/mcp` route, et chaque requête (y compris depuis un simple `curl`) revient avec : + + ```text + HTTP/1.1 421 Misdirected Request + + Invalid Host header + ``` + + Vous ne trouverez pas ces mots côté client. Un `421` est une réponse HTTP en texte brut, pas une + erreur JSON-RPC, si bien que le client MCP lève une erreur de transport générique ; le nom d’hôte + qu’il n’a pas apprécié n’apparaît que dans le journal du **serveur**, sous la forme d’un unique + avertissement. Un serveur fraîchement déployé qui refuse toutes les connexions est un problème + de liste des hôtes autorisés jusqu’à preuve du contraire. + **[Dépannage](../troubleshooting.md)** commence aussi par là. + +## Les workers, et qui a besoin d’affinité {#workers-and-who-has-to-be-sticky} + +Une fois que le nom d’hôte répond, placez plus d’un worker derrière lui. Le SDK n’a aucun réglage pour cela ; vous passez une application Starlette à l’échelle comme n’importe quelle application ASGI, en confiant l’objet à quelque chose qui sait créer des processus (fork) : + +```console +uvicorn server:app --workers 4 +``` + +Quatre processus, un socket. Et maintenant la question à laquelle tout déploiement doit répondre : **une requête doit-elle atteindre le worker qui a vu la précédente ?** + +Pour un client qui parle le protocole **2026-07-28**, non. Une requête moderne est un unique POST autonome : pas de poignée de main (handshake) `initialize` avant elle, pas de `Mcp-Session-Id` sur la réponse, rien *vers quoi* une deuxième requête devrait revenir. Routez-la vers n’importe quel worker. + +Ce n’est pas un mode que vous activez. `stateless_http=True` en a tout l’air, mais le transport route d’après l’en-tête de requête `MCP-Protocol-Version`, confie une requête moderne au gestionnaire moderne, et **rend la main**. La ligne qui lit `stateless_http` vient *après* ce retour. Ce n’est pas que l’indicateur soit ignoré sur le chemin 2026-07-28 ; il n’est jamais atteint. `stateless_http` est un réglage pour la branche **historique** uniquement, et le chemin moderne est sans session par construction. + +Pour un client historique en version 2025-11-25 de la spécification ou antérieure, la réponse dépend de cet indicateur : + +| Version du protocole du client | Session | Ce que le répartiteur de charge doit faire | +| --- | --- | --- | +| **2026-07-28** | Aucune. `Mcp-Session-Id` n’est jamais défini. | Rien. N’importe quel worker sert n’importe quelle requête. | +| **2025-11-25 et antérieures** (par défaut) | `Mcp-Session-Id`, conservé dans la mémoire d’un seul worker. | **Affinité de session (sticky sessions).** Une requête suivante qui atteint un autre worker reçoit un `404` *« Session not found »*. | +| **2025-11-25 et antérieures**, avec `stateless_http=True` | Aucune. | Rien. Le prix à payer est le canal de retour (back-channel) du serveur vers le client — échantillonnage (sampling), élicitation (elicitation) en push, `roots/list` — et la reprise. | + +L’affinité de session et le coût de la branche historique ont leur propre page, **[Prendre en charge les clients historiques](legacy-clients.md)** ; les deux générations elles-mêmes sont décrites dans **[Versions du protocole](../protocol-versions.md)**. Ce qui compte ici, c’est la forme de la réponse : *en version 2026-07-28, vous êtes déjà sans état, sans rien à configurer.* + +Le reste de cette page porte sur les deux choses que l’absence d’état ne vous apporte **pas**. + +## `requestState` d’un worker à l’autre {#requeststate-across-workers} + +Un outil **[à plusieurs allers-retours (multi-round-trip)](../handlers/multi-round-trip.md)** a besoin de quelque chose que le client doit aller chercher (une confirmation, un choix, un identifiant), il renvoie donc une question au lieu d’une réponse et termine lors de la nouvelle tentative. Entre les deux tours, le client détient un jeton `request_state` opaque émis par le serveur. Lors de la nouvelle tentative, le serveur doit rouvrir ce jeton. + +*Scellé sous quelle clé ?* Par défaut, une clé que le serveur a générée avec `os.urandom(32)` au moment de sa construction. Avec `--workers 4`, cela fait quatre constructions, dans quatre processus : quatre clés différentes, jamais écrites nulle part, jamais partagées, perdues au redémarrage. + +Voici un outil qui demande avant d’agir, sur un serveur qui ne configure rien : + +```python title="server.py" hl_lines="14 20" +--8<-- "docs_src/deploy/tutorial002.py" +``` + +Le premier tour atteint le worker A. Le worker A scelle `refund:120` sous **sa** clé et renvoie le jeton. Le client présente la question à une personne, obtient un oui, et retente. La nouvelle tentative est une requête HTTP toute neuve. + +!!! check + Laissez cette nouvelle tentative atteindre le worker B. B essaie de desceller un jeton qu’il n’a + pas émis, n’y parvient pas, et refuse tout le tour. `refund` n’est jamais appelé ; le client + reçoit une erreur JSON-RPC : + + ```json + { + "code": -32602, + "message": "Invalid or expired requestState", + "data": {"reason": "invalid_request_state"} + } + ``` + + Ce message est **figé**. Expiré, falsifié, rejoué avec des arguments différents, ou (de loin la + cause la plus fréquente dans un vrai déploiement) scellé par un worker voisin : le client reçoit + chaque fois la même chose, si bien que la liaison ne révèle jamais quelle vérification a échoué. + La vraie raison est un unique `WARNING` dans le journal du serveur : + + ```text + requestState rejected on tools/call: unknown key + ``` + + Un outil à plusieurs allers-retours qui fonctionnait avec un worker et s’est mis à échouer *de + temps en temps* avec deux, c’est cela. Les deux tours doivent toujours atteindre le même + processus, il échoue donc exactement aussi souvent que votre répartiteur de charge les sépare. + +Les deux tours sont deux requêtes HTTP indépendantes, et plusieurs choses ordinaires les séparent : un proxy qui répartit requête par requête, une connexion tombée entre les deux, un déploiement ou un redémarrage, un client qui a persisté `request_state` et reprend depuis un tout autre processus (**[Piloter la boucle vous-même](../handlers/multi-round-trip.md#driving-the-loop-yourself)**). Chacune d’elles revient à « un autre worker ». + +Le correctif tient en un argument. Il a **deux** moitiés. + +```python title="server.py" hl_lines="1 12 14" +--8<-- "docs_src/deploy/tutorial003.py" +``` + +* **`keys=[...]`** est la moitié que tout le monde trouve. Donnez à chaque instance le même secret (au moins 32 octets), et chaque instance peut desceller ce que n’importe quelle autre a émis. `keys[0]` scelle et chaque clé de la liste descelle, ce qui forme l’anneau de rotation ; **[Faire tourner les clés](../handlers/multi-round-trip.md#rotating-keys)** explique comment le faire tourner sans interruption de service. +* **Le nom du serveur** est la moitié que presque personne ne trouve, et la raison pour laquelle les nouvelles tentatives entre instances échouent encore après avoir partagé la clé. Chaque jeton scellé porte le `name` du serveur comme **revendication d’audience** (audience claim), vérifiée strictement au retour. Deux instances construites à partir du même code ont le même nom et ne le remarquent jamais. Nommez-les différemment (`MCPServer(f"billing-{POD}")` ressemble à une bonne hygiène d’observabilité), et chaque nouvelle tentative entre instances est refusée exactement comme ci-dessus, clé partagée ou non. Le journal indique `audience` au lieu de `unknown key` ; le client ne voit pas la différence. + +Générez le secret une fois et donnez la même valeur à chaque instance. C’est la commande que le message d’erreur du SDK lui-même vous indique d’exécuter si vous lui passez moins de 32 octets : + +```console +python -c "import secrets; print(secrets.token_hex(32))" +``` + +!!! warning "Les mêmes clés, *et* le même nom" + Un déploiement à plusieurs instances doit partager les deux. Si les noms par instance comptent + vraiment pour vous, donnez plutôt une audience explicite à toute la flotte : + `RequestStateSecurity(keys=[...], audience="billing")`. Chaque instance émet et accepte alors + sous `"billing"`, quel que soit son nom. + +Tout le reste sur le scellement se trouve dans **[Protéger `requestState`](../handlers/multi-round-trip.md#protecting-requeststate)** : ce qu’il lie, le `ttl` par tour (600 secondes par défaut), apporter votre propre codec, pourquoi la valeur par défaut non configurée est exactement ce qu’il faut sur `stdio`. Toute la contribution de cette page tient en une liste de contrôle à deux éléments : *mêmes clés, même nom.* + +!!! info + Vous êtes sur ce chemin même si vous n’avez jamais tapé `InputRequiredResult`. Un outil dont les + paramètres utilisent `Resolve(...)` (**[Dépendances](../handlers/dependencies.md)**) est un outil + à plusieurs allers-retours, et le SDK émet et scelle son `request_state` pour lui. Même clé par + défaut, même échec entre workers, même correctif. + +## Notifications de changement d’une réplique à l’autre {#change-notifications-across-replicas} + +Le flux `subscriptions/listen` d’un client est une unique réponse de longue durée, il est donc épinglé à une réplique pendant toute sa durée de vie. Un `ctx.notify_resource_updated(...)` publié sur une **autre** réplique doit l’atteindre. + +La jonction entre les deux est le `SubscriptionBus`. Le bus que vous donnez à un serveur est celui où va chaque publication et sur lequel écoute chaque flux ouvert ; donnez donc le même bus à chaque réplique : + +```python title="server.py" hl_lines="2 7 9" +--8<-- "docs_src/deploy/tutorial004.py" +``` + +Rien dans la diffusion ne se soucie de l’objet serveur auquel un flux est attaché. Deux serveurs qui partagent un même `InMemorySubscriptionBus` se comportent déjà ainsi : ouvrez un flux d’écoute sur l’un, appelez `edit_note` sur l’autre, et le flux en est informé. Ce bus en mémoire ne couvre que les objets serveur d’un même processus, ce qui en fait le modèle, pas le déploiement : + +* Entre de vrais processus, **le SDK ne fournit aucun bus qui puisse vous aider.** `SubscriptionBus` est un `Protocol` à deux méthodes (`publish` et `subscribe`) que vous implémentez par-dessus votre propre backend pub/sub (Redis, NATS, ce que vous exploitez déjà) et passez sous la forme `MCPServer(subscriptions=...)`. **[Abonnements](../handlers/subscriptions.md#scaling-past-one-process)** contient l’esquisse et le contrat. +* Le bus transporte quatre petits événements typés, jamais de JSON-RPC. L’accusé de réception, le filtrage et le cycle de vie des flux restent dans le SDK, si bien que votre bus ne peut pas casser le protocole ; il ne peut que déplacer des événements entre processus. +* Les flux ne sont **pas** reprenables et les événements ne sont **pas** rejoués. Perdre une réplique abandonne ses flux ; les clients se remettent à l’écoute et récupèrent de nouveau les données. Il n’y a pas de magasin d’événements à partager et rien d’autre à configurer. C’est le seul endroit où la montée en charge horizontale revient réellement à faire la même chose en plus grand. + +## Ce que le SDK ne vous donne pas {#what-the-sdk-does-not-give-you} + +Un `MCPServer` est une implémentation du protocole, pas un serveur d’applications. Les réglages de déploiement que vous chercherez ensuite manquent volontairement : + +* **Pas de `workers=`.** `mcp.run("streamable-http")` démarre exactement un processus uvicorn, et c’est tout ce qu’il démarrera jamais. Le multi-processus, c’est `streamable_http_app()` confié à ce avec quoi vous déployez déjà de l’ASGI : `uvicorn --workers`, gunicorn, le gestionnaire de processus de votre plateforme. Cette page n’est délibérément un tutoriel pour aucun d’eux ; leur documentation est meilleure que ne le serait une copie ici. +* **Pas de route de contrôle de santé.** `@mcp.custom_route("/health", methods=["GET"])` est toute la réponse, et elle n’est jamais authentifiée même quand le reste du serveur l’est. C’est ce qu’il faut pour une sonde de vivacité, pas pour quoi que ce soit de privé. **[Ajouter à une application existante](asgi.md#custom-routes)** en montre une. +* **Pas d’objet de réglages de production.** Il n’y a nulle part sur `MCPServer` où noter les délais d’expiration, TLS, l’arrêt progressif ou les limites de connexions, parce que rien de cela n’est son travail. Cela relève de votre serveur ASGI, et c’est là que vous le configurez. **[Exécuter votre serveur](index.md)** couvre la poignée de réglages que le constructeur accepte *effectivement*. +* **Pas de `EventStore` fourni, et en version 2026-07-28 aucun usage pour un tel objet.** La reprise est une fonctionnalité de la branche historique avec état ; un échange moderne, c’est un POST, une réponse, et rien à reprendre. + +## Récapitulatif {#recap} + +* Par défaut, l’application ne répond qu’aux requêtes adressées à localhost. `transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])` est le passage obligé avant la mise en production : tant que vous ne le passez pas, chaque requête derrière un vrai nom d’hôte est un `421` et la raison n’est que dans le journal du serveur. +* En version 2026-07-28, il n’y a pas de session et rien sur quoi un répartiteur de charge pourrait établir une affinité. `stateless_http=True` est un réglage réservé à la branche historique, parce qu’une requête moderne est routée et traitée avant même que cet indicateur soit lu. +* La clé `requestState` par défaut est `os.urandom(32)`, générée par processus. Une nouvelle tentative à plusieurs allers-retours qui atteint un autre worker échoue avec `-32602` *« Invalid or expired requestState »*. +* Le correctif est `RequestStateSecurity(keys=[...])` **et** le même nom de serveur sur chaque instance. Le nom est la revendication d’audience par défaut du jeton. Mêmes clés, même nom. +* Les notifications de changement traversent les répliques via un unique `SubscriptionBus` partagé. La seule implémentation du SDK fonctionne dans un seul processus ; le `Protocol` à deux méthodes par-dessus votre propre pub/sub, c’est à vous de l’écrire. +* Il n’y a pas de `workers=`, pas de route de santé, pas d’objet de réglages de production. Apportez votre propre serveur ASGI. + +L’autre chose dont un vrai nom d’hôte a besoin devant lui, c’est un jeton : **[Autorisation](authorization.md)**. diff --git a/i18n/fr/pages/run/index.md b/i18n/fr/pages/run/index.md new file mode 100644 index 0000000000..16f2419d6e --- /dev/null +++ b/i18n/fr/pages/run/index.md @@ -0,0 +1,156 @@ +--- +translation: + sections: [fea8d769ff9edeba, ce8e2ad42f29ef71, 0d705efb19cf99c2, 7a53ead3e704a7f0, 9adc400e8c88e854, 318893ad8e2e9924, 6b63ab96b34476c0] + tool: 1 +--- +# Exécuter votre serveur {#running-your-server} + +`mcp.run()` démarre le serveur. + +La seule décision que vous prenez concerne le **transport** : la façon dont les octets circulent réellement entre votre serveur et son client. + +## Choisir un transport {#pick-a-transport} + +| Transport | Ce que c’est | Quand | +|---|---|---| +| `stdio` | L’hôte lance votre fichier comme sous-processus et communique via son stdin et son stdout. | Serveurs locaux. La valeur par défaut. | +| `streamable-http` | Un véritable serveur HTTP qui écoute sur un port. | Tout ce que vous déployez. | +| `sse` | L’ancien transport HTTP. | Jamais. | + +!!! warning + SSE a été remplacé par Streamable HTTP dans la révision 2025-03-26 du protocole. + `mcp.run(transport="sse")` fonctionne toujours, avec ses propres options `sse_path=` et `message_path=`, + mais il n’existe que pour les clients qui n’ont pas encore migré. Ne construisez rien de nouveau dessus. + +## `mcp.run()` {#mcprun} + +```python title="server.py" hl_lines="12-13" +--8<-- "docs_src/run/tutorial001.py" +``` + +* `run()` est synchrone. Elle bloque pendant toute la durée de vie du serveur. +* Sans argument, le transport est `stdio`. +* Elle se trouve sous `if __name__ == "__main__":` parce que tout ce qui charge votre serveur (`mcp dev`, `mcp run`, `mcp install`, vos tests) **importe** ce fichier. La garde empêche un import de se transformer en serveur en cours d’exécution. + +### stdio {#stdio} + +Il n’y a rien à configurer. L’hôte démarre votre fichier comme processus enfant, écrit les requêtes sur son stdin et lit les réponses sur son stdout. + +Lancez-le vous-même et vous en voyez la conséquence : + +```console +python server.py +``` + +Rien ne s’affiche, et le programme ne rend pas la main. Il attend sur stdin qu’un hôte parle en premier. + +Cela signifie aussi que stdout **est la liaison elle-même**. Pendant le service, le SDK déplace la liaison vers un descripteur privé et redirige vers stderr la sortie *vidée* sur stdout (un sous-processus qui écrit sur son stdout hérité, un `print()` vidé), où elle ne peut pas corrompre le flux. La sortie vidée sur stdout *avant* le début du service (un script d’enrobage qui affiche quelque chose, un print non tamponné au moment de l’import) atterrit toujours sur la liaison, de même qu’un `print()` qui reste en tampon jusqu’à ce que l’interpréteur le vide à la sortie. Pour la sortie que vous voulez réellement, le module `logging` est le bon outil : son gestionnaire vide chaque enregistrement sur stderr au moment où il se produit. Tous les détails sont dans **[Journalisation](../handlers/logging.md)**. + +### Essayer {#try-it} + +```console +uv run mcp dev server.py +``` + +L’Inspector fait exactement ce que fait un véritable hôte : il lance `server.py` comme sous-processus et s’y connecte via stdio. + +Vous ne lui avez jamais donné de port. Il n’y en a pas. + +## Streamable HTTP {#streamable-http} + +Pour placer le même serveur sur un port à la place, nommez le transport (et ses options) dans `run()` : + +```python title="server.py" hl_lines="13" +--8<-- "docs_src/run/tutorial002.py" +``` + +Cette seule ligne construit une application Starlette et la sert avec uvicorn. Les clients se connectent à `http://127.0.0.1:3001/mcp`. + +Chaque transport a ses propres arguments nommés, tous sur `run()` : + +* `host` / `port` : où écouter. Valeurs par défaut `127.0.0.1` et `8000`. +* `streamable_http_path` : où se trouve le point de terminaison MCP. Valeur par défaut `/mcp`. +* `json_response=True` : répondre à chaque POST par un corps JSON unique au lieu d’un flux SSE. Ce corps a de la place pour la réponse et rien d’autre : un outil qui rappelle le client en cours de requête (`ctx.elicit()`, échantillonnage) lève donc `NoBackChannelError` sur ce tronçon, et les notifications liées à l’appel en cours (la progression de `ctx.report_progress()`, les messages de journal par appel) sont abandonnées ; le flux `GET` autonome transporte toujours celles qui n’y sont pas liées. +* `stateless_http=True` : un transport neuf par requête, sans suivi de session. +* `max_request_body_size` : la taille maximale acceptée pour le corps d’un POST, en octets. Vaut 4 Mio par défaut ; les requêtes plus grandes + reçoivent un HTTP 413 avant toute analyse ou création de session. Ne l’augmentez que lorsque des messages MCP légitimes + dépassent cette taille. +* `event_store`, `retry_interval`, `transport_security` : reprise après coupure et protection contre le DNS rebinding. Ils peuvent attendre, jusqu’à ce que vous déployiez ailleurs que sur localhost ; **[Déployer et passer à l’échelle](deploy.md)** couvre `transport_security`. + +!!! warning + Les options de transport vont à `run()`, **pas** à `MCPServer(...)`. Le constructeur décrit ce que + votre serveur *est* : nom, version, instructions. `run()` décrit comment il est servi. Inversez-les + et Python répond avant même que MCP n’entre en jeu : + + ```text + TypeError: MCPServer.__init__() got an unexpected keyword argument 'port' + ``` + +`run()` est le chemin court. Dès que vous avez besoin de plus (votre serveur monté dans une application existante, deux serveurs dans un même processus, CORS pour les clients navigateur), vous construisez l’application ASGI vous-même et la confiez à n’importe quel hôte ASGI. C’est **[Ajouter à une application existante](asgi.md)**. + +## Paramètres du serveur {#server-settings} + +Quelques aspects de l’exécution ne concernent pas le transport. Ce sont des arguments du constructeur : + +```python title="server.py" hl_lines="3" +--8<-- "docs_src/run/tutorial003.py" +``` + +* `log_level` : transmis à `logging.basicConfig()` au moment où `MCPServer(...)` est construit. Cela configure le logger **racine**, et fixe donc le niveau de vos propres loggers aussi, pas seulement ceux du SDK. Valeur par défaut `"INFO"`. +* `debug` : transmis à l’application Starlette que construisent les transports HTTP. Valeur par défaut `False`. + +Les deux atterrissent sur `mcp.settings`, que vous pouvez relire à l’exécution. + +## La commande `mcp` {#the-mcp-command} + +L’extra `[cli]` installe un petit outil en ligne de commande autour de tout cela. + +`mcp dev` exécute votre serveur sous le **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` ajoute des paquets à l’environnement qu’il construit ; `--with-editable` y installe votre propre paquet. Il a besoin de `npx` dans votre `PATH` : l’Inspector est une application Node.js. + +`mcp run` importe le fichier, trouve l’objet serveur (un `mcp`, `server` ou `app` au niveau du module) et appelle `run()` dessus : + +```console +uv run mcp run server.py +uv run mcp run server.py:bookshop +``` + +Le suffixe `:` nomme l’objet lorsqu’il ne s’appelle pas `mcp`, `server` ou `app`. + +Votre bloc `if __name__ == "__main__":` ne s’exécute jamais ici : `mcp run` appelle `run()` lui-même, et la seule option qu’il transmet est `--transport`. + +`mcp install` enregistre le serveur auprès de **Claude Desktop**, pour que l’application le lance pour vous : + +```console +uv run mcp install server.py --name "Bookshop" +uv run mcp install server.py -v API_KEY=abc123 -f .env +``` + +`-v KEY=VALUE` et `-f .env` consignent des variables d’environnement dans cette entrée. Claude Desktop démarre votre serveur dans son propre processus. L’environnement de votre shell n’y est pas. + +Claude Desktop est le seul hôte que `mcp install` connaît. Tous les autres hôtes (Claude Code, Cursor, VS Code) prennent la même commande de lancement dans leur propre fichier de configuration, et **[Se connecter à un véritable hôte](../get-started/real-host.md)** détaille chacun d’eux. + +`mcp version` affiche la version du SDK installée. + +!!! tip + `mcp dev` et `mcp run` ne comprennent que `MCPServer`. Si vous construisez avec le `Server` bas niveau, + vous l’exécutez vous-même. Voir **[Le Server bas niveau](../advanced/low-level-server.md)**. + +## Récapitulatif {#recap} + +* Un **transport** est la façon dont les octets atteignent votre serveur : `stdio` pour un sous-processus local, `streamable-http` pour un port. SSE est remplacé. +* `mcp.run()` choisit le transport. Sans argument, c’est `stdio`, et elle bloque. +* Chaque option de transport (`host`, `port`, `streamable_http_path`, ...) est un argument de `run()`, jamais de `MCPServer(...)`. +* Gardez `run()` sous `if __name__ == "__main__":`. Tout ce qui charge votre serveur importe d’abord le fichier. +* `log_level=` et `debug=` sont des arguments du constructeur ; ils atterrissent sur `mcp.settings`. +* `mcp dev` pour l’Inspector, `mcp run` pour exécuter un fichier, `mcp install` pour Claude Desktop, `mcp version` pour la version. +* Le transport ne change jamais ce que votre serveur *est* : les trois fichiers de cette page exposent le même outil, à l’identique. + +Quand `run()` elle-même est la limite (votre serveur à l’intérieur d’une application qui existe déjà), c’est **[Ajouter à une application existante](asgi.md)**. Un vrai nom d’hôte et plus d’un worker, c’est **[Déployer et passer à l’échelle](deploy.md)**. Et si certains de vos clients sont encore sur la version 2025-11-25 de la spécification ou une version antérieure, **[Prendre en charge les clients historiques](legacy-clients.md)** est la bonne nouvelle. diff --git a/i18n/fr/pages/run/legacy-clients.md b/i18n/fr/pages/run/legacy-clients.md new file mode 100644 index 0000000000..80b1f796e3 --- /dev/null +++ b/i18n/fr/pages/run/legacy-clients.md @@ -0,0 +1,135 @@ +--- +translation: + sections: [3d1663c18edc824c, d4fd37009a13f03d, af9f398a5a8b679a, 470c2dd144294d69, 8e45827e6d24e8c8, 91dfd0ce98ebb03c] + tool: 1 +--- +# Prendre en charge les clients historiques {#serving-legacy-clients} + +MCP a deux générations de protocole : la génération de la poignée de main (handshake) `initialize`, jusqu’à la version de spécification `2025-11-25`, et la génération moderne, `2026-07-28`. **[Versions du protocole](../protocol-versions.md)** est la page consacrée à cette séparation elle-même. + +Cette page traite du côté serveur de cette séparation, et la réponse tient en une phrase : **le `streamable_http_app()` que vous déployez déjà sert les deux.** + +Le SDK route chaque requête selon son en-tête `MCP-Protocol-Version`. Une requête qui indique `2026-07-28` va au gestionnaire (handler) moderne. Une requête qui indique une version de la génération poignée de main, ou qui ne porte aucun en-tête (c’est ainsi qu’arrive la requête `initialize` d’un client antérieur à 2026), va au transport que ces clients attendent : poignée de main `initialize`, sessions et tout le reste. Cela se fait requête par requête, avant votre code, sur la même et unique application. + +Un client historique n’est donc pas quelque chose *pour* lequel vous construisez. C’est quelque chose qui se connecte *au* serveur que vous avez déjà écrit. Vous ne configurez rien. + +!!! note + Rien, littéralement. Il n’y a pas d’option `legacy=`, pas de liste de versions autorisées, aucun + moyen de refuser ou de désactiver une génération : ni sur `streamable_http_app()`, ni sur `run()`, + ni sur le gestionnaire de sessions. Les deux générations sont toujours actives. Ce qui se rapproche + le plus d’un interrupteur par génération dans cette signature, c’est `stateless_http`, et il occupe + l’essentiel de cette page. + +## Un gestionnaire, deux générations {#one-handler-both-eras} + +Voici un outil (tool) qui doit demander quelque chose à l’utilisateur, et des clients des deux générations qui l’appellent : + +```python title="server.py" hl_lines="24 37-38" +--8<-- "docs_src/legacy_clients/tutorial001.py" +``` + +`reserve` a besoin d’une chose que le modèle n’a pas fournie : le nombre d’exemplaires. `Annotated[..., Resolve(ask_quantity)]` est la façon dont un outil le déclare (tous les détails sont dans **[Dépendances](../handlers/dependencies.md)**). Rien dans `reserve` ne nomme une version, ne vérifie une capacité ni ne bifurque. + +Les deux clients sont ouverts **en même temps**, sur le même objet `mcp`. `mode="legacy"` exécute la poignée de main `initialize` : exactement la connexion qu’ouvre un client antérieur à 2026. L’autre prend la valeur par défaut et arrive en version `2026-07-28`. + +```text +2025-11-25 {'result': "Reserved 2 of 'Dune'."} +2026-07-28 {'result': "Reserved 2 of 'Dune'."} +``` + +Même serveur, même gestionnaire, même réponse. C’est toute la fonctionnalité. + +Cela vaut la peine de s’arrêter sur le *comment*, car la même question a été posée aux deux clients sur deux liaisons complètement différentes. La connexion `2026-07-28` n’a aucun canal sur lequel le serveur puisse envoyer une requête ; `Resolve` a donc renvoyé la question dans le résultat de l’outil, et le client a relancé l’appel avec la réponse (**[Requêtes à plusieurs allers-retours (multi-round-trip)](../handlers/multi-round-trip.md)**). La connexion `2025-11-25` n’a rien de tel ; là, `Resolve` a envoyé une vraie requête `elicitation/create` en plein appel et a attendu. Vous n’avez écrit ni l’un ni l’autre. `Resolve` lit la version négociée de la connexion et choisit ; le corps de votre outil voit un `AcceptedElicitation` dans les deux cas. + +!!! tip + Cette portabilité entre générations est *la raison* pour laquelle `Resolve` est l’API sur laquelle + construire. Son aînée `ctx.elicit()` (**[Élicitation](../handlers/elicitation.md)**) n’envoie jamais + que `elicitation/create`, et ne fonctionne donc que sur une connexion historique. Sur une connexion + `2026-07-28`, l’appel échoue. Si un outil l’utilise encore, le correctif est celui que vous voyez + ci-dessus, pas une vérification de version. + +## Ce que vous coûte une session historique {#what-a-legacy-session-costs-you} + +Le routage est gratuit. La session ne l’est pas. + +Une connexion `2026-07-28` est **sans session** : chaque requête est autonome, et le gestionnaire moderne n’émet jamais de `Mcp-Session-Id`. Une connexion historique, c’est l’inverse. Dès qu’un client antérieur à 2026 envoie `initialize`, le SDK crée un `Mcp-Session-Id`, le renvoie dans un en-tête de réponse et conserve derrière lui un enregistrement vivant que les requêtes ultérieures du client retrouveront : la version négociée, les flux ouverts, une tâche d’arrière-plan qui pilote la session. + +Cet enregistrement est un **simple `dict` en mémoire du processus**. Il n’y a pas de magasin de sessions distribué, ni aucun moyen d’en brancher un. + +Sur un seul worker, c’est invisible. Sur deux, c’est tout le problème : une requête qui porte un `Mcp-Session-Id` et atterrit sur un worker qui ne l’a pas créé ne trouve rien dans ce dict, et la réponse est un `404` (`Session not found`), pas le résultat de l’outil. Dès que vous exécutez plus d’un worker, **les clients historiques ont donc besoin d’un routage avec affinité (sticky routing)** : chaque requête d’une session doit atteindre le processus qui l’a démarrée. Les clients modernes, jamais ; ils n’ont aucune session à laquelle rester attachés. **[Déployer et passer à l’échelle](deploy.md)** couvre l’affinité et tout le reste sur l’exécution de plusieurs instances. + +!!! warning + `event_store=` ressemble au correctif et ne l’est pas. C’est la **reprise** (rejouer les + événements SSE manqués pour un client qui se reconnecte à la *même* session), pas un magasin de + sessions. Il ne rend jamais une session accessible depuis un autre processus. + +## Le seul réglage : `stateless_http` {#the-one-knob-stateless_http} + +Si l’affinité est un coût que vous refusez de payer, il y a exactement une chose que vous pouvez changer. + +```python title="server.py" hl_lines="28" +--8<-- "docs_src/legacy_clients/tutorial002.py" +``` + +C’est le serveur du haut de la page, plus un mot-clé. `stateless_http=True` fait que la voie historique construit à la place une session jetable, propre à chaque requête : aucun `Mcp-Session-Id` émis, rien de mémorisé entre les requêtes, si bien que n’importe quel worker peut servir n’importe quelle requête et que le répartiteur de charge peut faire ce qu’il veut. + +Deux choses à son sujet comptent plus que ce qu’il fait. + +**Il ne touche que la voie historique.** Les requêtes sont routées sur l’en-tête de version *avant* que `stateless_http` ne soit lu, si bien que la voie moderne ne le voit jamais. Une connexion `2026-07-28` est déjà sans session et reste exactement la même quelle que soit la valeur. + +**Il coûte les deux canaux serveur-vers-client sur cette voie.** Une session qui vit le temps d’un seul `POST` n’a aucun flux dans lequel le serveur puisse pousser une requête, ni aucun flux autonome dans lequel pousser des notifications. Toute requête à l’initiative du serveur lève `NoBackChannelError` : `ctx.elicit()`, les appels retirés d’échantillonnage (sampling) et de racines (roots) (**[Fonctionnalités obsolètes](../deprecated.md)**), et, oui, `Resolve` qui pose sa question à un client *historique*. Les notifications n’ont même pas droit à une erreur ; elles sont abandonnées silencieusement. + +!!! note + `json_response=True` n’est pas ce réglage, mais il prélève la moitié du même coût sur *chaque* + session historique : un `POST` auquel on répond par un seul corps JSON n’a aucun flux pour le canal + lié à la requête, si bien qu’un `ctx.elicit()` en cours de requête lève la même `NoBackChannelError` + et que les notifications liées à la requête sont abandonnées. Le flux autonome de la session n’est + pas touché : les notifications sans rapport arrivent toujours. + +!!! check + Faites la mauvaise chose. `reserve` est exactement l’outil qui vient de servir les deux clients. + Déployez-le avec `stateless_http=True`, connectez les deux mêmes clients en HTTP et appelez-le + depuis chacun. + + Le client moderne obtient toujours `Reserved 2 of 'Dune'.` La voie moderne n’a pas changé. + + L’appel du client historique ne revient pas sous la forme d’un résultat `is_error` que le modèle + pourrait lire. La requête entière échoue, en erreur de protocole de premier niveau : + + ```text + mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests. + ``` + + `Resolve` ne vous a pas sauvé. Sur une connexion `2025-11-25`, il *doit* envoyer + `elicitation/create`, et le canal dont il a besoin est exactement ce que `stateless_http=True` a + abandonné. Un code portable entre générations n’est pas un code sans canal de retour (back-channel). + +C’est donc un vrai compromis, et il n’existe que sur la voie historique : **avec session et affinité, ou sans état et à sens unique.** Si vos outils ne rappellent jamais le client, `stateless_http=True` est gratuit et vous devriez le prendre. S’ils le font, gardez les sessions et gardez le routage avec affinité. + +## Où votre code bifurque réellement {#where-your-code-actually-forks} + +Presque nulle part. + +Outils, ressources, prompts, sortie structurée, progression, erreurs : aucun ne se soucie de la génération qui a appelé. La poignée de main `initialize`, le `Mcp-Session-Id`, le flux autonome, le `DELETE` qui met fin à une session : le SDK possède tout cela, et un gestionnaire n’en voit jamais rien. La saisie interactive est *le* seul endroit où les générations diffèrent véritablement sur la liaison, et `Resolve` existe pour que ce ne soit pas votre problème : vous venez de voir un seul outil servir les deux. + +Il reste exactement une chose, et ce sont les **notifications de changement**, parce que les deux générations écoutent sur des tuyaux différents : + +* Un client `2026-07-28` ouvre un flux `subscriptions/listen` et lit le bus des abonnements. `ctx.notify_resource_updated()` (et `notify_tools_changed()`, `notify_prompts_changed()`, `notify_resources_changed()`) y publient, et *seulement* là. **[Abonnements](../handlers/subscriptions.md)** est la page correspondante. +* Un client historique lit le flux autonome que sa session garde ouvert. `ctx.session.send_resource_updated()` (et `send_tool_list_changed()` et consorts) écrivent sur la *connexion* qui a porté la requête : pour une session historique, c’est son flux autonome. Une connexion moderne n’a pas d’endroit pour cela : en HTTP, ce canal n’existe pas, et en stdio les quatre types de notifications de changement ne circulent que sur les flux `subscriptions/listen`, si bien que sur une connexion moderne la notification est discrètement abandonnée. + +En HTTP, aucun des deux appels n’atteint les clients de l’autre génération. Pour prévenir tout le monde, appelez les deux : + +```python title="server.py" hl_lines="19-20" +--8<-- "docs_src/legacy_clients/tutorial003.py" +``` + +Deux lignes, pas de `if`, pas de vérification de version, et c’est terminé. C’est la liste complète des choses qu’un gestionnaire fait différemment parce qu’un client historique existe. + +## Récapitulatif {#recap} + +* Un seul `streamable_http_app()` sert les deux générations de protocole. Le SDK route chaque requête selon son en-tête `MCP-Protocol-Version` ; il n’y a rien à configurer et aucun réglage de génération à chercher. +* Un client historique vous coûte une session : un enregistrement `Mcp-Session-Id` en mémoire du processus, sans magasin distribué derrière. Plus d’un worker signifie **routage avec affinité**, sinon le mauvais worker répond `404 Session not found`. Tous les détails sur le multi-worker sont dans **[Déployer et passer à l’échelle](deploy.md)**. +* `stateless_http=True` est le seul réglage, et il ne concerne **que la voie historique**. Il offre une répartition de charge gratuite aux clients historiques au prix des deux canaux serveur-vers-client sur cette voie : les requêtes à l’initiative du serveur lèvent `NoBackChannelError` (une erreur de premier niveau côté client, pas un résultat `is_error`), et les notifications sont abandonnées. +* Une connexion `2026-07-28` est sans session dans tous les cas. `stateless_http` ne la touche jamais. +* Le code de vos gestionnaires bifurque selon la génération à un seul endroit exactement : les notifications de changement. `ctx.notify_*` atteint les clients `subscriptions/listen` ; `ctx.session.send_*` atteint les sessions historiques. Appelez les deux. +* Tout le reste (y compris demander une saisie à l’utilisateur, via `Resolve`) est portable entre générations par construction. Écrivez la version moderne une seule fois. diff --git a/i18n/fr/pages/run/opentelemetry.md b/i18n/fr/pages/run/opentelemetry.md new file mode 100644 index 0000000000..b5df8edc88 --- /dev/null +++ b/i18n/fr/pages/run/opentelemetry.md @@ -0,0 +1,112 @@ +--- +translation: + sections: [bc0227014724fa49, 15738c2f7fd67d86, a2c17bbe3f707e2f, d0d853376f162c06, b6368643fcc1c8d8, 902e33e17564a607] + tool: 1 +--- +# OpenTelemetry {#opentelemetry} + +Votre serveur est déjà tracé. Vous n’avez rien à ajouter. + +Chaque serveur que vous créez émet un span [OpenTelemetry](https://opentelemetry.io/) pour chaque +message qu’il traite. Vous ne l’avez pas écrit, et vous ne l’importez pas. Il est là dès l’instant où vous +appelez `MCPServer(...)`. + +```python title="server.py" +--8<-- "docs_src/opentelemetry/tutorial001.py" +``` + +C’est un serveur complet, et tracé. Appelez `search_books` et un span est créé pour cet appel. Il en va de +même pour le `Server` bas niveau : le traçage est présent sur les deux. + +## Ce que vous obtenez {#what-you-get} + +Chaque message entrant devient un span `SERVER` nommé d’après la méthode et sa cible. Ainsi, un +`tools/call` pour `search_books` donne le span `tools/call search_books`, et un simple `tools/list` +donne tout bonnement `tools/list`. + +Chaque span porte quelques attributs : + +* `mcp.method.name` et `mcp.protocol.version`, sur chaque span. +* `jsonrpc.request.id`, sur une requête (une notification n’en a pas). +* Un gestionnaire qui lève une exception passe le statut du span à erreur. Un résultat d’outil avec `is_error=True` aussi. + +Et comme tracer un appel d’outil est un besoin très courant, les spans `tools/call` parlent les +[conventions sémantiques GenAI](https://opentelemetry.io/docs/specs/semconv/gen-ai/) d’OpenTelemetry : + +* `gen_ai.operation.name`, défini à `"execute_tool"`. +* `gen_ai.tool.name`, défini au nom de l’outil appelé. + +Un span `prompts/get` reçoit `gen_ai.prompt.name` dans le même esprit. Les méthodes de liste ne portent aucune +clé `gen_ai.*`, car il n’y a rien à nommer. + +!!! tip + Ces attributs GenAI sont la raison pour laquelle une interface de traçage regroupe vos appels d’outils + comme elle regroupe ceux de n’importe quel autre agent. Vous obtenez ce regroupement gratuitement, sans code supplémentaire. + +## Cela ne coûte rien tant que vous n’en voulez pas {#it-costs-nothing-until-you-want-it} + +Voici ce qui fait de « activé par défaut » une valeur par défaut confortable. + +Le SDK ne dépend que de `opentelemetry-api`, la moitié légère d’OpenTelemetry. Sans SDK +ni exportateur installé, créer un span est une opération vide. Les spans que votre serveur émet en ce +moment même ne vous coûtent donc presque rien, et personne ne les collecte. + +Le jour où vous voulez les *voir*, vous installez l’autre moitié et vous la pointez quelque part : + +```console +uv add opentelemetry-sdk opentelemetry-exporter-otlp +``` + +Configurez un exportateur de la manière habituelle pour OpenTelemetry, et chaque span que le SDK +créait discrètement s’allume. Le code de votre serveur ne change pas. Pas une ligne. + +!!! info + [Pydantic Logfire](https://logfire.pydantic.dev/) est l’un de ces backends, et il fait la + configuration pour vous : `pip install logfire`, `logfire.configure()`, et vos spans MCP apparaissent + dans la vue en direct. Il est construit sur OpenTelemetry, donc tout ce qui suit s’y applique aussi. + +## Des traces qui traversent la liaison {#traces-that-cross-the-wire} + +Une trace est surtout utile lorsqu’elle suit une requête du client jusque dans le serveur, en une +seule image cohérente. + +Lorsque le client et le serveur exécutent tous deux le SDK, ce lien est automatique. Le client injecte +le [contexte de trace W3C](https://www.w3.org/TR/trace-context/) dans la requête, et le serveur +le relit à l’arrivée, de sorte que le span serveur s’imbrique sous le span client dans la même trace. C’est la +[SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414), et vous l’obtenez sans +rien demander. + +Si le message entrant ne porte aucun contexte de trace, par exemple une requête provenant d’un client qui n’est pas +le SDK, le span serveur se rattache simplement au span déjà courant côté serveur, au lieu +de démarrer une toute nouvelle trace orpheline. + +## Le désactiver {#turning-it-off} + +Le traçage est un middleware, le premier de la liste de votre serveur. Si vous voulez vraiment un serveur qui +n’émet aucun span, retirez-le : + +```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 + Cet import commence par un tiret bas, et c’est voulu. La classe est provisoire, de la + même manière que [`Server.middleware`](../advanced/middleware.md) est provisoire : attendez-vous donc + à ce que le chemin d’import change. Vous n’en avez presque jamais besoin : sans exportateur installé, les spans + sont gratuits, et la réponse habituelle consiste donc à les laisser activés et à ne pas installer d’exportateur. + +## Récapitulatif {#recap} + +* Chaque `MCPServer` et chaque `Server` bas niveau émet un span `SERVER` par message entrant, par + défaut. Vous n’écrivez rien. +* Les spans portent `mcp.method.name` et `mcp.protocol.version` ; `tools/call` et `prompts/get` portent + aussi des attributs GenAI, pour que vos appels d’outils se regroupent comme ceux de n’importe quel autre agent. +* Cela ne coûte rien tant que vous n’installez pas un SDK OpenTelemetry et un exportateur, puis tout s’allume + sans aucune modification de votre serveur. +* Le contexte de trace du client vers le serveur se propage automatiquement lorsque les deux côtés exécutent le SDK. + +Ce qui décide si une requête s’exécute ou non, c’est l’**[Autorisation](authorization.md)**. diff --git a/i18n/fr/pages/servers/completions.md b/i18n/fr/pages/servers/completions.md new file mode 100644 index 0000000000..29dfb2e917 --- /dev/null +++ b/i18n/fr/pages/servers/completions.md @@ -0,0 +1,130 @@ +--- +translation: + sections: [72f9c964769076dd, 9a2c14e10935b515, 235299eb78ab12d7, 8aee1e78c8237fb8, 9bd86acd4112138f, 55343cb7f250dc7b] + tool: 1 +--- +# Complétions {#completions} + +Un client qui construit une interface utilisateur au-dessus de votre serveur veut autocompléter les valeurs des arguments au fil de la saisie de l’utilisateur : noms de langages, noms de dépôts, chemins de fichiers. + +Les **complétions** sont le moyen par lequel votre serveur fournit ces suggestions. + +## Quelque chose à compléter {#something-worth-completing} + +Les complétions s’appliquent à exactement deux choses : les arguments d’un **prompt** et les paramètres d’un **modèle de ressource**. Commencez donc par un serveur qui en possède un de chaque : + +```python title="server.py" hl_lines="6 12" +--8<-- "docs_src/completions/tutorial001.py" +``` + +Rien ici ne concerne encore les complétions. + +* `review_code` prend un `language`. Un utilisateur ne devrait pas avoir à deviner quelles orthographes vous acceptez. +* `github_repo` prend un `owner` et un `repo`. Des champs de texte libre pour les deux font un mauvais formulaire. + +## Le gestionnaire de complétion {#the-completion-handler} + +Ajoutez **une** seule fonction décorée avec `@mcp.completion()` : + +```python title="server.py" hl_lines="21-29" +--8<-- "docs_src/completions/tutorial002.py" +``` + +* Il y a un seul gestionnaire (handler) par serveur. Chaque requête de complétion arrive ici, et vous aiguillez selon ce qui est en cours de complétion. +* Il doit être `async def` : le SDK l’attend avec await. +* Il reçoit trois arguments : + * `ref` : *quel* prompt ou modèle de ressource, sous la forme d’une `PromptReference` ou d’une `ResourceTemplateReference`. C’est `isinstance` qui vous permet de les distinguer. + * `argument` : `argument.name` est l’argument en cours de complétion, `argument.value` est ce que l’utilisateur a saisi jusqu’ici. + * `context` : les arguments déjà résolus. Ignorez-le pour l’instant. +* Vous renvoyez une `Completion(values=[...])`, ou `None` quand vous n’avez rien à proposer. + +!!! tip + `argument.value` est le préfixe que l’utilisateur a saisi. Le SDK ne filtre **pas** pour vous : ce que + vous mettez dans `values` est ce que l’interface affiche. Le `startswith`, c’est à vous de l’écrire. + +### Essayer {#try-it} + +Pilotez-le avec le `Client` en mémoire de **[Tests](../get-started/testing.md)**. Appelez +`client.complete()` avec `ref=PromptReference(name="review_code")` et +`argument={"name": "language", "value": "py"}` : + +```python +result.completion.values # ['python'] +``` + +* `ref` est le même type de référence que celui que reçoit votre gestionnaire. +* `argument` est un simple dict avec exactement deux clés, `name` et `value`. + +Envoyez une `value` vide et vous obtenez toute la liste en retour. `lang.startswith("")` est vrai pour chaque langage : + +```python +result.completion.values # ['go', 'javascript', 'python', 'rust', 'typescript'] +``` + +Interrogez-le sur `code` (un argument que votre gestionnaire ne reconnaît pas) et il renvoie `None`, que le SDK transforme en liste vide : + +```python +result.completion.values # [] +``` + +`None` signifie *« aucune suggestion »*, jamais une erreur. Une interface se rabat sur un simple champ de texte. + +## Une capacité que vous n’avez jamais déclarée {#a-capability-you-never-declared} + +Enregistrer le gestionnaire, c’est la déclarer. Connectez un client et regardez : + +```python +client.server_capabilities.completions # CompletionsCapability() +``` + +Vous n’avez listé `completions` nulle part. Le SDK a vu le gestionnaire et a déclaré la capacité pour vous. Toutes les capacités *optionnelles* fonctionnent ainsi : le gestionnaire est la déclaration. (Les trois primitives ne sont pas optionnelles : `MCPServer` les déclare toujours, gestionnaires ou non.) + +!!! check + Revenez au premier `server.py` (celui sans gestionnaire) et interrogez-le quand même. L’appel échoue + avec une erreur JSON-RPC : + + ```text + Method not found + ``` + + Et `client.server_capabilities.completions` vaut `None`. C’est tout l’intérêt de la capacité : un + client bien conçu la vérifie et n’envoie jamais la requête à laquelle vous ne pouvez pas répondre. + +## Arguments dépendants {#dependent-arguments} + +`github://repos/{owner}/{repo}` a deux paramètres, et les valeurs utiles pour `repo` dépendent du `owner` choisi en premier. + +C’est à cela que sert `context`. Il transporte les arguments que l’utilisateur a **déjà résolus** : + +```python title="server.py" hl_lines="8-11 34-38" +--8<-- "docs_src/completions/tutorial003.py" +``` + +* La nouvelle branche se déclenche pour le paramètre `repo` du modèle. +* `context.arguments` est un `dict[str, str] | None` des valeurs choisies jusqu’ici (ici, `owner`). +* Pas encore de `owner` signifie pas de suggestion pertinente, donc le gestionnaire renvoie `None`. + +Le client envoie ces valeurs résolues avec `context_arguments=`. Cette fois, `ref` est une +`ResourceTemplateReference(uri="github://repos/{owner}/{repo}")`. Demandez `repo` avec une +`value` vide et passez `context_arguments={"owner": "modelcontextprotocol"}` : + +```python +result.completion.values # ['python-sdk', 'typescript-sdk', 'inspector'] +``` + +Retirez `context_arguments=` et le même appel renvoie `[]`. Le gestionnaire ne peut pas savoir quels dépôts proposer tant qu’il ne connaît pas le propriétaire. + +!!! info + `Completion` accepte aussi `total=` et `has_more=`. Renseignez-les quand `values` est une tranche d’une liste + plus longue, pour qu’une interface puisse afficher *« et 200 de plus »*. La plupart des gestionnaires n’en ont jamais besoin. + +## Récapitulatif {#recap} + +* Les complétions sont des suggestions pour les **arguments de prompt** et les **paramètres de modèle de ressource**. Rien d’autre. +* `@mcp.completion()` enregistre l’unique gestionnaire. Sa signature est `async def (ref, argument, context) -> Completion | None`. +* Aiguillez sur `isinstance(ref, ...)` et sur `argument.name`. Filtrez vous-même selon `argument.value`. +* `None` devient une liste vide. Ce n’est jamais une erreur. +* `context.arguments` contient les valeurs déjà résolues ; le client les fournit via `context_arguments=`. +* La capacité `completions` apparaît dès que vous enregistrez le gestionnaire. Sans lui, la requête reçoit `Method not found`. + +Les suggestions aident pendant que l’utilisateur *remplit* encore un prompt ou un modèle ; pour lui poser une question au *milieu* d’un appel d’outil, c’est l’**[élicitation (elicitation)](../handlers/elicitation.md)** qu’il vous faut. Tout ce qu’un outil peut renvoyer en plus du texte se trouve dans **[Images, audio et icônes](media.md)**. diff --git a/i18n/fr/pages/servers/handling-errors.md b/i18n/fr/pages/servers/handling-errors.md new file mode 100644 index 0000000000..8fdef7fc0b --- /dev/null +++ b/i18n/fr/pages/servers/handling-errors.md @@ -0,0 +1,139 @@ +--- +translation: + sections: [e33d441f12d50535, 7099694c603e0f5f, c1df4cf9673433e6, c9cd294541422e6e, 6cec073617bfd037, efa92b8f99e908c8, 6a22a29e27fb4601] + tool: 1 +--- +# Gérer les erreurs {#handling-errors} + +Un outil (tool) peut échouer de deux manières, et le SDK les traite très différemment. + +Levez une exception ordinaire et c’est le **modèle** qui la voit. Levez `MCPError` et c’est le **protocole** qui la voit. + +Cette page vous aide à choisir. + +## Une erreur que le modèle peut corriger {#an-error-the-model-can-fix} + +Prenez un outil qui effectue une recherche, et laissez cette recherche échouer : + +```python title="server.py" hl_lines="11-12" +--8<-- "docs_src/handling_errors/tutorial001.py" +``` + +Ces deux lignes n’ont rien de spécifique à MCP. `get_author` lève une simple `ValueError`, comme le ferait n’importe quelle fonction Python. + +Appelez-le avec un titre absent du catalogue et regardez le résultat : + +```python +result.is_error # True +result.content # [TextContent(text="Error executing tool get_author: No book titled 'Nothing' in the catalog.")] +result.structured_content # None +``` + +* La requête a **réussi**. Il y a un résultat ; rien n’a été levé côté appelant. +* `is_error` vaut `True`, et le message de votre exception (préfixé du nom de l’outil) se trouve dans `content`, exactement là où le modèle lit. +* `structured_content` vaut `None`. Un appel en échec n’a aucune valeur de retour à structurer. + +C’est une **erreur d’outil** (tool error), et c’est le comportement par défaut pour *toute* exception que lève votre outil. C’est aussi presque toujours ce que vous voulez. + +C’est le modèle qui appelle votre outil. C’est lui qui a choisi les arguments. Une erreur d’outil est donc un tour de conversation : le modèle lit *« No book titled 'Nothing' in the catalog. »*, comprend qu’il s’est trompé de titre et rappelle l’outil avec un meilleur. Vous avez écrit un seul `raise` et obtenu un agent qui se corrige tout seul. + +!!! tip + N’utilisez jamais `return` pour renvoyer un message d’erreur depuis un outil. Une chaîne renvoyée a + `is_error=False` : pour le modèle (et pour toute interface cliente), l’outil semble avoir + fonctionné et cette chaîne semble être la réponse. Utilisez `raise`. C’est le drapeau qui fait signal. + +## Une erreur que le modèle ne peut pas corriger {#an-error-the-model-cannot-fix} + +Remplacez maintenant `ValueError` par `MCPError`. + +```python title="server.py" hl_lines="1 3 14" +--8<-- "docs_src/handling_errors/tutorial002.py" +``` + +`MCPError` est l’**erreur de protocole** du SDK. C’est la seule exception que l’enveloppe de l’outil n’intercepte *pas* : elle se propage, et toute la requête `tools/call` échoue avec une erreur JSON-RPC au lieu d’un résultat. + +```json +{ + "code": -32602, + "message": "No book titled 'Nothing' in the catalog." +} +``` + +* Il n’y a **aucun résultat**. Pas de `content`, pas de `is_error` : rien à lire pour le modèle. +* C’est l’application **hôte** qui reçoit l’erreur, exactement comme si l’outil n’existait pas du tout. +* `code`, `message` et `data` arrivent intacts. `INVALID_PARAMS` vaut `-32602` ; `mcp.types` l’exporte, avec les autres codes d’erreur JSON-RPC (`INVALID_REQUEST`, `INTERNAL_ERROR`, …), sous forme de constantes pour que vous n’ayez jamais à saisir de nombre magique. + +!!! check + Même recherche, même échec, mais cette fois l’appel *lève une exception* côté client au lieu de renvoyer un résultat : + + ```text + mcp.shared.exceptions.MCPError: No book titled 'Nothing' in the catalog. + ``` + + La première version donnait au modèle une phrase à laquelle réagir. Celle-ci ne lui donne rien. + Pour `get_author`, c’est strictement pire, et c’est tout l’objet de la section suivante. + +## Laquelle lever {#which-one-to-raise} + +Les deux voies répondent à deux questions différentes. + +* **Levez n’importe quelle exception** pour un échec d’*exécution* : ce que votre outil a tenté de faire n’a pas fonctionné. Le modèle a choisi l’appel, il devrait donc en voir la conséquence et avoir une chance de se rattraper. Un titre mal orthographié, une API amont qui a expiré, une ligne qui n’existe pas : autant d’erreurs d’outil. +* **Levez `MCPError`** quand c’est la *requête elle-même* qui doit être rejetée : il manque au client une capacité dont dépend votre outil, le serveur n’est pas en état de servir qui que ce soit, l’appelant a sauté une étape obligatoire. Aucune nouvelle tentative du modèle ne corrige cela, il n’y a donc rien à gagner à lui transmettre le message. + +Une seule question tranche : **un modèle plus malin aurait-il pu éviter cela ?** Oui -> exception ordinaire. Non -> `MCPError`. + +Selon ce critère, la seconde version de `get_author` a fait le mauvais choix : un meilleur titre règle le problème, le modèle méritait donc de voir le message. Elle est là pour vous montrer le mécanisme, pas pour le recommander. + +!!! info + `MCPError` s’importe avec `from mcp import MCPError` et prend `code`, `message` et une charge + utile `data` facultative. Ce que vous y mettez est ce que le client reçoit : le SDK transmet telle + quelle une `MCPError` levée au lieu de l’assainir. + +## Une ressource qui n’existe pas {#a-resource-that-doesnt-exist} + +Les ressources tracent la même frontière, et fournissent une exception dédiée pour le cas courant. + +```python title="server.py" hl_lines="2 13" +--8<-- "docs_src/handling_errors/tutorial003.py" +``` + +`books://{title}` est un **modèle** (template). Il correspond à *n’importe quel* titre, donc « l’URI est bien formé » et « le livre existe » sont deux questions différentes, et seule votre fonction peut répondre à la seconde. + +Quand elle ne le peut pas, levez `ResourceNotFoundError`. Le SDK la transforme en l’erreur de protocole que la spécification attribue à une ressource manquante : `-32602` avec l’URI demandé dans `data`, pour que le client sache *quelle* lecture a échoué. + +```json +{ + "code": -32602, + "message": "No book titled 'Nothing' in the catalog.", + "data": {"uri": "books://Nothing"} +} +``` + +Remarquez qu’il n’y a pas ici de demi-résultat `is_error=True`. La lecture d’une ressource renvoie un contenu ou échoue : les ressources n’ont que la voie du protocole. Les modèles et tout ce qui concerne les ressources se trouvent dans **[Ressources](resources.md)**. + +## Les erreurs que vous ne levez jamais {#errors-you-never-raise} + +Un mauvais argument n’atteint jamais votre fonction. + +Envoyez à `get_author` un `title` qui n’est pas une chaîne et le SDK le rejette d’après le schéma d’entrée **avant** de vous appeler, sous la forme du même genre d’erreur d’outil `is_error=True` que le modèle peut lire et corriger. **[Outils](tools.md)** montre le même rejet avec une contrainte `Field(le=50)`. + +Cela représente toute une catégorie d’instructions `raise` que vous n’écrivez pas : ne revalidez pas vos propres annotations de type. + +!!! info + Tout ce que décrit cette page est ce qu’un **client** voit, et le `Client` en mémoire avec lequel + vous écrirez vos tests voit exactement la même chose. Même `raise_exceptions=True` ne retransforme + pas une erreur d’outil en traceback : au moment où ce drapeau pourrait agir, votre exception est déjà + devenue le résultat `is_error=True`. Faites vos assertions sur le résultat. **[Tests](../get-started/testing.md)** présente ce schéma. + +## Récapitulatif {#recap} + +* Levez **n’importe quelle exception** dans un outil -> l’appel renvoie `is_error=True` avec votre message dans `content`. Le modèle le lit et peut réessayer. C’est le comportement par défaut. +* Levez **`MCPError`** -> l’appel lui-même échoue avec une erreur JSON-RPC. Le modèle ne voit rien ; c’est l’hôte qui s’en occupe. `code`, `message` et `data` arrivent intacts. +* La question qui tranche : *un modèle plus malin aurait-il pu éviter cela ?* Oui -> exception. Non -> `MCPError`. +* `ResourceNotFoundError` depuis un gestionnaire (handler) de ressource -> le `-32602` du protocole, avec l’URI dans `data`. +* Les mauvais arguments sont rejetés d’après le schéma avant que votre fonction ne s’exécute ; vous n’avez pas de `raise` à écrire pour eux. +* `from mcp import MCPError` ; les constantes de codes d’erreur viennent de `mcp.types`. + +Les erreurs sont gérées. C’est tout ce qu’un serveur *expose*. Ce que chaque gestionnaire peut lire, et faire en retour auprès du client pendant qu’il s’exécute, fait l’objet de la section suivante : **[Dans votre gestionnaire](../handlers/index.md)**. + +Le texte exact des erreurs du SDK que vous avez le plus de chances de rencontrer, ce que chacune signifie et le correctif en un geste pour chacune se trouvent dans **[Dépannage](../troubleshooting.md)**. diff --git a/i18n/fr/pages/servers/index.md b/i18n/fr/pages/servers/index.md new file mode 100644 index 0000000000..462d9bc87a --- /dev/null +++ b/i18n/fr/pages/servers/index.md @@ -0,0 +1,22 @@ +--- +translation: + sections: [09defc170a0da89d] + tool: 1 +--- +# Serveurs {#servers} + +Un `MCPServer` expose trois primitives à un client connecté. Ce qui les distingue, c’est qui décide de les utiliser : + +* Un **[outil (tool)](tools.md)** est une action que le *modèle* choisit et appelle. C’est la page que la plupart des lecteurs veulent en premier, et **[Sortie structurée](structured-output.md)** en est le complément de référence : tout sur la forme de ce qu’un outil renvoie. +* Une **[ressource](resources.md)** est une donnée en lecture seule que l’*application* choisit de lire. **[Modèles d’URI](uri-templates.md)** en est le complément de référence : la syntaxe d’adressage complète et les règles de sécurité des chemins. +* Un **[prompt](prompts.md)** est un modèle de message qu’une *personne* invoque par son nom, depuis un menu ou une commande slash. + +Autour de ces trois primitives, voici le reste de ce qu’un serveur déclare : + +* **[Complétions](completions.md)** décrit l’autocomplétion côté serveur des arguments de prompts et de modèles de ressources. +* **[Images, audio et icônes](media.md)** couvre tout ce qu’un outil peut renvoyer en dehors du texte, ainsi que les icônes qu’un client affiche à côté de votre serveur. +* **[Gérer les erreurs](handling-errors.md)** explique la différence entre une erreur dont le modèle peut se remettre et une erreur qu’il ne doit jamais voir. + +Chaque page ici se suffit à elle-même ; allez directement à celle dont vous avez besoin. Si vous n’avez pas encore construit de serveur, commencez plutôt par **[Premiers pas](../get-started/first-steps.md)**. + +Ce qui se passe *à l’intérieur* des fonctions que vous enregistrez (l’objet `Context`, l’injection de dépendances, demander à l’utilisateur des informations supplémentaires en cours d’appel) fait l’objet de la section suivante, **[Dans votre gestionnaire](../handlers/index.md)**. diff --git a/i18n/fr/pages/servers/media.md b/i18n/fr/pages/servers/media.md new file mode 100644 index 0000000000..4990021881 --- /dev/null +++ b/i18n/fr/pages/servers/media.md @@ -0,0 +1,122 @@ +--- +translation: + sections: [496394d24d221bf1, 4ceb4591180dc6c3, 0fd63e4682d02e0c, 969ede0bd3686a16, 043f526230dd243d, 6ee3e9bcfd24047a] + tool: 1 +--- +# Médias {#media} + +Le texte n’est pas la seule chose qu’un outil (tool) peut renvoyer. + +Le SDK fournit deux utilitaires pour les résultats binaires (**`Image`** et **`Audio`**) et un type **`Icon`** pour donner un visage à votre serveur, à vos outils, à vos ressources et à vos prompts dans l’interface du client. + +## Renvoyer une image {#returning-an-image} + +Annotez le type de retour avec `Image`, pointez-le vers un fichier, et renvoyez-le : + +```python title="server.py" hl_lines="8 12 14" +--8<-- "docs_src/media/tutorial001.py" +``` + +* `Image` prend exactement l’un des deux : `path` (un fichier à lire) ou `data` (des octets bruts). +* Le type MIME que voit le client est deviné à partir de l’extension : `logo.png` est annoncé comme `image/png`. +* Les logos n’ont rien de particulier ici. N’importe quel PNG placé à côté de `server.py` convient : un graphique que votre code a généré, un schéma, une photo. + +`Image` est une commodité du SDK, pas un type du protocole. Sur la liaison, votre valeur de retour devient un bloc **`ImageContent`** (les octets du fichier encodés en base64, plus le type MIME) : + +```python +result.content # [ImageContent(type="image", data="iVBORw0KGgoAAAANSUhEUg...", mime_type="image/png")] +result.structured_content # None +``` + +Deux choses à remarquer : + +* `data` est en base64. Vous n’avez jamais touché aux octets ; le SDK a lu le fichier et s’est chargé de l’encodage. +* `structured_content` vaut `None`. Une `Image` est du contenu que le modèle regarde, pas des données que l’application analyse : il n’y a pas de schéma de sortie. (À comparer avec la **[Sortie structurée](structured-output.md)**, où l’annotation de retour *est* le schéma.) + +!!! info + `ImageContent` et `AudioContent` se trouvent dans `mcp.types`, juste à côté du `TextContent` + que devient un simple résultat `str` (**[Outils](tools.md)**). Un résultat d’outil est une liste de blocs de contenu ; `Image` et `Audio` sont + le moyen le plus court de produire les deux variantes binaires. + +### Essayer {#try-it} + +Déposez n’importe quel PNG à côté de `server.py`, nommez-le `logo.png`, et lancez : + +```console +uv run mcp dev server.py +``` + +Ouvrez l’onglet **Tools** et appelez `logo`. Le résultat n’est pas une chaîne : c’est un bloc de contenu `image`, et l’Inspector affiche votre image. Tout ce qui s’est passé entre le fichier sur le disque et les pixels à l’écran, c’est le SDK. + +## Renvoyer de l’audio {#returning-audio} + +`Audio` a la même forme. Laissez `logo.png` là où il était, et placez n’importe quel WAV à côté, sous le nom `chime.wav` : + +```python title="server.py" hl_lines="18-21" +--8<-- "docs_src/media/tutorial002.py" +``` + +Le résultat est un bloc **`AudioContent`** : + +```python +result.content # [AudioContent(type="audio", data="UklGR...", mime_type="audio/wav")] +result.structured_content # None +``` + +Même principe : un fichier sur le disque en entrée, du base64 et un type MIME en sortie, pas de schéma de sortie. + +## Des octets ou un fichier {#bytes-or-a-file} + +Les deux utilitaires acceptent aussi `data=` (des octets bruts) à la place de `path=`. C’est le mode prévu pour des octets qui n’ont jamais eu de fichier à eux — une colonne de base de données, une réponse HTTP, quelque chose que Pillow vient de dessiner : + +```python title="server.py" hl_lines="14 15" +--8<-- "docs_src/media/tutorial003.py" +``` + +Avec `path=`, il n’y a rien à déclarer : le fichier est lu au moment où le résultat est construit, et le type MIME est deviné à partir de l’extension : + +* `Image` : `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`. +* `Audio` : `.wav`, `.mp3`, `.ogg`, `.flac`, `.aac`, `.m4a`. + +Une extension qu’il ne reconnaît pas se rabat sur `application/octet-stream`. + +!!! check + Avec `data=`, il n’y a pas de nom de fichier, donc rien à partir de quoi deviner. Oubliez `format=` et + le SDK se rabat sur une valeur par défaut : `image/png` pour les images, `audio/wav` pour l’audio. Construisez un + `Audio` à partir d’octets MP3 de cette façon et le client reçoit `mime_type="audio/wav"`, puis + échoue consciencieusement à le décoder. Quand vous passez `data=`, passez `format=`. + +## Icônes {#icons} + +Une `Icon` est une métadonnée, pas du contenu. Elle ne transporte pas l’image ; elle en désigne une par un URI, et un client peut la récupérer et l’afficher à côté du nom de votre serveur, d’un outil, d’une ressource ou d’un prompt. + +```python title="server.py" hl_lines="4-5 7 10 16" +--8<-- "docs_src/media/tutorial004.py" +``` + +* `src` est un URI que le client peut résoudre : `https:`, ou un URI `data:` si vous voulez l’icône embarquée sans récupération supplémentaire. +* `mime_type` et `sizes` (`"48x48"`, ou `"any"` pour un format vectoriel) permettent au client de choisir la bonne lorsque vous en proposez plusieurs. +* `theme="light"` ou `theme="dark"` réserve une icône à un jeu de couleurs. + +Le même mot-clé `icons=[...]` est accepté par `MCPServer(...)`, `@mcp.tool()`, `@mcp.resource()` et `@mcp.prompt()`. + +### Où un client les voit {#where-a-client-sees-them} + +Les icônes voyagent avec ce qu’elles décorent. Celles du serveur arrivent quand le client se connecte, sur `client.server_info` (facultatif sur les connexions de génération 2026, donc restreignez d’abord le type) : + +```python +assert client.server_info is not None # python-sdk servers identify themselves by default +client.server_info.icons # [Icon(src="https://example.com/brand-kit.png", mime_type="image/png", sizes=["48x48"])] +``` + +Les icônes d’un outil sont sur l’objet `Tool` issu de `tools/list`, celles d’une ressource sur le `Resource` issu de `resources/list`, celles d’un prompt sur le `Prompt` issu de `prompts/list`. Le champ s’appelle toujours `icons`. + +## Récapitulatif {#recap} + +* Renvoyez une `Image` ou un `Audio` depuis un outil et le client reçoit un bloc `ImageContent` / `AudioContent` : vos octets encodés en base64, avec un type MIME. +* Construisez-en un à partir d’un `path=` et laissez l’extension décider du type MIME, ou à partir de `data=` en mémoire plus un `format=` explicite. +* Les résultats média ne portent ni `structured_content` ni schéma de sortie. +* Une `Icon` est un pointeur : un URI `src` plus, en option, `mime_type`, `sizes` et `theme`. +* `icons=[...]` fonctionne sur le serveur, sur les outils, sur les ressources et sur les prompts, et les clients les retrouvent sur les objets correspondants. + +C’est tout ce qu’un outil peut mettre *dans* un résultat. Ce qui se passe quand un outil *échoue* (et qui doit l’apprendre), c’est **[Gérer les erreurs](handling-errors.md)**. diff --git a/i18n/fr/pages/servers/prompts.md b/i18n/fr/pages/servers/prompts.md new file mode 100644 index 0000000000..90cf36b8a2 --- /dev/null +++ b/i18n/fr/pages/servers/prompts.md @@ -0,0 +1,155 @@ +--- +translation: + sections: [d65c098f37f5b6c3, dd0c2724d6f2877e, 6835bb3570c6714c, ffe823cb0fedd488, f33651add1b59094] + tool: 1 +--- +# Prompts {#prompts} + +Un **prompt** est un modèle de message que l’utilisateur choisit. + +Les outils sont destinés au modèle. Un prompt, c’est l’inverse : l’utilisateur en choisit un dans un menu de son client (une commande slash, un bouton), renseigne ses arguments, et les messages rendus entrent dans la conversation comme s’il les avait saisis lui-même. + +Vous en déclarez un en plaçant `@mcp.prompt()` sur une fonction qui renvoie le texte. + +## Votre premier prompt {#your-first-prompt} + +```python title="server.py" hl_lines="6-9" +--8<-- "docs_src/prompts/tutorial001.py" +``` + +Le SDK lit les trois mêmes éléments qu’il lit sur un outil : + +* Le **nom** est le nom de la fonction : `review_code`. +* La **description** que le client affiche est la docstring : `Review a piece of code.` +* Les **arguments** proviennent des paramètres. `code` n’a pas de valeur par défaut, il est donc obligatoire. + +Voici ce qu’un client obtient en retour de `prompts/list` : + +```json +{ + "name": "review_code", + "description": "Review a piece of code.", + "arguments": [ + {"name": "code", "required": true} + ] +} +``` + +Il n’y a pas de JSON Schema ici. Les arguments d’un prompt forment une liste plate de **valeurs chaînes nommées** : un formulaire qu’une personne remplit, pas une charge utile qu’un modèle construit. + +### Le rendre {#rendering-it} + +Le client rend le modèle avec `prompts/get`, en passant les arguments. Votre fonction s’exécute et la `str` que vous renvoyez devient **un seul message utilisateur** : + +```json +{ + "description": "Review a piece of code.", + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "Please review this code:\n\ndef add(a, b): return a + b" + } + } + ], + "resultType": "complete" +} +``` + +C’est toute la vie d’un prompt : listé par son nom, rendu à la demande, déposé dans la conversation. + +!!! check + `required` est vérifié avant l’exécution de votre fonction. Rendez `review_code` sans `code` et la + requête elle-même échoue avec une erreur JSON-RPC (code `-32603`) : + + ```text + mcp.shared.exceptions.MCPError: Internal server error + ``` + + Il n’y a pas de résultat d’erreur à la manière des outils à remettre à un modèle, car aucun modèle n’est dans la boucle : + l’appel lève une exception. La raison (`Missing required arguments: {'code'}`) arrive dans le journal de votre serveur. + +### Essayer {#try-it} + +Lancez le serveur avec le MCP Inspector : + +```console +uv run mcp dev server.py +``` + +Ouvrez l’onglet **Prompts** et sélectionnez `review_code`. L’Inspector dessine un formulaire avec un seul champ obligatoire `code`. Renseignez-le, lancez le rendu, et vous obtenez en retour exactement le message utilisateur ci-dessus. + +## Plus d’un message {#more-than-one-message} + +Une revue de code, c’est un message. Une session de débogage, c’est une conversation, et un prompt peut l’amorcer tout entière. + +Renvoyez une liste de messages au lieu d’une `str` : + +```python title="server.py" hl_lines="2 13-20" +--8<-- "docs_src/prompts/tutorial002.py" +``` + +* `UserMessage` et `AssistantMessage` viennent de `mcp.server.mcpserver.prompts.base`. Passez-leur une `str` et ils l’enveloppent dans un `TextContent` pour vous. Le rôle est le nom de la classe. +* `Message` est leur classe de base commune. Utilisez-la comme annotation de retour. + +Le rendu de `debug_error` produit désormais trois messages, dans l’ordre : + +```json +{ + "description": "Start a debugging conversation.", + "messages": [ + {"role": "user", "content": {"type": "text", "text": "I'm seeing this error:"}}, + {"role": "user", "content": {"type": "text", "text": "TypeError: 'int' object is not iterable"}}, + { + "role": "assistant", + "content": {"type": "text", "text": "I'll help debug that. What have you tried so far?"} + } + ], + "resultType": "complete" +} +``` + +Remarquez le dernier. Préremplir un tour `assistant`, c’est la façon d’orienter la *prochaine* réponse du modèle sans obliger l’utilisateur à saisir lui-même cette orientation. + +## Titres et descriptions d’arguments {#titles-and-argument-descriptions} + +`review_code` est un nom de fonction, pas un libellé. Donnez au client quelque chose de mieux à afficher sur le bouton, et décrivez chaque argument pour que le formulaire s’explique de lui-même : + +```python title="server.py" hl_lines="10-13" +--8<-- "docs_src/prompts/tutorial003.py" +``` + +* `title="Code review"` est le nom lisible par un humain, exactement comme le `title` d’un outil. +* `Annotated[str, Field(description=...)]` est le même motif que celui que **[Outils](tools.md)** utilise pour décrire les paramètres d’un outil. Ici, la description se retrouve sur l’argument plutôt que dans un schéma. +* `language` a une valeur par défaut, il cesse donc d’être obligatoire. + +L’entrée `prompts/list` contient désormais tout ce dont un client a besoin pour dessiner un bon formulaire : + +```json +{ + "name": "review_code", + "title": "Code review", + "description": "Review a piece of code.", + "arguments": [ + {"name": "code", "description": "The code to review.", "required": true}, + {"name": "language", "description": "The language the code is written in.", "required": false} + ] +} +``` + +!!! info + Si vous avez lu **[Outils](tools.md)**, vous connaissez déjà tout ce que contient cette page. Même décorateur, même + docstring servant de description, mêmes `Annotated`/`Field`. Seuls changent qui + le déclenche (l’utilisateur) et où va le résultat (dans la conversation). + +## Récapitulatif {#recap} + +* `@mcp.prompt()` sur une fonction en fait un prompt. Le nom vient de la fonction, la description de la docstring. +* Les prompts sont **contrôlés par l’utilisateur** : le client les liste, l’utilisateur en choisit un et renseigne les arguments. +* Les arguments forment une liste plate de chaînes nommées (pas de schéma). Un paramètre avec une valeur par défaut est facultatif. +* Renvoyez une `str` et elle devient un seul message utilisateur. Renvoyez une liste de `UserMessage` / `AssistantMessage` pour amorcer une conversation à plusieurs tours. +* `title=` et `Field(description=...)` sont ce qu’un client affiche dans son interface. +* Un argument obligatoire manquant fait échouer toute la requête. Il n’y a pas de résultat d’erreur par prompt. + +L’autocomplétion côté serveur des arguments d’un prompt (ou d’un modèle de ressource), c’est **[Complétions](completions.md)**. diff --git a/i18n/fr/pages/servers/resources.md b/i18n/fr/pages/servers/resources.md new file mode 100644 index 0000000000..1db4d5ea0a --- /dev/null +++ b/i18n/fr/pages/servers/resources.md @@ -0,0 +1,146 @@ +--- +translation: + sections: [09df998c2a799f78, 0cf131146d16d4f9, 4e6b91e3f8025346, 8fe4eef576db17ed, 0d0d1ed43e3d0a53] + tool: 1 +--- +# Ressources {#resources} + +Une **ressource** (resource), ce sont des données que vous exposez pour que l’application les lise. + +C’est là la ligne de partage. Un outil est quelque chose que le **modèle** décide d’appeler. Une ressource est quelque chose que l’**application** décide de charger (un fichier de configuration, un enregistrement, un document) et de placer devant le modèle comme contexte. + +Vous en déclarez une en posant `@mcp.resource(uri)` sur une simple fonction Python. + +## Votre première ressource {#your-first-resource} + +```python title="server.py" hl_lines="6-8" +--8<-- "docs_src/resources/tutorial001.py" +``` + +C’est la même forme qu’un outil, avec une chose en plus : l’**URI**. Les ressources ont une adresse, pas un nom. Un client demande `config://app`, jamais `get_config`. + +Le SDK lit tout de même le reste à partir de la fonction : + +* Le **nom** est le nom de la fonction : `get_config`. +* La **description** que voit le client est la docstring. +* Le **contenu** est ce que vous renvoyez. + +Lors de `resources/list`, le client reçoit ceci : + +```json +{ + "name": "get_config", + "uri": "config://app", + "description": "The active shop configuration.", + "mimeType": "text/plain" +} +``` + +Et lorsqu’il lit `config://app`, votre fonction s’exécute et la valeur de retour revient sous forme de texte : + +```python +result.contents # [TextResourceContents(uri="config://app", mime_type="text/plain", text="theme=dark\nlanguage=en")] +``` + +!!! tip + Lister ne coûte rien. Votre fonction n’est **pas** appelée lors de `resources/list`, seulement lors + de `resources/read`, et uniquement pour l’URI demandé. Exposez un millier de ressources + et vous ne payez que pour celles que quelqu’un ouvre. + +### Essayer {#try-it} + +Lancez le serveur avec le MCP Inspector : + +```console +uv run mcp dev server.py +``` + +Ouvrez l’URL qu’il affiche et allez dans l’onglet **Resources**. `config://app` figure dans la liste avec sa description. Cliquez dessus et l’Inspector la lit : voilà vos deux lignes de configuration. + +## Modèles de ressources {#resource-templates} + +Un URI par enregistrement, cela ne passe pas à l’échelle. Mettez un **paramètre de substitution** (placeholder) dans l’URI et un paramètre correspondant sur la fonction : + +```python title="server.py" hl_lines="12-13" +--8<-- "docs_src/resources/tutorial002.py" +``` + +`{user_id}` dans l’URI, `user_id: str` sur la fonction. C’est tout le contrat. + +Il s’agit désormais d’un **modèle de ressource** (resource template), et il déménage : il quitte `resources/list` et apparaît à la place dans `resources/templates/list`, sous forme de motif plutôt que d’adresse : + +```json +{ + "name": "get_user_profile", + "uriTemplate": "users://{user_id}/profile", + "description": "A customer's profile.", + "mimeType": "text/plain" +} +``` + +Le client remplit le paramètre de substitution et lit un URI concret : `users://42/profile`, `users://ada/profile`. Une seule fonction répond à tous, et reçoit la valeur extraite dans `user_id` : + +```python +result.contents # [TextResourceContents(uri="users://42/profile", text="User 42: 12 orders since 2021.")] +``` + +Remarquez le champ `uri` dans le résultat. C’est l’URI **concret** demandé par le client, pas le modèle. + +!!! check + Les paramètres de substitution et les paramètres de la fonction doivent concorder. Renommez le + paramètre de la fonction en `user` alors que l’URI dit toujours `{user_id}`, et le décorateur refuse + **dès l’import**, avant qu’aucun client ne s’en approche : + + ```text + ValueError: Mismatch between URI parameters {'user_id'} and function parameters {'user'} + ``` + + Une discordance ne peut être qu’un bug ; le SDK rend donc impossible le démarrage du serveur avec une telle erreur. + +La syntaxe des paramètres de substitution est celle de la [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570) : `{+path}` pour les valeurs sur plusieurs segments, `{?q,lang}` pour les paramètres de requête optionnels, et bien d’autres. Par défaut, le SDK applique aussi des vérifications de sécurité des chemins aux valeurs extraites. Consultez **[Modèles d’URI et sécurité des chemins](uri-templates.md)** pour la référence complète. + +`get_user_profile` peut également prendre un paramètre annoté `Context`. Le SDK l’injecte sans jamais le traiter comme un paramètre d’URI, et la page **[L’objet Context](../handlers/context.md)** décrit ce qu’il vous apporte. + +## Ce que vous renvoyez {#what-you-return} + +Vous n’êtes pas limité à `str`. Donnez à chaque ressource un `mime_type` et renvoyez ce qui convient : + +```python title="server.py" hl_lines="8-9 14-15 20-21" +--8<-- "docs_src/resources/tutorial003.py" +``` + +* `readme` renvoie une `str`, elle est donc envoyée telle quelle. C’est le cas courant. +* `catalog_stats` renvoie un `dict`, le SDK le sérialise donc pour vous en **texte JSON** : + + ```json + { + "books": 1204, + "authors": 391 + } + ``` + +* `placeholder_cover` renvoie des `bytes`, le client reçoit donc un `BlobResourceContents` au lieu d’un `TextResourceContents`, avec vos octets encodés en base64 dans son champ `blob`. + +La même règle vaut pour tout ce qui est sérialisable en JSON : une liste, un modèle Pydantic, une dataclass. Si ce n’est ni une `str` ni des `bytes`, cela devient du JSON. + +C’est à vous de déclarer `mime_type`, et sa valeur par défaut est `text/plain`. Le SDK n’inspecte jamais ce que vous renvoyez pour le deviner : une ressource `dict` que vous n’étiquetez pas est donc toujours annoncée comme du texte brut. + +!!! tip + `@mcp.resource()` accepte aussi `name=`, `title=` et `description=` lorsque vous ne souhaitez + pas les dériver de la fonction. Et lorsqu’il n’y a aucune fonction à écrire, + `mcp.server.mcpserver.resources` propose des classes `Resource` prêtes à l’emploi (`TextResource`, + `BinaryResource`, `FileResource`, `HttpResource`, `DirectoryResource`) que vous enregistrez + avec `mcp.add_resource(...)`. + +Un client peut aussi **s’abonner** à une ressource et être notifié lorsqu’elle change ; c’est la moitié de l’histoire côté client, et elle se trouve dans **[Le client](../client/index.md)**. + +## Récapitulatif {#recap} + +* `@mcp.resource(uri)` sur une fonction en fait une ressource. L’URI est l’adresse, la valeur de retour est le contenu, la docstring est la description. +* Un `{placeholder}` dans l’URI en fait un **modèle** : il est listé sous `resources/templates/list` et une seule fonction sert tous les URI qui correspondent. +* Les noms des paramètres de substitution doivent être identiques aux noms des paramètres de la fonction. Trompez-vous et vous le découvrez à l’import, pas en production. +* Votre fonction s’exécute quand la ressource est **lue**, pas quand elle est listée. +* `str` devient du texte, `bytes` devient un blob base64, tout le reste devient du texte JSON. `mime_type=` sert à l’étiqueter. +* Les outils servent au modèle pour agir. Les ressources servent à l’application pour lire. + +La troisième primitive, celle qu’une personne choisit dans un menu, ce sont les **[prompts](prompts.md)**. diff --git a/i18n/fr/pages/servers/structured-output.md b/i18n/fr/pages/servers/structured-output.md new file mode 100644 index 0000000000..a74c85eda7 --- /dev/null +++ b/i18n/fr/pages/servers/structured-output.md @@ -0,0 +1,250 @@ +--- +translation: + sections: [a838d57f003aed44, 857d03886a0137ed, 42d9efcb9f542867, 2290ff08435b5573, e866c192e11d1c14, 6cdbad079f7b47f0, d4b607372fb28b51, 18dbf726ac45e0b7, c6f7d2a148aa49f4, c851964bb3301907, d715db6f8dccc9cc, ef86634aa70498a7] + tool: 1 +--- +# Sortie structurée {#structured-output} + +Un outil (tool) qui renvoie une simple `str` produit le résultat deux fois : sous forme de texte dans `content`, et sous la forme `{"result": "..."}` dans `structured_content`. + +Cette page porte sur ce second canal : d’où il vient, toutes les formes qu’il peut prendre et la façon dont le SDK en garantit l’exactitude. + +En bref : **l’annotation du type de retour est le schéma de sortie**. Vous l’avez déjà écrite. + +## Le schéma de sortie {#the-output-schema} + +```python title="server.py" hl_lines="9" +--8<-- "docs_src/structured_output/tutorial001.py" +``` + +La ligne qui compte est la signature : `-> int`. + +Grâce à elle, l’outil que le SDK envoie lors de `tools/list` porte un `output_schema` à côté du schéma d’entrée qu’il construit à partir de vos paramètres (la page **[Outils](tools.md)** traite de celui-là) : + +```json +{ + "properties": { + "result": {"title": "Result", "type": "integer"} + }, + "required": ["result"], + "title": "get_temperatureOutput", + "type": "object" +} +``` + +Un `int` seul n’est pas un objet JSON, le SDK l’**enveloppe** donc dans `{"result": ...}`. Appelez l’outil et les deux canaux sont remplis : + +```python +result.content # [TextContent(text="17")] +result.structured_content # {"result": 17} +``` + +Tous les scalaires reçoivent la même enveloppe : `str`, `int`, `float`, `bool`, `bytes`, `None`. + +## Deux canaux {#two-channels} + +Pourquoi envoyer la même valeur deux fois ? + +* `content` est destiné au **modèle**. Un modèle de langage lit du texte ; c’est la seule partie du résultat qu’il voit. +* `structured_content` est destiné à l’**application** dans laquelle le modèle s’exécute : du code qui veut `17`, pas une phrase contenant « 17 ». +* `output_schema` est le contrat entre les deux, publié avant même le premier appel de l’outil. + +Vous renvoyez une seule valeur Python. Le SDK remplit les trois. + +## Renvoyer un modèle {#return-a-model} + +Déclarez la forme comme un `BaseModel` Pydantic et renvoyez une instance : + +```python title="server.py" hl_lines="8-11 15" +--8<-- "docs_src/structured_output/tutorial002.py" +``` + +`WeatherData` **est** désormais le schéma. Pas d’enveloppe, pas de clé `result` : + +```json +{ + "properties": { + "temperature": {"description": "Degrees Celsius.", "title": "Temperature", "type": "number"}, + "humidity": {"description": "Relative humidity, 0 to 1.", "title": "Humidity", "type": "number"}, + "conditions": {"title": "Conditions", "type": "string"} + }, + "required": ["temperature", "humidity", "conditions"], + "title": "WeatherData", + "type": "object" +} +``` + +`structured_content` est l’objet, champ pour champ : + +```python +result.structured_content # {"temperature": 16.2, "humidity": 0.83, "conditions": "Overcast"} +``` + +Et le modèle n’est pas oublié. Le SDK sérialise le même objet en texte JSON pour `content` : + +```json +{ + "temperature": 16.2, + "humidity": 0.83, + "conditions": "Overcast" +} +``` + +Remarquez que les `Field(description=...)` de `temperature` et `humidity` ont atterri dans le schéma. Le même `Field` qui décrivait vos **entrées** décrit vos sorties. + +!!! info + Si vous avez utilisé le `response_model` de FastAPI, vous connaissez déjà cela : un modèle Pydantic + comme réponse déclarée, sérialisé et documenté pour vous. La seule différence est qu’ici + l’annotation de retour constitue toute la déclaration. + +## Un `TypedDict` {#a-typeddict} + +Toutes les formes ne méritent pas une classe. Un `TypedDict` produit le même schéma : + +```python title="server.py" hl_lines="8" +--8<-- "docs_src/structured_output/tutorial003.py" +``` + +Un `TypedDict` est un simple `dict` à l’exécution : c’est donc ce que vous construisez et renvoyez. Le schéma, la validation et `structured_content` sont identiques à la version `BaseModel` (à l’exception des descriptions, pour lesquelles `TypedDict` n’a pas de place). + +## Une dataclass {#a-dataclass} + +Les dataclasses fonctionnent aussi, tout comme n’importe quelle classe ordinaire dont les attributs portent des annotations de type. Le SDK construit en coulisses un modèle Pydantic à partir des annotations. + +```python title="server.py" hl_lines="8-9" +--8<-- "docs_src/structured_output/tutorial004.py" +``` + +Trois écritures, un seul schéma. Utilisez celle que votre base de code emploie déjà. + +## Listes {#lists} + +Une `list[...]` n’est pas non plus un objet JSON : elle reçoit donc l’enveloppe `{"result": ...}`, avec votre type d’élément sous forme de référence `$defs` à l’intérieur : + +```python title="server.py" hl_lines="15" +--8<-- "docs_src/structured_output/tutorial005.py" +``` + +```json +{ + "$defs": { + "WeatherData": { + "properties": { + "temperature": {"title": "Temperature", "type": "number"}, + "humidity": {"title": "Humidity", "type": "number"}, + "conditions": {"title": "Conditions", "type": "string"} + }, + "required": ["temperature", "humidity", "conditions"], + "title": "WeatherData", + "type": "object" + } + }, + "properties": { + "result": {"items": {"$ref": "#/$defs/WeatherData"}, "title": "Result", "type": "array"} + }, + "required": ["result"], + "title": "get_forecastOutput", + "type": "object" +} +``` + +Demandez une prévision sur deux jours et `structured_content` vaut `{"result": [{...}, {...}]}`. `content` devient **deux** blocs `TextContent`, un par élément : une liste est aplatie pour le modèle plutôt que déversée en une seule chaîne. + +`tuple[...]`, les unions et `Optional[...]` sont enveloppés de la même façon. + +## Dictionnaires {#dictionaries} + +`dict[str, ...]` est le seul générique qui *est* déjà un objet JSON ; il n’est donc pas enveloppé : + +```python title="server.py" hl_lines="9" +--8<-- "docs_src/structured_output/tutorial006.py" +``` + +```json +{ + "additionalProperties": {"type": "number"}, + "title": "get_temperaturesDictOutput", + "type": "object" +} +``` + +```python +result.structured_content # {"London": 16.2, "Reykjavik": 4.4} +``` + +Les clés doivent être des `str`. Un `dict[int, float]` ne peut pas être un objet JSON ; il retombe donc sur l’enveloppe `{"result": ...}`. + +## Validation {#validation} + +`output_schema` n’est pas de la documentation. Tout ce que renvoie votre fonction est **validé par rapport à lui** avant de quitter le serveur. + +Vous ne le remarquez pas tant que vous construisez la valeur à la main : Pydantic s’est déjà assuré que votre `WeatherData` était bien un `WeatherData`. Vous le remarquez le jour où les données viennent d’un endroit que vous ne contrôlez pas : + +```python title="server.py" hl_lines="9 21" +--8<-- "docs_src/structured_output/tutorial007.py" +``` + +L’annotation promet un `WeatherData`. La réponse en amont a cessé d’envoyer `humidity`. + +!!! check + Appelez `get_weather` : il ne remet pas discrètement au client un objet à moitié vide. L’appel + échoue, et les premières lignes de l’erreur nomment le champ : + + ```text + Error executing tool get_weather: 1 validation error for WeatherData + humidity + Field required [type=missing, input_value={'temperature': 16.2, 'conditions': 'Overcast'}, input_type=dict] + ``` + + Ce texte revient comme résultat de l’outil avec `is_error=True` : le modèle sait donc que l’appel + a échoué au lieu de lire avec assurance une météo qui n’existe pas. + +Au passage, renvoyer un simple `dict` depuis un outil `-> WeatherData` ne pose aucun problème. C’est exactement ce que `json.loads` a produit. La validation porte sur la valeur, pas sur le type Python. + +## Désactiver la sortie structurée {#opting-out} + +Parfois, l’annotation de retour est destinée à votre vérificateur de types, pas au protocole. Passez `structured_output=False` et l’outil devient purement textuel : + +```python title="server.py" hl_lines="6" +--8<-- "docs_src/structured_output/tutorial008.py" +``` + +Aucun `output_schema`, aucune enveloppe, aucune validation. `structured_content` vaut `None` et `content` est la chaîne que vous avez renvoyée. + +L’inverse, `structured_output=True`, transforme la détection automatique en exigence : un outil dont le type de retour ne peut pas produire de schéma lève une exception à l’import au lieu de se rabattre sur du texte. + +## Une classe sans annotations de type {#a-class-without-type-hints} + +Il existe une façon de se retrouver sans sortie structurée sans l’avoir demandé : renvoyer une classe qui n’a **aucune annotation dans son corps**. + +```python title="server.py" hl_lines="6-9" +--8<-- "docs_src/structured_output/tutorial009.py" +``` + +`Station` définit `name` et `online` dans `__init__`, mais la *classe* ne déclare rien. Le SDK lit les annotations de la classe, n’en trouve aucune et abandonne. + +!!! warning + Il abandonne **silencieusement**. `output_schema` vaut `None`, `structured_content` vaut `None`, + et le texte que lit le modèle est le `repr` de l’objet : + + ```text + "" + ``` + + Aucune erreur, aucun avertissement, un outil inutile. Déplacez les annotations dans le corps de la + classe, ou passez `structured_output=True`, qui transforme cela en erreur franche dès l’import du + module : `Function get_station: return type is not serializable for structured output`. + +!!! tip + Besoin d’un contrôle total (construire vous-même le `CallToolResult`, ou attacher un `_meta` que + l’application voit mais pas le modèle) ? C’est le sujet de **[Le Server de bas niveau](../advanced/low-level-server.md)**. + +## Récapitulatif {#recap} + +* L’**annotation du type de retour** est le schéma de sortie. Elle est publiée dans `tools/list` sous le nom `output_schema`. +* Les scalaires, listes, tuples et unions sont enveloppés dans `{"result": ...}`. Les modèles, les `TypedDict`, les dataclasses, les classes annotées et `dict[str, ...]` sont déjà des objets et restent tels quels. +* Chaque résultat porte `content` (du texte, pour le modèle) **et** `structured_content` (des données, pour l’application). +* Ce que vous renvoyez est validé par rapport au schéma. Une incohérence est une erreur d’outil, pas un résultat corrompu. +* `structured_output=False` désactive la sortie structurée d’un outil. Une classe sans annotations de type la désactive silencieusement ; surveillez ce cas. + +Vous maîtrisez désormais tout ce qu’un outil peut répondre. Ensuite, la deuxième primitive : **[Ressources](resources.md)**. diff --git a/i18n/fr/pages/servers/tools.md b/i18n/fr/pages/servers/tools.md new file mode 100644 index 0000000000..af7d4bece0 --- /dev/null +++ b/i18n/fr/pages/servers/tools.md @@ -0,0 +1,177 @@ +--- +translation: + sections: [e4cc390d56573409, 8566e2b68594e9ad, 2c97b9f888398951, 048e5471dfa71aea, 3076b1e16ad95950, edbedf2a16e71311, 3d8ef8da89fa87c1, f6c0e02e6ea5a363] + tool: 1 +--- +# Outils {#tools} + +Un **outil** (tool) est une fonction que le modèle peut appeler. + +Vous en déclarez un en posant `@mcp.tool()` sur une simple fonction Python. C’est toute l’API. + +## Votre premier outil {#your-first-tool} + +```python title="server.py" hl_lines="6-8" +--8<-- "docs_src/tools/tutorial001.py" +``` + +Regardez ce que vous avez écrit. Pas de schémas, pas de JSON, pas de protocole : juste une fonction. Le SDK en lit trois choses : + +* Le **nom** de l’outil est le nom de la fonction : `search_books`. +* La **description** que voit le modèle est la docstring : `Search the catalog by title or author.` +* Les **arguments** que le modèle a le droit de passer proviennent des annotations de type : `query: str` et `limit: int`. + +### Le schéma d’entrée {#the-input-schema} + +À partir de ces annotations de type, le SDK génère un JSON Schema et l’envoie au client lors de `tools/list` : + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"title": "Limit", "type": "integer"} + }, + "required": ["query", "limit"], + "title": "search_booksArguments" +} +``` + +Les deux arguments figurent dans `required` parce qu’aucun n’a de valeur par défaut. Vous allez corriger cela dans un instant. (Les clés `title` sont des artefacts de Pydantic ; les propriétés, leurs types et `required` constituent le contrat.) + +!!! tip + Ici, les annotations de type ne sont pas de la documentation. Elles sont **le contrat**. Si un client envoie `"limit": "ten"`, + le SDK le rejette avant même que votre fonction ne s’exécute. + +### Ce que le modèle reçoit en retour {#what-the-model-gets-back} + +Appelez l’outil avec `{"query": "dune", "limit": 5}` et le résultat comporte deux parties : + +```python +result.content # [TextContent(text="Found 3 books matching 'dune' (showing up to 5).")] +result.structured_content # {'result': "Found 3 books matching 'dune' (showing up to 5)."} +``` + +`content` est le texte que lit le **modèle**. `structured_content` contient des données typées destinées à l’**application cliente**. Elles sont là parce que vous avez déclaré le type de retour `-> str`. + +Ne vous souciez pas encore de `structured_content`. Renvoyez de vrais objets Python depuis vos outils et tout se passe comme il faut ; la page **[Sortie structurée](structured-output.md)** y est entièrement consacrée. + +### Essayer {#try-it} + +Lancez le serveur avec le MCP Inspector : + +```console +uv run mcp dev server.py +``` + +Ouvrez l’URL qu’il affiche, allez dans l’onglet **Tools** et appelez `search_books`. + +L’Inspector affiche un formulaire avec un champ texte `query` obligatoire et un champ numérique `limit` obligatoire. Il a construit ce formulaire à partir de vos annotations de type. Tous les autres clients MCP feront de même. + +## Arguments optionnels {#optional-arguments} + +Donnez une valeur par défaut à un paramètre et il cesse d’être obligatoire. C’est tout. C’est du Python, tout simplement. + +```python title="server.py" hl_lines="7" +--8<-- "docs_src/tools/tutorial002.py" +``` + +Le schéma suit : + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"default": 10, "title": "Limit", "type": "integer"} + }, + "required": ["query"], + "title": "search_booksArguments" +} +``` + +`limit` a quitté `required` et a gagné `"default": 10`. Un client qui l’omet obtient `10`, exactement comme en Python. + +## Des schémas plus riches avec `Field` {#richer-schemas-with-field} + +Les annotations de type vous mènent loin, mais vous voulez parfois *décrire* un argument, ou le contraindre. + +Enveloppez le type dans `Annotated` et ajoutez un `Field` Pydantic : + +```python title="server.py" hl_lines="12-14" +--8<-- "docs_src/tools/tutorial003.py" +``` + +Trois nouveautés, toutes sur les paramètres : + +* `Field(description=...)` : une description par argument, que le modèle lit en plus de la docstring. +* `Field(ge=1, le=50)` : des bornes numériques. Elles arrivent dans le schéma sous la forme `"minimum": 1, "maximum": 50`. +* `Literal["fiction", "non-fiction", "poetry"]` : une énumération. Le modèle ne peut choisir que l’une de ces valeurs. + +!!! check + Les contraintes ne sont pas décoratives. Appelez l’outil avec `limit=999` et le SDK répond par une + erreur d’outil **avant que votre fonction ne s’exécute** : + + ```text + Input should be less than or equal to 50 + ``` + + Cette erreur revient au modèle comme résultat de l’outil ; le modèle la lit et réessaie avec + une valeur valide. Vous avez écrit `le=50` une seule fois et obtenu, sans rien de plus, des agents qui se corrigent d’eux-mêmes. + +!!! info + Si vous avez utilisé FastAPI ou Pydantic, vous connaissez déjà tout cela. C’est le même `Field`, + le même `Annotated`, la même validation. Il n’y a rien de propre à MCP à apprendre ici. + +## Un modèle comme paramètre {#a-model-as-a-parameter} + +Quand un outil prend plus de deux ou trois arguments, regroupez-les dans un modèle Pydantic : + +```python title="server.py" hl_lines="8-11 15" +--8<-- "docs_src/tools/tutorial004.py" +``` + +Le schéma de `Book` est imbriqué dans le schéma d’entrée de l’outil (sous forme de référence `$defs`), le modèle le remplit comme un objet JSON, et votre fonction reçoit une **véritable instance de `Book`**, déjà validée, avec les attributs `.title`, `.author` et `.year`. + +Vous pouvez combiner librement : des paramètres simples à côté de paramètres modèles, des modèles imbriqués, des listes de modèles. C’est du Pydantic de bout en bout. + +## `async def` {#async-def} + +Si un outil fait des E/S (appelle une API, lit un fichier, interroge une base de données), déclarez-le en `async def` et utilisez `await` à l’intérieur. Le SDK se charge de l’attendre. + +Un outil en simple `def` fonctionne aussi : le SDK l’exécute dans un thread, si bien qu’il ne bloque jamais le serveur. + +Il n’y a rien d’autre à configurer. + +## Noms, titres et annotations {#names-titles-and-annotations} + +Tout ce que le SDK déduit, vous pouvez le redéfinir dans le décorateur : + +```python title="server.py" hl_lines="7-10" +--8<-- "docs_src/tools/tutorial005.py" +``` + +* `title` est un nom lisible par un humain, destiné aux interfaces. Les clients affichent *« Search the catalog »* au lieu de `search_books`. +* `annotations` regroupe des **indications** de comportement destinées au client : + * `read_only_hint=True` : cet outil ne modifie rien. + * `open_world_hint=False` : il opère sur un ensemble fermé de choses (ce catalogue), pas sur le web ouvert. + * Les deux autres, `destructive_hint` et `idempotent_hint`, décrivent un outil qui *écrit* : peut-il + supprimer quelque chose, et l’appeler deux fois revient-il au même que l’appeler une fois ? La spécification ne les définit + que pour les outils qui ne sont pas en lecture seule ; elles ne diraient donc rien sur `search_books`. + +Un client bien conçu s’en sert pour trancher des questions comme *« dois-je demander à l’utilisateur avant d’exécuter ceci ? »*. Ce sont des indications, pas de la sécurité. Ne comptez jamais sur un client pour les respecter. + +!!! tip + `name=` et `description=` sont également acceptés par `@mcp.tool()` si vous ne voulez pas les dériver + du nom de la fonction et de la docstring. La plupart du temps, c’est ce que vous voulez. + +## Récapitulatif {#recap} + +* `@mcp.tool()` sur une fonction en fait un outil. Le nom vient de la fonction, la description de la docstring. +* Les annotations de type **sont** le schéma d’entrée. Les valeurs par défaut rendent les arguments optionnels. +* `Annotated[..., Field(...)]` ajoute descriptions et contraintes ; `Literal` ajoute les énumérations. +* Un paramètre modèle Pydantic est la façon de recevoir un « corps » structuré. +* Les arguments invalides sont rejetés pour vous, avec une erreur que le modèle peut lire et dont il peut se remettre. +* `async def` pour les E/S, `def` tout court pour tout le reste. + +**[Sortie structurée](structured-output.md)** explique ce qu’il advient de la valeur que vous renvoyez avec `return`. diff --git a/i18n/fr/pages/servers/uri-templates.md b/i18n/fr/pages/servers/uri-templates.md new file mode 100644 index 0000000000..434dbb3697 --- /dev/null +++ b/i18n/fr/pages/servers/uri-templates.md @@ -0,0 +1,303 @@ +--- +translation: + sections: [4a7033e1ed8ad602, 55dcbfff0c6271bf, 101ef9d14bf4ec46, 4b6c4a845438abc7, f98b46bafbee4acd] + tool: 1 +--- +# Modèles d’URI et sûreté des chemins {#uri-templates-and-path-safety} + +Cette page est la référence de la syntaxe de modèle d’URI (URI template) +qu’accepte [`@mcp.resource`](resources.md), ainsi que de la politique de +sûreté des chemins que le SDK applique aux valeurs extraites. Pour une +introduction à ce que sont les ressources et au moment où les utiliser, +commencez par **[Ressources](resources.md)** ; cette page suppose que vous +savez déjà déclarer une ressource et que vous cherchez le jeu complet +d’opérateurs, les réglages de sécurité ou le câblage de bas niveau. + +La syntaxe des modèles est celle de la [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570). +Le SDK en prend en charge un sous-ensemble choisi pour faire correspondre +les URI des requêtes `resources/read` entrantes, auquel s’ajoute une couche +de sécurité qui rejette les valeurs qui se résoudraient en dehors du +répertoire que vous comptez servir. Pour les détails au niveau du protocole +(formats des messages, cycle de vie, pagination), consultez la +[spécification MCP des ressources](https://modelcontextprotocol.io/specification/latest/server/resources). + +## Le jeu complet d’opérateurs {#the-full-operator-set} + +L’espace réservé simple, `{user_id}`, est celui que présente **[Ressources](resources.md)**. Il existe quatre autres +formes d’opérateur ; les voici réunies sur un même serveur pour que vous +puissiez les comparer côte à côte : + +```python title="server.py" hl_lines="16-17 22-23 28-29 34-35 40-41" +--8<-- "docs_src/uri_templates/tutorial001.py" +``` + +Chaque décorateur mis en évidence découpe l’URI d’une manière différente. +Les sections ci-dessous les parcourent de haut en bas. + +### Expansion simple : `{name}` {#simple-expansion-name} + +`books://{isbn}` est la forme simple, celle de tous les jours. L’espace +réservé correspond au paramètre `isbn` ; un client qui lit +`books://978-0441172719` appelle donc `get_book("978-0441172719")`. + +Un `{name}` simple s’arrête au premier `/`. `books://978/extra` ne +correspond pas, car la barre oblique après `978` met fin à la capture et +`/extra` reste en trop. + +### Conversion de type {#type-conversion} + +Les valeurs extraites arrivent sous forme de chaînes, mais vous pouvez +déclarer un type plus précis et le SDK se charge de la conversion. +`orders://{order_id}` aboutit dans une fonction dont le paramètre est +`order_id: int` ; lire `orders://12345` appelle donc `get_order(12345)`, et +non `get_order("12345")`. Le gestionnaire (handler) fait de l’arithmétique +dessus (`order_id + 1`) sans transtypage. + +### Chemins à plusieurs segments : `{+name}` {#multi-segment-paths-name} + +Pour capturer une valeur qui contient des barres obliques, utilisez +`{+name}`. Avec `manuals://{+path}` : + +* `manuals://returns.md` donne `path = "returns.md"` +* `manuals://printing/setup.md` donne `path = "printing/setup.md"` + +Tournez-vous vers `{+name}` dès que la valeur est hiérarchique : chemins +du système de fichiers, clés d’objets imbriqués, chemins d’URL que vous +relayez. + +### Paramètres de requête : `{?a,b,c}` {#query-parameters-abc} + +`reviews://{isbn}{?limit,sort}` place `limit` et `sort` après le `?`. +Le chemin identifie *quel* livre ; la chaîne de requête règle *comment* +vous le lisez. + +Les paramètres de requête sont mis en correspondance avec souplesse : +l’ordre n’a pas d’importance, les paramètres en trop sont ignorés et les +paramètres omis retombent sur les valeurs par défaut de votre fonction. +Ainsi, `reviews://978-0441172719` utilise `limit=10, sort="newest"`, et +`reviews://978-0441172719?sort=top` ne remplace que `sort`. + +### Segments de chemin sous forme de liste : `{/name*}` {#path-segments-as-a-list-name} + +Si vous voulez chaque segment de chemin comme un élément de liste distinct +plutôt qu’une seule chaîne contenant des barres obliques, utilisez +`{/name*}`. Avec `shelves://browse{/path*}`, un client qui lit +`shelves://browse/fiction/sci-fi` appelle +`browse_shelf(["fiction", "sci-fi"])`. + +### Référence des modèles {#template-reference} + +Les motifs les plus courants : + +| Motif | Exemple d’entrée | Vous obtenez | +|--------------|-----------------------|-------------------------| +| `{name}` | `alice` | `"alice"` | +| `{name}` | `docs/intro.md` | *pas de correspondance* (s’arrête au `/`) | +| `{+path}` | `docs/intro.md` | `"docs/intro.md"` | +| `{.ext}` | `.json` | `"json"` | +| `{/segment}` | `/v2` | `"v2"` | +| `{?key}` | `?key=value` | `"value"` | +| `{?a,b}` | `?a=1&b=2` | `"1"`, `"2"` | +| `{/path*}` | `/a/b/c` | `["a", "b", "c"]` | + +### Ce que l’analyseur rejette {#what-the-parser-rejects} + +Quelques formes de modèle sont interceptées d’emblée plutôt que d’échouer +à la première requête. `@mcp.resource` analyse le modèle au moment où le +décorateur s’exécute ; aucune d’entre elles n’atteint donc jamais un +serveur en fonctionnement. + +`UriTemplate.parse()` lève `InvalidUriTemplate` pour : + +* **Deux variables sans rien entre elles.** `manuals://{+path}{ext}` + est rejeté : la mise en correspondance ne peut pas savoir où `path` se + termine et où `ext` commence. Placez un littéral entre les deux + (`manuals://{+path}/{ext}`) ou utilisez un opérateur qui fournit son + propre délimiteur. `manuals://{+path}{.ext}` est accepté parce que + `{.ext}` apporte lui-même le `.`. +* **Plus d’une variable à plusieurs segments.** Au plus une variable + parmi `{+var}`, `{#var}` ou une variable éclatée (`{/var*}`, `{.var*}`, + `{;var*}`) par modèle. Deux sont intrinsèquement ambiguës : il n’existe + aucun moyen rigoureux de décider laquelle absorbe un segment + supplémentaire. +* **Les erreurs de syntaxe habituelles** : une accolade non fermée, un nom + de variable utilisé deux fois ou une fonctionnalité de la RFC 6570 que + le SDK ne prend pas en charge, comme le modificateur de préfixe + `{var:3}` ou l’éclatement de requête `{?vars*}`. + +En plus de cela, `@mcp.resource` lève `ValueError` lorsqu’un paramètre du +gestionnaire est lié à une variable de requête dans la séquence finale +`{?...}`/`{&...}` du modèle mais n’a pas de valeur par défaut Python. Ces +variables sont mises en correspondance avec souplesse (un client peut +omettre n’importe laquelle), si bien qu’un paramètre sans valeur par défaut +ne se manifesterait que sous la forme d’une erreur interne opaque à la +première requête qui l’omet. `reviews://{isbn}{?limit,sort}` dans le +serveur ci-dessus est la version bien formée : `limit` et `sort` portent +tous deux une valeur par défaut. + +## Sécurité {#security} + +Les paramètres de modèle proviennent du client. S’ils se retrouvent sans +contrôle dans des opérations sur le système de fichiers ou la base de +données, des valeurs comme `../../etc/passwd` peuvent se résoudre en +dehors du répertoire que vous comptiez servir. + +### Ce que le SDK vérifie par défaut {#what-the-sdk-checks-by-default} + +Avant que votre gestionnaire ne s’exécute, le SDK rejette tout paramètre +qui : + +* s’échapperait de son répertoire de départ via des composants `..` +* ressemble à un chemin absolu (`/etc/passwd`, `C:\Windows`) ou à un + chemin Windows relatif à un lecteur (`C:foo`). Une valeur relative à un + lecteur et un identifiant à espace de noms comme `x:y` sont + indiscernables en tant que chaînes ; toute valeur composée d’une seule + lettre suivie de deux-points est donc rejetée par défaut. Exemptez le + paramètre s’il reçoit légitimement de telles valeurs +* contient un octet nul (`\x00`) + +La vérification des `..` se fait par composant, et non par recherche de +sous-chaîne. Des valeurs comme `v1.0..v2.0` ou `HEAD~3..HEAD` passent, +car `..` n’y constitue pas un segment de chemin autonome. + +Ces vérifications s’appliquent à la valeur décodée ; elles interceptent +donc la traversée de répertoires quelle que soit la façon dont elle a été +encodée dans l’URI (`../etc`, `..%2Fetc`, `%2E%2E/etc`, `..%5Cetc`, `%00` +sont tous interceptés). + +!!! check + Lisez `manuals://../etc/passwd` sur le serveur ci-dessus et la requête + est rejetée purement et simplement : la mise en correspondance des + modèles s’arrête au premier échec, si bien qu’aucun modèle ultérieur + (potentiellement plus permissif) n’est essayé en repli. Le client voit + la même erreur `-32602` « Unknown resource » que pour un URI qui ne + correspond à aucun modèle, et `read_manual` ne s’exécute jamais. + +### Gestionnaires sur le système de fichiers : utiliser safe_join {#filesystem-handlers-use-safe_join} + +Les vérifications intégrées bloquent les cas courants, mais ne peuvent pas +connaître la frontière de votre bac à sable. Pour l’accès au système de +fichiers, utilisez `safe_join` pour résoudre le chemin et vérifier qu’il +reste à l’intérieur de votre répertoire de base : + +```python title="server.py" hl_lines="4 14" +--8<-- "docs_src/uri_templates/tutorial002.py" +``` + +`safe_join` intercepte les échappements par lien symbolique, les séquences +`..` et les astuces à base de chemin absolu qu’une simple vérification de +chaîne laisserait passer. Si le chemin résolu s’échappe de `DOCS_ROOT`, il +lève `PathEscapeError`, qui parvient au client sous la forme d’une +`ResourceError`. + +### Quand les valeurs par défaut vous gênent {#when-the-defaults-get-in-the-way} + +Parfois, les vérifications bloquent des valeurs légitimes. Un outil +d’importation de catalogue peut recevoir intentionnellement un chemin +absolu, ou un paramètre peut être une référence relative comme +`../sibling` que votre gestionnaire interprète en toute sécurité sans +toucher au système de fichiers. Exemptez ce paramètre ou assouplissez la +politique pour tout le serveur : + +```python title="server.py" hl_lines="9 16-19" +--8<-- "docs_src/uri_templates/tutorial003.py" +``` + +* `security=ResourceSecurity(exempt_params={"source"})` sur le décorateur + saute les vérifications pour ce seul paramètre sur cette seule + ressource. Le reste du serveur conserve la politique par défaut. +* `resource_security=` sur le constructeur de `MCPServer` définit la + valeur par défaut pour chaque ressource. Ici, `relaxed` désactive + entièrement la vérification des `..`. + +Les vérifications configurables : + +| Réglage | Par défaut | Ce qu’il fait | +|-------------------------|---------|-------------------------------------| +| `reject_path_traversal` | `True` | Rejette les séquences `..` qui s’échappent du répertoire de départ | +| `reject_absolute_paths` | `True` | Rejette `/foo`, `C:\foo`, les chemins UNC et le `C:foo` relatif à un lecteur (intercepte aussi `x:y`) | +| `reject_null_bytes` | `True` | Rejette les valeurs contenant `\x00` | +| `exempt_params` | vide | Noms des paramètres à exempter des vérifications | + +Ces vérifications sont un préfiltre heuristique ; pour l’accès au système +de fichiers, `safe_join` reste la frontière de confinement. + +!!! tip + Si votre gestionnaire ne peut pas satisfaire la requête (le fichier + n’existe pas, l’identifiant est inconnu), levez une exception. Le SDK + la transforme en réponse d’erreur. Consultez **[Gérer les erreurs](handling-errors.md)** pour la + différence entre une erreur de protocole et une erreur d’outil. + +## Les ressources sur le Server de bas niveau {#resources-on-the-low-level-server} + +Si vous construisez sur le `Server` de bas niveau (voir **[Le Server de +bas niveau](../advanced/low-level-server.md)**), vous enregistrez directement des gestionnaires pour les +méthodes de protocole `resources/list` et `resources/read`. Il n’y a pas +de décorateur ; vous renvoyez vous-même les types du protocole. + +### Ressources statiques {#static-resources} + +Pour des URI fixes, tenez un registre et répartissez sur correspondance +exacte : + +```python title="server.py" hl_lines="17 21 27" +--8<-- "docs_src/uri_templates/tutorial004.py" +``` + +Le gestionnaire de liste indique aux clients ce qui est disponible ; le +gestionnaire de lecture sert le contenu. Consultez d’abord votre registre, +retombez sur les modèles (ci-dessous) si vous en avez, puis levez une +exception pour tout le reste. + +### Modèles {#templates} + +Le moteur de modèles qu’utilise `MCPServer` se trouve dans +`mcp.shared.uri_template` et fonctionne de manière autonome. Vous +bénéficiez de la même analyse et de la même mise en correspondance ; vous +câblez vous-même le routage et la politique de sécurité. + +```python title="server.py" hl_lines="13-16 22-25 29 33 45" +--8<-- "docs_src/uri_templates/tutorial005.py" +``` + +Trois choses se passent dans les lignes mises en évidence : + +* **Analyser une fois, faire correspondre à chaque requête.** + `UriTemplate.parse()` construit le modèle ; `template.match(uri)` + renvoie les variables extraites sous forme de `dict`, ou `None` si l’URI + ne convient pas. Le décodage d’URL a lieu dans `match()` ; les valeurs + décodées sont renvoyées telles quelles, sans validation de sûreté des + chemins. Les valeurs sortent sous forme de chaînes : convertissez-les + vous-même (`int(matched["id"])`, `Path(matched["path"])`). +* **Appliquer vous-même les vérifications de sûreté.** Les vérifications + des `..` et des chemins absolus que `MCPServer` exécute par défaut se + trouvent dans `mcp.shared.path_security`. `read_manual_safely` les + appelle avant de toucher à `MANUALS`. Si un paramètre n’est pas un + chemin du système de fichiers (un ISBN, une requête de recherche), + sautez les vérifications pour cette valeur : vous maîtrisez la politique + gestionnaire par gestionnaire plutôt qu’au travers d’un objet de + configuration. +* **Lister les modèles à partir de la même source.** Les clients + découvrent les modèles via `resources/templates/list`. `str(template)` + restitue la chaîne de modèle d’origine, si bien que la liste et le + moteur de correspondance partagent une seule source de vérité. + +## Récapitulatif {#recap} + +* `{name}` correspond à un seul segment ; `{+name}` conserve les barres + obliques ; `{?a,b}` puise dans la chaîne de requête ; `{/name*}` découpe + les segments en liste. +* Deux variables sans rien entre elles, ou une seconde variable à + plusieurs segments, sont rejetées à l’analyse. Un paramètre lié à une + variable de requête dans une séquence finale `{?...}`/`{&...}` doit + déclarer une valeur par défaut Python. +* Annotez le paramètre (`order_id: int`) et le SDK convertit. +* La politique de sécurité par défaut rejette `..`, les chemins absolus + et les octets nuls avant que votre gestionnaire ne s’exécute ; + remplacez-la par ressource avec `security=ResourceSecurity(...)` ou pour + tout le serveur avec `resource_security=`. +* Pour l’accès au système de fichiers, `safe_join` est la frontière de + confinement. +* Sur le `Server` de bas niveau, analysez avec `UriTemplate.parse()`, + faites correspondre avec `.match()` et appliquez + `mcp.shared.path_security` vous-même. diff --git a/i18n/fr/pages/translations.md b/i18n/fr/pages/translations.md new file mode 100644 index 0000000000..c985b0e073 --- /dev/null +++ b/i18n/fr/pages/translations.md @@ -0,0 +1,30 @@ +--- +translation: + sections: [f671b445b16e4f99, 3983a560eb2cece7, 004b3ee918529d8c, c6e2debf1da06eb7, 81d412ed5f399f94] + tool: 1 +--- +# Traductions {#translations} + +Cette documentation est rédigée en anglais. Pour qu’elle soit utile à davantage de personnes, nous en publions aussi des éditions traduites automatiquement. Cette page explique ce que cela signifie pour vous et comment contribuer à les améliorer. + +## Ce qui est disponible {#whats-available} + +La documentation traduite est actuellement une **préversion** en douze langues : Deutsch, español, français, हिन्दी, 日本語, 한국어, português (Brasil), русский язык, Türkçe, українська мова, 简体中文 et 繁體中文. Choisissez-en une dans le sélecteur de langue en haut de n’importe quelle page. D’autres langues pourront suivre une fois que celles-ci auront fait leurs preuves. + +La référence de l’API n’est pas traduite : le site traduit renvoie vers l’unique version anglaise. + +## L’anglais fait foi {#english-is-the-source-of-truth} + +Si une page traduite et son original anglais divergent, c’est la page anglaise qui a raison. Chaque page d’un site traduit s’ouvre sur l’une de ces trois mentions, qui indique où elle en est : + +- **Traduction automatique** — la page a été traduite automatiquement et renvoie vers son original anglais. +- **Traduction en retard sur la page anglaise** — l’original anglais a changé après la traduction de la page ; certaines parties peuvent donc être obsolètes jusqu’à ce que la traduction rattrape son retard. +- **Affichée en anglais** — il n’existe pas de traduction à jour de la page ; vous lisez donc le texte anglais. + +## Comment les traductions sont produites {#how-the-translations-are-made} + +Les pages traduites sont générées automatiquement par un outil de ce dépôt à partir des pages anglaises situées sous `docs/`, guidé par deux documents rédigés par des humains pour chaque langue : un guide de style (registre, ton, typographie, traitement des plaisanteries et des expressions idiomatiques) et un glossaire (les termes qui restent en anglais, ainsi que les traductions imposées et interdites pour les autres). Le texte généré n’est jamais modifié à la main. Chaque amélioration va dans ces documents, de sorte qu’elle survit à la prochaine régénération des pages. + +## Signaler un problème de traduction {#reporting-a-translation-problem} + +Vous avez repéré un terme incorrect, une phrase maladroite ou une traduction qui dit autre chose que l’anglais ? [Ouvrez un ticket](https://github.com/modelcontextprotocol/python-sdk/issues) en indiquant la langue, la page et le passage ; les signalements de locuteurs natifs sont particulièrement précieux. Si vous connaissez la correction, proposez-la directement sous forme de pull request sur le guide de style (`instructions.md`) ou le glossaire (`glossary.json`) de la langue concernée, sous [`i18n/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/i18n) — la correction se propage alors à toutes les pages concernées lors de la prochaine régénération des traductions. Les problèmes du texte anglais lui-même se corrigent dans les pages sous `docs/`, comme toute autre modification de la documentation. diff --git a/i18n/fr/pages/troubleshooting.md b/i18n/fr/pages/troubleshooting.md new file mode 100644 index 0000000000..e43b544b91 --- /dev/null +++ b/i18n/fr/pages/troubleshooting.md @@ -0,0 +1,423 @@ +--- +translation: + sections: [2efaecdef109a5c5, fcacd3e66b8635a4, 25323d737dcf0261, 4835ed1772f1d113, 137454d469c867f5, 6392596bd6df54f0, 41126fa9c4fe432f, 480b6d7897e30ab4, d83bb682e708dde0, ebbed3449c499db4, 323ef84f6b4bebde, 30fd31be74169d9a, 656943c6cb567218, c2dc3b1007d2e987, 7cf5386b997d04e9, 0b59feed8384456e, 0cba47bae78d04eb, 954dc21efdb532a3] + tool: 1 +--- +# Dépannage {#troubleshooting} + +Chaque titre de cette page reprend le texte exact d’une erreur produite par le SDK, suivi de ce qu’elle signifie et du correctif en un seul geste. Cherchez ici la dernière ligne de votre traceback (ou de votre journal serveur) avec la recherche dans la page de votre navigateur, et ne lisez que cette entrée. + +Plusieurs entrées s’appuient sur ce même serveur. Un outil (tool) et une ressource à modèle, chacun levant une exception pour une ville qu’il ne connaît pas : + +```python title="server.py" +--8<-- "docs_src/troubleshooting/tutorial001.py" +``` + +Les erreurs citées sur cette page sont réelles : la suite de tests du SDK reproduit chacune d’entre elles. + +## `ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)` {#exceptiongroup-unhandled-errors-in-a-taskgroup-1-sub-exception} + +Ce n’est pas une erreur MCP. C’est du bruit produit par anyio, et votre vraie erreur est la **dernière ligne** de ce que vous avez collé. + +`Client.__aenter__` démarre un groupe de tâches. anyio enveloppe tout ce qui sort d’un groupe de tâches dans un `ExceptionGroup`, si bien que *toute* exception qui s’échappe d’un bloc `async with Client(...)`, quelle qu’elle soit, arrive à l’intérieur de l’un d’eux : + +```python +async def main() -> None: + async with Client(mcp) as client: + await client.read_resource("weather://Atlantis") +``` + +```text + + Exception Group Traceback (most recent call last): + | ... + | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception) + +-+---------------- 1 ---------------- + | Exception Group Traceback (most recent call last): + | ... + | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception) + +-+---------------- 1 ---------------- + | Traceback (most recent call last): + | ... + | mcp.shared.exceptions.MCPError: No forecast for 'Atlantis'. + +------------------------------------ +``` + +Deux choses à faire avec cela : + +1. **Lisez le bas.** `MCPError: No forecast for 'Atlantis'.` est l’échec ; cherchez *son* texte sur cette page. +2. **Interceptez à l’intérieur du bloc.** Le groupe `ExceptionGroup` n’apparaît que lorsque l’exception *quitte* le `async with`. Interceptée à l’intérieur, la même erreur est l’exception `MCPError` toute simple, sans aucun groupe : + +```python +async def main() -> None: + async with Client(mcp) as client: + try: + await client.read_resource("weather://Atlantis") + except MCPError as e: + print(e) # No forecast for 'Atlantis'. +``` + +!!! tip + Un échec pendant la *connexion* (une URL erronée, un serveur qui ne tourne pas, le `421` plus + bas sur cette page) s’échappe de `async with` lui-même, il n’y a donc pas d’« intérieur » où + l’intercepter. Pour ceux-là, lisez le bas du groupe. + +## `RuntimeError: Client must be used within an async context manager` {#runtimeerror-client-must-be-used-within-an-async-context-manager} + +`Client(...)` ne fait que construire l’objet. Rien ne se connecte avant `async with`, donc chaque méthode refuse : + +```python +async def main() -> None: + client = Client(mcp) + tools = await client.list_tools() # RuntimeError +``` + +Entrez-y. `__aenter__` est la connexion : + +```python +async def main() -> None: + async with Client(mcp) as client: + tools = await client.list_tools() +``` + +`__aexit__` est la déconnexion, c’est pourquoi il n’y a pas de `client.close()` à oublier. **[Tests](get-started/testing.md)** repose exactement sur ce modèle. + +## `Error executing tool : ` et `Unknown tool: ` {#error-executing-tool-name-message-and-unknown-tool-name} + +Vous lisez un **résultat**, pas une exception. `call_tool` n’a pas levé d’exception, et ne le fera jamais pour un outil qui échoue. + +Appelez `forecast` pour une ville que le serveur ne connaît pas, et l’exception qu’il lève revient avec la requête marquée comme *réussie* : + +```python +result.is_error # True +result.content # [TextContent(text="Error executing tool forecast: No forecast for 'Atlantis'.")] +result.structured_content # None +``` + +`Unknown tool: get_forecast` a la même forme pour un nom que le serveur n’a jamais enregistré, et un mauvais argument est rejeté de la même manière, confronté au schéma d’entrée de l’outil, avant même que votre fonction ne s’exécute. + +Le correctif est dans votre client : **vérifiez `result.is_error`**. Un `try/except` autour de `call_tool` n’intercepte rien de tout cela, parce qu’il n’y a rien à intercepter. C’est voulu, et c’est la chose la plus utile de cette page à intégrer : c’est le *modèle* qui a choisi l’appel, donc c’est le modèle qui reçoit le message et une chance de réessayer. Tous les détails sont dans **[Gérer les erreurs](servers/handling-errors.md)**, y compris le chemin `MCPError` qui, lui, *lève* bien une exception. + +## `TypeError: The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool` {#typeerror-the-tool-decorator-was-used-incorrectly-did-you-forget-to-call-it-use-tool-instead-of-tool} + +Vous avez écrit `@mcp.tool` au lieu de `@mcp.tool()`. `tool()` est une *fabrique* de décorateurs : sans les parenthèses, Python passe votre fonction à son paramètre `name=`. + +```python +@mcp.tool # <- missing () +def forecast(city: str) -> str: + """Today's forecast for one city.""" + return f"{city}: Rain." +``` + +```text +TypeError: The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool +``` + +Ajoutez les parenthèses. `@mcp.resource(...)` et `@mcp.prompt()` disent la même chose pour la même étourderie. + +!!! note + Cette exception est levée à l’**import** du module, avant qu’un client ne se connecte. Un hôte + qui affiche votre serveur comme *en échec au démarrage* (ou *déconnecté*), plutôt que comme + connecté avec zéro outil, présente donc cette forme : lancez vous-même `python server.py` et + lisez le traceback. Un vérificateur de types l’attrape aussi : une fonction n’est pas un + `name=` valide. + +## `Tool already exists: ` {#tool-already-exists-name} + +Deux enregistrements ont utilisé le même nom d’outil. Le **premier** l’emporte, le second est silencieusement écarté, et cet avertissement dans le *journal du serveur* est le seul signal : + +```python title="server.py" hl_lines="6 12" +--8<-- "docs_src/troubleshooting/tutorial002.py" +``` + +```text +WARNING mcp.server.mcpserver.tools.tool_manager: Tool already exists: forecast +``` + +`tools/list` signale un seul `forecast`, et c’est `forecast_today`. Renommez l’un des deux. `MCPServer(..., warn_on_duplicate_tools=False)` fait taire l’avertissement sans changer le résultat, laissez-le donc activé. Les ressources et les prompts suivent la même règle et produisent la même ligne de journal (`Resource already exists:`, `Prompt already exists:`). + +## Mon hôte ne liste aucun outil {#my-host-lists-zero-tools} + +Il n’y a pas de message d’erreur pour ce cas, et c’est précisément pour cela qu’il est difficile à rechercher. Le SDK ne retire jamais un outil enregistré de `tools/list`, élargissez donc progressivement la recherche : + +* **Le serveur a-t-il seulement démarré ?** `@mcp.tool` sans parenthèses lève une exception à l’import, et un serveur planté ressemble beaucoup à un serveur vide dans certains hôtes. Lancez vous-même `python server.py`. +* **L’outil est-il sur le `mcp` que l’hôte exécute ?** Un second `MCPServer(...)` dans un autre module est un serveur différent, vide. Vérifiez quel objet la commande de l’hôte importe réellement. +* **Deux outils partagent-ils un nom ?** Alors l’un d’eux a disparu. Cherchez `Tool already exists:` dans le journal du serveur. +* **La liste de l’hôte est-elle périmée ?** Un outil ajouté après le démarrage n’atteint que les clients qui traitent `notifications/tools/list_changed`. Redémarrer l’hôte est le correctif brutal mais efficace. +* **Quelque chose a-t-il écrit sur `stdout` en dehors de la fenêtre de redirection ?** Pendant qu’il sert, le SDK redirige vers stderr les écritures parasites *vidées* sur stdout (au mieux : un environnement qui remplace les flux standard est servi tel quel), mais une sortie vidée vers stdout plus tôt (un script d’enrobage qui fait un echo, un `print()` à l’import dans un processus sans tampon) ou un `print()` mis en tampon et vidé à la sortie de l’interpréteur atterrit sur le flux du protocole, et une seule ligne parasite peut pousser l’hôte à couper la connexion, ce que certains hôtes affichent comme un serveur qui ne contient rien. Journalisez plutôt avec le module `logging`. Le reste de la liste de vérifications côté hôte se trouve sur **[Se connecter à un vrai hôte](get-started/real-host.md)**. + +Un nom d’outil « invalide » ne figure *pas* dans cette liste : un nom non conforme journalise un avertissement, mais l’outil est quand même enregistré et listé. + +## `MCPError: Server returned an error response` {#mcperror-server-returned-an-error-response} + +Le serveur a refusé net la requête HTTP, avec un corps qui n’est pas du JSON-RPC, si bien que le `Client` python n’a rien de mieux à vous montrer que ce message de remplacement. + +La cause de loin la plus fréquente est un serveur Streamable HTTP fraîchement déployé. `streamable_http_app()` (et `mcp.run("streamable-http")`) sans `transport_security=` active par défaut la **protection contre le DNS rebinding** : il n’accepte que les requêtes dont l’en-tête `Host` est localhost. C’est la bonne valeur par défaut sur votre portable et la mauvaise derrière un vrai nom d’hôte : + +```python title="server.py" hl_lines="12" +--8<-- "docs_src/troubleshooting/tutorial003.py" +``` + +Déployez cela, pointez un client dessus, et la connexion échoue dès la poignée de main (handshake) : + +```python +async with Client("https://mcp.example.com/mcp") as client: + ... +``` + +```text +mcp.shared.exceptions.MCPError: Server returned an error response +``` + +Les mots que le serveur a réellement envoyés, `421` et `Invalid Host header`, ne vous parviennent jamais : le corps du 421 n’a pas de `Content-Type: application/json`, donc le client ne peut pas l’analyser. Ils sont dans le **journal du serveur**, et c’est là qu’il faut regarder ensuite : + +```text +WARNING mcp.server.transport_security: Invalid Host header: mcp.example.com +``` + +Le correctif est `transport_security=`. Mettez en liste d’autorisation le nom d’hôte que vous servez réellement : + +```python title="server.py" hl_lines="14-17" +--8<-- "docs_src/troubleshooting/tutorial004.py" +``` + +!!! check + C’est tout le changement. Le même client se connecte désormais, négocie `2026-07-28` et + appelle `forecast`. + +**[Déployer et passer à l’échelle](run/deploy.md)** explique ce que signifie chaque champ, le cas du proxy inverse et tout ce qui change d’autre au moment du déploiement. Et `421 Misdirected Request` / `Invalid Host header`, juste en dessous, est le même échec vu de l’autre côté. + +## `421 Misdirected Request` / `Invalid Host header` {#421-misdirected-request-invalid-host-header} + +C’est `Server returned an error response`, vu depuis tout ce qui n’est *pas* le `Client` python : curl, l’onglet réseau d’un navigateur, le journal d’accès d’un proxy inverse ou un autre SDK. + +```bash +curl -i https://mcp.example.com/mcp \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}' +``` + +```text +HTTP/1.1 421 Misdirected Request + +Invalid Host header +``` + +`421 Misdirected Request` est le libellé propre à HTTP pour ce statut ; `Invalid Host header` est le corps de réponse du SDK ; et le `Client` python rend le même événement sous la forme `Server returned an error response`. Les trois sont un seul et même refus. La vérification porte sur l’**en-tête `Host` que transporte la requête**, pas sur l’adresse à laquelle le serveur s’est lié, si bien qu’un proxy inverse qui transmet le nom d’hôte public la déclenche exactement comme un client direct. + +Le correctif est le même `transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])` que celui montré sous `Server returned an error response`. Deux de ses cas limites méritent d’être nommés : + +* Une entrée de `allowed_hosts` est une chaîne exacte. `"mcp.example.com"` correspond à un en-tête `Host` nu et `"mcp.example.com:*"` correspond à n’importe quel port explicite. Listez les deux. +* Un `403` avec le corps `Invalid Origin header` est la vérification jumelle sur l’en-tête `Origin`. Elle ne se déclenche que pour les navigateurs (rien d’autre n’envoie `Origin`), et `allowed_origins=` est sa liste d’autorisation. + +**[Déployer et passer à l’échelle](run/deploy.md)** traite le sujet en entier, y compris les cas où désactiver la vérification est la configuration honnête. + +## `RuntimeError: Task group is not initialized. Make sure to use run().` {#runtimeerror-task-group-is-not-initialized-make-sure-to-use-run} + +Votre application MCP est montée dans une autre application ASGI, et rien n’a démarré son **gestionnaire de sessions**. + +`mcp.streamable_http_app()` renvoie une application Starlette dont le propre cycle de vie (lifespan) démarre le gestionnaire, et `uvicorn server:app` exécute ce cycle de vie pour vous. Mais Starlette **n’exécute jamais le cycle de vie d’une sous-application montée**, si bien que dès que l’application passe dans un `Mount`, le gestionnaire ne démarre jamais et la première requête explose : + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/troubleshooting/tutorial005.py" +``` + +Le serveur démarre. La route se résout. Puis `uvicorn` affiche ceci pour chaque requête : + +```text +ERROR: Exception in ASGI application +Traceback (most recent call last): + ... +RuntimeError: Task group is not initialized. Make sure to use run(). +``` + +Le client voit un 500. Le correctif est un cycle de vie sur l’application **hôte** qui entre dans `mcp.session_manager.run()` : + +```python +@asynccontextmanager +async def lifespan(app: Starlette) -> AsyncIterator[None]: + async with mcp.session_manager.run(): + yield + + +app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lifespan) +``` + +**[Ajouter à une application existante](run/asgi.md)** est la page consacrée à ce sujet, y compris plusieurs serveurs dans une seule application et FastAPI. Deux messages voisins issus de la même classe : + +* `StreamableHTTPSessionManager .run() can only be called once per instance. Create a new instance if you need to run again.` Le gestionnaire est à usage unique ; entrer deux fois dans le cycle de vie de la même application le déclenche. +* `mcp.session_manager` n’existe qu’**après** l’appel de `streamable_http_app()`, donc construisez d’abord les routes et ne touchez au gestionnaire qu’à l’intérieur du cycle de vie. + +## `MCPError: Session not found` {#mcperror-session-not-found} + +Le serveur ne reconnaît pas le `Mcp-Session-Id` que votre client a envoyé, presque toujours parce que le serveur a **redémarré** (ou que vous avez été routé vers une autre instance). Les sessions vivent dans la mémoire de ce seul processus. + +Il n’y a pas de bogue serveur à trouver. La réponse HTTP est un `404` dont le corps *est* du JSON-RPC, donc, contrairement au `421` ci-dessus, le `Client` python vous montre celui-ci mot pour mot : + +```json +{"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "Session not found"}} +``` + +Le correctif est de vous reconnecter : quittez le bloc `async with Client(...)` et entrez dans un nouveau, qui négocie une session neuve. Pour un client de longue durée, cela signifie intercepter `MCPError` autour de vos appels et vous reconnecter sur ce message plutôt que de réessayer dans une session morte. + +Si cela arrive *sans* redémarrage, vous exécutez plus d’un worker sans sessions persistantes (sticky sessions) : chaque worker détient sa propre table de sessions, donc une requête routée vers le mauvais atterrit ici. **[Déployer et passer à l’échelle](run/deploy.md)** et **[Prendre en charge les clients historiques](run/legacy-clients.md)** traitent ce sujet et ses deux correctifs (routage persistant, ou `stateless_http=True`). + +Pour l’opérateur du serveur, la ligne de journal correspondante est `Rejected request with unknown or expired session ID: `. Elle est journalisée au niveau `INFO`, elle est donc invisible au seuil habituel `WARNING`. La voir par rafales juste après un déploiement est normal ; chaque client connecté se reconnecte. + +## `MCPError: Method not found` {#mcperror-method-not-found} + +Un côté a envoyé une requête JSON-RPC pour laquelle l’autre n’a pas de gestionnaire (handler), et `e.error.data` nomme la méthode. La cause habituelle est une **différence de génération** : une méthode qui existe dans une révision du protocole et pas dans l’autre, envoyée à un pair sur la mauvaise, comme un `resources/subscribe` de génération `2025` arrivant sur une connexion `2026-07-28`, ou un `subscriptions/listen` réservé à `2026` envoyé par un client épinglé sur `mode="legacy"`. **[Versions du protocole](protocol-versions.md)** est la carte de qui parle quoi, et l’autre cause honnête (une capacité optionnelle pour laquelle vous n’avez jamais enregistré de gestionnaire) se trouve sur **[Complétions](servers/completions.md)**. + +Une chose ne produit **pas** cette erreur, bien qu’il s’agisse d’une requête que le protocole moderne a supprimée : un outil qui appelle `ctx.elicit()` sur une connexion `2026-07-28`. Le serveur refuse tout bonnement d’*envoyer* cette requête, si bien que vous obtenez à la place `Cannot send 'elicitation/create': ...`, plus bas sur cette page. + +## `MCPError: Client did not declare the form elicitation capability required by resolver ''` {#mcperror-client-did-not-declare-the-form-elicitation-capability-required-by-resolver-name} + +Votre serveur veut demander quelque chose à l’utilisateur, et ce client n’a jamais dit qu’on pouvait l’interroger. + +Un résolveur d’élicitation (elicitation) refuse d’emblée lorsque le client connecté n’a pas déclaré l’élicitation par formulaire, et `e.error.data` nomme exactement ce qui manque : + +```json +{ + "code": -32021, + "message": "Client did not declare the form elicitation capability required by resolver 'server:ask_to_confirm'", + "data": {"requiredCapabilities": {"elicitation": {"form": {}}}} +} +``` + +Passez `elicitation_callback=` à `Client(...)`. Enregistrer la fonction de rappel (callback) *est* la déclaration de capacité ; il n’y a pas de second interrupteur : + +```python +async def main() -> None: + async with Client(mcp, elicitation_callback=handle_elicitation) as client: + result = await client.call_tool("book_table", {"date": "Friday"}) +``` + +**[Fonctions de rappel du client](client/callbacks.md)** liste les autres (`sampling_callback`, `list_roots_callback`), dont chacune est une déclaration de la même manière. + +!!! info + `-32021` est `MISSING_REQUIRED_CLIENT_CAPABILITY`, l’un des trois codes d’erreur que la + spécification 2026-07-28 ajoute. Aucun n’est une classe d’exception : ils arrivent tous sous + forme de `MCPError`, et c’est `e.error.code` qu’il faut regarder. `mcp.types` exporte les + constantes. Les deux autres sont `-32020` `HEADER_MISMATCH` (un en-tête HTTP est en désaccord + avec le corps de requête qu’il accompagne) et `-32022` `UNSUPPORTED_PROTOCOL_VERSION` (la + requête nommait une version que ce serveur ne parle pas). Un client SDK conforme ne peut + produire ni l’un ni l’autre, donc si vous en voyez un, regardez ce qui réécrit les requêtes + entre votre client et votre serveur. + +## `MCPError: Elicitation not supported` {#mcperror-elicitation-not-supported} + +Le même manque que `Client did not declare the form elicitation capability ...`, formulé par les chemins qui ne vérifient pas d’emblée : le serveur avait besoin qu’on réponde à une élicitation, et le client connecté n’a enregistré aucun `elicitation_callback`. + +Vous voyez celui-ci depuis `ctx.elicit()` sur une connexion historique, et sur n’importe quelle connexion depuis une question à plusieurs allers-retours (multi-round-trip) renvoyée (**[Requêtes à plusieurs allers-retours](handlers/multi-round-trip.md)**) qui atteint un client sans fonction de rappel pour y répondre. Le correctif est identique : passez `elicitation_callback=` à `Client(...)`. Il n’existe aucune version de « l’utilisateur n’a pas été interrogé » que votre outil recevrait sous forme de `decline` ; un client qu’on ne peut pas interroger est un appel en échec, concevez donc vos outils en conséquence. + +## `MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests.` {#mcperror-cannot-send-elicitationcreate-this-transport-context-has-no-back-channel-for-server-initiated-requests} + +Votre gestionnaire a tenté de joindre le client en cours de requête, sur une connexion dont l’appel n’a aucun canal capable de transporter une requête venant du serveur. Trois configurations de serveur placent un appel dans cette situation. + +**Une connexion `2026-07-28` : n’importe quel transport, toujours.** Le protocole moderne n’a aucune requête à l’initiative du serveur, si bien que le serveur refuse avant que quoi que ce soit ne soit envoyé. `ctx.elicit()` dans un outil est la façon classique de la rencontrer (dès le tout premier test en mémoire, puisque `Client(server)` négocie `2026-07-28` sans qu’on le lui demande), et passer `elicitation_callback=` ne change rien, parce qu’aucune requête n’atteint jamais le client pour qu’il y réponde : + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/troubleshooting/tutorial006.py" +``` + +```python +async def main() -> None: + async with Client(mcp) as client: + await client.call_tool("book_table", {"date": "Friday"}) +``` + +```text +mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests. +``` + +**Une connexion historique sur un serveur `stateless_http=True`.** L’absence d’état signifie que chaque requête est un monde à part : pas de session, pas de flux serveur-vers-client, et donc nulle part où envoyer un `elicitation/create` (ou un `sampling/createMessage`, ou un `roots/list`) même pour la génération qui les possède : + +```python title="server.py" hl_lines="16 23" +--8<-- "docs_src/troubleshooting/tutorial008.py" +``` + +**Une connexion historique sur un serveur `json_response=True`.** Le `POST` reçoit pour réponse un seul corps JSON, et un seul corps ne transporte que la réponse, si bien que le flux attaché à la requête dont a besoin un `ctx.elicit()` en cours de requête n’existe pas ici non plus. La session, son `Mcp-Session-Id` et son flux autonome sont tous encore là ; seul le canal attaché à la requête a disparu. + +Le message nomme la méthode qu’il n’a pas pu envoyer. `NoBackChannelError` est la classe que lève le serveur, mais la liaison ne transporte que la `MCPError` de base, si bien que la phrase ci-dessus est la dernière ligne de votre traceback, pas le nom de la classe. + +Pour un client `2026-07-28`, le correctif est le même dans les trois cas : ne rappelez pas le client en cours d’appel. Déplacez la question dans un **résolveur** (ou renvoyez vous-même un `InputRequiredResult`) et elle devient une partie de la *réponse*, que toute connexion peut transporter : + +```python title="server.py" hl_lines="15-17 21" +--8<-- "docs_src/troubleshooting/tutorial007.py" +``` + +Même question, même `elicitation_callback` côté client. La différence est sous le capot : un résolveur permet au serveur de *renvoyer* la question depuis l’appel au lieu de la pousser, si bien que rien ne circule jamais du serveur vers le client. Cela sauve chaque client `2026-07-28`, quelle que soit celle des trois configurations dans laquelle se trouve le serveur. Un client *historique* n’est pas sauvé par la réécriture seule : `2025-11-25` n’a aucun moyen de renvoyer une question, donc sur une connexion historique le résolveur envoie toujours `elicitation/create` par le canal attaché à la requête, et a toujours besoin d’un serveur qui le conserve — ni `stateless_http=True` ni `json_response=True`. **[Élicitation](handlers/elicitation.md)** couvre les résolveurs ; **[Requêtes à plusieurs allers-retours](handlers/multi-round-trip.md)** couvre ce qui se passe sur la liaison. + +!!! check + L’outil avec `ctx.elicit()` n’est pas faux, il est *antérieur à 2026*. Connectez-vous avec + `mode="legacy"` (la poignée de main `initialize` classique, spécification `2025-11-25` et + antérieures) à un serveur qui n’est ni `stateless_http=True` ni `json_response=True`, et cela + fonctionne, parce que le canal serveur-vers-client existe là. + **[Versions du protocole](protocol-versions.md)** est la page qui détaille ce que possède + chaque version. + +## `MCPError: Invalid or expired requestState` {#mcperror-invalid-or-expired-requeststate} + +Le serveur n’a pas pu vérifier le jeton `requestState` que votre client a renvoyé en écho, il a donc refusé ce tour. + +`requestState` est le jeton de reprise opaque qu’un appel **[à plusieurs allers-retours](handlers/multi-round-trip.md)** transporte entre ses étapes. `MCPServer` le scelle à la sortie et vérifie chaque écho, et il vérifie *chaque* `request_state` entrant sur `tools/call`, `prompts/get` et `resources/read`, même pour un gestionnaire qui n’en émet jamais. Un jeton que ce processus n’a pas scellé est donc refusé où qu’il atterrisse : + +```python +async def main() -> None: + async with Client(mcp) as client: + await client.call_tool("forecast", {"city": "London"}, request_state="round-1-from-worker-a") +``` + +```text +mcp.shared.exceptions.MCPError: Invalid or expired requestState +``` + +Le message est délibérément figé : la liaison ne révèle jamais quelle vérification a échoué. La raison va dans le **journal du serveur**, et le lire est tout le diagnostic : + +```text +WARNING mcp.server.request_state: requestState rejected on tools/call: malformed +``` + +Les raisons que vous verrez réellement : + +* **`unknown key`** est celle qui compte. La clé de scellement par défaut est générée au démarrage du processus, donc une nouvelle tentative qui atterrit sur un **autre worker**, une autre instance derrière un répartiteur de charge, ou le même serveur **après un redémarrage** a été scellée sous une clé que ce processus n’a jamais eue. Ce n’est pas un attaquant ; c’est la valeur par défaut confrontée à plus d’un processus. +* **`audience`** : le jeton a été scellé par une instance portant un *nom de serveur différent*. Le nom est la revendication d’audience par défaut du sceau, donc une flotte doit partager le nom (ou définir un `RequestStateSecurity(audience=...)` explicite) en plus des clés. +* **`expired`** : le tour a pris plus longtemps que le `ttl` du sceau, qui est de 600 secondes et s’applique par tour, pas par appel. +* **`malformed`** / **`codec error`** : le jeton a été altéré en transit, ou n’a jamais été un jeton scellé. +* **`request binding`** : le jeton est revenu avec un outil différent, des arguments différents ou une méthode différente. + +Le correctif multi-processus tient en un argument (les *mêmes* `keys` sur chaque instance) plus une chose qui n’est pas un argument du tout : le même *nom* de serveur (ou un `audience=` partagé explicite). + +```python +mcp = MCPServer("Weather", request_state_security=RequestStateSecurity(keys=[key])) +``` + +`keys[0]` scelle ; chaque clé de la liste vérifie, ce qui rend possible la rotation sans interruption. **[Requêtes à plusieurs allers-retours](handlers/multi-round-trip.md#protecting-requeststate)** explique ce que protège le sceau et la séquence de rotation, et **[Déployer et passer à l’échelle](run/deploy.md)** parcourt en entier l’échec à deux workers et son correctif en deux parties. + +!!! tip + `keys=[...]` refuse immédiatement une clé faible, avec un message d’une utilité inhabituelle : + + ```text + ValueError: request-state keys must be at least 32 bytes of secret randomness; keys[0] is 7 bytes. Generate one with: python -c "import secrets; print(secrets.token_hex(32))" + ``` + + Faites ce qu’il dit. + +## Toujours bloqué ? {#still-stuck} + +* Si un message produit par le SDK n’est pas sur cette page, c’est un bogue de documentation qui mérite d’être signalé en tant que tel. +* Cherchez dans le [suivi des tickets](https://github.com/modelcontextprotocol/python-sdk/issues) ; la plupart des messages d’erreur qui y apparaissent ont déjà été expliqués par quelqu’un. +* Rien trouvé ? [Ouvrez un ticket](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml) avec le traceback complet, ou posez la question dans [#python-sdk-dev sur le Discord MCP Contributors](https://discord.gg/6CSzBmMkjX). + +## Récapitulatif {#recap} + +* `ExceptionGroup: unhandled errors in a TaskGroup` n’est jamais l’erreur. Lisez la **dernière ligne** ; intercepter `MCPError` *à l’intérieur* du bloc `async with Client(...)` évite entièrement l’enveloppe. +* `call_tool` ne lève pas d’exception pour un outil qui échoue. `Error executing tool ...` et `Unknown tool: ...` sont des résultats : vérifiez `result.is_error`. +* `Client must be used within an async context manager` -> utilisez `async with`. `Use @tool() instead of @tool` -> ajoutez les parenthèses. +* `Tool already exists:` dans le journal du serveur est le seul signe que deux outils de même nom se sont fondus en un seul. +* Un 421, trois formulations : `Server returned an error response` (le `Client` python), `421 Misdirected Request` / `Invalid Host header` (tout le reste), `Invalid Host header: ` (le journal du serveur). Correctif : `transport_security=TransportSecuritySettings(allowed_hosts=[...])`. +* `Task group is not initialized` -> une application montée dont le cycle de vie de l’hôte n’est jamais entré dans `mcp.session_manager.run()`. +* `Session not found` -> le serveur a redémarré ; reconnectez-vous. +* `Cannot send 'elicitation/create': ... no back-channel ...` -> `ctx.elicit()` a besoin d’un canal serveur-vers-client : une connexion `2026-07-28` n’en a jamais, `stateless_http=True` retire celui des connexions historiques, et `json_response=True` retire celui attaché à la requête. Utilisez un résolveur (un client historique a aussi besoin d’un serveur qui conserve le canal). Son voisin `Method not found` est une requête pour une méthode que la révision du protocole de l’autre côté ne possède pas. +* `Client did not declare the form elicitation capability ...` et `Elicitation not supported` -> il manque `elicitation_callback=` au client. +* `Invalid or expired requestState` ne dit jamais pourquoi sur la liaison. Le journal du serveur, si ; `unknown key` signifie qu’il faut partager `RequestStateSecurity(keys=[...])` entre les workers. diff --git a/i18n/fr/pages/whats-new.md b/i18n/fr/pages/whats-new.md new file mode 100644 index 0000000000..a6e26e0127 --- /dev/null +++ b/i18n/fr/pages/whats-new.md @@ -0,0 +1,215 @@ +--- +translation: + sections: [cfe01c0c5863dfa2, 11d93f1fa09eadf5, a7392996acf1ad8f, 875eb2889263424e] + tool: 1 +--- +# Nouveautés de la v2 {#whats-new-in-v2} + +Deux choses se sont produites en même temps dans la v2. Le **SDK a été reconstruit** : un nouveau moteur sous le client comme sous le serveur, un `Client` de premier plan et une série de renommages qu’une base de code v1 rencontre dès son premier import. Et le **protocole a évolué** : la v2 parle la révision 2026-07-28 de MCP, qui supprime la poignée de main (handshake) de connexion, la session et toutes les requêtes initiées par le serveur, sans abandonner les clients que vous avez déjà. + +Cette page fait le tour des deux volets, une section par grand titre, chacune se terminant par la page de référence du sujet. Ce n’est pas le manuel de portage. Celui-ci, c’est le **[Guide de migration](migration.md)** : chaque changement incompatible, avec le code avant et après. + +!!! note "La v2 est la branche stable" + `pip install mcp` installe la 2.x, et **[Installation](get-started/installation.md)** donne la + ligne d’installation à copier-coller. Si quoi que ce soit dans la v2 casse, vous surprend ou vous + ralentit, [dites-le-nous](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml). + +## Le SDK : de la v1 à la v2 {#the-sdk-v1-to-v2} + +### `FastMCP` s’appelle désormais `MCPServer` {#fastmcp-is-now-mcpserver} + +La classe de serveur haut niveau a été renommée, et son module avec elle. C’est la première chose sur laquelle bute tout serveur v1, car l’ancien chemin d’import a disparu au lieu d’être simplement obsolète : + +```python +from mcp.server import MCPServer # v1: from mcp.server.fastmcp import FastMCP + +mcp = MCPServer("Demo") # v1: FastMCP("Demo") +``` + +C’est aussi, pour un serveur construit avec des décorateurs, l’essentiel du portage. `@mcp.tool()`, `@mcp.resource()` et `@mcp.prompt()` acceptent ce qu’ils acceptaient en v1 (`@mcp.resource()` ajoute un mot-clé optionnel `security=`), et le schéma d’entrée provient toujours de vos annotations de type. À la marge : tout ce qui se trouvait sous `mcp.server.fastmcp.*` vit maintenant sous `mcp.server.mcpserver.*`, `ctx.fastmcp` devient `ctx.mcp_server`, `get_context()` a disparu (déclarez plutôt un paramètre `ctx: Context`) et la classe d’exception de base `FastMCPError` devient `MCPServerError`. Le **[Guide de migration](migration.md#fastmcp-renamed-to-mcpserver)** contient le tableau des imports. + +### `Resolve` : la nouvelle façon de demander une saisie à l’utilisateur {#resolve-the-new-way-to-ask-the-user-for-input} + +Tout ce dont un outil a besoin ne devrait pas venir du modèle. Nouveauté de la v2 : un paramètre d’outil annoté avec `Resolve(fn)` est rempli à la place par une fonction que vous écrivez, de façon invisible pour le modèle, et cette fonction peut renvoyer `Elicit(...)` pour poser une question à l’utilisateur. C’est la façon privilégiée d’obtenir quoi que ce soit du client en cours d’appel : le SDK achemine la question par le mécanisme que la connexion prend en charge (une requête d’élicitation (elicitation) en direct pour un client historique, une requête à plusieurs allers-retours (multi-round-trip) en version 2026-07-28), si bien qu’un seul corps d’outil sert les deux générations. La page à lire est **[Dépendances](handlers/dependencies.md)**. + +!!! note + Les deux autres formes restent disponibles si vous en avez besoin : `ctx.elicit()` fonctionne + toujours pour les clients sur des connexions historiques (**[Élicitation](handlers/elicitation.md)**), + et un gestionnaire peut renvoyer lui-même un `InputRequiredResult` et piloter les tours à la main, + ce qui est aussi la façon dont les requêtes d’échantillonnage (sampling) et de racines (roots) + voyagent en version 2026-07-28 (**[Requêtes à plusieurs allers-retours](handlers/multi-round-trip.md)**). + +### Un `Client` de premier plan {#a-first-class-client} + +La v1 vous donnait trois couches imbriquées : un gestionnaire de contexte de transport produisant des flux bruts, une `ClientSession` qui les enveloppait et un `await session.initialize()` appelé à la main. La v2 a un seul objet : + +```python title="client.py" hl_lines="14-18" +--8<-- "docs_src/client/tutorial001.py" +``` + +`Client` accepte un objet serveur (en mémoire, sans transport : c’est la solution pour les tests), une URL (Streamable HTTP) ou n’importe quel gestionnaire de contexte de transport comme `stdio_client(...)`. Entrer dans `async with` établit la connexion et négocie la version du protocole, quelle que soit la génération que parle le serveur ; `client.server_capabilities` et `client.protocol_version` sont simplement disponibles ensuite, et `client.server_info` aussi lorsque le serveur s’identifie (c’est désormais `Implementation | None`, puisque l’identité est optionnelle dans la génération 2026). Les fonctions de rappel (callbacks) d’échantillonnage et d’élicitation que vous aviez enregistrées en v1 fonctionnent toujours (leur corps voit le même renommage d’attributs en snake_case que tout le reste de cette page), elles répondent désormais aussi aux requêtes-dans-les-résultats de style 2026 (ci-dessous), et elles s’exécutent de façon concurrente plutôt qu’une à la fois. `ClientSession` reste en dessous pour qui veut la surface bas niveau, et `client.session` vous la donne ; elle a bougé elle aussi (elle tourne sur le nouveau moteur de répartition, et certaines de ses propres signatures ont changé), alors lisez le **[Guide de migration](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)** avant de descendre à ce niveau. + +**[Le Client](client/index.md)** le présente, **[Transports du client](client/transports.md)** couvre les trois formes de connexion, **[Fonctions de rappel du client](client/callbacks.md)** couvre les fonctions de rappel elles-mêmes, et **[Tests](get-started/testing.md)** montre le modèle en mémoire qui remplace l’utilitaire `create_connected_server_and_client_session()` de la v1. + +### Le `Server` bas niveau a été reconstruit, pas renommé {#the-low-level-server-was-rebuilt-not-renamed} + +Si vous travaillez au niveau de la couche JSON-RPC, c’est la partie « tout est différent » de la v2. Voici le même serveur à un seul outil dans les deux versions ; cliquez sur les marqueurs pour voir ce qui a bougé. + + + +```python title="v1" +from typing import Any + +import mcp.types as types +from mcp.server.lowlevel import Server + +server = Server("Bookshop") + + +@server.list_tools() # (1)! +async def list_tools() -> list[types.Tool]: + return [ # (2)! + types.Tool( + name="search_books", + description="Search the catalog by title or author.", + inputSchema={ # (3)! + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + }, + ) + ] + + +@server.call_tool() +async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentBlock]: # (4)! + if name != "search_books": + raise ValueError(f"Unknown tool: {name}") # (5)! + ctx = server.request_context # (6)! + return [types.TextContent(type="text", text=f"Found 3 books matching {arguments['query']!r}.")] # (7)! +``` + +1. Les gestionnaires sont enregistrés avec des décorateurs (appelés, avec parenthèses), à tout moment une fois que le serveur existe. +2. Vous renvoyez une simple `list[Tool]` et le SDK l’enveloppe dans un `ListToolsResult`. +3. Les champs sont en camelCase côté Python, et le schéma est **appliqué** : le SDK valide avec jsonschema les arguments de `call_tool` par rapport à ce schéma avant que votre fonction ne s’exécute, ce qui explique pourquoi `arguments["query"]` ci-dessous est sûr. +4. Un seul gestionnaire `call_tool` sert tous les outils, et il reçoit le nom de l’outil et les arguments déjà validés, dépaquetés et jamais `None`. +5. Lever une exception est la façon dont un outil v1 signale un échec : toute exception est interceptée et renvoyée comme `CallToolResult(isError=True)` avec `str(e)` pour texte, si bien que le modèle appelant lit ce message et peut réessayer. +6. Le contexte vient d’une ContextVar ambiante, accessible via l’objet serveur en cours de requête. +7. Les blocs de contenu nus sont enveloppés dans un `CallToolResult` pour vous. + +```python title="v2" +--8<-- "docs_src/whats_new/tutorial001.py" +``` + +1. Les champs sont maintenant en snake_case, et le schéma est **annoncé mais jamais appliqué** : rien ne vérifie les arguments avant l’exécution de votre gestionnaire. +2. Tous les gestionnaires ont la même forme : `async (ctx, params) -> result`. Le contexte est le premier argument (`ctx.session`, `ctx.request_id`, `ctx.protocol_version` s’y trouvent) ; c’est là qu’est passé `server.request_context`. +3. Vous construisez vous-même le `ListToolsResult` complet. Renvoyer une simple liste est désormais une `TypeError` côté serveur, et non quelque chose que le SDK enveloppe. +4. Des paramètres typés en entrée (`params.name`, `params.arguments`), un résultat complet en sortie. Rien n’est dépaqueté, enveloppé ni converti pour vous. +5. Même vérification, autre verbe. Une `ValueError` ici atteindrait le modèle sous forme d’un `-32603` opaque (voir ci-dessous), donc une erreur volontaire sur la liaison se lève en `MCPError` : elle passe telle quelle avec son code et son message, et `-32602` avec ce texte est la réponse prévue par la spécification elle-même pour un outil inconnu. +6. `params.arguments` peut valoir `None` ; la v1 le remplaçait par `{}` avant même que votre code ne le voie. Sans validation devant le gestionnaire, cette ligne est indispensable. +7. Une exception inattendue levée ici devient une erreur de protocole **expurgée**, `-32603` `"Internal server error"` : le modèle ne voit jamais le message. Pour un échec que le modèle doit lire et auquel il doit réagir, renvoyez `CallToolResult(is_error=True, ...)`. +8. Les gestionnaires sont des arguments du constructeur, si bien que la surface du serveur est complète dès qu’il existe ; `add_request_handler()` est l’échappatoire après construction, et la porte d’entrée vers les méthodes personnalisées. + +L’exemple illustre le modèle. Plus généralement : tous les gestionnaires ont la même forme, paramètres typés en entrée et type de résultat complet en sortie ; l’ancienne vérification jsonschema des arguments d’outil a disparu ; une exception est une erreur de protocole, jamais un résultat d’outil `is_error=True` ; et la ContextVar ambiante `server.request_context` a disparu. Les méthodes personnalisées, dans un espace de noms fournisseur, sont de premier plan via `add_request_handler(method, params_type, handler)`, qui valide les paramètres entrants par rapport à votre modèle avant l’exécution de votre gestionnaire. Et une liste `middleware` (délibérément marquée comme provisoire) enveloppe chaque message entrant, remplaçant les méthodes privées `_handle_*` que l’on avait l’habitude de surcharger. + +En dessous, la boucle de réception `BaseSession` de la v1 a été remplacée par un moteur de répartition que le client et le serveur partagent désormais, et c’est ce qui rend vraies en même temps plusieurs affirmations de cette page : un seul objet `Server` sert les deux générations du protocole, `Client(server)` répartit dans le processus sans encadrement JSON-RPC, et une requête client expirée annule désormais réellement le gestionnaire côté serveur. + +**[Le Server bas niveau](advanced/low-level-server.md)** est la page de référence ; le **[Guide de migration](migration.md#lowlevel-server-decorator-based-handlers-replaced-with-constructor-on_-params)** passe en revue chaque hook supprimé. Si vous n’êtes jamais descendu en dessous de `MCPServer`, rien de tout cela ne vous concerne. + +### Les types de la liaison ont déménagé dans `mcp-types`, et chaque champ est en snake_case {#the-wire-types-moved-to-mcp-types-and-every-field-is-snake_case} + +Les types du protocole vivent désormais dans leur propre distribution, `mcp-types`. Elle ne dépend de rien d’autre que de pydantic et typing-extensions, si bien qu’une passerelle, un proxy ou un générateur de code peut consommer les formes que MCP échange sur la liaison sans installer de pile HTTP : un tel projet installe `mcp-types` et importe `mcp_types`. `mcp` lui-même dépend de ce paquet dans une version exacte et le réexpose, donc le code qui dépend du SDK continue d’écrire `import mcp.types as types` et `from mcp.types import Tool` (un alias permanent, chaque nom désignant le même objet) et ne déclare que sa seule vraie dépendance, `mcp`. La règle empirique : importez via le paquet dont vous dépendez réellement. + +Sur ces types, chaque attribut Python est désormais en snake_case : `result.is_error`, `tool.input_schema`, `listing.next_cursor`. Le JSON qui circule sur la liaison est en camelCase, exactement comme avant ; seule l’orthographe des attributs a changé. Deux valeurs par défaut plus strictes l’accompagnent : les champs inconnus sont ignorés au lieu d’être conservés à l’aller-retour (mettez les extras dans `_meta`), et les deux côtés valident le trafic par rapport à la version du protocole qu’ils ont négociée. Consultez le **[Guide de migration](migration.md#field-names-changed-from-camelcase-to-snake_case)** pour le tableau des renommages. + +### La configuration du transport a déménagé dans `run()` {#transport-configuration-moved-to-run} + +`MCPServer(...)` décrit ce que votre serveur *est* : son nom, ses instructions, son cycle de vie (lifespan), son authentification. La façon dont il est *servi* relève désormais de `run()` et des constructeurs d’application, et c’est là que sont passés `host`, `port`, `stateless_http`, `json_response`, les chemins des points de terminaison et `transport_security` (`MCPServer("x", port=9000)` est une `TypeError`). Les surcharges sont typées par transport, si bien que votre éditeur vous indique quelles options accepte `stdio` et lesquelles accepte `streamable-http`. Une suppression à connaître : `mount_path` a disparu ; monter l’application ASGI est la façon prise en charge de servir sous un préfixe. + +**[Exécuter votre serveur](run/index.md)** couvre les options ; **[Ajouter à une application existante](run/asgi.md)** couvre le montage. + +### Les comportements qui changent sans erreur d’import {#behavior-that-changes-without-an-import-error} + +Les renommages s’annoncent d’eux-mêmes. Ceux-ci, non : + +* **Les fonctions synchrones s’exécutent sur un thread de travail.** Un outil `def` (ou une ressource, un prompt ou un résolveur) ne bloque plus la boucle d’événements ; la contrepartie est que son corps ne s’exécute plus *sur* le thread de la boucle d’événements, ce qui compte pour le code lié à un thread particulier. Les gestionnaires `async def` ne sont pas touchés. **[Guide de migration](migration.md#sync-handler-functions-now-run-on-a-worker-thread)**. +* **Une `MCPError` (la `McpError` de la v1) levée dans un outil est désormais une erreur de protocole.** Le modèle ne la voit jamais. Toute autre exception devient toujours un résultat `is_error=True` que le modèle peut lire et auquel il peut réagir. **[Gérer les erreurs](servers/handling-errors.md)** détaille la distinction. +* **Les résultats sont validés avant de partir.** Un `Tool` construit à la main dont le `input_schema` vaut `{}` fait désormais échouer `tools/list` (la spécification exige `"type": "object"`). Les serveurs construits avec `@mcp.tool()` ne voient jamais cela ; le SDK écrit leurs schémas. +* **Votre client valide ce qu’il reçoit.** `list_tools()` et `call_tool()` vérifient la réponse du serveur par rapport à la version du protocole négociée, si bien qu’un serveur pas tout à fait valide que l’analyse indulgente de la v1 tolérait lève désormais `pydantic.ValidationError`. Si vous vous connectez à des serveurs que vous ne contrôlez pas, attendez-vous à être celui qui les découvre ; le **[Guide de migration](migration.md#client-validates-inbound-traffic-against-the-protocol-schema)** a les détails. +* **Les modèles d’URI suivent désormais vraiment la RFC 6570.** `{+path}`, `{?query}` et leurs semblables fonctionnent, la correspondance est exacte au lieu d’être approximative façon regex, et la traversée de répertoires dans les valeurs extraites est rejetée par défaut. Les modèles plus stricts échouent au moment de la décoration, pas à la première requête. **[Modèles d’URI](servers/uri-templates.md)**. +* **Le cycle de vie Streamable HTTP s’exécute une seule fois**, au démarrage, et son état est partagé par toutes les sessions et requêtes. En v1 il s’exécutait une fois par session, et une fois par requête avec `stateless_http=True`. Les pools et caches construits dans un cycle de vie deviennent nettement moins coûteux ; tout ce qui y acquérait une ressource par connexion a désormais sa place dans le corps du gestionnaire. **[Cycle de vie](handlers/lifespan.md)**. +* **`mcp dev` et `mcp install` épinglent l’environnement qu’ils lancent** sur la version du SDK que vous avez installée. Les deux commandes exécutent votre serveur dans un environnement `uv run --with ...` tout neuf, qui résolvait auparavant `mcp` vers la dernière version stable plutôt que vers la version avec laquelle vous développez. **[Guide de migration](migration.md#mcp-dev-and-mcp-install-pin-the-spawned-environment-to-your-sdk-version)**. +* **Le client HTTP est désormais `httpx2`, et non `httpx`.** Le changement de dépendance modifie ce que votre code intercepte et transmet (`httpx2.AsyncClient`, `httpx2.ConnectError`), et il modifie la façon dont les certificats TLS sont vérifiés : `httpx2` valide via `truststore` par rapport au magasin de confiance du système d’exploitation au lieu de la liste d’autorités de certification embarquée de certifi. La plupart des environnements ne remarquent rien ; un conteneur minimal sans magasin d’AC système, ou une AC privée que seul le bundle de certifi connaissait, se met à échouer à la poignée de main TLS. Définissez `SSL_CERT_FILE`/`SSL_CERT_DIR` ou passez `verify=ssl_context` à votre client. **[Guide de migration](migration.md#httpx-and-httpx-sse-replaced-by-httpx2)**. + +### Supprimés purement et simplement {#removed-outright} + +Chacun de ces points fait l’objet d’une section du **[Guide de migration](migration.md)** : + +* Le **transport WebSocket**, des deux côtés, et l’extra `mcp[ws]`. Il n’a jamais fait partie de la spécification MCP. +* L’API **expérimentale Tasks** (`mcp.*.experimental`). La version 2026-07-28 sort les tâches du cœur du protocole pour en faire une extension officielle ([SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663)), que ce SDK n’implémente pas encore. +* `mcp.shared.version`, `mcp.shared.progress` et `mcp.shared.session` (avec le stub `RequestResponder` qu’importaient les annotations de `message_handler` en v1) en tant que chemins d’import. (`mcp.types` n’est *pas* supprimé : il reste un alias permanent du paquet autonome `mcp_types`.) +* L’orthographe obsolète `streamablehttp_client`, et la fonction de rappel `get_session_id` de `streamable_http_client` (qui produit désormais exactement deux flux). +* `McpError`, renommée **`MCPError`** avec un constructeur direct `(code, message, data)`. +* `MCPServer.get_context()`, `mount_path=`, ainsi que les méthodes décorateur, la ContextVar et les dictionnaires de gestionnaires du `Server` bas niveau. + +## Le protocole : de 2025-11-25 à 2026-07-28 {#the-protocol-2025-11-25-to-2026-07-28} + +La v2 implémente la révision 2026-07-28, et elle sert **les deux** révisions à la fois : la même `streamable_http_app()` (et le même serveur stdio) répond au `initialize` d’un client de génération 2025 et aux requêtes d’un client de génération 2026 sans rien à configurer, sans option à basculer et sans déploiement séparé. Servir la nouvelle révision n’abandonne pas un client resté sur l’ancienne. Ce qui suit décrit ce que la nouvelle révision change en elle-même. + +### Pas de poignée de main, pas de session {#no-handshake-no-session} + +Un client 2026-07-28 n’ouvre pas une connexion pour négocier avant de parler. Chaque requête transporte sa version de protocole, les informations du client et les capacités du client dans `_meta`, et l’unique appel de découverte, `server/discover`, est une requête ordinaire comme les autres. `Client` fait ce qu’il faut par défaut : il sonde `server/discover` une fois et se rabat sur la poignée de main `initialize` si le serveur est plus ancien. + +En Streamable HTTP, il n’y a pas de `Mcp-Session-Id` sur le chemin 2026, ce qui est le point majeur côté exploitation : **rien ne lie une requête moderne à un worker**, si bien que n’importe quelle réplique derrière un simple répartiteur de charge en round-robin peut y répondre. Deux réserves honnêtes. Vos clients de génération 2025 (aujourd’hui, c’est-à-dire la plupart des clients) ouvrent toujours des sessions et ont toujours besoin de l’affinité dont ils avaient besoin en v1 ; rien ne change pour eux. Et la seule chose qu’une nouvelle tentative *à plusieurs allers-retours* doit transporter d’un worker à l’autre est son `request_state` scellé, dont la clé par défaut est générée par processus, si bien qu’un déploiement à plusieurs instances passe `RequestStateSecurity(keys=[...])`. (`stateless_http=True` n’a rien à voir : il n’affecte que la façon dont les clients de génération 2025 sont servis, et le trafic 2026 ne le lit jamais ; si vous l’aviez déjà défini en v1, rien ne change.) + +**[Versions du protocole](protocol-versions.md)** présente le côté client, **[Déployer et passer à l’échelle](run/deploy.md)** est la liste de contrôle de l’opérateur (la liste d’autorisation Host, la clé `request_state`, les notifications entre répliques), et **[Prendre en charge les clients historiques](run/legacy-clients.md)** explique comment servir les deux générations à la fois. + +### Le serveur ne peut pas appeler le client : requêtes à plusieurs allers-retours {#the-server-cannot-call-the-client-multi-round-trip-requests} + +Toutes les requêtes initiées par le serveur disparaissent en version 2026-07-28 : élicitation poussée, échantillonnage, `roots/list`. Sur une connexion 2026 il n’existe aucun canal de retour (back-channel) pour elles, si bien que `ctx.elicit()` et `ctx.session.create_message()` y échouent avec `NoBackChannelError` (elles fonctionnent toujours pour les clients historiques). + +Le remplacement inverse l’appel. Un outil qui a besoin de quelque chose de la part de l’utilisateur *renvoie* la question (`InputRequiredResult`), le client y répond avec les mêmes fonctions de rappel qu’il a toujours eues, et l’appel est relancé avec les réponses jointes. `Client` pilote cette boucle pour vous. Côté serveur, vous construisez rarement le résultat vous-même, car une **[dépendance](handlers/dependencies.md)** le fait : annotez un paramètre avec `Resolve(ask_quantity)`, où `ask_quantity` est une fonction ordinaire que vous écrivez, et le SDK pose la question par le mécanisme que la connexion prend en charge, une requête d’élicitation en direct sur une session historique ou une requête à plusieurs allers-retours en 2026. Un seul corps d’outil, les deux générations : + +```python title="dual_era.py" hl_lines="24 37-38" +--8<-- "docs_src/legacy_clients/tutorial001.py" +``` + +Ce fichier résume tout l’argument en un seul endroit : un serveur, un outil adossé à `Resolve`, et un client historique plus un client moderne qui obtiennent tous deux leur réponse, en mémoire. **[Requêtes à plusieurs allers-retours](handlers/multi-round-trip.md)** explique le mécanisme (y compris `request_state`, que le SDK scelle et vérifie pour vous) ; **[Élicitation](handlers/elicitation.md)** couvre la façon de poser la question. + +!!! warning "C’est le seul endroit où un serveur v1 porté change de comportement" + Vos propres tests y butent en premier : `Client(mcp)` négocie par défaut 2026-07-28 avec votre + serveur v2, si bien qu’un outil qui appelle `ctx.elicit()` échoue dans un test qui passait en v1. + Déplacez la question dans un paramètre `Resolve(...)` (portable entre générations), ou épinglez le + client de test sur `mode="legacy"` si vous voulez vraiment le comportement poussé. + +### Racines, échantillonnage et journalisation protocolaire sont obsolètes ; `ping` est supprimé {#roots-sampling-and-protocol-logging-are-deprecated-ping-is-removed} + +La [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) rend obsolètes trois *capacités* entières, sur toutes les versions du protocole : les racines, l’échantillonnage et la journalisation au niveau MCP (`ctx.info()` et consorts). C’est un axe distinct de l’absence de canal de retour ci-dessus ; obsolète est un simple avis, tout continue de fonctionner avec les sessions de génération 2025, et rien ne change sur la liaison. Ce que vous remarquez, c’est `MCPDeprecationWarning`, qui est un `UserWarning` et s’affiche donc par défaut ; attendez-vous à ce que votre premier `ctx.info(...)` après la mise à niveau vous le dise. + +`ping` est traité plus sévèrement : supprimé du protocole, pas obsolète. Deux des méthodes autonomes des fonctionnalités obsolètes sont supprimées en version 2026-07-28 de la même façon, `logging/setLevel` et le `notifications/roots/list_changed` du client, et les notifications de progression vont désormais uniquement du serveur vers le client. + +**[Fonctionnalités obsolètes](deprecated.md)** donne le tableau complet, le remplacement de chacune et le filtre d’une ligne si vous avez besoin d’un journal silencieux pendant que vous servez des clients historiques. + +### Les notifications de changement deviennent un seul flux {#change-notifications-become-one-stream} + +En version 2026-07-28, le flux HTTP GET autonome et `resources/subscribe` sont remplacés par `subscriptions/listen` : le client ouvre un seul flux de longue durée et nomme les types de notifications qu’il souhaite. `MCPServer` le sert par défaut ; vous publiez avec `await ctx.notify_resource_updated(uri)` (et `notify_tools_changed()`, etc.), un middleware peut refuser une requête d’écoute par appelant, et les déploiements à plusieurs répliques branchent un `SubscriptionBus` partagé. Côté client, `async with client.listen(...)` ouvre le flux : le filtre passe en arguments nommés, des événements de changement typés reviennent, et `sub.honored` est le sous-ensemble que le serveur a accepté de livrer. + +**[Abonnements](handlers/subscriptions.md)** couvre la publication et le service, **[sa page jumelle côté client](client/subscriptions.md)** le côté observation, et **[Déployer et passer à l’échelle](run/deploy.md)** le bus. + +### Le reste, rapidement {#the-rest-quickly} + +* **L’identité est une métadonnée optionnelle, par message.** La clé `_meta` `clientInfo` côté requête est optionnelle (la paire obligatoire est `protocolVersion` + `clientCapabilities`), et `serverInfo` a quitté le corps du résultat de `server/discover` : les serveurs l’inscrivent à la place dans le `_meta` de chaque résultat de génération 2026 ([spec #3002](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3002)). Le SDK l’inscrit toujours ; `client.server_info` vaut `None` lorsqu’un serveur ne s’identifie pas (par exemple, un middleware a retiré la clé). **[Le Server bas niveau](advanced/low-level-server.md)** montre cette inscription sur la liaison. +* **Les requêtes sont routables sans analyser les corps.** Les requêtes HTTP modernes portent `Mcp-Method` (et, pour les trois appels de type outil, `Mcp-Name`) ; une propriété de schéma d’entrée d’outil annotée avec `x-mcp-header` est recopiée dans un en-tête `Mcp-Param-*` et recoupée par le serveur ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243)). Les passerelles et limiteurs de débit peuvent router sur les seuls en-têtes ; le **[Guide de migration](migration.md#servers-validate-mcp-param-headers-against-the-request-body-sep-2243)** a les règles. +* **Les résultats portent des indications de cache.** Les résultats de liste et de lecture déclarent `ttlMs` et `cacheScope` ([SEP-2549](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549)) ; vous les définissez par méthode avec `cache_hints=`, et `Client` les honore avec un cache de réponses intégré. Un serveur qui n’envoie aucune indication (tout serveur antérieur à 2026) voit un trafic identique, non mis en cache. **[Indications de mise en cache](client/caching.md)**. +* **Les extensions sont de premier plan.** Serveurs et clients déclarent des lots de capacités optionnels sous des identifiants en DNS inversé ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)) ; l’extension intégrée `Apps` (MCP Apps) sert de référence. **[Extensions](advanced/extensions.md)** et **[MCP Apps](advanced/apps.md)**. +* **Les codes d’erreur ont été normalisés.** Une ressource manquante est un `-32602` avec l’URI dans `error.data`, et les nouveaux codes réservés par la spécification apparaissent comme `-32020` (incohérence d’en-tête), `-32021` (capacité obligatoire manquante) et `-32022` (version de protocole non prise en charge). **[Dépannage](troubleshooting.md)** est indexé par les messages exacts. +* **L’autorisation est devenue plus difficile à mal utiliser.** Le client valide le `iss` renvoyé avec le code d’autorisation ([RFC 9207](https://datatracker.ietf.org/doc/html/rfc9207) ; votre `callback_handler` renvoie désormais un `AuthorizationCodeResult`), envoie `application_type` lorsqu’il s’enregistre, et ne rejoue jamais d’identifiants auprès d’un serveur d’autorisation différent. Nouveauté côté entreprise : le flux d’assertion d’identité de la [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990). Le **[Guide de migration](migration.md)** répertorie chaque changement OAuth ; **[OAuth pour les clients](client/oauth-clients.md)** et **[Assertion d’identité](client/identity-assertion.md)** sont les pages à lire. +* **Chaque serveur est traçable.** OpenTelemetry est activé par défaut sous forme de middleware : chaque requête obtient un span serveur, sans coût tant que le processus ne configure pas d’exporteur. Lorsque les deux extrémités exécutent le SDK, le client propage aussi le contexte de trace W3C dans `_meta`, si bien que les traces se rejoignent. **[OpenTelemetry](run/opentelemetry.md)**. + +## Vous migrez depuis la v1 ? {#upgrading-from-v1} + +* Le **[Guide de migration](migration.md)** est la liste complète et exacte de ce qu’il faut changer ; cette page expliquait le pourquoi. +* **La v1.x ne va nulle part.** Elle passe en maintenance, continue de recevoir les correctifs critiques et les correctifs de sécurité, et rien dans la publication de la spécification 2026-07-28 ne la casse ; sa documentation se trouve sur [/v1/](https://py.sdk.modelcontextprotocol.io/v1/). Si vous publiez une bibliothèque qui dépend de `mcp` et que vous n’êtes pas prêt à migrer, gardez une borne supérieure (par exemple `mcp>=1.28,<2`) pour qu’une résolution non épinglée reste sur la 1.x. +* Quelque chose d’approximatif, de déroutant ou de cassé ? **[Envoyez votre retour sur la v2](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml)** ; tout est lu. diff --git a/i18n/general-prompt.md b/i18n/general-prompt.md new file mode 100644 index 0000000000..b585cf0acf --- /dev/null +++ b/i18n/general-prompt.md @@ -0,0 +1,47 @@ +# Translation rules + +You are translating a page of the MCP Python SDK documentation from English into the target language named in the language instructions that follow. The readers are software developers using the SDK. + +## Your role + +- Write natural, native-quality prose in the target language. The page should read as if a developer who is a native speaker wrote it, not as a translation. +- Keep the meaning exact. Do not add claims, drop caveats, reorder steps, or change the strength of a requirement (must / should / may). +- Follow the language instructions and the glossary strictly. Where the two disagree, the glossary wins. +- Translate the whole page. Never summarise, abridge, or leave a placeholder such as "translation continues below". + +## Never translate + +Leave these exactly as they are in the English source: + +- Code: every fenced code block from its opening fence to its closing fence — info string, contents, comments, `--8<--` include lines and `# (1)!` markers included — and every inline code span between backticks. +- Link and image targets: URLs, relative paths, `#fragment` anchors, and the placeholder targets `ENGLISH_PAGE` and `TRANSLATIONS_PAGE`. Translate only the link text and the image alt text. +- Heading anchor attributes such as `{#some-id}` where a heading carries one. +- The markers of admonitions, collapsible blocks and content tabs (`!!!`, `???`, `???+`, `===`) and the type keyword after them (`note`, `tip`, `warning`, …). The quoted title that follows is prose: translate it. +- Terms the glossary says to keep, and the names of classes, functions, parameters, modules, packages, commands, environment variables, HTTP headers and protocol methods wherever they appear. + +The English pages have no front matter; do not add any. + +Do translate everything else a reader reads: prose, headings, list items, table cells, link text, image alt text, admonition titles and bodies, and content-tab labels. + +## Keep the structure + +The translation has the same shape as the English page, block for block: + +- the same headings at the same levels in the same order, so the page keeps the same sections; +- the same code blocks, links and images — never add, drop or merge one; code blocks and images stay where they are, and a link keeps its target and stays in its sentence (its place within the sentence may follow the target language's word order); +- the same lists with the same nesting and number of items, the same tables with the same rows and columns, and the same admonitions, collapsible blocks and tab groups in the same order; +- the same blank lines between blocks. + +Do not add translator's notes, explanations or examples the English does not have. + +## Updating an existing translation + +When the request includes a previous translation of the page and lists sections to revise (a section is the text before the first `##` heading, or one `##` heading with everything under it up to the next): + +- Outside the listed sections, reproduce the previous translation verbatim, line by line. Do not rephrase, re-punctuate or reflow text there, however much you would like to improve it. +- Inside the listed sections, make the translation say exactly what the current English says: where the English changed, translate it afresh instead of reusing the stale wording; where the previous wording breaks the current instructions or glossary, fix it; leave every other line as it was. Keep terminology and tone consistent with the surrounding sections. +- Change only what has to change. The result is reviewed as a diff against the previous translation, so the smaller and cleaner the diff, the better. + +## Output + +Return only the translated Markdown page, from its first line to its last. No preamble, summary or commentary, and no code fence wrapped around the page. diff --git a/i18n/hi/glossary.json b/i18n/hi/glossary.json new file mode 100644 index 0000000000..075450f755 --- /dev/null +++ b/i18n/hi/glossary.json @@ -0,0 +1,292 @@ +{ + "keep": [ + "MCP", + "Model Context Protocol", + "MCPServer", + "FastMCP", + "ClientSession", + "Context", + "ctx", + "stdio", + "Streamable HTTP", + "SSE", + "JSON-RPC", + "JSON", + "OAuth", + "PKCE", + "JWT", + "CIMD", + "HTTP", + "HTTPS", + "TLS", + "CORS", + "URI", + "URL", + "ASGI", + "WebSocket", + "API", + "SDK", + "CLI", + "IDE", + "LLM", + "SEP", + "RFC", + "Python", + "TypeScript", + "Node.js", + "PyPI", + "Pydantic", + "Starlette", + "FastAPI", + "uvicorn", + "httpx", + "anyio", + "asyncio", + "trio", + "pytest", + "OpenTelemetry", + "Inspector", + "Claude", + "GitHub", + "VS Code", + "Windows", + "macOS", + "Linux", + "llms.txt", + "2026-07-28", + "2025-11-25", + "2025-06-18", + "2025-03-26" + ], + "terms": [ + { + "source": "tool", + "target": "tool", + "note": "MCP protocol noun (a server exposes tools). Latin script, lower-case, masculine (tool चलता है, tool की सूची); plural tools where Hindi needs one. Provisional pending native review: not the transliteration टूल and not उपकरण / औज़ार. `tools/call` and `@mcp.tool()` are code; the Inspector's **Tools** tab is a UI label and stays as shown." + }, + { + "source": "resource", + "target": "resource", + "note": "MCP protocol noun (data a server exposes for reading) and the general noun alike. Latin script, masculine. Provisional pending native review: not रिसोर्स, and not संसाधन, which reads as natural or system resources. `resources/read` and `@mcp.resource()` are code." + }, + { + "source": "prompt", + "target": "prompt", + "note": "The MCP feature (a reusable prompt template a server exposes) and the everyday AI sense. Latin script, masculine. Provisional pending native review: not प्रॉम्प्ट, never संकेत (a hint or signal, the wrong sense). `prompts/get` and `@mcp.prompt()` are code." + }, + { + "source": "sampling", + "target": "sampling", + "note": "The (deprecated) client feature that lets a server borrow the client's model for a completion. Latin script, feminine like other -ing loans (sampling होती है). Provisional pending native review: not सैंपलिंग and not the statistical coinages नमूनाकरण / प्रतिचयन. The `sampling` capability key and `sampling/createMessage` are code." + }, + { + "source": "roots", + "target": "roots", + "note": "The (deprecated) client feature listing the workspace directories a client exposes; the reader meets it as `roots/list`. Latin script, masculine plural (client अपने roots बताता है). Provisional pending native review: never जड़ें / मूल, which are tree roots and origins; \"root directory\" is root directory. A `Root` object in code font stays as code." + }, + { + "source": "elicitation", + "target": "elicitation", + "note": "OPEN QUESTION for native review: the server asking the user a question mid-request through the client. There is no Hindi term; keep the English word in Latin script, masculine like other -tion loans (elicitation शुरू होता है), and let the surrounding sentence explain it as the English does. Not एलिसिटेशन, and no coinage such as प्राप्ति or पृच्छा. `elicitation/create` and `ctx.elicit()` are code." + }, + { + "source": "capability", + "target": "capability", + "note": "What client and server declare during initialization (\"capability negotiation\" → capability negotiation; \"the client's capabilities\" → client की capabilities). Latin script, feminine (capability होती है). Provisional pending native review: क्षमता stays available for the ordinary sense \"ability\" but is not the protocol noun. The `capabilities` field and keys such as `sampling.tools` are code." + }, + { + "source": "transport", + "target": "transport", + "note": "The connection mechanism (\"every standard transport\" → हर standard transport). Latin script, masculine. Never परिवहन, which is road and freight transport; not ट्रांसपोर्ट. The transport names stdio, Streamable HTTP and SSE are on the keep list.", + "avoid": ["परिवहन"] + }, + { + "source": "session", + "target": "session", + "note": "An MCP session (the negotiated connection state; \"session ID\" → session ID). Latin script, masculine. Provisional pending native review: not सत्र (a parliamentary or academic session) and not सेशन. `session` objects, `ClientSession` and `ServerSession` are code." + }, + { + "source": "handler", + "target": "handler", + "note": "The tool, resource or prompt function you register (nav section \"Inside your handler\" → आपके handler के अंदर). Latin script, masculine. Provisional pending native review: not हैंडलर, never संचालक." + }, + { + "source": "dependency", + "target": "dependency", + "note": "The SDK's parameter-injection feature (the \"Dependencies\" page → Dependencies; \"dependency injection\" stays dependency injection) and package dependencies alike. Latin script, feminine (dependency जुड़ती है). Provisional pending native review: निर्भरता remains the ordinary word for \"dependence on something\" but is not the feature name. The `Resolve` marker class is code." + }, + { + "source": "resolver", + "target": "resolver", + "note": "The plain function attached with `Resolve(...)` that computes or asks for a parameter's value before the tool runs. Latin script, masculine. Provisional pending native review; no coinage such as समाधानकर्ता. The `Resolve` class is code." + }, + { + "source": "client", + "target": "client", + "note": "An MCP client and the client side of a connection. Latin script, masculine (client जुड़ता है). Provisional pending native review: not क्लाइंट; and never ग्राहक, which is a customer — ग्राहक is right only where the English itself talks about the example bookshop's customers. The `Client` class and the `mcp.client` module are code." + }, + { + "source": "server", + "target": "server", + "note": "An MCP server (the program you build). Latin script, masculine (server चलता है, MCP server). Provisional pending native review: सर्वर is common in general Hindi, but this corpus keeps one script for all technical nouns, so server. The `MCPServer`, `Server` and `ServerSession` classes are code." + }, + { + "source": "host", + "target": "host", + "note": "The MCP host — the application the user talks to (Claude Desktop, an IDE, an agent runtime) — and a network host alike. Latin script, masculine. Never मेज़बान / मेजबान (the host of a party or event); not होस्ट.", + "avoid": ["मेज़बान", "मेजबान"] + }, + { + "source": "context", + "target": "context", + "note": "The generic lower-case word (\"provide context to LLMs\" → LLM को context देना). Latin script, masculine. Provisional pending native review: संदर्भ stays available only in the ordinary phrase इस संदर्भ में (\"in this regard\"). The capitalised `Context` is the SDK object injected as `ctx`; it is on the keep list (\"The Context\" → Context)." + }, + { + "source": "request", + "target": "request", + "note": "A JSON-RPC or HTTP request (\"the initialize request\" → initialize request; \"send a request\" → request भेजें). Latin script, feminine as spoken usage has it (request आती है, भेजी गई request); plural requests. Provisional pending native review, gender included: not रिक्वेस्ट, and not अनुरोध, which is a polite personal request. `Request` types in code font are code." + }, + { + "source": "response", + "target": "response", + "note": "A JSON-RPC or HTTP response (response आता है, server का response). Latin script, masculine. Provisional pending native review: not रिस्पॉन्स; not प्रतिक्रिया (a reaction) or उत्तर for the protocol noun — जवाब is fine for an ordinary \"answer\". `Response` types in code font are code." + }, + { + "source": "callback", + "target": "callback", + "note": "Client callbacks and OAuth redirect callbacks alike. Latin script, masculine. Provisional pending native review: not कॉलबैक. Parameter names such as `sampling_callback` are code." + }, + { + "source": "decorator", + "target": "decorator", + "note": "The Python decorators the SDK is built on (\"put the decorator on a function\" → function पर decorator लगाएँ). Latin script, masculine. Not डेकोरेटर, never सज्जाकार. `@mcp.tool()` and its siblings are code." + }, + { + "source": "type hint", + "target": "type hint", + "note": "Python type hints (\"from your type hints\" → आपके type hints से). Latin script, masculine; plural type hints. Provisional pending native review: not टाइप हिंट and no coinage such as प्रकार संकेत." + }, + { + "source": "notification", + "target": "notification", + "note": "A JSON-RPC notification (a message with no response) and the change notifications a server publishes. Latin script, masculine (notification आता है); plural notifications. Provisional pending native review: not नोटिफ़िकेशन; and not सूचना / अधिसूचना for the protocol noun — सूचना stays the ordinary word for \"information\". `notifications/...` method strings are code." + }, + { + "source": "round trip", + "target": "round trip", + "note": "One request-and-response exchange (\"zero negotiation round trips\" → negotiation का एक भी round trip नहीं). Latin script, masculine. Provisional pending native review; no coinage such as आना-जाना or परिक्रमा." + }, + { + "source": "multi-round-trip", + "target": "multi-round-trip", + "note": "The 2026-07-28 request pattern, used as an English modifier (\"Multi-round-trip requests\" → Multi-round-trip requests as a heading, multi-round-trip request in prose). Latin script. Provisional pending native review. The abbreviation MRTR stays as written." + }, + { + "source": "lifespan", + "target": "lifespan", + "note": "The server's startup/shutdown scope and its `lifespan=` parameter (the \"Lifespan\" page → Lifespan). Latin script, masculine. Provisional pending native review: not लाइफ़स्पैन, and not जीवनकाल for the feature — जीवनकाल may still render the neighbouring word \"lifetime\" (\"for the lifetime of the app\" → app के पूरे जीवनकाल में)." + }, + { + "source": "back-channel", + "target": "back-channel", + "note": "This documentation's term for the server calling back into the client during a request, which exists only on legacy connections; it maps to `NoBackChannelError`, which is code. Latin script with the hyphen, masculine. Provisional pending native review; no coinage such as वापसी मार्ग." + }, + { + "source": "deprecated", + "target": "deprecated", + "note": "Advisory status: still works, scheduled for removal later (\"sampling is deprecated\" → sampling deprecated है; \"Deprecated features\" → Deprecated features; \"deprecation warning\" → deprecation warning). Latin script, invariable adjective. Provisional pending native review: never the coinage पदावनत; not अप्रचलित (obsolete, out of use) or बहिष्कृत; \"removed\" is हटा दिया गया. `MCPDeprecationWarning` is code.", + "avoid": ["पदावनत"] + }, + { + "source": "legacy", + "target": "legacy", + "note": "\"A legacy connection / client\" = one negotiated at spec version 2025-11-25 or earlier → legacy connection, legacy client (\"Serving legacy clients\" → legacy clients को serve करना). Latin script, invariable adjective. Provisional pending native review: पुराना alone loses the technical sense; विरासती is not used." + }, + { + "source": "era", + "target": "पीढ़ी", + "note": "\"Protocol era\" (\"a 2025-era client\", \"whatever era the client speaks\") → protocol की पीढ़ी, 2025 पीढ़ी का client. Feminine. Provisional pending native review; not the literal युग or काल." + }, + { + "source": "handshake", + "target": "handshake", + "note": "The initialization handshake (\"the classic handshake\" → पुराना classic handshake). Latin script, masculine. Not हैंडशेक, never the literal हाथ मिलाना." + }, + { + "source": "wire", + "target": "wire", + "note": "The corpus's light metaphor for the byte stream between client and server; developer Hindi keeps the English (\"stdout is the wire\" → stdout ही wire है; \"invisible on the wire\" → wire पर नहीं दिखता; \"the JSON on the wire\" → wire पर जाने वाला असली JSON). Latin script, masculine. Provisional pending native review; never the literal तार." + }, + { + "source": "token", + "target": "token", + "note": "OAuth tokens (access token, refresh token) and LLM tokens alike. Latin script, masculine; plural tokens. Standard developer usage; not टोकन." + }, + { + "source": "schema", + "target": "schema", + "note": "A JSON schema describing tool input or output (\"the input schema\" → input schema; \"`a: int, b: int` is the schema\" → `a: int, b: int` ही schema है). Latin script, masculine. Not स्कीमा; the proper name JSON Schema stays as written; `inputSchema` / `outputSchema` are code." + }, + { + "source": "default", + "target": "default", + "note": "\"by default\" → default रूप से; \"the default value\" → default value; \"defaults to X\" → default X है. Latin script, invariable. Provisional pending native review: not डिफ़ॉल्ट, never पूर्वनिर्धारित / व्यतिक्रम. `default=` in code font is code." + }, + { + "source": "file", + "target": "file", + "note": "A source or config file (\"Create a file `server.py`\" → `server.py` नाम की file बनाएँ). Latin script, feminine (file बनती है, इस file में). Provisional pending native review: फ़ाइल is everyday Hindi and a reviewer may prefer it, but this corpus keeps one script for technical nouns; never संचिका. \"folder\" / \"directory\" likewise stay folder (masculine) / directory (feminine)." + }, + { + "source": "user", + "target": "user", + "note": "The human at the host application (\"ask the user\" → user से पूछें). Latin script, masculine generic (user तय करता है). Provisional pending native review: उपयोगकर्ता is the formal UI word and यूज़र the transliteration; developer prose says user. Never ग्राहक." + }, + { + "source": "library", + "target": "library", + "note": "A code library (\"the standard library\" → standard library). Latin script, feminine (library देती है). Never पुस्तकालय, which is a building with books; not लाइब्रेरी.", + "avoid": ["पुस्तकालय"] + }, + { + "source": "exception", + "target": "exception", + "note": "A raised Python exception (\"raises `ToolError`\" → `ToolError` raise करता है; \"an exception is raised\" → exception raise होता है). Latin script, masculine. Not अपवाद for the Python object (अपवाद stays the ordinary word for \"an exception to a rule\"). Exception class names are code." + }, + { + "source": "return", + "target": "लौटाना", + "note": "What a function or tool gives back: \"returns `3`\" → `3` लौटाता है; \"the return value\" → return value (Latin, feminine: return value मिलती है) or लौटाई गई value. Provisional pending native review: return करता है is equally natural developer Hindi; pin लौटाता है for the verb so pages do not alternate. The `return` keyword is code." + }, + { + "source": "async", + "target": "async", + "note": "The prose adjective (\"the async runtime\" → async runtime; \"an asynchronous callback\" → async callback). Latin script. Not असिंक्रोनस, never the coinage अतुल्यकालिक. The `async` and `await` keywords in code font are code." + }, + { + "source": "error", + "target": "error", + "note": "An error the code reports (\"the error message\" → error message; \"an error occurred\" → error आया). Latin script, masculine. Provisional pending native review, gender included: त्रुटि and गड़बड़ी are the UI-Hindi words and गलती is fine for a human mistake, but the technical noun is error." + }, + { + "source": "Get started", + "target": "शुरू करें", + "note": "The nav section that opens the guide, and the title of its index page. \"First steps\" is a separate page inside that section (पहले कदम), so the two need distinct renderings or the sidebar shows the same title twice. Provisional pending native review; शुरुआत is the alternative for the section." + }, + { + "source": "First steps", + "target": "पहले कदम", + "note": "The tutorial page inside the \"Get started\" section; never reuse this rendering for the section itself (see that entry). Provisional pending native review." + }, + { + "source": "Recap", + "target": "सारांश", + "note": "Recurring section heading that closes most pages; one rendering everywhere, not सारांश on some pages and संक्षेप में or Recap on others. Provisional pending native review; never पुनरावलोकन." + }, + { + "source": "Try it", + "target": "इसे आज़माएँ", + "note": "Recurring section heading above a runnable example; one rendering everywhere (with the nuqta and chandrabindu: आज़माएँ), not आज़माएं on some pages and इसे try करें on others. Provisional pending native review." + } + ] +} diff --git a/i18n/hi/instructions.md b/i18n/hi/instructions.md new file mode 100644 index 0000000000..033b93f9af --- /dev/null +++ b/i18n/hi/instructions.md @@ -0,0 +1,170 @@ +# Hindi (hi) — translation instructions + +Target language: Hindi (हिन्दी) in Devanagari script, directory and URL code +`hi`, page language tag `hi`. This file is sent verbatim with every +translation request for this language, on top of the shared rules in +`../general-prompt.md`. The termbase in `glossary.json` is sent alongside it +and wins any terminology conflict with this file. + +## 1. Register + +Write modern technical Hindi the way Indian developers write it for each +other: polite-neutral, plain, at ease with English words in Hindi sentences. + +- Address the reader as **आप**, always, with the matching plural-honorific + agreement: आप देख सकते हैं, आप चाहें तो, आपका server. Never तुम or तू forms + (बनाओ, चलाओ, तुम्हारा), never the colloquial आप + -ो (आप देखो), never a + mix. Agreement with आप is the generic masculine plural (कर सकते हैं), with + no सकते/सकती doublets. +- Steps and instructions are the polite -ें / -एँ imperative: बनाएँ, चलाएँ, + जोड़ें, खोलें, install करें — करें / दें / लें, not कीजिए / दीजिए / लीजिए, so a + page has one imperative shape; prohibitions are न + the same form (stdout + पर कुछ न लिखें). No कृपया before every step, and no bare -ना infinitive as + a command in body text (folder बनाना ✗ as a step). +- Headings, tab labels and table headers are noun phrases or -ना verbal + nouns: "Handling errors" → errors संभालना, "Running your server" → अपना + server चलाना. A short imperative heading may stay one ("Run it" → इसे + चलाएँ), and a question may stay a question (यह कहाँ जाए?). +- Hindi does not need a pronoun in every clause. Translate "you" / "your" + with आप / आपका only where the sentence needs a subject or the ownership + matters; "your server" is usually just server, and three or four आप in one + paragraph is a signal to restructure. Name the role (server, client, user) + rather than leaning on यह / वह / इसे chains. The authorial "we" is हम. +- One page, one register: a page that drifts from आप to तुम, or from करें to + कीजिए to करो, is wrong even when each sentence is acceptable on its own. + +## 2. Voice + +The English is warm, direct and confident: short sentences, second person, +the occasional one-line payoff ("That's the whole API."). Educated everyday +Hindi carries that tone naturally; keep the payoff lines short — पूरा API बस +इतना ही है। Guide rather than lecture: split long English sentences and follow +Hindi word order (verb last) rather than the English clause chain, but +never merge, drop or reorder the technical claims themselves. + +- Technical actions use the natural light-verb pattern — install करें, + import करें, call करें, register करें, deploy करें, parse करता है — and + everyday actions use plain Hindi verbs: चलाएँ (run), भेजें, लिखें, पढ़ें, + खोलें, जोड़ें, हटाएँ, बदलें, चुनें, बनाएँ, पूछें, लौटाता है (returns), मिलता है. +- Avoid शुद्ध-हिन्दी officialese, the default failure of formal Hindi + translation: no उपर्युक्त / निम्नलिखित (→ ऊपर बताया गया / नीचे दिया गया), no + प्रदान करना where देना is meant, no करने में सक्षम हैं for "can" (→ कर + सकते हैं), no के द्वारा passives where an active sentence is natural ("The + tool is called by the model" → model tool को call करता है), and none of the + coinages the glossary rules out (संचिका, प्रलेखन, कार्यान्वयन, पदावनत). +- Avoid English-shaped Hindi too — एक as an article in every noun phrase + ("That's a complete MCP server" → यह पूरा MCP server है, not यह एक पूर्ण + MCP server है), जो कि chains, word-for-word idioms — and the opposite + over-correction: no street Hinglish (यार, मस्त, झट से), no तुम. + +Example — English: "You don't construct it and you don't configure it. You +ask for it." + +- Not this (officialese, pronoun in every clause): आप इसका निर्माण नहीं करते + हैं और आप इसे कॉन्फ़िगर नहीं करते हैं। आप इसके लिए अनुरोध करते हैं। +- Not this either (तुम register, slang): इसे बनाना-वनाना नहीं है, configure + भी नहीं। बस माँग लो यार। +- This: न आपको इसे बनाना है, न configure करना है। बस माँगना है। + +## 3. Humour and idioms + +- Translate the intent of a joke, aside or idiom, never its words. Recast it + as a friendly plain sentence carrying the same information; if a light + phrase carries no information, keep the sentence brief rather than + inventing a Hindi joke or reaching for a मुहावरा. Never drop the technical + content around it; culture-bound references take the plain meaning. +- Recurring English tags get fixed renderings: "**[X](…)** has the whole + story" / "The whole story is in **[X](…)**" → पूरी जानकारी **[X](…)** में + है।; "That's the whole API." / "That is the whole API." → पूरा API बस इतना + ही है।; "That's the whole protocol." → पूरा protocol बस इतना ही है।; + "That's it. It's just Python." → बस इतना ही। यह सिर्फ़ Python है।; "You get + `3` back. ✨" → आपको `3` वापस मिलता है। ✨ +- Idioms take the plain meaning, not the picture: "Out of the box the app + answers **only** requests addressed to localhost." → बिना कुछ configure किए + app **सिर्फ़** उन्हीं requests का जवाब देता है जो localhost को भेजी गई हों। — + not डिब्बे से निकालते ही. "Under the hood" → अंदर ही अंदर / असल में. +- Exclamation marks: one only where the English is genuinely emphatic; never + doubled, never in a heading, never after a warning. Emoji: only where the + English page has one, in the same place (✨ closes two payoff lines); never + add one, never in a heading. + +## 4. Typography + +- The sentence terminator is the danda । (U+0964): every declarative and + imperative sentence of Hindi prose ends in ।, no space before it, one + space after. Never a Latin full stop after Devanagari text, never the pipe + character | in place of the danda, never the double danda ॥. Question and + exclamation marks, commas, colons and parentheses are ASCII, used as in + the source; a fragment in a list or table takes no terminator. +- Digits are Latin (0–9) everywhere — counts, versions, ports, status codes + — never Devanagari numerals (०–९). Identifiers are copied byte for byte + (`2026-07-28`, RFC and SEP numbers, error codes); a calendar date written + out in prose, if any, becomes 28 जुलाई 2026. A space between a number and + a Latin unit (100 MB, 30 s); % attaches (100%); number words stay words. +- Straight double quotes "…" and ASCII apostrophes as in the source; no + curly or single quotes. No italics on Devanagari (a slanted शिरोरेखा reads + as broken): where the English italicises a word that becomes Hindi, use + **bold** or nothing; keep `**bold**` where the source has it, negations + included ("does **not** raise" → raise **नहीं** करता). An English em-dash + aside is recast with commas, parentheses or a second sentence, or keeps + the source's " — "; hyphens stay in pairs (अलग-अलग), never on postpositions. +- Spelling follows current standard Hindi: chandrabindu on nasalised vowel + endings (बनाएँ, जाएँ, भाषाएँ; में, हैं, नहीं keep the bindu), nuqta where + standard Hindi has it (ज़रूरत, सिर्फ़, फ़ायदा; ड़ / ढ़ always), गई / गए / नई / + लिए rather than गयी / गये / नयी / लिये, and one spelling per word per page + (हिन्दी or हिंदी, not both). +- Spacing around Latin script and code: a postposition or particle after an + English word, an acronym or a code span is a separate word with one + ordinary space before it — Python में, MCP का, `ctx` को, `add` से, server + पर, tools की सूची — never glued (Pythonमें ✗), never hyphenated (Python-में + ✗), and never a Devanagari ending grafted onto a Latin word (serverों ✗). + Compound labels keep their space too: MCP server, tool call. Line breaks + inside a paragraph are harmless in Hindi; keep the source's block + structure and indentation exactly. + +## 5. Terminology pointer + +The termbase `glossary.json` is injected separately and overrides anything +written here. This section fixes the conventions its renderings assume: + +- Script rule. English technical and computing terms stay in Latin script, + lower-case as in running English, and are not transliterated: server, + client, host, tool, resource, prompt, request, response, file, code, app, + schema, token — not सर्वर, क्लाइंट, टूल, रिक्वेस्ट, फ़ाइल. Hindi words are for + everything that is ordinary language (उदाहरण, तरीका, सवाल, जवाब, सूची, चरण, + बदलाव, ज़रूरत, सुरक्षा, अनुमति), and Sanskritised coinages for technical + concepts (संचिका for file, सत्र for session, अनुरोध for the protocol + request) are not used. The only Devanagari loanwords are the few that are + everyday Hindi beyond computing (कंप्यूटर, इंटरनेट, ईमेल); when unsure, Latin. +- Latin-script terms stay lower-case even first in a Hindi sentence or + heading (server चलाएँ); a heading made only of English words takes English + sentence case (Structured output). They may take the English plural -s + where Hindi grammar calls for a plural and no Hindi word carries the + number (सभी tools, इन clients को), never a possessive 's (→ server का); + `keep`-list terms are copied exactly as listed, no s added (दोनों SDK). +- Grammatical gender of Latin-script nouns, for verb and का / की / के + agreement, is masculine by default (server, tool, token, response, error, + code, app, object, message, schema …) and feminine for file, directory, + library, repository, registry, key, query, entry, property, body, + capability, dependency, request, list, line, class, API, ID, image and + the -ing nouns (sampling, logging, caching). One gender per term per page. +- Identifiers — class, function, parameter, module and package names, + protocol method strings (`tools/call`), header names, environment + variables, anything in code font — are copied byte for byte and take + postpositions like any Latin word (`Context` को, `lifespan=` में). +- Text quoted from what the example code prints or displays — an output + line, a log message, an error string, a UI label such as the Inspector's + **Tools** and **Resources** tabs — stays exactly as the code emits it + (usually English); do not translate it or add a Hindi reading in brackets. +- Between an everyday word and its formal twin (ज़रूरी / आवश्यक, शुरू / आरंभ, + इस्तेमाल / प्रयोग) prefer the everyday one and keep to it. One rendering per + term per page: the glossary target, every time — also where a note marks + it provisional. + +## 6. Provisional note + +Every decision in this file — the आप register, the Latin-script rule for +technical terms, the genders, the fixed renderings — and every entry in +`glossary.json` is provisional pending review by native Hindi-speaking +developers. To propose a change, edit this file or `glossary.json` in a pull +request; the generated pages under `pages/` are never edited by hand. diff --git a/i18n/hi/notices.md b/i18n/hi/notices.md new file mode 100644 index 0000000000..464ddbfca7 --- /dev/null +++ b/i18n/hi/notices.md @@ -0,0 +1,20 @@ +--- +translation: + sections: [aff1b3e872b7876a, 4d80558ad052d586, 0bb81f1e62062d26, d5c35dcec50156bc] + tool: 1 +--- +# अनुवाद संबंधी सूचनाएँ {#translation-notices} + +अनुवादित documentation site के हर page के ऊपर इनमें से एक सूचना दिखती है। + +## मशीनी अनुवाद {#translated} + +यह page अंग्रेज़ी documentation से अपने-आप अनुवादित किया गया है, और [अंग्रेज़ी page](ENGLISH_PAGE) ही प्रामाणिक version है। अगर कुछ गलत लगे, तो [अनुवाद](TRANSLATIONS_PAGE) page बताता है कि इसकी सूचना कैसे दें। + +## अंग्रेज़ी page से पीछे रह गया अनुवाद {#outdated} + +यह अनुवाद बनने के बाद अंग्रेज़ी page बदल गया है, इसलिए इसके कुछ हिस्से पुराने हो सकते हैं। शक हो तो [अंग्रेज़ी page](ENGLISH_PAGE) पढ़ें; [अनुवाद](TRANSLATIONS_PAGE) page बताता है कि अनुवादित documentation कैसे काम करती है। + +## अंग्रेज़ी में दिखाया गया {#english} + +इस page का कोई मौजूदा अनुवाद नहीं है, इसलिए आप इसे अंग्रेज़ी में पढ़ रहे हैं। [अनुवाद](TRANSLATIONS_PAGE) page बताता है कि अनुवादित documentation कैसे काम करती है। diff --git a/i18n/hi/pages/advanced/apps.md b/i18n/hi/pages/advanced/apps.md new file mode 100644 index 0000000000..13361d90a5 --- /dev/null +++ b/i18n/hi/pages/advanced/apps.md @@ -0,0 +1,158 @@ +--- +translation: + sections: [0355618e5f4d5fe4, 1821eaf50f2d0b64, 82e0b28ebd3abf5a, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487] + tool: 1 +--- +# MCP Apps {#mcp-apps} + +**MCP App** ऐसा tool है जिसका एक चेहरा है: अपने data के साथ-साथ tool एक HTML document की ओर इशारा करता है, जिसे host interactive surface के रूप में render करता है। + +दो हिस्से, हमेशा दो हिस्से: + +1. **एक tool**, जो काम करता है और data लौटाता है, किसी भी दूसरे tool की तरह। +2. **एक `ui://` resource**, जिसमें वह HTML है जो host उसके लिए दिखाता है। + +tool में resource का `_meta.ui.resourceUri` reference होता है। host उसे `resources/read` से fetch करता है, **sandboxed iframe** में render करता है, और tool का result `postMessage` के ज़रिए उस iframe में भेजता है। आपका server कभी कोई `ui/*` message न भेजता है, न पाता है: वह traffic host और iframe के बीच का है। आप एक tool और एक HTML document serve करते हैं; दिखाने का सारा काम host करता है। + +SDK इसे built-in `Apps` extension (`io.modelcontextprotocol/ui`) के रूप में देता है। अगर [Extensions](extensions.md) आपके लिए नए हैं, तो पहले उस page पर एक नज़र डाल लें। एक मिनट लगेगा, फिर वापस आएँ। + +## चेहरे वाली घड़ी {#a-clock-with-a-face} + +```python title="server.py" hl_lines="19 22 30 32" +--8<-- "docs_src/apps/tutorial001.py" +``` + +चार कदम: + +* `Apps()`: एक instance में आपके UI-bound tools और उनके resources रहते हैं। +* `@apps.tool(resource_uri="ui://clock/app.html")`: एक साधारण tool, साथ में + `_meta.ui.resourceUri` की मुहर। जो कुछ `@mcp.tool()` लेता है (name, title, + description, ...) वह सब यहाँ भी चलता है। +* `apps.add_html_resource("ui://clock/app.html", CLOCK_HTML)`: उससे मेल खाता + resource, जो `text/html;profile=mcp-app` के रूप में serve होता है। ठीक यही MIME type + host को बताता है "यह app है, इसे render करें"। +* `MCPServer("clock", extensions=[apps])`: opt in करें। server अब + `capabilities.extensions` के तहत `io.modelcontextprotocol/ui` advertise करता है। + +HTML खुद host के `postMessage` को सुनता है और result दिखाता है। असली +apps के लिए अपने HTML के अंदर official [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps) +browser SDK इस्तेमाल करें। यह आपको raw message events की जगह `ontoolresult`, `callServerTool`, +`getHostContext` और `onhostcontextchanged` देता है। + +## Graceful degradation {#graceful-degradation} + +हर client apps render नहीं करता। इसका आपके लिए क्या मतलब है, spec साफ़-साफ़ कहता है: + +> UI उपलब्ध होने पर भी tools का एक सार्थक `content` array लौटाना **अनिवार्य (MUST)** है। + +model `content` पढ़ता है; iframe इंसानों के लिए है। UI-capable host भी text result +model को देता है, और text-only client को **सिर्फ़** वही मिलता है। इसलिए मानक +pattern है: एक tool, दो जवाब। `get_time` को फिर से देखें: + +```python title="server.py" hl_lines="23-27" +--8<-- "docs_src/apps/tutorial001.py" +``` + +`client_supports_apps(ctx)` तभी `True` होता है जब client ने +`io.modelcontextprotocol/ui` extension declare किया हो **और** अपनी `mimeTypes` +settings में `text/html;profile=mcp-app` सूचीबद्ध किया हो। यह field ज़रूरी है, +इसलिए जो client इसे छोड़ देता है वह गिना नहीं जाता। इसी file में `main()` ठीक यही +declare करता है: negotiation का client वाला आधा हिस्सा, और rich जवाब वापस आता है। + +!!! warning + कभी भी `"[Rendered UI]"` जैसा placeholder अकेले content के रूप में न लौटाएँ। + अगर fallback text बेकार है, तो tool हर text-only client के लिए और खुद model + के लिए बेकार है। वह वाक्य लिखें। + +## iframe को lock करना {#locking-the-iframe-down} + +सुरक्षा metadata resource वाले हिस्से पर रहता है: iframe क्या load कर सकता है, उसे +कौन-सी browser permissions चाहिए, वह किस तरह frame होना चाहेगा: + +```python title="server.py" hl_lines="9 19-22" +--8<-- "docs_src/apps/tutorial002.py" +``` + +`csp` और `permissions` **host से की गई requests** हैं, server का व्यवहार नहीं। host +इन्हीं से iframe की Content-Security-Policy और Permissions-Policy बनाता है, और +मना भी कर सकता है। अनुमति मिल ही गई, यह मानने के बजाय अपने JS में feature-detect करें। + +`ResourceCsp`, एक-एक field करके (Python नाम, wire key, host उसके साथ क्या करता है): + +| Python | Wire (`_meta.ui.csp`) | क्या नियंत्रित करता है | +|---|---|---| +| `connect_domains` | `connectDomains` | `connect-src`: `fetch`/XHR कहाँ जा सकते हैं | +| `resource_domains` | `resourceDomains` | `img-src`, `style-src`, ...: static assets | +| `frame_domains` | `frameDomains` | `frame-src`: nested iframes | +| `base_uri_domains` | `baseUriDomains` | `base-uri`: `` किस ओर इशारा कर सकता है | + +`ResourcePermissions`: हर field iframe के लिए एक browser permission माँगता है। + +| Python | Wire (`_meta.ui.permissions`) | +|---|---| +| `camera` | `camera` | +| `microphone` | `microphone` | +| `geolocation` | `geolocation` | +| `clipboard_write` | `clipboardWrite` | + +!!! note + CSP और permissions **resource** पर रहते हैं, tool पर कभी नहीं। spec के tool + metadata में इनके लिए कोई जगह नहीं है, और hosts वहाँ इन्हें अनदेखा करते हैं। SDK इस + गलती को लिखना ही नामुमकिन बना देता है: `@apps.tool()` में `csp` parameter है ही नहीं। + +### Visibility {#visibility} + +tool पर `visibility=["app"]` कहता है "यह iframe के लिए है, model के लिए नहीं": + +* `"model"`: model इसे call कर सकता है। +* `"app"`: iframe इसे call कर सकता है (`callServerTool` के ज़रिए)। +* छोड़ दिया जाए: दोनों, जो default है। + +Filtering **host का** काम है। आपका server app-only tools को `tools/list` में किसी भी +दूसरे tool की तरह सूचीबद्ध करता है; host उन्हें model से छिपाता है। server-side filter न करें। + +## वे नियम जो SDK लागू करता है {#the-rules-the-sdk-enforces} + +ये सब startup पर ही fail होते हैं, production में नहीं: + +* जो `resource_uri` या resource URI `ui://...` नहीं है, वह decoration/registration + के समय `ValueError` है। +* ऐसे URI से बँधा tool जिसका **कोई मेल खाता registered resource नहीं** है, तब `ValueError` + है जब `MCPServer(extensions=[apps])` extension को consume करता है। ऐसा tool जो HTML + advertise करे पर `resources/read` पर 404 दे, misconfiguration है, इसलिए server + construct होने से मना कर देता है। +* `@apps.tool()` पर `meta={"ui": ...}` `ValueError` है। `_meta["ui"]` decorator का + है; अपनी बात `resource_uri=` और `visibility=` से कहें। बाकी `meta=` keys + साथ में आराम से merge हो जाती हैं। + +आज न TypeScript ext-apps SDK इनमें से कुछ पकड़ता है, न FastMCP; हम चाहेंगे कि +आपको यह किसी host से पहले पता चल जाए। + +## Inline HTML से आगे {#beyond-inline-html} + +`add_html_resource` आम मामले को संभालता है: HTML की एक string। बाकी किसी भी चीज़ के लिए, +disk पर रखा HTML हो या generate किया गया content, resource खुद बनाएँ और सौंप दें: + +```python title="server.py" hl_lines="12 18" +--8<-- "docs_src/apps/tutorial003.py" +``` + +जब resource कोई MIME type साफ़ तौर पर set नहीं करता, तो `add_resource` +`text/html;profile=mcp-app` MIME type भर देता है, और साफ़ तौर पर दिए गए बेमेल type को +reject कर देता है: किसी और MIME type वाला `ui://` resource ऐसा resource है जिसे कोई host render नहीं करेगा। + +!!! tip + क्या आप ऐसे pre-GA host के लिए बना रहे हैं जो अब भी deprecated flat + `_meta["ui/resourceUri"]` key पढ़ता है? इसे खुद merge करें: + `@apps.tool(resource_uri="ui://x", meta={"ui/resourceUri": "ui://x"})`। + nested `ui` object ही spec वाला आकार है; flat key हटने वाली है। + +## इसे चलता देखें {#see-it-run} + +`examples/stories/` में `apps` story यही page एक चलाने लायक जोड़ी के रूप में है: UI-bound +clock tool वाला एक server, और एक client जो Apps negotiate करता है, tool का +`_meta.ui.resourceUri` पढ़ता है, HTML fetch करता है और tool को call करता है। + +```bash +uv run python -m stories.apps.client +``` diff --git a/i18n/hi/pages/advanced/extensions.md b/i18n/hi/pages/advanced/extensions.md new file mode 100644 index 0000000000..3617649c98 --- /dev/null +++ b/i18n/hi/pages/advanced/extensions.md @@ -0,0 +1,172 @@ +--- +translation: + sections: [05891e7cc1938a13, b3c01a6af28c51ee, 7ffc91f5e38bdfe0, 717d3f235a8333a7, f471a13b2fe5d737, ed6af2df4b656dff] + tool: 1 +--- +# Extensions {#extensions} + +**extension** एक identifier के पीछे रखा गया MCP behaviour का opt-in bundle है। + +server पर यह tools, resources और नए request methods जोड़ सकता है, और `tools/call` को wrap कर सकता है। client पर यह `tools/call` के अतिरिक्त result shapes claim कर सकता है और vendor notifications observe कर सकता है। हर पक्ष अपने-अपने `capabilities.extensions` के तहत advertise करता है, और जिसने इसे नहीं माँगा उसके लिए कुछ नहीं बदलता। यही contract है ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)), और इसका एक सुनहरा नियम है: **extensions default रूप से बंद रहते हैं**। + +## extension इस्तेमाल करना {#using-an-extension} + +construction के समय instances पास करें: + +```python title="server.py" +--8<-- "docs_src/extensions/tutorial001.py" +``` + +हो गया। अब server `capabilities.extensions` के तहत `io.modelcontextprotocol/ui` advertise करता है और extension जो कुछ जोड़ता है वह सब serve करता है। + +`Apps` built-in reference extension है, और इसका अपना अलग page है: **[MCP Apps](apps.md)**। + +!!! note + extensions construction के समय ही तय हो जाते हैं। बाद में call करने के लिए कोई `add_extension` नहीं है: जब clients server से जुड़े हों, तब उसका capability map बदलना नहीं चाहिए। + +capability map `server/discover` के साथ जाता है, जो **2026-07-28** का रास्ता है। legacy `initialize` handshake में इसे रखने की कोई जगह नहीं है, इसलिए legacy client को extension दिखता ही नहीं। इसे ध्यान में रखकर design करें: extension server को **बढ़ाता** है, server को इस्तेमाल करने का यही एकमात्र तरीका नहीं होना चाहिए। + +## अपना extension लिखना {#writing-your-own} + +`Extension` को subclass करें और सिर्फ़ वही override करें जिसकी ज़रूरत हो। हर method का default है। + +### Identifier {#the-identifier} + +```python +--8<-- "docs_src/extensions/tutorial002.py" +``` + +identifier एक `vendor-prefix/name` string है जो spec की `_meta` key grammar का पालन करती है: dot से अलग किए गए labels (हर label अक्षर से शुरू होता है, अक्षर या अंक पर खत्म होता है), फिर एक slash, फिर name। यह **class define होते ही** validate होता है, इसलिए typo पकड़ने के लिए server के boot होने का इंतज़ार नहीं करना पड़ता: + +```text +TypeError: Stamps.identifier must be a `vendor-prefix/name` string +(reverse-DNS prefix required), got 'stamps' +``` + +prefix के रूप में ऐसा domain इस्तेमाल करें जो आपके नियंत्रण में हो। `io.modelcontextprotocol/*` उन extensions के लिए है जिन्हें खुद MCP project specify करता है। + +### tools जोड़ना {#contributing-tools} + +सबसे छोटा काम का extension एक tool और एक settings map है: + +```python title="server.py" hl_lines="17 19-20 22-23 26" +--8<-- "docs_src/extensions/tutorial003.py" +``` + +* `tools()` `ToolBinding`s लौटाता है। server हर एक को ठीक वैसे ही register करता है जैसे आपने खुद `mcp.add_tool(...)` call किया हो: वही schema generation, वही `Context` injection, सब कुछ वही। +* `settings()` वह value है जो `capabilities.extensions["com.example/stamps"]` पर advertise होती है। बिना settings के extension advertise करने के लिए `{}` (default) लौटाएँ। +* extension को server कभी नहीं मिलता। यह अपने योगदान data के रूप में declare करता है; `MCPServer` उन्हें consume करता है। mutate करने के लिए कोई `self.server` नहीं है। + +और `main()` इसका सबूत है, सीधे `mcp` से जुड़ा एक in-memory client: + +```python title="server.py" hl_lines="29-34" +--8<-- "docs_src/extensions/tutorial003.py" +``` + +### अपने methods serve करना {#serving-your-own-methods} + +extension **नए request methods** register कर सकता है: उसके अपने verbs, जो spec के verbs के साथ-साथ serve होते हैं: + +```python title="server.py" hl_lines="16-22 31 40-48" +--8<-- "docs_src/extensions/tutorial004.py" +``` + +* `SearchParams` `RequestParams` को subclass करता है, इसलिए 2026 का `_meta` envelope एक समान तरीके से parse होता है और आपके handler को validated params मिलते हैं, कच्चा dict कभी नहीं। जो client के नियंत्रण में है उसकी सीमा बाँधें: `Field(ge=1, le=100)` किसी बेतुके `limit` को तभी reject कर देता है, इससे पहले कि आपका code उसके लिए कुछ allocate करे। +* `require_client_extension(ctx, EXTENSION_ID)` ही gate है: जिस client ने extension declare नहीं किया उसे `-32021` (missing required client capability) error मिलता है, साथ में वह machine-readable `requiredCapabilities` payload जो spec माँगता है। +* `protocol_versions=frozenset({"2026-07-28"})` method को एक wire version पर pin कर देता है। किसी भी दूसरे version पर client को `METHOD_NOT_FOUND` मिलता है, ठीक वैसे जैसे method वहाँ मौजूद ही न हो। उस client के लिए, वह है भी नहीं। + +methods **सख़्ती से additive** हैं। SDK इसे construction के समय लागू करता है, runtime पर नहीं: + +* spec में define किए गए method (`tools/list`, `completion/complete`, ...) के लिए `MethodBinding` बनाते ही `ValueError` raise होता है। core verbs server के हैं। +* एक ही method को bind करने वाले दो extensions हों, तो दूसरा register होते ही raise होता है। plugins एक-दूसरे को last-write-wins से ही खराब करते हैं; हम ऐसा नहीं करते। +* खाली `protocol_versions` set भी raise करता है: जो method कभी serve ही नहीं हो सकता वह bug है, configuration नहीं। + +### Client side {#the-client-side} + +उसी file का `main()` ही client की पूरी कहानी है, उसके दोनों हिस्से: + +```python title="server.py" hl_lines="54-58" +--8<-- "docs_src/extensions/tutorial004.py" +``` + +* `Client(..., extensions=[advertise(EXTENSION_ID)])` extension declare करता है। ये declarations `ClientCapabilities.extensions` बन जाती हैं: 2026-07-28 connection पर यह map हर request के `_meta` envelope में जाता है, इसलिए server इसे **हर** request पर देखता है; legacy connection पर यह `initialize` handshake के साथ जाता है। server code को फ़र्क नहीं पड़ता कि कौन सा: `require_client_extension(ctx, ...)` और `ctx.session.check_client_capability(...)` दोनों रास्तों पर सही स्रोत पढ़ते हैं। +* vendor methods एक परत नीचे `client.session.send_request(...)` पर उतरते हैं; `Client` सिर्फ़ spec verbs के लिए first-class methods जोड़ता है। `send_request` कोई भी `Request` subclass स्वीकार करता है, इसलिए vendor request जैसी है वैसी ही चली जाती है। + +### `tools/call` को intercept करना {#intercepting-toolscall} + +यह इकलौता interceptive hook है। tool call को observe, short-circuit या veto करने के लिए `intercept_tool_call` override करें: + +```python title="server.py" hl_lines="17-24" +--8<-- "docs_src/extensions/tutorial005.py" +``` + +* `params` validated `CallToolRequestParams` है: आपको `params.name` और `params.arguments` बिना raw JSON छुए मिलते हैं। यही तय करता है कि कौन सा tool call चलेगा: `call_next` से rewritten context पास करने से वह बदलता है जो handler `ctx` पर देखता है, tool invocation नहीं। wire-level request rewriting [Middleware](middleware.md) का काम है। +* `call_next(ctx)` chain का बाकी हिस्सा चलाता है और handler का result लौटाता है। इसे बिना बदले लौटाएँ (observe), कुछ और लौटाएँ (replace), या `MCPError` raise करें (refuse)। आप जो भी लौटाते हैं वह किसी भी handler result की तरह serialize होता है, 2026 पीढ़ी के `serverInfo` identity stamp समेत, इसलिए short-circuit करने वाला interceptor कभी anonymous या off-schema response नहीं बनाता। +* कई extensions होने पर interceptors registration के क्रम में nest होते हैं: `extensions=[...]` में पहला extension सबसे बाहर होता है। +* default implementation pass-through है, और जिस server के extensions इस hook को कभी override नहीं करते, उसका bare `tools/call` handler अनछुआ रहता है। जो आप इस्तेमाल नहीं करते उसकी कीमत नहीं चुकाते। + +hook `tools/call` को wrap करता है, और कुछ नहीं। हर message से जुड़ी बातों के लिए [Middleware](middleware.md) इस्तेमाल करें। वह इसी के लिए है। + +## client extension इस्तेमाल करना {#using-a-client-extension} + +**client extension** वही contract है, इस्तेमाल करने वाले पक्ष से: एक identifier के पीछे client-side behaviour का bundle। instances को `Client(extensions=[...])` में पास करें और tools सामान्य तरीके से call करें: + +```python title="client.py" hl_lines="66-68" +--8<-- "docs_src/extensions/tutorial006.py" +``` + +`call_tool("buy", ...)` हर दूसरे call की तरह सादा `CallToolResult` लौटाता है। extension ने जो बदला: server अब `buy` का जवाब final result के बजाय `receipt` **result shape** से दे सकता है, और `call_tool` के लौटने से पहले `Receipts` उसे पूरा कर देता है (यहाँ follow-up call से receipt redeem करके)। call site में कुछ नहीं हिलता। + +extension हटा दें तो इनमें से कुछ भी मौजूद नहीं: server का gate उस client को मना कर देता है जिसने इसे declare नहीं किया (error -32021), और gate छोड़ने वाले server से आया claimed shape validation में fail होता है, ठीक वैसे जैसे spec अनजान `resultType` के लिए माँगता है। default रूप से बंद, wire के दोनों सिरों पर। + +**बिना** किसी client-side behaviour के identifier advertise करने के लिए (server capability पर gate लगाता है, client कुछ नहीं करता, जैसे ऊपर वाले search client में), `advertise()` इस्तेमाल करें: + +```python +from mcp.client import advertise + +client = Client(mcp, extensions=[advertise("com.example/search")]) +``` + +## client extension लिखना {#writing-a-client-extension} + +`ClientExtension` को subclass करें और सिर्फ़ वही override करें जिसकी ज़रूरत हो। योगदान के तीन प्रकार, हर एक का default: `settings()`, `claims()` और `notifications()`। + +```python title="client.py" hl_lines="17-18 43-44 46-47" +--8<-- "docs_src/extensions/tutorial006.py" +``` + +* identifier वही grammar मानता है जो server का, और class define होते ही validate होता है। +* `claims()` `ResultClaim`s लौटाता है: एक wire tag, उसे parse करने वाला model, और उसे पूरा करने वाला resolver। model के लिए `result_type: Literal["receipt"]` से tag pin करना ज़रूरी है और वह verb के core result types को subclass नहीं कर सकता; दोनों बातें claim बनते समय enforce होती हैं। `receipt_token` जैसे vendor fields wire पर जैसे हैं वैसे जाते हैं: substituted shape client तक हू-ब-हू पहुँचता है। +* resolver को parsed model और एक `ClaimContext` मिलता है; `ctx.session` वही public handle है जो `client.session`, इसलिए follow-ups साधारण session calls हैं। यह verb का सामान्य `CallToolResult` लौटाता है। +* `settings()` वह value है जो `ClientCapabilities.extensions[identifier]` पर advertise होती है, और `Client` बनते समय एक बार पढ़ी जाती है। + +`notifications()` observe करने के लिए vendor server notifications declare करता है: + +```python +def notifications(self) -> Sequence[NotificationBinding[Any]]: + return [NotificationBinding(method="notifications/receipts", params_type=ReceiptEvent, handler=self.on_receipt)] +``` + +handler को validated params एक-एक करके, dispatch के क्रम में मिलते हैं। यह observe करता है; veto या reply नहीं कर सकता। + +दो शांत नियम। claims सिर्फ़ 2026-07-28 connections पर सक्रिय रहते हैं, और capability advertisement उन्हीं के पीछे चलता है: legacy connection पर claims गायब हो जाते हैं और identifier भी उनके साथ advertisement से हट जाता है, इसलिए client कभी ऐसा extension advertise नहीं करता जिसके shapes वह खुद reject कर देता। और जब claimed shape resolver के बजाय आपको खुद चाहिए, तो `client.session.call_tool(..., allow_claimed=True)` call करें; उस flag के बिना, session-tier caller तक पहुँचने वाला claimed shape `UnexpectedClaimedResult` raise करता है। + +### Extension verbs {#extension-verbs} + +extension के अपने request methods को client-side registration की ज़रूरत नहीं। vendor request type `mcp.types.Request` को subclass करता है और `client.session.send_request` से जाता है, जैसा [अपने methods serve करना](#serving-your-own-methods) में है। एक बात और: जब किसी params key का `Mcp-Name` header में जाना ज़रूरी हो (tasks जैसे extension specs अपने verbs के लिए यह माँगते हैं), तो request type `name_param` declare करता है: + +```python title="client.py" hl_lines="22-25 46-47" +--8<-- "docs_src/extensions/tutorial007.py" +``` + +session हर send path पर `params["jobId"]` को `Mcp-Name` में mirror करता है, और value न होने पर ज़रूरी header चुपचाप छोड़ने के बजाय साफ़ तौर पर fail होता है। + +## extension क्या नहीं कर सकता {#what-an-extension-cannot-do} + +योगदान की surface जानबूझकर **बंद** रखी गई है। server पर: settings, tools, resources, methods, एक `tools/call` interceptor। client पर: settings, result claims, notification bindings। extension ये नहीं कर सकता: + +* **host के अंदर पहुँचना।** यह data declare करता है; इसके पास server या client का कोई reference नहीं होता। +* **core behaviour बदलना।** spec methods और core result tags construction के समय reject हो जाते हैं (`initialize` को runner ने पूरी तरह reserve कर रखा है); core vocabulary से ढकी notification binding इसके बजाय warning के साथ चुप हो जाती है। +* **देर से register करना।** `MCPServer(...)` या `Client(...)` के लौटने के बाद extension set जैसा है वैसा ही रहता है। + +अगर आप इन दीवारों से लड़ रहे हैं, तो आप extension नहीं लिख रहे। आप fork लिख रहे हैं। ये दीवारें ही feature हैं: `extensions=[Apps(), Stamps()]` पढ़ने वाला user **सब कुछ** जानता है जिसे ये दोनों छू सकते थे। diff --git a/i18n/hi/pages/advanced/index.md b/i18n/hi/pages/advanced/index.md new file mode 100644 index 0000000000..2261d45087 --- /dev/null +++ b/i18n/hi/pages/advanced/index.md @@ -0,0 +1,34 @@ +--- +translation: + sections: [ca6988b7503cd2d3] + tool: 1 +--- +# Advanced {#advanced} + +एक साधारण server या client को जो कुछ चाहिए, उस सबकी विषय के हिसाब से जगह ऊपर के sections में है। +यह section उन रास्तों के लिए है जिनकी ज़रूरत तब पड़ती है जब `MCPServer` की convenience +layer आड़े आने लगे: + +* **[Low-level Server](low-level-server.md)**: वह class जिस पर `MCPServer` बना है। + हाथ से लिखे schemas, `on_*` handlers, आपके लिए कुछ भी check नहीं होता, और आपके अपने custom JSON-RPC + methods। +* **[Pagination](pagination.md)** और **[Middleware](middleware.md)**: दो चीज़ें जो आप + **सिर्फ़** low-level `Server` पर ही कर सकते हैं। +* **[Extensions](extensions.md)** और **[MCP Apps](apps.md)**: protocol की + extension surface। extension packages को server में जोड़ें, या अपना खुद का लिखें। + +कुछ चीज़ें जिन्हें आप शायद यहाँ ढूँढें, असल में वहीं रखी गई हैं जहाँ उनका इस्तेमाल +होता है: + +* **Authorization**, **[अपना server चलाना](../run/index.md)** के अंतर्गत है, क्योंकि server + को वहीं सुरक्षित किया जाता है जहाँ उसे deploy किया जाता है। +* **OAuth**, **identity assertion**, **एक से ज़्यादा servers** से जुड़ना, और + response **cache**, ये सब **[Clients](../client/index.md)** के अंतर्गत हैं। +* **Multi-round-trip requests** और **Subscriptions**, + **[आपके handler के अंदर](../handlers/index.md)** के अंतर्गत हैं, क्योंकि दोनों ही ऐसे काम हैं जो + handler **करता** है। +* **URI templates**, **[Servers](../servers/index.md)** के अंतर्गत है, Resources के बगल में। +* **[Protocol versions](../protocol-versions.md)** और + **[Deprecated features](../deprecated.md)**, दोनों का अपना-अपना top-level page है। + +अगर आपको पक्का नहीं पता कि इस section की ज़रूरत है या नहीं, तो नहीं है। diff --git a/i18n/hi/pages/advanced/low-level-server.md b/i18n/hi/pages/advanced/low-level-server.md new file mode 100644 index 0000000000..8f9d894e7d --- /dev/null +++ b/i18n/hi/pages/advanced/low-level-server.md @@ -0,0 +1,207 @@ +--- +translation: + sections: [2c79b6338e09b7ac, 7edc43b3fae11314, 1086e77ce561cd7f, a3f71823df5efc31, 9fc7109f72201cae, 7bf25983df655b66, 6330e1f4c6029683, 2f1749c8c133fa1c, b3530fcf4d11fd56, ebc33704fbd74262, cd0e9c933350390e] + tool: 1 +--- +# Low-level Server {#the-low-level-server} + +`@mcp.tool()` एक layer है। इसके नीचे एक दूसरी server class है, `Server`, जो raw MCP बोलती है: आप इसे protocol objects देते हैं और यह उन्हें बिना बदले wire पर रख देती है। + +`MCPServer` इसी के ऊपर बना है। नीचे आप तब उतरते हैं जब convenience layer रास्ते में आने लगे: + +* आपको **हूबहू** कोई schema भेजना है (file से load किया हुआ, database से generate किया हुआ), न कि Python signature से निकाला गया। +* आपको result पर पूरा नियंत्रण चाहिए: `_meta`, `is_error`, `structured_content` की हर key। +* आपको ऐसा method handle करना है जिसे MCP define नहीं करता। + +बाकी सब के लिए `MCPServer` पर ही रहें। + +## वही tool, हाथ से {#the-same-tool-by-hand} + +यह वही `search_books` tool है जिसे **[Tools](../servers/tools.md)** `@mcp.tool()` की नौ lines में लिखता है, बस sugar हटाकर: + +```python title="server.py" hl_lines="22 26 32" +--8<-- "docs_src/lowlevel/tutorial001.py" +``` + +तीन चीज़ें बदलीं, और पूरा low-level API बस यही है: + +* **Handlers constructor parameters हैं।** `on_list_tools=` और `on_call_tool=` `Server(...)` में जाते हैं। यहाँ नीचे कोई decorator नहीं है, और हर handler का आकार एक ही है: `async (ctx, params) -> result`। +* **Input schema आप लिखते हैं।** `Tool.input_schema` एक सादा JSON Schema `dict` है। कोई इसे type hints से नहीं निकालता, क्योंकि निकालने के लिए type hints हैं ही नहीं। +* **Result आप बनाते हैं।** `CallToolResult(content=[TextContent(...)])`, हाथ से। न कुछ wrap होता है, न convert, न return annotation से अनुमान लगाया जाता है। + +`params` parse की हुई request है: `CallToolRequestParams` आपको `.name` और `.arguments` देता है। `ctx` एक `ServerRequestContext` है: client से वापस बात करने के लिए `ctx.session`, `ctx.lifespan_context`, `ctx.request_id`, और `ctx.meta`, यानी request का आने वाला `_meta`। + +!!! info + अगर आपने FastAPI इस्तेमाल किया है, तो यह रिश्ता आप पहले से जानते हैं। `MCPServer` decorators और type hints वाली layer है; `Server` उसके नीचे का Starlette है। ये प्रतिद्वंद्वी नहीं हैं: `MCPServer` एक `Server` बनाता है और उस पर ठीक ऐसे ही handlers register करता है। + +### इसे आज़माएँ {#try-it} + +इसके लिए कोई Inspector नहीं है: `mcp dev` और `mcp run` सिर्फ़ `MCPServer` स्वीकार करते हैं। In-memory `Client` को कोई फ़र्क नहीं पड़ता; वह low-level `Server` को ठीक वैसे ही लेता है जैसे `MCPServer` को: + +```python title="main.py" +import asyncio + +from mcp import Client + +from server import server + + +async def main() -> None: + async with Client(server) as client: + result = await client.call_tool("search_books", {"query": "dune", "limit": 5}) + print(result.content) + + +asyncio.run(main()) +``` + +```text +[TextContent(type='text', text="Found 3 books matching 'dune' (showing up to 5).", annotations=None, meta=None)] +``` + +वही text जो `@mcp.tool()` वाले version ने दिया था। दो असली अंतर: + +* `result.structured_content` `None` है। High-level server आपके लिए `-> str` को `{"result": ...}` में wrap कर देता है; यहाँ जो आपने नहीं बनाया, उसे कोई नहीं बनाता। +* `list_tools` वही schema लौटाता है जो **आपने** type किया, अक्षर-दर-अक्षर। High-level version में हर property पर `"title": "Query"` था और root पर `"title": "search_booksArguments"`: Pydantic की देन। यहाँ नीचे, अगर कुछ wire पर है, तो उसे वहाँ आपने रखा है। + +## आपके लिए कुछ जाँचा नहीं जाता {#nothing-is-checked-for-you} + +`MCPServer` गलत argument को आपका function चलने से पहले ही ठुकरा देता है, call को अपने generate किए schema से validate करके (**[Tools](../servers/tools.md)**)। + +`Server` ऐसा नहीं करता। आपका `input_schema` client को **advertise** होता है; `params.arguments` पर कभी **लागू** नहीं होता। + +!!! check + `search_books` को बिना `limit` के call करें और आपका `args["limit"]` `KeyError` raise करता है। Client को दिखता है: + + ```text + MCPError: Internal server error + ``` + + एक JSON-RPC error, code `-32603`, जान-बूझकर generic message के साथ: SDK आपका traceback किसी remote caller को leak नहीं करेगा। Model को कभी पता नहीं चलता कि उसने क्या गलत किया, इसलिए वह दोबारा कोशिश नहीं कर सकता। (Test में `raise_exceptions=True` इसके बजाय असली exception सामने लाता है; देखें **[Testing](../get-started/testing.md)**।) + +यह बात हर जगह लागू होती है। Low-level handler से raise हुआ exception **हमेशा** protocol error होता है, कभी `is_error=True` वाला tool result नहीं। अगर आप चाहते हैं कि model failure पढ़े और संभल जाए, तो `params.arguments` खुद validate करें और `CallToolResult(content=[TextContent(...)], is_error=True)` लौटाएँ। Failure के ये दो प्रकार **[Errors संभालना](../servers/handling-errors.md)** का विषय हैं। + +## दो tools, एक handler {#two-tools-one-handler} + +`on_call_tool` server के हर tool के लिए अकेला entry point है। Routing आप `params.name` पर करते हैं: + +```python title="server.py" hl_lines="38-43" +--8<-- "docs_src/lowlevel/tutorial002.py" +``` + +* `list_tools` दोनों advertise करता है। `call_tool` नाम के आधार पर dispatch करता है। +* `else` branch मायने रखती है: `Server` किसी ऐसे नाम के लिए आई `tools/call` को भी, जिसे आपने कभी list नहीं किया, सीधे आपके handler में भेज देगा। वहाँ raise करने से call ऊपर वाले `-32603` में ही बदल जाती है। + +## Structured output, हाथ से {#structured-output-by-hand} + +`Tool` पर `output_schema` declare करें और result पर `structured_content` रखें। दोनों आपके हैं: + +```python title="server.py" hl_lines="19-23 36" +--8<-- "docs_src/lowlevel/tutorial003.py" +``` + +इसे call करें और result में दोनों रूप आते हैं: + +```json +{ + "content": [{"type": "text", "text": "Found 3 books matching 'dune'."}], + "structuredContent": {"matches": 3, "query": "dune"}, + "isError": false, + "resultType": "complete", + "_meta": {"io.modelcontextprotocol/serverInfo": {"name": "Bookshop", "version": "2.0.0"}} +} +``` + +`_meta` block server की पहचान की मुहर है: SDK इसे 2026 पीढ़ी के हर result में जोड़ता है, constructor से लिए `version` के साथ (जो server कोई version set नहीं करता वह खाली string बताता है)। जिस server को अपनी पहचान नहीं बतानी है, वह middleware से यह key हटा सकता है, क्योंकि middleware जो results लौटाता है उनका मालिक वही है। + +Server इन दोनों fields की कभी तुलना नहीं करता। इस SDK का `Client` करता है: ऐसा `structured_content` लौटाएँ जो आपके declare किए `output_schema` पर खरा न उतरे, और `call_tool` एक `RuntimeError` raise करता है जो `Invalid structured content returned by tool search_books` से शुरू होता है और आगे `jsonschema` की failure उद्धृत करता है। Schema का वादा करना सस्ता है; उसे निभाना आपकी ज़िम्मेदारी है। Return types और schemas की पूरी सीढ़ी **[Structured Output](../servers/structured-output.md)** में है। + +## `_meta`: application के लिए, model के लिए नहीं {#\_meta-for-the-application-not-the-model} + +`content` जवाब का वह हिस्सा है जिसे model पढ़ता है। `structured_content` वही जवाब typed data के रूप में है। `_meta` तीसरा channel है: ऐसा data जो result के साथ **client application** के लिए चलता है, जवाब का हिस्सा बने बिना। + +इसे record IDs, trace IDs, ऐसी किसी भी चीज़ के लिए इस्तेमाल करें जिसकी ज़रूरत आपके UI को है और आपके prompt को नहीं: + +```python title="server.py" hl_lines="37" +--8<-- "docs_src/lowlevel/tutorial004.py" +``` + +* आप इसे `_meta=` के रूप में बनाते हैं, जो wire वाला नाम है। Client इसे `result.meta` के रूप में वापस पढ़ता है। +* अपनी keys को namespace दें (`bookshop/record_ids`)। `io.modelcontextprotocol/*` keys protocol के लिए reserved हैं। + +!!! warning + `_meta` आपके और client application के बीच की convention है, इसकी guarantee नहीं कि model तक क्या + पहुँचता है। क्या render करना है यह host तय करता है। Tool result के किसी भी हिस्से में कभी कोई secret न रखें। + +## Capabilities आपके handlers से तय होती हैं {#capabilities-follow-your-handlers} + +`Server` ठीक उन्हीं method families को advertise करता है जिनके लिए आपने उसे handlers दिए। ऊपर वाला `Bookshop` `on_list_tools` और `on_call_tool` pass करता है और कुछ नहीं, इसलिए इससे जुड़ने वाला client देखता है: + +```json +{"tools": {"listChanged": false}} +``` + +न `resources`, न `prompts`: उनके पीछे कुछ है ही नहीं। `on_list_prompts` pass करें और `prompts` दिखने लगता है; `on_completion` pass करें और `completions` दिखने लगता है। + +`MCPServer` हमेशा tools, resources और prompts advertise करता है, चाहे आपने कोई register किया हो या नहीं, क्योंकि उसके managers हमेशा मौजूद रहते हैं। यहाँ नीचे constructor call **ही** declaration है। + +## Lifespan generic {#the-lifespan-generic} + +`Server` उस type में generic है जो उसका lifespan yield करता है। इसे एक बार annotate करें और object जहाँ भी सामने आता है, typed होता है: + +```python title="server.py" hl_lines="24-26 44-45 50" +--8<-- "docs_src/lowlevel/tutorial005.py" +``` + +* Lifespan एक `Callable[[Server[Catalog]], AbstractAsyncContextManager[Catalog]]` है; `async` generator पर `@asynccontextmanager` आपको ठीक यही देता है। +* यह जो भी `yield` करता है वह `ctx.lifespan_context` बन जाता है, और चूँकि handlers `ServerRequestContext[Catalog]` से annotate हैं, `.search(...)` autocomplete होता है और type-check होता है। +* Server शुरू होने पर इसमें एक बार enter किया जाता है और रुकने पर एक बार exit। Startup, teardown, और इसी विचार का `MCPServer` वाला version **[Lifespan](../handlers/lifespan.md)** में हैं। + +`lifespan=` के बिना `ctx.lifespan_context` एक खाली `dict` है। + +## आपका अपना method {#a-method-of-your-own} + +Constructor उन methods को cover करता है जिन्हें MCP define करता है। बाकी सब `add_request_handler` cover करता है: + +```python title="server.py" hl_lines="35-36 39-40 43-44 48" +--8<-- "docs_src/lowlevel/tutorial006.py" +``` + +* पहला argument method string है। Notifications के लिए इसका जुड़वाँ है, `add_notification_handler`। +* `params_type` वह model है जिससे आने वाले `params` आपका handler चलने से **पहले** validate होते हैं, इसलिए custom methods को वह validation **मिलती** है जो tools को नहीं मिलती। `RequestParams` को subclass करें ताकि `_meta` field हर दूसरे method की तरह parse हो। +* Handler `BaseModel`, `dict`, या `None` लौटाता है। SDK इसे JSON-RPC result में serialise कर देता है। + +एक बात साफ़-साफ़: high-level `Client` के पास सिर्फ़ उन्हीं methods के लिए verbs हैं जिन्हें MCP define करता है, इसलिए कोई `client.reindex()` नहीं है। Vendor method ऐसे peer के लिए है जो पहले से जानता है कि यह मौजूद है: ऐसा client जो आप खुद ship करते हैं, या आपकी कोई दूसरी service जो JSON-RPC बोलती है। + +एक method जिस पर आप दावा नहीं कर सकते: + +```text +ValueError: 'initialize' is handled by the server runner and cannot be overridden; +use Server.middleware to observe or wrap initialization +``` + +Handshake runner का है। `server/discover`, `ping`, और बाकी हर built-in को आप बदल सकते हैं। + +!!! tip + उस error में जिस `Server.middleware` का ज़िक्र है, वह **हर** आने वाले message को wrap करता है, `initialize` समेत। अगर आप किसी नए method का जवाब देने के बजाय traffic देखना या फिर से लिखना चाहते हैं, तो **[Middleware](middleware.md)** से शुरू करें। + +## बाकी handlers {#the-other-handlers} + +इनमें से हर एक ऐसा विचार है जिसकी शब्दावली अब आपके पास है; हर एक का अपना page है। + +* `on_call_tool`, `on_get_prompt`, और `on_read_resource` अपने सामान्य result के बजाय `InputRequiredResult` लौटा सकते हैं, ताकि call रुक जाए और client से input माँगा जाए; देखें **[Multi-round-trip requests](../handlers/multi-round-trip.md)**। इस tier के मुताबिक, आपके लिए कुछ install नहीं होता: जहाँ `MCPServer` default रूप से `requestState` को seal करता है, वहीं यहाँ आपका set किया `request_state` ठीक वैसे ही wire पार करता है जैसा लिखा गया, जब तक आप `server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[...]), default_audience=server.name))` से opt in न करें: एक line (दोनों नाम `mcp.server.request_state` से import होते हैं) और ठीक वही sealing और verification मिलती है जो `MCPServer` करता है (**[`requestState` की सुरक्षा](../handlers/multi-round-trip.md#protecting-requeststate)**)। +* `on_list_resources`, `on_read_resource`, `on_list_prompts`, `on_get_prompt`, `on_completion` बाकी primitives के लिए वही `(ctx, params) -> result` आकार हैं। +* `on_subscriptions_listen` 2026-07-28 की `subscriptions/listen` stream serve करता है। `SubscriptionBus` के ऊपर बना `ListenHandler` pass करें और अपने बाकी handlers से bus पर events publish करें; पूरी रचना के लिए देखें **[Subscriptions](../handlers/subscriptions.md)**। +* `server.streamable_http_app()` वही Starlette app लौटाता है जो `MCPServer` का लौटाता है; इसे वैसे ही deploy करें जैसे **[अपना server चलाना](../run/index.md)** किसी भी दूसरे ASGI app को deploy करता है। यहाँ नीचे कोई `server.run(transport=...)` नहीं है: `server.run(read_stream, write_stream, server.create_initialization_options())` streams की एक जोड़ी पर एक connection चलाता है, और पूरी जानकारी बस वही एक line है। + +## सारांश {#recap} + +* Low-level `Server` अपने handlers `on_*` **constructor parameters** के रूप में लेता है; हर handler `async (ctx, params) -> result` है। +* `input_schema` dict आप लिखते हैं और `CallToolResult` आप बनाते हैं। आपके लिए न कुछ derive होता है, न wrap, न validate। +* Handler में exception `-32603` protocol error है। जिस tool error को model पढ़ सके, वह `is_error=True` वाला `CallToolResult` है जिसे **आप** लौटाते हैं। +* Result पर `_meta` client application के नाम है, model के नहीं। +* `Server[T]` उस चीज़ में generic है जो उसका lifespan yield करता है; `ctx.lifespan_context` एक typed `T` है। +* `add_request_handler(method, params_type, handler)` कोई भी method serve करता है। `initialize` reserved है। +* `Server` जो capabilities advertise करता है, वे इससे निकलती हैं कि आपने कौन से handlers register किए। + +`Client(server)` ने दोनों servers के साथ एक जैसा बर्ताव किया क्योंकि वे एक ही protocol **हैं**, और यही असली बात है। इससे नीचे की अगली layer कोई class है ही नहीं: वह **[Middleware](middleware.md)** है। diff --git a/i18n/hi/pages/advanced/middleware.md b/i18n/hi/pages/advanced/middleware.md new file mode 100644 index 0000000000..85699d6c15 --- /dev/null +++ b/i18n/hi/pages/advanced/middleware.md @@ -0,0 +1,121 @@ +--- +translation: + sections: [6048b4f308edbb8c, 068bda0f21ee9c1b, c3e565b61acd75c5, c62422b159c6ed09, 47204fab253cc45c] + tool: 1 +--- +# Middleware {#middleware} + +**middleware** एक async function है जो server को मिलने वाले हर message को wrap करता है। + +इसे आप `async (ctx, call_next)` के रूप में लिखते हैं और `server.middleware` में append करते हैं। पूरा API बस इतना ही है। + +!!! warning + middleware list source में **provisional** के रूप में चिह्नित है: इसका signature और semantics + किसी 2.x minor release में बदल सकते हैं। इसका इस्तेमाल messages को **देखने** (timing, logging, tracing) और + **अस्वीकार करने** के लिए करें; इसे वह नींव न बनाएँ जिस पर आपका server खड़ा हो। + +`MCPServer` यह list construction के समय लेता है (`MCPServer(name, middleware=[...])`) और इसे +`mcp.middleware` के रूप में उपलब्ध कराता है; low-level `Server` वही list `server.middleware` के रूप में देता है। नीचे दिया गया +उदाहरण low-level `Server` इस्तेमाल करता है; अगर `Server(name, on_call_tool=...)` आपके लिए नया है, तो पहले +**[Low-level Server](low-level-server.md)** पढ़ें। + +## Timing middleware {#a-timing-middleware} + +एक server, एक tool, एक middleware जो log करता है कि हर message में कितना समय लगा: + +```python title="server.py" hl_lines="39-45 49" +--8<-- "docs_src/middleware/tutorial001.py" +``` + +* `ctx` वही `ServerRequestContext` है जो आपके handlers को मिलता है। `ctx.method` raw + method string है; `ctx.params` raw params हैं, किसी भी validation से **पहले**। +* `call_next(ctx)` बाकी chain चलाता है: validation, handler lookup, आपका handler। + जो उसने लौटाया वही लौटा दें, तो response जस का तस रहता है। +* `try`/`finally` जानबूझकर है: जो handler raise करता है उसका समय भी मापा जाता है, क्योंकि failure + आपके middleware तक `call_next` से निकले exception के रूप में पहुँचती है। +* `server.middleware.append(...)` इसे register करता है। list outermost-first चलती है, इसलिए + `middleware[0]` वह है जो wire के सबसे नज़दीक है। + +### इसे आज़माएँ {#try-it} + +client connect करें, tools की सूची लें, एक को call करें। आपके log में **तीन** lines हैं: + +```text +server/discover took 18.3 ms +tools/list took 0.1 ms +tools/call took 0.1 ms +``` + +आपने दो calls किए और तीन lines मिलीं। पहली `server/discover` है: वह request जो +client ने connection तैयार करने के लिए भेजी, आपके कुछ माँगने से पहले। + +यही असली बात है। middleware **हर** inbound message को wrap करता है: + +* connection setup: `server/discover`, या legacy session पर `initialize` और `notifications/initialized`। +* हर request और हर notification। notification के लिए `ctx.request_id is None` होता है, + `call_next(ctx)` `None` लौटाता है, और आप जो भी लौटाएँ वह फेंक दिया जाता है। +* वह method भी जिसके लिए server के पास कोई handler नहीं है: `call_next` + `MCPError(-32601, "Method not found")` को client की ओर जाते हुए आपके middleware के **बीच से** raise करता है। + +## इसके अंदर आप क्या कर सकते हैं {#what-you-can-do-inside-one} + +इस क्रम में कि आपको कितना हिचकना चाहिए, कम से ज़्यादा की ओर: + +* **देखें (Observe)।** समय मापें, गिनें, log करें। ऊपर वाला उदाहरण। +* **अस्वीकार करें (Refuse)।** `call_next(ctx)` call करने के **बजाय** `MCPError` raise करें और उस एक message का + जवाब JSON-RPC error से दिया जाता है। connection बना रहता है; अगला message निकल जाता है। इसी तरह + server हर caller के लिए `subscriptions/listen` को gate करता है: + Subscriptions page पर **[यह तय करना कि कौन देख सकता है](../handlers/subscriptions.md#deciding-who-may-watch)** + इसे चरण दर चरण समझाता है। +* **फिर से लिखें (Rewrite)।** `ctx` dataclass है: `await call_next(dataclasses.replace(ctx, params=...))` + बाकी chain को client के भेजे params से अलग params देता है। `initialize` के साथ ऐसा कभी न करें: + client को जो result वापस मिलता है वह आपके बदले हुए params से बनता है, लेकिन + server अपनी connection state मूल wire params से commit करता है। दोनों पक्ष + handshake इस असहमति के साथ पूरा कर सकते हैं कि उन्होंने क्या negotiate किया। +* **जवाब दें (Answer)।** `call_next(ctx)` call किए बिना result लौटाएँ और वह आपके response के रूप में client को + जाता है। `call_next` आपको तैयार wire form देता है, और pipeline आप जो लौटाते हैं उसे कभी patch नहीं करता, + इसलिए पूरा envelope आपका है: 2026 पीढ़ी के connection पर इसमें + `serverInfo` का `_meta` stamp शामिल है, जिसे SDK handler results में जोड़ता है पर आपके results में नहीं। + +!!! check + `initialize` उन चीज़ों में से एक है जिन्हें middleware wrap करता है, और इसके लिए आपको मिलने वाला यह **एकमात्र** hook है। + `add_request_handler` से इसे अपने हाथ में लेने की कोशिश करें तो SDK मना कर देता है: + + ```text + ValueError: 'initialize' is handled by the server runner and cannot be overridden; + use Server.middleware to observe or wrap initialization + ``` + +!!! warning + `initialize` inline संभाला जाता है: जब तक आपकी middleware chain लौट नहीं आती, server आगे कोई inbound + message नहीं पढ़ता। इसलिए `initialize` संभालते समय server-to-client request (`ctx.session.send_request(...)`, + कोई elicitation) को await करना **connection को deadlock कर देता है**: जिस + response का आप इंतज़ार कर रहे हैं वह कभी पढ़ा ही नहीं जा सकता। fire-and-forget notifications ठीक हैं। + +## वह एक middleware जो default रूप से चालू आता है {#the-one-middleware-that-ships-on-by-default} + +SDK ठीक एक middleware साथ देता है, और वह पहले से आपके server की list में है: वह जो +हर message के लिए OpenTelemetry span emit करता है। आप इसे append नहीं करते, और ज़्यादातर समय +इसके बारे में सोचते भी नहीं। जब तक आप कोई exporter install नहीं करते यह no-op है, और इसका अपना page है: +**[OpenTelemetry](../run/opentelemetry.md)**। + +!!! info + अगर आपने ASGI middleware लिखा है, तो यह आकार आप पहले से जानते हैं। Starlette का + `(scope, receive, send)` यहाँ `(ctx, call_next)` बन गया, और यह transport के **बाद** चलता है, + raw HTTP request की जगह decoded message पर। दोनों साथ मिलकर काम करते हैं: `streamable_http_app()` पर + Starlette middleware HTTP देखता है; यह MCP देखता है। + +## सारांश {#recap} + +* middleware `async (ctx, call_next) -> result` है, जिसे `MCPServer(middleware=[...])` के रूप में पास किया जाता है (या + `mcp.middleware` में append किया जाता है), और low-level `Server` पर `server.middleware` में append किया जाता है। +* यह **हर** inbound message को wrap करता है (`server/discover`, `initialize`, requests, notifications, + अनजान methods) और outermost-first चलता है। +* `ctx.request_id is None` से आप notification और request में फ़र्क करते हैं। +* एक message को अस्वीकार करने के लिए `call_next` call करने के बजाय raise करें; connection बचा रहता है। +* SDK का अपना OpenTelemetry tracing भी एक middleware है, जो पहले से list में है। देखें + **[OpenTelemetry](../run/opentelemetry.md)**। +* पूरा surface provisional है। इससे देखें; इस पर निर्माण न करें। + +request को wrap करने वाली हर चीज़ बस इतनी ही है। **[Authorization](../run/authorization.md)** वह है जो तय करता है कि request +को चलने दिया जाए भी या नहीं। diff --git a/i18n/hi/pages/advanced/pagination.md b/i18n/hi/pages/advanced/pagination.md new file mode 100644 index 0000000000..310964061f --- /dev/null +++ b/i18n/hi/pages/advanced/pagination.md @@ -0,0 +1,85 @@ +--- +translation: + sections: [a9aba7a026c7bd85, ed32bda7ba9ae33a, 7e64cc5646abb91f, 22a0129ee78b3c63, d875373c06d8d2f9] + tool: 1 +--- +# Pagination {#pagination} + +ज़्यादातर servers को इसकी ज़रूरत कभी नहीं पड़ती। + +`MCPServer` हर `list_*` request का जवाब अपने पास मौजूद सब कुछ देकर करता है, एक ही page में, `next_cursor=None` के साथ। कुछ दर्जन tools, resources या prompts के लिए यही सही जवाब है और configure करने को कुछ नहीं है। + +Pagination उस server के लिए है जिसकी resource list असल में एक database है: हज़ारों rows जिन्हें वह एक ही response में serialize करने से मना करता है। Protocol का जवाब है **cursor**: server एक page के साथ एक opaque token लौटाता है, और client अगला page पाने के लिए वही token वापस भेजता है। + +`@mcp.resource()` में इसके लिए कोई hook नहीं है। Paging करने के लिए आप list handler ख़ुद लिखते हैं, **[low-level Server](low-level-server.md)** पर। + +## Paging करने वाला server {#a-server-that-pages} + +```python title="server.py" hl_lines="12 15-16" +--8<-- "docs_src/pagination/tutorial001.py" +``` + +* Low-level `Server` पर handlers constructor arguments होते हैं, decorators नहीं। `on_list_resources` हर `resources/list` request का जवाब देता है; जोड़ने का पूरा काम बस इतना ही है। +* हर paged handler का type `params: PaginatedRequestParams | None` होता है, और उदाहरण दोनों स्वीकार करता है। लेकिन किसी connection पर SDK आपको कभी `None` नहीं देता (बिना `params` member वाली request handler तक default values वाले model के रूप में पहुँचती है), इसलिए जो संकेत मायने रखता है वह है `params.cursor is None`: **शुरू से शुरू करें**। +* Cursor **क्या** है, यह आप तय करते हैं। यहाँ यह string के रूप में लिखा गया offset है। Timestamp, primary key, base64 blob: कुछ भी जिसे आप बाहर भेजते समय बना सकें और वापस आने पर पहचान सकें। +* `next_cursor=None` से आप कहते हैं "वह आख़िरी page था"। कोई count नहीं, कोई total नहीं, कोई `has_more` नहीं। `None` ही पूरा संकेत है। + +!!! tip + 10 का `PAGE_SIZE` उदाहरण को पढ़ने लायक बनाता है। अपना page size हर endpoint के हिसाब से चुनें: + एक-line वाले resources की list 500 का page झेल सकती है; भारी-भरकम prompt templates की list नहीं। + इसमें client की कोई राय नहीं चलती, और यह जानबूझकर ऐसा है। + +### इसे आज़माएँ {#try-it} + +`Client(server)` memory में low-level `Server` से ठीक वैसे ही जुड़ता है जैसे `MCPServer` से। + +बिना arguments के `list_resources()` call करें। आपको दस resources मिलते हैं, `book-1` से `book-10` तक, और `next_cursor` string `"10"` है। + +इसे `list_resources(cursor="10")` से वापस दें, तो पहला resource `book-11` है और नया `next_cursor` `"20"` है। + +दसवाँ page `next_cursor` को `None` पर set करके लौटता है। हो गया। + +## Client loop {#the-client-loop} + +`Client` का हर `list_*` method (`list_tools`, `list_resources`, `list_resource_templates`, `list_prompts`) `cursor=` keyword लेता है। Paged list को पूरा खींचना एक `while True` है: + +```python title="client.py" hl_lines="26-32" +--8<-- "docs_src/pagination/tutorial002.py" +``` + +* `cursor` `None` से शुरू होता है, इसलिए पहली request में कोई cursor नहीं जाता। +* `next_cursor` देखने से **पहले** extend करें: आख़िरी page में भी resources होते हैं। +* `next_cursor is None` ही बाहर निकलने का रास्ता है। बाकी कुछ भी सीधे `cursor=` में वापस जाता है, बिना छेड़े। + +इसका `main()` चलाएँ और यह `100 resources` print करता है: दस-दस के दस pages, एक ऐसे loop से जुड़े हुए जिसे कभी पता ही नहीं था कि दस pages थे। + +यह वही loop है जो **[The Client](../client/index.md)** हर `list_*` verb के लिए दिखाता है, और paging न करने वाले server पर इसकी कोई क़ीमत नहीं: पहले ही response में `next_cursor` `None` होता है और loop एक बार चलता है। + +## तीन नियम {#the-three-rules} + +**Cursors opaque होते हैं।** Client को कभी किसी cursor को parse करना, बनाना या अंदाज़ा लगाना नहीं चाहिए। Cursor का एकमात्र वैध स्रोत पिछले page का `next_cursor` है, जस का तस। + +**Page size server चुनता है।** Protocol में कोई `limit=` नहीं है। अलग page size चाहिए तो server बदलें। + +**Paging को नज़रअंदाज़ करने वाला client भी काम करता है।** वह एक बार `list_resources()` call करता है, पहले दस पाता है, और जिस `next_cursor` को उसने फेंक दिया उस पर कभी ध्यान नहीं देता। कुछ टूटता नहीं; उसे बस कम दिखता है। + +!!! check + Opaque का मतलब opaque। कोई cursor गढ़ लें (`list_resources(cursor="page-2")`) तो + protocol आपके लिए कुछ नहीं कर सकता। यह server `int("page-2")` आज़माता है, handler raise करता है, + और client के पास जो लौटता है वह है: + + ```text + MCPError(-32603, 'Internal server error', None) + ``` + + जो cursor आपको server से नहीं मिला, वह bug है, feature request नहीं। + +## सारांश {#recap} + +* `MCPServer` सब कुछ एक page में लौटाता है। Pagination opt-in है, और opt in आप low-level `Server` पर करते हैं। +* `on_list_resources` (और `on_list_tools`, `on_list_prompts`, `on_list_resource_templates`) को `PaginatedRequestParams | None` मिलता है; पहले page के लिए `params.cursor` `None` होता है। +* आप एक page और `next_cursor` लौटाते हैं: कोई भी string जिसे आप बाद में पहचान लें, या `None` जब कुछ बचा न हो। +* Client loop: `cursor=` दें, जमा करें, `next_cursor is None` होने तक दोहराएँ। +* Cursors opaque होते हैं, page size server का है, और paging न करने वाले client को भी पहला page मिलता है। + +हाथ से लिखी `Server` API का बाकी हिस्सा (`on_call_tool`, `input_schema` dicts, `_meta`) **[The low-level Server](low-level-server.md)** में है। diff --git a/i18n/hi/pages/client/caching.md b/i18n/hi/pages/client/caching.md new file mode 100644 index 0000000000..dd239cb2c5 --- /dev/null +++ b/i18n/hi/pages/client/caching.md @@ -0,0 +1,122 @@ +--- +translation: + sections: [9e7b9a1710e5aeba, b74ca4c1d2ddddee, fa8714e61bf90c5a, 04db67a886b7271c, 857690fb8f876800] + tool: 1 +--- +# Caching hints {#caching-hints} + +2026-07-28 protocol पर server `tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read` और `server/discover` के लिए जो भी result लौटाता है, उसमें दो fields होते हैं: `ttlMs`, यानी client कितने milliseconds तक उस result को fresh मान सकता है, और `cacheScope`, यानी cache किया गया result users के बीच share किया जा सकता है (`"public"`) या किसी एक authorization context का है (`"private"`)। + +server खुद कुछ भी cache नहीं करता। ये fields एक **घोषणा** हैं: "यह tool list सबके लिए एक जैसी है और अगले एक मिनट तक नहीं बदलेगी।" इसके बाद client (या आपके आगे लगा कोई gateway) round trip छोड़ सकता है। hints मानना या न मानना client की मर्ज़ी है; उन्हें भेजना server का काम है, और SDK यह आपके लिए कर देता है। + +बिना कुछ configure किए हर result `ttlMs: 0, cacheScope: "private"` कहता है: तुरंत stale, कभी share नहीं। यह हमेशा सुरक्षित है और हमेशा spec के अनुरूप। अगर आपकी lists सच में स्थिर हैं और सभी callers के लिए एक जैसी हैं, तो construction के समय ही यह बता दें: + +```python title="server.py" hl_lines="5-8" +--8<-- "docs_src/caching/tutorial001.py" +``` + +* map की keys **method name** हैं, और सिर्फ़ वही छह cacheable methods वैध keys हैं। parameter का type `Mapping[CacheableMethod, CacheHint]` है, इसलिए editor keys को autocomplete करता है और चलाने से पहले ही typo पकड़ लेता है; जो कुछ type checker से बच निकलता है, वह construction के समय raise होता है। +* जिस method का आप ज़िक्र नहीं करते, उसके defaults बने रहते हैं। map overrides का समूह है, manifest नहीं। +* `CacheHint(ttl_ms=5_000)` ने `scope` को unset छोड़ा, इसलिए वह `"private"` ही रहता है: हर caller के लिए पाँच second की freshness। scope और TTL अलग-अलग फ़ैसले हैं। +* `"server/discover"` भी वैध key है, क्योंकि discovery result किसी भी list की तरह cacheable है। + +!!! warning + `cacheScope: "public"` का मतलब है कि आपका cache किया गया response **किसी को भी** दिया जा + सकता है। shared gateway बेझिझक एक user का result दूसरे को थमा देगा, भले ही request + authenticated रही हो। किसी result को `"public"` तभी mark करें जब वह हर caller के लिए एक जैसा + हो, और `cacheScope` को कभी access control की तरह इस्तेमाल न करें: यह label है, ताला नहीं। + +## Per-handler override {#per-handler-override} + +low-level `Server` पर handlers अपने results खुद बनाते हैं, और `ttl_ms` / `cache_scope` result models पर बस fields हैं। जो handler इन्हें explicitly set करता है, वह constructor map पर हमेशा भारी पड़ता है, field दर field: + +```python title="server.py" hl_lines="10 16" +--8<-- "docs_src/caching/tutorial002.py" +``` + +handler ने `ttl_ms=1_000` कहा और scope के बारे में कुछ नहीं। wire पर: `ttlMs: 1000` (handler वाला, map का `60_000` नहीं) और `cacheScope: "public"` (map वाला, क्योंकि handler ने इसे unset छोड़ा)। explicit, configured पर भारी पड़ता है, और configured, default पर। यह हर field पर अलग से लागू होता है, इसलिए handler एक field को पक्का कर सकता है और दूसरे को server-wide policy पर छोड़ सकता है। + +यही उन dynamic मामलों का रास्ता भी है जिन्हें constructor जान नहीं सकता: जो handler `resources/read` को हर user के हिसाब से filter करता है, वह बाकी तरह से public server में किसी एक URI के लिए `cache_scope="private"` लौटा सकता है। + +paginated lists पर एक सावधानी: protocol की माँग है कि एक list के **हर page पर वही `cacheScope`** हो। constructor map यह अपने आप पूरा करता है, क्योंकि उसकी keys method हैं, page नहीं। लेकिन जो handler scope को खुद override करता है, उस consistency की ज़िम्मेदारी उसी की है: इसे **हर** page पर override करें, सिर्फ़ cursor मौजूद होने पर नहीं, वरना पहले page और दूसरे page में मेल नहीं रहेगा। + +## client को क्या दिखता है {#what-the-client-sees} + +2026-07-28 session पर `Client` आपके लिए hints का पालन करता है: इसमें built-in response cache है, जो default रूप से चालू रहता है। जो result `ttlMs` के साथ आता है, वह store हो जाता है, और उस TTL के भीतर वैसा ही call cache से serve होता है, बिना round trip के। जिस result में **कोई** hint नहीं होता, वह cache नहीं होता: बिना hint वाले results को `CacheConfig.default_ttl_ms` मिलता है, जिसका default `0` है (तुरंत stale), इसलिए जो server कुछ भी declare नहीं करता, उसे ठीक वैसा ही call-दर-call traffic दिखता है जैसा हमेशा दिखता था। + +```python title="client.py" hl_lines="33 35 38" +--8<-- "docs_src/caching/tutorial003.py" +``` + +चार calls, तीन fetches। दूसरे call को fresh entry मिली और वह server तक पहुँचा ही नहीं; (inject की गई) clock को TTL से आगे बढ़ाने पर तीसरे ने फिर से fetch किया; चौथे ने `cache_mode="refresh"` कहा। यह kwarg पाँचों caching verbs पर मौजूद है (`list_tools`, `list_prompts`, `list_resources`, `list_resource_templates`, `read_resource`): + +* `"use"` (default) fresh entry हो तो उसे serve करता है, और न हो तो fetch करके store करता है। +* `"refresh"` कभी serve नहीं करता: यह fetch करता है और result store करता है, जो कुछ cache में था उसे बदलते हुए। +* `"bypass"` cache को छुए बिना round trip करता है: न read, न write। + +एक नियम `"use"` से ऊपर है: **`meta` वाले calls हमेशा server तक पहुँचते हैं।** जिस request में `meta` set हो (progress token, tracing fields), उसे wire request की उम्मीद होती है, इसलिए `cache_mode="use"` में उसे `"refresh"` माना जाता है: cache read छोड़ दिया जाता है, और fetch किया गया result फिर भी cache की entry की जगह ले लेता है। `"bypass"` और explicit `"refresh"` हमेशा की तरह ही बर्ताव करते हैं। + +caching पूरी तरह बंद करने के लिए `Client(server, cache=None)` से construct करें: हर call फिर से round trip है, और `cache_mode`, भले ही अब भी स्वीकार होता है, कुछ नहीं करता। + +scope का पालन भी अपने आप होता है: `"private"` entries cache के *partition* (नीचे देखें) से बँधी होती हैं, जबकि `"public"` वाली चाहें तो ज़्यादा व्यापक sharing चुन सकती हैं। और जिन entries का नाम notifications लेते हैं, ठीक उनके लिए **notifications TTL पर भारी पड़ते हैं**: `list_changed` notification मेल खाती cached listing को evict कर देता है, और `resources/updated` ठीक उसी URI के तहत store किए गए cached read को evict करता है, चाहे वे कितने भी fresh रहे हों। 2026-07-28 connection पर ये notifications `subscriptions/listen` stream पर आते हैं जिसे आप `client.listen(...)` से खोलते हैं, और eviction आपके watcher को event दिखने से पहले पूरा हो जाता है; **[Subscriptions](subscriptions.md)** वही page है। + +`resources/updated` पर एक सावधानी: eviction सिर्फ़ exact URI पर होता है। store contract में कोई enumerate या scan operation नहीं है (reference TypeScript implementation की तरह ही), इसलिए *sub*-resource URI वाला notification उसके parent के cached read को evict नहीं करता। अगर आपका server sub-resources का संकेत इसी तरह देता है, तो parent को `cache_mode="refresh"` से फिर fetch करें। + +### इसे configure करना: `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`: entries कहाँ रहती हैं। default हर client के लिए नया in-memory store है; clients या processes के बीच cache share करना हो तो अपना `ResponseCacheStore` implementation (जैसे Redis-backed) pass करें। contract types (`ResponseCacheStore`, `CacheKey`, `CacheEntry`, और default `InMemoryResponseCacheStore`) `mcp.client` से import किए जा सकते हैं। एक lookup एक के बाद एक ज़्यादा से ज़्यादा दो store `get` जारी कर सकता है (पहले private arm, फिर public), इसलिए remote store की latency की उम्मीदें उसी हिसाब से तय करें। custom store के लिए explicit `partition` **ज़रूरी** है। +* `partition`: authorization-context label, जो shared store के भीतर एक principal की `"private"` entries को किसी दूसरे को serve होने से रोकता है। +* `target_id`: explicit server identity, custom transports और in-process servers के लिए (नीचे देखें)। +* `default_ttl_ms`: उन results पर लागू TTL जिनमें `ttlMs` hint नहीं है। default `0` बिना hint वाले results को uncached छोड़ देता है। +* `share_public`: server ने जिन entries को `"public"` बताया, उन्हें partitions के पार serve करना (नीचे देखें)। default रूप से बंद। +* `clock`: wall-clock source, epoch seconds में। ऊपर के उदाहरण की तरह एक inject करें, और expiry tests में sleep की ज़रूरत नहीं रहती। + +!!! warning "Partition = verified principal" + `partition` किसी **verified credential** से निकालें, जैसे validate किए गए token का subject। इसे request में आए data से कभी न निकालें, और server URL से भी कभी नहीं (server identity key की एक अलग axis है)। SDK एक library है जिसका अपना कोई authentication नहीं: trust anchor वही है जो `CacheConfig` construct करता है, यानी deployment, tenant नहीं। multi-tenant gateway हर authenticated principal के लिए एक अलग `CacheConfig` बनाता है। + + partition `Client` के पूरे जीवनकाल के लिए स्थिर भी रहता है। अगर connection का authorization context session के बीच बदलता है (जैसे किसी दूसरे principal के रूप में re-authentication), तो cache उसके साथ नहीं बदलता; नए principal के लिए नया `Client` construct करें। + +cache keys में **server की identity** भी होती है: वह URL string जिसे आपने dial किया, जिसमें से `user:pass@` userinfo हटा दी जाती है और बाकी byte-दर-byte वैसी ही रहती है। न case folding, न query reordering, न trailing slash की सफ़ाई। कम normalize करने से सिर्फ़ sharing घटती है, जबकि ज़्यादा normalize करने से दो tenants (`?tenant=a` बनाम `?tenant=b`) आपस में मिल सकते हैं, इसलिए ऊपरी तौर पर अलग URL बस entries share नहीं करते। जब कोई URL नहीं होता (in-process server, या `Transport` instance), तो client को उसकी जगह हर instance के लिए एक random identity मिलती है; server को नाम देने के लिए `CacheConfig.target_id` set करें (custom store के साथ यह ज़रूरी है, और construction यह बता देता है)। identity key material में जाने से पहले sha256-hash की जाती है, इसलिए जिस URL की query string में secrets हों, वह store keys में कभी नहीं दिखता। pre-hash रूप को खुद भी log न करें। + +!!! warning "`share_public` server पर भरोसा करता है, पूरे fleet में" + default रूप से `"public"` entries भी अपने partition के भीतर ही रहती हैं। `share_public=True` उन entries को, जिन्हें server ने `cacheScope: "public"` mark किया, store इस्तेमाल करने वाले **हर** partition को serve करता है, और उन सबकी ओर से server के वर्गीकरण पर भरोसा करता है। जो server per-tenant data पर `"public"` की मुहर लगा देता है (bug से या बदनीयती से), वह फिर एक tenant का response बाकियों को leak कर देता है। यह flag जान-बूझकर सिर्फ़ constructor स्तर पर है: per-call `cache_mode` caching को सीमित कर सकता है, लेकिन per-call कोई भी चीज़ sharing को बढ़ा नहीं सकती। + +### cache क्या कभी नहीं करता {#what-the-cache-never-does} + +* **Session-tier calls इसे bypass करते हैं।** `client.session.list_tools()` और उसके साथी हमेशा round trip करते हैं; cache `Client` verbs पर रहता है। +* **`server/discover` इससे बाहर रहता है।** discover result एक बार, connect के समय, दिया जाता है और response cache में कभी नहीं जाता, भले ही उसमें `ttlMs` हो। अगर reconnect probe से बचने के लिए आप उसे खुद persist करते हैं ([`prior_discover`](../protocol-versions.md#reconnecting-with-prior_discover)), तो उसकी freshness का हिसाब आपका है: `DiscoverResult` में ठीक इसी काम के लिए `ttl_ms` और `cache_scope` पहले से parse किए हुए मौजूद हैं। +* **Continuation pages कभी cache नहीं होते।** सिर्फ़ बिना cursor वाले calls हिस्सा लेते हैं। expired cursor के कारण reject हुआ continuation page cached listing को *evict* ज़रूर करता है, क्योंकि listing उसके नीचे बदल गई। +* **Multi-round-trip reads कभी cache नहीं होते।** `input_responses`/`request_state` से seed किया गया `read_resource`, या ऐसा जो input rounds से होकर resolve होता है, कभी cache में नहीं जाता (spec का MUST)। +* **Notification eviction को notifications चाहिए।** eviction उतना ही अच्छा है जितनी transport की delivery, और आधुनिक in-process path (default `mode="auto"` के साथ `Client(server)`) आज standalone notifications deliver नहीं करता। +* **Eviction eventual है, तात्कालिक नहीं।** wire-path notifications spawn किए गए tasks से dispatch होते हैं, इसलिए किसी notification के आने से race कर रहे call को pre-eviction entry एक बार और serve हो सकती है; यह window dispatch latency से सीमित है, और eviction फिर भी हो ही जाता है। +* **कोई stale-if-error नहीं।** expired entry कभी इसलिए serve नहीं होती कि refetch fail हो गया; error आगे propagate होता है। +* **कोई early re-fetch नहीं।** store की गई entry तब तक serve होती है जब तक उसका TTL expire न हो जाए, और उसके बाद का अगला call round trip की कीमत चुकाता है; background में कुछ refresh नहीं होता। +* **कोई coalescing नहीं।** दो concurrent एक जैसे calls दो fetches हैं। +* **24 घंटे से ज़्यादा का TTL नहीं।** इससे बड़ा `ttlMs`, चाहे server ने भेजा हो या configure किया गया हो, store करते समय घटा दिया जाता है (`mcp.client.caching.MAX_TTL_MS`), जिससे यह सीमित रहता है कि कोई भी entry, hint चाहे कितना भी उदार हो, कितनी देर serve हो सकती है। +* **shared store** पर clients आपस में race करते हैं। जब किसी eviction ने चल रहे fetch को पीछे छोड़ दिया हो तो हर client अपना write छोड़ देता है, लेकिन कोई *co-tenant* client अब भी ऐसी entry वापस लिख सकता है जिसे किसी ऐसे eviction ने हटा दिया था जो उसने कभी देखा ही नहीं; और race का यह हिसाब-किताब भी खुद सीमित है: 4096 tracked keys के बाद सबसे पुरानी key का guard सबसे पहले हटता है। दोनों windows स्वीकार्य हैं, और ऊपर बताई गई TTL cap उन्हें बंद कर देती है। +* **protocol की अलग-अलग पीढ़ियों के बीच serve नहीं किया जाता।** entries negotiated protocol version तक सीमित हैं: shared persistent store पर कोई session कभी ऐसी entry serve नहीं करता जो किसी दूसरे negotiated version के तहत लिखी गई हो (वही listing पीढ़ी के हिसाब से सच में अलग होती है, क्योंकि SDK पुराने sessions के लिए 2026 वाले fields हटा देता है)। eviction भी इसी तरह सिर्फ़ मौजूदा पीढ़ी की entries को छूता है; दूसरी पीढ़ी की entries बस TTL से अपने आप पुरानी होकर हट जाती हैं। + +### hints खुद पढ़ना {#reading-the-hints-yourself} + +hints हर cacheable result पर सादे fields के रूप में भी मौजूद हैं (`result.ttl_ms` और `result.cache_scope`, पहले से parse किए हुए), अगर आप built-in cache के ऊपर (या उसकी जगह) अपना हिसाब-किताब रखना चाहें। + +किसी **पुराने server** (pre-2026 protocol) के सामने ये fields wire पर होते ही नहीं, और models अपने conservative defaults दिखाते हैं: `ttl_ms == 0` और `cache_scope == "private"`, stale और unshared, जो कुछ भी declare न करने वाले server के लिए सही मान्यता है। cache legacy session के साथ भी यही करता है: वहाँ hints कभी देखे ही नहीं जाते (wire पर चाहे जो keys आएँ), सिर्फ़ `default_ttl_ms` लागू होता है, और उसका default `0` कुछ भी cache नहीं करता, इसलिए pre-2026 connection ठीक वैसे ही बर्ताव करता है जैसे cache के आने से पहले करता था। अगर आपको "server ने 0 कहा" और "server ने कुछ नहीं कहा" में फ़र्क करना हो, तो `"ttl_ms" in result.model_fields_set` जाँचें: यह तभी set होता है जब field सच में आया हो। + +## पुराने clients {#older-clients} + +pre-2026 protocol versions वाले clients को इनमें से कोई भी field कभी नहीं दिखता; SDK उन connections के लिए serialization के समय इन्हें हटा देता है। hints एक बार configure करें; version के हिसाब से अलग कुछ लिखने को नहीं है। + +## सारांश {#recap} + +* छह methods में `ttlMs`/`cacheScope` होते हैं; SDK इनका default `0`/`"private"` रखता है, stale और unshared, हमेशा सुरक्षित। +* construction के समय `cache_hints={method: CacheHint(...)}` (`MCPServer` और `Server` दोनों में) हर method के लिए server-wide values set करता है। +* जो handler अपने result पर ये fields set करता है, वह map को override करता है, field दर field। +* `"public"` एक वादा है कि result हर caller के लिए एक जैसा है। यह access control नहीं है। +* `Client` hints का पालन अपने आप करता है: उसका response cache default रूप से चालू है, दोबारा fetch करने की बजाय fresh entries serve करता है, और जो servers (या sessions) कोई hint नहीं देते उनके लिए कुछ भी cache नहीं करता। +* हर call पर, `cache_mode="refresh"` दोबारा fetch करता है और `"bypass"` cache को छोड़ देता है; construction के समय `cache=None` इसे पूरी तरह बंद कर देता है। diff --git a/i18n/hi/pages/client/callbacks.md b/i18n/hi/pages/client/callbacks.md new file mode 100644 index 0000000000..c7f70ccf7b --- /dev/null +++ b/i18n/hi/pages/client/callbacks.md @@ -0,0 +1,154 @@ +--- +translation: + sections: [adf3c545b5be46b6, 916cd3ab1c03f461, e9be7a8d0eb0a456, 565890a636288ecf, 6af7e49db9129ec3, 06b0238c174186af, 90c6043be435fcb0] + tool: 1 +--- +# Client callbacks {#client-callbacks} + +MCP में लगभग हर request एक ही दिशा में जाती है: client से server की ओर। + +server भी **client** से चीज़ें माँग सकता है: user से कोई सवाल पूछना, user के model से sampling करना, user के workspace folders की सूची लेना। इन requests का जवाब आप `Client(...)` को **callbacks** देकर देते हैं। + +## पूछने वाला server {#a-server-that-asks} + +यह एक ऐसा server है जिसका tool अपने आप पूरा नहीं हो सकता: + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/client_callbacks/tutorial001.py" +``` + +* `ctx.elicit(...)` **client को** `elicitation/create` request भेजता है और इंतज़ार करता है। +* जब तक कोई (form में कोई व्यक्ति, या आपका code) `name` नहीं देता, tool लौटता नहीं। + +यह server वाला आधा हिस्सा है, और इसकी पूरी जानकारी **[Elicitation](../handlers/elicitation.md)** page में है। यह page wire का दूसरा सिरा है। + +## Elicitation callback {#the-elicitation-callback} + +```python title="client.py" hl_lines="6-10 16-17" +--8<-- "docs_src/client_callbacks/tutorial002.py" +``` + +* elicitation callback का रूप `async (context, params) -> ElicitResult` है। +* `params.message` सवाल है। `params.requested_schema` उस जवाब का JSON Schema है जो server चाहता है। असली client इससे form बनाकर दिखाता है; यह वाला अपने आप भर देता है। +* आप `ElicitResult(action="accept", content={...})` लौटाते हैं, या `action="decline"`, या `action="cancel"`। इनके अलावा सिर्फ़ एक विकल्प है `ErrorData(...)`, जो request को ठुकरा देता है और पूरा call fail कर देता है। +* `context` एक `ClientRequestContext` है: चालू `session`, server का `request_id`, और उसके साथ लगाया गया कोई भी `meta`। + +!!! tip + `params` elicitation के दोनों modes का union है। यहाँ `params.mode` का मान `"form"` है; `"url"` request + में schema की जगह `params.url` आता है। एक ही callback दोनों को संभालता है; `params.mode` पर branch करें। + पूरा pattern **[Elicitation](../handlers/elicitation.md)** में दिखाया गया है। + +### इसे आज़माएँ {#try-it} + +`issue_card` call करें और दोनों सिरों को देखें। + +आपके callback को server का सवाल मिलता है, पहले से parse किया हुआ: + +```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'} +``` + +वह जवाब देता है, tool के अंदर `ctx.elicit(...)` आगे बढ़ता है, और tool पूरा हो जाता है: + +```python +result.content # [TextContent(type='text', text='Card issued to Ada Lovelace.')] +``` + +आपकी ओर से एक `tools/call`, server की ओर से वापस एक `elicitation/create`, जिसका जवाब आपके function ने दिया, और यह सब एक ही tool call के अंदर। + +!!! info + `Client(...)` call पर `mode="legacy"` असल में काम कर रहा है। default रूप से `Client(...)` modern + protocol path negotiate करता है, और उस path में server-से-client requests के लिए कोई back-channel नहीं है: + आपका callback चलने से पहले ही `ctx.elicit` fail हो जाता है। यह transport तय नहीं करता; negotiated + protocol तय करता है, in-memory में भी और URL पर भी। जब भी आपके client को ऐसी किसी request का जवाब देना हो, + `mode="legacy"` तय करें; इस page के पीछे का हर test यही करता है। पूरी जानकारी **[Protocol versions](../protocol-versions.md)** में है। + + 2026-07-28 session पर callback बेकार नहीं होता, उसे input अलग तरीके से मिलता है: जब कोई tool + `ElicitRequest` वाला `InputRequiredResult` लौटाता है, तो `Client` उस entry को उसी + `elicitation_callback` को भेज देता है और आपके लिए call दोबारा करता है। वह flow **[Multi-round-trip requests](../handlers/multi-round-trip.md)** है। + +## callback ही capability है {#a-callback-is-a-capability} + +आपने server को कभी नहीं बताया कि आपका client elicitation requests का जवाब दे सकता है। SDK ने बताया। + +जब client जुड़ता है तो वह अपनी `capabilities` घोषित करता है, जो server की capabilities का ठीक उल्टा रूप है। वह object आप नहीं लिखते। **callback register करना ही घोषणा है।** + +| आप देते हैं | client घोषित करता है | +| --- | --- | +| `elicitation_callback=` | `"elicitation": {"form": {}, "url": {}}` | +| `sampling_callback=` | `"sampling": {}` | +| `list_roots_callback=` | `"roots": {"listChanged": true}` | +| इनमें से कोई नहीं | `{}` | + +sampling की sub-capabilities ही एकमात्र बारीकी हैं: जब आपका sampler `tools` / `tool_choice` parameters संभालता हो, तो `sampling_callback` के साथ `sampling_capabilities=SamplingCapability(tools=SamplingToolsCapability())` दें। servers को `sampling.tools` घोषित दिखनी चाहिए, तभी वे इन्हें भेज सकते हैं। + +`logging_callback` और `message_handler` इस table में नहीं हैं। वे notifications संभालते हैं, और notifications को किसी capability की ज़रूरत नहीं। + +server इस घोषणा को `ctx.session.check_client_capability(...)` से पढ़ता है। ऐसा करने वाला एक tool जोड़ें: + +```python title="server.py" hl_lines="23-31" +--8<-- "docs_src/client_callbacks/tutorial003.py" +``` + +सिर्फ़ `elicitation_callback` के साथ जुड़ें और इसे call करें: + +```python +result.structured_content # {'result': ['elicitation']} +``` + +तीनों callbacks दें तो आपको `['elicitation', 'sampling', 'roots']` मिलता है। कोई न दें तो `[]` मिलता है। + +!!! check + अब गलत काम करें: `elicitation_callback` के **बिना** जुड़ें और फिर भी `issue_card` call करें। + + server की `elicitation/create` request फिर भी आपके client तक पहुँचती है, और SDK आपकी ओर से उसका + जवाब देता है, error के साथ, क्योंकि आपने कभी कहा ही नहीं कि आप इसे संभाल सकते हैं। वह error पूरे call को + डुबो देता है। `call_tool` कोई `is_error` result नहीं लौटाता; वह raise करता है: + + ```text + MCPError: Elicitation not supported + ``` + + यह protocol error है (`-32600`, **invalid request**), tool error नहीं: model के पढ़ने और दोबारा + कोशिश करने के लिए इसमें कुछ नहीं है। इसीलिए `client_features` रखना फ़ायदेमंद है: अच्छे ढंग से बना server + पूछने से पहले जाँच लेता है। + +## Deprecated जोड़ी {#the-deprecated-pair} + +`sampling_callback` `sampling/createMessage` का जवाब देता है: server **आपके** model से कुछ complete करने को कहता है। `list_roots_callback` `roots/list` का जवाब देता है: server पूछता है कि वह किन directories में काम कर सकता है। + +दोनों काम करते हैं। दोनों ऊपर वाले नियम का पालन करते हैं। और दोनों ऐसे RPCs को serve करते हैं जिन्हें **2026-07-28 spec हटा देता है**: modern server request के बीच में आपके client को वापस call नहीं करता, वह request को tool result के हिस्से के रूप में आपको वापस सौंप देता है (**[Multi-round-trip requests](../handlers/multi-round-trip.md)**)। callbacks खुद बेकार नहीं हुए हैं। जब किसी `InputRequiredResult` में `CreateMessageRequest` या `ListRootsRequest` होता है, तो `Client` का auto-loop उसे उसी `sampling_callback` या `list_roots_callback` को भेज देता है जो आपने यहाँ register किया था। पूरी सूची **[Deprecated features](../deprecated.md)** में है। + +जो servers अभी आगे नहीं बढ़े हैं, उनसे बात करने के लिए आपको ये callbacks अब भी चाहिए। signatures: + +```python title="client.py" +--8<-- "docs_src/client_callbacks/tutorial004.py" +``` + +* sampling callback को पूरा `CreateMessageRequestParams` (`messages`, `model_preferences`, `max_tokens`) मिलता है और वह `CreateMessageResult` लौटाता है। model **आप** चलाते हैं, जैसे चाहें; SDK सिर्फ़ request पहुँचाता है। +* roots callback कोई params नहीं लेता और `ListRootsResult` लौटाता है। +* दोनों में से कोई भी, मना करने के लिए, इसकी जगह `ErrorData(...)` लौटा सकता है। + +इन्हें `Client(...)` को ठीक वैसे ही दें जैसे `elicitation_callback` को। + +## Notification callbacks {#the-notification-callbacks} + +दो और। इनमें से कोई कुछ घोषित नहीं करता। + +`logging_callback` को server की भेजी हुई `notifications/message` मिलती है, `LoggingMessageNotificationParams` (`level`, `logger`, `data`) के रूप में। protocol logging खुद 2026-07-28 spec में deprecated है (इसकी जगह क्या करना है, यह **[Logging](../handlers/logging.md)** में है), इसलिए यह callback उन servers के लिए है जो इसे अब भी emit करते हैं। 2026 पीढ़ी के connection पर अकेला callback आपको कुछ नहीं दिलाता, क्योंकि 2026 servers log messages सिर्फ़ उन्हीं requests को भेजते हैं जो इसके लिए opt in करती हैं: हर request पर वह opt-in लगाने और उस level व उससे ऊपर के messages पाने के लिए `Client(...)` को `log_level="info"` (या कोई और level) दें। 2026 से पहले के servers इसे नज़रअंदाज़ करते हैं और अपना `logging/setLevel` वाला व्यवहार बनाए रखते हैं। + +`message_handler` सब कुछ पकड़ने वाला है: session जो भी server notification सामने लाता है, वह इस तक पहुँचती है (उसके खास callback के अलावा), और stream-backed transport पर हर transport-level `Exception` भी। दो कभी नहीं पहुँचते: `notifications/cancelled` को SDK सामने लाने के बजाय खुद लागू करता है, और चालू `listen()` stream की subscription acknowledgment उसी stream में खप जाती है। parameter को `IncomingMessage` (`ServerNotification | Exception`, `mcp.client` से export किया हुआ) से annotate करें। जानने लायक एक ही pattern है `if isinstance(message, Exception): raise message`, ताकि टूटा हुआ connection चुपचाप गायब होने के बजाय ज़ोर से fail हो। + +## सारांश {#recap} + +* server client को requests भेज सकता है। आप उनका जवाब `Client(...)` को दिए गए callbacks से देते हैं। +* elicitation callback मौजूदा वाला है: `async (context, params) -> ElicitResult`, form और URL mode दोनों के लिए एक ही function। +* **callback register करना ही capability घोषित करना है।** इसके बिना SDK आपकी ओर से server की request ठुकरा देता है और पूरा call `MCPError` के साथ fail हो जाता है। +* server पूछने से पहले `ctx.session.check_client_capability(...)` से पता कर लेता है। +* `sampling_callback` और `list_roots_callback` इसी तरह काम करते हैं लेकिन deprecated features को serve करते हैं; modern servers इनकी जगह multi-round-trip requests इस्तेमाल करते हैं। +* `logging_callback` और `message_handler` को notifications मिलती हैं। वे कुछ घोषित नहीं करते। + +`Client(...)` का पहला argument transport object है। हर प्रकार की जानकारी **[Client transports](transports.md)** में है। diff --git a/i18n/hi/pages/client/identity-assertion.md b/i18n/hi/pages/client/identity-assertion.md new file mode 100644 index 0000000000..899c3d9805 --- /dev/null +++ b/i18n/hi/pages/client/identity-assertion.md @@ -0,0 +1,151 @@ +--- +translation: + sections: [a91322c46111d16d, 8e6fd6d6f59bb568, e7828fd2729b2c9d, a03ec26bfc678b65, 1034c653c0bcf1b0] + tool: 1 +--- +# Identity assertion {#identity-assertion} + +साधारण OAuth provider (**[OAuth clients](oauth-clients.md)**) MCP server से एक सवाल पूछकर शुरू करता है: **आप किस authorization server पर भरोसा करते हैं?** जवाब जिधर इशारा करे, वह उधर चला जाता है, और फिर या तो कोई इंसान sign in करता है या उसकी जगह कोई pre-shared secret काम आता है। + +Enterprise नहीं चाहता कि इनमें से कोई भी बात हर server के हिसाब से अलग तय हो। वह पहले से एक identity provider चलाता है (Okta, Microsoft Entra ID, या आपका अपना); user आज सुबह ही उसमें sign in कर चुका है; और यही वह एक जगह है जहाँ security team तय करना चाहती है कि कौन कहाँ तक पहुँच सकता है। [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990), यानी **Enterprise-Managed Authorization** extension, यह फ़ैसला वहीं ले जाता है। IdP एक short-lived JWT sign करता है, **Identity Assertion JWT Authorization Grant**, यानी **ID-JAG**: यह बयान कि **यह user**, **इस client** के ज़रिए, **इस MCP server** तक पहुँच सकता है। Client इसे देकर बदले में साधारण access token ले लेता है। न browser, न consent screen, न dynamic registration। + +यह page उसी लेन-देन के दोनों सिरों के बारे में है। MCP server खुद कभी नहीं बदलता: वह अब भी **[Authorization](../run/authorization.md)** वाला resource server ही है, जो भी token सामने आए उसे जाँचता है। + +## दो token requests {#two-token-requests} + +यहाँ दो अलग-अलग authorities काम कर रही हैं, और उन्हें अलग-अलग नाम से पहचान लेना ही इस page को समझने का ज़्यादातर हिस्सा है। **Enterprise IdP** आपके organization का identity provider है: उसे पता है कि employee कौन है, policy वहीं रहती है, और ID-JAG वही जारी करता है। SDK उससे कभी बात नहीं करता। **MCP authorization server** वही पक्ष है जो **[Authorization](../run/authorization.md)** में था: MCP server के metadata में नामित issuer, वह चीज़ जो वे tokens बनाती है जिन्हें वह MCP server स्वीकार करता है। साधारण OAuth flow में ये दोनों भूमिकाएँ आमतौर पर एक ही system निभाता है। यहाँ ये दो हैं, और पूरा grant बस इतना है कि दूसरा पहले पर भरोसा करने को राज़ी हो। + +Client इनमें से हर एक को एक token request भेजता है। + +1. **Enterprise IdP को।** Client user के sign-in (उनका OpenID Connect ID token) के बदले ID-JAG लेता है। यह [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) token exchange है, यह पूरी तरह आपके IdP का API है, और **SDK इसे नहीं करता**। यह आप करते हैं, एक async callback के अंदर। Policy का फ़ैसला भी यहीं होता है: जो IdP मना कर दे वह ID-JAG जारी ही नहीं करता, और पेश करने को कुछ बचता ही नहीं। +2. **MCP authorization server को।** Client ID-JAG को [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) `jwt-bearer` grant के तहत पेश करता है (`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`, ID-JAG `assertion` के रूप में) और access token पाता है। **यही वह request है जो SDK भेजता है**, और इसे स्वीकार करना ही वह एक चीज़ है जो यह page authorization server में जोड़ता है। + +नीचे सब कुछ दूसरी request के बारे में है: उसे भेजने वाला client और उसका जवाब देने वाला authorization server। + +## Client {#the-client} + +**`IdentityAssertionOAuthProvider`** `mcp.client.auth.extensions.identity_assertion` में रहता है। **[OAuth clients](oauth-clients.md)** के हर provider की तरह यह भी `httpx2.Auth` है: एक बनाएँ, उसे `auth=` पर रखें, और `httpx2.AsyncClient` transport को सौंप दें। + +```python title="client.py" hl_lines="49-50 53-61" +--8<-- "docs_src/identity_assertion/tutorial001.py" +``` + +इसे नीचे से पढ़ें। + +* `main()` वही standard OAuth-client वाला `main()` है (**[OAuth clients](oauth-clients.md)**), पंक्ति-दर-पंक्ति बिना बदलाव। यही बात है: एक बार provider बन जाए तो आगे किसी को पता नहीं चलता कि token किस grant से आया। +* Provider वह लेता है जो बाकी providers discover नहीं कर सकते: `client_id` और `client_secret` जो किसी ने authorization server के साथ **पहले से register** कर रखे हैं, उस authorization server का `issuer`, और `assertion_provider`, एक async callback जो माँगने पर ताज़ा ID-JAG लौटाता है। +* `storage` वही `TokenStorage` protocol है। सिर्फ़ दो token methods ही कभी call होते हैं; यहाँ dynamic registration नहीं है, इसलिए याद रखने को कोई `client_info` नहीं है। + +### Assertion provider {#the-assertion-provider} + +`fetch_id_jag(audience, resource)` ही वह इकलौता code है जो आप लिखते हैं। यह हर token exchange पर एक बार await होता है, construction के समय कभी नहीं, और सिर्फ़ तब **जब** authorization server का metadata fetch और validate हो चुका हो, इसलिए गलत configure किया गया issuer कभी assertion leak नहीं करवाता। इसके दो arguments उन claims में से दो हैं जिनके साथ ID-JAG बनना ज़रूरी है: `audience` authorization server का issuer है (ID-JAG का `aud`) और `resource` MCP server का canonical identifier है (ID-JAG का `resource`)। तीसरा वह है जो आपके पास पहले से है: ID-JAG के `client_id` claim में वही `client_id` होना चाहिए जो आपने provider को दिया, वरना authorization server exchange से मना कर देता है। + +उसके ऊपर वाला `idp_issue_id_jag` **आपका code नहीं है**। वह identity provider की जगह खड़ा है, और assertion को उसी process में sign करता है ताकि file पूरी रहे और आप ID-JAG में जाने वाला हर claim पढ़ सकें। असली `fetch_id_jag` इसकी जगह पिछले section की पहली token request भेजता है: आपके IdP के सामने [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) token exchange, जिसे Identity Assertion JWT Authorization Grant draft परिभाषित करता है और जिसे [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) profile करता है। Sign in किए हुए user का ID token `subject_token` के रूप में जाता है, `requested_token_type` ID-JAG का अपना URN है (`urn:ietf:params:oauth:token-type:id-jag`), `audience` और `resource` जस के तस आगे जाते हैं, और response में ID-JAG आता है। अपने IdP की documentation में इन्हीं नामों के साथ यही exchange ढूँढें। + +!!! tip + हर exchange के लिए ताज़ा ID-JAG माँगा जाता है, और यही मक़सद है: यह एक बार इस्तेमाल होने वाला, + कुछ मिनट जीने वाला grant है, और इस page का authorization server एक ही ID-JAG को दो बार स्वीकार + करने से मना कर देता है। इसे cache न करें। इसके बदले जो access token मिलता है, दोबारा इस्तेमाल वही होता है। + +### Issuer configuration है {#the-issuer-is-configuration} + +उलटफेर यहाँ है। `OAuthClientProvider` resource server से पूछता है कि कौन सा authorization server इस्तेमाल करे, और जवाब जिधर इशारा करे उधर चला जाता है। यह provider ऐसा करने से मना करता है: `issuer` ज़रूरी है, [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) metadata उसी issuer के अपने well-known path से fetch होता है, token endpoint उसी issuer के origin पर होना चाहिए, और resource server से कभी कुछ नहीं पूछा जाता। + +Extension इसकी माँग नहीं करता; यह जान-बूझकर चुना गया ज़्यादा सख़्त रास्ता है। इस client के पास चुराने लायक दो चीज़ें हैं, एक pre-registered secret और एक audience-bound assertion, और जो client किसी compromised MCP server को खुद को हमलावर के authorization server की तरफ़ मोड़ने दे, वह दोनों उसी को post कर देगा। Construction के समय issuer को pin कर देने से वह बातचीत ही ख़त्म हो जाती है। + +!!! warning + Configure किए गए `issuer` की तुलना metadata document के `issuer` field से RFC 8414 §3.3 के + simple string comparison से होती है: एक-एक character, आख़िरी slash समेत, बिना किसी normalization के। + इसका अंदाज़ा न लगाएँ। अपने authorization server से `/.well-known/oauth-authorization-server` fetch करें + और जो `issuer` value वह लौटाए उसे copy करें। इस page के authorization server के लिए वह + `https://auth.example.com/` है, slash के साथ, क्योंकि उसका issuer pydantic URL object से बना था। + Mismatch होने पर flow एक भी credential या assertion भेजे जाने से पहले `OAuthFlowError: Authorization server metadata issuer + mismatch` पर रुक जाता है। + +### Confidential client {#a-confidential-client} + +`client_secret` ज़रूरी है; इसके बिना constructor `ValueError` raise करता है। [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) के नीचे वाला IETF profile इस grant को confidential clients के लिए आरक्षित रखता है, SEP-990 client से authenticate करने की माँग करता है, और यह SDK shared secret पर ज़ोर देकर दोनों लागू करता है। `token_endpoint_auth_method` तय करता है कि यह कहाँ से होकर जाए: `client_secret_post` (default, form body में) या `client_secret_basic` (HTTP Basic header)। Profile `private_key_jwt` की भी इजाज़त देता है; यह provider उसे support नहीं करता। + +!!! tip + `client_secret` को environment या किसी secret manager से पढ़ें, source control से कभी नहीं। + +### Provider आपके लिए क्या करता है {#what-the-provider-does-for-you} + +पहली request बिना authentication के जाती है, और server का `401` flow शुरू करता है। + +1. **Discovery।** यह configure किए गए issuer के [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) well-known path से authorization server metadata fetch करता है, जाँचता है कि document का `issuer` मेल खाता है, और जाँचता है कि token endpoint issuer के origin पर है। +2. **Assertion।** यह आपके `assertion_provider` को await करता है। +3. **Exchange।** यह token endpoint पर `jwt-bearer` grant POST करता है, `OAuthToken` store करता है, और आपकी मूल request `Authorization: Bearer ...` के साथ दोबारा भेजता है। + +जिस `403` का `WWW-Authenticate` `insufficient_scope` बताता है, वह चरण 2 और 3 को आपके `scope` और challenge किए गए scope के union के साथ दोबारा चलाता है। (`scope` हमेशा सिर्फ़ एक माँग है; इस page का authorization server वही देता है जो ID-JAG कहता है, उससे ज़्यादा कुछ नहीं।) इसमें कहीं कोई refresh token नहीं है: access token expire होने पर अगला `401` ताज़ा ID-JAG बनवाता है और फिर exchange करता है, और **यही** वह lever है जो IdP के हाथ में है। नाकामियाँ **[OAuth clients](oauth-clients.md)** के बाकी हिस्से वाले वही दो exceptions हैं: discovery और validation के लिए `OAuthFlowError`, और जब token endpoint मना करे तब उसका subclass `OAuthTokenError`। + +## Authorization server {#the-authorization-server} + +ज़्यादातर बार आप यहीं रुक जाते हैं। MCP authorization server किसी और का product है, ID-JAGs स्वीकार करना उसकी configuration में चालू करने की चीज़ है, और [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) का SDK वाला आधा हिस्सा ऊपर का client है। + +SDK खुद authorization server भी **बन** सकता है: `create_auth_routes` authorization server के routes एक list के रूप में लौटाता है जिसे कोई भी Starlette app mount कर सकता है; repository में `examples/servers/simple-auth/` इसी तरह एक चलाता है। SEP-990 उस surface में एक flag और एक method जोड़ता है: + +```python title="auth_server.py" hl_lines="48-50 105-107" +--8<-- "docs_src/identity_assertion/tutorial002.py" +``` + +* `identity_assertion_enabled=True` सब कुछ gate करता है। बंद होने पर, जो default है, `/token` इस grant का जवाब `unsupported_grant_type` से देता है चाहे आपने hook implement किया हो, और metadata में इसका ज़िक्र नहीं होता। चालू होने पर metadata में `jwt-bearer` grant type जुड़ जाता है और `authorization_grant_profiles_supported` में `urn:ietf:params:oauth:grant-profile:id-jag` सूचीबद्ध हो जाता है; यही वह field है जिससे extension support का ऐलान करता है। (इस SDK का client इसे कभी नहीं पढ़ता: वह एक issuer के लिए provision किया गया है और सीधे माँग लेता है।) +* **`exchange_identity_assertion`** ही hook है। इसके चलने से पहले SDK client को authenticate कर चुका होता है, public clients को मना कर चुका होता है, और उन clients को मना कर चुका होता है जिनके registration में यह grant सूचीबद्ध नहीं है। आपको `IdentityAssertionParams` मिलता है (कच्चा `assertion`, माँगे गए `scopes` और `resource`) और आप सादा `OAuthToken` लौटाते हैं। +* Dynamic client registration इस grant को बिना शर्त मना करता है, इसलिए यहाँ `get_client` हाथ से provision किया गया client देता है। ID-JAG client खुद को register करके अस्तित्व में नहीं ला सकता। +* आधी class इनकारों से भरी है। `OAuthAuthorizationServerProvider` **पूरा** authorization server है, इसलिए वह authorization-code flow भी माँगता है; जो server users को sign in भी कराता है वह उन्हें सच में implement करता है, और इस वाले में ठीक एक ही दरवाज़ा है। + +!!! warning + SDK assertion को कभी decode नहीं करता: सिर्फ़ आपके deployment को पता है कि वह किस IdP पर भरोसा करता है + और वह IdP कौन सी keys publish करता है, इसलिए `exchange_identity_assertion` के अंदर की हर चीज़ पर पूरा भार टिका है। + Signature को IdP की published keys (उसकी JWKS; यहाँ वाला shared secret demo का है) से verify करें, + और [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) §3 के मुताबिक `iss` और `exp` भी। JWT header का `typ` + `oauth-id-jag+jwt` होना ज़रूरी करें; यह profile का बचाव है ताकि कोई और JWT grant बनाकर replay न किया जा सके। + `aud` आपका अपना issuer हो, यह ज़रूरी करें। ID-JAG का `client_id` claim उसी client के बराबर हो जिसे + handler ने authenticate किया, और उसका `resource` claim किसी ऐसे resource का नाम ले जिसे आप सच में serve करते हैं, + यह ज़रूरी करें। `jti` को assertion के `exp` तक track करें ताकि वह एक ही बार स्वीकार हो। और दिए गए scopes, + और सबसे बढ़कर जारी किए गए token का `resource`, validated ID-JAG से लें, request से कभी नहीं: + `params.resource` वही है जो client ने type किया। Processing के पूरे नियम + [Enterprise-Managed Authorization specification](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization) में हैं। + +ख़राब assertion को `TokenError("invalid_grant", ...)` से reject करें। इस flow का दूसरा error code `invalid_target` है: जो ID-JAG किसी ऐसे resource का नाम ले जिसे आप serve नहीं करते, उसे इसी से मना किया जाता है, और यही इस server को किसी और के resource के लिए tokens बनाने से रोकता है। और दिए गए scopes ID-JAG के `scope` claim से आते हैं (जिस assertion में यह न हो उसे भी मना किया जाता है); आपका server शायद इसकी जगह user के groups map करे। + +और ध्यान दें कि लौटाए गए `OAuthToken` में क्या नहीं है: refresh token। IdP यह तय करके कि अगला ID-JAG जारी करना है या नहीं, तय करता है कि इस user की पहुँच कब तक बनी रहे। यहाँ बना refresh token वह फ़ैसला चुपचाप वापस सौंप देता। + +!!! info + जो server अब भी `auth_server_provider=` से अपना authorization server embed करता है, वह + `AuthSettings(identity_assertion_enabled=True)` के ज़रिए इसी code तक पहुँचता है। **[Authorization](../run/authorization.md)** समझाता है कि नए + servers को वहाँ से शुरू क्यों नहीं करना चाहिए। + +!!! check + इस page की दोनों files को आपस में जोड़ दें और पूरा grant बस एक `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"} + ``` + + न `/authorize`, न `/register`, न protected-resource-metadata fetch। Wire पर सिर्फ़ ये requests हैं: + वह जिस पर `401` आया, well-known fetch, यह exchange, और फिर bearer लगा हुआ साधारण + MCP traffic। और जो `sub` आपके validator ने ID-JAG से पढ़ा, tool के अंदर + `get_access_token().subject` ठीक वही बताता है। + +### इसे आज़माएँ {#try-it} + +SDK repository में `examples/stories/identity_assertion/` यही page असल में चलता हुआ है: वही `exchange_identity_assertion` validator, उसके tokens पर gate किया गया MCP server, एक stand-in IdP, और client, सब एक self-checking program में। `uv run python -m stories.identity_assertion.client --http` पूरा exchange चलाता है और assert करता है कि जिस user का नाम IdP ने लिया, tool को वही user दिखता है। + +## सारांश {#recap} + +* [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) यह फ़ैसला end user के बजाय enterprise identity provider को करने देता है कि client किन MCP servers तक पहुँच सकता है। IdP उस फ़ैसले को sign करके **ID-JAG** में डाल देता है। +* ID-JAG हासिल करना **आपके IdP** के सामने [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) token exchange है, और SDK इसे नहीं करता। उसे MCP authorization server के सामने पेश करना [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) `jwt-bearer` grant है, और SDK उसके दोनों पक्ष करता है। +* `IdentityAssertionOAuthProvider` एक और `httpx2.Auth` है: pre-registered confidential client, pin किया गया `issuer`, और एक `assertion_provider(audience, resource)` callback। न browser, न registration, न refresh token। +* Authorization server कभी resource server से discover नहीं होता। `issuer` को ठीक उसी string पर configure करें जो उसका metadata document देता है; तुलना एक-एक character की होती है। +* Server की तरफ़, `identity_assertion_enabled=True` और `exchange_identity_assertion`। SDK client को authenticate करता है और grant को gate करता है; ID-JAG validate करना पूरी तरह आपका काम है, और जारी किया गया token ID-JAG के `resource` से बँधा होता है, request के नहीं। + +इकलौता पक्ष जिसे इस page ने कभी नहीं छुआ, वह MCP server है। अभी-अभी बनाए गए token के साथ वह जो करता है, वह **[Authorization](../run/authorization.md)** में पहले से कर ही रहा था। diff --git a/i18n/hi/pages/client/index.md b/i18n/hi/pages/client/index.md new file mode 100644 index 0000000000..16f14a10b5 --- /dev/null +++ b/i18n/hi/pages/client/index.md @@ -0,0 +1,217 @@ +--- +translation: + sections: [ebef1e7a0df854f4, a4c687d3d627d516, 8e79141fc2985342, b345dd05b9c3c7ab, 80ce41579825a6fa, 5f0fa90494de8f65, 83d10514eaa62fa5, 9190555aa39a5d28, 84a4c9d8bf14dddb, 927d71cf40b58c30] + tool: 1 +--- +# Client {#the-client} + +**`Client`** वह ज़रिया है जिससे Python program किसी MCP server से बात करता है। + +यह एक object है जिसका एक ही lifecycle है: इसे बनाएँ, `async with` में enter करें, methods call करें। protocol का हर verb (tools की सूची लेना, किसी tool को call करना, resource पढ़ना, prompt render करना) इस पर एक `async` method है जो typed result लौटाता है। + +## आपका पहला client {#your-first-client} + +```python title="client.py" hl_lines="14-18" +--8<-- "docs_src/client/tutorial001.py" +``` + +ऊपर वाला server सिर्फ़ इसलिए है ताकि connect करने के लिए कुछ हो। client वे पाँच highlighted lines हैं। + +* `Client(mcp)` को **server object ही** दिया गया है। यही in-memory transport है: न subprocess, न port, न HTTP। इस page का हर उदाहरण, और आपका लिखा हर test, इसी तरह connect करता है। +* `async with` ही **lifecycle** है। इसमें enter करते ही connect और negotiate होता है; बाहर निकलते ही disconnect। कोई `connect()` / `close()` जोड़ी नहीं है, और block खत्म होने के बाद `Client` दोबारा इस्तेमाल नहीं हो सकता। +* block के अंदर connection की जानकारी पहले से सादी properties के रूप में मौजूद है। + +### `Client` को क्या दे सकते हैं {#what-you-can-pass-to-client} + +`Client` एक positional argument लेता है और उसके type से transport तय करता है: + +* `MCPServer` (या low-level `Server`) instance: **in-process** connect होता है। +* URL string (`Client("http://localhost:8000/mcp")`): Streamable HTTP, production वाला रास्ता। +* **transport**: कोई भी चीज़ जिसे आप `async with ... as (read, write)` कर सकें, जैसे subprocess को wrap करने वाला `stdio_client(...)`। + +इस page की बाकी हर चीज़ तीनों में एक जैसी है। Headers, subprocesses, timeouts और `Transport` protocol का अपना अलग page है: **[Client transports](transports.md)**। + +### connected client पर क्या है {#whats-on-a-connected-client} + +चार read-only properties, जो block में enter करते ही भर जाती हैं: + +* `client.server_info`: server की पहचान, या `None` अगर 2026 पीढ़ी का server इसे report नहीं करता (python-sdk servers default रूप से करते हैं)। यहाँ `server_info.name` `"Bookshop"` है, और `server_info.version` वही है जो server report करता है। +* `client.server_capabilities`: server क्या कर सकता है (`tools`, `resources`, `prompts`, `completions`, ...)। जो capability server के पास नहीं है वह `None` होती है। +* `client.protocol_version`: वह protocol version जिस पर दोनों पक्ष सहमत हुए। यहाँ यह `"2026-07-28"` है। +* `client.instructions`: server की `instructions=` string, या `None` अगर उसने कोई set नहीं की। + +आपने कोई protocol version नहीं चुना। default रूप से `Client` server को probe करता है और पुराने servers पर पुराने classic handshake पर लौट आता है, इसलिए एक ही client किसी भी पीढ़ी के server के साथ काम करता है। जब इसे नियंत्रित करने की ज़रूरत हो, पूरी जानकारी **[Protocol versions](../protocol-versions.md)** में है। + +!!! tip + `client.session` अंदर का `ClientSession` है, low-level escape hatch। + इस page की किसी भी चीज़ के लिए आपको इसकी ज़रूरत नहीं पड़ेगी। + +## tools की सूची लेना {#listing-tools} + +```python title="client.py" hl_lines="15-20" +--8<-- "docs_src/client/tutorial002.py" +``` + +`list_tools()` एक `ListToolsResult` लौटाता है; tools `.tools` में हैं। हर एक वह पूरी definition है जो host किसी model को देगा: + +```python +tool.name # 'search_books' +tool.title # 'Search the catalog' +tool.description # 'Search the catalog by title or author.' +``` + +और `tool.input_schema` वह JSON Schema है जो server ने function के type hints से निकाला: + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"default": 10, "title": "Limit", "type": "integer"} + }, + "required": ["query"], + "title": "search_booksArguments" +} +``` + +UI को argument form दिखाने के लिए, और model को valid arguments बनाने के लिए, जो कुछ चाहिए वह सब इसी schema में है। + +!!! tip + `title` optional है, इसलिए किसी इंसान को tools दिखाने वाले UI को चुनना पड़ता है: `title` हो तो वही, + नहीं तो `name`। `from mcp.shared.metadata_utils import get_display_name` ठीक यही करता है, + tools, resources, resource templates और prompts के लिए। + +## tool call करना {#calling-a-tool} + +`call_tool(name, arguments)` tool चलाता है और आपको `CallToolResult` वापस देता है। + +```python title="client.py" hl_lines="26-33" +--8<-- "docs_src/client/tutorial003.py" +``` + +server का `lookup_book` एक Pydantic `Book` लौटाता है। 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 +``` + +एक return value, पढ़ने की तीन चीज़ें। हर एक को पढ़ने वाला अलग है। + +### `content`: जो model पढ़ता है {#content-what-the-model-reads} + +`content` **content blocks** की एक `list` है, और content block एक union है: `TextContent`, `ImageContent`, `AudioContent`, `ResourceLink`, या `EmbeddedResource`। एक tool कई blocks लौटा सकता है, अलग-अलग तरह के। + +इसीलिए `main` `block.text` को छूने से पहले `isinstance(block, TextContent)` से narrow करता है। ध्यान दें कि `isinstance` के बाहर कहीं `.text` नहीं है: type checker इसकी अनुमति नहीं देगा, क्योंकि `ImageContent` में `.data` है, `.text` नहीं। tool आपको क्या भेज सकता है, इस बारे में union ईमानदार है; आपका code भी होना चाहिए। + +### `structured_content`: जो आपका application पढ़ता है {#structured_content-what-your-application-reads} + +`structured_content` tool की return value JSON के रूप में है, जो tool के declared `output_schema` से मेल खाती है। न string parsing, न अंदाज़ा। + +जब दोनों मौजूद हों तो वे जानबूझकर एक ही बात दो बार कहते हैं: `content` model के लिए है, `structured_content` code के लिए। structured वाला हिस्सा कहाँ से आता है, और उसे कैसे नियंत्रित करें, यह **[Structured output](../servers/structured-output.md)** page पर है। + +### `is_error`: tool fail हुआ या नहीं {#is_error-whether-the-tool-failed} + +जो tool raise करता है वह आपके client में raise **नहीं** होता। वह `is_error=True` के साथ एक साधारण result के रूप में लौटता है। + +!!! check + `lookup_book` से `"Solaris"` माँगें (ऐसा title जो catalog में नहीं है) और function + `ValueError` raise करता है। call फिर भी सामान्य रूप से लौटता है: + + ```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 + ``` + + exception का message `content` में पहुँचा, जहाँ **model** उसे पढ़कर दोबारा कोशिश कर सकता है। यह + जानबूझकर है: tool error बातचीत का हिस्सा है, crash नहीं। `structured_content` पर भरोसा करने से + पहले हमेशा `is_error` देखें। + +!!! warning + `is_error=True` सिर्फ़ आपके अपने `raise` तक सीमित नहीं है। ऐसा tool माँगें जो server के पास है ही नहीं + (`call_tool("does_not_exist", {})`) और कुछ raise नहीं होता। आपको वही shape वापस मिलता है, + `is_error=True` और `content` में `Unknown tool: does_not_exist`। `Client` का कोई method + `MCPError` तभी raise करता है जब server result की जगह JSON-RPC **error** से जवाब दे, और + server कब क्या भेजता है यह **[errors संभालना](../servers/handling-errors.md)** में बताया गया है। + +## Resources {#resources} + +resource verbs जोड़ियों में आते हैं: सूची लेने के दो तरीके, पढ़ने का एक। + +```python title="client.py" hl_lines="22-31" +--8<-- "docs_src/client/tutorial004.py" +``` + +* `list_resources()` **concrete** resources लौटाता है, जिनका URI तय है। यहाँ: `['catalog://genres']`। +* `list_resource_templates()` **parameterised** वाले लौटाता है। यहाँ: `['catalog://genres/{genre}']`। ये दो अलग सूचियाँ हैं क्योंकि template तब तक पढ़ा नहीं जा सकता जब तक आप उसे भर न दें। +* `read_resource(uri)` एक सादा `str` URI लेता है और दोनों पर काम करता है: `"catalog://genres/poetry"` दें और server उसे template से match कर लेता है। + +`read_resource` `contents` लौटाता है, `TextResourceContents` या `BlobResourceContents` की सूची। वही तरीका जो tool content का है: `isinstance` से narrow करें, फिर `.text` (या `.blob`) पढ़ें। + +client को यह भी बताया जा सकता है कि कोई resource कब बदला। 2025 पीढ़ी के connections पर यह `subscribe_resource(uri)` / `unsubscribe_resource(uri)` है - methods की ऐसी जोड़ी जिसे `MCPServer` implement नहीं करता, इसलिए 2026-07-28 wire पर (जहाँ ये verbs अब मौजूद नहीं हैं) request का जवाब `-32601`, *Method not found* आता है। 2026 में इसकी जगह `subscriptions/listen` stream है, जिसे `MCPServer` serve **करता है** - वहाँ `server_capabilities.resources.subscribe` `True` है - और उसे `client.listen(...)` से consume करना इस section का **[Subscriptions](subscriptions.md)** page है। + +## Prompts {#prompts} + +```python title="client.py" hl_lines="15-20" +--8<-- "docs_src/client/tutorial005.py" +``` + +`list_prompts()` बताता है कि server क्या देता है और हर prompt को क्या चाहिए: + +```python +prompt.name # 'recommend' +prompt.title # 'Recommend a book' +prompt.arguments # [PromptArgument(name='genre', required=True)] +``` + +`get_prompt(name, arguments)` उसे render करता है। arguments dict `str -> str` है: prompt arguments हमेशा strings होते हैं। result `messages` है, `PromptMessage` की सूची, जिनमें हर एक का `role` और एक `content` block है: + +```python +message.role # 'user' +message.content # TextContent(type='text', text='Recommend one poetry book from the catalog and say why.') +``` + +host ये messages सीधे model को दे देता है। पूरा feature बस इतना ही है। + +## Completions {#completions} + +जिस server में completion handler हो वह user के type करते-करते prompt और resource-template arguments autocomplete कर सकता है। + +```python title="client.py" hl_lines="27-31" +--8<-- "docs_src/client/tutorial006.py" +``` + +* `ref` बताता है कि आप **कौन-सा** prompt या template भर रहे हैं: `PromptReference` या `ResourceTemplateReference`। +* `argument` `{"name": ..., "value": ...}` है: argument और user ने अब तक जो type किया है। + +जवाब `result.completion.values` में है। `"p"` type करें और server `['poetry']` लौटाता है। server वाला पक्ष, और handler पहले से भरे **बाकी** arguments का इस्तेमाल अपने सुझाव कम करने के लिए कैसे करता है, यह **[Completions](../servers/completions.md)** page पर है। + +## Pagination {#pagination} + +हर `list_*` method एक `cursor=` keyword लेता है और हर result में `next_cursor` होता है। जब `next_cursor` `None` हो, आपके पास सब कुछ है। + +```python title="client.py" hl_lines="22-30" +--8<-- "docs_src/client/tutorial007.py" +``` + +यह loop हर server के साथ सही है। `MCPServer` सब कुछ एक ही page में लौटाता है, इसलिए `next_cursor` `None` होता है और loop एक बार चलता है, यही वजह है कि ज़्यादातर code इसे कभी लिखता ही नहीं। जो servers सच में page करते हैं, और cursors जिन नियमों का पालन करते हैं, वे **[Pagination](../advanced/pagination.md)** में हैं। + +## tests में {#in-tests} + +बिना process और बिना port वाला `Client(mcp)` अपने आप में server के लिए test harness है। + +इसी के लिए एक constructor flag बना है: `Client(mcp, raise_exceptions=True)`। इसका असर सिर्फ़ in-memory connections पर होता है, और **[Testing](../get-started/testing.md)** वह page है जो इसे समझाता है और इसके चारों ओर पूरा pattern बनाता है। + +## सारांश {#recap} + +* `Client(x)` server object से in-memory connect होता है, URL string से Streamable HTTP पर, और बाकी किसी भी चीज़ से transport के ज़रिए। +* `async with` ही पूरा lifecycle है। इसके अंदर `server_capabilities` और `protocol_version` पहले से भरे होते हैं; server दे तो `server_info` और `instructions` भी। +* `list_tools()` आपको हर tool का `name`, `title`, `description` और `input_schema` देता है। +* `call_tool()` model के लिए `content`, आपके code के लिए `structured_content`, और `is_error` लौटाता है। raise करने वाला tool एक result है, exception नहीं। +* `content` block types का union है; पढ़ने से पहले `isinstance` से narrow करें। +* `list_resources` / `list_resource_templates` / `read_resource`, `list_prompts` / `get_prompt`, और `complete` बाकी verbs पूरे करते हैं। +* हर `list_*` `cursor=` लेता है; `next_cursor` के `None` होने तक loop करें। + +server *client* से जो चीज़ें माँग सकता है, और आप उनका जवाब कैसे देते हैं, वह **[Client callbacks](callbacks.md)** है। diff --git a/i18n/hi/pages/client/oauth-clients.md b/i18n/hi/pages/client/oauth-clients.md new file mode 100644 index 0000000000..51526eb208 --- /dev/null +++ b/i18n/hi/pages/client/oauth-clients.md @@ -0,0 +1,152 @@ +--- +translation: + sections: [c6899d3892bd9fa0, 79372cff3cc48a88, 63878d29e87c3e73, 13175843d3588af4, e7e2b9fd516f77de, 758f06399b513c1f, a05d7278487d610b] + tool: 1 +--- +# OAuth clients {#oauth-clients} + +कुछ MCP servers सुरक्षित होते हैं। उन्हें बिना token के request भेजें तो जवाब `401 Unauthorized` आता है। + +token पाने का तरीका **`OAuthClientProvider`** है। यह कोई MCP object है ही नहीं। यह `httpx2.Auth` है, "हर request के साथ कुछ करो" वाला httpx2 का standard hook। इसे `httpx2.AsyncClient` पर लगाएँ, वह client Streamable HTTP transport को दें, और इसके बारे में सोचना बंद कर दें। + +यह page client वाला हिस्सा है। अपने server से token की माँग करवाना **[Authorization](../run/authorization.md)** में है। + +## Provider {#the-provider} + +```python title="client.py" hl_lines="44-54" +--8<-- "docs_src/oauth_clients/tutorial001.py" +``` + +आप इसे चार चीज़ें देते हैं: + +* `server_url`: वह MCP endpoint जिससे आप जुड़ रहे हैं। बाकी सब कुछ provider इसी से खोज लेता है। +* `client_metadata`: वही जो आप किसी authorization server के "register an application" form में भरते। +* `storage`: जहाँ दो runs के बीच tokens रहते हैं। +* `redirect_handler` और `callback_handler`: वे दो पल जिनमें कोई इंसान शामिल होता है। + +file में और कहीं OAuth का ज़िक्र नहीं है। `main()` को कभी कोई token दिखता ही नहीं। + +### Client metadata {#client-metadata} + +`OAuthClientMetadata` असली [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591) registration document है, Pydantic model के रूप में। + +आप तीन fields भरते हैं। बाकी defaults भर देते हैं: `grant_types` पहले से `["authorization_code", "refresh_token"]` है और `response_types` पहले से `["code"]` है, ठीक वही flow जो यह provider चलाता है। + +!!! check + चूँकि यह Pydantic model है, यह **network पर एक भी byte जाने से पहले** validate करता है। + `redirect_uris` छोड़ दें तो construction वहीं के वहीं `ValidationError` के साथ fail हो जाता है, + जो field का नाम बताता है: + + ```text + redirect_uris + Field required [type=missing, input_value={'client_name': 'Bookshop Agent'}, input_type=dict] + ``` + + न कोई browser खुला, न authorization server पर कोई अधूरी registration पीछे छूटी। + +### Token storage {#token-storage} + +**`TokenStorage`** चार async methods वाला `Protocol` है। आपको किसी से inherit नहीं करना; methods लिख दें और कोई भी class token store बन जाती है: + +* `get_tokens` / `set_tokens` `OAuthToken` रखते हैं: access token, refresh token, expiry, scope। +* `get_client_info` / `set_client_info` वह `OAuthClientInformationFull` रखते हैं जो authorization server ने तब जारी किया था जब provider ने आपको register किया, आपके `client_id` समेत। + +ऊपर वाला in-memory version काम करता है। यह process के ख़त्म होते ही सब कुछ भूल भी जाता है, इसलिए अगला run पूरी प्रक्रिया फिर से दोहराता है। इसे किसी file या अपने platform के keyring में persist करें और अगला run चुपचाप चल जाता है। + +!!! tip + सिर्फ़ tokens नहीं, `client_info` भी store करें। provider पहली बार तब dynamically register करता है + जब उसे कोई stored `client_info` नहीं मिलता। इसे फेंक दें तो हर run पर एक नई registration बनती है। + +### दो handlers {#the-two-handlers} + +authorization code flow को इंसान की ज़रूरत ठीक एक बार पड़ती है: किसी को sign in करके "allow" पर click करना होता है। + +* **`redirect_handler`** को पूरी तरह बने हुए authorization URL के साथ await किया जाता है। `client_id`, `redirect_uri`, `state` और PKCE challenge उसमें पहले से मौजूद हैं। आपका काम बस इतना है कि browser को वहाँ पहुँचाएँ। desktop app `webbrowser.open` call करता है; यह file उसे print कर देती है। +* उसके बाद **`callback_handler`** await होता है। यह तब तक इंतज़ार करता है जब तक user वापस आपके `redirect_uri` पर नहीं पहुँच जाता, और उस redirect के query parameters को `AuthorizationCodeResult` के रूप में लौटाता है। + +असली client `input()` call करने के बजाय redirect URI पर एक छोटा local HTTP server चलाता है। आकार बिल्कुल वही है: redirect हों, और `code`, `state` व `iss` वापस दें। + +!!! warning + `state` और `iss` को ठीक वैसे ही आगे दें जैसे वे आए थे। provider `state` की तुलना उससे करता है + जो उसने खुद बनाया था, और `iss` की तुलना खोजे गए issuer से, और मेल न खाने पर मना कर देता है। + यही CSRF और server-mix-up से बचाव हैं। + +### `Client` में {#into-the-client} + +`main()` देखें। provider **httpx2 client** पर जाता है, httpx2 client `streamable_http_client(url, http_client=...)` में जाता है, और वह transport `Client` में जाता है। + +`streamable_http_client` में `auth=` keyword नहीं है। HTTP स्तर की हर चीज़ (auth, headers, timeouts, proxies) उस `httpx2.AsyncClient` पर होती है जो आप लाते हैं। यह layering **[Client transports](transports.md)** में है। + +## Provider आपके लिए क्या करता है {#what-the-provider-does-for-you} + +जब `Client` पहली बार request भेजता है, server `401` लौटाता है। provider कमान संभाल लेता है: + +1. **Discovery.** यह `WWW-Authenticate` header पढ़ता है, `/.well-known/oauth-protected-resource` से server का Protected Resource Metadata लाता है, पता करता है कि कौन सा authorization server इस resource की रक्षा करता है, और **उस** server का metadata लाता है। +2. **Registration.** storage में कुछ नहीं है? यह आपके `OAuthClientMetadata` के साथ आपको dynamically register करता है और नतीजा store कर लेता है। +3. **Authorization.** यह PKCE pair और `state` बनाता है, authorization URL तैयार करता है, आपके `redirect_handler` को await करता है, फिर code के लिए आपके `callback_handler` को await करता है। +4. **Exchange.** यह code के बदले `OAuthToken` लेता है, उसे store करता है, और आपकी मूल request को `Authorization: Bearer ...` के साथ दोबारा भेजता है। + +उसके बाद यह शांत रहता है। tokens storage से आते हैं, expire हुआ access token refresh token से refresh हो जाता है, और सिर्फ़ तब जब इनमें से कुछ काम नहीं करता, यह flow फिर से चलाता है। + +आपने इसमें से कुछ नहीं लिखा। दो keyword arguments बचते हैं (`client_metadata_url` और `validate_resource_url`), और इस file को दोनों में से किसी की ज़रूरत नहीं। `client_metadata_url` जानने लायक है; इसका अपना section नीचे है। + +### इसे आज़माएँ {#try-it} + +इन docs के ज़्यादातर उदाहरण आप in-memory `Client(server)` से जाँच सकते हैं। यह नहीं: इस flow का पूरा मतलब ही HTTP `401` है, और in-memory client व उसके server के बीच कोई HTTP होता ही नहीं। + +repository में live version मौजूद है। `examples/servers/simple-auth/` एक standalone authorization server और एक protected MCP server चलाता है; `examples/clients/simple-auth-client/` इसी page का client है, छोटी CLI में बढ़ा हुआ। उसकी README में दो commands हैं: servers शुरू करें, client को उनके सामने चलाएँ, और चारों चरण अपनी आँखों के सामने होते देखें। + +## Client ID Metadata Documents {#client-id-metadata-documents} + +spec का 2026-07-28 revision dynamic client registration को **Client ID Metadata Documents** (CIMD) के पक्ष में deprecated कर देता है। जिस भी authorization server से मिले उस पर नई registration POST करने के बजाय, आपका client अपने बारे में एक JSON document किसी स्थिर HTTPS URL पर publish करता है, और वही URL उसका `client_id` **है**। authorization server वह document लाता है; provider उसे कभी छूता तक नहीं। + +SDK पहले से इसे समझता है: provider बनाते समय URL को `client_metadata_url=` के रूप में दें। जब authorization server का metadata `client_id_metadata_document_supported: true` बताता है, provider `/register` request को पूरी तरह छोड़ देता है: URL `client_id` बनकर flow में जाता है, और कोई `client_secret` नहीं होता। जब server इसे नहीं बताता (ज़्यादातर अभी नहीं बताते), या आप URL देते ही नहीं, तो provider **चुपचाप** dynamic registration पर लौट आता है, और ऊपर बताया सब कुछ ठीक वैसे ही काम करता है। stored `client_info` अब भी दोनों पर भारी पड़ती है। + +URL HTTPS होना चाहिए और उसका path root न हो; इसके अलावा कुछ भी construction पर `ValueError` है, किसी network गतिविधि से पहले। साथ आने वाला `examples/clients/simple-auth-client/` इसे `MCP_CLIENT_METADATA_URL` environment variable के रूप में लेता है। + +## Machine to machine {#machine-to-machine} + +रात को चलने वाला job, CI का कोई step, कोई दूसरी service। न browser है, न "allow" पर click करने वाला कोई। यही **client credentials** grant है: आपके पास पहले से `client_id` और `client_secret` हैं, और token endpoint ही पूरा flow है। + +`ClientCredentialsOAuthProvider` वही `httpx2.Auth` है, बस इंसान के बिना: + +```python title="client.py" hl_lines="4 27-33" +--8<-- "docs_src/oauth_clients/tutorial002.py" +``` + +क्या बदला: + +* न `OAuthClientMetadata`, न handlers। आप `client_id` और `client_secret` देते हैं; provider उनके इर्द-गिर्द एक न्यूनतम `client_credentials` registration बनाता है और dynamic registration पूरी तरह छोड़ देता है। +* `scope` space से अलग की गई string है, OAuth का wire format। +* आगे का सब कुछ बिल्कुल वही है: वही `TokenStorage`, वही `httpx2.AsyncClient(auth=...)`, वही `streamable_http_client`। + +default रूप से secret token request पर HTTP Basic auth के रूप में जाता है (`client_secret_basic`)। उसे form body में डालने के लिए `token_endpoint_auth_method="client_secret_post"` दें। कुछ authorization servers दोनों में से सिर्फ़ एक ही स्वीकार करते हैं। + +!!! tip + `client_secret` को environment या किसी secret manager से पढ़ें, source control से कभी नहीं। + +!!! info + एक और provider `mcp.client.auth.extensions.client_credentials` में रहता है: + **`PrivateKeyJWTOAuthProvider`**, उन clients के लिए जो shared secret के बजाय JWT से + authenticate करते हैं (`private_key_jwt`, key-pair और workload-identity वाला रूप)। यह उसी + pattern पर चलता है: एक बनाएँ, `auth=` पर लगाएँ। उसी module में + `SignedJWTParameters` और `static_assertion_provider` भी हैं, दो helpers जो इसका assertion बनाते हैं। + +बिना इंसान वाली एक और स्थिति है: client किसी enterprise का है जिसका identity provider, न कि user, तय करता है कि वह किन MCP servers तक पहुँच सकता है। वह अलग grant है, अपने trust model और अपने page के साथ, **[Identity assertion](identity-assertion.md)**। + +## जब यह fail होता है {#when-it-fails} + +जब OAuth flow में गड़बड़ होती है, provider `mcp.client.auth` से `OAuthFlowError` raise करता है। इसके दो subclasses हैं। `OAuthRegistrationError` का मतलब है कि registration से ऐसा client नहीं मिला जिसे आप इस्तेमाल कर सकें: authorization server ने आपको register करने से मना कर दिया, या register तो किया लेकिन ऐसे credentials के साथ जो यह flow इस्तेमाल नहीं कर सकता (उदाहरण के लिए कोई authentication method जिसे यह implement नहीं करता)। `OAuthTokenError` का मतलब है कि token नहीं मिल सका: token endpoint ने मना कर दिया, या किसी stored client record में ऐसा authentication method है जिसे यह client लागू नहीं कर सकता, जिसकी report token request बनाते समय होती है, भेजी नहीं जाती। एक `except OAuthFlowError:` discovery, registration, authorization और exchange, सबको cover करता है। + +हर चीज़ flow error नहीं होती। network अब भी fail हो सकता है; वे साधारण `httpx2` exceptions हैं और बिना छुए आगे निकल जाते हैं। + +## सारांश {#recap} + +* `OAuthClientProvider` `httpx2.Auth` है। इसे `httpx2.AsyncClient` पर लगाएँ, उसे `streamable_http_client(url, http_client=...)` को दें, और `Client` को कभी पता नहीं चलता कि OAuth हुआ। +* आप चार चीज़ें देते हैं: server URL, एक `OAuthClientMetadata`, एक `TokenStorage`, और redirect/callback handler की जोड़ी। +* `TokenStorage` `Protocol` है: चार async methods, कोई base class नहीं। tokens के साथ-साथ `client_info` भी persist करें। +* discovery, registration (dynamic, या **Client ID Metadata Document** के ज़रिए), PKCE, `state` और `iss` की जाँच, और token refresh provider का काम हैं, आपका नहीं। +* `ClientCredentialsOAuthProvider` बिना इंसान वाला version है: `client_id` + `client_secret`, न handlers, न browser। +* हर OAuth failure `OAuthFlowError` है; `OAuthRegistrationError` और `OAuthTokenError` इसके subclasses हैं। + +इस handshake का दूसरा हिस्सा, अपने **server** से token की माँग करवाना, **[Authorization](../run/authorization.md)** में है। diff --git a/i18n/hi/pages/client/session-groups.md b/i18n/hi/pages/client/session-groups.md new file mode 100644 index 0000000000..f06c315b01 --- /dev/null +++ b/i18n/hi/pages/client/session-groups.md @@ -0,0 +1,87 @@ +--- +translation: + sections: [09c857a25a9dc37a, 43bc6a76a243a50e, 0a716022a88768df, 4b7f78042bfcfff7, c112662e61b03315, 58974ba1f489a8b4, d18adbdbb835ea73] + tool: 1 +--- +# Session groups {#session-groups} + +`Client` सिर्फ़ एक server से जुड़ता है। असली applications को अक्सर कई servers चाहिए होते हैं (search server, database server, कोई internal API) और फिर हर एक के लिए अलग connection और अलग tools की सूची संभालनी पड़ती है। + +**`ClientSessionGroup`** एक अकेला object है जिसमें कई connections रहते हैं और जो उन सबकी expose की गई हर चीज़ को एक ही view में मिला देता है। + +## दो servers {#two-servers} + +दो साधारण servers से शुरू करें। इनका आपस में कोई लेना-देना नहीं है, इसलिए स्वाभाविक रूप से दोनों ने अपने tool का नाम `search` रखा: + +```python title="library_server.py" hl_lines="7" +--8<-- "docs_src/session_groups/tutorial001.py" +``` + +```python title="web_server.py" hl_lines="7" +--8<-- "docs_src/session_groups/tutorial002.py" +``` + +## एक group {#one-group} + +`ClientSessionGroup` बनाएँ और हर server के लिए एक बार **`connect_to_server`** call करें: + +```python title="client.py" hl_lines="10-12" +--8<-- "docs_src/session_groups/tutorial003.py" +``` + +* `connect_to_server` transport parameters लेता है, server object नहीं: subprocess शुरू करने के लिए `StdioServerParameters` (`mcp` से), या पहले से किसी URL पर सुन रहे server के लिए `StreamableHttpParameters` / `SseServerParameters` (`mcp.client.session_group` से)। +* `group.tools` हर जुड़े हुए server के tools का `dict[str, Tool]` है। `group.resources` और `group.prompts` का आकार भी यही है। +* `group.call_tool(name, arguments)` नाम खोजता है, वह session ढूँढता है जिसका यह tool है, और call आगे भेज देता है। आपको कभी बताना नहीं पड़ता कि कौन सा server। + +!!! check + `client.py` को दोनों servers के साथ रखें और चलाएँ। दूसरा `connect_to_server` मना कर देता है: + + ```text + mcp.shared.exceptions.MCPError: {'search'} already exist in group tools. + ``` + + यह `MCPError` है, जो दूसरे server से कुछ भी register होने से पहले raise होता है। नाम **पूरे** + group में unique होना ज़रूरी है, और जिन दो servers पर आपका नियंत्रण नहीं है वे कभी न कभी टकराएँगे ही। + +## `component_name_hook` {#component_name_hook} + +इसे servers पर नहीं, group पर ठीक किया जाता है। `(name, server_info)` लेने वाला function pass करें, और group उसे हर उस नाम पर चलाता है जिसे वह register करता है: + +```python title="client.py" hl_lines="7-8 15" +--8<-- "docs_src/session_groups/tutorial004.py" +``` + +इसे फिर चलाएँ। `print(sorted(group.tools))` अब दोनों दिखाता है: + +```text +['Library.search', 'Web.search'] +``` + +* **key** आपकी है। `by_server` ने इसे `server_info.name` से बनाया, यानी वह नाम जिससे हर `MCPServer(...)` बनाया गया था। +* अंदर का `Tool` जस का तस है: `group.tools["Web.search"].name` अब भी `"search"` है, और `call_tool` wire पर यही नाम भेजता है। prefix आपके process से बाहर कभी नहीं जाता। +* बात सिर्फ़ tools की नहीं है। library का `hours` resource `Library.hours` नाम से register होता है। + +!!! tip + hook **हर** server के **हर** नाम पर चलता है, सिर्फ़ टकराव पर नहीं: सिर्फ़-टकराव-पर-prefix + जैसा कोई mode नहीं है। एक scheme चुनें और उसे हर जगह लागू होने दें। + +## servers जोड़ना और हटाना {#adding-and-removing-servers} + +`connect_to_server` वह `ClientSession` लौटाता है जो उसने खोला। अगर कभी उस server को हटाना हो तो इसे संभालकर रखें: `await group.disconnect_from_server(session)` उसके tools, resources और prompts group से हटा देता है। + +अगर आपके पास पहले से जुड़ा हुआ `ClientSession` है (`Client.session` ऐसा ही एक है), तो नया transport खोलने के बजाय उसे `await group.connect_with_session(server_info, session)` को सौंप दें। यह उसी तरह aggregate करता है। group कभी ऐसा session बंद नहीं करता जो उसने खुद नहीं खोला। `server_info` component prefixes के लिए server का नाम देता है; 2026 पीढ़ी के connection पर `client.server_info` `None` हो सकता है (identity वैकल्पिक है), इसलिए उस स्थिति में अपना `Implementation(name=..., version=...)` pass करें। + +## Classic handshake {#the-classic-handshake} + +`ClientSessionGroup` `Client` पर नहीं, `ClientSession` पर बना है। हर `connect_to_server` classic `initialize` handshake चलाता है। यह **[Protocol versions](../protocol-versions.md)** में बताया गया `server/discover` probe कभी नहीं भेजता। हर MCP server वह handshake समझता है, इसलिए इससे किसी के साथ भी compatibility नहीं खोती; इसका मतलब बस इतना है कि group ऐसे server तक भी पुराने, धीमे रास्ते से पहुँचता है जो बेहतर कर सकता था। + +## सारांश {#recap} + +* `ClientSessionGroup` कई server connections रखता है और उनके tools, resources और prompts को एक-एक `dict` में मिला देता है। +* हर server के लिए `connect_to_server(params)`। यह transport parameters लेता है, कभी वह server object या URL नहीं जो `Client` लेता है। +* `group.call_tool(name, arguments)` आपके लिए call को उस server तक पहुँचाता है जिसका वह tool है। +* नाम पूरे group में unique होने ज़रूरी हैं; `search` tool वाले दो servers अपने आप साथ नहीं रह सकते। +* `component_name_hook=` हर register किए गए नाम को फिर से लिखता है। dict key बदलती है, wire पर जाने वाला नाम नहीं। +* `connect_with_session` पहले से आपके पास मौजूद session जोड़ता है; `disconnect_from_server` एक session हटाता है। + +group कौन सा handshake बोलता है (और `Client` किस तेज़ handshake को तरजीह देता है), यही **[Protocol versions](../protocol-versions.md)** का विषय है। diff --git a/i18n/hi/pages/client/subscriptions.md b/i18n/hi/pages/client/subscriptions.md new file mode 100644 index 0000000000..37eb3b437f --- /dev/null +++ b/i18n/hi/pages/client/subscriptions.md @@ -0,0 +1,91 @@ +--- +translation: + sections: [8f9558e57f29eee1, a88c587739e0465c, 46ebfd5b325ed041, 4d10b00b57ce4bd9, 2cdb0edd1f59b3e2] + tool: 1 +--- +# Subscriptions {#subscriptions} + +server का catalog स्थिर नहीं होता। tools runtime पर आते हैं, और किसी resource URI के पीछे का content बदलता रहता है। client को इसकी ख़बर `client.listen(...)` से मिलती है: एक `subscriptions/listen` request, जिसका response ही stream **है**। यह खुला रहता है और वे change notifications लाता है जो client ने माँगे थे। + +यह page client वाला सिरा है: stream खोलना, उस पर अपने main flow के साथ-साथ नज़र रखना, और उसके ख़त्म होने को संभालना। बदलाव publish करना, filter करना और method को serve करना server की तरफ़ की कहानी है, जो **आपके handler के अंदर** section के **[Subscriptions](../handlers/subscriptions.md)** page में बताई गई है। यहाँ के उदाहरण वहीं बनाए गए sprint-board server से बात करते हैं। + +## stream पर नज़र रखना {#watching-the-stream} + +subscription बस एक context manager है। इसमें enter करते ही request भेजी जाती है, जिसमें आपके keyword arguments subscription filter बनते हैं, और server के acknowledgment का इंतज़ार होता है, इसलिए block शुरू होने तक stream live हो चुका होता है। + +```python title="client.py" hl_lines="15 18 28" +--8<-- "docs_src/subscriptions/tutorial003.py" +``` + +iterate करने पर चार typed events मिलते हैं: `ToolsListChanged`, `PromptsListChanged`, `ResourcesListChanged`, और `ResourceUpdated(uri=...)`। + +event यह बताता है कि **क्या** बदला, कभी यह नहीं कि **कैसे**। इसीलिए `follow_board` `read_resource` और `list_tools` को call करता है: event दोबारा fetch करने का इशारा है। कौन-सा resource बदला, यह मान लेने के बजाय `event.uri` पढ़ें: filter में कई URI हो सकते हैं, और server उनमें से किसी एक के sub-resource पर बदलाव की ख़बर दे सकता है। + +consume होने का इंतज़ार कर रहे duplicate events मिलकर एक हो जाते हैं, और दोबारा fetch करने पर आपको फिर भी मौजूदा state ही मिलता है। सिर्फ़ एक जैसे events ही मिलते हैं: अलग-अलग URI के दो `ResourceUpdated` दो events हैं। + +handle की दो और properties: + +* `sub.honored` वह filter है जिसे server ने acknowledge किया: एक `SubscriptionFilter` जिसमें आपके दिए fields हैं, जिन्हें attributes की तरह पढ़ा जाता है (`sub.honored.prompts_list_changed`)। `MCPServer` आपके माँगे हर kind को honor करता है, इसलिए वह आपकी request ज्यों की त्यों वापस लौटा देता है। कम kinds support करने वाला server कम acknowledge करता है, और honor किया गया kind फिर भी शायद कभी fire न हो। server पूरी request को acknowledge करने के बजाय उसे ठुकरा भी सकता है (server page पर [कौन देख सकता है, यह तय करना](../handlers/subscriptions.md#deciding-who-may-watch) देखें), जो request के error के रूप में सामने आता है। +* `sub.subscription_id` listen request की id है, वही जो इस stream के हर frame पर लगी होती है। एक साथ कई subscriptions खुले हो सकते हैं, और हर एक अपनी id से demultiplex होता है। + +## बिना block किए नज़र रखना {#watching-without-blocking} + +`follow_board` तब तक चलता है जब तक server stream बंद न कर दे, जो शायद कभी न हो, इसलिए अकेले चलाने पर यह आपके पूरे program पर क़ब्ज़ा कर लेता है। असली clients को watcher main flow के **साथ-साथ** चाहिए: agent tools call करता रहे और watcher cache या UI को ताज़ा रखे। + +पहले subscription खोलें, फिर watcher शुरू करें और अपने काम में लग जाएँ। + +=== "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` पहले उदाहरण से `BOARD` और `read_board` import करता है, जिसे यह repo + `tutorial003.py` नाम से रखता है। अगर आप rendered files को साथ-साथ `client.py` और `app.py` नाम से save करते हैं, + तो इसके बजाय `from client import BOARD, read_board` लिखें। नीचे दिया `watch.py` उदाहरण + भी `read_board` को इसी तरह import करता है। + +असली बात क्रम की है। कुछ भी replay नहीं होता, इसलिए stream बनने से पहले publish हुआ event छूट जाता है। `client.listen(...)` में enter करना acknowledgment का इंतज़ार करता है, इसलिए उस पल के बाद का हर बदलाव आपके watcher तक पहुँचता है, और block के अंदर लिया गया snapshot एक भी बदलाव नहीं चूक सकता। + +खुले stream के साथ-साथ उसी client पर requests बेरोक चलती हैं, चाहे watcher task से हों या किसी और से। चूँकि consume न हुए **duplicate** events मिलकर एक हो जाते हैं, इसलिए व्यस्त main flow में तीन के बजाय शायद एक ही refetch हो। अलग-अलग events नहीं मिलते: कई URI वाला filter हर URI के लिए एक pending event queue में रखता है। + +नज़र रखना बंद करने के लिए block से बाहर निकलें: कोई `unsubscribe` call नहीं है। block वाले task को cancel करने से यह अपने आप हो जाता है, और SDK listen request को उसी तरह cancel करता है जैसा transport चाहता है: Streamable HTTP पर, उस request का stream बंद करके। app के पूरे जीवनकाल तक चलने वाला watcher कभी अपने आप नहीं लौटता, इसलिए shutdown पर उसे, या उसके task group के scope को, cancel करें। + +## streams का ख़त्म होना {#streams-end} + +stream दो में से किसी एक तरह से ख़त्म होता है, और दोनों साधारण control flow हैं। server का graceful close `async for` को ख़त्म कर देता है; अचानक टूटने पर `SubscriptionLost` raise होता है। + +यह फ़र्क़ सिर्फ़ diagnosis के काम का है, आगे क्या करना है उसमें कोई फ़र्क़ नहीं: stream जा चुका है, कुछ replay नहीं हुआ, और जिस watcher को अब भी परवाह है वह दोबारा listen करता है और दोबारा fetch करता है। + +```python title="watch.py" hl_lines="16 20" +--8<-- "docs_src/subscriptions/tutorial005.py" +``` + +servers अपने कारणों से streams को gracefully बंद करते हैं, जिनमें ऐसे subscriber को हटाना भी शामिल है जिसका backlog बहुत बड़ा हो गया हो, इसलिए साफ़ अंत नज़र रखना बंद करने का इशारा नहीं है। दोबारा listen करने से पहले back off करें। + +`SubscriptionLost` का एक local कारण भी है। client ज़्यादा से ज़्यादा 1024 बिना consume हुए events रखता है, और जो consumer इतना पीछे रह जाए वह बिना सीमा के बढ़ते जाने के बजाय subscription खो देता है। `async for` की body छोटी रखें और धीमा काम कहीं और करें। + +`keep_following` सिर्फ़ `SubscriptionLost` को catch करता है। `listen()` में enter करने पर `MCPError` (connection fail हुआ, या server यह method serve नहीं करता), `TimeoutError` (कोई acknowledgment नहीं आया), और `ListenNotSupportedError` (2026 से पहले का connection) भी raise हो सकते हैं। तय करें कि इनमें से किन पर आपका watcher retry करे: आख़िरी वाला कभी ठीक नहीं होता। + +## सारांश {#recap} + +* `async with client.listen(...)` में enter करें; enter करना acknowledgment का इंतज़ार करता है, इसलिए उसके बाद publish हुआ कुछ भी नहीं छूटता। +* `async for event in sub` से iterate करें। events दोबारा fetch करने के इशारे हैं, payload कभी नहीं। +* subscription खोलें, फिर watcher को task के रूप में चलाएँ, और tool calls उसके साथ-साथ चलते रहते हैं। +* साफ़ अंत loop को रोक देता है; टूटने पर `SubscriptionLost` raise होता है। दोनों ही हालात में: दोबारा listen करें, दोबारा fetch करें, पहले back off करें। +* block से बाहर निकलना ही unsubscribe है। + +इन events को publish करना, filter को सीमित करना, और एक process से आगे scale करना server की कहानी है: **[Subscriptions](../handlers/subscriptions.md)**। यही events client-side cache को भी सही बनाए रखते हैं, और अगला page **[Caching](caching.md)** है। diff --git a/i18n/hi/pages/client/transports.md b/i18n/hi/pages/client/transports.md new file mode 100644 index 0000000000..1a0a85f217 --- /dev/null +++ b/i18n/hi/pages/client/transports.md @@ -0,0 +1,128 @@ +--- +translation: + sections: [9cac816674181eb0, 0700f337babcd4dd, 2bde0dd58cdf00f5, ff7401df479af877, 3d0832f39b0d7059, d4bf7e4479637768, 05e20c0a798860e7] + tool: 1 +--- +# Client transports {#client-transports} + +हर `Client` अपने server से एक **transport** के ज़रिए बात करता है: वही चीज़ जो असल में messages ले जाती है। + +आप कभी transport को अलग से configure नहीं करते। `Client` सिर्फ़ एक positional argument लेता है और उसके type से transport तय कर लेता है। + +हर transport का **server** वाला पक्ष (`mcp.run()` क्या करता है और आप क्या deploy करते हैं) **[अपना server चलाना](../run/index.md)** में है। + +## Memory में {#in-memory} + +server object ही पास करें: + +```python title="client.py" hl_lines="14" +--8<-- "docs_src/client_transports/tutorial001.py" +``` + +न कोई subprocess, न कोई port, न wire पर कोई bytes। client और server एक ही process में दो objects हैं, और call फिर भी असली protocol layer से होकर जाती है: `search_books` ठीक वैसे ही list, validate और invoke होता है जैसे HTTP पर होता। + +इससे यह एक साथ दो काम करता है: + +* **Test harness।** इस documentation का हर उदाहरण इसी तरीके से चलाया जाता है, और **[Testing](../get-started/testing.md)** page पूरा pattern इसी के इर्द-गिर्द बनाता है। +* **Embedding API।** जो application खुद server बनाता है, उसे उसके tools call करने के लिए network hop की ज़रूरत नहीं। + +## Streamable HTTP {#streamable-http} + +URL string पास करें और आपको **Streamable HTTP** मिलता है, वह transport जिसके पीछे आप deploy करते हैं: + +```python title="client.py" hl_lines="5" +--8<-- "docs_src/client_transports/tutorial002.py" +``` + +पूरा production client बस इतना ही है। `Client` आपके लिए URL को `streamable_http_client(...)` में लपेट देता है, एक `httpx2.AsyncClient` के ऊपर जो MCP की ज़रूरत के हिसाब से configure किया गया है: `follow_redirects=True`, connect/write/pool के लिए 30 सेकंड का timeout, और 300 सेकंड का read timeout, क्योंकि server response stream को खुला रख सकता है। + +!!! check + जो `Client` आपने बनाया है वह connected **नहीं** है। बनाने से सिर्फ़ transport चुना जाता है; + उसे खोलता `async with` है। enter करने से पहले connection तक पहुँचने की कोशिश करें तो SDK साफ़ बता देता है: + + ```text + RuntimeError: Client must be used within an async context manager + ``` + + जब आपने `Client("http://...")` लिखा, तब न कुछ resolve हुआ, न fetch, न spawn। वह line मुफ़्त है। + +### अपना `httpx2.AsyncClient` लाएँ {#bring-your-own-httpx2asyncclient} + +जैसे ही आपको `Authorization` header, cookie, proxy, mTLS या कोई अलग timeout चाहिए, `httpx2.AsyncClient` खुद बनाएँ और उसे `streamable_http_client` को दें: + +```python title="client.py" hl_lines="8-14" +--8<-- "docs_src/client_transports/tutorial003.py" +``` + +दो बातें ध्यान देने लायक हैं: + +* `httpx2.AsyncClient` आपका है, इसलिए उसे enter और exit भी **आप** ही करते हैं। SDK कभी ऐसे client को बंद नहीं करता जो उसने नहीं बनाया। +* `streamable_http_client(url, http_client=...)` एक transport लौटाता है, और `Client(transport)` उसे किसी भी दूसरी चीज़ की तरह स्वीकार करता है। + +TLS पर एक बात: `httpx2` certificates को operating system के trust store ( +[`truststore`](https://pypi.org/project/truststore/) के ज़रिए) से verify करता है, किसी bundled CA list से नहीं। ऐसे environment में जहाँ +काम का system CA store न हो (कुछ minimal containers), standard `SSL_CERT_FILE`/`SSL_CERT_DIR` +environment variables set करें या अपने `httpx2.AsyncClient` को explicit `verify=ssl_context` पास करें +(पृष्ठभूमि +[`httpx` and `httpx-sse` replaced by `httpx2`](../migration.md#httpx-and-httpx-sse-replaced-by-httpx2) में है)। + +!!! warning + `streamable_http_client` पहले `headers=` और `timeout=` सीधे लेता था। अब नहीं लेता: + इसके parameters सिर्फ़ `url`, `http_client` और `terminate_on_close` हैं। आदत से `headers=` + लिख दें तो यह मिलता है: + + ```text + TypeError: streamable_http_client() got an unexpected keyword argument 'headers' + ``` + + HTTP से जुड़ी हर चीज़ अब उसी एक `httpx2.AsyncClient` पर रहती है जो आप पास करते हैं। + +!!! info + `httpx2` जाना-पहचाना `httpx` API ही रखता है, इसलिए अगर आप `httpx` जानते हैं तो यहाँ auth, + proxies, event hooks, retries और connection limits कैसे करने हैं, यह आप पहले से जानते हैं। SDK न ऊपर से कुछ जोड़ता है, न कुछ + हटाता है। OAuth भी यहीं जुड़ता है: + `httpx2.AsyncClient(auth=OAuthClientProvider(...))`। वह पूरा flow **[OAuth clients](oauth-clients.md)** में है। + +## stdio {#stdio} + +**stdio** server एक subprocess है। client उसे launch करता है, उसके stdin पर JSON-RPC लिखता है और उसके stdout से JSON-RPC पढ़ता है। desktop host आपकी machine पर server इसी तरह चलाता है: host यही code **है**, बस ऊपर एक UI के साथ, और **[असली host से जुड़ें](../get-started/real-host.md)** यही रिश्ता host की तरफ़ से, एक config file के रूप में दिखाता है। + +process को `StdioServerParameters` से बताएँ, `stdio_client` से उसे transport में बदलें, और **वही** `Client` को दें: + +```python title="client.py" hl_lines="4-8 12" +--8<-- "docs_src/client_transports/tutorial004.py" +``` + +`Client` अकेले parameters object को स्वीकार नहीं करता। `StdioServerParameters` configuration है; `stdio_client(server)` वह transport है जो उससे process spawn करना जानता है। हमेशा wrap करें। + +`async with` block से बाहर निकलने पर subprocess भी बंद हो जाता है: stdin बंद, इंतज़ार, और अटका रहे तो kill। आपको उसे खुद कभी साफ़ नहीं करना पड़ता। + +!!! warning + child आपका environment inherit **नहीं** करता। उसे एक minimal allow-list मिलती है (POSIX पर `HOME`, `LOGNAME`, + `PATH`, `SHELL`, `TERM` और `USER`) ताकि ऐसे process में कुछ भी संवेदनशील leak न हो जिसे शायद + आपने लिखा ही न हो। + + जिस server को API key चाहिए, उसे वह वहाँ नहीं मिलेगी। उसे `env=` से explicitly पास करें; वे + variables allow-list के ऊपर merge हो जाते हैं। ऊपर `BOOKSHOP_API_KEY` यही कर रहा है। + +## SSE {#sse} + +`mcp.client.sse` का `sse_client(url)` वह HTTP transport है जिसकी जगह Streamable HTTP ने ली। जो server अब भी इसे बोलता है, उससे बात करने के लिए इसे उसी तरह wrap करें, `Client(sse_client("http://localhost:8000/sse"))`, और इस पर कुछ नया न बनाएँ। + +## `Transport` protocol {#the-transport-protocol} + +`Client` के लिए ऊपर की सभी चीज़ें एक ही हैं। + +**transport** कोई भी async context manager है जो message streams का `(read, write)` जोड़ा yield करता है: औपचारिक रूप से, `mcp.client` का `Transport` protocol। `Client` अपने argument को type से resolve करता है: server object in-process जुड़ता है, `str` `streamable_http_client(url)` बन जाता है, और बाकी सब कुछ सीधे transport के रूप में enter किया जाता है। यही आख़िरी नियम वजह है कि `stdio_client(...)`, `streamable_http_client(...)` और `sse_client(...)` सब उसी एक slot में बैठते हैं, और यही वजह है कि आप अपना खुद का भी लिख सकते हैं। + +## सारांश {#recap} + +* `Client(mcp)` (server object) memory में जुड़ता है। इसे tests और embedding के लिए इस्तेमाल करें। +* `Client("http://.../mcp")` (URL) Streamable HTTP पर जुड़ता है, जो production transport है। +* Headers, auth, proxies और timeouts उस `httpx2.AsyncClient` पर होने चाहिए जो आप `streamable_http_client(url, http_client=...)` को पास करते हैं। कोई `headers=` keyword नहीं है। +* stdio है `Client(stdio_client(StdioServerParameters(...)))`, अकेला parameters object कभी नहीं। +* subprocess को allow-list वाला environment मिलता है, आपका नहीं; `env=` उसमें जोड़ता है। +* transport वह हर चीज़ है जिस पर आप `async with x as (read, write)` कर सकें। जो कुछ server object या URL नहीं है, `Client` उसे सीधे उसी protocol को सौंप देता है। +* `Client` बनाने से transport चुना जाता है। `async with` उसे खोलता है। + +transport खुल जाने के बाद दोनों पक्षों को protocol version पर सहमत होना होता है। आम तौर पर आपको इस बारे में सोचना ही नहीं पड़ता; जब पड़े, तो **[Protocol versions](../protocol-versions.md)** वह page है। diff --git a/i18n/hi/pages/deprecated.md b/i18n/hi/pages/deprecated.md new file mode 100644 index 0000000000..c6fdf3290d --- /dev/null +++ b/i18n/hi/pages/deprecated.md @@ -0,0 +1,96 @@ +--- +translation: + sections: [20541a40dbdd5980, 01262a123ad9501d, 429db5b574a2ac08, 56b2d49da412cb28, 6a1717123fe4513c] + tool: 1 +--- +# Deprecated features {#deprecated-features} + +2026-07-28 spec पाँच चीज़ों को retire करता है। SDK अब भी इनमें से हर एक को implement करता है, और अब हर एक पर **deprecation warning** लगी है। + +नीचे दी गई table हर deprecated feature का नाम, उसके हटने की वजह, और उसकी जगह किस पर build करना है, यह बताती है। + +## क्या deprecated है {#what-is-deprecated} + +| Deprecated | क्यों | इसके बजाय क्या करें | +|---|---|---| +| **Roots**: `ctx.session.list_roots()`, `client.send_roots_list_changed()`, `Client(...)` को दिया जाने वाला `list_roots_callback=` | [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) इस capability को retire करता है। | paths को साधारण tool arguments या resource URIs के रूप में लें, या `InputRequiredResult` में `ListRootsRequest` embed करें (**[Multi-round-trip requests](handlers/multi-round-trip.md)** देखें)। | +| **Server-initiated sampling**: `ctx.session.create_message()`, `Client(...)` को दिया जाने वाला `sampling_callback=` | SEP-2577 इस capability को retire करता है। | `InputRequiredResult` लौटाएँ और client को call retry करने दें (**[Multi-round-trip requests](handlers/multi-round-trip.md)** देखें)। | +| **Protocol logging**: `ctx.log()`, `ctx.debug()`, `ctx.info()`, `ctx.warning()`, `ctx.error()`, `ctx.session.send_log_message()`, `client.set_logging_level()` | SEP-2577 इस capability को retire करता है। protocol के अंदर इसकी जगह कुछ नहीं लेता। | stderr पर साधारण `import logging` (**[Logging](handlers/logging.md)** देखें)। | +| **`ping`**: `client.send_ping()` | protocol से **हटा दिया गया**, सिर्फ़ deprecated नहीं। 2026-07-28 में कोई `ping` method नहीं है। | कुछ नहीं। यह सिर्फ़ `mode="legacy"` connection पर काम करता है। | +| **Client->server progress**: `client.send_progress_notification()` | 2026-07-28 progress को सिर्फ़ server->client बनाता है। | भेजने को कुछ नहीं। आपका *server* `ctx.report_progress()` से progress report करता है (**[Progress](handlers/progress.md)** देखें)। | + +इस table से तीन बातें निकलती हैं: + +* roots, sampling और logging साथ-साथ जाते हैं। एक ही proposal, **SEP-2577**, तीनों capabilities को एक साथ deprecate करता है। +* sampling और roots की एक गहरी साझा समस्या है: ये वे जगहें हैं जहाँ **server** **client** को **request** भेजता है। यही वह पूरी दिशा है जिसे 2026-07-28 **[Multi-round-trip requests](handlers/multi-round-trip.md)** से बदलता है। जो गए हैं वे standalone RPC methods हैं (`sampling/createMessage`, `roots/list`, और push-style `elicitation/create`); `CreateMessageRequest` / `ListRootsRequest` / `ElicitRequest` payload types बचे रहते हैं, `InputRequiredResult.input_requests` में embed होकर, और client पर वे उन्हीं callbacks तक पहुँचते हैं। +* `ping` बाकियों से अलग है। protocol इसे deprecate नहीं करता, हटा देता है। SDK method अब भी warn करता है (उसका message *removed* कहता है, *deprecated* नहीं) और modern connection पर इसे call करने पर जवाब *"Method not found"* आता है। + +## Deprecated होना बस सलाह भर है {#deprecated-is-advisory} + +आज कुछ नहीं टूटता। + +ऊपर का हर method ऐसे किसी भी session पर काम करता रहता है जिसने **2025-11-25 या उससे पहले** का version negotiate किया हो। client पर `mode="legacy"` pin करें और आपको ठीक 2026 से पहले वाला व्यवहार मिलता है। wire में कोई बदलाव नहीं है और capability negotiation जस का तस है। + +बदलता यह है कि हर एक के पहली बार चलने पर आपको साफ़ दिखने वाली warning मिलती है: + +```text +MCPDeprecationWarning: The logging capability is deprecated as of 2026-07-28 (SEP-2577). +``` + +`MCPDeprecationWarning` `UserWarning` का subclass है, `DeprecationWarning` का **नहीं**। यह जानबूझकर है: Python का default filter `DeprecationWarning` को सिर्फ़ उसी code में दिखाता है जो सीधे `__main__` के रूप में चलता है, और इसी तरह libraries चीज़ें deprecate करती हैं और दो साल तक किसी को पता नहीं चलता। यह वाली हर जगह दिखती है, बिना किसी `-W` flag के। + +!!! warning + "बस सलाह" वाली बात wire पर आकर खत्म हो जाती है। sampling और roots server-से-client + *requests* हैं, और 2026-07-28 session के पास इन्हें ले जाने का कोई channel नहीं है। modern + connection पर tool के अंदर `ctx.session.create_message()` call करें तो warning फिर भी + fire होती है, और फिर send एक error के साथ fail हो जाता है: + + ```text + Cannot send 'sampling/createMessage': this transport context has no back-channel + for server-initiated requests. + ``` + + दो संकेत, इसी क्रम में। `MCPDeprecationWarning` उसी पल fire होती है जब आप method call + करते हैं, किसी भी connection पर। error वह है जो तब वापस आता है जब SDK उसे भेजने की + कोशिश करता है। ये दोनों end-to-end सिर्फ़ ऐसे `mode="legacy"` connection पर काम करते हैं + जिसके client ने matching callback register किया हो। + +## warning को चुप कराना {#silencing-the-warning} + +नए code में ऐसा न करें। + +लेकिन जिस server की आप देखरेख करते हैं और जो सच में 2026 से पहले के clients को serve करता है, उसे शांत log का पूरा हक है। पहला deprecated call चलने से पहले इस category को filter करें: + +```python +import warnings + +from mcp import MCPDeprecationWarning + +warnings.filterwarnings("ignore", category=MCPDeprecationWarning) +``` + +पूरा API बस इतना ही है। हर method के लिए अलग switch नहीं है, और आपको चाहिए भी नहीं: एक category का मतलब ही यह है कि एक line उसे चुप कराती है और एक line उसे वापस ले आती है। + +!!! check + filter को उल्टा चलाएँ और आपको मुफ़्त में regression test मिलता है। अपनी pytest + configuration की `filterwarnings` setting में `"error::mcp.MCPDeprecationWarning"` + जोड़ें और deprecated call warn करने के बजाय **raise** करता है। `old_log` नाम का tool + जो अब भी `ctx.info()` call करता है, pass होना बंद कर देता है और यह report करने लगता है: + + ```text + Error executing tool old_log: The logging capability is deprecated as of 2026-07-28 (SEP-2577). + ``` + + pytest configuration की एक line, और कोई deprecated call बिना test fail किए आपके + codebase में चुपके से वापस नहीं आ सकता। + +## सारांश {#recap} + +* 2026-07-28 spec **roots**, server-initiated **sampling**, और protocol **logging** को deprecate करता है (तीनों [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), **progress** को server-से-client तक सीमित करता है, और **`ping`** को हटा देता है। +* replacement वाला column आपको आगे का रास्ता दिखाता है: sampling और roots के लिए **[Multi-round-trip requests](handlers/multi-round-trip.md)**, logging के लिए **[Logging](handlers/logging.md)**, progress के लिए **[Progress](handlers/progress.md)**। `ping` को कुछ भी नहीं चाहिए। +* Deprecated होना बस सलाह भर है: wire में कोई बदलाव नहीं, 2026 से पहले के sessions पर सब कुछ काम करता रहता है, और आपको साफ़ दिखने वाली `MCPDeprecationWarning` मिलती है (यह `UserWarning` है, इसलिए default रूप से चालू है)। +* sampling और roots को इसके अलावा back-channel चाहिए जो 2026-07-28 session के पास नहीं है। modern connection पर ये warn करते हैं और फिर raise करते हैं। +* `warnings.filterwarnings("ignore", category=MCPDeprecationWarning)` पूरी category को चुप कराता है; pytest में `"error::mcp.MCPDeprecationWarning"` इसे test failure में बदल देता है। +* नया code इनमें से किसी पर भी नहीं बनना चाहिए। + +इन docs का बाकी हर page मौजूदा API सिखाता है। diff --git a/i18n/hi/pages/get-started/first-steps.md b/i18n/hi/pages/get-started/first-steps.md new file mode 100644 index 0000000000..89dffcdcc0 --- /dev/null +++ b/i18n/hi/pages/get-started/first-steps.md @@ -0,0 +1,144 @@ +--- +translation: + sections: [0d6c05bcbf836bf3, 59a7b14eeefc68c1, 7114d8d6daba203f, e8bbb56a98ba7bc9, 5138010f6159901c, f78da7c7c363d4c6, 220a939cab348686] + tool: 1 +--- +# पहले कदम {#first-steps} + +**[landing page](../index.md)** तेज़ी से चलता है: server लिखें, उसे चलाएँ, tool call करें। + +यह page आराम से चलता है: वे तीनों चीज़ें जो server expose कर सकता है, और रास्ते में हर चीज़ का नाम भी। + +## host, client और server {#host-client-and-server} + +तीन शब्द जो यहाँ से आगे हर page पर दिखेंगे: + +* **host** LLM application है: Claude, कोई IDE, कोई agent runtime। यह वह चीज़ है जिससे user बात करता है। +* **client** host के अंदर रहता है और MCP बोलता है। host जितने servers से जुड़ा है, हर एक के लिए एक client चलाता है। +* **server** वह है जो आप इस SDK से बनाते हैं। यह clients को चीज़ें expose करता है। यह model से सीधे कभी बात नहीं करता। + +server आप लिखते हैं। hosts किसी और का product हैं। SDK आपको एक `Client` भी देता है। इससे आप अपने servers test करेंगे, और यह इसी page पर आगे दिखता है। + +## तीन primitives {#the-three-primitives} + +server ठीक तीन तरह की चीज़ें expose करता है। इन्हें अलग करने वाली बात यह है कि **इन्हें इस्तेमाल करने का फ़ैसला कौन करता है**: + +| Primitive | नियंत्रण किसका | यह क्या है | उदाहरण | +|---------------|-----------------|-----------------------------------------------------|------------------------------------| +| **Tools** | model | ऐसा function जिसे model कोई काम करने के लिए call करता है | API call, database write | +| **Resources** | application | data जिसे host model के context में load करता है | किसी file की सामग्री, API response | +| **Prompts** | user | दोबारा इस्तेमाल होने वाला message template जिसे user नाम से चलाता है | slash command, menu entry | + +"नियंत्रण किसका" ही इस बँटवारे का पूरा मतलब है। tool इसलिए चलता है क्योंकि **model** ने उसे call करने का फ़ैसला किया। resource इसलिए जुड़ता है क्योंकि **application** ने तय किया कि model को उसकी ज़रूरत है। prompt इसलिए चलता है क्योंकि **user** ने उसे चुना। + +!!! info + अगर आपने web API बनाया है तो ज़्यादातर समझ आपके पास पहले से है: **resource** एक `GET` है + (data load करता है और कुछ बदलता नहीं) और **tool** एक `POST` है (काम करता है और उसके + side effects हो सकते हैं)। **prompt** का HTTP में कोई जोड़ीदार नहीं; यह उस saved query के + ज़्यादा करीब है जिसे user नाम से चलाता है। + +## एक server, तीनों चीज़ें {#one-server-all-three} + +```python title="server.py" hl_lines="6 12 18" +--8<-- "docs_src/first_steps/tutorial001.py" +``` + +तीन सादे functions, तीन decorators। हर decorator ही पूरा registration है: + +* `@mcp.tool()` `add` को **tool** बनाता है। +* `@mcp.resource("greeting://{name}")` `greeting` को **resource template** बनाता है: URI में `{name}` function का parameter है। +* `@mcp.prompt()` `summarize` को **prompt** बनाता है। यह जो string लौटाता है, वही user message बन जाती है। + +बाकी सब कुछ (नाम, description, argument schema) SDK function से ही पढ़ लेता है: उसका नाम, उसका docstring, उसके type hints। आपने इनमें से कुछ भी अलग से declare नहीं किया। + +!!! tip + SDK के दो हिस्सों के दो import paths हैं: `from mcp import Client` और + `from mcp.server import MCPServer`। `from mcp import MCPServer` जैसा कुछ नहीं है। + +### इसे आज़माएँ {#try-it} + +इसे MCP Inspector से चलाएँ: + +```console +uv run mcp dev server.py +``` + +जो URL यह print करता है उसे खोलें। Inspector में हर primitive के लिए एक tab है; उन्हें क्रम से देखें। + +**Tools.** एक entry: `add`, जिसका description है *Add two numbers.* form में `a` के लिए एक ज़रूरी integer field है और `b` के लिए एक और। उन्हें भरें, call करें, और result `3` है। Inspector ने वह form `a: int, b: int` से बनाया। बाकी हर client भी यही करता है। + +**Resources.** *Resources* सूची खाली है। `greeting` **Resource Templates** के नीचे है, क्योंकि `greeting://{name}` में parameter है: जब तक कोई `name` न दे, सूची में दिखाने के लिए कोई एक resource है ही नहीं। इसे `World` दें और पढ़ें: + +```text +Hello, World! +``` + +**Prompts.** एक entry: `summarize`, एक ही ज़रूरी `text` argument के साथ। कुछ text देकर इसे get करें और आपको एक message मिलता है जिसमें `role: user` है और content के रूप में आपकी render की हुई string। prompt बस इतना ही है: messages बनाने वाला function। + +Inspector ने आपका server **stdio** पर चलाया, जो उन transports में से एक है जो MCP server बोल सकता है। अभी आपको कोई चुनना नहीं है; उसके लिए **[अपना server चलाना](../run/index.md)** page है। + +## Capabilities {#capabilities} + +Inspector में आपने तीन tabs देखे। उसे कैसे पता चला कि तीन हैं? + +जब client जुड़ता है, server अपनी **capabilities** declare करता है: requests के कौन-से परिवारों का वह जवाब देगा। client उसी declaration से तय करता है कि माँगे भी तो क्या। आपने यह कभी नहीं लिखा; `MCPServer` आपके लिए इसे declare करता है। + +खुद देखें। SDK का `Client` सीधे server object लेता है और उससे **in memory** जुड़ता है (न subprocess, न port): + +```python +import asyncio + +from mcp import Client + +from server import mcp + + +async def main() -> None: + async with Client(mcp) as client: + print(client.server_capabilities.model_dump(exclude_none=True)) + + +asyncio.run(main()) +``` + +```text +{'prompts': {'list_changed': True}, 'resources': {'subscribe': True, 'list_changed': True}, 'tools': {'list_changed': True}} +``` + +वह dictionary आपके server की declared **capabilities** है। हर जुड़ने वाला client सबसे पहले यही जानता है: + +| Capability | client अब ये call कर सकता है | +|-------------|------------------------------------------------------------| +| `tools` | `tools/list`, `tools/call` | +| `resources` | `resources/list`, `resources/templates/list`, `resources/read` | +| `prompts` | `prompts/list`, `prompts/get` | + +`MCPServer` तीनों primitives serve करता है, इसलिए तीनों हमेशा declare होती हैं। + +ध्यान दें कि क्या नहीं है। `completions` (resource templates और prompts के लिए argument autocomplete) को आपका लिखा handler चाहिए, इस server में वह नहीं है, इसलिए capability मौजूद नहीं है और सलीके वाला client पूछेगा ही नहीं। हर optional चीज़ का यही नियम है: चीज़ register करें और capability आ जाती है; **[Completions](../servers/completions.md)** इसे साबित करता है। + +!!! info + `Client(mcp)` वही in-memory client है जिससे इन docs का हर उदाहरण test होता है, और + इसी से आप अपने servers test करेंगे। इसे पूरा एक page मिलता है: **[Testing](testing.md)**। + +## जो आपने नहीं लिखा {#what-you-did-not-write} + +इस page पर पीछे मुड़कर देखें। आपने तीन छोटे Python functions लिखे। आपने ये **नहीं** लिखे: + +* JSON Schema। `a: int, b: int` **ही** `add` का schema है। +* request handler। `tools/list`, `resources/read`, `prompts/get`: सब आपके लिए serve होते हैं। +* capability declaration। `MCPServer` ने आपके लिए बना दिया। +* protocol की एक भी line। version negotiation, JSON-RPC framing, capability exchange: यह सब `mcp dev` और `Client(mcp)` के अंदर हुआ, और आपने कभी देखा ही नहीं। + +यही अनुपात SDK का पूरा मतलब है। + +## सारांश {#recap} + +* **host** LLM app है, **client** उसका MCP बोलने वाला हिस्सा है, **server** वह है जो आप बनाते हैं। +* tools पर **model** का नियंत्रण है, resources पर **application** का, prompts पर **user** का। +* हर primitive के लिए एक decorator: `@mcp.tool()`, `@mcp.resource(uri)`, `@mcp.prompt()`। नाम, description और schema function से आते हैं। +* `{param}` वाला URI resource **template** बनाता है, जो concrete resources से अलग सूची में दिखता है। +* server की **capabilities** आपके लिए declare हो जाती हैं, और client वही माँगता है जो server declare करता है। +* `Client(mcp)` server object से in memory जुड़ता है: पहले दिन से आपका test harness। + +आगे है **[असली host से जुड़ें](real-host.md)**: यही server Claude Desktop या किसी IDE के अंदर, सच में। फिर **[Testing](testing.md)**: एक page, एक in-memory client, और आपको कभी अंदाज़ा नहीं लगाना पड़ेगा कि यह काम करता है या नहीं। उसके बाद हर primitive को अपना page मिलता है, शुरुआत उससे जिसे model चलाता है: **[Tools](../servers/tools.md)**। diff --git a/i18n/hi/pages/get-started/index.md b/i18n/hi/pages/get-started/index.md new file mode 100644 index 0000000000..c3e55b6149 --- /dev/null +++ b/i18n/hi/pages/get-started/index.md @@ -0,0 +1,57 @@ +--- +translation: + sections: [ed4a756b4c53c585, 97e2fb315b7fe398, 4d04f1c6f4bf6c1d, 577d73078fc62baf] + tool: 1 +--- +# शुरू करें {#get-started} + +MCP में नए हैं, या इस SDK में? यहीं से शुरू करें। ये pages आपको शून्य से एक चालू, +test किए हुए server तक ले जाते हैं: [SDK install करें](installation.md), अपना +[पहला server](first-steps.md) बनाएँ, [उसे असली host से जोड़ें](real-host.md), और +in-memory client से [उसे test करें](testing.md)। + +## code चलाएँ {#run-the-code} + +सभी code blocks सीधे copy करके इस्तेमाल किए जा सकते हैं: ये पूरी, काम करने वाली files हैं। + +साथ-साथ चलने के लिए, किसी block को `server.py` में paste करें और उसे MCP Inspector में खोलें: + +```console +uv run mcp dev server.py +``` + +**पुरज़ोर सलाह** है कि code खुद लिखें (या copy करें), उसमें बदलाव करें और उसे locally चलाएँ। अपने editor में इस्तेमाल करने पर ही असली बात समझ आती है: कितना कम लिखना पड़ता है, autocompletion, और कुछ भी चलाने से पहले गलतियाँ पकड़ लेने वाले type checks। + +## आपको अंदाज़ा नहीं लगाना पड़ेगा {#you-will-not-be-guessing} + +इन docs का हर उदाहरण SDK की अपनी repository में [`docs_src/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/docs_src) के नीचे एक पूरी file है, और SDK का test suite हर एक को **in-memory client** के ज़रिए चलाकर परखता है: + +```python +import pytest +from mcp import Client + +from server import mcp + + +@pytest.mark.anyio +async def test_add() -> None: + async with Client(mcp) as client: + result = await client.call_tool("add", {"a": 1, "b": 2}) + assert result.structured_content == {"result": 3} +``` + +न subprocess, न port, न transport। `Client(mcp)` सीधे server object से जुड़ता है। + +अगर SDK में कोई बदलाव इन pages के किसी उदाहरण को तोड़ता है, तो page से पहले CI लाल हो जाता है। जो code आप यहाँ पढ़ते हैं, वही code चलता है। + +[Testing](testing.md) में आप इसे खुद इस्तेमाल करेंगे; अपने servers भी इसी तरह test किए जाते हैं। + +## आगे कहाँ जाएँ {#where-to-go-next} + +एक बार server चल जाए, तो बाकी docs course नहीं, reference हैं। +हर page अपने आप में पूरा है, इसलिए सीधे वहीं जाएँ जिसकी ज़रूरत है: + +* server क्या expose करता है (tools, resources, prompts), यह **[Servers](../servers/index.md)** में है। +* आपके register किए functions के अंदर क्या-क्या उपलब्ध है, यह **[आपके handler के अंदर](../handlers/index.md)** में है। +* इसे clients के सामने लाना (stdio, HTTP, आपका मौजूदा FastAPI app) **[अपना server चलाना](../run/index.md)** में है। +* दूसरा पक्ष बनाना, यानी ऐसा application जो MCP servers **इस्तेमाल करता है**, **[Clients](../client/index.md)** में है। diff --git a/i18n/hi/pages/get-started/installation.md b/i18n/hi/pages/get-started/installation.md new file mode 100644 index 0000000000..b8460135ea --- /dev/null +++ b/i18n/hi/pages/get-started/installation.md @@ -0,0 +1,47 @@ +--- +translation: + sections: [6e2f9bab94d5ed36, 8cf653388f69e28b, 6fd9ea2f65de0df6] + tool: 1 +--- +# Installation {#installation} + +Python SDK PyPI पर [`mcp`](https://pypi.org/project/mcp/) नाम से उपलब्ध है। इसके लिए **Python 3.10+** ज़रूरी है। + +ये docs **v2** का वर्णन करते हैं, जो मौजूदा stable release line है: + +=== "uv" + + ```bash + uv add "mcp[cli]" + ``` + +=== "pip" + + ```bash + pip install "mcp[cli]" + ``` + +!!! note "v1 से आ रहे हैं?" + v2 एक major version है जिसमें breaking changes हैं; **[Migration Guide](../migration.md)** + में हर एक बदलाव की जानकारी है। अगर आपका **package** `mcp` पर निर्भर है और अभी migrate करने के लिए + तैयार नहीं है, तो `<2` की upper bound रखें (उदाहरण के लिए `mcp>=1.28,<2`), ताकि बिना pin किया गया resolve 1.x line पर ही रहे। + +## क्या-क्या install होता है {#what-gets-installed} + +SDK इस्तेमाल करने के लिए यह सब जानना ज़रूरी नहीं है, लेकिन अगर आप सोच रहे हैं कि हर dependency किस काम की है: + +* `mcp-types`: हर protocol type (requests, results, content blocks) अपने अलग package के रूप में, जिसका version SDK के साथ कदम मिलाकर चलता है। जो code `mcp` पर निर्भर है, वह इसे `mcp.types` alias के ज़रिए import करता है (इन docs में हर `from mcp.types import ...`); `mcp_types` को सीधे सिर्फ़ उसी project में import करें जो SDK के बिना `mcp-types` install करता है। +* [`anyio`](https://anyio.readthedocs.io/): async runtime। पूरा SDK anyio के आधार पर लिखा गया है, इसलिए यह `asyncio` या `trio` दोनों में से किसी पर भी चलता है। +* [`pydantic`](https://docs.pydantic.dev/): हर `mcp.types` model इसी पर बना है, साथ ही पूरा schema generation और validation भी। +* [`httpx2`](https://pypi.org/project/httpx2/): Streamable HTTP और SSE **client** transports के पीछे का HTTP client, जिसमें server-sent events का support पहले से मौजूद है। +* [`starlette`](https://www.starlette.io/), [`uvicorn`](https://www.uvicorn.org/), [`sse-starlette`](https://pypi.org/project/sse-starlette/), और [`python-multipart`](https://pypi.org/project/python-multipart/): HTTP **server** transports। +* [`jsonschema`](https://pypi.org/project/jsonschema/): tool के structured output को उसके घोषित output schema के अनुसार validate करता है। +* [`pyjwt[crypto]`](https://pyjwt.readthedocs.io/): authorization के लिए OAuth token संभालना। +* [`opentelemetry-api`](https://opentelemetry-python.readthedocs.io/): सिर्फ़ हल्का-सा API, ताकि SDK के tracing middleware की कोई लागत न हो, जब तक आप खुद OpenTelemetry SDK और exporter install न करें। +* [`typing-extensions`](https://typing-extensions.readthedocs.io/) और [`typing-inspection`](https://pypi.org/project/typing-inspection/): Python 3.10 पर आधुनिक typing features। +* [`pywin32`](https://pypi.org/project/pywin32/): सिर्फ़ Windows पर, `stdio` subprocess management के लिए इस्तेमाल होता है। + +## Optional extras {#optional-extras} + +* `mcp[cli]`, `mcp` command-line tool (`mcp dev`, `mcp run`, `mcp install`) के लिए [`typer`](https://typer.tiangolo.com/) और [`python-dotenv`](https://pypi.org/project/python-dotenv/) जोड़ता है। development के दौरान आपको यह चाहिए होगा; deploy किए गए server में शायद इसकी ज़रूरत न पड़े। +* `mcp[rich]` बेहतर server logs के लिए [`rich`](https://rich.readthedocs.io/) जोड़ता है। diff --git a/i18n/hi/pages/get-started/real-host.md b/i18n/hi/pages/get-started/real-host.md new file mode 100644 index 0000000000..0f3aca69ab --- /dev/null +++ b/i18n/hi/pages/get-started/real-host.md @@ -0,0 +1,182 @@ +--- +translation: + sections: [3c4f2f06b4e978b6, 22520eecae3d1961, f4e1709db18d635a, 2eb57992049671d9, 1ba83e9af37cc1b4, 4822586344b08d9e, 1c93afef72478992, b6b448f9eddd51dc, fe55370fd931815b] + tool: 1 +--- +# असली host से connect करना {#connect-to-a-real-host} + +**host** वह application है जिसके अंदर आपका server आखिरकार चलता है: Claude Desktop, Claude Code, कोई IDE। user इसी host से बात करता है। इसके अंदर एक MCP **client** आपके server को child process के रूप में launch करता है और उसी process के stdin और stdout पर उससे बात करता है। + +यानी host से connect करना बस एक काम है: आप उसे **वह command बताते हैं जो आपका server शुरू करता है**। इस page पर जो कुछ है (दो CLI commands, तीन JSON files), वह उसी एक command को रखने की अलग-अलग जगहें हैं। + +## एक server, हर host {#one-server-every-host} + +```python title="server.py" hl_lines="3 33-34" +--8<-- "docs_src/real_host/tutorial001.py" +``` + +दो tools और एक resource, एक ही file में। इस file की तीन बातें नीचे के हर host के लिए मायने रखती हैं: + +* बिना arguments के `mcp.run()` एक **stdio** server शुरू करता है: यह block होता है, stdin पर protocol messages पढ़ता है और stdout पर लिखता है। इस page का हर host यही transport बोलता है। host आपकी file को child process के रूप में शुरू करता है और उन दोनों pipes का मालिक होता है, इसीलिए connect करना हमेशा बस "यह रहा command" ही होता है। आप कभी port नहीं चुनते, और किसी port पर कुछ listen नहीं करता। +* `run()` `if __name__ == "__main__":` के नीचे है। नीचे की हर चीज़ इस file को execute करने के बजाय **import** करती है, इसलिए बिना guard वाला `run()` module के load होते ही server शुरू कर देता। +* server object module-level global है जिसका नाम `mcp` है। `mcp run` इसी नाम को ढूँढता है (`server` और `app` भी चलते हैं)। कोई और नाम रखें तो उसे साफ़-साफ़ बताना होगा: `mcp run server.py:bookshop`। + +इस page पर Python की यह आख़िरी line है। यहाँ से नीचे सब host configuration है। + +## Launch command {#the-launch-command} + +नीचे के हर host को यही एक command मिलता है: + +```bash +uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py +``` + +सबके लिए एक ही command, क्योंकि `uv run --with` उसी वक़्त SDK को एक नए environment में resolve कर देता है: यह किसी भी directory से चलता है और इसे न कोई project चाहिए, न activate करने के लिए कोई virtual environment। यहाँ यह बात कहीं और से ज़्यादा मायने रखती है, क्योंकि host आपके server को आपके shell से नहीं, बल्कि **अपनी** working directory से, लगभग खाली environment के साथ launch करता है। + +यही वह command है जो `mcp install` आपके लिए Claude Desktop के config में लिखता है (नीचे देखें), इसलिए जो आप हाथ से लिखते हैं और जो tool बनाता है, दोनों मेल खाते हैं, सिवाय उस exact version pin के जो tool जोड़ता है। + +!!! tip "अगर host को `uv` न मिले" + host आपके server को बहुत छोटे `PATH` के साथ spawn करता है, और हो सकता है `uv` उसमें न हो। सिर्फ़ + `uv` की जगह `which uv` (macOS/Linux) या `where uv` (Windows) से मिला absolute path लिखें। `mcp install` + ठीक यही लिखता है। + +!!! note "यह page local setup की बात है" + यहाँ की हर चीज़ आपके server को उसी machine पर चलाती है जिस पर host है: host आपकी + file को stdio पर launch करता है। निजी या एक ही machine वाले tool के लिए यह बिल्कुल सही है। जिन + लोगों के पास आपकी file **नहीं** है, उन्हें server देने के लिए आप command नहीं, **URL** देते हैं: वही + `mcp` object, Streamable HTTP पर serve किया हुआ। **[अपना server चलाना](../run/index.md)** + वह फ़ैसला एक table में रखता है, और **[Deploy और scale](../run/deploy.md)** वहाँ से + असली hostname तक का रास्ता है। + + और host किसी application से ज़्यादा कुछ नहीं जिसके अंदर MCP client हो, इसलिए आपका अपना + Python भी host की भूमिका निभा सकता है: **[Client transports](../client/transports.md)** इसी + file को `stdio_client(...)` से subprocess के रूप में launch करता है, और **[Testing](testing.md)** + बिना किसी process के, memory में ही उससे connect करता है। + +## Claude Desktop {#claude-desktop} + +वह इकलौता host जिसे SDK आपके लिए configure कर सकता है: + +```bash +uv run mcp install server.py +``` + +बस इतना ही। `mcp install` server का नाम पढ़ने के लिए file को import करता है, Claude Desktop की config file ढूँढता है और उसमें launch command लिख देता है। साथ ही यह आपके path को absolute बना देता है, ताकि आपको न करना पड़े। + +इसमें कोई रहस्य नहीं है। यह रही वह entry जो यह लिखता है: + +```json +{ + "mcpServers": { + "Bookshop": { + "command": "/absolute/path/to/uv", + "args": [ + "run", + "--frozen", + "--with", + "mcp[cli]==2.0.0", + "mcp", + "run", + "/absolute/path/to/server.py" + ] + } + } +} +``` + +यह ऊपर वाले section का launch command ही है, तीन चीज़ें जोड़कर: `uv` का absolute path, `--frozen` ताकि `uv` आस-पास पड़ी किसी lockfile को कभी दोबारा न लिखे, और आपके install किए हुए `mcp` version का exact pin। यह `claude_desktop_config.json` में जाता है, जो यहाँ रहती है: + +* **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` +* **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` + +यह file आप हाथ से भी लिख सकते हैं। `mcp install` इसलिए है ताकि ऐसा करते समय आप वह जानी-पहचानी गलती (relative path) न करें। + +Claude Desktop को पूरी तरह quit करें (सिर्फ़ उसकी window नहीं) और दोबारा खोलें। + +!!! warning + अगर Claude Desktop की config **directory** अभी मौजूद नहीं है तो `mcp install` `Claude app not found` के साथ + fail होता है। Claude Desktop install करें और एक बार चलाएँ: directory उसी से बनती है। + +!!! tip + Claude Desktop आपके server को अपने process में शुरू करता है, इसलिए आपके shell के environment variables + वहाँ नहीं होते। `uv run mcp install server.py -v API_KEY=abc123` (या `-f .env`) उन्हें entry के + `env` field में दर्ज कर देता है। `--name` entry का नाम override करता है; default server का `name` है। + +## Claude Code {#claude-code} + +edit करने के लिए कोई file नहीं है। server को `claude` CLI से register करें; `--` के बाद जो कुछ है वही launch command है। + +```bash +claude mcp add bookshop -- uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py +``` + +यह पक्का करने के लिए कि `bookshop` connected है और उसके tools सूची में दिख रहे हैं, Claude Code session के अंदर `/mcp` चलाएँ। + +## Cursor {#cursor} + +अपने project root में `.cursor/mcp.json` बनाएँ। + +```json +{ + "mcpServers": { + "bookshop": { + "command": "uv", + "args": ["run", "--with", "mcp[cli]", "mcp", "run", "/absolute/path/to/server.py"] + } + } +} +``` + +वही `command` और `args`, उसी `mcpServers` key के नीचे जो Claude Desktop इस्तेमाल करता है। server दोनों tools के साथ Cursor की MCP settings में दिखता है। + +## VS Code {#vs-code} + +अपने project root में `.vscode/mcp.json` बनाएँ। + +```json +{ + "servers": { + "bookshop": { + "type": "stdio", + "command": "uv", + "args": ["run", "--with", "mcp[cli]", "mcp", "run", "/absolute/path/to/server.py"] + } + } +} +``` + +Cursor की file से दो फ़र्क़ हैं, और बस यही दो: wrapper key `mcpServers` नहीं, `servers` है, और हर entry अपना `type` बताती है। trust prompt को confirm करें, फिर Command Palette में **MCP: List Servers** `bookshop` को चलता हुआ दिखाता है। + +!!! note + आपको VS Code 1.99 या उससे नया चाहिए, जिसमें **GitHub Copilot** extension signed in हो (Copilot Free + काफ़ी है), और Copilot Chat **Agent** mode में होना चाहिए, क्योंकि कोई और mode tools को call नहीं करता। + +## यह दिख नहीं रहा {#it-doesnt-show-up} + +किसी भी host config को छूने से पहले, launch command खुद चलाएँ: + +```bash +uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py +``` + +कुछ print नहीं होता, और यह लौटता नहीं। यह चुप्पी सही है: stdio server इंतज़ार कर रहा है कि host पहले stdin पर बोले (रोकने के लिए `Ctrl-C`)। traceback या तुरंत exit ही असली bug है, और अब आप उसे host के ज़रिए अंदाज़ा लगाने के बजाय सीधे पढ़ सकते हैं। + +जब वह command बैठकर इंतज़ार करने लगे, तो बाकी समस्या लगभग हमेशा इन तीन में से एक होती है: + +* **Relative path।** host आपके server को **अपनी** working directory से launch करता है, उस directory से नहीं जहाँ से आपने register किया था। जहाँ `/absolute/path/to/server.py` चाहिए वहाँ `server.py` लिखना सबसे आम गड़बड़ी है। अगर host को `uv` भी न मिले, तो वह path भी absolute होना चाहिए। +* **host अब भी पुराने config पर चल रहा है।** hosts अपना config launch के समय पढ़ते हैं। ख़ासकर Claude Desktop को **पूरी तरह quit** करना (सिर्फ़ window बंद करना नहीं) और दोबारा खोलना पड़ता है, तभी `claude_desktop_config.json` में किया गया बदलाव लागू होता है। +* **divert की गई window के बाहर कुछ stdout तक पहुँच गया।** stdio पर stdout **ही** protocol है। serve करते समय SDK flush हुए भटके हुए output को stderr की ओर मोड़ देता है, लेकिन उससे पहले stdout पर flush हुआ output (कोई wrapper script जो echo करती है, unbuffered process में import के समय चला `print()`), या interpreter exit पर खाली होने वाला buffered `print()`, host को corrupt message थमा देता है और host connection छोड़ देता है। default `logging` configuration से log करें, जिसका stderr handler हर record को flush करता है; custom handlers को भी stdout से बचना ही है। पूरी जानकारी **[Logging](../handlers/logging.md)** में है। + +Claude Desktop हर server का अलग log रखता है: `mcp-server-.log` आपके server का stderr है, connections के लिए `mcp.log` के बगल में, macOS पर `~/Library/Logs/Claude` में और Windows पर `%APPDATA%\Claude\logs` में। + +इन तीनों के आगे कुछ भी हो, तो **[Troubleshooting](../troubleshooting.md)** वाला page देखें। + +## सारांश {#recap} + +* **host** (Claude Desktop, कोई IDE) एक MCP client चलाता है जो आपके server को stdio पर child process के रूप में launch करता है। connect करने का मतलब है उसे एक launch command देना। +* वह command है `uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py`: कोई venv activate नहीं करना, किसी भी directory से चलता है। +* **Claude Desktop** वह इकलौता host है जिसे `mcp install` आपके लिए configure करता है। यह वही command (साथ में `uv` का absolute path, `--frozen`, और आपके install किए हुए version का exact pin) `claude_desktop_config.json` में लिख देता है, ताकि आपको कभी न लिखना पड़े। +* **Claude Code** के लिए `claude mcp add bookshop -- `। **Cursor** के लिए `mcpServers` के नीचे `.cursor/mcp.json`। **VS Code** के लिए `servers` के नीचे `.vscode/mcp.json`, हर entry में एक `type`। +* हर जगह absolute paths, config edit करने के बाद host को restart करें, और SDK के अलावा किसी को stdout पर न लिखने दें। + +इस page का हर host उसी एक file से, उसी एक command से connect हुआ। वह file क्या **expose** कर सकती है, यही बाकी docs हैं: **[Tools](../servers/tools.md)**, **[Resources](../servers/resources.md)**, और stdio के अलावा हर transport **[अपना server चलाना](../run/index.md)** में। diff --git a/i18n/hi/pages/get-started/testing.md b/i18n/hi/pages/get-started/testing.md new file mode 100644 index 0000000000..85721f0ef4 --- /dev/null +++ b/i18n/hi/pages/get-started/testing.md @@ -0,0 +1,105 @@ +--- +translation: + sections: ['4926721070127497', c52a1de2b6b32f40, 2e410b412c25f314, 627195f7159e24ef] + tool: 1 +--- +# Testing {#testing} + +Python SDK में `Client` class आती है जिसके साथ **in-memory transport** मिलता है: इसे अपना server object दें और यह उससे सीधे जुड़ जाता है। + +कोई subprocess नहीं। कोई port नहीं। कोई transport ही नहीं। यह वही विचार है जो FastAPI के `TestClient` का है। + +## Basic usage {#basic-usage} + +मान लेते हैं कि आपके पास एक ही tool वाला सीधा-सादा server है: + +```python title="server.py" +--8<-- "docs_src/testing/tutorial001.py" +``` + +नीचे दिया गया test चलाने के लिए दो अतिरिक्त (development) dependencies चाहिए: + +=== "uv" + + ```bash + uv add --dev pytest inline-snapshot + ``` + +=== "pip" + + ```bash + pip install pytest inline-snapshot + ``` + +!!! info + ये docs मानकर चलते हैं कि आप [`pytest`](https://docs.pytest.org/en/stable/) पहले से जानते हैं। + + नीचे दिया गया test पूरे result object पर एक ही line में assert करने के लिए + [`inline-snapshot`](https://15r10nk.github.io/inline-snapshot/latest/) का इस्तेमाल करता है। यह test के + output को उसी `snapshot(...)` literal के रूप में record करता है जो आपको दिख रहा है। अगर आप इसे इस्तेमाल + नहीं करना चाहते, तो import हटा दें और किसी भी दूसरे test की तरह उन्हीं fields पर assert करें जो आपके + लिए मायने रखती हैं (`result.content[0].text == "3"`)। + +अब test: + +```python title="test_server.py" +import pytest +from inline_snapshot import snapshot +from mcp import Client +from mcp.types import CallToolResult, TextContent + +from server import mcp + + +@pytest.fixture +def anyio_backend(): # (1)! + return "asyncio" + + +@pytest.fixture +async def client(): # (2)! + async with Client(mcp, raise_exceptions=True) as c: + yield c + + +@pytest.mark.anyio +async def test_call_add_tool(client: Client): + result = await client.call_tool("add", {"a": 1, "b": 2}) + # Drop the server identity stamp in `_meta`; it is not what this test is about. + result.meta = None + assert result == snapshot( + CallToolResult( + content=[TextContent(type="text", text="3")], + structured_content={"result": 3}, + ) + ) +``` + +1. अगर आप `trio` इस्तेमाल कर रहे हैं, तो इसकी जगह `"trio"` लौटाएँ। विस्तार से जानने के लिए [anyio documentation](https://anyio.readthedocs.io/en/stable/testing.html#specifying-the-backends-to-run-on) देखें। +2. यह fixture एक जुड़ा हुआ client yield करता है। हर test जो `client` लेता है, उसे उसी server से नया in-memory connection मिलता है। + +हो गया! अब आप और scenarios को cover करने के लिए अपने tests बढ़ा सकते हैं। + +## `raise_exceptions=True` क्यों? {#why-raise_exceptionstrue} + +दो अलग-अलग चीज़ें गड़बड़ हो सकती हैं, और यह flag उनमें से सिर्फ़ एक को छूता है। + +**आपके tools** में से किसी के अंदर हुआ exception protocol failure नहीं है। वह `is_error=True` वाला सामान्य result बन जाता है, और model उसका message पढ़ता है। `raise_exceptions` इसमें कुछ नहीं बदलता: इसके साथ या इसके बिना, `call_tool` वही `is_error=True` वाला result लौटाता है। इस पर एक पूरा page है: +**[Errors संभालना](../servers/handling-errors.md)**। + +Tool body के **बाहर** की failure अलग है। `Client(mcp)` जो connection देता है, उस पर server इसे client तक पहुँचने से पहले एक सामान्य `"Internal server error"` में sanitise कर देता है। किसी अनपेक्षित crash की बारीकियाँ remote caller तक कभी leak नहीं होनी चाहिए। Test में आप ठीक यही **नहीं** चाहते, और `raise_exceptions=True` यही बदलता है: आपके test को sanitise किया हुआ message नहीं, बल्कि असली message दिखता है। + +Tests में इसे चालू रहने दें। Production code में इसका कोई मतलब नहीं है। + +## Default रूप से in-process {#in-process-by-default} + +!!! note + `Client(mcp)` in-process जुड़ता है और default रूप से **पीढ़ी-निरपेक्ष** है: यह server को probe करता है और + सही protocol path चुनता है। अगर आपका test legacy-विशेष semantics (sampling या elicitation push, + `message_handler`) को परखता है, तो `mode="legacy"` pin करें, और वहाँ `raise_exceptions=True` हटा दें: + legacy connection पहले से ही कभी sanitise नहीं करता, और यह flag failure को आपके test में नहीं, बल्कि + server task के अंदर दोबारा raise करता है। + +यही एक line वह वजह भी है कि ये docs आपसे वादा कर सकते हैं कि इनके उदाहरण काम करते हैं: हर example file को SDK का अपना test suite चलाकर परखता है, और उनमें से लगभग सभी को ठीक इसी client के ज़रिए। आप वही tool इस्तेमाल कर रहे हैं जो SDK खुद पर इस्तेमाल करता है। + +आपके पास एक चलता हुआ, tested server है। इसे किसी असली application (Claude Desktop, कोई IDE) के अंदर रखना **[असली host से जुड़ें](real-host.md)** में है; इसे serve करने का हर दूसरा तरीका **[अपना server चलाना](../run/index.md)** में है। diff --git a/i18n/hi/pages/handlers/context.md b/i18n/hi/pages/handlers/context.md new file mode 100644 index 0000000000..3c75c3ac25 --- /dev/null +++ b/i18n/hi/pages/handlers/context.md @@ -0,0 +1,134 @@ +--- +translation: + sections: [b50152f05c81e786, b302059b22fb7cb4, 85682a1bf561243a, 53fc48838eb6837a, b24190e0842786ec, 85f93e150fc9b240] + tool: 1 +--- +# Context {#the-context} + +tool के arguments model से आते हैं। बाकी सब कुछ (जिस request को आप serve कर रहे हैं, जिस server में आप हैं, client से वापस बात करने का तरीका) एक ही object से आता है: **`Context`**। + +न आपको इसे बनाना है, न configure करना है। बस माँगना है। + +## इसे माँगें {#ask-for-it} + +किसी भी tool में `Context` से annotate किया गया parameter जोड़ें: + +```python title="server.py" hl_lines="2 8" +--8<-- "docs_src/context/tutorial001.py" +``` + +* SDK हर request के लिए नया `Context` बनाता है और उसे pass करता है। +* parameter का **नाम मायने नहीं रखता**। `ctx`, `context`, `c`: SDK इसे annotation से पहचानता है। +* resources और prompts भी इसी तरह एक declare कर सकते हैं। +* `ctx.request_id` उस request की id है जिसे आपका function अभी serve कर रहा है। + +!!! info + अगर आपने FastAPI इस्तेमाल किया है, तो यह तरीका आपने देखा है: framework के अपने type + (वहाँ `Request`, यहाँ `Context`) वाला parameter declare करें और framework उसे दे देता है। कुछ register नहीं करना, कुछ + configure नहीं करना: type annotation ही पूरा mechanism है। + +### model को नहीं दिखता {#invisible-to-the-model} + +यही बात अच्छे से समझ लेने की है। यह रहा वह input schema जो `tools/list` `search_books` के लिए बताता है: + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"} + }, + "required": ["query"], + "title": "search_booksArguments" +} +``` + +एक ही property। `ctx` कोई argument नहीं है: यह schema में कभी नहीं आता, model को इसके बारे में कभी नहीं बताया जाता, और कोई client इसे भर नहीं सकता। यह आपके और SDK के बीच का समझौता है, wire पर नहीं दिखता। + +### इसे आज़माएँ {#try-it} + +MCP Inspector के साथ server चलाएँ: + +```console +uv run mcp dev server.py +``` + +`search_books` के form में सिर्फ़ एक `query` field है। इसे `dune` के साथ call करें: + +```text +[request 3] Found 3 books matching 'dune'. +``` + +संख्या वही है जो यह request संयोग से थी। tool को दोबारा call करें और यह बदल जाती है: हर request को अपना `Context` मिलता है। + +## यह आपको क्या देता है {#what-it-gives-you} + +inject किया गया object छोटा है। `request_id` के अलावा: + +* `await ctx.read_resource(uri)`: tool के अंदर से server का **अपना** resource पढ़ें। अगला section। +* `await ctx.report_progress(progress, total, message)`: लंबे call के दौरान caller को progress भेजते रहें। पूरी जानकारी **[Progress](progress.md)** में है। +* `await ctx.elicit(message, schema)` और `await ctx.elicit_url(...)`: tool को रोककर user से सवाल पूछें। यह **[Elicitation](elicitation.md)** है। +* `ctx.session`: इस client के साथ बातचीत का server वाला पक्ष। client को भेजे जाने वाले notifications यहीं रहते हैं; आखिरी section इसका इस्तेमाल करता है। +* `ctx.headers`: transport जो request headers लाया, या stdio पर `None`। custom header `(ctx.headers or {}).get("x-...")` से पढ़ें। headers client का दिया हुआ input हैं - locale या feature flag के लिए ठीक, identity के लिए कभी नहीं। +* `ctx.request_context`: हर request का raw record। जिस field की ज़रूरत पड़ेगी वह है `lifespan_context`, वह object जो आपके startup code ने yield किया था (**[Lifespan](lifespan.md)** देखें)। + +logging जानबूझकर इस सूची में नहीं है। server Python के `logging` module से log करता है, किसी भी दूसरे Python program की तरह। **[Logging](logging.md)** वह छोटा page है जो बताता है क्यों। + +!!! tip + injection सिर्फ़ उसी function के लिए होता है जिसे आपने register किया। आपका tool जिस helper को call करता है, उसे + अपना `Context` नहीं मिलता; `ctx` को साधारण argument की तरह नीचे pass करें। कहीं और से लाने के लिए कोई ambient + "current context" नहीं है। + +## अपने resources पढ़ें {#read-your-own-resources} + +server के resources सिर्फ़ clients के लिए नहीं हैं। tool भी उन्हें पढ़ सकता है: + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/context/tutorial002.py" +``` + +`ctx.read_resource` URI को उसी registry से resolve करता है जो `resources/read` को serve करती है, इसलिए tool को वही मिलता है जो client को मिलता: `ReadResourceContents` का iterable, हर content block के लिए एक। इस URI के लिए एक है: + +```python +contents.content # 'fiction, non-fiction, poetry' +contents.mime_type # 'text/plain' +``` + +* `content` ठीक वही है जो `genres()` ने लौटाया। सच का एक ही स्रोत: client resource को browse करता है, आपके tools उसे इस्तेमाल करते हैं, कोई string की copy नहीं बनाता। +* `describe_catalog` का इकलौता parameter `Context` है, इसलिए इसके input schema में **कोई property ही नहीं** है। model इसे `{}` के साथ call करता है। + +## client को बताएँ कि सूची बदल गई {#tell-the-client-the-list-changed} + +server जो देता है वह import time पर तय नहीं है। runtime पर tool register करें, फिर client को बताएँ: + +```python title="server.py" hl_lines="15-16" +--8<-- "docs_src/context/tutorial003.py" +``` + +* `mcp.add_tool(recommend_book)` एक साधारण function को tool की तरह register करता है: name, description और schema ठीक वैसे ही निकाले जाते हैं जैसे `@mcp.tool()` निकालता। +* `await ctx.session.send_tool_list_changed()` `notifications/tools/list_changed` भेजता है। जिस client को यह मिलता है वह `tools/list` दोबारा call करता है और `recommend_book` देखता है। + +इसके साथी हैं `send_resource_list_changed()`, `send_prompt_list_changed()`, और किसी एक खास resource में बदलाव के लिए `send_resource_updated(uri)`। + +2026-07-28 connection पर clients को change notifications सिर्फ़ उस `subscriptions/listen` stream पर मिलते हैं जो उन्होंने खोला, इसलिए ऊपर के `send_*` methods उन streams तक नहीं पहुँचते। `Context` के publish methods हर subscribed stream पर एक साथ deliver करते हैं: `await ctx.notify_tools_changed()`, `await ctx.notify_prompts_changed()`, `await ctx.notify_resources_changed()`, और `await ctx.notify_resource_updated(uri)`। पूरी जानकारी, replicas पर scale out करने समेत, **[Subscriptions](subscriptions.md)** में है। + +!!! check + जब तक कोई `enable_recommendations` नहीं चलाता, जिस tool का आप वादा कर रहे हैं वह मौजूद नहीं है। फिर भी उसे call + करें और नतीजा एक error है जिसे model पढ़ सकता है: + + ```text + Unknown tool: recommend_book + ``` + + `enable_recommendations` चलाएँ, और ठीक वही call सफल हो जाता है। tool की सूची सच में + dynamic है: `tools/list` वही दिखाता है जो **अभी** register है। + +## सारांश {#recap} + +* किसी parameter को `Context` से annotate करें (tool, resource या prompt में) और SDK उसे inject कर देता है। नाम आपकी मर्ज़ी का। +* यह model को नहीं दिखता: input schema में हमेशा सिर्फ़ आपके असली arguments होते हैं। +* `ctx.request_id` request की पहचान है; `ctx.request_context.lifespan_context` वह है जो आपके startup ने yield किया। +* `await ctx.read_resource(uri)` से tool server के अपने resources पढ़ सकता है। +* `ctx.session` client तक वापस जाने का channel है: `send_tool_list_changed()` और उसके साथी उसे बताते हैं कि बदली गई सूची दोबारा fetch करे। +* progress reporting और elicitation भी `Context` से शुरू होते हैं; दोनों का अपना page है। + +जो parameters model कभी नहीं देखता, और जिन्हें आपके अपने functions भरते हैं, वे **[Dependencies](dependencies.md)** हैं। diff --git a/i18n/hi/pages/handlers/dependencies.md b/i18n/hi/pages/handlers/dependencies.md new file mode 100644 index 0000000000..9ae52f1e8a --- /dev/null +++ b/i18n/hi/pages/handlers/dependencies.md @@ -0,0 +1,163 @@ +--- +translation: + sections: [b0389403e98d25ad, e2cf58b43b285e86, a363e1a38e1a5971, 6cfac078feb18013, b4535bd61df337e6, e97ed44207f929fd] + tool: 1 +--- +# Dependencies {#dependencies} + +tool के arguments model से आते हैं। कुछ values कभी वहाँ से नहीं आनी चाहिए: आपके records से निकाली गई कीमत, ऐसी confirmation जो सिर्फ़ कोई इंसान दे सकता है, कोई भी ऐसी चीज़ जिसे model गढ़कर गलत कर सकता है। + +**Dependencies** वे parameters हैं जिन्हें आपके अपने functions भरते हैं। आप parameter को annotate करते हैं, function का नाम देते हैं, और tool चलने से पहले SDK उसे call करता है। + +## एक declare करें {#declare-one} + +parameter के type को `Annotated[...]` में लपेटें और `Resolve(fn)` जोड़ें: + +```python title="server.py" hl_lines="18-19 23" +--8<-- "docs_src/dependencies/tutorial001.py" +``` + +* `check_stock` **resolver** है: सादा function, जिसे SDK `reserve_book` से पहले चलाता है और जिसकी return value `stock` argument बन जाती है। +* इसका `title` parameter tool का अपना `title` argument ही है, जिसका मिलान **नाम से** होता है। resolver को ठीक वही validated value दिखती है जो tool body को दिखेगी। +* tool body ऐसे `Stock` से शुरू होती है जो पहले से मौजूद है। tool में कोई lookup code नहीं, कोई "अगर यह न मिले तो" वाली भूमिका नहीं। + +!!! info + अगर आपने FastAPI इस्तेमाल किया है, तो यह `Depends` है। वही तरीका, वही वजह: function बताता है + कि उसे क्या चाहिए, framework वह देता है, और सारी wiring type annotation में रहती है। + +### model को नहीं दिखता {#invisible-to-the-model} + +यह रहा वह input schema जो `tools/list` `reserve_book` के लिए बताता है: + +```json +{ + "type": "object", + "properties": { + "title": {"title": "Title", "type": "string"} + }, + "required": ["title"], + "title": "reserve_bookArguments" +} +``` + +एक ही property। **[Context](context.md)** के `Context` की तरह, resolve किया गया parameter आपके और SDK के बीच का अनुबंध है: `stock` schema में नहीं है, model को इसके बारे में कभी नहीं बताया जाता, और जो client फिर भी `stock` value भेजता है उसे अनदेखा कर दिया जाता है। resolver की value ही वह अकेली value है जो आपके tool को मिल सकती है। + +आखिरी बात ही असली बात है। जो parameter model दे ही नहीं सकता, उसे model गलत भी नहीं कर सकता। + +### इसे आज़माएँ {#try-it} + +server को MCP Inspector के साथ चलाएँ: + +```console +uv run mcp dev server.py +``` + +`reserve_book` के form में सिर्फ़ एक `title` field है। `stock` उस पर कहीं नहीं है। इसे `Dune` के साथ call करें: + +```text +Reserved 'Dune' (6 copies left). +``` + +tool body ने खुद कुछ भी नहीं खोजा: पहले `check_stock` चला, और उसका लौटाया `Stock` argument बनकर आया। `Neuromancer` आज़माएँ और वही resolver tool को शून्य थमा देता है। + +!!! tip + आप tool body में सीधे `check_stock(title)` call भी कर सकते हैं। इसे dependency तब declare करें + जब value एक helper call से ज़्यादा की हकदार हो: stock की ज़रूरत वाला हर tool वही parameter + declare करता है, और चाहे कितने भी tools इसे declare करें, SDK resolver को प्रति call ज़्यादा से + ज़्यादा एक बार चलाता है। अगले sections बाकी जोड़ते हैं: एक-दूसरे पर निर्भर resolvers, और user से + पूछने वाले resolvers। + +## Dependencies की dependencies {#dependencies-of-dependencies} + +resolver उसी annotation से अपनी खुद की dependencies declare कर सकता है: + +```python title="server.py" hl_lines="22 29-30" +--8<-- "docs_src/dependencies/tutorial002.py" +``` + +* `estimate_delivery` `check_stock` पर निर्भर है। SDK graph को क्रम से चलाता है: पहले stock, फिर estimate, फिर tool। +* `stock` और `delivery` दोनों को आखिरकार `check_stock` चाहिए, लेकिन यह **प्रति call एक बार** चलता है। एक inventory lookup, दो consumers। +* register करने को कुछ नहीं है। annotations ही graph **हैं**। + +!!! check + "प्रति call एक बार" पर आँख मूँदकर भरोसा न करें। `check_stock` में एक `print` डालें और Inspector से + `order_book` call करें: प्रति call एक line। दो consumers, एक lookup। + +SDK graph का विश्लेषण तब करता है जब tool register होता है, न कि जब उसे call किया जाता है। ऐसा parameter जिसे वह वर्गीकृत न कर सके - न `Context`, न `Resolve(...)`, न किसी tool argument का नाम - और resolvers का कोई cycle, दोनों startup पर `InvalidSignature` raise करते हैं। server किसी भी client के जुड़ने से पहले ही fail हो जाता है, और error में गड़बड़ी वाले parameter या resolver का नाम होता है। + +resolver के parameters ठीक tool के parameters की तरह resolve होते हैं: कोई और `Resolve(...)`, नाम से tool के अपने arguments, या `Context` - `ctx.headers`, lifespan object, सब कुछ। + +!!! warning + HTTP transports पर `Context` में `ctx.headers` शामिल होते हैं। headers **client का भेजा हुआ input** हैं, + किसी भी tool argument की तरह: locale या feature flag के लिए ठीक, पहचान के लिए कभी नहीं। caller कौन + है, यह आपकी authorization layer (**[Authorization](../run/authorization.md)**) से आता है, किसी ऐसे header से नहीं जिसे कोई भी set कर सकता है। + +!!! tip + **प्रति call एक बार** का मतलब ठीक यही है: अगला `tools/call` `check_stock` को फिर से चलाता है। ऐसा resource + जिसे एक request से ज़्यादा जीना चाहिए - database pool, HTTP client - उसकी जगह **[Lifespan](lifespan.md)** में है, और + resolver उस तक `ctx.request_context.lifespan_context` के ज़रिए पहुँच सकता है। + +## तभी पूछें जब ज़रूरी हो {#ask-when-you-must} + +resolver को जवाब पता हो, यह ज़रूरी नहीं। वह `Elicit(message, Model)` लौटा सकता है और SDK user से पूछ लेता है - यानी **[Elicitation](elicitation.md)** की machinery, जो आपके लिए चलाई जाती है: + +```python title="server.py" hl_lines="26-32 39" +--8<-- "docs_src/dependencies/tutorial003.py" +``` + +* stock में हो: `confirm_backorder` सीधे `Backorder` लौटाता है। **कोई सवाल नहीं, कोई round-trip नहीं।** user को तभी टोका जाता है जब उसका जवाब मायने रखता हो। +* stock में न हो: SDK elicitation भेजता है, जवाब को `Backorder` के हिसाब से validate करता है, और उसे inject कर देता है। आपका resolver protocol को कभी छूता तक नहीं। +* tool `backorder.confirm` को किसी भी दूसरे argument की तरह पढ़ता है। **नहीं** कहना भी एक जवाब है: elicitation `confirm=False` के साथ accept होता है, tool चलता है, और कोई order नहीं दिया जाता। पूछना tool body की plumbing नहीं, एक precondition बन गया। + +और अगर user जवाब ही न दे - सवाल decline कर दे, या cancel कर दे? + +!!! check + `Neuromancer` के लिए `order_book` चलाएँ और सवाल decline करें। annotation + `Annotated[Backorder, Resolve(...)]` के रूप में लिखी हो तो tool body कभी नहीं चलती; call ऐसे error + result के साथ fail होता है जिसे model पढ़ सकता है: + + ```text + Error executing tool order_book: Resolver for parameter 'backorder' could not resolve: elicitation was decline + ``` + +precondition के लिए यही सही default है: जवाब नहीं, तो order नहीं। जब decline होना ऐसा नतीजा हो जिसे आपका tool खुद संभालना चाहे - backorder छोड़ दे पर फिर भी कोई दूसरा title सुझाए - तो इसके बजाय `ElicitationResult[Backorder]` annotate करें और tool को पूरा accept/decline/cancel नतीजा मिलता है जिस पर वह branch कर सके। **[Elicitation](elicitation.md)** वह रूप दिखाता है, और पूछने के बारे में बाकी सब भी: schema के नियम, तीनों जवाब, बातचीत का client वाला पक्ष। + +!!! info + framework सवाल का transport negotiate हुए protocol version से चुनता है; ऊपर का code दोनों पर एक जैसा + है। **2026-07-28** और उसके बाद सवाल एक multi-round-trip `tools/call` के भीतर जाता है - server उसे + लौटाता है, client का `elicitation_callback` उसका जवाब देता है, और `Client` आपके लिए call को फिर से + आज़माता है (**[Multi-round-trip requests](multi-round-trip.md)**)। **2025-11-25** और उससे पहले यह call के + बीच में एक synchronous elicitation request होती है। हर सवाल प्रति call ठीक एक बार पूछा जाता है - यह + गारंटी सवाल के बारे में है, resolver के बारे में नहीं। multi-round-trip रूप में, जब भी call किसी सवाल के + बाद फिर से शुरू होता है, कोई भी resolver दोबारा चल सकता है, इसलिए `return Elicit(...)` से पहले का code + उन हर rounds पर चलता है; फिर दर्ज किया गया जवाब दोहराए गए सवाल को user से दोबारा पूछे बिना पूरा कर + देता है। दर्ज जवाब सिर्फ़ तभी देखा जाता है जब resolver पूछता है; जो resolver पूछे **बिना** जवाब दे देता + है, जैसे `check_stock`, वह हमेशा अपनी खुद की गणना की गई value देता है। चूँकि हर जवाब वापस उसके सवाल + से मिलाया जाता है, elicit करने वाले resolver को अपना सवाल tool के arguments और पहले के जवाबों से + deterministic ढंग से बनाना होगा। प्रति call बनने वाली value (`default_factory` id, timestamp) हर round + पर फिर से बनती है और ऐसे सवाल में नहीं आनी चाहिए जिससे जवाब को बँधना है। ऐसे अस्थिर data से बना सवाल + हर दर्ज जवाब को बासी दिखा देता है, इसलिए server उसे हर round पर फिर से पूछता है, जब तक client की + round limit call को खत्म नहीं कर देती। + +## user से नहीं, client से पूछें {#ask-the-client-not-the-user} + +Elicitation उन तीन सवालों में से एक है जो resolver पूछ सकता है, और multi-round-trip flow इनके अलावा कोई और सवाल नहीं होने देता। बाकी दो user के बजाय **client** के पास जाते हैं: client के ज़रिए LLM call चलाने के लिए `Sample(...)` लौटाएँ (एक `sampling/createMessage` request), या client के मौजूदा roots लाने के लिए `ListRoots()`। दोनों में से किसी का accept/decline नतीजा नहीं होता; consumer सीधे result type annotate करता है, `CreateMessageResult` (जब request में `tools` या `tool_choice` हो तो `CreateMessageResultWithTools`) या `ListRootsResult`: + +```python title="server.py" hl_lines="10-15 21" +--8<-- "docs_src/dependencies/tutorial004.py" +``` + +* framework इन्हें ठीक `Elicit` की तरह route करता है: **2026-07-28** पर multi-round-trip `tools/call` के भीतर, **2025-11-25** पर standalone server->client request के ज़रिए। declare न की गई capability call को `-32021` protocol error के साथ मना कर देती है (`sampling`, `roots`, form-mode `elicitation`; जब request में `tools` या `tool_choice` हो तो `sampling.tools`)। +* ऊपर वाला info box सवालों के बारे में जो कुछ कहता है, वह बिना बदलाव लागू होता है: `Sample` request का मिलान उसके दर्ज result से उसके हूबहू rendering से होता है, इसलिए उसे tool के arguments और पहले के जवाबों से deterministic ढंग से बनाएँ; तब client LLM call की कीमत प्रति tool call एक बार चुकाता है, प्रति round एक बार नहीं। दर्ज result बाकी call भर `request_state` में साथ चलता है, इसलिए बहुत बड़ा completion बचे हुए हर round-trip को भारी बना देता है। +* standalone sampling और roots **features** 2026-07-28 पर deprecated हैं (SEP-2577)। जिन नए servers को client के model की ज़रूरत है वे इसी carrier के ज़रिए पूछते हैं; जिन्हें नहीं है उन्हें सीधे किसी LLM provider से integrate करना चाहिए। `"none"` के अलावा `include_context` की values खुद deprecated हैं; उनसे बचें। + +## सारांश {#recap} + +* tool parameter पर `Annotated[T, Resolve(fn)]`: SDK `fn` चलाता है और उसकी return value inject करता है। +* resolve किया गया parameter model को नहीं दिखता और कोई client उसे भेज नहीं सकता। जो values model को गढ़नी नहीं चाहिए - कीमतें, पहचान, अनुमतियाँ - उनकी जगह यहीं है। +* resolver के parameters उसी तरह resolve होते हैं: `Context`, कोई और `Resolve(...)`, या नाम से कोई tool argument। graph हर resolver को प्रति round ज़्यादा से ज़्यादा एक बार चलाता है, चाहे उसके कितने भी consumers हों; हर सवाल ठीक एक बार पूछा जाता है, और call के किसी सवाल के बाद फिर से शुरू होने पर कोई भी resolver दोबारा चल सकता है। +* खराब graphs registration के समय `InvalidSignature` के साथ fail होते हैं, call के बीच में नहीं। +* user से पूछने के लिए `Elicit(message, Model)` लौटाएँ, सिर्फ़ तब जब ज़रूरी हो। बिना wrap की annotations decline पर abort करती हैं; `ElicitationResult[T]` tool को branch करने देती है। +* client से LLM completion या roots की सूची माँगने के लिए `Sample(...)` या `ListRoots()` लौटाएँ; सादा result inject हो जाता है। + +server startup पर एक बार जो state बनाता है, और handler उस तक कैसे पहुँचता है, वह **[Lifespan](lifespan.md)** page है। diff --git a/i18n/hi/pages/handlers/elicitation.md b/i18n/hi/pages/handlers/elicitation.md new file mode 100644 index 0000000000..0a47697d87 --- /dev/null +++ b/i18n/hi/pages/handlers/elicitation.md @@ -0,0 +1,191 @@ +--- +translation: + sections: [335ca2a0b266f003, d1ad562d3fe87bc0, 0bb1396c86daeba4, d1cb1235bb9ee267, 833179c09d239c83, e5d6dec2d2e655e8] + tool: 1 +--- +# Elicitation {#elicitation} + +जो tool अपना काम आधा कर चुका हो और उसके पास बस एक जवाब की कमी हो, उसका fail होना ज़रूरी नहीं। + +**Elicitation** उसे पूछने देता है। tool call के बीच में user को एक सवाल मिलता है, और उसका जवाब उसी function call में वापस आ जाता है। + +इसके दो mode हैं: + +* **Form mode**: आपको एक value चाहिए (confirmation, तारीख, मात्रा)। आप fields बताते हैं, client form render करता है। +* **URL mode**: आपको user को कहीं और भेजना है (OAuth consent screen, payment page)। user वहाँ जो कुछ भी करता है, वह protocol से होकर नहीं गुज़रता। + +और पूछने के दो तरीके हैं। जिसे पहले अपनाना चाहिए वह है **resolver**: आप सवाल को एक parameter पर टाँग देते हैं, और SDK पूछ लेता है - किसी भी connection पर, client चाहे किसी भी protocol पीढ़ी का हो। सीधा तरीका, `await ctx.elicit(...)`, *server* से *client* को जाने वाली request है, एक ऐसा channel जो सिर्फ़ legacy connection (spec version 2025-11-25 या उससे पहले) वाले client के लिए ही मौजूद होता है। दोनों इस page पर हैं; resolver से शुरू करें। + +## resolver से पूछना {#ask-with-a-resolver} + +जो सवाल पूरे tool को रोके रखता है - **पक्का? तीन मिलते-जुलते accounts में से कौन-सा?** - उसे tool body से निकालकर **resolver** में रखा जा सकता है, और framework उसे आपके लिए पूछ लेता है। + +`Annotated[T, Resolve(fn)]` से annotate किया गया parameter tool body से पहले `fn` चलाकर भरा जाता है। जब resolver को value पहले से पता हो तो वह उसे सीधे लौटाता है, वरना `Elicit(...)` लौटाता है ताकि framework पूछ ले: + +```python title="server.py" hl_lines="24-30 35-36" +--8<-- "docs_src/elicitation/tutorial004.py" +``` + +* `confirm_delete` tool के अपने `path` argument को नाम से पढ़ता है, folder की सूची बनाता है, और **सिर्फ़ तभी elicit करता है जब ज़रूरी हो** - खाली folder client तक एक भी round trip के बिना `Confirm(ok=True)` में resolve हो जाता है। +* `delete_folder` `ElicitationResult[Confirm]` annotate करता है, इसलिए framework पूरा नतीजा inject करता है और tool हर स्थिति को `match` करता है: accept-and-confirm, accept-but-keep (`ok=False`), decline, cancel। +* `confirm` parameter tool के input schema में कभी नहीं दिखता - client `path` देता है, resolver `confirm` देता है। + +जब tool को branch करने की ज़रूरत न हो तो इसके बजाय unwrapped model (`Annotated[Confirm, Resolve(confirm_delete)]`) annotate करें: accept पर उसे model मिलता है और decline या cancel पर call एक error के साथ abort हो जाता है। + +resolver **हर** connection पर काम करता है। legacy connection वाले client को SDK सवाल सीधे भेजता है; **2026-07-28** connection पर SDK call से सवाल **लौटाता** है, और client की अगली कोशिश जवाब साथ लाती है। आपके resolver को फ़र्क कभी पता नहीं चलता; नीचे जो होता है, वह **[Multi-round-trip requests](multi-round-trip.md)** है। + +पूछना तो resolver के कामों में से सिर्फ़ एक है। सामान्य तंत्र - बिना पूछे compute होने वाली dependencies, dependencies की dependencies, model क्या दे सकता है और क्या नहीं - **[Dependencies](dependencies.md)** page पर है। + +## tool के अंदर से पूछना {#ask-from-inside-the-tool} + +tool अपनी body के बीच में रुककर भी पूछ सकता है। + +!!! warning + `ctx.elicit()` और `ctx.elicit_url()` *server* से *client* को जाने वाली requests हैं - एक + ऐसा channel जो सिर्फ़ legacy connection (spec version **2025-11-25** या उससे पहले) वाले + client के लिए मौजूद होता है। **2026-07-28** connection पर server की ओर से शुरू की गई कोई + request नहीं होती, इसलिए ये calls fail हो जाते हैं। resolver दोनों पर काम करता है। + पूरी जानकारी **[Protocol versions](../protocol-versions.md)** में है। + +`await ctx.elicit()` एक message और एक Pydantic model लेता है: + +```python title="server.py" hl_lines="9-11 20-23 25" +--8<-- "docs_src/elicitation/tutorial001.py" +``` + +* **`Context`** parameter ही आपको `ctx.elicit` देता है; कोई भी tool इसे ले सकता है। उस object का अपना page है: **[Context](context.md)**। +* `AlternativeDate` उस जवाब का **schema** है जो आप चाहते हैं। +* tool `async def` है। होना ही चाहिए: यह बीच में रुककर किसी इंसान का इंतज़ार करता है। +* किसी भी दूसरी तारीख पर tool तुरंत लौट आता है। यह सिर्फ़ तभी पूछता है जब ज़रूरी हो। +* user जो तारीख accept करता है, वह `book_table` से ही होकर वापस जाती है। जवाब भी बाकी input की तरह input ही है: अगर विकल्प वाली तारीख भी पूरी तरह booked है तो उसके बारे में फिर से पूछा जाता है, आँख मूँदकर confirm नहीं किया जाता। + +### client को क्या मिलता है {#what-the-client-receives} + +client को आपका message मिलता है और उसके साथ model से generate किया गया एक JSON Schema: + +```json +{ + "properties": { + "accept_alternative": { + "description": "Try another date?", + "title": "Accept Alternative", + "type": "boolean" + }, + "date": { + "default": "2025-12-26", + "description": "Alternative date (YYYY-MM-DD)", + "title": "Date", + "type": "string" + } + }, + "required": ["accept_alternative"], + "title": "AlternativeDate", + "type": "object" +} +``` + +वही schema form है। `Field(description=...)` label है; default input को पहले से भर देता है और field को optional बना देता है। यह वही Pydantic-to-JSON-Schema तंत्र है जो **[Tools](../servers/tools.md)** tool के arguments के लिए बताता है। + +!!! warning + elicitation schema tool के input schema जितना expressive नहीं होता। सिर्फ़ flat, primitive + fields: `str`, `int`, `float`, `bool`, या strings का `Literal` (यह `enum` बन जाता है)। + model के अंदर model रखें और `ctx.elicit` client को कुछ भी भेजे जाने से पहले ही raise कर देता है: + + ```text + TypeError: Elicitation schema field 'address' rendered as {'$ref': '#/$defs/Address'}, which is not a valid PrimitiveSchemaDefinition + ``` + + आप किसी इंसान को काम के बीच में टोक रहे हैं। अगर जवाब में nesting चाहिए, तो उसे tool का + argument होना चाहिए था। + +### तीन जवाब {#the-three-answers} + +`result.action` बताता है कि user ने क्या किया, और संभावनाएँ ठीक तीन हैं: + +* `"accept"`: user ने form submit किया। `result.data` एक `AlternativeDate` instance है, पहले से validated। +* `"decline"`: user ने मना कर दिया। +* `"cancel"`: user ने बिना कुछ चुने सवाल को हटा दिया। + +`result.data` सिर्फ़ `"accept"` पर ही मौजूद होता है, इसीलिए उदाहरण पहले `result.action` जाँचता है। आपका type checker यह क्रम लागू करता है: `result.action == "accept"` के बाद `result.data` एक `AlternativeDate` है; उससे पहले `.data` है ही नहीं। + +इनकार कोई error नहीं है। decline का क्या मतलब है, यह tool तय करता है (यहाँ, कोई booking नहीं) और model को सामान्य रूप से जवाब देता है। + +!!! tip + जवाब आपके code तक पहुँचने से पहले आपके model के विरुद्ध validate होता है। जो client + `bool` के लिए `"maybe"` भेजता है, वह आपकी booking को खराब नहीं करता: call + schema-mismatch error के साथ fail हो जाता है, आपका `if` कभी नहीं चलता। + +## user को URL पर भेजना {#send-the-user-to-a-url} + +कुछ चीज़ें model या client से होकर कभी नहीं गुज़रनी चाहिए: credentials, card numbers, OAuth consent। इनके लिए आप data नहीं माँगते; आप user से कहीं जाने को कहते हैं: + +```python title="server.py" hl_lines="10-14 23" +--8<-- "docs_src/elicitation/tutorial002.py" +``` + +* `ctx.elicit_url()` message, जाने के लिए **URL**, और आपकी चुनी हुई एक `elicitation_id` लेता है: कोई भी string जो आपके server के भीतर इस elicitation को पहचानती हो। +* result में एक action है और कुछ नहीं। `"accept"` का मतलब है user URL खोलने के लिए राज़ी हुआ, यह **नहीं** कि उसने दूसरी तरफ़ का काम पूरा कर लिया। +* payment out of band होता है, user के browser और आपके payment provider के बीच। MCP से होकर कोई content कभी वापस नहीं आता। + +दूसरा tool देखें। जब आपके server को पता चलता है कि out-of-band flow पूरा हो गया (webhook, poll; यहाँ इसे दूसरे tool के रूप में दिखाया गया है), तो `ctx.session.send_elicit_complete(...)` उसी `elicitation_id` के साथ `notifications/elicitation/complete` भेजता है। इसी से client को पता चलता है कि वह *"waiting for payment..."* दिखाना बंद कर सकता है। इसके बिना client सिर्फ़ अंदाज़ा लगा सकता है। + +## client की तरफ़ {#the-client-side} + +servers पूछते हैं। clients `Client(...)` को एक **`elicitation_callback`** देकर जवाब देते हैं: + +```python title="client.py" hl_lines="6-7 18" +--8<-- "docs_src/elicitation/tutorial003.py" +``` + +* एक ही callback दोनों modes संभालता है। `params` `ElicitRequestFormParams` और `ElicitRequestURLParams` का union है; `isinstance` ही branch है। +* URL के लिए, आप user को `params.url` दिखाते हैं और उसका चुना हुआ action लौटाते हैं। कभी कोई `content` नहीं। +* form के लिए, असली application `params.requested_schema` render करता है और user का input `content` के रूप में लौटाता है। यह वाला हमेशा एक तयशुदा जवाब के साथ हाँ कहता है, जो test में ठीक वैसा ही callback है जैसा आप चाहते हैं। +* callback देना ही **capability declaration** भी है: इसी से server को पता चलता है कि इस client से पूछा जा सकता है। client server के लिए और किन चीज़ों का जवाब दे सकता है, वह **[Client callbacks](../client/callbacks.md)** में है। + +!!! info + elicitation *server* से *client* को जाने वाली request है, और ऐसी requests सिर्फ़ + classic-handshake session पर ही होती हैं, इसीलिए यह client `mode="legacy"` देता है। + **2026-07-28** connection पर tool इसके बजाय call से सवाल **लौटाकर** पूछता है; + वह flow **[Multi-round-trip requests](multi-round-trip.md)** है। + +### इसे आज़माएँ {#try-it} + +`ctx.elicit` वाले form-mode `server.py` (`book_table` वाला) को Streamable HTTP पर शुरू करें (one-liner **[अपना server चलाना](../run/index.md)** में है), फिर client का `main()` चलाएँ और `book_table` से Christmas के दिन के लिए पूछें। + +callback उसे भेजा गया सवाल print करता है: + +```text +No tables for 2 on 2025-12-25. Would you like to try another date? +``` + +यह `{"accept_alternative": True, "date": "2025-12-27"}` से जवाब देता है, और tool, जो इस पूरे समय `await ctx.elicit(...)` के अंदर इंतज़ार कर रहा था, booking पूरी कर देता है: + +```text +Booked a table for 2 on 2025-12-27. +``` + +अब URL-mode वाला `server.py` लगाएँ और उसी `main()` को `pay_deposit` की ओर कर दें: वही callback दूसरी branch लेता है, payment link print करता है, और tool *"Complete the payment in your browser."* के साथ लौटता है। एक round trip, call के बीच में, दोनों दिशाओं में। + +!!! check + अब `Client` से `elicitation_callback=` हटाएँ और `book_table` को Christmas के दिन के लिए + फिर से call करें। पूरा call एक protocol error के साथ fail हो जाता है: + + ```text + Elicitation not supported + ``` + + जिस client ने कोई callback register नहीं किया, उसने `elicitation` capability कभी declare ही + नहीं की, इसलिए पूछने के लिए कोई है ही नहीं। आपके tool को `"decline"` नहीं मिला; उसे exception + मिला। इसे ध्यान में रखकर design करें: हर elicitation के पास "अगर मैं पूछ न सकूँ तो?" का + एक समझदार जवाब होना चाहिए। + +## सारांश {#recap} + +* `Annotated[T, Resolve(fn)]` से annotate किया गया parameter resolver भरता है, जो पूछना ज़रूरी होने पर `Elicit(...)` लौटाता है। यह हर connection पर काम करता है। +* schema एक flat Pydantic model है: सिर्फ़ primitive fields, वापसी पर validate होते हैं। +* `result.action` `"accept"`, `"decline"` या `"cancel"` होता है; `result.data` सिर्फ़ accept पर मौजूद होता है। +* `await ctx.elicit(message, schema=Model)` tool body के अंदर से पूछता है, और `await ctx.elicit_url(message, url, elicitation_id)` उन सब चीज़ों के लिए है जो model से होकर नहीं गुज़रनी चाहिए (`ctx.session.send_elicit_complete(elicitation_id)` बताता है कि out-of-band हिस्सा पूरा हो गया)। दोनों server-to-client requests हैं: इन्हें legacy connection वाला client चाहिए। +* client एक `elicitation_callback` से जवाब देता है, params के type पर branch करके; उसे register करना ही capability declare करना है। +* 2026-07-28 connection पर server सवाल को push करने के बजाय लौटाता है; वही callback **[Multi-round-trip requests](multi-round-trip.md)** से भरता है। + +उस return के नीचे जो कुछ भी है (retry loop, `requestState` की सुरक्षा, इसे खुद चलाना), वह **[Multi-round-trip requests](multi-round-trip.md)** है। diff --git a/i18n/hi/pages/handlers/index.md b/i18n/hi/pages/handlers/index.md new file mode 100644 index 0000000000..692d760eae --- /dev/null +++ b/i18n/hi/pages/handlers/index.md @@ -0,0 +1,24 @@ +--- +translation: + sections: [424930166c4bc6f3] + tool: 1 +--- +# आपके handler के अंदर {#inside-your-handler} + +handler के arguments client से आते हैं। इसके **अलावा** वह जो कुछ पढ़ सकता है, और चलते समय जो कुछ कर सकता है, वह सब यहाँ है। + +वह क्या पढ़ सकता है: + +* **[Context](context.md)** वह एक अतिरिक्त parameter है जिसे कोई भी handler माँग सकता है: चल रही request, उसके headers, उसका session, और progress व change-notification के verbs। +* **[Dependencies](dependencies.md)** वे parameters हैं जिन्हें model कभी नहीं देखता; इन्हें `Resolve` के ज़रिए आपके अपने functions भरते हैं। +* **[Lifespan](lifespan.md)** उस state के बारे में है जिसे server startup पर एक बार बनाता है, और handler `Context` के ज़रिए उस तक कैसे पहुँचता है। + +चलते समय वह क्या कर सकता है: + +* **[Elicitation](elicitation.md)** से user से और input माँगना, और **[Multi-round-trip requests](multi-round-trip.md)**, 2026-07-28 का वह pattern जो इसे ले जाता है। +* **[Sampling और roots](sampling-and-roots.md)** से client से LLM completion या उसके workspace folders माँगना; ये deprecated हैं पर अब भी serve होते हैं। +* किसी धीमे काम पर **[Progress](progress.md)** बताना। +* **[Logging](logging.md)** से logs लिखना (standard error पर, server चलाने वाले के लिए)। +* **[Subscriptions](subscriptions.md)** से subscribe किए हुए clients को बताना कि कुछ बदला है। + +अगर आपने अभी तक कोई handler register नहीं किया है, तो **[Tools](../servers/tools.md)** से शुरू करें। यहाँ का हर page मानकर चलता है कि आपके पास एक handler है। diff --git a/i18n/hi/pages/handlers/lifespan.md b/i18n/hi/pages/handlers/lifespan.md new file mode 100644 index 0000000000..3b66d2b5c7 --- /dev/null +++ b/i18n/hi/pages/handlers/lifespan.md @@ -0,0 +1,107 @@ +--- +translation: + sections: [f3ca8ac5f90f2dfa, 85a1ef3588ba0736, 563346d4d5804933, 9e3528340d0bab53] + tool: 1 +--- +# Lifespan {#lifespan} + +ज़्यादातर असली servers पूरी ज़िंदगी भर कुछ न कुछ संभाले रखते हैं: database pool, HTTP client, load किया हुआ model। + +इसे हर call पर दोबारा बनाना कोई नहीं चाहता, और इसे साफ़-सुथरे ढंग से बंद करना ज़रूर चाहिए। **lifespan** इसी के लिए है। + +## Typed lifespan {#a-typed-lifespan} + +lifespan एक `@asynccontextmanager` है जिसे server मिलता है और जो **एक object** `yield` करता है। आप जो भी yield करते हैं, वह server के चलते रहने तक हर handler को उपलब्ध रहता है। + +```python title="server.py" hl_lines="25-31 34 38 40" +--8<-- "docs_src/lifespan/tutorial001.py" +``` + +इसे नीचे से ऊपर पढ़ें: + +* `app_lifespan` `yield` से **पहले** `Database` को connect करता है और उसके **बाद**, `finally` में, disconnect करता है। यही startup और shutdown है। +* यह `AppContext` yield करता है, एक सादा dataclass जिसमें वे चीज़ें हैं जो आपने set up कीं। आज एक field, कल दस। +* `MCPServer("Bookshop", lifespan=app_lifespan)` ही पूरी wiring है। +* tool के अंदर, yield किया गया object `ctx.request_context.lifespan_context` है। + +lifespan **एक बार** चलता है। server शुरू होने पर (पहली request से पहले) इसमें प्रवेश होता है और server रुकने पर इससे बाहर निकला जाता है। बीच की हर request वही `AppContext` साझा करती है। + +!!! info + अगर आपने FastAPI का `lifespan` लिखा है, तो आप यह पहले से जानते हैं। वही decorator, वही `yield`, वही `finally`। + +### model को क्या दिखता है {#what-the-model-sees} + +कुछ नया नहीं। `ctx` एक **Context** parameter है, इसलिए SDK इसे inject करता है और यह input schema तक कभी नहीं पहुँचता: + +```json +{ + "type": "object", + "properties": { + "genre": {"title": "Genre", "type": "string"} + }, + "required": ["genre"], + "title": "count_booksArguments" +} +``` + +`genre` ही एकमात्र argument है जो model दे सकता है। lifespan आपके server का अपना मामला है। + +`@mcp.resource()` और `@mcp.prompt()` functions भी `ctx` parameter ले सकते हैं, जिसे सिर्फ़ `Context` लिखा जाता है; इसकी वजह अगला section बताता है। `ctx` में जो कुछ भी है, वह सब **[Context](context.md)** में है। + +### यह सच में typed है {#it-really-is-typed} + +annotation को फिर से देखें: `ctx: Context[AppContext]`। + +इसी एक type parameter की वजह से आपके type checker के लिए `ctx.request_context.lifespan_context` एक `AppContext` **है**। `.db` autocomplete होता है; `.dbb` server चलाने से पहले ही error है। + +इसकी जगह सिर्फ़ `Context` लिखें तो `lifespan_context` का type `dict[str, Any]` हो जाता है: type checker के पास यह जानने का कोई तरीका नहीं कि आपके lifespan ने क्या yield किया। runtime पर object फिर भी मौजूद रहता है; बस मदद चली जाती है। + +!!! warning + `Context[AppContext]` **सिर्फ़ tools के लिए** लिखने का तरीका है। इसे किसी `@mcp.resource()` या + `@mcp.prompt()` function पर लगाएँ तो उस handler की हर call विफल हो जाती है। client को error वापस मिलता है, + और server log बताता है क्यों: + + ```text + Context is not available outside of a request + ``` + + resources और prompts में सिर्फ़ `ctx: Context` लिखें। आपके lifespan ने जो object yield किया वह + runtime पर अब भी `ctx.request_context.lifespan_context` ही है; आप type parameter छोड़ते हैं, + object नहीं। + +!!! tip + lifespan हमेशा होता है। अगर आप कोई pass नहीं करते, तो SDK का default एक खाली `dict` yield करता है, + इसलिए `ctx.request_context.lifespan_context` `{}` होता है, कभी `None` नहीं। इसी default की वजह से + सिर्फ़ `Context` लिखने पर इसका type `dict[str, Any]` होता है। + +## इसे होते हुए देखें {#watch-it-happen} + +"startup पहली request से पहले चलता है" ऐसा वाक्य है जिस पर आपको बिना देखे भरोसा नहीं करना पड़ना चाहिए। + +server को सिर्फ़ lifecycle तक सीमित कर दें: `Database` को एक `connected` flag दें, `connect()` और `disconnect()` में उसे पलटें, और एक tool जोड़ें जो उसकी स्थिति बताए। + +```python title="server.py" hl_lines="11 14 17 25 44" +--8<-- "docs_src/lifespan/tutorial002.py" +``` + +`database` module level पर एक ही वजह से है: ताकि आप इसे server के **बाहर** से देख सकें। + +!!! check + तीन पल, तीन values: + + * server शुरू होने से पहले, `database.connected` `False` है। module import करने से कुछ connect नहीं हुआ। + * जब यह चल रहा हो, `database_status` call करें और result `"connected"` मिलता है। + * server रोकें और `finally` block चलता है: `database.connected` फिर से `False` है। + + काम ठीक वहीं हुआ जहाँ आपने उसे रखा: `yield` के आसपास, न import के समय और न हर request पर। + +## सारांश {#recap} + +* `lifespan=` एक `@asynccontextmanager` लेता है जिसे server मिलता है और जो एक object `yield` करता है। +* `yield` से पहले का code startup है। उसके बाद का `finally` shutdown है। +* यह एक बार चलता है, server की पूरी ज़िंदगी के इर्द-गिर्द, हर request पर नहीं। +* आप जो भी `yield` करते हैं, वह हर tool, resource और prompt में `ctx.request_context.lifespan_context` है। +* `ctx: Context[AppContext]` tools में इस access को पूरी तरह typed बना देता है। resources और prompts सिर्फ़ `Context` लेते हैं। +* `lifespan=` न हो तो खाली `dict` मिलता है, कभी `None` नहीं। + +जो handler call के बीच रुककर user से वह पूछता है जो सिर्फ़ user ही जानता है, वह **[Elicitation](elicitation.md)** है। diff --git a/i18n/hi/pages/handlers/logging.md b/i18n/hi/pages/handlers/logging.md new file mode 100644 index 0000000000..5914c20dcd --- /dev/null +++ b/i18n/hi/pages/handlers/logging.md @@ -0,0 +1,86 @@ +--- +translation: + sections: [c93a3e1aefd77955, 7851abd5ec54393b, f49d1ca2f330f9cd, c03764bd9dfeef7b, 4a0391691a674ae4, 2df5cd279eabf9f5] + tool: 1 +--- +# Logging {#logging} + +किसी tool से log वैसे ही करें जैसे किसी भी दूसरे Python function से करते हैं: standard library के साथ। + +MCP में protocol स्तर की **logging capability** है: server अपने log messages को `Context` object के methods के ज़रिए notifications के रूप में client तक भेज सकता था। spec का 2026-07-28 revision **उस capability को deprecate करता है और उसकी जगह कुछ नहीं लाता**, इसलिए ये docs उसे नहीं सिखाते। क्या-क्या deprecated है और उसके बदले क्या करना है, इसकी पूरी सूची **[Deprecated features](../deprecated.md)** में है। + +उसके बदले आप वही करते हैं जो हर दूसरे Python program में करते हैं: standard library। + +## log करने वाला tool {#a-tool-that-logs} + +```python title="server.py" hl_lines="1 5 13" +--8<-- "docs_src/logging/tutorial001.py" +``` + +* `logging.getLogger(__name__)` आपको आपके module के नाम वाला logger देता है। इसे एक बार बनाएँ, सबसे ऊपर। +* tool के अंदर आप `logger.info(...)` को किसी भी दूसरे function की तरह call करते हैं। न कुछ inject करना है, न कुछ `await` करना है, न कुछ MCP-specific है। + +!!! check + tool को call करें और पूरा result देखें: + + ```python + result.content # [TextContent(text="Found 3 books matching 'dune'.")] + result.structured_content # {'result': "Found 3 books matching 'dune'."} + ``` + + log line इसमें कहीं नहीं है। logging **आपके** लिए है, उस व्यक्ति के लिए जो server चला रहा है। model + इसे कभी नहीं देखता। अगर model को कुछ पढ़ना चाहिए, तो उसे `return` करें। + +## यह कहाँ जाता है {#where-it-goes} + +**stdio** server के लिए यह सवाल आम से ज़्यादा मायने रखता है। host ने आपके server को subprocess के रूप में शुरू किया है और उसके **stdout** से MCP messages पढ़ रहा है। standard error आपका है। + +standard library पहले से सही काम करती है: log output default रूप से `sys.stderr` पर जाता है। आपकी `logger.info(...)` lines terminal में पहुँचती हैं (या जहाँ भी host subprocess का stderr इकट्ठा करता है), और protocol stream साफ़ रहता है। + +!!! tip + stdio server में `print()` न करें। `print` **stdout** पर लिखता है, और stdout protocol का है। + serve करते समय SDK उस stdout को stderr की ओर मोड़ देता है जो सच में **flush** हुआ हो, ताकि वह + wire को खराब न कर सके, लेकिन block-buffered process में `print()` आमतौर पर `sys.stdout` के buffer में + बिना flush हुए पड़ा रहता है, जब तक interpreter exit पर उसे खाली नहीं करता, सीधे protocol stream पर। जब उसे + मोड़ा भी जाता है, तब भी वह line log output के बीच कच्ची ही पहुँचती है, बिना level के, बिना logger नाम के, और बिना उसे filter करने के किसी तरीके के। + + `logger.debug("got here")` उतनी ही एक line की मेहनत है और सही जगह जाता है। + +## Level {#the-level} + +आपको `logging.basicConfig()` खुद call करने की ज़रूरत नहीं है। `MCPServer` बनाते ही यह पहले से हो चुका है, standard error की ओर इशारा करते handler के साथ, उस level पर जो आप `log_level=` में देते हैं, इसलिए अपनी `logger.debug(...)` lines देखने के लिए `MCPServer("Bookshop", log_level="DEBUG")` ही काफ़ी है। + +default `"INFO"` है। + +`logging.basicConfig()` पहले से मौजूद handlers को कभी नहीं बदलता। अगर आप server बनाने से पहले खुद logging configure करते हैं, तो आपका configuration ही चलता है। + +## इसे आज़माएँ {#try-it} + +server को MCP Inspector के साथ चलाएँ: + +```console +uv run mcp dev server.py +``` + +**Tools** tab से `search_books` को call करें। Inspector आपको result दिखाता है: सिर्फ़ return value। यह line + +```text +Searching for 'dune' +``` + +standard error पर गई: terminal पर, wire पर नहीं। + +!!! info + अगर आपको असल में **tracing** चाहिए (हर request, उसमें कितना समय लगा, वह fail हुई या नहीं), तो आपको + log lines नहीं, spans चाहिए। आपका server उन्हें पहले से भेजता है: SDK बिना कुछ configure किए हर + message को OpenTelemetry से trace करता है। **[OpenTelemetry](../run/opentelemetry.md)** देखें। + +## सारांश {#recap} + +* MCP protocol की logging capability को 2026-07-28 spec deprecate करता है और उसकी जगह कुछ नहीं लाता। उस पर कुछ न बनाएँ। +* module स्तर पर `logger = logging.getLogger(__name__)`, tool में `logger.info(...)`। पूरा pattern बस इतना ही है। +* log output कभी model तक नहीं पहुँचता। सिर्फ़ वही value पहुँचती है जो आप `return` करते हैं। +* standard error आपका है; stdout protocol का है। serve करते समय SDK flush हुए भटके stdout को stderr की ओर मोड़ देता है, लेकिन बिना flush हुआ `print()` फिर भी exit पर wire पर खाली हो सकता है, और मोड़ी गई lines बिना label के पहुँचती हैं; `logging` इस्तेमाल करें, जिसका handler हर record को flush करता है। +* `MCPServer(..., log_level="DEBUG")` level तय करता है, और जो logging configuration आपने पहले बनाया हो उसे छेड़ा नहीं जाता। + +जुड़े हुए clients को यह बताना कि आपके server पर कुछ बदला है (tool list, कोई resource), **[Subscriptions](subscriptions.md)** का विषय है। diff --git a/i18n/hi/pages/handlers/multi-round-trip.md b/i18n/hi/pages/handlers/multi-round-trip.md new file mode 100644 index 0000000000..251145ad9d --- /dev/null +++ b/i18n/hi/pages/handlers/multi-round-trip.md @@ -0,0 +1,191 @@ +--- +translation: + sections: [74011e683045eea9, 9b64cc175c18b6a9, 4b41be4824030397, e3b1502da786ec33, 71e41161f143c6a9, 9ec2c1eeb8c36378, 8dd027377d46448b, f81491125dcbfe8b] + tool: 1 +--- +# Multi-round-trip requests {#multi-round-trip-requests} + +कभी-कभी कोई tool एक round trip में पूरा नहीं हो पाता। उसे कुछ ऐसा चाहिए जो सिर्फ़ user के पास है: कोई चुनाव, कोई पुष्टि, कोई credential। + +2026-07-28 से पहले server यह चीज़ **वापस call करके** लेता था: मूल request को संभालने के बीच में ही client की तरफ़ अपनी request खोलकर (कोई elicitation, कोई sampling call)। 2026-07-28 spec उस back-channel को बंद कर देता है। + +इसके बजाय, server **लौटाता** है। + +## लौटाएँ, वापस call न करें {#return-dont-call-back} + +server `tools/call` का जवाब `CallToolResult` की जगह **`InputRequiredResult`** से देता है। इसके दो fields सारा काम करते हैं: + +* **`input_requests`**: server को अभी और क्या चाहिए, एक dict के रूप में जिसकी keys server ने खुद चुनी हैं। हर value एक `ElicitRequest`, `CreateMessageRequest`, या `ListRootsRequest` है। +* **`request_state`**: एक opaque token। client retry पर इसे ज्यों का त्यों वापस भेजता है। इसे पढ़ने वाला सिर्फ़ server है। + +client हर request पूरी करता है, फिर **उसी tool को दोबारा** call करता है, अपने जवाब `input_responses` में और token `request_state` में लेकर। अब server के पास वह है जो पहले नहीं था, और वह सामान्य `CallToolResult` लौटाता है। + +पूरा protocol बस इतना ही है। हर चरण client से server की ओर जाने वाली साधारण request है। उल्टी दिशा में कभी कुछ नहीं बहता। + +## server की तरफ़ {#the-server-side} + +`@mcp.tool()` पर आप इसे शायद ही कभी हाथ से बनाते हैं: ऐसी dependency घोषित करें जो user से पूछती है (`Elicit`), client के LLM से sample लेती है (`Sample`), या उसके roots की सूची लेती है (`ListRoots`), और SDK आपके लिए `InputRequiredResult` लौटा देता है; वह रूप **[Dependencies](dependencies.md)** page पर है। दोनों रूप आपस में नहीं मिलते: एक call के पास `input_responses`/`request_state` का एक ही channel होता है, इसलिए `Resolve(...)` parameters इस्तेमाल करने वाला tool अपनी body से `InputRequiredResult` भी नहीं लौटा सकता। घोषित `InputRequiredResult` return registration के समय ही अस्वीकार हो जाता है (`InvalidSignature`), और बिना घोषित वाला runtime पर call को fail कर देता है। हाथ से बनाने वाला रूप **low-level** `Server` है, जिसके `on_call_tool` handler को दोनों में से कोई भी result type लौटाने की अनुमति है: + +```python title="server.py" hl_lines="43-46" +--8<-- "docs_src/mrtr/tutorial001.py" +``` + +* `on_call_tool` का type `-> CallToolResult | InputRequiredResult` है। दूसरा वाला लौटाना ही server की तरफ़ का पूरा API है। +* पहली call पर `params.input_responses` `None` है, इसलिए guard चलता है और handler जवाब देने के बजाय पूछता है। +* retry पर, client का भेजा `ElicitResult` **उसी key** (`"region"`) के नीचे रखा मिलता है जो server ने `input_requests` में इस्तेमाल की थी। + +उस file का बाकी सब कुछ (स्पष्ट `input_schema`, हाथ से बना `CallToolResult`) साधारण low-level `Server` है, जो **[Low-level Server](../advanced/low-level-server.md)** में बताया गया है। यह page सिर्फ़ दूसरा return type जोड़ता है। + +## tools से आगे {#beyond-tools} + +`tools/call` में कुछ खास नहीं है: 2026-07-28 पर server `prompts/get` और `resources/read` का जवाब भी इसी तरह दे सकता है। `MCPServer` पर, `@mcp.prompt()` function — या `@mcp.resource()` **template** function — खुद `InputRequiredResult` लौटाता है और retry के जवाब context से पढ़ता है: + +```python title="server.py" hl_lines="20 22 24" +--8<-- "docs_src/mrtr/tutorial004.py" +``` + +* पहला round `InputRequiredResult` लौटाता है। retry पर, `ctx.input_responses` में वही keys के नीचे जवाब होते हैं और function अपना साधारण result लौटाता है — यहाँ prompt messages, template resource के लिए resource content। +* आपका set किया `request_state` wire पार करने से पहले seal होता है और echo पर verify होता है, server पर बाकी सब की तरह; नीचे **[`requestState` की सुरक्षा](#protecting-requeststate)** बताता है कि seal आपको क्या देता है और keys कब configure करनी होती हैं। +* जब dependency वाला रूप फिट न बैठे, तो `@mcp.tool()` function भी इसी तरह सीधे result लौटा सकता है। +* static `@mcp.resource()` functions इसमें हिस्सा नहीं लेते: वे `Context` नहीं लेते, इसलिए retry कभी पढ़ ही नहीं सकते। सिर्फ़ template resources पूछ सकते हैं। +* नीचे दिए पीढ़ी के नियम बिना बदलाव लागू होते हैं: pre-2026 session पर `InputRequiredResult` लौटाना वही `-32603` है जिसका ज़िक्र warning में है। + +## client की तरफ़ {#the-client-side} + +`Client` आपके लिए loop चलाता है। + +वे callbacks register करें जो server माँग सकता है (`elicitation_callback`, `sampling_callback`, `list_roots_callback`) और tool call करें। जब `InputRequiredResult` आता है, `Client` `input_requests` की हर entry को मेल खाते callback के पास भेजता है, जवाबों और echo किए `request_state` के साथ retry करता है, और तब तक चलता रहता है जब तक `CallToolResult` वापस न आ जाए: + +```python title="client.py" hl_lines="11 12" +--8<-- "docs_src/mrtr/tutorial003.py" +``` + +* वह `elicitation_callback` वही है जिस पर pre-2026 server का back-channel `elicitation/create` पहुँचता। `sampling/createMessage` के लिए `sampling_callback` और `roots/list` के लिए `list_roots_callback` पर भी यही बात लागू है: 2026-07-28 पर अलग से चलने वाले server->client RPC चले गए हैं, लेकिन हूबहू वही `ElicitRequest` / `CreateMessageRequest` / `ListRootsRequest` payloads `input_requests` के अंदर आते हैं और उन्हीं तीन callbacks तक पहुँचते हैं। callbacks का एक ही set दोनों पीढ़ियों को serve करता है। +* `call_tool` सादा `CallToolResult` लौटाता है। बीच के rounds caller को नहीं दिखते। +* `get_prompt` और `read_resource` भी यही loop चलाते हैं। + +!!! check + callback न लगाएँ तो loop पहले ही round में fail हो जाता है: SDK का stand-in callback + हर elicitation का जवाब error से देता है, और `call_tool` *"Elicitation not supported"* + message के साथ `MCPError` raise करता है। + +loop की सीमा है। `Client(..., input_required_max_rounds=10)` default cap है; जो server उससे आगे भी `InputRequiredResult` लौटाता रहे, वह `call_tool` से raise करवा देता है। अगर किसी round में सिर्फ़ `request_state` हो और कोई `input_requests` न हो, तो `Client` retry करने से पहले थोड़ी देर sleep करता है (50ms से दोगुना होते हुए 250ms की सीमा तक), ताकि जो server बस *"अभी पूरा नहीं हुआ"* कह रहा है उसे लगातार poll न किया जाए। + +### loop खुद चलाना {#driving-the-loop-yourself} + +एक ही process वाले client के लिए auto-loop काफ़ी है। loop खुद तब संभालें जब: + +* आपका client **distributed** है: जो process user को सवाल दिखाता है, वह वही process नहीं है जिसने `call_tool` call किया था, इसलिए retry कोई दूसरा worker भेजता है। `request_state` वह सहेजा जा सकने वाला token है जिसे आप अपने storage के ज़रिए उस सीमा के पार ले जाते हैं, और `input_responses` वह है जो दूसरी तरफ़ से उसके साथ वापस आता है। +* आप हर round को **जाँचना** चाहते हैं: `input_requests` की हर entry को log या audit करना, कुछ तरह की requests को मना करना, या चरणों के बीच अपना backoff लगाना। +* आपको round की गिनती के बजाय **घड़ी के समय** की सीमा चाहिए: `input_required_max_rounds` पर निर्भर रहने के बजाय अपने loop को `anyio.fail_after(...)` में लपेटें। + +नीचे के session पर उतरें, जहाँ `allow_input_required=True` आपको सीधे union देता है: + +```python title="client.py" hl_lines="12 13 19" +--8<-- "docs_src/mrtr/tutorial002.py" +``` + +* `client.session.call_tool(..., allow_input_required=True)` return type को `CallToolResult | InputRequiredResult` तक चौड़ा कर देता है। `isinstance` ही उसे वापस संकरा करता है। +* `request_state` अब आपके हाथ में है। चरणों के बीच इसे लिखकर रख लें तो बातचीत किसी नए process से फिर शुरू हो सकती है। +* `input_requests` की हर entry के लिए आप `input_responses` में **उसी key** के नीचे एक `InputResponse` रखते हैं। `fulfil` वह जगह है जहाँ आपका UI आता है; यह वाला जवाब hard-code करता है। +* हर चरण में वही tool name, वही `arguments`। retry मूल call को दोबारा पूरा करना है, कोई नया method नहीं। + +## `requestState` की सुरक्षा {#protecting-requeststate} + +ऊपर सब कुछ `request_state` को echo मानता है, और wire पर वह बस इतना ही है। लेकिन client इसे चरणों के बीच अपने पास रखता है (processes के पार इसे लिखकर रखना ही वह चीज़ है जिसकी पिछले section ने अनुमति दी), इसलिए जो वापस आता है वह **client का दिया input** है: उसमें बदलाव हो सकता है, वह expire हो सकता है, या किसी बिल्कुल अलग call से उठाया गया हो सकता है। spec की माँग है कि जब भी यह state authorization, resource access, या business logic पर असर डाल सकता हो, servers इस state की integrity सुरक्षित रखें और verification fail होने पर round को अस्वीकार करें। + +`MCPServer` default रूप से इसकी सुरक्षा करता है। हर server बाहर जाने वाले `requestState` को seal करता है और हर echo को verify करता है — resolver state और हाथ से बना state, दोनों — process शुरू होने पर बनी key के तहत। आपको कुछ configure नहीं करना, plaintext लिखना है और plaintext पढ़ना है; wire पर सिर्फ़ एक opaque encrypted token जाता है। + +default key process के साथ ही जीती-मरती है, और एक process से आगे deploy करने से पहले यही एक बात आपको पता होनी चाहिए: + +```python +from mcp.server.mcpserver import MCPServer, RequestStateSecurity + +# Multi-instance or restart-surviving: one or more shared secret keys (>= 32 bytes each). +mcp = MCPServer("fleet", request_state_security=RequestStateSecurity(keys=[key])) +``` + +* **default (बिना configuration)** एक process के लिए ठीक है: stdio, या ठीक एक HTTP worker। जो retry किसी दूसरे worker पर, load balancer के पीछे किसी दूसरे instance पर, या restart के बाद उसी server पर पहुँचती है, वह ऐसी key के तहत seal हुई होती है जो उस process के पास नहीं है — client को नीचे वाला तय rejection मिलता है और उसे flow फिर से शुरू करना पड़ता है। +* **`keys=[...]`** तब ज़रूरी है जब भी retry किसी **दूसरे instance** तक पहुँच सकती हो (multi-worker `uvicorn`, load-balanced HTTP) या restarts के पार बचनी हो: हर instance वह verify करता है जो किसी भी sibling ने mint किया। वही मशीनरी, बनाई गई key की जगह आपका secret। +* अपनी crypto के लिए, जैसे कोई KMS या मौजूदा token service, `keys` की जगह `RequestStateSecurity(codec=...)` दें; नीचे **[अपनी crypto लाएँ](#bring-your-own-crypto)** contract बताता है। + +### seal में क्या होता है {#what-the-seal-carries} + +default हो या configured, wire पर `requestState` एक encrypted, authenticated token है। आपका code इसे कभी नहीं देखता: handlers और resolvers plaintext लिखते हैं और plaintext पढ़ते हैं (`ctx.request_state`); SDK बाहर जाते समय seal करता है और अंदर आते समय verify करता है। integrity के अलावा, हर token इनसे बँधा होता है: + +* **एक समय सीमा।** हर round नई expiry के साथ दोबारा seal करता है, इसलिए `RequestStateSecurity(ttl=...)` (default 600 seconds) हर round के सोचने के समय को बाँधता है, पूरे flow को नहीं। +* **authenticated principal।** जब request में ऐसा OAuth access token हो जिसे SDK ने validate किया, तो state उस token के client, issuer, और subject से बँध जाता है: एक user के लिए mint हुआ state दूसरे user के तहत fail होता है, भले ही दोनों users एक ही OAuth client साझा करते हों। जो verifier कोई subject नहीं देता, उसके साथ binding घटकर सिर्फ़ client identity तक रह जाती है, जो URL-आधारित client IDs में उस client software के हर user के बीच साझा होती है। जब auth SDK के बाहर खत्म होता है (आगे लगा proxy), या transport unauthenticated है, तो बाँधने के लिए कोई principal नहीं होता और यह जाँच निष्क्रिय रहती है, जब तक `RequestStateSecurity(bind_principal=...)` आपके अपने identity signal से कोई principal न दे। आपका token verifier जो भी components देता है, उन्हें लगातार एक जैसे देना चाहिए: जो verifier कुछ requests पर subject शामिल करे और दूसरों पर छोड़ दे, वह flow के बीच में principal बदल देता है, और चल रहे rounds अस्वीकार हो जाते हैं। +* **मूल request।** method, tool या prompt का नाम (या resource URI), और arguments का digest। किसी दूसरे tool, दूसरे arguments, या दूसरे method के विरुद्ध replay किया token fail होता है। +* **पूछा गया ठीक वही सवाल।** हर resolver जवाब उस rendered सवाल से जुड़ा होता है जो client को दिखाया गया था, उस round पर भी जब वह पहली बार आता है और तब भी जब दर्ज किया जवाब बाद में दोबारा इस्तेमाल होता है। बदले हुए शब्दों वाले message या बदले schema के साथ redeploy करें तो server बासी जवाब खाने के बजाय दोबारा पूछता है। यही जुड़ाव दूसरी दिशा में भी असर करता है: messages tool के arguments से बनाएँ, हर call के data से नहीं। timestamp या live rate से बना message हर round में अलग render होता है, इसलिए हर दर्ज जवाब बासी दिखता है और server तब तक दोबारा पूछता रहता है जब तक client की round सीमा call को खत्म न कर दे। + +यह सब SDK का काम है, आपका नहीं, और अगर आप अपना codec लाते हैं तो codec का भी नहीं। + +### keys बदलना (rotation) {#rotating-keys} + +`keys[0]` नया state seal करती है; सूची की हर key verify करती है। zero-downtime rotation तीन चरणों में होता है, हर चरण अगले से पहले पूरी तरह roll out: + +```python +RequestStateSecurity(keys=[OLD, NEW]) # 1: every instance learns to verify NEW; OLD still mints +RequestStateSecurity(keys=[NEW, OLD]) # 2: NEW mints; in-flight OLD state keeps verifying +RequestStateSecurity(keys=[NEW]) # 3: one ttl after phase 2 is fully out, retire OLD +``` + +minter को कभी पहले promote न करें: ऐसी key के तहत mint करना जिसे कोई instance अभी verify नहीं कर सकता, rollout के बीच में चल रहे rounds गिरा देता है। + +keys एक service तक सीमित हैं। sealed envelope में server का नाम audience claim के रूप में भी होता है, इसलिए किसी दूसरी service का mint किया token, जो संयोग से वही secret साझा करती हो, वैसे भी अस्वीकार हो जाता है। claim उतना ही विशिष्ट है जितना नाम, इसलिए जिस server को स्पष्ट policy दी गई हो उसका असली नाम होना चाहिए या उसे `RequestStateSecurity(audience=...)` set करना चाहिए — बिना नाम वाला construction पर ही raise करता है। `audience=` जान-बूझकर बनाई multi-service topologies के भी काम आता है जहाँ एक service को दूसरी का mint किया state स्वीकार करना हो। (बिना configuration वाला default इससे मुक्त है: उसकी key कभी process से बाहर नहीं जाती, इसलिए audience claim के पास जोड़ने को कुछ नहीं है।) + +### अपनी crypto लाएँ {#bring-your-own-crypto} + +`RequestStateSecurity(codec=...)` ऐसी कोई भी चीज़ लेता है जिसमें `seal(bytes) -> str` और `unseal(str) -> bytes` हों और जो हर उस token के लिए `InvalidRequestState` raise करे जो उसने mint नहीं किया। इसका classic रूप KMS के विरुद्ध envelope encryption है, जहाँ आप startup पर एक बार data key unwrap करते हैं और हर token की crypto local रखते हैं: + +```python title="server.py" hl_lines="12 26-27 34-35 38" +--8<-- "docs_src/mrtr/tutorial005.py" +``` + +TTL, principal binding, और request binding codec का काम **नहीं** हैं: SDK हर codec के लिए इन्हें `seal` से पहले payload में डालता है और `unseal` के बाद दोबारा verify करता है। codec की ज़िम्मेदारियाँ सिर्फ़ integrity (छेड़छाड़ का मतलब raise) और, आदर्श रूप से, confidentiality हैं। + +### जब verification fail हो {#when-verification-fails} + +हर inbound failure, चाहे छेड़छाड़ हुई हो, expire हुआ हो, किसी दूसरी request या principal के विरुद्ध replay हुआ हो, या ऐसी key के तहत seal हुआ हो जिसे यह server नहीं जानता, एक ही जवाब पाता है: + +```json +{"code": -32602, "message": "Invalid or expired requestState"} +``` + +हर कारण के लिए एक ही तय message, ताकि wire कभी न बताए कि कौन सी जाँच fail हुई; असली कारण server log में जाता है। `tools/call`, `prompts/get`, और `resources/read` पर हर inbound `requestState` जाँचा जाता है, वह भी जो ऐसे handler के लिए आए जो कभी state mint नहीं करता। व्यवहार में सबसे आम rejection कोई हमलावर नहीं है — यह default process-local key का restart से पहले वाली या किसी दूसरे instance की retry से टकराना है; client flow फिर शुरू करता है, और जब यह मायने रखता हो तो `keys=[...]` ही उपाय है। + +### हाथ से बना state {#hand-built-state} + +जो `request_state` आप खुद set करते हैं (tool, prompt, या resource-template function से `InputRequiredResult` लौटाकर), वह उसी मशीनरी से seal और verify होता है जिससे resolver state, code में एक भी बदलाव के बिना: plaintext लिखें, plaintext पढ़ें, और ऊपर की हर binding लागू होती है। + +एक चीज़ जो SDK आपके लिए तय नहीं कर सकता, configured होने पर भी, वह है सवाल की पहचान: उसे नहीं पता कि आपके state में कोई जवाब **आपके** किस सवाल का है। अगर आप जवाब सवाल की key से store करते हैं, तो state में अपना सवाल-identifier शामिल करें और retry पर उसे जाँचें। + +low-level `Server` बिना-batteries वाला स्तर है: `MCPServer` के विपरीत, जब तक आप boundary खुद न जोड़ें तब तक कुछ seal नहीं होता, और ऐसा करने तक आपका `request_state` ठीक वैसे ही wire पार करता है जैसा लिखा गया। एक line वाला opt-in **[Low-level Server](../advanced/low-level-server.md#the-other-handlers)** में दिखाया गया है। + +## एक 2026-07-28 result {#a-2026-07-28-result} + +`InputRequiredResult` सिर्फ़ protocol version **2026-07-28** पर मौजूद है। in-memory `Client(server)` इसे आपके लिए negotiate करता है; wire पर, `mode="auto"` इसे खोज लेता है। connect करने के बाद `client.protocol_version` बताता है कि आपको क्या मिला। + +!!! warning + pre-2026 session के पास `InputRequiredResult` रखने की कोई जगह नहीं है। `mode="legacy"` connection पर + अपने handler से इसे लौटाएँ तो runner इसे negotiate हुए version में serialize नहीं कर पाता; client + को `-32603` *"Handler returned an invalid result"* error वापस मिलता है। जो server दोनों पीढ़ियों को + serve करता है, उसे इसका सहारा लेने से पहले `ctx.protocol_version` जाँचना होगा। + +!!! info + **URL-mode elicitation** 2026 connection पर ठीक इसी mechanism पर चलता है। `input_requests` + की entry ऐसी `ElicitRequest` है जिसके params `ElicitRequestURLParams` हैं; user out-of-band + flow पूरा करता है और आपका client call retry करता है। वही loop, कोई नया API नहीं। high-level + server वाला हिस्सा **[Elicitation](elicitation.md)** में है। + +## सारांश {#recap} + +* 2026-07-28 पर जिस server को call के बीच input चाहिए, वह `InputRequiredResult` **लौटाता** है। वह client की तरफ़ कभी request नहीं खोलता। +* `input_requests` वह है जो उसे चाहिए। `request_state` एक opaque resume token है जिसे सिर्फ़ server पढ़ता है। +* `Client` आपके लिए retry loop चलाता है: `elicitation_callback` / `sampling_callback` / `list_roots_callback` register करें और `call_tool` सादा `CallToolResult` लौटाता है। `input_required_max_rounds` (default 10) इसकी सीमा है। +* rounds जाँचने या सहेजने के लिए `client.session.call_tool(..., allow_input_required=True)` इस्तेमाल करें और `while isinstance(result, InputRequiredResult)` loop खुद संभालें। +* `@mcp.tool()` पर, user से पूछने वाली dependency यह result आपके लिए बनाती है (**[Dependencies](dependencies.md)**); **low-level** `Server` हाथ से बनाने वाला रूप है। +* prompts और resources भी हिस्सा लेते हैं: `@mcp.prompt()` या template `@mcp.resource()` function खुद `InputRequiredResult` लौटाता है और retry पर `ctx.input_responses` पढ़ता है। +* `requestState` client के दिए input के रूप में वापस आता है, इसलिए `MCPServer` इसे default रूप से seal करता है — resolver state और हाथ से बना state, दोनों — process-local key के तहत; multi-instance deployments `RequestStateSecurity(keys=[...])` (या custom codec) देते हैं ताकि हर instance वह verify कर सके जो किसी sibling ने mint किया। seal हर token को एक समय सीमा, मूल request, और authenticated principal से बाँधता है, जब request में SDK का validate किया auth हो या `bind_principal=` आपका अपना identity signal दे (**[`requestState` की सुरक्षा](#protecting-requeststate)**)। + +यही वह mechanism है जो server-initiated sampling और push-शैली के बाकी back-channel की जगह लेता है; **[Deprecated features](../deprecated.md)** देखें। diff --git a/i18n/hi/pages/handlers/progress.md b/i18n/hi/pages/handlers/progress.md new file mode 100644 index 0000000000..b7b3f16ca7 --- /dev/null +++ b/i18n/hi/pages/handlers/progress.md @@ -0,0 +1,121 @@ +--- +translation: + sections: [5315262fe26b33e1, 9d8e98840f1b78f0, 0284b215e85366c4, 8534d8dbb4053a70, 2966fac6fe697007] + tool: 1 +--- +# Progress {#progress} + +जो tool तीस सेकंड लेता है और तीस सेकंड तक कुछ नहीं बोलता, वह टूटा हुआ लगता है। + +**Progress notifications** इसे ठीक करते हैं। Tool बताता है कि काम कितना हो चुका है; client तय करता है कि उससे क्या दिखाए: bar, spinner, या log line। + +## Tool से report करें {#report-it-from-the-tool} + +एक **`Context`** parameter लें और `report_progress` call करें: + +```python title="server.py" hl_lines="8 11" +--8<-- "docs_src/progress/tutorial001.py" +``` + +तीन arguments, और उनका मतलब आप तय करते हैं: + +* `progress`: आप कहाँ तक पहुँचे हैं। Spec की माँग है कि यह हर report के साथ **बढ़े**; कोई value न दोहराएँ, न पीछे जाएँ। +* `total`: कुल कितना है, अगर आपको पता हो। Optional। +* `message`: **इसी** चरण के बारे में एक human-readable line। Optional। + +`ctx` अपने type hint की वजह से inject होता है और model इसे कभी नहीं देखता: `import_catalog` के input schema में सिर्फ़ एक property है, `urls`। **[Context](context.md)** page पूरी तरह उसी object के बारे में है; progress उन चीज़ों में से एक है जो वह आपको देता है। + +## Client से सुनें {#listen-for-it-from-the-client} + +Client **हर call पर** अलग से opt in करता है, `call_tool` को `progress_callback=` देकर: + +```python title="client.py" hl_lines="7 16" +import anyio +from mcp import Client + +from server import mcp + + +async def show(progress: float, total: float | None, message: str | None) -> None: + print(f"{message} ({progress}/{total})") + + +async def main() -> None: + async with Client(mcp) as client: + result = await client.call_tool( + "import_catalog", + {"urls": ["https://example.com/a.json", "https://example.com/b.json"]}, + progress_callback=show, + ) + print(result.structured_content) + + +anyio.run(main) +``` + +Callback एक `async` function है जो ठीक वही लेता है जो server ने report किया: `progress`, `total`, `message`। + +!!! info + `Client(mcp)` सीधे server object से जुड़ता है, memory में, वही client जिस पर **[Testing](../get-started/testing.md)** + page बना है। `Client` चाहे कोई भी transport इस्तेमाल करे, `progress_callback` parameter वही रहता है; + जो **timing** आप अभी देखने वाले हैं वह in-memory connection की है। वह आपका callback inline चलाता है, + इसलिए हर report `call_tool` के लौटने से पहले पहुँच जाती है। असली transport पर notifications और result + में होड़ लगती है, और एक धीमा callback `call_tool` के लौटने के बाद भी चल रहा हो सकता है। + +### इसे आज़माएँ {#try-it} + +`client.py` को `server.py` के बगल में रखें और चलाएँ: + +```console +python client.py +``` + +```text +Imported https://example.com/a.json (1/2) +Imported https://example.com/b.json (2/2) +{'result': 'Imported 2 records.'} +``` + +Server पर हर `await ctx.report_progress(...)` client पर `show` का एक call बना, उसी क्रम में, और दोनों lines `call_tool` के लौटने से **पहले** print हुईं। Progress result में बंडल होकर नहीं आता; tool के काम करते रहने के दौरान ही stream होता है। + +!!! warning + `progress_callback` **call** का है, `Client` का नहीं। इसके लिए कोई constructor argument नहीं है, + क्योंकि अलग-अलग calls को अलग-अलग callbacks चाहिए: एक download bar चलाता है, अगला + एक log line। + +!!! check + अब `progress_callback=show` हटा दें और फिर से चलाएँ: + + ```text + {'result': 'Imported 2 records.'} + ``` + + कोई error नहीं, कोई warning नहीं, वही result। जब caller ने progress नहीं माँगा हो तब + `report_progress` **no-op** है, इसलिए आप बिना शर्त report करें और कभी यह सोचने की ज़रूरत नहीं + कि कोई सुन भी रहा है या नहीं। + +## जब total पता न हो {#when-you-dont-know-the-total} + +`total` तब के लिए है जब आपको denominator पता हो। अक्सर नहीं होता: आप कोई feed खाली कर रहे हैं, cursor पर चल रहे हैं, बिना length header वाली कोई चीज़ download कर रहे हैं। + +इसे छोड़ दें: + +```python title="server.py" hl_lines="20" +--8<-- "docs_src/progress/tutorial002.py" +``` + +Callback को `total=None` मिलता है। Client अब भी **activity** दिखा सकता है ("3 imported so far..."), लेकिन percentage नहीं दिखा सकता। ज़्यादा सुंदर bar पाने के लिए कोई total न गढ़ें। + +!!! tip + ज़रूरी नहीं कि `progress` किसी ख़ास चीज़ को गिने। Bytes, rows, pages: वह unit चुनें जिसे + user पहचाने, और सिर्फ़ वही `total` वादा करें जिसे आप निभा सकें। + +## सारांश {#recap} + +* `Context` लेने वाले किसी भी tool से `await ctx.report_progress(progress, total=None, message=None)`। +* Client `call_tool` को `progress_callback=` देता है: हर call पर, कभी `Client` पर नहीं। +* Callback `async (progress, total, message) -> None` है और tool के चलते रहने के दौरान ही fire होता है। +* Call पर callback न हो तो `report_progress` कुछ नहीं करता। बिना शर्त report करें। +* जब `total` पता न हो तो उसे छोड़ दें; callback को `None` मिलता है। + +Progress वह है जो चलता हुआ tool **user** को दिखाता है। जो lines वह **आपके** लिए, यानी server चलाने वाले व्यक्ति के लिए log करता है, वे एक अलग channel हैं: **[Logging](logging.md)**। diff --git a/i18n/hi/pages/handlers/sampling-and-roots.md b/i18n/hi/pages/handlers/sampling-and-roots.md new file mode 100644 index 0000000000..77b0d6d3cd --- /dev/null +++ b/i18n/hi/pages/handlers/sampling-and-roots.md @@ -0,0 +1,51 @@ +--- +translation: + sections: [5c82b20cbd65ded0, 9dc22632be79a533, 1fb8f452e990c456, 42666ab914ff0cb1, c4e0cb3667fd5ff9] + tool: 1 +--- +# Sampling और roots {#sampling-and-roots} + +handler जुड़े हुए client से दो और चीज़ें माँग सकता है: client के अपने model से एक completion (**sampling**), और client के workspace folders (**roots**)। + +दोनों अब भी काम करते हैं, उस हर protocol version पर जो SDK बोलता है। लेकिन इनके इर्द-गिर्द design बनाने से पहले यह warning पढ़ लें: + +!!! warning "2026-07-28 specification में deprecated" + Sampling और roots `2026-07-28` से deprecated हैं ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2577))। ये पूरी तरह काम करते रहेंगे और हटाए जाने योग्य होने से पहले कम से कम बारह महीने specification में बने रहेंगे, लेकिन नए implementations को इन पर नहीं बनना चाहिए। सुझाए गए migrations: sampling की जगह सीधे अपने LLM provider की API से integrate करें, और roots की जगह directories को tool parameters, resource URIs या server configuration से दें। SDK भर की सूची **[Deprecated features](../deprecated.md)** में है। + +## Sampling: client का model उधार लेना {#sampling-borrow-the-clients-model} + +resolver `Sample(...)` लौटाता है और tool को completion मिलता है, उसी dependency तंत्र के ज़रिए जो **[Dependencies](dependencies.md)** में `Elicit` चलाता है: + +```python title="server.py" hl_lines="10-15 19" +--8<-- "docs_src/sampling_and_roots/tutorial001.py" +``` + +* `Sample(messages, max_tokens=...)` `sampling/createMessage` के parameters को दोहराता है। inject की गई value client का `CreateMessageResult` है; `tools` या `tool_choice` दें तो यह `CreateMessageResultWithTools` बन जाता है। +* client ने `sampling` capability declare की होनी चाहिए (अगर आप `tools` या `tool_choice` देते हैं तो `sampling.tools`)। अगर नहीं की, तो ऐसी request भेजने के बजाय जिसे client संभाल नहीं सकता, call `-32021` protocol error के साथ fail हो जाता है। बिना back-channel वाला 2026 से पहले का session अपने सामान्य no-back-channel error के साथ fail होता है, क्योंकि भेजने के लिए कोई रास्ता ही नहीं है। +* `2026-07-28` पर request multi-round-trip flow के अंदर पहुँचाई जाती है (**[Multi-round-trip requests](multi-round-trip.md)**); `2025-11-25` पर यह client को भेजी गई एक अलग request होती है। code दोनों तरह से वही रहता है, लेकिन multi-round-trip का नियम ध्यान में रखें: request हर retry round में एक जैसी बननी चाहिए, इसलिए इसे सिर्फ़ tool के arguments और दूसरे स्थिर data से ही बनाएँ। +* `include_context` को न छेड़ें: `"none"` के अलावा बाकी values खुद deprecated हैं (SEP-2596) और उन्हें ऐसी capability चाहिए जो लगभग कोई client declare नहीं करता। + +## Roots: यह कहाँ जाए? {#roots-where-should-this-go} + +Roots वे folders हैं जिन पर, client के अनुसार, server काम कर सकता है। ये जानकारी के लिए दिए गए मार्गदर्शन हैं, access-control का तंत्र नहीं। resolver `ListRoots()` लौटाता है: + +```python title="server.py" hl_lines="10-11 15" +--8<-- "docs_src/sampling_and_roots/tutorial002.py" +``` + +* inject किया गया `ListRootsResult` `Root`s की एक सूची रखता है: एक `file://` URI और एक वैकल्पिक display name। +* शर्त वही है जो sampling के लिए है: declare की गई `roots` capability के बिना, request भेजने के बजाय call `-32021` के साथ fail हो जाता है। + +wire के दूसरी तरफ़, client दोनों requests का जवाब उन्हीं callbacks से देता है जो उसके पास पहले से हैं: `sampling_callback` और `list_roots_callback`, जिनकी जानकारी **[Client callbacks](../client/callbacks.md)** में है। + +## 2025 पीढ़ी के connections पर {#on-2025-era-connections} + +`ctx.session.create_message(...)` और `ctx.session.list_roots()` उस code के लिए अब भी मौजूद हैं जो session को सीधे चलाता है। ये सिर्फ़ वहीं काम करते हैं जहाँ back-channel मौजूद है (2025 पीढ़ी के, non-stateless connections), और इन्हें call करने पर deprecation warning आती है। ऊपर दिए गए resolver markers ही समर्थित तरीका हैं: वे negotiate हुए version के हिसाब से delivery चुनते हैं और कोई warning नहीं देते। + +## सारांश {#recap} + +* resolver से `Sample(...)` या `ListRoots()` लौटाएँ; tool को `CreateMessageResult` या `ListRootsResult` किसी भी दूसरी dependency की तरह मिलता है। +* client को मेल खाती capability declare करनी होगी, वरना request भेजे जाने के बजाय call `-32021` के साथ fail हो जाता है। +* दोनों features `2026-07-28` पर deprecated हैं: फ़िलहाल पूरी तरह काम करते हैं, पर नए designs के लिए गलत चुनाव हैं। sampling की जगह provider APIs और roots की जगह स्पष्ट parameters को तरजीह दें। + +धीमा tool कितना आगे बढ़ा, यह बताना: **[Progress](progress.md)**। diff --git a/i18n/hi/pages/handlers/subscriptions.md b/i18n/hi/pages/handlers/subscriptions.md new file mode 100644 index 0000000000..b7fc4833e8 --- /dev/null +++ b/i18n/hi/pages/handlers/subscriptions.md @@ -0,0 +1,152 @@ +--- +translation: + sections: [60a9de8a0bdaa531, 317bbe7e4355cdcc, a61d660c8029e04a, 8f7e82fcb88df8a9, b165db51249ff8ed, 266f56fb798068a4, 7c0e57030b622139, df18d7c2417a9883] + tool: 1 +--- +# Subscriptions {#subscriptions} + +किसी server का catalog तय नहीं होता। tools runtime पर आ जाते हैं, और resource URI के पीछे का content बदलता रहता है। + +client को इसकी खबर **subscriptions** से मिलती है। client एक `subscriptions/listen` request भेजता है, और उस request का response ही stream है: वह खुला रहता है और वही change notifications लाता है जो client ने माँगे थे। + +## tool से publish करना {#publish-it-from-the-tool} + +आपके हिस्से का काम बस एक line है: बदलाव publish करें। + +```python title="server.py" hl_lines="20 32" +--8<-- "docs_src/subscriptions/tutorial001.py" +``` + +* `await ctx.notify_resource_updated("board://sprint")` हर उस खुले stream तक पहुँचता है जिसने उस URI को subscribe किया था। और किसी तक नहीं। +* `await ctx.notify_tools_changed()` हर उस stream तक पहुँचता है जिसने tool-list के बदलाव माँगे थे। जिस client को यह मिलता है वह `tools/list` दोबारा call करता है, और अब उसे `sprint_report` दिखता है। +* इसके साथी `notify_prompts_changed()` और `notify_resources_changed()` हैं। +* कोई subscriber नहीं, तो कोई काम नहीं। खाली बैठे server पर publish करना no-op है, इसलिए आपको कभी जाँचना नहीं पड़ता कि कोई सुन रहा है या नहीं। आप बस बताते हैं कि क्या बदला। + +`MCPServer` आपके लिए `subscriptions/listen` serve करता है। wire की ज़िम्मेदारियाँ (पहले frame के रूप में acknowledgment, हर stream के हिसाब से filtering, हर frame पर subscription id) SDK संभालता है। + +!!! check + wire पर, जिस stream के filter में `board://sprint` का नाम था वह `complete_task` चलने के बाद ऐसा दिखता है: + + ```json + {"method": "notifications/subscriptions/acknowledged", + "params": {"notifications": {"resourceSubscriptions": ["board://sprint"]}, "_meta": {"io.modelcontextprotocol/subscriptionId": "listen-1"}}} + + {"method": "notifications/resources/updated", + "params": {"uri": "board://sprint", "_meta": {"io.modelcontextprotocol/subscriptionId": "listen-1"}}} + ``` + + ध्यान दें कि update में क्या **नहीं** है: board। हर frame में `_meta` के नीचे listen request की JSON-RPC id होती है, और वही id subscription id है। इसे client गढ़ता है: Python का `Client` `"listen-1"` जैसी strings इस्तेमाल करता है; दूसरे clients integers इस्तेमाल कर सकते हैं। + +## सिर्फ़ वही जो माँगा गया {#only-what-was-asked-for} + +filter एक contract है। जिस stream ने tool-list के बदलाव और एक resource URI माँगे थे, उसे यही दो तरह की चीज़ें मिलती हैं और कुछ नहीं। कोई prompt change publish करें, तो वह stream चुप रहता है। + +`MCPServer` resource URIs को हूबहू strings के रूप में मिलाता है, इसलिए जिस stream ने `board://sprint` का नाम दिया उसे `board://sprint/tasks/1` के बारे में कुछ सुनाई नहीं देता। spec server को subscribe किए गए URI के किसी sub-resource पर बदलाव बताने देता है; `MCPServer` ऐसा कभी नहीं करता, पर clients इसकी उम्मीद रखने के लिए बने होते हैं। + +दो चीज़ें जो stream **नहीं** है: + +* **यह replay log नहीं है।** टूटा हुआ stream चला गया, और जब कोई जुड़ा नहीं था तब publish हुए events queue में नहीं रखे जाते। clients दोबारा listen करते हैं और दोबारा fetch करते हैं। +* **यह 2025 वाला रास्ता नहीं है।** जिन clients ने `resources/subscribe` call किया था उन्हें `ctx.session.send_resource_updated(uri)` serve करता है। `notify_*` methods सिर्फ़ `subscriptions/listen` streams तक पहुँचते हैं। + +## कौन देख सकता है, यह तय करना {#deciding-who-may-watch} + +default रूप से हर माँगा गया kind और URI मान लिया जाता है: कोई भी caller आपके publish किए किसी भी URI को देख सकता है। कोई भी आपके read handler से नहीं पूछता, क्योंकि कोई पढ़ ही नहीं रहा — जिस caller को आपका `files://{name}` handler लौटा देता, वह भी `files://payroll.csv` पर stream खोल सकता है और जान सकता है कि वह बदली, और कब। उसे content कभी नहीं मिलता, और वह यह टटोल नहीं सकता कि क्या मौजूद है, क्योंकि अनजान URI भी मान लिया जाता है और बस कभी fire नहीं होता। खतरा छोटा है पर असली है, इसलिए multi-tenant server से हर user के अलग URIs publish करने से पहले इस पर gate लगाएँ। + +यह gate एक middleware है। वह `subscriptions/listen` request को SDK के acknowledge करने से पहले देखता है, और जब caller कुछ ऐसा माँगता है जिसे पढ़ने की उसे इजाज़त नहीं, तो मना कर देता है: + +```python title="server.py" hl_lines="19-26 29" +--8<-- "docs_src/subscriptions/tutorial006.py" +``` + +* `ctx.params` कच्ची request है, इसलिए middleware खुद उसे `SubscriptionsListenRequestParams` में validate करता है और वह filter पढ़ता है जो client ने माँगा था। +* मना करने का मतलब `call_next(ctx)` से पहले `MCPError` raise करना है: client को वह error मिलता है और कोई stream नहीं, और connection चलता रहता है। message एक जैसा रखें, किसी URI का नाम न लें, ताकि मना करने से कभी यह पक्का न हो कि कौन से URIs सुरक्षित हैं। +* एक ही `can_access(user, uri)` दोनों सवालों का जवाब देता है। resource handler उससे `resources/read` पर पूछता है; middleware उससे `subscriptions/listen` पर पूछता है। table की जगह database या अपना RBAC system रख दें, और दोनों कदम मिलाकर चलते रहते हैं। +* फ़ैसला stream के पूरे जीवनकाल तक लागू रहता है। हर event पर दोबारा जाँच नहीं होती, इसलिए अगर किसी caller की पहुँच stream के बीच में खत्म हो सकती है (expire होता token), तो जब ऐसा हो तब उस caller का connection बंद कर दें। + +middleware का पूरा contract, यह और क्या-क्या wrap करता है और इसे provisional क्यों कहा गया है, यह सब **[Middleware](../advanced/middleware.md)** पर है। + +## client वाला सिरा {#the-client-end} + +यह रहा उस stream के दूसरी तरफ़ का client, जो board पर नज़र रख रहा है: + +```python title="client.py" hl_lines="15" +--8<-- "docs_src/subscriptions/tutorial003.py" +``` + +`client.listen(...)` में दाख़िल होते ही request भेजी जाती है और आपके acknowledgment का इंतज़ार होता है, इसलिए block शुरू होते समय stream चालू होता है, और हर typed event दोबारा fetch करने का इशारा है, payload कभी नहीं। पूरा contract एक ही screen में बस इतना है। client वाले सिरे की बाकी हर बात अपने अलग page पर है: main flow के साथ-साथ नज़र रखना, stream का खत्म होना, और दोबारा listen करना। *Clients* के नीचे **[Subscriptions](../client/subscriptions.md)** देखें। + +## एक process से आगे scale करना {#scaling-past-one-process} + +publishes आपके handler से खुले streams तक `SubscriptionBus` के ज़रिए पहुँचते हैं। default in-memory है: एक process, उसके अंदर का हर stream। यही सही जवाब है जब तक आप load balancer के पीछे replicas नहीं चलाते, क्योंकि तब client का stream एक replica से बँध जाता है, और किसी दूसरे replica पर हुए publish को उस तक पहुँचना होता है। + +यह जोड़ आपको implement करना है: आपके pub/sub backend के ऊपर दो methods। + +```python +from collections.abc import Callable + +from redis.asyncio import Redis + +from mcp.server.mcpserver import MCPServer +from mcp.server.subscriptions import ServerEvent # SubscriptionBus is a Protocol: no base class + + +class RedisSubscriptionBus: + def __init__(self, redis: Redis) -> None: + self._redis = redis + self._listeners: dict[object, Callable[[ServerEvent], None]] = {} + + async def publish(self, event: ServerEvent) -> None: + await self._redis.publish("mcp-events", encode(event)) # to every replica + + def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]: + token = object() + self._listeners[token] = listener + + def unsubscribe() -> None: + self._listeners.pop(token, None) + + return unsubscribe + + +mcp = MCPServer("Sprint Board", subscriptions=RedisSubscriptionBus(redis)) +``` + +`encode` आपका है, और हर replica पर वह reader task भी आपका है जो आने वाले messages को decode करता है और हर registered listener को call करता है। listeners synchronous होते हैं, उन्हें raise करना मना है, और वे server के event loop पर चलते हैं। + +bus typed `ServerEvent` values ले जाता है, चार छोटी dataclasses, JSON-RPC कभी नहीं। stamping, filtering, और stream lifecycles SDK में ही रहते हैं, इसलिए bus का कोई implementation protocol नहीं तोड़ सकता। वह सिर्फ़ events को processes के बीच पहुँचा सकता है। + +request के बाहर से publish करने के लिए bus खुद बनाएँ ताकि reference आपके पास रहे। जब आप कुछ pass नहीं करते तो `MCPServer` अंदर ही अंदर एक बना लेता है, और उसे बाहर नहीं दिखाता। + +```python +from mcp.server.subscriptions import InMemorySubscriptionBus, ToolsListChanged + +bus = InMemorySubscriptionBus() +mcp = MCPServer("Sprint Board", subscriptions=bus) + + +async def tools_reloaded() -> None: + await bus.publish(ToolsListChanged()) # from a lifespan task, a webhook, anywhere +``` + +## Low-level composition {#the-low-level-composition} + +low-level `Server` पर पहले से कुछ भी जुड़ा हुआ नहीं है, और वही हिस्से तीन lines में जुड़ जाते हैं: + +```python title="server.py" hl_lines="8-9 47" +--8<-- "docs_src/subscriptions/tutorial002.py" +``` + +* bus आपका है, इसलिए आप सीधे उस पर publish करते हैं: `await bus.publish(ResourceUpdated(uri=...))`। उसे वहाँ रखें जहाँ आपके handlers उस तक पहुँच सकें: यहाँ module scope में, बड़े app में lifespan में। +* `ListenHandler(bus)` वही handler है जो `MCPServer` register करता है, और `on_subscriptions_listen=` एक साधारण handler slot है। अलग semantics के लिए उस slot में अपना callable रखें, और spec की ज़िम्मेदारियाँ आप पर आ जाती हैं: पहले acknowledge करें, हर frame पर subscription id की मुहर लगाएँ, filter के बाहर कुछ भी न भेजें। +* `ListenHandler.close()` हर खुले stream को सलीके से खत्म करता है। हर एक को अपने आख़िरी frame के रूप में listen request का result मिलता है, जो spec का यह कहने का तरीका है कि server ने subscription जान-बूझकर खत्म किया। यह उन streams के flush पूरा करने से पहले लौट आता है, इसलिए transport गिराने से पहले उन्हें एक पल दें। इसके बिना, streams तब खत्म होते हैं जब client disconnect करता है। + +## सारांश {#recap} + +* client एक `subscriptions/listen` request से शामिल होता है, और response ही stream है। इसे serve करना पहले से बना हुआ है। +* आप `ctx.notify_*` से publish करते हैं, और stamping, filtering, और lifecycle का काम SDK करता है। +* events इशारे हैं, payloads नहीं। दोनों सिरे दोबारा fetch करते हैं। +* client वाला सिरा `async with client.listen(...)` है: उसकी कहानी *Clients* के नीचे **[Subscriptions](../client/subscriptions.md)** में है। +* low-level `Server` पर आप वही हिस्से खुद जोड़ते हैं: एक bus, `ListenHandler(bus)`, `on_subscriptions_listen` slot। +* scale out करने का मतलब है `SubscriptionBus` implement करना, बस दो methods, और उसे `MCPServer(subscriptions=...)` के रूप में pass करना। + +यह सब serve करने वाले server को चलाना, एक replica के पीछे हो या बीस के, **[Deploy और scale](../run/deploy.md)** में है। diff --git a/i18n/hi/pages/index.md b/i18n/hi/pages/index.md new file mode 100644 index 0000000000..ab70b31a7e --- /dev/null +++ b/i18n/hi/pages/index.md @@ -0,0 +1,102 @@ +--- +translation: + sections: [154c4309937b9f85, 3ad8fc6caa76a9b0, a07f3f5b151ab746, bf6e476b712930c0, cf0b1f13978c6623] + tool: 1 +--- +# MCP Python SDK {#mcp-python-sdk} + +!!! info "यह v2 का documentation है, जो मौजूदा stable release line है" + v2 पर नए हैं, या v1 से आ रहे हैं? **[v2 में नया क्या है](whats-new.md)** पाँच मिनट में दिखाता है कि क्या बदला, और **[Migration Guide](migration.md)** में हर breaking change शामिल है। + अभी भी v1.x पर हैं? उसका documentation [v1.x docs](https://py.sdk.modelcontextprotocol.io/v1/) पर है। + कुछ अटपटा या उलझाने वाला लगा? [हमें बताएँ](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml)। + +**Model Context Protocol (MCP)** applications को standardized तरीके से LLM को context देने देता है, जिससे context **देने** का काम खुद LLM interaction से अलग रहता है। + +यह उसका official Python SDK है। इससे आप: + +* **MCP servers बना सकते हैं** जो किसी भी MCP host के लिए tools, resources और prompts expose करते हैं। +* **MCP clients बना सकते हैं** जो किसी भी MCP server से जुड़ते हैं। +* हर standard transport इस्तेमाल कर सकते हैं: stdio, Streamable HTTP और SSE। + +## ज़रूरतें {#requirements} + +Python 3.10+। + +## Installation {#installation} + +=== "uv" + + ```bash + uv add "mcp[cli]" + ``` + +=== "pip" + + ```bash + pip install "mcp[cli]" + ``` + +`[cli]` extra से आपको `mcp` command मिलता है; development के दौरान इसकी ज़रूरत पड़ेगी। +हर dependency किस काम की है, यह [Installation](get-started/installation.md) में देखें। + +## उदाहरण {#example} + +### इसे बनाएँ {#create-it} + +`server.py` नाम की file बनाएँ: + +```python title="server.py" +--8<-- "docs_src/index/tutorial001.py" +``` + +यह पूरा MCP server है। + +यह एक **tool**, `add`, और एक templated **resource**, `greeting://{name}` expose करता है। + +### इसे चलाएँ {#run-it} + +```console +uv run mcp dev server.py +``` + +इससे server शुरू होता है और [MCP Inspector](https://github.com/modelcontextprotocol/inspector) खुलता है, जो server को परखने के लिए बना interactive UI है। यह जो URL print करता है, उसे खोलें। + +!!! note + Inspector Node.js app है, इसलिए `mcp dev` को आपके `PATH` पर `npx` चाहिए। + +### इसे आज़माएँ {#try-it} + +Inspector में **Tools** पर जाएँ और `a=1`, `b=2` के साथ `add` को call करें। + +आपको `3` वापस मिलता है। ✨ + +Inspector ने वह form (`a` के लिए एक ज़रूरी integer field, `b` के लिए एक और) आपके type hints से बनाया। Claude और बाकी हर MCP host भी यही करेगा। + +अब **Resources** पर जाएँ और `greeting://World` पढ़ें: + +```text +Hello, World! +``` + +### सारांश {#recap} + +एक बार फिर देखें कि आपने क्या **नहीं** लिखा: + +* कोई JSON Schema नहीं। `a: int, b: int` ही schema है। +* न request parsing, न serialization, न validation code। +* protocol handling बिल्कुल नहीं। + +आपने type hints और docstring वाले दो Python functions लिखे। बाकी सब SDK करता है। + +## आगे कहाँ जाएँ {#where-to-go-next} + +* **[शुरू करें](get-started/index.md)** आपको install से लेकर चलते हुए, test किए हुए server तक ले जाता है। +* ऐसा application बना रहे हैं जो MCP servers **इस्तेमाल** करता है? **[Clients](client/index.md)** से शुरू करें। +* पहले से FastAPI या Starlette app है? **[मौजूदा app में जोड़ें](run/asgi.md)** उसके अंदर MCP server mount करता है। +* कोई ख़ास error message ढूँढ रहे हैं? **[Troubleshooting](troubleshooting.md)** हूबहू text के हिसाब से व्यवस्थित है। +* सोच रहे हैं कि v2 में क्या बदला? **[v2 में नया क्या है](whats-new.md)** पाँच मिनट में सब दिखा देता है। +* v1 से migrate कर रहे हैं? **[Migration Guide](migration.md)** से शुरू करें। +* कोई ख़ास signature ढूँढ रहे हैं? **[API Reference](api/mcp/index.md)** सीधे source से generate होता है। +* LLM के साथ पढ़ रहे हैं? यह documentation [llms.txt](https://llmstxt.org/) format में भी publish होता है: + [llms.txt](https://py.sdk.modelcontextprotocol.io/llms.txt) pages की सूची है, और + [llms-full.txt](https://py.sdk.modelcontextprotocol.io/llms-full.txt) में हर page एक ही file में है। diff --git a/i18n/hi/pages/protocol-versions.md b/i18n/hi/pages/protocol-versions.md new file mode 100644 index 0000000000..79eecb1c78 --- /dev/null +++ b/i18n/hi/pages/protocol-versions.md @@ -0,0 +1,132 @@ +--- +translation: + sections: [478fd619e5f90ef8, aef094a00e44e248, bab8cbf3449fa7e9, df1809b15a58335b, 5f9d8c2336ed0239, f54974398e43ddef, b24443dd78584870] + tool: 1 +--- +# Protocol versions {#protocol-versions} + +MCP की दो पीढ़ियाँ हैं। + +2026-07-28 से पहले आए servers हर connection **`initialize` handshake** से खोलते हैं: client एक version सुझाता है, server अपना जवाब देता है, client उसे मान लेता है, और यह सब पहली काम की request से पहले होता है। **2026-07-28** वाले servers handshake छोड़ देते हैं। client एक **`server/discover`** probe भेजता है और server एक ही result में सब कुछ लौटा देता है। + +आपको इसकी चिंता लगभग कभी नहीं करनी पड़ती, क्योंकि `Client` आपके लिए negotiate कर लेता है। यह page उस एक constructor argument के बारे में है जो इसे नियंत्रित करता है, `mode=`, और उन तीन मौकों के बारे में जब आप इसे बदलते हैं। + +## `mode="auto"` {#modeauto} + +```python title="client.py" hl_lines="14-15" +--8<-- "docs_src/protocol_versions/tutorial001.py" +``` + +आपने `mode` नहीं दिया, इसलिए default मिला: `"auto"`। `async with` में दाख़िल होते ही इस SDK के सबसे नए version पर एक `server/discover` probe भेजा जाता है। फिर: + +* **modern server** इसका जवाब देता है। client उस result को अपना लेता है। एक round trip, और काम ख़त्म। +* **पुराना server** `server/discover` को जानता ही नहीं और error लौटाता है। client पुराने classic `initialize` handshake पर लौट आता है और वह जो भी negotiate करे, उसे ले लेता है। + +दोनों ही सूरतों में connection बन जाता है, और `client.protocol_version` बताता है कि कौन-सा रास्ता लिया गया: + +```text +2026-07-28 +``` + +पूरा feature बस इतना ही है। एक `Client`, किसी भी पीढ़ी का server, और code में कोई branching नहीं। + +!!! info + `MCPServer` हर transport पर `server/discover` का जवाब देता है — in-memory, stdio, streamable + HTTP — इसलिए आपके अपने server के साथ `auto` हमेशा `2026-07-28` पर पहुँचता है। fallback सिर्फ़ + असली pre-2026 server के सामने ही चलता है, और ठीक वहीं आप इसे चाहते भी हैं। + +## `mode="legacy"` {#modelegacy} + +```python title="client.py" hl_lines="14" +--8<-- "docs_src/protocol_versions/tutorial002.py" +``` + +`mode="legacy"` कभी probe नहीं करता। यह `initialize` handshake चलाता है, वही connection जो pre-2026 client खोलता है। + +```text +2025-11-25 +``` + +server वही है। यह `2026-07-28` बख़ूबी बोलता है; आपने ही client से कहा कि वह न पूछे। + +इसकी ज़रूरत **push-style** features के लिए पड़ती है। + +server-initiated request का मतलब है server का **आपको** call करना: `ctx.elicit(...)` आपके user के सामने form रखता है, sampling tool call के बीच में आपके model से completion माँगती है। यह channel सिर्फ़ handshake पीढ़ी के session पर ही मौजूद होता है। + +2026-07-28 पर यह channel नहीं रहा। server अपने सवाल **लौटाता** है और आप जवाबों के साथ call दोबारा करते हैं (**[Multi-round-trip requests](handlers/multi-round-trip.md)**)। + +`mode="auto"` handshake तभी देता है जब server इतना पुराना हो कि और कुछ चले ही नहीं। `mode="legacy"` इसकी गारंटी देता है। जब भी आप `Client(...)` को `sampling_callback`, request के रूप में चलाया जाने वाला `elicitation_callback`, या `message_handler` देते हैं, इसे चुनें। **[Client callbacks](client/callbacks.md)** में हर एक की बात विस्तार से है। + +## Version pin करना {#pinning-a-version} + +`mode` modern protocol version string भी स्वीकार करता है। आज यह set ठीक `["2026-07-28"]` है। + +```python title="client.py" hl_lines="14" +--8<-- "docs_src/protocol_versions/tutorial003.py" +``` + +pin **कुछ भी नहीं** भेजता। न probe, न handshake। client locally `2026-07-28` अपना लेता है और `async with` के लौटते ही connection चालू हो जाता है। + +pin एक वादा है जो **आप** करते हैं: आपको पहले से पता है कि server वह version बोलता है। client जाँचता नहीं। + +!!! check + pin discovery नहीं है। `client.server_info` print करें और इसकी कीमत सामने दिख जाती है: + + ```text + None + ``` + + client ने server से कभी पूछा ही नहीं कि वह कौन है, इसलिए `server_info` `None` है। `client.server_capabilities` + का भी यही हाल है: हर capability `None` है। tool calls फिर भी काम करते हैं (protocol को इनमें से किसी की ज़रूरत नहीं); + जो code यह तय करने के लिए `server_capabilities` पढ़ता है कि क्या पेश करना है, वह काम नहीं करता। + + अगला section इसका हल है। + +सिर्फ़ modern versions ही pin किए जा सकते हैं। handshake पीढ़ी की string construction के समय ही, किसी भी I/O से पहले, ठुकरा दी जाती है, और error बताता है कि इसकी जगह क्या लिखना है: + +```text +ValueError: mode must be 'legacy', 'auto', or one of ['2026-07-28']; got '2025-06-18' ('2025-06-18' is a handshake-era version; use mode='legacy') +``` + +## `prior_discover` के साथ दोबारा connect करना {#reconnecting-with-prior_discover} + +probe सस्ता है, लेकिन फिर भी यह एक round trip है जो हर reconnect पर चुकाना पड़ता है, और इसका जवाब लगभग कभी नहीं बदलता। + +इसलिए इसे संभाल कर रखें। `auto` connection के बाद `client.session.discover_result` में ठीक वही `DiscoverResult` होता है जो server ने भेजा था: उसके `supported_versions`, उसकी `capabilities`, उसके `instructions`, और वह पहचान जो server ने result के `_meta` में दर्ज की थी। अगली बार इसे `prior_discover=` के रूप में वापस दें: + +```python title="client.py" hl_lines="15 17" +--8<-- "docs_src/protocol_versions/tutorial004.py" +``` + +```text +2026-07-28 +Bookshop +``` + +दूसरे connection ने negotiation का **एक भी** round trip नहीं किया और फिर भी ठीक-ठीक जानता है कि वह किससे बात कर रहा है। pinned mode का सही तरीका यही है: `mode=` version बताता है, `prior_discover=` पहचान देता है। ✨ + +`DiscoverResult` Pydantic model है। `saved.model_dump_json()` किसी file या cache में जाता है; `DiscoverResult.model_validate_json(...)` अगले process में इसे वापस ले आता है। + +!!! tip + `prior_discover=` तभी कुछ करता है जब `mode` version pin हो। `"auto"` में client + वैसे भी server को probe करता है, और `"legacy"` में इसे नज़रअंदाज़ कर दिया जाता है। + +## चार modes {#the-four-modes} + +| आप लिखते हैं | Negotiation traffic | आपको मिलता है | +| --- | --- | --- | +| `Client(target)` | एक `server/discover` probe; वह नाकाम हो तो `initialize` handshake | सबसे नया version जो दोनों तरफ़ बोलते हैं, पीढ़ी कोई भी हो | +| `Client(target, mode="legacy")` | `initialize` handshake | handshake पीढ़ी का version; server-initiated requests काम करती हैं | +| `Client(target, mode="2026-07-28")` | कुछ नहीं | वही version, pinned, `server_info` `None` के साथ | +| `Client(target, mode="2026-07-28", prior_discover=saved)` | कुछ नहीं | वही version, pinned, **और** वह पहचान जो आपने पिछली बार संभाली थी | + +## सारांश {#recap} + +* MCP की एक handshake पीढ़ी है (`2025-11-25` तक, `initialize` handshake) और एक modern पीढ़ी (`2026-07-28`, `server/discover`)। `Client` दोनों को जोड़ता है। +* `mode="auto"` default है: probe, फिर ज़रूरत पड़े तो fall back। इसे वैसे ही रहने दें, जब तक बाकी तीन rows में से कोई आप पर लागू न हो। +* "मुझे क्या मिला?" का जवाब हमेशा `client.protocol_version` है। +* `mode="legacy"` handshake ज़बरदस्ती करवाता है। server-initiated requests के लिए आपको यही चाहिए: sampling, push elicitation, `message_handler`। +* version pin (`mode="2026-07-28"`) negotiation traffic बिल्कुल नहीं भेजता, इसकी कीमत यह कि `client.server_info` `None` रहता है। +* `prior_discover=` वह कीमत लौटा देता है: `client.session.discover_result` संभाल कर रखें, उसी के साथ reconnect करें, दोनों पाएँ। + +modern connection में push channel नहीं होता, तो 2026 पीढ़ी का server call के बीच में आपसे सवाल कैसे पूछे? वह उसे लौटा देता है: **[Multi-round-trip requests](handlers/multi-round-trip.md)**। diff --git a/i18n/hi/pages/run/asgi.md b/i18n/hi/pages/run/asgi.md new file mode 100644 index 0000000000..aab873623b --- /dev/null +++ b/i18n/hi/pages/run/asgi.md @@ -0,0 +1,145 @@ +--- +translation: + sections: [1062ef792791488a, 4be2b831547184a9, 374b049e770385f2, b72f6947089e6de0, b172c9db7831bb31, 70b9ece244ca1b0c, cba78e052898c3f6, f06bdb541cb0b469, fb82d526320b7cc3] + tool: 1 +--- +# मौजूदा app में जोड़ना {#add-to-an-existing-app} + +`mcp.run("streamable-http")` आपके लिए web server शुरू कर देता है। कभी-कभी आप यह नहीं चाहते: आपका MCP server किसी बड़ी web application का एक हिस्सा है, या आपके पास पहले से ASGI deployment है। + +इसके लिए `mcp.streamable_http_app()` एक **Starlette application** लौटाता है। + +Starlette app एक ASGI app है, इसलिए जो कुछ भी ASGI host कर सकता है (uvicorn, Hypercorn, कोई दूसरा Starlette, FastAPI), वह आपका MCP server host कर सकता है। + +## app {#the-app} + +```python title="server.py" hl_lines="12" +--8<-- "docs_src/asgi/tutorial001.py" +``` + +`app` एक साधारण ASGI application है। इसे किसी भी ASGI server को सौंप दें: + +```console +uvicorn server:app +``` + +MCP endpoint `/mcp` पर है, इसलिए client `http://127.0.0.1:8000/mcp` से जुड़ता है। + +app में दो चीज़ें पहले से मौजूद हैं: + +* एक route, `/mcp`: Streamable HTTP endpoint। +* एक **lifespan**, जो `mcp.session_manager` को शुरू करता है, यानी वह object जो हर live session के background काम का मालिक है। + +app को अकेले चलाएँ (`uvicorn server:app`) तो आपको दोनों में से किसी के बारे में सोचना नहीं पड़ता। + +!!! tip + `streamable_http_app()` वही keyword arguments लेता है जो `mcp.run("streamable-http", ...)` + लेता है, बस `port` को छोड़कर: port उसका है जो app को serve करता है। `host` अभी भी स्वीकार + होता है लेकिन यहाँ कुछ bind नहीं करता; **[Deploy & scale](deploy.md)** बताता है कि वह असल + में क्या नियंत्रित करता है। खुद options की जानकारी **[अपना server चलाना](index.md)** में है। + +`mcp.sse_app()` पुराने पड़ चुके SSE transport के लिए यही करता है। + +## सिर्फ़ localhost, जब तक आप कुछ और न कहें {#localhost-only-until-you-say-otherwise} + +बिना कुछ configure किए app **सिर्फ़** उन्हीं requests का जवाब देता है जो localhost को भेजी गई हों। +`streamable_http_app()` यह नहीं जान सकता कि उसे किस hostname के पीछे serve किया जाएगा, इसलिए वह +सबसे सुरक्षित allowlist के साथ DNS-rebinding protection चालू कर देता है; आपकी मशीन पर यह बिल्कुल +सही है। असली hostname के पीछे deploy होने पर इसका मतलब है कि **हर request `421 Misdirected Request` +के साथ reject होती है**, जब तक आप `transport_security=` में वह allowlist नहीं देते जो आप असल में +serve करते हैं। आपने जो कुछ बनाया है, उससे पहले पूछा तक नहीं जाता। वह allowlist, और काम करते app +से असली hostname तक के बीच की बाकी हर चीज़, **[Deploy & scale](deploy.md)** में है। + +## इसे mount करना {#mounting-it} + +जैसे ही MCP server किसी बड़ी application का **हिस्सा** बनता है, आप app को `Mount` के अंदर रखते हैं। और जैसे ही आप ऐसा करते हैं, lifespan आपकी ज़िम्मेदारी बन जाता है: + +```python title="server.py" hl_lines="18-21 25-26" +--8<-- "docs_src/asgi/tutorial002.py" +``` + +* `Mount("/", ...)` और default `/mcp` path मिलकर endpoint को `/mcp` पर ही रखते हैं। Starlette routes को क्रम से आज़माता है और `Mount("/")` **हर** path से match करता है, इसलिए आपके अपने routes सूची में इससे **पहले** जाते हैं। इसके बाद जो कुछ भी है, वहाँ तक पहुँचा नहीं जा सकता। +* `lifespan` function **host** app के पूरे जीवनकाल के लिए `mcp.session_manager.run()` में प्रवेश करता है। यही वह line है जिसे सब भूल जाते हैं। +* `mcp.session_manager` तभी मौजूद होता है जब `streamable_http_app()` call हो चुका हो। इसीलिए routes module level पर बनते हैं और manager को सिर्फ़ lifespan के अंदर छुआ जाता है। + +Starlette का `Host` route इसी तरह काम करता है: path के बजाय hostname से route करने के लिए `Mount("/", ...)` की जगह `Host("mcp.example.com", ...)` रखें। lifespan का नियम नहीं बदलता, और transport-security का भी नहीं। `Host("mcp.example.com", ...)` route को सिर्फ़ वही requests मिलती हैं जो उस hostname को भेजी गई हों, लेकिन transport की अपनी Host allowlist (**[Deploy & scale](deploy.md)**) फिर भी पहले चलती है। उसमें `"mcp.example.com"` न हो तो वह route उनमें से हर एक का जवाब `421` से देता है। + +!!! warning "lifespan का मालिक host app है" + `streamable_http_app()` जो Starlette लौटाता है, उसके lifespan में `session_manager.run()` + जोड़ देता है, लेकिन **mount की गई sub-application का lifespan कभी नहीं चलता**। app को mount + करें और वह built-in lifespan dead code बन जाता है। आपके ASGI stack में सबसे ऊपर जो भी app + है, उसे अपने lifespan में `mcp.session_manager.run()` में प्रवेश करना होगा। + +!!! check + `lifespan=lifespan` वाली line हटाएँ और server शुरू करें। वह शुरू होता है। route resolve + होता है। फिर `/mcp` पर पहली request इस error के साथ fail होती है: + + ```text + RuntimeError: Task group is not initialized. Make sure to use run(). + ``` + + session manager को उसके `run()` के अलावा कुछ शुरू नहीं करता। + +## दो servers, एक app {#two-servers-one-app} + +हर `MCPServer` अपने session manager के साथ अपना अलग app है। जितने चाहें mount करें; हर manager में उसी एक host lifespan से प्रवेश करें: + +```python title="server.py" hl_lines="27-30 35-36" +--8<-- "docs_src/asgi/tutorial003.py" +``` + +* `AsyncExitStack` दोनों managers में प्रवेश करता है; वे साथ शुरू होते हैं और उल्टे क्रम में बंद होते हैं। +* endpoints `/notes/mcp` और `/tasks/mcp` हैं: mount prefix और default path मिलाकर। + +## path बदलना {#changing-the-path} + +अंत वाला वह `/mcp` ही `streamable_http_path` है। इसे `"/"` पर set करें और mount prefix ही पूरा public path बन जाता है: + +```python title="server.py" hl_lines="25" +--8<-- "docs_src/asgi/tutorial004.py" +``` + +अब clients `/notes` से जुड़ते हैं, `/notes/mcp` से नहीं। + +## browser clients के लिए CORS {#cors-for-browser-clients} + +browser-based client को आपसे दो अनुमतियाँ चाहिए: अपने MCP request headers **भेजने** की, और MCP जो header वापस भेजता है उसे **पढ़ने** की। दोनों host app पर CORS configuration हैं, और ऊपर वाली transport-security allowlist का इससे मेल खाना ज़रूरी है: + +```python title="server.py" hl_lines="27-30 33 35-49" +--8<-- "docs_src/asgi/tutorial005.py" +``` + +* `allow_headers` वह आधा हिस्सा है जिसे सब भूल जाते हैं। browser हर MCP request से पहले **preflight** करता है, क्योंकि `Content-Type: application/json` और `Mcp-*` request headers CORS safelist में नहीं हैं, और जिस header की अनुमति preflight नहीं देता, वह ऐसी request है जिसे browser कभी भेजता ही नहीं। (`allow_headers=["*"]` भी काम करता है: Starlette preflight का जवाब उसी से देता है जो उसने माँगा था।) +* `expose_headers=["Mcp-Session-Id"]` पढ़ने वाला आधा हिस्सा है। Streamable HTTP session ID उसी response header में लौटाता है, और जब तक CORS उन्हें नाम से expose न करे, browsers response headers को JavaScript से छिपाते हैं। इसके बिना client अपनी दूसरी request कभी नहीं कर सकता। +* `allow_origins` आपका फ़ैसला है, MCP का नहीं। सटीक रहें, और इसे ऊपर `allowed_origins=` में भी दोहराएँ: CORS browser लागू करता है, लेकिन server `Origin` खुद जाँचता है, और जिस origin पर transport भरोसा नहीं करता उसे साफ़ preflight के बाद भी `403` मिलता है। +* `allow_methods` उन तीन methods की सूची है जो Streamable HTTP इस्तेमाल करता है: messages भेजने के लिए `POST`, server-to-client stream खोलने के लिए `GET`, session खत्म करने के लिए `DELETE`। + +## custom routes {#custom-routes} + +`@mcp.custom_route()` उसी app पर एक सादा HTTP endpoint register करता है, उन चीज़ों के लिए जो हर deployed service को चाहिए पर जिनका MCP से कोई लेना-देना नहीं: health check, OAuth callback। + +```python title="server.py" hl_lines="15-17" +--8<-- "docs_src/asgi/tutorial006.py" +``` + +* handler सादा Starlette है: `Request` से `Response` तक का एक `async` function। +* `streamable_http_app()` हर custom route को उठा लेता है। `app.routes` अब `/mcp` और `/health` है। +* `GET /health` का जवाब `{"status": "ok"}` है, जिसमें MCP कहीं नहीं। + +!!! warning + custom routes **कभी authenticate नहीं होते**, तब भी जब बाकी server होता है। यह जानबूझकर + है: health checks और OAuth callbacks तक किसी token के मौजूद होने से पहले पहुँचा जा सकना + ज़रूरी है। इनके पीछे कुछ भी निजी न रखें। + +## सारांश {#recap} + +* `mcp.streamable_http_app()` एक route, `/mcp`, वाला Starlette app लौटाता है। कोई भी ASGI server इसे चला सकता है। +* बिना कुछ configure किए app सिर्फ़ localhost को भेजी गई requests का जवाब देता है, और असली hostname के पीछे वह हर चीज़ को `421` से reject करता है, जब तक आप `transport_security=` में allowlist नहीं देते। यह, और production तक का बाकी रास्ता, **[Deploy & scale](deploy.md)** का विषय है। +* `Mount` (या `Host`) इसे किसी बड़े Starlette या FastAPI app के अंदर रखता है। +* **mount करने से built-in lifespan बंद हो जाता है।** host app के lifespan को `mcp.session_manager.run()` में प्रवेश करना होगा, वरना पहली request fail होती है। +* एक app में कई servers का मतलब है कई mounts और एक lifespan जो हर session manager में प्रवेश करता है। +* `streamable_http_path="/"` endpoint को खुद mount prefix पर ले जाता है। +* browser clients को CORS चाहिए: `Mcp-*` request headers के लिए `allow_headers`, response के लिए `expose_headers=["Mcp-Session-Id"]`। +* `@mcp.custom_route()` `/mcp` के बगल में सादे, बिना authentication वाले HTTP endpoints जोड़ता है। + +जब server असली URL पर पहुँच में आ जाए, तो **[Client](../client/index.md)** server object के बजाय उसी URL से उससे जुड़ता है। diff --git a/i18n/hi/pages/run/authorization.md b/i18n/hi/pages/run/authorization.md new file mode 100644 index 0000000000..40f932377c --- /dev/null +++ b/i18n/hi/pages/run/authorization.md @@ -0,0 +1,130 @@ +--- +translation: + sections: [d62c13457fc4a534, 80e73abaca6e0652, d1dc4c54cd00ec9c, 14ad3bc7904036bb, 5225f127bc1b9c77, fe1626fdd5aad1da, 4556cb7ea1a04a31] + tool: 1 +--- +# Authorization {#authorization} + +Streamable HTTP पर आपका MCP server साधारण web service ही है, और आप इसे उसी तरह सुरक्षित करते हैं जैसे किसी भी web service को: OAuth 2.1 bearer tokens से। + +OAuth की भाषा में, आपका server **resource server** है। यह न किसी को sign in कराता है, न कभी कोई token जारी करता है। यह बस एक काम करता है: हर request पर `Authorization` header देखता है और तय करता है कि उसमें रखा token सही है या नहीं। + +यह page server side के बारे में है। जो client आपके authorization server को खोजता है और token लाता है, उसकी जानकारी **[OAuth clients](../client/oauth-clients.md)** में है। + +## तीन पक्ष {#the-three-parties} + +* **authorization server** लोगों को sign in कराता है और access tokens जारी करता है। इसे आप नहीं लिखते। यह आपका identity provider है (Auth0, Keycloak, Entra, या आपका अपना)। +* **resource server** आपका MCP server है। यह हर request पर token verify करता है। +* **client** पता लगाता है कि आप किस authorization server पर भरोसा करते हैं, उससे token लेता है, और उसे `Authorization: Bearer ` के रूप में आपको वापस भेजता है। + +पूरा त्रिकोण बस इतना ही है। इस page पर जो कुछ है, वह बीच वाला bullet है। + +## Token verifier {#a-token-verifier} + +valid token कैसा दिखता है, इस बारे में SDK की कोई राय नहीं है। यह आप बताते हैं, **`TokenVerifier`** implement करके: + +```python title="server.py" hl_lines="12-14 19-24" +--8<-- "docs_src/authorization/tutorial001.py" +``` + +* `TokenVerifier` एक async method वाला protocol है। `verify_token` को `Authorization` header से raw token मिलता है, और token valid हो तो यह **`AccessToken`** लौटाता है, न हो तो `None`। इसके अलावा implement करने को कुछ नहीं है। +* यह वाला token को एक table में ढूँढता है। असली verifier JWT signature verify करता है या authorization server के token-introspection endpoint को call करता है। वह code आपका है; SDK उसे सिर्फ़ call करता है। +* `token_verifier=` और `auth=` हमेशा साथ चलते हैं। एक को दूसरे के बिना pass करें तो `MCPServer(...)` कोई request serve करने से पहले ही `ValueError` raise कर देता है। + +`AuthSettings` आपके resource server का सार्वजनिक चेहरा है: + +* `issuer_url`: वह authorization server जो आपके tokens जारी करता है। +* `resource_server_url`: इस MCP endpoint का public URL। यह बताता है कि token **किस** resource के लिए है, और discovery document भी यहीं रहता है। +* `required_scopes`: हर token में ये सभी होने ही चाहिए। + +!!! tip + SDK repository में `examples/servers/simple-auth/` के अंदर एक `IntrospectionTokenVerifier` है जो + असली authorization server के [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662) endpoint को call करता है। ज़्यादातर production verifiers का आकार यही होता है। + +## HTTP पर आपको क्या मिलता है {#what-you-get-over-http} + +authorization HTTP headers में रहता है, इसलिए यह सिर्फ़ HTTP transports पर मौजूद है। इसे उसी transport पर चलाएँ जिसे आप deploy करते हैं: `mcp.run(transport="streamable-http")` इसे `http://127.0.0.1:8000/mcp` पर रखता है, और बाकी जानकारी **[अपना server चलाना](index.md)** में है। app के पास अब दो routes हैं: + +```text +/mcp +/.well-known/oauth-protected-resource/mcp +``` + +आपने एक tool register किया था। दूसरा route SDK का है। + +### Discovery {#discovery} + +उस well-known path पर `GET` करें और आपको **[RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) Protected Resource Metadata** मिलता है, जो सीधे आपके `AuthSettings` से बना है: + +```json +{ + "resource": "http://127.0.0.1:8000/mcp", + "authorization_servers": ["https://auth.example.com/"], + "scopes_supported": ["notes:read"], + "bearer_methods_supported": ["header"] +} +``` + +जिस client ने आपके server के बारे में कभी सुना भी नहीं, वह इसी document के सहारे अंदर का रास्ता ढूँढता है: वह `authorization_servers` पढ़ता है और token के लिए वहाँ जाता है। इसमें से कुछ भी आपने नहीं लिखा। + +!!! check + `/mcp` को बिना token के call करें (या ऐसे token के साथ जिसके लिए आपके verifier ने `None` लौटाया) और request + दरवाज़े पर ही रोक दी जाती है: + + ```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"} + ``` + + न कुछ parse हुआ, न कोई tool चला। और `WWW-Authenticate` में जो `resource_metadata` pointer है, वही + discovery को automatic बनाता है: 401 -> metadata document -> authorization server -> token -> retry। + +!!! warning + इनमें से कुछ भी `stdio` को सुरक्षित नहीं करता। pipe में कोई `Authorization` header नहीं होता, इसलिए वहाँ `token_verifier` से कभी + पूछा ही नहीं जाता। `stdio` server की सुरक्षा सीमा वह process है जिसने उसे शुरू किया। यही बात + tests में इस्तेमाल होने वाले in-memory `Client(mcp)` पर भी लागू होती है: वह सीधे server object से जुड़ता है + और HTTP layer को, authorization समेत, छोड़ देता है। + +## caller की पहचान {#the-callers-identity} + +किसी भी handler के अंदर, **`get_access_token()`** वही `AccessToken` है जो आपके verifier ने मौजूदा request के लिए लौटाया था: + +```python title="server.py" hl_lines="4 32-35" +--8<-- "docs_src/authorization/tutorial002.py" +``` + +* यह tools, resources और prompts में काम करता है, और कुछ इधर-उधर pass करने की ज़रूरत नहीं: auth middleware इसे हर request के लिए एक context variable में रखता है। +* आपको **वही object वापस मिलता है जो आपके verifier ने बनाया था**: `client_id`, `scopes`, `subject`, `expires_at`, और जो भी अतिरिक्त `claims` आपने जोड़े। per-tool नियमों के लिए यही hook है: scopes पढ़ें और मना कर दें। +* authenticated HTTP request के बाहर यह `None` लौटाता है। in-memory और `stdio` पर यह हमेशा `None` है। + +`Authorization: Bearer alice-token` के साथ `whoami` call करें और model को यह पढ़ने को मिलता है: + +```text +alice (scopes: notes:read) +``` + +## वह आधा हिस्सा जो SDK नहीं करता {#the-half-the-sdk-doesnt-do} + +SDK आपको resource-server वाला आधा हिस्सा देता है: verify करना, advertise करना, मना करना। यह आपको न login page देता है, न consent screen, न token। + +तीनों पक्षों को काम करते देखना हो तो SDK repository से `examples/servers/simple-auth/` चलाएँ (एक छोटा authorization server और ठीक इस page की तरह set up किया गया resource server) और फिर discovery-और-token के पूरे क्रम के लिए `examples/clients/simple-auth-client/` को उसकी ओर point करें। + +!!! info + constructor का एक दूसरा argument भी है, `auth_server_provider=`, जो आपके MCP server के अंदर पूरा authorization + server embed कर देता है। यह उस AS/RS अलगाव से पहले का है जिसके इर्द-गिर्द MCP authorization spec + बना है। नए servers को इसकी ओर हाथ नहीं बढ़ाना चाहिए। + +authorization server, user के consent screen पर click करने की जगह, किसी enterprise identity provider का signed assertion भी स्वीकार कर सकता है, और SDK उस आदान-प्रदान के दोनों पक्षों को support करता है। वह grant, और उसे पेश करने वाला client, **[Identity assertion](../client/identity-assertion.md)** में है। + +## सारांश {#recap} + +* Streamable HTTP पर आपका server OAuth 2.1 **resource server** है: यह tokens verify करता है, जारी कभी नहीं करता। +* पूरा integration surface बस `TokenVerifier` है: एक async method, token अंदर, `AccessToken | None` बाहर। +* `token_verifier=` और `auth=AuthSettings(issuer_url=..., resource_server_url=..., required_scopes=[...])` हमेशा साथ चलते हैं। +* SDK `/.well-known/oauth-protected-resource/...` पर [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) Protected Resource Metadata publish करता है और unauthenticated requests का जवाब 401 से देता है, जिसका `WWW-Authenticate` header उसी की ओर इशारा करता है। discovery की पूरी कहानी बस इतनी ही है। +* किसी भी handler में `get_access_token()` बताता है कि call कौन कर रहा है। +* authorization HTTP का मामला है। `stdio` और in-memory client इसे कभी नहीं देखते। + +client वाला आधा हिस्सा (आपके authorization server को खोजना और आपके लिए token लाना) **[OAuth clients](../client/oauth-clients.md)** में है। और जो client user से पहचान पूछने के बजाय खुद कोई पहचान **assert** करता है, वह **[Identity assertion](../client/identity-assertion.md)** में है। diff --git a/i18n/hi/pages/run/deploy.md b/i18n/hi/pages/run/deploy.md new file mode 100644 index 0000000000..b27a3a9319 --- /dev/null +++ b/i18n/hi/pages/run/deploy.md @@ -0,0 +1,179 @@ +--- +translation: + sections: [28221886b198784f, f88ea1f1614f3a1d, ce926d686730b6d0, 3be24f8ad8bb5ab9, 3fad24032b2224ff, f25a7f860e579ecb, e758745df6fb7b0a] + tool: 1 +--- +# Deploy और scale करना {#deploy-scale} + +आपका server काम कर रहा है। अब इसे असली hostname चाहिए, और उसके पीछे एक से ज़्यादा worker। + +इसमें से लगभग कुछ भी MCP का काम नहीं है। ASGI server, process manager, load balancer — ये आप लाते हैं। इस page पर उन थोड़ी-सी चीज़ों की छोटी सूची है जो सच में MCP का काम **हैं**: एक setting जो हर deployment का रास्ता रोकती है, और वे दो जगहें जहाँ "एक से ज़्यादा worker" होने पर SDK का व्यवहार बदल जाता है। + +## सबसे पहले: Host allowlist {#before-anything-else-the-host-allowlist} + +`streamable_http_app()` यह नहीं जान सकता कि उसे किस hostname के पीछे serve किया जाएगा, इसलिए वह सबसे सुरक्षित जवाब मान लेता है: localhost। `transport_security=` न दिया हो तो app **DNS-rebinding protection** चालू कर देता है और कोई request तभी स्वीकार करता है जब उसका `Host` header `127.0.0.1:`, `localhost:`, या `[::1]:` हो। `Origin` header, जब मौजूद हो, तो उसी का `http://` रूप होना चाहिए। आपकी मशीन पर यह बिल्कुल सही है: यह किसी दुर्भावनापूर्ण web page को ऐसे DNS नाम के ज़रिए आपका local server चलाने से रोकता है जिसे उसने `127.0.0.1` पर rebind कर दिया हो। + +असली hostname के पीछे deploy होने पर वही default **हर request** को ठुकरा देता है, जब तक आप कुछ और न कहें। यह जाँच MCP से जुड़ी किसी भी चीज़ से पहले चलती है, इसलिए आपने जो बनाया उससे पूछा तक नहीं जाता: + +```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=` है। जो आप सच में serve करते हैं उसे allowlist करें: + +```python title="server.py" hl_lines="2 13-17" +--8<-- "docs_src/deploy/tutorial001.py" +``` + +* `allowed_hosts` की entries हूबहू strings हैं: `"mcp.example.com"` बिना port वाले `Host` header से मेल खाती है और `"mcp.example.com:*"` किसी भी port से। दोनों लिखें। +* `allowed_origins` सिर्फ़ browsers के लिए मायने रखती है, क्योंकि और कोई `Origin` नहीं भेजता। यह **[मौजूदा app में जोड़ना](asgi.md)** में बताई गई CORS configuration का server-side जोड़ीदार है। +* ऐसे reverse proxy के पीछे जो पहले से `Host` header को नियंत्रित करता है, इस जाँच को बंद कर देना ही ईमानदार configuration है: `TransportSecuritySettings(enable_dns_rebinding_protection=False)`। +* localhost से अलग `host=` देना (जैसे `host="mcp.example.com"`) उस hostname को allowlist **नहीं** करता। इससे बस इतना होता है कि localhost वाला default protection चालू नहीं करता, यानी हर Host और Origin स्वीकार हो जाता है। इसके बजाय `transport_security=` से साफ़-साफ़ कहें कि आप क्या चाहते हैं। + +!!! check + `transport_security=security` argument हटा दें और app को फिर भी deploy करें। वह शुरू होता है, `/mcp` + route होता है, और हर request (सादे `curl` से भेजी गई भी) का यह जवाब आता है: + + ```text + HTTP/1.1 421 Misdirected Request + + Invalid Host header + ``` + + client की तरफ़ आपको ये शब्द नहीं मिलेंगे। `421` plain-text HTTP response है, JSON-RPC error नहीं, + इसलिए MCP client एक सामान्य transport error raise करता है; जो hostname उसे पसंद नहीं आया + वह सिर्फ़ **server** के log में दिखता है, एक अकेली warning के रूप में। नया-नया + deploy हुआ server जो हर connection ठुकरा रहा हो, उसे Host allowlist की समस्या ही मानें जब तक कुछ और साबित न हो। + **[Troubleshooting](../troubleshooting.md)** भी यहीं से शुरू होता है। + +## Workers, और sticky किसे होना है {#workers-and-who-has-to-be-sticky} + +जब hostname जवाब देने लगे, तो उसके पीछे एक से ज़्यादा worker लगाएँ। इसके लिए SDK में कोई knob नहीं है; Starlette app को वैसे ही scale किया जाता है जैसे किसी भी ASGI app को, object किसी ऐसी चीज़ को सौंपकर जो fork करना जानती है: + +```console +uvicorn server:app --workers 4 +``` + +चार processes, एक socket। और अब वह सवाल जिसका जवाब हर deployment को देना होता है: **क्या किसी request का उसी worker तक पहुँचना ज़रूरी है जिसने पिछली request देखी थी?** + +**2026-07-28** protocol बोलने वाले client के लिए, नहीं। modern request अपने आप में पूरी एक POST है: उससे पहले कोई `initialize` handshake नहीं, response पर कोई `Mcp-Session-Id` नहीं, ऐसा कुछ भी नहीं जिस पर दूसरी request को लौटकर आना पड़े। इसे किसी भी worker को भेज दें। + +यह कोई ऐसा mode नहीं जिसे आप चालू करते हैं। `stateless_http=True` देखने में ऐसा लगता है, लेकिन transport `MCP-Protocol-Version` request header देखकर route करता है, modern request को modern handler को सौंपता है, और **return कर जाता है**। `stateless_http` पढ़ने वाली line उस return के **बाद** आती है। ऐसा नहीं कि 2026-07-28 path पर flag अनदेखा होता है; वहाँ तक पहुँचा ही नहीं जाता। `stateless_http` सिर्फ़ **legacy** हिस्से का knob है, और modern path बनावट से ही sessionless है। + +spec version 2025-11-25 या उससे पहले वाले legacy client के लिए जवाब उस flag पर निर्भर करता है: + +| client का protocol version | Session | load balancer को क्या करना होगा | +| --- | --- | --- | +| **2026-07-28** | कोई नहीं। `Mcp-Session-Id` कभी set नहीं होता। | कुछ नहीं। कोई भी worker कोई भी request serve करता है। | +| **2025-11-25 और पहले** (default) | `Mcp-Session-Id`, एक worker की memory में रखा हुआ। | **Sticky sessions।** कोई अगली request जो दूसरे worker तक पहुँचे उसे `404` *"Session not found"* मिलता है। | +| **2025-11-25 और पहले**, `stateless_http=True` के साथ | कोई नहीं। | कुछ नहीं। कीमत है server से client वाला back-channel (sampling, push elicitation, `roots/list`) और resumability। | + +Sticky sessions और legacy हिस्से की कीमत का अपना अलग page है, **[legacy clients को serve करना](legacy-clients.md)**; दोनों पीढ़ियाँ खुद **[Protocol versions](../protocol-versions.md)** में हैं। यहाँ मायने रखता है जवाब का आकार: **2026-07-28 पर आप पहले से stateless हैं, configure करने को कुछ नहीं।** + +इस page का बाकी हिस्सा उन दो चीज़ों के बारे में है जो stateless होने से आपको **नहीं** मिलतीं। + +## अलग-अलग workers के बीच `requestState` {#requeststate-across-workers} + +**[multi-round-trip](../handlers/multi-round-trip.md)** tool को कुछ ऐसा चाहिए होता है जो client को जाकर लाना पड़ता है (एक confirmation, एक चुनाव, एक credential), इसलिए वह जवाब की जगह सवाल लौटाता है और retry पर काम पूरा करता है। दोनों rounds के बीच client के पास एक opaque `request_state` token होता है जिसे server ने बनाया था। retry पर server को वह token फिर से खोलना होता है। + +**किस key से seal किया गया?** default रूप से, उस key से जो server ने construction के समय `os.urandom(32)` से बनाई थी। `--workers 4` में यह चार constructions हैं, चार processes में: चार अलग-अलग keys, कहीं लिखी नहीं गईं, कभी साझा नहीं हुईं, restart पर गायब। + +यह रहा एक tool जो कुछ करने से पहले पूछता है, ऐसे server पर जो कुछ भी configure नहीं करता: + +```python title="server.py" hl_lines="14 20" +--8<-- "docs_src/deploy/tutorial002.py" +``` + +पहला round worker A तक पहुँचता है। worker A `refund:120` को **अपनी** key से seal करता है और token लौटाता है। client सवाल किसी इंसान के सामने रखता है, हाँ पाता है, और retry करता है। यह retry बिल्कुल नई HTTP request है। + +!!! check + मान लें वह retry worker B तक पहुँचती है। B ऐसे token को unseal करने की कोशिश करता है जो उसने नहीं बनाया, कर नहीं पाता, और + पूरा round ठुकरा देता है। `refund` कभी call नहीं होता; client को JSON-RPC error मिलता है: + + ```json + { + "code": -32602, + "message": "Invalid or expired requestState", + "data": {"reason": "invalid_request_state"} + } + ``` + + यह message **कभी नहीं बदलता**। Expired हो, छेड़छाड़ हुई हो, अलग arguments के साथ replay किया गया हो, या + (असली deployment में सबसे आम कारण) किसी सहोदर worker ने seal किया हो: client को + हर बार यही बताया जाता है, इसलिए wire पर कभी पता नहीं चलता कि कौन-सी जाँच fail हुई। असली कारण + server के log में एक `WARNING` है: + + ```text + requestState rejected on tools/call: unknown key + ``` + + जो multi-round-trip tool एक worker पर चलता था और दो पर **कभी-कभी** fail होने लगा, + उसकी वजह यही है। दोनों rounds को अब भी एक ही process तक पहुँचना होता है, इसलिए यह ठीक उतनी बार fail होता है जितनी बार + आपका load balancer उन्हें अलग कर देता है। + +दोनों rounds दो स्वतंत्र HTTP requests हैं, और कई आम चीज़ें उन्हें अलग कर देती हैं: हर request पर balance करने वाला proxy, बीच में टूट गया connection, कोई deploy या restart, ऐसा client जिसने `request_state` सहेज रखा था और अब बिल्कुल अलग process से resume कर रहा है (**[Loop खुद चलाना](../handlers/multi-round-trip.md#driving-the-loop-yourself)**)। इनमें से कोई भी "एक अलग worker" है। + +इलाज एक argument है। उसके **दो** हिस्से हैं। + +```python title="server.py" hl_lines="1 12 14" +--8<-- "docs_src/deploy/tutorial003.py" +``` + +* **`keys=[...]`** वह हिस्सा है जो सबको मिल जाता है। हर instance को एक ही secret दें (कम से कम 32 bytes का), और हर instance वह unseal कर सकता है जो किसी भी सहोदर ने बनाया। `keys[0]` seal करती है और सूची की हर key unseal करती है, यही rotation ring है; इसे बिना downtime के कैसे घुमाएँ, यह **[Keys rotate करना](../handlers/multi-round-trip.md#rotating-keys)** में है। +* **server का नाम** वह हिस्सा है जो लगभग किसी को नहीं मिलता, और यही कारण है कि key साझा करने के बाद भी cross-instance retries fail होती रहती हैं। हर sealed token में server का `name` एक **audience claim** के रूप में होता है, जिसे वापसी पर सख़्ती से जाँचा जाता है। एक ही code से बने दो instances का नाम एक ही होता है और उन्हें इसका कभी पता भी नहीं चलता। उन्हें अलग-अलग नाम दें (`MCPServer(f"billing-{POD}")` अच्छी observability आदत जैसा लगता है), और हर cross-instance retry ठीक ऊपर की तरह ठुकरा दी जाती है, key साझा हो या न हो। log में `unknown key` की जगह `audience` लिखा आता है; client को फ़र्क़ पता नहीं चलता। + +secret एक बार बनाएँ और वही value हर instance को दें। अगर आप 32 bytes से कम देते हैं तो SDK का अपना error message यही command चलाने को कहता है: + +```console +python -c "import secrets; print(secrets.token_hex(32))" +``` + +!!! warning "वही keys, **और** वही नाम" + multi-instance deployment को दोनों साझा करने ही होंगे। अगर हर instance का अलग नाम आपके लिए ज़रूरी है, + तो इसके बजाय पूरे fleet को एक स्पष्ट audience दें: `RequestStateSecurity(keys=[...], audience="billing")`। + फिर हर instance `"billing"` के तहत बनाता और स्वीकार करता है, चाहे उसका नाम कुछ भी हो। + +seal के बारे में बाकी सब कुछ **[`requestState` की सुरक्षा](../handlers/multi-round-trip.md#protecting-requeststate)** में है: यह क्या-क्या bind करता है, हर round का `ttl` (default रूप से 600 seconds), अपना codec लाना, और बिना configure किया default `stdio` पर बिल्कुल सही क्यों है। इस page का पूरा योगदान दो बातों की checklist है: **वही keys, वही नाम।** + +!!! info + भले ही आपने कभी `InputRequiredResult` न लिखा हो, आप इसी path पर हैं। जिस tool के parameters + `Resolve(...)` इस्तेमाल करते हैं (**[Dependencies](../handlers/dependencies.md)**) वह multi-round-trip tool है, + और SDK उसके लिए उसका `request_state` बनाता और seal करता है। वही default key, workers के बीच वही + failure, वही इलाज। + +## अलग-अलग replicas के बीच change notifications {#change-notifications-across-replicas} + +client की `subscriptions/listen` stream एक लंबे समय तक चलने वाला response है, इसलिए वह अपनी पूरी ज़िंदगी एक replica से बँधी रहती है। किसी **दूसरे** replica पर publish हुआ `ctx.notify_resource_updated(...)` उस तक पहुँचना चाहिए। + +दोनों के बीच का जोड़ `SubscriptionBus` है। आप server को जो भी bus देते हैं, हर publish उसी में जाता है और हर खुली stream उसी को सुनती है, इसलिए हर replica को वही bus दें: + +```python title="server.py" hl_lines="2 7 9" +--8<-- "docs_src/deploy/tutorial004.py" +``` + +fan-out को इससे कोई मतलब नहीं कि stream किस server object से जुड़ी है। एक ही `InMemorySubscriptionBus` रखने वाले दो servers पहले से ऐसे ही बर्ताव करते हैं: एक पर listen stream खोलें, दूसरे पर `edit_note` चलाएँ, और stream को इसकी ख़बर मिल जाती है। वह in-memory bus सिर्फ़ एक process के अंदर के server objects तक फैलता है, इसलिए यह model है, deployment नहीं: + +* असली processes के बीच, **SDK में ऐसा कोई bus नहीं आता जो आपकी मदद कर सके।** `SubscriptionBus` दो methods वाला `Protocol` है (`publish` और `subscribe`) जिसे आप अपने pub/sub backend (Redis, NATS, जो भी आप पहले से चलाते हैं) के ऊपर implement करते हैं और `MCPServer(subscriptions=...)` के रूप में देते हैं। sketch और contract **[Subscriptions](../handlers/subscriptions.md#scaling-past-one-process)** में हैं। +* bus चार छोटे typed events ढोता है, JSON-RPC कभी नहीं। Acknowledgment, filtering, और stream lifecycle SDK में ही रहते हैं, इसलिए आपका bus protocol तोड़ नहीं सकता; वह सिर्फ़ events को processes के बीच ले जा सकता है। +* Streams resumable **नहीं** हैं और events replay **नहीं** होते। कोई replica खो जाए तो उसकी streams गिर जाती हैं; clients फिर से listen और फिर से fetch करते हैं। साझा करने को कोई event store नहीं और configure करने को और कुछ नहीं। यह वह एक जगह है जहाँ scale out करना सच में बस वही चीज़ और ज़्यादा है। + +## SDK आपको क्या नहीं देता {#what-the-sdk-does-not-give-you} + +`MCPServer` एक protocol implementation है, application server नहीं। जिन deployment knobs को आप आगे ढूँढने जाएँगे वे जान-बूझकर नहीं हैं: + +* **कोई `workers=` नहीं।** `mcp.run("streamable-http")` ठीक एक uvicorn process शुरू करता है, और वह कभी बस इतना ही शुरू करेगा। Multi-process का मतलब है `streamable_http_app()` को उसी चीज़ को सौंपना जिससे आप पहले से ASGI deploy करते हैं: `uvicorn --workers`, gunicorn, आपके platform का process manager। यह page जान-बूझकर इनमें से किसी का tutorial नहीं है; उनका documentation यहाँ उसकी नकल से बेहतर है। +* **कोई health-check route नहीं।** `@mcp.custom_route("/health", methods=["GET"])` ही पूरा जवाब है, और इस पर कभी authentication नहीं लगता, तब भी नहीं जब बाकी server पर लगा हो। liveness probe के लिए यह सही है, किसी भी निजी चीज़ के लिए गलत। **[मौजूदा app में जोड़ना](asgi.md#custom-routes)** में एक उदाहरण है। +* **कोई production settings object नहीं।** `MCPServer` पर timeouts, TLS, graceful shutdown, या connection limits लिखने की कोई जगह नहीं है, क्योंकि इनमें से कोई भी उसका काम नहीं। ये आपके ASGI server के हैं, और आप उन्हें वहीं configure करते हैं। constructor जो गिनी-चुनी settings **लेता है**, वे **[अपना server चलाना](index.md)** में हैं। +* **कोई `EventStore` साथ नहीं आता, और 2026-07-28 पर उसका कोई काम भी नहीं।** Resumability legacy stateful हिस्से की feature है; modern exchange एक POST, एक response है, और resume करने को कुछ नहीं। + +## सारांश {#recap} + +* बिना कुछ configure किए app सिर्फ़ उन्हीं requests का जवाब देता है जो localhost को भेजी गई हों। `transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])` ही go-live gate है: जब तक आप इसे नहीं देते, असली hostname के पीछे हर request `421` है और कारण सिर्फ़ server के log में है। +* 2026-07-28 पर कोई session नहीं है और load balancer के sticky होने के लिए कुछ नहीं। `stateless_http=True` सिर्फ़ legacy का knob है क्योंकि modern request उस flag के पढ़े जाने से पहले ही route होकर जवाब पा लेती है। +* default `requestState` key `os.urandom(32)` है, हर process में अलग बनी हुई। कोई multi-round-trip retry जो दूसरे worker तक पहुँचे, `-32602` *"Invalid or expired requestState"* के साथ fail होती है। +* इलाज है `RequestStateSecurity(keys=[...])` **और** हर instance पर एक ही server नाम। नाम ही token का default audience claim है। वही keys, वही नाम। +* Change notifications एक साझा `SubscriptionBus` के ज़रिए replicas के पार जाते हैं। SDK का एकमात्र implementation in-process है; अपने pub/sub के ऊपर दो methods वाला `Protocol` आपको खुद लिखना है। +* कोई `workers=` नहीं, कोई health route नहीं, कोई production settings object नहीं। अपना ASGI server खुद लाएँ। + +असली hostname के सामने जो दूसरी चीज़ चाहिए वह है token: **[Authorization](authorization.md)**। diff --git a/i18n/hi/pages/run/index.md b/i18n/hi/pages/run/index.md new file mode 100644 index 0000000000..6ecb764082 --- /dev/null +++ b/i18n/hi/pages/run/index.md @@ -0,0 +1,156 @@ +--- +translation: + sections: [fea8d769ff9edeba, ce8e2ad42f29ef71, 0d705efb19cf99c2, 7a53ead3e704a7f0, 9adc400e8c88e854, 318893ad8e2e9924, 6b63ab96b34476c0] + tool: 1 +--- +# अपना server चलाना {#running-your-server} + +`mcp.run()` server को शुरू करता है। + +आपको सिर्फ़ एक फ़ैसला करना है: **transport** कौन सा हो, यानी server और उसके client के बीच bytes असल में कैसे आएँ-जाएँ। + +## Transport चुनना {#pick-a-transport} + +| Transport | यह क्या है | कब | +|---|---|---| +| `stdio` | Host आपकी file को subprocess के रूप में launch करता है और उसके stdin और stdout पर बात करता है। | Local servers के लिए। यही default है। | +| `streamable-http` | Port पर सुनने वाला असली HTTP server। | जो कुछ भी आप deploy करें। | +| `sse` | पुराना HTTP transport। | कभी नहीं। | + +!!! warning + 2025-03-26 protocol revision में SSE की जगह Streamable HTTP ने ले ली। + `mcp.run(transport="sse")` अब भी काम करता है, अपने `sse_path=` और `message_path=` + options के साथ, लेकिन यह सिर्फ़ उन clients के लिए है जो अभी तक नहीं बदले। इस पर कुछ नया न बनाएँ। + +## `mcp.run()` {#mcprun} + +```python title="server.py" hl_lines="12-13" +--8<-- "docs_src/run/tutorial001.py" +``` + +* `run()` synchronous है। यह server के पूरे जीवनकाल तक block करता है। +* बिना argument के transport `stdio` होता है। +* यह `if __name__ == "__main__":` के नीचे इसलिए है क्योंकि server को load करने वाली हर चीज़ (`mcp dev`, `mcp run`, `mcp install`, आपके tests) इस file को **import** करती है। यह guard import को चलते हुए server में बदलने से रोकता है। + +### stdio {#stdio} + +Configure करने को कुछ नहीं है। Host आपकी file को child process के रूप में शुरू करता है, उसके stdin पर requests लिखता है, और उसके stdout से responses पढ़ता है। + +इसे खुद चलाएँ और नतीजा दिखेगा: + +```console +python server.py +``` + +कुछ print नहीं होता, और यह लौटता भी नहीं। यह stdin पर इंतज़ार कर रहा है कि कोई host पहले बोले। + +इसका मतलब यह भी है कि stdout **ही wire है**। Serve करते समय SDK wire को एक private descriptor पर ले जाता है और जो output stdout पर **flush** होता है (कोई subprocess जो अपने inherited stdout पर लिखता है, flush किया गया `print()`), उसे stderr पर मोड़ देता है, जहाँ वह stream को बिगाड़ नहीं सकता। Serving शुरू होने से **पहले** stdout पर flush हुआ output (कोई wrapper script जो echo करे, import के समय का unbuffered print) अब भी wire पर पहुँचता है, और वैसा `print()` भी जो तब तक buffered रहता है जब तक interpreter exit पर उसे drain नहीं कर देता। जो output आप सच में चाहते हैं, उसके लिए `logging` module सही तरीका है: उसका handler हर record को उसी समय stderr पर flush करता है। वह पूरी जानकारी **[Logging](../handlers/logging.md)** में है। + +### इसे आज़माएँ {#try-it} + +```console +uv run mcp dev server.py +``` + +Inspector ठीक वही करता है जो असली host करता है: यह `server.py` को subprocess के रूप में launch करता है और stdio पर उससे जुड़ता है। + +आपने इसे कभी port नहीं दिया। कोई port है ही नहीं। + +## Streamable HTTP {#streamable-http} + +इसी server को port पर रखने के लिए `run()` में transport (और उसके options) का नाम दें: + +```python title="server.py" hl_lines="13" +--8<-- "docs_src/run/tutorial002.py" +``` + +वह एक line Starlette app बनाती है और उसे uvicorn से serve करती है। Clients `http://127.0.0.1:3001/mcp` से जुड़ते हैं। + +हर transport के अपने keyword arguments हैं, सब `run()` पर: + +* `host` / `port`: कहाँ सुनना है। Default `127.0.0.1` और `8000`। +* `streamable_http_path`: MCP endpoint कहाँ रहता है। Default `/mcp`। +* `json_response=True`: हर POST का जवाब SSE stream के बजाय एक अकेली JSON body से देना। उस body में सिर्फ़ response की जगह है, और कुछ नहीं, इसलिए जो tool request के बीच में client को वापस call करता है (`ctx.elicit()`, sampling), वह इस leg पर `NoBackChannelError` raise करता है, और चल रही call से जुड़े notifications (`ctx.report_progress()` का progress, per-call log messages) छोड़ दिए जाते हैं; standalone `GET` stream असंबंधित notifications अब भी ले जाती है। +* `stateless_http=True`: हर request के लिए नया transport, कोई session tracking नहीं। +* `max_request_body_size`: स्वीकार की जाने वाली सबसे बड़ी POST body, bytes में। Default 4 MiB है; इससे बड़ी requests + को parsing या session बनने से पहले ही HTTP 413 मिलता है। इसे तभी बढ़ाएँ जब जायज़ MCP messages + उस आकार से बड़े हों। +* `event_store`, `retry_interval`, `transport_security`: resumability और DNS-rebinding से सुरक्षा। ये इंतज़ार कर सकते हैं, जब तक आप localhost के अलावा कहीं deploy न करें; `transport_security` की जानकारी **[Deploy & scale](deploy.md)** में है। + +!!! warning + Transport options `run()` को जाते हैं, `MCPServer(...)` को **नहीं**। Constructor बताता है कि + आपका server **क्या है**: name, version, instructions. `run()` बताता है कि वह कैसे serve होता है। इसे + उल्टा करेंगे तो MCP के शामिल होने से पहले ही Python जवाब दे देता है: + + ```text + TypeError: MCPServer.__init__() got an unexpected keyword argument 'port' + ``` + +`run()` छोटा रास्ता है। जैसे ही आपको इससे ज़्यादा चाहिए (server किसी मौजूदा app के अंदर mount हो, एक process में दो servers, browser clients के लिए CORS), आप ASGI app खुद बनाते हैं और उसे किसी भी ASGI host को सौंप देते हैं। वह **[मौजूदा app में जोड़ना](asgi.md)** है। + +## Server settings {#server-settings} + +चलाने से जुड़ी कुछ चीज़ें transport के बारे में नहीं हैं। वे constructor arguments हैं: + +```python title="server.py" hl_lines="3" +--8<-- "docs_src/run/tutorial003.py" +``` + +* `log_level`: `MCPServer(...)` बनते ही `logging.basicConfig()` को दे दिया जाता है। यह **root** logger को configure करता है, इसलिए यह सिर्फ़ SDK के नहीं, आपके अपने loggers का level भी तय करता है। Default `"INFO"`। +* `debug`: उस Starlette app को आगे भेजा जाता है जिसे HTTP transports बनाते हैं। Default `False`। + +दोनों `mcp.settings` पर पहुँचते हैं, जिसे आप runtime पर पढ़ सकते हैं। + +## `mcp` command {#the-mcp-command} + +`[cli]` extra इन सबके इर्द-गिर्द एक छोटा command-line tool install करता है। + +`mcp dev` आपके server को **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` जो environment यह बनाता है उसमें packages जोड़ता है; `--with-editable` उसमें आपका अपना package install करता है। इसे आपके `PATH` पर `npx` चाहिए: Inspector Node.js app है। + +`mcp run` file को import करता है, server object ढूँढता है (module-level `mcp`, `server`, या `app`), और उस पर `run()` call करता है: + +```console +uv run mcp run server.py +uv run mcp run server.py:bookshop +``` + +जब object का नाम `mcp`, `server`, या `app` नहीं है, तब `:` suffix उसका नाम बताता है। + +आपका `if __name__ == "__main__":` block यहाँ कभी नहीं चलता: `mcp run` खुद `run()` call करता है, और जो अकेला option वह आगे भेजता है वह `--transport` है। + +`mcp install` server को **Claude Desktop** में register करता है, ताकि app उसे आपके लिए launch करे: + +```console +uv run mcp install server.py --name "Bookshop" +uv run mcp install server.py -v API_KEY=abc123 -f .env +``` + +`-v KEY=VALUE` और `-f .env` उस entry में environment variables दर्ज करते हैं। Claude Desktop आपके server को अपने process में शुरू करता है। आपके shell का environment वहाँ नहीं होता। + +`mcp install` सिर्फ़ Claude Desktop को host के रूप में जानता है। बाकी हर host (Claude Code, Cursor, VS Code) वही launch command अपनी config file में लेता है, और हर एक की जानकारी **[असली host से जुड़ना](../get-started/real-host.md)** में है। + +`mcp version` install किया गया SDK version print करता है। + +!!! tip + `mcp dev` और `mcp run` सिर्फ़ `MCPServer` समझते हैं। अगर आप low-level `Server` से बनाते हैं, + तो उसे खुद चलाते हैं। देखें **[Low-level Server](../advanced/low-level-server.md)**। + +## सारांश {#recap} + +* **Transport** वह तरीका है जिससे bytes आपके server तक पहुँचते हैं: local subprocess के लिए `stdio`, port के लिए `streamable-http`। SSE की जगह ले ली गई है। +* `mcp.run()` transport चुनता है। बिना argument के यह `stdio` है, और यह block करता है। +* हर transport option (`host`, `port`, `streamable_http_path`, ...) `run()` का argument है, `MCPServer(...)` का कभी नहीं। +* `run()` को `if __name__ == "__main__":` के नीचे रखें। Server को load करने वाली हर चीज़ पहले file import करती है। +* `log_level=` और `debug=` constructor arguments हैं; वे `mcp.settings` पर पहुँचते हैं। +* Inspector के लिए `mcp dev`, file चलाने के लिए `mcp run`, Claude Desktop के लिए `mcp install`, version के लिए `mcp version`। +* Transport कभी नहीं बदलता कि आपका server **क्या है**: इस page की तीनों files बिल्कुल वही tool expose करती हैं। + +जब `run()` खुद सीमा बन जाए (आपका server किसी पहले से मौजूद app के अंदर), तो वह **[मौजूदा app में जोड़ना](asgi.md)** है। असली hostname और एक से ज़्यादा worker **[Deploy & scale](deploy.md)** है। और अगर आपके कुछ clients अब भी spec version 2025-11-25 या उससे पहले पर हैं, तो अच्छी ख़बर **[Legacy clients को serve करना](legacy-clients.md)** में है। diff --git a/i18n/hi/pages/run/legacy-clients.md b/i18n/hi/pages/run/legacy-clients.md new file mode 100644 index 0000000000..c97b54130e --- /dev/null +++ b/i18n/hi/pages/run/legacy-clients.md @@ -0,0 +1,132 @@ +--- +translation: + sections: [3d1663c18edc824c, d4fd37009a13f03d, af9f398a5a8b679a, 470c2dd144294d69, 8e45827e6d24e8c8, 91dfd0ce98ebb03c] + tool: 1 +--- +# legacy clients को serve करना {#serving-legacy-clients} + +MCP में protocol की दो पीढ़ियाँ हैं: `initialize`-handshake वाली पीढ़ी, जो spec version `2025-11-25` तक चलती है, और modern पीढ़ी, `2026-07-28`। इस बँटवारे पर अलग से पूरा page **[Protocol versions](../protocol-versions.md)** है। + +यह page उस बँटवारे के server वाले पहलू के बारे में है, और जवाब एक वाक्य में आ जाता है: **जो `streamable_http_app()` आप पहले से deploy करते हैं, वही दोनों को serve करता है।** + +SDK हर request को उसके `MCP-Protocol-Version` header के हिसाब से route करता है। जिस request में `2026-07-28` लिखा हो, वह modern handler के पास जाती है। जिस request में handshake पीढ़ी का कोई version हो, या कोई header ही न हो (2026 से पहले के client की `initialize` इसी तरह आती है), वह उसी transport के पास जाती है जिसकी उन clients को उम्मीद होती है: `initialize` handshake, sessions, सब कुछ। यह हर request पर होता है, आपके code से पहले, उसी एक app पर। + +इसलिए legacy client कोई ऐसी चीज़ नहीं जिसके **लिए** आप कुछ बनाएँ। वह बस उस server **से जुड़ता** है जो आप पहले ही लिख चुके हैं। configure कुछ नहीं करना। + +!!! note + सचमुच कुछ नहीं। न कोई `legacy=` option है, न version allowlist, न किसी पीढ़ी को reject या + disable करने का कोई तरीका: न `streamable_http_app()` पर, न `run()` पर, न session manager पर। + दोनों पीढ़ियाँ हमेशा चालू रहती हैं। उस signature में पीढ़ी के हिसाब से switch जैसी सबसे नज़दीकी चीज़ + `stateless_http` है, और इस page का ज़्यादातर हिस्सा उसी के बारे में है। + +## एक handler, दोनों पीढ़ियाँ {#one-handler-both-eras} + +यह रहा एक tool जिसे user से कुछ पूछना है, और दोनों पीढ़ियों के client जो उसे call कर रहे हैं: + +```python title="server.py" hl_lines="24 37-38" +--8<-- "docs_src/legacy_clients/tutorial001.py" +``` + +`reserve` को एक चीज़ चाहिए जो model ने नहीं दी: कितनी copies। tool यह बात `Annotated[..., Resolve(ask_quantity)]` से declare करता है (पूरी जानकारी **[Dependencies](../handlers/dependencies.md)** में है)। `reserve` में कहीं भी न किसी version का नाम है, न capability की जाँच, न कोई branch। + +दोनों clients **एक ही समय पर** खुले हैं, उसी `mcp` object पर। `mode="legacy"` `initialize` handshake चलाता है: ठीक वही connection जो 2026 से पहले का client खोलता है। दूसरा client default लेता है और `2026-07-28` पर पहुँचता है। + +```text +2025-11-25 {'result': "Reserved 2 of 'Dune'."} +2026-07-28 {'result': "Reserved 2 of 'Dune'."} +``` + +वही server, वही handler, वही जवाब। पूरा feature बस इतना ही है। + +यह **कैसे** हुआ, इस पर थोड़ा रुकना ठीक रहेगा, क्योंकि दोनों clients से वही सवाल दो बिल्कुल अलग wires पर पूछा गया। `2026-07-28` connection में ऐसा कोई channel नहीं जिस पर server request भेज सके, इसलिए `Resolve` ने सवाल tool result के अंदर लौटाया और client ने जवाब के साथ call दोबारा किया (**[Multi-round-trip requests](../handlers/multi-round-trip.md)**)। `2025-11-25` connection में ऐसा कुछ नहीं है; वहाँ `Resolve` ने call के बीच में ही live `elicitation/create` request भेजी और इंतज़ार किया। आपने दोनों में से कुछ नहीं लिखा। `Resolve` connection का negotiated version पढ़ता है और चुनता है; आपकी tool body को दोनों सूरतों में `AcceptedElicitation` ही दिखता है। + +!!! tip + पीढ़ियों के बीच यही portability वह **वजह** है कि `Resolve` ही वह API है जिस पर बनाना चाहिए। इसका पुराना + भाई `ctx.elicit()` (**[Elicitation](../handlers/elicitation.md)**) हमेशा सिर्फ़ `elicitation/create` भेजता है, + इसलिए यह सिर्फ़ legacy connection पर ही काम करता है। `2026-07-28` connection पर call fail हो जाता है। + अगर कोई tool अब भी इसे इस्तेमाल करता है, तो उसका हल वही है जो ऊपर दिखा, version check नहीं। + +## legacy session की कीमत क्या है {#what-a-legacy-session-costs-you} + +routing मुफ़्त है। session नहीं। + +`2026-07-28` connection **sessionless** होता है: हर request अपने आप में पूरी होती है, और modern handler कभी `Mcp-Session-Id` जारी नहीं करता। legacy connection इसका उल्टा है। जैसे ही 2026 से पहले का client `initialize` भेजता है, SDK एक `Mcp-Session-Id` बनाता है, उसे response header में लौटाता है, और उसके पीछे एक live record रखता है ताकि client की बाद की requests उसे ढूँढ सकें: negotiated version, खुले streams, session को चलाने वाला background task। + +वह record बस **सादा in-process `dict`** है। कोई distributed session store नहीं है, और न कोई जोड़ने का तरीका। + +एक worker पर यह दिखता ही नहीं। दो पर, पूरी समस्या यही है: जो request `Mcp-Session-Id` लेकर आए और ऐसे worker पर पहुँचे जिसने वह ID नहीं बनाई थी, उसे उस dict में कुछ नहीं मिलता, और जवाब `404` (`Session not found`) होता है, tool result नहीं। इसलिए जैसे ही आप एक से ज़्यादा worker चलाते हैं, **legacy clients को sticky routing चाहिए**: session की हर request को उसी process तक पहुँचना होगा जिसने उसे शुरू किया था। modern clients को कभी नहीं; उनके पास कोई session ही नहीं जिससे चिपका जाए। stickiness और एक से ज़्यादा worker चलाने से जुड़ी बाकी सारी बातें **[Deploy और scale](deploy.md)** में हैं। + +!!! warning + `event_store=` हल जैसा दिखता है पर है नहीं। यह **resumability** है (**उसी** session से दोबारा जुड़ रहे + client को छूटे हुए SSE events फिर से भेजना), session store नहीं। यह कभी किसी session को + दूसरे process से पहुँच लायक नहीं बनाता। + +## इकलौता switch: `stateless_http` {#the-one-knob-stateless_http} + +अगर stickiness ऐसी कीमत है जो आप चुकाना नहीं चाहते, तो ठीक एक चीज़ है जो आप बदल सकते हैं। + +```python title="server.py" hl_lines="28" +--8<-- "docs_src/legacy_clients/tutorial002.py" +``` + +यह page के ऊपर वाला server ही है, बस एक keyword ज़्यादा। `stateless_http=True` से legacy हिस्सा इसके बजाय हर request के लिए अलग, अस्थायी session बनाता है: कोई `Mcp-Session-Id` जारी नहीं होती, requests के बीच कुछ याद नहीं रखा जाता, इसलिए कोई भी worker कोई भी request serve कर सकता है और load balancer जो चाहे कर सकता है। + +इसके बारे में दो बातें इससे ज़्यादा मायने रखती हैं कि यह करता क्या है। + +**यह सिर्फ़ legacy हिस्से को छूता है।** requests version header के हिसाब से `stateless_http` पढ़े जाने से **पहले** route हो जाती हैं, इसलिए modern path इसे कभी देखता ही नहीं। `2026-07-28` connection पहले से sessionless है और दोनों values पर बिल्कुल एक जैसा रहता है। + +**उस हिस्से पर इसकी कीमत server से client जाने वाले दोनों channels हैं।** जो session सिर्फ़ एक `POST` तक जीता है, उसके पास न ऐसा stream है जिस पर server request भेज सके, न ऐसा standalone stream जिस पर वह notifications भेज सके। server की तरफ़ से शुरू हुई हर request `NoBackChannelError` raise करती है: `ctx.elicit()`, retire हो चुके sampling और roots calls (**[Deprecated features](../deprecated.md)**), और, हाँ, `Resolve` का किसी **legacy** client से अपना सवाल पूछना भी। notifications को तो error भी नहीं मिलता; वे चुपचाप गिरा दिए जाते हैं। + +!!! note + `json_response=True` वह switch नहीं है, पर **हर** legacy session पर वही कीमत आधी वसूलता है: + जिस `POST` का जवाब एक JSON body से दिया जाए, उसके पास request-scoped channel के लिए कोई stream + नहीं होता, इसलिए request के बीच में किया गया `ctx.elicit()` वही `NoBackChannelError` raise करता है और + request से जुड़े notifications गिरा दिए जाते हैं। session का standalone stream अछूता रहता है: असंबंधित + notifications अब भी पहुँचते हैं। + +!!! check + जानबूझकर गलत काम करें। `reserve` ठीक वही tool है जिसने अभी दोनों clients को serve किया। इसे + `stateless_http=True` के साथ deploy करें, वही दो clients HTTP पर जोड़ें, और हर एक से इसे call करें। + + modern client को अब भी `Reserved 2 of 'Dune'.` मिलता है। modern हिस्सा नहीं बदला। + + legacy client का call ऐसे `is_error` result के रूप में वापस नहीं आता जिसे model पढ़ सके। + पूरी request fail होती है, top-level protocol error के रूप में: + + ```text + mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests. + ``` + + `Resolve` ने आपको नहीं बचाया। `2025-11-25` connection पर इसे `elicitation/create` भेजना ही **पड़ता** है, + और जो channel इसे चाहिए वह ठीक वही चीज़ है जो `stateless_http=True` ने गँवा दी। पीढ़ियों के बीच + portable code का मतलब back-channel से मुक्त code नहीं है। + +तो यह सचमुच का सौदा है, और यह सिर्फ़ legacy हिस्से पर मौजूद है: **session वाला और sticky, या stateless और एकतरफ़ा।** अगर आपके tools कभी client में वापस call नहीं करते, तो `stateless_http=True` मुफ़्त है और आपको इसे ले लेना चाहिए। अगर करते हैं, तो sessions रखें और routing sticky रखें। + +## आपका code असल में कहाँ बँटता है {#where-your-code-actually-forks} + +लगभग कहीं नहीं। + +tools, resources, prompts, structured output, progress, errors: इनमें से किसी को फ़र्क नहीं पड़ता कि किस पीढ़ी ने call किया। `initialize` handshake, `Mcp-Session-Id`, standalone stream, session खत्म करने वाला `DELETE`: यह सब SDK के ज़िम्मे है, और handler को इनमें से कुछ कभी नहीं दिखता। interactive input **वही एक** जगह है जहाँ wire पर पीढ़ियाँ सच में अलग हैं, और `Resolve` इसीलिए है कि यह आपकी समस्या न बने: आपने अभी एक ही tool को दोनों को serve करते देखा। + +ठीक एक चीज़ बचती है, और वह है **change notifications**, क्योंकि दोनों पीढ़ियाँ अलग-अलग pipes पर सुनती हैं: + +* `2026-07-28` client `subscriptions/listen` stream खोलता है और subscriptions bus पढ़ता है। `ctx.notify_resource_updated()` (और `notify_tools_changed()`, `notify_prompts_changed()`, `notify_resources_changed()`) वहीं publish करते हैं, और **सिर्फ़** वहीं। वह page **[Subscriptions](../handlers/subscriptions.md)** है। +* legacy client वह standalone stream पढ़ता है जो उसका session खुला रखता है। `ctx.session.send_resource_updated()` (और `send_tool_list_changed()` व उसके साथी) उस **connection** पर लिखते हैं जिस पर request आई थी: legacy session के लिए वह उसका standalone stream है। modern connection में इसके लिए कोई जगह नहीं: HTTP पर ऐसा कोई channel है ही नहीं, और stdio पर चारों तरह के change notifications सिर्फ़ `subscriptions/listen` streams पर चलते हैं, इसलिए modern connection पर notification चुपचाप गिरा दिया जाता है। + +HTTP पर, दोनों में से कोई call दूसरी पीढ़ी के clients तक नहीं पहुँचता। सबको बताने के लिए, दोनों call करें: + +```python title="server.py" hl_lines="19-20" +--8<-- "docs_src/legacy_clients/tutorial003.py" +``` + +दो lines, कोई `if` नहीं, कोई version check नहीं, और काम पूरा। legacy client के होने की वजह से handler जो कुछ अलग करता है, उसकी पूरी सूची बस इतनी ही है। + +## सारांश {#recap} + +* एक ही `streamable_http_app()` protocol की दोनों पीढ़ियों को serve करता है। SDK हर request को उसके `MCP-Protocol-Version` header के हिसाब से route करता है; configure करने को कुछ नहीं है और पीढ़ी का कोई switch ढूँढने को नहीं है। +* legacy client की कीमत एक session है: in-process `Mcp-Session-Id` record जिसके पीछे कोई distributed store नहीं। एक से ज़्यादा worker का मतलब **sticky routing** है, वरना गलत worker `404 Session not found` जवाब देता है। कई workers वाली पूरी जानकारी **[Deploy और scale](deploy.md)** में है। +* `stateless_http=True` इकलौता switch है, और यह **सिर्फ़ legacy हिस्से पर** असर करता है। यह legacy clients के लिए बेरोक load balancing दिलाता है, पर बदले में उस हिस्से के server से client जाने वाले दोनों channels जाते हैं: server की तरफ़ से शुरू हुई requests `NoBackChannelError` raise करती हैं (client पर top-level error, `is_error` result नहीं), और notifications गिरा दिए जाते हैं। +* `2026-07-28` connection हर हाल में sessionless है। `stateless_http` इसे कभी नहीं छूता। +* आपका handler code पीढ़ी के हिसाब से ठीक एक जगह बँटता है: change notifications। `ctx.notify_*` `subscriptions/listen` clients तक पहुँचता है; `ctx.session.send_*` legacy sessions तक। दोनों call करें। +* बाकी सब कुछ (`Resolve` के ज़रिए user से input माँगना भी) बनावट से ही पीढ़ियों के बीच portable है। modern तरीका एक बार लिखें। diff --git a/i18n/hi/pages/run/opentelemetry.md b/i18n/hi/pages/run/opentelemetry.md new file mode 100644 index 0000000000..4dc55c4c3f --- /dev/null +++ b/i18n/hi/pages/run/opentelemetry.md @@ -0,0 +1,85 @@ +--- +translation: + sections: [bc0227014724fa49, 15738c2f7fd67d86, a2c17bbe3f707e2f, d0d853376f162c06, b6368643fcc1c8d8, 902e33e17564a607] + tool: 1 +--- +# OpenTelemetry {#opentelemetry} + +आपका server पहले से trace हो रहा है। आपको कुछ जोड़ने की ज़रूरत नहीं। + +आप जो भी server बनाते हैं, वह अपने संभाले हर message के लिए एक [OpenTelemetry](https://opentelemetry.io/) span emit करता है। यह आपने नहीं लिखा, और न आप इसे import करते हैं। जिस पल आप `MCPServer(...)` call करते हैं, यह मौजूद होता है। + +```python title="server.py" +--8<-- "docs_src/opentelemetry/tutorial001.py" +``` + +यह पूरा, traced server है। `search_books` को call करें और उसके लिए span बन जाता है। low-level `Server` के लिए भी यही सच है: tracing दोनों में मौजूद है। + +## आपको क्या मिलता है {#what-you-get} + +हर inbound message एक `SERVER` span बन जाता है, जिसका नाम method और उसके target पर रखा जाता है। तो `search_books` के लिए `tools/call` का span `tools/call search_books` होता है, और सिर्फ़ `tools/list` बस `tools/list` रहता है। + +हर span में कुछ attributes होते हैं: + +* `mcp.method.name` और `mcp.protocol.version`, हर span पर। +* `jsonrpc.request.id`, request पर (notification का कोई नहीं होता)। +* जो handler raise करता है, वह span status को error पर set कर देता है। `is_error=True` वाला tool result भी यही करता है। + +और क्योंकि tool call को trace करना बहुत आम ज़रूरत है, `tools/call` spans OpenTelemetry की [GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/) का पालन करते हैं: + +* `gen_ai.operation.name`, जो `"execute_tool"` पर set होता है। +* `gen_ai.tool.name`, जो call हो रहे tool के नाम पर set होता है। + +इसी तर्ज़ पर `prompts/get` span को `gen_ai.prompt.name` मिलता है। list methods में कोई `gen_ai.*` keys नहीं होतीं, क्योंकि वहाँ नाम देने के लिए कुछ है ही नहीं। + +!!! tip + इन्हीं GenAI attributes की वजह से tracing UI आपके tool calls को उसी तरह group करती है जैसे किसी भी दूसरे agent के। यह grouping आपको मुफ़्त मिलती है, बिना किसी अतिरिक्त code के। + +## जब तक आप न चाहें, इसकी कोई कीमत नहीं {#it-costs-nothing-until-you-want-it} + +यही वह हिस्सा है जो "default रूप से चालू" को एक सहज default बनाता है। + +SDK सिर्फ़ `opentelemetry-api` पर depend करता है, जो OpenTelemetry का हल्का आधा हिस्सा है। जब कोई SDK और कोई exporter install न हो, तो span बनाना no-op है। इसलिए आपका server अभी जो spans emit कर रहा है, उनकी कीमत लगभग कुछ भी नहीं, और कोई उन्हें इकट्ठा नहीं कर रहा। + +जिस दिन आप उन्हें **देखना** चाहें, दूसरा आधा हिस्सा install करें और उसे कहीं point करें: + +```console +uv add opentelemetry-sdk opentelemetry-exporter-otlp +``` + +exporter को OpenTelemetry के सामान्य तरीके से configure करें, और SDK जो spans चुपचाप बनाता आ रहा था, वे सब दिखने लगते हैं। आपका server code नहीं बदलता। एक line भी नहीं। + +!!! info + [Pydantic Logfire](https://logfire.pydantic.dev/) ऐसा ही एक backend है, और यह configuration आपके लिए कर देता है: `pip install logfire`, `logfire.configure()`, और आपके MCP spans live view में दिखने लगते हैं। यह OpenTelemetry पर बना है, इसलिए नीचे लिखी हर बात इस पर भी लागू होती है। + +## wire पार करने वाले traces {#traces-that-cross-the-wire} + +trace सबसे ज़्यादा काम का तब होता है जब वह request को client से लेकर server के अंदर तक, एक जुड़ी हुई तस्वीर में follow करे। + +जब client और server दोनों SDK चला रहे हों, तो यह जुड़ाव अपने आप होता है। client request में [W3C trace context](https://www.w3.org/TR/trace-context/) inject करता है, और server उसे वापस पढ़ लेता है, इसलिए server span उसी trace में client span के नीचे nest हो जाता है। यही [SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414) है, और यह आपको बिना माँगे मिलता है। + +अगर inbound message में कोई trace context नहीं है, जैसे ऐसे client से आई request जो SDK नहीं है, तो server span बिल्कुल नया orphan trace शुरू करने के बजाय server पर जो भी span पहले से current है उसी का child बन जाता है। + +## इसे बंद करना {#turning-it-off} + +tracing एक middleware है, आपके server की सूची में पहला। अगर आप सच में ऐसा server चाहते हैं जो कोई span emit न करे, तो इसे हटा दें: + +```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 + उस import में आगे underscore है, और यह जानबूझकर है। class provisional है, ठीक वैसे ही जैसे [`Server.middleware`](../advanced/middleware.md) provisional है, इसलिए import path के बदलने की उम्मीद रखें। आपको इसकी ज़रूरत लगभग कभी नहीं पड़ती: जब कोई exporter install न हो तो spans मुफ़्त हैं, इसलिए आम जवाब यही है कि उन्हें चालू रहने दें और exporter install न करें। + +## सारांश {#recap} + +* हर `MCPServer` और हर low-level `Server` बिना कुछ configure किए हर inbound message पर एक `SERVER` span emit करता है। आप कुछ नहीं लिखते। +* spans में `mcp.method.name` और `mcp.protocol.version` होते हैं; `tools/call` और `prompts/get` में GenAI attributes भी होते हैं ताकि आपके tool calls किसी भी दूसरे agent की तरह group हों। +* जब तक आप OpenTelemetry SDK और exporter install नहीं करते, इसकी कोई कीमत नहीं, और फिर यह आपके server में बिना किसी बदलाव के दिखने लगता है। +* जब दोनों तरफ़ SDK चल रहा हो, तो client से server तक trace context अपने आप propagate होता है। + +कोई request चलेगी भी या नहीं, यह **[Authorization](authorization.md)** तय करता है। diff --git a/i18n/hi/pages/servers/completions.md b/i18n/hi/pages/servers/completions.md new file mode 100644 index 0000000000..58a2c8eb64 --- /dev/null +++ b/i18n/hi/pages/servers/completions.md @@ -0,0 +1,130 @@ +--- +translation: + sections: [72f9c964769076dd, 9a2c14e10935b515, 235299eb78ab12d7, 8aee1e78c8237fb8, 9bd86acd4112138f, 55343cb7f250dc7b] + tool: 1 +--- +# Completions {#completions} + +आपके server के ऊपर UI बना रहा कोई client चाहता है कि user के टाइप करते ही argument values अपने आप पूरे हों: भाषाओं के नाम, repositories के नाम, file paths। + +**Completions** वह तरीका है जिससे server ये सुझाव देता है। + +## कुछ ऐसा जो complete करने लायक हो {#something-worth-completing} + +Completions ठीक दो चीज़ों पर लागू होते हैं: किसी **prompt** के arguments और किसी **resource template** के parameters। तो ऐसे server से शुरू करें जिसमें दोनों में से एक-एक हो: + +```python title="server.py" hl_lines="6 12" +--8<-- "docs_src/completions/tutorial001.py" +``` + +यहाँ अभी तक completions के बारे में कुछ नहीं है। + +* `review_code` एक `language` लेता है। user को यह अनुमान नहीं लगाना चाहिए कि आप कौन-सी वर्तनियाँ स्वीकार करते हैं। +* `github_repo` एक `owner` और एक `repo` लेता है। दोनों के लिए free-text boxes रखना खराब form बनाता है। + +## Completion handler {#the-completion-handler} + +`@mcp.completion()` से सजाया हुआ **एक** function जोड़ें: + +```python title="server.py" hl_lines="21-29" +--8<-- "docs_src/completions/tutorial002.py" +``` + +* हर server में एक ही handler होता है। हर completion request यहीं आती है, और जो complete हो रहा है उसके हिसाब से आप branch करते हैं। +* इसे `async def` होना ज़रूरी है: SDK इसे await करता है। +* इसे तीन arguments मिलते हैं: + * `ref`: **कौन-सा** prompt या resource template, `PromptReference` या `ResourceTemplateReference` के रूप में। दोनों में फ़र्क `isinstance` से पता चलता है। + * `argument`: `argument.name` वह argument है जो complete हो रहा है, `argument.value` वह है जो user ने अब तक टाइप किया है। + * `context`: पहले से तय हो चुके arguments। अभी इसे नज़रअंदाज़ करें। +* आप `Completion(values=[...])` लौटाते हैं, या जब देने को कुछ न हो तो `None`। + +!!! tip + `argument.value` वह prefix है जो user ने टाइप किया है। SDK आपके लिए filter **नहीं** करता: जो कुछ + आप `values` में रखते हैं, UI वही दिखाता है। `startswith` आपको खुद लिखना है। + +### इसे आज़माएँ {#try-it} + +इसे **[Testing](../get-started/testing.md)** वाले in-memory `Client` से चलाएँ। +`client.complete()` को `ref=PromptReference(name="review_code")` और +`argument={"name": "language", "value": "py"}` के साथ call करें: + +```python +result.completion.values # ['python'] +``` + +* `ref` वही reference type है जो आपके handler को मिलता है। +* `argument` एक सादी dict है जिसमें ठीक दो keys हैं, `name` और `value`। + +खाली `value` भेजें और आपको पूरी सूची वापस मिलती है। `lang.startswith("")` हर भाषा के लिए true है: + +```python +result.completion.values # ['go', 'javascript', 'python', 'rust', 'typescript'] +``` + +`code` के बारे में पूछें (ऐसा argument जिसे handler नहीं पहचानता) और वह `None` लौटाता है, जिसे SDK खाली list में बदल देता है: + +```python +result.completion.values # [] +``` + +`None` का मतलब है **"कोई सुझाव नहीं"**, error कभी नहीं। UI सादे text box पर लौट आता है। + +## एक capability जो आपने कभी declare नहीं की {#a-capability-you-never-declared} + +handler register करना ही declaration है। कोई client जोड़ें और देखें: + +```python +client.server_capabilities.completions # CompletionsCapability() +``` + +आपने `completions` कहीं नहीं लिखी। SDK ने handler देखा और आपके लिए capability declare कर दी। हर **optional** capability ऐसे ही काम करती है: handler ही declaration है। (तीनों primitives optional नहीं हैं: `MCPServer` उन्हें हमेशा declare करता है, handlers हों या न हों।) + +!!! check + पहली `server.py` पर वापस जाएँ (जिसमें कोई handler नहीं है) और फिर भी उससे पूछें। call + JSON-RPC error के साथ fail होती है: + + ```text + Method not found + ``` + + और `client.server_capabilities.completions` `None` है। capability का यही मतलब है: + सही ढंग से बना client इसे जाँचता है और वह request कभी नहीं भेजता जिसका जवाब आप नहीं दे सकते। + +## एक-दूसरे पर निर्भर arguments {#dependent-arguments} + +`github://repos/{owner}/{repo}` में दो parameters हैं, और `repo` के काम के values इस पर निर्भर करते हैं कि पहले कौन-सा `owner` चुना गया। + +`context` इसी के लिए है। इसमें वे arguments होते हैं जो user **पहले ही तय कर चुका है**: + +```python title="server.py" hl_lines="8-11 34-38" +--8<-- "docs_src/completions/tutorial003.py" +``` + +* नई branch template के `repo` parameter के लिए चलती है। +* `context.arguments` अब तक चुने गए values (यहाँ, `owner`) की `dict[str, str] | None` है। +* अभी `owner` नहीं है तो कोई समझदार सुझाव भी नहीं, इसलिए handler `None` लौटाता है। + +client ये तय हो चुके values `context_arguments=` से भेजता है। इस बार `ref` है +`ResourceTemplateReference(uri="github://repos/{owner}/{repo}")`। खाली `value` के साथ +`repo` माँगें और `context_arguments={"owner": "modelcontextprotocol"}` pass करें: + +```python +result.completion.values # ['python-sdk', 'typescript-sdk', 'inspector'] +``` + +`context_arguments=` हटा दें और वही call `[]` लौटाती है। जब तक handler को owner पता न हो, वह नहीं जान सकता कि कौन-से repos सुझाए। + +!!! info + `Completion` `total=` और `has_more=` भी लेता है। इन्हें तब set करें जब `values` किसी लंबी सूची का + एक हिस्सा हो, ताकि UI **"और 200 बाकी"** दिखा सके। ज़्यादातर handlers को इनकी कभी ज़रूरत नहीं पड़ती। + +## सारांश {#recap} + +* Completions **prompt arguments** और **resource template parameters** के लिए सुझाव हैं। और कुछ नहीं। +* `@mcp.completion()` वह एक handler register करता है। यह `async def (ref, argument, context) -> Completion | None` है। +* `isinstance(ref, ...)` और `argument.name` पर branch करें। `argument.value` से filter खुद करें। +* `None` खाली list बन जाता है। यह कभी error नहीं है। +* `context.arguments` में पहले से तय values होती हैं; client उन्हें `context_arguments=` के रूप में देता है। +* `completions` capability उसी पल आ जाती है जब आप handler register करते हैं। उसके बिना, request का जवाब `Method not found` है। + +सुझाव तब काम आते हैं जब user अभी prompt या template **भर ही रहा हो**; किसी tool call के **बीच** में उससे सवाल पूछना हो तो आपको **[Elicitation](../handlers/elicitation.md)** चाहिए। text के अलावा tool जो कुछ लौटा सकता है वह सब **[Images, audio और icons](media.md)** में है। diff --git a/i18n/hi/pages/servers/handling-errors.md b/i18n/hi/pages/servers/handling-errors.md new file mode 100644 index 0000000000..defd15dd15 --- /dev/null +++ b/i18n/hi/pages/servers/handling-errors.md @@ -0,0 +1,139 @@ +--- +translation: + sections: [e33d441f12d50535, 7099694c603e0f5f, c1df4cf9673433e6, c9cd294541422e6e, 6cec073617bfd037, efa92b8f99e908c8, 6a22a29e27fb4601] + tool: 1 +--- +# errors संभालना {#handling-errors} + +tool दो तरीकों से fail हो सकता है, और SDK दोनों के साथ बहुत अलग बर्ताव करता है। + +साधारण exception raise करें तो उसे **model** देखता है। `MCPError` raise करें तो उसे **protocol** देखता है। + +यह page इन दोनों में से चुनने के बारे में है। + +## ऐसा error जिसे model ठीक कर सकता है {#an-error-the-model-can-fix} + +ऐसा tool लें जो कुछ खोजता है, और खोज को नाकाम होने दें: + +```python title="server.py" hl_lines="11-12" +--8<-- "docs_src/handling_errors/tutorial001.py" +``` + +उन दो lines में MCP जैसा कुछ नहीं है। `get_author` सादा `ValueError` raise करता है, जैसे कोई भी Python function करता। + +इसे ऐसे title से call करें जो catalog में नहीं है और result देखें: + +```python +result.is_error # True +result.content # [TextContent(text="Error executing tool get_author: No book titled 'Nothing' in the catalog.")] +result.structured_content # None +``` + +* request **सफल रही**। result मौजूद है; caller की तरफ़ कुछ raise नहीं हुआ। +* `is_error` `True` है, और आपके exception का message (आगे tool का नाम लगा हुआ) `content` में है, ठीक वहीं जहाँ model पढ़ता है। +* `structured_content` `None` है। fail हुए call के पास structure करने को कोई return value नहीं होती। + +यह **tool error** है, और आपका tool **कोई भी** exception raise करे, default यही है। और लगभग हमेशा आप यही चाहते भी हैं। + +आपके tool को call करने वाला model ही है। arguments उसी ने चुने। इसलिए tool error बातचीत का एक turn है: model *"No book titled 'Nothing' in the catalog."* पढ़ता है, समझ जाता है कि उसने title का गलत अंदाज़ा लगाया, और बेहतर title के साथ फिर call करता है। आपने एक `raise` लिखा और बदले में खुद को सुधारने वाला agent मिल गया। + +!!! tip + tool से कभी error message `return` न करें। लौटाई गई string का `is_error=False` होता है, इसलिए + model को (और हर client UI को) लगता है कि tool ठीक चला और वही string जवाब थी। + `raise` करें। flag ही संकेत है। + +## ऐसा error जिसे model ठीक नहीं कर सकता {#an-error-the-model-cannot-fix} + +अब `ValueError` की जगह `MCPError` रखें। + +```python title="server.py" hl_lines="1 3 14" +--8<-- "docs_src/handling_errors/tutorial002.py" +``` + +`MCPError` SDK का **protocol error** है। यही वह एक exception है जिसे tool wrapper catch **नहीं** करता: यह ऊपर propagate होता है, और पूरी `tools/call` request result के बजाय JSON-RPC error के साथ fail हो जाती है। + +```json +{ + "code": -32602, + "message": "No book titled 'Nothing' in the catalog." +} +``` + +* कोई **result नहीं** है। न `content`, न `is_error`: model के पढ़ने के लिए कुछ भी नहीं। +* इसके बजाय error **host** application को मिलता है, ठीक वैसे ही जैसे tool के बिल्कुल मौजूद न होने पर मिलता। +* `code`, `message`, और `data` जस के तस पहुँचते हैं। `INVALID_PARAMS` `-32602` है; `mcp.types` इसे और बाकी JSON-RPC error codes (`INVALID_REQUEST`, `INTERNAL_ERROR`, ...) को constants के रूप में export करता है, ताकि आपको कभी magic number न लिखना पड़े। + +!!! check + वही lookup, वही चूक, लेकिन अब call client की तरफ़ लौटने के बजाय **raise** होता है: + + ```text + mcp.shared.exceptions.MCPError: No book titled 'Nothing' in the catalog. + ``` + + पहले version ने model को एक वाक्य थमाया जिस पर वह कुछ कर सकता था। यह version उसे कुछ नहीं देता। + `get_author` के लिए यह साफ़ तौर पर बदतर है, और यही अगले section का मुद्दा है। + +## कौन सा raise करें {#which-one-to-raise} + +दोनों रास्ते दो अलग-अलग सवालों का जवाब देते हैं। + +* **कोई भी exception raise करें** जब नाकामी **execution** की हो: आपके tool ने जो करने की कोशिश की, वह नहीं हुआ। call model ने चुना था, इसलिए नतीजा भी model को दिखना चाहिए और उसे संभलने का मौका मिलना चाहिए। गलत वर्तनी वाला title, timeout हो गया upstream API, ऐसी row जो मौजूद नहीं: सब tool errors। +* **`MCPError` raise करें** जब **request खुद** ठुकराई जानी चाहिए: client के पास वह capability नहीं जिस पर आपका tool निर्भर है, server किसी को भी serve करने की हालत में नहीं है, caller ने कोई ज़रूरी चरण छोड़ दिया। model का कोई retry इनमें से किसी को ठीक नहीं करता, इसलिए उसे message थमाने से कुछ हासिल नहीं। + +एक सवाल से फ़ैसला हो जाता है: **क्या ज़्यादा समझदार model इससे बच सकता था?** हाँ -> साधारण exception। नहीं -> `MCPError`। + +इस कसौटी पर `get_author` के दूसरे version ने गलत चुनाव किया: बेहतर title से बात बन जाती है, इसलिए model message देखने का हक़दार था। वह version आपको mechanism दिखाने के लिए है, उसकी सिफ़ारिश करने के लिए नहीं। + +!!! info + `MCPError` `from mcp import MCPError` पर मिलता है और `code`, `message`, और एक optional + `data` payload लेता है। इनमें आप जो भी रखें, client को वही मिलता है: SDK raise किए गए + `MCPError` को sanitise करने के बजाय जस का तस आगे भेज देता है। + +## ऐसा resource जो मौजूद नहीं है {#a-resource-that-doesnt-exist} + +resources भी यही रेखा खींचते हैं, और आम मामले के लिए एक नाम वाला exception साथ देते हैं। + +```python title="server.py" hl_lines="2 13" +--8<-- "docs_src/handling_errors/tutorial003.py" +``` + +`books://{title}` एक **template** है। यह **किसी भी** title से match करता है, इसलिए "URI सही बना है" और "किताब मौजूद है" दो अलग सवाल हैं, और दूसरे का जवाब सिर्फ़ आपका function दे सकता है। + +जब वह न दे सके, `ResourceNotFoundError` raise करें। SDK इसे उस protocol error में बदल देता है जो spec ने गायब resource के लिए तय किया है: `-32602`, और `data` में माँगा गया URI, ताकि client को पता रहे कि **कौन सा** read fail हुआ। + +```json +{ + "code": -32602, + "message": "No book titled 'Nothing' in the catalog.", + "data": {"uri": "books://Nothing"} +} +``` + +ध्यान दें, यहाँ कोई `is_error=True` वाला आधा-अधूरा result नहीं है। resource read या तो contents लौटाता है या fail होता है: resources के पास सिर्फ़ protocol वाला रास्ता है। templates और resources के बारे में बाकी सब कुछ **[Resources](resources.md)** में है। + +## ऐसे errors जो आप कभी raise नहीं करते {#errors-you-never-raise} + +गलत argument आपके function तक कभी पहुँचता ही नहीं। + +`get_author` को ऐसा `title` भेजें जो string नहीं है, और SDK आपको call करने से **पहले** ही उसे input schema के आधार पर ठुकरा देता है, उसी तरह के `is_error=True` tool error के रूप में जिसे model पढ़ और सुधार सकता है। **[Tools](tools.md)** यही अस्वीकृति `Field(le=50)` constraint के साथ दिखाता है। + +इसका मतलब है `raise` statements की एक पूरी श्रेणी जो आपको लिखनी नहीं पड़ती: अपने ही type hints को दोबारा validate न करें। + +!!! info + इस page पर सब कुछ वही है जो **client** को दिखता है, और जिस in-memory `Client` से आप + tests लिखेंगे, उसे भी ठीक यही दिखता है। `raise_exceptions=True` भी tool error को वापस + traceback में नहीं बदलता: जब तक वह flag कुछ कर पाता, आपका exception पहले ही + `is_error=True` result बन चुका होता है। result पर assert करें। **[Testing](../get-started/testing.md)** में यह pattern बताया गया है। + +## सारांश {#recap} + +* tool में **कोई भी exception** raise करें -> call `is_error=True` लौटाता है, `content` में आपके message के साथ। model उसे पढ़ता है और retry कर सकता है। यही default है। +* **`MCPError`** raise करें -> call खुद JSON-RPC error के साथ fail हो जाता है। model को कुछ नहीं दिखता; host इससे निपटता है। `code`, `message`, और `data` जस के तस बचे रहते हैं। +* फ़ैसला करने वाला सवाल: **क्या ज़्यादा समझदार model इससे बच सकता था?** हाँ -> exception। नहीं -> `MCPError`। +* resource handler से `ResourceNotFoundError` -> protocol का `-32602`, `data` में URI के साथ। +* गलत arguments आपका function चलने से पहले ही schema के आधार पर ठुकरा दिए जाते हैं; उनके लिए आप `raise` नहीं करते। +* `from mcp import MCPError`; error-code constants `mcp.types` से आते हैं। + +errors संभल गए। server जो कुछ **expose** करता है, वह सब यही है। हर handler क्या पढ़ सकता है, और चलते-चलते client के साथ वापस क्या कर सकता है, यह अगला section है: **[आपके handler के अंदर](../handlers/index.md)**। + +जिन SDK errors से आपका सामना होने की सबसे ज़्यादा संभावना है, उनका हूबहू text, हर एक का मतलब, और हर एक का एक-कदम वाला हल **[Troubleshooting](../troubleshooting.md)** में है। diff --git a/i18n/hi/pages/servers/index.md b/i18n/hi/pages/servers/index.md new file mode 100644 index 0000000000..4051a71659 --- /dev/null +++ b/i18n/hi/pages/servers/index.md @@ -0,0 +1,35 @@ +--- +translation: + sections: [09defc170a0da89d] + tool: 1 +--- +# Servers {#servers} + +`MCPServer` जुड़े हुए client को तीन primitives देता है। इनमें फ़र्क इस बात का है कि +इन्हें इस्तेमाल करने का फ़ैसला कौन करता है: + +* **[tool](tools.md)** वह action है जिसे **model** चुनता और call करता है। यही + वह page है जो ज़्यादातर लोग सबसे पहले चाहते हैं, और + **[Structured Output](structured-output.md)** इसका reference साथी है: + tool जो लौटाता है उसके आकार से जुड़ी हर बात वहाँ है। +* **[resource](resources.md)** read-only data है जिसे **application** + पढ़ना चुनता है। **[URI templates](uri-templates.md)** इसका reference + साथी है: addressing का पूरा syntax और path-safety के नियम। +* **[prompt](prompts.md)** एक message template है जिसे कोई **इंसान** नाम से + invoke करता है, menu से या slash command से। + +इन तीन primitives के इर्द-गिर्द वह सब है जो server और declare करता है: + +* **[Completions](completions.md)** prompt और resource-template के arguments + के लिए server-side autocomplete है। +* **[Images, audio & icons](media.md)** में वह सब है जो tool text के अलावा + लौटा सकता है, और वे icons जो client आपके server के बगल में दिखाता है। +* **[Handling errors](handling-errors.md)** समझाता है कि जिस error से model + उबर सकता है और जिसे model को कभी नहीं देखना चाहिए, उन दोनों में क्या फ़र्क है। + +यहाँ का हर page अपने आप में पूरा है; सीधे उसी पर जाएँ जिसकी ज़रूरत है। अगर अभी तक +कोई server नहीं बनाया है, तो इसके बजाय **[पहले कदम](../get-started/first-steps.md)** से शुरू करें। + +जो functions आप register करते हैं उनके **अंदर** क्या होता है (`Context`, dependency injection, +call के बीच में user से और input माँगना), वह अगला section है, +**[आपके handler के अंदर](../handlers/index.md)**। diff --git a/i18n/hi/pages/servers/media.md b/i18n/hi/pages/servers/media.md new file mode 100644 index 0000000000..7f2373379b --- /dev/null +++ b/i18n/hi/pages/servers/media.md @@ -0,0 +1,122 @@ +--- +translation: + sections: [496394d24d221bf1, 4ceb4591180dc6c3, 0fd63e4682d02e0c, 969ede0bd3686a16, 043f526230dd243d, 6ee3e9bcfd24047a] + tool: 1 +--- +# Media {#media} + +tool सिर्फ़ text ही लौटा सके, ऐसा नहीं है। + +SDK में binary results के लिए दो helpers (**`Image`** और **`Audio`**) हैं, और एक **`Icon`** type है जो client के UI में आपके server, tools, resources और prompts को एक चेहरा देता है। + +## image लौटाना {#returning-an-image} + +return type को `Image` से annotate करें, उसे किसी file की ओर point करें, और लौटा दें: + +```python title="server.py" hl_lines="8 12 14" +--8<-- "docs_src/media/tutorial001.py" +``` + +* `Image` इन दोनों में से ठीक एक लेता है: `path` (पढ़ने के लिए file) या `data` (raw bytes)। +* client को जो MIME type दिखता है, उसका अंदाज़ा suffix से लगाया जाता है: `logo.png` को `image/png` बताया जाता है। +* यहाँ logos में कुछ खास नहीं है। `server.py` के बगल में रखी कोई भी PNG चलेगी: आपके code का render किया हुआ chart, कोई diagram, कोई photo। + +`Image` SDK की सुविधा है, protocol type नहीं। wire पर आपकी return value एक **`ImageContent`** block बन जाती है (file के bytes base64-encoded, साथ में MIME type): + +```python +result.content # [ImageContent(type="image", data="iVBORw0KGgoAAAANSUhEUg...", mime_type="image/png")] +result.structured_content # None +``` + +दो बातें ध्यान देने लायक हैं: + +* `data` base64 है। आपने bytes को छुआ तक नहीं; SDK ने file पढ़ी और encoding की। +* `structured_content` `None` है। `Image` model के देखने के लिए content है, application के parse करने के लिए data नहीं: कोई output schema नहीं है। (इसकी तुलना **[Structured output](structured-output.md)** से करें, जहाँ return annotation **ही** schema है।) + +!!! info + `ImageContent` और `AudioContent` `mcp.types` में रहते हैं, ठीक उस `TextContent` के बगल में + जो एक सादा `str` result बन जाता है (**[Tools](tools.md)**)। tool result content blocks की list होता है; दो binary + किस्मों को बनाने का सबसे छोटा रास्ता `Image` और `Audio` हैं। + +### इसे आज़माएँ {#try-it} + +कोई भी PNG `server.py` के बगल में रखें, उसका नाम `logo.png` रखें, और चलाएँ: + +```console +uv run mcp dev server.py +``` + +**Tools** tab खोलें और `logo` को call करें। result कोई string नहीं है: यह `image` content block है, और Inspector आपकी तस्वीर render करता है। disk पर रखी file से लेकर screen पर दिखते pixels तक, बीच का सारा काम SDK ने किया। + +## audio लौटाना {#returning-audio} + +`Audio` का आकार भी वही है। `logo.png` को जहाँ था वहीं रहने दें, और कोई भी WAV उसके बगल में `chime.wav` नाम से रख दें: + +```python title="server.py" hl_lines="18-21" +--8<-- "docs_src/media/tutorial002.py" +``` + +result एक **`AudioContent`** block है: + +```python +result.content # [AudioContent(type="audio", data="UklGR...", mime_type="audio/wav")] +result.structured_content # None +``` + +वही बात: अंदर disk पर रखी file जाती है, बाहर base64 और MIME type आते हैं, कोई output schema नहीं। + +## bytes या file {#bytes-or-a-file} + +दोनों helpers `path=` की जगह `data=` (raw bytes) भी लेते हैं। यह उन bytes के लिए है जो कभी अपनी किसी file से आए ही नहीं — कोई database column, कोई HTTP response, कुछ जो Pillow ने अभी-अभी बनाया: + +```python title="server.py" hl_lines="14 15" +--8<-- "docs_src/media/tutorial003.py" +``` + +`path=` के साथ कुछ declare करने की ज़रूरत नहीं: result बनते समय file पढ़ी जाती है, और MIME type का अंदाज़ा suffix से लगाया जाता है: + +* `Image`: `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`. +* `Audio`: `.wav`, `.mp3`, `.ogg`, `.flac`, `.aac`, `.m4a`. + +जिस suffix को यह नहीं पहचानता, वह `application/octet-stream` पर लौट आता है। + +!!! check + `data=` के साथ कोई filename नहीं होता, इसलिए अंदाज़ा लगाने के लिए कुछ नहीं है। `format=` भूल जाएँ तो + SDK default पर आ जाता है: images के लिए `image/png`, audio के लिए `audio/wav`। इस तरह + MP3 bytes से `Audio` बनाएँ तो client को `mime_type="audio/wav"` बताया जाता है, और फिर + वह ईमानदारी से उसे decode करने में नाकाम रहता है। जब `data=` दें, तो `format=` भी दें। + +## Icons {#icons} + +`Icon` metadata है, content नहीं। इसमें image नहीं होती; यह URI से किसी image की ओर इशारा करता है, और client उसे fetch करके आपके server के नाम, किसी tool, resource या prompt के बगल में दिखा सकता है। + +```python title="server.py" hl_lines="4-5 7 10 16" +--8<-- "docs_src/media/tutorial004.py" +``` + +* `src` ऐसा URI है जिसे client resolve कर सके: `https:`, या `data:` URI अगर आप icon को बिना किसी अतिरिक्त fetch के embed करना चाहें। +* `mime_type` और `sizes` (`"48x48"`, या scalable format के लिए `"any"`) से client सही icon चुन पाता है जब आप कई icons दें। +* `theme="light"` या `theme="dark"` किसी icon को एक colour scheme के लिए चिह्नित करता है। + +यही `icons=[...]` keyword `MCPServer(...)`, `@mcp.tool()`, `@mcp.resource()` और `@mcp.prompt()` सब लेते हैं। + +### client इन्हें कहाँ देखता है {#where-a-client-sees-them} + +icons उसी चीज़ के साथ चलते हैं जिसे वे सजाते हैं। server के icons client के connect होने पर `client.server_info` पर आते हैं (2026 पीढ़ी के connections पर यह optional है, इसलिए पहले इसे narrow करें): + +```python +assert client.server_info is not None # python-sdk servers identify themselves by default +client.server_info.icons # [Icon(src="https://example.com/brand-kit.png", mime_type="image/png", sizes=["48x48"])] +``` + +tool के icons `tools/list` से मिले `Tool` object पर होते हैं, resource के `resources/list` से मिले `Resource` पर, और prompt के `prompts/list` से मिले `Prompt` पर। field का नाम हमेशा `icons` होता है। + +## सारांश {#recap} + +* tool से `Image` या `Audio` लौटाएँ तो client को `ImageContent` / `AudioContent` block मिलता है: आपके bytes base64-encoded, MIME type के साथ। +* इसे `path=` से बनाएँ और suffix को MIME type तय करने दें, या in-memory `data=` और स्पष्ट `format=` से बनाएँ। +* media results में न `structured_content` होता है, न output schema। +* `Icon` एक pointer है: `src` URI और साथ में optional `mime_type`, `sizes` और `theme`। +* `icons=[...]` server पर, tools पर, resources पर और prompts पर काम करता है, और clients इन्हें संबंधित objects पर पाते हैं। + +tool किसी result **में** जो कुछ डाल सकता है, वह सब यही है। जब tool **नाकाम** होता है तब क्या होता है (और किसे पता चलना चाहिए), यह **[errors संभालना](handling-errors.md)** में है। diff --git a/i18n/hi/pages/servers/prompts.md b/i18n/hi/pages/servers/prompts.md new file mode 100644 index 0000000000..c270e30175 --- /dev/null +++ b/i18n/hi/pages/servers/prompts.md @@ -0,0 +1,155 @@ +--- +translation: + sections: [d65c098f37f5b6c3, dd0c2724d6f2877e, 6835bb3570c6714c, ffe823cb0fedd488, f33651add1b59094] + tool: 1 +--- +# Prompts {#prompts} + +**Prompt** एक message template है जिसे user चुनता है। + +Tools model के लिए होते हैं। Prompt इसका उल्टा है: user अपने client के menu (slash command, button) से कोई prompt चुनता है, उसके arguments भरता है, और render हुए messages बातचीत में ऐसे जुड़ जाते हैं मानो user ने खुद type किए हों। + +Prompt declare करने के लिए text लौटाने वाले function पर `@mcp.prompt()` लगाएँ। + +## आपका पहला prompt {#your-first-prompt} + +```python title="server.py" hl_lines="6-9" +--8<-- "docs_src/prompts/tutorial001.py" +``` + +SDK वही तीन चीज़ें पढ़ता है जो वह tool से पढ़ता है: + +* **Name** function का नाम है: `review_code`। +* Client जो **description** दिखाता है, वह docstring है: `Review a piece of code.` +* **Arguments** parameters से आते हैं। `code` का कोई default नहीं है, इसलिए वह required है। + +`prompts/list` से client को यही वापस मिलता है: + +```json +{ + "name": "review_code", + "description": "Review a piece of code.", + "arguments": [ + {"name": "code", "required": true} + ] +} +``` + +यहाँ कोई JSON Schema नहीं है। Prompt arguments **named string values** की एक flat list हैं: ऐसा form जिसे इंसान भरता है, ऐसा payload नहीं जिसे model बनाता है। + +### इसे render करना {#rendering-it} + +Client arguments pass करते हुए `prompts/get` से template render करता है। आपका function चलता है और जो `str` आप लौटाते हैं, वह **एक user message** बन जाता है: + +```json +{ + "description": "Review a piece of code.", + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "Please review this code:\n\ndef add(a, b): return a + b" + } + } + ], + "resultType": "complete" +} +``` + +Prompt का पूरा जीवन बस इतना ही है: नाम से list होना, माँगे जाने पर render होना, chat में डाल दिया जाना। + +!!! check + `required` आपके function के चलने से पहले ही enforce होता है। `review_code` को `code` के बिना render करें और + request खुद JSON-RPC error (code `-32603`) के साथ fail हो जाती है: + + ```text + mcp.shared.exceptions.MCPError: Internal server error + ``` + + Model को लौटाने के लिए tool जैसा कोई error result नहीं है, क्योंकि यहाँ कोई model शामिल ही नहीं है: + call raise करता है। वजह (`Missing required arguments: {'code'}`) आपके server के log में जाती है। + +### इसे आज़माएँ {#try-it} + +Server को MCP Inspector के साथ चलाएँ: + +```console +uv run mcp dev server.py +``` + +**Prompts** tab खोलें और `review_code` चुनें। Inspector एक required `code` field वाला form बनाता है। इसे भरें, render करें, और आपको ठीक ऊपर वाला user message वापस मिलता है। + +## एक से ज़्यादा messages {#more-than-one-message} + +Code review एक message है। Debugging session एक बातचीत है, और prompt पूरी बातचीत की शुरुआत कर सकता है। + +`str` की जगह messages की list लौटाएँ: + +```python title="server.py" hl_lines="2 13-20" +--8<-- "docs_src/prompts/tutorial002.py" +``` + +* `UserMessage` और `AssistantMessage`, `mcp.server.mcpserver.prompts.base` से आते हैं। इन्हें `str` दें और ये उसे आपके लिए `TextContent` में wrap कर देते हैं। Role class का नाम है। +* `Message` इनका साझा base है। इसे return annotation के रूप में इस्तेमाल करें। + +`debug_error` को render करने पर अब तीन messages इसी क्रम में बनते हैं: + +```json +{ + "description": "Start a debugging conversation.", + "messages": [ + {"role": "user", "content": {"type": "text", "text": "I'm seeing this error:"}}, + {"role": "user", "content": {"type": "text", "text": "TypeError: 'int' object is not iterable"}}, + { + "role": "assistant", + "content": {"type": "text", "text": "I'll help debug that. What have you tried so far?"} + } + ], + "resultType": "complete" +} +``` + +आख़िरी message पर ध्यान दें। `assistant` turn पहले से भरना ही वह तरीका है जिससे आप model के **अगले** जवाब की दिशा तय करते हैं, बिना user से वह निर्देश खुद type करवाए। + +## Titles और argument descriptions {#titles-and-argument-descriptions} + +`review_code` function का नाम है, label नहीं। Client को button पर लगाने के लिए कुछ बेहतर दें, और हर argument का description लिखें ताकि form खुद ही समझ में आ जाए: + +```python title="server.py" hl_lines="10-13" +--8<-- "docs_src/prompts/tutorial003.py" +``` + +* `title="Code review"` इंसानों के पढ़ने लायक नाम है, ठीक tool के `title` की तरह। +* `Annotated[str, Field(description=...)]` वही pattern है जो **[Tools](tools.md)** tool के parameters describe करने के लिए इस्तेमाल करता है। यहाँ description schema में जाने के बजाय argument पर लगता है। +* `language` का default है, इसलिए वह अब required नहीं रहता। + +`prompts/list` entry में अब वह सब है जो client को अच्छा form बनाने के लिए चाहिए: + +```json +{ + "name": "review_code", + "title": "Code review", + "description": "Review a piece of code.", + "arguments": [ + {"name": "code", "description": "The code to review.", "required": true}, + {"name": "language", "description": "The language the code is written in.", "required": false} + ] +} +``` + +!!! info + अगर आपने **[Tools](tools.md)** पढ़ लिया है, तो इस page की हर बात आप पहले से जानते हैं। वही decorator, वही + docstring-as-description, वही `Annotated`/`Field`। बदलता सिर्फ़ इतना है कि इसे + trigger कौन करता है (user) और result कहाँ जाता है (बातचीत में)। + +## सारांश {#recap} + +* Function पर `@mcp.prompt()` लगाने से वह prompt बन जाता है। नाम function से, description docstring से। +* Prompts **user-controlled** हैं: client इन्हें list करता है, user कोई एक चुनता है और arguments भरता है। +* Arguments named strings की flat list हैं (कोई schema नहीं)। Default वाला parameter optional है। +* `str` लौटाएँ और वह एक user message बन जाता है। Multi-turn बातचीत की शुरुआत करने के लिए `UserMessage` / `AssistantMessage` की list लौटाएँ। +* `title=` और `Field(description=...)` वही हैं जो client अपने UI में दिखाता है। +* कोई required argument छूट जाए तो पूरी request fail होती है। हर prompt का अलग error result नहीं होता। + +Prompt के (या resource template के) arguments के लिए server-side autocomplete **[Completions](completions.md)** में है। diff --git a/i18n/hi/pages/servers/resources.md b/i18n/hi/pages/servers/resources.md new file mode 100644 index 0000000000..7638162f95 --- /dev/null +++ b/i18n/hi/pages/servers/resources.md @@ -0,0 +1,146 @@ +--- +translation: + sections: [09df998c2a799f78, 0cf131146d16d4f9, 4e6b91e3f8025346, 8fe4eef576db17ed, 0d0d1ed43e3d0a53] + tool: 1 +--- +# Resources {#resources} + +**resource** वह data है जिसे आप application के पढ़ने के लिए expose करते हैं। + +फ़र्क बस यही है। tool वह है जिसे call करने का फ़ैसला **model** करता है। resource वह है जिसे load करने का फ़ैसला **application** करता है (कोई config file, कोई record, कोई document) और फिर model के सामने context के रूप में रखता है। + +किसी सादे Python function पर `@mcp.resource(uri)` लगाकर आप resource declare करते हैं। + +## आपका पहला resource {#your-first-resource} + +```python title="server.py" hl_lines="6-8" +--8<-- "docs_src/resources/tutorial001.py" +``` + +इसका आकार बिल्कुल tool जैसा है, बस एक चीज़ और है: **URI**। resources के पते होते हैं, नाम नहीं। client `config://app` माँगता है, `get_config` कभी नहीं। + +बाकी सब SDK अब भी function से ही पढ़ता है: + +* **नाम** function का नाम है: `get_config`। +* client को दिखने वाला **description** docstring है। +* **content** वही है जो आप लौटाते हैं। + +`resources/list` के दौरान client को यह मिलता है: + +```json +{ + "name": "get_config", + "uri": "config://app", + "description": "The active shop configuration.", + "mimeType": "text/plain" +} +``` + +और जब client `config://app` पढ़ता है, तो आपका function चलता है और return value text के रूप में वापस आती है: + +```python +result.contents # [TextResourceContents(uri="config://app", mime_type="text/plain", text="theme=dark\nlanguage=en")] +``` + +!!! tip + listing सस्ती है। आपका function `resources/list` के दौरान call **नहीं** होता, सिर्फ़ + `resources/read` के दौरान होता है, और वह भी सिर्फ़ उसी URI के लिए जो माँगा गया हो। हज़ार resources + expose करें, कीमत सिर्फ़ उन्हीं की चुकानी पड़ती है जिन्हें कोई खोलता है। + +### इसे आज़माएँ {#try-it} + +server को MCP Inspector के साथ चलाएँ: + +```console +uv run mcp dev server.py +``` + +यह जो URL print करता है उसे खोलें और **Resources** tab पर जाएँ। `config://app` अपने description के साथ सूची में है। उस पर click करें और Inspector उसे पढ़ लेता है: config की आपकी दोनों lines सामने हैं। + +## Resource templates {#resource-templates} + +हर record के लिए एक अलग URI बड़े पैमाने पर नहीं चलता। URI में एक **placeholder** रखें और function पर उससे मेल खाता parameter: + +```python title="server.py" hl_lines="12-13" +--8<-- "docs_src/resources/tutorial002.py" +``` + +URI में `{user_id}`, function पर `user_id: str`। पूरा contract बस इतना ही है। + +अब यह **resource template** है, और इसका ठिकाना बदल जाता है: यह `resources/list` छोड़ देता है और उसकी जगह `resources/templates/list` में दिखता है, पते के बजाय pattern के रूप में: + +```json +{ + "name": "get_user_profile", + "uriTemplate": "users://{user_id}/profile", + "description": "A customer's profile.", + "mimeType": "text/plain" +} +``` + +client placeholder भरता है और एक ठोस URI पढ़ता है: `users://42/profile`, `users://ada/profile`। एक ही function इन सबका जवाब देता है, और match हुई value `user_id` के रूप में pass की जाती है: + +```python +result.contents # [TextResourceContents(uri="users://42/profile", text="User 42: 12 orders since 2021.")] +``` + +result में `uri` पर ध्यान दें। यह वही **ठोस** URI है जो client ने माँगा था, template नहीं। + +!!! check + placeholders और parameters का मेल खाना ज़रूरी है। function parameter का नाम बदलकर + `user` कर दें जबकि URI में अब भी `{user_id}` लिखा हो, तो decorator **import time पर ही** मना कर देता है, + किसी client के उसके पास पहुँचने से पहले: + + ```text + ValueError: Mismatch between URI parameters {'user_id'} and function parameters {'user'} + ``` + + ऐसा mismatch सिर्फ़ bug ही हो सकता है, इसलिए SDK mismatch के साथ server शुरू होने ही नहीं देता। + +placeholder syntax [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570) है: कई segments वाली values के लिए `{+path}`, वैकल्पिक query parameters के लिए `{?q,lang}`, और भी बहुत कुछ। SDK निकाली गई values पर default रूप से path-safety जाँच भी लागू करता है। पूरा reference **[URI templates और path safety](uri-templates.md)** में देखें। + +`get_user_profile` `Context` से annotate किया गया parameter भी ले सकता है। SDK उसे inject करता है और उसे कभी URI parameter नहीं मानता, और वह आपको क्या देता है यह **[Context](../handlers/context.md)** page बताता है। + +## आप क्या लौटाते हैं {#what-you-return} + +आप `str` तक सीमित नहीं हैं। हर resource को `mime_type` दें और जो सही बैठे वह लौटाएँ: + +```python title="server.py" hl_lines="8-9 14-15 20-21" +--8<-- "docs_src/resources/tutorial003.py" +``` + +* `readme` `str` लौटाता है, इसलिए वह जस का तस भेजा जाता है। यही आम मामला है। +* `catalog_stats` `dict` लौटाता है, इसलिए SDK उसे आपके लिए **JSON text** में serialise कर देता है: + + ```json + { + "books": 1204, + "authors": 391 + } + ``` + +* `placeholder_cover` `bytes` लौटाता है, इसलिए client को `TextResourceContents` की जगह `BlobResourceContents` मिलता है, जिसके `blob` field में आपके bytes base64-encoded होते हैं। + +यही नियम हर उस चीज़ पर लागू होता है जो JSON-serialisable है: list, Pydantic model, dataclass। अगर वह `str` नहीं है और `bytes` नहीं है, तो वह JSON बन जाता है। + +`mime_type` declare करना आपका काम है, और इसका default `text/plain` है। इसका अंदाज़ा लगाने के लिए SDK कभी यह नहीं जाँचता कि आप क्या लौटाते हैं, इसलिए जिस `dict` resource पर आप label नहीं लगाते वह अब भी plain text के रूप में ही advertise होता है। + +!!! tip + जब आप इन्हें function से derive नहीं करना चाहते, तब `@mcp.resource()` `name=`, `title=` और `description=` भी + स्वीकार करता है। और जब लिखने को कोई function ही न हो, तब + `mcp.server.mcpserver.resources` में तैयार `Resource` classes हैं (`TextResource`, + `BinaryResource`, `FileResource`, `HttpResource`, `DirectoryResource`) जिन्हें आप + `mcp.add_resource(...)` से register करते हैं। + +client किसी resource को **subscribe** भी कर सकता है और उसके बदलने पर notification पा सकता है; यह कहानी का client वाला हिस्सा है और **[Client](../client/index.md)** में है। + +## सारांश {#recap} + +* function पर `@mcp.resource(uri)` उसे resource बना देता है। URI पता है, return value content है, docstring description है। +* URI में `{placeholder}` उसे **template** बना देता है: यह `resources/templates/list` के तहत list होता है और एक ही function हर मेल खाते URI को serve करता है। +* placeholder के नाम function के parameter नामों के बराबर होने चाहिए। गलती करें तो पता import time पर चलता है, production में नहीं। +* आपका function तब चलता है जब resource **पढ़ा** जाता है, तब नहीं जब उसे list किया जाता है। +* `str` text बनता है, `bytes` base64 blob बनता है, बाकी सब JSON text बनता है। label आप `mime_type=` से लगाते हैं। +* tools model के काम करने के लिए हैं। resources application के पढ़ने के लिए हैं। + +तीसरा primitive, जिसे कोई इंसान menu से चुनता है, **[Prompts](prompts.md)** है। diff --git a/i18n/hi/pages/servers/structured-output.md b/i18n/hi/pages/servers/structured-output.md new file mode 100644 index 0000000000..d760a9ce30 --- /dev/null +++ b/i18n/hi/pages/servers/structured-output.md @@ -0,0 +1,250 @@ +--- +translation: + sections: [a838d57f003aed44, 857d03886a0137ed, 42d9efcb9f542867, 2290ff08435b5573, e866c192e11d1c14, 6cdbad079f7b47f0, d4b607372fb28b51, 18dbf726ac45e0b7, c6f7d2a148aa49f4, c851964bb3301907, d715db6f8dccc9cc, ef86634aa70498a7] + tool: 1 +--- +# Structured output {#structured-output} + +जो tool सादा `str` लौटाता है, वह result दो बार देता है: `content` में text के रूप में, और `structured_content` में `{"result": "..."}` के रूप में। + +यह page उसी दूसरे channel के बारे में है: यह कहाँ से आता है, यह किन-किन रूपों में हो सकता है, और SDK इसे भरोसेमंद कैसे रखता है। + +संक्षेप में: **return type annotation ही output schema है**। वह आप पहले ही लिख चुके हैं। + +## Output schema {#the-output-schema} + +```python title="server.py" hl_lines="9" +--8<-- "docs_src/structured_output/tutorial001.py" +``` + +जो line मायने रखती है वह signature है: `-> int`। + +इसी की वजह से `tools/list` के दौरान SDK जो tool भेजता है, उसमें input schema के साथ-साथ `output_schema` भी होता है। input schema आपके parameters से बनता है (उसकी जानकारी **[Tools](tools.md)** में है): + +```json +{ + "properties": { + "result": {"title": "Result", "type": "integer"} + }, + "required": ["result"], + "title": "get_temperatureOutput", + "type": "object" +} +``` + +अकेला `int` JSON object नहीं है, इसलिए SDK उसे `{"result": ...}` में **wrap** कर देता है। tool को call करें और दोनों channel भर जाते हैं: + +```python +result.content # [TextContent(text="17")] +result.structured_content # {"result": 17} +``` + +हर scalar को यही wrapper मिलता है: `str`, `int`, `float`, `bool`, `bytes`, `None`। + +## दो channel {#two-channels} + +एक ही value दो बार क्यों भेजें? + +* `content` **model** के लिए है। language model text पढ़ता है; result का यही हिस्सा उसे दिखता है। +* `structured_content` उस **application** के लिए है जिसके अंदर model चलता है: वह code जिसे `17` चाहिए, न कि ऐसा वाक्य जिसमें "17" आता हो। +* `output_schema` इन दोनों के बीच का करार है, जो tool के पहली बार call होने से पहले ही publish हो जाता है। + +आप एक Python value लौटाते हैं। SDK तीनों भर देता है। + +## Model लौटाना {#return-a-model} + +आकार को Pydantic `BaseModel` के रूप में declare करें और उसका instance लौटाएँ: + +```python title="server.py" hl_lines="8-11 15" +--8<-- "docs_src/structured_output/tutorial002.py" +``` + +अब `WeatherData` ही schema **है**। न कोई wrapper, न `result` key: + +```json +{ + "properties": { + "temperature": {"description": "Degrees Celsius.", "title": "Temperature", "type": "number"}, + "humidity": {"description": "Relative humidity, 0 to 1.", "title": "Humidity", "type": "number"}, + "conditions": {"title": "Conditions", "type": "string"} + }, + "required": ["temperature", "humidity", "conditions"], + "title": "WeatherData", + "type": "object" +} +``` + +`structured_content` वही object है, field दर field: + +```python +result.structured_content # {"temperature": 16.2, "humidity": 0.83, "conditions": "Overcast"} +``` + +और model को भी नहीं भूला गया। SDK उसी object को `content` के लिए JSON text में serialize करता है: + +```json +{ + "temperature": 16.2, + "humidity": 0.83, + "conditions": "Overcast" +} +``` + +ध्यान दें, `temperature` और `humidity` पर लगा `Field(description=...)` schema में पहुँच गया। जो `Field` आपके **inputs** का वर्णन करता था, वही आपके outputs का भी वर्णन करता है। + +!!! info + अगर आपने FastAPI का `response_model` इस्तेमाल किया है तो यह आपको पहले से पता है: declared + response के रूप में Pydantic model, जो आपके लिए serialize और document हो जाता है। फ़र्क सिर्फ़ इतना है कि यहाँ return annotation + ही पूरा declaration है। + +## `TypedDict` {#a-typeddict} + +हर आकार के लिए class बनाना ज़रूरी नहीं। `TypedDict` से भी वही schema बनता है: + +```python title="server.py" hl_lines="8" +--8<-- "docs_src/structured_output/tutorial003.py" +``` + +runtime पर `TypedDict` सादा `dict` होता है, इसलिए आप वही बनाते और लौटाते हैं। schema, validation और `structured_content` ठीक `BaseModel` वाले version जैसे हैं (descriptions को छोड़कर, जिनके लिए `TypedDict` में कोई जगह नहीं)। + +## Dataclass {#a-dataclass} + +dataclasses भी काम करते हैं, और हर वह साधारण class भी जिसके attributes पर type hints हों। SDK अंदर ही अंदर annotations से Pydantic model बना लेता है। + +```python title="server.py" hl_lines="8-9" +--8<-- "docs_src/structured_output/tutorial004.py" +``` + +तीन लिखावटें, एक schema। जो आपके codebase में पहले से है, वही इस्तेमाल करें। + +## Lists {#lists} + +`list[...]` भी JSON object नहीं है, इसलिए इसे भी `{"result": ...}` wrapper मिलता है, और आपका item type उसके अंदर `$defs` reference के रूप में आता है: + +```python title="server.py" hl_lines="15" +--8<-- "docs_src/structured_output/tutorial005.py" +``` + +```json +{ + "$defs": { + "WeatherData": { + "properties": { + "temperature": {"title": "Temperature", "type": "number"}, + "humidity": {"title": "Humidity", "type": "number"}, + "conditions": {"title": "Conditions", "type": "string"} + }, + "required": ["temperature", "humidity", "conditions"], + "title": "WeatherData", + "type": "object" + } + }, + "properties": { + "result": {"items": {"$ref": "#/$defs/WeatherData"}, "title": "Result", "type": "array"} + }, + "required": ["result"], + "title": "get_forecastOutput", + "type": "object" +} +``` + +दो दिन का forecast माँगें और `structured_content` होगा `{"result": [{...}, {...}]}`। `content` **दो** `TextContent` blocks बन जाता है, हर item के लिए एक: model के लिए list को एक string में dump करने के बजाय सपाट कर दिया जाता है। + +`tuple[...]`, unions और `Optional[...]` भी इसी तरह wrap होते हैं। + +## Dictionaries {#dictionaries} + +`dict[str, ...]` वह इकलौता generic है जो पहले से ही JSON object **है**, इसलिए यह wrap नहीं होता: + +```python title="server.py" hl_lines="9" +--8<-- "docs_src/structured_output/tutorial006.py" +``` + +```json +{ + "additionalProperties": {"type": "number"}, + "title": "get_temperaturesDictOutput", + "type": "object" +} +``` + +```python +result.structured_content # {"London": 16.2, "Reykjavik": 4.4} +``` + +keys का `str` होना ज़रूरी है। `dict[int, float]` JSON object नहीं बन सकता, इसलिए यह वापस `{"result": ...}` wrapper पर आ जाता है। + +## Validation {#validation} + +`output_schema` documentation नहीं है। आपका function जो भी लौटाता है, server से बाहर जाने से पहले उसे **इसके मुक़ाबले validate** किया जाता है। + +जब तक आप value हाथ से बनाते हैं, इसका पता नहीं चलता: Pydantic पहले ही पक्का कर चुका होता है कि आपका `WeatherData` सच में `WeatherData` है। पता उस दिन चलता है जब data ऐसी जगह से आता है जो आपके हाथ में नहीं: + +```python title="server.py" hl_lines="9 21" +--8<-- "docs_src/structured_output/tutorial007.py" +``` + +annotation `WeatherData` का वादा करता है। upstream response ने `humidity` भेजना बंद कर दिया। + +!!! check + `get_weather` को call करें और यह चुपचाप client को आधा-खाली object नहीं थमाता। call fail होता है, + और error की पहली lines field का नाम बताती हैं: + + ```text + Error executing tool get_weather: 1 validation error for WeatherData + humidity + Field required [type=missing, input_value={'temperature': 16.2, 'conditions': 'Overcast'}, input_type=dict] + ``` + + यह text `is_error=True` के साथ tool result बनकर लौटता है, ताकि model को पता रहे कि call fail हुआ है, + बजाय इसके कि वह पूरे भरोसे से ऐसा मौसम पढ़े जो है ही नहीं। + +वैसे, `-> WeatherData` वाले tool से सादा `dict` लौटाना ठीक है। `json.loads` ने ठीक वही तो बनाया था। validation value पर होता है, Python type पर नहीं। + +## इससे बाहर रहना {#opting-out} + +कभी-कभी return annotation आपके type checker के लिए होता है, protocol के लिए नहीं। `structured_output=False` pass करें और tool सिर्फ़ text वाला हो जाता है: + +```python title="server.py" hl_lines="6" +--8<-- "docs_src/structured_output/tutorial008.py" +``` + +न `output_schema`, न wrapping, न validation। `structured_content` `None` है और `content` वह string है जो आपने लौटाई। + +इसका उल्टा, `structured_output=True`, automatic detection को शर्त बना देता है: जिस tool का return type schema नहीं बना सकता, वह text पर वापस आने के बजाय import के समय ही raise करता है। + +## बिना type hints वाली class {#a-class-without-type-hints} + +बिना माँगे unstructured रह जाने का एक तरीका है: ऐसी class लौटाना जिसकी **body पर कोई annotations न हों**। + +```python title="server.py" hl_lines="6-9" +--8<-- "docs_src/structured_output/tutorial009.py" +``` + +`Station` `__init__` के अंदर `name` और `online` set करती है, लेकिन **class** ख़ुद कुछ declare नहीं करती। SDK class annotations पढ़ता है, कोई नहीं मिलता, और हार मान लेता है। + +!!! warning + वह **चुपचाप** हार मानता है। `output_schema` `None` है, `structured_content` `None` है, और जो text + model पढ़ता है वह object का `repr` है: + + ```text + "" + ``` + + न error, न warning, बस एक बेकार tool। annotations को class body पर ले जाएँ, या + `structured_output=True` pass करें, जो module के import होते ही इसे hard error बना देता है: + `Function get_station: return type is not serializable for structured output`। + +!!! tip + पूरा control चाहिए (`CallToolResult` ख़ुद बनाना, या ऐसा `_meta` जोड़ना जो + application देख सके पर model नहीं)? उसके लिए **[Low-level Server](../advanced/low-level-server.md)** है। + +## सारांश {#recap} + +* **return type annotation** ही output schema है। यह `tools/list` में `output_schema` के रूप में publish होता है। +* scalars, lists, tuples और unions `{"result": ...}` में wrap होते हैं। models, `TypedDict`, dataclasses, annotated classes और `dict[str, ...]` पहले से object हैं और जैसे हैं वैसे ही रहते हैं। +* हर result में `content` (text, model के लिए) **और** `structured_content` (data, application के लिए) होता है। +* आप जो लौटाते हैं वह schema के मुक़ाबले validate होता है। मेल न खाना tool error है, ख़राब result नहीं। +* `structured_output=False` tool को इससे बाहर रखता है। बिना type hints वाली class चुपचाप बाहर हो जाती है; इस पर नज़र रखें। + +अब tool जो कुछ भी जवाब में कह सकता है, वह सब आपके हाथ में है। आगे, दूसरा primitive: **[Resources](resources.md)**। diff --git a/i18n/hi/pages/servers/tools.md b/i18n/hi/pages/servers/tools.md new file mode 100644 index 0000000000..3abe183a96 --- /dev/null +++ b/i18n/hi/pages/servers/tools.md @@ -0,0 +1,177 @@ +--- +translation: + sections: [e4cc390d56573409, 8566e2b68594e9ad, 2c97b9f888398951, 048e5471dfa71aea, 3076b1e16ad95950, edbedf2a16e71311, 3d8ef8da89fa87c1, f6c0e02e6ea5a363] + tool: 1 +--- +# Tools {#tools} + +**tool** ऐसा function है जिसे model call कर सकता है। + +किसी सादे Python function पर `@mcp.tool()` लगाकर आप tool declare करते हैं। पूरा API बस इतना ही है। + +## आपका पहला tool {#your-first-tool} + +```python title="server.py" hl_lines="6-8" +--8<-- "docs_src/tools/tutorial001.py" +``` + +देखें आपने क्या लिखा। न कोई schema, न JSON, न protocol, बस एक function। SDK इससे तीन चीज़ें पढ़ता है: + +* tool का **नाम** function का नाम है: `search_books`। +* model को जो **description** दिखता है वह docstring है: `Search the catalog by title or author.` +* model जो **arguments** pass कर सकता है वे type hints से आते हैं: `query: str` और `limit: int`। + +### Input schema {#the-input-schema} + +इन्हीं type hints से SDK एक JSON Schema बनाता है और `tools/list` के दौरान client को भेजता है: + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"title": "Limit", "type": "integer"} + }, + "required": ["query", "limit"], + "title": "search_booksArguments" +} +``` + +दोनों arguments `required` में हैं क्योंकि किसी का भी default नहीं है। इसे आप थोड़ी ही देर में ठीक करेंगे। (`title` keys Pydantic की देन हैं; properties, उनके types और `required` ही असली contract हैं।) + +!!! tip + यहाँ type hints documentation नहीं हैं। वे ही **contract** हैं। अगर कोई client `"limit": "ten"` भेजता है, + तो SDK उसे आपके function के चलने से पहले ही reject कर देता है। + +### model को क्या वापस मिलता है {#what-the-model-gets-back} + +tool को `{"query": "dune", "limit": 5}` के साथ call करें और result के दो हिस्से होते हैं: + +```python +result.content # [TextContent(text="Found 3 books matching 'dune' (showing up to 5).")] +result.structured_content # {'result': "Found 3 books matching 'dune' (showing up to 5)."} +``` + +`content` वह text है जो **model** पढ़ता है। `structured_content` **client application** के लिए typed data है। यह इसलिए मौजूद है क्योंकि आपने return type `-> str` declare किया। + +`structured_content` की अभी चिंता न करें। अपने tools से असली Python objects लौटाएँ और सही चीज़ अपने-आप होती है; **[Structured Output](structured-output.md)** page पूरा इसी बारे में है। + +### इसे आज़माएँ {#try-it} + +server को MCP Inspector के साथ चलाएँ: + +```console +uv run mcp dev server.py +``` + +यह जो URL print करे उसे खोलें, **Tools** tab पर जाएँ, और `search_books` call करें। + +Inspector एक form दिखाता है जिसमें एक required `query` text field और एक required `limit` number field है। यह form उसने आपके type hints से बनाया। बाकी हर MCP client भी यही करेगा। + +## Optional arguments {#optional-arguments} + +किसी parameter को default value दें और वह required नहीं रहता। बस इतना ही। यह सिर्फ़ Python है। + +```python title="server.py" hl_lines="7" +--8<-- "docs_src/tools/tutorial002.py" +``` + +schema भी साथ बदलता है: + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"default": 10, "title": "Limit", "type": "integer"} + }, + "required": ["query"], + "title": "search_booksArguments" +} +``` + +`limit` `required` से बाहर हो गया और उसे `"default": 10` मिल गया। जो client इसे छोड़ देता है उसे `10` मिलता है, ठीक वैसे ही जैसे Python में होता। + +## `Field` के साथ ज़्यादा विस्तृत schemas {#richer-schemas-with-field} + +type hints से काफ़ी काम चल जाता है, लेकिन कभी-कभी आप किसी argument की **description देना** चाहते हैं, या उस पर constraints लगाना। + +type को `Annotated` में लपेटें और एक Pydantic `Field` जोड़ें: + +```python title="server.py" hl_lines="12-14" +--8<-- "docs_src/tools/tutorial003.py" +``` + +तीन नई चीज़ें, सब parameters पर: + +* `Field(description=...)`: हर argument की अपनी description, जिसे model docstring के साथ पढ़ता है। +* `Field(ge=1, le=50)`: संख्या की सीमाएँ। ये schema में `"minimum": 1, "maximum": 50` बनकर पहुँचती हैं। +* `Literal["fiction", "non-fiction", "poetry"]`: एक enum। model इन्हीं में से कोई एक चुन सकता है। + +!!! check + constraints सजावट नहीं हैं। tool को `limit=999` के साथ call करें और SDK + **आपके function के चलने से पहले ही** tool error के साथ जवाब देता है: + + ```text + Input should be less than or equal to 50 + ``` + + यह error tool result के रूप में model के पास वापस जाता है, model इसे पढ़ता है और सही value के साथ + दोबारा कोशिश करता है। आपने एक बार `le=50` लिखा और खुद को सुधारने वाले agents मुफ़्त में मिल गए। + +!!! info + अगर आपने FastAPI या Pydantic इस्तेमाल किया है, तो यह सब आप पहले से जानते हैं। वही `Field`, + वही `Annotated`, वही validation। यहाँ MCP से जुड़ा कुछ नया सीखने को नहीं है। + +## parameter के रूप में model {#a-model-as-a-parameter} + +जब कोई tool दो-तीन से ज़्यादा arguments लेता है, तो उन्हें एक Pydantic model में समेट लें: + +```python title="server.py" hl_lines="8-11 15" +--8<-- "docs_src/tools/tutorial004.py" +``` + +`Book` schema tool के input schema के अंदर nested होता है (एक `$defs` reference के रूप में), model इसे JSON object के रूप में भरता है, और आपके function को एक **असली `Book` instance** मिलता है, पहले से validated, जिसमें `.title`, `.author` और `.year` attributes हैं। + +आप इन्हें मिला-जुला सकते हैं: model parameters के साथ सादे parameters, nested models, models की lists। नीचे तक सब Pydantic ही है। + +## `async def` {#async-def} + +अगर कोई tool I/O करता है (कोई API call करता है, file पढ़ता है, database से query करता है), तो उसे `async def` declare करें और उसके अंदर `await` करें। SDK उसे await करता है। + +सादा `def` tool भी चलता है: SDK उसे एक thread में चलाता है ताकि वह server को कभी block न करे। + +और कुछ configure करने को नहीं है। + +## नाम, titles और annotations {#names-titles-and-annotations} + +SDK जो कुछ भी अनुमान लगाता है, उसे आप decorator में override कर सकते हैं: + +```python title="server.py" hl_lines="7-10" +--8<-- "docs_src/tools/tutorial005.py" +``` + +* `title` UIs के लिए इंसानों के पढ़ने लायक नाम है। clients `search_books` की जगह *"Search the catalog"* दिखाते हैं। +* `annotations` client के लिए व्यवहार से जुड़े **hints** हैं: + * `read_only_hint=True`: यह tool कुछ नहीं बदलता। + * `open_world_hint=False`: यह चीज़ों के एक बंद set (इस catalog) पर काम करता है, खुले web पर नहीं। + * बाकी दो, `destructive_hint` और `idempotent_hint`, ऐसे tool के बारे में बताते हैं जो **लिखता** है: क्या वह + कुछ delete कर सकता है, और क्या उसे दो बार call करना एक बार call करने जैसा ही है? spec दोनों को + सिर्फ़ non-read-only tools के लिए define करता है, इसलिए `search_books` पर ये कुछ नहीं कहते। + +सलीकेदार client "क्या इसे चलाने से पहले मुझे user से पूछना होगा?" जैसी बातें इन्हीं से तय करता है। ये hints हैं, security नहीं। कभी इस भरोसे न रहें कि client इनका पालन करेगा। + +!!! tip + अगर आप इन्हें function के नाम और docstring से नहीं निकालना चाहते, तो `@mcp.tool()` `name=` और `description=` भी + स्वीकार करता है। ज़्यादातर वक्त आप उन्हीं से निकालना चाहेंगे। + +## सारांश {#recap} + +* function पर `@mcp.tool()` उसे tool बना देता है। नाम function से, description docstring से। +* type hints **ही** input schema हैं। defaults arguments को optional बनाते हैं। +* `Annotated[..., Field(...)]` descriptions और constraints जोड़ता है; `Literal` enums जोड़ता है। +* structured "body" लेने का तरीका Pydantic model parameter है। +* गलत arguments आपके लिए reject कर दिए जाते हैं, ऐसे error के साथ जिसे model पढ़ सके और संभल सके। +* I/O के लिए `async def`, बाकी सब के लिए सादा `def`। + +जो value आप `return` करते हैं उसका क्या होता है, यह **[Structured Output](structured-output.md)** में है। diff --git a/i18n/hi/pages/servers/uri-templates.md b/i18n/hi/pages/servers/uri-templates.md new file mode 100644 index 0000000000..2de67a4834 --- /dev/null +++ b/i18n/hi/pages/servers/uri-templates.md @@ -0,0 +1,190 @@ +--- +translation: + sections: [4a7033e1ed8ad602, 55dcbfff0c6271bf, 101ef9d14bf4ec46, 4b6c4a845438abc7, f98b46bafbee4acd] + tool: 1 +--- +# URI templates और path safety {#uri-templates-and-path-safety} + +यह उस URI-template syntax का reference है जिसे +[`@mcp.resource`](resources.md) स्वीकार करता है, और उस path-safety policy का भी जो SDK निकाली गई values पर लागू करता है। resources क्या हैं और उन्हें कब इस्तेमाल करना है, इसके परिचय के लिए **[Resources](resources.md)** से शुरू करें; यह page मानकर चलता है कि आप resource declare करने में पहले से सहज हैं और अब पूरा operator set, security से जुड़े विकल्प, या low-level wiring जानना चाहते हैं। + +template syntax [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570) है। +SDK इसका एक subset support करता है जो आने वाले `resources/read` URIs को match करने के लिए चुना गया है, साथ ही एक security layer भी है जो ऐसी values को reject करती है जो उस directory के बाहर resolve होतीं जिसे आप serve करना चाहते हैं। protocol-स्तर के विवरण (message formats, lifecycle, pagination) के लिए +[MCP resources specification](https://modelcontextprotocol.io/specification/latest/server/resources) देखें। + +## पूरा operator set {#the-full-operator-set} + +सादा placeholder, `{user_id}`, वही है जिसका परिचय **[Resources](resources.md)** देता है। operator के चार और रूप हैं; यहाँ वे एक ही server पर हैं ताकि आप उन्हें साथ-साथ देख सकें: + +```python title="server.py" hl_lines="16-17 22-23 28-29 34-35 40-41" +--8<-- "docs_src/uri_templates/tutorial001.py" +``` + +हर highlighted decorator URI को बाँटने का अलग तरीका है। +नीचे के sections इन्हें ऊपर से नीचे तक एक-एक करके समझाते हैं। + +### Simple expansion: `{name}` {#simple-expansion-name} + +`books://{isbn}` सादा, रोज़मर्रा का रूप है। placeholder `isbn` parameter से जुड़ता है, इसलिए `books://978-0441172719` पढ़ने वाला client +`get_book("978-0441172719")` call करता है। + +सादा `{name}` पहले `/` पर रुक जाता है। `books://978/extra` match नहीं करता क्योंकि `978` के बाद का slash capture को खत्म कर देता है और `/extra` बचा रह जाता है। + +### Type conversion {#type-conversion} + +निकाली गई values strings के रूप में आती हैं, लेकिन आप ज़्यादा सटीक type declare कर सकते हैं और SDK convert कर देगा। `orders://{order_id}` ऐसे function में पहुँचता है जिसका parameter `order_id: int` है, इसलिए `orders://12345` पढ़ने पर +`get_order(12345)` call होता है, `get_order("12345")` नहीं। handler बिना cast के उस पर arithmetic करता है (`order_id + 1`)। + +### Multi-segment paths: `{+name}` {#multi-segment-paths-name} + +ऐसी value capture करने के लिए जिसमें slashes हों, `{+name}` इस्तेमाल करें। +`manuals://{+path}` के साथ: + +* `manuals://returns.md` से `path = "returns.md"` मिलता है +* `manuals://printing/setup.md` से `path = "printing/setup.md"` मिलता है + +जब भी value hierarchical हो, `{+name}` चुनें: filesystem paths, nested object keys, वे URL paths जिन्हें आप proxy कर रहे हैं। + +### Query parameters: `{?a,b,c}` {#query-parameters-abc} + +`reviews://{isbn}{?limit,sort}` `limit` और `sort` को `?` के बाद रखता है। +path बताता है **कौन-सी** किताब; query तय करती है उसे **कैसे** पढ़ना है। + +query params उदारता से match होते हैं: क्रम मायने नहीं रखता, अतिरिक्त params नज़रअंदाज़ होते हैं, और छोड़े गए params आपके function defaults पर आ जाते हैं। इसलिए +`reviews://978-0441172719` `limit=10, sort="newest"` इस्तेमाल करता है, और +`reviews://978-0441172719?sort=top` सिर्फ़ `sort` को override करता है। + +### List के रूप में path segments: `{/name*}` {#path-segments-as-a-list-name} + +अगर आप हर path segment को slashes वाली एक string की जगह list के अलग-अलग item के रूप में चाहते हैं, तो `{/name*}` इस्तेमाल करें। `shelves://browse{/path*}` के साथ, `shelves://browse/fiction/sci-fi` पढ़ने वाला client +`browse_shelf(["fiction", "sci-fi"])` call करता है। + +### Template reference {#template-reference} + +सबसे आम patterns: + +| Pattern | उदाहरण input | आपको मिलता है | +|--------------|-----------------------|-------------------------| +| `{name}` | `alice` | `"alice"` | +| `{name}` | `docs/intro.md` | **कोई match नहीं** (`/` पर रुकता है) | +| `{+path}` | `docs/intro.md` | `"docs/intro.md"` | +| `{.ext}` | `.json` | `"json"` | +| `{/segment}` | `/v2` | `"v2"` | +| `{?key}` | `?key=value` | `"value"` | +| `{?a,b}` | `?a=1&b=2` | `"1"`, `"2"` | +| `{/path*}` | `/a/b/c` | `["a", "b", "c"]` | + +### Parser क्या reject करता है {#what-the-parser-rejects} + +template के कुछ आकार पहली request पर fail होने की बजाय शुरू में ही पकड़ लिए जाते हैं। `@mcp.resource` decorator चलते समय template को parse करता है, इसलिए इनमें से कोई भी चलते हुए server तक कभी नहीं पहुँचता। + +`UriTemplate.parse()` इनके लिए `InvalidUriTemplate` raise करता है: + +* **दो variables जिनके बीच कुछ न हो।** `manuals://{+path}{ext}` + reject होता है: matching यह नहीं बता सकती कि `path` कहाँ खत्म होता है और `ext` कहाँ शुरू होता है। + उनके बीच कोई literal रखें (`manuals://{+path}/{ext}`), या ऐसा operator इस्तेमाल करें जो अपना delimiter खुद देता हो। `manuals://{+path}{.ext}` + स्वीकार होता है क्योंकि `{.ext}` खुद `.` जोड़ता है। +* **एक से ज़्यादा multi-segment variable।** हर template में `{+var}`, + `{#var}`, या exploded variable (`{/var*}`, `{.var*}`, `{;var*}`) में से ज़्यादा से ज़्यादा एक। दो होना स्वभाव से ही अस्पष्ट है: यह तय करने का कोई सिद्धांत-सम्मत तरीका नहीं है कि अतिरिक्त segment किसमें समाए। +* **आम syntax errors**: बिना बंद किया brace, दो बार इस्तेमाल हुआ variable नाम, या RFC 6570 का कोई ऐसा feature जिसे SDK support नहीं करता, जैसे `{var:3}` prefix modifier या `{?vars*}` query explode। + +इसके अलावा, `@mcp.resource` `ValueError` raise करता है जब handler का कोई parameter template के आखिरी `{?...}`/`{&...}` हिस्से के किसी query variable से बँधा हो लेकिन उसका कोई Python default न हो। वे variables उदारता से match होते हैं (client उनमें से कोई भी छोड़ सकता है), इसलिए बिना default वाला parameter सिर्फ़ उसे छोड़ने वाली पहली request पर एक अस्पष्ट internal error के रूप में सामने आता। ऊपर के server में `reviews://{isbn}{?limit,sort}` सही बना हुआ रूप है: `limit` और `sort` दोनों के defaults हैं। + +## Security {#security} + +template parameters client से आते हैं। अगर वे बिना जाँच के filesystem या database operations में चले जाएँ, तो `../../etc/passwd` जैसी values उस directory के बाहर resolve हो सकती हैं जिसे आप serve करना चाहते थे। + +### SDK default रूप से क्या जाँचता है {#what-the-sdk-checks-by-default} + +आपका handler चलने से पहले, SDK हर उस parameter को reject करता है जो: + +* `..` components के ज़रिए अपनी शुरुआती directory से बाहर निकलता हो +* absolute path जैसा दिखता हो (`/etc/passwd`, `C:\Windows`) या + Windows का drive-relative path हो (`C:foo`)। drive-relative value और `x:y` जैसा namespaced identifier strings के रूप में एक-दूसरे से अलग नहीं किए जा सकते, इसलिए एक-अक्षर-और-colon वाली कोई भी value default रूप से reject होती है; अगर parameter को वाजिब तौर पर ऐसी values मिलती हैं तो उसे exempt करें +* null byte (`\x00`) रखता हो + +`..` की जाँच component-आधारित है, substring scan नहीं। `v1.0..v2.0` या `HEAD~3..HEAD` जैसी values pass होती हैं क्योंकि वहाँ `..` कोई अलग path segment नहीं है। + +ये जाँचें decoded value पर लागू होती हैं, इसलिए traversal URI में चाहे जैसे भी encode किया गया हो, पकड़ा जाता है (`../etc`, `..%2Fetc`, +`%2E%2E/etc`, `..%5Cetc`, `%00` सब पकड़े जाते हैं)। + +!!! check + ऊपर के server से `manuals://../etc/passwd` पढ़ें और request सीधे reject हो जाती है: template matching पहली विफलता पर ही रुक जाती है, इसलिए बाद का कोई (संभवतः ज़्यादा उदार) template fallback के रूप में आज़माया नहीं जाता। client को वही `-32602` "Unknown resource" error दिखता है जो किसी भी template से match न करने वाले URI के लिए दिखता, और `read_manual` कभी नहीं चलता। + +### Filesystem handlers: safe_join इस्तेमाल करें {#filesystem-handlers-use-safe_join} + +built-in जाँचें आम मामलों को रोकती हैं लेकिन आपकी sandbox सीमा नहीं जान सकतीं। filesystem access के लिए, path resolve करने और यह पक्का करने के लिए कि वह आपकी base directory के अंदर ही रहे, `safe_join` इस्तेमाल करें: + +```python title="server.py" hl_lines="4 14" +--8<-- "docs_src/uri_templates/tutorial002.py" +``` + +`safe_join` symlink escapes, `..` sequences, और absolute-path की वे चालें पकड़ता है जो सादी string जाँच से छूट जातीं। अगर resolved path `DOCS_ROOT` से बाहर निकलता है, तो यह `PathEscapeError` raise करता है, जो client तक `ResourceError` के रूप में पहुँचता है। + +### जब defaults आड़े आएँ {#when-the-defaults-get-in-the-way} + +कभी-कभी ये जाँचें वाजिब values को रोक देती हैं। catalog-import tool जानबूझकर absolute path ले सकता है, या कोई parameter `../sibling` जैसा relative reference हो सकता है जिसे आपका handler filesystem छुए बिना सुरक्षित रूप से समझता है। उस parameter को exempt करें, या पूरे server के लिए policy ढीली करें: + +```python title="server.py" hl_lines="9 16-19" +--8<-- "docs_src/uri_templates/tutorial003.py" +``` + +* decorator पर `security=ResourceSecurity(exempt_params={"source"})` + उस एक resource के उस एक parameter के लिए जाँचें छोड़ देता है। बाकी server default policy रखता है। +* `MCPServer` constructor पर `resource_security=` हर resource के लिए default तय करता है। यहाँ `relaxed` `..` की जाँच पूरी तरह बंद कर देता है। + +configure की जा सकने वाली जाँचें: + +| Setting | Default | यह क्या करता है | +|-------------------------|---------|-------------------------------------| +| `reject_path_traversal` | `True` | शुरुआती directory से बाहर निकलने वाले `..` sequences reject करता है | +| `reject_absolute_paths` | `True` | `/foo`, `C:\foo`, UNC paths, और drive-relative `C:foo` reject करता है (`x:y` भी पकड़ता है) | +| `reject_null_bytes` | `True` | `\x00` वाली values reject करता है | +| `exempt_params` | खाली | वे parameter नाम जिनके लिए जाँचें छोड़नी हैं | + +ये जाँचें एक heuristic pre-filter हैं; filesystem access के लिए, +`safe_join` ही containment boundary बना रहता है। + +!!! tip + अगर आपका handler request पूरी नहीं कर सकता (file मौजूद नहीं है, id अनजान है), तो exception raise करें। SDK उसे error response में बदल देता है। protocol error और tool error के बीच के फ़र्क़ के लिए **[errors संभालना](handling-errors.md)** देखें। + +## Low-level Server पर resources {#resources-on-the-low-level-server} + +अगर आप low-level `Server` पर बना रहे हैं (देखें **[Low-level +Server](../advanced/low-level-server.md)**), तो आप `resources/list` और `resources/read` protocol methods के लिए handlers सीधे register करते हैं। कोई decorator नहीं है; protocol types आप खुद लौटाते हैं। + +### Static resources {#static-resources} + +तय URIs के लिए, एक registry रखें और exact match पर dispatch करें: + +```python title="server.py" hl_lines="17 21 27" +--8<-- "docs_src/uri_templates/tutorial004.py" +``` + +list handler clients को बताता है कि क्या उपलब्ध है; read handler content serve करता है। पहले अपनी registry जाँचें, अगर आपके पास templates (नीचे) हैं तो उन पर जाएँ, फिर बाकी सब के लिए raise करें। + +### Templates {#templates} + +`MCPServer` जो template engine इस्तेमाल करता है वह `mcp.shared.uri_template` में रहता है और अपने आप में काम करता है। आपको वही parsing और matching मिलती है; routing और security policy आप खुद जोड़ते हैं। + +```python title="server.py" hl_lines="13-16 22-25 29 33 45" +--8<-- "docs_src/uri_templates/tutorial005.py" +``` + +highlighted lines में तीन चीज़ें हो रही हैं: + +* **एक बार parse करें, हर request पर match करें।** `UriTemplate.parse()` template बनाता है; `template.match(uri)` निकाले गए variables को `dict` के रूप में लौटाता है, या URI फ़िट न हो तो `None`। URL decoding `match()` के अंदर होती है; decoded values बिना path-safety validation के जस की तस लौटाई जाती हैं। values strings के रूप में निकलती हैं: उन्हें खुद convert करें (`int(matched["id"])`, `Path(matched["path"])`)। +* **safety जाँचें खुद लागू करें।** `..` और absolute-path की जो जाँचें `MCPServer` default रूप से चलाता है वे `mcp.shared.path_security` में रहती हैं। + `read_manual_safely` `MANUALS` को छूने से पहले उन्हें call करता है। अगर कोई parameter filesystem path नहीं है (ISBN, search query), तो उस value के लिए जाँचें छोड़ दें: policy आप config object के ज़रिए नहीं बल्कि हर handler के स्तर पर नियंत्रित करते हैं। +* **templates को उसी source से list करें।** clients + `resources/templates/list` के ज़रिए templates खोजते हैं। `str(template)` मूल template string वापस देता है, इसलिए listing और matcher का source of truth एक ही रहता है। + +## सारांश {#recap} + +* `{name}` एक segment match करता है; `{+name}` slashes रखता है; `{?a,b}` + query string से लेता है; `{/name*}` segments को list में बाँटता है। +* दो variables जिनके बीच कुछ न हो, या दूसरा multi-segment variable, parse के समय reject होते हैं। आखिरी `{?...}`/`{&...}` query variable से बँधे parameter को Python default declare करना ज़रूरी है। +* parameter को annotate करें (`order_id: int`) और SDK convert कर देता है। +* default security policy आपका handler चलने से पहले `..`, absolute paths, और null bytes reject करती है; हर resource के लिए `security=ResourceSecurity(...)` से या पूरे server के लिए `resource_security=` से override करें। +* filesystem access के लिए, `safe_join` ही containment boundary है। +* low-level `Server` पर, `UriTemplate.parse()` से parse करें, `.match()` से match करें, और `mcp.shared.path_security` खुद लागू करें। diff --git a/i18n/hi/pages/translations.md b/i18n/hi/pages/translations.md new file mode 100644 index 0000000000..ef28dbcb2f --- /dev/null +++ b/i18n/hi/pages/translations.md @@ -0,0 +1,30 @@ +--- +translation: + sections: [f671b445b16e4f99, 3983a560eb2cece7, 004b3ee918529d8c, c6e2debf1da06eb7, 81d412ed5f399f94] + tool: 1 +--- +# अनुवाद {#translations} + +यह documentation अंग्रेज़ी में लिखी गई है। इसे ज़्यादा लोगों के लिए उपयोगी बनाने के लिए हम इसके machine-translated संस्करण भी प्रकाशित करते हैं। यह page बताता है कि इसका आपके लिए क्या मतलब है और इन्हें बेहतर बनाने में आप कैसे मदद कर सकते हैं। + +## क्या उपलब्ध है {#whats-available} + +अनुवादित documentation फ़िलहाल बारह भाषाओं में **preview** के रूप में उपलब्ध है: Deutsch, español, français, हिन्दी, 日本語, 한국어, português (Brasil), русский язык, Türkçe, українська мова, 简体中文 और 繁體中文। किसी भी page के ऊपर बने language switcher से भाषा चुनें। जब ये भाषाएँ अपनी उपयोगिता साबित कर देंगी, तो और भाषाएँ भी जोड़ी जा सकती हैं। + +API reference का अनुवाद नहीं किया गया है: अनुवादित site उसी एक अंग्रेज़ी reference से link करती है। + +## अंग्रेज़ी ही सही मानी जाएगी {#english-is-the-source-of-truth} + +अगर किसी अनुवादित page और उसके अंग्रेज़ी मूल में फ़र्क हो, तो अंग्रेज़ी page सही है। अनुवादित site का हर page इन तीन notes में से किसी एक से शुरू होता है, जो बताता है कि वह page किस स्थिति में है: + +- **Machine translation** — page का अनुवाद अपने आप किया गया है और उसमें उसके अंग्रेज़ी मूल का link है। +- **Translation behind the English page** — page का अनुवाद होने के बाद अंग्रेज़ी मूल बदल गया है, इसलिए जब तक अनुवाद फिर से नहीं होता, इसके कुछ हिस्से पुराने हो सकते हैं। +- **Shown in English** — इस page का कोई मौजूदा अनुवाद नहीं है, इसलिए आप अंग्रेज़ी text पढ़ रहे हैं। + +## अनुवाद कैसे बनते हैं {#how-the-translations-are-made} + +अनुवादित pages इसी repository के एक tool से `docs/` के अंग्रेज़ी pages से machine-generated होते हैं। हर भाषा के लिए इंसानों के लिखे दो inputs इसका मार्गदर्शन करते हैं: एक style guide (register, tone, typography, मज़ाक और मुहावरों को कैसे संभालना है) और एक glossary (कौन से terms अंग्रेज़ी में रहेंगे, और बाकी के लिए ज़रूरी और मना किए गए renderings)। Generate हुए text को कभी हाथ से edit नहीं किया जाता। हर सुधार इन्हीं inputs में जाता है, ताकि अगली बार pages फिर से generate होने पर भी वह बना रहे। + +## अनुवाद की समस्या की सूचना देना {#reporting-a-translation-problem} + +कोई गलत term, अटपटा वाक्य, या ऐसा अनुवाद मिला जो अंग्रेज़ी में कही ही नहीं गई बात कहता हो? भाषा, page और उस अंश के साथ [issue खोलें](https://github.com/modelcontextprotocol/python-sdk/issues); मूल भाषा बोलने वालों की reports खास तौर पर कीमती हैं। अगर आपको सुधार पता है, तो उसे सीधे [`i18n/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/i18n) के अंदर उस भाषा की style guide (`instructions.md`) या glossary (`glossary.json`) पर pull request के रूप में प्रस्तावित करें — फिर अगली बार अनुवाद generate होने पर वह सुधार हर प्रभावित page तक पहुँच जाता है। अंग्रेज़ी text की समस्याएँ, documentation के किसी भी दूसरे बदलाव की तरह, `docs/` के pages में ठीक की जाती हैं। diff --git a/i18n/hi/pages/troubleshooting.md b/i18n/hi/pages/troubleshooting.md new file mode 100644 index 0000000000..f979f3459f --- /dev/null +++ b/i18n/hi/pages/troubleshooting.md @@ -0,0 +1,420 @@ +--- +translation: + sections: [2efaecdef109a5c5, fcacd3e66b8635a4, 25323d737dcf0261, 4835ed1772f1d113, 137454d469c867f5, 6392596bd6df54f0, 41126fa9c4fe432f, 480b6d7897e30ab4, d83bb682e708dde0, ebbed3449c499db4, 323ef84f6b4bebde, 30fd31be74169d9a, 656943c6cb567218, c2dc3b1007d2e987, 7cf5386b997d04e9, 0b59feed8384456e, 0cba47bae78d04eb, 954dc21efdb532a3] + tool: 1 +--- +# समस्याएँ सुलझाना {#troubleshooting} + +इस page की हर heading ठीक वही text है जो SDK किसी error में लिखता है; उसके नीचे बताया गया है कि उसका मतलब क्या है और उसे एक ही कदम में कैसे ठीक करें। अपने traceback (या server log) की आखिरी line को browser के find-in-page से यहाँ खोजें, और सिर्फ़ वही entry पढ़ें। + +कई entries इसी एक server पर चलती हैं। एक tool और एक templated resource, दोनों ऐसे city के लिए raise करते हैं जिसे वे नहीं जानते: + +```python title="server.py" +--8<-- "docs_src/troubleshooting/tutorial001.py" +``` + +इस page पर quote किए गए errors असली हैं: SDK का अपना test suite इनमें से हर एक को reproduce करता है। + +## `ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)` {#exceptiongroup-unhandled-errors-in-a-taskgroup-1-sub-exception} + +यह MCP error नहीं है। यह anyio का शोर है, और असली error paste की **आखिरी line** है। + +`Client.__aenter__` एक task group शुरू करता है। task group से बाहर निकलने वाली हर चीज़ को anyio `ExceptionGroup` में लपेट देता है, इसलिए `async with Client(...)` block से निकलने वाला **हर** exception, वह कुछ भी हो, इसी के अंदर आता है: + +```python +async def main() -> None: + async with Client(mcp) as client: + await client.read_resource("weather://Atlantis") +``` + +```text + + Exception Group Traceback (most recent call last): + | ... + | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception) + +-+---------------- 1 ---------------- + | Exception Group Traceback (most recent call last): + | ... + | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception) + +-+---------------- 1 ---------------- + | Traceback (most recent call last): + | ... + | mcp.shared.exceptions.MCPError: No forecast for 'Atlantis'. + +------------------------------------ +``` + +इसके साथ दो काम करें: + +1. **सबसे नीचे पढ़ें।** `MCPError: No forecast for 'Atlantis'.` ही असली failure है; इस page पर **उसी** का text खोजें। +2. **block के अंदर catch करें।** `ExceptionGroup` तभी दिखता है जब exception `async with` से **बाहर निकलता** है। अंदर ही catch कर लें तो वही failure सादा `MCPError` है, कहीं कोई group नहीं: + +```python +async def main() -> None: + async with Client(mcp) as client: + try: + await client.read_resource("weather://Atlantis") + except MCPError as e: + print(e) # No forecast for 'Atlantis'. +``` + +!!! tip + **connection** के दौरान होने वाला failure (गलत URL, बंद पड़ा server, इस page पर नीचे दिया + गया `421`) खुद `async with` से ही बाहर निकलता है, इसलिए उसे catch करने के लिए कोई "अंदर" है + ही नहीं। ऐसे मामलों में group का सबसे निचला हिस्सा पढ़ें। + +## `RuntimeError: Client must be used within an async context manager` {#runtimeerror-client-must-be-used-within-an-async-context-manager} + +`Client(...)` सिर्फ़ object बनाता है। `async with` तक कुछ भी connect नहीं होता, इसलिए हर method मना कर देता है: + +```python +async def main() -> None: + client = Client(mcp) + tools = await client.list_tools() # RuntimeError +``` + +इसमें enter करें। `__aenter__` ही connection है: + +```python +async def main() -> None: + async with Client(mcp) as client: + tools = await client.list_tools() +``` + +`__aexit__` ही disconnection है, इसीलिए भूल जाने लायक कोई `client.close()` है ही नहीं। **[Testing](get-started/testing.md)** ठीक इसी pattern पर बना है। + +## `Error executing tool : ` और `Unknown tool: ` {#error-executing-tool-name-message-and-unknown-tool-name} + +आप एक **result** पढ़ रहे हैं, exception नहीं। `call_tool` ने raise नहीं किया, और fail होने वाले tool के लिए वह कभी करेगा भी नहीं। + +`forecast` को ऐसे city के लिए call करें जिसे server नहीं जानता, तो उसका raise किया हुआ exception वापस आता है और request **सफल** mark होती है: + +```python +result.is_error # True +result.content # [TextContent(text="Error executing tool forecast: No forecast for 'Atlantis'.")] +result.structured_content # None +``` + +जो नाम server ने कभी register ही नहीं किया, उसके लिए `Unknown tool: get_forecast` इसी shape में आता है, और गलत argument भी इसी तरह, tool के input schema के आधार पर, आपका function चलने से पहले ही reject हो जाता है। + +सुधार आपके client में है: **`result.is_error` जाँचें**। `call_tool` के चारों ओर लगा `try/except` इनमें से कुछ नहीं पकड़ता, क्योंकि पकड़ने को कुछ है ही नहीं। यह जान-बूझकर है, और इस page की सबसे काम की बात यही है जिसे मन में बिठा लें: call **model** ने चुना था, इसलिए message भी model को मिलता है और दोबारा कोशिश करने का मौका भी। पूरी जानकारी **[errors संभालना](servers/handling-errors.md)** में है, उस `MCPError` वाले रास्ते समेत जो सच में raise **करता** है। + +## `TypeError: The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool` {#typeerror-the-tool-decorator-was-used-incorrectly-did-you-forget-to-call-it-use-tool-instead-of-tool} + +आपने `@mcp.tool()` की जगह `@mcp.tool` लिख दिया। `tool()` एक decorator **factory** है: parentheses के बिना Python आपका function उसके `name=` parameter को थमा देता है। + +```python +@mcp.tool # <- missing () +def forecast(city: str) -> str: + """Today's forecast for one city.""" + return f"{city}: Rain." +``` + +```text +TypeError: The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool +``` + +parentheses जोड़ें। यही चूक होने पर `@mcp.resource(...)` और `@mcp.prompt()` भी यही बात कहते हैं। + +!!! note + यह module **import** होते ही raise हो जाता है, किसी भी client के जुड़ने से पहले। इसलिए जो + host आपके server को zero tools के साथ connected दिखाने के बजाय **failed to start** (या + **disconnected**) दिखाता है, वह इसी shape का है: खुद `python server.py` चलाएँ और traceback + पढ़ें। type checker भी इसे पकड़ लेता है: function कोई valid `name=` नहीं है। + +## `Tool already exists: ` {#tool-already-exists-name} + +दो registrations ने एक ही tool नाम इस्तेमाल किया। **पहला** जीतता है, दूसरा चुपचाप छोड़ दिया जाता है, और **server log** में आने वाली यह warning ही इसका इकलौता संकेत है: + +```python title="server.py" hl_lines="6 12" +--8<-- "docs_src/troubleshooting/tutorial002.py" +``` + +```text +WARNING mcp.server.mcpserver.tools.tool_manager: Tool already exists: forecast +``` + +`tools/list` एक ही `forecast` बताती है, और वह `forecast_today` है। इनमें से एक का नाम बदलें। `MCPServer(..., warn_on_duplicate_tools=False)` नतीजा बदले बिना सिर्फ़ warning चुप करा देता है, इसलिए इसे चालू ही रहने दें। resources और prompts पर भी यही नियम और यही log line लागू है (`Resource already exists:`, `Prompt already exists:`)। + +## मेरा host एक भी tool नहीं दिखाता {#my-host-lists-zero-tools} + +इसके लिए कोई error string नहीं है, और ठीक इसीलिए इसे खोजना मुश्किल है। SDK कभी किसी registered tool को `tools/list` से नहीं हटाता, इसलिए अंदर से बाहर की ओर जाँचें: + +* **क्या server शुरू भी हुआ?** बिना parentheses वाला `@mcp.tool` import के समय raise करता है, और कुछ hosts में crash हुआ server खाली server जैसा ही दिखता है। खुद `python server.py` चलाएँ। +* **क्या tool उसी `mcp` पर है जिसे host चला रहा है?** किसी दूसरे module में दूसरा `MCPServer(...)` एक अलग, खाली server है। जाँचें कि host का command असल में कौन-सा object import करता है। +* **क्या दो tools का नाम एक ही था?** तो उनमें से एक गायब है। server log में `Tool already exists:` खोजें। +* **क्या host की सूची पुरानी पड़ गई है?** startup के बाद जोड़ा गया tool सिर्फ़ उन्हीं clients तक पहुँचता है जो `notifications/tools/list_changed` संभालते हैं। host को restart करना सीधा-सादा इलाज है। +* **क्या diverted window के बाहर किसी चीज़ ने `stdout` पर लिखा?** serve करते समय SDK भटके हुए **flushed** stdout को stderr की ओर मोड़ देता है (best-effort: जो environment standard streams बदल देता है, उसे जैसा है वैसा ही serve किया जाता है), लेकिन उससे पहले stdout पर flush हुआ output (echo करती wrapper script, unbuffered process में import-time `print()`) या interpreter exit पर खाली होने वाला buffered `print()` protocol stream पर पहुँच जाता है, और एक भी कचरा line से host connection तोड़ सकता है, जिसे कुछ hosts खाली server की तरह दिखाते हैं। इसके बजाय `logging` module से log करें। host-side checklist का बाकी हिस्सा **[असली host से जुड़ें](get-started/real-host.md)** पर है। + +"invalid" tool नाम इस सूची में **नहीं** है: नियम से हटकर रखा गया नाम warning log करता है, पर tool फिर भी register और list होता है। + +## `MCPError: Server returned an error response` {#mcperror-server-returned-an-error-response} + +server ने HTTP request को सीधे ठुकरा दिया, ऐसी body के साथ जो JSON-RPC नहीं है, इसलिए python `Client` के पास आपको दिखाने के लिए इस stand-in से बेहतर कुछ नहीं है। + +सबसे आम कारण, बाकी सबसे कहीं ज़्यादा, अभी-अभी deploy किया गया Streamable HTTP server है। बिना `transport_security=` के `streamable_http_app()` (और `mcp.run("streamable-http")`) का default **DNS-rebinding protection** है: यह सिर्फ़ वही requests स्वीकार करता है जिनका `Host` header localhost हो। आपके laptop पर यह सही default है, और असली hostname के पीछे गलत: + +```python title="server.py" hl_lines="12" +--8<-- "docs_src/troubleshooting/tutorial003.py" +``` + +इसे deploy करें, किसी client को इस पर point करें, और connection handshake पर ही fail हो जाता है: + +```python +async with Client("https://mcp.example.com/mcp") as client: + ... +``` + +```text +mcp.shared.exceptions.MCPError: Server returned an error response +``` + +server ने असल में जो शब्द भेजे, `421` और `Invalid Host header`, वे आप तक कभी नहीं पहुँचते: 421 body में `Content-Type: application/json` नहीं है, इसलिए client उसे parse नहीं कर सकता। वे **server के log** में हैं, और अगली नज़र वहीं डालनी है: + +```text +WARNING mcp.server.transport_security: Invalid Host header: mcp.example.com +``` + +सुधार `transport_security=` है। जिस hostname पर आप सच में serve करते हैं, उसे allowlist करें: + +```python title="server.py" hl_lines="14-17" +--8<-- "docs_src/troubleshooting/tutorial004.py" +``` + +!!! check + पूरा बदलाव बस इतना ही है। बिल्कुल वही client अब connect होता है, `2026-07-28` negotiate करता + है, और `forecast` call करता है। + +हर field का मतलब, reverse-proxy वाला मामला, और deploy के समय बदलने वाली बाकी हर चीज़ **[Deploy और scale](run/deploy.md)** में है। और ठीक नीचे दिया गया `421 Misdirected Request` / `Invalid Host header` यही failure है, दूसरी तरफ़ से देखा हुआ। + +## `421 Misdirected Request` / `Invalid Host header` {#421-misdirected-request-invalid-host-header} + +यह वही `Server returned an error response` है, किसी भी ऐसी चीज़ से देखा हुआ जो python `Client` **नहीं** है: curl, browser का network tab, reverse proxy का access log, या कोई दूसरा SDK। + +```bash +curl -i https://mcp.example.com/mcp \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}' +``` + +```text +HTTP/1.1 421 Misdirected Request + +Invalid Host header +``` + +`421 Misdirected Request` इस status के लिए HTTP का अपना reason phrase है; `Invalid Host header` SDK की response body है; और python `Client` इसी घटना को `Server returned an error response` के रूप में दिखाता है। तीनों एक ही इनकार हैं। जाँच **request में आए `Host` header** पर चलती है, उस address पर नहीं जिससे server bind हुआ, इसलिए public hostname आगे भेजने वाला reverse proxy इसे ठीक वैसे ही trip करता है जैसे सीधा client। + +सुधार वही `transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])` है जो `Server returned an error response` के नीचे दिखाया गया है। इसके दो किनारे नाम लेने लायक हैं: + +* `allowed_hosts` की entry exact string होती है। `"mcp.example.com"` बिना port वाले `Host` header से match करती है और `"mcp.example.com:*"` किसी भी explicit port से। दोनों को list करें। +* `Invalid Origin header` body वाला `403` इसी का जुड़वाँ check है जो `Origin` header पर चलता है। यह सिर्फ़ browsers के लिए fire होता है (और कोई `Origin` भेजता ही नहीं), और `allowed_origins=` इसकी allowlist है। + +पूरी जानकारी **[Deploy और scale](run/deploy.md)** में है, इस बात समेत कि कब check बंद कर देना ही ईमानदार configuration है। + +## `RuntimeError: Task group is not initialized. Make sure to use run().` {#runtimeerror-task-group-is-not-initialized-make-sure-to-use-run} + +आपका MCP app किसी दूसरे ASGI app के अंदर mount है, और उसका **session manager** किसी ने शुरू नहीं किया। + +`mcp.streamable_http_app()` एक Starlette app लौटाता है जिसका अपना lifespan manager शुरू करता है, और `uvicorn server:app` वह lifespan आपके लिए चला देता है। लेकिन Starlette **mounted sub-application का lifespan कभी नहीं चलाता**, इसलिए जैसे ही app किसी `Mount` के अंदर जाता है, manager कभी शुरू नहीं होता और पहली ही request फट पड़ती है: + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/troubleshooting/tutorial005.py" +``` + +server शुरू होता है। route resolve होता है। फिर `uvicorn` हर request पर यह print करता है: + +```text +ERROR: Exception in ASGI application +Traceback (most recent call last): + ... +RuntimeError: Task group is not initialized. Make sure to use run(). +``` + +client को 500 दिखता है। सुधार **host** app पर एक lifespan है जो `mcp.session_manager.run()` में enter करता है: + +```python +@asynccontextmanager +async def lifespan(app: Starlette) -> AsyncIterator[None]: + async with mcp.session_manager.run(): + yield + + +app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lifespan) +``` + +इसके लिए **[मौजूदा app में जोड़ें](run/asgi.md)** वाला page है, एक app में कई servers और FastAPI समेत। उसी class से दो पड़ोसी strings: + +* `StreamableHTTPSessionManager .run() can only be called once per instance. Create a new instance if you need to run again.` manager single-use है; एक ही app के lifespan में दो बार enter करने पर यह मिलता है। +* `mcp.session_manager` सिर्फ़ `streamable_http_app()` call होने के **बाद** ही मौजूद होता है, इसलिए पहले routes बनाएँ और manager को सिर्फ़ lifespan के अंदर ही छुएँ। + +## `MCPError: Session not found` {#mcperror-session-not-found} + +client ने जो `Mcp-Session-Id` भेजा उसे server नहीं पहचानता, लगभग हमेशा इसलिए कि server **restart** हुआ (या आपको किसी दूसरे instance पर route कर दिया गया)। sessions उसी एक process की memory में रहते हैं। + +खोजने को कोई server bug नहीं है। HTTP response एक `404` है जिसकी body JSON-RPC **है**, इसलिए ऊपर वाले `421` के उलट, python `Client` इसे आपको ज्यों का त्यों दिखाता है: + +```json +{"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "Session not found"}} +``` + +सुधार है reconnect करना: `async with Client(...)` block से बाहर निकलें और नए में enter करें, जो नया session negotiate करता है। लंबे समय तक चलने वाले client के लिए इसका मतलब है अपने calls के चारों ओर `MCPError` catch करना और इस message पर reconnect करना, न कि मरे हुए session के अंदर retry करते रहना। + +अगर यह restart के **बिना** होता है, तो आप sticky sessions के बिना एक से ज़्यादा worker चला रहे हैं: हर worker की अपनी session table होती है, इसलिए गलत worker पर route हुई request यहीं आ गिरती है। वह पूरी कहानी और उसके दो सुधार (sticky routing, या `stateless_http=True`) **[Deploy और scale](run/deploy.md)** और **[legacy clients को serve करना](run/legacy-clients.md)** में हैं। + +server operator के लिए इससे मेल खाती log line है `Rejected request with unknown or expired session ID: `। यह `INFO` पर log होती है, इसलिए आम `WARNING` threshold पर नहीं दिखती। deploy के ठीक बाद इसे झुंड में देखना सामान्य है; हर जुड़ा हुआ client reconnect कर रहा है। + +## `MCPError: Method not found` {#mcperror-method-not-found} + +एक side ने ऐसी JSON-RPC request भेजी जिसके लिए दूसरी side के पास कोई handler नहीं है, और `e.error.data` उस method का नाम बताता है। आम कारण है **पीढ़ी का मेल न खाना**: ऐसा method जो एक protocol revision में है और दूसरे में नहीं, गलत revision वाले peer को भेज दिया गया, जैसे `2025` पीढ़ी की `resources/subscribe` किसी `2026-07-28` connection पर आ पहुँचे, या `mode="legacy"` पर pin किया हुआ client सिर्फ़ `2026` में मौजूद `subscriptions/listen` भेज दे। कौन-सी side क्या बोलती है, इसका नक्शा **[Protocol versions](protocol-versions.md)** है, और दूसरा जायज़ कारण (एक optional capability जिसके लिए आपने कभी handler register नहीं किया) **[Completions](servers/completions.md)** पर है। + +एक चीज़ यह error पैदा **नहीं** करती, भले वह ऐसी request है जिसे आधुनिक protocol ने हटा दिया: `2026-07-28` connection पर `ctx.elicit()` call करता tool। server उस request को **भेजने** से ही मना कर देता है, इसलिए आपको इसके बजाय `Cannot send 'elicitation/create': ...` मिलता है, जो इस page पर और नीचे है। + +## `MCPError: Client did not declare the form elicitation capability required by resolver ''` {#mcperror-client-did-not-declare-the-form-elicitation-capability-required-by-resolver-name} + +आपका server user से कुछ पूछना चाहता है, और इस client ने कभी कहा ही नहीं कि उससे पूछा जा सकता है। + +जब जुड़े हुए client ने form elicitation declare नहीं किया हो, तो elicitation resolver शुरू में ही मना कर देता है, और `e.error.data` ठीक-ठीक बताता है कि क्या गायब है: + +```json +{ + "code": -32021, + "message": "Client did not declare the form elicitation capability required by resolver 'server:ask_to_confirm'", + "data": {"requiredCapabilities": {"elicitation": {"form": {}}}} +} +``` + +`Client(...)` को `elicitation_callback=` दें। callback register करना **ही** capability declaration है; कोई दूसरा switch नहीं है: + +```python +async def main() -> None: + async with Client(mcp, elicitation_callback=handle_elicitation) as client: + result = await client.call_tool("book_table", {"date": "Friday"}) +``` + +बाकी callbacks (`sampling_callback`, `list_roots_callback`) की सूची **[Client callbacks](client/callbacks.md)** में है, और उनमें से हर एक इसी तरह एक declaration है। + +!!! info + `-32021` है `MISSING_REQUIRED_CLIENT_CAPABILITY`, उन तीन error codes में से एक जो 2026-07-28 + spec जोड़ता है। इनमें से कोई भी exception class नहीं है: ये सब `MCPError` बनकर आते हैं, और + देखने की जगह `e.error.code` है। `mcp.types` ये constants export करता है। बाकी दो हैं + `-32020` `HEADER_MISMATCH` (कोई HTTP header अपने साथ वाली request body से मेल नहीं खाता) + और `-32022` `UNSUPPORTED_PROTOCOL_VERSION` (request ने ऐसा version बताया जो यह server नहीं + बोलता)। नियम मानने वाला SDK client इनमें से कोई भी पैदा नहीं कर सकता, इसलिए अगर कोई दिखे, तो उस + चीज़ को देखें जो आपके client और server के बीच requests को बदल रही है। + +## `MCPError: Elicitation not supported` {#mcperror-elicitation-not-supported} + +वही कमी जो `Client did not declare the form elicitation capability ...` में है, बस उन रास्तों के शब्दों में जो शुरू में जाँच नहीं करते: server को एक elicitation का जवाब चाहिए था, और जुड़े हुए client ने कोई `elicitation_callback` register नहीं किया। + +यह legacy connection पर `ctx.elicit()` से दिखता है, और किसी भी connection पर तब, जब लौटाया गया multi-round-trip सवाल (**[Multi-round-trip requests](handlers/multi-round-trip.md)**) ऐसे client तक पहुँचे जिसके पास जवाब देने को कोई callback नहीं। सुधार बिल्कुल वही है: `Client(...)` को `elicitation_callback=` दें। "user से पूछा ही नहीं गया" का कोई ऐसा रूप नहीं है जो आपके tool को `decline` के रूप में मिले; जिस client से पूछा नहीं जा सकता वह fail हुआ call है, इसलिए अपने tools को इसी हिसाब से बनाएँ। + +## `MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests.` {#mcperror-cannot-send-elicitationcreate-this-transport-context-has-no-back-channel-for-server-initiated-requests} + +आपके handler ने request के बीच में client तक पहुँचने की कोशिश की, ऐसे connection पर जिसके call में server की ओर से request ले जाने वाला कोई channel नहीं है। तीन server configurations हैं जो किसी call को इस हालत में डालते हैं। + +**`2026-07-28` connection: कोई भी transport, हमेशा।** आधुनिक protocol में server-initiated requests हैं ही नहीं, इसलिए server कुछ भेजे जाने से पहले ही मना कर देता है। tool के अंदर `ctx.elicit()` इससे टकराने का classic तरीका है (पहले ही in-memory test पर, क्योंकि `Client(server)` बिना कहे `2026-07-28` negotiate करता है), और `elicitation_callback=` देने से कुछ नहीं बदलता, क्योंकि client तक कभी कोई request पहुँचती ही नहीं जिसका वह जवाब दे: + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/troubleshooting/tutorial006.py" +``` + +```python +async def main() -> None: + async with Client(mcp) as client: + await client.call_tool("book_table", {"date": "Friday"}) +``` + +```text +mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests. +``` + +**`stateless_http=True` server पर legacy connection।** statelessness का मतलब है हर request अपनी अलग दुनिया है: न session, न server-to-client stream, और इसलिए `elicitation/create` (या `sampling/createMessage`, या `roots/list`) भेजने की कोई जगह नहीं, उस पीढ़ी के लिए भी जिसमें ये मौजूद हैं: + +```python title="server.py" hl_lines="16 23" +--8<-- "docs_src/troubleshooting/tutorial008.py" +``` + +**`json_response=True` server पर legacy connection।** `POST` का जवाब एक JSON body से दिया जाता है, और एक body में सिर्फ़ response आता है, इसलिए request के बीच `ctx.elicit()` को जिस request-scoped stream की ज़रूरत है, वह यहाँ भी मौजूद नहीं है। session, उसका `Mcp-Session-Id`, और उसकी standalone stream, सब अब भी हैं; सिर्फ़ request-scoped channel गायब है। + +message उस method का नाम बताता है जिसे वह भेज नहीं सका। server जो class raise करता है वह `NoBackChannelError` है, पर wire पर सिर्फ़ base `MCPError` जाता है, इसलिए आपके traceback की आखिरी line ऊपर वाला वाक्य है, class का नाम नहीं। + +`2026-07-28` client के लिए तीनों में सुधार एक ही है: call के बीच में पीछे न पहुँचें। सवाल को एक **resolver** में ले जाएँ (या खुद `InputRequiredResult` लौटाएँ) और वह **response** का हिस्सा बन जाता है, जिसे हर connection ले जा सकता है: + +```python title="server.py" hl_lines="15-17 21" +--8<-- "docs_src/troubleshooting/tutorial007.py" +``` + +वही सवाल, client पर वही `elicitation_callback`। फ़र्क अंदर ही अंदर है: resolver की वजह से server सवाल को push करने के बजाय call से **लौटा** सकता है, इसलिए server से client की ओर कभी कुछ बहता ही नहीं। इससे हर `2026-07-28` client बच जाता है, server तीनों में से किसी भी configuration में हो। **legacy** client सिर्फ़ इस rewrite से नहीं बचता: `2025-11-25` के पास सवाल लौटाने का कोई तरीका नहीं है, इसलिए legacy connection पर resolver अब भी `elicitation/create` को request-scoped channel से भेजता है, और अब भी ऐसा server चाहिए जो वह channel रखता हो — यानी न `stateless_http=True`, न `json_response=True`। resolvers की जानकारी **[Elicitation](handlers/elicitation.md)** में है; wire पर क्या होता है, वह **[Multi-round-trip requests](handlers/multi-round-trip.md)** में। + +!!! check + `ctx.elicit()` वाला tool गलत नहीं है, वह **2026 से पहले** का है। `mode="legacy"` (classic + `initialize` handshake, spec `2025-11-25` और उससे पहले) से ऐसे server से जुड़ें जो न + `stateless_http=True` है न `json_response=True`, और यह काम करता है, क्योंकि वहाँ + server-to-client channel मौजूद है। + हर version में क्या है, इसका page **[Protocol versions](protocol-versions.md)** है। + +## `MCPError: Invalid or expired requestState` {#mcperror-invalid-or-expired-requeststate} + +client ने जो `requestState` token वापस echo किया, server उसे verify नहीं कर सका, इसलिए उसने round ठुकरा दिया। + +`requestState` वह opaque resume token है जो **[multi-round-trip](handlers/multi-round-trip.md)** call अपने legs के बीच साथ ले जाता है। `MCPServer` बाहर जाते समय इसे seal करता है और हर echo को verify करता है, और `tools/call`, `prompts/get`, और `resources/read` पर आने वाले **हर** `request_state` को verify करता है, उस handler के लिए भी जो कभी token बनाता ही नहीं। इसलिए जिस token को इस process ने seal नहीं किया, वह जहाँ भी पहुँचे, ठुकरा दिया जाता है: + +```python +async def main() -> None: + async with Client(mcp) as client: + await client.call_tool("forecast", {"city": "London"}, request_state="round-1-from-worker-a") +``` + +```text +mcp.shared.exceptions.MCPError: Invalid or expired requestState +``` + +message जान-बूझकर जड़ रखा गया है: wire कभी नहीं बताता कि कौन-सा check fail हुआ। कारण **server log** में जाता है, और उसे पढ़ना ही पूरा diagnosis है: + +```text +WARNING mcp.server.request_state: requestState rejected on tools/call: malformed +``` + +जो कारण आपको असल में दिखेंगे: + +* **`unknown key`** वह है जो मायने रखता है। default sealing key process शुरू होने पर generate होती है, इसलिए जो retry किसी **दूसरे worker** पर, load balancer के पीछे किसी दूसरे instance पर, या **restart के बाद** उसी server पर पहुँचे, वह ऐसी key से seal हुआ था जो इस process के पास कभी थी ही नहीं। वह कोई attacker नहीं है; वह default का एक से ज़्यादा process से सामना है। +* **`audience`**: token किसी **अलग server नाम** वाले instance ने seal किया था। नाम ही seal का default audience claim है, इसलिए fleet को keys के साथ-साथ नाम भी साझा करना होगा (या explicit `RequestStateSecurity(audience=...)` set करना होगा)। +* **`expired`**: round ने seal के `ttl` से ज़्यादा समय लिया, जो 600 seconds है और हर round पर लागू है, हर call पर नहीं। +* **`malformed`** / **`codec error`**: token रास्ते में बदल गया, या वह कभी sealed token था ही नहीं। +* **`request binding`**: token किसी अलग tool, अलग arguments, या अलग method के साथ वापस आया। + +multi-process सुधार एक argument है (हर instance पर **वही** `keys`) और साथ में एक ऐसी चीज़ जो argument है ही नहीं: वही server **नाम** (या explicit साझा `audience=`)। + +```python +mcp = MCPServer("Weather", request_state_security=RequestStateSecurity(keys=[key])) +``` + +`keys[0]` seal करती है; सूची की हर key verify करती है, और यही zero-downtime rotation को संभव बनाता है। seal क्या बचाता है और rotation का क्रम क्या है, यह **[Multi-round-trip requests](handlers/multi-round-trip.md#protecting-requeststate)** समझाता है, और **[Deploy और scale](run/deploy.md)** पूरे two-worker failure और उसके दो हिस्सों वाले सुधार से होकर गुज़रता है। + +!!! tip + `keys=[...]` कमज़ोर key को तुरंत ठुकरा देता है, असामान्य रूप से मददगार message के साथ: + + ```text + ValueError: request-state keys must be at least 32 bytes of secret randomness; keys[0] is 7 bytes. Generate one with: python -c "import secrets; print(secrets.token_hex(32))" + ``` + + जैसा कहा है वैसा करें। + +## अब भी अटके हैं? {#still-stuck} + +* अगर SDK का कोई message इस page पर नहीं है, तो वह अपने आप में report करने लायक documentation bug है। +* [issue tracker](https://github.com/modelcontextprotocol/python-sdk/issues) में खोजें; वहाँ दिखने वाली ज़्यादातर error strings पहले से किसी का write-up हैं। +* कुछ नहीं मिला? पूरे traceback के साथ [issue खोलें](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml), या [MCP Contributors Discord के #python-sdk-dev](https://discord.gg/6CSzBmMkjX) में पूछें। + +## सारांश {#recap} + +* `ExceptionGroup: unhandled errors in a TaskGroup` कभी असली error नहीं है। **आखिरी line** पढ़ें; `async with Client(...)` block के **अंदर** `MCPError` catch करने से wrapping पूरी तरह टल जाती है। +* `call_tool` fail होने वाले tool के लिए raise नहीं करता। `Error executing tool ...` और `Unknown tool: ...` results हैं: `result.is_error` जाँचें। +* `Client must be used within an async context manager` -> `async with` इस्तेमाल करें। `Use @tool() instead of @tool` -> parentheses जोड़ें। +* server log में `Tool already exists:` ही इकलौता संकेत है कि एक ही नाम के दो tools सिमटकर एक रह गए। +* एक 421, तीन रूप: `Server returned an error response` (python `Client`), `421 Misdirected Request` / `Invalid Host header` (बाकी सब), `Invalid Host header: ` (server log)। सुधार: `transport_security=TransportSecuritySettings(allowed_hosts=[...])`। +* `Task group is not initialized` -> mounted app जिसके host lifespan ने कभी `mcp.session_manager.run()` में enter नहीं किया। +* `Session not found` -> server restart हुआ; reconnect करें। +* `Cannot send 'elicitation/create': ... no back-channel ...` -> `ctx.elicit()` को server-to-client channel चाहिए: `2026-07-28` connection में वह कभी नहीं होता, `stateless_http=True` legacy वाला छीन लेता है, और `json_response=True` request-scoped वाला। resolver इस्तेमाल करें (legacy client को ऐसा server भी चाहिए जो channel रखता हो)। इसका पड़ोसी `Method not found` ऐसे method की request है जो दूसरी side के protocol revision में है ही नहीं। +* `Client did not declare the form elicitation capability ...` और `Elicitation not supported` -> client में `elicitation_callback=` गायब है। +* `Invalid or expired requestState` wire पर कभी नहीं बताता कि क्यों। server log बताता है; `unknown key` का मतलब है workers के बीच `RequestStateSecurity(keys=[...])` साझा करें। diff --git a/i18n/hi/pages/whats-new.md b/i18n/hi/pages/whats-new.md new file mode 100644 index 0000000000..220fa68ef4 --- /dev/null +++ b/i18n/hi/pages/whats-new.md @@ -0,0 +1,214 @@ +--- +translation: + sections: [cfe01c0c5863dfa2, 11d93f1fa09eadf5, a7392996acf1ad8f, 875eb2889263424e] + tool: 1 +--- +# v2 में नया क्या है {#whats-new-in-v2} + +v2 में दो चीज़ें एक साथ हुईं। **SDK को दोबारा बनाया गया**: client और server दोनों के नीचे नया engine, एक first-class `Client`, और कुछ renames जिनसे v1 codebase का सामना पहले import पर ही हो जाता है। और **protocol आगे बढ़ा**: v2 MCP का 2026-07-28 revision बोलता है, जो connection handshake, session और हर server-initiated request को हटा देता है, वह भी आपके मौजूदा clients को बीच में छोड़े बिना। + +यह page दोनों हिस्सों का tour है, हर headline के लिए एक section, और हर section उस page पर खत्म होता है जो उस विषय का मालिक है। यह porting manual नहीं है। वह **[Migration Guide](migration.md)** है: हर breaking change, पहले और बाद के code के साथ। + +!!! note "v2 ही stable line है" + `pip install mcp` 2.x install करता है, और **[Installation](get-started/installation.md)** में + copy-paste करने लायक install line है। अगर v2 में कुछ टूटता है, चौंकाता है, या आपकी रफ़्तार धीमी करता है, तो + [हमें बताएँ](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml)। + +## SDK: v1 से v2 {#the-sdk-v1-to-v2} + +### `FastMCP` अब `MCPServer` है {#fastmcp-is-now-mcpserver} + +High-level server class का नाम बदला, और उसके module का भी। हर v1 server सबसे पहले इसी से टकराता है, क्योंकि पुराना import path deprecated नहीं, बल्कि हटा दिया गया है: + +```python +from mcp.server import MCPServer # v1: from mcp.server.fastmcp import FastMCP + +mcp = MCPServer("Demo") # v1: FastMCP("Demo") +``` + +Decorator से बने server के लिए port का ज़्यादातर हिस्सा भी बस यही है। `@mcp.tool()`, `@mcp.resource()` और `@mcp.prompt()` वही स्वीकार करते हैं जो v1 में करते थे (`@mcp.resource()` में एक optional `security=` keyword जुड़ा है), और input schema अब भी आपके type hints से आता है। किनारों पर: `mcp.server.fastmcp.*` के नीचे की हर चीज़ अब `mcp.server.mcpserver.*` के नीचे रहती है, `ctx.fastmcp` अब `ctx.mcp_server` है, `get_context()` हटा दिया गया है (उसकी जगह `ctx: Context` parameter declare करें), और exception base `FastMCPError` अब `MCPServerError` है। Import table **[Migration Guide](migration.md#fastmcp-renamed-to-mcpserver)** में है। + +### `Resolve`: user से input माँगने का नया तरीका {#resolve-the-new-way-to-ask-the-user-for-input} + +Tool को जो कुछ चाहिए, वह सब model से नहीं आना चाहिए। v2 में नया: `Resolve(fn)` से annotate किया गया tool parameter model के बजाय आपके लिखे function से भरा जाता है, model को इसकी भनक तक नहीं लगती, और वह function user के सामने सवाल रखने के लिए `Elicit(...)` लौटा सकता है। Call के बीच client से कुछ भी पाने का यही पसंदीदा तरीका है: SDK सवाल को उसी mechanism पर ले जाता है जिसे connection support करता है (legacy client के लिए live elicitation request, 2026-07-28 पर multi-round-trip), इसलिए एक ही tool body दोनों पीढ़ियों को serve करती है। इसका page **[Dependencies](handlers/dependencies.md)** है। + +!!! note + बाकी दो रूप ज़रूरत पड़ने पर अब भी मौजूद हैं: legacy connections पर clients के लिए `ctx.elicit()` अब भी काम करता है + (**[Elicitation](handlers/elicitation.md)**), और handler खुद `InputRequiredResult` लौटाकर + rounds को हाथ से चला सकता है, और 2026-07-28 पर sampling और roots requests भी इसी रास्ते से जाती हैं + (**[Multi-round-trip requests](handlers/multi-round-trip.md)**)। + +### एक first-class `Client` {#a-first-class-client} + +v1 आपको तीन nested परतें थमाता था: raw streams देने वाला transport context manager, उनके चारों ओर लिपटा `ClientSession`, और हाथ से call किया जाने वाला `await session.initialize()`। v2 में एक ही object है: + +```python title="client.py" hl_lines="14-18" +--8<-- "docs_src/client/tutorial001.py" +``` + +`Client` एक server object लेता है (in memory, कोई transport नहीं: testing की कहानी), एक URL (Streamable HTTP), या कोई भी transport context manager जैसे `stdio_client(...)`। `async with` में प्रवेश करते ही connect होता है और protocol version negotiate होता है, server चाहे जिस पीढ़ी का हो; उसके बाद `client.server_capabilities` और `client.protocol_version` बस उपलब्ध रहते हैं, और जब server अपनी पहचान बताता है तो `client.server_info` भी (यह अब `Implementation | None` है, क्योंकि 2026 पीढ़ी में identity optional है)। v1 में register किए गए sampling और elicitation callbacks अब भी काम करते हैं (उनकी bodies में वही snake_case attribute rename दिखता है जो इस page की हर चीज़ में), वे अब 2026-style requests-inside-results (नीचे) का जवाब भी देते हैं, और वे एक-एक करके नहीं, बल्कि concurrently चलते हैं। जिसे low-level surface चाहिए, उसके लिए `ClientSession` अब भी नीचे मौजूद है, और `client.session` उसे आपको देता है; वह भी बदला है (वह नए dispatcher engine पर चलता है, और उसके कुछ अपने signatures बदले हैं), इसलिए नीचे उतरने से पहले **[Migration Guide](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)** पढ़ें। + +**[The Client](client/index.md)** इसका परिचय देता है, **[Client transports](client/transports.md)** connection के तीनों रूप समझाता है, **[Client callbacks](client/callbacks.md)** खुद callbacks को, और **[Testing](get-started/testing.md)** वह in-memory pattern दिखाता है जो v1 के `create_connected_server_and_client_session()` helper की जगह लेता है। + +### Low-level `Server` का नाम नहीं बदला, उसे दोबारा बनाया गया {#the-low-level-server-was-rebuilt-not-renamed} + +अगर आप JSON-RPC layer पर काम करते हैं, तो v2 का "सब कुछ अलग है" वाला हिस्सा यही है। यहाँ वही one-tool server दोनों तरह से है; क्या बदला, यह देखने के लिए markers पर click करें। + + + +```python title="v1" +from typing import Any + +import mcp.types as types +from mcp.server.lowlevel import Server + +server = Server("Bookshop") + + +@server.list_tools() # (1)! +async def list_tools() -> list[types.Tool]: + return [ # (2)! + types.Tool( + name="search_books", + description="Search the catalog by title or author.", + inputSchema={ # (3)! + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + }, + ) + ] + + +@server.call_tool() +async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentBlock]: # (4)! + if name != "search_books": + raise ValueError(f"Unknown tool: {name}") # (5)! + ctx = server.request_context # (6)! + return [types.TextContent(type="text", text=f"Found 3 books matching {arguments['query']!r}.")] # (7)! +``` + +1. Handlers decorators से register होते हैं (call किए गए, parentheses के साथ), server बनने के बाद कभी भी। +2. आप bare `list[Tool]` लौटाते हैं और SDK उसे `ListToolsResult` में लपेट देता है। +3. Python में fields camelCase हैं, और schema **enforce होता है**: SDK आपके function के चलने से पहले `call_tool` arguments को इसके सामने jsonschema-validate करता है, इसीलिए नीचे `arguments["query"]` सुरक्षित है। +4. एक ही `call_tool` handler हर tool को serve करता है, और उसे tool का नाम और पहले से validate किए हुए arguments मिलते हैं, unpack किए हुए और कभी `None` नहीं। +5. v1 tool failure का संकेत raise करके देता है: कोई भी exception पकड़ा जाता है और `CallToolResult(isError=True)` के रूप में लौटाया जाता है, text में `str(e)` के साथ, इसलिए call करने वाला model यह message पढ़ता है और retry कर सकता है। +6. Context एक ambient ContextVar से आता है, जिस तक request के बीच server object के ज़रिए पहुँचा जाता है। +7. Bare content blocks आपके लिए `CallToolResult` में लपेट दिए जाते हैं। + +```python title="v2" +--8<-- "docs_src/whats_new/tutorial001.py" +``` + +1. Fields अब snake_case हैं, और schema **advertise होता है, पर कभी apply नहीं होता**: आपके handler के चलने से पहले arguments को कोई नहीं जाँचता। +2. हर handler का आकार एक जैसा है: `async (ctx, params) -> result`। Context पहला argument है (`ctx.session`, `ctx.request_id`, `ctx.protocol_version` इसी पर रहते हैं); `server.request_context` यहीं गया। +3. पूरा `ListToolsResult` आप खुद बनाते हैं। Bare list लौटाना अब server-side `TypeError` है, SDK उसे लपेटता नहीं। +4. Typed params अंदर (`params.name`, `params.arguments`), पूरा result बाहर। आपके लिए कुछ भी unpack, wrap या convert नहीं किया जाता। +5. वही जाँच, अलग verb। यहाँ `ValueError` model तक एक opaque `-32603` बनकर पहुँचता (नीचे देखें), इसलिए जानबूझकर भेजा जाने वाला wire error `MCPError` के रूप में raise किया जाता है: वह अपने code और message के साथ जस का तस निकल जाता है, और unknown tool के लिए इस text के साथ `-32602` spec का अपना जवाब है। +6. `params.arguments` `None` हो सकता है; v1 इसे आपके code तक पहुँचने से पहले ही `{}` कर देता था। Handler के सामने कोई validation न होने से यह line ज़रूरी है। +7. यहाँ raise हुआ कोई अनपेक्षित exception एक **sanitized** protocol error बनता है, `-32603` `"Internal server error"`: model को message कभी नहीं दिखता। ऐसे failure के लिए जिसे model पढ़े और उस पर प्रतिक्रिया दे, `CallToolResult(is_error=True, ...)` लौटाएँ। +8. Handlers constructor arguments हैं, इसलिए server बनते ही उसकी surface पूरी हो जाती है; `add_request_handler()` construction के बाद का escape hatch है, और custom methods का दरवाज़ा भी। + +यह उदाहरण ही pattern है। और आम तौर पर: हर handler का आकार एक जैसा है, typed params अंदर और पूरा result type बाहर; tool arguments की पुरानी jsonschema जाँच हट गई है; exception एक protocol error है, कभी `is_error=True` tool result नहीं; और ambient `server.request_context` ContextVar हट गया है। Custom, vendor-namespaced methods `add_request_handler(method, params_type, handler)` के ज़रिए first class हैं, जो आपके handler के चलने से पहले inbound params को आपके model के सामने validate करता है। और एक `middleware` list (जानबूझकर provisional चिह्नित) हर inbound message को लपेटती है, उन private `_handle_*` methods की जगह जिन्हें लोग override किया करते थे। + +अंदर ही अंदर, v1 के `BaseSession` receive loop की जगह एक dispatcher engine ने ली है जिसे अब client और server दोनों साझा करते हैं, और इसी की वजह से इस page की कई बातें एक साथ सच हैं: एक ही `Server` object दोनों protocol पीढ़ियों को serve करता है, `Client(server)` बिना JSON-RPC framing के in process dispatch करता है, और timed-out client request अब वाकई server-side handler को cancel करती है। + +इसका page **[The low-level Server](advanced/low-level-server.md)** है; **[Migration Guide](migration.md#lowlevel-server-decorator-based-handlers-replaced-with-constructor-on_-params)** हर हटाए गए hook से गुज़रता है। अगर आप कभी `MCPServer` से नीचे नहीं उतरे, तो इनमें से कुछ भी आपको नहीं छूता। + +### Wire types `mcp-types` में चले गए, और हर field snake_case है {#the-wire-types-moved-to-mcp-types-and-every-field-is-snake_case} + +Protocol types अब अपने अलग distribution, `mcp-types`, में रहते हैं। यह pydantic और typing-extensions के सिवा किसी पर निर्भर नहीं है, इसलिए कोई gateway, proxy या code generator बिना HTTP stack install किए MCP के wire shapes इस्तेमाल कर सकता है: ऐसा project `mcp-types` install करता है और `mcp_types` import करता है। खुद `mcp` उस package पर exact version के साथ निर्भर है और उसे दोबारा expose करता है, इसलिए SDK पर निर्भर code पहले की तरह `import mcp.types as types` और `from mcp.types import Tool` लिखता रहता है (एक स्थायी alias, हर नाम वही object) और सिर्फ़ अपनी एक असली dependency, `mcp`, declare करता है। मोटा नियम: जिस package पर आप वाकई निर्भर हैं, उसी से import करें। + +उन types पर हर Python attribute अब snake_case है: `result.is_error`, `tool.input_schema`, `listing.next_cursor`। Wire पर जाने वाला JSON camelCase है, बिल्कुल पहले जैसा; सिर्फ़ attribute की spelling बदली है। दो और सख्त defaults साथ आते हैं: unknown fields round-trip होने के बजाय ignore किए जाते हैं (extras `_meta` में रखें), और दोनों पक्ष traffic को उस protocol version के सामने validate करते हैं जो उन्होंने negotiate किया। Rename table के लिए **[Migration Guide](migration.md#field-names-changed-from-camelcase-to-snake_case)** देखें। + +### Transport configuration `run()` में चली गई {#transport-configuration-moved-to-run} + +`MCPServer(...)` इस बारे में है कि आपका server **क्या है**: उसका नाम, उसके instructions, उसका lifespan, उसका auth। उसे **serve कैसे** किया जाता है, यह अब `run()` और app builders का काम है, और `host`, `port`, `stateless_http`, `json_response`, endpoint paths और `transport_security` वहीं गए (`MCPServer("x", port=9000)` अब `TypeError` है)। Overloads हर transport के लिए typed हैं, इसलिए आपका editor बताता है कि `stdio` कौन से options लेता है और `streamable-http` कौन से। एक हटाव जानने लायक है: `mount_path` हट गया है; prefix के नीचे serve करने का supported तरीका ASGI app को mount करना है। + +Options के लिए **[अपना server चलाना](run/index.md)** देखें; mounting के लिए **[मौजूदा app में जोड़ना](run/asgi.md)**। + +### बिना import error के बदलने वाला व्यवहार {#behavior-that-changes-without-an-import-error} + +Renames खुद अपनी घोषणा करते हैं। ये नहीं करते: + +* **Sync functions worker thread पर चलते हैं।** `def` tool (या resource, prompt, या resolver) अब event loop को block नहीं करता; बदले में उसकी body अब event-loop thread **पर** नहीं चलती, जो thread-affine code के लिए मायने रखता है। `async def` handlers अछूते हैं। **[Migration Guide](migration.md#sync-handler-functions-now-run-on-a-worker-thread)**। +* **Tool के अंदर raise हुआ `MCPError` (v1 का `McpError`) अब protocol error है।** Model उसे कभी नहीं देखता। बाकी हर exception अब भी `is_error=True` result बनता है जिसे model पढ़ सकता है और उस पर प्रतिक्रिया दे सकता है। यह विभाजन **[Errors संभालना](servers/handling-errors.md)** में है। +* **Results निकलने से पहले validate होते हैं।** हाथ से बना `Tool` जिसका `input_schema` `{}` है, अब `tools/list` में fail होता है (spec को `"type": "object"` चाहिए)। `@mcp.tool()` पर बने servers को यह कभी नहीं दिखता; उनके schemas SDK लिखता है। +* **आपका client जो पाता है उसे validate करता है।** `list_tools()` और `call_tool()` server के जवाब को negotiated protocol version के सामने जाँचते हैं, इसलिए पूरी तरह valid न रहने वाला server, जिसे v1 का ढीला parse सह लेता था, अब `pydantic.ValidationError` raise करता है। अगर आप ऐसे servers से connect करते हैं जो आपके नियंत्रण में नहीं हैं, तो मानकर चलें कि उन्हें खोजने वाले आप ही होंगे; ब्योरा **[Migration Guide](migration.md#client-validates-inbound-traffic-against-the-protocol-schema)** में है। +* **URI templates अब असली RFC 6570 हैं।** `{+path}`, `{?query}` वगैरह काम करते हैं, matching regex-loose के बजाय exact है, और निकाली गई values में path traversal default रूप से reject होता है। ज़्यादा सख्त templates decoration के समय fail होते हैं, पहली request पर नहीं। **[URI templates](servers/uri-templates.md)**। +* **Streamable HTTP lifespan एक बार चलता है**, startup पर, और उसका state हर session और request के बीच साझा होता है। v1 में यह हर session पर एक बार चलता था, और `stateless_http=True` के तहत हर request पर एक बार। Lifespan में बने pools और caches बहुत सस्ते हो जाते हैं; जो कुछ वहाँ per-connection resource लेता था, वह अब handler body में होना चाहिए। **[Lifespan](handlers/lifespan.md)**। +* **`mcp dev` और `mcp install` जो environment spawn करते हैं उसे** आपके installed SDK version पर pin करते हैं। दोनों commands आपके server को नए `uv run --with ...` environment में चलाते हैं, जो पहले `mcp` को उस version के बजाय newest stable release पर resolve करता था जिसके सामने आप develop कर रहे हैं। **[Migration Guide](migration.md#mcp-dev-and-mcp-install-pin-the-spawned-environment-to-your-sdk-version)**। +* **HTTP client अब `httpx` नहीं, `httpx2` है।** Dependency बदलने से यह बदलता है कि आपका code क्या catch करता और pass करता है (`httpx2.AsyncClient`, `httpx2.ConnectError`), और यह भी कि TLS certificates कैसे verify होते हैं: `httpx2` certifi की bundled CA list के बजाय `truststore` के ज़रिए operating system trust store के सामने validate करता है। ज़्यादातर environments को पता भी नहीं चलता; बिना system CA store वाला minimal container, या ऐसा private CA जिसे सिर्फ़ certifi का bundle जानता था, TLS handshake fail करने लगता है। `SSL_CERT_FILE`/`SSL_CERT_DIR` set करें या अपने client को `verify=ssl_context` pass करें। **[Migration Guide](migration.md#httpx-and-httpx-sse-replaced-by-httpx2)**। + +### पूरी तरह हटाए गए {#removed-outright} + +इनमें से हर एक **[Migration Guide](migration.md)** में एक section है: + +* **WebSocket transport**, दोनों तरफ़, और `mcp[ws]` extra। यह कभी MCP specification का हिस्सा नहीं था। +* **Experimental Tasks** API (`mcp.*.experimental`)। 2026-07-28 tasks को core protocol से निकालकर एक official extension में ले जाता है ([SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663)), जिसे यह SDK अभी implement नहीं करता। +* Import paths के रूप में `mcp.shared.version`, `mcp.shared.progress` और `mcp.shared.session` (उस `RequestResponder` stub के साथ जिसे v1 के `message_handler` annotations import करते थे)। (`mcp.types` हटाया **नहीं** गया है: यह standalone `mcp_types` package के स्थायी alias के रूप में बना रहता है।) +* Deprecated `streamablehttp_client` spelling, और `streamable_http_client` से `get_session_id` callback (जो अब ठीक दो streams देता है)। +* `McpError`, जिसका नाम बदलकर **`MCPError`** हुआ, सीधे `(code, message, data)` constructor के साथ। +* `MCPServer.get_context()`, `mount_path=`, और lowlevel `Server` के decorator methods, ContextVar और handler dicts। + +## Protocol: 2025-11-25 से 2026-07-28 {#the-protocol-2025-11-25-to-2026-07-28} + +v2 2026-07-28 revision implement करता है, और यह **दोनों** revisions एक साथ serve करता है: वही `streamable_http_app()` (और वही stdio server) 2025 पीढ़ी के client के `initialize` और 2026 पीढ़ी के client की requests, दोनों का जवाब देता है, बिना कुछ configure किए, बिना कोई flag पलटे, और बिना अलग deployment के। नया revision serve करने से पुराने revision वाला client बीच में नहीं छूटता। आगे वह है जो नया revision खुद बदलता है। + +### न handshake, न session {#no-handshake-no-session} + +2026-07-28 client connection खोलकर, negotiate करके, फिर बात नहीं करता। हर request अपना protocol version, client info और client capabilities `_meta` में साथ ले जाती है, और इकलौती discovery call, `server/discover`, किसी भी दूसरी request जैसी सादी request है। `Client` default रूप से सही काम करता है: वह एक बार `server/discover` probe करता है और अगर server पुराना है तो `initialize` handshake पर लौट आता है। + +Streamable HTTP पर 2026 path में कोई `Mcp-Session-Id` नहीं है, और operational headline यही है: **कोई चीज़ modern request को किसी worker से नहीं बाँधती**, इसलिए सादे round-robin load balancer के पीछे कोई भी replica उसका जवाब दे सकता है। दो ईमानदार शर्तें। आपके 2025 पीढ़ी के clients (आज ज़्यादातर clients यही हैं) अब भी sessions खोलते हैं और उन्हें अब भी वही stickiness चाहिए जो v1 पर चाहिए थी; उनके लिए कुछ नहीं बदलता। और एक चीज़ जो **multi-round-trip** retry को workers के पार ले जानी होती है, वह उसका sealed `request_state` है, जिसकी default key हर process में अलग बनती है, इसलिए scaled-out deployment `RequestStateSecurity(keys=[...])` pass करता है। (`stateless_http=True` का इससे लेना-देना नहीं: वह सिर्फ़ यह तय करता है कि 2025 पीढ़ी के clients कैसे serve हों, और 2026 traffic उसे कभी नहीं पढ़ता; अगर आपने v1 में उसे पहले से set किया है, तो कुछ नहीं बदलता।) + +इसका client वाला पहलू **[Protocol versions](protocol-versions.md)** है, operator की checklist **[Deploy & scale](run/deploy.md)** है (Host allowlist, `request_state` key, replicas के पार notifications), और दोनों पीढ़ियाँ एक साथ serve करने की कहानी **[Legacy clients को serve करना](run/legacy-clients.md)** है। + +### Server client को call नहीं कर सकता: multi-round-trip requests {#the-server-cannot-call-the-client-multi-round-trip-requests} + +2026-07-28 पर हर server-initiated request हट गई है: push elicitation, sampling, `roots/list`। 2026 connection पर उनके लिए कोई channel नहीं है, इसलिए `ctx.elicit()` और `ctx.session.create_message()` वहाँ `NoBackChannelError` के साथ fail होते हैं (legacy clients के लिए वे अब भी काम करते हैं)। + +इसका विकल्प call को पलट देता है। जिस tool को user से कुछ चाहिए, वह सवाल **लौटाता** है (`InputRequiredResult`), client उन्हीं callbacks से उसका जवाब देता है जो उसके पास हमेशा से थे, और call को जवाबों के साथ retry किया जाता है। `Client` यह loop आपके लिए चलाता है। Server पर आप result शायद ही कभी खुद बनाते हैं, क्योंकि एक **[dependency](handlers/dependencies.md)** यह कर देती है: parameter को `Resolve(ask_quantity)` से annotate करें, जहाँ `ask_quantity` आपका लिखा साधारण function है, और SDK उसी mechanism से पूछता है जिसे connection support करता है, legacy session पर live elicitation request या 2026 पर multi-round-trip। एक tool body, दोनों पीढ़ियाँ: + +```python title="dual_era.py" hl_lines="24 37-38" +--8<-- "docs_src/legacy_clients/tutorial001.py" +``` + +वह file पूरी बात एक जगह कह देती है: एक server, एक `Resolve`-backed tool, और एक legacy client तथा एक modern client, दोनों को अपना जवाब मिलता है, in memory। **[Multi-round-trip requests](handlers/multi-round-trip.md)** mechanism समझाता है (`request_state` समेत, जिसे SDK आपके लिए seal और verify करता है); पूछने का हिस्सा **[Elicitation](handlers/elicitation.md)** में है। + +!!! warning "यही वह एक जगह है जहाँ port किए गए v1 server का व्यवहार बदलता है" + आपके अपने tests इससे सबसे पहले टकराते हैं: `Client(mcp)` default रूप से आपके v2 server के सामने 2026-07-28 negotiate करता है, + इसलिए `ctx.elicit()` call करने वाला tool ऐसे test में fail होता है जो v1 पर pass होता था। सवाल को + `Resolve(...)` parameter में ले जाएँ (हर पीढ़ी में चलने वाला), या अगर आपको वाकई push व्यवहार चाहिए तो + test client को `mode="legacy"` पर pin करें। + +### Roots, sampling और protocol logging deprecated हैं; `ping` हटा दिया गया {#roots-sampling-and-protocol-logging-are-deprecated-ping-is-removed} + +[SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) तीन पूरी **capabilities** को हर protocol version पर deprecated करता है: roots, sampling और MCP-level logging (`ctx.info()` वगैरह)। यह ऊपर के गायब back-channel से अलग धुरी है; deprecated सिर्फ़ सलाह है, 2025 पीढ़ी के sessions के सामने सब कुछ काम करता रहता है, और wire पर कुछ नहीं बदलता। जो आपको दिखता है वह `MCPDeprecationWarning` है, जो `UserWarning` है, इसलिए default रूप से print होता है; मानकर चलें कि upgrade के बाद आपका पहला `ctx.info(...)` यही कहेगा। + +`ping` ज़्यादा सख्त है: deprecated नहीं, protocol से हटा दिया गया। Deprecated features के दो standalone methods भी 2026-07-28 पर इसी तरह हटाए गए हैं, `logging/setLevel` और client का `notifications/roots/list_changed`, और progress notifications अब सिर्फ़ server-to-client हैं। + +**[Deprecated features](deprecated.md)** में पूरी table, हर एक का विकल्प, और legacy clients को serve करते समय शांत log चाहिए तो one-line filter है। + +### Change notifications एक stream बन जाते हैं {#change-notifications-become-one-stream} + +2026-07-28 पर standalone HTTP GET stream और `resources/subscribe` की जगह `subscriptions/listen` लेता है: client एक long-lived stream खोलता है और बताता है कि उसे किस तरह के notifications चाहिए। `MCPServer` इसे बिना कुछ configure किए serve करता है; आप `await ctx.notify_resource_updated(uri)` (और `notify_tools_changed()`, वगैरह) से publish करते हैं, एक middleware हर caller के लिए listen request ठुकरा सकता है, और multi-replica deployments एक साझा `SubscriptionBus` लगाते हैं। Client पर `async with client.listen(...)` stream खोलता है: filter keyword arguments के रूप में जाता है, typed change events वापस आते हैं, और `sub.honored` वह subset है जिसे server deliver करने पर राज़ी हुआ। + +Publishing और serving **[Subscriptions](handlers/subscriptions.md)** में है, देखने वाला छोर **[इसके Clients वाले जुड़वाँ page](client/subscriptions.md)** में, और bus **[Deploy & scale](run/deploy.md)** में। + +### बाकी, फटाफट {#the-rest-quickly} + +* **Identity optional, per-message metadata है।** Request-side `clientInfo` `_meta` key optional है (ज़रूरी जोड़ी `protocolVersion` + `clientCapabilities` है), और `serverInfo` `server/discover` result body से बाहर चला गया: servers इसके बजाय उसे हर 2026 पीढ़ी के result के `_meta` में stamp करते हैं ([spec #3002](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3002))। SDK हमेशा stamp करता है; जब server अपनी पहचान नहीं बताता (उदाहरण के लिए, किसी middleware ने key हटा दी) तो `client.server_info` `None` होता है। **[The low-level Server](advanced/low-level-server.md)** wire पर stamp दिखाता है। +* **Requests bodies parse किए बिना route हो सकती हैं।** Modern HTTP requests `Mcp-Method` ले जाती हैं (और तीन tool जैसी calls के लिए `Mcp-Name`); `x-mcp-header` से annotate की गई tool input-schema property को `Mcp-Param-*` header में mirror किया जाता है और server उसे cross-check करता है ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243))। Gateways और rate limiters सिर्फ़ headers पर route कर सकते हैं; नियम **[Migration Guide](migration.md#servers-validate-mcp-param-headers-against-the-request-body-sep-2243)** में हैं। +* **Results cache hints ले जाते हैं।** List और read results `ttlMs` और `cacheScope` declare करते हैं ([SEP-2549](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549)); आप उन्हें `cache_hints=` से हर method के लिए set करते हैं, और `Client` built-in response cache के साथ उनका मान रखता है। जो server कोई hints नहीं भेजता (हर pre-2026 server), उसे जस का तस, uncached traffic दिखता है। **[Caching hints](client/caching.md)**। +* **Extensions first class हैं।** Servers और clients reverse-DNS identifiers के नीचे optional capability bundles declare करते हैं ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)); built-in `Apps` extension (MCP Apps) reference है। **[Extensions](advanced/extensions.md)** और **[MCP Apps](advanced/apps.md)**। +* **Error codes standardized हो गए।** गायब resource `-32602` है, `error.data` में URI के साथ, और नए spec-reserved codes `-32020` (header mismatch), `-32021` (ज़रूरी capability गायब) और `-32022` (unsupported protocol version) के रूप में दिखते हैं। **[Troubleshooting](troubleshooting.md)** ठीक उन्हीं messages के हिसाब से व्यवस्थित है। +* **Authorization को गलत पकड़ना अब मुश्किल है।** Client authorization code के साथ लौटे `iss` को validate करता है ([RFC 9207](https://datatracker.ietf.org/doc/html/rfc9207); आपका `callback_handler` अब `AuthorizationCodeResult` लौटाता है), register करते समय `application_type` भेजता है, और credentials को कभी किसी दूसरे authorization server के सामने replay नहीं करता। Enterprise कोने में नया: [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) identity-assertion flow। **[Migration Guide](migration.md)** हर OAuth बदलाव की सूची देता है; pages **[OAuth for clients](client/oauth-clients.md)** और **[Identity assertion](client/identity-assertion.md)** हैं। +* **हर server traceable है।** OpenTelemetry middleware के रूप में default रूप से चालू आता है: हर request को एक server span मिलता है, और जब तक process कोई exporter configure न करे, इसकी कोई लागत नहीं। जब दोनों छोर SDK चलाते हैं, तो client `_meta` में W3C trace context भी propagate करता है, इसलिए traces जुड़ जाते हैं। **[OpenTelemetry](run/opentelemetry.md)**। + +## v1 से upgrade कर रहे हैं? {#upgrading-from-v1} + +* **[Migration Guide](migration.md)** बदलने वाली हर चीज़ की पूरी, सटीक सूची है; यह page "क्यों" था। +* **v1.x कहीं नहीं जा रहा।** वह maintenance में जाता है, critical fixes और security patches पाता रहता है, और 2026-07-28 spec release की कोई चीज़ उसे नहीं तोड़ती; उसके docs [/v1/](https://py.sdk.modelcontextprotocol.io/v1/) पर हैं। अगर आप `mcp` पर निर्भर कोई library publish करते हैं और migrate करने के लिए तैयार नहीं हैं, तो एक upper bound रखें (उदाहरण के लिए `mcp>=1.28,<2`) ताकि unpinned resolve 1.x पर रहे। +* कुछ खुरदुरा, उलझाने वाला या टूटा हुआ लगा? **[v2 feedback दर्ज करें](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml)**; सब पढ़ा जाता है। diff --git a/i18n/ja/glossary.json b/i18n/ja/glossary.json new file mode 100644 index 0000000000..6c99bc176e --- /dev/null +++ b/i18n/ja/glossary.json @@ -0,0 +1,258 @@ +{ + "keep": [ + "MCP", + "Model Context Protocol", + "MCPServer", + "FastMCP", + "ClientSession", + "Context", + "ctx", + "stdio", + "Streamable HTTP", + "SSE", + "JSON-RPC", + "JSON", + "OAuth", + "PKCE", + "JWT", + "CIMD", + "HTTP", + "HTTPS", + "TLS", + "CORS", + "URI", + "URL", + "ASGI", + "WebSocket", + "API", + "SDK", + "CLI", + "IDE", + "LLM", + "SEP", + "RFC", + "Python", + "TypeScript", + "Node.js", + "PyPI", + "Pydantic", + "Starlette", + "FastAPI", + "uvicorn", + "httpx", + "anyio", + "asyncio", + "trio", + "pytest", + "OpenTelemetry", + "Inspector", + "Claude", + "GitHub", + "VS Code", + "Windows", + "macOS", + "Linux", + "llms.txt", + "2026-07-28", + "2025-11-25", + "2025-06-18", + "2025-03-26" + ], + "terms": [ + { + "source": "tool", + "target": "ツール", + "note": "MCP protocol noun (a server exposes tools). Standard rendering. Wire identifiers such as `tools/call` and `tools/list` are code and stay Latin." + }, + { + "source": "resource", + "target": "リソース", + "note": "MCP protocol noun, and also the general noun (a pool acquired in a lifespan is still リソース). Standard rendering. Never 資源, which is the natural-resources sense; `resources/read` stays Latin.", + "avoid": ["資源"] + }, + { + "source": "prompt", + "target": "プロンプト", + "note": "The MCP feature (a reusable prompt a server exposes) and the everyday word; プロンプト in both senses. Standard rendering. `prompts/get` stays Latin." + }, + { + "source": "sampling", + "target": "サンプリング", + "note": "The (deprecated) client feature that lets a server borrow the client's model. Standard rendering. Never 標本抽出, which is statistical sampling and the wrong sense; the `sampling` capability key and `sampling/createMessage` stay Latin.", + "avoid": ["標本抽出"] + }, + { + "source": "roots", + "target": "ルート", + "note": "The (deprecated) client feature listing workspace folders. Provisional pending native review: ルート also spells \"route\" and \"root path\", so gloss the English on first use per page — ルート(roots). Never ルーツ (ancestry/origins). A `Root` object in code font stays Latin.", + "avoid": ["ルーツ"] + }, + { + "source": "elicitation", + "target": "エリシテーション", + "note": "OPEN QUESTION for native review: there is no established Japanese term for the server asking the user a question mid-request. Provisionally pinned to the transliteration エリシテーション, glossed with the English on its first appearance per page — エリシテーション(elicitation). Do not substitute 誘導 or 引き出し unless review settles on one. `elicitation/create` and the `Elicit` class stay Latin." + }, + { + "source": "capability", + "target": "ケイパビリティ", + "note": "A negotiated protocol capability (what a client or server declared it supports). Provisional pending native review: ケイパビリティ rather than the general-purpose 機能 (feature) or 能力 (ability). The `capabilities` field and keys such as `sampling.tools` stay Latin." + }, + { + "source": "transport", + "target": "トランスポート", + "note": "The connection mechanism (\"every standard transport\" → 標準のトランスポート). Standard rendering; never 輸送 (freight transport) or 輸送手段. The transport names stdio, Streamable HTTP and SSE stay in English.", + "avoid": ["輸送"] + }, + { + "source": "session", + "target": "セッション", + "note": "An MCP session (the negotiated connection state). Standard rendering; never the coinage 会期. `session` objects in code font stay Latin.", + "avoid": ["会期"] + }, + { + "source": "handler", + "target": "ハンドラー", + "note": "The tool, resource or prompt function you register (nav section \"Inside your handler\" → ハンドラーの中で). Standard word; the long-vowel spelling ハンドラー (not ハンドラ) follows the katakana rule in instructions.md and is provisional pending native review." + }, + { + "source": "dependency", + "target": "依存関係", + "note": "The SDK's parameter-injection feature (the \"Dependencies\" page → 依存関係). Provisional pending native review: the pattern name \"dependency injection\" is customarily 依存性の注入, so a page may use that phrase for the pattern while individual dependencies are 依存関係. The `Resolve` marker class stays Latin." + }, + { + "source": "client", + "target": "クライアント", + "note": "An MCP client, and the client side of a connection. Standard rendering; never 顧客 (a customer). The `Client` class name stays Latin in code font.", + "avoid": ["顧客"] + }, + { + "source": "server", + "target": "サーバー", + "note": "An MCP server (the program you build). Standard rendering with the long-vowel mark — サーバー, never サーバ (see instructions.md). The low-level `Server` class stays Latin in code font." + }, + { + "source": "host", + "target": "ホスト", + "note": "The MCP host: the application that embeds the client and drives the model, and also a network host. Standard rendering in both senses; never 宿主 (a biological host).", + "avoid": ["宿主"] + }, + { + "source": "context", + "target": "コンテキスト", + "note": "The generic lower-case word (\"provide context to LLMs\" → LLM にコンテキストを提供する). Provisional pending native review: pin one spelling per corpus — コンテキスト, not コンテクスト. The capitalised `Context` is the SDK object injected as `ctx`; it is on the keep list and stays Latin in prose (\"The Context\" → Context)." + }, + { + "source": "resolver", + "target": "リゾルバー", + "note": "The function attached to a parameter with `Resolve(...)` that computes or asks for its value. Provisional pending native review: the loanword リゾルバー, not 解決器. The `Resolve` class stays Latin.", + "avoid": ["解決器"] + }, + { + "source": "lifespan", + "target": "ライフスパン", + "note": "The server's startup/shutdown scope (the \"Lifespan\" page, as in the ASGI lifespan). Provisional pending native review: the loanword ライフスパン, not 寿命 (the biological sense). 寿命 is not on the avoid list, because the neighbouring English word \"lifetime\" (\"for the lifetime of the host app\") can legitimately render as 寿命 in the same block. The `lifespan` parameter name stays Latin in code font." + }, + { + "source": "deprecated", + "target": "非推奨", + "note": "Advisory status: still works, scheduled for removal later — 非推奨, not 廃止 (which reads as already removed); \"removed\" is 削除. \"Deprecation warning\" → 非推奨の警告; the `MCPDeprecationWarning` class stays Latin. Provisional pending native review." + }, + { + "source": "back-channel", + "target": "バックチャネル", + "note": "The server-to-client request channel that exists only on legacy connections. Provisional coinage pending native review: gloss the English on first use per page — バックチャネル(back-channel). Not the older spelling バックチャンネル." + }, + { + "source": "wire", + "target": "通信路", + "note": "The corpus's light metaphor for the byte stream between client and server (\"stdout is the wire\" → stdout が通信路そのものです; \"invisible on the wire\" → 通信上には現れません; \"the JSON on the wire\" → 実際に送受信される JSON). Provisional pending native review. Never a literal 電線 or ワイヤー.", + "avoid": ["電線"] + }, + { + "source": "era", + "target": "世代", + "note": "\"Protocol era\" (\"a 2025-era client\", \"whatever era the client speaks\") → プロトコルの世代, 2025 年世代のクライアント. Provisional pending native review; not the literal 時代." + }, + { + "source": "legacy", + "target": "レガシー", + "note": "\"A legacy connection/client\" = one negotiated at spec version 2025-11-25 or earlier → レガシー接続, レガシークライアント (prenominal loanword). Provisional pending native review." + }, + { + "source": "multi-round-trip", + "target": "マルチラウンドトリップ", + "note": "The 2026-07-28 request pattern (\"Multi-round-trip requests\" → マルチラウンドトリップリクエスト); a single \"round trip\" → ラウンドトリップ or 往復 by context. Provisional coinage pending native review: gloss the English on first use per page — マルチラウンドトリップ(multi-round-trip). The abbreviation MRTR stays Latin." + }, + { + "source": "handshake", + "target": "ハンドシェイク", + "note": "The initialization handshake (\"the classic handshake\" → 従来のハンドシェイク). The established loanword; never the literal 握手. Provisional pending native review.", + "avoid": ["握手"] + }, + { + "source": "request", + "target": "リクエスト", + "note": "A JSON-RPC or HTTP request (\"the initialize request\" → 初期化リクエスト); the verb is リクエストする or 要求する by context. `Request` types in code font stay Latin. Provisional pending native review." + }, + { + "source": "response", + "target": "レスポンス", + "note": "A JSON-RPC or HTTP response; `Response` types in code font stay Latin. Provisional pending native review." + }, + { + "source": "callback", + "target": "コールバック", + "note": "Client callbacks and OAuth redirect callbacks alike; parameter names such as `sampling_callback` stay Latin. Provisional pending native review." + }, + { + "source": "decorator", + "target": "デコレーター", + "note": "The Python decorators the SDK is built on; `@mcp.tool()` and its siblings are code and stay untouched. Long-vowel spelling per instructions.md. Provisional pending native review." + }, + { + "source": "type hint", + "target": "型ヒント", + "note": "Python type hints (\"from your type hints\" → 型ヒントから). Provisional pending native review." + }, + { + "source": "argument", + "target": "引数", + "note": "A call argument; the declared parameter is パラメーター (see the katakana rule in instructions.md). Provisional pending native review." + }, + { + "source": "return value", + "target": "戻り値", + "note": "A function's return value; the `return` keyword and return annotations are code. Provisional pending native review." + }, + { + "source": "exception", + "target": "例外", + "note": "A raised Python exception (\"raises an exception\" → 例外を送出する); exception class names stay Latin. Provisional pending native review." + }, + { + "source": "async", + "target": "非同期", + "note": "The prose adjective (\"the async runtime\" → 非同期ランタイム, \"an async callback\" → 非同期コールバック); the `async` and `await` keywords in code font stay Latin. Provisional pending native review." + }, + { + "source": "Get started", + "target": "はじめに", + "note": "The nav section that opens the guide, and the title of its index page. \"First steps\" is a separate page inside that section (最初のステップ), so the two need distinct renderings or the sidebar shows the same title twice. Provisional pending native review." + }, + { + "source": "First steps", + "target": "最初のステップ", + "note": "The tutorial page inside the \"Get started\" section; never reuse this rendering for the section itself (see that entry). Provisional pending native review." + }, + { + "source": "Recap", + "target": "まとめ", + "note": "Recurring section heading that closes most pages; one rendering everywhere. Provisional pending native review." + }, + { + "source": "Try it", + "target": "試してみる", + "note": "Recurring section heading above a runnable example; one rendering everywhere, not 試す on some pages and 試してみる on others. Provisional pending native review." + } + ] +} diff --git a/i18n/ja/instructions.md b/i18n/ja/instructions.md new file mode 100644 index 0000000000..f729ff5870 --- /dev/null +++ b/i18n/ja/instructions.md @@ -0,0 +1,177 @@ +# Japanese (ja) — translation instructions + +Target language: Japanese (日本語), directory and URL code `ja`, page language +tag `ja`. This file is sent verbatim with every translation request for this +language, on top of the shared rules in `../general-prompt.md`. The termbase +in `glossary.json` is sent alongside it and wins any terminology conflict with +this file. + +## 1. Register + +Write body prose in the polite です・ます form (敬体), consistently, on every +page — tutorials, reference tables, admonitions and troubleshooting entries +alike. + +- Never mix in だ・である (常体) sentence endings within body text, and do + not escalate into honorifics (尊敬語・謙譲語): 使うときは, not + お使いいただく際には. +- Headings, table headers, content-tab labels and other UI-like fragments + are noun phrases (体言止め) or the plain dictionary form of a verb, never + です・ます: "Run it" → 実行する or 実行方法, "The Context" → Context, + "Handling errors" → エラーの処理. A heading phrased as a question in + English may stay a question in the plain form: "Where does this go?" → + これはどこに置くべきか. +- Instructions to the reader: 〜してください for a step to perform, + 〜します / 〜できます for describing what code does, 〜しないでください + for prohibitions. Prefer 〜です over 〜になります / 〜となります when both + are grammatical. +- The reader is never named. Do not translate "you" / "your" as あなた, + あなたの, 君, ユーザー様: drop the subject, which Japanese does + naturally, or restructure the sentence. "You can pass a schema" → + スキーマを渡せます. Where a subject is unavoidable, name the role — + サーバー, クライアント, ツール, 呼び出し側 — never a pronoun. "Your server" + is サーバー, or 自分のサーバー / 作成中のサーバー only when the ownership + is the point. +- One page, one register: a page that drifts between です・ます and である, + or that reintroduces あなた, is wrong even when each sentence is + acceptable on its own. + +## 2. Voice + +The English source is warm, direct and confident: short sentences, second +person, and the occasional one-line payoff ("That's the whole API."). Carry +that voice into natural Japanese; do not flatten it into formality, and do not +mirror the English word for word. + +- Guide, don't lecture. The reader should feel accompanied by a knowledgeable + colleague, not addressed by a notice. Directness comes from concrete verbs + and plain word order; warmth comes from the polite register itself, + considerate connectives (まず, ここでは, なお) and the occasional + 〜してみましょう / 〜してみてください for an encouraging aside. +- Keep the short payoff sentences short: "That's the whole API." → + API はこれだけです。 — not a formal summary sentence. +- Split long English sentences; follow Japanese rhythm rather than the + source's clause structure, but never merge, drop or reorder the technical + claims themselves. +- Anti-patterns — the stiff, legalistic translationese that Japanese + technical translations drift into by default: no 〜なのである / + 〜のである; no nominalisation chains (〜の実施を行うことにより → + 〜すると); no boilerplate such as 〜するものとします or 〜が求められます + where 〜してください is meant; no stacked ただし / なお clauses; no + needlessly formal kanji where kana reads more easily (できる not 出来る). + The opposite over-correction is also wrong: no よ endings, no + buddy-casual tone, and ね at most sparingly in tutorial prose, never in + reference pages. + +Example — English: "You don't construct it and you don't configure it. You +ask for it." + +- Not this (translationese): 利用者がその構築および構成を実施する必要はなく、 + 要求のみを行うものとする。 +- Not this either (pronoun + casual): あなたはそれを構築しないし、設定もしない。 + 要求するだけだよ。 +- This: 自分で組み立てる必要も、設定する必要もありません。要求するだけです。 + +## 3. Humour and idioms + +- Translate the intent of a joke, aside or idiom, never its words. Recast + it as a friendly plain sentence carrying the same information; if a + lighthearted phrase carries no information at all, keep the sentence brief + and natural rather than inventing a Japanese joke. Never drop the technical + content around it. +- Recurring English tags get fixed renderings: "X has the whole story" / + "The whole story is in X" → 詳しくは X を参照してください; + "That's it. It's just Python." → これだけです。ただの Python です。 +- Idioms take the plain meaning, not the picture: "Out of the box the app + answers **only** requests addressed to localhost." → デフォルトでは、この + アプリは localhost 宛てのリクエストに**だけ**応答します。 — not the literal + 箱から出してすぐ. +- Exclamation marks: drop them by default. Keep a single full-width ! + only where the English is a genuine exclamation of encouragement, never + after a warning or instruction, never doubled, never in a heading. +- Emoji: reproduce an emoji only where the English page has one, in the same + place (the source occasionally closes a step with ✨); never add emoji and + never put one in a heading. + +## 4. Typography + +- Punctuation is full-width 「、」 and 「。」; never 「,」「.」, and never a + half-width `,` or `.` closing Japanese prose. A colon that introduces a + code block, list or example becomes 「:」, or better a complete sentence + ending in 「。」 (次のように書きます。). +- Full-width forms inside Japanese text: 「」 for quoted terms and English + scare quotes, 『』 for nested quotes and titles, ? and ! when kept, and + () always — Japanese parentheses are full-width even when they enclose + only Latin text or code, as in the first-use gloss ルート(roots). +- Widths: kana and kanji full-width, no half-width katakana; Latin letters, + digits and code half-width. Counting uses half-width Arabic numerals + (3 つの答え, not 三つ), except in set phrases such as 一度 or 一部. +- Spacing: insert one half-width space between Japanese text and any + half-width run — an English word, a number, an inline code span, a link + whose text is Latin: Python の型ヒント, `Context` を受け取ります, + MCP サーバー. No space next to 「、」「。」 or full-width brackets + (`ctx.session` を使うと、), and none inside katakana compounds + (エラーメッセージ, ツール呼び出し). This spacing convention is provisional; + apply it uniformly. +- No italics: Japanese type has no true italic, so never wrap Japanese text + in `*…*` or `_…_`. When the English italicises a word that gets + translated, use 「」 or drop the emphasis; keep `**bold**` where the source + has it, and keep the bold on negations (**not** → **ではありません** / + **しません**). Emphasis markers around text that stays in English are + copied as-is. +- Dashes and ranges: an English em-dash aside is recast with 、, () or a + second sentence, not with a ――; ranges use から (3.10 から 3.14), not 〜 + or –. +- Sentence length: one idea per sentence and at most three 「、」. In one + bulleted list, items either all end in 「。」 (complete sentences) or none + do (fragments). +- Line breaks: never put a newline between two Japanese characters, not even + after 「。」 — the renderer turns it into a stray space. Where the English + wraps a paragraph, list item or admonition body over several lines, or + gives each sentence its own line, write the Japanese on one line, sentence + after sentence; block structure and indentation stay as in the source. + +## 5. Terminology pointer + +The glossary is sent separately and takes precedence over anything here. +It holds every term-by-term rendering — the six core MCP nouns and the +everyday computing vocabulary alike — and marks each one as standard, +provisional or an open question; use its renderings and its first-use +glosses exactly as noted. The rules below are the conventions those +renderings assume. + +- Identifiers stay in Latin script exactly as written: class, function, + method, parameter, environment-variable, error and package names, + protocol method names such as `tools/call`, and everything in code font. + Product and standard names, and every term in the glossary's keep list, + stay in English too (MCP, Streamable HTTP, JSON-RPC, OAuth, the SDK's + class names, spec revision dates such as 2026-07-28), always in the + singular: an English plural "s" is dropped, "the APIs" → API. Do not + append a katakana reading after them. +- Text quoted from what the example code prints or displays — an output + line, a log message, a UI label — stays exactly as the code emits it + (usually English); do not translate it or add a Japanese reading. +- A term the glossary marks for a first-use gloss carries the English in + full-width parentheses on its first appearance in a page — ルート(roots), + エリシテーション(elicitation) — and appears alone after that. A glossary + word used as a wire identifier or a key in code font is code and stays + Latin. +- Katakana loanwords take the long-vowel mark for -er, -or and -ar endings: + サーバー (never サーバ), ハンドラー, リゾルバー, ユーザー, パラメーター, + ヘッダー, フォルダー, プロバイダー. Words ending in -y keep their customary + short form: プロパティ, ディレクトリ, ライブラリ, セキュリティ, メモリ. Words + ending in -ware take ウェア: ミドルウェア, ソフトウェア. +- Katakana compounds are written solid, without a space or a 中黒: + エラーメッセージ, プロトコルバージョン (use ・ only between two proper + names). +- Prefer the established loanword over an invented native coinage; the + glossary lists the settled pairs (セッション not 会期, トランスポート not + 輸送手段, ハンドシェイク not 握手). + +## 6. Provisional note + +These conventions are provisional and awaiting review by native +Japanese-speaking contributors. To propose a change — a better rendering, a +rule that produces awkward Japanese, a term that needs pinning — edit this +file, or `glossary.json` next to it, in a pull request. The generated pages +are never edited by hand; they are regenerated from these inputs. diff --git a/i18n/ja/notices.md b/i18n/ja/notices.md new file mode 100644 index 0000000000..10778c969f --- /dev/null +++ b/i18n/ja/notices.md @@ -0,0 +1,20 @@ +--- +translation: + sections: [aff1b3e872b7876a, 4d80558ad052d586, 0bb81f1e62062d26, d5c35dcec50156bc] + tool: 1 +--- +# 翻訳に関するお知らせ {#translation-notices} + +翻訳版ドキュメントサイトでは、すべてのページの冒頭にこれらの注記のいずれかが表示されます。 + +## 機械翻訳 {#translated} + +このページは英語版ドキュメントから自動翻訳されたものであり、正式な版は[英語版のページ](ENGLISH_PAGE)です。不自然な箇所があれば、[翻訳について](TRANSLATIONS_PAGE)で報告の方法を説明しています。 + +## 英語版より古い翻訳 {#outdated} + +この翻訳が作成された後に英語版のページが変更されたため、一部の内容が古くなっている可能性があります。迷ったときは[英語版のページ](ENGLISH_PAGE)を参照してください。翻訳版ドキュメントの仕組みは[翻訳について](TRANSLATIONS_PAGE)で説明しています。 + +## 英語で表示中 {#english} + +このページには現在有効な翻訳がないため、英語で表示しています。翻訳版ドキュメントの仕組みは[翻訳について](TRANSLATIONS_PAGE)で説明しています。 diff --git a/i18n/ja/pages/advanced/apps.md b/i18n/ja/pages/advanced/apps.md new file mode 100644 index 0000000000..497d06beb6 --- /dev/null +++ b/i18n/ja/pages/advanced/apps.md @@ -0,0 +1,121 @@ +--- +translation: + sections: [0355618e5f4d5fe4, 1821eaf50f2d0b64, 82e0b28ebd3abf5a, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487] + tool: 1 +--- +# MCP Apps {#mcp-apps} + +**MCP App** とは、見た目を持つツールのことです。データと並んで、ツールがホストに対話型の画面として描画させる HTML ドキュメントを指し示します。 + +構成要素は 2 つで、常にこの 2 つです。 + +1. **ツール**。ほかのツールと同じように、処理を行ってデータを返します。 +2. **`ui://` リソース**。ホストがそのツールのために表示する HTML を収めます。 + +ツールは `_meta.ui.resourceUri` でリソースを参照します。ホストはそれを `resources/read` で取得し、**サンドボックス化された iframe** に描画し、ツールの結果を `postMessage` 経由でその iframe に送り込みます。サーバーが `ui/*` メッセージを送受信することは一切ありません。そのやり取りはホストと iframe の間のものです。サーバーが提供するのはツールと HTML ドキュメントだけで、演出はホストが担当します。 + +SDK はこれを組み込みの `Apps` 拡張(`io.modelcontextprotocol/ui`)として提供しています。[拡張](extensions.md)になじみがなければ、先にそのページにざっと目を通してください。1 分で済みます。それから戻ってきてください。 + +## 見た目のある時計 {#a-clock-with-a-face} + +```python title="server.py" hl_lines="19 22 30 32" +--8<-- "docs_src/apps/tutorial001.py" +``` + +やることは 4 つです。 + +* `Apps()`:1 つのインスタンスが、UI に紐づくツールとそのリソースをまとめて保持します。 +* `@apps.tool(resource_uri="ui://clock/app.html")`:通常のツールに `_meta.ui.resourceUri` の印を加えたものです。`@mcp.tool()` が受け付けるもの(name、title、description など)はすべてそのまま渡せます。 +* `apps.add_html_resource("ui://clock/app.html", CLOCK_HTML)`:対応するリソースで、`text/html;profile=mcp-app` として提供されます。この MIME タイプこそが、ホストに「これはアプリなので描画せよ」と伝える目印です。 +* `MCPServer("clock", extensions=[apps])`:オプトインします。これでサーバーは `capabilities.extensions` の下で `io.modelcontextprotocol/ui` を公開します。 + +HTML 自体はホストの `postMessage` を待ち受けて結果を表示します。本格的なアプリでは、HTML の中で公式の [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps) ブラウザー SDK を使ってください。生のメッセージイベントの代わりに `ontoolresult`、`callServerTool`、`getHostContext`、`onhostcontextchanged` が使えます。 + +## グレースフルデグラデーション {#graceful-degradation} + +すべてのクライアントがアプリを描画するわけではありません。それが何を意味するかについて、仕様は率直です。 + +> ツールは、UI が利用できる場合でも、意味のある `content` 配列を返さ**なければなりません**。 + +モデルが読むのは `content` で、iframe は人間のためのものです。UI に対応したホストでもテキストの結果はモデルに渡されますし、テキスト専用のクライアントはそれ「だけ」を受け取ります。ですから定番のパターンは「1 つのツール、2 つの答え」です。もう一度 `get_time` を見てください。 + +```python title="server.py" hl_lines="23-27" +--8<-- "docs_src/apps/tutorial001.py" +``` + +`client_supports_apps(ctx)` が `True` になるのは、クライアントが `io.modelcontextprotocol/ui` 拡張を宣言し、**かつ** `mimeTypes` 設定に `text/html;profile=mcp-app` を含めている場合だけです。このフィールドは必須なので、省略したクライアントは該当しません。同じファイルの `main()` が宣言しているのはまさにこれです。ネゴシエーションのクライアント側であり、その結果リッチな答えが返ってきます。 + +!!! warning + `"[Rendered UI]"` のようなプレースホルダーを唯一のコンテンツとして返さないでください。フォールバックのテキストが役に立たなければ、そのツールはテキスト専用のすべてのクライアントにとっても、モデル自身にとっても役に立ちません。きちんと文を書いてください。 + +## iframe を厳しく制限する {#locking-the-iframe-down} + +セキュリティのメタデータはリソース側が持ちます。iframe が何を読み込めるか、どのブラウザー権限を要求するか、どのようにフレーム内に表示されたいか、です。 + +```python title="server.py" hl_lines="9 19-22" +--8<-- "docs_src/apps/tutorial002.py" +``` + +`csp` と `permissions` は**ホストへの要望**であって、サーバーの振る舞いではありません。ホストはそれらをもとに iframe の Content-Security-Policy と Permissions-Policy を組み立てますが、拒否することもあります。許可されたと決めつけず、JS 側で機能検出してください。 + +`ResourceCsp` をフィールドごとに示します(Python の名前、通信上のキー、ホストがそれで何をするか)。 + +| Python | 通信上のキー(`_meta.ui.csp`) | 制御対象 | +|---|---|---| +| `connect_domains` | `connectDomains` | `connect-src`:`fetch` や XHR の接続先 | +| `resource_domains` | `resourceDomains` | `img-src`、`style-src` など:静的アセット | +| `frame_domains` | `frameDomains` | `frame-src`:入れ子の iframe | +| `base_uri_domains` | `baseUriDomains` | `base-uri`:`` が指せる先 | + +`ResourcePermissions`:各フィールドが iframe 用のブラウザー権限を要求します。 + +| Python | 通信上のキー(`_meta.ui.permissions`) | +|---|---| +| `camera` | `camera` | +| `microphone` | `microphone` | +| `geolocation` | `geolocation` | +| `clipboard_write` | `clipboardWrite` | + +!!! note + CSP と権限は**リソース**に置くもので、ツールには決して置きません。仕様のツールメタデータにはそれらの入る場所がなく、そこに置いてもホストは無視します。SDK ではこの間違いをそもそも表現できないようにしています。`@apps.tool()` には `csp` パラメーターが存在しません。 + +### 可視性 {#visibility} + +ツールに `visibility=["app"]` を付けると、「これはモデルのためではなく iframe のために存在する」という意味になります。 + +* `"model"`:モデルが呼び出せます。 +* `"app"`:iframe が(`callServerTool` 経由で)呼び出せます。 +* 省略:両方。これがデフォルトです。 + +フィルタリングは**ホスト**の仕事です。サーバーはアプリ専用のツールもほかのツールと同じように `tools/list` に載せ、ホストがそれをモデルから隠します。サーバー側でフィルタリングしないでください。 + +## SDK が強制するルール {#the-rules-the-sdk-enforces} + +これらはすべて、本番ではなく起動時に失敗します。 + +* `resource_uri` やリソース URI が `ui://...` でない場合、デコレート時または登録時に `ValueError` になります。 +* **対応する登録済みリソースのない** URI に紐づけられたツールは、`MCPServer(extensions=[apps])` が拡張を取り込む時点で `ValueError` になります。`resources/read` で 404 になる HTML を公開するツールは設定ミスなので、構築を拒否します。 +* `@apps.tool()` に `meta={"ui": ...}` を渡すと `ValueError` になります。`_meta["ui"]` はデコレーターの管轄です。`resource_uri=` と `visibility=` で指定してください。ほかの `meta=` キーは問題なく一緒にマージされます。 + +現時点では、TypeScript の ext-apps SDK も FastMCP もこれらをどれも検出しません。ホストより先に自分で気づけるほうがよいと考えています。 + +## インライン HTML の先へ {#beyond-inline-html} + +`add_html_resource` はよくあるケース、つまり HTML の文字列を扱います。それ以外、たとえばディスク上の HTML や生成されたコンテンツでは、リソースを自分で組み立てて渡してください。 + +```python title="server.py" hl_lines="12 18" +--8<-- "docs_src/apps/tutorial003.py" +``` + +`add_resource` は、リソースに MIME タイプが明示されていなければ `text/html;profile=mcp-app` を補い、明示的な不一致は拒否します。ほかの MIME タイプの `ui://` リソースは、どのホストも描画しないリソースだからです。 + +!!! tip + 非推奨のフラットな `_meta["ui/resourceUri"]` キーをまだ読んでいる GA 前のホストを対象にしていますか? 自分でマージしてください。`@apps.tool(resource_uri="ui://x", meta={"ui/resourceUri": "ui://x"})` と書きます。入れ子の `ui` オブジェクトが仕様の形で、フラットなキーはいずれなくなります。 + +## 動かしてみる {#see-it-run} + +`examples/stories/` の `apps` ストーリーは、このページを実行可能なペアにしたものです。UI に紐づく時計ツールを持つサーバーと、Apps をネゴシエートしてツールの `_meta.ui.resourceUri` を読み、HTML を取得してツールを呼び出すクライアントです。 + +```bash +uv run python -m stories.apps.client +``` diff --git a/i18n/ja/pages/advanced/extensions.md b/i18n/ja/pages/advanced/extensions.md new file mode 100644 index 0000000000..5734fafd43 --- /dev/null +++ b/i18n/ja/pages/advanced/extensions.md @@ -0,0 +1,172 @@ +--- +translation: + sections: [05891e7cc1938a13, b3c01a6af28c51ee, 7ffc91f5e38bdfe0, 717d3f235a8333a7, f471a13b2fe5d737, ed6af2df4b656dff] + tool: 1 +--- +# 拡張機能 {#extensions} + +**拡張機能**とは、1 つの識別子の下にまとめられた、オプトイン式の MCP の振る舞い一式です。 + +サーバー側では、ツール、リソース、新しいリクエストメソッドを提供でき、`tools/call` をラップすることもできます。クライアント側では、追加の `tools/call` の結果形状を引き受け(claim)、ベンダー通知を監視できます。それぞれの側が自分の `capabilities.extensions` でアドバタイズし、求めなかった人にとっては何も変わりません。これが契約です([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133))。そして黄金律が 1 つあります。**拡張機能はデフォルトでオフです**。 + +## 拡張機能を使う {#using-an-extension} + +構築時にインスタンスを渡します。 + +```python title="server.py" +--8<-- "docs_src/extensions/tutorial001.py" +``` + +これで完了です。サーバーは `capabilities.extensions` の下で `io.modelcontextprotocol/ui` をアドバタイズし、拡張機能が提供するものをすべて配信するようになります。 + +`Apps` は組み込みのリファレンス拡張機能で、専用のページがあります。**[MCP Apps](apps.md)** を参照してください。 + +!!! note + 拡張機能は構築時に固定されます。後から呼び出す `add_extension` はありません。クライアントが接続している間、サーバーのケイパビリティマップは変わるべきではないからです。 + +ケイパビリティマップは `server/discover` に載って運ばれます。これは **2026-07-28** の経路です。レガシーの `initialize` ハンドシェイクにはこれを載せる場所がないため、レガシークライアントには拡張機能がそもそも見えません。それを前提に設計してください。拡張機能はサーバーを「補強する」ものであり、サーバーを使う唯一の手段になってはいけません。 + +## 独自の拡張機能を書く {#writing-your-own} + +`Extension` をサブクラス化し、必要なものだけをオーバーライドします。どのメソッドにもデフォルトがあります。 + +### 識別子 {#the-identifier} + +```python +--8<-- "docs_src/extensions/tutorial002.py" +``` + +識別子は、仕様の `_meta` キーの文法に従った `vendor-prefix/name` 形式の文字列です。ドット区切りのラベル(それぞれ英字で始まり、英字または数字で終わる)、スラッシュ、そして名前が続きます。**クラスが定義された時点で**検証されるため、タイプミスがサーバーの起動まで放置されることはありません。 + +```text +TypeError: Stamps.identifier must be a `vendor-prefix/name` string +(reverse-DNS prefix required), got 'stamps' +``` + +プレフィックスには自分が管理するドメインを使ってください。`io.modelcontextprotocol/*` は MCP プロジェクト自身が仕様化する拡張機能用です。 + +### ツールの提供 {#contributing-tools} + +役に立つ最小の拡張機能は、ツール 1 つと設定マップ 1 つです。 + +```python title="server.py" hl_lines="17 19-20 22-23 26" +--8<-- "docs_src/extensions/tutorial003.py" +``` + +* `tools()` は `ToolBinding` を返します。サーバーはそれぞれを、自分で `mcp.add_tool(...)` を呼んだ場合とまったく同じように登録します。スキーマ生成も、`Context` の注入も、何もかも同じです。 +* `settings()` は `capabilities.extensions["com.example/stamps"]` にアドバタイズされる値です。設定なしで拡張機能をアドバタイズするには `{}`(デフォルト)を返してください。 +* 拡張機能がサーバーを受け取ることはありません。提供するものをデータとして宣言し、`MCPServer` がそれを消費します。書き換えられる `self.server` はありません。 + +そして `main()` がその証明です。`mcp` に直接つなぐインメモリのクライアントです。 + +```python title="server.py" hl_lines="29-34" +--8<-- "docs_src/extensions/tutorial003.py" +``` + +### 独自メソッドの提供 {#serving-your-own-methods} + +拡張機能は**新しいリクエストメソッド**を登録できます。仕様のメソッドと並んで配信される、独自の動詞(verb)です。 + +```python title="server.py" hl_lines="16-22 31 40-48" +--8<-- "docs_src/extensions/tutorial004.py" +``` + +* `SearchParams` は `RequestParams` をサブクラス化しているため、2026 の `_meta` エンベロープが一様にパースされ、ハンドラーが受け取るのは検証済みのパラメーターであって、生の dict ではありません。クライアントが制御できる値には上限を設けてください。`Field(ge=1, le=100)` は、コードが何かを割り当てる前に、ばかげた `limit` を拒否します。 +* `require_client_extension(ctx, EXTENSION_ID)` がゲートです。拡張機能を宣言しなかったクライアントには `-32021`(必須のクライアントケイパビリティの欠如)エラーが返り、仕様が求める機械可読な `requiredCapabilities` ペイロードが付きます。 +* `protocol_versions=frozenset({"2026-07-28"})` はメソッドを通信路上の 1 つのバージョンに固定します。他のバージョンではクライアントは `METHOD_NOT_FOUND` を受け取ります。そのバージョンにメソッドが存在しないのとまったく同じです。そのクライアントにとっては、実際に存在しません。 + +メソッドは**厳密に追加のみ**です。SDK はこれを実行時ではなく構築時に強制します。 + +* 仕様で定義されたメソッド(`tools/list`、`completion/complete` など)に対する `MethodBinding` は、バインディングの構築時に `ValueError` を送出します。コアの動詞はサーバーのものです。 +* 2 つの拡張機能が同じメソッドをバインドすると、2 つ目の登録時に送出されます。後勝ちはプラグイン同士が互いを壊す原因です。SDK はそうしません。 +* 空の `protocol_versions` セットも送出します。決して配信できないメソッドはバグであって、設定ではありません。 + +### クライアント側 {#the-client-side} + +同じファイルの `main()` に、クライアント側の話がすべて、その両半分とも入っています。 + +```python title="server.py" hl_lines="54-58" +--8<-- "docs_src/extensions/tutorial004.py" +``` + +* `Client(..., extensions=[advertise(EXTENSION_ID)])` が拡張機能を宣言します。宣言は `ClientCapabilities.extensions` になります。2026-07-28 接続では、このマップはリクエストごとの `_meta` エンベロープで運ばれるため、サーバーは**すべての**リクエストでそれを見ます。レガシー接続では `initialize` ハンドシェイクに載ります。サーバーのコードはどちらでも気にしません。`require_client_extension(ctx, ...)` と `ctx.session.check_client_capability(...)` は、どちらの経路でも正しい情報源を読みます。 +* ベンダーメソッドは 1 層下がって `client.session.send_request(...)` を使います。`Client` がファーストクラスのメソッドを増やすのは仕様の動詞に対してだけです。`send_request` はどんな `Request` サブクラスも受け付けるため、ベンダーリクエストはそのまま渡せます。 + +### `tools/call` のインターセプト {#intercepting-toolscall} + +唯一の介入型フックです。ツール呼び出しを監視、短絡、または拒否するには `intercept_tool_call` をオーバーライドします。 + +```python title="server.py" hl_lines="17-24" +--8<-- "docs_src/extensions/tutorial005.py" +``` + +* `params` は検証済みの `CallToolRequestParams` です。生の JSON に触れずに `params.name` と `params.arguments` が手に入ります。どのツール呼び出しが実行されるかを決めるのもこれです。書き換えたコンテキストを `call_next` に渡して変わるのは、ハンドラーが `ctx` 上で観測するものであり、ツールの呼び出しではありません。通信路レベルのリクエスト書き換えは[ミドルウェア](middleware.md)の仕事です。 +* `call_next(ctx)` はチェーンの残りを実行し、ハンドラーの結果を返します。そのまま返す(監視)、別のものを返す(置換)、または `MCPError` を送出する(拒否)のいずれかです。何を返しても、2026 年世代の `serverInfo` アイデンティティスタンプを含め、ハンドラーの結果と同様にシリアライズされるため、短絡するインターセプターが匿名またはスキーマ外のレスポンスを生み出すことはありません。 +* 複数の拡張機能がある場合、インターセプターは登録順にネストします。`extensions=[...]` の最初の拡張機能が最も外側です。 +* デフォルトの実装は素通しで、拡張機能がこのフックを一切オーバーライドしないサーバーでは、素の `tools/call` ハンドラーがそのまま保たれます。使わないもののコストを払うことはありません。 + +このフックがラップするのは `tools/call` だけです。すべてのメッセージに関わる処理には[ミドルウェア](middleware.md)を使ってください。それがミドルウェアの役目です。 + +## クライアント拡張機能を使う {#using-a-client-extension} + +**クライアント拡張機能**は、同じ契約を利用する側から見たものです。1 つの識別子の下にまとめられたクライアント側の振る舞い一式です。インスタンスを `Client(extensions=[...])` に渡し、通常どおりツールを呼び出します。 + +```python title="client.py" hl_lines="66-68" +--8<-- "docs_src/extensions/tutorial006.py" +``` + +`call_tool("buy", ...)` は、他のすべての呼び出しと同様にプレーンな `CallToolResult` を返します。拡張機能が変えたのは次の点です。サーバーは `buy` に対して、最終結果の代わりに `receipt` という**結果の形状**で答えられるようになり、`call_tool` が戻る前に `Receipts` がそれを完了させます(ここでは後続の呼び出しでレシートを引き換えます)。呼び出し側のコードは何も変わりません。 + +拡張機能を外せば、このどれも存在しません。サーバーのゲートは宣言しなかったクライアントを拒否し(エラー -32021)、ゲートを省いたサーバーから届いた引き受け対象の形状は検証に失敗します。認識できない `resultType` に対して仕様が求めるとおりです。通信路の両端で、デフォルトはオフです。 + +クライアント側の振る舞いを**一切持たない**識別子をアドバタイズするには(サーバーがケイパビリティでゲートし、クライアントは何もしない、上の検索クライアントのような場合)、`advertise()` を使います。 + +```python +from mcp.client import advertise + +client = Client(mcp, extensions=[advertise("com.example/search")]) +``` + +## クライアント拡張機能を書く {#writing-a-client-extension} + +`ClientExtension` をサブクラス化し、必要なものだけをオーバーライドします。提供できるものは 3 種類で、それぞれにデフォルトがあります。`settings()`、`claims()`、`notifications()` です。 + +```python title="client.py" hl_lines="17-18 43-44 46-47" +--8<-- "docs_src/extensions/tutorial006.py" +``` + +* 識別子はサーバー側と同じ文法に従い、クラスの定義時に検証されます。 +* `claims()` は `ResultClaim` を返します。通信上のタグ、それをパースするモデル、それを完了させるリゾルバーの組です。モデルは `result_type: Literal["receipt"]` でタグを固定しなければならず、その動詞のコア結果型をサブクラス化してはいけません。どちらも引き受けの構築時に強制されます。`receipt_token` のようなベンダーフィールドはそのまま通信路を流れます。差し替えられた形状はそのままの形でクライアントに届きます。 +* リゾルバーはパース済みのモデルと `ClaimContext` を受け取ります。`ctx.session` は `client.session` と同じ公開ハンドルなので、後続の処理は通常のセッション呼び出しです。戻り値はその動詞の通常の `CallToolResult` です。 +* `settings()` は `ClientCapabilities.extensions[identifier]` にアドバタイズされる値で、`Client` の構築時に一度だけ読み取られます。 + +`notifications()` は、監視するベンダーのサーバー通知を宣言します。 + +```python +def notifications(self) -> Sequence[NotificationBinding[Any]]: + return [NotificationBinding(method="notifications/receipts", params_type=ReceiptEvent, handler=self.on_receipt)] +``` + +ハンドラーは検証済みのパラメーターをディスパッチ順に 1 つずつ受け取ります。監視するだけで、拒否も返信もできません。 + +目立たないルールが 2 つあります。引き受けが有効なのは 2026-07-28 接続だけで、ケイパビリティのアドバタイズもそれに従います。レガシー接続では引き受けは消え、識別子も一緒にアドバタイズから外れるため、自分が拒否してしまう形状を持つ拡張機能をクライアントがアドバタイズすることはありません。また、リゾルバーではなく自分で引き受け対象の形状を受け取りたいときは、`client.session.call_tool(..., allow_claimed=True)` を呼び出してください。このフラグがないと、セッション層の呼び出し側に届いた引き受け対象の形状は `UnexpectedClaimedResult` を送出します。 + +### 拡張機能の動詞 {#extension-verbs} + +拡張機能独自のリクエストメソッドには、クライアント側の登録は不要です。ベンダーリクエスト型は `mcp.types.Request` をサブクラス化し、[独自メソッドの提供](#serving-your-own-methods)と同様に `client.session.send_request` を通ります。追加が 1 つあります。パラメーターのキーを `Mcp-Name` ヘッダーに載せなければならない場合(tasks のような拡張機能の仕様では、その動詞にこれが必要です)、リクエスト型は `name_param` を宣言します。 + +```python title="client.py" hl_lines="22-25 46-47" +--8<-- "docs_src/extensions/tutorial007.py" +``` + +セッションはどの送信経路でも `params["jobId"]` を `Mcp-Name` に反映し、値が欠けている場合は必須ヘッダーを黙って省くのではなく、はっきりとエラーになります。 + +## 拡張機能にできないこと {#what-an-extension-cannot-do} + +提供できる範囲は意図的に**閉じて**います。サーバー側では、設定、ツール、リソース、メソッド、`tools/call` のインターセプター 1 つ。クライアント側では、設定、結果の引き受け、通知のバインディング。拡張機能には次のことができません。 + +* **ホストの内部に手を伸ばすこと。** データを宣言するだけで、サーバーやクライアントへの参照は持ちません。 +* **コアの振る舞いを置き換えること。** 仕様のメソッドとコアの結果タグは構築時に拒否されます(`initialize` はランナーが完全に予約しています)。コアの語彙に隠れた通知バインディングは、代わりに警告を出して沈黙します。 +* **後から登録すること。** `MCPServer(...)` や `Client(...)` が戻った後は、拡張機能の集合はそのまま確定です。 + +これらの壁と戦っているなら、書いているのは拡張機能ではありません。フォークです。壁こそが機能です。`extensions=[Apps(), Stamps()]` を読んだユーザーは、この 2 つが触れた可能性のあるものを「すべて」把握できます。 diff --git a/i18n/ja/pages/advanced/index.md b/i18n/ja/pages/advanced/index.md new file mode 100644 index 0000000000..191fb34c8d --- /dev/null +++ b/i18n/ja/pages/advanced/index.md @@ -0,0 +1,22 @@ +--- +translation: + sections: [ca6988b7503cd2d3] + tool: 1 +--- +# 高度なトピック {#advanced} + +通常のサーバーやクライアントに必要なものは、すべて上のセクションのいずれかにトピック別にまとまっています。このセクションは、`MCPServer` の便利なレイヤーがかえって邪魔になるときに使う抜け道です。 + +* **[低レベルの Server](low-level-server.md)**:`MCPServer` の土台になっているクラスです。スキーマは手書き、ハンドラーは `on_*`、代わりにチェックしてくれるものは何もなく、独自のカスタム JSON-RPC メソッドも定義できます。 +* **[ページネーション](pagination.md)** と **[ミドルウェア](middleware.md)**:どちらも低レベルの `Server` でしかできないことです。 +* **[拡張機能](extensions.md)** と **[MCP Apps](apps.md)**:プロトコルの拡張のための領域です。拡張パッケージをサーバーに組み込むことも、自分で書くこともできます。 + +ここにありそうだと思われるもののいくつかは、実際に使う場所のほうに置かれています。 + +* **認可**は **[サーバーの実行](../run/index.md)** の下にあります。サーバーを保護するのはデプロイする場所だからです。 +* **OAuth**、**ID アサーション**、**複数のサーバー**への接続、そしてレスポンスの**キャッシュ**は、すべて **[クライアント](../client/index.md)** の下にあります。 +* **マルチラウンドトリップ(multi-round-trip)リクエスト**と**サブスクリプション**は **[ハンドラーの中で](../handlers/index.md)** の下にあります。どちらもハンドラーが「行う」ことだからです。 +* **URI テンプレート**は **[サーバー](../servers/index.md)** の下、リソースの隣にあります。 +* **[プロトコルバージョン](../protocol-versions.md)** と **[非推奨の機能](../deprecated.md)** には、それぞれ専用のトップレベルページがあります。 + +このセクションが必要かどうか迷っているなら、必要ありません。 diff --git a/i18n/ja/pages/advanced/low-level-server.md b/i18n/ja/pages/advanced/low-level-server.md new file mode 100644 index 0000000000..1b6be6fcbd --- /dev/null +++ b/i18n/ja/pages/advanced/low-level-server.md @@ -0,0 +1,206 @@ +--- +translation: + sections: [2c79b6338e09b7ac, 7edc43b3fae11314, 1086e77ce561cd7f, a3f71823df5efc31, 9fc7109f72201cae, 7bf25983df655b66, 6330e1f4c6029683, 2f1749c8c133fa1c, b3530fcf4d11fd56, ebc33704fbd74262, cd0e9c933350390e] + tool: 1 +--- +# 低レベルの Server {#the-low-level-server} + +`@mcp.tool()` はひとつの層です。その下には 2 つ目のサーバークラス `Server` があり、生の MCP を話します。プロトコルオブジェクトを渡すと、それをそのまま通信路に載せます。 + +`MCPServer` はその上に作られています。便利な層が邪魔になるときは、下に降ります。 + +* Python のシグネチャから導出されたものではなく、**正確な**スキーマ(ファイルから読み込んだもの、データベースから生成したもの)を出力する必要がある。 +* 結果を完全に制御する必要がある。`_meta`、`is_error`、`structured_content` のすべてのキー。 +* MCP が定義していないメソッドを扱う必要がある。 + +それ以外はすべて、`MCPServer` のままで構いません。 + +## 同じツールを手書きする {#the-same-tool-by-hand} + +これは **[ツール](../servers/tools.md)** が `@mcp.tool()` を使って 9 行で書いている `search_books` ツールから、糖衣構文を取り除いたものです。 + +```python title="server.py" hl_lines="22 26 32" +--8<-- "docs_src/lowlevel/tutorial001.py" +``` + +変わったのは 3 つで、それが低レベル API のすべてです。 + +* **ハンドラーはコンストラクターのパラメーターです。** `on_list_tools=` と `on_call_tool=` を `Server(...)` に渡します。この層にデコレーターはなく、すべてのハンドラーが同じ形 `async (ctx, params) -> result` をしています。 +* **入力スキーマは自分で書きます。** `Tool.input_schema` は素の JSON Schema の `dict` です。誰も型ヒントから導出してはくれません。導出元になる型ヒントがないからです。 +* **結果は自分で組み立てます。** `CallToolResult(content=[TextContent(...)])` を手で書きます。ラップされるものも、変換されるものも、戻り値のアノテーションから推論されるものもありません。 + +`params` はパース済みのリクエストです。`CallToolRequestParams` からは `.name` と `.arguments` が取れます。`ctx` は `ServerRequestContext` です。クライアントに話しかけるための `ctx.session`、`ctx.lifespan_context`、`ctx.request_id`、そして受信したリクエストの `_meta` である `ctx.meta` があります。 + +!!! info + FastAPI を使ったことがあれば、この関係はもう知っています。`MCPServer` はデコレーターと型ヒントの層で、`Server` はその下の Starlette です。両者は競合するものではありません。`MCPServer` は `Server` を構築し、まさにこのようなハンドラーをそこに登録します。 + +### 試してみる {#try-it} + +これには Inspector がありません。`mcp dev` と `mcp run` は `MCPServer` しか受け付けないからです。インメモリの `Client` は気にしません。`MCPServer` を受け取るのとまったく同じように、低レベルの `Server` を受け取ります。 + +```python title="main.py" +import asyncio + +from mcp import Client + +from server import server + + +async def main() -> None: + async with Client(server) as client: + result = await client.call_tool("search_books", {"query": "dune", "limit": 5}) + print(result.content) + + +asyncio.run(main()) +``` + +```text +[TextContent(type='text', text="Found 3 books matching 'dune' (showing up to 5).", annotations=None, meta=None)] +``` + +`@mcp.tool()` 版が出力したのと同じテキストです。正直に言うと、違いが 2 つあります。 + +* `result.structured_content` は `None` です。高レベルのサーバーは `-> str` を `{"result": ...}` にラップしてくれますが、ここでは自分で組み立てなかったものを誰も組み立ててくれません。 +* `list_tools` は**自分で**打ち込んだスキーマを一字一句そのまま返します。高レベル版にはすべてのプロパティに `"title": "Query"` があり、ルートに `"title": "search_booksArguments"` がありました。Pydantic の産物です。この層では、通信上に現れるものはすべて自分が載せたものです。 + +## 何もチェックされない {#nothing-is-checked-for-you} + +`MCPServer` は、生成したスキーマに照らして呼び出しを検証し、関数が実行される前に不正な引数を拒否します(**[ツール](../servers/tools.md)**)。 + +`Server` はそれをしません。`input_schema` はクライアントに「公開」されますが、`params.arguments` に「適用」されることは決してありません。 + +!!! check + `limit` なしで `search_books` を呼び出すと、`args["limit"]` が `KeyError` を送出します。クライアントに見えるのは次のとおりです。 + + ```text + MCPError: Internal server error + ``` + + JSON-RPC エラー、コード `-32603`、メッセージは意図的に一般的なものです。SDK はトレースバックをリモートの呼び出し側に漏らしません。モデルは自分が何を間違えたのか知ることができないので、再試行もできません。(テストでは、`raise_exceptions=True` を指定すると代わりに本当の例外が表に出ます。**[テスト](../get-started/testing.md)** を参照してください。) + +これは一般化できます。低レベルのハンドラーから送出された例外は**常に**プロトコルエラーであり、`is_error=True` のツール結果になることはありません。モデルに失敗を読ませて回復させたいなら、`params.arguments` を自分で検証し、`CallToolResult(content=[TextContent(...)], is_error=True)` を返してください。この 2 種類の失敗が **[エラーの処理](../servers/handling-errors.md)** の主題です。 + +## 2 つのツール、1 つのハンドラー {#two-tools-one-handler} + +`on_call_tool` はサーバー上のすべてのツールの唯一の入り口です。`params.name` で振り分けます。 + +```python title="server.py" hl_lines="38-43" +--8<-- "docs_src/lowlevel/tutorial002.py" +``` + +* `list_tools` は両方を公開します。`call_tool` は名前でディスパッチします。 +* `else` 分岐は重要です。`Server` は、一度もリストに載せていない名前への `tools/call` でも、そのままハンドラーに転送してしまいます。そこで例外を送出すると、呼び出しは上と同じ `-32603` になります。 + +## 構造化出力を手書きする {#structured-output-by-hand} + +`Tool` に `output_schema` を宣言し、結果に `structured_content` を載せます。どちらも自分の責任です。 + +```python title="server.py" hl_lines="19-23 36" +--8<-- "docs_src/lowlevel/tutorial003.py" +``` + +呼び出すと、結果は両方の表現を持ちます。 + +```json +{ + "content": [{"type": "text", "text": "Found 3 books matching 'dune'."}], + "structuredContent": {"matches": 3, "query": "dune"}, + "isError": false, + "resultType": "complete", + "_meta": {"io.modelcontextprotocol/serverInfo": {"name": "Bookshop", "version": "2.0.0"}} +} +``` + +`_meta` ブロックはサーバーの識別スタンプです。SDK は 2026 年世代のすべての結果にこれを追加し、`version` はコンストラクターの値を使います(何も設定していないサーバーは空文字列を報告します)。自身を識別してはならないサーバーは、返す結果を所有するミドルウェアでこのキーを取り除けます。 + +サーバーは 2 つのフィールドを比較しません。この SDK の `Client` は比較します。宣言した `output_schema` を満たさない `structured_content` を返すと、`call_tool` は `Invalid structured content returned by tool search_books` で始まり、続けて `jsonschema` の失敗内容を引用する `RuntimeError` を送出します。スキーマを約束するのは簡単ですが、守るのは自分の仕事です。戻り値の型とスキーマの全段階については **[構造化出力](../servers/structured-output.md)** を参照してください。 + +## `_meta`:モデルではなくアプリケーションのために {#\_meta-for-the-application-not-the-model} + +`content` は答えのうちモデルが読む部分です。`structured_content` は同じ答えを型付きデータにしたものです。`_meta` は 3 つ目のチャネルで、答えの一部ではまったくなく、**クライアントアプリケーション**のために結果に同乗するデータです。 + +レコード ID、トレース ID など、UI が必要としプロンプトが必要としないものに使います。 + +```python title="server.py" hl_lines="37" +--8<-- "docs_src/lowlevel/tutorial004.py" +``` + +* 構築するときは通信路上の名前である `_meta=` を使います。クライアントは `result.meta` として読み出します。 +* キーには名前空間を付けてください(`bookshop/record_ids`)。`io.modelcontextprotocol/*` のキーはプロトコルが予約しています。 + +!!! warning + `_meta` はサーバーとクライアントアプリケーションの間の取り決めであり、何がモデルに届くかについての保証ではありません。何を描画するかはホストが決めます。ツール結果のどの部分にも、決して秘密情報を入れないでください。 + +## ケイパビリティはハンドラーに従う {#capabilities-follow-your-handlers} + +`Server` は、ハンドラーを渡したメソッド群だけを正確に公開します。上の `Bookshop` は `on_list_tools` と `on_call_tool` だけを渡しているので、接続したクライアントには次のように見えます。 + +```json +{"tools": {"listChanged": false}} +``` + +`resources` も `prompts` もありません。裏付けるものがないからです。`on_list_prompts` を渡せば `prompts` が現れ、`on_completion` を渡せば `completions` が現れます。 + +`MCPServer` は、何かを登録したかどうかにかかわらず、常にツール、リソース、プロンプトを公開します。そのマネージャーが常に存在するからです。この層では、宣言とはコンストラクター呼び出しそのものです。 + +## ライフスパンのジェネリック {#the-lifespan-generic} + +`Server` は、そのライフスパンが yield する型についてジェネリックです。一度アノテーションを付ければ、そのオブジェクトは現れる場所すべてで型が付きます。 + +```python title="server.py" hl_lines="24-26 44-45 50" +--8<-- "docs_src/lowlevel/tutorial005.py" +``` + +* ライフスパンは `Callable[[Server[Catalog]], AbstractAsyncContextManager[Catalog]]` です。`async` ジェネレーターに `@asynccontextmanager` を付けると、まさにそれが得られます。 +* `yield` したものが `ctx.lifespan_context` になり、ハンドラーに `ServerRequestContext[Catalog]` とアノテーションが付いているので、`.search(...)` が補完され、型チェックされます。 +* サーバーの起動時に一度入り、停止時に一度出ます。起動、後始末、そして同じ考え方の `MCPServer` 版については **[ライフスパン](../handlers/lifespan.md)** を参照してください。 + +`lifespan=` がなければ、`ctx.lifespan_context` は空の `dict` です。 + +## 独自のメソッド {#a-method-of-your-own} + +コンストラクターは MCP が定義するメソッドを扱います。`add_request_handler` はそれ以外のすべてを扱います。 + +```python title="server.py" hl_lines="35-36 39-40 43-44 48" +--8<-- "docs_src/lowlevel/tutorial006.py" +``` + +* 最初の引数はメソッド文字列です。通知には対になる `add_notification_handler` があります。 +* `params_type` は、受信した `params` をハンドラーの実行**前**に検証するためのモデルです。つまり、カスタムメソッドはツールが受けられない検証を受けられます。`_meta` フィールドがほかのメソッドと同じようにパースされるよう、`RequestParams` をサブクラス化してください。 +* ハンドラーは `BaseModel`、`dict`、または `None` を返します。SDK がそれを JSON-RPC の結果にシリアライズします。 + +正直な注意点が 1 つあります。高レベルの `Client` には MCP が定義するメソッドの動詞しかないので、`client.reindex()` はありません。ベンダーメソッドは、その存在をすでに知っている相手のためのものです。一緒に配布するクライアントや、JSON-RPC を話す自前の別のサービスなどです。 + +自分のものにできないメソッドが 1 つあります。 + +```text +ValueError: 'initialize' is handled by the server runner and cannot be overridden; +use Server.middleware to observe or wrap initialization +``` + +ハンドシェイクはランナーのものです。`server/discover`、`ping`、その他すべての組み込みは自由に置き換えられます。 + +!!! tip + このエラーで言及されている `Server.middleware` は、`initialize` を含む**すべての**受信メッセージをラップします。新しいメソッドに応答するのではなく、トラフィックを観察したり書き換えたりしたいなら、**[ミドルウェア](middleware.md)** から始めてください。 + +## その他のハンドラー {#the-other-handlers} + +以下はどれも、ここまでで身につけた語彙で理解できる考え方です。それぞれに専用のページがあります。 + +* `on_call_tool`、`on_get_prompt`、`on_read_resource` は、通常の結果の代わりに `InputRequiredResult` を返して呼び出しを一時停止し、クライアントに入力を求めることができます。**[マルチラウンドトリップ(multi-round-trip)リクエスト](../handlers/multi-round-trip.md)** を参照してください。この層らしく、何も代わりにインストールされません。`MCPServer` はデフォルトで `requestState` を封印しますが、ここでは設定した `request_state` は書いたとおりに通信路を渡ります。`server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[...]), default_audience=server.name))` でオプトインするまではそうです。この 1 行(どちらの名前も `mcp.server.request_state` からインポートします)で、`MCPServer` が行うのとまったく同じ封印と検証が得られます(**[`requestState` の保護](../handlers/multi-round-trip.md#protecting-requeststate)**)。 +* `on_list_resources`、`on_read_resource`、`on_list_prompts`、`on_get_prompt`、`on_completion` は、ほかのプリミティブ向けの同じ `(ctx, params) -> result` の形です。 +* `on_subscriptions_listen` は 2026-07-28 の `subscriptions/listen` ストリームを提供します。`SubscriptionBus` の上に構築した `ListenHandler` を渡し、ほかのハンドラーからバスにイベントを発行してください。全体の組み立て方については **[サブスクリプション](../handlers/subscriptions.md)** を参照してください。 +* `server.streamable_http_app()` は `MCPServer` のものと同じ Starlette アプリを返します。**[サーバーの実行](../run/index.md)** がほかの ASGI アプリをデプロイするのと同じ方法でデプロイしてください。この層には `server.run(transport=...)` はありません。`server.run(read_stream, write_stream, server.create_initialization_options())` が 1 組のストリーム上で 1 つの接続を駆動し、その 1 行がすべてです。 + +## まとめ {#recap} + +* 低レベルの `Server` はハンドラーを `on_*` の**コンストラクターパラメーター**として受け取ります。すべてのハンドラーは `async (ctx, params) -> result` です。 +* `input_schema` の dict は自分で書き、`CallToolResult` は自分で組み立てます。導出も、ラップも、検証も、代わりにしてくれるものはありません。 +* ハンドラー内の例外は `-32603` のプロトコルエラーです。モデルが読めるツールエラーは、`is_error=True` を付けて**自分で**返す `CallToolResult` です。 +* 結果の `_meta` はモデルではなくクライアントアプリケーション宛てです。 +* `Server[T]` はライフスパンが yield するものについてジェネリックで、`ctx.lifespan_context` は型付きの `T` です。 +* `add_request_handler(method, params_type, handler)` は任意のメソッドを提供します。`initialize` は予約されています。 +* `Server` が公開するケイパビリティは、どのハンドラーを登録したかから導出されます。 + +`Client(server)` が両方のサーバーを同じように扱ったのは、両者がまさに同じプロトコルだからであり、それこそが要点です。さらに下の層はクラスですらありません。**[ミドルウェア](middleware.md)** です。 diff --git a/i18n/ja/pages/advanced/middleware.md b/i18n/ja/pages/advanced/middleware.md new file mode 100644 index 0000000000..40af1070b8 --- /dev/null +++ b/i18n/ja/pages/advanced/middleware.md @@ -0,0 +1,84 @@ +--- +translation: + sections: [6048b4f308edbb8c, 068bda0f21ee9c1b, c3e565b61acd75c5, c62422b159c6ed09, 47204fab253cc45c] + tool: 1 +--- +# ミドルウェア {#middleware} + +**ミドルウェア**とは、サーバーが受け取るすべてのメッセージを包み込む 1 つの非同期関数です。 + +`async (ctx, call_next)` の形で書き、`server.middleware` に追加します。API はこれだけです。 + +!!! warning + ミドルウェアのリストは、ソース上で**暫定(provisional)**とマークされています。シグネチャやセマンティクスは 2.x のマイナーリリースで変わる可能性があります。メッセージを「観察」する(計時、ログ、トレース)ため、あるいは「拒否」するために使ってください。サーバーの土台にはしないでください。 + +`MCPServer` は構築時にこのリストを受け取り(`MCPServer(name, middleware=[...])`)、`mcp.middleware` として公開します。低レベルの `Server` も同じリストを `server.middleware` として公開します。以下の例では低レベルの `Server` を使います。`Server(name, on_call_tool=...)` に馴染みがなければ、先に**[低レベルの Server](low-level-server.md)** を読んでください。 + +## 計時ミドルウェア {#a-timing-middleware} + +サーバー 1 つ、ツール 1 つ、そして各メッセージにかかった時間をログに出すミドルウェア 1 つです。 + +```python title="server.py" hl_lines="39-45 49" +--8<-- "docs_src/middleware/tutorial001.py" +``` + +* `ctx` はハンドラーが受け取るのと同じ `ServerRequestContext` です。`ctx.method` は生のメソッド文字列、`ctx.params` はバリデーション**前**の生のパラメーターです。 +* `call_next(ctx)` はチェーンの残り、つまりバリデーション、ハンドラーの検索、ハンドラー本体を実行します。返ってきたものをそのまま返せば、レスポンスには手が加わりません。 +* `try`/`finally` は意図的なものです。ハンドラーが例外を送出しても計時されます。失敗は `call_next` から出てくる例外としてミドルウェアに届くからです。 +* `server.middleware.append(...)` で登録します。リストは外側から順に実行されるので、`middleware[0]` が通信路に最も近いミドルウェアです。 + +### 試してみる {#try-it} + +クライアントを接続し、ツールを一覧し、1 つ呼び出してください。ログには **3 行**出ます。 + +```text +server/discover took 18.3 ms +tools/list took 0.1 ms +tools/call took 0.1 ms +``` + +呼び出しは 2 回なのに、行は 3 つです。最初の行は `server/discover`、つまり何かを要求する前に、クライアントが接続をセットアップするために送ったリクエストです。 + +ここがポイントです。ミドルウェアは受信する**すべての**メッセージを包みます。 + +* 接続のセットアップ。`server/discover`、あるいはレガシーセッションでは `initialize` と `notifications/initialized` です。 +* すべてのリクエストとすべての通知。通知の場合は `ctx.request_id is None` であり、`call_next(ctx)` は `None` を返し、何を返しても破棄されます。 +* サーバーにハンドラーがないメソッドでさえ対象です。`call_next` は `MCPError(-32601, "Method not found")` を送出し、それがミドルウェアを「通り抜けて」クライアントへ向かいます。 + +## ミドルウェアの中でできること {#what-you-can-do-inside-one} + +ためらうべき度合いが小さいものから順に並べます。 + +* **観察する。** 時間を計る、数える、ログに出す。上の例がこれです。 +* **拒否する。** `call_next(ctx)` を呼ぶ「代わりに」`MCPError` を送出すると、そのメッセージ 1 つに JSON-RPC エラーで応答します。接続は維持され、次のメッセージは通ります。サーバーが呼び出し側ごとに `subscriptions/listen` を制御するのはこの方法です。サブスクリプションのページの**[誰が監視できるかを決める](../handlers/subscriptions.md#deciding-who-may-watch)**で順を追って説明しています。 +* **書き換える。** `ctx` はデータクラスです。`await call_next(dataclasses.replace(ctx, params=...))` とすると、クライアントが送ったものとは異なるパラメーターをチェーンの残りに渡せます。`initialize` に対しては決して行わないでください。クライアントが受け取る結果は書き換えたパラメーターから組み立てられますが、サーバーは元の通信路上のパラメーターから接続状態を確定します。両者が、ネゴシエートした内容について食い違ったままハンドシェイクを終える可能性があります。 +* **応答する。** `call_next(ctx)` を呼ばずに結果を返すと、それがレスポンスとしてクライアントへ送られます。`call_next` が渡してくるのは完成した送信形式であり、パイプラインは返したものに一切手を加えないので、エンベロープ全体が自分の責任になります。2026 年世代の接続ではこれに `serverInfo` の `_meta` スタンプが含まれます。SDK はハンドラーの結果にはこれを付けますが、ミドルウェアが返すものには付けません。 + +!!! check + `initialize` もミドルウェアが包むものの 1 つであり、ミドルウェアはそのための「唯一の」フックです。`add_request_handler` で乗っ取ろうとすると、SDK は拒否します。 + + ```text + ValueError: 'initialize' is handled by the server runner and cannot be overridden; + use Server.middleware to observe or wrap initialization + ``` + +!!! warning + `initialize` はインラインで処理されます。ミドルウェアチェーンが返るまで、サーバーはそれ以上の受信メッセージを読みません。そのため、`initialize` の処理中にサーバーからクライアントへのリクエスト(`ctx.session.send_request(...)` やエリシテーション(elicitation))を await すると、**接続がデッドロックします**。待っているレスポンスは決して読まれないからです。送りっぱなしの通知は問題ありません。 + +## デフォルトで有効な唯一のミドルウェア {#the-one-middleware-that-ships-on-by-default} + +SDK が同梱するミドルウェアはちょうど 1 つで、すでにサーバーのリストに載っています。すべてのメッセージに対して OpenTelemetry のスパンを発行するミドルウェアです。自分で追加する必要はなく、ほとんどの場合は意識することもありません。エクスポーターをインストールするまでは何もしません。専用のページがあります。**[OpenTelemetry](../run/opentelemetry.md)** を参照してください。 + +!!! info + ASGI ミドルウェアを書いたことがあれば、この形はもう知っています。Starlette の `(scope, receive, send)` が `(ctx, call_next)` になり、トランスポートの「後」で、生の HTTP リクエストではなくデコード済みのメッセージに対して動きます。2 つは組み合わせられます。`streamable_http_app()` 上の Starlette ミドルウェアは HTTP を見て、こちらは MCP を見ます。 + +## まとめ {#recap} + +* ミドルウェアは `async (ctx, call_next) -> result` です。`MCPServer(middleware=[...])` として渡すか(または `mcp.middleware` に追加し)、低レベルの `Server` では `server.middleware` に追加します。 +* 受信する**すべての**メッセージ(`server/discover`、`initialize`、リクエスト、通知、未知のメソッド)を包み、外側から順に実行されます。 +* `ctx.request_id is None` で、通知とリクエストを見分けます。 +* `call_next` を呼ぶ代わりに例外を送出すると、メッセージを 1 つ拒否できます。接続は維持されます。 +* SDK 自身の OpenTelemetry トレースもミドルウェアであり、すでにリストに載っています。**[OpenTelemetry](../run/opentelemetry.md)** を参照してください。 +* この仕組み全体が暫定です。観察には使っても、その上に何かを築かないでください。 + +リクエストを包むものはこれですべてです。**[認可](../run/authorization.md)**は、そもそもそのリクエストを実行させるかどうかを決めるものです。 diff --git a/i18n/ja/pages/advanced/pagination.md b/i18n/ja/pages/advanced/pagination.md new file mode 100644 index 0000000000..fb8c489026 --- /dev/null +++ b/i18n/ja/pages/advanced/pagination.md @@ -0,0 +1,81 @@ +--- +translation: + sections: [a9aba7a026c7bd85, ed32bda7ba9ae33a, 7e64cc5646abb91f, 22a0129ee78b3c63, d875373c06d8d2f9] + tool: 1 +--- +# ページネーション {#pagination} + +ほとんどのサーバーには必要ありません。 + +`MCPServer` はすべての `list_*` リクエストに対して、持っているものを全部 1 ページにまとめ、`next_cursor=None` で返します。ツールやリソース、プロンプトが数十個程度なら、それが正しい答えであり、設定することは何もありません。 + +ページネーションは、リソース一覧が実質的にデータベースであるようなサーバーのためのものです。数千行もあり、1 つのレスポンスにシリアライズするわけにはいかない場合です。プロトコルの答えは**カーソル**です。サーバーはページと不透明なトークンを返し、クライアントはそのトークンを送り返して次のページを取得します。 + +`@mcp.resource()` にはそのためのフックがありません。ページングするには、**[低レベルの Server](low-level-server.md)** の上で、リストハンドラーを自分で書きます。 + +## ページングするサーバー {#a-server-that-pages} + +```python title="server.py" hl_lines="12 15-16" +--8<-- "docs_src/pagination/tutorial001.py" +``` + +* 低レベルの `Server` では、ハンドラーはデコレーターではなくコンストラクターの引数です。`on_list_resources` がすべての `resources/list` リクエストに応答します。接続の仕組みはこれだけです。 +* ページングするハンドラーはすべて `params: PaginatedRequestParams | None` という型で、この例は両方を受け付けます。ただし接続越しでは、SDK が `None` を渡すことはありません(`params` メンバーのないリクエストは、デフォルト値を持つモデルとしてハンドラーに届きます)。したがって重要なシグナルは `params.cursor is None`、つまり**先頭から始める**ということです。 +* カーソルが「何であるか」は自分で決めます。ここでは文字列として表現したオフセットです。タイムスタンプでも主キーでも base64 のかたまりでもよく、返すときに発行でき、戻ってきたときに認識できるものなら何でもかまいません。 +* `next_cursor=None` が「これが最後のページでした」と伝える方法です。件数も、合計も、`has_more` もありません。`None` がシグナルのすべてです。 + +!!! tip + `PAGE_SIZE` を 10 にしているのは、例を読みやすくするためです。実際の値はエンドポイントごとに選んでください。1 行のリソースが並ぶ一覧なら 1 ページ 500 件でも問題ありませんが、大きなプロンプトテンプレートの一覧ではそうはいきません。クライアントに選択の余地はなく、それは意図された設計です。 + +### 試してみる {#try-it} + +`Client(server)` は、`MCPServer` に接続するのとまったく同じように、低レベルの `Server` にメモリ内で接続します。 + +引数なしで `list_resources()` を呼び出してください。`book-1` から `book-10` までの 10 個のリソースが返り、`next_cursor` は文字列 `"10"` です。 + +それを `list_resources(cursor="10")` として返すと、最初のリソースは `book-11` になり、新しい `next_cursor` は `"20"` です。 + +10 ページ目は `next_cursor` が `None` に設定されて返ってきます。これで完了です。 + +## クライアントのループ {#the-client-loop} + +`Client` のすべての `list_*` メソッド(`list_tools`、`list_resources`、`list_resource_templates`、`list_prompts`)は `cursor=` キーワードを受け取ります。ページングされた一覧をすべて取り出すには、`while True` を 1 つ書くだけです。 + +```python title="client.py" hl_lines="26-32" +--8<-- "docs_src/pagination/tutorial002.py" +``` + +* `cursor` は `None` から始まるので、最初のリクエストにはカーソルがありません。 +* `next_cursor` を見る**前に** extend してください。最後のページにもリソースはあります。 +* `next_cursor is None` が出口です。それ以外はそのまま、手を加えずに `cursor=` に戻します。 + +その `main()` を実行すると `100 resources` と表示されます。10 件ずつの 10 ページが、10 ページあることなど知らないループによってつなぎ合わされた結果です。 + +これは **[クライアント](../client/index.md)** がすべての `list_*` メソッドについて示しているのと同じループで、ページングしないサーバーに対してもコストはかかりません。最初のレスポンスで `next_cursor` が `None` になり、ループは 1 回だけ回ります。 + +## 3 つのルール {#the-three-rules} + +**カーソルは不透明です。** クライアントはカーソルを解析したり、組み立てたり、推測したりしてはいけません。カーソルの正当な出どころは、前のページの `next_cursor` をそのまま使うことだけです。 + +**ページサイズはサーバーが決めます。** プロトコルに `limit=` はありません。別のページサイズが必要なら、サーバーを変更します。 + +**ページングを無視するクライアントもそのまま動きます。** `list_resources()` を 1 回呼び、最初の 10 件を受け取り、捨ててしまった `next_cursor` に気づくことはありません。何も壊れません。見えるものが少ないだけです。 + +!!! check + 不透明とは本当に不透明ということです。カーソルをでっち上げても(`list_resources(cursor="page-2")`)、プロトコルにできることは何もありません。このサーバーは `int("page-2")` を試み、ハンドラーが例外を送出し、クライアントに返ってくるのは次のとおりです。 + + ```text + MCPError(-32603, 'Internal server error', None) + ``` + + サーバーから受け取ったものではないカーソルはバグであり、機能要望ではありません。 + +## まとめ {#recap} + +* `MCPServer` はすべてを 1 ページで返します。ページネーションはオプトインであり、低レベルの `Server` でオプトインします。 +* `on_list_resources`(および `on_list_tools`、`on_list_prompts`、`on_list_resource_templates`)は `PaginatedRequestParams | None` を受け取ります。最初のページでは `params.cursor` が `None` です。 +* ページと `next_cursor` を返します。後で認識できる任意の文字列か、残りが何もないときは `None` です。 +* クライアントのループは、`cursor=` を渡し、蓄積し、`next_cursor is None` になるまで繰り返します。 +* カーソルは不透明で、ページサイズはサーバーが決め、ページングしないクライアントも 1 ページ目は受け取れます。 + +手書きの `Server` API の残り(`on_call_tool`、`input_schema` の dict、`_meta`)は **[低レベルの Server](low-level-server.md)** にあります。 diff --git a/i18n/ja/pages/client/caching.md b/i18n/ja/pages/client/caching.md new file mode 100644 index 0000000000..79ffd11917 --- /dev/null +++ b/i18n/ja/pages/client/caching.md @@ -0,0 +1,119 @@ +--- +translation: + sections: [9e7b9a1710e5aeba, b74ca4c1d2ddddee, fa8714e61bf90c5a, 04db67a886b7271c, 857690fb8f876800] + tool: 1 +--- +# キャッシュヒント {#caching-hints} + +2026-07-28 プロトコルでは、サーバーが `tools/list`、`prompts/list`、`resources/list`、`resources/templates/list`、`resources/read`、`server/discover` に対して返す結果はすべて、2 つのフィールドを持ちます。`ttlMs` はクライアントがその結果を新鮮なものとして扱ってよいミリ秒数、`cacheScope` はキャッシュした結果をユーザー間で共有してよいか(`"public"`)、それとも 1 つの認可コンテキストに属するか(`"private"`)を表します。 + +サーバーは何もキャッシュしません。これらのフィールドは「宣言」です。つまり「このツール一覧は全員にとって同じで、1 分間は変わりません」という意思表示です。それを受けてクライアント(または手前にあるゲートウェイ)はラウンドトリップを省略できます。ヒントに従うかどうかはクライアントの判断で、ヒントを出すのがサーバーの仕事です。そしてその仕事は SDK が肩代わりします。 + +デフォルトでは、どの結果も `ttlMs: 0, cacheScope: "private"` を返します。すぐに古くなり、決して共有されないという意味です。これは常に安全で、常に仕様に準拠しています。一覧が本当に安定していて、すべての呼び出し側に対して同一なら、構築時にそう伝えてください。 + +```python title="server.py" hl_lines="5-8" +--8<-- "docs_src/caching/tutorial001.py" +``` + +* このマップのキーは**メソッド名**で、キャッシュ可能な 6 つのメソッドだけが有効なキーです。パラメーターの型は `Mapping[CacheableMethod, CacheHint]` なので、エディターがキーを補完し、実行前にタイプミスを指摘します。型チェッカーをすり抜けたものは構築時に例外を送出します。 +* 記載しなかったメソッドはデフォルトのままです。このマップは上書きの集合であって、一覧表ではありません。 +* `CacheHint(ttl_ms=5_000)` は `scope` を設定していないので、`"private"` のままです。呼び出し側ごとに 5 秒間新鮮、という意味です。スコープと TTL は独立した判断です。 +* `"server/discover"` も有効なキーです。ディスカバリーの結果も一覧と同じくキャッシュ可能だからです。 + +!!! warning + `cacheScope: "public"` は、キャッシュしたレスポンスを「誰にでも」返してよいという意味です。共有ゲートウェイは、リクエストが認証されていたとしても、あるユーザーの結果を平気で別のユーザーに渡します。結果を `"public"` とするのは、すべての呼び出し側に対して同一である場合だけにしてください。また `cacheScope` をアクセス制御として使わないでください。これはラベルであって、鍵ではありません。 + +## ハンドラーごとの上書き {#per-handler-override} + +低レベルの `Server` では、ハンドラーが結果を手作業で組み立てます。`ttl_ms` と `cache_scope` は結果モデルの単なるフィールドです。これらを明示的に設定したハンドラーは、フィールド単位で常にコンストラクターのマップより優先されます。 + +```python title="server.py" hl_lines="10 16" +--8<-- "docs_src/caching/tutorial002.py" +``` + +ハンドラーは `ttl_ms=1_000` と指定し、スコープについては何も指定していません。実際に送受信される内容は `ttlMs: 1000`(マップの `60_000` ではなくハンドラーの値)と `cacheScope: "public"`(ハンドラーが未設定なのでマップの値)です。明示的な値は設定値に勝ち、設定値はデフォルトに勝ちます。これはフィールドごとに成り立つので、ハンドラーは片方のフィールドを固定し、もう片方をサーバー全体のポリシーに任せられます。 + +これはコンストラクターが知り得ない動的な事情への逃げ道にもなります。`resources/read` をユーザーごとにフィルターするハンドラーは、それ以外は公開であるサーバーでも、特定の URI については `cache_scope="private"` を返せます。 + +ページ分割された一覧について 1 つ注意点があります。プロトコルは、1 つの一覧の**すべてのページで同じ `cacheScope`** を要求します。コンストラクターのマップはページではなくメソッドをキーにしているため、この条件を構造上満たします。しかしスコープを自分で上書きするハンドラーは、その一貫性に自ら責任を持ちます。カーソルがあるときだけではなく「すべての」ページで上書きしてください。そうしないと 1 ページ目と 2 ページ目で食い違います。 + +## クライアントから見えるもの {#what-the-client-sees} + +2026-07-28 のセッションでは、`Client` がヒントに自動で従います。組み込みのレスポンスキャッシュがあり、デフォルトで有効です。`ttlMs` を持って届いた結果は保存され、その TTL 内に同一の呼び出しがあれば、ラウンドトリップなしでキャッシュから返されます。ヒントを「持たない」結果はキャッシュされません。ヒントのない結果には `CacheConfig.default_ttl_ms` が適用され、そのデフォルトは `0`(すぐに古くなる)です。そのため、何も宣言しないサーバーには、これまでとまったく同じ呼び出しごとのトラフィックが届きます。 + +```python title="client.py" hl_lines="33 35 38" +--8<-- "docs_src/caching/tutorial003.py" +``` + +呼び出し 4 回、取得 3 回です。2 回目の呼び出しは新鮮なエントリーを見つけ、サーバーに到達しませんでした。(注入した)クロックを TTL の先へ進めたことで、3 回目は再び取得しました。4 回目は `cache_mode="refresh"` を指定しています。このキーワード引数はキャッシュ対象の 5 つのメソッド(`list_tools`、`list_prompts`、`list_resources`、`list_resource_templates`、`read_resource`)にあります。 + +* `"use"`(デフォルト)は、新鮮なエントリーがあればそれを返し、なければ取得して保存します。 +* `"refresh"` はキャッシュから返しません。取得して結果を保存し、キャッシュにあったものを置き換えます。 +* `"bypass"` はキャッシュに一切触れずにラウンドトリップします。読み込みも書き込みもしません。 + +`"use"` より上位にルールが 1 つあります。**`meta` を持つ呼び出しは必ずサーバーに到達します。**`meta` を設定したリクエスト(進捗トークンやトレーシング用フィールドなど)は実際のリクエスト送信を前提にしているため、`cache_mode="use"` では `"refresh"` として扱われます。キャッシュの読み込みは省略され、取得した結果は引き続きキャッシュのエントリーを置き換えます。`"bypass"` と明示的な `"refresh"` はいつもどおりに動作します。 + +キャッシュを完全に無効にするには、`Client(server, cache=None)` で構築してください。すべての呼び出しが再びラウンドトリップになり、`cache_mode` は受け付けられるものの何もしません。 + +スコープも自動的に尊重されます。`"private"` のエントリーはキャッシュの「パーティション」(後述)をキーにし、`"public"` のエントリーはより広い共有を選べます。そして、名指しされたエントリーについては**通知が TTL に勝ちます**。`list_changed` 通知は対応するキャッシュ済みの一覧を破棄し、`resources/updated` はその URI と完全に一致するキーで保存されたキャッシュ済みの読み込み結果を、どれだけ新鮮でも破棄します。2026-07-28 の接続では、これらの通知は `client.listen(...)` で開く `subscriptions/listen` ストリームに届き、破棄はウォッチャーがイベントを見る前に完了します。詳しくは **[サブスクリプション](subscriptions.md)** を参照してください。 + +`resources/updated` について 1 つ注意点があります。破棄は URI の完全一致のみです。ストアの契約には列挙やスキャンの操作がありません(TypeScript のリファレンス実装と同じです)。そのため「サブ」リソースの URI を持つ通知は、その親のキャッシュ済み読み込み結果を破棄しません。サーバーがサブリソースをこの方法で通知する場合は、`cache_mode="refresh"` で親を再取得してください。 + +### 設定方法:`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`:エントリーの保存先です。デフォルトはクライアントごとの新しいインメモリストアです。クライアントやプロセスをまたいでキャッシュを共有するには、独自の `ResponseCacheStore` 実装(たとえば Redis ベース)を渡してください。契約の型(`ResponseCacheStore`、`CacheKey`、`CacheEntry`、そしてデフォルトの `InMemoryResponseCacheStore`)は `mcp.client` からインポートできます。1 回の検索で、ストアの `get` が最大 2 回(private 側、次に public 側)順に発行されることがあるので、リモートストアのレイテンシー要件はそれに合わせて見積もってください。カスタムストアには明示的な `partition` が**必須**です。 +* `partition`:認可コンテキストのラベルです。共有ストア内で、あるプリンシパルの `"private"` エントリーが別のプリンシパルに返されないようにします。 +* `target_id`:明示的なサーバーの識別子です。カスタムトランスポートやインプロセスサーバー用です(後述)。 +* `default_ttl_ms`:`ttlMs` ヒントを持たない結果に適用する TTL です。デフォルトの `0` では、ヒントのない結果はキャッシュされません。 +* `share_public`:サーバーが `"public"` と主張したエントリーをパーティションをまたいで返します(後述)。デフォルトでは無効です。 +* `clock`:エポック秒単位の時刻ソースです。上の例のように注入すれば、有効期限のテストでスリープする必要がありません。 + +!!! warning "パーティション = 検証済みプリンシパル" + `partition` は、検証済みトークンの subject など、**検証済みの資格情報**から導出してください。リクエストで渡されたデータから導出してはいけませんし、サーバーの URL からも導出してはいけません(サーバーの識別子は別のキー軸です)。SDK はライブラリであり、独自の認証を持ちません。信頼の起点は `CacheConfig` を構築する主体、つまりテナントではなくデプロイメントです。マルチテナントのゲートウェイは、認証済みプリンシパルごとに 1 つの `CacheConfig` を作ります。 + + パーティションは `Client` の寿命の間、固定でもあります。接続の認可コンテキストがセッション途中で変わった場合(たとえば別のプリンシパルとして再認証した場合)、キャッシュは追従しません。新しいプリンシパルには新しい `Client` を構築してください。 + +キャッシュキーには**サーバーの識別子**も含まれます。接続先として指定した URL 文字列から `user:pass@` のユーザー情報を取り除いたもので、それ以外はバイト単位で完全一致です。大文字小文字の畳み込みも、クエリの並べ替えも、末尾スラッシュの整理もしません。正規化が足りない場合は共有の機会を失うだけですが、正規化しすぎると 2 つのテナント(`?tenant=a` と `?tenant=b`)を統合しかねません。そのため、見かけ上異なる URL は単純にエントリーを共有しません。URL がない場合(インプロセスサーバーや `Transport` インスタンス)、クライアントには代わりにインスタンスごとのランダムな識別子が与えられます。サーバーに名前を付けるには `CacheConfig.target_id` を設定してください(カスタムストアでは必須で、構築時にそう指摘されます)。識別子はキー素材に入る前に sha256 でハッシュされるので、クエリ文字列に秘密情報を含む URL がストアのキーに現れることはありません。ハッシュ前の形を自分でログに出すこともしないでください。 + +!!! warning "`share_public` はサーバーをフリート全体で信頼する" + デフォルトでは `"public"` のエントリーであっても、自身のパーティション内にとどまります。`share_public=True` にすると、サーバーが `cacheScope: "public"` と付けたエントリーが、そのストアを使う**すべての**パーティションに返されます。サーバーの分類を、全パーティションを代表して信頼することになります。サーバーが(バグであれ悪意であれ)テナント固有のデータに `"public"` を付けると、あるテナントのレスポンスが他のテナントに漏れます。このフラグは意図的にコンストラクターレベル専用です。呼び出しごとの `cache_mode` はキャッシュを狭められますが、呼び出しごとの指定で共有を広げることはできません。 + +### キャッシュが決してしないこと {#what-the-cache-never-does} + +* **セッション層の呼び出しはキャッシュを経由しません。**`client.session.list_tools()` などは常にラウンドトリップします。キャッシュは `Client` のメソッド上にあります。 +* **`server/discover` は対象外です。**ディスカバリーの結果は接続時に一度だけ届き、`ttlMs` を持っていてもレスポンスキャッシュには入りません。再接続時のプローブを省略するために自分で永続化する場合([`prior_discover`](../protocol-versions.md#reconnecting-with-prior_discover))、その鮮度は自分で管理します。`DiscoverResult` はまさにその目的で、解析済みの `ttl_ms` と `cache_scope` を持っています。 +* **継続ページは決してキャッシュされません。**カーソルなしの呼び出しだけが対象です。期限切れのカーソルで拒否された継続ページは、キャッシュ済みの一覧を「破棄」します。一覧がその下で変わったからです。 +* **マルチラウンドトリップ(multi-round-trip)の読み込みは決してキャッシュされません。**`input_responses` / `request_state` を与えた `read_resource`、または入力ラウンドを経て解決されるものは、決してキャッシュに入りません(仕様上の MUST です)。 +* **通知による破棄には通知が必要です。**破棄の確実さはトランスポートの配信に依存します。現在、モダンなインプロセスの経路(デフォルトの `mode="auto"` での `Client(server)`)は単独の通知を配信しません。 +* **破棄は結果整合であり、即時ではありません。**通信経由の通知は生成されたタスクから配送されるため、通知の到着と競合した呼び出しには、破棄前のエントリーがもう一度返されることがあります。その時間幅は配送のレイテンシーで抑えられ、破棄自体は必ず行われます。 +* **stale-if-error はありません。**再取得が失敗したからといって期限切れのエントリーが返されることはありません。エラーはそのまま伝播します。 +* **早期の再取得はありません。**保存されたエントリーは TTL が切れるまで返され、その後の最初の呼び出しがラウンドトリップの費用を負担します。バックグラウンドでの更新はありません。 +* **集約はありません。**同時に行われた同一の呼び出し 2 つは、取得 2 回です。 +* **24 時間を超える TTL はありません。**サーバーが送ったものでも設定したものでも、それより大きな `ttlMs` は保存時に切り詰められます(`mcp.client.caching.MAX_TTL_MS`)。どれだけ寛大なヒントが付いていても、エントリーが返され続ける期間には上限があります。 +* **共有ストア**では、クライアント同士が競合します。各クライアントは、取得中に破棄が先行した場合は自身の書き込みを捨てますが、「同居する」クライアントは、自分が見ていない破棄によって削除されたエントリーを書き戻すことがあります。そしてこの競合の管理自体にも上限があり、追跡するキーが 4096 個を超えると最も古いキーのガードから外されます。どちらの時間幅も許容されており、上記の TTL 上限によって閉じられます。 +* **プロトコルの世代をまたいで返すことはありません。**エントリーはネゴシエートされたプロトコルバージョンにスコープされます。共有の永続ストア上で、あるセッションが別のネゴシエート済みバージョンで書かれたエントリーを返すことはありません(SDK は古いセッション向けに 2026 のフィールドを取り除くので、同じ一覧でも世代によって実際に異なります)。破棄も同様に現在の世代のエントリーだけに作用し、別の世代のエントリーは TTL によって自然に期限切れになります。 + +### ヒントを自分で読む {#reading-the-hints-yourself} + +ヒントは、キャッシュ可能なすべての結果の単なるフィールドでもあります(解析済みの `result.ttl_ms` と `result.cache_scope`)。組み込みキャッシュの上に(またはその代わりに)独自の管理を重ねたい場合に使えます。 + +**古いサーバー**(2026 より前のプロトコル)に対しては、これらのフィールドは通信上に存在せず、モデルは保守的なデフォルトを示します。`ttl_ms == 0` と `cache_scope == "private"`、つまり古く、共有されない状態です。何も宣言しなかったサーバーに対する正しい前提です。キャッシュはレガシーセッションも同じように扱います。そこではヒントは一切参照されず(通信上にどんなキーが現れても)、`default_ttl_ms` だけが適用されます。そのデフォルトの `0` は何もキャッシュしないので、2026 より前の接続はキャッシュが存在する前とまったく同じように振る舞います。「サーバーが 0 と言った」と「サーバーが何も言わなかった」を区別する必要がある場合は、`"ttl_ms" in result.model_fields_set` を確認してください。フィールドが実際に届いたときだけ設定されます。 + +## 古いクライアント {#older-clients} + +2026 より前のプロトコルバージョンのクライアントには、どちらのフィールドも見えません。SDK がそれらの接続ではシリアライズ時に取り除きます。ヒントは一度設定するだけで、バージョン固有に書くものは何もありません。 + +## まとめ {#recap} + +* 6 つのメソッドが `ttlMs` / `cacheScope` を持ちます。SDK のデフォルトは `0` / `"private"` で、古く、共有されず、常に安全です。 +* 構築時の `cache_hints={method: CacheHint(...)}`(`MCPServer` と `Server` の両方)で、メソッドごとにサーバー全体の値を設定します。 +* 結果にフィールドを設定したハンドラーは、フィールド単位でマップを上書きします。 +* `"public"` は、結果がすべての呼び出し側に対して同一であるという約束です。アクセス制御ではありません。 +* `Client` はヒントに自動で従います。レスポンスキャッシュはデフォルトで有効で、再取得の代わりに新鮮なエントリーを返し、ヒントを提供しないサーバー(またはセッション)については何もキャッシュしません。 +* 呼び出しごとに、`cache_mode="refresh"` は再取得し、`"bypass"` はキャッシュを飛ばします。構築時の `cache=None` でキャッシュを完全に無効にできます。 diff --git a/i18n/ja/pages/client/callbacks.md b/i18n/ja/pages/client/callbacks.md new file mode 100644 index 0000000000..bcf4c266a1 --- /dev/null +++ b/i18n/ja/pages/client/callbacks.md @@ -0,0 +1,142 @@ +--- +translation: + sections: [adf3c545b5be46b6, 916cd3ab1c03f461, e9be7a8d0eb0a456, 565890a636288ecf, 6af7e49db9129ec3, 06b0238c174186af, 90c6043be435fcb0] + tool: 1 +--- +# クライアントのコールバック {#client-callbacks} + +MCP のリクエストは、ほぼすべてが一方向です。クライアントからサーバーへ送られます。 + +サーバーのほうから**クライアント**に何かを頼むこともできます。ユーザーに質問する、ユーザーのモデルでサンプリングする、ユーザーのワークスペースフォルダーを一覧する、といったことです。こうしたリクエストには、`Client(...)` に**コールバック**を渡して応答します。 + +## 問い合わせをするサーバー {#a-server-that-asks} + +次のサーバーのツールは、単独では処理を終えられません。 + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/client_callbacks/tutorial001.py" +``` + +* `ctx.elicit(...)` は `elicitation/create` リクエストを**クライアントに**送り、待機します。 +* 誰か(フォームに入力する人か、こちらのコード)が `name` を渡すまで、ツールは戻りません。 + +これはサーバー側の話で、**[エリシテーション(elicitation)](../handlers/elicitation.md)** のページが扱います。このページは通信路の反対側の話です。 + +## エリシテーションのコールバック {#the-elicitation-callback} + +```python title="client.py" hl_lines="6-10 16-17" +--8<-- "docs_src/client_callbacks/tutorial002.py" +``` + +* エリシテーションのコールバックは `async (context, params) -> ElicitResult` です。 +* `params.message` が質問です。`params.requested_schema` は、サーバーが求める答えの JSON Schema です。実際のクライアントはこれをもとにフォームを描画しますが、ここでは自動で埋めています。 +* 戻り値は `ElicitResult(action="accept", content={...})`、`action="decline"`、`action="cancel"` のいずれかです。それ以外の選択肢は `ErrorData(...)` だけで、これはリクエストを拒否し、呼び出し全体を失敗させます。 +* `context` は `ClientRequestContext` です。使用中の `session`、サーバーの `request_id`、サーバーが付けた `meta` を持ちます。 + +!!! tip + `params` は 2 つのエリシテーションモードのユニオンです。ここでは `params.mode` は `"form"` です。`"url"` のリクエストはスキーマの代わりに `params.url` を持ちます。1 つのコールバックで両方を扱い、`params.mode` で分岐してください。パターンの全体は **[エリシテーション](../handlers/elicitation.md)** にあります。 + +### 試してみる {#try-it} + +`issue_card` を呼び出し、両側の様子を見てみましょう。 + +コールバックは、サーバーからの質問をパース済みの状態で受け取ります。 + +```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'} +``` + +コールバックが答えると、ツールの中で `ctx.elicit(...)` が再開し、ツールが完了します。 + +```python +result.content # [TextContent(type='text', text='Card issued to Ada Lovelace.')] +``` + +こちらから `tools/call` が 1 回、サーバーからの折り返しの `elicitation/create` が 1 回、それに答えるのがこちらの関数です。すべてが 1 回のツール呼び出しの中で完結します。 + +!!! info + `Client(...)` の呼び出しにある `mode="legacy"` は、実際に働いています。デフォルトでは `Client(...)` は新しいプロトコルの経路をネゴシエートしますが、その経路にはサーバーからクライアントへのリクエストのためのバックチャネル(back-channel)がありません。コールバックが動く前に `ctx.elicit` が失敗します。これを決めるのはトランスポートではなく、ネゴシエートされたプロトコルです。インメモリでも URL 越しでも同じです。クライアントがこうしたリクエストに答える必要があるときは、必ず `mode="legacy"` を指定してください。このページの裏にあるテストはすべてそうしています。詳しくは **[プロトコルバージョン](../protocol-versions.md)** を参照してください。 + + 2026-07-28 のセッションでもコールバックが使われなくなるわけではなく、呼ばれ方が変わります。ツールが `ElicitRequest` を含む `InputRequiredResult` を返すと、`Client` はそのエントリを同じ `elicitation_callback` に振り分け、呼び出しを再試行してくれます。この流れは **[マルチラウンドトリップ(multi-round-trip)リクエスト](../handlers/multi-round-trip.md)** で説明しています。 + +## コールバックはケイパビリティ {#a-callback-is-a-capability} + +クライアントがエリシテーションのリクエストに答えられることを、サーバーに伝えた覚えはないはずです。伝えたのは SDK です。 + +クライアントは接続時に自分の `capabilities` を宣言します。サーバー側の宣言と鏡写しの関係です。このオブジェクトを自分で書くことはありません。**コールバックを登録すること自体が宣言です。** + +| 渡すもの | クライアントが宣言するもの | +| --- | --- | +| `elicitation_callback=` | `"elicitation": {"form": {}, "url": {}}` | +| `sampling_callback=` | `"sampling": {}` | +| `list_roots_callback=` | `"roots": {"listChanged": true}` | +| どれも渡さない | `{}` | + +細かい指定が 1 つだけあります。サンプリングのサブケイパビリティです。サンプラーが `tools` / `tool_choice` パラメーターを扱える場合は、`sampling_callback` と一緒に `sampling_capabilities=SamplingCapability(tools=SamplingToolsCapability())` を渡してください。サーバーは `sampling.tools` が宣言されているのを確認してからでないと、これらを送れません。 + +`logging_callback` と `message_handler` は表にありません。これらは通知を扱うもので、通知にケイパビリティは要りません。 + +サーバーは `ctx.session.check_client_capability(...)` で宣言を読み取ります。これを行うツールを追加します。 + +```python title="server.py" hl_lines="23-31" +--8<-- "docs_src/client_callbacks/tutorial003.py" +``` + +`elicitation_callback` だけを渡して接続し、呼び出します。 + +```python +result.structured_content # {'result': ['elicitation']} +``` + +3 つのコールバックをすべて渡すと結果は `['elicitation', 'sampling', 'roots']`、どれも渡さなければ `[]` です。 + +!!! check + 今度はわざと間違えてみましょう。`elicitation_callback` **なしで**接続し、それでも `issue_card` を呼び出します。 + + サーバーの `elicitation/create` リクエストはそれでもクライアントに届きます。そして、扱えると宣言していないので、SDK が代わりにエラーで答えます。そのエラーが呼び出し全体を失敗させます。`call_tool` は `is_error` の結果を返すのではなく、例外を送出します。 + + ```text + MCPError: Elicitation not supported + ``` + + これはツールのエラーではなくプロトコルエラー(`-32600`、*invalid request*)です。モデルが読んで再試行できるものは何もありません。`client_features` を用意する価値があるのはこのためです。行儀のよいサーバーは、頼む前に確認します。 + +## 非推奨の 2 つ {#the-deprecated-pair} + +`sampling_callback` は `sampling/createMessage` に答えます。サーバーがクライアント側のモデルに何かを補完させるリクエストです。`list_roots_callback` は `roots/list` に答えます。サーバーが、作業してよいディレクトリを尋ねるリクエストです。 + +どちらも動作します。どちらも上のルールに従います。そしてどちらも、**2026-07-28 の仕様で削除される** RPC に応えるものです。新しいサーバーはリクエストの途中でクライアントを呼び返すことはせず、リクエストをツール結果の一部として返してきます(**[マルチラウンドトリップリクエスト](../handlers/multi-round-trip.md)**)。コールバック自体が使われなくなるわけではありません。`InputRequiredResult` が `CreateMessageRequest` や `ListRootsRequest` を含んでいると、`Client` の自動ループが、ここで登録したのと同じ `sampling_callback` または `list_roots_callback` にそれを振り分けます。一覧は **[非推奨の機能](../deprecated.md)** にあります。 + +まだ移行していないサーバーとやり取りするには、引き続きこれらのコールバックが必要です。シグネチャは次のとおりです。 + +```python title="client.py" +--8<-- "docs_src/client_callbacks/tutorial004.py" +``` + +* サンプリングのコールバックは `CreateMessageRequestParams` の全体(`messages`、`model_preferences`、`max_tokens`)を受け取り、`CreateMessageResult` を返します。モデルを動かすのはこちら側で、やり方は自由です。SDK はリクエストを運ぶだけです。 +* ルート(roots)のコールバックはパラメーターを一切取らず、`ListRootsResult` を返します。 +* どちらも、拒否するときは代わりに `ErrorData(...)` を返せます。 + +`elicitation_callback` とまったく同じように `Client(...)` に渡します。 + +## 通知のコールバック {#the-notification-callbacks} + +あと 2 つあります。どちらも何も宣言しません。 + +`logging_callback` は、サーバーが送る `notifications/message` を `LoggingMessageNotificationParams`(`level`、`logger`、`data`)として受け取ります。プロトコルのロギング自体が 2026-07-28 の仕様で非推奨になっています(代わりにどうするかは **[ロギング](../handlers/logging.md)** にあります)。そのため、このコールバックはまだ通知を出すサーバーのために存在します。2026 年世代の接続では、コールバックだけでは何も届きません。2026 年のサーバーは、オプトインしたリクエストにしかログメッセージを送らないからです。`Client(...)` に `log_level="info"`(または別のレベル)を渡すと、すべてのリクエストにそのオプトインが付き、そのレベル以上を受け取れます。2026 年より前のサーバーはこれを無視し、従来どおり `logging/setLevel` の挙動を保ちます。 + +`message_handler` は何でも受け取る窓口です。セッションが表に出すサーバー通知はすべて(それぞれ専用のコールバックに加えて)ここに届きます。ストリームを使うトランスポートでは、トランスポートレベルの `Exception` もすべて届きます。届かないものが 2 つあります。`notifications/cancelled` は表に出されず SDK が適用します。動作中の `listen()` ストリームに対する購読の確認応答は、そのストリームが消費します。パラメーターには `IncomingMessage`(`ServerNotification | Exception`、`mcp.client` からエクスポート)で注釈を付けてください。覚えておく価値のあるパターンは `if isinstance(message, Exception): raise message` の 1 つです。これで、接続が壊れたときに黙って消えるのではなく、はっきり失敗します。 + +## まとめ {#recap} + +* サーバーはクライアントにリクエストを送れます。`Client(...)` に渡したコールバックで応答します。 +* 現行のものはエリシテーションのコールバックです。`async (context, params) -> ElicitResult` で、フォームモードと URL モードの両方を 1 つの関数で扱います。 +* **コールバックの登録がケイパビリティの宣言です。** 登録がなければ、SDK が代わりにサーバーのリクエストを拒否し、呼び出し全体が `MCPError` で失敗します。 +* サーバーは、頼む前に `ctx.session.check_client_capability(...)` で確認します。 +* `sampling_callback` と `list_roots_callback` も同じように動きますが、非推奨の機能のためのものです。新しいサーバーは代わりにマルチラウンドトリップリクエストを使います。 +* `logging_callback` と `message_handler` は通知を受け取ります。何も宣言しません。 + +`Client(...)` の第 1 引数はトランスポートのオブジェクトです。すべての種類は **[クライアントのトランスポート](transports.md)** で扱っています。 diff --git a/i18n/ja/pages/client/identity-assertion.md b/i18n/ja/pages/client/identity-assertion.md new file mode 100644 index 0000000000..5f10acf273 --- /dev/null +++ b/i18n/ja/pages/client/identity-assertion.md @@ -0,0 +1,129 @@ +--- +translation: + sections: [a91322c46111d16d, 8e6fd6d6f59bb568, e7828fd2729b2c9d, a03ec26bfc678b65, 1034c653c0bcf1b0] + tool: 1 +--- +# アイデンティティアサーション {#identity-assertion} + +通常の OAuth プロバイダー(**[OAuth クライアント](oauth-clients.md)**)は、まず MCP サーバーに「どの認可サーバーを信頼しているか」を尋ねるところから始まります。返ってきた答えが指す先へどこまでも従い、そのうえで人がサインインするか、事前共有したシークレットがその代わりを務めます。 + +企業は、そのどちらもサーバーごとに決めたくはありません。企業はすでに ID プロバイダー(Okta、Microsoft Entra ID、自社製のもの)を運用しています。ユーザーは今朝すでにそこへサインイン済みです。そしてそこは、セキュリティチームが「誰が何に到達してよいか」を一か所で決めたい場所でもあります。**Enterprise-Managed Authorization** 拡張である [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) は、その判断をそこへ移します。IdP は有効期間の短い JWT、すなわち **Identity Assertion JWT Authorization Grant**(**ID-JAG**)に署名します。これは「このユーザーが、このクライアントを通じて、この MCP サーバーに到達してよい」という表明です。クライアントはそれを通常のアクセストークンと交換します。ブラウザーも、同意画面も、動的登録もありません。 + +このページでは、その交換の両端を扱います。MCP サーバー自体は何も変わりません。**[認可](../run/authorization.md)** で説明したリソースサーバーのまま、届いたトークンを何であれ検査します。 + +## 2 つのトークンリクエスト {#two-token-requests} + +ここには 2 つの別々の権限主体が関わっています。両者を区別して呼び分けられれば、このページの大半は理解できたも同然です。**エンタープライズ IdP** は組織の ID プロバイダーです。従業員が誰であるかを知っており、ポリシーが置かれる場所であり、ID-JAG を発行します。SDK がこれと通信することはありません。**MCP 認可サーバー** は **[認可](../run/authorization.md)** のときと同じ当事者です。MCP サーバーのメタデータに名前が載っている発行者(issuer)であり、その MCP サーバーが受け入れるトークンを発行する存在です。通常の OAuth フローでは、この 2 つの役割はたいてい 1 つの箱に収まっています。ここでは 2 つに分かれており、このグラント全体は、後者が前者を信頼すると同意することにほかなりません。 + +クライアントは、それぞれに 1 回ずつトークンリクエストを送ります。 + +1. **エンタープライズ IdP へ。** クライアントはユーザーのサインイン(OpenID Connect の ID トークン)を ID-JAG と交換します。これは [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) のトークン交換であり、完全に IdP 側の API であって、**SDK はこのリクエストを行いません**。行うのは呼び出し側で、1 つの非同期コールバックの中で実装します。ポリシーの判断が下されるのもここです。IdP が拒否すれば ID-JAG は発行されず、提示するものは何もありません。 +2. **MCP 認可サーバーへ。** クライアントは [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) の `jwt-bearer` グラント(`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`、ID-JAG を `assertion` として)で ID-JAG を提示し、アクセストークンを受け取ります。**SDK が行うのはこちらのリクエストです**。そしてこれを受け入れることが、このページが認可サーバーに追加する唯一の事柄です。 + +以降はすべて 2 番目のリクエストの話です。それを送るクライアントと、それに応答する認可サーバーを扱います。 + +## クライアント {#the-client} + +**`IdentityAssertionOAuthProvider`** は `mcp.client.auth.extensions.identity_assertion` にあります。**[OAuth クライアント](oauth-clients.md)** のどのプロバイダーとも同じく `httpx2.Auth` です。インスタンスを作り、`auth=` に載せ、その `httpx2.AsyncClient` をトランスポートに渡します。 + +```python title="client.py" hl_lines="49-50 53-61" +--8<-- "docs_src/identity_assertion/tutorial001.py" +``` + +下から順に読んでいきます。 + +* `main()` は標準的な OAuth クライアントの `main()`(**[OAuth クライアント](oauth-clients.md)**)そのもので、1 行も変わっていません。そこが肝心です。プロバイダーさえできてしまえば、下流のどこも、どのグラントがトークンを生んだのかを知りません。 +* プロバイダーが受け取るのは、ほかのプロバイダーには発見できないものです。誰かが認可サーバーに**事前登録**した `client_id` と `client_secret`、その認可サーバーの `issuer`、そして要求に応じて新しい ID-JAG を返す非同期コールバック `assertion_provider` です。 +* `storage` は同じ `TokenStorage` プロトコルです。呼ばれるのは 2 つのトークンメソッドだけです。ここには動的登録がないので、覚えておくべき `client_info` もありません。 + +### アサーションプロバイダー {#the-assertion-provider} + +自分で書くコードは `fetch_id_jag(audience, resource)` だけです。トークン交換のたびに 1 回 await され、構築時に呼ばれることはありません。しかも認可サーバーのメタデータを取得して検証した「後」でしか呼ばれないため、issuer の設定ミスでアサーションが漏れることはありません。2 つの引数は、ID-JAG の発行時に含めなければならないクレームのうちの 2 つです。`audience` は認可サーバーの issuer(ID-JAG の `aud`)、`resource` は MCP サーバーの正規識別子(ID-JAG の `resource`)です。3 つ目はすでに手元にあります。ID-JAG の `client_id` クレームは、プロバイダーに渡した `client_id` を指していなければならず、そうでなければ認可サーバーは交換を拒否します。 + +その上にある `idp_issue_id_jag` は**自分で書くコードではありません**。これは ID プロバイダーの代役で、ファイルが単体で完結し、ID-JAG が運ぶクレームをすべて読めるように、同一プロセス内でアサーションに署名しています。実際の `fetch_id_jag` は、代わりに前節の 1 番目のトークンリクエストを行います。すなわち IdP に対する [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) のトークン交換で、[SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) がプロファイル化している Identity Assertion JWT Authorization Grant ドラフトで定義されています。サインイン済みユーザーの ID トークンが `subject_token` として入り、`requested_token_type` は ID-JAG 自身の URN(`urn:ietf:params:oauth:token-type:id-jag`)です。`audience` と `resource` はそのまま渡され、レスポンスが ID-JAG を運んできます。IdP のドキュメントで探すべきは、これらの名前を使ったこの交換です。 + +!!! tip + 交換のたびに新しい ID-JAG が要求されますが、それこそが狙いです。ID-JAG は使い切りで数分しか生きないグラントであり、このページの認可サーバーは同じものを 2 度受け入れることを拒否します。キャッシュしないでください。再利用されるのは、それで手に入れたアクセストークンのほうです。 + +### issuer は設定値 {#the-issuer-is-configuration} + +ここに逆転があります。`OAuthClientProvider` は、どの認可サーバーを使うかをリソースサーバーに尋ね、返ってきた答えが指す先へどこまでも従います。このプロバイダーはそれを拒みます。`issuer` は必須で、[RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) のメタデータはその issuer 自身の well-known パスから取得されます。トークンエンドポイントはその issuer のオリジン上になければならず、リソースサーバーには何も尋ねません。 + +拡張仕様がこれを要求しているわけではありません。意図的に、より厳しくした選択です。このクライアントは盗む価値のあるものを 2 つ持っています。事前登録されたシークレットと、audience に束縛されたアサーションです。侵害された MCP サーバーに攻撃者の認可サーバーへ誘導されるのを許すクライアントなら、その両方をそこへ POST してしまうでしょう。構築時に issuer を固定すれば、そのやり取り自体がなくなります。 + +!!! warning + 設定した `issuer` は、メタデータ文書の `issuer` フィールドと RFC 8414 §3.3 の単純な文字列比較で照合されます。1 文字ずつ、末尾のスラッシュも含め、正規化なしです。推測しないでください。認可サーバーから `/.well-known/oauth-authorization-server` を取得し、返ってきた `issuer` の値をコピーしてください。このページの認可サーバーでは、それはスラッシュ付きの `https://auth.example.com/` です。issuer が pydantic の URL オブジェクトから組み立てられているためです。一致しない場合、クレデンシャルやアサーションが 1 つも送られる前に、`OAuthFlowError: Authorization server metadata issuer + mismatch` でフローが止まります。 + +### コンフィデンシャルクライアント {#a-confidential-client} + +`client_secret` は必須で、ないとコンストラクターが `ValueError` を送出します。[SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) の下敷きになっている IETF プロファイルはこのグラントをコンフィデンシャルクライアント専用としており、SEP-990 はクライアントの認証を要求しています。この SDK は、共有シークレットを必須とすることでその両方を強制しています。`token_endpoint_auth_method` で、シークレットをどこに載せて送るかを選びます。`client_secret_post`(デフォルト、フォーム本体の中)か `client_secret_basic`(HTTP Basic ヘッダー)です。プロファイルは `private_key_jwt` も許可していますが、このプロバイダーはサポートしていません。 + +!!! tip + `client_secret` は環境変数かシークレットマネージャーから読み込んでください。ソース管理には決して入れないでください。 + +### プロバイダーがしてくれること {#what-the-provider-does-for-you} + +最初のリクエストは認証なしで送られ、サーバーの `401` がフローを開始します。 + +1. **ディスカバリー。** 設定した issuer の [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) well-known パスから認可サーバーのメタデータを取得し、文書の `issuer` が一致することと、トークンエンドポイントが issuer のオリジン上にあることを確認します。 +2. **アサーション。** `assertion_provider` を await します。 +3. **交換。** `jwt-bearer` グラントをトークンエンドポイントに POST し、`OAuthToken` を保存し、元のリクエストを `Authorization: Bearer ...` 付きで再送します。 + +`WWW-Authenticate` に `insufficient_scope` が示された `403` では、指定した `scope` とチャレンジされたスコープの和集合で手順 2 と 3 をもう一度実行します。(`scope` はあくまで要求にすぎません。このページの認可サーバーは ID-JAG に書かれたものを付与し、それ以外は付与しません。)ここにはリフレッシュトークンはどこにもありません。アクセストークンが期限切れになると、次の `401` で新しい ID-JAG が発行されて再び交換が行われます。IdP が握っているレバーはまさに「そこ」です。失敗は **[OAuth クライアント](oauth-clients.md)** のほかの部分と同じ 2 つの例外です。ディスカバリーと検証には `OAuthFlowError`、トークンエンドポイントが拒否したときはそのサブクラスの `OAuthTokenError` です。 + +## 認可サーバー {#the-authorization-server} + +たいていの場合、ここで終わりです。MCP 認可サーバーは誰か別の人の製品であり、ID-JAG の受け入れはその製品側で有効にする設定です。[SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) のうち SDK が担う半分は、上のクライアントです。 + +SDK が認可サーバー「そのもの」になることもできます。`create_auth_routes` は認可サーバーのルートを、どんな Starlette アプリでもマウントできるリストとして返します。リポジトリの `examples/servers/simple-auth/` はそうやって認可サーバーを動かしています。SEP-990 は、そのインターフェースにフラグを 1 つとメソッドを 1 つ追加します。 + +```python title="auth_server.py" hl_lines="48-50 105-107" +--8<-- "docs_src/identity_assertion/tutorial002.py" +``` + +* `identity_assertion_enabled=True` がすべての門番です。オフ(これがデフォルト)のときは、フックを実装していても `/token` はこのグラントに `unsupported_grant_type` で応答し、メタデータにも載りません。オンにすると、メタデータに `jwt-bearer` グラントタイプが加わり、`authorization_grant_profiles_supported` に `urn:ietf:params:oauth:grant-profile:id-jag` が列挙されます。これは拡張仕様がサポートを告知するために使うフィールドです。(この SDK のクライアントはそれを読みません。1 つの issuer 向けにプロビジョニングされており、単に要求するだけです。) +* **`exchange_identity_assertion`** がフックです。これが実行される前に、SDK はクライアントを認証し、パブリッククライアントを拒否し、登録内容にこのグラントが含まれていないクライアントを拒否しています。受け取るのは `IdentityAssertionParams`(生の `assertion`、要求された `scopes` と `resource`)で、返すのは素の `OAuthToken` です。 +* 動的クライアント登録はこのグラントを無条件に拒否するので、ここでの `get_client` は手作業でプロビジョニングしたクライアントを返します。ID-JAG クライアントが自分で自分を登録して存在するようになることはできません。 +* クラスの半分は拒否です。`OAuthAuthorizationServerProvider` は認可サーバー「全体」なので、認可コードフローも求められます。ユーザーのサインインも行うサーバーならそれらを本当に実装しますが、このサーバーには入口がちょうど 1 つしかありません。 + +!!! warning + SDK がアサーションをデコードすることは決してありません。どの IdP を信頼し、その IdP がどの鍵を公開しているかを知っているのはデプロイメントだけなので、`exchange_identity_assertion` の中身はすべてが安全性を支える要です。[RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) §3 に従い、IdP が公開している鍵(JWKS。ここでの共有シークレットはデモ用です)で署名を検証し、`iss` と `exp` も検証してください。JWT ヘッダーの `typ` が `oauth-id-jag+jwt` であることを要求してください。これは、別の JWT がグラントとして再利用されるのを防ぐプロファイルの防護策です。`aud` が自分自身の issuer であることを要求してください。ID-JAG の `client_id` クレームがハンドラーの認証したクライアントと一致すること、`resource` クレームが実際に提供しているリソースを指していることを要求してください。`jti` をアサーションの `exp` まで追跡し、一度しか受け入れないようにしてください。そして付与するスコープ、とりわけ発行するトークンの `resource` は、検証済みの ID-JAG から取り、リクエストからは決して取らないでください。`params.resource` はクライアントが入力したものにすぎません。処理ルールの全体は [Enterprise-Managed Authorization 仕様](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization) にあります。 + +不正なアサーションは `TokenError("invalid_grant", ...)` で拒否してください。このフローのもう 1 つのエラーコードは `invalid_target` です。提供していないリソースを指す ID-JAG はこれで拒否され、それによってこのサーバーが他人のリソース向けのトークンを発行するのを防ぎます。そして付与するスコープは ID-JAG の `scope` クレームから取ります(これを持たないアサーションも拒否されます)。実際のサーバーでは、代わりにユーザーのグループをマッピングするかもしれません。 + +返される `OAuthToken` が持っていないものにも注目してください。リフレッシュトークンです。IdP は、次の ID-JAG を発行するかどうかを決めることで、このユーザーがいつまでアクセスを保てるかを決めます。ここでリフレッシュトークンを発行してしまうと、その決定権をこっそり手放すことになります。 + +!!! info + 今も `auth_server_provider=` で認可サーバーを組み込んでいるサーバーは、`AuthSettings(identity_assertion_enabled=True)` を通じて同じコードに到達します。新しいサーバーがそこから始めるべきでない理由は **[認可](../run/authorization.md)** で説明しています。 + +!!! check + このページの 2 つのファイルをつなぎ合わせると、グラント全体は 1 回の `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"} + ``` + + `/authorize` も、`/register` も、protected resource metadata の取得もありません。通信路に流れるリクエストは、`401` を引き出したもの、well-known の取得、この交換、そしてベアラートークンを付けた通常の MCP トラフィックだけです。そして、バリデーターが ID-JAG から読み取った `sub` は、ツールの中で `get_access_token().subject` が報告する値とまったく同じです。 + +### 試してみる {#try-it} + +SDK リポジトリの `examples/stories/identity_assertion/` は、このページを実際に動かしたものです。同じ `exchange_identity_assertion` バリデーター、そのトークンで保護された MCP サーバー、代役の IdP、そしてクライアントが、1 つの自己検証プログラムにまとまっています。`uv run python -m stories.identity_assertion.client --http` で交換全体を実行し、IdP が名指ししたユーザーがツールから見えるユーザーであることを assert します。 + +## まとめ {#recap} + +* [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) により、クライアントがどの MCP サーバーに到達してよいかを、エンドユーザーではなく企業の ID プロバイダーが決められます。IdP はその決定を **ID-JAG** に署名して封じ込めます。 +* ID-JAG の取得は「自分の IdP」に対する [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) のトークン交換であり、SDK は行いません。それを MCP 認可サーバーに提示するのが [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) の `jwt-bearer` グラントで、SDK はその両側を担います。 +* `IdentityAssertionOAuthProvider` もまた `httpx2.Auth` の 1 つです。事前登録済みのコンフィデンシャルクライアント、固定した `issuer`、そして 1 つの `assertion_provider(audience, resource)` コールバックから成ります。ブラウザーも、登録も、リフレッシュトークンもありません。 +* 認可サーバーがリソースサーバーから発見されることはありません。`issuer` には、そのメタデータ文書が返す文字列と完全に同じものを設定してください。比較は 1 文字ずつです。 +* サーバー側は `identity_assertion_enabled=True` と `exchange_identity_assertion` です。SDK はクライアントを認証し、グラントの可否を判定します。ID-JAG の検証は完全に自分の責任で、発行されるトークンはリクエストのものではなく ID-JAG の `resource` に束縛されます。 + +このページが一度も触れなかった当事者が 1 つあります。MCP サーバーです。たった今発行したトークンで MCP サーバーが何をするかは、**[認可](../run/authorization.md)** ですでに行っていたことです。 diff --git a/i18n/ja/pages/client/index.md b/i18n/ja/pages/client/index.md new file mode 100644 index 0000000000..e9a9f266b1 --- /dev/null +++ b/i18n/ja/pages/client/index.md @@ -0,0 +1,207 @@ +--- +translation: + sections: [ebef1e7a0df854f4, a4c687d3d627d516, 8e79141fc2985342, b345dd05b9c3c7ab, 80ce41579825a6fa, 5f0fa90494de8f65, 83d10514eaa62fa5, 9190555aa39a5d28, 84a4c9d8bf14dddb, 927d71cf40b58c30] + tool: 1 +--- +# Client {#the-client} + +**`Client`** は、Python プログラムが MCP サーバーと対話するための手段です。 + +1 つのオブジェクトに 1 つのライフサイクルがあります。組み立てて、`async with` に入り、メソッドを呼び出します。プロトコルの動詞(ツールの一覧取得、ツールの呼び出し、リソースの読み取り、プロンプトのレンダリング)はどれも、このオブジェクトの `async` メソッドで、型付きの結果を返します。 + +## 最初のクライアント {#your-first-client} + +```python title="client.py" hl_lines="14-18" +--8<-- "docs_src/client/tutorial001.py" +``` + +冒頭のサーバーは、接続先を用意するためだけにあります。クライアントはハイライトされた 5 行です。 + +* `Client(mcp)` には**サーバーオブジェクトそのもの**を渡しています。これがインメモリのトランスポートです。サブプロセスもポートも HTTP もありません。このページのすべての例、そして作成するすべてのテストが、この方法で接続します。 +* `async with` が**ライフサイクル**です。入ると接続してネゴシエーションを行い、出ると切断します。`connect()` / `close()` のペアはなく、ブロックが終わった後の `Client` は再利用できません。 +* ブロックの中では、接続に関する情報がすでに通常のプロパティとして揃っています。 + +### `Client` に渡せるもの {#what-you-can-pass-to-client} + +`Client` は位置引数を 1 つ取り、その型からトランスポートを決定します。 + +* `MCPServer`(または低レベルの `Server`)のインスタンス:**プロセス内**で接続します。 +* URL 文字列(`Client("http://localhost:8000/mcp")`):Streamable HTTP。本番向けの経路です。 +* **トランスポート**:`async with ... as (read, write)` できるものなら何でも。たとえばサブプロセスをラップする `stdio_client(...)` です。 + +このページの残りの内容は、3 つのどれでも同じです。ヘッダー、サブプロセス、タイムアウト、そして `Transport` プロトコルについては、専用のページ **[クライアントのトランスポート](transports.md)** があります。 + +### 接続済みクライアントが持つもの {#whats-on-a-connected-client} + +読み取り専用のプロパティが 4 つあり、ブロックに入った瞬間に値が入ります。 + +* `client.server_info`:サーバーの識別情報。報告しない 2026 年世代のサーバーでは `None` です(python-sdk のサーバーはデフォルトで報告します)。ここでは `server_info.name` が `"Bookshop"` で、`server_info.version` はサーバーが報告する値です。 +* `client.server_capabilities`:サーバーができること(`tools`、`resources`、`prompts`、`completions`、...)。サーバーが持たないケイパビリティは `None` です。 +* `client.protocol_version`:両者が合意したプロトコルバージョン。ここでは `"2026-07-28"` です。 +* `client.instructions`:サーバーの `instructions=` 文字列。設定されていなければ `None` です。 + +プロトコルバージョンを選んだ覚えはないはずです。デフォルトでは `Client` がサーバーを調べ、古いサーバーに対しては従来のハンドシェイクにフォールバックします。そのため、1 つのクライアントがどの世代のサーバーに対しても動作します。これを制御する必要がある場合、詳しくは **[プロトコルバージョン](../protocol-versions.md)** を参照してください。 + +!!! tip + `client.session` は下層の `ClientSession` で、低レベルへの抜け道です。このページの内容では必要ありません。 + +## ツールの一覧取得 {#listing-tools} + +```python title="client.py" hl_lines="15-20" +--8<-- "docs_src/client/tutorial002.py" +``` + +`list_tools()` は `ListToolsResult` を返し、ツールは `.tools` に入っています。それぞれが、ホストがモデルに渡す完全な定義です。 + +```python +tool.name # 'search_books' +tool.title # 'Search the catalog' +tool.description # 'Search the catalog by title or author.' +``` + +そして `tool.input_schema` は、サーバーが関数の型ヒントから導き出した JSON Schema です。 + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"default": 10, "title": "Limit", "type": "integer"} + }, + "required": ["query"], + "title": "search_booksArguments" +} +``` + +このスキーマには、UI が引数フォームを描画するのに必要なものも、モデルが有効な引数を生成するのに必要なものも、すべて含まれています。 + +!!! tip + `title` は省略可能なので、人間にツールを見せる UI はどちらかを選ぶ必要があります。`title` があればそれを、なければ `name` を使います。`from mcp.shared.metadata_utils import get_display_name` がまさにそれを行い、ツール、リソース、リソーステンプレート、プロンプトに対応しています。 + +## ツールの呼び出し {#calling-a-tool} + +`call_tool(name, arguments)` はツールを実行し、`CallToolResult` を返します。 + +```python title="client.py" hl_lines="26-33" +--8<-- "docs_src/client/tutorial003.py" +``` + +サーバーの `lookup_book` は Pydantic の `Book` を返します。クライアントから見えるのは次のとおりです。 + +```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 +``` + +戻り値は 1 つ、読むべきものは 3 つです。それぞれ読み手が異なります。 + +### `content`:モデルが読むもの {#content-what-the-model-reads} + +`content` は**コンテンツブロック**の `list` で、コンテンツブロックはユニオン型です。`TextContent`、`ImageContent`、`AudioContent`、`ResourceLink`、`EmbeddedResource` のいずれかです。ツールは種類の異なるブロックを複数返せます。 + +`main` が `block.text` に触れる前に `isinstance(block, TextContent)` で絞り込んでいるのはそのためです。`isinstance` の外に `.text` がないことに注目してください。`ImageContent` が持つのは `.text` ではなく `.data` なので、型チェッカーが許しません。このユニオンは、ツールが送ってよいものを正直に表しています。コードもそうあるべきです。 + +### `structured_content`:アプリケーションが読むもの {#structured_content-what-your-application-reads} + +`structured_content` はツールの戻り値を JSON にしたもので、ツールが宣言した `output_schema` に一致します。文字列の解析も推測も不要です。 + +両方があるときは、意図的に同じことを 2 回言っています。`content` はモデル向け、`structured_content` はコード向けです。構造化されたほうがどこから来るのか、どう制御するのかは、**[構造化出力](../servers/structured-output.md)** のページで説明しています。 + +### `is_error`:ツールが失敗したかどうか {#is_error-whether-the-tool-failed} + +例外を送出するツールが、クライアント側で例外を送出することは**ありません**。`is_error=True` の付いた通常の結果として返ってきます。 + +!!! check + `lookup_book` に `"Solaris"`(カタログにない書名)を問い合わせると、関数は `ValueError` を送出します。それでも呼び出しは正常に返ります。 + + ```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 + ``` + + 例外のメッセージは `content` に入りました。そこなら**モデル**が読んで、やり直せます。これは意図的なものです。ツールのエラーはクラッシュではなく、会話の一部です。`structured_content` を信用する前に、必ず `is_error` を確認してください。 + +!!! warning + `is_error=True` がカバーするのは、自分で書いた `raise` だけではありません。サーバーに存在すらしないツールを要求しても(`call_tool("does_not_exist", {})`)、何も送出されません。同じ形の結果が返り、`is_error=True` で `content` には `Unknown tool: does_not_exist` が入ります。`Client` のメソッドが `MCPError` を送出するのは、サーバーが結果ではなく JSON-RPC の**エラー**で応答したときだけです。サーバーがどんなときにどちらを返すかは **[エラーの処理](../servers/handling-errors.md)** で扱っています。 + +## リソース {#resources} + +リソースの動詞は組になっています。一覧取得が 2 通り、読み取りが 1 通りです。 + +```python title="client.py" hl_lines="22-31" +--8<-- "docs_src/client/tutorial004.py" +``` + +* `list_resources()` は**具体的な**リソース、つまり URI が固定のものを返します。ここでは `['catalog://genres']` です。 +* `list_resource_templates()` は**パラメーター化された**ものを返します。ここでは `['catalog://genres/{genre}']` です。テンプレートは値を埋めるまで読み取れないため、2 つは別々のリストになっています。 +* `read_resource(uri)` は通常の `str` の URI を受け取り、両方に対して動作します。`"catalog://genres/poetry"` を渡せば、サーバーがテンプレートに照合します。 + +`read_resource` は `contents` を返します。これは `TextResourceContents` または `BlobResourceContents` のリストです。考え方はツールのコンテンツと同じで、`isinstance` で絞り込んでから `.text`(または `.blob`)を読みます。 + +クライアントは、リソースが変更されたときに通知を受けることもできます。2025 年世代の接続では `subscribe_resource(uri)` / `unsubscribe_resource(uri)` がそれにあたります。ただしこのメソッドのペアは `MCPServer` が実装していないため、2026-07-28 の通信上(これらの動詞はもう存在しません)ではリクエストに `-32601`、*Method not found* が返ります。2026 年の代替は `subscriptions/listen` ストリームで、こちらは `MCPServer` が実際に提供しています(そこでは `server_capabilities.resources.subscribe` が `True` です)。これを `client.listen(...)` で消費する方法は、このセクションの **[サブスクリプション](subscriptions.md)** のページで説明しています。 + +## プロンプト {#prompts} + +```python title="client.py" hl_lines="15-20" +--8<-- "docs_src/client/tutorial005.py" +``` + +`list_prompts()` は、サーバーが何を提供していて、各プロンプトが何を必要とするかを教えてくれます。 + +```python +prompt.name # 'recommend' +prompt.title # 'Recommend a book' +prompt.arguments # [PromptArgument(name='genre', required=True)] +``` + +`get_prompt(name, arguments)` でレンダリングします。引数の dict は `str -> str` で、プロンプトの引数は常に文字列です。結果は `messages`、つまり `PromptMessage` のリストで、それぞれが `role` と `content` ブロックを持ちます。 + +```python +message.role # 'user' +message.content # TextContent(type='text', text='Recommend one poetry book from the catalog and say why.') +``` + +ホストはこれらのメッセージをそのままモデルに渡します。機能はこれだけです。 + +## 補完 {#completions} + +補完ハンドラーを持つサーバーは、ユーザーの入力に合わせてプロンプトやリソーステンプレートの引数を自動補完できます。 + +```python title="client.py" hl_lines="27-31" +--8<-- "docs_src/client/tutorial006.py" +``` + +* `ref` は、「どの」プロンプトまたはテンプレートを埋めているかを示します。`PromptReference` または `ResourceTemplateReference` です。 +* `argument` は `{"name": ..., "value": ...}` で、引数と、ユーザーがこれまでに入力した内容です。 + +答えは `result.completion.values` に入っています。`"p"` と入力すると、サーバーは `['poetry']` を返します。サーバー側の実装と、ハンドラーがすでに埋まっている「他の」引数を使って候補を絞り込む方法は、**[補完](../servers/completions.md)** のページで説明しています。 + +## ページネーション {#pagination} + +`list_*` メソッドはどれも `cursor=` キーワードを取り、結果はどれも `next_cursor` を持ちます。`next_cursor` が `None` なら、すべて取得済みです。 + +```python title="client.py" hl_lines="22-30" +--8<-- "docs_src/client/tutorial007.py" +``` + +このループはどのサーバーに対しても正しく動きます。`MCPServer` はすべてを 1 ページで返すので、`next_cursor` は `None` になり、ループは 1 回だけ実行されます。ほとんどのコードがこのループを書かないのはそのためです。実際にページ分割するサーバーと、カーソルが従うルールについては **[ページネーション](../advanced/pagination.md)** を参照してください。 + +## テストでの利用 {#in-tests} + +プロセスもポートも使わない `Client(mcp)` は、それだけでサーバーのテストハーネスになります。 + +そのために用意されたコンストラクターのフラグが 1 つあります。`Client(mcp, raise_exceptions=True)` です。効果があるのはインメモリ接続のときだけで、その説明と、それを中心にしたパターン全体の組み立ては **[テスト](../get-started/testing.md)** のページにあります。 + +## まとめ {#recap} + +* `Client(x)` は、サーバーオブジェクトにはインメモリで、URL 文字列には Streamable HTTP で、それ以外にはトランスポート経由で接続します。 +* `async with` がライフサイクルのすべてです。その中では `server_capabilities` と `protocol_version` にすでに値が入っており、サーバーが提供していれば `server_info` と `instructions` も同様です。 +* `list_tools()` で各ツールの `name`、`title`、`description`、`input_schema` が得られます。 +* `call_tool()` はモデル向けの `content`、コード向けの `structured_content`、そして `is_error` を返します。例外を送出するツールは、例外ではなく結果として返ってきます。 +* `content` はブロック型のユニオンです。読む前に `isinstance` で絞り込みます。 +* `list_resources` / `list_resource_templates` / `read_resource`、`list_prompts` / `get_prompt`、そして `complete` で動詞は一通り揃います。 +* `list_*` はどれも `cursor=` を取ります。`next_cursor` が `None` になるまでループします。 + +サーバーのほうからクライアントに要求できることと、それにどう応えるかは、**[クライアントのコールバック](callbacks.md)** で扱います。 diff --git a/i18n/ja/pages/client/oauth-clients.md b/i18n/ja/pages/client/oauth-clients.md new file mode 100644 index 0000000000..e71580ae43 --- /dev/null +++ b/i18n/ja/pages/client/oauth-clients.md @@ -0,0 +1,145 @@ +--- +translation: + sections: [c6899d3892bd9fa0, 79372cff3cc48a88, 63878d29e87c3e73, 13175843d3588af4, e7e2b9fd516f77de, 758f06399b513c1f, a05d7278487d610b] + tool: 1 +--- +# OAuth クライアント {#oauth-clients} + +一部の MCP サーバーは保護されています。トークンなしでリクエストを送ると、`401 Unauthorized` が返ってきます。 + +そのトークンを手に入れる手段が **`OAuthClientProvider`** です。これは MCP のオブジェクトではまったくありません。`httpx2.Auth`、つまり「すべてのリクエストに何かを施す」ための httpx2 標準のフックです。これを `httpx2.AsyncClient` に取り付け、そのクライアントを Streamable HTTP トランスポートに渡せば、あとは気にする必要がありません。 + +このページはクライアント側の話です。自分のサーバーにトークンを要求させる方法は **[認可](../run/authorization.md)** で扱います。 + +## プロバイダー {#the-provider} + +```python title="client.py" hl_lines="44-54" +--8<-- "docs_src/oauth_clients/tutorial001.py" +``` + +渡すものは 4 つです。 + +* `server_url`:接続先の MCP エンドポイント。プロバイダーはそれ以外のすべてをここから発見します。 +* `client_metadata`:認可サーバーの「アプリケーションを登録する」フォームに入力するような内容。 +* `storage`:実行と実行のあいだにトークンを保管しておく場所。 +* `redirect_handler` と `callback_handler`:人間が関わる 2 つの場面。 + +ファイル内のほかの箇所には OAuth は一切登場しません。`main()` がトークンを目にすることはありません。 + +### クライアントメタデータ {#client-metadata} + +`OAuthClientMetadata` は、本物の [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591) 登録ドキュメントを Pydantic モデルにしたものです。 + +設定するフィールドは 3 つです。残りはデフォルト値が埋めてくれます。`grant_types` は最初から `["authorization_code", "refresh_token"]`、`response_types` は最初から `["code"]` で、これはまさにこのプロバイダーが実行するフローです。 + +!!! check + Pydantic モデルなので、**ネットワークに 1 バイトも流れる前に**検証されます。 + `redirect_uris` を省くと、構築の時点でそのフィールド名を指した `ValidationError` で失敗します。 + + ```text + redirect_uris + Field required [type=missing, input_value={'client_name': 'Bookshop Agent'}, input_type=dict] + ``` + + ブラウザーは開かず、認可サーバーに中途半端な登録が残ることもありません。 + +### トークンストレージ {#token-storage} + +**`TokenStorage`** は 4 つの非同期メソッドを持つ `Protocol` です。何かを継承する必要はありません。メソッドを書けば、どんなクラスでもトークンストアになります。 + +* `get_tokens` / `set_tokens` は `OAuthToken`(アクセストークン、リフレッシュトークン、有効期限、スコープ)を保持します。 +* `get_client_info` / `set_client_info` は、プロバイダーが登録したときに認可サーバーが発行した `OAuthClientInformationFull`(`client_id` を含む)を保持します。 + +上のインメモリ版はちゃんと動きます。ただしプロセスが終了するとすべてを忘れるので、次の実行では一連の手順を最初からやり直すことになります。ファイルやプラットフォームのキーリングに永続化すれば、次の実行は何も聞かれずに済みます。 + +!!! tip + トークンだけでなく `client_info` も保存してください。プロバイダーは、保存済みの `client_info` が見つからない初回に動的登録を行います。これを捨ててしまうと、実行のたびに新しい登録を発行することになります。 + +### 2 つのハンドラー {#the-two-handlers} + +認可コードフローで人間が必要になるのはちょうど一度だけです。誰かがサインインして「許可」をクリックしなければなりません。 + +* **`redirect_handler`** は、完全に組み立て済みの認可 URL を引数に await されます。`client_id`、`redirect_uri`、`state`、PKCE チャレンジはすでにその中に入っています。やるべきことはブラウザーをそこへ向かわせることだけです。デスクトップアプリなら `webbrowser.open` を呼び、このファイルでは表示するだけです。 +* 次に **`callback_handler`** が await されます。ユーザーが `redirect_uri` に戻ってくるまで待ち、そのリダイレクトのクエリパラメーターを `AuthorizationCodeResult` として返します。 + +実際のクライアントは、`input()` を呼ぶ代わりにリダイレクト URI 上で小さなローカル HTTP サーバーを動かします。形はまったく同じです。リダイレクトを受け取り、`code`、`state`、`iss` を返します。 + +!!! warning + `state` と `iss` は届いたとおりそのまま渡してください。プロバイダーは `state` を自分が生成したものと、`iss` を発見した発行者と照合し、一致しなければ拒否します。これらは CSRF とサーバー取り違えに対する防御です。 + +### `Client` へ {#into-the-client} + +`main()` を見てください。プロバイダーは **httpx2 クライアント**に載り、httpx2 クライアントは `streamable_http_client(url, http_client=...)` に入り、そのトランスポートが `Client` に入ります。 + +`streamable_http_client` には `auth=` キーワードがありません。HTTP レベルのもの(認証、ヘッダー、タイムアウト、プロキシ)はすべて、持ち込む `httpx2.AsyncClient` に設定します。このレイヤー構成については **[クライアントのトランスポート](transports.md)** を参照してください。 + +## プロバイダーがやってくれること {#what-the-provider-does-for-you} + +`Client` が初めてリクエストを送ると、サーバーは `401` を返します。そこからプロバイダーが引き継ぎます。 + +1. **発見。** `WWW-Authenticate` ヘッダーを読み、サーバーの Protected Resource Metadata を `/.well-known/oauth-protected-resource` から取得します。そこからこのリソースを保護している認可サーバーを知り、「その」サーバーのメタデータを取得します。 +2. **登録。** ストレージに何もなければ、`OAuthClientMetadata` を使って動的に登録し、結果を保存します。 +3. **認可。** PKCE のペアと `state` を生成し、認可 URL を組み立て、`redirect_handler` を await します。続いて、コードを受け取るために `callback_handler` を await します。 +4. **交換。** コードを `OAuthToken` と引き換えて保存し、元のリクエストを `Authorization: Bearer ...` 付きで再送します。 + +それ以降は静かになります。トークンはストレージから取り出され、期限切れのアクセストークンはリフレッシュトークンで更新されます。そのどれもうまくいかないときだけ、フローをもう一度実行します。 + +これらを自分で書く必要はまったくありませんでした。残るキーワード引数は 2 つ(`client_metadata_url` と `validate_resource_url`)で、このファイルではどちらも不要です。知っておく価値があるのは `client_metadata_url` のほうで、下に専用のセクションがあります。 + +### 試してみる {#try-it} + +このドキュメントの例のほとんどは、インメモリの `Client(server)` で確認できます。これは違います。このフローの要点は HTTP の `401` であり、インメモリのクライアントとサーバーのあいだには HTTP がありません。 + +リポジトリには実際に動くバージョンが同梱されています。`examples/servers/simple-auth/` はスタンドアロンの認可サーバーと保護された MCP サーバーを動かし、`examples/clients/simple-auth-client/` はこのページのクライアントを小さな CLI に育てたものです。その README に 2 つのコマンドが載っています。サーバーを起動し、それに対してクライアントを実行すれば、4 つのステップが進んでいくのを見られます。 + +## Client ID Metadata Documents {#client-id-metadata-documents} + +仕様の 2026-07-28 改訂では、動的クライアント登録が非推奨になり、代わりに **Client ID Metadata Documents**(CIMD)が推奨されます。出会う認可サーバーごとに新しい登録を POST する代わりに、クライアントは自分自身についての JSON ドキュメントを 1 つ、安定した HTTPS URL で公開します。そしてその URL がそのまま `client_id` になります。ドキュメントを取得するのは認可サーバーで、プロバイダーはそれに一切触れません。 + +SDK はすでにこれに対応しています。プロバイダーを構築するときに URL を `client_metadata_url=` として渡してください。認可サーバーのメタデータが `client_id_metadata_document_supported: true` を公表していれば、プロバイダーは `/register` リクエストを完全に省きます。URL が `client_id` としてフローに入り、`client_secret` はありません。サーバーがそれを公表していない場合(まだ大半がそうです)、または URL を渡さなかった場合、プロバイダーは**何も言わずに**動的登録にフォールバックし、上の説明どおりにすべてが動きます。保存済みの `client_info` は、依然としてそのどちらよりも優先されます。 + +URL は HTTPS で、ルート以外のパスを持っている必要があります。それ以外は、ネットワーク通信が起こる前の構築時点で `ValueError` になります。同梱の `examples/clients/simple-auth-client/` は、これを `MCP_CLIENT_METADATA_URL` 環境変数として受け取ります。 + +## マシン間通信 {#machine-to-machine} + +夜間ジョブ、CI のステップ、別のサービス。ブラウザーはなく、「許可」をクリックする人もいません。これが **クライアントクレデンシャル** グラントです。`client_id` と `client_secret` はすでに手元にあり、トークンエンドポイントがフローのすべてです。 + +`ClientCredentialsOAuthProvider` は同じ `httpx2.Auth` で、人間がいないだけです。 + +```python title="client.py" hl_lines="4 27-33" +--8<-- "docs_src/oauth_clients/tutorial002.py" +``` + +変わった点は次のとおりです。 + +* `OAuthClientMetadata` もハンドラーもありません。`client_id` と `client_secret` を渡すと、プロバイダーはそれらを中心に最小限の `client_credentials` 登録を組み立て、動的登録を完全に省きます。 +* `scope` はスペース区切りの文字列で、OAuth の通信上の形式です。 +* その先はすべて同じです。同じ `TokenStorage`、同じ `httpx2.AsyncClient(auth=...)`、同じ `streamable_http_client` です。 + +デフォルトでは、シークレットはトークンリクエストの HTTP Basic 認証として送られます(`client_secret_basic`)。代わりにフォームボディに入れるには、`token_endpoint_auth_method="client_secret_post"` を渡してください。認可サーバーによっては、2 つのうち片方しか受け付けません。 + +!!! tip + `client_secret` は環境変数かシークレットマネージャーから読み込んでください。ソース管理からは決して読み込まないでください。 + +!!! info + `mcp.client.auth.extensions.client_credentials` にはもう 1 つプロバイダーがあります。 + **`PrivateKeyJWTOAuthProvider`** は、共有シークレットの代わりに JWT で認証するクライアント向けです(`private_key_jwt`、つまり鍵ペアやワークロードアイデンティティの方式)。パターンは同じで、1 つ構築して `auth=` に載せます。同じモジュールには、そのアサーションを組み立てる 2 つのヘルパー、`SignedJWTParameters` と `static_assertion_provider` も含まれています。 + +人間がいない状況はもう 1 つあります。クライアントが企業に属していて、どの MCP サーバーに到達してよいかをユーザーではなくその企業のアイデンティティプロバイダーが決める場合です。これは独自の信頼モデルを持つ別のグラントで、専用のページ **[アイデンティティアサーション](identity-assertion.md)** があります。 + +## 失敗したとき {#when-it-fails} + +OAuth フローがうまくいかないと、プロバイダーは `mcp.client.auth` の `OAuthFlowError` を送出します。これには 2 つのサブクラスがあります。`OAuthRegistrationError` は、登録の結果として使えるクライアントが得られなかったことを意味します。認可サーバーが登録を拒否したか、登録はされたもののこのフローでは使えないクレデンシャル(たとえば実装していない認証方式)だった場合です。`OAuthTokenError` は、トークンを取得できなかったことを意味します。トークンエンドポイントに拒否されたか、保存済みのクライアントレコードにこのクライアントが適用できない認証方式が含まれていた場合で、後者は送信されずにトークンリクエストの組み立て中に報告されます。`except OAuthFlowError:` 1 つで、発見、登録、認可、交換のすべてをカバーできます。 + +すべてがフローエラーというわけではありません。ネットワークが失敗することもあります。それらは通常の `httpx2` の例外で、手を加えられずにそのまま通り抜けます。 + +## まとめ {#recap} + +* `OAuthClientProvider` は `httpx2.Auth` です。`httpx2.AsyncClient` に載せ、それを `streamable_http_client(url, http_client=...)` に渡せば、`Client` は OAuth が行われたことを知ることすらありません。 +* 渡すものは 4 つです。サーバーの URL、`OAuthClientMetadata`、`TokenStorage`、そしてリダイレクト/コールバックのハンドラーのペアです。 +* `TokenStorage` は `Protocol` です。非同期メソッドが 4 つで、基底クラスはありません。トークンだけでなく `client_info` も永続化してください。 +* 発見、登録(動的、または **Client ID Metadata Document** 経由)、PKCE、`state` と `iss` のチェック、トークンの更新はプロバイダーの仕事であり、呼び出し側の仕事ではありません。 +* `ClientCredentialsOAuthProvider` は人間がいない版です。`client_id` と `client_secret` だけで、ハンドラーもブラウザーも要りません。 +* OAuth の失敗はすべて `OAuthFlowError` です。`OAuthRegistrationError` と `OAuthTokenError` がそのサブクラスです。 + +このハンドシェイクのもう半分、つまり「サーバー」にトークンを要求させる方法は **[認可](../run/authorization.md)** で扱います。 diff --git a/i18n/ja/pages/client/session-groups.md b/i18n/ja/pages/client/session-groups.md new file mode 100644 index 0000000000..88c62acb0b --- /dev/null +++ b/i18n/ja/pages/client/session-groups.md @@ -0,0 +1,85 @@ +--- +translation: + sections: [09c857a25a9dc37a, 43bc6a76a243a50e, 0a716022a88768df, 4b7f78042bfcfff7, c112662e61b03315, 58974ba1f489a8b4, d18adbdbb835ea73] + tool: 1 +--- +# セッショングループ {#session-groups} + +`Client` は 1 つのサーバーに接続します。実際のアプリケーションでは複数のサーバー(検索サーバー、データベースサーバー、社内 API など)を使いたいことが多く、結局それぞれの接続とツール一覧を個別に管理することになります。 + +**`ClientSessionGroup`** は、多数の接続を保持し、それらが公開するものすべてを 1 つのビューにまとめる単一のオブジェクトです。 + +## 2 つのサーバー {#two-servers} + +まず、ごく普通のサーバーを 2 つ用意します。互いに何の関係もないので、どちらも自然とツールに `search` という名前を付けています。 + +```python title="library_server.py" hl_lines="7" +--8<-- "docs_src/session_groups/tutorial001.py" +``` + +```python title="web_server.py" hl_lines="7" +--8<-- "docs_src/session_groups/tutorial002.py" +``` + +## 1 つのグループ {#one-group} + +`ClientSessionGroup` を作成し、サーバーごとに **`connect_to_server`** を 1 回ずつ呼び出します。 + +```python title="client.py" hl_lines="10-12" +--8<-- "docs_src/session_groups/tutorial003.py" +``` + +* `connect_to_server` はサーバーオブジェクトではなく、トランスポートのパラメーターを受け取ります。サブプロセスを起動するなら `StdioServerParameters`(`mcp` から)、すでに URL で待ち受けているサーバーなら `StreamableHttpParameters` または `SseServerParameters`(`mcp.client.session_group` から)です。 +* `group.tools` は、接続しているすべてのサーバーのツールを集めた `dict[str, Tool]` です。`group.resources` と `group.prompts` も同じ形です。 +* `group.call_tool(name, arguments)` は名前を引き、それを所有するセッションを見つけて呼び出しを転送します。どのサーバーかを指定する必要はありません。 + +!!! check + `client.py` を 2 つのサーバーと同じ場所に置いて実行してください。2 回目の `connect_to_server` は拒否されます。 + + ```text + mcp.shared.exceptions.MCPError: {'search'} already exist in group tools. + ``` + + これは `MCPError` で、2 つ目のサーバーの何かが登録される前に送出されます。名前はグループ**全体**で一意でなければならず、自分で管理していない 2 つのサーバーはいずれ衝突します。 + +## `component_name_hook` {#component_name_hook} + +これはサーバー側ではなく、グループ側で解決します。`(name, server_info)` を受け取る関数を渡すと、グループは登録するすべての名前に対してその関数を実行します。 + +```python title="client.py" hl_lines="7-8 15" +--8<-- "docs_src/session_groups/tutorial004.py" +``` + +もう一度実行してください。`print(sorted(group.tools))` には両方が表示されます。 + +```text +['Library.search', 'Web.search'] +``` + +* **キー**は自分で決めたものです。`by_server` は `server_info.name`、つまり各 `MCPServer(...)` の構築時に渡された名前からキーを組み立てました。 +* 中の `Tool` は変更されていません。`group.tools["Web.search"].name` は依然として `"search"` であり、`call_tool` が通信路に載せるのはこの名前です。プレフィックスがプロセスの外に出ることはありません。 +* ツールだけではありません。ライブラリの `hours` リソースは `Library.hours` として登録されます。 + +!!! tip + フックは衝突したものだけでなく、**すべて**のサーバーの**すべて**の名前に対して実行されます。衝突時だけプレフィックスを付けるモードはありません。1 つの方式を決めて、全体に適用してください。 + +## サーバーの追加と削除 {#adding-and-removing-servers} + +`connect_to_server` は開いた `ClientSession` を返します。後でそのサーバーを外したくなる場合に備えて保持しておいてください。`await group.disconnect_from_server(session)` で、そのサーバーのツール、リソース、プロンプトがグループから削除されます。 + +すでに接続済みの `ClientSession` を持っている場合(`Client.session` がそうです)、新しいトランスポートを開く代わりに `await group.connect_with_session(server_info, session)` に渡してください。同じように集約されます。グループは、自分で開いていないセッションを閉じることはありません。`server_info` はコンポーネントのプレフィックスに使うサーバー名を指定します。2026 年世代の接続では `client.server_info` が `None` になることがある(識別情報は任意です)ため、その場合は自分で `Implementation(name=..., version=...)` を渡してください。 + +## 従来のハンドシェイク {#the-classic-handshake} + +`ClientSessionGroup` は `Client` ではなく `ClientSession` の上に構築されています。`connect_to_server` を呼ぶたびに従来の `initialize` ハンドシェイクが実行されます。**[プロトコルバージョン](../protocol-versions.md)**で説明している `server/discover` プローブを送ることはありません。このハンドシェイクはすべての MCP サーバーが理解するので、互換性が失われることは一切ありません。ただ、もっと良い方法に対応しているサーバーに対しても、グループは古くて遅い経路を取るというだけです。 + +## まとめ {#recap} + +* `ClientSessionGroup` は多数のサーバー接続を保持し、それらのツール、リソース、プロンプトをそれぞれ 1 つの `dict` にまとめます。 +* サーバーごとに `connect_to_server(params)` を呼びます。受け取るのはトランスポートのパラメーターであり、`Client` が受け取るサーバーオブジェクトや URL ではありません。 +* `group.call_tool(name, arguments)` は、所有するサーバーへのルーティングを代わりに行います。 +* 名前はグループ全体で一意でなければなりません。`search` ツールを持つ 2 つのサーバーは、そのままでは共存できません。 +* `component_name_hook=` は登録されるすべての名前を書き換えます。dict のキーは変わりますが、実際に送信される名前は変わりません。 +* `connect_with_session` はすでに持っているセッションを追加し、`disconnect_from_server` はセッションを削除します。 + +グループが使うハンドシェイク(と、`Client` が優先するより高速なハンドシェイク)について詳しくは、**[プロトコルバージョン](../protocol-versions.md)**を参照してください。 diff --git a/i18n/ja/pages/client/subscriptions.md b/i18n/ja/pages/client/subscriptions.md new file mode 100644 index 0000000000..eeb0dc723c --- /dev/null +++ b/i18n/ja/pages/client/subscriptions.md @@ -0,0 +1,88 @@ +--- +translation: + sections: [8f9558e57f29eee1, a88c587739e0465c, 46ebfd5b325ed041, 4d10b00b57ce4bd9, 2cdb0edd1f59b3e2] + tool: 1 +--- +# サブスクリプション {#subscriptions} + +サーバーのカタログは固定ではありません。ツールは実行時に現れ、リソース URI の背後にある内容は変化します。クライアントはそれを `client.listen(...)` を通じて知ります。これは 1 つの `subscriptions/listen` リクエストで、そのレスポンスが「そのまま」ストリームになります。ストリームは開いたままになり、クライアントが求めた変更通知を運びます。 + +このページはクライアント側の話です。ストリームを開き、メインの処理の横で監視し、その終わり方を扱います。変更の公開、フィルタリング、このメソッドの提供はサーバー側の話で、「ハンドラーの中で」にある **[サブスクリプション](../handlers/subscriptions.md)** で説明しています。ここでの例は、そこで作ったスプリントボードサーバーと通信します。 + +## ストリームを監視する {#watching-the-stream} + +サブスクリプションは 1 つのコンテキストマネージャーです。中に入るとリクエストが送られ、キーワード引数がサブスクリプションのフィルターになります。そのうえでサーバーの確認応答を待つので、ブロックが始まる時点でストリームはすでに稼働しています。 + +```python title="client.py" hl_lines="15 18 28" +--8<-- "docs_src/subscriptions/tutorial003.py" +``` + +反復処理では 4 種類の型付きイベントが得られます。`ToolsListChanged`、`PromptsListChanged`、`ResourcesListChanged`、`ResourceUpdated(uri=...)` です。 + +イベントが伝えるのは「何が」変わったかであり、「どう」変わったかではありません。`follow_board` が `read_resource` と `list_tools` を呼ぶのはそのためで、イベントは再取得の合図です。どのリソースが動いたかを決めつけず、`event.uri` を読んでください。フィルターは複数の URI を指定でき、サーバーはそのうちの 1 つのサブリソースに対する変更を報告することもあります。 + +消費待ちの重複したイベントは 1 つにまとめられますが、再取得すれば現在の状態は得られます。まとめられるのは同一のイベントだけです。異なる URI に対する 2 つの `ResourceUpdated` は 2 つのイベントです。 + +ハンドルにはさらに 2 つのプロパティがあります。 + +* `sub.honored` はサーバーが確認応答したフィルターです。渡したフィールドを持つ `SubscriptionFilter` で、属性として読めます(`sub.honored.prompts_list_changed`)。`MCPServer` は求めた種類をすべて受け入れるので、リクエストをそのまま返します。対応する種類が少ないサーバーは確認応答する内容も少なくなり、受け入れられた種類でも一度も発火しないことがあります。サーバーは確認応答する代わりにリクエスト全体を拒否することもあり(サーバー側のページの[誰が監視できるかを決める](../handlers/subscriptions.md#deciding-who-may-watch)を参照)、その場合はリクエストのエラーとして表面化します。 +* `sub.subscription_id` は listen リクエストの ID で、このストリームのすべてのフレームに刻まれます。複数のサブスクリプションを同時に開くことができ、それぞれが自分の ID で多重分離されます。 + +## ブロックせずに監視する {#watching-without-blocking} + +`follow_board` はサーバーがストリームを閉じるまで動き続けます。それは永遠に来ないかもしれないので、単独で動かすとプログラムを占有してしまいます。実際のクライアントが欲しいのは、メインの処理の「横で」動くウォッチャーです。エージェントがツールを呼ぶ一方で、ウォッチャーがキャッシュや UI を最新に保ちます。 + +まずサブスクリプションを開き、それからウォッチャーを起動して本来の作業に取りかかってください。 + +=== "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` は最初の例から `BOARD` と `read_board` をインポートします。このリポジトリではその例を `tutorial003.py` として保存しています。レンダリングされたファイルを `client.py` と `app.py` として並べて保存する場合は、代わりに `from client import BOARD, read_board` と書いてください。さらに下の `watch.py` の例も同じように `read_board` をインポートします。 + +重要なのは順序です。何も再送されないので、ストリームができる前に公開されたイベントは取りこぼします。`client.listen(...)` に入ると確認応答を待つので、その瞬間以降のすべての変更はウォッチャーに届きます。ブロックの中で取るスナップショットが変更を取りこぼすことはありません。 + +リクエストは開いたストリームの横で自由に実行できます。ウォッチャーのタスクからでも他のタスクからでも、同じクライアント上で構いません。未消費の「重複した」イベントはまとめられるので、メインの処理が忙しいときは再取得が 3 回ではなく 1 回で済むこともあります。異なるイベントはまとめられません。多くの URI を指定したフィルターでは、URI ごとに保留中のイベントが 1 つずつキューに入ります。 + +監視をやめるにはブロックを抜けます。`unsubscribe` の呼び出しはありません。ブロックを所有するタスクをキャンセルすればそうなり、SDK はトランスポートが期待する方法で listen リクエストをキャンセルします。Streamable HTTP では、そのリクエストのストリームを閉じます。アプリの寿命のあいだ動き続けるウォッチャーは自分からは返らないので、シャットダウン時にそのタスク、またはタスクグループのスコープをキャンセルしてください。 + +## ストリームの終わり {#streams-end} + +ストリームの終わり方は 2 通りあり、どちらも通常の制御フローです。サーバーが正常に閉じると `async for` が終わり、突然切れると `SubscriptionLost` が送出されます。 + +この違いは診断上のもので、次に何をすべきかの違いではありません。ストリームはなくなり、何も再送されず、まだ関心のあるウォッチャーは改めて listen して再取得します。 + +```python title="watch.py" hl_lines="16 20" +--8<-- "docs_src/subscriptions/tutorial005.py" +``` + +サーバーは自分の都合でストリームを正常に閉じます。バックログが大きくなりすぎた購読者を切り離す場合もそうです。そのため、きれいに終わったことは監視をやめる合図ではありません。改めて listen する前にバックオフしてください。 + +`SubscriptionLost` にはローカルな原因も 1 つあります。クライアントが保持する未消費イベントは最大 1024 件で、そこまで遅れた消費側は際限なく膨らむ代わりにサブスクリプションを失います。`async for` の本体は短く保ち、時間のかかる作業は別の場所で行ってください。 + +`keep_following` が捕捉するのは `SubscriptionLost` だけです。`listen()` に入るときには、`MCPError`(接続に失敗した、またはサーバーがこのメソッドを提供していない)、`TimeoutError`(確認応答が届かなかった)、`ListenNotSupportedError`(2026 年より前の接続)が送出されることもあります。ウォッチャーがそのうちどれを再試行すべきかを決めてください。最後のものは決して回復しません。 + +## まとめ {#recap} + +* `async with client.listen(...)` に入ります。入ると確認応答を待つので、その後に公開されたものを取りこぼすことはありません。 +* `async for event in sub` で反復します。イベントは再取得の合図であって、ペイロードではありません。 +* サブスクリプションを開いてからウォッチャーをタスクとして実行すれば、その横でツール呼び出しが流れ続けます。 +* きれいに終わればループが止まり、切れれば `SubscriptionLost` が送出されます。どちらの場合も、改めて listen して再取得します。その前にまずバックオフしてください。 +* ブロックを抜けることがサブスクリプションの解除です。 + +これらのイベントの公開、フィルターの絞り込み、1 プロセスを超えたスケーリングはサーバー側の話です。詳しくは **[サブスクリプション](../handlers/subscriptions.md)** を参照してください。同じイベントはクライアント側のキャッシュを正確に保つのにも役立ちます。次のページは **[キャッシュ](caching.md)** です。 diff --git a/i18n/ja/pages/client/transports.md b/i18n/ja/pages/client/transports.md new file mode 100644 index 0000000000..7ac7c611ea --- /dev/null +++ b/i18n/ja/pages/client/transports.md @@ -0,0 +1,114 @@ +--- +translation: + sections: [9cac816674181eb0, 0700f337babcd4dd, 2bde0dd58cdf00f5, ff7401df479af877, 3d0832f39b0d7059, d4bf7e4479637768, 05e20c0a798860e7] + tool: 1 +--- +# クライアントのトランスポート {#client-transports} + +どの `Client` も、**トランスポート**を介してサーバーと対話します。トランスポートとは、実際にメッセージを運ぶもののことです。 + +トランスポートを別途設定することはありません。`Client` は位置引数を 1 つだけ受け取り、その型からトランスポートを判断します。 + +それぞれの「サーバー」側(`mcp.run()` が何をするのか、何をデプロイするのか)については、**[サーバーの実行](../run/index.md)** を参照してください。 + +## インメモリ {#in-memory} + +サーバーオブジェクトそのものを渡します。 + +```python title="client.py" hl_lines="14" +--8<-- "docs_src/client_transports/tutorial001.py" +``` + +サブプロセスも、ポートも、通信路を流れるバイト列もありません。クライアントとサーバーは同じプロセス内の 2 つのオブジェクトですが、呼び出しは本物のプロトコル層を通ります。`search_books` は、HTTP 越しの場合とまったく同じように一覧に載り、検証され、呼び出されます。 + +そのため、これは同時に 2 つの役割を果たします。 + +* **テストハーネス。** このドキュメントの例はすべてこの方法で実行されており、**[テスト](../get-started/testing.md)** のページはこのパターンを中心に組み立てられています。 +* **組み込み用の API。** サーバーを自分で構築するアプリケーションなら、そのツールを呼び出すのにネットワーク越しの経路は必要ありません。 + +## Streamable HTTP {#streamable-http} + +URL の文字列を渡すと **Streamable HTTP** になります。デプロイ時に使うトランスポートです。 + +```python title="client.py" hl_lines="5" +--8<-- "docs_src/client_transports/tutorial002.py" +``` + +本番用のクライアントはこれですべてです。`Client` は URL を `streamable_http_client(...)` で包み、MCP に必要な設定を施した `httpx2.AsyncClient` の上に載せてくれます。具体的には `follow_redirects=True`、connect/write/pool のタイムアウトが 30 秒、そしてサーバーがレスポンスストリームを開いたままにすることがあるため read のタイムアウトが 300 秒です。 + +!!! check + 構築しただけの `Client` は接続されて**いません**。構築時に行われるのはトランスポートの選択だけで、実際に開くのは `async with` です。入る前に接続に手を伸ばすと、SDK がそのことを教えてくれます。 + + ```text + RuntimeError: Client must be used within an async context manager + ``` + + `Client("http://...")` と書いた時点では、何も解決も取得も起動もされていません。この行にコストはかかりません。 + +### 自前の `httpx2.AsyncClient` を使う {#bring-your-own-httpx2asyncclient} + +`Authorization` ヘッダー、Cookie、プロキシ、mTLS、あるいは別のタイムアウトが必要になったら、`httpx2.AsyncClient` を自分で組み立てて `streamable_http_client` に渡します。 + +```python title="client.py" hl_lines="8-14" +--8<-- "docs_src/client_transports/tutorial003.py" +``` + +注目すべき点が 2 つあります。 + +* `httpx2.AsyncClient` の所有者は**自分**なので、入るのも出るのも自分で行います。SDK は自身が作成していないクライアントを決して閉じません。 +* `streamable_http_client(url, http_client=...)` はトランスポートを返し、`Client(transport)` はそれを他のものと同じように受け取ります。 + +TLS について 1 点。`httpx2` は、同梱の CA リストではなく、オペレーティングシステムのトラストストアに対して証明書を検証します([`truststore`](https://pypi.org/project/truststore/) を使用)。利用できるシステム CA ストアがない環境(一部の最小構成コンテナなど)では、標準の環境変数 `SSL_CERT_FILE`/`SSL_CERT_DIR` を設定するか、`httpx2.AsyncClient` に明示的に `verify=ssl_context` を渡してください(背景は [`httpx` と `httpx-sse` の `httpx2` への置き換え](../migration.md#httpx-and-httpx-sse-replaced-by-httpx2)を参照)。 + +!!! warning + `streamable_http_client` は以前、`headers=` と `timeout=` を直接受け取っていました。今はもう受け取りません。パラメーターは `url`、`http_client`、`terminate_on_close` だけです。習慣で `headers=` を渡すと、次のようになります。 + + ```text + TypeError: streamable_http_client() got an unexpected keyword argument 'headers' + ``` + + HTTP に関わるものはすべて、渡す 1 つの `httpx2.AsyncClient` に集約されています。 + +!!! info + `httpx2` はおなじみの `httpx` の API をそのまま保っているので、`httpx` を知っていれば、認証、プロキシ、イベントフック、リトライ、接続数の制限のやり方はすでに知っていることになります。SDK はその上に何も足さず、何も引きません。OAuth が差し込まれるのもここです。`httpx2.AsyncClient(auth=OAuthClientProvider(...))` のように書きます。そのフロー全体については **[OAuth クライアント](oauth-clients.md)** を参照してください。 + +## stdio {#stdio} + +**stdio** サーバーはサブプロセスです。クライアントがそれを起動し、stdin に JSON-RPC を書き込み、stdout から JSON-RPC を読み取ります。デスクトップのホストが手元のマシンでサーバーを動かす方法がこれです。ホストとは、このコードに UI を加えたもの「そのもの」です。**[本物のホストに接続する](../get-started/real-host.md)** は、同じ関係をホストの側から設定ファイルとして見たものです。 + +`StdioServerParameters` でプロセスを記述し、`stdio_client` でトランスポートに変換して、「それ」を `Client` に渡します。 + +```python title="client.py" hl_lines="4-8 12" +--8<-- "docs_src/client_transports/tutorial004.py" +``` + +`Client` はパラメーターオブジェクトをそのままでは受け取りません。`StdioServerParameters` は設定であり、`stdio_client(server)` はそこからプロセスを起動する方法を知っているトランスポートです。必ず包んでください。 + +`async with` ブロックを抜けると、サブプロセスも終了されます。stdin を閉じ、待機し、居残っていれば強制終了します。自分で後始末をすることはありません。 + +!!! warning + 子プロセスは環境変数を継承**しません**。最小限の許可リスト(POSIX では `HOME`、`LOGNAME`、`PATH`、`SHELL`、`TERM`、`USER`)だけを受け取るので、自分が書いたとは限らないプロセスに機密情報が漏れることはありません。 + + API キーを必要とするサーバーは、そこでキーを見つけられません。`env=` で明示的に渡してください。それらの変数は許可リストの上にマージされます。上の例で `BOOKSHOP_API_KEY` がしているのがまさにそれです。 + +## SSE {#sse} + +`mcp.client.sse` の `sse_client(url)` は、Streamable HTTP に取って代わられた HTTP トランスポートです。まだこれを話すサーバーと対話するには、同じように `Client(sse_client("http://localhost:8000/sse"))` と包みます。そして、新しいものをこの上に作らないでください。 + +## `Transport` プロトコル {#the-transport-protocol} + +`Client` から見れば、上記はすべて同じものです。 + +**トランスポート**とは、`(read, write)` というメッセージストリームのペアを yield する非同期コンテキストマネージャーのことです。正式には `mcp.client` の `Transport` プロトコルです。`Client` は引数を型で解決します。サーバーオブジェクトならインプロセスで接続し、`str` なら `streamable_http_client(url)` になり、それ以外は直接トランスポートとして入ります。この最後の規則があるからこそ、`stdio_client(...)`、`streamable_http_client(...)`、`sse_client(...)` はすべて同じ場所に収まり、自分で独自のものを書くこともできます。 + +## まとめ {#recap} + +* `Client(mcp)`(サーバーオブジェクト)はインメモリで接続します。テストと組み込みに使ってください。 +* `Client("http://.../mcp")`(URL)は、本番用のトランスポートである Streamable HTTP で接続します。 +* ヘッダー、認証、プロキシ、タイムアウトは、`streamable_http_client(url, http_client=...)` に渡す `httpx2.AsyncClient` に設定します。`headers=` キーワードはありません。 +* stdio は `Client(stdio_client(StdioServerParameters(...)))` であり、パラメーターオブジェクト単体では決してありません。 +* サブプロセスが受け取るのは自分の環境ではなく、許可リストに基づく環境です。`env=` でそこに追加します。 +* トランスポートとは、`async with x as (read, write)` と書けるものすべてです。`Client` は、サーバーオブジェクトでも URL でもないものをそのままこのプロトコルに渡します。 +* `Client` の構築でトランスポートが選ばれ、`async with` でそれが開かれます。 + +トランスポートが開いたら、両者はプロトコルバージョンについて合意する必要があります。普段は意識することはありません。意識することになったら、**[プロトコルバージョン](../protocol-versions.md)** のページを参照してください。 diff --git a/i18n/ja/pages/deprecated.md b/i18n/ja/pages/deprecated.md new file mode 100644 index 0000000000..9b251f69f0 --- /dev/null +++ b/i18n/ja/pages/deprecated.md @@ -0,0 +1,86 @@ +--- +translation: + sections: [20541a40dbdd5980, 01262a123ad9501d, 429db5b574a2ac08, 56b2d49da412cb28, 6a1717123fe4513c] + tool: 1 +--- +# 非推奨の機能 {#deprecated-features} + +2026-07-28 の仕様では、5 つのものが役目を終えます。SDK は今もその 5 つすべてを実装しており、そのすべてに**非推奨の警告**が付くようになりました。 + +下の表は、非推奨になった機能それぞれについて、なくなる理由と、代わりに土台にすべきものを挙げています。 + +## 非推奨になるもの {#what-is-deprecated} + +| 非推奨 | 理由 | 代わりにすること | +|---|---|---| +| **ルート(roots)**:`ctx.session.list_roots()`、`client.send_roots_list_changed()`、`Client(...)` に渡す `list_roots_callback=` | [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) がこのケイパビリティを非推奨にします。 | パスを通常のツール引数やリソース URI として受け取るか、`InputRequiredResult` に `ListRootsRequest` を埋め込みます(**[マルチラウンドトリップ(multi-round-trip)リクエスト](handlers/multi-round-trip.md)** を参照)。 | +| **サーバー起点のサンプリング**:`ctx.session.create_message()`、`Client(...)` に渡す `sampling_callback=` | SEP-2577 がこのケイパビリティを非推奨にします。 | `InputRequiredResult` を返し、クライアントに呼び出しを再試行させます(**[マルチラウンドトリップリクエスト](handlers/multi-round-trip.md)** を参照)。 | +| **プロトコルのロギング**:`ctx.log()`、`ctx.debug()`、`ctx.info()`、`ctx.warning()`、`ctx.error()`、`ctx.session.send_log_message()`、`client.set_logging_level()` | SEP-2577 がこのケイパビリティを非推奨にします。プロトコル内でこれに代わるものはありません。 | stderr へ出力する通常の `import logging`(**[ロギング](handlers/logging.md)** を参照)。 | +| **`ping`**:`client.send_ping()` | 単なる非推奨ではなく、プロトコルから**削除**されました。2026-07-28 には `ping` メソッドがありません。 | 何もありません。`mode="legacy"` の接続に対してしか動作しません。 | +| **クライアントからサーバーへの進捗**:`client.send_progress_notification()` | 2026-07-28 では、進捗はサーバーからクライアントへの方向だけになります。 | 送るものはありません。進捗はサーバー側が `ctx.report_progress()` で報告します(**[進捗](handlers/progress.md)** を参照)。 | + +この表から 3 つのことがわかります。 + +* ルート、サンプリング、ロギングはひとまとまりです。**SEP-2577** という 1 つの提案が、3 つのケイパビリティを一度にすべて非推奨にしています。 +* サンプリングとルートには、より根深い共通の問題があります。どちらも**サーバー**が**クライアント**に**リクエスト**を送る場面だという点です。2026-07-28 が **[マルチラウンドトリップリクエスト](handlers/multi-round-trip.md)** で置き換えるのは、まさにこの方向の通信全体です。なくなるのは単独の RPC メソッド(`sampling/createMessage`、`roots/list`、プッシュ型の `elicitation/create`)です。`CreateMessageRequest` / `ListRootsRequest` / `ElicitRequest` というペイロード型は `InputRequiredResult.input_requests` に埋め込まれる形で残り、クライアント側ではこれまでと同じコールバックに届きます。 +* `ping` だけは毛色が違います。プロトコルはこれを非推奨にするのではなく、削除します。SDK のメソッドは依然として警告を出し(メッセージは「deprecated」ではなく「removed」と述べます)、現行仕様の接続で呼び出すと「Method not found」が返ります。 + +## 非推奨は勧告にすぎない {#deprecated-is-advisory} + +今日の時点で壊れるものはありません。 + +上に挙げたメソッドはすべて、**2025-11-25 以前**で交渉されたセッションに対しては引き続き動作します。クライアントで `mode="legacy"` を固定すれば、2026 年より前とまったく同じ挙動になります。通信上の変更はなく、ケイパビリティの交渉も変わりません。 + +変わるのは、それぞれが最初に実行されたときに、目に見える警告が出る点です。 + +```text +MCPDeprecationWarning: The logging capability is deprecated as of 2026-07-28 (SEP-2577). +``` + +`MCPDeprecationWarning` は `UserWarning` のサブクラスであり、`DeprecationWarning` のサブクラス**ではありません**。これは意図的なものです。Python のデフォルトのフィルターは、`__main__` として直接実行されるコードでしか `DeprecationWarning` を表示しません。ライブラリが何かを非推奨にしても 2 年間誰も気づかない、というのはこの仕組みのせいです。この警告は `-W` フラグなしで、どこでも表示されます。 + +!!! warning + 「勧告にすぎない」のは通信路の手前までです。サンプリングとルートはサーバーからクライアントへの「リクエスト」であり、2026-07-28 のセッションにはそれを運ぶチャネルがありません。現行仕様の接続のツール内で `ctx.session.create_message()` を呼び出すと、警告はやはり出ますが、そのあと送信がエラーで失敗します。 + + ```text + Cannot send 'sampling/createMessage': this transport context has no back-channel + for server-initiated requests. + ``` + + シグナルは 2 つ、この順番です。`MCPDeprecationWarning` は、どの接続でもメソッドを呼び出した瞬間に発生します。エラーは、そのあと SDK が送信を試みたときに返ってくるものです。この 2 つの機能がエンドツーエンドで動作するのは、対応するコールバックをクライアントが登録した `mode="legacy"` の接続だけです。 + +## 警告を抑止する {#silencing-the-warning} + +新しいコードでは、しないでください。 + +ただし、保守しているサーバーが実際に 2026 年より前のクライアントを相手にしているなら、ログを静かに保つ正当な理由があります。最初の非推奨の呼び出しが実行される前に、このカテゴリをフィルターしてください。 + +```python +import warnings + +from mcp import MCPDeprecationWarning + +warnings.filterwarnings("ignore", category=MCPDeprecationWarning) +``` + +API はこれだけです。メソッドごとのスイッチはありませんし、必要もありません。カテゴリが 1 つである利点は、1 行で黙らせ、1 行で元に戻せることです。 + +!!! check + フィルターを逆向きにかければ、無料で回帰テストが手に入ります。pytest の設定の `filterwarnings` に `"error::mcp.MCPDeprecationWarning"` を追加すると、非推奨の呼び出しは警告ではなく**例外を送出**します。まだ `ctx.info()` を呼んでいる `old_log` という名前のツールは通らなくなり、次のように報告し始めます。 + + ```text + Error executing tool old_log: The logging capability is deprecated as of 2026-07-28 (SEP-2577). + ``` + + pytest の設定を 1 行足すだけで、非推奨の呼び出しがテストを失敗させずにコードベースへ紛れ込むことは二度とありません。 + +## まとめ {#recap} + +* 2026-07-28 の仕様は、**ルート**、サーバー起点の**サンプリング**、プロトコルの**ロギング**を非推奨にし(いずれも [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577))、**進捗**をサーバーからクライアントへの方向に限定し、**`ping`** を削除します。 +* 「代わりにすること」の列が次の行き先を示しています。サンプリングとルートには **[マルチラウンドトリップリクエスト](handlers/multi-round-trip.md)**、ロギングには **[ロギング](handlers/logging.md)**、進捗には **[進捗](handlers/progress.md)** です。`ping` には何も必要ありません。 +* 非推奨は勧告にすぎません。通信上の変更はなく、2026 年より前のセッションに対してはすべてが引き続き動作します。そして目に見える `MCPDeprecationWarning` が出ます(`UserWarning` なので、デフォルトで有効です)。 +* サンプリングとルートにはさらに、2026-07-28 のセッションにはないバックチャネル(back-channel)が必要です。現行仕様の接続では警告を出し、そのあと例外を送出します。 +* `warnings.filterwarnings("ignore", category=MCPDeprecationWarning)` でカテゴリ全体を黙らせます。pytest で `"error::mcp.MCPDeprecationWarning"` を指定すれば、テストの失敗に変わります。 +* 新しいコードは、これらのどれの上にも築くべきではありません。 + +このドキュメントのほかのページはすべて、現行の API を扱っています。 diff --git a/i18n/ja/pages/get-started/first-steps.md b/i18n/ja/pages/get-started/first-steps.md new file mode 100644 index 0000000000..01798d6d41 --- /dev/null +++ b/i18n/ja/pages/get-started/first-steps.md @@ -0,0 +1,139 @@ +--- +translation: + sections: [0d6c05bcbf836bf3, 59a7b14eeefc68c1, 7114d8d6daba203f, e8bbb56a98ba7bc9, 5138010f6159901c, f78da7c7c363d4c6, 220a939cab348686] + tool: 1 +--- +# 最初のステップ {#first-steps} + +**[トップページ](../index.md)** は駆け足です。サーバーを書き、実行し、ツールを呼び出します。 + +このページではじっくり進めます。サーバーが公開できる 3 種類のものをすべて取り上げ、途中で出てくるものすべてに名前を付けていきます。 + +## ホスト、クライアント、サーバー {#host-client-and-server} + +ここから先、どのページにも登場する言葉が 3 つあります。 + +* **ホスト**は LLM アプリケーションです。Claude、IDE、エージェントランタイムなどがこれにあたります。ユーザーが対話している相手です。 +* **クライアント**はホストの中にあり、MCP を話します。ホストは、接続するサーバーごとにクライアントを 1 つずつ動かします。 +* **サーバー**は、この SDK で作るものです。クライアントに対して何かを公開します。モデルと直接やり取りすることは決してありません。 + +自分で書くのはサーバーです。ホストは別の誰かが作る製品です。SDK には `Client` も用意されています。サーバーのテストに使うもので、このページの後半にも登場します。 + +## 3 つのプリミティブ {#the-three-primitives} + +サーバーが公開するものは、ちょうど 3 種類です。それらを分けるのは、**誰が使うと決めるのか**という点です。 + +| プリミティブ | 制御する主体 | どんなものか | 例 | +|---------------|-----------------|-----------------------------------------------------|------------------------------------| +| **ツール** | モデル | アクションを起こすためにモデルが呼び出す関数 | API 呼び出し、データベースへの書き込み | +| **リソース** | アプリケーション | ホストがモデルのコンテキストに読み込むデータ | ファイルの内容、API のレスポンス | +| **プロンプト** | ユーザー | ユーザーが名前で呼び出す、再利用可能なメッセージテンプレート | スラッシュコマンド、メニュー項目 | + +「制御する主体」こそが、この区分の核心です。ツールが実行されるのは、**モデル**が呼び出すと決めたからです。リソースが添付されるのは、**アプリケーション**がモデルに必要だと判断したからです。プロンプトが実行されるのは、**ユーザー**が選んだからです。 + +!!! info + Web API を作ったことがあれば、勘どころはもうほとんどつかめています。**リソース**は `GET`(データを読み込み、何も変更しない)で、**ツール**は `POST`(処理を行い、副作用を持つことがある)です。**プロンプト**に HTTP の対応物はありません。ユーザーが名前を指定して実行する、保存済みのクエリに近いものです。 + +## 1 つのサーバーで 3 つすべて {#one-server-all-three} + +```python title="server.py" hl_lines="6 12 18" +--8<-- "docs_src/first_steps/tutorial001.py" +``` + +ごく普通の関数が 3 つ、デコレーターが 3 つです。どのデコレーターも、それだけで登録が完結します。 + +* `@mcp.tool()` は `add` を**ツール**にします。 +* `@mcp.resource("greeting://{name}")` は `greeting` を**リソーステンプレート**にします。URI の中の `{name}` が関数のパラメーターです。 +* `@mcp.prompt()` は `summarize` を**プロンプト**にします。返した文字列がユーザーメッセージになります。 + +それ以外のもの(名前、説明、引数のスキーマ)は、SDK が関数そのものから読み取ります。関数名、docstring、型ヒントからです。どれも別途宣言してはいません。 + +!!! tip + SDK の 2 つの半分には、インポートパスも 2 つあります。`from mcp import Client` と `from mcp.server import MCPServer` です。`from mcp import MCPServer` はありません。 + +### 試してみる {#try-it} + +MCP Inspector で実行してください。 + +```console +uv run mcp dev server.py +``` + +出力された URL を開いてください。Inspector にはプリミティブごとにタブが 1 つずつあります。順に見ていきましょう。 + +**Tools** タブには項目が 1 つあります。`add` で、説明は *Add two numbers.* です。フォームには必須の整数フィールドが 2 つあり、1 つは `a` 用、もう 1 つは `b` 用です。値を入力して呼び出すと、結果は `3` です。Inspector はこのフォームを `a: int, b: int` から組み立てました。ほかのどのクライアントも同じことをします。 + +**Resources** タブでは、*Resources* の一覧は空です。`greeting` は **Resource Templates** の下にあります。`greeting://{name}` にはパラメーターがあり、誰かが `name` を指定するまでは一覧に載せられる単体のリソースが存在しないからです。`World` を指定して読み取ると、こう返ってきます。 + +```text +Hello, World! +``` + +**Prompts** タブにも項目が 1 つあります。`summarize` で、必須の引数 `text` を 1 つだけ取ります。適当なテキストを渡して取得すると、`role: user` を持ち、レンダリングされた文字列を内容とするメッセージが 1 つ返ってきます。プロンプトとはそれだけのものです。メッセージを組み立てる関数にすぎません。 + +Inspector はサーバーを **stdio** で実行しました。MCP サーバーが話せるトランスポートの 1 つです。トランスポートを選ぶのはまだ先で、そのためのページが **[サーバーの実行](../run/index.md)** です。 + +## ケイパビリティ {#capabilities} + +Inspector にはタブが 3 つありました。3 つあると、どうやってわかったのでしょうか。 + +クライアントが接続すると、サーバーは自身の**ケイパビリティ**を宣言します。どの系統のリクエストに応答するか、ということです。クライアントはこの宣言をもとに、そもそも何を要求するかを決めます。この宣言を自分で書いてはいません。`MCPServer` が代わりに宣言します。 + +自分の目で確かめてみましょう。SDK の `Client` はサーバーオブジェクトをそのまま受け取り、**インメモリ**で接続します(サブプロセスもポートも使いません)。 + +```python +import asyncio + +from mcp import Client + +from server import mcp + + +async def main() -> None: + async with Client(mcp) as client: + print(client.server_capabilities.model_dump(exclude_none=True)) + + +asyncio.run(main()) +``` + +```text +{'prompts': {'list_changed': True}, 'resources': {'subscribe': True, 'list_changed': True}, 'tools': {'list_changed': True}} +``` + +この辞書が、サーバーが宣言した**ケイパビリティ**です。接続してくるどのクライアントも、最初にこれを知ります。 + +| ケイパビリティ | クライアントが呼び出せるようになるもの | +|-------------|------------------------------------------------------------| +| `tools` | `tools/list`, `tools/call` | +| `resources` | `resources/list`, `resources/templates/list`, `resources/read` | +| `prompts` | `prompts/list`, `prompts/get` | + +`MCPServer` は 3 つのプリミティブすべてを提供するので、3 つとも常に宣言されます。 + +ここにないものにも注目してください。`completions`(リソーステンプレートとプロンプトの引数の自動補完)には自分で書くハンドラーが必要ですが、このサーバーにはありません。そのためこのケイパビリティは宣言されず、行儀のよいクライアントなら要求もしません。オプションのものはすべてこのルールに従います。登録すればケイパビリティが現れます。**[補完](../servers/completions.md)** のページがそれを実証しています。 + +!!! info + `Client(mcp)` は、このドキュメントのすべてのサンプルをテストしているのと同じインメモリクライアントで、自分のサーバーをテストするときにもこれを使います。まるごと 1 ページを割いています。**[テスト](testing.md)** です。 + +## 書かなかったもの {#what-you-did-not-write} + +このページを振り返ってみてください。書いたのは小さな Python 関数 3 つです。次のものは書いて**いません**。 + +* JSON Schema。`a: int, b: int` がそのまま `add` のスキーマです。 +* リクエストハンドラー。`tools/list`、`resources/read`、`prompts/get` は、すべて代わりに処理されます。 +* ケイパビリティの宣言。`MCPServer` が代わりに作りました。 +* プロトコルのコードを 1 行も。バージョンのネゴシエーション、JSON-RPC のフレーミング、ケイパビリティの交換は、すべて `mcp dev` と `Client(mcp)` の内部で行われ、目にすることはありませんでした。 + +この比率こそが、この SDK の存在意義です。 + +## まとめ {#recap} + +* **ホスト**は LLM アプリ、**クライアント**はそのうち MCP を話す部分、**サーバー**は自分で作るものです。 +* ツールは**モデル**が、リソースは**アプリケーション**が、プロンプトは**ユーザー**が制御します。 +* デコレーターはプリミティブごとに 1 つです。`@mcp.tool()`、`@mcp.resource(uri)`、`@mcp.prompt()`。名前、説明、スキーマは関数から取られます。 +* `{param}` を含む URI はリソース**テンプレート**を作り、具体的なリソースとは別に一覧表示されます。 +* サーバーの**ケイパビリティ**は代わりに宣言され、クライアントはサーバーが宣言したものだけを要求します。 +* `Client(mcp)` はサーバーオブジェクトにインメモリで接続します。初日から使えるテストハーネスです。 + +次は **[実際のホストに接続する](real-host.md)** です。このサーバーを Claude Desktop や IDE の中で、本当に動かします。その次は **[テスト](testing.md)** です。1 ページ、インメモリクライアント 1 つで、動くかどうかを当て推量することはもうありません。そのあとは各プリミティブに専用のページがあり、まずはモデルが動かすもの、**[ツール](../servers/tools.md)** から始まります。 diff --git a/i18n/ja/pages/get-started/index.md b/i18n/ja/pages/get-started/index.md new file mode 100644 index 0000000000..e3832c6d4d --- /dev/null +++ b/i18n/ja/pages/get-started/index.md @@ -0,0 +1,53 @@ +--- +translation: + sections: [ed4a756b4c53c585, 97e2fb315b7fe398, 4d04f1c6f4bf6c1d, 577d73078fc62baf] + tool: 1 +--- +# はじめに {#get-started} + +MCP が初めてでも、この SDK が初めてでも、ここから始めてください。ここにあるページでは、何もない状態から、テスト済みの動くサーバーができあがるまでを案内します。[SDK をインストール](installation.md)し、[最初のサーバー](first-steps.md)を作り、[実際のホストに接続](real-host.md)して、インメモリクライアントで[テスト](testing.md)します。 + +## コードを実行する {#run-the-code} + +コードブロックはすべて、そのままコピーして使えます。どれも完結した、実際に動くファイルです。 + +一緒に進めるには、コードブロックを `server.py` に貼り付けて、MCP Inspector で開いてください。 + +```console +uv run mcp dev server.py +``` + +コードを自分で書き(またはコピーし)、編集し、ローカルで実行することを**強くおすすめします**。自分のエディターで使ってみてこそ、肝心な点が実感できます。書く量がどれほど少ないか、自動補完が効くこと、そして何も実行しないうちに型チェックがミスを見つけてくれることです。 + +## 推測に頼る必要はない {#you-will-not-be-guessing} + +このドキュメントの例はすべて、SDK 自身のリポジトリの [`docs_src/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/docs_src) 以下に置かれた完結したファイルです。そのどれもが、SDK のテストスイートによって**インメモリクライアント**を通じて実行されています。 + +```python +import pytest +from mcp import Client + +from server import mcp + + +@pytest.mark.anyio +async def test_add() -> None: + async with Client(mcp) as client: + result = await client.call_tool("add", {"a": 1, "b": 2}) + assert result.structured_content == {"result": 3} +``` + +サブプロセスも、ポートも、トランスポートもありません。`Client(mcp)` がサーバーオブジェクトに直接接続します。 + +SDK への変更によってこれらのページの例が壊れた場合、ページが壊れるより先に CI が赤くなります。ここで読むコードが、実際に動くコードそのものです。 + +この仕組みは[テスト](testing.md)で実際に使うことになります。自分のサーバーをテストする方法も、まさにこれです。 + +## 次に進む先 {#where-to-go-next} + +サーバーが動くようになれば、残りのドキュメントは講座ではなくリファレンスです。どのページも単独で完結しているので、必要なところへ直接進んでください。 + +* サーバーが公開するもの(ツール、リソース、プロンプト)は **[サーバー](../servers/index.md)** です。 +* 登録した関数の中で使えるものは **[ハンドラーの中で](../handlers/index.md)** です。 +* クライアントから使えるようにする方法(stdio、HTTP、既存の FastAPI アプリ)は **[サーバーの実行](../run/index.md)** です。 +* 反対側、つまり MCP サーバーを「使う」側のアプリケーションを作る方法は **[クライアント](../client/index.md)** です。 diff --git a/i18n/ja/pages/get-started/installation.md b/i18n/ja/pages/get-started/installation.md new file mode 100644 index 0000000000..0a63a8af9e --- /dev/null +++ b/i18n/ja/pages/get-started/installation.md @@ -0,0 +1,45 @@ +--- +translation: + sections: [6e2f9bab94d5ed36, 8cf653388f69e28b, 6fd9ea2f65de0df6] + tool: 1 +--- +# インストール {#installation} + +Python SDK は PyPI 上で [`mcp`](https://pypi.org/project/mcp/) として公開されています。**Python 3.10 以上**が必要です。 + +このドキュメントは、現在の安定版リリースラインである **v2** について説明しています。 + +=== "uv" + + ```bash + uv add "mcp[cli]" + ``` + +=== "pip" + + ```bash + pip install "mcp[cli]" + ``` + +!!! note "v1 から移行する場合" + v2 は破壊的変更を含むメジャーバージョンです。変更点はすべて **[移行ガイド](../migration.md)** にまとめてあります。自分のパッケージが `mcp` に依存していて、まだ移行の準備ができていない場合は、バージョンの上限として `<2` を付けておいてください(たとえば `mcp>=1.28,<2`)。そうすれば、バージョンを固定しない依存解決でも 1.x 系にとどまります。 + +## インストールされるもの {#what-gets-installed} + +SDK を使うだけなら、以下の内容を知っている必要はありません。それぞれの依存関係が何のためにあるのか気になる場合のために、まとめておきます。 + +* `mcp-types`:すべてのプロトコル型(リクエスト、結果、コンテンツブロック)を独立したパッケージにしたもので、SDK と足並みをそろえてバージョン管理されます。`mcp` に依存するコードは、`mcp.types` というエイリアス経由でこれをインポートします(このドキュメントに出てくる `from mcp.types import ...` はすべてそうです)。`mcp_types` を直接インポートするのは、SDK なしで `mcp-types` をインストールするプロジェクトだけにしてください。 +* [`anyio`](https://anyio.readthedocs.io/):非同期ランタイムです。SDK 全体が anyio を前提に書かれているので、`asyncio` でも `trio` でも動きます。 +* [`pydantic`](https://docs.pydantic.dev/):`mcp.types` のあらゆるモデルの土台であり、スキーマの生成と検証もすべて担っています。 +* [`httpx2`](https://pypi.org/project/httpx2/):Streamable HTTP と SSE のクライアント側トランスポートを支える HTTP クライアントで、Server-Sent Events のサポートを内蔵しています。 +* [`starlette`](https://www.starlette.io/)、[`uvicorn`](https://www.uvicorn.org/)、[`sse-starlette`](https://pypi.org/project/sse-starlette/)、[`python-multipart`](https://pypi.org/project/python-multipart/):HTTP のサーバー側トランスポートです。 +* [`jsonschema`](https://pypi.org/project/jsonschema/):ツールの構造化出力を、宣言された出力スキーマに照らして検証します。 +* [`pyjwt[crypto]`](https://pyjwt.readthedocs.io/):認可のための OAuth トークン処理を担います。 +* [`opentelemetry-api`](https://opentelemetry-python.readthedocs.io/):軽量な API だけです。そのため、OpenTelemetry の SDK とエクスポーターを自分でインストールしない限り、この SDK のトレーシングミドルウェアにコストは発生しません。 +* [`typing-extensions`](https://typing-extensions.readthedocs.io/) と [`typing-inspection`](https://pypi.org/project/typing-inspection/):Python 3.10 でも新しい型付け機能を使えるようにします。 +* [`pywin32`](https://pypi.org/project/pywin32/):Windows 専用で、`stdio` のサブプロセス管理に使われます。 + +## オプションの extras {#optional-extras} + +* `mcp[cli]` は、`mcp` コマンドラインツール(`mcp dev`、`mcp run`、`mcp install`)のために [`typer`](https://typer.tiangolo.com/) と [`python-dotenv`](https://pypi.org/project/python-dotenv/) を追加します。開発中は入れておきたいところですが、デプロイしたサーバーでは必要ないかもしれません。 +* `mcp[rich]` は、サーバーのログを見やすくするために [`rich`](https://rich.readthedocs.io/) を追加します。 diff --git a/i18n/ja/pages/get-started/real-host.md b/i18n/ja/pages/get-started/real-host.md new file mode 100644 index 0000000000..e594ee6cb0 --- /dev/null +++ b/i18n/ja/pages/get-started/real-host.md @@ -0,0 +1,168 @@ +--- +translation: + sections: [3c4f2f06b4e978b6, 22520eecae3d1961, f4e1709db18d635a, 2eb57992049671d9, 1ba83e9af37cc1b4, 4822586344b08d9e, 1c93afef72478992, b6b448f9eddd51dc, fe55370fd931815b] + tool: 1 +--- +# 実際のホストに接続する {#connect-to-a-real-host} + +**ホスト**とは、サーバーが最終的にその中で動くことになるアプリケーションのことです。Claude Desktop、Claude Code、IDE などがそうです。ユーザーがやり取りする相手はホストです。その内部では、MCP **クライアント**がサーバーを子プロセスとして起動し、そのプロセスの stdin と stdout を介してサーバーと通信します。 + +つまり、ホストに接続するためにやることは 1 つだけです。**サーバーを起動するコマンド**をホストに伝えます。このページに出てくるもの(2 つの CLI コマンドと 3 つの JSON ファイル)はすべて、その同じコマンドの置き場所が違うだけです。 + +## 1 つのサーバー、すべてのホスト {#one-server-every-host} + +```python title="server.py" hl_lines="3 33-34" +--8<-- "docs_src/real_host/tutorial001.py" +``` + +ツール 2 つとリソース 1 つが、1 つのファイルに収まっています。このファイルについて、以降のどのホストにも関わる点が 3 つあります。 + +* 引数なしの `mcp.run()` は **stdio** サーバーを起動します。ブロックし、stdin でプロトコルメッセージを読み、stdout に書き出します。これが、このページのどのホストも話すトランスポートです。ホストはこのファイルを子プロセスとして起動し、その 2 本のパイプを所有します。だからこそ、接続は常に「これがコマンドです」と伝えるだけで済みます。ポートを選ぶことはなく、どこかのポートで待ち受けるものもありません。 +* `run()` は `if __name__ == "__main__":` の下にあります。以降のものはすべてこのファイルを実行するのではなく**インポート**するので、ガードのない `run()` だと、何かがモジュールを読み込んだ瞬間にサーバーが起動してしまいます。 +* サーバーオブジェクトは `mcp` という名前のモジュールレベルのグローバル変数です。これは `mcp run` が探す名前です(`server` と `app` でも動きます)。別の名前を付けた場合は、`mcp run server.py:bookshop` のように明示的に指定します。 + +このページの Python はこれが最後の 1 行です。ここから下はすべてホストの設定です。 + +## 起動コマンド {#the-launch-command} + +以降のどのホストにも同じコマンドを渡します。 + +```bash +uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py +``` + +どのホストにも 1 つのコマンドで済むのは、`uv run --with` がその場で SDK を新しい環境へ解決してくれるからです。どのディレクトリからでも動き、プロジェクトも、有効化すべき仮想環境も要りません。このことがほかのどこよりもここで効いてくるのは、ホストがサーバーを起動するのがシェルからではなく、ほぼ空の環境でホスト自身の作業ディレクトリからだからです。 + +このコマンドは、`mcp install` が Claude Desktop の設定に書き込んでくれるコマンドでもあります(後述)。そのため、手で入力するものとツールが生成するものは、ツールが付け加える正確なバージョン固定を除いて一致します。 + +!!! tip "ホストが `uv` を見つけられない場合" + ホストは最小限の `PATH` でサーバーを起動するため、そこに `uv` が入っていないことがあります。`uv` とだけ書いた部分を、`which uv`(macOS/Linux)または `where uv`(Windows)で得られる絶対パスに置き換えてください。`mcp install` が書き込むのもまさにこの形です。 + +!!! note "このページはローカルの話" + ここで扱うものはすべて、ホストと同じマシン上でサーバーを動かします。ホストがファイルを stdio 経由で起動する形です。個人用のツールや 1 台のマシンで使うツールなら、まさにこれが正解です。ファイルを持って**いない**人たちにサーバーを渡すには、コマンドではなく **URL** を配ります。つまり、同じ `mcp` オブジェクトを Streamable HTTP で提供します。**[サーバーの実行](../run/index.md)** はその判断を 1 つの表にまとめており、**[デプロイとスケール](../run/deploy.md)** はそこから実際のホスト名に至るまでの道のりです。 + + また、ホストとは内部に MCP クライアントを持つアプリケーションにすぎないので、自分の Python コードがホストの役を演じることもできます。**[クライアントのトランスポート](../client/transports.md)** ではこの同じファイルを `stdio_client(...)` でサブプロセスとして起動し、**[テスト](testing.md)** ではプロセスを一切使わずにメモリ内で接続します。 + +## Claude Desktop {#claude-desktop} + +SDK が代わりに設定してくれる唯一のホストです。 + +```bash +uv run mcp install server.py +``` + +これだけです。`mcp install` はファイルをインポートしてサーバーの名前を読み取り、Claude Desktop の設定ファイルを探し出し、そこに起動コマンドを書き込みます。その過程でパスを絶対パスに変換してくれるので、自分で変換する必要はありません。 + +謎めいたところは何もありません。書き込まれるエントリは次のとおりです。 + +```json +{ + "mcpServers": { + "Bookshop": { + "command": "/absolute/path/to/uv", + "args": [ + "run", + "--frozen", + "--with", + "mcp[cli]==2.0.0", + "mcp", + "run", + "/absolute/path/to/server.py" + ] + } + } +} +``` + +これは前の節の起動コマンドに 3 つの要素を加えたものです。`uv` への絶対パス、たまたま近くにあるロックファイルを `uv` が書き換えることのないようにする `--frozen`、そしてインストール済みの `mcp` のバージョンへの正確な固定です。書き込み先は `claude_desktop_config.json` で、このファイルは次の場所にあります。 + +* **macOS**:`~/Library/Application Support/Claude/claude_desktop_config.json` +* **Windows**:`%APPDATA%\Claude\claude_desktop_config.json` + +このファイルは手で書くこともできます。`mcp install` があるのは、手で書くときにありがちなミス(相対パス)を避けるためです。 + +Claude Desktop を(ウィンドウだけでなく)完全に終了し、もう一度開いてください。 + +!!! warning + Claude Desktop の設定「ディレクトリ」がまだ存在しない場合、`mcp install` は `Claude app not found` で失敗します。Claude Desktop をインストールして一度起動してください。ディレクトリはそのときに作られます。 + +!!! tip + Claude Desktop はサーバーを自身のプロセスで起動するので、シェルの環境変数はそこにはありません。`uv run mcp install server.py -v API_KEY=abc123`(または `-f .env`)とすると、それらがエントリの `env` フィールドに記録されます。`--name` はエントリ名を上書きします。デフォルトはサーバーの `name` です。 + +## Claude Code {#claude-code} + +編集するファイルはありません。`claude` CLI でサーバーを登録してください。`--` の後ろはすべて起動コマンドです。 + +```bash +claude mcp add bookshop -- uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py +``` + +Claude Code のセッション内で `/mcp` を実行し、`bookshop` が接続されていてそのツールが一覧表示されることを確認してください。 + +## Cursor {#cursor} + +プロジェクトのルートに `.cursor/mcp.json` を作成してください。 + +```json +{ + "mcpServers": { + "bookshop": { + "command": "uv", + "args": ["run", "--with", "mcp[cli]", "mcp", "run", "/absolute/path/to/server.py"] + } + } +} +``` + +Claude Desktop が使うのと同じ `mcpServers` キーの下に、同じ `command` と `args` を置きます。サーバーは Cursor の MCP 設定に表示され、両方のツールが一覧に並びます。 + +## VS Code {#vs-code} + +プロジェクトのルートに `.vscode/mcp.json` を作成してください。 + +```json +{ + "servers": { + "bookshop": { + "type": "stdio", + "command": "uv", + "args": ["run", "--with", "mcp[cli]", "mcp", "run", "/absolute/path/to/server.py"] + } + } +} +``` + +Cursor のファイルとの違いは 2 つだけです。ラッパーのキーが `mcpServers` ではなく `servers` であること、そして各エントリが `type` を宣言することです。信頼を確認するプロンプトを承認すると、コマンドパレットの **MCP: List Servers** に `bookshop` が実行中として表示されます。 + +!!! note + VS Code 1.99 以降と、サインイン済みの **GitHub Copilot** 拡張機能が必要です(Copilot Free で十分です)。また、Copilot Chat は **Agent** モードでなければなりません。ほかのモードはツールを呼び出さないからです。 + +## 表示されないとき {#it-doesnt-show-up} + +ホストの設定に手を付ける前に、起動コマンドを自分で実行してみてください。 + +```bash +uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py +``` + +何も表示されず、コマンドも戻ってきません。この沈黙は正しい動作です。stdio サーバーは、ホストが先に stdin で話しかけてくるのを待っています(止めるには `Ctrl-C`)。本当のバグはトレースバックや即座の終了のほうで、こうして実行すれば、ホスト越しに推測する代わりにそれを直接読めます。 + +このコマンドがじっと待機するようになったら、残る原因はほぼ決まって次の 3 つのどれかです。 + +* **相対パス。** ホストがサーバーを起動するのは、登録したときのディレクトリではなく、ホスト自身の作業ディレクトリからです。`/absolute/path/to/server.py` が必要なところに `server.py` と書くのが、飛び抜けて多い失敗です。ホストが `uv` も見つけられないなら、そのパスも絶対パスにする必要があります。 +* **ホストがまだ古い設定で動いている。** ホストは起動時に設定を読み込みます。特に Claude Desktop は、`claude_desktop_config.json` の編集を反映させるには、ウィンドウを閉じるだけでなく「完全に終了」してから開き直す必要があります。 +* **退避される期間の外で、何かが stdout に届いた。** stdio では、stdout がプロトコルそのものです。SDK はサービス中、フラッシュされた余計な出力を stderr に退避させます。しかし、それ以前に stdout へフラッシュされた出力(echo するラッパースクリプトや、バッファリングなしのプロセスでのインポート時の `print()`)や、インタープリター終了時に書き出されるバッファ済みの `print()` は別です。これらは壊れたメッセージをホストに渡してしまい、ホストは接続を切ります。ログ出力にはデフォルトの `logging` 設定を使ってください。その stderr ハンドラーはレコードごとにフラッシュします。独自のハンドラーも stdout を避ける必要があります。詳しくは **[ロギング](../handlers/logging.md)** を参照してください。 + +Claude Desktop はサーバーごとにログを残します。`mcp-server-.log` がサーバーの stderr で、接続についての `mcp.log` と並んで、macOS では `~/Library/Logs/Claude`、Windows では `%APPDATA%\Claude\logs` の下にあります。 + +この 3 つに当てはまらない場合は、**[トラブルシューティング](../troubleshooting.md)** のページを参照してください。 + +## まとめ {#recap} + +* **ホスト**(Claude Desktop や IDE)は MCP クライアントを動かし、そのクライアントがサーバーを子プロセスとして stdio 経由で起動します。接続とは、起動コマンドを 1 つ渡すことです。 +* そのコマンドは `uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py` です。有効化する venv は不要で、どのディレクトリからでも動きます。 +* **Claude Desktop** は、`mcp install` が代わりに設定してくれる唯一のホストです。その同じコマンド(`uv` への絶対パス、`--frozen`、インストール済みバージョンへの正確な固定を加えたもの)を `claude_desktop_config.json` に書き込むので、自分で書く必要はありません。 +* **Claude Code** は `claude mcp add bookshop -- ` です。**Cursor** は `mcpServers` の下に書く `.cursor/mcp.json` です。**VS Code** は `servers` の下に書く `.vscode/mcp.json` で、各エントリに `type` を付けます。 +* どこでも絶対パスを使い、設定を編集したらホストを再起動し、SDK 以外のものには決して stdout に書き込ませないでください。 + +このページのどのホストも、同じファイルに同じコマンドで接続しました。そのファイルが何を「公開」できるかが、このドキュメントの残りのテーマです。**[ツール](../servers/tools.md)**、**[リソース](../servers/resources.md)**、そして stdio 以外のあらゆるトランスポートを扱う **[サーバーの実行](../run/index.md)** へと続きます。 diff --git a/i18n/ja/pages/get-started/testing.md b/i18n/ja/pages/get-started/testing.md new file mode 100644 index 0000000000..f510f983eb --- /dev/null +++ b/i18n/ja/pages/get-started/testing.md @@ -0,0 +1,96 @@ +--- +translation: + sections: ['4926721070127497', c52a1de2b6b32f40, 2e410b412c25f314, 627195f7159e24ef] + tool: 1 +--- +# テスト {#testing} + +Python SDK には、**インメモリトランスポート**を備えた `Client` クラスが付属しています。サーバーオブジェクトを渡せば、そのサーバーに直接接続します。 + +サブプロセスも、ポートも要りません。トランスポートすら使いません。FastAPI の `TestClient` と同じ発想です。 + +## 基本的な使い方 {#basic-usage} + +ツールを 1 つだけ持つシンプルなサーバーがあるとします。 + +```python title="server.py" +--8<-- "docs_src/testing/tutorial001.py" +``` + +以下のテストを実行するには、追加の(開発用)依存関係が 2 つ必要です。 + +=== "uv" + + ```bash + uv add --dev pytest inline-snapshot + ``` + +=== "pip" + + ```bash + pip install pytest inline-snapshot + ``` + +!!! info + このドキュメントは、[`pytest`](https://docs.pytest.org/en/stable/) をすでに知っていることを前提にしています。 + + [`inline-snapshot`](https://15r10nk.github.io/inline-snapshot/latest/) は、以下のテストで結果オブジェクト全体を 1 行でアサートするために使っているライブラリです。テストの出力を、コードにあるとおりの `snapshot(...)` リテラルとして記録します。使いたくない場合は import を削除し、ほかのテストと同じように、関心のあるフィールド(`result.content[0].text == "3"`)をアサートしてください。 + +テストは次のとおりです。 + +```python title="test_server.py" +import pytest +from inline_snapshot import snapshot +from mcp import Client +from mcp.types import CallToolResult, TextContent + +from server import mcp + + +@pytest.fixture +def anyio_backend(): # (1)! + return "asyncio" + + +@pytest.fixture +async def client(): # (2)! + async with Client(mcp, raise_exceptions=True) as c: + yield c + + +@pytest.mark.anyio +async def test_call_add_tool(client: Client): + result = await client.call_tool("add", {"a": 1, "b": 2}) + # Drop the server identity stamp in `_meta`; it is not what this test is about. + result.meta = None + assert result == snapshot( + CallToolResult( + content=[TextContent(type="text", text="3")], + structured_content={"result": 3}, + ) + ) +``` + +1. `trio` を使っている場合は、代わりに `"trio"` を返してください。詳しくは [anyio のドキュメント](https://anyio.readthedocs.io/en/stable/testing.html#specifying-the-backends-to-run-on) を参照してください。 +2. このフィクスチャは接続済みのクライアントを yield します。`client` を受け取るテストごとに、同じサーバーへの新しいインメモリ接続が用意されます。 + +これで準備完了です。あとはテストを拡張して、さらに多くのシナリオをカバーしていけます。 + +## なぜ `raise_exceptions=True` なのか {#why-raise_exceptionstrue} + +問題が起こりうる場所は 2 種類あり、このフラグが関わるのはそのうちの一方だけです。 + +**ツール**の内部で発生した例外は、プロトコル上の失敗ではありません。`is_error=True` の付いた通常の結果になり、モデルがそのメッセージを読みます。`raise_exceptions` はこの挙動を変えません。指定してもしなくても、`call_tool` は同じ `is_error=True` の結果を返します。これについては専用のページがあります。**[エラーの処理](../servers/handling-errors.md)** を参照してください。 + +ツール本体の**外側**で起きた失敗は事情が異なります。`Client(mcp)` で得られる接続では、クライアントの目に触れる前に、サーバーがその失敗を汎用の `"Internal server error"` にサニタイズします。予期しないクラッシュの詳細は、リモートの呼び出し側に決して漏らしてはいけないからです。しかしテストでは、これはまさに望まない挙動です。そして `raise_exceptions=True` が変えるのはまさにこの点で、テストからはサニタイズ後のメッセージではなく本来のメッセージが見えるようになります。 + +テストでは有効にしたままにしてください。本番コードでは意味を持ちません。 + +## デフォルトはインプロセス {#in-process-by-default} + +!!! note + `Client(mcp)` はインプロセスで接続し、デフォルトでは**プロトコルの世代を問いません**。サーバーを調べ、適切なプロトコル経路を選びます。テストがレガシー固有のセマンティクス(サンプリングやエリシテーション(elicitation)のプッシュ、`message_handler`)を検証する場合は `mode="legacy"` に固定し、その場合は `raise_exceptions=True` を外してください。レガシー接続はそもそもサニタイズを行わず、このフラグを付けると失敗がテストの中ではなくサーバータスクの中で再送出されてしまうからです。 + +この 1 行こそが、このドキュメントが「掲載している例は動く」と約束できる理由でもあります。すべてのサンプルファイルは SDK 自身のテストスイートで実行されており、そのほぼすべてがまさにこのクライアントを経由しています。SDK が自分自身に対して使っているのと同じツールを使っているわけです。 + +これで、きちんと動く、テスト済みのサーバーが手元にあります。実際のアプリケーション(Claude Desktop や IDE)に組み込む方法は **[実際のホストに接続する](real-host.md)** に、それ以外の提供方法はすべて **[サーバーの実行](../run/index.md)** にまとまっています。 diff --git a/i18n/ja/pages/handlers/context.md b/i18n/ja/pages/handlers/context.md new file mode 100644 index 0000000000..ffcc4b3b29 --- /dev/null +++ b/i18n/ja/pages/handlers/context.md @@ -0,0 +1,128 @@ +--- +translation: + sections: [b50152f05c81e786, b302059b22fb7cb4, 85682a1bf561243a, 53fc48838eb6837a, b24190e0842786ec, 85f93e150fc9b240] + tool: 1 +--- +# Context {#the-context} + +ツールの引数はモデルから渡されます。それ以外のすべて(処理中のリクエスト、ツールが属するサーバー、クライアントに話しかける手段)は、1 つのオブジェクトから得られます。それが **`Context`** です。 + +自分で組み立てる必要も、設定する必要もありません。要求するだけです。 + +## 要求する {#ask-for-it} + +任意のツールに、`Context` で注釈したパラメーターを追加してください。 + +```python title="server.py" hl_lines="2 8" +--8<-- "docs_src/context/tutorial001.py" +``` + +* SDK はリクエストごとに新しい `Context` を組み立てて渡します。 +* パラメーターの**名前は関係ありません**。`ctx`、`context`、`c` のどれでもよく、SDK は注釈を見て見つけます。 +* リソースやプロンプトでも、同じように宣言できます。 +* `ctx.request_id` は、関数がいま処理しているリクエストの id です。 + +!!! info + FastAPI を使ったことがあれば、この仕組みには見覚えがあるはずです。フレームワーク自身の型(あちらでは `Request`、こちらでは `Context`)でパラメーターを宣言すると、フレームワークがそれを供給します。登録するものも設定するものもありません。型注釈がこの仕組みのすべてです。 + +### モデルからは見えない {#invisible-to-the-model} + +ここはしっかり身につけておきたい部分です。`tools/list` が `search_books` について報告する入力スキーマは次のとおりです。 + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"} + }, + "required": ["query"], + "title": "search_booksArguments" +} +``` + +プロパティは 1 つです。`ctx` は引数ではありません。スキーマには決して現れず、モデルに知らされることもなく、どのクライアントも値を入れられません。これは作成者と SDK の間の取り決めであり、通信上には現れません。 + +### 試してみる {#try-it} + +MCP Inspector でサーバーを実行してください。 + +```console +uv run mcp dev server.py +``` + +`search_books` のフォームには `query` フィールドが 1 つだけあります。`dune` を指定して呼び出してください。 + +```text +[request 3] Found 3 books matching 'dune'. +``` + +この数字は、たまたまそのときのリクエストの番号です。もう一度ツールを呼び出すと変わります。リクエストごとに専用の `Context` が作られるからです。 + +## 何が得られるか {#what-it-gives-you} + +注入されるオブジェクトは小さなものです。`request_id` のほかに次のものがあります。 + +* `await ctx.read_resource(uri)`:ツールの中からサーバー**自身の**リソースを 1 つ読みます。次のセクションで扱います。 +* `await ctx.report_progress(progress, total, message)`:長い呼び出しの最中に、進捗を呼び出し側へ逐次送ります。詳しくは **[進捗](progress.md)** を参照してください。 +* `await ctx.elicit(message, schema)` と `await ctx.elicit_url(...)`:ツールを一時停止してユーザーに質問します。これが **[エリシテーション(elicitation)](elicitation.md)** です。 +* `ctx.session`:このクライアントとの会話のサーバー側です。クライアントに送る通知はここにあり、最後のセクションで使います。 +* `ctx.headers`:トランスポートが運んだリクエストヘッダー、stdio では `None` です。カスタムヘッダーは `(ctx.headers or {}).get("x-...")` で読めます。ヘッダーはクライアントが与える入力です。ロケールや機能フラグには使えますが、身元の確認には決して使わないでください。 +* `ctx.request_context`:リクエストごとの生のレコードです。実際に手を伸ばすフィールドは `lifespan_context`、つまり起動コードが yield したオブジェクトです(**[ライフスパン](lifespan.md)** を参照)。 + +ロギングは意図的にこの一覧に入れていません。サーバーは、ほかの Python プログラムと同じく Python の `logging` モジュールでログを記録します。その理由は短いページ **[ロギング](logging.md)** にまとめてあります。 + +!!! tip + 注入が行われるのは登録した関数だけです。ツールが呼び出すヘルパーに専用の `Context` は渡されないので、`ctx` を通常の引数として渡してください。どこか別の場所から取り出せる暗黙の「現在のコンテキスト」はありません。 + +## 自分のリソースを読む {#read-your-own-resources} + +サーバーのリソースはクライアントだけのものではありません。ツールからも読めます。 + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/context/tutorial002.py" +``` + +`ctx.read_resource` は `resources/read` を処理するのと同じレジストリを通して URI を解決するので、ツールはクライアントが受け取るのと同じものを得ます。コンテンツブロックごとに 1 つの `ReadResourceContents` を持つイテラブルです。この URI の場合は 1 つです。 + +```python +contents.content # 'fiction, non-fiction, poetry' +contents.mime_type # 'text/plain' +``` + +* `content` は `genres()` が返したものそのままです。情報源は 1 つです。クライアントはリソースを閲覧し、ツールはそれを消費し、誰も文字列をコピーしません。 +* `describe_catalog` の唯一のパラメーターは `Context` なので、その入力スキーマには**プロパティが 1 つもありません**。モデルは `{}` で呼び出します。 + +## 一覧が変わったことをクライアントに伝える {#tell-the-client-the-list-changed} + +サーバーが提供するものは、インポート時に固定されるわけではありません。実行時にツールを登録し、それをクライアントに伝えます。 + +```python title="server.py" hl_lines="15-16" +--8<-- "docs_src/context/tutorial003.py" +``` + +* `mcp.add_tool(recommend_book)` は普通の関数をツールとして登録します。名前、説明、スキーマは `@mcp.tool()` を使った場合とまったく同じように導出されます。 +* `await ctx.session.send_tool_list_changed()` は `notifications/tools/list_changed` を送ります。これを受け取ったクライアントは `tools/list` を再度呼び出し、`recommend_book` を目にします。 + +同種のメソッドには `send_resource_list_changed()`、`send_prompt_list_changed()`、そして特定の 1 つのリソースの変更を知らせる `send_resource_updated(uri)` があります。 + +2026-07-28 の接続では、クライアントは自分が開いた `subscriptions/listen` ストリーム上でしか変更通知を受け取らないため、上記の `send_*` メソッドはそれらのストリームに届きません。`Context` の公開メソッドは、購読中のすべてのストリームに一度に配信します。`await ctx.notify_tools_changed()`、`await ctx.notify_prompts_changed()`、`await ctx.notify_resources_changed()`、`await ctx.notify_resource_updated(uri)` です。レプリカをまたいだスケールアウトも含め、詳しくは **[サブスクリプション](subscriptions.md)** を参照してください。 + +!!! check + 誰かが `enable_recommendations` を実行するまで、約束しているツールは存在しません。それでも呼び出すと、結果はモデルが読めるエラーです。 + + ```text + Unknown tool: recommend_book + ``` + + `enable_recommendations` を実行すると、まったく同じ呼び出しが成功します。ツールの一覧は本当に動的です。`tools/list` は「いま」登録されているものをそのまま反映します。 + +## まとめ {#recap} + +* パラメーターに `Context` を注釈すると(ツールでも、リソースでも、プロンプトでも)、SDK がそれを注入します。名前は自由です。 +* モデルからは見えません。入力スキーマに含まれるのは、常に本物の引数だけです。 +* `ctx.request_id` はリクエストを識別し、`ctx.request_context.lifespan_context` は起動時に yield したものです。 +* `await ctx.read_resource(uri)` を使うと、ツールからサーバー自身のリソースを読めます。 +* `ctx.session` はクライアントへ戻るチャネルです。`send_tool_list_changed()` とその同種のメソッドは、変更した一覧を取得し直すようクライアントに伝えます。 +* 進捗の報告とエリシテーションも `Context` が出発点です。それぞれに専用のページがあります。 + +モデルが目にすることのない、自分の関数で埋めるパラメーターが **[依存関係](dependencies.md)** です。 diff --git a/i18n/ja/pages/handlers/dependencies.md b/i18n/ja/pages/handlers/dependencies.md new file mode 100644 index 0000000000..c5f96f780d --- /dev/null +++ b/i18n/ja/pages/handlers/dependencies.md @@ -0,0 +1,137 @@ +--- +translation: + sections: [b0389403e98d25ad, e2cf58b43b285e86, a363e1a38e1a5971, 6cfac078feb18013, b4535bd61df337e6, e97ed44207f929fd] + tool: 1 +--- +# 依存関係 {#dependencies} + +ツールの引数はモデルから渡されます。しかし、モデルから渡されるべきではない値もあります。記録から調べた価格、人間にしか出せない確認、モデルがでっち上げると間違えかねないあらゆる値です。 + +**依存関係**とは、自分の関数で埋めるパラメーターです。パラメーターに注釈を付けて関数を指定すると、ツールが実行される前に SDK がその関数を呼び出します。 + +## 宣言する {#declare-one} + +パラメーターの型を `Annotated[...]` で包み、`Resolve(fn)` を追加します。 + +```python title="server.py" hl_lines="18-19 23" +--8<-- "docs_src/dependencies/tutorial001.py" +``` + +* `check_stock` は**リゾルバー**です。SDK が `reserve_book` の前に実行する普通の関数で、その戻り値が `stock` 引数になります。 +* その `title` パラメーターはツール自身の `title` 引数で、**名前で**照合されます。リゾルバーが受け取るのは、ツール本体が受け取るのとまったく同じ、検証済みの値です。 +* ツール本体は、すでに存在する `Stock` から始まります。ツールの中に在庫を調べるコードはなく、「見つからなかったら」という前置きもありません。 + +!!! info + FastAPI を使ったことがあれば、これは `Depends` です。同じ仕組みで、同じ理由です。関数が必要なものを宣言し、フレームワークがそれを供給し、配線は型注釈の中にあります。 + +### モデルからは見えない {#invisible-to-the-model} + +`tools/list` が `reserve_book` について報告する入力スキーマは次のとおりです。 + +```json +{ + "type": "object", + "properties": { + "title": {"title": "Title", "type": "string"} + }, + "required": ["title"], + "title": "reserve_bookArguments" +} +``` + +プロパティは 1 つです。**[Context](context.md)** の `Context` と同じく、解決されるパラメーターは自分と SDK の間の取り決めです。`stock` はスキーマに含まれず、モデルには一切知らされません。それでも `stock` の値を送ってくるクライアントがあっても、その値は無視されます。ツールが受け取れるのはリゾルバーの値だけです。 + +肝心なのは最後の部分です。モデルが渡せないパラメーターは、モデルが間違えようのないパラメーターです。 + +### 試してみる {#try-it} + +MCP Inspector でサーバーを実行します。 + +```console +uv run mcp dev server.py +``` + +`reserve_book` のフォームには `title` フィールドが 1 つあるだけです。`stock` はどこにもありません。`Dune` で呼び出してみてください。 + +```text +Reserved 'Dune' (6 copies left). +``` + +ツール本体は何も調べていません。先に `check_stock` が実行され、それが返した `Stock` が引数として届きました。`Neuromancer` を試すと、同じリゾルバーがツールにゼロを渡します。 + +!!! tip + ツール本体で `check_stock(title)` を呼ぶだけでも済みます。依存関係として宣言するのは、その値がヘルパー呼び出し以上の扱いに値するときです。在庫を必要とするツールはどれも同じパラメーターを宣言し、いくつのツールが宣言していても、SDK はリゾルバーを 1 回の呼び出しにつき最大 1 回しか実行しません。残りは次のセクションで扱います。互いに依存するリゾルバーと、ユーザーに質問するリゾルバーです。 + +## 依存関係の依存関係 {#dependencies-of-dependencies} + +リゾルバーは、同じ注釈を使って自分自身の依存関係を宣言できます。 + +```python title="server.py" hl_lines="22 29-30" +--8<-- "docs_src/dependencies/tutorial002.py" +``` + +* `estimate_delivery` は `check_stock` に依存しています。SDK はグラフを順番に実行します。まず在庫、次に見積もり、最後にツールです。 +* `stock` も `delivery` も最終的には `check_stock` を必要としますが、実行されるのは**1 回の呼び出しにつき 1 回**です。在庫の検索は 1 回、利用側は 2 つです。 +* 登録するものは何もありません。グラフは注釈「そのもの」です。 + +!!! check + 「呼び出しごとに 1 回」を鵜呑みにしないでください。`check_stock` に `print` を入れて、Inspector から `order_book` を呼び出してみましょう。呼び出しごとに 1 行です。利用側は 2 つ、検索は 1 回です。 + +SDK がグラフを解析するのは、ツールが呼び出されたときではなく、登録されたときです。分類できないパラメーター(`Context` でも `Resolve(...)` でもツール引数の名前でもないもの)とリゾルバーの循環は、どちらも起動時に `InvalidSignature` を送出します。サーバーはクライアントが接続する前に失敗し、問題のパラメーターやリゾルバーの名前がエラーに示されます。 + +リゾルバーのパラメーターは、ツールのパラメーターとまったく同じように解決されます。別の `Resolve(...)`、名前で照合されるツール自身の引数、または `Context` です。`ctx.headers` もライフスパンのオブジェクトも、すべて使えます。 + +!!! warning + HTTP トランスポートでは、`Context` に `ctx.headers` が含まれます。ヘッダーはツール引数と同じく**クライアントが供給する入力**です。ロケールや機能フラグには問題ありませんが、身元の確認には決して使わないでください。呼び出し側が誰であるかは、誰でも設定できるヘッダーではなく、認可レイヤー(**[認可](../run/authorization.md)**)から得ます。 + +!!! tip + 「呼び出しごとに 1 回」は文字どおりの意味です。次の `tools/call` では `check_stock` が再び実行されます。リクエストより長く生き続けるべきリソース(データベースプールや HTTP クライアントなど)は **[ライフスパン](lifespan.md)** に置くものです。リゾルバーからは `ctx.request_context.lifespan_context` を通じて参照できます。 + +## 必要なときだけ尋ねる {#ask-when-you-must} + +リゾルバーは答えを知っている必要はありません。`Elicit(message, Model)` を返せば、SDK がユーザーに尋ねます。**[エリシテーション(elicitation)](elicitation.md)** の仕組みを、代わりに実行してくれます。 + +```python title="server.py" hl_lines="26-32 39" +--8<-- "docs_src/dependencies/tutorial003.py" +``` + +* 在庫がある場合:`confirm_backorder` は `Backorder` を直接返します。**質問もラウンドトリップもありません。**ユーザーの作業を中断するのは、その答えが意味を持つときだけです。 +* 在庫がない場合:SDK がエリシテーションを送信し、答えを `Backorder` に照らして検証し、注入します。リゾルバーはプロトコルに一切触れません。 +* ツールは `backorder.confirm` をほかの引数と同じように読み取ります。**いいえ**と答えるのも立派な答えです。エリシテーションは `confirm=False` で受理され、ツールは実行され、注文は行われません。尋ねることは、ツール本体の配管ではなく前提条件になりました。 + +では、ユーザーがまったく答えない場合、つまり質問を辞退したりキャンセルしたりした場合はどうなるでしょうか。 + +!!! check + `Neuromancer` で `order_book` を実行し、質問を辞退してみてください。注釈を `Annotated[Backorder, Resolve(...)]` と書いた場合、ツール本体は実行されません。呼び出しは、モデルが読めるエラー結果で失敗します。 + + ```text + Error executing tool order_book: Resolver for parameter 'backorder' could not resolve: elicitation was decline + ``` + +前提条件としてはこれが正しいデフォルトです。答えがなければ注文もありません。辞退をツールで扱いたい結果にしたいとき(取り寄せはやめても、別のタイトルを提案したいなど)は、代わりに `ElicitationResult[Backorder]` で注釈を付けてください。ツールは受理・辞退・キャンセルの結果をまるごと受け取り、それに応じて分岐できます。この形式のほか、尋ねることに関するそれ以外のすべて(スキーマの規則、3 つの答え、会話のクライアント側)は **[エリシテーション](elicitation.md)** で説明しています。 + +!!! info + フレームワークは、ネゴシエートされたプロトコルバージョンから質問のトランスポートを選びます。上のコードはどちらでも同じです。**2026-07-28** 以降では、質問はマルチラウンドトリップ(multi-round-trip)の `tools/call` の中で運ばれます。サーバーが質問を返し、クライアントの `elicitation_callback` がそれに答え、`Client` が呼び出しを再試行してくれます(**[マルチラウンドトリップリクエスト](multi-round-trip.md)**)。**2025-11-25** 以前では、呼び出しの途中で行われる同期的なエリシテーションリクエストです。各質問は 1 回の呼び出しにつきちょうど 1 回だけ尋ねられます。これは質問についての保証であり、リゾルバーについての保証ではありません。マルチラウンドトリップの形式では、質問の後に呼び出しが再開されるたびに、どのリゾルバーも再び実行される可能性があります。そのため、`return Elicit(...)` より前のコードはそれらのラウンドごとに実行されます。記録された答えは、繰り返される質問をユーザーに再度尋ねることなく満たします。記録された答えが参照されるのは、リゾルバーが尋ねたときだけです。`check_stock` のように尋ね**ずに**答えるリゾルバーは、常に自分で計算した値を供給します。それぞれの答えは対応する質問に照合されるので、エリシテーションを行うリゾルバーは、ツールの引数とそれまでの答えから決定論的に質問を導かなければなりません。呼び出しごとに生成される値(`default_factory` の ID やタイムスタンプ)はラウンドごとに導き直されるため、答えを結び付けたい質問の中に含めてはいけません。そうした変わりやすいデータから組み立てた質問は、記録された答えをすべて古く見せてしまいます。その結果、サーバーはクライアントのラウンド上限が呼び出しを終わらせるまで、ラウンドごとに同じ質問を繰り返します。 + +## ユーザーではなくクライアントに尋ねる {#ask-the-client-not-the-user} + +エリシテーションは、リゾルバーが尋ねられる 3 つの質問のうちの 1 つで、マルチラウンドトリップのフローではこれ以外は許されません。残りの 2 つはユーザーではなく**クライアント**に向けられます。`Sample(...)` を返せばクライアントを通じて LLM の呼び出しを実行し(`sampling/createMessage` リクエスト)、`ListRoots()` を返せばクライアントの現在のルート(roots)を取得します。どちらにも受理・辞退という結果はありません。利用側は結果の型を直接注釈に書きます。`CreateMessageResult`(リクエストが `tools` または `tool_choice` を伴う場合は `CreateMessageResultWithTools`)、または `ListRootsResult` です。 + +```python title="server.py" hl_lines="10-15 21" +--8<-- "docs_src/dependencies/tutorial004.py" +``` + +* フレームワークはこれらを `Elicit` とまったく同じように振り分けます。**2026-07-28** ではマルチラウンドトリップの `tools/call` の中で、**2025-11-25** では単独のサーバーからクライアントへのリクエストで運ばれます。宣言されていないケイパビリティは、`-32021` のプロトコルエラーで呼び出しを拒否します(`sampling`、`roots`、フォームモードの `elicitation`。リクエストが `tools` または `tool_choice` を伴う場合は `sampling.tools`)。 +* 上の info ボックスが質問について述べていることは、すべてそのまま当てはまります。`Sample` リクエストは、その正確な表現によって記録された結果と照合されます。そのため、ツールの引数とそれまでの答えから決定論的に組み立ててください。そうすれば、クライアントが LLM の呼び出しに支払うのはラウンドごとに 1 回ではなく、ツール呼び出しごとに 1 回になります。記録された結果は呼び出しの残りの間 `request_state` に載って運ばれるため、補完が非常に大きいと、残りのラウンドトリップがすべて重くなります。 +* 単独のサンプリングとルートの「機能」は、2026-07-28 で非推奨になります(SEP-2577)。クライアントのモデルを必要とする新しいサーバーは、この運び手を通じて尋ねます。必要としないサーバーは、LLM プロバイダーと直接統合してください。`"none"` 以外の `include_context` の値はそれ自体が非推奨です。使わないでください。 + +## まとめ {#recap} + +* ツールのパラメーターに `Annotated[T, Resolve(fn)]` を付けると、SDK が `fn` を実行し、その戻り値を注入します。 +* 解決されるパラメーターはモデルからは見えず、クライアントからも渡せません。モデルがでっち上げてはならない値(価格、身元、権限)はここに置きます。 +* リゾルバーのパラメーターも同じ方法で解決されます。`Context`、別の `Resolve(...)`、または名前で照合されるツール引数です。グラフは、利用側がいくつあっても、各リゾルバーをラウンドごとに最大 1 回だけ実行します。各質問はちょうど 1 回だけ尋ねられ、質問の後に呼び出しが再開されると、どのリゾルバーも再び実行される可能性があります。 +* 不正なグラフは、呼び出しの途中ではなく登録時に `InvalidSignature` で失敗します。 +* ユーザーに尋ねるには `Elicit(message, Model)` を返します。ただし、必要なときだけです。包まない注釈は辞退されると中断し、`ElicitationResult[T]` ならツールが分岐できます。 +* クライアントに LLM の補完やルートの一覧を尋ねるには、`Sample(...)` または `ListRoots()` を返します。そのままの結果が注入されます。 + +サーバーが起動時に一度だけ組み立てる状態と、ハンドラーからそこに到達する方法については、**[ライフスパン](lifespan.md)** のページを参照してください。 diff --git a/i18n/ja/pages/handlers/elicitation.md b/i18n/ja/pages/handlers/elicitation.md new file mode 100644 index 0000000000..367225d3a5 --- /dev/null +++ b/i18n/ja/pages/handlers/elicitation.md @@ -0,0 +1,175 @@ +--- +translation: + sections: [335ca2a0b266f003, d1ad562d3fe87bc0, 0bb1396c86daeba4, d1cb1235bb9ee267, 833179c09d239c83, e5d6dec2d2e655e8] + tool: 1 +--- +# エリシテーション {#elicitation} + +処理の途中で答えがひとつ足りないだけのツールは、失敗する必要はありません。 + +**エリシテーション(elicitation)**を使えば、質問できます。ツール呼び出しの途中でユーザーに質問が届き、その答えが同じ関数呼び出しの中に戻ってきます。 + +モードは 2 つあります。 + +* **フォームモード**:値(確認、日付、数量)が必要な場合です。フィールドを記述すると、クライアントがフォームを描画します。 +* **URL モード**:ユーザーに別の場所(OAuth の同意画面、決済ページ)へ行ってもらう必要がある場合です。そこでユーザーが行うことは、何ひとつプロトコルを通りません。 + +そして、質問の仕方も 2 通りあります。まず選ぶべきなのは**リゾルバー**です。質問をパラメーターに結び付けておけば、SDK が質問します。どんな接続でも、クライアントがどのプロトコルの世代を話していても動きます。直接的な方法である `await ctx.elicit(...)` は、サーバーからクライアントへのリクエストです。この経路は、レガシー接続(仕様バージョン 2025-11-25 以前)のクライアントにしか存在しません。このページでは両方を扱いますが、まずはリゾルバーから始めてください。 + +## リゾルバーで質問する {#ask-with-a-resolver} + +ツール全体の実行を左右する質問(「本当によろしいですか」「一致した 3 つのアカウントのうちどれですか」など)は、ツール本体から取り出して**リゾルバー**に移せます。そうすれば、フレームワークが代わりに質問してくれます。 + +`Annotated[T, Resolve(fn)]` と注釈したパラメーターには、ツール本体の前に `fn` を実行した結果が入ります。リゾルバーは、値がすでに分かっていればそのまま返し、フレームワークに質問させたいときは `Elicit(...)` を返します。 + +```python title="server.py" hl_lines="24-30 35-36" +--8<-- "docs_src/elicitation/tutorial004.py" +``` + +* `confirm_delete` はツール自身の `path` 引数を名前で読み取り、フォルダーの中身を一覧し、**必要なときにだけ質問します**。空のフォルダーなら、クライアントとの往復なしで `Confirm(ok=True)` に解決されます。 +* `delete_folder` は `ElicitationResult[Confirm]` と注釈しているので、フレームワークは結果全体を注入し、ツールはすべての場合を `match` で分岐します。承諾して確認、承諾したが削除しない(`ok=False`)、拒否、キャンセルです。 +* `confirm` パラメーターはツールの入力スキーマには決して現れません。クライアントが `path` を渡し、リゾルバーが `confirm` を渡します。 + +ツールが分岐する必要がないなら、代わりにラップしていないモデル(`Annotated[Confirm, Resolve(confirm_delete)]`)で注釈してください。承諾ならツールはモデルを受け取り、拒否やキャンセルなら呼び出しはエラーで中断されます。 + +リゾルバーは**すべての**接続で動きます。レガシー接続のクライアントには、SDK が質問を直接送ります。**2026-07-28** の接続では、SDK が呼び出しから質問を「返し」、クライアントの次の試行が答えを運んできます。リゾルバーがその違いを知ることはありません。その裏で何が起きているかは **[マルチラウンドトリップリクエスト(multi-round-trip requests)](multi-round-trip.md)** で説明しています。 + +質問は、リゾルバーにできることのひとつにすぎません。質問せずに計算する依存関係、依存関係の依存関係、モデルが渡せるものと渡せないものといった仕組み全般は、**[依存関係](dependencies.md)** のページで説明しています。 + +## ツールの中から質問する {#ask-from-inside-the-tool} + +ツールは、自分の本体の途中で止まって質問することもできます。 + +!!! warning + `ctx.elicit()` と `ctx.elicit_url()` はサーバーからクライアントへのリクエストです。この経路は、レガシー接続(仕様バージョン **2025-11-25** 以前)のクライアントにしか存在しません。**2026-07-28** の接続にはサーバー起点のリクエストがないため、これらの呼び出しは失敗します。リゾルバーはどちらでも動きます。詳しくは **[プロトコルバージョン](../protocol-versions.md)** を参照してください。 + +`await ctx.elicit()` はメッセージと Pydantic モデルを受け取ります。 + +```python title="server.py" hl_lines="9-11 20-23 25" +--8<-- "docs_src/elicitation/tutorial001.py" +``` + +* **`Context`** パラメーターがあるからこそ `ctx.elicit` が使えます。どのツールでも受け取れます。このオブジェクトについては専用のページ **[Context](context.md)** があります。 +* `AlternativeDate` は、欲しい答えの**スキーマ**です。 +* ツールは `async def` です。そうでなければなりません。途中で止まって人を待つからです。 +* ほかの日付なら、ツールはすぐに返ります。質問するのは必要なときだけです。 +* ユーザーが承諾した日付は、`book_table` 自身をもう一度通ります。答えも、ほかの入力と同じ入力です。代わりの日付も満席なら、やみくもに確定するのではなく、もう一度質問します。 + +### クライアントが受け取るもの {#what-the-client-receives} + +クライアントは、メッセージと一緒に、モデルから生成された JSON Schema を受け取ります。 + +```json +{ + "properties": { + "accept_alternative": { + "description": "Try another date?", + "title": "Accept Alternative", + "type": "boolean" + }, + "date": { + "default": "2025-12-26", + "description": "Alternative date (YYYY-MM-DD)", + "title": "Date", + "type": "string" + } + }, + "required": ["accept_alternative"], + "title": "AlternativeDate", + "type": "object" +} +``` + +このスキーマがフォームです。`Field(description=...)` がラベルになり、デフォルト値は入力欄にあらかじめ入って、そのフィールドを省略可能にします。これは、**[ツール](../servers/tools.md)** のページがツールの引数について説明しているのと同じ、Pydantic から JSON Schema への変換の仕組みです。 + +!!! warning + エリシテーションのスキーマは、ツールの入力スキーマほど表現力がありません。フラットなプリミティブ型のフィールドだけです。`str`、`int`、`float`、`bool`、または文字列の `Literal`(`enum` になります)。モデルの中にモデルを入れると、クライアントに何かを送る前に `ctx.elicit` が例外を送出します。 + + ```text + TypeError: Elicitation schema field 'address' rendered as {'$ref': '#/$defs/Address'}, which is not a valid PrimitiveSchemaDefinition + ``` + + 人の作業を中断して質問しているのです。答えに入れ子が必要なら、それはツールの引数にすべきでした。 + +### 3 つの答え {#the-three-answers} + +`result.action` を見れば、ユーザーが何をしたかが分かります。可能性はちょうど 3 つです。 + +* `"accept"`:フォームを送信しました。`result.data` は検証済みの `AlternativeDate` インスタンスです。 +* `"decline"`:断りました。 +* `"cancel"`:選ばずに質問を閉じました。 + +`result.data` は `"accept"` のときにしか存在しません。だからこそ、この例では先に `result.action` を確認しています。この順序は型チェッカーが強制します。`result.action == "accept"` の後では `result.data` は `AlternativeDate` ですが、その前には `.data` 自体がありません。 + +断られてもエラーではありません。断られたことが何を意味するか(ここでは、予約しないこと)はツールが決め、モデルには普通に答えます。 + +!!! tip + 答えは、コードに届く前にモデルに照らして検証されます。`bool` に `"maybe"` を送ってくるクライアントがいても、予約が壊れることはありません。呼び出しはスキーマ不一致のエラーで失敗し、`if` は実行されません。 + +## ユーザーを URL へ誘導する {#send-the-user-to-a-url} + +認証情報、カード番号、OAuth の同意など、モデルやクライアントを通してはならないものがあります。そうしたものについては、データを求めるのではなく、ユーザーにどこかへ行ってもらうよう頼みます。 + +```python title="server.py" hl_lines="10-14 23" +--8<-- "docs_src/elicitation/tutorial002.py" +``` + +* `ctx.elicit_url()` は、メッセージ、開いてもらう **URL**、そして自分で決める `elicitation_id` を受け取ります。これは、サーバー内でこのエリシテーションを識別できる任意の文字列です。 +* 結果にあるのはアクションだけです。`"accept"` はユーザーが URL を開くことに同意したという意味で、向こう側でやるべきことを終えたという意味**ではありません**。 +* 決済は、ユーザーのブラウザーと決済プロバイダーの間で、帯域外で行われます。MCP を通って戻ってくる内容は一切ありません。 + +2 つ目のツールを見てください。帯域外のフローが終わったことをサーバーが知ったとき(webhook やポーリング。ここでは 2 つ目のツールとしてモデル化しています)、`ctx.session.send_elicit_complete(...)` が同じ `elicitation_id` を付けて `notifications/elicitation/complete` を送ります。これによってクライアントは、「waiting for payment...」の表示をやめてよいと分かります。これがなければ、クライアントは推測するしかありません。 + +## クライアント側 {#the-client-side} + +質問するのはサーバーです。クライアントは、`Client(...)` に **`elicitation_callback`** を渡すことで答えます。 + +```python title="client.py" hl_lines="6-7 18" +--8<-- "docs_src/elicitation/tutorial003.py" +``` + +* 1 つのコールバックで両方のモードを扱います。`params` は `ElicitRequestFormParams` と `ElicitRequestURLParams` のユニオンで、`isinstance` で分岐します。 +* URL の場合は、`params.url` をユーザーに見せ、ユーザーが選んだアクションを返します。`content` は決して返しません。 +* フォームの場合、本物のアプリケーションなら `params.requested_schema` を描画し、ユーザーの入力を `content` として返します。このコールバックは決まった答えで常に「はい」と答えます。これはまさに、テストで欲しいコールバックです。 +* コールバックを渡すことは、**ケイパビリティの宣言**でもあります。これによってサーバーは、このクライアントに質問できることを知ります。クライアントがサーバーの代わりに答えられるほかのことは、**[クライアントのコールバック](../client/callbacks.md)** にまとまっています。 + +!!! info + エリシテーションはサーバーからクライアントへのリクエストであり、それは従来のハンドシェイクによるセッションにしか存在しません。このクライアントが `mode="legacy"` を渡しているのはそのためです。**2026-07-28** の接続では、ツールは代わりに呼び出しから質問を「返す」ことで質問します。その流れは **[マルチラウンドトリップリクエスト](multi-round-trip.md)** で説明しています。 + +### 試してみる {#try-it} + +`ctx.elicit` を使うフォームモードの `server.py`(`book_table` のほう)を Streamable HTTP で起動し(1 行で起動するコマンドは **[サーバーの実行](../run/index.md)** にあります)、クライアントの `main()` を実行して、`book_table` にクリスマス当日の予約を頼んでください。 + +コールバックは、送られてきた質問を表示します。 + +```text +No tables for 2 on 2025-12-25. Would you like to try another date? +``` + +コールバックは `{"accept_alternative": True, "date": "2025-12-27"}` と答え、その間ずっと `await ctx.elicit(...)` の中で待っていたツールが予約を完了します。 + +```text +Booked a table for 2 on 2025-12-27. +``` + +今度は URL モードの `server.py` に差し替え、同じ `main()` を `pay_deposit` に向けてください。同じコールバックがもう一方の分岐を通り、決済リンクを表示し、ツールは「Complete the payment in your browser.」と返してきます。呼び出しの途中で、双方向に 1 往復です。 + +!!! check + 今度は `Client` から `elicitation_callback=` を取り除き、もう一度クリスマス当日で `book_table` を呼び出してください。呼び出し全体がプロトコルエラーで失敗します。 + + ```text + Elicitation not supported + ``` + + コールバックを登録しなかったクライアントは `elicitation` ケイパビリティを宣言していないので、質問する相手がいません。ツールが受け取ったのは `"decline"` ではなく、例外です。これに備えて設計してください。どのエリシテーションにも、「質問できなかったらどうするか」に対する妥当な答えが必要です。 + +## まとめ {#recap} + +* `Annotated[T, Resolve(fn)]` と注釈したパラメーターはリゾルバーが埋め、リゾルバーは質問が必要なときに `Elicit(...)` を返します。すべての接続で動きます。 +* スキーマはフラットな Pydantic モデルです。プリミティブ型のフィールドだけで、戻ってくるときに検証されます。 +* `result.action` は `"accept"`、`"decline"`、`"cancel"` のいずれかで、`result.data` は承諾のときにだけ存在します。 +* `await ctx.elicit(message, schema=Model)` はツール本体の中から質問し、`await ctx.elicit_url(message, url, elicitation_id)` はモデルを通してはならないもののためにあります(帯域外の部分が終わったことは `ctx.session.send_elicit_complete(elicitation_id)` で伝えます)。どちらもサーバーからクライアントへのリクエストなので、クライアントがレガシー接続である必要があります。 +* クライアントは 1 つの `elicitation_callback` で答え、params の型で分岐します。これを登録することがケイパビリティの宣言になります。 +* 2026-07-28 の接続では、サーバーは質問をプッシュする代わりに返します。同じコールバックに質問を届けるのは **[マルチラウンドトリップリクエスト](multi-round-trip.md)** です。 + +その「返す」仕組みの裏側(再試行ループ、`requestState` の保護、自分で駆動する方法)は、すべて **[マルチラウンドトリップリクエスト](multi-round-trip.md)** で説明しています。 diff --git a/i18n/ja/pages/handlers/index.md b/i18n/ja/pages/handlers/index.md new file mode 100644 index 0000000000..8971a5ccad --- /dev/null +++ b/i18n/ja/pages/handlers/index.md @@ -0,0 +1,24 @@ +--- +translation: + sections: [424930166c4bc6f3] + tool: 1 +--- +# ハンドラーの中で {#inside-your-handler} + +ハンドラーの引数はクライアントから渡されます。それ以外にハンドラーが読み取れるものと、実行中にできることは、すべてここにまとめてあります。 + +読み取れるもの: + +* **[Context](context.md)** は、どのハンドラーでも要求できる唯一の追加パラメーターです。処理中のリクエスト、そのヘッダー、セッション、そして進捗通知と変更通知のための操作を提供します。 +* **[依存関係](dependencies.md)** は、モデルからは決して見えないパラメーターです。`Resolve` を使って自分の関数で値を埋めます。 +* **[ライフスパン](lifespan.md)** では、サーバーが起動時に一度だけ構築する状態と、ハンドラーが `Context` を通じてそれにアクセスする方法を扱います。 + +実行中にできること: + +* **[エリシテーション(elicitation)](elicitation.md)** と、それを運ぶ 2026-07-28 のパターンである **[マルチラウンドトリップ(multi-round-trip)リクエスト](multi-round-trip.md)** を使って、ユーザーに追加の入力を求めます。 +* **[サンプリングとルート(roots)](sampling-and-roots.md)** を使って、クライアントに LLM の補完やワークスペースのフォルダーを要求します。非推奨ですが、引き続き提供されています。 +* 時間のかかる処理について **[進捗](progress.md)** を報告します。 +* **[ロギング](logging.md)** でログを書き出します(サーバーを運用する人に向けて、標準エラーに出力します)。 +* **[サブスクリプション](subscriptions.md)** で、購読中のクライアントに変更があったことを伝えます。 + +まだハンドラーを登録していない場合は、**[ツール](../servers/tools.md)** から始めてください。ここにあるページはどれも、ハンドラーが 1 つあることを前提にしています。 diff --git a/i18n/ja/pages/handlers/lifespan.md b/i18n/ja/pages/handlers/lifespan.md new file mode 100644 index 0000000000..53e8670d85 --- /dev/null +++ b/i18n/ja/pages/handlers/lifespan.md @@ -0,0 +1,101 @@ +--- +translation: + sections: [f3ca8ac5f90f2dfa, 85a1ef3588ba0736, 563346d4d5804933, 9e3528340d0bab53] + tool: 1 +--- +# ライフスパン {#lifespan} + +実際のサーバーの多くは、動いている間ずっと何かを保持しています。データベースのプール、HTTP クライアント、読み込んだモデルなどです。 + +それを呼び出しのたびに組み立てたくはありませんし、終了時にはきれいに閉じたいはずです。そのためにあるのが**ライフスパン**です。 + +## 型付きのライフスパン {#a-typed-lifespan} + +ライフスパンは、サーバーを受け取って**オブジェクトを 1 つ** `yield` する `@asynccontextmanager` です。yield したものは、サーバーが動いている限りすべてのハンドラーから利用できます。 + +```python title="server.py" hl_lines="25-31 34 38 40" +--8<-- "docs_src/lifespan/tutorial001.py" +``` + +下から順に読んでいきます。 + +* `app_lifespan` は `yield` の**前**で `Database` に接続し、その**後**、`finally` の中で切断します。これが起動と終了の処理です。 +* yield するのは `AppContext` です。セットアップしたものを保持するだけの素朴な dataclass です。今日はフィールドが 1 つでも、明日は 10 個になるかもしれません。 +* つなぎ込みは `MCPServer("Bookshop", lifespan=app_lifespan)` だけで完了します。 +* ツールの中では、yield したオブジェクトは `ctx.request_context.lifespan_context` として取り出せます。 + +ライフスパンは **1 回だけ**実行されます。サーバーの起動時(最初のリクエストより前)に入り、サーバーの停止時に抜けます。その間のすべてのリクエストが同じ `AppContext` を共有します。 + +!!! info + FastAPI の `lifespan` を書いたことがあれば、すでに知っている内容です。同じデコレーター、同じ `yield`、同じ `finally` です。 + +### モデルから見えるもの {#what-the-model-sees} + +新しいものは何もありません。`ctx` は **Context** パラメーターなので、SDK が注入し、入力スキーマには決して現れません。 + +```json +{ + "type": "object", + "properties": { + "genre": {"title": "Genre", "type": "string"} + }, + "required": ["genre"], + "title": "count_booksArguments" +} +``` + +モデルが渡せる引数は `genre` だけです。ライフスパンはサーバー側の内部事情です。 + +`@mcp.resource()` と `@mcp.prompt()` の関数も `ctx` パラメーターを受け取れます。ただし型は裸の `Context` と書きます。理由は次の節で説明します。`ctx` が持っているものはすべて **[Context](context.md)** にまとめてあります。 + +### 本当に型が付いている {#it-really-is-typed} + +もう一度アノテーションを見てください。`ctx: Context[AppContext]` です。 + +この型パラメーター 1 つがあるからこそ、型チェッカーにとって `ctx.request_context.lifespan_context` は `AppContext` **そのもの**になります。`.db` は自動補完され、`.dbb` はサーバーを動かす前からエラーになります。 + +代わりに裸の `Context` と書くと、`lifespan_context` の型は `dict[str, Any]` になります。ライフスパンが何を yield したのか、型チェッカーには知りようがないからです。実行時にはオブジェクトはそこにありますが、型による補助は失われます。 + +!!! warning + `Context[AppContext]` は**ツール専用**の書き方です。`@mcp.resource()` や `@mcp.prompt()` の関数に付けると、そのハンドラーの呼び出しはすべて失敗します。クライアントにはエラーが返り、サーバーのログには理由が記録されます。 + + ```text + Context is not available outside of a request + ``` + + リソースとプロンプトでは、裸の `ctx: Context` と書いてください。ライフスパンが yield したオブジェクトは、実行時には引き続き `ctx.request_context.lifespan_context` にあります。手放すのは型パラメーターであって、オブジェクトではありません。 + +!!! tip + ライフスパンは必ず存在します。渡さなければ SDK のデフォルトが空の `dict` を yield するので、`ctx.request_context.lifespan_context` は `{}` であり、`None` になることはありません。裸の `Context` で型が `dict[str, Any]` になるのも、このデフォルトがあるためです。 + +## 実際に動かして確かめる {#watch-it-happen} + +「起動処理は最初のリクエストより前に走る」というのは、言われたまま信じるべき類の話ではありません。 + +サーバーをライフサイクルだけに絞り込みましょう。`Database` に `connected` フラグを持たせ、`connect()` と `disconnect()` でそれを切り替え、その状態を報告するツールを追加します。 + +```python title="server.py" hl_lines="11 14 17 25 44" +--8<-- "docs_src/lifespan/tutorial002.py" +``` + +`database` をモジュールレベルに置いている理由は 1 つだけです。サーバーの「外側」から覗けるようにするためです。 + +!!! check + 3 つの時点で、3 つの値になります。 + + * サーバーの起動前、`database.connected` は `False` です。モジュールをインポートしただけでは何も接続されていません。 + * 動いている間に `database_status` を呼び出すと、結果は `"connected"` です。 + * サーバーを止めると `finally` ブロックが走り、`database.connected` は再び `False` になります。 + + 処理は置いた場所でちょうど実行されました。`yield` の前後であって、インポート時でもリクエストごとでもありません。 + +## まとめ {#recap} + +* `lifespan=` には、サーバーを受け取ってオブジェクトを 1 つ `yield` する `@asynccontextmanager` を渡します。 +* `yield` の前のコードが起動処理です。その後の `finally` が終了処理です。 +* 実行は 1 回だけで、サーバーの一生全体を囲みます。リクエストごとではありません。 +* `yield` したものは、すべてのツール、リソース、プロンプトで `ctx.request_context.lifespan_context` として使えます。 +* `ctx: Context[AppContext]` と書けば、ツールではそのアクセスに完全に型が付きます。リソースとプロンプトでは裸の `Context` を使います。 +* `lifespan=` を渡さなければ空の `dict` です。`None` になることはありません。 + +呼び出しの途中で止まり、本人にしかわからないことをユーザーに尋ねるハンドラーについては、**[エリシテーション(elicitation)](elicitation.md)** を参照してください。 diff --git a/i18n/ja/pages/handlers/logging.md b/i18n/ja/pages/handlers/logging.md new file mode 100644 index 0000000000..825d3ebc0c --- /dev/null +++ b/i18n/ja/pages/handlers/logging.md @@ -0,0 +1,79 @@ +--- +translation: + sections: [c93a3e1aefd77955, 7851abd5ec54393b, f49d1ca2f330f9cd, c03764bd9dfeef7b, 4a0391691a674ae4, 2df5cd279eabf9f5] + tool: 1 +--- +# ロギング {#logging} + +ツールからのログ出力は、他のどの Python 関数でも同じやり方です。標準ライブラリを使います。 + +MCP にはプロトコルレベルの**ロギングのケイパビリティ**があります。サーバーは `Context` オブジェクトのメソッドを通じて、ログメッセージを通知としてクライアントへ送り出せました。仕様の 2026-07-28 版では**このケイパビリティが非推奨となり、代わりのものは用意されていません**。そのため、このドキュメントでは扱いません。非推奨になったものと、代わりにどうすればよいかの一覧は、**[非推奨の機能](../deprecated.md)**にあります。 + +代わりにやることは、他のどの Python プログラムでもやっていることと同じです。標準ライブラリを使います。 + +## ログを出すツール {#a-tool-that-logs} + +```python title="server.py" hl_lines="1 5 13" +--8<-- "docs_src/logging/tutorial001.py" +``` + +* `logging.getLogger(__name__)` は、モジュール名にちなんだ名前のロガーを返します。冒頭で一度だけ作成してください。 +* ツールの中では、他の関数と同じように `logger.info(...)` を呼び出します。注入するものも、`await` するものも、MCP 固有のものも何もありません。 + +!!! check + ツールを呼び出して、結果全体を見てみましょう。 + + ```python + result.content # [TextContent(text="Found 3 books matching 'dune'.")] + result.structured_content # {'result': "Found 3 books matching 'dune'."} + ``` + + ログの行はどこにもありません。ロギングは**サーバーを運用する人**のためのものです。モデルがそれを見ることはありません。モデルに何かを読ませたいなら、`return` してください。 + +## 出力先 {#where-it-goes} + +**stdio** サーバーでは、この問いがいつも以上に重要です。ホストはサーバーをサブプロセスとして起動し、その **stdout** から MCP メッセージを読み取っています。標準エラーは自由に使えます。 + +標準ライブラリは最初から正しく動作します。ログ出力はデフォルトで `sys.stderr` に送られます。`logger.info(...)` の行はターミナル(またはホストがサブプロセスの stderr を集める場所)に届き、プロトコルのストリームはきれいなまま保たれます。 + +!!! tip + stdio サーバーで `print()` を使わないでください。`print` は **stdout** に書き込みますが、stdout はプロトコルのものです。サーバーの稼働中、SDK は実際に「フラッシュされた」stdout を stderr へ振り向けるので、通信路を壊すことはありません。しかし、ブロックバッファリングされたプロセスでの `print()` は、たいていフラッシュされないまま `sys.stdout` のバッファに残り、終了時にインタープリターがそれを吐き出すと、そのままプロトコルのストリームに流れ込みます。振り向けられた場合でも、その行はレベルもロガー名もなく、フィルターする手段もないまま、生の状態でログ出力の中に紛れ込みます。 + + `logger.debug("got here")` なら同じ 1 行の手間で、正しい場所に出力されます。 + +## レベル {#the-level} + +`logging.basicConfig()` を自分で呼び出す必要はありません。`MCPServer` を構築した時点で、すでに呼び出されています。標準エラーに向けたハンドラーが、`log_level=` で渡したレベルで設定されます。つまり `MCPServer("Bookshop", log_level="DEBUG")` と書くだけで、`logger.debug(...)` の行が見えるようになります。 + +デフォルトは `"INFO"` です。 + +`logging.basicConfig()` は、すでに存在するハンドラーを置き換えることはありません。サーバーを作成する前に自分でロギングを設定していれば、その設定が優先されます。 + +## 試してみる {#try-it} + +MCP Inspector でサーバーを実行してください。 + +```console +uv run mcp dev server.py +``` + +**Tools** タブから `search_books` を呼び出してください。Inspector に表示される結果は、戻り値だけです。次の行は、 + +```text +Searching for 'dune' +``` + +標準エラー、つまりターミナルに出力されました。通信上には現れません。 + +!!! info + 本当に欲しいものが「トレーシング」(すべてのリクエスト、かかった時間、失敗したかどうか)なら、必要なのはログ行ではなくスパンです。サーバーはすでにスパンを出力しています。SDK はデフォルトで、すべてのメッセージを OpenTelemetry でトレースします。**[OpenTelemetry](../run/opentelemetry.md)** を参照してください。 + +## まとめ {#recap} + +* MCP プロトコルのロギングのケイパビリティは 2026-07-28 版の仕様で非推奨となり、代わりのものはありません。これを土台にしないでください。 +* モジュールレベルで `logger = logging.getLogger(__name__)`、ツールの中で `logger.info(...)`。パターンはこれだけです。 +* ログ出力がモデルに届くことはありません。届くのは `return` した値だけです。 +* 標準エラーは自由に使えますが、stdout はプロトコルのものです。SDK は稼働中、フラッシュされた紛れ込みの stdout 出力を stderr へ振り向けますが、フラッシュされていない `print()` は終了時に通信路へ流れ込むことがあり、振り向けられた行もラベルなしで届きます。すべてのレコードをフラッシュするハンドラーを持つ `logging` を使ってください。 +* `MCPServer(..., log_level="DEBUG")` でレベルを設定でき、先に行ったロギングの設定はそのまま残されます。 + +サーバー上で何か(ツール一覧やリソース)が変わったことを接続中のクライアントに伝える方法は、**[サブスクリプション](subscriptions.md)**にあります。 diff --git a/i18n/ja/pages/handlers/multi-round-trip.md b/i18n/ja/pages/handlers/multi-round-trip.md new file mode 100644 index 0000000000..dc7bb0a4e1 --- /dev/null +++ b/i18n/ja/pages/handlers/multi-round-trip.md @@ -0,0 +1,183 @@ +--- +translation: + sections: [74011e683045eea9, 9b64cc175c18b6a9, 4b41be4824030397, e3b1502da786ec33, 71e41161f143c6a9, 9ec2c1eeb8c36378, 8dd027377d46448b, f81491125dcbfe8b] + tool: 1 +--- +# マルチラウンドトリップ(multi-round-trip)リクエスト {#multi-round-trip-requests} + +ツールが 1 回のラウンドトリップでは完了できないことがあります。選択、確認、認証情報など、ユーザーだけが持っているものが必要になる場合です。 + +2026-07-28 より前は、サーバーは**呼び返す**ことでそれを手に入れていました。つまり、元のリクエストを処理している途中で、エリシテーション(elicitation)やサンプリング呼び出しといった自分のリクエストをクライアントに向けて開いていました。2026-07-28 の仕様は、このバックチャネル(back-channel)を廃止します。 + +代わりに、サーバーは**返します**。 + +## 呼び返さずに返す {#return-dont-call-back} + +サーバーは `tools/call` に対して、`CallToolResult` の代わりに **`InputRequiredResult`** で応答します。仕事をするのはそのうち 2 つのフィールドです。 + +* **`input_requests`**:サーバーがまだ必要としているもの。サーバーが選んだ名前をキーとする dict です。各値は `ElicitRequest`、`CreateMessageRequest`、`ListRootsRequest` のいずれかです。 +* **`request_state`**:不透明なトークン。クライアントはリトライ時にこれをそのまま送り返します。これを読むのはサーバーだけです。 + +クライアントはそれぞれのリクエストに応えたうえで、**同じツールをもう一度**呼び出します。このとき回答を `input_responses` に、トークンを `request_state` に載せます。サーバーは足りなかったものを手に入れ、通常の `CallToolResult` を返します。 + +プロトコルはこれだけです。どの区間もクライアントからサーバーへの普通のリクエストです。逆向きに流れるものは一切ありません。 + +## サーバー側 {#the-server-side} + +`@mcp.tool()` では、これを手で組み立てることはめったにありません。ユーザーに尋ねる依存関係(`Elicit`)、クライアントの LLM をサンプリングする依存関係(`Sample`)、クライアントのルート(roots)を一覧する依存関係(`ListRoots`)のいずれかを宣言すれば、SDK が `InputRequiredResult` を返してくれます。その形式は **[依存関係](dependencies.md)** のページで扱います。2 つの形式は混在できません。1 回の呼び出しには `input_responses`/`request_state` のチャネルが 1 つしかないため、`Resolve(...)` パラメーターを使うツールは、本体から `InputRequiredResult` を返すこともできません。`InputRequiredResult` を戻り値として宣言すると登録時に拒否され(`InvalidSignature`)、宣言せずに返すと実行時に呼び出しが失敗します。手動の形式は**低レベル**の `Server` で、その `on_call_tool` ハンドラーはどちらの結果型を返してもかまいません。 + +```python title="server.py" hl_lines="43-46" +--8<-- "docs_src/mrtr/tutorial001.py" +``` + +* `on_call_tool` の型は `-> CallToolResult | InputRequiredResult` です。2 つ目を返すこと、それがサーバー側の API のすべてです。 +* 最初の呼び出しでは `params.input_responses` が `None` なので、ガードが働き、ハンドラーは答える代わりに尋ねます。 +* リトライ時には、クライアントが送った `ElicitResult` が、サーバーが `input_requests` で使ったのと**同じキー**(`"region"`)の下に入っています。 + +そのファイルの残り(明示的な `input_schema`、手組みの `CallToolResult`)は普通の低レベル `Server` で、**[低レベル Server](../advanced/low-level-server.md)** で扱っています。このページが付け加えるのは 2 つ目の戻り値の型だけです。 + +## ツール以外 {#beyond-tools} + +`tools/call` は特別ではありません。2026-07-28 では、サーバーは `prompts/get` と `resources/read` にも同じように応答できます。`MCPServer` では、`@mcp.prompt()` 関数、または `@mcp.resource()` の**テンプレート**関数が、自分で `InputRequiredResult` を返し、リトライ時の回答をコンテキストから読み取ります。 + +```python title="server.py" hl_lines="20 22 24" +--8<-- "docs_src/mrtr/tutorial004.py" +``` + +* 1 回目は `InputRequiredResult` を返します。リトライ時には `ctx.input_responses` が同じキーの下に回答を保持しており、関数は通常の結果を返します。ここではプロンプトメッセージ、テンプレートリソースならリソースの内容です。 +* 設定した `request_state` は、サーバー上の他のものと同じく、通信路に出る前に封印され、送り返されたときに検証されます。封印で何が得られるか、いつキーの設定が必要かは、下の **[`requestState` の保護](#protecting-requeststate)** で扱います。 +* `@mcp.tool()` 関数も、依存関係の形式が合わない場合は、同じように結果を直接返せます。 +* 静的な `@mcp.resource()` 関数は参加しません。`Context` を受け取らないので、リトライを読み取りようがないからです。尋ねられるのはテンプレートリソースだけです。 +* 下の世代のルールはそのまま適用されます。2026 より前のセッションで `InputRequiredResult` を返すと、警告で説明しているのと同じ `-32603` になります。 + +## クライアント側 {#the-client-side} + +`Client` がループを回してくれます。 + +サーバーが求める可能性のあるコールバック(`elicitation_callback`、`sampling_callback`、`list_roots_callback`)を登録し、ツールを呼び出します。`InputRequiredResult` が届くと、`Client` は `input_requests` の各エントリーを対応するコールバックに振り分け、回答とエコーバックした `request_state` を付けてリトライし、`CallToolResult` が返ってくるまで続けます。 + +```python title="client.py" hl_lines="11 12" +--8<-- "docs_src/mrtr/tutorial003.py" +``` + +* この `elicitation_callback` は、2026 より前のサーバーのバックチャネル `elicitation/create` が呼び出していたはずのものと同じです。`sampling/createMessage` に対する `sampling_callback`、`roots/list` に対する `list_roots_callback` も同様です。2026-07-28 では単独のサーバー→クライアント RPC はなくなりましたが、まったく同じ `ElicitRequest` / `CreateMessageRequest` / `ListRootsRequest` のペイロードが `input_requests` の中に載り、同じ 3 つのコールバックに振り分けられます。1 組のコールバックで両方の世代に対応できます。 +* `call_tool` は素の `CallToolResult` を返します。途中のラウンドは呼び出し側からは見えません。 +* `get_prompt` と `read_resource` も同じループを回します。 + +!!! check + コールバックを付けないままにすると、ループは 1 回目で失敗します。SDK の代役のコールバックはすべてのエリシテーションにエラーで答え、`call_tool` は *"Elicitation not supported"* というメッセージの `MCPError` を送出します。 + +ループには上限があります。`Client(..., input_required_max_rounds=10)` がデフォルトの上限で、それを超えて `InputRequiredResult` を返し続けるサーバーに対しては `call_tool` が例外を送出します。あるラウンドが `request_state` だけを載せていて `input_requests` がない場合、`Client` はリトライの前に短くスリープします(50ms から倍々に増えて上限 250ms)。そのため、単に「まだ終わっていない」と言っているだけのサーバーをビジーポーリングすることはありません。 + +### ループを自分で回す {#driving-the-loop-yourself} + +自動ループは単一プロセスのクライアントには十分です。次のような場合は、代わりに自分でループを握ってください。 + +* クライアントが**分散**している場合。ユーザーに質問を表示するプロセスが `call_tool` を呼んだプロセスではなく、別のワーカーがリトライを発行します。`request_state` はその境界を越えて自分のストレージ経由で持ち運べる永続化可能なトークンであり、`input_responses` は向こう側がそれと一緒に送り返すものです。 +* 各ラウンドを**検査**したい場合。すべての `input_requests` エントリーを記録・監査する、特定の種類のリクエストを拒否する、区間の間に独自のバックオフを適用する、などです。 +* ラウンド数ではなく**実時間**で上限をかけたい場合。`input_required_max_rounds` に頼る代わりに、自分のループを `anyio.fail_after(...)` で包みます。 + +下層のセッションに降りると、`allow_input_required=True` がユニオン型をそのまま渡してくれます。 + +```python title="client.py" hl_lines="12 13 19" +--8<-- "docs_src/mrtr/tutorial002.py" +``` + +* `client.session.call_tool(..., allow_input_required=True)` は戻り値の型を `CallToolResult | InputRequiredResult` に広げます。それを絞り直すのが `isinstance` です。 +* `request_state` はこれで自分の手の中にあります。区間の間で書き留めておけば、新しいプロセスから会話を再開できます。 +* `input_requests` の各エントリーについて、`input_responses` の**同じキー**の下に `InputResponse` を置きます。`fulfil` が UI の入る場所です。この例では回答をハードコードしています。 +* どの区間でも、ツール名も `arguments` も同じです。リトライは元の呼び出しをもう一度実行するものであり、新しいメソッドではありません。 + +## `requestState` の保護 {#protecting-requeststate} + +ここまでは `request_state` をエコーとして扱ってきましたし、通信上はまさにそれだけのものです。しかしクライアントは区間の間それを保持します(プロセスをまたいで書き留めることは、まさに前のセクションが認めたことです)。そのため、戻ってくるものは**クライアントが供給する入力**です。改変されているかもしれず、期限切れかもしれず、まったく別の呼び出しから抜き取られたものかもしれません。仕様はサーバーに対し、この状態が認可、リソースアクセス、ビジネスロジックに影響しうる場合は常に、状態の完全性を保護し、検証に失敗したラウンドを拒否することを要求しています。 + +`MCPServer` はデフォルトでこれを保護します。どのサーバーも、送り出す `requestState` を封印し、すべてのエコーを検証します。リゾルバーの状態も手組みの状態も同様で、プロセス起動時に生成されたキーを使います。設定するものは何もなく、平文を書き、平文を読みます。通信路に載るのは不透明な暗号化トークンだけです。 + +デフォルトのキーはプロセスとともに生まれて消えます。単一プロセスを超えてデプロイする前に、これだけは知っておく必要があります。 + +```python +from mcp.server.mcpserver import MCPServer, RequestStateSecurity + +# Multi-instance or restart-surviving: one or more shared secret keys (>= 32 bytes each). +mcp = MCPServer("fleet", request_state_security=RequestStateSecurity(keys=[key])) +``` + +* **デフォルト(設定なし)**は単一プロセスに向いています。stdio、または HTTP ワーカーがちょうど 1 つの場合です。別のワーカー、ロードバランサーの背後の別インスタンス、再起動後の同じサーバーに届いたリトライは、そのプロセスが持っていないキーで封印されています。クライアントは下記の固定の拒否を受け取り、フローを最初からやり直さなければなりません。 +* **`keys=[...]`** は、リトライが**別のインスタンス**に届く可能性がある場合(マルチワーカーの `uvicorn`、ロードバランスされた HTTP)や、再起動をまたいで生き残る必要がある場合に必須です。どのインスタンスも、兄弟インスタンスが発行したものを検証できます。仕組みは同じで、生成されたものの代わりに自分のシークレットを使うだけです。 +* KMS や既存のトークンサービスなど独自の暗号を使うなら、`keys` の代わりに `RequestStateSecurity(codec=...)` を渡します。その契約は下の **[独自の暗号を持ち込む](#bring-your-own-crypto)** で扱います。 + +### 封印が運ぶもの {#what-the-seal-carries} + +デフォルトでも設定済みでも、通信路上の `requestState` は暗号化され認証されたトークンです。自分のコードがそれを目にすることはありません。ハンドラーとリゾルバーは平文を書き、平文を読みます(`ctx.request_state`)。SDK が送り出すときに封印し、受け取るときに検証します。完全性に加え、各トークンは次のものに束縛されます。 + +* **時間枠。** ラウンドごとに新しい有効期限で封印し直すため、`RequestStateSecurity(ttl=...)`(デフォルト 600 秒)が制限するのはフロー全体ではなく、ラウンドごとの考慮時間です。 +* **認証されたプリンシパル。** SDK が検証した OAuth アクセストークンをリクエストが載せている場合、状態はトークンのクライアント、発行者、サブジェクトに束縛されます。あるユーザー向けに発行された状態は、両者が 1 つの OAuth クライアントを共有していても、別のユーザーの下では失敗します。サブジェクトを供給しないベリファイアーは、束縛をクライアントの識別情報だけに弱めます。URL ベースのクライアント ID の下では、それはそのクライアントソフトウェアのすべてのユーザーで共有されます。認証が SDK の外(前段のプロキシ)で終端されている場合や、トランスポートが認証なしの場合は、束縛するプリンシパルがないためこのチェックは働きません。`RequestStateSecurity(bind_principal=...)` で独自のアイデンティティシグナルから供給すれば別です。トークンベリファイアーがどの要素を供給するにせよ、一貫して供給しなければなりません。あるリクエストではサブジェクトを含め、別のリクエストでは省くベリファイアーは、フローの途中でプリンシパルを変えてしまい、進行中のラウンドは拒否されます。 +* **元のリクエスト。** メソッド、ツール名またはプロンプト名(あるいはリソース URI)、そして引数のダイジェストです。別のツール、別の引数、別のメソッドに対してリプレイされたトークンは失敗します。 +* **尋ねた質問そのもの。** リゾルバーの回答はすべて、クライアントに表示されたレンダリング済みの質問に固定されます。最初に届いたラウンドでも、記録済みの回答を後で再利用するときでも同じです。メッセージの文言を変えたりスキーマを変えたりして再デプロイすると、サーバーは古い回答を消費する代わりに尋ね直します。同じ固定は逆向きにも効きます。メッセージは呼び出しごとのデータではなく、ツールの引数から導出してください。タイムスタンプやライブのレートから組み立てたメッセージはラウンドごとにレンダリングが変わるため、記録済みの回答はどれも古く見え、クライアントのラウンド上限で呼び出しが終わるまでサーバーは尋ね直し続けます。 + +これらはすべて SDK の仕事であり、自分の仕事ではありません。独自のコーデックを持ち込んでも、コーデックの仕事ではありません。 + +### キーのローテーション {#rotating-keys} + +`keys[0]` が新しい状態を封印し、リスト内のすべてのキーが検証に使われます。ダウンタイムなしのローテーションは 3 段階で、各段階を完全にロールアウトしてから次に進みます。 + +```python +RequestStateSecurity(keys=[OLD, NEW]) # 1: every instance learns to verify NEW; OLD still mints +RequestStateSecurity(keys=[NEW, OLD]) # 2: NEW mints; in-flight OLD state keeps verifying +RequestStateSecurity(keys=[NEW]) # 3: one ttl after phase 2 is fully out, retire OLD +``` + +発行側を先に昇格させないでください。まだ検証できないインスタンスがあるキーで発行すると、ロールアウトの途中で進行中のラウンドが落ちます。 + +キーのスコープは 1 つのサービスです。封印されたエンベロープはサーバーの名前もオーディエンスクレームとして載せているため、たまたまシークレットを共有している別のサービスが発行したトークンは、いずれにせよ拒否されます。このクレームの識別力は名前次第なので、明示的なポリシーを与えられたサーバーは本物の名前を持つか、`RequestStateSecurity(audience=...)` を設定しなければなりません。名前のないサーバーは構築時に例外を送出します。`audience=` は、あるサービスが別のサービスの発行した状態を受け入れなければならない、意図的なマルチサービス構成にも使えます。(設定なしのデフォルトは対象外です。そのキーはプロセスの外に出ることがないので、オーディエンスクレームが付け加えるものはありません。) + +### 独自の暗号を持ち込む {#bring-your-own-crypto} + +`RequestStateSecurity(codec=...)` は、`seal(bytes) -> str` と `unseal(str) -> bytes` を持ち、自分が発行していないトークンに対しては `InvalidRequestState` を送出するものなら何でも受け取ります。典型的な形は KMS に対するエンベロープ暗号化で、起動時にデータキーを一度アンラップし、トークンごとの暗号処理はローカルに保ちます。 + +```python title="server.py" hl_lines="12 26-27 34-35 38" +--8<-- "docs_src/mrtr/tutorial005.py" +``` + +TTL、プリンシパルの束縛、リクエストの束縛はコーデックの仕事**ではありません**。SDK はどのコーデックについても、`seal` の前にそれらをペイロードに刻み込み、`unseal` の後で再検証します。コーデックの義務は完全性(改ざんされていれば送出する)と、理想的には機密性だけです。 + +### 検証に失敗したとき {#when-verification-fails} + +受信側の失敗はすべて、改ざん、期限切れ、別のリクエストやプリンシパルに対するリプレイ、このサーバーが知らないキーでの封印のいずれであっても、同じ答えを受け取ります。 + +```json +{"code": -32602, "message": "Invalid or expired requestState"} +``` + +どの原因にも固定のメッセージが 1 つなので、どのチェックが失敗したかが通信上に漏れることはありません。本当の理由はサーバーのログに出ます。`tools/call`、`prompts/get`、`resources/read` に届く `requestState` はすべてチェックされ、状態を発行しないハンドラー宛てに届いたものも含まれます。実際に最も多い拒否は攻撃者ではありません。デフォルトのプロセスローカルなキーが、再起動前や別インスタンスからのリトライと出会うケースです。クライアントはフローをやり直し、それが問題になる場合の対策が `keys=[...]` です。 + +### 手組みの状態 {#hand-built-state} + +自分で設定する `request_state`(ツール、プロンプト、リソーステンプレートの関数から `InputRequiredResult` を返す場合)は、リゾルバーの状態と同じ仕組みで封印・検証され、コードの変更は一切不要です。平文を書き、平文を読むだけで、上記のすべての束縛が適用されます。 + +設定済みであっても SDK が代わりに固定できない唯一のものは、質問の同一性です。状態の中にある回答が、こちらで定義したどの質問に属するのかを SDK は知りません。質問をキーにして回答を保存するなら、独自の質問識別子を状態に含め、リトライ時にそれをチェックしてください。 + +低レベルの `Server` は何も付いてこない層です。`MCPServer` と違い、自分で境界を追加するまで何も封印されず、それまでは `request_state` が書いたとおりに通信路を渡ります。1 行のオプトインは **[低レベル Server](../advanced/low-level-server.md#the-other-handlers)** に示しています。 + +## 2026-07-28 の結果型 {#a-2026-07-28-result} + +`InputRequiredResult` はプロトコルバージョン **2026-07-28** にしか存在しません。インメモリの `Client(server)` はそれを代わりにネゴシエートしてくれます。通信路越しでは `mode="auto"` がそれを検出します。接続後、`client.protocol_version` で何が得られたかが分かります。 + +!!! warning + 2026 より前のセッションには `InputRequiredResult` を入れる場所がありません。`mode="legacy"` の接続でハンドラーからこれを返すと、ランナーはネゴシエートされたバージョンにシリアライズできず、クライアントには `-32603` *"Handler returned an invalid result"* エラーが返ります。両方の世代に対応するサーバーは、これを使う前に `ctx.protocol_version` をチェックしなければなりません。 + +!!! info + **URL モードのエリシテーション**は、2026 の接続ではまさにこの仕組みに載ります。`input_requests` のエントリーは、params が `ElicitRequestURLParams` である `ElicitRequest` です。ユーザーが帯域外のフローを終えると、クライアントが呼び出しをリトライします。同じループで、新しい API はありません。高レベルサーバー側の話は **[エリシテーション](elicitation.md)** にあります。 + +## まとめ {#recap} + +* 2026-07-28 では、呼び出しの途中で入力が必要なサーバーは `InputRequiredResult` を**返します**。クライアントへのリクエストを開くことは決してありません。 +* `input_requests` は必要としているものです。`request_state` はサーバーだけが読む不透明な再開トークンです。 +* `Client` がリトライループを回してくれます。`elicitation_callback` / `sampling_callback` / `list_roots_callback` を登録すれば、`call_tool` は素の `CallToolResult` を返します。`input_required_max_rounds`(デフォルト 10)が上限をかけます。 +* ラウンドを検査したり永続化したりするには、`client.session.call_tool(..., allow_input_required=True)` を使い、`while isinstance(result, InputRequiredResult)` ループを自分で握ります。 +* `@mcp.tool()` では、ユーザーに尋ねる依存関係がこの結果を作ってくれます(**[依存関係](dependencies.md)**)。**低レベル**の `Server` が手動の形式です。 +* プロンプトとリソースも参加します。`@mcp.prompt()` またはテンプレートの `@mcp.resource()` 関数は自分で `InputRequiredResult` を返し、リトライ時に `ctx.input_responses` を読みます。 +* `requestState` はクライアントが供給する入力として戻ってくるため、`MCPServer` はデフォルトで、リゾルバーの状態も手組みの状態も同様に、プロセスローカルなキーで封印します。マルチインスタンスのデプロイでは `RequestStateSecurity(keys=[...])`(またはカスタムコーデック)を渡し、どのインスタンスも兄弟インスタンスが発行したものを検証できるようにします。封印はすべてのトークンを時間枠と元のリクエストに束縛します。さらに、SDK が検証した認証をリクエストが載せている場合や、`bind_principal=` で独自のアイデンティティシグナルを供給している場合は、認証されたプリンシパルにも束縛します(**[`requestState` の保護](#protecting-requeststate)**)。 + +これがサーバー起点のサンプリングや、プッシュ型のバックチャネルの残りを置き換える仕組みです。**[非推奨の機能](../deprecated.md)** を参照してください。 diff --git a/i18n/ja/pages/handlers/progress.md b/i18n/ja/pages/handlers/progress.md new file mode 100644 index 0000000000..6d695d1d0b --- /dev/null +++ b/i18n/ja/pages/handlers/progress.md @@ -0,0 +1,112 @@ +--- +translation: + sections: [5315262fe26b33e1, 9d8e98840f1b78f0, 0284b215e85366c4, 8534d8dbb4053a70, 2966fac6fe697007] + tool: 1 +--- +# 進捗 {#progress} + +30 秒かかるツールが 30 秒間なにも言わなければ、壊れているように見えます。 + +**進捗通知**はそれを解決します。ツールはどこまで進んだかを報告し、クライアントはそれを使って何を描くかを決めます。プログレスバー、スピナー、ログの 1 行などです。 + +## ツールから報告する {#report-it-from-the-tool} + +**`Context`** パラメーターを受け取り、`report_progress` を呼び出してください。 + +```python title="server.py" hl_lines="8 11" +--8<-- "docs_src/progress/tutorial001.py" +``` + +引数は 3 つで、その意味は自分で決めます。 + +* `progress`:どこまで進んだか。仕様では、報告のたびに**増加する**ことが必須です。同じ値を繰り返したり、減らしたりしないでください。 +* `total`:全体でどれだけあるか(わかっている場合)。省略可能です。 +* `message`:「この」ステップについての、人が読める 1 行。省略可能です。 + +`ctx` は型ヒントによって注入され、モデルからは決して見えません。`import_catalog` の入力スキーマにあるプロパティは `urls` の 1 つだけです。**[Context](context.md)** のページはこのオブジェクトについて詳しく扱っています。進捗はそれが提供するものの 1 つです。 + +## クライアントで受け取る {#listen-for-it-from-the-client} + +クライアントは、`call_tool` に `progress_callback=` を渡すことで、**呼び出しごとに**オプトインします。 + +```python title="client.py" hl_lines="7 16" +import anyio +from mcp import Client + +from server import mcp + + +async def show(progress: float, total: float | None, message: str | None) -> None: + print(f"{message} ({progress}/{total})") + + +async def main() -> None: + async with Client(mcp) as client: + result = await client.call_tool( + "import_catalog", + {"urls": ["https://example.com/a.json", "https://example.com/b.json"]}, + progress_callback=show, + ) + print(result.structured_content) + + +anyio.run(main) +``` + +コールバックは `async` 関数で、サーバーが報告したものをそのまま受け取ります。`progress`、`total`、`message` です。 + +!!! info + `Client(mcp)` はサーバーオブジェクトにメモリ内で直接接続します。**[テスト](../get-started/testing.md)** のページの土台になっているのと同じクライアントです。`progress_callback` は、`Client` がどのトランスポートを使っていても同じパラメーターです。これから目にする「タイミング」はメモリ内接続のものです。メモリ内接続はコールバックをインラインで実行するため、すべての報告が `call_tool` が返る前に届きます。実際のトランスポートでは通知と結果の到着順は保証されず、遅いコールバックは `call_tool` が返ったあともまだ実行中のことがあります。 + +### 試してみる {#try-it} + +`client.py` を `server.py` の隣に置いて、実行してください。 + +```console +python client.py +``` + +```text +Imported https://example.com/a.json (1/2) +Imported https://example.com/b.json (2/2) +{'result': 'Imported 2 records.'} +``` + +サーバー側の `await ctx.report_progress(...)` はそれぞれ、クライアント側で順番どおりに `show` の 1 回の呼び出しになり、2 行とも `call_tool` が返る**前に**出力されました。進捗は結果にまとめられるのではなく、ツールがまだ動いている間にストリーミングされます。 + +!!! warning + `progress_callback` は `Client` ではなく、**呼び出し**に属します。そのためのコンストラクター引数はありません。呼び出しごとに必要なコールバックが違うからです。ある呼び出しはダウンロードバーを動かし、次の呼び出しはログの 1 行を出します。 + +!!! check + 今度は `progress_callback=show` を削除して、もう一度実行してください。 + + ```text + {'result': 'Imported 2 records.'} + ``` + + エラーも警告もなく、結果は同じです。`report_progress` は、**呼び出し側が進捗を要求しなかったときは何もしません**。ですから無条件に報告すればよく、誰かが聞いているかどうかを気にする必要はありません。 + +## 全体量がわからないとき {#when-you-dont-know-the-total} + +`total` は分母がわかっているときのためのものです。わからないことも多いでしょう。フィードを読み尽くしているとき、カーソルをたどっているとき、長さヘッダーのないものをダウンロードしているときなどです。 + +その場合は省略してください。 + +```python title="server.py" hl_lines="20" +--8<-- "docs_src/progress/tutorial002.py" +``` + +コールバックは `total=None` を受け取ります。クライアントはそれでも「活動中」であることは表示できます(「3 imported so far...」など)が、パーセンテージは表示できません。見栄えのよいバーのために全体量をでっち上げないでください。 + +!!! tip + `progress` は特定の何かを数える必要はありません。バイト、行、ページ。ユーザーにとってわかりやすい単位を選び、守れる `total` だけを約束してください。 + +## まとめ {#recap} + +* `Context` を受け取るツールならどこからでも `await ctx.report_progress(progress, total=None, message=None)` を呼べます。 +* クライアントは `call_tool` に `progress_callback=` を渡します。呼び出しごとであり、`Client` には渡しません。 +* コールバックは `async (progress, total, message) -> None` で、ツールがまだ実行中の間に呼ばれます。 +* 呼び出しにコールバックがなければ、`report_progress` は何もしません。無条件に報告してください。 +* わからないときは `total` を省略します。コールバックは `None` を受け取ります。 + +進捗は、実行中のツールが「ユーザー」に見せるものです。サーバーを運用する「自分」のために記録する行は、別のチャネルです。**[ロギング](logging.md)** を参照してください。 diff --git a/i18n/ja/pages/handlers/sampling-and-roots.md b/i18n/ja/pages/handlers/sampling-and-roots.md new file mode 100644 index 0000000000..7a230aaff7 --- /dev/null +++ b/i18n/ja/pages/handlers/sampling-and-roots.md @@ -0,0 +1,51 @@ +--- +translation: + sections: [5c82b20cbd65ded0, 9dc22632be79a533, 1fb8f452e990c456, 42666ab914ff0cb1, c4e0cb3667fd5ff9] + tool: 1 +--- +# サンプリングとルート {#sampling-and-roots} + +ハンドラーは、接続しているクライアントにさらに 2 つのことを要求できます。1 つはクライアント自身のモデルによる補完、つまり**サンプリング**です。もう 1 つはクライアントのワークスペースフォルダー、つまり**ルート**(roots)です。 + +どちらも、SDK が話すすべてのプロトコルバージョンで引き続き動作します。ただし、これらを前提に設計する前に、次の警告を読んでください。 + +!!! warning "2026-07-28 仕様で非推奨" + サンプリングとルートは `2026-07-28` で非推奨になりました([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2577))。引き続き完全に機能し、削除の対象になるまで少なくとも 12 か月は仕様に残りますが、新しい実装はこれらを土台にすべきではありません。推奨される移行先は次のとおりです。サンプリングの代わりに LLM プロバイダーの API と直接統合し、ルートの代わりにツールのパラメーター、リソース URI、またはサーバー設定でディレクトリを渡します。SDK 全体の一覧は **[非推奨の機能](../deprecated.md)** にあります。 + +## サンプリング:クライアントのモデルを借りる {#sampling-borrow-the-clients-model} + +リゾルバーが `Sample(...)` を返すと、ツールは補完結果を受け取ります。これは **[依存関係](dependencies.md)** で `Elicit` を動かしているのと同じ依存関係のしくみを通ります。 + +```python title="server.py" hl_lines="10-15 19" +--8<-- "docs_src/sampling_and_roots/tutorial001.py" +``` + +* `Sample(messages, max_tokens=...)` は `sampling/createMessage` のパラメーターをそのまま反映しています。注入される値はクライアントの `CreateMessageResult` です。`tools` または `tool_choice` を渡すと、代わりに `CreateMessageResultWithTools` になります。 +* クライアントは `sampling` ケイパビリティを宣言している必要があります(`tools` または `tool_choice` を渡す場合は `sampling.tools`)。宣言していない場合、クライアントが処理できないリクエストを送る代わりに、呼び出しは `-32021` のプロトコルエラーで失敗します。バックチャネル(back-channel)のない 2026 年より前のセッションでは、送る経路がそもそもないため、いつものバックチャネルなしのエラーで失敗します。 +* `2026-07-28` では、リクエストはマルチラウンドトリップ(multi-round-trip)のフローの中で配送されます(**[マルチラウンドトリップリクエスト](multi-round-trip.md)**)。`2025-11-25` では、クライアントへの単独のリクエストです。コードはどちらでも同じですが、マルチラウンドトリップのルールに注意してください。リクエストは再試行の各ラウンドでまったく同じ内容にならなければならないため、ツールの引数やその他の安定したデータだけから組み立ててください。 +* `include_context` には触れないでください。`"none"` 以外の値はそれ自体が非推奨で(SEP-2596)、ほとんどのクライアントが宣言しないケイパビリティを必要とします。 + +## ルート:これはどこに置くべきか {#roots-where-should-this-go} + +ルートは、サーバーが操作してよいとクライアントが示すフォルダーです。参考情報としての案内であり、アクセス制御のしくみではありません。リゾルバーは `ListRoots()` を返します。 + +```python title="server.py" hl_lines="10-11 15" +--8<-- "docs_src/sampling_and_roots/tutorial002.py" +``` + +* 注入される `ListRootsResult` は `Root` のリストを持ちます。それぞれが `file://` URI と、省略可能な表示名です。 +* 条件はサンプリングと同じです。`roots` ケイパビリティが宣言されていなければ、リクエストを送る代わりに呼び出しは `-32021` で失敗します。 + +通信路の反対側では、クライアントはすでに持っているコールバックで両方のリクエストに応答します。`sampling_callback` と `list_roots_callback` で、**[クライアントのコールバック](../client/callbacks.md)** で説明しています。 + +## 2025 年世代の接続では {#on-2025-era-connections} + +`ctx.session.create_message(...)` と `ctx.session.list_roots()` は、セッションを直接操作するコードのために今も存在します。これらはバックチャネルが存在する場所(2025 年世代の、ステートレスではない接続)でのみ動作し、呼び出すと非推奨の警告が出ます。上で紹介したリゾルバーのマーカーがサポートされる形です。ネゴシエートされたバージョンから配送方法を選び、警告も出しません。 + +## まとめ {#recap} + +* リゾルバーから `Sample(...)` または `ListRoots()` を返します。ツールは、ほかの依存関係と同じように `CreateMessageResult` または `ListRootsResult` を受け取ります。 +* クライアントは対応するケイパビリティを宣言しなければなりません。そうでなければ、リクエストは送られず、呼び出しは `-32021` で失敗します。 +* どちらの機能も `2026-07-28` で非推奨です。当面は完全に機能しますが、新しい設計には向きません。サンプリングよりプロバイダーの API を、ルートより明示的なパラメーターを選んでください。 + +遅いツールがどこまで進んだかを報告するには、**[進捗](progress.md)** を参照してください。 diff --git a/i18n/ja/pages/handlers/subscriptions.md b/i18n/ja/pages/handlers/subscriptions.md new file mode 100644 index 0000000000..9c567837bc --- /dev/null +++ b/i18n/ja/pages/handlers/subscriptions.md @@ -0,0 +1,152 @@ +--- +translation: + sections: [60a9de8a0bdaa531, 317bbe7e4355cdcc, a61d660c8029e04a, 8f7e82fcb88df8a9, b165db51249ff8ed, 266f56fb798068a4, 7c0e57030b622139, df18d7c2417a9883] + tool: 1 +--- +# サブスクリプション {#subscriptions} + +サーバーのカタログは固定ではありません。ツールは実行時に現れますし、リソース URI の背後にある内容も変わります。 + +クライアントがそれを知る手段が**サブスクリプション**です。クライアントは `subscriptions/listen` リクエストを 1 つ送り、そのリクエストへのレスポンス自体がストリームになります。開いたままになり、クライアントが求めた変更通知を運びます。 + +## ツールから発行する {#publish-it-from-the-tool} + +サーバー側でやることは 1 行だけです。変更を発行します。 + +```python title="server.py" hl_lines="20 32" +--8<-- "docs_src/subscriptions/tutorial001.py" +``` + +* `await ctx.notify_resource_updated("board://sprint")` は、その URI をサブスクライブした開いているストリームすべてに届きます。それ以外には届きません。 +* `await ctx.notify_tools_changed()` は、ツール一覧の変更を求めたストリームすべてに届きます。これを受け取ったクライアントは `tools/list` をもう一度呼び出し、今度は `sprint_report` が見えます。 +* 兄弟にあたるのが `notify_prompts_changed()` と `notify_resources_changed()` です。 +* サブスクライバーがいなければ、何も起こりません。アイドル状態のサーバーへの発行は no-op なので、誰かが聞いているかどうかを確認することはありません。何が変わったかを述べるだけです。 + +`MCPServer` は `subscriptions/listen` を代わりに処理します。通信上の義務(最初のフレームとしての確認応答、ストリームごとのフィルタリング、全フレームへのサブスクリプション ID の付与)は SDK の仕事です。 + +!!! check + 実際の通信では、フィルターに `board://sprint` を指定したストリームは、`complete_task` の実行後に次のようになります。 + + ```json + {"method": "notifications/subscriptions/acknowledged", + "params": {"notifications": {"resourceSubscriptions": ["board://sprint"]}, "_meta": {"io.modelcontextprotocol/subscriptionId": "listen-1"}}} + + {"method": "notifications/resources/updated", + "params": {"uri": "board://sprint", "_meta": {"io.modelcontextprotocol/subscriptionId": "listen-1"}}} + ``` + + 更新が運んでいないものに注目してください。ボードそのものです。どのフレームも `_meta` の下に listen リクエストの JSON-RPC id を持ち、その id がサブスクリプション ID です。これを発番するのはクライアントです。Python の `Client` は `"listen-1"` のような文字列を使いますが、他のクライアントは整数を使うこともあります。 + +## 求められたものだけ {#only-what-was-asked-for} + +フィルターは契約です。ツール一覧の変更と 1 つのリソース URI を要求したストリームは、その 2 種類だけを受け取り、他は何も受け取りません。プロンプトの変更を発行しても、そのストリームは沈黙したままです。 + +`MCPServer` はリソース URI を文字列として完全一致で照合するので、`board://sprint` を指定したストリームには `board://sprint/tasks/1` のことは何も届きません。仕様では、サブスクライブされた URI のサブリソースの変更をサーバーが報告することを認めています。`MCPServer` がそうすることはありませんが、クライアントはそれを想定して作られています。 + +このストリームには、当てはまらないことが 2 つあります。 + +* **リプレイログではありません。** 切断されたストリームは失われ、誰も接続していない間に発行されたイベントはキューに入りません。クライアントは listen し直して再取得します。 +* **2025 年世代の経路ではありません。** `resources/subscribe` を呼び出したクライアントには `ctx.session.send_resource_updated(uri)` で届けます。`notify_*` メソッドが届くのは `subscriptions/listen` のストリームだけです。 + +## 誰が監視できるかを決める {#deciding-who-may-watch} + +デフォルトでは、要求された種類と URI はすべて受け入れられます。つまり、どの呼び出し側も、発行されるどの URI でも監視できます。読み取りハンドラーが参照されることはありません。誰も読み取っていないからです。`files://{name}` ハンドラーなら拒否するはずの呼び出し側でも、`files://payroll.csv` のストリームを開き、それが変わったこと、そしていつ変わったかを知ることができます。内容を知ることは決してありませんし、何が存在するかを探ることもできません。未知の URI も受け入れられ、単に一度も発火しないだけだからです。狭いとはいえ現実に存在する隙なので、マルチテナントのサーバーからユーザーごとの URI を発行する前にゲートを設けてください。 + +ゲートはミドルウェアです。SDK が確認応答する前に `subscriptions/listen` リクエストを見て、呼び出し側が読み取れないものを求めたときに拒否します。 + +```python title="server.py" hl_lines="19-26 29" +--8<-- "docs_src/subscriptions/tutorial006.py" +``` + +* `ctx.params` は生のリクエストなので、ミドルウェアは自分でそれを `SubscriptionsListenRequestParams` として検証し、クライアントが求めたフィルターを読み取ります。 +* 拒否は `call_next(ctx)` の前に `MCPError` を送出することで行います。クライアントはそのエラーを受け取り、ストリームは得られず、接続はそのまま続きます。メッセージは URI を挙げない一様なものにして、拒否によってどの URI が保護されているかが確認されることのないようにしてください。 +* 1 つの `can_access(user, uri)` が両方の問いに答えます。リソースハンドラーは `resources/read` でそれを問い合わせ、ミドルウェアは `subscriptions/listen` で問い合わせます。テーブルをデータベースや RBAC システムに置き換えても、両者の足並みはそろったままです。 +* 判定はストリームの寿命のあいだ有効です。イベントごとの再チェックはないので、呼び出し側のアクセス権がストリームの途中で失効しうる場合(期限切れになるトークンなど)は、失効した時点でその呼び出し側の接続を終了してください。 + +ミドルウェアの契約の全体は、他に何をラップするか、なぜ暫定扱いなのかも含めて、**[ミドルウェア](../advanced/middleware.md)** にあります。 + +## クライアント側 {#the-client-end} + +そのストリームの反対側で、ボードを追いかけるクライアントがこちらです。 + +```python title="client.py" hl_lines="15" +--8<-- "docs_src/subscriptions/tutorial003.py" +``` + +`client.listen(...)` に入るとリクエストが送られ、サーバーの確認応答を待つので、ブロックが始まる時点でストリームは生きています。型付きのイベントはどれも再取得の合図であって、ペイロードではありません。契約の全体が 1 画面に収まっています。クライアント側のそれ以外のことは専用のページにあります。メインのフローと並行しての監視、ストリームの終了、listen のやり直しです。「クライアント」の下の **[サブスクリプション](../client/subscriptions.md)** を参照してください。 + +## 1 プロセスを超えてスケールする {#scaling-past-one-process} + +発行はハンドラーから開いているストリームへ、`SubscriptionBus` を経由して伝わります。デフォルトはインメモリで、1 つのプロセスとその中のすべてのストリームです。ロードバランサーの背後でレプリカを動かすまでは、これが正解です。レプリカを動かすと、クライアントのストリームは 1 つのレプリカに固定され、別のレプリカでの発行がそこに届かなければならないからです。 + +その継ぎ目は自分で実装します。pub/sub バックエンドの上に 2 つのメソッドを載せるだけです。 + +```python +from collections.abc import Callable + +from redis.asyncio import Redis + +from mcp.server.mcpserver import MCPServer +from mcp.server.subscriptions import ServerEvent # SubscriptionBus is a Protocol: no base class + + +class RedisSubscriptionBus: + def __init__(self, redis: Redis) -> None: + self._redis = redis + self._listeners: dict[object, Callable[[ServerEvent], None]] = {} + + async def publish(self, event: ServerEvent) -> None: + await self._redis.publish("mcp-events", encode(event)) # to every replica + + def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]: + token = object() + self._listeners[token] = listener + + def unsubscribe() -> None: + self._listeners.pop(token, None) + + return unsubscribe + + +mcp = MCPServer("Sprint Board", subscriptions=RedisSubscriptionBus(redis)) +``` + +`encode` は自分で用意します。各レプリカで到着したメッセージをデコードし、登録されたすべてのリスナーを呼び出すリーダータスクも同様です。リスナーは同期的で、例外を送出してはならず、サーバーのイベントループ上で動きます。 + +バスが運ぶのは型付きの `ServerEvent` 値(小さなデータクラスが 4 つ)であって、JSON-RPC ではありません。ID の付与、フィルタリング、ストリームのライフサイクルは SDK に残るので、バスの実装がプロトコルを壊すことはできません。できるのはプロセス間でイベントを移動させることだけです。 + +リクエストの外から発行するには、参照を手元に持てるようにバスを自分で組み立てます。何も渡さないと `MCPServer` は内部で 1 つ作りますが、それを公開しません。 + +```python +from mcp.server.subscriptions import InMemorySubscriptionBus, ToolsListChanged + +bus = InMemorySubscriptionBus() +mcp = MCPServer("Sprint Board", subscriptions=bus) + + +async def tools_reloaded() -> None: + await bus.publish(ToolsListChanged()) # from a lifespan task, a webhook, anywhere +``` + +## 低レベルでの組み立て {#the-low-level-composition} + +低レベルの `Server` には、あらかじめ配線されたものは何もありません。同じ部品を 3 行で組み立てます。 + +```python title="server.py" hl_lines="8-9 47" +--8<-- "docs_src/subscriptions/tutorial002.py" +``` + +* バスは自分のものなので、直接そこへ発行します。`await bus.publish(ResourceUpdated(uri=...))` です。ハンドラーから届く場所に置いてください。ここではモジュールスコープ、大きなアプリではライフスパンです。 +* `ListenHandler(bus)` は `MCPServer` が登録するのと同じハンドラーで、`on_subscriptions_listen=` は普通のハンドラースロットです。別のセマンティクスが欲しければそのスロットに独自の callable を入れてください。その場合、仕様上の義務は自分に移ります。まず確認応答し、すべてのフレームにサブスクリプション ID を付与し、フィルター外のものは何も配信しないことです。 +* `ListenHandler.close()` は開いているすべてのストリームを正常に終了させます。各ストリームは最後のフレームとして listen リクエストの result を受け取ります。これは、サーバーが意図的にサブスクリプションを終了したことを示す仕様上の方法です。ストリームのフラッシュが終わる前に戻るので、トランスポートを破棄する前に少し待ってください。これを呼ばなければ、ストリームはクライアントが切断したときに終わります。 + +## まとめ {#recap} + +* クライアントは `subscriptions/listen` リクエスト 1 つでオプトインし、そのレスポンスがストリームです。それを処理する機能は組み込まれています。 +* 発行は `ctx.notify_*` で行い、ID の付与、フィルタリング、ライフサイクルの処理は SDK が担当します。 +* イベントは合図であって、ペイロードではありません。両端とも再取得します。 +* クライアント側は `async with client.listen(...)` です。詳しくは「クライアント」の下の **[サブスクリプション](../client/subscriptions.md)** を参照してください。 +* 低レベルの `Server` では同じ部品を自分で組み立てます。バス、`ListenHandler(bus)`、`on_subscriptions_listen` スロットです。 +* スケールアウトとは、`SubscriptionBus`(メソッド 2 つ)を実装し、`MCPServer(subscriptions=...)` として渡すことです。 + +これらすべてを処理するサーバーを、レプリカ 1 つでも 20 でも動かす方法は、**[デプロイとスケール](../run/deploy.md)** にあります。 diff --git a/i18n/ja/pages/index.md b/i18n/ja/pages/index.md new file mode 100644 index 0000000000..554e24f66a --- /dev/null +++ b/i18n/ja/pages/index.md @@ -0,0 +1,97 @@ +--- +translation: + sections: [154c4309937b9f85, 3ad8fc6caa76a9b0, a07f3f5b151ab746, bf6e476b712930c0, cf0b1f13978c6623] + tool: 1 +--- +# MCP Python SDK {#mcp-python-sdk} + +!!! info "このドキュメントの対象は v2(現行の安定版リリース系列)" + v2 が初めての場合や v1 から移行する場合は、**[v2 の新機能](whats-new.md)**で変更点を 5 分で確認できます。破壊的変更は**[移行ガイド](migration.md)**がすべて扱っています。まだ v1.x を使っている場合、そのドキュメントは [v1.x のドキュメント](https://py.sdk.modelcontextprotocol.io/v1/)にあります。わかりにくい点や使いにくい点があれば、[教えてください](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml)。 + +**Model Context Protocol(MCP)**を使うと、アプリケーションは標準化された方法で LLM にコンテキストを提供できます。コンテキストを「提供する」という関心事を、LLM とのやり取りそのものから切り離せます。 + +これはその公式 Python SDK です。この SDK を使うと次のことができます。 + +* あらゆる MCP ホストにツール、リソース、プロンプトを公開する **MCP サーバーを構築**できます。 +* あらゆる MCP サーバーに接続する **MCP クライアントを構築**できます。 +* stdio、Streamable HTTP、SSE という標準のトランスポートすべてを扱えます。 + +## 要件 {#requirements} + +Python 3.10 以上が必要です。 + +## インストール {#installation} + +=== "uv" + + ```bash + uv add "mcp[cli]" + ``` + +=== "pip" + + ```bash + pip install "mcp[cli]" + ``` + +`[cli]` エクストラを付けると `mcp` コマンドが使えるようになります。開発には入れておくことをおすすめします。各依存関係の用途については[インストール](get-started/installation.md)を参照してください。 + +## 例 {#example} + +### 作成する {#create-it} + +`server.py` というファイルを作成します。 + +```python title="server.py" +--8<-- "docs_src/index/tutorial001.py" +``` + +これだけで完全な MCP サーバーです。 + +このサーバーは、**ツール**を 1 つ(`add`)と、テンプレート化された**リソース**を 1 つ(`greeting://{name}`)公開しています。 + +### 実行する {#run-it} + +```console +uv run mcp dev server.py +``` + +これでサーバーが起動し、[MCP Inspector](https://github.com/modelcontextprotocol/inspector) が開きます。サーバーをあれこれ触って試せる対話型の UI です。表示される URL を開いてください。 + +!!! note + Inspector は Node.js アプリなので、`mcp dev` を使うには `PATH` 上に `npx` が必要です。 + +### 試してみる {#try-it} + +Inspector で **Tools** を開き、`a=1`、`b=2` を指定して `add` を呼び出してください。 + +`3` が返ってきます。✨ + +Inspector はこのフォーム(`a` 用の必須の整数フィールドが 1 つ、`b` 用にもう 1 つ)を型ヒントから組み立てました。Claude も、そのほかのあらゆる MCP ホストも同じことをします。 + +今度は **Resources** を開き、`greeting://World` を読み取ってみてください。 + +```text +Hello, World! +``` + +### まとめ {#recap} + +ここで、**書かなかった**ものに改めて目を向けてみましょう。 + +* JSON Schema はありません。`a: int, b: int` がそのままスキーマです。 +* リクエストの解析も、シリアライズも、バリデーションのコードもありません。 +* プロトコルの処理は一切ありません。 + +書いたのは、型ヒントと docstring を付けた Python 関数 2 つだけです。残りは SDK が引き受けます。 + +## 次に読むもの {#where-to-go-next} + +* **[はじめに](get-started/index.md)**では、インストールから、テストも済んだ動作するサーバーの完成までを案内します。 +* MCP サーバーを「使う」側のアプリケーションを作るなら、**[クライアント](client/index.md)**から始めてください。 +* すでに FastAPI や Starlette のアプリがあるなら、**[既存のアプリに追加する](run/asgi.md)**でその中に MCP サーバーをマウントできます。 +* 特定のエラーメッセージを探しているなら、**[トラブルシューティング](troubleshooting.md)**がメッセージの文言そのままで引けるように整理されています。 +* v2 で何が変わったか気になるなら、**[v2 の新機能](whats-new.md)**が 5 分で読めるツアーです。 +* v1 から移行するなら、**[移行ガイド](migration.md)**から始めてください。 +* 正確なシグネチャを探しているなら、**[API リファレンス](api/mcp/index.md)**がソースから生成されています。 +* LLM と一緒に読んでいるなら、このドキュメントは [llms.txt](https://llmstxt.org/) 形式でも公開されています。[llms.txt](https://py.sdk.modelcontextprotocol.io/llms.txt) は各ページの索引で、[llms-full.txt](https://py.sdk.modelcontextprotocol.io/llms-full.txt) は全ページを 1 つのファイルに収めたものです。 diff --git a/i18n/ja/pages/protocol-versions.md b/i18n/ja/pages/protocol-versions.md new file mode 100644 index 0000000000..1e2a6b5161 --- /dev/null +++ b/i18n/ja/pages/protocol-versions.md @@ -0,0 +1,127 @@ +--- +translation: + sections: [478fd619e5f90ef8, aef094a00e44e248, bab8cbf3449fa7e9, df1809b15a58335b, 5f9d8c2336ed0239, f54974398e43ddef, b24443dd78584870] + tool: 1 +--- +# プロトコルバージョン {#protocol-versions} + +MCP には 2 つの世代があります。 + +2026-07-28 より前にリリースされたサーバーは、すべての接続を **`initialize` ハンドシェイク**で始めます。クライアントがバージョンを提案し、サーバーが対案を返し、クライアントが了承します。これらがすべて、最初の実質的なリクエストより前に行われます。**2026-07-28** のサーバーはこのハンドシェイクをやめました。クライアントが **`server/discover`** のプローブを 1 回送り、サーバーは必要なものすべてを 1 つの結果にまとめて返します。 + +`Client` が代わりにネゴシエーションしてくれるので、気にする必要はほとんどありません。このページで扱うのは、それを制御するたった 1 つのコンストラクター引数 `mode=` と、それを変更する 3 つの場面です。 + +## `mode="auto"` {#modeauto} + +```python title="client.py" hl_lines="14-15" +--8<-- "docs_src/protocol_versions/tutorial001.py" +``` + +`mode` を渡していないので、デフォルトの `"auto"` が使われます。`async with` に入ると、この SDK が話せる最新のバージョンで `server/discover` プローブを 1 回だけ送ります。その後は次のどちらかです。 + +* **新世代のサーバー**はこれに応答します。クライアントはその結果を採用します。ラウンドトリップ 1 回で完了です。 +* **古いサーバー**は `server/discover` を知らないので、エラーを返します。クライアントは従来の `initialize` ハンドシェイクにフォールバックし、そこでネゴシエートされた結果を受け入れます。 + +どちらの場合でも接続は確立され、どちらだったかは `client.protocol_version` でわかります。 + +```text +2026-07-28 +``` + +機能としてはこれだけです。`Client` は 1 つ、サーバーはどの世代でもよく、コードに分岐は要りません。 + +!!! info + `MCPServer` はインメモリ、stdio、Streamable HTTP のどのトランスポートでも `server/discover` に応答します。そのため、自分のサーバーが相手なら `auto` は必ず `2026-07-28` になります。フォールバックが発動するのは 2026 年より前の本物のサーバーが相手のときだけで、それはまさにフォールバックしてほしい場面です。 + +## `mode="legacy"` {#modelegacy} + +```python title="client.py" hl_lines="14" +--8<-- "docs_src/protocol_versions/tutorial002.py" +``` + +`mode="legacy"` はプローブを一切送りません。`initialize` ハンドシェイクを実行します。2026 年より前のクライアントが開くのと同じ接続です。 + +```text +2025-11-25 +``` + +同じサーバーです。このサーバーは `2026-07-28` を問題なく話せますが、尋ねないようクライアントに指示したのです。 + +これが必要になるのは、**プッシュ型**の機能を使うときです。 + +サーバー起点のリクエストとは、サーバーのほうから「呼び出し側」を呼ぶものです。`ctx.elicit(...)` がユーザーの前にフォームを出したり、サンプリングがツール呼び出しの途中でモデルに補完を求めたりします。このチャネルはハンドシェイク世代のセッションにしか存在しません。 + +2026-07-28 ではこのチャネルはなくなりました。サーバーは質問を結果として「返し」、クライアントは答えを添えて呼び出しをやり直します(**[マルチラウンドトリップ(multi-round-trip)リクエスト](handlers/multi-round-trip.md)**)。 + +`mode="auto"` でハンドシェイクになるのは、サーバーが古すぎてほかに手がないときだけです。`mode="legacy"` ならハンドシェイクが保証されます。`Client(...)` に `sampling_callback` を渡すとき、リクエストとして駆動させたい `elicitation_callback` を渡すとき、あるいは `message_handler` を渡すときは、いつでもこれを使ってください。それぞれについては **[クライアントのコールバック](client/callbacks.md)** で説明しています。 + +## バージョンのピン留め {#pinning-a-version} + +`mode` には新世代のプロトコルバージョン文字列も指定できます。現時点でその集合はちょうど `["2026-07-28"]` です。 + +```python title="client.py" hl_lines="14" +--8<-- "docs_src/protocol_versions/tutorial003.py" +``` + +ピン留めすると**何も**送りません。プローブもハンドシェイクもありません。クライアントはローカルで `2026-07-28` を採用し、`async with` から戻った瞬間に接続は使える状態です。 + +ピン留めは「呼び出し側」がする約束です。サーバーがそのバージョンを話せるとすでに知っている、という約束です。クライアントは確認しません。 + +!!! check + ピン留めはディスカバリーではありません。`client.server_info` を表示してみると、その代償がはっきり見えます。 + + ```text + None + ``` + + クライアントはサーバーに素性を尋ねていないので、`server_info` は `None` です。`client.server_capabilities` も同じで、どのケイパビリティも `None` です。ツール呼び出しは引き続き動きます(プロトコルはそのどれも必要としません)。しかし、`server_capabilities` を読んで何を提供するか決めるコードは動きません。 + + 次のセクションがその解決策です。 + +ピン留めできるのは新世代のバージョンだけです。ハンドシェイク世代の文字列は、I/O が発生する前の構築時点で拒否され、代わりに何を書くべきかはエラーが教えてくれます。 + +```text +ValueError: mode must be 'legacy', 'auto', or one of ['2026-07-28']; got '2025-06-18' ('2025-06-18' is a handshake-era version; use mode='legacy') +``` + +## `prior_discover` を使った再接続 {#reconnecting-with-prior_discover} + +プローブは安価ですが、それでも再接続のたびに支払うラウンドトリップであり、答えが変わることはほとんどありません。 + +なので取っておきましょう。`auto` で接続した後、`client.session.discover_result` にはサーバーが送った `DiscoverResult` がそのまま入っています。`supported_versions`、`capabilities`、`instructions`、そしてサーバーが結果の `_meta` に刻んだ識別情報です。次回はそれを `prior_discover=` として渡します。 + +```python title="client.py" hl_lines="15 17" +--8<-- "docs_src/protocol_versions/tutorial004.py" +``` + +```text +2026-07-28 +Bookshop +``` + +2 回目の接続はネゴシエーションのラウンドトリップが**ゼロ**で、それでも相手が誰なのかを正確に把握しています。これがピン留めモードの正しい使い方です。`mode=` でバージョンを指定し、`prior_discover=` で識別情報を与えます。✨ + +`DiscoverResult` は Pydantic モデルです。`saved.model_dump_json()` をファイルやキャッシュに保存し、次のプロセスで `DiscoverResult.model_validate_json(...)` を使って復元します。 + +!!! tip + `prior_discover=` が効くのは、`mode` がバージョンのピン留めのときだけです。`"auto"` ではクライアントはいずれにせよサーバーをプローブしますし、`"legacy"` では無視されます。 + +## 4 つのモード {#the-four-modes} + +| 書くコード | ネゴシエーションの通信 | 得られるもの | +| --- | --- | --- | +| `Client(target)` | `server/discover` プローブ 1 回。失敗したら `initialize` ハンドシェイク | 世代を問わず、両者が話せる最新のバージョン | +| `Client(target, mode="legacy")` | `initialize` ハンドシェイク | ハンドシェイク世代のバージョン。サーバー起点のリクエストが使える | +| `Client(target, mode="2026-07-28")` | なし | そのバージョンに固定。`server_info` は `None` | +| `Client(target, mode="2026-07-28", prior_discover=saved)` | なし | そのバージョンに固定。さらに前回保存した識別情報も付く | + +## まとめ {#recap} + +* MCP にはハンドシェイク世代(`2025-11-25` まで、`initialize` ハンドシェイク)と新世代(`2026-07-28`、`server/discover`)があります。`Client` がその橋渡しをします。 +* `mode="auto"` がデフォルトです。プローブし、だめならフォールバックします。ほかの 3 行のどれかに当てはまらない限り、そのままにしておいてください。 +* 「何になったのか」の答えは、いつでも `client.protocol_version` です。 +* `mode="legacy"` はハンドシェイクを強制します。サンプリング、プッシュ型のエリシテーション(elicitation)、`message_handler` といったサーバー起点のリクエストに必要なのはこれです。 +* バージョンのピン留め(`mode="2026-07-28"`)はネゴシエーションの通信を一切送りません。その代償として `client.server_info` が `None` になります。 +* `prior_discover=` がその代償を取り戻します。`client.session.discover_result` を保存し、それを使って再接続すれば、両方が手に入ります。 + +新世代の接続にはプッシュ用のチャネルがありません。では 2026 年世代のサーバーは、呼び出しの途中でどうやって質問するのでしょうか。結果として返すのです。詳しくは **[マルチラウンドトリップリクエスト](handlers/multi-round-trip.md)** を参照してください。 diff --git a/i18n/ja/pages/run/asgi.md b/i18n/ja/pages/run/asgi.md new file mode 100644 index 0000000000..f49b8864fc --- /dev/null +++ b/i18n/ja/pages/run/asgi.md @@ -0,0 +1,130 @@ +--- +translation: + sections: [1062ef792791488a, 4be2b831547184a9, 374b049e770385f2, b72f6947089e6de0, b172c9db7831bb31, 70b9ece244ca1b0c, cba78e052898c3f6, f06bdb541cb0b469, fb82d526320b7cc3] + tool: 1 +--- +# 既存のアプリに組み込む {#add-to-an-existing-app} + +`mcp.run("streamable-http")` は Web サーバーを起動してくれます。ただ、そうしたくない場合もあります。MCP サーバーがより大きな Web アプリケーションの一部である場合や、すでに ASGI のデプロイ環境がある場合です。 + +そのために、`mcp.streamable_http_app()` は **Starlette アプリケーション**を返します。 + +Starlette アプリは ASGI アプリなので、ASGI をホストできるもの(uvicorn、Hypercorn、別の Starlette、FastAPI)なら何でも MCP サーバーをホストできます。 + +## アプリ {#the-app} + +```python title="server.py" hl_lines="12" +--8<-- "docs_src/asgi/tutorial001.py" +``` + +`app` は普通の ASGI アプリケーションです。任意の ASGI サーバーに渡せます。 + +```console +uvicorn server:app +``` + +MCP エンドポイントは `/mcp` にあるので、クライアントは `http://127.0.0.1:8000/mcp` に接続します。 + +このアプリには最初から 2 つのものが備わっています。 + +* ルートが 1 つ(`/mcp`)。Streamable HTTP のエンドポイントです。 +* **ライフスパン**。`mcp.session_manager` を起動します。これは、稼働中のすべてのセッションのバックグラウンド処理を管理するオブジェクトです。 + +アプリを単体で動かす(`uvicorn server:app`)なら、どちらも意識することはありません。 + +!!! tip + `streamable_http_app()` は `mcp.run("streamable-http", ...)` と同じキーワード引数を受け取ります。ただし `port` は除きます。ポートはアプリを配信する側のものだからです。`host` は引き続き受け付けますが、ここでは何もバインドしません。実際に何を制御するのかは **[デプロイとスケール](deploy.md)** で説明しています。オプションそのものは **[サーバーの実行](index.md)** で扱っています。 + +`mcp.sse_app()` は、すでに置き換えられた SSE トランスポート向けに同じものを提供します。 + +## 指定しない限り localhost のみ {#localhost-only-until-you-say-otherwise} + +デフォルトでは、このアプリは localhost 宛てのリクエストに**だけ**応答します。`streamable_http_app()` は自分がどのホスト名の背後で配信されるのか知りようがないため、もっとも安全な許可リストで DNS リバインディング保護を有効にします。手元のマシンではまさにそれが正解です。実際のホスト名の背後にデプロイすると、`transport_security=` に実際に配信するホストの許可リストを渡すまで、**すべてのリクエストが `421 Misdirected Request` で拒否されます**。作成したものは何ひとつ参照すらされません。その許可リストをはじめ、動くアプリと実際のホスト名との間にあるものすべてについては、**[デプロイとスケール](deploy.md)** を参照してください。 + +## マウントする {#mounting-it} + +MCP サーバーがより大きなアプリケーションの「一部」になった瞬間、このアプリは `Mount` の中に置くことになります。そしてそうした瞬間、ライフスパンは自分で面倒を見るべきものになります。 + +```python title="server.py" hl_lines="18-21 25-26" +--8<-- "docs_src/asgi/tutorial002.py" +``` + +* `Mount("/", ...)` とデフォルトの `/mcp` パスの組み合わせで、エンドポイントは `/mcp` のままです。Starlette はルートを順に試し、`Mount("/")` は**すべての**パスにマッチします。そのため、自前のルートはリストの中でこれより「前」に置きます。後ろにあるものには到達できません。 +* `lifespan` 関数は、**ホスト**アプリが生きている間ずっと `mcp.session_manager.run()` に入った状態を保ちます。これは誰もが忘れる 1 行です。 +* `mcp.session_manager` は `streamable_http_app()` が呼ばれた「後」でしか存在しません。ルートをモジュールレベルで組み立て、マネージャーにはライフスパンの中でだけ触れているのはそのためです。 + +Starlette の `Host` ルートも同じように動きます。`Mount("/", ...)` を `Host("mcp.example.com", ...)` に差し替えれば、パスではなくホスト名でルーティングできます。ライフスパンのルールは変わりませんし、トランスポートセキュリティのルールも変わりません。`Host("mcp.example.com", ...)` ルートはそのホスト名宛てのリクエストしか受け取りませんが、トランスポート自身の Host 許可リスト(**[デプロイとスケール](deploy.md)**)は依然として先に実行されます。そこに `"mcp.example.com"` がなければ、このルートはすべてのリクエストに `421` で応答します。 + +!!! warning "ライフスパンはホストアプリのもの" + `streamable_http_app()` は、返す Starlette のライフスパンに `session_manager.run()` を組み込みますが、**マウントされたサブアプリケーションのライフスパンは決して実行されません**。アプリをマウントすると、その組み込みのライフスパンはデッドコードになります。ASGI スタックの最上位にあるアプリが、自身のライフスパンで `mcp.session_manager.run()` に入らなければなりません。 + +!!! check + `lifespan=lifespan` の行を削除してサーバーを起動してみてください。起動します。ルートも解決されます。そして `/mcp` への最初のリクエストが次のエラーで失敗します。 + + ```text + RuntimeError: Task group is not initialized. Make sure to use run(). + ``` + + セッションマネージャーを起動するのは、その `run()` だけです。 + +## 2 つのサーバー、1 つのアプリ {#two-servers-one-app} + +各 `MCPServer` は、それぞれ独自のセッションマネージャーを持つ独立したアプリです。好きなだけマウントし、すべてのマネージャーに 1 つのホストのライフスパンから入ってください。 + +```python title="server.py" hl_lines="27-30 35-36" +--8<-- "docs_src/asgi/tutorial003.py" +``` + +* `AsyncExitStack` が両方のマネージャーに入ります。2 つは一緒に起動し、逆順でシャットダウンします。 +* エンドポイントは `/notes/mcp` と `/tasks/mcp` です。マウントのプレフィックスにデフォルトのパスを足したものです。 + +## パスを変える {#changing-the-path} + +末尾の `/mcp` は `streamable_http_path` です。これを `"/"` にすると、マウントのプレフィックスがそのまま公開パス全体になります。 + +```python title="server.py" hl_lines="25" +--8<-- "docs_src/asgi/tutorial004.py" +``` + +これでクライアントは `/notes/mcp` ではなく `/notes` に接続します。 + +## ブラウザークライアント向けの CORS {#cors-for-browser-clients} + +ブラウザーベースのクライアントには 2 つの許可が必要です。MCP のリクエストヘッダーを**送る**許可と、MCP が返すヘッダーを**読む**許可です。どちらもホストアプリ側の CORS 設定であり、上で触れたトランスポートセキュリティの許可リストもそれと一致している必要があります。 + +```python title="server.py" hl_lines="27-30 33 35-49" +--8<-- "docs_src/asgi/tutorial005.py" +``` + +* `allow_headers` は誰もが忘れるほうの半分です。ブラウザーは MCP リクエストのたびに**プリフライト**を行います。`Content-Type: application/json` と `Mcp-*` リクエストヘッダーは CORS のセーフリストに載っていないためです。そして、プリフライトで許可されなかったヘッダーがあれば、ブラウザーはそのリクエストを決して送りません。(`allow_headers=["*"]` でも動きます。Starlette はプリフライトに対して、要求されたものをそのまま返すからです。) +* `expose_headers=["Mcp-Session-Id"]` は読む側の半分です。Streamable HTTP はセッション ID をこのレスポンスヘッダーで返しますが、ブラウザーは CORS で名前を指定して公開しない限り、レスポンスヘッダーを JavaScript から隠します。これがないと、クライアントは 2 回目のリクエストを決して送れません。 +* `allow_origins` は MCP ではなく自分で決めることです。厳密に指定し、上の `allowed_origins=` にも同じ内容を反映してください。CORS を強制するのはブラウザーですが、サーバー自身も `Origin` を検査します。トランスポートが信頼しないオリジンは、プリフライトが問題なく通った後でも `403` になります。 +* `allow_methods` には Streamable HTTP が使う 3 つのメソッドを列挙します。メッセージを送る `POST`、サーバーからクライアントへのストリームを開く `GET`、セッションを終える `DELETE` です。 + +## カスタムルート {#custom-routes} + +`@mcp.custom_route()` は、同じアプリ上に素の HTTP エンドポイントを登録します。デプロイされたサービスなら必ず必要になるものの、MCP とは何の関係もないもの、たとえばヘルスチェックや OAuth コールバックのためのものです。 + +```python title="server.py" hl_lines="15-17" +--8<-- "docs_src/asgi/tutorial006.py" +``` + +* ハンドラーは素の Starlette です。`Request` を受け取って `Response` を返す `async` 関数です。 +* `streamable_http_app()` はすべてのカスタムルートを拾います。`app.routes` は今や `/mcp` と `/health` です。 +* `GET /health` は `{"status": "ok"}` を返し、MCP はどこにも出てきません。 + +!!! warning + カスタムルートは**決して認証されません**。サーバーのほかの部分が認証されている場合でもです。これは意図的なものです。ヘルスチェックや OAuth コールバックは、トークンが 1 つも存在しない段階で到達できなければならないからです。非公開のものをカスタムルートの背後に置かないでください。 + +## まとめ {#recap} + +* `mcp.streamable_http_app()` は、`/mcp` というルートを 1 つ持つ Starlette アプリを返します。どの ASGI サーバーでも実行できます。 +* デフォルトでは、このアプリは localhost 宛てのリクエストにだけ応答し、実際のホスト名の背後では `transport_security=` に許可リストを渡すまですべてを `421` で拒否します。そこは **[デプロイとスケール](deploy.md)** の担当で、本番環境までの残りの道のりも同様です。 +* `Mount`(または `Host`)で、より大きな Starlette や FastAPI のアプリの中に置けます。 +* **マウントすると組み込みのライフスパンは無効になります。** ホストアプリのライフスパンで `mcp.session_manager.run()` に入らなければ、最初のリクエストが失敗します。 +* 1 つのアプリに複数のサーバーを載せるなら、マウントを複数用意し、すべてのセッションマネージャーに入るライフスパンを 1 つ用意します。 +* `streamable_http_path="/"` で、エンドポイントはマウントのプレフィックスそのものに移ります。 +* ブラウザークライアントには CORS が必要です。`Mcp-*` リクエストヘッダーのための `allow_headers` と、レスポンスのための `expose_headers=["Mcp-Session-Id"]` です。 +* `@mcp.custom_route()` は、認証なしの素の HTTP エンドポイントを `/mcp` の隣に追加します。 + +サーバーに実際の URL で到達できるようになったら、**[クライアント](../client/index.md)** はサーバーオブジェクトの代わりにその URL を使って接続します。 diff --git a/i18n/ja/pages/run/authorization.md b/i18n/ja/pages/run/authorization.md new file mode 100644 index 0000000000..a1795adab1 --- /dev/null +++ b/i18n/ja/pages/run/authorization.md @@ -0,0 +1,122 @@ +--- +translation: + sections: [d62c13457fc4a534, 80e73abaca6e0652, d1dc4c54cd00ec9c, 14ad3bc7904036bb, 5225f127bc1b9c77, fe1626fdd5aad1da, 4556cb7ea1a04a31] + tool: 1 +--- +# 認可 {#authorization} + +Streamable HTTP を使うと、MCP サーバーはごく普通の Web サービスになります。保護の仕方もほかの Web サービスと同じで、OAuth 2.1 のベアラートークンを使います。 + +OAuth の用語でいえば、サーバーは**リソースサーバー**です。誰かをサインインさせることはなく、トークンを発行することもありません。やることは 1 つだけです。各リクエストの `Authorization` ヘッダーを見て、そこに入っているトークンが有効かどうかを判断します。 + +このページはサーバー側の話です。認可サーバーを見つけてトークンを取得するクライアントについては、**[OAuth クライアント](../client/oauth-clients.md)**を参照してください。 + +## 3 つの当事者 {#the-three-parties} + +* **認可サーバー**はユーザーをサインインさせ、アクセストークンを発行します。これを自分で書くことはありません。ID プロバイダー(Auth0、Keycloak、Entra、自前のもの)がこれにあたります。 +* **リソースサーバー**は MCP サーバーです。リクエストごとにトークンを検証します。 +* **クライアント**は、サーバーがどの認可サーバーを信頼しているかを見つけ、そこからトークンを取得し、`Authorization: Bearer ` として送り返してきます。 + +三角形はこれで全部です。このページで扱うのはすべて真ん中の項目です。 + +## トークンベリファイアー {#a-token-verifier} + +有効なトークンがどんな形をしているかについて、SDK は何の前提も持ちません。**`TokenVerifier`** を実装して、こちらから伝えます。 + +```python title="server.py" hl_lines="12-14 19-24" +--8<-- "docs_src/authorization/tutorial001.py" +``` + +* `TokenVerifier` は非同期メソッドを 1 つだけ持つプロトコルです。`verify_token` は `Authorization` ヘッダーから取り出した生のトークンを受け取り、有効なら **`AccessToken`** を、無効なら `None` を返します。実装するものはほかにありません。 +* この例ではトークンをテーブルから引いています。実際のものは JWT の署名を検証するか、認可サーバーのトークンイントロスペクションエンドポイントを呼び出します。そのコードは自分で書きます。SDK はそれを呼び出すだけです。 +* `token_verifier=` と `auth=` は必ずセットで渡します。片方だけ渡すと、`MCPServer(...)` はリクエストを 1 つも処理しないうちに `ValueError` を送出します。 + +`AuthSettings` はリソースサーバーの表向きの顔です。 + +* `issuer_url`:トークンを発行する認可サーバー。 +* `resource_server_url`:この MCP エンドポイントの公開 URL。トークンが「どの」リソース向けかを示す名前であり、ディスカバリードキュメントが置かれる場所でもあります。 +* `required_scopes`:すべてのトークンがこれらをすべて持っている必要があります。 + +!!! tip + SDK リポジトリの `examples/servers/simple-auth/` には、実際の認可サーバーの [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662) エンドポイントを呼び出す `IntrospectionTokenVerifier` があります。本番用のベリファイアーの多くはこの形になります。 + +## HTTP で得られるもの {#what-you-get-over-http} + +認可は HTTP ヘッダーに乗るので、HTTP トランスポートにしか存在しません。デプロイするトランスポートで実行してください。`mcp.run(transport="streamable-http")` とすると `http://127.0.0.1:8000/mcp` で動きます。そのほかについては**[サーバーの実行](index.md)**を参照してください。これでアプリには 2 つのルートができます。 + +```text +/mcp +/.well-known/oauth-protected-resource/mcp +``` + +登録したのはツール 1 つです。2 つ目のルートは SDK が用意したものです。 + +### ディスカバリー {#discovery} + +この well-known パスに `GET` すると、`AuthSettings` からそのまま組み立てられた **[RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) Protected Resource Metadata** が返ります。 + +```json +{ + "resource": "http://127.0.0.1:8000/mcp", + "authorization_servers": ["https://auth.example.com/"], + "scopes_supported": ["notes:read"], + "bearer_methods_supported": ["header"] +} +``` + +このサーバーのことを何も知らないクライアントは、このドキュメントを手がかりに入口を見つけます。`authorization_servers` を読み、そこへトークンを取りに行きます。このドキュメントは 1 行も自分では書いていません。 + +!!! check + トークンなしで(あるいはベリファイアーが `None` を返したトークンで)`/mcp` を呼び出すと、リクエストは入口で止められます。 + + ```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"} + ``` + + 何もパースされず、ツールも実行されていません。そして `WWW-Authenticate` にある `resource_metadata` のポインターこそが、ディスカバリーを自動化する仕掛けです。401 -> メタデータドキュメント -> 認可サーバー -> トークン -> 再試行、という流れです。 + +!!! warning + これらはどれも `stdio` を保護しません。パイプには `Authorization` ヘッダーがないので、そこで `token_verifier` が参照されることはありません。`stdio` サーバーのセキュリティ境界は、それを起動したプロセスです。テストで使うインメモリの `Client(mcp)` も同じです。サーバーオブジェクトに直接接続し、認可を含む HTTP レイヤーを丸ごと飛ばします。 + +## 呼び出し側の ID {#the-callers-identity} + +どのハンドラーの中でも、**`get_access_token()`** は現在のリクエストに対してベリファイアーが返した `AccessToken` です。 + +```python title="server.py" hl_lines="4 32-35" +--8<-- "docs_src/authorization/tutorial002.py" +``` + +* ツール、リソース、プロンプトのどれでも動き、持ち回る必要のあるものはありません。認証ミドルウェアがリクエストごとにコンテキスト変数へ保存しています。 +* 返ってくるのは**ベリファイアーが組み立てたのと同じオブジェクト**です。`client_id`、`scopes`、`subject`、`expires_at`、そして追加で付けた `claims` が入っています。ツールごとのルールはここに掛けます。スコープを読んで、拒否するだけです。 +* 認証済みの HTTP リクエストの外では `None` を返します。インメモリと `stdio` では常に `None` です。 + +`Authorization: Bearer alice-token` を付けて `whoami` を呼び出すと、モデルは次のテキストを読みます。 + +```text +alice (scopes: notes:read) +``` + +## SDK がやらない半分 {#the-half-the-sdk-doesnt-do} + +SDK が提供するのはリソースサーバーの半分、つまり検証、告知、拒否です。ログインページも、同意画面も、トークンも提供しません。 + +3 つの当事者すべてが動く様子を見るには、SDK リポジトリの `examples/servers/simple-auth/`(小さな認可サーバーと、このページとまったく同じように構成されたリソースサーバー)を実行し、そこへ `examples/clients/simple-auth-client/` を向けてください。ディスカバリーからトークン取得までの一連の流れを追えます。 + +!!! info + コンストラクターにはもう 1 つ、`auth_server_provider=` という引数があり、完全な認可サーバーを MCP サーバーの中に埋め込みます。これは MCP の認可仕様が土台にしている AS/RS 分離より前からあるものです。新しく作るサーバーでは使うべきではありません。 + +認可サーバーは、ユーザーが同意画面をクリックして進む代わりに、企業の ID プロバイダーが署名したアサーションを受け付けることもできます。SDK はこのやり取りの両側をサポートしています。このグラントと、それを提示するクライアントについては、**[ID アサーション](../client/identity-assertion.md)**を参照してください。 + +## まとめ {#recap} + +* Streamable HTTP では、サーバーは OAuth 2.1 の**リソースサーバー**です。トークンを検証しますが、発行することはありません。 +* 統合の接点は `TokenVerifier` がすべてです。非同期メソッドが 1 つ、トークンを受け取り、`AccessToken | None` を返します。 +* `token_verifier=` と `auth=AuthSettings(issuer_url=..., resource_server_url=..., required_scopes=[...])` は必ずセットで渡します。 +* SDK は [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) Protected Resource Metadata を `/.well-known/oauth-protected-resource/...` で公開し、未認証のリクエストには、そこを指す `WWW-Authenticate` ヘッダー付きの 401 で応答します。ディスカバリーの仕組みはこれだけです。 +* どのハンドラーでも、`get_access_token()` を呼べば誰が呼び出しているかがわかります。 +* 認可は HTTP の関心事です。`stdio` とインメモリクライアントがそれを目にすることはありません。 + +クライアント側の半分(認可サーバーを見つけてトークンを取得してくれる部分)については、**[OAuth クライアント](../client/oauth-clients.md)**を参照してください。そして、ユーザーに尋ねる代わりに ID を「アサート」するクライアントについては、**[ID アサーション](../client/identity-assertion.md)**を参照してください。 diff --git a/i18n/ja/pages/run/deploy.md b/i18n/ja/pages/run/deploy.md new file mode 100644 index 0000000000..dd665f9e20 --- /dev/null +++ b/i18n/ja/pages/run/deploy.md @@ -0,0 +1,163 @@ +--- +translation: + sections: [28221886b198784f, f88ea1f1614f3a1d, ce926d686730b6d0, 3be24f8ad8bb5ab9, 3fad24032b2224ff, f25a7f860e579ecb, e758745df6fb7b0a] + tool: 1 +--- +# デプロイとスケール {#deploy-scale} + +サーバーは動いています。次に必要なのは本物のホスト名と、その背後で動く複数のワーカーです。 + +そのほとんどは MCP の管轄外です。ASGI サーバー、プロセスマネージャー、ロードバランサーは各自で用意します。このページにあるのは、本当に MCP の管轄に入るものだけを集めた短いリストです。すべてのデプロイの関門となる設定が 1 つと、「複数のワーカー」によって SDK の動作が変わる箇所が 2 つです。 + +## まず確認すべきこと:Host の許可リスト {#before-anything-else-the-host-allowlist} + +`streamable_http_app()` は、どのホスト名の背後で配信されるかを知ることができません。そのため、最も安全な答えである localhost を前提にします。`transport_security=` を指定しないと、アプリは **DNS リバインディング保護**を有効にし、`Host` ヘッダーが `127.0.0.1:`、`localhost:`、`[::1]:` のいずれかであるリクエストだけを受け付けます。`Origin` ヘッダーがある場合は、同じものの `http://` 形式でなければなりません。手元のマシンではこれがまさに正しい動作です。悪意のある Web ページが、`127.0.0.1` にリバインドした DNS 名を通じてローカルサーバーを操作するのを防ぎます。 + +本物のホスト名の背後にデプロイすると、同じデフォルトが、別途指示するまで**すべてのリクエスト**を拒否します。このチェックは MCP に関わるどんな処理よりも前に実行されるので、自分で作ったものは一切参照されません。 + +```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=` です。実際に配信するものを許可リストに入れます。 + +```python title="server.py" hl_lines="2 13-17" +--8<-- "docs_src/deploy/tutorial001.py" +``` + +* `allowed_hosts` のエントリは完全一致の文字列です。`"mcp.example.com"` はポートなしの `Host` ヘッダーに一致し、`"mcp.example.com:*"` は任意のポートに一致します。両方を並べてください。 +* `allowed_origins` が意味を持つのはブラウザーに対してだけです。ほかに `Origin` を送るものはないからです。これは **[既存のアプリに追加する](asgi.md)** で扱う CORS 設定と対になる、サーバー側の設定です。 +* すでに `Host` ヘッダーを制御しているリバースプロキシの背後では、チェックを無効にするのが実態に即した設定です。`TransportSecuritySettings(enable_dns_rebinding_protection=False)` とします。 +* localhost 以外の `host=`(たとえば `host="mcp.example.com"`)を渡しても、そのホスト名は許可リストに**入りません**。localhost のデフォルトが保護を有効にするのを止めるだけで、その結果あらゆる Host と Origin が受け付けられます。意図は `transport_security=` で明示してください。 + +!!! check + `transport_security=security` 引数を削除して、そのままアプリをデプロイしてみてください。起動し、`/mcp` にルーティングされ、そしてすべてのリクエスト(素の `curl` からのものも含めて)が次のように返ってきます。 + + ```text + HTTP/1.1 421 Misdirected Request + + Invalid Host header + ``` + + この文言はクライアント側では見つかりません。`421` は JSON-RPC エラーではなくプレーンテキストの HTTP レスポンスなので、MCP クライアントは汎用的なトランスポートエラーを送出します。気に入らなかったホスト名は**サーバー**のログに、警告として 1 行出るだけです。デプロイしたばかりのサーバーがすべての接続を拒否するなら、そうでないと証明されるまでは Host の許可リストが原因です。**[トラブルシューティング](../troubleshooting.md)** もここから始まります。 + +## ワーカーと、スティッキーにする必要があるのは誰か {#workers-and-who-has-to-be-sticky} + +ホスト名が応答するようになったら、その背後に複数のワーカーを置きます。そのための SDK の設定項目はありません。Starlette アプリは、どんな ASGI アプリとも同じ方法でスケールします。fork の仕方を知っているものにオブジェクトを渡すだけです。 + +```console +uvicorn server:app --workers 4 +``` + +プロセスは 4 つ、ソケットは 1 つです。そしてここで、すべてのデプロイが答えなければならない問いが出てきます。**リクエストは、直前のリクエストを受けたワーカーに届かなければならないのか。** + +**2026-07-28** プロトコルを話すクライアントについては、答えはノーです。モダンなリクエストは、自己完結した 1 つの POST です。その前に `initialize` のハンドシェイクはなく、レスポンスに `Mcp-Session-Id` は付かず、2 つ目のリクエストが「戻ってくる」先もありません。どのワーカーにルーティングしてもかまいません。 + +これは有効にするモードではありません。`stateless_http=True` がそう見えるかもしれませんが、トランスポートは `MCP-Protocol-Version` リクエストヘッダーでルーティングし、モダンなリクエストをモダンなハンドラーに渡して、**return します**。`stateless_http` を読む行は、その return の「後」にあります。2026-07-28 の経路でフラグが無視されるのではなく、そもそも到達しないのです。`stateless_http` は**レガシー**側の経路だけの設定項目であり、モダンな経路は構造上セッションを持ちません。 + +仕様バージョン 2025-11-25 以前のレガシークライアントについては、答えはそのフラグ次第です。 + +| クライアントのプロトコルバージョン | セッション | ロードバランサーがすべきこと | +| --- | --- | --- | +| **2026-07-28** | なし。`Mcp-Session-Id` は設定されません。 | 何もなし。どのワーカーもどのリクエストでも処理できます。 | +| **2025-11-25 以前**(デフォルト) | `Mcp-Session-Id`。1 つのワーカーのメモリに保持されます。 | **スティッキーセッション。**別のワーカーに届いた後続リクエストは `404` *"Session not found"* になります。 | +| **2025-11-25 以前**、`stateless_http=True` を指定 | なし。 | 何もなし。代償は、サーバーからクライアントへのバックチャネル(back-channel)、つまりサンプリング、プッシュ型のエリシテーション(elicitation)、`roots/list` と、再開可能性です。 | + +スティッキーセッションと、レガシー側の経路の代償については、専用のページ **[レガシークライアントへの対応](legacy-clients.md)** があります。2 つの世代そのものについては **[プロトコルバージョン](../protocol-versions.md)** を参照してください。ここで重要なのは答えの形です。2026-07-28 ではすでにステートレスであり、設定するものは何もありません。 + +このページの残りは、ステートレスになっても解決**しない** 2 つの事柄です。 + +## ワーカーをまたぐ `requestState` {#requeststate-across-workers} + +**[マルチラウンドトリップ(multi-round-trip)](../handlers/multi-round-trip.md)** のツールは、クライアントが取りに行かなければならないもの(確認、選択、資格情報)を必要とします。そのため答えの代わりに質問を返し、リトライで完了します。2 つのラウンドの間、クライアントはサーバーが発行した不透明な `request_state` トークンを保持します。リトライ時には、サーバーがそのトークンをもう一度開けなければなりません。 + +では、どの鍵で封印されているのでしょうか。デフォルトでは、サーバーが構築時に `os.urandom(32)` で生成した鍵です。`--workers 4` では、4 つのプロセスで 4 回構築されます。つまり 4 つの異なる鍵があり、どこにも書き出されず、共有もされず、再起動すれば消えます。 + +次は、何も設定していないサーバー上で、実行前に確認を取るツールです。 + +```python title="server.py" hl_lines="14 20" +--8<-- "docs_src/deploy/tutorial002.py" +``` + +1 回目のラウンドはワーカー A に届きます。ワーカー A は**自分の**鍵で `refund:120` を封印し、トークンを返します。クライアントは質問を人に提示し、承諾を得て、リトライします。リトライはまったく新しい HTTP リクエストです。 + +!!! check + そのリトライがワーカー B に届いたとします。B は自分が発行していないトークンの開封を試み、できず、ラウンド全体を拒否します。`refund` は呼び出されず、クライアントは JSON-RPC エラーを受け取ります。 + + ```json + { + "code": -32602, + "message": "Invalid or expired requestState", + "data": {"reason": "invalid_request_state"} + } + ``` + + このメッセージは**固定**です。期限切れでも、改ざんされていても、別の引数に対してリプレイされていても、あるいは(実際のデプロイで群を抜いて多い原因である)兄弟ワーカーが封印したものであっても、クライアントには毎回同じことが伝えられます。そのため、どのチェックに失敗したかは通信上には現れません。本当の理由は、サーバーのログに出る 1 行の `WARNING` です。 + + ```text + requestState rejected on tools/call: unknown key + ``` + + ワーカーが 1 つなら動いていたマルチラウンドトリップのツールが、2 つにしたとたん「ときどき」失敗し始めたなら、原因はこれです。両方のラウンドは依然として同じプロセスに届かなければならないので、ロードバランサーがそれらを引き離すのとちょうど同じ頻度で失敗します。 + +2 つのラウンドは独立した 2 つの HTTP リクエストであり、ごくありふれたことがいくつもそれらを引き離します。リクエスト単位で振り分けるプロキシ、間で切れた接続、デプロイや再起動、`request_state` を永続化してまったく別のプロセスから再開するクライアント(**[ループを自分で回す](../handlers/multi-round-trip.md#driving-the-loop-yourself)**)などです。どれも「別のワーカー」です。 + +解決策は引数 1 つです。ただし、それは **2 つ**の部分からなります。 + +```python title="server.py" hl_lines="1 12 14" +--8<-- "docs_src/deploy/tutorial003.py" +``` + +* **`keys=[...]`** は、誰もが見つけるほうの部分です。すべてのインスタンスに同じシークレット(少なくとも 32 バイト)を与えれば、どのインスタンスも兄弟が発行したものを開封できます。`keys[0]` が封印し、リストのすべての鍵が開封できます。これがローテーション用のリングであり、ダウンタイムなしで回す方法は **[鍵のローテーション](../handlers/multi-round-trip.md#rotating-keys)** にあります。 +* **サーバーの名前**は、ほとんど誰も見つけないほうの部分であり、鍵を共有してもインスタンスをまたぐリトライが失敗し続ける理由です。封印されたトークンはすべて、サーバーの `name` を **audience クレーム**として持ち、戻ってくるときに厳密にチェックされます。同じコードから構築された 2 つのインスタンスは同じ名前を持つので、これに気づくことはありません。名前を分けると(`MCPServer(f"billing-{POD}")` は可観測性の作法としてよさそうに見えます)、鍵を共有していようといまいと、インスタンスをまたぐリトライはすべて上とまったく同じように拒否されます。ログには `unknown key` の代わりに `audience` と出ますが、クライアントには違いがわかりません。 + +シークレットは一度だけ生成し、同じ値をすべてのインスタンスに渡します。次は、32 バイト未満を渡したときに SDK 自身のエラーメッセージが実行を促すコマンドです。 + +```console +python -c "import secrets; print(secrets.token_hex(32))" +``` + +!!! warning "鍵も同じ、そして名前も同じ" + マルチインスタンスのデプロイでは、両方を共有しなければなりません。インスタンスごとの名前が欠かせないのであれば、代わりにフリート全体に明示的な audience を 1 つ与えます。`RequestStateSecurity(keys=[...], audience="billing")` とすれば、どのインスタンスも、何という名前であっても `"billing"` で発行し、受け付けます。 + +封印に関するそれ以外のすべて、つまり何をバインドするか、ラウンドごとの `ttl`(デフォルトで 600 秒)、独自のコーデックの持ち込み、未設定のデフォルトが `stdio` ではまさに正しい理由については、**[`requestState` の保護](../handlers/multi-round-trip.md#protecting-requeststate)** を参照してください。このページの貢献は、2 項目のチェックリストに尽きます。「鍵も同じ、名前も同じ」、これだけです。 + +!!! info + `InputRequiredResult` を一度もタイプしたことがなくても、この経路には乗っています。パラメーターに `Resolve(...)`(**[依存関係](../handlers/dependencies.md)**)を使うツールはマルチラウンドトリップのツールであり、SDK がその `request_state` を代わりに発行して封印します。デフォルトの鍵も同じ、ワーカーをまたいだときの失敗も同じ、解決策も同じです。 + +## レプリカをまたぐ変更通知 {#change-notifications-across-replicas} + +クライアントの `subscriptions/listen` ストリームは 1 つの長寿命なレスポンスなので、その一生の間 1 つのレプリカに固定されます。**別の**レプリカで発行された `ctx.notify_resource_updated(...)` は、そこに届かなければなりません。 + +両者の継ぎ目が `SubscriptionBus` です。サーバーに与えたバスが、すべての publish の送り先であり、開いているすべてのストリームの待ち受け先です。ですから、すべてのレプリカに同じバスを渡します。 + +```python title="server.py" hl_lines="2 7 9" +--8<-- "docs_src/deploy/tutorial004.py" +``` + +ファンアウトは、ストリームがどのサーバーオブジェクトに紐づいているかを一切気にしません。1 つの `InMemorySubscriptionBus` を共有する 2 つのサーバーは、すでにこのように振る舞います。一方で listen ストリームを開き、もう一方で `edit_note` を実行すれば、ストリームにそれが届きます。このインメモリのバスがまたげるのは 1 つのプロセス内のサーバーオブジェクトだけなので、これはモデルであって、デプロイ方法ではありません。 + +* 本物のプロセスをまたぐ場合、**SDK には役に立つバスが同梱されていません。** `SubscriptionBus` は 2 つのメソッド(`publish` と `subscribe`)からなる `Protocol` であり、自前の pub/sub バックエンド(Redis、NATS、そのほかすでに運用しているもの)の上に実装して、`MCPServer(subscriptions=...)` として渡します。スケッチと契約は **[サブスクリプション](../handlers/subscriptions.md#scaling-past-one-process)** にあります。 +* バスが運ぶのは 4 種類の小さな型付きイベントであり、JSON-RPC ではありません。確認応答、フィルタリング、ストリームのライフサイクルは SDK に残るので、バスがプロトコルを壊すことはできません。できるのはプロセス間でイベントを運ぶことだけです。 +* ストリームは再開可能**ではなく**、イベントはリプレイ**されません**。レプリカを失えばそのストリームは切れ、クライアントは listen し直し、取得し直します。共有すべきイベントストアはなく、ほかに設定するものもありません。スケールアウトが本当に「同じことを増やすだけ」で済むのは、ここだけです。 + +## SDK が提供しないもの {#what-the-sdk-does-not-give-you} + +`MCPServer` はプロトコルの実装であり、アプリケーションサーバーではありません。次に探しに行くであろうデプロイ用の設定項目は、意図的に存在しません。 + +* **`workers=` はありません。** `mcp.run("streamable-http")` はちょうど 1 つの uvicorn プロセスを起動し、それ以上起動することは決してありません。マルチプロセスにするには、`streamable_http_app()` を、すでに ASGI のデプロイに使っているもの(`uvicorn --workers`、gunicorn、プラットフォームのプロセスマネージャー)に渡します。このページは意図的に、それらのどれのチュートリアルにもなっていません。それぞれのドキュメントのほうが、ここに写しを置くより優れているからです。 +* **ヘルスチェック用のルートはありません。** 答えは `@mcp.custom_route("/health", methods=["GET"])` に尽きます。そして、サーバーの残りが認証付きであっても、これは決して認証されません。これは liveness プローブには正しく、非公開のものには不適切です。**[既存のアプリに追加する](asgi.md#custom-routes)** に例があります。 +* **本番用の設定オブジェクトはありません。** タイムアウト、TLS、グレースフルシャットダウン、接続数の上限を書き込む場所は `MCPServer` のどこにもありません。どれもその仕事ではないからです。それらは ASGI サーバーの領分であり、そこで設定します。コンストラクターが実際に受け取る少数の設定については **[サーバーの実行](index.md)** で扱っています。 +* **同梱の `EventStore` はなく、2026-07-28 ではその使い道もありません。** 再開可能性はレガシーのステートフルな経路の機能です。モダンなやり取りは POST が 1 つ、レスポンスが 1 つで、再開するものは何もありません。 + +## まとめ {#recap} + +* デフォルトでは、このアプリは localhost 宛てのリクエストにだけ応答します。`transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])` が公開時の関門です。これを渡すまでは、本物のホスト名の背後ではすべてのリクエストが `421` になり、理由はサーバーのログにしか出ません。 +* 2026-07-28 ではセッションはなく、ロードバランサーがスティッキーにすべき対象もありません。`stateless_http=True` がレガシー専用の設定項目なのは、モダンなリクエストはこのフラグが読まれる前にルーティングされ、応答されるからです。 +* デフォルトの `requestState` の鍵は、プロセスごとに生成される `os.urandom(32)` です。別のワーカーに届いたマルチラウンドトリップのリトライは、`-32602` *"Invalid or expired requestState"* で失敗します。 +* 解決策は `RequestStateSecurity(keys=[...])` **と**、すべてのインスタンスで同じサーバー名にすることです。名前はトークンのデフォルトの audience クレームです。鍵も同じ、名前も同じ。 +* 変更通知は、共有された 1 つの `SubscriptionBus` を通じてレプリカをまたぎます。SDK の唯一の実装はプロセス内のものです。自前の pub/sub 上に 2 メソッドの `Protocol` を書くのは、自分の仕事です。 +* `workers=` も、ヘルスチェック用のルートも、本番用の設定オブジェクトもありません。ASGI サーバーは自分で用意してください。 + +本物のホスト名の前に必要なもう 1 つのものはトークンです。**[認可](authorization.md)** に進んでください。 diff --git a/i18n/ja/pages/run/index.md b/i18n/ja/pages/run/index.md new file mode 100644 index 0000000000..5a407e1121 --- /dev/null +++ b/i18n/ja/pages/run/index.md @@ -0,0 +1,150 @@ +--- +translation: + sections: [fea8d769ff9edeba, ce8e2ad42f29ef71, 0d705efb19cf99c2, 7a53ead3e704a7f0, 9adc400e8c88e854, 318893ad8e2e9924, 6b63ab96b34476c0] + tool: 1 +--- +# サーバーの実行 {#running-your-server} + +`mcp.run()` がサーバーを起動します。 + +決めることはただ 1 つ、**トランスポート**です。サーバーとクライアントの間でバイト列が実際にどうやり取りされるかを指します。 + +## トランスポートを選ぶ {#pick-a-transport} + +| トランスポート | 概要 | 使う場面 | +|---|---|---| +| `stdio` | ホストがファイルをサブプロセスとして起動し、その stdin と stdout を介して通信します。 | ローカルサーバー。デフォルトです。 | +| `streamable-http` | ポートで待ち受ける本物の HTTP サーバーです。 | デプロイするものすべて。 | +| `sse` | 古い HTTP トランスポートです。 | 使いません。 | + +!!! warning + SSE は 2025-03-26 のプロトコル改訂で Streamable HTTP に置き換えられました。 + `mcp.run(transport="sse")` は今も動作し、専用の `sse_path=` と `message_path=` オプションもありますが、まだ移行していないクライアントのために残されているだけです。新しいものをこの上に作らないでください。 + +## `mcp.run()` {#mcprun} + +```python title="server.py" hl_lines="12-13" +--8<-- "docs_src/run/tutorial001.py" +``` + +* `run()` は同期的です。サーバーが動いている間ずっとブロックします。 +* 引数がなければ、トランスポートは `stdio` です。 +* `if __name__ == "__main__":` の下に置くのは、サーバーを読み込むものすべて(`mcp dev`、`mcp run`、`mcp install`、テスト)がこのファイルを **import** するからです。このガードにより、import しただけでサーバーが起動してしまうのを防ぎます。 + +### stdio {#stdio} + +設定するものは何もありません。ホストがファイルを子プロセスとして起動し、その stdin にリクエストを書き込み、stdout からレスポンスを読み取ります。 + +自分で実行してみると、その結果がわかります。 + +```console +python server.py +``` + +何も表示されず、戻ってもきません。ホストが先に話しかけてくるのを stdin で待っているのです。 + +つまり stdout **が通信路そのもの**だということでもあります。サービス提供中、SDK は通信路をプライベートなディスクリプターに移し、stdout に「フラッシュされた」出力(継承した stdout に書き込むサブプロセスや、フラッシュされた `print()`)を stderr へ振り向けます。そこならストリームを壊すおそれがありません。サービス開始「前」に stdout にフラッシュされた出力(ラッパースクリプトの echo や、バッファリングなしの import 時の print)は、依然として通信路に流れ込みます。終了時にインタープリターが吐き出すまでバッファに溜まったままの `print()` も同様です。本当に必要な出力には `logging` モジュールが適切な手段です。そのハンドラーは各レコードを発生のたびに stderr へフラッシュします。詳しくは **[ロギング](../handlers/logging.md)** を参照してください。 + +### 試してみる {#try-it} + +```console +uv run mcp dev server.py +``` + +Inspector は本物のホストとまったく同じことをします。`server.py` をサブプロセスとして起動し、stdio で接続します。 + +ポートは指定していません。そもそもポートがないのです。 + +## Streamable HTTP {#streamable-http} + +同じサーバーを代わりにポートに載せるには、`run()` でトランスポート(とそのオプション)を指定します。 + +```python title="server.py" hl_lines="13" +--8<-- "docs_src/run/tutorial002.py" +``` + +この 1 行で Starlette アプリが組み立てられ、uvicorn で配信されます。クライアントは `http://127.0.0.1:3001/mcp` に接続します。 + +トランスポートごとに固有のキーワード引数があり、すべて `run()` に渡します。 + +* `host` / `port`:待ち受ける場所です。デフォルトは `127.0.0.1` と `8000` です。 +* `streamable_http_path`:MCP エンドポイントの場所です。デフォルトは `/mcp` です。 +* `json_response=True`:各 POST に SSE ストリームではなく単一の JSON ボディで応答します。このボディにはレスポンスしか入る余地がありません。そのため、リクエストの途中でクライアントを呼び返すツール(`ctx.elicit()` やサンプリング)は、この区間で `NoBackChannelError` を送出します。進行中の呼び出しに紐づく通知(`ctx.report_progress()` による進捗や呼び出しごとのログメッセージ)は破棄されますが、独立した `GET` ストリームは無関係な通知を引き続き運びます。 +* `stateless_http=True`:リクエストごとに新しいトランスポートを作り、セッションを追跡しません。 +* `max_request_body_size`:受け付ける POST ボディの最大サイズ(バイト単位)です。デフォルトは 4 MiB で、これより大きいリクエストはパースやセッション作成の前に HTTP 413 を受け取ります。正当な MCP メッセージがこのサイズを超える場合にだけ引き上げてください。 +* `event_store`、`retry_interval`、`transport_security`:再開可能性と DNS リバインディング保護です。localhost 以外の場所にデプロイするまでは後回しでかまいません。`transport_security` については **[デプロイとスケール](deploy.md)** で扱います。 + +!!! warning + トランスポートのオプションは `run()` に渡します。`MCPServer(...)` には**渡しません**。コンストラクターはサーバーが「何であるか」、つまり名前、バージョン、instructions を記述します。`run()` はそれをどう配信するかを記述します。逆にすると、MCP が関わる前に Python が答えを返します。 + + ```text + TypeError: MCPServer.__init__() got an unexpected keyword argument 'port' + ``` + +`run()` は近道です。それ以上のことが必要になった瞬間(既存のアプリの中にサーバーをマウントする、1 つのプロセスで 2 つのサーバーを動かす、ブラウザークライアント向けの CORS)、ASGI アプリを自分で組み立てて任意の ASGI ホストに渡すことになります。それが **[既存のアプリに追加する](asgi.md)** です。 + +## サーバー設定 {#server-settings} + +実行に関することのうち、いくつかはトランスポートとは無関係です。これらはコンストラクター引数です。 + +```python title="server.py" hl_lines="3" +--8<-- "docs_src/run/tutorial003.py" +``` + +* `log_level`:`MCPServer(...)` が構築された瞬間に `logging.basicConfig()` に渡されます。これは**ルート**ロガーを設定するため、SDK のロガーだけでなく自分のロガーのレベルも決まります。デフォルトは `"INFO"` です。 +* `debug`:HTTP トランスポートが組み立てる Starlette アプリに転送されます。デフォルトは `False` です。 + +どちらも `mcp.settings` に載り、実行時に読み出せます。 + +## `mcp` コマンド {#the-mcp-command} + +`[cli]` エクストラをインストールすると、これらすべてを包む小さなコマンドラインツールが入ります。 + +`mcp dev` はサーバーを **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` は組み立てる環境にパッケージを追加し、`--with-editable` は自分のパッケージをそこにインストールします。`PATH` に `npx` が必要です。Inspector は Node.js アプリだからです。 + +`mcp run` はファイルを import し、サーバーオブジェクト(モジュールレベルの `mcp`、`server`、`app` のいずれか)を見つけて、その `run()` を呼び出します。 + +```console +uv run mcp run server.py +uv run mcp run server.py:bookshop +``` + +`:` の接尾辞は、オブジェクトが `mcp`、`server`、`app` 以外の名前のときにそのオブジェクトを指定します。 + +ここでは `if __name__ == "__main__":` ブロックは決して実行されません。`mcp run` が自分で `run()` を呼び出し、転送するオプションは `--transport` だけです。 + +`mcp install` はサーバーを **Claude Desktop** に登録し、アプリが代わりに起動してくれるようにします。 + +```console +uv run mcp install server.py --name "Bookshop" +uv run mcp install server.py -v API_KEY=abc123 -f .env +``` + +`-v KEY=VALUE` と `-f .env` はそのエントリに環境変数を記録します。Claude Desktop はサーバーを独自のプロセスで起動します。シェルの環境はそこにはありません。 + +`mcp install` が知っているホストは Claude Desktop だけです。他のホスト(Claude Code、Cursor、VS Code)はそれぞれの設定ファイルに同じ起動コマンドを書きます。それぞれについては **[本物のホストに接続する](../get-started/real-host.md)** に載っています。 + +`mcp version` はインストールされている SDK のバージョンを表示します。 + +!!! tip + `mcp dev` と `mcp run` が理解するのは `MCPServer` だけです。低レベルの `Server` で組み立てる場合は、自分で実行します。**[低レベルの Server](../advanced/low-level-server.md)** を参照してください。 + +## まとめ {#recap} + +* **トランスポート**とは、バイト列がサーバーに届く方法です。ローカルのサブプロセスなら `stdio`、ポートなら `streamable-http` です。SSE は置き換えられました。 +* `mcp.run()` でトランスポートを選びます。引数がなければ `stdio` で、ブロックします。 +* トランスポートのオプション(`host`、`port`、`streamable_http_path` など)はすべて `run()` の引数であり、`MCPServer(...)` の引数ではありません。 +* `run()` は `if __name__ == "__main__":` の下に置いてください。サーバーを読み込むものはすべて、まずファイルを import します。 +* `log_level=` と `debug=` はコンストラクター引数で、`mcp.settings` に載ります。 +* Inspector には `mcp dev`、ファイルの実行には `mcp run`、Claude Desktop には `mcp install`、バージョンには `mcp version` です。 +* トランスポートによってサーバーが「何であるか」が変わることはありません。このページの 3 つのファイルはすべて、まったく同じツールを公開しています。 + +`run()` そのものが限界になるとき(すでに存在するアプリの中にサーバーを置く場合)は **[既存のアプリに追加する](asgi.md)** です。本物のホスト名と複数のワーカーは **[デプロイとスケール](deploy.md)** です。そして、一部のクライアントがまだ仕様バージョン 2025-11-25 以前にとどまっているなら、**[レガシークライアントへの対応](legacy-clients.md)** が朗報です。 diff --git a/i18n/ja/pages/run/legacy-clients.md b/i18n/ja/pages/run/legacy-clients.md new file mode 100644 index 0000000000..4de105d578 --- /dev/null +++ b/i18n/ja/pages/run/legacy-clients.md @@ -0,0 +1,116 @@ +--- +translation: + sections: [3d1663c18edc824c, d4fd37009a13f03d, af9f398a5a8b679a, 470c2dd144294d69, 8e45827e6d24e8c8, 91dfd0ce98ebb03c] + tool: 1 +--- +# レガシークライアントへの対応 {#serving-legacy-clients} + +MCP のプロトコルには 2 つの世代があります。仕様バージョン `2025-11-25` までの `initialize` ハンドシェイクの世代と、モダンな世代である `2026-07-28` です。この区分そのものについては **[プロトコルバージョン](../protocol-versions.md)** のページで説明しています。 + +このページが扱うのはその区分のサーバー側ですが、答えは 1 文で済みます。**すでにデプロイしている `streamable_http_app()` が両方に対応します。** + +SDK はすべてのリクエストを `MCP-Protocol-Version` ヘッダーで振り分けます。`2026-07-28` を指定したリクエストはモダンなハンドラーに渡ります。ハンドシェイク世代のバージョンを指定したリクエスト、またはヘッダーをまったく持たないリクエスト(2026 年より前のクライアントの `initialize` はこの形で届きます)は、それらのクライアントが期待するトランスポートに渡ります。`initialize` ハンドシェイクもセッションも、すべてそろっています。この振り分けはリクエストごとに、コードが動く前に、1 つのアプリ上で行われます。 + +つまり、レガシークライアントは「そのために」何かを作る対象ではありません。すでに書いたサーバー「に」接続してくる存在です。設定することは何もありません。 + +!!! note + 文字どおり、何もありません。`legacy=` オプションも、バージョンの許可リストも、ある世代を拒否したり無効にしたりする手段もありません。`streamable_http_app()` にも、`run()` にも、セッションマネージャーにもありません。両方の世代が常に有効です。そのシグネチャの中で世代ごとのスイッチに最も近いものは `stateless_http` で、このページの大半はその話です。 + +## 1 つのハンドラーで両方の世代 {#one-handler-both-eras} + +ユーザーに何かを尋ねる必要があるツールと、それを呼び出す両方の世代のクライアントを示します。 + +```python title="server.py" hl_lines="24 37-38" +--8<-- "docs_src/legacy_clients/tutorial001.py" +``` + +`reserve` には、モデルが渡してこなかったものが 1 つ必要です。何冊予約するかです。ツールはそれを `Annotated[..., Resolve(ask_quantity)]` で宣言します(詳しくは **[依存関係](../handlers/dependencies.md)** を参照してください)。`reserve` の中には、バージョンを指定する箇所も、ケイパビリティを確認する箇所も、分岐する箇所もありません。 + +2 つのクライアントは同じ `mcp` オブジェクトに対して**同時に**開かれています。`mode="legacy"` は `initialize` ハンドシェイクを実行します。2026 年より前のクライアントが開くのとまったく同じ接続です。もう一方はデフォルトのままで、`2026-07-28` になります。 + +```text +2025-11-25 {'result': "Reserved 2 of 'Dune'."} +2026-07-28 {'result': "Reserved 2 of 'Dune'."} +``` + +同じサーバー、同じハンドラー、同じ答えです。この機能はこれですべてです。 + +「どのように」実現されたかは、少し立ち止まって見る価値があります。2 つのクライアントは、まったく異なる 2 つの通信路を通じて同じ質問を受けたからです。`2026-07-28` の接続にはサーバーがリクエストを送るためのチャネルがないため、`Resolve` は質問をツール結果の中に入れて返し、クライアントは答えを添えて呼び出しを再試行しました(**[マルチラウンドトリップ(multi-round-trip)リクエスト](../handlers/multi-round-trip.md)** を参照してください)。`2025-11-25` の接続にはそうした仕組みはありません。そこでは `Resolve` が呼び出しの途中で実際の `elicitation/create` リクエストを送り、待機しました。どちらも自分で書いてはいません。`Resolve` が接続でネゴシエートされたバージョンを読み取って選びます。どちらの場合も、ツール本体が目にするのは `AcceptedElicitation` です。 + +!!! tip + この世代間の可搬性こそが、`Resolve` が土台とすべき API である理由です。古い兄弟分の `ctx.elicit()`(**[エリシテーション(elicitation)](../handlers/elicitation.md)**)は `elicitation/create` を送ることしかしないため、レガシー接続でしか動きません。`2026-07-28` の接続では呼び出しは失敗します。ツールがまだこれを使っている場合、直し方は上で見たとおりの方法であって、バージョンチェックではありません。 + +## レガシーセッションのコスト {#what-a-legacy-session-costs-you} + +振り分けにコストはかかりません。セッションにはかかります。 + +`2026-07-28` の接続は**セッションレス**です。各リクエストは独立しており、モダンなハンドラーが `Mcp-Session-Id` を発行することはありません。レガシー接続はその逆です。2026 年より前のクライアントが `initialize` を送った瞬間に、SDK は `Mcp-Session-Id` を発行し、レスポンスヘッダーで返し、その裏に生きたレコードを保持します。クライアントの後続リクエストが見つけられるように、ネゴシエートされたバージョン、開いているストリーム、セッションを駆動するバックグラウンドタスクが記録されます。 + +そのレコードは**プロセス内の単なる `dict`** です。分散セッションストアはなく、差し込む手段もありません。 + +ワーカーが 1 つなら、これは表に出ません。2 つになると、これが問題のすべてになります。`Mcp-Session-Id` を持つリクエストが、それを発行していないワーカーに届くと、その辞書には何も見つからず、返るのはツール結果ではなく `404`(`Session not found`)です。したがって、複数のワーカーを動かした瞬間から、**レガシークライアントにはスティッキールーティングが必要です**。セッション内のすべてのリクエストが、そのセッションを開始したプロセスに届かなければなりません。モダンなクライアントにはその必要はありません。スティッキーにすべきセッションがないからです。スティッキー性をはじめ、複数台で動かす際のあらゆることは **[デプロイとスケール](deploy.md)** で扱っています。 + +!!! warning + `event_store=` は解決策に見えますが、そうではありません。これは**再開可能性**(「同じ」セッションに再接続するクライアントに、取りこぼした SSE イベントを再送する機能)であって、セッションストアではありません。別のプロセスからセッションに到達できるようにはしません。 + +## 唯一のスイッチ:`stateless_http` {#the-one-knob-stateless_http} + +スティッキー性というコストを払いたくないなら、変更できるものがちょうど 1 つだけあります。 + +```python title="server.py" hl_lines="28" +--8<-- "docs_src/legacy_clients/tutorial002.py" +``` + +これはページ冒頭のサーバーに、キーワードを 1 つ加えただけのものです。`stateless_http=True` にすると、レガシー経路はリクエストごとの使い捨てセッションを作るようになります。`Mcp-Session-Id` は発行されず、リクエスト間で何も記憶されないため、どのワーカーもどのリクエストにも応答でき、ロードバランサーは好きなように振り分けられます。 + +これについては、何をするかよりも重要なことが 2 つあります。 + +**影響するのはレガシー経路だけです。** リクエストは、`stateless_http` が読まれる「前に」バージョンヘッダーで振り分けられるため、モダンな経路がこの設定を目にすることはありません。`2026-07-28` の接続はもともとセッションレスで、どちらの値でもまったく同じです。 + +**その経路では、サーバーからクライアントへの 2 つのチャネルが両方とも失われます。** 1 回の `POST` の間しか存在しないセッションには、サーバーがリクエストを送り込むストリームも、通知を送り込むスタンドアロンストリームもありません。サーバー起点のリクエストはすべて `NoBackChannelError` を送出します。`ctx.elicit()`、非推奨となったサンプリングとルート(roots)の呼び出し(**[非推奨の機能](../deprecated.md)**)、そしてもちろん、「レガシー」クライアントに質問する `Resolve` もです。通知はエラーにすらなりません。黙って捨てられます。 + +!!! note + `json_response=True` はそのスイッチではありませんが、「すべての」レガシーセッションで同じコストの半分を負います。1 つの JSON ボディで応答される `POST` にはリクエストスコープのチャネル用のストリームがないため、リクエスト途中の `ctx.elicit()` は同じ `NoBackChannelError` を送出し、そのリクエストに結び付いた通知は捨てられます。セッションのスタンドアロンストリームには影響しません。無関係な通知は引き続き届きます。 + +!!! check + あえて間違ったことをしてみましょう。`reserve` は、先ほど両方のクライアントに応答したそのツールです。これを `stateless_http=True` でデプロイし、同じ 2 つのクライアントを HTTP で接続して、それぞれから呼び出してください。 + + モダンなクライアントには引き続き `Reserved 2 of 'Dune'.` が返ります。モダンな経路は変わっていません。 + + レガシークライアントの呼び出しは、モデルが読める `is_error` の結果としては返ってきません。リクエスト全体が、トップレベルのプロトコルエラーとして失敗します。 + + ```text + mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests. + ``` + + `Resolve` は助けにはなりませんでした。`2025-11-25` の接続では `elicitation/create` を送る「しかない」のですが、そのために必要なチャネルこそが、`stateless_http=True` で手放したものです。世代間で可搬なコードは、バックチャネル(back-channel)を必要としないコードではありません。 + +つまりこれは本物のトレードオフで、レガシー経路にだけ存在します。**セッションありでスティッキーか、ステートレスで一方向か。** ツールがクライアントに呼び返すことが決してないなら、`stateless_http=True` にコストはないので採用すべきです。呼び返すなら、セッションを維持し、ルーティングもスティッキーのままにしてください。 + +## コードが実際に分岐する場所 {#where-your-code-actually-forks} + +ほぼどこにもありません。 + +ツール、リソース、プロンプト、構造化出力、進捗、エラー。これらはどれも、どの世代から呼ばれたかを気にしません。`initialize` ハンドシェイク、`Mcp-Session-Id`、スタンドアロンストリーム、セッションを終わらせる `DELETE`。これらはすべて SDK の管轄で、ハンドラーが目にすることはありません。対話的な入力は、世代によって通信路上で本当に違いが出る唯一の場所ですが、それを気にしなくて済むように `Resolve` があります。1 つのツールが両方に応答するのを見たばかりです。 + +残るのはちょうど 1 つ、**変更通知**です。2 つの世代が別々の経路で待ち受けているからです。 + +* `2026-07-28` のクライアントは `subscriptions/listen` ストリームを開き、サブスクリプションバスを読みます。`ctx.notify_resource_updated()`(および `notify_tools_changed()`、`notify_prompts_changed()`、`notify_resources_changed()`)はそこに、そしてそこに「だけ」発行します。詳しくは **[サブスクリプション](../handlers/subscriptions.md)** を参照してください。 +* レガシークライアントは、自分のセッションが開いたままにしているスタンドアロンストリームを読みます。`ctx.session.send_resource_updated()`(および `send_tool_list_changed()` などの仲間)は、リクエストを運んだ「接続」に書き込みます。レガシーセッションの場合、それはスタンドアロンストリームです。モダンな接続にはその置き場所がありません。HTTP ではそうしたチャネルがなく、stdio では 4 種類の変更通知は `subscriptions/listen` ストリームにしか乗らないため、モダンな接続ではその通知は黙って捨てられます。 + +HTTP では、どちらの呼び出しももう一方の世代のクライアントには届きません。全員に知らせるには、両方を呼び出します。 + +```python title="server.py" hl_lines="19-20" +--8<-- "docs_src/legacy_clients/tutorial003.py" +``` + +2 行だけで、`if` もバージョンチェックもなく、これで完了です。レガシークライアントが存在するためにハンドラーが違うことをする箇所は、これがすべてです。 + +## まとめ {#recap} + +* 1 つの `streamable_http_app()` が両方のプロトコル世代に対応します。SDK は各リクエストを `MCP-Protocol-Version` ヘッダーで振り分けます。設定するものはなく、探すべき世代のスイッチもありません。 +* レガシークライアントにはセッションというコストがかかります。裏に分散ストアを持たない、プロセス内の `Mcp-Session-Id` レコードです。ワーカーが複数なら**スティッキールーティング**が必要で、さもなければ間違ったワーカーが `404 Session not found` を返します。複数ワーカーについて詳しくは **[デプロイとスケール](deploy.md)** を参照してください。 +* `stateless_http=True` が唯一のスイッチで、**レガシー経路にだけ**効きます。レガシークライアントのロードバランシングが自由になる代わりに、その経路ではサーバーからクライアントへのチャネルが両方とも失われます。サーバー起点のリクエストは `NoBackChannelError` を送出し(クライアント側では `is_error` の結果ではなくトップレベルのエラーになります)、通知は捨てられます。 +* `2026-07-28` の接続はどちらにしてもセッションレスです。`stateless_http` がこれに影響することはありません。 +* ハンドラーのコードが世代で分岐するのはちょうど 1 か所、変更通知です。`ctx.notify_*` は `subscriptions/listen` のクライアントに届き、`ctx.session.send_*` はレガシーセッションに届きます。両方を呼び出してください。 +* それ以外はすべて(`Resolve` 経由でユーザーに入力を求めることも含めて)、仕組みのうえで世代間で可搬です。モダンなやり方で一度書けば済みます。 diff --git a/i18n/ja/pages/run/opentelemetry.md b/i18n/ja/pages/run/opentelemetry.md new file mode 100644 index 0000000000..4108ad79bb --- /dev/null +++ b/i18n/ja/pages/run/opentelemetry.md @@ -0,0 +1,85 @@ +--- +translation: + sections: [bc0227014724fa49, 15738c2f7fd67d86, a2c17bbe3f707e2f, d0d853376f162c06, b6368643fcc1c8d8, 902e33e17564a607] + tool: 1 +--- +# OpenTelemetry {#opentelemetry} + +サーバーはすでにトレースされています。何も追加する必要はありません。 + +作成するサーバーはどれも、処理するメッセージごとに [OpenTelemetry](https://opentelemetry.io/) のスパンを発行します。自分で書いたわけでも、インポートしたわけでもありません。`MCPServer(...)` を呼び出した瞬間から、そこにあります。 + +```python title="server.py" +--8<-- "docs_src/opentelemetry/tutorial001.py" +``` + +これで完全な、トレース済みのサーバーです。`search_books` を呼び出すと、そのためのスパンが作成されます。低レベルの `Server` でも同じです。トレースはどちらにも組み込まれています。 + +## 得られるもの {#what-you-get} + +受信したメッセージはすべて、メソッドとその対象にちなんだ名前の `SERVER` スパンになります。`search_books` に対する `tools/call` は `tools/call search_books` というスパンになり、単なる `tools/list` はそのまま `tools/list` です。 + +各スパンはいくつかの属性を持ちます。 + +* `mcp.method.name` と `mcp.protocol.version`。すべてのスパンに付きます。 +* `jsonrpc.request.id`。リクエストに付きます(通知には ID がありません)。 +* ハンドラーが例外を送出すると、スパンのステータスがエラーになります。`is_error=True` のツール結果でも同様です。 + +そしてツール呼び出しのトレースは非常によくある要望なので、`tools/call` のスパンは OpenTelemetry の [GenAI セマンティック規約](https://opentelemetry.io/docs/specs/semconv/gen-ai/)に従います。 + +* `gen_ai.operation.name`。`"execute_tool"` が設定されます。 +* `gen_ai.tool.name`。呼び出されるツールが設定されます。 + +同じ考え方で、`prompts/get` のスパンには `gen_ai.prompt.name` が付きます。一覧系のメソッドには名前を付ける対象がないため、`gen_ai.*` のキーは付きません。 + +!!! tip + トレース UI がツール呼び出しを他のエージェントと同じようにグループ化してくれるのは、これらの GenAI 属性のおかげです。このグループ化は追加のコードなしで手に入ります。 + +## 必要になるまでコストはかからない {#it-costs-nothing-until-you-want-it} + +「デフォルトで有効」が安心できるデフォルトである理由はここにあります。 + +SDK が依存しているのは、OpenTelemetry の軽量な半分である `opentelemetry-api` だけです。SDK もエクスポーターもインストールされていなければ、スパンの作成は何もしません。つまり、サーバーが今まさに発行しているスパンのコストはほぼゼロで、誰もそれを収集していません。 + +実際に「見たく」なった日には、残りの半分をインストールして、送り先を指定します。 + +```console +uv add opentelemetry-sdk opentelemetry-exporter-otlp +``` + +通常の OpenTelemetry のやり方でエクスポーターを設定すれば、SDK が静かに作成してきたスパンがすべて見えるようになります。サーバーのコードは変わりません。1 行たりともです。 + +!!! info + [Pydantic Logfire](https://logfire.pydantic.dev/) はそうしたバックエンドの 1 つで、設定まで代わりにやってくれます。`pip install logfire`、`logfire.configure()` とするだけで、MCP のスパンがライブビューに表示されます。OpenTelemetry の上に構築されているので、以下の内容もすべてそのまま当てはまります。 + +## 通信路をまたぐトレース {#traces-that-cross-the-wire} + +トレースが最も役に立つのは、リクエストをクライアントからサーバーまで、1 つにつながった図として追えるときです。 + +クライアントとサーバーの両方が SDK を使っていれば、そのつながりは自動的に得られます。クライアントが [W3C トレースコンテキスト](https://www.w3.org/TR/trace-context/)をリクエストに注入し、サーバーがそれを読み取るので、サーバーのスパンは同じトレース内でクライアントのスパンの下にネストされます。これが [SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414) で、特に何もしなくても使えます。 + +受信したメッセージにトレースコンテキストが含まれていない場合、たとえば SDK ではないクライアントからのリクエストでは、サーバーのスパンは孤立した新しいトレースを開始するのではなく、サーバー側ですでに現在のスパンになっているものを親にします。 + +## 無効にする {#turning-it-off} + +トレースはミドルウェアであり、サーバーのリストの先頭にあります。スパンをまったく発行しないサーバーが本当に必要なら、取り除いてください。 + +```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 + このインポートには先頭にアンダースコアが付いていますが、これは意図的なものです。このクラスは、[`Server.middleware`](../advanced/middleware.md) が暫定的であるのと同じく暫定的なものなので、インポートパスは変わるものと考えてください。これが必要になることはほとんどありません。エクスポーターをインストールしていなければスパンはコストがかからないので、通常は有効のままにしてエクスポーターをインストールしない、というのが答えです。 + +## まとめ {#recap} + +* すべての `MCPServer` とすべての低レベルの `Server` は、受信したメッセージごとに `SERVER` スパンを 1 つ、デフォルトで発行します。何も書く必要はありません。 +* スパンには `mcp.method.name` と `mcp.protocol.version` が付きます。`tools/call` と `prompts/get` にはさらに GenAI 属性も付くので、ツール呼び出しは他のエージェントと同じようにグループ化されます。 +* OpenTelemetry の SDK とエクスポーターをインストールするまでコストはかからず、インストールすればサーバーを変更することなく見えるようになります。 +* 両側が SDK を使っていれば、クライアントからサーバーへのトレースコンテキストは自動的に伝播します。 + +そもそもリクエストを実行してよいかどうかを決めるのが、**[認可](authorization.md)**です。 diff --git a/i18n/ja/pages/servers/completions.md b/i18n/ja/pages/servers/completions.md new file mode 100644 index 0000000000..f039e415fc --- /dev/null +++ b/i18n/ja/pages/servers/completions.md @@ -0,0 +1,122 @@ +--- +translation: + sections: [72f9c964769076dd, 9a2c14e10935b515, 235299eb78ab12d7, 8aee1e78c8237fb8, 9bd86acd4112138f, 55343cb7f250dc7b] + tool: 1 +--- +# 補完 {#completions} + +サーバーの上に UI を構築するクライアントは、ユーザーの入力に合わせて引数の値を自動補完したいと考えます。言語名、リポジトリ名、ファイルパスなどです。 + +**補完(completions)**は、サーバーがそうした候補を提供するための仕組みです。 + +## 補完する対象を用意する {#something-worth-completing} + +補完が適用されるのはちょうど 2 つだけです。**プロンプト**の引数と、**リソーステンプレート**のパラメーターです。そこで、まずはその両方を 1 つずつ持つサーバーから始めます。 + +```python title="server.py" hl_lines="6 12" +--8<-- "docs_src/completions/tutorial001.py" +``` + +ここにはまだ補完に関するものは何もありません。 + +* `review_code` は `language` を受け取ります。どの綴りが受け付けられるかをユーザーに推測させるべきではありません。 +* `github_repo` は `owner` と `repo` を受け取ります。両方とも自由入力のテキストボックスでは、使いにくいフォームになります。 + +## 補完ハンドラー {#the-completion-handler} + +`@mcp.completion()` でデコレートした関数を **1 つ**追加します。 + +```python title="server.py" hl_lines="21-29" +--8<-- "docs_src/completions/tutorial002.py" +``` + +* ハンドラーはサーバーごとに 1 つです。補完リクエストはすべてここに届くので、何が補完されているかに応じて分岐します。 +* `async def` でなければなりません。SDK がこれを await します。 +* 3 つの引数を受け取ります。 + * `ref`:「どの」プロンプトまたはリソーステンプレートかを表し、`PromptReference` か `ResourceTemplateReference` のどちらかです。見分けるには `isinstance` を使います。 + * `argument`:`argument.name` は補完対象の引数、`argument.value` はユーザーがこれまでに入力した文字列です。 + * `context`:すでに解決済みの引数です。今は無視してかまいません。 +* 戻り値は `Completion(values=[...])`、または提示するものがないときは `None` です。 + +!!! tip + `argument.value` はユーザーが入力したプレフィックスです。SDK はフィルタリングを**しません**。`values` に入れたものがそのまま UI に表示されます。`startswith` は自分で書きます。 + +### 試してみる {#try-it} + +**[テスト](../get-started/testing.md)**で紹介したインメモリの `Client` で動かします。`ref=PromptReference(name="review_code")` と `argument={"name": "language", "value": "py"}` を指定して `client.complete()` を呼び出します。 + +```python +result.completion.values # ['python'] +``` + +* `ref` はハンドラーが受け取るのと同じ参照型です。 +* `argument` は `name` と `value` のちょうど 2 つのキーを持つ、普通の dict です。 + +空の `value` を送ると、リスト全体が返ってきます。`lang.startswith("")` はどの言語に対しても真だからです。 + +```python +result.completion.values # ['go', 'javascript', 'python', 'rust', 'typescript'] +``` + +`code`(ハンドラーが認識しない引数)について尋ねると `None` が返り、SDK はそれを空のリストに変換します。 + +```python +result.completion.values # [] +``` + +`None` は「候補なし」という意味であり、決してエラーではありません。UI は普通のテキストボックスにフォールバックします。 + +## 宣言した覚えのないケイパビリティ {#a-capability-you-never-declared} + +ハンドラーを登録すること自体が宣言です。クライアントを接続して確認してみてください。 + +```python +client.server_capabilities.completions # CompletionsCapability() +``` + +`completions` をどこにも列挙していません。SDK がハンドラーを見つけて、代わりにケイパビリティを宣言したのです。「オプション」のケイパビリティはすべてこの仕組みで動きます。ハンドラーが宣言そのものです。(3 つのプリミティブはオプションではありません。`MCPServer` はハンドラーの有無にかかわらず常にそれらを宣言します。) + +!!! check + 最初の `server.py`(ハンドラーのないほう)に戻り、それでも問い合わせてみてください。呼び出しは JSON-RPC エラーで失敗します。 + + ```text + Method not found + ``` + + そして `client.server_capabilities.completions` は `None` です。これこそがケイパビリティの存在意義です。行儀のよいクライアントはこれを確認し、応答できないリクエストは最初から送りません。 + +## 依存する引数 {#dependent-arguments} + +`github://repos/{owner}/{repo}` にはパラメーターが 2 つあり、`repo` として意味のある値は、先にどの `owner` が選ばれたかによって変わります。 + +そのためにあるのが `context` です。ユーザーが**すでに解決した**引数を運びます。 + +```python title="server.py" hl_lines="8-11 34-38" +--8<-- "docs_src/completions/tutorial003.py" +``` + +* 新しい分岐は、テンプレートの `repo` パラメーターに対して実行されます。 +* `context.arguments` は、これまでに選ばれた値(ここでは `owner`)を持つ `dict[str, str] | None` です。 +* `owner` がまだなければ意味のある候補も出せないので、ハンドラーは `None` を返します。 + +クライアントは、解決済みの値を `context_arguments=` で送ります。今回の `ref` は `ResourceTemplateReference(uri="github://repos/{owner}/{repo}")` です。空の `value` で `repo` を要求し、`context_arguments={"owner": "modelcontextprotocol"}` を渡します。 + +```python +result.completion.values # ['python-sdk', 'typescript-sdk', 'inspector'] +``` + +`context_arguments=` を外すと、同じ呼び出しが `[]` を返します。ハンドラーは、オーナーがわかるまでどのリポジトリを提示すべきか知りようがありません。 + +!!! info + `Completion` は `total=` と `has_more=` も受け取ります。`values` がより長いリストの一部であるときに設定すると、UI が「ほか 200 件」のように表示できます。ほとんどのハンドラーには必要ありません。 + +## まとめ {#recap} + +* 補完は、**プロンプトの引数**と**リソーステンプレートのパラメーター**に対する候補です。それ以外にはありません。 +* `@mcp.completion()` で唯一のハンドラーを登録します。シグネチャは `async def (ref, argument, context) -> Completion | None` です。 +* `isinstance(ref, ...)` と `argument.name` で分岐します。`argument.value` によるフィルタリングは自分で行います。 +* `None` は空のリストになります。決してエラーではありません。 +* `context.arguments` は解決済みの値を保持し、クライアントはそれを `context_arguments=` として渡します。 +* `completions` ケイパビリティは、ハンドラーを登録した瞬間に現れます。ハンドラーがなければ、リクエストは `Method not found` になります。 + +候補が役立つのは、ユーザーがまだプロンプトやテンプレートを「入力している」あいだです。ツール呼び出しの「途中」でユーザーに質問したいなら、必要なのは**[エリシテーション(elicitation)](../handlers/elicitation.md)**です。ツールがテキスト以外に返せるものはすべて**[画像、音声、アイコン](media.md)**にまとめてあります。 diff --git a/i18n/ja/pages/servers/handling-errors.md b/i18n/ja/pages/servers/handling-errors.md new file mode 100644 index 0000000000..257d6a5b90 --- /dev/null +++ b/i18n/ja/pages/servers/handling-errors.md @@ -0,0 +1,131 @@ +--- +translation: + sections: [e33d441f12d50535, 7099694c603e0f5f, c1df4cf9673433e6, c9cd294541422e6e, 6cec073617bfd037, efa92b8f99e908c8, 6a22a29e27fb4601] + tool: 1 +--- +# エラーの処理 {#handling-errors} + +ツールの失敗には 2 通りあり、SDK はそれぞれをまったく違う形で扱います。 + +通常の例外を送出すると、**モデル**がそれを目にします。`MCPError` を送出すると、**プロトコル**がそれを目にします。 + +このページは、そのどちらを選ぶかについてです。 + +## モデルが直せるエラー {#an-error-the-model-can-fix} + +何かを検索するツールを用意し、その検索を空振りさせてみます。 + +```python title="server.py" hl_lines="11-12" +--8<-- "docs_src/handling_errors/tutorial001.py" +``` + +この 2 行に MCP らしいところは何もありません。`get_author` は、どんな Python 関数でもそうするように、ただの `ValueError` を送出しているだけです。 + +カタログにないタイトルで呼び出して、結果を見てみましょう。 + +```python +result.is_error # True +result.content # [TextContent(text="Error executing tool get_author: No book titled 'Nothing' in the catalog.")] +result.structured_content # None +``` + +* リクエストは**成功**しています。結果が返っており、呼び出し側では何も送出されていません。 +* `is_error` は `True` で、例外のメッセージ(ツール名が前に付きます)が `content` に入っています。まさにモデルが読む場所です。 +* `structured_content` は `None` です。失敗した呼び出しには、構造化すべき戻り値がありません。 + +これが**ツールエラー**で、ツールが送出する「あらゆる」例外のデフォルトの扱いです。そして、ほとんどの場合これこそが望む挙動です。 + +ツールを呼び出しているのはモデルです。引数を選んだのもモデルです。つまりツールエラーは会話の 1 ターンになります。モデルは「No book titled 'Nothing' in the catalog.」を読み、タイトルを推測し損ねたことに気づき、もっと良いタイトルで呼び直します。`raise` を 1 つ書いただけで、自己修正するエージェントが手に入りました。 + +!!! tip + ツールからエラーメッセージを `return` しないでください。返された文字列は `is_error=False` なので、モデルにとっても(そしてあらゆるクライアント UI にとっても)ツールは正常に動作し、その文字列が答えだったように見えます。`raise` してください。シグナルはこのフラグです。 + +## モデルが直せないエラー {#an-error-the-model-cannot-fix} + +今度は `ValueError` を `MCPError` に置き換えます。 + +```python title="server.py" hl_lines="1 3 14" +--8<-- "docs_src/handling_errors/tutorial002.py" +``` + +`MCPError` は SDK の**プロトコルエラー**です。ツールのラッパーが捕捉**しない**唯一の例外で、そのまま伝播し、`tools/call` リクエスト全体が結果ではなく JSON-RPC エラーで失敗します。 + +```json +{ + "code": -32602, + "message": "No book titled 'Nothing' in the catalog." +} +``` + +* **結果がありません**。`content` も `is_error` もなく、モデルが読めるものは何もありません。 +* 代わりに**ホスト**アプリケーションがエラーを受け取ります。ツールがそもそも存在しなかった場合と同じ扱いです。 +* `code`、`message`、`data` はそのまま届きます。`INVALID_PARAMS` は `-32602` です。`mcp.types` はこれを含む JSON-RPC のエラーコード(`INVALID_REQUEST`、`INTERNAL_ERROR` など)を定数としてエクスポートしているので、マジックナンバーを手で打つ必要はありません。 + +!!! check + 同じ検索、同じ空振りですが、今度はクライアント側で呼び出しが結果を返す代わりに「送出」します。 + + ```text + mcp.shared.exceptions.MCPError: No book titled 'Nothing' in the catalog. + ``` + + 最初のバージョンは、モデルが反応できる一文を渡しました。こちらは何も渡しません。`get_author` にとってこれは明らかに改悪であり、それが次のセクションの要点です。 + +## どちらを送出するか {#which-one-to-raise} + +2 つの経路は、2 つの異なる問いに答えるものです。 + +* 「実行」の失敗、つまりツールがやろうとしたことがうまくいかなかった場合は、**任意の例外を送出**します。呼び出しを選んだのはモデルなので、モデルがその結果を目にし、立て直す機会を得るべきです。綴りの間違ったタイトル、タイムアウトした上流の API、存在しない行。どれもツールエラーです。 +* 「リクエストそのもの」を拒否すべきときは **`MCPError` を送出**します。ツールが依存するケイパビリティをクライアントが持っていない、サーバーが誰にも応答できる状態にない、呼び出し側が必要な手順を飛ばした。どれもモデルが再試行しても直らないので、メッセージを渡しても得るものはありません。 + +決め手になる問いは 1 つです。**もっと賢いモデルならこれを避けられたか**。はい → 通常の例外。いいえ → `MCPError`。 + +この基準で見ると、`get_author` の 2 番目のバージョンは選択を誤っています。より良いタイトルで直るのですから、モデルはメッセージを見るべきでした。あれは仕組みを見せるためのもので、推奨するためのものではありません。 + +!!! info + `MCPError` は `from mcp import MCPError` でインポートでき、`code`、`message`、省略可能な `data` ペイロードを受け取ります。そこに入れた内容がそのままクライアントに届きます。SDK は送出された `MCPError` をサニタイズせず、そのまま転送します。 + +## 存在しないリソース {#a-resource-that-doesnt-exist} + +リソースも同じ線引きをします。そして、よくあるケースのために名前付きの例外を 1 つ用意しています。 + +```python title="server.py" hl_lines="2 13" +--8<-- "docs_src/handling_errors/tutorial003.py" +``` + +`books://{title}` は**テンプレート**です。「あらゆる」タイトルにマッチするので、「URI が正しい形式か」と「その本が存在するか」は別の問いであり、2 番目に答えられるのはこの関数だけです。 + +答えられないときは `ResourceNotFoundError` を送出してください。SDK はこれを、仕様が存在しないリソースに割り当てているプロトコルエラーに変換します。`-32602` で、リクエストされた URI が `data` に入るので、クライアントは「どの」読み取りが失敗したのかがわかります。 + +```json +{ + "code": -32602, + "message": "No book titled 'Nothing' in the catalog.", + "data": {"uri": "books://Nothing"} +} +``` + +ここには `is_error=True` のような中間的な結果がないことに注目してください。リソースの読み取りは、内容を返すか失敗するかのどちらかです。リソースにはプロトコルの経路しかありません。テンプレートをはじめ、リソースに関するその他すべては **[リソース](resources.md)** にあります。 + +## 送出する必要のないエラー {#errors-you-never-raise} + +不正な引数が関数に届くことはありません。 + +`get_author` に文字列ではない `title` を送ると、SDK は関数を呼び出す**前に**入力スキーマと照合して拒否します。その結果は同じ種類の `is_error=True` のツールエラーなので、モデルが読んで修正できます。**[ツール](tools.md)** では、`Field(le=50)` 制約で同じ拒否の様子を示しています。 + +つまり、書かなくてよい `raise` 文がまるごと一群あるということです。自分の型ヒントを改めて検証しないでください。 + +!!! info + このページの内容はすべて**クライアント**から見えるものです。テストを書くときに使うインメモリの `Client` にも、まったく同じものが見えます。`raise_exceptions=True` でもツールエラーがトレースバックに戻ることはありません。このフラグが作用できる時点では、例外はすでに `is_error=True` の結果になっています。結果に対してアサートしてください。このパターンは **[テスト](../get-started/testing.md)** で扱っています。 + +## まとめ {#recap} + +* ツールの中で**任意の例外**を送出する → 呼び出しは `is_error=True` を返し、メッセージが `content` に入ります。モデルはそれを読み、再試行できます。これがデフォルトです。 +* **`MCPError`** を送出する → 呼び出しそのものが JSON-RPC エラーで失敗します。モデルには何も見えず、ホストが対処します。`code`、`message`、`data` はそのまま残ります。 +* 決め手の問い:「もっと賢いモデルならこれを避けられたか」。はい → 例外。いいえ → `MCPError`。 +* リソースのハンドラーから `ResourceNotFoundError` を送出する → プロトコルの `-32602` になり、URI が `data` に入ります。 +* 不正な引数は関数が実行される前にスキーマと照合して拒否されます。そのために `raise` する必要はありません。 +* `from mcp import MCPError` でインポートします。エラーコードの定数は `mcp.types` から取得します。 + +エラーの処理はここまでです。サーバーが「公開する」ものはこれですべてです。すべてのハンドラーが実行中に読み取れるもの、そして実行中にクライアントに対して行えることは、次のセクション **[ハンドラーの中で](../handlers/index.md)** で扱います。 + +遭遇する可能性が最も高い SDK エラーの正確な文面、それぞれの意味、そしてそれぞれを一手で直す方法は **[トラブルシューティング](../troubleshooting.md)** にあります。 diff --git a/i18n/ja/pages/servers/index.md b/i18n/ja/pages/servers/index.md new file mode 100644 index 0000000000..ee267460cd --- /dev/null +++ b/i18n/ja/pages/servers/index.md @@ -0,0 +1,22 @@ +--- +translation: + sections: [09defc170a0da89d] + tool: 1 +--- +# サーバー {#servers} + +`MCPServer` は、接続したクライアントに 3 つのプリミティブを公開します。それぞれの違いは、誰がそれを使うと決めるかにあります。 + +* **[ツール](tools.md)**は、モデルが選んで呼び出すアクションです。ほとんどの人がまず読みたいのはこのページです。その対になるリファレンスが**[構造化出力](structured-output.md)**で、ツールが返すものの形に関することはすべてそこにまとまっています。 +* **[リソース](resources.md)**は、アプリケーションが選んで読む、読み取り専用のデータです。その対になるリファレンスが**[URI テンプレート](uri-templates.md)**で、アドレス指定の構文のすべてとパスの安全性に関するルールを扱います。 +* **[プロンプト](prompts.md)**は、人がメニューやスラッシュコマンドから名前で呼び出すメッセージテンプレートです。 + +この 3 つのプリミティブの周辺に、サーバーが宣言するそのほかの要素があります。 + +* **[補完](completions.md)**は、プロンプトやリソーステンプレートの引数に対する、サーバー側のオートコンプリートです。 +* **[画像、音声、アイコン](media.md)**では、ツールがテキスト以外に返せるものすべてと、クライアントがサーバーの横に表示するアイコンを扱います。 +* **[エラーの処理](handling-errors.md)**では、モデルにとって回復可能なエラーと、モデルに決して見せてはならないエラーの違いを説明します。 + +ここにあるページはどれも単独で読めます。必要なページに直接進んでください。まだサーバーを作ったことがなければ、代わりに**[最初のステップ](../get-started/first-steps.md)**から始めてください。 + +登録した関数の内側で起きること(`Context`、依存性の注入、呼び出しの途中でユーザーに追加の入力を求めること)を扱うのが、次のセクション「**[ハンドラーの中で](../handlers/index.md)**」です。 diff --git a/i18n/ja/pages/servers/media.md b/i18n/ja/pages/servers/media.md new file mode 100644 index 0000000000..74d7ac21e0 --- /dev/null +++ b/i18n/ja/pages/servers/media.md @@ -0,0 +1,117 @@ +--- +translation: + sections: [496394d24d221bf1, 4ceb4591180dc6c3, 0fd63e4682d02e0c, 969ede0bd3686a16, 043f526230dd243d, 6ee3e9bcfd24047a] + tool: 1 +--- +# メディア {#media} + +ツールが返せるのはテキストだけではありません。 + +SDK には、バイナリの結果を扱うヘルパーが 2 つ(**`Image`** と **`Audio`**)と、サーバー、ツール、リソース、プロンプトにクライアントの UI 上での「顔」を与える **`Icon`** 型が用意されています。 + +## 画像を返す {#returning-an-image} + +戻り値の型を `Image` と注釈し、ファイルを指定して返します。 + +```python title="server.py" hl_lines="8 12 14" +--8<-- "docs_src/media/tutorial001.py" +``` + +* `Image` は `path`(読み込むファイル)か `data`(生のバイト列)のどちらか一方だけを取ります。 +* クライアントに見える MIME タイプは拡張子から推測されます。`logo.png` は `image/png` として通知されます。 +* ロゴだからといって特別なことは何もありません。`server.py` の隣にある PNG なら何でも使えます。コードが描画したグラフでも、図でも、写真でもかまいません。 + +`Image` は SDK の便利機能であって、プロトコルの型ではありません。実際に送受信されるときには、戻り値は **`ImageContent`** ブロック(ファイルのバイト列を base64 エンコードしたものと MIME タイプ)になります。 + +```python +result.content # [ImageContent(type="image", data="iVBORw0KGgoAAAANSUhEUg...", mime_type="image/png")] +result.structured_content # None +``` + +注目すべき点が 2 つあります。 + +* `data` は base64 です。バイト列には一切触れていません。ファイルを読み込んでエンコードしたのは SDK です。 +* `structured_content` は `None` です。`Image` はモデルが見るためのコンテンツであり、アプリケーションが解析するためのデータではありません。出力スキーマはありません。(戻り値の注釈そのものがスキーマになる **[構造化出力](structured-output.md)** と比べてみてください。) + +!!! info + `ImageContent` と `AudioContent` は `mcp.types` にあり、単純な `str` の結果が変換される `TextContent` のすぐ隣に並んでいます(**[ツール](tools.md)**)。ツールの結果はコンテンツブロックのリストです。`Image` と `Audio` は、2 種類のバイナリブロックを作る最短の方法です。 + +### 試してみる {#try-it} + +任意の PNG を `server.py` の隣に置いて `logo.png` という名前にし、次を実行してください。 + +```console +uv run mcp dev server.py +``` + +**Tools** タブを開いて `logo` を呼び出します。結果は文字列ではありません。`image` コンテンツブロックであり、Inspector が画像を描画します。ディスク上のファイルから画面上のピクセルまでの間は、すべて SDK が処理しました。 + +## 音声を返す {#returning-audio} + +`Audio` も同じ形です。`logo.png` はそのままにして、任意の WAV を `chime.wav` として隣に置いてください。 + +```python title="server.py" hl_lines="18-21" +--8<-- "docs_src/media/tutorial002.py" +``` + +結果は **`AudioContent`** ブロックです。 + +```python +result.content # [AudioContent(type="audio", data="UklGR...", mime_type="audio/wav")] +result.structured_content # None +``` + +仕組みは同じです。ディスク上のファイルが入力で、base64 と MIME タイプが出力、出力スキーマはありません。 + +## バイト列かファイルか {#bytes-or-a-file} + +どちらのヘルパーも `path=` の代わりに `data=`(生のバイト列)を受け付けます。これは、そもそもファイルとして存在したことのないバイト列のためのモードです。データベースのカラム、HTTP のレスポンス、Pillow が描いたばかりの画像などです。 + +```python title="server.py" hl_lines="14 15" +--8<-- "docs_src/media/tutorial003.py" +``` + +`path=` なら宣言するものは何もありません。ファイルは結果を組み立てるときに読み込まれ、MIME タイプは拡張子から推測されます。 + +* `Image`:`.png`、`.jpg`、`.jpeg`、`.gif`、`.webp`。 +* `Audio`:`.wav`、`.mp3`、`.ogg`、`.flac`、`.aac`、`.m4a`。 + +認識できない拡張子は `application/octet-stream` にフォールバックします。 + +!!! check + `data=` の場合はファイル名がないので、推測する材料がありません。`format=` を忘れると、SDK はデフォルトにフォールバックします。画像なら `image/png`、音声なら `audio/wav` です。この方法で MP3 のバイト列から `Audio` を作ると、クライアントには `mime_type="audio/wav"` と伝えられ、それを忠実に信じてデコードに失敗します。`data=` を渡すときは `format=` も渡してください。 + +## アイコン {#icons} + +`Icon` はメタデータであって、コンテンツではありません。画像そのものは運ばず、URI で画像を指し示します。クライアントはそれを取得して、サーバーの名前やツール、リソース、プロンプトの横に表示することがあります。 + +```python title="server.py" hl_lines="4-5 7 10 16" +--8<-- "docs_src/media/tutorial004.py" +``` + +* `src` はクライアントが解決できる URI です。`https:` か、追加の取得なしでアイコンを埋め込みたければ `data:` URI です。 +* `mime_type` と `sizes`(`"48x48"`、スケーラブルな形式なら `"any"`)を指定すると、複数のアイコンを提供したときにクライアントが適切なものを選べます。 +* `theme="light"` または `theme="dark"` で、アイコンを一方の配色向けとして印を付けます。 + +同じ `icons=[...]` キーワードは `MCPServer(...)`、`@mcp.tool()`、`@mcp.resource()`、`@mcp.prompt()` のいずれでも受け付けられます。 + +### クライアントからはどこに見えるか {#where-a-client-sees-them} + +アイコンは、それが飾る対象と一緒に送られます。サーバーのアイコンはクライアントの接続時に `client.server_info` に届きます(2026 年世代の接続では省略可能なので、まず絞り込んでください)。 + +```python +assert client.server_info is not None # python-sdk servers identify themselves by default +client.server_info.icons # [Icon(src="https://example.com/brand-kit.png", mime_type="image/png", sizes=["48x48"])] +``` + +ツールのアイコンは `tools/list` の `Tool` オブジェクトに、リソースのアイコンは `resources/list` の `Resource` に、プロンプトのアイコンは `prompts/list` の `Prompt` にあります。フィールド名は常に `icons` です。 + +## まとめ {#recap} + +* ツールから `Image` または `Audio` を返すと、クライアントは `ImageContent` / `AudioContent` ブロックを受け取ります。バイト列が base64 エンコードされ、MIME タイプが付きます。 +* `path=` から作って拡張子に MIME タイプを決めさせるか、メモリ上の `data=` に明示的な `format=` を添えて作ります。 +* メディアの結果には `structured_content` も出力スキーマもありません。 +* `Icon` はポインターです。`src` URI に、省略可能な `mime_type`、`sizes`、`theme` を加えたものです。 +* `icons=[...]` はサーバー、ツール、リソース、プロンプトのどれにも使え、クライアントは対応するオブジェクト上でそれらを見つけます。 + +ツールが結果に「入れられる」ものはこれですべてです。ツールが「失敗した」ときに何が起こるか(そして誰がそれを知るべきか)は **[エラーの処理](handling-errors.md)** で扱います。 diff --git a/i18n/ja/pages/servers/prompts.md b/i18n/ja/pages/servers/prompts.md new file mode 100644 index 0000000000..a367f7cde3 --- /dev/null +++ b/i18n/ja/pages/servers/prompts.md @@ -0,0 +1,151 @@ +--- +translation: + sections: [d65c098f37f5b6c3, dd0c2724d6f2877e, 6835bb3570c6714c, ffe823cb0fedd488, f33651add1b59094] + tool: 1 +--- +# プロンプト {#prompts} + +**プロンプト**は、ユーザーが選ぶメッセージテンプレートです。 + +ツールはモデルのためのものです。プロンプトはその逆です。ユーザーがクライアントのメニュー(スラッシュコマンドやボタン)から 1 つを選んで引数を入力すると、レンダリングされたメッセージが、ユーザー自身が入力したかのように会話に入ります。 + +プロンプトを宣言するには、テキストを返す関数に `@mcp.prompt()` を付けます。 + +## 最初のプロンプト {#your-first-prompt} + +```python title="server.py" hl_lines="6-9" +--8<-- "docs_src/prompts/tutorial001.py" +``` + +SDK が読み取るのは、ツールの場合と同じ 3 つです。 + +* **名前**は関数名、つまり `review_code` です。 +* クライアントが表示する**説明**は docstring、つまり `Review a piece of code.` です。 +* **引数**はパラメーターから決まります。`code` にはデフォルト値がないので必須です。 + +クライアントが `prompts/list` で受け取るのは次のとおりです。 + +```json +{ + "name": "review_code", + "description": "Review a piece of code.", + "arguments": [ + {"name": "code", "required": true} + ] +} +``` + +ここには JSON Schema がありません。プロンプトの引数は、**名前付きの文字列値**が並んだフラットなリストです。モデルが組み立てるペイロードではなく、人が記入するフォームです。 + +### レンダリングする {#rendering-it} + +クライアントは `prompts/get` に引数を渡してテンプレートをレンダリングします。関数が実行され、返した `str` が **1 つのユーザーメッセージ**になります。 + +```json +{ + "description": "Review a piece of code.", + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "Please review this code:\n\ndef add(a, b): return a + b" + } + } + ], + "resultType": "complete" +} +``` + +プロンプトの一生はこれがすべてです。名前で一覧に載り、必要なときにレンダリングされ、チャットに差し込まれます。 + +!!! check + `required` のチェックは関数が実行される前に行われます。`code` なしで `review_code` をレンダリングすると、リクエスト自体が JSON-RPC エラー(コード `-32603`)で失敗します。 + + ```text + mcp.shared.exceptions.MCPError: Internal server error + ``` + + モデルに返すためのツール形式のエラー結果はありません。そもそもモデルが関与していないからです。呼び出しは例外を送出します。理由(`Missing required arguments: {'code'}`)はサーバーのログに記録されます。 + +### 試してみる {#try-it} + +MCP Inspector でサーバーを実行してください。 + +```console +uv run mcp dev server.py +``` + +**Prompts** タブを開いて `review_code` を選択してください。Inspector は、必須の `code` フィールドが 1 つあるフォームを表示します。入力してレンダリングすると、上のユーザーメッセージがそのまま返ってきます。 + +## 複数のメッセージ {#more-than-one-message} + +コードレビューは 1 つのメッセージです。デバッグセッションは会話であり、プロンプトはその会話全体の出発点を用意できます。 + +`str` の代わりに、メッセージのリストを返します。 + +```python title="server.py" hl_lines="2 13-20" +--8<-- "docs_src/prompts/tutorial002.py" +``` + +* `UserMessage` と `AssistantMessage` は `mcp.server.mcpserver.prompts.base` にあります。`str` を渡すと、`TextContent` にラップしてくれます。ロールはクラス名で決まります。 +* `Message` は両者に共通の基底クラスです。戻り値のアノテーションにはこれを使ってください。 + +`debug_error` をレンダリングすると、3 つのメッセージがこの順番で生成されるようになります。 + +```json +{ + "description": "Start a debugging conversation.", + "messages": [ + {"role": "user", "content": {"type": "text", "text": "I'm seeing this error:"}}, + {"role": "user", "content": {"type": "text", "text": "TypeError: 'int' object is not iterable"}}, + { + "role": "assistant", + "content": {"type": "text", "text": "I'll help debug that. What have you tried so far?"} + } + ], + "resultType": "complete" +} +``` + +最後のメッセージに注目してください。`assistant` のターンをあらかじめ埋めておくのは、誘導の文言をユーザー自身に入力させることなく、モデルの「次の」返答を方向づけるための方法です。 + +## タイトルと引数の説明 {#titles-and-argument-descriptions} + +`review_code` は関数名であって、ラベルではありません。ボタンに載せるのにもっとふさわしいものをクライアントに渡し、フォームを見ただけで意味がわかるように各引数に説明を付けます。 + +```python title="server.py" hl_lines="10-13" +--8<-- "docs_src/prompts/tutorial003.py" +``` + +* `title="Code review"` は人が読むための名前で、ツールの `title` とまったく同じです。 +* `Annotated[str, Field(description=...)]` は、**[ツール](tools.md)** でツールのパラメーターを説明するのに使うのと同じパターンです。ここでは、説明はスキーマの中ではなく引数に付きます。 +* `language` にはデフォルト値があるので、必須ではなくなります。 + +これで `prompts/list` のエントリには、クライアントがよいフォームを描くのに必要なものがすべてそろいます。 + +```json +{ + "name": "review_code", + "title": "Code review", + "description": "Review a piece of code.", + "arguments": [ + {"name": "code", "description": "The code to review.", "required": true}, + {"name": "language", "description": "The language the code is written in.", "required": false} + ] +} +``` + +!!! info + **[ツール](tools.md)** を読んでいれば、このページの内容はもうすべて知っています。同じデコレーター、同じく docstring が説明になる仕組み、同じ `Annotated`/`Field` です。変わるのは、誰が起動するか(ユーザー)と、結果がどこへ行くか(会話の中)だけです。 + +## まとめ {#recap} + +* 関数に `@mcp.prompt()` を付けるとプロンプトになります。名前は関数から、説明は docstring から取られます。 +* プロンプトは**ユーザーが制御する**ものです。クライアントが一覧を出し、ユーザーが 1 つ選んで引数を入力します。 +* 引数は名前付き文字列のフラットなリストです(スキーマなし)。デフォルト値のあるパラメーターは省略可能です。 +* `str` を返すと 1 つのユーザーメッセージになります。`UserMessage` / `AssistantMessage` のリストを返すと、複数ターンの会話の出発点を用意できます。 +* `title=` と `Field(description=...)` は、クライアントが UI に表示するものです。 +* 必須の引数が欠けていると、リクエスト全体が失敗します。プロンプト単位のエラー結果はありません。 + +プロンプト(やリソーステンプレート)の引数をサーバー側でオートコンプリートする機能については、**[補完](completions.md)** を参照してください。 diff --git a/i18n/ja/pages/servers/resources.md b/i18n/ja/pages/servers/resources.md new file mode 100644 index 0000000000..b9ec1cd08c --- /dev/null +++ b/i18n/ja/pages/servers/resources.md @@ -0,0 +1,138 @@ +--- +translation: + sections: [09df998c2a799f78, 0cf131146d16d4f9, 4e6b91e3f8025346, 8fe4eef576db17ed, 0d0d1ed43e3d0a53] + tool: 1 +--- +# リソース {#resources} + +**リソース**とは、アプリケーションが読めるように公開するデータです。 + +これが分かれ目です。ツールは**モデル**が呼び出すと決めるものです。リソースは**アプリケーション**が読み込むと決めるもの(設定ファイル、レコード、ドキュメントなど)で、コンテキストとしてモデルの前に置かれます。 + +宣言するには、普通の Python 関数に `@mcp.resource(uri)` を付けます。 + +## 最初のリソース {#your-first-resource} + +```python title="server.py" hl_lines="6-8" +--8<-- "docs_src/resources/tutorial001.py" +``` + +形はツールと同じで、1 つだけ加わるものがあります。**URI** です。リソースは名前ではなくアドレスで指定されます。クライアントが要求するのは `config://app` であって、`get_config` ではありません。 + +残りは、やはり SDK が関数から読み取ります。 + +* **名前**は関数名、つまり `get_config` です。 +* クライアントに見える**説明**は docstring です。 +* **内容**は関数が返すものです。 + +`resources/list` でクライアントが受け取るのは次のとおりです。 + +```json +{ + "name": "get_config", + "uri": "config://app", + "description": "The active shop configuration.", + "mimeType": "text/plain" +} +``` + +そして `config://app` を読むと関数が実行され、戻り値がテキストとして返ってきます。 + +```python +result.contents # [TextResourceContents(uri="config://app", mime_type="text/plain", text="theme=dark\nlanguage=en")] +``` + +!!! tip + 一覧の取得は軽い処理です。関数は `resources/list` のときには**呼び出されません**。呼び出されるのは `resources/read` のときだけで、それも要求された URI についてだけです。リソースを 1000 個公開しても、コストがかかるのは誰かが開いたものだけです。 + +### 試してみる {#try-it} + +MCP Inspector でサーバーを起動してください。 + +```console +uv run mcp dev server.py +``` + +表示された URL を開き、**Resources** タブに移動してください。一覧に `config://app` が説明付きで並んでいます。クリックすると Inspector がそれを読み込み、2 行の設定が表示されます。 + +## リソーステンプレート {#resource-templates} + +レコードごとに URI を 1 つずつ用意するやり方はスケールしません。URI に**プレースホルダー**を置き、それに対応するパラメーターを関数に持たせます。 + +```python title="server.py" hl_lines="12-13" +--8<-- "docs_src/resources/tutorial002.py" +``` + +URI に `{user_id}`、関数に `user_id: str` と書きます。約束事はこれですべてです。 + +これで**リソーステンプレート**になり、居場所も変わります。`resources/list` からは外れ、代わりに `resources/templates/list` に、アドレスではなくパターンとして現れます。 + +```json +{ + "name": "get_user_profile", + "uriTemplate": "users://{user_id}/profile", + "description": "A customer's profile.", + "mimeType": "text/plain" +} +``` + +クライアントはプレースホルダーを埋め、`users://42/profile` や `users://ada/profile` のような具体的な URI を読みます。そのすべてに 1 つの関数が応答し、マッチした値が `user_id` として渡されます。 + +```python +result.contents # [TextResourceContents(uri="users://42/profile", text="User 42: 12 orders since 2021.")] +``` + +結果の `uri` に注目してください。これはクライアントが要求した**具体的な** URI であって、テンプレートではありません。 + +!!! check + プレースホルダーとパラメーターは一致している必要があります。URI が `{user_id}` のまま関数のパラメーターを `user` に改名すると、デコレーターは**インポート時に**、つまりどのクライアントも触れないうちに拒否します。 + + ```text + ValueError: Mismatch between URI parameters {'user_id'} and function parameters {'user'} + ``` + + 不一致はバグでしかありえないので、SDK は不一致を抱えたままではサーバーを起動できないようにしています。 + +プレースホルダーの構文は [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570) です。複数セグメントにまたがる値には `{+path}`、省略可能なクエリパラメーターには `{?q,lang}` というように、ほかにも書き方があります。また、SDK は取り出した値に対して、デフォルトでパス安全性のチェックを行います。完全なリファレンスは **[URI テンプレートとパス安全性](uri-templates.md)** を参照してください。 + +`get_user_profile` は、`Context` と注釈を付けたパラメーターを受け取ることもできます。SDK はそれを URI パラメーターとして扱うことなく注入します。それで何が得られるかは **[Context](../handlers/context.md)** のページで説明しています。 + +## 何を返すか {#what-you-return} + +返せるのは `str` だけではありません。リソースごとに `mime_type` を指定し、合うものを返してください。 + +```python title="server.py" hl_lines="8-9 14-15 20-21" +--8<-- "docs_src/resources/tutorial003.py" +``` + +* `readme` は `str` を返すので、そのまま送られます。これがよくあるケースです。 +* `catalog_stats` は `dict` を返すので、SDK が **JSON テキスト**にシリアライズしてくれます。 + + ```json + { + "books": 1204, + "authors": 391 + } + ``` + +* `placeholder_cover` は `bytes` を返すので、クライアントは `TextResourceContents` ではなく `BlobResourceContents` を受け取ります。その `blob` フィールドに、バイト列が base64 エンコードされて入っています。 + +JSON にシリアライズできるほかのもの、つまりリスト、Pydantic モデル、dataclass にも同じルールが当てはまります。`str` でも `bytes` でもなければ、JSON になります。 + +`mime_type` は自分で宣言するもので、デフォルトは `text/plain` です。SDK が戻り値の中身を調べて推測することはありません。そのため、ラベルを付けていない `dict` のリソースは、相変わらずプレーンテキストとして案内されます。 + +!!! tip + 関数から導き出したくないときは、`@mcp.resource()` に `name=`、`title=`、`description=` も渡せます。また、書くべき関数がそもそもないときのために、`mcp.server.mcpserver.resources` には既製の `Resource` クラス(`TextResource`、`BinaryResource`、`FileResource`、`HttpResource`、`DirectoryResource`)が用意されており、`mcp.add_resource(...)` で登録します。 + +クライアントはリソースを**購読**して、変更があったときに通知を受け取ることもできます。これはクライアント側の話なので、**[クライアント](../client/index.md)** で説明しています。 + +## まとめ {#recap} + +* 関数に `@mcp.resource(uri)` を付けるとリソースになります。URI がアドレス、戻り値が内容、docstring が説明です。 +* URI に `{placeholder}` を入れると**テンプレート**になります。`resources/templates/list` に載り、マッチするすべての URI に 1 つの関数が応答します。 +* プレースホルダーの名前は関数のパラメーター名と一致させなければなりません。間違えても、気づくのは本番ではなくインポート時です。 +* 関数が実行されるのはリソースが**読まれた**ときで、一覧に載るときではありません。 +* `str` はテキストに、`bytes` は base64 の blob に、それ以外は JSON テキストになります。ラベルを付けるには `mime_type=` を使います。 +* ツールはモデルが行動するためのもので、リソースはアプリケーションが読むためのものです。 + +3 つ目のプリミティブ、つまり人がメニューから選ぶものが **[プロンプト](prompts.md)** です。 diff --git a/i18n/ja/pages/servers/structured-output.md b/i18n/ja/pages/servers/structured-output.md new file mode 100644 index 0000000000..ad278aa435 --- /dev/null +++ b/i18n/ja/pages/servers/structured-output.md @@ -0,0 +1,242 @@ +--- +translation: + sections: [a838d57f003aed44, 857d03886a0137ed, 42d9efcb9f542867, 2290ff08435b5573, e866c192e11d1c14, 6cdbad079f7b47f0, d4b607372fb28b51, 18dbf726ac45e0b7, c6f7d2a148aa49f4, c851964bb3301907, d715db6f8dccc9cc, ef86634aa70498a7] + tool: 1 +--- +# 構造化出力 {#structured-output} + +単なる `str` を返すツールは、結果を 2 回生み出します。`content` にはテキストとして、`structured_content` には `{"result": "..."}` として入ります。 + +このページで扱うのは、その 2 つ目のチャネルです。それがどこから来るのか、どんな形を取りうるのか、そして SDK がその正しさをどう担保しているのかを見ていきます。 + +ひとことで言えば、**戻り値の型アノテーションが出力スキーマです**。もう書いてあります。 + +## 出力スキーマ {#the-output-schema} + +```python title="server.py" hl_lines="9" +--8<-- "docs_src/structured_output/tutorial001.py" +``` + +重要なのはシグネチャの行、`-> int` です。 + +この行があるおかげで、SDK が `tools/list` で送るツールには、パラメーターから組み立てる入力スキーマ(こちらは **[ツール](tools.md)** で扱っています)の隣に `output_schema` が付きます。 + +```json +{ + "properties": { + "result": {"title": "Result", "type": "integer"} + }, + "required": ["result"], + "title": "get_temperatureOutput", + "type": "object" +} +``` + +`int` 単体は JSON オブジェクトではないため、SDK はそれを `{"result": ...}` で**ラップ**します。ツールを呼び出すと、両方のチャネルが埋まります。 + +```python +result.content # [TextContent(text="17")] +result.structured_content # {"result": 17} +``` + +スカラーはどれも同じラッパーに包まれます。`str`、`int`、`float`、`bool`、`bytes`、`None` のすべてが対象です。 + +## 2 つのチャネル {#two-channels} + +なぜ同じ値を 2 回送るのでしょうか。 + +* `content` は**モデル**のためのものです。言語モデルが読むのはテキストであり、結果のうちモデルの目に入るのはこの部分だけです。 +* `structured_content` は、モデルがその中で動いている**アプリケーション**のためのものです。つまり「17」を含んだ文章ではなく、`17` そのものが欲しいコードです。 +* `output_schema` は両者をつなぐ契約で、ツールが一度でも呼ばれる前に公開されます。 + +返すのは Python の値 1 つです。3 つすべてを埋めるのは SDK です。 + +## モデルを返す {#return-a-model} + +形を Pydantic の `BaseModel` として宣言し、そのインスタンスを返します。 + +```python title="server.py" hl_lines="8-11 15" +--8<-- "docs_src/structured_output/tutorial002.py" +``` + +今度は `WeatherData` **そのものが**スキーマです。ラッパーも `result` キーもありません。 + +```json +{ + "properties": { + "temperature": {"description": "Degrees Celsius.", "title": "Temperature", "type": "number"}, + "humidity": {"description": "Relative humidity, 0 to 1.", "title": "Humidity", "type": "number"}, + "conditions": {"title": "Conditions", "type": "string"} + }, + "required": ["temperature", "humidity", "conditions"], + "title": "WeatherData", + "type": "object" +} +``` + +`structured_content` は、そのオブジェクトをフィールドごとにそのまま写したものです。 + +```python +result.structured_content # {"temperature": 16.2, "humidity": 0.83, "conditions": "Overcast"} +``` + +モデルも置き去りにはしません。SDK は同じオブジェクトを JSON テキストにシリアライズして `content` に入れます。 + +```json +{ + "temperature": 16.2, + "humidity": 0.83, + "conditions": "Overcast" +} +``` + +`temperature` と `humidity` に付けた `Field(description=...)` がスキーマに入っている点に注目してください。**入力**を説明したのと同じ `Field` が、出力も説明します。 + +!!! info + FastAPI の `response_model` を使ったことがあれば、これはすでにおなじみのはずです。宣言したレスポンスとして Pydantic モデルを置けば、シリアライズもドキュメント化も任せられる、というものです。唯一の違いは、ここでは戻り値のアノテーションだけで宣言が完結する点です。 + +## `TypedDict` {#a-typeddict} + +どんな形にもクラスがふさわしいわけではありません。`TypedDict` でも同じスキーマになります。 + +```python title="server.py" hl_lines="8" +--8<-- "docs_src/structured_output/tutorial003.py" +``` + +`TypedDict` は実行時にはただの `dict` なので、組み立てて返すのもそれです。スキーマもバリデーションも `structured_content` も、`BaseModel` 版と同一です(説明だけは付きません。`TypedDict` には説明を書く場所がないからです)。 + +## データクラス {#a-dataclass} + +データクラスも使えますし、属性に型ヒントの付いた普通のクラスならどれでも使えます。SDK が裏側で、アノテーションから Pydantic モデルを組み立てます。 + +```python title="server.py" hl_lines="8-9" +--8<-- "docs_src/structured_output/tutorial004.py" +``` + +書き方は 3 通り、スキーマは 1 つです。コードベースにすでにあるものを使ってください。 + +## リスト {#lists} + +`list[...]` も JSON オブジェクトではないので、`{"result": ...}` ラッパーに包まれます。要素の型は、その中で `$defs` への参照になります。 + +```python title="server.py" hl_lines="15" +--8<-- "docs_src/structured_output/tutorial005.py" +``` + +```json +{ + "$defs": { + "WeatherData": { + "properties": { + "temperature": {"title": "Temperature", "type": "number"}, + "humidity": {"title": "Humidity", "type": "number"}, + "conditions": {"title": "Conditions", "type": "string"} + }, + "required": ["temperature", "humidity", "conditions"], + "title": "WeatherData", + "type": "object" + } + }, + "properties": { + "result": {"items": {"$ref": "#/$defs/WeatherData"}, "title": "Result", "type": "array"} + }, + "required": ["result"], + "title": "get_forecastOutput", + "type": "object" +} +``` + +2 日分の予報を要求すると、`structured_content` は `{"result": [{...}, {...}]}` になります。`content` のほうは、要素ごとに 1 つずつ、**2 つ**の `TextContent` ブロックになります。リストは 1 本の文字列として丸ごと出力されるのではなく、モデル向けに平坦化されます。 + +`tuple[...]`、ユニオン、`Optional[...]` も同じようにラップされます。 + +## 辞書 {#dictionaries} + +`dict[str, ...]` は、それ自体がすでに JSON オブジェクトである唯一のジェネリック型なので、ラップされません。 + +```python title="server.py" hl_lines="9" +--8<-- "docs_src/structured_output/tutorial006.py" +``` + +```json +{ + "additionalProperties": {"type": "number"}, + "title": "get_temperaturesDictOutput", + "type": "object" +} +``` + +```python +result.structured_content # {"London": 16.2, "Reykjavik": 4.4} +``` + +キーは `str` でなければなりません。`dict[int, float]` は JSON オブジェクトになれないため、`{"result": ...}` ラッパーにフォールバックします。 + +## バリデーション {#validation} + +`output_schema` は単なるドキュメントではありません。関数が返すものは何であれ、サーバーを出る前に**このスキーマに照らして検証されます**。 + +値を手で組み立てているうちは、このことに気づきません。`WeatherData` が本当に `WeatherData` であることは、Pydantic がすでに保証しているからです。気づくのは、自分では制御できない場所からデータが来るようになった日です。 + +```python title="server.py" hl_lines="9 21" +--8<-- "docs_src/structured_output/tutorial007.py" +``` + +アノテーションは `WeatherData` を約束しています。ところが、上流のレスポンスが `humidity` を送ってこなくなりました。 + +!!! check + `get_weather` を呼び出しても、中身が半分欠けたオブジェクトがこっそりクライアントに渡ることはありません。呼び出しは失敗し、エラーの冒頭の数行にそのフィールド名が示されます。 + + ```text + Error executing tool get_weather: 1 validation error for WeatherData + humidity + Field required [type=missing, input_value={'temperature': 16.2, 'conditions': 'Overcast'}, input_type=dict] + ``` + + このテキストは `is_error=True` の付いたツール結果として返ってくるので、モデルは、ありもしない天気を自信満々に読み上げる代わりに、呼び出しが失敗したと分かります。 + +ちなみに、`-> WeatherData` のツールから単なる `dict` を返してもかまいません。`json.loads` が返したのはまさにそれです。バリデーションの対象は Python の型ではなく、値です。 + +## オプトアウト {#opting-out} + +戻り値のアノテーションが、プロトコルのためではなく型チェッカーのためにある場合もあります。`structured_output=False` を渡せば、ツールはテキストのみになります。 + +```python title="server.py" hl_lines="6" +--8<-- "docs_src/structured_output/tutorial008.py" +``` + +`output_schema` も、ラップも、バリデーションもありません。`structured_content` は `None` になり、`content` は返した文字列そのものです。 + +その逆の `structured_output=True` は、自動検出を必須要件に変えます。戻り値の型からスキーマを作れないツールは、テキストにフォールバックするのではなく、インポート時に例外を送出します。 + +## 型ヒントのないクラス {#a-class-without-type-hints} + +頼んでもいないのに非構造化になってしまう道が 1 つだけあります。**本体にアノテーションが 1 つもない**クラスを返すことです。 + +```python title="server.py" hl_lines="6-9" +--8<-- "docs_src/structured_output/tutorial009.py" +``` + +`Station` は `__init__` の中で `name` と `online` を設定していますが、クラス自体は何も宣言していません。SDK はクラスのアノテーションを読みにいき、何も見つからず、諦めます。 + +!!! warning + しかも**黙って**諦めます。`output_schema` は `None`、`structured_content` も `None` で、モデルが読むテキストはオブジェクトの `repr` です。 + + ```text + "" + ``` + + エラーも警告もなく、役に立たないツールができあがります。アノテーションをクラス本体へ移すか、`structured_output=True` を渡してください。後者なら、モジュールをインポートした瞬間に `Function get_station: return type is not serializable for structured output` というハードエラーに変わります。 + +!!! tip + 完全な制御が必要な場合(`CallToolResult` を自分で組み立てたい、あるいはアプリケーションからは見えてモデルからは見えない `_meta` を付けたいなど)は、**[低レベル Server](../advanced/low-level-server.md)** を参照してください。 + +## まとめ {#recap} + +* **戻り値の型アノテーション**が出力スキーマです。`tools/list` で `output_schema` として公開されます。 +* スカラー、リスト、タプル、ユニオンは `{"result": ...}` でラップされます。モデル、`TypedDict`、データクラス、アノテーション付きクラス、`dict[str, ...]` はもともとオブジェクトなので、そのままです。 +* どの結果も `content`(モデル向けのテキスト)**と** `structured_content`(アプリケーション向けのデータ)の両方を持ちます。 +* 返したものはスキーマに照らして検証されます。食い違いは壊れた結果ではなく、ツールエラーになります。 +* `structured_output=False` を渡すと、そのツールはオプトアウトします。型ヒントのないクラスは黙ってオプトアウトするので、気をつけてください。 + +これで、ツールが返せるものはすべて押さえました。次は 2 つ目のプリミティブ、**[リソース](resources.md)** です。 diff --git a/i18n/ja/pages/servers/tools.md b/i18n/ja/pages/servers/tools.md new file mode 100644 index 0000000000..78c3243092 --- /dev/null +++ b/i18n/ja/pages/servers/tools.md @@ -0,0 +1,170 @@ +--- +translation: + sections: [e4cc390d56573409, 8566e2b68594e9ad, 2c97b9f888398951, 048e5471dfa71aea, 3076b1e16ad95950, edbedf2a16e71311, 3d8ef8da89fa87c1, f6c0e02e6ea5a363] + tool: 1 +--- +# ツール {#tools} + +**ツール**とは、モデルが呼び出せる関数のことです。 + +普通の Python 関数に `@mcp.tool()` を付ければ宣言できます。API はこれだけです。 + +## 最初のツール {#your-first-tool} + +```python title="server.py" hl_lines="6-8" +--8<-- "docs_src/tools/tutorial001.py" +``` + +書いたコードを見てください。スキーマも JSON もプロトコルもなく、あるのは関数だけです。SDK はこの関数から 3 つのことを読み取ります。 + +* ツールの**名前**は関数名、つまり `search_books` です。 +* モデルが目にする**説明**は docstring、つまり `Search the catalog by title or author.` です。 +* モデルが渡すことを許される**引数**は型ヒント、つまり `query: str` と `limit: int` から決まります。 + +### 入力スキーマ {#the-input-schema} + +SDK はこれらの型ヒントから JSON Schema を生成し、`tools/list` のときにクライアントへ送ります。 + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"title": "Limit", "type": "integer"} + }, + "required": ["query", "limit"], + "title": "search_booksArguments" +} +``` + +どちらの引数にもデフォルト値がないため、両方とも `required` に入っています。これはすぐ後で直します。(`title` キーは Pydantic が生成した付随物です。契約にあたるのは、プロパティとその型、そして `required` です。) + +!!! tip + ここでの型ヒントはドキュメントではありません。**契約そのもの**です。クライアントが `"limit": "ten"` を送ってきても、関数が実行される前に SDK が拒否します。 + +### モデルが受け取るもの {#what-the-model-gets-back} + +`{"query": "dune", "limit": 5}` でツールを呼び出すと、結果は 2 つの部分からなります。 + +```python +result.content # [TextContent(text="Found 3 books matching 'dune' (showing up to 5).")] +result.structured_content # {'result': "Found 3 books matching 'dune' (showing up to 5)."} +``` + +`content` は**モデル**が読むテキストです。`structured_content` は**クライアントアプリケーション**向けの型付きデータです。これが含まれているのは、戻り値の型を `-> str` と宣言したからです。 + +`structured_content` については、まだ気にしなくてかまいません。ツールから本物の Python オブジェクトを返せば、適切に処理されます。詳しくは **[構造化出力](structured-output.md)** のページを参照してください。 + +### 試してみる {#try-it} + +MCP Inspector でサーバーを実行してください。 + +```console +uv run mcp dev server.py +``` + +表示される URL を開き、**Tools** タブに移動して `search_books` を呼び出してください。 + +Inspector は、必須の `query` テキストフィールドと必須の `limit` 数値フィールドを持つフォームを描画します。このフォームは型ヒントから組み立てられたものです。ほかの MCP クライアントもすべて同じようにします。 + +## 省略可能な引数 {#optional-arguments} + +パラメーターにデフォルト値を与えると、必須ではなくなります。これだけです。ただの Python です。 + +```python title="server.py" hl_lines="7" +--8<-- "docs_src/tools/tutorial002.py" +``` + +スキーマは次のようになります。 + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"default": 10, "title": "Limit", "type": "integer"} + }, + "required": ["query"], + "title": "search_booksArguments" +} +``` + +`limit` は `required` から外れ、`"default": 10` が付きました。省略したクライアントには `10` が渡ります。Python とまったく同じです。 + +## `Field` を使った詳細なスキーマ {#richer-schemas-with-field} + +型ヒントだけでもかなりのことができますが、引数に「説明」を付けたり、制約を課したりしたい場合もあります。 + +型を `Annotated` で包み、Pydantic の `Field` を加えます。 + +```python title="server.py" hl_lines="12-14" +--8<-- "docs_src/tools/tutorial003.py" +``` + +新しい点が 3 つあり、どれもパラメーターに付いています。 + +* `Field(description=...)`:引数ごとの説明です。モデルは docstring と合わせてこれを読みます。 +* `Field(ge=1, le=50)`:数値の範囲です。スキーマには `"minimum": 1, "maximum": 50` として入ります。 +* `Literal["fiction", "non-fiction", "poetry"]`:列挙型です。モデルはこの中の 1 つしか選べません。 + +!!! check + 制約は飾りではありません。`limit=999` でツールを呼び出すと、**関数が実行される前に** SDK がツールエラーを返します。 + + ```text + Input should be less than or equal to 50 + ``` + + このエラーはツールの結果としてモデルに返され、モデルはそれを読んで有効な値でやり直します。`le=50` と一度書いただけで、自己修正するエージェントがただで手に入ったことになります。 + +!!! info + FastAPI や Pydantic を使ったことがあれば、これはすべて既知の内容です。同じ `Field`、同じ `Annotated`、同じバリデーションです。MCP 固有の学ぶべきことはここにはありません。 + +## パラメーターとしてのモデル {#a-model-as-a-parameter} + +ツールが取る引数が 2、3 個を超えるときは、Pydantic モデルにまとめます。 + +```python title="server.py" hl_lines="8-11 15" +--8<-- "docs_src/tools/tutorial004.py" +``` + +`Book` のスキーマはツールの入力スキーマの中に(`$defs` の参照として)ネストされます。モデルはこれを JSON オブジェクトとして埋め、関数はバリデーション済みの**本物の `Book` インスタンス**を受け取ります。`.title`、`.author`、`.year` の各属性が使えます。 + +自由に組み合わせられます。普通のパラメーターとモデルのパラメーターを並べても、モデルをネストしても、モデルのリストにしてもかまいません。どこまで行っても Pydantic です。 + +## `async def` {#async-def} + +ツールが I/O を行う場合(API を呼ぶ、ファイルを読む、データベースに問い合わせるなど)は、`async def` で宣言し、その中で `await` してください。SDK 側がそれを await します。 + +普通の `def` のツールも使えます。SDK がスレッド内で実行するので、サーバーをブロックすることはありません。 + +ほかに設定することはありません。 + +## 名前、タイトル、アノテーション {#names-titles-and-annotations} + +SDK が推論するものはすべて、デコレーターで上書きできます。 + +```python title="server.py" hl_lines="7-10" +--8<-- "docs_src/tools/tutorial005.py" +``` + +* `title` は UI 向けの、人が読むための名前です。クライアントは `search_books` の代わりに *"Search the catalog"* を表示します。 +* `annotations` はクライアントに対する振る舞いの**ヒント**です。 + * `read_only_hint=True`:このツールは何も変更しません。 + * `open_world_hint=False`:開かれた Web ではなく、閉じた対象の集合(このカタログ)に対して働きます。 + * 残りの 2 つ、`destructive_hint` と `idempotent_hint` は「書き込む」ツールを説明するものです。何かを削除する可能性があるか、そして 2 回呼び出すのは 1 回呼び出すのと同じか、を表します。仕様はどちらも読み取り専用でないツールに対してだけ定義しているので、`search_books` に付けても何も伝わりません。 + +行儀のよいクライアントは、これらを使って「これを実行する前にユーザーに確認する必要があるか」といったことを判断します。これらはヒントであって、セキュリティではありません。クライアントがこれらを守ることを決して当てにしないでください。 + +!!! tip + 関数名と docstring から導きたくない場合は、`@mcp.tool()` に `name=` と `description=` を渡すこともできます。たいていは導くほうで十分です。 + +## まとめ {#recap} + +* 関数に `@mcp.tool()` を付けるとツールになります。名前は関数から、説明は docstring から取られます。 +* 型ヒントが**そのまま**入力スキーマです。デフォルト値を付けると引数は省略可能になります。 +* `Annotated[..., Field(...)]` で説明と制約を、`Literal` で列挙型を加えられます。 +* 構造化された「ボディ」を受け取るには、Pydantic モデルのパラメーターを使います。 +* 不正な引数は自動的に拒否され、モデルが読んで立て直せるエラーが返ります。 +* I/O には `async def` を、それ以外には普通の `def` を使います。 + +`return` した値がその後どうなるかは、**[構造化出力](structured-output.md)** で扱います。 diff --git a/i18n/ja/pages/servers/uri-templates.md b/i18n/ja/pages/servers/uri-templates.md new file mode 100644 index 0000000000..c4f6a7eaa0 --- /dev/null +++ b/i18n/ja/pages/servers/uri-templates.md @@ -0,0 +1,167 @@ +--- +translation: + sections: [4a7033e1ed8ad602, 55dcbfff0c6271bf, 101ef9d14bf4ec46, 4b6c4a845438abc7, f98b46bafbee4acd] + tool: 1 +--- +# URI テンプレートとパスの安全性 {#uri-templates-and-path-safety} + +このページは、[`@mcp.resource`](resources.md) が受け付ける URI テンプレート構文と、抽出した値に SDK が適用するパス安全性ポリシーのリファレンスです。リソースとは何か、いつ使うのかについては、まず **[リソース](resources.md)** を参照してください。このページでは、リソースの宣言にはすでに慣れていて、演算子の全セットやセキュリティの設定項目、低レベルでの組み込み方を知りたい、という読者を想定しています。 + +テンプレート構文は [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570) です。SDK がサポートするのは、受信する `resources/read` の URI のマッチング向けに選んだそのサブセットです。加えて、提供するつもりのディレクトリの外へ解決されてしまう値を拒否するセキュリティレイヤーを備えています。プロトコルレベルの詳細(メッセージ形式、ライフサイクル、ページネーション)については、[MCP のリソース仕様](https://modelcontextprotocol.io/specification/latest/server/resources) を参照してください。 + +## 演算子の全セット {#the-full-operator-set} + +単純なプレースホルダー `{user_id}` は、**[リソース](resources.md)** で紹介したものです。演算子の形式はほかに 4 つあります。並べて見比べられるように、1 つのサーバーにまとめました。 + +```python title="server.py" hl_lines="16-17 22-23 28-29 34-35 40-41" +--8<-- "docs_src/uri_templates/tutorial001.py" +``` + +ハイライトされたデコレーターは、それぞれ異なる方法で URI を切り分けています。以下のセクションで上から順に説明します。 + +### 単純な展開:`{name}` {#simple-expansion-name} + +`books://{isbn}` は、日常的に使う単純な形式です。プレースホルダーは `isbn` パラメーターに対応するので、クライアントが `books://978-0441172719` を読むと `get_book("978-0441172719")` が呼び出されます。 + +単純な `{name}` は最初の `/` で止まります。`books://978/extra` はマッチしません。`978` の後ろのスラッシュでキャプチャが終わり、`/extra` が余ってしまうからです。 + +### 型変換 {#type-conversion} + +抽出した値は文字列として届きますが、より具体的な型を宣言すれば SDK が変換します。`orders://{order_id}` の値は、パラメーターが `order_id: int` の関数に渡るので、`orders://12345` を読むと `get_order("12345")` ではなく `get_order(12345)` が呼び出されます。ハンドラーはキャストなしで、そのまま算術演算(`order_id + 1`)を行えます。 + +### 複数セグメントのパス:`{+name}` {#multi-segment-paths-name} + +スラッシュを含む値をキャプチャするには `{+name}` を使います。`manuals://{+path}` の場合は次のようになります。 + +* `manuals://returns.md` なら `path = "returns.md"` +* `manuals://printing/setup.md` なら `path = "printing/setup.md"` + +値が階層構造を持つときは、いつでも `{+name}` を使ってください。ファイルシステムのパス、ネストしたオブジェクトのキー、プロキシする URL のパスなどです。 + +### クエリパラメーター:`{?a,b,c}` {#query-parameters-abc} + +`reviews://{isbn}{?limit,sort}` は、`limit` と `sort` を `?` の後ろに置きます。パスは「どの」本かを特定し、クエリは「どのように」読むかを調整します。 + +クエリパラメーターのマッチングは緩やかです。順序は問わず、余分なものは無視され、省略されたパラメーターには関数のデフォルト値が使われます。つまり `reviews://978-0441172719` では `limit=10, sort="newest"` が使われ、`reviews://978-0441172719?sort=top` では `sort` だけが上書きされます。 + +### リストとしてのパスセグメント:`{/name*}` {#path-segments-as-a-list-name} + +スラッシュ入りの 1 つの文字列ではなく、パスの各セグメントを別々のリスト要素として受け取りたい場合は `{/name*}` を使います。`shelves://browse{/path*}` なら、クライアントが `shelves://browse/fiction/sci-fi` を読むと `browse_shelf(["fiction", "sci-fi"])` が呼び出されます。 + +### テンプレート早見表 {#template-reference} + +よく使うパターンは次のとおりです。 + +| パターン | 入力例 | 得られる値 | +|--------------|-----------------------|-------------------------| +| `{name}` | `alice` | `"alice"` | +| `{name}` | `docs/intro.md` | マッチしない(`/` で止まる) | +| `{+path}` | `docs/intro.md` | `"docs/intro.md"` | +| `{.ext}` | `.json` | `"json"` | +| `{/segment}` | `/v2` | `"v2"` | +| `{?key}` | `?key=value` | `"value"` | +| `{?a,b}` | `?a=1&b=2` | `"1"`, `"2"` | +| `{/path*}` | `/a/b/c` | `["a", "b", "c"]` | + +### パーサーが拒否するもの {#what-the-parser-rejects} + +テンプレートの形によっては、最初のリクエストで失敗するのを待たず、事前に検出されるものがあります。`@mcp.resource` はデコレーターの実行時にテンプレートを解析するので、これらが稼働中のサーバーに到達することはありません。 + +`UriTemplate.parse()` は、次の場合に `InvalidUriTemplate` を送出します。 + +* **間に何もない 2 つの変数。** `manuals://{+path}{ext}` は拒否されます。マッチングでは、`path` がどこで終わり `ext` がどこで始まるのか判断できないからです。間にリテラルを挟む(`manuals://{+path}/{ext}`)か、区切り文字を自前で持つ演算子を使ってください。`manuals://{+path}{.ext}` は、`{.ext}` 自体が `.` を提供するので受け付けられます。 +* **複数セグメントの変数が 2 つ以上ある場合。** `{+var}`、`{#var}`、explode 修飾子付きの変数(`{/var*}`、`{.var*}`、`{;var*}`)は、1 つのテンプレートにつき多くても 1 つです。2 つあると本質的にあいまいになります。余分なセグメントをどちらが吸収するのか、筋の通った決め方がないからです。 +* **よくある構文エラー**:閉じていない波括弧、2 回使われている変数名、あるいは SDK がサポートしていない RFC 6570 の機能です。たとえばプレフィックス修飾子の `{var:3}` や、クエリの explode である `{?vars*}` などが該当します。 + +これに加えて `@mcp.resource` は、ハンドラーのパラメーターがテンプレート末尾に連なる `{?...}`/`{&...}` のクエリ変数に束縛されているのに Python のデフォルト値を持たない場合、`ValueError` を送出します。これらの変数は緩やかにマッチングされる(クライアントはどれを省略してもかまいません)ので、デフォルト値のないパラメーターは、それを省略した最初のリクエストで、わかりにくい内部エラーとして表面化するだけになってしまいます。上のサーバーの `reviews://{isbn}{?limit,sort}` は正しく書かれた例で、`limit` と `sort` はどちらもデフォルト値を持っています。 + +## セキュリティ {#security} + +テンプレートのパラメーターはクライアントから届きます。チェックしないままファイルシステムやデータベースの操作に流し込むと、`../../etc/passwd` のような値が、提供するつもりだったディレクトリの外に解決されてしまうことがあります。 + +### SDK がデフォルトでチェックする内容 {#what-the-sdk-checks-by-default} + +SDK はハンドラーの実行前に、次のいずれかに当てはまるパラメーターを拒否します。 + +* `..` の構成要素を使って開始ディレクトリの外に出てしまうもの。 +* 絶対パス(`/etc/passwd`、`C:\Windows`)や Windows のドライブ相対パス(`C:foo`)のように見えるもの。ドライブ相対の値と `x:y` のような名前空間付きの識別子は、文字列としては区別できません。そのため、1 文字の後にコロンが続く形の値は、デフォルトではすべて拒否されます。そのような値を正当に受け取るパラメーターは、チェックの対象から除外してください。 +* ヌルバイト(`\x00`)を含むもの。 + +`..` のチェックは部分文字列の走査ではなく、パスの構成要素単位で行われます。`v1.0..v2.0` や `HEAD~3..HEAD` のような値は通ります。そこでの `..` は独立したパスセグメントではないからです。 + +これらのチェックはデコード後の値に適用されるので、URI でどのようにエンコードされていてもトラバーサルを検出します(`../etc`、`..%2Fetc`、`%2E%2E/etc`、`..%5Cetc`、`%00` はすべて検出されます)。 + +!!! check + 上のサーバーから `manuals://../etc/passwd` を読むと、リクエストは即座に拒否されます。テンプレートのマッチングは最初の失敗で止まるので、後続の(より緩いかもしれない)テンプレートがフォールバックとして試されることはありません。クライアントには、どのテンプレートにもマッチしない URI の場合と同じ `-32602` の「Unknown resource」エラーが返り、`read_manual` は実行されません。 + +### ファイルシステムのハンドラー:safe_join を使う {#filesystem-handlers-use-safe_join} + +組み込みのチェックはよくあるケースを止めますが、サンドボックスの境界までは知りようがありません。ファイルシステムにアクセスする場合は、`safe_join` を使ってパスを解決し、ベースディレクトリの内側に収まっていることを検証してください。 + +```python title="server.py" hl_lines="4 14" +--8<-- "docs_src/uri_templates/tutorial002.py" +``` + +`safe_join` は、単純な文字列チェックでは見逃してしまうシンボリックリンクによる脱出、`..` の並び、絶対パスを使ったトリックを検出します。解決されたパスが `DOCS_ROOT` の外に出ると `PathEscapeError` を送出し、クライアントには `ResourceError` として伝わります。 + +### デフォルトが妨げになるとき {#when-the-defaults-get-in-the-way} + +チェックが正当な値をブロックしてしまうこともあります。カタログのインポートツールが意図的に絶対パスを受け取る場合や、パラメーターが `../sibling` のような相対参照で、ハンドラーがファイルシステムに触れずに安全に解釈する場合などです。そのパラメーターをチェックの対象から除外するか、サーバー全体のポリシーを緩めてください。 + +```python title="server.py" hl_lines="9 16-19" +--8<-- "docs_src/uri_templates/tutorial003.py" +``` + +* デコレーターに付けた `security=ResourceSecurity(exempt_params={"source"})` は、その 1 つのリソースのその 1 つのパラメーターに限ってチェックをスキップします。サーバーのほかの部分はデフォルトのポリシーのままです。 +* `MCPServer` のコンストラクターに渡す `resource_security=` は、すべてのリソースのデフォルトを設定します。ここでは `relaxed` によって `..` のチェックが完全に無効になります。 + +設定できるチェックは次のとおりです。 + +| 設定 | デフォルト | 動作 | +|-------------------------|---------|-------------------------------------| +| `reject_path_traversal` | `True` | 開始ディレクトリの外に出る `..` の並びを拒否する | +| `reject_absolute_paths` | `True` | `/foo`、`C:\foo`、UNC パス、ドライブ相対の `C:foo` を拒否する(`x:y` も対象になる) | +| `reject_null_bytes` | `True` | `\x00` を含む値を拒否する | +| `exempt_params` | 空 | チェックをスキップするパラメーター名 | + +これらのチェックはヒューリスティックな事前フィルターです。ファイルシステムへのアクセスでは、依然として `safe_join` が封じ込めの境界です。 + +!!! tip + ハンドラーがリクエストに応えられない場合(ファイルが存在しない、ID が不明など)は、例外を送出してください。SDK がそれをエラーレスポンスに変換します。プロトコルエラーとツールエラーの違いについては、**[エラーの処理](handling-errors.md)** を参照してください。 + +## 低レベル Server でのリソース {#resources-on-the-low-level-server} + +低レベルの `Server` の上に構築している場合(**[低レベル Server](../advanced/low-level-server.md)** を参照)は、プロトコルメソッド `resources/list` と `resources/read` のハンドラーを直接登録します。デコレーターはなく、プロトコルの型は自分で返します。 + +### 静的リソース {#static-resources} + +固定の URI については、レジストリを持っておき、完全一致でディスパッチします。 + +```python title="server.py" hl_lines="17 21 27" +--8<-- "docs_src/uri_templates/tutorial004.py" +``` + +一覧ハンドラーは利用できるものをクライアントに伝え、読み取りハンドラーはコンテンツを返します。まずレジストリを調べ、テンプレートがあればそちら(後述)に回し、それ以外はすべて例外を送出してください。 + +### テンプレート {#templates} + +`MCPServer` が使っているテンプレートエンジンは `mcp.shared.uri_template` にあり、単独でも動作します。解析とマッチングは同じものが手に入ります。ルーティングとセキュリティポリシーの組み立ては自分で行います。 + +```python title="server.py" hl_lines="13-16 22-25 29 33 45" +--8<-- "docs_src/uri_templates/tutorial005.py" +``` + +ハイライトされた行では 3 つのことが行われています。 + +* **解析は一度、マッチングはリクエストごと。** `UriTemplate.parse()` がテンプレートを組み立て、`template.match(uri)` が抽出した変数を `dict` として返します。URI が合わなければ `None` です。URL のデコードは `match()` の内部で行われ、デコード後の値はパス安全性の検証なしにそのまま返されます。値は文字列として出てくるので、自分で変換してください(`int(matched["id"])`、`Path(matched["path"])`)。 +* **安全性チェックは自分で適用する。** `MCPServer` がデフォルトで実行する `..` と絶対パスのチェックは `mcp.shared.path_security` にあります。`read_manual_safely` は `MANUALS` に触れる前にそれらを呼び出します。パラメーターがファイルシステムのパスでない場合(ISBN や検索クエリなど)は、その値のチェックをスキップしてください。ポリシーは設定オブジェクト経由ではなく、ハンドラーごとに制御します。 +* **テンプレートの一覧も同じ情報源から。** クライアントは `resources/templates/list` を通じてテンプレートを見つけます。`str(template)` は元のテンプレート文字列を返すので、一覧とマッチャーは同じ 1 つの情報源を共有します。 + +## まとめ {#recap} + +* `{name}` は 1 つのセグメントにマッチし、`{+name}` はスラッシュを保持し、`{?a,b}` はクエリ文字列から値を取り出し、`{/name*}` はセグメントをリストに分割します。 +* 間に何もない 2 つの変数や、2 つ目の複数セグメント変数は、解析時に拒否されます。末尾の `{?...}`/`{&...}` のクエリ変数に束縛されるパラメーターは、Python のデフォルト値を宣言しなければなりません。 +* パラメーターに型注釈を付ければ(`order_id: int`)、SDK が変換します。 +* デフォルトのセキュリティポリシーは、ハンドラーの実行前に `..`、絶対パス、ヌルバイトを拒否します。リソースごとに上書きするには `security=ResourceSecurity(...)` を、サーバー全体で上書きするには `resource_security=` を使います。 +* ファイルシステムへのアクセスでは、`safe_join` が封じ込めの境界です。 +* 低レベルの `Server` では、`UriTemplate.parse()` で解析し、`.match()` でマッチングし、`mcp.shared.path_security` を自分で適用します。 diff --git a/i18n/ja/pages/translations.md b/i18n/ja/pages/translations.md new file mode 100644 index 0000000000..e0c3b6a0e6 --- /dev/null +++ b/i18n/ja/pages/translations.md @@ -0,0 +1,30 @@ +--- +translation: + sections: [f671b445b16e4f99, 3983a560eb2cece7, 004b3ee918529d8c, c6e2debf1da06eb7, 81d412ed5f399f94] + tool: 1 +--- +# 翻訳について {#translations} + +このドキュメントは英語で書かれています。より多くの人に役立ててもらうため、機械翻訳版も公開しています。このページでは、それが読者にとって何を意味するのか、そして改善に協力する方法を説明します。 + +## 利用できる言語 {#whats-available} + +翻訳版のドキュメントは現在、Deutsch、español、français、हिन्दी、日本語、한국어、português(Brasil)、русский язык、Türkçe、українська мова、简体中文、繁體中文の 12 言語で**プレビュー**として提供しています。各ページ上部の言語切り替えから選んでください。これらの言語で実績が得られれば、ほかの言語も追加されるかもしれません。 + +API リファレンスは翻訳されません。翻訳版のサイトからは、英語版の API リファレンスにリンクしています。 + +## 正となるのは英語版 {#english-is-the-source-of-truth} + +翻訳されたページと英語の原文が食い違う場合は、英語のページが正しいものです。翻訳版サイトのすべてのページの冒頭には、そのページの状態を示す次の 3 つの注記のいずれかが表示されます。 + +- **機械翻訳**:ページは自動的に翻訳されたもので、英語の原文へのリンクがあります。 +- **英語版より古い翻訳**:翻訳後に英語の原文が変更されたため、翻訳が追いつくまで一部の内容が古くなっている可能性があります。 +- **英語で表示**:そのページの最新の翻訳がないため、英語のテキストが表示されています。 + +## 翻訳の作り方 {#how-the-translations-are-made} + +翻訳されたページは、このリポジトリにあるツールが `docs/` 配下の英語ページから機械的に生成します。その際、言語ごとに人の手で書かれた 2 つの入力が指針になります。1 つはスタイルガイド(文体、トーン、表記、冗談や慣用句の扱い方)、もう 1 つは用語集(英語のまま残す用語と、それ以外の用語に対する必須の訳語・禁止する訳語)です。生成されたテキストを手で編集することはありません。改善はすべてこれらの入力に反映します。そうすることで、次にページを再生成したときにも改善が失われません。 + +## 翻訳の問題を報告する {#reporting-a-translation-problem} + +誤った用語、ぎこちない文、英語版にない内容が書かれた翻訳を見つけた場合は、言語、ページ、該当箇所を添えて [issue を作成](https://github.com/modelcontextprotocol/python-sdk/issues)してください。ネイティブスピーカーからの報告は特に貴重です。修正方法がわかっている場合は、[`i18n/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/i18n) 配下にあるその言語のスタイルガイド(`instructions.md`)または用語集(`glossary.json`)へのプルリクエストとして直接提案してください。次に翻訳を再生成したときに、影響を受けるすべてのページに修正が反映されます。英語のテキスト自体の問題は、ほかのドキュメントの変更と同様に、`docs/` 配下のページで修正します。 diff --git a/i18n/ja/pages/troubleshooting.md b/i18n/ja/pages/troubleshooting.md new file mode 100644 index 0000000000..caffc9d146 --- /dev/null +++ b/i18n/ja/pages/troubleshooting.md @@ -0,0 +1,404 @@ +--- +translation: + sections: [2efaecdef109a5c5, fcacd3e66b8635a4, 25323d737dcf0261, 4835ed1772f1d113, 137454d469c867f5, 6392596bd6df54f0, 41126fa9c4fe432f, 480b6d7897e30ab4, d83bb682e708dde0, ebbed3449c499db4, 323ef84f6b4bebde, 30fd31be74169d9a, 656943c6cb567218, c2dc3b1007d2e987, 7cf5386b997d04e9, 0b59feed8384456e, 0cba47bae78d04eb, 954dc21efdb532a3] + tool: 1 +--- +# トラブルシューティング {#troubleshooting} + +このページの見出しはすべて、SDK が出すエラーの文字列そのままです。その下に、エラーの意味と一手で済む直し方を書いてあります。トレースバックの最後の行(またはサーバーログ)をブラウザーのページ内検索で探し、該当する項目だけを読んでください。 + +いくつかの項目は、次の 1 つのサーバーを相手に動かしています。ツールが 1 つとテンプレート付きリソースが 1 つあり、どちらも知らない都市を渡されると例外を送出します。 + +```python title="server.py" +--8<-- "docs_src/troubleshooting/tutorial001.py" +``` + +このページで引用しているエラーは本物です。SDK 自身のテストスイートが、そのすべてを再現しています。 + +## `ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)` {#exceptiongroup-unhandled-errors-in-a-taskgroup-1-sub-exception} + +これは MCP のエラーではありません。anyio のノイズであり、本当のエラーは貼り付けたトレースバックの**最後の行**にあります。 + +`Client.__aenter__` はタスクグループを開始します。anyio はタスクグループから抜けていくものをすべて `ExceptionGroup` に包むので、`async with Client(...)` ブロックから抜け出した例外は、種類を問わず「すべて」この形で届きます。 + +```python +async def main() -> None: + async with Client(mcp) as client: + await client.read_resource("weather://Atlantis") +``` + +```text + + Exception Group Traceback (most recent call last): + | ... + | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception) + +-+---------------- 1 ---------------- + | Exception Group Traceback (most recent call last): + | ... + | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception) + +-+---------------- 1 ---------------- + | Traceback (most recent call last): + | ... + | mcp.shared.exceptions.MCPError: No forecast for 'Atlantis'. + +------------------------------------ +``` + +これに対してやることは 2 つです。 + +1. **一番下を読む。** 失敗の正体は `MCPError: No forecast for 'Atlantis'.` です。「その」文字列をこのページで探してください。 +2. **ブロックの内側で捕まえる。** `ExceptionGroup` が現れるのは、例外が `async with` から「抜け出した」ときだけです。内側で捕まえれば、同じ失敗は素の `MCPError` であり、グループはどこにもありません。 + +```python +async def main() -> None: + async with Client(mcp) as client: + try: + await client.read_resource("weather://Atlantis") + except MCPError as e: + print(e) # No forecast for 'Atlantis'. +``` + +!!! tip + 「接続」中の失敗(間違った URL、起動していないサーバー、このページの後ろに出てくる `421`)は `async with` そのものから抜け出すので、捕まえるための「内側」がありません。その場合は、グループの一番下を読んでください。 + +## `RuntimeError: Client must be used within an async context manager` {#runtimeerror-client-must-be-used-within-an-async-context-manager} + +`Client(...)` はオブジェクトを組み立てるだけです。`async with` に入るまで何も接続しないので、どのメソッドも拒否します。 + +```python +async def main() -> None: + client = Client(mcp) + tools = await client.list_tools() # RuntimeError +``` + +中に入ってください。`__aenter__` が接続です。 + +```python +async def main() -> None: + async with Client(mcp) as client: + tools = await client.list_tools() +``` + +`__aexit__` が切断です。だからこそ、呼び忘れる `client.close()` というものが存在しません。**[テスト](get-started/testing.md)** は、まさにこのパターンの上に組み立てられています。 + +## `Error executing tool : ` と `Unknown tool: ` {#error-executing-tool-name-message-and-unknown-tool-name} + +読んでいるのは**結果**であって、例外ではありません。`call_tool` は例外を送出しておらず、ツールが失敗しても送出することは決してありません。 + +サーバーが知らない都市で `forecast` を呼ぶと、ツールが送出した例外は、リクエストが「成功」と記された形で返ってきます。 + +```python +result.is_error # True +result.content # [TextContent(text="Error executing tool forecast: No forecast for 'Atlantis'.")] +result.structured_content # None +``` + +`Unknown tool: get_forecast` は、サーバーが登録したことのない名前に対する同じ形の結果です。不正な引数も同じように、関数が実行される前にツールの入力スキーマと照合されて拒否されます。 + +直すのはクライアント側です。**`result.is_error` を確認してください**。`call_tool` を `try/except` で囲んでも、これらはどれも捕まりません。捕まえるものがないからです。これは意図した設計であり、このページで身につけておくと一番役に立つ点です。呼び出しを選んだのは「モデル」なので、メッセージを受け取ってやり直す機会を得るのもモデルです。詳しくは **[エラーの処理](servers/handling-errors.md)** を参照してください。例外を「送出する」側の `MCPError` の経路も含めて説明しています。 + +## `TypeError: The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool` {#typeerror-the-tool-decorator-was-used-incorrectly-did-you-forget-to-call-it-use-tool-instead-of-tool} + +`@mcp.tool()` ではなく `@mcp.tool` と書いています。`tool()` はデコレーターの「ファクトリー」です。括弧がないと、Python は関数をその `name=` パラメーターに渡してしまいます。 + +```python +@mcp.tool # <- missing () +def forecast(city: str) -> str: + """Today's forecast for one city.""" + return f"{city}: Rain." +``` + +```text +TypeError: The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool +``` + +括弧を付けてください。`@mcp.resource(...)` と `@mcp.prompt()` も、同じ書き間違いに対して同じことを言います。 + +!!! note + これはモジュールが**インポート**された時点で送出されます。どのクライアントが接続するよりも前です。そのため、ホストがサーバーを「接続済みでツール 0 個」ではなく「起動失敗」(または「切断」)と表示している場合は、この形を疑ってください。自分で `python server.py` を実行し、トレースバックを読んでください。型チェッカーでも検出できます。関数は有効な `name=` ではないからです。 + +## `Tool already exists: ` {#tool-already-exists-name} + +2 つの登録が同じツール名を使いました。勝つのは**最初の**登録で、2 つ目は黙って捨てられます。「サーバーログ」に出るこの警告が唯一の合図です。 + +```python title="server.py" hl_lines="6 12" +--8<-- "docs_src/troubleshooting/tutorial002.py" +``` + +```text +WARNING mcp.server.mcpserver.tools.tool_manager: Tool already exists: forecast +``` + +`tools/list` が報告する `forecast` は 1 つで、それは `forecast_today` のほうです。どちらかの名前を変えてください。`MCPServer(..., warn_on_duplicate_tools=False)` は結果を変えずに警告だけを黙らせるので、有効のままにしておいてください。リソースとプロンプトにも同じ規則と同じログ行があります(`Resource already exists:`、`Prompt already exists:`)。 + +## ホストに表示されるツールが 0 個 {#my-host-lists-zero-tools} + +これにはエラー文字列がありません。だからこそ検索しにくいのです。SDK が登録済みのツールを `tools/list` から落とすことはないので、外側に向かって確認していきます。 + +* **サーバーはそもそも起動したか。** 括弧のない `@mcp.tool` はインポート時に例外を送出しますし、クラッシュしたサーバーは一部のホストでは空のサーバーによく似て見えます。自分で `python server.py` を実行してください。 +* **ツールは、ホストが動かしている `mcp` に載っているか。** 別のモジュールにある 2 つ目の `MCPServer(...)` は、別の空のサーバーです。ホストのコマンドが実際にどのオブジェクトをインポートしているか確認してください。 +* **2 つのツールが名前を共有していないか。** していれば、片方は消えています。サーバーログで `Tool already exists:` を探してください。 +* **ホスト側の一覧が古くないか。** 起動後に追加したツールは、`notifications/tools/list_changed` を処理するクライアントにしか届きません。ホストの再起動が手っ取り早い直し方です。 +* **退避される区間の外で、何かが `stdout` に書き込んでいないか。** 提供中、SDK は「フラッシュされた」迷子の stdout 出力を stderr に退避します(ベストエフォートです。標準ストリームを差し替える環境はそのまま提供されます)。しかし、それより前に stdout にフラッシュされた出力(エコーするラッパースクリプト、バッファリングなしのプロセスでのインポート時の `print()`)や、インタープリター終了時に吐き出されるバッファリング済みの `print()` は、プロトコルのストリームに載ってしまいます。ゴミが 1 行混じるだけでホストが接続を切ることがあり、一部のホストはそれを中身のないサーバーとして表示します。代わりに `logging` モジュールでログを出してください。ホスト側のチェックリストの残りは **[実際のホストに接続する](get-started/real-host.md)** にあります。 + +「無効な」ツール名は、このリストには「入りません」。規約に沿わない名前は警告をログに出しますが、ツールはそれでも登録され、一覧に載ります。 + +## `MCPError: Server returned an error response` {#mcperror-server-returned-an-error-response} + +サーバーが HTTP リクエストを門前払いし、そのボディが JSON-RPC ではなかったため、python の `Client` にはこの代用メッセージよりましなものを見せる手段がありません。 + +群を抜いて多い原因は、デプロイしたばかりの Streamable HTTP サーバーです。`transport_security=` なしの `streamable_http_app()`(および `mcp.run("streamable-http")`)は、デフォルトで **DNS リバインディング保護** が有効です。`Host` ヘッダーが localhost のリクエストだけを受け付けます。手元のノート PC では正しいデフォルトですが、実際のホスト名の裏では間違ったデフォルトです。 + +```python title="server.py" hl_lines="12" +--8<-- "docs_src/troubleshooting/tutorial003.py" +``` + +これをデプロイしてクライアントを向けると、接続はハンドシェイクで失敗します。 + +```python +async with Client("https://mcp.example.com/mcp") as client: + ... +``` + +```text +mcp.shared.exceptions.MCPError: Server returned an error response +``` + +サーバーが実際に送った言葉、`421` と `Invalid Host header` は、手元には届きません。421 のボディには `Content-Type: application/json` がないので、クライアントはそれをパースできないのです。それらは**サーバーのログ**にあります。次に見るべき場所はそこです。 + +```text +WARNING mcp.server.transport_security: Invalid Host header: mcp.example.com +``` + +直し方は `transport_security=` です。実際に提供するホスト名を許可リストに入れてください。 + +```python title="server.py" hl_lines="14-17" +--8<-- "docs_src/troubleshooting/tutorial004.py" +``` + +!!! check + 変更はこれだけです。まったく同じクライアントが今度は接続し、`2026-07-28` をネゴシエートして、`forecast` を呼び出します。 + +各フィールドの意味、リバースプロキシの場合、その他デプロイ時に変わることはすべて **[デプロイとスケール](run/deploy.md)** で扱っています。そして、すぐ下の `421 Misdirected Request` / `Invalid Host header` は、同じ失敗を反対側から見たものです。 + +## `421 Misdirected Request` / `Invalid Host header` {#421-misdirected-request-invalid-host-header} + +これは `Server returned an error response` を、python の `Client` 「以外」のもの、つまり curl、ブラウザーのネットワークタブ、リバースプロキシのアクセスログ、あるいは別の SDK から見たものです。 + +```bash +curl -i https://mcp.example.com/mcp \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}' +``` + +```text +HTTP/1.1 421 Misdirected Request + +Invalid Host header +``` + +`421 Misdirected Request` は、このステータスに対する HTTP 自体の理由句です。`Invalid Host header` は SDK のレスポンスボディです。そして python の `Client` は、同じ出来事を `Server returned an error response` として表示します。3 つとも 1 つの拒否です。チェックはサーバーがバインドしたアドレスではなく、**リクエストが運ぶ `Host` ヘッダー**に対して行われます。そのため、公開ホスト名を転送するリバースプロキシは、直接接続するクライアントとまったく同じようにこれに引っかかります。 + +直し方は `Server returned an error response` で示したのと同じ `transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])` です。境界的な挙動を 2 つ挙げておきます。 + +* `allowed_hosts` の項目は完全一致の文字列です。`"mcp.example.com"` はポートなしの `Host` ヘッダーに一致し、`"mcp.example.com:*"` は明示的なポートが付いたものすべてに一致します。両方を列挙してください。 +* ボディが `Invalid Origin header` の `403` は、`Origin` ヘッダーに対する兄弟分のチェックです。発動するのはブラウザーに対してだけで(`Origin` を送るものは他にありません)、その許可リストが `allowed_origins=` です。 + +チェックを無効にするのが正直な設定になるのはいつか、という点も含め、詳しくは **[デプロイとスケール](run/deploy.md)** を参照してください。 + +## `RuntimeError: Task group is not initialized. Make sure to use run().` {#runtimeerror-task-group-is-not-initialized-make-sure-to-use-run} + +MCP アプリが別の ASGI アプリの中にマウントされていて、その**セッションマネージャー**を起動するものが何もありません。 + +`mcp.streamable_http_app()` は、自身のライフスパンでマネージャーを起動する Starlette アプリを返します。`uvicorn server:app` はそのライフスパンを実行してくれます。しかし Starlette は**マウントされたサブアプリケーションのライフスパンを決して実行しません**。そのため、アプリを `Mount` の中に入れた瞬間にマネージャーは起動されなくなり、最初のリクエストで爆発します。 + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/troubleshooting/tutorial005.py" +``` + +サーバーは起動します。ルートも解決します。そのうえで、`uvicorn` はリクエストごとにこれを出力します。 + +```text +ERROR: Exception in ASGI application +Traceback (most recent call last): + ... +RuntimeError: Task group is not initialized. Make sure to use run(). +``` + +クライアントには 500 が見えます。直し方は、**ホスト**側のアプリに `mcp.session_manager.run()` に入るライフスパンを付けることです。 + +```python +@asynccontextmanager +async def lifespan(app: Starlette) -> AsyncIterator[None]: + async with mcp.session_manager.run(): + yield + + +app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lifespan) +``` + +これを扱うページは **[既存のアプリに追加する](run/asgi.md)** です。1 つのアプリに複数のサーバーを入れる場合や FastAPI の場合も含みます。同じクラスから出る隣接した文字列を 2 つ挙げます。 + +* `StreamableHTTPSessionManager .run() can only be called once per instance. Create a new instance if you need to run again.` マネージャーは使い切りです。同じアプリのライフスパンに 2 回入ると、これに当たります。 +* `mcp.session_manager` が存在するのは `streamable_http_app()` が呼ばれた**後**だけです。先にルートを組み立て、マネージャーにはライフスパンの中でだけ触れてください。 + +## `MCPError: Session not found` {#mcperror-session-not-found} + +クライアントが送った `Mcp-Session-Id` をサーバーが認識していません。ほぼ確実に、サーバーが**再起動した**(または別のインスタンスにルーティングされた)のが原因です。セッションは、その 1 つのプロセスのメモリの中にあります。 + +探すべきサーバーのバグはありません。HTTP レスポンスは `404` で、そのボディは JSON-RPC「です」。そのため上の `421` とは違い、python の `Client` はこれをそのまま見せてくれます。 + +```json +{"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "Session not found"}} +``` + +直し方は再接続です。`async with Client(...)` ブロックを抜けて新しいブロックに入れば、新しいセッションがネゴシエートされます。長く生きるクライアントであれば、呼び出しの周りで `MCPError` を捕まえ、死んだセッションの中でリトライするのではなく、このメッセージを見たら再接続することになります。 + +再起動「なしで」これが起きるなら、スティッキーセッションなしで複数のワーカーを動かしています。ワーカーごとに独自のセッションテーブルを持つので、間違ったワーカーにルーティングされたリクエストはここに行き着きます。この話とその 2 つの直し方(スティッキールーティング、または `stateless_http=True`)は、**[デプロイとスケール](run/deploy.md)** と **[レガシークライアントへの提供](run/legacy-clients.md)** が担当しています。 + +サーバー運用者向けには、対応するログ行は `Rejected request with unknown or expired session ID: ` です。`INFO` で記録されるので、通常の `WARNING` のしきい値では見えません。デプロイ直後にまとまって出るのは正常です。接続中のクライアントがすべて再接続しているのです。 + +## `MCPError: Method not found` {#mcperror-method-not-found} + +片側が、もう片側にハンドラーのない JSON-RPC リクエストを送りました。`e.error.data` にメソッド名が入っています。よくある原因は**世代の不一致**です。あるプロトコルリビジョンには存在して別のリビジョンには存在しないメソッドを、違う世代のピアに送った場合です。たとえば `2025` 年世代の `resources/subscribe` が `2026-07-28` の接続に届いたり、`2026` 専用の `subscriptions/listen` が `mode="legacy"` に固定されたクライアントから送られたりした場合です。どちら側が何を話すかの地図は **[プロトコルバージョン](protocol-versions.md)** にあります。もう 1 つの正当な原因(ハンドラーを登録しなかったオプションのケイパビリティ)は **[補完](servers/completions.md)** にあります。 + +モダンなプロトコルが削除したリクエストであるにもかかわらず、このエラーに**ならない**ものが 1 つあります。`2026-07-28` の接続でツールが `ctx.elicit()` を呼ぶ場合です。サーバーはそのリクエストを「送る」こと自体を拒否するので、代わりに得られるのは、このページの後ろに出てくる `Cannot send 'elicitation/create': ...` です。 + +## `MCPError: Client did not declare the form elicitation capability required by resolver ''` {#mcperror-client-did-not-declare-the-form-elicitation-capability-required-by-resolver-name} + +サーバーはユーザーに何かを尋ねたいのに、このクライアントは尋ねられることができると一度も言っていません。 + +エリシテーション(elicitation)のリゾルバーは、接続中のクライアントがフォームのエリシテーションを宣言していない場合、最初の時点で拒否します。`e.error.data` には、足りないものが正確に書かれています。 + +```json +{ + "code": -32021, + "message": "Client did not declare the form elicitation capability required by resolver 'server:ask_to_confirm'", + "data": {"requiredCapabilities": {"elicitation": {"form": {}}}} +} +``` + +`Client(...)` に `elicitation_callback=` を渡してください。コールバックの登録「が」ケイパビリティの宣言です。2 つ目のスイッチはありません。 + +```python +async def main() -> None: + async with Client(mcp, elicitation_callback=handle_elicitation) as client: + result = await client.call_tool("book_table", {"date": "Friday"}) +``` + +他のコールバック(`sampling_callback`、`list_roots_callback`)は **[クライアントのコールバック](client/callbacks.md)** に一覧があります。どれも同じように宣言を兼ねています。 + +!!! info + `-32021` は `MISSING_REQUIRED_CLIENT_CAPABILITY` で、2026-07-28 の仕様が追加した 3 つのエラーコードのうちの 1 つです。どれも例外クラスではありません。すべて `MCPError` として届き、見るべき場所は `e.error.code` です。定数は `mcp.types` がエクスポートしています。残りの 2 つは `-32020` `HEADER_MISMATCH`(HTTP ヘッダーが、それに伴うリクエストボディと食い違っている)と `-32022` `UNSUPPORTED_PROTOCOL_VERSION`(リクエストが、このサーバーの話さないバージョンを指定した)です。仕様に準拠した SDK クライアントはどちらも起こせないので、見かけたら、クライアントとサーバーの間でリクエストを書き換えている何かを調べてください。 + +## `MCPError: Elicitation not supported` {#mcperror-elicitation-not-supported} + +`Client did not declare the form elicitation capability ...` と同じ欠落を、最初の時点でチェックしない経路が綴ったものです。サーバーはエリシテーションへの回答を必要としたのに、接続中のクライアントは `elicitation_callback` を登録していませんでした。 + +これを目にするのは、レガシー接続での `ctx.elicit()` からです。また、どの接続であっても、返されたマルチラウンドトリップ(multi-round-trip)の質問(**[マルチラウンドトリップリクエスト](handlers/multi-round-trip.md)**)が、それに答えるコールバックのないクライアントに届いた場合にも目にします。直し方はまったく同じで、`Client(...)` に `elicitation_callback=` を渡してください。「ユーザーに尋ねなかった」ことをツールが `decline` として受け取る形はありません。尋ねられないクライアントは失敗した呼び出しなので、それを前提にツールを設計してください。 + +## `MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests.` {#mcperror-cannot-send-elicitationcreate-this-transport-context-has-no-back-channel-for-server-initiated-requests} + +ハンドラーがリクエストの途中でクライアントに連絡を取ろうとしましたが、その接続の呼び出しには、サーバーからのリクエストを運べるチャネルがありません。呼び出しをそこに置くサーバー設定は 3 つあります。 + +**`2026-07-28` の接続。どのトランスポートでも、常に。** モダンなプロトコルにはサーバー起点のリクエストがそもそも存在しないので、サーバーは何かを送る前に拒否します。ツールの中の `ctx.elicit()` が、これに出会う典型的な経路です(`Client(server)` は頼まれなくても `2026-07-28` をネゴシエートするので、最初のインメモリテストで出会います)。`elicitation_callback=` を渡しても何も変わりません。答えるべきリクエストがクライアントに届くことがないからです。 + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/troubleshooting/tutorial006.py" +``` + +```python +async def main() -> None: + async with Client(mcp) as client: + await client.call_tool("book_table", {"date": "Friday"}) +``` + +```text +mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests. +``` + +**`stateless_http=True` のサーバーでのレガシー接続。** ステートレスとは、すべてのリクエストがそれぞれ独立した世界だということです。セッションもサーバーからクライアントへのストリームもなく、したがって、それらを持つ世代であっても `elicitation/create`(または `sampling/createMessage`、または `roots/list`)を送る先がどこにもありません。 + +```python title="server.py" hl_lines="16 23" +--8<-- "docs_src/troubleshooting/tutorial008.py" +``` + +**`json_response=True` のサーバーでのレガシー接続。** `POST` には 1 つの JSON ボディで応答します。1 つのボディが運ぶのはレスポンスだけなので、リクエスト途中の `ctx.elicit()` が必要とするリクエストスコープのストリームは、ここにも存在しません。セッション、その `Mcp-Session-Id`、そしてスタンドアロンのストリームはすべて残っています。なくなったのはリクエストスコープのチャネルだけです。 + +メッセージには、送れなかったメソッド名が入っています。サーバーが送出するクラスは `NoBackChannelError` ですが、通信路が運ぶのは基底の `MCPError` だけなので、トレースバックの最後の行はクラス名ではなく上の文章です。 + +`2026-07-28` のクライアントに対しては、直し方は 3 つとも同じです。呼び出しの途中で連絡を取り返さないことです。質問を**リゾルバー**に移す(または自分で `InputRequiredResult` を返す)と、質問は「レスポンス」の一部になり、どの接続でも運べます。 + +```python title="server.py" hl_lines="15-17 21" +--8<-- "docs_src/troubleshooting/tutorial007.py" +``` + +同じ質問、クライアント側も同じ `elicitation_callback` です。違いは内部にあります。リゾルバーを使うと、サーバーは質問を押し込むのではなく呼び出しから「返す」ことができるので、サーバーからクライアントへ流れるものは何もなくなります。これで、サーバーが 3 つの設定のどれであっても、すべての `2026-07-28` クライアントが救われます。「レガシー」クライアントは、書き換えだけでは救われません。`2025-11-25` には質問を返す手段がないので、レガシー接続ではリゾルバーは依然として `elicitation/create` をリクエストスコープのチャネルに送ります。そのため、そのチャネルを保持するサーバー、つまり `stateless_http=True` でも `json_response=True` でもないサーバーが依然として必要です。リゾルバーについては **[エリシテーション](handlers/elicitation.md)**、通信路上で何が起きるかについては **[マルチラウンドトリップリクエスト](handlers/multi-round-trip.md)** を参照してください。 + +!!! check + `ctx.elicit()` を使うツールは間違っているのではなく、「2026 年より前」のものです。`mode="legacy"`(従来の `initialize` ハンドシェイク、仕様 `2025-11-25` 以前)で、`stateless_http=True` でも `json_response=True` でもないサーバーに接続すれば動きます。そこにはサーバーからクライアントへのチャネルが存在するからです。各バージョンが何を持つかについては **[プロトコルバージョン](protocol-versions.md)** を参照してください。 + +## `MCPError: Invalid or expired requestState` {#mcperror-invalid-or-expired-requeststate} + +クライアントがエコーバックした `requestState` トークンをサーバーが検証できなかったため、そのラウンドを拒否しました。 + +`requestState` は、**[マルチラウンドトリップ](handlers/multi-round-trip.md)**の呼び出しが区間と区間の間で運ぶ、不透明な再開トークンです。`MCPServer` は送り出すときにこれを封印し、すべてのエコーを検証します。さらに、トークンを発行しないハンドラーに対してであっても、`tools/call`、`prompts/get`、`resources/read` に届く「すべての」`request_state` を検証します。そのため、このプロセスが封印していないトークンは、どこに届いても拒否されます。 + +```python +async def main() -> None: + async with Client(mcp) as client: + await client.call_tool("forecast", {"city": "London"}, request_state="round-1-from-worker-a") +``` + +```text +mcp.shared.exceptions.MCPError: Invalid or expired requestState +``` + +メッセージは意図的に固定されています。どのチェックが失敗したかは、通信上には決して現れません。理由は**サーバーログ**に行くので、それを読むことが診断のすべてです。 + +```text +WARNING mcp.server.request_state: requestState rejected on tools/call: malformed +``` + +実際に目にする理由は次のとおりです。 + +* **`unknown key`** が重要です。デフォルトの封印キーはプロセス起動時に生成されるので、**別のワーカー**、ロードバランサーの裏の別のインスタンス、あるいは**再起動後の**同じサーバーに届いたリトライは、このプロセスが一度も持ったことのないキーで封印されています。これは攻撃者ではなく、デフォルトが複数のプロセスに出会っただけです。 +* **`audience`**:トークンは「別のサーバー名」を持つインスタンスによって封印されました。名前は封印のデフォルトの audience クレームなので、フリートはキーだけでなく名前も共有する(または明示的な `RequestStateSecurity(audience=...)` を設定する)必要があります。 +* **`expired`**:ラウンドが封印の `ttl` より長くかかりました。これは 600 秒で、呼び出しごとではなくラウンドごとです。 +* **`malformed`** / **`codec error`**:トークンが転送中に改変されたか、そもそも封印されたトークンではありませんでした。 +* **`request binding`**:トークンが、別のツール、別の引数、または別のメソッドとともに戻ってきました。 + +マルチプロセスの直し方は、引数 1 つ(すべてのインスタンスで「同じ」`keys`)に加えて、引数ですらないものが 1 つです。同じサーバー「名」(または明示的に共有した `audience=`)です。 + +```python +mcp = MCPServer("Weather", request_state_security=RequestStateSecurity(keys=[key])) +``` + +封印するのは `keys[0]` で、リスト内のすべてのキーが検証します。これがダウンタイムなしのローテーションを可能にしています。封印が何を守るのかとローテーションの手順は **[マルチラウンドトリップリクエスト](handlers/multi-round-trip.md#protecting-requeststate)** で説明しています。2 ワーカーでの失敗の一部始終とその 2 段構えの直し方は **[デプロイとスケール](run/deploy.md)** でたどっています。 + +!!! tip + `keys=[...]` は弱いキーを即座に拒否し、珍しく親切なメッセージを出します。 + + ```text + ValueError: request-state keys must be at least 32 bytes of secret randomness; keys[0] is 7 bytes. Generate one with: python -c "import secrets; print(secrets.token_hex(32))" + ``` + + 書いてあるとおりにしてください。 + +## まだ解決しない場合 {#still-stuck} + +* SDK が出したメッセージがこのページにないなら、それ自体が報告する価値のあるドキュメントのバグです。 +* [イシュートラッカー](https://github.com/modelcontextprotocol/python-sdk/issues)を検索してください。そこに出てくるエラー文字列の大半は、すでに誰かがまとめています。 +* 何も見つからない場合は、完全なトレースバックを添えて[イシューを開く](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml)か、[MCP Contributors Discord の #python-sdk-dev](https://discord.gg/6CSzBmMkjX) で尋ねてください。 + +## まとめ {#recap} + +* `ExceptionGroup: unhandled errors in a TaskGroup` がエラーであることは決してありません。**最後の行**を読んでください。`async with Client(...)` ブロックの「内側」で `MCPError` を捕まえれば、包まれること自体を完全に避けられます。 +* `call_tool` は、ツールが失敗しても例外を送出しません。`Error executing tool ...` と `Unknown tool: ...` は結果です。`result.is_error` を確認してください。 +* `Client must be used within an async context manager` -> `async with` を使ってください。`Use @tool() instead of @tool` -> 括弧を付けてください。 +* サーバーログの `Tool already exists:` は、同名の 2 つのツールが 1 つに潰れた唯一の合図です。 +* 1 つの 421、3 つの綴り:`Server returned an error response`(python の `Client`)、`421 Misdirected Request` / `Invalid Host header`(それ以外すべて)、`Invalid Host header: `(サーバーログ)。直し方:`transport_security=TransportSecuritySettings(allowed_hosts=[...])`。 +* `Task group is not initialized` -> マウントされたアプリで、ホストのライフスパンが `mcp.session_manager.run()` に入っていません。 +* `Session not found` -> サーバーが再起動しました。再接続してください。 +* `Cannot send 'elicitation/create': ... no back-channel ...` -> `ctx.elicit()` にはサーバーからクライアントへのチャネルが必要です。`2026-07-28` の接続にはそれが決してなく、`stateless_http=True` はレガシーのチャネルを奪い、`json_response=True` はリクエストスコープのチャネルを奪います。リゾルバーを使ってください(レガシークライアントには、チャネルを保持するサーバーも必要です)。隣の `Method not found` は、相手側のプロトコルリビジョンにないメソッドへのリクエストです。 +* `Client did not declare the form elicitation capability ...` と `Elicitation not supported` -> クライアントに `elicitation_callback=` が足りません。 +* `Invalid or expired requestState` は、通信上では決して理由を言いません。サーバーログが言います。`unknown key` は、ワーカー間で `RequestStateSecurity(keys=[...])` を共有せよという意味です。 diff --git a/i18n/ja/pages/whats-new.md b/i18n/ja/pages/whats-new.md new file mode 100644 index 0000000000..c145a4bf0e --- /dev/null +++ b/i18n/ja/pages/whats-new.md @@ -0,0 +1,206 @@ +--- +translation: + sections: [cfe01c0c5863dfa2, 11d93f1fa09eadf5, a7392996acf1ad8f, 875eb2889263424e] + tool: 1 +--- +# v2 の新機能 {#whats-new-in-v2} + +v2 では 2 つのことが同時に起こりました。1 つは **SDK の再構築**です。クライアントとサーバーの両方の下に新しいエンジンが入り、第一級の `Client` が加わり、v1 のコードベースが最初のインポートでぶつかる一連の名前変更があります。もう 1 つは**プロトコルの移行**です。v2 が話すのは MCP の 2026-07-28 リビジョンで、このリビジョンは接続のハンドシェイク、セッション、そしてサーバー起点のリクエストをすべて取り除きます。それでも、すでに使われているクライアントを置き去りにはしません。 + +このページはその両方を巡るツアーです。見出しごとに 1 つのセクションを設け、それぞれの最後にそのトピックを扱うページを示します。移植の手順書ではありません。それは**[移行ガイド](migration.md)**の役目で、すべての破壊的変更を変更前と変更後のコード付きで載せています。 + +!!! note "v2 が安定版の系列" + `pip install mcp` は 2.x をインストールします。コピーしてそのまま貼り付けられるインストールコマンドは**[インストール](get-started/installation.md)**にあります。v2 で何かが壊れたり、意外な動きをしたり、作業の妨げになったりしたら、[知らせてください](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml)。 + +## SDK:v1 から v2 へ {#the-sdk-v1-to-v2} + +### `FastMCP` は `MCPServer` になった {#fastmcp-is-now-mcpserver} + +高レベルのサーバークラスは名前が変わり、モジュールも一緒に変わりました。古いインポートパスは非推奨になったのではなく削除されたので、どの v1 サーバーも最初にここでつまずきます。 + +```python +from mcp.server import MCPServer # v1: from mcp.server.fastmcp import FastMCP + +mcp = MCPServer("Demo") # v1: FastMCP("Demo") +``` + +デコレーターで組み立てたサーバーなら、移植作業の大半もこれで終わりです。`@mcp.tool()`、`@mcp.resource()`、`@mcp.prompt()` は v1 で受け付けていたものをそのまま受け付け(`@mcp.resource()` には省略可能な `security=` キーワードが 1 つ加わりました)、入力スキーマも引き続き型ヒントから作られます。周辺の変更は次のとおりです。`mcp.server.fastmcp.*` の下にあったものはすべて `mcp.server.mcpserver.*` の下に移りました。`ctx.fastmcp` は `ctx.mcp_server` になり、`get_context()` は削除されました(代わりに `ctx: Context` パラメーターを宣言してください)。例外の基底クラス `FastMCPError` は `MCPServerError` です。インポートの対応表は**[移行ガイド](migration.md#fastmcp-renamed-to-mcpserver)**にあります。 + +### `Resolve`:ユーザーに入力を求める新しい方法 {#resolve-the-new-way-to-ask-the-user-for-input} + +ツールが必要とするものを、すべてモデルから受け取るべきとは限りません。v2 の新機能として、`Resolve(fn)` で注釈したツールのパラメーターは、代わりに自分で書いた関数によってモデルからは見えない形で埋められます。その関数は `Elicit(...)` を返して、ユーザーに質問を提示できます。呼び出しの途中でクライアントから何かを得るには、これが推奨の方法です。SDK は接続が対応している仕組みに乗せて質問を運びます。レガシークライアントにはその場で送るエリシテーション(elicitation)リクエスト、2026-07-28 ではマルチラウンドトリップ(multi-round-trip)です。そのため、1 つのツール本体で両方の世代に対応できます。詳しくは**[依存関係](handlers/dependencies.md)**を参照してください。 + +!!! note + 必要なときのために、ほかの 2 つの形も残っています。`ctx.elicit()` はレガシー接続のクライアントに対して引き続き動作します(**[エリシテーション](handlers/elicitation.md)**)。また、ハンドラーが自分で `InputRequiredResult` を返してラウンドを手動で進めることもでき、2026-07-28 でサンプリングやルート(roots)のリクエストが運ばれるのもこの方法です(**[マルチラウンドトリップリクエスト](handlers/multi-round-trip.md)**)。 + +### 第一級の `Client` {#a-first-class-client} + +v1 では 3 つの層が入れ子になっていました。生のストリームを返すトランスポートのコンテキストマネージャー、それを包む `ClientSession`、そして手で呼び出す `await session.initialize()` です。v2 にあるのはオブジェクト 1 つです。 + +```python title="client.py" hl_lines="14-18" +--8<-- "docs_src/client/tutorial001.py" +``` + +`Client` が受け取るのは、サーバーオブジェクト(インメモリでトランスポートなし。テストで使う形です)、URL(Streamable HTTP)、または `stdio_client(...)` のような任意のトランスポートのコンテキストマネージャーです。`async with` に入ると接続し、サーバーがどの世代を話すかにかかわらずプロトコルバージョンをネゴシエートします。その後は `client.server_capabilities` と `client.protocol_version` がそのまま使え、サーバーが自身を名乗る場合は `client.server_info` も使えます(2026 年世代では識別情報が省略可能なので、`Implementation | None` になりました)。v1 で登録したサンプリングとエリシテーションのコールバックは引き続き動作します(コールバックの本体には、このページのほかの項目と同じ snake_case への属性名の変更が及びます)。加えて 2026 形式の「結果に埋め込まれたリクエスト」(後述)にも応答するようになり、1 つずつではなく並行して実行されます。低レベルのインターフェースが必要な人のために `ClientSession` は今も下にあり、`client.session` で取り出せます。ただしこちらも変わっています(新しいディスパッチャーエンジンの上で動き、自身のシグネチャも一部変わりました)。下りていく前に**[移行ガイド](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)**を読んでください。 + +**[Client](client/index.md)** で紹介し、**[クライアントのトランスポート](client/transports.md)**で 3 つの接続形態を、**[クライアントのコールバック](client/callbacks.md)**でコールバックそのものを扱います。**[テスト](get-started/testing.md)**では、v1 の `create_connected_server_and_client_session()` ヘルパーに代わるインメモリのパターンを示します。 + +### 低レベルの `Server` は改名ではなく再構築 {#the-low-level-server-was-rebuilt-not-renamed} + +JSON-RPC の層で作業しているなら、ここが v2 の「すべてが違う」部分です。ツールが 1 つの同じサーバーを両方の書き方で示します。何が移ったかは、マーカーをクリックして確認してください。 + + + +```python title="v1" +from typing import Any + +import mcp.types as types +from mcp.server.lowlevel import Server + +server = Server("Bookshop") + + +@server.list_tools() # (1)! +async def list_tools() -> list[types.Tool]: + return [ # (2)! + types.Tool( + name="search_books", + description="Search the catalog by title or author.", + inputSchema={ # (3)! + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + }, + ) + ] + + +@server.call_tool() +async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentBlock]: # (4)! + if name != "search_books": + raise ValueError(f"Unknown tool: {name}") # (5)! + ctx = server.request_context # (6)! + return [types.TextContent(type="text", text=f"Found 3 books matching {arguments['query']!r}.")] # (7)! +``` + +1. ハンドラーはデコレーター(括弧を付けて呼び出す形)で登録します。サーバーができた後ならいつでもかまいません。 +2. 素の `list[Tool]` を返すと、SDK が `ListToolsResult` に包みます。 +3. フィールドは Python でも camelCase で、スキーマは**強制されます**。関数が動く前に、SDK が `call_tool` の引数をこのスキーマに対して jsonschema で検証します。下の `arguments["query"]` が安全なのはそのためです。 +4. 1 つの `call_tool` ハンドラーがすべてのツールを受け持ち、ツール名と検証済みの引数を受け取ります。引数は展開済みで、`None` になることはありません。 +5. v1 のツールは例外の送出で失敗を伝えます。どんな例外も捕捉され、`str(e)` をテキストにした `CallToolResult(isError=True)` として返されるので、呼び出し側のモデルはこのメッセージを読んで再試行できます。 +6. コンテキストは暗黙の ContextVar から来ており、リクエストの途中でサーバーオブジェクトを通じて取り出します。 +7. 素のコンテンツブロックは自動で `CallToolResult` に包まれます。 + +```python title="v2" +--8<-- "docs_src/whats_new/tutorial001.py" +``` + +1. フィールドは snake_case になり、スキーマは**公開されるだけで適用はされません**。ハンドラーが動く前に引数を検査するものは何もありません。 +2. どのハンドラーも `async (ctx, params) -> result` という同じ形です。コンテキストは第 1 引数で(`ctx.session`、`ctx.request_id`、`ctx.protocol_version` はここにあります)、`server.request_context` の行き先はここです。 +3. 完全な `ListToolsResult` を自分で組み立てます。素のリストを返しても SDK は包んでくれず、サーバー側の `TypeError` になります。 +4. 型付きの params が入り(`params.name`、`params.arguments`)、完全な結果が出ていきます。展開も、包みも、変換も自動では行われません。 +5. 検査は同じで、手段が違います。ここで `ValueError` を送出すると、モデルには中身の見えない `-32603` として届きます(後述)。そのため、意図した通信上のエラーは `MCPError` として送出します。コードとメッセージはそのまま通り抜け、このテキストを添えた `-32602` は未知のツールに対する仕様自身の答えです。 +6. `params.arguments` は `None` のことがあります。v1 では、コードに届く前に既定値の `{}` が入っていました。ハンドラーの前に検証がないので、この行は欠かせません。 +7. ここで送出された予期しない例外は、**無害化された**プロトコルエラー `-32603` `"Internal server error"` になり、モデルがメッセージを見ることはありません。モデルに読ませて対応させたい失敗には、`CallToolResult(is_error=True, ...)` を返してください。 +8. ハンドラーはコンストラクターの引数なので、サーバーのインターフェースはできた瞬間に完成しています。`add_request_handler()` は構築後に使える抜け道であり、カスタムメソッドへの入り口でもあります。 + +この例がそのままパターンです。より一般的に言うと、次のとおりです。どのハンドラーも同じ形で、型付きの params が入り、完全な結果型が出ていきます。ツール引数に対する以前の jsonschema 検査はなくなりました。例外はプロトコルエラーであり、`is_error=True` のツール結果になることはありません。暗黙の `server.request_context` ContextVar もなくなりました。ベンダーの名前空間を持つカスタムメソッドは `add_request_handler(method, params_type, handler)` によって第一級の扱いになり、ハンドラーが動く前に、受信した params が渡したモデルに照らして検証されます。そして `middleware` リスト(意図的に暫定扱いとしています)がすべての受信メッセージを包み、これまで上書きの対象になっていた非公開の `_handle_*` メソッドを置き換えます。 + +その下では、v1 の `BaseSession` の受信ループが、クライアントとサーバーが共有するディスパッチャーエンジンに置き換わりました。このページのいくつかの事柄が同時に成り立つのは、このエンジンのおかげです。1 つの `Server` オブジェクトが両方のプロトコル世代を受け持ちます。`Client(server)` は JSON-RPC のフレーミングなしにプロセス内でディスパッチします。そしてタイムアウトしたクライアントのリクエストは、サーバー側のハンドラーを実際にキャンセルするようになりました。 + +詳しくは**[低レベルの Server](advanced/low-level-server.md)** を参照してください。削除されたフックは**[移行ガイド](migration.md#lowlevel-server-decorator-based-handlers-replaced-with-constructor-on_-params)**が 1 つずつたどります。`MCPServer` より下に下りたことがなければ、どれも影響しません。 + +### 通信用の型は `mcp-types` に移り、フィールドはすべて snake_case に {#the-wire-types-moved-to-mcp-types-and-every-field-is-snake_case} + +プロトコルの型は、独立したディストリビューション `mcp-types` に置かれるようになりました。依存するのは pydantic と typing-extensions だけなので、ゲートウェイやプロキシ、コードジェネレーターは HTTP スタックをインストールせずに MCP の通信上の形を扱えます。そうしたプロジェクトは `mcp-types` をインストールして `mcp_types` をインポートします。`mcp` 自体はそのパッケージに厳密に一致するバージョンで依存し、再公開しています。そのため SDK に依存するコードは、これまでどおり `import mcp.types as types` や `from mcp.types import Tool` と書き(恒久的なエイリアスで、どの名前も同じオブジェクトです)、本当の依存先である `mcp` だけを宣言します。目安は、実際に依存しているパッケージを通じてインポートすることです。 + +これらの型では、Python の属性がすべて snake_case になりました。`result.is_error`、`tool.input_schema`、`listing.next_cursor` のような形です。実際に送受信される JSON はこれまでとまったく同じ camelCase で、変わったのは属性のつづりだけです。より厳格なデフォルトも 2 つ付いてきます。未知のフィールドはそのまま往復させずに無視されます(追加の情報は `_meta` に入れてください)。そして両側とも、ネゴシエートしたプロトコルバージョンに照らして通信を検証します。名前変更の対応表は**[移行ガイド](migration.md#field-names-changed-from-camelcase-to-snake_case)**を参照してください。 + +### トランスポートの設定は `run()` へ {#transport-configuration-moved-to-run} + +`MCPServer(...)` が扱うのは、サーバーが「何であるか」です。名前、インストラクション、ライフスパン、認証がそうです。「どう配信するか」は `run()` とアプリビルダーの役目になりました。`host`、`port`、`stateless_http`、`json_response`、エンドポイントのパス、`transport_security` の移った先がそこです(`MCPServer("x", port=9000)` は `TypeError` です)。オーバーロードはトランスポートごとに型付けされているので、`stdio` が取るオプションと `streamable-http` が取るオプションはエディターが教えてくれます。知っておきたい削除が 1 つあります。`mount_path` はなくなりました。プレフィックスの下で配信するには、ASGI アプリをマウントするのがサポートされた方法です。 + +オプションは**[サーバーの実行](run/index.md)**、マウントは**[既存のアプリに追加する](run/asgi.md)**で扱います。 + +### インポートエラーなしに変わる動作 {#behavior-that-changes-without-an-import-error} + +名前の変更は自分から存在を知らせてくれます。次のものは知らせてくれません。 + +* **同期関数はワーカースレッドで動きます。** `def` のツール(リソース、プロンプト、リゾルバーも同様)はイベントループをブロックしなくなりました。その代わり、本体はイベントループのスレッド上では動かなくなったので、特定のスレッドに縛られたコードには影響します。`async def` のハンドラーはそのままです。詳しくは**[移行ガイド](migration.md#sync-handler-functions-now-run-on-a-worker-thread)**を参照してください。 +* **ツールの中で送出した `MCPError`(v1 の `McpError`)はプロトコルエラーになりました。** モデルがそれを見ることはありません。ほかの例外はすべて、これまでどおりモデルが読んで対応できる `is_error=True` の結果になります。この切り分けは**[エラーの処理](servers/handling-errors.md)**で説明しています。 +* **結果は送り出す前に検証されます。** `input_schema` が `{}` の手組みの `Tool` は、`tools/list` で失敗するようになりました(仕様は `"type": "object"` を要求します)。`@mcp.tool()` で作ったサーバーがこれに出会うことはありません。スキーマは SDK が書くからです。 +* **クライアントは受け取ったものを検証します。** `list_tools()` と `call_tool()` は、ネゴシエートしたプロトコルバージョンに照らしてサーバーの応答を検査します。そのため、v1 の寛容なパースが見逃していた「少しだけ不正な」サーバーは `pydantic.ValidationError` を送出するようになりました。自分で管理していないサーバーに接続するなら、そうしたサーバーを見つけるのは自分だと思っておいてください。詳しくは**[移行ガイド](migration.md#client-validates-inbound-traffic-against-the-protocol-schema)**を参照してください。 +* **URI テンプレートは本物の RFC 6570 になりました。** `{+path}`、`{?query}` などが使え、マッチングは正規表現的な緩さではなく厳密になり、取り出した値に含まれるパストラバーサルはデフォルトで拒否されます。厳格になったテンプレートは、最初のリクエストではなくデコレーターの適用時に失敗します。詳しくは **[URI テンプレート](servers/uri-templates.md)**を参照してください。 +* **Streamable HTTP のライフスパンは 1 回だけ**、起動時に実行され、その状態はすべてのセッションとリクエストで共有されます。v1 ではセッションごとに 1 回、`stateless_http=True` ではリクエストごとに 1 回実行されていました。ライフスパンで作るプールやキャッシュは劇的に安くなります。そこで接続ごとのリソースを取得していたものは、ハンドラー本体に移してください。詳しくは**[ライフスパン](handlers/lifespan.md)**を参照してください。 +* **`mcp dev` と `mcp install` は、起動する環境を**インストール済みの SDK バージョンに固定します。どちらのコマンドもサーバーを新しい `uv run --with ...` 環境で実行しますが、以前はその環境で `mcp` が開発対象のバージョンではなく最新の安定リリースに解決されていました。詳しくは**[移行ガイド](migration.md#mcp-dev-and-mcp-install-pin-the-spawned-environment-to-your-sdk-version)**を参照してください。 +* **HTTP クライアントは `httpx` ではなく `httpx2` になりました。** 依存関係の入れ替えによって、コードが捕捉したり渡したりするもの(`httpx2.AsyncClient`、`httpx2.ConnectError`)が変わり、TLS 証明書の検証方法も変わります。`httpx2` は certifi 同梱の CA リストではなく、`truststore` を通じてオペレーティングシステムのトラストストアに照らして検証します。ほとんどの環境では気づくこともありません。システムの CA ストアを持たない最小構成のコンテナや、certifi のバンドルだけが知っていたプライベート CA では、TLS ハンドシェイクが失敗し始めます。`SSL_CERT_FILE`/`SSL_CERT_DIR` を設定するか、クライアントに `verify=ssl_context` を渡してください。詳しくは**[移行ガイド](migration.md#httpx-and-httpx-sse-replaced-by-httpx2)**を参照してください。 + +### 完全に削除されたもの {#removed-outright} + +次の項目には、それぞれ**[移行ガイド](migration.md)**のセクションがあります。 + +* **WebSocket トランスポート**(クライアント側とサーバー側の両方)と `mcp[ws]` extra です。MCP 仕様の一部だったことは一度もありません。 +* **実験的な Tasks** API(`mcp.*.experimental`)です。2026-07-28 はタスクをコアプロトコルの外に出して公式の拡張([SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663))に移しており、この SDK はまだそれを実装していません。 +* インポートパスとしての `mcp.shared.version`、`mcp.shared.progress`、`mcp.shared.session`(v1 の `message_handler` の注釈がインポートしていた `RequestResponder` スタブを含む)です。(`mcp.types` は削除されていません。独立した `mcp_types` パッケージの恒久的なエイリアスとして残っています。) +* 非推奨だった `streamablehttp_client` というつづりと、`streamable_http_client` の `get_session_id` コールバックです(この関数が返すストリームはちょうど 2 つになりました)。 +* `McpError` です。**`MCPError`** に改名され、`(code, message, data)` を直接受け取るコンストラクターになりました。 +* `MCPServer.get_context()`、`mount_path=`、そして低レベル `Server` のデコレーターメソッド、ContextVar、ハンドラーの辞書です。 + +## プロトコル:2025-11-25 から 2026-07-28 へ {#the-protocol-2025-11-25-to-2026-07-28} + +v2 は 2026-07-28 リビジョンを実装し、しかも**両方の**リビジョンを同時に扱います。同じ `streamable_http_app()`(と同じ stdio サーバー)が、2025 年世代のクライアントの `initialize` にも 2026 年世代のクライアントのリクエストにも応答します。設定するものも、切り替えるフラグも、別のデプロイも要りません。新しいリビジョンに対応しても、古いリビジョンのクライアントが置き去りになることはありません。ここから先は、新しいリビジョン自体が何を変えるのかを説明します。 + +### ハンドシェイクもセッションもない {#no-handshake-no-session} + +2026-07-28 のクライアントは、接続を開いてネゴシエートしてから話し始める、ということをしません。どのリクエストもプロトコルバージョン、クライアント情報、クライアントのケイパビリティを `_meta` に載せて運びます。唯一のディスカバリー呼び出しである `server/discover` も、ほかと変わらない普通のリクエストです。`Client` はデフォルトで正しく振る舞います。`server/discover` を一度試し、サーバーが古ければ `initialize` のハンドシェイクにフォールバックします。 + +Streamable HTTP では、2026 の経路に `Mcp-Session-Id` がありません。運用面での目玉はこれです。**新世代のリクエストをワーカーに結び付けるものが何もない**ので、単純なラウンドロビンのロードバランサーの後ろにあるどのレプリカでも応答できます。正直に言っておくべき但し書きが 2 つあります。2025 年世代のクライアント(今日ではほとんどのクライアントがそうです)は引き続きセッションを開き、v1 で必要だったのと同じスティッキネスを引き続き必要とします。それらについては何も変わりません。そして、マルチラウンドトリップの再試行がワーカーをまたいで運ばなければならない唯一のものは封印された `request_state` で、そのデフォルトの鍵はプロセスごとに生成されます。そのため、スケールアウトしたデプロイでは `RequestStateSecurity(keys=[...])` を渡します。(`stateless_http=True` は無関係です。2025 年世代のクライアントの扱い方にだけ影響し、2026 の通信がそれを読むことはありません。v1 ですでに設定しているなら、何も変わりません。) + +クライアント側の話は**[プロトコルバージョン](protocol-versions.md)**、運用者向けのチェックリスト(Host の許可リスト、`request_state` の鍵、レプリカをまたぐ通知)は**[デプロイとスケール](run/deploy.md)**、両方の世代を同時に扱う話は**[レガシークライアントへの対応](run/legacy-clients.md)**にあります。 + +### サーバーはクライアントを呼び出せない:マルチラウンドトリップリクエスト {#the-server-cannot-call-the-client-multi-round-trip-requests} + +2026-07-28 では、サーバー起点のリクエストはすべてなくなりました。プッシュ型のエリシテーション、サンプリング、`roots/list` です。2026 の接続にはそれらのためのチャネルがないので、`ctx.elicit()` と `ctx.session.create_message()` はそこでは `NoBackChannelError` で失敗します(レガシークライアントに対しては引き続き動作します)。 + +代わりの仕組みは呼び出しの向きを逆にします。ユーザーから何かを必要とするツールは質問を「返し」(`InputRequiredResult`)、クライアントはこれまでと同じコールバックでそれに答え、答えを添えて呼び出しが再試行されます。そのループは `Client` が回します。サーバー側で結果を自分で組み立てることはめったにありません。**[依存関係](handlers/dependencies.md)**がやってくれるからです。パラメーターを `Resolve(ask_quantity)` で注釈します(`ask_quantity` は自分で書く普通の関数です)。すると SDK は接続が対応している仕組み、つまりレガシーセッションならその場で送るエリシテーションリクエスト、2026 ならマルチラウンドトリップで質問します。ツール本体は 1 つ、世代は両方です。 + +```python title="dual_era.py" hl_lines="24 37-38" +--8<-- "docs_src/legacy_clients/tutorial001.py" +``` + +このファイル 1 つに要点が詰まっています。1 つのサーバー、`Resolve` に支えられた 1 つのツール、そしてレガシークライアントと新世代のクライアントの両方がインメモリで答えを受け取ります。仕組み(SDK が封印と検証を行う `request_state` を含む)は**[マルチラウンドトリップリクエスト](handlers/multi-round-trip.md)**が説明し、質問のしかたは**[エリシテーション](handlers/elicitation.md)**が扱います。 + +!!! warning "移植した v1 サーバーの動作が変わる唯一の場所" + 最初にぶつかるのは自分のテストです。`Client(mcp)` はデフォルトで v2 サーバーに対して 2026-07-28 をネゴシエートするので、`ctx.elicit()` を呼ぶツールは v1 で通っていたテストで失敗します。質問を `Resolve(...)` パラメーターに移す(世代をまたいで使えます)か、本当にプッシュ型の動作が欲しいならテストクライアントを `mode="legacy"` に固定してください。 + +### ルート、サンプリング、プロトコルのロギングは非推奨、`ping` は削除 {#roots-sampling-and-protocol-logging-are-deprecated-ping-is-removed} + +[SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) は、すべてのプロトコルバージョンで 3 つの「ケイパビリティ」をまるごと非推奨にします。ルート、サンプリング、MCP レベルのロギング(`ctx.info()` など)です。これは上で述べたバックチャネル(back-channel)の欠如とは別の軸です。非推奨は勧告にすぎず、2025 年世代のセッションに対してはすべてが動き続け、通信上は何も変わりません。気づくのは `MCPDeprecationWarning` です。これは `UserWarning` なのでデフォルトで表示されます。アップグレード後の最初の `ctx.info(...)` がそう告げると思っておいてください。 + +`ping` はもっと厳しく、非推奨ではなくプロトコルから削除されました。非推奨になった機能の単独メソッドのうち 2 つ、`logging/setLevel` とクライアントの `notifications/roots/list_changed` も、2026-07-28 で同じように削除されています。また、進捗通知はサーバーからクライアントへの方向だけになりました。 + +完全な表、それぞれの代替、そしてレガシークライアントに対応しつつログを静かにしたい場合の 1 行のフィルターは**[非推奨の機能](deprecated.md)**にあります。 + +### 変更通知は 1 本のストリームに {#change-notifications-become-one-stream} + +2026-07-28 では、単独の HTTP GET ストリームと `resources/subscribe` が `subscriptions/listen` に置き換わります。クライアントは長寿命のストリームを 1 本開き、欲しい通知の種類を指定します。`MCPServer` は追加の設定なしでこれに対応します。発行には `await ctx.notify_resource_updated(uri)`(や `notify_tools_changed()` など)を使い、ミドルウェアは呼び出し側ごとに listen リクエストを拒否でき、複数レプリカのデプロイでは共有の `SubscriptionBus` を差し込みます。クライアントでは `async with client.listen(...)` がストリームを開きます。フィルターはキーワード引数として渡し、型付きの変更イベントが返り、`sub.honored` はサーバーが配信に同意した部分集合です。 + +発行と配信は**[サブスクリプション](handlers/subscriptions.md)**、監視する側は**[クライアント編の対になるページ](client/subscriptions.md)**、バスは**[デプロイとスケール](run/deploy.md)**で扱います。 + +### そのほかを手短に {#the-rest-quickly} + +* **識別情報は省略可能な、メッセージごとのメタデータです。** リクエスト側の `clientInfo` `_meta` キーは省略可能で(必須の組は `protocolVersion` と `clientCapabilities` です)、`serverInfo` は `server/discover` の結果本体の外に出ました。サーバーは代わりに、2026 年世代のすべての結果の `_meta` にそれを書き込みます([spec #3002](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3002))。SDK は常に書き込みます。サーバーが自身を名乗らない場合(たとえばミドルウェアがキーを取り除いた場合)、`client.server_info` は `None` です。通信路上での書き込みの様子は**[低レベルの Server](advanced/low-level-server.md)** が示します。 +* **リクエストは本体をパースしなくてもルーティングできます。** 新世代の HTTP リクエストは `Mcp-Method`(と、ツール系の 3 つの呼び出しでは `Mcp-Name`)を運びます。`x-mcp-header` で注釈したツールの入力スキーマのプロパティは `Mcp-Param-*` ヘッダーに写され、サーバーが本体と突き合わせて検査します([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243))。ゲートウェイやレートリミッターはヘッダーだけでルーティングできます。ルールは**[移行ガイド](migration.md#servers-validate-mcp-param-headers-against-the-request-body-sep-2243)**にあります。 +* **結果はキャッシュのヒントを運びます。** 一覧と読み取りの結果は `ttlMs` と `cacheScope` を宣言します([SEP-2549](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549))。メソッドごとに `cache_hints=` で設定し、`Client` は組み込みのレスポンスキャッシュでそれに従います。ヒントを送らないサーバー(2026 より前のサーバーはすべてそうです)には、これまでと同じキャッシュされない通信が届きます。詳しくは**[キャッシュのヒント](client/caching.md)**を参照してください。 +* **拡張は第一級です。** サーバーとクライアントは、逆引き DNS 形式の識別子の下に省略可能なケイパビリティの束を宣言します([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133))。組み込みの `Apps` 拡張(MCP Apps)がそのリファレンスです。詳しくは**[拡張](advanced/extensions.md)**と **[MCP Apps](advanced/apps.md)** を参照してください。 +* **エラーコードが標準化されました。** 存在しないリソースは `-32602` で、URI が `error.data` に入ります。仕様で新たに予約されたコードは `-32020`(ヘッダーの不一致)、`-32021`(必須のケイパビリティの欠如)、`-32022`(未対応のプロトコルバージョン)として現れます。**[トラブルシューティング](troubleshooting.md)**は正確なメッセージで引けるようになっています。 +* **認可は誤った使い方をしにくくなりました。** クライアントは認可コードとともに返される `iss` を検証し([RFC 9207](https://datatracker.ietf.org/doc/html/rfc9207)。`callback_handler` は `AuthorizationCodeResult` を返すようになりました)、登録時に `application_type` を送り、別の認可サーバーに対して資格情報を使い回すことはありません。エンタープライズ方面の新機能は [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) の ID アサーションフローです。OAuth の変更は**[移行ガイド](migration.md)**にすべて載っています。該当するページは**[クライアント向けの OAuth](client/oauth-clients.md)** と **[ID アサーション](client/identity-assertion.md)**です。 +* **どのサーバーもトレースできます。** OpenTelemetry はミドルウェアとして、デフォルトで有効な状態で同梱されます。どのリクエストにもサーバースパンが付き、プロセスがエクスポーターを設定するまでコストはかかりません。両端が SDK を使っていれば、クライアントは W3C のトレースコンテキストも `_meta` で伝播するので、トレースがつながります。詳しくは **[OpenTelemetry](run/opentelemetry.md)** を参照してください。 + +## v1 からアップグレードする場合 {#upgrading-from-v1} + +* 何を変えるかの完全で正確な一覧は**[移行ガイド](migration.md)**です。このページはその「なぜ」を説明しました。 +* **v1.x はなくなりません。** メンテナンス段階に移り、重大な修正とセキュリティパッチを受け続けます。2026-07-28 の仕様リリースによって壊れることもありません。ドキュメントは [/v1/](https://py.sdk.modelcontextprotocol.io/v1/) にあります。`mcp` に依存するライブラリを公開していて、まだ移行の準備ができていないなら、固定していない依存解決が 1.x にとどまるように上限を付けてください(たとえば `mcp>=1.28,<2`)。 +* 荒削りなところ、わかりにくいところ、壊れているところがあれば、**[v2 のフィードバックを送ってください](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml)**。すべて目を通しています。 diff --git a/i18n/ko/glossary.json b/i18n/ko/glossary.json new file mode 100644 index 0000000000..ca64f0916b --- /dev/null +++ b/i18n/ko/glossary.json @@ -0,0 +1,205 @@ +{ + "keep": [ + "MCP", + "Model Context Protocol", + "MCPServer", + "FastMCP", + "ClientSession", + "Context", + "ctx", + "stdio", + "Streamable HTTP", + "SSE", + "JSON-RPC", + "JSON", + "JSON Schema", + "OAuth", + "PKCE", + "JWT", + "CIMD", + "HTTP", + "HTTPS", + "TLS", + "CORS", + "URI", + "URL", + "ASGI", + "WebSocket", + "API", + "SDK", + "CLI", + "IDE", + "LLM", + "SEP", + "RFC", + "Python", + "TypeScript", + "Node.js", + "PyPI", + "Pydantic", + "Starlette", + "FastAPI", + "uvicorn", + "httpx", + "anyio", + "asyncio", + "trio", + "pytest", + "OpenTelemetry", + "Inspector", + "Claude", + "GitHub", + "VS Code", + "Windows", + "macOS", + "Linux", + "llms.txt", + "2026-07-28", + "2025-11-25", + "2025-06-18", + "2025-03-26" + ], + "terms": [ + { + "source": "tool", + "target": "도구", + "note": "MCP primitive: an action the model chooses and calls. The wire identifiers `tools/call` and `tools/list` and any code identifiers stay in Latin script inside code font; not the colloquial 툴. Provisional pending native review." + }, + { + "source": "resource", + "target": "리소스", + "note": "MCP primitive: read-only data the application reads. `resources/read` and other identifiers stay Latin in code font. Provisional pending native review; 자원 is the general system-resources word and is avoided here." + }, + { + "source": "prompt", + "target": "프롬프트", + "note": "Both the MCP primitive (a message template a person invokes) and the general LLM sense; 프롬프트 in both. Provisional pending native review." + }, + { + "source": "sampling", + "target": "샘플링", + "note": "MCP feature where the server asks the client's model for a completion; not the statistics sense (표본 추출). Provisional pending native review.", + "avoid": ["표본 추출", "표집"] + }, + { + "source": "roots", + "target": "루트", + "note": "Filesystem locations the client exposes. No plural marker (write 루트, not 루트들); the `roots/list` identifier stays Latin. Target provisional pending native review; 뿌리 (the botanical root) is never correct here.", + "avoid": ["뿌리"] + }, + { + "source": "elicitation", + "target": "엘리시테이션", + "note": "Transliteration; write 엘리시테이션(elicitation) at the first occurrence on a page, 엘리시테이션 alone afterwards. The verb \"elicit\" in prose is 사용자에게 입력을 요청하다; `ctx.elicit(...)` stays code. Provisional pending native review — the least settled term in this file; 유도 is the candidate alternative to confirm against, so it is noted here rather than banned; 도출 is not a candidate." + }, + { + "source": "capability", + "target": "기능", + "note": "A negotiated protocol feature (\"capability negotiation\" → 기능 협상), not 역량 or 능력; the `capabilities` wire field and `client.server_capabilities` stay Latin in code. Provisional pending native review — 기능 doubles as \"feature\"; native review should confirm the collision is acceptable." + }, + { + "source": "transport", + "target": "트랜스포트", + "note": "The countable, named connection mechanism (stdio 트랜스포트, 인메모리 트랜스포트) and the `Transport` protocol name in code. Prose describing the generic idea of how messages are carried may say 전송 방식 without implying a separate term. Provisional pending native review; 운송/수송 (freight transport) are never correct.", + "avoid": ["운송", "수송"] + }, + { + "source": "session", + "target": "세션", + "note": "Standard loanword; `ctx.session` and other identifiers stay code. Provisional pending native review." + }, + { + "source": "handler", + "target": "핸들러", + "note": "The function registered for a tool, resource or prompt. 핸들러 rather than 처리기, to match modern Korean developer docs and the code vocabulary. Provisional pending native review." + }, + { + "source": "dependency", + "target": "의존성", + "note": "Dependency-injection sense (\"dependency injection\" → 의존성 주입); a package dependency in installation contexts is also 의존성. Provisional pending native review; 종속성 is the competing house style." + }, + { + "source": "client", + "target": "클라이언트", + "note": "The protocol role. The `Client` and `ClientSession` class names stay Latin in code font (see keep list). Provisional pending native review; 고객 means a customer and is never correct here.", + "avoid": ["고객"] + }, + { + "source": "server", + "target": "서버", + "note": "The protocol role. The `MCPServer` and `Server` class names stay Latin in code font (see keep list). Provisional pending native review." + }, + { + "source": "host", + "target": "호스트", + "note": "\"MCP host\" is the application that embeds a client (a desktop assistant, an editor); the corpus also says \"ASGI host\" for the app that serves an ASGI app — 호스트 covers both. Provisional pending native review.", + "avoid": ["주최자"] + }, + { + "source": "request", + "target": "요청", + "note": "A JSON-RPC or HTTP request; the verb \"to request\" is 요청하다. Not the transliteration 리퀘스트. Provisional pending native review." + }, + { + "source": "response", + "target": "응답", + "note": "A JSON-RPC or HTTP response; not the transliteration 리스폰스. Provisional pending native review." + }, + { + "source": "context", + "target": "컨텍스트", + "note": "The \"context provided to a model\" sense (as in Model Context Protocol). The `Context` class and `ctx` are identifiers and stay Latin (see keep list). The everyday idiom \"in this context\" is not this term and may be recast (이 경우, 여기서는). Provisional pending native review; 맥락 is the competing rendering for the general word." + }, + { + "source": "notification", + "target": "알림", + "note": "Protocol notifications (`notifications/...` identifiers stay Latin); \"send a notification\" → 알림을 보내다; not 통지. Provisional pending native review." + }, + { + "source": "resolver", + "target": "리졸버", + "note": "The SDK's `Resolve(...)`-annotated parameter resolvers. Provisional pending native review; 해석기 (parser/interpreter) and 해결자 are avoided.", + "avoid": ["해결자"] + }, + { + "source": "authorization", + "target": "인가", + "note": "Security sense: authorization → 인가 (인가 서버 for authorization server, 인가 코드 for authorization code), distinct from authentication → 인증. The `Authorization` HTTP header and code identifiers stay Latin. Provisional pending native review — 승인 and 권한 부여 are competing renderings to confirm against." + }, + { + "source": "deprecated", + "target": "지원 중단 예정", + "note": "Advisory status: still works but discouraged. First occurrence on a page may add the original: 지원 중단 예정(deprecated). \"Removed\" is a different word (제거됨); the corpus contrasts the two, so never render deprecated as 폐기됨 or 제거됨. Provisional pending native review." + }, + { + "source": "round-trip", + "target": "왕복", + "note": "A network round trip; \"multi-round-trip requests\" → 다중 왕복 요청; not the transliteration 라운드 트립. Provisional pending native review." + }, + { + "source": "lifespan", + "target": "lifespan", + "note": "Keep the English word in Korean prose (lifespan 함수, 호스트 앱의 lifespan), matching the `lifespan=` parameter it names. Provisional pending native review — 수명 주기 was rejected because it collides with \"lifecycle\" (생명 주기)." + }, + { + "source": "Get started", + "target": "시작하기", + "note": "The nav section that opens the guide, and the title of its index page. \"First steps\" is a separate page inside that section (첫걸음), so the two need distinct renderings or the sidebar shows the same title twice. Provisional pending native review. 첫 단계 is the alternative for the page." + }, + { + "source": "First steps", + "target": "첫걸음", + "note": "The tutorial page inside the \"Get started\" section; never reuse this rendering for the section itself (see that entry). Provisional pending native review." + }, + { + "source": "Recap", + "target": "요약", + "note": "Recurring section heading that closes most pages; one rendering everywhere, not 요약 on some pages and 정리 on others. Provisional pending native review." + }, + { + "source": "Try it", + "target": "직접 해 보기", + "note": "Recurring section heading above a runnable example; one rendering everywhere, with this spacing (해 보기), not 실행해 보기 or 사용해 보기 on some pages. Provisional pending native review." + } + ] +} diff --git a/i18n/ko/instructions.md b/i18n/ko/instructions.md new file mode 100644 index 0000000000..92f41c472d --- /dev/null +++ b/i18n/ko/instructions.md @@ -0,0 +1,143 @@ +# Korean (ko) — translation instructions + +Target language: Korean (한국어), directory and URL code `ko`, page language +tag `ko`. This file is sent verbatim with every translation request for this +language, on top of the shared rules in `../general-prompt.md`. The termbase +in `glossary.json` is sent alongside it and wins any terminology conflict with +this file. + +## 1. Register + +Write 합쇼체 (formal-polite, sentence endings in -습니다 / -ㅂ니다) as the one +register for the whole page. + +- Body prose, list items, table cells and admonition bodies end in -습니다 / + -ㅂ니다: "The SDK does the rest." → SDK가 나머지를 처리합니다. +- Short imperatives (steps, instructions, calls to action) use -세요: + "Create a file `server.py`" → `server.py` 파일을 만드세요. Never -십시오, never + the bare 해요체 (-어요 / -예요 / -해요), and never plain-style -다 endings. +- Headings are noun phrases where the English heading is a noun phrase + ("Installation" → 설치). An English heading phrased as a sentence or a + question becomes a noun phrase too: "What's new in v2" → v2에서 달라진 점. + Do not write -나요? or -습니까? headings. +- Never address the reader with 당신, 여러분 or 우리. Korean drops the + subject: "you can pass a URL" → URL을 전달할 수 있습니다. Where a subject is + unavoidable, name the role — 클라이언트, 서버, 사용자 — never a pronoun. + "Your server" is 서버 or, when the contrast matters, 작성한 서버. +- One page, one register. Mixing -습니다 with -어요, or -세요 with -십시오, is + wrong even when each sentence is correct on its own. + +## 2. Voice + +Warm, direct and considerate: the reader is a capable developer being +guided by a colleague, not lectured by a manual. + +- Keep the source's directness and its short payoff sentences. "That's a + complete MCP server." → 이것으로 완전한 MCP 서버가 완성됩니다. Do not pad the + translation with hedges the English does not have. +- A brief friendly aside is welcome in Korean too — 참고로, 다행히, a plain + 환영합니다 — as long as it stays in 합쇼체. +- Prefer verbs over noun stacks. "Configuration of the transport" is 트랜스포트를 + 설정하는 방법, not 트랜스포트의 설정. +- Avoid translationese (번역체): + - no double passives: -되어지다 → -되다; no -할 것입니다 chains where -합니다 + says the same thing; + - no pronoun crutches: drop 그것, 그들, 이것들 — repeat the noun or restructure; + - do not stack a conditional marker on top of -면: drop 만약 when -(으)면 + already carries the condition; + - mark plurals sparingly: Korean rarely needs -들 ("the tools" → 도구); + - no honorific inflation: 살펴보시면 ✗ → 살펴보면 ✓ (-세요 endings are the + only place -시- appears); + - do not overuse -에 대해 / -에 대하여 where a plain object particle works. + +Example — English: "A **host** is the LLM application: Claude, an IDE, an +agent runtime. It's the thing the user is talking to." + +- Wrong (translationese): **호스트**는 LLM 애플리케이션입니다: Claude, IDE, + 에이전트 런타임. 그것은 사용자가 그것에게 이야기하는 것입니다. +- Right: **호스트**는 LLM 애플리케이션입니다. Claude, IDE, 에이전트 런타임이 여기에 + 해당하며, 사용자가 대화하는 상대가 바로 호스트입니다. + +## 3. Humour and idioms + +Translate the information, not the joke. + +- Idioms, puns and light asides are recast into a plain friendly 합쇼체 + sentence that carries the same fact, never translated word for word: "Out + of the box the app answers **only** requests addressed to localhost." → + 기본적으로 이 앱은 localhost로 오는 요청**만** 받습니다. — not 상자에서 꺼내자마자. +- Recurring English tags get fixed renderings: "**[X](…)** has the whole + story" / "The whole story is in **[X](…)**" → 자세한 내용은 **[X](…)**에서 + 확인하세요.; "That's the whole API." / "That's the whole protocol." → 이것이 + API의 전부입니다. / 프로토콜은 이것이 전부입니다.; "That's it. It's just Python." + → 이게 전부입니다. 평범한 Python일 뿐입니다. +- Exclamation marks: keep one only where the English is genuinely + emphatic; a routine sentence ends with 온점 even if the source ends in "!". +- Emoji: the source's only emoji are two ✨ closing payoff lines, and they + are dropped in Korean; the friendliness moves into the wording. "You get + `3` back. ✨" → `3`이 돌아옵니다. Emoji shortcodes (`:smile:`) are syntax and + stay untouched. +- If a light aside has no natural Korean equivalent, replace it with a + neutral sentence stating the underlying point — never leave a gap and + never add a translator's note explaining the joke. + +## 4. Typography + +- Punctuation is ASCII: `. , ? ! ( )`. Never 。 、 「」 or full-width forms. + Every sentence, including -세요 imperatives, ends with 온점 `.`. +- No sentence-final colon or dash before a code block or list: "Try this:" + → 다음을 시도해 보세요. An English em-dash aside becomes a comma, a + parenthesis, or its own sentence — no ` — ` in Korean prose. +- Straight quotes only. No italics on Hangul: where the source italicises a + word that becomes Korean, use `**굵게**` or nothing; italics may stay + around Latin-script words. +- Spacing follows 한글 맞춤법: words are separated by spaces, but a + particle (조사) attaches to the word before it — also after Latin words and + code spans, with no space in between: Python은, MCP를, `add`를 호출합니다, + `Client`가 연결을 맺습니다. Latin words otherwise sit in the sentence like + Korean words, with normal spacing on each side. +- Choose the particle after a Latin word or code span by how the term is + read aloud: Python은 (파이썬), stdio는, MCP는 (엠씨피), `list_tools`를, + Streamable HTTP를. When the reading is unclear (symbols, mixed digits), + restructure so a Korean noun carries the particle — `x` 값을, `--port` + 옵션은. Never write the double form 은(는) / 을(를) / 이(가). +- Digits are ASCII; a unit or counter follows a numeral without a space: + 3개, 30초, 8000번 포트, 5MB. Version numbers and the protocol's date-shaped + revision strings are identifiers and are copied byte-for-byte (they are in + the glossary's keep list). A calendar date written out in prose, if any, + becomes 2026년 7월 28일. +- Parenthetical originals use ASCII parentheses with no space before them: + 엘리시테이션(elicitation). + +## 5. Terminology pointer + +The glossary (`glossary.json`) is injected separately and overrides this +file on every term it covers. These conventions apply to everything the +glossary does not pin: + +- Loanword spellings follow the standard 외래어 표기법: 서버, 클라이언트, + 콜백 (not 콜빽), 프롬프트, 세션, 토큰, 스키마, 데코레이터, 미들웨어. Where an + ICT term is not in the glossary, prefer the rendering that mainstream + Korean developer documentation uses; treat 국립국어원 and TTA usage as the + tie-breaker. +- Three strategies coexist and the glossary decides which applies per term: + transliterate established loanwords (스트림, 서버), translate into the common + Sino-Korean word where that is the mainstream (요청, 응답, 알림, 도구, 인가, + 의존성), and keep in Latin script anything that is an identifier or a + proper name — class and function names, wire method names such as + `tools/call`, package names, protocol and product names. +- Text quoted from what the example code prints or displays (an output + line, a log message, a UI label) stays exactly as the code emits it, + usually English. +- 한글(English) 병기: the glossary marks a few MCP-specific nouns for a + parenthetical original on first mention only — 엘리시테이션(elicitation) once, + then 엘리시테이션. Class names never get a Hangul gloss. +- One term, one rendering, throughout the page. Do not alternate between + 객체 and 오브젝트, or between 컨텍스트 and 맥락, for the same source term. +- Abbreviations stay Latin and lose the English plural "s": "the APIs" → API. + +## 6. Provisional note + +Every decision in this file is provisional pending review by native Korean +speakers. To propose a change, edit this file (or `glossary.json`) in a pull +request — never edit the generated pages under `pages/`. diff --git a/i18n/ko/notices.md b/i18n/ko/notices.md new file mode 100644 index 0000000000..2b964cbf69 --- /dev/null +++ b/i18n/ko/notices.md @@ -0,0 +1,20 @@ +--- +translation: + sections: [aff1b3e872b7876a, 4d80558ad052d586, 0bb81f1e62062d26, d5c35dcec50156bc] + tool: 1 +--- +# 번역 안내 {#translation-notices} + +번역된 문서 사이트의 모든 페이지 상단에는 아래 안내문 중 하나가 표시됩니다. + +## 기계 번역 {#translated} + +이 페이지는 영어 문서를 자동으로 번역한 것이며, [영어 페이지](ENGLISH_PAGE)가 기준이 되는 정식 버전입니다. 어색하거나 잘못된 부분이 있다면 [번역](TRANSLATIONS_PAGE) 페이지에서 제보하는 방법을 확인하세요. + +## 영어 페이지보다 오래된 번역 {#outdated} + +이 번역이 만들어진 뒤 영어 페이지가 변경되어 일부 내용이 최신이 아닐 수 있습니다. 확실하지 않을 때는 [영어 페이지](ENGLISH_PAGE)를 읽으세요. 번역 문서가 어떻게 운영되는지는 [번역](TRANSLATIONS_PAGE) 페이지에서 설명합니다. + +## 영어로 표시됨 {#english} + +이 페이지는 현재 번역본이 없어 영어로 표시됩니다. 번역 문서가 어떻게 운영되는지는 [번역](TRANSLATIONS_PAGE) 페이지에서 설명합니다. diff --git a/i18n/ko/pages/advanced/apps.md b/i18n/ko/pages/advanced/apps.md new file mode 100644 index 0000000000..bb040b9da7 --- /dev/null +++ b/i18n/ko/pages/advanced/apps.md @@ -0,0 +1,123 @@ +--- +translation: + sections: [0355618e5f4d5fe4, 1821eaf50f2d0b64, 82e0b28ebd3abf5a, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487] + tool: 1 +--- +# MCP Apps {#mcp-apps} + +**MCP App**은 얼굴을 가진 도구입니다. 도구가 데이터와 함께 HTML 문서를 가리키면, 호스트는 이 문서를 상호작용 가능한 화면으로 렌더링합니다. + +두 부분으로 이루어지며, 언제나 두 부분입니다. + +1. 다른 도구와 마찬가지로 작업을 수행하고 데이터를 반환하는 **도구**. +2. 호스트가 도구를 위해 보여 줄 HTML을 담은 **`ui://` 리소스**. + +도구는 리소스를 가리키는 `_meta.ui.resourceUri` 참조를 지닙니다. 호스트는 `resources/read`로 리소스를 가져와 **샌드박스 처리된 iframe**에 렌더링하고, 도구의 결과를 `postMessage`로 그 iframe에 전달합니다. 서버는 어떤 `ui/*` 메시지도 주고받지 않습니다. 그 트래픽은 호스트와 iframe 사이의 일입니다. 서버는 도구와 HTML 문서를 제공할 뿐이고, 나머지 연출은 호스트가 맡습니다. + +SDK는 이를 내장 `Apps` 확장(`io.modelcontextprotocol/ui`)으로 제공합니다. [확장](extensions.md)이 처음이라면 먼저 그 페이지를 훑어보세요. 1분이면 충분하니 읽고 돌아오면 됩니다. + +## 얼굴을 가진 시계 {#a-clock-with-a-face} + +```python title="server.py" hl_lines="19 22 30 32" +--8<-- "docs_src/apps/tutorial001.py" +``` + +네 가지 단계입니다. + +* `Apps()`: 인스턴스 하나가 UI에 연결된 도구와 그 리소스를 모두 담습니다. +* `@apps.tool(resource_uri="ui://clock/app.html")`: 일반 도구에 `_meta.ui.resourceUri` 표시가 더해집니다. `@mcp.tool()`이 받는 모든 것(name, title, description, ...)이 그대로 전달됩니다. +* `apps.add_html_resource("ui://clock/app.html", CLOCK_HTML)`: 짝이 되는 리소스이며 `text/html;profile=mcp-app`으로 제공됩니다. 바로 이 MIME 타입이 호스트에게 "이것은 앱이니 렌더링하라"고 알려 줍니다. +* `MCPServer("clock", extensions=[apps])`: 옵트인입니다. 이제 서버는 `capabilities.extensions` 아래에 `io.modelcontextprotocol/ui`를 알립니다. + +HTML 자체는 호스트의 `postMessage`를 수신하고 결과를 표시합니다. 실제 앱에서는 HTML 안에서 공식 [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps) 브라우저 SDK를 사용하세요. 원시 메시지 이벤트 대신 `ontoolresult`, `callServerTool`, `getHostContext`, `onhostcontextchanged`를 제공합니다. + +## 우아한 성능 저하 {#graceful-degradation} + +모든 클라이언트가 앱을 렌더링하지는 않습니다. 이것이 서버에 어떤 의미인지 사양은 분명하게 말합니다. + +> 도구는 UI를 사용할 수 있는 경우에도 의미 있는 `content` 배열을 반환해야 **합니다(MUST)**. + +모델은 `content`를 읽고, iframe은 사람을 위한 것입니다. UI를 지원하는 호스트도 여전히 텍스트 결과를 모델에 전달하며, 텍스트 전용 클라이언트는 **오직** 그 텍스트만 받습니다. 따라서 표준 패턴은 도구 하나에 답 둘입니다. `get_time`을 다시 살펴보세요. + +```python title="server.py" hl_lines="23-27" +--8<-- "docs_src/apps/tutorial001.py" +``` + +`client_supports_apps(ctx)`는 클라이언트가 `io.modelcontextprotocol/ui` 확장을 선언했고 **동시에** `mimeTypes` 설정에 `text/html;profile=mcp-app`을 나열했을 때만 `True`입니다. 이 필드는 필수이므로 생략한 클라이언트는 해당하지 않습니다. 같은 파일의 `main()`이 선언하는 것이 바로 이것입니다. 협상의 클라이언트 쪽 절반을 선언하면 풍부한 답이 돌아옵니다. + +!!! warning + `"[Rendered UI]"` 같은 자리 표시자를 유일한 content로 반환하지 마세요. 대체 텍스트가 쓸모없다면, 그 도구는 모든 텍스트 전용 클라이언트와 모델 자체에 쓸모없는 도구가 됩니다. 제대로 된 문장을 작성하세요. + +## iframe 잠그기 {#locking-the-iframe-down} + +리소스 쪽이 보안 메타데이터를 지닙니다. iframe이 무엇을 로드할 수 있는지, 어떤 브라우저 권한을 원하는지, 어떻게 프레임에 담기기를 원하는지를 담습니다. + +```python title="server.py" hl_lines="9 19-22" +--8<-- "docs_src/apps/tutorial002.py" +``` + +`csp`와 `permissions`는 서버의 동작이 아니라 **호스트에 대한 요청**입니다. 호스트는 이를 바탕으로 iframe의 Content-Security-Policy와 Permissions-Policy를 구성하며, 거부할 수도 있습니다. 허가되었다고 가정하지 말고 JS에서 기능 탐지를 하세요. + +`ResourceCsp`를 필드별로 살펴보면 다음과 같습니다(Python 이름, 와이어 키, 호스트가 이것으로 하는 일). + +| Python | 와이어 (`_meta.ui.csp`) | 제어 대상 | +|---|---|---| +| `connect_domains` | `connectDomains` | `connect-src`: `fetch`/XHR이 갈 수 있는 곳 | +| `resource_domains` | `resourceDomains` | `img-src`, `style-src`, ...: 정적 자산 | +| `frame_domains` | `frameDomains` | `frame-src`: 중첩 iframe | +| `base_uri_domains` | `baseUriDomains` | `base-uri`: ``가 가리킬 수 있는 곳 | + +`ResourcePermissions`: 각 필드는 iframe을 위한 브라우저 권한 하나를 요청합니다. + +| Python | 와이어 (`_meta.ui.permissions`) | +|---|---| +| `camera` | `camera` | +| `microphone` | `microphone` | +| `geolocation` | `geolocation` | +| `clipboard_write` | `clipboardWrite` | + +!!! note + CSP와 권한은 **리소스**에 있으며, 도구에는 절대 두지 않습니다. 사양의 도구 메타데이터에는 이를 위한 자리가 없고, 호스트는 그곳에 있는 값을 무시합니다. SDK는 이 실수를 아예 표현할 수 없게 만듭니다. `@apps.tool()`에는 `csp` 매개변수가 없습니다. + +### 가시성 {#visibility} + +도구의 `visibility=["app"]`은 "이것은 모델이 아니라 iframe을 위해 존재한다"는 뜻입니다. + +* `"model"`: 모델이 호출할 수 있습니다. +* `"app"`: iframe이 호출할 수 있습니다(`callServerTool`을 통해). +* 생략: 둘 다이며, 이것이 기본값입니다. + +필터링은 **호스트**의 일입니다. 서버는 앱 전용 도구를 다른 도구와 마찬가지로 `tools/list`에 나열하고, 호스트가 이를 모델에게서 숨깁니다. 서버 쪽에서 필터링하지 마세요. + +## SDK가 강제하는 규칙 {#the-rules-the-sdk-enforces} + +모두 프로덕션이 아니라 시작 시점에 실패합니다. + +* `ui://...`가 아닌 `resource_uri`나 리소스 URI는 데코레이션/등록 시점에 `ValueError`입니다. +* **짝이 되는 등록된 리소스가 없는** URI에 연결된 도구는 `MCPServer(extensions=[apps])`가 확장을 소비할 때 `ValueError`입니다. `resources/read`에서 404가 나는 HTML을 알리는 도구는 잘못된 설정이므로, 생성 자체를 거부합니다. +* `@apps.tool()`의 `meta={"ui": ...}`는 `ValueError`입니다. `_meta["ui"]`는 데코레이터의 소유이니 `resource_uri=`와 `visibility=`로 표현하세요. 다른 `meta=` 키는 문제없이 함께 병합됩니다. + +TypeScript ext-apps SDK도 FastMCP도 현재는 이 중 어느 것도 잡아내지 못합니다. 호스트가 발견하기 전에 먼저 알게 되는 편이 낫다고 생각합니다. + +## 인라인 HTML 너머 {#beyond-inline-html} + +`add_html_resource`는 흔한 경우, 즉 HTML 문자열을 다룹니다. 그 밖의 경우, 디스크에 있는 HTML이나 생성된 콘텐츠라면 리소스를 직접 만들어 넘기세요. + +```python title="server.py" hl_lines="12 18" +--8<-- "docs_src/apps/tutorial003.py" +``` + +`add_resource`는 리소스가 MIME 타입을 명시적으로 설정하지 않았을 때 `text/html;profile=mcp-app`을 채워 넣고, 명시적으로 불일치하는 값은 거부합니다. 다른 MIME 타입의 `ui://` 리소스는 어떤 호스트도 렌더링하지 않기 때문입니다. + +!!! tip + 지원 중단 예정(deprecated)인 평면 키 `_meta["ui/resourceUri"]`를 여전히 읽는 GA 이전 호스트를 대상으로 하나요? 직접 병합하세요. + `@apps.tool(resource_uri="ui://x", meta={"ui/resourceUri": "ui://x"})`. + 중첩된 `ui` 객체가 사양의 형태이며, 평면 키는 사라지는 중입니다. + +## 실행해 보기 {#see-it-run} + +`examples/stories/`의 `apps` 스토리는 이 페이지를 실행 가능한 한 쌍으로 만든 것입니다. UI에 연결된 시계 도구를 갖춘 서버, 그리고 Apps를 협상하고 도구의 `_meta.ui.resourceUri`를 읽고 HTML을 가져와 도구를 호출하는 클라이언트입니다. + +```bash +uv run python -m stories.apps.client +``` diff --git a/i18n/ko/pages/advanced/extensions.md b/i18n/ko/pages/advanced/extensions.md new file mode 100644 index 0000000000..a269ffc28a --- /dev/null +++ b/i18n/ko/pages/advanced/extensions.md @@ -0,0 +1,172 @@ +--- +translation: + sections: [05891e7cc1938a13, b3c01a6af28c51ee, 7ffc91f5e38bdfe0, 717d3f235a8333a7, f471a13b2fe5d737, ed6af2df4b656dff] + tool: 1 +--- +# 확장 {#extensions} + +**확장**은 하나의 식별자 아래에 묶어 두고 원할 때만 켜서 쓰는 MCP 동작의 묶음입니다. + +서버에서는 도구, 리소스, 새로운 요청 메서드를 제공할 수 있고 `tools/call`을 감쌀 수 있습니다. 클라이언트에서는 추가적인 `tools/call` 결과 형태를 클레임하고 벤더 알림을 관찰할 수 있습니다. 양쪽 모두 각자의 `capabilities.extensions` 아래에 이를 광고하며, 요청하지 않은 쪽에는 아무것도 달라지지 않습니다. 이것이 계약([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133))이고, 황금률은 단 하나입니다. **확장은 기본적으로 꺼져 있습니다**. + +## 확장 사용하기 {#using-an-extension} + +생성할 때 인스턴스를 전달하세요. + +```python title="server.py" +--8<-- "docs_src/extensions/tutorial001.py" +``` + +끝입니다. 이제 서버는 `capabilities.extensions` 아래에 `io.modelcontextprotocol/ui`를 광고하고 확장이 제공하는 모든 것을 서비스합니다. + +`Apps`는 내장된 참조 확장이며 별도의 페이지에서 다룹니다. **[MCP Apps](apps.md)**를 참고하세요. + +!!! note + 확장은 생성 시점에 고정됩니다. 나중에 호출할 `add_extension`은 없습니다. 클라이언트가 연결되어 있는 동안 서버의 기능 맵이 바뀌어서는 안 되기 때문입니다. + +기능 맵은 `server/discover`에 실려 전달되는데, 이는 **2026-07-28** 경로입니다. 레거시 `initialize` 핸드셰이크에는 이를 담을 자리가 없으므로 레거시 클라이언트는 확장을 아예 보지 못합니다. 이를 고려해 설계하세요. 확장은 서버를 **보강**하는 것이지, 서버를 사용할 수 있는 유일한 방법이 되어서는 안 됩니다. + +## 직접 작성하기 {#writing-your-own} + +`Extension`을 서브클래싱하고 필요한 것만 재정의하세요. 모든 메서드에는 기본 구현이 있습니다. + +### 식별자 {#the-identifier} + +```python +--8<-- "docs_src/extensions/tutorial002.py" +``` + +식별자는 사양의 `_meta` 키 문법을 따르는 `vendor-prefix/name` 문자열입니다. 점으로 구분된 레이블(각각 문자로 시작하고 문자 또는 숫자로 끝남), 슬래시, 그리고 이름 순서입니다. **클래스가 정의될 때** 검증되므로 오타가 서버 부팅까지 숨어 있지 않습니다. + +```text +TypeError: Stamps.identifier must be a `vendor-prefix/name` string +(reverse-DNS prefix required), got 'stamps' +``` + +직접 관리하는 도메인을 접두사로 사용하세요. `io.modelcontextprotocol/*`는 MCP 프로젝트 자체가 규정하는 확장을 위한 것입니다. + +### 도구 제공하기 {#contributing-tools} + +쓸모 있는 가장 작은 확장은 도구 하나와 설정 맵 하나입니다. + +```python title="server.py" hl_lines="17 19-20 22-23 26" +--8<-- "docs_src/extensions/tutorial003.py" +``` + +* `tools()`는 `ToolBinding`을 반환합니다. 서버는 각각을 직접 `mcp.add_tool(...)`을 호출한 것과 똑같이 등록합니다. 스키마 생성도, `Context` 주입도, 나머지도 모두 같습니다. +* `settings()`는 `capabilities.extensions["com.example/stamps"]`에 광고되는 값입니다. 설정 없이 확장을 광고하려면 `{}`(기본값)을 반환하세요. +* 확장은 서버를 절대 전달받지 않습니다. 기여할 내용을 데이터로 선언하고, `MCPServer`가 이를 소비합니다. 변경할 `self.server` 같은 것은 없습니다. + +그리고 `main()`이 그 증거입니다. `mcp`에 바로 연결하는 인메모리 클라이언트입니다. + +```python title="server.py" hl_lines="29-34" +--8<-- "docs_src/extensions/tutorial003.py" +``` + +### 자체 메서드 제공하기 {#serving-your-own-methods} + +확장은 **새로운 요청 메서드**를 등록할 수 있습니다. 사양의 동사 옆에서 함께 서비스되는 자체 동사입니다. + +```python title="server.py" hl_lines="16-22 31 40-48" +--8<-- "docs_src/extensions/tutorial004.py" +``` + +* `SearchParams`는 `RequestParams`를 서브클래싱하므로 2026 `_meta` 봉투가 일관되게 파싱되고, 핸들러는 원시 dict가 아니라 검증된 params를 받습니다. 클라이언트가 제어하는 값에는 한계를 두세요. `Field(ge=1, le=100)`은 코드가 무언가를 할당하기 전에 터무니없는 `limit`을 거부합니다. +* `require_client_extension(ctx, EXTENSION_ID)`가 관문입니다. 확장을 선언하지 않은 클라이언트는 사양이 요구하는 기계 판독 가능한 `requiredCapabilities` 페이로드와 함께 `-32021`(필수 클라이언트 기능 누락) 오류를 받습니다. +* `protocol_versions=frozenset({"2026-07-28"})`은 메서드를 하나의 와이어 버전에 고정합니다. 다른 버전에서는 클라이언트가 `METHOD_NOT_FOUND`를 받는데, 그 버전에 메서드가 존재하지 않는 것과 똑같습니다. 그 클라이언트에게는 실제로 존재하지 않는 셈입니다. + +메서드는 **엄격하게 추가만 가능**합니다. SDK는 이를 런타임이 아니라 생성 시점에 강제합니다. + +* 사양에 정의된 메서드(`tools/list`, `completion/complete`, ...)에 대한 `MethodBinding`은 바인딩이 생성될 때 `ValueError`를 일으킵니다. 핵심 동사는 서버의 것입니다. +* 두 확장이 같은 메서드를 바인딩하면 두 번째가 등록될 때 오류가 납니다. 마지막 쓰기가 이기는 방식은 플러그인이 서로를 망가뜨리는 원인이므로 그렇게 하지 않습니다. +* 빈 `protocol_versions` 집합도 오류를 일으킵니다. 절대 서비스될 수 없는 메서드는 설정이 아니라 버그입니다. + +### 클라이언트 측 {#the-client-side} + +같은 파일의 `main()`이 클라이언트 쪽 이야기의 전부이며, 두 부분을 모두 담고 있습니다. + +```python title="server.py" hl_lines="54-58" +--8<-- "docs_src/extensions/tutorial004.py" +``` + +* `Client(..., extensions=[advertise(EXTENSION_ID)])`가 확장을 선언합니다. 선언은 `ClientCapabilities.extensions`가 됩니다. 2026-07-28 연결에서는 이 맵이 요청별 `_meta` 봉투에 실려 이동하므로 서버는 **모든** 요청에서 이를 봅니다. 레거시 연결에서는 `initialize` 핸드셰이크에 실립니다. 서버 코드는 어느 쪽인지 신경 쓰지 않습니다. `require_client_extension(ctx, ...)`와 `ctx.session.check_client_capability(...)`는 두 경로 모두에서 올바른 출처를 읽습니다. +* 벤더 메서드는 한 계층 아래인 `client.session.send_request(...)`로 내려갑니다. `Client`는 사양 동사에 대해서만 일급 메서드를 갖춥니다. `send_request`는 모든 `Request` 서브클래스를 받으므로 벤더 요청은 그대로 통과합니다. + +### `tools/call` 가로채기 {#intercepting-toolscall} + +유일하게 개입하는 훅입니다. 도구 호출을 관찰하거나, 단락시키거나, 거부하려면 `intercept_tool_call`을 재정의하세요. + +```python title="server.py" hl_lines="17-24" +--8<-- "docs_src/extensions/tutorial005.py" +``` + +* `params`는 검증된 `CallToolRequestParams`입니다. 원시 JSON을 건드리지 않고 `params.name`과 `params.arguments`를 얻습니다. 어느 도구 호출이 실행될지 결정하는 것도 바로 이것입니다. 다시 작성한 컨텍스트를 `call_next`에 넘기면 핸들러가 `ctx`에서 관찰하는 내용이 바뀔 뿐 도구 호출 자체는 바뀌지 않습니다. 와이어 수준의 요청 재작성은 [미들웨어](middleware.md)의 몫입니다. +* `call_next(ctx)`는 체인의 나머지를 실행하고 핸들러의 결과를 반환합니다. 그대로 반환하거나(관찰), 다른 것을 반환하거나(대체), `MCPError`를 일으키세요(거부). 무엇을 반환하든 2026 계열의 `serverInfo` 신원 스탬프를 포함해 다른 핸들러 결과와 똑같이 직렬화되므로, 단락시키는 인터셉터가 익명이거나 스키마에 어긋나는 응답을 만들어 내는 일은 없습니다. +* 확장이 여럿이면 인터셉터는 등록 순서대로 중첩됩니다. `extensions=[...]`의 첫 번째 확장이 가장 바깥쪽입니다. +* 기본 구현은 그대로 통과시키며, 확장이 이 훅을 재정의하지 않는 서버는 원래의 `tools/call` 핸들러를 그대로 유지합니다. 쓰지 않는 것에는 비용을 치르지 않습니다. + +이 훅은 `tools/call`만 감싸고 다른 것은 감싸지 않습니다. 모든 메시지에 걸친 관심사에는 [미들웨어](middleware.md)를 사용하세요. 미들웨어는 바로 그런 용도입니다. + +## 클라이언트 확장 사용하기 {#using-a-client-extension} + +**클라이언트 확장**은 소비하는 쪽에서 본 같은 계약으로, 하나의 식별자 아래 묶인 클라이언트 측 동작의 묶음입니다. `Client(extensions=[...])`에 인스턴스를 전달하고 평소처럼 도구를 호출하세요. + +```python title="client.py" hl_lines="66-68" +--8<-- "docs_src/extensions/tutorial006.py" +``` + +`call_tool("buy", ...)`은 다른 모든 호출처럼 평범한 `CallToolResult`를 반환합니다. 확장이 바꾼 것은 이렇습니다. 이제 서버는 `buy`에 최종 결과 대신 `receipt` **결과 형태**로 응답할 수 있고, `call_tool`이 반환하기 전에 `Receipts`가 이를 마무리합니다(여기서는 후속 호출로 영수증을 정산합니다). 호출 지점에서는 아무것도 달라지지 않습니다. + +확장을 빼면 이 중 어떤 것도 존재하지 않습니다. 서버의 관문은 확장을 선언하지 않은 클라이언트를 거부하고(오류 -32021), 관문을 건너뛰는 서버가 보낸 클레임된 형태는 인식되지 않은 `resultType`에 대해 사양이 요구하는 그대로 검증에 실패합니다. 와이어 양 끝 모두에서 기본적으로 꺼져 있습니다. + +클라이언트 측 동작이 **전혀 없는** 식별자를 광고하려면(위의 검색 클라이언트처럼 서버는 기능을 기준으로 관문을 두고 클라이언트는 아무것도 하지 않는 경우) `advertise()`를 사용하세요. + +```python +from mcp.client import advertise + +client = Client(mcp, extensions=[advertise("com.example/search")]) +``` + +## 클라이언트 확장 작성하기 {#writing-a-client-extension} + +`ClientExtension`을 서브클래싱하고 필요한 것만 재정의하세요. 기여 종류는 세 가지이며 각각 기본 구현이 있습니다. `settings()`, `claims()`, `notifications()`입니다. + +```python title="client.py" hl_lines="17-18 43-44 46-47" +--8<-- "docs_src/extensions/tutorial006.py" +``` + +* 식별자는 서버의 것과 같은 문법을 따르며 클래스가 정의될 때 검증됩니다. +* `claims()`는 `ResultClaim`을 반환합니다. 와이어 태그, 이를 파싱하는 모델, 마무리하는 리졸버로 구성됩니다. 모델은 `result_type: Literal["receipt"]`으로 태그를 고정해야 하며 해당 동사의 핵심 결과 타입을 서브클래싱해서는 안 됩니다. 둘 다 클레임이 생성될 때 강제됩니다. `receipt_token` 같은 벤더 필드는 와이어에 그대로 실립니다. 대체된 형태는 클라이언트에 원문 그대로 도달합니다. +* 리졸버는 파싱된 모델과 `ClaimContext`를 받습니다. `ctx.session`은 `client.session`과 같은 공개 핸들이므로 후속 작업은 평범한 세션 호출입니다. 리졸버는 해당 동사의 일반적인 `CallToolResult`를 반환합니다. +* `settings()`는 `ClientCapabilities.extensions[identifier]`에 광고되는 값이며 `Client` 생성 시 한 번 읽힙니다. + +`notifications()`는 관찰할 벤더 서버 알림을 선언합니다. + +```python +def notifications(self) -> Sequence[NotificationBinding[Any]]: + return [NotificationBinding(method="notifications/receipts", params_type=ReceiptEvent, handler=self.on_receipt)] +``` + +핸들러는 검증된 params를 디스패치 순서대로 하나씩 받습니다. 관찰만 할 뿐, 거부하거나 응답할 수는 없습니다. + +조용한 규칙이 두 가지 있습니다. 클레임은 2026-07-28 연결에서만 활성화되며 기능 광고도 이를 따릅니다. 레거시 연결에서는 클레임이 사라지고 식별자도 함께 광고에서 빠지므로, 클라이언트가 스스로 거부할 형태의 확장을 광고하는 일은 없습니다. 그리고 리졸버 대신 클레임된 형태를 직접 받고 싶다면 `client.session.call_tool(..., allow_claimed=True)`를 호출하세요. 이 플래그가 없으면 세션 계층 호출자에게 도달한 클레임된 형태는 `UnexpectedClaimedResult`를 일으킵니다. + +### 확장 동사 {#extension-verbs} + +확장의 자체 요청 메서드에는 클라이언트 측 등록이 필요 없습니다. 벤더 요청 타입은 `mcp.types.Request`를 서브클래싱하고, [자체 메서드 제공하기](#serving-your-own-methods)에서처럼 `client.session.send_request`를 거칩니다. 한 가지가 더 있습니다. params 키가 `Mcp-Name` 헤더에 실려야 할 때(tasks 같은 확장 사양은 자신의 동사에 이를 요구합니다) 요청 타입이 `name_param`을 선언합니다. + +```python title="client.py" hl_lines="22-25 46-47" +--8<-- "docs_src/extensions/tutorial007.py" +``` + +세션은 모든 전송 경로에서 `params["jobId"]`를 `Mcp-Name`에 반영하며, 값이 없으면 필수 헤더를 조용히 빠뜨리는 대신 명시적으로 실패합니다. + +## 확장이 할 수 없는 것 {#what-an-extension-cannot-do} + +기여 범위는 의도적으로 **닫혀** 있습니다. 서버에서는 설정, 도구, 리소스, 메서드, `tools/call` 인터셉터 하나입니다. 클라이언트에서는 설정, 결과 클레임, 알림 바인딩입니다. 확장은 다음을 할 수 없습니다. + +* **호스트 내부에 손대기.** 데이터를 선언할 뿐, 서버나 클라이언트 참조를 쥐지 않습니다. +* **핵심 동작 바꾸기.** 사양 메서드와 핵심 결과 태그는 생성 시점에 거부되며(`initialize`는 러너가 아예 예약해 둡니다), 핵심 어휘에 가려지는 알림 바인딩은 대신 경고와 함께 조용해집니다. +* **늦게 등록하기.** `MCPServer(...)`나 `Client(...)`가 반환된 뒤에는 확장 집합이 그대로 확정됩니다. + +이 벽과 씨름하고 있다면 확장을 작성하는 것이 아니라 포크를 작성하는 것입니다. 벽이 곧 기능입니다. `extensions=[Apps(), Stamps()]`라는 코드를 읽는 사용자는 그 둘이 건드렸을 수 있는 **모든 것**을 압니다. diff --git a/i18n/ko/pages/advanced/index.md b/i18n/ko/pages/advanced/index.md new file mode 100644 index 0000000000..7f88264fd1 --- /dev/null +++ b/i18n/ko/pages/advanced/index.md @@ -0,0 +1,22 @@ +--- +translation: + sections: [ca6988b7503cd2d3] + tool: 1 +--- +# 고급 {#advanced} + +일반적인 서버나 클라이언트에 필요한 모든 것은 위의 섹션에서 주제별로 다룹니다. 이 섹션은 `MCPServer`의 편의 계층이 오히려 방해가 될 때 꺼내 쓰는 비상구입니다. + +* **[저수준 Server](low-level-server.md)**: `MCPServer`가 기반으로 삼는 클래스입니다. 손으로 작성하는 스키마, `on_*` 핸들러, 아무것도 대신 검사해 주지 않는 구조, 그리고 직접 정의하는 커스텀 JSON-RPC 메서드를 다룹니다. +* **[페이지네이션](pagination.md)**과 **[미들웨어](middleware.md)**: 저수준 `Server`에서**만** 할 수 있는 두 가지입니다. +* **[확장](extensions.md)**과 **[MCP Apps](apps.md)**: 프로토콜의 확장 지점입니다. 확장 패키지를 서버에 조합해 넣거나 직접 작성할 수 있습니다. + +여기서 찾을 법한 몇 가지 항목은 실제로 사용하는 곳에 배치되어 있습니다. + +* **인가**는 **[서버 실행하기](../run/index.md)** 아래에 있습니다. 서버는 배포하는 곳에서 보호하기 때문입니다. +* **OAuth**, **신원 어설션**, **여러 서버**에 연결하기, 응답 **캐시**는 모두 **[클라이언트](../client/index.md)** 아래에 있습니다. +* **다중 왕복 요청**과 **구독**은 **[핸들러 내부](../handlers/index.md)** 아래에 있습니다. 둘 다 핸들러가 **수행하는** 일이기 때문입니다. +* **URI 템플릿**은 **[서버](../servers/index.md)** 아래, 리소스 옆에 있습니다. +* **[프로토콜 버전](../protocol-versions.md)**과 **[지원 중단 예정 기능](../deprecated.md)**은 각각 별도의 최상위 페이지가 있습니다. + +이 섹션이 필요한지 확신이 서지 않는다면, 필요하지 않은 것입니다. diff --git a/i18n/ko/pages/advanced/low-level-server.md b/i18n/ko/pages/advanced/low-level-server.md new file mode 100644 index 0000000000..543acf708d --- /dev/null +++ b/i18n/ko/pages/advanced/low-level-server.md @@ -0,0 +1,207 @@ +--- +translation: + sections: [2c79b6338e09b7ac, 7edc43b3fae11314, 1086e77ce561cd7f, a3f71823df5efc31, 9fc7109f72201cae, 7bf25983df655b66, 6330e1f4c6029683, 2f1749c8c133fa1c, b3530fcf4d11fd56, ebc33704fbd74262, cd0e9c933350390e] + tool: 1 +--- +# 저수준 Server {#the-low-level-server} + +`@mcp.tool()`은 하나의 계층입니다. 그 아래에는 두 번째 서버 클래스인 `Server`가 있으며, 이 클래스는 MCP를 날것 그대로 다룹니다. 프로토콜 객체를 넘기면 변경 없이 그대로 와이어에 실어 보냅니다. + +`MCPServer`는 그 위에 만들어져 있습니다. 편의 계층이 방해가 될 때 저수준으로 내려갑니다. + +* Python 시그니처에서 도출한 스키마가 아니라 **정확히 그대로의** 스키마(파일에서 읽어 오거나 데이터베이스에서 생성한 스키마)를 내보내야 할 때. +* 결과를 완전히 제어해야 할 때: `_meta`, `is_error`, `structured_content`의 모든 키. +* MCP가 정의하지 않은 메서드를 처리해야 할 때. + +그 밖의 모든 경우에는 `MCPServer`를 계속 사용하세요. + +## 같은 도구를 직접 작성하기 {#the-same-tool-by-hand} + +다음은 **[도구](../servers/tools.md)**에서 `@mcp.tool()` 아홉 줄로 작성한 `search_books` 도구에서 문법적 편의를 걷어 낸 모습입니다. + +```python title="server.py" hl_lines="22 26 32" +--8<-- "docs_src/lowlevel/tutorial001.py" +``` + +세 가지가 바뀌었고, 이 세 가지가 저수준 API의 전부입니다. + +* **핸들러는 생성자 매개변수입니다.** `on_list_tools=`와 `on_call_tool=`은 `Server(...)`에 들어갑니다. 여기에는 데코레이터가 없으며, 모든 핸들러의 형태가 `async (ctx, params) -> result`로 동일합니다. +* **입력 스키마를 직접 작성합니다.** `Tool.input_schema`는 평범한 JSON Schema `dict`입니다. 타입 힌트에서 대신 도출해 주는 곳이 없습니다. 도출할 타입 힌트 자체가 없기 때문입니다. +* **결과를 직접 만듭니다.** `CallToolResult(content=[TextContent(...)])`를 손으로 작성합니다. 감싸거나 변환하거나 반환 어노테이션에서 추론하는 것은 아무것도 없습니다. + +`params`는 파싱된 요청입니다. `CallToolRequestParams`에는 `.name`과 `.arguments`가 있습니다. `ctx`는 `ServerRequestContext`입니다. 클라이언트에 다시 말을 거는 데 쓰는 `ctx.session`, 그리고 `ctx.lifespan_context`, `ctx.request_id`, 요청에 실려 들어온 `_meta`인 `ctx.meta`가 있습니다. + +!!! info + FastAPI를 써 봤다면 이 관계를 이미 알고 있습니다. `MCPServer`는 데코레이터와 타입 힌트로 이루어진 계층이고, `Server`는 그 아래의 Starlette에 해당합니다. 둘은 경쟁 관계가 아닙니다. `MCPServer`는 `Server`를 생성하고 그 위에 바로 이런 핸들러를 등록합니다. + +### 직접 해 보기 {#try-it} + +이번에는 Inspector를 쓸 수 없습니다. `mcp dev`와 `mcp run`은 `MCPServer`만 받습니다. 인메모리 `Client`는 상관하지 않으며, `MCPServer`를 받는 것과 똑같이 저수준 `Server`도 받습니다. + +```python title="main.py" +import asyncio + +from mcp import Client + +from server import server + + +async def main() -> None: + async with Client(server) as client: + result = await client.call_tool("search_books", {"query": "dune", "limit": 5}) + print(result.content) + + +asyncio.run(main()) +``` + +```text +[TextContent(type='text', text="Found 3 books matching 'dune' (showing up to 5).", annotations=None, meta=None)] +``` + +`@mcp.tool()` 버전이 만들어 낸 것과 같은 텍스트입니다. 숨김없이 말하면 차이점이 두 가지 있습니다. + +* `result.structured_content`가 `None`입니다. 고수준 서버는 `-> str` 반환값을 `{"result": ...}`로 감싸 주지만, 여기서는 직접 만들지 않은 것을 대신 만들어 주는 곳이 없습니다. +* `list_tools`는 **직접** 입력한 스키마를 글자 하나까지 그대로 반환합니다. 고수준 버전에는 모든 속성에 `"title": "Query"`가, 루트에 `"title": "search_booksArguments"`가 있었습니다. Pydantic이 남긴 흔적입니다. 여기서는 와이어에 실린 것이라면 전부 직접 넣은 것입니다. + +## 자동 검증 없음 {#nothing-is-checked-for-you} + +`MCPServer`는 함수가 실행되기도 전에, 생성한 스키마에 호출을 대조해 검증하여 잘못된 인수를 거부합니다(**[도구](../servers/tools.md)**). + +`Server`는 그렇게 하지 않습니다. `input_schema`는 클라이언트에 **알려지기만** 할 뿐, `params.arguments`에 **적용되는** 일은 없습니다. + +!!! check + `limit` 없이 `search_books`를 호출하면 `args["limit"]`에서 `KeyError`가 발생합니다. 클라이언트가 보는 것은 다음과 같습니다. + + ```text + MCPError: Internal server error + ``` + + 코드 `-32603`의 JSON-RPC 오류이며, 메시지는 일부러 포괄적으로 되어 있습니다. SDK는 트레이스백을 원격 호출자에게 새어 나가게 하지 않습니다. 모델은 무엇을 잘못했는지 끝내 알지 못하므로 재시도할 수 없습니다. (테스트에서는 `raise_exceptions=True`로 실제 예외를 대신 드러낼 수 있습니다. **[테스트](../get-started/testing.md)**를 참고하세요.) + +이것은 일반적인 규칙입니다. 저수준 핸들러에서 발생한 예외는 **언제나** 프로토콜 오류이며, 결코 `is_error=True` 도구 결과가 되지 않습니다. 모델이 실패 내용을 읽고 복구하기를 원한다면 `params.arguments`를 직접 검증하고 `CallToolResult(content=[TextContent(...)], is_error=True)`를 반환하세요. 이 두 가지 실패는 **[오류 처리](../servers/handling-errors.md)**에서 다룹니다. + +## 도구 두 개, 핸들러 하나 {#two-tools-one-handler} + +`on_call_tool`은 서버에 있는 모든 도구의 단일 진입점입니다. `params.name`으로 분기합니다. + +```python title="server.py" hl_lines="38-43" +--8<-- "docs_src/lowlevel/tutorial002.py" +``` + +* `list_tools`는 둘 다 알립니다. `call_tool`은 이름에 따라 디스패치합니다. +* `else` 분기가 중요합니다. `Server`는 목록에 올린 적 없는 이름의 `tools/call`도 기꺼이 핸들러로 그대로 전달합니다. 거기서 예외를 일으키면 위와 같은 `-32603`이 됩니다. + +## 구조화된 출력 직접 작성하기 {#structured-output-by-hand} + +`Tool`에 `output_schema`를 선언하고 결과에 `structured_content`를 넣습니다. 둘 다 직접 작성합니다. + +```python title="server.py" hl_lines="19-23 36" +--8<-- "docs_src/lowlevel/tutorial003.py" +``` + +호출하면 결과에 두 가지 표현이 모두 실립니다. + +```json +{ + "content": [{"type": "text", "text": "Found 3 books matching 'dune'."}], + "structuredContent": {"matches": 3, "query": "dune"}, + "isError": false, + "resultType": "complete", + "_meta": {"io.modelcontextprotocol/serverInfo": {"name": "Bookshop", "version": "2.0.0"}} +} +``` + +`_meta` 블록은 서버의 신원 도장입니다. SDK는 2026 계열 프로토콜의 모든 결과에 이것을 추가하며, `version`은 생성자에서 가져옵니다(버전을 설정하지 않은 서버는 빈 문자열을 보고합니다). 자신을 드러내서는 안 되는 서버는 미들웨어로 이 키를 제거할 수 있습니다. 미들웨어는 자신이 반환하는 결과를 소유하기 때문입니다. + +서버는 두 필드를 비교하지 않습니다. 이 SDK의 `Client`는 비교합니다. 선언한 `output_schema`를 만족하지 않는 `structured_content`를 반환하면 `call_tool`이 `RuntimeError`를 일으키는데, 메시지는 `Invalid structured content returned by tool search_books`로 시작해 `jsonschema` 실패 내용을 인용합니다. 스키마를 약속하기는 쉽지만, 지키는 것은 작성자의 몫입니다. 반환 타입과 스키마의 전체 단계는 **[구조화된 출력](../servers/structured-output.md)**에서 확인하세요. + +## `_meta`: 모델이 아닌 애플리케이션을 위한 데이터 {#\_meta-for-the-application-not-the-model} + +`content`는 답변 중 모델이 읽는 부분입니다. `structured_content`는 같은 답변을 타입이 있는 데이터로 나타낸 것입니다. `_meta`는 세 번째 채널입니다. 답변의 일부가 전혀 아니면서 결과에 함께 실려 **클라이언트 애플리케이션**으로 가는 데이터입니다. + +레코드 ID, 트레이스 ID처럼 UI에는 필요하고 프롬프트에는 필요 없는 것이라면 무엇이든 여기에 넣으세요. + +```python title="server.py" hl_lines="37" +--8<-- "docs_src/lowlevel/tutorial004.py" +``` + +* 구성할 때는 와이어 이름인 `_meta=`로 씁니다. 클라이언트는 `result.meta`로 읽습니다. +* 키에 네임스페이스를 붙이세요(`bookshop/record_ids`). `io.modelcontextprotocol/*` 키는 프로토콜이 예약해 두었습니다. + +!!! warning + `_meta`는 작성자와 클라이언트 애플리케이션 사이의 관례이지, 무엇이 모델에 도달하는지에 관한 보장이 + 아닙니다. 무엇을 렌더링할지는 호스트가 결정합니다. 도구 결과의 어느 부분에도 절대 비밀 값을 넣지 마세요. + +## 핸들러에 따라 결정되는 기능 {#capabilities-follow-your-handlers} + +`Server`는 핸들러를 제공한 메서드 군만 정확히 알립니다. 위의 `Bookshop`은 `on_list_tools`와 `on_call_tool`만 전달하고 다른 것은 전달하지 않으므로, 여기에 연결하는 클라이언트가 보는 것은 다음과 같습니다. + +```json +{"tools": {"listChanged": false}} +``` + +`resources`도 `prompts`도 없습니다. 뒷받침할 것이 없기 때문입니다. `on_list_prompts`를 전달하면 `prompts`가 나타나고, `on_completion`을 전달하면 `completions`가 나타납니다. + +`MCPServer`는 등록한 것이 있든 없든 항상 도구, 리소스, 프롬프트를 알립니다. 관리자 객체가 항상 존재하기 때문입니다. 여기서는 선언이 **곧** 생성자 호출입니다. + +## lifespan 제네릭 {#the-lifespan-generic} + +`Server`는 lifespan이 yield하는 타입에 대해 제네릭입니다. 어노테이션을 한 번 달면 그 객체가 나타나는 모든 곳에서 타입이 지정됩니다. + +```python title="server.py" hl_lines="24-26 44-45 50" +--8<-- "docs_src/lowlevel/tutorial005.py" +``` + +* lifespan은 `Callable[[Server[Catalog]], AbstractAsyncContextManager[Catalog]]` 형태이며, `async` 제너레이터에 `@asynccontextmanager`를 붙이면 정확히 이것이 됩니다. +* `yield`한 것은 무엇이든 `ctx.lifespan_context`가 되고, 핸들러에 `ServerRequestContext[Catalog]` 어노테이션이 달려 있으므로 `.search(...)`가 자동 완성되고 타입 검사를 통과합니다. +* 서버가 시작할 때 한 번 진입하고 멈출 때 한 번 빠져나옵니다. 시작, 정리, 그리고 같은 개념의 `MCPServer` 버전은 **[Lifespan](../handlers/lifespan.md)**에서 확인하세요. + +`lifespan=` 인수가 없으면 `ctx.lifespan_context`는 빈 `dict`입니다. + +## 직접 정의하는 메서드 {#a-method-of-your-own} + +생성자는 MCP가 정의한 메서드를 다룹니다. 그 밖의 모든 것은 `add_request_handler`가 다룹니다. + +```python title="server.py" hl_lines="35-36 39-40 43-44 48" +--8<-- "docs_src/lowlevel/tutorial006.py" +``` + +* 첫 번째 인수는 메서드 문자열입니다. 알림에는 짝이 되는 `add_notification_handler`가 있습니다. +* `params_type`은 핸들러가 실행되기 **전에** 들어오는 `params`를 검증하는 기준 모델입니다. 따라서 커스텀 메서드는 도구가 받지 못하는 검증을 **받습니다**. `_meta` 필드가 다른 모든 메서드처럼 파싱되도록 `RequestParams`를 상속하세요. +* 핸들러는 `BaseModel`, `dict`, `None` 중 하나를 반환합니다. SDK가 이를 JSON-RPC 결과로 직렬화합니다. + +솔직한 단서 하나가 있습니다. 고수준 `Client`에는 MCP가 정의한 메서드용 동사만 있으므로 `client.reindex()`는 없습니다. 벤더 메서드는 그 메서드의 존재를 이미 아는 상대를 위한 것입니다. 함께 배포하는 클라이언트나, JSON-RPC를 말하는 자체 서비스가 여기에 해당합니다. + +차지할 수 없는 메서드가 하나 있습니다. + +```text +ValueError: 'initialize' is handled by the server runner and cannot be overridden; +use Server.middleware to observe or wrap initialization +``` + +핸드셰이크는 러너의 소유입니다. `server/discover`, `ping`, 그 밖의 모든 내장 메서드는 자유롭게 대체할 수 있습니다. + +!!! tip + 오류 메시지에 언급된 `Server.middleware`는 `initialize`를 포함해 들어오는 **모든** 메시지를 감쌉니다. 새 메서드에 응답하는 것이 아니라 트래픽을 관찰하거나 다시 쓰고 싶다면 **[미들웨어](middleware.md)**부터 시작하세요. + +## 나머지 핸들러 {#the-other-handlers} + +다음은 각각 이제 이해할 어휘를 갖춘 개념 하나씩이며, 각각 별도의 페이지가 있습니다. + +* `on_call_tool`, `on_get_prompt`, `on_read_resource`는 호출을 일시 중지하고 클라이언트에 입력을 요청하기 위해 평소의 결과 대신 `InputRequiredResult`를 반환할 수 있습니다. **[다중 왕복 요청](../handlers/multi-round-trip.md)**을 참고하세요. 이 계층답게 대신 설치해 주는 것은 없습니다. `MCPServer`가 기본적으로 `requestState`를 봉인하는 반면, 여기서는 설정한 `request_state`가 `server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[...]), default_audience=server.name))`로 명시적으로 켜기 전까지 쓴 그대로 와이어를 건너갑니다. 이 한 줄(두 이름 모두 `mcp.server.request_state`에서 임포트합니다)이면 `MCPServer`가 수행하는 것과 동일한 봉인과 검증이 이루어집니다(**[`requestState` 보호하기](../handlers/multi-round-trip.md#protecting-requeststate)**). +* `on_list_resources`, `on_read_resource`, `on_list_prompts`, `on_get_prompt`, `on_completion`은 나머지 프리미티브용으로 같은 `(ctx, params) -> result` 형태입니다. +* `on_subscriptions_listen`은 2026-07-28의 `subscriptions/listen` 스트림을 제공합니다. `SubscriptionBus` 위에 만든 `ListenHandler`를 전달하고 다른 핸들러에서 버스로 이벤트를 발행하세요. 전체 구성은 **[구독](../handlers/subscriptions.md)**에서 확인하세요. +* `server.streamable_http_app()`은 `MCPServer`의 것과 같은 Starlette 앱을 반환합니다. **[서버 실행하기](../run/index.md)**에서 다른 ASGI 앱을 배포하는 방식 그대로 배포하세요. 여기에는 `server.run(transport=...)` 같은 것이 없습니다. `server.run(read_stream, write_stream, server.create_initialization_options())` 호출이 스트림 한 쌍 위에서 연결 하나를 구동하며, 이 한 줄이 전부입니다. + +## 요약 {#recap} + +* 저수준 `Server`는 핸들러를 `on_*` **생성자 매개변수**로 받으며, 모든 핸들러는 `async (ctx, params) -> result`입니다. +* `input_schema` dict를 직접 작성하고 `CallToolResult`를 직접 만듭니다. 대신 도출하거나 감싸거나 검증해 주는 것은 없습니다. +* 핸들러의 예외는 `-32603` 프로토콜 오류입니다. 모델이 읽을 수 있는 도구 오류는 `is_error=True`인 `CallToolResult`이며 **직접** 반환해야 합니다. +* 결과의 `_meta`는 모델이 아니라 클라이언트 애플리케이션에 보내는 것입니다. +* `Server[T]`는 lifespan이 yield하는 것에 대해 제네릭이며, `ctx.lifespan_context`는 타입이 지정된 `T`입니다. +* `add_request_handler(method, params_type, handler)`는 어떤 메서드든 제공합니다. `initialize`는 예약되어 있습니다. +* `Server`가 알리는 기능은 등록한 핸들러에서 도출됩니다. + +`Client(server)`가 두 서버를 똑같이 다룬 것은 둘이 **같은** 프로토콜이기 때문이며, 바로 그 점이 핵심입니다. 그다음 아래 계층은 클래스가 아닙니다. 바로 **[미들웨어](middleware.md)**입니다. diff --git a/i18n/ko/pages/advanced/middleware.md b/i18n/ko/pages/advanced/middleware.md new file mode 100644 index 0000000000..0099e795cb --- /dev/null +++ b/i18n/ko/pages/advanced/middleware.md @@ -0,0 +1,125 @@ +--- +translation: + sections: [6048b4f308edbb8c, 068bda0f21ee9c1b, c3e565b61acd75c5, c62422b159c6ed09, 47204fab253cc45c] + tool: 1 +--- +# 미들웨어 {#middleware} + +**미들웨어**는 서버가 받는 모든 메시지를 감싸는 하나의 async 함수입니다. + +`async (ctx, call_next)` 형태로 작성해서 `server.middleware`에 추가하면 됩니다. 이것이 API의 전부입니다. + +!!! warning + 미들웨어 목록은 소스에서 **잠정적**(provisional)으로 표시되어 있습니다. 시그니처와 동작 의미는 + 2.x 마이너 릴리스에서 바뀔 수 있습니다. 메시지를 **관찰**(시간 측정, 로깅, 트레이싱)하고 + **거부**하는 데 사용하세요. 서버가 딛고 서는 기반으로 삼지는 마세요. + +`MCPServer`는 생성 시 목록을 받아(`MCPServer(name, middleware=[...])`) `mcp.middleware`로 노출하고, +저수준 `Server`는 같은 목록을 `server.middleware`로 노출합니다. 아래 예제는 저수준 `Server`를 +사용합니다. `Server(name, on_call_tool=...)`가 처음이라면 +**[저수준 Server](low-level-server.md)**를 먼저 읽으세요. + +## 시간을 재는 미들웨어 {#a-timing-middleware} + +서버 하나, 도구 하나, 그리고 메시지마다 걸린 시간을 로그로 남기는 미들웨어 하나입니다. + +```python title="server.py" hl_lines="39-45 49" +--8<-- "docs_src/middleware/tutorial001.py" +``` + +* `ctx`는 핸들러가 받는 것과 같은 `ServerRequestContext`입니다. `ctx.method`는 원시 메서드 + 문자열이고, `ctx.params`는 어떤 검증도 거치기 **전**의 원시 params입니다. +* `call_next(ctx)`는 체인의 나머지, 즉 검증, 핸들러 조회, 작성한 핸들러를 실행합니다. + 반환된 값을 그대로 반환하면 응답은 손대지 않은 채로 나갑니다. +* `try`/`finally`는 의도적인 선택입니다. 예외를 일으키는 핸들러도 시간이 측정됩니다. 실패는 + `call_next`에서 빠져나오는 예외로 미들웨어에 도달하기 때문입니다. +* `server.middleware.append(...)`로 등록합니다. 목록은 바깥쪽부터 실행되므로 + `middleware[0]`이 와이어에 가장 가까운 미들웨어입니다. + +### 직접 해 보기 {#try-it} + +클라이언트를 연결하고, 도구 목록을 조회하고, 하나를 호출해 보세요. 로그에는 **세** 줄이 남습니다. + +```text +server/discover took 18.3 ms +tools/list took 0.1 ms +tools/call took 0.1 ms +``` + +호출은 두 번 했는데 줄은 세 개입니다. 첫 번째 줄은 `server/discover`로, 아무것도 요청하기 전에 +클라이언트가 연결을 설정하려고 보낸 요청입니다. + +바로 이것이 핵심입니다. 미들웨어는 들어오는 **모든** 메시지를 감쌉니다. + +* 연결 설정. `server/discover`이거나, 레거시 세션에서는 `initialize`와 + `notifications/initialized`입니다. +* 모든 요청과 모든 알림. 알림의 경우 `ctx.request_id is None`이고, + `call_next(ctx)`는 `None`을 반환하며, 무엇을 반환하든 버려집니다. +* 서버에 핸들러가 없는 메서드까지도 포함됩니다. `call_next`가 + `MCPError(-32601, "Method not found")`를 일으키고, 이 예외는 클라이언트로 가는 길에 + 미들웨어를 **통과합니다**. + +## 미들웨어 안에서 할 수 있는 일 {#what-you-can-do-inside-one} + +망설임이 적게 필요한 것부터 순서대로 나열합니다. + +* **관찰.** 시간을 재고, 횟수를 세고, 로그를 남기세요. 위의 예제가 이에 해당합니다. +* **거부.** `call_next(ctx)`를 호출하는 **대신** `MCPError`를 일으키면 그 메시지 하나에 + JSON-RPC 오류로 응답합니다. 연결은 유지되고 다음 메시지는 그대로 통과합니다. 서버가 + 호출자별로 `subscriptions/listen`을 제한하는 방법이 바로 이것입니다. 구독 페이지의 + **[누가 지켜볼 수 있는지 정하기](../handlers/subscriptions.md#deciding-who-may-watch)**에서 + 단계별로 설명합니다. +* **재작성.** `ctx`는 데이터클래스입니다. `await call_next(dataclasses.replace(ctx, params=...))`는 + 체인의 나머지에 클라이언트가 보낸 것과 다른 params를 넘깁니다. `initialize`에는 절대 이렇게 + 하지 마세요. 클라이언트가 돌려받는 결과는 재작성한 params로 만들어지지만, 서버는 원래 와이어 + params를 기준으로 연결 상태를 확정합니다. 양쪽이 무엇을 협상했는지 서로 다르게 이해한 채로 + 핸드셰이크를 마칠 수 있습니다. +* **응답.** `call_next(ctx)`를 호출하지 않고 결과를 반환하면 그 결과가 응답으로 클라이언트에 + 전달됩니다. `call_next`는 완성된 와이어 형식을 넘겨주고, 파이프라인은 반환한 값을 손보지 + 않으므로 봉투 전체가 미들웨어의 몫입니다. 2026년 세대의 연결에서는 `serverInfo` `_meta` + 스탬프가 여기에 포함되는데, SDK는 이를 핸들러 결과에는 추가하지만 미들웨어가 반환한 결과에는 + 추가하지 않습니다. + +!!! check + `initialize`도 미들웨어가 감싸는 대상 중 하나이며, 미들웨어는 이에 대해 얻을 수 + 있는 **유일한** 훅입니다. `add_request_handler`로 가로채려고 하면 SDK가 거부합니다. + + ```text + ValueError: 'initialize' is handled by the server runner and cannot be overridden; + use Server.middleware to observe or wrap initialization + ``` + +!!! warning + `initialize`는 인라인으로 처리됩니다. 미들웨어 체인이 반환할 때까지 서버는 들어오는 메시지를 + 더 읽지 않습니다. 따라서 `initialize`를 처리하는 동안 서버에서 클라이언트로 가는 요청 + (`ctx.session.send_request(...)`, 엘리시테이션(elicitation))을 await하면 **연결이 교착 상태에 + 빠집니다**. 기다리는 응답은 결코 읽힐 수 없기 때문입니다. 보내고 잊는 방식의 알림은 괜찮습니다. + +## 기본으로 켜져 있는 단 하나의 미들웨어 {#the-one-middleware-that-ships-on-by-default} + +SDK에는 미들웨어가 정확히 하나 포함되어 있으며, 이미 서버의 목록에 들어 있습니다. 모든 메시지마다 +OpenTelemetry 스팬을 내보내는 미들웨어입니다. 직접 추가할 필요가 없고, 대부분의 경우 신경 쓸 +필요도 없습니다. 익스포터를 설치하기 전까지는 아무 일도 하지 않으며, 별도의 페이지가 있습니다. +**[OpenTelemetry](../run/opentelemetry.md)**를 참고하세요. + +!!! info + ASGI 미들웨어를 작성해 본 적이 있다면 이 형태가 이미 익숙할 것입니다. Starlette의 + `(scope, receive, send)`가 `(ctx, call_next)`가 되었고, 트랜스포트 **이후에**, 원시 HTTP 요청이 + 아니라 디코딩된 메시지를 대상으로 실행됩니다. 둘은 함께 조합됩니다. `streamable_http_app()`에 + 붙인 Starlette 미들웨어는 HTTP를 보고, 이 미들웨어는 MCP를 봅니다. + +## 요약 {#recap} + +* 미들웨어는 `async (ctx, call_next) -> result` 형태이며, `MCPServer(middleware=[...])`로 + 전달하거나(또는 `mcp.middleware`에 추가하거나) 저수준 `Server`에서는 `server.middleware`에 + 추가합니다. +* 들어오는 **모든** 메시지(`server/discover`, `initialize`, 요청, 알림, 알 수 없는 메서드)를 + 감싸며 바깥쪽부터 실행됩니다. +* `ctx.request_id is None`으로 알림과 요청을 구분합니다. +* `call_next`를 호출하는 대신 예외를 일으키면 메시지 하나를 거부합니다. 연결은 유지됩니다. +* SDK 자체의 OpenTelemetry 트레이싱도 미들웨어이며, 이미 목록에 있습니다. + **[OpenTelemetry](../run/opentelemetry.md)**를 참고하세요. +* 이 표면 전체가 잠정적입니다. 관찰하는 데 사용하고, 그 위에 무언가를 쌓지는 마세요. + +요청을 감싸는 것은 이것이 전부입니다. 요청이 애초에 실행될 수 있는지를 결정하는 것은 +**[인가](../run/authorization.md)**입니다. diff --git a/i18n/ko/pages/advanced/pagination.md b/i18n/ko/pages/advanced/pagination.md new file mode 100644 index 0000000000..def248d824 --- /dev/null +++ b/i18n/ko/pages/advanced/pagination.md @@ -0,0 +1,85 @@ +--- +translation: + sections: [a9aba7a026c7bd85, ed32bda7ba9ae33a, 7e64cc5646abb91f, 22a0129ee78b3c63, d875373c06d8d2f9] + tool: 1 +--- +# 페이지네이션 {#pagination} + +대부분의 서버에는 필요 없는 기능입니다. + +`MCPServer`는 모든 `list_*` 요청에 가진 것을 전부 한 페이지에 담아 `next_cursor=None`으로 응답합니다. 도구, 리소스, 프롬프트가 수십 개 정도라면 이것이 올바른 동작이며, 설정할 것은 아무것도 없습니다. + +페이지네이션은 리소스 목록이 사실상 데이터베이스인 서버를 위한 것입니다. 한 번의 응답으로 직렬화하기에는 무리인 수천 개의 행이 있는 경우입니다. 프로토콜의 해법은 **커서**입니다. 서버는 페이지 하나와 불투명한 토큰을 함께 반환하고, 클라이언트는 그 토큰을 다시 보내 다음 페이지를 받습니다. + +`@mcp.resource()`에는 이를 위한 훅이 없습니다. 페이지를 나누려면 **[저수준 Server](low-level-server.md)**에서 목록 핸들러를 직접 작성해야 합니다. + +## 페이지를 나누는 서버 {#a-server-that-pages} + +```python title="server.py" hl_lines="12 15-16" +--8<-- "docs_src/pagination/tutorial001.py" +``` + +* 저수준 `Server`에서 핸들러는 데코레이터가 아니라 생성자 인자입니다. `on_list_resources`가 모든 `resources/list` 요청에 응답하며, 연결 작업은 이것이 전부입니다. +* 페이지를 나누는 핸들러는 모두 `params: PaginatedRequestParams | None` 타입을 받으며, 예제는 두 경우를 모두 처리합니다. 다만 실제 연결에서는 SDK가 `None`을 넘기는 일이 없습니다(`params` 멤버가 없는 요청은 기본값이 채워진 모델로 핸들러에 도달합니다). 따라서 의미 있는 신호는 `params.cursor is None`이며, 이는 **처음부터 시작하라**는 뜻입니다. +* 커서가 **무엇인지**는 직접 정합니다. 여기서는 문자열로 표현한 오프셋입니다. 타임스탬프, 기본 키, base64 덩어리 등 내보낼 때 만들어 낼 수 있고 돌아왔을 때 알아볼 수 있는 것이면 무엇이든 됩니다. +* `next_cursor=None`은 "이것이 마지막 페이지였다"고 알리는 방법입니다. 개수도, 총계도, `has_more`도 없습니다. `None`이 신호의 전부입니다. + +!!! tip + `PAGE_SIZE`를 10으로 둔 것은 예제를 읽기 쉽게 하기 위해서입니다. 실제 값은 엔드포인트마다 정하세요. + 한 줄짜리 리소스 목록이라면 500개 페이지도 감당할 수 있지만, 덩치 큰 프롬프트 템플릿 목록은 그럴 수 없습니다. + 클라이언트는 여기에 관여할 수 없으며, 이는 의도된 설계입니다. + +### 직접 해 보기 {#try-it} + +`Client(server)`는 `MCPServer`에 연결할 때와 똑같이 저수준 `Server`에 인메모리로 연결합니다. + +인자 없이 `list_resources()`를 호출하세요. `book-1`부터 `book-10`까지 리소스 10개가 돌아오고, `next_cursor`는 문자열 `"10"`입니다. + +이를 `list_resources(cursor="10")`으로 다시 넘기면 첫 번째 리소스는 `book-11`이고, 새 `next_cursor`는 `"20"`입니다. + +열 번째 페이지는 `next_cursor`가 `None`으로 설정되어 돌아옵니다. 끝입니다. + +## 클라이언트 루프 {#the-client-loop} + +`Client`의 모든 `list_*` 메서드(`list_tools`, `list_resources`, `list_resource_templates`, `list_prompts`)는 `cursor=` 키워드를 받습니다. 페이지로 나뉜 목록을 전부 가져오는 것은 `while True` 하나면 됩니다. + +```python title="client.py" hl_lines="26-32" +--8<-- "docs_src/pagination/tutorial002.py" +``` + +* `cursor`는 `None`으로 시작하므로 첫 요청에는 커서가 실리지 않습니다. +* `next_cursor`를 확인하기 **전에** 결과를 덧붙이세요. 마지막 페이지에도 리소스가 있습니다. +* `next_cursor is None`이 종료 조건입니다. 그 외의 값은 손대지 않고 그대로 `cursor=`에 다시 넣습니다. + +이 파일의 `main()`을 실행하면 `100 resources`가 출력됩니다. 열 개씩 열 페이지가, 페이지가 열 개라는 사실조차 모르는 루프에 의해 하나로 이어 붙여진 결과입니다. + +이 루프는 **[클라이언트](../client/index.md)**에서 모든 `list_*` 동사에 대해 보여 주는 것과 같은 루프이며, 페이지를 나누지 않는 서버에 대해서도 비용이 들지 않습니다. 첫 응답에서 `next_cursor`가 `None`이므로 루프는 한 번만 돕니다. + +## 세 가지 규칙 {#the-three-rules} + +**커서는 불투명합니다.** 클라이언트는 커서를 파싱하거나, 만들거나, 추측해서는 안 됩니다. 커서를 얻는 유일하게 정당한 출처는 이전 페이지의 `next_cursor`를 그대로 쓰는 것입니다. + +**페이지 크기는 서버가 정합니다.** 프로토콜에 `limit=`은 없습니다. 다른 페이지 크기가 필요하면 서버를 바꿔야 합니다. + +**페이징을 무시하는 클라이언트도 여전히 동작합니다.** `list_resources()`를 한 번 호출하고, 처음 열 개를 받고, 버린 `next_cursor`는 알아채지 못합니다. 아무것도 깨지지 않습니다. 덜 보일 뿐입니다. + +!!! check + 불투명하다는 것은 말 그대로 불투명하다는 뜻입니다. 커서를 지어내면(`list_resources(cursor="page-2")`) + 프로토콜이 해 줄 수 있는 것은 아무것도 없습니다. 이 서버는 `int("page-2")`를 시도하고, 핸들러는 예외를 + 일으키며, 클라이언트에게 돌아오는 것은 다음과 같습니다. + + ```text + MCPError(-32603, 'Internal server error', None) + ``` + + 서버에서 받지 않은 커서는 기능 요청이 아니라 버그입니다. + +## 요약 {#recap} + +* `MCPServer`는 모든 것을 한 페이지로 반환합니다. 페이지네이션은 선택 사항이며, 저수준 `Server`에서 선택합니다. +* `on_list_resources`(그리고 `on_list_tools`, `on_list_prompts`, `on_list_resource_templates`)는 `PaginatedRequestParams | None`을 받으며, 첫 페이지에서는 `params.cursor`가 `None`입니다. +* 페이지와 함께 `next_cursor`를 반환합니다. 나중에 알아볼 수 있는 문자열이면 무엇이든 되고, 남은 것이 없으면 `None`입니다. +* 클라이언트 루프는 `cursor=`를 전달하고, 누적하고, `next_cursor is None`이 될 때까지 반복합니다. +* 커서는 불투명하고, 페이지 크기는 서버가 정하며, 페이징을 하지 않는 클라이언트도 첫 페이지는 받습니다. + +직접 작성하는 `Server` API의 나머지(`on_call_tool`, `input_schema` 딕셔너리, `_meta`)는 **[저수준 Server](low-level-server.md)**에서 확인하세요. diff --git a/i18n/ko/pages/client/caching.md b/i18n/ko/pages/client/caching.md new file mode 100644 index 0000000000..026272c29f --- /dev/null +++ b/i18n/ko/pages/client/caching.md @@ -0,0 +1,122 @@ +--- +translation: + sections: [9e7b9a1710e5aeba, b74ca4c1d2ddddee, fa8714e61bf90c5a, 04db67a886b7271c, 857690fb8f876800] + tool: 1 +--- +# 캐싱 힌트 {#caching-hints} + +2026-07-28 프로토콜에서는 서버가 `tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read`, `server/discover`에 대해 반환하는 모든 결과에 두 필드가 실립니다. 클라이언트가 결과를 신선한 것으로 취급해도 되는 밀리초 수인 `ttlMs`와, 캐시된 결과를 사용자 간에 공유해도 되는지(`"public"`) 아니면 하나의 인가 컨텍스트에 속하는지(`"private"`)를 나타내는 `cacheScope`입니다. + +서버는 아무것도 캐시하지 않습니다. 이 필드는 **선언**입니다. "이 도구 목록은 모두에게 동일하며 1분 동안 바뀌지 않습니다"라는 뜻입니다. 그러면 클라이언트(또는 서버 앞단의 게이트웨이)가 왕복을 생략할 수 있습니다. 힌트를 따를지는 클라이언트의 선택이고, 힌트를 내보내는 것은 서버의 일이며, SDK가 이를 대신 처리합니다. + +기본적으로 모든 결과는 `ttlMs: 0, cacheScope: "private"`라고 말합니다. 즉시 만료되고 절대 공유되지 않는다는 뜻입니다. 이는 언제나 안전하고 언제나 규격에 맞습니다. 목록이 정말로 안정적이고 모든 호출자에게 동일하다면 생성 시점에 그렇게 알려 주세요. + +```python title="server.py" hl_lines="5-8" +--8<-- "docs_src/caching/tutorial001.py" +``` + +* 맵의 키는 **메서드 이름**이며, 캐시 가능한 여섯 메서드만이 유효한 키입니다. 매개변수 타입이 `Mapping[CacheableMethod, CacheHint]`이므로 에디터가 키를 자동 완성하고 실행 전에 오타를 표시합니다. 타입 검사기를 빠져나간 것은 생성 시점에 예외를 일으킵니다. +* 언급하지 않은 메서드는 기본값을 유지합니다. 맵은 재정의 모음이지 전체 명세가 아닙니다. +* `CacheHint(ttl_ms=5_000)`은 `scope`를 설정하지 않았으므로 `"private"`로 남습니다. 호출자별로 5초 동안 신선합니다. 범위와 TTL은 서로 독립적인 결정입니다. +* `"server/discover"`도 유효한 키입니다. 디스커버리 결과도 다른 목록처럼 캐시할 수 있기 때문입니다. + +!!! warning + `cacheScope: "public"`은 캐시된 응답이 **누구에게나** 제공될 수 있다는 뜻입니다. 공유 + 게이트웨이는 요청이 인증된 경우에도 한 사용자의 결과를 다른 사용자에게 기꺼이 건네줍니다. + 결과가 모든 호출자에게 동일할 때만 `"public"`으로 표시하고, `cacheScope`를 접근 제어로 + 쓰지 마세요. 이것은 라벨이지 자물쇠가 아닙니다. + +## 핸들러별 재정의 {#per-handler-override} + +저수준 `Server`에서는 핸들러가 결과를 직접 조립하며, `ttl_ms` / `cache_scope`는 결과 모델의 필드일 뿐입니다. 이 필드를 명시적으로 설정한 핸들러는 필드 단위로 언제나 생성자 맵보다 우선합니다. + +```python title="server.py" hl_lines="10 16" +--8<-- "docs_src/caching/tutorial002.py" +``` + +핸들러는 `ttl_ms=1_000`이라고 했고 범위는 언급하지 않았습니다. 실제로 전송되는 값은 `ttlMs: 1000`(맵의 `60_000`이 아니라 핸들러의 값)과 `cacheScope: "public"`(핸들러가 설정하지 않았으므로 맵의 값)입니다. 명시적 설정이 구성값을 이기고, 구성값이 기본값을 이깁니다. 이 규칙은 필드별로 적용되므로 핸들러는 한 필드만 고정하고 다른 필드는 서버 전역 정책에 맡길 수 있습니다. + +이는 생성자가 알 수 없는 동적인 상황을 위한 탈출구이기도 합니다. `resources/read`를 사용자별로 필터링하는 핸들러는 그 밖에는 public인 서버에서 특정 URI 하나만 `cache_scope="private"`로 반환할 수 있습니다. + +페이지로 나뉜 목록에 관한 주의 사항이 하나 있습니다. 프로토콜은 한 목록의 **모든 페이지에서 같은 `cacheScope`**를 요구합니다. 생성자 맵은 페이지가 아니라 메서드를 키로 하므로 구조상 이를 만족합니다. 그러나 범위를 직접 재정의하는 핸들러는 그 일관성을 스스로 책임집니다. 커서가 있을 때만이 아니라 **모든** 페이지에서 재정의하세요. 그러지 않으면 1페이지와 2페이지가 서로 어긋납니다. + +## 클라이언트가 보는 것 {#what-the-client-sees} + +2026-07-28 세션에서는 `Client`가 힌트를 대신 따릅니다. 기본으로 켜져 있는 내장 응답 캐시가 있기 때문입니다. `ttlMs`를 싣고 도착한 결과는 저장되고, 그 TTL 안에 동일한 호출이 오면 왕복 없이 캐시에서 제공됩니다. 힌트가 **없는** 결과는 캐시되지 않습니다. 힌트 없는 결과에는 `CacheConfig.default_ttl_ms`가 적용되는데 기본값이 `0`(즉시 만료)이므로, 아무것도 선언하지 않는 서버는 늘 그랬듯 호출마다 요청이 오는 트래픽을 그대로 보게 됩니다. + +```python title="client.py" hl_lines="33 35 38" +--8<-- "docs_src/caching/tutorial003.py" +``` + +호출은 네 번, 서버에서 가져온 것은 세 번입니다. 두 번째 호출은 신선한 항목을 찾았고 서버에 도달하지 않았습니다. (주입된) 시계를 TTL 너머로 진행시키자 세 번째 호출은 다시 가져왔고, 네 번째 호출은 `cache_mode="refresh"`를 지정했습니다. 이 키워드 인자는 캐싱 동사 다섯 개(`list_tools`, `list_prompts`, `list_resources`, `list_resource_templates`, `read_resource`)에 있습니다. + +* `"use"`(기본값)는 신선한 항목이 있으면 해당 항목을 제공하고, 없으면 가져와서 저장합니다. +* `"refresh"`는 캐시에서 제공하는 일이 없습니다. 가져와서 결과를 저장하며, 캐시된 내용이 무엇이든 대체합니다. +* `"bypass"`는 캐시를 전혀 건드리지 않고 왕복합니다. 읽기도 쓰기도 없습니다. + +`"use"` 위에 규칙이 하나 더 있습니다. **`meta`를 담은 호출은 언제나 서버에 도달합니다.** `meta`가 설정된 요청(진행률 토큰, 추적 필드)은 실제로 전송되는 요청을 기대하므로, `cache_mode="use"`에서는 `"refresh"`로 취급됩니다. 캐시 읽기는 건너뛰고, 가져온 결과는 여전히 캐시된 항목을 대체합니다. `"bypass"`와 명시적 `"refresh"`는 평소대로 동작합니다. + +캐싱을 완전히 끄려면 `Client(server, cache=None)`으로 생성하세요. 모든 호출이 다시 왕복이 되며, `cache_mode`는 여전히 받아들여지지만 아무 일도 하지 않습니다. + +범위도 자동으로 존중됩니다. `"private"` 항목은 캐시의 **파티션**(아래 참고)을 키로 하고, `"public"` 항목은 더 넓은 공유를 선택할 수 있습니다. 그리고 알림이 지목하는 바로 그 항목에 대해서는 **알림이 TTL보다 우선합니다**. `list_changed` 알림은 일치하는 캐시된 목록을 축출하고, `resources/updated`는 정확히 그 URI로 저장된 캐시된 읽기 결과를 축출합니다. 얼마나 신선했든 상관없습니다. 2026-07-28 연결에서 이 알림은 `client.listen(...)`으로 여는 `subscriptions/listen` 스트림으로 도착하며, 축출은 감시자가 이벤트를 보기 전에 완료됩니다. 자세한 내용은 **[구독](subscriptions.md)**에서 확인하세요. + +`resources/updated`에 관한 주의 사항이 하나 있습니다. 축출은 정확히 일치하는 URI에만 적용됩니다. 스토어 계약에는 열거나 스캔하는 연산이 없으므로(참조 TypeScript 구현과 동일) **하위** 리소스 URI를 담은 알림은 부모의 캐시된 읽기 결과를 축출하지 않습니다. 서버가 하위 리소스를 이런 식으로 알린다면 `cache_mode="refresh"`로 부모를 다시 가져오세요. + +### `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`: 항목이 저장되는 곳입니다. 기본값은 클라이언트마다 새로 만드는 인메모리 스토어입니다. 클라이언트나 프로세스 간에 캐시를 공유하려면 직접 만든 `ResponseCacheStore` 구현(예: Redis 기반)을 전달하세요. 계약 타입(`ResponseCacheStore`, `CacheKey`, `CacheEntry`, 기본 `InMemoryResponseCacheStore`)은 `mcp.client`에서 가져올 수 있습니다. 조회 한 번에 스토어 `get`을 순차적으로 최대 두 번(private 쪽, 그다음 public 쪽) 호출할 수 있으므로 원격 스토어의 지연 기대치를 그에 맞게 잡으세요. 사용자 정의 스토어에는 명시적인 `partition`이 **필수**입니다. +* `partition`: 공유 스토어 안에서 한 주체의 `"private"` 항목이 다른 주체에게 제공되지 않도록 하는 인가 컨텍스트 라벨입니다. +* `target_id`: 명시적인 서버 식별자로, 사용자 정의 트랜스포트와 인프로세스 서버용입니다(아래 참고). +* `default_ttl_ms`: `ttlMs` 힌트가 없는 결과에 적용되는 TTL입니다. 기본값 `0`은 힌트 없는 결과를 캐시하지 않습니다. +* `share_public`: 서버가 `"public"`이라고 단언한 항목을 파티션 간에 제공합니다(아래 참고). 기본으로 꺼져 있습니다. +* `clock`: 에포크 초 단위의 벽시계 소스입니다. 위 예제처럼 하나를 주입하면 만료 테스트에 sleep이 필요 없습니다. + +!!! warning "파티션 = 검증된 주체" + `partition`은 검증된 토큰의 subject 같은 **검증된 자격 증명**에서 도출하세요. 요청이 제공한 데이터에서 도출하지 말고, 서버 URL에서도 도출하지 마세요(서버 식별자는 별도의 키 축입니다). SDK는 자체 인증이 없는 라이브러리입니다. 신뢰의 기준점은 `CacheConfig`를 생성하는 쪽, 즉 테넌트가 아니라 배포입니다. 멀티테넌트 게이트웨이는 인증된 주체마다 `CacheConfig`를 하나씩 만듭니다. + + 파티션은 `Client`의 수명 동안 고정되기도 합니다. 연결의 인가 컨텍스트가 세션 도중 바뀌면(예를 들어 다른 주체로 재인증하는 경우) 캐시는 따라가지 않습니다. 새 주체용으로 새 `Client`를 생성하세요. + +캐시 키에는 **서버의 식별자**도 담깁니다. 연결한 URL 문자열에서 `user:pass@` 형태의 userinfo만 제거하고 나머지는 바이트 그대로입니다. 대소문자 접기도, 쿼리 재정렬도, 끝 슬래시 정리도 없습니다. 정규화를 덜 하면 공유 기회를 잃을 뿐이지만, 지나치게 정규화하면 두 테넌트(`?tenant=a`와 `?tenant=b`)를 합쳐 버릴 수 있으므로, 겉보기에 다른 URL은 그냥 항목을 공유하지 않습니다. URL이 없을 때(인프로세스 서버나 `Transport` 인스턴스)는 클라이언트가 대신 인스턴스별 무작위 식별자를 받습니다. 서버에 이름을 붙이려면 `CacheConfig.target_id`를 설정하세요(사용자 정의 스토어에서는 필수이며 생성 시점에 그렇게 알려 줍니다). 식별자는 키 재료에 들어가기 전에 sha256으로 해시되므로 쿼리 문자열에 비밀을 담은 URL이 스토어 키에 나타나는 일은 없습니다. 해시 전 형태를 직접 로그로 남기지도 마세요. + +!!! warning "`share_public`은 서버를 플릿 전체 단위로 신뢰합니다" + 기본적으로 `"public"` 항목조차 자기 파티션 안에 머뭅니다. `share_public=True`는 서버가 `cacheScope: "public"`으로 표시한 항목을 스토어를 사용하는 **모든** 파티션에 제공하며, 그 모두를 대신해 서버의 분류를 신뢰합니다. 그러면 테넌트별 데이터에 `"public"`을 찍는 서버(버그든 악의든)는 한 테넌트의 응답을 다른 테넌트에게 유출합니다. 이 플래그는 의도적으로 생성자 수준에만 있습니다. 호출별 `cache_mode`는 캐싱을 좁힐 수 있지만, 호출별 설정 어느 것도 공유를 넓힐 수는 없습니다. + +### 캐시가 하지 않는 일 {#what-the-cache-never-does} + +* **세션 계층 호출은 캐시를 우회합니다.** `client.session.list_tools()`와 같은 메서드는 언제나 왕복합니다. 캐시는 `Client` 동사에 있습니다. +* **`server/discover`는 캐시에 들어가지 않습니다.** 디스커버 결과는 연결 시 한 번 전달되며, `ttlMs`를 담고 있어도 응답 캐시에 들어가지 않습니다. 재연결 탐지를 건너뛰려고 직접 보관한다면([`prior_discover`](../protocol-versions.md#reconnecting-with-prior_discover)) 그 신선도 관리는 직접 해야 합니다. `DiscoverResult`는 바로 그 용도로 이미 파싱된 `ttl_ms`와 `cache_scope`를 담고 있습니다. +* **이어지는 페이지는 캐시되지 않습니다.** 커서 없는 호출만 참여합니다. 만료된 커서 때문에 거부된 이어지는 페이지는 캐시된 목록을 **축출**합니다. 그 아래에서 목록이 바뀌었기 때문입니다. +* **다중 왕복 읽기는 캐시되지 않습니다.** `input_responses`/`request_state`로 시작했거나 입력 라운드를 거쳐 해결되는 `read_resource`는 캐시에 들어가지 않습니다(명세의 MUST). +* **알림 기반 축출에는 알림이 필요합니다.** 축출은 트랜스포트의 전달 품질만큼만 동작하며, 현대적인 인프로세스 경로(기본 `mode="auto"`의 `Client(server)`)는 현재 단독 알림을 전달하지 않습니다. +* **축출은 즉각적이 아니라 결과적으로 일어납니다.** 전송 경로 알림은 생성된 태스크에서 디스패치되므로, 알림 도착과 경합하는 호출은 축출 전 항목을 한 번 더 받을 수 있습니다. 그 구간은 디스패치 지연으로 제한되며 축출은 결국 적용됩니다. +* **stale-if-error는 없습니다.** 다시 가져오기가 실패했다고 해서 만료된 항목이 제공되는 일은 없습니다. 오류가 전파됩니다. +* **조기 재요청은 없습니다.** 저장된 항목은 TTL이 만료될 때까지 제공되고, 그다음 첫 호출이 왕복 비용을 냅니다. 백그라운드에서 갱신되는 것은 없습니다. +* **병합은 없습니다.** 동시에 일어난 동일한 호출 두 개는 두 번 가져옵니다. +* **24시간을 넘는 TTL은 없습니다.** 더 큰 `ttlMs`는 서버가 보냈든 설정했든 저장 시점에 잘립니다(`mcp.client.caching.MAX_TTL_MS`). 힌트가 아무리 넉넉해도 어떤 항목이든 제공될 수 있는 기간에 상한을 둡니다. +* **공유 스토어**에서는 클라이언트끼리 경합합니다. 각 클라이언트는 진행 중인 가져오기를 축출이 추월했을 때 자기 쓰기를 버리지만, **공동 테넌트** 클라이언트는 자신이 보지 못한 축출이 제거한 항목을 여전히 다시 써넣을 수 있습니다. 그리고 그 경합 관리 자체에도 한계가 있습니다. 추적 키가 4096개를 넘으면 가장 오래된 키의 가드부터 버려집니다. 두 구간 모두 허용된 것이며, 위의 TTL 상한으로 닫힙니다. +* **프로토콜 세대를 넘어 제공하지 않습니다.** 항목은 협상된 프로토콜 버전에 한정됩니다. 공유 영속 스토어에서 세션은 다른 협상 버전으로 기록된 항목을 제공하지 않습니다(SDK가 구버전 세션용으로 2026 필드를 제거하므로 같은 목록이라도 세대별로 실제로 다릅니다). 축출도 마찬가지로 현재 세대의 항목만 건드리며, 다른 세대의 항목은 TTL로 자연히 만료됩니다. + +### 힌트를 직접 읽기 {#reading-the-hints-yourself} + +힌트는 캐시 가능한 모든 결과의 평범한 필드이기도 하므로(`result.ttl_ms`와 `result.cache_scope`, 이미 파싱됨), 내장 캐시 위에(또는 대신에) 자체 관리 로직을 얹고 싶을 때 쓸 수 있습니다. + +**구버전 서버**(2026 이전 프로토콜)를 상대하면 이 필드는 전송되는 메시지에 아예 없고, 모델은 보수적인 기본값을 보여 줍니다. `ttl_ms == 0`과 `cache_scope == "private"`, 즉 만료 상태이며 공유되지 않음으로, 아무것도 선언하지 않은 서버에 대한 올바른 가정입니다. 캐시는 레거시 세션도 같은 방식으로 다룹니다. 거기서는 힌트를 전혀 참고하지 않고(전송 메시지에 어떤 키가 나타나든) `default_ttl_ms`만 적용되며, 그 기본값 `0`은 아무것도 캐시하지 않으므로 2026 이전 연결은 캐시가 존재하기 전과 정확히 똑같이 동작합니다. "서버가 0이라고 했다"와 "서버가 아무 말도 안 했다"를 구별해야 한다면 `"ttl_ms" in result.model_fields_set`을 확인하세요. 필드가 실제로 도착했을 때만 설정됩니다. + +## 구버전 클라이언트 {#older-clients} + +2026 이전 프로토콜 버전의 클라이언트는 두 필드 모두 보지 못합니다. SDK가 해당 연결에서는 직렬화 시점에 이 필드를 제거합니다. 힌트는 한 번만 설정하세요. 버전별로 따로 작성할 것은 없습니다. + +## 요약 {#recap} + +* 여섯 메서드가 `ttlMs`/`cacheScope`를 담습니다. SDK는 기본값을 `0`/`"private"`, 즉 만료 상태이고 공유되지 않음으로 두며, 이는 언제나 안전합니다. +* 생성 시점의 `cache_hints={method: CacheHint(...)}`(`MCPServer`와 `Server` 모두)는 메서드별로 서버 전역 값을 설정합니다. +* 결과에 필드를 설정한 핸들러는 필드 단위로 맵을 재정의합니다. +* `"public"`은 결과가 모든 호출자에게 동일하다는 약속입니다. 접근 제어가 아닙니다. +* `Client`는 힌트를 자동으로 따릅니다. 응답 캐시는 기본으로 켜져 있고, 다시 가져오는 대신 신선한 항목을 제공하며, 힌트를 제공하지 않는 서버(또는 세션)에는 아무것도 캐시하지 않습니다. +* 호출별로 `cache_mode="refresh"`는 다시 가져오고 `"bypass"`는 캐시를 건너뜁니다. 생성 시점의 `cache=None`은 캐시를 완전히 끕니다. diff --git a/i18n/ko/pages/client/callbacks.md b/i18n/ko/pages/client/callbacks.md new file mode 100644 index 0000000000..49bbd347a3 --- /dev/null +++ b/i18n/ko/pages/client/callbacks.md @@ -0,0 +1,154 @@ +--- +translation: + sections: [adf3c545b5be46b6, 916cd3ab1c03f461, e9be7a8d0eb0a456, 565890a636288ecf, 6af7e49db9129ec3, 06b0238c174186af, 90c6043be435fcb0] + tool: 1 +--- +# 클라이언트 콜백 {#client-callbacks} + +MCP에서 거의 모든 요청은 한 방향, 즉 클라이언트에서 서버로 갑니다. + +서버도 **클라이언트**에 무언가를 요청할 수 있습니다. 사용자에게 질문을 하거나, 사용자의 모델을 샘플링하거나, 사용자의 작업 공간 폴더 목록을 달라고 하는 식입니다. 이런 요청에는 `Client(...)`에 **콜백**을 전달해 응답합니다. + +## 요청하는 서버 {#a-server-that-asks} + +다음은 도구가 혼자서는 완료할 수 없는 서버입니다. + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/client_callbacks/tutorial001.py" +``` + +* `ctx.elicit(...)` 호출은 `elicitation/create` 요청을 **클라이언트로** 보내고 기다립니다. +* 누군가(폼 앞의 사람이든, 작성한 코드든)가 `name`을 제공하기 전까지 도구는 반환하지 않습니다. + +여기까지가 서버 쪽 절반이며, 이 부분은 **[엘리시테이션(elicitation)](../handlers/elicitation.md)** 페이지에서 다룹니다. 이 페이지는 연결의 반대쪽 끝을 다룹니다. + +## 엘리시테이션 콜백 {#the-elicitation-callback} + +```python title="client.py" hl_lines="6-10 16-17" +--8<-- "docs_src/client_callbacks/tutorial002.py" +``` + +* 엘리시테이션 콜백은 `async (context, params) -> ElicitResult` 형태입니다. +* `params.message`는 질문입니다. `params.requested_schema`는 서버가 원하는 답의 JSON Schema입니다. 실제 클라이언트는 이것으로 폼을 그리지만, 이 예제는 자동으로 채웁니다. +* `ElicitResult(action="accept", content={...})`를 반환하거나, `action="decline"` 또는 `action="cancel"`을 반환합니다. 그 외 유일한 선택지는 `ErrorData(...)`로, 요청을 거부하고 호출 전체를 실패시킵니다. +* `context`는 `ClientRequestContext`입니다. 살아 있는 `session`, 서버의 `request_id`, 서버가 첨부한 `meta`가 들어 있습니다. + +!!! tip + `params`는 두 가지 엘리시테이션 모드의 유니온입니다. 여기서 `params.mode`는 `"form"`이며, `"url"` 요청은 + 스키마 대신 `params.url`을 담고 있습니다. 콜백 하나로 둘 다 처리하며, `params.mode`로 분기하세요. + 전체 패턴은 **[엘리시테이션](../handlers/elicitation.md)**에서 확인하세요. + +### 직접 해 보기 {#try-it} + +`issue_card`를 호출하고 양쪽 끝을 지켜보세요. + +콜백은 이미 파싱된 서버의 질문을 받습니다. + +```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'} +``` + +콜백이 응답하면 도구 안에서 `ctx.elicit(...)` 호출이 다시 진행되고, 도구가 완료됩니다. + +```python +result.content # [TextContent(type='text', text='Card issued to Ada Lovelace.')] +``` + +클라이언트가 보낸 `tools/call` 하나, 서버가 되돌려 보낸 `elicitation/create` 하나, 그리고 그에 대한 함수의 응답까지, 모두 단일 도구 호출 안에서 일어납니다. + +!!! info + `Client(...)` 호출의 `mode="legacy"`는 실제로 중요한 역할을 합니다. 기본적으로 `Client(...)`는 최신 + 프로토콜 경로를 협상하는데, 그 경로에는 서버에서 클라이언트로 가는 요청을 위한 역방향 채널이 없어서 + 콜백이 실행되기도 전에 `ctx.elicit` 호출이 실패합니다. 이를 결정하는 것은 트랜스포트가 아니라 협상된 + 프로토콜이며, 인메모리든 URL을 통하든 마찬가지입니다. 클라이언트가 이런 요청에 응답해야 할 때마다 + `mode="legacy"`로 고정하세요. 이 페이지를 뒷받침하는 모든 테스트가 그렇게 합니다. 자세한 내용은 **[프로토콜 버전](../protocol-versions.md)**에서 확인하세요. + + 2026-07-28 세션에서도 콜백이 쓸모없어지는 것은 아니며, 입력을 받는 방식이 다를 뿐입니다. 도구가 + `ElicitRequest`를 담은 `InputRequiredResult`를 반환하면 `Client`는 그 항목을 같은 + `elicitation_callback`으로 전달하고 호출을 대신 재시도합니다. 이 흐름은 **[다중 왕복 요청](../handlers/multi-round-trip.md)**에서 다룹니다. + +## 콜백이 곧 기능 {#a-callback-is-a-capability} + +클라이언트가 엘리시테이션 요청에 응답할 수 있다고 서버에 알린 적은 없습니다. SDK가 대신 알렸습니다. + +클라이언트는 연결할 때 자신의 `capabilities`를 선언하며, 이는 서버 쪽 선언과 거울처럼 대응됩니다. 이 객체를 직접 작성하지는 않습니다. **콜백을 등록하는 것이 곧 선언입니다.** + +| 전달하는 것 | 클라이언트가 선언하는 것 | +| --- | --- | +| `elicitation_callback=` | `"elicitation": {"form": {}, "url": {}}` | +| `sampling_callback=` | `"sampling": {}` | +| `list_roots_callback=` | `"roots": {"listChanged": true}` | +| 아무것도 전달하지 않음 | `{}` | + +샘플링 하위 기능이 유일하게 더 세밀한 부분입니다. 샘플러가 `tools` / `tool_choice` 매개변수를 처리한다면 `sampling_callback`과 함께 `sampling_capabilities=SamplingCapability(tools=SamplingToolsCapability())`를 전달하세요. 서버는 `sampling.tools`가 선언된 것을 확인해야만 이 매개변수를 보낼 수 있습니다. + +`logging_callback`과 `message_handler`는 표에 없습니다. 이 둘은 알림을 처리하며, 알림에는 기능 선언이 필요 없습니다. + +서버는 `ctx.session.check_client_capability(...)`로 이 선언을 읽어 옵니다. 그렇게 하는 도구를 추가하세요. + +```python title="server.py" hl_lines="23-31" +--8<-- "docs_src/client_callbacks/tutorial003.py" +``` + +`elicitation_callback`만 전달해 연결하고 호출하세요. + +```python +result.structured_content # {'result': ['elicitation']} +``` + +콜백 세 개를 모두 전달하면 `['elicitation', 'sampling', 'roots']`를 받습니다. 아무것도 전달하지 않으면 `[]`를 받습니다. + +!!! check + 이번에는 잘못된 방식으로 해 보세요. `elicitation_callback` **없이** 연결하고 그래도 `issue_card`를 호출하세요. + + 서버의 `elicitation/create` 요청은 여전히 클라이언트에 도달하며, 처리할 수 있다고 알린 적이 없으므로 + SDK가 대신 오류로 응답합니다. 그 오류가 호출 전체를 무너뜨립니다. + `call_tool`은 `is_error` 결과를 반환하지 않고 예외를 던집니다. + + ```text + MCPError: Elicitation not supported + ``` + + 이는 도구 오류가 아니라 프로토콜 오류(`-32600`, *invalid request*)입니다. 모델이 읽고 재시도할 것이 + 아무것도 없습니다. 바로 이 때문에 `client_features`를 둘 가치가 있습니다. 제대로 동작하는 서버는 + 요청하기 전에 확인합니다. + +## 지원 중단 예정(deprecated)인 두 콜백 {#the-deprecated-pair} + +`sampling_callback`은 `sampling/createMessage`에 응답합니다. 서버가 **클라이언트 쪽** 모델에 무언가를 완성해 달라고 요청하는 것입니다. `list_roots_callback`은 `roots/list`에 응답합니다. 서버가 어느 디렉터리에서 작업해도 되는지 묻는 것입니다. + +둘 다 동작합니다. 둘 다 위의 규칙을 따릅니다. 그리고 둘 다 **2026-07-28 사양에서 제거되는** RPC를 처리합니다. 최신 서버는 요청 도중에 클라이언트를 역으로 호출하지 않고, 요청을 도구 결과의 일부로 되돌려 줍니다(**[다중 왕복 요청](../handlers/multi-round-trip.md)**). 콜백 자체가 쓸모없어지는 것은 아닙니다. `InputRequiredResult`가 `CreateMessageRequest`나 `ListRootsRequest`를 담고 있으면 `Client`의 자동 루프가 여기서 등록한 바로 그 `sampling_callback` 또는 `list_roots_callback`으로 전달합니다. 전체 목록은 **[지원 중단 예정 기능](../deprecated.md)**에서 확인하세요. + +아직 옮겨 가지 않은 서버와 통신하려면 여전히 이 콜백이 필요합니다. 시그니처는 다음과 같습니다. + +```python title="client.py" +--8<-- "docs_src/client_callbacks/tutorial004.py" +``` + +* 샘플링 콜백은 전체 `CreateMessageRequestParams`(`messages`, `model_preferences`, `max_tokens`)를 받고 `CreateMessageResult`를 반환합니다. 모델을 실행하는 것은 **클라이언트 쪽**이며 방식은 자유입니다. SDK는 요청을 전달할 뿐입니다. +* 루트 콜백은 params를 전혀 받지 않고 `ListRootsResult`를 반환합니다. +* 둘 다 거부하려면 대신 `ErrorData(...)`를 반환할 수 있습니다. + +`elicitation_callback`과 똑같이 `Client(...)`에 전달하세요. + +## 알림 콜백 {#the-notification-callbacks} + +두 개가 더 있습니다. 둘 다 아무것도 선언하지 않습니다. + +`logging_callback`은 서버가 보내는 `notifications/message`를 `LoggingMessageNotificationParams`(`level`, `logger`, `data`)로 받습니다. 프로토콜 로깅 자체가 2026-07-28 사양에서 지원 중단 예정이므로(대신 무엇을 해야 하는지는 **[로깅](../handlers/logging.md)**에서 다룹니다), 이 콜백은 여전히 로그를 내보내는 서버를 위해 존재합니다. 2026년 세대 연결에서는 콜백만으로는 아무것도 받지 못합니다. 2026 서버는 옵트인한 요청에만 로그 메시지를 보내기 때문입니다. `Client(...)`에 `log_level="info"`(또는 다른 레벨)를 전달하면 모든 요청에 이 옵트인이 찍혀 해당 레벨 이상을 받습니다. 2026 이전 서버는 이를 무시하고 기존 `logging/setLevel` 동작을 유지합니다. + +`message_handler`는 모든 것을 받는 콜백입니다. 세션이 드러내는 모든 서버 알림이 (각각의 전용 콜백과 더불어) 여기에 도달하며, 스트림 기반 트랜스포트에서는 트랜스포트 수준의 모든 `Exception`도 마찬가지입니다. 절대 도달하지 않는 것이 두 가지 있습니다. `notifications/cancelled`는 드러나는 대신 SDK가 직접 적용하고, 살아 있는 `listen()` 스트림에 대한 구독 확인 응답은 그 스트림이 소비합니다. 매개변수에는 `IncomingMessage`(`ServerNotification | Exception`, `mcp.client`에서 내보냄)로 타입을 표기하세요. 알아 둘 만한 패턴은 `if isinstance(message, Exception): raise message` 하나로, 끊어진 연결이 조용히 사라지는 대신 확실하게 실패하도록 합니다. + +## 요약 {#recap} + +* 서버는 클라이언트에 요청을 보낼 수 있습니다. `Client(...)`에 전달한 콜백으로 응답합니다. +* 현재 기준의 콜백은 엘리시테이션 콜백입니다. `async (context, params) -> ElicitResult` 형태이며, 폼 모드와 URL 모드 모두 함수 하나로 처리합니다. +* **콜백을 등록하는 것이 곧 기능을 선언하는 것입니다.** 콜백이 없으면 SDK가 대신 서버의 요청을 거부하고 호출 전체가 `MCPError`로 실패합니다. +* 서버는 `ctx.session.check_client_capability(...)`로 요청하기 전에 미리 확인합니다. +* `sampling_callback`과 `list_roots_callback`도 같은 방식으로 동작하지만 지원 중단 예정 기능을 처리합니다. 최신 서버는 대신 다중 왕복 요청을 사용합니다. +* `logging_callback`과 `message_handler`는 알림을 받습니다. 아무것도 선언하지 않습니다. + +`Client(...)`의 첫 번째 인자는 트랜스포트 객체입니다. 모든 종류는 **[클라이언트 트랜스포트](transports.md)**에서 다룹니다. diff --git a/i18n/ko/pages/client/identity-assertion.md b/i18n/ko/pages/client/identity-assertion.md new file mode 100644 index 0000000000..cce752fde6 --- /dev/null +++ b/i18n/ko/pages/client/identity-assertion.md @@ -0,0 +1,152 @@ +--- +translation: + sections: [a91322c46111d16d, 8e6fd6d6f59bb568, e7828fd2729b2c9d, a03ec26bfc678b65, 1034c653c0bcf1b0] + tool: 1 +--- +# ID 어설션 {#identity-assertion} + +일반적인 OAuth 공급자(**[OAuth 클라이언트](oauth-clients.md)**)는 먼저 MCP 서버에 **어느 인가 서버를 신뢰하는지** 묻는 것으로 시작합니다. 그 답이 가리키는 곳이면 어디든 따라가고, 그런 다음 사람이 로그인하거나 사전 공유된 시크릿이 사람을 대신합니다. + +기업은 이 둘 중 어느 것도 서버마다 따로 결정되기를 원하지 않습니다. 기업은 이미 ID 공급자(Okta, Microsoft Entra ID, 또는 자체 운영하는 것)를 운영하고 있고, 사용자는 오늘 아침에 이미 거기에 로그인했으며, 보안 팀이 누가 무엇에 접근할 수 있는지를 결정하고 싶어 하는 유일한 곳이 바로 그곳입니다. **Enterprise-Managed Authorization** 확장인 [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990)은 그 결정을 그곳으로 옮깁니다. IdP는 수명이 짧은 JWT, 즉 **Identity Assertion JWT Authorization Grant**, 줄여서 **ID-JAG**에 서명합니다. **이 사용자**가 **이 클라이언트**를 통해 **이 MCP 서버**에 접근해도 된다는 진술입니다. 클라이언트는 이를 일반적인 액세스 토큰으로 교환합니다. 브라우저도, 동의 화면도, 동적 등록도 없습니다. + +이 페이지는 그 교환의 양쪽 끝을 모두 다룹니다. MCP 서버 자체는 전혀 바뀌지 않습니다. 여전히 **[인가](../run/authorization.md)**에서 본 리소스 서버이며, 들어오는 토큰이 무엇이든 검사할 뿐입니다. + +## 두 번의 토큰 요청 {#two-token-requests} + +여기에는 서로 다른 두 권한 주체가 등장하며, 이 둘을 구분해서 부르는 것이 이 페이지를 이해하는 일의 대부분입니다. **엔터프라이즈 IdP**는 조직의 ID 공급자입니다. 직원이 누구인지 알고, 정책이 있는 곳이며, ID-JAG를 발급합니다. SDK는 이 IdP와 절대 통신하지 않습니다. **MCP 인가 서버**는 **[인가](../run/authorization.md)**에서와 같은 당사자입니다. MCP 서버의 메타데이터에 명시된 발급자이자, 그 MCP 서버가 받아들이는 토큰을 발급하는 주체입니다. 일반적인 OAuth 흐름에서는 이 두 역할이 보통 하나의 시스템입니다. 여기서는 둘로 나뉘며, 이 그랜트 전체는 결국 후자가 전자를 신뢰하기로 동의하는 것입니다. + +클라이언트는 각각에 토큰 요청을 한 번씩 보냅니다. + +1. **엔터프라이즈 IdP에 보내는 요청.** 클라이언트는 사용자의 로그인(OpenID Connect ID 토큰)을 ID-JAG로 교환합니다. 이것은 [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) 토큰 교환이고, 전적으로 IdP의 API이며, **SDK는 이 요청을 보내지 않습니다**. 하나의 async 콜백 안에서 직접 보냅니다. 정책 결정이 일어나는 곳도 여기입니다. IdP가 거부하면 ID-JAG는 발급되지 않고, 제시할 것도 없습니다. +2. **MCP 인가 서버에 보내는 요청.** 클라이언트는 [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) `jwt-bearer` 그랜트(`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`, ID-JAG를 `assertion`으로)로 ID-JAG를 제시하고 액세스 토큰을 받습니다. **이것이 SDK가 보내는 요청이며**, 이 요청을 받아들이는 것이 이 페이지가 인가 서버에 추가하는 단 하나의 기능입니다. + +아래의 모든 내용은 두 번째 요청, 즉 이를 보내는 클라이언트와 이에 응답하는 인가 서버에 관한 것입니다. + +## 클라이언트 {#the-client} + +**`IdentityAssertionOAuthProvider`**는 `mcp.client.auth.extensions.identity_assertion`에 있습니다. **[OAuth 클라이언트](oauth-clients.md)**의 모든 공급자와 마찬가지로 `httpx2.Auth`입니다. 하나를 생성해 `auth=`에 넣고, `httpx2.AsyncClient`를 트랜스포트에 넘기면 됩니다. + +```python title="client.py" hl_lines="49-50 53-61" +--8<-- "docs_src/identity_assertion/tutorial001.py" +``` + +아래에서부터 읽어 보세요. + +* `main()`은 표준 OAuth 클라이언트의 `main()`(**[OAuth 클라이언트](oauth-clients.md)**)이며, 한 줄도 바뀌지 않았습니다. 그것이 핵심입니다. 공급자가 일단 존재하면, 그 뒤의 어떤 코드도 어떤 그랜트가 토큰을 만들어 냈는지 알지 못합니다. +* 공급자는 다른 공급자가 스스로 알아낼 수 없는 것을 받습니다. 누군가가 인가 서버에 **사전 등록**해 둔 `client_id`와 `client_secret`, 그 인가 서버의 `issuer`, 그리고 요청할 때마다 새 ID-JAG를 반환하는 async 콜백인 `assertion_provider`입니다. +* `storage`는 같은 `TokenStorage` 프로토콜입니다. 호출되는 것은 토큰 관련 메서드 두 개뿐입니다. 여기에는 동적 등록이 없으므로 기억해 둘 `client_info`도 없습니다. + +### 어설션 공급자 {#the-assertion-provider} + +`fetch_id_jag(audience, resource)`가 직접 작성하는 유일한 코드입니다. 토큰 교환마다 한 번씩 await되고, 생성 시점에는 절대 호출되지 않으며, 인가 서버의 메타데이터를 가져와 검증한 **뒤에만** 호출되므로 잘못 설정된 발급자로 어설션이 새어 나가는 일은 없습니다. 두 인수는 ID-JAG를 발급할 때 담아야 하는 클레임 중 두 가지입니다. `audience`는 인가 서버의 발급자(ID-JAG의 `aud`)이고 `resource`는 MCP 서버의 정식 식별자(ID-JAG의 `resource`)입니다. 세 번째는 이미 가지고 있는 값입니다. ID-JAG의 `client_id` 클레임은 공급자에 넘긴 `client_id`를 가리켜야 하며, 그렇지 않으면 인가 서버가 교환을 거부합니다. + +그 위에 있는 `idp_issue_id_jag`는 **작성할 코드가 아닙니다**. ID 공급자를 대신하는 것으로, 파일이 그 자체로 완결되고 ID-JAG가 담는 모든 클레임을 읽어 볼 수 있도록 프로세스 안에서 어설션에 서명합니다. 실제 `fetch_id_jag`는 그 대신 앞 절의 첫 번째 토큰 요청을 보냅니다. IdP를 상대로 한 [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) 토큰 교환이며, [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990)이 프로파일로 삼는 Identity Assertion JWT Authorization Grant 초안이 이를 정의합니다. 로그인한 사용자의 ID 토큰이 `subject_token`으로 들어가고, `requested_token_type`은 ID-JAG 고유의 URN(`urn:ietf:params:oauth:token-type:id-jag`)이며, `audience`와 `resource`는 그대로 전달되고, 응답에 ID-JAG가 실려 옵니다. IdP 문서에서 찾아봐야 할 것이 바로 이 이름들로 이루어지는 이 교환입니다. + +!!! tip + 교환할 때마다 새 ID-JAG를 요청하며, 그것이 의도된 설계입니다. ID-JAG는 수명이 몇 분에 불과한 + 일회용 그랜트이고, 이 페이지의 인가 서버는 같은 ID-JAG를 두 번 받아들이지 않습니다. 캐시하지 + 마세요. 재사용되는 것은 ID-JAG로 얻은 액세스 토큰입니다. + +### 설정으로 지정하는 발급자 {#the-issuer-is-configuration} + +여기서 관계가 뒤집힙니다. `OAuthClientProvider`는 어느 인가 서버를 쓸지 리소스 서버에 묻고 그 답이 가리키는 곳이면 어디든 따라갑니다. 이 공급자는 그렇게 하기를 거부합니다. `issuer`는 필수이고, [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) 메타데이터는 그 발급자 자신의 well-known 경로에서 가져오며, 토큰 엔드포인트는 그 발급자의 오리진에 있어야 하고, 리소스 서버에는 아무것도 묻지 않습니다. + +확장이 이를 요구하는 것은 아닙니다. 의도적으로 더 엄격하게 선택한 것입니다. 이 클라이언트는 훔칠 가치가 있는 것을 두 가지 지니고 있습니다. 사전 등록된 시크릿과 audience에 묶인 어설션입니다. 침해된 MCP 서버가 공격자의 인가 서버로 유도하도록 내버려 두는 클라이언트라면 둘 다 그곳에 POST하게 됩니다. 생성 시점에 발급자를 고정하면 그런 대화 자체가 사라집니다. + +!!! warning + 설정한 `issuer`는 메타데이터 문서의 `issuer` 필드와 RFC 8414 §3.3의 단순 문자열 비교로 + 대조됩니다. 한 글자씩, 끝의 슬래시까지 포함해, 정규화 없이 비교합니다. 추측하지 마세요. 인가 + 서버에서 `/.well-known/oauth-authorization-server`를 가져와 반환된 `issuer` 값을 그대로 + 복사하세요. 이 페이지의 인가 서버라면 그 값은 슬래시가 붙은 `https://auth.example.com/`입니다. + 발급자가 pydantic URL 객체로부터 만들어졌기 때문입니다. 일치하지 않으면 자격 증명이나 어설션을 + 단 하나도 보내기 전에 `OAuthFlowError: Authorization server metadata issuer + mismatch`에서 흐름이 멈춥니다. + +### 기밀 클라이언트 {#a-confidential-client} + +`client_secret`은 필수이며, 없으면 생성자가 `ValueError`를 발생시킵니다. [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990)의 기반이 되는 IETF 프로파일은 이 그랜트를 기밀 클라이언트 전용으로 두고, SEP-990은 클라이언트가 인증할 것을 요구하며, 이 SDK는 공유 시크릿을 반드시 요구함으로써 둘 다 강제합니다. `token_endpoint_auth_method`는 시크릿이 어디에 실려 가는지를 고릅니다. `client_secret_post`(기본값, 폼 본문에)나 `client_secret_basic`(HTTP Basic 헤더) 중 하나입니다. 프로파일은 `private_key_jwt`도 허용하지만, 이 공급자는 지원하지 않습니다. + +!!! tip + `client_secret`은 환경 변수나 시크릿 관리자에서 읽어 오세요. 소스 관리에서 읽어서는 절대 안 됩니다. + +### 공급자가 대신 처리하는 일 {#what-the-provider-does-for-you} + +첫 번째 요청은 인증 없이 나가고, 서버의 `401`이 흐름을 시작합니다. + +1. **탐색.** 설정된 발급자의 [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) well-known 경로에서 인가 서버 메타데이터를 가져오고, 문서의 `issuer`가 일치하는지 확인하고, 토큰 엔드포인트가 발급자의 오리진에 있는지 확인합니다. +2. **어설션.** `assertion_provider`를 await합니다. +3. **교환.** 토큰 엔드포인트에 `jwt-bearer` 그랜트를 POST하고, `OAuthToken`을 저장한 뒤, 원래 요청을 `Authorization: Bearer ...`를 붙여 다시 보냅니다. + +`WWW-Authenticate`에 `insufficient_scope`가 명시된 `403`을 받으면 설정한 `scope`와 챌린지로 요구된 범위의 합집합으로 2단계와 3단계를 다시 실행합니다. (`scope`는 어디까지나 요청일 뿐입니다. 이 페이지의 인가 서버는 ID-JAG에 적힌 것만 부여하고 그 외에는 아무것도 부여하지 않습니다.) 이 과정 어디에도 리프레시 토큰은 없습니다. 액세스 토큰이 만료되면 다음 `401`에서 새 ID-JAG를 발급받아 다시 교환하며, 바로 **그것이** IdP가 쥐고 있는 지렛대입니다. 실패는 **[OAuth 클라이언트](oauth-clients.md)**의 나머지와 같은 두 가지 예외로 나타납니다. 탐색과 검증에는 `OAuthFlowError`, 토큰 엔드포인트가 거부하면 그 하위 클래스인 `OAuthTokenError`입니다. + +## 인가 서버 {#the-authorization-server} + +대부분의 경우 여기서 멈추면 됩니다. MCP 인가 서버는 다른 누군가의 제품이고, ID-JAG를 받아들이는 것은 그 제품에서 켜야 할 설정이며, [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990)에서 SDK가 맡는 절반은 위의 클라이언트입니다. + +SDK가 직접 인가 서버가 **될** 수도 있습니다. `create_auth_routes`는 인가 서버의 라우트를 어떤 Starlette 앱이든 마운트할 수 있는 리스트로 반환하며, 저장소의 `examples/servers/simple-auth/`가 바로 이 방식으로 인가 서버를 실행합니다. SEP-990은 그 표면에 플래그 하나와 메서드 하나를 추가합니다. + +```python title="auth_server.py" hl_lines="48-50 105-107" +--8<-- "docs_src/identity_assertion/tutorial002.py" +``` + +* `identity_assertion_enabled=True`가 모든 것의 관문입니다. 꺼져 있으면(기본값), 훅을 구현했더라도 `/token`은 이 그랜트에 `unsupported_grant_type`으로 응답하고 메타데이터에도 언급되지 않습니다. 켜면 메타데이터에 `jwt-bearer` 그랜트 유형이 추가되고, 확장이 지원을 알리는 데 쓰는 필드인 `authorization_grant_profiles_supported`에 `urn:ietf:params:oauth:grant-profile:id-jag`가 나열됩니다. (이 SDK의 클라이언트는 이 필드를 읽지 않습니다. 발급자 하나에 맞춰 프로비저닝되어 있으므로 그냥 요청할 뿐입니다.) +* **`exchange_identity_assertion`**이 훅입니다. 이 훅이 실행되기 전에 SDK는 이미 클라이언트를 인증하고, 공개 클라이언트를 거부하고, 등록 정보에 이 그랜트가 나열되지 않은 클라이언트를 거부한 상태입니다. `IdentityAssertionParams`(원시 `assertion`, 요청된 `scopes`와 `resource`)를 받아 평범한 `OAuthToken`을 반환합니다. +* 동적 클라이언트 등록은 이 그랜트를 무조건 거부하므로, 여기서 `get_client`는 수동으로 프로비저닝한 클라이언트를 내줍니다. ID-JAG 클라이언트는 스스로 등록해서 생겨날 수 없습니다. +* 클래스의 절반은 거부 코드입니다. `OAuthAuthorizationServerProvider`는 인가 서버 **전체**이므로 인가 코드 흐름도 요구합니다. 사용자 로그인까지 처리하는 서버라면 그 부분을 실제로 구현하지만, 이 서버에는 문이 정확히 하나뿐입니다. + +!!! warning + SDK는 어설션을 절대 디코딩하지 않습니다. 어떤 IdP를 신뢰하고 그 IdP가 어떤 키를 공개하는지는 + 해당 배포 환경만 알기 때문이며, 따라서 `exchange_identity_assertion` 안의 모든 코드가 보안을 + 떠받칩니다. [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) §3에 따라 IdP가 공개한 키(JWKS, 여기서 쓰는 공유 시크릿은 + 데모용입니다)로 서명을 검증하고, `iss`와 `exp`도 검증하세요. JWT 헤더의 `typ`이 + `oauth-id-jag+jwt`일 것을 요구하세요. 다른 JWT가 그랜트로 재사용되는 것을 막는 프로파일의 + 안전장치입니다. `aud`가 자기 자신의 발급자일 것을 요구하세요. ID-JAG의 `client_id` 클레임이 + 핸들러가 인증한 클라이언트와 같을 것을, 그리고 `resource` 클레임이 실제로 서비스하는 리소스를 + 가리킬 것을 요구하세요. 어설션이 한 번만 받아들여지도록 어설션의 `exp`까지 `jti`를 추적하세요. + 그리고 부여하는 범위와, 무엇보다도 발급하는 토큰의 `resource`는 검증된 ID-JAG에서 가져오고 + 절대 요청에서 가져오지 마세요. `params.resource`는 클라이언트가 입력한 값일 뿐입니다. 전체 + 처리 규칙은 [Enterprise-Managed Authorization 사양](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization)에 + 있습니다. + +잘못된 어설션은 `TokenError("invalid_grant", ...)`로 거부하세요. 이 흐름의 다른 오류 코드는 `invalid_target`입니다. 서비스하지 않는 리소스를 가리키는 ID-JAG는 이 코드로 거부되며, 이것이 이 서버가 다른 누군가의 리소스용 토큰을 발급하지 못하게 막는 장치입니다. 그리고 부여되는 범위는 ID-JAG의 `scope` 클레임에서 옵니다(이 클레임이 없는 어설션도 거부됩니다). 실제 구현에서는 대신 사용자의 그룹을 매핑할 수도 있습니다. + +반환되는 `OAuthToken`에 무엇이 없는지도 눈여겨보세요. 리프레시 토큰이 없습니다. IdP는 다음 ID-JAG를 발급할지 말지를 결정함으로써 이 사용자가 얼마나 오래 접근을 유지할지 결정합니다. 여기서 리프레시 토큰을 발급하면 그 결정권을 조용히 되돌려주는 셈이 됩니다. + +!!! info + 여전히 `auth_server_provider=`로 인가 서버를 내장하는 서버는 + `AuthSettings(identity_assertion_enabled=True)`를 통해 같은 코드에 도달합니다. 새 서버가 왜 그 + 방식으로 시작하면 안 되는지는 **[인가](../run/authorization.md)**에서 설명합니다. + +!!! check + 이 페이지의 두 파일을 서로 연결하면 그랜트 전체가 `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"} + ``` + + `/authorize`도, `/register`도, protected-resource-metadata 가져오기도 없습니다. 실제로 오가는 + 요청은 `401`을 끌어낸 요청, well-known 가져오기, 이 교환, 그리고 그 뒤로 bearer를 붙인 일반적인 + MCP 트래픽뿐입니다. 그리고 검증 코드가 ID-JAG에서 읽어 낸 `sub`는 도구 안에서 + `get_access_token().subject`가 보고하는 값과 정확히 같습니다. + +### 직접 해 보기 {#try-it} + +SDK 저장소의 `examples/stories/identity_assertion/`은 이 페이지를 실제로 실행한 것입니다. 같은 `exchange_identity_assertion` 검증 코드, 그 토큰으로 보호되는 MCP 서버, 대역 IdP, 그리고 클라이언트까지, 스스로 검증하는 프로그램 하나에 모두 담겨 있습니다. `uv run python -m stories.identity_assertion.client --http`는 교환 전체를 실행하고 IdP가 지명한 사용자가 도구가 보는 사용자와 같은지 assert합니다. + +## 요약 {#recap} + +* [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990)은 최종 사용자가 아니라 엔터프라이즈 ID 공급자가 클라이언트가 어느 MCP 서버에 접근할 수 있는지를 결정하게 합니다. IdP는 그 결정을 **ID-JAG**에 서명해 담습니다. +* ID-JAG를 얻는 것은 **자체 IdP**를 상대로 한 [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) 토큰 교환이며, SDK는 이를 수행하지 않습니다. ID-JAG를 MCP 인가 서버에 제시하는 것은 [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) `jwt-bearer` 그랜트이며, SDK는 그 양쪽을 모두 수행합니다. +* `IdentityAssertionOAuthProvider`는 또 하나의 `httpx2.Auth`입니다. 사전 등록된 기밀 클라이언트, 고정된 `issuer`, 그리고 `assertion_provider(audience, resource)` 콜백 하나로 이루어집니다. 브라우저도, 등록도, 리프레시 토큰도 없습니다. +* 인가 서버를 리소스 서버를 통해 찾아내는 일은 없습니다. `issuer`는 메타데이터 문서가 내주는 문자열과 정확히 같게 설정하세요. 비교는 한 글자씩 이루어집니다. +* 서버 쪽에서는 `identity_assertion_enabled=True`에 `exchange_identity_assertion`을 더합니다. SDK는 클라이언트를 인증하고 그랜트의 관문을 지키며, ID-JAG 검증은 전적으로 직접 구현할 몫이고, 발급되는 토큰은 요청의 것이 아니라 ID-JAG의 `resource`에 묶입니다. + +이 페이지가 한 번도 건드리지 않은 당사자는 MCP 서버입니다. 방금 발급한 토큰으로 MCP 서버가 하는 일은 이미 **[인가](../run/authorization.md)**에서 하던 일입니다. diff --git a/i18n/ko/pages/client/index.md b/i18n/ko/pages/client/index.md new file mode 100644 index 0000000000..00312b731e --- /dev/null +++ b/i18n/ko/pages/client/index.md @@ -0,0 +1,217 @@ +--- +translation: + sections: [ebef1e7a0df854f4, a4c687d3d627d516, 8e79141fc2985342, b345dd05b9c3c7ab, 80ce41579825a6fa, 5f0fa90494de8f65, 83d10514eaa62fa5, 9190555aa39a5d28, 84a4c9d8bf14dddb, 927d71cf40b58c30] + tool: 1 +--- +# 클라이언트 {#the-client} + +**`Client`**는 Python 프로그램이 MCP 서버와 통신하는 수단입니다. + +하나의 객체에 하나의 생명 주기가 있습니다. 객체를 만들고, `async with`에 들어가고, 메서드를 호출하면 됩니다. 모든 프로토콜 동작(도구 목록 조회, 도구 호출, 리소스 읽기, 프롬프트 렌더링)은 타입이 지정된 결과를 돌려주는 `async` 메서드로 제공됩니다. + +## 첫 번째 클라이언트 {#your-first-client} + +```python title="client.py" hl_lines="14-18" +--8<-- "docs_src/client/tutorial001.py" +``` + +맨 위의 서버는 연결할 대상을 마련하기 위해 있을 뿐입니다. 클라이언트는 강조 표시된 다섯 줄입니다. + +* `Client(mcp)`에는 **서버 객체 자체**를 넘깁니다. 이것이 인메모리 트랜스포트입니다. 서브프로세스도, 포트도, HTTP도 없습니다. 이 페이지의 모든 예제와 앞으로 작성할 모든 테스트가 이 방식으로 연결합니다. +* `async with`가 **생명 주기**입니다. 들어가면 연결하고 협상하며, 나오면 연결을 끊습니다. `connect()` / `close()` 쌍은 없으며, 블록이 끝난 뒤에는 `Client`를 재사용할 수 없습니다. +* 블록 안에서는 연결 정보가 이미 평범한 프로퍼티로 준비되어 있습니다. + +### `Client`에 전달할 수 있는 것 {#what-you-can-pass-to-client} + +`Client`는 위치 인자 하나를 받고, 그 타입으로 트랜스포트를 결정합니다. + +* `MCPServer`(또는 저수준 `Server`) 인스턴스: **프로세스 내부**에서 연결합니다. +* URL 문자열(`Client("http://localhost:8000/mcp")`): 프로덕션 경로인 Streamable HTTP입니다. +* **트랜스포트**: `async with ... as (read, write)`로 사용할 수 있는 모든 것, 예를 들어 서브프로세스를 감싸는 `stdio_client(...)`입니다. + +이 페이지의 나머지 내용은 세 가지 모두에서 동일합니다. 헤더, 서브프로세스, 타임아웃, `Transport` 프로토콜은 별도의 페이지인 **[클라이언트 트랜스포트](transports.md)**에서 다룹니다. + +### 연결된 클라이언트에 있는 것 {#whats-on-a-connected-client} + +블록에 들어가는 순간 채워지는 읽기 전용 프로퍼티 네 개가 있습니다. + +* `client.server_info`: 서버의 신원 정보입니다. 이를 보고하지 않는 2026년 시대의 서버라면 `None`입니다(python-sdk 서버는 기본적으로 보고합니다). 여기서 `server_info.name`은 `"Bookshop"`이고, `server_info.version`은 서버가 보고하는 값입니다. +* `client.server_capabilities`: 서버가 할 수 있는 것(`tools`, `resources`, `prompts`, `completions`, ...)입니다. 서버에 없는 기능은 `None`입니다. +* `client.protocol_version`: 양쪽이 합의한 프로토콜 버전입니다. 여기서는 `"2026-07-28"`입니다. +* `client.instructions`: 서버의 `instructions=` 문자열이며, 설정하지 않았다면 `None`입니다. + +프로토콜 버전을 직접 고른 적은 없습니다. 기본적으로 `Client`는 서버를 탐색하고, 오래된 서버에서는 전통적인 핸드셰이크로 대체하므로, 하나의 클라이언트가 어느 시대의 서버와도 동작합니다. 이를 제어해야 할 때 자세한 내용은 **[프로토콜 버전](../protocol-versions.md)**에서 확인하세요. + +!!! tip + `client.session`은 내부의 `ClientSession`으로, 저수준 탈출구입니다. + 이 페이지의 어떤 내용에도 필요하지 않습니다. + +## 도구 목록 조회 {#listing-tools} + +```python title="client.py" hl_lines="15-20" +--8<-- "docs_src/client/tutorial002.py" +``` + +`list_tools()`는 `ListToolsResult`를 반환하며, 도구는 `.tools`에 들어 있습니다. 각 도구는 호스트가 모델에 건네는 완전한 정의입니다. + +```python +tool.name # 'search_books' +tool.title # 'Search the catalog' +tool.description # 'Search the catalog by title or author.' +``` + +그리고 `tool.input_schema`는 서버가 함수의 타입 힌트에서 도출한 JSON Schema입니다. + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"default": 10, "title": "Limit", "type": "integer"} + }, + "required": ["query"], + "title": "search_booksArguments" +} +``` + +이 스키마는 UI가 인자 입력 폼을 렌더링하는 데 필요한 전부이자, 모델이 유효한 인자를 만들어 내는 데 필요한 전부입니다. + +!!! tip + `title`은 선택 사항이므로, 사람에게 도구를 보여 주는 UI는 무엇을 표시할지 골라야 합니다. `title`이 있으면 쓰고, + 없으면 `name`을 씁니다. `from mcp.shared.metadata_utils import get_display_name`이 정확히 그 일을 하며, + 도구, 리소스, 리소스 템플릿, 프롬프트 모두에 쓸 수 있습니다. + +## 도구 호출 {#calling-a-tool} + +`call_tool(name, arguments)`는 도구를 실행하고 `CallToolResult`를 돌려줍니다. + +```python title="client.py" hl_lines="26-33" +--8<-- "docs_src/client/tutorial003.py" +``` + +서버의 `lookup_book`은 Pydantic `Book`을 반환합니다. 클라이언트가 보는 것은 다음과 같습니다. + +```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 +``` + +반환값은 하나이고, 읽을 것은 세 가지입니다. 각각 소비하는 쪽이 다릅니다. + +### `content`: 모델이 읽는 것 {#content-what-the-model-reads} + +`content`는 **콘텐츠 블록**의 `list`이며, 콘텐츠 블록은 `TextContent`, `ImageContent`, `AudioContent`, `ResourceLink`, `EmbeddedResource`의 유니온입니다. 도구는 서로 다른 종류의 블록을 여러 개 반환할 수 있습니다. + +그래서 `main`은 `block.text`를 건드리기 전에 `isinstance(block, TextContent)`로 타입을 좁힙니다. `isinstance` 바깥에는 `.text`가 없다는 점에 주목하세요. `ImageContent`에는 `.text`가 아니라 `.data`가 있기 때문에 타입 검사기가 허용하지 않습니다. 유니온은 도구가 보낼 수 있는 것을 정직하게 드러내며, 코드도 그래야 합니다. + +### `structured_content`: 애플리케이션이 읽는 것 {#structured_content-what-your-application-reads} + +`structured_content`는 도구의 반환값을 JSON으로 표현한 것으로, 도구가 선언한 `output_schema`와 일치합니다. 문자열 파싱도, 추측도 필요 없습니다. + +둘 다 있을 때는 의도적으로 같은 내용을 두 번 말합니다. `content`는 모델을 위한 것이고, `structured_content`는 코드를 위한 것입니다. 구조화된 쪽이 어디서 오고 어떻게 제어하는지는 **[구조화된 출력](../servers/structured-output.md)** 페이지에서 다룹니다. + +### `is_error`: 도구의 실패 여부 {#is_error-whether-the-tool-failed} + +예외를 발생시키는 도구라도 클라이언트에서 예외를 발생시키지 **않습니다**. `is_error=True`인 평범한 결과로 돌아옵니다. + +!!! check + `lookup_book`에 `"Solaris"`(카탈로그에 없는 제목)를 요청하면 함수가 + `ValueError`를 발생시킵니다. 그래도 호출은 정상적으로 반환됩니다. + + ```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 + ``` + + 예외 메시지는 **모델**이 읽고 다시 시도할 수 있는 `content`에 담겼습니다. 이는 + 의도된 것입니다. 도구 오류는 충돌이 아니라 대화의 일부입니다. `structured_content`를 + 믿기 전에 항상 `is_error`를 확인하세요. + +!!! warning + `is_error=True`는 직접 작성한 `raise`보다 더 많은 경우를 포괄합니다. 서버에 아예 없는 도구를 요청해도 + (`call_tool("does_not_exist", {})`) 아무 예외도 발생하지 않습니다. 같은 형태로, + `content`에 `Unknown tool: does_not_exist`가 담긴 `is_error=True`가 돌아옵니다. `Client` 메서드는 + 서버가 결과 대신 JSON-RPC **오류**로 응답할 때만 `MCPError`를 발생시키며, + 서버가 언제 어느 쪽을 내보내는지는 **[오류 처리](../servers/handling-errors.md)**에서 다룹니다. + +## 리소스 {#resources} + +리소스 동작은 짝을 이룹니다. 목록을 조회하는 방법이 둘, 읽는 방법이 하나입니다. + +```python title="client.py" hl_lines="22-31" +--8<-- "docs_src/client/tutorial004.py" +``` + +* `list_resources()`는 **구체적인** 리소스, 즉 URI가 고정된 리소스를 반환합니다. 여기서는 `['catalog://genres']`입니다. +* `list_resource_templates()`는 **매개변수화된** 리소스를 반환합니다. 여기서는 `['catalog://genres/{genre}']`입니다. 템플릿은 값을 채우기 전에는 읽을 수 없으므로 두 목록은 서로 다릅니다. +* `read_resource(uri)`는 평범한 `str` URI를 받으며 둘 다에 동작합니다. `"catalog://genres/poetry"`를 전달하면 서버가 템플릿에 매칭합니다. + +`read_resource`는 `TextResourceContents` 또는 `BlobResourceContents`의 리스트인 `contents`를 반환합니다. 도구 콘텐츠와 같은 방식입니다. `isinstance`로 좁힌 다음 `.text`(또는 `.blob`)를 읽으세요. + +클라이언트는 리소스가 변경될 때 알림을 받을 수도 있습니다. 2025년 시대의 연결에서는 `subscribe_resource(uri)` / `unsubscribe_resource(uri)`이며, `MCPServer`가 구현하지 않는 메서드 쌍이므로 2026-07-28 와이어(이 동작이 더 이상 존재하지 않는)에서는 요청이 `-32601`, *Method not found*로 응답합니다. 2026년의 대체 수단은 `subscriptions/listen` 스트림으로, `MCPServer`가 **실제로** 제공합니다(여기서 `server_capabilities.resources.subscribe`는 `True`입니다). 이를 `client.listen(...)`으로 소비하는 방법은 이 섹션의 **[구독](subscriptions.md)** 페이지에서 다룹니다. + +## 프롬프트 {#prompts} + +```python title="client.py" hl_lines="15-20" +--8<-- "docs_src/client/tutorial005.py" +``` + +`list_prompts()`는 서버가 무엇을 제공하는지, 각 프롬프트에 무엇이 필요한지 알려 줍니다. + +```python +prompt.name # 'recommend' +prompt.title # 'Recommend a book' +prompt.arguments # [PromptArgument(name='genre', required=True)] +``` + +`get_prompt(name, arguments)`가 프롬프트를 렌더링합니다. 인자 딕셔너리는 `str -> str`입니다. 프롬프트 인자는 항상 문자열입니다. 결과는 `PromptMessage`의 리스트인 `messages`이며, 각 메시지에는 `role`과 `content` 블록이 있습니다. + +```python +message.role # 'user' +message.content # TextContent(type='text', text='Recommend one poetry book from the catalog and say why.') +``` + +호스트는 이 메시지를 곧바로 모델에 건넵니다. 이 기능은 이것이 전부입니다. + +## 자동 완성 {#completions} + +자동 완성 핸들러가 있는 서버는 사용자가 입력하는 동안 프롬프트와 리소스 템플릿 인자를 자동 완성할 수 있습니다. + +```python title="client.py" hl_lines="27-31" +--8<-- "docs_src/client/tutorial006.py" +``` + +* `ref`는 **어느** 프롬프트 또는 템플릿을 채우고 있는지 지정합니다. `PromptReference` 또는 `ResourceTemplateReference`입니다. +* `argument`는 `{"name": ..., "value": ...}`로, 인자와 사용자가 지금까지 입력한 값입니다. + +답은 `result.completion.values`에 있습니다. `"p"`를 입력하면 서버가 `['poetry']`를 돌려줍니다. 서버 쪽 구현과, 핸들러가 이미 채워진 **다른** 인자를 사용해 제안을 좁히는 방법은 **[자동 완성](../servers/completions.md)** 페이지에서 다룹니다. + +## 페이지네이션 {#pagination} + +모든 `list_*` 메서드는 `cursor=` 키워드를 받고, 모든 결과에는 `next_cursor`가 있습니다. `next_cursor`가 `None`이면 전부 받은 것입니다. + +```python title="client.py" hl_lines="22-30" +--8<-- "docs_src/client/tutorial007.py" +``` + +이 루프는 어떤 서버에 대해서도 올바릅니다. `MCPServer`는 모든 것을 한 페이지에 반환하므로 `next_cursor`는 `None`이고 루프는 한 번만 돌며, 그래서 대부분의 코드는 이 루프를 작성하지 않습니다. 실제로 페이지를 나누는 서버와 커서가 따르는 규칙은 **[페이지네이션](../advanced/pagination.md)**에서 다룹니다. + +## 테스트에서 {#in-tests} + +프로세스도 포트도 없는 `Client(mcp)`는 그 자체로 이미 서버의 테스트 하네스입니다. + +이를 위해 만들어진 생성자 플래그가 하나 있습니다. `Client(mcp, raise_exceptions=True)`입니다. 인메모리 연결에서만 효과가 있으며, 이를 설명하고 전체 패턴을 구축하는 페이지는 **[테스트](../get-started/testing.md)**입니다. + +## 요약 {#recap} + +* `Client(x)`는 서버 객체에는 인메모리로, URL 문자열에는 Streamable HTTP로, 그 밖의 것에는 트랜스포트를 통해 연결합니다. +* `async with`가 생명 주기의 전부입니다. 그 안에서는 `server_capabilities`와 `protocol_version`이 이미 채워져 있으며, 서버가 제공하는 경우 `server_info`와 `instructions`도 마찬가지입니다. +* `list_tools()`는 각 도구의 `name`, `title`, `description`, `input_schema`를 제공합니다. +* `call_tool()`은 모델을 위한 `content`, 코드를 위한 `structured_content`, 그리고 `is_error`를 반환합니다. 예외를 발생시키는 도구는 예외가 아니라 결과입니다. +* `content`는 블록 타입의 유니온입니다. 읽기 전에 `isinstance`로 좁히세요. +* `list_resources` / `list_resource_templates` / `read_resource`, `list_prompts` / `get_prompt`, `complete`가 나머지 동작을 이룹니다. +* 모든 `list_*`는 `cursor=`를 받습니다. `next_cursor`가 `None`이 될 때까지 루프를 도세요. + +서버가 **클라이언트**에 요청할 수 있는 것과 이에 응답하는 방법은 **[클라이언트 콜백](callbacks.md)**에서 다룹니다. diff --git a/i18n/ko/pages/client/oauth-clients.md b/i18n/ko/pages/client/oauth-clients.md new file mode 100644 index 0000000000..534311a96f --- /dev/null +++ b/i18n/ko/pages/client/oauth-clients.md @@ -0,0 +1,152 @@ +--- +translation: + sections: [c6899d3892bd9fa0, 79372cff3cc48a88, 63878d29e87c3e73, 13175843d3588af4, e7e2b9fd516f77de, 758f06399b513c1f, a05d7278487d610b] + tool: 1 +--- +# OAuth 클라이언트 {#oauth-clients} + +일부 MCP 서버는 보호되어 있습니다. 토큰 없이 요청을 보내면 `401 Unauthorized`로 응답합니다. + +**`OAuthClientProvider`**가 바로 토큰을 얻는 수단입니다. MCP 객체가 전혀 아닙니다. "모든 요청에 무언가를 한다"는 표준 httpx2 훅인 `httpx2.Auth`입니다. `httpx2.AsyncClient`에 붙이고, 그 클라이언트를 Streamable HTTP 트랜스포트에 넘긴 다음에는 더 신경 쓰지 않아도 됩니다. + +이 페이지는 클라이언트 쪽을 다룹니다. 작성한 서버가 토큰을 요구하도록 만드는 방법은 **[인가](../run/authorization.md)**에서 다룹니다. + +## 프로바이더 {#the-provider} + +```python title="client.py" hl_lines="44-54" +--8<-- "docs_src/oauth_clients/tutorial001.py" +``` + +프로바이더에는 네 가지를 넘깁니다. + +* `server_url`: 연결할 MCP 엔드포인트입니다. 프로바이더가 나머지는 모두 여기서 알아냅니다. +* `client_metadata`: 인가 서버의 "애플리케이션 등록" 양식에 입력할 만한 내용입니다. +* `storage`: 실행과 실행 사이에 토큰이 보관되는 곳입니다. +* `redirect_handler`와 `callback_handler`: 사람이 개입하는 두 순간입니다. + +파일의 나머지 부분에는 OAuth가 등장하지 않습니다. `main()`은 토큰을 전혀 보지 못합니다. + +### 클라이언트 메타데이터 {#client-metadata} + +`OAuthClientMetadata`는 실제 [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591) 등록 문서를 Pydantic 모델로 옮긴 것입니다. + +세 필드를 설정합니다. 나머지는 기본값이 채웁니다. `grant_types`는 이미 `["authorization_code", "refresh_token"]`이고 `response_types`는 이미 `["code"]`이며, 이 프로바이더가 실행하는 흐름이 정확히 이것입니다. + +!!! check + Pydantic 모델이므로 **네트워크로 단 한 바이트도 나가기 전에** 검증합니다. + `redirect_uris`를 빼면 생성하는 즉시 그 필드를 지목하는 `ValidationError`와 함께 + 실패합니다. + + ```text + redirect_uris + Field required [type=missing, input_value={'client_name': 'Bookshop Agent'}, input_type=dict] + ``` + + 브라우저도 열리지 않고, 인가 서버에 반쯤 끝난 등록이 남지도 않습니다. + +### 토큰 저장소 {#token-storage} + +**`TokenStorage`**는 비동기 메서드 네 개를 가진 `Protocol`입니다. 아무것도 상속하지 않습니다. 메서드만 작성하면 어떤 클래스든 토큰 저장소가 됩니다. + +* `get_tokens` / `set_tokens`는 `OAuthToken`을 보관합니다. 액세스 토큰, 리프레시 토큰, 만료 시각, 스코프가 여기에 담깁니다. +* `get_client_info` / `set_client_info`는 프로바이더가 등록할 때 인가 서버가 발급한 `OAuthClientInformationFull`을 보관하며, 여기에는 `client_id`가 포함됩니다. + +위의 인메모리 버전도 동작합니다. 다만 프로세스가 종료되면 모든 것을 잊어버리므로 다음 실행 때 전체 절차를 처음부터 다시 밟습니다. 파일이나 플랫폼의 키링에 영속화하면 다음 실행은 조용히 지나갑니다. + +!!! tip + 토큰만이 아니라 `client_info`도 저장하세요. 프로바이더는 저장된 `client_info`가 없으면 + 처음에 동적으로 등록합니다. 이를 버리면 실행할 때마다 새 등록을 만들어 냅니다. + +### 두 핸들러 {#the-two-handlers} + +인가 코드 흐름에는 사람이 정확히 한 번 필요합니다. 누군가 로그인해서 "허용"을 클릭해야 합니다. + +* **`redirect_handler`**는 완전히 조립된 인가 URL과 함께 await됩니다. `client_id`, `redirect_uri`, `state`, PKCE 챌린지가 이미 들어 있습니다. 할 일은 브라우저를 그 URL로 보내는 것뿐입니다. 데스크톱 앱이라면 `webbrowser.open`을 호출하고, 이 파일은 URL을 출력합니다. +* **`callback_handler`**가 그다음에 await됩니다. 사용자가 `redirect_uri`로 되돌아올 때까지 기다렸다가 그 리다이렉트의 쿼리 파라미터를 `AuthorizationCodeResult`로 반환합니다. + +실제 클라이언트는 `input()`을 호출하는 대신 리다이렉트 URI에서 작은 로컬 HTTP 서버를 띄웁니다. 형태는 똑같습니다. 리다이렉트를 받고 `code`, `state`, `iss`를 돌려줍니다. + +!!! warning + `state`와 `iss`는 도착한 그대로 전달하세요. 프로바이더는 `state`를 자신이 생성한 값과, + `iss`를 디스커버리로 알아낸 발급자와 비교하고, 일치하지 않으면 거부합니다. 이 둘이 CSRF와 + 서버 혼동(mix-up) 공격을 막는 방어 장치입니다. + +### `Client`에 넣기 {#into-the-client} + +`main()`을 보세요. 프로바이더는 **httpx2 클라이언트**에 붙고, httpx2 클라이언트는 `streamable_http_client(url, http_client=...)`에 들어가며, 그 트랜스포트가 `Client`에 들어갑니다. + +`streamable_http_client`에는 `auth=` 키워드가 없습니다. HTTP 수준의 것(인증, 헤더, 타임아웃, 프록시)은 모두 직접 가져오는 `httpx2.AsyncClient`에 속합니다. 이 계층 구조는 **[클라이언트 트랜스포트](transports.md)**에서 다룹니다. + +## 프로바이더가 대신 해 주는 일 {#what-the-provider-does-for-you} + +`Client`가 처음 요청을 보내면 서버는 `401`로 응답합니다. 그러면 프로바이더가 이어받습니다. + +1. **디스커버리.** `WWW-Authenticate` 헤더를 읽고, `/.well-known/oauth-protected-resource`에서 서버의 Protected Resource Metadata를 가져오고, 어느 인가 서버가 이 리소스를 보호하는지 알아낸 뒤, **그** 서버의 메타데이터를 가져옵니다. +2. **등록.** 저장소에 아무것도 없으면 `OAuthClientMetadata`로 동적으로 등록하고 결과를 저장합니다. +3. **인가.** PKCE 쌍과 `state`를 생성하고, 인가 URL을 조립하고, `redirect_handler`를 await한 다음, 코드를 받기 위해 `callback_handler`를 await합니다. +4. **교환.** 코드를 `OAuthToken`으로 교환해 저장하고, 원래 요청에 `Authorization: Bearer ...`를 붙여 다시 보냅니다. + +그다음부터는 조용합니다. 토큰은 저장소에서 꺼내 쓰고, 만료된 액세스 토큰은 리프레시 토큰으로 갱신하며, 그 어느 것도 통하지 않을 때에만 흐름을 다시 실행합니다. + +이 가운데 직접 작성한 코드는 하나도 없습니다. 키워드 인자가 두 개 더 남아 있는데(`client_metadata_url`과 `validate_resource_url`), 이 파일에는 둘 다 필요 없습니다. 알아 둘 만한 것은 `client_metadata_url`이며, 아래에 별도 섹션이 있습니다. + +### 직접 해 보기 {#try-it} + +이 문서의 예제 대부분은 인메모리 `Client(server)`로 확인할 수 있습니다. 이 예제는 아닙니다. 이 흐름의 핵심이 HTTP `401`인데, 인메모리 클라이언트와 서버 사이에는 HTTP가 없기 때문입니다. + +리포지토리에는 실제로 동작하는 버전이 들어 있습니다. `examples/servers/simple-auth/`는 독립 실행형 인가 서버와 보호된 MCP 서버를 실행하고, `examples/clients/simple-auth-client/`는 이 페이지의 클라이언트를 작은 CLI로 키운 것입니다. 그 README에 두 명령이 있습니다. 서버를 시작하고, 그 서버를 대상으로 클라이언트를 실행하면 네 단계가 지나가는 모습을 볼 수 있습니다. + +## Client ID Metadata Documents {#client-id-metadata-documents} + +사양의 2026-07-28 리비전은 동적 클라이언트 등록을 지원 중단 예정(deprecated)으로 돌리고 **Client ID Metadata Documents**(CIMD)를 권장합니다. 만나는 인가 서버마다 새 등록을 POST하는 대신, 클라이언트는 자신을 설명하는 JSON 문서 하나를 안정적인 HTTPS URL에 게시하고, 그 URL **자체가** `client_id`가 됩니다. 문서는 인가 서버가 가져가며, 프로바이더는 이를 전혀 건드리지 않습니다. + +SDK는 이미 이를 지원합니다. 프로바이더를 생성할 때 URL을 `client_metadata_url=`로 전달하세요. 인가 서버의 메타데이터가 `client_id_metadata_document_supported: true`를 광고하면 프로바이더는 `/register` 요청을 완전히 건너뜁니다. URL이 `client_id`로 흐름에 들어가고 `client_secret`은 없습니다. 서버가 이를 광고하지 않거나(아직 대부분 그렇습니다) URL을 전달하지 않으면 프로바이더는 **조용히** 동적 등록으로 되돌아가며, 위의 모든 내용이 설명한 그대로 동작합니다. 저장된 `client_info`는 여전히 둘보다 우선합니다. + +URL은 루트가 아닌 경로를 가진 HTTPS여야 합니다. 그 외에는 네트워크 통신이 일어나기 전, 생성 시점에 `ValueError`가 납니다. 함께 제공되는 `examples/clients/simple-auth-client/`는 이를 `MCP_CLIENT_METADATA_URL` 환경 변수로 받습니다. + +## 머신 대 머신 {#machine-to-machine} + +야간 작업, CI 단계, 다른 서비스. 브라우저도 없고 "허용"을 클릭할 사람도 없습니다. 이것이 **클라이언트 자격 증명(client credentials)** 그랜트입니다. `client_id`와 `client_secret`을 이미 가지고 있고, 토큰 엔드포인트가 흐름의 전부입니다. + +`ClientCredentialsOAuthProvider`는 사람만 빠진 똑같은 `httpx2.Auth`입니다. + +```python title="client.py" hl_lines="4 27-33" +--8<-- "docs_src/oauth_clients/tutorial002.py" +``` + +달라진 점은 다음과 같습니다. + +* `OAuthClientMetadata`도 핸들러도 없습니다. `client_id`와 `client_secret`을 전달하면 프로바이더가 이를 감싸는 최소한의 `client_credentials` 등록을 만들고 동적 등록은 완전히 건너뜁니다. +* `scope`는 공백으로 구분한 문자열로, OAuth의 전송 형식입니다. +* 그 아래는 모두 동일합니다. 같은 `TokenStorage`, 같은 `httpx2.AsyncClient(auth=...)`, 같은 `streamable_http_client`를 씁니다. + +기본적으로 시크릿은 토큰 요청에서 HTTP Basic 인증으로 전달됩니다(`client_secret_basic`). 대신 폼 본문에 넣으려면 `token_endpoint_auth_method="client_secret_post"`를 전달하세요. 둘 중 하나만 받는 인가 서버도 있습니다. + +!!! tip + `client_secret`은 환경 변수나 시크릿 매니저에서 읽고, 소스 관리에서는 절대 읽지 마세요. + +!!! info + `mcp.client.auth.extensions.client_credentials`에는 프로바이더가 하나 더 있습니다. + 공유 시크릿 대신 JWT로 인증하는 클라이언트를 위한 **`PrivateKeyJWTOAuthProvider`**입니다 + (`private_key_jwt`, 즉 키 쌍과 워크로드 아이덴티티 방식). 같은 패턴을 따릅니다. + 하나를 생성해 `auth=`에 넣으면 됩니다. 같은 모듈에는 그 어설션을 만드는 두 헬퍼인 + `SignedJWTParameters`와 `static_assertion_provider`도 들어 있습니다. + +사람이 없는 상황이 하나 더 있습니다. 클라이언트가 기업에 속해 있고, 어느 MCP 서버에 접근할 수 있는지를 사용자가 아니라 그 기업의 아이덴티티 공급자가 결정하는 경우입니다. 이는 고유한 신뢰 모델을 가진 다른 그랜트이며, 별도 페이지인 **[아이덴티티 어설션](identity-assertion.md)**에서 다룹니다. + +## 실패할 때 {#when-it-fails} + +OAuth 흐름이 잘못되면 프로바이더는 `mcp.client.auth`의 `OAuthFlowError`를 발생시킵니다. 하위 클래스가 둘 있습니다. `OAuthRegistrationError`는 등록 결과로 쓸 수 있는 클라이언트를 얻지 못했다는 뜻입니다. 인가 서버가 등록을 거부했거나, 등록은 했지만 이 흐름이 쓸 수 없는 자격 증명(예를 들어 구현하지 않은 인증 방식)을 준 경우입니다. `OAuthTokenError`는 토큰을 얻지 못했다는 뜻입니다. 토큰 엔드포인트가 거절했거나, 저장된 클라이언트 레코드에 이 클라이언트가 적용할 수 없는 인증 방식이 담겨 있는 경우로, 후자는 요청을 보내는 대신 토큰 요청을 조립하는 도중에 보고됩니다. `except OAuthFlowError:` 하나로 디스커버리, 등록, 인가, 교환을 모두 잡을 수 있습니다. + +모든 것이 흐름 오류인 것은 아닙니다. 네트워크는 여전히 실패할 수 있으며, 그런 경우는 평범한 `httpx2` 예외이고 그대로 통과합니다. + +## 요약 {#recap} + +* `OAuthClientProvider`는 `httpx2.Auth`입니다. `httpx2.AsyncClient`에 붙이고, 이를 `streamable_http_client(url, http_client=...)`에 전달하면 `Client`는 OAuth가 일어났는지조차 모릅니다. +* 네 가지를 제공합니다. 서버 URL, `OAuthClientMetadata`, `TokenStorage`, 그리고 리다이렉트/콜백 핸들러 쌍입니다. +* `TokenStorage`는 `Protocol`입니다. 비동기 메서드 네 개, 기반 클래스는 없습니다. 토큰뿐 아니라 `client_info`도 영속화하세요. +* 디스커버리, 등록(동적 등록 또는 **Client ID Metadata Document**를 통한 등록), PKCE, `state`와 `iss` 검사, 토큰 갱신은 프로바이더의 일이지 직접 할 일이 아닙니다. +* `ClientCredentialsOAuthProvider`는 사람이 없는 버전입니다. `client_id` + `client_secret`, 핸들러도 브라우저도 없습니다. +* 모든 OAuth 실패는 `OAuthFlowError`이며, `OAuthRegistrationError`와 `OAuthTokenError`가 그 하위 클래스입니다. + +이 핸드셰이크의 나머지 절반, 즉 **서버**가 토큰을 요구하도록 만드는 방법은 **[인가](../run/authorization.md)**에서 다룹니다. diff --git a/i18n/ko/pages/client/session-groups.md b/i18n/ko/pages/client/session-groups.md new file mode 100644 index 0000000000..be15ae2084 --- /dev/null +++ b/i18n/ko/pages/client/session-groups.md @@ -0,0 +1,87 @@ +--- +translation: + sections: [09c857a25a9dc37a, 43bc6a76a243a50e, 0a716022a88768df, 4b7f78042bfcfff7, c112662e61b03315, 58974ba1f489a8b4, d18adbdbb835ea73] + tool: 1 +--- +# 세션 그룹 {#session-groups} + +`Client`는 하나의 서버에 연결합니다. 실제 애플리케이션은 여러 서버(검색 서버, 데이터베이스 서버, 내부 API)가 필요한 경우가 많고, 결국 서버마다 연결과 도구 목록을 따로 관리하게 됩니다. + +**`ClientSessionGroup`**은 여러 연결을 담고, 각 연결이 제공하는 모든 것을 하나의 뷰로 합쳐 주는 단일 객체입니다. + +## 서버 두 개 {#two-servers} + +평범한 서버 두 개로 시작합니다. 서로 아무 관련이 없으므로 둘 다 자연스럽게 도구 이름을 `search`라고 지었습니다. + +```python title="library_server.py" hl_lines="7" +--8<-- "docs_src/session_groups/tutorial001.py" +``` + +```python title="web_server.py" hl_lines="7" +--8<-- "docs_src/session_groups/tutorial002.py" +``` + +## 그룹 하나 {#one-group} + +`ClientSessionGroup`을 만들고 서버마다 **`connect_to_server`**를 한 번씩 호출하세요. + +```python title="client.py" hl_lines="10-12" +--8<-- "docs_src/session_groups/tutorial003.py" +``` + +* `connect_to_server`는 서버 객체가 아니라 트랜스포트 매개변수를 받습니다. 서브프로세스를 띄우려면 `StdioServerParameters`(`mcp`에서 가져옴), 이미 URL에서 수신 대기 중인 서버라면 `StreamableHttpParameters` / `SseServerParameters`(`mcp.client.session_group`에서 가져옴)를 사용합니다. +* `group.tools`는 연결된 모든 서버의 도구를 담은 `dict[str, Tool]`입니다. `group.resources`와 `group.prompts`도 같은 형태입니다. +* `group.call_tool(name, arguments)`는 이름을 조회해 그 이름을 소유한 세션을 찾고 호출을 전달합니다. 어느 서버인지 지정할 일이 없습니다. + +!!! check + `client.py`를 두 서버와 같은 곳에 두고 실행하세요. 두 번째 `connect_to_server`가 거부합니다. + + ```text + mcp.shared.exceptions.MCPError: {'search'} already exist in group tools. + ``` + + 이것은 `MCPError`이며, 두 번째 서버의 어떤 것도 등록되기 전에 발생합니다. 이름은 그룹 **전체**에서 + 고유해야 하고, 직접 제어하지 않는 두 서버는 언젠가 충돌하기 마련입니다. + +## `component_name_hook` {#component_name_hook} + +이 문제는 서버가 아니라 그룹에서 해결합니다. `(name, server_info)`를 받는 함수를 전달하면 그룹이 등록하는 모든 이름에 대해 그 함수를 실행합니다. + +```python title="client.py" hl_lines="7-8 15" +--8<-- "docs_src/session_groups/tutorial004.py" +``` + +다시 실행하세요. 이제 `print(sorted(group.tools))`가 둘 다 보여 줍니다. + +```text +['Library.search', 'Web.search'] +``` + +* **키**는 직접 정한 것입니다. `by_server`가 `server_info.name`, 즉 각 `MCPServer(...)`를 생성할 때 지정한 이름으로 키를 만들었습니다. +* 안에 든 `Tool`은 그대로입니다. `group.tools["Web.search"].name`은 여전히 `"search"`이며, `call_tool`이 전송할 때 쓰는 이름도 바로 이것입니다. 접두사는 프로세스 밖으로 나가지 않습니다. +* 도구만 해당하는 것이 아닙니다. 라이브러리의 `hours` 리소스는 `Library.hours`로 등록됩니다. + +!!! tip + 훅은 충돌이 있을 때만이 아니라 **모든** 서버의 **모든** 이름에 대해 실행됩니다. 충돌 시에만 접두사를 + 붙이는 모드는 없습니다. 방식을 하나 정하고 어디에나 적용되도록 하세요. + +## 서버 추가와 제거 {#adding-and-removing-servers} + +`connect_to_server`는 자신이 연 `ClientSession`을 반환합니다. 나중에 그 서버를 빼고 싶다면 이 값을 보관해 두세요. `await group.disconnect_from_server(session)`이 그 서버의 도구, 리소스, 프롬프트를 그룹에서 제거합니다. + +이미 연결된 `ClientSession`을 갖고 있다면(`Client.session`이 그런 예입니다) 새 트랜스포트를 여는 대신 `await group.connect_with_session(server_info, session)`에 넘기세요. 같은 방식으로 합쳐집니다. 그룹은 자신이 열지 않은 세션을 절대 닫지 않습니다. `server_info`는 구성 요소 접두사에 쓰일 서버 이름을 지정합니다. 2026년대 연결에서는 `client.server_info`가 `None`일 수 있으므로(신원 정보는 선택 사항입니다), 그런 경우에는 직접 만든 `Implementation(name=..., version=...)`을 전달하세요. + +## 고전 핸드셰이크 {#the-classic-handshake} + +`ClientSessionGroup`은 `Client`가 아니라 `ClientSession` 위에 만들어졌습니다. `connect_to_server`는 매번 고전적인 `initialize` 핸드셰이크를 실행합니다. **[프로토콜 버전](../protocol-versions.md)**에서 설명하는 `server/discover` 탐색은 보내지 않습니다. 모든 MCP 서버가 이 핸드셰이크를 이해하므로 호환성에서 잃는 것은 없습니다. 다만 더 나은 경로를 지원하는 서버에도 그룹은 더 오래되고 느린 경로를 택한다는 뜻일 뿐입니다. + +## 요약 {#recap} + +* `ClientSessionGroup`은 여러 서버 연결을 담고 도구, 리소스, 프롬프트를 각각 하나의 `dict`로 합칩니다. +* 서버마다 `connect_to_server(params)`를 호출합니다. `Client`가 받는 서버 객체나 URL이 아니라 트랜스포트 매개변수를 받습니다. +* `group.call_tool(name, arguments)`는 소유한 서버로 알아서 라우팅합니다. +* 이름은 그룹 전체에서 고유해야 합니다. `search` 도구를 가진 두 서버는 그대로는 공존할 수 없습니다. +* `component_name_hook=`은 등록되는 모든 이름을 다시 씁니다. 딕셔너리 키는 바뀌지만 전송되는 이름은 바뀌지 않습니다. +* `connect_with_session`은 이미 가진 세션을 추가하고, `disconnect_from_server`는 세션을 제거합니다. + +그룹이 사용하는 핸드셰이크(그리고 `Client`가 선호하는 더 빠른 핸드셰이크)에 관한 자세한 내용은 **[프로토콜 버전](../protocol-versions.md)**에서 확인하세요. diff --git a/i18n/ko/pages/client/subscriptions.md b/i18n/ko/pages/client/subscriptions.md new file mode 100644 index 0000000000..87772ce915 --- /dev/null +++ b/i18n/ko/pages/client/subscriptions.md @@ -0,0 +1,91 @@ +--- +translation: + sections: [8f9558e57f29eee1, a88c587739e0465c, 46ebfd5b325ed041, 4d10b00b57ce4bd9, 2cdb0edd1f59b3e2] + tool: 1 +--- +# 구독 {#subscriptions} + +서버의 카탈로그는 고정되어 있지 않습니다. 도구는 런타임에 생겨나고, 리소스 URI 뒤에 있는 내용은 바뀝니다. 클라이언트는 `client.listen(...)`을 통해 이런 변화를 전달받습니다. `subscriptions/listen` 요청 하나를 보내면 그 응답 자체가 **스트림**이 됩니다. 이 스트림은 열린 채로 유지되며 클라이언트가 요청한 변경 알림을 실어 나릅니다. + +이 페이지는 클라이언트 쪽 이야기입니다. 스트림을 열고, 메인 흐름 옆에서 지켜보고, 스트림이 끝나는 상황을 처리하는 방법을 다룹니다. 변경 사항 발행, 필터링, 메서드 제공은 서버 쪽 이야기이며, **핸들러 내부** 아래의 **[구독](../handlers/subscriptions.md)**에서 설명합니다. 여기 나오는 예제는 그 페이지에서 만든 스프린트 보드 서버와 통신합니다. + +## 스트림 지켜보기 {#watching-the-stream} + +구독은 컨텍스트 매니저 하나입니다. 진입하면 키워드 인수를 구독 필터로 삼아 요청을 보내고 서버의 확인 응답을 기다리므로, 블록이 시작될 때는 이미 스트림이 살아 있습니다. + +```python title="client.py" hl_lines="15 18 28" +--8<-- "docs_src/subscriptions/tutorial003.py" +``` + +반복하면 네 가지 타입의 이벤트가 나옵니다. `ToolsListChanged`, `PromptsListChanged`, `ResourcesListChanged`, `ResourceUpdated(uri=...)`입니다. + +이벤트는 **무엇이** 바뀌었는지만 알려 주고 **어떻게** 바뀌었는지는 알려 주지 않습니다. `follow_board`가 `read_resource`와 `list_tools`를 호출하는 이유가 바로 이것입니다. 이벤트는 다시 가져오라는 신호입니다. 어느 리소스가 바뀌었는지 짐작하지 말고 `event.uri`를 읽으세요. 필터 하나가 여러 URI를 지정할 수 있고, 서버가 그중 하나의 하위 리소스에 대한 변경을 보고할 수도 있습니다. + +소비되기를 기다리는 중복 이벤트는 하나로 합쳐지며, 그래도 다시 가져오면 현재 상태를 얻습니다. 합쳐지는 것은 동일한 이벤트뿐입니다. 서로 다른 URI에 대한 `ResourceUpdated` 두 개는 두 개의 이벤트입니다. + +핸들에는 속성이 두 가지 더 있습니다. + +* `sub.honored`는 서버가 확인 응답으로 인정한 필터입니다. 전달한 필드를 담은 `SubscriptionFilter`이며 속성으로 읽습니다(`sub.honored.prompts_list_changed`). `MCPServer`는 요청한 종류를 모두 인정하므로 요청을 그대로 되돌려 줍니다. 더 적은 종류를 지원하는 서버는 더 적게 인정하며, 인정된 종류라도 한 번도 발생하지 않을 수 있습니다. 서버가 요청을 인정하는 대신 통째로 거부할 수도 있는데(서버 페이지의 [누가 지켜볼 수 있는지 결정하기](../handlers/subscriptions.md#deciding-who-may-watch) 참고), 이 경우 요청의 오류로 나타납니다. +* `sub.subscription_id`는 listen 요청의 id이며, 이 스트림의 모든 프레임에 찍히는 바로 그 값입니다. 여러 구독을 동시에 열어 둘 수 있고, 각각은 자신의 id로 역다중화됩니다. + +## 블로킹 없이 지켜보기 {#watching-without-blocking} + +`follow_board`는 서버가 스트림을 닫을 때까지 실행되는데, 그 시점이 영영 오지 않을 수도 있으므로 단독으로 두면 프로그램 전체를 차지합니다. 실제 클라이언트는 감시자를 메인 흐름 **옆에** 두고 싶어 합니다. 에이전트가 도구를 호출하는 동안 감시자는 캐시나 UI를 최신 상태로 유지합니다. + +먼저 구독을 열고, 그다음 감시자를 시작한 뒤 하던 일을 계속하세요. + +=== "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`는 첫 번째 예제에서 `BOARD`와 `read_board`를 가져오는데, 이 저장소에서는 그 예제를 + `tutorial003.py`로 저장합니다. 렌더링된 파일을 `client.py`와 `app.py`로 나란히 저장했다면 + 대신 `from client import BOARD, read_board`라고 쓰세요. 아래쪽의 `watch.py` 예제도 + 같은 방식으로 `read_board`를 가져옵니다. + +핵심은 순서입니다. 아무것도 재생되지 않으므로 스트림이 존재하기 전에 발행된 이벤트는 놓칩니다. `client.listen(...)`에 진입하면 확인 응답을 기다리므로, 그 순간부터의 모든 변경이 감시자에게 도달하며 블록 안에서 찍은 스냅샷은 하나도 빠뜨리지 않습니다. + +열린 스트림 옆에서도 요청은 자유롭게 실행됩니다. 감시자 태스크에서든 다른 태스크에서든 같은 클라이언트로 보낼 수 있습니다. 소비되지 않은 **중복** 이벤트는 합쳐지므로, 메인 흐름이 바쁘면 다시 가져오기가 세 번이 아니라 한 번만 일어날 수 있습니다. 서로 다른 이벤트는 합쳐지지 않습니다. 여러 URI를 지정한 필터는 URI마다 대기 중인 이벤트를 하나씩 큐에 쌓습니다. + +지켜보기를 멈추려면 블록을 벗어나세요. `unsubscribe` 호출은 없습니다. 블록을 소유한 태스크를 취소하면 그렇게 되며, SDK는 트랜스포트가 기대하는 방식으로 listen 요청을 취소합니다. Streamable HTTP에서는 해당 요청의 스트림을 닫습니다. 앱이 살아 있는 동안 계속 도는 감시자는 스스로 반환하지 않으므로, 종료 시 그 감시자나 감시자가 속한 태스크 그룹의 스코프를 취소하세요. + +## 스트림의 끝 {#streams-end} + +스트림은 두 가지 방식 중 하나로 끝나며, 둘 다 평범한 제어 흐름입니다. 서버가 정상적으로 닫으면 `async for`가 끝나고, 갑자기 끊기면 `SubscriptionLost`가 발생합니다. + +이 차이는 진단용일 뿐, 다음에 할 일이 달라지지는 않습니다. 스트림은 사라졌고, 재생된 것은 없으며, 여전히 관심이 있는 감시자는 다시 listen하고 다시 가져옵니다. + +```python title="watch.py" hl_lines="16 20" +--8<-- "docs_src/subscriptions/tutorial005.py" +``` + +서버는 나름의 이유로 스트림을 정상적으로 닫습니다. 백로그가 너무 커진 구독자를 떼어 내는 경우도 여기에 포함되므로, 깔끔한 종료가 지켜보기를 멈추라는 신호는 아닙니다. 다시 listen하기 전에 백오프하세요. + +`SubscriptionLost`에는 로컬 원인도 하나 있습니다. 클라이언트는 소비되지 않은 이벤트를 최대 1024개까지 보관하며, 그만큼 뒤처진 소비자는 한없이 불어나는 대신 구독을 잃습니다. `async for` 본문은 짧게 유지하고 느린 작업은 다른 곳에서 하세요. + +`keep_following`은 `SubscriptionLost`만 잡습니다. `listen()`에 진입할 때는 `MCPError`(연결이 실패했거나 서버가 해당 메서드를 제공하지 않음), `TimeoutError`(확인 응답이 도착하지 않음), `ListenNotSupportedError`(2026년 이전 연결)도 발생할 수 있습니다. 감시자가 이 중 무엇을 재시도해야 할지 정하세요. 마지막 것은 결코 회복되지 않습니다. + +## 요약 {#recap} + +* `async with client.listen(...)`에 진입하세요. 진입하면 확인 응답을 기다리므로 그 이후에 발행된 것은 하나도 놓치지 않습니다. +* `async for event in sub`로 반복하세요. 이벤트는 다시 가져오라는 신호이지 페이로드가 아닙니다. +* 구독을 연 다음 감시자를 태스크로 실행하면, 도구 호출은 그 옆에서 계속 흐릅니다. +* 깔끔한 종료는 루프를 멈추고, 끊김은 `SubscriptionLost`를 발생시킵니다. 어느 쪽이든 다시 listen하고, 다시 가져오되, 먼저 백오프하세요. +* 블록을 벗어나는 것이 곧 구독 해지입니다. + +이 이벤트를 발행하고, 필터를 좁히고, 단일 프로세스를 넘어 확장하는 것은 서버 쪽 이야기입니다. 자세한 내용은 **[구독](../handlers/subscriptions.md)**에서 확인하세요. 같은 이벤트는 클라이언트 쪽 캐시를 정확하게 유지하는 데도 쓰이며, 다음 페이지는 **[캐싱](caching.md)**입니다. diff --git a/i18n/ko/pages/client/transports.md b/i18n/ko/pages/client/transports.md new file mode 100644 index 0000000000..3a8dbca682 --- /dev/null +++ b/i18n/ko/pages/client/transports.md @@ -0,0 +1,128 @@ +--- +translation: + sections: [9cac816674181eb0, 0700f337babcd4dd, 2bde0dd58cdf00f5, ff7401df479af877, 3d0832f39b0d7059, d4bf7e4479637768, 05e20c0a798860e7] + tool: 1 +--- +# 클라이언트 트랜스포트 {#client-transports} + +모든 `Client`는 **트랜스포트**를 통해 서버와 통신합니다. 메시지를 실제로 실어 나르는 것이 바로 트랜스포트입니다. + +트랜스포트를 따로 설정할 일은 없습니다. `Client`는 위치 인자 하나만 받으며, 그 타입을 보고 어떤 트랜스포트를 쓸지 판단합니다. + +각 트랜스포트의 **서버** 쪽(`mcp.run()`이 하는 일과 배포 대상)은 **[서버 실행하기](../run/index.md)**에서 다룹니다. + +## 인메모리 {#in-memory} + +서버 객체 자체를 전달하세요. + +```python title="client.py" hl_lines="14" +--8<-- "docs_src/client_transports/tutorial001.py" +``` + +서브프로세스도, 포트도, 네트워크를 오가는 바이트도 없습니다. 클라이언트와 서버는 같은 프로세스 안의 두 객체일 뿐이지만, 호출은 여전히 실제 프로토콜 계층을 거칩니다. `search_books`는 HTTP를 통할 때와 똑같이 나열되고, 검증되고, 호출됩니다. + +덕분에 이 방식은 동시에 두 가지 역할을 합니다. + +* **테스트 도구.** 이 문서의 모든 예제는 이 방식으로 실행되며, **[테스트](../get-started/testing.md)** 페이지는 전체 패턴을 이 방식 위에 구축합니다. +* **임베딩 API.** 서버를 직접 생성하는 애플리케이션은 도구를 호출하기 위해 네트워크를 거칠 필요가 없습니다. + +## Streamable HTTP {#streamable-http} + +URL 문자열을 전달하면 **Streamable HTTP**를 얻습니다. 배포 시 사용하는 트랜스포트입니다. + +```python title="client.py" hl_lines="5" +--8<-- "docs_src/client_transports/tutorial002.py" +``` + +이것이 프로덕션 클라이언트의 전부입니다. `Client`가 URL을 `streamable_http_client(...)`로 감싸 주며, 그 아래에는 MCP에 맞게 설정된 `httpx2.AsyncClient`가 있습니다. `follow_redirects=True`, connect/write/pool에 30초 타임아웃, 그리고 서버가 응답 스트림을 열어 둘 수 있으므로 read에는 300초 타임아웃이 적용됩니다. + +!!! check + 생성만 한 `Client`는 **연결되지 않은** 상태입니다. 생성은 트랜스포트를 고를 뿐이고, + 실제로 여는 것은 `async with`입니다. 진입하기 전에 연결을 사용하려 하면 SDK가 이를 알려 줍니다. + + ```text + RuntimeError: Client must be used within an async context manager + ``` + + `Client("http://...")`를 작성한 시점에는 아무것도 리졸브되거나, 가져오거나, 생성되지 않았습니다. 그 줄은 비용이 들지 않습니다. + +### 직접 만든 `httpx2.AsyncClient` 사용하기 {#bring-your-own-httpx2asyncclient} + +`Authorization` 헤더, 쿠키, 프록시, mTLS, 다른 타임아웃이 필요해지는 순간, `httpx2.AsyncClient`를 직접 만들어 `streamable_http_client`에 넘기세요. + +```python title="client.py" hl_lines="8-14" +--8<-- "docs_src/client_transports/tutorial003.py" +``` + +눈여겨볼 점이 두 가지 있습니다. + +* `httpx2.AsyncClient`의 소유자는 작성한 코드이므로, 진입과 종료도 **직접** 해야 합니다. SDK는 자신이 만들지 않은 클라이언트를 절대 닫지 않습니다. +* `streamable_http_client(url, http_client=...)`는 트랜스포트를 반환하고, `Client(transport)`는 이를 다른 것과 마찬가지로 받아들입니다. + +TLS 관련 참고 사항이 하나 있습니다. `httpx2`는 번들된 CA 목록이 아니라 +([`truststore`](https://pypi.org/project/truststore/)를 통해) 운영체제의 신뢰 저장소를 기준으로 +인증서를 검증합니다. 사용 가능한 시스템 CA 저장소가 없는 환경(일부 최소 컨테이너)에서는 표준 +`SSL_CERT_FILE`/`SSL_CERT_DIR` 환경 변수를 설정하거나 `httpx2.AsyncClient`에 명시적으로 +`verify=ssl_context`를 전달하세요(배경 설명은 +[`httpx2`로 대체된 `httpx`와 `httpx-sse`](../migration.md#httpx-and-httpx-sse-replaced-by-httpx2)에 있습니다). + +!!! warning + `streamable_http_client`는 예전에 `headers=`와 `timeout=`을 직접 받았습니다. 이제는 받지 않습니다. + 매개변수는 `url`, `http_client`, `terminate_on_close`뿐입니다. 습관적으로 `headers=`를 쓰면 + 다음 오류가 납니다. + + ```text + TypeError: streamable_http_client() got an unexpected keyword argument 'headers' + ``` + + HTTP와 관련된 모든 것은 이제 전달하는 `httpx2.AsyncClient` 하나에 담깁니다. + +!!! info + `httpx2`는 익숙한 `httpx` API를 그대로 유지하므로, `httpx`를 안다면 여기서 인증, 프록시, + 이벤트 훅, 재시도, 연결 제한을 다루는 방법도 이미 아는 셈입니다. SDK는 그 위에 아무것도 + 더하지 않고 아무것도 빼지 않습니다. OAuth가 연결되는 지점도 여기입니다. + `httpx2.AsyncClient(auth=OAuthClientProvider(...))`. 전체 흐름은 **[OAuth 클라이언트](oauth-clients.md)**에서 다룹니다. + +## stdio {#stdio} + +**stdio** 서버는 서브프로세스입니다. 클라이언트가 이를 실행하고, stdin에 JSON-RPC를 쓰고, stdout에서 JSON-RPC를 읽습니다. 데스크톱 호스트가 사용자 컴퓨터에서 서버를 실행하는 방식이 바로 이것입니다. 호스트는 **곧** 이 코드에 UI를 더한 것이며, **[실제 호스트에 연결하기](../get-started/real-host.md)**는 같은 관계를 호스트 쪽에서 설정 파일로 바라본 것입니다. + +`StdioServerParameters`로 프로세스를 기술하고, `stdio_client`로 트랜스포트로 바꾼 다음, **그것**을 `Client`에 넘기세요. + +```python title="client.py" hl_lines="4-8 12" +--8<-- "docs_src/client_transports/tutorial004.py" +``` + +`Client`는 매개변수 객체를 단독으로 받지 않습니다. `StdioServerParameters`는 설정이고, `stdio_client(server)`는 그 설정으로 프로세스를 띄우는 방법을 아는 트랜스포트입니다. 항상 감싸서 전달하세요. + +`async with` 블록을 벗어나면 서브프로세스도 함께 종료됩니다. stdin을 닫고, 기다리고, 남아 있으면 강제 종료합니다. 직접 정리할 일은 없습니다. + +!!! warning + 자식 프로세스는 환경 변수를 상속하지 **않습니다**. 직접 작성하지 않았을 수도 있는 프로세스로 + 민감한 정보가 새어 나가지 않도록 최소한의 허용 목록(POSIX에서는 `HOME`, `LOGNAME`, `PATH`, + `SHELL`, `TERM`, `USER`)만 전달됩니다. + + API 키가 필요한 서버는 거기서 키를 찾지 못합니다. `env=`로 명시적으로 전달하세요. 해당 + 변수는 허용 목록 위에 병합됩니다. 위 예제에서 `BOOKSHOP_API_KEY`가 하는 일이 바로 이것입니다. + +## SSE {#sse} + +`mcp.client.sse`의 `sse_client(url)`은 Streamable HTTP로 대체된 이전 HTTP 트랜스포트입니다. 아직 이 방식을 쓰는 서버와 통신하려면 `Client(sse_client("http://localhost:8000/sse"))`처럼 같은 방식으로 감싸서 사용하되, 새로운 것을 이 위에 만들지는 마세요. + +## `Transport` 프로토콜 {#the-transport-protocol} + +`Client`에게 위의 모든 것은 같은 것입니다. + +**트랜스포트**란 `(read, write)` 메시지 스트림 쌍을 내어주는 비동기 컨텍스트 매니저라면 무엇이든 해당합니다. 정식으로는 `mcp.client`의 `Transport` 프로토콜입니다. `Client`는 인자를 타입으로 구분합니다. 서버 객체는 프로세스 내에서 연결하고, `str`은 `streamable_http_client(url)`이 되며, 그 밖의 것은 트랜스포트로 직접 진입합니다. 마지막 규칙 덕분에 `stdio_client(...)`, `streamable_http_client(...)`, `sse_client(...)`가 모두 같은 자리에 들어가고, 직접 만든 트랜스포트도 쓸 수 있습니다. + +## 요약 {#recap} + +* `Client(mcp)`(서버 객체)는 인메모리로 연결합니다. 테스트와 임베딩에 사용하세요. +* `Client("http://.../mcp")`(URL)는 프로덕션 트랜스포트인 Streamable HTTP로 연결합니다. +* 헤더, 인증, 프록시, 타임아웃은 `streamable_http_client(url, http_client=...)`에 전달하는 `httpx2.AsyncClient`에 설정합니다. `headers=` 키워드는 없습니다. +* stdio는 `Client(stdio_client(StdioServerParameters(...)))`이며, 매개변수 객체만 단독으로 쓰는 일은 절대 없습니다. +* 서브프로세스는 현재 환경이 아니라 허용 목록에 있는 환경 변수만 받습니다. `env=`로 여기에 추가합니다. +* 트랜스포트는 `async with x as (read, write)`로 쓸 수 있는 것이면 무엇이든 됩니다. `Client`는 서버 객체나 URL이 아닌 것은 모두 그 프로토콜에 그대로 넘깁니다. +* `Client`를 생성하면 트랜스포트가 정해집니다. `async with`가 이를 엽니다. + +트랜스포트가 열리면 양쪽은 프로토콜 버전에 합의해야 합니다. 보통은 신경 쓸 일이 없지만, 필요할 때는 **[프로토콜 버전](../protocol-versions.md)** 페이지를 확인하세요. diff --git a/i18n/ko/pages/deprecated.md b/i18n/ko/pages/deprecated.md new file mode 100644 index 0000000000..ba4dc0e836 --- /dev/null +++ b/i18n/ko/pages/deprecated.md @@ -0,0 +1,96 @@ +--- +translation: + sections: [20541a40dbdd5980, 01262a123ad9501d, 429db5b574a2ac08, 56b2d49da412cb28, 6a1717123fe4513c] + tool: 1 +--- +# 지원 중단 예정 기능 {#deprecated-features} + +2026-07-28 사양은 다섯 가지를 퇴역시킵니다. SDK는 여전히 이 다섯 가지를 모두 구현하며, 이제 모두에 **지원 중단 예정(deprecated) 경고**가 붙습니다. + +아래 표는 지원 중단 예정인 각 기능의 이름, 사라지는 이유, 그리고 대신 사용할 대체 수단을 정리한 것입니다. + +## 지원 중단 예정 대상 {#what-is-deprecated} + +| 지원 중단 예정 | 이유 | 대신 할 일 | +|---|---|---| +| **루트**: `ctx.session.list_roots()`, `client.send_roots_list_changed()`, `Client(...)`에 전달하는 `list_roots_callback=` | [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)이 이 기능을 퇴역시킵니다. | 경로를 일반 도구 인자나 리소스 URI로 받거나, `InputRequiredResult`에 `ListRootsRequest`를 담으세요(**[다중 왕복 요청](handlers/multi-round-trip.md)** 참고). | +| **서버 주도 샘플링**: `ctx.session.create_message()`, `Client(...)`에 전달하는 `sampling_callback=` | SEP-2577이 이 기능을 퇴역시킵니다. | `InputRequiredResult`를 반환하고 클라이언트가 호출을 재시도하게 하세요(**[다중 왕복 요청](handlers/multi-round-trip.md)** 참고). | +| **프로토콜 로깅**: `ctx.log()`, `ctx.debug()`, `ctx.info()`, `ctx.warning()`, `ctx.error()`, `ctx.session.send_log_message()`, `client.set_logging_level()` | SEP-2577이 이 기능을 퇴역시킵니다. 프로토콜 안에서 이를 대체하는 것은 없습니다. | stderr로 보내는 일반 `import logging`을 사용하세요(**[로깅](handlers/logging.md)** 참고). | +| **`ping`**: `client.send_ping()` | 단순히 지원 중단 예정이 아니라 프로토콜에서 **제거되었습니다**. 2026-07-28에는 `ping` 메서드가 없습니다. | 없습니다. `mode="legacy"` 연결에서만 동작합니다. | +| **클라이언트->서버 진행 상황**: `client.send_progress_notification()` | 2026-07-28에서는 진행 상황이 서버->클라이언트 방향만 허용됩니다. | 보낼 것이 없습니다. **서버**가 `ctx.report_progress()`로 진행 상황을 보고합니다(**[진행 상황](handlers/progress.md)** 참고). | + +이 표에서 세 가지를 읽어낼 수 있습니다. + +* 루트, 샘플링, 로깅은 한 묶음입니다. 하나의 제안인 **SEP-2577**이 세 기능을 한꺼번에 지원 중단 예정으로 지정합니다. +* 샘플링과 루트는 더 근본적인 문제를 공유합니다. 둘 다 **서버**가 **클라이언트**에게 **요청**을 보내는 지점입니다. 2026-07-28은 바로 이 방향 전체를 **[다중 왕복 요청](handlers/multi-round-trip.md)**으로 대체합니다. 사라지는 것은 독립된 RPC 메서드(`sampling/createMessage`, `roots/list`, 푸시 방식의 `elicitation/create`)이고, `CreateMessageRequest` / `ListRootsRequest` / `ElicitRequest` 페이로드 타입은 `InputRequiredResult.input_requests`에 담긴 형태로 살아남으며, 클라이언트에서는 같은 콜백에 도달합니다. +* `ping`은 예외적인 경우입니다. 프로토콜은 이를 지원 중단 예정으로 지정한 것이 아니라 제거합니다. SDK 메서드는 여전히 경고를 내며(경고 메시지는 *deprecated*가 아니라 *removed*라고 말합니다), 최신 연결에서 호출하면 *"Method not found"*가 돌아옵니다. + +## 지원 중단 예정은 권고 사항입니다 {#deprecated-is-advisory} + +오늘 당장 깨지는 것은 없습니다. + +위의 모든 메서드는 **2025-11-25 또는 그 이전**으로 협상된 세션에서 계속 동작합니다. 클라이언트에서 `mode="legacy"`로 고정하면 2026년 이전과 정확히 같은 동작을 얻습니다. 와이어 변경은 없고 기능 협상도 그대로입니다. + +달라지는 점은 각 메서드가 처음 실행될 때 눈에 띄는 경고가 나온다는 것입니다. + +```text +MCPDeprecationWarning: The logging capability is deprecated as of 2026-07-28 (SEP-2577). +``` + +`MCPDeprecationWarning`은 `DeprecationWarning`이 **아니라** `UserWarning`의 하위 클래스입니다. 의도된 선택입니다. Python의 기본 필터는 `__main__`으로 직접 실행되는 코드에서만 `DeprecationWarning`을 보여 주는데, 라이브러리가 이런 식으로 지원 중단 예정을 알리면 2년 동안 아무도 눈치채지 못합니다. 이 경고는 `-W` 플래그 없이도 어디서나 나타납니다. + +!!! warning + "권고 사항"은 와이어 앞에서 멈춥니다. 샘플링과 루트는 서버에서 클라이언트로 가는 + **요청**이고, 2026-07-28 세션에는 이를 실어 나를 채널이 없습니다. 최신 연결의 도구 + 안에서 `ctx.session.create_message()`를 호출하면 경고는 여전히 발생하고, 그다음 전송이 + 오류와 함께 실패합니다. + + ```text + Cannot send 'sampling/createMessage': this transport context has no back-channel + for server-initiated requests. + ``` + + 두 개의 신호가 이 순서로 나옵니다. `MCPDeprecationWarning`은 어떤 연결에서든 메서드를 + 호출하는 순간 발생합니다. 오류는 그다음 SDK가 전송을 시도할 때 돌아오는 결과입니다. + 이 두 기능은 클라이언트가 해당 콜백을 등록한 `mode="legacy"` 연결에서만 처음부터 + 끝까지 동작합니다. + +## 경고 끄기 {#silencing-the-warning} + +새 코드에서는 끄지 마세요. + +하지만 유지보수 중인 서버가 실제로 2026년 이전 클라이언트를 상대한다면 조용한 로그를 가질 자격이 충분합니다. 지원 중단 예정 호출이 처음 실행되기 전에 카테고리를 필터링하세요. + +```python +import warnings + +from mcp import MCPDeprecationWarning + +warnings.filterwarnings("ignore", category=MCPDeprecationWarning) +``` + +이것이 API의 전부입니다. 메서드별 스위치는 없으며, 있을 필요도 없습니다. 카테고리가 하나라는 것의 핵심은 한 줄로 끄고 한 줄로 다시 켤 수 있다는 점입니다. + +!!! check + 필터를 반대 방향으로 적용하면 회귀 테스트를 거저 얻습니다. pytest 설정의 + `filterwarnings` 항목에 `"error::mcp.MCPDeprecationWarning"`을 추가하면 지원 중단 예정 + 호출이 경고 대신 예외를 **발생시킵니다**. 여전히 `ctx.info()`를 호출하는 `old_log`라는 + 도구는 더 이상 통과하지 못하고 다음과 같이 보고하기 시작합니다. + + ```text + Error executing tool old_log: The logging capability is deprecated as of 2026-07-28 (SEP-2577). + ``` + + pytest 설정 한 줄이면, 지원 중단 예정 호출이 테스트를 실패시키지 않고 코드베이스에 + 몰래 다시 들어오는 일은 결코 없습니다. + +## 요약 {#recap} + +* 2026-07-28 사양은 **루트**, 서버 주도 **샘플링**, 프로토콜 **로깅**을 지원 중단 예정으로 지정하고(모두 [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), **진행 상황**을 서버에서 클라이언트 방향으로 제한하며, **`ping`**을 제거합니다. +* 대체 수단 열이 다음 단계를 안내합니다. 샘플링과 루트는 **[다중 왕복 요청](handlers/multi-round-trip.md)**, 로깅은 **[로깅](handlers/logging.md)**, 진행 상황은 **[진행 상황](handlers/progress.md)**을 보세요. `ping`은 아무것도 필요 없습니다. +* 지원 중단 예정은 권고 사항입니다. 와이어 변경은 없고, 2026년 이전 세션에서는 모든 것이 계속 동작하며, 눈에 띄는 `MCPDeprecationWarning`이 나옵니다(`UserWarning`이므로 기본적으로 켜져 있습니다). +* 샘플링과 루트는 추가로 2026-07-28 세션에는 없는 백채널이 필요합니다. 최신 연결에서는 경고를 낸 뒤 예외를 발생시킵니다. +* `warnings.filterwarnings("ignore", category=MCPDeprecationWarning)`은 카테고리 전체를 끄고, pytest의 `"error::mcp.MCPDeprecationWarning"`은 이를 테스트 실패로 바꿉니다. +* 새 코드는 이 기능 중 어느 것에도 기반해서는 안 됩니다. + +이 문서의 다른 모든 페이지는 현재 API를 설명합니다. diff --git a/i18n/ko/pages/get-started/first-steps.md b/i18n/ko/pages/get-started/first-steps.md new file mode 100644 index 0000000000..dab57cc989 --- /dev/null +++ b/i18n/ko/pages/get-started/first-steps.md @@ -0,0 +1,143 @@ +--- +translation: + sections: [0d6c05bcbf836bf3, 59a7b14eeefc68c1, 7114d8d6daba203f, e8bbb56a98ba7bc9, 5138010f6159901c, f78da7c7c363d4c6, 220a939cab348686] + tool: 1 +--- +# 첫걸음 {#first-steps} + +**[랜딩 페이지](../index.md)**는 빠르게 진행합니다. 서버를 작성하고, 실행하고, 도구를 호출합니다. + +이 페이지는 천천히 진행합니다. 서버가 노출할 수 있는 세 가지를 모두 다루고, 그 과정에서 등장하는 모든 것에 이름을 붙입니다. + +## 호스트, 클라이언트, 서버 {#host-client-and-server} + +지금부터 모든 페이지에서 마주칠 세 단어입니다. + +* **호스트**는 LLM 애플리케이션입니다. Claude, IDE, 에이전트 런타임이 여기에 해당하며, 사용자가 대화하는 상대가 바로 호스트입니다. +* **클라이언트**는 호스트 안에 있으며 MCP로 통신합니다. 호스트는 연결된 서버마다 클라이언트를 하나씩 실행합니다. +* **서버**는 이 SDK로 만드는 것입니다. 서버는 클라이언트에 여러 가지를 노출하며, 모델과 직접 대화하는 일은 없습니다. + +직접 작성하는 것은 서버입니다. 호스트는 다른 누군가가 만든 제품입니다. SDK는 `Client`도 제공합니다. 서버를 테스트할 때 쓰게 되며, 이 페이지 뒷부분에서 다시 등장합니다. + +## 세 가지 프리미티브 {#the-three-primitives} + +서버가 노출하는 것은 정확히 세 종류입니다. 셋을 가르는 기준은 **누가 사용을 결정하는가**입니다. + +| 프리미티브 | 제어 주체 | 설명 | 예시 | +|---------------|-----------------|-----------------------------------------------------|------------------------------------| +| **도구** | 모델 | 모델이 어떤 동작을 수행하려고 호출하는 함수 | API 호출, 데이터베이스 쓰기 | +| **리소스** | 애플리케이션 | 호스트가 모델의 컨텍스트에 불러오는 데이터 | 파일 내용, API 응답 | +| **프롬프트** | 사용자 | 사용자가 이름으로 호출하는 재사용 가능한 메시지 템플릿 | 슬래시 명령, 메뉴 항목 | + +"제어 주체"가 이 구분의 핵심입니다. 도구는 **모델**이 호출하기로 결정했기 때문에 실행됩니다. 리소스는 **애플리케이션**이 모델에 필요하다고 판단했기 때문에 첨부됩니다. 프롬프트는 **사용자**가 골랐기 때문에 실행됩니다. + +!!! info + 웹 API를 만들어 본 적이 있다면 필요한 감각은 이미 대부분 갖추고 있습니다. **리소스**는 `GET`(데이터를 + 불러오고 아무것도 바꾸지 않음)이고 **도구**는 `POST`(작업을 수행하며 부작용이 있을 수 있음)입니다. + **프롬프트**는 HTTP에 대응하는 것이 없으며, 사용자가 이름으로 실행하는 저장된 쿼리에 더 가깝습니다. + +## 서버 하나에 세 가지 모두 {#one-server-all-three} + +```python title="server.py" hl_lines="6 12 18" +--8<-- "docs_src/first_steps/tutorial001.py" +``` + +평범한 함수 셋, 데코레이터 셋입니다. 각 데코레이터가 곧 등록의 전부입니다. + +* `@mcp.tool()`은 `add`를 **도구**로 만듭니다. +* `@mcp.resource("greeting://{name}")`은 `greeting`을 **리소스 템플릿**으로 만듭니다. URI의 `{name}`이 함수의 매개변수입니다. +* `@mcp.prompt()`는 `summarize`를 **프롬프트**로 만듭니다. 이 함수가 반환하는 문자열은 사용자 메시지가 됩니다. + +나머지(이름, 설명, 인자 스키마)는 모두 SDK가 함수 자체에서 읽어 냅니다. 함수 이름, 독스트링, 타입 힌트에서 가져오는 것입니다. 어느 것도 따로 선언하지 않았습니다. + +!!! tip + SDK의 두 부분은 임포트 경로도 둘입니다. `from mcp import Client`와 + `from mcp.server import MCPServer`입니다. `from mcp import MCPServer`는 없습니다. + +### 직접 해 보기 {#try-it} + +MCP Inspector로 실행하세요. + +```console +uv run mcp dev server.py +``` + +출력되는 URL을 여세요. Inspector에는 프리미티브마다 탭이 하나씩 있습니다. 순서대로 살펴보세요. + +**도구.** 항목은 `add` 하나이며, *Add two numbers.*라는 설명이 붙어 있습니다. 폼에는 필수 정수 필드가 `a`에 하나, `b`에 하나 있습니다. 값을 채워 호출하면 결과는 `3`입니다. Inspector는 `a: int, b: int`를 보고 이 폼을 만들었습니다. 다른 모든 클라이언트도 마찬가지입니다. + +**리소스.** *Resources* 목록은 비어 있습니다. `greeting`은 **Resource Templates** 아래에 있습니다. `greeting://{name}`에 매개변수가 있어서, 누군가 `name`을 제공하기 전까지는 나열할 단일 리소스가 없기 때문입니다. `World`를 넣고 읽어 보세요. + +```text +Hello, World! +``` + +**프롬프트.** 항목은 `summarize` 하나이며, 필수 인자는 `text` 하나입니다. 텍스트를 넣어 프롬프트를 가져오면 `role: user`와 렌더링된 문자열을 내용으로 하는 메시지 하나가 돌아옵니다. 프롬프트는 이것이 전부입니다. 메시지를 만드는 함수일 뿐입니다. + +Inspector는 서버를 **stdio**로 실행했습니다. stdio는 MCP 서버가 사용할 수 있는 트랜스포트 중 하나입니다. 아직 트랜스포트를 고를 필요는 없습니다. 그 내용은 **[서버 실행하기](../run/index.md)** 페이지에서 다룹니다. + +## 기능 {#capabilities} + +Inspector에서 탭 세 개를 보았습니다. Inspector가 세 개라는 것을 어떻게 알았는지 살펴보겠습니다. + +클라이언트가 연결하면 서버는 **기능**, 즉 어떤 부류의 요청에 응답할지를 선언합니다. 클라이언트는 이 선언을 보고 애초에 무엇을 요청할지 결정합니다. 이 선언을 작성한 적은 없습니다. `MCPServer`가 대신 선언합니다. + +직접 확인해 보세요. SDK의 `Client`는 서버 객체를 그대로 받아 **인메모리**로 연결합니다(서브프로세스도, 포트도 없습니다). + +```python +import asyncio + +from mcp import Client + +from server import mcp + + +async def main() -> None: + async with Client(mcp) as client: + print(client.server_capabilities.model_dump(exclude_none=True)) + + +asyncio.run(main()) +``` + +```text +{'prompts': {'list_changed': True}, 'resources': {'subscribe': True, 'list_changed': True}, 'tools': {'list_changed': True}} +``` + +이 딕셔너리가 서버가 선언한 **기능**입니다. 연결하는 모든 클라이언트가 가장 먼저 알게 되는 내용입니다. + +| 기능 | 클라이언트가 이제 호출할 수 있는 것 | +|-------------|------------------------------------------------------------| +| `tools` | `tools/list`, `tools/call` | +| `resources` | `resources/list`, `resources/templates/list`, `resources/read` | +| `prompts` | `prompts/list`, `prompts/get` | + +`MCPServer`는 세 프리미티브를 모두 제공하므로 셋 다 항상 선언됩니다. + +없는 것에도 주목하세요. `completions`(리소스 템플릿과 프롬프트의 인자 자동 완성)에는 직접 작성하는 핸들러가 필요한데, 이 서버에는 핸들러가 없으므로 해당 기능이 빠져 있고, 올바르게 동작하는 클라이언트라면 요청하지 않습니다. 선택 사항은 모두 이 규칙을 따릅니다. 등록하면 기능이 나타납니다. **[자동 완성](../servers/completions.md)** 페이지가 이를 보여 줍니다. + +!!! info + `Client(mcp)`는 이 문서의 모든 예제를 테스트하는 데 쓰이는 바로 그 인메모리 클라이언트이며, + 작성한 서버도 같은 방식으로 테스트하게 됩니다. 이를 다루는 페이지가 따로 있습니다. **[테스트](testing.md)**입니다. + +## 작성하지 않은 것 {#what-you-did-not-write} + +이 페이지를 되돌아보세요. 작성한 것은 작은 Python 함수 세 개입니다. 다음은 작성하지 **않았습니다**. + +* JSON Schema. `a: int, b: int`가 **곧** `add`의 스키마입니다. +* 요청 핸들러. `tools/list`, `resources/read`, `prompts/get`은 모두 대신 처리됩니다. +* 기능 선언. `MCPServer`가 대신 만들었습니다. +* 프로토콜 코드 단 한 줄. 버전 협상, JSON-RPC 프레이밍, 기능 교환은 모두 `mcp dev`와 `Client(mcp)` 안에서 일어났고, 눈에 보이지도 않았습니다. + +이 비율이야말로 SDK가 존재하는 이유입니다. + +## 요약 {#recap} + +* **호스트**는 LLM 앱이고, **클라이언트**는 그 안에서 MCP로 통신하는 부분이며, **서버**는 직접 만드는 것입니다. +* 도구는 **모델**이, 리소스는 **애플리케이션**이, 프롬프트는 **사용자**가 제어합니다. +* 프리미티브마다 데코레이터 하나면 됩니다. `@mcp.tool()`, `@mcp.resource(uri)`, `@mcp.prompt()`입니다. 이름, 설명, 스키마는 함수에서 가져옵니다. +* `{param}`이 들어간 URI는 리소스 **템플릿**을 만들며, 구체적인 리소스와는 따로 나열됩니다. +* 서버의 **기능**은 자동으로 선언되며, 클라이언트는 서버가 선언한 것만 요청합니다. +* `Client(mcp)`는 서버 객체에 인메모리로 연결합니다. 첫날부터 갖추는 테스트 하네스입니다. + +다음은 **[실제 호스트에 연결하기](real-host.md)**입니다. 이 서버를 Claude Desktop이나 IDE 안에서 실제로 돌려 봅니다. 그다음은 **[테스트](testing.md)**입니다. 페이지 하나, 인메모리 클라이언트 하나면 동작하는지 추측할 일이 없어집니다. 그 뒤로는 프리미티브마다 전용 페이지가 이어지며, 모델이 주도하는 프리미티브인 **[도구](../servers/tools.md)**부터 시작합니다. diff --git a/i18n/ko/pages/get-started/index.md b/i18n/ko/pages/get-started/index.md new file mode 100644 index 0000000000..d85a42d3f9 --- /dev/null +++ b/i18n/ko/pages/get-started/index.md @@ -0,0 +1,57 @@ +--- +translation: + sections: [ed4a756b4c53c585, 97e2fb315b7fe398, 4d04f1c6f4bf6c1d, 577d73078fc62baf] + tool: 1 +--- +# 시작하기 {#get-started} + +MCP가 처음이거나 이 SDK가 처음이라면 여기서 시작하세요. 이곳의 페이지는 아무것도 없는 상태에서 시작해, +테스트까지 마친 동작하는 서버를 완성하도록 안내합니다. [SDK 설치](installation.md), +[첫 번째 서버](first-steps.md) 만들기, [실제 호스트에 연결하기](real-host.md), 그리고 인메모리 클라이언트로 +[테스트하기](testing.md) 순서로 진행합니다. + +## 코드 실행하기 {#run-the-code} + +모든 코드 블록은 그대로 복사해서 바로 사용할 수 있습니다. 하나하나가 완전하게 동작하는 파일입니다. + +따라 하려면 코드 블록을 `server.py`에 붙여 넣고 MCP Inspector에서 여세요. + +```console +uv run mcp dev server.py +``` + +코드를 직접 작성(또는 복사)하고, 수정하고, 로컬에서 실행해 보기를 **강력히 권장합니다**. 평소 쓰는 편집기에서 직접 다뤄 봐야 핵심이 제대로 와닿습니다. 작성할 코드가 얼마나 적은지, 자동 완성은 어떤지, 실행하기도 전에 타입 검사가 실수를 잡아내는 모습까지 확인할 수 있습니다. + +## 추측할 필요 없는 예제 {#you-will-not-be-guessing} + +이 문서의 모든 예제는 SDK 저장소의 [`docs_src/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/docs_src) 아래에 있는 완전한 파일이며, 하나도 빠짐없이 SDK의 테스트 스위트가 **인메모리 클라이언트**를 통해 실행합니다. + +```python +import pytest +from mcp import Client + +from server import mcp + + +@pytest.mark.anyio +async def test_add() -> None: + async with Client(mcp) as client: + result = await client.call_tool("add", {"a": 1, "b": 2}) + assert result.structured_content == {"result": 3} +``` + +서브프로세스도, 포트도, 트랜스포트도 없습니다. `Client(mcp)`가 서버 객체에 직접 연결합니다. + +SDK 변경으로 이 문서의 예제가 하나라도 깨지면, 페이지에 문제가 드러나기 전에 CI가 먼저 실패합니다. 여기서 읽는 코드가 곧 실제로 실행되는 코드입니다. + +이 방식은 [테스트](testing.md)에서 직접 사용해 봅니다. 작성한 서버를 테스트하는 방법도 바로 이것입니다. + +## 다음 단계 {#where-to-go-next} + +서버를 실행하고 나면 나머지 문서는 강좌가 아니라 레퍼런스입니다. +모든 페이지가 독립적으로 읽히므로 필요한 곳으로 바로 이동하세요. + +* 서버가 노출하는 것(도구, 리소스, 프롬프트)은 **[서버](../servers/index.md)**에서 다룹니다. +* 등록한 함수 안에서 사용할 수 있는 것은 **[핸들러 내부](../handlers/index.md)**에서 다룹니다. +* 클라이언트 앞에 내놓는 방법(stdio, HTTP, 기존 FastAPI 앱)은 **[서버 실행하기](../run/index.md)**에서 다룹니다. +* 반대편, 즉 MCP 서버를 **사용하는** 애플리케이션을 만드는 방법은 **[클라이언트](../client/index.md)**에서 다룹니다. diff --git a/i18n/ko/pages/get-started/installation.md b/i18n/ko/pages/get-started/installation.md new file mode 100644 index 0000000000..023991873c --- /dev/null +++ b/i18n/ko/pages/get-started/installation.md @@ -0,0 +1,47 @@ +--- +translation: + sections: [6e2f9bab94d5ed36, 8cf653388f69e28b, 6fd9ea2f65de0df6] + tool: 1 +--- +# 설치 {#installation} + +Python SDK는 PyPI에 [`mcp`](https://pypi.org/project/mcp/)라는 이름으로 올라와 있습니다. **Python 3.10 이상**이 필요합니다. + +이 문서는 현재 안정 릴리스 계열인 **v2**를 설명합니다. + +=== "uv" + + ```bash + uv add "mcp[cli]" + ``` + +=== "pip" + + ```bash + pip install "mcp[cli]" + ``` + +!!! note "v1에서 옮겨 오는 경우" + v2는 호환성이 깨지는 변경이 포함된 메이저 버전이며, **[마이그레이션 가이드](../migration.md)**에서 + 그 변경을 하나도 빠짐없이 다룹니다. 작성한 **패키지**가 `mcp`에 의존하는데 아직 마이그레이션할 준비가 되지 않았다면, + 버전을 고정하지 않은 의존성 해석이 1.x 계열에 머무르도록 `<2` 상한을 유지하세요(예: `mcp>=1.28,<2`). + +## 설치되는 항목 {#what-gets-installed} + +SDK를 사용하는 데 이런 내용을 알 필요는 전혀 없지만, 각 의존성이 어떤 용도인지 궁금하다면 다음을 참고하세요. + +* `mcp-types`: 모든 프로토콜 타입(요청, 결과, 콘텐츠 블록)을 담은 별도 패키지로, SDK와 항상 같은 버전으로 맞춰 릴리스됩니다. `mcp`에 의존하는 코드는 이 패키지를 `mcp.types` 별칭을 통해 임포트합니다(이 문서에 나오는 모든 `from mcp.types import ...`가 그렇습니다). `mcp_types`를 직접 임포트하는 것은 SDK 없이 `mcp-types`만 설치하는 프로젝트에서만 하세요. +* [`anyio`](https://anyio.readthedocs.io/): 비동기 런타임입니다. SDK 전체가 anyio를 기반으로 작성되어 있으므로 `asyncio`와 `trio` 중 어느 쪽에서든 실행됩니다. +* [`pydantic`](https://docs.pydantic.dev/): 모든 `mcp.types` 모델이 이 위에 만들어져 있으며, 스키마 생성과 검증도 전부 담당합니다. +* [`httpx2`](https://pypi.org/project/httpx2/): Streamable HTTP와 SSE **클라이언트** 트랜스포트의 기반이 되는 HTTP 클라이언트로, server-sent events 지원이 내장되어 있습니다. +* [`starlette`](https://www.starlette.io/), [`uvicorn`](https://www.uvicorn.org/), [`sse-starlette`](https://pypi.org/project/sse-starlette/), [`python-multipart`](https://pypi.org/project/python-multipart/): HTTP **서버** 트랜스포트를 구성합니다. +* [`jsonschema`](https://pypi.org/project/jsonschema/): 도구의 구조화된 출력이 선언된 출력 스키마에 맞는지 검증합니다. +* [`pyjwt[crypto]`](https://pyjwt.readthedocs.io/): 인가에 쓰이는 OAuth 토큰 처리를 담당합니다. +* [`opentelemetry-api`](https://opentelemetry-python.readthedocs.io/): 가벼운 API만 들어 있으므로, OpenTelemetry SDK와 익스포터를 직접 설치하지 않는 한 SDK의 트레이싱 미들웨어에는 아무 비용도 들지 않습니다. +* [`typing-extensions`](https://typing-extensions.readthedocs.io/)와 [`typing-inspection`](https://pypi.org/project/typing-inspection/): Python 3.10에서 최신 타이핑 기능을 쓸 수 있게 해 줍니다. +* [`pywin32`](https://pypi.org/project/pywin32/): Windows 전용으로, `stdio` 하위 프로세스 관리에 사용됩니다. + +## 선택적 추가 기능 {#optional-extras} + +* `mcp[cli]`는 `mcp` 명령줄 도구(`mcp dev`, `mcp run`, `mcp install`)에 필요한 [`typer`](https://typer.tiangolo.com/)와 [`python-dotenv`](https://pypi.org/project/python-dotenv/)를 추가합니다. 개발하는 동안에는 있는 편이 좋지만, 배포된 서버에는 필요하지 않을 수도 있습니다. +* `mcp[rich]`는 서버 로그를 더 보기 좋게 만들어 주는 [`rich`](https://rich.readthedocs.io/)를 추가합니다. diff --git a/i18n/ko/pages/get-started/real-host.md b/i18n/ko/pages/get-started/real-host.md new file mode 100644 index 0000000000..7eca25370c --- /dev/null +++ b/i18n/ko/pages/get-started/real-host.md @@ -0,0 +1,184 @@ +--- +translation: + sections: [3c4f2f06b4e978b6, 22520eecae3d1961, f4e1709db18d635a, 2eb57992049671d9, 1ba83e9af37cc1b4, 4822586344b08d9e, 1c93afef72478992, b6b448f9eddd51dc, fe55370fd931815b] + tool: 1 +--- +# 실제 호스트에 연결하기 {#connect-to-a-real-host} + +**호스트**는 서버가 최종적으로 들어가 동작하는 애플리케이션입니다. Claude Desktop, Claude Code, IDE가 여기에 해당하며, 사용자가 대화하는 상대가 바로 호스트입니다. 호스트 안에서는 MCP **클라이언트**가 서버를 자식 프로세스로 실행하고, 그 프로세스의 stdin과 stdout을 통해 서버와 통신합니다. + +따라서 호스트에 연결하는 일은 단 하나의 동작으로 끝납니다. **서버를 시작하는 명령**을 호스트에 알려 주는 것입니다. 이 페이지에 나오는 모든 것(CLI 명령 두 개, JSON 파일 세 개)은 바로 그 명령을 넣어 두는 서로 다른 위치일 뿐입니다. + +## 서버 하나, 모든 호스트 {#one-server-every-host} + +```python title="server.py" hl_lines="3 33-34" +--8<-- "docs_src/real_host/tutorial001.py" +``` + +도구 두 개와 리소스 하나가 파일 하나에 들어 있습니다. 이 파일에서 아래의 모든 호스트에 중요한 점은 세 가지입니다. + +* 인자 없이 호출한 `mcp.run()`은 **stdio** 서버를 시작합니다. 블로킹 상태로 동작하며, stdin에서 프로토콜 메시지를 읽고 stdout에 씁니다. 이 페이지의 모든 호스트가 사용하는 트랜스포트가 바로 이것입니다. 호스트가 파일을 자식 프로세스로 시작하고 그 두 파이프를 소유하기 때문에, 연결은 언제나 "명령은 이것입니다"로 끝납니다. 포트를 고를 일이 없고, 포트에서 대기하는 것도 없습니다. +* `run()`은 `if __name__ == "__main__":` 아래에 있습니다. 아래에 나오는 모든 방법은 이 파일을 실행하는 대신 **임포트**하므로, 가드 없이 `run()`을 두면 무엇이든 이 모듈을 로드하는 순간 서버가 시작되어 버립니다. +* 서버 객체는 `mcp`라는 이름의 모듈 수준 전역 변수입니다. `mcp run`이 찾는 이름이 바로 이것입니다(`server`와 `app`도 동작합니다). 다른 이름을 쓴다면 `mcp run server.py:bookshop`처럼 명시적으로 지정합니다. + +이것이 이 페이지의 마지막 Python 코드입니다. 여기서부터는 전부 호스트 설정입니다. + +## 실행 명령 {#the-launch-command} + +아래의 모든 호스트에는 같은 명령을 사용합니다. + +```bash +uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py +``` + +모든 호스트에 명령 하나로 충분한 이유는 `uv run --with`가 그 자리에서 SDK를 새 환경에 설치해 주기 때문입니다. 어느 디렉터리에서든 동작하며, 프로젝트도 활성화할 가상 환경도 필요 없습니다. 이 점은 다른 어느 곳보다 여기서 중요합니다. 호스트는 셸이 아니라 **호스트 자신의** 작업 디렉터리에서, 거의 비어 있는 환경으로 서버를 실행하기 때문입니다. + +이 명령은 `mcp install`이 Claude Desktop 설정에 대신 써 주는 명령이기도 합니다(아래 참고). 따라서 직접 입력하는 내용과 도구가 생성하는 내용은, 도구가 덧붙이는 정확한 버전 고정만 빼면 일치합니다. + +!!! tip "호스트가 `uv`를 찾지 못할 때" + 호스트는 최소한의 `PATH`로 서버를 실행하므로 `uv`가 그 안에 없을 수 있습니다. 그냥 `uv`라고 + 쓴 부분을 `which uv`(macOS/Linux) 또는 `where uv`(Windows)로 얻은 절대 경로로 바꾸세요. + `mcp install`이 쓰는 것도 정확히 이것입니다. + +!!! note "이 페이지는 로컬 실행을 다룹니다" + 여기 나오는 모든 방법은 호스트가 있는 바로 그 머신에서 서버를 실행합니다. 호스트가 파일을 + stdio로 직접 실행하는 방식입니다. 개인용 도구나 머신 한 대에서 쓰는 도구라면 이것이 정확히 + 맞는 방법입니다. 파일을 **가지고 있지 않은** 사람들에게 서버를 제공하려면 명령이 아니라 + **URL**을 건네야 합니다. 같은 `mcp` 객체를 Streamable HTTP로 서비스하는 것입니다. + **[서버 실행하기](../run/index.md)**는 그 결정을 표 하나로 정리하고, + **[배포와 확장](../run/deploy.md)**은 거기서 실제 호스트 이름까지 가는 길을 안내합니다. + + 그리고 호스트란 MCP 클라이언트를 품은 애플리케이션에 지나지 않으므로, 직접 작성한 Python + 코드도 호스트 역할을 할 수 있습니다. **[클라이언트 트랜스포트](../client/transports.md)**에서는 + `stdio_client(...)`로 같은 파일을 서브프로세스로 실행하고, **[테스트](testing.md)**에서는 + 프로세스 없이 인메모리로 연결합니다. + +## Claude Desktop {#claude-desktop} + +SDK가 대신 설정해 줄 수 있는 유일한 호스트입니다. + +```bash +uv run mcp install server.py +``` + +이게 전부입니다. `mcp install`은 파일을 임포트해 서버 이름을 읽고, Claude Desktop의 설정 파일을 찾아 실행 명령을 써 넣습니다. 그 과정에서 경로를 절대 경로로 바꿔 주므로 직접 할 필요가 없습니다. + +감춰진 것은 아무것도 없습니다. 써 넣는 항목은 다음과 같습니다. + +```json +{ + "mcpServers": { + "Bookshop": { + "command": "/absolute/path/to/uv", + "args": [ + "run", + "--frozen", + "--with", + "mcp[cli]==2.0.0", + "mcp", + "run", + "/absolute/path/to/server.py" + ] + } + } +} +``` + +앞 절의 실행 명령에 세 가지가 더해진 것입니다. `uv`의 절대 경로, 근처에 있는 락파일을 `uv`가 다시 쓰지 않도록 하는 `--frozen`, 그리고 설치된 `mcp` 버전을 정확히 지정하는 버전 고정입니다. 이 항목은 `claude_desktop_config.json`에 들어가며, 파일 위치는 다음과 같습니다. + +* **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` +* **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` + +이 파일은 손으로 직접 써도 됩니다. `mcp install`은 그 과정에서 흔히 하는 실수(상대 경로)를 막기 위해 존재합니다. + +Claude Desktop을 창만 닫지 말고 완전히 종료한 뒤 다시 여세요. + +!!! warning + Claude Desktop의 설정 **디렉터리**가 아직 없으면 `mcp install`은 `Claude app not found` 오류로 + 실패합니다. Claude Desktop을 설치하고 한 번 실행하세요. 디렉터리는 그때 만들어집니다. + +!!! tip + Claude Desktop은 서버를 별도의 프로세스로 시작하므로 셸의 환경 변수는 거기에 없습니다. + `uv run mcp install server.py -v API_KEY=abc123` 명령(또는 `-f .env` 옵션)을 사용하면 환경 + 변수가 항목의 `env` 필드에 기록됩니다. `--name` 옵션은 항목 이름을 덮어쓰며, 기본값은 서버의 + `name`입니다. + +## Claude Code {#claude-code} + +편집할 파일은 없습니다. `claude` CLI로 서버를 등록하세요. `--` 뒤에 오는 모든 것이 실행 명령입니다. + +```bash +claude mcp add bookshop -- uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py +``` + +Claude Code 세션 안에서 `/mcp`를 실행해 `bookshop`이 연결되어 있고 도구가 나열되는지 확인하세요. + +## Cursor {#cursor} + +프로젝트 루트에 `.cursor/mcp.json` 파일을 만드세요. + +```json +{ + "mcpServers": { + "bookshop": { + "command": "uv", + "args": ["run", "--with", "mcp[cli]", "mcp", "run", "/absolute/path/to/server.py"] + } + } +} +``` + +Claude Desktop이 쓰는 것과 같은 `mcpServers` 키 아래에, 같은 `command`와 `args`가 들어갑니다. 서버는 Cursor의 MCP 설정에 두 도구와 함께 나타납니다. + +## VS Code {#vs-code} + +프로젝트 루트에 `.vscode/mcp.json` 파일을 만드세요. + +```json +{ + "servers": { + "bookshop": { + "type": "stdio", + "command": "uv", + "args": ["run", "--with", "mcp[cli]", "mcp", "run", "/absolute/path/to/server.py"] + } + } +} +``` + +Cursor의 파일과 다른 점은 두 가지이며, 정확히 그 두 가지뿐입니다. 감싸는 키가 `mcpServers`가 아니라 `servers`라는 점, 그리고 각 항목이 `type`을 선언한다는 점입니다. 신뢰 여부를 묻는 메시지를 확인한 뒤 명령 팔레트에서 **MCP: List Servers**를 실행하면 `bookshop`이 실행 중으로 표시됩니다. + +!!! note + VS Code 1.99 이상이 필요하고 **GitHub Copilot** 확장에 로그인되어 있어야 합니다(Copilot Free면 + 충분합니다). 또한 Copilot Chat은 **Agent** 모드여야 합니다. 다른 모드는 도구를 호출하지 않기 + 때문입니다. + +## 서버가 나타나지 않을 때 {#it-doesnt-show-up} + +호스트 설정을 건드리기 전에 실행 명령을 직접 실행해 보세요. + +```bash +uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py +``` + +아무것도 출력되지 않고, 명령이 반환되지도 않습니다. 이 침묵이 정상입니다. stdio 서버는 호스트가 stdin으로 먼저 말을 걸기를 기다리고 있습니다(멈추려면 `Ctrl-C`를 누르세요). 트레이스백이 뜨거나 즉시 종료된다면 그것이 진짜 버그이며, 이제 호스트 너머로 추측하는 대신 직접 읽을 수 있습니다. + +이 명령이 가만히 대기하는 상태가 되었다면, 남은 원인은 거의 항상 다음 세 가지 중 하나입니다. + +* **상대 경로.** 호스트는 등록할 때 있던 디렉터리가 아니라 **호스트 자신의** 작업 디렉터리에서 서버를 실행합니다. `/absolute/path/to/server.py`가 필요한 자리에 `server.py`를 쓰는 것이 단연 가장 흔한 실패 원인입니다. 호스트가 `uv`도 찾지 못한다면 그 경로 역시 절대 경로여야 합니다. +* **호스트가 아직 예전 설정으로 동작 중인 경우.** 호스트는 시작할 때 설정을 읽습니다. 특히 Claude Desktop은 창만 닫는 것이 아니라 **완전히 종료**한 뒤 다시 열어야 `claude_desktop_config.json`을 수정한 내용이 반영됩니다. +* **우회 구간 밖에서 무언가가 stdout에 도달한 경우.** stdio에서는 stdout이 **곧** 프로토콜입니다. SDK는 서버가 동작하는 동안 플러시된 엉뚱한 출력을 stderr로 돌려 보내지만, 그 전에 stdout으로 플러시된 출력(래퍼 스크립트의 echo, 버퍼링하지 않는 프로세스에서 임포트 시점에 실행된 `print()`)이나 인터프리터 종료 시점에 비워지는 버퍼링된 `print()`는 호스트에 손상된 메시지를 건네게 되고, 호스트는 연결을 끊어 버립니다. 기본 `logging` 설정으로 로그를 남기세요. 기본 설정의 stderr 핸들러는 레코드마다 플러시합니다. 사용자 정의 핸들러도 stdout을 피해야 합니다. 자세한 내용은 **[로깅](../handlers/logging.md)**에서 확인하세요. + +Claude Desktop은 서버마다 로그를 남깁니다. `mcp-server-.log`가 서버의 stderr이고, 연결을 기록하는 `mcp.log`가 그 옆에 있습니다. 위치는 macOS에서 `~/Library/Logs/Claude`, Windows에서 `%APPDATA%\Claude\logs`입니다. + +이 세 가지를 넘어서는 문제는 **[문제 해결](../troubleshooting.md)** 페이지를 참고하세요. + +## 요약 {#recap} + +* **호스트**(Claude Desktop, IDE)는 MCP 클라이언트를 실행하고, 이 클라이언트가 서버를 자식 프로세스로 띄워 stdio로 통신합니다. 연결한다는 것은 호스트에 실행 명령 하나를 알려 주는 것입니다. +* 그 명령은 `uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py`입니다. 활성화할 가상 환경이 필요 없고, 어느 디렉터리에서든 동작합니다. +* **Claude Desktop**은 `mcp install`이 대신 설정해 주는 유일한 호스트입니다. 바로 그 명령에 `uv`의 절대 경로, `--frozen`, 설치된 버전을 정확히 지정하는 버전 고정을 더해 `claude_desktop_config.json`에 써 주므로 직접 쓸 일이 전혀 없습니다. +* **Claude Code**는 `claude mcp add bookshop -- `입니다. **Cursor**는 `.cursor/mcp.json`의 `mcpServers` 아래입니다. **VS Code**는 `.vscode/mcp.json`의 `servers` 아래이며, 각 항목에 `type`을 둡니다. +* 어디서나 절대 경로를 쓰고, 설정을 수정한 뒤에는 호스트를 다시 시작하며, SDK 외에는 무엇도 stdout에 쓰지 못하게 하세요. + +이 페이지의 모든 호스트가 같은 파일에, 같은 명령으로 연결했습니다. 그 파일이 무엇을 **노출할 수 있는지**는 이 문서의 나머지가 다룹니다. **[도구](../servers/tools.md)**, **[리소스](../servers/resources.md)**, 그리고 stdio 외의 모든 트랜스포트는 **[서버 실행하기](../run/index.md)**에서 확인하세요. diff --git a/i18n/ko/pages/get-started/testing.md b/i18n/ko/pages/get-started/testing.md new file mode 100644 index 0000000000..859ce61e7f --- /dev/null +++ b/i18n/ko/pages/get-started/testing.md @@ -0,0 +1,114 @@ +--- +translation: + sections: ['4926721070127497', c52a1de2b6b32f40, 2e410b412c25f314, 627195f7159e24ef] + tool: 1 +--- +# 테스트 {#testing} + +Python SDK는 **인메모리 트랜스포트**를 갖춘 `Client` 클래스를 제공합니다. 서버 객체를 넘기면 그 서버에 직접 연결합니다. + +서브프로세스도 없습니다. 포트도 없습니다. 트랜스포트도 전혀 없습니다. FastAPI의 `TestClient`와 같은 발상입니다. + +## 기본 사용법 {#basic-usage} + +도구가 하나뿐인 간단한 서버가 있다고 해 보겠습니다. + +```python title="server.py" +--8<-- "docs_src/testing/tutorial001.py" +``` + +아래 테스트를 실행하려면 (개발용) 의존성 두 개가 더 필요합니다. + +=== "uv" + + ```bash + uv add --dev pytest inline-snapshot + ``` + +=== "pip" + + ```bash + pip install pytest inline-snapshot + ``` + +!!! info + 이 문서는 [`pytest`](https://docs.pytest.org/en/stable/)를 이미 알고 있다고 가정합니다. + + 아래 테스트에서는 [`inline-snapshot`](https://15r10nk.github.io/inline-snapshot/latest/)으로 + 결과 객체 전체를 한 줄에 검증합니다. 테스트의 출력을 코드에 보이는 `snapshot(...)` 리터럴 + 형태로 기록해 주는 라이브러리입니다. 쓰고 싶지 않다면 import를 빼고, 여느 테스트에서 하듯 + 관심 있는 필드(`result.content[0].text == "3"`)를 직접 검증하세요. + +이제 테스트 코드입니다. + +```python title="test_server.py" +import pytest +from inline_snapshot import snapshot +from mcp import Client +from mcp.types import CallToolResult, TextContent + +from server import mcp + + +@pytest.fixture +def anyio_backend(): # (1)! + return "asyncio" + + +@pytest.fixture +async def client(): # (2)! + async with Client(mcp, raise_exceptions=True) as c: + yield c + + +@pytest.mark.anyio +async def test_call_add_tool(client: Client): + result = await client.call_tool("add", {"a": 1, "b": 2}) + # Drop the server identity stamp in `_meta`; it is not what this test is about. + result.meta = None + assert result == snapshot( + CallToolResult( + content=[TextContent(type="text", text="3")], + structured_content={"result": 3}, + ) + ) +``` + +1. `trio`를 사용한다면 대신 `"trio"`를 반환하세요. 자세한 내용은 [anyio 문서](https://anyio.readthedocs.io/en/stable/testing.html#specifying-the-backends-to-run-on)를 참고하세요. +2. 이 픽스처는 연결을 마친 클라이언트를 yield합니다. `client`를 받는 모든 테스트는 같은 서버로 이어지는 새 인메모리 연결을 받습니다. + +다 됐습니다. 이제 더 많은 시나리오를 다루도록 테스트를 확장할 수 있습니다. + +## `raise_exceptions=True`를 쓰는 이유 {#why-raise_exceptionstrue} + +잘못될 수 있는 일은 서로 다른 두 가지이고, 이 플래그는 그중 하나에만 관여합니다. + +**작성한 도구** 안에서 발생한 예외는 프로토콜 실패가 아닙니다. `is_error=True`인 정상적인 결과가 +되고, 모델이 그 메시지를 읽습니다. `raise_exceptions`는 이 점을 바꾸지 않습니다. 플래그가 있든 +없든 `call_tool`은 똑같은 `is_error=True` 결과를 반환합니다. 이 주제를 통째로 다루는 페이지가 +따로 있습니다. **[오류 처리](../servers/handling-errors.md)**를 참고하세요. + +도구 본문 **바깥**에서 일어난 실패는 다릅니다. `Client(mcp)`가 제공하는 연결에서는 클라이언트가 +보기 전에 서버가 이 실패를 일반적인 `"Internal server error"`로 정제합니다. 예상치 못한 크래시의 +세부 내용을 원격 호출자에게 흘려서는 절대 안 됩니다. 하지만 테스트에서는 바로 이것이 원하지 +**않는** 동작이며, `raise_exceptions=True`가 바꾸는 것도 바로 이 부분입니다. 테스트는 정제된 +메시지 대신 실제 메시지를 보게 됩니다. + +테스트에서는 켜 두세요. 프로덕션 코드에서는 아무 의미가 없습니다. + +## 기본값은 인프로세스 연결 {#in-process-by-default} + +!!! note + `Client(mcp)`는 인프로세스로 연결하며 기본적으로 **세대 중립적**(era-neutral)입니다. 서버를 + 조사해 알맞은 프로토콜 경로를 고릅니다. 테스트가 레거시 전용 동작, 즉 샘플링이나 + 엘리시테이션(elicitation) 푸시, `message_handler` 같은 것을 검증한다면 `mode="legacy"`로 + 고정하고, 그 경우에는 `raise_exceptions=True`를 빼세요. 레거시 연결은 애초에 정제를 하지 + 않으며, 이 플래그는 실패를 테스트가 아니라 서버 태스크 안에서 다시 발생시키기 때문입니다. + +이 문서의 예제가 실제로 동작한다고 약속할 수 있는 것도 바로 그 한 줄 덕분입니다. 모든 예제 +파일은 SDK 자체의 테스트 스위트에서 실행되며, 거의 전부가 정확히 이 클라이언트를 거칩니다. +SDK가 스스로를 검증하는 데 쓰는 바로 그 도구를 사용하고 있는 것입니다. + +이제 동작하고 테스트까지 거친 서버가 생겼습니다. 이 서버를 실제 애플리케이션(Claude Desktop, +IDE)에 넣는 방법은 **[실제 호스트에 연결하기](real-host.md)**에서, 그 밖에 서버를 구동하는 모든 +방법은 **[서버 실행하기](../run/index.md)**에서 다룹니다. diff --git a/i18n/ko/pages/handlers/context.md b/i18n/ko/pages/handlers/context.md new file mode 100644 index 0000000000..4e936a81ee --- /dev/null +++ b/i18n/ko/pages/handlers/context.md @@ -0,0 +1,128 @@ +--- +translation: + sections: [b50152f05c81e786, b302059b22fb7cb4, 85682a1bf561243a, 53fc48838eb6837a, b24190e0842786ec, 85f93e150fc9b240] + tool: 1 +--- +# Context {#the-context} + +도구의 인자는 모델이 채웁니다. 그 밖의 모든 것(지금 처리 중인 요청, 도구가 속한 서버, 클라이언트에 되돌려 말을 건넬 수단)은 단 하나의 객체, **`Context`**에서 옵니다. + +직접 생성하지도, 설정하지도 않습니다. 달라고 하기만 하면 됩니다. + +## Context 받기 {#ask-for-it} + +아무 도구에나 `Context`로 어노테이션한 매개변수를 추가하세요. + +```python title="server.py" hl_lines="2 8" +--8<-- "docs_src/context/tutorial001.py" +``` + +* SDK는 요청마다 새 `Context`를 만들어 전달합니다. +* 매개변수 **이름은 중요하지 않습니다**. `ctx`, `context`, `c` 무엇이든 SDK는 어노테이션으로 찾아냅니다. +* 리소스와 프롬프트도 같은 방식으로 선언할 수 있습니다. +* `ctx.request_id`는 함수가 바로 지금 처리하고 있는 요청의 ID입니다. + +!!! info + FastAPI를 써 봤다면 익숙한 방식입니다. 프레임워크 고유의 타입(FastAPI에서는 `Request`, 여기서는 `Context`)으로 매개변수를 선언하면 프레임워크가 값을 채워 줍니다. 등록할 것도, 설정할 것도 없습니다. 타입 어노테이션이 메커니즘의 전부입니다. + +### 모델에게 보이지 않는 매개변수 {#invisible-to-the-model} + +꼭 새겨 둘 부분입니다. 다음은 `tools/list`가 보고하는 `search_books`의 입력 스키마입니다. + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"} + }, + "required": ["query"], + "title": "search_booksArguments" +} +``` + +속성은 하나뿐입니다. `ctx`는 인자가 아닙니다. 스키마에 나타나지 않고, 모델은 그 존재를 전혀 듣지 못하며, 어떤 클라이언트도 값을 채울 수 없습니다. 개발자와 SDK 사이의 약속일 뿐, 와이어 위에서는 보이지 않습니다. + +### 직접 해 보기 {#try-it} + +MCP Inspector로 서버를 실행하세요. + +```console +uv run mcp dev server.py +``` + +`search_books` 폼에는 `query` 필드 하나만 있습니다. `dune`으로 호출해 보세요. + +```text +[request 3] Found 3 books matching 'dune'. +``` + +숫자는 이 호출이 우연히 몇 번째 요청이었는지에 따라 정해집니다. 도구를 다시 호출하면 숫자가 바뀝니다. 요청마다 고유한 `Context`를 받기 때문입니다. + +## Context가 제공하는 것 {#what-it-gives-you} + +주입되는 객체는 작습니다. `request_id` 외에 다음이 있습니다. + +* `await ctx.read_resource(uri)`: 도구 안에서 서버 **자신의** 리소스를 읽습니다. 다음 절에서 다룹니다. +* `await ctx.report_progress(progress, total, message)`: 오래 걸리는 호출 중에 호출자에게 진행 상황을 스트리밍합니다. 자세한 내용은 **[진행 상황](progress.md)**에서 확인하세요. +* `await ctx.elicit(message, schema)`와 `await ctx.elicit_url(...)`: 도구를 잠시 멈추고 사용자에게 질문합니다. **[엘리시테이션(elicitation)](elicitation.md)**에서 다룹니다. +* `ctx.session`: 이 클라이언트와 나누는 대화의 서버 쪽 끝입니다. 클라이언트로 보내는 알림이 여기에 있으며, 마지막 절에서 사용합니다. +* `ctx.headers`: 트랜스포트가 실어 온 요청 헤더이며, stdio에서는 `None`입니다. 사용자 정의 헤더는 `(ctx.headers or {}).get("x-...")`로 읽습니다. 헤더는 클라이언트가 제공하는 입력이므로 로캘이나 기능 플래그에는 괜찮지만, 신원으로는 절대 쓰면 안 됩니다. +* `ctx.request_context`: 요청별 원시 레코드입니다. 주로 찾게 될 필드는 `lifespan_context`로, 시작 코드가 yield한 객체입니다(**[Lifespan](lifespan.md)** 참고). + +로깅은 일부러 이 목록에 넣지 않았습니다. 서버는 다른 Python 프로그램과 마찬가지로 Python의 `logging` 모듈로 로그를 남깁니다. 그 이유는 짧은 페이지 **[로깅](logging.md)**에서 설명합니다. + +!!! tip + 주입은 등록한 함수에만 일어납니다. 도구가 호출하는 헬퍼 함수는 자체 `Context`를 받지 않으므로 `ctx`를 일반 인자로 넘겨주세요. 다른 곳에서 가져다 쓸 수 있는 전역 "현재 컨텍스트" 같은 것은 없습니다. + +## 서버 자신의 리소스 읽기 {#read-your-own-resources} + +서버의 리소스는 클라이언트만을 위한 것이 아닙니다. 도구도 읽을 수 있습니다. + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/context/tutorial002.py" +``` + +`ctx.read_resource`는 `resources/read`를 처리하는 것과 같은 레지스트리를 통해 URI를 해석하므로, 도구는 클라이언트가 받는 것과 똑같은 결과를 받습니다. 콘텐츠 블록마다 하나씩 담긴 `ReadResourceContents`의 이터러블입니다. 이 URI에는 하나가 있습니다. + +```python +contents.content # 'fiction, non-fiction, poetry' +contents.mime_type # 'text/plain' +``` + +* `content`는 `genres()`가 반환한 값 그대로입니다. 진실의 원천은 하나입니다. 클라이언트는 리소스를 둘러보고, 도구는 리소스를 사용하며, 누구도 문자열을 복사하지 않습니다. +* `describe_catalog`의 유일한 매개변수는 `Context`이므로 입력 스키마에는 **속성이 아예 없습니다**. 모델이 호출할 때 넘기는 인자는 `{}`입니다. + +## 목록이 바뀌었음을 클라이언트에 알리기 {#tell-the-client-the-list-changed} + +서버가 제공하는 것은 임포트 시점에 고정되지 않습니다. 런타임에 도구를 등록한 다음 클라이언트에 알리세요. + +```python title="server.py" hl_lines="15-16" +--8<-- "docs_src/context/tutorial003.py" +``` + +* `mcp.add_tool(recommend_book)`은 평범한 함수를 도구로 등록합니다. 이름, 설명, 스키마는 `@mcp.tool()`을 썼을 때와 똑같이 도출됩니다. +* `await ctx.session.send_tool_list_changed()`는 `notifications/tools/list_changed`를 보냅니다. 이를 받은 클라이언트는 `tools/list`를 다시 호출하고 `recommend_book`을 보게 됩니다. + +형제 메서드로는 `send_resource_list_changed()`, `send_prompt_list_changed()`, 그리고 특정 리소스 하나의 변경을 알리는 `send_resource_updated(uri)`가 있습니다. + +2026-07-28 연결에서 클라이언트는 직접 연 `subscriptions/listen` 스트림에서만 변경 알림을 받으므로, 위의 `send_*` 메서드는 그 스트림에 닿지 않습니다. `Context`의 발행 메서드는 구독 중인 모든 스트림에 한 번에 전달합니다. `await ctx.notify_tools_changed()`, `await ctx.notify_prompts_changed()`, `await ctx.notify_resources_changed()`, `await ctx.notify_resource_updated(uri)`입니다. 여러 복제본으로 확장하는 방법까지 포함한 자세한 내용은 **[구독](subscriptions.md)**에서 확인하세요. + +!!! check + 누군가 `enable_recommendations`를 실행하기 전까지는 약속한 도구가 존재하지 않습니다. 그래도 호출하면 모델이 읽을 수 있는 오류가 결과로 돌아옵니다. + + ```text + Unknown tool: recommend_book + ``` + + `enable_recommendations`를 실행하면 똑같은 호출이 성공합니다. 도구 목록은 진짜로 동적입니다. `tools/list`는 **바로 지금** 등록되어 있는 것을 반영합니다. + +## 요약 {#recap} + +* (도구, 리소스, 프롬프트에서) 매개변수에 `Context` 어노테이션을 달면 SDK가 주입합니다. 이름은 마음대로 정하면 됩니다. +* 모델에게는 보이지 않습니다. 입력 스키마에는 언제나 실제 인자만 들어갑니다. +* `ctx.request_id`는 요청을 식별하고, `ctx.request_context.lifespan_context`는 시작 코드가 yield한 객체입니다. +* `await ctx.read_resource(uri)`로 도구가 서버 자신의 리소스를 읽을 수 있습니다. +* `ctx.session`은 클라이언트로 되돌아가는 채널입니다. `send_tool_list_changed()`와 형제 메서드는 바뀐 목록을 다시 가져오라고 클라이언트에 알립니다. +* 진행 상황 보고와 엘리시테이션도 `Context`에서 시작하며, 각각 별도 페이지가 있습니다. + +모델은 전혀 보지 못하고 직접 작성한 함수가 채우는 매개변수는 **[의존성](dependencies.md)**입니다. diff --git a/i18n/ko/pages/handlers/dependencies.md b/i18n/ko/pages/handlers/dependencies.md new file mode 100644 index 0000000000..f06da6454f --- /dev/null +++ b/i18n/ko/pages/handlers/dependencies.md @@ -0,0 +1,161 @@ +--- +translation: + sections: [b0389403e98d25ad, e2cf58b43b285e86, a363e1a38e1a5971, 6cfac078feb18013, b4535bd61df337e6, e97ed44207f929fd] + tool: 1 +--- +# 의존성 {#dependencies} + +도구의 인자는 모델이 제공합니다. 하지만 모델에게서 와서는 안 되는 값도 있습니다. 기록에서 조회한 가격, 사람만이 줄 수 있는 확인, 모델이 지어내면 틀릴 수 있는 모든 값이 여기에 해당합니다. + +**의존성**은 직접 작성한 함수가 채우는 매개변수입니다. 매개변수에 어노테이션을 달고 함수를 지정하면, 도구가 실행되기 전에 SDK가 그 함수를 호출합니다. + +## 선언하기 {#declare-one} + +매개변수의 타입을 `Annotated[...]`로 감싸고 `Resolve(fn)`을 추가하세요. + +```python title="server.py" hl_lines="18-19 23" +--8<-- "docs_src/dependencies/tutorial001.py" +``` + +* `check_stock`은 **리졸버**입니다. SDK가 `reserve_book`보다 먼저 실행하는 평범한 함수이며, 반환값이 `stock` 인자가 됩니다. +* 리졸버의 `title` 매개변수는 도구 자신의 `title` 인자이며, **이름으로** 매칭됩니다. 리졸버는 도구 본문이 보게 될 검증된 값과 정확히 같은 값을 봅니다. +* 도구 본문은 이미 존재하는 `Stock`에서 시작합니다. 도구 안에 조회 코드도 없고, "값이 없으면 어떻게 하나" 같은 사전 처리도 없습니다. + +!!! info + FastAPI를 써 봤다면 이것은 `Depends`와 같습니다. 방식도 같고 이유도 같습니다. 함수가 필요한 것을 + 선언하면 프레임워크가 공급하고, 연결은 타입 어노테이션 안에 담깁니다. + +### 모델에게는 보이지 않음 {#invisible-to-the-model} + +다음은 `tools/list`가 `reserve_book`에 대해 보고하는 입력 스키마입니다. + +```json +{ + "type": "object", + "properties": { + "title": {"title": "Title", "type": "string"} + }, + "required": ["title"], + "title": "reserve_bookArguments" +} +``` + +속성이 하나뿐입니다. **[Context](context.md)**의 `Context`와 마찬가지로, 리졸브된 매개변수는 작성자와 SDK 사이의 계약입니다. `stock`은 스키마에 없고, 모델은 이 매개변수를 전혀 알지 못하며, 그런데도 `stock` 값을 보내는 클라이언트가 있다면 그 값은 무시됩니다. 도구가 받을 수 있는 값은 리졸버의 값뿐입니다. + +바로 이 마지막 부분이 핵심입니다. 모델이 제공할 수 없는 매개변수는 모델이 틀릴 수 없는 매개변수입니다. + +### 직접 해 보기 {#try-it} + +MCP Inspector로 서버를 실행하세요. + +```console +uv run mcp dev server.py +``` + +`reserve_book` 폼에는 `title` 필드 하나만 있습니다. `stock`은 어디에도 없습니다. `Dune`으로 호출해 보세요. + +```text +Reserved 'Dune' (6 copies left). +``` + +도구 본문은 아무것도 조회하지 않았습니다. `check_stock`이 먼저 실행되었고, 반환한 `Stock`이 인자로 도착했습니다. `Neuromancer`로 시도하면 같은 리졸버가 도구에 0을 건넵니다. + +!!! tip + 도구 본문에서 그냥 `check_stock(title)`을 호출해도 됩니다. 값이 헬퍼 호출 이상의 대접을 받을 만할 때 + 의존성으로 선언하세요. 재고가 필요한 모든 도구가 같은 매개변수를 선언하고, 몇 곳에서 선언하든 SDK는 + 호출당 최대 한 번만 리졸버를 실행합니다. 다음 절에서 나머지를 다룹니다. 서로 의존하는 리졸버, 그리고 + 사용자에게 묻는 리졸버입니다. + +## 의존성의 의존성 {#dependencies-of-dependencies} + +리졸버도 같은 어노테이션으로 자신의 의존성을 선언할 수 있습니다. + +```python title="server.py" hl_lines="22 29-30" +--8<-- "docs_src/dependencies/tutorial002.py" +``` + +* `estimate_delivery`는 `check_stock`에 의존합니다. SDK는 그래프를 순서대로 실행합니다. 재고가 먼저, 그다음 배송 예상, 그다음 도구입니다. +* `stock`과 `delivery` 모두 결국 `check_stock`이 필요하지만, 이 리졸버는 **호출당 한 번** 실행됩니다. 재고 조회 한 번에 소비자 둘입니다. +* 등록할 것은 아무것도 없습니다. 어노테이션 자체가 **곧** 그래프입니다. + +!!! check + 호출당 한 번이라는 말을 그냥 믿지 마세요. `check_stock`에 `print`를 넣고 Inspector에서 `order_book`을 + 호출해 보세요. 호출마다 한 줄이 찍힙니다. 소비자는 둘, 조회는 한 번입니다. + +SDK는 도구가 호출될 때가 아니라 등록될 때 그래프를 분석합니다. 분류할 수 없는 매개변수(`Context`도 아니고, `Resolve(...)`도 아니고, 도구 인자의 이름도 아닌 경우)와 리졸버의 순환은 모두 시작 시점에 `InvalidSignature`를 발생시킵니다. 서버는 클라이언트가 연결하기도 전에 실패하며, 문제가 된 매개변수나 리졸버의 이름이 오류에 표시됩니다. + +리졸버의 매개변수는 도구의 매개변수와 똑같이 리졸브됩니다. 또 다른 `Resolve(...)`, 이름으로 매칭되는 도구 자신의 인자, 또는 `Context`(`ctx.headers`, lifespan 객체 등 전부)입니다. + +!!! warning + HTTP 트랜스포트에서는 `Context`에 `ctx.headers`가 포함됩니다. 헤더는 여느 도구 인자와 마찬가지로 + **클라이언트가 제공한 입력**입니다. 로캘이나 기능 플래그로는 괜찮지만, 신원으로는 절대 안 됩니다. + 호출자가 누구인지는 누구나 설정할 수 있는 헤더가 아니라 인가 계층(**[인가](../run/authorization.md)**)에서 나옵니다. + +!!! tip + **호출당 한 번**은 말 그대로입니다. 다음 `tools/call`은 `check_stock`을 다시 실행합니다. 요청보다 + 오래 살아야 하는 리소스(데이터베이스 풀, HTTP 클라이언트)는 **[Lifespan](lifespan.md)**에 속하며, + 리졸버는 `ctx.request_context.lifespan_context`를 통해 접근할 수 있습니다. + +## 꼭 필요할 때만 묻기 {#ask-when-you-must} + +리졸버가 답을 꼭 알아야 하는 것은 아닙니다. `Elicit(message, Model)`을 반환하면 SDK가 사용자에게 묻습니다. **[엘리시테이션(elicitation)](elicitation.md)** 메커니즘을 대신 실행해 주는 셈입니다. + +```python title="server.py" hl_lines="26-32 39" +--8<-- "docs_src/dependencies/tutorial003.py" +``` + +* 재고 있음: `confirm_backorder`는 `Backorder`를 바로 반환합니다. **질문도 없고, 왕복도 없습니다.** 사용자의 답이 중요할 때만 사용자를 방해합니다. +* 재고 없음: SDK가 엘리시테이션을 보내고, 답을 `Backorder`에 맞춰 검증한 뒤 주입합니다. 리졸버는 프로토콜을 전혀 건드리지 않습니다. +* 도구는 `backorder.confirm`을 여느 인자처럼 읽습니다. **아니요**라고 답하는 것도 여전히 답입니다. 엘리시테이션은 `confirm=False`로 수락되고, 도구가 실행되며, 주문은 들어가지 않습니다. 묻는 일이 도구 본문의 배관 코드가 아니라 전제 조건이 되었습니다. + +그렇다면 사용자가 아예 답하지 않으면, 즉 질문을 거절하거나 취소하면 어떻게 됩니까? + +!!! check + `Neuromancer`로 `order_book`을 실행하고 질문을 거절해 보세요. 어노테이션을 + `Annotated[Backorder, Resolve(...)]`로 작성한 경우 도구 본문은 실행되지 않으며, 호출은 모델이 + 읽을 수 있는 오류 결과와 함께 실패합니다. + + ```text + Error executing tool order_book: Resolver for parameter 'backorder' could not resolve: elicitation was decline + ``` + +전제 조건으로서는 이것이 올바른 기본 동작입니다. 답이 없으면 주문도 없습니다. 거절이 도구가 직접 처리하고 싶은 결과라면(예약 주문은 건너뛰되 다른 책을 추천하는 식으로) 대신 `ElicitationResult[Backorder]`로 어노테이션하세요. 그러면 도구가 수락/거절/취소 결과 전체를 받아 분기할 수 있습니다. **[엘리시테이션](elicitation.md)**에서 이 형태와 함께 묻기에 관한 나머지 모든 것, 즉 스키마 규칙, 세 가지 답, 대화의 클라이언트 쪽을 보여 줍니다. + +!!! info + 프레임워크는 협상된 프로토콜 버전에 따라 질문의 전송 방식을 고릅니다. 위 코드는 양쪽 모두에서 + 동일합니다. **2026-07-28** 및 그 이후 버전에서는 질문이 다중 왕복 `tools/call` 안에 실려 갑니다. + 서버가 질문을 반환하고, 클라이언트의 `elicitation_callback`이 답하며, `Client`가 호출을 대신 + 재시도합니다(**[다중 왕복 요청](multi-round-trip.md)**). **2025-11-25** 및 그 이전 버전에서는 호출 + 도중에 보내는 동기식 엘리시테이션 요청입니다. 각 질문은 호출당 정확히 한 번만 물어봅니다. 이는 + 리졸버가 아니라 질문에 대한 보장입니다. 다중 왕복 형태에서는 질문 후 호출이 재개될 때마다 어떤 + 리졸버든 다시 실행될 수 있으므로, `return Elicit(...)` 앞의 코드는 그런 라운드마다 실행됩니다. + 이때 기록된 답이 반복된 질문을 충족하므로 사용자에게 다시 묻지 않습니다. 기록된 답은 리졸버가 물을 + 때만 참조됩니다. `check_stock`처럼 묻지 **않고** 답하는 리졸버는 항상 스스로 계산한 값을 + 공급합니다. 각 답은 해당 질문에 다시 매칭되므로, 엘리시테이션을 하는 리졸버는 도구의 인자와 이전 + 답으로부터 질문을 결정적으로 도출해야 합니다. 호출마다 생성되는 값(`default_factory` ID, 타임스탬프)은 + 라운드마다 다시 만들어지므로, 답이 결합되어야 할 질문에 나타나서는 안 됩니다. 이런 변동성 데이터로 + 만든 질문은 기록된 모든 답을 낡은 것처럼 보이게 하므로, 클라이언트의 라운드 제한이 호출을 끝낼 + 때까지 서버가 라운드마다 다시 묻게 됩니다. + +## 사용자가 아닌 클라이언트에게 묻기 {#ask-the-client-not-the-user} + +엘리시테이션은 리졸버가 할 수 있는 세 가지 질문 중 하나이며, 다중 왕복 흐름은 그 외의 질문을 허용하지 않습니다. 나머지 둘은 사용자가 아니라 **클라이언트**에게 갑니다. 클라이언트를 통해 LLM 호출을 실행하려면(`sampling/createMessage` 요청) `Sample(...)`을, 클라이언트의 현재 루트를 가져오려면 `ListRoots()`를 반환하세요. 둘 다 수락/거절 결과가 없으므로, 소비자는 결과 타입을 직접 어노테이션합니다. `CreateMessageResult`(요청에 `tools`나 `tool_choice`가 있으면 `CreateMessageResultWithTools`) 또는 `ListRootsResult`입니다. + +```python title="server.py" hl_lines="10-15 21" +--8<-- "docs_src/dependencies/tutorial004.py" +``` + +* 프레임워크는 이들을 `Elicit`과 똑같이 라우팅합니다. **2026-07-28**에서는 다중 왕복 `tools/call` 안에서, **2025-11-25**에서는 독립적인 서버->클라이언트 요청을 통해서입니다. 선언되지 않은 기능은 `-32021` 프로토콜 오류로 호출을 거부합니다(`sampling`, `roots`, 폼 모드 `elicitation`, 요청에 `tools`나 `tool_choice`가 있으면 `sampling.tools`). +* 위 정보 상자에서 질문에 관해 말한 모든 내용이 그대로 적용됩니다. `Sample` 요청은 정확한 렌더링으로 기록된 결과와 매칭되므로, 도구의 인자와 이전 답으로부터 결정적으로 만드세요. 그러면 클라이언트는 LLM 호출 비용을 라운드마다가 아니라 도구 호출당 한 번만 냅니다. 기록된 결과는 호출이 끝날 때까지 `request_state`에 실려 다니므로, 매우 큰 컴플리션은 남은 모든 왕복을 더 무겁게 만듭니다. +* 독립적인 샘플링 및 루트 **기능**은 2026-07-28에서 지원 중단 예정(deprecated)입니다(SEP-2577). 클라이언트의 모델이 필요한 새 서버는 이 경로를 통해 묻고, 그렇지 않은 서버는 LLM 제공자와 직접 통합해야 합니다. `"none"` 이외의 `include_context` 값 자체도 지원 중단 예정이므로 피하세요. + +## 요약 {#recap} + +* 도구 매개변수에 `Annotated[T, Resolve(fn)]`을 붙이면 SDK가 `fn`을 실행하고 반환값을 주입합니다. +* 리졸브된 매개변수는 모델에게 보이지 않으며 클라이언트가 제공할 수 없습니다. 모델이 지어내서는 안 되는 값(가격, 신원, 권한)은 여기에 속합니다. +* 리졸버의 매개변수도 같은 방식으로 리졸브됩니다. `Context`, 또 다른 `Resolve(...)`, 또는 이름으로 매칭되는 도구 인자입니다. 그래프는 소비자가 몇이든 각 리졸버를 라운드당 최대 한 번 실행합니다. 각 질문은 정확히 한 번만 물어보며, 질문 후 호출이 재개되면 어떤 리졸버든 다시 실행될 수 있습니다. +* 잘못된 그래프는 호출 도중이 아니라 등록 시점에 `InvalidSignature`로 실패합니다. +* 사용자에게 물으려면 `Elicit(message, Model)`을 반환하되, 꼭 필요할 때만 하세요. 감싸지 않은 어노테이션은 거절 시 중단되고, `ElicitationResult[T]`는 도구가 분기할 수 있게 해 줍니다. +* 클라이언트에게 LLM 컴플리션이나 루트 목록을 요청하려면 `Sample(...)`이나 `ListRoots()`를 반환하세요. 결과가 그대로 주입됩니다. + +서버가 시작 시 한 번 구축하는 상태, 그리고 핸들러가 그 상태에 접근하는 방법은 **[Lifespan](lifespan.md)** 페이지에서 다룹니다. diff --git a/i18n/ko/pages/handlers/elicitation.md b/i18n/ko/pages/handlers/elicitation.md new file mode 100644 index 0000000000..8fa7053142 --- /dev/null +++ b/i18n/ko/pages/handlers/elicitation.md @@ -0,0 +1,191 @@ +--- +translation: + sections: [335ca2a0b266f003, d1ad562d3fe87bc0, 0bb1396c86daeba4, d1cb1235bb9ee267, 833179c09d239c83, e5d6dec2d2e655e8] + tool: 1 +--- +# 엘리시테이션 {#elicitation} + +작업을 절반쯤 진행하다가 답 하나가 모자란 도구라고 해서 실패해야 하는 것은 아닙니다. + +**엘리시테이션**(elicitation)을 사용하면 도구가 물어볼 수 있습니다. 도구 호출 도중에 사용자는 질문을 받고, 사용자의 답은 같은 함수 호출 안으로 돌아옵니다. + +두 가지 모드가 있습니다. + +* **폼 모드**: 값(확인, 날짜, 수량)이 필요한 경우입니다. 필드를 기술하면 클라이언트가 폼을 렌더링합니다. +* **URL 모드**: 사용자가 다른 곳(OAuth 동의 화면, 결제 페이지)으로 가야 하는 경우입니다. 사용자가 그곳에서 하는 일은 프로토콜을 전혀 거치지 않습니다. + +그리고 물어보는 방법도 두 가지입니다. 먼저 손이 가야 할 것은 **리졸버**입니다. 질문을 파라미터에 걸어 두면 SDK가 대신 물어봅니다. 어떤 연결에서든, 클라이언트가 어느 시대의 프로토콜을 쓰든 상관없습니다. 직접적인 방법인 `await ctx.elicit(...)`은 **서버**가 **클라이언트**에게 보내는 요청인데, 이 채널은 레거시 연결(사양 버전 2025-11-25 이하)을 쓰는 클라이언트에게만 존재합니다. 두 방법 모두 이 페이지에서 다루며, 리졸버부터 시작하세요. + +## 리졸버로 물어보기 {#ask-with-a-resolver} + +도구 전체의 실행을 좌우하는 질문("정말 실행할까요?", "일치하는 계정 세 개 중 어느 것인가요?")은 도구 본문에서 꺼내 **리졸버**로 옮길 수 있으며, 그러면 프레임워크가 대신 물어봅니다. + +`Annotated[T, Resolve(fn)]`로 어노테이션한 파라미터는 도구 본문보다 먼저 `fn`을 실행해 채워집니다. 리졸버는 값을 이미 알고 있으면 그대로 반환하고, 프레임워크가 물어보게 하려면 `Elicit(...)`을 반환합니다. + +```python title="server.py" hl_lines="24-30 35-36" +--8<-- "docs_src/elicitation/tutorial004.py" +``` + +* `confirm_delete`는 도구 자신의 `path` 인자를 이름으로 읽고 폴더 내용을 나열하며, **꼭 필요할 때만 사용자에게 묻습니다**. 빈 폴더라면 클라이언트와 왕복할 필요 없이 `Confirm(ok=True)` 값으로 바로 결정됩니다. +* `delete_folder`는 `ElicitationResult[Confirm]`으로 어노테이션하므로 프레임워크가 결과 전체를 주입하고, 도구는 `match`로 모든 경우를 처리합니다. 수락 후 확인, 수락했지만 유지(`ok=False`), 거절, 취소입니다. +* `confirm` 파라미터는 도구의 입력 스키마에 전혀 나타나지 않습니다. `path`는 클라이언트가, `confirm`은 리졸버가 제공합니다. + +도구가 분기할 필요가 없다면 대신 감싸지 않은 모델(`Annotated[Confirm, Resolve(confirm_delete)]`)로 어노테이션하세요. 수락하면 도구가 모델을 받고, 거절이나 취소면 호출이 오류와 함께 중단됩니다. + +리졸버는 **모든** 연결에서 동작합니다. 레거시 연결을 쓰는 클라이언트에게는 SDK가 질문을 직접 보내고, **2026-07-28** 연결에서는 SDK가 호출에서 질문을 **반환**하며 클라이언트의 다음 시도에 답이 실려 옵니다. 리졸버는 그 차이를 전혀 알지 못합니다. 그 아래에서 일어나는 일은 **[다중 왕복 요청](multi-round-trip.md)**에서 다룹니다. + +물어보는 것은 리졸버가 할 수 있는 일 중 하나일 뿐입니다. 묻지 않고 계산하는 의존성, 의존성의 의존성, 모델이 제공할 수 있는 것과 없는 것 같은 일반적인 메커니즘은 **[의존성](dependencies.md)** 페이지에서 다룹니다. + +## 도구 안에서 물어보기 {#ask-from-inside-the-tool} + +도구는 자기 본문 한가운데서 멈추고 물어볼 수도 있습니다. + +!!! warning + `ctx.elicit()`과 `ctx.elicit_url()`은 **서버**가 **클라이언트**에게 보내는 요청이며, 이 채널은 + 레거시 연결(사양 버전 **2025-11-25** 이하)을 쓰는 클라이언트에게만 존재합니다. + **2026-07-28** 연결에는 서버가 시작하는 요청이 없으므로 이 호출은 실패합니다. + 리졸버는 양쪽 모두에서 동작합니다. 자세한 내용은 **[프로토콜 버전](../protocol-versions.md)**에서 + 확인하세요. + +`await ctx.elicit()`은 메시지와 Pydantic 모델을 받습니다. + +```python title="server.py" hl_lines="9-11 20-23 25" +--8<-- "docs_src/elicitation/tutorial001.py" +``` + +* **`Context`** 파라미터가 있어야 `ctx.elicit`을 쓸 수 있으며, 어떤 도구든 이 파라미터를 받을 수 있습니다. 이 객체는 별도의 페이지 **[Context](context.md)**에서 다룹니다. +* `AlternativeDate`는 원하는 답의 **스키마**입니다. +* 도구는 `async def`입니다. 그래야만 합니다. 도중에 멈춰서 사람을 기다리기 때문입니다. +* 그 밖의 날짜라면 도구는 곧바로 반환합니다. 꼭 필요할 때만 묻습니다. +* 사용자가 수락한 날짜는 다시 `book_table` 자체를 거칩니다. 답도 다른 입력과 마찬가지로 입력입니다. 대안 날짜 역시 예약이 꽉 차 있다면 무작정 확정하지 않고 다시 물어봅니다. + +### 클라이언트가 받는 것 {#what-the-client-receives} + +클라이언트는 메시지와 함께, 모델에서 생성된 JSON Schema를 받습니다. + +```json +{ + "properties": { + "accept_alternative": { + "description": "Try another date?", + "title": "Accept Alternative", + "type": "boolean" + }, + "date": { + "default": "2025-12-26", + "description": "Alternative date (YYYY-MM-DD)", + "title": "Date", + "type": "string" + } + }, + "required": ["accept_alternative"], + "title": "AlternativeDate", + "type": "object" +} +``` + +이 스키마가 곧 폼입니다. `Field(description=...)`은 레이블이 되고, 기본값은 입력란을 미리 채우며 그 필드를 선택 사항으로 만듭니다. **[도구](../servers/tools.md)** 페이지가 도구 인자를 두고 설명하는, Pydantic을 JSON Schema로 변환하는 바로 그 장치입니다. + +!!! warning + 엘리시테이션 스키마는 도구의 입력 스키마만큼 표현력이 높지 않습니다. 평평한 원시 타입 필드만 + 가능합니다. `str`, `int`, `float`, `bool`, 또는 문자열 `Literal`(`enum`이 됩니다)입니다. + 모델 안에 모델을 넣으면 클라이언트에 아무것도 보내기 전에 `ctx.elicit`이 예외를 일으킵니다. + + ```text + TypeError: Elicitation schema field 'address' rendered as {'$ref': '#/$defs/Address'}, which is not a valid PrimitiveSchemaDefinition + ``` + + 작업 중인 사람을 가로막고 있는 것입니다. 답에 중첩 구조가 필요하다면 애초에 도구의 인자로 + 받았어야 합니다. + +### 세 가지 답 {#the-three-answers} + +`result.action`은 사용자가 무엇을 했는지 알려 주며, 가능한 경우는 정확히 세 가지입니다. + +* `"accept"`: 폼을 제출했습니다. `result.data`는 이미 검증된 `AlternativeDate` 인스턴스입니다. +* `"decline"`: 거절했습니다. +* `"cancel"`: 선택하지 않고 질문을 닫았습니다. + +`result.data`는 `"accept"`일 때만 존재하며, 그래서 예제는 `result.action`을 먼저 확인합니다. 타입 체커가 이 순서를 강제합니다. `result.action == "accept"`를 확인한 뒤에는 `result.data`가 `AlternativeDate`이고, 그 전에는 `.data` 자체가 없습니다. + +거절은 오류가 아닙니다. 거절이 무엇을 뜻하는지(여기서는 예약하지 않음)는 도구가 정하고, 모델에게는 평소처럼 답합니다. + +!!! tip + 답은 코드가 보기 전에 모델을 기준으로 검증됩니다. `bool` 자리에 `"maybe"`를 보내는 클라이언트가 + 예약을 망가뜨리지는 않습니다. 호출은 스키마 불일치 오류로 실패하고, `if` 문은 실행되지 + 않습니다. + +## 사용자를 URL로 보내기 {#send-the-user-to-a-url} + +모델이나 클라이언트를 거쳐서는 안 되는 것이 있습니다. 자격 증명, 카드 번호, OAuth 동의가 그렇습니다. 이런 경우에는 데이터를 요청하지 않고, 사용자에게 어딘가로 가 달라고 요청합니다. + +```python title="server.py" hl_lines="10-14 23" +--8<-- "docs_src/elicitation/tutorial002.py" +``` + +* `ctx.elicit_url()`은 메시지, 방문할 **URL**, 그리고 직접 정하는 `elicitation_id`를 받습니다. 서버 안에서 이 엘리시테이션을 식별하는 문자열이면 무엇이든 됩니다. +* 결과에는 action만 있고 그 외에는 아무것도 없습니다. `"accept"`는 사용자가 URL을 열겠다고 동의했다는 뜻이지, 그 너머에 있는 일을 끝냈다는 뜻이 **아닙니다**. +* 결제는 대역 외로, 사용자의 브라우저와 결제 제공자 사이에서 이루어집니다. 어떤 내용도 MCP를 통해 돌아오지 않습니다. + +두 번째 도구를 보세요. 서버가 대역 외 흐름이 끝났음을 알게 되면(웹훅, 폴링, 여기서는 두 번째 도구로 모델링했습니다) `ctx.session.send_elicit_complete(...)`가 같은 `elicitation_id`로 `notifications/elicitation/complete`를 보냅니다. 클라이언트는 이를 통해 *"waiting for payment..."* 표시를 멈춰도 된다는 것을 압니다. 이 알림이 없으면 클라이언트는 짐작만 할 수 있습니다. + +## 클라이언트 쪽 {#the-client-side} + +서버는 묻고, 클라이언트는 `Client(...)`에 **`elicitation_callback`**을 전달해 답합니다. + +```python title="client.py" hl_lines="6-7 18" +--8<-- "docs_src/elicitation/tutorial003.py" +``` + +* 콜백 하나가 두 모드를 모두 처리합니다. `params`는 `ElicitRequestFormParams`와 `ElicitRequestURLParams`의 유니언이며, `isinstance`로 분기합니다. +* URL이면 사용자에게 `params.url`을 보여 주고 사용자가 고른 action을 반환합니다. `content`는 절대 넣지 않습니다. +* 폼이면 실제 애플리케이션은 `params.requested_schema`를 렌더링하고 사용자의 입력을 `content`로 반환합니다. 이 예제는 항상 미리 준비된 답으로 예라고 답하는데, 테스트에서 원하는 콜백이 바로 이런 것입니다. +* 콜백을 전달하는 것이 곧 **기능 선언**이기도 합니다. 서버는 이를 통해 이 클라이언트에게 물어볼 수 있다는 것을 알게 됩니다. 클라이언트가 서버에게 답해 줄 수 있는 다른 것은 **[클라이언트 콜백](../client/callbacks.md)**에 있습니다. + +!!! info + 엘리시테이션은 **서버**가 **클라이언트**에게 보내는 요청이며, 이런 요청은 고전적인 핸드셰이크 + 세션에만 존재합니다. 그래서 이 클라이언트는 `mode="legacy"`를 전달합니다. + **2026-07-28** 연결에서는 도구가 호출에서 질문을 **반환**하는 방식으로 묻습니다. + 그 흐름은 **[다중 왕복 요청](multi-round-trip.md)**에서 다룹니다. + +### 직접 해 보기 {#try-it} + +`ctx.elicit` 폼 모드 `server.py`(`book_table`이 있는 것)를 Streamable HTTP로 시작하고(한 줄짜리 명령은 **[서버 실행하기](../run/index.md)**에 있습니다), 클라이언트의 `main()`을 실행해 `book_table`에 크리스마스 당일을 요청하세요. + +콜백은 전달받은 질문을 출력합니다. + +```text +No tables for 2 on 2025-12-25. Would you like to try another date? +``` + +콜백은 `{"accept_alternative": True, "date": "2025-12-27"}`로 답하고, 그동안 `await ctx.elicit(...)` 안에서 내내 기다리던 도구가 예약을 마무리합니다. + +```text +Booked a table for 2 on 2025-12-27. +``` + +이제 URL 모드 `server.py`로 바꾸고 같은 `main()`이 `pay_deposit`을 호출하게 하세요. 같은 콜백이 다른 쪽 분기를 타서 결제 링크를 출력하고, 도구는 *"Complete the payment in your browser."*를 돌려줍니다. 호출 도중에 양방향으로 왕복 한 번이 오간 것입니다. + +!!! check + 이제 `Client`에서 `elicitation_callback=`을 제거하고 다시 크리스마스 당일로 `book_table`을 + 호출해 보세요. 호출 전체가 프로토콜 오류로 실패합니다. + + ```text + Elicitation not supported + ``` + + 콜백을 등록하지 않은 클라이언트는 `elicitation` 기능을 선언한 적이 없으므로 물어볼 상대가 + 없습니다. 도구는 `"decline"`을 받은 것이 아니라 예외를 받았습니다. 이 경우를 염두에 두고 + 설계하세요. 모든 엘리시테이션에는 "물어볼 수 없다면 어떻게 할 것인가?"에 대한 합리적인 답이 + 필요합니다. + +## 요약 {#recap} + +* `Annotated[T, Resolve(fn)]`로 어노테이션한 파라미터는 리졸버가 채우며, 리졸버는 물어봐야 할 때 `Elicit(...)`을 반환합니다. 모든 연결에서 동작합니다. +* 스키마는 평평한 Pydantic 모델입니다. 원시 타입 필드만 가능하며, 돌아오는 길에 검증됩니다. +* `result.action`은 `"accept"`, `"decline"`, `"cancel"` 중 하나이며, `result.data`는 accept일 때만 존재합니다. +* `await ctx.elicit(message, schema=Model)`은 도구 본문 안에서 묻고, `await ctx.elicit_url(message, url, elicitation_id)`는 모델을 거쳐서는 안 되는 모든 것을 위한 것입니다(`ctx.session.send_elicit_complete(elicitation_id)`는 대역 외 부분이 끝났음을 알립니다). 둘 다 서버가 클라이언트에게 보내는 요청이므로 클라이언트가 레거시 연결을 쓰고 있어야 합니다. +* 클라이언트는 `elicitation_callback` 하나로 답하며 params 타입에 따라 분기합니다. 콜백을 등록하는 것이 곧 기능을 선언하는 것입니다. +* 2026-07-28 연결에서는 서버가 질문을 밀어 넣는 대신 반환하며, 같은 콜백에 **[다중 왕복 요청](multi-round-trip.md)**이 질문을 공급합니다. + +그 반환 아래에 있는 모든 것(재시도 루프, `requestState` 보호, 직접 구동하기)은 **[다중 왕복 요청](multi-round-trip.md)**에서 다룹니다. diff --git a/i18n/ko/pages/handlers/index.md b/i18n/ko/pages/handlers/index.md new file mode 100644 index 0000000000..bee33f0381 --- /dev/null +++ b/i18n/ko/pages/handlers/index.md @@ -0,0 +1,24 @@ +--- +translation: + sections: [424930166c4bc6f3] + tool: 1 +--- +# 핸들러 내부 {#inside-your-handler} + +핸들러의 인자는 클라이언트에서 옵니다. 그 **밖에** 핸들러가 읽을 수 있는 것, 그리고 실행 중에 할 수 있는 모든 것은 여기에서 다룹니다. + +읽을 수 있는 것은 다음과 같습니다. + +* **[Context](context.md)**는 어떤 핸들러든 요청할 수 있는 유일한 추가 매개변수입니다. 진행 중인 요청, 요청 헤더, 세션, 그리고 진행률 보고와 변경 알림 메서드를 제공합니다. +* **[의존성](dependencies.md)**은 모델이 절대 보지 못하는 매개변수로, `Resolve`를 사용해 직접 작성한 함수가 값을 채웁니다. +* **[Lifespan](lifespan.md)**은 서버가 시작할 때 한 번 만들어 두는 상태와, 핸들러가 `Context`를 통해 그 상태에 접근하는 방법을 다룹니다. + +실행 중에 할 수 있는 일은 다음과 같습니다. + +* **[엘리시테이션(elicitation)](elicitation.md)**으로 사용자에게 추가 입력을 요청합니다. 이를 실어 나르는 2026-07-28 패턴은 **[다중 왕복 요청](multi-round-trip.md)**에서 다룹니다. +* **[샘플링과 루트](sampling-and-roots.md)**로 클라이언트에 LLM 완성이나 작업 공간 폴더를 요청합니다. 지원 중단 예정(deprecated)이지만 여전히 제공됩니다. +* 오래 걸리는 작업의 **[진행률](progress.md)**을 보고합니다. +* **[로깅](logging.md)**으로 로그를 남깁니다(서버를 운영하는 사람을 위해 표준 오류로 출력됩니다). +* **[구독](subscriptions.md)**으로 구독 중인 클라이언트에게 변경 사항을 알립니다. + +아직 핸들러를 등록하지 않았다면 **[도구](../servers/tools.md)**부터 시작하세요. 이 섹션의 모든 페이지는 핸들러가 하나 있다고 가정합니다. diff --git a/i18n/ko/pages/handlers/lifespan.md b/i18n/ko/pages/handlers/lifespan.md new file mode 100644 index 0000000000..3736b8c8cc --- /dev/null +++ b/i18n/ko/pages/handlers/lifespan.md @@ -0,0 +1,107 @@ +--- +translation: + sections: [f3ca8ac5f90f2dfa, 85a1ef3588ba0736, 563346d4d5804933, 9e3528340d0bab53] + tool: 1 +--- +# Lifespan {#lifespan} + +실제 서버는 대부분 데이터베이스 풀, HTTP 클라이언트, 로드된 모델처럼 살아 있는 동안 내내 유지하는 무언가가 있습니다. + +호출할 때마다 새로 만들고 싶지는 않고, 깔끔하게 닫고 싶기는 합니다. 바로 이를 위한 것이 **lifespan**입니다. + +## 타입이 지정된 lifespan {#a-typed-lifespan} + +lifespan은 서버를 받아 **객체 하나**를 `yield`하는 `@asynccontextmanager`입니다. yield한 객체는 서버가 실행되는 동안 모든 핸들러에서 사용할 수 있습니다. + +```python title="server.py" hl_lines="25-31 34 38 40" +--8<-- "docs_src/lifespan/tutorial001.py" +``` + +아래에서 위로 읽어 보세요. + +* `app_lifespan`은 `yield` **앞에서** `Database`에 연결하고, 그 **뒤** `finally`에서 연결을 끊습니다. 이것이 시작과 종료입니다. +* 설정한 것을 담는 평범한 dataclass인 `AppContext`를 yield합니다. 오늘은 필드 하나, 내일은 열 개입니다. +* `MCPServer("Bookshop", lifespan=app_lifespan)`이 연결 작업의 전부입니다. +* 도구 안에서 yield된 객체는 `ctx.request_context.lifespan_context`입니다. + +lifespan은 **한 번** 실행됩니다. 서버가 시작될 때(첫 요청 전) 진입하고 서버가 멈출 때 빠져나옵니다. 그 사이의 모든 요청은 같은 `AppContext`를 공유합니다. + +!!! info + FastAPI `lifespan`을 작성해 본 적이 있다면 이미 아는 내용입니다. 같은 데코레이터, 같은 `yield`, 같은 `finally`입니다. + +### 모델에게 보이는 것 {#what-the-model-sees} + +새로운 것은 없습니다. `ctx`는 **Context** 매개변수이므로 SDK가 주입하며, 입력 스키마에는 절대 들어가지 않습니다. + +```json +{ + "type": "object", + "properties": { + "genre": {"title": "Genre", "type": "string"} + }, + "required": ["genre"], + "title": "count_booksArguments" +} +``` + +모델이 전달할 수 있는 인자는 `genre`뿐입니다. lifespan은 서버 내부의 일입니다. + +`@mcp.resource()`와 `@mcp.prompt()` 함수도 `ctx` 매개변수를 받을 수 있는데, 다음 절에서 설명할 이유로 타입 매개변수 없는 `Context`로 씁니다. `ctx`가 담고 있는 모든 것은 **[Context](context.md)**에서 확인하세요. + +### 제대로 된 타입 지정 {#it-really-is-typed} + +어노테이션을 다시 보세요. `ctx: Context[AppContext]`입니다. + +이 타입 매개변수 하나 덕분에 타입 검사기에게 `ctx.request_context.lifespan_context`는 **곧** `AppContext`입니다. `.db`는 자동 완성되고, `.dbb`는 서버를 실행하기도 전에 오류가 됩니다. + +대신 타입 매개변수 없는 `Context`를 쓰면 `lifespan_context`의 타입은 `dict[str, Any]`가 됩니다. 타입 검사기로서는 lifespan이 무엇을 yield했는지 알 방법이 없기 때문입니다. 객체는 런타임에 여전히 존재하지만, 도움은 잃게 됩니다. + +!!! warning + `Context[AppContext]`는 **도구 전용** 표기입니다. `@mcp.resource()`나 + `@mcp.prompt()` 함수에 붙이면 해당 핸들러 호출은 모두 실패합니다. 클라이언트는 오류를 돌려받고, + 서버 로그에 그 이유가 나타납니다. + + ```text + Context is not available outside of a request + ``` + + 리소스와 프롬프트에서는 타입 매개변수 없는 `ctx: Context`를 쓰세요. lifespan이 yield한 객체는 + 런타임에 여전히 `ctx.request_context.lifespan_context`에 있습니다. 포기하는 것은 타입 매개변수이지 + 객체가 아닙니다. + +!!! tip + lifespan은 항상 있습니다. 전달하지 않으면 SDK의 기본 lifespan이 빈 `dict`를 yield하므로 + `ctx.request_context.lifespan_context`는 `{}`이며, 절대 `None`이 아닙니다. 타입 매개변수 없는 + `Context`가 이를 `dict[str, Any]`로 타입 지정하는 것도 이 기본값 때문입니다. + +## 직접 확인하기 {#watch-it-happen} + +"시작 코드는 첫 요청 전에 실행된다"는 말은 그냥 믿고 넘어갈 것이 아니라 직접 확인해 볼 만한 문장입니다. + +서버를 생명 주기만 남도록 줄여 보세요. `Database`에 `connected` 플래그를 두고, `connect()`와 `disconnect()`에서 이를 뒤집고, 이 값을 보고하는 도구를 추가합니다. + +```python title="server.py" hl_lines="11 14 17 25 44" +--8<-- "docs_src/lifespan/tutorial002.py" +``` + +`database`가 모듈 수준에 있는 이유는 단 하나, 서버 **바깥**에서 볼 수 있게 하기 위해서입니다. + +!!! check + 세 시점, 세 값입니다. + + * 서버가 시작되기 전에는 `database.connected`가 `False`입니다. 모듈을 임포트해도 아무것도 연결되지 않았습니다. + * 실행 중에 `database_status`를 호출하면 결과는 `"connected"`입니다. + * 서버를 멈추면 `finally` 블록이 실행되고 `database.connected`는 다시 `False`가 됩니다. + + 작업은 정확히 배치한 곳, 즉 `yield` 주변에서 일어났습니다. 임포트 시점도, 요청마다도 아닙니다. + +## 요약 {#recap} + +* `lifespan=` 매개변수는 서버를 받아 객체 하나를 `yield`하는 `@asynccontextmanager`를 받습니다. +* `yield` 앞의 코드는 시작입니다. 뒤의 `finally`는 종료입니다. +* 요청마다 실행되는 것이 아니라 서버의 전체 수명을 감싸며 한 번 실행됩니다. +* `yield`한 것은 모든 도구, 리소스, 프롬프트에서 `ctx.request_context.lifespan_context`입니다. +* `ctx: Context[AppContext]`는 도구에서 이 접근에 완전한 타입을 부여합니다. 리소스와 프롬프트는 타입 매개변수 없는 `Context`를 받습니다. +* `lifespan=` 매개변수가 없으면 빈 `dict`이며, 절대 `None`이 아닙니다. + +호출 도중 멈추고 사용자만 아는 것을 사용자에게 묻는 핸들러는 **[엘리시테이션(elicitation)](elicitation.md)**에서 다룹니다. diff --git a/i18n/ko/pages/handlers/logging.md b/i18n/ko/pages/handlers/logging.md new file mode 100644 index 0000000000..758cabf560 --- /dev/null +++ b/i18n/ko/pages/handlers/logging.md @@ -0,0 +1,87 @@ +--- +translation: + sections: [c93a3e1aefd77955, 7851abd5ec54393b, f49d1ca2f330f9cd, c03764bd9dfeef7b, 4a0391691a674ae4, 2df5cd279eabf9f5] + tool: 1 +--- +# 로깅 {#logging} + +도구에서 로그를 남기는 방법은 다른 Python 함수에서와 똑같습니다. 표준 라이브러리를 사용하세요. + +MCP에는 프로토콜 수준의 **로깅 기능**이 있습니다. 서버가 `Context` 객체의 메서드를 통해 로그 메시지를 알림으로 클라이언트에 보낼 수 있는 기능입니다. 사양의 2026-07-28 리비전은 **이 기능을 지원 중단 예정(deprecated)으로 지정하면서 대체 수단을 제공하지 않으므로**, 이 문서에서는 다루지 않습니다. 지원 중단 예정인 항목 전체와 대신 사용할 방법은 **[지원 중단 예정 기능](../deprecated.md)**에서 확인하세요. + +대신 할 일은 다른 모든 Python 프로그램에서 하는 것과 같습니다. 표준 라이브러리를 사용합니다. + +## 로그를 남기는 도구 {#a-tool-that-logs} + +```python title="server.py" hl_lines="1 5 13" +--8<-- "docs_src/logging/tutorial001.py" +``` + +* `logging.getLogger(__name__)`은 모듈 이름을 딴 로거를 돌려줍니다. 파일 맨 위에서 한 번만 만드세요. +* 도구 안에서는 다른 함수에서와 마찬가지로 `logger.info(...)`를 호출합니다. 주입할 것도, `await`할 것도, MCP에 특화된 것도 없습니다. + +!!! check + 도구를 호출하고 결과 전체를 살펴보세요. + + ```python + result.content # [TextContent(text="Found 3 books matching 'dune'.")] + result.structured_content # {'result': "Found 3 books matching 'dune'."} + ``` + + 로그 줄은 어디에도 없습니다. 로깅은 서버를 운영하는 **사람**을 위한 것입니다. 모델은 + 이를 절대 보지 못합니다. 모델이 읽어야 하는 내용이 있다면 `return`으로 돌려주세요. + +## 출력 위치 {#where-it-goes} + +**stdio** 서버에서는 이 질문이 평소보다 중요합니다. 호스트는 서버를 서브프로세스로 실행했고, 서버의 **stdout**에서 MCP 메시지를 읽고 있습니다. 표준 에러는 서버의 몫입니다. + +표준 라이브러리는 이미 올바르게 동작합니다. 로그 출력은 기본적으로 `sys.stderr`로 갑니다. `logger.info(...)` 줄은 터미널(또는 호스트가 서브프로세스의 stderr를 수집하는 곳)에 도착하고, 프로토콜 스트림은 깨끗하게 유지됩니다. + +!!! tip + stdio 서버에서는 `print()`를 쓰지 마세요. `print`는 **stdout**에 쓰는데, stdout은 프로토콜의 몫입니다. + 서비스 중에 SDK는 실제로 **플러시된** stdout 출력을 stderr로 돌리므로 통신을 망가뜨릴 수는 + 없지만, 블록 버퍼링되는 프로세스에서 `print()`의 출력은 대개 플러시되지 않은 채 `sys.stdout`의 + 버퍼에 남아 있다가, 인터프리터가 종료 시 버퍼를 비울 때 프로토콜 스트림으로 그대로 흘러 들어갑니다. + 설령 stderr로 돌려지더라도 그 줄은 레벨도, 로거 이름도, 걸러낼 방법도 없이 날것 그대로 로그 출력 + 사이에 섞입니다. + + `logger.debug("got here")`는 똑같이 한 줄이면 되고, 올바른 곳으로 갑니다. + +## 레벨 {#the-level} + +`logging.basicConfig()`를 직접 호출할 필요는 없습니다. `MCPServer`를 생성하는 것만으로 이미 호출되며, 핸들러는 표준 에러를 향하고 레벨은 `log_level=`로 전달한 값을 따릅니다. 따라서 `logger.debug(...)` 줄을 보려면 `MCPServer("Bookshop", log_level="DEBUG")`만으로 충분합니다. + +기본값은 `"INFO"`입니다. + +`logging.basicConfig()`는 이미 존재하는 핸들러를 절대 교체하지 않습니다. 서버를 만들기 전에 로깅을 직접 설정했다면 그 설정이 우선합니다. + +## 직접 해 보기 {#try-it} + +MCP Inspector로 서버를 실행하세요. + +```console +uv run mcp dev server.py +``` + +**Tools** 탭에서 `search_books`를 호출하세요. Inspector가 보여주는 결과는 반환값뿐입니다. 다음 줄은 + +```text +Searching for 'dune' +``` + +표준 에러로 갔습니다. 통신이 아니라 터미널입니다. + +!!! info + 정말로 원하는 것이 **트레이싱**(모든 요청, 걸린 시간, 실패 여부)이라면 로그 줄이 아니라 + 스팬이 필요합니다. 서버는 이미 스팬을 내보내고 있습니다. SDK는 기본적으로 모든 메시지를 + OpenTelemetry로 추적합니다. **[OpenTelemetry](../run/opentelemetry.md)**를 참고하세요. + +## 요약 {#recap} + +* MCP 프로토콜의 로깅 기능은 2026-07-28 사양에서 지원 중단 예정으로 지정되었고 대체 수단이 없습니다. 이 기능 위에 무언가를 만들지 마세요. +* 모듈 수준에 `logger = logging.getLogger(__name__)`, 도구 안에 `logger.info(...)`. 이것이 패턴의 전부입니다. +* 로그 출력은 절대 모델에 닿지 않습니다. `return`한 값만 닿습니다. +* 표준 에러는 서버의 몫이고 stdout은 프로토콜의 몫입니다. SDK는 서비스 중 플러시된 stdout 출력을 stderr로 돌리지만, 플러시되지 않은 `print()`는 종료 시 여전히 통신으로 흘러 들어갈 수 있고, 돌려진 줄은 레이블 없이 도착합니다. 레코드마다 핸들러가 플러시하는 `logging`을 사용하세요. +* `MCPServer(..., log_level="DEBUG")`로 레벨을 설정하며, 먼저 만든 로깅 설정은 그대로 유지됩니다. + +서버에서 무언가(도구 목록, 리소스)가 바뀌었음을 연결된 클라이언트에 알리는 방법은 **[구독](subscriptions.md)**에서 다룹니다. diff --git a/i18n/ko/pages/handlers/multi-round-trip.md b/i18n/ko/pages/handlers/multi-round-trip.md new file mode 100644 index 0000000000..3955da968e --- /dev/null +++ b/i18n/ko/pages/handlers/multi-round-trip.md @@ -0,0 +1,191 @@ +--- +translation: + sections: [74011e683045eea9, 9b64cc175c18b6a9, 4b41be4824030397, e3b1502da786ec33, 71e41161f143c6a9, 9ec2c1eeb8c36378, 8dd027377d46448b, f81491125dcbfe8b] + tool: 1 +--- +# 다중 왕복 요청 {#multi-round-trip-requests} + +도구가 한 번의 왕복으로 끝나지 않을 때가 있습니다. 선택, 확인, 자격 증명처럼 사용자만 가진 무언가가 필요한 경우입니다. + +2026-07-28 이전에는 서버가 **역방향 호출**로 이를 얻었습니다. 원래 요청을 처리하는 도중에 엘리시테이션(elicitation)이나 샘플링 호출 같은 자체 요청을 클라이언트에게 여는 방식입니다. 2026-07-28 사양은 이 백채널을 폐지합니다. + +대신 서버는 **반환**합니다. + +## 역방향 호출 대신 반환 {#return-dont-call-back} + +서버는 `tools/call`에 `CallToolResult` 대신 **`InputRequiredResult`**로 응답합니다. 핵심은 두 필드입니다. + +* **`input_requests`**: 서버에 아직 필요한 것으로, 서버가 고른 이름을 키로 하는 dict입니다. 각 값은 `ElicitRequest`, `CreateMessageRequest`, `ListRootsRequest` 중 하나입니다. +* **`request_state`**: 불투명 토큰입니다. 클라이언트는 재시도할 때 이 토큰을 그대로 되돌려 보냅니다. 이 토큰을 읽는 것은 서버뿐입니다. + +클라이언트는 각 요청을 처리한 뒤, 답을 `input_responses`에, 토큰을 `request_state`에 담아 **같은 도구를 다시** 호출합니다. 이제 서버는 부족했던 것을 갖추었으므로 일반적인 `CallToolResult`를 반환합니다. + +프로토콜은 이것이 전부입니다. 모든 구간은 클라이언트가 서버로 보내는 평범한 요청입니다. 반대 방향으로 흐르는 것은 아무것도 없습니다. + +## 서버 측 {#the-server-side} + +`@mcp.tool()`에서는 이것을 직접 만드는 일이 거의 없습니다. 사용자에게 묻거나(`Elicit`), 클라이언트의 LLM을 샘플링하거나(`Sample`), 루트를 나열하는(`ListRoots`) 의존성을 선언하면 SDK가 대신 `InputRequiredResult`를 반환합니다. 이 형태는 **[의존성](dependencies.md)** 페이지에서 다룹니다. 두 형태는 섞어 쓸 수 없습니다. 호출 하나에는 `input_responses`/`request_state` 채널이 하나뿐이므로, `Resolve(...)` 매개변수를 쓰는 도구는 본문에서 `InputRequiredResult`를 함께 반환할 수 없습니다. `InputRequiredResult` 반환을 선언하면 등록 시점에 거부되고(`InvalidSignature`), 선언하지 않고 반환하면 런타임에 호출이 실패합니다. 수동 형태는 **저수준** `Server`이며, 그 `on_call_tool` 핸들러는 두 결과 타입 중 어느 쪽이든 반환할 수 있습니다. + +```python title="server.py" hl_lines="43-46" +--8<-- "docs_src/mrtr/tutorial001.py" +``` + +* `on_call_tool`의 타입은 `-> CallToolResult | InputRequiredResult`입니다. 두 번째를 반환하는 것이 서버 측 API의 전부입니다. +* 첫 호출에서 `params.input_responses`는 `None`이므로 가드가 작동하여 핸들러는 답하는 대신 묻습니다. +* 재시도에서는 클라이언트가 보낸 `ElicitResult`가 서버가 `input_requests`에서 사용한 것과 **같은 키**(`"region"`) 아래에 들어 있습니다. + +그 파일의 나머지(명시적인 `input_schema`, 직접 만든 `CallToolResult`)는 평범한 저수준 `Server`이며 **[저수준 Server](../advanced/low-level-server.md)**에서 다룹니다. 이 페이지는 두 번째 반환 타입만 더합니다. + +## 도구 외의 경우 {#beyond-tools} + +`tools/call`만 특별한 것은 아닙니다. 2026-07-28에서는 서버가 `prompts/get`과 `resources/read`에도 같은 방식으로 응답할 수 있습니다. `MCPServer`에서는 `@mcp.prompt()` 함수(또는 `@mcp.resource()` **템플릿** 함수)가 직접 `InputRequiredResult`를 반환하고, 재시도의 답을 컨텍스트에서 읽습니다. + +```python title="server.py" hl_lines="20 22 24" +--8<-- "docs_src/mrtr/tutorial004.py" +``` + +* 첫 라운드는 `InputRequiredResult`를 반환합니다. 재시도에서는 `ctx.input_responses`가 같은 키 아래에 답을 담고 있으며, 함수는 평소의 결과를 반환합니다. 여기서는 프롬프트 메시지이고, 템플릿 리소스라면 리소스 콘텐츠입니다. +* 직접 설정한 `request_state`는 서버의 다른 모든 것과 마찬가지로 전송되기 전에 봉인되고 되돌아올 때 검증됩니다. 봉인이 무엇을 보장하는지, 언제 키를 설정해야 하는지는 아래 **[`requestState` 보호](#protecting-requeststate)**에서 다룹니다. +* 의존성 형태가 맞지 않을 때는 `@mcp.tool()` 함수도 같은 방식으로 결과를 직접 반환할 수 있습니다. +* 정적 `@mcp.resource()` 함수는 참여하지 않습니다. `Context`를 받지 않으므로 재시도를 읽을 방법이 없기 때문입니다. 물을 수 있는 것은 템플릿 리소스뿐입니다. +* 아래의 프로토콜 세대 규칙은 그대로 적용됩니다. 2026 이전 세션에서 `InputRequiredResult`를 반환하면 경고에서 설명하는 것과 같은 `-32603`입니다. + +## 클라이언트 측 {#the-client-side} + +`Client`가 루프를 대신 돌립니다. + +서버가 요청할 수 있는 콜백(`elicitation_callback`, `sampling_callback`, `list_roots_callback`)을 등록하고 도구를 호출하세요. `InputRequiredResult`가 도착하면 `Client`는 `input_requests`의 각 항목을 해당 콜백으로 보내고, 답과 되돌려 보낼 `request_state`를 담아 재시도하며, `CallToolResult`가 돌아올 때까지 계속합니다. + +```python title="client.py" hl_lines="11 12" +--8<-- "docs_src/mrtr/tutorial003.py" +``` + +* 이 `elicitation_callback`은 2026 이전 서버의 백채널 `elicitation/create`가 호출했을 바로 그 콜백입니다. `sampling/createMessage`의 `sampling_callback`, `roots/list`의 `list_roots_callback`도 마찬가지입니다. 2026-07-28에서는 독립적인 서버->클라이언트 RPC가 사라졌지만, 동일한 `ElicitRequest` / `CreateMessageRequest` / `ListRootsRequest` 페이로드가 `input_requests` 안에 실려 와서 같은 세 콜백으로 전달됩니다. 한 벌의 콜백이 두 세대를 모두 처리합니다. +* `call_tool`은 평범한 `CallToolResult`를 반환합니다. 중간 라운드는 호출자에게 보이지 않습니다. +* `get_prompt`와 `read_resource`도 같은 루프를 구동합니다. + +!!! check + 콜백을 빼면 루프는 첫 라운드에서 실패합니다. SDK의 대체 콜백은 모든 엘리시테이션에 + 오류로 답하며, `call_tool`은 *"Elicitation not supported"*라는 메시지와 함께 `MCPError`를 + 발생시킵니다. + +루프에는 한도가 있습니다. `Client(..., input_required_max_rounds=10)`이 기본 상한이며, 이를 넘겨서도 계속 `InputRequiredResult`를 반환하는 서버는 `call_tool`에서 예외를 일으킵니다. 라운드에 `input_requests` 없이 `request_state`만 실려 있으면 `Client`는 재시도 전에 잠시 쉽니다(50ms에서 시작해 250ms 상한까지 두 배씩 늘어납니다). 그래서 그저 "아직 끝나지 않았음"을 알리는 서버를 바쁘게 폴링하지 않습니다. + +### 루프 직접 구동 {#driving-the-loop-yourself} + +단일 프로세스 클라이언트에는 자동 루프로 충분합니다. 다음과 같은 경우에는 루프를 직접 맡으세요. + +* 클라이언트가 **분산**되어 있을 때. 사용자에게 질문을 표시하는 프로세스가 `call_tool`을 호출한 프로세스와 다르므로 다른 워커가 재시도를 보냅니다. `request_state`는 그 경계를 넘어 자체 저장소를 통해 운반하는 영속 가능한 토큰이고, `input_responses`는 반대편이 그 토큰과 함께 돌려보내는 것입니다. +* 각 라운드를 **검사**하고 싶을 때. 모든 `input_requests` 항목을 기록하거나 감사하고, 특정 종류의 요청을 거부하거나, 구간 사이에 자체 백오프를 적용합니다. +* 라운드 수가 아니라 **실제 경과 시간**으로 한도를 두고 싶을 때. `input_required_max_rounds`에 기대는 대신 자체 루프를 `anyio.fail_after(...)`로 감싸세요. + +하위 세션으로 내려가면 `allow_input_required=True`가 유니언을 직접 건네줍니다. + +```python title="client.py" hl_lines="12 13 19" +--8<-- "docs_src/mrtr/tutorial002.py" +``` + +* `client.session.call_tool(..., allow_input_required=True)`는 반환 타입을 `CallToolResult | InputRequiredResult`로 넓힙니다. 다시 좁히는 것은 `isinstance`입니다. +* 이제 `request_state`는 직접 다룹니다. 구간 사이에 기록해 두면 새 프로세스에서 대화를 재개할 수 있습니다. +* `input_requests`의 모든 항목에 대해 `input_responses`의 **같은 키** 아래에 `InputResponse`를 넣습니다. `fulfil`이 UI가 들어갈 자리이며, 이 예제는 답을 하드코딩합니다. +* 모든 구간에서 같은 도구 이름, 같은 `arguments`입니다. 재시도는 원래 호출을 다시 수행하는 것이지 새 메서드가 아닙니다. + +## `requestState` 보호 {#protecting-requeststate} + +위의 모든 내용은 `request_state`를 단순 에코로 취급하며, 전송 구간에서는 실제로 그것이 전부입니다. 하지만 클라이언트가 구간 사이에 이를 보관하므로(프로세스를 넘어 기록해 두는 것이 바로 앞 절에서 허용한 일입니다), 돌아오는 것은 **클라이언트가 제공한 입력**입니다. 변조되었거나, 만료되었거나, 아예 다른 호출에서 가져온 것일 수 있습니다. 사양은 상태가 인가, 리소스 접근, 비즈니스 로직에 영향을 줄 수 있는 경우 서버가 이 상태의 무결성을 보호하고 검증에 실패하면 라운드를 거부할 것을 요구합니다. + +`MCPServer`는 기본적으로 이를 보호합니다. 모든 서버는 프로세스 시작 시 생성된 키로 나가는 `requestState`를 봉인하고, 리졸버 상태든 직접 만든 상태든 되돌아오는 모든 에코를 검증합니다. 아무것도 설정할 필요 없이 평문을 쓰고 평문을 읽으며, 전송 구간에는 불투명한 암호화 토큰만 오갑니다. + +기본 키는 프로세스와 생사를 함께합니다. 단일 프로세스를 넘어 배포하기 전에 반드시 알아야 할 한 가지가 바로 이것입니다. + +```python +from mcp.server.mcpserver import MCPServer, RequestStateSecurity + +# Multi-instance or restart-surviving: one or more shared secret keys (>= 32 bytes each). +mcp = MCPServer("fleet", request_state_security=RequestStateSecurity(keys=[key])) +``` + +* **기본값(설정 없음)**은 단일 프로세스에 적합합니다. stdio 또는 정확히 하나의 HTTP 워커입니다. 다른 워커, 로드 밸런서 뒤의 다른 인스턴스, 또는 재시작 후의 같은 서버에 도착한 재시도는 그 프로세스가 갖고 있지 않은 키로 봉인되어 있으므로, 클라이언트는 아래의 고정된 거부 응답을 받고 흐름을 처음부터 다시 시작해야 합니다. +* **`keys=[...]`** 설정은 재시도가 **다른 인스턴스**에 도달할 수 있거나(다중 워커 `uvicorn`, 로드 밸런싱된 HTTP) 재시작 후에도 살아남아야 할 때 필수입니다. 모든 인스턴스가 형제 인스턴스가 발급한 것을 검증합니다. 같은 장치이되, 생성된 비밀 대신 직접 제공한 비밀을 씁니다. +* KMS나 기존 토큰 서비스 같은 자체 암호화를 쓰려면 `keys` 대신 `RequestStateSecurity(codec=...)`를 전달하세요. 계약은 아래 **[자체 암호화 사용](#bring-your-own-crypto)**에서 다룹니다. + +### 봉인이 담는 것 {#what-the-seal-carries} + +기본값이든 설정했든, 전송 구간의 `requestState`는 암호화되고 인증된 토큰입니다. 코드에서는 이를 볼 일이 없습니다. 핸들러와 리졸버는 평문을 쓰고 평문을 읽으며(`ctx.request_state`), SDK가 나갈 때 봉인하고 들어올 때 검증합니다. 무결성 외에도 각 토큰은 다음에 묶입니다. + +* **시간 창.** 매 라운드마다 새 만료 시각으로 다시 봉인하므로, `RequestStateSecurity(ttl=...)`(기본 600초)는 전체 흐름이 아니라 라운드별 생각할 시간을 제한합니다. +* **인증된 주체.** 요청이 SDK가 검증한 OAuth 액세스 토큰을 지니고 있으면 상태는 토큰의 클라이언트, 발급자(issuer), 사용자 식별자(subject)에 묶입니다. 한 사용자를 위해 발급된 상태는 두 사용자가 하나의 OAuth 클라이언트를 공유하더라도 다른 사용자 아래에서는 실패합니다. subject를 제공하지 않는 검증기는 바인딩을 클라이언트 ID만으로 약화시키는데, URL 기반 클라이언트 ID에서는 그 클라이언트 소프트웨어의 모든 사용자가 이를 공유합니다. 인증이 SDK 바깥(앞단 프록시)에서 종료되거나 트랜스포트가 인증되지 않은 경우에는 묶을 주체가 없으므로 이 검사는 작동하지 않습니다. 단, `RequestStateSecurity(bind_principal=...)`로 자체 ID 신호에서 주체를 제공하면 작동합니다. 토큰 검증기가 어떤 구성 요소를 제공하든 일관되게 제공해야 합니다. 어떤 요청에는 subject를 포함하고 다른 요청에는 빼는 검증기는 흐름 도중에 주체를 바꾸는 셈이고, 진행 중인 라운드는 거부됩니다. +* **원래 요청.** 메서드, 도구 또는 프롬프트 이름(또는 리소스 URI), 그리고 인수의 다이제스트입니다. 다른 도구, 다른 인수, 다른 메서드에 재사용된 토큰은 실패합니다. +* **질문한 내용 그대로.** 모든 리졸버 답은 클라이언트에게 표시된 렌더링된 질문에 고정됩니다. 답이 처음 도착한 라운드에서도, 기록된 답을 나중에 재사용할 때도 마찬가지입니다. 문구를 바꾼 메시지나 변경된 스키마로 재배포하면 서버는 오래된 답을 소비하는 대신 다시 묻습니다. 같은 고정은 반대로도 작용합니다. 메시지는 호출별 데이터가 아니라 도구의 인수에서 만드세요. 타임스탬프나 실시간 시세로 만든 메시지는 라운드마다 다르게 렌더링되므로 기록된 모든 답이 오래된 것으로 보이고, 서버는 클라이언트의 라운드 한도가 호출을 끝낼 때까지 다시 묻습니다. + +이 모든 것은 SDK의 일이지 작성자의 일이 아니며, 자체 코덱을 가져오더라도 코덱의 일이 아닙니다. + +### 키 교체 {#rotating-keys} + +`keys[0]`이 새 상태를 봉인하고, 목록의 모든 키가 검증에 쓰입니다. 무중단 교체는 세 단계이며, 각 단계는 다음 단계로 넘어가기 전에 완전히 배포되어야 합니다. + +```python +RequestStateSecurity(keys=[OLD, NEW]) # 1: every instance learns to verify NEW; OLD still mints +RequestStateSecurity(keys=[NEW, OLD]) # 2: NEW mints; in-flight OLD state keeps verifying +RequestStateSecurity(keys=[NEW]) # 3: one ttl after phase 2 is fully out, retire OLD +``` + +발급 키를 먼저 승격하지 마세요. 일부 인스턴스가 아직 검증할 수 없는 키로 발급하면 배포 도중 진행 중인 라운드가 버려집니다. + +키는 하나의 서비스에 한정됩니다. 봉인된 봉투에는 서버 이름도 audience 클레임으로 담기므로, 우연히 같은 비밀을 공유하는 다른 서비스가 발급한 토큰은 어차피 거부됩니다. 클레임의 변별력은 이름만큼이므로, 명시적 정책이 주어진 서버는 실제 이름이 있거나 `RequestStateSecurity(audience=...)`를 설정해야 합니다. 이름 없는 서버는 생성 시점에 예외를 일으킵니다. `audience=`는 한 서비스가 다른 서비스가 발급한 상태를 받아들여야 하는 의도적인 다중 서비스 토폴로지에도 쓰입니다. (설정 없는 기본값은 예외입니다. 키가 프로세스를 떠나지 않으므로 audience 클레임이 더할 것이 없습니다.) + +### 자체 암호화 사용 {#bring-your-own-crypto} + +`RequestStateSecurity(codec=...)`에는 `seal(bytes) -> str`과 `unseal(str) -> bytes`를 갖추고 자신이 발급하지 않은 토큰에 대해 `InvalidRequestState`를 발생시키는 것이면 무엇이든 전달할 수 있습니다. 전형적인 형태는 KMS를 이용한 봉투 암호화로, 시작 시 데이터 키를 한 번 풀고 토큰별 암호화는 로컬에서 수행합니다. + +```python title="server.py" hl_lines="12 26-27 34-35 38" +--8<-- "docs_src/mrtr/tutorial005.py" +``` + +TTL, 주체 바인딩, 요청 바인딩은 코덱의 일이 **아닙니다**. SDK가 모든 코덱에 대해 `seal` 전에 페이로드에 이를 찍어 넣고 `unseal` 후에 다시 검증합니다. 코덱의 의무는 무결성(변조되었으면 예외를 발생시킴)과, 가능하면 기밀성뿐입니다. + +### 검증 실패 시 {#when-verification-fails} + +들어오는 쪽의 모든 실패는 변조든, 만료든, 다른 요청이나 주체에 대한 재사용이든, 이 서버가 모르는 키로 봉인된 것이든 같은 답을 받습니다. + +```json +{"code": -32602, "message": "Invalid or expired requestState"} +``` + +원인이 무엇이든 하나의 고정된 메시지이므로 전송 구간에서는 어떤 검사가 실패했는지 드러나지 않으며, 실제 이유는 서버 로그에 남습니다. `tools/call`, `prompts/get`, `resources/read`로 들어오는 모든 `requestState`가 검사되며, 상태를 발급한 적 없는 핸들러로 오는 것도 포함됩니다. 실제로 가장 흔한 거부는 공격자가 아니라, 기본 프로세스 로컬 키가 재시작 이전이나 다른 인스턴스에서 온 재시도를 만나는 경우입니다. 클라이언트는 흐름을 다시 시작하며, 이것이 문제가 된다면 `keys=[...]` 설정이 해결책입니다. + +### 직접 만든 상태 {#hand-built-state} + +직접 설정한 `request_state`(도구, 프롬프트, 리소스 템플릿 함수에서 `InputRequiredResult`를 반환하는 경우)는 코드 변경 없이 리졸버 상태와 같은 장치로 봉인되고 검증됩니다. 평문을 쓰고 평문을 읽으면 위의 모든 바인딩이 적용됩니다. + +설정했더라도 SDK가 대신 고정할 수 없는 한 가지는 질문의 동일성입니다. 상태에 있는 답이 **직접 만든** 질문 중 어느 것에 속하는지 SDK는 알지 못합니다. 답을 질문별로 키를 매겨 저장한다면 자체 질문 식별자를 상태에 넣고 재시도에서 확인하세요. + +저수준 `Server`는 기본 제공 기능이 없는 계층입니다. `MCPServer`와 달리 경계를 직접 덧붙이기 전까지는 아무것도 봉인되지 않으며, 그러기 전까지 `request_state`는 작성한 그대로 전송 구간을 건너갑니다. 한 줄짜리 옵트인은 **[저수준 Server](../advanced/low-level-server.md#the-other-handlers)**에 나와 있습니다. + +## 2026-07-28 전용 결과 {#a-2026-07-28-result} + +`InputRequiredResult`는 프로토콜 버전 **2026-07-28**에만 존재합니다. 인메모리 `Client(server)`는 이를 대신 협상하고, 네트워크를 통할 때는 `mode="auto"`가 이를 발견합니다. 연결한 뒤에는 `client.protocol_version`이 무엇을 얻었는지 알려 줍니다. + +!!! warning + 2026 이전 세션에는 `InputRequiredResult`를 넣을 곳이 없습니다. `mode="legacy"` 연결에서 + 핸들러가 이를 반환하면 러너는 협상된 버전으로 직렬화할 수 없고, 클라이언트는 `-32603` + *"Handler returned an invalid result"* 오류를 돌려받습니다. 두 세대를 모두 지원하는 서버는 + 이를 쓰기 전에 `ctx.protocol_version`을 확인해야 합니다. + +!!! info + **URL 모드 엘리시테이션**은 2026 연결에서 바로 이 메커니즘을 탑니다. `input_requests`의 + 항목은 params가 `ElicitRequestURLParams`인 `ElicitRequest`입니다. 사용자가 대역 외 흐름을 + 마치면 클라이언트가 호출을 재시도합니다. 같은 루프이고 새 API는 없습니다. 고수준 서버 쪽 + 절반은 **[엘리시테이션](elicitation.md)**에 있습니다. + +## 요약 {#recap} + +* 2026-07-28에서 호출 도중 입력이 필요한 서버는 `InputRequiredResult`를 **반환**합니다. 클라이언트에게 요청을 여는 일은 없습니다. +* `input_requests`는 필요한 것이고, `request_state`는 서버만 읽는 불투명한 재개 토큰입니다. +* `Client`가 재시도 루프를 대신 돌립니다. `elicitation_callback` / `sampling_callback` / `list_roots_callback`을 등록하면 `call_tool`은 평범한 `CallToolResult`를 반환합니다. `input_required_max_rounds`(기본 10)가 한도입니다. +* 라운드를 검사하거나 영속화하려면 `client.session.call_tool(..., allow_input_required=True)`를 쓰고 `while isinstance(result, InputRequiredResult)` 루프를 직접 맡으세요. +* `@mcp.tool()`에서는 사용자에게 묻는 의존성이 이 결과를 대신 만들어 줍니다(**[의존성](dependencies.md)**). 수동 형태는 **저수준** `Server`입니다. +* 프롬프트와 리소스도 참여합니다. `@mcp.prompt()` 또는 템플릿 `@mcp.resource()` 함수가 직접 `InputRequiredResult`를 반환하고 재시도에서 `ctx.input_responses`를 읽습니다. +* `requestState`는 클라이언트가 제공한 입력으로 돌아오므로 `MCPServer`는 리졸버 상태든 직접 만든 상태든 기본적으로 프로세스 로컬 키로 봉인합니다. 다중 인스턴스 배포에서는 `RequestStateSecurity(keys=[...])`나 커스텀 코덱을 전달하여 모든 인스턴스가 형제 인스턴스가 발급한 것을 검증할 수 있게 합니다. 봉인은 모든 토큰을 시간 창과 원래 요청에 묶으며, 요청이 SDK가 검증한 인증을 지니거나 `bind_principal=`이 자체 ID 신호를 제공하는 경우에는 인증된 주체에도 묶습니다(**[`requestState` 보호](#protecting-requeststate)**). + +이것이 서버 주도 샘플링과 그 밖의 푸시 방식 백채널을 대체하는 메커니즘입니다. **[지원 중단 예정 기능](../deprecated.md)**을 참고하세요. diff --git a/i18n/ko/pages/handlers/progress.md b/i18n/ko/pages/handlers/progress.md new file mode 100644 index 0000000000..0b0bbd9876 --- /dev/null +++ b/i18n/ko/pages/handlers/progress.md @@ -0,0 +1,120 @@ +--- +translation: + sections: [5315262fe26b33e1, 9d8e98840f1b78f0, 0284b215e85366c4, 8534d8dbb4053a70, 2966fac6fe697007] + tool: 1 +--- +# 진행 상황 {#progress} + +30초가 걸리면서 그 30초 동안 아무 말도 하지 않는 도구는 고장 난 것처럼 보입니다. + +**진행 상황 알림**이 이 문제를 해결합니다. 도구는 얼마나 진행되었는지 보고하고, 클라이언트는 그 정보로 무엇을 그릴지 결정합니다. 진행 막대일 수도, 스피너일 수도, 로그 한 줄일 수도 있습니다. + +## 도구에서 보고하기 {#report-it-from-the-tool} + +**`Context`** 매개변수를 받고 `report_progress`를 호출하세요. + +```python title="server.py" hl_lines="8 11" +--8<-- "docs_src/progress/tutorial001.py" +``` + +인자는 세 개이며, 각 인자의 의미는 직접 정합니다. + +* `progress`: 얼마나 진행했는지입니다. 사양은 보고할 때마다 이 값이 **증가**해야 한다고 요구합니다. 같은 값을 반복하거나 뒤로 돌아가면 안 됩니다. +* `total`: 알고 있다면, 전체가 얼마나 되는지입니다. 선택 사항입니다. +* `message`: **이** 단계를 설명하는, 사람이 읽을 수 있는 한 줄입니다. 선택 사항입니다. + +`ctx`는 타입 힌트 덕분에 주입되며 모델에는 전혀 보이지 않습니다. `import_catalog`의 입력 스키마에는 `urls` 속성 하나만 있습니다. **[Context](context.md)** 페이지는 이 객체를 본격적으로 다루며, 진행 상황 보고는 이 객체가 제공하는 기능 중 하나입니다. + +## 클라이언트에서 수신하기 {#listen-for-it-from-the-client} + +클라이언트는 **호출 단위로** 수신을 선택합니다. `call_tool`에 `progress_callback=` 인자를 전달하면 됩니다. + +```python title="client.py" hl_lines="7 16" +import anyio +from mcp import Client + +from server import mcp + + +async def show(progress: float, total: float | None, message: str | None) -> None: + print(f"{message} ({progress}/{total})") + + +async def main() -> None: + async with Client(mcp) as client: + result = await client.call_tool( + "import_catalog", + {"urls": ["https://example.com/a.json", "https://example.com/b.json"]}, + progress_callback=show, + ) + print(result.structured_content) + + +anyio.run(main) +``` + +콜백은 서버가 보고한 값 그대로, 즉 `progress`, `total`, `message`를 받는 `async` 함수입니다. + +!!! info + `Client(mcp)`는 서버 객체에 메모리 안에서 직접 연결하며, **[테스트](../get-started/testing.md)** + 페이지의 기반이 되는 것과 같은 클라이언트입니다. `progress_callback`은 `Client`가 어떤 트랜스포트를 + 쓰든 같은 매개변수입니다. 다만 곧 보게 될 **타이밍**은 인메모리 연결의 타이밍입니다. 인메모리 연결은 + 콜백을 인라인으로 실행하므로 모든 보고가 `call_tool`이 반환되기 전에 도착합니다. 실제 트랜스포트에서는 + 알림과 결과가 경쟁하므로, 느린 콜백은 `call_tool`이 반환된 뒤에도 여전히 실행 중일 수 있습니다. + +### 직접 해 보기 {#try-it} + +`client.py`를 `server.py` 옆에 두고 실행하세요. + +```console +python client.py +``` + +```text +Imported https://example.com/a.json (1/2) +Imported https://example.com/b.json (2/2) +{'result': 'Imported 2 records.'} +``` + +서버의 `await ctx.report_progress(...)` 하나하나가 클라이언트에서 `show` 호출 하나가 되었고, 순서도 그대로이며, 두 줄 모두 `call_tool`이 반환되기 **전에** 출력되었습니다. 진행 상황은 결과에 묶여 오지 않고, 도구가 아직 작업하는 동안 스트리밍됩니다. + +!!! warning + `progress_callback`은 `Client`가 아니라 **호출**에 속합니다. 이를 위한 생성자 인자는 없습니다. + 호출마다 원하는 콜백이 다르기 때문입니다. 어떤 호출은 다운로드 막대를 움직이고, 다음 호출은 + 로그 한 줄을 남깁니다. + +!!! check + 이제 `progress_callback=show` 부분을 지우고 다시 실행하세요. + + ```text + {'result': 'Imported 2 records.'} + ``` + + 오류도 경고도 없고 결과는 같습니다. `report_progress`는 **호출자가 진행 상황을 요청하지 않았으면 + 아무 일도 하지 않으므로**, 조건 없이 보고하면 되고 누가 듣고 있는지 신경 쓸 필요가 없습니다. + +## 전체 양을 모를 때 {#when-you-dont-know-the-total} + +`total`은 분모를 알 때 쓰는 값입니다. 모르는 경우도 많습니다. 피드를 비우거나, 커서를 따라가거나, 길이 헤더가 없는 무언가를 내려받을 때가 그렇습니다. + +그럴 때는 생략하세요. + +```python title="server.py" hl_lines="20" +--8<-- "docs_src/progress/tutorial002.py" +``` + +콜백은 `total=None` 값을 받습니다. 클라이언트는 여전히 **활동**("3 imported so far...")은 보여 줄 수 있지만 백분율은 보여 줄 수 없습니다. 더 보기 좋은 막대를 위해 전체 양을 지어내지 마세요. + +!!! tip + `progress`가 꼭 특정한 무언가를 세어야 하는 것은 아닙니다. 바이트, 행, 페이지 중 사용자가 + 알아볼 단위를 고르고, 지킬 수 있는 `total`만 약속하세요. + +## 요약 {#recap} + +* `Context`를 받는 도구라면 어디서든 `await ctx.report_progress(progress, total=None, message=None)` 형태로 호출합니다. +* 클라이언트는 `call_tool`에 `progress_callback=` 인자를 전달합니다. 호출마다 지정하며, `Client`에는 지정하지 않습니다. +* 콜백은 `async (progress, total, message) -> None` 형태이며 도구가 아직 실행 중인 동안 호출됩니다. +* 호출에 콜백이 없으면 `report_progress`는 아무 일도 하지 않습니다. 조건 없이 보고하세요. +* `total`을 모르면 생략하세요. 콜백은 `None`을 받습니다. + +진행 상황은 실행 중인 도구가 **사용자**에게 보여 주는 것입니다. 서버를 운영하는 **운영자**를 위해 도구가 남기는 로그 줄은 별개의 채널이며, **[로깅](logging.md)**에서 다룹니다. diff --git a/i18n/ko/pages/handlers/sampling-and-roots.md b/i18n/ko/pages/handlers/sampling-and-roots.md new file mode 100644 index 0000000000..deba387067 --- /dev/null +++ b/i18n/ko/pages/handlers/sampling-and-roots.md @@ -0,0 +1,51 @@ +--- +translation: + sections: [5c82b20cbd65ded0, 9dc22632be79a533, 1fb8f452e990c456, 42666ab914ff0cb1, c4e0cb3667fd5ff9] + tool: 1 +--- +# 샘플링과 루트 {#sampling-and-roots} + +핸들러는 연결된 클라이언트에 두 가지를 더 요청할 수 있습니다. 클라이언트가 가진 모델의 완성 결과(**샘플링**)와 클라이언트의 작업 공간 폴더(**루트**)입니다. + +두 기능 모두 SDK가 지원하는 모든 프로토콜 버전에서 여전히 동작합니다. 다만 이를 중심으로 설계하기 전에 아래 경고를 먼저 읽어 보세요. + +!!! warning "2026-07-28 사양에서 지원 중단 예정" + 샘플링과 루트는 `2026-07-28`부터 지원 중단 예정(deprecated)입니다([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2577)). 완전히 동작하는 상태로 유지되며 제거 대상이 되기 전까지 최소 12개월 동안 사양에 남아 있지만, 새로 구현하는 경우에는 이 기능을 기반으로 삼지 않아야 합니다. 권장하는 마이그레이션 방법은 다음과 같습니다. 샘플링 대신 LLM 제공자의 API와 직접 통합하고, 루트 대신 도구 매개변수, 리소스 URI 또는 서버 설정으로 디렉터리를 전달하세요. SDK 전체 목록은 **[지원 중단 예정 기능](../deprecated.md)**에서 확인하세요. + +## 샘플링: 클라이언트의 모델 빌려 쓰기 {#sampling-borrow-the-clients-model} + +리졸버가 `Sample(...)`을 반환하면 도구는 완성 결과를 받습니다. **[의존성](dependencies.md)**에서 `Elicit`를 실행하는 것과 같은 의존성 메커니즘을 거칩니다. + +```python title="server.py" hl_lines="10-15 19" +--8<-- "docs_src/sampling_and_roots/tutorial001.py" +``` + +* `Sample(messages, max_tokens=...)`은 `sampling/createMessage` 매개변수를 그대로 따릅니다. 주입되는 값은 클라이언트의 `CreateMessageResult`이며, `tools`나 `tool_choice`를 전달하면 대신 `CreateMessageResultWithTools`가 됩니다. +* 클라이언트는 `sampling` 기능을 선언해 두어야 합니다(`tools`나 `tool_choice`를 전달한다면 `sampling.tools`). 선언하지 않았다면 클라이언트가 처리할 수 없는 요청을 보내는 대신 `-32021` 프로토콜 오류로 호출이 실패합니다. 백채널이 없는 2026 이전 세션은 보낼 통로가 없으므로 평소와 같은 백채널 없음 오류로 실패합니다. +* `2026-07-28`에서는 요청이 다중 왕복 흐름(**[다중 왕복 요청](multi-round-trip.md)**) 안에서 전달되고, `2025-11-25`에서는 클라이언트로 가는 독립된 요청입니다. 코드는 어느 쪽이든 동일하지만 다중 왕복 규칙에 유의하세요. 요청은 재시도 라운드마다 동일하게 구성되어야 하므로 도구의 인자와 그 밖의 안정적인 데이터만으로 만들어야 합니다. +* `include_context`는 건드리지 마세요. `"none"` 이외의 값은 그 자체로 지원 중단 예정(SEP-2596)이며, 거의 어떤 클라이언트도 선언하지 않는 기능이 필요합니다. + +## 루트: 어디에 두어야 할까 {#roots-where-should-this-go} + +루트는 서버가 작업해도 된다고 클라이언트가 알려 주는 폴더입니다. 참고용 안내일 뿐 접근 제어 메커니즘이 아닙니다. 리졸버가 `ListRoots()`를 반환합니다. + +```python title="server.py" hl_lines="10-11 15" +--8<-- "docs_src/sampling_and_roots/tutorial002.py" +``` + +* 주입되는 `ListRootsResult`에는 `Root` 목록이 담깁니다. 각 항목은 `file://` URI와 선택적인 표시 이름으로 이루어집니다. +* 조건은 샘플링과 같습니다. `roots` 기능이 선언되어 있지 않으면 요청을 보내는 대신 `-32021`로 호출이 실패합니다. + +연결 반대편에서 클라이언트는 이미 가지고 있는 콜백인 `sampling_callback`과 `list_roots_callback`으로 두 요청에 응답합니다. 자세한 내용은 **[클라이언트 콜백](../client/callbacks.md)**에서 확인하세요. + +## 2025년대 연결에서 {#on-2025-era-connections} + +세션을 직접 다루는 코드를 위해 `ctx.session.create_message(...)`와 `ctx.session.list_roots()`가 여전히 존재합니다. 백채널이 있는 곳(2025년대, 비무상태 연결)에서만 동작하며, 호출하면 지원 중단 경고가 발생합니다. 위의 리졸버 마커가 지원되는 형태입니다. 협상된 버전에 따라 전달 방식을 선택하며 경고를 내지 않습니다. + +## 요약 {#recap} + +* 리졸버에서 `Sample(...)`이나 `ListRoots()`를 반환하세요. 도구는 다른 의존성과 마찬가지로 `CreateMessageResult`나 `ListRootsResult`를 받습니다. +* 클라이언트는 해당하는 기능을 선언해야 하며, 그렇지 않으면 요청이 전송되는 대신 `-32021`로 호출이 실패합니다. +* 두 기능 모두 `2026-07-28`에서 지원 중단 예정입니다. 지금은 완전히 동작하지만 새 설계에는 적합하지 않습니다. 샘플링보다는 제공자 API를, 루트보다는 명시적 매개변수를 사용하세요. + +느린 도구가 얼마나 진행되었는지 보고하는 방법은 **[진행 상황](progress.md)**에서 확인하세요. diff --git a/i18n/ko/pages/handlers/subscriptions.md b/i18n/ko/pages/handlers/subscriptions.md new file mode 100644 index 0000000000..6592a740e5 --- /dev/null +++ b/i18n/ko/pages/handlers/subscriptions.md @@ -0,0 +1,152 @@ +--- +translation: + sections: [60a9de8a0bdaa531, 317bbe7e4355cdcc, a61d660c8029e04a, 8f7e82fcb88df8a9, b165db51249ff8ed, 266f56fb798068a4, 7c0e57030b622139, df18d7c2417a9883] + tool: 1 +--- +# 구독 {#subscriptions} + +서버의 카탈로그는 고정되어 있지 않습니다. 도구는 런타임에 나타나고, 리소스 URI 뒤의 콘텐츠는 바뀝니다. + +**구독**은 클라이언트가 이런 변화를 알게 되는 방법입니다. 클라이언트가 `subscriptions/listen` 요청을 한 번 보내면, 그 요청에 대한 응답이 **곧** 스트림입니다. 응답은 열린 채로 유지되며 클라이언트가 요청한 변경 알림을 실어 나릅니다. + +## 도구에서 게시하기 {#publish-it-from-the-tool} + +서버 쪽에서 할 일은 한 줄, 변경을 게시하는 것뿐입니다. + +```python title="server.py" hl_lines="20 32" +--8<-- "docs_src/subscriptions/tutorial001.py" +``` + +* `await ctx.notify_resource_updated("board://sprint")`는 해당 URI를 구독한 열린 스트림 모두에 도달합니다. 그 외에는 아무에게도 가지 않습니다. +* `await ctx.notify_tools_changed()`는 도구 목록 변경을 요청한 모든 스트림에 도달합니다. 이를 받은 클라이언트는 `tools/list`를 다시 호출하고, 이제 `sprint_report`를 보게 됩니다. +* 형제 메서드로 `notify_prompts_changed()`와 `notify_resources_changed()`가 있습니다. +* 구독자가 없으면 할 일도 없습니다. 유휴 상태의 서버에 게시하는 것은 아무 동작도 하지 않으므로, 누가 듣고 있는지 확인할 필요가 전혀 없습니다. 무엇이 바뀌었는지만 알리면 됩니다. + +`MCPServer`가 `subscriptions/listen`을 대신 처리합니다. 와이어 수준의 의무(첫 프레임으로 보내는 확인 응답, 스트림별 필터링, 모든 프레임에 붙는 구독 id)는 SDK의 몫입니다. + +!!! check + 와이어 위에서, 필터에 `board://sprint`를 지정한 스트림은 `complete_task`가 실행된 뒤 다음과 같이 보입니다. + + ```json + {"method": "notifications/subscriptions/acknowledged", + "params": {"notifications": {"resourceSubscriptions": ["board://sprint"]}, "_meta": {"io.modelcontextprotocol/subscriptionId": "listen-1"}}} + + {"method": "notifications/resources/updated", + "params": {"uri": "board://sprint", "_meta": {"io.modelcontextprotocol/subscriptionId": "listen-1"}}} + ``` + + 업데이트에 **담기지 않은** 것에 주목하세요. 보드 자체가 없습니다. 모든 프레임은 `_meta` 아래에 listen 요청의 JSON-RPC id를 담으며, 그 id가 구독 id입니다. 이 id는 클라이언트가 발급합니다. Python `Client`는 `"listen-1"` 같은 문자열을 쓰고, 다른 클라이언트는 정수를 쓰기도 합니다. + +## 요청한 것만 {#only-what-was-asked-for} + +필터는 계약입니다. 도구 목록 변경과 리소스 URI 하나를 요청한 스트림은 그 두 종류만 받고 다른 것은 받지 않습니다. 프롬프트 변경을 게시해도 그 스트림은 조용합니다. + +`MCPServer`는 리소스 URI를 정확한 문자열로 비교하므로, `board://sprint`를 지정한 스트림은 `board://sprint/tasks/1`에 관해서는 아무것도 듣지 못합니다. 명세는 구독한 URI의 하위 리소스 변경을 서버가 보고하는 것을 허용합니다. `MCPServer`는 절대 그렇게 하지 않지만, 클라이언트는 이를 예상하도록 만들어져 있습니다. + +스트림이 **아닌** 것 두 가지가 있습니다. + +* **재생 로그가 아닙니다.** 끊어진 스트림은 사라지며, 아무도 연결되어 있지 않은 동안 게시된 이벤트는 대기열에 쌓이지 않습니다. 클라이언트는 다시 listen하고 다시 가져옵니다. +* **2025 방식이 아닙니다.** `resources/subscribe`를 호출한 클라이언트는 `ctx.session.send_resource_updated(uri)`로 처리됩니다. `notify_*` 메서드는 `subscriptions/listen` 스트림에만 도달합니다. + +## 누가 지켜볼 수 있는지 정하기 {#deciding-who-may-watch} + +기본적으로 요청된 모든 종류와 URI가 받아들여집니다. 어떤 호출자든 게시하는 모든 URI를 지켜볼 수 있습니다. 아무도 읽지 않으므로 read 핸들러는 전혀 참조되지 않습니다. `files://{name}` 핸들러가 거절할 호출자라도 `files://payroll.csv`에 스트림을 열어 그것이 바뀌었다는 사실과 언제 바뀌었는지를 알 수 있습니다. 내용은 절대 알 수 없고, 무엇이 존재하는지 탐색할 수도 없습니다. 알 수 없는 URI도 받아들여지며 단지 이벤트가 발생하지 않을 뿐이기 때문입니다. 좁지만 실재하는 문제이므로, 멀티테넌트 서버에서 사용자별 URI를 게시하기 전에 관문을 두세요. + +관문은 미들웨어입니다. SDK가 확인 응답을 보내기 전에 `subscriptions/listen` 요청을 보고, 호출자가 읽어서는 안 되는 것을 하나라도 요청하면 거부합니다. + +```python title="server.py" hl_lines="19-26 29" +--8<-- "docs_src/subscriptions/tutorial006.py" +``` + +* `ctx.params`는 원시 요청이므로, 미들웨어가 직접 `SubscriptionsListenRequestParams`로 검증하고 클라이언트가 요청한 필터를 읽습니다. +* 거부는 `call_next(ctx)` 전에 `MCPError`를 발생시키는 것입니다. 클라이언트는 그 오류를 받고 스트림은 받지 못하며, 연결은 계속 유지됩니다. 메시지는 URI를 명시하지 않고 일관되게 유지하여, 거부가 어떤 URI가 보호되는지를 확인해 주는 일이 없도록 하세요. +* 하나의 `can_access(user, uri)`가 두 질문 모두에 답합니다. 리소스 핸들러는 `resources/read`에서, 미들웨어는 `subscriptions/listen`에서 이를 묻습니다. 테이블을 데이터베이스나 RBAC 시스템으로 바꿔도 둘은 계속 보조를 맞춥니다. +* 이 결정은 스트림의 수명 동안 유지됩니다. 이벤트마다 다시 확인하지 않으므로, 호출자의 접근 권한이 스트림 도중에 만료될 수 있다면(만료되는 토큰) 그 시점에 해당 호출자의 연결을 끊으세요. + +미들웨어가 그 밖에 무엇을 감싸는지, 왜 잠정적(provisional)으로 표시되어 있는지를 포함한 전체 미들웨어 계약은 **[미들웨어](../advanced/middleware.md)**에 있습니다. + +## 클라이언트 쪽 {#the-client-end} + +다음은 그 스트림의 반대편에서 보드를 따라가는 클라이언트입니다. + +```python title="client.py" hl_lines="15" +--8<-- "docs_src/subscriptions/tutorial003.py" +``` + +`client.listen(...)`에 진입하면 요청을 보내고 서버의 확인 응답을 기다리므로, 블록이 시작될 때 스트림은 이미 살아 있고, 타입이 지정된 각 이벤트는 다시 가져오라는 신호일 뿐 절대 페이로드가 아닙니다. 이것이 한 화면에 담긴 계약의 전부입니다. 주 흐름과 나란히 지켜보기, 스트림 종료, 다시 listen하기 등 클라이언트 쪽의 나머지 내용은 별도 페이지에 있습니다. **클라이언트** 아래의 **[구독](../client/subscriptions.md)**을 참고하세요. + +## 단일 프로세스를 넘어 확장하기 {#scaling-past-one-process} + +게시된 이벤트는 핸들러에서 열린 스트림까지 `SubscriptionBus`를 거쳐 이동합니다. 기본은 인메모리입니다. 프로세스 하나, 그 안의 모든 스트림입니다. 로드 밸런서 뒤에서 레플리카를 실행하기 전까지는 이것이 정답입니다. 그 이후에는 클라이언트의 스트림이 한 레플리카에 고정되고, 다른 레플리카에서 게시한 이벤트가 그 스트림에 도달해야 하기 때문입니다. + +그 이음매는 직접 구현할 부분입니다. 사용하는 pub/sub 백엔드 위에 메서드 두 개를 만들면 됩니다. + +```python +from collections.abc import Callable + +from redis.asyncio import Redis + +from mcp.server.mcpserver import MCPServer +from mcp.server.subscriptions import ServerEvent # SubscriptionBus is a Protocol: no base class + + +class RedisSubscriptionBus: + def __init__(self, redis: Redis) -> None: + self._redis = redis + self._listeners: dict[object, Callable[[ServerEvent], None]] = {} + + async def publish(self, event: ServerEvent) -> None: + await self._redis.publish("mcp-events", encode(event)) # to every replica + + def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]: + token = object() + self._listeners[token] = listener + + def unsubscribe() -> None: + self._listeners.pop(token, None) + + return unsubscribe + + +mcp = MCPServer("Sprint Board", subscriptions=RedisSubscriptionBus(redis)) +``` + +`encode`는 직접 작성하며, 도착하는 메시지를 디코드해 등록된 모든 리스너를 호출하는 각 레플리카의 리더 태스크도 마찬가지입니다. 리스너는 동기 함수이고, 예외를 발생시켜서는 안 되며, 서버의 이벤트 루프에서 실행됩니다. + +버스는 타입이 지정된 `ServerEvent` 값, 즉 작은 데이터클래스 네 개를 실어 나르며 JSON-RPC는 절대 나르지 않습니다. 스탬핑, 필터링, 스트림 생명 주기는 SDK에 남아 있으므로 버스 구현이 프로토콜을 깨뜨릴 수는 없습니다. 프로세스 사이에서 이벤트를 옮길 수 있을 뿐입니다. + +요청 바깥에서 게시하려면 참조를 보유할 수 있도록 버스를 직접 생성하세요. `MCPServer`는 아무것도 전달하지 않으면 내부적으로 하나를 만들며, 이를 노출하지 않습니다. + +```python +from mcp.server.subscriptions import InMemorySubscriptionBus, ToolsListChanged + +bus = InMemorySubscriptionBus() +mcp = MCPServer("Sprint Board", subscriptions=bus) + + +async def tools_reloaded() -> None: + await bus.publish(ToolsListChanged()) # from a lifespan task, a webhook, anywhere +``` + +## 저수준 구성 {#the-low-level-composition} + +저수준 `Server`에는 미리 연결된 것이 아무것도 없으며, 같은 부품이 세 줄로 조립됩니다. + +```python title="server.py" hl_lines="8-9 47" +--8<-- "docs_src/subscriptions/tutorial002.py" +``` + +* 버스를 직접 소유하므로 버스에 직접 게시합니다. `await bus.publish(ResourceUpdated(uri=...))`. 핸들러가 닿을 수 있는 곳에 두세요. 여기서는 모듈 스코프이고, 더 큰 앱에서는 lifespan입니다. +* `ListenHandler(bus)`는 `MCPServer`가 등록하는 것과 같은 핸들러이고, `on_subscriptions_listen=`은 평범한 핸들러 슬롯입니다. 다른 의미 체계를 원하면 그 슬롯에 직접 만든 callable을 넣으세요. 그러면 명세상의 의무가 작성자에게 넘어옵니다. 먼저 확인 응답을 보내고, 모든 프레임에 구독 id를 찍고, 필터 밖의 것은 아무것도 전달하지 않아야 합니다. +* `ListenHandler.close()`는 열린 스트림을 모두 정상적으로 종료합니다. 각 스트림은 마지막 프레임으로 listen 요청의 결과를 받으며, 이는 서버가 의도적으로 구독을 끝냈음을 나타내는 명세상의 방식입니다. 이 메서드는 스트림이 플러시를 마치기 전에 반환하므로, 트랜스포트를 해체하기 전에 잠깐 여유를 주세요. 이를 호출하지 않으면 스트림은 클라이언트가 연결을 끊을 때 끝납니다. + +## 요약 {#recap} + +* 클라이언트는 `subscriptions/listen` 요청 하나로 참여하고, 그 응답이 스트림입니다. 이를 처리하는 기능은 내장되어 있습니다. +* `ctx.notify_*`로 게시하면 스탬핑, 필터링, 생명 주기 작업은 SDK가 처리합니다. +* 이벤트는 페이로드가 아니라 신호입니다. 양쪽 모두 다시 가져옵니다. +* 클라이언트 쪽은 `async with client.listen(...)`입니다. 자세한 내용은 **클라이언트** 아래의 **[구독](../client/subscriptions.md)**에서 확인하세요. +* 저수준 `Server`에서는 같은 부품을 직접 조립합니다. 버스, `ListenHandler(bus)`, `on_subscriptions_listen` 슬롯입니다. +* 스케일 아웃은 메서드 두 개짜리 `SubscriptionBus`를 구현하고 `MCPServer(subscriptions=...)`로 전달하는 것을 뜻합니다. + +레플리카 하나 뒤에서든 스무 개 뒤에서든, 이 모든 것을 처리하는 서버를 실행하는 방법은 **[배포와 확장](../run/deploy.md)**에서 확인하세요. diff --git a/i18n/ko/pages/index.md b/i18n/ko/pages/index.md new file mode 100644 index 0000000000..06d21eb5d6 --- /dev/null +++ b/i18n/ko/pages/index.md @@ -0,0 +1,102 @@ +--- +translation: + sections: [154c4309937b9f85, 3ad8fc6caa76a9b0, a07f3f5b151ab746, bf6e476b712930c0, cf0b1f13978c6623] + tool: 1 +--- +# MCP Python SDK {#mcp-python-sdk} + +!!! info "현재 안정 릴리스 계열인 v2를 다루는 문서" + v2를 처음 접하거나 v1에서 넘어왔다면 **[v2에서 달라진 점](whats-new.md)**에서 바뀐 내용을 5분 만에 둘러볼 수 있고, **[마이그레이션 가이드](migration.md)**에서 호환성을 깨는 변경 사항을 빠짐없이 확인할 수 있습니다. + 아직 v1.x를 사용 중이라면 해당 버전의 문서는 [v1.x 문서](https://py.sdk.modelcontextprotocol.io/v1/)에서 볼 수 있습니다. + 매끄럽지 않거나 헷갈리는 부분이 있다면 [알려 주세요](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml). + +**Model Context Protocol(MCP)**은 애플리케이션이 표준화된 방식으로 LLM에 컨텍스트를 제공할 수 있게 해 주며, 컨텍스트를 **제공하는** 일을 LLM과의 상호작용 자체와 분리합니다. + +이 라이브러리가 바로 MCP의 공식 Python SDK입니다. 이 SDK로 다음과 같은 일을 할 수 있습니다. + +* 어떤 MCP 호스트에든 도구, 리소스, 프롬프트를 노출하는 **MCP 서버를 만듭니다**. +* 어떤 MCP 서버에든 연결하는 **MCP 클라이언트를 만듭니다**. +* 모든 표준 트랜스포트(stdio, Streamable HTTP, SSE)로 통신합니다. + +## 요구 사항 {#requirements} + +Python 3.10 이상이 필요합니다. + +## 설치 {#installation} + +=== "uv" + + ```bash + uv add "mcp[cli]" + ``` + +=== "pip" + + ```bash + pip install "mcp[cli]" + ``` + +`[cli]` extra는 `mcp` 명령을 제공하며, 개발할 때 이 명령을 쓰게 됩니다. +각 의존성의 용도는 [설치](get-started/installation.md)에서 확인하세요. + +## 예제 {#example} + +### 만들기 {#create-it} + +`server.py` 파일을 만드세요. + +```python title="server.py" +--8<-- "docs_src/index/tutorial001.py" +``` + +이것으로 완전한 MCP 서버가 완성됩니다. + +이 서버는 **도구** 하나(`add`)와 템플릿 **리소스** 하나(`greeting://{name}`)를 노출합니다. + +### 실행하기 {#run-it} + +```console +uv run mcp dev server.py +``` + +이 명령은 서버를 시작하고, 서버를 이것저것 눌러 볼 수 있는 대화형 UI인 [MCP Inspector](https://github.com/modelcontextprotocol/inspector)를 엽니다. 출력되는 URL을 여세요. + +!!! note + Inspector는 Node.js 앱이므로 `mcp dev`를 쓰려면 `PATH`에 `npx`가 있어야 합니다. + +### 직접 해 보기 {#try-it} + +Inspector에서 **Tools**로 이동해 `a=1`, `b=2` 값으로 `add`를 호출하세요. + +`3`이 돌아옵니다. + +Inspector는 타입 힌트를 바탕으로 그 입력 폼(`a`에 해당하는 필수 정수 필드 하나, `b`에 해당하는 필드 하나)을 만들었습니다. Claude도, 다른 모든 MCP 호스트도 똑같이 합니다. + +이제 **Resources**로 이동해 `greeting://World`를 읽어 보세요. + +```text +Hello, World! +``` + +### 요약 {#recap} + +작성하지 **않은** 것이 무엇인지 다시 살펴보세요. + +* JSON Schema가 없습니다. `a: int, b: int`가 **바로** 스키마입니다. +* 요청 파싱도, 직렬화도, 유효성 검사 코드도 없습니다. +* 프로토콜 처리는 전혀 없습니다. + +타입 힌트와 독스트링이 달린 Python 함수 두 개를 작성했을 뿐입니다. SDK가 나머지를 처리합니다. + +## 다음으로 살펴볼 곳 {#where-to-go-next} + +* **[시작하기](get-started/index.md)**는 설치에서 출발해 제대로 동작하고 테스트까지 마친 서버에 이르기까지 안내합니다. +* MCP 서버를 **사용하는** 애플리케이션을 만들고 있다면 **[클라이언트](client/index.md)**부터 시작하세요. +* 이미 FastAPI나 Starlette 앱이 있다면 **[기존 앱에 추가하기](run/asgi.md)**를 참고하세요. 그 앱 안에 MCP 서버를 마운트하는 방법을 다룹니다. +* 특정 오류 메시지를 추적하고 있다면 **[문제 해결](troubleshooting.md)**을 보세요. 오류 메시지 원문을 기준으로 정리되어 있습니다. +* v2에서 무엇이 바뀌었는지 궁금하다면 **[v2에서 달라진 점](whats-new.md)**에서 5분 만에 둘러볼 수 있습니다. +* v1에서 마이그레이션한다면 **[마이그레이션 가이드](migration.md)**부터 시작하세요. +* 정확한 시그니처를 찾고 있다면 소스 코드에서 생성된 **[API 레퍼런스](api/mcp/index.md)**를 보세요. +* LLM으로 이 문서를 읽고 있다면 [llms.txt](https://llmstxt.org/) 형식으로도 게시되어 있으니 참고하세요. + [llms.txt](https://py.sdk.modelcontextprotocol.io/llms.txt) 파일은 페이지 색인이고, + [llms-full.txt](https://py.sdk.modelcontextprotocol.io/llms-full.txt) 파일은 모든 페이지를 한 파일에 담고 있습니다. diff --git a/i18n/ko/pages/protocol-versions.md b/i18n/ko/pages/protocol-versions.md new file mode 100644 index 0000000000..033e968cea --- /dev/null +++ b/i18n/ko/pages/protocol-versions.md @@ -0,0 +1,132 @@ +--- +translation: + sections: [478fd619e5f90ef8, aef094a00e44e248, bab8cbf3449fa7e9, df1809b15a58335b, 5f9d8c2336ed0239, f54974398e43ddef, b24443dd78584870] + tool: 1 +--- +# 프로토콜 버전 {#protocol-versions} + +MCP에는 두 시대가 있습니다. + +2026-07-28 이전에 나온 서버는 모든 연결을 **`initialize` 핸드셰이크**로 시작합니다. 클라이언트가 버전을 제안하고, 서버가 다른 버전으로 답하고, 클라이언트가 이를 수락하는 과정이 첫 번째 실질적인 요청보다 앞서 모두 이루어집니다. **2026-07-28** 서버는 핸드셰이크를 없앴습니다. 클라이언트가 **`server/discover`** 프로브를 한 번 보내면 서버는 모든 것을 하나의 결과에 담아 답합니다. + +`Client`가 대신 협상하므로 신경 쓸 일은 거의 없습니다. 이 페이지는 이를 제어하는 단 하나의 생성자 인자인 `mode=`와 이 값을 바꾸게 되는 세 가지 경우를 다룹니다. + +## `mode="auto"` {#modeauto} + +```python title="client.py" hl_lines="14-15" +--8<-- "docs_src/protocol_versions/tutorial001.py" +``` + +`mode`를 전달하지 않았으므로 기본값인 `"auto"`가 적용됩니다. `async with`에 진입하면 이 SDK가 지원하는 가장 새 버전으로 `server/discover` 프로브를 한 번 보냅니다. 그다음은 다음과 같습니다. + +* **최신 서버**는 프로브에 응답합니다. 클라이언트는 그 결과를 채택합니다. 왕복 한 번으로 끝납니다. +* **오래된 서버**는 `server/discover`를 알지 못하므로 오류를 반환합니다. 클라이언트는 전통적인 `initialize` 핸드셰이크로 되돌아가 거기서 협상된 결과를 그대로 받아들입니다. + +어느 쪽이든 연결된 상태가 되며, `client.protocol_version`이 어느 경우였는지 알려 줍니다. + +```text +2026-07-28 +``` + +이것이 기능의 전부입니다. `Client` 하나로 어느 시대의 서버든 상대하며, 코드에 분기가 필요 없습니다. + +!!! info + `MCPServer`는 인메모리, stdio, Streamable HTTP 등 모든 트랜스포트에서 `server/discover`에 + 응답하므로, 직접 작성한 서버를 상대로는 `auto`가 항상 `2026-07-28`에 도달합니다. 폴백은 + 실제 2026년 이전 서버를 상대할 때만 발동하며, 바로 그때가 폴백이 필요한 순간입니다. + +## `mode="legacy"` {#modelegacy} + +```python title="client.py" hl_lines="14" +--8<-- "docs_src/protocol_versions/tutorial002.py" +``` + +`mode="legacy"`는 프로브를 보내지 않습니다. `initialize` 핸드셰이크를 실행하며, 이는 2026년 이전 클라이언트가 여는 것과 같은 연결입니다. + +```text +2025-11-25 +``` + +같은 서버입니다. 이 서버는 `2026-07-28`을 문제없이 지원하지만, 클라이언트에게 묻지 말라고 지시한 것입니다. + +이 모드는 **푸시 방식** 기능에 필요합니다. + +서버 시작 요청이란 서버가 **클라이언트를** 호출하는 것입니다. `ctx.elicit(...)`가 사용자 앞에 폼을 띄우거나, 샘플링이 도구 호출 도중에 클라이언트의 모델에 컴플리션을 요청하는 경우가 여기에 해당합니다. 이 채널은 핸드셰이크 시대의 세션에만 존재합니다. + +2026-07-28에서는 이 채널이 사라졌습니다. 서버는 질문을 **반환**하고, 클라이언트는 답을 담아 호출을 재시도합니다(**[다중 왕복 요청](handlers/multi-round-trip.md)**). + +`mode="auto"`는 서버가 너무 오래되어 다른 방법이 없을 때만 핸드셰이크를 합니다. `mode="legacy"`는 핸드셰이크를 보장합니다. `Client(...)`에 `sampling_callback`, 요청으로 구동되기를 원하는 `elicitation_callback`, 또는 `message_handler`를 넘길 때마다 이 모드를 사용하세요. 각각은 **[클라이언트 콜백](client/callbacks.md)**에서 다룹니다. + +## 버전 고정 {#pinning-a-version} + +`mode`에는 최신 프로토콜 버전 문자열도 넣을 수 있습니다. 현재 그 집합은 정확히 `["2026-07-28"]`입니다. + +```python title="client.py" hl_lines="14" +--8<-- "docs_src/protocol_versions/tutorial003.py" +``` + +버전을 고정하면 **아무것도** 보내지 않습니다. 프로브도, 핸드셰이크도 없습니다. 클라이언트는 로컬에서 `2026-07-28`을 채택하고, `async with`가 반환되는 순간 연결이 살아 있습니다. + +버전 고정은 **개발자가** 하는 약속입니다. 서버가 해당 버전을 지원한다는 것을 이미 알고 있다는 약속이며, 클라이언트는 이를 확인하지 않습니다. + +!!! check + 버전 고정은 디스커버리가 아닙니다. `client.server_info`를 출력해 보면 그 대가가 바로 드러납니다. + + ```text + None + ``` + + 클라이언트가 서버에게 정체를 물은 적이 없으므로 `server_info`는 `None`입니다. `client.server_capabilities`도 + 마찬가지로 모든 기능이 `None`입니다. 도구 호출은 여전히 동작하지만(프로토콜은 이 정보가 전혀 필요 없습니다), + `server_capabilities`를 읽어 무엇을 제공할지 결정하는 코드는 동작하지 않습니다. + + 해결책은 다음 절에 있습니다. + +고정할 수 있는 것은 최신 버전뿐입니다. 핸드셰이크 시대의 문자열은 어떤 I/O도 일어나기 전인 생성 시점에 거부되며, 오류 메시지가 대신 무엇을 써야 하는지 알려 줍니다. + +```text +ValueError: mode must be 'legacy', 'auto', or one of ['2026-07-28']; got '2025-06-18' ('2025-06-18' is a handshake-era version; use mode='legacy') +``` + +## `prior_discover`로 다시 연결하기 {#reconnecting-with-prior_discover} + +프로브는 가볍지만, 다시 연결할 때마다 치러야 하는 왕복인 것은 변함없고, 그 답은 거의 바뀌지 않습니다. + +그러니 보관해 두세요. `auto` 연결 후 `client.session.discover_result`에는 서버가 보낸 `DiscoverResult`가 그대로 담겨 있습니다. `supported_versions`, `capabilities`, `instructions`, 그리고 서버가 결과의 `_meta`에 새겨 넣은 신원 정보까지 포함됩니다. 다음번에는 이를 `prior_discover=`로 다시 넘기세요. + +```python title="client.py" hl_lines="15 17" +--8<-- "docs_src/protocol_versions/tutorial004.py" +``` + +```text +2026-07-28 +Bookshop +``` + +두 번째 연결은 협상 왕복을 **한 번도** 하지 않았으면서도 상대가 누구인지 정확히 알고 있습니다. 이것이 고정 모드를 제대로 쓰는 방법입니다. `mode=`가 버전을 지정하고, `prior_discover=`가 신원 정보를 제공합니다. + +`DiscoverResult`는 Pydantic 모델입니다. `saved.model_dump_json()`의 결과는 파일이나 캐시에 저장하고, 다음 프로세스에서 `DiscoverResult.model_validate_json(...)`으로 되살립니다. + +!!! tip + `prior_discover=`는 `mode`가 버전 고정일 때만 효과가 있습니다. `"auto"`에서는 클라이언트가 + 어차피 서버에 프로브를 보내고, `"legacy"`에서는 무시됩니다. + +## 네 가지 모드 {#the-four-modes} + +| 작성하는 코드 | 협상 트래픽 | 결과 | +| --- | --- | --- | +| `Client(target)` | `server/discover` 프로브 한 번, 실패하면 `initialize` 핸드셰이크 | 시대와 관계없이 양쪽이 모두 지원하는 가장 새 버전 | +| `Client(target, mode="legacy")` | `initialize` 핸드셰이크 | 핸드셰이크 시대 버전, 서버 시작 요청이 동작함 | +| `Client(target, mode="2026-07-28")` | 없음 | 해당 버전으로 고정, `server_info`는 `None` | +| `Client(target, mode="2026-07-28", prior_discover=saved)` | 없음 | 해당 버전으로 고정, **그리고** 지난번에 저장한 신원 정보 | + +## 요약 {#recap} + +* MCP에는 핸드셰이크 시대(`2025-11-25`까지, `initialize` 핸드셰이크)와 최신 시대(`2026-07-28`, `server/discover`)가 있습니다. `Client`가 둘 사이를 이어 줍니다. +* `mode="auto"`가 기본값이며, 프로브를 보내고 실패하면 폴백합니다. 나머지 세 행 중 하나에 해당하지 않는 한 그대로 두세요. +* "무엇을 얻었는가?"에 대한 답은 언제나 `client.protocol_version`입니다. +* `mode="legacy"`는 핸드셰이크를 강제합니다. 샘플링, 푸시 엘리시테이션(elicitation), `message_handler` 같은 서버 시작 요청에 필요한 모드입니다. +* 버전 고정(`mode="2026-07-28"`)은 협상 트래픽을 전혀 보내지 않는 대신 `client.server_info`가 `None`이 됩니다. +* `prior_discover=`가 그 대가를 되돌려 줍니다. `client.session.discover_result`를 저장해 두었다가 그 값으로 다시 연결하면 둘 다 얻습니다. + +최신 연결에는 푸시 채널이 없습니다. 그렇다면 2026 서버는 호출 도중 어떻게 질문합니까? 질문을 반환합니다. 자세한 내용은 **[다중 왕복 요청](handlers/multi-round-trip.md)**에서 확인하세요. diff --git a/i18n/ko/pages/run/asgi.md b/i18n/ko/pages/run/asgi.md new file mode 100644 index 0000000000..1c1795f046 --- /dev/null +++ b/i18n/ko/pages/run/asgi.md @@ -0,0 +1,144 @@ +--- +translation: + sections: [1062ef792791488a, 4be2b831547184a9, 374b049e770385f2, b72f6947089e6de0, b172c9db7831bb31, 70b9ece244ca1b0c, cba78e052898c3f6, f06bdb541cb0b469, fb82d526320b7cc3] + tool: 1 +--- +# 기존 앱에 추가하기 {#add-to-an-existing-app} + +`mcp.run("streamable-http")`는 웹 서버를 대신 띄워 줍니다. 하지만 그걸 원하지 않을 때도 있습니다. MCP 서버가 더 큰 웹 애플리케이션의 한 부분이거나, 이미 ASGI 배포 환경이 있는 경우입니다. + +이럴 때 `mcp.streamable_http_app()`은 **Starlette 애플리케이션**을 반환합니다. + +Starlette 앱은 ASGI 앱이므로, ASGI를 호스팅할 수 있는 것이라면 무엇이든(uvicorn, Hypercorn, 또 다른 Starlette, FastAPI) MCP 서버를 호스팅할 수 있습니다. + +## 앱 {#the-app} + +```python title="server.py" hl_lines="12" +--8<-- "docs_src/asgi/tutorial001.py" +``` + +`app`은 평범한 ASGI 애플리케이션입니다. 아무 ASGI 서버에나 넘기면 됩니다. + +```console +uvicorn server:app +``` + +MCP 엔드포인트는 `/mcp`에 있으므로, 클라이언트는 `http://127.0.0.1:8000/mcp`에 연결합니다. + +이 앱은 이미 두 가지를 갖추고 있습니다. + +* 라우트 하나, `/mcp`: Streamable HTTP 엔드포인트입니다. +* `mcp.session_manager`를 시작하는 **lifespan**: 살아 있는 모든 세션의 백그라운드 작업을 소유하는 객체입니다. + +앱을 단독으로 실행하면(`uvicorn server:app`) 둘 다 신경 쓸 일이 없습니다. + +!!! tip + `streamable_http_app()`은 `mcp.run("streamable-http", ...)`과 같은 키워드 인자를 받되, + `port`만 빠집니다. 포트는 앱을 서빙하는 쪽의 몫이기 때문입니다. `host`는 여전히 받지만 + 여기서는 아무것도 바인딩하지 않습니다. 이 값이 실제로 무엇을 제어하는지는 **[배포와 확장](deploy.md)**에서 설명합니다. + 옵션 자체는 **[서버 실행하기](index.md)**에서 다룹니다. + +`mcp.sse_app()`은 이제 대체된 SSE 트랜스포트에 대해 같은 일을 합니다. + +## 별도로 지정하기 전까지는 localhost 전용 {#localhost-only-until-you-say-otherwise} + +기본적으로 이 앱은 localhost로 오는 요청**만** 받습니다. `streamable_http_app()`은 +자신이 어떤 호스트 이름 뒤에서 서빙될지 알 수 없으므로, 가능한 한 가장 안전한 허용 목록으로 DNS 리바인딩 보호를 +켭니다. 개발 머신에서는 이 설정이 정확히 맞습니다. 실제 호스트 이름 뒤에 배포하면, +실제로 서빙하는 대상의 허용 목록을 `transport_security=`로 넘기기 전까지 **모든 요청이 `421 Misdirected Request`로 거부됩니다**. +작성한 코드는 아예 참조되지도 않습니다. 이 허용 목록을 비롯해, 동작하는 앱과 실제 호스트 이름 사이에 있는 모든 것은 +**[배포와 확장](deploy.md)**에서 다룹니다. + +## 마운트하기 {#mounting-it} + +MCP 서버가 더 큰 애플리케이션의 **일부**가 되는 순간, 앱을 `Mount` 안에 넣게 됩니다. 그리고 그렇게 하는 순간 lifespan은 직접 챙겨야 할 일이 됩니다. + +```python title="server.py" hl_lines="18-21 25-26" +--8<-- "docs_src/asgi/tutorial002.py" +``` + +* `Mount("/", ...)`에 기본 경로 `/mcp`가 더해져 엔드포인트는 `/mcp`에 그대로 유지됩니다. Starlette는 라우트를 순서대로 시도하고 `Mount("/")`는 **모든** 경로와 매칭되므로, 직접 만든 라우트는 목록에서 그 **앞에** 두어야 합니다. 뒤에 오는 것은 무엇이든 도달할 수 없습니다. +* `lifespan` 함수는 **호스트** 앱이 살아 있는 동안 `mcp.session_manager.run()`에 진입합니다. 다들 잊어버리는 줄이 바로 이것입니다. +* `mcp.session_manager`는 `streamable_http_app()`이 호출된 **뒤에야** 존재합니다. 그래서 라우트는 모듈 수준에서 만들고, 매니저는 lifespan 안에서만 건드립니다. + +Starlette의 `Host` 라우트도 같은 방식으로 동작합니다. 경로 대신 호스트 이름으로 라우팅하려면 `Mount("/", ...)`를 `Host("mcp.example.com", ...)`로 바꾸세요. lifespan 규칙은 달라지지 않으며, 트랜스포트 보안 규칙도 마찬가지입니다. `Host("mcp.example.com", ...)` 라우트는 해당 호스트 이름으로 오는 요청만 받지만, 트랜스포트 자체의 Host 허용 목록(**[배포와 확장](deploy.md)**)이 여전히 먼저 실행됩니다. 그 목록에 `"mcp.example.com"`이 없으면, 이 라우트는 모든 요청에 `421`로 응답합니다. + +!!! warning "lifespan은 호스트 앱의 소유입니다" + `streamable_http_app()`은 반환하는 Starlette의 lifespan에 `session_manager.run()`을 연결해 두지만, + **마운트된 하위 애플리케이션의 lifespan은 절대 실행되지 않습니다**. 앱을 마운트하면 + 내장된 lifespan은 죽은 코드가 됩니다. ASGI 스택의 맨 위에 있는 앱이 무엇이든, 그 앱이 자신의 lifespan에서 + `mcp.session_manager.run()`에 진입해야 합니다. + +!!! check + `lifespan=lifespan` 줄을 지우고 서버를 시작해 보세요. 시작됩니다. 라우트도 해석됩니다. + 그런데 `/mcp`로 가는 첫 요청이 다음과 같이 실패합니다. + + ```text + RuntimeError: Task group is not initialized. Make sure to use run(). + ``` + + 세션 매니저를 시작하는 것은 `run()`뿐입니다. + +## 서버 둘, 앱 하나 {#two-servers-one-app} + +각 `MCPServer`는 자체 세션 매니저를 가진 독립된 앱입니다. 원하는 만큼 마운트하고, 하나의 호스트 lifespan에서 모든 매니저에 진입하세요. + +```python title="server.py" hl_lines="27-30 35-36" +--8<-- "docs_src/asgi/tutorial003.py" +``` + +* `AsyncExitStack`이 두 매니저에 모두 진입합니다. 함께 시작하고 역순으로 종료됩니다. +* 엔드포인트는 `/notes/mcp`와 `/tasks/mcp`입니다. 마운트 접두사에 기본 경로를 더한 것입니다. + +## 경로 바꾸기 {#changing-the-path} + +끝에 붙는 `/mcp`는 `streamable_http_path`입니다. 이 값을 `"/"`로 설정하면 마운트 접두사가 공개 경로 전체가 됩니다. + +```python title="server.py" hl_lines="25" +--8<-- "docs_src/asgi/tutorial004.py" +``` + +이제 클라이언트는 `/notes/mcp`가 아니라 `/notes`에 연결합니다. + +## 브라우저 클라이언트를 위한 CORS {#cors-for-browser-clients} + +브라우저 기반 클라이언트에는 두 가지 허가가 필요합니다. MCP 요청 헤더를 **보내는** 허가와, MCP가 돌려보내는 헤더를 **읽는** 허가입니다. 둘 다 호스트 앱의 CORS 설정이며, 위의 트랜스포트 보안 허용 목록도 이와 일치해야 합니다. + +```python title="server.py" hl_lines="27-30 33 35-49" +--8<-- "docs_src/asgi/tutorial005.py" +``` + +* `allow_headers`는 다들 잊어버리는 절반입니다. `Content-Type: application/json`과 `Mcp-*` 요청 헤더는 CORS 안전 목록에 없기 때문에 브라우저는 모든 MCP 요청에 대해 **프리플라이트**를 수행하고, 프리플라이트가 허용하지 않은 헤더가 있으면 브라우저는 그 요청을 아예 보내지 않습니다. (`allow_headers=["*"]`도 동작합니다. Starlette는 프리플라이트가 요청한 것을 그대로 응답합니다.) +* `expose_headers=["Mcp-Session-Id"]`는 읽는 쪽 절반입니다. Streamable HTTP는 세션 ID를 이 응답 헤더로 돌려주며, 브라우저는 CORS가 이름으로 노출하지 않는 한 응답 헤더를 JavaScript로부터 숨깁니다. 이것이 없으면 클라이언트는 두 번째 요청을 결코 보낼 수 없습니다. +* `allow_origins`는 MCP가 아니라 직접 결정할 사항입니다. 정확하게 지정하고, 위의 `allowed_origins=`에도 똑같이 반영하세요. CORS는 브라우저가 강제하지만 서버도 `Origin`을 직접 검사하므로, 트랜스포트가 신뢰하지 않는 오리진은 프리플라이트를 깔끔하게 통과한 뒤에도 `403`을 받습니다. +* `allow_methods`는 Streamable HTTP가 쓰는 세 가지 메서드를 나열합니다. 메시지를 보내는 `POST`, 서버에서 클라이언트로 가는 스트림을 여는 `GET`, 세션을 끝내는 `DELETE`입니다. + +## 커스텀 라우트 {#custom-routes} + +`@mcp.custom_route()`는 같은 앱에 평범한 HTTP 엔드포인트를 등록합니다. 배포된 모든 서비스에 필요하지만 MCP와는 무관한 것, 이를테면 헬스 체크나 OAuth 콜백을 위한 것입니다. + +```python title="server.py" hl_lines="15-17" +--8<-- "docs_src/asgi/tutorial006.py" +``` + +* 핸들러는 평범한 Starlette입니다. `Request`를 받아 `Response`를 돌려주는 `async` 함수입니다. +* `streamable_http_app()`은 모든 커스텀 라우트를 가져갑니다. 이제 `app.routes`는 `/mcp`와 `/health`입니다. +* `GET /health`는 MCP와 전혀 상관없이 `{"status": "ok"}`로 응답합니다. + +!!! warning + 커스텀 라우트는 서버의 나머지 부분이 인증되더라도 **절대 인증되지 않습니다**. 이는 + 의도된 것입니다. 헬스 체크와 OAuth 콜백은 토큰이 존재하기 전에도 도달할 수 있어야 하기 때문입니다. + 비공개인 것은 그 뒤에 두지 마세요. + +## 요약 {#recap} + +* `mcp.streamable_http_app()`은 라우트 하나(`/mcp`)를 가진 Starlette 앱을 반환합니다. 어떤 ASGI 서버로든 실행할 수 있습니다. +* 기본적으로 이 앱은 localhost로 오는 요청만 받으며, 실제 호스트 이름 뒤에서는 `transport_security=`로 허용 목록을 넘기기 전까지 모든 요청을 `421`로 거부합니다. 이 부분과 프로덕션까지의 나머지 여정은 **[배포와 확장](deploy.md)**에서 다룹니다. +* `Mount`(또는 `Host`)로 더 큰 Starlette나 FastAPI 앱 안에 넣습니다. +* **마운트하면 내장 lifespan이 비활성화됩니다.** 호스트 앱의 lifespan이 `mcp.session_manager.run()`에 진입해야 하며, 그러지 않으면 첫 요청이 실패합니다. +* 한 앱에 여러 서버를 두려면 마운트를 여러 개 하고, 모든 세션 매니저에 진입하는 lifespan 하나를 둡니다. +* `streamable_http_path="/"`는 엔드포인트를 마운트 접두사 자체로 옮깁니다. +* 브라우저 클라이언트에는 CORS가 필요합니다. `Mcp-*` 요청 헤더를 위한 `allow_headers`, 응답을 위한 `expose_headers=["Mcp-Session-Id"]`입니다. +* `@mcp.custom_route()`는 `/mcp` 옆에 인증되지 않는 평범한 HTTP 엔드포인트를 추가합니다. + +서버가 실제 URL로 도달 가능해지면, **[클라이언트](../client/index.md)**는 서버 객체 대신 그 URL로 연결합니다. diff --git a/i18n/ko/pages/run/authorization.md b/i18n/ko/pages/run/authorization.md new file mode 100644 index 0000000000..3e2e68a83a --- /dev/null +++ b/i18n/ko/pages/run/authorization.md @@ -0,0 +1,130 @@ +--- +translation: + sections: [d62c13457fc4a534, 80e73abaca6e0652, d1dc4c54cd00ec9c, 14ad3bc7904036bb, 5225f127bc1b9c77, fe1626fdd5aad1da, 4556cb7ea1a04a31] + tool: 1 +--- +# 인가 {#authorization} + +Streamable HTTP에서 MCP 서버는 평범한 웹 서비스이며, 다른 웹 서비스와 똑같은 방식으로 보호합니다. 바로 OAuth 2.1 bearer 토큰입니다. + +OAuth 용어로 말하면 서버는 **리소스 서버**입니다. 누구도 로그인시키지 않고 토큰을 발급하지도 않습니다. 하는 일은 단 하나, 각 요청의 `Authorization` 헤더를 보고 그 안의 토큰이 유효한지 판단하는 것입니다. + +이 페이지는 서버 쪽을 다룹니다. 인가 서버를 찾아내고 토큰을 가져오는 클라이언트는 **[OAuth 클라이언트](../client/oauth-clients.md)**에서 확인하세요. + +## 세 당사자 {#the-three-parties} + +* **인가 서버**는 사용자를 로그인시키고 액세스 토큰을 발급합니다. 직접 작성하는 것이 아닙니다. ID 제공자(Auth0, Keycloak, Entra, 자체 구축한 것)가 이 역할을 합니다. +* **리소스 서버**는 MCP 서버입니다. 모든 요청에서 토큰을 검증합니다. +* **클라이언트**는 서버가 신뢰하는 인가 서버가 어디인지 찾아내고, 거기서 토큰을 받아 `Authorization: Bearer `으로 서버에 보냅니다. + +삼각형은 이것이 전부입니다. 이 페이지의 모든 내용은 가운데 항목에 관한 것입니다. + +## 토큰 검증기 {#a-token-verifier} + +SDK는 유효한 토큰이 어떤 모습인지에 대해 아무런 의견이 없습니다. **`TokenVerifier`**를 구현해서 알려 주면 됩니다. + +```python title="server.py" hl_lines="12-14 19-24" +--8<-- "docs_src/authorization/tutorial001.py" +``` + +* `TokenVerifier`는 async 메서드 하나를 가진 프로토콜입니다. `verify_token`은 `Authorization` 헤더에서 꺼낸 원시 토큰을 받아, 유효하면 **`AccessToken`**을, 유효하지 않으면 `None`을 반환합니다. 그 외에 구현할 것은 없습니다. +* 이 예제는 테이블에서 토큰을 조회합니다. 실제 구현은 JWT 서명을 검증하거나 인가 서버의 토큰 인트로스펙션 엔드포인트를 호출합니다. 그 코드는 직접 작성하는 것이고, SDK는 호출만 합니다. +* `token_verifier=`와 `auth=`는 항상 함께 다닙니다. 한쪽만 전달하면 `MCPServer(...)`가 요청을 하나도 처리하기 전에 `ValueError`를 발생시킵니다. + +`AuthSettings`는 리소스 서버의 공개 정보입니다. + +* `issuer_url`: 토큰을 발급하는 인가 서버입니다. +* `resource_server_url`: 이 MCP 엔드포인트의 공개 URL입니다. 토큰이 **어떤** 리소스를 위한 것인지 지칭하며, 디스커버리 문서가 위치하는 곳이기도 합니다. +* `required_scopes`: 모든 토큰이 이 스코프를 전부 가지고 있어야 합니다. + +!!! tip + SDK 저장소의 `examples/servers/simple-auth/`에는 실제 인가 서버의 + [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662) 엔드포인트를 호출하는 `IntrospectionTokenVerifier`가 있습니다. 대부분의 프로덕션 검증기가 취하는 형태입니다. + +## HTTP에서 얻는 것 {#what-you-get-over-http} + +인가는 HTTP 헤더에 있으므로 HTTP 트랜스포트에서만 존재합니다. 배포할 트랜스포트로 실행하세요. `mcp.run(transport="streamable-http")`는 서버를 `http://127.0.0.1:8000/mcp`에 올리며, 나머지는 **[서버 실행하기](index.md)**에서 확인하세요. 이제 앱에는 라우트가 두 개 있습니다. + +```text +/mcp +/.well-known/oauth-protected-resource/mcp +``` + +등록한 도구는 하나입니다. 두 번째 라우트는 SDK가 만든 것입니다. + +### 디스커버리 {#discovery} + +이 well-known 경로에 `GET` 요청을 보내면 `AuthSettings`에서 곧바로 만들어진 **[RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) Protected Resource Metadata**가 돌아옵니다. + +```json +{ + "resource": "http://127.0.0.1:8000/mcp", + "authorization_servers": ["https://auth.example.com/"], + "scopes_supported": ["notes:read"], + "bearer_methods_supported": ["header"] +} +``` + +서버를 전혀 모르는 클라이언트가 들어오는 길을 찾는 수단이 바로 이 문서입니다. `authorization_servers`를 읽고 그곳에서 토큰을 받아옵니다. 이 문서는 한 줄도 직접 작성하지 않았습니다. + +!!! check + 토큰 없이(또는 검증기가 `None`을 반환한 토큰으로) `/mcp`를 호출하면 요청은 + 문 앞에서 차단됩니다. + + ```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"} + ``` + + 아무것도 파싱되지 않았고 어떤 도구도 실행되지 않았습니다. 그리고 `WWW-Authenticate`의 `resource_metadata` + 포인터가 디스커버리를 자동으로 만들어 줍니다. 401 -> 메타데이터 문서 -> 인가 서버 -> 토큰 -> 재시도 순서입니다. + +!!! warning + 이 중 어느 것도 `stdio`를 보호하지 않습니다. 파이프에는 `Authorization` 헤더가 없으므로 + `token_verifier`는 거기서 전혀 호출되지 않습니다. `stdio` 서버의 보안 경계는 서버를 실행한 프로세스입니다. + 테스트에서 사용하는 인메모리 `Client(mcp)`도 마찬가지입니다. 서버 객체에 직접 연결하여 + 인가를 포함한 HTTP 계층을 건너뜁니다. + +## 호출자의 신원 {#the-callers-identity} + +어떤 핸들러 안에서든 **`get_access_token()`**은 현재 요청에 대해 검증기가 반환한 `AccessToken`입니다. + +```python title="server.py" hl_lines="4 32-35" +--8<-- "docs_src/authorization/tutorial002.py" +``` + +* 도구, 리소스, 프롬프트에서 모두 동작하며 전달해야 할 것은 아무것도 없습니다. 인증 미들웨어가 요청마다 컨텍스트 변수에 저장합니다. +* **검증기가 만든 것과 동일한 객체**가 돌아옵니다. `client_id`, `scopes`, `subject`, `expires_at`, 그리고 덧붙인 추가 `claims`까지 그대로입니다. 도구별 규칙을 걸 지점이 바로 여기입니다. 스코프를 읽고 거부하면 됩니다. +* 인증된 HTTP 요청 밖에서는 `None`을 반환합니다. 인메모리와 `stdio`에서는 항상 `None`입니다. + +`Authorization: Bearer alice-token`으로 `whoami`를 호출하면 모델은 다음을 읽습니다. + +```text +alice (scopes: notes:read) +``` + +## SDK가 하지 않는 절반 {#the-half-the-sdk-doesnt-do} + +SDK는 리소스 서버 쪽 절반을 제공합니다. 검증하고, 알리고, 거부합니다. 로그인 페이지, 동의 화면, 토큰은 제공하지 않습니다. + +세 당사자가 모두 움직이는 모습을 보려면 SDK 저장소의 `examples/servers/simple-auth/`(작은 인가 서버와 이 페이지와 똑같이 설정된 리소스 서버)를 실행한 다음, `examples/clients/simple-auth-client/`를 그 서버로 연결해 디스커버리부터 토큰까지의 전체 흐름을 확인하세요. + +!!! info + 두 번째 생성자 인자인 `auth_server_provider=`는 MCP 서버 안에 완전한 인가 서버를 + 내장합니다. MCP 인가 사양의 근간인 AS/RS 분리가 도입되기 전에 만들어진 것입니다. + 새 서버에서는 사용하지 않아야 합니다. + +인가 서버는 사용자가 동의 화면을 클릭하는 대신 기업 ID 제공자의 서명된 어설션을 받을 수도 있으며, SDK는 이 교환의 양쪽을 모두 지원합니다. 이 그랜트와 이를 제시하는 클라이언트는 **[ID 어설션](../client/identity-assertion.md)**에서 확인하세요. + +## 요약 {#recap} + +* Streamable HTTP에서 서버는 OAuth 2.1 **리소스 서버**입니다. 토큰을 검증할 뿐, 결코 발급하지 않습니다. +* `TokenVerifier`가 통합 지점의 전부입니다. async 메서드 하나에 토큰이 들어가고 `AccessToken | None`이 나옵니다. +* `token_verifier=`와 `auth=AuthSettings(issuer_url=..., resource_server_url=..., required_scopes=[...])`는 항상 함께 다닙니다. +* SDK는 `/.well-known/oauth-protected-resource/...`에 [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) Protected Resource Metadata를 게시하고, 인증되지 않은 요청에는 이 문서를 가리키는 `WWW-Authenticate` 헤더가 담긴 401로 응답합니다. 디스커버리는 이것이 전부입니다. +* 어떤 핸들러에서든 `get_access_token()`이 곧 호출자입니다. +* 인가는 HTTP의 관심사입니다. `stdio`와 인메모리 클라이언트에서는 인가가 전혀 보이지 않습니다. + +클라이언트 쪽 절반(인가 서버를 찾아내고 토큰을 대신 가져오는 일)은 **[OAuth 클라이언트](../client/oauth-clients.md)**에서 확인하세요. 그리고 사용자에게 신원을 묻는 대신 신원을 **어설션**하는 클라이언트는 **[ID 어설션](../client/identity-assertion.md)**에서 확인하세요. diff --git a/i18n/ko/pages/run/deploy.md b/i18n/ko/pages/run/deploy.md new file mode 100644 index 0000000000..8c3384bad1 --- /dev/null +++ b/i18n/ko/pages/run/deploy.md @@ -0,0 +1,179 @@ +--- +translation: + sections: [28221886b198784f, f88ea1f1614f3a1d, ce926d686730b6d0, 3be24f8ad8bb5ab9, 3fad24032b2224ff, f25a7f860e579ecb, e758745df6fb7b0a] + tool: 1 +--- +# 배포와 확장 {#deploy-scale} + +서버는 잘 동작합니다. 이제 실제 호스트명이 필요하고, 그 뒤에 워커도 둘 이상 두어야 합니다. + +그중 MCP가 관여하는 부분은 거의 없습니다. ASGI 서버, 프로세스 매니저, 로드 밸런서는 직접 준비합니다. 이 페이지가 다루는 것은 MCP가 **실제로** 관여하는 몇 가지뿐입니다. 모든 배포의 관문이 되는 설정 하나, 그리고 "워커가 둘 이상"일 때 SDK의 동작이 달라지는 두 곳입니다. + +## 가장 먼저: Host 허용 목록 {#before-anything-else-the-host-allowlist} + +`streamable_http_app()`은 어떤 호스트명 뒤에서 서비스될지 알 수 없으므로 가장 안전한 답인 localhost를 가정합니다. `transport_security=` 인자를 지정하지 않으면 앱은 **DNS 리바인딩 보호**를 켜고, `Host` 헤더가 `127.0.0.1:`, `localhost:`, `[::1]:` 중 하나일 때만 요청을 받습니다. `Origin` 헤더가 있다면 같은 값의 `http://` 형태여야 합니다. 개발 머신에서는 이것이 정확히 맞는 동작입니다. 악성 웹 페이지가 `127.0.0.1`로 리바인딩한 DNS 이름을 통해 로컬 서버를 조종하지 못하게 막아 줍니다. + +실제 호스트명 뒤에 배포하면 바로 그 기본값이 별도로 지정하기 전까지 **모든 요청**을 거부합니다. 이 검사는 MCP와 관련된 어떤 처리보다 먼저 실행되므로, 작성한 코드는 참조조차 되지 않습니다. + +```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=`입니다. 실제로 서비스하는 것을 허용 목록에 넣으세요. + +```python title="server.py" hl_lines="2 13-17" +--8<-- "docs_src/deploy/tutorial001.py" +``` + +* `allowed_hosts` 항목은 정확히 일치하는 문자열입니다. `"mcp.example.com"`은 포트 없는 `Host` 헤더와 일치하고, `"mcp.example.com:*"` 형태는 모든 포트와 일치합니다. 둘 다 나열하세요. +* `allowed_origins`는 브라우저에만 의미가 있습니다. 다른 것은 `Origin`을 보내지 않기 때문입니다. 이는 **[기존 앱에 추가하기](asgi.md)**에서 다루는 CORS 설정과 짝을 이루는 서버 측 설정입니다. +* `Host` 헤더를 이미 통제하는 리버스 프록시 뒤에서는 `TransportSecuritySettings(enable_dns_rebinding_protection=False)`로 검사를 끄는 것이 정직한 설정입니다. +* localhost가 아닌 `host=` 값(예: `host="mcp.example.com"`)을 전달해도 그 호스트명이 허용 목록에 들어가지 **않습니다**. localhost 기본값이 보호를 활성화하지 않게 할 뿐이며, 그러면 모든 Host와 Origin이 허용됩니다. 대신 `transport_security=` 인자로 의도를 명확히 지정하세요. + +!!! check + `transport_security=security` 인자를 지우고 앱을 그대로 배포해 보세요. 앱은 시작되고 `/mcp`도 + 라우팅되지만, 모든 요청(평범한 `curl`도 포함)이 다음과 같이 돌아옵니다. + + ```text + HTTP/1.1 421 Misdirected Request + + Invalid Host header + ``` + + 클라이언트 쪽에서는 이 문구를 볼 수 없습니다. `421`은 JSON-RPC 오류가 아니라 평문 HTTP 응답이므로 + MCP 클라이언트는 일반적인 트랜스포트 오류를 발생시키고, 거부된 호스트명은 **서버** 로그에 + 경고 한 줄로만 나타납니다. 새로 배포한 서버가 모든 연결을 거부한다면, 달리 밝혀지기 전까지는 + Host 허용 목록 문제입니다. **[문제 해결](../troubleshooting.md)**도 여기서 시작합니다. + +## 워커, 그리고 스티키가 필요한 쪽 {#workers-and-who-has-to-be-sticky} + +호스트명이 응답하기 시작했다면 그 뒤에 워커를 둘 이상 두세요. 이를 위한 SDK 설정은 없습니다. Starlette 앱은 다른 ASGI 앱과 똑같이, 포크할 줄 아는 도구에 객체를 넘겨서 확장합니다. + +```console +uvicorn server:app --workers 4 +``` + +프로세스 네 개, 소켓 하나. 이제 모든 배포가 답해야 하는 질문은 **요청이 직전 요청을 받았던 바로 그 워커에 도달해야 하는가**입니다. + +**2026-07-28** 프로토콜을 사용하는 클라이언트라면 그럴 필요가 없습니다. 최신 방식의 요청은 독립된 POST 하나입니다. 그 앞에 `initialize` 핸드셰이크도 없고, 응답에 `Mcp-Session-Id`도 없으며, 두 번째 요청이 되돌아갈 **대상** 자체가 없습니다. 어느 워커로 보내도 됩니다. + +이것은 켜는 모드가 아닙니다. `stateless_http=True`가 그런 스위치처럼 보이지만, 트랜스포트는 `MCP-Protocol-Version` 요청 헤더로 라우팅하여 최신 요청을 최신 핸들러에 넘기고 **반환합니다**. `stateless_http`를 읽는 줄은 그 반환 **뒤에** 있습니다. 2026-07-28 경로에서 이 플래그가 무시되는 것이 아니라 아예 도달하지 않는 것입니다. `stateless_http`는 **레거시** 경로 전용 설정이며, 최신 경로는 구조상 세션이 없습니다. + +사양 버전 2025-11-25 이하의 레거시 클라이언트라면 답은 그 플래그에 따라 달라집니다. + +| 클라이언트의 프로토콜 버전 | 세션 | 로드 밸런서가 해야 할 일 | +| --- | --- | --- | +| **2026-07-28** | 없음. `Mcp-Session-Id`는 설정되지 않습니다. | 없음. 어느 워커든 어느 요청이든 처리합니다. | +| **2025-11-25 이하**(기본값) | `Mcp-Session-Id`, 한 워커의 메모리에 보관됩니다. | **스티키 세션.** 후속 요청이 다른 워커에 도달하면 `404` *"Session not found"*를 받습니다. | +| **2025-11-25 이하**, `stateless_http=True` 사용 | 없음. | 없음. 대가는 서버에서 클라이언트로 가는 역방향 채널, 즉 샘플링, 푸시 엘리시테이션(elicitation), `roots/list`와 재개 기능을 잃는 것입니다. | + +스티키 세션과 레거시 경로의 비용은 별도 페이지인 **[레거시 클라이언트 지원](legacy-clients.md)**에서 다루고, 두 시대 자체는 **[프로토콜 버전](../protocol-versions.md)**에서 다룹니다. 여기서 중요한 것은 답의 형태입니다. **2026-07-28에서는 이미 무상태이며 설정할 것이 없습니다.** + +이 페이지의 나머지는 무상태라고 해서 저절로 해결되지 **않는** 두 가지입니다. + +## 워커 간 `requestState` {#requeststate-across-workers} + +**[다중 왕복](../handlers/multi-round-trip.md)** 도구는 클라이언트가 가져와야 하는 것(확인, 선택, 자격 증명)이 필요하므로, 답 대신 질문을 반환하고 재시도에서 마무리합니다. 두 라운드 사이에 클라이언트는 서버가 발급한 불투명한 `request_state` 토큰을 들고 있습니다. 재시도 때 서버는 그 토큰을 다시 열어야 합니다. + +**문제는 어떤 키로 봉인하느냐입니다.** 기본적으로는 서버가 생성 시점에 `os.urandom(32)`로 만든 키입니다. `--workers 4`에서는 네 프로세스에서 생성이 네 번 일어납니다. 서로 다른 키 네 개가 어디에도 기록되지 않고, 공유되지 않으며, 재시작하면 사라집니다. + +다음은 아무것도 설정하지 않은 서버에서, 실행하기 전에 먼저 묻는 도구입니다. + +```python title="server.py" hl_lines="14 20" +--8<-- "docs_src/deploy/tutorial002.py" +``` + +첫 라운드는 워커 A에 도달합니다. 워커 A는 **자신의** 키로 `refund:120` 값을 봉인하고 토큰을 반환합니다. 클라이언트는 질문을 사람에게 보여 주고 승낙을 받은 뒤 재시도합니다. 재시도는 완전히 새로운 HTTP 요청입니다. + +!!! check + 그 재시도가 워커 B에 도달하게 해 보세요. B는 자신이 발급하지 않은 토큰의 봉인을 풀려고 하지만 + 풀 수 없어 라운드 전체를 거부합니다. `refund`는 호출되지 않고, 클라이언트는 JSON-RPC 오류를 + 받습니다. + + ```json + { + "code": -32602, + "message": "Invalid or expired requestState", + "data": {"reason": "invalid_request_state"} + } + ``` + + 이 메시지는 **고정**되어 있습니다. 만료됐든, 변조됐든, 다른 인자로 재전송됐든, (실제 배포에서 + 단연 가장 흔한 원인인) 형제 워커가 봉인했든, 클라이언트는 매번 같은 메시지를 받으므로 어떤 + 검사가 실패했는지 와이어에서는 드러나지 않습니다. 진짜 이유는 서버 로그의 `WARNING` 한 줄에 + 있습니다. + + ```text + requestState rejected on tools/call: unknown key + ``` + + 워커 하나일 때는 잘 되다가 둘이 되자 **가끔씩** 실패하기 시작한 다중 왕복 도구가 바로 이 + 경우입니다. 두 라운드가 여전히 같은 프로세스에 도달해야 하므로, 로드 밸런서가 둘을 갈라놓는 + 빈도만큼 정확히 실패합니다. + +두 라운드는 독립된 HTTP 요청 두 개이며, 평범한 일 몇 가지가 둘을 갈라놓습니다. 요청 단위로 분산하는 프록시, 중간에 끊어진 연결, 배포나 재시작, `request_state`를 저장해 두었다가 전혀 다른 프로세스에서 재개하는 클라이언트(**[루프를 직접 구동하기](../handlers/multi-round-trip.md#driving-the-loop-yourself)**) 등입니다. 이 중 어느 것이든 "다른 워커"가 됩니다. + +해결책은 인자 하나입니다. 이 인자에는 **두** 부분이 있습니다. + +```python title="server.py" hl_lines="1 12 14" +--8<-- "docs_src/deploy/tutorial003.py" +``` + +* **`keys=[...]`**는 누구나 찾아내는 부분입니다. 모든 인스턴스에 같은 비밀 값(최소 32바이트)을 주면, 어느 형제가 발급한 것이든 모든 인스턴스가 봉인을 풀 수 있습니다. `keys[0]` 항목이 봉인하고 목록의 모든 키가 봉인을 푸는데, 이것이 로테이션 링입니다. 다운타임 없이 이를 돌리는 방법은 **[키 로테이션](../handlers/multi-round-trip.md#rotating-keys)**에서 확인하세요. +* **서버의 이름**은 거의 아무도 찾지 못하는 부분이자, 키를 공유한 뒤에도 인스턴스 간 재시도가 계속 실패하는 이유입니다. 봉인된 모든 토큰은 서버의 `name`을 **audience 클레임**으로 담고 있으며, 돌아올 때 엄격하게 검사됩니다. 같은 코드로 만든 두 인스턴스는 이름이 같으므로 이를 알아챌 일이 없습니다. 이름을 서로 다르게 지으면(`MCPServer(f"billing-{POD}")`는 관측 가능성 측면에서 좋은 습관처럼 보입니다), 키를 공유했든 아니든 모든 인스턴스 간 재시도가 위와 똑같이 거부됩니다. 로그에는 `unknown key` 대신 `audience`가 찍히지만, 클라이언트는 그 차이를 알 수 없습니다. + +비밀 값은 한 번만 만들어 모든 인스턴스에 같은 값을 넘기세요. 32바이트 미만을 전달하면 SDK의 오류 메시지가 직접 실행하라고 알려 주는 명령이 바로 이것입니다. + +```console +python -c "import secrets; print(secrets.token_hex(32))" +``` + +!!! warning "같은 키, **그리고** 같은 이름" + 다중 인스턴스 배포는 둘 다 공유해야 합니다. 인스턴스별 이름이 꼭 필요하다면 대신 + `RequestStateSecurity(keys=[...], audience="billing")`처럼 전체 인스턴스에 명시적인 audience 하나를 + 지정하세요. 그러면 이름이 무엇이든 모든 인스턴스가 `"billing"`으로 발급하고 수락합니다. + +봉인에 관한 나머지 모든 것, 즉 무엇을 묶는지, 라운드별 `ttl`(기본 600초), 자체 코덱 사용하기, 설정하지 않은 기본값이 `stdio`에서는 정확히 맞는 이유는 **[`requestState` 보호하기](../handlers/multi-round-trip.md#protecting-requeststate)**에서 다룹니다. 이 페이지가 보태는 것은 두 항목짜리 체크리스트뿐입니다. **같은 키, 같은 이름.** + +!!! info + `InputRequiredResult`를 한 번도 입력해 본 적이 없어도 이 경로에 해당합니다. 매개변수에 + `Resolve(...)`를 쓰는 도구(**[의존성](../handlers/dependencies.md)**)는 다중 왕복 도구이며, + SDK가 대신 `request_state`를 발급하고 봉인합니다. 기본 키도 같고, 워커 간 실패도 같고, + 해결책도 같습니다. + +## 레플리카 간 변경 알림 {#change-notifications-across-replicas} + +클라이언트의 `subscriptions/listen` 스트림은 오래 유지되는 응답 하나이므로 살아 있는 동안 내내 레플리카 하나에 고정됩니다. **다른** 레플리카에서 발행한 `ctx.notify_resource_updated(...)`가 그 스트림에 도달해야 합니다. + +둘 사이의 접점은 `SubscriptionBus`입니다. 서버에 어떤 버스를 주든 모든 발행이 그 버스로 들어가고 열려 있는 모든 스트림이 그 버스를 듣습니다. 따라서 모든 레플리카에 같은 버스를 넘기세요. + +```python title="server.py" hl_lines="2 7 9" +--8<-- "docs_src/deploy/tutorial004.py" +``` + +팬아웃은 스트림이 어느 서버 객체에 붙어 있는지 전혀 신경 쓰지 않습니다. `InMemorySubscriptionBus` 하나를 공유하는 서버 두 개는 이미 이렇게 동작합니다. 한쪽에서 listen 스트림을 열고 다른 쪽에서 `edit_note`를 호출하면 스트림이 그 소식을 듣습니다. 이 인메모리 버스는 한 프로세스 안의 서버 객체에만 걸쳐 있으므로, 배포 방식이 아니라 모델일 뿐입니다. + +* 실제 프로세스 간에는 **SDK가 제공하는 버스 중 도움이 되는 것이 없습니다.** `SubscriptionBus`는 메서드 두 개(`publish`와 `subscribe`)짜리 `Protocol`이며, 자체 pub/sub 백엔드(Redis, NATS, 이미 운영 중인 무엇이든) 위에 구현해서 `MCPServer(subscriptions=...)`로 전달합니다. 스케치와 계약은 **[구독](../handlers/subscriptions.md#scaling-past-one-process)**에서 확인하세요. +* 버스는 작은 타입 이벤트 네 가지만 나르며, JSON-RPC는 절대 나르지 않습니다. 확인 응답, 필터링, 스트림 생명 주기는 SDK에 남아 있으므로, 버스가 프로토콜을 깨뜨릴 수는 없고 프로세스 간에 이벤트를 옮길 수만 있습니다. +* 스트림은 재개할 수 **없고** 이벤트는 재생되지 **않습니다**. 레플리카를 잃으면 그 스트림도 끊기고, 클라이언트는 다시 listen하고 다시 가져옵니다. 공유할 이벤트 저장소도, 따로 설정할 것도 없습니다. 확장이 정말로 같은 것을 더 늘리는 일에 불과한 곳은 여기 하나뿐입니다. + +## SDK가 제공하지 않는 것 {#what-the-sdk-does-not-give-you} + +`MCPServer`는 애플리케이션 서버가 아니라 프로토콜 구현체입니다. 다음으로 찾게 될 배포 설정은 의도적으로 빠져 있습니다. + +* **`workers=` 없음.** `mcp.run("streamable-http")` 호출은 uvicorn 프로세스를 정확히 하나 시작하며, 앞으로도 그 이상은 시작하지 않습니다. 다중 프로세스는 `streamable_http_app()`을 이미 ASGI 배포에 쓰고 있는 도구, 즉 `uvicorn --workers`, gunicorn, 플랫폼의 프로세스 매니저에 넘기는 것입니다. 이 페이지는 일부러 그중 어느 것의 튜토리얼도 되지 않습니다. 여기에 옮겨 적는 것보다 각 도구의 문서가 더 낫기 때문입니다. +* **헬스 체크 라우트 없음.** `@mcp.custom_route("/health", methods=["GET"])`가 답의 전부이며, 서버의 나머지가 인증을 요구하더라도 이 라우트는 인증되지 않습니다. 활성 프로브에는 맞지만 비공개여야 하는 것에는 맞지 않습니다. 예시는 **[기존 앱에 추가하기](asgi.md#custom-routes)**에서 확인하세요. +* **프로덕션 설정 객체 없음.** `MCPServer`에는 타임아웃, TLS, 정상 종료, 연결 제한을 적어 둘 곳이 없습니다. 그중 어느 것도 이 클래스의 일이 아니기 때문입니다. 이들은 ASGI 서버의 몫이며 거기서 설정합니다. 생성자가 **실제로** 받는 몇 안 되는 설정은 **[서버 실행하기](index.md)**에서 다룹니다. +* **제공되는 `EventStore` 없음, 그리고 2026-07-28에서는 쓸 일도 없음.** 재개 기능은 레거시 상태 유지 경로의 기능입니다. 최신 방식의 교환은 POST 하나, 응답 하나이며 재개할 것이 없습니다. + +## 요약 {#recap} + +* 기본적으로 이 앱은 localhost로 오는 요청만 받습니다. `transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])`가 서비스 개시의 관문입니다. 이를 전달하기 전까지 실제 호스트명 뒤의 모든 요청은 `421`이 되며, 그 이유는 서버 로그에만 남습니다. +* 2026-07-28에는 세션이 없고, 로드 밸런서가 스티키로 붙들 대상도 없습니다. `stateless_http=True`는 레거시 전용 설정입니다. 최신 요청은 이 플래그를 읽기도 전에 라우팅되고 응답되기 때문입니다. +* 기본 `requestState` 키는 프로세스마다 만들어지는 `os.urandom(32)`입니다. 다른 워커에 도달한 다중 왕복 재시도는 `-32602` *"Invalid or expired requestState"*로 실패합니다. +* 해결책은 `RequestStateSecurity(keys=[...])`를 쓰는 것, **그리고** 모든 인스턴스에 같은 서버 이름을 쓰는 것입니다. 이름은 토큰의 기본 audience 클레임입니다. 같은 키, 같은 이름. +* 변경 알림은 공유 `SubscriptionBus` 하나를 통해 레플리카를 넘나듭니다. SDK의 유일한 구현은 프로세스 내부용이며, 자체 pub/sub 위의 메서드 두 개짜리 `Protocol`은 직접 작성해야 합니다. +* `workers=`도, 헬스 라우트도, 프로덕션 설정 객체도 없습니다. ASGI 서버는 직접 준비하세요. + +실제 호스트명 앞에 필요한 또 하나는 토큰입니다. **[인가](authorization.md)**에서 이어집니다. diff --git a/i18n/ko/pages/run/index.md b/i18n/ko/pages/run/index.md new file mode 100644 index 0000000000..5ed10af203 --- /dev/null +++ b/i18n/ko/pages/run/index.md @@ -0,0 +1,156 @@ +--- +translation: + sections: [fea8d769ff9edeba, ce8e2ad42f29ef71, 0d705efb19cf99c2, 7a53ead3e704a7f0, 9adc400e8c88e854, 318893ad8e2e9924, 6b63ab96b34476c0] + tool: 1 +--- +# 서버 실행하기 {#running-your-server} + +`mcp.run()`이 서버를 시작합니다. + +결정해야 할 것은 **트랜스포트** 하나뿐입니다. 서버와 클라이언트 사이에서 바이트가 실제로 어떻게 오가는지를 정하는 것입니다. + +## 트랜스포트 선택 {#pick-a-transport} + +| 트랜스포트 | 설명 | 사용 시점 | +|---|---|---| +| `stdio` | 호스트가 파일을 서브프로세스로 실행하고 stdin과 stdout으로 통신합니다. | 로컬 서버. 기본값입니다. | +| `streamable-http` | 포트에서 수신 대기하는 실제 HTTP 서버입니다. | 배포하는 모든 것. | +| `sse` | 예전 HTTP 트랜스포트입니다. | 사용하지 않습니다. | + +!!! warning + SSE는 2025-03-26 프로토콜 개정에서 Streamable HTTP로 대체되었습니다. + `mcp.run(transport="sse")`는 여전히 동작하며 고유한 `sse_path=`와 `message_path=` + 옵션도 있지만, 아직 옮겨 가지 않은 클라이언트를 위해 남아 있을 뿐입니다. 새로 만드는 것은 여기에 기반하지 마세요. + +## `mcp.run()` {#mcprun} + +```python title="server.py" hl_lines="12-13" +--8<-- "docs_src/run/tutorial001.py" +``` + +* `run()`은 동기 함수입니다. 서버가 살아 있는 동안 블로킹합니다. +* 인수가 없으면 트랜스포트는 `stdio`입니다. +* `if __name__ == "__main__":` 아래에 두는 이유는 서버를 불러오는 모든 것(`mcp dev`, `mcp run`, `mcp install`, 테스트)이 이 파일을 **임포트**하기 때문입니다. 이 가드가 임포트가 실행 중인 서버로 바뀌는 것을 막아 줍니다. + +### stdio {#stdio} + +설정할 것이 없습니다. 호스트가 파일을 자식 프로세스로 시작하고, stdin에 요청을 쓰고, stdout에서 응답을 읽습니다. + +직접 실행해 보면 그 결과를 확인할 수 있습니다. + +```console +python server.py +``` + +아무것도 출력되지 않고, 반환하지도 않습니다. 호스트가 먼저 말을 걸기를 stdin에서 기다리고 있는 것입니다. + +이는 stdout이 **곧 통신 회선**이라는 뜻이기도 합니다. 서비스하는 동안 SDK는 이 회선을 비공개 디스크립터로 옮기고, stdout으로 **플러시되는** 출력(상속받은 stdout에 쓰는 서브프로세스, 플러시된 `print()`)을 스트림을 망가뜨릴 수 없는 stderr로 돌립니다. 서비스가 시작되기 **전에** stdout으로 플러시된 출력(래퍼 스크립트의 echo, 버퍼링되지 않은 임포트 시점의 print)은 여전히 회선에 실리며, 인터프리터가 종료 시 비울 때까지 버퍼에 남아 있는 `print()`도 마찬가지입니다. 실제로 원하는 출력에는 `logging` 모듈이 알맞은 도구입니다. 이 모듈의 핸들러는 각 레코드를 발생 즉시 stderr로 플러시합니다. 자세한 내용은 **[로깅](../handlers/logging.md)**에서 확인하세요. + +### 직접 해 보기 {#try-it} + +```console +uv run mcp dev server.py +``` + +Inspector는 실제 호스트가 하는 일을 그대로 합니다. `server.py`를 서브프로세스로 실행하고 stdio로 연결합니다. + +포트를 지정한 적이 없습니다. 포트는 애초에 없습니다. + +## Streamable HTTP {#streamable-http} + +같은 서버를 포트에 올리려면 `run()`에 트랜스포트(와 그 옵션)를 지정하세요. + +```python title="server.py" hl_lines="13" +--8<-- "docs_src/run/tutorial002.py" +``` + +그 한 줄이 Starlette 앱을 만들고 uvicorn으로 서비스합니다. 클라이언트는 `http://127.0.0.1:3001/mcp`에 연결합니다. + +트랜스포트마다 고유한 키워드 인수가 있으며, 모두 `run()`에 전달합니다. + +* `host` / `port`: 수신 대기할 위치. 기본값은 `127.0.0.1`과 `8000`입니다. +* `streamable_http_path`: MCP 엔드포인트가 위치하는 경로. 기본값은 `/mcp`입니다. +* `json_response=True`: 각 POST에 SSE 스트림 대신 단일 JSON 본문으로 응답합니다. 이 본문에는 응답 외에 다른 것을 담을 자리가 없으므로, 요청 도중 클라이언트를 다시 호출하는 도구(`ctx.elicit()`, 샘플링)는 이 구간에서 `NoBackChannelError`를 발생시키고, 진행 중인 호출에 묶인 알림(`ctx.report_progress()`의 진행 상황, 호출별 로그 메시지)은 버려집니다. 독립된 `GET` 스트림은 관련 없는 알림을 여전히 전달합니다. +* `stateless_http=True`: 요청마다 새 트랜스포트를 만들고 세션을 추적하지 않습니다. +* `max_request_body_size`: 허용되는 POST 본문의 최대 크기(바이트). 기본값은 4MiB이며, 더 큰 요청은 + 파싱이나 세션 생성 전에 HTTP 413을 받습니다. 정상적인 MCP 메시지가 이 크기를 넘을 때만 + 올리세요. +* `event_store`, `retry_interval`, `transport_security`: 재개 가능성과 DNS 리바인딩 보호. localhost가 아닌 곳에 배포하기 전까지는 미뤄도 됩니다. `transport_security`는 **[배포와 확장](deploy.md)**에서 다룹니다. + +!!! warning + 트랜스포트 옵션은 `MCPServer(...)`가 **아니라** `run()`에 전달합니다. 생성자는 서버가 + **무엇인지**(이름, 버전, 지침)를 기술하고, `run()`은 어떻게 서비스되는지를 기술합니다. 거꾸로 + 하면 MCP가 관여하기도 전에 Python이 답합니다. + + ```text + TypeError: MCPServer.__init__() got an unexpected keyword argument 'port' + ``` + +`run()`은 지름길입니다. 더 많은 것이 필요한 순간(기존 앱 안에 서버를 마운트하기, 한 프로세스에 서버 두 개, 브라우저 클라이언트를 위한 CORS)이 오면, ASGI 앱을 직접 만들어 아무 ASGI 호스트에나 넘기면 됩니다. 그 내용은 **[기존 앱에 추가하기](asgi.md)**에 있습니다. + +## 서버 설정 {#server-settings} + +실행에 관한 것 중 몇 가지는 트랜스포트와 관계가 없습니다. 생성자 인수입니다. + +```python title="server.py" hl_lines="3" +--8<-- "docs_src/run/tutorial003.py" +``` + +* `log_level`: `MCPServer(...)`가 생성되는 순간 `logging.basicConfig()`에 전달됩니다. 이는 **루트** 로거를 설정하므로 SDK의 로거뿐 아니라 직접 만든 로거의 레벨도 정합니다. 기본값은 `"INFO"`입니다. +* `debug`: HTTP 트랜스포트가 만드는 Starlette 앱으로 전달됩니다. 기본값은 `False`입니다. + +둘 다 `mcp.settings`에 저장되며, 런타임에 다시 읽을 수 있습니다. + +## `mcp` 명령 {#the-mcp-command} + +`[cli]` 엑스트라는 이 모든 것을 감싸는 작은 명령줄 도구를 설치합니다. + +`mcp dev`는 **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`는 빌드되는 환경에 패키지를 추가하고, `--with-editable`은 직접 만든 패키지를 그 환경에 설치합니다. `PATH`에 `npx`가 있어야 합니다. Inspector는 Node.js 앱이기 때문입니다. + +`mcp run`은 파일을 임포트하고, 서버 객체(모듈 수준의 `mcp`, `server`, `app`)를 찾아 `run()`을 호출합니다. + +```console +uv run mcp run server.py +uv run mcp run server.py:bookshop +``` + +`:` 접미사는 객체 이름이 `mcp`, `server`, `app`이 아닐 때 객체를 지정합니다. + +여기서는 `if __name__ == "__main__":` 블록이 전혀 실행되지 않습니다. `mcp run`이 직접 `run()`을 호출하며, 전달하는 옵션은 `--transport`뿐입니다. + +`mcp install`은 서버를 **Claude Desktop**에 등록해 앱이 대신 실행하도록 합니다. + +```console +uv run mcp install server.py --name "Bookshop" +uv run mcp install server.py -v API_KEY=abc123 -f .env +``` + +`-v KEY=VALUE`와 `-f .env`는 환경 변수를 해당 항목에 기록합니다. Claude Desktop은 서버를 자체 프로세스에서 시작합니다. 셸의 환경은 거기에 없습니다. + +`mcp install`이 아는 호스트는 Claude Desktop뿐입니다. 다른 호스트(Claude Code, Cursor, VS Code)는 모두 같은 실행 명령을 각자의 설정 파일에 받으며, 호스트별 방법은 **[실제 호스트에 연결하기](../get-started/real-host.md)**에서 확인하세요. + +`mcp version`은 설치된 SDK 버전을 출력합니다. + +!!! tip + `mcp dev`와 `mcp run`은 `MCPServer`만 이해합니다. 저수준 `Server`로 만들었다면 + 직접 실행해야 합니다. **[저수준 Server](../advanced/low-level-server.md)**를 참고하세요. + +## 요약 {#recap} + +* **트랜스포트**는 바이트가 서버에 도달하는 방식입니다. 로컬 서브프로세스에는 `stdio`, 포트에는 `streamable-http`를 씁니다. SSE는 대체되었습니다. +* `mcp.run()`이 트랜스포트를 고릅니다. 인수가 없으면 `stdio`이고, 블로킹합니다. +* 모든 트랜스포트 옵션(`host`, `port`, `streamable_http_path`, ...)은 `run()`의 인수이지, 결코 `MCPServer(...)`의 인수가 아닙니다. +* `run()`은 `if __name__ == "__main__":` 아래에 두세요. 서버를 불러오는 모든 것이 먼저 파일을 임포트합니다. +* `log_level=`과 `debug=`는 생성자 인수이며 `mcp.settings`에 저장됩니다. +* Inspector에는 `mcp dev`, 파일 실행에는 `mcp run`, Claude Desktop에는 `mcp install`, 버전 확인에는 `mcp version`을 씁니다. +* 트랜스포트는 서버가 **무엇인지**를 결코 바꾸지 않습니다. 이 페이지의 세 파일은 모두 동일한 도구를 노출합니다. + +`run()` 자체가 한계인 경우(이미 존재하는 앱 안에 서버를 넣는 경우)는 **[기존 앱에 추가하기](asgi.md)**에서 다룹니다. 실제 호스트 이름과 둘 이상의 워커는 **[배포와 확장](deploy.md)**에서 다룹니다. 그리고 일부 클라이언트가 아직 사양 버전 2025-11-25 이하에 머물러 있다면, **[레거시 클라이언트 서비스하기](legacy-clients.md)**에서 반가운 소식을 확인하세요. diff --git a/i18n/ko/pages/run/legacy-clients.md b/i18n/ko/pages/run/legacy-clients.md new file mode 100644 index 0000000000..b762492463 --- /dev/null +++ b/i18n/ko/pages/run/legacy-clients.md @@ -0,0 +1,131 @@ +--- +translation: + sections: [3d1663c18edc824c, d4fd37009a13f03d, af9f398a5a8b679a, 470c2dd144294d69, 8e45827e6d24e8c8, 91dfd0ce98ebb03c] + tool: 1 +--- +# 레거시 클라이언트 지원 {#serving-legacy-clients} + +MCP에는 두 가지 프로토콜 시대가 있습니다. 사양 버전 `2025-11-25`까지의 `initialize` 핸드셰이크 시대와 현대 시대인 `2026-07-28`입니다. 이 구분 자체를 다루는 페이지는 **[프로토콜 버전](../protocol-versions.md)**입니다. + +이 페이지는 그 구분의 서버 쪽을 다루며, 답은 한 문장이면 충분합니다. **이미 배포하고 있는 `streamable_http_app()`이 두 시대를 모두 지원합니다.** + +SDK는 모든 요청을 `MCP-Protocol-Version` 헤더에 따라 라우팅합니다. `2026-07-28` 버전을 명시한 요청은 현대 핸들러로 갑니다. 핸드셰이크 시대의 버전을 명시한 요청이나 헤더가 아예 없는 요청(2026 이전 클라이언트의 `initialize`가 바로 이렇게 도착합니다)은 그런 클라이언트가 기대하는 트랜스포트로 갑니다. `initialize` 핸드셰이크, 세션 등 모든 것을 갖춘 트랜스포트입니다. 이 라우팅은 요청마다, 작성한 코드보다 먼저, 하나의 앱 안에서 일어납니다. + +따라서 레거시 클라이언트는 따로 대비해서 만들어야 하는 대상이 아닙니다. 이미 작성한 서버에 접속해 오는 존재일 뿐입니다. 설정할 것은 아무것도 없습니다. + +!!! note + 말 그대로 아무것도 없습니다. `legacy=` 옵션도, 버전 허용 목록도, 특정 시대를 거부하거나 + 비활성화하는 방법도 없습니다. `streamable_http_app()`에도, `run()`에도, 세션 매니저에도 없습니다. + 두 시대는 항상 켜져 있습니다. 그 시그니처에서 시대별 스위치에 가장 가까운 것은 + `stateless_http`이며, 이 페이지의 대부분이 이 옵션을 다룹니다. + +## 하나의 핸들러, 두 시대 {#one-handler-both-eras} + +다음은 사용자에게 무언가를 물어봐야 하는 도구와, 그 도구를 호출하는 두 시대의 클라이언트입니다. + +```python title="server.py" hl_lines="24 37-38" +--8<-- "docs_src/legacy_clients/tutorial001.py" +``` + +`reserve`에는 모델이 제공하지 않은 정보가 하나 필요합니다. 몇 권인지입니다. `Annotated[..., Resolve(ask_quantity)]`는 도구가 이를 선언하는 방법입니다(자세한 내용은 **[의존성](../handlers/dependencies.md)**에서 확인하세요). `reserve` 안에는 버전을 명시하거나, 기능을 확인하거나, 분기하는 코드가 전혀 없습니다. + +두 클라이언트는 같은 `mcp` 객체에 **동시에** 열려 있습니다. `mode="legacy"`는 `initialize` 핸드셰이크를 실행합니다. 2026 이전 클라이언트가 여는 바로 그 연결입니다. 다른 하나는 기본값을 사용해 `2026-07-28` 버전으로 연결됩니다. + +```text +2025-11-25 {'result': "Reserved 2 of 'Dune'."} +2026-07-28 {'result': "Reserved 2 of 'Dune'."} +``` + +같은 서버, 같은 핸들러, 같은 답입니다. 이 기능은 이것이 전부입니다. + +**어떻게** 가능한지는 잠시 짚고 넘어갈 만합니다. 두 클라이언트는 완전히 다른 두 가지 전송 경로를 통해 같은 질문을 받았기 때문입니다. `2026-07-28` 연결에는 서버가 요청을 보낼 채널이 없으므로, `Resolve`는 질문을 도구 결과 안에 담아 반환했고 클라이언트는 답을 담아 호출을 재시도했습니다(**[다중 왕복 요청](../handlers/multi-round-trip.md)**). `2025-11-25` 연결에는 그런 것이 없습니다. 거기서는 `Resolve`가 호출 도중에 실제 `elicitation/create` 요청을 보내고 기다렸습니다. 둘 다 직접 작성한 것이 아닙니다. `Resolve`는 연결에서 협상된 버전을 읽고 방식을 고릅니다. 도구 본문은 어느 쪽이든 `AcceptedElicitation`을 받습니다. + +!!! tip + 이러한 시대 이식성이 바로 `Resolve`를 기반으로 삼아야 하는 **이유**입니다. 더 오래된 형제 격인 `ctx.elicit()` + (**[엘리시테이션(elicitation)](../handlers/elicitation.md)**)은 언제나 `elicitation/create`만 보내므로 + 레거시 연결에서만 동작합니다. `2026-07-28` 연결에서는 호출이 실패합니다. 아직 이를 사용하는 도구가 + 있다면, 해결책은 버전 확인이 아니라 위에서 본 방식입니다. + +## 레거시 세션의 비용 {#what-a-legacy-session-costs-you} + +라우팅은 공짜입니다. 세션은 그렇지 않습니다. + +`2026-07-28` 연결은 **세션이 없습니다**. 모든 요청이 독립적이며, 현대 핸들러는 `Mcp-Session-Id`를 발급하지 않습니다. 레거시 연결은 정반대입니다. 2026 이전 클라이언트가 `initialize`를 보내는 순간 SDK는 `Mcp-Session-Id`를 발급해 응답 헤더에 담아 돌려주고, 클라이언트의 이후 요청이 찾을 수 있도록 그 뒤에 살아 있는 기록을 유지합니다. 협상된 버전, 열린 스트림, 세션을 구동하는 백그라운드 작업이 그 기록입니다. + +그 기록은 **평범한 프로세스 내부 `dict`**입니다. 분산 세션 저장소는 없으며 연결할 방법도 없습니다. + +워커가 하나일 때는 보이지 않습니다. 둘이면 이것이 문제의 전부입니다. `Mcp-Session-Id`를 가진 요청이 그 ID를 발급하지 않은 워커에 도착하면 해당 dict에서 아무것도 찾지 못하고, 응답은 도구 결과가 아니라 `404`(`Session not found`)입니다. 따라서 워커를 둘 이상 실행하는 순간 **레거시 클라이언트에는 스티키 라우팅이 필요합니다**. 한 세션의 모든 요청은 그 세션을 시작한 프로세스에 도달해야 합니다. 현대 클라이언트는 그럴 필요가 전혀 없습니다. 고정될 세션 자체가 없기 때문입니다. 스티키 라우팅을 비롯해 둘 이상을 실행하는 데 관한 모든 내용은 **[배포와 확장](deploy.md)**에서 다룹니다. + +!!! warning + `event_store=` 옵션은 해결책처럼 보이지만 아닙니다. 이것은 세션 저장소가 아니라 **재개 기능**(**같은** + 세션에 다시 연결하는 클라이언트에게 놓친 SSE 이벤트를 재생하는 것)입니다. 다른 프로세스에서 + 세션에 도달할 수 있게 해 주는 일은 결코 없습니다. + +## 유일한 옵션: `stateless_http` {#the-one-knob-stateless_http} + +스티키 라우팅이 치르기 싫은 비용이라면, 바꿀 수 있는 것은 정확히 하나입니다. + +```python title="server.py" hl_lines="28" +--8<-- "docs_src/legacy_clients/tutorial002.py" +``` + +페이지 맨 위의 서버에 키워드 하나를 더한 것입니다. `stateless_http=True`는 레거시 경로가 요청마다 쓰고 버리는 세션을 만들게 합니다. `Mcp-Session-Id`를 발급하지 않고 요청 사이에 아무것도 기억하지 않으므로, 어느 워커든 어느 요청이나 처리할 수 있고 로드 밸런서는 마음대로 분배할 수 있습니다. + +이 옵션에 관해서는 무엇을 하는지보다 더 중요한 두 가지가 있습니다. + +**레거시 경로에만 영향을 줍니다.** 요청은 `stateless_http`를 읽기 **전에** 버전 헤더로 라우팅되므로 현대 경로는 이 옵션을 보지 못합니다. `2026-07-28` 연결은 이미 세션이 없으며 어느 값이든 완전히 똑같습니다. + +**그 경로에서 서버에서 클라이언트로 가는 두 채널을 모두 잃습니다.** `POST` 하나 동안만 사는 세션에는 서버가 요청을 밀어 보낼 스트림도, 알림을 밀어 보낼 독립 스트림도 없습니다. 서버가 시작하는 모든 요청은 `NoBackChannelError`를 일으킵니다. `ctx.elicit()`, 이제 은퇴한 샘플링과 루트 호출(**[지원 중단 예정 기능](../deprecated.md)**), 그리고 `Resolve`가 **레거시** 클라이언트에게 질문하는 경우도 마찬가지입니다. 알림은 오류조차 나지 않고 조용히 버려집니다. + +!!! note + `json_response=True`는 그 옵션이 아니지만, **모든** 레거시 세션에서 같은 비용의 절반을 치릅니다. + JSON 본문 하나로 응답하는 `POST`에는 요청 범위 채널을 위한 스트림이 없으므로, 요청 도중의 + `ctx.elicit()`은 같은 `NoBackChannelError`를 일으키고 요청에 묶인 알림은 버려집니다. 세션의 + 독립 스트림은 영향을 받지 않으므로 관련 없는 알림은 여전히 도착합니다. + +!!! check + 일부러 잘못된 설정을 해 보세요. `reserve`는 방금 두 클라이언트를 모두 지원한 바로 그 도구입니다. + `stateless_http=True`로 배포하고, 같은 두 클라이언트를 HTTP로 연결한 뒤, 각각에서 호출해 보세요. + + 현대 클라이언트는 여전히 `Reserved 2 of 'Dune'.`을 받습니다. 현대 경로는 바뀌지 않았습니다. + + 레거시 클라이언트의 호출은 모델이 읽을 수 있는 `is_error` 결과로 돌아오지 않습니다. + 요청 전체가 최상위 프로토콜 오류로 실패합니다. + + ```text + mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests. + ``` + + `Resolve`도 구해 주지 못했습니다. `2025-11-25` 연결에서는 `elicitation/create`를 **반드시** 보내야 하며, + 그때 필요한 채널이 바로 `stateless_http=True`가 포기한 것입니다. 시대 이식성이 있는 코드라고 해서 + 백 채널이 필요 없는 코드인 것은 아닙니다. + +따라서 이것은 실제 트레이드오프이며, 레거시 경로에만 존재합니다. **세션을 유지하고 스티키로 가거나, 상태 없이 단방향으로 가거나**입니다. 도구가 클라이언트를 되불러 호출하는 일이 전혀 없다면 `stateless_http=True`는 공짜이니 선택하세요. 그런 일이 있다면 세션을 유지하고 라우팅도 스티키로 유지하세요. + +## 코드가 실제로 갈라지는 지점 {#where-your-code-actually-forks} + +거의 없습니다. + +도구, 리소스, 프롬프트, 구조화된 출력, 진행 상황, 오류 중 어느 것도 어느 시대가 호출했는지 신경 쓰지 않습니다. `initialize` 핸드셰이크, `Mcp-Session-Id`, 독립 스트림, 세션을 끝내는 `DELETE`는 모두 SDK가 소유하며 핸들러는 그 어느 것도 보지 못합니다. 대화형 입력은 두 시대가 전송 수준에서 실제로 다른 **유일한** 지점이며, 그것이 신경 쓸 문제가 되지 않도록 `Resolve`가 존재합니다. 방금 하나의 도구가 두 시대를 모두 지원하는 것을 보았습니다. + +남은 것은 정확히 하나, **변경 알림**입니다. 두 시대가 서로 다른 통로에서 듣기 때문입니다. + +* `2026-07-28` 클라이언트는 `subscriptions/listen` 스트림을 열고 구독 버스를 읽습니다. `ctx.notify_resource_updated()`(그리고 `notify_tools_changed()`, `notify_prompts_changed()`, `notify_resources_changed()`)는 거기에, **오직** 거기에만 게시합니다. 자세한 내용은 **[구독](../handlers/subscriptions.md)**에서 확인하세요. +* 레거시 클라이언트는 세션이 열어 두는 독립 스트림을 읽습니다. `ctx.session.send_resource_updated()`(그리고 `send_tool_list_changed()` 등)는 요청을 실어 온 **연결**에 씁니다. 레거시 세션에서는 그것이 독립 스트림입니다. 현대 연결에는 이를 받을 곳이 없습니다. HTTP에서는 그런 채널이 없고, stdio에서는 네 가지 변경 알림이 `subscriptions/listen` 스트림으로만 전달되므로, 현대 연결에서는 알림이 조용히 버려집니다. + +HTTP에서는 어느 호출도 다른 시대의 클라이언트에 도달하지 않습니다. 모두에게 알리려면 둘 다 호출하세요. + +```python title="server.py" hl_lines="19-20" +--8<-- "docs_src/legacy_clients/tutorial003.py" +``` + +두 줄, `if`도 버전 확인도 없이 끝입니다. 레거시 클라이언트가 존재한다는 이유로 핸들러가 다르게 하는 일은 이것이 전부입니다. + +## 요약 {#recap} + +* 하나의 `streamable_http_app()`이 두 프로토콜 시대를 모두 지원합니다. SDK가 각 요청을 `MCP-Protocol-Version` 헤더에 따라 라우팅하며, 설정할 것도 찾아볼 시대별 옵션도 없습니다. +* 레거시 클라이언트의 비용은 세션입니다. 뒤에 분산 저장소가 없는 프로세스 내부 `Mcp-Session-Id` 기록입니다. 워커가 둘 이상이면 **스티키 라우팅**이 필요하며, 그렇지 않으면 엉뚱한 워커가 `404 Session not found`로 응답합니다. 다중 워커에 관한 자세한 내용은 **[배포와 확장](deploy.md)**에서 확인하세요. +* `stateless_http=True`가 유일한 옵션이며, **레거시 경로에만** 적용됩니다. 레거시 클라이언트에 자유로운 로드 밸런싱을 제공하는 대신 그 경로에서 서버에서 클라이언트로 가는 두 채널을 모두 잃습니다. 서버가 시작하는 요청은 `NoBackChannelError`를 일으키고(클라이언트에서는 `is_error` 결과가 아니라 최상위 오류), 알림은 버려집니다. +* `2026-07-28` 연결은 어느 쪽이든 세션이 없습니다. `stateless_http`는 이 연결을 건드리지 않습니다. +* 핸들러 코드가 시대에 따라 갈라지는 곳은 정확히 한 군데, 변경 알림입니다. `ctx.notify_*` 계열은 `subscriptions/listen` 클라이언트에 도달하고, `ctx.session.send_*` 계열은 레거시 세션에 도달합니다. 둘 다 호출하세요. +* 그 밖의 모든 것(`Resolve`를 통해 사용자에게 입력을 요청하는 것 포함)은 설계상 시대 이식성을 갖습니다. 현대 방식으로 한 번만 작성하세요. diff --git a/i18n/ko/pages/run/opentelemetry.md b/i18n/ko/pages/run/opentelemetry.md new file mode 100644 index 0000000000..c67199b8fa --- /dev/null +++ b/i18n/ko/pages/run/opentelemetry.md @@ -0,0 +1,85 @@ +--- +translation: + sections: [bc0227014724fa49, 15738c2f7fd67d86, a2c17bbe3f707e2f, d0d853376f162c06, b6368643fcc1c8d8, 902e33e17564a607] + tool: 1 +--- +# OpenTelemetry {#opentelemetry} + +서버는 이미 추적되고 있습니다. 아무것도 추가하지 않아도 됩니다. + +생성하는 모든 서버는 처리하는 모든 메시지에 대해 [OpenTelemetry](https://opentelemetry.io/) 스팬을 내보냅니다. 직접 작성하지도 않았고, 임포트하지도 않습니다. `MCPServer(...)`를 호출하는 순간 이미 들어 있습니다. + +```python title="server.py" +--8<-- "docs_src/opentelemetry/tutorial001.py" +``` + +이것으로 추적이 적용된 완전한 서버가 완성됩니다. `search_books`를 호출하면 그에 해당하는 스팬이 만들어집니다. 저수준 `Server`도 마찬가지입니다. 추적은 양쪽 모두에 들어 있습니다. + +## 얻게 되는 것 {#what-you-get} + +들어오는 모든 메시지는 메서드와 그 대상의 이름을 딴 `SERVER` 스팬이 됩니다. 따라서 `search_books`에 대한 `tools/call`은 `tools/call search_books` 스팬이 되고, 대상이 없는 `tools/list`는 그냥 `tools/list`가 됩니다. + +각 스팬에는 몇 가지 속성이 붙습니다. + +* `mcp.method.name`과 `mcp.protocol.version`은 모든 스팬에 있습니다. +* `jsonrpc.request.id`는 요청에 있습니다(알림에는 없습니다). +* 핸들러가 예외를 일으키면 스팬 상태가 오류로 설정됩니다. `is_error=True`인 도구 결과도 마찬가지입니다. + +도구 호출을 추적하려는 요구가 워낙 흔하기 때문에, `tools/call` 스팬은 OpenTelemetry의 [GenAI 시맨틱 컨벤션](https://opentelemetry.io/docs/specs/semconv/gen-ai/)을 따릅니다. + +* `gen_ai.operation.name`은 `"execute_tool"`로 설정됩니다. +* `gen_ai.tool.name`은 호출되는 도구로 설정됩니다. + +`prompts/get` 스팬에도 같은 취지로 `gen_ai.prompt.name`이 붙습니다. 목록 조회 메서드에는 이름을 붙일 대상이 없으므로 `gen_ai.*` 키가 없습니다. + +!!! tip + 이 GenAI 속성 덕분에 추적 UI가 도구 호출을 다른 에이전트의 호출과 같은 방식으로 묶어 보여 줍니다. 추가 코드 없이 이 그룹화를 그대로 얻습니다. + +## 원할 때까지는 비용이 들지 않습니다 {#it-costs-nothing-until-you-want-it} + +"기본적으로 켜져 있음"이 부담 없는 기본값이 되는 이유는 다음과 같습니다. + +SDK는 OpenTelemetry의 가벼운 절반인 `opentelemetry-api`에만 의존합니다. SDK와 익스포터가 설치되어 있지 않으면 스팬을 만드는 일은 아무 동작도 하지 않습니다. 따라서 서버가 지금 내보내고 있는 스팬은 비용이 거의 들지 않으며, 아무도 수집하지 않습니다. + +스팬을 실제로 **보고** 싶은 날이 오면, 나머지 절반을 설치하고 보낼 곳을 지정하면 됩니다. + +```console +uv add opentelemetry-sdk opentelemetry-exporter-otlp +``` + +일반적인 OpenTelemetry 방식으로 익스포터를 설정하면, SDK가 조용히 만들어 오던 모든 스팬이 드러납니다. 서버 코드는 바뀌지 않습니다. 단 한 줄도 바뀌지 않습니다. + +!!! info + [Pydantic Logfire](https://logfire.pydantic.dev/)가 그런 백엔드 중 하나이며, 설정까지 대신 해 줍니다. `pip install logfire`, `logfire.configure()`만 하면 MCP 스팬이 라이브 뷰에 나타납니다. OpenTelemetry 위에 만들어졌으므로 아래 내용도 모두 적용됩니다. + +## 네트워크를 건너는 트레이스 {#traces-that-cross-the-wire} + +트레이스는 클라이언트에서 서버까지 요청을 하나로 이어진 그림으로 따라갈 때 가장 유용합니다. + +클라이언트와 서버가 모두 SDK를 실행하면 이 연결은 자동으로 이루어집니다. 클라이언트는 요청에 [W3C 트레이스 컨텍스트](https://www.w3.org/TR/trace-context/)를 주입하고, 서버는 이를 다시 읽어 내어 서버 스팬이 같은 트레이스 안에서 클라이언트 스팬 아래에 중첩됩니다. 이것이 [SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414)이며, 따로 요청하지 않아도 얻습니다. + +들어오는 메시지에 트레이스 컨텍스트가 없으면, 예를 들어 SDK가 아닌 클라이언트가 보낸 요청이면, 서버 스팬은 완전히 새로운 고아 트레이스를 시작하는 대신 서버에서 이미 현재 상태인 스팬을 부모로 삼습니다. + +## 끄기 {#turning-it-off} + +추적은 미들웨어이며, 서버 목록의 첫 번째 미들웨어입니다. 스팬을 전혀 내보내지 않는 서버를 정말로 원한다면 제거하세요. + +```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 + 이 임포트에는 앞에 밑줄이 붙어 있으며, 의도된 것입니다. 이 클래스는 [`Server.middleware`](../advanced/middleware.md)가 잠정적인 것과 마찬가지로 잠정적이므로, 임포트 경로는 바뀔 수 있다고 예상해야 합니다. 이 작업이 필요한 경우는 거의 없습니다. 익스포터가 설치되어 있지 않으면 스팬은 공짜이므로, 보통은 켜 둔 채 익스포터를 설치하지 않는 것이 답입니다. + +## 요약 {#recap} + +* 모든 `MCPServer`와 모든 저수준 `Server`는 기본적으로 들어오는 메시지마다 `SERVER` 스팬을 하나씩 내보냅니다. 작성할 것은 아무것도 없습니다. +* 스팬에는 `mcp.method.name`과 `mcp.protocol.version`이 붙고, `tools/call`과 `prompts/get`에는 GenAI 속성도 붙어 도구 호출이 다른 에이전트의 호출처럼 묶입니다. +* OpenTelemetry SDK와 익스포터를 설치하기 전까지는 비용이 들지 않으며, 설치하고 나면 서버를 바꾸지 않고도 드러납니다. +* 양쪽이 모두 SDK를 실행하면 클라이언트에서 서버로 트레이스 컨텍스트가 자동으로 전파됩니다. + +요청을 아예 실행할지 말지를 결정하는 것은 **[인가](authorization.md)**입니다. diff --git a/i18n/ko/pages/servers/completions.md b/i18n/ko/pages/servers/completions.md new file mode 100644 index 0000000000..fe8459922c --- /dev/null +++ b/i18n/ko/pages/servers/completions.md @@ -0,0 +1,130 @@ +--- +translation: + sections: [72f9c964769076dd, 9a2c14e10935b515, 235299eb78ab12d7, 8aee1e78c8237fb8, 9bd86acd4112138f, 55343cb7f250dc7b] + tool: 1 +--- +# 자동 완성 {#completions} + +서버 위에 UI를 만드는 클라이언트는 사용자가 입력하는 동안 인수 값을 자동 완성하고 싶어 합니다. 언어 이름, 리포지토리 이름, 파일 경로 같은 것들입니다. + +**자동 완성(completions)**은 서버가 이런 제안을 제공하는 방법입니다. + +## 자동 완성할 만한 것 {#something-worth-completing} + +자동 완성은 정확히 두 가지에만 적용됩니다. **프롬프트**의 인수와 **리소스 템플릿**의 매개변수입니다. 그러니 각각 하나씩 가진 서버로 시작하세요. + +```python title="server.py" hl_lines="6 12" +--8<-- "docs_src/completions/tutorial001.py" +``` + +아직 자동 완성과 관련된 것은 아무것도 없습니다. + +* `review_code`는 `language`를 받습니다. 서버가 어떤 철자를 허용하는지 사용자가 추측해야 해서는 안 됩니다. +* `github_repo`는 `owner`와 `repo`를 받습니다. 둘 다 자유 입력 칸으로 두면 좋은 폼이 아닙니다. + +## 자동 완성 핸들러 {#the-completion-handler} + +`@mcp.completion()`으로 데코레이트한 함수를 **하나** 추가하세요. + +```python title="server.py" hl_lines="21-29" +--8<-- "docs_src/completions/tutorial002.py" +``` + +* 핸들러는 서버당 하나입니다. 모든 자동 완성 요청이 여기로 들어오며, 무엇을 자동 완성하는지에 따라 분기합니다. +* 반드시 `async def`여야 합니다. SDK가 이 함수를 await합니다. +* 인수 세 개를 받습니다. + * `ref`: **어떤** 프롬프트 또는 리소스 템플릿인지를 `PromptReference` 또는 `ResourceTemplateReference`로 나타냅니다. 둘을 구분할 때는 `isinstance`를 사용합니다. + * `argument`: `argument.name`은 자동 완성 중인 인수이고, `argument.value`는 사용자가 지금까지 입력한 내용입니다. + * `context`: 이미 확정된 인수입니다. 지금은 무시하세요. +* `Completion(values=[...])`을 반환하거나, 제안할 것이 없으면 `None`을 반환합니다. + +!!! tip + `argument.value`는 사용자가 입력한 접두사입니다. SDK는 대신 필터링해 주지 **않습니다**. + `values`에 넣은 것이 그대로 UI에 표시됩니다. `startswith`는 직접 작성해야 합니다. + +### 직접 해 보기 {#try-it} + +**[테스트](../get-started/testing.md)**의 인메모리 `Client`로 실행해 보세요. +`ref=PromptReference(name="review_code")`와 +`argument={"name": "language", "value": "py"}`로 `client.complete()`를 호출하세요. + +```python +result.completion.values # ['python'] +``` + +* `ref`는 핸들러가 받는 것과 같은 참조 타입입니다. +* `argument`는 `name`과 `value`라는 키 두 개만 가진 평범한 dict입니다. + +빈 `value`를 보내면 전체 목록이 돌아옵니다. `lang.startswith("")`는 모든 언어에서 참이기 때문입니다. + +```python +result.completion.values # ['go', 'javascript', 'python', 'rust', 'typescript'] +``` + +`code`(핸들러가 인식하지 못하는 인수)를 물어보면 `None`을 반환하고, SDK는 이를 빈 목록으로 바꿉니다. + +```python +result.completion.values # [] +``` + +`None`은 **"제안 없음"**을 뜻할 뿐, 결코 오류가 아닙니다. UI는 일반 텍스트 입력 칸으로 대체합니다. + +## 선언한 적 없는 기능 {#a-capability-you-never-declared} + +핸들러를 등록하는 것이 곧 선언입니다. 클라이언트를 연결하고 확인해 보세요. + +```python +client.server_capabilities.completions # CompletionsCapability() +``` + +어디에도 `completions`를 나열하지 않았습니다. SDK가 핸들러를 보고 기능을 대신 선언했습니다. 모든 **선택적** 기능은 이런 식으로 동작합니다. 핸들러가 곧 선언입니다. (세 가지 프리미티브는 선택적이지 않습니다. `MCPServer`는 핸들러가 있든 없든 항상 이 세 가지를 선언합니다.) + +!!! check + 첫 번째 `server.py`(핸들러가 없는 버전)로 돌아가서 그래도 요청해 보세요. 호출은 + JSON-RPC 오류와 함께 실패합니다. + + ```text + Method not found + ``` + + 그리고 `client.server_capabilities.completions`는 `None`입니다. 이것이 바로 기능의 의의입니다. + 제대로 동작하는 클라이언트는 기능을 확인하고, 서버가 응답할 수 없는 요청은 아예 보내지 않습니다. + +## 의존하는 인수 {#dependent-arguments} + +`github://repos/{owner}/{repo}`에는 매개변수가 두 개 있고, `repo`에 유용한 값은 먼저 어떤 `owner`를 골랐는지에 따라 달라집니다. + +`context`는 바로 이를 위한 것입니다. 사용자가 **이미 확정한** 인수를 담고 있습니다. + +```python title="server.py" hl_lines="8-11 34-38" +--8<-- "docs_src/completions/tutorial003.py" +``` + +* 새 분기는 템플릿의 `repo` 매개변수에 대해 실행됩니다. +* `context.arguments`는 지금까지 선택된 값(여기서는 `owner`)을 담은 `dict[str, str] | None`입니다. +* 아직 `owner`가 없으면 의미 있는 제안도 없으므로, 핸들러는 `None`을 반환합니다. + +클라이언트는 확정된 값을 `context_arguments=`로 보냅니다. 이번에는 `ref`가 +`ResourceTemplateReference(uri="github://repos/{owner}/{repo}")`입니다. 빈 `value`로 `repo`를 +요청하면서 `context_arguments={"owner": "modelcontextprotocol"}`를 전달하세요. + +```python +result.completion.values # ['python-sdk', 'typescript-sdk', 'inspector'] +``` + +`context_arguments=`를 빼면 같은 호출이 `[]`를 반환합니다. 핸들러는 owner를 알기 전까지는 어떤 리포지토리를 제안해야 할지 알 수 없습니다. + +!!! info + `Completion`은 `total=`과 `has_more=`도 받습니다. `values`가 더 긴 목록의 일부일 때 설정하면 + UI가 **"외 200개"**처럼 표시할 수 있습니다. 대부분의 핸들러에는 필요하지 않습니다. + +## 요약 {#recap} + +* 자동 완성은 **프롬프트 인수**와 **리소스 템플릿 매개변수**에 대한 제안입니다. 그 외에는 없습니다. +* `@mcp.completion()`은 하나뿐인 핸들러를 등록합니다. 형태는 `async def (ref, argument, context) -> Completion | None`입니다. +* `isinstance(ref, ...)`와 `argument.name`으로 분기하세요. `argument.value`로 필터링하는 것은 직접 해야 합니다. +* `None`은 빈 목록이 됩니다. 결코 오류가 아닙니다. +* `context.arguments`는 이미 확정된 값을 담고 있으며, 클라이언트는 이를 `context_arguments=`로 제공합니다. +* `completions` 기능은 핸들러를 등록하는 순간 나타납니다. 핸들러가 없으면 요청은 `Method not found`가 됩니다. + +제안은 사용자가 프롬프트나 템플릿을 아직 **채우고 있는** 동안 도움이 됩니다. 도구 호출 **도중에** 사용자에게 질문하려면 **[엘리시테이션(elicitation)](../handlers/elicitation.md)**이 필요합니다. 도구가 텍스트 외에 반환할 수 있는 모든 것은 **[이미지, 오디오, 아이콘](media.md)**에서 확인하세요. diff --git a/i18n/ko/pages/servers/handling-errors.md b/i18n/ko/pages/servers/handling-errors.md new file mode 100644 index 0000000000..00bd4296aa --- /dev/null +++ b/i18n/ko/pages/servers/handling-errors.md @@ -0,0 +1,139 @@ +--- +translation: + sections: [e33d441f12d50535, 7099694c603e0f5f, c1df4cf9673433e6, c9cd294541422e6e, 6cec073617bfd037, efa92b8f99e908c8, 6a22a29e27fb4601] + tool: 1 +--- +# 오류 처리 {#handling-errors} + +도구가 실패하는 방식은 두 가지이며, SDK는 이 둘을 매우 다르게 다룹니다. + +일반적인 예외를 발생시키면 **모델**이 보게 됩니다. `MCPError`를 발생시키면 **프로토콜**이 보게 됩니다. + +이 페이지는 둘 중 무엇을 선택할지에 관한 내용입니다. + +## 모델이 고칠 수 있는 오류 {#an-error-the-model-can-fix} + +무언가를 조회하는 도구를 하나 두고, 조회가 실패하게 해 봅시다. + +```python title="server.py" hl_lines="11-12" +--8<-- "docs_src/handling_errors/tutorial001.py" +``` + +이 두 줄에는 MCP와 관련된 것이 전혀 없습니다. `get_author`는 여느 Python 함수가 그러듯 평범한 `ValueError`를 발생시킵니다. + +카탈로그에 없는 제목으로 호출하고 결과를 살펴보세요. + +```python +result.is_error # True +result.content # [TextContent(text="Error executing tool get_author: No book titled 'Nothing' in the catalog.")] +result.structured_content # None +``` + +* 요청은 **성공했습니다**. 결과가 있고, 호출한 쪽에서는 아무 예외도 발생하지 않았습니다. +* `is_error`가 `True`이고, 예외 메시지(앞에 도구 이름이 붙음)가 `content`에, 즉 모델이 읽는 바로 그 자리에 들어 있습니다. +* `structured_content`는 `None`입니다. 실패한 호출에는 구조화할 반환 값이 없습니다. + +이것이 **도구 오류**이며, 도구가 발생시키는 **모든** 예외의 기본 동작입니다. 그리고 거의 언제나 원하는 동작이기도 합니다. + +도구를 호출하는 쪽은 모델입니다. 인자를 고른 것도 모델입니다. 그래서 도구 오류는 대화의 한 차례가 됩니다. 모델은 *"No book titled 'Nothing' in the catalog."*를 읽고, 제목을 잘못 추측했다는 것을 깨닫고, 더 나은 제목으로 다시 호출합니다. `raise` 한 줄을 썼을 뿐인데 스스로 교정하는 에이전트를 얻은 셈입니다. + +!!! tip + 도구에서 오류 메시지를 `return`으로 돌려주지 마세요. 반환된 문자열은 `is_error=False`이므로, + 모델에게(그리고 모든 클라이언트 UI에게) 도구가 제대로 작동했고 그 문자열이 답인 것처럼 보입니다. + `raise`를 쓰세요. 플래그가 신호입니다. + +## 모델이 고칠 수 없는 오류 {#an-error-the-model-cannot-fix} + +이제 `ValueError`를 `MCPError`로 바꿔 봅시다. + +```python title="server.py" hl_lines="1 3 14" +--8<-- "docs_src/handling_errors/tutorial002.py" +``` + +`MCPError`는 SDK의 **프로토콜 오류**입니다. 도구 래퍼가 잡지 **않는** 유일한 예외로, 그대로 전파되어 `tools/call` 요청 전체가 결과 대신 JSON-RPC 오류로 실패합니다. + +```json +{ + "code": -32602, + "message": "No book titled 'Nothing' in the catalog." +} +``` + +* **결과가 없습니다**. `content`도, `is_error`도 없으므로 모델이 읽을 것이 아무것도 없습니다. +* 대신 **호스트** 애플리케이션이 오류를 받습니다. 도구가 아예 존재하지 않을 때와 같은 방식입니다. +* `code`, `message`, `data`는 그대로 도착합니다. `INVALID_PARAMS`는 `-32602`입니다. `mcp.types`는 이 코드와 나머지 JSON-RPC 오류 코드(`INVALID_REQUEST`, `INTERNAL_ERROR`, ...)를 상수로 내보내므로 매직 넘버를 직접 입력할 일이 없습니다. + +!!! check + 같은 조회, 같은 실패지만, 이번에는 클라이언트 쪽에서 호출이 반환되는 대신 예외를 **발생시킵니다**. + + ```text + mcp.shared.exceptions.MCPError: No book titled 'Nothing' in the catalog. + ``` + + 첫 번째 버전은 모델에게 반응할 수 있는 문장을 건넸습니다. 이 버전은 아무것도 건네지 않습니다. + `get_author`의 경우 이는 명백히 더 나쁜 선택이며, 바로 그 점이 다음 절의 주제입니다. + +## 무엇을 발생시킬 것인가 {#which-one-to-raise} + +두 경로는 서로 다른 두 질문에 답합니다. + +* **실행**이 실패했을 때는 **아무 예외나 발생시키세요**. 도구가 하려던 일이 되지 않은 경우입니다. 호출을 선택한 것은 모델이므로, 모델이 그 결과를 보고 회복할 기회를 얻어야 합니다. 철자가 틀린 제목, 시간 초과된 상위 API, 존재하지 않는 행은 모두 도구 오류입니다. +* **요청 자체**를 거부해야 할 때는 **`MCPError`를 발생시키세요**. 도구가 의존하는 기능이 클라이언트에 없거나, 서버가 누구에게도 응답할 수 있는 상태가 아니거나, 호출한 쪽이 필수 단계를 건너뛴 경우입니다. 모델이 재시도해도 이 중 어느 것도 해결되지 않으므로, 메시지를 모델에게 건네서 얻을 것이 없습니다. + +판단 기준은 질문 하나입니다. **더 똑똑한 모델이었다면 이 상황을 피할 수 있었을까?** 예 -> 일반 예외. 아니요 -> `MCPError`. + +이 기준으로 보면 `get_author`의 두 번째 버전은 잘못된 선택을 했습니다. 더 나은 제목이면 해결되므로, 모델이 메시지를 볼 자격이 있었습니다. 그 버전은 메커니즘을 보여 주기 위한 것이지, 권장하기 위한 것이 아닙니다. + +!!! info + `MCPError`는 `from mcp import MCPError`로 가져오며 `code`, `message`, 그리고 선택적인 + `data` 페이로드를 받습니다. 여기에 넣은 내용이 그대로 클라이언트가 받는 내용입니다. SDK는 발생한 + `MCPError`를 정제하지 않고 그대로 전달합니다. + +## 존재하지 않는 리소스 {#a-resource-that-doesnt-exist} + +리소스도 같은 선을 긋고, 흔한 경우를 위해 이름 붙은 예외를 하나 제공합니다. + +```python title="server.py" hl_lines="2 13" +--8<-- "docs_src/handling_errors/tutorial003.py" +``` + +`books://{title}`은 **템플릿**입니다. **어떤** 제목과도 일치하므로 "URI의 형식이 올바른가"와 "책이 존재하는가"는 서로 다른 질문이고, 두 번째 질문에는 작성한 함수만 답할 수 있습니다. + +답할 수 없을 때는 `ResourceNotFoundError`를 발생시키세요. SDK는 이를 명세가 존재하지 않는 리소스에 지정한 프로토콜 오류로 바꿉니다. `-32602`에 요청된 URI가 `data`에 담기므로, 클라이언트는 **어느** 읽기가 실패했는지 알 수 있습니다. + +```json +{ + "code": -32602, + "message": "No book titled 'Nothing' in the catalog.", + "data": {"uri": "books://Nothing"} +} +``` + +여기에는 `is_error=True`인 절반짜리 결과가 없다는 점에 주목하세요. 리소스 읽기는 내용을 반환하거나 실패하거나 둘 중 하나입니다. 리소스에는 프로토콜 경로만 있습니다. 템플릿을 비롯해 리소스에 관한 나머지 모든 내용은 **[리소스](resources.md)**에서 다룹니다. + +## 직접 발생시킬 일이 없는 오류 {#errors-you-never-raise} + +잘못된 인자는 함수에 도달하지 않습니다. + +`get_author`에 문자열이 아닌 `title`을 보내면 SDK는 함수를 호출하기 **전에** 입력 스키마와 대조해 거부하며, 모델이 읽고 교정할 수 있는 같은 종류의 `is_error=True` 도구 오류로 돌려줍니다. **[도구](tools.md)**에서 `Field(le=50)` 제약으로 같은 거부가 일어나는 것을 보여 줍니다. + +이는 작성하지 않아도 되는 `raise` 문이 한 부류 통째로 있다는 뜻입니다. 타입 힌트를 직접 다시 검증하지 마세요. + +!!! info + 이 페이지의 모든 내용은 **클라이언트**가 보는 것이며, 테스트를 작성할 때 쓸 인메모리 `Client`도 + 정확히 같은 것을 봅니다. `raise_exceptions=True`조차 도구 오류를 트레이스백으로 되돌리지 않습니다. + 그 플래그가 동작할 수 있는 시점에는 예외가 이미 `is_error=True` 결과가 되어 있기 때문입니다. + 결과에 대해 단언하세요. 이 패턴은 **[테스트](../get-started/testing.md)**에서 다룹니다. + +## 요약 {#recap} + +* 도구에서 **아무 예외**나 발생시키면 -> 호출은 `is_error=True`와 함께 메시지를 `content`에 담아 반환합니다. 모델이 읽고 재시도할 수 있습니다. 이것이 기본 동작입니다. +* **`MCPError`**를 발생시키면 -> 호출 자체가 JSON-RPC 오류로 실패합니다. 모델은 아무것도 보지 못하고, 호스트가 처리합니다. `code`, `message`, `data`는 그대로 유지됩니다. +* 판단 기준이 되는 질문은 **더 똑똑한 모델이었다면 이 상황을 피할 수 있었을까?**입니다. 예 -> 예외. 아니요 -> `MCPError`. +* 리소스 핸들러에서 `ResourceNotFoundError`를 발생시키면 -> 프로토콜의 `-32602`가 되며, URI가 `data`에 담깁니다. +* 잘못된 인자는 함수가 실행되기 전에 스키마와 대조해 거부되므로, 이를 위해 `raise`를 쓰지 않습니다. +* `from mcp import MCPError`를 쓰고, 오류 코드 상수는 `mcp.types`에서 가져옵니다. + +오류 처리까지 마쳤습니다. 이것으로 서버가 **노출하는** 모든 것을 다뤘습니다. 모든 핸들러가 실행 중에 무엇을 읽을 수 있고 클라이언트에게 무엇을 되돌려 할 수 있는지는 다음 절인 **[핸들러 내부](../handlers/index.md)**에서 다룹니다. + +가장 자주 마주칠 SDK 오류의 정확한 문구, 각각의 의미, 그리고 각각에 대한 한 번의 조치로 끝나는 해결책은 **[문제 해결](../troubleshooting.md)**에서 확인하세요. diff --git a/i18n/ko/pages/servers/index.md b/i18n/ko/pages/servers/index.md new file mode 100644 index 0000000000..19b726836d --- /dev/null +++ b/i18n/ko/pages/servers/index.md @@ -0,0 +1,32 @@ +--- +translation: + sections: [09defc170a0da89d] + tool: 1 +--- +# 서버 {#servers} + +`MCPServer`는 연결된 클라이언트에 세 가지 프리미티브를 노출합니다. 이 세 가지는 누가 사용을 결정하느냐에 따라 구분됩니다. + +* **[도구](tools.md)**는 **모델**이 골라서 호출하는 동작입니다. 대부분 가장 먼저 찾는 + 페이지이며, 함께 볼 레퍼런스 페이지는 **[구조화된 출력](structured-output.md)**으로, + 도구가 반환하는 값의 형태에 관한 모든 것을 담고 있습니다. +* **[리소스](resources.md)**는 **애플리케이션**이 골라서 읽는 읽기 전용 데이터입니다. + 함께 볼 레퍼런스 페이지는 **[URI 템플릿](uri-templates.md)**으로, 전체 주소 지정 문법과 + 경로 안전 규칙을 담고 있습니다. +* **[프롬프트](prompts.md)**는 **사람**이 메뉴나 슬래시 명령에서 이름으로 호출하는 + 메시지 템플릿입니다. + +세 가지 프리미티브를 둘러싸고, 서버가 선언하는 나머지 항목은 다음과 같습니다. + +* **[완성](completions.md)**은 프롬프트와 리소스 템플릿의 인수를 서버 측에서 자동 완성하는 + 기능입니다. +* **[이미지, 오디오, 아이콘](media.md)**은 도구가 텍스트 외에 반환할 수 있는 모든 것과, + 클라이언트가 서버 옆에 표시하는 아이콘을 다룹니다. +* **[오류 처리](handling-errors.md)**는 모델이 복구할 수 있는 오류와 모델에게 절대 + 보여서는 안 되는 오류의 차이를 설명합니다. + +여기 있는 페이지는 각각 독립적이므로 필요한 페이지로 바로 이동하세요. 아직 서버를 만들어 본 +적이 없다면 먼저 **[첫걸음](../get-started/first-steps.md)**부터 시작하세요. + +등록한 함수 **안에서** 일어나는 일(`Context`, 의존성 주입, 호출 도중 사용자에게 추가 입력을 +요청하는 것)은 다음 섹션인 **[핸들러 내부](../handlers/index.md)**에서 다룹니다. diff --git a/i18n/ko/pages/servers/media.md b/i18n/ko/pages/servers/media.md new file mode 100644 index 0000000000..826b5609d3 --- /dev/null +++ b/i18n/ko/pages/servers/media.md @@ -0,0 +1,122 @@ +--- +translation: + sections: [496394d24d221bf1, 4ceb4591180dc6c3, 0fd63e4682d02e0c, 969ede0bd3686a16, 043f526230dd243d, 6ee3e9bcfd24047a] + tool: 1 +--- +# 미디어 {#media} + +도구가 반환할 수 있는 것은 텍스트만이 아닙니다. + +SDK는 바이너리 결과를 위한 두 가지 헬퍼(**`Image`**와 **`Audio`**)와, 클라이언트 UI에서 서버, 도구, 리소스, 프롬프트에 얼굴을 부여하는 **`Icon`** 타입을 제공합니다. + +## 이미지 반환하기 {#returning-an-image} + +반환 타입을 `Image`로 표기하고, 파일을 지정한 뒤 반환하세요. + +```python title="server.py" hl_lines="8 12 14" +--8<-- "docs_src/media/tutorial001.py" +``` + +* `Image`는 `path`(읽을 파일) 또는 `data`(원시 바이트) 중 정확히 하나만 받습니다. +* 클라이언트가 보는 MIME 타입은 확장자로 추측합니다. `logo.png`는 `image/png`로 알려집니다. +* 로고라서 특별한 것은 아닙니다. `server.py` 옆에 있는 PNG라면 무엇이든 됩니다. 코드가 렌더링한 차트, 다이어그램, 사진 모두 가능합니다. + +`Image`는 SDK의 편의 기능이지 프로토콜 타입이 아닙니다. 전송 시 반환값은 **`ImageContent`** 블록(파일의 바이트를 base64로 인코딩한 값과 MIME 타입)이 됩니다. + +```python +result.content # [ImageContent(type="image", data="iVBORw0KGgoAAAANSUhEUg...", mime_type="image/png")] +result.structured_content # None +``` + +눈여겨볼 점이 두 가지 있습니다. + +* `data`는 base64입니다. 바이트를 직접 다룬 적이 없습니다. SDK가 파일을 읽고 인코딩까지 처리했습니다. +* `structured_content`는 `None`입니다. `Image`는 모델이 보기 위한 콘텐츠이지 애플리케이션이 파싱할 데이터가 아니므로 출력 스키마가 없습니다. (반환 타입 표기가 **곧** 스키마가 되는 **[구조화된 출력](structured-output.md)**과 대조해 보세요.) + +!!! info + `ImageContent`와 `AudioContent`는 `mcp.types`에 있으며, 평범한 `str` 결과가 변환되는 `TextContent` + 바로 옆에 있습니다(**[도구](tools.md)**). 도구 결과는 콘텐츠 블록의 리스트이고, `Image`와 `Audio`는 + 두 가지 바이너리 종류를 만드는 가장 짧은 방법입니다. + +### 직접 해 보기 {#try-it} + +아무 PNG나 `server.py` 옆에 두고 이름을 `logo.png`로 바꾼 뒤 다음을 실행하세요. + +```console +uv run mcp dev server.py +``` + +**Tools** 탭을 열고 `logo`를 호출하세요. 결과는 문자열이 아니라 `image` 콘텐츠 블록이며, Inspector가 그림을 렌더링합니다. 디스크의 파일에서 화면의 픽셀까지, 그 사이의 모든 일은 SDK가 했습니다. + +## 오디오 반환하기 {#returning-audio} + +`Audio`도 같은 형태입니다. `logo.png`는 그대로 두고, 아무 WAV나 그 옆에 `chime.wav`로 두세요. + +```python title="server.py" hl_lines="18-21" +--8<-- "docs_src/media/tutorial002.py" +``` + +결과는 **`AudioContent`** 블록입니다. + +```python +result.content # [AudioContent(type="audio", data="UklGR...", mime_type="audio/wav")] +result.structured_content # None +``` + +마찬가지입니다. 디스크의 파일이 들어가고, base64와 MIME 타입이 나오며, 출력 스키마는 없습니다. + +## 바이트 또는 파일 {#bytes-or-a-file} + +두 헬퍼 모두 `path=` 대신 `data=`(원시 바이트)도 받습니다. 애초에 자기 파일에서 온 적이 없는 바이트, 즉 데이터베이스 컬럼, HTTP 응답, Pillow가 방금 그린 결과물 같은 경우에 쓰는 방식입니다. + +```python title="server.py" hl_lines="14 15" +--8<-- "docs_src/media/tutorial003.py" +``` + +`path=`를 쓰면 선언할 것이 없습니다. 결과를 만들 때 파일을 읽고, MIME 타입은 확장자로 추측합니다. + +* `Image`: `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`. +* `Audio`: `.wav`, `.mp3`, `.ogg`, `.flac`, `.aac`, `.m4a`. + +인식하지 못하는 확장자는 `application/octet-stream`으로 대체됩니다. + +!!! check + `data=`를 쓰면 파일 이름이 없으므로 추측할 근거가 없습니다. `format=`을 빠뜨리면 + SDK는 기본값으로 대체합니다. 이미지는 `image/png`, 오디오는 `audio/wav`입니다. MP3 바이트로 + `Audio`를 그렇게 만들면 클라이언트는 `mime_type="audio/wav"`라고 전달받고, 그대로 믿고 + 디코딩에 실패합니다. `data=`를 전달할 때는 `format=`도 전달하세요. + +## 아이콘 {#icons} + +`Icon`은 콘텐츠가 아니라 메타데이터입니다. 이미지를 담지 않고 URI로 이미지를 가리키며, 클라이언트는 이를 가져와 서버 이름, 도구, 리소스, 프롬프트 옆에 표시할 수 있습니다. + +```python title="server.py" hl_lines="4-5 7 10 16" +--8<-- "docs_src/media/tutorial004.py" +``` + +* `src`는 클라이언트가 해석할 수 있는 URI입니다. `https:`이거나, 추가로 가져오지 않고 아이콘을 내장하고 싶다면 `data:` URI를 씁니다. +* `mime_type`과 `sizes`(`"48x48"`, 또는 크기 조절이 가능한 형식이면 `"any"`)는 여러 개를 제공할 때 클라이언트가 알맞은 것을 고르게 해 줍니다. +* `theme="light"` 또는 `theme="dark"`는 아이콘을 한 가지 색 구성표용으로 표시합니다. + +같은 `icons=[...]` 키워드를 `MCPServer(...)`, `@mcp.tool()`, `@mcp.resource()`, `@mcp.prompt()`가 모두 받습니다. + +### 클라이언트가 보는 위치 {#where-a-client-sees-them} + +아이콘은 자신이 꾸미는 대상과 함께 전달됩니다. 서버의 아이콘은 클라이언트가 연결할 때 `client.server_info`로 도착합니다(2026년대 연결에서는 선택 사항이므로 먼저 타입을 좁히세요). + +```python +assert client.server_info is not None # python-sdk servers identify themselves by default +client.server_info.icons # [Icon(src="https://example.com/brand-kit.png", mime_type="image/png", sizes=["48x48"])] +``` + +도구의 아이콘은 `tools/list`의 `Tool` 객체에, 리소스의 아이콘은 `resources/list`의 `Resource`에, 프롬프트의 아이콘은 `prompts/list`의 `Prompt`에 있습니다. 필드 이름은 언제나 `icons`입니다. + +## 요약 {#recap} + +* 도구에서 `Image`나 `Audio`를 반환하면 클라이언트는 `ImageContent` / `AudioContent` 블록을 받습니다. 바이트는 base64로 인코딩되고 MIME 타입이 함께 갑니다. +* `path=`로 만들어 확장자가 MIME 타입을 정하게 하거나, 메모리의 `data=`와 명시적인 `format=`으로 만드세요. +* 미디어 결과에는 `structured_content`도 출력 스키마도 없습니다. +* `Icon`은 포인터입니다. `src` URI에 선택적인 `mime_type`, `sizes`, `theme`이 더해집니다. +* `icons=[...]`는 서버, 도구, 리소스, 프롬프트에서 동작하며, 클라이언트는 대응하는 객체에서 아이콘을 찾습니다. + +이것이 도구가 결과에 **넣을** 수 있는 전부입니다. 도구가 **실패**할 때 무슨 일이 일어나는지(그리고 누가 알아야 하는지)는 **[오류 처리](handling-errors.md)**에서 다룹니다. diff --git a/i18n/ko/pages/servers/prompts.md b/i18n/ko/pages/servers/prompts.md new file mode 100644 index 0000000000..fffb218cd7 --- /dev/null +++ b/i18n/ko/pages/servers/prompts.md @@ -0,0 +1,155 @@ +--- +translation: + sections: [d65c098f37f5b6c3, dd0c2724d6f2877e, 6835bb3570c6714c, ffe823cb0fedd488, f33651add1b59094] + tool: 1 +--- +# 프롬프트 {#prompts} + +**프롬프트**는 사용자가 고르는 메시지 템플릿입니다. + +도구는 모델을 위한 것입니다. 프롬프트는 그 반대입니다. 사용자가 클라이언트의 메뉴(슬래시 명령, 버튼)에서 하나를 고르고 인수를 채우면, 렌더링된 메시지가 마치 사용자가 직접 입력한 것처럼 대화에 들어갑니다. + +텍스트를 반환하는 함수에 `@mcp.prompt()`를 붙이면 프롬프트가 선언됩니다. + +## 첫 번째 프롬프트 {#your-first-prompt} + +```python title="server.py" hl_lines="6-9" +--8<-- "docs_src/prompts/tutorial001.py" +``` + +SDK는 도구에서 읽는 것과 똑같은 세 가지를 읽습니다. + +* **이름**은 함수 이름인 `review_code`입니다. +* 클라이언트가 보여 주는 **설명**은 docstring인 `Review a piece of code.`입니다. +* **인수**는 매개변수에서 나옵니다. `code`에는 기본값이 없으므로 필수입니다. + +클라이언트가 `prompts/list`에서 돌려받는 내용은 다음과 같습니다. + +```json +{ + "name": "review_code", + "description": "Review a piece of code.", + "arguments": [ + {"name": "code", "required": true} + ] +} +``` + +여기에는 JSON Schema가 없습니다. 프롬프트 인수는 **이름이 붙은 문자열 값**의 평평한 목록입니다. 모델이 구성하는 페이로드가 아니라 사람이 채우는 양식입니다. + +### 렌더링 {#rendering-it} + +클라이언트는 인수를 전달하며 `prompts/get`으로 템플릿을 렌더링합니다. 함수가 실행되고, 반환한 `str`은 **사용자 메시지 하나**가 됩니다. + +```json +{ + "description": "Review a piece of code.", + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "Please review this code:\n\ndef add(a, b): return a + b" + } + } + ], + "resultType": "complete" +} +``` + +프롬프트의 생애는 이것이 전부입니다. 이름으로 나열되고, 필요할 때 렌더링되어, 채팅에 들어갑니다. + +!!! check + `required`는 함수가 실행되기 전에 강제됩니다. `code` 없이 `review_code`를 렌더링하면 + 요청 자체가 JSON-RPC 오류(코드 `-32603`)로 실패합니다. + + ```text + mcp.shared.exceptions.MCPError: Internal server error + ``` + + 이 과정에는 모델이 관여하지 않으므로 모델에게 돌려줄 도구 방식의 오류 결과는 없습니다. + 호출이 예외를 발생시킵니다. 이유(`Missing required arguments: {'code'}`)는 서버 로그에 남습니다. + +### 직접 해 보기 {#try-it} + +MCP Inspector로 서버를 실행하세요. + +```console +uv run mcp dev server.py +``` + +**Prompts** 탭을 열고 `review_code`를 선택하세요. Inspector가 필수 `code` 필드 하나가 있는 양식을 그립니다. 필드를 채우고 렌더링하면 위의 사용자 메시지가 그대로 돌아옵니다. + +## 여러 개의 메시지 {#more-than-one-message} + +코드 리뷰는 메시지 하나입니다. 디버깅 세션은 대화이며, 프롬프트로 대화 전체의 시작점을 마련할 수 있습니다. + +`str` 대신 메시지 목록을 반환하세요. + +```python title="server.py" hl_lines="2 13-20" +--8<-- "docs_src/prompts/tutorial002.py" +``` + +* `UserMessage`와 `AssistantMessage`는 `mcp.server.mcpserver.prompts.base`에 있습니다. `str`을 넘기면 알아서 `TextContent`로 감싸 줍니다. 역할은 클래스 이름입니다. +* `Message`는 둘의 공통 기반 클래스입니다. 반환 어노테이션으로 사용하세요. + +이제 `debug_error`를 렌더링하면 메시지 세 개가 순서대로 만들어집니다. + +```json +{ + "description": "Start a debugging conversation.", + "messages": [ + {"role": "user", "content": {"type": "text", "text": "I'm seeing this error:"}}, + {"role": "user", "content": {"type": "text", "text": "TypeError: 'int' object is not iterable"}}, + { + "role": "assistant", + "content": {"type": "text", "text": "I'll help debug that. What have you tried so far?"} + } + ], + "resultType": "complete" +} +``` + +마지막 메시지를 눈여겨보세요. `assistant` 턴을 미리 채워 두면 사용자가 직접 방향을 입력하지 않아도 모델의 **다음** 응답을 원하는 방향으로 이끌 수 있습니다. + +## 제목과 인수 설명 {#titles-and-argument-descriptions} + +`review_code`는 레이블이 아니라 함수 이름입니다. 클라이언트가 버튼에 표시할 더 나은 이름을 주고, 양식이 스스로를 설명하도록 각 인수에 설명을 붙이세요. + +```python title="server.py" hl_lines="10-13" +--8<-- "docs_src/prompts/tutorial003.py" +``` + +* `title="Code review"`는 도구의 `title`과 똑같이 사람이 읽기 위한 이름입니다. +* `Annotated[str, Field(description=...)]`은 **[도구](tools.md)**에서 도구의 매개변수를 설명할 때 쓰는 것과 같은 패턴입니다. 여기서는 설명이 스키마가 아니라 인수에 붙습니다. +* `language`에는 기본값이 있으므로 더 이상 필수가 아닙니다. + +이제 `prompts/list` 항목에는 클라이언트가 좋은 양식을 그리는 데 필요한 모든 것이 담깁니다. + +```json +{ + "name": "review_code", + "title": "Code review", + "description": "Review a piece of code.", + "arguments": [ + {"name": "code", "description": "The code to review.", "required": true}, + {"name": "language", "description": "The language the code is written in.", "required": false} + ] +} +``` + +!!! info + **[도구](tools.md)**를 읽었다면 이 페이지의 내용은 이미 모두 알고 있는 셈입니다. 같은 데코레이터, + 설명이 되는 같은 docstring, 같은 `Annotated`/`Field`입니다. 달라지는 것은 누가 실행하는지(사용자)와 + 결과가 어디로 가는지(대화 속으로)뿐입니다. + +## 요약 {#recap} + +* 함수에 `@mcp.prompt()`를 붙이면 프롬프트가 됩니다. 이름은 함수에서, 설명은 docstring에서 옵니다. +* 프롬프트는 **사용자가 제어**합니다. 클라이언트가 나열하고, 사용자가 하나를 골라 인수를 채웁니다. +* 인수는 이름이 붙은 문자열의 평평한 목록입니다(스키마 없음). 기본값이 있는 매개변수는 선택 사항입니다. +* `str`을 반환하면 사용자 메시지 하나가 됩니다. `UserMessage` / `AssistantMessage`의 목록을 반환하면 여러 턴의 대화 시작점을 마련할 수 있습니다. +* `title=`과 `Field(description=...)`은 클라이언트가 UI에 표시하는 내용입니다. +* 필수 인수가 빠지면 요청 전체가 실패합니다. 프롬프트별 오류 결과는 없습니다. + +프롬프트(또는 리소스 템플릿) 인수의 서버 측 자동 완성은 **[자동 완성](completions.md)**에서 다룹니다. diff --git a/i18n/ko/pages/servers/resources.md b/i18n/ko/pages/servers/resources.md new file mode 100644 index 0000000000..c14a10d9d6 --- /dev/null +++ b/i18n/ko/pages/servers/resources.md @@ -0,0 +1,146 @@ +--- +translation: + sections: [09df998c2a799f78, 0cf131146d16d4f9, 4e6b91e3f8025346, 8fe4eef576db17ed, 0d0d1ed43e3d0a53] + tool: 1 +--- +# 리소스 {#resources} + +**리소스**는 애플리케이션이 읽도록 노출하는 데이터입니다. + +도구와 리소스를 가르는 기준이 바로 이것입니다. 도구는 **모델**이 호출하기로 결정하는 것입니다. 리소스는 **애플리케이션**이 불러오기로 결정해서(설정 파일, 레코드, 문서 등) 모델에게 컨텍스트로 제시하는 것입니다. + +평범한 Python 함수에 `@mcp.resource(uri)`를 붙이면 리소스를 선언할 수 있습니다. + +## 첫 번째 리소스 {#your-first-resource} + +```python title="server.py" hl_lines="6-8" +--8<-- "docs_src/resources/tutorial001.py" +``` + +도구와 같은 모양이지만 한 가지가 더 있습니다. 바로 **URI**입니다. 리소스는 이름이 아니라 주소로 지정합니다. 클라이언트는 `config://app`을 요청하지, `get_config`를 요청하는 일은 없습니다. + +나머지는 SDK가 여전히 함수에서 읽어 냅니다. + +* **이름**은 함수 이름인 `get_config`입니다. +* 클라이언트가 보는 **설명**은 독스트링입니다. +* **내용**은 반환하는 값 그대로입니다. + +`resources/list` 때 클라이언트는 다음을 받습니다. + +```json +{ + "name": "get_config", + "uri": "config://app", + "description": "The active shop configuration.", + "mimeType": "text/plain" +} +``` + +그리고 클라이언트가 `config://app`을 읽으면 함수가 실행되고 반환값이 텍스트로 돌아옵니다. + +```python +result.contents # [TextResourceContents(uri="config://app", mime_type="text/plain", text="theme=dark\nlanguage=en")] +``` + +!!! tip + 목록 조회는 비용이 거의 들지 않습니다. 함수는 `resources/list` 때는 호출되지 **않고**, + `resources/read` 때만, 그것도 요청된 URI에 한해서만 호출됩니다. 리소스를 천 개 노출해도 + 비용은 누군가 실제로 여는 리소스만큼만 듭니다. + +### 직접 해 보기 {#try-it} + +MCP Inspector로 서버를 실행하세요. + +```console +uv run mcp dev server.py +``` + +출력되는 URL을 열고 **Resources** 탭으로 이동하세요. `config://app`이 설명과 함께 목록에 있습니다. 클릭하면 Inspector가 읽어 들이며, 앞서 작성한 설정 두 줄이 보입니다. + +## 리소스 템플릿 {#resource-templates} + +레코드마다 URI를 하나씩 두는 방식은 확장되지 않습니다. URI에 **플레이스홀더**를 넣고 함수에 그에 대응하는 매개변수를 두세요. + +```python title="server.py" hl_lines="12-13" +--8<-- "docs_src/resources/tutorial002.py" +``` + +URI에는 `{user_id}` 자리를, 함수에는 `user_id: str` 매개변수를 둡니다. 계약은 이것이 전부입니다. + +이제 이것은 **리소스 템플릿**이며, 있는 곳도 바뀝니다. `resources/list`에서 빠지고 대신 `resources/templates/list`에 주소가 아닌 패턴으로 나타납니다. + +```json +{ + "name": "get_user_profile", + "uriTemplate": "users://{user_id}/profile", + "description": "A customer's profile.", + "mimeType": "text/plain" +} +``` + +클라이언트는 플레이스홀더를 채워 `users://42/profile`, `users://ada/profile` 같은 구체적인 URI를 읽습니다. 함수 하나가 이 모든 URI에 응답하며, 일치한 값은 `user_id`로 전달됩니다. + +```python +result.contents # [TextResourceContents(uri="users://42/profile", text="User 42: 12 orders since 2021.")] +``` + +결과의 `uri`에 주목하세요. 템플릿이 아니라 클라이언트가 요청한 **구체적인** URI입니다. + +!!! check + 플레이스홀더와 매개변수는 서로 일치해야 합니다. URI는 여전히 `{user_id}`인데 함수 매개변수 + 이름을 `user`로 바꾸면, 어떤 클라이언트도 접근하기 전인 **임포트 시점에** 데코레이터가 + 거부합니다. + + ```text + ValueError: Mismatch between URI parameters {'user_id'} and function parameters {'user'} + ``` + + 불일치는 버그일 수밖에 없으므로, SDK는 불일치가 있는 채로는 서버를 아예 시작할 수 없게 만듭니다. + +플레이스홀더 문법은 [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570)을 따릅니다. 여러 세그먼트에 걸친 값에는 `{+path}`, 선택적 쿼리 매개변수에는 `{?q,lang}` 등을 쓸 수 있습니다. SDK는 추출된 값에 기본적으로 경로 안전성 검사도 적용합니다. 전체 레퍼런스는 **[URI 템플릿과 경로 안전성](uri-templates.md)**에서 확인하세요. + +`get_user_profile`은 `Context`로 어노테이션한 매개변수도 받을 수 있습니다. SDK는 이 매개변수를 URI 매개변수로 취급하는 일 없이 주입해 주며, 무엇을 제공하는지는 **[Context](../handlers/context.md)** 페이지에서 다룹니다. + +## 반환하는 값 {#what-you-return} + +`str`만 반환할 수 있는 것은 아닙니다. 리소스마다 `mime_type`을 지정하고 알맞은 값을 반환하세요. + +```python title="server.py" hl_lines="8-9 14-15 20-21" +--8<-- "docs_src/resources/tutorial003.py" +``` + +* `readme`는 `str`을 반환하므로 그대로 전송됩니다. 가장 흔한 경우입니다. +* `catalog_stats`는 `dict`를 반환하므로 SDK가 대신 **JSON 텍스트**로 직렬화합니다. + + ```json + { + "books": 1204, + "authors": 391 + } + ``` + +* `placeholder_cover`는 `bytes`를 반환하므로 클라이언트는 `TextResourceContents` 대신 `BlobResourceContents`를 받으며, 반환한 바이트는 base64로 인코딩되어 `blob` 필드에 담깁니다. + +JSON으로 직렬화할 수 있는 다른 모든 것(리스트, Pydantic 모델, 데이터클래스)에도 같은 규칙이 적용됩니다. `str`도 `bytes`도 아니면 JSON이 됩니다. + +`mime_type`은 직접 선언하는 값이며 기본값은 `text/plain`입니다. SDK는 반환값을 들여다보고 이를 추측하는 일이 결코 없으므로, 따로 표시하지 않은 `dict` 리소스는 클라이언트에 여전히 일반 텍스트로 알려집니다. + +!!! tip + 이름, 제목, 설명을 함수에서 끌어내고 싶지 않다면 `@mcp.resource()`는 `name=`, `title=`, + `description=`도 받습니다. 그리고 작성할 함수가 아예 없는 경우에는 + `mcp.server.mcpserver.resources`에 미리 만들어진 `Resource` 클래스(`TextResource`, + `BinaryResource`, `FileResource`, `HttpResource`, `DirectoryResource`)가 있으며, + `mcp.add_resource(...)`로 등록하면 됩니다. + +클라이언트는 리소스를 **구독**해서 리소스가 바뀔 때 알림을 받을 수도 있습니다. 이것은 클라이언트 쪽 이야기이며 **[클라이언트](../client/index.md)**에서 다룹니다. + +## 요약 {#recap} + +* 함수에 `@mcp.resource(uri)`를 붙이면 리소스가 됩니다. URI는 주소, 반환값은 내용, 독스트링은 설명입니다. +* URI에 `{placeholder}` 자리가 있으면 **템플릿**이 됩니다. `resources/templates/list`에 나열되며 함수 하나가 일치하는 모든 URI를 처리합니다. +* 플레이스홀더 이름은 함수의 매개변수 이름과 같아야 합니다. 틀리면 프로덕션이 아니라 임포트 시점에 알게 됩니다. +* 함수는 리소스를 나열할 때가 아니라 **읽을** 때 실행됩니다. +* `str`은 텍스트가 되고, `bytes`는 base64 blob이 되며, 그 밖의 것은 모두 JSON 텍스트가 됩니다. 레이블은 `mime_type=` 인자로 붙입니다. +* 도구는 모델이 행동하기 위한 것이고, 리소스는 애플리케이션이 읽기 위한 것입니다. + +세 번째 프리미티브, 즉 사람이 메뉴에서 고르는 것은 **[프롬프트](prompts.md)**입니다. diff --git a/i18n/ko/pages/servers/structured-output.md b/i18n/ko/pages/servers/structured-output.md new file mode 100644 index 0000000000..f94b0d8870 --- /dev/null +++ b/i18n/ko/pages/servers/structured-output.md @@ -0,0 +1,242 @@ +--- +translation: + sections: [a838d57f003aed44, 857d03886a0137ed, 42d9efcb9f542867, 2290ff08435b5573, e866c192e11d1c14, 6cdbad079f7b47f0, d4b607372fb28b51, 18dbf726ac45e0b7, c6f7d2a148aa49f4, c851964bb3301907, d715db6f8dccc9cc, ef86634aa70498a7] + tool: 1 +--- +# 구조화된 출력 {#structured-output} + +평범한 `str`을 반환하는 도구는 결과를 두 번 내놓습니다. `content`에는 텍스트로, `structured_content`에는 `{"result": "..."}` 형태로 담깁니다. + +이 페이지는 그 두 번째 채널을 다룹니다. 이 채널이 어디에서 비롯되는지, 어떤 형태를 취할 수 있는지, 그리고 SDK가 이 채널이 선언과 어긋나지 않도록 어떻게 지키는지 설명합니다. + +짧게 말하면 **반환 타입 어노테이션이 곧 출력 스키마**입니다. 이미 작성해 둔 셈입니다. + +## 출력 스키마 {#the-output-schema} + +```python title="server.py" hl_lines="9" +--8<-- "docs_src/structured_output/tutorial001.py" +``` + +중요한 줄은 시그니처, 즉 `-> int` 부분입니다. + +이 어노테이션이 있기 때문에, SDK가 `tools/list` 중에 보내는 도구에는 매개변수로부터 만든 입력 스키마(이쪽은 **[도구](tools.md)**에서 다룹니다) 옆에 `output_schema`가 함께 실립니다. + +```json +{ + "properties": { + "result": {"title": "Result", "type": "integer"} + }, + "required": ["result"], + "title": "get_temperatureOutput", + "type": "object" +} +``` + +`int` 하나만으로는 JSON 객체가 아니므로 SDK가 이를 `{"result": ...}` 형태로 **감쌉니다**. 도구를 호출하면 두 채널이 모두 채워집니다. + +```python +result.content # [TextContent(text="17")] +result.structured_content # {"result": 17} +``` + +모든 스칼라 값은 똑같이 감싸집니다. `str`, `int`, `float`, `bool`, `bytes`, `None`이 여기에 해당합니다. + +## 두 채널 {#two-channels} + +같은 값을 두 번 보내는 데는 이유가 있습니다. + +* `content`는 **모델**을 위한 것입니다. 언어 모델은 텍스트를 읽으며, 결과 가운데 모델이 보는 부분은 이것뿐입니다. +* `structured_content`는 모델이 그 안에서 동작하는 **애플리케이션**, 즉 "17"이 들어간 문장이 아니라 숫자 `17` 자체를 원하는 코드를 위한 것입니다. +* `output_schema`는 이 둘 사이의 계약이며, 도구가 한 번이라도 호출되기 전에 공개됩니다. + +반환하는 것은 Python 값 하나입니다. 세 가지 모두 SDK가 채웁니다. + +## 모델 반환하기 {#return-a-model} + +형태를 Pydantic `BaseModel`로 선언하고 인스턴스를 반환하세요. + +```python title="server.py" hl_lines="8-11 15" +--8<-- "docs_src/structured_output/tutorial002.py" +``` + +이제 `WeatherData`가 **바로** 스키마입니다. 감싸는 것도, `result` 키도 없습니다. + +```json +{ + "properties": { + "temperature": {"description": "Degrees Celsius.", "title": "Temperature", "type": "number"}, + "humidity": {"description": "Relative humidity, 0 to 1.", "title": "Humidity", "type": "number"}, + "conditions": {"title": "Conditions", "type": "string"} + }, + "required": ["temperature", "humidity", "conditions"], + "title": "WeatherData", + "type": "object" +} +``` + +`structured_content`는 필드 하나하나 그대로 이 객체입니다. + +```python +result.structured_content # {"temperature": 16.2, "humidity": 0.83, "conditions": "Overcast"} +``` + +모델도 빠지지 않습니다. SDK가 같은 객체를 JSON 텍스트로 직렬화해 `content`에 담습니다. + +```json +{ + "temperature": 16.2, + "humidity": 0.83, + "conditions": "Overcast" +} +``` + +`temperature`와 `humidity`에 붙인 `Field(description=...)` 설정이 스키마에 반영된 점을 눈여겨보세요. **입력**을 설명하던 바로 그 `Field`가 출력도 설명합니다. + +!!! info + FastAPI의 `response_model`을 써 본 적이 있다면 이미 아는 내용입니다. Pydantic 모델을 응답으로 선언하면 직렬화와 문서화가 알아서 이루어집니다. 유일한 차이는 여기서는 반환 어노테이션이 선언의 전부라는 점입니다. + +## `TypedDict` {#a-typeddict} + +모든 형태에 클래스가 필요한 것은 아닙니다. `TypedDict`로도 같은 스키마가 만들어집니다. + +```python title="server.py" hl_lines="8" +--8<-- "docs_src/structured_output/tutorial003.py" +``` + +`TypedDict`는 런타임에 평범한 `dict`이므로, 바로 그 형태로 만들어서 반환하면 됩니다. 스키마와 검증, `structured_content`는 `BaseModel` 버전과 똑같습니다(설명은 빠지는데, `TypedDict`에는 설명을 둘 자리가 없기 때문입니다). + +## 데이터클래스 {#a-dataclass} + +데이터클래스도 되고, 속성에 타입 힌트가 달린 평범한 클래스라면 무엇이든 됩니다. SDK가 내부적으로 어노테이션을 바탕으로 Pydantic 모델을 만듭니다. + +```python title="server.py" hl_lines="8-9" +--8<-- "docs_src/structured_output/tutorial004.py" +``` + +표기법은 세 가지, 스키마는 하나입니다. 코드베이스에서 이미 쓰고 있는 방식을 사용하세요. + +## 리스트 {#lists} + +`list[...]` 역시 JSON 객체가 아니므로 `{"result": ...}` 형태로 감싸지며, 그 안에 항목 타입이 `$defs` 참조로 들어갑니다. + +```python title="server.py" hl_lines="15" +--8<-- "docs_src/structured_output/tutorial005.py" +``` + +```json +{ + "$defs": { + "WeatherData": { + "properties": { + "temperature": {"title": "Temperature", "type": "number"}, + "humidity": {"title": "Humidity", "type": "number"}, + "conditions": {"title": "Conditions", "type": "string"} + }, + "required": ["temperature", "humidity", "conditions"], + "title": "WeatherData", + "type": "object" + } + }, + "properties": { + "result": {"items": {"$ref": "#/$defs/WeatherData"}, "title": "Result", "type": "array"} + }, + "required": ["result"], + "title": "get_forecastOutput", + "type": "object" +} +``` + +이틀치 예보를 요청하면 `structured_content`는 `{"result": [{...}, {...}]}` 형태가 됩니다. `content`는 항목마다 하나씩, **두 개**의 `TextContent` 블록이 됩니다. 리스트는 하나의 문자열로 통째로 쏟아 내는 대신 모델이 읽기 좋게 펼쳐집니다. + +`tuple[...]`, 유니언, `Optional[...]`도 같은 방식으로 감싸집니다. + +## 딕셔너리 {#dictionaries} + +제네릭 가운데 `dict[str, ...]` 하나만은 **이미** 그 자체로 JSON 객체이므로 감싸지 않습니다. + +```python title="server.py" hl_lines="9" +--8<-- "docs_src/structured_output/tutorial006.py" +``` + +```json +{ + "additionalProperties": {"type": "number"}, + "title": "get_temperaturesDictOutput", + "type": "object" +} +``` + +```python +result.structured_content # {"London": 16.2, "Reykjavik": 4.4} +``` + +키는 반드시 `str`이어야 합니다. `dict[int, float]` 타입은 JSON 객체가 될 수 없으므로 `{"result": ...}` 형태로 감싸는 방식으로 되돌아갑니다. + +## 검증 {#validation} + +`output_schema`는 문서가 아닙니다. 함수가 무엇을 반환하든 서버를 떠나기 전에 **이 스키마에 맞춰 검증됩니다**. + +값을 직접 만드는 동안에는 이 사실이 눈에 띄지 않습니다. `WeatherData`가 정말 `WeatherData`인지는 Pydantic이 이미 확인해 두었기 때문입니다. 눈에 띄는 것은 데이터가 통제할 수 없는 곳에서 들어오는 날입니다. + +```python title="server.py" hl_lines="9 21" +--8<-- "docs_src/structured_output/tutorial007.py" +``` + +어노테이션은 `WeatherData`를 약속합니다. 그런데 업스트림 응답이 더 이상 `humidity`를 보내지 않습니다. + +!!! check + `get_weather`를 호출해도 반쯤 빈 객체를 클라이언트에 슬그머니 넘기지 않습니다. 호출은 실패하고, 오류의 첫 몇 줄이 문제의 필드를 지목합니다. + + ```text + Error executing tool get_weather: 1 validation error for WeatherData + humidity + Field required [type=missing, input_value={'temperature': 16.2, 'conditions': 'Overcast'}, input_type=dict] + ``` + + 이 텍스트는 `is_error=True` 상태의 도구 결과로 돌아오므로, 모델은 있지도 않은 날씨를 자신 있게 읽어 내는 대신 호출이 실패했다는 사실을 알게 됩니다. + +참고로 `-> WeatherData` 도구에서 평범한 `dict`를 반환해도 괜찮습니다. 위 예제에서 `json.loads`가 만들어 낸 결과가 바로 평범한 딕셔너리였습니다. 검증 대상은 Python 타입이 아니라 값입니다. + +## 구조화된 출력 끄기 {#opting-out} + +반환 어노테이션이 프로토콜이 아니라 타입 체커를 위한 것일 때도 있습니다. `structured_output=False` 옵션을 전달하면 도구는 텍스트만 내놓습니다. + +```python title="server.py" hl_lines="6" +--8<-- "docs_src/structured_output/tutorial008.py" +``` + +`output_schema`도, 감싸기도, 검증도 없습니다. `structured_content`는 `None`이고 `content`는 반환한 문자열 그대로입니다. + +반대로 `structured_output=True` 옵션은 자동 감지를 필수 요건으로 바꿉니다. 반환 타입으로 스키마를 만들 수 없는 도구는 텍스트로 물러나는 대신 임포트 시점에 예외를 일으킵니다. + +## 타입 힌트가 없는 클래스 {#a-class-without-type-hints} + +요청하지 않았는데도 구조화되지 않은 결과로 끝나는 길이 하나 있습니다. **본문에 어노테이션이 전혀 없는** 클래스를 반환하는 경우입니다. + +```python title="server.py" hl_lines="6-9" +--8<-- "docs_src/structured_output/tutorial009.py" +``` + +`Station`은 `__init__` 안에서 `name`과 `online`을 설정하지만, **클래스** 자체는 아무것도 선언하지 않습니다. SDK는 클래스 어노테이션을 읽고, 아무것도 찾지 못하면 포기합니다. + +!!! warning + 게다가 **조용히** 포기합니다. `output_schema`는 `None`, `structured_content`도 `None`이 되고, 모델이 읽는 텍스트는 객체의 `repr`입니다. + + ```text + "" + ``` + + 오류도 경고도 없이 쓸모없는 도구만 남습니다. 어노테이션을 클래스 본문으로 옮기거나 `structured_output=True` 옵션을 전달하세요. 후자는 모듈을 임포트하는 순간 이 문제를 `Function get_station: return type is not serializable for structured output`이라는 확실한 오류로 바꿔 줍니다. + +!!! tip + 완전한 제어(`CallToolResult`를 직접 만들거나, 애플리케이션은 볼 수 있지만 모델은 볼 수 없는 `_meta`를 붙이는 것)가 필요하다면 **[저수준 Server](../advanced/low-level-server.md)**를 살펴보세요. + +## 요약 {#recap} + +* **반환 타입 어노테이션**이 곧 출력 스키마입니다. `tools/list`에서 `output_schema`로 공개됩니다. +* 스칼라, 리스트, 튜플, 유니언은 `{"result": ...}` 형태로 감싸집니다. 모델, `TypedDict`, 데이터클래스, 어노테이션이 달린 클래스, 그리고 `dict[str, ...]` 타입은 이미 객체이므로 그대로 유지됩니다. +* 모든 결과에는 `content`(모델을 위한 텍스트)와 `structured_content`(애플리케이션을 위한 데이터)가 **함께** 담깁니다. +* 반환한 값은 스키마에 맞춰 검증됩니다. 어긋나면 손상된 결과가 아니라 도구 오류가 됩니다. +* `structured_output=False` 옵션으로 도구의 구조화된 출력을 끌 수 있습니다. 타입 힌트가 없는 클래스는 아무 경고 없이 꺼지므로 주의하세요. + +이제 도구가 돌려줄 수 있는 모든 것을 손에 넣었습니다. 다음은 두 번째 프리미티브인 **[리소스](resources.md)**입니다. diff --git a/i18n/ko/pages/servers/tools.md b/i18n/ko/pages/servers/tools.md new file mode 100644 index 0000000000..e1453d0304 --- /dev/null +++ b/i18n/ko/pages/servers/tools.md @@ -0,0 +1,177 @@ +--- +translation: + sections: [e4cc390d56573409, 8566e2b68594e9ad, 2c97b9f888398951, 048e5471dfa71aea, 3076b1e16ad95950, edbedf2a16e71311, 3d8ef8da89fa87c1, f6c0e02e6ea5a363] + tool: 1 +--- +# 도구 {#tools} + +**도구**는 모델이 호출할 수 있는 함수입니다. + +평범한 Python 함수에 `@mcp.tool()` 데코레이터를 붙여 선언합니다. 이것이 API의 전부입니다. + +## 첫 번째 도구 {#your-first-tool} + +```python title="server.py" hl_lines="6-8" +--8<-- "docs_src/tools/tutorial001.py" +``` + +방금 작성한 코드를 살펴보세요. 스키마도, JSON도, 프로토콜도 없고 함수 하나만 있습니다. SDK는 이 함수에서 세 가지를 읽어 냅니다. + +* 도구의 **이름**은 함수의 이름, 즉 `search_books`입니다. +* 모델이 보는 **설명**은 독스트링, 즉 `Search the catalog by title or author.`입니다. +* 모델이 넘길 수 있는 **인자**는 타입 힌트인 `query: str`, `limit: int`에서 나옵니다. + +### 입력 스키마 {#the-input-schema} + +SDK는 이 타입 힌트로부터 JSON Schema를 생성해 `tools/list` 과정에서 클라이언트에 보냅니다. + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"title": "Limit", "type": "integer"} + }, + "required": ["query", "limit"], + "title": "search_booksArguments" +} +``` + +두 인자 모두 기본값이 없으므로 `required`에 들어 있습니다. 이 부분은 곧 고칩니다. (`title` 키는 Pydantic이 만들어 내는 부산물입니다. 계약에 해당하는 것은 속성과 그 타입, 그리고 `required`입니다.) + +!!! tip + 여기서 타입 힌트는 문서가 아닙니다. 타입 힌트가 바로 **계약**입니다. 클라이언트가 `"limit": "ten"`을 보내면 + 함수가 실행되기도 전에 SDK가 거부합니다. + +### 모델이 돌려받는 것 {#what-the-model-gets-back} + +`{"query": "dune", "limit": 5}`로 도구를 호출하면 결과는 두 부분으로 이루어집니다. + +```python +result.content # [TextContent(text="Found 3 books matching 'dune' (showing up to 5).")] +result.structured_content # {'result': "Found 3 books matching 'dune' (showing up to 5)."} +``` + +`content`는 **모델**이 읽는 텍스트입니다. `structured_content`는 **클라이언트 애플리케이션**을 위한 타입이 지정된 데이터입니다. 이 값이 들어 있는 이유는 반환 타입을 `-> str`로 선언했기 때문입니다. + +`structured_content`는 아직 신경 쓰지 않아도 됩니다. 도구에서 실제 Python 객체를 반환하기만 하면 알맞게 처리됩니다. 이 주제는 **[구조화된 출력](structured-output.md)** 페이지에서 자세히 다룹니다. + +### 직접 해 보기 {#try-it} + +MCP Inspector로 서버를 실행하세요. + +```console +uv run mcp dev server.py +``` + +출력된 URL을 열고 **Tools** 탭으로 가서 `search_books`를 호출하세요. + +Inspector는 필수 항목인 `query` 텍스트 필드와 필수 항목인 `limit` 숫자 필드로 이루어진 폼을 그려 줍니다. 이 폼은 타입 힌트를 보고 만든 것입니다. 다른 모든 MCP 클라이언트도 똑같이 합니다. + +## 선택적 인자 {#optional-arguments} + +매개변수에 기본값을 주면 더 이상 필수가 아니게 됩니다. 이게 전부입니다. 평범한 Python일 뿐입니다. + +```python title="server.py" hl_lines="7" +--8<-- "docs_src/tools/tutorial002.py" +``` + +스키마도 그에 맞게 바뀝니다. + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"default": 10, "title": "Limit", "type": "integer"} + }, + "required": ["query"], + "title": "search_booksArguments" +} +``` + +`limit`은 `required`에서 빠지고 `"default": 10`이 생겼습니다. 이 인자를 생략한 클라이언트는 Python에서 그렇듯 `10`을 받습니다. + +## `Field`로 더 풍부한 스키마 만들기 {#richer-schemas-with-field} + +타입 힌트만으로도 꽤 많은 것을 할 수 있지만, 때로는 인자를 **설명**하거나 제약하고 싶을 때가 있습니다. + +타입을 `Annotated`로 감싸고 Pydantic `Field`를 추가하세요. + +```python title="server.py" hl_lines="12-14" +--8<-- "docs_src/tools/tutorial003.py" +``` + +새로 등장한 것은 세 가지이고, 모두 매개변수에 붙습니다. + +* `Field(description=...)`: 모델이 독스트링과 함께 읽는 인자별 설명입니다. +* `Field(ge=1, le=50)`: 숫자 범위입니다. 스키마에는 `"minimum": 1, "maximum": 50`으로 들어갑니다. +* `Literal["fiction", "non-fiction", "poetry"]`: 열거형입니다. 모델은 이 중 하나만 고를 수 있습니다. + +!!! check + 제약 조건은 장식이 아닙니다. `limit=999`로 도구를 호출하면 SDK는 **함수가 실행되기 전에** + 도구 오류로 응답합니다. + + ```text + Input should be less than or equal to 50 + ``` + + 이 오류는 도구 결과로 모델에게 돌아가고, 모델은 오류를 읽은 뒤 유효한 값으로 다시 시도합니다. + `le=50`을 한 번 적었을 뿐인데 스스로 교정하는 에이전트를 덤으로 얻은 셈입니다. + +!!! info + FastAPI나 Pydantic을 써 본 적이 있다면 이미 전부 아는 내용입니다. 같은 `Field`, 같은 `Annotated`, + 같은 검증입니다. MCP에만 해당하는 새로 배울 내용은 없습니다. + +## 매개변수로 모델 받기 {#a-model-as-a-parameter} + +도구가 받는 인자가 두어 개를 넘어가면 Pydantic 모델 하나로 묶으세요. + +```python title="server.py" hl_lines="8-11 15" +--8<-- "docs_src/tools/tutorial004.py" +``` + +`Book` 스키마는 도구의 입력 스키마 안에 `$defs` 참조로 중첩되고, 모델은 그 자리를 JSON 객체로 채우며, 함수는 이미 검증이 끝난 **진짜 `Book` 인스턴스**를 받습니다. 이 인스턴스에는 `.title`, `.author`, `.year` 속성이 있습니다. + +조합은 자유롭습니다. 일반 매개변수 옆에 모델 매개변수를 두어도 되고, 모델을 중첩하거나 모델의 리스트를 받아도 됩니다. 처음부터 끝까지 전부 Pydantic입니다. + +## `async def` {#async-def} + +도구가 I/O를 한다면(API를 호출하거나, 파일을 읽거나, 데이터베이스를 조회한다면) `async def`로 선언하고 그 안에서 `await`를 쓰세요. SDK가 알아서 await합니다. + +일반 `def` 도구도 잘 동작합니다. SDK가 스레드에서 실행하므로 서버를 막는 일이 없습니다. + +따로 설정할 것은 아무것도 없습니다. + +## 이름, 제목, 애너테이션 {#names-titles-and-annotations} + +SDK가 추론하는 것은 모두 데코레이터에서 덮어쓸 수 있습니다. + +```python title="server.py" hl_lines="7-10" +--8<-- "docs_src/tools/tutorial005.py" +``` + +* `title`은 UI에 표시할 사람이 읽기 쉬운 이름입니다. 클라이언트는 `search_books` 대신 *"Search the catalog"*라고 표시합니다. +* `annotations`는 클라이언트를 위한 동작 **힌트**입니다. + * `read_only_hint=True`: 이 도구는 아무것도 바꾸지 않습니다. + * `open_world_hint=False`: 열린 웹이 아니라 닫힌 집합(이 카탈로그)을 대상으로 동작합니다. + * 나머지 둘인 `destructive_hint`와 `idempotent_hint`는 **쓰기**를 하는 도구를 설명합니다. 무언가를 + 삭제할 수 있는지, 두 번 호출해도 한 번 호출한 것과 결과가 같은지를 나타냅니다. 명세는 이 둘을 읽기 + 전용이 아닌 도구에 대해서만 정의하므로, `search_books`에 붙여도 아무 의미가 없습니다. + +잘 만들어진 클라이언트는 이 힌트를 바탕으로 "이 도구를 실행하기 전에 사용자에게 물어봐야 할까?" 같은 판단을 내립니다. 어디까지나 힌트일 뿐 보안 장치가 아닙니다. 클라이언트가 힌트를 지켜 주리라고 기대해서는 안 됩니다. + +!!! tip + 이름과 설명을 함수 이름과 독스트링에서 가져오고 싶지 않다면 `@mcp.tool()`에 `name=`과 `description=`을 + 넘겨도 됩니다. 대개는 그대로 가져오면 됩니다. + +## 요약 {#recap} + +* 함수에 `@mcp.tool()` 데코레이터를 붙이면 도구가 됩니다. 이름은 함수에서, 설명은 독스트링에서 가져옵니다. +* 타입 힌트가 **곧** 입력 스키마입니다. 기본값이 있으면 인자는 선택 사항이 됩니다. +* `Annotated[..., Field(...)]` 조합은 설명과 제약 조건을 더하고, `Literal`은 열거형을 더합니다. +* Pydantic 모델 매개변수는 구조화된 "본문"을 받는 방법입니다. +* 잘못된 인자는 알아서 거부되며, 모델이 읽고 스스로 복구할 수 있는 오류가 돌아갑니다. +* I/O에는 `async def`를, 그 밖의 모든 경우에는 일반 `def`를 씁니다. + +`return`으로 돌려준 값이 어떻게 되는지는 **[구조화된 출력](structured-output.md)**에서 이어집니다. diff --git a/i18n/ko/pages/servers/uri-templates.md b/i18n/ko/pages/servers/uri-templates.md new file mode 100644 index 0000000000..730ee28bc0 --- /dev/null +++ b/i18n/ko/pages/servers/uri-templates.md @@ -0,0 +1,167 @@ +--- +translation: + sections: [4a7033e1ed8ad602, 55dcbfff0c6271bf, 101ef9d14bf4ec46, 4b6c4a845438abc7, f98b46bafbee4acd] + tool: 1 +--- +# URI 템플릿과 경로 안전성 {#uri-templates-and-path-safety} + +이 페이지는 [`@mcp.resource`](resources.md)가 받아들이는 URI 템플릿 문법과, 추출된 값에 SDK가 적용하는 경로 안전성 정책을 다루는 레퍼런스입니다. 리소스가 무엇이고 언제 사용하는지에 관한 소개는 **[리소스](resources.md)**에서 먼저 살펴보세요. 이 페이지는 리소스를 선언하는 데 이미 익숙하고, 전체 연산자 집합이나 보안 설정, 저수준 연결 방법을 알고 싶은 경우를 가정합니다. + +템플릿 문법은 [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570)을 따릅니다. SDK는 들어오는 `resources/read` URI를 매칭하기 위해 선택한 부분 집합을 지원하며, 여기에 더해 서비스하려는 디렉터리 바깥으로 해석될 수 있는 값을 거부하는 보안 계층을 제공합니다. 프로토콜 수준의 세부 사항(메시지 형식, 생명 주기, 페이지네이션)은 [MCP 리소스 명세](https://modelcontextprotocol.io/specification/latest/server/resources)를 참고하세요. + +## 전체 연산자 집합 {#the-full-operator-set} + +단순 플레이스홀더인 `{user_id}`는 **[리소스](resources.md)**에서 소개한 형태입니다. 연산자 형태는 네 가지가 더 있으며, 나란히 비교할 수 있도록 하나의 서버에 모아 두었습니다. + +```python title="server.py" hl_lines="16-17 22-23 28-29 34-35 40-41" +--8<-- "docs_src/uri_templates/tutorial001.py" +``` + +강조 표시된 데코레이터는 각각 URI를 다른 방식으로 분해합니다. 아래 섹션에서 위에서부터 차례로 살펴봅니다. + +### 단순 확장: `{name}` {#simple-expansion-name} + +`books://{isbn}`은 평범하고 일상적인 형태입니다. 플레이스홀더는 `isbn` 매개변수에 대응하므로, 클라이언트가 `books://978-0441172719`를 읽으면 `get_book("978-0441172719")`이 호출됩니다. + +단순한 `{name}`은 첫 번째 `/`에서 멈춥니다. `books://978/extra`는 매칭되지 않습니다. `978` 뒤의 슬래시에서 캡처가 끝나고 `/extra`가 남기 때문입니다. + +### 타입 변환 {#type-conversion} + +추출된 값은 문자열로 들어오지만, 더 구체적인 타입을 선언하면 SDK가 변환합니다. `orders://{order_id}`는 매개변수가 `order_id: int`인 함수로 전달되므로, `orders://12345`를 읽으면 `get_order("12345")`가 아니라 `get_order(12345)`가 호출됩니다. 핸들러는 형 변환 없이 바로 산술 연산(`order_id + 1`)을 수행합니다. + +### 여러 세그먼트에 걸친 경로: `{+name}` {#multi-segment-paths-name} + +슬래시가 포함된 값을 캡처하려면 `{+name}`을 사용하세요. `manuals://{+path}`의 경우 다음과 같습니다. + +* `manuals://returns.md`는 `path = "returns.md"`를 줍니다. +* `manuals://printing/setup.md`는 `path = "printing/setup.md"`를 줍니다. + +값이 계층 구조를 가질 때는 언제든 `{+name}`을 사용하세요. 파일시스템 경로, 중첩된 객체 키, 프록시하는 URL 경로가 여기에 해당합니다. + +### 쿼리 매개변수: `{?a,b,c}` {#query-parameters-abc} + +`reviews://{isbn}{?limit,sort}`는 `limit`과 `sort`를 `?` 뒤에 둡니다. 경로는 **어떤** 책인지를 식별하고, 쿼리는 **어떻게** 읽을지를 조정합니다. + +쿼리 매개변수는 느슨하게 매칭됩니다. 순서는 상관없고, 추가된 항목은 무시되며, 생략된 매개변수는 함수의 기본값으로 처리됩니다. 따라서 `reviews://978-0441172719`는 `limit=10, sort="newest"`를 사용하고, `reviews://978-0441172719?sort=top`은 `sort`만 덮어씁니다. + +### 경로 세그먼트를 리스트로: `{/name*}` {#path-segments-as-a-list-name} + +각 경로 세그먼트를 슬래시가 포함된 하나의 문자열이 아니라 별개의 리스트 항목으로 받고 싶다면 `{/name*}`을 사용하세요. `shelves://browse{/path*}`의 경우, 클라이언트가 `shelves://browse/fiction/sci-fi`를 읽으면 `browse_shelf(["fiction", "sci-fi"])`가 호출됩니다. + +### 템플릿 레퍼런스 {#template-reference} + +가장 흔한 패턴은 다음과 같습니다. + +| 패턴 | 예시 입력 | 얻는 값 | +|--------------|-----------------------|-------------------------| +| `{name}` | `alice` | `"alice"` | +| `{name}` | `docs/intro.md` | **매칭 안 됨**(`/`에서 멈춤) | +| `{+path}` | `docs/intro.md` | `"docs/intro.md"` | +| `{.ext}` | `.json` | `"json"` | +| `{/segment}` | `/v2` | `"v2"` | +| `{?key}` | `?key=value` | `"value"` | +| `{?a,b}` | `?a=1&b=2` | `"1"`, `"2"` | +| `{/path*}` | `/a/b/c` | `["a", "b", "c"]` | + +### 파서가 거부하는 것 {#what-the-parser-rejects} + +몇 가지 템플릿 형태는 첫 요청에서 실패하는 대신 미리 잡아냅니다. `@mcp.resource`는 데코레이터가 실행될 때 템플릿을 파싱하므로, 아래 경우는 실행 중인 서버에 도달하지 않습니다. + +`UriTemplate.parse()`는 다음 경우에 `InvalidUriTemplate`을 발생시킵니다. + +* **두 변수 사이에 아무것도 없는 경우.** `manuals://{+path}{ext}`는 거부됩니다. 매칭 과정에서 `path`가 어디서 끝나고 `ext`가 어디서 시작하는지 알 수 없기 때문입니다. 사이에 리터럴을 두거나(`manuals://{+path}/{ext}`), 자체 구분자를 제공하는 연산자를 사용하세요. `manuals://{+path}{.ext}`는 `{.ext}`가 직접 `.`을 제공하므로 허용됩니다. +* **여러 세그먼트에 걸친 변수가 둘 이상인 경우.** 템플릿 하나에 `{+var}`, `{#var}`, 또는 전개 변수(`{/var*}`, `{.var*}`, `{;var*}`)는 최대 하나만 허용됩니다. 둘이면 본질적으로 모호합니다. 어느 쪽이 추가 세그먼트를 흡수해야 하는지 결정할 원칙적인 방법이 없습니다. +* **일반적인 문법 오류.** 닫히지 않은 중괄호, 두 번 사용된 변수 이름, 또는 `{var:3}` 접두사 수정자나 `{?vars*}` 쿼리 전개처럼 SDK가 지원하지 않는 RFC 6570 기능이 여기에 해당합니다. + +이에 더해 `@mcp.resource`는 핸들러 매개변수가 템플릿 끝의 `{?...}`/`{&...}` 구간에 있는 쿼리 변수에 바인딩되어 있으면서 Python 기본값이 없는 경우 `ValueError`를 발생시킵니다. 이 변수들은 느슨하게 매칭되므로(클라이언트가 어느 것이든 생략할 수 있습니다), 기본값이 없는 매개변수는 이를 생략한 첫 요청에서 불투명한 내부 오류로만 드러나게 됩니다. 위 서버의 `reviews://{isbn}{?limit,sort}`는 올바른 형태입니다. `limit`과 `sort` 모두 기본값을 갖고 있습니다. + +## 보안 {#security} + +템플릿 매개변수는 클라이언트에서 옵니다. 이 값이 검증 없이 파일시스템이나 데이터베이스 연산으로 흘러가면, `../../etc/passwd` 같은 값이 서비스하려던 디렉터리 바깥으로 해석될 수 있습니다. + +### SDK가 기본으로 검사하는 것 {#what-the-sdk-checks-by-default} + +핸들러가 실행되기 전에 SDK는 다음에 해당하는 매개변수를 거부합니다. + +* `..` 구성 요소를 통해 시작 디렉터리를 벗어나는 경우 +* 절대 경로(`/etc/passwd`, `C:\Windows`)나 Windows 드라이브 상대 경로(`C:foo`)처럼 보이는 경우. 드라이브 상대 경로 값과 `x:y` 같은 네임스페이스 식별자는 문자열로는 구별할 수 없으므로, 한 글자 뒤에 콜론이 오는 값은 기본적으로 모두 거부됩니다. 그런 값을 정당하게 받는 매개변수라면 검사에서 제외하세요. +* 널 바이트(`\x00`)를 포함하는 경우 + +`..` 검사는 부분 문자열 스캔이 아니라 구성 요소 기반입니다. `v1.0..v2.0`이나 `HEAD~3..HEAD` 같은 값은 `..`가 독립된 경로 세그먼트가 아니므로 통과합니다. + +이 검사는 디코딩된 값에 적용되므로, URI에서 어떻게 인코딩되었든 경로 탐색 시도를 잡아냅니다(`../etc`, `..%2Fetc`, `%2E%2E/etc`, `..%5Cetc`, `%00` 모두 잡힙니다). + +!!! check + 위 서버에서 `manuals://../etc/passwd`를 읽으면 요청은 즉시 거부됩니다. 템플릿 매칭은 첫 번째 실패에서 멈추므로, 이후의(더 관대할 수도 있는) 템플릿을 대체 수단으로 시도하지 않습니다. 클라이언트는 어떤 템플릿에도 매칭되지 않는 URI와 동일한 `-32602` "Unknown resource" 오류를 받고, `read_manual`은 실행되지 않습니다. + +### 파일시스템 핸들러: safe_join 사용 {#filesystem-handlers-use-safe_join} + +내장 검사는 흔한 경우를 막아 주지만 샌드박스 경계까지는 알 수 없습니다. 파일시스템에 접근할 때는 `safe_join`으로 경로를 해석하고 기준 디렉터리 안에 머무르는지 확인하세요. + +```python title="server.py" hl_lines="4 14" +--8<-- "docs_src/uri_templates/tutorial002.py" +``` + +`safe_join`은 단순 문자열 검사로는 놓칠 수 있는 심볼릭 링크 탈출, `..` 시퀀스, 절대 경로 트릭을 잡아냅니다. 해석된 경로가 `DOCS_ROOT`를 벗어나면 `PathEscapeError`를 발생시키고, 이는 클라이언트에 `ResourceError`로 전달됩니다. + +### 기본값이 방해가 될 때 {#when-the-defaults-get-in-the-way} + +검사가 정당한 값을 막는 경우도 있습니다. 카탈로그 가져오기 도구는 의도적으로 절대 경로를 받을 수 있고, 어떤 매개변수는 핸들러가 파일시스템을 건드리지 않고 안전하게 해석하는 `../sibling` 같은 상대 참조일 수 있습니다. 해당 매개변수를 검사에서 제외하거나, 서버 전체의 정책을 완화하세요. + +```python title="server.py" hl_lines="9 16-19" +--8<-- "docs_src/uri_templates/tutorial003.py" +``` + +* 데코레이터의 `security=ResourceSecurity(exempt_params={"source"})`는 해당 리소스의 해당 매개변수 하나에 대해서만 검사를 건너뜁니다. 서버의 나머지 부분은 기본 정책을 유지합니다. +* `MCPServer` 생성자의 `resource_security=`는 모든 리소스의 기본값을 설정합니다. 여기서 `relaxed`는 `..` 검사를 완전히 끕니다. + +설정 가능한 검사는 다음과 같습니다. + +| 설정 | 기본값 | 동작 | +|-------------------------|---------|-------------------------------------| +| `reject_path_traversal` | `True` | 시작 디렉터리를 벗어나는 `..` 시퀀스를 거부합니다 | +| `reject_absolute_paths` | `True` | `/foo`, `C:\foo`, UNC 경로, 드라이브 상대 경로 `C:foo`를 거부합니다(`x:y`도 잡힙니다) | +| `reject_null_bytes` | `True` | `\x00`을 포함하는 값을 거부합니다 | +| `exempt_params` | 비어 있음 | 검사를 건너뛸 매개변수 이름 | + +이 검사는 휴리스틱 사전 필터입니다. 파일시스템 접근에서는 `safe_join`이 여전히 격리 경계입니다. + +!!! tip + 핸들러가 요청을 처리할 수 없다면(파일이 없거나, id를 알 수 없는 경우) 예외를 발생시키세요. SDK가 이를 오류 응답으로 바꿉니다. 프로토콜 오류와 도구 오류의 차이는 **[오류 처리](handling-errors.md)**에서 확인하세요. + +## 저수준 Server의 리소스 {#resources-on-the-low-level-server} + +저수준 `Server` 위에서 구축하는 경우(**[저수준 Server](../advanced/low-level-server.md)** 참고), `resources/list`와 `resources/read` 프로토콜 메서드의 핸들러를 직접 등록합니다. 데코레이터는 없으며, 프로토콜 타입을 직접 반환합니다. + +### 정적 리소스 {#static-resources} + +고정 URI의 경우 레지스트리를 두고 정확히 일치하는지에 따라 분기하세요. + +```python title="server.py" hl_lines="17 21 27" +--8<-- "docs_src/uri_templates/tutorial004.py" +``` + +list 핸들러는 클라이언트에게 사용 가능한 것을 알려 주고, read 핸들러는 콘텐츠를 제공합니다. 먼저 레지스트리를 확인하고, 템플릿이 있다면 템플릿(아래)으로 넘기고, 그 외에는 예외를 발생시키세요. + +### 템플릿 {#templates} + +`MCPServer`가 사용하는 템플릿 엔진은 `mcp.shared.uri_template`에 있으며 독립적으로 동작합니다. 동일한 파싱과 매칭을 얻되, 라우팅과 보안 정책은 직접 연결합니다. + +```python title="server.py" hl_lines="13-16 22-25 29 33 45" +--8<-- "docs_src/uri_templates/tutorial005.py" +``` + +강조 표시된 줄에서는 세 가지 일이 일어납니다. + +* **한 번 파싱하고, 요청마다 매칭합니다.** `UriTemplate.parse()`가 템플릿을 만들고, `template.match(uri)`는 추출된 변수를 `dict`로 반환하거나 URI가 맞지 않으면 `None`을 반환합니다. URL 디코딩은 `match()` 안에서 일어나며, 디코딩된 값은 경로 안전성 검증 없이 그대로 반환됩니다. 값은 문자열로 나오므로 직접 변환하세요(`int(matched["id"])`, `Path(matched["path"])`). +* **안전성 검사를 직접 적용합니다.** `MCPServer`가 기본으로 실행하는 `..` 검사와 절대 경로 검사는 `mcp.shared.path_security`에 있습니다. `read_manual_safely`는 `MANUALS`를 건드리기 전에 이를 호출합니다. 매개변수가 파일시스템 경로가 아니라면(ISBN, 검색 쿼리 등) 해당 값의 검사는 건너뛰세요. 정책은 설정 객체가 아니라 핸들러마다 직접 제어합니다. +* **같은 출처에서 템플릿을 나열합니다.** 클라이언트는 `resources/templates/list`를 통해 템플릿을 발견합니다. `str(template)`은 원래 템플릿 문자열을 돌려주므로, 목록과 매처가 하나의 단일 출처를 공유합니다. + +## 요약 {#recap} + +* `{name}`은 세그먼트 하나를 매칭하고, `{+name}`은 슬래시를 유지하며, `{?a,b}`는 쿼리 문자열에서 값을 가져오고, `{/name*}`은 세그먼트를 리스트로 나눕니다. +* 사이에 아무것도 없는 두 변수, 또는 여러 세그먼트에 걸친 두 번째 변수는 파싱 시점에 거부됩니다. 끝의 `{?...}`/`{&...}` 쿼리 변수에 바인딩된 매개변수는 Python 기본값을 선언해야 합니다. +* 매개변수에 타입을 표기하면(`order_id: int`) SDK가 변환합니다. +* 기본 보안 정책은 핸들러가 실행되기 전에 `..`, 절대 경로, 널 바이트를 거부합니다. 리소스별로는 `security=ResourceSecurity(...)`로, 서버 전체로는 `resource_security=`로 재정의하세요. +* 파일시스템 접근에서는 `safe_join`이 격리 경계입니다. +* 저수준 `Server`에서는 `UriTemplate.parse()`로 파싱하고, `.match()`로 매칭하며, `mcp.shared.path_security`를 직접 적용하세요. diff --git a/i18n/ko/pages/translations.md b/i18n/ko/pages/translations.md new file mode 100644 index 0000000000..58503a8489 --- /dev/null +++ b/i18n/ko/pages/translations.md @@ -0,0 +1,30 @@ +--- +translation: + sections: [f671b445b16e4f99, 3983a560eb2cece7, 004b3ee918529d8c, c6e2debf1da06eb7, 81d412ed5f399f94] + tool: 1 +--- +# 번역 {#translations} + +이 문서는 영어로 작성되었습니다. 더 많은 사람에게 도움이 되도록 기계 번역판도 함께 발행하고 있으며, 이 페이지에서는 그것이 독자에게 어떤 의미인지와 번역 품질을 개선하는 데 참여하는 방법을 설명합니다. + +## 제공되는 언어 {#whats-available} + +번역된 문서는 현재 Deutsch, español, français, हिन्दी, 日本語, 한국어, português (Brasil), русский язык, Türkçe, українська мова, 简体中文, 繁體中文 열두 가지 언어로 **프리뷰** 상태입니다. 어느 페이지에서든 상단의 언어 전환기에서 언어를 선택하세요. 이 언어들이 자리를 잡으면 더 많은 언어가 추가될 수 있습니다. + +API 레퍼런스는 번역되지 않습니다. 번역된 사이트는 하나뿐인 영어 레퍼런스로 연결됩니다. + +## 영어 페이지가 기준입니다 {#english-is-the-source-of-truth} + +번역된 페이지와 영어 원문이 서로 다르면 영어 페이지가 맞습니다. 번역된 사이트의 모든 페이지는 현재 상태를 알려 주는 다음 세 가지 안내 중 하나로 시작합니다. + +- **기계 번역**: 페이지가 자동으로 번역되었으며 영어 원문으로 연결됩니다. +- **영어 페이지보다 뒤처진 번역**: 페이지가 번역된 뒤에 영어 원문이 바뀌었으므로, 번역이 따라잡을 때까지 일부 내용이 최신이 아닐 수 있습니다. +- **영어로 표시됨**: 페이지의 최신 번역이 없어서 영어 본문을 읽고 있는 상태입니다. + +## 번역이 만들어지는 방식 {#how-the-translations-are-made} + +번역된 페이지는 이 저장소에 있는 도구가 `docs/` 아래의 영어 페이지를 바탕으로 기계 생성하며, 언어마다 사람이 작성한 두 가지 입력이 이를 이끕니다. 하나는 스타일 가이드(격식, 어조, 표기법, 농담과 관용구를 다루는 방법)이고, 다른 하나는 용어집(영어로 남겨 둘 용어, 그리고 나머지 용어에 대해 반드시 써야 하는 번역어와 금지된 번역어)입니다. 생성된 본문은 절대 손으로 고치지 않습니다. 모든 개선 사항은 대신 이 입력 파일에 반영되므로, 다음에 페이지를 다시 생성해도 그대로 유지됩니다. + +## 번역 문제 신고하기 {#reporting-a-translation-problem} + +잘못된 용어, 어색한 문장, 또는 영어 원문에 없는 내용을 말하는 번역을 발견했다면 언어, 페이지, 해당 구절을 적어 [이슈를 열어 주세요](https://github.com/modelcontextprotocol/python-sdk/issues). 원어민의 신고는 특히 소중합니다. 고치는 방법을 알고 있다면 [`i18n/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/i18n) 아래 해당 언어의 스타일 가이드(`instructions.md`)나 용어집(`glossary.json`)에 대한 풀 리퀘스트로 직접 제안하세요. 그러면 다음에 번역을 다시 생성할 때 영향을 받는 모든 페이지에 수정 사항이 반영됩니다. 영어 본문 자체의 문제는 다른 문서 변경과 마찬가지로 `docs/` 아래의 페이지에서 고칩니다. diff --git a/i18n/ko/pages/troubleshooting.md b/i18n/ko/pages/troubleshooting.md new file mode 100644 index 0000000000..f1f815cd91 --- /dev/null +++ b/i18n/ko/pages/troubleshooting.md @@ -0,0 +1,420 @@ +--- +translation: + sections: [2efaecdef109a5c5, fcacd3e66b8635a4, 25323d737dcf0261, 4835ed1772f1d113, 137454d469c867f5, 6392596bd6df54f0, 41126fa9c4fe432f, 480b6d7897e30ab4, d83bb682e708dde0, ebbed3449c499db4, 323ef84f6b4bebde, 30fd31be74169d9a, 656943c6cb567218, c2dc3b1007d2e987, 7cf5386b997d04e9, 0b59feed8384456e, 0cba47bae78d04eb, 954dc21efdb532a3] + tool: 1 +--- +# 문제 해결 {#troubleshooting} + +이 페이지의 모든 제목은 SDK가 내는 오류 메시지를 글자 그대로 옮긴 것이고, 그 아래에 그 의미와 한 번에 끝나는 해결책이 이어집니다. 트레이스백(또는 서버 로그)의 마지막 줄을 브라우저의 페이지 내 찾기로 이 페이지에서 검색한 뒤, 해당 항목만 읽으세요. + +여러 항목이 아래의 서버 하나를 대상으로 합니다. 도구 하나와 템플릿 리소스 하나로 이루어져 있으며, 둘 다 모르는 도시가 들어오면 예외를 일으킵니다. + +```python title="server.py" +--8<-- "docs_src/troubleshooting/tutorial001.py" +``` + +이 페이지에서 인용하는 오류는 모두 실제 오류입니다. SDK 자체의 테스트 스위트가 하나하나 전부 재현합니다. + +## `ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)` {#exceptiongroup-unhandled-errors-in-a-taskgroup-1-sub-exception} + +이것은 MCP 오류가 아닙니다. anyio가 내는 잡음이며, 진짜 오류는 붙여 넣은 내용의 **마지막 줄**에 있습니다. + +`Client.__aenter__`에서 태스크 그룹이 시작됩니다. anyio는 태스크 그룹을 빠져나가는 모든 것을 `ExceptionGroup`으로 감싸므로, `async with Client(...)` 블록을 벗어나는 예외는 종류와 상관없이 **전부** 그 안에 담겨 도착합니다. + +```python +async def main() -> None: + async with Client(mcp) as client: + await client.read_resource("weather://Atlantis") +``` + +```text + + Exception Group Traceback (most recent call last): + | ... + | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception) + +-+---------------- 1 ---------------- + | Exception Group Traceback (most recent call last): + | ... + | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception) + +-+---------------- 1 ---------------- + | Traceback (most recent call last): + | ... + | mcp.shared.exceptions.MCPError: No forecast for 'Atlantis'. + +------------------------------------ +``` + +이에 대해 할 일은 두 가지입니다. + +1. **맨 아래를 읽으세요.** `MCPError: No forecast for 'Atlantis'.`가 실패의 원인입니다. 이 페이지에서 찾아야 할 것은 바로 **이** 텍스트입니다. +2. **블록 안에서 잡으세요.** `ExceptionGroup`은 예외가 `async with`를 **벗어날** 때만 나타납니다. 안에서 잡으면 같은 실패가 그룹 없이 평범한 `MCPError`로 나타납니다. + +```python +async def main() -> None: + async with Client(mcp) as client: + try: + await client.read_resource("weather://Atlantis") + except MCPError as e: + print(e) # No forecast for 'Atlantis'. +``` + +!!! tip + **연결** 도중의 실패(잘못된 URL, 실행 중이 아닌 서버, 이 페이지 아래쪽의 `421`)는 + `async with` 자체에서 빠져나오므로, 잡을 수 있는 "안쪽"이 없습니다. + 이런 경우에는 그룹의 맨 아래를 읽으세요. + +## `RuntimeError: Client must be used within an async context manager` {#runtimeerror-client-must-be-used-within-an-async-context-manager} + +`Client(...)`는 객체를 만들기만 합니다. `async with`에 들어가기 전에는 아무것도 연결되지 않으므로 모든 메서드가 거부합니다. + +```python +async def main() -> None: + client = Client(mcp) + tools = await client.list_tools() # RuntimeError +``` + +블록에 진입하세요. 연결은 `__aenter__`에서 맺어집니다. + +```python +async def main() -> None: + async with Client(mcp) as client: + tools = await client.list_tools() +``` + +연결을 끊는 일은 `__aexit__`에서 일어나므로, 잊어버릴 `client.close()` 같은 것은 없습니다. **[테스트](get-started/testing.md)**는 바로 이 패턴 위에 만들어져 있습니다. + +## `Error executing tool : ` 및 `Unknown tool: ` {#error-executing-tool-name-message-and-unknown-tool-name} + +지금 보고 있는 것은 예외가 아니라 **결과**입니다. `call_tool`은 예외를 일으키지 않았고, 실패한 도구에 대해서는 앞으로도 절대 일으키지 않습니다. + +서버가 모르는 도시로 `forecast`를 호출하면, 도구가 일으킨 예외는 **성공**으로 표시된 요청에 담겨 돌아옵니다. + +```python +result.is_error # True +result.content # [TextContent(text="Error executing tool forecast: No forecast for 'Atlantis'.")] +result.structured_content # None +``` + +`Unknown tool: get_forecast`는 서버에 등록된 적 없는 이름에 대해 같은 형태로 나타나며, 잘못된 인자도 함수가 실행되기 전에 도구의 입력 스키마에 비추어 같은 방식으로 거부됩니다. + +해결책은 클라이언트 쪽에 있습니다. **`result.is_error`를 확인하세요.** `call_tool`을 `try/except`로 감싸도 잡히는 것은 하나도 없습니다. 잡을 것이 없기 때문입니다. 이것은 의도된 설계이며, 이 페이지에서 가장 체득할 가치가 있는 한 가지입니다. 호출을 선택한 것은 **모델**이므로, 메시지를 받고 다시 시도할 기회를 얻는 것도 모델입니다. 예외를 **실제로** 일으키는 `MCPError` 경로를 포함해 자세한 내용은 **[오류 처리](servers/handling-errors.md)**에서 확인하세요. + +## `TypeError: The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool` {#typeerror-the-tool-decorator-was-used-incorrectly-did-you-forget-to-call-it-use-tool-instead-of-tool} + +`@mcp.tool()` 대신 `@mcp.tool`을 쓴 경우입니다. `tool()`은 데코레이터 **팩토리**이므로, 괄호가 없으면 Python은 함수를 `name=` 매개변수에 넘겨 버립니다. + +```python +@mcp.tool # <- missing () +def forecast(city: str) -> str: + """Today's forecast for one city.""" + return f"{city}: Rain." +``` + +```text +TypeError: The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool +``` + +괄호를 추가하세요. `@mcp.resource(...)`와 `@mcp.prompt()`도 같은 실수에 같은 메시지를 냅니다. + +!!! note + 이 예외는 클라이언트가 연결되기 전, 모듈을 **임포트**하는 시점에 발생합니다. 따라서 호스트가 + 서버를 도구 0개로 연결된 상태가 아니라 **시작 실패**(또는 **연결 끊김**)로 표시한다면 + 이 경우에 해당합니다. 직접 `python server.py`를 실행해 트레이스백을 읽으세요. 타입 검사기도 + 이를 잡아냅니다. 함수는 유효한 `name=` 값이 아니기 때문입니다. + +## `Tool already exists: ` {#tool-already-exists-name} + +두 등록이 같은 도구 이름을 사용한 경우입니다. **먼저** 등록된 쪽이 이기고 두 번째는 조용히 버려지며, **서버 로그**에 남는 이 경고가 유일한 신호입니다. + +```python title="server.py" hl_lines="6 12" +--8<-- "docs_src/troubleshooting/tutorial002.py" +``` + +```text +WARNING mcp.server.mcpserver.tools.tool_manager: Tool already exists: forecast +``` + +`tools/list`가 보고하는 `forecast`는 하나뿐이고, 그 정체는 `forecast_today`입니다. 둘 중 하나의 이름을 바꾸세요. `MCPServer(..., warn_on_duplicate_tools=False)`는 결과는 바꾸지 않은 채 경고만 끄므로, 켜 둔 채로 두세요. 리소스와 프롬프트에도 같은 규칙과 같은 로그 줄(`Resource already exists:`, `Prompt already exists:`)이 적용됩니다. + +## 호스트에 도구가 하나도 나타나지 않는 경우 {#my-host-lists-zero-tools} + +이 경우에는 오류 문자열이 없으며, 바로 그래서 검색하기 어렵습니다. SDK는 등록된 도구를 `tools/list`에서 절대 빠뜨리지 않으므로, 안쪽부터 바깥쪽으로 차례로 확인하세요. + +* **서버가 시작되기는 했습니까?** 괄호 없는 `@mcp.tool`은 임포트 시점에 예외를 일으키며, 일부 호스트에서는 죽은 서버가 빈 서버와 매우 비슷하게 보입니다. 직접 `python server.py`를 실행해 보세요. +* **호스트가 실행하는 `mcp`에 도구가 등록되어 있습니까?** 다른 모듈의 두 번째 `MCPServer(...)`는 별개의 빈 서버입니다. 호스트의 명령이 실제로 어느 객체를 임포트하는지 확인하세요. +* **두 도구가 같은 이름을 썼습니까?** 그렇다면 둘 중 하나는 사라졌습니다. 서버 로그에서 `Tool already exists:`를 찾아보세요. +* **호스트의 목록이 오래된 것입니까?** 시작 이후에 추가한 도구는 `notifications/tools/list_changed`를 처리하는 클라이언트에만 전달됩니다. 호스트를 재시작하는 것이 투박하지만 확실한 해결책입니다. +* **전환 구간 밖에서 무언가가 `stdout`에 썼습니까?** 서비스 중에는 SDK가 **플러시된** 엉뚱한 stdout 출력을 stderr로 돌립니다(최선 노력 방식이며, 표준 스트림을 교체하는 환경은 그대로 서비스됩니다). 하지만 그보다 먼저 stdout으로 플러시된 출력(래퍼 스크립트의 echo, 버퍼링이 꺼진 프로세스의 임포트 시점 `print()`)이나 인터프리터 종료 시 비워지는 버퍼링된 `print()`는 프로토콜 스트림에 실리며, 쓰레기 한 줄만으로도 호스트가 연결을 끊을 수 있고 일부 호스트는 이를 아무것도 없는 서버로 표시합니다. 대신 `logging` 모듈로 로그를 남기세요. 호스트 쪽 점검 목록의 나머지는 **[실제 호스트에 연결하기](get-started/real-host.md)**에 있습니다. + +"유효하지 않은" 도구 이름은 이 목록에 **없습니다**. 규격에 맞지 않는 이름은 경고를 남기지만, 도구는 어쨌든 등록되고 목록에도 나타납니다. + +## `MCPError: Server returned an error response` {#mcperror-server-returned-an-error-response} + +서버가 HTTP 요청을 단칼에 거부했고 본문이 JSON-RPC가 아니어서, Python `Client`가 보여 줄 수 있는 것이 이 대체 메시지뿐인 경우입니다. + +압도적으로 흔한 원인은 막 배포한 Streamable HTTP 서버입니다. `transport_security=` 없이 쓴 `streamable_http_app()`(그리고 `mcp.run("streamable-http")`)은 기본값이 **DNS 리바인딩 보호**여서, `Host` 헤더가 localhost인 요청만 받습니다. 노트북에서는 올바른 기본값이지만 실제 호스트 이름 뒤에서는 잘못된 기본값입니다. + +```python title="server.py" hl_lines="12" +--8<-- "docs_src/troubleshooting/tutorial003.py" +``` + +이것을 배포하고 클라이언트를 연결하면, 핸드셰이크에서 연결이 실패합니다. + +```python +async with Client("https://mcp.example.com/mcp") as client: + ... +``` + +```text +mcp.shared.exceptions.MCPError: Server returned an error response +``` + +서버가 실제로 보낸 문구인 `421`과 `Invalid Host header`는 클라이언트까지 오지 않습니다. 421 본문에 `Content-Type: application/json`이 없어서 클라이언트가 파싱할 수 없기 때문입니다. 이 문구는 **서버 로그**에 있으며, 다음으로 살펴볼 곳이 바로 거기입니다. + +```text +WARNING mcp.server.transport_security: Invalid Host header: mcp.example.com +``` + +해결책은 `transport_security=`입니다. 실제로 서비스하는 호스트 이름을 허용 목록에 넣으세요. + +```python title="server.py" hl_lines="14-17" +--8<-- "docs_src/troubleshooting/tutorial004.py" +``` + +!!! check + 바꿀 것은 이것이 전부입니다. 똑같은 클라이언트가 이제 연결되고, `2026-07-28`을 협상하고, + `forecast`를 호출합니다. + +각 필드의 의미, 리버스 프록시의 경우, 그 밖에 배포 시점에 달라지는 모든 것은 **[배포와 확장](run/deploy.md)**에서 다룹니다. 그리고 바로 아래의 `421 Misdirected Request` / `Invalid Host header`는 같은 실패를 반대편에서 본 모습입니다. + +## `421 Misdirected Request` / `Invalid Host header` {#421-misdirected-request-invalid-host-header} + +이것은 `Server returned an error response`를 Python `Client`가 **아닌** 곳에서 본 모습입니다. curl, 브라우저의 네트워크 탭, 리버스 프록시의 액세스 로그, 다른 SDK 등이 여기에 해당합니다. + +```bash +curl -i https://mcp.example.com/mcp \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}' +``` + +```text +HTTP/1.1 421 Misdirected Request + +Invalid Host header +``` + +`421 Misdirected Request`는 이 상태 코드에 HTTP 자체가 붙인 사유 문구이고, `Invalid Host header`는 SDK의 응답 본문이며, Python `Client`는 같은 사건을 `Server returned an error response`로 표시합니다. 셋 모두 하나의 거부입니다. 검사는 서버가 바인드한 주소가 아니라 **요청에 실린 `Host` 헤더**를 대상으로 하므로, 공개 호스트 이름을 그대로 전달하는 리버스 프록시도 직접 연결한 클라이언트와 똑같이 이 검사에 걸립니다. + +해결책은 `Server returned an error response`에서 보인 것과 같은 `transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])`입니다. 짚어 둘 만한 세부 사항이 두 가지 있습니다. + +* `allowed_hosts`의 항목은 정확히 일치하는 문자열입니다. `"mcp.example.com"` 항목은 포트 없는 `Host` 헤더와 일치하고, `"mcp.example.com:*"` 항목은 명시적 포트가 붙은 모든 경우와 일치합니다. 둘 다 넣으세요. +* 본문이 `Invalid Origin header`인 `403`은 `Origin` 헤더에 대한 자매 검사입니다. 브라우저에서만 발생하며(`Origin`을 보내는 것은 브라우저뿐입니다), 그 허용 목록은 `allowed_origins=`입니다. + +검사를 끄는 것이 정직한 설정인 경우를 포함해 자세한 내용은 **[배포와 확장](run/deploy.md)**에서 확인하세요. + +## `RuntimeError: Task group is not initialized. Make sure to use run().` {#runtimeerror-task-group-is-not-initialized-make-sure-to-use-run} + +MCP 앱이 다른 ASGI 앱 안에 마운트되어 있고, 아무것도 그 **세션 매니저**를 시작하지 않은 경우입니다. + +`mcp.streamable_http_app()`은 자체 lifespan에서 매니저를 시작하는 Starlette 앱을 반환하며, `uvicorn server:app`은 그 lifespan을 대신 실행해 줍니다. 하지만 Starlette은 **마운트된 하위 애플리케이션의 lifespan을 절대 실행하지 않으므로**, 앱이 `Mount` 안으로 들어가는 순간 매니저는 시작되지 않고 첫 요청에서 터집니다. + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/troubleshooting/tutorial005.py" +``` + +서버는 시작됩니다. 라우트도 확인됩니다. 그런 다음 `uvicorn`이 모든 요청마다 다음을 출력합니다. + +```text +ERROR: Exception in ASGI application +Traceback (most recent call last): + ... +RuntimeError: Task group is not initialized. Make sure to use run(). +``` + +클라이언트는 500을 받습니다. 해결책은 **호스트** 앱에 `mcp.session_manager.run()`에 진입하는 lifespan을 두는 것입니다. + +```python +@asynccontextmanager +async def lifespan(app: Starlette) -> AsyncIterator[None]: + async with mcp.session_manager.run(): + yield + + +app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lifespan) +``` + +한 앱에 여러 서버를 두는 경우와 FastAPI를 포함해, 이 주제는 **[기존 앱에 추가하기](run/asgi.md)**에서 다룹니다. 같은 클래스에서 나오는 이웃 문자열이 둘 있습니다. + +* `StreamableHTTPSessionManager .run() can only be called once per instance. Create a new instance if you need to run again.` 매니저는 일회용이며, 같은 앱의 lifespan에 두 번 진입하면 이 메시지가 나옵니다. +* `mcp.session_manager`는 `streamable_http_app()`이 호출된 **뒤에야** 존재하므로, 라우트를 먼저 만들고 매니저는 lifespan 안에서만 건드리세요. + +## `MCPError: Session not found` {#mcperror-session-not-found} + +서버가 클라이언트가 보낸 `Mcp-Session-Id`를 알아보지 못하는 경우이며, 거의 언제나 서버가 **재시작**되었기(또는 다른 인스턴스로 라우팅되었기) 때문입니다. 세션은 해당 프로세스 하나의 메모리에만 존재합니다. + +찾아야 할 서버 버그는 없습니다. HTTP 응답은 본문이 **실제로** JSON-RPC인 `404`이므로, 위의 `421`과 달리 Python `Client`가 이번에는 그대로 보여 줍니다. + +```json +{"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "Session not found"}} +``` + +해결책은 다시 연결하는 것입니다. `async with Client(...)` 블록을 벗어나 새 블록에 진입하면 새 세션을 협상합니다. 오래 실행되는 클라이언트라면, 호출을 감싸 `MCPError`를 잡은 뒤 이 메시지가 나오면 죽은 세션 안에서 재시도하지 말고 다시 연결해야 한다는 뜻입니다. + +재시작 **없이** 이 문제가 발생한다면, 스티키 세션 없이 워커를 둘 이상 실행하고 있는 것입니다. 각 워커가 자기만의 세션 테이블을 가지므로, 엉뚱한 워커로 라우팅된 요청이 여기에 도달합니다. 이 이야기와 두 가지 해결책(스티키 라우팅 또는 `stateless_http=True`)은 **[배포와 확장](run/deploy.md)**과 **[레거시 클라이언트 지원](run/legacy-clients.md)**에서 다룹니다. + +서버 운영자 쪽에서 대응하는 로그 줄은 `Rejected request with unknown or expired session ID: `입니다. `INFO` 수준으로 기록되므로 일반적인 `WARNING` 임계값에서는 보이지 않습니다. 배포 직후 이 줄이 한꺼번에 쏟아지는 것은 정상입니다. 연결되어 있던 모든 클라이언트가 다시 연결하는 중이기 때문입니다. + +## `MCPError: Method not found` {#mcperror-method-not-found} + +한쪽이 상대에게 핸들러가 없는 JSON-RPC 요청을 보낸 경우이며, `e.error.data`에 메서드 이름이 나옵니다. 흔한 원인은 **세대 불일치**입니다. 한 프로토콜 리비전에는 있고 다른 리비전에는 없는 메서드를 엉뚱한 쪽 피어에 보낸 경우로, 예를 들어 `2025` 세대의 `resources/subscribe`가 `2026-07-28` 연결에 도착하거나, `mode="legacy"`로 고정된 클라이언트가 `2026` 전용 `subscriptions/listen`을 보내는 경우입니다. 어느 쪽이 무엇을 말하는지에 관한 지도는 **[프로토콜 버전](protocol-versions.md)**이고, 또 하나의 정직한 원인(핸들러를 등록하지 않은 선택적 기능)은 **[자동 완성](servers/completions.md)**에 있습니다. + +최신 프로토콜에서 제거된 요청인데도 이 오류를 **내지 않는** 경우가 하나 있습니다. `2026-07-28` 연결에서 도구가 `ctx.elicit()`을 호출하는 경우입니다. 서버가 그 요청을 **보내는** 것 자체를 거부하므로, 대신 받게 되는 것은 이 페이지 아래쪽의 `Cannot send 'elicitation/create': ...`입니다. + +## `MCPError: Client did not declare the form elicitation capability required by resolver ''` {#mcperror-client-did-not-declare-the-form-elicitation-capability-required-by-resolver-name} + +서버가 사용자에게 무언가를 물어보려 하는데, 이 클라이언트가 물어볼 수 있다고 밝힌 적이 없는 경우입니다. + +엘리시테이션(elicitation) 리졸버는 연결된 클라이언트가 폼 엘리시테이션을 선언하지 않았으면 처음부터 거부하며, `e.error.data`가 정확히 무엇이 빠졌는지 알려 줍니다. + +```json +{ + "code": -32021, + "message": "Client did not declare the form elicitation capability required by resolver 'server:ask_to_confirm'", + "data": {"requiredCapabilities": {"elicitation": {"form": {}}}} +} +``` + +`Client(...)`에 `elicitation_callback=` 인자를 전달하세요. 콜백을 등록하는 것이 **곧** 기능 선언이며, 별도의 스위치는 없습니다. + +```python +async def main() -> None: + async with Client(mcp, elicitation_callback=handle_elicitation) as client: + result = await client.call_tool("book_table", {"date": "Friday"}) +``` + +나머지 콜백(`sampling_callback`, `list_roots_callback`)은 **[클라이언트 콜백](client/callbacks.md)**에 나열되어 있으며, 각각 같은 방식으로 선언 역할을 합니다. + +!!! info + `-32021`은 `MISSING_REQUIRED_CLIENT_CAPABILITY`로, 2026-07-28 사양이 추가한 세 오류 코드 중 + 하나입니다. 셋 중 어느 것도 예외 클래스가 아닙니다. 모두 `MCPError`로 도착하며, 살펴볼 곳은 + `e.error.code`입니다. 상수는 `mcp.types`가 내보냅니다. 나머지 둘은 + `-32020` `HEADER_MISMATCH`(HTTP 헤더가 함께 온 요청 본문과 어긋남)와 + `-32022` `UNSUPPORTED_PROTOCOL_VERSION`(요청이 이 서버가 말하지 않는 버전을 지정함)입니다. + 규격을 따르는 SDK 클라이언트는 둘 다 만들어 낼 수 없으므로, 둘 중 하나가 보인다면 클라이언트와 + 서버 사이에서 요청을 고쳐 쓰는 무언가를 살펴보세요. + +## `MCPError: Elicitation not supported` {#mcperror-elicitation-not-supported} + +`Client did not declare the form elicitation capability ...` 항목과 같은 공백을, 미리 검사하지 않는 경로가 표현한 것입니다. 서버는 엘리시테이션에 대한 답이 필요했고, 연결된 클라이언트는 `elicitation_callback`을 등록하지 않았습니다. + +이 메시지는 레거시 연결에서 `ctx.elicit()`을 호출할 때 나타나며, 반환된 다중 왕복 질문(**[다중 왕복 요청](handlers/multi-round-trip.md)**)이 답할 콜백이 없는 클라이언트에 도달하면 어떤 연결에서든 나타납니다. 해결책은 동일합니다. `Client(...)`에 `elicitation_callback=` 인자를 전달하세요. "사용자에게 묻지 않았다"는 상황이 도구에 `decline`으로 전달되는 경우는 없습니다. 물어볼 수 없는 클라이언트는 곧 실패한 호출이므로, 도구를 그에 맞게 설계하세요. + +## `MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests.` {#mcperror-cannot-send-elicitationcreate-this-transport-context-has-no-back-channel-for-server-initiated-requests} + +핸들러가 요청 도중에 클라이언트에 손을 뻗으려 했는데, 그 호출에 서버의 요청을 실어 나를 채널이 없는 연결이었던 경우입니다. 호출을 이런 상황에 놓는 서버 구성은 세 가지입니다. + +**`2026-07-28` 연결. 트랜스포트와 무관하게 항상.** 최신 프로토콜에는 서버가 시작하는 요청이 아예 없으므로, 서버는 무엇을 보내기도 전에 거부합니다. 도구 안에서 `ctx.elicit()`을 호출하는 것이 이 오류를 만나는 전형적인 길이며(`Client(server)`는 따로 요청하지 않아도 `2026-07-28`을 협상하므로, 첫 인메모리 테스트에서 바로 만납니다), `elicitation_callback=` 인자를 전달해도 달라지는 것은 없습니다. 클라이언트가 답할 요청 자체가 도달하지 않기 때문입니다. + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/troubleshooting/tutorial006.py" +``` + +```python +async def main() -> None: + async with Client(mcp) as client: + await client.call_tool("book_table", {"date": "Friday"}) +``` + +```text +mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests. +``` + +**`stateless_http=True` 서버의 레거시 연결.** 무상태란 모든 요청이 저마다 독립된 세계라는 뜻입니다. 세션도, 서버에서 클라이언트로 가는 스트림도 없으므로, 해당 메서드가 있는 세대라 해도 `elicitation/create`(또는 `sampling/createMessage`, `roots/list`)를 보낼 곳이 없습니다. + +```python title="server.py" hl_lines="16 23" +--8<-- "docs_src/troubleshooting/tutorial008.py" +``` + +**`json_response=True` 서버의 레거시 연결.** `POST`에는 JSON 본문 하나로 응답하며, 본문 하나에는 응답만 실리므로, 요청 도중의 `ctx.elicit()`에 필요한 요청 범위 스트림이 여기에도 존재하지 않습니다. 세션, 그 `Mcp-Session-Id`, 독립 스트림은 모두 그대로 있고, 사라진 것은 요청 범위 채널뿐입니다. + +메시지에는 보내지 못한 메서드 이름이 나옵니다. 서버가 일으키는 클래스는 `NoBackChannelError`이지만 와이어에는 기반 클래스인 `MCPError`만 실리므로, 트레이스백의 마지막 줄은 클래스 이름이 아니라 위의 문장입니다. + +`2026-07-28` 클라이언트라면 해결책은 세 경우 모두 같습니다. 호출 도중에 되돌아 손을 뻗지 마세요. 질문을 **리졸버**로 옮기면(또는 직접 `InputRequiredResult`를 반환하면) 질문이 **응답**의 일부가 되며, 응답은 모든 연결이 실어 나를 수 있습니다. + +```python title="server.py" hl_lines="15-17 21" +--8<-- "docs_src/troubleshooting/tutorial007.py" +``` + +질문도 같고, 클라이언트의 `elicitation_callback`도 같습니다. 차이는 내부에 있습니다. 리졸버를 쓰면 서버가 질문을 밀어 보내는 대신 호출에서 **반환**할 수 있으므로, 서버에서 클라이언트로 흐르는 것이 아무것도 없습니다. 이로써 서버가 세 구성 중 어느 것이든 모든 `2026-07-28` 클라이언트가 구제됩니다. **레거시** 클라이언트는 이렇게 고쳐 쓰는 것만으로는 구제되지 않습니다. `2025-11-25`에는 질문을 반환할 방법이 없으므로, 레거시 연결에서 리졸버는 여전히 요청 범위 채널로 `elicitation/create`를 보내며, 그 채널을 유지하는 서버(`stateless_http=True`도 `json_response=True`도 아닌 서버)가 여전히 필요합니다. 리졸버는 **[엘리시테이션](handlers/elicitation.md)**에서, 와이어에서 일어나는 일은 **[다중 왕복 요청](handlers/multi-round-trip.md)**에서 다룹니다. + +!!! check + `ctx.elicit()`을 쓰는 도구가 틀린 것은 아닙니다. **2026 이전** 방식일 뿐입니다. + `stateless_http=True`도 `json_response=True`도 아닌 서버에 `mode="legacy"`(고전적인 + `initialize` 핸드셰이크, 사양 `2025-11-25` 및 그 이전)로 연결하면 동작합니다. 거기에는 서버에서 + 클라이언트로 가는 채널이 있기 때문입니다. + 버전마다 무엇이 있는지는 **[프로토콜 버전](protocol-versions.md)**에서 다룹니다. + +## `MCPError: Invalid or expired requestState` {#mcperror-invalid-or-expired-requeststate} + +클라이언트가 되돌려 보낸 `requestState` 토큰을 서버가 검증하지 못해 해당 회차를 거부한 경우입니다. + +`requestState`는 **[다중 왕복](handlers/multi-round-trip.md)** 호출이 구간 사이에 들고 다니는 불투명한 재개 토큰입니다. `MCPServer`는 나가는 길에 토큰을 봉인하고 되돌아오는 것을 매번 검증하며, 토큰을 발행하지 않는 핸들러라 해도 `tools/call`, `prompts/get`, `resources/read`로 들어오는 `request_state`를 **전부** 검증합니다. 따라서 이 프로세스가 봉인하지 않은 토큰은 어디에 도착하든 거부됩니다. + +```python +async def main() -> None: + async with Client(mcp) as client: + await client.call_tool("forecast", {"city": "London"}, request_state="round-1-from-worker-a") +``` + +```text +mcp.shared.exceptions.MCPError: Invalid or expired requestState +``` + +메시지는 의도적으로 고정되어 있습니다. 와이어는 어느 검사가 실패했는지 절대 드러내지 않습니다. 이유는 **서버 로그**로 가며, 로그를 읽는 것이 진단의 전부입니다. + +```text +WARNING mcp.server.request_state: requestState rejected on tools/call: malformed +``` + +실제로 보게 될 이유는 다음과 같습니다. + +* **`unknown key`**가 중요한 이유입니다. 기본 봉인 키는 프로세스 시작 시 생성되므로, **다른 워커**, 로드 밸런서 뒤의 다른 인스턴스, 또는 **재시작 후의** 같은 서버에 도착한 재시도는 이 프로세스가 가져 본 적 없는 키로 봉인된 것입니다. 공격자가 아니라, 기본값이 둘 이상의 프로세스를 만난 것입니다. +* **`audience`**: 토큰이 **서버 이름이 다른** 인스턴스에서 봉인되었습니다. 이름이 봉인의 기본 audience 클레임이므로, 서버 군은 키뿐 아니라 이름도 공유해야 합니다(또는 명시적으로 `RequestStateSecurity(audience=...)`를 설정해야 합니다). +* **`expired`**: 회차가 봉인의 `ttl`보다 오래 걸렸습니다. 이 값은 600초이며 호출 단위가 아니라 회차 단위입니다. +* **`malformed`** / **`codec error`**: 토큰이 전송 중에 변조되었거나, 애초에 봉인된 토큰이 아니었습니다. +* **`request binding`**: 토큰이 다른 도구, 다른 인자, 또는 다른 메서드와 함께 돌아왔습니다. + +다중 프로세스 환경의 해결책은 인자 하나(모든 인스턴스에 **같은** `keys`)에, 인자가 아닌 한 가지, 즉 같은 서버 **이름**(또는 명시적으로 공유한 `audience=`)을 더한 것입니다. + +```python +mcp = MCPServer("Weather", request_state_security=RequestStateSecurity(keys=[key])) +``` + +봉인에는 `keys[0]`만 쓰이고 검증에는 목록의 모든 키가 쓰이며, 이것이 무중단 키 교체를 가능하게 합니다. 봉인이 보호하는 대상과 교체 순서는 **[다중 왕복 요청](handlers/multi-round-trip.md#protecting-requeststate)**에서 설명하고, 워커 두 개의 실패 전체와 두 부분으로 된 해결책은 **[배포와 확장](run/deploy.md)**에서 차근차근 살펴봅니다. + +!!! tip + `keys=[...]` 인자는 약한 키를 즉시 거부하며, 유난히 친절한 메시지를 냅니다. + + ```text + ValueError: request-state keys must be at least 32 bytes of secret randomness; keys[0] is 7 bytes. Generate one with: python -c "import secrets; print(secrets.token_hex(32))" + ``` + + 메시지가 시키는 대로 하세요. + +## 여전히 해결되지 않는 경우 {#still-stuck} + +* SDK가 낸 메시지가 이 페이지에 없다면, 그 자체로 제보할 가치가 있는 문서 버그입니다. +* [이슈 트래커](https://github.com/modelcontextprotocol/python-sdk/issues)를 검색하세요. 거기에 나오는 오류 문자열은 대부분 이미 누군가가 정리해 둔 것입니다. +* 아무것도 찾지 못했다면 전체 트레이스백과 함께 [이슈를 등록](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml)하거나, [MCP Contributors Discord의 #python-sdk-dev](https://discord.gg/6CSzBmMkjX)에서 물어보세요. + +## 요약 {#recap} + +* `ExceptionGroup: unhandled errors in a TaskGroup`은 절대 진짜 오류가 아닙니다. **마지막 줄**을 읽으세요. `async with Client(...)` 블록 **안에서** `MCPError`를 잡으면 감싸기를 완전히 건너뜁니다. +* `call_tool`은 실패한 도구에 대해 예외를 일으키지 않습니다. `Error executing tool ...` 및 `Unknown tool: ...` 메시지는 결과이므로 `result.is_error`를 확인하세요. +* `Client must be used within an async context manager` -> `async with`를 사용하세요. `Use @tool() instead of @tool` -> 괄호를 추가하세요. +* 서버 로그의 `Tool already exists:`는 이름이 같은 두 도구가 하나로 합쳐졌다는 유일한 신호입니다. +* 421 하나에 표기는 세 가지입니다. `Server returned an error response`(Python `Client`), `421 Misdirected Request` / `Invalid Host header`(그 밖의 모든 곳), `Invalid Host header: `(서버 로그). 해결책은 `transport_security=TransportSecuritySettings(allowed_hosts=[...])`입니다. +* `Task group is not initialized` -> 마운트된 앱에서 호스트 lifespan이 `mcp.session_manager.run()`에 진입하지 않은 경우입니다. +* `Session not found` -> 서버가 재시작되었습니다. 다시 연결하세요. +* `Cannot send 'elicitation/create': ... no back-channel ...` -> `ctx.elicit()`에는 서버에서 클라이언트로 가는 채널이 필요합니다. `2026-07-28` 연결에는 그런 채널이 아예 없고, `stateless_http=True`는 레거시 채널을 없애며, `json_response=True`는 요청 범위 채널을 없앱니다. 리졸버를 사용하세요(레거시 클라이언트라면 채널을 유지하는 서버도 필요합니다). 이웃인 `Method not found`는 상대편 프로토콜 리비전에 없는 메서드를 요청한 경우입니다. +* `Client did not declare the form elicitation capability ...` 및 `Elicitation not supported` -> 클라이언트에 `elicitation_callback=` 인자가 빠져 있습니다. +* `Invalid or expired requestState`는 와이어에서 이유를 절대 말하지 않습니다. 서버 로그가 말해 주며, `unknown key`는 워커 간에 `RequestStateSecurity(keys=[...])` 설정을 공유하라는 뜻입니다. diff --git a/i18n/ko/pages/whats-new.md b/i18n/ko/pages/whats-new.md new file mode 100644 index 0000000000..616d160dfd --- /dev/null +++ b/i18n/ko/pages/whats-new.md @@ -0,0 +1,214 @@ +--- +translation: + sections: [cfe01c0c5863dfa2, 11d93f1fa09eadf5, a7392996acf1ad8f, 875eb2889263424e] + tool: 1 +--- +# v2에서 달라진 점 {#whats-new-in-v2} + +v2에서는 두 가지 일이 동시에 일어났습니다. **SDK가 새로 만들어졌습니다**. 클라이언트와 서버 양쪽 아래에 새 엔진이 들어갔고, `Client`가 일급 객체가 되었으며, v1 코드베이스가 첫 import에서 바로 마주치는 일련의 이름 변경이 있습니다. 그리고 **프로토콜이 바뀌었습니다**. v2는 MCP의 2026-07-28 개정판을 사용하며, 이 개정판은 이미 사용 중인 클라이언트를 낙오시키지 않으면서 연결 핸드셰이크, 세션, 서버가 시작하는 모든 요청을 제거합니다. + +이 페이지는 두 부분을 둘러보는 안내입니다. 주요 변경 사항마다 한 섹션씩 다루고, 각 섹션은 해당 주제를 담당하는 페이지로 안내하며 끝납니다. 포팅 매뉴얼은 아닙니다. 포팅 매뉴얼은 **[마이그레이션 가이드](migration.md)**이며, 호환성이 깨지는 모든 변경 사항을 변경 전후 코드와 함께 담고 있습니다. + +!!! note "v2가 안정 릴리스 라인입니다" + `pip install mcp`는 2.x를 설치하며, 복사해 붙여 넣을 수 있는 설치 명령은 + **[설치](get-started/installation.md)**에 있습니다. v2에서 무언가가 깨지거나, 뜻밖으로 동작하거나, 작업을 더디게 만든다면 + [알려 주세요](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml). + +## SDK: v1에서 v2로 {#the-sdk-v1-to-v2} + +### `MCPServer`로 바뀐 `FastMCP` {#fastmcp-is-now-mcpserver} + +고수준 서버 클래스의 이름이 바뀌었고, 모듈도 함께 바뀌었습니다. 이전 import 경로가 지원 중단 예정(deprecated)이 아니라 아예 사라졌기 때문에, 모든 v1 서버가 가장 먼저 부딪히는 변경입니다. + +```python +from mcp.server import MCPServer # v1: from mcp.server.fastmcp import FastMCP + +mcp = MCPServer("Demo") # v1: FastMCP("Demo") +``` + +데코레이터로 만든 서버라면 포팅 작업의 대부분도 이것으로 끝납니다. `@mcp.tool()`, `@mcp.resource()`, `@mcp.prompt()`는 v1에서 받던 것을 그대로 받고(`@mcp.resource()`에는 선택적 `security=` 키워드가 하나 추가되었습니다), 입력 스키마는 여전히 타입 힌트에서 만들어집니다. 주변부의 변경은 다음과 같습니다. `mcp.server.fastmcp.*` 아래에 있던 모든 것은 이제 `mcp.server.mcpserver.*` 아래에 있고, `ctx.fastmcp`는 `ctx.mcp_server`가 되었으며, `get_context()`는 사라졌고(대신 `ctx: Context` 매개변수를 선언하세요), 예외 기반 클래스 `FastMCPError`는 `MCPServerError`가 되었습니다. import 대응표는 **[마이그레이션 가이드](migration.md#fastmcp-renamed-to-mcpserver)**에 있습니다. + +### `Resolve`: 사용자에게 입력을 요청하는 새로운 방법 {#resolve-the-new-way-to-ask-the-user-for-input} + +도구에 필요한 모든 것이 모델에서 와야 하는 것은 아닙니다. v2에 새로 추가된 기능으로, `Resolve(fn)` 어노테이션이 붙은 도구 매개변수는 모델에게 보이지 않게 직접 작성한 함수가 대신 채우며, 그 함수는 `Elicit(...)`을 반환해 사용자에게 질문을 띄울 수 있습니다. 호출 도중 클라이언트로부터 무언가를 얻는 방법으로는 이것이 권장됩니다. SDK는 연결이 지원하는 메커니즘을 통해 질문을 전달합니다. 레거시 클라이언트에는 실시간 엘리시테이션(elicitation) 요청으로, 2026-07-28 연결에는 다중 왕복으로 전달하므로, 도구 본문 하나로 두 시대를 모두 지원합니다. 자세한 내용은 **[의존성](handlers/dependencies.md)**에서 확인하세요. + +!!! note + 필요할 때는 나머지 두 형태도 그대로 쓸 수 있습니다. `ctx.elicit()`은 레거시 연결의 클라이언트에 대해 + 여전히 동작하고(**[엘리시테이션](handlers/elicitation.md)**), 핸들러가 직접 + `InputRequiredResult`를 반환해 왕복을 손수 진행할 수도 있습니다. 2026-07-28에서 샘플링과 + 루트 요청이 전달되는 방식도 바로 이것입니다(**[다중 왕복 요청](handlers/multi-round-trip.md)**). + +### 일급 `Client` {#a-first-class-client} + +v1은 세 겹으로 중첩된 계층을 건네주었습니다. 원시 스트림을 내놓는 트랜스포트 컨텍스트 매니저, 이를 감싸는 `ClientSession`, 그리고 직접 호출해야 하는 `await session.initialize()`입니다. v2에는 객체가 하나뿐입니다. + +```python title="client.py" hl_lines="14-18" +--8<-- "docs_src/client/tutorial001.py" +``` + +`Client`는 서버 객체(트랜스포트 없이 인메모리로 동작하며, 테스트에 쓰는 방식입니다), URL(Streamable HTTP), 또는 `stdio_client(...)` 같은 임의의 트랜스포트 컨텍스트 매니저를 받습니다. `async with`에 진입하면 서버가 어느 시대의 프로토콜을 말하든 연결을 맺고 프로토콜 버전을 협상합니다. 그 뒤에는 `client.server_capabilities`와 `client.protocol_version`이 그냥 준비되어 있고, 서버가 자신을 식별하는 경우에는 `client.server_info`도 마찬가지입니다(2026 시대에는 식별 정보가 선택 사항이므로 이제 타입은 `Implementation | None`입니다). v1에서 등록한 샘플링 및 엘리시테이션 콜백은 여전히 동작하며(콜백 본문에는 이 페이지의 다른 모든 것과 마찬가지로 snake_case 속성 이름 변경이 적용됩니다), 이제 2026 방식의 결과 속 요청(아래 참고)에도 응답하고, 한 번에 하나씩이 아니라 동시에 실행됩니다. 저수준 인터페이스를 원하는 경우를 위해 `ClientSession`은 여전히 그 아래에 있으며 `client.session`으로 얻을 수 있습니다. 다만 이 클래스 역시 바뀌었으므로(새 디스패처 엔진 위에서 실행되고, 자체 시그니처 일부도 변경되었습니다) 아래 계층으로 내려가기 전에 **[마이그레이션 가이드](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)**를 읽어 보세요. + +**[클라이언트](client/index.md)**에서 소개하고, **[클라이언트 트랜스포트](client/transports.md)**에서 세 가지 연결 형태를, **[클라이언트 콜백](client/callbacks.md)**에서 콜백 자체를 다루며, **[테스트](get-started/testing.md)**에서는 v1의 `create_connected_server_and_client_session()` 헬퍼를 대체하는 인메모리 패턴을 보여 줍니다. + +### 저수준 `Server`: 이름 변경이 아닌 재구축 {#the-low-level-server-was-rebuilt-not-renamed} + +JSON-RPC 계층에서 작업한다면, v2에서 "모든 것이 달라진" 부분이 바로 여기입니다. 도구가 하나인 같은 서버를 두 방식으로 보여 드립니다. 마커를 클릭하면 무엇이 바뀌었는지 볼 수 있습니다. + + + +```python title="v1" +from typing import Any + +import mcp.types as types +from mcp.server.lowlevel import Server + +server = Server("Bookshop") + + +@server.list_tools() # (1)! +async def list_tools() -> list[types.Tool]: + return [ # (2)! + types.Tool( + name="search_books", + description="Search the catalog by title or author.", + inputSchema={ # (3)! + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + }, + ) + ] + + +@server.call_tool() +async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentBlock]: # (4)! + if name != "search_books": + raise ValueError(f"Unknown tool: {name}") # (5)! + ctx = server.request_context # (6)! + return [types.TextContent(type="text", text=f"Found 3 books matching {arguments['query']!r}.")] # (7)! +``` + +1. 핸들러는 서버가 생성된 뒤 언제든 데코레이터로 등록합니다(괄호를 붙여 호출하는 형태로). +2. `list[Tool]`을 그대로 반환하면 SDK가 `ListToolsResult`로 감싸 줍니다. +3. 필드는 Python에서 camelCase이고, 스키마는 **강제됩니다**. 함수가 실행되기 전에 SDK가 `call_tool` 인자를 이 스키마에 맞춰 jsonschema로 검증하므로, 아래의 `arguments["query"]`가 안전합니다. +4. `call_tool` 핸들러 하나가 모든 도구를 처리하며, 도구 이름과 이미 검증된 인자를 받습니다. 인자는 풀어서 전달되고 `None`인 경우는 없습니다. +5. v1 도구는 예외를 발생시켜 실패를 알립니다. 어떤 예외든 잡혀서 `str(e)` 값을 텍스트로 하는 `CallToolResult(isError=True)`로 반환되므로, 호출한 모델이 이 메시지를 읽고 재시도할 수 있습니다. +6. 컨텍스트는 암묵적 ContextVar에서 오며, 요청 도중 서버 객체를 통해 접근합니다. +7. 콘텐츠 블록을 그대로 반환하면 `CallToolResult`로 감싸 줍니다. + +```python title="v2" +--8<-- "docs_src/whats_new/tutorial001.py" +``` + +1. 필드는 이제 snake_case이고, 스키마는 **알려 주기만 할 뿐 적용되지는 않습니다**. 핸들러가 실행되기 전에 인자를 검사하는 것은 아무것도 없습니다. +2. 모든 핸들러는 `async (ctx, params) -> result`라는 같은 형태입니다. 컨텍스트가 첫 번째 인자이며(`ctx.session`, `ctx.request_id`, `ctx.protocol_version`이 여기에 있습니다), `server.request_context`가 옮겨 간 곳이 바로 여기입니다. +3. 완전한 `ListToolsResult`를 직접 만듭니다. 리스트를 그대로 반환하면 이제 SDK가 감싸 주는 것이 아니라 서버 측 `TypeError`가 됩니다. +4. 타입이 지정된 params가 들어오고(`params.name`, `params.arguments`), 완전한 결과가 나갑니다. 풀어 주거나, 감싸 주거나, 변환해 주는 것은 없습니다. +5. 검사는 같고 수단이 다릅니다. 여기서 `ValueError`를 발생시키면 모델에는 불투명한 `-32603` 오류로 전달되므로(아래 참고), 의도적인 와이어 오류는 `MCPError`로 발생시킵니다. 이 오류는 코드와 메시지를 유지한 채 그대로 통과하며, 이 텍스트를 담은 `-32602` 응답은 알 수 없는 도구에 대해 사양 자체가 정한 응답입니다. +6. `params.arguments`는 `None`일 수 있습니다. v1에서는 코드가 이 값을 보기도 전에 빈 딕셔너리(`{}`)가 기본값으로 채워졌습니다. 핸들러 앞단에 검증이 없으므로 이 줄은 없어서는 안 됩니다. +7. 여기서 예상치 못한 예외가 발생하면 **내용이 가려진(sanitized)** 프로토콜 오류, 즉 `-32603` `"Internal server error"` 오류가 되며 모델은 메시지를 보지 못합니다. 모델이 읽고 반응해야 하는 실패라면 `CallToolResult(is_error=True, ...)`를 반환하세요. +8. 핸들러는 생성자 인자이므로, 서버의 인터페이스는 서버가 만들어지는 순간 완성됩니다. `add_request_handler()`는 생성 이후에 쓸 수 있는 비상 탈출구이자, 커스텀 메서드로 통하는 문입니다. + +이 예제가 곧 패턴입니다. 더 일반적으로 말하면 다음과 같습니다. 모든 핸들러는 타입이 지정된 params가 들어오고 완전한 결과 타입이 나가는 같은 형태이고, 도구 인자에 대한 예전의 jsonschema 검사는 사라졌으며, 예외는 언제나 프로토콜 오류이지 `is_error=True` 도구 결과가 되는 일은 없고, 암묵적 `server.request_context` ContextVar는 사라졌습니다. 벤더 네임스페이스를 붙인 커스텀 메서드는 `add_request_handler(method, params_type, handler)`를 통해 일급으로 지원되며, 이 함수는 핸들러가 실행되기 전에 들어오는 params를 작성한 모델에 맞춰 검증합니다. 그리고 `middleware` 목록(의도적으로 잠정적인 것으로 표시되어 있습니다)이 들어오는 모든 메시지를 감싸며, 사람들이 오버라이드하던 비공개 `_handle_*` 메서드를 대체합니다. + +그 아래에서는 v1의 `BaseSession` 수신 루프가 이제 클라이언트와 서버가 공유하는 디스패처 엔진으로 교체되었으며, 이 페이지의 여러 내용이 동시에 성립하는 것도 이 엔진 덕분입니다. `Server` 객체 하나가 두 프로토콜 시대를 모두 지원하고, `Client(server)`는 JSON-RPC 프레이밍 없이 프로세스 안에서 디스패치하며, 시간 초과된 클라이언트 요청은 이제 실제로 서버 측 핸들러를 취소합니다. + +자세한 내용은 **[저수준 Server](advanced/low-level-server.md)**에서 확인하세요. **[마이그레이션 가이드](migration.md#lowlevel-server-decorator-based-handlers-replaced-with-constructor-on_-params)**는 제거된 훅을 하나하나 짚어 줍니다. `MCPServer` 아래로 내려간 적이 없다면 이 내용은 전혀 해당하지 않습니다. + +### `mcp-types`로 옮겨 간 와이어 타입과 snake_case가 된 모든 필드 {#the-wire-types-moved-to-mcp-types-and-every-field-is-snake_case} + +프로토콜 타입은 이제 별도의 배포 패키지 `mcp-types`에 있습니다. 이 패키지는 pydantic과 typing-extensions 외에는 아무것에도 의존하지 않으므로, 게이트웨이나 프록시, 코드 생성기가 HTTP 스택을 설치하지 않고도 MCP의 와이어 형태를 사용할 수 있습니다. 그런 프로젝트는 `mcp-types`를 설치하고 `mcp_types`를 import합니다. `mcp` 자체는 그 패키지의 정확한 버전에 의존하며 이를 다시 노출하므로, SDK에 의존하는 코드는 계속 `import mcp.types as types`와 `from mcp.types import Tool`을 쓰고(영구적인 별칭이며, 모든 이름이 같은 객체입니다) 실제 의존성인 `mcp` 하나만 선언하면 됩니다. 경험칙은 이렇습니다. 실제로 의존하는 패키지를 통해 import하세요. + +이 타입에서 모든 Python 속성은 이제 snake_case입니다. `result.is_error`, `tool.input_schema`, `listing.next_cursor`처럼 씁니다. 와이어 위의 JSON은 전과 똑같이 camelCase이며, 바뀐 것은 속성 표기뿐입니다. 더 엄격해진 기본값 두 가지도 함께 따라옵니다. 알 수 없는 필드는 왕복 보존되는 대신 무시되고(추가 데이터는 `_meta`에 넣으세요), 양쪽 모두 협상한 프로토콜 버전에 맞춰 트래픽을 검증합니다. 이름 변경 표는 **[마이그레이션 가이드](migration.md#field-names-changed-from-camelcase-to-snake_case)**를 참고하세요. + +### `run()`으로 옮겨 간 트랜스포트 설정 {#transport-configuration-moved-to-run} + +`MCPServer(...)`는 서버가 **무엇인지**에 관한 것입니다. 이름, instructions, lifespan, 인증이 여기에 해당합니다. 서버를 어떻게 **구동하는지**는 이제 `run()`과 앱 빌더의 몫이며, `host`, `port`, `stateless_http`, `json_response`, 엔드포인트 경로, `transport_security`가 그쪽으로 옮겨 갔습니다(`MCPServer("x", port=9000)`처럼 쓰면 `TypeError`가 납니다). 오버로드는 트랜스포트별로 타입이 지정되어 있으므로, `stdio`가 받는 옵션과 `streamable-http`가 받는 옵션을 에디터가 알려 줍니다. 알아 둘 만한 제거 사항이 하나 있습니다. `mount_path`가 사라졌으며, 접두 경로 아래에서 서비스하려면 ASGI 앱을 마운트하는 것이 지원되는 방법입니다. + +옵션은 **[서버 실행하기](run/index.md)**에서, 마운트는 **[기존 앱에 추가하기](run/asgi.md)**에서 다룹니다. + +### import 오류 없이 바뀌는 동작 {#behavior-that-changes-without-an-import-error} + +이름 변경은 스스로 존재를 알립니다. 다음 항목은 그렇지 않습니다. + +* **동기 함수는 워커 스레드에서 실행됩니다.** `def` 도구(또는 리소스, 프롬프트, 리졸버)는 더 이상 이벤트 루프를 막지 않습니다. 그 대가로 본문이 더 이상 이벤트 루프 스레드 **위에서** 실행되지 않으며, 이는 특정 스레드에서 실행되어야 하는 코드에는 중요한 차이입니다. `async def` 핸들러는 영향이 없습니다. **[마이그레이션 가이드](migration.md#sync-handler-functions-now-run-on-a-worker-thread)**를 참고하세요. +* **도구 안에서 발생시킨 `MCPError`(v1의 `McpError`)는 이제 프로토콜 오류입니다.** 모델은 이 오류를 보지 못합니다. 그 밖의 모든 예외는 여전히 모델이 읽고 반응할 수 있는 `is_error=True` 결과가 됩니다. 이 구분은 **[오류 처리](servers/handling-errors.md)**에서 다룹니다. +* **결과는 나가기 전에 검증됩니다.** `input_schema`가 `{}`인 손수 만든 `Tool`은 이제 `tools/list`에서 실패합니다(사양은 `"type": "object"`를 요구합니다). `@mcp.tool()`로 만든 서버는 이 문제를 겪지 않습니다. 스키마를 SDK가 작성하기 때문입니다. +* **클라이언트는 받은 것을 검증합니다.** `list_tools()`와 `call_tool()`은 서버의 응답을 협상한 프로토콜 버전에 맞춰 검사하므로, v1의 관대한 파싱이 눈감아 주던 완전히 유효하지는 않은 서버는 이제 `pydantic.ValidationError`를 발생시킵니다. 직접 제어하지 않는 서버에 연결한다면 그런 서버를 가장 먼저 발견하는 쪽이 될 것을 예상하세요. 자세한 내용은 **[마이그레이션 가이드](migration.md#client-validates-inbound-traffic-against-the-protocol-schema)**에 있습니다. +* **URI 템플릿은 이제 진짜 RFC 6570입니다.** `{+path}`, `{?query}` 등이 동작하고, 매칭은 정규식처럼 느슨한 것이 아니라 정확하며, 추출된 값의 경로 탐색(path traversal)은 기본적으로 거부됩니다. 더 엄격해진 템플릿은 첫 요청 때가 아니라 데코레이터를 적용하는 시점에 실패합니다. **[URI 템플릿](servers/uri-templates.md)**을 참고하세요. +* **Streamable HTTP의 lifespan은 한 번만 실행됩니다.** 시작 시에 실행되며, 그 상태는 모든 세션과 요청이 공유합니다. v1에서는 세션마다 한 번, `stateless_http=True`에서는 요청마다 한 번 실행되었습니다. lifespan에서 만드는 풀과 캐시는 훨씬 저렴해지고, 거기서 연결별 리소스를 획득하던 코드는 이제 핸들러 본문에 있어야 합니다. **[Lifespan](handlers/lifespan.md)**을 참고하세요. +* **`mcp dev`와 `mcp install`은 생성하는 환경을** 설치된 SDK 버전에 고정합니다. 두 명령 모두 새로운 `uv run --with ...` 환경에서 서버를 실행하는데, 예전에는 이 환경이 `mcp`를 개발 중인 버전이 아니라 최신 안정 릴리스로 해석했습니다. **[마이그레이션 가이드](migration.md#mcp-dev-and-mcp-install-pin-the-spawned-environment-to-your-sdk-version)**를 참고하세요. +* **HTTP 클라이언트는 이제 `httpx`가 아니라 `httpx2`입니다.** 의존성이 바뀌면서 코드가 잡고 전달하는 대상이 달라지고(`httpx2.AsyncClient`, `httpx2.ConnectError`), TLS 인증서를 검증하는 방식도 달라집니다. `httpx2`는 certifi에 번들된 CA 목록 대신 `truststore`를 통해 운영 체제의 신뢰 저장소에 맞춰 검증합니다. 대부분의 환경에서는 전혀 알아채지 못합니다. 시스템 CA 저장소가 없는 최소 구성 컨테이너나, certifi 번들만 알고 있던 사설 CA는 TLS 핸드셰이크에 실패하기 시작합니다. `SSL_CERT_FILE`/`SSL_CERT_DIR` 환경 변수를 설정하거나 클라이언트에 `verify=ssl_context`를 전달하세요. **[마이그레이션 가이드](migration.md#httpx-and-httpx-sse-replaced-by-httpx2)**를 참고하세요. + +### 완전히 제거된 것 {#removed-outright} + +다음 각 항목은 **[마이그레이션 가이드](migration.md)**에 섹션으로 정리되어 있습니다. + +* **WebSocket 트랜스포트**(양쪽 모두)와 `mcp[ws]` extra. MCP 사양의 일부였던 적이 없습니다. +* **실험적 Tasks** API(`mcp.*.experimental`). 2026-07-28은 태스크를 핵심 프로토콜에서 빼내 공식 확장([SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663))으로 옮겼으며, 이 SDK는 아직 이를 구현하지 않습니다. +* import 경로로서의 `mcp.shared.version`, `mcp.shared.progress`, `mcp.shared.session`(v1 `message_handler` 어노테이션이 import하던 `RequestResponder` 스텁 포함). (`mcp.types`는 제거되지 **않았습니다**. 독립 패키지 `mcp_types`의 영구 별칭으로 남아 있습니다.) +* 지원 중단 예정이던 `streamablehttp_client` 표기, 그리고 `streamable_http_client`의 `get_session_id` 콜백(이 함수는 이제 정확히 스트림 두 개를 내놓습니다). +* `McpError`. `(code, message, data)`를 직접 받는 생성자를 갖춘 **`MCPError`**로 이름이 바뀌었습니다. +* `MCPServer.get_context()`, `mount_path=`, 그리고 저수준 `Server`의 데코레이터 메서드, ContextVar, 핸들러 딕셔너리. + +## 프로토콜: 2025-11-25에서 2026-07-28로 {#the-protocol-2025-11-25-to-2026-07-28} + +v2는 2026-07-28 개정판을 구현하며, **두** 개정판을 동시에 지원합니다. 동일한 `streamable_http_app()`과 동일한 stdio 서버가 아무것도 설정할 필요 없이, 켜야 할 플래그도, 별도의 배포도 없이 2025 시대 클라이언트의 `initialize`와 2026 시대 클라이언트의 요청에 모두 응답합니다. 새 개정판을 지원한다고 해서 예전 개정판을 쓰는 클라이언트가 낙오되지 않습니다. 아래는 새 개정판 자체가 바꾸는 내용입니다. + +### 핸드셰이크도 세션도 없음 {#no-handshake-no-session} + +2026-07-28 클라이언트는 연결을 열고, 협상하고, 그다음 대화하는 식으로 동작하지 않습니다. 모든 요청이 프로토콜 버전, 클라이언트 정보, 클라이언트 기능을 `_meta`에 담아 보내며, 유일한 탐색 호출인 `server/discover`도 다른 요청과 다를 바 없는 평범한 요청입니다. `Client`는 기본적으로 알맞게 동작합니다. `server/discover`를 한 번 시도해 보고, 서버가 더 오래된 경우 `initialize` 핸드셰이크로 되돌아갑니다. + +Streamable HTTP에서는 2026 경로에 `Mcp-Session-Id`가 없으며, 운영 측면에서 가장 중요한 점이 바로 이것입니다. **최신 방식의 요청을 특정 워커에 묶는 것이 아무것도 없으므로**, 단순한 라운드 로빈 로드 밸런서 뒤의 어느 복제본이든 응답할 수 있습니다. 솔직하게 두 가지 단서를 달아 둡니다. 2025 시대 클라이언트(오늘날 대부분의 클라이언트가 여기에 해당합니다)는 여전히 세션을 열고, v1에서 필요했던 고정(stickiness)이 무엇이든 여전히 필요합니다. 이 클라이언트에게는 아무것도 바뀌지 않습니다. 그리고 **다중 왕복** 재시도가 워커를 가로질러 가지고 다녀야 하는 단 하나는 봉인된 `request_state`인데, 그 기본 키는 프로세스마다 발급되므로 수평 확장한 배포에서는 `RequestStateSecurity(keys=[...])`를 전달합니다. (`stateless_http=True`는 이와 무관합니다. 2025 시대 클라이언트를 어떻게 지원하는지에만 영향을 주며 2026 트래픽은 이 값을 읽지 않습니다. v1에서 이미 설정해 두었다면 아무것도 바뀌지 않습니다.) + +클라이언트 쪽 이야기는 **[프로토콜 버전](protocol-versions.md)**에서, 운영자용 체크리스트(Host 허용 목록, `request_state` 키, 복제본 간 알림)는 **[배포와 확장](run/deploy.md)**에서, 두 시대를 동시에 지원하는 이야기는 **[레거시 클라이언트 지원](run/legacy-clients.md)**에서 확인하세요. + +### 클라이언트를 호출할 수 없는 서버: 다중 왕복 요청 {#the-server-cannot-call-the-client-multi-round-trip-requests} + +2026-07-28에서는 서버가 시작하는 요청이 모두 사라졌습니다. 푸시 방식 엘리시테이션, 샘플링, `roots/list`가 여기에 해당합니다. 2026 연결에는 이를 위한 채널이 없으므로, `ctx.elicit()`과 `ctx.session.create_message()`는 그 연결에서 `NoBackChannelError`로 실패합니다(레거시 클라이언트에 대해서는 여전히 동작합니다). + +대체 방식은 호출의 방향을 뒤집습니다. 사용자로부터 무언가가 필요한 도구는 질문을 **반환**하고(`InputRequiredResult`), 클라이언트는 늘 갖고 있던 것과 같은 콜백으로 질문에 답하며, 답이 첨부된 채로 호출이 재시도됩니다. 이 루프는 `Client`가 대신 돌려 줍니다. 서버에서 결과를 직접 만드는 일은 드뭅니다. **[의존성](handlers/dependencies.md)**이 대신 해 주기 때문입니다. 매개변수에 `Resolve(ask_quantity)` 어노테이션을 달면(`ask_quantity`는 직접 작성하는 평범한 함수입니다), SDK가 연결이 지원하는 메커니즘, 즉 레거시 세션에서는 실시간 엘리시테이션 요청, 2026에서는 다중 왕복을 통해 질문합니다. 도구 본문 하나로 두 시대를 지원합니다. + +```python title="dual_era.py" hl_lines="24 37-38" +--8<-- "docs_src/legacy_clients/tutorial001.py" +``` + +이 파일 하나에 핵심이 모두 담겨 있습니다. 서버 하나, `Resolve` 기반 도구 하나, 그리고 레거시 클라이언트와 최신 클라이언트가 모두 인메모리로 답을 받습니다. 메커니즘(SDK가 대신 봉인하고 검증하는 `request_state` 포함)은 **[다중 왕복 요청](handlers/multi-round-trip.md)**에서 설명하고, 질문하는 쪽은 **[엘리시테이션](handlers/elicitation.md)**에서 다룹니다. + +!!! warning "포팅한 v1 서버의 동작이 바뀌는 유일한 지점입니다" + 가장 먼저 부딪히는 것은 직접 작성한 테스트입니다. `Client(mcp)`는 기본적으로 v2 서버와 2026-07-28을 + 협상하므로, `ctx.elicit()`을 호출하는 도구는 v1에서 통과하던 테스트에서 실패합니다. 질문을 + `Resolve(...)` 매개변수로 옮기거나(시대에 구애받지 않습니다), 정말로 푸시 동작을 원한다면 테스트 클라이언트를 + `mode="legacy"`로 고정하세요. + +### 지원 중단 예정인 루트, 샘플링, 프로토콜 로깅과 제거된 `ping` {#roots-sampling-and-protocol-logging-are-deprecated-ping-is-removed} + +[SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)은 모든 프로토콜 버전에서 **기능** 세 가지를 통째로 지원 중단 예정으로 지정합니다. 루트, 샘플링, 그리고 MCP 수준 로깅(`ctx.info()` 등)입니다. 이는 위에서 말한 백 채널 부재와는 별개의 축입니다. 지원 중단 예정은 권고일 뿐이며, 2025 시대 세션에 대해서는 모든 것이 계속 동작하고, 와이어에서는 아무것도 바뀌지 않습니다. 눈에 띄는 것은 `MCPDeprecationWarning`인데, 이는 `UserWarning`이므로 기본적으로 출력됩니다. 업그레이드 후 처음 호출하는 `ctx.info(...)`에서 이 경고가 나타날 것으로 예상하세요. + +`ping`은 더 엄격합니다. 지원 중단 예정이 아니라 프로토콜에서 제거되었습니다. 지원 중단 예정 기능의 독립 메서드 중 두 개, 즉 `logging/setLevel`과 클라이언트의 `notifications/roots/list_changed`도 2026-07-28에서 같은 방식으로 제거되었으며, 진행 상황 알림은 이제 서버에서 클라이언트 방향으로만 갑니다. + +전체 표와 각각의 대체 방법, 그리고 레거시 클라이언트를 지원하는 동안 조용한 로그가 필요할 때 쓸 한 줄짜리 필터는 **[지원 중단 예정 기능](deprecated.md)**에서 확인하세요. + +### 하나의 스트림이 된 변경 알림 {#change-notifications-become-one-stream} + +2026-07-28에서는 독립적인 HTTP GET 스트림과 `resources/subscribe`가 `subscriptions/listen`으로 대체됩니다. 클라이언트가 오래 유지되는 스트림 하나를 열고 원하는 알림 종류를 지정하는 방식입니다. `MCPServer`는 이를 기본으로 지원합니다. `await ctx.notify_resource_updated(uri)`를 호출해 발행하고(`notify_tools_changed()` 등도 마찬가지입니다), 미들웨어는 호출자별로 listen 요청을 거부할 수 있으며, 복제본이 여럿인 배포에서는 공유 `SubscriptionBus`를 연결합니다. 클라이언트에서는 `async with client.listen(...)`이 스트림을 엽니다. 필터는 키워드 인자로 들어가고, 타입이 지정된 변경 이벤트가 돌아오며, `sub.honored`는 서버가 전달하기로 동의한 부분집합입니다. + +발행과 서비스는 **[구독](handlers/subscriptions.md)**에서, 지켜보는 쪽은 **[클라이언트 섹션의 구독 페이지](client/subscriptions.md)**에서, 버스는 **[배포와 확장](run/deploy.md)**에서 다룹니다. + +### 나머지 변경 사항 한눈에 보기 {#the-rest-quickly} + +* **식별 정보는 선택적인 메시지별 메타데이터입니다.** 요청 쪽의 `clientInfo` `_meta` 키는 선택 사항이고(필수 쌍은 `protocolVersion` + `clientCapabilities`입니다), `serverInfo`는 `server/discover` 결과 본문에서 빠졌습니다. 대신 서버가 2026 시대의 모든 결과의 `_meta`에 찍어 넣습니다([사양 #3002](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3002)). SDK는 항상 찍어 넣으며, 서버가 자신을 식별하지 않는 경우(예를 들어 미들웨어가 키를 제거한 경우) `client.server_info`는 `None`입니다. 와이어에 찍힌 모습은 **[저수준 Server](advanced/low-level-server.md)**에서 볼 수 있습니다. +* **본문을 파싱하지 않고도 요청을 라우팅할 수 있습니다.** 최신 방식의 HTTP 요청에는 `Mcp-Method`가 실리고(도구 성격의 호출 세 가지에는 `Mcp-Name`도 실립니다), `x-mcp-header`로 어노테이션한 도구 입력 스키마 속성은 `Mcp-Param-*` 헤더로 복제되어 서버가 교차 검증합니다([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243)). 게이트웨이와 속도 제한기는 헤더만으로 라우팅할 수 있습니다. 규칙은 **[마이그레이션 가이드](migration.md#servers-validate-mcp-param-headers-against-the-request-body-sep-2243)**에 있습니다. +* **결과에 캐시 힌트가 실립니다.** 목록 및 읽기 결과는 `ttlMs`, `cacheScope` 두 필드를 선언합니다([SEP-2549](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549)). 메서드별로 `cache_hints=` 인자로 설정하며, `Client`는 내장 응답 캐시로 이를 따릅니다. 힌트를 보내지 않는 서버(2026 이전의 모든 서버)는 이전과 동일한, 캐시되지 않은 트래픽을 받습니다. **[캐시 힌트](client/caching.md)**를 참고하세요. +* **확장은 일급입니다.** 서버와 클라이언트는 역방향 DNS 식별자 아래에 선택적 기능 묶음을 선언합니다([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)). 내장 `Apps` 확장(MCP Apps)이 참조 구현입니다. **[확장](advanced/extensions.md)**과 **[MCP Apps](advanced/apps.md)**를 참고하세요. +* **오류 코드가 표준화되었습니다.** 없는 리소스는 `error.data`에 URI를 담은 `-32602` 오류이고, 사양이 새로 예약한 코드는 `-32020`(헤더 불일치), `-32021`(필수 기능 누락), `-32022`(지원하지 않는 프로토콜 버전)입니다. **[문제 해결](troubleshooting.md)**은 정확한 메시지를 기준으로 정리되어 있습니다. +* **인가를 잘못 쓰기가 더 어려워졌습니다.** 클라이언트는 인가 코드와 함께 반환되는 `iss`를 검증하고([RFC 9207](https://datatracker.ietf.org/doc/html/rfc9207), 이에 따라 `callback_handler`는 이제 `AuthorizationCodeResult`를 반환합니다), 등록할 때 `application_type`을 보내며, 자격 증명을 다른 인가 서버에 재사용하지 않습니다. 엔터프라이즈 쪽에 새로 추가된 것은 [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) 신원 어설션(identity assertion) 흐름입니다. 모든 OAuth 변경 사항은 **[마이그레이션 가이드](migration.md)**에 나열되어 있고, 관련 페이지는 **[클라이언트용 OAuth](client/oauth-clients.md)**와 **[신원 어설션](client/identity-assertion.md)**입니다. +* **모든 서버는 추적 가능합니다.** OpenTelemetry가 미들웨어로 기본 활성화되어 제공됩니다. 모든 요청에 서버 스팬이 생기며, 프로세스가 익스포터를 구성하기 전까지는 비용이 들지 않습니다. 양쪽 끝이 모두 SDK를 실행하면 클라이언트가 W3C 트레이스 컨텍스트도 `_meta`로 전파하므로 트레이스가 이어집니다. **[OpenTelemetry](run/opentelemetry.md)**를 참고하세요. + +## v1에서 업그레이드하는 경우 {#upgrading-from-v1} + +* 무엇을 바꿔야 하는지 완전하고 정확하게 정리한 목록은 **[마이그레이션 가이드](migration.md)**입니다. 이 페이지는 그 이유를 설명한 것입니다. +* **v1.x는 사라지지 않습니다.** 유지 보수 단계로 전환되어 중요한 수정과 보안 패치를 계속 받으며, 2026-07-28 사양 릴리스의 어떤 것도 v1.x를 깨뜨리지 않습니다. 문서는 [/v1/](https://py.sdk.modelcontextprotocol.io/v1/)에 있습니다. `mcp`에 의존하는 라이브러리를 배포하고 있고 아직 마이그레이션할 준비가 되지 않았다면, 버전을 고정하지 않은 의존성 해석이 1.x에 머물도록 상한을 두세요(예: `mcp>=1.28,<2`). +* 거칠거나, 헷갈리거나, 망가진 부분이 있다면 **[v2 피드백을 남겨 주세요](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml)**. 빠짐없이 읽습니다. diff --git a/i18n/languages.yml b/i18n/languages.yml new file mode 100644 index 0000000000..9e01802eef --- /dev/null +++ b/i18n/languages.yml @@ -0,0 +1,52 @@ +# One entry per translated site. English is the source at docs/. +model: claude-opus-5 # public model id; used for every translation call +exclude: [migration.md] # nav pages never translated (exact path or "dir/**") +languages: + - code: de # directory under i18n/ and URL prefix /de/ + name: Deutsch # switcher label (shown as "de - Deutsch") + theme: de # theme `language` (UI strings, search) + hreflang: de # announced in + - code: es + name: español + theme: es + hreflang: es + - code: fr + name: français + theme: fr + hreflang: fr + - code: hi + name: हिन्दी + theme: hi + hreflang: hi + - code: ja + name: 日本語 + theme: ja + hreflang: ja + - code: ko + name: 한국어 + theme: ko + hreflang: ko + - code: pt # Brazilian Portuguese + name: português (Brasil) + theme: pt-BR + hreflang: pt + - code: ru + name: русский язык + theme: ru + hreflang: ru + - code: tr + name: Türkçe + theme: tr + hreflang: tr + - code: uk + name: українська мова + theme: uk + hreflang: uk + - code: zh + name: 简体中文 + theme: zh + hreflang: zh-Hans + - code: zh-hant + name: 繁體中文 + theme: zh-Hant + hreflang: zh-Hant diff --git a/i18n/notices.md b/i18n/notices.md new file mode 100644 index 0000000000..08bf98e4d1 --- /dev/null +++ b/i18n/notices.md @@ -0,0 +1,15 @@ +# Translation notices + +One of these notes appears at the top of every page of a translated documentation site. + +## Machine translation {#translated} + +This page was translated automatically from the English documentation, and the [English page](ENGLISH_PAGE) is the authoritative version. If something reads wrong, [Translations](TRANSLATIONS_PAGE) explains how to report it. + +## Translation behind the English page {#outdated} + +The English page changed after this translation was made, so parts of it may be out of date. When in doubt, read the [English page](ENGLISH_PAGE); [Translations](TRANSLATIONS_PAGE) explains how the translated documentation works. + +## Shown in English {#english} + +There is no current translation of this page, so you are reading it in English. [Translations](TRANSLATIONS_PAGE) explains how the translated documentation works. diff --git a/i18n/pt/glossary.json b/i18n/pt/glossary.json new file mode 100644 index 0000000000..5aec7e3216 --- /dev/null +++ b/i18n/pt/glossary.json @@ -0,0 +1,210 @@ +{ + "keep": [ + "MCP", + "Model Context Protocol", + "MCPServer", + "FastMCP", + "ClientSession", + "Context", + "ctx", + "stdio", + "Streamable HTTP", + "SSE", + "JSON-RPC", + "JSON", + "OAuth", + "PKCE", + "JWT", + "CIMD", + "HTTP", + "HTTPS", + "TLS", + "CORS", + "URI", + "URL", + "ASGI", + "WebSocket", + "API", + "SDK", + "CLI", + "IDE", + "LLM", + "SEP", + "RFC", + "Python", + "TypeScript", + "Node.js", + "PyPI", + "Pydantic", + "Starlette", + "FastAPI", + "uvicorn", + "httpx", + "anyio", + "asyncio", + "trio", + "pytest", + "OpenTelemetry", + "Inspector", + "Claude", + "GitHub", + "VS Code", + "Windows", + "macOS", + "Linux", + "llms.txt", + "2026-07-28", + "2025-11-25", + "2025-06-18", + "2025-03-26" + ], + "terms": [ + { + "source": "tool", + "target": "ferramenta", + "note": "MCP protocol noun (a server exposes tools), feminine: a ferramenta / as ferramentas. Wire identifiers such as `tools/call` and the `@mcp.tool()` decorator are code and stay untouched. First mention on a page may read \"ferramenta (tool)\"." + }, + { + "source": "resource", + "target": "recurso", + "note": "MCP protocol noun (data a server exposes for reading), masculine: o recurso / os recursos. `resources/read` and `@mcp.resource()` are code." + }, + { + "source": "prompt", + "target": "prompt", + "note": "MCP protocol noun for the reusable message templates a server exposes, and the general AI sense; kept in English as Brazilian AI writing does. Masculine: o prompt / os prompts. `prompts/get` and `@mcp.prompt()` are code." + }, + { + "source": "sampling", + "target": "amostragem", + "note": "MCP feature where the server asks the client for an LLM completion. First mention on a page reads \"amostragem (sampling)\" so the reader can map it to `sampling/createMessage`, which is code. Keeping the English word instead is the open alternative." + }, + { + "source": "roots", + "target": "roots", + "note": "MCP client capability (the directories a client exposes to the server); kept in English because readers meet it as the identifier `roots/list`, so not raízes. Masculine plural: os roots. May take the gloss \"roots (diretórios raiz)\" on first mention. Translating it as raízes is the open alternative." + }, + { + "source": "elicitation", + "target": "elicitação", + "note": "MCP feature where the server asks the user a question through the client (`elicitation/create`, `ctx.elicit()` are code). The Portuguese noun is rare but exact; first mention on a page reads \"elicitação (elicitation)\". Feminine: a elicitação." + }, + { + "source": "capability", + "target": "capacidade", + "note": "What client and server declare during initialization (\"negociação de capacidades\"); not habilidade. Note that funcionalidade is the word for a feature, which is a different thing." + }, + { + "source": "transport", + "target": "transporte", + "note": "The connection layer. The individual transports — stdio, Streamable HTTP, SSE — are names on the keep list and stay in English (\"o transporte stdio\")." + }, + { + "source": "session", + "target": "sessão", + "note": "Feminine: a sessão / as sessões (\"ID de sessão\"). The `session` object, `ClientSession` and `ServerSession` are code and stay in English." + }, + { + "source": "handler", + "target": "handler", + "note": "The functions you register on a server; kept in English as everyday Brazilian developer usage does, not manipulador, which reads dated in this audience. Masculine: o handler / os handlers." + }, + { + "source": "dependency", + "target": "dependência", + "note": "Both package dependencies and the SDK's dependency injection — parameters declared with `Resolve(...)` (\"injeção de dependência\"). Feminine: a dependência." + }, + { + "source": "resolver", + "target": "resolvedor", + "note": "The plain function that fills a `Resolve(...)`-annotated parameter before the tool runs. Rendered as the noun \"resolvedor\" (masculine: o resolvedor) because in Portuguese \"resolver\" is a verb; keeping the English noun is the open alternative." + }, + { + "source": "client", + "target": "cliente", + "note": "Masculine: o cliente. The `Client` class and the `mcp.client` module are code and stay in English." + }, + { + "source": "server", + "target": "servidor", + "note": "Masculine: o servidor. The `MCPServer`, `Server` and `ServerSession` classes are code and stay in English." + }, + { + "source": "host", + "target": "host", + "note": "The MCP host — the application the user talks to (Claude Desktop, an IDE) — kept in English as Brazilian developers say it. Masculine: o host / os hosts.", + "avoid": ["anfitrião", "hospedeiro"] + }, + { + "source": "request", + "target": "requisição", + "note": "HTTP and JSON-RPC requests (\"requisição HTTP\", \"a requisição de inicialização\"). Feminine: a requisição. solicitação is understood too, but use requisição throughout rather than alternating; never pedido." + }, + { + "source": "token", + "target": "token", + "note": "Kept in English for both OAuth tokens (\"token de acesso\", \"refresh token\") and LLM tokens. Masculine: o token / os tokens." + }, + { + "source": "lifespan", + "target": "lifespan", + "note": "The SDK feature and its `lifespan=` parameter; kept in English so the prose matches the parameter name, not vida útil. Masculine: o lifespan. May take the gloss \"lifespan (ciclo de vida do servidor)\" on first mention.", + "avoid": ["expectativa de vida"] + }, + { + "source": "callback", + "target": "callback", + "note": "Kept in English, covering both OAuth redirect callbacks and function callbacks. Masculine: o callback / os callbacks.", + "avoid": ["retorno de chamada"] + }, + { + "source": "deploy", + "target": "deploy", + "note": "Borrow the noun, not a verb: \"o deploy\", \"fazer o deploy\", \"depois do deploy\". Masculine. \"implantação\" is acceptable in formal contexts but pin \"deploy\" for consistency.", + "avoid": ["deployar", "deployado"] + }, + { + "source": "library", + "target": "biblioteca", + "note": "Feminine: a biblioteca. The false friend \"livraria\" means a bookshop and is never right here.", + "avoid": ["livraria"] + }, + { + "source": "back-channel", + "target": "canal de retorno", + "note": "This documentation's term for the server calling back into the client during a request. First mention on a page reads \"canal de retorno (back-channel)\" so the reader can connect it to the `NoBackChannelError` exception, which is code." + }, + { + "source": "file", + "target": "arquivo", + "note": "Masculine: o arquivo. \"ficheiro\" is European Portuguese and is always an error in this Brazilian Portuguese target.", + "avoid": ["ficheiro"] + }, + { + "source": "user", + "target": "usuário", + "note": "Masculine as the generic form: o usuário / os usuários. \"utilizador\" is European Portuguese and is always an error in this Brazilian Portuguese target.", + "avoid": ["utilizador"] + }, + { + "source": "escape hatch", + "target": "saída de emergência", + "note": "The API-design metaphor for the lower-level mechanism you drop to when the convenience layer is in the way (`client.session`, `add_request_handler()`, the low-level `Server`). Feminine: a saída de emergência / as saídas de emergência. Pinned so every page uses one rendering; válvula de escape is the open alternative. Provisional pending native review." + }, + { + "source": "type hint", + "target": "anotação de tipo", + "note": "Python type hints (\"from those type hints\" → a partir dessas anotações de tipo). Feminine: a anotação de tipo / as anotações de tipo — the plural goes on anotação, tipo stays singular. Translated on every page rather than left as \"type hints\" in the prose; `type hints` inside code font is code. Provisional pending native review." + }, + { + "source": "Get started", + "target": "Comece por aqui", + "note": "The nav section that opens the guide, and the title of its index page. \"First steps\" is a separate page inside that section (Primeiros passos), so the two need distinct renderings or the sidebar shows the same title twice. Provisional pending native review. Introdução is the alternative for the section." + }, + { + "source": "First steps", + "target": "Primeiros passos", + "note": "The tutorial page inside the \"Get started\" section; never reuse this rendering for the section itself (see that entry). Provisional pending native review." + } + ] +} diff --git a/i18n/pt/instructions.md b/i18n/pt/instructions.md new file mode 100644 index 0000000000..3cb13083e0 --- /dev/null +++ b/i18n/pt/instructions.md @@ -0,0 +1,190 @@ +# Brazilian Portuguese (pt) — translation instructions + +Target language: Brazilian Portuguese (Português do Brasil), directory and +URL code `pt`, page language tag `pt`. This file is sent verbatim with +every translation request for this language, on top of the shared translation +rules in `../general-prompt.md`. The termbase in `glossary.json` is sent +alongside it and wins any terminology conflict with this file. + +## 1. Register + +Write the casual-neutral register Brazilian developer documentation uses: +professional, relaxed, and direct. + +- Address the reader as **você**, with third-person-singular verb forms to + match. Never o senhor / a senhora, never tu, never vós, and never a mix. + The rule holds in body prose, headings, admonition titles, table cells and + link text. +- Instructions and steps are direct imperatives in the você form: "Install + the SDK, then run the server" → Instale o SDK e depois execute o servidor — + not Instala o SDK (tu form), and not Você deve instalar o SDK (needless + modal). A bare imperative per step is fine; a por favor in front of every + step is not. +- Portuguese drops the subject pronoun freely. Write você where a sentence + needs an explicit subject or a contrast, and let the verb carry the person + otherwise; three or four você in one paragraph is a signal to rephrase. + Object pronouns follow the same person: para você / a você, never the + tu-form te / ti. +- The authorial "we" is nós (Recomendamos, chamamos), never the spoken + a gente. +- The register is uniform across a page. A page that drifts between você and + o senhor, or between direct imperatives and an impersonal officialese voice, + is wrong even when each sentence is acceptable on its own. +- This is Brazilian Portuguese only. Every European Portuguese form is an + error here: + - vocabulary: arquivo (never ficheiro), tela (never ecrã), usuário (never + utilizador), salvar (never guardar), excluir / apagar (never eliminar for + "delete"), baixar (never transferir / descarregar for "download"), mouse + (never rato), site (never sítio); + - grammar: the progressive is estar + gerund — o servidor está rodando — + never estar a + infinitive (está a rodar, está a correr); + - spelling: the post-1990 orthography — ação, ótimo, ideia — never acção, + óptimo, idéia. + +## 2. Voice + +Aim for the voice of an experienced Brazilian engineer explaining a library +to a colleague: warm, direct, plain-spoken. The English is built on short +declarative payoff sentences ("That's the whole API."); keep them short — Essa +é a API inteira. + +Do: + +- Follow Portuguese rhythm. Split a long English sentence into two Portuguese + ones instead of mirroring its clause chain, and use everyday connectives + (então, ou seja, por isso) where they help the reader along. +- Use concrete verbs (executar, passar, retornar, declarar, bloquear) rather + than nominal chains: fazer a execução de → executar. +- Keep the source's directness. Where the English says "don't", the + Portuguese says não faça isso / não use, not a hedge like talvez seja + interessante evitar. + +Avoid — these are the marks of a machine or bureaucratic translation: + +- Officialese and legalistic filler: o presente documento, supracitado, + outrossim, faz-se necessário, deve-se ressaltar que, and o mesmo used as a + pronoun. +- Gerundismo: vamos estar mostrando → vamos mostrar; irá estar retornando → + vai retornar. +- Verbified anglicisms from spoken developer slang: deployar, commitar, + buildar, startar, mergear. Write fazer o deploy, fazer commit, gerar o build, + iniciar, fazer o merge. +- English-shaped Portuguese: calqued idioms (sob o capô for "under the hood" — + the Brazilian phrase is por baixo dos panos; no fim do dia for "at the end + of the day" — say no fim das contas), possessive chains, and passives where + an active sentence is natural ("The tool is called by the model" → o modelo + chama a ferramenta, not a ferramenta é chamada pelo modelo). +- Marketing hype and stacked exclamation marks. Keep an exclamation mark only + where the English one carries genuine emphasis. + +## 3. Humour and idioms + +The English is friendly and dry rather than jokey — short payoff sentences, a +few stock phrases, the rare emoji — and Brazilian technical writing is warm by +default, so most of that carries over unchanged. The idioms still need +recasting. + +- Never translate a pun, idiom or aside literally. Say what it means as a + short, natural Brazilian sentence in the same register. Where a common + Brazilian idiom happens to carry the same meaning, use it; where nothing + fits, use the plain statement. If an aside carries no information you may + drop it — but never drop a technical caveat that happens to be phrased + lightly. +- Recurring English tags get fixed renderings: "**[X](…)** has the whole + story" / "The whole story is in **[X](…)**" → **[X](…)** tem a história + completa; "That's the whole API." / "That's the whole protocol." → A API + inteira é essa. / O protocolo inteiro é esse.; "That's it. It's just + Python." → É só isso. É apenas Python. +- Idioms take the plain meaning, not the picture: "Out of the box the app + answers **only** requests addressed to localhost." → Por padrão, o app + responde **apenas** a requisições endereçadas ao localhost — not a calqued + fora da caixa. +- Culture-bound references (US sports, TV shows, holidays) → the plain + meaning. +- Emoji: keep the source's rare, deliberately placed emoji exactly where they + are — two payoff lines end in ✨ ("You get `3` back. ✨"). Never add new + ones. + +Worked examples (source → good / bad): + +- "You get `3` back. ✨" → good: Você recebe `3` de volta. ✨ / bad: Você + recebe `3` de volta! ✨ (added exclamation mark). +- "Give a parameter a default value and it stops being required. That's it. + It's just Python." → good: Dê um valor padrão a um parâmetro e ele deixa de + ser obrigatório. É só isso. É apenas Python. / bad: Dê um valor default + para um parâmetro e ele para de ser requerido. É isso aí, é só Python! ✨ + (untranslated default and requerido, slangy tag, added exclamation and + emoji). + +## 4. Typography + +- Prose punctuation is standard Brazilian usage written with the same + characters the source uses: keep straight double quotes ("…") and + apostrophes as they are; do not switch to «guillemets» or “curly quotes”; no + inverted ¿ ¡; no space before ! ? : ; (that is a French convention). +- Sentence case for headings, admonition titles and content-tab labels: + capitalise the first word and proper nouns only (Configurando o transporte, + not Configurando O Transporte). Language names, months and weekdays are + lower-case in Portuguese (a versão em inglês, em julho); proper nouns stay + capitalised (Python, GitHub, Claude Desktop). +- Digits stay ASCII. Protocol revision strings such as `2026-07-28` and + `2025-11-25` are identifiers, copied byte-for-byte — never 28/07/2026, + never 28 de julho de 2026. Version numbers, HTTP status codes, ports, error + codes, and RFC and SEP numbers are copied exactly. +- Ordinary prose quantities take the decimal comma only when nothing but the + separator changes (a timeout of 2.5 seconds → um timeout de 2,5 segundos); + when in doubt, keep the number as the source writes it. A space separates a + number from a Latin unit (100 MB, 30 s); % attaches with no space (100%). +- Latin abbreviations: e.g. → por exemplo, i.e. → ou seja / isto é; etc. + stays etc.; vs → versus, or ou / contra when a plain word reads better. + Where the English uses & in prose, write e. +- Loanwords kept in English are set in normal type — no italics, no scare + quotes — and take a Portuguese article: o handler, os tokens, a string. + Bold and italics land on the same words the source emphasises; a bolded + negation ("**not**" → **não**) stays bold. +- Ordinals use the indicators º / ª (1º, 2ª). Keep the source's dashes, + colons and parentheses as they are; do not turn a colon into a travessão + or the reverse. + +## 5. Terminology pointer + +The termbase is `glossary.json` next to this file. It is injected into the +prompt separately and its renderings override anything written here. This +section only fixes the conventions the glossary assumes: + +- Terms in the glossary's `keep` list are copied exactly as they appear in + the English source — same spelling, casing and plural "s" (SDKs stays + SDKs). They are not translated, italicised, re-cased or wrapped in quotes. +- Everything in code font — class, function, method, parameter and module + names, protocol method strings (`tools/call`, `notifications/...`), header + names, error text, config keys — stays byte-identical. You may put a + Portuguese article or the word for the kind of thing in front of it: a + classe `Context`, o parâmetro `lifespan=`, o método `client.list_tools()`. + A glossary term used as a code-font identifier stays in English even though + its prose noun is translated: "the `sampling` capability" → a capacidade + `sampling`. +- English technical nouns that stay in English keep their English spelling, + take a fixed grammatical gender, and pluralise the Brazilian way (add "s"). + Masculine by default — o token / os tokens, o handler, o callback, o host, + o schema, o payload, o endpoint, o log, o loop, o build, o commit, o deploy, + o prompt, o middleware — feminine where usage is settled: a string, a + thread, a query, a flag, a tag, a URL, a API, a issue. Where a glossary + entry's note gives a gender, it wins. +- Nouns are borrowed, verbs are not: fazer o deploy, fazer commit, fazer o + merge — never deployar, commitar, mergear (see §2). +- First-use gloss: a translated MCP concept the reader may need to map back to + the English specification carries the English in parentheses on its first + occurrence on a page — elicitação (elicitation) — and appears alone after + that. Each glossary entry's note says whether the term takes the gloss. +- One rendering per term per page: the glossary target, every time. Where an + entry's note marks the choice as open or provisional, still use the listed + target consistently rather than picking per sentence. + +## 6. Provisional note + +The register, voice and terminology decisions above, and every entry in +`glossary.json`, are provisional pending review by native Brazilian +Portuguese-speaking readers. To propose a change, edit this file or +`glossary.json` in a pull request — ideally with a short good/bad example when +the change is about phrasing; never edit the generated `pages/` or +`notices.md` next to this file, which the next translation run overwrites. diff --git a/i18n/pt/notices.md b/i18n/pt/notices.md new file mode 100644 index 0000000000..29cae750b2 --- /dev/null +++ b/i18n/pt/notices.md @@ -0,0 +1,20 @@ +--- +translation: + sections: [aff1b3e872b7876a, 4d80558ad052d586, 0bb81f1e62062d26, d5c35dcec50156bc] + tool: 1 +--- +# Avisos de tradução {#translation-notices} + +Um destes avisos aparece no topo de cada página de um site de documentação traduzido. + +## Tradução automática {#translated} + +Esta página foi traduzida automaticamente a partir da documentação em inglês, e a [página em inglês](ENGLISH_PAGE) é a versão de referência. Se algo parecer errado, [Traduções](TRANSLATIONS_PAGE) explica como avisar. + +## Tradução desatualizada em relação à página em inglês {#outdated} + +A página em inglês mudou depois que esta tradução foi feita, então partes dela podem estar desatualizadas. Na dúvida, leia a [página em inglês](ENGLISH_PAGE); [Traduções](TRANSLATIONS_PAGE) explica como funciona a documentação traduzida. + +## Exibida em inglês {#english} + +Não existe uma tradução atual desta página, por isso você a está lendo em inglês. [Traduções](TRANSLATIONS_PAGE) explica como funciona a documentação traduzida. diff --git a/i18n/pt/pages/advanced/apps.md b/i18n/pt/pages/advanced/apps.md new file mode 100644 index 0000000000..06a5c15df5 --- /dev/null +++ b/i18n/pt/pages/advanced/apps.md @@ -0,0 +1,165 @@ +--- +translation: + sections: [0355618e5f4d5fe4, 1821eaf50f2d0b64, 82e0b28ebd3abf5a, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487] + tool: 1 +--- +# MCP Apps {#mcp-apps} + +Um **MCP App** é uma ferramenta (tool) com uma cara: junto com os dados, a ferramenta aponta para um +documento HTML que o host renderiza como uma superfície interativa. + +Duas partes, sempre duas partes: + +1. **Uma ferramenta** que faz o trabalho e retorna dados, como qualquer outra ferramenta. +2. **Um recurso `ui://`** contendo o HTML que o host mostra para ela. + +A ferramenta carrega uma referência `_meta.ui.resourceUri` ao recurso. O host busca esse +recurso com `resources/read`, renderiza em um **iframe em sandbox** e envia o resultado +da ferramenta para dentro desse iframe via `postMessage`. Seu servidor nunca envia nem recebe +nenhuma mensagem `ui/*`: esse tráfego fica entre o host e o iframe. Você serve uma ferramenta +e um documento HTML; o host cuida do espetáculo. + +O SDK entrega isso como a extensão embutida `Apps` (`io.modelcontextprotocol/ui`). +Se [Extensões](extensions.md) são novidade para você, dê uma olhada naquela página primeiro. Um minuto, +e depois volte aqui. + +## Um relógio com uma cara {#a-clock-with-a-face} + +```python title="server.py" hl_lines="19 22 30 32" +--8<-- "docs_src/apps/tutorial001.py" +``` + +Quatro movimentos: + +* `Apps()`: uma única instância guarda suas ferramentas ligadas a UI e os recursos delas. +* `@apps.tool(resource_uri="ui://clock/app.html")`: uma ferramenta comum, mais o + carimbo `_meta.ui.resourceUri`. Tudo o que `@mcp.tool()` aceita (name, title, + description, ...) passa direto. +* `apps.add_html_resource("ui://clock/app.html", CLOCK_HTML)`: o recurso + correspondente, servido como `text/html;profile=mcp-app`. É exatamente esse MIME type que + diz ao host "isto é um app, renderize". +* `MCPServer("clock", extensions=[apps])`: você opta por participar. O servidor agora anuncia + `io.modelcontextprotocol/ui` em `capabilities.extensions`. + +O HTML em si escuta o `postMessage` do host e mostra o resultado. Para apps +de verdade, use o SDK de navegador oficial [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps) +dentro do seu HTML. Ele dá a você `ontoolresult`, `callServerTool`, +`getHostContext` e `onhostcontextchanged` em vez de eventos de mensagem crus. + +## Degradação elegante {#graceful-degradation} + +Nem todo cliente renderiza apps. A especificação é direta sobre o que isso significa para você: + +> As ferramentas **DEVEM** retornar um array `content` significativo mesmo quando há UI disponível. + +O modelo lê `content`; o iframe é para humanos. Um host com suporte a UI ainda passa +o resultado em texto para o modelo, e um cliente só de texto recebe *apenas* isso. Então o +padrão canônico é uma ferramenta, duas respostas. Olhe `get_time` de novo: + +```python title="server.py" hl_lines="23-27" +--8<-- "docs_src/apps/tutorial001.py" +``` + +`client_supports_apps(ctx)` é `True` somente quando o cliente declarou a +extensão `io.modelcontextprotocol/ui` **e** listou `text/html;profile=mcp-app` +nas suas configurações `mimeTypes`. O campo é obrigatório, então um cliente que o omite +não conta. É exatamente isso que `main()` no mesmo arquivo declara: a +metade cliente da negociação, e a resposta rica volta. + +!!! warning + Nunca retorne um placeholder como `"[Rendered UI]"` como único conteúdo. Se o + texto de fallback é inútil, a ferramenta é inútil para todo cliente só de texto e para + o próprio modelo. Escreva a frase. + +## Trancando o iframe {#locking-the-iframe-down} + +O lado do recurso carrega os metadados de segurança: o que o iframe pode carregar, quais +permissões do navegador ele quer, como gostaria de ser enquadrado: + +```python title="server.py" hl_lines="9 19-22" +--8<-- "docs_src/apps/tutorial002.py" +``` + +`csp` e `permissions` são **pedidos ao host**, não comportamento do servidor. O host +monta a Content-Security-Policy e a Permissions-Policy do iframe a partir deles, e +pode recusar. Faça detecção de funcionalidade no seu JS em vez de presumir que foi concedido. + +`ResourceCsp`, campo por campo (nome em Python, chave no protocolo, o que o host faz com ele): + +| Python | Protocolo (`_meta.ui.csp`) | Controla | +|---|---|---| +| `connect_domains` | `connectDomains` | `connect-src`: para onde `fetch`/XHR podem ir | +| `resource_domains` | `resourceDomains` | `img-src`, `style-src`, ...: assets estáticos | +| `frame_domains` | `frameDomains` | `frame-src`: iframes aninhados | +| `base_uri_domains` | `baseUriDomains` | `base-uri`: para onde `` pode apontar | + +`ResourcePermissions`: cada campo solicita uma permissão do navegador para o iframe. + +| Python | Protocolo (`_meta.ui.permissions`) | +|---|---| +| `camera` | `camera` | +| `microphone` | `microphone` | +| `geolocation` | `geolocation` | +| `clipboard_write` | `clipboardWrite` | + +!!! note + CSP e permissões vivem no **recurso**, nunca na ferramenta. Os metadados de ferramenta + da especificação não têm lugar para eles, e os hosts os ignoram ali. O SDK torna o + erro irrepresentável: `@apps.tool()` simplesmente não tem parâmetro `csp`. + +### Visibilidade {#visibility} + +`visibility=["app"]` em uma ferramenta diz "isto existe para o iframe, não para o modelo": + +* `"model"`: o modelo pode chamá-la. +* `"app"`: o iframe pode chamá-la (via `callServerTool`). +* Omitido: ambos, que é o padrão. + +Filtrar é trabalho do **host**. Seu servidor lista as ferramentas só de app em `tools/list` +como qualquer outra; o host as esconde do modelo. Não filtre no lado do servidor. + +## As regras que o SDK impõe {#the-rules-the-sdk-enforces} + +Todas estas falham na inicialização, não em produção: + +* Um `resource_uri` ou URI de recurso que não seja `ui://...` é um `ValueError` no + momento da decoração/registro. +* Uma ferramenta ligada a uma URI **sem recurso registrado correspondente** é um `ValueError` + quando `MCPServer(extensions=[apps])` consome a extensão. Uma ferramenta que anuncia + um HTML que dá 404 em `resources/read` é uma configuração errada, então o servidor se recusa + a ser construído. +* `meta={"ui": ...}` em `@apps.tool()` é um `ValueError`. O decorator é dono de + `_meta["ui"]`; diga isso com `resource_uri=` e `visibility=`. Outras chaves em `meta=` + são mescladas normalmente ao lado. + +Nem o SDK ext-apps em TypeScript nem o FastMCP pegam nenhum desses casos hoje; preferimos +que você descubra antes que um host descubra. + +## Além do HTML inline {#beyond-inline-html} + +`add_html_resource` cobre o caso comum: uma string de HTML. Para qualquer outra coisa, +HTML em disco ou conteúdo gerado, construa o recurso você mesmo e entregue: + +```python title="server.py" hl_lines="12 18" +--8<-- "docs_src/apps/tutorial003.py" +``` + +`add_resource` preenche o MIME type `text/html;profile=mcp-app` quando o recurso +não define um explicitamente, e rejeita uma incompatibilidade explícita: um recurso `ui://` +sob qualquer outro MIME type é um que nenhum host vai renderizar. + +!!! tip + Mirando um host pré-GA que ainda lê a chave plana depreciada + `_meta["ui/resourceUri"]`? Mescle você mesmo: + `@apps.tool(resource_uri="ui://x", meta={"ui/resourceUri": "ui://x"})`. + O objeto `ui` aninhado é o formato da especificação; a chave plana está de saída. + +## Veja rodando {#see-it-run} + +A história `apps` em `examples/stories/` é esta página como um par executável: um servidor +com uma ferramenta de relógio ligada a UI e um cliente que negocia Apps, lê o +`_meta.ui.resourceUri` da ferramenta, busca o HTML e chama a ferramenta. + +```bash +uv run python -m stories.apps.client +``` diff --git a/i18n/pt/pages/advanced/extensions.md b/i18n/pt/pages/advanced/extensions.md new file mode 100644 index 0000000000..c9ceb86f8c --- /dev/null +++ b/i18n/pt/pages/advanced/extensions.md @@ -0,0 +1,172 @@ +--- +translation: + sections: [05891e7cc1938a13, b3c01a6af28c51ee, 7ffc91f5e38bdfe0, 717d3f235a8333a7, f471a13b2fe5d737, ed6af2df4b656dff] + tool: 1 +--- +# Extensões {#extensions} + +Uma **extensão** é um pacote opcional de comportamento MCP reunido sob um único identificador. + +Em um servidor, ela pode contribuir com ferramentas (tools), recursos e novos métodos de requisição, e pode envolver `tools/call`. Em um cliente, ela pode reivindicar formatos extras de resultado de `tools/call` e observar notificações de fornecedores. Cada lado se anuncia no seu próprio `capabilities.extensions`, e nada muda para quem não pediu nada. Esse é o contrato ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)), e ele tem uma regra de ouro: **extensões vêm desligadas por padrão**. + +## Usando uma extensão {#using-an-extension} + +Passe as instâncias na construção: + +```python title="server.py" +--8<-- "docs_src/extensions/tutorial001.py" +``` + +Pronto. O servidor agora anuncia `io.modelcontextprotocol/ui` em `capabilities.extensions` e serve tudo o que a extensão contribui. + +`Apps` é a extensão de referência embutida, e ela tem uma página própria: **[MCP Apps](apps.md)**. + +!!! note + As extensões são fixadas na construção. Não existe um `add_extension` para chamar depois: o mapa de capacidades de um servidor não deve mudar enquanto há clientes conectados a ele. + +O mapa de capacidades viaja em `server/discover`, que é um caminho da **2026-07-28**. Um handshake `initialize` legado não tem onde colocá-lo, então um cliente legado simplesmente não enxerga a extensão. Projete pensando nisso: uma extensão *amplia* um servidor, ela não pode ser a única forma de usá-lo. + +## Escrevendo a sua {#writing-your-own} + +Herde de `Extension` e sobrescreva apenas o que precisar. Todo método tem um padrão. + +### O identificador {#the-identifier} + +```python +--8<-- "docs_src/extensions/tutorial002.py" +``` + +O identificador é uma string `vendor-prefix/name` que segue a gramática de chaves `_meta` da especificação: rótulos separados por ponto (cada um começa com uma letra e termina com uma letra ou dígito), uma barra e então o nome. Ele é validado **quando a classe é definida**, então um erro de digitação não espera o servidor subir: + +```text +TypeError: Stamps.identifier must be a `vendor-prefix/name` string +(reverse-DNS prefix required), got 'stamps' +``` + +Use como prefixo um domínio que você controla. `io.modelcontextprotocol/*` é reservado para extensões especificadas pelo próprio projeto MCP. + +### Contribuindo com ferramentas {#contributing-tools} + +A menor extensão útil é uma ferramenta e um mapa de configurações: + +```python title="server.py" hl_lines="17 19-20 22-23 26" +--8<-- "docs_src/extensions/tutorial003.py" +``` + +* `tools()` retorna `ToolBinding`s. O servidor registra cada uma exatamente como se você tivesse chamado `mcp.add_tool(...)` por conta própria: mesma geração de schema, mesma injeção de `Context`, tudo igual. +* `settings()` é o valor anunciado em `capabilities.extensions["com.example/stamps"]`. Retorne `{}` (o padrão) para anunciar a extensão sem configurações. +* A extensão nunca recebe o servidor. Ela declara contribuições como dados; o `MCPServer` as consome. Não existe um `self.server` para modificar. + +E `main()` é a prova, um cliente em memória direto contra `mcp`: + +```python title="server.py" hl_lines="29-34" +--8<-- "docs_src/extensions/tutorial003.py" +``` + +### Servindo seus próprios métodos {#serving-your-own-methods} + +Uma extensão pode registrar **novos métodos de requisição**: seus próprios verbos, servidos ao lado dos da especificação: + +```python title="server.py" hl_lines="16-22 31 40-48" +--8<-- "docs_src/extensions/tutorial004.py" +``` + +* `SearchParams` herda de `RequestParams`, então o envelope `_meta` de 2026 é analisado de forma uniforme e seu handler recebe parâmetros validados, nunca um dict cru. Limite o que o cliente controla: `Field(ge=1, le=100)` rejeita um `limit` absurdo antes que seu código aloque qualquer coisa para ele. +* `require_client_extension(ctx, EXTENSION_ID)` é a barreira: um cliente que não declarou a extensão recebe o erro `-32021` (capacidade obrigatória do cliente ausente), com o payload `requiredCapabilities` legível por máquina que a especificação pede. +* `protocol_versions=frozenset({"2026-07-28"})` fixa o método em uma única versão de protocolo. Em qualquer outra versão o cliente recebe `METHOD_NOT_FOUND`, exatamente como se o método não existisse ali. Para esse cliente, não existe. + +Os métodos são **estritamente aditivos**. O SDK impõe isso na construção, não em tempo de execução: + +* Um `MethodBinding` para um método definido pela especificação (`tools/list`, `completion/complete`, ...) lança `ValueError` quando o binding é construído. Os verbos centrais pertencem ao servidor. +* Duas extensões vinculando o mesmo método lançam quando a segunda se registra. A última escrita vencer é como plugins corrompem uns aos outros; não fazemos isso. +* Um conjunto `protocol_versions` vazio também lança: um método que nunca pode ser servido é um bug, não uma configuração. + +### O lado do cliente {#the-client-side} + +O `main()` do mesmo arquivo é a história inteira do cliente, com as duas metades: + +```python title="server.py" hl_lines="54-58" +--8<-- "docs_src/extensions/tutorial004.py" +``` + +* `Client(..., extensions=[advertise(EXTENSION_ID)])` declara a extensão. As declarações viram `ClientCapabilities.extensions`: em uma conexão 2026-07-28 o mapa viaja no envelope `_meta` de cada requisição, então o servidor o vê em **toda** requisição; em uma conexão legada ele vai no handshake `initialize`. O código do servidor não se importa com qual: `require_client_extension(ctx, ...)` e `ctx.session.check_client_capability(...)` leem a fonte certa nos dois caminhos. +* Métodos de fornecedor descem uma camada para `client.session.send_request(...)`; `Client` só ganha métodos de primeira classe para verbos da especificação. `send_request` aceita qualquer subclasse de `Request`, então a requisição do fornecedor passa como está. + +### Interceptando `tools/call` {#intercepting-toolscall} + +O único hook interceptador. Sobrescreva `intercept_tool_call` para observar, curto-circuitar ou vetar uma chamada de ferramenta: + +```python title="server.py" hl_lines="17-24" +--8<-- "docs_src/extensions/tutorial005.py" +``` + +* `params` é o `CallToolRequestParams` validado: você recebe `params.name` e `params.arguments` sem tocar em JSON cru. É também o que decide qual chamada de ferramenta é executada: passar um contexto reescrito por `call_next` muda o que o handler observa em `ctx`, não a invocação da ferramenta. Reescrita de requisição no nível do protocolo pertence ao [Middleware](middleware.md). +* `call_next(ctx)` executa o resto da cadeia e retorna o resultado do handler. Retorne-o sem alterações (observar), retorne outra coisa (substituir) ou lance um `MCPError` (recusar). O que você retornar é serializado como qualquer resultado de handler, incluindo o carimbo de identidade `serverInfo` da era 2026, então um interceptador que curto-circuita nunca produz uma resposta anônima ou fora do schema. +* Com várias extensões, os interceptadores se aninham na ordem de registro: a primeira extensão em `extensions=[...]` é a mais externa. +* A implementação padrão é um repasse direto, e um servidor cujas extensões nunca sobrescrevem esse hook mantém o handler puro de `tools/call` intocado. Você não paga pelo que não usa. + +O hook envolve `tools/call` e nada mais. Para preocupações que valem para toda mensagem, use o [Middleware](middleware.md). É para isso que ele serve. + +## Usando uma extensão de cliente {#using-a-client-extension} + +Uma **extensão de cliente** é o mesmo contrato visto do lado consumidor: um pacote de comportamento do lado do cliente reunido sob um único identificador. Passe as instâncias para `Client(extensions=[...])` e chame as ferramentas normalmente: + +```python title="client.py" hl_lines="66-68" +--8<-- "docs_src/extensions/tutorial006.py" +``` + +`call_tool("buy", ...)` retorna um `CallToolResult` comum, como toda outra chamada. O que a extensão mudou: o servidor agora pode responder a `buy` com um **formato de resultado** `receipt` em vez de um resultado final, e `Receipts` o finaliza (aqui, resgatando o recibo com uma chamada seguinte) antes de `call_tool` retornar. Nada muda no ponto da chamada. + +Tire a extensão e nada disso existe: a barreira do servidor recusa um cliente que não a declarou (erro -32021), e um formato reivindicado vindo de um servidor que pula a barreira falha na validação, exatamente como a especificação exige para um `resultType` não reconhecido. Desligado por padrão, nas duas pontas da conexão. + +Para anunciar um identificador **sem** nenhum comportamento do lado do cliente (o servidor faz a barreira pela capacidade, o cliente não faz nada, como no cliente de busca acima), use `advertise()`: + +```python +from mcp.client import advertise + +client = Client(mcp, extensions=[advertise("com.example/search")]) +``` + +## Escrevendo uma extensão de cliente {#writing-a-client-extension} + +Herde de `ClientExtension` e sobrescreva apenas o que precisar. Três tipos de contribuição, cada um com um padrão: `settings()`, `claims()` e `notifications()`. + +```python title="client.py" hl_lines="17-18 43-44 46-47" +--8<-- "docs_src/extensions/tutorial006.py" +``` + +* O identificador segue a mesma gramática do servidor, validada quando a classe é definida. +* `claims()` retorna `ResultClaim`s: uma tag de protocolo, o modelo que a analisa e o resolvedor que a finaliza. O modelo precisa fixar a tag com `result_type: Literal["receipt"]` e não pode herdar dos tipos de resultado centrais do verbo; as duas coisas são impostas quando a claim é construída. Campos de fornecedor como `receipt_token` viajam pela conexão como estão: um formato substituído chega ao cliente literalmente. +* O resolvedor recebe o modelo analisado e um `ClaimContext`; `ctx.session` é o mesmo handle público que `client.session`, então as chamadas seguintes são chamadas comuns de sessão. Ele retorna o `CallToolResult` normal do verbo. +* `settings()` é o valor anunciado em `ClientCapabilities.extensions[identifier]`, lido uma vez na construção do `Client`. + +`notifications()` declara notificações de servidor de fornecedor a observar: + +```python +def notifications(self) -> Sequence[NotificationBinding[Any]]: + return [NotificationBinding(method="notifications/receipts", params_type=ReceiptEvent, handler=self.on_receipt)] +``` + +O handler recebe parâmetros validados um de cada vez, na ordem de despacho. Ele observa; não pode vetar nem responder. + +Duas regras discretas. As claims ficam ativas apenas em conexões 2026-07-28, e o anúncio de capacidade as acompanha: em uma conexão legada as claims se dissolvem e o identificador sai do anúncio junto com elas, então o cliente nunca anuncia uma extensão cujos formatos ele rejeitaria. E quando você mesmo quer o formato reivindicado em vez do resolvedor, chame `client.session.call_tool(..., allow_claimed=True)`; sem essa flag, um formato reivindicado que chega a um chamador no nível da sessão lança `UnexpectedClaimedResult`. + +### Verbos de extensão {#extension-verbs} + +Os métodos de requisição próprios de uma extensão não precisam de registro no lado do cliente. Um tipo de requisição de fornecedor herda de `mcp.types.Request` e passa por `client.session.send_request`, como em [Servindo seus próprios métodos](#serving-your-own-methods). Um acréscimo: quando uma chave de params precisa viajar no header `Mcp-Name` (especificações de extensão como tasks exigem isso para seus verbos), o tipo de requisição declara `name_param`: + +```python title="client.py" hl_lines="22-25 46-47" +--8<-- "docs_src/extensions/tutorial007.py" +``` + +A sessão espelha `params["jobId"]` em `Mcp-Name` em todo caminho de envio, e um valor ausente falha de forma explícita em vez de omitir silenciosamente um header obrigatório. + +## O que uma extensão não pode fazer {#what-an-extension-cannot-do} + +A superfície de contribuição é **fechada** de propósito. No servidor: configurações, ferramentas, recursos, métodos, um interceptador de `tools/call`. No cliente: configurações, claims de resultado, bindings de notificação. Uma extensão não pode: + +* **Alcançar o host.** Ela declara dados; não guarda nenhuma referência ao servidor nem ao cliente. +* **Substituir comportamento central.** Métodos da especificação e tags de resultado centrais são rejeitados na construção (`initialize` é reservado pelo runner sem exceção); já um binding de notificação encoberto pelo vocabulário central fica em silêncio com um aviso. +* **Registrar-se depois.** Depois que `MCPServer(...)` ou `Client(...)` retorna, o conjunto de extensões é o que é. + +Se você está brigando com essas paredes, não está escrevendo uma extensão. Está escrevendo um fork. As paredes são a funcionalidade: um usuário que lê `extensions=[Apps(), Stamps()]` sabe *tudo* o que essas duas podem ter tocado. diff --git a/i18n/pt/pages/advanced/index.md b/i18n/pt/pages/advanced/index.md new file mode 100644 index 0000000000..2a8e333931 --- /dev/null +++ b/i18n/pt/pages/advanced/index.md @@ -0,0 +1,34 @@ +--- +translation: + sections: [ca6988b7503cd2d3] + tool: 1 +--- +# Avançado {#advanced} + +Tudo o que um servidor ou cliente comum precisa tem seu lugar por assunto nas seções acima. +Esta seção reúne as saídas de emergência a que você recorre quando a camada de conveniência +do `MCPServer` atrapalha: + +* **[O Server de baixo nível](low-level-server.md)**: a classe sobre a qual o `MCPServer` é construído. + Schemas escritos à mão, handlers `on_*`, nada verificado para você e métodos JSON-RPC + personalizados criados por você. +* **[Paginação](pagination.md)** e **[Middleware](middleware.md)**: duas coisas que você + *só* consegue fazer no `Server` de baixo nível. +* **[Extensões](extensions.md)** e **[MCP Apps](apps.md)**: a superfície de + extensão do protocolo. Componha pacotes de extensão em um servidor ou escreva os seus. + +Algumas coisas que você poderia, com razão, procurar aqui ficam onde você de fato +as usaria: + +* **Autorização** está em **[Executando seu servidor](../run/index.md)**, porque você + protege um servidor onde faz o deploy dele. +* **OAuth**, **asserção de identidade**, conexão a **vários servidores** e o + **cache** de respostas estão todos em **[Clientes](../client/index.md)**. +* **Requisições com várias idas e voltas** e **Assinaturas** estão em + **[Dentro do seu handler](../handlers/index.md)**, porque ambas são coisas que um + handler *faz*. +* **Templates de URI** está em **[Servidores](../servers/index.md)**, ao lado de Recursos. +* **[Versões do protocolo](../protocol-versions.md)** e + **[Funcionalidades descontinuadas](../deprecated.md)** têm, cada uma, sua própria página de nível superior. + +Se você não tem certeza de que precisa desta seção, não precisa. diff --git a/i18n/pt/pages/advanced/low-level-server.md b/i18n/pt/pages/advanced/low-level-server.md new file mode 100644 index 0000000000..4121d274b2 --- /dev/null +++ b/i18n/pt/pages/advanced/low-level-server.md @@ -0,0 +1,207 @@ +--- +translation: + sections: [2c79b6338e09b7ac, 7edc43b3fae11314, 1086e77ce561cd7f, a3f71823df5efc31, 9fc7109f72201cae, 7bf25983df655b66, 6330e1f4c6029683, 2f1749c8c133fa1c, b3530fcf4d11fd56, ebc33704fbd74262, cd0e9c933350390e] + tool: 1 +--- +# O Server de baixo nível {#the-low-level-server} + +`@mcp.tool()` é uma camada. Por baixo dela existe uma segunda classe de servidor, `Server`, que fala MCP cru: você entrega os objetos do protocolo e ela os coloca no fio, sem alterar nada. + +O `MCPServer` é construído em cima dela. Você desce um nível quando a camada de conveniência atrapalha: + +* Você precisa emitir um schema **exato** (carregado de um arquivo, gerado a partir de um banco de dados), não um derivado de uma assinatura Python. +* Você precisa de controle total do resultado: `_meta`, `is_error`, cada chave de `structured_content`. +* Você precisa tratar um método que o MCP não define. + +Para todo o resto, fique no `MCPServer`. + +## A mesma ferramenta, à mão {#the-same-tool-by-hand} + +Esta é a ferramenta (tool) `search_books` que **[Ferramentas](../servers/tools.md)** escreve em nove linhas de `@mcp.tool()`, com o açúcar removido: + +```python title="server.py" hl_lines="22 26 32" +--8<-- "docs_src/lowlevel/tutorial001.py" +``` + +Três coisas mudaram, e elas são a API de baixo nível inteira: + +* **Os handlers são parâmetros do construtor.** `on_list_tools=` e `on_call_tool=` entram em `Server(...)`. Não há decoradores aqui embaixo, e todo handler tem o mesmo formato: `async (ctx, params) -> result`. +* **Você escreve o schema de entrada.** `Tool.input_schema` é um `dict` JSON Schema comum. Ninguém o deriva de anotações de tipo, porque não há anotações de tipo de onde derivar. +* **Você monta o resultado.** `CallToolResult(content=[TextContent(...)])`, à mão. Nada é encapsulado, convertido ou inferido de uma anotação de retorno. + +`params` é a requisição já parseada: `CallToolRequestParams` dá `.name` e `.arguments`. `ctx` é um `ServerRequestContext`: `ctx.session` para falar de volta com o cliente, `ctx.lifespan_context`, `ctx.request_id` e `ctx.meta`, o `_meta` de entrada da requisição. + +!!! info + Se você já usou FastAPI, já conhece essa relação. O `MCPServer` é a camada de decoradores e anotações de tipo; o `Server` é o Starlette por baixo. Eles não são rivais: o `MCPServer` constrói um `Server` e registra nele handlers exatamente como esses. + +### Experimente {#try-it} + +Não existe Inspector para este aqui: `mcp dev` e `mcp run` só aceitam um `MCPServer`. O `Client` em memória não se importa; ele recebe um `Server` de baixo nível exatamente como recebe um `MCPServer`: + +```python title="main.py" +import asyncio + +from mcp import Client + +from server import server + + +async def main() -> None: + async with Client(server) as client: + result = await client.call_tool("search_books", {"query": "dune", "limit": 5}) + print(result.content) + + +asyncio.run(main()) +``` + +```text +[TextContent(type='text', text="Found 3 books matching 'dune' (showing up to 5).", annotations=None, meta=None)] +``` + +O mesmo texto que a versão com `@mcp.tool()` produziu. Duas diferenças honestas: + +* `result.structured_content` é `None`. O servidor de alto nível encapsula um `-> str` em `{"result": ...}` para você; aqui ninguém monta o que você não montou. +* `list_tools` retorna o schema que **você** digitou, caractere por caractere. A versão de alto nível tinha `"title": "Query"` em cada propriedade e um `"title": "search_booksArguments"` na raiz: artefatos do Pydantic. Aqui embaixo, se está no fio, foi você quem colocou lá. + +## Nada é verificado por você {#nothing-is-checked-for-you} + +O `MCPServer` rejeita um argumento ruim antes mesmo de a sua função executar, validando a chamada contra o schema que ele gerou (**[Ferramentas](../servers/tools.md)**). + +O `Server` não faz isso. O seu `input_schema` é *anunciado* ao cliente; ele nunca é *aplicado* a `params.arguments`. + +!!! check + Chame `search_books` sem `limit` e o seu `args["limit"]` levanta `KeyError`. O cliente vê: + + ```text + MCPError: Internal server error + ``` + + Um erro JSON-RPC, código `-32603`, com uma mensagem deliberadamente genérica: o SDK não vaza o seu traceback para um chamador remoto. O modelo nunca descobre o que fez de errado, então não consegue tentar de novo. (Em um teste, `raise_exceptions=True` expõe a exceção real; veja **[Testes](../get-started/testing.md)**.) + +Isso se generaliza. Uma exceção levantada de um handler de baixo nível é **sempre** um erro de protocolo, nunca um resultado de ferramenta com `is_error=True`. Se você quer que o modelo leia a falha e se recupere, valide `params.arguments` você mesmo e retorne `CallToolResult(content=[TextContent(...)], is_error=True)`. Os dois tipos de falha são o assunto de **[Tratando erros](../servers/handling-errors.md)**. + +## Duas ferramentas, um handler {#two-tools-one-handler} + +`on_call_tool` é o único ponto de entrada para todas as ferramentas do servidor. Você roteia por `params.name`: + +```python title="server.py" hl_lines="38-43" +--8<-- "docs_src/lowlevel/tutorial002.py" +``` + +* `list_tools` anuncia as duas. `call_tool` despacha pelo nome. +* O ramo `else` importa: o `Server` encaminha sem reclamar um `tools/call` para um nome que você nunca listou direto para o seu handler. Levantar uma exceção ali transforma a chamada no mesmo `-32603` de cima. + +## Saída estruturada, à mão {#structured-output-by-hand} + +Declare `output_schema` na `Tool` e coloque `structured_content` no resultado. Os dois são seus: + +```python title="server.py" hl_lines="19-23 36" +--8<-- "docs_src/lowlevel/tutorial003.py" +``` + +Chame e o resultado carrega as duas representações: + +```json +{ + "content": [{"type": "text", "text": "Found 3 books matching 'dune'."}], + "structuredContent": {"matches": 3, "query": "dune"}, + "isError": false, + "resultType": "complete", + "_meta": {"io.modelcontextprotocol/serverInfo": {"name": "Bookshop", "version": "2.0.0"}} +} +``` + +O bloco `_meta` é o carimbo de identidade do servidor: o SDK o adiciona a todo resultado da era 2026, com a `version` vinda do construtor (um servidor que não define nenhuma reporta uma string vazia). Um servidor que não deve se identificar pode remover a chave com um middleware, que é dono dos resultados que retorna. + +O servidor nunca compara os dois campos. O `Client` deste SDK compara: retorne um `structured_content` que não satisfaz o `output_schema` que você declarou e `call_tool` levanta um `RuntimeError` que começa com `Invalid structured content returned by tool search_books` e segue citando a falha do `jsonschema`. Prometer um schema é barato; cumprir a promessa é com você. A escada inteira de tipos de retorno e schemas está em **[Saída estruturada](../servers/structured-output.md)**. + +## `_meta`: para a aplicação, não para o modelo {#\_meta-for-the-application-not-the-model} + +`content` é a parte da resposta que o modelo lê. `structured_content` é a mesma resposta como dados tipados. `_meta` é o terceiro canal: dados que viajam junto com o resultado para a **aplicação cliente**, sem fazer parte da resposta de forma alguma. + +Use para IDs de registro, IDs de trace, qualquer coisa de que a sua UI precisa e o seu prompt não: + +```python title="server.py" hl_lines="37" +--8<-- "docs_src/lowlevel/tutorial004.py" +``` + +* Você o constrói como `_meta=`, o nome no fio. O cliente o lê de volta como `result.meta`. +* Use namespace nas suas chaves (`bookshop/record_ids`). As chaves `io.modelcontextprotocol/*` são reservadas pelo protocolo. + +!!! warning + `_meta` é uma convenção entre você e a aplicação cliente, não uma garantia sobre o que chega + ao modelo. O host decide o que renderiza. Nunca coloque um segredo em nenhuma parte de um resultado de ferramenta. + +## As capacidades seguem os seus handlers {#capabilities-follow-your-handlers} + +Um `Server` anuncia exatamente as famílias de métodos para as quais você deu handlers. O `Bookshop` acima passa `on_list_tools` e `on_call_tool` e nada mais, então um cliente que se conecta a ele vê: + +```json +{"tools": {"listChanged": false}} +``` + +Sem `resources`, sem `prompts`: não há nada que os sustente. Passe `on_list_prompts` e `prompts` aparece; passe `on_completion` e `completions` aparece. + +O `MCPServer` sempre anuncia ferramentas, recursos e prompts, tenha você registrado algum ou não, porque os seus managers sempre existem. Aqui embaixo a declaração *é* a chamada ao construtor. + +## O genérico do lifespan {#the-lifespan-generic} + +O `Server` é genérico no tipo que o seu lifespan produz. Anote uma vez e o objeto fica tipado em todo lugar onde aparece: + +```python title="server.py" hl_lines="24-26 44-45 50" +--8<-- "docs_src/lowlevel/tutorial005.py" +``` + +* O lifespan é um `Callable[[Server[Catalog]], AbstractAsyncContextManager[Catalog]]`; `@asynccontextmanager` em um gerador `async` dá exatamente isso. +* O que quer que ele produza com `yield` vira `ctx.lifespan_context`, e como os handlers são anotados com `ServerRequestContext[Catalog]`, `.search(...)` tem autocompletar e passa na checagem de tipos. +* Ele é aberto uma vez quando o servidor inicia e fechado uma vez quando para. Inicialização, encerramento e a versão do `MCPServer` da mesma ideia estão em **[Lifespan](../handlers/lifespan.md)**. + +Sem um `lifespan=`, `ctx.lifespan_context` é um `dict` vazio. + +## Um método só seu {#a-method-of-your-own} + +O construtor cobre os métodos que o MCP define. `add_request_handler` cobre todo o resto: + +```python title="server.py" hl_lines="35-36 39-40 43-44 48" +--8<-- "docs_src/lowlevel/tutorial006.py" +``` + +* O primeiro argumento é a string do método. Notificações têm um irmão gêmeo, `add_notification_handler`. +* `params_type` é o modelo contra o qual os `params` recebidos são validados **antes** de o seu handler executar, então métodos personalizados *recebem* a validação que as ferramentas não recebem. Faça subclasse de `RequestParams` para que o campo `_meta` seja parseado como o de qualquer outro método. +* O handler retorna um `BaseModel`, um `dict` ou `None`. O SDK serializa isso no resultado JSON-RPC. + +Uma ressalva honesta: o `Client` de alto nível só tem verbos para os métodos que o MCP define, então não existe `client.reindex()`. Um método de fornecedor é para um par que já sabe que ele existe: um cliente que você também distribui, ou outro serviço seu falando JSON-RPC. + +Um método que você não pode reivindicar: + +```text +ValueError: 'initialize' is handled by the server runner and cannot be overridden; +use Server.middleware to observe or wrap initialization +``` + +O handshake pertence ao runner. `server/discover`, `ping` e todos os outros embutidos são seus para substituir. + +!!! tip + `Server.middleware`, mencionado naquele erro, envolve **toda** mensagem de entrada, inclusive `initialize`. Se o que você quer é observar ou reescrever o tráfego em vez de responder a um método novo, comece por **[Middleware](middleware.md)**. + +## Os outros handlers {#the-other-handlers} + +Cada um destes é uma ideia para a qual você já tem o vocabulário; cada um tem sua própria página. + +* `on_call_tool`, `on_get_prompt` e `on_read_resource` podem retornar um `InputRequiredResult` em vez do resultado normal para pausar a chamada e pedir entrada ao cliente; veja **[Requisições de múltiplas idas e voltas](../handlers/multi-round-trip.md)**. Fiel a este nível, nada é instalado para você: enquanto o `MCPServer` sela o `requestState` por padrão, aqui o `request_state` que você define atravessa o fio exatamente como foi escrito até você optar com `server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[...]), default_audience=server.name))`: uma linha (os dois nomes são importados de `mcp.server.request_state`) para a mesma selagem e verificação que o `MCPServer` faz (**[Protegendo o `requestState`](../handlers/multi-round-trip.md#protecting-requeststate)**). +* `on_list_resources`, `on_read_resource`, `on_list_prompts`, `on_get_prompt`, `on_completion` têm o mesmo formato `(ctx, params) -> result` para as outras primitivas. +* `on_subscriptions_listen` serve o stream `subscriptions/listen` de 2026-07-28. Passe um `ListenHandler` construído sobre um `SubscriptionBus` e publique eventos no bus a partir dos seus outros handlers; veja **[Assinaturas](../handlers/subscriptions.md)** para a composição completa. +* `server.streamable_http_app()` retorna o mesmo app Starlette que o do `MCPServer`; faça o deploy dele do jeito que **[Executando o seu servidor](../run/index.md)** faz o deploy de qualquer outro app ASGI. Não existe `server.run(transport=...)` aqui embaixo: `server.run(read_stream, write_stream, server.create_initialization_options())` conduz uma conexão sobre um par de streams, e essa única linha é a história completa. + +## Recapitulando {#recap} + +* O `Server` de baixo nível recebe os seus handlers como **parâmetros do construtor** `on_*`; todo handler é `async (ctx, params) -> result`. +* Você escreve o dict `input_schema` e você monta o `CallToolResult`. Nada é derivado, encapsulado ou validado por você. +* Uma exceção em um handler é um erro de protocolo `-32603`. Um erro de ferramenta que o modelo consegue ler é um `CallToolResult` com `is_error=True` que **você** retorna. +* O `_meta` no resultado é endereçado à aplicação cliente, não ao modelo. +* `Server[T]` é genérico no que o seu lifespan produz; `ctx.lifespan_context` é um `T` tipado. +* `add_request_handler(method, params_type, handler)` serve qualquer método. `initialize` é reservado. +* As capacidades que um `Server` anuncia são derivadas de quais handlers você registrou. + +`Client(server)` tratou os dois servidores de forma idêntica porque eles *são* o mesmo protocolo, e essa é justamente a ideia. A próxima camada abaixo nem é uma classe: é **[Middleware](middleware.md)**. diff --git a/i18n/pt/pages/advanced/middleware.md b/i18n/pt/pages/advanced/middleware.md new file mode 100644 index 0000000000..fe7a500d02 --- /dev/null +++ b/i18n/pt/pages/advanced/middleware.md @@ -0,0 +1,123 @@ +--- +translation: + sections: [6048b4f308edbb8c, 068bda0f21ee9c1b, c3e565b61acd75c5, c62422b159c6ed09, 47204fab253cc45c] + tool: 1 +--- +# Middleware {#middleware} + +Um **middleware** é uma única função async que envolve toda mensagem que o seu servidor recebe. + +Você o escreve como `async (ctx, call_next)` e o adiciona ao fim de `server.middleware`. A API inteira é essa. + +!!! warning + A lista de middlewares está marcada como **provisória** no código-fonte: a assinatura e a + semântica podem mudar em uma versão minor 2.x. Use-a para *observar* (tempo, logs, tracing) e + para *recusar* mensagens; não faça dela o alicerce sobre o qual o seu servidor se apoia. + +`MCPServer` recebe a lista na construção (`MCPServer(name, middleware=[...])`) e a expõe como +`mcp.middleware`; o `Server` de baixo nível expõe a mesma lista como `server.middleware`. O exemplo +abaixo usa o `Server` de baixo nível; se `Server(name, on_call_tool=...)` é novidade para você, leia +**[O Server de baixo nível](low-level-server.md)** primeiro. + +## Um middleware de medição de tempo {#a-timing-middleware} + +Um servidor, uma ferramenta, um middleware que registra no log quanto tempo cada mensagem levou: + +```python title="server.py" hl_lines="39-45 49" +--8<-- "docs_src/middleware/tutorial001.py" +``` + +* `ctx` é o mesmo `ServerRequestContext` que os seus handlers recebem. `ctx.method` é a string + bruta do método; `ctx.params` são os params brutos, **antes** de qualquer validação. +* `call_next(ctx)` executa o restante da cadeia: a validação, a busca do handler, o seu handler. + Retorne o que ele retornou e a resposta fica intacta. +* O `try`/`finally` é proposital: um handler que lança exceção ainda é cronometrado, porque a falha + chega ao seu middleware como a exceção que sai de `call_next`. +* `server.middleware.append(...)` faz o registro. A lista executa do mais externo para o mais + interno, então `middleware[0]` é o que fica mais perto do fio. + +### Experimente {#try-it} + +Conecte um cliente, liste as ferramentas, chame uma. O seu log tem **três** linhas: + +```text +server/discover took 18.3 ms +tools/list took 0.1 ms +tools/call took 0.1 ms +``` + +Você fez duas chamadas e recebeu três linhas. A primeira é `server/discover`: a requisição que o +cliente enviou para estabelecer a conexão, antes de você pedir qualquer coisa. + +É justamente esse o ponto. O middleware envolve **toda** mensagem de entrada: + +* O estabelecimento da conexão: `server/discover`, ou `initialize` e `notifications/initialized` + em uma sessão legada. +* Toda requisição e toda notificação. Para uma notificação, `ctx.request_id is None`, + `call_next(ctx)` retorna `None` e o que quer que você retorne é descartado. +* Até um método para o qual o servidor não tem handler: `call_next` lança o + `MCPError(-32601, "Method not found")` *através* do seu middleware a caminho do cliente. + +## O que você pode fazer dentro de um {#what-you-can-do-inside-one} + +Em ordem crescente do quanto você deveria hesitar: + +* **Observar.** Cronometre, conte, registre no log. O exemplo acima. +* **Recusar.** Lance um `MCPError` *em vez de* chamar `call_next(ctx)` e essa única mensagem é + respondida com um erro JSON-RPC. A conexão continua de pé; a próxima mensagem passa. É assim + que um servidor controla o acesso a `subscriptions/listen` por chamador: + **[Decidindo quem pode observar](../handlers/subscriptions.md#deciding-who-may-watch)**, na + página de Assinaturas, percorre o passo a passo. +* **Reescrever.** `ctx` é uma dataclass: `await call_next(dataclasses.replace(ctx, params=...))` + entrega ao restante da cadeia params diferentes dos que o cliente enviou. Nunca faça isso com + `initialize`: o resultado que o cliente recebe de volta é construído a partir dos seus params + reescritos, mas o servidor grava o estado da conexão a partir dos params originais do fio. Os + dois lados podem terminar o handshake discordando sobre o que negociaram. +* **Responder.** Retorne um resultado sem chamar `call_next(ctx)` e ele vai para o cliente como a + sua resposta. `call_next` entrega a você a forma final do fio, e o pipeline nunca altera o que + você retorna, então o envelope inteiro é seu: em uma conexão da era 2026 isso inclui o carimbo + `_meta` de `serverInfo`, que o SDK adiciona aos resultados dos handlers, mas não aos seus. + +!!! check + `initialize` é uma das coisas que o middleware envolve, e é o *único* gancho que você tem + para ele. Tente assumi-lo com `add_request_handler` e o SDK recusa: + + ```text + ValueError: 'initialize' is handled by the server runner and cannot be overridden; + use Server.middleware to observe or wrap initialization + ``` + +!!! warning + `initialize` é tratado inline: o servidor não lê mais nenhuma mensagem de entrada até a sua + cadeia de middlewares retornar. Aguardar uma requisição do servidor para o cliente + (`ctx.session.send_request(...)`, uma elicitação (elicitation)) enquanto trata `initialize`, + portanto, **trava a conexão em deadlock**: a resposta que você está esperando nunca poderá ser + lida. Notificações do tipo fire-and-forget não têm problema. + +## O único middleware que já vem ligado por padrão {#the-one-middleware-that-ships-on-by-default} + +O SDK traz exatamente um middleware, e ele já está na lista do seu servidor: o que emite um span +do OpenTelemetry para cada mensagem. Você não o adiciona, e na maior parte do tempo nem pensa +nele. Ele é um no-op até você instalar um exportador, e tem a própria página: +**[OpenTelemetry](../run/opentelemetry.md)**. + +!!! info + Se você já escreveu middleware ASGI, já conhece esse formato. O `(scope, receive, send)` + do Starlette virou `(ctx, call_next)`, e ele executa *depois* do transporte, sobre a mensagem + decodificada em vez da requisição HTTP bruta. Os dois se compõem: o middleware do Starlette + em `streamable_http_app()` enxerga HTTP; este enxerga MCP. + +## Recapitulando {#recap} + +* Um middleware é `async (ctx, call_next) -> result`, passado como `MCPServer(middleware=[...])` (ou + adicionado a `mcp.middleware`) e adicionado a `server.middleware` no `Server` de baixo nível. +* Ele envolve **toda** mensagem de entrada (`server/discover`, `initialize`, requisições, + notificações, métodos desconhecidos) e executa do mais externo para o mais interno. +* `ctx.request_id is None` é como você distingue uma notificação de uma requisição. +* Lance uma exceção em vez de chamar `call_next` para recusar uma mensagem; a conexão sobrevive. +* O tracing do OpenTelemetry do próprio SDK também é um middleware, já na lista. Veja + **[OpenTelemetry](../run/opentelemetry.md)**. +* Toda essa superfície é provisória. Observe com ela; não construa em cima dela. + +Isso é tudo o que envolve uma requisição. **[Autorização](../run/authorization.md)** é o que decide +se a requisição chega a ser executada. diff --git a/i18n/pt/pages/advanced/pagination.md b/i18n/pt/pages/advanced/pagination.md new file mode 100644 index 0000000000..e0ff987638 --- /dev/null +++ b/i18n/pt/pages/advanced/pagination.md @@ -0,0 +1,85 @@ +--- +translation: + sections: [a9aba7a026c7bd85, ed32bda7ba9ae33a, 7e64cc5646abb91f, 22a0129ee78b3c63, d875373c06d8d2f9] + tool: 1 +--- +# Paginação {#pagination} + +A maioria dos servidores nunca precisa disso. + +O `MCPServer` responde a toda requisição `list_*` com tudo o que tem, em uma única página, `next_cursor=None`. Para algumas dezenas de ferramentas, recursos ou prompts, essa é a resposta certa e não há nada para configurar. + +A paginação é para o servidor cuja lista de recursos é, na verdade, um banco de dados: milhares de linhas que ele se recusa a serializar em uma única resposta. A resposta do protocolo é um **cursor**: o servidor retorna uma página mais um token opaco, e o cliente envia esse token de volta para obter a próxima página. + +O `@mcp.resource()` não tem nenhum gancho para isso. Para paginar, você mesmo escreve o handler de listagem, no **[Server de baixo nível](low-level-server.md)**. + +## Um servidor que pagina {#a-server-that-pages} + +```python title="server.py" hl_lines="12 15-16" +--8<-- "docs_src/pagination/tutorial001.py" +``` + +* Em um `Server` de baixo nível, os handlers são argumentos do construtor, não decoradores. O `on_list_resources` responde a toda requisição `resources/list`; a ligação inteira é essa. +* Todo handler paginado é tipado como `params: PaginatedRequestParams | None`, e o exemplo aceita os dois. Em uma conexão, porém, o SDK nunca entrega `None` para você (uma requisição sem o membro `params` chega ao handler como o modelo com seus valores padrão), então o sinal que importa é `params.cursor is None`: **comece do início**. +* Você decide o que um cursor *é*. Aqui é um offset representado como string. Um timestamp, uma chave primária, um blob em base64: qualquer coisa que você consiga gerar na saída e reconhecer na volta. +* `next_cursor=None` é como você diz "essa foi a última página". Não há contagem, nem total, nem `has_more`. O `None` é o sinal inteiro. + +!!! tip + Um `PAGE_SIZE` de 10 deixa o exemplo legível. Escolha o seu por endpoint: uma lista de + recursos de uma linha comporta uma página de 500; uma lista de templates de prompt pesados, não. + O cliente não tem voz nisso, e é assim de propósito. + +### Experimente {#try-it} + +`Client(server)` se conecta a um `Server` de baixo nível em memória exatamente como se conecta a um `MCPServer`. + +Chame `list_resources()` sem argumentos. Você recebe dez recursos, de `book-1` a `book-10`, e `next_cursor` é a string `"10"`. + +Devolva-a com `list_resources(cursor="10")` e o primeiro recurso é `book-11`, o novo `next_cursor` é `"20"`. + +A décima página volta com `next_cursor` definido como `None`. Pronto. + +## O loop do cliente {#the-client-loop} + +Todo método `list_*` do `Client` (`list_tools`, `list_resources`, `list_resource_templates`, `list_prompts`) aceita a palavra-chave `cursor=`. Esgotar uma lista paginada é um único `while True`: + +```python title="client.py" hl_lines="26-32" +--8<-- "docs_src/pagination/tutorial002.py" +``` + +* `cursor` começa como `None`, então a primeira requisição não carrega nenhum cursor. +* Acumule **antes** de olhar para `next_cursor`: a última página também tem recursos. +* `next_cursor is None` é a saída. Qualquer outra coisa volta direto para `cursor=`, intocada. + +Execute o `main()` dele e ele imprime `100 resources`: dez páginas de dez, costuradas por um loop que nunca soube que havia dez páginas. + +Este é o mesmo loop que **[O cliente](../client/index.md)** mostra para todo verbo `list_*`, e ele não custa nada contra um servidor que não pagina: `next_cursor` é `None` na primeira resposta e o loop roda uma vez. + +## As três regras {#the-three-rules} + +**Cursores são opacos.** Um cliente nunca deve interpretar, construir ou adivinhar um. A única fonte legítima de um cursor é o `next_cursor` da página anterior, literalmente. + +**O servidor escolhe o tamanho da página.** Não existe `limit=` no protocolo. Se você precisa de um tamanho de página diferente, altere o servidor. + +**Um cliente que ignora a paginação continua funcionando.** Ele chama `list_resources()` uma vez, recebe os dez primeiros e nunca percebe o `next_cursor` que jogou fora. Nada quebra; ele só vê menos. + +!!! check + Opaco quer dizer opaco. Invente um cursor (`list_resources(cursor="page-2")`) e não há + nada que o protocolo possa fazer por você. Este servidor tenta `int("page-2")`, o handler lança uma exceção, + e o que volta para o cliente é: + + ```text + MCPError(-32603, 'Internal server error', None) + ``` + + Um cursor que você não recebeu do servidor é um bug, não um pedido de funcionalidade. + +## Recapitulando {#recap} + +* O `MCPServer` retorna tudo em uma página. A paginação é opcional, e você opta por ela no `Server` de baixo nível. +* `on_list_resources` (e `on_list_tools`, `on_list_prompts`, `on_list_resource_templates`) recebe `PaginatedRequestParams | None`; `params.cursor` é `None` na primeira página. +* Você retorna uma página mais `next_cursor`: qualquer string que você vá reconhecer depois, ou `None` quando não sobrar nada. +* O loop do cliente: passe `cursor=`, acumule, repita até `next_cursor is None`. +* Cursores são opacos, o servidor é dono do tamanho da página, e um cliente que não pagina ainda recebe a primeira página. + +O restante da API do `Server` escrita à mão (`on_call_tool`, dicts `input_schema`, `_meta`) está em **[O Server de baixo nível](low-level-server.md)**. diff --git a/i18n/pt/pages/client/caching.md b/i18n/pt/pages/client/caching.md new file mode 100644 index 0000000000..e6cfdf837a --- /dev/null +++ b/i18n/pt/pages/client/caching.md @@ -0,0 +1,122 @@ +--- +translation: + sections: [9e7b9a1710e5aeba, b74ca4c1d2ddddee, fa8714e61bf90c5a, 04db67a886b7271c, 857690fb8f876800] + tool: 1 +--- +# Dicas de cache {#caching-hints} + +Todo resultado que um servidor retorna para `tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read` e `server/discover` carrega dois campos no protocolo 2026-07-28: `ttlMs`, por quantos milissegundos um cliente pode tratar o resultado como fresco, e `cacheScope`, se um resultado em cache pode ser compartilhado entre usuários (`"public"`) ou pertence a um único contexto de autorização (`"private"`). + +O servidor não faz cache de nada. Os campos são uma *declaração*: "esta lista de ferramentas é a mesma para todo mundo e não vai mudar por um minuto." Um cliente (ou um gateway na sua frente) pode então pular a viagem de ida e volta. Respeitar as dicas é escolha do cliente; emiti-las é trabalho do servidor, e o SDK faz isso por você. + +Por padrão, todo resultado diz `ttlMs: 0, cacheScope: "private"`: obsoleto imediatamente, nunca compartilhado. Isso é sempre seguro e sempre conforme. Se as suas listas realmente são estáveis e idênticas para todos os chamadores, diga isso na construção: + +```python title="server.py" hl_lines="5-8" +--8<-- "docs_src/caching/tutorial001.py" +``` + +* O mapa é indexado pelo **nome do método**, e os seis métodos cacheáveis são as únicas chaves válidas. O parâmetro é tipado como `Mapping[CacheableMethod, CacheHint]`, então o seu editor autocompleta as chaves e aponta um erro de digitação antes de você executar; qualquer coisa que escape do verificador de tipos levanta exceção na construção. +* Um método que você não menciona mantém os padrões. O mapa é um conjunto de sobrescritas, não um manifesto. +* `CacheHint(ttl_ms=5_000)` deixou `scope` sem definir, então ele continua `"private"`: cinco segundos de frescor, por chamador. Escopo e TTL são decisões independentes. +* `"server/discover"` também é uma chave válida, já que o resultado de descoberta é cacheável como qualquer lista. + +!!! warning + `cacheScope: "public"` significa que *qualquer um* pode receber a sua resposta em cache. Um + gateway compartilhado vai entregar sem hesitar o resultado de um usuário a outro, mesmo quando a + requisição foi autenticada. Marque um resultado como `"public"` apenas quando ele é idêntico para + todo chamador, e nunca use `cacheScope` como controle de acesso: é um rótulo, não um cadeado. + +## Sobrescrita por handler {#per-handler-override} + +No `Server` de baixo nível, os handlers montam seus resultados à mão, e `ttl_ms` / `cache_scope` são apenas campos nos modelos de resultado. Um handler que os define explicitamente sempre vence o mapa do construtor, campo a campo: + +```python title="server.py" hl_lines="10 16" +--8<-- "docs_src/caching/tutorial002.py" +``` + +O handler disse `ttl_ms=1_000` e nada sobre escopo. No fio: `ttlMs: 1000` (o do handler, não o `60_000` do mapa) e `cacheScope: "public"` (o do mapa, porque o handler o deixou sem definir). Explícito vence configurado, e configurado vence o padrão. Isso vale por campo, então um handler pode fixar um campo e deixar o outro para a política do servidor inteiro. + +Essa também é a saída de emergência para dinâmicas que o construtor não tem como conhecer: um handler que filtra `resources/read` por usuário pode retornar `cache_scope="private"` para uma URI de um servidor que, de resto, é público. + +Uma ressalva sobre listas paginadas: o protocolo exige o **mesmo `cacheScope` em todas as páginas** de uma lista. O mapa do construtor satisfaz isso por construção, já que é indexado por método, não por página. Mas um handler que sobrescreve o escopo assume ele mesmo essa consistência: sobrescreva em *todas* as páginas, nunca apenas quando há um cursor presente, ou a página um e a página dois vão discordar. + +## O que o cliente vê {#what-the-client-sees} + +Numa sessão 2026-07-28, o `Client` respeita as dicas por você: ele tem um cache de respostas embutido, ligado por padrão. Um resultado que chega carregando um `ttlMs` é armazenado, e uma chamada idêntica dentro desse TTL é servida do cache sem viagem de ida e volta. Um resultado que não carrega *nenhuma* dica não é armazenado: resultados sem dica recebem `CacheConfig.default_ttl_ms`, cujo padrão é `0` (obsoleto imediatamente), então um servidor que não declara nada vê exatamente o mesmo tráfego chamada a chamada de sempre. + +```python title="client.py" hl_lines="33 35 38" +--8<-- "docs_src/caching/tutorial003.py" +``` + +Quatro chamadas, três buscas. A segunda chamada encontrou uma entrada fresca e nunca chegou ao servidor; avançar o relógio (injetado) além do TTL fez a terceira buscar de novo; a quarta disse `cache_mode="refresh"`. Esse argumento nomeado existe nos cinco verbos com cache (`list_tools`, `list_prompts`, `list_resources`, `list_resource_templates`, `read_resource`): + +* `"use"` (o padrão) serve uma entrada fresca se houver uma, e armazena a busca se não houver. +* `"refresh"` nunca serve: busca e armazena o resultado, substituindo o que quer que estivesse em cache. +* `"bypass"` faz a viagem de ida e volta sem tocar no cache: sem leitura, sem escrita. + +Uma regra fica acima de `"use"`: **chamadas que carregam `meta` sempre chegam ao servidor.** Uma requisição com `meta` definido (um token de progresso, campos de rastreamento) espera uma requisição no fio, então sob `cache_mode="use"` ela é tratada como `"refresh"`: a leitura do cache é pulada, e o resultado buscado ainda substitui a entrada em cache. `"bypass"` e um `"refresh"` explícito se comportam como sempre. + +Para desligar o cache por completo, construa com `Client(server, cache=None)`: toda chamada volta a ser uma viagem de ida e volta, e `cache_mode`, embora ainda aceito, não faz nada. + +O escopo também é respeitado automaticamente: entradas `"private"` são indexadas pela *partição* do cache (abaixo), enquanto as `"public"` podem optar por um compartilhamento mais amplo. E **notificações vencem o TTL** para as entradas exatas que nomeiam: uma notificação `list_changed` remove a listagem em cache correspondente, e `resources/updated` remove a leitura em cache armazenada sob exatamente a sua URI, por mais frescas que estivessem. Numa conexão 2026-07-28 essas notificações chegam num stream `subscriptions/listen` que você abre com `client.listen(...)`, e a remoção se completa antes de o seu observador ver o evento; **[Assinaturas](subscriptions.md)** é essa página. + +Uma ressalva sobre `resources/updated`: a remoção é apenas por URI exata. O contrato do store não tem operação de enumeração nem de varredura (igual à implementação de referência em TypeScript), então uma notificação carregando a URI de um *sub*-recurso não remove a leitura em cache do seu pai. Se o seu servidor sinaliza sub-recursos dessa forma, busque o pai de novo com `cache_mode="refresh"`. + +### Configurando: `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`: onde as entradas vivem. O padrão é um store em memória novo por cliente; passe a sua própria implementação de `ResponseCacheStore` (apoiada em Redis, digamos) para compartilhar um cache entre clientes ou processos. Os tipos do contrato (`ResponseCacheStore`, `CacheKey`, `CacheEntry` e o padrão `InMemoryResponseCacheStore`) são importáveis de `mcp.client`. Uma consulta pode emitir até dois `get`s sequenciais ao store (o braço privado, depois o público), então dimensione as expectativas de latência de um store remoto de acordo. Um store personalizado **exige** uma `partition` explícita. +* `partition`: o rótulo de contexto de autorização que impede que as entradas `"private"` de um principal sejam servidas a outro dentro de um store compartilhado. +* `target_id`: identidade explícita do servidor, para transportes personalizados e servidores no mesmo processo (abaixo). +* `default_ttl_ms`: TTL aplicado a resultados que não carregam dica `ttlMs`. O padrão `0` deixa resultados sem dica fora do cache. +* `share_public`: servir entradas marcadas como `"public"` pelo servidor entre partições (abaixo). Desligado por padrão. +* `clock`: a fonte de relógio de parede, em segundos da época. Injete uma, como o exemplo acima faz, e testes de expiração não precisam dormir. + +!!! warning "Partição = principal verificado" + Derive `partition` de uma **credencial verificada**, como o sujeito de um token validado. Nunca a derive de dados fornecidos pela requisição, e nunca da URL do servidor (a identidade do servidor é um eixo de chave separado). O SDK é uma biblioteca sem autenticação própria: a âncora de confiança é quem constrói o `CacheConfig`, que é o deploy, não o tenant. Um gateway multi-tenant emite um `CacheConfig` por principal autenticado. + + A partição também é fixa pelo tempo de vida do `Client`. Se o contexto de autorização da conexão mudar no meio da sessão (uma reautenticação como um principal diferente, digamos), o cache não acompanha; construa um novo `Client` para o novo principal. + +As chaves de cache também carregam a **identidade do servidor**: a string de URL que você discou, com qualquer userinfo `user:pass@` removido e, de resto, exata byte a byte. Sem normalização de maiúsculas, sem reordenação de query, sem limpeza de barra final. Normalizar de menos só custa compartilhamento, enquanto normalizar demais poderia fundir dois tenants (`?tenant=a` versus `?tenant=b`), então URLs superficialmente diferentes simplesmente não compartilham entradas. Quando não há URL (um servidor no mesmo processo, ou uma instância de `Transport`), o cliente recebe uma identidade aleatória por instância; defina `CacheConfig.target_id` para nomear o servidor (com um store personalizado isso é obrigatório, e a construção avisa). A identidade passa por hash sha256 antes de entrar no material da chave, então uma URL carregando segredos na query string nunca aparece nas chaves do store. Também não registre em log a forma pré-hash por conta própria. + +!!! warning "`share_public` confia no servidor, para a frota inteira" + Por padrão, até entradas `"public"` ficam dentro da sua partição. `share_public=True` serve entradas que o servidor marcou como `cacheScope: "public"` a **todas** as partições que usam o store, confiando na classificação do servidor em nome de todas elas. Um servidor que carimba `"public"` em dados por tenant (por bug ou por malícia) então vaza a resposta de um tenant para os outros. A flag é deliberadamente apenas de nível de construtor: o `cache_mode` por chamada pode restringir o cache, mas nada por chamada pode ampliar o compartilhamento. + +### O que o cache nunca faz {#what-the-cache-never-does} + +* **Chamadas da camada de sessão o contornam.** `client.session.list_tools()` e companhia sempre fazem a viagem de ida e volta; o cache vive nos verbos do `Client`. +* **`server/discover` fica de fora.** O resultado de descoberta é entregue uma vez, na conexão, e nunca entra no cache de respostas, mesmo quando carrega um `ttlMs`. Se você persiste um por conta própria para pular a sondagem de reconexão ([`prior_discover`](../protocol-versions.md#reconnecting-with-prior_discover)), o frescor dele é contabilidade sua: `DiscoverResult` carrega `ttl_ms` e `cache_scope`, já parseados, exatamente para isso. +* **Páginas de continuação nunca são armazenadas.** Apenas chamadas sem cursor participam. Uma página de continuação rejeitada por cursor expirado *remove*, sim, a listagem em cache, porque a listagem mudou por baixo dela. +* **Leituras de múltiplas viagens nunca são armazenadas.** Um `read_resource` semeado com `input_responses`/`request_state`, ou um que se resolve por rodadas de entrada, nunca entra no cache (um MUST da especificação). +* **Remoção por notificação precisa de notificações.** A remoção é tão boa quanto a entrega do transporte, e o caminho moderno no mesmo processo (`Client(server)` com o padrão `mode="auto"`) hoje não entrega notificações avulsas. +* **A remoção é eventual, não instantânea.** Notificações pelo fio são despachadas a partir de tarefas iniciadas em paralelo, então uma chamada correndo contra a chegada de uma notificação pode receber a entrada pré-remoção mais uma vez; a janela é limitada pela latência de despacho, e a remoção ainda acontece. +* **Sem stale-if-error.** Uma entrada expirada nunca é servida porque a nova busca falhou; o erro se propaga. +* **Sem busca antecipada.** Uma entrada armazenada é servida até o TTL expirar, e a próxima chamada depois disso paga a viagem de ida e volta; nada se atualiza em segundo plano. +* **Sem coalescência.** Duas chamadas idênticas concorrentes são duas buscas. +* **Sem TTL acima de 24 horas.** Um `ttlMs` maior, seja enviado pelo servidor ou configurado, é reduzido ao armazenar (`mcp.client.caching.MAX_TTL_MS`), limitando por quanto tempo qualquer entrada, por mais generosa que seja a dica, pode ser servida. +* Num **store compartilhado**, os clientes correm uns contra os outros. Cada cliente descarta a própria escrita quando uma remoção ultrapassou a busca em andamento, mas um cliente *co-tenant* ainda pode escrever de volta uma entrada que uma remoção que ele nunca viu havia removido; e essa contabilidade de corrida é ela própria limitada: acima de 4096 chaves rastreadas, a guarda da chave mais antiga é descartada primeiro. Ambas as janelas são aceitas, e fechadas pelo limite de TTL acima. +* **Sem servir entre eras do protocolo.** As entradas têm escopo na versão de protocolo negociada: num store persistente compartilhado, uma sessão nunca serve uma entrada escrita sob uma versão negociada diferente (a mesma listagem difere de verdade por era, já que o SDK remove os campos de 2026 para sessões mais antigas). A remoção igualmente só toca as entradas da era atual; as entradas de outra era simplesmente envelhecem pelo TTL. + +### Lendo as dicas por conta própria {#reading-the-hints-yourself} + +As dicas também são campos simples em todo resultado cacheável (`result.ttl_ms` e `result.cache_scope`, já parseados), caso você queira acrescentar a sua própria contabilidade em cima do cache embutido (ou no lugar dele). + +Contra um **servidor mais antigo** (protocolo pré-2026), os campos simplesmente não existem no fio, e os modelos mostram seus padrões conservadores: `ttl_ms == 0` e `cache_scope == "private"`, obsoleto e não compartilhado, a suposição certa para um servidor que não declarou nada. O cache trata uma sessão legada do mesmo jeito: as dicas nunca são consultadas ali (quaisquer que sejam as chaves que apareçam no fio), só `default_ttl_ms` se aplica, e seu padrão de `0` não armazena nada, então uma conexão pré-2026 se comporta exatamente como antes de o cache existir. Se você precisa distinguir "o servidor disse 0" de "o servidor não disse nada", verifique `"ttl_ms" in result.model_fields_set`: só é definido quando o campo realmente chegou. + +## Clientes mais antigos {#older-clients} + +Clientes em versões de protocolo pré-2026 nunca veem nenhum dos dois campos; o SDK os remove na serialização para essas conexões. Configure as suas dicas uma vez; não há nada específico de versão para escrever. + +## Recapitulando {#recap} + +* Seis métodos carregam `ttlMs`/`cacheScope`; o SDK os define por padrão como `0`/`"private"`, obsoleto e não compartilhado, sempre seguro. +* `cache_hints={method: CacheHint(...)}` na construção (tanto `MCPServer` quanto `Server`) define valores do servidor inteiro por método. +* Um handler que define os campos no seu resultado sobrescreve o mapa, por campo. +* `"public"` é uma promessa de que o resultado é idêntico para todo chamador. Não é controle de acesso. +* O `Client` respeita as dicas automaticamente: seu cache de respostas fica ligado por padrão, serve entradas frescas em vez de buscar de novo, e não armazena nada para servidores (ou sessões) que não fornecem dicas. +* Por chamada, `cache_mode="refresh"` busca de novo e `"bypass"` pula o cache; `cache=None` na construção o desliga por completo. diff --git a/i18n/pt/pages/client/callbacks.md b/i18n/pt/pages/client/callbacks.md new file mode 100644 index 0000000000..39308a3e3a --- /dev/null +++ b/i18n/pt/pages/client/callbacks.md @@ -0,0 +1,154 @@ +--- +translation: + sections: [adf3c545b5be46b6, 916cd3ab1c03f461, e9be7a8d0eb0a456, 565890a636288ecf, 6af7e49db9129ec3, 06b0238c174186af, 90c6043be435fcb0] + tool: 1 +--- +# Callbacks do cliente {#client-callbacks} + +Quase toda requisição no MCP vai em um só sentido: do cliente para o servidor. + +Um servidor também pode pedir coisas ao **cliente**: fazer uma pergunta ao usuário, amostrar o modelo do usuário, listar as pastas do workspace do usuário. Você responde a essas requisições passando **callbacks** para `Client(...)`. + +## Um servidor que pergunta {#a-server-that-asks} + +Aqui está um servidor cuja ferramenta não consegue terminar sozinha: + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/client_callbacks/tutorial001.py" +``` + +* `ctx.elicit(...)` envia uma requisição `elicitation/create` **para o cliente** e espera. +* A ferramenta não retorna até que alguém (uma pessoa em um formulário, ou o seu código) forneça um `name`. + +Essa é a metade do servidor, e a página **[Elicitação](../handlers/elicitation.md)** cuida dela. Esta página é a outra ponta do fio. + +## O callback de elicitação {#the-elicitation-callback} + +```python title="client.py" hl_lines="6-10 16-17" +--8<-- "docs_src/client_callbacks/tutorial002.py" +``` + +* Um callback de elicitação (elicitation) é `async (context, params) -> ElicitResult`. +* `params.message` é a pergunta. `params.requested_schema` é o JSON Schema da resposta que o servidor quer. Um cliente de verdade renderiza um formulário a partir dele; este aqui preenche automaticamente. +* Você retorna `ElicitResult(action="accept", content={...})`, ou `action="decline"`, ou `action="cancel"`. A única outra opção é `ErrorData(...)`, que recusa a requisição e faz a chamada inteira falhar. +* `context` é um `ClientRequestContext`: a `session` ativa, o `request_id` do servidor e qualquer `meta` que ele tenha anexado. + +!!! tip + `params` é uma união dos dois modos de elicitação. Aqui `params.mode` é `"form"`; uma requisição `"url"` + traz `params.url` em vez de um schema. Um único callback trata os dois; ramifique em `params.mode`. + **[Elicitação](../handlers/elicitation.md)** mostra o padrão completo. + +### Experimente {#try-it} + +Chame `issue_card` e observe as duas pontas. + +Seu callback recebe a pergunta do servidor, já analisada: + +```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'} +``` + +Ele responde, `ctx.elicit(...)` retoma dentro da ferramenta, e a ferramenta termina: + +```python +result.content # [TextContent(type='text', text='Card issued to Ada Lovelace.')] +``` + +Um `tools/call` seu, um `elicitation/create` de volta do servidor, respondido pela sua função, tudo dentro de uma única chamada de ferramenta. + +!!! info + O `mode="legacy"` na chamada `Client(...)` está fazendo trabalho de verdade. Por padrão, `Client(...)` negocia o caminho + moderno do protocolo, e esse caminho não tem canal de retorno (back-channel) para requisições do servidor ao cliente: `ctx.elicit` + falha antes mesmo de o seu callback rodar. Não é o transporte que decide isso; é o protocolo + negociado, tanto em memória quanto por uma URL. Fixe `mode="legacy"` sempre que o seu cliente tiver + que responder a uma; todos os testes por trás desta página fazem isso. **[Versões do protocolo](../protocol-versions.md)** tem a história completa. + + Em uma sessão 2026-07-28 o callback não está morto, ele é alimentado de outro jeito: quando uma ferramenta retorna um + `InputRequiredResult` carregando um `ElicitRequest`, o `Client` despacha essa entrada para o mesmo + `elicitation_callback` e refaz a chamada para você. Esse fluxo está em **[Requisições de múltiplas idas e voltas](../handlers/multi-round-trip.md)**. + +## Um callback é uma capacidade {#a-callback-is-a-capability} + +Você nunca disse ao servidor que o seu cliente consegue responder a requisições de elicitação. O SDK disse. + +Quando um cliente se conecta, ele declara suas `capabilities`, a imagem espelhada das do servidor. Você não escreve esse objeto. **Registrar um callback é a declaração.** + +| você passa | o cliente declara | +| --- | --- | +| `elicitation_callback=` | `"elicitation": {"form": {}, "url": {}}` | +| `sampling_callback=` | `"sampling": {}` | +| `list_roots_callback=` | `"roots": {"listChanged": true}` | +| nenhum deles | `{}` | + +As subcapacidades de amostragem (sampling) são o único refinamento: passe `sampling_capabilities=SamplingCapability(tools=SamplingToolsCapability())` junto com `sampling_callback` quando o seu amostrador trata os parâmetros `tools` / `tool_choice`. Os servidores precisam ver `sampling.tools` declarado antes de poderem enviá-los. + +`logging_callback` e `message_handler` não estão na tabela. Eles tratam notificações, e notificações não precisam de capacidade. + +O servidor lê a declaração de volta com `ctx.session.check_client_capability(...)`. Adicione uma ferramenta que faça isso: + +```python title="server.py" hl_lines="23-31" +--8<-- "docs_src/client_callbacks/tutorial003.py" +``` + +Conecte com apenas `elicitation_callback` e chame-a: + +```python +result.structured_content # {'result': ['elicitation']} +``` + +Passe os três callbacks e você recebe `['elicitation', 'sampling', 'roots']`. Não passe nenhum e você recebe `[]`. + +!!! check + Agora faça a coisa errada: conecte **sem** `elicitation_callback` e chame `issue_card` mesmo assim. + + A requisição `elicitation/create` do servidor ainda chega ao seu cliente, e o SDK a responde por + você, com um erro, porque você nunca disse que conseguiria tratá-la. Esse erro afunda a chamada inteira. + `call_tool` não retorna um resultado `is_error`; ele levanta uma exceção: + + ```text + MCPError: Elicitation not supported + ``` + + Isso é um erro de protocolo (`-32600`, *invalid request*), não um erro de ferramenta: não há nada para + o modelo ler e tentar de novo. É por isso que vale a pena ter `client_features`: um servidor bem-comportado + verifica antes de perguntar. + +## O par descontinuado {#the-deprecated-pair} + +`sampling_callback` responde a `sampling/createMessage`: o servidor pedindo ao *seu* modelo que complete algo. `list_roots_callback` responde a `roots/list`: o servidor perguntando em quais diretórios ele pode trabalhar. + +Os dois funcionam. Os dois seguem a regra acima. E os dois atendem RPCs que a **spec 2026-07-28 remove**: um servidor moderno não chama de volta o seu cliente no meio de uma requisição, ele devolve a requisição para você como parte do resultado da ferramenta (**[Requisições de múltiplas idas e voltas](../handlers/multi-round-trip.md)**). Os callbacks em si não estão mortos. Quando um `InputRequiredResult` carrega um `CreateMessageRequest` ou um `ListRootsRequest`, o loop automático do `Client` o despacha para o mesmo `sampling_callback` ou `list_roots_callback` que você registrou aqui. A lista inteira está em **[Funcionalidades descontinuadas](../deprecated.md)**. + +Você ainda precisa dos callbacks para falar com servidores que não migraram. As assinaturas: + +```python title="client.py" +--8<-- "docs_src/client_callbacks/tutorial004.py" +``` + +* Um callback de amostragem recebe o `CreateMessageRequestParams` completo (`messages`, `model_preferences`, `max_tokens`) e retorna um `CreateMessageResult`. *Você* executa o modelo, do jeito que quiser; o SDK só transporta a requisição. +* Um callback de roots não recebe parâmetro nenhum e retorna um `ListRootsResult`. +* Qualquer um dos dois pode retornar `ErrorData(...)` no lugar, para recusar. + +Passe-os para `Client(...)` exatamente como `elicitation_callback`. + +## Os callbacks de notificação {#the-notification-callbacks} + +Mais dois. Nenhum deles declara nada. + +`logging_callback` recebe as `notifications/message` que um servidor envia, como `LoggingMessageNotificationParams` (`level`, `logger`, `data`). O logging de protocolo em si foi descontinuado pela spec 2026-07-28 (**[Logging](../handlers/logging.md)** diz o que fazer no lugar), então esse callback existe para os servidores que ainda o emitem. Em uma conexão da era 2026, o callback sozinho não te dá nada, porque servidores 2026 enviam mensagens de log apenas para requisições que optam por recebê-las: passe `log_level="info"` (ou outro nível) para `Client(...)` para carimbar essa opção em toda requisição e receber esse nível e acima. Servidores pré-2026 o ignoram e mantêm o comportamento de `logging/setLevel`. + +`message_handler` é o pega-tudo: toda notificação do servidor que a sessão expõe chega até ele (além do callback específico dela), e em um transporte baseado em stream toda `Exception` no nível do transporte também. Duas nunca chegam: `notifications/cancelled` é aplicada pelo SDK em vez de exposta, e a confirmação de assinatura de um stream `listen()` ativo é consumida por esse stream. Anote o parâmetro com `IncomingMessage` (`ServerNotification | Exception`, exportado de `mcp.client`). O único padrão que vale conhecer é `if isinstance(message, Exception): raise message`, para que uma conexão quebrada falhe em alto e bom som em vez de sumir. + +## Recapitulando {#recap} + +* Um servidor pode enviar requisições ao cliente. Você as responde com callbacks passados para `Client(...)`. +* O callback de elicitação é o atual: `async (context, params) -> ElicitResult`, uma função para os modos formulário e URL. +* **Registrar um callback é declarar a capacidade.** Sem ele, o SDK recusa a requisição do servidor em seu nome e a chamada inteira falha com `MCPError`. +* Um servidor descobre antes de perguntar com `ctx.session.check_client_capability(...)`. +* `sampling_callback` e `list_roots_callback` funcionam do mesmo jeito, mas atendem funcionalidades descontinuadas; servidores modernos usam requisições de múltiplas idas e voltas no lugar. +* `logging_callback` e `message_handler` recebem notificações. Eles não declaram nada. + +O primeiro argumento de `Client(...)` é um objeto de transporte. **[Transportes do cliente](transports.md)** cobre todos os tipos. diff --git a/i18n/pt/pages/client/identity-assertion.md b/i18n/pt/pages/client/identity-assertion.md new file mode 100644 index 0000000000..646ee921fd --- /dev/null +++ b/i18n/pt/pages/client/identity-assertion.md @@ -0,0 +1,152 @@ +--- +translation: + sections: [a91322c46111d16d, 8e6fd6d6f59bb568, e7828fd2729b2c9d, a03ec26bfc678b65, 1034c653c0bcf1b0] + tool: 1 +--- +# Asserção de identidade {#identity-assertion} + +Um provider OAuth comum (**[Clientes OAuth](oauth-clients.md)**) começa fazendo uma pergunta ao servidor MCP: *em qual servidor de autorização você confia?* Ele segue a resposta para onde quer que ela aponte e, depois, ou uma pessoa faz login ou um segredo pré-compartilhado faz esse papel. + +Uma empresa não quer nenhuma das duas coisas decidida servidor por servidor. Ela já opera um provedor de identidade (Okta, Microsoft Entra ID, o seu próprio); o usuário já fez login nele hoje de manhã; e esse é o único lugar onde o time de segurança quer decidir quem pode acessar o quê. A [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990), a extensão **Enterprise-Managed Authorization**, leva a decisão para lá. O IdP assina um JWT de curta duração, um **Identity Assertion JWT Authorization Grant**, o **ID-JAG**: uma declaração de que *este usuário*, por meio *deste cliente*, pode acessar *este servidor MCP*. O cliente o troca por um token de acesso comum. Sem navegador, sem tela de consentimento, sem registro dinâmico. + +Esta página cobre as duas pontas dessa troca. O servidor MCP em si não muda nada: continua sendo o servidor de recursos de **[Autorização](../run/authorization.md)**, verificando qualquer token que apareça. + +## Duas requisições de token {#two-token-requests} + +Duas autoridades diferentes estão em jogo, e saber distingui-las pelo nome é quase tudo o que você precisa para entender esta página. O **IdP corporativo** é o provedor de identidade da sua organização: ele sabe quem é o funcionário, é onde as políticas vivem e é quem emite o ID-JAG. O SDK nunca fala com ele. O **servidor de autorização MCP** é a mesma parte que era em **[Autorização](../run/authorization.md)**: o issuer nomeado nos metadados do servidor MCP, aquilo que emite os tokens que esse servidor MCP aceita. Em um fluxo OAuth comum, esses dois papéis costumam ser uma caixa só. Aqui são duas, e o grant inteiro é a segunda concordando em confiar na primeira. + +O cliente faz uma requisição de token a cada uma. + +1. **Ao IdP corporativo.** O cliente troca o login do usuário (o ID token OpenID Connect dele) pelo ID-JAG. É um token exchange da [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693), é inteiramente a API do seu IdP, e **o SDK não o faz**. Você faz, dentro de um único callback assíncrono. É também onde a decisão de política acontece: um IdP que diz não nunca emite o ID-JAG, e não há nada a apresentar. +2. **Ao servidor de autorização MCP.** O cliente apresenta o ID-JAG sob o grant `jwt-bearer` da [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) (`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`, o ID-JAG como `assertion`) e recebe o token de acesso. **Esta é a requisição que o SDK faz**, e aceitá-la é a única coisa que esta página acrescenta a um servidor de autorização. + +Tudo abaixo é a segunda requisição: o cliente que a envia e o servidor de autorização que a responde. + +## O cliente {#the-client} + +**`IdentityAssertionOAuthProvider`** fica em `mcp.client.auth.extensions.identity_assertion`. Como todo provider em **[Clientes OAuth](oauth-clients.md)**, ele é um `httpx2.Auth`: construa um, passe em `auth=`, entregue o `httpx2.AsyncClient` ao transporte. + +```python title="client.py" hl_lines="49-50 53-61" +--8<-- "docs_src/identity_assertion/tutorial001.py" +``` + +Leia de baixo para cima. + +* `main()` é o `main()` padrão de cliente OAuth (**[Clientes OAuth](oauth-clients.md)**), sem mudar uma linha sequer. Esse é o ponto: uma vez que o provider existe, nada adiante sabe qual grant produziu o token. +* O provider recebe aquilo que os outros providers não conseguem descobrir: um `client_id` e um `client_secret` que alguém **pré-registrou** no servidor de autorização, o `issuer` desse servidor de autorização e `assertion_provider`, um callback assíncrono que retorna um ID-JAG novo sob demanda. +* `storage` é o mesmo protocolo `TokenStorage`. Só os dois métodos de token são chamados; não há registro dinâmico aqui, então não há `client_info` para lembrar. + +### O provedor de asserção {#the-assertion-provider} + +`fetch_id_jag(audience, resource)` é o único código que você escreve. Ele é aguardado com await uma vez por troca de token, nunca na construção, e só *depois* que os metadados do servidor de autorização foram buscados e validados, de modo que um issuer mal configurado nunca vaza uma asserção. Seus dois argumentos são duas das claims com que o ID-JAG precisa ser emitido: `audience` é o issuer do servidor de autorização (o `aud` do ID-JAG) e `resource` é o identificador canônico do servidor MCP (o `resource` do ID-JAG). A terceira você já tem em mãos: a claim `client_id` do ID-JAG precisa nomear o `client_id` que você deu ao provider, ou o servidor de autorização recusa a troca. + +`idp_issue_id_jag`, logo acima, **não é código seu**. Ele faz o papel do provedor de identidade, assinando a asserção no próprio processo para que o arquivo fique completo e você possa ler cada claim que um ID-JAG carrega. Um `fetch_id_jag` de verdade faz, em vez disso, a primeira requisição de token da seção anterior: um token exchange da [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) contra o seu IdP, definido pelo draft Identity Assertion JWT Authorization Grant do qual a [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) é um perfil. O ID token do usuário logado entra como `subject_token`, o `requested_token_type` é a URN própria do ID-JAG (`urn:ietf:params:oauth:token-type:id-jag`), `audience` e `resource` passam direto, e a resposta traz o ID-JAG. Essa troca, com esses nomes, é o que procurar na documentação do seu IdP. + +!!! tip + Um ID-JAG novo é solicitado a cada troca, e esse é o ponto: é um grant de uso único, que vive + minutos, e o servidor de autorização desta página se recusa a aceitar o mesmo duas vezes. Não + faça cache dele. O que é reutilizado é o token de acesso que ele compra para você. + +### O issuer é configuração {#the-issuer-is-configuration} + +Aqui está a inversão. `OAuthClientProvider` pergunta ao servidor de recursos qual servidor de autorização usar e segue a resposta para onde quer que ela aponte. Este provider se recusa a fazer isso: `issuer` é obrigatório, os metadados da [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) são buscados no caminho well-known do próprio issuer, o endpoint de token precisa estar na origem desse issuer, e nada é perguntado ao servidor de recursos. + +A extensão não exige isso; é uma escolha deliberadamente mais rígida. Este cliente carrega duas coisas que valem a pena roubar, um segredo pré-registrado e uma asserção vinculada a uma audience, e um cliente que deixasse um servidor MCP comprometido conduzi-lo até o servidor de autorização de um atacante postaria as duas lá. Fixar o issuer na construção elimina essa conversa. + +!!! warning + O `issuer` configurado é comparado com o campo `issuer` do documento de metadados pela + comparação simples de strings da RFC 8414 §3.3: caractere por caractere, barra final incluída, + sem normalização. Não chute. Busque `/.well-known/oauth-authorization-server` no seu servidor + de autorização e copie o valor de `issuer` que ele retorna. Para o servidor de autorização desta + página, é `https://auth.example.com/`, com a barra, porque seu issuer foi construído a partir de + um objeto URL do pydantic. Uma divergência para o fluxo em `OAuthFlowError: Authorization server metadata issuer + mismatch` antes de qualquer credencial ou asserção ser enviada. + +### Um cliente confidencial {#a-confidential-client} + +`client_secret` é obrigatório; o construtor levanta `ValueError` sem ele. O perfil do IETF por baixo da [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) reserva este grant para clientes confidenciais, a SEP-990 exige que o cliente se autentique, e este SDK impõe as duas coisas insistindo em um segredo compartilhado. `token_endpoint_auth_method` escolhe por onde ele viaja: `client_secret_post` (o padrão, no corpo do formulário) ou `client_secret_basic` (um cabeçalho HTTP Basic). O perfil também permite `private_key_jwt`; este provider não oferece suporte a ele. + +!!! tip + Leia `client_secret` do ambiente ou de um gerenciador de segredos, nunca do controle de versão. + +### O que o provider faz por você {#what-the-provider-does-for-you} + +A primeira requisição sai sem autenticação, e o `401` do servidor inicia o fluxo. + +1. **Descoberta.** Ele busca os metadados do servidor de autorização no caminho well-known da [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) do issuer configurado, verifica que o `issuer` do documento confere e verifica que o endpoint de token está na origem do issuer. +2. **A asserção.** Ele aguarda com await o seu `assertion_provider`. +3. **Troca.** Ele faz POST do grant `jwt-bearer` no endpoint de token, armazena o `OAuthToken` e reenvia sua requisição original com `Authorization: Bearer ...`. + +Um `403` cujo `WWW-Authenticate` nomeia `insufficient_scope` executa os passos 2 e 3 de novo com a união do seu `scope` com o do desafio. (`scope` nunca passa de uma solicitação; o servidor de autorização desta página concede o que o ID-JAG diz e nada mais.) Não há refresh token em lugar nenhum disto: quando o token de acesso expira, o próximo `401` emite um ID-JAG novo e troca de novo, e *essa* é a alavanca que o IdP tem nas mãos. As falhas são as mesmas duas exceções do resto de **[Clientes OAuth](oauth-clients.md)**: `OAuthFlowError` para descoberta e validação, sua subclasse `OAuthTokenError` quando o endpoint de token diz não. + +## O servidor de autorização {#the-authorization-server} + +Na maioria das vezes você para aqui. O servidor de autorização MCP é produto de outra pessoa, aceitar ID-JAGs é uma configuração dele a ser ligada, e a metade da [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) que cabe ao SDK é o cliente acima. + +O SDK também pode *ser* o servidor de autorização: `create_auth_routes` retorna as rotas do servidor de autorização como uma lista que qualquer app Starlette pode montar, e é assim que `examples/servers/simple-auth/` no repositório executa um. A SEP-990 acrescenta uma flag e um método a essa superfície: + +```python title="auth_server.py" hl_lines="48-50 105-107" +--8<-- "docs_src/identity_assertion/tutorial002.py" +``` + +* `identity_assertion_enabled=True` controla tudo. Desligada, que é o padrão, `/token` responde a este grant com `unsupported_grant_type` mesmo que você tenha implementado o hook, e os metadados não o mencionam. Ligada, os metadados ganham o grant type `jwt-bearer` e listam `urn:ietf:params:oauth:grant-profile:id-jag` em `authorization_grant_profiles_supported`, o campo que a extensão usa para anunciar suporte. (O cliente deste SDK nunca o lê: ele é provisionado para um único issuer e simplesmente pede.) +* **`exchange_identity_assertion`** é o hook. Antes de ele rodar, o SDK já autenticou o cliente, recusou clientes públicos e recusou clientes cujo registro não lista o grant. Você recebe um `IdentityAssertionParams` (a `assertion` crua, os `scopes` e o `resource` solicitados) e retorna um `OAuthToken` simples. +* O registro dinâmico de clientes recusa este grant incondicionalmente, então `get_client` aqui serve um cliente provisionado à mão. Um cliente ID-JAG não consegue passar a existir registrando a si mesmo. +* Metade da classe são recusas. `OAuthAuthorizationServerProvider` é o servidor de autorização *inteiro*, então também pede o fluxo authorization code; um servidor que também faz login de usuários implementa esses métodos de verdade, e este aqui tem exatamente uma porta. + +!!! warning + O SDK nunca decodifica a asserção: só o seu deploy sabe em qual IdP confia e quais chaves esse + IdP publica, então tudo dentro de `exchange_identity_assertion` é o que sustenta a segurança. + Verifique a assinatura contra as chaves publicadas pelo IdP (o JWKS dele; o segredo + compartilhado aqui é o da demo), e também `iss` e `exp`, conforme a [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) §3. Exija que o + `typ` do cabeçalho do JWT seja `oauth-id-jag+jwt`, a proteção do perfil contra algum outro JWT + ser reapresentado como grant. Exija que `aud` seja o seu próprio issuer. Exija que a claim + `client_id` do ID-JAG seja igual ao cliente que o handler autenticou, e que a claim `resource` + nomeie um recurso que você de fato serve. Rastreie o `jti` até o `exp` da asserção para que ela + seja aceita uma vez só. E tire os escopos concedidos e, acima de tudo, o `resource` do token + emitido do ID-JAG validado, nunca da requisição: `params.resource` é o que quer que o cliente + tenha digitado. As regras completas de processamento estão na + [especificação Enterprise-Managed Authorization](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization). + +Rejeite uma asserção ruim com `TokenError("invalid_grant", ...)`. O outro código de erro neste fluxo é `invalid_target`: um ID-JAG que nomeia um recurso que você não serve é recusado com ele, e é isso que impede este servidor de emitir tokens para o recurso de outra pessoa. E os escopos concedidos vêm da claim `scope` do ID-JAG (uma asserção sem ela também é recusada); o seu talvez mapeie os grupos do usuário em vez disso. + +E repare no que o `OAuthToken` retornado não carrega: um refresh token. O IdP decide por quanto tempo este usuário mantém o acesso ao decidir se emite o próximo ID-JAG. Um refresh token emitido aqui devolveria essa decisão sem alarde. + +!!! info + Um servidor que ainda embute seu servidor de autorização com `auth_server_provider=` chega ao + mesmo código por meio de `AuthSettings(identity_assertion_enabled=True)`. **[Autorização](../run/authorization.md)** explica + por que servidores novos não deveriam começar por aí. + +!!! check + Conecte os dois arquivos desta página e o grant inteiro é um único `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"} + ``` + + Sem `/authorize`, sem `/register`, sem busca de protected-resource metadata. As únicas + requisições na rede são a que provocou o `401`, a busca do well-known, esta troca e, depois, + tráfego MCP comum com o bearer anexado. E o `sub` que o seu validador leu do ID-JAG é + exatamente o que `get_access_token().subject` informa dentro de uma ferramenta. + +### Experimente {#try-it} + +`examples/stories/identity_assertion/` no repositório do SDK é esta página rodando de verdade: o mesmo validador `exchange_identity_assertion`, um servidor MCP protegido pelos tokens dele, um IdP substituto e o cliente, em um único programa que se autoverifica. `uv run python -m stories.identity_assertion.client --http` executa a troca inteira e confirma com assert que o usuário que o IdP nomeou é o usuário que a ferramenta vê. + +## Recapitulando {#recap} + +* A [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) deixa o provedor de identidade corporativo, e não o usuário final, decidir quais servidores MCP um cliente pode acessar. O IdP assina essa decisão em um **ID-JAG**. +* Obter o ID-JAG é um token exchange da [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) contra *o seu IdP*, e o SDK não o faz. Apresentá-lo ao servidor de autorização MCP é o grant `jwt-bearer` da [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523), e o SDK faz os dois lados disso. +* `IdentityAssertionOAuthProvider` é mais um `httpx2.Auth`: um cliente confidencial pré-registrado, um `issuer` fixado e um callback `assertion_provider(audience, resource)`. Sem navegador, sem registro, sem refresh token. +* O servidor de autorização nunca é descoberto a partir do servidor de recursos. Configure `issuer` com exatamente a string que o documento de metadados dele serve; a comparação é caractere por caractere. +* Do lado do servidor, `identity_assertion_enabled=True` mais `exchange_identity_assertion`. O SDK autentica o cliente e controla o acesso ao grant; validar o ID-JAG é inteiramente com você, e o token emitido fica vinculado ao `resource` do ID-JAG, não ao da requisição. + +A única parte que esta página nunca tocou é o servidor MCP. O que ele faz com o token que você acabou de emitir, ele já fazia em **[Autorização](../run/authorization.md)**. diff --git a/i18n/pt/pages/client/index.md b/i18n/pt/pages/client/index.md new file mode 100644 index 0000000000..4089d397fe --- /dev/null +++ b/i18n/pt/pages/client/index.md @@ -0,0 +1,217 @@ +--- +translation: + sections: [ebef1e7a0df854f4, a4c687d3d627d516, 8e79141fc2985342, b345dd05b9c3c7ab, 80ce41579825a6fa, 5f0fa90494de8f65, 83d10514eaa62fa5, 9190555aa39a5d28, 84a4c9d8bf14dddb, 927d71cf40b58c30] + tool: 1 +--- +# O cliente {#the-client} + +Um **`Client`** é como um programa Python conversa com um servidor MCP. + +É um objeto com um ciclo de vida: construa, entre no `async with`, chame os métodos. Cada verbo do protocolo (listar as ferramentas, chamar uma, ler um recurso, renderizar um prompt) é um método `async` nele que retorna um resultado tipado. + +## Seu primeiro cliente {#your-first-client} + +```python title="client.py" hl_lines="14-18" +--8<-- "docs_src/client/tutorial001.py" +``` + +O servidor no topo só está ali para você ter algo a que se conectar. O cliente são as cinco linhas destacadas. + +* `Client(mcp)` recebe o **próprio objeto servidor**. Esse é o transporte em memória: sem subprocesso, sem porta, sem HTTP. É assim que todo exemplo nesta página, e todo teste que você escrever, se conecta. +* `async with` é o **ciclo de vida**. Entrar nele conecta e negocia; sair dele desconecta. Não há um par `connect()` / `close()`, e um `Client` não pode ser reutilizado depois que o bloco termina. +* Dentro do bloco, os fatos da conexão já estão ali como propriedades comuns. + +### O que você pode passar para `Client` {#what-you-can-pass-to-client} + +`Client` recebe um argumento posicional e resolve o transporte a partir do tipo dele: + +* Uma instância de `MCPServer` (ou do `Server` de baixo nível): conectada **no mesmo processo**. +* Uma string de URL (`Client("http://localhost:8000/mcp")`): Streamable HTTP, o caminho de produção. +* Um **transporte**: qualquer coisa com que você possa fazer `async with ... as (read, write)`, como `stdio_client(...)` encapsulando um subprocesso. + +Todo o resto desta página é idêntico entre os três. Cabeçalhos, subprocessos, timeouts e o protocolo `Transport` têm sua própria página: **[Transportes do cliente](transports.md)**. + +### O que há em um cliente conectado {#whats-on-a-connected-client} + +Quatro propriedades somente leitura, preenchidas no instante em que você entra no bloco: + +* `client.server_info`: a identidade do servidor, ou `None` para um servidor da era 2026 que não informa uma (servidores do python-sdk informam por padrão). `server_info.name` aqui é `"Bookshop"`, `server_info.version` é o que o servidor informar. +* `client.server_capabilities`: o que o servidor sabe fazer (`tools`, `resources`, `prompts`, `completions`, ...). Uma capacidade que o servidor não tem é `None`. +* `client.protocol_version`: a versão do protocolo em que os dois lados concordaram. Aqui é `"2026-07-28"`. +* `client.instructions`: a string `instructions=` do servidor, ou `None` se ele não definiu uma. + +Você nunca escolheu uma versão do protocolo. Por padrão, o `Client` sonda o servidor e recorre ao handshake clássico nos mais antigos, então um único cliente funciona contra servidores de qualquer era. Quando você precisar controlar isso, **[Versões do protocolo](../protocol-versions.md)** tem a história completa. + +!!! tip + `client.session` é a `ClientSession` subjacente, a saída de emergência de baixo nível. + Você não vai precisar dela para nada nesta página. + +## Listando ferramentas {#listing-tools} + +```python title="client.py" hl_lines="15-20" +--8<-- "docs_src/client/tutorial002.py" +``` + +`list_tools()` retorna um `ListToolsResult`; as ferramentas estão em `.tools`. Cada uma é a definição completa que um host entregaria a um modelo: + +```python +tool.name # 'search_books' +tool.title # 'Search the catalog' +tool.description # 'Search the catalog by title or author.' +``` + +e `tool.input_schema` é o JSON Schema que o servidor derivou das anotações de tipo da função: + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"default": 10, "title": "Limit", "type": "integer"} + }, + "required": ["query"], + "title": "search_booksArguments" +} +``` + +Esse schema é tudo o que uma UI precisa para renderizar um formulário de argumentos, e tudo o que um modelo precisa para produzir argumentos válidos. + +!!! tip + `title` é opcional, então uma UI que mostra ferramentas a um humano tem que escolher: o `title` se houver um, + o `name` se não. `from mcp.shared.metadata_utils import get_display_name` faz exatamente isso, + para ferramentas, recursos, templates de recurso e prompts. + +## Chamando uma ferramenta {#calling-a-tool} + +`call_tool(name, arguments)` executa a ferramenta e devolve um `CallToolResult`. + +```python title="client.py" hl_lines="26-33" +--8<-- "docs_src/client/tutorial003.py" +``` + +O `lookup_book` do servidor retorna um `Book` do Pydantic. Eis o que o cliente vê: + +```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 +``` + +Um valor de retorno, três coisas para ler. Cada uma tem um consumidor diferente. + +### `content`: o que o modelo lê {#content-what-the-model-reads} + +`content` é uma `list` de **blocos de conteúdo**, e um bloco de conteúdo é uma união: `TextContent`, `ImageContent`, `AudioContent`, `ResourceLink` ou `EmbeddedResource`. Uma ferramenta pode retornar vários, de tipos diferentes. + +É por isso que `main` faz o narrowing com `isinstance(block, TextContent)` antes de tocar em `block.text`. Repare que não há `.text` fora do `isinstance`: o verificador de tipos não permite, porque `ImageContent` tem `.data`, não `.text`. A união é honesta sobre o que uma ferramenta pode enviar a você; seu código também deve ser. + +### `structured_content`: o que sua aplicação lê {#structured_content-what-your-application-reads} + +`structured_content` é o valor de retorno da ferramenta como JSON, correspondendo ao `output_schema` declarado pela ferramenta. Sem parsing de strings, sem adivinhação. + +Quando ambos estão presentes, eles dizem a mesma coisa duas vezes de propósito: `content` é para um modelo, `structured_content` é para código. De onde vem a metade estruturada, e como controlá-la, é a página **[Saída estruturada](../servers/structured-output.md)**. + +### `is_error`: se a ferramenta falhou {#is_error-whether-the-tool-failed} + +Uma ferramenta que lança uma exceção **não** lança no seu cliente. Ela volta como um resultado comum com `is_error=True`. + +!!! check + Peça `"Solaris"` ao `lookup_book` (um título que não está no catálogo) e a função lança + `ValueError`. A chamada ainda retorna normalmente: + + ```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 + ``` + + A mensagem da exceção foi parar em `content`, onde o **modelo** pode lê-la e tentar de novo. Isso + é proposital: um erro de ferramenta faz parte da conversa, não é um crash. Sempre olhe `is_error` + antes de confiar em `structured_content`. + +!!! warning + `is_error=True` cobre mais do que o seu próprio `raise`. Peça uma ferramenta que o servidor nem tem + (`call_tool("does_not_exist", {})`) e nada lança exceção. Você recebe o mesmo formato de volta, + `is_error=True` com `Unknown tool: does_not_exist` em `content`. Um método de `Client` lança + `MCPError` apenas quando o servidor responde com um **erro** JSON-RPC em vez de um resultado, e + **[Tratando erros](../servers/handling-errors.md)** cobre quando um servidor produz cada um. + +## Recursos {#resources} + +Os verbos de recurso vêm em pares: duas formas de listar, uma forma de ler. + +```python title="client.py" hl_lines="22-31" +--8<-- "docs_src/client/tutorial004.py" +``` + +* `list_resources()` retorna os recursos **concretos**, os que têm uma URI fixa. Aqui: `['catalog://genres']`. +* `list_resource_templates()` retorna os **parametrizados**. Aqui: `['catalog://genres/{genre}']`. São duas listas diferentes porque um template não pode ser lido até você preenchê-lo. +* `read_resource(uri)` recebe uma URI `str` comum e funciona com ambos: passe `"catalog://genres/poetry"` e o servidor a casa com o template. + +`read_resource` retorna `contents`, uma lista de `TextResourceContents` ou `BlobResourceContents`. Mesma ideia do conteúdo de ferramenta: faça o narrowing com `isinstance`, depois leia `.text` (ou `.blob`). + +Um cliente também pode ser avisado quando um recurso muda. Em conexões da era 2025 isso é `subscribe_resource(uri)` / `unsubscribe_resource(uri)` - um par de métodos que o `MCPServer` não implementa, então no protocolo 2026-07-28 (onde esses verbos não existem mais) a requisição responde `-32601`, *Method not found*. O substituto de 2026 é um stream `subscriptions/listen`, que o `MCPServer` *serve* sim - `server_capabilities.resources.subscribe` é `True` ali - e consumi-lo com `client.listen(...)` é a página **[Assinaturas](subscriptions.md)** desta seção. + +## Prompts {#prompts} + +```python title="client.py" hl_lines="15-20" +--8<-- "docs_src/client/tutorial005.py" +``` + +`list_prompts()` diz o que o servidor oferece e do que cada prompt precisa: + +```python +prompt.name # 'recommend' +prompt.title # 'Recommend a book' +prompt.arguments # [PromptArgument(name='genre', required=True)] +``` + +`get_prompt(name, arguments)` o renderiza. O dict de argumentos é `str -> str`: argumentos de prompt são sempre strings. O resultado é `messages`, uma lista de `PromptMessage`, cada uma com um `role` e um bloco `content`: + +```python +message.role # 'user' +message.content # TextContent(type='text', text='Recommend one poetry book from the catalog and say why.') +``` + +Um host entrega essas mensagens direto ao modelo. A funcionalidade inteira é essa. + +## Completions {#completions} + +Um servidor com um handler de completion pode autocompletar argumentos de prompts e de templates de recurso enquanto o usuário digita. + +```python title="client.py" hl_lines="27-31" +--8<-- "docs_src/client/tutorial006.py" +``` + +* `ref` diz *qual* prompt ou template você está preenchendo: uma `PromptReference` ou uma `ResourceTemplateReference`. +* `argument` é `{"name": ..., "value": ...}`: o argumento e o que o usuário digitou até agora. + +A resposta está em `result.completion.values`. Digite `"p"` e o servidor volta com `['poetry']`. O lado do servidor, e como um handler usa os *outros* argumentos já preenchidos para refinar as sugestões, é a página **[Completions](../servers/completions.md)**. + +## Paginação {#pagination} + +Todo método `list_*` aceita um argumento nomeado `cursor=` e todo resultado carrega um `next_cursor`. Quando `next_cursor` é `None`, você tem tudo. + +```python title="client.py" hl_lines="22-30" +--8<-- "docs_src/client/tutorial007.py" +``` + +Esse loop está correto contra qualquer servidor. O `MCPServer` retorna tudo em uma página só, então `next_cursor` é `None` e o loop roda uma vez, e é por isso que a maioria do código nunca o escreve. Servidores que paginam de verdade, e as regras que os cursores obedecem, estão em **[Paginação](../advanced/pagination.md)**. + +## Em testes {#in-tests} + +`Client(mcp)`, sem processo e sem porta, já é um harness de teste para o seu servidor. + +Existe uma flag do construtor feita para isso: `Client(mcp, raise_exceptions=True)`. Ela só tem efeito em conexões em memória, e **[Testes](../get-started/testing.md)** é a página que a explica e constrói todo o padrão em torno dela. + +## Recapitulando {#recap} + +* `Client(x)` conecta em memória a um objeto servidor, via Streamable HTTP a uma string de URL, e por qualquer outra coisa via um transporte. +* `async with` é o ciclo de vida inteiro. Dentro dele, `server_capabilities` e `protocol_version` já estão preenchidos; `server_info` e `instructions` também, quando o servidor os fornece. +* `list_tools()` dá a você o `name`, `title`, `description` e `input_schema` de cada ferramenta. +* `call_tool()` retorna `content` para o modelo, `structured_content` para o seu código e `is_error`. Uma ferramenta que lança exceção é um resultado, não uma exceção. +* `content` é uma união de tipos de bloco; faça o narrowing com `isinstance` antes de ler. +* `list_resources` / `list_resource_templates` / `read_resource`, `list_prompts` / `get_prompt` e `complete` completam os verbos. +* Todo `list_*` aceita `cursor=`; itere até `next_cursor` ser `None`. + +As coisas que um servidor pode pedir ao *cliente*, e como você as responde, são os **[Callbacks do cliente](callbacks.md)**. diff --git a/i18n/pt/pages/client/oauth-clients.md b/i18n/pt/pages/client/oauth-clients.md new file mode 100644 index 0000000000..51d1824f2b --- /dev/null +++ b/i18n/pt/pages/client/oauth-clients.md @@ -0,0 +1,152 @@ +--- +translation: + sections: [c6899d3892bd9fa0, 79372cff3cc48a88, 63878d29e87c3e73, 13175843d3588af4, e7e2b9fd516f77de, 758f06399b513c1f, a05d7278487d610b] + tool: 1 +--- +# Clientes OAuth {#oauth-clients} + +Alguns servidores MCP são protegidos. Envie a eles uma requisição sem token e a resposta é `401 Unauthorized`. + +**`OAuthClientProvider`** é como você consegue o token. Ele não é um objeto MCP. É um `httpx2.Auth`, o hook padrão do httpx2 para "fazer algo em toda requisição". Você o anexa a um `httpx2.AsyncClient`, entrega esse cliente ao transporte Streamable HTTP e para de pensar no assunto. + +Esta página é o lado do cliente. Fazer o seu próprio servidor exigir um token está em **[Autorização](../run/authorization.md)**. + +## O provider {#the-provider} + +```python title="client.py" hl_lines="44-54" +--8<-- "docs_src/oauth_clients/tutorial001.py" +``` + +Você entrega quatro coisas a ele: + +* `server_url`: o endpoint MCP ao qual você está se conectando. O provider descobre todo o resto a partir dele. +* `client_metadata`: o que você digitaria no formulário "registrar uma aplicação" de um servidor de autorização. +* `storage`: onde os tokens ficam entre uma execução e outra. +* `redirect_handler` e `callback_handler`: os dois momentos em que um humano participa. + +Nada mais no arquivo menciona OAuth. `main()` nunca vê um token. + +### Metadados do cliente {#client-metadata} + +`OAuthClientMetadata` é o documento de registro real da [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591), na forma de um modelo Pydantic. + +Você define três campos. Os valores padrão preenchem o resto: `grant_types` já é `["authorization_code", "refresh_token"]` e `response_types` já é `["code"]`, que é exatamente o fluxo que este provider executa. + +!!! check + Por ser um modelo Pydantic, ele valida **antes de um único byte trafegar pela rede**. + Deixe `redirect_uris` de fora e a construção falha na hora com um `ValidationError` que + nomeia o campo: + + ```text + redirect_uris + Field required [type=missing, input_value={'client_name': 'Bookshop Agent'}, input_type=dict] + ``` + + Nenhum navegador aberto, nenhum registro pela metade deixado para trás no servidor de autorização. + +### Armazenamento de tokens {#token-storage} + +**`TokenStorage`** é um `Protocol` com quatro métodos async. Você não herda de nada; escreva os métodos e qualquer classe vira um armazenamento de tokens: + +* `get_tokens` / `set_tokens` guardam o `OAuthToken`: token de acesso, refresh token, expiração, escopo. +* `get_client_info` / `set_client_info` guardam o `OAuthClientInformationFull` que o servidor de autorização emitiu quando o provider registrou você, incluindo o seu `client_id`. + +A versão em memória acima funciona. Ela também esquece tudo quando o processo termina, então a próxima execução refaz a dança inteira. Persista em um arquivo ou no keyring da sua plataforma e a próxima execução fica silenciosa. + +!!! tip + Armazene `client_info`, não só os tokens. O provider faz o registro dinâmico na primeira vez em que + não encontra um `client_info` armazenado. Jogue-o fora e você cria um registro novo a cada execução. + +### Os dois handlers {#the-two-handlers} + +O fluxo de authorization code precisa de um humano exatamente uma vez: alguém tem que fazer login e clicar em "permitir". + +* **`redirect_handler`** recebe um await com a URL de autorização já montada. O `client_id`, a `redirect_uri`, o `state` e o desafio PKCE já estão nela. Seu único trabalho é levar um navegador até lá. Um app desktop chama `webbrowser.open`; este arquivo imprime a URL. +* **`callback_handler`** recebe o await em seguida. Ele espera até o usuário voltar para a sua `redirect_uri` e retorna os parâmetros de query desse redirecionamento como um `AuthorizationCodeResult`. + +Um cliente real executa um pequeno servidor HTTP local na URI de redirecionamento em vez de chamar `input()`. O formato é idêntico: receber o redirecionamento, devolver `code`, `state` e `iss`. + +!!! warning + Repasse `state` e `iss` exatamente como chegaram. O provider compara `state` com o que + ele gerou e `iss` com o issuer que descobriu, e recusa qualquer divergência. Eles são as defesas + contra CSRF e contra confusão de servidor (server mix-up). + +### Para dentro do `Client` {#into-the-client} + +Veja `main()`. O provider vai no **cliente httpx2**, o cliente httpx2 vai em `streamable_http_client(url, http_client=...)`, e esse transporte vai em `Client`. + +`streamable_http_client` não tem o parâmetro nomeado `auth=`. Tudo que é de nível HTTP (auth, cabeçalhos, timeouts, proxies) pertence ao `httpx2.AsyncClient` que você traz. Essa divisão em camadas está em **[Transportes do cliente](transports.md)**. + +## O que o provider faz por você {#what-the-provider-does-for-you} + +Na primeira vez que `Client` envia uma requisição, o servidor responde `401`. O provider assume: + +1. **Descoberta.** Ele lê o cabeçalho `WWW-Authenticate`, busca os Protected Resource Metadata do servidor em `/.well-known/oauth-protected-resource`, descobre qual servidor de autorização protege este recurso e busca os metadados *desse* servidor. +2. **Registro.** Nada no armazenamento? Ele registra você dinamicamente com o seu `OAuthClientMetadata` e armazena o resultado. +3. **Autorização.** Ele gera o par PKCE e um `state`, monta a URL de autorização, faz await no seu `redirect_handler` e depois faz await no seu `callback_handler` para obter o code. +4. **Troca.** Ele troca o code por um `OAuthToken`, armazena e reenvia a sua requisição original com `Authorization: Bearer ...`. + +Depois disso ele fica quieto. Os tokens saem do armazenamento, um token de acesso expirado é renovado com o refresh token, e só quando nada disso funciona ele executa o fluxo de novo. + +Você não escreveu nada disso. Restam dois argumentos nomeados (`client_metadata_url` e `validate_resource_url`), e este arquivo não precisa de nenhum dos dois. `client_metadata_url` é o que vale a pena conhecer; ele ganha uma seção própria abaixo. + +### Experimente {#try-it} + +A maioria dos exemplos nesta documentação você consegue conferir com um `Client(server)` em memória. Este não: o ponto central do fluxo é um `401` HTTP, e não há HTTP entre um cliente em memória e o seu servidor. + +O repositório traz a versão ao vivo. `examples/servers/simple-auth/` executa um servidor de autorização independente e um servidor MCP protegido; `examples/clients/simple-auth-client/` é o cliente desta página crescido até virar uma pequena CLI. O README dele tem os dois comandos: inicie os servidores, execute o cliente contra eles e veja as quatro etapas passarem. + +## Client ID Metadata Documents {#client-id-metadata-documents} + +A revisão 2026-07-28 da especificação torna obsoleto o registro dinâmico de clientes em favor dos **Client ID Metadata Documents** (CIMD). Em vez de fazer POST de um registro novo em cada servidor de autorização que encontra, o seu cliente publica um único documento JSON sobre si mesmo em uma URL HTTPS estável, e essa URL *é* o `client_id` dele. O servidor de autorização busca o documento; o provider nunca toca nele. + +O SDK já fala isso: passe a URL como `client_metadata_url=` ao construir o provider. Quando os metadados do servidor de autorização anunciam `client_id_metadata_document_supported: true`, o provider pula completamente a requisição a `/register`: a URL entra no fluxo como `client_id`, e não há `client_secret`. Quando o servidor não anuncia isso (a maioria ainda não anuncia), ou você nunca passa uma URL, o provider recorre ao registro dinâmico **silenciosamente**, e tudo acima funciona exatamente como descrito. Um `client_info` armazenado ainda prevalece sobre ambos. + +A URL precisa ser HTTPS com um caminho que não seja a raiz; qualquer outra coisa é um `ValueError` na construção, antes de qualquer tráfego de rede. O `examples/clients/simple-auth-client/` do repositório recebe a URL pela variável de ambiente `MCP_CLIENT_METADATA_URL`. + +## Máquina para máquina {#machine-to-machine} + +Um job noturno, uma etapa de CI, outro serviço. Não há navegador nem ninguém para clicar em "permitir". Esse é o grant **client credentials**: você já possui um `client_id` e um `client_secret`, e o endpoint de token é o fluxo inteiro. + +`ClientCredentialsOAuthProvider` é o mesmo `httpx2.Auth`, sem o humano: + +```python title="client.py" hl_lines="4 27-33" +--8<-- "docs_src/oauth_clients/tutorial002.py" +``` + +O que mudou: + +* Sem `OAuthClientMetadata`, sem handlers. Você passa `client_id` e `client_secret`; o provider monta um registro `client_credentials` mínimo em torno deles e pula o registro dinâmico por completo. +* `scope` é uma string separada por espaços, o formato OAuth usado na comunicação. +* Tudo a partir daí é idêntico: o mesmo `TokenStorage`, o mesmo `httpx2.AsyncClient(auth=...)`, o mesmo `streamable_http_client`. + +Por padrão, o secret viaja como HTTP Basic auth na requisição de token (`client_secret_basic`). Passe `token_endpoint_auth_method="client_secret_post"` para colocá-lo no corpo do formulário. Alguns servidores de autorização só aceitam um dos dois. + +!!! tip + Leia `client_secret` do ambiente ou de um gerenciador de segredos, nunca do controle de versão. + +!!! info + Mais um provider mora em `mcp.client.auth.extensions.client_credentials`: + **`PrivateKeyJWTOAuthProvider`**, para clientes que se autenticam com um JWT em vez de um + segredo compartilhado (`private_key_jwt`, a variante de par de chaves e workload identity). Ele segue + o mesmo padrão: construa um, coloque em `auth=`. O mesmo módulo traz + `SignedJWTParameters` e `static_assertion_provider`, dois helpers que montam a assertion dele. + +Há mais uma situação sem humano: o cliente pertence a uma empresa cujo provedor de identidade, e não o usuário, decide quais servidores MCP ele pode alcançar. Esse é um grant diferente, com seu próprio modelo de confiança e sua própria página, **[Asserção de identidade](identity-assertion.md)**. + +## Quando falha {#when-it-fails} + +Quando o fluxo OAuth dá errado, o provider levanta um `OAuthFlowError` de `mcp.client.auth`. Ele tem duas subclasses. `OAuthRegistrationError` significa que o registro não rendeu um cliente que você possa usar: o servidor de autorização se recusou a registrar você, ou até registrou, mas com credenciais que este fluxo não consegue usar (por exemplo, um método de autenticação que ele não implementa). `OAuthTokenError` significa que não foi possível obter um token: o endpoint de token disse não, ou um registro de cliente armazenado carrega um método de autenticação que este cliente não consegue aplicar, o que é reportado durante a montagem da requisição de token em vez de ser enviado. Um único `except OAuthFlowError:` cobre descoberta, registro, autorização e troca. + +Nem tudo é erro de fluxo. A rede ainda pode falhar; essas são exceções comuns do `httpx2` e passam intactas. + +## Recapitulando {#recap} + +* `OAuthClientProvider` é um `httpx2.Auth`. Coloque-o em um `httpx2.AsyncClient`, passe esse cliente para `streamable_http_client(url, http_client=...)`, e `Client` nunca fica sabendo que houve OAuth. +* Você fornece quatro coisas: a URL do servidor, um `OAuthClientMetadata`, um `TokenStorage` e o par de handlers redirect/callback. +* `TokenStorage` é um `Protocol`: quatro métodos async, sem classe base. Persista `client_info` além dos tokens. +* Descoberta, registro (dinâmico ou via um **Client ID Metadata Document**), PKCE, as verificações de `state` e `iss` e a renovação de tokens são trabalho do provider, não seu. +* `ClientCredentialsOAuthProvider` é a versão sem humano: `client_id` + `client_secret`, sem handlers, sem navegador. +* Toda falha OAuth é um `OAuthFlowError`; `OAuthRegistrationError` e `OAuthTokenError` são suas subclasses. + +A outra metade desse handshake, fazer o seu *servidor* exigir o token, está em **[Autorização](../run/authorization.md)**. diff --git a/i18n/pt/pages/client/session-groups.md b/i18n/pt/pages/client/session-groups.md new file mode 100644 index 0000000000..7f42531d44 --- /dev/null +++ b/i18n/pt/pages/client/session-groups.md @@ -0,0 +1,87 @@ +--- +translation: + sections: [09c857a25a9dc37a, 43bc6a76a243a50e, 0a716022a88768df, 4b7f78042bfcfff7, c112662e61b03315, 58974ba1f489a8b4, d18adbdbb835ea73] + tool: 1 +--- +# Grupos de sessões {#session-groups} + +Um `Client` se conecta a um servidor. Aplicações reais frequentemente querem vários (um servidor de busca, um servidor de banco de dados, uma API interna) e acabam fazendo malabarismo com uma conexão e uma lista de ferramentas (tools) para cada um. + +**`ClientSessionGroup`** é um único objeto que mantém várias conexões e reúne tudo o que elas expõem em uma única visão. + +## Dois servidores {#two-servers} + +Comece com dois servidores comuns. Eles não têm nada a ver um com o outro, então ambos naturalmente chamaram sua ferramenta de `search`: + +```python title="library_server.py" hl_lines="7" +--8<-- "docs_src/session_groups/tutorial001.py" +``` + +```python title="web_server.py" hl_lines="7" +--8<-- "docs_src/session_groups/tutorial002.py" +``` + +## Um grupo {#one-group} + +Crie um `ClientSessionGroup` e chame **`connect_to_server`** uma vez por servidor: + +```python title="client.py" hl_lines="10-12" +--8<-- "docs_src/session_groups/tutorial003.py" +``` + +* `connect_to_server` recebe parâmetros de transporte, não um objeto de servidor: `StdioServerParameters` (de `mcp`) para iniciar um subprocesso, ou `StreamableHttpParameters` / `SseServerParameters` (de `mcp.client.session_group`) para um servidor que já está escutando em uma URL. +* `group.tools` é um `dict[str, Tool]` com as ferramentas de todos os servidores conectados. `group.resources` e `group.prompts` têm o mesmo formato. +* `group.call_tool(name, arguments)` procura o nome, encontra a sessão dona dele e encaminha a chamada. Você nunca diz qual servidor. + +!!! check + Coloque `client.py` ao lado dos dois servidores e execute. O segundo `connect_to_server` recusa: + + ```text + mcp.shared.exceptions.MCPError: {'search'} already exist in group tools. + ``` + + Isso é um `MCPError`, lançado antes que qualquer coisa do segundo servidor seja registrada. Um nome precisa + ser único no grupo **inteiro**, e dois servidores que você não controla vão colidir mais cedo ou mais tarde. + +## `component_name_hook` {#component_name_hook} + +Você resolve isso no grupo, não nos servidores. Passe uma função de `(name, server_info)` e o grupo a executa em cada nome que registra: + +```python title="client.py" hl_lines="7-8 15" +--8<-- "docs_src/session_groups/tutorial004.py" +``` + +Execute de novo. `print(sorted(group.tools))` agora mostra as duas: + +```text +['Library.search', 'Web.search'] +``` + +* A **chave** é sua. `by_server` a montou a partir de `server_info.name`, o nome com que cada `MCPServer(...)` foi construído. +* O `Tool` dentro fica intacto: `group.tools["Web.search"].name` ainda é `"search"`, e esse é o nome que `call_tool` coloca na rede. O prefixo nunca sai do seu processo. +* Não são só ferramentas. O recurso `hours` da biblioteca é registrado como `Library.hours`. + +!!! tip + O hook é executado em **cada** nome de **cada** servidor, não só nos conflitos: não existe um + modo de prefixar apenas em caso de colisão. Escolha um esquema e deixe que ele valha em todo lugar. + +## Adicionando e removendo servidores {#adding-and-removing-servers} + +`connect_to_server` retorna a `ClientSession` que abriu. Guarde-a se algum dia quiser tirar aquele servidor: `await group.disconnect_from_server(session)` remove do grupo as ferramentas, recursos e prompts dele. + +Se você já tem em mãos uma `ClientSession` conectada (`Client.session` é uma), entregue-a a `await group.connect_with_session(server_info, session)` em vez de abrir um novo transporte. Ela é agregada da mesma forma. O grupo nunca fecha uma sessão que não abriu. `server_info` nomeia o servidor para os prefixos dos componentes; em uma conexão da era 2026, `client.server_info` pode ser `None` (a identidade é opcional), então nesse caso passe sua própria `Implementation(name=..., version=...)`. + +## O handshake clássico {#the-classic-handshake} + +`ClientSessionGroup` é construído sobre `ClientSession`, não sobre `Client`. Cada `connect_to_server` executa o handshake clássico `initialize`. Ele nunca envia a sondagem `server/discover` descrita em **[Versões do protocolo](../protocol-versions.md)**. Todo servidor MCP entende esse handshake, então isso não custa compatibilidade com nada; significa apenas que um grupo segue o caminho mais antigo e mais lento até um servidor que poderia fazer melhor. + +## Recapitulando {#recap} + +* `ClientSessionGroup` mantém várias conexões de servidor e reúne as ferramentas, recursos e prompts delas em um `dict` para cada tipo. +* `connect_to_server(params)` por servidor. Ele recebe parâmetros de transporte, nunca o objeto de servidor ou a URL que um `Client` recebe. +* `group.call_tool(name, arguments)` roteia para o servidor dono por você. +* Os nomes precisam ser únicos no grupo inteiro; dois servidores com uma ferramenta `search` não conseguem coexistir por conta própria. +* `component_name_hook=` reescreve cada nome registrado. A chave do dict muda, o nome na rede não. +* `connect_with_session` adiciona uma sessão que você já tem; `disconnect_from_server` remove uma. + +O handshake que um grupo fala (e o mais rápido que um `Client` prefere) é o assunto de **[Versões do protocolo](../protocol-versions.md)**. diff --git a/i18n/pt/pages/client/subscriptions.md b/i18n/pt/pages/client/subscriptions.md new file mode 100644 index 0000000000..9682ea3bae --- /dev/null +++ b/i18n/pt/pages/client/subscriptions.md @@ -0,0 +1,91 @@ +--- +translation: + sections: [8f9558e57f29eee1, a88c587739e0465c, 46ebfd5b325ed041, 4d10b00b57ce4bd9, 2cdb0edd1f59b3e2] + tool: 1 +--- +# Assinaturas {#subscriptions} + +O catálogo de um servidor não é fixo. Ferramentas (tools) aparecem em tempo de execução, e o conteúdo por trás da URI de um recurso muda. Um cliente fica sabendo disso por meio de `client.listen(...)`: uma única requisição `subscriptions/listen` cuja resposta *é* o stream. Ele fica aberto e carrega as notificações de mudança que o cliente pediu. + +Esta página é a ponta do cliente: abrir o stream, observá-lo ao lado do seu fluxo principal e lidar com seus encerramentos. Publicar mudanças, filtrar e servir o método são o lado do servidor dessa história, contado em **[Assinaturas](../handlers/subscriptions.md)**, em *Dentro do seu handler*. Os exemplos aqui conversam com o servidor de quadro de sprint construído lá. + +## Observando o stream {#watching-the-stream} + +Uma assinatura é um único gerenciador de contexto. Entrar nele envia a requisição, com seus argumentos nomeados como filtro da assinatura, e espera a confirmação do servidor, então o stream já está ativo quando o bloco começa. + +```python title="client.py" hl_lines="15 18 28" +--8<-- "docs_src/subscriptions/tutorial003.py" +``` + +A iteração produz quatro eventos tipados: `ToolsListChanged`, `PromptsListChanged`, `ResourcesListChanged` e `ResourceUpdated(uri=...)`. + +Um evento diz *o que* mudou, nunca *como*. É por isso que `follow_board` chama `read_resource` e `list_tools`: o evento é uma deixa para buscar de novo. Leia `event.uri` em vez de presumir qual recurso mudou: um filtro pode nomear várias URIs, e um servidor pode reportar uma mudança em um sub-recurso de uma delas. + +Eventos duplicados esperando para serem consumidos se fundem em um só, e buscar de novo ainda traz o estado atual para você. Só eventos idênticos se fundem: dois `ResourceUpdated` para URIs diferentes são dois eventos. + +Mais duas propriedades do handle: + +* `sub.honored` é o filtro que o servidor confirmou: um `SubscriptionFilter` com os campos que você passou, lidos como atributos (`sub.honored.prompts_list_changed`). O `MCPServer` honra todo tipo que você pede, então ele devolve sua requisição como eco. Um servidor que suporta menos tipos confirma menos, e um tipo honrado ainda pode nunca disparar. Um servidor também pode recusar a requisição inteira em vez de confirmá-la (veja [Decidindo quem pode observar](../handlers/subscriptions.md#deciding-who-may-watch) na página do servidor), o que aparece como o erro da requisição. +* `sub.subscription_id` é o id da requisição listen, aquele carimbado em cada frame deste stream. Várias assinaturas podem estar abertas ao mesmo tempo, cada uma demultiplexada pelo seu próprio id. + +## Observando sem bloquear {#watching-without-blocking} + +`follow_board` roda até o servidor fechar o stream, o que pode ser nunca, então sozinha ela toma conta do seu programa. Clientes reais querem o observador *ao lado* do fluxo principal: um agente chama ferramentas enquanto um observador mantém um cache ou uma UI atualizados. + +Abra a assinatura primeiro, depois inicie o observador e siga com o seu trabalho. + +=== "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` importa `BOARD` e `read_board` do primeiro exemplo, que este repositório guarda como + `tutorial003.py`. Se você salvar os arquivos renderizados lado a lado como `client.py` e `app.py`, + escreva `from client import BOARD, read_board` no lugar. O exemplo `watch.py` mais abaixo + importa `read_board` do mesmo jeito. + +A ordem é o ponto. Nada é reenviado, então um evento publicado antes de o seu stream existir se perde. Entrar em `client.listen(...)` espera a confirmação, então toda mudança daquele momento em diante chega ao seu observador, e o snapshot que você tira dentro do bloco não tem como perder nenhuma. + +Requisições rodam livremente ao lado de um stream aberto, a partir da tarefa do observador ou de qualquer outra, no mesmo cliente. Como eventos *duplicados* não consumidos se fundem, um fluxo principal movimentado pode produzir uma nova busca em vez de três. Eventos diferentes não se fundem: um filtro que nomeia muitas URIs enfileira um evento pendente por URI. + +Para parar de observar, saia do bloco: não existe chamada `unsubscribe`. Cancelar a tarefa que é dona do bloco faz isso por você, e o SDK cancela a requisição listen do jeito que o transporte espera: sobre Streamable HTTP, fechando o stream daquela requisição. Um observador que roda durante toda a vida do seu app nunca retorna sozinho, então cancele-o, ou o escopo do seu task group, no encerramento. + +## Streams terminam {#streams-end} + +Um stream termina de uma de duas maneiras, ambas fluxo de controle comum. Um fechamento gracioso do servidor encerra o `async for`; uma queda abrupta levanta `SubscriptionLost`. + +A diferença é de diagnóstico, não uma diferença no que fazer a seguir: o stream se foi, nada foi reenviado, e um observador que ainda se importa escuta de novo e busca de novo. + +```python title="watch.py" hl_lines="16 20" +--8<-- "docs_src/subscriptions/tutorial005.py" +``` + +Servidores fecham streams graciosamente por razões próprias, inclusive para se livrar de um assinante cujo backlog cresceu demais, então um fim limpo não é sinal para parar de observar. Espere um pouco (back off) antes de escutar de novo. + +`SubscriptionLost` também tem uma causa local. O cliente guarda no máximo 1024 eventos não consumidos, e um consumidor que fica tão para trás assim perde a assinatura em vez de crescer sem limite. Mantenha o corpo do `async for` curto e faça o trabalho lento em outro lugar. + +`keep_following` captura apenas `SubscriptionLost`. Entrar em `listen()` também pode levantar `MCPError` (a conexão falhou, ou o servidor não serve o método), `TimeoutError` (nenhuma confirmação chegou) e `ListenNotSupportedError` (uma conexão pré-2026). Decida quais desses o seu observador deve tentar de novo: o último nunca se resolve. + +## Recapitulando {#recap} + +* Entre em `async with client.listen(...)`; a entrada espera a confirmação, então nada publicado depois dela se perde. +* Itere com `async for event in sub`. Eventos são deixas para buscar de novo, nunca payloads. +* Abra a assinatura, depois rode o observador como uma tarefa, e as chamadas de ferramentas continuam fluindo ao lado dele. +* Um fim limpo para o loop; uma queda levanta `SubscriptionLost`. De qualquer forma: escute de novo, busque de novo, espere um pouco antes. +* Sair do bloco é o unsubscribe. + +Publicar esses eventos, estreitar o filtro e escalar além de um processo são a história do servidor: **[Assinaturas](../handlers/subscriptions.md)**. Esses mesmos eventos também mantêm um cache do lado do cliente honesto, e **[Cache](caching.md)** é a próxima página. diff --git a/i18n/pt/pages/client/transports.md b/i18n/pt/pages/client/transports.md new file mode 100644 index 0000000000..877eafd303 --- /dev/null +++ b/i18n/pt/pages/client/transports.md @@ -0,0 +1,128 @@ +--- +translation: + sections: [9cac816674181eb0, 0700f337babcd4dd, 2bde0dd58cdf00f5, ff7401df479af877, 3d0832f39b0d7059, d4bf7e4479637768, 05e20c0a798860e7] + tool: 1 +--- +# Transportes do cliente {#client-transports} + +Todo `Client` conversa com seu servidor por meio de um **transporte**: aquilo que de fato carrega as mensagens. + +Você nunca configura um transporte separadamente. `Client` recebe um único argumento posicional e deduz o transporte a partir do tipo dele. + +O lado do *servidor* de cada um (o que `mcp.run()` faz e o que você coloca no deploy) está em **[Executando seu servidor](../run/index.md)**. + +## Em memória {#in-memory} + +Passe o próprio objeto do servidor: + +```python title="client.py" hl_lines="14" +--8<-- "docs_src/client_transports/tutorial001.py" +``` + +Sem subprocesso, sem porta, sem bytes trafegando na rede. O cliente e o servidor são dois objetos no mesmo processo, e a chamada ainda passa pela camada real do protocolo: `search_books` é listada, validada e invocada exatamente como seria sobre HTTP. + +Isso faz dele duas coisas ao mesmo tempo: + +* **Uma estrutura de testes.** Todo exemplo desta documentação é exercitado dessa forma, e a página **[Testes](../get-started/testing.md)** constrói o padrão inteiro em torno disso. +* **Uma API de embutimento.** Uma aplicação que constrói o servidor não precisa de um salto pela rede para chamar as ferramentas dele. + +## Streamable HTTP {#streamable-http} + +Passe uma string de URL e você tem **Streamable HTTP**, o transporte atrás do qual você faz o deploy: + +```python title="client.py" hl_lines="5" +--8<-- "docs_src/client_transports/tutorial002.py" +``` + +Esse é o cliente de produção inteiro. `Client` envolve a URL em `streamable_http_client(...)` para você, sobre um `httpx2.AsyncClient` configurado do jeito que o MCP precisa: `follow_redirects=True`, um timeout de 30 segundos para connect/write/pool e um timeout de leitura de 300 segundos, porque o servidor pode manter um stream de resposta aberto. + +!!! check + Um `Client` que você construiu **não** está conectado. A construção só escolhe o transporte; + é o `async with` que o abre. Tente usar a conexão antes de entrar e o SDK avisa: + + ```text + RuntimeError: Client must be used within an async context manager + ``` + + Nada foi resolvido, buscado ou iniciado quando você escreveu `Client("http://...")`. Essa linha não custa nada. + +### Traga seu próprio `httpx2.AsyncClient` {#bring-your-own-httpx2asyncclient} + +No momento em que você precisar de um header `Authorization`, um cookie, um proxy, mTLS ou um timeout diferente, construa o `httpx2.AsyncClient` você mesmo e entregue-o a `streamable_http_client`: + +```python title="client.py" hl_lines="8-14" +--8<-- "docs_src/client_transports/tutorial003.py" +``` + +Duas coisas para notar: + +* Você é o dono do `httpx2.AsyncClient`, então é **você** quem entra e sai dele. O SDK nunca fecha um cliente que não criou. +* `streamable_http_client(url, http_client=...)` retorna um transporte, e `Client(transport)` o aceita como qualquer outra coisa. + +Uma observação sobre TLS: `httpx2` verifica certificados contra o repositório de confiança do sistema operacional (via +[`truststore`](https://pypi.org/project/truststore/)), não contra uma lista de CAs embutida. Em um ambiente +sem um repositório de CAs do sistema utilizável (alguns contêineres mínimos), defina as variáveis de ambiente padrão +`SSL_CERT_FILE`/`SSL_CERT_DIR` ou passe um `verify=ssl_context` explícito ao seu `httpx2.AsyncClient` +(contexto em +[`httpx` e `httpx-sse` substituídos por `httpx2`](../migration.md#httpx-and-httpx-sse-replaced-by-httpx2)). + +!!! warning + `streamable_http_client` costumava aceitar `headers=` e `timeout=` diretamente. Não aceita mais: + seus únicos parâmetros são `url`, `http_client` e `terminate_on_close`. Use `headers=` por + hábito e você recebe: + + ```text + TypeError: streamable_http_client() got an unexpected keyword argument 'headers' + ``` + + Tudo que tem cara de HTTP agora vive no único `httpx2.AsyncClient` que você passa. + +!!! info + `httpx2` mantém a API conhecida do `httpx`, então se você conhece `httpx` já sabe como fazer auth, + proxies, event hooks, retentativas e limites de conexão aqui. O SDK não acrescenta nada por cima nem + tira nada. É também onde o OAuth se encaixa: + `httpx2.AsyncClient(auth=OAuthClientProvider(...))`. Esse fluxo inteiro está em **[Clientes OAuth](oauth-clients.md)**. + +## stdio {#stdio} + +Um servidor **stdio** é um subprocesso. O cliente o inicia, escreve JSON-RPC no stdin dele e lê JSON-RPC do stdout dele. É assim que um host de desktop executa um servidor na sua máquina: um host *é* este código mais uma interface, e **[Conecte a um host real](../get-started/real-host.md)** é a mesma relação vista do lado do host, como um arquivo de configuração. + +Descreva o processo com `StdioServerParameters`, transforme-o em um transporte com `stdio_client` e entregue *isso* ao `Client`: + +```python title="client.py" hl_lines="4-8 12" +--8<-- "docs_src/client_transports/tutorial004.py" +``` + +`Client` não aceita o objeto de parâmetros sozinho. `StdioServerParameters` é configuração; `stdio_client(server)` é o transporte que sabe como iniciar um processo a partir dela. Sempre envolva. + +Sair do bloco `async with` também encerra o subprocesso: fecha o stdin, espera e mata o processo se ele demorar. Você nunca limpa isso por conta própria. + +!!! warning + O processo filho **não** herda o seu ambiente. Ele recebe uma allow-list mínima (`HOME`, `LOGNAME`, + `PATH`, `SHELL`, `TERM` e `USER` no POSIX), para que nada sensível vaze para um processo que talvez + não tenha sido escrito por você. + + Um servidor que precise de uma chave de API não vai encontrá-la ali. Passe-a explicitamente com `env=`; essas + variáveis são mescladas por cima da allow-list. É isso que `BOOKSHOP_API_KEY` está fazendo acima. + +## SSE {#sse} + +`sse_client(url)`, de `mcp.client.sse`, é o transporte HTTP que o Streamable HTTP substituiu. Envolva-o da mesma forma, `Client(sse_client("http://localhost:8000/sse"))`, para conversar com um servidor que ainda o fala, e não construa nada novo em cima dele. + +## O protocolo `Transport` {#the-transport-protocol} + +Para o `Client`, tudo acima é a mesma coisa. + +Um **transporte** é qualquer gerenciador de contexto assíncrono que produz um par `(read, write)` de streams de mensagens: formalmente, o protocolo `Transport` em `mcp.client`. `Client` resolve seu argumento pelo tipo: um objeto de servidor conecta no próprio processo, uma `str` vira `streamable_http_client(url)` e qualquer outra coisa é aberta diretamente como transporte. É por causa dessa última regra que `stdio_client(...)`, `streamable_http_client(...)` e `sse_client(...)` se encaixam todos no mesmo lugar, e que você pode escrever o seu próprio. + +## Recapitulando {#recap} + +* `Client(mcp)` (o objeto do servidor) conecta em memória. Use para testes e para embutir. +* `Client("http://.../mcp")` (uma URL) conecta por Streamable HTTP, o transporte de produção. +* Headers, auth, proxies e timeouts pertencem a um `httpx2.AsyncClient` que você passa a `streamable_http_client(url, http_client=...)`. Não existe o argumento `headers=`. +* stdio é `Client(stdio_client(StdioServerParameters(...)))`, nunca o objeto de parâmetros sozinho. +* O subprocesso recebe um ambiente em allow-list, não o seu; `env=` acrescenta a ele. +* Um transporte é qualquer coisa com que você possa fazer `async with x as (read, write)`. `Client` entrega direto a esse protocolo tudo que não for um objeto de servidor ou uma URL. +* Construir um `Client` escolhe o transporte. `async with` o abre. + +Depois que o transporte está aberto, os dois lados precisam concordar sobre uma versão do protocolo. Normalmente você nunca pensa nisso; quando pensar, **[Versões do protocolo](../protocol-versions.md)** é a página. diff --git a/i18n/pt/pages/deprecated.md b/i18n/pt/pages/deprecated.md new file mode 100644 index 0000000000..446da146d2 --- /dev/null +++ b/i18n/pt/pages/deprecated.md @@ -0,0 +1,96 @@ +--- +translation: + sections: [20541a40dbdd5980, 01262a123ad9501d, 429db5b574a2ac08, 56b2d49da412cb28, 6a1717123fe4513c] + tool: 1 +--- +# Funcionalidades descontinuadas {#deprecated-features} + +A especificação 2026-07-28 aposenta cinco coisas. O SDK ainda implementa cada uma delas, e cada uma agora carrega um **aviso de descontinuação**. + +A tabela abaixo nomeia cada funcionalidade descontinuada, o motivo de ela estar saindo e o substituto sobre o qual construir. + +## O que está descontinuado {#what-is-deprecated} + +| Descontinuado | Por quê | O que fazer no lugar | +|---|---|---| +| **Roots**: `ctx.session.list_roots()`, `client.send_roots_list_changed()`, o `list_roots_callback=` que você passa para `Client(...)` | A [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) aposenta a capacidade. | Receba os caminhos como argumentos comuns de ferramenta ou URIs de recurso, ou embuta um `ListRootsRequest` em um `InputRequiredResult` (veja **[Requisições de múltiplas idas e voltas](handlers/multi-round-trip.md)**). | +| **Amostragem (sampling) iniciada pelo servidor**: `ctx.session.create_message()`, o `sampling_callback=` que você passa para `Client(...)` | A SEP-2577 aposenta a capacidade. | Retorne `InputRequiredResult` e deixe o cliente repetir a chamada (veja **[Requisições de múltiplas idas e voltas](handlers/multi-round-trip.md)**). | +| **Logging de protocolo**: `ctx.log()`, `ctx.debug()`, `ctx.info()`, `ctx.warning()`, `ctx.error()`, `ctx.session.send_log_message()`, `client.set_logging_level()` | A SEP-2577 aposenta a capacidade. Nada dentro do protocolo a substitui. | O `import logging` comum para stderr (veja **[Logging](handlers/logging.md)**). | +| **`ping`**: `client.send_ping()` | **Removido** do protocolo, não apenas descontinuado. Não existe método `ping` em 2026-07-28. | Nada. Só funciona em uma conexão `mode="legacy"`. | +| **Progresso cliente->servidor**: `client.send_progress_notification()` | A 2026-07-28 torna o progresso exclusivamente servidor->cliente. | Nada a enviar. O seu *servidor* informa progresso com `ctx.report_progress()` (veja **[Progresso](handlers/progress.md)**). | + +Três coisas saem dessa tabela: + +* Roots, amostragem e logging andam juntos. Uma única proposta, a **SEP-2577**, descontinua as três capacidades de uma vez. +* Amostragem e roots compartilham um problema mais profundo: são pontos em que um **servidor** envia uma **requisição** ao **cliente**. Essa direção inteira é o que a 2026-07-28 substitui por **[Requisições de múltiplas idas e voltas](handlers/multi-round-trip.md)**. O que desaparece são os métodos RPC independentes (`sampling/createMessage`, `roots/list` e o `elicitation/create` no estilo push); os tipos de payload `CreateMessageRequest` / `ListRootsRequest` / `ElicitRequest` sobrevivem, embutidos em `InputRequiredResult.input_requests`, e no cliente chegam aos mesmos callbacks. +* `ping` é o diferente do grupo. O protocolo não o descontinua, ele o remove. O método do SDK ainda emite o aviso (a mensagem diz *removed*, não *deprecated*) e chamá-lo em uma conexão moderna responde com *"Method not found"*. + +## Descontinuado é consultivo {#deprecated-is-advisory} + +Nada quebra hoje. + +Cada método acima continua funcionando em qualquer sessão que tenha negociado **2025-11-25 ou anterior**. Fixe `mode="legacy"` no cliente e você obtém exatamente o comportamento pré-2026. Não há mudanças no protocolo de transmissão e a negociação de capacidades segue igual. + +O que muda é que você recebe um aviso visível na primeira vez que cada um é executado: + +```text +MCPDeprecationWarning: The logging capability is deprecated as of 2026-07-28 (SEP-2577). +``` + +`MCPDeprecationWarning` é subclasse de `UserWarning`, **não** de `DeprecationWarning`. Isso é proposital: o filtro padrão do Python só mostra `DeprecationWarning` em código executado diretamente como `__main__`, e é assim que bibliotecas descontinuam coisas sem ninguém perceber por dois anos. Este aparece em todo lugar, sem nenhuma flag `-W`. + +!!! warning + "Consultivo" termina no nível do protocolo de transmissão. Amostragem e roots são + *requisições* do servidor para o cliente, e uma sessão 2026-07-28 não tem canal para + carregar uma. Chame `ctx.session.create_message()` dentro de uma ferramenta em uma + conexão moderna e o aviso ainda dispara, e então o envio falha com um erro: + + ```text + Cannot send 'sampling/createMessage': this transport context has no back-channel + for server-initiated requests. + ``` + + Dois sinais, nessa ordem. O `MCPDeprecationWarning` dispara no momento em que você + chama o método, em qualquer conexão. O erro é o que volta quando o SDK tenta enviar + em seguida. Esses dois só funcionam de ponta a ponta em uma conexão `mode="legacy"` + cujo cliente registrou o callback correspondente. + +## Silenciando o aviso {#silencing-the-warning} + +Não faça isso, em código novo. + +Mas um servidor que você mantém e que de fato atende clientes pré-2026 tem todo o direito a um log silencioso. Filtre a categoria antes que a primeira chamada descontinuada seja executada: + +```python +import warnings + +from mcp import MCPDeprecationWarning + +warnings.filterwarnings("ignore", category=MCPDeprecationWarning) +``` + +A API inteira é essa. Não há uma chave por método, e você não quer uma: o sentido de ter uma única categoria é que uma linha a silencia e uma linha a traz de volta. + +!!! check + Aplique o filtro no sentido contrário e você ganha um teste de regressão de graça. + Adicione `"error::mcp.MCPDeprecationWarning"` à configuração `filterwarnings` do seu + pytest e a chamada descontinuada **lança uma exceção** em vez de avisar. Uma ferramenta + chamada `old_log` que ainda chama `ctx.info()` para de passar e começa a reportar: + + ```text + Error executing tool old_log: The logging capability is deprecated as of 2026-07-28 (SEP-2577). + ``` + + Uma linha de configuração do pytest, e uma chamada descontinuada nunca mais consegue + voltar sorrateiramente ao seu código sem quebrar um teste. + +## Recapitulando {#recap} + +* A especificação 2026-07-28 descontinua **roots**, a **amostragem** iniciada pelo servidor e o **logging** de protocolo (todos pela [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), restringe o **progresso** ao sentido servidor para cliente e remove o **`ping`**. +* A coluna de substitutos indica o próximo passo: **[Requisições de múltiplas idas e voltas](handlers/multi-round-trip.md)** para amostragem e roots, **[Logging](handlers/logging.md)** para logging, **[Progresso](handlers/progress.md)** para progresso. `ping` não precisa de nada. +* Descontinuado é consultivo: sem mudanças no protocolo de transmissão, tudo continua funcionando em sessões pré-2026, e você recebe um `MCPDeprecationWarning` visível (um `UserWarning`, então está ligado por padrão). +* Amostragem e roots precisam, além disso, de um canal de retorno (back-channel) que uma sessão 2026-07-28 não tem. Em uma conexão moderna elas avisam e depois lançam uma exceção. +* `warnings.filterwarnings("ignore", category=MCPDeprecationWarning)` silencia a categoria inteira; `"error::mcp.MCPDeprecationWarning"` no pytest a transforma em falha de teste. +* Código novo não deve ser construído sobre nenhuma delas. + +Todas as outras páginas desta documentação ensinam a API atual. diff --git a/i18n/pt/pages/get-started/first-steps.md b/i18n/pt/pages/get-started/first-steps.md new file mode 100644 index 0000000000..32b8bb46b1 --- /dev/null +++ b/i18n/pt/pages/get-started/first-steps.md @@ -0,0 +1,144 @@ +--- +translation: + sections: [0d6c05bcbf836bf3, 59a7b14eeefc68c1, 7114d8d6daba203f, e8bbb56a98ba7bc9, 5138010f6159901c, f78da7c7c363d4c6, 220a939cab348686] + tool: 1 +--- +# Primeiros passos {#first-steps} + +A **[página inicial](../index.md)** anda rápido: escrever um servidor, executá-lo, chamar uma ferramenta. + +Esta página vai com calma, passando pelas três coisas que um servidor pode expor e dando nome a tudo pelo caminho. + +## Host, cliente e servidor {#host-client-and-server} + +Três palavras que você vai ver em todas as páginas daqui em diante: + +* Um **host** é a aplicação de LLM: o Claude, uma IDE, um runtime de agentes. É com ele que o usuário conversa. +* Um **cliente** vive dentro do host e fala MCP. O host executa um cliente para cada servidor ao qual está conectado. +* Um **servidor** é o que você constrói com este SDK. Ele expõe coisas aos clientes. Nunca fala diretamente com o modelo. + +Você escreve o servidor. Os hosts são produto de terceiros. O SDK também traz um `Client`. Você vai usá-lo para testar seus servidores, e ele aparece mais adiante nesta página. + +## As três primitivas {#the-three-primitives} + +Um servidor expõe exatamente três tipos de coisa. O que as distingue é **quem decide usá-las**: + +| Primitiva | Quem controla | O que é | Exemplo | +|-----------------|-----------------|-----------------------------------------------------------------|----------------------------------------------------| +| **Ferramentas** | O modelo | Uma função que o modelo chama para executar uma ação | Uma chamada de API, uma escrita no banco de dados | +| **Recursos** | A aplicação | Dados que o host carrega no contexto do modelo | O conteúdo de um arquivo, uma resposta de API | +| **Prompts** | O usuário | Um template de mensagem reutilizável que o usuário invoca pelo nome | Um comando de barra, um item de menu | + +"Quem controla" é justamente o sentido da divisão. Uma ferramenta roda porque o **modelo** decidiu chamá-la. Um recurso é anexado porque a **aplicação** decidiu que o modelo precisava dele. Um prompt roda porque o **usuário** o escolheu. + +!!! info + Se você já construiu uma API web, já tem quase toda a intuição: um **recurso** é um `GET` + (carrega dados e não altera nada) e uma **ferramenta** é um `POST` (realiza trabalho e pode ter + efeitos colaterais). Um **prompt** não tem equivalente em HTTP; está mais para uma consulta salva + que o usuário executa pelo nome. + +## Um servidor, as três {#one-server-all-three} + +```python title="server.py" hl_lines="6 12 18" +--8<-- "docs_src/first_steps/tutorial001.py" +``` + +Três funções simples, três decoradores. Cada decorador já é o registro completo: + +* `@mcp.tool()` transforma `add` em uma **ferramenta**. +* `@mcp.resource("greeting://{name}")` transforma `greeting` em um **template de recurso**: o `{name}` na URI é o parâmetro da função. +* `@mcp.prompt()` transforma `summarize` em um **prompt**. A string que ela retorna vira uma mensagem de usuário. + +Todo o resto (o nome, a descrição, o schema dos argumentos) o SDK lê da própria função: o nome dela, a docstring, as anotações de tipo. Você nunca declarou nada disso separadamente. + +!!! tip + As duas metades do SDK têm dois caminhos de importação: `from mcp import Client` e + `from mcp.server import MCPServer`. Não existe `from mcp import MCPServer`. + +### Experimente {#try-it} + +Execute com o MCP Inspector: + +```console +uv run mcp dev server.py +``` + +Abra a URL que ele imprime. O Inspector tem uma aba por primitiva; passe por elas na ordem. + +**Ferramentas.** Uma entrada: `add`, descrita como *Add two numbers.* O formulário tem um campo inteiro obrigatório para `a` e outro para `b`. Preencha, chame, e o resultado é `3`. O Inspector montou esse formulário a partir de `a: int, b: int`. Qualquer outro cliente faz o mesmo. + +**Recursos.** A lista *Resources* está vazia. `greeting` fica em **Resource Templates**, porque `greeting://{name}` tem um parâmetro: não existe um recurso concreto para listar até alguém fornecer um `name`. Passe `World` e leia: + +```text +Hello, World! +``` + +**Prompts.** Uma entrada: `summarize`, com um único argumento obrigatório, `text`. Obtenha-o com algum texto e você recebe uma mensagem com `role: user` e sua string renderizada como conteúdo. Um prompt é só isso: uma função que monta mensagens. + +O Inspector executou seu servidor via **stdio**, um dos transportes que um servidor MCP sabe falar. Por enquanto você não escolhe um; **[Executando seu servidor](../run/index.md)** é a página para isso. + +## Capacidades {#capabilities} + +Você viu três abas no Inspector. Como ele sabia que eram três? + +Quando um cliente se conecta, o servidor declara suas **capacidades**: quais famílias de requisições ele vai responder. O cliente usa essa declaração para decidir o que faz sentido pedir. Você nunca escreveu isso; o `MCPServer` declara por você. + +Veja você mesmo. O `Client` do SDK aceita o objeto do servidor diretamente e se conecta a ele **em memória** (sem subprocesso, sem porta): + +```python +import asyncio + +from mcp import Client + +from server import mcp + + +async def main() -> None: + async with Client(mcp) as client: + print(client.server_capabilities.model_dump(exclude_none=True)) + + +asyncio.run(main()) +``` + +```text +{'prompts': {'list_changed': True}, 'resources': {'subscribe': True, 'list_changed': True}, 'tools': {'list_changed': True}} +``` + +Esse dicionário é a declaração de **capacidades** do seu servidor. É a primeira coisa que todo cliente aprende ao se conectar: + +| Capacidade | O cliente agora pode chamar | +|-------------|---------------------------------------------------------------| +| `tools` | `tools/list`, `tools/call` | +| `resources` | `resources/list`, `resources/templates/list`, `resources/read` | +| `prompts` | `prompts/list`, `prompts/get` | + +O `MCPServer` serve as três primitivas, então as três são sempre declaradas. + +Repare no que não aparece ali. `completions` (autocompletar de argumentos para templates de recurso e prompts) precisa de um handler escrito por você; este servidor não tem nenhum, então a capacidade fica de fora e um cliente bem-comportado nem pede. Essa é a regra para tudo que é opcional: registre a coisa e a capacidade aparece; **[Completions](../servers/completions.md)** comprova isso. + +!!! info + `Client(mcp)` é o mesmo cliente em memória com que todos os exemplos desta documentação são + testados, e é assim que você vai testar os seus. Ele ganha uma página inteira: **[Testes](testing.md)**. + +## O que você não escreveu {#what-you-did-not-write} + +Olhe de novo esta página. Você escreveu três funções Python pequenas. Você **não** escreveu: + +* Um JSON Schema. `a: int, b: int` *é* o schema de `add`. +* Um handler de requisição. `tools/list`, `resources/read`, `prompts/get`: o SDK atende todos por você. +* Uma declaração de capacidades. O `MCPServer` fez isso por você. +* Uma linha de protocolo. A negociação de versão, o enquadramento JSON-RPC, a troca de capacidades: tudo isso aconteceu dentro de `mcp dev` e `Client(mcp)`, e você nunca viu. + +Essa proporção é a razão de ser do SDK. + +## Recapitulando {#recap} + +* Um **host** é o app de LLM, um **cliente** é a metade dele que fala MCP, um **servidor** é o que você constrói. +* Ferramentas são controladas pelo **modelo**, recursos pela **aplicação**, prompts pelo **usuário**. +* Um decorador por primitiva: `@mcp.tool()`, `@mcp.resource(uri)`, `@mcp.prompt()`. Nome, descrição e schema vêm da função. +* Uma URI com um `{param}` cria um **template** de recurso, listado separadamente dos recursos concretos. +* As **capacidades** do servidor já vêm declaradas para você, e um cliente só pede o que o servidor declara. +* `Client(mcp)` se conecta ao objeto do servidor em memória: seu ambiente de testes desde o primeiro dia. + +A seguir vem **[Conecte a um host real](real-host.md)**: este servidor dentro do Claude Desktop ou de uma IDE, de verdade. Depois, **[Testes](testing.md)**: uma página, um cliente em memória, e você nunca mais fica adivinhando se funciona. Depois disso, cada primitiva ganha sua própria página, começando pela que o modelo comanda: **[Ferramentas](../servers/tools.md)**. diff --git a/i18n/pt/pages/get-started/index.md b/i18n/pt/pages/get-started/index.md new file mode 100644 index 0000000000..c6840ba418 --- /dev/null +++ b/i18n/pt/pages/get-started/index.md @@ -0,0 +1,57 @@ +--- +translation: + sections: [ed4a756b4c53c585, 97e2fb315b7fe398, 4d04f1c6f4bf6c1d, 577d73078fc62baf] + tool: 1 +--- +# Comece por aqui {#get-started} + +Novo no MCP, ou novo neste SDK? Comece aqui. Estas páginas levam você do zero a um +servidor funcional e testado: [instale o SDK](installation.md), construa seu +[primeiro servidor](first-steps.md), [conecte-o a um host real](real-host.md) e +[teste-o](testing.md) com um cliente em memória. + +## Execute o código {#run-the-code} + +Todos os blocos de código podem ser copiados e usados diretamente: são arquivos completos e funcionais. + +Para acompanhar, cole um bloco em um `server.py` e abra-o no MCP Inspector: + +```console +uv run mcp dev server.py +``` + +É **ALTAMENTE recomendado** que você escreva (ou copie) o código, edite-o e execute-o localmente. Usá-lo no seu próprio editor é o que mostra de verdade qual é a ideia: o pouco que você escreve, o autocompletar, a checagem de tipos pegando erros antes de executar qualquer coisa. + +## Você não vai precisar adivinhar {#you-will-not-be-guessing} + +Cada exemplo nesta documentação é um arquivo completo em [`docs_src/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/docs_src) no próprio repositório do SDK, e a suíte de testes do SDK exercita cada um deles por meio de um **cliente em memória**: + +```python +import pytest +from mcp import Client + +from server import mcp + + +@pytest.mark.anyio +async def test_add() -> None: + async with Client(mcp) as client: + result = await client.call_tool("add", {"a": 1, "b": 2}) + assert result.structured_content == {"result": 3} +``` + +Sem subprocesso, sem porta, sem transporte. `Client(mcp)` se conecta diretamente ao objeto do servidor. + +Se uma mudança no SDK quebrar um exemplo de uma destas páginas, o CI fica vermelho antes que a página quebre. O código que você lê aqui é o código que roda. + +Você mesmo vai usar isso em [Testes](testing.md); é assim que você testa seus próprios servidores também. + +## Para onde ir agora {#where-to-go-next} + +Depois que você tiver um servidor rodando, o resto desta documentação é uma referência, não um curso. +Cada página se sustenta sozinha, então pule direto para o que você precisa: + +* O que um servidor expõe (ferramentas, recursos, prompts) está em **[Servidores](../servers/index.md)**. +* O que está disponível dentro das funções que você registra está em **[Dentro do seu handler](../handlers/index.md)**. +* Levar o servidor até os clientes (stdio, HTTP, o app FastAPI que você já tem) está em **[Executando seu servidor](../run/index.md)**. +* Construir o outro lado, uma aplicação que *usa* servidores MCP, está em **[Clientes](../client/index.md)**. diff --git a/i18n/pt/pages/get-started/installation.md b/i18n/pt/pages/get-started/installation.md new file mode 100644 index 0000000000..533d1d8892 --- /dev/null +++ b/i18n/pt/pages/get-started/installation.md @@ -0,0 +1,47 @@ +--- +translation: + sections: [6e2f9bab94d5ed36, 8cf653388f69e28b, 6fd9ea2f65de0df6] + tool: 1 +--- +# Instalação {#installation} + +O SDK Python está no PyPI como [`mcp`](https://pypi.org/project/mcp/). Ele requer **Python 3.10+**. + +Esta documentação descreve a **v2**, a linha de versões estável atual: + +=== "uv" + + ```bash + uv add "mcp[cli]" + ``` + +=== "pip" + + ```bash + pip install "mcp[cli]" + ``` + +!!! note "Vindo da v1?" + A v2 é uma versão major com quebras de compatibilidade; o **[Guia de migração](../migration.md)** + cobre todas elas. Se o seu *pacote* depende de `mcp` e ainda não está pronto para migrar, mantenha um + limite superior `<2` (por exemplo `mcp>=1.28,<2`) para que uma resolução sem versão fixada continue na linha 1.x. + +## O que é instalado {#what-gets-installed} + +Você não precisa saber nada disso para usar o SDK, mas, se quiser saber para que serve cada dependência: + +* `mcp-types`: todos os tipos do protocolo (requisições, resultados, blocos de conteúdo) em um pacote próprio, versionado em sincronia com o SDK. O código que depende de `mcp` o importa pelo alias `mcp.types` (todo `from mcp.types import ...` nesta documentação); importe `mcp_types` diretamente apenas em um projeto que instala `mcp-types` sem o SDK. +* [`anyio`](https://anyio.readthedocs.io/): o runtime assíncrono. O SDK inteiro é escrito sobre o anyio, então roda tanto com `asyncio` quanto com `trio`. +* [`pydantic`](https://docs.pydantic.dev/): a base de todos os modelos de `mcp.types`, além de toda a geração e validação de schemas. +* [`httpx2`](https://pypi.org/project/httpx2/): o cliente HTTP por trás dos transportes de *cliente* Streamable HTTP e SSE, com suporte embutido a server-sent events. +* [`starlette`](https://www.starlette.io/), [`uvicorn`](https://www.uvicorn.org/), [`sse-starlette`](https://pypi.org/project/sse-starlette/) e [`python-multipart`](https://pypi.org/project/python-multipart/): os transportes HTTP de *servidor*. +* [`jsonschema`](https://pypi.org/project/jsonschema/): valida a saída estruturada de uma ferramenta (tool) contra o schema de saída declarado por ela. +* [`pyjwt[crypto]`](https://pyjwt.readthedocs.io/): tratamento de tokens OAuth para autorização. +* [`opentelemetry-api`](https://opentelemetry-python.readthedocs.io/): apenas a API leve, então o middleware de tracing do SDK não custa nada, a menos que você mesmo instale um SDK e um exporter do OpenTelemetry. +* [`typing-extensions`](https://typing-extensions.readthedocs.io/) e [`typing-inspection`](https://pypi.org/project/typing-inspection/): funcionalidades modernas de tipagem no Python 3.10. +* [`pywin32`](https://pypi.org/project/pywin32/): somente no Windows, usado para o gerenciamento de subprocessos `stdio`. + +## Extras opcionais {#optional-extras} + +* `mcp[cli]` adiciona [`typer`](https://typer.tiangolo.com/) e [`python-dotenv`](https://pypi.org/project/python-dotenv/) para a ferramenta de linha de comando `mcp` (`mcp dev`, `mcp run`, `mcp install`). Você vai querer isso durante o desenvolvimento; talvez não precise dele em um servidor depois do deploy. +* `mcp[rich]` adiciona [`rich`](https://rich.readthedocs.io/) para logs de servidor mais bonitos. diff --git a/i18n/pt/pages/get-started/real-host.md b/i18n/pt/pages/get-started/real-host.md new file mode 100644 index 0000000000..2b8643108c --- /dev/null +++ b/i18n/pt/pages/get-started/real-host.md @@ -0,0 +1,185 @@ +--- +translation: + sections: [3c4f2f06b4e978b6, 22520eecae3d1961, f4e1709db18d635a, 2eb57992049671d9, 1ba83e9af37cc1b4, 4822586344b08d9e, 1c93afef72478992, b6b448f9eddd51dc, fe55370fd931815b] + tool: 1 +--- +# Conecte-se a um host de verdade {#connect-to-a-real-host} + +Um **host** é a aplicação dentro da qual seu servidor acaba rodando: Claude Desktop, Claude Code, uma IDE. O host é aquilo com que o usuário conversa. Dentro dele, um **cliente** MCP inicia seu servidor como um processo filho e fala com ele pelo stdin e stdout desse processo. + +Ou seja, conectar a um host é um ato só: você informa a ele **o comando que inicia seu servidor**. Tudo nesta página (dois comandos de CLI, três arquivos JSON) é um lugar diferente para colocar esse mesmo comando. + +## Um servidor, todos os hosts {#one-server-every-host} + +```python title="server.py" hl_lines="3 33-34" +--8<-- "docs_src/real_host/tutorial001.py" +``` + +Duas ferramentas (tools) e um recurso, um arquivo só. Três coisas sobre esse arquivo importam para todos os hosts abaixo: + +* `mcp.run()` sem argumentos inicia um servidor **stdio**: ele bloqueia, lê mensagens do protocolo no stdin e as escreve no stdout. Esse é o transporte que todos os hosts desta página falam. O host inicia seu arquivo como um processo filho e é dono desses dois pipes, e é por isso que conectar nunca passa de "aqui está o comando". Você nunca escolhe uma porta, e nada fica escutando em uma. +* `run()` fica dentro de `if __name__ == "__main__":`. Tudo abaixo **importa** este arquivo em vez de executá-lo, então um `run()` sem essa proteção iniciaria um servidor no instante em que qualquer coisa carregasse o módulo. +* O objeto do servidor é uma global de nível de módulo chamada `mcp`. É esse o nome que `mcp run` procura (`server` e `app` também funcionam). Dê outro nome a ele e você precisa informá-lo explicitamente: `mcp run server.py:bookshop`. + +Essa é a última linha de Python nesta página. Daqui para baixo é tudo configuração de host. + +## O comando de inicialização {#the-launch-command} + +Todos os hosts abaixo recebem o mesmo comando: + +```bash +uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py +``` + +Um comando só para todos eles porque `uv run --with` resolve o SDK em um ambiente novo na hora: funciona a partir de qualquer diretório e não precisa de projeto nem de ambiente virtual para ativar. Isso importa aqui mais do que em qualquer outro lugar, porque um host inicia seu servidor a partir do diretório de trabalho *dele*, com um ambiente quase vazio, e não a partir do seu shell. + +É também o comando que `mcp install` grava na configuração do Claude Desktop para você (abaixo), então o que você digita à mão e o que o utilitário gera coincidem, fora o pin exato de versão que o utilitário adiciona. + +!!! tip "Se um host não encontrar o `uv`" + Um host inicia seu servidor com um `PATH` mínimo, e o `uv` pode não estar nele. Troque o + `uv` sozinho pelo caminho absoluto que `which uv` (macOS/Linux) ou `where uv` (Windows) + retorna. É exatamente isso que `mcp install` grava. + +!!! note "Esta página é o cenário local" + Tudo aqui executa seu servidor na máquina em que o host está: o host inicia seu + arquivo, via stdio. Isso é exatamente o certo para uma ferramenta pessoal ou de uma máquina + só. Para entregar um servidor a pessoas que *não* têm seu arquivo, você distribui uma + **URL**, não um comando: o mesmo objeto `mcp` servido via Streamable HTTP. + **[Executando seu servidor](../run/index.md)** é essa decisão em uma tabela só, e + **[Deploy e escala](../run/deploy.md)** é o caminho dali até um hostname de verdade. + + E um host nada mais é que uma aplicação com um cliente MCP dentro, então seu próprio + código Python pode fazer o papel do host: **[Transportes do cliente](../client/transports.md)** + inicia este mesmo arquivo como subprocesso com `stdio_client(...)`, e **[Testes](testing.md)** + se conecta a ele em memória, sem processo nenhum. + +## Claude Desktop {#claude-desktop} + +O único host que o SDK consegue configurar para você: + +```bash +uv run mcp install server.py +``` + +É só isso. `mcp install` importa o arquivo para ler o nome do servidor, encontra o arquivo de configuração do Claude Desktop e grava o comando de inicialização nele. De passagem, já converte o caminho para absoluto, então você não precisa fazer isso. + +Não há mistério nenhum. Esta é a entrada que ele grava: + +```json +{ + "mcpServers": { + "Bookshop": { + "command": "/absolute/path/to/uv", + "args": [ + "run", + "--frozen", + "--with", + "mcp[cli]==2.0.0", + "mcp", + "run", + "/absolute/path/to/server.py" + ] + } + } +} +``` + +É o comando de inicialização da seção acima com três acréscimos: o caminho absoluto do `uv`, `--frozen` para que o `uv` nunca reescreva um lockfile que por acaso esteja por perto, e um pin exato na versão do `mcp` que você tem instalada. Ele vai parar em `claude_desktop_config.json`, que fica em: + +* **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` +* **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` + +Você pode escrever esse arquivo à mão. `mcp install` existe para você não cometer o erro clássico (um caminho relativo) ao fazer isso. + +Encerre o Claude Desktop por completo (não só a janela) e abra-o de novo. + +!!! warning + `mcp install` falha com `Claude app not found` se o *diretório* de configuração do Claude + Desktop ainda não existir. Instale o Claude Desktop e execute-o uma vez: é isso que cria o + diretório. + +!!! tip + O Claude Desktop inicia seu servidor em um processo próprio, então as variáveis de ambiente do + seu shell não estão lá. `uv run mcp install server.py -v API_KEY=abc123` (ou `-f .env`) as + registra no campo `env` da entrada. `--name` sobrescreve o nome da entrada; o padrão é o + `name` do servidor. + +## Claude Code {#claude-code} + +Não há arquivo para editar. Registre o servidor com a CLI `claude`; tudo depois de `--` é o comando de inicialização. + +```bash +claude mcp add bookshop -- uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py +``` + +Execute `/mcp` dentro de uma sessão do Claude Code para confirmar que `bookshop` está conectado e que suas ferramentas aparecem listadas. + +## Cursor {#cursor} + +Crie `.cursor/mcp.json` na raiz do seu projeto. + +```json +{ + "mcpServers": { + "bookshop": { + "command": "uv", + "args": ["run", "--with", "mcp[cli]", "mcp", "run", "/absolute/path/to/server.py"] + } + } +} +``` + +Os mesmos `command` e `args`, sob a mesma chave `mcpServers` que o Claude Desktop usa. O servidor aparece nas configurações de MCP do Cursor com as duas ferramentas listadas. + +## VS Code {#vs-code} + +Crie `.vscode/mcp.json` na raiz do seu projeto. + +```json +{ + "servers": { + "bookshop": { + "type": "stdio", + "command": "uv", + "args": ["run", "--with", "mcp[cli]", "mcp", "run", "/absolute/path/to/server.py"] + } + } +} +``` + +Duas diferenças em relação ao arquivo do Cursor, e são as únicas duas: a chave externa é `servers`, não `mcpServers`, e cada entrada declara seu `type`. Confirme o diálogo de confiança e, em seguida, **MCP: List Servers** na paleta de comandos mostra `bookshop` rodando. + +!!! note + Você precisa do VS Code 1.99 ou posterior, com login feito na extensão **GitHub Copilot** + (o Copilot Free basta), e o Copilot Chat precisa estar no modo **Agent**, porque nenhum + outro modo chama ferramentas. + +## Não aparece {#it-doesnt-show-up} + +Antes de mexer em qualquer configuração de host, execute você mesmo o comando de inicialização: + +```bash +uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py +``` + +Nada é impresso, e ele não retorna. Esse silêncio está certo: um servidor stdio está esperando um host falar primeiro no stdin (`Ctrl-C` para pará-lo). Um traceback ou uma saída imediata é o bug de verdade, e agora você consegue lê-lo em vez de tentar adivinhá-lo através de um host. + +Uma vez que esse comando fica parado esperando, o que sobra é quase sempre uma de três coisas: + +* **Um caminho relativo.** O host inicia seu servidor a partir do diretório de trabalho *dele*, não daquele de onde você fez o registro. `server.py` onde é preciso `/absolute/path/to/server.py` é, de longe, a falha mais comum. Se o host também não encontrar o `uv`, esse caminho precisa ser absoluto também. +* **O host ainda está rodando a configuração antiga.** Os hosts leem a configuração ao iniciar. O Claude Desktop, em particular, precisa ser *encerrado por completo* (não basta fechar a janela) e reaberto para que uma edição em `claude_desktop_config.json` tenha efeito. +* **Algo chegou ao stdout fora do intervalo de desvio.** No stdio, o stdout *é* o protocolo. O SDK desvia para o stderr a saída avulsa descarregada (com flush) enquanto está servindo, mas uma saída descarregada no stdout antes disso (um script wrapper que ecoa algo, um `print()` em tempo de importação em um processo sem buffer), ou um `print()` em buffer que só é descarregado quando o interpretador encerra, entrega ao host uma mensagem corrompida, e ele derruba a conexão. Registre os logs usando a configuração padrão do `logging`, cujo handler de stderr faz flush de cada registro; handlers personalizados também precisam evitar o stdout. **[Logging](../handlers/logging.md)** tem a história completa. + +O Claude Desktop mantém um log por servidor: `mcp-server-.log` é o stderr do seu servidor, ao lado de `mcp.log` para as conexões, em `~/Library/Logs/Claude` no macOS e `%APPDATA%\Claude\logs` no Windows. + +Para qualquer coisa além dessas três, a página é **[Solução de problemas](../troubleshooting.md)**. + +## Recapitulando {#recap} + +* Um **host** (Claude Desktop, uma IDE) executa um cliente MCP que inicia seu servidor como processo filho via stdio. Conectar significa dar a ele um comando de inicialização. +* Esse comando é `uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py`: nenhum venv para ativar, funciona a partir de qualquer diretório. +* **Claude Desktop** é o único host que `mcp install` configura para você. Ele grava esse mesmo comando (mais o caminho absoluto do `uv`, `--frozen` e um pin exato na versão que você tem instalada) em `claude_desktop_config.json`, para você nunca precisar fazer isso. +* **Claude Code** é `claude mcp add bookshop -- `. **Cursor** é `.cursor/mcp.json` sob `mcpServers`. **VS Code** é `.vscode/mcp.json` sob `servers`, cada entrada com um `type`. +* Caminhos absolutos em todo lugar, reinicie o host depois de editar a configuração dele, e nunca deixe nada além do SDK escrever no stdout. + +Todos os hosts desta página se conectaram ao mesmo arquivo, com o mesmo comando. O que esse arquivo pode *expor* é o resto desta documentação: **[Ferramentas](../servers/tools.md)**, **[Recursos](../servers/resources.md)** e todos os transportes além do stdio em **[Executando seu servidor](../run/index.md)**. diff --git a/i18n/pt/pages/get-started/testing.md b/i18n/pt/pages/get-started/testing.md new file mode 100644 index 0000000000..ee9819d9b0 --- /dev/null +++ b/i18n/pt/pages/get-started/testing.md @@ -0,0 +1,115 @@ +--- +translation: + sections: ['4926721070127497', c52a1de2b6b32f40, 2e410b412c25f314, 627195f7159e24ef] + tool: 1 +--- +# Testes {#testing} + +O SDK Python traz uma classe `Client` com um **transporte em memória**: passe a ela o objeto do seu servidor e ela se conecta diretamente a ele. + +Sem subprocesso. Sem porta. Sem transporte nenhum. É a mesma ideia do `TestClient` do FastAPI. + +## Uso básico {#basic-usage} + +Vamos supor que você tenha um servidor simples com uma única ferramenta (tool): + +```python title="server.py" +--8<-- "docs_src/testing/tutorial001.py" +``` + +Para executar o teste abaixo, você vai precisar de duas dependências extras (de desenvolvimento): + +=== "uv" + + ```bash + uv add --dev pytest inline-snapshot + ``` + +=== "pip" + + ```bash + pip install pytest inline-snapshot + ``` + +!!! info + Esta documentação pressupõe que você já conhece o [`pytest`](https://docs.pytest.org/en/stable/). + + O [`inline-snapshot`](https://15r10nk.github.io/inline-snapshot/latest/) é o que o teste abaixo + usa para fazer a asserção sobre o objeto de resultado inteiro em uma única linha. Ele grava a saída + de um teste como o literal `snapshot(...)` que você vê. Se preferir não usá-lo, remova o import e + faça as asserções sobre os campos que interessam (`result.content[0].text == "3"`), como em + qualquer outro teste. + +Agora o teste: + +```python title="test_server.py" +import pytest +from inline_snapshot import snapshot +from mcp import Client +from mcp.types import CallToolResult, TextContent + +from server import mcp + + +@pytest.fixture +def anyio_backend(): # (1)! + return "asyncio" + + +@pytest.fixture +async def client(): # (2)! + async with Client(mcp, raise_exceptions=True) as c: + yield c + + +@pytest.mark.anyio +async def test_call_add_tool(client: Client): + result = await client.call_tool("add", {"a": 1, "b": 2}) + # Drop the server identity stamp in `_meta`; it is not what this test is about. + result.meta = None + assert result == snapshot( + CallToolResult( + content=[TextContent(type="text", text="3")], + structured_content={"result": 3}, + ) + ) +``` + +1. Se você estiver usando `trio`, retorne `"trio"` no lugar. Veja a [documentação do anyio](https://anyio.readthedocs.io/en/stable/testing.html#specifying-the-backends-to-run-on) para os detalhes. +2. A fixture entrega um cliente já conectado. Todo teste que recebe `client` ganha uma conexão em memória nova com o mesmo servidor. + +Pronto! Agora você pode estender seus testes para cobrir mais cenários. + +## Por que `raise_exceptions=True`? {#why-raise_exceptionstrue} + +Duas coisas diferentes podem dar errado, e essa flag só mexe em uma delas. + +Uma exceção dentro de uma das **suas ferramentas** não é uma falha de protocolo. Ela vira um resultado normal com +`is_error=True`, e o modelo lê a mensagem. `raise_exceptions` não muda isso: com ou +sem ela, `call_tool` retorna o mesmo resultado com `is_error=True`. Há uma página inteira sobre isso: +**[Tratamento de erros](../servers/handling-errors.md)**. + +Uma falha **fora** do corpo de uma ferramenta é diferente. Na conexão que `Client(mcp)` entrega, o +servidor a sanitiza em um genérico `"Internal server error"` antes que o cliente a veja. Você nunca +deve vazar os detalhes de um crash inesperado para um chamador remoto. Em um teste, isso é exatamente o que +você *não* quer, e é isso que `raise_exceptions=True` muda: seu teste vê a mensagem real +em vez da sanitizada. + +Deixe-a ligada nos testes. Em código de produção, ela não significa nada. + +## No mesmo processo por padrão {#in-process-by-default} + +!!! note + `Client(mcp)` se conecta no mesmo processo e é **neutro quanto à era** por padrão: ele sonda o servidor e + escolhe o caminho de protocolo adequado. Fixe `mode="legacy"` se o seu teste exercita semântica + específica do modo legado (push de amostragem (sampling) ou de elicitação (elicitation), `message_handler`), e remova `raise_exceptions=True` + nesse caso: uma conexão legada nem sequer sanitiza, e a flag relança a + falha dentro da task do servidor em vez de no seu teste. + +Essa única linha é também o motivo pelo qual esta documentação pode prometer que os exemplos funcionam: cada +arquivo de exemplo é exercitado pela própria suíte de testes do SDK, quase todos exatamente por meio deste +cliente. Você está usando a mesma ferramenta que o SDK usa em si mesmo. + +Você tem um servidor funcionando e testado. Colocá-lo dentro de uma aplicação real (Claude Desktop, uma +IDE) é o tema de **[Conecte-se a um host real](real-host.md)**; todas as outras formas de servi-lo estão em +**[Executando seu servidor](../run/index.md)**. diff --git a/i18n/pt/pages/handlers/context.md b/i18n/pt/pages/handlers/context.md new file mode 100644 index 0000000000..c2d263e4ed --- /dev/null +++ b/i18n/pt/pages/handlers/context.md @@ -0,0 +1,134 @@ +--- +translation: + sections: [b50152f05c81e786, b302059b22fb7cb4, 85682a1bf561243a, 53fc48838eb6837a, b24190e0842786ec, 85f93e150fc9b240] + tool: 1 +--- +# O Context {#the-context} + +Os argumentos de uma ferramenta (tool) vêm do modelo. Todo o resto (a requisição que você está atendendo, o servidor em que você vive, um jeito de falar de volta com o cliente) vem de um único objeto: o **`Context`**. + +Você não o constrói nem o configura. Você pede por ele. + +## Peça por ele {#ask-for-it} + +Adicione a qualquer ferramenta um parâmetro anotado com `Context`: + +```python title="server.py" hl_lines="2 8" +--8<-- "docs_src/context/tutorial001.py" +``` + +* O SDK constrói um `Context` novo para cada requisição e o passa para a função. +* O **nome do parâmetro não importa**. `ctx`, `context`, `c`: o SDK o encontra pela anotação. +* Recursos e prompts também podem declarar um, do mesmo jeito. +* `ctx.request_id` é o id da requisição que sua função está atendendo neste momento. + +!!! info + Se você já usou FastAPI, já conhece essa jogada: declare um parâmetro com o tipo do próprio + framework (`Request` lá, `Context` aqui) e o framework o fornece. Nada para registrar, nada para + configurar: a anotação de tipo é o mecanismo inteiro. + +### Invisível para o modelo {#invisible-to-the-model} + +Esta é a parte para internalizar. Aqui está o schema de entrada que `tools/list` informa para `search_books`: + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"} + }, + "required": ["query"], + "title": "search_booksArguments" +} +``` + +Uma única propriedade. `ctx` não é um argumento: ele nunca aparece no schema, o modelo nunca fica sabendo dele e nenhum cliente consegue preenchê-lo. É um contrato entre você e o SDK, invisível no protocolo. + +### Experimente {#try-it} + +Execute o servidor com o MCP Inspector: + +```console +uv run mcp dev server.py +``` + +O formulário de `search_books` tem um único campo, `query`. Chame-a com `dune`: + +```text +[request 3] Found 3 books matching 'dune'. +``` + +O número é o da requisição da vez. Chame a ferramenta de novo e ele muda: cada requisição recebe seu próprio `Context`. + +## O que ele oferece {#what-it-gives-you} + +O objeto injetado é pequeno. Além de `request_id`: + +* `await ctx.read_resource(uri)`: lê um dos recursos do **próprio** servidor, de dentro de uma ferramenta. É a próxima seção. +* `await ctx.report_progress(progress, total, message)`: envia o progresso de volta a quem chamou, durante uma chamada demorada. **[Progresso](progress.md)** tem a história completa. +* `await ctx.elicit(message, schema)` e `await ctx.elicit_url(...)`: pausam a ferramenta e fazem uma pergunta ao usuário. Isso é **[Elicitação](elicitation.md)** (elicitation). +* `ctx.session`: o lado do servidor na conversa com este cliente. As notificações que você envia ao cliente ficam aqui; a última seção usa isso. +* `ctx.headers`: os cabeçalhos da requisição que o transporte carregou, ou `None` no stdio. Leia um cabeçalho customizado com `(ctx.headers or {}).get("x-...")`. Cabeçalhos são entrada fornecida pelo cliente - servem para um locale ou uma feature flag, nunca para uma identidade. +* `ctx.request_context`: o registro bruto de cada requisição. O campo que você vai querer é `lifespan_context`, o objeto que seu código de inicialização entregou no yield (veja **[Lifespan](lifespan.md)**). + +Logging está fora dessa lista de propósito. Um servidor registra logs com o módulo `logging` do Python, como qualquer outro programa Python. **[Logging](logging.md)** é a página curta que explica o porquê. + +!!! tip + A injeção só acontece na função que você registrou. Uma função auxiliar que sua ferramenta chama + não recebe um `Context` próprio; passe `ctx` adiante como um argumento comum. Não existe um + "contexto atual" implícito para buscar de algum outro lugar. + +## Leia seus próprios recursos {#read-your-own-resources} + +Os recursos de um servidor não são só para os clientes. Uma ferramenta também pode lê-los: + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/context/tutorial002.py" +``` + +`ctx.read_resource` resolve a URI pelo mesmo registro que atende `resources/read`, então uma ferramenta recebe o que um cliente receberia: um iterável de `ReadResourceContents`, um por bloco de conteúdo. Para esta URI existe um só: + +```python +contents.content # 'fiction, non-fiction, poetry' +contents.mime_type # 'text/plain' +``` + +* `content` é exatamente o que `genres()` retornou. Uma única fonte da verdade: o cliente navega pelo recurso, suas ferramentas o consomem, ninguém copia a string. +* O único parâmetro de `describe_catalog` é o `Context`, então seu schema de entrada **não tem nenhuma propriedade**. O modelo a chama com `{}`. + +## Avise o cliente de que a lista mudou {#tell-the-client-the-list-changed} + +O que um servidor oferece não é fixo no momento do import. Registre uma ferramenta em tempo de execução e depois avise o cliente: + +```python title="server.py" hl_lines="15-16" +--8<-- "docs_src/context/tutorial003.py" +``` + +* `mcp.add_tool(recommend_book)` registra uma função comum como ferramenta: nome, descrição e schema derivados exatamente como `@mcp.tool()` faria. +* `await ctx.session.send_tool_list_changed()` envia `notifications/tools/list_changed`. Um cliente que a recebe chama `tools/list` de novo e vê `recommend_book`. + +Os irmãos são `send_resource_list_changed()`, `send_prompt_list_changed()` e `send_resource_updated(uri)`, este último para uma mudança em um recurso específico. + +Em uma conexão 2026-07-28, os clientes só recebem notificações de mudança em um stream `subscriptions/listen` que eles mesmos abriram, então os métodos `send_*` acima não alcançam esses streams. Os métodos de publicação do `Context` entregam a todos os streams assinantes de uma vez só: `await ctx.notify_tools_changed()`, `await ctx.notify_prompts_changed()`, `await ctx.notify_resources_changed()` e `await ctx.notify_resource_updated(uri)`. **[Assinaturas](subscriptions.md)** tem a história completa, incluindo como escalar horizontalmente entre réplicas. + +!!! check + Antes de alguém executar `enable_recommendations`, a ferramenta que você está prometendo não + existe. Chame-a mesmo assim e o resultado é um erro que o modelo consegue ler: + + ```text + Unknown tool: recommend_book + ``` + + Execute `enable_recommendations` e a mesmíssima chamada dá certo. A lista de ferramentas é + dinâmica de verdade: `tools/list` reflete o que quer que esteja registrado *neste exato momento*. + +## Recapitulando {#recap} + +* Anote um parâmetro com `Context` (em uma ferramenta, um recurso ou um prompt) e o SDK o injeta. O nome fica por sua conta. +* Ele é invisível para o modelo: o schema de entrada sempre contém apenas seus argumentos de verdade. +* `ctx.request_id` identifica a requisição; `ctx.request_context.lifespan_context` é o que sua inicialização entregou no yield. +* `await ctx.read_resource(uri)` permite que uma ferramenta leia os recursos do próprio servidor. +* `ctx.session` é o canal de volta para o cliente: `send_tool_list_changed()` e seus irmãos dizem a ele para buscar de novo uma lista que você mudou. +* Relatar progresso e a elicitação também começam no `Context`; cada um tem sua própria página. + +Parâmetros que o modelo nunca vê, preenchidos pelas suas próprias funções, são as **[Dependências](dependencies.md)**. diff --git a/i18n/pt/pages/handlers/dependencies.md b/i18n/pt/pages/handlers/dependencies.md new file mode 100644 index 0000000000..f76f921f02 --- /dev/null +++ b/i18n/pt/pages/handlers/dependencies.md @@ -0,0 +1,163 @@ +--- +translation: + sections: [b0389403e98d25ad, e2cf58b43b285e86, a363e1a38e1a5971, 6cfac078feb18013, b4535bd61df337e6, e97ed44207f929fd] + tool: 1 +--- +# Dependências {#dependencies} + +Os argumentos de uma ferramenta (tool) vêm do modelo. Alguns valores nunca deveriam vir dele: um preço consultado nos seus registros, uma confirmação que só uma pessoa pode dar, qualquer coisa que o modelo poderia errar se inventasse. + +**Dependências** são parâmetros preenchidos por funções suas. Você anota o parâmetro, indica a função, e o SDK a chama antes de a ferramenta rodar. + +## Declare uma {#declare-one} + +Envolva o tipo do parâmetro em `Annotated[...]` e adicione `Resolve(fn)`: + +```python title="server.py" hl_lines="18-19 23" +--8<-- "docs_src/dependencies/tutorial001.py" +``` + +* `check_stock` é um **resolvedor**: uma função comum que o SDK executa antes de `reserve_book` e cujo valor de retorno vira o argumento `stock`. +* O parâmetro `title` dele é o próprio argumento `title` da ferramenta, associado **pelo nome**. O resolvedor vê exatamente o valor validado que o corpo da ferramenta vai ver. +* O corpo da ferramenta já parte de um `Stock` que existe. Nada de código de consulta na ferramenta, nada de preâmbulo do tipo "e se estiver faltando". + +!!! info + Se você já usou FastAPI, isto é o `Depends`. Mesma ideia, mesmo motivo: a função declara o que + precisa, o framework fornece, e a ligação toda fica na anotação de tipo. + +### Invisível para o modelo {#invisible-to-the-model} + +Este é o schema de entrada que `tools/list` informa para `reserve_book`: + +```json +{ + "type": "object", + "properties": { + "title": {"title": "Title", "type": "string"} + }, + "required": ["title"], + "title": "reserve_bookArguments" +} +``` + +Uma única propriedade. Assim como o `Context` em **[O Context](context.md)**, um parâmetro resolvido é um contrato entre você e o SDK: `stock` não está no schema, o modelo nunca fica sabendo dele, e um cliente que mande um valor de `stock` mesmo assim é ignorado. O valor do resolvedor é o único que a sua ferramenta pode receber. + +É essa última parte que importa. Um parâmetro que o modelo não pode fornecer é um parâmetro que o modelo não pode errar. + +### Experimente {#try-it} + +Execute o servidor com o MCP Inspector: + +```console +uv run mcp dev server.py +``` + +O formulário de `reserve_book` tem um único campo `title`. Nem sinal de `stock` nele. Chame a ferramenta com `Dune`: + +```text +Reserved 'Dune' (6 copies left). +``` + +O corpo da ferramenta não consultou nada: `check_stock` rodou primeiro, e o `Stock` que ele retornou chegou como argumento. Experimente `Neuromancer` e o mesmo resolvedor entrega um zero à ferramenta. + +!!! tip + Você poderia simplesmente chamar `check_stock(title)` no corpo da ferramenta. Declare como dependência quando o + valor merecer mais que uma chamada a uma função auxiliar: toda ferramenta que precisa do estoque declara o mesmo parâmetro, + e o SDK executa o resolvedor no máximo uma vez por chamada, não importa quantas o declarem. As próximas + seções acrescentam o resto: resolvedores que dependem uns dos outros e resolvedores que perguntam ao usuário. + +## Dependências de dependências {#dependencies-of-dependencies} + +Um resolvedor pode declarar as próprias dependências, com a mesma anotação: + +```python title="server.py" hl_lines="22 29-30" +--8<-- "docs_src/dependencies/tutorial002.py" +``` + +* `estimate_delivery` depende de `check_stock`. O SDK executa o grafo em ordem: primeiro o estoque, depois a estimativa, depois a ferramenta. +* Tanto `stock` quanto `delivery` precisam, no fim das contas, de `check_stock`, mas ele roda **uma vez por chamada**. Uma consulta ao estoque, dois consumidores. +* Não há nada para registrar. O grafo *são* as anotações. + +!!! check + Não aceite o "uma vez por chamada" de olhos fechados. Coloque um `print` em `check_stock` e chame `order_book` pelo + Inspector: uma linha por chamada. Dois consumidores, uma consulta. + +O SDK analisa o grafo quando a ferramenta é registrada, não quando é chamada. Um parâmetro que ele não consegue classificar - que não é um `Context`, nem um `Resolve(...)`, nem o nome de um argumento da ferramenta - e um ciclo de resolvedores levantam, os dois, `InvalidSignature` na inicialização. O servidor falha antes mesmo de qualquer cliente se conectar, com o parâmetro ou resolvedor culpado nomeado no erro. + +Os parâmetros de um resolvedor se resolvem exatamente como os de uma ferramenta: outro `Resolve(...)`, os próprios argumentos da ferramenta pelo nome, ou o `Context` - `ctx.headers`, o objeto do lifespan, tudo isso. + +!!! warning + Nos transportes HTTP o `Context` inclui `ctx.headers`. Cabeçalhos são **entrada fornecida pelo cliente**, + como qualquer argumento de ferramenta: servem para um locale ou uma feature flag, nunca para uma identidade. A identidade + de quem chama vem da sua camada de autorização (**[Autorização](../run/authorization.md)**), não de um cabeçalho que qualquer um pode definir. + +!!! tip + *Uma vez por chamada* significa exatamente isso: o próximo `tools/call` executa `check_stock` de novo. Um recurso + que deve viver mais que uma requisição - um pool de banco de dados, um cliente HTTP - tem seu lugar no **[Lifespan](lifespan.md)**, e + um resolvedor chega até ele por `ctx.request_context.lifespan_context`. + +## Pergunte quando for preciso {#ask-when-you-must} + +Um resolvedor não precisa saber a resposta. Ele pode retornar `Elicit(message, Model)` e o SDK pergunta ao usuário - o mecanismo de **[Elicitação](elicitation.md)** (elicitation), executado para você: + +```python title="server.py" hl_lines="26-32 39" +--8<-- "docs_src/dependencies/tutorial003.py" +``` + +* Em estoque: `confirm_backorder` retorna um `Backorder` diretamente. **Sem pergunta, sem ida e volta.** O usuário só é interrompido quando a resposta dele faz diferença. +* Sem estoque: o SDK envia a elicitação, valida a resposta contra `Backorder` e a injeta. O seu resolvedor nunca encosta no protocolo. +* A ferramenta lê `backorder.confirm` como qualquer outro argumento. Responder **não** ainda é uma resposta: a elicitação é aceita com `confirm=False`, a ferramenta roda, e nenhum pedido é feito. Perguntar virou pré-condição, e não código de infraestrutura no corpo da ferramenta. + +E se o usuário simplesmente não responder - recusar a pergunta ou cancelá-la? + +!!! check + Execute `order_book` para `Neuromancer` e recuse a pergunta. Com a anotação escrita como + `Annotated[Backorder, Resolve(...)]` o corpo da ferramenta nunca roda; a chamada falha com um resultado + de erro que o modelo consegue ler: + + ```text + Error executing tool order_book: Resolver for parameter 'backorder' could not resolve: elicitation was decline + ``` + +Esse é o padrão certo para uma pré-condição: sem resposta, sem pedido. Quando a recusa é um desfecho que a sua ferramenta quer tratar - pular a encomenda, mas ainda assim sugerir outro título - anote `ElicitationResult[Backorder]` no lugar, e a ferramenta recebe o desfecho completo de aceitar/recusar/cancelar para decidir o que fazer. **[Elicitação](elicitation.md)** mostra essa forma e todo o resto sobre perguntar: as regras de schema, as três respostas, o lado do cliente na conversa. + +!!! info + O framework escolhe o transporte da pergunta a partir da versão de protocolo negociada; o código + acima é idêntico nas duas. Em **2026-07-28** e posteriores a pergunta viaja dentro de um + `tools/call` com múltiplas idas e voltas - o servidor a retorna, o `elicitation_callback` do cliente + a responde, e o `Client` refaz a chamada para você (**[Requisições com múltiplas idas e voltas](multi-round-trip.md)**). Em + **2025-11-25** e anteriores ela é uma requisição síncrona de elicitação no meio da chamada. Cada pergunta é + feita exatamente uma vez por chamada - uma garantia sobre a pergunta, não sobre o resolvedor. Na + forma com múltiplas idas e voltas, qualquer resolvedor pode rodar de novo sempre que a chamada é retomada depois de uma pergunta, + então o código antes de um `return Elicit(...)` roda em cada uma dessas rodadas; a resposta registrada então + satisfaz a pergunta repetida sem consultar o usuário outra vez. Uma resposta registrada só + é consultada quando o resolvedor pergunta; um resolvedor que responde *sem* perguntar, como + `check_stock`, sempre fornece o próprio valor calculado. Como cada resposta é associada de volta à + sua pergunta, um resolvedor que faz elicitação precisa derivar a pergunta de forma determinística a partir dos + argumentos da ferramenta e das respostas anteriores. Um valor gerado por chamada (um id de `default_factory`, um + timestamp) é derivado de novo a cada rodada e não pode aparecer em uma pergunta à qual a resposta deva + ficar vinculada. Uma pergunta montada com dados voláteis assim faz toda resposta registrada parecer obsoleta, + então o servidor a refaz a cada rodada até o limite de rodadas do cliente encerrar a chamada. + +## Pergunte ao cliente, não ao usuário {#ask-the-client-not-the-user} + +A elicitação é uma das três perguntas que um resolvedor pode fazer, e o fluxo com múltiplas idas e voltas não permite nenhuma outra. As outras duas vão para o **cliente**, e não para o usuário: retorne `Sample(...)` para executar uma chamada de LLM por meio do cliente (uma requisição `sampling/createMessage`), ou `ListRoots()` para buscar os roots (diretórios raiz) atuais do cliente. Nenhuma das duas tem desfecho de aceitar/recusar; o consumidor anota diretamente o tipo do resultado, `CreateMessageResult` (`CreateMessageResultWithTools` quando a requisição carrega `tools` ou `tool_choice`) ou `ListRootsResult`: + +```python title="server.py" hl_lines="10-15 21" +--8<-- "docs_src/dependencies/tutorial004.py" +``` + +* O framework roteia essas perguntas exatamente como `Elicit`: dentro do `tools/call` com múltiplas idas e voltas em **2026-07-28**, pela requisição independente servidor->cliente em **2025-11-25**. Uma capacidade não declarada recusa a chamada com um erro de protocolo `-32021` (`sampling`, `roots`, `elicitation` em modo formulário; `sampling.tools` quando a requisição carrega `tools` ou `tool_choice`). +* Tudo o que a caixa de informação acima diz sobre perguntas vale sem mudanças: uma requisição `Sample` é associada ao seu resultado registrado pela sua representação exata, então monte-a de forma determinística a partir dos argumentos da ferramenta e das respostas anteriores; assim o cliente paga pela chamada de LLM uma vez por chamada de ferramenta, não uma vez por rodada. O resultado registrado viaja no `request_state` pelo resto da chamada, então uma resposta de LLM muito grande deixa cada ida e volta restante mais pesada. +* As *funcionalidades* independentes de amostragem (sampling) e roots são descontinuadas em 2026-07-28 (SEP-2577). Servidores novos que precisam do modelo do cliente perguntam por este canal; servidores que não precisam devem se integrar diretamente a um provedor de LLM. Valores de `include_context` diferentes de `"none"` também estão descontinuados; evite-os. + +## Recapitulando {#recap} + +* `Annotated[T, Resolve(fn)]` em um parâmetro de ferramenta: o SDK executa `fn` e injeta o valor de retorno. +* Um parâmetro resolvido é invisível para o modelo e não pode ser fornecido por um cliente. Valores que o modelo não pode inventar - preços, identidades, permissões - entram aqui. +* Os parâmetros de um resolvedor são resolvidos do mesmo jeito: o `Context`, outro `Resolve(...)`, ou um argumento da ferramenta pelo nome. O grafo executa cada resolvedor no máximo uma vez por rodada, não importa quantos consumidores ele tenha; cada pergunta é feita exatamente uma vez, e qualquer resolvedor pode rodar de novo quando uma chamada é retomada depois de uma pergunta. +* Grafos ruins falham no registro com `InvalidSignature`, não no meio da chamada. +* Retorne `Elicit(message, Model)` para perguntar ao usuário, só quando for preciso. Anotações com o tipo puro abortam na recusa; `ElicitationResult[T]` deixa a ferramenta tratar cada desfecho. +* Retorne `Sample(...)` ou `ListRoots()` para pedir ao cliente uma resposta de LLM ou a lista de roots; o resultado é injetado diretamente. + +O estado que o seu servidor monta uma vez na inicialização, e como um handler chega até ele, é assunto da página **[Lifespan](lifespan.md)**. diff --git a/i18n/pt/pages/handlers/elicitation.md b/i18n/pt/pages/handlers/elicitation.md new file mode 100644 index 0000000000..7178e7ac09 --- /dev/null +++ b/i18n/pt/pages/handlers/elicitation.md @@ -0,0 +1,192 @@ +--- +translation: + sections: [335ca2a0b266f003, d1ad562d3fe87bc0, 0bb1396c86daeba4, d1cb1235bb9ee267, 833179c09d239c83, e5d6dec2d2e655e8] + tool: 1 +--- +# Elicitação {#elicitation} + +Uma ferramenta (tool) no meio do trabalho, à qual falta uma resposta, não precisa falhar. + +A **elicitação** (elicitation) permite que ela pergunte. No meio de uma chamada de ferramenta, o usuário recebe uma pergunta, e a resposta dele volta para dentro da mesma chamada de função. + +Existem dois modos: + +* **Modo formulário**: você precisa de um valor (uma confirmação, uma data, uma quantidade). Você descreve os campos, o cliente renderiza o formulário. +* **Modo URL**: você precisa que o usuário vá a outro lugar (uma tela de consentimento OAuth, uma página de pagamento). Nada do que ele fizer lá passa pelo protocolo. + +E existem duas formas de perguntar. A opção a preferir é um **resolvedor**: você pendura a pergunta em um parâmetro e o SDK pergunta - em qualquer conexão, seja qual for a era de protocolo que o cliente fale. A forma direta, `await ctx.elicit(...)`, é uma requisição do *servidor* para o *cliente*, um canal que só existe para um cliente em uma conexão legada (versão da especificação 2025-11-25 ou anterior). As duas estão nesta página; comece pelo resolvedor. + +## Pergunte com um resolvedor {#ask-with-a-resolver} + +Uma pergunta que condiciona a ferramenta inteira - *tem certeza? qual das três contas encontradas?* - pode ser tirada do corpo da ferramenta e colocada em um **resolvedor**, e o framework faz a pergunta por você. + +Um parâmetro anotado com `Annotated[T, Resolve(fn)]` é preenchido executando `fn` antes do corpo da ferramenta. O resolvedor retorna o valor diretamente quando já o conhece, ou retorna `Elicit(...)` para que o framework pergunte: + +```python title="server.py" hl_lines="24-30 35-36" +--8<-- "docs_src/elicitation/tutorial004.py" +``` + +* `confirm_delete` lê pelo nome o argumento `path` da própria ferramenta, lista a pasta e **só faz a elicitação quando precisa** - uma pasta vazia resolve para `Confirm(ok=True)` sem nenhuma ida e volta ao cliente. +* `delete_folder` anota `ElicitationResult[Confirm]`, então o framework injeta o resultado completo e a ferramenta trata cada caso com `match`: aceitou e confirmou, aceitou mas quer manter (`ok=False`), recusou, cancelou. +* O parâmetro `confirm` nunca aparece no schema de entrada da ferramenta - o cliente fornece `path`, o resolvedor fornece `confirm`. + +Quando a ferramenta não precisa ramificar, anote o modelo diretamente (`Annotated[Confirm, Resolve(confirm_delete)]`): ela recebe o modelo quando o usuário aceita, e a chamada é abortada com um erro quando ele recusa ou cancela. + +Um resolvedor funciona em **toda** conexão. Para um cliente em uma conexão legada, o SDK envia a pergunta diretamente a ele; em uma conexão **2026-07-28**, o SDK *retorna* a pergunta a partir da chamada, e a próxima tentativa do cliente traz a resposta. Seu resolvedor nunca percebe a diferença; o que acontece por baixo dos panos está em **[Requisições com múltiplas idas e voltas](multi-round-trip.md)**. + +Perguntar é só uma das coisas que um resolvedor pode fazer. O mecanismo geral - dependências que calculam sem perguntar, dependências de dependências, o que o modelo pode e não pode fornecer - é a página **[Dependências](dependencies.md)**. + +## Pergunte de dentro da ferramenta {#ask-from-inside-the-tool} + +Uma ferramenta também pode parar no meio do próprio corpo e perguntar. + +!!! warning + `ctx.elicit()` e `ctx.elicit_url()` são requisições do *servidor* para o *cliente* - um + canal que só existe para um cliente em uma conexão legada (versão da especificação + **2025-11-25** ou anterior). Em uma conexão **2026-07-28** não existem requisições + iniciadas pelo servidor, então essas chamadas falham. Um resolvedor funciona nas duas. + **[Versões do protocolo](../protocol-versions.md)** tem a história completa. + +`await ctx.elicit()` recebe uma mensagem e um modelo Pydantic: + +```python title="server.py" hl_lines="9-11 20-23 25" +--8<-- "docs_src/elicitation/tutorial001.py" +``` + +* É o parâmetro **`Context`** que dá acesso a `ctx.elicit`; qualquer ferramenta pode receber um. Esse objeto tem uma página própria: **[O Context](context.md)**. +* `AlternativeDate` é o **schema** da resposta que você quer. +* A ferramenta é `async def`. Tem que ser: ela para no meio e espera por uma pessoa. +* Em qualquer outra data, a ferramenta retorna na hora. Ela só pergunta quando precisa. +* A data que o usuário aceita passa de novo pela própria `book_table`. Uma resposta é uma entrada como qualquer outra: uma alternativa que também está lotada gera uma nova pergunta, em vez de ser confirmada às cegas. + +### O que o cliente recebe {#what-the-client-receives} + +O cliente recebe sua mensagem e, junto com ela, um JSON Schema gerado a partir do modelo: + +```json +{ + "properties": { + "accept_alternative": { + "description": "Try another date?", + "title": "Accept Alternative", + "type": "boolean" + }, + "date": { + "default": "2025-12-26", + "description": "Alternative date (YYYY-MM-DD)", + "title": "Date", + "type": "string" + } + }, + "required": ["accept_alternative"], + "title": "AlternativeDate", + "type": "object" +} +``` + +Esse schema é o formulário. `Field(description=...)` é o rótulo; um valor padrão pré-preenche a entrada e torna o campo opcional. É o mesmo mecanismo de Pydantic para JSON Schema que **[Ferramentas](../servers/tools.md)** descreve para os argumentos de uma ferramenta. + +!!! warning + Um schema de elicitação não é tão expressivo quanto o schema de entrada de uma ferramenta. + Só campos planos e primitivos: `str`, `int`, `float`, `bool` ou um `Literal` de strings + (que vira um `enum`). Coloque um modelo dentro do modelo e `ctx.elicit` lança uma exceção + antes de qualquer coisa ser enviada ao cliente: + + ```text + TypeError: Elicitation schema field 'address' rendered as {'$ref': '#/$defs/Address'}, which is not a valid PrimitiveSchemaDefinition + ``` + + Você está interrompendo uma pessoa no meio de uma tarefa. Se a resposta precisa de + aninhamento, ela deveria ter sido um argumento da ferramenta. + +### As três respostas {#the-three-answers} + +`result.action` diz o que o usuário fez, e existem exatamente três possibilidades: + +* `"accept"`: ele enviou o formulário. `result.data` é uma instância de `AlternativeDate`, já validada. +* `"decline"`: ele disse não. +* `"cancel"`: ele dispensou a pergunta sem escolher. + +`result.data` só existe em `"accept"`, e é por isso que o exemplo verifica `result.action` primeiro. Seu verificador de tipos garante essa ordem: depois de `result.action == "accept"`, `result.data` é um `AlternativeDate`; antes disso, `.data` simplesmente não existe. + +Uma recusa não é um erro. A ferramenta decide o que recusar significa (aqui, nenhuma reserva) e responde ao modelo normalmente. + +!!! tip + A resposta é validada contra seu modelo antes que seu código a veja. Um cliente que envia + `"maybe"` para um `bool` não corrompe sua reserva: a chamada falha com um erro de + incompatibilidade de schema, e seu `if` nem chega a executar. + +## Envie o usuário para uma URL {#send-the-user-to-a-url} + +Algumas coisas não devem passar pelo modelo nem pelo cliente: credenciais, números de cartão, consentimento OAuth. Para essas, você não pede dados; pede ao usuário que vá a algum lugar: + +```python title="server.py" hl_lines="10-14 23" +--8<-- "docs_src/elicitation/tutorial002.py" +``` + +* `ctx.elicit_url()` recebe a mensagem, a **URL** a visitar e um `elicitation_id` que você escolhe: qualquer string que identifique esta elicitação dentro do seu servidor. +* O resultado tem uma ação e mais nada. `"accept"` significa que o usuário concordou em abrir a URL, **não** que ele terminou o que está do outro lado. +* O pagamento acontece fora de banda, entre o navegador do usuário e seu provedor de pagamento. Nenhum conteúdo jamais volta pelo MCP. + +Observe a segunda ferramenta. Quando seu servidor fica sabendo que o fluxo fora de banda terminou (um webhook, um polling; aqui está modelado como uma segunda ferramenta), `ctx.session.send_elicit_complete(...)` envia `notifications/elicitation/complete` com o mesmo `elicitation_id`. É assim que o cliente sabe que pode parar de exibir *"aguardando pagamento..."*. Sem isso, o cliente só pode adivinhar. + +## O lado do cliente {#the-client-side} + +Servidores perguntam. Clientes respondem passando um **`elicitation_callback`** para `Client(...)`: + +```python title="client.py" hl_lines="6-7 18" +--8<-- "docs_src/elicitation/tutorial003.py" +``` + +* Um único callback trata os dois modos. `params` é uma união de `ElicitRequestFormParams` e `ElicitRequestURLParams`; o `isinstance` faz a ramificação. +* Para uma URL, você mostra `params.url` ao usuário e retorna a ação que ele escolheu. Nunca nenhum `content`. +* Para um formulário, uma aplicação real renderiza `params.requested_schema` e retorna a entrada do usuário como `content`. Este aqui sempre diz sim com uma resposta pronta, que é exatamente o callback que você quer em um teste. +* Passar o callback também é a **declaração de capacidade**: é assim que o servidor fica sabendo que pode perguntar a este cliente. As outras coisas que um cliente pode responder para um servidor estão em **[Callbacks do cliente](../client/callbacks.md)**. + +!!! info + A elicitação é uma requisição do *servidor* para o *cliente*, e requisições assim só + existem em uma sessão com handshake clássico; por isso este cliente passa `mode="legacy"`. + Em uma conexão **2026-07-28**, uma ferramenta pergunta *retornando* a pergunta a partir + da chamada; esse fluxo está em **[Requisições com múltiplas idas e voltas](multi-round-trip.md)**. + +### Experimente {#try-it} + +Inicie em Streamable HTTP o `server.py` do modo formulário com `ctx.elicit` (aquele da `book_table`) (**[Executando seu servidor](../run/index.md)** tem o comando de uma linha), depois execute a `main()` do cliente e peça à `book_table` o dia de Natal. + +O callback imprime a pergunta que recebeu: + +```text +No tables for 2 on 2025-12-25. Would you like to try another date? +``` + +Ele responde com `{"accept_alternative": True, "date": "2025-12-27"}`, e a ferramenta, que ficou esperando dentro de `await ctx.elicit(...)` esse tempo todo, conclui a reserva: + +```text +Booked a table for 2 on 2025-12-27. +``` + +Agora troque para o `server.py` do modo URL e aponte a mesma `main()` para `pay_deposit`: o mesmo callback segue pelo outro ramo, imprime o link de pagamento, e a ferramenta volta com *"Complete the payment in your browser."* Uma ida e volta, no meio da chamada, nos dois sentidos. + +!!! check + Agora remova `elicitation_callback=` do `Client` e chame `book_table` para o dia de Natal + outra vez. A chamada inteira falha com um erro de protocolo: + + ```text + Elicitation not supported + ``` + + Um cliente que não registrou nenhum callback nunca declarou a capacidade `elicitation`, + então não há a quem perguntar. Sua ferramenta não recebeu um `"decline"`; recebeu uma + exceção. Projete pensando nisso: toda elicitação precisa de uma resposta sensata para + "e se eu não puder perguntar?". + +## Recapitulando {#recap} + +* Um parâmetro anotado com `Annotated[T, Resolve(fn)]` é preenchido por um resolvedor, que retorna `Elicit(...)` quando precisa perguntar. Funciona em toda conexão. +* O schema é um modelo Pydantic plano: só campos primitivos, validados na volta. +* `result.action` é `"accept"`, `"decline"` ou `"cancel"`; `result.data` só existe quando o usuário aceita. +* `await ctx.elicit(message, schema=Model)` pergunta de dentro do corpo da ferramenta, e `await ctx.elicit_url(message, url, elicitation_id)` serve para tudo o que não deve passar pelo modelo (`ctx.session.send_elicit_complete(elicitation_id)` avisa que a parte fora de banda terminou). As duas são requisições do servidor para o cliente: precisam do cliente em uma conexão legada. +* O cliente responde com um único `elicitation_callback`, ramificando pelo tipo dos params; registrá-lo é o que declara a capacidade. +* Em uma conexão 2026-07-28, o servidor retorna a pergunta em vez de empurrá-la; o mesmo callback é alimentado por **[Requisições com múltiplas idas e voltas](multi-round-trip.md)**. + +Tudo o que fica por baixo desse retorno (o loop de novas tentativas, a proteção do `requestState`, conduzir o fluxo por conta própria) está em **[Requisições com múltiplas idas e voltas](multi-round-trip.md)**. diff --git a/i18n/pt/pages/handlers/index.md b/i18n/pt/pages/handlers/index.md new file mode 100644 index 0000000000..cfef268d4c --- /dev/null +++ b/i18n/pt/pages/handlers/index.md @@ -0,0 +1,38 @@ +--- +translation: + sections: [424930166c4bc6f3] + tool: 1 +--- +# Dentro do seu handler {#inside-your-handler} + +Os argumentos de um handler vêm do cliente. Todo o *resto* que ele pode ler, e +tudo o que ele pode fazer enquanto executa, está aqui. + +O que ele pode ler: + +* **[O Context](context.md)** é o único parâmetro extra que qualquer handler + pode pedir: a requisição em andamento, seus cabeçalhos, sua sessão e os + verbos de progresso e de notificação de mudanças. +* **[Dependências](dependencies.md)** são parâmetros que o modelo nunca vê, + preenchidos pelas suas próprias funções com `Resolve`. +* **[Lifespan](lifespan.md)** trata do estado que seu servidor monta uma única + vez na inicialização e de como um handler chega até ele por meio do + `Context`. + +O que ele pode fazer enquanto executa: + +* Pedir mais informações ao usuário com **[Elicitação](elicitation.md)** + (elicitation) e com **[Requisições de múltiplas idas e voltas](multi-round-trip.md)**, + o padrão de 2026-07-28 que a transporta. +* Pedir ao cliente uma completion de LLM ou as pastas do seu workspace com + **[Amostragem (sampling) e roots](sampling-and-roots.md)**, obsoletos, mas + ainda atendidos. +* Informar o **[Progresso](progress.md)** de algo demorado. +* Escrever logs (na saída de erro padrão, para quem opera o servidor) com + **[Logging](logging.md)**. +* Avisar os clientes assinantes de que algo mudou com + **[Assinaturas](subscriptions.md)**. + +Se você ainda não registrou um handler, comece por +**[Ferramentas](../servers/tools.md)**. Todas as páginas aqui pressupõem que +você já tem um. diff --git a/i18n/pt/pages/handlers/lifespan.md b/i18n/pt/pages/handlers/lifespan.md new file mode 100644 index 0000000000..e710a3b8cb --- /dev/null +++ b/i18n/pt/pages/handlers/lifespan.md @@ -0,0 +1,107 @@ +--- +translation: + sections: [f3ca8ac5f90f2dfa, 85a1ef3588ba0736, 563346d4d5804933, 9e3528340d0bab53] + tool: 1 +--- +# Lifespan {#lifespan} + +A maioria dos servidores reais mantém alguma coisa durante a vida inteira: um pool de banco de dados, um cliente HTTP, um modelo carregado. + +Você não quer construir isso a cada chamada, e quer fechar tudo de forma limpa. É para isso que serve o **lifespan** (ciclo de vida do servidor). + +## Um lifespan tipado {#a-typed-lifespan} + +Um lifespan é um `@asynccontextmanager` que recebe o servidor e faz `yield` de **um único objeto**. Seja qual for o objeto que você entregar, ele fica disponível para todos os handlers enquanto o servidor estiver rodando. + +```python title="server.py" hl_lines="25-31 34 38 40" +--8<-- "docs_src/lifespan/tutorial001.py" +``` + +Leia de baixo para cima: + +* `app_lifespan` conecta o `Database` **antes** do `yield` e o desconecta **depois**, dentro de um `finally`. Isso é a inicialização e o encerramento. +* Ele entrega um `AppContext`, uma dataclass comum que agrupa o que você configurou. Um campo hoje, dez amanhã. +* `MCPServer("Bookshop", lifespan=app_lifespan)` é toda a ligação necessária. +* Dentro da ferramenta (tool), o objeto entregue é `ctx.request_context.lifespan_context`. + +O lifespan executa **uma vez**. O servidor entra nele ao iniciar (antes da primeira requisição) e sai dele ao parar. Todas as requisições nesse intervalo compartilham o mesmo `AppContext`. + +!!! info + Se você já escreveu um `lifespan` do FastAPI, já conhece isso. Mesmo decorador, mesmo `yield`, mesmo `finally`. + +### O que o modelo vê {#what-the-model-sees} + +Nada de novo. `ctx` é um parâmetro **Context**, então o SDK o injeta e ele nunca chega ao schema de entrada: + +```json +{ + "type": "object", + "properties": { + "genre": {"title": "Genre", "type": "string"} + }, + "required": ["genre"], + "title": "count_booksArguments" +} +``` + +`genre` é o único argumento que o modelo pode passar. O lifespan é assunto do seu servidor. + +Funções `@mcp.resource()` e `@mcp.prompt()` também podem receber um parâmetro `ctx`, escrito como um `Context` puro por um motivo que a próxima seção explica. Tudo o que `ctx` carrega está em **[O Context](context.md)**. + +### É tipado de verdade {#it-really-is-typed} + +Olhe de novo a anotação: `ctx: Context[AppContext]`. + +Esse único parâmetro de tipo é o motivo pelo qual `ctx.request_context.lifespan_context` **é** um `AppContext` para o seu verificador de tipos. `.db` autocompleta; `.dbb` é um erro antes mesmo de você executar o servidor. + +Escreva um `Context` puro no lugar e `lifespan_context` passa a ser tipado como `dict[str, Any]`: o verificador de tipos não tem como saber o que o seu lifespan entregou. O objeto continua lá em tempo de execução; o que você perdeu foi a ajuda. + +!!! warning + `Context[AppContext]` é uma grafia **só para ferramentas**. Coloque-a em uma função + `@mcp.resource()` ou `@mcp.prompt()` e toda chamada a esse handler falha. O cliente recebe um + erro de volta, e o log do servidor mostra o porquê: + + ```text + Context is not available outside of a request + ``` + + Em recursos e prompts, escreva o `ctx: Context` puro. O objeto que o seu lifespan entregou + continua sendo `ctx.request_context.lifespan_context` em tempo de execução; você abre mão do + parâmetro de tipo, não do objeto. + +!!! tip + Sempre existe um lifespan. Se você não passar um, o padrão do SDK entrega um `dict` vazio, + então `ctx.request_context.lifespan_context` é `{}`, nunca `None`. Esse padrão também é o + motivo de um `Context` puro tipá-lo como `dict[str, Any]`. + +## Veja acontecer {#watch-it-happen} + +"A inicialização roda antes da primeira requisição" é o tipo de afirmação em que você não deveria ter que acreditar sem ver. + +Reduza o servidor só ao ciclo de vida: dê ao `Database` uma flag `connected`, inverta-a em `connect()` e `disconnect()`, e adicione uma ferramenta que informe o valor dela. + +```python title="server.py" hl_lines="11 14 17 25 44" +--8<-- "docs_src/lifespan/tutorial002.py" +``` + +`database` fica no nível do módulo por um único motivo: para que você possa observá-lo de *fora* do servidor. + +!!! check + Três momentos, três valores: + + * Antes de o servidor iniciar, `database.connected` é `False`. Importar o módulo não conectou nada. + * Enquanto ele está rodando, chame `database_status` e o resultado é `"connected"`. + * Pare o servidor e o bloco `finally` executa: `database.connected` é `False` de novo. + + O trabalho aconteceu exatamente onde você o colocou: em volta do `yield`, não na importação e nem a cada requisição. + +## Recapitulando {#recap} + +* `lifespan=` aceita um `@asynccontextmanager` que recebe o servidor e faz `yield` de um único objeto. +* O código antes do `yield` é a inicialização. O `finally` depois dele é o encerramento. +* Ele executa uma vez, em torno da vida inteira do servidor, não a cada requisição. +* O que você entregar no `yield` é `ctx.request_context.lifespan_context` em toda ferramenta, recurso e prompt. +* `ctx: Context[AppContext]` deixa esse acesso totalmente tipado em ferramentas. Recursos e prompts recebem o `Context` puro. +* Não passar `lifespan=` significa um `dict` vazio, nunca `None`. + +Um handler que para no meio da chamada para perguntar ao usuário algo que só ele sabe é **[Elicitação (elicitation)](elicitation.md)**. diff --git a/i18n/pt/pages/handlers/logging.md b/i18n/pt/pages/handlers/logging.md new file mode 100644 index 0000000000..f677986a85 --- /dev/null +++ b/i18n/pt/pages/handlers/logging.md @@ -0,0 +1,86 @@ +--- +translation: + sections: [c93a3e1aefd77955, 7851abd5ec54393b, f49d1ca2f330f9cd, c03764bd9dfeef7b, 4a0391691a674ae4, 2df5cd279eabf9f5] + tool: 1 +--- +# Logging {#logging} + +Faça log de uma ferramenta (tool) do mesmo jeito que faz de qualquer outra função Python: com a biblioteca padrão. + +O MCP tem uma **capacidade de logging** no nível do protocolo: um servidor poderia enviar suas mensagens de log para o cliente como notificações, por meio de métodos do objeto `Context`. A revisão 2026-07-28 da especificação **torna essa capacidade obsoleta e não a substitui**, por isso esta documentação não a ensina. A lista completa do que foi marcado como obsoleto e do que fazer no lugar está em **[Funcionalidades obsoletas](../deprecated.md)**. + +O que você faz no lugar é o que faz em qualquer outro programa Python: a biblioteca padrão. + +## Uma ferramenta que faz log {#a-tool-that-logs} + +```python title="server.py" hl_lines="1 5 13" +--8<-- "docs_src/logging/tutorial001.py" +``` + +* `logging.getLogger(__name__)` dá a você um logger com o nome do seu módulo. Crie-o uma vez, no topo. +* Dentro da ferramenta você chama `logger.info(...)` como em qualquer outra função. Nada para injetar, nada para fazer `await`, nada específico do MCP. + +!!! check + Chame a ferramenta e olhe o resultado inteiro: + + ```python + result.content # [TextContent(text="Found 3 books matching 'dune'.")] + result.structured_content # {'result': "Found 3 books matching 'dune'."} + ``` + + A linha de log não aparece em lugar nenhum. O logging é para **você**, a pessoa que opera o servidor. O modelo + nunca o vê. Se o modelo precisa ler alguma coisa, faça `return` dela. + +## Para onde vai {#where-it-goes} + +Para um servidor **stdio**, essa pergunta importa mais do que o normal. O host iniciou seu servidor como um subprocesso e está lendo mensagens MCP do **stdout** dele. O erro padrão (stderr) é seu. + +A biblioteca padrão já faz a coisa certa: a saída de log vai para `sys.stderr` por padrão. Suas linhas de `logger.info(...)` caem no terminal (ou onde quer que o host colete o stderr do subprocesso), e o fluxo do protocolo fica limpo. + +!!! tip + Não use `print()` em um servidor stdio. `print` escreve no **stdout**, e o stdout pertence ao protocolo. + Enquanto serve, o SDK desvia para o stderr o stdout que de fato recebe *flush*, então ele não consegue corromper a + comunicação, mas um `print()` em um processo com buffer em bloco costuma ficar sem flush no buffer de `sys.stdout` + até o interpretador esvaziá-lo na saída, direto no fluxo do protocolo. Mesmo quando é desviada, + a linha cai crua no meio da saída de log, sem nível, sem nome de logger e sem jeito de filtrá-la. + + `logger.debug("got here")` dá o mesmo trabalho de uma linha e vai para o lugar certo. + +## O nível {#the-level} + +Você não precisa chamar `logging.basicConfig()` por conta própria. Construir um `MCPServer` já fez isso, com um handler apontado para o erro padrão, no nível que você passa em `log_level=`, então `MCPServer("Bookshop", log_level="DEBUG")` é tudo o que precisa para ver suas linhas de `logger.debug(...)`. + +O padrão é `"INFO"`. + +`logging.basicConfig()` nunca substitui handlers que já existem. Se você configurar o logging por conta própria antes de criar o servidor, sua configuração vence. + +## Experimente {#try-it} + +Execute o servidor com o MCP Inspector: + +```console +uv run mcp dev server.py +``` + +Chame `search_books` na aba **Tools**. O Inspector mostra o resultado: apenas o valor de retorno. A linha + +```text +Searching for 'dune' +``` + +foi para o erro padrão: o terminal, não a comunicação com o cliente. + +!!! info + Se o que você quer de verdade é *tracing* (cada requisição, quanto tempo levou, se falhou), você + não quer linhas de log, quer spans. Seu servidor já os emite: o SDK faz tracing de cada + mensagem com OpenTelemetry por padrão. Veja **[OpenTelemetry](../run/opentelemetry.md)**. + +## Recapitulando {#recap} + +* A capacidade de logging do protocolo MCP foi tornada obsoleta pela especificação 2026-07-28 e não foi substituída. Não construa em cima dela. +* `logger = logging.getLogger(__name__)` no nível do módulo, `logger.info(...)` na ferramenta. O padrão inteiro é esse. +* A saída de log nunca chega ao modelo. Só o valor que você faz `return` chega. +* O erro padrão é seu; o stdout pertence ao protocolo. O SDK desvia para o stderr o stdout perdido que recebe flush enquanto serve, mas um `print()` sem flush ainda pode vazar para a comunicação na saída, e as linhas desviadas chegam sem rótulo; use `logging`, cujo handler faz flush de cada registro. +* `MCPServer(..., log_level="DEBUG")` define o nível, e uma configuração de logging que você tenha feito antes fica intacta. + +Avisar os clientes conectados de que algo no seu servidor mudou (a lista de ferramentas, um recurso) é assunto de **[Assinaturas](subscriptions.md)**. diff --git a/i18n/pt/pages/handlers/multi-round-trip.md b/i18n/pt/pages/handlers/multi-round-trip.md new file mode 100644 index 0000000000..77432a5764 --- /dev/null +++ b/i18n/pt/pages/handlers/multi-round-trip.md @@ -0,0 +1,191 @@ +--- +translation: + sections: [74011e683045eea9, 9b64cc175c18b6a9, 4b41be4824030397, e3b1502da786ec33, 71e41161f143c6a9, 9ec2c1eeb8c36378, 8dd027377d46448b, f81491125dcbfe8b] + tool: 1 +--- +# Requisições com múltiplas idas e voltas {#multi-round-trip-requests} + +Às vezes uma ferramenta (tool) não consegue terminar em uma única ida e volta. Ela precisa de algo que só o usuário tem: uma escolha, uma confirmação, uma credencial. + +Antes de 2026-07-28 o servidor conseguia isso chamando **de volta**: abria sua própria requisição para o cliente (uma elicitação (elicitation), uma chamada de amostragem (sampling)) no meio do tratamento da requisição original. A especificação 2026-07-28 aposenta esse canal de retorno (back-channel). + +Em vez disso, o servidor **retorna**. + +## Retorne, não chame de volta {#return-dont-call-back} + +O servidor responde a `tools/call` com um **`InputRequiredResult`** no lugar de um `CallToolResult`. Dois dos seus campos fazem o trabalho: + +* **`input_requests`**: o que o servidor ainda precisa, como um dict cujas chaves são nomes que o próprio servidor escolheu. Cada valor é um `ElicitRequest`, um `CreateMessageRequest` ou um `ListRootsRequest`. +* **`request_state`**: um token opaco. O cliente o devolve ao pé da letra na nova tentativa. Seu servidor é o único que o lê. + +O cliente atende cada requisição e então chama a **mesma ferramenta de novo**, levando suas respostas em `input_responses` e o token em `request_state`. Agora o servidor tem o que faltava e retorna um `CallToolResult` normal. + +O protocolo inteiro é esse. Cada trecho é uma requisição comum do cliente para o servidor. Nada jamais flui no sentido contrário. + +## O lado do servidor {#the-server-side} + +Em `@mcp.tool()` você raramente monta isso à mão: declare uma dependência que pergunta ao usuário (`Elicit`), faz amostragem no LLM do cliente (`Sample`) ou lista os roots dele (`ListRoots`) e o SDK retorna o `InputRequiredResult` por você; essa forma está na página **[Dependências](dependencies.md)**. As duas formas não se misturam: uma chamada tem um único canal `input_responses`/`request_state`, então uma ferramenta que usa parâmetros `Resolve(...)` não pode também retornar `InputRequiredResult` do seu corpo. Um retorno `InputRequiredResult` declarado é rejeitado no registro (`InvalidSignature`), e um não declarado faz a chamada falhar em tempo de execução. A forma manual é o `Server` de **baixo nível**, cujo handler `on_call_tool` pode retornar qualquer um dos dois tipos de resultado: + +```python title="server.py" hl_lines="43-46" +--8<-- "docs_src/mrtr/tutorial001.py" +``` + +* `on_call_tool` tem o tipo `-> CallToolResult | InputRequiredResult`. Retornar o segundo é a API inteira do lado do servidor. +* Na primeira chamada `params.input_responses` é `None`, então a guarda dispara e o handler pergunta em vez de responder. +* Na nova tentativa, o `ElicitResult` que o cliente enviou está sob a **mesma chave** (`"region"`) que o servidor usou em `input_requests`. + +Todo o resto naquele arquivo (o `input_schema` explícito, o `CallToolResult` montado à mão) é o `Server` de baixo nível comum, coberto em **[O Server de baixo nível](../advanced/low-level-server.md)**. Esta página só acrescenta o segundo tipo de retorno. + +## Além das ferramentas {#beyond-tools} + +`tools/call` não é especial: em 2026-07-28 um servidor pode responder a `prompts/get` e `resources/read` do mesmo jeito. No `MCPServer`, uma função `@mcp.prompt()` — ou uma função de **template** `@mcp.resource()` — retorna ela mesma o `InputRequiredResult` e lê as respostas da nova tentativa no contexto: + +```python title="server.py" hl_lines="20 22 24" +--8<-- "docs_src/mrtr/tutorial004.py" +``` + +* A primeira rodada retorna o `InputRequiredResult`. Na nova tentativa, `ctx.input_responses` traz as respostas sob as mesmas chaves e a função retorna seu resultado comum — mensagens de prompt aqui, conteúdo de recurso para um recurso de template. +* Um `request_state` que você define é selado antes de passar pela rede e verificado no eco, como todo o resto no servidor; **[Protegendo o `requestState`](#protecting-requeststate)** mais abaixo cobre o que o selo oferece e quando você precisa configurar chaves. +* Uma função `@mcp.tool()` pode retornar o resultado diretamente do mesmo jeito, quando a forma por dependência não serve. +* Funções `@mcp.resource()` estáticas não participam: elas não recebem `Context`, então nunca poderiam ler a nova tentativa. Só recursos de template podem perguntar. +* As regras de era mais abaixo valem sem mudança: retornar um `InputRequiredResult` em uma sessão pré-2026 é o mesmo `-32603` que o aviso descreve. + +## O lado do cliente {#the-client-side} + +O `Client` executa o loop por você. + +Registre os callbacks que o servidor pode pedir (`elicitation_callback`, `sampling_callback`, `list_roots_callback`) e chame a ferramenta. Quando chega um `InputRequiredResult`, o `Client` despacha cada entrada de `input_requests` para o callback correspondente, tenta de novo com as respostas e o `request_state` ecoado, e segue em frente até que um `CallToolResult` volte: + +```python title="client.py" hl_lines="11 12" +--8<-- "docs_src/mrtr/tutorial003.py" +``` + +* Esse `elicitation_callback` é o mesmo que o `elicitation/create` do canal de retorno de um servidor pré-2026 teria acionado. O mesmo vale para `sampling_callback` em relação a `sampling/createMessage` e para `list_roots_callback` em relação a `roots/list`: em 2026-07-28 os RPCs avulsos servidor->cliente deixaram de existir, mas os mesmíssimos payloads `ElicitRequest` / `CreateMessageRequest` / `ListRootsRequest` viajam dentro de `input_requests` e são despachados para os mesmos três callbacks. Um único conjunto de callbacks atende às duas eras. +* `call_tool` retorna um `CallToolResult` simples. As rodadas intermediárias são invisíveis para quem chama. +* `get_prompt` e `read_resource` conduzem o mesmo loop. + +!!! check + Deixe o callback de fora e o loop falha na primeira rodada: o callback substituto do SDK + responde a toda elicitação com um erro, e `call_tool` lança `MCPError` com a mensagem + *"Elicitation not supported"*. + +O loop tem limite. `Client(..., input_required_max_rounds=10)` é o teto padrão; um servidor que continua retornando `InputRequiredResult` além dele faz `call_tool` lançar. Se uma rodada traz apenas `request_state` e nenhum `input_requests`, o `Client` dorme brevemente (50 ms, dobrando até um teto de 250 ms) antes de tentar de novo, de modo que um servidor que está só dizendo *"ainda não terminei"* não sofre busy-polling. + +### Conduzindo o loop você mesmo {#driving-the-loop-yourself} + +O loop automático basta para um cliente de processo único. Assuma o loop você mesmo quando: + +* Seu cliente é **distribuído**: o processo que exibe a pergunta ao usuário não é o processo que chamou `call_tool`, então um worker diferente emite a nova tentativa. `request_state` é o token persistível que você carrega através dessa fronteira, pelo seu próprio armazenamento, e `input_responses` é o que o outro lado envia de volta junto com ele. +* Você quer **inspecionar** cada rodada: registrar em log ou auditar cada entrada de `input_requests`, recusar certos tipos de requisição ou aplicar seu próprio backoff entre os trechos. +* Você quer um limite de **relógio** em vez de um limite por contagem de rodadas: envolva seu próprio loop em `anyio.fail_after(...)` em vez de depender de `input_required_max_rounds`. + +Desça para a sessão subjacente, onde `allow_input_required=True` entrega a união diretamente: + +```python title="client.py" hl_lines="12 13 19" +--8<-- "docs_src/mrtr/tutorial002.py" +``` + +* `client.session.call_tool(..., allow_input_required=True)` amplia o tipo de retorno para `CallToolResult | InputRequiredResult`. O `isinstance` é o que o estreita de volta. +* `request_state` agora está nas suas mãos. Anote-o entre os trechos e a conversa pode ser retomada a partir de um processo novo. +* Para cada entrada em `input_requests` você coloca um `InputResponse` sob a **mesma chave** em `input_responses`. `fulfil` é onde entra sua UI; esta aqui fixa a resposta no código. +* Mesmo nome de ferramenta, mesmos `arguments`, em todo trecho. A nova tentativa é a chamada original realizada de novo, não um método novo. + +## Protegendo o `requestState` {#protecting-requeststate} + +Tudo acima trata `request_state` como um eco, e na rede é só isso mesmo. Mas o cliente o guarda entre os trechos (anotá-lo entre processos é exatamente o que a seção anterior aprovou), então o que volta é **entrada fornecida pelo cliente**: pode estar modificada, expirada ou ter sido retirada de uma chamada completamente diferente. A especificação exige que os servidores protejam a integridade desse estado e rejeitem a rodada quando a verificação falhar, sempre que o estado puder influenciar autorização, acesso a recursos ou lógica de negócio. + +O `MCPServer` o protege por padrão. Todo servidor sela o `requestState` de saída e verifica todo eco — estado de resolvedor e estado montado à mão do mesmo jeito — sob uma chave gerada na inicialização do processo. Você não configura nada, escreve texto puro e lê texto puro; pela rede só passa um token opaco e criptografado. + +A chave padrão vive e morre com o processo, e essa é a única coisa que você precisa saber antes de fazer o deploy além de um único processo: + +```python +from mcp.server.mcpserver import MCPServer, RequestStateSecurity + +# Multi-instance or restart-surviving: one or more shared secret keys (>= 32 bytes each). +mcp = MCPServer("fleet", request_state_security=RequestStateSecurity(keys=[key])) +``` + +* **O padrão (sem configuração)** serve para um único processo: stdio, ou exatamente um worker HTTP. Uma nova tentativa que cai em um worker diferente, em uma instância diferente atrás de um balanceador de carga ou no mesmo servidor depois de um reinício está selada sob uma chave que aquele processo não tem — o cliente recebe a rejeição fixa mostrada abaixo e precisa recomeçar o fluxo. +* **`keys=[...]`** é obrigatório sempre que uma nova tentativa pode chegar a uma **instância diferente** (`uvicorn` com múltiplos workers, HTTP com balanceamento de carga) ou precisa sobreviver a reinícios: cada instância verifica o que qualquer irmã emitiu. Mesma engrenagem, seu segredo em vez de um gerado. +* Para sua própria criptografia, como um KMS ou um serviço de tokens existente, passe `RequestStateSecurity(codec=...)` em vez de `keys`; **[Traga sua própria criptografia](#bring-your-own-crypto)** mais abaixo cobre o contrato. + +### O que o selo carrega {#what-the-seal-carries} + +Padrão ou configurado, o `requestState` na rede é um token criptografado e autenticado. Seu código nunca o vê: handlers e resolvedores escrevem texto puro e leem texto puro (`ctx.request_state`); o SDK sela na saída e verifica na entrada. Além da integridade, cada token fica vinculado a: + +* **Uma janela de tempo.** Cada rodada sela de novo com uma expiração nova, então `RequestStateSecurity(ttl=...)` (padrão de 600 segundos) limita o tempo de reflexão por rodada, não o fluxo inteiro. +* **O principal autenticado.** Quando a requisição carrega um token de acesso OAuth que o SDK validou, o estado fica vinculado ao cliente, ao emissor e ao sujeito do token: estado emitido para um usuário falha sob outro, mesmo quando os dois compartilham um único cliente OAuth. Um verificador que não fornece sujeito degrada o vínculo para apenas a identidade do cliente, que com IDs de cliente baseados em URL é compartilhada por todos os usuários daquele software cliente. Quando a autenticação é encerrada fora do SDK (um proxy na frente), ou o transporte não é autenticado, não há principal a vincular e essa verificação fica inerte, a menos que `RequestStateSecurity(bind_principal=...)` forneça um a partir do seu próprio sinal de identidade. Quaisquer que sejam os componentes que seu verificador de tokens forneça, ele precisa fornecê-los de forma consistente: um verificador que inclui o sujeito em algumas requisições e o omite em outras muda o principal no meio do fluxo, e as rodadas em andamento são rejeitadas. +* **A requisição de origem.** O método, o nome da ferramenta ou do prompt (ou a URI do recurso) e um digest dos argumentos. Um token reproduzido contra uma ferramenta diferente, argumentos diferentes ou um método diferente falha. +* **A pergunta exata que foi feita.** Toda resposta de resolvedor fica presa à pergunta renderizada que foi mostrada ao cliente, tanto na rodada em que ela chega pela primeira vez quanto quando uma resposta gravada é reutilizada depois. Faça um novo deploy com uma mensagem reformulada ou um schema alterado e o servidor pergunta de novo em vez de consumir uma resposta obsoleta. A mesma amarração também corta no outro sentido: derive as mensagens dos argumentos da ferramenta, não de dados que variam a cada chamada. Uma mensagem montada a partir de um timestamp ou de uma cotação ao vivo renderiza diferente a cada rodada, então toda resposta gravada parece obsoleta e o servidor pergunta de novo até que o limite de rodadas do cliente encerre a chamada. + +Tudo isso é trabalho do SDK, não seu, e nem do codec se você trouxer o seu. + +### Rotacionando chaves {#rotating-keys} + +`keys[0]` sela estado novo; toda chave da lista verifica. A rotação sem downtime tem três fases, cada uma totalmente distribuída antes da próxima: + +```python +RequestStateSecurity(keys=[OLD, NEW]) # 1: every instance learns to verify NEW; OLD still mints +RequestStateSecurity(keys=[NEW, OLD]) # 2: NEW mints; in-flight OLD state keeps verifying +RequestStateSecurity(keys=[NEW]) # 3: one ttl after phase 2 is fully out, retire OLD +``` + +Nunca promova o emissor primeiro: emitir sob uma chave que alguma instância ainda não sabe verificar derruba rodadas em andamento no meio da distribuição. + +As chaves têm escopo de um único serviço. O envelope selado também carrega o nome do servidor como uma declaração de audiência, então um token emitido por um serviço diferente que por acaso compartilha um segredo é rejeitado mesmo assim. A declaração é tão distintiva quanto o nome, então um servidor que recebe uma política explícita precisa ter um nome de verdade ou definir `RequestStateSecurity(audience=...)` — um sem nome lança na construção. `audience=` também atende topologias multisserviço deliberadas em que um serviço precisa aceitar estado que outro emitiu. (O padrão sem configuração está isento: sua chave nunca sai do processo, então a declaração de audiência não tem nada a acrescentar.) + +### Traga sua própria criptografia {#bring-your-own-crypto} + +`RequestStateSecurity(codec=...)` aceita qualquer coisa com `seal(bytes) -> str` e `unseal(str) -> bytes` que lance `InvalidRequestState` para qualquer token que não tenha emitido. O formato clássico é a criptografia de envelope contra um KMS, em que você desembrulha uma chave de dados uma vez na inicialização e mantém a criptografia por token local: + +```python title="server.py" hl_lines="12 26-27 34-35 38" +--8<-- "docs_src/mrtr/tutorial005.py" +``` + +TTL, vínculo de principal e vínculo de requisição **não** são trabalho do codec: o SDK os grava no payload antes de `seal` e os verifica de novo depois de `unseal`, para todo codec. As únicas obrigações de um codec são integridade (adulterado significa lançar) e, idealmente, confidencialidade. + +### Quando a verificação falha {#when-verification-fails} + +Toda falha de entrada, seja token adulterado, expirado, reproduzido contra uma requisição ou principal diferente, ou selado sob uma chave que este servidor não conhece, recebe a mesma resposta: + +```json +{"code": -32602, "message": "Invalid or expired requestState"} +``` + +Uma única mensagem fixa para toda causa, de modo que a rede nunca revela qual verificação falhou; o motivo real vai para o log do servidor. Todo `requestState` de entrada em `tools/call`, `prompts/get` e `resources/read` é verificado, inclusive um que chega para um handler que nunca emite estado. A rejeição mais comum na prática não é um atacante — é a chave padrão local ao processo encontrando uma nova tentativa de antes de um reinício ou de outra instância; o cliente recomeça o fluxo, e `keys=[...]` é a correção quando isso importa. + +### Estado montado à mão {#hand-built-state} + +Um `request_state` que você mesmo define (retornando `InputRequiredResult` de uma função de ferramenta, prompt ou template de recurso) é selado e verificado pela mesma engrenagem do estado de resolvedor, sem nenhuma mudança de código: escreva texto puro, leia texto puro, e todo vínculo acima se aplica. + +A única coisa que o SDK não consegue amarrar por você, mesmo configurado, é a identidade da pergunta: ele não sabe a qual das *suas* perguntas pertence uma resposta no seu estado. Se você armazena respostas indexadas por pergunta, inclua seu próprio identificador de pergunta no estado e confira-o na nova tentativa. + +O `Server` de baixo nível é o nível sem pilhas inclusas: diferente do `MCPServer`, nada é selado até que você mesmo acrescente a fronteira, e seu `request_state` atravessa a rede exatamente como foi escrito até você fazer isso. O opt-in de uma linha aparece em **[O Server de baixo nível](../advanced/low-level-server.md#the-other-handlers)**. + +## Um resultado de 2026-07-28 {#a-2026-07-28-result} + +`InputRequiredResult` só existe na versão de protocolo **2026-07-28**. O `Client(server)` em memória a negocia por você; pela rede, `mode="auto"` a descobre. Depois de conectar, `client.protocol_version` diz o que você obteve. + +!!! warning + Uma sessão pré-2026 não tem onde colocar um `InputRequiredResult`. Retorne um do seu handler em uma + conexão `mode="legacy"` e o executor não consegue serializá-lo na versão negociada; o + cliente recebe de volta um erro `-32603` *"Handler returned an invalid result"*. Um servidor que atende + às duas eras precisa conferir `ctx.protocol_version` antes de recorrer a ele. + +!!! info + A **elicitação em modo URL** usa exatamente esse mecanismo em uma conexão 2026. A entrada em + `input_requests` é um `ElicitRequest` cujos params são `ElicitRequestURLParams`; o usuário + termina o fluxo fora de banda e seu cliente tenta a chamada de novo. Mesmo loop, nenhuma API nova. A + metade do servidor de alto nível está em **[Elicitação](elicitation.md)**. + +## Recapitulando {#recap} + +* Em 2026-07-28 um servidor que precisa de entrada no meio de uma chamada **retorna** um `InputRequiredResult`. Ele nunca abre uma requisição para o cliente. +* `input_requests` é o que ele precisa. `request_state` é um token opaco de retomada que só o servidor lê. +* O `Client` executa o loop de novas tentativas por você: registre `elicitation_callback` / `sampling_callback` / `list_roots_callback` e `call_tool` retorna um `CallToolResult` simples. `input_required_max_rounds` (padrão 10) o limita. +* Para inspecionar ou persistir rodadas, use `client.session.call_tool(..., allow_input_required=True)` e assuma você mesmo o loop `while isinstance(result, InputRequiredResult)`. +* Em `@mcp.tool()`, uma dependência que pergunta ao usuário produz esse resultado por você (**[Dependências](dependencies.md)**); o `Server` de **baixo nível** é a forma manual. +* Prompts e recursos também participam: uma função `@mcp.prompt()` ou `@mcp.resource()` de template retorna ela mesma o `InputRequiredResult` e lê `ctx.input_responses` na nova tentativa. +* O `requestState` volta como entrada fornecida pelo cliente, então o `MCPServer` o sela por padrão — estado de resolvedor e estado montado à mão do mesmo jeito — sob uma chave local ao processo; deploys com múltiplas instâncias passam `RequestStateSecurity(keys=[...])` (ou um codec personalizado) para que cada instância possa verificar o que uma irmã emitiu. O selo vincula todo token a uma janela de tempo, à requisição de origem e ao principal autenticado quando a requisição carrega autenticação que o SDK validou ou `bind_principal=` fornece seu próprio sinal de identidade (**[Protegendo o `requestState`](#protecting-requeststate)**). + +Este é o mecanismo que substitui a amostragem iniciada pelo servidor e o resto do canal de retorno no estilo push; veja **[Funcionalidades descontinuadas](../deprecated.md)**. diff --git a/i18n/pt/pages/handlers/progress.md b/i18n/pt/pages/handlers/progress.md new file mode 100644 index 0000000000..caa2c09484 --- /dev/null +++ b/i18n/pt/pages/handlers/progress.md @@ -0,0 +1,122 @@ +--- +translation: + sections: [5315262fe26b33e1, 9d8e98840f1b78f0, 0284b215e85366c4, 8534d8dbb4053a70, 2966fac6fe697007] + tool: 1 +--- +# Progresso {#progress} + +Uma ferramenta (tool) que leva trinta segundos e não diz nada durante trinta segundos parece quebrada. + +**Notificações de progresso** resolvem isso. A ferramenta informa em que ponto está; o cliente decide o que desenhar com isso: uma barra, um spinner, uma linha de log. + +## Informe a partir da ferramenta {#report-it-from-the-tool} + +Receba um parâmetro **`Context`** e chame `report_progress`: + +```python title="server.py" hl_lines="8 11" +--8<-- "docs_src/progress/tutorial001.py" +``` + +Três argumentos, e você decide o que eles significam: + +* `progress`: até onde você chegou. A especificação exige que ele **aumente** a cada informe; nunca repita um valor nem volte atrás. +* `total`: quanto há no total, se você souber. Opcional. +* `message`: uma linha legível por humanos sobre *este* passo. Opcional. + +`ctx` é injetado por causa da sua anotação de tipo e o modelo nunca o vê: o schema de entrada de `import_catalog` tem uma única propriedade, `urls`. A página **[O Context](context.md)** trata inteiramente desse objeto; progresso é uma das coisas que ele oferece a você. + +## Escute a partir do cliente {#listen-for-it-from-the-client} + +O cliente opta por receber **por chamada**, passando `progress_callback=` para `call_tool`: + +```python title="client.py" hl_lines="7 16" +import anyio +from mcp import Client + +from server import mcp + + +async def show(progress: float, total: float | None, message: str | None) -> None: + print(f"{message} ({progress}/{total})") + + +async def main() -> None: + async with Client(mcp) as client: + result = await client.call_tool( + "import_catalog", + {"urls": ["https://example.com/a.json", "https://example.com/b.json"]}, + progress_callback=show, + ) + print(result.structured_content) + + +anyio.run(main) +``` + +O callback é uma função `async` que recebe exatamente o que o servidor informou: `progress`, `total`, `message`. + +!!! info + `Client(mcp)` conecta direto ao objeto do servidor, em memória, o mesmo cliente sobre o qual a página **[Testes](../get-started/testing.md)** + é construída. `progress_callback` é o mesmo parâmetro seja qual for o transporte que o `Client` + usa; o *timing* que você está prestes a ver é o da conexão em memória. Ela executa seu callback + inline, então todo informe chega antes de `call_tool` retornar. Em um transporte real, as + notificações disputam corrida com o resultado, e um callback lento ainda pode estar executando depois que `call_tool` + retornou. + +### Experimente {#try-it} + +Coloque `client.py` ao lado de `server.py` e execute: + +```console +python client.py +``` + +```text +Imported https://example.com/a.json (1/2) +Imported https://example.com/b.json (2/2) +{'result': 'Imported 2 records.'} +``` + +Cada `await ctx.report_progress(...)` no servidor virou uma chamada a `show` no cliente, em ordem, e as duas linhas foram impressas **antes** de `call_tool` retornar. O progresso não vem embutido no resultado; ele é transmitido enquanto a ferramenta ainda está trabalhando. + +!!! warning + `progress_callback` pertence à **chamada**, não ao `Client`. Não há argumento de construtor + para ele, porque chamadas diferentes querem callbacks diferentes: uma move uma barra de download, a + seguinte, uma linha de log. + +!!! check + Agora apague `progress_callback=show` e execute de novo: + + ```text + {'result': 'Imported 2 records.'} + ``` + + Nenhum erro, nenhum aviso, mesmo resultado. `report_progress` é um **no-op quando quem chamou não pediu + progresso**, então você informa incondicionalmente e nunca precisa se perguntar se alguém está + escutando. + +## Quando você não sabe o total {#when-you-dont-know-the-total} + +`total` serve para quando você conhece o denominador. Muitas vezes você não conhece: está esvaziando um feed, percorrendo um cursor, baixando algo sem cabeçalho de tamanho. + +Deixe-o de fora: + +```python title="server.py" hl_lines="20" +--8<-- "docs_src/progress/tutorial002.py" +``` + +O callback recebe `total=None`. Um cliente ainda consegue mostrar *atividade* ("3 importados até agora...") mas não consegue mostrar uma porcentagem. Não invente um total para ter uma barra mais bonita. + +!!! tip + `progress` não precisa contar nada em particular. Bytes, linhas, páginas: escolha a unidade que o + usuário reconheceria, e só prometa um `total` que você consiga cumprir. + +## Recapitulando {#recap} + +* `await ctx.report_progress(progress, total=None, message=None)` a partir de qualquer ferramenta que receba um `Context`. +* O cliente passa `progress_callback=` para `call_tool`: por chamada, nunca no `Client`. +* O callback é `async (progress, total, message) -> None` e dispara enquanto a ferramenta ainda está executando. +* Sem callback na chamada, `report_progress` não faz nada. Informe incondicionalmente. +* Omita `total` quando não o souber; o callback recebe `None`. + +Progresso é o que uma ferramenta em execução mostra ao *usuário*. As linhas que ela registra em log para *você*, a pessoa que opera o servidor, são um canal diferente: **[Logging](logging.md)**. diff --git a/i18n/pt/pages/handlers/sampling-and-roots.md b/i18n/pt/pages/handlers/sampling-and-roots.md new file mode 100644 index 0000000000..8debf5ea04 --- /dev/null +++ b/i18n/pt/pages/handlers/sampling-and-roots.md @@ -0,0 +1,51 @@ +--- +translation: + sections: [5c82b20cbd65ded0, 9dc22632be79a533, 1fb8f452e990c456, 42666ab914ff0cb1, c4e0cb3667fd5ff9] + tool: 1 +--- +# Amostragem e roots {#sampling-and-roots} + +Um handler pode pedir mais duas coisas ao cliente conectado: uma completion do próprio modelo do cliente (**amostragem**, sampling), e as pastas de workspace do cliente (**roots**). + +As duas continuam funcionando, em todas as versões do protocolo que o SDK fala. Mas leia o aviso antes de projetar algo em cima delas: + +!!! warning "Descontinuado pela especificação 2026-07-28" + Amostragem e roots estão descontinuados a partir de `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2577)). Eles continuam totalmente funcionais e permanecem na especificação por pelo menos doze meses antes de se tornarem elegíveis para remoção, mas novas implementações não devem se apoiar neles. As migrações sugeridas: integre diretamente com a API do seu provedor de LLM em vez de usar amostragem, e passe diretórios via parâmetros de ferramenta, URIs de recurso ou configuração do servidor em vez de roots. A lista de todo o SDK está em **[Funcionalidades descontinuadas](../deprecated.md)**. + +## Amostragem: pegue emprestado o modelo do cliente {#sampling-borrow-the-clients-model} + +Um resolvedor retorna `Sample(...)` e a ferramenta recebe a completion, pelo mesmo mecanismo de dependência que executa `Elicit` em **[Dependências](dependencies.md)**: + +```python title="server.py" hl_lines="10-15 19" +--8<-- "docs_src/sampling_and_roots/tutorial001.py" +``` + +* `Sample(messages, max_tokens=...)` espelha os parâmetros de `sampling/createMessage`. O valor injetado é o `CreateMessageResult` do cliente; passe `tools` ou `tool_choice` e ele vira um `CreateMessageResultWithTools`. +* O cliente precisa ter declarado a capacidade `sampling` (`sampling.tools` se você passar `tools` ou `tool_choice`). Se não declarou, a chamada falha com um erro de protocolo `-32021` em vez de enviar uma requisição que o cliente não consegue tratar. Uma sessão pré-2026 sem canal de retorno (back-channel) falha com o erro habitual de ausência de canal de retorno, já que não há por onde enviar. +* Em `2026-07-28` a requisição é entregue dentro do fluxo de múltiplas idas e voltas (**[Requisições com múltiplas idas e voltas](multi-round-trip.md)**); em `2025-11-25` ela é uma requisição independente para o cliente. O código é o mesmo nos dois casos, mas atenção à regra das múltiplas idas e voltas: a requisição precisa ser gerada de forma idêntica em todas as rodadas de retry, então construa-a apenas a partir dos argumentos da ferramenta e de outros dados estáveis. +* Deixe `include_context` quieto: valores diferentes de `"none"` também estão descontinuados (SEP-2596) e exigem uma capacidade que quase nenhum cliente declara. + +## Roots: onde isso deve ir? {#roots-where-should-this-go} + +Roots são as pastas sobre as quais o cliente diz que o servidor pode operar. São uma orientação informativa, não um mecanismo de controle de acesso. Um resolvedor retorna `ListRoots()`: + +```python title="server.py" hl_lines="10-11 15" +--8<-- "docs_src/sampling_and_roots/tutorial002.py" +``` + +* O `ListRootsResult` injetado traz uma lista de `Root`s: uma URI `file://` e um nome de exibição opcional. +* A barreira é a mesma da amostragem: sem uma capacidade `roots` declarada, a chamada falha com `-32021` em vez de enviar a requisição. + +Do outro lado da conexão, o cliente responde às duas requisições com os callbacks que já tem: `sampling_callback` e `list_roots_callback`, tratados em **[Callbacks do cliente](../client/callbacks.md)**. + +## Em conexões da era 2025 {#on-2025-era-connections} + +`ctx.session.create_message(...)` e `ctx.session.list_roots()` ainda existem para código que controla a sessão diretamente. Eles só funcionam onde existe um canal de retorno (conexões da era 2025, não stateless), e chamá-los dispara um aviso de descontinuação. Os marcadores de resolvedor acima são a forma suportada: eles escolhem a entrega conforme a versão negociada e não emitem aviso. + +## Recapitulando {#recap} + +* Retorne `Sample(...)` ou `ListRoots()` de um resolvedor; a ferramenta recebe o `CreateMessageResult` ou o `ListRootsResult` como qualquer outra dependência. +* O cliente precisa declarar a capacidade correspondente, ou a chamada falha com `-32021` em vez de uma requisição ser enviada. +* As duas funcionalidades estão descontinuadas em `2026-07-28`: totalmente funcionais por enquanto, erradas para novos projetos. Prefira APIs de provedor à amostragem e parâmetros explícitos aos roots. + +Para informar o andamento de uma ferramenta lenta: **[Progresso](progress.md)**. diff --git a/i18n/pt/pages/handlers/subscriptions.md b/i18n/pt/pages/handlers/subscriptions.md new file mode 100644 index 0000000000..1480258d42 --- /dev/null +++ b/i18n/pt/pages/handlers/subscriptions.md @@ -0,0 +1,152 @@ +--- +translation: + sections: [60a9de8a0bdaa531, 317bbe7e4355cdcc, a61d660c8029e04a, 8f7e82fcb88df8a9, b165db51249ff8ed, 266f56fb798068a4, 7c0e57030b622139, df18d7c2417a9883] + tool: 1 +--- +# Assinaturas {#subscriptions} + +O catálogo de um servidor não é fixo. Ferramentas aparecem em tempo de execução, e o conteúdo por trás da URI de um recurso muda. + +As **assinaturas** (subscriptions) são como um cliente fica sabendo disso. O cliente envia uma única requisição `subscriptions/listen`, e a resposta a essa requisição *é* o stream: ela fica aberta e carrega as notificações de mudança que o cliente pediu. + +## Publique a partir da ferramenta {#publish-it-from-the-tool} + +A sua parte é uma linha: publicar a mudança. + +```python title="server.py" hl_lines="20 32" +--8<-- "docs_src/subscriptions/tutorial001.py" +``` + +* `await ctx.notify_resource_updated("board://sprint")` chega a todo stream aberto que assinou essa URI. A mais ninguém. +* `await ctx.notify_tools_changed()` chega a todo stream que pediu mudanças na lista de ferramentas. Um cliente que recebe isso chama `tools/list` de novo e agora vê `sprint_report`. +* Os irmãos são `notify_prompts_changed()` e `notify_resources_changed()`. +* Sem assinantes, sem trabalho. Publicar em um servidor ocioso é um no-op, então você nunca verifica se há alguém ouvindo. Você declara o que mudou. + +O `MCPServer` serve `subscriptions/listen` para você. As obrigações do protocolo na conexão (o acknowledgment como primeiro frame, a filtragem por stream, o id da assinatura em cada frame) são trabalho do SDK. + +!!! check + Na conexão, um stream cujo filtro nomeou `board://sprint` fica assim depois que `complete_task` executa: + + ```json + {"method": "notifications/subscriptions/acknowledged", + "params": {"notifications": {"resourceSubscriptions": ["board://sprint"]}, "_meta": {"io.modelcontextprotocol/subscriptionId": "listen-1"}}} + + {"method": "notifications/resources/updated", + "params": {"uri": "board://sprint", "_meta": {"io.modelcontextprotocol/subscriptionId": "listen-1"}}} + ``` + + Repare no que a atualização *não* carrega: o quadro. Cada frame carrega o id JSON-RPC da requisição listen em `_meta`, e esse id é o id da assinatura. Quem o gera é o cliente: o `Client` em Python usa strings como `"listen-1"`; outros clientes podem usar inteiros. + +## Só o que foi pedido {#only-what-was-asked-for} + +O filtro é um contrato. Um stream que pediu mudanças na lista de ferramentas e uma URI de recurso recebe esses dois tipos e nada mais. Publique uma mudança de prompt e esse stream fica em silêncio. + +O `MCPServer` compara URIs de recurso como strings exatas, então um stream que nomeou `board://sprint` não ouve nada sobre `board://sprint/tasks/1`. A especificação permite que um servidor reporte uma mudança em um sub-recurso de uma URI assinada; o `MCPServer` nunca faz isso, mas os clientes são construídos para esperar por isso. + +Duas coisas que o stream *não* é: + +* **Não é um log de replay.** Um stream que caiu já era, e eventos publicados enquanto ninguém estava conectado não ficam em fila. Os clientes refazem o listen e buscam de novo. +* **Não é o caminho de 2025.** Clientes que chamaram `resources/subscribe` são atendidos por `ctx.session.send_resource_updated(uri)`. Os métodos `notify_*` chegam apenas a streams de `subscriptions/listen`. + +## Decidindo quem pode observar {#deciding-who-may-watch} + +Por padrão, todo tipo e toda URI pedidos são atendidos: qualquer chamador pode observar qualquer URI que você publica. Nada consulta o seu handler de leitura, porque ninguém está lendo — um chamador que o seu handler de `files://{name}` recusaria ainda pode abrir um stream em `files://payroll.csv` e saber que o arquivo mudou, e quando. Ele nunca descobre o conteúdo, e não consegue sondar o que existe, porque uma URI desconhecida também é atendida e simplesmente nunca dispara. Estreito, mas real, então bloqueie isso antes de publicar URIs por usuário a partir de um servidor multi-tenant. + +O bloqueio é um middleware. Ele vê a requisição `subscriptions/listen` antes de o SDK fazer o acknowledgment e recusa quando o chamador pede qualquer coisa que não pode ler: + +```python title="server.py" hl_lines="19-26 29" +--8<-- "docs_src/subscriptions/tutorial006.py" +``` + +* `ctx.params` é a requisição crua, então o próprio middleware a valida em `SubscriptionsListenRequestParams` e lê o filtro que o cliente pediu. +* A recusa é um `MCPError` lançado antes de `call_next(ctx)`: o cliente recebe esse erro e nenhum stream, e a conexão segue em frente. Mantenha a mensagem uniforme, sem nomear nenhuma URI, para que uma recusa nunca confirme quais URIs são protegidas. +* Um único `can_access(user, uri)` responde às duas perguntas. O handler do recurso o consulta em `resources/read`; o middleware o consulta em `subscriptions/listen`. Troque a tabela por um banco de dados ou pelo seu sistema de RBAC e os dois continuam em sintonia. +* A decisão vale por toda a vida do stream. Não há nova verificação por evento, então se o acesso de um chamador pode expirar no meio do stream (um token que vence), encerre a conexão desse chamador quando isso acontecer. + +O contrato completo do middleware, incluindo o que mais ele envolve e por que está marcado como provisório, está em **[Middleware](../advanced/middleware.md)**. + +## A ponta do cliente {#the-client-end} + +Aqui está um cliente do outro lado desse stream, acompanhando o quadro: + +```python title="client.py" hl_lines="15" +--8<-- "docs_src/subscriptions/tutorial003.py" +``` + +Entrar em `client.listen(...)` envia a requisição e espera pelo seu acknowledgment, então o stream já está ativo quando o bloco começa, e cada evento tipado é um sinal para buscar de novo, nunca um payload. Esse é o contrato inteiro em uma tela. Todo o resto sobre a ponta do cliente mora na sua própria página: observar ao lado de um fluxo principal, fim de streams e refazer o listen. Veja **[Assinaturas](../client/subscriptions.md)** em *Clientes*. + +## Escalando além de um processo {#scaling-past-one-process} + +As publicações viajam do seu handler até os streams abertos por um `SubscriptionBus`. O padrão é em memória: um processo, todos os streams dentro dele. Essa é a resposta certa até você rodar réplicas atrás de um balanceador de carga, porque aí o stream de um cliente fica preso a uma réplica, e uma publicação em outra réplica precisa chegar até ele. + +Essa costura é sua para implementar: dois métodos sobre o seu backend de pub/sub. + +```python +from collections.abc import Callable + +from redis.asyncio import Redis + +from mcp.server.mcpserver import MCPServer +from mcp.server.subscriptions import ServerEvent # SubscriptionBus is a Protocol: no base class + + +class RedisSubscriptionBus: + def __init__(self, redis: Redis) -> None: + self._redis = redis + self._listeners: dict[object, Callable[[ServerEvent], None]] = {} + + async def publish(self, event: ServerEvent) -> None: + await self._redis.publish("mcp-events", encode(event)) # to every replica + + def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]: + token = object() + self._listeners[token] = listener + + def unsubscribe() -> None: + self._listeners.pop(token, None) + + return unsubscribe + + +mcp = MCPServer("Sprint Board", subscriptions=RedisSubscriptionBus(redis)) +``` + +`encode` é seu, assim como a task leitora em cada réplica que decodifica as mensagens que chegam e chama cada listener registrado. Os listeners são síncronos, não podem lançar exceções e rodam no loop de eventos do servidor. + +O bus carrega valores `ServerEvent` tipados, quatro dataclasses pequenas, nunca JSON-RPC. Carimbo, filtragem e ciclos de vida dos streams ficam no SDK, então uma implementação de bus não consegue quebrar o protocolo. Ela só consegue mover eventos entre processos. + +Para publicar de fora de uma requisição, construa o bus você mesmo para ficar com a referência. O `MCPServer` monta um internamente quando você não passa nada, e não o expõe. + +```python +from mcp.server.subscriptions import InMemorySubscriptionBus, ToolsListChanged + +bus = InMemorySubscriptionBus() +mcp = MCPServer("Sprint Board", subscriptions=bus) + + +async def tools_reloaded() -> None: + await bus.publish(ToolsListChanged()) # from a lifespan task, a webhook, anywhere +``` + +## A composição de baixo nível {#the-low-level-composition} + +Lá embaixo, no `Server` de baixo nível, nada vem pré-conectado, e as mesmas peças se montam em três linhas: + +```python title="server.py" hl_lines="8-9 47" +--8<-- "docs_src/subscriptions/tutorial002.py" +``` + +* O bus é seu, então você publica nele diretamente: `await bus.publish(ResourceUpdated(uri=...))`. Coloque-o onde os seus handlers consigam alcançá-lo: escopo de módulo aqui, o lifespan em um app maior. +* `ListenHandler(bus)` é o mesmo handler que o `MCPServer` registra, e `on_subscriptions_listen=` é um slot de handler comum. Coloque o seu próprio callable nesse slot para ter uma semântica diferente, e as obrigações da especificação passam para você: fazer o acknowledgment primeiro, carimbar cada frame com o id da assinatura, não entregar nada fora do filtro. +* `ListenHandler.close()` encerra cada stream aberto de forma graciosa. Cada um recebe o resultado da requisição listen como seu frame final, que é o jeito da especificação de dizer que o servidor encerrou a assinatura de propósito. Ele retorna antes de esses streams terminarem de descarregar, então dê um instante a eles antes de derrubar o transporte. Sem ele, os streams terminam quando o cliente desconecta. + +## Recapitulando {#recap} + +* Um cliente opta por participar com uma única requisição `subscriptions/listen`, e a resposta é o stream. Servir isso já vem embutido. +* Você publica com `ctx.notify_*`, e o SDK cuida do carimbo, da filtragem e do ciclo de vida. +* Eventos são sinais, não payloads. As duas pontas buscam de novo. +* A ponta do cliente é `async with client.listen(...)`: **[Assinaturas](../client/subscriptions.md)** em *Clientes* conta essa história. +* No `Server` de baixo nível você monta as mesmas peças por conta própria: um bus, `ListenHandler(bus)`, o slot `on_subscriptions_listen`. +* Escalar horizontalmente significa implementar `SubscriptionBus`, dois métodos, e passá-lo como `MCPServer(subscriptions=...)`. + +Rodar o servidor que serve tudo isso, atrás de uma réplica ou de vinte, é **[Deploy e escala](../run/deploy.md)**. diff --git a/i18n/pt/pages/index.md b/i18n/pt/pages/index.md new file mode 100644 index 0000000000..4305076473 --- /dev/null +++ b/i18n/pt/pages/index.md @@ -0,0 +1,102 @@ +--- +translation: + sections: [154c4309937b9f85, 3ad8fc6caa76a9b0, a07f3f5b151ab746, bf6e476b712930c0, cf0b1f13978c6623] + tool: 1 +--- +# MCP Python SDK {#mcp-python-sdk} + +!!! info "Esta documentação cobre a v2, a linha de versões estável atual" + Começando na v2 ou vindo da v1? **[Novidades da v2](whats-new.md)** é o tour de cinco minutos pelo que mudou, e o **[Guia de migração](migration.md)** cobre todas as mudanças incompatíveis. + Ainda na v1.x? A documentação dela fica nos [docs da v1.x](https://py.sdk.modelcontextprotocol.io/v1/). + Encontrou algo mal-acabado ou confuso? [Conte para nós](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml). + +O **Model Context Protocol (MCP)** permite que aplicações forneçam contexto a LLMs de forma padronizada, separando a responsabilidade de *fornecer* contexto da interação com o LLM em si. + +Este é o SDK Python oficial do protocolo. Com ele, você pode: + +* **Construir servidores MCP** que expõem ferramentas (tools), recursos e prompts a qualquer host MCP. +* **Construir clientes MCP** que se conectam a qualquer servidor MCP. +* Comunicar-se por todos os transportes padrão: stdio, Streamable HTTP e SSE. + +## Requisitos {#requirements} + +Python 3.10+. + +## Instalação {#installation} + +=== "uv" + + ```bash + uv add "mcp[cli]" + ``` + +=== "pip" + + ```bash + pip install "mcp[cli]" + ``` + +O extra `[cli]` instala o comando `mcp`; você vai precisar dele durante o desenvolvimento. +Veja [Instalação](get-started/installation.md) para saber para que serve cada dependência. + +## Exemplo {#example} + +### Crie {#create-it} + +Crie um arquivo `server.py`: + +```python title="server.py" +--8<-- "docs_src/index/tutorial001.py" +``` + +Esse é um servidor MCP completo. + +Ele expõe uma **ferramenta**, `add`, e um **recurso** com template, `greeting://{name}`. + +### Execute {#run-it} + +```console +uv run mcp dev server.py +``` + +Isso inicia o seu servidor e abre o [MCP Inspector](https://github.com/modelcontextprotocol/inspector), uma interface interativa para explorá-lo. Abra a URL que ele imprime. + +!!! note + O Inspector é um app Node.js, então `mcp dev` precisa do `npx` no seu `PATH`. + +### Experimente {#try-it} + +No Inspector, vá em **Tools** e chame `add` com `a=1`, `b=2`. + +Você recebe `3` de volta. ✨ + +O Inspector montou esse formulário (um campo inteiro obrigatório para `a`, outro para `b`) a partir das suas anotações de tipo. O Claude faz o mesmo, assim como qualquer outro host MCP. + +Agora vá em **Resources** e leia `greeting://World`: + +```text +Hello, World! +``` + +### Recapitulando {#recap} + +Repare de novo no que você **não** escreveu: + +* Nenhum JSON Schema. `a: int, b: int` *é* o schema. +* Nenhum parsing de requisição, nenhuma serialização, nenhum código de validação. +* Absolutamente nenhum tratamento do protocolo. + +Você escreveu duas funções Python com anotações de tipo e uma docstring. O SDK faz o resto. + +## Para onde ir agora {#where-to-go-next} + +* **[Comece por aqui](get-started/index.md)** leva você da instalação até um servidor funcionando e testado. +* Construindo uma aplicação que *usa* servidores MCP? Comece por **[Clientes](client/index.md)**. +* Já tem um app FastAPI ou Starlette? **[Adicionar a um app existente](run/asgi.md)** monta um servidor MCP dentro dele. +* Atrás de uma mensagem de erro específica? **[Solução de problemas](troubleshooting.md)** é organizada pelo texto exato das mensagens. +* Quer saber o que mudou na v2? **[Novidades da v2](whats-new.md)** é o tour de cinco minutos. +* Migrando da v1? Comece pelo **[Guia de migração](migration.md)**. +* Atrás de uma assinatura exata? A **[Referência da API](api/mcp/index.md)** é gerada a partir do código-fonte. +* Lendo com um LLM? Esta documentação também é publicada no formato [llms.txt](https://llmstxt.org/): + [llms.txt](https://py.sdk.modelcontextprotocol.io/llms.txt) é um índice das páginas, e + [llms-full.txt](https://py.sdk.modelcontextprotocol.io/llms-full.txt) contém todas as páginas em um único arquivo. diff --git a/i18n/pt/pages/protocol-versions.md b/i18n/pt/pages/protocol-versions.md new file mode 100644 index 0000000000..6ce3ae2f2a --- /dev/null +++ b/i18n/pt/pages/protocol-versions.md @@ -0,0 +1,132 @@ +--- +translation: + sections: [478fd619e5f90ef8, aef094a00e44e248, bab8cbf3449fa7e9, df1809b15a58335b, 5f9d8c2336ed0239, f54974398e43ddef, b24443dd78584870] + tool: 1 +--- +# Versões do protocolo {#protocol-versions} + +O MCP tem duas eras. + +Os servidores lançados antes de 2026-07-28 abrem toda conexão com o **handshake `initialize`**: o cliente propõe uma versão, o servidor responde com outra, o cliente confirma, tudo antes da primeira requisição útil. Os servidores em **2026-07-28** abandonam o handshake. O cliente envia uma única sondagem **`server/discover`** e o servidor responde com tudo em um único resultado. + +Você quase nunca precisa se preocupar com isso, porque o `Client` negocia por você. Esta página trata do único argumento do construtor que controla isso, `mode=`, e das três situações em que você o altera. + +## `mode="auto"` {#modeauto} + +```python title="client.py" hl_lines="14-15" +--8<-- "docs_src/protocol_versions/tutorial001.py" +``` + +Você não passou `mode`, então recebeu o padrão: `"auto"`. Entrar no `async with` envia uma única sondagem `server/discover` na versão mais nova que este SDK fala. Depois: + +* Um **servidor moderno** responde. O cliente adota o resultado. Uma ida e volta, pronto. +* Um **servidor mais antigo** nunca ouviu falar de `server/discover` e retorna um erro. O cliente recorre ao handshake clássico `initialize` e fica com o que ele negociar. + +De um jeito ou de outro você sai conectado, e `client.protocol_version` diz qual foi o caso: + +```text +2026-07-28 +``` + +A funcionalidade inteira é essa. Um `Client`, qualquer era de servidor, sem ramificações no seu código. + +!!! info + O `MCPServer` responde a `server/discover` em todos os transportes — em memória, stdio, streamable + HTTP — então, contra o seu próprio servidor, `auto` sempre chega em `2026-07-28`. O fallback só + dispara contra um servidor real anterior a 2026, que é exatamente quando você quer que ele dispare. + +## `mode="legacy"` {#modelegacy} + +```python title="client.py" hl_lines="14" +--8<-- "docs_src/protocol_versions/tutorial002.py" +``` + +`mode="legacy"` nunca sonda. Ele executa o handshake `initialize`, a mesma conexão que um cliente anterior a 2026 abre. + +```text +2025-11-25 +``` + +Mesmo servidor. Ele fala `2026-07-28` perfeitamente bem; você disse ao cliente para não perguntar. + +Você quer isso para as funcionalidades no estilo **push**. + +Uma requisição iniciada pelo servidor é o servidor chamando *você*: `ctx.elicit(...)` colocando um formulário na frente do seu usuário, a amostragem (sampling) pedindo uma completion ao seu modelo no meio de uma chamada de ferramenta. Esse canal só existe em uma sessão da era do handshake. + +Em 2026-07-28 ele não existe mais. O servidor *retorna* suas perguntas e você repete a chamada com as respostas (**[Requisições com várias idas e voltas](handlers/multi-round-trip.md)**). + +`mode="auto"` só dá um handshake a você quando o servidor é antigo demais para qualquer outra coisa. `mode="legacy"` garante um. Recorra a ele sempre que passar ao `Client(...)` um `sampling_callback`, um `elicitation_callback` que você quer acionado como requisição, ou um `message_handler`. **[Callbacks do cliente](client/callbacks.md)** passa por cada um deles. + +## Fixando uma versão {#pinning-a-version} + +`mode` também aceita uma string de versão moderna do protocolo. Hoje esse conjunto é exatamente `["2026-07-28"]`. + +```python title="client.py" hl_lines="14" +--8<-- "docs_src/protocol_versions/tutorial003.py" +``` + +Uma versão fixada não envia **nada**. Sem sondagem, sem handshake. O cliente adota `2026-07-28` localmente e a conexão está ativa no instante em que `async with` retorna. + +Fixar uma versão é uma promessa que *você* faz: você já sabe que o servidor fala aquela versão. O cliente não verifica. + +!!! check + Fixar uma versão não é uma descoberta. Imprima `client.server_info` e o preço está bem ali: + + ```text + None + ``` + + O cliente nunca perguntou ao servidor quem ele é, então `server_info` é `None`. Com `client.server_capabilities` + é a mesma história: toda capacidade é `None`. As chamadas de ferramenta continuam funcionando (o protocolo não precisa de nada disso); + o código que lê `server_capabilities` para decidir o que oferecer, não. + + A próxima seção é a correção. + +Só as versões modernas podem ser fixadas. Uma string da era do handshake é rejeitada na construção, antes de qualquer I/O, e o erro diz o que escrever no lugar: + +```text +ValueError: mode must be 'legacy', 'auto', or one of ['2026-07-28']; got '2025-06-18' ('2025-06-18' is a handshake-era version; use mode='legacy') +``` + +## Reconectando com `prior_discover` {#reconnecting-with-prior_discover} + +A sondagem é barata, mas ainda é uma ida e volta que você paga a cada reconexão, e a resposta quase nunca muda. + +Então guarde-a. Depois de uma conexão `auto`, `client.session.discover_result` contém o `DiscoverResult` exato que o servidor enviou: seu `supported_versions`, seu `capabilities`, seu `instructions` e a identidade que o servidor carimbou no `_meta` do resultado. Passe-o de volta como `prior_discover=` na próxima vez: + +```python title="client.py" hl_lines="15 17" +--8<-- "docs_src/protocol_versions/tutorial004.py" +``` + +```text +2026-07-28 +Bookshop +``` + +A segunda conexão fez **zero** idas e voltas de negociação e ainda sabe exatamente com quem está falando. Esse é o modo fixado feito direito: `mode=` nomeia a versão, `prior_discover=` fornece a identidade. ✨ + +`DiscoverResult` é um modelo Pydantic. `saved.model_dump_json()` vai para um arquivo ou um cache; `DiscoverResult.model_validate_json(...)` o traz de volta no próximo processo. + +!!! tip + `prior_discover=` só faz alguma coisa quando `mode` é uma versão fixada. Com `"auto"` o cliente + sonda o servidor de qualquer forma, e com `"legacy"` ele é ignorado. + +## Os quatro modos {#the-four-modes} + +| Você escreve | Tráfego de negociação | Você recebe | +| --- | --- | --- | +| `Client(target)` | uma sondagem `server/discover`; o handshake `initialize` se ela falhar | a versão mais nova que os dois lados falam, de qualquer era | +| `Client(target, mode="legacy")` | o handshake `initialize` | uma versão da era do handshake; requisições iniciadas pelo servidor funcionam | +| `Client(target, mode="2026-07-28")` | nenhum | aquela versão, fixada, com `server_info` como `None` | +| `Client(target, mode="2026-07-28", prior_discover=saved)` | nenhum | aquela versão, fixada, *e* a identidade que você salvou da última vez | + +## Recapitulando {#recap} + +* O MCP tem uma era do handshake (até `2025-11-25`, o handshake `initialize`) e uma era moderna (`2026-07-28`, `server/discover`). O `Client` faz a ponte entre elas. +* `mode="auto"` é o padrão: sondar, recorrer ao fallback. Deixe como está, a menos que uma das outras três linhas descreva o seu caso. +* `client.protocol_version` é sempre a resposta para "o que eu recebi?". +* `mode="legacy"` força o handshake. É disso que você precisa para requisições iniciadas pelo servidor: amostragem, elicitação (elicitation) via push, `message_handler`. +* Uma versão fixada (`mode="2026-07-28"`) não envia nenhum tráfego de negociação, ao custo de `client.server_info` ser `None`. +* `prior_discover=` paga esse custo de volta: salve `client.session.discover_result`, reconecte com ele, fique com os dois. + +Uma conexão moderna não tem canal de push, então como um servidor de 2026 faz uma pergunta a você no meio de uma chamada? Ele a retorna: **[Requisições com várias idas e voltas](handlers/multi-round-trip.md)**. diff --git a/i18n/pt/pages/run/asgi.md b/i18n/pt/pages/run/asgi.md new file mode 100644 index 0000000000..563ef3d9b7 --- /dev/null +++ b/i18n/pt/pages/run/asgi.md @@ -0,0 +1,145 @@ +--- +translation: + sections: [1062ef792791488a, 4be2b831547184a9, 374b049e770385f2, b72f6947089e6de0, b172c9db7831bb31, 70b9ece244ca1b0c, cba78e052898c3f6, f06bdb541cb0b469, fb82d526320b7cc3] + tool: 1 +--- +# Adicione a um app existente {#add-to-an-existing-app} + +`mcp.run("streamable-http")` inicia um servidor web para você. Às vezes você não quer isso: seu servidor MCP é uma peça de uma aplicação web maior, ou você já tem um deploy ASGI. + +Para esses casos, `mcp.streamable_http_app()` retorna uma **aplicação Starlette**. + +Um app Starlette é um app ASGI, então qualquer coisa que hospede ASGI (uvicorn, Hypercorn, outro Starlette, FastAPI) pode hospedar seu servidor MCP. + +## O app {#the-app} + +```python title="server.py" hl_lines="12" +--8<-- "docs_src/asgi/tutorial001.py" +``` + +`app` é uma aplicação ASGI comum. Entregue-o a qualquer servidor ASGI: + +```console +uvicorn server:app +``` + +O endpoint MCP fica em `/mcp`, então um cliente se conecta a `http://127.0.0.1:8000/mcp`. + +O app já carrega duas coisas: + +* Uma rota, `/mcp`: o endpoint Streamable HTTP. +* Um **lifespan** que inicia o `mcp.session_manager`, o objeto que é dono do trabalho em segundo plano de cada sessão ativa. + +Execute o app sozinho (`uvicorn server:app`) e você nunca precisa pensar em nenhuma das duas. + +!!! tip + `streamable_http_app()` aceita os mesmos argumentos nomeados que `mcp.run("streamable-http", ...)`, + menos `port`: a porta pertence a quem quer que sirva o app. `host` ainda é aceito, mas não faz bind + de nada aqui; **[Deploy e escala](deploy.md)** explica o que ele controla de fato. + **[Executando seu servidor](index.md)** cobre as opções em si. + +`mcp.sse_app()` faz o mesmo para o transporte SSE, já superado. + +## Só localhost, até você dizer o contrário {#localhost-only-until-you-say-otherwise} + +Por padrão, o app responde **apenas** a requisições endereçadas ao localhost. `streamable_http_app()` +não tem como saber atrás de qual hostname vai ser servido, então ativa a proteção contra DNS rebinding com a +allowlist mais segura possível; na sua máquina, isso é exatamente o certo. Depois do deploy atrás de um hostname real, +isso significa que **toda requisição é rejeitada com `421 Misdirected Request`** até você passar em +`transport_security=` uma allowlist do que você realmente serve. Nada do que você construiu sequer é +consultado antes. Essa allowlist, e tudo o mais que existe entre um app funcionando e um hostname real, +é assunto de **[Deploy e escala](deploy.md)**. + +## Montando o app {#mounting-it} + +No momento em que o servidor MCP é *parte* de uma aplicação maior, você coloca o app dentro de um `Mount`. E no momento em que faz isso, o lifespan vira problema seu: + +```python title="server.py" hl_lines="18-21 25-26" +--8<-- "docs_src/asgi/tutorial002.py" +``` + +* `Mount("/", ...)` mais o caminho padrão `/mcp` mantém o endpoint em `/mcp`. O Starlette testa as rotas em ordem e `Mount("/")` casa com **todo** caminho, então suas próprias rotas vão *antes* dele na lista. Qualquer coisa depois dele fica inalcançável. +* A função `lifespan` entra em `mcp.session_manager.run()` pelo tempo de vida do app **host**. Essa é a linha que todo mundo esquece. +* `mcp.session_manager` só existe *depois* que `streamable_http_app()` foi chamado. É por isso que as rotas são construídas no nível do módulo e o manager só é tocado dentro do lifespan. + +A rota `Host` do Starlette funciona do mesmo jeito: troque `Mount("/", ...)` por `Host("mcp.example.com", ...)` para rotear por hostname em vez de por caminho. A regra do lifespan não muda, e a de segurança de transporte também não. Uma rota `Host("mcp.example.com", ...)` só recebe requisições endereçadas àquele hostname, mas a allowlist de Host do próprio transporte (**[Deploy e escala](deploy.md)**) ainda roda primeiro. Sem `"mcp.example.com"` nela, essa rota responde a cada uma delas com um `421`. + +!!! warning "O app host é dono do lifespan" + `streamable_http_app()` conecta `session_manager.run()` ao lifespan do Starlette que + retorna, mas **o lifespan de uma subaplicação montada nunca roda**. Monte o app e esse + lifespan embutido vira código morto. Seja qual for o app no topo da sua pilha ASGI, ele precisa entrar em + `mcp.session_manager.run()` no próprio lifespan. + +!!! check + Apague a linha `lifespan=lifespan` e inicie o servidor. Ele inicia. A rota resolve. + Aí a primeira requisição a `/mcp` falha com: + + ```text + RuntimeError: Task group is not initialized. Make sure to use run(). + ``` + + Nada inicia o session manager a não ser o `run()` dele. + +## Dois servidores, um app {#two-servers-one-app} + +Cada `MCPServer` é seu próprio app com seu próprio session manager. Monte quantos quiser; entre em cada manager a partir do único lifespan do host: + +```python title="server.py" hl_lines="27-30 35-36" +--8<-- "docs_src/asgi/tutorial003.py" +``` + +* `AsyncExitStack` entra nos dois managers; eles iniciam juntos e encerram na ordem inversa. +* Os endpoints são `/notes/mcp` e `/tasks/mcp`: o prefixo do mount mais o caminho padrão. + +## Mudando o caminho {#changing-the-path} + +Aquele `/mcp` no final é o `streamable_http_path`. Defina-o como `"/"` e o prefixo do mount vira o caminho público inteiro: + +```python title="server.py" hl_lines="25" +--8<-- "docs_src/asgi/tutorial004.py" +``` + +Agora os clientes se conectam a `/notes`, não a `/notes/mcp`. + +## CORS para clientes no navegador {#cors-for-browser-clients} + +Um cliente que roda no navegador precisa de duas permissões suas: para **enviar** seus headers de requisição MCP, e para **ler** o que o MCP manda de volta. As duas são configuração de CORS no app host, e a allowlist de segurança de transporte acima precisa concordar com ela: + +```python title="server.py" hl_lines="27-30 33 35-49" +--8<-- "docs_src/asgi/tutorial005.py" +``` + +* `allow_headers` é a metade que todo mundo esquece. O navegador faz **preflight** de toda requisição MCP, porque `Content-Type: application/json` e os headers de requisição `Mcp-*` não estão na safelist do CORS, e um header que o preflight não concede é uma requisição que o navegador nunca envia. (`allow_headers=["*"]` também funciona: o Starlette responde a um preflight com o que quer que ele tenha pedido.) +* `expose_headers=["Mcp-Session-Id"]` é a metade da leitura. O Streamable HTTP retorna o ID de sessão nesse header de resposta, e os navegadores escondem headers de resposta do JavaScript a menos que o CORS os exponha pelo nome. Sem ele, o cliente nunca consegue fazer sua segunda requisição. +* `allow_origins` é decisão sua, não do MCP. Seja preciso, e espelhe isso em `allowed_origins=` acima: o navegador impõe o CORS, mas o servidor verifica `Origin` por conta própria, e uma origem em que o transporte não confia recebe um `403` mesmo depois de um preflight limpo. +* `allow_methods` lista os três métodos que o Streamable HTTP usa: `POST` para enviar mensagens, `GET` para abrir o stream do servidor para o cliente, `DELETE` para encerrar a sessão. + +## Rotas customizadas {#custom-routes} + +`@mcp.custom_route()` registra um endpoint HTTP comum no mesmo app, para as coisas que todo serviço em produção precisa e que não têm nada a ver com MCP: um health check, um callback OAuth. + +```python title="server.py" hl_lines="15-17" +--8<-- "docs_src/asgi/tutorial006.py" +``` + +* O handler é Starlette puro: uma função `async` de `Request` para `Response`. +* `streamable_http_app()` recolhe toda rota customizada. `app.routes` agora é `/mcp` e `/health`. +* `GET /health` responde `{"status": "ok"}` sem MCP nenhum à vista. + +!!! warning + Rotas customizadas **nunca são autenticadas**, mesmo quando o resto do servidor é. Isso é + proposital: health checks e callbacks OAuth precisam estar acessíveis antes de existir qualquer token. + Não coloque nada privado atrás de uma delas. + +## Recapitulando {#recap} + +* `mcp.streamable_http_app()` retorna um app Starlette com uma rota, `/mcp`. Qualquer servidor ASGI consegue executá-lo. +* Por padrão, o app responde apenas a requisições endereçadas ao localhost, e atrás de um hostname real rejeita tudo com um `421` até você passar em `transport_security=` uma allowlist. **[Deploy e escala](deploy.md)** cuida disso, e do resto do caminho até a produção. +* `Mount` (ou `Host`) o coloca dentro de um app Starlette ou FastAPI maior. +* **Montar desativa o lifespan embutido.** O lifespan do app host precisa entrar em `mcp.session_manager.run()`, ou a primeira requisição falha. +* Vários servidores em um app significa vários mounts e um lifespan que entra em cada session manager. +* `streamable_http_path="/"` move o endpoint para o próprio prefixo do mount. +* Clientes no navegador precisam de CORS: `allow_headers` para os headers de requisição `Mcp-*`, `expose_headers=["Mcp-Session-Id"]` para a resposta. +* `@mcp.custom_route()` adiciona endpoints HTTP comuns, sem autenticação, ao lado de `/mcp`. + +Com o servidor acessível em uma URL real, **[O cliente](../client/index.md)** se conecta a ele com essa URL em vez de um objeto servidor. diff --git a/i18n/pt/pages/run/authorization.md b/i18n/pt/pages/run/authorization.md new file mode 100644 index 0000000000..468206c7ea --- /dev/null +++ b/i18n/pt/pages/run/authorization.md @@ -0,0 +1,130 @@ +--- +translation: + sections: [d62c13457fc4a534, 80e73abaca6e0652, d1dc4c54cd00ec9c, 14ad3bc7904036bb, 5225f127bc1b9c77, fe1626fdd5aad1da, 4556cb7ea1a04a31] + tool: 1 +--- +# Autorização {#authorization} + +Sobre Streamable HTTP, seu servidor MCP é um serviço web comum, e você o protege como protege qualquer serviço web: com bearer tokens do OAuth 2.1. + +Nos termos do OAuth, seu servidor é um **resource server**. Ele nunca autentica ninguém e nunca emite um token. Ele faz uma coisa só: olha o header `Authorization` de cada requisição e decide se o token que está ali é válido. + +Esta página é o lado do servidor. Um cliente que descobre seu servidor de autorização e busca o token está em **[Clientes OAuth](../client/oauth-clients.md)**. + +## As três partes {#the-three-parties} + +* O **servidor de autorização** autentica as pessoas e emite tokens de acesso. Você não escreve isso. É o seu provedor de identidade (Auth0, Keycloak, Entra, o seu próprio). +* O **resource server** é o seu servidor MCP. Ele verifica o token em cada requisição. +* O **cliente** descobre em qual servidor de autorização você confia, obtém um token dele e o envia de volta para você como `Authorization: Bearer `. + +O triângulo inteiro é esse. Tudo nesta página é o item do meio. + +## Um verificador de tokens {#a-token-verifier} + +O SDK não tem opinião sobre como é um token válido. Você diz a ele, implementando **`TokenVerifier`**: + +```python title="server.py" hl_lines="12-14 19-24" +--8<-- "docs_src/authorization/tutorial001.py" +``` + +* `TokenVerifier` é um protocolo com um único método assíncrono. `verify_token` recebe o token bruto do header `Authorization` e retorna um **`AccessToken`** se ele for válido, `None` se não for. Não há mais nada a implementar. +* Este aqui procura o token em uma tabela. Um de verdade verifica a assinatura de um JWT ou chama o endpoint de introspecção de tokens do servidor de autorização. Esse código é seu; o SDK apenas o chama. +* `token_verifier=` e `auth=` sempre andam juntos. Passe um sem o outro e `MCPServer(...)` levanta um `ValueError` antes mesmo de atender uma requisição. + +`AuthSettings` é a face pública do seu resource server: + +* `issuer_url`: o servidor de autorização que emite seus tokens. +* `resource_server_url`: a URL pública deste endpoint MCP. Ela indica *a qual* recurso um token se destina, e é onde fica o documento de descoberta. +* `required_scopes`: todo token deve conter todos eles. + +!!! tip + `examples/servers/simple-auth/` no repositório do SDK tem um `IntrospectionTokenVerifier` que chama + o endpoint da [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662) de um servidor de autorização real. É o formato que a maioria dos verificadores de produção tem. + +## O que você recebe sobre HTTP {#what-you-get-over-http} + +A autorização vive em headers HTTP, então só existe nos transportes HTTP. Execute-a no transporte em que você faz o deploy: `mcp.run(transport="streamable-http")` a coloca em `http://127.0.0.1:8000/mcp`, e **[Executando seu servidor](index.md)** tem o resto. O app agora tem duas rotas: + +```text +/mcp +/.well-known/oauth-protected-resource/mcp +``` + +Você registrou uma ferramenta. A segunda rota é do SDK. + +### Descoberta {#discovery} + +Faça um `GET` nesse caminho well-known e você recebe o **Protected Resource Metadata da [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728)**, montado direto a partir do seu `AuthSettings`: + +```json +{ + "resource": "http://127.0.0.1:8000/mcp", + "authorization_servers": ["https://auth.example.com/"], + "scopes_supported": ["notes:read"], + "bearer_methods_supported": ["header"] +} +``` + +Esse documento é como um cliente que nunca ouviu falar do seu servidor encontra o caminho de entrada: ele lê `authorization_servers` e vai até lá buscar um token. Você não escreveu nada disso. + +!!! check + Chame `/mcp` sem token (ou com um para o qual seu verificador retornou `None`) e a requisição é + barrada na porta: + + ```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"} + ``` + + Nada foi parseado e nenhuma ferramenta foi executada. E aquele ponteiro `resource_metadata` em `WWW-Authenticate` é + o que torna a descoberta automática: 401 -> documento de metadados -> servidor de autorização -> token -> nova tentativa. + +!!! warning + Nada disso protege o `stdio`. Um pipe não tem header `Authorization`, então `token_verifier` nunca é + consultado ali. A fronteira de segurança de um servidor `stdio` é o processo que o iniciou. O mesmo + vale para o `Client(mcp)` em memória que você usa nos testes: ele se conecta direto ao objeto do servidor + e pula a camada HTTP, autorização incluída. + +## A identidade de quem chama {#the-callers-identity} + +Dentro de qualquer handler, **`get_access_token()`** é o `AccessToken` que seu verificador retornou para a requisição atual: + +```python title="server.py" hl_lines="4 32-35" +--8<-- "docs_src/authorization/tutorial002.py" +``` + +* Funciona em ferramentas, recursos e prompts, e não há nada para passar adiante: o middleware de autenticação o guarda em uma variável de contexto por requisição. +* Você recebe de volta o **mesmo objeto que seu verificador montou**: `client_id`, `scopes`, `subject`, `expires_at` e quaisquer `claims` extras que você anexou. Esse é o gancho para regras por ferramenta: leia os escopos e recuse. +* Fora de uma requisição HTTP autenticada, ele retorna `None`. Em memória e sobre `stdio`, é sempre `None`. + +Chame `whoami` com `Authorization: Bearer alice-token` e o modelo lê: + +```text +alice (scopes: notes:read) +``` + +## A metade que o SDK não faz {#the-half-the-sdk-doesnt-do} + +O SDK entrega a metade do resource server: verificar, anunciar, recusar. Ele não entrega uma página de login, uma tela de consentimento nem um token. + +Para ver as três partes em ação, execute `examples/servers/simple-auth/` do repositório do SDK (um pequeno servidor de autorização e um resource server configurado exatamente como nesta página) e então aponte `examples/clients/simple-auth-client/` para ele e veja a dança completa de descoberta e token. + +!!! info + Existe um segundo argumento do construtor, `auth_server_provider=`, que embute um servidor de autorização + completo dentro do seu servidor MCP. Ele é anterior à separação AS/RS em torno da qual a especificação + de autorização do MCP foi construída. Servidores novos não devem recorrer a ele. + +Um servidor de autorização também pode aceitar a asserção assinada de um provedor de identidade corporativo no lugar de um usuário clicando em uma tela de consentimento, e o SDK dá suporte aos dois lados dessa troca. O grant, e o cliente que o apresenta, está em **[Asserção de identidade](../client/identity-assertion.md)**. + +## Recapitulando {#recap} + +* Sobre Streamable HTTP, seu servidor é um **resource server** do OAuth 2.1: ele verifica tokens, nunca os emite. +* `TokenVerifier` é toda a superfície de integração: um método assíncrono, token entra, `AccessToken | None` sai. +* `token_verifier=` e `auth=AuthSettings(issuer_url=..., resource_server_url=..., required_scopes=[...])` sempre andam juntos. +* O SDK publica o Protected Resource Metadata da [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) em `/.well-known/oauth-protected-resource/...` e responde a requisições não autenticadas com um 401 cujo header `WWW-Authenticate` aponta para ele. A história da descoberta é toda essa. +* `get_access_token()` em qualquer handler diz quem está chamando. +* Autorização é assunto do HTTP. O `stdio` e o cliente em memória nunca a veem. + +A metade do cliente (descobrir seu servidor de autorização e buscar o token para você) está em **[Clientes OAuth](../client/oauth-clients.md)**. E um cliente que *afirma* uma identidade em vez de pedir uma ao usuário está em **[Asserção de identidade](../client/identity-assertion.md)**. diff --git a/i18n/pt/pages/run/deploy.md b/i18n/pt/pages/run/deploy.md new file mode 100644 index 0000000000..8561e0e13d --- /dev/null +++ b/i18n/pt/pages/run/deploy.md @@ -0,0 +1,179 @@ +--- +translation: + sections: [28221886b198784f, f88ea1f1614f3a1d, ce926d686730b6d0, 3be24f8ad8bb5ab9, 3fad24032b2224ff, f25a7f860e579ecb, e758745df6fb7b0a] + tool: 1 +--- +# Deploy e escala {#deploy-scale} + +Seu servidor funciona. Agora ele precisa de um hostname de verdade, e de mais de um worker por trás dele. + +Quase nada disso é assunto do MCP. Você traz o servidor ASGI, o gerenciador de processos, o balanceador de carga. O que esta página tem é a lista curta das coisas que *são* assunto do MCP: uma configuração que bloqueia todo deploy, e os dois lugares em que "mais de um worker" muda o que o SDK faz. + +## Antes de qualquer coisa: a allowlist de Host {#before-anything-else-the-host-allowlist} + +`streamable_http_app()` não tem como saber atrás de qual hostname vai ser servido, então assume a resposta mais segura: localhost. Sem `transport_security=`, o app liga a **proteção contra DNS rebinding** e só aceita uma requisição se o header `Host` dela for `127.0.0.1:`, `localhost:` ou `[::1]:`. O header `Origin`, quando existe, tem que ser a forma `http://` do mesmo valor. Na sua máquina isso é exatamente o certo: impede que uma página web maliciosa controle seu servidor local através de um nome DNS que ela religou para `127.0.0.1`. + +Depois do deploy atrás de um hostname de verdade, esse mesmo padrão rejeita **toda requisição** até você dizer o contrário. A verificação roda antes de qualquer coisa com cara de MCP, então nada do que você construiu chega a ser consultado: + +```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=` é a correção. Coloque na allowlist o que você realmente serve: + +```python title="server.py" hl_lines="2 13-17" +--8<-- "docs_src/deploy/tutorial001.py" +``` + +* As entradas de `allowed_hosts` são strings exatas: `"mcp.example.com"` casa com um header `Host` sem porta e `"mcp.example.com:*"` casa com qualquer porta. Liste as duas. +* `allowed_origins` só importa para navegadores, porque nada mais envia `Origin`. É o par, do lado do servidor, da configuração de CORS em **[Adicione a um app existente](asgi.md)**. +* Atrás de um proxy reverso que já controla o header `Host`, desligar a verificação é a configuração honesta: `TransportSecuritySettings(enable_dns_rebinding_protection=False)`. +* Passar um `host=` que não seja localhost (por exemplo `host="mcp.example.com"`) **não** coloca esse hostname na allowlist. Só impede que o padrão de localhost arme a proteção, o que deixa todo Host e todo Origin aceitos. Diga o que você quer dizer com `transport_security=` em vez disso. + +!!! check + Apague o argumento `transport_security=security` e faça o deploy do app mesmo assim. Ele sobe, `/mcp` + roteia, e toda requisição (inclusive de um `curl` simples) volta assim: + + ```text + HTTP/1.1 421 Misdirected Request + + Invalid Host header + ``` + + Você não vai encontrar essas palavras do lado do cliente. Um `421` é uma resposta HTTP em texto puro, não um + erro JSON-RPC, então o cliente MCP levanta um erro genérico de transporte; o hostname de que ele + não gostou aparece só no log do **servidor**, como um único warning. Um servidor recém-implantado + que recusa toda conexão é uma allowlist de Host até que se prove o contrário. + **[Solução de problemas](../troubleshooting.md)** também começa por aqui. + +## Workers, e quem precisa de afinidade {#workers-and-who-has-to-be-sticky} + +Quando o hostname responder, coloque mais de um worker atrás dele. Não há botão no SDK para isso; você escala um app Starlette do jeito que escala qualquer app ASGI, entregando o objeto a algo que saiba fazer fork: + +```console +uvicorn server:app --workers 4 +``` + +Quatro processos, um socket. E agora a pergunta que todo deploy tem que responder: **uma requisição precisa chegar ao worker que viu a anterior?** + +Para um cliente que fala o protocolo **2026-07-28**, não. Uma requisição moderna é um único POST autocontido: nenhum handshake `initialize` antes dela, nenhum `Mcp-Session-Id` na resposta, nada *para onde* uma segunda requisição possa voltar. Roteie para qualquer worker. + +Isso não é um modo que você liga. `stateless_http=True` parece que deveria ser, mas o transporte roteia pelo header de requisição `MCP-Protocol-Version`, entrega uma requisição moderna ao handler moderno e **retorna**. A linha que lê `stateless_http` vem *depois* desse retorno. Não é que a flag seja ignorada no caminho 2026-07-28; ela nunca é alcançada. `stateless_http` é um botão só para o ramo **legado**, e o caminho moderno é sem sessão por construção. + +Para um cliente legado na versão de spec 2025-11-25 ou anterior, a resposta depende dessa flag: + +| Versão de protocolo do cliente | Sessão | O que o balanceador de carga precisa fazer | +| --- | --- | --- | +| **2026-07-28** | Nenhuma. `Mcp-Session-Id` nunca é definido. | Nada. Qualquer worker atende qualquer requisição. | +| **2025-11-25 e anteriores** (o padrão) | `Mcp-Session-Id`, guardado na memória de um worker. | **Sessões com afinidade (sticky sessions).** Uma requisição seguinte que chega a outro worker recebe um `404` *"Session not found"*. | +| **2025-11-25 e anteriores**, com `stateless_http=True` | Nenhuma. | Nada. O custo é o canal de retorno (back-channel) do servidor para o cliente (amostragem (sampling), elicitação por push, `roots/list`) e a retomada de streams. | + +Sessões com afinidade e o que o ramo legado custa têm sua própria página, **[Atendendo clientes legados](legacy-clients.md)**; as duas eras em si são **[Versões do protocolo](../protocol-versions.md)**. O que importa aqui é o formato da resposta: *no 2026-07-28 você já é stateless, sem nada para configurar.* + +O resto desta página são as duas coisas que ser stateless **não** te compra. + +## `requestState` entre workers {#requeststate-across-workers} + +Uma ferramenta (tool) **[de múltiplas idas e voltas](../handlers/multi-round-trip.md)** precisa de algo que o cliente tem que ir buscar (uma confirmação, uma escolha, uma credencial), então ela retorna uma pergunta em vez de uma resposta e termina na nova tentativa. Entre as duas rodadas o cliente segura um token opaco `request_state` que o servidor cunhou. Na nova tentativa o servidor tem que abrir esse token de novo. + +*Selado com qual chave?* Por padrão, uma que o servidor gerou com `os.urandom(32)` no momento da construção. Com `--workers 4` são quatro construções, em quatro processos: quatro chaves diferentes, nunca gravadas em lugar nenhum, nunca compartilhadas, perdidas no restart. + +Aqui está uma ferramenta que pergunta antes de agir, em um servidor que não configura nada: + +```python title="server.py" hl_lines="14 20" +--8<-- "docs_src/deploy/tutorial002.py" +``` + +A primeira rodada chega ao worker A. O worker A sela `refund:120` com a chave **dele** e retorna o token. O cliente coloca a pergunta na frente de uma pessoa, recebe um sim e tenta de novo. A nova tentativa é uma requisição HTTP novinha em folha. + +!!! check + Deixe essa nova tentativa chegar ao worker B. B tenta abrir um token que não cunhou, não consegue e recusa a + rodada inteira. `refund` nunca é chamado; o cliente recebe um erro JSON-RPC: + + ```json + { + "code": -32602, + "message": "Invalid or expired requestState", + "data": {"reason": "invalid_request_state"} + } + ``` + + Essa mensagem é **fixa**. Expirado, adulterado, reenviado contra argumentos diferentes ou (de + longe a causa mais comum em um deploy real) selado por um worker irmão: o cliente ouve + a mesma coisa toda vez, então o que trafega nunca revela qual verificação falhou. O motivo real é um + `WARNING` no log do servidor: + + ```text + requestState rejected on tools/call: unknown key + ``` + + Uma ferramenta de múltiplas idas e voltas que funcionava com um worker e começou a falhar *às vezes* com + dois é isto. As duas rodadas ainda precisam chegar ao mesmo processo, então ela falha exatamente na mesma + frequência com que seu balanceador de carga as separa. + +As duas rodadas são duas requisições HTTP independentes, e várias coisas corriqueiras as separam: um proxy que balanceia por requisição, uma conexão que caiu no meio, um deploy ou um restart, um cliente que persistiu o `request_state` e está retomando de um processo totalmente diferente (**[Conduzindo o loop por conta própria](../handlers/multi-round-trip.md#driving-the-loop-yourself)**). Qualquer uma delas é "um worker diferente". + +A correção é um argumento. Ela tem **duas** metades. + +```python title="server.py" hl_lines="1 12 14" +--8<-- "docs_src/deploy/tutorial003.py" +``` + +* **`keys=[...]`** é a metade que todo mundo encontra. Dê a cada instância o mesmo segredo (pelo menos 32 bytes dele), e toda instância consegue abrir o que qualquer irmã cunhou. `keys[0]` sela e toda chave da lista abre, e esse é o anel de rotação; **[Rotacionando chaves](../handlers/multi-round-trip.md#rotating-keys)** mostra como girá-lo sem downtime. +* **O nome do servidor** é a metade que quase ninguém encontra, e o motivo pelo qual novas tentativas entre instâncias continuam falhando depois que você compartilha a chave. Todo token selado carrega o `name` do servidor como uma **claim de audiência**, verificada estritamente na volta. Duas instâncias construídas a partir do mesmo código têm o mesmo nome e nunca percebem isso. Dê nomes diferentes a elas (`MCPServer(f"billing-{POD}")` parece boa higiene de observabilidade), e toda nova tentativa entre instâncias é recusada exatamente como acima, com ou sem chave compartilhada. O log diz `audience` em vez de `unknown key`; o cliente não consegue distinguir. + +Cunhe o segredo uma vez e entregue o mesmo valor a toda instância. Este é o comando que a própria mensagem de erro do SDK manda você rodar se passar menos de 32 bytes para ele: + +```console +python -c "import secrets; print(secrets.token_hex(32))" +``` + +!!! warning "Mesmas chaves *e* o mesmo nome" + Um deploy com múltiplas instâncias precisa compartilhar os dois. Se nomes por instância são essenciais para você, + dê à frota uma audiência explícita em vez disso: `RequestStateSecurity(keys=[...], audience="billing")`. + Toda instância então cunha e aceita sob `"billing"`, não importa como se chame. + +Todo o resto sobre o selo está em **[Protegendo o `requestState`](../handlers/multi-round-trip.md#protecting-requeststate)**: o que ele vincula, o `ttl` por rodada (600 segundos por padrão), trazer seu próprio codec, por que o padrão não configurado é exatamente o certo em `stdio`. A contribuição inteira desta página é um checklist de dois itens: *mesmas chaves, mesmo nome.* + +!!! info + Você está neste caminho mesmo que nunca tenha digitado `InputRequiredResult`. Uma ferramenta cujos parâmetros + usam `Resolve(...)` (**[Dependências](../handlers/dependencies.md)**) é uma ferramenta de múltiplas idas e voltas, + e o SDK cunha e sela o `request_state` dela por ela. Mesma chave padrão, mesma falha entre + workers, mesma correção. + +## Notificações de mudança entre réplicas {#change-notifications-across-replicas} + +O stream `subscriptions/listen` de um cliente é uma única resposta de longa duração, então fica preso a uma réplica pela vida toda. Um `ctx.notify_resource_updated(...)` publicado em uma réplica **diferente** tem que chegar até ele. + +A costura entre os dois é o `SubscriptionBus`. Qualquer bus que você dê a um servidor é aquele em que toda publicação entra e que todo stream aberto escuta, então entregue o mesmo bus a toda réplica: + +```python title="server.py" hl_lines="2 7 9" +--8<-- "docs_src/deploy/tutorial004.py" +``` + +Nada no fan-out se importa com qual objeto de servidor um stream está ligado. Dois servidores segurando um único `InMemorySubscriptionBus` já se comportam assim: abra um stream de listen em um, `edit_note` no outro, e o stream fica sabendo. Esse bus em memória só abrange objetos de servidor dentro de um processo, o que faz dele o modelo, não o deploy: + +* Entre processos de verdade, **o SDK não traz nenhum bus que possa te ajudar.** `SubscriptionBus` é um `Protocol` de dois métodos (`publish` e `subscribe`) que você implementa sobre seu próprio backend de pub/sub (Redis, NATS, o que você já roda) e passa como `MCPServer(subscriptions=...)`. **[Assinaturas](../handlers/subscriptions.md#scaling-past-one-process)** tem o esboço e o contrato. +* O bus carrega quatro pequenos eventos tipados, nunca JSON-RPC. Confirmação, filtragem e ciclo de vida do stream ficam no SDK, então seu bus não consegue quebrar o protocolo; ele só consegue mover eventos entre processos. +* Streams **não** são retomáveis e eventos **não** são reenviados. Perder uma réplica derruba os streams dela; os clientes escutam de novo e buscam de novo. Não há event store para compartilhar e nada mais para configurar. Este é o único lugar em que escalar horizontalmente é de fato só mais do mesmo. + +## O que o SDK não te dá {#what-the-sdk-does-not-give-you} + +Um `MCPServer` é uma implementação de protocolo, não um servidor de aplicação. Os botões de deploy que você vai procurar em seguida estão ausentes de propósito: + +* **Sem `workers=`.** `mcp.run("streamable-http")` inicia exatamente um processo uvicorn, e isso é tudo o que ele jamais vai iniciar. Multiprocesso é `streamable_http_app()` entregue ao que você já usa para fazer deploy de ASGI: `uvicorn --workers`, gunicorn, o gerenciador de processos da sua plataforma. Esta página deliberadamente não é um tutorial de nenhum deles; a documentação deles é melhor do que uma cópia aqui seria. +* **Sem rota de health check.** `@mcp.custom_route("/health", methods=["GET"])` é a resposta inteira, e nunca é autenticada mesmo quando o resto do servidor é. Isso está certo para uma sonda de liveness, errado para qualquer coisa privada. **[Adicione a um app existente](asgi.md#custom-routes)** mostra uma. +* **Sem objeto de configurações de produção.** Não há lugar no `MCPServer` para anotar timeouts, TLS, shutdown gracioso ou limites de conexão, porque nada disso é trabalho dele. Isso pertence ao seu servidor ASGI, e você configura lá. **[Executando seu servidor](index.md)** cobre o punhado de configurações que o construtor *de fato* aceita. +* **Nenhum `EventStore` incluído, e no 2026-07-28 nenhum uso para um.** A retomada de streams é uma funcionalidade do ramo legado com estado; uma troca moderna é um POST, uma resposta, e nada para retomar. + +## Recapitulando {#recap} + +* Por padrão, o app responde apenas a requisições endereçadas ao localhost. `transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])` é o portão para ir ao ar: até você passar isso, toda requisição atrás de um hostname de verdade é um `421` e o motivo só está no log do servidor. +* No 2026-07-28 não há sessão e nada em que um balanceador de carga possa ter afinidade. `stateless_http=True` é um botão só para o legado porque uma requisição moderna é roteada e respondida antes de essa flag ser lida. +* A chave padrão do `requestState` é `os.urandom(32)`, cunhada por processo. Uma nova tentativa de múltiplas idas e voltas que chega a um worker diferente falha com `-32602` *"Invalid or expired requestState"*. +* A correção é `RequestStateSecurity(keys=[...])` **e** o mesmo nome de servidor em toda instância. O nome é a claim de audiência padrão do token. Mesmas chaves, mesmo nome. +* Notificações de mudança atravessam réplicas por um único `SubscriptionBus` compartilhado. A única implementação do SDK é dentro do processo; o `Protocol` de dois métodos sobre seu próprio pub/sub é seu para escrever. +* Não há `workers=`, nem rota de health, nem objeto de configurações de produção. Traga seu próprio servidor ASGI. + +A outra coisa que um hostname de verdade precisa na frente dele é um token: **[Autorização](authorization.md)**. diff --git a/i18n/pt/pages/run/index.md b/i18n/pt/pages/run/index.md new file mode 100644 index 0000000000..65484fce8e --- /dev/null +++ b/i18n/pt/pages/run/index.md @@ -0,0 +1,156 @@ +--- +translation: + sections: [fea8d769ff9edeba, ce8e2ad42f29ef71, 0d705efb19cf99c2, 7a53ead3e704a7f0, 9adc400e8c88e854, 318893ad8e2e9924, 6b63ab96b34476c0] + tool: 1 +--- +# Executando seu servidor {#running-your-server} + +`mcp.run()` inicia o servidor. + +A única decisão que você toma é o **transporte**: como os bytes entre seu servidor e o cliente realmente trafegam. + +## Escolha um transporte {#pick-a-transport} + +| Transporte | O que é | Quando | +|---|---|---| +| `stdio` | O host inicia seu arquivo como um subprocesso e conversa pelo stdin e stdout dele. | Servidores locais. O padrão. | +| `streamable-http` | Um servidor HTTP de verdade, escutando em uma porta. | Tudo o que você faz deploy. | +| `sse` | O transporte HTTP antigo. | Nunca. | + +!!! warning + O SSE foi substituído pelo Streamable HTTP na revisão 2025-03-26 do protocolo. + `mcp.run(transport="sse")` ainda funciona, com suas próprias opções `sse_path=` e `message_path=`, + mas existe apenas para clientes que ainda não migraram. Não construa nada novo em cima dele. + +## `mcp.run()` {#mcprun} + +```python title="server.py" hl_lines="12-13" +--8<-- "docs_src/run/tutorial001.py" +``` + +* `run()` é síncrono. Ele bloqueia durante toda a vida do servidor. +* Sem argumentos, o transporte é `stdio`. +* Ele fica sob `if __name__ == "__main__":` porque tudo o que carrega seu servidor (`mcp dev`, `mcp run`, `mcp install`, seus testes) **importa** este arquivo. A guarda impede que um import vire um servidor em execução. + +### stdio {#stdio} + +Não há nada para configurar. O host inicia seu arquivo como processo filho, escreve requisições no stdin dele e lê respostas do stdout. + +Execute você mesmo e veja a consequência: + +```console +python server.py +``` + +Nada é impresso, e ele não retorna. Está esperando no stdin que um host fale primeiro. + +Isso também significa que o stdout **é o canal de comunicação**. Enquanto serve, o SDK move esse canal para um descritor privado e desvia para o stderr a saída que é *descarregada* (flushed) no stdout (um subprocesso escrevendo no stdout herdado, um `print()` com flush), onde ela não pode corromper o fluxo. A saída descarregada no stdout *antes* de o servidor começar a servir (um script wrapper fazendo echo, um print sem buffer em tempo de import) ainda cai no canal, assim como um `print()` que fica no buffer até o interpretador esvaziá-lo na saída. Para a saída que você realmente quer, o módulo `logging` é a ferramenta certa: o handler dele descarrega cada registro no stderr assim que acontece. Essa história está em **[Logging](../handlers/logging.md)**. + +### Experimente {#try-it} + +```console +uv run mcp dev server.py +``` + +O Inspector faz exatamente o que um host de verdade faz: inicia `server.py` como subprocesso e se conecta a ele via stdio. + +Você nunca informou uma porta. Não existe nenhuma. + +## Streamable HTTP {#streamable-http} + +Para colocar o mesmo servidor em uma porta, nomeie o transporte (e suas opções) em `run()`: + +```python title="server.py" hl_lines="13" +--8<-- "docs_src/run/tutorial002.py" +``` + +Essa única linha monta um app Starlette e o serve com uvicorn. Os clientes se conectam em `http://127.0.0.1:3001/mcp`. + +Cada transporte tem seus próprios argumentos nomeados, todos em `run()`: + +* `host` / `port`: onde escutar. Padrões `127.0.0.1` e `8000`. +* `streamable_http_path`: onde fica o endpoint MCP. Padrão `/mcp`. +* `json_response=True`: responde a cada POST com um único corpo JSON em vez de um fluxo SSE. Esse corpo tem espaço para a resposta e nada mais, então uma ferramenta que chama o cliente de volta no meio da requisição (`ctx.elicit()`, amostragem (sampling)) lança `NoBackChannelError` nesse trecho, e as notificações ligadas à chamada em andamento (progresso de `ctx.report_progress()`, mensagens de log por chamada) são descartadas; o fluxo `GET` avulso continua transportando as que não têm relação. +* `stateless_http=True`: um transporte novo por requisição, sem rastreamento de sessão. +* `max_request_body_size`: maior corpo de POST aceito, em bytes. O padrão é 4 MiB; requisições maiores + recebem HTTP 413 antes do parsing ou da criação da sessão. Aumente apenas quando mensagens MCP legítimas + ultrapassarem esse tamanho. +* `event_store`, `retry_interval`, `transport_security`: retomada e proteção contra DNS rebinding. Podem esperar até você fazer o deploy em algum lugar que não seja o localhost; **[Deploy e escala](deploy.md)** cobre `transport_security`. + +!!! warning + As opções de transporte vão para `run()`, **não** para `MCPServer(...)`. O construtor descreve o que + seu servidor *é*: nome, versão, instruções. `run()` descreve como ele é servido. Inverta isso + e o Python responde antes mesmo de o MCP entrar em cena: + + ```text + TypeError: MCPServer.__init__() got an unexpected keyword argument 'port' + ``` + +`run()` é o caminho curto. No momento em que você precisar de mais (seu servidor montado dentro de um app existente, dois servidores em um só processo, CORS para clientes no navegador), monte o app ASGI você mesmo e entregue a qualquer host ASGI. Isso está em **[Adicione a um app existente](asgi.md)**. + +## Configurações do servidor {#server-settings} + +Algumas coisas relacionadas à execução não dizem respeito ao transporte. São argumentos do construtor: + +```python title="server.py" hl_lines="3" +--8<-- "docs_src/run/tutorial003.py" +``` + +* `log_level`: passado para `logging.basicConfig()` no momento em que `MCPServer(...)` é construído. Isso configura o logger **raiz**, então define o nível dos seus próprios loggers também, não só os do SDK. Padrão `"INFO"`. +* `debug`: repassado ao app Starlette que os transportes HTTP montam. Padrão `False`. + +Ambos vão parar em `mcp.settings`, que você pode ler de volta em tempo de execução. + +## O comando `mcp` {#the-mcp-command} + +O extra `[cli]` instala uma pequena ferramenta de linha de comando em torno de tudo isso. + +`mcp dev` executa seu servidor sob o **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` adiciona pacotes ao ambiente que ele monta; `--with-editable` instala seu próprio pacote nele. Ele precisa de `npx` no seu `PATH`: o Inspector é um app Node.js. + +`mcp run` importa o arquivo, encontra o objeto do servidor (um `mcp`, `server` ou `app` no nível do módulo) e chama `run()` nele: + +```console +uv run mcp run server.py +uv run mcp run server.py:bookshop +``` + +O sufixo `:` nomeia o objeto quando ele não se chama `mcp`, `server` ou `app`. + +Seu bloco `if __name__ == "__main__":` nunca executa aqui: o próprio `mcp run` chama `run()`, e a única opção que ele repassa é `--transport`. + +`mcp install` registra o servidor no **Claude Desktop**, para que o app o inicie por você: + +```console +uv run mcp install server.py --name "Bookshop" +uv run mcp install server.py -v API_KEY=abc123 -f .env +``` + +`-v KEY=VALUE` e `-f .env` gravam variáveis de ambiente nessa entrada. O Claude Desktop inicia seu servidor em um processo próprio. O ambiente do seu shell não está lá. + +O Claude Desktop é o único host que `mcp install` conhece. Todos os outros hosts (Claude Code, Cursor, VS Code) aceitam o mesmo comando de inicialização no próprio arquivo de configuração, e **[Conecte a um host de verdade](../get-started/real-host.md)** tem cada um deles. + +`mcp version` imprime a versão do SDK instalada. + +!!! tip + `mcp dev` e `mcp run` só entendem `MCPServer`. Se você constrói com o `Server` de baixo nível, + você mesmo o executa. Veja **[O Server de baixo nível](../advanced/low-level-server.md)**. + +## Recapitulando {#recap} + +* Um **transporte** é como os bytes chegam ao seu servidor: `stdio` para um subprocesso local, `streamable-http` para uma porta. O SSE foi substituído. +* `mcp.run()` escolhe o transporte. Sem argumentos é `stdio`, e ele bloqueia. +* Toda opção de transporte (`host`, `port`, `streamable_http_path`, ...) é um argumento de `run()`, nunca de `MCPServer(...)`. +* Mantenha `run()` sob `if __name__ == "__main__":`. Tudo o que carrega seu servidor importa o arquivo primeiro. +* `log_level=` e `debug=` são argumentos do construtor; eles vão parar em `mcp.settings`. +* `mcp dev` para o Inspector, `mcp run` para executar um arquivo, `mcp install` para o Claude Desktop, `mcp version` para a versão. +* O transporte nunca muda o que seu servidor *é*: os três arquivos desta página expõem exatamente a mesma ferramenta. + +Quando o próprio `run()` é o limite (seu servidor dentro de um app que já existe), o caminho é **[Adicione a um app existente](asgi.md)**. Um hostname de verdade e mais de um worker é **[Deploy e escala](deploy.md)**. E se alguns dos seus clientes ainda estão na versão 2025-11-25 da especificação ou anterior, **[Servindo clientes legados](legacy-clients.md)** traz as boas notícias. diff --git a/i18n/pt/pages/run/legacy-clients.md b/i18n/pt/pages/run/legacy-clients.md new file mode 100644 index 0000000000..e6f0349dbb --- /dev/null +++ b/i18n/pt/pages/run/legacy-clients.md @@ -0,0 +1,132 @@ +--- +translation: + sections: [3d1663c18edc824c, d4fd37009a13f03d, af9f398a5a8b679a, 470c2dd144294d69, 8e45827e6d24e8c8, 91dfd0ce98ebb03c] + tool: 1 +--- +# Atendendo clientes legados {#serving-legacy-clients} + +O MCP tem duas eras de protocolo: a era do handshake `initialize`, até a versão da especificação `2025-11-25`, e a era moderna, `2026-07-28`. **[Versões do protocolo](../protocol-versions.md)** é a página sobre a divisão em si. + +Esta página trata do lado do servidor dessa divisão, e a resposta cabe em uma frase: **o `streamable_http_app()` que você já faz o deploy atende as duas.** + +O SDK roteia cada requisição pelo header `MCP-Protocol-Version`. Uma requisição que indica `2026-07-28` vai para o handler moderno. Uma requisição que indica uma versão da era do handshake, ou que não traz header nenhum (que é como o `initialize` de um cliente pré-2026 chega), vai para o transporte que esses clientes esperam: handshake `initialize`, sessões e tudo mais. Isso acontece por requisição, antes do seu código, no mesmo app. + +Então um cliente legado não é algo *para* o qual você constrói. É algo que se conecta *ao* servidor que você já escreveu. Você não configura nada. + +!!! note + Nada, literalmente. Não existe opção `legacy=`, nem allowlist de versões, nem forma de rejeitar ou + desabilitar uma era: nem em `streamable_http_app()`, nem em `run()`, nem no gerenciador de sessões. + As duas eras estão sempre ativas. O mais próximo de uma chave por era nessa assinatura é + `stateless_http`, e ele é a maior parte desta página. + +## Um handler, as duas eras {#one-handler-both-eras} + +Aqui está uma ferramenta (tool) que precisa perguntar algo ao usuário, e clientes das duas eras chamando-a: + +```python title="server.py" hl_lines="24 37-38" +--8<-- "docs_src/legacy_clients/tutorial001.py" +``` + +`reserve` precisa de uma coisa que o modelo não forneceu: quantas cópias. `Annotated[..., Resolve(ask_quantity)]` é como uma ferramenta declara isso (**[Dependências](../handlers/dependencies.md)** tem essa história completa). Nada em `reserve` cita uma versão, verifica uma capacidade ou ramifica. + +Os dois clientes ficam abertos **ao mesmo tempo**, no mesmo objeto `mcp`. `mode="legacy"` executa o handshake `initialize`: exatamente a conexão que um cliente pré-2026 abre. O outro usa o padrão e cai em `2026-07-28`. + +```text +2025-11-25 {'result': "Reserved 2 of 'Dune'."} +2026-07-28 {'result': "Reserved 2 of 'Dune'."} +``` + +Mesmo servidor, mesmo handler, mesma resposta. A funcionalidade inteira é essa. + +Vale parar no *como*, porque os dois clientes receberam a mesma pergunta por dois fios completamente diferentes. A conexão `2026-07-28` não tem canal para o servidor enviar uma requisição, então `Resolve` retornou a pergunta dentro do resultado da ferramenta e o cliente repetiu a chamada com a resposta (**[Requisições de múltiplas idas e voltas](../handlers/multi-round-trip.md)**). A conexão `2025-11-25` não tem nada disso; ali, `Resolve` enviou uma requisição `elicitation/create` ao vivo no meio da chamada e esperou. Você não escreveu nenhum dos dois. `Resolve` lê a versão negociada da conexão e escolhe; o corpo da sua ferramenta vê um `AcceptedElicitation` de qualquer forma. + +!!! tip + Essa portabilidade entre eras é *o motivo* de `Resolve` ser a API sobre a qual construir. Seu irmão mais velho, `ctx.elicit()` + (**[Elicitação](../handlers/elicitation.md)**), só envia `elicitation/create`, então só + funciona em uma conexão legada. Em uma `2026-07-28`, a chamada falha. Se uma ferramenta ainda o usa, + a correção é a que você vê acima, não uma verificação de versão. + +## Quanto uma sessão legada custa para você {#what-a-legacy-session-costs-you} + +O roteamento é grátis. A sessão não. + +Uma conexão `2026-07-28` é **sem sessão**: cada requisição é independente, e o handler moderno nunca emite um `Mcp-Session-Id`. Uma conexão legada é o oposto. No momento em que um cliente pré-2026 envia `initialize`, o SDK gera um `Mcp-Session-Id`, retorna-o em um header de resposta e mantém um registro vivo por trás dele para as requisições posteriores do cliente encontrarem: a versão negociada, os streams abertos, uma task em segundo plano conduzindo a sessão. + +Esse registro é **um `dict` simples dentro do processo**. Não existe armazenamento distribuído de sessões nem forma de plugar um. + +Com um worker, isso é invisível. Com dois, é o problema inteiro: uma requisição que traz um `Mcp-Session-Id` e cai em um worker que não o gerou não encontra nada naquele dict, e a resposta é um `404` (`Session not found`), não o resultado da ferramenta. Então, no momento em que você executa mais de um worker, **clientes legados precisam de roteamento sticky**: toda requisição de uma sessão tem que chegar ao processo que a iniciou. Clientes modernos nunca precisam; eles não têm sessão à qual aderir. **[Deploy e escala](deploy.md)** cobre stickiness e tudo mais sobre executar mais de uma dessas instâncias. + +!!! warning + `event_store=` parece a solução e não é. Ele é **retomabilidade** (reenviar eventos SSE + perdidos para um cliente que se reconecta à *mesma* sessão), não um armazenamento de sessões. Ele nunca torna uma + sessão alcançável a partir de outro processo. + +## A única chave: `stateless_http` {#the-one-knob-stateless_http} + +Se stickiness é um custo que você se recusa a pagar, existe exatamente uma coisa que você pode mudar. + +```python title="server.py" hl_lines="28" +--8<-- "docs_src/legacy_clients/tutorial002.py" +``` + +Esse é o servidor do topo da página mais uma keyword. `stateless_http=True` faz a perna legada construir uma sessão descartável por requisição: nenhum `Mcp-Session-Id` emitido, nada lembrado entre requisições, então qualquer worker pode atender qualquer requisição e o load balancer pode fazer o que quiser. + +Duas coisas sobre ele importam mais do que o que ele faz. + +**Ele só afeta a perna legada.** As requisições são roteadas pelo header de versão *antes* de `stateless_http` ser lido, então o caminho moderno nunca o vê. Uma conexão `2026-07-28` já é sem sessão e fica exatamente igual com qualquer um dos valores. + +**Ele custa os dois canais servidor-para-cliente nessa perna.** Uma sessão que vive por um `POST` não tem stream para o servidor empurrar uma requisição nem stream independente para empurrar notificações. Toda requisição iniciada pelo servidor levanta `NoBackChannelError`: `ctx.elicit()`, as chamadas aposentadas de amostragem (sampling) e roots (**[Funcionalidades obsoletas](../deprecated.md)**) e, sim, `Resolve` fazendo sua pergunta a um cliente *legado*. As notificações nem recebem erro; são descartadas silenciosamente. + +!!! note + `json_response=True` não é essa chave, mas cobra metade do mesmo custo em *toda* sessão + legada: um `POST` respondido com um único corpo JSON não tem stream para o canal com escopo de requisição, + então um `ctx.elicit()` no meio da requisição levanta o mesmo `NoBackChannelError` e as notificações ligadas à + requisição são descartadas. O stream independente da sessão fica intacto: notificações não relacionadas + continuam chegando. + +!!! check + Faça a coisa errada. `reserve` é exatamente a ferramenta que acabou de atender os dois clientes. Faça o deploy dela com + `stateless_http=True`, conecte os mesmos dois clientes via HTTP e chame-a de cada um. + + O cliente moderno ainda recebe `Reserved 2 of 'Dune'.` A perna moderna não mudou. + + A chamada do cliente legado não volta como um resultado `is_error` que o modelo poderia ler. + A requisição inteira falha, como um erro de protocolo de nível superior: + + ```text + mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests. + ``` + + `Resolve` não salvou você. Em uma conexão `2025-11-25` ele *tem* que enviar `elicitation/create`, + e o canal de que precisa é exatamente o que `stateless_http=True` abriu mão. Código + portável entre eras não é código sem canal de retorno (back-channel). + +Então é uma troca real, e ela só existe na perna legada: **com sessão e sticky, ou sem estado e unidirecional.** Se suas ferramentas nunca chamam o cliente de volta, `stateless_http=True` é grátis e você deve usá-lo. Se chamam, mantenha as sessões e mantenha o roteamento sticky. + +## Onde seu código realmente se bifurca {#where-your-code-actually-forks} + +Quase em lugar nenhum. + +Ferramentas, recursos, prompts, saída estruturada, progresso, erros: nenhum deles se importa com qual era chamou. O handshake `initialize`, o `Mcp-Session-Id`, o stream independente, o `DELETE` que encerra uma sessão: o SDK cuida de tudo isso, e um handler nunca vê nada disso. Entrada interativa é *o* lugar em que as eras genuinamente diferem no fio, e `Resolve` existe para que isso não seja problema seu: você acabou de ver uma ferramenta atender as duas. + +Sobra exatamente uma coisa, e são as **notificações de mudança**, porque as duas eras escutam em canais diferentes: + +* Um cliente `2026-07-28` abre um stream `subscriptions/listen` e lê o barramento de assinaturas. `ctx.notify_resource_updated()` (e `notify_tools_changed()`, `notify_prompts_changed()`, `notify_resources_changed()`) publicam ali, e *somente* ali. **[Assinaturas](../handlers/subscriptions.md)** é essa página. +* Um cliente legado lê o stream independente que sua sessão mantém aberto. `ctx.session.send_resource_updated()` (e `send_tool_list_changed()` e companhia) escrevem na *conexão* que carregou a requisição: para uma sessão legada, esse é o stream independente dela. Uma conexão moderna não tem lugar para isso: via HTTP não existe tal canal, e via stdio os quatro tipos de notificação de mudança trafegam apenas em streams `subscriptions/listen`, então em uma conexão moderna a notificação é descartada silenciosamente. + +Via HTTP, nenhuma das duas chamadas alcança os clientes da outra era. Para avisar todo mundo, chame as duas: + +```python title="server.py" hl_lines="19-20" +--8<-- "docs_src/legacy_clients/tutorial003.py" +``` + +Duas linhas, nenhum `if`, nenhuma verificação de versão, e pronto. Essa é a lista inteira de coisas que um handler faz diferente porque um cliente legado existe. + +## Recapitulando {#recap} + +* Um único `streamable_http_app()` atende as duas eras de protocolo. O SDK roteia cada requisição pelo header `MCP-Protocol-Version`; não há nada para configurar nem chave de era para procurar. +* Um cliente legado custa uma sessão: um registro `Mcp-Session-Id` dentro do processo, sem armazenamento distribuído por trás. Mais de um worker significa **roteamento sticky**, ou o worker errado responde `404 Session not found`. **[Deploy e escala](deploy.md)** tem a história completa de múltiplos workers. +* `stateless_http=True` é a única chave, e ela vale **apenas para a perna legada**. Ela compra balanceamento de carga livre para clientes legados ao preço dos dois canais servidor-para-cliente nessa perna: requisições iniciadas pelo servidor levantam `NoBackChannelError` (um erro de nível superior no cliente, não um resultado `is_error`), e as notificações são descartadas. +* Uma conexão `2026-07-28` é sem sessão de qualquer forma. `stateless_http` nunca a afeta. +* O código do seu handler se bifurca por era em exatamente um lugar: notificações de mudança. `ctx.notify_*` alcança clientes `subscriptions/listen`; `ctx.session.send_*` alcança sessões legadas. Chame os dois. +* Todo o resto (incluindo pedir entrada ao usuário, via `Resolve`) é portável entre eras por construção. Escreva a versão moderna uma vez só. diff --git a/i18n/pt/pages/run/opentelemetry.md b/i18n/pt/pages/run/opentelemetry.md new file mode 100644 index 0000000000..df80025cc4 --- /dev/null +++ b/i18n/pt/pages/run/opentelemetry.md @@ -0,0 +1,117 @@ +--- +translation: + sections: [bc0227014724fa49, 15738c2f7fd67d86, a2c17bbe3f707e2f, d0d853376f162c06, b6368643fcc1c8d8, 902e33e17564a607] + tool: 1 +--- +# OpenTelemetry {#opentelemetry} + +Seu servidor já é rastreado. Você não precisa adicionar nada. + +Todo servidor que você cria emite um span do [OpenTelemetry](https://opentelemetry.io/) para cada +mensagem que processa. Você não escreveu isso e não importa isso. Está lá no momento em que você +chama `MCPServer(...)`. + +```python title="server.py" +--8<-- "docs_src/opentelemetry/tutorial001.py" +``` + +Esse é um servidor completo e rastreado. Chame `search_books` e um span é criado para a chamada. O +mesmo vale para o `Server` de baixo nível: o rastreamento vive nos dois. + +## O que você recebe {#what-you-get} + +Cada mensagem recebida vira um span `SERVER` com o nome do método e do seu alvo. Então um +`tools/call` para `search_books` é o span `tools/call search_books`, e um `tools/list` simples +é apenas `tools/list`. + +Cada span carrega alguns atributos: + +* `mcp.method.name` e `mcp.protocol.version`, em todo span. +* `jsonrpc.request.id`, em uma requisição (uma notificação não tem). +* Um handler que lança uma exceção define o status do span como erro. Um resultado de ferramenta com `is_error=True` também. + +E como rastrear uma chamada de ferramenta é algo tão comum de se querer, os spans `tools/call` +falam as [convenções semânticas GenAI](https://opentelemetry.io/docs/specs/semconv/gen-ai/) do OpenTelemetry: + +* `gen_ai.operation.name`, definido como `"execute_tool"`. +* `gen_ai.tool.name`, definido como a ferramenta sendo chamada. + +Um span `prompts/get` recebe `gen_ai.prompt.name` no mesmo espírito. Os métodos de listagem não +carregam chaves `gen_ai.*`, porque não há nada para nomear. + +!!! tip + Esses atributos GenAI são o motivo pelo qual uma interface de rastreamento agrupa suas chamadas + de ferramenta do mesmo jeito que agrupa as de qualquer outro agente. Você ganha esse agrupamento + de graça, sem código extra. + +## Não custa nada até você querer {#it-costs-nothing-until-you-want-it} + +Aqui está a parte que faz de "ligado por padrão" um padrão confortável. + +O SDK depende apenas de `opentelemetry-api`, a metade leve do OpenTelemetry. Sem nenhum SDK e +nenhum exporter instalados, criar um span é um no-op. Então os spans que seu servidor está +emitindo agora mesmo não custam quase nada, e ninguém os está coletando. + +No dia em que você quiser *vê-los*, instale a outra metade e aponte-a para algum lugar: + +```console +uv add opentelemetry-sdk opentelemetry-exporter-otlp +``` + +Configure um exporter do jeito habitual do OpenTelemetry, e cada span que o SDK vinha criando +em silêncio se acende. O código do seu servidor não muda. Nem uma linha. + +!!! info + O [Pydantic Logfire](https://logfire.pydantic.dev/) é um desses backends, e faz a + configuração para você: `pip install logfire`, `logfire.configure()`, e seus spans MCP + aparecem na visualização ao vivo. Ele é construído sobre o OpenTelemetry, então tudo o que + vem abaixo também se aplica a ele. + +## Traces que atravessam a rede {#traces-that-cross-the-wire} + +Um trace é mais útil quando acompanha uma requisição do cliente até o servidor, em uma única +imagem conectada. + +Quando o cliente e o servidor rodam o SDK, essa conexão é automática. O cliente injeta o +[contexto de trace W3C](https://www.w3.org/TR/trace-context/) na requisição, e o servidor o lê de +volta, de modo que o span do servidor fica aninhado sob o span do cliente no mesmo trace. Isso é a +[SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414), e você ganha isso +sem pedir. + +Se a mensagem recebida não carrega contexto de trace, por exemplo uma requisição de um cliente que +não é o SDK, o span do servidor simplesmente fica sob o span que já estiver ativo no servidor, em +vez de iniciar um trace órfão novo. + +## Desligando {#turning-it-off} + +O rastreamento é um middleware, o primeiro da lista do seu servidor. Se você quer mesmo um servidor +que não emite spans, retire-o: + +```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 + Esse import tem um underscore inicial, e isso é de propósito. A classe é provisória, do mesmo + jeito que [`Server.middleware`](../advanced/middleware.md) é provisório, então o caminho de + import é algo que você deve esperar que mude. Você quase nunca precisa disso: sem um exporter + instalado os spans são gratuitos, então a resposta habitual é deixá-los ligados e não instalar + um exporter. + +## Recapitulando {#recap} + +* Todo `MCPServer` e todo `Server` de baixo nível emite um span `SERVER` por mensagem recebida, + por padrão. Você não escreve nada. +* Os spans carregam `mcp.method.name` e `mcp.protocol.version`; `tools/call` e `prompts/get` + também carregam atributos GenAI para que suas chamadas de ferramenta se agrupem como as de + qualquer outro agente. +* Não custa nada até você instalar um SDK do OpenTelemetry e um exporter, e aí tudo se acende + sem nenhuma mudança no seu servidor. +* O contexto de trace do cliente para o servidor se propaga automaticamente quando os dois lados + rodam o SDK. + +O que decide se uma requisição chega a rodar é a **[Autorização](authorization.md)**. diff --git a/i18n/pt/pages/servers/completions.md b/i18n/pt/pages/servers/completions.md new file mode 100644 index 0000000000..764015413d --- /dev/null +++ b/i18n/pt/pages/servers/completions.md @@ -0,0 +1,130 @@ +--- +translation: + sections: [72f9c964769076dd, 9a2c14e10935b515, 235299eb78ab12d7, 8aee1e78c8237fb8, 9bd86acd4112138f, 55343cb7f250dc7b] + tool: 1 +--- +# Completions {#completions} + +Um cliente que monta uma UI em cima do seu servidor quer autocompletar os valores dos argumentos enquanto o usuário digita: nomes de linguagens, nomes de repositórios, caminhos de arquivo. + +É com as **completions** que o seu servidor fornece essas sugestões. + +## Algo que valha a pena completar {#something-worth-completing} + +As completions se aplicam a exatamente duas coisas: os argumentos de um **prompt** e os parâmetros de um **template de recurso**. Então comece com um servidor que tenha um de cada: + +```python title="server.py" hl_lines="6 12" +--8<-- "docs_src/completions/tutorial001.py" +``` + +Ainda não há nada de completions aqui. + +* `review_code` recebe um `language`. O usuário não deveria precisar adivinhar quais grafias você aceita. +* `github_repo` recebe um `owner` e um `repo`. Campos de texto livre para os dois resultam em um formulário ruim. + +## O handler de completion {#the-completion-handler} + +Adicione **uma** função decorada com `@mcp.completion()`: + +```python title="server.py" hl_lines="21-29" +--8<-- "docs_src/completions/tutorial002.py" +``` + +* Existe um handler por servidor. Toda requisição de completion chega aqui, e você ramifica de acordo com o que está sendo completado. +* Ele precisa ser `async def`: o SDK faz o await dele. +* Ele recebe três argumentos: + * `ref`: *qual* prompt ou template de recurso, como um `PromptReference` ou um `ResourceTemplateReference`. É com `isinstance` que você distingue um do outro. + * `argument`: `argument.name` é o argumento que está sendo completado, `argument.value` é o que o usuário digitou até agora. + * `context`: os argumentos já resolvidos. Ignore-o por enquanto. +* Você retorna um `Completion(values=[...])`, ou `None` quando não tem nada a oferecer. + +!!! tip + `argument.value` é o prefixo que o usuário digitou. O SDK **não** filtra para você: o que + você colocar em `values` é o que a UI mostra. O `startswith` é você quem escreve. + +### Experimente {#try-it} + +Use o `Client` em memória de **[Testes](../get-started/testing.md)** para exercitá-lo. Chame +`client.complete()` com `ref=PromptReference(name="review_code")` e +`argument={"name": "language", "value": "py"}`: + +```python +result.completion.values # ['python'] +``` + +* `ref` é o mesmo tipo de referência que o seu handler recebe. +* `argument` é um dict simples com exatamente duas chaves, `name` e `value`. + +Envie um `value` vazio e você recebe a lista inteira de volta. `lang.startswith("")` é verdadeiro para toda linguagem: + +```python +result.completion.values # ['go', 'javascript', 'python', 'rust', 'typescript'] +``` + +Pergunte sobre `code` (um argumento que o seu handler não reconhece) e ele retorna `None`, que o SDK transforma em uma lista vazia: + +```python +result.completion.values # [] +``` + +`None` significa *"sem sugestões"*, nunca um erro. A UI recorre a uma caixa de texto simples. + +## Uma capacidade que você nunca declarou {#a-capability-you-never-declared} + +Registrar o handler é a declaração. Conecte um cliente e veja: + +```python +client.server_capabilities.completions # CompletionsCapability() +``` + +Você não listou `completions` em lugar nenhum. O SDK viu o handler e declarou a capacidade por você. Toda capacidade *opcional* funciona assim: o handler é a declaração. (As três primitivas não são opcionais: o `MCPServer` sempre as declara, com ou sem handlers.) + +!!! check + Volte ao primeiro `server.py` (aquele sem handler) e pergunte mesmo assim. A chamada falha + com um erro JSON-RPC: + + ```text + Method not found + ``` + + E `client.server_capabilities.completions` é `None`. É para isso que a capacidade existe: um + cliente bem-comportado a confere e nunca envia uma requisição que você não tem como responder. + +## Argumentos dependentes {#dependent-arguments} + +`github://repos/{owner}/{repo}` tem dois parâmetros, e os valores úteis para `repo` dependem de qual `owner` foi escolhido antes. + +É para isso que serve o `context`. Ele carrega os argumentos que o usuário **já resolveu**: + +```python title="server.py" hl_lines="8-11 34-38" +--8<-- "docs_src/completions/tutorial003.py" +``` + +* O novo ramo é acionado para o parâmetro `repo` do template. +* `context.arguments` é um `dict[str, str] | None` com os valores escolhidos até agora (aqui, `owner`). +* Sem `owner` ainda, não há sugestões que façam sentido, então o handler retorna `None`. + +O cliente envia esses valores resolvidos com `context_arguments=`. Desta vez, `ref` é um +`ResourceTemplateReference(uri="github://repos/{owner}/{repo}")`. Peça `repo` com um +`value` vazio e passe `context_arguments={"owner": "modelcontextprotocol"}`: + +```python +result.completion.values # ['python-sdk', 'typescript-sdk', 'inspector'] +``` + +Tire o `context_arguments=` e a mesma chamada retorna `[]`. O handler não tem como saber quais repositórios oferecer antes de saber quem é o owner. + +!!! info + `Completion` também aceita `total=` e `has_more=`. Defina-os quando `values` for uma fatia de uma + lista maior, para que a UI possa mostrar *"e mais 200"*. A maioria dos handlers nunca precisa deles. + +## Recapitulando {#recap} + +* Completions são sugestões para **argumentos de prompt** e **parâmetros de template de recurso**. Nada mais. +* `@mcp.completion()` registra o único handler. Ele é `async def (ref, argument, context) -> Completion | None`. +* Ramifique com base em `isinstance(ref, ...)` e em `argument.name`. Filtre por `argument.value` você mesmo. +* `None` vira uma lista vazia. Nunca é um erro. +* `context.arguments` guarda os valores já resolvidos; o cliente os fornece como `context_arguments=`. +* A capacidade `completions` aparece no momento em que você registra o handler. Sem ele, a requisição dá `Method not found`. + +As sugestões ajudam enquanto o usuário ainda está *preenchendo* um prompt ou template; para fazer uma pergunta a ele no *meio* de uma chamada de ferramenta, o que você quer é a **[Elicitação](../handlers/elicitation.md)** (elicitation). Tudo o que uma ferramenta pode retornar além de texto está em **[Imagens, áudio e ícones](media.md)**. diff --git a/i18n/pt/pages/servers/handling-errors.md b/i18n/pt/pages/servers/handling-errors.md new file mode 100644 index 0000000000..1bb2ecbe1b --- /dev/null +++ b/i18n/pt/pages/servers/handling-errors.md @@ -0,0 +1,139 @@ +--- +translation: + sections: [e33d441f12d50535, 7099694c603e0f5f, c1df4cf9673433e6, c9cd294541422e6e, 6cec073617bfd037, efa92b8f99e908c8, 6a22a29e27fb4601] + tool: 1 +--- +# Tratando erros {#handling-errors} + +Uma ferramenta (tool) pode falhar de duas maneiras, e o SDK trata cada uma de forma bem diferente. + +Lance uma exceção comum e é o **modelo** que a vê. Lance `MCPError` e é o **protocolo** que a vê. + +Esta página é sobre essa escolha. + +## Um erro que o modelo consegue corrigir {#an-error-the-model-can-fix} + +Pegue uma ferramenta que faz uma consulta e deixe a consulta não encontrar nada: + +```python title="server.py" hl_lines="11-12" +--8<-- "docs_src/handling_errors/tutorial001.py" +``` + +Não há nada de MCP nessas duas linhas. `get_author` lança um `ValueError` comum, como qualquer função Python faria. + +Chame a ferramenta com um título que não está no catálogo e veja o resultado: + +```python +result.is_error # True +result.content # [TextContent(text="Error executing tool get_author: No book titled 'Nothing' in the catalog.")] +result.structured_content # None +``` + +* A requisição **foi bem-sucedida**. Há um resultado; nada foi lançado no lado de quem chamou. +* `is_error` é `True`, e a mensagem da sua exceção (prefixada com o nome da ferramenta) está em `content`, exatamente onde o modelo lê. +* `structured_content` é `None`. Uma chamada que falhou não tem valor de retorno para estruturar. + +Isso é um **erro de ferramenta**, e é o padrão para *qualquer* exceção que a sua ferramenta lançar. Também é, quase sempre, o que você quer. + +Quem chama a sua ferramenta é o modelo. Foi ele que escolheu os argumentos. Então um erro de ferramenta é um turno na conversa: o modelo lê *"No book titled 'Nothing' in the catalog."*, percebe que chutou o título errado e chama de novo com um melhor. Você escreveu um `raise` e ganhou um agente que se corrige sozinho. + +!!! tip + Nunca faça `return` de uma mensagem de erro em uma ferramenta. Uma string retornada tem `is_error=False`, então, para o + modelo (e para toda interface de cliente), parece que a ferramenta funcionou e que aquela string era a resposta. + Use `raise`. A flag é o sinal. + +## Um erro que o modelo não consegue corrigir {#an-error-the-model-cannot-fix} + +Agora troque `ValueError` por `MCPError`. + +```python title="server.py" hl_lines="1 3 14" +--8<-- "docs_src/handling_errors/tutorial002.py" +``` + +`MCPError` é o **erro de protocolo** do SDK. É a única exceção que o wrapper da ferramenta *não* captura: ela se propaga, e a requisição `tools/call` inteira falha com um erro JSON-RPC em vez de um resultado. + +```json +{ + "code": -32602, + "message": "No book titled 'Nothing' in the catalog." +} +``` + +* **Não há resultado**. Sem `content`, sem `is_error`: nada para o modelo ler. +* Quem recebe o erro é a aplicação **host**, do mesmo jeito que receberia se a ferramenta nem existisse. +* `code`, `message` e `data` chegam intactos. `INVALID_PARAMS` é `-32602`; `mcp.types` exporta esse e os outros códigos de erro JSON-RPC (`INVALID_REQUEST`, `INTERNAL_ERROR`, ...) como constantes, para que você nunca precise digitar um número mágico. + +!!! check + Mesma consulta, mesma falha, mas agora a chamada *lança* a exceção no lado do cliente em vez de retornar: + + ```text + mcp.shared.exceptions.MCPError: No book titled 'Nothing' in the catalog. + ``` + + A primeira versão entregou ao modelo uma frase à qual ele podia reagir. Esta não entrega nada. + Para `get_author` isso é estritamente pior, e é esse o ponto da próxima seção. + +## Qual delas lançar {#which-one-to-raise} + +Os dois caminhos respondem a duas perguntas diferentes. + +* **Lance qualquer exceção** para uma falha de *execução*: aquilo que a sua ferramenta tentou fazer não funcionou. Foi o modelo que escolheu a chamada, então é o modelo que deve ver a consequência e ter a chance de se recuperar. Um título escrito errado, uma API upstream que deu timeout, uma linha que não existe: tudo erro de ferramenta. +* **Lance `MCPError`** quando a *própria requisição* deve ser rejeitada: o cliente não tem uma capacidade da qual a sua ferramenta depende, o servidor não está em condições de atender ninguém, quem chamou pulou uma etapa obrigatória. Nenhuma nova tentativa do modelo corrige nada disso, então não há nada a ganhar entregando a mensagem a ele. + +Uma pergunta decide: **um modelo mais esperto teria evitado isso?** Sim -> exceção comum. Não -> `MCPError`. + +Por esse critério, a segunda versão de `get_author` fez a escolha errada: um título melhor resolve, então o modelo merecia ver a mensagem. Ela está ali para mostrar o mecanismo, não para recomendá-lo. + +!!! info + `MCPError` fica em `from mcp import MCPError` e recebe `code`, `message` e um payload + `data` opcional. O que você colocar neles é o que o cliente recebe: o SDK repassa um + `MCPError` lançado tal e qual, em vez de sanitizá-lo. + +## Um recurso que não existe {#a-resource-that-doesnt-exist} + +Recursos fazem a mesma distinção, e vêm com uma exceção nomeada para o caso mais comum. + +```python title="server.py" hl_lines="2 13" +--8<-- "docs_src/handling_errors/tutorial003.py" +``` + +`books://{title}` é um **template**. Ele casa com *qualquer* título, então "a URI está bem formada" e "o livro existe" são duas perguntas diferentes, e só a sua função consegue responder à segunda. + +Quando não consegue, lance `ResourceNotFoundError`. O SDK a transforma no erro de protocolo que a especificação atribui a um recurso ausente: `-32602` com a URI requisitada em `data`, para que o cliente saiba *qual* leitura falhou. + +```json +{ + "code": -32602, + "message": "No book titled 'Nothing' in the catalog.", + "data": {"uri": "books://Nothing"} +} +``` + +Repare que aqui não existe um meio-resultado com `is_error=True`. A leitura de um recurso ou retorna conteúdo ou falha: recursos só têm o caminho do protocolo. Templates e todo o resto sobre recursos ficam em **[Recursos](resources.md)**. + +## Erros que você nunca lança {#errors-you-never-raise} + +Um argumento inválido nunca chega à sua função. + +Mande para `get_author` um `title` que não seja uma string e o SDK o rejeita com base no schema de entrada **antes** de chamar você, como o mesmo tipo de erro de ferramenta com `is_error=True` que o modelo consegue ler e corrigir. **[Ferramentas](tools.md)** mostra a mesma rejeição com uma restrição `Field(le=50)`. + +Isso significa uma classe inteira de instruções `raise` que você não escreve: não revalide as suas próprias anotações de tipo. + +!!! info + Tudo nesta página é o que um **cliente** vê, e o `Client` em memória com o qual você vai escrever + seus testes vê exatamente a mesma coisa. Nem `raise_exceptions=True` transforma um erro de ferramenta + de volta em traceback: no momento em que essa flag poderia agir, a sua exceção já virou o + resultado com `is_error=True`. Faça o assert no resultado. **[Testes](../get-started/testing.md)** cobre o padrão. + +## Recapitulando {#recap} + +* Lance **qualquer exceção** em uma ferramenta -> a chamada retorna `is_error=True` com a sua mensagem em `content`. O modelo lê e pode tentar de novo. Esse é o padrão. +* Lance **`MCPError`** -> a própria chamada falha com um erro JSON-RPC. O modelo não vê nada; quem lida com isso é o host. `code`, `message` e `data` sobrevivem intactos. +* A pergunta que decide: *um modelo mais esperto teria evitado isso?* Sim -> exceção. Não -> `MCPError`. +* `ResourceNotFoundError` em um handler de recurso -> o `-32602` do protocolo, com a URI em `data`. +* Argumentos inválidos são rejeitados com base no schema antes de a sua função executar; você não dá `raise` para eles. +* `from mcp import MCPError`; as constantes de código de erro vêm de `mcp.types`. + +Erros tratados. Isso é tudo o que um servidor *expõe*. O que cada handler pode ler, e fazer de volta ao cliente enquanto executa, é a próxima seção: **[Dentro do seu handler](../handlers/index.md)**. + +O texto exato dos erros do SDK que você tem mais chance de encontrar, o que cada um significa e a correção de um passo só para cada um estão em **[Solução de problemas](../troubleshooting.md)**. diff --git a/i18n/pt/pages/servers/index.md b/i18n/pt/pages/servers/index.md new file mode 100644 index 0000000000..5c8635cd68 --- /dev/null +++ b/i18n/pt/pages/servers/index.md @@ -0,0 +1,35 @@ +--- +translation: + sections: [09defc170a0da89d] + tool: 1 +--- +# Servidores {#servers} + +Um `MCPServer` expõe três primitivas a um cliente conectado. O que as +distingue é quem decide usá-las: + +* Uma **[ferramenta](tools.md)** (tool) é uma ação que o *modelo* escolhe e chama. Esta é + a página que a maioria das pessoas procura primeiro, e + **[Saída estruturada](structured-output.md)** é a referência que a acompanha: + tudo sobre o formato do que uma ferramenta retorna. +* Um **[recurso](resources.md)** é um dado somente leitura que a *aplicação* + escolhe ler. **[Templates de URI](uri-templates.md)** é a referência que o + acompanha: a sintaxe completa de endereçamento e as regras de segurança de caminhos. +* Um **[prompt](prompts.md)** é um template de mensagem que uma *pessoa* invoca pelo + nome, a partir de um menu ou de um comando de barra. + +Em torno das três primitivas, o restante do que um servidor declara: + +* **[Autocompletar](completions.md)** (completions) é o preenchimento automático, feito no + servidor, dos argumentos de prompts e de templates de recurso. +* **[Imagens, áudio e ícones](media.md)** cobre tudo o que uma ferramenta pode + retornar além de texto, e os ícones que um cliente mostra ao lado do seu servidor. +* **[Tratamento de erros](handling-errors.md)** explica a diferença entre um + erro do qual o modelo consegue se recuperar e um que ele nunca deve ver. + +Cada página aqui é independente; vá direto para a que você precisa. Se você ainda não +construiu um servidor, comece antes por **[Primeiros passos](../get-started/first-steps.md)**. + +O que acontece *dentro* das funções que você registra (o `Context`, a injeção de dependência, +pedir mais informações ao usuário no meio de uma chamada) é o assunto da próxima seção, +**[Dentro do seu handler](../handlers/index.md)**. diff --git a/i18n/pt/pages/servers/media.md b/i18n/pt/pages/servers/media.md new file mode 100644 index 0000000000..4b6a847921 --- /dev/null +++ b/i18n/pt/pages/servers/media.md @@ -0,0 +1,122 @@ +--- +translation: + sections: [496394d24d221bf1, 4ceb4591180dc6c3, 0fd63e4682d02e0c, 969ede0bd3686a16, 043f526230dd243d, 6ee3e9bcfd24047a] + tool: 1 +--- +# Mídia {#media} + +Texto não é a única coisa que uma ferramenta pode retornar. + +O SDK traz dois helpers para resultados binários (**`Image`** e **`Audio`**) e um tipo **`Icon`** para dar uma cara ao seu servidor, às ferramentas, aos recursos e aos prompts na interface do cliente. + +## Retornando uma imagem {#returning-an-image} + +Anote o tipo de retorno como `Image`, aponte para um arquivo e retorne: + +```python title="server.py" hl_lines="8 12 14" +--8<-- "docs_src/media/tutorial001.py" +``` + +* `Image` recebe exatamente um entre `path` (um arquivo a ser lido) ou `data` (bytes brutos). +* O tipo MIME que o cliente vê é inferido a partir do sufixo: `logo.png` é anunciado como `image/png`. +* Não há nada aqui específico de logos. Qualquer PNG ao lado de `server.py` funciona: um gráfico que seu código renderizou, um diagrama, uma foto. + +`Image` é uma conveniência do SDK, não um tipo do protocolo. Na rede, o seu valor de retorno vira um bloco **`ImageContent`** (os bytes do arquivo codificados em base64, mais o tipo MIME): + +```python +result.content # [ImageContent(type="image", data="iVBORw0KGgoAAAANSUhEUg...", mime_type="image/png")] +result.structured_content # None +``` + +Repare em duas coisas: + +* `data` é base64. Você nunca tocou nos bytes; o SDK leu o arquivo e fez a codificação. +* `structured_content` é `None`. Uma `Image` é conteúdo para o modelo olhar, não dados para a aplicação interpretar: não há schema de saída. (Compare com **[Saída estruturada](structured-output.md)**, onde a anotação de retorno *é* o schema.) + +!!! info + `ImageContent` e `AudioContent` ficam em `mcp.types`, bem ao lado do `TextContent` + em que um resultado `str` simples se transforma (**[Ferramentas](tools.md)**). O resultado de uma ferramenta é uma lista de blocos de conteúdo; `Image` e `Audio` são + o caminho mais curto para produzir os dois tipos binários. + +### Experimente {#try-it} + +Coloque qualquer PNG ao lado de `server.py`, dê a ele o nome `logo.png` e execute: + +```console +uv run mcp dev server.py +``` + +Abra a aba **Tools** e chame `logo`. O resultado não é uma string: é um bloco de conteúdo `image`, e o Inspector renderiza sua imagem. Tudo o que aconteceu entre o arquivo no disco e os pixels na tela foi obra do SDK. + +## Retornando áudio {#returning-audio} + +`Audio` segue o mesmo molde. Mantenha `logo.png` onde estava e coloque qualquer WAV ao lado dele como `chime.wav`: + +```python title="server.py" hl_lines="18-21" +--8<-- "docs_src/media/tutorial002.py" +``` + +O resultado é um bloco **`AudioContent`**: + +```python +result.content # [AudioContent(type="audio", data="UklGR...", mime_type="audio/wav")] +result.structured_content # None +``` + +Funciona do mesmo jeito: entra um arquivo em disco, saem base64 e um tipo MIME, nenhum schema de saída. + +## Bytes ou um arquivo {#bytes-or-a-file} + +Os dois helpers também aceitam `data=` (bytes brutos) em vez de `path=`. Esse é o modo para bytes que nunca vieram de um arquivo próprio — uma coluna de banco de dados, uma resposta HTTP, algo que o Pillow acabou de desenhar: + +```python title="server.py" hl_lines="14 15" +--8<-- "docs_src/media/tutorial003.py" +``` + +Com `path=` não há nada a declarar: o arquivo é lido quando o resultado é montado, e o tipo MIME é inferido a partir do sufixo: + +* `Image`: `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`. +* `Audio`: `.wav`, `.mp3`, `.ogg`, `.flac`, `.aac`, `.m4a`. + +Um sufixo não reconhecido cai no padrão `application/octet-stream`. + +!!! check + Com `data=` não há nome de arquivo, então não há de onde inferir nada. Esqueça o `format=` e + o SDK recorre a um padrão: `image/png` para imagens, `audio/wav` para áudio. Monte um + `Audio` a partir de bytes MP3 desse jeito e o cliente recebe `mime_type="audio/wav"` e, + confiando nisso, falha ao decodificar. Quando você passar `data=`, passe `format=`. + +## Ícones {#icons} + +Um `Icon` é metadado, não conteúdo. Ele não carrega a imagem; aponta para uma por meio de uma URI, e um cliente pode buscá-la e mostrá-la ao lado do nome do seu servidor, de uma ferramenta, de um recurso ou de um prompt. + +```python title="server.py" hl_lines="4-5 7 10 16" +--8<-- "docs_src/media/tutorial004.py" +``` + +* `src` é uma URI que o cliente consegue resolver: `https:`, ou uma URI `data:` se você quiser o ícone embutido, sem uma busca extra. +* `mime_type` e `sizes` (`"48x48"`, ou `"any"` para um formato escalável) permitem que o cliente escolha o certo quando você oferece vários. +* `theme="light"` ou `theme="dark"` marca um ícone para um único esquema de cores. + +`MCPServer(...)`, `@mcp.tool()`, `@mcp.resource()` e `@mcp.prompt()` aceitam o mesmo argumento nomeado `icons=[...]`. + +### Onde um cliente os vê {#where-a-client-sees-them} + +Os ícones viajam junto com aquilo que decoram. Os do servidor chegam quando o cliente se conecta, em `client.server_info` (opcional em conexões da era 2026, então restrinja o tipo primeiro): + +```python +assert client.server_info is not None # python-sdk servers identify themselves by default +client.server_info.icons # [Icon(src="https://example.com/brand-kit.png", mime_type="image/png", sizes=["48x48"])] +``` + +Os ícones de uma ferramenta ficam no objeto `Tool` de `tools/list`; os de um recurso, no `Resource` de `resources/list`; os de um prompt, no `Prompt` de `prompts/list`. O campo sempre se chama `icons`. + +## Recapitulando {#recap} + +* Retorne uma `Image` ou um `Audio` de uma ferramenta e o cliente recebe um bloco `ImageContent` / `AudioContent`: seus bytes codificados em base64, com um tipo MIME. +* Monte um a partir de um `path=` e deixe o sufixo decidir o tipo MIME, ou a partir de `data=` em memória mais um `format=` explícito. +* Resultados de mídia não trazem `structured_content` nem schema de saída. +* Um `Icon` é um ponteiro: uma URI `src` mais `mime_type`, `sizes` e `theme` opcionais. +* `icons=[...]` funciona no servidor, em ferramentas, em recursos e em prompts, e os clientes os encontram nos objetos correspondentes. + +Isso é tudo o que uma ferramenta pode colocar *dentro* de um resultado. O que acontece quando uma ferramenta *falha* (e quem deve ficar sabendo) está em **[Tratando erros](handling-errors.md)**. diff --git a/i18n/pt/pages/servers/prompts.md b/i18n/pt/pages/servers/prompts.md new file mode 100644 index 0000000000..e2ae6ace32 --- /dev/null +++ b/i18n/pt/pages/servers/prompts.md @@ -0,0 +1,155 @@ +--- +translation: + sections: [d65c098f37f5b6c3, dd0c2724d6f2877e, 6835bb3570c6714c, ffe823cb0fedd488, f33651add1b59094] + tool: 1 +--- +# Prompts {#prompts} + +Um **prompt** é um template de mensagem que o usuário escolhe. + +Ferramentas são para o modelo. Um prompt é o oposto: o usuário escolhe um em um menu do seu cliente (um comando de barra, um botão), preenche os argumentos, e as mensagens renderizadas entram na conversa como se ele mesmo as tivesse digitado. + +Para declarar um, coloque `@mcp.prompt()` em uma função que retorna o texto. + +## Seu primeiro prompt {#your-first-prompt} + +```python title="server.py" hl_lines="6-9" +--8<-- "docs_src/prompts/tutorial001.py" +``` + +O SDK lê as mesmas três coisas que lê de uma ferramenta: + +* O **nome** é o nome da função: `review_code`. +* A **descrição** que o cliente exibe é a docstring: `Review a piece of code.` +* Os **argumentos** vêm dos parâmetros. `code` não tem valor padrão, então é obrigatório. + +É isso que um cliente recebe de volta de `prompts/list`: + +```json +{ + "name": "review_code", + "description": "Review a piece of code.", + "arguments": [ + {"name": "code", "required": true} + ] +} +``` + +Não há JSON Schema aqui. Os argumentos de um prompt são uma lista plana de **strings nomeadas**: um formulário que uma pessoa preenche, não um payload que um modelo constrói. + +### Renderizando {#rendering-it} + +O cliente renderiza o template com `prompts/get`, passando os argumentos. Sua função executa e a `str` que você retorna vira **uma mensagem de usuário**: + +```json +{ + "description": "Review a piece of code.", + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "Please review this code:\n\ndef add(a, b): return a + b" + } + } + ], + "resultType": "complete" +} +``` + +Essa é a vida inteira de um prompt: listado pelo nome, renderizado sob demanda, colocado no chat. + +!!! check + `required` é verificado antes que sua função execute. Renderize `review_code` sem `code` e a + própria requisição falha com um erro JSON-RPC (código `-32603`): + + ```text + mcp.shared.exceptions.MCPError: Internal server error + ``` + + Não há um resultado de erro no estilo das ferramentas para devolver a um modelo, porque não há + nenhum modelo envolvido: a chamada levanta uma exceção. O motivo (`Missing required arguments: {'code'}`) vai parar no log do seu servidor. + +### Experimente {#try-it} + +Execute o servidor com o MCP Inspector: + +```console +uv run mcp dev server.py +``` + +Abra a aba **Prompts** e selecione `review_code`. O Inspector desenha um formulário com um único campo obrigatório, `code`. Preencha, renderize e você recebe de volta exatamente a mensagem de usuário acima. + +## Mais de uma mensagem {#more-than-one-message} + +Uma revisão de código é uma mensagem só. Uma sessão de depuração é uma conversa, e um prompt pode iniciar a coisa toda. + +Retorne uma lista de mensagens em vez de uma `str`: + +```python title="server.py" hl_lines="2 13-20" +--8<-- "docs_src/prompts/tutorial002.py" +``` + +* `UserMessage` e `AssistantMessage` vêm de `mcp.server.mcpserver.prompts.base`. Passe uma `str` para elas e elas a embrulham em `TextContent` para você. O papel (role) é o nome da classe. +* `Message` é a base comum delas. Use-a como anotação de retorno. + +Renderizar `debug_error` agora produz três mensagens, nesta ordem: + +```json +{ + "description": "Start a debugging conversation.", + "messages": [ + {"role": "user", "content": {"type": "text", "text": "I'm seeing this error:"}}, + {"role": "user", "content": {"type": "text", "text": "TypeError: 'int' object is not iterable"}}, + { + "role": "assistant", + "content": {"type": "text", "text": "I'll help debug that. What have you tried so far?"} + } + ], + "resultType": "complete" +} +``` + +Repare na última. Pré-preencher um turno de `assistant` é como você direciona a *próxima* resposta do modelo sem fazer o usuário digitar esse direcionamento por conta própria. + +## Títulos e descrições dos argumentos {#titles-and-argument-descriptions} + +`review_code` é um nome de função, não um rótulo. Dê ao cliente algo melhor para colocar no botão e descreva cada argumento para que o formulário se explique sozinho: + +```python title="server.py" hl_lines="10-13" +--8<-- "docs_src/prompts/tutorial003.py" +``` + +* `title="Code review"` é o nome legível por humanos, exatamente como o `title` de uma ferramenta. +* `Annotated[str, Field(description=...)]` é o mesmo padrão que **[Ferramentas](tools.md)** usa para descrever os parâmetros de uma ferramenta. Aqui a descrição vai parar no argumento, e não em um schema. +* `language` tem um valor padrão, então deixa de ser obrigatório. + +A entrada em `prompts/list` agora traz tudo de que um cliente precisa para desenhar um bom formulário: + +```json +{ + "name": "review_code", + "title": "Code review", + "description": "Review a piece of code.", + "arguments": [ + {"name": "code", "description": "The code to review.", "required": true}, + {"name": "language", "description": "The language the code is written in.", "required": false} + ] +} +``` + +!!! info + Se você leu **[Ferramentas](tools.md)**, já sabe tudo o que está nesta página. O mesmo decorador, a mesma + docstring como descrição, o mesmo `Annotated`/`Field`. As únicas coisas que mudam são quem + dispara (o usuário) e para onde vai o resultado (para a conversa). + +## Recapitulando {#recap} + +* `@mcp.prompt()` em uma função faz dela um prompt. O nome vem da função, a descrição vem da docstring. +* Prompts são **controlados pelo usuário**: o cliente os lista, o usuário escolhe um e preenche os argumentos. +* Os argumentos são uma lista plana de strings nomeadas (sem schema). Um parâmetro com valor padrão é opcional. +* Retorne uma `str` e ela vira uma mensagem de usuário. Retorne uma lista de `UserMessage` / `AssistantMessage` para iniciar uma conversa de vários turnos. +* `title=` e `Field(description=...)` são o que um cliente coloca na interface dele. +* Um argumento obrigatório ausente faz a requisição inteira falhar. Não existe um resultado de erro por prompt. + +O autocomplete do lado do servidor para os argumentos de um prompt (ou de um template de recurso) é assunto de **[Completions](completions.md)**. diff --git a/i18n/pt/pages/servers/resources.md b/i18n/pt/pages/servers/resources.md new file mode 100644 index 0000000000..3eb95d20eb --- /dev/null +++ b/i18n/pt/pages/servers/resources.md @@ -0,0 +1,146 @@ +--- +translation: + sections: [09df998c2a799f78, 0cf131146d16d4f9, 4e6b91e3f8025346, 8fe4eef576db17ed, 0d0d1ed43e3d0a53] + tool: 1 +--- +# Recursos {#resources} + +Um **recurso** (resource) é um dado que você expõe para a aplicação ler. + +A divisão é essa. Uma ferramenta é algo que o **modelo** decide chamar. Um recurso é algo que a **aplicação** decide carregar (um arquivo de configuração, um registro, um documento) e colocar diante do modelo como contexto. + +Você declara um colocando `@mcp.resource(uri)` em uma função Python comum. + +## Seu primeiro recurso {#your-first-resource} + +```python title="server.py" hl_lines="6-8" +--8<-- "docs_src/resources/tutorial001.py" +``` + +Tem o mesmo formato de uma ferramenta, com uma coisa a mais: a **URI**. Recursos têm endereço, não nome. Um cliente pede `config://app`, nunca `get_config`. + +O SDK ainda lê o restante a partir da função: + +* O **nome** é o nome da função: `get_config`. +* A **descrição** que o cliente vê é a docstring. +* O **conteúdo** é o que você retornar. + +Durante `resources/list`, o cliente recebe isto: + +```json +{ + "name": "get_config", + "uri": "config://app", + "description": "The active shop configuration.", + "mimeType": "text/plain" +} +``` + +E quando ele lê `config://app`, sua função roda e o valor de retorno volta como texto: + +```python +result.contents # [TextResourceContents(uri="config://app", mime_type="text/plain", text="theme=dark\nlanguage=en")] +``` + +!!! tip + Listar é barato. Sua função **não** é chamada durante `resources/list`, só durante + `resources/read`, e apenas para a URI que foi pedida. Exponha mil recursos + e você só paga pelos que alguém abrir. + +### Experimente {#try-it} + +Execute o servidor com o MCP Inspector: + +```console +uv run mcp dev server.py +``` + +Abra a URL que ele imprime e vá até a aba **Resources**. `config://app` está na lista, com sua descrição. Clique nele e o Inspector o lê: ali estão suas duas linhas de configuração. + +## Templates de recurso {#resource-templates} + +Uma URI por registro não escala. Coloque um **placeholder** na URI e um parâmetro correspondente na função: + +```python title="server.py" hl_lines="12-13" +--8<-- "docs_src/resources/tutorial002.py" +``` + +`{user_id}` na URI, `user_id: str` na função. O contrato inteiro é esse. + +Agora isso é um **template de recurso** (resource template), e ele se muda: sai de `resources/list` e passa a aparecer em `resources/templates/list`, como um padrão em vez de um endereço: + +```json +{ + "name": "get_user_profile", + "uriTemplate": "users://{user_id}/profile", + "description": "A customer's profile.", + "mimeType": "text/plain" +} +``` + +O cliente preenche o placeholder e lê uma URI concreta: `users://42/profile`, `users://ada/profile`. Uma única função responde a todas elas, com o valor capturado passado como `user_id`: + +```python +result.contents # [TextResourceContents(uri="users://42/profile", text="User 42: 12 orders since 2021.")] +``` + +Repare na `uri` do resultado. É a URI **concreta** que o cliente pediu, não o template. + +!!! check + Os placeholders e os parâmetros precisam bater. Renomeie o parâmetro da função para + `user` enquanto a URI ainda diz `{user_id}` e o decorador se recusa **em tempo de importação**, + antes que qualquer cliente chegue perto: + + ```text + ValueError: Mismatch between URI parameters {'user_id'} and function parameters {'user'} + ``` + + Uma divergência dessas só pode ser bug, então o SDK torna impossível iniciar o servidor com uma. + +A sintaxe dos placeholders é a da [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570): `{+path}` para valores com vários segmentos, `{?q,lang}` para parâmetros de query opcionais, e mais. O SDK também aplica, por padrão, verificações de segurança de caminho aos valores extraídos. Veja **[Templates de URI e segurança de caminhos](uri-templates.md)** para a referência completa. + +`get_user_profile` também pode receber um parâmetro anotado com `Context`. O SDK o injeta sem nunca tratá-lo como parâmetro da URI, e a página **[O Context](../handlers/context.md)** cobre o que ele oferece a você. + +## O que você retorna {#what-you-return} + +Você não está limitado a `str`. Dê a cada recurso um `mime_type` e retorne o que fizer sentido: + +```python title="server.py" hl_lines="8-9 14-15 20-21" +--8<-- "docs_src/resources/tutorial003.py" +``` + +* `readme` retorna uma `str`, então ela é enviada como está. Esse é o caso comum. +* `catalog_stats` retorna um `dict`, então o SDK o serializa em **texto JSON** para você: + + ```json + { + "books": 1204, + "authors": 391 + } + ``` + +* `placeholder_cover` retorna `bytes`, então o cliente recebe um `BlobResourceContents` em vez de um `TextResourceContents`, com seus bytes codificados em base64 no campo `blob`. + +A mesma regra vale para qualquer outra coisa serializável em JSON: uma lista, um modelo Pydantic, uma dataclass. Se não é `str` nem `bytes`, vira JSON. + +O `mime_type` é você quem declara, e o padrão é `text/plain`. O SDK nunca inspeciona o que você retorna para adivinhá-lo, então um recurso `dict` que você não rotula continua sendo anunciado como texto puro. + +!!! tip + `@mcp.resource()` também aceita `name=`, `title=` e `description=` quando você não + quer derivá-los da função. E quando não há função nenhuma a escrever, + `mcp.server.mcpserver.resources` tem classes `Resource` prontas (`TextResource`, + `BinaryResource`, `FileResource`, `HttpResource`, `DirectoryResource`) que você registra + com `mcp.add_resource(...)`. + +Um cliente também pode **assinar** um recurso e ser notificado quando ele muda; essa metade da história é do cliente e está em **[O cliente](../client/index.md)**. + +## Recapitulando {#recap} + +* `@mcp.resource(uri)` em uma função a transforma em um recurso. A URI é o endereço, o valor de retorno é o conteúdo, a docstring é a descrição. +* Um `{placeholder}` na URI a transforma em um **template**: ele é listado em `resources/templates/list` e uma única função atende a toda URI que corresponder. +* Os nomes dos placeholders devem ser iguais aos nomes dos parâmetros da função. Erre isso e você descobre em tempo de importação, não em produção. +* Sua função roda quando o recurso é **lido**, não quando é listado. +* `str` vira texto, `bytes` vira um blob em base64, qualquer outra coisa vira texto JSON. `mime_type=` é como você rotula isso. +* Ferramentas são para o modelo agir. Recursos são para a aplicação ler. + +A terceira primitiva, aquela que uma pessoa escolhe em um menu, são os **[Prompts](prompts.md)**. diff --git a/i18n/pt/pages/servers/structured-output.md b/i18n/pt/pages/servers/structured-output.md new file mode 100644 index 0000000000..aa2d364bcb --- /dev/null +++ b/i18n/pt/pages/servers/structured-output.md @@ -0,0 +1,250 @@ +--- +translation: + sections: [a838d57f003aed44, 857d03886a0137ed, 42d9efcb9f542867, 2290ff08435b5573, e866c192e11d1c14, 6cdbad079f7b47f0, d4b607372fb28b51, 18dbf726ac45e0b7, c6f7d2a148aa49f4, c851964bb3301907, d715db6f8dccc9cc, ef86634aa70498a7] + tool: 1 +--- +# Saída estruturada {#structured-output} + +Uma ferramenta (tool) que retorna uma `str` simples produz o resultado duas vezes: como texto em `content` e como `{"result": "..."}` em `structured_content`. + +Esta página trata desse segundo canal: de onde ele vem, todas as formas que ele pode assumir e como o SDK garante que ele seja confiável. + +A versão curta: **a anotação do tipo de retorno é o schema de saída**. Você já a escreveu. + +## O schema de saída {#the-output-schema} + +```python title="server.py" hl_lines="9" +--8<-- "docs_src/structured_output/tutorial001.py" +``` + +A linha que importa é a assinatura: `-> int`. + +Por causa dela, a ferramenta que o SDK envia durante `tools/list` carrega um `output_schema` ao lado do schema de entrada que ele monta a partir dos seus parâmetros (**[Ferramentas](tools.md)** cobre esse): + +```json +{ + "properties": { + "result": {"title": "Result", "type": "integer"} + }, + "required": ["result"], + "title": "get_temperatureOutput", + "type": "object" +} +``` + +Um `int` sozinho não é um objeto JSON, então o SDK o **envolve** em `{"result": ...}`. Chame a ferramenta e os dois canais vêm preenchidos: + +```python +result.content # [TextContent(text="17")] +result.structured_content # {"result": 17} +``` + +Todo escalar recebe o mesmo wrapper: `str`, `int`, `float`, `bool`, `bytes`, `None`. + +## Dois canais {#two-channels} + +Por que enviar o mesmo valor duas vezes? + +* `content` é para o **modelo**. Um modelo de linguagem lê texto; essa é a única parte do resultado que ele vê. +* `structured_content` é para a **aplicação** dentro da qual o modelo roda: código que quer `17`, não uma frase contendo "17". +* `output_schema` é o contrato entre os dois, publicado antes mesmo de a ferramenta ser chamada. + +Você retorna um único valor Python. O SDK preenche os três. + +## Retorne um modelo {#return-a-model} + +Declare a forma como um `BaseModel` do Pydantic e retorne uma instância: + +```python title="server.py" hl_lines="8-11 15" +--8<-- "docs_src/structured_output/tutorial002.py" +``` + +`WeatherData` agora **é** o schema. Sem wrapper, sem chave `result`: + +```json +{ + "properties": { + "temperature": {"description": "Degrees Celsius.", "title": "Temperature", "type": "number"}, + "humidity": {"description": "Relative humidity, 0 to 1.", "title": "Humidity", "type": "number"}, + "conditions": {"title": "Conditions", "type": "string"} + }, + "required": ["temperature", "humidity", "conditions"], + "title": "WeatherData", + "type": "object" +} +``` + +`structured_content` é o objeto, campo por campo: + +```python +result.structured_content # {"temperature": 16.2, "humidity": 0.83, "conditions": "Overcast"} +``` + +E o modelo não fica de fora. O SDK serializa o mesmo objeto como texto JSON para `content`: + +```json +{ + "temperature": 16.2, + "humidity": 0.83, + "conditions": "Overcast" +} +``` + +Repare que o `Field(description=...)` em `temperature` e `humidity` foi parar no schema. O mesmo `Field` que descrevia as suas **entradas** descreve as suas saídas. + +!!! info + Se você já usou o `response_model` do FastAPI, já conhece isso: um modelo Pydantic como a resposta + declarada, serializado e documentado para você. A única diferença é que aqui a anotação de retorno + é a declaração inteira. + +## Um `TypedDict` {#a-typeddict} + +Nem toda forma merece uma classe. Um `TypedDict` produz o mesmo schema: + +```python title="server.py" hl_lines="8" +--8<-- "docs_src/structured_output/tutorial003.py" +``` + +Um `TypedDict` é um `dict` comum em tempo de execução, então é isso que você monta e retorna. O schema, a validação e o `structured_content` são idênticos aos da versão com `BaseModel` (menos as descrições, para as quais o `TypedDict` não tem lugar). + +## Uma dataclass {#a-dataclass} + +Dataclasses também funcionam, assim como qualquer classe comum cujos atributos tenham anotações de tipo. O SDK monta um modelo Pydantic a partir das anotações por baixo dos panos. + +```python title="server.py" hl_lines="8-9" +--8<-- "docs_src/structured_output/tutorial004.py" +``` + +Três formas de escrever, um schema só. Use a que a sua base de código já tem. + +## Listas {#lists} + +Uma `list[...]` também não é um objeto JSON, então ela recebe o wrapper `{"result": ...}`, com o tipo dos seus itens como uma referência `$defs` dentro dele: + +```python title="server.py" hl_lines="15" +--8<-- "docs_src/structured_output/tutorial005.py" +``` + +```json +{ + "$defs": { + "WeatherData": { + "properties": { + "temperature": {"title": "Temperature", "type": "number"}, + "humidity": {"title": "Humidity", "type": "number"}, + "conditions": {"title": "Conditions", "type": "string"} + }, + "required": ["temperature", "humidity", "conditions"], + "title": "WeatherData", + "type": "object" + } + }, + "properties": { + "result": {"items": {"$ref": "#/$defs/WeatherData"}, "title": "Result", "type": "array"} + }, + "required": ["result"], + "title": "get_forecastOutput", + "type": "object" +} +``` + +Peça uma previsão de dois dias e `structured_content` vem como `{"result": [{...}, {...}]}`. `content` vira **dois** blocos `TextContent`, um por item: uma lista é achatada para o modelo em vez de ser despejada como uma única string. + +`tuple[...]`, uniões e `Optional[...]` são envolvidos da mesma forma. + +## Dicionários {#dictionaries} + +`dict[str, ...]` é o único genérico que já *é* um objeto JSON, então ele não é envolvido: + +```python title="server.py" hl_lines="9" +--8<-- "docs_src/structured_output/tutorial006.py" +``` + +```json +{ + "additionalProperties": {"type": "number"}, + "title": "get_temperaturesDictOutput", + "type": "object" +} +``` + +```python +result.structured_content # {"London": 16.2, "Reykjavik": 4.4} +``` + +As chaves precisam ser `str`. Um `dict[int, float]` não pode ser um objeto JSON, então ele recai no wrapper `{"result": ...}`. + +## Validação {#validation} + +`output_schema` não é documentação. O que quer que a sua função retorne é **validado contra ele** antes de sair do servidor. + +Você não percebe enquanto monta o valor à mão: o Pydantic já garantiu que o seu `WeatherData` era um `WeatherData`. Você percebe no dia em que os dados vêm de algum lugar que você não controla: + +```python title="server.py" hl_lines="9 21" +--8<-- "docs_src/structured_output/tutorial007.py" +``` + +A anotação promete `WeatherData`. A resposta do serviço upstream parou de enviar `humidity`. + +!!! check + Chame `get_weather` e ela não entrega discretamente ao cliente um objeto pela metade. A chamada falha, + e as primeiras linhas do erro dão o nome do campo: + + ```text + Error executing tool get_weather: 1 validation error for WeatherData + humidity + Field required [type=missing, input_value={'temperature': 16.2, 'conditions': 'Overcast'}, input_type=dict] + ``` + + Esse texto volta como o resultado da ferramenta com `is_error=True`, então o modelo sabe que a chamada + falhou em vez de ler, com toda a confiança, um clima que não existe. + +Retornar um `dict` comum de uma ferramenta `-> WeatherData` não tem problema, aliás. É exatamente isso que `json.loads` produziu. A validação é feita sobre o valor, não sobre o tipo Python. + +## Desativando {#opting-out} + +Às vezes a anotação de retorno é para o seu verificador de tipos, não para o protocolo. Passe `structured_output=False` e a ferramenta fica só com texto: + +```python title="server.py" hl_lines="6" +--8<-- "docs_src/structured_output/tutorial008.py" +``` + +Sem `output_schema`, sem wrapper, sem validação. `structured_content` é `None` e `content` é a string que você retornou. + +O oposto, `structured_output=True`, transforma a detecção automática em exigência: uma ferramenta cujo tipo de retorno não consegue produzir um schema levanta uma exceção no momento do import em vez de recair para texto. + +## Uma classe sem anotações de tipo {#a-class-without-type-hints} + +Existe um jeito de acabar sem estrutura sem ter pedido por isso: retornar uma classe que **não tem anotações no corpo**. + +```python title="server.py" hl_lines="6-9" +--8<-- "docs_src/structured_output/tutorial009.py" +``` + +`Station` define `name` e `online` dentro de `__init__`, mas a *classe* não declara nada. O SDK lê as anotações da classe, não encontra nenhuma e desiste. + +!!! warning + Ele desiste **em silêncio**. `output_schema` é `None`, `structured_content` é `None`, e o texto + que o modelo lê é o `repr` do objeto: + + ```text + "" + ``` + + Nenhum erro, nenhum aviso, uma ferramenta inútil. Mova as anotações para o corpo da classe, ou passe + `structured_output=True`, que transforma isso em um erro de verdade no momento em que o módulo é importado: + `Function get_station: return type is not serializable for structured output`. + +!!! tip + Precisa de controle total (montar o `CallToolResult` você mesmo, ou anexar um `_meta` que a + aplicação enxerga mas o modelo não)? Isso está em **[O Server de baixo nível](../advanced/low-level-server.md)**. + +## Recapitulando {#recap} + +* A **anotação do tipo de retorno** é o schema de saída. Ela é publicada em `tools/list` como `output_schema`. +* Escalares, listas, tuplas e uniões são envolvidos em `{"result": ...}`. Modelos, `TypedDict`s, dataclasses, classes anotadas e `dict[str, ...]` já são objetos e ficam como estão. +* Todo resultado carrega `content` (texto, para o modelo) **e** `structured_content` (dados, para a aplicação). +* O que você retorna é validado contra o schema. Uma divergência vira um erro de ferramenta, não um resultado corrompido. +* `structured_output=False` deixa uma ferramenta de fora. Uma classe sem anotações de tipo fica de fora em silêncio; fique atento a isso. + +Agora você domina tudo o que uma ferramenta pode dizer de volta. A seguir, a segunda primitiva: **[Recursos](resources.md)**. diff --git a/i18n/pt/pages/servers/tools.md b/i18n/pt/pages/servers/tools.md new file mode 100644 index 0000000000..cc17259ff5 --- /dev/null +++ b/i18n/pt/pages/servers/tools.md @@ -0,0 +1,177 @@ +--- +translation: + sections: [e4cc390d56573409, 8566e2b68594e9ad, 2c97b9f888398951, 048e5471dfa71aea, 3076b1e16ad95950, edbedf2a16e71311, 3d8ef8da89fa87c1, f6c0e02e6ea5a363] + tool: 1 +--- +# Ferramentas {#tools} + +Uma **ferramenta** (tool) é uma função que o modelo pode chamar. + +Você declara uma colocando `@mcp.tool()` em uma função Python comum. A API inteira é essa. + +## Sua primeira ferramenta {#your-first-tool} + +```python title="server.py" hl_lines="6-8" +--8<-- "docs_src/tools/tutorial001.py" +``` + +Veja o que você escreveu. Não há schemas, nem JSON, nem protocolo, só uma função. O SDK lê três coisas dela: + +* O **nome** da ferramenta é o nome da função: `search_books`. +* A **descrição** que o modelo vê é a docstring: `Search the catalog by title or author.` +* Os **argumentos** que o modelo pode passar vêm das anotações de tipo: `query: str` e `limit: int`. + +### O schema de entrada {#the-input-schema} + +A partir dessas anotações de tipo, o SDK gera um JSON Schema e o envia ao cliente durante `tools/list`: + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"title": "Limit", "type": "integer"} + }, + "required": ["query", "limit"], + "title": "search_booksArguments" +} +``` + +Os dois argumentos estão em `required` porque nenhum deles tem valor padrão. Você vai resolver isso daqui a pouco. (As chaves `title` são artefatos do Pydantic; as propriedades, seus tipos e `required` são o contrato.) + +!!! tip + Aqui, as anotações de tipo não são documentação. Elas são **o contrato**. Se um cliente enviar `"limit": "ten"`, + o SDK rejeita isso antes mesmo de a sua função executar. + +### O que o modelo recebe de volta {#what-the-model-gets-back} + +Chame a ferramenta com `{"query": "dune", "limit": 5}` e o resultado tem duas partes: + +```python +result.content # [TextContent(text="Found 3 books matching 'dune' (showing up to 5).")] +result.structured_content # {'result': "Found 3 books matching 'dune' (showing up to 5)."} +``` + +`content` é o texto que o **modelo** lê. `structured_content` são dados tipados para a **aplicação cliente**. Ele está ali porque você declarou o tipo de retorno como `-> str`. + +Não se preocupe com `structured_content` por enquanto. Retorne objetos Python de verdade das suas ferramentas e a coisa certa acontece; a página **[Saída estruturada](structured-output.md)** trata exatamente disso. + +### Experimente {#try-it} + +Execute o servidor com o MCP Inspector: + +```console +uv run mcp dev server.py +``` + +Abra a URL que ele imprime, vá até a aba **Tools** e chame `search_books`. + +O Inspector renderiza um formulário com um campo de texto obrigatório `query` e um campo numérico obrigatório `limit`. Ele montou esse formulário a partir das suas anotações de tipo. Todos os outros clientes MCP vão fazer o mesmo. + +## Argumentos opcionais {#optional-arguments} + +Dê um valor padrão a um parâmetro e ele deixa de ser obrigatório. É só isso. É apenas Python. + +```python title="server.py" hl_lines="7" +--8<-- "docs_src/tools/tutorial002.py" +``` + +O schema acompanha: + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"default": 10, "title": "Limit", "type": "integer"} + }, + "required": ["query"], + "title": "search_booksArguments" +} +``` + +`limit` saiu de `required` e ganhou `"default": 10`. Um cliente que o omite recebe `10`, exatamente como aconteceria em Python. + +## Schemas mais ricos com `Field` {#richer-schemas-with-field} + +As anotações de tipo levam você longe, mas às vezes você quer *descrever* um argumento, ou restringi-lo. + +Envolva o tipo em `Annotated` e adicione um `Field` do Pydantic: + +```python title="server.py" hl_lines="12-14" +--8<-- "docs_src/tools/tutorial003.py" +``` + +Três novidades, todas nos parâmetros: + +* `Field(description=...)`: uma descrição por argumento que o modelo lê junto com a docstring. +* `Field(ge=1, le=50)`: limites numéricos. Eles entram no schema como `"minimum": 1, "maximum": 50`. +* `Literal["fiction", "non-fiction", "poetry"]`: um enum. O modelo só pode escolher um desses valores. + +!!! check + Restrições não são enfeite. Chame a ferramenta com `limit=999` e o SDK responde com um + erro de ferramenta **antes de a sua função executar**: + + ```text + Input should be less than or equal to 50 + ``` + + Esse erro volta para o modelo como o resultado da ferramenta, e o modelo o lê e tenta de novo com + um valor válido. Você escreveu `le=50` uma vez e ganhou de graça agentes que se corrigem sozinhos. + +!!! info + Se você já usou FastAPI ou Pydantic, já sabe tudo isso. É o mesmo `Field`, + o mesmo `Annotated`, a mesma validação. Não há nada específico de MCP para aprender aqui. + +## Um modelo como parâmetro {#a-model-as-a-parameter} + +Quando uma ferramenta recebe mais do que alguns poucos argumentos, agrupe-os em um modelo Pydantic: + +```python title="server.py" hl_lines="8-11 15" +--8<-- "docs_src/tools/tutorial004.py" +``` + +O schema de `Book` fica aninhado dentro do schema de entrada da ferramenta (como uma referência em `$defs`), o modelo o preenche como um objeto JSON, e sua função recebe uma **instância real de `Book`**, já validada, com os atributos `.title`, `.author` e `.year`. + +Você pode misturar à vontade: parâmetros simples ao lado de parâmetros de modelo, modelos aninhados, listas de modelos. É Pydantic de ponta a ponta. + +## `async def` {#async-def} + +Se uma ferramenta faz I/O (chama uma API, lê um arquivo, consulta um banco de dados), declare-a como `async def` e use `await` dentro dela. O SDK se encarrega de aguardá-la. + +Uma ferramenta com `def` comum também funciona: o SDK a executa em uma thread, então ela nunca bloqueia o servidor. + +Não há mais nada para configurar. + +## Nomes, títulos e anotações {#names-titles-and-annotations} + +Tudo o que o SDK infere, você pode sobrescrever no decorador: + +```python title="server.py" hl_lines="7-10" +--8<-- "docs_src/tools/tutorial005.py" +``` + +* `title` é um nome legível por humanos, pensado para interfaces. Os clientes mostram *"Search the catalog"* em vez de `search_books`. +* `annotations` são **dicas** de comportamento para o cliente: + * `read_only_hint=True`: esta ferramenta não altera nada. + * `open_world_hint=False`: ela opera sobre um conjunto fechado de coisas (este catálogo), não sobre a web aberta. + * As outras duas, `destructive_hint` e `idempotent_hint`, descrevem uma ferramenta que *escreve*: ela pode + apagar alguma coisa? E chamá-la duas vezes dá no mesmo que chamá-la uma vez? A especificação define as duas + apenas para ferramentas que não são somente leitura, então elas não diriam nada em `search_books`. + +Um cliente bem-comportado as usa para decidir coisas como *"preciso perguntar ao usuário antes de executar isto?"*. São dicas, não segurança. Nunca conte com um cliente respeitando-as. + +!!! tip + `@mcp.tool()` também aceita `name=` e `description=` se você não quiser derivá-los + do nome da função e da docstring. Na maioria das vezes você quer. + +## Recapitulando {#recap} + +* `@mcp.tool()` em uma função a transforma em ferramenta. O nome vem da função, a descrição vem da docstring. +* As anotações de tipo **são** o schema de entrada. Valores padrão tornam os argumentos opcionais. +* `Annotated[..., Field(...)]` adiciona descrições e restrições; `Literal` adiciona enums. +* Um modelo Pydantic como parâmetro é a forma de receber um "corpo" estruturado. +* Argumentos inválidos são rejeitados para você, com um erro que o modelo consegue ler e do qual consegue se recuperar. +* `async def` para I/O, `def` comum para todo o resto. + +**[Saída estruturada](structured-output.md)** é o que acontece com o valor que você devolve no `return`. diff --git a/i18n/pt/pages/servers/uri-templates.md b/i18n/pt/pages/servers/uri-templates.md new file mode 100644 index 0000000000..6ad4554117 --- /dev/null +++ b/i18n/pt/pages/servers/uri-templates.md @@ -0,0 +1,291 @@ +--- +translation: + sections: [4a7033e1ed8ad602, 55dcbfff0c6271bf, 101ef9d14bf4ec46, 4b6c4a845438abc7, f98b46bafbee4acd] + tool: 1 +--- +# Templates de URI e segurança de caminhos {#uri-templates-and-path-safety} + +Esta é a referência da sintaxe de templates de URI que +[`@mcp.resource`](resources.md) aceita e da política de segurança de +caminhos que o SDK aplica aos valores extraídos. Para uma introdução ao +que são recursos e quando usá-los, comece por +**[Recursos](resources.md)**; esta página parte do princípio de que você já +está à vontade declarando um recurso e quer o conjunto completo de +operadores, os controles de segurança ou a integração de baixo nível. + +A sintaxe dos templates é a [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570). +O SDK dá suporte a um subconjunto escolhido para casar as URIs de +`resources/read` que chegam, mais uma camada de segurança que rejeita +valores que seriam resolvidos para fora do diretório que você pretende +servir. Para os detalhes no nível do protocolo (formatos de mensagem, +ciclo de vida, paginação), veja a +[especificação de recursos do MCP](https://modelcontextprotocol.io/specification/latest/server/resources). + +## O conjunto completo de operadores {#the-full-operator-set} + +O placeholder simples, `{user_id}`, é o que **[Recursos](resources.md)** apresenta. Existem mais +quatro formas de operador; aqui estão todas em um só servidor, para você +vê-las lado a lado: + +```python title="server.py" hl_lines="16-17 22-23 28-29 34-35 40-41" +--8<-- "docs_src/uri_templates/tutorial001.py" +``` + +Cada decorador destacado é um jeito diferente de recortar a URI. +As seções abaixo percorrem todos eles, de cima para baixo. + +### Expansão simples: `{name}` {#simple-expansion-name} + +`books://{isbn}` é a forma simples, do dia a dia. O placeholder mapeia +para o parâmetro `isbn`, então um cliente que lê `books://978-0441172719` +chama `get_book("978-0441172719")`. + +Um `{name}` simples para na primeira `/`. `books://978/extra` não casa +porque a barra depois de `978` encerra a captura e `/extra` fica +sobrando. + +### Conversão de tipos {#type-conversion} + +Os valores extraídos chegam como strings, mas você pode declarar um tipo +mais específico e o SDK converte. `orders://{order_id}` vai parar em uma +função cujo parâmetro é `order_id: int`, então ler `orders://12345` chama +`get_order(12345)`, e não `get_order("12345")`. O handler faz contas com +ele (`order_id + 1`) sem precisar de cast. + +### Caminhos com vários segmentos: `{+name}` {#multi-segment-paths-name} + +Para capturar um valor que contém barras, use `{+name}`. Com +`manuals://{+path}`: + +* `manuals://returns.md` dá `path = "returns.md"` +* `manuals://printing/setup.md` dá `path = "printing/setup.md"` + +Recorra a `{+name}` sempre que o valor for hierárquico: caminhos do +sistema de arquivos, chaves de objetos aninhados, caminhos de URL que +você repassa como proxy. + +### Parâmetros de query: `{?a,b,c}` {#query-parameters-abc} + +`reviews://{isbn}{?limit,sort}` coloca `limit` e `sort` depois do `?`. +O caminho identifica *qual* livro; a query ajusta *como* você o lê. + +O casamento dos parâmetros de query é tolerante: a ordem não importa, os +extras são ignorados e os parâmetros omitidos caem nos valores padrão da +sua função. Então `reviews://978-0441172719` usa `limit=10, sort="newest"`, +e `reviews://978-0441172719?sort=top` sobrescreve apenas `sort`. + +### Segmentos de caminho como lista: `{/name*}` {#path-segments-as-a-list-name} + +Se você quer cada segmento do caminho como um item separado de uma lista, +em vez de uma única string com barras, use `{/name*}`. Com +`shelves://browse{/path*}`, um cliente que lê +`shelves://browse/fiction/sci-fi` chama +`browse_shelf(["fiction", "sci-fi"])`. + +### Referência de templates {#template-reference} + +Os padrões mais comuns: + +| Padrão | Entrada de exemplo | Você recebe | +|--------------|-----------------------|-------------------------| +| `{name}` | `alice` | `"alice"` | +| `{name}` | `docs/intro.md` | *não casa* (para na `/`) | +| `{+path}` | `docs/intro.md` | `"docs/intro.md"` | +| `{.ext}` | `.json` | `"json"` | +| `{/segment}` | `/v2` | `"v2"` | +| `{?key}` | `?key=value` | `"value"` | +| `{?a,b}` | `?a=1&b=2` | `"1"`, `"2"` | +| `{/path*}` | `/a/b/c` | `["a", "b", "c"]` | + +### O que o parser rejeita {#what-the-parser-rejects} + +Alguns formatos de template são barrados logo de início, em vez de +falharem na primeira requisição. `@mcp.resource` faz o parse do template +quando o decorador executa, então nenhum deles chega a um servidor em +execução. + +`UriTemplate.parse()` levanta `InvalidUriTemplate` nestes casos: + +* **Duas variáveis sem nada entre elas.** `manuals://{+path}{ext}` + é rejeitado: o casamento não tem como saber onde `path` termina e + `ext` começa. Coloque um literal entre elas (`manuals://{+path}/{ext}`) + ou use um operador que traga seu próprio delimitador. + `manuals://{+path}{.ext}` é aceito porque `{.ext}` contribui ele mesmo + com o `.`. +* **Mais de uma variável de vários segmentos.** No máximo uma entre + `{+var}`, `{#var}` ou uma variável explodida (`{/var*}`, `{.var*}`, + `{;var*}`) por template. Duas são inerentemente ambíguas: não há um + critério consistente para decidir qual delas absorve um segmento extra. +* **Os erros de sintaxe de sempre**: uma chave não fechada, um nome de + variável usado duas vezes ou uma funcionalidade da RFC 6570 à qual o + SDK não dá suporte, como o modificador de prefixo `{var:3}` ou a + explosão de query `{?vars*}`. + +Além disso, `@mcp.resource` levanta `ValueError` quando um parâmetro do +handler está vinculado a uma variável de query na sequência +`{?...}`/`{&...}` do final do template, mas não tem valor padrão em +Python. O casamento dessas variáveis é tolerante (um cliente pode deixar +qualquer uma delas de fora), então um parâmetro sem valor padrão só +apareceria como um erro interno opaco na primeira requisição que o +omitisse. `reviews://{isbn}{?limit,sort}` no servidor acima é a versão +bem formada: `limit` e `sort` têm, ambos, valores padrão. + +## Segurança {#security} + +Os parâmetros de template vêm do cliente. Se eles chegarem sem +verificação a operações de sistema de arquivos ou de banco de dados, +valores como `../../etc/passwd` podem ser resolvidos para fora do +diretório que você pretendia servir. + +### O que o SDK verifica por padrão {#what-the-sdk-checks-by-default} + +Antes de o seu handler executar, o SDK rejeita qualquer parâmetro que: + +* escaparia do seu diretório de partida por meio de componentes `..` +* parece um caminho absoluto (`/etc/passwd`, `C:\Windows`) ou um caminho + do Windows relativo a uma unidade (`C:foo`). Um valor relativo a + unidade e um identificador com namespace como `x:y` são + indistinguíveis como strings, então qualquer valor do tipo uma letra + seguida de dois-pontos é rejeitado por padrão; isente o parâmetro se + ele recebe valores assim de forma legítima +* contém um byte nulo (`\x00`) + +A verificação de `..` é feita por componente, não por busca de +substring. Valores como `v1.0..v2.0` ou `HEAD~3..HEAD` passam porque ali +`..` não é um segmento de caminho isolado. + +Essas verificações se aplicam ao valor decodificado, então elas pegam +tentativas de path traversal independentemente de como foram codificadas +na URI (`../etc`, `..%2Fetc`, `%2E%2E/etc`, `..%5Cetc`, `%00` são todos +barrados). + +!!! check + Leia `manuals://../etc/passwd` do servidor acima e a requisição é + rejeitada na hora: o casamento de templates para na primeira falha, + então nenhum template posterior (potencialmente mais permissivo) é + tentado como fallback. O cliente vê o mesmo erro `-32602` "Unknown + resource" que veria para uma URI que não casa com nenhum template, e + `read_manual` nunca executa. + +### Handlers de sistema de arquivos: use safe_join {#filesystem-handlers-use-safe_join} + +As verificações embutidas barram os casos comuns, mas não têm como +conhecer o limite do seu sandbox. Para acesso ao sistema de arquivos, +use `safe_join` para resolver o caminho e verificar que ele continua +dentro do seu diretório base: + +```python title="server.py" hl_lines="4 14" +--8<-- "docs_src/uri_templates/tutorial002.py" +``` + +`safe_join` pega escapes por link simbólico, sequências `..` e truques +com caminhos absolutos que uma verificação simples de string deixaria +passar. Se o caminho resolvido escapa de `DOCS_ROOT`, a função levanta +`PathEscapeError`, que chega ao cliente como um `ResourceError`. + +### Quando o comportamento padrão atrapalha {#when-the-defaults-get-in-the-way} + +Às vezes as verificações bloqueiam valores legítimos. Uma ferramenta de +importação de catálogo pode receber de propósito um caminho absoluto, ou +um parâmetro pode ser uma referência relativa como `../sibling` que o +seu handler interpreta com segurança sem tocar no sistema de arquivos. +Isente esse parâmetro ou afrouxe a política para o servidor inteiro: + +```python title="server.py" hl_lines="9 16-19" +--8<-- "docs_src/uri_templates/tutorial003.py" +``` + +* `security=ResourceSecurity(exempt_params={"source"})` no decorador + pula as verificações só para aquele parâmetro, só naquele recurso. O + resto do servidor mantém a política padrão. +* `resource_security=` no construtor de `MCPServer` define o padrão + para todos os recursos. Aqui, `relaxed` desliga por completo a + verificação de `..`. + +As verificações configuráveis: + +| Configuração | Padrão | O que faz | +|-------------------------|---------|-------------------------------------| +| `reject_path_traversal` | `True` | Rejeita sequências `..` que escapam do diretório de partida | +| `reject_absolute_paths` | `True` | Rejeita `/foo`, `C:\foo`, caminhos UNC e o `C:foo` relativo a unidade (também pega `x:y`) | +| `reject_null_bytes` | `True` | Rejeita valores que contêm `\x00` | +| `exempt_params` | vazio | Nomes de parâmetros para os quais pular as verificações | + +Essas verificações são um pré-filtro heurístico; para acesso ao sistema +de arquivos, `safe_join` continua sendo a fronteira de contenção. + +!!! tip + Se o seu handler não consegue atender à requisição (o arquivo não + existe, o id é desconhecido), levante uma exceção. O SDK a transforma + em uma resposta de erro. Veja **[Tratamento de erros](handling-errors.md)** para a + diferença entre um erro de protocolo e um erro de ferramenta. + +## Recursos no Server de baixo nível {#resources-on-the-low-level-server} + +Se você está construindo sobre o `Server` de baixo nível (veja **[O Server +de baixo nível](../advanced/low-level-server.md)**), registra handlers diretamente para os +métodos de protocolo `resources/list` e `resources/read`. Não há +decorador; quem retorna os tipos do protocolo é você. + +### Recursos estáticos {#static-resources} + +Para URIs fixas, mantenha um registro e despache por casamento exato: + +```python title="server.py" hl_lines="17 21 27" +--8<-- "docs_src/uri_templates/tutorial004.py" +``` + +O handler de listagem diz aos clientes o que está disponível; o handler +de leitura serve o conteúdo. Consulte primeiro o seu registro, caia nos +templates (abaixo) se tiver algum, e levante uma exceção para qualquer +outra coisa. + +### Templates {#templates} + +O motor de templates que o `MCPServer` usa fica em +`mcp.shared.uri_template` e funciona sozinho. Você ganha o mesmo parse e +o mesmo casamento; o roteamento e a política de segurança, você monta por +conta própria. + +```python title="server.py" hl_lines="13-16 22-25 29 33 45" +--8<-- "docs_src/uri_templates/tutorial005.py" +``` + +Três coisas acontecem nas linhas destacadas: + +* **Faça o parse uma vez, case a cada requisição.** `UriTemplate.parse()` + monta o template; `template.match(uri)` retorna as variáveis extraídas + como um `dict`, ou `None` se a URI não se encaixa. A decodificação de + URL acontece dentro de `match()`; os valores decodificados são + retornados como estão, sem validação de segurança de caminho. Os + valores saem como strings: converta-os você mesmo + (`int(matched["id"])`, `Path(matched["path"])`). +* **Aplique você mesmo as verificações de segurança.** As verificações de + `..` e de caminho absoluto que o `MCPServer` executa por padrão ficam em + `mcp.shared.path_security`. `read_manual_safely` as chama antes de + tocar em `MANUALS`. Se um parâmetro não é um caminho do sistema de + arquivos (um ISBN, uma consulta de busca), pule as verificações para + esse valor: você controla a política por handler, e não por meio de um + objeto de configuração. +* **Liste os templates a partir da mesma fonte.** Os clientes descobrem + templates por meio de `resources/templates/list`. `str(template)` + devolve a string original do template, então a listagem e o casamento + compartilham uma única fonte da verdade. + +## Recapitulando {#recap} + +* `{name}` casa com um segmento; `{+name}` mantém as barras; `{?a,b}` + puxa da query string; `{/name*}` divide os segmentos em uma lista. +* Duas variáveis sem nada entre elas, ou uma segunda variável de vários + segmentos, são rejeitadas na hora do parse. Um parâmetro vinculado a + uma variável de query em um `{?...}`/`{&...}` final deve declarar um + valor padrão em Python. +* Anote o parâmetro (`order_id: int`) e o SDK converte. +* A política de segurança padrão rejeita `..`, caminhos absolutos e bytes + nulos antes de o seu handler executar; sobrescreva por recurso com + `security=ResourceSecurity(...)` ou no servidor inteiro com + `resource_security=`. +* Para acesso ao sistema de arquivos, `safe_join` é a fronteira de + contenção. +* No `Server` de baixo nível, faça o parse com `UriTemplate.parse()`, + case com `.match()` e aplique `mcp.shared.path_security` você mesmo. diff --git a/i18n/pt/pages/translations.md b/i18n/pt/pages/translations.md new file mode 100644 index 0000000000..ceadcb2a9f --- /dev/null +++ b/i18n/pt/pages/translations.md @@ -0,0 +1,30 @@ +--- +translation: + sections: [f671b445b16e4f99, 3983a560eb2cece7, 004b3ee918529d8c, c6e2debf1da06eb7, 81d412ed5f399f94] + tool: 1 +--- +# Traduções {#translations} + +Esta documentação é escrita em inglês. Para torná-la útil a mais pessoas, também publicamos edições dela traduzidas por máquina, e esta página explica o que isso significa para você e como ajudar a melhorá-las. + +## O que está disponível {#whats-available} + +A documentação traduzida é atualmente uma **prévia** em doze idiomas: Deutsch, español, français, हिन्दी, 日本語, 한국어, português (Brasil), русский язык, Türkçe, українська мова, 简体中文 e 繁體中文. Escolha um no seletor de idioma no topo de qualquer página. Outros idiomas podem vir depois que estes se provarem. + +A referência da API não é traduzida: o site traduzido aponta para a única referência, em inglês. + +## O inglês é a fonte da verdade {#english-is-the-source-of-truth} + +Se uma página traduzida e seu original em inglês discordarem, a página em inglês é a correta. Toda página de um site traduzido abre com uma de três notas dizendo em que pé ela está: + +- **Tradução automática** — a página foi traduzida automaticamente e tem um link para o original em inglês. +- **Tradução atrás da página em inglês** — o original em inglês mudou depois que a página foi traduzida, então partes dela podem estar desatualizadas até a tradução alcançá-lo. +- **Exibida em inglês** — não há tradução atual da página, então você está lendo o texto em inglês. + +## Como as traduções são feitas {#how-the-translations-are-made} + +As páginas traduzidas são geradas por máquina por uma ferramenta deste repositório a partir das páginas em inglês em `docs/`, guiadas por dois insumos escritos por humanos para cada idioma: um guia de estilo (registro, tom, tipografia, como lidar com piadas e expressões idiomáticas) e um glossário (quais termos ficam em inglês e as traduções obrigatórias e proibidas para o restante). O texto gerado nunca é editado à mão. Toda melhoria vai para esses insumos, de modo que ela sobrevive à próxima vez que as páginas forem regeneradas. + +## Reportando um problema de tradução {#reporting-a-translation-problem} + +Encontrou um termo errado, uma frase estranha ou uma tradução que diz algo que o inglês não diz? [Abra uma issue](https://github.com/modelcontextprotocol/python-sdk/issues) com o idioma, a página e o trecho; relatos de falantes nativos são especialmente valiosos. Se você sabe a correção, proponha-a diretamente como um pull request no guia de estilo (`instructions.md`) ou no glossário (`glossary.json`) daquele idioma, em [`i18n/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/i18n) — a correção então chega a todas as páginas afetadas na próxima vez que as traduções forem regeneradas. Problemas com o próprio texto em inglês são corrigidos nas páginas em `docs/`, como qualquer outra mudança na documentação. diff --git a/i18n/pt/pages/troubleshooting.md b/i18n/pt/pages/troubleshooting.md new file mode 100644 index 0000000000..fef6a22828 --- /dev/null +++ b/i18n/pt/pages/troubleshooting.md @@ -0,0 +1,420 @@ +--- +translation: + sections: [2efaecdef109a5c5, fcacd3e66b8635a4, 25323d737dcf0261, 4835ed1772f1d113, 137454d469c867f5, 6392596bd6df54f0, 41126fa9c4fe432f, 480b6d7897e30ab4, d83bb682e708dde0, ebbed3449c499db4, 323ef84f6b4bebde, 30fd31be74169d9a, 656943c6cb567218, c2dc3b1007d2e987, 7cf5386b997d04e9, 0b59feed8384456e, 0cba47bae78d04eb, 954dc21efdb532a3] + tool: 1 +--- +# Solução de problemas {#troubleshooting} + +Cada título desta página é o texto exato de um erro que o SDK produz, seguido do que ele significa e da correção de um passo só. Procure aqui a última linha do seu traceback (ou do log do seu servidor) com a busca na página do navegador e leia apenas aquela entrada. + +Várias entradas usam este mesmo servidor. Uma ferramenta (tool) e um recurso com template, cada um lançando uma exceção para uma cidade que não conhece: + +```python title="server.py" +--8<-- "docs_src/troubleshooting/tutorial001.py" +``` + +Os erros que esta página cita são reais: a própria suíte de testes do SDK reproduz cada um deles. + +## `ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)` {#exceptiongroup-unhandled-errors-in-a-taskgroup-1-sub-exception} + +Isto não é um erro do MCP. É ruído do anyio, e o seu erro de verdade é a **última linha** do que você colou. + +`Client.__aenter__` inicia um task group. O anyio embrulha tudo o que sai de um task group em um `ExceptionGroup`, então *toda* exceção que escapa de um bloco `async with Client(...)`, seja ela qual for, chega dentro de um: + +```python +async def main() -> None: + async with Client(mcp) as client: + await client.read_resource("weather://Atlantis") +``` + +```text + + Exception Group Traceback (most recent call last): + | ... + | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception) + +-+---------------- 1 ---------------- + | Exception Group Traceback (most recent call last): + | ... + | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception) + +-+---------------- 1 ---------------- + | Traceback (most recent call last): + | ... + | mcp.shared.exceptions.MCPError: No forecast for 'Atlantis'. + +------------------------------------ +``` + +Duas coisas a fazer com isso: + +1. **Leia o final.** `MCPError: No forecast for 'Atlantis'.` é a falha; procure o texto *dela* nesta página. +2. **Capture dentro do bloco.** O `ExceptionGroup` só aparece quando a exceção *sai* do `async with`. Capturada lá dentro, a mesma falha é o `MCPError` puro, sem grupo nenhum: + +```python +async def main() -> None: + async with Client(mcp) as client: + try: + await client.read_resource("weather://Atlantis") + except MCPError as e: + print(e) # No forecast for 'Atlantis'. +``` + +!!! tip + Uma falha durante a *conexão* (uma URL errada, um servidor que não está rodando, o `421` mais + abaixo nesta página) escapa do próprio `async with`, então não existe um "dentro" onde + capturá-la. Para essas, leia o final do grupo. + +## `RuntimeError: Client must be used within an async context manager` {#runtimeerror-client-must-be-used-within-an-async-context-manager} + +`Client(...)` só constrói o objeto. Nada se conecta até o `async with`, então todo método recusa: + +```python +async def main() -> None: + client = Client(mcp) + tools = await client.list_tools() # RuntimeError +``` + +Entre nele. `__aenter__` é a conexão: + +```python +async def main() -> None: + async with Client(mcp) as client: + tools = await client.list_tools() +``` + +`__aexit__` é a desconexão, e é por isso que não existe um `client.close()` para esquecer. **[Testes](get-started/testing.md)** se baseia exatamente nesse padrão. + +## `Error executing tool : ` e `Unknown tool: ` {#error-executing-tool-name-message-and-unknown-tool-name} + +Você está lendo um **resultado**, não uma exceção. `call_tool` não lançou exceção, e nunca vai lançar para uma ferramenta que falha. + +Chame `forecast` para uma cidade que o servidor não conhece, e a exceção que ela lança volta com a requisição marcada como *bem-sucedida*: + +```python +result.is_error # True +result.content # [TextContent(text="Error executing tool forecast: No forecast for 'Atlantis'.")] +result.structured_content # None +``` + +`Unknown tool: get_forecast` é o mesmo formato para um nome que o servidor nunca registrou, e um argumento inválido é rejeitado do mesmo jeito, contra o schema de entrada da ferramenta, antes de a sua função sequer executar. + +A correção está no seu cliente: **verifique `result.is_error`**. Um `try/except` em volta de `call_tool` não captura nenhum desses, porque não há nada para capturar. Isso é proposital, e é a coisa mais útil desta página para internalizar: foi o *modelo* que escolheu a chamada, então é o modelo que recebe a mensagem e uma chance de tentar de novo. **[Tratamento de erros](servers/handling-errors.md)** tem a história completa, incluindo o caminho do `MCPError` que *de fato* lança. + +## `TypeError: The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool` {#typeerror-the-tool-decorator-was-used-incorrectly-did-you-forget-to-call-it-use-tool-instead-of-tool} + +Você escreveu `@mcp.tool` em vez de `@mcp.tool()`. `tool()` é uma *fábrica* de decoradores: sem os parênteses, o Python entrega a sua função ao parâmetro `name=` dela. + +```python +@mcp.tool # <- missing () +def forecast(city: str) -> str: + """Today's forecast for one city.""" + return f"{city}: Rain." +``` + +```text +TypeError: The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool +``` + +Adicione os parênteses. `@mcp.resource(...)` e `@mcp.prompt()` dizem a mesma coisa para o mesmo deslize. + +!!! note + Isso lança quando o módulo é **importado**, antes de qualquer cliente se conectar. Então um host + que mostra o seu servidor como *falha ao iniciar* (ou *desconectado*), em vez de conectado com + zero ferramentas, tem esse formato: execute `python server.py` você mesmo e leia o traceback. Um + verificador de tipos também pega isso: uma função não é um `name=` válido. + +## `Tool already exists: ` {#tool-already-exists-name} + +Dois registros usaram o mesmo nome de ferramenta. O **primeiro** vence, o segundo é descartado em silêncio, e este aviso no *log do servidor* é o único sinal: + +```python title="server.py" hl_lines="6 12" +--8<-- "docs_src/troubleshooting/tutorial002.py" +``` + +```text +WARNING mcp.server.mcpserver.tools.tool_manager: Tool already exists: forecast +``` + +`tools/list` reporta um `forecast`, e é o `forecast_today`. Renomeie um deles. `MCPServer(..., warn_on_duplicate_tools=False)` silencia o aviso sem mudar o resultado, então deixe ligado. Recursos e prompts têm a mesma regra e a mesma linha de log (`Resource already exists:`, `Prompt already exists:`). + +## Meu host lista zero ferramentas {#my-host-lists-zero-tools} + +Não existe string de erro para isso, e é exatamente por isso que é difícil de pesquisar. O SDK nunca descarta uma ferramenta registrada do `tools/list`, então vá de dentro para fora: + +* **O servidor chegou a iniciar?** `@mcp.tool` sem parênteses lança no momento do import, e um servidor que caiu se parece muito com um vazio em alguns hosts. Execute `python server.py` você mesmo. +* **A ferramenta está no `mcp` que o host está executando?** Um segundo `MCPServer(...)` em outro módulo é um servidor diferente e vazio. Confira qual objeto o comando do host realmente importa. +* **Duas ferramentas compartilharam um nome?** Então uma delas sumiu. Procure `Tool already exists:` no log do servidor. +* **A lista do host está desatualizada?** Adicionar uma ferramenta depois da inicialização só chega a clientes que tratam `notifications/tools/list_changed`. Reiniciar o host é a correção bruta. +* **Algo escreveu em `stdout` fora da janela desviada?** Enquanto serve, o SDK desvia para stderr o stdout perdido que já passou por *flush* (na medida do possível: um ambiente que substitui os streams padrão é servido como está), mas saída descarregada em stdout antes disso (um script wrapper ecoando, um `print()` em tempo de import num processo sem buffer) ou um `print()` em buffer esvaziado na saída do interpretador cai no stream do protocolo, e uma única linha de lixo pode fazer o host derrubar a conexão, o que alguns hosts mostram como um servidor sem nada dentro. Use o módulo `logging` para registrar logs. O resto do checklist do lado do host está em **[Conecte a um host real](get-started/real-host.md)**. + +Um nome de ferramenta "inválido" *não* está nessa lista: um nome fora do padrão registra um aviso no log, mas a ferramenta é registrada e listada mesmo assim. + +## `MCPError: Server returned an error response` {#mcperror-server-returned-an-error-response} + +O servidor recusou a requisição HTTP de cara, com um corpo que não é JSON-RPC, então o `Client` python não tem nada melhor para mostrar do que este substituto. + +De longe a causa mais comum é um servidor Streamable HTTP que acabou de passar pelo deploy. `streamable_http_app()` (e `mcp.run("streamable-http")`) sem `transport_security=` usa por padrão a **proteção contra DNS rebinding**: aceita apenas requisições cujo header `Host` é localhost. Esse é o padrão certo no seu laptop e o errado atrás de um hostname real: + +```python title="server.py" hl_lines="12" +--8<-- "docs_src/troubleshooting/tutorial003.py" +``` + +Faça o deploy disso, aponte um cliente para ele, e a conexão falha no handshake: + +```python +async with Client("https://mcp.example.com/mcp") as client: + ... +``` + +```text +mcp.shared.exceptions.MCPError: Server returned an error response +``` + +As palavras que o servidor de fato enviou, `421` e `Invalid Host header`, nunca chegam até você: o corpo do 421 não tem `Content-Type: application/json`, então o cliente não consegue fazer o parse dele. Elas estão no **log do servidor**, que é onde olhar em seguida: + +```text +WARNING mcp.server.transport_security: Invalid Host header: mcp.example.com +``` + +A correção é `transport_security=`. Coloque na allowlist o hostname que você de fato serve: + +```python title="server.py" hl_lines="14-17" +--8<-- "docs_src/troubleshooting/tutorial004.py" +``` + +!!! check + A mudança inteira é essa. O cliente idêntico agora conecta, negocia `2026-07-28` e + chama `forecast`. + +**[Deploy e escala](run/deploy.md)** cobre o que cada campo significa, o caso do proxy reverso e tudo o mais que muda na hora do deploy. E `421 Misdirected Request` / `Invalid Host header`, logo abaixo, é a mesma falha vista do outro lado. + +## `421 Misdirected Request` / `Invalid Host header` {#421-misdirected-request-invalid-host-header} + +Isto é `Server returned an error response`, visto de qualquer coisa que *não* seja o `Client` python: curl, a aba de rede de um navegador, o log de acesso de um proxy reverso ou outro SDK. + +```bash +curl -i https://mcp.example.com/mcp \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}' +``` + +```text +HTTP/1.1 421 Misdirected Request + +Invalid Host header +``` + +`421 Misdirected Request` é a reason phrase do próprio HTTP para o status; `Invalid Host header` é o corpo de resposta do SDK; e o `Client` python mostra o mesmo evento como `Server returned an error response`. Os três são uma única recusa. A verificação roda contra o **header `Host` que a requisição carrega**, não contra o endereço em que o servidor fez o bind, então um proxy reverso que repassa o hostname público a dispara exatamente como um cliente direto. + +A correção é o mesmo `transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])` mostrado em `Server returned an error response`. Vale nomear dois dos seus casos-limite: + +* Uma entrada de `allowed_hosts` é uma string exata. `"mcp.example.com"` casa com um header `Host` sem porta e `"mcp.example.com:*"` casa com qualquer porta explícita. Liste as duas. +* Um `403` com o corpo `Invalid Origin header` é a verificação irmã no header `Origin`. Ela só dispara para navegadores (nada mais envia `Origin`), e `allowed_origins=` é a allowlist dela. + +**[Deploy e escala](run/deploy.md)** tem o tratamento completo, inclusive quando desligar a verificação é a configuração honesta. + +## `RuntimeError: Task group is not initialized. Make sure to use run().` {#runtimeerror-task-group-is-not-initialized-make-sure-to-use-run} + +Seu app MCP está montado dentro de outro app ASGI, e nada iniciou o **session manager** dele. + +`mcp.streamable_http_app()` retorna um app Starlette cujo próprio lifespan inicia o manager, e `uvicorn server:app` executa esse lifespan para você. Mas o Starlette **nunca executa o lifespan de uma subaplicação montada**, então no momento em que o app vai para dentro de um `Mount`, o manager nunca inicia e a primeira requisição explode: + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/troubleshooting/tutorial005.py" +``` + +O servidor inicia. A rota resolve. Aí o `uvicorn` imprime isto para cada requisição: + +```text +ERROR: Exception in ASGI application +Traceback (most recent call last): + ... +RuntimeError: Task group is not initialized. Make sure to use run(). +``` + +O cliente vê um 500. A correção é um lifespan no app **host** que entra em `mcp.session_manager.run()`: + +```python +@asynccontextmanager +async def lifespan(app: Starlette) -> AsyncIterator[None]: + async with mcp.session_manager.run(): + yield + + +app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lifespan) +``` + +**[Adicione a um app existente](run/asgi.md)** é a página para isso, incluindo vários servidores em um app só e FastAPI. Duas strings vizinhas da mesma classe: + +* `StreamableHTTPSessionManager .run() can only be called once per instance. Create a new instance if you need to run again.` O manager é de uso único; entrar duas vezes no lifespan do mesmo app bate nela. +* `mcp.session_manager` só existe **depois** que `streamable_http_app()` foi chamado, então monte as rotas primeiro e toque no manager apenas dentro do lifespan. + +## `MCPError: Session not found` {#mcperror-session-not-found} + +O servidor não reconhece o `Mcp-Session-Id` que o seu cliente enviou, quase sempre porque o servidor **reiniciou** (ou você foi roteado para uma instância diferente). As sessões vivem na memória daquele único processo. + +Não há bug de servidor para encontrar. A resposta HTTP é um `404` cujo corpo *é* JSON-RPC, então, ao contrário do `421` acima, o `Client` python mostra esta aqui palavra por palavra: + +```json +{"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "Session not found"}} +``` + +A correção é reconectar: saia do bloco `async with Client(...)` e entre em um novo, que negocia uma sessão nova. Para um cliente de vida longa, isso significa capturar `MCPError` em volta das suas chamadas e reconectar ao ver esta mensagem, em vez de tentar de novo dentro de uma sessão morta. + +Se isso acontece *sem* um reinício, você está rodando mais de um worker sem sticky sessions: cada worker mantém a própria tabela de sessões, então uma requisição roteada para o errado cai aqui. **[Deploy e escala](run/deploy.md)** e **[Atendendo clientes legados](run/legacy-clients.md)** são donos dessa história e das suas duas correções (roteamento sticky, ou `stateless_http=True`). + +Para quem opera o servidor, a linha de log correspondente é `Rejected request with unknown or expired session ID: `. Ela é registrada em `INFO`, então é invisível no limite usual de `WARNING`. Vê-la em rajadas logo depois de um deploy é normal; todo cliente conectado está reconectando. + +## `MCPError: Method not found` {#mcperror-method-not-found} + +Um lado enviou uma requisição JSON-RPC para a qual o outro não tem handler, e `e.error.data` nomeia o método. A causa usual é um **descompasso de era**: um método que existe em uma revisão do protocolo e não na outra, enviado a um par que está na errada, como um `resources/subscribe` da era `2025` chegando a uma conexão `2026-07-28`, ou um `subscriptions/listen` exclusivo de `2026` enviado por um cliente fixado em `mode="legacy"`. **[Versões do protocolo](protocol-versions.md)** é o mapa de qual lado fala o quê, e a outra causa honesta (uma capacidade opcional para a qual você nunca registrou um handler) está em **[Completions](servers/completions.md)**. + +Uma coisa **não** produz este erro, apesar de ser uma requisição que o protocolo moderno removeu: uma ferramenta chamando `ctx.elicit()` em uma conexão `2026-07-28`. O servidor se recusa a sequer *enviar* essa requisição, então o que você recebe em vez disso é `Cannot send 'elicitation/create': ...`, mais abaixo nesta página. + +## `MCPError: Client did not declare the form elicitation capability required by resolver ''` {#mcperror-client-did-not-declare-the-form-elicitation-capability-required-by-resolver-name} + +Seu servidor quer perguntar algo ao usuário, e este cliente nunca disse que pode receber perguntas. + +Um resolvedor de elicitação (elicitation) recusa logo de início quando o cliente conectado não declarou elicitação por formulário, e `e.error.data` nomeia exatamente o que falta: + +```json +{ + "code": -32021, + "message": "Client did not declare the form elicitation capability required by resolver 'server:ask_to_confirm'", + "data": {"requiredCapabilities": {"elicitation": {"form": {}}}} +} +``` + +Passe `elicitation_callback=` para `Client(...)`. Registrar o callback *é* a declaração da capacidade; não existe uma segunda chave: + +```python +async def main() -> None: + async with Client(mcp, elicitation_callback=handle_elicitation) as client: + result = await client.call_tool("book_table", {"date": "Friday"}) +``` + +**[Callbacks do cliente](client/callbacks.md)** lista os outros (`sampling_callback`, `list_roots_callback`), cada um dos quais é uma declaração do mesmo jeito. + +!!! info + `-32021` é `MISSING_REQUIRED_CLIENT_CAPABILITY`, um dos três códigos de erro que a especificação + 2026-07-28 adiciona. Nenhum deles é uma classe de exceção: todos chegam como `MCPError`, e + `e.error.code` é onde olhar. `mcp.types` exporta as constantes. Os outros dois são + `-32020` `HEADER_MISMATCH` (um header HTTP discorda do corpo da requisição que ele acompanha) + e `-32022` `UNSUPPORTED_PROTOCOL_VERSION` (a requisição nomeou uma versão que este servidor não + fala). Um cliente SDK em conformidade não consegue produzir nenhum dos dois, então, se você vir + um, olhe para o que quer que esteja reescrevendo requisições entre o seu cliente e o seu servidor. + +## `MCPError: Elicitation not supported` {#mcperror-elicitation-not-supported} + +A mesma lacuna de `Client did not declare the form elicitation capability ...`, escrita pelos caminhos que não verificam de início: o servidor precisava de uma elicitação respondida, e o cliente conectado não registrou nenhum `elicitation_callback`. + +Você vê esta a partir de `ctx.elicit()` em uma conexão legada, e em qualquer conexão a partir de uma pergunta de múltiplas idas e voltas retornada (**[Requisições de múltiplas idas e voltas](handlers/multi-round-trip.md)**) que chega a um cliente sem callback para respondê-la. A correção é idêntica: passe `elicitation_callback=` para `Client(...)`. Não existe versão de "o usuário não foi perguntado" que a sua ferramenta receba como um `decline`; um cliente que não pode receber perguntas é uma chamada que falhou, então projete as suas ferramentas para isso. + +## `MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests.` {#mcperror-cannot-send-elicitationcreate-this-transport-context-has-no-back-channel-for-server-initiated-requests} + +Seu handler tentou alcançar o cliente no meio da requisição, em uma conexão cuja chamada não tem canal capaz de carregar uma requisição vinda do servidor. Há três configurações de servidor que colocam uma chamada nessa situação. + +**Uma conexão `2026-07-28`: qualquer transporte, sempre.** O protocolo moderno não tem nenhuma requisição iniciada pelo servidor, então o servidor recusa antes que qualquer coisa seja enviada. `ctx.elicit()` dentro de uma ferramenta é o jeito clássico de topar com isso (logo no primeiro teste em memória, já que `Client(server)` negocia `2026-07-28` sem que ninguém peça), e passar `elicitation_callback=` não muda nada, porque nenhuma requisição chega ao cliente para ele responder: + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/troubleshooting/tutorial006.py" +``` + +```python +async def main() -> None: + async with Client(mcp) as client: + await client.call_tool("book_table", {"date": "Friday"}) +``` + +```text +mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests. +``` + +**Uma conexão legada em um servidor `stateless_http=True`.** Ser stateless significa que cada requisição é um mundo próprio: sem sessão, sem stream do servidor para o cliente e, portanto, sem lugar para enviar um `elicitation/create` (ou `sampling/createMessage`, ou `roots/list`) mesmo na era que os tem: + +```python title="server.py" hl_lines="16 23" +--8<-- "docs_src/troubleshooting/tutorial008.py" +``` + +**Uma conexão legada em um servidor `json_response=True`.** O `POST` é respondido com um único corpo JSON, e um único corpo carrega só a resposta, então o stream com escopo de requisição de que um `ctx.elicit()` no meio da requisição precisa também não existe aqui. A sessão, o `Mcp-Session-Id` dela e o stream avulso dela continuam todos lá; só o canal com escopo de requisição sumiu. + +A mensagem nomeia o método que não conseguiu enviar. `NoBackChannelError` é a classe que o servidor lança, mas na rede trafega apenas o `MCPError` base, então a frase acima é a última linha do seu traceback, não o nome da classe. + +Para um cliente `2026-07-28`, a correção é a mesma nas três: não tente voltar ao cliente no meio da chamada. Mova a pergunta para um **resolvedor** (ou retorne você mesmo um `InputRequiredResult`) e ela vira parte da *resposta*, que toda conexão consegue carregar: + +```python title="server.py" hl_lines="15-17 21" +--8<-- "docs_src/troubleshooting/tutorial007.py" +``` + +Mesma pergunta, mesmo `elicitation_callback` no cliente. A diferença está por baixo dos panos: um resolvedor deixa o servidor *retornar* a pergunta a partir da chamada em vez de empurrá-la, então nada nunca flui do servidor para o cliente. Isso salva todo cliente `2026-07-28`, em qualquer das três configurações em que o servidor esteja. Um cliente *legado* não é salvo só pela reescrita: `2025-11-25` não tem como retornar uma pergunta, então em uma conexão legada o resolvedor ainda envia `elicitation/create` pelo canal com escopo de requisição, e ainda precisa de um servidor que o mantenha — nem `stateless_http=True` nem `json_response=True`. **[Elicitação](handlers/elicitation.md)** cobre resolvedores; **[Requisições de múltiplas idas e voltas](handlers/multi-round-trip.md)** cobre o que acontece na rede. + +!!! check + A ferramenta com `ctx.elicit()` não está errada, ela é *pré-2026*. Conecte com `mode="legacy"` + (o handshake `initialize` clássico, especificação `2025-11-25` e anteriores) a um servidor que não + seja nem `stateless_http=True` nem `json_response=True`, e funciona, porque o canal do servidor + para o cliente existe ali. + **[Versões do protocolo](protocol-versions.md)** é a página sobre o que cada versão tem. + +## `MCPError: Invalid or expired requestState` {#mcperror-invalid-or-expired-requeststate} + +O servidor não conseguiu verificar o token `requestState` que o seu cliente devolveu, então recusou a rodada. + +`requestState` é o token opaco de retomada que uma chamada de **[múltiplas idas e voltas](handlers/multi-round-trip.md)** carrega entre um trecho e outro. O `MCPServer` o sela na saída e verifica cada devolução, e verifica *todo* `request_state` de entrada em `tools/call`, `prompts/get` e `resources/read`, mesmo para um handler que nunca emite um. Então um token que este processo não selou é recusado onde quer que ele caia: + +```python +async def main() -> None: + async with Client(mcp) as client: + await client.call_tool("forecast", {"city": "London"}, request_state="round-1-from-worker-a") +``` + +```text +mcp.shared.exceptions.MCPError: Invalid or expired requestState +``` + +A mensagem é congelada de propósito: a rede nunca revela qual verificação falhou. O motivo vai para o **log do servidor**, e lê-lo é o diagnóstico inteiro: + +```text +WARNING mcp.server.request_state: requestState rejected on tools/call: malformed +``` + +Os motivos que você vai ver de fato: + +* **`unknown key`** é o que importa. A chave de selagem padrão é gerada na inicialização do processo, então uma nova tentativa que cai em um **worker diferente**, em uma instância diferente atrás de um balanceador de carga, ou no mesmo servidor **depois de um reinício** foi selada com uma chave que este processo nunca teve. Isso não é um atacante; é o padrão encontrando mais de um processo. +* **`audience`**: o token foi selado por uma instância com um *nome de servidor diferente*. O nome é a claim de audience padrão do selo, então uma frota precisa compartilhar o nome (ou definir um `RequestStateSecurity(audience=...)` explícito) além das chaves. +* **`expired`**: a rodada demorou mais que o `ttl` do selo, que é de 600 segundos e por rodada, não por chamada. +* **`malformed`** / **`codec error`**: o token foi alterado em trânsito, ou nunca foi um token selado. +* **`request binding`**: o token voltou com uma ferramenta diferente, argumentos diferentes ou um método diferente. + +A correção para múltiplos processos é um argumento (as *mesmas* `keys` em toda instância) mais uma coisa que nem argumento é: o mesmo *nome* de servidor (ou um `audience=` compartilhado explícito). + +```python +mcp = MCPServer("Weather", request_state_security=RequestStateSecurity(keys=[key])) +``` + +`keys[0]` sela; toda chave da lista verifica, e é isso que torna possível a rotação sem downtime. **[Requisições de múltiplas idas e voltas](handlers/multi-round-trip.md#protecting-requeststate)** explica o que o selo protege e a sequência de rotação, e **[Deploy e escala](run/deploy.md)** percorre a falha completa com dois workers e a sua correção em duas partes. + +!!! tip + `keys=[...]` recusa uma chave fraca na hora, com uma mensagem incomumente útil: + + ```text + ValueError: request-state keys must be at least 32 bytes of secret randomness; keys[0] is 7 bytes. Generate one with: python -c "import secrets; print(secrets.token_hex(32))" + ``` + + Faça o que ela diz. + +## Ainda travado? {#still-stuck} + +* Se uma mensagem que o SDK produziu não está nesta página, isso é um bug de documentação que vale reportar por si só. +* Pesquise no [issue tracker](https://github.com/modelcontextprotocol/python-sdk/issues); a maioria das strings de erro que aparecem lá já é o relato de alguém. +* Não achou nada? [Abra uma issue](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml) com o traceback completo, ou pergunte no [#python-sdk-dev no Discord MCP Contributors](https://discord.gg/6CSzBmMkjX). + +## Recapitulando {#recap} + +* `ExceptionGroup: unhandled errors in a TaskGroup` nunca é o erro. Leia a **última linha**; capturar `MCPError` *dentro* do bloco `async with Client(...)` pula o embrulho por completo. +* `call_tool` não lança exceção para uma ferramenta que falha. `Error executing tool ...` e `Unknown tool: ...` são resultados: verifique `result.is_error`. +* `Client must be used within an async context manager` -> use `async with`. `Use @tool() instead of @tool` -> adicione os parênteses. +* `Tool already exists:` no log do servidor é o único sinal de que duas ferramentas com o mesmo nome viraram uma só. +* Um 421, três grafias: `Server returned an error response` (o `Client` python), `421 Misdirected Request` / `Invalid Host header` (todo o resto), `Invalid Host header: ` (o log do servidor). Correção: `transport_security=TransportSecuritySettings(allowed_hosts=[...])`. +* `Task group is not initialized` -> um app montado cujo lifespan do host nunca entrou em `mcp.session_manager.run()`. +* `Session not found` -> o servidor reiniciou; reconecte. +* `Cannot send 'elicitation/create': ... no back-channel ...` -> `ctx.elicit()` precisa de um canal do servidor para o cliente: uma conexão `2026-07-28` nunca tem um, `stateless_http=True` tira o legado, e `json_response=True` tira o de escopo de requisição. Use um resolvedor (um cliente legado também precisa de um servidor que mantenha o canal). O vizinho `Method not found` é uma requisição para um método que a revisão do protocolo do outro lado não tem. +* `Client did not declare the form elicitation capability ...` e `Elicitation not supported` -> falta `elicitation_callback=` no cliente. +* `Invalid or expired requestState` nunca diz o porquê na rede. O log do servidor diz; `unknown key` significa compartilhar `RequestStateSecurity(keys=[...])` entre os workers. diff --git a/i18n/pt/pages/whats-new.md b/i18n/pt/pages/whats-new.md new file mode 100644 index 0000000000..c4565dc2c4 --- /dev/null +++ b/i18n/pt/pages/whats-new.md @@ -0,0 +1,215 @@ +--- +translation: + sections: [cfe01c0c5863dfa2, 11d93f1fa09eadf5, a7392996acf1ad8f, 875eb2889263424e] + tool: 1 +--- +# O que há de novo na v2 {#whats-new-in-v2} + +Duas coisas aconteceram ao mesmo tempo na v2. O **SDK foi reconstruído**: um motor novo por baixo tanto do cliente quanto do servidor, um `Client` de primeira classe e um conjunto de renomeações em que uma base de código v1 esbarra logo no primeiro import. E o **protocolo mudou**: a v2 fala a revisão 2026-07-28 do MCP, que remove o handshake de conexão, a sessão e toda requisição iniciada pelo servidor, sem abandonar os clientes que você já tem. + +Esta página é o tour pelas duas metades, uma seção por destaque, cada uma terminando na página responsável pelo assunto. Não é o manual de como portar. Esse é o **[Guia de migração](migration.md)**: cada quebra de compatibilidade, com o código de antes e de depois. + +!!! note "A v2 é a linha estável" + `pip install mcp` instala a 2.x, e **[Instalação](get-started/installation.md)** tem a linha de + instalação para copiar e colar. Se algo na v2 quebrar, surpreender ou atrasar você, + [conte para nós](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml). + +## O SDK: da v1 para a v2 {#the-sdk-v1-to-v2} + +### `FastMCP` agora é `MCPServer` {#fastmcp-is-now-mcpserver} + +A classe de servidor de alto nível foi renomeada, e o módulo dela junto. É a primeira coisa em que todo servidor v1 esbarra, porque o caminho de import antigo sumiu em vez de ficar obsoleto: + +```python +from mcp.server import MCPServer # v1: from mcp.server.fastmcp import FastMCP + +mcp = MCPServer("Demo") # v1: FastMCP("Demo") +``` + +Para um servidor feito com decoradores, isso também é a maior parte do trabalho de portar. `@mcp.tool()`, `@mcp.resource()` e `@mcp.prompt()` aceitam o que aceitavam na v1 (`@mcp.resource()` ganha um argumento nomeado opcional, `security=`), e o schema de entrada continua vindo das suas anotações de tipo. Em volta disso: tudo que ficava em `mcp.server.fastmcp.*` agora vive em `mcp.server.mcpserver.*`, `ctx.fastmcp` virou `ctx.mcp_server`, `get_context()` sumiu (declare um parâmetro `ctx: Context` no lugar), e a exceção base `FastMCPError` virou `MCPServerError`. O **[Guia de migração](migration.md#fastmcp-renamed-to-mcpserver)** tem a tabela de imports. + +### `Resolve`: o novo jeito de pedir informações ao usuário {#resolve-the-new-way-to-ask-the-user-for-input} + +Nem tudo de que uma ferramenta (tool) precisa deve vir do modelo. Novidade na v2: um parâmetro de ferramenta anotado com `Resolve(fn)` é preenchido por uma função que você escreve, de forma invisível para o modelo, e essa função pode retornar `Elicit(...)` para apresentar uma pergunta ao usuário. Esse é o jeito preferido de obter qualquer coisa do cliente no meio de uma chamada: o SDK leva a pergunta pelo mecanismo que a conexão suportar (uma requisição de elicitação (elicitation) ao vivo para um cliente legado, um multi-round-trip na 2026-07-28), então um único corpo de ferramenta atende as duas eras. **[Dependências](handlers/dependencies.md)** é a página. + +!!! note + As outras duas formas continuam lá para quando você precisar delas: `ctx.elicit()` ainda + funciona para clientes em conexões legadas (**[Elicitação](handlers/elicitation.md)**), e um + handler pode retornar ele mesmo um `InputRequiredResult` e conduzir as rodadas à mão, que é + também como as requisições de amostragem (sampling) e de roots trafegam na 2026-07-28 + (**[Requisições multi-round-trip](handlers/multi-round-trip.md)**). + +### Um `Client` de primeira classe {#a-first-class-client} + +A v1 entregava três camadas aninhadas: um gerenciador de contexto de transporte que produzia streams brutos, uma `ClientSession` em volta deles e um `await session.initialize()` chamado à mão. A v2 tem um objeto só: + +```python title="client.py" hl_lines="14-18" +--8<-- "docs_src/client/tutorial001.py" +``` + +`Client` recebe um objeto de servidor (em memória, sem transporte: é o cenário dos testes), uma URL (Streamable HTTP) ou qualquer gerenciador de contexto de transporte, como `stdio_client(...)`. Entrar no `async with` conecta e negocia a versão do protocolo, seja qual for a era que o servidor fale; `client.server_capabilities` e `client.protocol_version` simplesmente estão lá depois disso, e `client.server_info` também, quando o servidor se identifica (agora ele é `Implementation | None`, já que na era 2026 a identidade é opcional). Os callbacks de amostragem e de elicitação que você registrou na v1 continuam funcionando (o corpo deles passa pela mesma renomeação de atributos para snake_case que todo o resto desta página), agora também respondem às requisições-dentro-de-resultados no estilo 2026 (abaixo), e rodam de forma concorrente em vez de um por vez. `ClientSession` continua por baixo para quem quer a superfície de baixo nível, e `client.session` a entrega para você; ela também mudou (roda sobre o novo motor de dispatcher, e algumas das próprias assinaturas dela mudaram), então leia o **[Guia de migração](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)** antes de descer de nível. + +**[O Client](client/index.md)** o apresenta, **[Transportes do cliente](client/transports.md)** cobre as três formas de conexão, **[Callbacks do cliente](client/callbacks.md)** cobre os callbacks em si, e **[Testes](get-started/testing.md)** mostra o padrão em memória que substitui o helper `create_connected_server_and_client_session()` da v1. + +### O `Server` de baixo nível foi reconstruído, não renomeado {#the-low-level-server-was-rebuilt-not-renamed} + +Se você trabalha na camada JSON-RPC, esta é a parte "tudo é diferente" da v2. Aqui está o mesmo servidor de uma ferramenta só das duas formas; clique nos marcadores para ver o que mudou de lugar. + + + +```python title="v1" +from typing import Any + +import mcp.types as types +from mcp.server.lowlevel import Server + +server = Server("Bookshop") + + +@server.list_tools() # (1)! +async def list_tools() -> list[types.Tool]: + return [ # (2)! + types.Tool( + name="search_books", + description="Search the catalog by title or author.", + inputSchema={ # (3)! + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + }, + ) + ] + + +@server.call_tool() +async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentBlock]: # (4)! + if name != "search_books": + raise ValueError(f"Unknown tool: {name}") # (5)! + ctx = server.request_context # (6)! + return [types.TextContent(type="text", text=f"Found 3 books matching {arguments['query']!r}.")] # (7)! +``` + +1. Os handlers são registrados com decoradores (chamados, com parênteses), a qualquer momento depois que o servidor existe. +2. Você retorna uma `list[Tool]` pura e o SDK a embrulha em um `ListToolsResult`. +3. Os campos são camelCase em Python, e o schema é **aplicado**: o SDK valida os argumentos de `call_tool` contra ele com jsonschema antes de a sua função rodar, e é por isso que `arguments["query"]` abaixo é seguro. +4. Um único handler `call_tool` atende todas as ferramentas, e recebe o nome da ferramenta e os argumentos já validados, desempacotados e nunca `None`. +5. Lançar uma exceção é como uma ferramenta v1 sinaliza falha: qualquer exceção é capturada e retornada como `CallToolResult(isError=True)` com `str(e)` como texto, então o modelo que fez a chamada lê essa mensagem e pode tentar de novo. +6. O contexto vem de uma ContextVar ambiente, alcançada pelo objeto do servidor no meio da requisição. +7. Blocos de conteúdo puros são embrulhados em um `CallToolResult` para você. + +```python title="v2" +--8<-- "docs_src/whats_new/tutorial001.py" +``` + +1. Os campos agora são snake_case, e o schema é **anunciado, mas nunca aplicado**: nada confere os argumentos antes de o seu handler rodar. +2. Todo handler tem o mesmo formato: `async (ctx, params) -> result`. O contexto é o primeiro argumento (`ctx.session`, `ctx.request_id`, `ctx.protocol_version` moram nele); é aqui que `server.request_context` foi parar. +3. Você monta o `ListToolsResult` completo por conta própria. Retornar uma lista pura agora é um `TypeError` no lado do servidor, não algo que o SDK embrulha. +4. Entram params tipados (`params.name`, `params.arguments`), sai um resultado completo. Nada é desempacotado, embrulhado ou convertido para você. +5. A mesma verificação, outro verbo. Um `ValueError` aqui chegaria ao modelo como um `-32603` opaco (veja abaixo), então um erro de protocolo deliberado é lançado como `MCPError`: ele passa direto, com código e mensagem intactos, e `-32602` com esse texto é a resposta da própria especificação para uma ferramenta desconhecida. +6. `params.arguments` pode ser `None`; a v1 o trocava por `{}` antes mesmo de o seu código vê-lo. Sem validação na frente do handler, esta linha é indispensável. +7. Uma exceção inesperada lançada aqui vira um erro de protocolo **sanitizado**, `-32603` `"Internal server error"`: o modelo nunca vê a mensagem. Para uma falha que o modelo deva ler e à qual deva reagir, retorne `CallToolResult(is_error=True, ...)`. +8. Os handlers são argumentos do construtor, então a superfície do servidor está completa no instante em que ele existe; `add_request_handler()` é a saída de emergência pós-construção, e a porta para métodos personalizados. + +O exemplo é o padrão. De forma mais geral: todo handler tem o mesmo formato, com params tipados na entrada e um tipo de resultado completo na saída; a antiga verificação com jsonschema dos argumentos de ferramenta sumiu; uma exceção é um erro de protocolo, nunca um resultado de ferramenta com `is_error=True`; e a ContextVar ambiente `server.request_context` sumiu. Métodos personalizados, com namespace de fornecedor, são de primeira classe via `add_request_handler(method, params_type, handler)`, que valida os params de entrada contra o seu modelo antes de o seu handler rodar. E uma lista `middleware` (marcada como provisória de propósito) envolve toda mensagem de entrada, substituindo os métodos privados `_handle_*` que as pessoas costumavam sobrescrever. + +Por baixo dos panos, o loop de recebimento do `BaseSession` da v1 foi substituído por um motor de dispatcher que cliente e servidor agora compartilham, e é ele que torna várias coisas desta página verdadeiras ao mesmo tempo: um único objeto `Server` atende as duas eras do protocolo, `Client(server)` despacha dentro do processo sem o enquadramento JSON-RPC, e uma requisição de cliente que estoura o timeout agora cancela de fato o handler do lado do servidor. + +**[O Server de baixo nível](advanced/low-level-server.md)** é a página; o **[Guia de migração](migration.md#lowlevel-server-decorator-based-handlers-replaced-with-constructor-on_-params)** percorre cada hook removido. Se você nunca desceu abaixo do `MCPServer`, nada disso afeta você. + +### Os tipos do protocolo foram para `mcp-types`, e todo campo é snake_case {#the-wire-types-moved-to-mcp-types-and-every-field-is-snake_case} + +Os tipos do protocolo agora vivem em uma distribuição própria, `mcp-types`. Ela não depende de nada além de pydantic e typing-extensions, então um gateway, um proxy ou um gerador de código consegue consumir os formatos de mensagem do MCP sem instalar uma pilha HTTP: um projeto assim instala `mcp-types` e importa `mcp_types`. O próprio `mcp` depende desse pacote em uma versão exata e o reexpõe, então o código que depende do SDK continua escrevendo `import mcp.types as types` e `from mcp.types import Tool` (um alias permanente, cada nome é o mesmo objeto) e declara apenas a sua única dependência real, `mcp`. A regra prática: importe pelo pacote do qual você de fato depende. + +Nesses tipos, todo atributo Python agora é snake_case: `result.is_error`, `tool.input_schema`, `listing.next_cursor`. O JSON que trafega é camelCase, exatamente como antes; só a grafia dos atributos mudou. Dois padrões mais rígidos vêm junto: campos desconhecidos são ignorados em vez de preservados na ida e volta (coloque os extras em `_meta`), e os dois lados validam o tráfego contra a versão do protocolo que negociaram. Veja o **[Guia de migração](migration.md#field-names-changed-from-camelcase-to-snake_case)** para a tabela de renomeações. + +### A configuração de transporte foi para `run()` {#transport-configuration-moved-to-run} + +`MCPServer(...)` diz respeito ao que o seu servidor *é*: o nome, as instruções, o lifespan, a autenticação. Como ele é *servido* agora é assunto de `run()` e dos construtores de app, e foi para lá que `host`, `port`, `stateless_http`, `json_response`, os caminhos dos endpoints e `transport_security` foram (`MCPServer("x", port=9000)` é um `TypeError`). As sobrecargas são tipadas por transporte, então o seu editor diz quais opções `stdio` aceita e quais `streamable-http` aceita. Uma remoção que vale conhecer: `mount_path` sumiu; montar o app ASGI é o jeito suportado de servir sob um prefixo. + +**[Executando seu servidor](run/index.md)** cobre as opções; **[Adicionar a um app existente](run/asgi.md)** cobre a montagem. + +### Comportamento que muda sem erro de import {#behavior-that-changes-without-an-import-error} + +As renomeações se anunciam sozinhas. Estas aqui, não: + +* **Funções síncronas rodam em uma thread de trabalho.** Uma ferramenta `def` (ou recurso, prompt ou resolvedor) não bloqueia mais o loop de eventos; a contrapartida é que o corpo dela não roda mais *na* thread do loop de eventos, o que importa para código com afinidade de thread. Handlers `async def` ficam intocados. **[Guia de migração](migration.md#sync-handler-functions-now-run-on-a-worker-thread)**. +* **`MCPError` (o `McpError` da v1) lançado dentro de uma ferramenta agora é um erro de protocolo.** O modelo nunca o vê. Toda outra exceção continua virando um resultado `is_error=True` que o modelo pode ler e ao qual pode reagir. **[Tratando erros](servers/handling-errors.md)** explica a divisão. +* **Os resultados são validados antes de sair.** Uma `Tool` montada à mão cujo `input_schema` é `{}` agora falha em `tools/list` (a especificação exige `"type": "object"`). Servidores construídos com `@mcp.tool()` nunca veem isso; o SDK escreve os schemas deles. +* **O seu cliente valida o que recebe.** `list_tools()` e `call_tool()` conferem a resposta do servidor contra a versão de protocolo negociada, então um servidor quase válido que o parsing tolerante da v1 aceitava agora lança `pydantic.ValidationError`. Se você se conecta a servidores que não controla, espere ser você quem os descobre; o **[Guia de migração](migration.md#client-validates-inbound-traffic-against-the-protocol-schema)** tem os detalhes. +* **Templates de URI agora são RFC 6570 de verdade.** `{+path}`, `{?query}` e companhia funcionam, a correspondência é exata em vez de frouxa à base de regex, e path traversal nos valores extraídos é rejeitado por padrão. Templates mais rígidos falham no momento da decoração, não na primeira requisição. **[Templates de URI](servers/uri-templates.md)**. +* **O lifespan do Streamable HTTP roda uma vez só**, na inicialização, e o estado dele é compartilhado por toda sessão e requisição. Na v1 ele rodava uma vez por sessão, e uma vez por requisição com `stateless_http=True`. Pools e caches montados em um lifespan ficam drasticamente mais baratos; qualquer coisa que adquiria ali um recurso por conexão agora pertence ao corpo do handler. **[Lifespan](handlers/lifespan.md)**. +* **`mcp dev` e `mcp install` fixam o ambiente que criam** na versão do SDK que você tem instalada. Os dois comandos rodam o seu servidor em um ambiente `uv run --with ...` novo, que antes resolvia `mcp` para a versão estável mais recente em vez da versão contra a qual você está desenvolvendo. **[Guia de migração](migration.md#mcp-dev-and-mcp-install-pin-the-spawned-environment-to-your-sdk-version)**. +* **O cliente HTTP agora é `httpx2`, não `httpx`.** A troca de dependência muda o que o seu código captura e repassa (`httpx2.AsyncClient`, `httpx2.ConnectError`), e muda como os certificados TLS são verificados: `httpx2` valida via `truststore` contra o repositório de certificados confiáveis do sistema operacional em vez da lista de CAs embutida do certifi. A maioria dos ambientes nem percebe; um contêiner mínimo sem repositório de CAs do sistema, ou uma CA privada que só o bundle do certifi conhecia, começa a falhar no handshake TLS. Defina `SSL_CERT_FILE`/`SSL_CERT_DIR` ou passe `verify=ssl_context` para o seu cliente. **[Guia de migração](migration.md#httpx-and-httpx-sse-replaced-by-httpx2)**. + +### Removidos de vez {#removed-outright} + +Cada um destes é uma seção no **[Guia de migração](migration.md)**: + +* O **transporte WebSocket**, dos dois lados, e o extra `mcp[ws]`. Nunca fez parte da especificação do MCP. +* A API **experimental de Tasks** (`mcp.*.experimental`). A 2026-07-28 tira as tasks do núcleo do protocolo e as leva para uma extensão oficial ([SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663)), que este SDK ainda não implementa. +* `mcp.shared.version`, `mcp.shared.progress` e `mcp.shared.session` (junto com o stub `RequestResponder` que as anotações de `message_handler` da v1 importavam) como caminhos de import. (`mcp.types` *não* foi removido: continua como alias permanente do pacote independente `mcp_types`.) +* A grafia obsoleta `streamablehttp_client`, e o callback `get_session_id` de `streamable_http_client` (que agora produz exatamente dois streams). +* `McpError`, renomeado para **`MCPError`** com um construtor direto `(code, message, data)`. +* `MCPServer.get_context()`, `mount_path=`, e os métodos decoradores, a ContextVar e os dicts de handlers do `Server` de baixo nível. + +## O protocolo: de 2025-11-25 para 2026-07-28 {#the-protocol-2025-11-25-to-2026-07-28} + +A v2 implementa a revisão 2026-07-28 e serve **as duas** revisões ao mesmo tempo: o mesmo `streamable_http_app()` (e o mesmo servidor stdio) responde ao `initialize` de um cliente da era 2025 e às requisições de um cliente da era 2026 sem nada para configurar, sem flag para virar e sem deploy separado. Servir a revisão nova não abandona um cliente que está na antiga. O que vem a seguir é o que a revisão nova em si muda. + +### Sem handshake, sem sessão {#no-handshake-no-session} + +Um cliente 2026-07-28 não abre uma conexão, negocia e só então conversa. Toda requisição carrega a versão do protocolo, as informações do cliente e as capacidades do cliente em `_meta`, e a única chamada de descoberta, `server/discover`, é uma requisição comum como qualquer outra. `Client` faz a coisa certa por padrão: sonda `server/discover` uma vez e recua para o handshake `initialize` se o servidor for mais antigo. + +Sobre Streamable HTTP não existe `Mcp-Session-Id` no caminho 2026, e esse é o grande destaque operacional: **nada amarra uma requisição moderna a um worker**, então qualquer réplica atrás de um balanceador de carga round-robin simples pode respondê-la. Duas ressalvas honestas. Os seus clientes da era 2025 (hoje, isso é a maioria dos clientes) ainda abrem sessões e ainda precisam de toda a afinidade de sessão de que precisavam na v1; nada muda para eles. E a única coisa que uma nova tentativa *multi-round-trip* precisa carregar entre workers é o seu `request_state` selado, cuja chave padrão é gerada por processo, então um deploy com escala horizontal passa `RequestStateSecurity(keys=[...])`. (`stateless_http=True` não tem relação: ele só afeta como os clientes da era 2025 são servidos, e o tráfego 2026 nunca o lê; se você já o definia na v1, nada muda.) + +**[Versões do protocolo](protocol-versions.md)** é o lado do cliente disso, **[Deploy e escala](run/deploy.md)** é o checklist do operador (a allowlist de Host, a chave do `request_state`, notificações entre réplicas), e **[Servindo clientes legados](run/legacy-clients.md)** é a história das duas eras ao mesmo tempo. + +### O servidor não pode chamar o cliente: requisições multi-round-trip {#the-server-cannot-call-the-client-multi-round-trip-requests} + +Toda requisição iniciada pelo servidor sumiu na 2026-07-28: elicitação por push, amostragem, `roots/list`. Em uma conexão 2026 não há canal para elas, então `ctx.elicit()` e `ctx.session.create_message()` falham ali com `NoBackChannelError` (continuam funcionando para clientes legados). + +A substituição inverte a chamada. Uma ferramenta que precisa de algo do usuário *retorna* a pergunta (`InputRequiredResult`), o cliente a responde com os mesmos callbacks que sempre teve, e a chamada é repetida com as respostas anexadas. `Client` conduz esse loop para você. No servidor você raramente monta o resultado por conta própria, porque uma **[dependência](handlers/dependencies.md)** faz isso: anote um parâmetro com `Resolve(ask_quantity)`, onde `ask_quantity` é uma função comum que você escreve, e o SDK pergunta pelo mecanismo que a conexão suportar, uma requisição de elicitação ao vivo em uma sessão legada ou um multi-round-trip na 2026. Um corpo de ferramenta, as duas eras: + +```python title="dual_era.py" hl_lines="24 37-38" +--8<-- "docs_src/legacy_clients/tutorial001.py" +``` + +Esse arquivo é a proposta inteira em um lugar só: um servidor, uma ferramenta apoiada em `Resolve`, e um cliente legado mais um cliente moderno, os dois recebendo a sua resposta, em memória. **[Requisições multi-round-trip](handlers/multi-round-trip.md)** explica o mecanismo (incluindo o `request_state`, que o SDK sela e verifica para você); **[Elicitação](handlers/elicitation.md)** cobre a parte de perguntar. + +!!! warning "Este é o único lugar em que um servidor v1 portado muda de comportamento" + Os seus próprios testes esbarram nisso primeiro: `Client(mcp)` negocia 2026-07-28 com o seu + servidor v2 por padrão, então uma ferramenta que chama `ctx.elicit()` falha em um teste que + passava na v1. Mova a pergunta para um parâmetro `Resolve(...)` (portável entre eras), ou fixe o + cliente de teste em `mode="legacy"` se você quer mesmo o comportamento de push. + +### Roots, amostragem e logging de protocolo estão obsoletos; `ping` foi removido {#roots-sampling-and-protocol-logging-are-deprecated-ping-is-removed} + +A [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) marca como obsoletas três *capacidades* inteiras, em toda versão do protocolo: roots, amostragem e logging no nível do MCP (`ctx.info()` e companhia). Esse é um eixo separado do canal de retorno (back-channel) ausente acima; obsoleto é só um aviso, tudo continua funcionando em sessões da era 2025, e nada muda no que trafega. O que você nota é o `MCPDeprecationWarning`, que é um `UserWarning`, então ele aparece por padrão; espere que o seu primeiro `ctx.info(...)` depois da atualização avise isso. + +`ping` é mais severo: removido do protocolo, não obsoleto. Dois dos métodos avulsos das funcionalidades obsoletas são removidos na 2026-07-28 do mesmo jeito, `logging/setLevel` e o `notifications/roots/list_changed` do cliente, e as notificações de progresso agora vão apenas do servidor para o cliente. + +**[Funcionalidades obsoletas](deprecated.md)** tem a tabela completa, o substituto de cada uma, e o filtro de uma linha caso você precise de um log silencioso enquanto serve clientes legados. + +### Notificações de mudança viram um stream só {#change-notifications-become-one-stream} + +Na 2026-07-28, o stream HTTP GET avulso e `resources/subscribe` são substituídos por `subscriptions/listen`: o cliente abre um stream de longa duração e informa os tipos de notificação que quer. O `MCPServer` o serve por padrão; você publica com `await ctx.notify_resource_updated(uri)` (e `notify_tools_changed()`, e assim por diante), um middleware pode recusar uma requisição de listen por chamador, e deploys com várias réplicas encaixam um `SubscriptionBus` compartilhado. No cliente, `async with client.listen(...)` abre o stream: o filtro entra como argumentos nomeados, eventos de mudança tipados voltam, e `sub.honored` é o subconjunto que o servidor concordou em entregar. + +**[Assinaturas](handlers/subscriptions.md)** cobre publicar e servir, **[a página gêmea em Clientes](client/subscriptions.md)** a ponta que observa, e **[Deploy e escala](run/deploy.md)** o barramento. + +### O resto, rapidamente {#the-rest-quickly} + +* **A identidade é um metadado opcional, por mensagem.** A chave `clientInfo` de `_meta` no lado da requisição é opcional (o par obrigatório é `protocolVersion` + `clientCapabilities`), e `serverInfo` saiu do corpo do resultado de `server/discover`: em vez disso, os servidores o carimbam no `_meta` de todo resultado da era 2026 ([especificação #3002](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3002)). O SDK sempre carimba; `client.server_info` é `None` quando um servidor não se identifica (por exemplo, um middleware removeu a chave). **[O Server de baixo nível](advanced/low-level-server.md)** mostra o carimbo no tráfego real. +* **As requisições são roteáveis sem fazer parse do corpo.** Requisições HTTP modernas carregam `Mcp-Method` (e, para as três chamadas no estilo de ferramenta, `Mcp-Name`); uma propriedade do schema de entrada de uma ferramenta anotada com `x-mcp-header` é espelhada em um cabeçalho `Mcp-Param-*` e conferida pelo servidor ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243)). Gateways e rate limiters podem rotear só pelos cabeçalhos; o **[Guia de migração](migration.md#servers-validate-mcp-param-headers-against-the-request-body-sep-2243)** tem as regras. +* **Os resultados carregam dicas de cache.** Resultados de listagem e de leitura declaram `ttlMs` e `cacheScope` ([SEP-2549](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549)); você os define por método com `cache_hints=`, e `Client` os respeita com um cache de respostas embutido. Um servidor que não envia dicas (todo servidor pré-2026) vê tráfego idêntico, sem cache. **[Dicas de cache](client/caching.md)**. +* **Extensões são de primeira classe.** Servidores e clientes declaram conjuntos opcionais de capacidades sob identificadores em DNS reverso ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)); a extensão embutida `Apps` (MCP Apps) é a referência. **[Extensões](advanced/extensions.md)** e **[MCP Apps](advanced/apps.md)**. +* **Os códigos de erro foram padronizados.** Um recurso inexistente é `-32602` com a URI em `error.data`, e os novos códigos reservados pela especificação aparecem como `-32020` (cabeçalho divergente), `-32021` (capacidade obrigatória ausente) e `-32022` (versão de protocolo não suportada). **[Solução de problemas](troubleshooting.md)** é organizada pelas mensagens exatas. +* **A autorização ficou mais difícil de usar errado.** O cliente valida o `iss` retornado com o código de autorização ([RFC 9207](https://datatracker.ietf.org/doc/html/rfc9207); o seu `callback_handler` agora retorna um `AuthorizationCodeResult`), envia `application_type` quando se registra, e nunca reutiliza credenciais em um servidor de autorização diferente. Novidade no lado corporativo: o fluxo de asserção de identidade da [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990). O **[Guia de migração](migration.md)** lista cada mudança de OAuth; **[OAuth para clientes](client/oauth-clients.md)** e **[Asserção de identidade](client/identity-assertion.md)** são as páginas. +* **Todo servidor é rastreável.** O OpenTelemetry vem ativado por padrão como middleware: toda requisição ganha um span de servidor, sem custo até o processo configurar um exportador. Quando as duas pontas rodam o SDK, o cliente também propaga o contexto de trace W3C em `_meta`, então os traces se conectam. **[OpenTelemetry](run/opentelemetry.md)**. + +## Atualizando a partir da v1? {#upgrading-from-v1} + +* O **[Guia de migração](migration.md)** é a lista completa e exata do que mudar; esta página foi o porquê. +* **A v1.x não vai a lugar nenhum.** Ela entra em manutenção, continua recebendo correções críticas e patches de segurança, e nada no lançamento da especificação 2026-07-28 a quebra; a documentação dela fica em [/v1/](https://py.sdk.modelcontextprotocol.io/v1/). Se você publica uma biblioteca que depende de `mcp` e ainda não está pronto para migrar, mantenha um limite superior (por exemplo `mcp>=1.28,<2`) para que uma resolução sem versão fixada fique na 1.x. +* Algo mal-acabado, confuso ou quebrado? **[Envie feedback da v2](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml)**; tudo é lido. diff --git a/i18n/ru/glossary.json b/i18n/ru/glossary.json new file mode 100644 index 0000000000..f2241d87c3 --- /dev/null +++ b/i18n/ru/glossary.json @@ -0,0 +1,248 @@ +{ + "keep": [ + "MCP", + "Model Context Protocol", + "MCPServer", + "FastMCP", + "ClientSession", + "Context", + "ctx", + "stdio", + "Streamable HTTP", + "SSE", + "JSON-RPC", + "JSON", + "OAuth", + "PKCE", + "JWT", + "CIMD", + "HTTP", + "HTTPS", + "TLS", + "CORS", + "URI", + "URL", + "ASGI", + "WebSocket", + "API", + "SDK", + "CLI", + "IDE", + "LLM", + "SEP", + "RFC", + "Python", + "TypeScript", + "Node.js", + "PyPI", + "Pydantic", + "Starlette", + "FastAPI", + "uvicorn", + "httpx", + "anyio", + "asyncio", + "trio", + "pytest", + "OpenTelemetry", + "Inspector", + "Claude", + "GitHub", + "VS Code", + "Windows", + "macOS", + "Linux", + "llms.txt", + "2026-07-28", + "2025-11-25", + "2025-06-18", + "2025-03-26" + ], + "terms": [ + { + "source": "tool", + "target": "инструмент", + "note": "MCP protocol noun (a server exposes tools): инструмент / инструменты. Standard rendering. Wire identifiers such as `tools/call` and the `@mcp.tool()` decorator are code and stay Latin; the Inspector's **Tools** tab is a UI label and stays English." + }, + { + "source": "resource", + "target": "ресурс", + "note": "MCP protocol noun (data a server exposes for reading), and also the general noun (a pool acquired in a lifespan is still ресурс). Standard rendering. `resources/read` and `@mcp.resource()` are code." + }, + { + "source": "prompt", + "target": "промпт", + "note": "The MCP feature (a reusable message template a server exposes) and the everyday LLM sense; the loanword промпт (masculine, declined: промпта, промпты) is what Russian AI writing uses. Not подсказка (a hint or tooltip) and not приглашение (a command-line prompt), which are other senses. `prompts/get` and `@mcp.prompt()` are code. Provisional pending native review." + }, + { + "source": "sampling", + "target": "сэмплирование", + "note": "The (deprecated) client feature that lets a server borrow the client's model for a completion. Gloss the English on first use per page — сэмплирование (sampling) — so the reader maps it to `sampling/createMessage`, which is code. Not выборка (a statistical sample, the wrong sense). Spelling сэмплирование, not семплирование, provisional pending native review." + }, + { + "source": "roots", + "target": "корневые каталоги", + "note": "The (deprecated) client feature listing the workspace directories a client exposes. Descriptive rendering with the English glossed on first use per page — корневые каталоги (roots); a single root is корневой каталог. `roots/list` and the `Root` type are code and stay Latin. Provisional pending native review; keeping roots in Latin script is the open alternative." + }, + { + "source": "elicitation", + "target": "элицитация", + "note": "The mechanism by which a server asks the user a question through the client mid-request. There is no settled Russian term; pinned to элицитация (feminine, declinable; the word exists in Russian linguistics for eliciting data from a person), glossed on first use per page — элицитация (elicitation). Do not alternate with запрос ввода or уточнение on the same page. `elicitation/create`, `ctx.elicit()` and the `Elicit` class stay Latin. Provisional pending native review." + }, + { + "source": "capability", + "target": "возможность", + "note": "A negotiated protocol capability (what a client or server declared it supports): возможности сервера, согласование возможностей. Not способность. The `capabilities` field and keys such as `sampling.tools` stay Latin. Provisional pending native review." + }, + { + "source": "transport", + "target": "транспорт", + "note": "The connection mechanism (\"every standard transport\" → все стандартные транспорты; транспортный уровень for \"transport layer\"). Standard networking usage. The transport names stdio, Streamable HTTP and SSE stay in English: транспорт stdio, stdio-транспорт." + }, + { + "source": "session", + "target": "сессия", + "note": "An MCP session (the negotiated connection state): сессия, идентификатор сессии. Pinned over сеанс for consistency with prevailing developer usage; do not alternate the two. `session` objects, `ClientSession` and `ServerSession` are code. Provisional pending native review." + }, + { + "source": "handler", + "target": "обработчик", + "note": "The tool, resource or prompt function you register, and request handlers generally (nav section \"Inside your handler\" → Внутри обработчика). Standard term; never the slang хендлер / хэндлер." + }, + { + "source": "dependency", + "target": "зависимость", + "note": "Both package dependencies and the SDK's parameter-injection feature (the \"Dependencies\" page → Зависимости; \"dependency injection\" → внедрение зависимостей). Standard rendering. The `Resolve` marker class stays Latin." + }, + { + "source": "resolver", + "target": "резолвер", + "note": "The plain function attached to a parameter with `Resolve(...)` that computes or asks for its value: резолвер (masculine, declinable), or функция-резолвер where the kind needs naming. Provisional pending native review; функция разрешения is the descriptive alternative. The `Resolve` class stays Latin." + }, + { + "source": "client", + "target": "клиент", + "note": "An MCP client, and the client side of a connection. Standard rendering; grammatically inanimate when it is a program (запустить клиент). The `Client` class and the `mcp.client` module are code and stay Latin." + }, + { + "source": "server", + "target": "сервер", + "note": "An MCP server (the program you build): сервер, MCP-сервер. Standard rendering. The `MCPServer`, `Server` and `ServerSession` classes are code and stay Latin." + }, + { + "source": "host", + "target": "хост", + "note": "The MCP host — the application the user talks to (Claude Desktop, an IDE, an agent runtime) — and also a network host; хост in both senses. Standard loanword; never хозяин." + }, + { + "source": "context", + "target": "контекст", + "note": "The generic lower-case word (\"provide context to LLMs\" → предоставлять контекст LLM). The capitalised `Context` is the SDK object injected as `ctx`; it is on the keep list and stays Latin in prose (\"The Context\" heading → Объект Context). Standard rendering." + }, + { + "source": "request", + "target": "запрос", + "note": "A JSON-RPC or HTTP request (\"the initialize request\" → запрос initialize / запрос инициализации; HTTP-запрос). Standard term, never реквест. `Request` types in code font stay Latin." + }, + { + "source": "response", + "target": "ответ", + "note": "A JSON-RPC or HTTP response (HTTP-ответ, тело ответа). Standard term, never респонс. `Response` types in code font stay Latin." + }, + { + "source": "notification", + "target": "уведомление", + "note": "A JSON-RPC notification (a message that expects no response): отправить уведомление, уведомление о ходе выполнения. Standard term, never нотификация. Method strings such as `notifications/tools/list_changed` stay Latin." + }, + { + "source": "callback", + "target": "колбэк", + "note": "Client callbacks and OAuth redirect callbacks alike (the \"Callbacks\" page → Колбэки): колбэк, masculine, declinable, spelled this way (not коллбек, колбек, каллбэк). Provisional pending native review; функция обратного вызова is the formal alternative and may serve as a one-time gloss. Parameter names such as `sampling_callback` stay Latin." + }, + { + "source": "decorator", + "target": "декоратор", + "note": "The Python decorators the SDK is built on; `@mcp.tool()` and its siblings are code and stay untouched. Standard rendering." + }, + { + "source": "type hint", + "target": "аннотация типов", + "note": "Python type hints (\"from your type hints\" → по аннотациям типов). Pinned over подсказки типов; use one rendering throughout. Provisional pending native review." + }, + { + "source": "exception", + "target": "исключение", + "note": "A raised Python exception; \"raises an exception\" → выбрасывает исключение (or генерирует исключение), not возбуждает. Exception class names stay Latin. Provisional pending native review." + }, + { + "source": "async", + "target": "асинхронный", + "note": "The prose adjective (\"the async runtime\" → асинхронная среда выполнения, \"an async callback\" → асинхронный колбэк); the `async` and `await` keywords in code font stay Latin. Standard rendering." + }, + { + "source": "lifespan", + "target": "жизненный цикл", + "note": "The server's startup/shutdown scope (the \"Lifespan\" page, as in the ASGI lifespan) → жизненный цикл, glossed on first use per page — жизненный цикл (lifespan) — so the reader maps it to the `lifespan=` parameter, which is code and stays Latin, as does the function passed to it when named in code font. Not срок жизни or продолжительность жизни. Provisional pending native review; keeping lifespan in Latin script throughout is the open alternative." + }, + { + "source": "back-channel", + "target": "обратный канал", + "note": "This documentation's term for the server calling back into the client during a request, which exists only on legacy connections. Gloss the English on first use per page — обратный канал (back-channel) — so the reader can connect it to `NoBackChannelError`, which is code. Provisional pending native review." + }, + { + "source": "multi-round-trip", + "target": "многораундовый", + "note": "The 2026-07-28 request pattern: \"Multi-round-trip requests\" → Многораундовые запросы, glossed on first use per page — многораундовые запросы (multi-round-trip). A single \"round trip\" is раунд обмена or один цикл «запрос — ответ» by context, never a literal поездка туда и обратно. The abbreviation MRTR stays Latin. Provisional coinage pending native review; многоходовые запросы is the alternative to weigh." + }, + { + "source": "deprecated", + "target": "устаревший", + "note": "Advisory status: still works, scheduled for removal later — устаревший / объявлен устаревшим (\"Deprecated features\" → Устаревшие возможности; \"deprecation warning\" → предупреждение об устаревании). \"Removed\" is a different word (удалён); the corpus contrasts the two. The `MCPDeprecationWarning` class stays Latin. Provisional pending native review." + }, + { + "source": "legacy", + "target": "старого поколения", + "note": "\"A legacy connection / client\" = one negotiated at spec version 2025-11-25 or earlier → подключение старого поколения, клиент старого поколения (the page \"Serving legacy clients\" → Обслуживание клиентов старого поколения). Pairs with \"era\" → поколение and keeps устаревший free for \"deprecated\". Never the slang легаси. Provisional pending native review; прежних версий is the alternative." + }, + { + "source": "era", + "target": "поколение", + "note": "\"Protocol era\" (\"a 2025-era client\", \"whatever era the client speaks\") → поколение протокола, клиент поколения 2025. Not the literal эра or эпоха. Provisional pending native review." + }, + { + "source": "handshake", + "target": "рукопожатие", + "note": "The initialization handshake (\"the classic handshake\" → классическое рукопожатие). рукопожатие is the standard Russian networking term (as in TLS-рукопожатие), so the literal word is correct here. Standard rendering." + }, + { + "source": "middleware", + "target": "middleware", + "note": "Kept in Latin script, indeclinable, lower-case in running text (the \"Middleware\" page title stays Middleware); name the kind where a case is needed: слой middleware, функция middleware. May take the one-time gloss middleware (промежуточный слой). Provisional pending native review; промежуточное ПО is the formal alternative." + }, + { + "source": "authorization", + "target": "авторизация", + "note": "Security sense: авторизация (сервер авторизации, код авторизации), distinct from authentication → аутентификация. The `Authorization` header and code identifiers stay Latin. Standard rendering." + }, + { + "source": "Get started", + "target": "Начало работы", + "note": "The nav section that opens the guide, and the title of its index page. \"First steps\" is a separate page inside that section (Первые шаги), so the two need distinct renderings or the sidebar shows the same title twice. Provisional pending native review." + }, + { + "source": "First steps", + "target": "Первые шаги", + "note": "The tutorial page inside the \"Get started\" section; never reuse this rendering for the section itself (see that entry). Provisional pending native review." + }, + { + "source": "Recap", + "target": "Итоги", + "note": "Recurring section heading that closes most pages; one rendering everywhere, not Итоги on some pages and Резюме or Подведём итоги on others. Provisional pending native review." + }, + { + "source": "Try it", + "target": "Попробуйте сами", + "note": "Recurring section heading above a runnable example; one rendering everywhere, not Попробуйте on some pages and Проверка on others. Provisional pending native review." + } + ] +} diff --git a/i18n/ru/instructions.md b/i18n/ru/instructions.md new file mode 100644 index 0000000000..8632136267 --- /dev/null +++ b/i18n/ru/instructions.md @@ -0,0 +1,170 @@ +# Russian (ru) — translation instructions + +Target language: Russian (русский язык), directory and URL code `ru`, page +language tag `ru`. This file is sent verbatim with every translation request +for this language, on top of the shared rules in `../general-prompt.md`. The +termbase in `glossary.json` is sent alongside it and wins any terminology +conflict with this file. + +## 1. Register + +Write the neutral, literate register of good Russian developer documentation: +closer to a well-edited technical book than to an official notice or a chat. + +- The reader is «вы», always lowercase mid-sentence: вы, вас, вам, ваш. + Capitalised Вы / Ваш is for a personal letter to one person and is wrong + here. Never ты, never a mix. +- Reach for the pronoun rarely. Russian technical prose prefers constructions + that need no subject: "You can pass a schema" → Можно передать схему; "If + you need the raw result" → Если нужен сам результат; "You get a + `CallToolResult`" → Возвращается `CallToolResult`. Three вы in one paragraph + is a signal to rephrase. Never replace "you" with пользователь — the user is + the person talking to the host, not the reader. +- Steps and instructions are plain imperatives in the вы form: "Install the + SDK, then run the server" → Установите SDK и запустите сервер. A purpose + clause is the other natural shape: "To run it: …" → Чтобы запустить: …. Not + Вам необходимо установить, not Следует произвести установку. +- Headings, table headers and content-tab labels are noun phrases in sentence + case with no final punctuation: "Running your server" → Запуск сервера, + "Handling errors" → Обработка ошибок, "Inside your handler" → Внутри + обработчика. "How to …" becomes Как + infinitive; a heading the English + phrases as a question may stay a question. +- The authorial "we" is fine where the English has it (Рекомендуем …), but no + мы с вами or давайте. One page, one register: a page that drifts between + imperatives and officialese, or between вы and Вы, is wrong even when each + sentence is acceptable on its own. + +## 2. Voice + +The English is warm, direct and confident: short sentences, second person, the +occasional one-line payoff ("That's the whole API."). Carry that into living +Russian — neither wooden nor familiar. + +- Use concrete verbs and let them carry the sentence: запустить, передать, + вернуть, объявить, заблокировать. Prefer the active voice: "The tool is + called by the model" → Модель вызывает инструмент, not Инструмент вызывается + моделью. Keep the payoff lines short: "That's a complete MCP server." → Это + уже готовый MCP-сервер. +- Split long English sentences and follow Russian word order; never merge, + drop or reorder the technical claims themselves. +- Avoid канцелярит, the bureaucratic register technical translation slides + into by default: данный → этот; является → есть, a dash, or nothing (Хост — + это приложение); осуществлять / производить / выполнять + noun → the verb + itself (осуществляет отправку → отправляет); в целях → чтобы; посредством → + с помощью, через; в случае если → если; функционал → возможности; and no + chains of verbal nouns (для обеспечения возможности выполнения запуска → + чтобы запустить). +- No hedging the English does not have ("don't" is не используйте, not + возможно, стоит воздержаться) — and no over-correction either: no ты, no + slang (юзать, тулза, дефолтный, задеплоить), no smileys. + +Example — English: "You don't construct it and you don't configure it. You ask +for it." + +- Not this (канцелярит): Пользователю не требуется осуществлять его создание и + конфигурирование. Необходимо лишь выполнить соответствующий запрос. +- Not this either (familiar): Ты его не создаёшь и не настраиваешь. Просто + просишь. +- This: Его не нужно ни создавать, ни настраивать. Достаточно попросить. + +## 3. Humour and idioms + +- Translate the intent of a joke, aside or idiom, never its words. Recast it + as a short, natural Russian sentence in the same register; if a light phrase + carries no information at all, keep the sentence brief rather than inventing + a Russian joke. Never drop the technical content around it. +- Recurring English tags get fixed renderings: "**[X](…)** has the whole + story" / "The whole story is in **[X](…)**" → Подробнее — на странице + **[X](…)**.; "That's the whole API." / "That's the whole protocol." → Вот и + весь API. / Вот и весь протокол.; "That's it. It's just Python." → Вот и + всё. Это обычный Python.; "You get `3` back. ✨" → В ответ приходит `3`. ✨ +- Idioms take the plain meaning, not the picture: "Out of the box the app + answers **only** requests addressed to localhost." → По умолчанию приложение + отвечает **только** на запросы, адресованные localhost. — not из коробки; + "under the hood" → внутри, not под капотом; "on the wire" → в передаваемых + данных / по сети, never по проводу. Culture-bound references (sports, TV, + holidays) → the plain meaning. +- Keep an exclamation mark only where the English is a genuine exclamation of + encouragement — never after a warning or a step, never doubled, never in a + heading. Reproduce an emoji only where the English has one, in the same + place (two payoff lines end in ✨); never add one. + +## 4. Typography + +- Quotation marks in Russian prose are «ёлочки»; a quote nested inside them + takes „лапки“. Straight quotes inside code spans, code blocks, commands and + URLs stay untouched. When the English quotes a word the example code prints + or a UI label, the text inside stays exactly as emitted and only the marks + change: вкладка «Tools», кнопка «Connect». +- Use ё wherever it belongs, consistently: ещё, её, всё, объём, передаёт, + вернётся, трёх. A page that writes все for всё is wrong. +- Dashes: the grammatical dash is an em dash with a space on each side (Хост — + это приложение, с которым говорит пользователь); a hyphen only joins + compounds (MCP-сервер, HTTP-запрос); numeric ranges use an en dash without + spaces (3.10–3.14) or от 3.10 до 3.14. Never a hyphen where a dash is meant. + An English em-dash aside may also become a comma pair, parentheses or its + own sentence. +- Sentence case everywhere: headings, admonition titles, tab labels and table + headers capitalise the first word and proper nouns only. No capital after a + colon. Language names, weekdays and months are lowercase (на английском, в + июле). +- Digits stay ASCII. Protocol revision strings such as `2026-07-28` and + `2025-11-25` are identifiers, copied byte for byte — never 28.07.2026, never + 28 июля 2026 г. Version numbers, ports, HTTP status codes, error codes, RFC + and SEP numbers are copied exactly. +- Prose quantities take the decimal comma only when nothing but the separator + changes (2.5 seconds → 2,5 секунды); when in doubt keep the number as + written. A space separates a number from its unit (100 МБ, 30 секунд, 5 с); + % attaches with no space (100%). Numerals govern the noun the Russian way: + 1 инструмент, 3 инструмента, 5 инструментов. +- e.g. → например; i.e. → то есть; etc. → и т. д.; "&" → и. Emphasis lands + on the same words the source emphasises, and a bolded negation ("**not**" → + **не**) stays bold. Loanwords and Latin-script names are set in plain type — + no italics, no quotes around them. Keep the source's colons and parentheses; + a colon before a list or code block is natural Russian too. + +## 5. Terminology pointer + +The glossary (`glossary.json`) is injected separately and overrides this file +on every term it covers; each entry says whether its choice is standard or +provisional and whether it takes a first-use gloss. These conventions are what +its renderings assume: + +- Identifiers stay in Latin script exactly as written: class, function, + method, parameter, module, environment-variable and header names, protocol + method strings such as `tools/call`, and everything in code font. So do the + keep-list terms, acronyms and product and protocol names, always without + the English plural "s": "the SDKs" → SDK or пакеты SDK. +- Never decline a Latin-script word with an apostrophe or a glued ending + (API'шка, SDK-а, в `Client`'е). Let a Russian word carry the case instead: a + hyphenated head noun (MCP-сервер, MCP-клиент, HTTP-запрос, JSON-объект, + ASGI-приложение, OAuth-токен) or the kind of thing in front of code (класс + `Context`, параметр `lifespan=`, метод `client.call_tool()`, команда + `uv run`, заголовок `Mcp-Method`). Adjectives and verbs agree with that + Russian word. +- Programs and components are grammatically inanimate: запустить клиент, + подключить хост (not клиента in the accusative). Provisional; apply uniformly. +- Where an established Russian term exists, use it, not the anglicism: + обработчик (not хендлер), запрос / ответ (not реквест / респонс), + уведомление (not нотификация), исключение (not эксепшен), экземпляр (not + инстанс), по умолчанию (not дефолтный), развёртывание (not деплой), среда + выполнения (not рантайм). Settled loanwords stay: сервер, клиент, хост, + токен, сессия, схема, декоратор, промпт, репозиторий, фреймворк, плагин, лог. +- Text quoted from what the example code prints or displays — an output line, + a log message, an Inspector tab or button label — stays exactly as the code + emits it (usually English), in or out of code font; never translate it. +- First-use gloss: a term the glossary marks for it carries the English in + parentheses on its first appearance in a page — элицитация (elicitation), + корневые каталоги (roots) — and appears alone after that. A glossary word + used as a wire identifier or a key in code font is code and stays Latin: + "the `sampling` capability" → возможность `sampling`. +- One rendering per term per page: the glossary target, every time, even + where its note marks the choice as provisional. + +## 6. Provisional note + +Every decision in this file, and every entry in `glossary.json`, is +provisional pending review by native Russian-speaking developers. To propose a +change, edit this file or `glossary.json` in a pull request, ideally with a +short good/bad example; never edit the generated pages under `pages/` or +`notices.md` next to this file, which the next translation run overwrites. diff --git a/i18n/ru/notices.md b/i18n/ru/notices.md new file mode 100644 index 0000000000..cbb7b98028 --- /dev/null +++ b/i18n/ru/notices.md @@ -0,0 +1,20 @@ +--- +translation: + sections: [aff1b3e872b7876a, 4d80558ad052d586, 0bb81f1e62062d26, d5c35dcec50156bc] + tool: 1 +--- +# Уведомления о переводе {#translation-notices} + +Одна из этих заметок показывается в начале каждой страницы переведённого сайта документации. + +## Машинный перевод {#translated} + +Эта страница переведена с английской документации автоматически, и основной версией остаётся [английская страница](ENGLISH_PAGE). Если что-то читается неправильно, на странице [Переводы](TRANSLATIONS_PAGE) объясняется, как об этом сообщить. + +## Перевод отстаёт от английской страницы {#outdated} + +Английская страница изменилась после того, как был сделан этот перевод, поэтому отдельные его части могли устареть. Если сомневаетесь, читайте [английскую страницу](ENGLISH_PAGE); на странице [Переводы](TRANSLATIONS_PAGE) объясняется, как устроена переведённая документация. + +## Показано на английском {#english} + +Актуального перевода этой страницы нет, поэтому вы читаете её на английском. На странице [Переводы](TRANSLATIONS_PAGE) объясняется, как устроена переведённая документация. diff --git a/i18n/ru/pages/advanced/apps.md b/i18n/ru/pages/advanced/apps.md new file mode 100644 index 0000000000..c4de3c2d01 --- /dev/null +++ b/i18n/ru/pages/advanced/apps.md @@ -0,0 +1,123 @@ +--- +translation: + sections: [0355618e5f4d5fe4, 1821eaf50f2d0b64, 82e0b28ebd3abf5a, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487] + tool: 1 +--- +# MCP Apps {#mcp-apps} + +**MCP App** — это инструмент с собственным лицом: помимо данных, инструмент указывает на HTML-документ, который хост отображает как интерактивную поверхность. + +Две части, всегда две: + +1. **Инструмент**, который делает работу и возвращает данные, как любой другой инструмент. +2. **Ресурс `ui://`** с HTML, который хост показывает для этого инструмента. + +Инструмент несёт ссылку на ресурс в `_meta.ui.resourceUri`. Хост получает его через `resources/read`, отображает в **изолированном iframe** (песочнице) и передаёт результат инструмента в этот iframe через `postMessage`. Ваш сервер никогда не отправляет и не принимает сообщений `ui/*`: этот трафик идёт между хостом и iframe. Вы отдаёте инструмент и HTML-документ, а всё представление устраивает хост. + +В SDK это встроенное расширение `Apps` (`io.modelcontextprotocol/ui`). Если [расширения](extensions.md) вам в новинку, сначала пробегите ту страницу. Одна минута — и возвращайтесь. + +## Часы с циферблатом {#a-clock-with-a-face} + +```python title="server.py" hl_lines="19 22 30 32" +--8<-- "docs_src/apps/tutorial001.py" +``` + +Четыре шага: + +* `Apps()`: один экземпляр хранит инструменты, привязанные к UI, и их ресурсы. +* `@apps.tool(resource_uri="ui://clock/app.html")`: обычный инструмент плюс отметка `_meta.ui.resourceUri`. Всё, что принимает `@mcp.tool()` (name, title, description, ...), передаётся дальше. +* `apps.add_html_resource("ui://clock/app.html", CLOCK_HTML)`: парный ресурс, который отдаётся как `text/html;profile=mcp-app`. Именно этот MIME-тип говорит хосту: «это приложение, отобрази его». +* `MCPServer("clock", extensions=[apps])`: подключение расширения. Теперь сервер объявляет `io.modelcontextprotocol/ui` в `capabilities.extensions`. + +Сам HTML слушает `postMessage` от хоста и показывает результат. В настоящих приложениях используйте внутри HTML официальный браузерный SDK [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps). Он даёт `ontoolresult`, `callServerTool`, `getHostContext` и `onhostcontextchanged` вместо сырых событий сообщений. + +## Корректная деградация {#graceful-degradation} + +Не каждый клиент отображает приложения. Спецификация прямо говорит, что это значит для вас: + +> Инструменты **ДОЛЖНЫ** возвращать осмысленный массив `content`, даже когда UI доступен. + +Модель читает `content`; iframe — для людей. Хост с поддержкой UI всё равно передаёт текстовый результат модели, а чисто текстовый клиент получает *только* его. Поэтому канонический паттерн — один инструмент, два ответа. Взгляните на `get_time` ещё раз: + +```python title="server.py" hl_lines="23-27" +--8<-- "docs_src/apps/tutorial001.py" +``` + +`client_supports_apps(ctx)` возвращает `True`, только когда клиент объявил расширение `io.modelcontextprotocol/ui` **и** указал `text/html;profile=mcp-app` в настройке `mimeTypes`. Поле обязательное, так что клиент, который его опустил, не считается. Именно это объявляет `main()` в том же файле: клиентскую половину согласования — и в ответ приходит развёрнутый результат. + +!!! warning + Никогда не возвращайте заглушку вроде `"[Rendered UI]"` в качестве единственного содержимого. Если запасной текст бесполезен, инструмент бесполезен для любого текстового клиента и для самой модели. Напишите нормальное предложение. + +## Ограничение iframe {#locking-the-iframe-down} + +Метаданные безопасности несёт ресурс: что iframe может загружать, какие разрешения браузера ему нужны, как его хотелось бы встроить: + +```python title="server.py" hl_lines="9 19-22" +--8<-- "docs_src/apps/tutorial002.py" +``` + +`csp` и `permissions` — это **просьбы к хосту**, а не поведение сервера. Хост строит по ним Content-Security-Policy и Permissions-Policy для iframe и может отказать. Проверяйте наличие возможности в своём JS, а не рассчитывайте, что разрешение выдано. + +`ResourceCsp`, поле за полем (имя в Python, ключ в передаваемых данных, что с ним делает хост): + +| Python | В передаваемых данных (`_meta.ui.csp`) | Что контролирует | +|---|---|---| +| `connect_domains` | `connectDomains` | `connect-src`: куда могут обращаться `fetch`/XHR | +| `resource_domains` | `resourceDomains` | `img-src`, `style-src`, ...: статические ресурсы | +| `frame_domains` | `frameDomains` | `frame-src`: вложенные iframe | +| `base_uri_domains` | `baseUriDomains` | `base-uri`: на что может указывать `` | + +`ResourcePermissions`: каждое поле запрашивает для iframe разрешение браузера. + +| Python | В передаваемых данных (`_meta.ui.permissions`) | +|---|---| +| `camera` | `camera` | +| `microphone` | `microphone` | +| `geolocation` | `geolocation` | +| `clipboard_write` | `clipboardWrite` | + +!!! note + CSP и разрешения живут на **ресурсе**, никогда не на инструменте. В метаданных инструмента по спецификации для них нет места, и хосты их там игнорируют. SDK делает эту ошибку невозможной в принципе: у `@apps.tool()` просто нет параметра `csp`. + +### Видимость {#visibility} + +`visibility=["app"]` на инструменте говорит: «это существует для iframe, а не для модели»: + +* `"model"`: модель может его вызывать. +* `"app"`: iframe может его вызывать (через `callServerTool`). +* Не указано: и то и другое, это значение по умолчанию. + +Фильтрация — задача **хоста**. Сервер перечисляет инструменты только для приложения в `tools/list`, как и любые другие; хост скрывает их от модели. Не фильтруйте на стороне сервера. + +## Правила, за которыми следит SDK {#the-rules-the-sdk-enforces} + +Всё это падает при запуске, а не в рабочей среде: + +* `resource_uri` или URI ресурса, не начинающийся с `ui://...`, — это `ValueError` в момент декорирования или регистрации. +* Инструмент, привязанный к URI **без соответствующего зарегистрированного ресурса**, — это `ValueError`, когда `MCPServer(extensions=[apps])` обрабатывает расширение. Инструмент, объявляющий HTML, который отдаёт 404 на `resources/read`, — это ошибка конфигурации, поэтому сервер отказывается создаваться. +* `meta={"ui": ...}` на `@apps.tool()` — это `ValueError`. Ключом `_meta["ui"]` владеет декоратор; выражайте это через `resource_uri=` и `visibility=`. Остальные ключи `meta=` спокойно объединяются рядом. + +Ни TypeScript SDK ext-apps, ни FastMCP сегодня ничего из этого не ловят; лучше узнать об этом раньше, чем узнает хост. + +## Не только встроенный HTML {#beyond-inline-html} + +`add_html_resource` покрывает типичный случай: строку HTML. Для всего остального — HTML на диске или генерируемого содержимого — постройте ресурс сами и передайте его: + +```python title="server.py" hl_lines="12 18" +--8<-- "docs_src/apps/tutorial003.py" +``` + +`add_resource` подставляет MIME-тип `text/html;profile=mcp-app`, когда ресурс не задаёт его явно, и отклоняет явное несоответствие: ресурс `ui://` с любым другим MIME-типом не отобразит ни один хост. + +!!! tip + Ориентируетесь на хост, выпущенный до GA, который всё ещё читает устаревший плоский ключ `_meta["ui/resourceUri"]`? Добавьте его сами: + `@apps.tool(resource_uri="ui://x", meta={"ui/resourceUri": "ui://x"})`. + Вложенный объект `ui` — это форма по спецификации; плоский ключ доживает последние дни. + +## Запуск примера {#see-it-run} + +Сценарий `apps` в `examples/stories/` — это эта страница в виде готовой к запуску пары: сервер с привязанным к UI инструментом-часами и клиент, который согласовывает Apps, читает `_meta.ui.resourceUri` инструмента, получает HTML и вызывает инструмент. + +```bash +uv run python -m stories.apps.client +``` diff --git a/i18n/ru/pages/advanced/extensions.md b/i18n/ru/pages/advanced/extensions.md new file mode 100644 index 0000000000..a2d233679c --- /dev/null +++ b/i18n/ru/pages/advanced/extensions.md @@ -0,0 +1,172 @@ +--- +translation: + sections: [05891e7cc1938a13, b3c01a6af28c51ee, 7ffc91f5e38bdfe0, 717d3f235a8333a7, f471a13b2fe5d737, ed6af2df4b656dff] + tool: 1 +--- +# Расширения {#extensions} + +**Расширение** — это набор поведения MCP, который включается только по желанию и объединён одним идентификатором. + +На сервере оно может добавлять инструменты, ресурсы и новые методы запросов, а также оборачивать `tools/call`. На клиенте — заявлять дополнительные формы результата `tools/call` и наблюдать за вендорными уведомлениями. Каждая сторона объявляет его в собственном `capabilities.extensions`, и для тех, кто об этом не просил, ничего не меняется. Таков контракт ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)), и у него одно золотое правило: **по умолчанию расширения выключены**. + +## Использование расширения {#using-an-extension} + +Передайте экземпляры при создании: + +```python title="server.py" +--8<-- "docs_src/extensions/tutorial001.py" +``` + +Готово. Теперь сервер объявляет `io.modelcontextprotocol/ui` в `capabilities.extensions` и обслуживает всё, что добавляет расширение. + +`Apps` — встроенное эталонное расширение, и ему посвящена отдельная страница: **[MCP Apps](apps.md)**. + +!!! note + Расширения фиксируются при создании. Метода `add_extension`, который можно вызвать позже, нет: карта возможностей сервера не должна меняться, пока к нему подключены клиенты. + +Карта возможностей передаётся через `server/discover`, а это путь версии **2026-07-28**. В рукопожатии `initialize` старого поколения для неё просто нет места, поэтому клиент старого поколения расширения не видит. Учитывайте это при проектировании: расширение *дополняет* сервер и не должно быть единственным способом им пользоваться. + +## Написание собственного расширения {#writing-your-own} + +Унаследуйтесь от `Extension` и переопределите только то, что нужно. У каждого метода есть реализация по умолчанию. + +### Идентификатор {#the-identifier} + +```python +--8<-- "docs_src/extensions/tutorial002.py" +``` + +Идентификатор — это строка вида `vendor-prefix/name`, подчиняющаяся грамматике ключей `_meta` из спецификации: метки, разделённые точками (каждая начинается с буквы и заканчивается буквой или цифрой), косая черта, затем имя. Он проверяется **в момент определения класса**, так что опечатка не ждёт запуска сервера: + +```text +TypeError: Stamps.identifier must be a `vendor-prefix/name` string +(reverse-DNS prefix required), got 'stamps' +``` + +В качестве префикса используйте домен, которым вы управляете. `io.modelcontextprotocol/*` отведён для расширений, описанных самим проектом MCP. + +### Добавление инструментов {#contributing-tools} + +Самое маленькое полезное расширение — один инструмент и карта настроек: + +```python title="server.py" hl_lines="17 19-20 22-23 26" +--8<-- "docs_src/extensions/tutorial003.py" +``` + +* `tools()` возвращает объекты `ToolBinding`. Сервер регистрирует каждый из них ровно так же, как если бы вы сами вызвали `mcp.add_tool(...)`: та же генерация схемы, то же внедрение `Context`, всё то же самое. +* `settings()` — значение, объявляемое в `capabilities.extensions["com.example/stamps"]`. Верните `{}` (значение по умолчанию), чтобы объявить расширение без настроек. +* Расширение никогда не получает сервер. Оно описывает свой вклад как данные; `MCPServer` их потребляет. Никакого `self.server`, который можно было бы менять, нет. + +А `main()` служит доказательством: клиент в памяти, подключённый напрямую к `mcp`: + +```python title="server.py" hl_lines="29-34" +--8<-- "docs_src/extensions/tutorial003.py" +``` + +### Обслуживание собственных методов {#serving-your-own-methods} + +Расширение может регистрировать **новые методы запросов** — собственные глаголы, обслуживаемые рядом с методами спецификации: + +```python title="server.py" hl_lines="16-22 31 40-48" +--8<-- "docs_src/extensions/tutorial004.py" +``` + +* `SearchParams` наследуется от `RequestParams`, поэтому конверт `_meta` версии 2026 разбирается единообразно, а обработчик получает провалидированные параметры, а не сырой словарь. Ограничивайте то, чем управляет клиент: `Field(ge=1, le=100)` отклонит абсурдный `limit` раньше, чем ваш код что-либо под него выделит. +* `require_client_extension(ctx, EXTENSION_ID)` — это пропускной пункт: клиент, не объявивший расширение, получает ошибку `-32021` (отсутствует обязательная возможность клиента) с машиночитаемой полезной нагрузкой `requiredCapabilities`, которую требует спецификация. +* `protocol_versions=frozenset({"2026-07-28"})` привязывает метод к одной версии протокола. На любой другой версии клиент получает `METHOD_NOT_FOUND` — ровно так, как если бы метода там не существовало. Для этого клиента его и нет. + +Методы **строго аддитивны**. SDK проверяет это при создании, а не во время выполнения: + +* `MethodBinding` для метода, определённого спецификацией (`tools/list`, `completion/complete`, ...), выбрасывает `ValueError` при создании привязки. Базовые глаголы принадлежат серверу. +* Если два расширения привязывают один и тот же метод, исключение выбрасывается при регистрации второго. Принцип «побеждает последняя запись» — это то, как плагины портят друг друга; мы так не делаем. +* Пустое множество `protocol_versions` тоже приводит к исключению: метод, который никогда нельзя обслужить, — это ошибка, а не конфигурация. + +### Клиентская сторона {#the-client-side} + +`main()` из того же файла — это вся клиентская часть, обе её половины: + +```python title="server.py" hl_lines="54-58" +--8<-- "docs_src/extensions/tutorial004.py" +``` + +* `Client(..., extensions=[advertise(EXTENSION_ID)])` объявляет расширение. Объявления превращаются в `ClientCapabilities.extensions`: на подключении версии 2026-07-28 карта передаётся в конверте `_meta` каждого запроса, так что сервер видит её в **каждом** запросе; на подключении старого поколения она едет в рукопожатии `initialize`. Серверному коду всё равно, какой из вариантов: `require_client_extension(ctx, ...)` и `ctx.session.check_client_capability(...)` читают нужный источник на обоих путях. +* Вендорные методы спускаются на уровень ниже, к `client.session.send_request(...)`; `Client` обзаводится полноценными методами только для глаголов спецификации. `send_request` принимает любой подкласс `Request`, так что вендорный запрос проходит как есть. + +### Перехват `tools/call` {#intercepting-toolscall} + +Единственный перехватывающий хук. Переопределите `intercept_tool_call`, чтобы наблюдать за вызовом инструмента, завершать его досрочно или запрещать: + +```python title="server.py" hl_lines="17-24" +--8<-- "docs_src/extensions/tutorial005.py" +``` + +* `params` — провалидированный `CallToolRequestParams`: `params.name` и `params.arguments` доступны без работы с сырым JSON. Он же определяет, какой вызов инструмента выполняется: передача переписанного контекста через `call_next` меняет то, что обработчик видит в `ctx`, но не сам вызов инструмента. Переписывание запросов на уровне протокола — задача [Middleware](middleware.md). +* `call_next(ctx)` выполняет остаток цепочки и возвращает результат обработчика. Верните его без изменений (наблюдение), верните что-то другое (замена) или выбросьте `MCPError` (отказ). Всё, что вы вернёте, сериализуется как любой результат обработчика, включая штамп идентичности `serverInfo` поколения 2026, так что перехватчик, завершающий вызов досрочно, никогда не выдаёт анонимный или не соответствующий схеме ответ. +* При нескольких расширениях перехватчики вкладываются друг в друга в порядке регистрации: первое расширение в `extensions=[...]` — самое внешнее. +* Реализация по умолчанию просто пропускает вызов дальше, и сервер, расширения которого не переопределяют этот хук, сохраняет голый обработчик `tools/call` нетронутым. За то, чем не пользуетесь, платить не приходится. + +Хук оборачивает `tools/call` и ничего больше. Для задач, касающихся каждого сообщения, используйте [Middleware](middleware.md). Оно для этого и предназначено. + +## Использование клиентского расширения {#using-a-client-extension} + +**Клиентское расширение** — тот же контракт со стороны потребителя: набор клиентского поведения за одним идентификатором. Передайте экземпляры в `Client(extensions=[...])` и вызывайте инструменты как обычно: + +```python title="client.py" hl_lines="66-68" +--8<-- "docs_src/extensions/tutorial006.py" +``` + +`call_tool("buy", ...)` возвращает обычный `CallToolResult`, как и любой другой вызов. Что изменило расширение: теперь сервер может ответить на `buy` **формой результата** `receipt` вместо окончательного результата, а `Receipts` доводит её до конца (здесь — погашая квитанцию дополнительным вызовом) до того, как `call_tool` вернёт управление. В месте вызова не меняется ничего. + +Уберите расширение — и ничего этого не будет: пропускной пункт сервера отклонит клиент, который его не объявил (ошибка -32021), а заявленная форма от сервера, пропускающего эту проверку, не пройдёт валидацию — ровно так, как спецификация требует для нераспознанного `resultType`. Выключено по умолчанию, на обоих концах соединения. + +Чтобы объявить идентификатор **без** какого-либо клиентского поведения (сервер проверяет наличие возможности, клиент ничего не делает — как в клиенте поиска выше), используйте `advertise()`: + +```python +from mcp.client import advertise + +client = Client(mcp, extensions=[advertise("com.example/search")]) +``` + +## Написание клиентского расширения {#writing-a-client-extension} + +Унаследуйтесь от `ClientExtension` и переопределите только то, что нужно. Три вида вклада, у каждого реализация по умолчанию: `settings()`, `claims()` и `notifications()`. + +```python title="client.py" hl_lines="17-18 43-44 46-47" +--8<-- "docs_src/extensions/tutorial006.py" +``` + +* Идентификатор подчиняется той же грамматике, что и на сервере, и проверяется при определении класса. +* `claims()` возвращает объекты `ResultClaim`: тег в передаваемых данных, модель, которая его разбирает, и резолвер, который доводит результат до конца. Модель обязана зафиксировать тег через `result_type: Literal["receipt"]` и не должна наследоваться от базовых типов результата этого глагола; и то и другое проверяется при создании заявки. Вендорные поля вроде `receipt_token` передаются по сети как есть: подставленная форма доходит до клиента дословно. +* Резолвер получает разобранную модель и `ClaimContext`; `ctx.session` — тот же публичный дескриптор, что и `client.session`, так что последующие вызовы — это обычные вызовы сессии. Возвращает он обычный для глагола `CallToolResult`. +* `settings()` — значение, объявляемое в `ClientCapabilities.extensions[identifier]`; оно считывается один раз при создании `Client`. + +`notifications()` объявляет вендорные уведомления сервера, за которыми нужно наблюдать: + +```python +def notifications(self) -> Sequence[NotificationBinding[Any]]: + return [NotificationBinding(method="notifications/receipts", params_type=ReceiptEvent, handler=self.on_receipt)] +``` + +Обработчик получает провалидированные параметры по одному, в порядке диспетчеризации. Он наблюдает; запретить или ответить он не может. + +Два негромких правила. Заявки действуют только на подключениях версии 2026-07-28, и объявление возможностей следует за ними: на подключении старого поколения заявки исчезают, а вместе с ними из объявления выпадает и идентификатор, так что клиент никогда не объявляет расширение, формы которого он бы отклонил. А если заявленная форма нужна вам самим, а не резолверу, вызывайте `client.session.call_tool(..., allow_claimed=True)`; без этого флага заявленная форма, дошедшая до вызывающего кода на уровне сессии, приводит к исключению `UnexpectedClaimedResult`. + +### Глаголы расширения {#extension-verbs} + +Собственные методы запросов расширения не требуют регистрации на стороне клиента. Тип вендорного запроса наследуется от `mcp.types.Request` и отправляется через `client.session.send_request`, как в разделе [Обслуживание собственных методов](#serving-your-own-methods). Одно дополнение: когда ключ из params должен передаваться в заголовке `Mcp-Name` (спецификации расширений, например tasks, требуют этого для своих глаголов), тип запроса объявляет `name_param`: + +```python title="client.py" hl_lines="22-25 46-47" +--8<-- "docs_src/extensions/tutorial007.py" +``` + +Сессия дублирует `params["jobId"]` в `Mcp-Name` на каждом пути отправки, а отсутствующее значение приводит к явной ошибке, а не к молчаливому пропуску обязательного заголовка. + +## Чего расширение не может {#what-an-extension-cannot-do} + +Поверхность вклада **закрыта** намеренно. На сервере: настройки, инструменты, ресурсы, методы, один перехватчик `tools/call`. На клиенте: настройки, заявки на результаты, привязки уведомлений. Расширение не может: + +* **Дотянуться до хоста.** Оно объявляет данные; ссылки на сервер или клиент у него нет. +* **Заменить базовое поведение.** Методы спецификации и базовые теги результатов отклоняются при создании (`initialize` и вовсе зарезервирован за механизмом запуска); привязка уведомления, перекрытая базовым словарём, вместо этого замолкает с предупреждением. +* **Зарегистрироваться с опозданием.** После того как `MCPServer(...)` или `Client(...)` вернул управление, набор расширений уже не меняется. + +Если вы боретесь с этими стенами, вы пишете не расширение. Вы пишете форк. Стены и есть главное достоинство: тот, кто читает `extensions=[Apps(), Stamps()]`, знает *всё*, чего эти два расширения могли коснуться. diff --git a/i18n/ru/pages/advanced/index.md b/i18n/ru/pages/advanced/index.md new file mode 100644 index 0000000000..3af0ac2de2 --- /dev/null +++ b/i18n/ru/pages/advanced/index.md @@ -0,0 +1,34 @@ +--- +translation: + sections: [ca6988b7503cd2d3] + tool: 1 +--- +# Продвинутые темы {#advanced} + +Всё, что нужно обычному серверу или клиенту, уже разобрано по темам в разделах выше. +Этот раздел — обходные пути, к которым обращаются, когда удобный слой `MCPServer` +начинает мешать: + +* **[Низкоуровневый Server](low-level-server.md)**: класс, на котором построен `MCPServer`. + Схемы, написанные вручную, обработчики `on_*`, никаких проверок за вас и собственные + JSON-RPC-методы. +* **[Пагинация](pagination.md)** и **[Middleware](middleware.md)**: две вещи, которые + можно сделать *только* на низкоуровневом `Server`. +* **[Расширения](extensions.md)** и **[MCP Apps](apps.md)**: точки расширения + протокола. Подключайте пакеты расширений к серверу или пишите свои. + +Кое-что из того, что логично было бы искать здесь, находится там, где это +действительно используется: + +* **Авторизация** — в разделе **[Запуск сервера](../run/index.md)**, потому что + сервер защищают там, где его развёртывают. +* **OAuth**, **подтверждение идентичности**, подключение к **нескольким серверам** и + **кеш** ответов — всё это в разделе **[Клиенты](../client/index.md)**. +* **Многораундовые запросы** (multi-round-trip) и **Подписки** — в разделе + **[Внутри обработчика](../handlers/index.md)**, потому что и то и другое — это то, + что обработчик *делает*. +* **Шаблоны URI** — в разделе **[Серверы](../servers/index.md)**, рядом с ресурсами. +* У **[Версий протокола](../protocol-versions.md)** и + **[Устаревших возможностей](../deprecated.md)** есть собственные страницы верхнего уровня. + +Если вы не уверены, нужен ли вам этот раздел, — он вам не нужен. diff --git a/i18n/ru/pages/advanced/low-level-server.md b/i18n/ru/pages/advanced/low-level-server.md new file mode 100644 index 0000000000..bbee2af34c --- /dev/null +++ b/i18n/ru/pages/advanced/low-level-server.md @@ -0,0 +1,207 @@ +--- +translation: + sections: [2c79b6338e09b7ac, 7edc43b3fae11314, 1086e77ce561cd7f, a3f71823df5efc31, 9fc7109f72201cae, 7bf25983df655b66, 6330e1f4c6029683, 2f1749c8c133fa1c, b3530fcf4d11fd56, ebc33704fbd74262, cd0e9c933350390e] + tool: 1 +--- +# Низкоуровневый Server {#the-low-level-server} + +`@mcp.tool()` — это слой. Под ним лежит второй класс сервера, `Server`, который говорит на чистом MCP: вы передаёте ему объекты протокола, и он отправляет их по сети без изменений. + +`MCPServer` построен поверх него. Спускаться ниже стоит тогда, когда слой удобства мешает: + +* Нужно отдать **точную** схему (загруженную из файла, сгенерированную из базы данных), а не выведенную из сигнатуры Python. +* Нужен полный контроль над результатом: `_meta`, `is_error`, каждый ключ `structured_content`. +* Нужно обработать метод, который MCP не определяет. + +Во всех остальных случаях оставайтесь на `MCPServer`. + +## Тот же инструмент, вручную {#the-same-tool-by-hand} + +Это инструмент `search_books`, который на странице **[Инструменты](../servers/tools.md)** занимает девять строк с `@mcp.tool()`, — но без синтаксического сахара: + +```python title="server.py" hl_lines="22 26 32" +--8<-- "docs_src/lowlevel/tutorial001.py" +``` + +Изменились три вещи, и это весь низкоуровневый API: + +* **Обработчики — параметры конструктора.** `on_list_tools=` и `on_call_tool=` передаются в `Server(...)`. Декораторов здесь нет, и у каждого обработчика одна и та же форма: `async (ctx, params) -> result`. +* **Входную схему пишете вы.** `Tool.input_schema` — обычный `dict` с JSON Schema. Никто не выводит её из аннотаций типов, потому что аннотаций типов, из которых её можно было бы вывести, нет. +* **Результат собираете вы.** `CallToolResult(content=[TextContent(...)])`, вручную. Ничего не оборачивается, не преобразуется и не выводится из аннотации возвращаемого значения. + +`params` — это разобранный запрос: `CallToolRequestParams` даёт `.name` и `.arguments`. `ctx` — это `ServerRequestContext`: `ctx.session` для обращения к клиенту, `ctx.lifespan_context`, `ctx.request_id` и `ctx.meta` — входящий `_meta` запроса. + +!!! info + Если вы работали с FastAPI, это соотношение вам уже знакомо. `MCPServer` — слой с декораторами и аннотациями типов; `Server` — это Starlette под ним. Они не конкуренты: `MCPServer` создаёт `Server` и регистрирует на нём ровно такие же обработчики. + +### Попробуйте сами {#try-it} + +Inspector здесь не поможет: `mcp dev` и `mcp run` принимают только `MCPServer`. Клиенту `Client`, работающему в памяти, всё равно — он принимает низкоуровневый `Server` точно так же, как `MCPServer`: + +```python title="main.py" +import asyncio + +from mcp import Client + +from server import server + + +async def main() -> None: + async with Client(server) as client: + result = await client.call_tool("search_books", {"query": "dune", "limit": 5}) + print(result.content) + + +asyncio.run(main()) +``` + +```text +[TextContent(type='text', text="Found 3 books matching 'dune' (showing up to 5).", annotations=None, meta=None)] +``` + +Тот же текст, что выдала версия с `@mcp.tool()`. Два честных отличия: + +* `result.structured_content` равен `None`. Высокоуровневый сервер сам оборачивает `-> str` в `{"result": ...}`; здесь никто не соберёт то, чего не собрали вы. +* `list_tools` возвращает схему, которую набрали **вы**, символ в символ. В высокоуровневой версии у каждого свойства было `"title": "Query"`, а в корне — `"title": "search_booksArguments"`: артефакты Pydantic. Здесь всё, что есть в передаваемых данных, положили туда вы. + +## За вас ничего не проверяют {#nothing-is-checked-for-you} + +`MCPServer` отклоняет некорректный аргумент ещё до запуска вашей функции, проверяя вызов по сгенерированной им схеме (**[Инструменты](../servers/tools.md)**). + +`Server` этого не делает. Ваша `input_schema` *объявляется* клиенту, но никогда не *применяется* к `params.arguments`. + +!!! check + Вызовите `search_books` без `limit`, и ваше `args["limit"]` выбросит `KeyError`. Клиент увидит: + + ```text + MCPError: Internal server error + ``` + + Ошибка JSON-RPC с кодом `-32603` и намеренно общим сообщением: SDK не станет выдавать вашу трассировку удалённому вызывающему. Модель так и не узнает, что сделала не так, и не сможет повторить попытку. (В тесте `raise_exceptions=True` вместо этого показывает настоящее исключение; см. **[Тестирование](../get-started/testing.md)**.) + +Это обобщается. Исключение, выброшенное из низкоуровневого обработчика, — **всегда** ошибка протокола и никогда не результат инструмента с `is_error=True`. Если хотите, чтобы модель прочитала описание сбоя и восстановилась, проверяйте `params.arguments` сами и возвращайте `CallToolResult(content=[TextContent(...)], is_error=True)`. Этим двум видам сбоев посвящена страница **[Обработка ошибок](../servers/handling-errors.md)**. + +## Два инструмента, один обработчик {#two-tools-one-handler} + +`on_call_tool` — единственная точка входа для всех инструментов сервера. Маршрутизация идёт по `params.name`: + +```python title="server.py" hl_lines="38-43" +--8<-- "docs_src/lowlevel/tutorial002.py" +``` + +* `list_tools` объявляет оба. `call_tool` выбирает ветку по имени. +* Ветка `else` важна: `Server` без возражений передаст `tools/call` с именем, которое вы никогда не объявляли, прямо в ваш обработчик. Исключение там превращает вызов в тот же `-32603`, что и выше. + +## Структурированный вывод, вручную {#structured-output-by-hand} + +Объявите `output_schema` в `Tool` и поместите `structured_content` в результат. И то и другое — ваше: + +```python title="server.py" hl_lines="19-23 36" +--8<-- "docs_src/lowlevel/tutorial003.py" +``` + +Вызовите его, и результат несёт оба представления: + +```json +{ + "content": [{"type": "text", "text": "Found 3 books matching 'dune'."}], + "structuredContent": {"matches": 3, "query": "dune"}, + "isError": false, + "resultType": "complete", + "_meta": {"io.modelcontextprotocol/serverInfo": {"name": "Bookshop", "version": "2.0.0"}} +} +``` + +Блок `_meta` — это идентификационная отметка сервера: SDK добавляет её в каждый результат поколения 2026, с `version` из конструктора (сервер, который её не задал, сообщает пустую строку). Сервер, который не должен себя называть, может убрать этот ключ с помощью middleware — оно владеет результатами, которые возвращает. + +Сервер никогда не сравнивает эти два поля. А вот `Client` из этого SDK сравнивает: верните `structured_content`, не соответствующий объявленной вами `output_schema`, и `call_tool` выбросит `RuntimeError`, который начинается с `Invalid structured content returned by tool search_books` и дальше цитирует ошибку `jsonschema`. Пообещать схему легко; соблюдать её — ваша забота. Вся лестница возвращаемых типов и схем — на странице **[Структурированный вывод](../servers/structured-output.md)**. + +## `_meta`: для приложения, не для модели {#\_meta-for-the-application-not-the-model} + +`content` — это та часть ответа, которую читает модель. `structured_content` — тот же ответ в виде типизированных данных. `_meta` — третий канал: данные, которые едут вместе с результатом для **клиентского приложения** и вообще не являются частью ответа. + +Используйте его для идентификаторов записей, идентификаторов трассировки — всего, что нужно вашему UI и не нужно промпту: + +```python title="server.py" hl_lines="37" +--8<-- "docs_src/lowlevel/tutorial004.py" +``` + +* При создании вы пишете `_meta=` — имя, которое идёт по сети. Клиент читает его обратно как `result.meta`. +* Давайте ключам пространство имён (`bookshop/record_ids`). Ключи `io.modelcontextprotocol/*` зарезервированы протоколом. + +!!! warning + `_meta` — это соглашение между вами и клиентским приложением, а не гарантия того, что дойдёт + до модели. Что отображать, решает хост. Никогда не помещайте секрет ни в одну часть результата инструмента. + +## Возможности следуют за обработчиками {#capabilities-follow-your-handlers} + +`Server` объявляет ровно те семейства методов, для которых вы передали обработчики. `Bookshop` выше передаёт `on_list_tools` и `on_call_tool` и больше ничего, поэтому подключившийся к нему клиент видит: + +```json +{"tools": {"listChanged": false}} +``` + +Ни `resources`, ни `prompts`: их нечем обеспечить. Передайте `on_list_prompts` — появится `prompts`; передайте `on_completion` — появится `completions`. + +`MCPServer` всегда объявляет инструменты, ресурсы и промпты, зарегистрировали вы что-нибудь или нет, потому что его менеджеры существуют всегда. Здесь же объявление — это *и есть* вызов конструктора. + +## Дженерик жизненного цикла {#the-lifespan-generic} + +`Server` — дженерик по типу, который отдаёт его жизненный цикл (lifespan). Аннотируйте его один раз, и объект будет типизирован везде, где появляется: + +```python title="server.py" hl_lines="24-26 44-45 50" +--8<-- "docs_src/lowlevel/tutorial005.py" +``` + +* Жизненный цикл — это `Callable[[Server[Catalog]], AbstractAsyncContextManager[Catalog]]`; `@asynccontextmanager` на `async`-генераторе даёт ровно это. +* То, что он отдаёт через `yield`, становится `ctx.lifespan_context`, а поскольку обработчики аннотированы как `ServerRequestContext[Catalog]`, `.search(...)` автодополняется и проходит проверку типов. +* Вход в него происходит один раз при запуске сервера, выход — один раз при остановке. Запуск, завершение и версия той же идеи в `MCPServer` — на странице **[Жизненный цикл](../handlers/lifespan.md)**. + +Без `lifespan=` значение `ctx.lifespan_context` — пустой `dict`. + +## Собственный метод {#a-method-of-your-own} + +Конструктор покрывает методы, которые определяет MCP. `add_request_handler` покрывает всё остальное: + +```python title="server.py" hl_lines="35-36 39-40 43-44 48" +--8<-- "docs_src/lowlevel/tutorial006.py" +``` + +* Первый аргумент — строка метода. У уведомлений есть двойник, `add_notification_handler`. +* `params_type` — модель, по которой входящие `params` проверяются **до** запуска вашего обработчика, так что пользовательские методы *получают* ту проверку, которой нет у инструментов. Наследуйтесь от `RequestParams`, чтобы поле `_meta` разбиралось так же, как у любого другого метода. +* Обработчик возвращает `BaseModel`, `dict` или `None`. SDK сериализует это в результат JSON-RPC. + +Одна честная оговорка: у высокоуровневого `Client` есть глаголы только для методов, определённых MCP, так что `client.reindex()` не существует. Вендорный метод предназначен для стороны, которая уже знает о его существовании: клиента, который вы тоже поставляете, или другого вашего сервиса, говорящего на JSON-RPC. + +Один метод занять нельзя: + +```text +ValueError: 'initialize' is handled by the server runner and cannot be overridden; +use Server.middleware to observe or wrap initialization +``` + +Рукопожатие принадлежит раннеру. `server/discover`, `ping` и все остальные встроенные методы можно заменять. + +!!! tip + `Server.middleware`, упомянутый в этой ошибке, оборачивает **каждое** входящее сообщение, включая `initialize`. Если нужно наблюдать за трафиком или переписывать его, а не отвечать на новый метод, начните со страницы **[Middleware](middleware.md)**. + +## Остальные обработчики {#the-other-handlers} + +Каждый из них — одна идея, для которой у вас теперь есть словарь; у каждого своя страница. + +* `on_call_tool`, `on_get_prompt` и `on_read_resource` могут вернуть `InputRequiredResult` вместо обычного результата, чтобы приостановить вызов и запросить ввод у клиента; см. **[Многораундовые запросы](../handlers/multi-round-trip.md)**. Верные духу этого уровня, они ничего не устанавливают за вас: там, где `MCPServer` по умолчанию запечатывает `requestState`, здесь заданный вами `request_state` идёт по сети ровно в том виде, в каком написан, пока вы не включите защиту явно: `server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[...]), default_audience=server.name))` — одна строка (оба имени импортируются из `mcp.server.request_state`) для точно такого же запечатывания и проверки, какие выполняет `MCPServer` (**[Защита `requestState`](../handlers/multi-round-trip.md#protecting-requeststate)**). +* `on_list_resources`, `on_read_resource`, `on_list_prompts`, `on_get_prompt`, `on_completion` — та же форма `(ctx, params) -> result` для остальных примитивов. +* `on_subscriptions_listen` обслуживает поток `subscriptions/listen` версии 2026-07-28. Передайте `ListenHandler`, построенный поверх `SubscriptionBus`, и публикуйте события в шину из остальных обработчиков; полная схема компоновки — на странице **[Подписки](../handlers/subscriptions.md)**. +* `server.streamable_http_app()` возвращает то же Starlette-приложение, что и у `MCPServer`; разворачивайте его так же, как страница **[Запуск сервера](../run/index.md)** разворачивает любое другое ASGI-приложение. `server.run(transport=...)` здесь нет: `server.run(read_stream, write_stream, server.create_initialization_options())` ведёт одно подключение по паре потоков, и этой одной строкой всё исчерпывается. + +## Итоги {#recap} + +* Низкоуровневый `Server` принимает обработчики как **параметры конструктора** `on_*`; каждый обработчик — `async (ctx, params) -> result`. +* Словарь `input_schema` пишете вы, и `CallToolResult` собираете вы. Ничего не выводится, не оборачивается и не проверяется за вас. +* Исключение в обработчике — ошибка протокола `-32603`. Ошибка инструмента, которую может прочитать модель, — это `CallToolResult` с `is_error=True`, который возвращаете **вы**. +* `_meta` в результате адресован клиентскому приложению, а не модели. +* `Server[T]` — дженерик по тому, что отдаёт его жизненный цикл; `ctx.lifespan_context` — типизированный `T`. +* `add_request_handler(method, params_type, handler)` обслуживает любой метод. `initialize` зарезервирован. +* Возможности, которые объявляет `Server`, выводятся из того, какие обработчики вы зарегистрировали. + +`Client(server)` обращался с обоими серверами одинаково, потому что это *и есть* один и тот же протокол — в этом весь смысл. Следующий уровень вниз — вообще не класс: это **[Middleware](middleware.md)**. diff --git a/i18n/ru/pages/advanced/middleware.md b/i18n/ru/pages/advanced/middleware.md new file mode 100644 index 0000000000..8ecfa14a66 --- /dev/null +++ b/i18n/ru/pages/advanced/middleware.md @@ -0,0 +1,84 @@ +--- +translation: + sections: [6048b4f308edbb8c, 068bda0f21ee9c1b, c3e565b61acd75c5, c62422b159c6ed09, 47204fab253cc45c] + tool: 1 +--- +# Middleware {#middleware} + +**Middleware** (промежуточный слой) — это одна асинхронная функция, которая оборачивает каждое сообщение, приходящее на сервер. + +Её пишут в виде `async (ctx, call_next)` и добавляют в `server.middleware`. Вот и весь API. + +!!! warning + Список middleware в исходном коде помечен как **provisional** (предварительный): его сигнатура и семантика могут измениться в минорном выпуске 2.x. Используйте его, чтобы *наблюдать* (замер времени, логирование, трассировка) и *отклонять* сообщения; не делайте его фундаментом, на котором держится сервер. + +`MCPServer` принимает список при создании (`MCPServer(name, middleware=[...])`) и предоставляет его как `mcp.middleware`; низкоуровневый `Server` предоставляет тот же список как `server.middleware`. В примере ниже используется низкоуровневый `Server`; если конструкция `Server(name, on_call_tool=...)` вам незнакома, сначала прочитайте **[Низкоуровневый Server](low-level-server.md)**. + +## Middleware для замера времени {#a-timing-middleware} + +Один сервер, один инструмент, один слой middleware, который пишет в лог, сколько заняло каждое сообщение: + +```python title="server.py" hl_lines="39-45 49" +--8<-- "docs_src/middleware/tutorial001.py" +``` + +* `ctx` — тот же `ServerRequestContext`, который получают обработчики. `ctx.method` — строка метода как есть; `ctx.params` — параметры как есть, **до** какой-либо валидации. +* `call_next(ctx)` запускает остаток цепочки: валидацию, поиск обработчика, сам обработчик. Верните то, что вернул он, — и ответ останется нетронутым. +* `try`/`finally` здесь намеренно: обработчик, выбросивший исключение, всё равно замеряется, потому что сбой доходит до middleware в виде исключения из `call_next`. +* `server.middleware.append(...)` регистрирует его. Список выполняется начиная с внешнего слоя, так что `middleware[0]` — ближайший к сети. + +### Попробуйте сами {#try-it} + +Подключите клиент, запросите список инструментов, вызовите один из них. В логе **три** строки: + +```text +server/discover took 18.3 ms +tools/list took 0.1 ms +tools/call took 0.1 ms +``` + +Вызовов было два, а строк три. Первая — `server/discover`: запрос, который клиент отправил, чтобы установить подключение, ещё до того, как вы что-либо запросили. + +В этом и суть. Middleware оборачивает **каждое** входящее сообщение: + +* Установку подключения: `server/discover` или, в сессии старого поколения, `initialize` и `notifications/initialized`. +* Каждый запрос и каждое уведомление. Для уведомления `ctx.request_id is None`, `call_next(ctx)` возвращает `None`, а всё, что вернёте вы, отбрасывается. +* Даже метод, для которого у сервера нет обработчика: `call_next` выбрасывает `MCPError(-32601, "Method not found")` *сквозь* middleware по пути к клиенту. + +## Что можно делать внутри {#what-you-can-do-inside-one} + +В порядке возрастания того, насколько стоит задуматься, прежде чем это делать: + +* **Наблюдать.** Замерять, считать, логировать. Пример выше. +* **Отклонять.** Выбросьте `MCPError` *вместо* вызова `call_next(ctx)` — и на это одно сообщение придёт ответ с ошибкой JSON-RPC. Подключение не рвётся; следующее сообщение проходит. Именно так сервер ограничивает `subscriptions/listen` для каждого вызывающего: раздел **[Кому разрешено наблюдать](../handlers/subscriptions.md#deciding-who-may-watch)** на странице о подписках разбирает это пошагово. +* **Переписывать.** `ctx` — это dataclass: `await call_next(dataclasses.replace(ctx, params=...))` передаёт остальной цепочке не те параметры, что прислал клиент. Никогда не делайте этого с `initialize`: результат, который получает клиент, строится из переписанных параметров, но состояние подключения сервер фиксирует по исходным параметрам из сети. Стороны могут завершить рукопожатие, расходясь в том, о чём они договорились. +* **Отвечать.** Верните результат, не вызывая `call_next(ctx)`, — и он уйдёт клиенту как ваш ответ. `call_next` отдаёт готовую сетевую форму, а конвейер никогда не правит то, что вы возвращаете, так что вся обёртка целиком на вас: на подключении поколения 2026 сюда входит отметка `serverInfo` в `_meta`, которую SDK добавляет к результатам обработчиков, но не к вашим. + +!!! check + `initialize` — одно из того, что оборачивает middleware, и это *единственный* хук для него. Попробуйте перехватить его через `add_request_handler` — и SDK откажет: + + ```text + ValueError: 'initialize' is handled by the server runner and cannot be overridden; + use Server.middleware to observe or wrap initialization + ``` + +!!! warning + `initialize` обрабатывается на месте: сервер не читает дальнейшие входящие сообщения, пока цепочка middleware не вернёт управление. Поэтому ожидание запроса от сервера к клиенту (`ctx.session.send_request(...)`, элицитация (elicitation)) во время обработки `initialize` **приводит к взаимной блокировке подключения**: ответ, которого вы ждёте, никогда не будет прочитан. Уведомления по принципу «отправил и забыл» допустимы. + +## Единственный слой middleware, включённый по умолчанию {#the-one-middleware-that-ships-on-by-default} + +SDK поставляет ровно один слой middleware, и он уже в списке вашего сервера: тот, что создаёт спан OpenTelemetry для каждого сообщения. Его не нужно добавлять, и чаще всего о нём не приходится думать. Пока не установлен экспортёр, он ничего не делает, и у него есть своя страница: **[OpenTelemetry](../run/opentelemetry.md)**. + +!!! info + Если вы писали ASGI middleware, эта форма вам уже знакома. `(scope, receive, send)` из Starlette превратилось в `(ctx, call_next)` и выполняется *после* транспорта — над декодированным сообщением, а не над сырым HTTP-запросом. Одно с другим сочетается: middleware Starlette поверх `streamable_http_app()` видит HTTP; этот слой видит MCP. + +## Итоги {#recap} + +* Middleware — это `async (ctx, call_next) -> result`; его передают как `MCPServer(middleware=[...])` (или добавляют в `mcp.middleware`), а в низкоуровневом `Server` добавляют в `server.middleware`. +* Middleware оборачивает **каждое** входящее сообщение (`server/discover`, `initialize`, запросы, уведомления, неизвестные методы) и выполняется начиная с внешнего слоя. +* `ctx.request_id is None` — так уведомление отличают от запроса. +* Чтобы отклонить одно сообщение, выбросьте исключение вместо вызова `call_next`; подключение это переживёт. +* Собственная трассировка OpenTelemetry в SDK — тоже middleware, уже в списке. См. **[OpenTelemetry](../run/opentelemetry.md)**. +* Весь этот интерфейс предварительный. Наблюдайте с его помощью; не стройте на нём. + +Это всё, что оборачивает запрос. А решает, будет ли запрос вообще выполнен, **[Авторизация](../run/authorization.md)**. diff --git a/i18n/ru/pages/advanced/pagination.md b/i18n/ru/pages/advanced/pagination.md new file mode 100644 index 0000000000..adc3596339 --- /dev/null +++ b/i18n/ru/pages/advanced/pagination.md @@ -0,0 +1,85 @@ +--- +translation: + sections: [a9aba7a026c7bd85, ed32bda7ba9ae33a, 7e64cc5646abb91f, 22a0129ee78b3c63, d875373c06d8d2f9] + tool: 1 +--- +# Пагинация {#pagination} + +Большинству серверов это никогда не понадобится. + +`MCPServer` отвечает на каждый запрос `list_*` всем, что у него есть, одной страницей, с `next_cursor=None`. Для нескольких десятков инструментов, ресурсов или промптов это правильный ответ, и настраивать здесь нечего. + +Пагинация нужна серверу, чей список ресурсов — это по сути база данных: тысячи строк, которые он не станет сериализовать в одном ответе. Протокол предлагает для этого **курсор**: сервер возвращает страницу и непрозрачный токен, а клиент отправляет этот токен обратно, чтобы получить следующую страницу. + +У `@mcp.resource()` для этого нет никакой точки расширения. Чтобы отдавать данные постранично, обработчик списка пишется вручную — на **[низкоуровневом Server](low-level-server.md)**. + +## Сервер с пагинацией {#a-server-that-pages} + +```python title="server.py" hl_lines="12 15-16" +--8<-- "docs_src/pagination/tutorial001.py" +``` + +* На низкоуровневом `Server` обработчики — это аргументы конструктора, а не декораторы. `on_list_resources` отвечает на каждый запрос `resources/list`; вот и всё подключение. +* Каждый обработчик с пагинацией имеет тип параметра `params: PaginatedRequestParams | None`, и пример принимает оба варианта. Однако при работе через подключение SDK никогда не передаёт `None` (запрос без поля `params` доходит до обработчика как модель со значениями по умолчанию), поэтому значимый сигнал — это `params.cursor is None`: **начать с начала**. +* Что *такое* курсор, решаете вы. Здесь это смещение, записанное строкой. Временная метка, первичный ключ, blob в base64 — всё, что можно выдать на выходе и распознать на обратном пути. +* `next_cursor=None` — это способ сказать «это была последняя страница». Нет ни счётчика, ни общего количества, ни `has_more`. `None` — и есть весь сигнал. + +!!! tip + `PAGE_SIZE`, равный 10, делает пример читаемым. Свой размер выбирайте отдельно для каждой конечной точки: список + однострочных ресурсов может позволить себе страницу на 500 элементов; список объёмных шаблонов промптов — нет. + Клиент на это никак не влияет, и так задумано. + +### Попробуйте сами {#try-it} + +`Client(server)` подключается к низкоуровневому `Server` в памяти точно так же, как к `MCPServer`. + +Вызовите `list_resources()` без аргументов. Придут десять ресурсов, от `book-1` до `book-10`, а `next_cursor` будет строкой `"10"`. + +Передайте его обратно через `list_resources(cursor="10")` — первым ресурсом окажется `book-11`, а новый `next_cursor` будет `"20"`. + +Десятая страница приходит с `next_cursor`, равным `None`. Готово. + +## Цикл на стороне клиента {#the-client-loop} + +Каждый метод `list_*` у `Client` (`list_tools`, `list_resources`, `list_resource_templates`, `list_prompts`) принимает именованный аргумент `cursor=`. Вычитать список постранично целиком — это один `while True`: + +```python title="client.py" hl_lines="26-32" +--8<-- "docs_src/pagination/tutorial002.py" +``` + +* `cursor` начинается с `None`, поэтому первый запрос уходит без курсора. +* Добавляйте элементы **до** того, как смотреть на `next_cursor`: на последней странице тоже есть ресурсы. +* `next_cursor is None` — условие выхода. Всё остальное без изменений отправляется обратно в `cursor=`. + +Запустите его `main()`, и он напечатает `100 resources`: десять страниц по десять, сшитых циклом, который и не знал, что страниц было десять. + +Это тот же цикл, который **[Клиент](../client/index.md)** показывает для каждого метода `list_*`, и против сервера без пагинации он ничего не стоит: `next_cursor` равен `None` уже в первом ответе, и цикл выполняется один раз. + +## Три правила {#the-three-rules} + +**Курсоры непрозрачны.** Клиент никогда не должен разбирать, собирать или угадывать курсор. Единственный законный источник курсора — `next_cursor` предыдущей страницы, дословно. + +**Размер страницы выбирает сервер.** В протоколе нет `limit=`. Если нужен другой размер страницы, меняется сервер. + +**Клиент, игнорирующий пагинацию, всё равно работает.** Он вызывает `list_resources()` один раз, получает первые десять и не замечает выброшенный `next_cursor`. Ничего не ломается; он просто видит меньше. + +!!! check + Непрозрачный значит непрозрачный. Придумайте курсор (`list_resources(cursor="page-2")`) — и + протокол ничем не сможет помочь. Этот сервер пробует `int("page-2")`, обработчик выбрасывает исключение, + и клиенту приходит: + + ```text + MCPError(-32603, 'Internal server error', None) + ``` + + Курсор, полученный не от сервера, — это ошибка, а не запрос на новую возможность. + +## Итоги {#recap} + +* `MCPServer` возвращает всё одной страницей. Пагинация включается по желанию, и включается она на низкоуровневом `Server`. +* `on_list_resources` (а также `on_list_tools`, `on_list_prompts`, `on_list_resource_templates`) получает `PaginatedRequestParams | None`; для первой страницы `params.cursor` равен `None`. +* Вы возвращаете страницу и `next_cursor`: любую строку, которую потом узнаете, или `None`, когда больше ничего не осталось. +* Цикл клиента: передать `cursor=`, накопить, повторять, пока не `next_cursor is None`. +* Курсоры непрозрачны, размер страницы — за сервером, а клиент без пагинации всё равно получает первую страницу. + +Остальной API `Server`, который пишется вручную (`on_call_tool`, словари `input_schema`, `_meta`), — на странице **[Низкоуровневый Server](low-level-server.md)**. diff --git a/i18n/ru/pages/client/caching.md b/i18n/ru/pages/client/caching.md new file mode 100644 index 0000000000..f23dbc7b06 --- /dev/null +++ b/i18n/ru/pages/client/caching.md @@ -0,0 +1,122 @@ +--- +translation: + sections: [9e7b9a1710e5aeba, b74ca4c1d2ddddee, fa8714e61bf90c5a, 04db67a886b7271c, 857690fb8f876800] + tool: 1 +--- +# Подсказки по кэшированию {#caching-hints} + +В протоколе 2026-07-28 каждый результат, который сервер возвращает для `tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read` и `server/discover`, несёт два поля: `ttlMs` — сколько миллисекунд клиент может считать результат свежим, и `cacheScope` — можно ли делить закэшированный результат между пользователями (`"public"`) или он принадлежит одному контексту авторизации (`"private"`). + +Сам сервер ничего не кэширует. Эти поля — *объявление*: «этот список инструментов одинаков для всех и не изменится в ближайшую минуту». Клиент (или шлюз перед вашим сервером) может тогда обойтись без обращения к серверу. Учитывать подсказки или нет — решает клиент; выдавать их — задача сервера, и SDK делает это за вас. + +По умолчанию каждый результат говорит `ttlMs: 0, cacheScope: "private"`: сразу устаревший, никому не передаётся. Это всегда безопасно и всегда соответствует спецификации. Если ваши списки действительно стабильны и одинаковы для всех вызывающих, скажите об этом при создании сервера: + +```python title="server.py" hl_lines="5-8" +--8<-- "docs_src/caching/tutorial001.py" +``` + +* Ключи словаря — **имена методов**, и шесть кэшируемых методов — единственные допустимые ключи. Параметр имеет тип `Mapping[CacheableMethod, CacheHint]`, поэтому редактор подсказывает ключи автодополнением и отмечает опечатку ещё до запуска; всё, что проскользнёт мимо проверки типов, выбрасывает исключение при создании. +* Метод, который вы не упомянули, сохраняет значения по умолчанию. Словарь — это набор переопределений, а не полный перечень. +* `CacheHint(ttl_ms=5_000)` оставил `scope` незаданным, поэтому он остаётся `"private"`: пять секунд свежести, отдельно для каждого вызывающего. Область и TTL — независимые решения. +* `"server/discover"` — тоже допустимый ключ, поскольку результат обнаружения кэшируется так же, как любой список. + +!!! warning + `cacheScope: "public"` означает, что ваш закэшированный ответ могут отдать *кому угодно*. + Общий шлюз охотно передаст результат одного пользователя другому, даже если запрос был + аутентифицирован. Помечайте результат как `"public"`, только если он одинаков для каждого + вызывающего, и никогда не используйте `cacheScope` для управления доступом: это метка, а не замок. + +## Переопределение в обработчике {#per-handler-override} + +В низкоуровневом классе `Server` обработчики собирают результаты вручную, а `ttl_ms` и `cache_scope` — это просто поля моделей результата. Обработчик, который задаёт их явно, всегда берёт верх над словарём из конструктора, поле за полем: + +```python title="server.py" hl_lines="10 16" +--8<-- "docs_src/caching/tutorial002.py" +``` + +Обработчик указал `ttl_ms=1_000` и ничего про область. В передаваемых данных: `ttlMs: 1000` (значение обработчика, а не `60_000` из словаря) и `cacheScope: "public"` (из словаря, потому что обработчик его не задал). Явное значение важнее настроенного, а настроенное важнее значения по умолчанию. Это действует для каждого поля отдельно, так что обработчик может зафиксировать одно поле, а другое оставить общесерверной политике. + +Это же и выход для динамики, о которой конструктор знать не может: обработчик, фильтрующий `resources/read` по пользователю, может вернуть `cache_scope="private"` для одного URI на сервере, где всё остальное публично. + +Одна оговорка о постраничных списках: протокол требует **одинакового `cacheScope` на каждой странице** одного списка. Словарь конструктора выполняет это по построению, поскольку его ключи — методы, а не страницы. Но обработчик, переопределяющий область сам, сам же и отвечает за согласованность: переопределяйте её на *каждой* странице, а не только когда есть курсор, иначе первая и вторая страницы разойдутся. + +## Что видит клиент {#what-the-client-sees} + +В сессии 2026-07-28 `Client` учитывает подсказки за вас: у него есть встроенный кэш ответов, включённый по умолчанию. Результат, пришедший с `ttlMs`, сохраняется, и идентичный вызов в пределах этого TTL обслуживается из кэша без обращения к серверу. Результат *без* подсказки не кэшируется: результаты без подсказок получают `CacheConfig.default_ttl_ms`, по умолчанию равный `0` (сразу устаревший), так что сервер, ничего не объявляющий, видит ровно тот же трафик — вызов за вызовом, — что и всегда. + +```python title="client.py" hl_lines="33 35 38" +--8<-- "docs_src/caching/tutorial003.py" +``` + +Четыре вызова, три обращения к серверу. Второй вызов нашёл свежую запись и до сервера не дошёл; перевод (внедрённых) часов за пределы TTL заставил третий снова обратиться к серверу; четвёртый указал `cache_mode="refresh"`. Этот именованный аргумент есть у пяти кэширующих методов (`list_tools`, `list_prompts`, `list_resources`, `list_resource_templates`, `read_resource`): + +* `"use"` (по умолчанию) отдаёт свежую запись, если она есть, а если нет — запрашивает и сохраняет результат. +* `"refresh"` никогда не отдаёт из кэша: запрашивает и сохраняет результат, заменяя то, что было закэшировано. +* `"bypass"` обращается к серверу, вообще не трогая кэш: ни чтения, ни записи. + +Над `"use"` стоит одно правило: **вызовы с `meta` всегда доходят до сервера.** Запрос с заданным `meta` (токен прогресса, поля трассировки) рассчитывает на настоящий сетевой запрос, поэтому при `cache_mode="use"` он обрабатывается как `"refresh"`: чтение из кэша пропускается, а полученный результат всё равно заменяет закэшированную запись. `"bypass"` и явный `"refresh"` ведут себя как обычно. + +Чтобы совсем выключить кэширование, создайте клиент как `Client(server, cache=None)`: каждый вызов снова обращается к серверу, а `cache_mode`, хотя и принимается, ничего не делает. + +Область тоже учитывается автоматически: записи `"private"` привязаны к *разделу* (partition) кэша (о нём ниже), тогда как записи `"public"` могут быть разделены шире. И **уведомления важнее TTL** для ровно тех записей, которые они называют: уведомление `list_changed` вытесняет соответствующий закэшированный список, а `resources/updated` вытесняет закэшированное чтение, сохранённое ровно под его URI, какими бы свежими они ни были. На подключении 2026-07-28 эти уведомления приходят по потоку `subscriptions/listen`, который открывается через `client.listen(...)`, и вытеснение завершается раньше, чем наблюдатель увидит событие; подробнее — на странице **[Подписки](subscriptions.md)**. + +Одна оговорка о `resources/updated`: вытеснение работает только по точному URI. В контракте хранилища нет операции перечисления или сканирования (как и в эталонной реализации на TypeScript), поэтому уведомление с URI *под*ресурса не вытесняет закэшированное чтение его родителя. Если ваш сервер сигнализирует о подресурсах таким образом, перечитайте родителя с `cache_mode="refresh"`. + +### Настройка: `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`: где живут записи. По умолчанию — свежее хранилище в памяти для каждого клиента; передайте собственную реализацию `ResponseCacheStore` (скажем, на Redis), чтобы разделять кэш между клиентами или процессами. Типы контракта (`ResponseCacheStore`, `CacheKey`, `CacheEntry` и стандартный `InMemoryResponseCacheStore`) импортируются из `mcp.client`. Один поиск может выполнить до двух последовательных `get` к хранилищу (сначала приватная ветвь, затем публичная), так что рассчитывайте ожидания по задержке удалённого хранилища соответственно. Собственное хранилище **требует** явного `partition`. +* `partition`: метка контекста авторизации, не позволяющая отдать записи `"private"` одного принципала другому в общем хранилище. +* `target_id`: явная идентичность сервера, для собственных транспортов и внутрипроцессных серверов (ниже). +* `default_ttl_ms`: TTL, применяемый к результатам без подсказки `ttlMs`. Значение по умолчанию `0` оставляет результаты без подсказок незакэшированными. +* `share_public`: отдавать записи, которые сервер объявил `"public"`, между разделами (ниже). По умолчанию выключено. +* `clock`: источник настенного времени, в секундах эпохи. Внедрите его, как в примере выше, и тестам на истечение не придётся спать. + +!!! warning "Раздел = проверенный принципал" + Выводите `partition` из **проверенного удостоверения**, например из субъекта валидированного токена. Никогда не выводите его из данных, пришедших в запросе, и никогда — из URL сервера (идентичность сервера — отдельная ось ключа). SDK — это библиотека без собственной аутентификации: якорь доверия — тот, кто создаёт `CacheConfig`, то есть развёртывание, а не арендатор. Мультиарендный шлюз создаёт по одному `CacheConfig` на каждого аутентифицированного принципала. + + Раздел также фиксирован на всё время жизни `Client`. Если контекст авторизации подключения меняется посреди сессии (скажем, повторная аутентификация под другим принципалом), кэш за этим не следует; создайте новый `Client` для нового принципала. + +Ключи кэша также несут **идентичность сервера**: строку URL, по которой вы подключились, с убранным userinfo вида `user:pass@`, а в остальном байт в байт. Никакого приведения регистра, никакой перестановки параметров запроса, никакой чистки завершающего слэша. Недостаточная нормализация стоит лишь совместного использования, тогда как избыточная могла бы слить двух арендаторов (`?tenant=a` и `?tenant=b`), поэтому внешне разные URL просто не делят записи. Когда URL нет (внутрипроцессный сервер или экземпляр `Transport`), клиент вместо этого получает случайную идентичность на экземпляр; задайте `CacheConfig.target_id`, чтобы назвать сервер (с собственным хранилищем это обязательно, и создание об этом сообщит). Идентичность хешируется sha256 прежде, чем попасть в материал ключа, так что URL с секретами в строке запроса никогда не появляется в ключах хранилища. И сами не пишите в лог форму до хеширования. + +!!! warning "`share_public` доверяет серверу — для всего парка клиентов" + По умолчанию даже записи `"public"` остаются в пределах своего раздела. `share_public=True` отдаёт записи, которые сервер пометил `cacheScope: "public"`, **каждому** разделу, использующему хранилище, доверяя классификации сервера от имени их всех. Сервер, который ставит `"public"` на данные отдельного арендатора (по ошибке или злонамеренно), тогда раскрывает ответ одного арендатора остальным. Флаг намеренно существует только на уровне конструктора: `cache_mode` для отдельного вызова может сузить кэширование, но ничто на уровне вызова не может расширить совместное использование. + +### Чего кэш никогда не делает {#what-the-cache-never-does} + +* **Вызовы уровня сессии его обходят.** `client.session.list_tools()` и ему подобные всегда обращаются к серверу; кэш живёт в методах `Client`. +* **`server/discover` в него не попадает.** Результат discover доставляется один раз, при подключении, и никогда не входит в кэш ответов, даже если несёт `ttlMs`. Если вы сохраняете его сами, чтобы пропустить пробу при переподключении ([`prior_discover`](../protocol-versions.md#reconnecting-with-prior_discover)), его свежесть — ваша забота: `DiscoverResult` несёт `ttl_ms` и `cache_scope`, уже разобранные, ровно для этого. +* **Страницы продолжения никогда не кэшируются.** Участвуют только вызовы без курсора. Страница продолжения, отклонённая из-за истёкшего курсора, при этом *вытесняет* закэшированный список, потому что список под ней изменился. +* **Многораундовые (multi-round-trip) чтения никогда не кэшируются.** `read_resource`, которому переданы `input_responses`/`request_state`, или тот, что разрешается через раунды ввода, никогда не попадает в кэш (MUST в спецификации). +* **Вытеснению по уведомлениям нужны уведомления.** Вытеснение работает настолько хорошо, насколько транспорт их доставляет, а современный внутрипроцессный путь (`Client(server)` с `mode="auto"` по умолчанию) сегодня не доставляет самостоятельные уведомления. +* **Вытеснение происходит в конечном счёте, а не мгновенно.** Уведомления, пришедшие по сети, диспетчеризуются из порождённых задач, поэтому вызов, состязающийся с приходом уведомления, может ещё раз получить запись до вытеснения; окно ограничено задержкой диспетчеризации, и вытеснение всё равно произойдёт. +* **Нет stale-if-error.** Истёкшая запись никогда не отдаётся из-за того, что повторный запрос завершился ошибкой; ошибка пробрасывается дальше. +* **Нет упреждающего повторного запроса.** Сохранённая запись отдаётся, пока не истечёт её TTL, и следующий вызов после этого платит обращением к серверу; ничего не обновляется в фоне. +* **Нет объединения запросов.** Два одновременных идентичных вызова — это два обращения к серверу. +* **Нет TTL больше 24 часов.** Большее `ttlMs`, присланное сервером или настроенное, урезается при сохранении (`mcp.client.caching.MAX_TTL_MS`), что ограничивает, как долго может отдаваться любая запись, сколь бы щедрой ни была подсказка. +* В **общем хранилище** клиенты состязаются друг с другом. Каждый клиент отбрасывает собственную запись, если вытеснение обогнало запрос в полёте, но клиент-*сосед* всё же может записать обратно запись, удалённую вытеснением, которого он не видел; и сам учёт этих гонок ограничен: после 4096 отслеживаемых ключей первым сбрасывается страж самого старого ключа. Оба окна приняты и закрываются ограничением TTL выше. +* **Нет выдачи между поколениями протокола.** Записи привязаны к согласованной версии протокола: в общем постоянном хранилище сессия никогда не отдаёт запись, сделанную при другой согласованной версии (один и тот же список действительно различается по поколениям, поскольку SDK убирает поля 2026 для более старых сессий). Вытеснение точно так же затрагивает только записи текущего поколения; записи другого поколения просто устаревают по TTL. + +### Чтение подсказок вручную {#reading-the-hints-yourself} + +Подсказки — это ещё и обычные поля каждого кэшируемого результата (`result.ttl_ms` и `result.cache_scope`, уже разобранные), на случай если захочется надстроить собственный учёт поверх встроенного кэша (или вместо него). + +С **более старым сервером** (протокол до 2026) этих полей в передаваемых данных просто нет, и модели показывают консервативные значения по умолчанию: `ttl_ms == 0` и `cache_scope == "private"` — устаревший и неразделяемый, правильное предположение для сервера, который ничего не объявил. Кэш относится к сессии старого поколения так же: подсказки там никогда не учитываются (какие бы ключи ни появились в данных), применяется только `default_ttl_ms`, а его значение по умолчанию `0` ничего не кэширует, так что подключение до 2026 ведёт себя ровно так, как до появления кэша. Если нужно отличить «сервер сказал 0» от «сервер ничего не сказал», проверьте `"ttl_ms" in result.model_fields_set`: оно задано, только когда поле действительно пришло. + +## Более старые клиенты {#older-clients} + +Клиенты на версиях протокола до 2026 никогда не видят ни одного из этих полей; для таких подключений SDK убирает их при сериализации. Настройте подсказки один раз — ничего зависящего от версии писать не нужно. + +## Итоги {#recap} + +* Шесть методов несут `ttlMs`/`cacheScope`; SDK по умолчанию ставит `0`/`"private"` — устаревший и неразделяемый, всегда безопасно. +* `cache_hints={method: CacheHint(...)}` при создании (и `MCPServer`, и `Server`) задаёт общесерверные значения по методам. +* Обработчик, задающий поля в своём результате, переопределяет словарь, поле за полем. +* `"public"` — это обещание, что результат одинаков для каждого вызывающего. Это не управление доступом. +* `Client` учитывает подсказки автоматически: его кэш ответов включён по умолчанию, отдаёт свежие записи вместо повторного запроса и ничего не кэширует для серверов (или сессий), не дающих подсказок. +* Для отдельного вызова `cache_mode="refresh"` запрашивает заново, а `"bypass"` обходит кэш; `cache=None` при создании выключает его совсем. diff --git a/i18n/ru/pages/client/callbacks.md b/i18n/ru/pages/client/callbacks.md new file mode 100644 index 0000000000..c0c29f503a --- /dev/null +++ b/i18n/ru/pages/client/callbacks.md @@ -0,0 +1,154 @@ +--- +translation: + sections: [adf3c545b5be46b6, 916cd3ab1c03f461, e9be7a8d0eb0a456, 565890a636288ecf, 6af7e49db9129ec3, 06b0238c174186af, 90c6043be435fcb0] + tool: 1 +--- +# Колбэки клиента {#client-callbacks} + +Почти все запросы в MCP идут в одну сторону: от клиента к серверу. + +Но и сервер может о чём-то попросить **клиент**: задать вопрос пользователю, попросить модель пользователя сгенерировать ответ, получить список папок его рабочего пространства. На такие запросы отвечают **колбэки**, которые передаются в `Client(...)`. + +## Сервер, который спрашивает {#a-server-that-asks} + +Вот сервер, инструмент которого не может завершиться самостоятельно: + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/client_callbacks/tutorial001.py" +``` + +* `ctx.elicit(...)` отправляет запрос `elicitation/create` **клиенту** и ждёт. +* Инструмент не вернёт результат, пока кто-нибудь (человек через форму или ваш код) не предоставит `name`. + +Это серверная половина, и ей посвящена страница **[Элицитация](../handlers/elicitation.md)**. Эта страница — о другом конце соединения. + +## Колбэк элицитации {#the-elicitation-callback} + +```python title="client.py" hl_lines="6-10 16-17" +--8<-- "docs_src/client_callbacks/tutorial002.py" +``` + +* Колбэк элицитации (elicitation) — это `async (context, params) -> ElicitResult`. +* `params.message` — это вопрос. `params.requested_schema` — JSON Schema ответа, который нужен серверу. Настоящий клиент строит по ней форму; этот заполняет её автоматически. +* Возвращается `ElicitResult(action="accept", content={...})`, либо `action="decline"`, либо `action="cancel"`. Единственный другой вариант — `ErrorData(...)`: он отклоняет запрос, и весь вызов завершается ошибкой. +* `context` — это `ClientRequestContext`: действующая `session`, `request_id` сервера и `meta`, если сервер что-то приложил. + +!!! tip + `params` — объединение двух режимов элицитации. Здесь `params.mode` равен `"form"`; запрос в режиме `"url"` + несёт `params.url` вместо схемы. Один колбэк обрабатывает оба режима; ветвитесь по `params.mode`. + Полный шаблон показан на странице **[Элицитация](../handlers/elicitation.md)**. + +### Попробуйте сами {#try-it} + +Вызовите `issue_card` и проследите за обоими концами. + +Колбэк получает вопрос сервера, уже разобранный: + +```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'} +``` + +Он отвечает, `ctx.elicit(...)` внутри инструмента возобновляется, и инструмент завершается: + +```python +result.content # [TextContent(type='text', text='Card issued to Ada Lovelace.')] +``` + +Один `tools/call` от вас, один встречный `elicitation/create` от сервера, на который ответила ваша функция, — и всё это внутри одного вызова инструмента. + +!!! info + `mode="legacy"` в вызове `Client(...)` стоит не просто так. По умолчанию `Client(...)` согласовывает современный + вариант протокола, а в нём нет обратного канала (back-channel) для запросов от сервера к клиенту: `ctx.elicit` + завершается ошибкой ещё до того, как колбэк успевает запуститься. Решает это не транспорт, а согласованный + протокол — и в памяти, и по URL одинаково. Фиксируйте `mode="legacy"` всякий раз, когда клиент должен + отвечать на такие запросы; так делает каждый тест, стоящий за этой страницей. Подробнее — на странице **[Версии протокола](../protocol-versions.md)**. + + В сессии 2026-07-28 колбэк не бесполезен — просто данные поступают к нему иначе: когда инструмент возвращает + `InputRequiredResult` с `ElicitRequest` внутри, `Client` передаёт эту запись тому же + `elicitation_callback` и повторяет вызов за вас. Этот сценарий описан на странице **[Многораундовые запросы](../handlers/multi-round-trip.md)** (multi-round-trip). + +## Колбэк — это возможность {#a-callback-is-a-capability} + +Вы нигде не сообщали серверу, что клиент умеет отвечать на запросы элицитации. Это сделал SDK. + +При подключении клиент объявляет свои `capabilities` — зеркальное отражение возможностей сервера. Этот объект не нужно писать вручную. **Регистрация колбэка и есть объявление.** + +| вы передаёте | клиент объявляет | +| --- | --- | +| `elicitation_callback=` | `"elicitation": {"form": {}, "url": {}}` | +| `sampling_callback=` | `"sampling": {}` | +| `list_roots_callback=` | `"roots": {"listChanged": true}` | +| ничего из этого | `{}` | + +Единственное уточнение — подвозможности сэмплирования (sampling): передайте `sampling_capabilities=SamplingCapability(tools=SamplingToolsCapability())` вместе с `sampling_callback`, если ваш обработчик сэмплирования поддерживает параметры `tools` / `tool_choice`. Сервер может отправлять их, только когда видит объявленную `sampling.tools`. + +`logging_callback` и `message_handler` в таблице нет. Они обрабатывают уведомления, а уведомлениям возможность не нужна. + +Сервер читает это объявление с помощью `ctx.session.check_client_capability(...)`. Добавьте инструмент, который это делает: + +```python title="server.py" hl_lines="23-31" +--8<-- "docs_src/client_callbacks/tutorial003.py" +``` + +Подключитесь, передав только `elicitation_callback`, и вызовите его: + +```python +result.structured_content # {'result': ['elicitation']} +``` + +Передайте все три колбэка — получите `['elicitation', 'sampling', 'roots']`. Не передавайте ни одного — получите `[]`. + +!!! check + Теперь сделайте неправильно: подключитесь **без** `elicitation_callback` и всё равно вызовите `issue_card`. + + Запрос `elicitation/create` от сервера всё равно доходит до клиента, и SDK отвечает на него за + вас — ошибкой, потому что вы не заявляли, что умеете его обрабатывать. Эта ошибка губит весь вызов. + `call_tool` не возвращает результат с `is_error`, а выбрасывает исключение: + + ```text + MCPError: Elicitation not supported + ``` + + Это ошибка протокола (`-32600`, *invalid request*), а не ошибка инструмента: модели нечего + прочитать и повторить. Вот зачем нужен `client_features`: корректно написанный сервер + проверяет, прежде чем спрашивать. + +## Устаревшая пара {#the-deprecated-pair} + +`sampling_callback` отвечает на `sampling/createMessage`: сервер просит *вашу* модель что-то сгенерировать. `list_roots_callback` отвечает на `roots/list`: сервер спрашивает, в каких каталогах ему можно работать. + +Оба работают. Оба подчиняются правилу выше. И оба обслуживают RPC, которые **спецификация 2026-07-28 удаляет**: современный сервер не обращается к клиенту посреди запроса, а возвращает запрос вам как часть результата инструмента (**[Многораундовые запросы](../handlers/multi-round-trip.md)**). Сами колбэки не бесполезны. Когда `InputRequiredResult` несёт `CreateMessageRequest` или `ListRootsRequest`, автоматический цикл `Client` передаёт его тому же `sampling_callback` или `list_roots_callback`, который вы зарегистрировали здесь. Полный список — на странице **[Устаревшие возможности](../deprecated.md)**. + +Колбэки по-прежнему нужны, чтобы общаться с серверами, которые ещё не перешли. Сигнатуры: + +```python title="client.py" +--8<-- "docs_src/client_callbacks/tutorial004.py" +``` + +* Колбэк сэмплирования получает полный `CreateMessageRequestParams` (`messages`, `model_preferences`, `max_tokens`) и возвращает `CreateMessageResult`. Модель запускаете *вы* — как угодно; SDK лишь доставляет запрос. +* Колбэк корневых каталогов (roots) вообще не принимает параметров и возвращает `ListRootsResult`. +* Любой из них может вместо этого вернуть `ErrorData(...)`, чтобы отказать. + +Передавайте их в `Client(...)` точно так же, как `elicitation_callback`. + +## Колбэки уведомлений {#the-notification-callbacks} + +Ещё два. Ни один ничего не объявляет. + +`logging_callback` получает `notifications/message`, которые отправляет сервер, в виде `LoggingMessageNotificationParams` (`level`, `logger`, `data`). Протокольное логирование само объявлено устаревшим в спецификации 2026-07-28 (что делать вместо него — на странице **[Логирование](../handlers/logging.md)**), так что этот колбэк существует ради серверов, которые всё ещё его отправляют. На подключении поколения 2026 один лишь колбэк ничего не даст, потому что серверы 2026 отправляют сообщения лога только тем запросам, которые явно их запросили: передайте `log_level="info"` (или другой уровень) в `Client(...)`, чтобы проставлять эту отметку на каждом запросе и получать сообщения этого уровня и выше. Серверы до 2026 игнорируют её и сохраняют своё поведение с `logging/setLevel`. + +`message_handler` — обработчик на все случаи: до него доходит каждое уведомление сервера, которое сессия пропускает наружу (помимо его специального колбэка), а на транспорте, работающем поверх потока, — ещё и каждое `Exception` транспортного уровня. Два сообщения туда не попадают никогда: `notifications/cancelled` SDK применяет сам, а не пропускает наружу, а подтверждение подписки для активного потока `listen()` поглощает сам этот поток. Аннотируйте параметр типом `IncomingMessage` (`ServerNotification | Exception`, экспортируется из `mcp.client`). Единственный приём, который стоит знать, — `if isinstance(message, Exception): raise message`, чтобы разорванное соединение падало громко, а не исчезало молча. + +## Итоги {#recap} + +* Сервер может отправлять запросы клиенту. На них отвечают колбэки, переданные в `Client(...)`. +* Колбэк элицитации — актуальный: `async (context, params) -> ElicitResult`, одна функция и для режима формы, и для режима URL. +* **Зарегистрировать колбэк — значит объявить возможность.** Без него SDK отклоняет запрос сервера от вашего имени, и весь вызов завершается ошибкой `MCPError`. +* Сервер узнаёт об этом заранее с помощью `ctx.session.check_client_capability(...)`. +* `sampling_callback` и `list_roots_callback` работают так же, но обслуживают устаревшие возможности; современные серверы вместо этого используют многораундовые запросы. +* `logging_callback` и `message_handler` получают уведомления. Они ничего не объявляют. + +Первый аргумент `Client(...)` — объект транспорта. Все их виды описаны на странице **[Транспорты клиента](transports.md)**. diff --git a/i18n/ru/pages/client/identity-assertion.md b/i18n/ru/pages/client/identity-assertion.md new file mode 100644 index 0000000000..65d82a9089 --- /dev/null +++ b/i18n/ru/pages/client/identity-assertion.md @@ -0,0 +1,156 @@ +--- +translation: + sections: [a91322c46111d16d, 8e6fd6d6f59bb568, e7828fd2729b2c9d, a03ec26bfc678b65, 1034c653c0bcf1b0] + tool: 1 +--- +# Утверждение идентичности {#identity-assertion} + +Обычный OAuth-провайдер (**[OAuth-клиенты](oauth-clients.md)**) начинает с вопроса к MCP-серверу: *какому серверу авторизации тот доверяет?* Он идёт за ответом, куда бы тот ни указывал, а дальше либо человек входит в систему, либо его заменяет заранее выданный общий секрет. + +В корпоративной среде ни то ни другое не должно решаться на уровне отдельного сервера. Там уже работает провайдер идентификации (Okta, Microsoft Entra ID, ваш собственный); пользователь уже вошёл в него сегодня утром; и именно там, в одном месте, служба безопасности хочет решать, кому что доступно. [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990), расширение **Enterprise-Managed Authorization**, переносит это решение туда. IdP подписывает короткоживущий JWT — **Identity Assertion JWT Authorization Grant**, или **ID-JAG**: утверждение о том, что *этот пользователь* через *этот клиент* может обращаться к *этому MCP-серверу*. Клиент обменивает его на обычный токен доступа. Ни браузера, ни экрана согласия, ни динамической регистрации. + +Эта страница — обе стороны этого обмена. Сам MCP-сервер не меняется вовсе: это всё тот же сервер ресурсов со страницы **[Авторизация](../run/authorization.md)**, который проверяет любой пришедший токен. + +## Два запроса токена {#two-token-requests} + +Здесь участвуют две разные инстанции, и различать их по именам — это почти всё, что нужно для понимания этой страницы. **Корпоративный IdP** — провайдер идентификации вашей организации: он знает, кто этот сотрудник, в нём живёт политика доступа, и он выпускает ID-JAG. SDK с ним никогда не общается. **Сервер авторизации MCP** — та же сторона, что и на странице **[Авторизация](../run/authorization.md)**: издатель, названный в метаданных MCP-сервера, тот, кто выпускает токены, которые этот MCP-сервер принимает. В обычном OAuth-сценарии обе роли, как правило, играет одна система. Здесь их две, и весь грант сводится к тому, что вторая соглашается доверять первой. + +Клиент делает по одному запросу токена к каждой. + +1. **К корпоративному IdP.** Клиент обменивает вход пользователя (его ID-токен OpenID Connect) на ID-JAG. Это обмен токенов по [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693), это целиком API вашего IdP, и **SDK этот запрос не делает**. Его делаете вы — внутри одного асинхронного колбэка. Здесь же принимается решение по политике: IdP, который говорит «нет», просто не выпускает ID-JAG, и предъявлять нечего. +2. **К серверу авторизации MCP.** Клиент предъявляет ID-JAG по гранту `jwt-bearer` из [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) (`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`, ID-JAG в параметре `assertion`) и получает токен доступа. **Этот запрос делает SDK**, а приём такого запроса — единственное, что эта страница добавляет к серверу авторизации. + +Всё, что ниже, — о втором запросе: о клиенте, который его отправляет, и о сервере авторизации, который на него отвечает. + +## Клиент {#the-client} + +**`IdentityAssertionOAuthProvider`** находится в модуле `mcp.client.auth.extensions.identity_assertion`. Как и все провайдеры на странице **[OAuth-клиенты](oauth-clients.md)**, это `httpx2.Auth`: создайте экземпляр, передайте его в `auth=`, отдайте `httpx2.AsyncClient` транспорту. + +```python title="client.py" hl_lines="49-50 53-61" +--8<-- "docs_src/identity_assertion/tutorial001.py" +``` + +Читайте снизу вверх. + +* `main()` — стандартная функция `main()` OAuth-клиента (**[OAuth-клиенты](oauth-clients.md)**), не изменённая ни в одной строке. В этом и смысл: как только провайдер создан, дальше по цепочке никто не знает, какой грант дал токен. +* Провайдер принимает то, что другие провайдеры не могут обнаружить сами: `client_id` и `client_secret`, которые кто-то **заранее зарегистрировал** на сервере авторизации, `issuer` этого сервера авторизации и `assertion_provider` — асинхронный колбэк, возвращающий свежий ID-JAG по требованию. +* `storage` — тот же протокол `TokenStorage`. Вызываются только два метода для токенов; динамической регистрации здесь нет, так что и запоминать `client_info` незачем. + +### Провайдер утверждения {#the-assertion-provider} + +`fetch_id_jag(audience, resource)` — единственный код, который вы пишете. Он вызывается один раз на каждый обмен токенов, никогда — при создании провайдера, и только *после* того, как метаданные сервера авторизации получены и проверены, так что неверно настроенный издатель никогда не приведёт к утечке утверждения. Два его аргумента — это два из полей, с которыми должен быть выпущен ID-JAG: `audience` — издатель сервера авторизации (поле `aud` в ID-JAG), а `resource` — канонический идентификатор MCP-сервера (поле `resource` в ID-JAG). Третье у вас уже есть: поле `client_id` в ID-JAG должно указывать тот `client_id`, который вы передали провайдеру, иначе сервер авторизации откажет в обмене. + +`idp_issue_id_jag` над ней — **не ваш код**. Эта функция замещает провайдер идентификации и подписывает утверждение прямо в процессе, чтобы файл был самодостаточным и можно было прочитать каждое поле, которое несёт ID-JAG. Настоящая `fetch_id_jag` вместо этого делает первый запрос токена из предыдущего раздела: обмен токенов по [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) с вашим IdP, определённый черновиком Identity Assertion JWT Authorization Grant, профиль которого задаёт [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990). ID-токен вошедшего пользователя передаётся как `subject_token`, `requested_token_type` — это собственный URN ID-JAG (`urn:ietf:params:oauth:token-type:id-jag`), `audience` и `resource` проходят насквозь без изменений, а ответ содержит ID-JAG. Именно этот обмен, под этими именами, и нужно искать в документации вашего IdP. + +!!! tip + Свежий ID-JAG запрашивается для каждого обмена, и в этом весь смысл: это одноразовый грант, + живущий считаные минуты, и сервер авторизации на этой странице отказывается принимать один + и тот же дважды. Не кэшируйте его. Повторно используется токен доступа, который вы на него + покупаете. + +### Издатель задаётся в конфигурации {#the-issuer-is-configuration} + +Вот где всё переворачивается. `OAuthClientProvider` спрашивает сервер ресурсов, какой сервер авторизации использовать, и идёт за ответом, куда бы тот ни указывал. Этот провайдер так не делает: `issuer` обязателен, метаданные [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) запрашиваются по собственному пути well-known этого издателя, конечная точка токенов должна иметь тот же origin, что и издатель, а сервер ресурсов вообще ни о чём не спрашивают. + +Расширение этого не требует; это сознательно более строгий выбор. У этого клиента есть две вещи, которые стоит украсть: заранее зарегистрированный секрет и утверждение, привязанное к аудитории, — и клиент, позволивший скомпрометированному MCP-серверу направить себя на сервер авторизации злоумышленника, отправил бы туда и то и другое. Закрепление издателя при создании провайдера исключает этот разговор вовсе. + +!!! warning + Настроенный `issuer` сравнивается с полем `issuer` документа метаданных простым сравнением + строк по RFC 8414 §3.3: символ в символ, включая завершающую косую черту, без нормализации. + Не угадывайте его. Запросите `/.well-known/oauth-authorization-server` у своего сервера + авторизации и скопируйте значение `issuer`, которое он вернёт. Для сервера авторизации на этой + странице это `https://auth.example.com/`, с косой чертой, потому что его издатель построен из + URL-объекта pydantic. Несовпадение останавливает процесс на `OAuthFlowError: Authorization server metadata issuer + mismatch` ещё до отправки каких-либо учётных данных или утверждения. + +### Конфиденциальный клиент {#a-confidential-client} + +`client_secret` обязателен; без него конструктор выбрасывает `ValueError`. Профиль IETF, лежащий в основе [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990), оставляет этот грант только конфиденциальным клиентам, SEP-990 требует, чтобы клиент аутентифицировался, а этот SDK обеспечивает и то и другое, настаивая на общем секрете. `token_endpoint_auth_method` выбирает, где он передаётся: `client_secret_post` (по умолчанию, в теле формы) или `client_secret_basic` (заголовок HTTP Basic). Профиль допускает ещё `private_key_jwt`; этот провайдер его не поддерживает. + +!!! tip + Читайте `client_secret` из переменных окружения или менеджера секретов и никогда — из + системы контроля версий. + +### Что провайдер делает за вас {#what-the-provider-does-for-you} + +Первый запрос уходит без аутентификации, и ответ сервера `401` запускает процесс. + +1. **Обнаружение.** Провайдер получает метаданные сервера авторизации по пути well-known [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) настроенного издателя, проверяет, что `issuer` в документе совпадает, и проверяет, что конечная точка токенов имеет тот же origin, что и издатель. +2. **Утверждение.** Он вызывает ваш `assertion_provider` и дожидается результата. +3. **Обмен.** Он отправляет POST-запрос с грантом `jwt-bearer` на конечную точку токенов, сохраняет `OAuthToken` и повторяет ваш исходный запрос с заголовком `Authorization: Bearer ...`. + +Ответ `403`, в `WWW-Authenticate` которого указано `insufficient_scope`, повторяет шаги 2 и 3 с объединением вашего `scope` и запрошенного в этом ответе. (`scope` — всегда лишь просьба; сервер авторизации с этой страницы выдаёт то, что сказано в ID-JAG, и ничего больше.) Токена обновления здесь нет нигде: когда токен доступа истекает, следующий `401` приводит к выпуску свежего ID-JAG и новому обмену — и *это* тот рычаг, который держит в руках IdP. Ошибки — те же два исключения, что и на остальной странице **[OAuth-клиенты](oauth-clients.md)**: `OAuthFlowError` для обнаружения и проверки и его подкласс `OAuthTokenError`, когда конечная точка токенов отвечает отказом. + +## Сервер авторизации {#the-authorization-server} + +Чаще всего на этом можно остановиться. Сервер авторизации MCP — чей-то чужой продукт, приём ID-JAG — настройка, которую нужно включить в нём, а половина [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990), которую реализует SDK, — это описанный выше клиент. + +SDK может и сам *быть* сервером авторизации: `create_auth_routes` возвращает маршруты сервера авторизации списком, который может смонтировать любое Starlette-приложение, — именно так его запускает `examples/servers/simple-auth/` в репозитории. SEP-990 добавляет к этой поверхности один флаг и один метод: + +```python title="auth_server.py" hl_lines="48-50 105-107" +--8<-- "docs_src/identity_assertion/tutorial002.py" +``` + +* `identity_assertion_enabled=True` открывает всё остальное. Когда флаг выключен (а по умолчанию это так), `/token` отвечает на этот грант `unsupported_grant_type`, даже если вы реализовали хук, и метаданные о нём не упоминают. Когда включён, в метаданных появляется тип гранта `jwt-bearer`, а в `authorization_grant_profiles_supported` — поле, через которое расширение объявляет о поддержке, — указывается `urn:ietf:params:oauth:grant-profile:id-jag`. (Клиент этого SDK его никогда не читает: он настроен на одного издателя и просто делает запрос.) +* **`exchange_identity_assertion`** — это и есть хук. К моменту его запуска SDK уже аутентифицировал клиент, отклонил публичные клиенты и отклонил клиенты, в регистрации которых этот грант не указан. Вы получаете `IdentityAssertionParams` (сырое `assertion`, запрошенные `scopes` и `resource`) и возвращаете обычный `OAuthToken`. +* Динамическая регистрация клиентов отклоняет этот грант безусловно, поэтому `get_client` здесь отдаёт клиент, заведённый вручную. Клиент ID-JAG не может появиться, зарегистрировав сам себя. +* Половина класса — отказы. `OAuthAuthorizationServerProvider` — это *весь* сервер авторизации, поэтому он требует и сценарий с кодом авторизации; сервер, который ещё и выполняет вход пользователей, реализует эти методы по-настоящему, а у этого ровно одна дверь. + +!!! warning + SDK никогда не декодирует утверждение: только ваше развёртывание знает, какому IdP оно + доверяет и какие ключи этот IdP публикует, поэтому на всём, что внутри + `exchange_identity_assertion`, держится безопасность. Проверяйте подпись по опубликованным + ключам IdP (его JWKS; общий секрет здесь — только для демонстрации), а также `iss` и `exp`, + согласно [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) §3. Требуйте, чтобы `typ` в заголовке JWT был + `oauth-id-jag+jwt` — это защита профиля от того, чтобы какой-нибудь другой JWT был повторно + предъявлен как грант. Требуйте, чтобы `aud` был вашим собственным издателем. Требуйте, чтобы + поле `client_id` в ID-JAG совпадало с тем клиентом, что был аутентифицирован обработчиком, а + поле `resource` называло ресурс, который вы действительно обслуживаете. Отслеживайте `jti` + до наступления `exp` утверждения, чтобы оно принималось лишь однажды. И берите выданные + области доступа и, главное, `resource` выпускаемого токена из проверенного ID-JAG, а не из + запроса: `params.resource` — это то, что ввёл клиент. Полные правила обработки — в + [спецификации Enterprise-Managed Authorization](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization). + +Некорректное утверждение отклоняйте через `TokenError("invalid_grant", ...)`. Второй код ошибки в этом сценарии — `invalid_target`: им отклоняется ID-JAG, называющий ресурс, который вы не обслуживаете, — именно это не даёт серверу выпускать токены для чужих ресурсов. А выданные области доступа берутся из поля `scope` ID-JAG (утверждение без него тоже отклоняется); ваш сервер может вместо этого отображать группы пользователя. + +И обратите внимание, чего в возвращаемом `OAuthToken` нет: токена обновления. IdP решает, как долго пользователь сохраняет доступ, решая, выпускать ли следующий ID-JAG. Выпущенный здесь токен обновления тихо вернул бы это решение обратно. + +!!! info + Сервер, который по-прежнему встраивает свой сервер авторизации через `auth_server_provider=`, + приходит к тому же коду через `AuthSettings(identity_assertion_enabled=True)`. На странице + **[Авторизация](../run/authorization.md)** объясняется, почему новым серверам не стоит с этого + начинать. + +!!! check + Соедините два файла с этой страницы — и весь грант сведётся к одному `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"} + ``` + + Ни `/authorize`, ни `/register`, ни запроса метаданных защищённого ресурса. По сети проходят + только запрос, получивший `401`, запрос well-known, этот обмен, а затем обычный MCP-трафик с + приложенным bearer-токеном. А `sub`, который ваш валидатор прочитал из ID-JAG, — ровно то, + что `get_access_token().subject` сообщает внутри инструмента. + +### Попробуйте сами {#try-it} + +`examples/stories/identity_assertion/` в репозитории SDK — это эта страница в действии: тот же валидатор `exchange_identity_assertion`, MCP-сервер, закрытый его токенами, IdP-заглушка и клиент — в одной самопроверяющейся программе. Команда `uv run python -m stories.identity_assertion.client --http` прогоняет весь обмен и проверяет, что пользователь, которого назвал IdP, — тот же, кого видит инструмент. + +## Итоги {#recap} + +* [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) позволяет корпоративному провайдеру идентификации, а не конечному пользователю, решать, к каким MCP-серверам может обращаться клиент. IdP закрепляет это решение подписью в **ID-JAG**. +* Получение ID-JAG — это обмен токенов по [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) с *вашим IdP*, и SDK его не делает. Предъявление его серверу авторизации MCP — грант `jwt-bearer` из [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523), и тут SDK реализует обе стороны. +* `IdentityAssertionOAuthProvider` — ещё один `httpx2.Auth`: заранее зарегистрированный конфиденциальный клиент, закреплённый `issuer` и один колбэк `assertion_provider(audience, resource)`. Ни браузера, ни регистрации, ни токена обновления. +* Сервер авторизации никогда не обнаруживается через сервер ресурсов. Задайте `issuer` в точности той строкой, которую отдаёт его документ метаданных; сравнение идёт символ в символ. +* На стороне сервера — `identity_assertion_enabled=True` плюс `exchange_identity_assertion`. SDK аутентифицирует клиент и ограничивает доступ к гранту; проверка ID-JAG целиком на вас, а выпущенный токен привязан к `resource` из ID-JAG, а не из запроса. + +Единственная сторона, которой эта страница так и не коснулась, — MCP-сервер. То, что он делает с только что выпущенным вами токеном, он уже делал на странице **[Авторизация](../run/authorization.md)**. diff --git a/i18n/ru/pages/client/index.md b/i18n/ru/pages/client/index.md new file mode 100644 index 0000000000..dd494c182d --- /dev/null +++ b/i18n/ru/pages/client/index.md @@ -0,0 +1,217 @@ +--- +translation: + sections: [ebef1e7a0df854f4, a4c687d3d627d516, 8e79141fc2985342, b345dd05b9c3c7ab, 80ce41579825a6fa, 5f0fa90494de8f65, 83d10514eaa62fa5, 9190555aa39a5d28, 84a4c9d8bf14dddb, 927d71cf40b58c30] + tool: 1 +--- +# Объект Client {#the-client} + +Через **`Client`** программа на Python общается с MCP-сервером. + +Это один объект с одним жизненным циклом: создать его, войти в `async with`, вызывать методы. Каждая операция протокола (получить список инструментов, вызвать один из них, прочитать ресурс, отрендерить промпт) — это `async`-метод этого объекта, возвращающий типизированный результат. + +## Первый клиент {#your-first-client} + +```python title="client.py" hl_lines="14-18" +--8<-- "docs_src/client/tutorial001.py" +``` + +Сервер в начале файла нужен лишь для того, чтобы было к чему подключаться. Клиент — это пять выделенных строк. + +* `Client(mcp)` получает **сам объект сервера**. Это транспорт в памяти: ни подпроцесса, ни порта, ни HTTP. Именно так подключается каждый пример на этой странице и каждый тест, который вы напишете. +* `async with` — это **жизненный цикл**. Вход в блок подключает и согласовывает возможности; выход — отключает. Пары `connect()` / `close()` нет, и `Client` нельзя использовать повторно после завершения блока. +* Внутри блока сведения о подключении уже доступны как обычные свойства. + +### Что можно передать в `Client` {#what-you-can-pass-to-client} + +`Client` принимает один позиционный аргумент и определяет транспорт по его типу: + +* Экземпляр `MCPServer` (или низкоуровневого `Server`): подключение **внутри процесса**. +* Строка с URL (`Client("http://localhost:8000/mcp")`): Streamable HTTP, основной вариант для реального развёртывания. +* **Транспорт**: всё, что можно использовать как `async with ... as (read, write)`, например `stdio_client(...)`, оборачивающий подпроцесс. + +Всё остальное на этой странице одинаково для всех трёх вариантов. Заголовкам, подпроцессам, тайм-аутам и протоколу `Transport` посвящена отдельная страница: **[Транспорты клиента](transports.md)**. + +### Что есть у подключённого клиента {#whats-on-a-connected-client} + +Четыре свойства только для чтения, заполняемые в момент входа в блок: + +* `client.server_info`: сведения о сервере или `None` для сервера поколения 2026, который их не сообщает (серверы на python-sdk по умолчанию сообщают). Здесь `server_info.name` — `"Bookshop"`, а `server_info.version` — то, что сообщает сервер. +* `client.server_capabilities`: что умеет сервер (`tools`, `resources`, `prompts`, `completions`, ...). Возможность, которой у сервера нет, равна `None`. +* `client.protocol_version`: версия протокола, о которой договорились стороны. Здесь это `"2026-07-28"`. +* `client.instructions`: строка `instructions=` сервера или `None`, если сервер её не задал. + +Версию протокола вы нигде не выбирали. По умолчанию `Client` зондирует сервер и на старых серверах переходит к классическому рукопожатию, так что один клиент работает с сервером любого поколения. Если этим нужно управлять, подробнее — на странице **[Версии протокола](../protocol-versions.md)**. + +!!! tip + `client.session` — это лежащий в основе `ClientSession`, низкоуровневый запасной выход. + Ни для чего на этой странице он не понадобится. + +## Получение списка инструментов {#listing-tools} + +```python title="client.py" hl_lines="15-20" +--8<-- "docs_src/client/tutorial002.py" +``` + +`list_tools()` возвращает `ListToolsResult`; инструменты лежат в `.tools`. Каждый из них — полное определение, которое хост передал бы модели: + +```python +tool.name # 'search_books' +tool.title # 'Search the catalog' +tool.description # 'Search the catalog by title or author.' +``` + +а `tool.input_schema` — это JSON Schema, которую сервер вывел из аннотаций типов функции: + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"default": 10, "title": "Limit", "type": "integer"} + }, + "required": ["query"], + "title": "search_booksArguments" +} +``` + +Этой схемы достаточно и интерфейсу, чтобы отрисовать форму аргументов, и модели, чтобы сформировать корректные аргументы. + +!!! tip + `title` необязателен, поэтому интерфейсу, показывающему инструменты человеку, приходится выбирать: `title`, если он есть, + иначе `name`. `from mcp.shared.metadata_utils import get_display_name` делает именно это — + для инструментов, ресурсов, шаблонов ресурсов и промптов. + +## Вызов инструмента {#calling-a-tool} + +`call_tool(name, arguments)` запускает инструмент и возвращает `CallToolResult`. + +```python title="client.py" hl_lines="26-33" +--8<-- "docs_src/client/tutorial003.py" +``` + +Серверный `lookup_book` возвращает Pydantic-модель `Book`. Вот что видит клиент: + +```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 +``` + +Одно возвращаемое значение, три поля для чтения. У каждого свой потребитель. + +### `content`: что читает модель {#content-what-the-model-reads} + +`content` — это `list` **блоков содержимого**, а блок содержимого — объединение типов: `TextContent`, `ImageContent`, `AudioContent`, `ResourceLink` или `EmbeddedResource`. Инструмент может вернуть несколько блоков, причём разных видов. + +Поэтому `main` сужает тип с помощью `isinstance(block, TextContent)`, прежде чем обращаться к `block.text`. Обратите внимание: вне `isinstance` обращения к `.text` нет — проверка типов этого не допустит, потому что у `ImageContent` есть `.data`, а не `.text`. Объединение честно описывает, что инструмент вправе прислать; код должен быть столь же честен. + +### `structured_content`: что читает приложение {#structured_content-what-your-application-reads} + +`structured_content` — это возвращаемое значение инструмента в виде JSON, соответствующего объявленной `output_schema` инструмента. Никакого разбора строк, никаких догадок. + +Когда есть и то и другое, они намеренно говорят одно и то же дважды: `content` — для модели, `structured_content` — для кода. Откуда берётся структурированная часть и как ею управлять — на странице **[Структурированный вывод](../servers/structured-output.md)**. + +### `is_error`: завершился ли инструмент ошибкой {#is_error-whether-the-tool-failed} + +Инструмент, выбросивший исключение, **не** выбрасывает его в клиенте. Он возвращается обычным результатом с `is_error=True`. + +!!! check + Запросите у `lookup_book` `"Solaris"` (название, которого нет в каталоге), и функция выбросит + `ValueError`. Вызов всё равно завершится нормально: + + ```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 + ``` + + Сообщение исключения попало в `content`, где **модель** может его прочитать и попробовать снова. Так + и задумано: ошибка инструмента — часть диалога, а не крах. Всегда проверяйте `is_error`, + прежде чем доверять `structured_content`. + +!!! warning + `is_error=True` покрывает не только ваш собственный `raise`. Запросите инструмент, которого у сервера вообще нет + (`call_tool("does_not_exist", {})`), — и исключения не будет. Вернётся та же структура: + `is_error=True` с `Unknown tool: does_not_exist` в `content`. Метод `Client` выбрасывает + `MCPError` только тогда, когда сервер отвечает **ошибкой** JSON-RPC вместо результата, а + когда сервер выдаёт одно, а когда другое, описано на странице **[Обработка ошибок](../servers/handling-errors.md)**. + +## Ресурсы {#resources} + +Операции с ресурсами идут парами: два способа получить список и один способ прочитать. + +```python title="client.py" hl_lines="22-31" +--8<-- "docs_src/client/tutorial004.py" +``` + +* `list_resources()` возвращает **конкретные** ресурсы — те, у которых фиксированный URI. Здесь: `['catalog://genres']`. +* `list_resource_templates()` возвращает **параметризованные**. Здесь: `['catalog://genres/{genre}']`. Это два разных списка, потому что шаблон нельзя прочитать, пока его не заполнить. +* `read_resource(uri)` принимает URI обычной строкой `str` и работает с обоими: передайте `"catalog://genres/poetry"`, и сервер сопоставит его с шаблоном. + +`read_resource` возвращает `contents` — список `TextResourceContents` или `BlobResourceContents`. Та же идея, что и с содержимым инструмента: сузьте тип через `isinstance`, затем читайте `.text` (или `.blob`). + +Клиент может также узнавать об изменениях ресурса. На подключениях поколения 2025 это `subscribe_resource(uri)` / `unsubscribe_resource(uri)` — пара методов, которую `MCPServer` не реализует, поэтому в протоколе 2026-07-28 (где этих операций больше нет) запрос получает в ответ `-32601`, *Method not found*. Замена в поколении 2026 — поток `subscriptions/listen`, который `MCPServer` *как раз* обслуживает (`server_capabilities.resources.subscribe` там равно `True`), а как читать его через `client.listen(...)` — на странице **[Подписки](subscriptions.md)** этого раздела. + +## Промпты {#prompts} + +```python title="client.py" hl_lines="15-20" +--8<-- "docs_src/client/tutorial005.py" +``` + +`list_prompts()` сообщает, что предлагает сервер и что нужно каждому промпту: + +```python +prompt.name # 'recommend' +prompt.title # 'Recommend a book' +prompt.arguments # [PromptArgument(name='genre', required=True)] +``` + +`get_prompt(name, arguments)` рендерит его. Словарь аргументов имеет вид `str -> str`: аргументы промпта всегда строки. Результат — `messages`, список `PromptMessage`, у каждого есть `role` и блок `content`: + +```python +message.role # 'user' +message.content # TextContent(type='text', text='Recommend one poetry book from the catalog and say why.') +``` + +Хост передаёт эти сообщения прямо модели. Вот и вся возможность. + +## Автодополнение {#completions} + +Сервер с обработчиком автодополнения может дополнять аргументы промптов и шаблонов ресурсов по мере того, как пользователь их вводит. + +```python title="client.py" hl_lines="27-31" +--8<-- "docs_src/client/tutorial006.py" +``` + +* `ref` указывает, *какой* промпт или шаблон заполняется: `PromptReference` или `ResourceTemplateReference`. +* `argument` — это `{"name": ..., "value": ...}`: аргумент и то, что пользователь уже успел набрать. + +Ответ лежит в `result.completion.values`. Наберите `"p"` — и сервер вернёт `['poetry']`. Серверная сторона и то, как обработчик использует *другие*, уже заполненные аргументы, чтобы сузить подсказки, — на странице **[Автодополнение](../servers/completions.md)**. + +## Пагинация {#pagination} + +Каждый метод `list_*` принимает именованный аргумент `cursor=`, а каждый результат содержит `next_cursor`. Когда `next_cursor` равен `None`, у вас есть всё. + +```python title="client.py" hl_lines="22-30" +--8<-- "docs_src/client/tutorial007.py" +``` + +Этот цикл корректен для любого сервера. `MCPServer` возвращает всё одной страницей, так что `next_cursor` равен `None` и цикл выполняется один раз — поэтому в большинстве программ его и не пишут. О серверах, которые действительно разбивают выдачу на страницы, и о правилах, которым подчиняются курсоры, — на странице **[Пагинация](../advanced/pagination.md)**. + +## В тестах {#in-tests} + +`Client(mcp)` без процесса и без порта — уже готовая тестовая обвязка для сервера. + +Для этого есть один специальный флаг конструктора: `Client(mcp, raise_exceptions=True)`. Он действует только на подключениях в памяти, а объясняет его и строит вокруг него весь подход страница **[Тестирование](../get-started/testing.md)**. + +## Итоги {#recap} + +* `Client(x)` подключается в памяти к объекту сервера, по Streamable HTTP — к строке с URL, а ко всему остальному — через транспорт. +* `async with` — это весь жизненный цикл. Внутри него `server_capabilities` и `protocol_version` уже заполнены; `server_info` и `instructions` — тоже, если сервер их предоставляет. +* `list_tools()` даёт `name`, `title`, `description` и `input_schema` каждого инструмента. +* `call_tool()` возвращает `content` для модели, `structured_content` для кода и `is_error`. Инструмент, выбросивший исключение, — это результат, а не исключение. +* `content` — объединение типов блоков; сужайте тип через `isinstance` перед чтением. +* `list_resources` / `list_resource_templates` / `read_resource`, `list_prompts` / `get_prompt` и `complete` замыкают набор операций. +* Каждый `list_*` принимает `cursor=`; крутите цикл, пока `next_cursor` не станет `None`. + +То, о чём сервер может попросить *клиент*, и как на это отвечать, — на странице **[Колбэки клиента](callbacks.md)**. diff --git a/i18n/ru/pages/client/oauth-clients.md b/i18n/ru/pages/client/oauth-clients.md new file mode 100644 index 0000000000..3af177a2fa --- /dev/null +++ b/i18n/ru/pages/client/oauth-clients.md @@ -0,0 +1,154 @@ +--- +translation: + sections: [c6899d3892bd9fa0, 79372cff3cc48a88, 63878d29e87c3e73, 13175843d3588af4, e7e2b9fd516f77de, 758f06399b513c1f, a05d7278487d610b] + tool: 1 +--- +# OAuth-клиенты {#oauth-clients} + +Некоторые MCP-серверы защищены. Отправьте им запрос без токена — и в ответ придёт `401 Unauthorized`. + +**`OAuthClientProvider`** — это способ получить токен. Это вовсе не объект MCP. Это `httpx2.Auth`, стандартный хук httpx2 для задачи «сделать что-то с каждым запросом». Его подключают к `httpx2.AsyncClient`, передают этот клиент транспорту Streamable HTTP — и больше о нём не думают. + +Эта страница — о клиентской стороне. Как заставить собственный сервер требовать токен, описано на странице **[Авторизация](../run/authorization.md)**. + +## Провайдер {#the-provider} + +```python title="client.py" hl_lines="44-54" +--8<-- "docs_src/oauth_clients/tutorial001.py" +``` + +Ему передают четыре вещи: + +* `server_url`: конечная точка MCP, к которой вы подключаетесь. Всё остальное провайдер выясняет по ней сам. +* `client_metadata`: то, что вы ввели бы в форму «зарегистрировать приложение» на сервере авторизации. +* `storage`: место, где токены хранятся между запусками. +* `redirect_handler` и `callback_handler`: два момента, когда участвует человек. + +Больше нигде в файле OAuth не упоминается. `main()` токена не видит вовсе. + +### Метаданные клиента {#client-metadata} + +`OAuthClientMetadata` — это настоящий регистрационный документ из [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591), оформленный как модель Pydantic. + +Вы задаёте три поля. Остальное заполняют значения по умолчанию: `grant_types` уже равно `["authorization_code", "refresh_token"]`, а `response_types` — `["code"]`, и это ровно тот сценарий, который выполняет провайдер. + +!!! check + Поскольку это модель Pydantic, она проверяется **ещё до того, как хоть один байт уйдёт в сеть**. + Опустите `redirect_uris` — и создание объекта тут же завершится ошибкой `ValidationError`, + в которой названо поле: + + ```text + redirect_uris + Field required [type=missing, input_value={'client_name': 'Bookshop Agent'}, input_type=dict] + ``` + + Браузер не открылся, и на сервере авторизации не осталось недоделанной регистрации. + +### Хранилище токенов {#token-storage} + +**`TokenStorage`** — это `Protocol` с четырьмя асинхронными методами. Наследоваться ни от чего не нужно: напишите эти методы — и любой класс станет хранилищем токенов: + +* `get_tokens` / `set_tokens` хранят `OAuthToken`: токен доступа, токен обновления, срок действия, область доступа (scope). +* `get_client_info` / `set_client_info` хранят `OAuthClientInformationFull`, который сервер авторизации выдал, когда провайдер вас зарегистрировал, — включая ваш `client_id`. + +Версия в памяти из примера выше работает. Но при завершении процесса она всё забывает, так что при следующем запуске вся процедура повторяется с начала. Сохраняйте данные в файл или в связку ключей вашей платформы — и следующий запуск пройдёт тихо. + +!!! tip + Сохраняйте `client_info`, а не только токены. Провайдер проходит динамическую регистрацию + в первый раз, когда не находит сохранённого `client_info`. Выбросьте его — и при каждом + запуске будет создаваться новая регистрация. + +### Два обработчика {#the-two-handlers} + +Сценарию с кодом авторизации человек нужен ровно один раз: кто-то должен войти в систему и нажать «разрешить». + +* **`redirect_handler`** асинхронно вызывается с полностью собранным URL авторизации. `client_id`, `redirect_uri`, `state` и PKCE challenge в нём уже есть. Ваша единственная задача — открыть его в браузере. Настольное приложение вызывает `webbrowser.open`; в этом файле URL просто печатается. +* **`callback_handler`** вызывается следующим. Он ждёт, пока пользователь вернётся на ваш `redirect_uri`, и возвращает параметры запроса из этого перенаправления в виде `AuthorizationCodeResult`. + +Настоящий клиент вместо вызова `input()` поднимает небольшой локальный HTTP-сервер на URI перенаправления. Схема та же: принять перенаправление, вернуть `code`, `state` и `iss`. + +!!! warning + Передавайте `state` и `iss` ровно в том виде, в каком они пришли. Провайдер сравнивает + `state` со значением, которое сгенерировал сам, а `iss` — с издателем, которого обнаружил, + и при несовпадении отказывает. Это защита от CSRF и от подмены сервера (mix-up). + +### Подключение к `Client` {#into-the-client} + +Посмотрите на `main()`. Провайдер подключается к **клиенту httpx2**, клиент httpx2 передаётся в `streamable_http_client(url, http_client=...)`, а этот транспорт — в `Client`. + +У `streamable_http_client` нет именованного параметра `auth=`. Всё, что относится к уровню HTTP (аутентификация, заголовки, тайм-ауты, прокси), настраивается на `httpx2.AsyncClient`, который вы приносите сами. Об этом разделении уровней — на странице **[Транспорты клиента](transports.md)**. + +## Что провайдер делает за вас {#what-the-provider-does-for-you} + +Когда `Client` отправляет первый запрос, сервер отвечает `401`. Дальше действует провайдер: + +1. **Обнаружение.** Он читает заголовок `WWW-Authenticate`, загружает метаданные защищённого ресурса (Protected Resource Metadata) сервера с `/.well-known/oauth-protected-resource`, узнаёт, какой сервер авторизации защищает этот ресурс, и загружает метаданные уже *того* сервера. +2. **Регистрация.** В хранилище пусто? Он динамически регистрирует вас с вашим `OAuthClientMetadata` и сохраняет результат. +3. **Авторизация.** Он генерирует пару PKCE и `state`, собирает URL авторизации, ждёт ваш `redirect_handler`, а затем ждёт от `callback_handler` код. +4. **Обмен.** Он обменивает код на `OAuthToken`, сохраняет его и повторяет исходный запрос уже с `Authorization: Bearer ...`. + +После этого он работает незаметно. Токены берутся из хранилища, истёкший токен доступа обновляется с помощью токена обновления, и только когда ничего из этого не срабатывает, сценарий запускается заново. + +Ничего из этого вы не писали. Остаются два именованных аргумента (`client_metadata_url` и `validate_resource_url`), и этому файлу не нужен ни один из них. О `client_metadata_url` стоит знать — ему посвящён отдельный раздел ниже. + +### Попробуйте сами {#try-it} + +Большинство примеров в этой документации можно проверить с `Client(server)` в памяти. Этот — нет: весь смысл сценария в HTTP-ответе `401`, а между клиентом в памяти и его сервером никакого HTTP нет. + +В репозитории есть живая версия. `examples/servers/simple-auth/` запускает отдельный сервер авторизации и защищённый MCP-сервер; `examples/clients/simple-auth-client/` — это клиент с этой страницы, выросший в небольшой CLI. В его README — две команды: запустите серверы, запустите клиент, подключив его к ним, — и наблюдайте, как проходят все четыре шага. + +## Client ID Metadata Documents {#client-id-metadata-documents} + +Ревизия спецификации 2026-07-28 объявляет динамическую регистрацию клиентов устаревшей в пользу **Client ID Metadata Documents** (CIMD). Вместо того чтобы отправлять POST-запросом новую регистрацию каждому встреченному серверу авторизации, клиент публикует один JSON-документ о себе по стабильному HTTPS URL — и этот URL *и есть* его `client_id`. Документ загружает сервер авторизации; провайдер его вообще не трогает. + +SDK это уже умеет: передайте URL как `client_metadata_url=` при создании провайдера. Если метаданные сервера авторизации объявляют `client_id_metadata_document_supported: true`, провайдер полностью пропускает запрос `/register`: URL идёт в сценарий как `client_id`, а `client_secret` нет вовсе. Если сервер этого не объявляет (большинство пока не объявляет) или URL не передан, провайдер **молча** откатывается к динамической регистрации, и всё описанное выше работает ровно так, как описано. Сохранённый `client_info` по-прежнему имеет приоритет над обоими вариантами. + +URL должен быть HTTPS и с некорневым путём; всё остальное — `ValueError` при создании, до любого обращения к сети. Поставляемый пример `examples/clients/simple-auth-client/` принимает его в переменной окружения `MCP_CLIENT_METADATA_URL`. + +## Межмашинное взаимодействие {#machine-to-machine} + +Ночное задание, шаг CI, другой сервис. Браузера нет, и нажать «разрешить» некому. Это грант **client credentials**: `client_id` и `client_secret` у вас уже есть, а весь сценарий сводится к конечной точке токенов. + +`ClientCredentialsOAuthProvider` — тот же `httpx2.Auth`, только без человека: + +```python title="client.py" hl_lines="4 27-33" +--8<-- "docs_src/oauth_clients/tutorial002.py" +``` + +Что изменилось: + +* Нет `OAuthClientMetadata`, нет обработчиков. Вы передаёте `client_id` и `client_secret`; провайдер строит вокруг них минимальную регистрацию `client_credentials` и полностью пропускает динамическую регистрацию. +* `scope` — строка с разделением пробелами, формат OAuth для передачи по сети. +* Всё дальше по цепочке идентично: тот же `TokenStorage`, тот же `httpx2.AsyncClient(auth=...)`, тот же `streamable_http_client`. + +По умолчанию секрет передаётся в запросе токена через HTTP Basic auth (`client_secret_basic`). Передайте `token_endpoint_auth_method="client_secret_post"`, чтобы вместо этого поместить его в тело формы. Некоторые серверы авторизации принимают только один из двух способов. + +!!! tip + Читайте `client_secret` из окружения или менеджера секретов, никогда не из системы контроля версий. + +!!! info + Ещё один провайдер находится в `mcp.client.auth.extensions.client_credentials`: + **`PrivateKeyJWTOAuthProvider`** — для клиентов, которые аутентифицируются с помощью JWT + вместо общего секрета (`private_key_jwt`, вариант с парой ключей и workload identity). + Схема та же: создайте экземпляр и передайте его в `auth=`. В том же модуле есть + `SignedJWTParameters` и `static_assertion_provider` — два вспомогательных средства, + которые собирают для него утверждение (assertion). + +Есть ещё одна ситуация без человека: клиент принадлежит организации, чей провайдер удостоверений, а не пользователь, решает, к каким MCP-серверам он может обращаться. Это другой грант со своей моделью доверия и своей страницей — **[Подтверждение идентичности](identity-assertion.md)**. + +## Когда возникает ошибка {#when-it-fails} + +Когда сценарий OAuth идёт не так, провайдер выбрасывает `OAuthFlowError` из `mcp.client.auth`. У него два подкласса. `OAuthRegistrationError` означает, что регистрация не дала пригодного клиента: сервер авторизации отказал в регистрации или всё же зарегистрировал вас, но с учётными данными, которые этот сценарий использовать не может (например, с методом аутентификации, который он не реализует). `OAuthTokenError` означает, что получить токен не удалось: конечная точка токенов ответила отказом, или в сохранённой записи клиента указан метод аутентификации, который этот клиент применить не может, — об этом сообщается при сборке запроса токена, а не после его отправки. Один `except OAuthFlowError:` охватывает обнаружение, регистрацию, авторизацию и обмен. + +Не всё — ошибка сценария. Сеть по-прежнему может подвести; это обычные исключения `httpx2`, и они проходят насквозь без изменений. + +## Итоги {#recap} + +* `OAuthClientProvider` — это `httpx2.Auth`. Подключите его к `httpx2.AsyncClient`, передайте тот в `streamable_http_client(url, http_client=...)` — и `Client` так и не узнает, что был OAuth. +* От вас нужны четыре вещи: URL сервера, `OAuthClientMetadata`, `TokenStorage` и пара обработчиков redirect/callback. +* `TokenStorage` — это `Protocol`: четыре асинхронных метода, без базового класса. Сохраняйте `client_info` наряду с токенами. +* Обнаружение, регистрация (динамическая или через **Client ID Metadata Document**), PKCE, проверки `state` и `iss` и обновление токенов — забота провайдера, а не ваша. +* `ClientCredentialsOAuthProvider` — версия без человека: `client_id` + `client_secret`, без обработчиков, без браузера. +* Любой сбой OAuth — это `OAuthFlowError`; `OAuthRegistrationError` и `OAuthTokenError` — его подклассы. + +Вторая половина этого рукопожатия — как заставить ваш *сервер* требовать токен — на странице **[Авторизация](../run/authorization.md)**. diff --git a/i18n/ru/pages/client/session-groups.md b/i18n/ru/pages/client/session-groups.md new file mode 100644 index 0000000000..7014c96012 --- /dev/null +++ b/i18n/ru/pages/client/session-groups.md @@ -0,0 +1,87 @@ +--- +translation: + sections: [09c857a25a9dc37a, 43bc6a76a243a50e, 0a716022a88768df, 4b7f78042bfcfff7, c112662e61b03315, 58974ba1f489a8b4, d18adbdbb835ea73] + tool: 1 +--- +# Группы сессий {#session-groups} + +`Client` подключается к одному серверу. Настоящим приложениям часто нужно несколько (поисковый сервер, сервер базы данных, внутренний API), и в итоге приходится отдельно вести подключение и список инструментов для каждого. + +**`ClientSessionGroup`** — это один объект, который держит много подключений и сводит всё, что они предоставляют, в единое представление. + +## Два сервера {#two-servers} + +Начнём с двух обычных серверов. Они никак не связаны друг с другом, поэтому оба, естественно, назвали свой инструмент `search`: + +```python title="library_server.py" hl_lines="7" +--8<-- "docs_src/session_groups/tutorial001.py" +``` + +```python title="web_server.py" hl_lines="7" +--8<-- "docs_src/session_groups/tutorial002.py" +``` + +## Одна группа {#one-group} + +Создайте `ClientSessionGroup` и вызовите **`connect_to_server`** по одному разу на каждый сервер: + +```python title="client.py" hl_lines="10-12" +--8<-- "docs_src/session_groups/tutorial003.py" +``` + +* `connect_to_server` принимает параметры транспорта, а не объект сервера: `StdioServerParameters` (из `mcp`), чтобы запустить подпроцесс, или `StreamableHttpParameters` / `SseServerParameters` (из `mcp.client.session_group`) для сервера, который уже слушает по URL. +* `group.tools` — это `dict[str, Tool]` с инструментами всех подключённых серверов. `group.resources` и `group.prompts` устроены так же. +* `group.call_tool(name, arguments)` ищет имя, находит сессию, которой оно принадлежит, и перенаправляет вызов. Какой именно сервер — указывать не нужно. + +!!! check + Положите `client.py` рядом с двумя серверами и запустите его. Второй вызов `connect_to_server` завершится отказом: + + ```text + mcp.shared.exceptions.MCPError: {'search'} already exist in group tools. + ``` + + Это `MCPError`, выброшенное ещё до того, как что-либо от второго сервера было зарегистрировано. Имя должно + быть уникальным в пределах **всей** группы, а два сервера, которые вы не контролируете, рано или поздно столкнутся. + +## `component_name_hook` {#component_name_hook} + +Исправляется это на уровне группы, а не серверов. Передайте функцию от `(name, server_info)`, и группа будет применять её к каждому регистрируемому имени: + +```python title="client.py" hl_lines="7-8 15" +--8<-- "docs_src/session_groups/tutorial004.py" +``` + +Запустите снова. Теперь `print(sorted(group.tools))` показывает оба: + +```text +['Library.search', 'Web.search'] +``` + +* **Ключ** — ваш. `by_server` собрала его из `server_info.name` — имени, с которым был создан каждый `MCPServer(...)`. +* `Tool` внутри не тронут: `group.tools["Web.search"].name` по-прежнему `"search"`, и именно это имя `call_tool` передаёт по сети. Префикс никогда не покидает ваш процесс. +* Это касается не только инструментов. Ресурс `hours` библиотеки зарегистрирован как `Library.hours`. + +!!! tip + Хук применяется к **каждому** имени от **каждого** сервера, а не только при конфликтах: режима + «префикс при столкновении» нет. Выберите одну схему и пусть она действует везде. + +## Добавление и удаление серверов {#adding-and-removing-servers} + +`connect_to_server` возвращает открытую им `ClientSession`. Сохраните её, если когда-нибудь захотите убрать этот сервер: `await group.disconnect_from_server(session)` удаляет его инструменты, ресурсы и промпты из группы. + +Если уже есть подключённая `ClientSession` (например, `Client.session`), передайте её в `await group.connect_with_session(server_info, session)` вместо того, чтобы открывать новый транспорт. Агрегирование работает так же. Группа никогда не закрывает сессию, которую открыла не она. `server_info` задаёт имя сервера для префиксов компонентов; на подключении поколения 2026 `client.server_info` может быть `None` (идентификация необязательна), так что в этом случае передайте собственный `Implementation(name=..., version=...)`. + +## Классическое рукопожатие {#the-classic-handshake} + +`ClientSessionGroup` построен на `ClientSession`, а не на `Client`. Каждый вызов `connect_to_server` выполняет классическое рукопожатие `initialize`. Он никогда не отправляет пробный запрос `server/discover`, описанный на странице **[Версии протокола](../protocol-versions.md)**. Это рукопожатие понимает любой MCP-сервер, так что совместимостью вы не жертвуете ни с чем; это лишь значит, что к серверу, который умеет лучше, группа идёт более старым и медленным путём. + +## Итоги {#recap} + +* `ClientSessionGroup` держит много подключений к серверам и сводит их инструменты, ресурсы и промпты в один `dict` для каждого вида. +* `connect_to_server(params)` — по одному на сервер. Принимает параметры транспорта, а не объект сервера или URL, как `Client`. +* `group.call_tool(name, arguments)` сам направляет вызов на сервер-владелец. +* Имена должны быть уникальны в пределах всей группы; два сервера с инструментом `search` сами по себе ужиться не могут. +* `component_name_hook=` переписывает каждое регистрируемое имя. Меняется ключ словаря, но не имя в передаваемых данных. +* `connect_with_session` добавляет сессию, которая у вас уже есть; `disconnect_from_server` удаляет сессию. + +Рукопожатию, на котором говорит группа (и более быстрому, которое предпочитает `Client`), посвящена страница **[Версии протокола](../protocol-versions.md)**. diff --git a/i18n/ru/pages/client/subscriptions.md b/i18n/ru/pages/client/subscriptions.md new file mode 100644 index 0000000000..5376841793 --- /dev/null +++ b/i18n/ru/pages/client/subscriptions.md @@ -0,0 +1,91 @@ +--- +translation: + sections: [8f9558e57f29eee1, a88c587739e0465c, 46ebfd5b325ed041, 4d10b00b57ce4bd9, 2cdb0edd1f59b3e2] + tool: 1 +--- +# Подписки {#subscriptions} + +Каталог сервера не постоянен. Инструменты появляются во время работы, а содержимое по URI ресурса меняется. Клиент узнаёт об этом через `client.listen(...)`: один запрос `subscriptions/listen`, ответ на который и *есть* поток. Он остаётся открытым и несёт те уведомления об изменениях, которые запросил клиент. + +Эта страница — о клиентской стороне: как открыть поток, наблюдать за ним рядом с основной логикой и обрабатывать его завершение. Публикация изменений, фильтрация и обслуживание метода — серверная сторона, о ней рассказано на странице **[Подписки](../handlers/subscriptions.md)** в разделе *Внутри обработчика*. Примеры здесь общаются с сервером спринт-доски, построенным там. + +## Наблюдение за потоком {#watching-the-stream} + +Подписка — это один контекстный менеджер. Вход в него отправляет запрос (именованные аргументы становятся фильтром подписки) и дожидается подтверждения от сервера, так что к началу блока поток уже работает. + +```python title="client.py" hl_lines="15 18 28" +--8<-- "docs_src/subscriptions/tutorial003.py" +``` + +Итерация выдаёт четыре типизированных события: `ToolsListChanged`, `PromptsListChanged`, `ResourcesListChanged` и `ResourceUpdated(uri=...)`. + +Событие говорит, *что* изменилось, но никогда — *как*. Поэтому `follow_board` вызывает `read_resource` и `list_tools`: событие — это сигнал запросить данные заново. Читайте `event.uri`, а не предполагайте, какой ресурс изменился: фильтр может перечислять несколько URI, а сервер может сообщить об изменении подресурса одного из них. + +Дубликаты событий, ожидающих обработки, схлопываются в одно, а повторный запрос всё равно даёт актуальное состояние. Схлопываются только одинаковые события: два `ResourceUpdated` для разных URI — это два события. + +Ещё два свойства дескриптора: + +* `sub.honored` — фильтр, который подтвердил сервер: `SubscriptionFilter` с переданными вами полями, доступными как атрибуты (`sub.honored.prompts_list_changed`). `MCPServer` принимает все виды, которые вы запросили, поэтому возвращает запрос как есть. Сервер, поддерживающий меньше видов, подтверждает меньше, а подтверждённый вид всё равно может ни разу не сработать. Сервер может и отклонить запрос целиком вместо подтверждения (см. [Кому разрешено наблюдать](../handlers/subscriptions.md#deciding-who-may-watch) на странице сервера) — это проявится как ошибка запроса. +* `sub.subscription_id` — идентификатор запроса listen, тот самый, что проставлен на каждом кадре этого потока. Одновременно может быть открыто несколько подписок, и каждая демультиплексируется по своему идентификатору. + +## Наблюдение без блокировки {#watching-without-blocking} + +`follow_board` работает, пока сервер не закроет поток, а этого может не случиться никогда, так что сама по себе она забирает всю программу. Настоящим клиентам наблюдатель нужен *рядом* с основной логикой: агент вызывает инструменты, пока наблюдатель поддерживает актуальность кэша или интерфейса. + +Сначала откройте подписку, затем запустите задачу-наблюдатель и занимайтесь своей работой. + +=== "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` импортирует `BOARD` и `read_board` из первого примера, который в этом репозитории + хранится как `tutorial003.py`. Если вы сохраните показанные файлы рядом под именами `client.py` + и `app.py`, напишите вместо этого `from client import BOARD, read_board`. Пример `watch.py` + ниже импортирует `read_board` так же. + +Всё дело в порядке. Ничего не воспроизводится повторно, поэтому событие, опубликованное до появления потока, теряется. Вход в `client.listen(...)` дожидается подтверждения, так что каждое изменение с этого момента доходит до наблюдателя, и снимок, сделанный внутри блока, не может ни одно пропустить. + +Запросы свободно выполняются рядом с открытым потоком — из задачи-наблюдателя или любой другой, на том же клиенте. Поскольку *одинаковые* необработанные события объединяются, загруженная основная логика может дать один повторный запрос вместо трёх. Разные события не объединяются: фильтр со многими URI ставит в очередь по одному ожидающему событию на каждый URI. + +Чтобы прекратить наблюдение, выйдите из блока: вызова `unsubscribe` нет. Отмена задачи, владеющей блоком, делает это за вас, а SDK отменяет запрос listen так, как того ожидает транспорт: в Streamable HTTP — закрытием потока этого запроса. Наблюдатель, работающий всё время жизни приложения, сам никогда не завершится, поэтому отмените его (или область его группы задач) при завершении работы. + +## Потоки заканчиваются {#streams-end} + +Поток заканчивается одним из двух способов, и оба — штатный ход выполнения. Корректное закрытие сервером завершает `async for`; резкий обрыв выбрасывает `SubscriptionLost`. + +Разница диагностическая, а не в том, что делать дальше: потока больше нет, ничего не воспроизводилось повторно, и наблюдатель, которому это всё ещё нужно, слушает заново и перезапрашивает данные. + +```python title="watch.py" hl_lines="16 20" +--8<-- "docs_src/subscriptions/tutorial005.py" +``` + +Серверы корректно закрывают потоки по своим причинам, в том числе чтобы сбросить подписчика, чья очередь слишком разрослась, поэтому чистое завершение — не сигнал прекратить наблюдение. Перед повторным прослушиванием сделайте паузу. + +У `SubscriptionLost` есть и одна локальная причина. Клиент хранит не более 1024 необработанных событий, и потребитель, отставший настолько, теряет подписку, а не растёт без ограничений. Держите тело `async for` коротким, а медленную работу выполняйте в другом месте. + +`keep_following` перехватывает только `SubscriptionLost`. Вход в `listen()` может также выбросить `MCPError` (сбой подключения или сервер не обслуживает метод), `TimeoutError` (подтверждение не пришло) и `ListenNotSupportedError` (подключение до поколения 2026). Решите, какие из них наблюдателю стоит повторять: последняя не проходит никогда. + +## Итоги {#recap} + +* Входите в `async with client.listen(...)`; вход дожидается подтверждения, поэтому ничего из опубликованного после него не теряется. +* Итерируйте с помощью `async for event in sub`. События — сигналы запросить данные заново, а не сами данные. +* Откройте подписку, затем запустите наблюдатель отдельной задачей — и вызовы инструментов продолжают идти рядом. +* Чистое завершение останавливает цикл; обрыв выбрасывает `SubscriptionLost`. В обоих случаях: слушайте заново, перезапросите данные, но сначала сделайте паузу. +* Выход из блока и есть отписка. + +Публикация этих событий, сужение фильтра и масштабирование за пределы одного процесса — серверная сторона: **[Подписки](../handlers/subscriptions.md)**. Эти же события помогают клиентскому кэшу оставаться актуальным, и следующая страница — **[Кэширование](caching.md)**. diff --git a/i18n/ru/pages/client/transports.md b/i18n/ru/pages/client/transports.md new file mode 100644 index 0000000000..41d6914cf5 --- /dev/null +++ b/i18n/ru/pages/client/transports.md @@ -0,0 +1,128 @@ +--- +translation: + sections: [9cac816674181eb0, 0700f337babcd4dd, 2bde0dd58cdf00f5, ff7401df479af877, 3d0832f39b0d7059, d4bf7e4479637768, 05e20c0a798860e7] + tool: 1 +--- +# Клиентские транспорты {#client-transports} + +Каждый `Client` общается со своим сервером через **транспорт** — то, что на самом деле переносит сообщения. + +Настраивать его отдельно не нужно. `Client` принимает один позиционный аргумент и определяет транспорт по его типу. + +*Серверная* сторона каждого из них (то, что делает `mcp.run()` и что вы развёртываете) описана на странице **[Запуск сервера](../run/index.md)**. + +## В памяти {#in-memory} + +Передайте сам объект сервера: + +```python title="client.py" hl_lines="14" +--8<-- "docs_src/client_transports/tutorial001.py" +``` + +Ни подпроцесса, ни порта, ни байтов в передаваемых данных. Клиент и сервер — два объекта в одном процессе, и вызов всё равно проходит через настоящий протокольный уровень: `search_books` перечисляется, валидируется и вызывается ровно так же, как это было бы по HTTP. + +Поэтому это сразу две вещи: + +* **Тестовый стенд.** Каждый пример в этой документации проверяется именно так, а страница **[Тестирование](../get-started/testing.md)** строит вокруг этого весь подход. +* **API для встраивания.** Приложению, которое создаёт сервер, не нужен сетевой переход, чтобы вызывать его инструменты. + +## Streamable HTTP {#streamable-http} + +Передайте строку с URL — и получите **Streamable HTTP**, транспорт, за которым вы развёртываете сервер: + +```python title="client.py" hl_lines="5" +--8<-- "docs_src/client_transports/tutorial002.py" +``` + +Это уже готовый клиент для продакшена. `Client` сам оборачивает URL в `streamable_http_client(...)` поверх `httpx2.AsyncClient`, настроенного так, как нужно MCP: `follow_redirects=True`, таймаут 30 секунд на connect/write/pool и таймаут чтения 300 секунд, потому что сервер может держать поток ответа открытым. + +!!! check + Созданный `Client` **не** подключён. Конструктор только выбирает транспорт; + открывает его `async with`. Обратитесь к соединению до входа в блок — и SDK сообщит об этом: + + ```text + RuntimeError: Client must be used within an async context manager + ``` + + Когда вы написали `Client("http://...")`, ничего не разрешалось, не загружалось и не запускалось. Эта строка ничего не стоит. + +### Собственный `httpx2.AsyncClient` {#bring-your-own-httpx2asyncclient} + +Как только понадобится заголовок `Authorization`, cookie, прокси, mTLS или другой таймаут, создайте `httpx2.AsyncClient` сами и передайте его в `streamable_http_client`: + +```python title="client.py" hl_lines="8-14" +--8<-- "docs_src/client_transports/tutorial003.py" +``` + +Обратите внимание на две вещи: + +* `httpx2.AsyncClient` принадлежит вам, поэтому входите в него и выходите из него **вы**. SDK никогда не закрывает клиент, который он не создавал. +* `streamable_http_client(url, http_client=...)` возвращает транспорт, а `Client(transport)` принимает его, как и всё остальное. + +Одно замечание о TLS: `httpx2` проверяет сертификаты по хранилищу доверия операционной системы (через +[`truststore`](https://pypi.org/project/truststore/)), а не по встроенному списку CA. В среде без +пригодного системного хранилища CA (некоторые минимальные контейнеры) задайте стандартные +переменные окружения `SSL_CERT_FILE`/`SSL_CERT_DIR` или передайте явный `verify=ssl_context` в свой `httpx2.AsyncClient` +(подробности в разделе +[`httpx` и `httpx-sse` заменены на `httpx2`](../migration.md#httpx-and-httpx-sse-replaced-by-httpx2)). + +!!! warning + Раньше `streamable_http_client` принимал `headers=` и `timeout=` напрямую. Больше не принимает: + его единственные параметры — `url`, `http_client` и `terminate_on_close`. Напишите по привычке `headers=` — + и получите: + + ```text + TypeError: streamable_http_client() got an unexpected keyword argument 'headers' + ``` + + Всё, что относится к HTTP, теперь живёт в одном `httpx2.AsyncClient`, который вы передаёте. + +!!! info + `httpx2` сохраняет привычный API `httpx`, так что, если вы знаете `httpx`, вы уже умеете делать здесь + аутентификацию, прокси, хуки событий, повторные попытки и ограничения соединений. SDK ничего не добавляет сверху + и ничего не убирает. Здесь же подключается OAuth: + `httpx2.AsyncClient(auth=OAuthClientProvider(...))`. Весь этот сценарий — на странице **[OAuth-клиенты](oauth-clients.md)**. + +## stdio {#stdio} + +Сервер **stdio** — это подпроцесс. Клиент запускает его, пишет JSON-RPC в его stdin и читает JSON-RPC из его stdout. Именно так десктопный хост запускает сервер на вашей машине: хост — это *и есть* этот код плюс UI, а страница **[Подключение к реальному хосту](../get-started/real-host.md)** показывает те же отношения со стороны хоста, в виде файла конфигурации. + +Опишите процесс с помощью `StdioServerParameters`, превратите его в транспорт с помощью `stdio_client` и передайте *его* в `Client`: + +```python title="client.py" hl_lines="4-8 12" +--8<-- "docs_src/client_transports/tutorial004.py" +``` + +Сам по себе объект параметров `Client` не принимает. `StdioServerParameters` — это конфигурация; `stdio_client(server)` — транспорт, который умеет запускать по ней процесс. Всегда оборачивайте. + +Выход из блока `async with` заодно завершает подпроцесс: закрывает stdin, ждёт, убивает, если тот задерживается. Убирать за ним самостоятельно не нужно. + +!!! warning + Дочерний процесс **не** наследует ваше окружение. Он получает минимальный разрешённый список (`HOME`, `LOGNAME`, + `PATH`, `SHELL`, `TERM` и `USER` в POSIX), чтобы ничего чувствительного не утекло в процесс, который, + возможно, написали не вы. + + Сервер, которому нужен API-ключ, там его не найдёт. Передайте его явно через `env=`; эти + переменные добавляются поверх разрешённого списка. Именно это делает `BOOKSHOP_API_KEY` выше. + +## SSE {#sse} + +`sse_client(url)` из `mcp.client.sse` — это HTTP-транспорт, который заменил Streamable HTTP. Оборачивайте его так же, `Client(sse_client("http://localhost:8000/sse"))`, чтобы общаться с сервером, который всё ещё на нём говорит, и не стройте на нём ничего нового. + +## Протокол `Transport` {#the-transport-protocol} + +Для `Client` всё перечисленное — одно и то же. + +**Транспорт** — это любой асинхронный контекстный менеджер, который отдаёт пару потоков сообщений `(read, write)`: формально — протокол `Transport` из `mcp.client`. `Client` разрешает свой аргумент по типу: объект сервера подключается внутри процесса, `str` превращается в `streamable_http_client(url)`, а всё остальное используется как транспорт напрямую. Благодаря последнему правилу `stdio_client(...)`, `streamable_http_client(...)` и `sse_client(...)` подходят в одно и то же место — и вы можете написать свой. + +## Итоги {#recap} + +* `Client(mcp)` (объект сервера) подключается в памяти. Используйте для тестов и для встраивания. +* `Client("http://.../mcp")` (URL) подключается по Streamable HTTP, транспорту для продакшена. +* Заголовки, аутентификация, прокси и таймауты задаются на `httpx2.AsyncClient`, который передаётся в `streamable_http_client(url, http_client=...)`. Именованного аргумента `headers=` нет. +* stdio — это `Client(stdio_client(StdioServerParameters(...)))`, и никогда не объект параметров сам по себе. +* Подпроцесс получает окружение из разрешённого списка, а не ваше; `env=` добавляет к нему. +* Транспорт — это всё, с чем можно написать `async with x as (read, write)`. `Client` передаёт всё, что не объект сервера и не URL, прямо в этот протокол. +* Создание `Client` выбирает транспорт. `async with` его открывает. + +Когда транспорт открыт, двум сторонам нужно договориться о версии протокола. Обычно думать об этом не приходится; когда всё-таки придётся, нужная страница — **[Версии протокола](../protocol-versions.md)**. diff --git a/i18n/ru/pages/deprecated.md b/i18n/ru/pages/deprecated.md new file mode 100644 index 0000000000..57c29e1c7e --- /dev/null +++ b/i18n/ru/pages/deprecated.md @@ -0,0 +1,99 @@ +--- +translation: + sections: [20541a40dbdd5980, 01262a123ad9501d, 429db5b574a2ac08, 56b2d49da412cb28, 6a1717123fe4513c] + tool: 1 +--- +# Устаревшие возможности {#deprecated-features} + +Спецификация 2026-07-28 выводит из обращения пять возможностей. SDK по-прежнему реализует каждую из них, и каждая теперь выдаёт **предупреждение об устаревании**. + +В таблице ниже перечислены все устаревшие возможности, причина, по которой каждая уходит, и замена, на которую стоит опираться. + +## Что объявлено устаревшим {#what-is-deprecated} + +| Устарело | Почему | Что делать вместо этого | +|---|---|---| +| **Корневые каталоги (roots)**: `ctx.session.list_roots()`, `client.send_roots_list_changed()`, параметр `list_roots_callback=`, который передаётся в `Client(...)` | [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) выводит возможность из обращения. | Принимайте пути как обычные аргументы инструмента или URI ресурсов либо встраивайте `ListRootsRequest` в `InputRequiredResult` (см. **[Многораундовые запросы (multi-round-trip)](handlers/multi-round-trip.md)**). | +| **Сэмплирование (sampling) по инициативе сервера**: `ctx.session.create_message()`, параметр `sampling_callback=`, который передаётся в `Client(...)` | SEP-2577 выводит возможность из обращения. | Верните `InputRequiredResult` и позвольте клиенту повторить вызов (см. **[Многораундовые запросы](handlers/multi-round-trip.md)**). | +| **Протокольное логирование**: `ctx.log()`, `ctx.debug()`, `ctx.info()`, `ctx.warning()`, `ctx.error()`, `ctx.session.send_log_message()`, `client.set_logging_level()` | SEP-2577 выводит возможность из обращения. Внутри протокола её ничто не заменяет. | Обычный `import logging` с выводом в stderr (см. **[Логирование](handlers/logging.md)**). | +| **`ping`**: `client.send_ping()` | **Удалён** из протокола, а не просто объявлен устаревшим. В 2026-07-28 метода `ping` нет. | Ничего. Работает только на подключении с `mode="legacy"`. | +| **Прогресс от клиента к серверу**: `client.send_progress_notification()` | В 2026-07-28 прогресс передаётся только от сервера к клиенту. | Отправлять нечего. О ходе выполнения сообщает ваш *сервер* — через `ctx.report_progress()` (см. **[Прогресс](handlers/progress.md)**). | + +Из этой таблицы следуют три вывода: + +* Корневые каталоги, сэмплирование и логирование идут вместе. Одно предложение, **SEP-2577**, объявляет устаревшими все три возможности разом. +* У сэмплирования и корневых каталогов есть общая, более глубокая проблема: это места, где **сервер** отправляет **запрос** **клиенту**. Именно это направление целиком спецификация 2026-07-28 заменяет **[многораундовыми запросами](handlers/multi-round-trip.md)**. Исчезли самостоятельные RPC-методы (`sampling/createMessage`, `roots/list` и push-вариант `elicitation/create`); типы полезной нагрузки `CreateMessageRequest` / `ListRootsRequest` / `ElicitRequest` сохранились — они встраиваются в `InputRequiredResult.input_requests`, а на клиенте попадают в те же колбэки. +* `ping` стоит особняком. Протокол не объявляет его устаревшим, а удаляет. Метод SDK всё равно выдаёт предупреждение (в его тексте сказано *removed*, а не *deprecated*), а вызов на современном подключении возвращает *«Method not found»*. + +## Устаревание носит рекомендательный характер {#deprecated-is-advisory} + +Сегодня ничего не ломается. + +Все перечисленные методы продолжают работать в любой сессии, согласованной на версии **2025-11-25 или более ранней**. Зафиксируйте на клиенте `mode="legacy"` — и получите в точности поведение до 2026 года. В передаваемых данных ничего не меняется, согласование возможностей остаётся прежним. + +Меняется одно: при первом запуске каждого из них появляется заметное предупреждение: + +```text +MCPDeprecationWarning: The logging capability is deprecated as of 2026-07-28 (SEP-2577). +``` + +`MCPDeprecationWarning` наследуется от `UserWarning`, а **не** от `DeprecationWarning`. Это сделано намеренно: фильтр Python по умолчанию показывает `DeprecationWarning` только в коде, запущенном напрямую как `__main__`, — именно так библиотеки объявляют что-то устаревшим, и два года этого никто не замечает. Это предупреждение видно везде, без всякого флага `-W`. + +!!! warning + Рекомендательный характер заканчивается на уровне сети. Сэмплирование и корневые + каталоги — это *запросы* от сервера к клиенту, а в сессии 2026-07-28 нет канала, по + которому такой запрос можно передать. Вызовите `ctx.session.create_message()` внутри + инструмента на современном подключении — предупреждение всё равно сработает, а затем + отправка завершится ошибкой: + + ```text + Cannot send 'sampling/createMessage': this transport context has no back-channel + for server-initiated requests. + ``` + + Два сигнала, именно в таком порядке. `MCPDeprecationWarning` срабатывает в момент + вызова метода, на любом подключении. Ошибка — это то, что возвращается, когда SDK + после этого пытается отправить запрос. От начала до конца эти две возможности работают + только на подключении с `mode="legacy"`, клиент которого зарегистрировал + соответствующий колбэк. + +## Отключение предупреждения {#silencing-the-warning} + +В новом коде — не отключайте. + +Но сервер, который вы поддерживаете и который действительно обслуживает клиентов до 2026 года, имеет полное право на тихий лог. Отфильтруйте категорию до того, как выполнится первый устаревший вызов: + +```python +import warnings + +from mcp import MCPDeprecationWarning + +warnings.filterwarnings("ignore", category=MCPDeprecationWarning) +``` + +Вот и весь API. Переключателя для отдельных методов нет, и он не нужен: смысл единой категории в том, что одна строка её заглушает и одна строка возвращает. + +!!! check + Разверните фильтр в обратную сторону — и получите бесплатный регрессионный тест. + Добавьте `"error::mcp.MCPDeprecationWarning"` в параметр `filterwarnings` конфигурации + pytest, и устаревший вызов будет **выбрасывать исключение**, а не предупреждать. + Инструмент `old_log`, который всё ещё вызывает `ctx.info()`, перестаёт проходить тест + и начинает сообщать: + + ```text + Error executing tool old_log: The logging capability is deprecated as of 2026-07-28 (SEP-2577). + ``` + + Одна строка в конфигурации pytest — и устаревший вызов уже не сможет незаметно + вернуться в кодовую базу, не провалив тест. + +## Итоги {#recap} + +* Спецификация 2026-07-28 объявляет устаревшими **корневые каталоги**, **сэмплирование** по инициативе сервера и протокольное **логирование** (всё — [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), ограничивает **прогресс** направлением от сервера к клиенту и удаляет **`ping`**. +* Столбец с заменами указывает, куда идти дальше: **[Многораундовые запросы](handlers/multi-round-trip.md)** — для сэмплирования и корневых каталогов, **[Логирование](handlers/logging.md)** — для логирования, **[Прогресс](handlers/progress.md)** — для прогресса. `ping` не требует вообще ничего. +* Устаревание носит рекомендательный характер: в передаваемых данных ничего не меняется, всё продолжает работать в сессиях до 2026 года, и появляется заметное предупреждение `MCPDeprecationWarning` (это `UserWarning`, поэтому оно включено по умолчанию). +* Сэмплированию и корневым каталогам дополнительно нужен обратный канал (back-channel), которого в сессии 2026-07-28 нет. На современном подключении они выдают предупреждение, а затем выбрасывают исключение. +* `warnings.filterwarnings("ignore", category=MCPDeprecationWarning)` заглушает всю категорию; `"error::mcp.MCPDeprecationWarning"` в pytest превращает её в провал теста. +* Новый код не следует строить ни на одной из этих возможностей. + +Все остальные страницы этой документации описывают актуальный API. diff --git a/i18n/ru/pages/get-started/first-steps.md b/i18n/ru/pages/get-started/first-steps.md new file mode 100644 index 0000000000..0a2d279030 --- /dev/null +++ b/i18n/ru/pages/get-started/first-steps.md @@ -0,0 +1,145 @@ +--- +translation: + sections: [0d6c05bcbf836bf3, 59a7b14eeefc68c1, 7114d8d6daba203f, e8bbb56a98ba7bc9, 5138010f6159901c, f78da7c7c363d4c6, 220a939cab348686] + tool: 1 +--- +# Первые шаги {#first-steps} + +**[Главная страница](../index.md)** идёт быстро: написать сервер, запустить его, вызвать инструмент. + +Эта страница идёт не спеша: все три вида того, что сервер может предоставлять, и название для всего, что встретится по дороге. + +## Хост, клиент и сервер {#host-client-and-server} + +Три слова, которые с этого момента будут встречаться на каждой странице: + +* **Хост** — это LLM-приложение: Claude, IDE, среда выполнения агентов. Это то, с чем разговаривает пользователь. +* **Клиент** живёт внутри хоста и говорит на MCP. Хост запускает по одному клиенту на каждый сервер, к которому подключён. +* **Сервер** — это то, что вы строите с помощью этого SDK. Он предоставляет клиентам разные вещи. С моделью напрямую он никогда не общается. + +Вы пишете сервер. Хосты — это чужой продукт. SDK также даёт класс `Client`. Он пригодится для тестирования серверов и появится ниже на этой странице. + +## Три примитива {#the-three-primitives} + +Сервер предоставляет ровно три вида сущностей. Различает их то, **кто решает ими воспользоваться**: + +| Примитив | Кто управляет | Что это такое | Пример | +|---------------|-----------------|------------------------------------------------------------|-------------------------------------| +| **Инструменты** | Модель | Функция, которую модель вызывает, чтобы совершить действие | Вызов API, запись в базу данных | +| **Ресурсы** | Приложение | Данные, которые хост загружает в контекст модели | Содержимое файла, ответ API | +| **Промпты** | Пользователь | Многоразовый шаблон сообщения, который пользователь вызывает по имени | Слэш-команда, пункт меню | + +«Кто управляет» — в этом весь смысл разделения. Инструмент запускается, потому что его решила вызвать **модель**. Ресурс прикрепляется, потому что **приложение** решило, что он нужен модели. Промпт запускается, потому что его выбрал **пользователь**. + +!!! info + Если вы уже строили веб-API, интуиция у вас по большей части есть: **ресурс** — это `GET` + (загружает данные и ничего не меняет), а **инструмент** — это `POST` (делает работу и может + иметь побочные эффекты). У **промпта** нет аналога в HTTP; он ближе к сохранённому запросу, + который пользователь запускает по имени. + +## Один сервер, все три {#one-server-all-three} + +```python title="server.py" hl_lines="6 12 18" +--8<-- "docs_src/first_steps/tutorial001.py" +``` + +Три обычные функции, три декоратора. Каждый декоратор — это и есть вся регистрация: + +* `@mcp.tool()` делает `add` **инструментом**. +* `@mcp.resource("greeting://{name}")` делает `greeting` **шаблоном ресурса**: `{name}` в URI — это параметр функции. +* `@mcp.prompt()` делает `summarize` **промптом**. Строка, которую она возвращает, становится сообщением пользователя. + +Всё остальное (имя, описание, схему аргументов) SDK считывает из самой функции: её имени, строки документации, аннотаций типов. Ничего из этого вы отдельно не объявляли. + +!!! tip + У двух половин SDK два пути импорта: `from mcp import Client` и + `from mcp.server import MCPServer`. Варианта `from mcp import MCPServer` нет. + +### Попробуйте сами {#try-it} + +Запустите сервер в MCP Inspector: + +```console +uv run mcp dev server.py +``` + +Откройте URL, который он напечатает. В Inspector по одной вкладке на каждый примитив; пройдитесь по ним по порядку. + +**Tools.** Одна запись: `add` с описанием *Add two numbers.* В форме обязательное целочисленное поле для `a` и ещё одно для `b`. Заполните их, вызовите инструмент — результат `3`. Inspector построил эту форму по `a: int, b: int`. Так же поступает любой другой клиент. + +**Resources.** Список *Resources* пуст. `greeting` находится в разделе **Resource Templates**, потому что в `greeting://{name}` есть параметр: пока кто-нибудь не укажет `name`, конкретного ресурса для списка нет. Передайте `World` и прочитайте его: + +```text +Hello, World! +``` + +**Prompts.** Одна запись: `summarize` с единственным обязательным аргументом `text`. Получите его с каким-нибудь текстом — придёт одно сообщение с `role: user` и вашей готовой строкой в качестве содержимого. Вот и всё, что такое промпт: функция, которая собирает сообщения. + +Inspector запустил ваш сервер через **stdio** — один из транспортов, на которых может говорить MCP-сервер. Выбирать транспорт пока не нужно; этому посвящена страница **[Запуск сервера](../run/index.md)**. + +## Возможности {#capabilities} + +В Inspector вы видели три вкладки. Откуда он узнал, что их три? + +Когда клиент подключается, сервер объявляет свои **возможности**: на какие семейства запросов он будет отвечать. По этому объявлению клиент решает, о чём вообще имеет смысл спрашивать. Вы его не писали; `MCPServer` объявляет его за вас. + +Посмотрите сами. Класс `Client` из SDK принимает объект сервера напрямую и подключается к нему **в памяти** (без подпроцесса, без порта): + +```python +import asyncio + +from mcp import Client + +from server import mcp + + +async def main() -> None: + async with Client(mcp) as client: + print(client.server_capabilities.model_dump(exclude_none=True)) + + +asyncio.run(main()) +``` + +```text +{'prompts': {'list_changed': True}, 'resources': {'subscribe': True, 'list_changed': True}, 'tools': {'list_changed': True}} +``` + +Этот словарь — объявленные **возможности** вашего сервера. Это первое, что узнаёт каждый подключающийся клиент: + +| Возможность | Теперь клиент может вызывать | +|-------------|---------------------------------------------------------------| +| `tools` | `tools/list`, `tools/call` | +| `resources` | `resources/list`, `resources/templates/list`, `resources/read` | +| `prompts` | `prompts/list`, `prompts/get` | + +`MCPServer` обслуживает все три примитива, поэтому все три возможности объявлены всегда. + +Обратите внимание на то, чего здесь нет. `completions` (автодополнение аргументов для шаблонов ресурсов и промптов) требует обработчика, который пишете вы; у этого сервера его нет, поэтому возможность отсутствует, и корректный клиент о ней не спросит. Таково правило для всего необязательного: зарегистрируйте нужное — и возможность появится; страница **[Автодополнение](../servers/completions.md)** это демонстрирует. + +!!! info + `Client(mcp)` — тот самый клиент в памяти, которым тестируется каждый пример в этой + документации, и именно так вы будете тестировать свои. Ему отведена целая страница: + **[Тестирование](testing.md)**. + +## Чего вы не писали {#what-you-did-not-write} + +Оглянитесь на эту страницу. Вы написали три маленькие функции на Python. Вы **не** писали: + +* JSON Schema. `a: int, b: int` — это *и есть* схема для `add`. +* Обработчик запросов. `tools/list`, `resources/read`, `prompts/get` — всё это обслуживается за вас. +* Объявление возможностей. `MCPServer` составил его за вас. +* Ни строчки протокола. Согласование версии, обрамление JSON-RPC, обмен возможностями — всё это произошло внутри `mcp dev` и `Client(mcp)`, и вы этого не видели. + +В этом соотношении — весь смысл SDK. + +## Итоги {#recap} + +* **Хост** — это LLM-приложение, **клиент** — его половина, говорящая на MCP, **сервер** — то, что строите вы. +* Инструментами управляет **модель**, ресурсами — **приложение**, промптами — **пользователь**. +* По одному декоратору на примитив: `@mcp.tool()`, `@mcp.resource(uri)`, `@mcp.prompt()`. Имя, описание и схема берутся из функции. +* URI с `{param}` создаёт **шаблон** ресурса, который отображается отдельно от конкретных ресурсов. +* **Возможности** сервера объявляются за вас, а клиент спрашивает только о том, что сервер объявил. +* `Client(mcp)` подключается к объекту сервера в памяти: ваш тестовый стенд с первого дня. + +Дальше — **[Подключение к настоящему хосту](real-host.md)**: этот же сервер внутри Claude Desktop или IDE, по-настоящему. Затем **[Тестирование](testing.md)**: одна страница, один клиент в памяти — и больше не придётся гадать, работает ли оно. После этого каждому примитиву отведена своя страница, начиная с того, которым управляет модель: **[Инструменты](../servers/tools.md)**. diff --git a/i18n/ru/pages/get-started/index.md b/i18n/ru/pages/get-started/index.md new file mode 100644 index 0000000000..499a62085f --- /dev/null +++ b/i18n/ru/pages/get-started/index.md @@ -0,0 +1,56 @@ +--- +translation: + sections: [ed4a756b4c53c585, 97e2fb315b7fe398, 4d04f1c6f4bf6c1d, 577d73078fc62baf] + tool: 1 +--- +# Начало работы {#get-started} + +Впервые работаете с MCP или с этим SDK? Начните здесь. Эти страницы проведут вас от нуля до работающего, протестированного сервера: [установите SDK](installation.md), соберите +[первый сервер](first-steps.md), [подключите его к настоящему хосту](real-host.md) и +[протестируйте](testing.md) с помощью клиента в памяти. + +## Запуск кода {#run-the-code} + +Все блоки кода можно копировать и использовать как есть: это полные, рабочие файлы. + +Чтобы повторять шаги по ходу чтения, вставьте блок в файл `server.py` и откройте его в MCP Inspector: + +```console +uv run mcp dev server.py +``` + +**Настоятельно рекомендуем** писать (или копировать) код, править его и запускать локально. Именно работа в собственном редакторе показывает суть: как мало приходится писать, как помогает автодополнение, как проверка типов ловит ошибки ещё до запуска. + +## Гадать не придётся {#you-will-not-be-guessing} + +Каждый пример в этой документации — полный файл в каталоге [`docs_src/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/docs_src) в репозитории самого SDK, и каждый из них прогоняется набором тестов SDK через **клиент в памяти**: + +```python +import pytest +from mcp import Client + +from server import mcp + + +@pytest.mark.anyio +async def test_add() -> None: + async with Client(mcp) as client: + result = await client.call_tool("add", {"a": 1, "b": 2}) + assert result.structured_content == {"result": 3} +``` + +Ни подпроцесса, ни порта, ни транспорта. `Client(mcp)` подключается к объекту сервера напрямую. + +Если изменение в SDK ломает пример на одной из этих страниц, CI краснеет раньше, чем страница. Код, который вы здесь читаете, — это код, который выполняется. + +Вы и сами воспользуетесь этим приёмом на странице [Тестирование](testing.md): свои серверы тестируют точно так же. + +## Куда дальше {#where-to-go-next} + +Как только сервер запущен, остальная документация — это справочник, а не курс. +Каждая страница самодостаточна, так что переходите сразу к нужному: + +* Что сервер предоставляет (инструменты, ресурсы, промпты) — **[Серверы](../servers/index.md)**. +* Что доступно внутри регистрируемых функций — **[Внутри обработчика](../handlers/index.md)**. +* Как донести сервер до клиентов (stdio, HTTP, существующее FastAPI-приложение) — **[Запуск сервера](../run/index.md)**. +* Как построить другую сторону — приложение, которое *использует* MCP-серверы, — **[Клиенты](../client/index.md)**. diff --git a/i18n/ru/pages/get-started/installation.md b/i18n/ru/pages/get-started/installation.md new file mode 100644 index 0000000000..a5c61f11c6 --- /dev/null +++ b/i18n/ru/pages/get-started/installation.md @@ -0,0 +1,48 @@ +--- +translation: + sections: [6e2f9bab94d5ed36, 8cf653388f69e28b, 6fd9ea2f65de0df6] + tool: 1 +--- +# Установка {#installation} + +Python SDK опубликован на PyPI как [`mcp`](https://pypi.org/project/mcp/). Для работы нужен **Python 3.10+**. + +Эта документация описывает **v2** — текущую стабильную линейку выпусков: + +=== "uv" + + ```bash + uv add "mcp[cli]" + ``` + +=== "pip" + + ```bash + pip install "mcp[cli]" + ``` + +!!! note "Переходите с v1?" + v2 — мажорная версия с несовместимыми изменениями; все они описаны в + **[руководстве по миграции](../migration.md)**. Если ваш *пакет* зависит от `mcp` и ещё не готов + к переходу, сохраните верхнюю границу `<2` (например, `mcp>=1.28,<2`), чтобы при разрешении + зависимостей без фиксированных версий оставаться на линейке 1.x. + +## Что устанавливается {#what-gets-installed} + +Чтобы пользоваться SDK, всё это знать не обязательно, но если интересно, зачем нужна каждая зависимость: + +* `mcp-types`: все типы протокола (запросы, результаты, блоки содержимого) в виде отдельного пакета, версии которого выходят синхронно с SDK. Код, зависящий от `mcp`, импортирует его через псевдоним `mcp.types` (каждый `from mcp.types import ...` в этой документации); импортируйте `mcp_types` напрямую только в проекте, который устанавливает `mcp-types` без SDK. +* [`anyio`](https://anyio.readthedocs.io/): асинхронная среда выполнения. Весь SDK написан поверх anyio, поэтому работает как на `asyncio`, так и на `trio`. +* [`pydantic`](https://docs.pydantic.dev/): основа всех моделей `mcp.types`, а также вся генерация схем и валидация. +* [`httpx2`](https://pypi.org/project/httpx2/): HTTP-клиент, на котором построены *клиентские* транспорты Streamable HTTP и SSE, со встроенной поддержкой server-sent events. +* [`starlette`](https://www.starlette.io/), [`uvicorn`](https://www.uvicorn.org/), [`sse-starlette`](https://pypi.org/project/sse-starlette/) и [`python-multipart`](https://pypi.org/project/python-multipart/): *серверные* HTTP-транспорты. +* [`jsonschema`](https://pypi.org/project/jsonschema/): проверяет структурированный вывод инструмента на соответствие объявленной выходной схеме. +* [`pyjwt[crypto]`](https://pyjwt.readthedocs.io/): работа с OAuth-токенами для авторизации. +* [`opentelemetry-api`](https://opentelemetry-python.readthedocs.io/): только лёгкий API, поэтому middleware трассировки в SDK ничего не стоит, пока вы сами не установите SDK и экспортёр OpenTelemetry. +* [`typing-extensions`](https://typing-extensions.readthedocs.io/) и [`typing-inspection`](https://pypi.org/project/typing-inspection/): современные возможности типизации на Python 3.10. +* [`pywin32`](https://pypi.org/project/pywin32/): только для Windows, используется для управления подпроцессами `stdio`. + +## Дополнительные компоненты {#optional-extras} + +* `mcp[cli]` добавляет [`typer`](https://typer.tiangolo.com/) и [`python-dotenv`](https://pypi.org/project/python-dotenv/) для утилиты командной строки `mcp` (`mcp dev`, `mcp run`, `mcp install`). Во время разработки она пригодится; в развёрнутом сервере может и не понадобиться. +* `mcp[rich]` добавляет [`rich`](https://rich.readthedocs.io/) для более красивых логов сервера. diff --git a/i18n/ru/pages/get-started/real-host.md b/i18n/ru/pages/get-started/real-host.md new file mode 100644 index 0000000000..2bc8af97c9 --- /dev/null +++ b/i18n/ru/pages/get-started/real-host.md @@ -0,0 +1,186 @@ +--- +translation: + sections: [3c4f2f06b4e978b6, 22520eecae3d1961, f4e1709db18d635a, 2eb57992049671d9, 1ba83e9af37cc1b4, 4822586344b08d9e, 1c93afef72478992, b6b448f9eddd51dc, fe55370fd931815b] + tool: 1 +--- +# Подключение к настоящему хосту {#connect-to-a-real-host} + +**Хост** — это приложение, внутри которого в итоге оказывается ваш сервер: Claude Desktop, Claude Code, IDE. Именно с хостом говорит пользователь. Внутри него MCP-**клиент** запускает ваш сервер как дочерний процесс и общается с ним через stdin и stdout этого процесса. + +А значит, подключение к хосту сводится к одному действию: сообщить ему **команду, которая запускает сервер**. Всё на этой странице (две команды CLI, три JSON-файла) — это разные места, куда кладётся одна и та же команда. + +## Один сервер, любой хост {#one-server-every-host} + +```python title="server.py" hl_lines="3 33-34" +--8<-- "docs_src/real_host/tutorial001.py" +``` + +Два инструмента и ресурс, один файл. Три вещи в этом файле важны для каждого хоста ниже: + +* `mcp.run()` без аргументов запускает **stdio**-сервер: он блокируется, читает сообщения протокола из stdin и пишет их в stdout. Это тот транспорт, на котором говорят все хосты на этой странице. Хост запускает ваш файл как дочерний процесс и владеет обоими каналами, поэтому подключение всегда сводится к «вот команда». Порт выбирать не нужно, и ничто на нём не слушает. +* `run()` стоит под `if __name__ == "__main__":`. Всё, что ниже, **импортирует** этот файл, а не выполняет его, так что незащищённый `run()` запускал бы сервер в тот момент, когда что-нибудь загружает модуль. +* Объект сервера — глобальная переменная уровня модуля с именем `mcp`. Это имя ищет `mcp run` (`server` и `app` тоже подходят). Назовёте иначе — придётся указать имя явно: `mcp run server.py:bookshop`. + +Это последняя строка Python на этой странице. Дальше — только настройка хостов. + +## Команда запуска {#the-launch-command} + +Каждый хост ниже получает одну и ту же команду: + +```bash +uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py +``` + +Одна команда для всех, потому что `uv run --with` прямо на месте разворачивает SDK в свежем окружении: она работает из любого каталога, не требует проекта и не требует активировать виртуальное окружение. Здесь это важнее, чем где бы то ни было, потому что хост запускает сервер из *своего* рабочего каталога с почти пустым окружением, а не из вашей оболочки. + +Это же команда, которую `mcp install` записывает за вас в конфигурацию Claude Desktop (ниже), так что набранное вручную и сгенерированное инструментом совпадают, если не считать точной фиксации версии, которую добавляет инструмент. + +!!! tip "Если хост не находит `uv`" + Хост порождает ваш сервер с минимальным `PATH`, и `uv` в нём может не оказаться. Замените + голое `uv` абсолютным путём из `which uv` (macOS/Linux) или `where uv` (Windows). Именно + это и записывает `mcp install`. + +!!! note "Эта страница — про локальный сценарий" + Всё здесь запускает сервер на той же машине, где находится хост: хост запускает ваш + файл через stdio. Это в точности то, что нужно для личного инструмента или инструмента + на одну машину. Чтобы отдать сервер людям, у которых *нет* вашего файла, раздают + **URL**, а не команду: тот же объект `mcp`, обслуживаемый по Streamable HTTP. + **[Запуск сервера](../run/index.md)** сводит это решение в одну таблицу, а + **[Развёртывание и масштабирование](../run/deploy.md)** — дорога оттуда к настоящему + имени хоста. + + А хост — это всего лишь приложение с MCP-клиентом внутри, так что роль хоста может + сыграть ваш собственный код на Python: **[Клиентские транспорты](../client/transports.md)** + запускают этот же файл как подпроцесс через `stdio_client(...)`, а + **[Тестирование](testing.md)** подключается к нему в памяти вообще без процесса. + +## Claude Desktop {#claude-desktop} + +Единственный хост, который SDK умеет настроить за вас: + +```bash +uv run mcp install server.py +``` + +Вот и всё. `mcp install` импортирует файл, чтобы прочитать имя сервера, находит файл конфигурации Claude Desktop и записывает в него команду запуска. Попутно она превращает ваш путь в абсолютный, чтобы этого не пришлось делать вам. + +Никакой магии здесь нет. Вот запись, которую она создаёт: + +```json +{ + "mcpServers": { + "Bookshop": { + "command": "/absolute/path/to/uv", + "args": [ + "run", + "--frozen", + "--with", + "mcp[cli]==2.0.0", + "mcp", + "run", + "/absolute/path/to/server.py" + ] + } + } +} +``` + +Это команда запуска из раздела выше с тремя добавлениями: абсолютный путь к `uv`, `--frozen`, чтобы `uv` никогда не переписывал lock-файл, рядом с которым случайно окажется, и точная фиксация установленной у вас версии `mcp`. Запись попадает в `claude_desktop_config.json`, который лежит здесь: + +* **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` +* **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` + +Этот файл можно написать и вручную. `mcp install` существует затем, чтобы вы при этом не допустили классической ошибки (относительного пути). + +Полностью завершите Claude Desktop (а не просто закройте окно) и откройте заново. + +!!! warning + `mcp install` завершается ошибкой `Claude app not found`, если *каталога* конфигурации Claude + Desktop ещё не существует. Установите Claude Desktop и запустите его один раз: именно это + создаёт каталог. + +!!! tip + Claude Desktop запускает сервер в собственном процессе, так что переменных окружения вашей + оболочки там нет. `uv run mcp install server.py -v API_KEY=abc123` (или `-f .env`) записывает + их в поле `env` записи. `--name` переопределяет имя записи; по умолчанию берётся `name` + сервера. + +## Claude Code {#claude-code} + +Редактировать никакой файл не нужно. Зарегистрируйте сервер через CLI `claude`; всё после `--` — это команда запуска. + +```bash +claude mcp add bookshop -- uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py +``` + +Выполните `/mcp` внутри сессии Claude Code, чтобы убедиться, что `bookshop` подключён и его инструменты перечислены. + +## Cursor {#cursor} + +Создайте `.cursor/mcp.json` в корне проекта. + +```json +{ + "mcpServers": { + "bookshop": { + "command": "uv", + "args": ["run", "--with", "mcp[cli]", "mcp", "run", "/absolute/path/to/server.py"] + } + } +} +``` + +Те же `command` и `args` под тем же ключом `mcpServers`, что использует Claude Desktop. Сервер появляется в настройках MCP Cursor с обоими инструментами в списке. + +## VS Code {#vs-code} + +Создайте `.vscode/mcp.json` в корне проекта. + +```json +{ + "servers": { + "bookshop": { + "type": "stdio", + "command": "uv", + "args": ["run", "--with", "mcp[cli]", "mcp", "run", "/absolute/path/to/server.py"] + } + } +} +``` + +Два отличия от файла Cursor, и только эти два: ключ-обёртка — `servers`, а не `mcpServers`, и каждая запись объявляет свой `type`. Подтвердите запрос о доверии, после чего **MCP: List Servers** в палитре команд покажет запущенный `bookshop`. + +!!! note + Нужен VS Code 1.99 или новее с расширением **GitHub Copilot**, в котором выполнен вход + (достаточно Copilot Free), и Copilot Chat должен быть в режиме **Agent**, потому что никакой + другой режим не вызывает инструменты. + +## Сервер не появляется {#it-doesnt-show-up} + +Прежде чем трогать конфигурацию какого-либо хоста, выполните команду запуска сами: + +```bash +uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py +``` + +Ничего не печатается, и команда не завершается. Эта тишина правильна: stdio-сервер ждёт, пока хост первым заговорит через stdin (`Ctrl-C`, чтобы остановить). Настоящая ошибка — это трассировка или немедленный выход, и теперь её можно прочитать, а не угадывать через хост. + +Когда эта команда спокойно ждёт, остаётся почти всегда одно из трёх: + +* **Относительный путь.** Хост запускает сервер из *своего* рабочего каталога, а не из того, где вы его регистрировали. `server.py` там, где нужен `/absolute/path/to/server.py`, — самая распространённая ошибка. Если хост не находит и `uv`, этот путь тоже должен быть абсолютным. +* **Хост всё ещё работает со старой конфигурацией.** Хосты читают конфигурацию при запуске. Claude Desktop в особенности нужно *полностью завершить* (а не просто закрыть окно) и открыть заново, прежде чем правка в `claude_desktop_config.json` вступит в силу. +* **Что-то попало в stdout вне перенаправляемого окна.** На stdio stdout *и есть* протокол. SDK на время обслуживания перенаправляет сброшенный посторонний вывод в stderr, но вывод, сброшенный в stdout до этого (скрипт-обёртка с echo, `print()` при импорте в небуферизованном процессе), или буферизованный `print()`, слитый при завершении интерпретатора, отдаёт хосту испорченное сообщение, и тот разрывает соединение. Пишите логи с конфигурацией `logging` по умолчанию, чей stderr-обработчик сбрасывает каждую запись; пользовательские обработчики тоже должны избегать stdout. Подробнее — на странице **[Логирование](../handlers/logging.md)**. + +Claude Desktop ведёт лог для каждого сервера: `mcp-server-.log` — это stderr вашего сервера, рядом с `mcp.log` для подключений, в `~/Library/Logs/Claude` на macOS и `%APPDATA%\Claude\logs` на Windows. + +Всё, что выходит за рамки этих трёх случаев, — на странице **[Устранение неполадок](../troubleshooting.md)**. + +## Итоги {#recap} + +* **Хост** (Claude Desktop, IDE) содержит MCP-клиент, который запускает ваш сервер как дочерний процесс через stdio. Подключиться — значит дать ему одну команду запуска. +* Эта команда — `uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py`: не нужно активировать venv, работает из любого каталога. +* **Claude Desktop** — единственный хост, который `mcp install` настраивает за вас. Она записывает ту же команду (плюс абсолютный путь к `uv`, `--frozen` и точную фиксацию установленной у вас версии) в `claude_desktop_config.json`, чтобы этого никогда не пришлось делать вам. +* **Claude Code** — это `claude mcp add bookshop -- `. **Cursor** — `.cursor/mcp.json` под ключом `mcpServers`. **VS Code** — `.vscode/mcp.json` под ключом `servers`, каждая запись с `type`. +* Везде абсолютные пути, перезапуск хоста после правки его конфигурации, и ничто, кроме SDK, не должно писать в stdout. + +Каждый хост на этой странице подключился к одному и тому же файлу одной и той же командой. То, что этот файл может *предоставлять*, — остальная документация: **[Инструменты](../servers/tools.md)**, **[Ресурсы](../servers/resources.md)** и все транспорты, кроме stdio, на странице **[Запуск сервера](../run/index.md)**. diff --git a/i18n/ru/pages/get-started/testing.md b/i18n/ru/pages/get-started/testing.md new file mode 100644 index 0000000000..402f9e0a68 --- /dev/null +++ b/i18n/ru/pages/get-started/testing.md @@ -0,0 +1,116 @@ +--- +translation: + sections: ['4926721070127497', c52a1de2b6b32f40, 2e410b412c25f314, 627195f7159e24ef] + tool: 1 +--- +# Тестирование {#testing} + +В Python SDK есть класс `Client` со **встроенным in-memory транспортом**: передайте ему объект сервера, и он подключится к нему напрямую. + +Ни подпроцесса. Ни порта. Вообще никакого транспорта. Та же идея, что и `TestClient` в FastAPI. + +## Базовое использование {#basic-usage} + +Предположим, есть простой сервер с одним инструментом: + +```python title="server.py" +--8<-- "docs_src/testing/tutorial001.py" +``` + +Чтобы запустить тест ниже, понадобятся две дополнительные зависимости (для разработки): + +=== "uv" + + ```bash + uv add --dev pytest inline-snapshot + ``` + +=== "pip" + + ```bash + pip install pytest inline-snapshot + ``` + +!!! info + Эта документация предполагает, что вы уже знакомы с [`pytest`](https://docs.pytest.org/en/stable/). + + [`inline-snapshot`](https://15r10nk.github.io/inline-snapshot/latest/) — то, с помощью чего + тест ниже проверяет весь объект результата одной строкой. Библиотека записывает вывод теста + в виде литерала `snapshot(...)`, который вы видите. Если не хотите её использовать, уберите + импорт и проверяйте нужные поля (`result.content[0].text == "3"`), как в любом другом тесте. + +Теперь сам тест: + +```python title="test_server.py" +import pytest +from inline_snapshot import snapshot +from mcp import Client +from mcp.types import CallToolResult, TextContent + +from server import mcp + + +@pytest.fixture +def anyio_backend(): # (1)! + return "asyncio" + + +@pytest.fixture +async def client(): # (2)! + async with Client(mcp, raise_exceptions=True) as c: + yield c + + +@pytest.mark.anyio +async def test_call_add_tool(client: Client): + result = await client.call_tool("add", {"a": 1, "b": 2}) + # Drop the server identity stamp in `_meta`; it is not what this test is about. + result.meta = None + assert result == snapshot( + CallToolResult( + content=[TextContent(type="text", text="3")], + structured_content={"result": 3}, + ) + ) +``` + +1. Если используете `trio`, верните вместо этого `"trio"`. Подробности — в [документации anyio](https://anyio.readthedocs.io/en/stable/testing.html#specifying-the-backends-to-run-on). +2. Фикстура отдаёт подключённый клиент. Каждый тест, принимающий `client`, получает свежее in-memory подключение к тому же серверу. + +Готово! Теперь можно расширять тесты, чтобы покрыть больше сценариев. + +## Зачем `raise_exceptions=True`? {#why-raise_exceptionstrue} + +Пойти не так могут две разные вещи, и этот флаг касается только одной из них. + +Исключение внутри одного из **ваших инструментов** — не сбой протокола. Оно превращается в +обычный результат с `is_error=True`, и модель читает сообщение. `raise_exceptions` этого не +меняет: с ним или без него `call_tool` возвращает один и тот же результат с `is_error=True`. +Этому посвящена целая страница: **[Обработка ошибок](../servers/handling-errors.md)**. + +Сбой **вне** тела инструмента — другое дело. На подключении, которое даёт `Client(mcp)`, сервер +очищает его до обобщённого `"Internal server error"`, прежде чем оно дойдёт до клиента. Детали +неожиданного падения никогда не должны утекать к удалённому вызывающему. В тесте это ровно то, +чего вы *не* хотите, и именно это меняет `raise_exceptions=True`: тест видит настоящее сообщение +вместо очищенного. + +В тестах оставляйте его включённым. В продакшен-коде он не имеет смысла. + +## Внутри процесса по умолчанию {#in-process-by-default} + +!!! note + `Client(mcp)` подключается внутри процесса и по умолчанию **не привязан к поколению + протокола**: он опрашивает сервер и выбирает подходящий путь протокола. Зафиксируйте + `mode="legacy"`, если тест проверяет семантику, специфичную для подключений старого + поколения (push-сообщения сэмплирования (sampling) или элицитации (elicitation), + `message_handler`), и уберите там `raise_exceptions=True`: подключение старого поколения + вообще ничего не очищает, а флаг повторно выбрасывает сбой внутри задачи сервера, а не в + вашем тесте. + +Именно благодаря этой одной строке документация может обещать, что её примеры работают: каждый +файл с примером прогоняется собственным набором тестов SDK, и почти все — ровно через этот +клиент. Вы пользуетесь тем же инструментом, которым SDK проверяет сам себя. + +У вас есть работающий, протестированный сервер. Как поместить его в настоящее приложение +(Claude Desktop, IDE) — на странице **[Подключение к настоящему хосту](real-host.md)**; все +остальные способы его запустить — в разделе **[Запуск сервера](../run/index.md)**. diff --git a/i18n/ru/pages/handlers/context.md b/i18n/ru/pages/handlers/context.md new file mode 100644 index 0000000000..4c7ea36b02 --- /dev/null +++ b/i18n/ru/pages/handlers/context.md @@ -0,0 +1,135 @@ +--- +translation: + sections: [b50152f05c81e786, b302059b22fb7cb4, 85682a1bf561243a, 53fc48838eb6837a, b24190e0842786ec, 85f93e150fc9b240] + tool: 1 +--- +# Объект Context {#the-context} + +Аргументы инструмента приходят от модели. Всё остальное (запрос, который вы обслуживаете, сервер, внутри которого работаете, способ обратиться к клиенту) приходит из одного объекта: **`Context`**. + +Его не нужно ни создавать, ни настраивать. Достаточно попросить. + +## Попросите его {#ask-for-it} + +Добавьте в любой инструмент параметр с аннотацией `Context`: + +```python title="server.py" hl_lines="2 8" +--8<-- "docs_src/context/tutorial001.py" +``` + +* SDK создаёт новый `Context` для каждого запроса и передаёт его в функцию. +* **Имя параметра не важно**. `ctx`, `context`, `c`: SDK находит его по аннотации. +* Ресурсы и промпты могут объявить такой параметр точно так же. +* `ctx.request_id` — идентификатор запроса, который ваша функция обслуживает прямо сейчас. + +!!! info + Если вы работали с FastAPI, этот приём вам знаком: объявляете параметр с типом самого фреймворка + (там `Request`, здесь `Context`), и фреймворк его подставляет. Ничего регистрировать, ничего + настраивать: весь механизм — это аннотация типа. + +### Невидим для модели {#invisible-to-the-model} + +Вот что стоит усвоить. Так выглядит входная схема, которую `tools/list` сообщает для `search_books`: + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"} + }, + "required": ["query"], + "title": "search_booksArguments" +} +``` + +Одно свойство. `ctx` — не аргумент: он никогда не появляется в схеме, модели о нём не сообщают, и ни один клиент не может его заполнить. Это договорённость между вами и SDK, невидимая в передаваемых данных. + +### Попробуйте сами {#try-it} + +Запустите сервер через MCP Inspector: + +```console +uv run mcp dev server.py +``` + +В форме для `search_books` единственное поле — `query`. Вызовите инструмент со значением `dune`: + +```text +[request 3] Found 3 books matching 'dune'. +``` + +Число — номер того запроса, которым оказался этот вызов. Вызовите инструмент ещё раз, и оно изменится: каждый запрос получает свой `Context`. + +## Что он даёт {#what-it-gives-you} + +Внедряемый объект невелик. Помимо `request_id`: + +* `await ctx.read_resource(uri)`: прочитать один из **собственных** ресурсов сервера изнутри инструмента. Об этом следующий раздел. +* `await ctx.report_progress(progress, total, message)`: передавать вызывающей стороне ход выполнения во время долгого вызова. Подробнее — на странице **[Прогресс](progress.md)**. +* `await ctx.elicit(message, schema)` и `await ctx.elicit_url(...)`: приостановить инструмент и задать пользователю вопрос. Это **[элицитация (elicitation)](elicitation.md)**. +* `ctx.session`: серверная сторона разговора с этим клиентом. Здесь живут уведомления, которые вы отправляете клиенту; последний раздел её использует. +* `ctx.headers`: заголовки запроса, которые передал транспорт, или `None` на stdio. Прочитать нестандартный заголовок можно так: `(ctx.headers or {}).get("x-...")`. Заголовки — это данные от клиента: годятся для локали или флага возможности, но никогда для идентификации. +* `ctx.request_context`: сырая запись о текущем запросе. Поле, к которому вы будете обращаться, — `lifespan_context`, объект, который вернул ваш код запуска (см. **[Жизненный цикл (lifespan)](lifespan.md)**). + +Логирования в этом списке нет намеренно. Сервер пишет логи через модуль Python `logging`, как любая другая программа на Python. Почему так — на короткой странице **[Логирование](logging.md)**. + +!!! tip + Внедрение происходит только для функции, которую вы зарегистрировали. Вспомогательная функция, + которую вызывает ваш инструмент, не получает собственный `Context`; передавайте ей `ctx` как + обычный аргумент. Никакого фонового «текущего контекста», который можно достать откуда-то ещё, + не существует. + +## Чтение собственных ресурсов {#read-your-own-resources} + +Ресурсы сервера предназначены не только для клиентов. Инструмент тоже может их читать: + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/context/tutorial002.py" +``` + +`ctx.read_resource` разрешает URI через тот же реестр, что обслуживает `resources/read`, поэтому инструмент получает то же, что получил бы клиент: итерируемый набор `ReadResourceContents`, по одному на блок содержимого. Для этого URI он один: + +```python +contents.content # 'fiction, non-fiction, poetry' +contents.mime_type # 'text/plain' +``` + +* `content` — ровно то, что вернула `genres()`. Один источник истины: клиент просматривает ресурс, ваши инструменты его потребляют, никто не копирует строку. +* Единственный параметр `describe_catalog` — это `Context`, поэтому в его входной схеме **вообще нет свойств**. Модель вызывает его с `{}`. + +## Сообщите клиенту, что список изменился {#tell-the-client-the-list-changed} + +То, что предлагает сервер, не зафиксировано на момент импорта. Зарегистрируйте инструмент во время выполнения, а затем сообщите об этом клиенту: + +```python title="server.py" hl_lines="15-16" +--8<-- "docs_src/context/tutorial003.py" +``` + +* `mcp.add_tool(recommend_book)` регистрирует обычную функцию как инструмент: имя, описание и схема выводятся точно так же, как это сделал бы `@mcp.tool()`. +* `await ctx.session.send_tool_list_changed()` отправляет `notifications/tools/list_changed`. Клиент, получивший его, снова вызывает `tools/list` и видит `recommend_book`. + +Родственные методы — `send_resource_list_changed()`, `send_prompt_list_changed()` и `send_resource_updated(uri)` для изменения одного конкретного ресурса. + +На подключении 2026-07-28 клиенты получают уведомления об изменениях только в потоке `subscriptions/listen`, который они открыли, поэтому перечисленные выше методы `send_*` до этих потоков не доходят. Методы публикации в `Context` доставляют уведомление сразу во все подписанные потоки: `await ctx.notify_tools_changed()`, `await ctx.notify_prompts_changed()`, `await ctx.notify_resources_changed()` и `await ctx.notify_resource_updated(uri)`. Подробнее, включая масштабирование на несколько реплик, — на странице **[Подписки](subscriptions.md)**. + +!!! check + Пока никто не запустил `enable_recommendations`, обещанного инструмента не существует. Вызовите + его всё равно, и результатом будет ошибка, которую модель может прочитать: + + ```text + Unknown tool: recommend_book + ``` + + Запустите `enable_recommendations`, и тот же самый вызов проходит успешно. Список инструментов + действительно динамический: `tools/list` отражает то, что зарегистрировано *прямо сейчас*. + +## Итоги {#recap} + +* Аннотируйте параметр типом `Context` (в инструменте, ресурсе или промпте), и SDK его внедрит. Имя выбираете вы. +* Для модели он невидим: входная схема всегда содержит только ваши настоящие аргументы. +* `ctx.request_id` идентифицирует запрос; `ctx.request_context.lifespan_context` — то, что вернул ваш код запуска. +* `await ctx.read_resource(uri)` позволяет инструменту читать собственные ресурсы сервера. +* `ctx.session` — канал обратно к клиенту: `send_tool_list_changed()` и родственные методы велят ему заново запросить изменённый список. +* Отчёты о ходе выполнения и элицитация тоже начинаются с `Context`; у каждой темы своя страница. + +Параметры, которых модель никогда не видит и которые заполняют ваши собственные функции, — это **[Зависимости](dependencies.md)**. diff --git a/i18n/ru/pages/handlers/dependencies.md b/i18n/ru/pages/handlers/dependencies.md new file mode 100644 index 0000000000..fa35ec996e --- /dev/null +++ b/i18n/ru/pages/handlers/dependencies.md @@ -0,0 +1,167 @@ +--- +translation: + sections: [b0389403e98d25ad, e2cf58b43b285e86, a363e1a38e1a5971, 6cfac078feb18013, b4535bd61df337e6, e97ed44207f929fd] + tool: 1 +--- +# Зависимости {#dependencies} + +Аргументы инструмента приходят от модели. Некоторые значения приходить от неё не должны никогда: цена, найденная в ваших записях; подтверждение, которое может дать только человек; всё, что модель способна исказить, просто выдумав. + +**Зависимости** — это параметры, которые заполняют ваши собственные функции. Вы аннотируете параметр, указываете функцию, и SDK вызывает её до запуска инструмента. + +## Объявление зависимости {#declare-one} + +Оберните тип параметра в `Annotated[...]` и добавьте `Resolve(fn)`: + +```python title="server.py" hl_lines="18-19 23" +--8<-- "docs_src/dependencies/tutorial001.py" +``` + +* `check_stock` — это **резолвер**: обычная функция, которую SDK запускает перед `reserve_book`; её возвращаемое значение становится аргументом `stock`. +* Её параметр `title` — это собственный аргумент `title` инструмента, сопоставленный **по имени**. Резолвер видит ровно то же проверенное значение, что увидит тело инструмента. +* Тело инструмента начинает с уже готового `Stock`. Никакого кода поиска в инструменте, никакой преамбулы «а что, если его нет». + +!!! info + Если вы работали с FastAPI, это `Depends`. Тот же приём по той же причине: функция объявляет, + что ей нужно, фреймворк это предоставляет, а вся связка живёт в аннотации типа. + +### Параметр, невидимый для модели {#invisible-to-the-model} + +Вот входная схема, которую `tools/list` сообщает для `reserve_book`: + +```json +{ + "type": "object", + "properties": { + "title": {"title": "Title", "type": "string"} + }, + "required": ["title"], + "title": "reserve_bookArguments" +} +``` + +Одно свойство. Как и `Context` на странице **[Объект Context](context.md)**, разрешённый параметр — это договор между вами и SDK: `stock` нет в схеме, модели о нём никогда не сообщают, а значение `stock`, которое клиент всё же пришлёт, игнорируется. Значение резолвера — единственное, которое может получить инструмент. + +В последнем и весь смысл. Параметр, который модель не может передать, — это параметр, в котором модель не может ошибиться. + +### Попробуйте сами {#try-it} + +Запустите сервер с MCP Inspector: + +```console +uv run mcp dev server.py +``` + +В форме для `reserve_book` одно поле — `title`. Поля `stock` в ней нет нигде. Вызовите инструмент с `Dune`: + +```text +Reserved 'Dune' (6 copies left). +``` + +Тело инструмента ничего не искало: сначала выполнился `check_stock`, и возвращённый им `Stock` пришёл как аргумент. Попробуйте `Neuromancer` — и тот же резолвер передаст инструменту ноль. + +!!! tip + Можно было бы просто вызвать `check_stock(title)` в теле инструмента. Объявляйте зависимость, + когда значение заслуживает большего, чем вызов вспомогательной функции: каждый инструмент, + которому нужны остатки, объявляет один и тот же параметр, а SDK запускает резолвер не более + одного раза за вызов, сколько бы потребителей его ни объявляли. Следующие разделы добавят + остальное: резолверы, зависящие друг от друга, и резолверы, которые спрашивают пользователя. + +## Зависимости зависимостей {#dependencies-of-dependencies} + +Резолвер может объявлять собственные зависимости той же аннотацией: + +```python title="server.py" hl_lines="22 29-30" +--8<-- "docs_src/dependencies/tutorial002.py" +``` + +* `estimate_delivery` зависит от `check_stock`. SDK выполняет граф по порядку: сначала остатки, затем оценка, затем инструмент. +* И `stock`, и `delivery` в конечном счёте нуждаются в `check_stock`, но он выполняется **один раз за вызов**. Один запрос к складу, два потребителя. +* Регистрировать ничего не нужно. Граф — это и *есть* аннотации. + +!!! check + Не принимайте «один раз за вызов» на веру. Поставьте `print` в `check_stock` и вызовите + `order_book` из Inspector: одна строка на вызов. Два потребителя, один поиск. + +SDK анализирует граф при регистрации инструмента, а не при его вызове. Параметр, который не удаётся классифицировать (не `Context`, не `Resolve(...)`, не имя аргумента инструмента), и цикл резолверов одинаково выбрасывают `InvalidSignature` при запуске. Сервер падает ещё до того, как подключится первый клиент, и в ошибке назван виновный параметр или резолвер. + +Параметры резолвера разрешаются точно так же, как параметры инструмента: другой `Resolve(...)`, собственные аргументы инструмента по имени или `Context` — `ctx.headers`, объект жизненного цикла (lifespan), всё это. + +!!! warning + На HTTP-транспортах `Context` включает `ctx.headers`. Заголовки — это **входные данные от + клиента**, как любой аргумент инструмента: годятся для локали или флага функции, но никогда — + для установления личности. Кто именно вызывает, определяет слой авторизации + (**[Авторизация](../run/authorization.md)**), а не заголовок, который может выставить кто угодно. + +!!! tip + *Один раз за вызов* означает ровно это: следующий `tools/call` снова запустит `check_stock`. + Ресурсу, который должен пережить запрос (пул соединений с базой данных, HTTP-клиент), место + на странице **[Жизненный цикл](lifespan.md)**, а резолвер может добраться до него через + `ctx.request_context.lifespan_context`. + +## Вопрос пользователю, когда без него нельзя {#ask-when-you-must} + +Резолвер не обязан знать ответ. Он может вернуть `Elicit(message, Model)`, и SDK спросит пользователя — это механизм элицитации (elicitation) со страницы **[Элицитация](elicitation.md)**, запущенный за вас: + +```python title="server.py" hl_lines="26-32 39" +--8<-- "docs_src/dependencies/tutorial003.py" +``` + +* Есть в наличии: `confirm_backorder` возвращает `Backorder` напрямую. **Нет вопроса — нет лишнего раунда обмена.** Пользователя отвлекают только тогда, когда его ответ на что-то влияет. +* Нет в наличии: SDK отправляет элицитацию, проверяет ответ по `Backorder` и внедряет его. Резолвер вообще не касается протокола. +* Инструмент читает `backorder.confirm` как любой другой аргумент. Ответ **нет** — тоже ответ: элицитация принимается с `confirm=False`, инструмент выполняется, и заказ не оформляется. Вопрос стал предусловием, а не служебным кодом в теле инструмента. + +А если пользователь вообще не станет отвечать — отклонит вопрос или отменит его? + +!!! check + Запустите `order_book` для `Neuromancer` и отклоните вопрос. С аннотацией в виде + `Annotated[Backorder, Resolve(...)]` тело инструмента не выполняется вовсе; вызов завершается + результатом-ошибкой, который модель может прочитать: + + ```text + Error executing tool order_book: Resolver for parameter 'backorder' could not resolve: elicitation was decline + ``` + +Это правильное поведение по умолчанию для предусловия: нет ответа — нет заказа. Когда отказ — это исход, который инструмент хочет обработать (пропустить дозаказ, но всё же предложить другую книгу), укажите в аннотации `ElicitationResult[Backorder]`, и инструмент получит полный исход accept/decline/cancel, по которому можно ветвиться. Эту форму, как и всё остальное о том, как спрашивать: правила схемы, три варианта ответа, сторону клиента в этом разговоре, — показывает страница **[Элицитация](elicitation.md)**. + +!!! info + Фреймворк выбирает транспорт для вопроса по согласованной версии протокола; приведённый выше + код одинаков в обоих случаях. На **2026-07-28** и новее вопрос передаётся внутри + многораундового (multi-round-trip) `tools/call`: сервер возвращает его, `elicitation_callback` + клиента отвечает, а `Client` повторяет вызов за вас (**[Многораундовые запросы](multi-round-trip.md)**). + На **2025-11-25** и старше это синхронный запрос элицитации посреди вызова. Каждый вопрос + задаётся ровно один раз за вызов — это гарантия о вопросе, а не о резолвере. В многораундовой + форме любой резолвер может выполниться снова всякий раз, когда вызов возобновляется после + вопроса, поэтому код перед `return Elicit(...)` выполняется в каждом таком раунде; записанный + ответ затем закрывает повторный вопрос, не спрашивая пользователя заново. К записанному ответу + обращаются только тогда, когда резолвер спрашивает; резолвер, который отвечает, *не* спрашивая, + как `check_stock`, всегда поставляет собственное вычисленное значение. Поскольку каждый ответ + сопоставляется со своим вопросом, резолвер с элицитацией должен выводить вопрос + детерминированно из аргументов инструмента и предыдущих ответов. Значение, генерируемое заново + при каждом вызове (идентификатор из `default_factory`, метка времени), пересчитывается в каждом + раунде и не должно попадать в вопрос, к которому привязывается ответ. Вопрос, построенный на + таких изменчивых данных, делает любой записанный ответ устаревшим на вид, и сервер задаёт его + заново в каждом раунде, пока лимит раундов на стороне клиента не завершит вызов. + +## Вопрос клиенту, а не пользователю {#ask-the-client-not-the-user} + +Элицитация — один из трёх вопросов, которые может задать резолвер, и многораундовый поток других не допускает. Два других адресованы **клиенту**, а не пользователю: верните `Sample(...)`, чтобы выполнить вызов LLM через клиент (запрос `sampling/createMessage`), или `ListRoots()`, чтобы получить текущие корневые каталоги (roots) клиента. Ни у одного из них нет исхода accept/decline; потребитель аннотирует тип результата напрямую: `CreateMessageResult` (`CreateMessageResultWithTools`, когда запрос несёт `tools` или `tool_choice`) или `ListRootsResult`: + +```python title="server.py" hl_lines="10-15 21" +--8<-- "docs_src/dependencies/tutorial004.py" +``` + +* Фреймворк маршрутизирует их точно так же, как `Elicit`: внутри многораундового `tools/call` на **2026-07-28**, через отдельный запрос сервер->клиент на **2025-11-25**. При необъявленной возможности вызов отклоняется с протокольной ошибкой `-32021` (`sampling`, `roots`, `elicitation` в режиме формы; `sampling.tools`, когда запрос несёт `tools` или `tool_choice`). +* Всё, что сказано о вопросах в блоке info выше, применимо без изменений: запрос `Sample` сопоставляется с записанным результатом по точному представлению, поэтому стройте его детерминированно из аргументов инструмента и предыдущих ответов; тогда клиент платит за вызов LLM один раз за вызов инструмента, а не один раз за раунд. Записанный результат передаётся в `request_state` до конца вызова, так что очень большой результат генерации утяжеляет каждый оставшийся раунд обмена. +* Отдельные *возможности* сэмплирования (sampling) и корневых каталогов объявлены устаревшими в 2026-07-28 (SEP-2577). Новые серверы, которым нужна модель клиента, спрашивают через этот носитель; серверам, которым она не нужна, следует интегрироваться с провайдером LLM напрямую. Значения `include_context`, отличные от `"none"`, сами объявлены устаревшими; избегайте их. + +## Итоги {#recap} + +* `Annotated[T, Resolve(fn)]` у параметра инструмента: SDK запускает `fn` и внедряет её возвращаемое значение. +* Разрешённый параметр невидим для модели, и клиент не может его передать. Значениям, которые модель не должна выдумывать, — ценам, данным о личности, правам доступа — место здесь. +* Параметры резолвера разрешаются так же: `Context`, другой `Resolve(...)` или аргумент инструмента по имени. Граф запускает каждый резолвер не более одного раза за раунд, сколько бы потребителей у него ни было; каждый вопрос задаётся ровно один раз, и любой резолвер может выполниться снова, когда вызов возобновляется после вопроса. +* Плохие графы падают при регистрации с `InvalidSignature`, а не посреди вызова. +* Возвращайте `Elicit(message, Model)`, чтобы спросить пользователя, — только когда иначе нельзя. Аннотации без обёртки прерывают вызов при отказе; `ElicitationResult[T]` позволяет инструменту ветвиться. +* Возвращайте `Sample(...)` или `ListRoots()`, чтобы запросить у клиента генерацию LLM или список корневых каталогов; внедряется сам результат. + +Состоянию, которое сервер строит один раз при запуске, и тому, как обработчик до него добирается, посвящена страница **[Жизненный цикл](lifespan.md)**. diff --git a/i18n/ru/pages/handlers/elicitation.md b/i18n/ru/pages/handlers/elicitation.md new file mode 100644 index 0000000000..9ac0be4b4d --- /dev/null +++ b/i18n/ru/pages/handlers/elicitation.md @@ -0,0 +1,190 @@ +--- +translation: + sections: [335ca2a0b266f003, d1ad562d3fe87bc0, 0bb1396c86daeba4, d1cb1235bb9ee267, 833179c09d239c83, e5d6dec2d2e655e8] + tool: 1 +--- +# Элицитация {#elicitation} + +Инструменту, который уже наполовину сделал свою работу и которому не хватает одного ответа, не обязательно завершаться ошибкой. + +**Элицитация** (elicitation) позволяет ему спросить. Прямо посреди вызова инструмента пользователь получает вопрос, а его ответ возвращается в тот же самый вызов функции. + +Есть два режима: + +* **Режим формы**: нужно значение (подтверждение, дата, количество). Вы описываете поля, клиент отображает форму. +* **Режим URL**: нужно, чтобы пользователь перешёл куда-то ещё (экран согласия OAuth, страница оплаты). Ничто из того, что он там делает, не проходит через протокол. + +И есть два способа спросить. Предпочтительный — **резолвер**: вопрос привязывается к параметру, а SDK задаёт его сам — на любом подключении, какого бы поколения протокол ни использовал клиент. Прямой способ, `await ctx.elicit(...)`, — это запрос от *сервера* к *клиенту*, а такой канал существует только для клиента на подключении старого поколения (версия спецификации 2025-11-25 или более ранняя). На этой странице описаны оба; начните с резолвера. + +## Вопрос с помощью резолвера {#ask-with-a-resolver} + +Вопрос, от которого зависит весь инструмент, — *вы уверены? какой из трёх подходящих аккаунтов?* — можно вынести из тела инструмента в **резолвер**, и фреймворк задаст его за вас. + +Параметр с аннотацией `Annotated[T, Resolve(fn)]` заполняется результатом вызова `fn` перед телом инструмента. Резолвер возвращает значение напрямую, если уже знает его, или возвращает `Elicit(...)`, чтобы вопрос задал фреймворк: + +```python title="server.py" hl_lines="24-30 35-36" +--8<-- "docs_src/elicitation/tutorial004.py" +``` + +* `confirm_delete` читает по имени аргумент `path` самого инструмента, перечисляет содержимое папки и **спрашивает только тогда, когда это необходимо** — для пустой папки сразу возвращается `Confirm(ok=True)`, без обмена с клиентом. +* `delete_folder` указывает в аннотации `ElicitationResult[Confirm]`, поэтому фреймворк внедряет результат целиком, а инструмент разбирает через `match` каждый случай: принять и подтвердить, принять, но оставить (`ok=False`), отказаться, отменить. +* Параметр `confirm` никогда не попадает во входную схему инструмента — клиент передаёт `path`, резолвер передаёт `confirm`. + +Если ветвление инструменту не нужно, укажите в аннотации саму модель без обёртки (`Annotated[Confirm, Resolve(confirm_delete)]`): при согласии инструмент получает модель, а при отказе или отмене вызов прерывается с ошибкой. + +Резолвер работает на **любом** подключении. Клиенту на подключении старого поколения SDK отправляет вопрос напрямую; на подключении **2026-07-28** SDK *возвращает* вопрос из вызова, а следующая попытка клиента несёт ответ. Резолвер разницы не замечает; что происходит внутри — на странице **[Многораундовые запросы](multi-round-trip.md)** (multi-round-trip). + +Задать вопрос — лишь одно из того, что умеет резолвер. Общий механизм — зависимости, которые вычисляются без вопросов, зависимости зависимостей, что модель может и не может передать — описан на странице **[Зависимости](dependencies.md)**. + +## Вопрос изнутри инструмента {#ask-from-inside-the-tool} + +Инструмент может и сам остановиться посреди своего тела и спросить. + +!!! warning + `ctx.elicit()` и `ctx.elicit_url()` — это запросы от *сервера* к *клиенту*, а такой + канал существует только для клиента на подключении старого поколения (версия спецификации + **2025-11-25** или более ранняя). На подключении **2026-07-28** запросов по инициативе + сервера нет, поэтому эти вызовы завершаются ошибкой. Резолвер работает в обоих случаях. + Подробнее — на странице **[Версии протокола](../protocol-versions.md)**. + +`await ctx.elicit()` принимает сообщение и модель Pydantic: + +```python title="server.py" hl_lines="9-11 20-23 25" +--8<-- "docs_src/elicitation/tutorial001.py" +``` + +* Параметр **`Context`** — это то, что даёт `ctx.elicit`; принять его может любой инструмент. У этого объекта есть своя страница: **[Объект Context](context.md)**. +* `AlternativeDate` — **схема** нужного ответа. +* Инструмент объявлен как `async def`. Иначе нельзя: он останавливается посреди выполнения и ждёт человека. +* На любую другую дату инструмент отвечает сразу. Спрашивает он только тогда, когда приходится. +* Дата, которую принял пользователь, снова проходит через сам `book_table`. Ответ — такой же ввод, как и любой другой: если альтернативная дата тоже полностью занята, о ней спросят ещё раз, а не подтвердят вслепую. + +### Что получает клиент {#what-the-client-receives} + +Клиент получает ваше сообщение, а рядом с ним — JSON Schema, сгенерированную из модели: + +```json +{ + "properties": { + "accept_alternative": { + "description": "Try another date?", + "title": "Accept Alternative", + "type": "boolean" + }, + "date": { + "default": "2025-12-26", + "description": "Alternative date (YYYY-MM-DD)", + "title": "Date", + "type": "string" + } + }, + "required": ["accept_alternative"], + "title": "AlternativeDate", + "type": "object" +} +``` + +Эта схема и есть форма. `Field(description=...)` — подпись поля; значение по умолчанию заранее заполняет поле ввода и делает его необязательным. Это тот же механизм преобразования Pydantic в JSON Schema, который страница **[Инструменты](../servers/tools.md)** описывает для аргументов инструмента. + +!!! warning + Схема элицитации не так выразительна, как входная схема инструмента. Только плоские + примитивные поля: `str`, `int`, `float`, `bool` или `Literal` из строк (он становится `enum`). + Вложите модель в модель — и `ctx.elicit` выбросит исключение ещё до того, как что-либо уйдёт клиенту: + + ```text + TypeError: Elicitation schema field 'address' rendered as {'$ref': '#/$defs/Address'}, which is not a valid PrimitiveSchemaDefinition + ``` + + Вы прерываете человека посреди задачи. Если ответу нужна вложенность, он должен был быть + аргументом инструмента. + +### Три ответа {#the-three-answers} + +`result.action` говорит, что сделал пользователь, и вариантов ровно три: + +* `"accept"`: пользователь отправил форму. `result.data` — экземпляр `AlternativeDate`, уже прошедший валидацию. +* `"decline"`: пользователь отказался. +* `"cancel"`: пользователь закрыл вопрос, ничего не выбрав. + +`result.data` существует только при `"accept"`, поэтому пример сначала проверяет `result.action`. Средство проверки типов следит за этим порядком: после `result.action == "accept"` `result.data` — это `AlternativeDate`; до этой проверки никакого `.data` нет вообще. + +Отказ — не ошибка. Инструмент сам решает, что означает отказ (здесь — бронь не создаётся), и отвечает модели как обычно. + +!!! tip + Ответ проверяется по вашей модели до того, как его увидит ваш код. Клиент, приславший + `"maybe"` вместо `bool`, не испортит бронирование: вызов завершится ошибкой + несоответствия схеме, а ваш `if` так и не выполнится. + +## Отправка пользователя по URL {#send-the-user-to-a-url} + +Некоторые вещи не должны проходить через модель или клиент: учётные данные, номера карт, согласие OAuth. В таких случаях вы просите не данные, а просите пользователя куда-то перейти: + +```python title="server.py" hl_lines="10-14 23" +--8<-- "docs_src/elicitation/tutorial002.py" +``` + +* `ctx.elicit_url()` принимает сообщение, **URL**, который нужно открыть, и выбранный вами `elicitation_id` — любую строку, идентифицирующую эту элицитацию в пределах сервера. +* В результате есть действие и больше ничего. `"accept"` означает, что пользователь согласился открыть URL, а **не** что он завершил то, что находится по ту сторону. +* Оплата происходит вне протокола, между браузером пользователя и вашим платёжным провайдером. Никакое содержимое через MCP обратно не приходит. + +Взгляните на второй инструмент. Когда сервер узнаёт, что внешний процесс завершился (вебхук, опрос; здесь это смоделировано как второй инструмент), `ctx.session.send_elicit_complete(...)` отправляет `notifications/elicitation/complete` с тем же `elicitation_id`. Так клиент узнаёт, что можно перестать показывать *«ожидание оплаты…»*. Без этого клиенту остаётся только гадать. + +## Сторона клиента {#the-client-side} + +Серверы спрашивают. Клиенты отвечают, передавая **`elicitation_callback`** в `Client(...)`: + +```python title="client.py" hl_lines="6-7 18" +--8<-- "docs_src/elicitation/tutorial003.py" +``` + +* Один колбэк обслуживает оба режима. `params` — объединение `ElicitRequestFormParams` и `ElicitRequestURLParams`; ветвление делается через `isinstance`. +* Для URL вы показываете пользователю `params.url` и возвращаете выбранное им действие. Никакого `content`. +* Для формы настоящее приложение отображает `params.requested_schema` и возвращает ввод пользователя в `content`. Этот колбэк всегда соглашается с заготовленным ответом — ровно то, что нужно в тесте. +* Передача колбэка — это ещё и **объявление возможности**: так сервер узнаёт, что этому клиенту можно задавать вопросы. Остальное, на что клиент может отвечать серверу, — на странице **[Колбэки клиента](../client/callbacks.md)**. + +!!! info + Элицитация — запрос от *сервера* к *клиенту*, а такие запросы существуют только + в сессии с классическим рукопожатием, поэтому этот клиент передаёт `mode="legacy"`. + На подключении **2026-07-28** инструмент вместо этого спрашивает, *возвращая* вопрос из вызова; + этот сценарий — **[Многораундовые запросы](multi-round-trip.md)**. + +### Попробуйте сами {#try-it} + +Запустите `server.py` с `ctx.elicit` в режиме формы (тот, что с `book_table`) на Streamable HTTP (однострочная команда есть на странице **[Запуск сервера](../run/index.md)**), затем запустите `main()` клиента и попросите у `book_table` столик на Рождество. + +Колбэк печатает присланный ему вопрос: + +```text +No tables for 2 on 2025-12-25. Would you like to try another date? +``` + +Он отвечает `{"accept_alternative": True, "date": "2025-12-27"}`, и инструмент, всё это время ждавший внутри `await ctx.elicit(...)`, завершает бронирование: + +```text +Booked a table for 2 on 2025-12-27. +``` + +Теперь подставьте `server.py` в режиме URL и направьте тот же `main()` на `pay_deposit`: тот же колбэк идёт по другой ветке, печатает ссылку на оплату, а инструмент возвращает *«Complete the payment in your browser.»*. Один раунд обмена, посреди вызова, в обе стороны. + +!!! check + Теперь уберите `elicitation_callback=` из `Client` и снова вызовите `book_table` на Рождество. + Весь вызов завершается ошибкой протокола: + + ```text + Elicitation not supported + ``` + + Клиент, не зарегистрировавший колбэк, не объявил возможность `elicitation`, так что спрашивать + некого. Инструмент получил не `"decline"`, а исключение. Учитывайте это при проектировании: + у каждой элицитации должен быть разумный ответ на вопрос «а что, если спросить нельзя?». + +## Итоги {#recap} + +* Параметр с аннотацией `Annotated[T, Resolve(fn)]` заполняет резолвер, который возвращает `Elicit(...)`, когда нужно спросить. Это работает на любом подключении. +* Схема — плоская модель Pydantic: только примитивные поля, ответ проверяется на обратном пути. +* `result.action` — это `"accept"`, `"decline"` или `"cancel"`; `result.data` существует только при accept. +* `await ctx.elicit(message, schema=Model)` спрашивает изнутри тела инструмента, а `await ctx.elicit_url(message, url, elicitation_id)` — для всего, что не должно проходить через модель (`ctx.session.send_elicit_complete(elicitation_id)` сообщает, что внешняя часть завершена). Оба — запросы от сервера к клиенту: клиент должен быть на подключении старого поколения. +* Клиент отвечает одним `elicitation_callback`, ветвясь по типу params; его регистрация и объявляет возможность. +* На подключении 2026-07-28 сервер возвращает вопрос, а не отправляет его сам; тот же колбэк получает вопросы через **[Многораундовые запросы](multi-round-trip.md)**. + +Всё, что стоит за этим возвратом (цикл повторных попыток, защита `requestState`, самостоятельное управление процессом), — на странице **[Многораундовые запросы](multi-round-trip.md)**. diff --git a/i18n/ru/pages/handlers/index.md b/i18n/ru/pages/handlers/index.md new file mode 100644 index 0000000000..c3cf7497b8 --- /dev/null +++ b/i18n/ru/pages/handlers/index.md @@ -0,0 +1,38 @@ +--- +translation: + sections: [424930166c4bc6f3] + tool: 1 +--- +# Внутри обработчика {#inside-your-handler} + +Аргументы обработчика приходят от клиента. Всё *остальное*, что он может +прочитать, и всё, что он может делать во время выполнения, описано здесь. + +Что он может прочитать: + +* **[Объект Context](context.md)** — единственный дополнительный параметр, + который может запросить любой обработчик: текущий запрос, его заголовки, + его сессия, а также методы для уведомлений о ходе выполнения и об изменениях. +* **[Зависимости](dependencies.md)** — параметры, которые модель никогда не + видит; их заполняют ваши собственные функции через `Resolve`. +* **[Жизненный цикл](lifespan.md)** (lifespan) — состояние, которое сервер + создаёт один раз при запуске, и то, как обработчик добирается до него через + `Context`. + +Что он может делать во время выполнения: + +* Запрашивать у пользователя дополнительные данные — **[Элицитация](elicitation.md)** + (elicitation) и **[Многораундовые запросы](multi-round-trip.md)** + (multi-round-trip), шаблон версии 2026-07-28, через который она передаётся. +* Просить у клиента генерацию LLM или папки его рабочего пространства — + **[Сэмплирование и корневые каталоги](sampling-and-roots.md)** (sampling + и roots), возможности устаревшие, но всё ещё обслуживаемые. +* Сообщать о **[ходе выполнения](progress.md)** долгой операции. +* Писать логи (в стандартный поток ошибок, для тех, кто эксплуатирует + сервер) — **[Логирование](logging.md)**. +* Сообщать подписанным клиентам, что что-то изменилось, — + **[Подписки](subscriptions.md)**. + +Если обработчик ещё не зарегистрирован, начните со страницы +**[Инструменты](../servers/tools.md)**. Каждая страница здесь предполагает, +что он у вас уже есть. diff --git a/i18n/ru/pages/handlers/lifespan.md b/i18n/ru/pages/handlers/lifespan.md new file mode 100644 index 0000000000..0205bb049d --- /dev/null +++ b/i18n/ru/pages/handlers/lifespan.md @@ -0,0 +1,107 @@ +--- +translation: + sections: [f3ca8ac5f90f2dfa, 85a1ef3588ba0736, 563346d4d5804933, 9e3528340d0bab53] + tool: 1 +--- +# Жизненный цикл {#lifespan} + +Большинство настоящих серверов держат что-то на протяжении всей своей работы: пул соединений с базой данных, HTTP-клиент, загруженную модель. + +Создавать это при каждом вызове не хочется, а вот закрыть аккуратно — нужно. Для этого и служит **жизненный цикл** (lifespan). + +## Типизированный жизненный цикл {#a-typed-lifespan} + +Жизненный цикл — это `@asynccontextmanager`, который получает сервер и отдаёт через `yield` **один объект**. Всё, что вы отдаёте, доступно каждому обработчику, пока сервер работает. + +```python title="server.py" hl_lines="25-31 34 38 40" +--8<-- "docs_src/lifespan/tutorial001.py" +``` + +Читайте снизу вверх: + +* `app_lifespan` подключает `Database` **до** `yield` и отключает её **после**, в блоке `finally`. Это запуск и остановка. +* Он отдаёт `AppContext` — обычный dataclass с тем, что вы подготовили. Сегодня одно поле, завтра десять. +* `MCPServer("Bookshop", lifespan=app_lifespan)` — вот и вся связка. +* Внутри инструмента отданный объект — это `ctx.request_context.lifespan_context`. + +Жизненный цикл выполняется **один раз**. Вход в него происходит при запуске сервера (до первого запроса), выход — при остановке. Все запросы между этими моментами разделяют один и тот же `AppContext`. + +!!! info + Если вы писали `lifespan` для FastAPI, вы это уже знаете. Тот же декоратор, тот же `yield`, тот же `finally`. + +### Что видит модель {#what-the-model-sees} + +Ничего нового. `ctx` — параметр типа **Context**, поэтому SDK внедряет его сам, и во входную схему он не попадает: + +```json +{ + "type": "object", + "properties": { + "genre": {"title": "Genre", "type": "string"} + }, + "required": ["genre"], + "title": "count_booksArguments" +} +``` + +`genre` — единственный аргумент, который может передать модель. Жизненный цикл — внутреннее дело вашего сервера. + +Функции `@mcp.resource()` и `@mcp.prompt()` тоже могут принимать параметр `ctx`, записанный как просто `Context` — почему, объясняется в следующем разделе. Всё, что несёт в себе `ctx`, описано на странице **[Объект Context](context.md)**. + +### Он действительно типизирован {#it-really-is-typed} + +Посмотрите на аннотацию ещё раз: `ctx: Context[AppContext]`. + +Именно благодаря этому одному параметру типа `ctx.request_context.lifespan_context` для анализатора типов **и есть** `AppContext`. `.db` дополняется автоматически; `.dbb` — ошибка ещё до того, как вы запустите сервер. + +Напишите вместо этого просто `Context` — и `lifespan_context` получит тип `dict[str, Any]`: анализатору типов неоткуда узнать, что отдал ваш жизненный цикл. Во время выполнения объект по-прежнему на месте; вы лишь теряете подсказки. + +!!! warning + `Context[AppContext]` — запись **только для инструментов**. Поставьте её на функцию + `@mcp.resource()` или `@mcp.prompt()` — и каждый вызов этого обработчика завершится ошибкой. + Клиент получит ошибку в ответ, а в логе сервера будет видна причина: + + ```text + Context is not available outside of a request + ``` + + В ресурсах и промптах пишите просто `ctx: Context`. Объект, который отдал ваш жизненный + цикл, во время выполнения по-прежнему лежит в `ctx.request_context.lifespan_context`; вы + отказываетесь от параметра типа, а не от объекта. + +!!! tip + Жизненный цикл есть всегда. Если не передать свой, вариант SDK по умолчанию отдаёт пустой + `dict`, так что `ctx.request_context.lifespan_context` равен `{}` и никогда не `None`. Из-за + этого же значения по умолчанию простой `Context` типизирует его как `dict[str, Any]`. + +## Посмотрите, как это происходит {#watch-it-happen} + +«Запуск выполняется до первого запроса» — из тех утверждений, которые не стоит принимать на веру. + +Урежьте сервер до одного только жизненного цикла: дайте `Database` флаг `connected`, переключайте его в `connect()` и `disconnect()` и добавьте инструмент, который о нём сообщает. + +```python title="server.py" hl_lines="11 14 17 25 44" +--8<-- "docs_src/lifespan/tutorial002.py" +``` + +`database` живёт на уровне модуля по одной причине: чтобы на неё можно было посмотреть *снаружи* сервера. + +!!! check + Три момента — три значения: + + * До запуска сервера `database.connected` равно `False`. Импорт модуля ничего не подключил. + * Пока сервер работает, вызовите `database_status` — результат будет `"connected"`. + * Остановите сервер, и выполнится блок `finally`: `database.connected` снова `False`. + + Работа произошла ровно там, куда вы её поместили: вокруг `yield`, а не при импорте и не на каждый запрос. + +## Итоги {#recap} + +* `lifespan=` принимает `@asynccontextmanager`, который получает сервер и отдаёт через `yield` один объект. +* Код до `yield` — это запуск. `finally` после него — остановка. +* Он выполняется один раз, вокруг всей жизни сервера, а не на каждый запрос. +* Всё, что вы отдаёте через `yield`, — это `ctx.request_context.lifespan_context` в каждом инструменте, ресурсе и промпте. +* `ctx: Context[AppContext]` делает этот доступ полностью типизированным в инструментах. Ресурсы и промпты принимают просто `Context`. +* Нет `lifespan=` — значит, пустой `dict`, и никогда не `None`. + +Обработчик, который останавливается посреди вызова, чтобы спросить пользователя о том, что знает только он, — это **[элицитация (elicitation)](elicitation.md)**. diff --git a/i18n/ru/pages/handlers/logging.md b/i18n/ru/pages/handlers/logging.md new file mode 100644 index 0000000000..45e0172dba --- /dev/null +++ b/i18n/ru/pages/handlers/logging.md @@ -0,0 +1,87 @@ +--- +translation: + sections: [c93a3e1aefd77955, 7851abd5ec54393b, f49d1ca2f330f9cd, c03764bd9dfeef7b, 4a0391691a674ae4, 2df5cd279eabf9f5] + tool: 1 +--- +# Логирование {#logging} + +Пишите в лог из инструмента так же, как из любой другой функции Python: средствами стандартной библиотеки. + +В MCP есть **возможность логирования** на уровне протокола: сервер мог отправлять свои сообщения лога клиенту в виде уведомлений через методы объекта `Context`. Ревизия спецификации 2026-07-28 **объявляет эту возможность устаревшей и ничем её не заменяет**, поэтому в этой документации она не описывается. Полный список того, что устарело и что делать взамен, — на странице **[Устаревшие возможности](../deprecated.md)**. + +Взамен делайте то же, что в любой другой программе на Python: используйте стандартную библиотеку. + +## Инструмент, который пишет в лог {#a-tool-that-logs} + +```python title="server.py" hl_lines="1 5 13" +--8<-- "docs_src/logging/tutorial001.py" +``` + +* `logging.getLogger(__name__)` возвращает логгер, названный по имени модуля. Создайте его один раз, в начале файла. +* Внутри инструмента вызывайте `logger.info(...)`, как в любой другой функции. Ничего не нужно внедрять, ничего не нужно ждать через `await`, ничего специфичного для MCP. + +!!! check + Вызовите инструмент и посмотрите на результат целиком: + + ```python + result.content # [TextContent(text="Found 3 books matching 'dune'.")] + result.structured_content # {'result': "Found 3 books matching 'dune'."} + ``` + + Строки лога в нём нет нигде. Логи — для **вас**, того, кто эксплуатирует сервер. Модель + их никогда не видит. Если модель должна что-то прочитать, верните это через `return`. + +## Куда попадает вывод {#where-it-goes} + +Для сервера на **stdio** этот вопрос важнее обычного. Хост запустил ваш сервер как подпроцесс и читает MCP-сообщения из его **stdout**. Стандартный поток ошибок — ваш. + +Стандартная библиотека уже поступает правильно: по умолчанию вывод логов идёт в `sys.stderr`. Строки из `logger.info(...)` попадают в терминал (или туда, куда хост собирает stderr подпроцесса), а поток протокола остаётся чистым. + +!!! tip + Не используйте `print()` в stdio-сервере. `print` пишет в **stdout**, а stdout принадлежит протоколу. + Пока сервер работает, SDK перенаправляет в stderr то, что действительно *сброшено* из буфера stdout, + так что испортить передаваемые данные это не может. Но в процессе с блочной буферизацией вывод + `print()` обычно лежит несброшенным в буфере `sys.stdout`, пока интерпретатор не опустошит его + при выходе — прямо в поток протокола. И даже когда строка перенаправлена, она попадает в вывод + логов как есть: без уровня, без имени логгера и без возможности её отфильтровать. + + `logger.debug("got here")` — те же усилия на одну строку, и попадает она куда нужно. + +## Уровень {#the-level} + +Вызывать `logging.basicConfig()` самостоятельно не нужно. Конструктор `MCPServer` уже сделал это: с обработчиком, направленным в стандартный поток ошибок, и с уровнем, который вы передаёте в `log_level=`. Так что `MCPServer("Bookshop", log_level="DEBUG")` — всё, что нужно, чтобы увидеть строки из `logger.debug(...)`. + +По умолчанию — `"INFO"`. + +`logging.basicConfig()` никогда не заменяет уже существующие обработчики. Если настроить логирование самостоятельно до создания сервера, ваша конфигурация имеет приоритет. + +## Попробуйте сами {#try-it} + +Запустите сервер через MCP Inspector: + +```console +uv run mcp dev server.py +``` + +Вызовите `search_books` на вкладке **Tools**. Inspector показывает результат: только возвращённое значение. Строка + +```text +Searching for 'dune' +``` + +ушла в стандартный поток ошибок: в терминал, а не в передаваемые данные. + +!!! info + Если на самом деле нужна *трассировка* (каждый запрос, сколько он занял, завершился ли ошибкой), + нужны не строки лога, а спаны. Сервер уже их выдаёт: SDK по умолчанию трассирует каждое + сообщение с помощью OpenTelemetry. См. **[OpenTelemetry](../run/opentelemetry.md)**. + +## Итоги {#recap} + +* Возможность логирования в протоколе MCP объявлена устаревшей в спецификации 2026-07-28 и ничем не заменена. Не стройте на ней ничего. +* `logger = logging.getLogger(__name__)` на уровне модуля, `logger.info(...)` в инструменте. Вот и весь паттерн. +* Вывод логов никогда не доходит до модели. Доходит только значение, которое вы возвращаете через `return`. +* Стандартный поток ошибок — ваш; stdout принадлежит протоколу. Пока сервер работает, SDK перенаправляет сброшенный посторонний вывод из stdout в stderr, но несброшенный `print()` всё равно может вылиться в поток протокола при выходе, а перенаправленные строки приходят без меток. Используйте `logging` — его обработчик сбрасывает буфер после каждой записи. +* `MCPServer(..., log_level="DEBUG")` задаёт уровень, а конфигурацию логирования, которую вы сделали раньше, не трогает. + +О том, как сообщить подключённым клиентам, что на сервере что-то изменилось (список инструментов, ресурс), — на странице **[Подписки](subscriptions.md)**. diff --git a/i18n/ru/pages/handlers/multi-round-trip.md b/i18n/ru/pages/handlers/multi-round-trip.md new file mode 100644 index 0000000000..2c0ba39534 --- /dev/null +++ b/i18n/ru/pages/handlers/multi-round-trip.md @@ -0,0 +1,191 @@ +--- +translation: + sections: [74011e683045eea9, 9b64cc175c18b6a9, 4b41be4824030397, e3b1502da786ec33, 71e41161f143c6a9, 9ec2c1eeb8c36378, 8dd027377d46448b, f81491125dcbfe8b] + tool: 1 +--- +# Многораундовые запросы (multi-round-trip) {#multi-round-trip-requests} + +Иногда инструмент не может завершить работу за один цикл «запрос — ответ». Ему нужно что-то, что есть только у пользователя: выбор, подтверждение, учётные данные. + +До версии 2026-07-28 сервер получал это, вызывая клиент **в ответ**: прямо посреди обработки исходного запроса он открывал собственный запрос к клиенту — элицитацию (elicitation) или вызов сэмплирования (sampling). Спецификация 2026-07-28 упраздняет этот обратный канал (back-channel). + +Вместо этого сервер **возвращает результат**. + +## Возвращать, а не вызывать в ответ {#return-dont-call-back} + +На `tools/call` сервер отвечает **`InputRequiredResult`** вместо `CallToolResult`. Всю работу делают два его поля: + +* **`input_requests`**: то, чего серверу ещё не хватает, в виде словаря с ключами, которые выбрал сам сервер. Каждое значение — `ElicitRequest`, `CreateMessageRequest` или `ListRootsRequest`. +* **`request_state`**: непрозрачный токен. При повторном вызове клиент возвращает его дословно. Читает его только ваш сервер. + +Клиент выполняет каждый запрос, а затем вызывает **тот же инструмент снова**, передавая ответы в `input_responses`, а токен — в `request_state`. Теперь у сервера есть всё, чего не хватало, и он возвращает обычный `CallToolResult`. + +Вот и весь протокол. Каждый этап — обычный запрос от клиента к серверу. В обратную сторону ничего не передаётся. + +## Сторона сервера {#the-server-side} + +В `@mcp.tool()` это редко собирают вручную: объявите зависимость, которая спрашивает пользователя (`Elicit`), сэмплирует LLM клиента (`Sample`) или получает список его корневых каталогов (roots; `ListRoots`), — и SDK вернёт `InputRequiredResult` за вас; эта форма описана на странице **[Зависимости](dependencies.md)**. Две формы не сочетаются: у вызова один канал `input_responses`/`request_state`, поэтому инструмент с параметрами `Resolve(...)` не может ещё и возвращать `InputRequiredResult` из своего тела. Объявленный возвращаемый тип `InputRequiredResult` отклоняется при регистрации (`InvalidSignature`), а необъявленный приводит к ошибке вызова во время выполнения. Ручная форма — это **низкоуровневый** `Server`, чей обработчик `on_call_tool` может возвращать результат любого из двух типов: + +```python title="server.py" hl_lines="43-46" +--8<-- "docs_src/mrtr/tutorial001.py" +``` + +* `on_call_tool` имеет тип `-> CallToolResult | InputRequiredResult`. Вернуть второй — вот и весь серверный API. +* При первом вызове `params.input_responses` равен `None`, поэтому срабатывает проверка и обработчик спрашивает, а не отвечает. +* При повторном вызове `ElicitResult`, присланный клиентом, лежит под **тем же ключом** (`"region"`), который сервер использовал в `input_requests`. + +Всё остальное в этом файле (явная `input_schema`, собранный вручную `CallToolResult`) — обычный низкоуровневый `Server`, описанный на странице **[Низкоуровневый Server](../advanced/low-level-server.md)**. Эта страница лишь добавляет второй тип возвращаемого значения. + +## Не только инструменты {#beyond-tools} + +`tools/call` ничем не выделяется: в версии 2026-07-28 сервер может так же отвечать на `prompts/get` и `resources/read`. В `MCPServer` функция `@mcp.prompt()` — или **шаблонная** функция `@mcp.resource()` — сама возвращает `InputRequiredResult` и читает ответы повторного вызова из контекста: + +```python title="server.py" hl_lines="20 22 24" +--8<-- "docs_src/mrtr/tutorial004.py" +``` + +* Первый раунд возвращает `InputRequiredResult`. При повторном вызове ответы лежат в `ctx.input_responses` под теми же ключами, и функция возвращает свой обычный результат — здесь это сообщения промпта, а для шаблонного ресурса — содержимое ресурса. +* Заданный вами `request_state` запечатывается перед отправкой по сети и проверяется при возврате, как и всё остальное на сервере; раздел **[Защита `requestState`](#protecting-requeststate)** ниже рассказывает, что даёт запечатывание и когда нужно настраивать ключи. +* Функция `@mcp.tool()` может точно так же вернуть результат напрямую, если форма с зависимостями не подходит. +* Статические функции `@mcp.resource()` не участвуют: они не принимают `Context`, а значит, никак не смогли бы прочитать повторный вызов. Спрашивать могут только шаблонные ресурсы. +* Правила поколений, описанные ниже, действуют без изменений: вернуть `InputRequiredResult` в сессии до 2026 года — это та же ошибка `-32603`, о которой говорит предупреждение. + +## Сторона клиента {#the-client-side} + +`Client` выполняет цикл за вас. + +Зарегистрируйте колбэки, которые могут понадобиться серверу (`elicitation_callback`, `sampling_callback`, `list_roots_callback`), и вызовите инструмент. Когда приходит `InputRequiredResult`, `Client` передаёт каждую запись из `input_requests` соответствующему колбэку, повторяет вызов с ответами и возвращённым `request_state` и продолжает, пока не вернётся `CallToolResult`: + +```python title="client.py" hl_lines="11 12" +--8<-- "docs_src/mrtr/tutorial003.py" +``` + +* Этот `elicitation_callback` — тот же самый, в который попал бы `elicitation/create` по обратному каналу от сервера до 2026 года. То же верно для `sampling_callback` и `sampling/createMessage`, а также для `list_roots_callback` и `roots/list`: в версии 2026-07-28 отдельных RPC от сервера к клиенту больше нет, но те же самые полезные нагрузки `ElicitRequest` / `CreateMessageRequest` / `ListRootsRequest` передаются внутри `input_requests` и попадают в те же три колбэка. Один набор колбэков обслуживает оба поколения. +* `call_tool` возвращает обычный `CallToolResult`. Промежуточные раунды для вызывающего кода невидимы. +* `get_prompt` и `read_resource` запускают тот же цикл. + +!!! check + Уберите колбэк — и цикл завершится ошибкой на первом же раунде: колбэк-заглушка SDK + отвечает на каждую элицитацию ошибкой, и `call_tool` выбрасывает `MCPError` с сообщением + *«Elicitation not supported»*. + +Цикл ограничен. По умолчанию предел — `Client(..., input_required_max_rounds=10)`; если сервер продолжает возвращать `InputRequiredResult` сверх него, `call_tool` выбрасывает исключение. Если раунд несёт только `request_state` без `input_requests`, `Client` перед повтором делает короткую паузу (50 мс, с удвоением до потолка в 250 мс), чтобы не донимать частым опросом сервер, который просто говорит *«ещё не готово»*. + +### Управление циклом вручную {#driving-the-loop-yourself} + +Автоматического цикла хватает для клиента в одном процессе. Берите цикл в свои руки, когда: + +* Клиент **распределённый**: процесс, который показывает вопрос пользователю, — не тот, что вызвал `call_tool`, поэтому повторный вызов отправляет другой рабочий процесс. `request_state` — сохраняемый токен, который переносится через эту границу с помощью вашего собственного хранилища, а `input_responses` — то, что другая сторона отправляет вместе с ним. +* Нужно **инспектировать** каждый раунд: логировать или аудировать каждую запись `input_requests`, отклонять определённые виды запросов или применять собственную задержку между этапами. +* Нужно ограничение по **реальному времени**, а не по числу раундов: оберните собственный цикл в `anyio.fail_after(...)`, вместо того чтобы полагаться на `input_required_max_rounds`. + +Спуститесь к нижележащей сессии, где `allow_input_required=True` отдаёт объединённый тип напрямую: + +```python title="client.py" hl_lines="12 13 19" +--8<-- "docs_src/mrtr/tutorial002.py" +``` + +* `client.session.call_tool(..., allow_input_required=True)` расширяет возвращаемый тип до `CallToolResult | InputRequiredResult`. Обратно его сужает `isinstance`. +* `request_state` теперь в ваших руках. Записывайте его между этапами — и разговор можно продолжить из нового процесса. +* Для каждой записи в `input_requests` кладите `InputResponse` под **тем же ключом** в `input_responses`. `fulfil` — место для вашего UI; здесь ответ зашит в коде. +* То же имя инструмента, те же `arguments` — на каждом этапе. Повторный вызов — это исходный вызов, выполненный ещё раз, а не новый метод. + +## Защита `requestState` {#protecting-requeststate} + +Всё сказанное выше обращается с `request_state` как с эхом, и в передаваемых данных это эхо и есть. Но между этапами его держит клиент (записывать его при переходе между процессами — ровно то, что одобрил предыдущий раздел), поэтому возвращается **ввод, предоставленный клиентом**: его могли изменить, он мог устареть или его могли целиком взять из другого вызова. Спецификация требует, чтобы серверы защищали целостность этого состояния и отклоняли раунд при неудачной проверке — всякий раз, когда состояние способно повлиять на авторизацию, доступ к ресурсам или бизнес-логику. + +`MCPServer` защищает его по умолчанию. Каждый сервер запечатывает исходящий `requestState` и проверяет каждое эхо — и состояние резолверов, и собранное вручную — ключом, сгенерированным при запуске процесса. Настраивать ничего не нужно: вы пишете открытый текст и читаете открытый текст, а по сети всегда передаётся только непрозрачный зашифрованный токен. + +Ключ по умолчанию живёт и умирает вместе с процессом — и это единственное, что нужно знать перед развёртыванием за пределами одного процесса: + +```python +from mcp.server.mcpserver import MCPServer, RequestStateSecurity + +# Multi-instance or restart-surviving: one or more shared secret keys (>= 32 bytes each). +mcp = MCPServer("fleet", request_state_security=RequestStateSecurity(keys=[key])) +``` + +* **Вариант по умолчанию (без настройки)** подходит для одного процесса: stdio или ровно один рабочий процесс HTTP. Повторный вызов, попавший на другой рабочий процесс, на другой экземпляр за балансировщиком нагрузки или на тот же сервер после перезапуска, запечатан ключом, которого у этого процесса нет, — клиент получает описанный ниже фиксированный отказ и должен начать обмен заново. +* **`keys=[...]`** обязателен всякий раз, когда повторный вызов может попасть на **другой экземпляр** (`uvicorn` с несколькими рабочими процессами, HTTP за балансировщиком) или должен переживать перезапуски: каждый экземпляр проверяет то, что выпустил любой из его собратьев. Тот же механизм, только ваш секрет вместо сгенерированного. +* Для собственной криптографии, например KMS или уже существующего сервиса токенов, передайте `RequestStateSecurity(codec=...)` вместо `keys`; контракт описан ниже, в разделе **[Собственная криптография](#bring-your-own-crypto)**. + +### Что несёт в себе запечатанный токен {#what-the-seal-carries} + +С настройками или без, `requestState` в передаваемых данных — это зашифрованный аутентифицированный токен. Ваш код его никогда не видит: обработчики и резолверы пишут и читают открытый текст (`ctx.request_state`); SDK запечатывает на выходе и проверяет на входе. Помимо целостности, каждый токен привязан к: + +* **Временному окну.** Каждый раунд запечатывает состояние заново со свежим сроком действия, поэтому `RequestStateSecurity(ttl=...)` (по умолчанию 600 секунд) ограничивает время на раздумья в одном раунде, а не весь обмен. +* **Аутентифицированному принципалу.** Когда запрос несёт OAuth-токен доступа, проверенный SDK, состояние привязывается к клиенту, издателю и субъекту токена: состояние, выпущенное для одного пользователя, не пройдёт проверку у другого, даже если оба пользуются одним OAuth-клиентом. Верификатор, не сообщающий субъекта, ослабляет привязку до одной лишь идентичности клиента, которая при идентификаторах клиентов на основе URL общая для всех пользователей этого клиентского ПО. Когда аутентификация завершается вне SDK (на прокси перед сервером) или транспорт не аутентифицирован, привязывать не к кому, и эта проверка бездействует — если только `RequestStateSecurity(bind_principal=...)` не предоставит принципала из вашего собственного сигнала идентичности. Какие бы компоненты ни сообщал ваш верификатор токенов, он должен сообщать их единообразно: верификатор, который в одних запросах включает субъект, а в других опускает, меняет принципала посреди обмена, и раунды в процессе выполнения отклоняются. +* **Исходному запросу.** Метод, имя инструмента или промпта (или URI ресурса) и дайджест аргументов. Токен, воспроизведённый против другого инструмента, других аргументов или другого метода, не пройдёт проверку. +* **Точному заданному вопросу.** Каждый ответ резолвера закреплён за отрисованным вопросом, который показали клиенту, — и в том раунде, где он впервые приходит, и когда записанный ответ используется повторно позже. Разверните версию с перефразированным сообщением или изменённой схемой — и сервер спросит заново, а не примет устаревший ответ. Это же закрепление работает и в другую сторону: стройте сообщения из аргументов инструмента, а не из данных конкретного вызова. Сообщение, собранное из метки времени или текущего курса, в каждом раунде отрисовывается по-разному, поэтому каждый записанный ответ выглядит устаревшим, и сервер переспрашивает, пока лимит раундов клиента не завершит вызов. + +Всё это — работа SDK, а не ваша, и не работа кодека, если вы приносите свой. + +### Ротация ключей {#rotating-keys} + +`keys[0]` запечатывает новое состояние; проверяет каждый ключ из списка. Ротация без простоя проходит в три фазы, каждая из которых полностью развёрнута до начала следующей: + +```python +RequestStateSecurity(keys=[OLD, NEW]) # 1: every instance learns to verify NEW; OLD still mints +RequestStateSecurity(keys=[NEW, OLD]) # 2: NEW mints; in-flight OLD state keeps verifying +RequestStateSecurity(keys=[NEW]) # 3: one ttl after phase 2 is fully out, retire OLD +``` + +Никогда не продвигайте выпускающий ключ первым: выпуск под ключом, который какой-то экземпляр ещё не умеет проверять, обрывает раунды в процессе выполнения прямо посреди развёртывания. + +Ключи ограничены одним сервисом. Запечатанный конверт также содержит имя сервера в качестве утверждения об аудитории (audience), поэтому токен, выпущенный другим сервисом, который случайно использует тот же секрет, всё равно отклоняется. Это утверждение отличительно ровно настолько, насколько отличительно имя, поэтому сервер с явно заданной политикой должен иметь настоящее имя или задать `RequestStateSecurity(audience=...)` — безымянный выбрасывает исключение при создании. `audience=` также служит намеренным многосервисным топологиям, где один сервис должен принимать состояние, выпущенное другим. (Вариант по умолчанию без настройки от этого освобождён: его ключ никогда не покидает процесс, так что утверждению об аудитории нечего добавить.) + +### Собственная криптография {#bring-your-own-crypto} + +`RequestStateSecurity(codec=...)` принимает что угодно с методами `seal(bytes) -> str` и `unseal(str) -> bytes`, выбрасывающими `InvalidRequestState` для любого токена, который этот кодек не выпускал. Классическая форма — конвертное шифрование через KMS: ключ данных разворачивается один раз при запуске, а криптография для каждого токена остаётся локальной: + +```python title="server.py" hl_lines="12 26-27 34-35 38" +--8<-- "docs_src/mrtr/tutorial005.py" +``` + +TTL, привязка к принципалу и привязка к запросу — **не** забота кодека: SDK вписывает их в полезную нагрузку перед `seal` и заново проверяет после `unseal`, для любого кодека. Единственные обязанности кодека — целостность (подделан — значит, исключение) и, в идеале, конфиденциальность. + +### Когда проверка не проходит {#when-verification-fails} + +Любой входящий сбой — токен подделан, просрочен, воспроизведён против другого запроса или принципала либо запечатан ключом, которого этот сервер не знает, — получает один и тот же ответ: + +```json +{"code": -32602, "message": "Invalid or expired requestState"} +``` + +Одно фиксированное сообщение на все причины, так что по сети никогда не раскрывается, какая именно проверка не прошла; настоящая причина уходит в лог сервера. Проверяется каждый входящий `requestState` в `tools/call`, `prompts/get` и `resources/read`, в том числе пришедший для обработчика, который вообще не выпускает состояние. На практике самый частый отказ — это не атака, а локальный для процесса ключ по умолчанию, встретивший повторный вызов, сделанный до перезапуска или с другого экземпляра; клиент начинает обмен заново, а когда это важно, лекарство — `keys=[...]`. + +### Состояние, собранное вручную {#hand-built-state} + +`request_state`, который вы задаёте сами (возвращая `InputRequiredResult` из функции инструмента, промпта или шаблонного ресурса), запечатывается и проверяется тем же механизмом, что и состояние резолверов, без единого изменения в коде: пишете открытый текст, читаете открытый текст, и действуют все описанные выше привязки. + +Единственное, что SDK не может закрепить за вас, даже будучи настроенным, — это идентичность вопроса: он не знает, к какому из *ваших* вопросов относится ответ в вашем состоянии. Если ответы хранятся с ключами по вопросам, включайте в состояние собственный идентификатор вопроса и проверяйте его при повторном вызове. + +Низкоуровневый `Server` — уровень без готовых решений: в отличие от `MCPServer`, ничего не запечатывается, пока вы сами не добавите эту границу, и до тех пор ваш `request_state` передаётся по сети ровно так, как записан. Однострочное включение показано на странице **[Низкоуровневый Server](../advanced/low-level-server.md#the-other-handlers)**. + +## Результат версии 2026-07-28 {#a-2026-07-28-result} + +`InputRequiredResult` существует только в версии протокола **2026-07-28**. `Client(server)` в памяти согласует её за вас; по сети её обнаруживает `mode="auto"`. После подключения `client.protocol_version` сообщает, что именно получилось. + +!!! warning + В сессии до 2026 года `InputRequiredResult` некуда положить. Верните его из обработчика на + подключении `mode="legacy"` — и исполнитель не сможет сериализовать его в согласованную версию; + клиент получит ошибку `-32603` *«Handler returned an invalid result»*. Сервер, обслуживающий + оба поколения, должен проверять `ctx.protocol_version`, прежде чем к нему прибегать. + +!!! info + **Элицитация в режиме URL** на подключении 2026 года работает ровно на этом механизме. Запись в + `input_requests` — это `ElicitRequest`, чьи параметры — `ElicitRequestURLParams`; пользователь + завершает внешний сценарий, и ваш клиент повторяет вызов. Тот же цикл, никакого нового API. + Часть про высокоуровневый сервер — на странице **[Элицитация](elicitation.md)**. + +## Итоги {#recap} + +* В версии 2026-07-28 сервер, которому нужен ввод посреди вызова, **возвращает** `InputRequiredResult`. Он никогда не открывает запрос к клиенту. +* `input_requests` — то, что ему нужно. `request_state` — непрозрачный токен возобновления, который читает только сервер. +* `Client` выполняет цикл повторов за вас: зарегистрируйте `elicitation_callback` / `sampling_callback` / `list_roots_callback` — и `call_tool` вернёт обычный `CallToolResult`. Ограничивает его `input_required_max_rounds` (по умолчанию 10). +* Чтобы инспектировать или сохранять раунды, используйте `client.session.call_tool(..., allow_input_required=True)` и ведите цикл `while isinstance(result, InputRequiredResult)` сами. +* В `@mcp.tool()` этот результат за вас формирует зависимость, которая спрашивает пользователя (**[Зависимости](dependencies.md)**); **низкоуровневый** `Server` — ручная форма. +* Промпты и ресурсы тоже участвуют: функция `@mcp.prompt()` или шаблонная `@mcp.resource()` сама возвращает `InputRequiredResult` и читает `ctx.input_responses` при повторном вызове. +* `requestState` возвращается как ввод, предоставленный клиентом, поэтому `MCPServer` запечатывает его по умолчанию — и состояние резолверов, и собранное вручную — локальным для процесса ключом; развёртывания с несколькими экземплярами передают `RequestStateSecurity(keys=[...])` (или собственный кодек), чтобы каждый экземпляр мог проверить то, что выпустил его собрат. Запечатывание привязывает каждый токен к временному окну, исходному запросу и аутентифицированному принципалу — когда запрос несёт аутентификацию, проверенную SDK, или `bind_principal=` предоставляет ваш собственный сигнал идентичности (**[Защита `requestState`](#protecting-requeststate)**). + +Это механизм, который заменяет сэмплирование по инициативе сервера и весь остальной обратный канал в push-стиле; см. **[Устаревшие возможности](../deprecated.md)**. diff --git a/i18n/ru/pages/handlers/progress.md b/i18n/ru/pages/handlers/progress.md new file mode 100644 index 0000000000..77fcb71fb1 --- /dev/null +++ b/i18n/ru/pages/handlers/progress.md @@ -0,0 +1,123 @@ +--- +translation: + sections: [5315262fe26b33e1, 9d8e98840f1b78f0, 0284b215e85366c4, 8534d8dbb4053a70, 2966fac6fe697007] + tool: 1 +--- +# Ход выполнения {#progress} + +Инструмент, который работает тридцать секунд и все тридцать секунд молчит, выглядит сломанным. + +**Уведомления о ходе выполнения** решают эту проблему. Инструмент сообщает, насколько он продвинулся, а клиент решает, что из этого нарисовать: полосу, спиннер, строку в логе. + +## Отчёт о ходе из инструмента {#report-it-from-the-tool} + +Примите параметр **`Context`** и вызовите `report_progress`: + +```python title="server.py" hl_lines="8 11" +--8<-- "docs_src/progress/tutorial001.py" +``` + +Три аргумента, и их смысл определяете вы: + +* `progress`: насколько вы продвинулись. Спецификация требует, чтобы значение **росло** с каждым отчётом: никогда не повторяйте значение и не уменьшайте его. +* `total`: сколько всего, если это известно. Необязательный. +* `message`: одна понятная человеку строка об *этом* шаге. Необязательный. + +`ctx` внедряется по аннотации типов, и модель его не видит: во входной схеме `import_catalog` единственное свойство — `urls`. Этому объекту целиком посвящена страница **[Объект Context](context.md)**; ход выполнения — одна из возможностей, которые он даёт. + +## Приём на стороне клиента {#listen-for-it-from-the-client} + +Клиент подписывается **на каждый вызов отдельно**, передавая `progress_callback=` в `call_tool`: + +```python title="client.py" hl_lines="7 16" +import anyio +from mcp import Client + +from server import mcp + + +async def show(progress: float, total: float | None, message: str | None) -> None: + print(f"{message} ({progress}/{total})") + + +async def main() -> None: + async with Client(mcp) as client: + result = await client.call_tool( + "import_catalog", + {"urls": ["https://example.com/a.json", "https://example.com/b.json"]}, + progress_callback=show, + ) + print(result.structured_content) + + +anyio.run(main) +``` + +Колбэк — это `async`-функция, принимающая ровно то, что сообщил сервер: `progress`, `total`, `message`. + +!!! info + `Client(mcp)` подключается напрямую к объекту сервера, в памяти, — это тот же клиент, на котором + построена страница **[Тестирование](../get-started/testing.md)**. Параметр `progress_callback` один + и тот же, какой бы транспорт ни использовал `Client`; а вот *временны́е характеристики*, которые вы + сейчас увидите, относятся к подключению в памяти. Оно выполняет колбэк прямо на месте, поэтому + каждый отчёт приходит до того, как `call_tool` вернёт управление. По настоящему транспорту + уведомления соревнуются с результатом, и медленный колбэк может всё ещё работать после того, как + `call_tool` уже вернул управление. + +### Попробуйте сами {#try-it} + +Положите `client.py` рядом с `server.py` и запустите: + +```console +python client.py +``` + +```text +Imported https://example.com/a.json (1/2) +Imported https://example.com/b.json (2/2) +{'result': 'Imported 2 records.'} +``` + +Каждый `await ctx.report_progress(...)` на сервере превратился в один вызов `show` на клиенте, в том же порядке, и обе строки напечатались **до** того, как `call_tool` вернул управление. Ход выполнения не упаковывается в результат: он передаётся потоком, пока инструмент ещё работает. + +!!! warning + `progress_callback` относится к **вызову**, а не к `Client`. Аргумента конструктора для него нет, + потому что разным вызовам нужны разные колбэки: один управляет полосой загрузки, следующий — + строкой в логе. + +!!! check + Теперь удалите `progress_callback=show` и запустите снова: + + ```text + {'result': 'Imported 2 records.'} + ``` + + Ни ошибки, ни предупреждения, тот же результат. `report_progress` **ничего не делает, если + вызывающая сторона не запросила ход выполнения**, поэтому сообщайте о нём безусловно и никогда + не гадайте, слушает ли кто-нибудь. + +## Когда общий объём неизвестен {#when-you-dont-know-the-total} + +`total` нужен, когда известен знаменатель. Часто это не так: вы вычитываете ленту, идёте по курсору, скачиваете что-то без заголовка длины. + +Просто не указывайте его: + +```python title="server.py" hl_lines="20" +--8<-- "docs_src/progress/tutorial002.py" +``` + +Колбэк получает `total=None`. Клиент по-прежнему может показывать *активность* («пока импортировано 3...»), но не процент. Не выдумывайте общий объём ради полосы покрасивее. + +!!! tip + `progress` не обязан считать что-то конкретное. Байты, строки, страницы: выберите единицу, + понятную пользователю, и обещайте `total`, только если сможете это обещание сдержать. + +## Итоги {#recap} + +* `await ctx.report_progress(progress, total=None, message=None)` из любого инструмента, принимающего `Context`. +* Клиент передаёт `progress_callback=` в `call_tool`: на каждый вызов, никогда не в `Client`. +* Колбэк имеет вид `async (progress, total, message) -> None` и срабатывает, пока инструмент ещё работает. +* Нет колбэка у вызова — `report_progress` ничего не делает. Сообщайте безусловно. +* Опускайте `total`, когда он неизвестен; колбэк получит `None`. + +Ход выполнения — это то, что работающий инструмент показывает *пользователю*. Строки, которые он пишет в лог для *вас*, человека, обслуживающего сервер, — это другой канал: **[Логирование](logging.md)**. diff --git a/i18n/ru/pages/handlers/sampling-and-roots.md b/i18n/ru/pages/handlers/sampling-and-roots.md new file mode 100644 index 0000000000..0c028808c8 --- /dev/null +++ b/i18n/ru/pages/handlers/sampling-and-roots.md @@ -0,0 +1,51 @@ +--- +translation: + sections: [5c82b20cbd65ded0, 9dc22632be79a533, 1fb8f452e990c456, 42666ab914ff0cb1, c4e0cb3667fd5ff9] + tool: 1 +--- +# Сэмплирование и корневые каталоги {#sampling-and-roots} + +Обработчик может попросить у подключённого клиента ещё две вещи: завершение (completion) от собственной модели клиента — это **сэмплирование** (sampling), и рабочие папки клиента — это **корневые каталоги** (roots). + +И то и другое по-прежнему работает, на каждой версии протокола, которую поддерживает SDK. Но прежде чем строить на них архитектуру, прочтите предупреждение: + +!!! warning "Объявлено устаревшим в спецификации 2026-07-28" + Сэмплирование и корневые каталоги объявлены устаревшими начиная с `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2577)). Они остаются полностью работоспособными и сохраняются в спецификации как минимум двенадцать месяцев, прежде чем их можно будет удалить, но новые реализации не должны на них опираться. Предлагаемые пути миграции: вместо сэмплирования интегрируйтесь напрямую с API вашего поставщика LLM, а вместо корневых каталогов передавайте каталоги через параметры инструментов, URI ресурсов или конфигурацию сервера. Общий для SDK список — на странице **[Устаревшие возможности](../deprecated.md)**. + +## Сэмплирование: одолжить модель клиента {#sampling-borrow-the-clients-model} + +Резолвер возвращает `Sample(...)`, и инструмент получает завершение — через тот же механизм зависимостей, который выполняет `Elicit` на странице **[Зависимости](dependencies.md)**: + +```python title="server.py" hl_lines="10-15 19" +--8<-- "docs_src/sampling_and_roots/tutorial001.py" +``` + +* `Sample(messages, max_tokens=...)` повторяет параметры `sampling/createMessage`. Внедряемое значение — `CreateMessageResult` клиента; передайте `tools` или `tool_choice`, и вместо него придёт `CreateMessageResultWithTools`. +* Клиент должен был объявить возможность `sampling` (`sampling.tools`, если передаёте `tools` или `tool_choice`). Если он этого не сделал, вызов завершается ошибкой протокола `-32021`, а не отправкой запроса, который клиент не сможет обработать. Сессия до 2026 года без обратного канала (back-channel) завершается своей обычной ошибкой об отсутствии обратного канала, поскольку отправлять запрос попросту некуда. +* На `2026-07-28` запрос доставляется внутри многораундового потока (**[Многораундовые запросы](multi-round-trip.md)**); на `2025-11-25` это самостоятельный запрос к клиенту. Код в обоих случаях один и тот же, но помните о правиле многораундовых запросов: запрос должен выглядеть одинаково во всех раундах повтора, поэтому стройте его только из аргументов инструмента и других стабильных данных. +* Не трогайте `include_context`: значения, отличные от `"none"`, сами объявлены устаревшими (SEP-2596) и требуют возможности, которую почти ни один клиент не объявляет. + +## Корневые каталоги: куда это положить? {#roots-where-should-this-go} + +Корневые каталоги — это папки, с которыми, по словам клиента, серверу разрешено работать. Это справочная подсказка, а не механизм контроля доступа. Резолвер возвращает `ListRoots()`: + +```python title="server.py" hl_lines="10-11 15" +--8<-- "docs_src/sampling_and_roots/tutorial002.py" +``` + +* Внедряемый `ListRootsResult` содержит список объектов `Root`: URI вида `file://` и необязательное отображаемое имя. +* Проверка та же, что и для сэмплирования: без объявленной возможности `roots` вызов завершается ошибкой `-32021`, а не отправкой запроса. + +На другой стороне соединения клиент отвечает на оба запроса уже имеющимися у него колбэками: `sampling_callback` и `list_roots_callback`, описанными на странице **[Колбэки клиента](../client/callbacks.md)**. + +## На подключениях поколения 2025 {#on-2025-era-connections} + +`ctx.session.create_message(...)` и `ctx.session.list_roots()` по-прежнему существуют для кода, который управляет сессией напрямую. Они работают только там, где есть обратный канал (подключения поколения 2025, не stateless), а их вызов выдаёт предупреждение об устаревании. Маркеры резолверов, показанные выше, — поддерживаемая форма: они выбирают способ доставки по согласованной версии и не выдают предупреждений. + +## Итоги {#recap} + +* Возвращайте `Sample(...)` или `ListRoots()` из резолвера; инструмент получает `CreateMessageResult` или `ListRootsResult` как любую другую зависимость. +* Клиент должен объявить соответствующую возможность, иначе вызов завершится ошибкой `-32021` вместо отправки запроса. +* Обе возможности объявлены устаревшими в `2026-07-28`: пока полностью работоспособны, но для новых проектов не годятся. Предпочитайте API поставщика сэмплированию, а явные параметры — корневым каталогам. + +Как сообщать, насколько продвинулся медленный инструмент: **[Ход выполнения](progress.md)**. diff --git a/i18n/ru/pages/handlers/subscriptions.md b/i18n/ru/pages/handlers/subscriptions.md new file mode 100644 index 0000000000..f48b7be97c --- /dev/null +++ b/i18n/ru/pages/handlers/subscriptions.md @@ -0,0 +1,152 @@ +--- +translation: + sections: [60a9de8a0bdaa531, 317bbe7e4355cdcc, a61d660c8029e04a, 8f7e82fcb88df8a9, b165db51249ff8ed, 266f56fb798068a4, 7c0e57030b622139, df18d7c2417a9883] + tool: 1 +--- +# Подписки {#subscriptions} + +Каталог сервера не статичен. Инструменты появляются во время работы, а содержимое, стоящее за URI ресурса, меняется. + +**Подписки** — способ, которым клиент об этом узнаёт. Клиент отправляет один запрос `subscriptions/listen`, и ответ на этот запрос *и есть* поток: он остаётся открытым и несёт уведомления об изменениях, которые клиент запросил. + +## Публикация из инструмента {#publish-it-from-the-tool} + +С вашей стороны нужна одна строка: опубликовать изменение. + +```python title="server.py" hl_lines="20 32" +--8<-- "docs_src/subscriptions/tutorial001.py" +``` + +* `await ctx.notify_resource_updated("board://sprint")` доходит до каждого открытого потока, подписанного на этот URI. И ни до кого больше. +* `await ctx.notify_tools_changed()` доходит до каждого потока, запросившего изменения списка инструментов. Получив его, клиент снова вызывает `tools/list` и теперь видит `sprint_report`. +* Родственные методы — `notify_prompts_changed()` и `notify_resources_changed()`. +* Нет подписчиков — нет работы. Публикация на сервере, который никто не слушает, ничего не делает, поэтому проверять, слушает ли кто-нибудь, не нужно. Вы просто сообщаете, что изменилось. + +`MCPServer` обслуживает `subscriptions/listen` за вас. Протокольные обязательства (подтверждение первым кадром, фильтрация для каждого потока, идентификатор подписки в каждом кадре) — забота SDK. + +!!! check + В передаваемых данных поток, в фильтре которого указан `board://sprint`, после выполнения `complete_task` выглядит так: + + ```json + {"method": "notifications/subscriptions/acknowledged", + "params": {"notifications": {"resourceSubscriptions": ["board://sprint"]}, "_meta": {"io.modelcontextprotocol/subscriptionId": "listen-1"}}} + + {"method": "notifications/resources/updated", + "params": {"uri": "board://sprint", "_meta": {"io.modelcontextprotocol/subscriptionId": "listen-1"}}} + ``` + + Обратите внимание, чего в обновлении *нет*: самой доски. Каждый кадр несёт в `_meta` JSON-RPC-идентификатор запроса listen, и этот идентификатор и есть идентификатор подписки. Его выдаёт клиент: `Client` на Python использует строки вроде `"listen-1"`, другие клиенты могут использовать целые числа. + +## Только то, что запрошено {#only-what-was-asked-for} + +Фильтр — это контракт. Поток, запросивший изменения списка инструментов и один URI ресурса, получает эти два вида событий и ничего больше. Опубликуйте изменение промптов — и этот поток промолчит. + +`MCPServer` сопоставляет URI ресурсов как точные строки, поэтому поток, указавший `board://sprint`, ничего не услышит о `board://sprint/tasks/1`. Спецификация разрешает серверу сообщать об изменении подресурса подписанного URI; `MCPServer` так никогда не делает, но клиенты рассчитаны на такую возможность. + +Две вещи, которыми поток *не* является: + +* **Это не журнал для воспроизведения.** Оборвавшийся поток потерян, а события, опубликованные, пока никто не был подключён, в очередь не ставятся. Клиенты подписываются заново и заново запрашивают данные. +* **Это не механизм 2025 года.** Клиентов, вызвавших `resources/subscribe`, обслуживает `ctx.session.send_resource_updated(uri)`. Методы `notify_*` доходят только до потоков `subscriptions/listen`. + +## Кто может наблюдать {#deciding-who-may-watch} + +По умолчанию удовлетворяется каждый запрошенный вид и URI: любой вызывающий может наблюдать за любым URI, который вы публикуете. К вашему обработчику чтения никто не обращается, потому что никто не читает: вызывающий, которому обработчик `files://{name}` отказал бы, всё равно может открыть поток на `files://payroll.csv` и узнать, что файл изменился и когда. Содержимого он не узнает никогда и не сможет прощупать, что существует, потому что неизвестный URI тоже принимается и просто никогда не срабатывает. Утечка узкая, но реальная, так что поставьте заслон до того, как публиковать пользовательские URI с мультитенантного сервера. + +Заслоном служит middleware (промежуточный слой). Оно видит запрос `subscriptions/listen` раньше, чем SDK его подтвердит, и отказывает, когда вызывающий просит то, что ему нельзя читать: + +```python title="server.py" hl_lines="19-26 29" +--8<-- "docs_src/subscriptions/tutorial006.py" +``` + +* `ctx.params` — это сырой запрос, поэтому middleware само валидирует его в `SubscriptionsListenRequestParams` и читает фильтр, который запросил клиент. +* Отказ — это исключение `MCPError`, выброшенное до `call_next(ctx)`: клиент получает эту ошибку и не получает потока, а соединение продолжает работать. Сообщение делайте единообразным, без упоминания URI, чтобы отказ никогда не подтверждал, какие URI защищены. +* Одна функция `can_access(user, uri)` отвечает на оба вопроса. Обработчик ресурса спрашивает её при `resources/read`, middleware — при `subscriptions/listen`. Замените таблицу базой данных или своей RBAC-системой, и обе проверки останутся согласованными. +* Решение действует всё время жизни потока. Повторной проверки на каждое событие нет, поэтому, если доступ вызывающего может истечь посреди потока (токен с ограниченным сроком), завершите его соединение, когда это случится. + +Полный контракт middleware, включая то, что ещё оно оборачивает и почему помечено как предварительное, — на странице **[Middleware](../advanced/middleware.md)**. + +## Клиентская сторона {#the-client-end} + +Вот клиент на другом конце этого потока, следящий за доской: + +```python title="client.py" hl_lines="15" +--8<-- "docs_src/subscriptions/tutorial003.py" +``` + +Вход в `client.listen(...)` отправляет запрос и ждёт вашего подтверждения, так что к началу блока поток уже работает, а каждое типизированное событие — сигнал заново запросить данные, но никогда не сами данные. Вот и весь контракт на одном экране. Всё остальное о клиентской стороне — на отдельной странице: наблюдение параллельно с основным потоком выполнения, завершение потоков и повторная подписка. См. **[Подписки](../client/subscriptions.md)** в разделе *Клиенты*. + +## Масштабирование за пределы одного процесса {#scaling-past-one-process} + +Публикации идут от обработчика к открытым потокам через `SubscriptionBus`. По умолчанию шина в памяти: один процесс и все потоки в нём. Это правильный выбор, пока вы не запускаете реплики за балансировщиком нагрузки: тогда поток клиента привязан к одной реплике, а публикация на другой реплике должна до него дойти. + +Этот стык реализуете вы: два метода поверх вашего pub/sub-бэкенда. + +```python +from collections.abc import Callable + +from redis.asyncio import Redis + +from mcp.server.mcpserver import MCPServer +from mcp.server.subscriptions import ServerEvent # SubscriptionBus is a Protocol: no base class + + +class RedisSubscriptionBus: + def __init__(self, redis: Redis) -> None: + self._redis = redis + self._listeners: dict[object, Callable[[ServerEvent], None]] = {} + + async def publish(self, event: ServerEvent) -> None: + await self._redis.publish("mcp-events", encode(event)) # to every replica + + def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]: + token = object() + self._listeners[token] = listener + + def unsubscribe() -> None: + self._listeners.pop(token, None) + + return unsubscribe + + +mcp = MCPServer("Sprint Board", subscriptions=RedisSubscriptionBus(redis)) +``` + +`encode` пишете вы, как и задачу-читатель на каждой реплике, которая декодирует приходящие сообщения и вызывает каждого зарегистрированного слушателя. Слушатели синхронны, не должны выбрасывать исключения и выполняются в цикле событий сервера. + +Шина несёт типизированные значения `ServerEvent` — четыре небольших dataclass — и никогда JSON-RPC. Проставление идентификаторов, фильтрация и жизненные циклы потоков остаются в SDK, поэтому реализация шины не может нарушить протокол. Она может лишь переносить события между процессами. + +Чтобы публиковать вне запроса, создайте шину сами, чтобы ссылка на неё была у вас. Если ничего не передать, `MCPServer` создаёт шину внутри и наружу её не отдаёт. + +```python +from mcp.server.subscriptions import InMemorySubscriptionBus, ToolsListChanged + +bus = InMemorySubscriptionBus() +mcp = MCPServer("Sprint Board", subscriptions=bus) + + +async def tools_reloaded() -> None: + await bus.publish(ToolsListChanged()) # from a lifespan task, a webhook, anywhere +``` + +## Низкоуровневая сборка {#the-low-level-composition} + +На низкоуровневом `Server` ничего заранее не подключено, и те же детали собираются в три строки: + +```python title="server.py" hl_lines="8-9 47" +--8<-- "docs_src/subscriptions/tutorial002.py" +``` + +* Шина принадлежит вам, поэтому публикуете вы прямо в неё: `await bus.publish(ResourceUpdated(uri=...))`. Разместите её там, куда дотянутся обработчики: здесь — на уровне модуля, в приложении побольше — в жизненном цикле (lifespan). +* `ListenHandler(bus)` — тот же обработчик, который регистрирует `MCPServer`, а `on_subscriptions_listen=` — обычный слот обработчика. Поставьте в этот слот свой вызываемый объект ради другой семантики — и обязательства по спецификации переходят к вам: сначала подтверждение, в каждом кадре идентификатор подписки, ничего за пределами фильтра. +* `ListenHandler.close()` корректно завершает все открытые потоки. Каждый получает последним кадром результат запроса listen — так спецификация сообщает, что сервер завершил подписку намеренно. Метод возвращает управление раньше, чем потоки успевают всё отправить, так что дайте им мгновение, прежде чем закрывать транспорт. Без этого вызова потоки заканчиваются, когда отключается клиент. + +## Итоги {#recap} + +* Клиент подключается одним запросом `subscriptions/listen`, и ответом служит поток. Его обслуживание встроено. +* Вы публикуете через `ctx.notify_*`, а проставление идентификаторов, фильтрацию и жизненный цикл потоков берёт на себя SDK. +* События — сигналы, а не данные. Обе стороны запрашивают данные заново. +* Клиентская сторона — это `async with client.listen(...)`: подробнее — на странице **[Подписки](../client/subscriptions.md)** в разделе *Клиенты*. +* На низкоуровневом `Server` те же детали вы собираете сами: шина, `ListenHandler(bus)`, слот `on_subscriptions_listen`. +* Горизонтальное масштабирование — это реализовать `SubscriptionBus` (два метода) и передать его как `MCPServer(subscriptions=...)`. + +О запуске сервера, который всё это обслуживает, с одной репликой или с двадцатью, — на странице **[Развёртывание и масштабирование](../run/deploy.md)**. diff --git a/i18n/ru/pages/index.md b/i18n/ru/pages/index.md new file mode 100644 index 0000000000..15bda0c117 --- /dev/null +++ b/i18n/ru/pages/index.md @@ -0,0 +1,102 @@ +--- +translation: + sections: [154c4309937b9f85, 3ad8fc6caa76a9b0, a07f3f5b151ab746, bf6e476b712930c0, cf0b1f13978c6623] + tool: 1 +--- +# MCP Python SDK {#mcp-python-sdk} + +!!! info "Это документация v2 — текущей стабильной ветки релизов" + Впервые работаете с v2 или переходите с v1? **[Что нового в v2](whats-new.md)** — пятиминутный обзор изменений, а **[Руководство по миграции](migration.md)** описывает каждое несовместимое изменение. + Всё ещё на v1.x? Документация к ней находится в [документации v1.x](https://py.sdk.modelcontextprotocol.io/v1/). + Что-то работает плохо или сбивает с толку? [Сообщите нам](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml). + +**Model Context Protocol (MCP)** позволяет приложениям предоставлять контекст LLM стандартизированным способом, отделяя задачу *предоставления* контекста от самого взаимодействия с LLM. + +Это официальный Python SDK для него. С его помощью можно: + +* **Создавать MCP-серверы**, которые предоставляют инструменты, ресурсы и промпты любому MCP-хосту. +* **Создавать MCP-клиенты**, которые подключаются к любому MCP-серверу. +* Работать по всем стандартным транспортам: stdio, Streamable HTTP и SSE. + +## Требования {#requirements} + +Python 3.10+. + +## Установка {#installation} + +=== "uv" + + ```bash + uv add "mcp[cli]" + ``` + +=== "pip" + + ```bash + pip install "mcp[cli]" + ``` + +Дополнение `[cli]` добавляет команду `mcp`; она пригодится при разработке. +О том, для чего нужна каждая зависимость, см. раздел [Установка](get-started/installation.md). + +## Пример {#example} + +### Создание {#create-it} + +Создайте файл `server.py`: + +```python title="server.py" +--8<-- "docs_src/index/tutorial001.py" +``` + +Это уже готовый MCP-сервер. + +Он предоставляет один **инструмент**, `add`, и один шаблонный **ресурс**, `greeting://{name}`. + +### Запуск {#run-it} + +```console +uv run mcp dev server.py +``` + +Эта команда запускает сервер и открывает [MCP Inspector](https://github.com/modelcontextprotocol/inspector) — интерактивный интерфейс, в котором можно его исследовать. Откройте URL, который она выведет. + +!!! note + Inspector — приложение на Node.js, поэтому для `mcp dev` нужен `npx` в `PATH`. + +### Попробуйте сами {#try-it} + +В Inspector перейдите на вкладку **Tools** и вызовите `add` с параметрами `a=1`, `b=2`. + +В ответ приходит `3`. ✨ + +Inspector построил эту форму (обязательное целочисленное поле для `a` и ещё одно для `b`) по аннотациям типов. Так же поступит Claude и любой другой MCP-хост. + +Теперь перейдите на вкладку **Resources** и прочитайте `greeting://World`: + +```text +Hello, World! +``` + +### Итоги {#recap} + +Посмотрите ещё раз, чего писать **не** пришлось: + +* Никакой JSON Schema. `a: int, b: int` *и есть* схема. +* Ни разбора запросов, ни сериализации, ни кода валидации. +* Вообще никакой обработки протокола. + +Вы написали две функции на Python с аннотациями типов и строкой документации. Остальное делает SDK. + +## Что дальше {#where-to-go-next} + +* **[Начало работы](get-started/index.md)** проведёт от установки до работающего и протестированного сервера. +* Создаёте приложение, которое *использует* MCP-серверы? Начните со страницы **[Клиенты](client/index.md)**. +* Уже есть приложение на FastAPI или Starlette? На странице **[Добавление в существующее приложение](run/asgi.md)** показано, как встроить в него MCP-сервер. +* Ищете точный текст ошибки? Раздел **[Устранение неполадок](troubleshooting.md)** упорядочен по дословным сообщениям. +* Интересно, что изменилось в v2? **[Что нового в v2](whats-new.md)** — пятиминутный обзор. +* Переходите с v1? Начните с **[Руководства по миграции](migration.md)**. +* Ищете точную сигнатуру? **[Справочник API](api/mcp/index.md)** генерируется из исходного кода. +* Читаете вместе с LLM? Эта документация также публикуется в формате [llms.txt](https://llmstxt.org/): + [llms.txt](https://py.sdk.modelcontextprotocol.io/llms.txt) — это указатель страниц, а + [llms-full.txt](https://py.sdk.modelcontextprotocol.io/llms-full.txt) содержит все страницы в одном файле. diff --git a/i18n/ru/pages/protocol-versions.md b/i18n/ru/pages/protocol-versions.md new file mode 100644 index 0000000000..195d3026b7 --- /dev/null +++ b/i18n/ru/pages/protocol-versions.md @@ -0,0 +1,132 @@ +--- +translation: + sections: [478fd619e5f90ef8, aef094a00e44e248, bab8cbf3449fa7e9, df1809b15a58335b, 5f9d8c2336ed0239, f54974398e43ddef, b24443dd78584870] + tool: 1 +--- +# Версии протокола {#protocol-versions} + +У MCP два поколения. + +Серверы, выпущенные до 2026-07-28, открывают каждое подключение **рукопожатием `initialize`**: клиент предлагает версию, сервер отвечает встречным предложением, клиент подтверждает — и всё это до первого полезного запроса. Серверы на **2026-07-28** от рукопожатия отказываются. Клиент отправляет один пробный запрос **`server/discover`**, и сервер отвечает на него всем сразу в одном результате. + +Заботиться об этом почти никогда не приходится: `Client` договаривается за вас. Эта страница — об одном аргументе конструктора, который этим управляет, `mode=`, и о трёх случаях, когда его меняют. + +## `mode="auto"` {#modeauto} + +```python title="client.py" hl_lines="14-15" +--8<-- "docs_src/protocol_versions/tutorial001.py" +``` + +`mode` не передан, поэтому действует значение по умолчанию — `"auto"`. Вход в `async with` отправляет один пробный запрос `server/discover` на самой новой версии, которую понимает этот SDK. Дальше: + +* **Современный сервер** на него отвечает. Клиент принимает результат. Один раунд обмена — и готово. +* **Более старый сервер** никогда не слышал о `server/discover` и возвращает ошибку. Клиент откатывается к классическому рукопожатию `initialize` и берёт то, о чём оно договорится. + +В любом случае подключение установлено, а `client.protocol_version` сообщает, как именно: + +```text +2026-07-28 +``` + +Вот и вся механика. Один `Client`, сервер любого поколения, никаких ветвлений в коде. + +!!! info + `MCPServer` отвечает на `server/discover` на любом транспорте — в памяти, stdio, Streamable + HTTP, — поэтому с собственным сервером `auto` всегда приходит к `2026-07-28`. Откат + срабатывает только с настоящим сервером до 2026 года — ровно тогда, когда он и нужен. + +## `mode="legacy"` {#modelegacy} + +```python title="client.py" hl_lines="14" +--8<-- "docs_src/protocol_versions/tutorial002.py" +``` + +`mode="legacy"` никогда не отправляет пробный запрос. Он выполняет рукопожатие `initialize` — то же подключение, которое открывает клиент до 2026 года. + +```text +2025-11-25 +``` + +Тот же сервер. Он прекрасно говорит на `2026-07-28` — это вы велели клиенту не спрашивать. + +Этот режим нужен ради **push-возможностей**. + +Запрос, инициированный сервером, — это когда сервер вызывает *вас*: `ctx.elicit(...)` показывает форму вашему пользователю, сэмплирование (sampling) запрашивает у вашей модели генерацию прямо посреди вызова инструмента. Такой канал существует только в сессии поколения рукопожатия. + +На 2026-07-28 его больше нет. Сервер *возвращает* свои вопросы, а вы повторяете вызов уже с ответами (**[Многораундовые запросы](handlers/multi-round-trip.md)**, multi-round-trip). + +`mode="auto"` даёт рукопожатие, только когда сервер слишком стар для чего-либо ещё. `mode="legacy"` его гарантирует. Берите его всякий раз, когда передаёте в `Client(...)` `sampling_callback`, `elicitation_callback`, который должен работать как запрос, или `message_handler`. Каждый из них разобран на странице **[Колбэки клиента](client/callbacks.md)**. + +## Фиксация версии {#pinning-a-version} + +`mode` принимает и строку современной версии протокола. Сегодня это множество ровно `["2026-07-28"]`. + +```python title="client.py" hl_lines="14" +--8<-- "docs_src/protocol_versions/tutorial003.py" +``` + +Фиксированная версия не отправляет **ничего**. Ни пробного запроса, ни рукопожатия. Клиент локально принимает `2026-07-28`, и подключение готово к работе в тот же миг, когда `async with` возвращает управление. + +Фиксация — это обещание, которое даёте *вы*: вам уже известно, что сервер говорит на этой версии. Клиент не проверяет. + +!!! check + Фиксация — не обнаружение. Выведите `client.server_info`, и цена сразу видна: + + ```text + None + ``` + + Клиент так и не спросил у сервера, кто он, поэтому `server_info` равен `None`. С `client.server_capabilities` + та же история: каждая возможность — `None`. Вызовы инструментов по-прежнему работают (протоколу ничего из этого не нужно), + а вот код, который читает `server_capabilities`, чтобы решить, что предлагать, — нет. + + Следующий раздел это исправляет. + +Фиксировать можно только современные версии. Строка поколения рукопожатия отклоняется при создании объекта, до любого ввода-вывода, а ошибка подсказывает, что написать вместо неё: + +```text +ValueError: mode must be 'legacy', 'auto', or one of ['2026-07-28']; got '2025-06-18' ('2025-06-18' is a handshake-era version; use mode='legacy') +``` + +## Переподключение с `prior_discover` {#reconnecting-with-prior_discover} + +Пробный запрос дёшев, но это всё же раунд обмена, за который платят при каждом переподключении, а ответ почти никогда не меняется. + +Так что сохраните его. После подключения в режиме `auto` в `client.session.discover_result` лежит ровно тот `DiscoverResult`, который прислал сервер: его `supported_versions`, `capabilities`, `instructions` и идентификационные данные, которые сервер записал в `_meta` результата. В следующий раз передайте его обратно как `prior_discover=`: + +```python title="client.py" hl_lines="15 17" +--8<-- "docs_src/protocol_versions/tutorial004.py" +``` + +```text +2026-07-28 +Bookshop +``` + +Второе подключение сделало **ноль** раундов согласования и всё равно точно знает, с кем говорит. Это и есть режим с фиксацией, сделанный как надо: `mode=` называет версию, `prior_discover=` даёт идентификационные данные. ✨ + +`DiscoverResult` — модель Pydantic. `saved.model_dump_json()` уходит в файл или кэш; `DiscoverResult.model_validate_json(...)` восстанавливает его в следующем процессе. + +!!! tip + `prior_discover=` что-то делает только тогда, когда `mode` — фиксированная версия. В режиме `"auto"` клиент + всё равно опрашивает сервер, а в режиме `"legacy"` аргумент игнорируется. + +## Четыре режима {#the-four-modes} + +| Вы пишете | Трафик согласования | Вы получаете | +| --- | --- | --- | +| `Client(target)` | один пробный запрос `server/discover`; рукопожатие `initialize`, если он не удался | самую новую версию, на которой говорят обе стороны, любого поколения | +| `Client(target, mode="legacy")` | рукопожатие `initialize` | версию поколения рукопожатия; запросы, инициированные сервером, работают | +| `Client(target, mode="2026-07-28")` | нет | эту версию, зафиксированную, с `server_info`, равным `None` | +| `Client(target, mode="2026-07-28", prior_discover=saved)` | нет | эту версию, зафиксированную, *и* идентификационные данные, сохранённые в прошлый раз | + +## Итоги {#recap} + +* У MCP есть поколение рукопожатия (до `2025-11-25` включительно, рукопожатие `initialize`) и современное поколение (`2026-07-28`, `server/discover`). `Client` соединяет их. +* `mode="auto"` — значение по умолчанию: пробный запрос, затем откат. Не трогайте его, если только вас не описывает одна из трёх других строк таблицы. +* `client.protocol_version` — всегда ответ на вопрос «что я получил?». +* `mode="legacy"` принудительно включает рукопожатие. Это то, что нужно для запросов, инициированных сервером: сэмплирования, push-элицитации (elicitation), `message_handler`. +* Фиксация версии (`mode="2026-07-28"`) не отправляет вообще никакого трафика согласования — ценой того, что `client.server_info` равен `None`. +* `prior_discover=` возвращает эту цену: сохраните `client.session.discover_result`, переподключитесь с ним — и получите и то и другое. + +У современного подключения нет push-канала — так как же сервер 2026 года задаёт вопрос посреди вызова? Он его возвращает: **[Многораундовые запросы](handlers/multi-round-trip.md)**. diff --git a/i18n/ru/pages/run/asgi.md b/i18n/ru/pages/run/asgi.md new file mode 100644 index 0000000000..8fd397b480 --- /dev/null +++ b/i18n/ru/pages/run/asgi.md @@ -0,0 +1,148 @@ +--- +translation: + sections: [1062ef792791488a, 4be2b831547184a9, 374b049e770385f2, b72f6947089e6de0, b172c9db7831bb31, 70b9ece244ca1b0c, cba78e052898c3f6, f06bdb541cb0b469, fb82d526320b7cc3] + tool: 1 +--- +# Добавление в существующее приложение {#add-to-an-existing-app} + +`mcp.run("streamable-http")` запускает веб-сервер за вас. Иногда это не то, что нужно: MCP-сервер — лишь часть более крупного веб-приложения, или у вас уже есть развёрнутое ASGI-приложение. + +Для таких случаев `mcp.streamable_http_app()` возвращает **приложение Starlette**. + +Приложение Starlette — это ASGI-приложение, поэтому разместить MCP-сервер может всё, что умеет запускать ASGI: uvicorn, Hypercorn, другое приложение Starlette, FastAPI. + +## Приложение {#the-app} + +```python title="server.py" hl_lines="12" +--8<-- "docs_src/asgi/tutorial001.py" +``` + +`app` — обычное ASGI-приложение. Передайте его любому ASGI-серверу: + +```console +uvicorn server:app +``` + +Конечная точка MCP находится по пути `/mcp`, так что клиент подключается к `http://127.0.0.1:8000/mcp`. + +В приложении уже есть две вещи: + +* Один маршрут, `/mcp`: конечная точка Streamable HTTP. +* **Жизненный цикл** (lifespan), который запускает `mcp.session_manager` — объект, владеющий фоновой работой всех активных сессий. + +Запустите приложение само по себе (`uvicorn server:app`) — и ни о том, ни о другом думать не придётся. + +!!! tip + `streamable_http_app()` принимает те же именованные аргументы, что и `mcp.run("streamable-http", ...)`, + кроме `port`: порт принадлежит тому, что обслуживает приложение. `host` по-прежнему принимается, + но здесь ни к чему не привязывается; что он на самом деле контролирует, объясняет страница + **[Развёртывание и масштабирование](deploy.md)**. + Сами параметры описаны на странице **[Запуск сервера](index.md)**. + +`mcp.sse_app()` делает то же самое для вытесненного транспорта SSE. + +## Только localhost, пока вы не укажете иное {#localhost-only-until-you-say-otherwise} + +По умолчанию приложение отвечает **только** на запросы, адресованные localhost. `streamable_http_app()` +не может знать, за каким именем хоста его будут обслуживать, поэтому включает защиту от DNS-rebinding +с самым безопасным из возможных списком разрешённых хостов; на вашей машине это ровно то, что нужно. +При развёртывании за настоящим именем хоста это означает, что **каждый запрос отклоняется с +`421 Misdirected Request`**, пока вы не передадите в `transport_security=` список того, что +действительно обслуживаете. До вашего кода дело даже не доходит. Этот список и всё остальное, +что отделяет работающее приложение от настоящего имени хоста, — на странице +**[Развёртывание и масштабирование](deploy.md)**. + +## Монтирование {#mounting-it} + +Как только MCP-сервер становится *частью* более крупного приложения, вы помещаете его приложение внутрь `Mount`. И как только вы это делаете, жизненный цикл становится вашей заботой: + +```python title="server.py" hl_lines="18-21 25-26" +--8<-- "docs_src/asgi/tutorial002.py" +``` + +* `Mount("/", ...)` вместе с путём `/mcp` по умолчанию оставляет конечную точку по адресу `/mcp`. Starlette перебирает маршруты по порядку, а `Mount("/")` совпадает с **любым** путём, поэтому ваши собственные маршруты идут в списке *перед* ним. Всё, что после него, недостижимо. +* Функция `lifespan` входит в `mcp.session_manager.run()` на всё время жизни **хост-приложения**. Именно эту строку все забывают. +* `mcp.session_manager` существует только *после* вызова `streamable_http_app()`. Поэтому маршруты строятся на уровне модуля, а к менеджеру обращаются только внутри жизненного цикла. + +Маршрут `Host` из Starlette работает так же: замените `Mount("/", ...)` на `Host("mcp.example.com", ...)`, чтобы маршрутизировать по имени хоста, а не по пути. Правило о жизненном цикле не меняется, как и правило о транспортной безопасности. Маршрут `Host("mcp.example.com", ...)` получает только запросы, адресованные этому имени хоста, но собственный список разрешённых значений Host у транспорта (**[Развёртывание и масштабирование](deploy.md)**) всё равно проверяется первым. Если в нём нет `"mcp.example.com"`, этот маршрут отвечает на каждый такой запрос кодом `421`. + +!!! warning "Жизненным циклом владеет хост-приложение" + `streamable_http_app()` встраивает `session_manager.run()` в жизненный цикл возвращаемого + приложения Starlette, но **жизненный цикл смонтированного подприложения никогда не выполняется**. + Смонтируйте приложение — и этот встроенный жизненный цикл станет мёртвым кодом. Приложение, + стоящее на вершине вашего ASGI-стека, должно войти в `mcp.session_manager.run()` в своём + собственном жизненном цикле. + +!!! check + Удалите строку `lifespan=lifespan` и запустите сервер. Он запускается. Маршрут находится. + А затем первый запрос к `/mcp` падает с ошибкой: + + ```text + RuntimeError: Task group is not initialized. Make sure to use run(). + ``` + + Менеджер сессий не запускает ничто, кроме его метода `run()`. + +## Два сервера, одно приложение {#two-servers-one-app} + +Каждый `MCPServer` — отдельное приложение со своим менеджером сессий. Монтируйте сколько угодно; входите в каждый менеджер из одного жизненного цикла хост-приложения: + +```python title="server.py" hl_lines="27-30 35-36" +--8<-- "docs_src/asgi/tutorial003.py" +``` + +* `AsyncExitStack` входит в оба менеджера; они запускаются вместе и завершаются в обратном порядке. +* Конечные точки — `/notes/mcp` и `/tasks/mcp`: префикс монтирования плюс путь по умолчанию. + +## Изменение пути {#changing-the-path} + +Завершающий `/mcp` — это `streamable_http_path`. Задайте ему значение `"/"`, и префикс монтирования станет полным публичным путём: + +```python title="server.py" hl_lines="25" +--8<-- "docs_src/asgi/tutorial004.py" +``` + +Теперь клиенты подключаются к `/notes`, а не к `/notes/mcp`. + +## CORS для браузерных клиентов {#cors-for-browser-clients} + +Браузерному клиенту нужны от вас два разрешения: **отправлять** свои заголовки MCP-запроса и **читать** тот заголовок, что MCP присылает в ответ. И то и другое — настройка CORS в хост-приложении, и список разрешённых хостов транспортной безопасности, описанный выше, должен с ней согласовываться: + +```python title="server.py" hl_lines="27-30 33 35-49" +--8<-- "docs_src/asgi/tutorial005.py" +``` + +* `allow_headers` — та половина, которую все забывают. Браузер выполняет **предварительный запрос** (preflight) перед каждым MCP-запросом, потому что `Content-Type: application/json` и заголовки запроса `Mcp-*` не входят в безопасный список CORS, а заголовок, не разрешённый предварительным запросом, — это запрос, который браузер никогда не отправит. (`allow_headers=["*"]` тоже работает: Starlette отвечает на предварительный запрос тем, что тот запросил.) +* `expose_headers=["Mcp-Session-Id"]` — половина, отвечающая за чтение. Streamable HTTP возвращает идентификатор сессии в этом заголовке ответа, а браузеры скрывают заголовки ответа от JavaScript, если CORS не раскрывает их поимённо. Без этого клиент никогда не сможет сделать второй запрос. +* `allow_origins` — ваше решение, а не MCP. Будьте точны и продублируйте его в `allowed_origins=` выше: CORS обеспечивает браузер, но сервер сам проверяет `Origin`, и источник, которому транспорт не доверяет, получает `403` даже после успешного предварительного запроса. +* `allow_methods` перечисляет три метода, которые использует Streamable HTTP: `POST` для отправки сообщений, `GET` для открытия потока от сервера к клиенту, `DELETE` для завершения сессии. + +## Пользовательские маршруты {#custom-routes} + +`@mcp.custom_route()` регистрирует обычную HTTP-точку в том же приложении — для вещей, которые нужны каждому развёрнутому сервису и не имеют отношения к MCP: проверка работоспособности, колбэк OAuth. + +```python title="server.py" hl_lines="15-17" +--8<-- "docs_src/asgi/tutorial006.py" +``` + +* Обработчик — обычный Starlette: `async`-функция из `Request` в `Response`. +* `streamable_http_app()` подхватывает все пользовательские маршруты. Теперь `app.routes` — это `/mcp` и `/health`. +* `GET /health` отвечает `{"status": "ok"}` без всякого MCP. + +!!! warning + Пользовательские маршруты **никогда не аутентифицируются**, даже когда остальной сервер защищён. + Это сделано намеренно: проверки работоспособности и колбэки OAuth должны быть доступны до того, + как появится какой-либо токен. Не размещайте за ними ничего приватного. + +## Итоги {#recap} + +* `mcp.streamable_http_app()` возвращает приложение Starlette с одним маршрутом, `/mcp`. Запустить его может любой ASGI-сервер. +* По умолчанию приложение отвечает только на запросы, адресованные localhost, а за настоящим именем хоста отклоняет всё кодом `421`, пока вы не передадите список разрешённых хостов в `transport_security=`. За это и за остальной путь к продакшену отвечает страница **[Развёртывание и масштабирование](deploy.md)**. +* `Mount` (или `Host`) помещает его внутрь более крупного приложения Starlette или FastAPI. +* **Монтирование отключает встроенный жизненный цикл.** Жизненный цикл хост-приложения должен войти в `mcp.session_manager.run()`, иначе первый запрос завершится ошибкой. +* Несколько серверов в одном приложении — это несколько монтирований и один жизненный цикл, который входит в каждый менеджер сессий. +* `streamable_http_path="/"` переносит конечную точку на сам префикс монтирования. +* Браузерным клиентам нужен CORS: `allow_headers` для заголовков запроса `Mcp-*`, `expose_headers=["Mcp-Session-Id"]` для ответа. +* `@mcp.custom_route()` добавляет обычные HTTP-точки без аутентификации рядом с `/mcp`. + +Когда сервер доступен по настоящему URL, **[Клиент](../client/index.md)** подключается к нему по этому URL вместо объекта сервера. diff --git a/i18n/ru/pages/run/authorization.md b/i18n/ru/pages/run/authorization.md new file mode 100644 index 0000000000..c4059c1ae6 --- /dev/null +++ b/i18n/ru/pages/run/authorization.md @@ -0,0 +1,130 @@ +--- +translation: + sections: [d62c13457fc4a534, 80e73abaca6e0652, d1dc4c54cd00ec9c, 14ad3bc7904036bb, 5225f127bc1b9c77, fe1626fdd5aad1da, 4556cb7ea1a04a31] + tool: 1 +--- +# Авторизация {#authorization} + +Через Streamable HTTP MCP-сервер — это обычный веб-сервис, и защищается он так же, как любой веб-сервис: с помощью bearer-токенов OAuth 2.1. + +В терминах OAuth ваш сервер — это **сервер ресурсов**. Он никого не аутентифицирует и не выдаёт токенов. Он делает ровно одно: смотрит на заголовок `Authorization` каждого запроса и решает, годится ли токен в нём. + +Эта страница — о серверной стороне. Клиент, который обнаруживает ваш сервер авторизации и получает токен, описан на странице **[OAuth-клиенты](../client/oauth-clients.md)**. + +## Три стороны {#the-three-parties} + +* **Сервер авторизации** аутентифицирует пользователей и выдаёт токены доступа. Его вы не пишете. Это ваш провайдер идентификации (Auth0, Keycloak, Entra или ваш собственный). +* **Сервер ресурсов** — это ваш MCP-сервер. Он проверяет токен в каждом запросе. +* **Клиент** выясняет, какому серверу авторизации вы доверяете, получает у него токен и присылает его вам в виде `Authorization: Bearer `. + +Вот и весь треугольник. Всё на этой странице — про средний пункт. + +## Верификатор токенов {#a-token-verifier} + +SDK ничего не предполагает о том, как выглядит действительный токен. Это определяете вы, реализуя **`TokenVerifier`**: + +```python title="server.py" hl_lines="12-14 19-24" +--8<-- "docs_src/authorization/tutorial001.py" +``` + +* `TokenVerifier` — это протокол с одним асинхронным методом. `verify_token` получает сырой токен из заголовка `Authorization` и возвращает **`AccessToken`**, если токен действителен, или `None`, если нет. Больше реализовывать нечего. +* Этот верификатор ищет токен в таблице. Настоящий проверяет подпись JWT или обращается к эндпоинту интроспекции токенов на сервере авторизации. Этот код — ваш; SDK его только вызывает. +* `token_verifier=` и `auth=` всегда идут в паре. Передайте один без другого — и `MCPServer(...)` выбросит `ValueError` ещё до того, как обслужит хоть один запрос. + +`AuthSettings` — это публичное лицо вашего сервера ресурсов: + +* `issuer_url`: сервер авторизации, который выдаёт ваши токены. +* `resource_server_url`: публичный URL этого MCP-эндпоинта. Он указывает, *для какого* ресурса предназначен токен, и по нему же размещается документ обнаружения. +* `required_scopes`: каждый токен должен содержать их все. + +!!! tip + В `examples/servers/simple-auth/` в репозитории SDK есть `IntrospectionTokenVerifier`, который обращается + к эндпоинту [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662) настоящего сервера авторизации. Именно так устроено большинство верификаторов в продакшене. + +## Что вы получаете через HTTP {#what-you-get-over-http} + +Авторизация живёт в HTTP-заголовках, поэтому существует только на HTTP-транспортах. Запускайте её на том транспорте, который развёртываете: `mcp.run(transport="streamable-http")` поднимает её на `http://127.0.0.1:8000/mcp`, а остальное — на странице **[Запуск сервера](index.md)**. Теперь у приложения два маршрута: + +```text +/mcp +/.well-known/oauth-protected-resource/mcp +``` + +Вы зарегистрировали один инструмент. Второй маршрут добавил SDK. + +### Обнаружение {#discovery} + +Выполните `GET` по этому well-known-пути — и получите **Protected Resource Metadata по [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728)**, собранные прямо из ваших `AuthSettings`: + +```json +{ + "resource": "http://127.0.0.1:8000/mcp", + "authorization_servers": ["https://auth.example.com/"], + "scopes_supported": ["notes:read"], + "bearer_methods_supported": ["header"] +} +``` + +Именно по этому документу клиент, который никогда не слышал о вашем сервере, находит к нему дорогу: читает `authorization_servers` и идёт туда за токеном. Ничего из этого вы не писали. + +!!! check + Обратитесь к `/mcp` без токена (или с таким, для которого верификатор вернул `None`) — и запрос + остановят на входе: + + ```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"} + ``` + + Ничего не разбиралось, ни один инструмент не запускался. А указатель `resource_metadata` в `WWW-Authenticate` — + это то, что делает обнаружение автоматическим: 401 -> документ метаданных -> сервер авторизации -> токен -> повтор. + +!!! warning + Ничто из этого не защищает `stdio`. У канала нет заголовка `Authorization`, поэтому к `token_verifier` там никогда + не обращаются. Граница безопасности `stdio`-сервера — это процесс, который его запустил. То же + относится к `Client(mcp)` в памяти, который используется в тестах: он подключается напрямую к объекту сервера + и минует HTTP-уровень вместе с авторизацией. + +## Личность вызывающего {#the-callers-identity} + +Внутри любого обработчика **`get_access_token()`** — это `AccessToken`, который ваш верификатор вернул для текущего запроса: + +```python title="server.py" hl_lines="4 32-35" +--8<-- "docs_src/authorization/tutorial002.py" +``` + +* Это работает в инструментах, ресурсах и промптах, и ничего передавать не нужно: middleware авторизации сохраняет его в контекстной переменной для каждого запроса. +* Возвращается **тот самый объект, который собрал ваш верификатор**: `client_id`, `scopes`, `subject`, `expires_at` и любые дополнительные `claims`, которые вы прикрепили. Это и есть точка для правил на уровне отдельных инструментов: прочитайте scopes и откажите. +* Вне аутентифицированного HTTP-запроса функция возвращает `None`. В памяти и через `stdio` это всегда `None`. + +Вызовите `whoami` с `Authorization: Bearer alice-token` — и модель прочитает: + +```text +alice (scopes: notes:read) +``` + +## Половина, которую SDK не делает {#the-half-the-sdk-doesnt-do} + +SDK даёт вам половину сервера ресурсов: проверить, объявить, отказать. Он не даёт страницу входа, экран согласия или токен. + +Чтобы увидеть все три стороны в действии, запустите `examples/servers/simple-auth/` из репозитория SDK (небольшой сервер авторизации и сервер ресурсов, настроенный ровно так, как на этой странице), а затем направьте на него `examples/clients/simple-auth-client/` — и пройдите весь путь от обнаружения до токена. + +!!! info + Есть второй аргумент конструктора, `auth_server_provider=`, который встраивает полноценный сервер + авторизации внутрь MCP-сервера. Он появился раньше разделения AS/RS, вокруг которого построена спецификация + авторизации MCP. В новых серверах к нему прибегать не следует. + +Сервер авторизации также может принять подписанное утверждение от корпоративного провайдера идентификации вместо того, чтобы пользователь проходил через экран согласия, и SDK поддерживает обе стороны этого обмена. Сам грант и клиент, который его предъявляет, описаны на странице **[Утверждение личности](../client/identity-assertion.md)**. + +## Итоги {#recap} + +* Через Streamable HTTP ваш сервер — это **сервер ресурсов** OAuth 2.1: он проверяет токены, но никогда их не выдаёт. +* `TokenVerifier` — вся поверхность интеграции: один асинхронный метод, токен на входе, `AccessToken | None` на выходе. +* `token_verifier=` и `auth=AuthSettings(issuer_url=..., resource_server_url=..., required_scopes=[...])` всегда идут в паре. +* SDK публикует Protected Resource Metadata по [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) по адресу `/.well-known/oauth-protected-resource/...` и отвечает на неаутентифицированные запросы кодом 401, заголовок `WWW-Authenticate` которого указывает на них. Это и есть вся история обнаружения. +* `get_access_token()` в любом обработчике — это тот, кто вызывает. +* Авторизация — забота HTTP. `stdio` и клиент в памяти её никогда не видят. + +Клиентская половина (обнаружение вашего сервера авторизации и получение токена за вас) — на странице **[OAuth-клиенты](../client/oauth-clients.md)**. А клиент, который *утверждает* личность вместо того, чтобы запрашивать её у пользователя, — на странице **[Утверждение личности](../client/identity-assertion.md)**. diff --git a/i18n/ru/pages/run/deploy.md b/i18n/ru/pages/run/deploy.md new file mode 100644 index 0000000000..1671d9230c --- /dev/null +++ b/i18n/ru/pages/run/deploy.md @@ -0,0 +1,180 @@ +--- +translation: + sections: [28221886b198784f, f88ea1f1614f3a1d, ce926d686730b6d0, 3be24f8ad8bb5ab9, 3fad24032b2224ff, f25a7f860e579ecb, e758745df6fb7b0a] + tool: 1 +--- +# Развёртывание и масштабирование {#deploy-scale} + +Сервер работает. Теперь ему нужно настоящее доменное имя и больше одного рабочего процесса за ним. + +Почти ничего из этого MCP не касается. ASGI-сервер, менеджер процессов, балансировщик нагрузки — всё это на вашей стороне. На этой странице собран короткий список того, что MCP *касается*: одна настройка, от которой зависит любое развёртывание, и два места, где «больше одного рабочего процесса» меняет поведение SDK. + +## Прежде всего: список разрешённых значений Host {#before-anything-else-the-host-allowlist} + +`streamable_http_app()` не может знать, за каким доменным именем его будут отдавать, поэтому предполагает самый безопасный вариант: localhost. Без параметра `transport_security=` приложение включает **защиту от DNS-rebinding** и принимает запрос, только если его заголовок `Host` равен `127.0.0.1:`, `localhost:` или `[::1]:`. Заголовок `Origin`, если он есть, должен быть `http://`-формой того же самого. На вашей машине это ровно то, что нужно: вредоносная веб-страница не сможет управлять локальным сервером через DNS-имя, которое она перепривязала к `127.0.0.1`. + +При развёртывании за настоящим доменным именем то же самое поведение по умолчанию отклоняет **каждый запрос**, пока вы не скажете иначе. Проверка выполняется раньше всего, что относится к MCP, так что до написанного вами кода дело даже не доходит: + +```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=`. Разрешите то, что действительно обслуживаете: + +```python title="server.py" hl_lines="2 13-17" +--8<-- "docs_src/deploy/tutorial001.py" +``` + +* Элементы `allowed_hosts` — точные строки: `"mcp.example.com"` совпадает с заголовком `Host` без порта, а `"mcp.example.com:*"` — с любым портом. Укажите оба. +* `allowed_origins` имеет значение только для браузеров, потому что больше никто не отправляет `Origin`. Это серверный близнец конфигурации CORS со страницы **[Добавление в существующее приложение](asgi.md)**. +* За обратным прокси, который уже контролирует заголовок `Host`, честная конфигурация — отключить проверку: `TransportSecuritySettings(enable_dns_rebinding_protection=False)`. +* Передача `host=`, отличного от localhost (например, `host="mcp.example.com"`), **не** добавляет это имя в список разрешённых. Она лишь не даёт значению localhost по умолчанию включить защиту, в результате чего принимаются любые Host и Origin. Вместо этого скажите прямо, что имеете в виду, через `transport_security=`. + +!!! check + Удалите аргумент `transport_security=security` и всё равно разверните приложение. Оно + запускается, маршрут `/mcp` работает, и каждый запрос (включая обычный `curl`) возвращает: + + ```text + HTTP/1.1 421 Misdirected Request + + Invalid Host header + ``` + + На стороне клиента этих слов не найти. `421` — это обычный текстовый HTTP-ответ, а не + ошибка JSON-RPC, поэтому MCP-клиент выбрасывает общее исключение транспорта; доменное имя, + которое не понравилось серверу, появляется только в логе **сервера**, одним предупреждением. + Свежеразвёрнутый сервер, который отклоняет все подключения, — это список разрешённых Host, + пока не доказано обратное. **[Устранение неполадок](../troubleshooting.md)** тоже начинается отсюда. + +## Рабочие процессы и кому нужна привязка {#workers-and-who-has-to-be-sticky} + +Как только доменное имя отвечает, поставьте за ним больше одного рабочего процесса. В SDK для этого нет никакой ручки; приложение Starlette масштабируется так же, как любое ASGI-приложение: объект передаётся тому, кто умеет порождать процессы: + +```console +uvicorn server:app --workers 4 +``` + +Четыре процесса, один сокет. И теперь вопрос, на который должно ответить каждое развёртывание: **должен ли запрос попасть к тому же рабочему процессу, что видел предыдущий?** + +Для клиента, говорящего на протоколе **2026-07-28**, — нет. Современный запрос — это один самодостаточный POST: никакого рукопожатия `initialize` перед ним, никакого `Mcp-Session-Id` в ответе, второму запросу просто *некуда* возвращаться. Направляйте его любому рабочему процессу. + +Это не режим, который нужно включать. `stateless_http=True` выглядит так, будто им и должен быть, но транспорт маршрутизирует по заголовку запроса `MCP-Protocol-Version`, передаёт современный запрос современному обработчику и **возвращает управление**. Строка, читающая `stateless_http`, идёт *после* этого возврата. Дело не в том, что флаг игнорируется на пути 2026-07-28; до него просто никогда не доходит. `stateless_http` — ручка только для ветки **старого поколения**, а современный путь лишён сессий по построению. + +Для клиента старого поколения на версии спецификации 2025-11-25 или более ранней ответ зависит от этого флага: + +| Версия протокола клиента | Сессия | Что должен делать балансировщик нагрузки | +| --- | --- | --- | +| **2026-07-28** | Нет. `Mcp-Session-Id` никогда не устанавливается. | Ничего. Любой рабочий процесс обслуживает любой запрос. | +| **2025-11-25 и ранее** (по умолчанию) | `Mcp-Session-Id`, хранится в памяти одного рабочего процесса. | **Привязка сессий (sticky sessions).** Последующий запрос, попавший к другому рабочему процессу, получает `404` *«Session not found»*. | +| **2025-11-25 и ранее**, с `stateless_http=True` | Нет. | Ничего. Цена — обратный канал (back-channel) от сервера к клиенту (сэмплирование (sampling), push-элицитация (elicitation), `roots/list`) и возобновляемость. | + +Привязке сессий и цене ветки старого поколения посвящена отдельная страница — **[Обслуживание клиентов старого поколения](legacy-clients.md)**; сами два поколения — **[Версии протокола](../protocol-versions.md)**. Здесь важна форма ответа: *на 2026-07-28 вы уже работаете без состояния, и настраивать нечего.* + +Остаток этой страницы — две вещи, которые работа без состояния вам **не** даёт. + +## `requestState` между рабочими процессами {#requeststate-across-workers} + +**[Многораундовому](../handlers/multi-round-trip.md)** (multi-round-trip) инструменту нужно что-то, за чем клиент должен сходить (подтверждение, выбор, учётные данные), поэтому он возвращает вопрос вместо ответа и завершается при повторе. Между двумя раундами клиент держит непрозрачный токен `request_state`, выпущенный сервером. При повторе сервер должен снова открыть этот токен. + +*Запечатанный каким ключом?* По умолчанию — тем, что сервер сгенерировал через `os.urandom(32)` при создании. Под `--workers 4` это четыре создания в четырёх процессах: четыре разных ключа, нигде не записанных, никем не разделяемых и исчезающих при перезапуске. + +Вот инструмент, который спрашивает, прежде чем действовать, на сервере, который ничего не настраивает: + +```python title="server.py" hl_lines="14 20" +--8<-- "docs_src/deploy/tutorial002.py" +``` + +Первый раунд попадает к рабочему процессу A. Процесс A запечатывает `refund:120` **своим** ключом и возвращает токен. Клиент показывает вопрос человеку, получает «да» и повторяет запрос. Повтор — это совершенно новый HTTP-запрос. + +!!! check + Пусть этот повтор попадёт к рабочему процессу B. B пытается распечатать токен, который не выпускал, + не может и отклоняет весь раунд. `refund` так и не вызывается; клиент получает ошибку JSON-RPC: + + ```json + { + "code": -32602, + "message": "Invalid or expired requestState", + "data": {"reason": "invalid_request_state"} + } + ``` + + Это сообщение **неизменно**. Истёк срок, подделан, воспроизведён с другими аргументами или + (с большим отрывом самая частая причина в реальном развёртывании) запечатан соседним рабочим + процессом: клиенту каждый раз сообщают одно и то же, так что по сети никогда не видно, какая + проверка не прошла. Настоящая причина — одно сообщение `WARNING` в логе сервера: + + ```text + requestState rejected on tools/call: unknown key + ``` + + Многораундовый инструмент, который работал с одним рабочим процессом и начал падать *время от + времени* на двух, — это именно оно. Обоим раундам по-прежнему нужно попасть в один процесс, + поэтому он падает ровно настолько часто, насколько балансировщик их разводит. + +Два раунда — это два независимых HTTP-запроса, и их разводят вполне обычные вещи: прокси, балансирующий по запросам, соединение, оборвавшееся между ними, развёртывание или перезапуск, клиент, который сохранил `request_state` и возобновляет работу вообще из другого процесса (**[Управление циклом вручную](../handlers/multi-round-trip.md#driving-the-loop-yourself)**). Любое из этого — «другой рабочий процесс». + +Решение — один аргумент. У него **две** половины. + +```python title="server.py" hl_lines="1 12 14" +--8<-- "docs_src/deploy/tutorial003.py" +``` + +* **`keys=[...]`** — половина, которую находят все. Дайте каждому экземпляру один и тот же секрет (не меньше 32 байт), и каждый экземпляр сможет распечатать то, что выпустил любой сосед. `keys[0]` запечатывает, а распечатывает любой ключ из списка — это кольцо ротации; как провернуть его без простоя — в разделе **[Ротация ключей](../handlers/multi-round-trip.md#rotating-keys)**. +* **Имя сервера** — половина, которую почти никто не находит, и причина, по которой повторы между экземплярами всё ещё падают после того, как ключ сделан общим. Каждый запечатанный токен несёт `name` сервера как **audience claim**, который строго проверяется на обратном пути. Два экземпляра, собранные из одного кода, имеют одно имя и никогда этого не замечают. Назовите их по-разному (`MCPServer(f"billing-{POD}")` выглядит как хорошая гигиена наблюдаемости) — и каждый повтор между экземплярами отклоняется ровно как выше, с общим ключом или без. В логе вместо `unknown key` будет `audience`; клиент разницы не увидит. + +Выпустите секрет один раз и передайте одно и то же значение каждому экземпляру. Это та самая команда, которую собственное сообщение об ошибке SDK предлагает запустить, если передать ему меньше 32 байт: + +```console +python -c "import secrets; print(secrets.token_hex(32))" +``` + +!!! warning "Одни ключи *и* одно имя" + Развёртывание с несколькими экземплярами должно разделять и то и другое. Если имена по + экземплярам для вас важны, дайте всему парку один явный audience: + `RequestStateSecurity(keys=[...], audience="billing")`. Тогда каждый экземпляр выпускает и + принимает токены под `"billing"`, как бы он ни назывался. + +Всё остальное о запечатывании — в разделе **[Защита `requestState`](../handlers/multi-round-trip.md#protecting-requeststate)**: что оно связывает, `ttl` на раунд (600 секунд по умолчанию), собственный кодек, почему ненастроенное значение по умолчанию ровно подходит для `stdio`. Весь вклад этой страницы — чек-лист из двух пунктов: *одни ключи, одно имя.* + +!!! info + Вы на этом пути, даже если никогда не писали `InputRequiredResult`. Инструмент, чьи параметры + используют `Resolve(...)` (**[Зависимости](../handlers/dependencies.md)**), — многораундовый, + и SDK выпускает и запечатывает его `request_state` за него. Тот же ключ по умолчанию, тот же + сбой между рабочими процессами, то же решение. + +## Уведомления об изменениях между репликами {#change-notifications-across-replicas} + +Поток `subscriptions/listen` клиента — это один долгоживущий ответ, поэтому он привязан к одной реплике на всю свою жизнь. `ctx.notify_resource_updated(...)`, опубликованное на **другой** реплике, должно до него дойти. + +Шов между ними — `SubscriptionBus`. Какую шину вы дадите серверу, в ту и идёт каждая публикация и ту слушает каждый открытый поток, так что передайте одну и ту же шину каждой реплике: + +```python title="server.py" hl_lines="2 7 9" +--8<-- "docs_src/deploy/tutorial004.py" +``` + +Рассылке совершенно всё равно, к какому объекту сервера прикреплён поток. Два сервера с одним `InMemorySubscriptionBus` уже ведут себя так: откройте поток listen на одном, вызовите `edit_note` на другом — и поток об этом услышит. Эта шина в памяти охватывает только объекты серверов внутри одного процесса, так что это модель, а не развёртывание: + +* Между настоящими процессами **в SDK нет шины, которая могла бы помочь.** `SubscriptionBus` — это `Protocol` из двух методов (`publish` и `subscribe`), который вы реализуете поверх собственного pub/sub-бэкенда (Redis, NATS, что угодно, что у вас уже работает) и передаёте как `MCPServer(subscriptions=...)`. Набросок и контракт — на странице **[Подписки](../handlers/subscriptions.md#scaling-past-one-process)**. +* Шина переносит четыре небольших типизированных события и никогда — JSON-RPC. Подтверждение, фильтрация и жизненный цикл потоков остаются в SDK, поэтому ваша шина не может сломать протокол; она может только перемещать события между процессами. +* Потоки **не** возобновляемы, и события **не** воспроизводятся повторно. Потеря реплики обрывает её потоки; клиенты заново подписываются и заново запрашивают данные. Нет хранилища событий, которое нужно разделять, и больше нечего настраивать. Это единственное место, где горизонтальное масштабирование — действительно просто больше того же самого. + +## Чего SDK не даёт {#what-the-sdk-does-not-give-you} + +`MCPServer` — это реализация протокола, а не сервер приложений. Ручки развёртывания, которые вы пойдёте искать следующими, отсутствуют намеренно: + +* **Нет `workers=`.** `mcp.run("streamable-http")` запускает ровно один процесс uvicorn, и больше он ничего не запустит никогда. Многопроцессность — это `streamable_http_app()`, переданное тому, чем вы уже развёртываете ASGI: `uvicorn --workers`, gunicorn, менеджер процессов вашей платформы. Эта страница намеренно не учебник ни по одному из них; их документация лучше, чем была бы её копия здесь. +* **Нет маршрута проверки работоспособности.** `@mcp.custom_route("/health", methods=["GET"])` — вот и весь ответ, и он никогда не требует аутентификации, даже когда остальной сервер требует. Для liveness-пробы это правильно, для чего угодно приватного — нет. Пример есть на странице **[Добавление в существующее приложение](asgi.md#custom-routes)**. +* **Нет объекта настроек для продакшена.** В `MCPServer` негде записать таймауты, TLS, плавное завершение или лимиты соединений, потому что ничто из этого не его работа. Всё это принадлежит вашему ASGI-серверу, там и настраивается. Те немногие настройки, что конструктор *всё-таки* принимает, описаны на странице **[Запуск сервера](index.md)**. +* **Нет поставляемого `EventStore`, а на 2026-07-28 он и не нужен.** Возобновляемость — возможность ветки старого поколения с состоянием; современный обмен — это один POST, один ответ, и возобновлять нечего. + +## Итоги {#recap} + +* По умолчанию приложение отвечает только на запросы, адресованные localhost. `transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])` — это ворота в продакшен: пока вы его не передадите, каждый запрос за настоящим доменным именем получает `421`, а причина есть только в логе сервера. +* На 2026-07-28 нет сессии, и балансировщику не к чему привязываться. `stateless_http=True` — ручка только для старого поколения, потому что современный запрос маршрутизируется и получает ответ раньше, чем этот флаг вообще читается. +* Ключ `requestState` по умолчанию — `os.urandom(32)`, выпускаемый в каждом процессе. Многораундовый повтор, попавший к другому рабочему процессу, падает с `-32602` *«Invalid or expired requestState»*. +* Решение — `RequestStateSecurity(keys=[...])` **и** одно и то же имя сервера на каждом экземпляре. Имя — это audience claim токена по умолчанию. Одни ключи, одно имя. +* Уведомления об изменениях пересекают реплики через одну общую `SubscriptionBus`. Единственная реализация в SDK — внутрипроцессная; `Protocol` из двух методов поверх собственного pub/sub предстоит написать вам. +* Нет `workers=`, нет маршрута работоспособности, нет объекта настроек для продакшена. ASGI-сервер — ваш. + +Второе, что нужно настоящему доменному имени перед собой, — это токен: **[Авторизация](authorization.md)**. diff --git a/i18n/ru/pages/run/index.md b/i18n/ru/pages/run/index.md new file mode 100644 index 0000000000..491564361c --- /dev/null +++ b/i18n/ru/pages/run/index.md @@ -0,0 +1,156 @@ +--- +translation: + sections: [fea8d769ff9edeba, ce8e2ad42f29ef71, 0d705efb19cf99c2, 7a53ead3e704a7f0, 9adc400e8c88e854, 318893ad8e2e9924, 6b63ab96b34476c0] + tool: 1 +--- +# Запуск сервера {#running-your-server} + +`mcp.run()` запускает сервер. + +Единственное решение, которое нужно принять, — это **транспорт**: как именно байты перемещаются между сервером и его клиентом. + +## Выбор транспорта {#pick-a-transport} + +| Транспорт | Что это | Когда | +|---|---|---| +| `stdio` | Хост запускает ваш файл как подпроцесс и общается с ним через его stdin и stdout. | Локальные серверы. Вариант по умолчанию. | +| `streamable-http` | Настоящий HTTP-сервер, слушающий порт. | Всё, что вы развёртываете. | +| `sse` | Старый HTTP-транспорт. | Никогда. | + +!!! warning + В редакции протокола 2025-03-26 на смену SSE пришёл Streamable HTTP. + `mcp.run(transport="sse")` по-прежнему работает, со своими параметрами `sse_path=` и `message_path=`, + но существует лишь ради клиентов, которые ещё не перешли. Ничего нового на нём не стройте. + +## `mcp.run()` {#mcprun} + +```python title="server.py" hl_lines="12-13" +--8<-- "docs_src/run/tutorial001.py" +``` + +* `run()` синхронный. Он блокирует выполнение на всё время жизни сервера. +* Без аргументов транспорт — `stdio`. +* Вызов стоит под `if __name__ == "__main__":`, потому что всё, что загружает ваш сервер (`mcp dev`, `mcp run`, `mcp install`, тесты), **импортирует** этот файл. Эта проверка не даёт импорту превратиться в работающий сервер. + +### stdio {#stdio} + +Настраивать нечего. Хост запускает ваш файл как дочерний процесс, пишет запросы в его stdin и читает ответы из его stdout. + +Запустите его сами — и увидите следствие: + +```console +python server.py +``` + +Ничего не выводится, и управление не возвращается. Процесс ждёт на stdin, пока хост не заговорит первым. + +Это также означает, что stdout **и есть канал связи**. Во время обслуживания SDK переносит этот канал на приватный дескриптор, а вывод, который *сбрасывается* (flush) в stdout (подпроцесс, пишущий в унаследованный stdout, `print()` со сбросом буфера), перенаправляет в stderr, где он не может повредить поток. Вывод, сброшенный в stdout *до* начала обслуживания (echo в скрипте-обёртке, небуферизованный print при импорте), всё равно попадает в канал связи — как и `print()`, который остаётся в буфере, пока интерпретатор не сбросит его при выходе. Для вывода, который вам действительно нужен, правильный инструмент — модуль `logging`: его обработчик сбрасывает каждую запись в stderr сразу по мере появления. Подробнее — на странице **[Логирование](../handlers/logging.md)**. + +### Попробуйте сами {#try-it} + +```console +uv run mcp dev server.py +``` + +Inspector делает ровно то же, что и настоящий хост: запускает `server.py` как подпроцесс и подключается к нему через stdio. + +Порт вы ему не указывали. Его и нет. + +## Streamable HTTP {#streamable-http} + +Чтобы вместо этого посадить тот же сервер на порт, укажите транспорт (и его параметры) в `run()`: + +```python title="server.py" hl_lines="13" +--8<-- "docs_src/run/tutorial002.py" +``` + +Эта единственная строка собирает приложение Starlette и обслуживает его через uvicorn. Клиенты подключаются к `http://127.0.0.1:3001/mcp`. + +У каждого транспорта свои именованные аргументы, и все они передаются в `run()`: + +* `host` / `port`: где слушать. По умолчанию `127.0.0.1` и `8000`. +* `streamable_http_path`: где находится конечная точка MCP. По умолчанию `/mcp`. +* `json_response=True`: отвечать на каждый POST одним JSON-телом вместо SSE-потока. В этом теле есть место только для ответа и ничего больше, поэтому инструмент, который обращается к клиенту посреди запроса (`ctx.elicit()`, сэмплирование (sampling)), на этом участке выбрасывает `NoBackChannelError`, а уведомления, привязанные к выполняющемуся вызову (ход выполнения от `ctx.report_progress()`, лог-сообщения отдельного вызова), отбрасываются; отдельный поток `GET` по-прежнему доставляет не связанные с вызовом уведомления. +* `stateless_http=True`: свежий транспорт на каждый запрос, без отслеживания сессий. +* `max_request_body_size`: максимальный принимаемый размер тела POST в байтах. По умолчанию 4 МиБ; более крупные запросы + получают HTTP 413 ещё до разбора и создания сессии. Увеличивайте его, только если легитимные MCP-сообщения + превышают этот размер. +* `event_store`, `retry_interval`, `transport_security`: возобновляемость и защита от DNS-rebinding. Они могут подождать, пока вы не развернётесь где-то кроме localhost; `transport_security` разобран на странице **[Развёртывание и масштабирование](deploy.md)**. + +!!! warning + Параметры транспорта передаются в `run()`, а **не** в `MCPServer(...)`. Конструктор описывает, что + ваш сервер собой *представляет*: имя, версию, инструкции. `run()` описывает, как он обслуживается. Перепутайте — + и Python ответит раньше, чем дело вообще дойдёт до MCP: + + ```text + TypeError: MCPServer.__init__() got an unexpected keyword argument 'port' + ``` + +`run()` — короткий путь. Как только нужно больше (сервер, смонтированный внутри существующего приложения, два сервера в одном процессе, CORS для браузерных клиентов), вы собираете ASGI-приложение сами и отдаёте его любому ASGI-хосту. Об этом — **[Добавление в существующее приложение](asgi.md)**. + +## Настройки сервера {#server-settings} + +Пара вещей, связанных с запуском, к транспорту не относится. Это аргументы конструктора: + +```python title="server.py" hl_lines="3" +--8<-- "docs_src/run/tutorial003.py" +``` + +* `log_level`: передаётся в `logging.basicConfig()` в момент создания `MCPServer(...)`. Это настраивает **корневой** логгер, так что уровень задаётся и для ваших собственных логгеров, а не только для логгеров SDK. По умолчанию `"INFO"`. +* `debug`: пробрасывается в приложение Starlette, которое собирают HTTP-транспорты. По умолчанию `False`. + +Оба попадают в `mcp.settings`, откуда их можно прочитать во время выполнения. + +## Команда `mcp` {#the-mcp-command} + +Дополнение `[cli]` устанавливает небольшую утилиту командной строки поверх всего этого. + +`mcp dev` запускает ваш сервер под **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` добавляет пакеты в создаваемое окружение; `--with-editable` устанавливает в него ваш собственный пакет. Нужен `npx` в `PATH`: Inspector — это приложение на Node.js. + +`mcp run` импортирует файл, находит объект сервера (`mcp`, `server` или `app` на уровне модуля) и вызывает у него `run()`: + +```console +uv run mcp run server.py +uv run mcp run server.py:bookshop +``` + +Суффикс после `:` указывает имя объекта, если он называется не `mcp`, `server` и не `app`. + +Блок `if __name__ == "__main__":` здесь никогда не выполняется: `mcp run` вызывает `run()` сам, и единственный параметр, который он пробрасывает, — `--transport`. + +`mcp install` регистрирует сервер в **Claude Desktop**, чтобы приложение запускало его за вас: + +```console +uv run mcp install server.py --name "Bookshop" +uv run mcp install server.py -v API_KEY=abc123 -f .env +``` + +`-v KEY=VALUE` и `-f .env` записывают в эту запись переменные окружения. Claude Desktop запускает ваш сервер в отдельном процессе. Окружения вашей оболочки там нет. + +Claude Desktop — единственный хост, который знает `mcp install`. Все остальные хосты (Claude Code, Cursor, VS Code) принимают ту же команду запуска в собственном конфигурационном файле, и каждый из них описан на странице **[Подключение к настоящему хосту](../get-started/real-host.md)**. + +`mcp version` выводит версию установленного SDK. + +!!! tip + `mcp dev` и `mcp run` понимают только `MCPServer`. Если вы строите сервер на низкоуровневом `Server`, + запускать его придётся самим. См. **[Низкоуровневый Server](../advanced/low-level-server.md)**. + +## Итоги {#recap} + +* **Транспорт** — это то, как байты добираются до сервера: `stdio` для локального подпроцесса, `streamable-http` для порта. SSE вытеснен. +* Транспорт выбирает `mcp.run()`. Без аргументов это `stdio`, и вызов блокирует выполнение. +* Любой параметр транспорта (`host`, `port`, `streamable_http_path`, ...) — это аргумент `run()` и никогда не `MCPServer(...)`. +* Держите `run()` под `if __name__ == "__main__":`. Всё, что загружает ваш сервер, сначала импортирует файл. +* `log_level=` и `debug=` — аргументы конструктора; они попадают в `mcp.settings`. +* `mcp dev` — для Inspector, `mcp run` — чтобы выполнить файл, `mcp install` — для Claude Desktop, `mcp version` — узнать версию. +* Транспорт никогда не меняет того, что ваш сервер собой *представляет*: все три файла на этой странице предоставляют один и тот же инструмент. + +Когда ограничением становится сам `run()` (сервер внутри уже существующего приложения), нужна страница **[Добавление в существующее приложение](asgi.md)**. Настоящее имя хоста и больше одного воркера — это **[Развёртывание и масштабирование](deploy.md)**. А если часть ваших клиентов всё ещё на версии спецификации 2025-11-25 или более ранней, хорошие новости ждут на странице **[Обслуживание клиентов старого поколения](legacy-clients.md)**. diff --git a/i18n/ru/pages/run/legacy-clients.md b/i18n/ru/pages/run/legacy-clients.md new file mode 100644 index 0000000000..254ec014fc --- /dev/null +++ b/i18n/ru/pages/run/legacy-clients.md @@ -0,0 +1,134 @@ +--- +translation: + sections: [3d1663c18edc824c, d4fd37009a13f03d, af9f398a5a8b679a, 470c2dd144294d69, 8e45827e6d24e8c8, 91dfd0ce98ebb03c] + tool: 1 +--- +# Обслуживание клиентов старого поколения {#serving-legacy-clients} + +У MCP два поколения протокола: поколение рукопожатия `initialize` — до версии спецификации `2025-11-25` включительно — и современное поколение, `2026-07-28`. Самому этому разделению посвящена страница **[Версии протокола](../protocol-versions.md)**. + +Эта страница — о серверной стороне этого разделения, и ответ умещается в одно предложение: **приложение `streamable_http_app()`, которое вы уже развёртываете, обслуживает оба поколения.** + +SDK маршрутизирует каждый запрос по его заголовку `MCP-Protocol-Version`. Запрос, в котором указана `2026-07-28`, попадает в современный обработчик. Запрос с версией поколения рукопожатия или вовсе без заголовка (именно так приходит `initialize` от клиента до 2026 года) уходит в транспорт, которого ждут такие клиенты: рукопожатие `initialize`, сессии и всё остальное. Это происходит для каждого запроса отдельно, до вашего кода, в одном и том же приложении. + +Так что клиент старого поколения — не то, *ради* чего вы что-то пишете. Это то, что само подключается *к* уже написанному серверу. Настраивать ничего не нужно. + +!!! note + Буквально ничего. Нет параметра `legacy=`, нет списка разрешённых версий, нет способа + отклонить или отключить поколение: ни в `streamable_http_app()`, ни в `run()`, ни в менеджере + сессий. Оба поколения включены всегда. Ближе всего к переключателю поколений в этой сигнатуре + параметр `stateless_http` — ему и посвящена бо́льшая часть страницы. + +## Один обработчик, оба поколения {#one-handler-both-eras} + +Вот инструмент, которому нужно кое-что спросить у пользователя, и клиенты обоих поколений, которые его вызывают: + +```python title="server.py" hl_lines="24 37-38" +--8<-- "docs_src/legacy_clients/tutorial001.py" +``` + +Инструменту `reserve` нужно одно, чего модель не сообщила: сколько экземпляров. `Annotated[..., Resolve(ask_quantity)]` — так инструмент это объявляет (подробнее — на странице **[Зависимости](../handlers/dependencies.md)**). Ничто в `reserve` не называет версию, не проверяет возможность и не ветвится. + +Оба клиента открыты **одновременно**, на одном и том же объекте `mcp`. `mode="legacy"` выполняет рукопожатие `initialize` — ровно такое подключение открывает клиент до 2026 года. Второй клиент берёт значение по умолчанию и оказывается на `2026-07-28`. + +```text +2025-11-25 {'result': "Reserved 2 of 'Dune'."} +2026-07-28 {'result': "Reserved 2 of 'Dune'."} +``` + +Тот же сервер, тот же обработчик, тот же ответ. Вот и весь механизм. + +Стоит задержаться на том, *как* это работает, потому что один и тот же вопрос двум клиентам задали по двум совершенно разным каналам. У подключения `2026-07-28` нет канала, по которому сервер мог бы отправить запрос, поэтому `Resolve` вернул вопрос внутри результата инструмента, а клиент повторил вызов уже с ответом (**[Многораундовые запросы (multi-round-trip)](../handlers/multi-round-trip.md)**). У подключения `2025-11-25` ничего подобного нет; там `Resolve` отправил настоящий запрос `elicitation/create` прямо посреди вызова и дождался ответа. Ни того ни другого вы не писали. `Resolve` читает согласованную версию подключения и выбирает сам; тело инструмента в обоих случаях получает `AcceptedElicitation`. + +!!! tip + Именно эта переносимость между поколениями — причина, *почему* строить стоит на `Resolve`. + Его старший родственник `ctx.elicit()` (**[Элицитация (elicitation)](../handlers/elicitation.md)**) + умеет отправлять только `elicitation/create`, так что работает только на подключении старого + поколения. На подключении `2026-07-28` вызов завершается ошибкой. Если какой-то инструмент всё + ещё им пользуется, исправление — то, что показано выше, а не проверка версии. + +## Во что обходится сессия старого поколения {#what-a-legacy-session-costs-you} + +Маршрутизация бесплатна. Сессия — нет. + +Подключение `2026-07-28` **бессессионное**: каждый запрос самостоятелен, и современный обработчик никогда не выдаёт `Mcp-Session-Id`. Подключение старого поколения — полная противоположность. Как только клиент до 2026 года отправляет `initialize`, SDK создаёт `Mcp-Session-Id`, возвращает его в заголовке ответа и хранит за ним живую запись, которую будут находить последующие запросы клиента: согласованная версия, открытые потоки, фоновая задача, ведущая сессию. + +Эта запись — **обычный `dict` внутри процесса**. Распределённого хранилища сессий нет, и подключить своё невозможно. + +На одном рабочем процессе это незаметно. На двух — в этом вся проблема: запрос с `Mcp-Session-Id`, попавший на рабочий процесс, который этот идентификатор не создавал, ничего в словаре не находит, и в ответ приходит `404` (`Session not found`), а не результат инструмента. Поэтому, как только рабочих процессов больше одного, **клиентам старого поколения нужна липкая маршрутизация (sticky routing)**: каждый запрос сессии должен попадать в тот процесс, который её начал. Современным клиентам это не нужно никогда: у них нет сессии, к которой можно было бы привязаться. О привязке и обо всём остальном, что касается запуска нескольких экземпляров, — на странице **[Развёртывание и масштабирование](deploy.md)**. + +!!! warning + `event_store=` выглядит как решение, но это не оно. Это **возобновляемость** (повторная + отправка пропущенных SSE-событий клиенту, который переподключается к *той же* сессии), а не + хранилище сессий. Сессию доступной из другого процесса он не делает никогда. + +## Единственный переключатель: `stateless_http` {#the-one-knob-stateless_http} + +Если привязка — цена, которую вы платить не готовы, изменить можно ровно одно. + +```python title="server.py" hl_lines="28" +--8<-- "docs_src/legacy_clients/tutorial002.py" +``` + +Это сервер из начала страницы плюс один именованный аргумент. С `stateless_http=True` ветка старого поколения вместо этого создаёт одноразовую сессию на каждый запрос: `Mcp-Session-Id` не выдаётся, между запросами ничего не запоминается, так что любой рабочий процесс может обслужить любой запрос, а балансировщик нагрузки волен делать что угодно. + +Две вещи о нём важнее того, что он делает. + +**Он затрагивает только ветку старого поколения.** Запросы маршрутизируются по заголовку версии *до* того, как читается `stateless_http`, так что современный путь его не видит вовсе. Подключение `2026-07-28` и так бессессионное и ведёт себя совершенно одинаково при любом значении. + +**Он стоит обоих каналов от сервера к клиенту на этой ветке.** У сессии, живущей один `POST`, нет потока, по которому сервер мог бы отправить запрос, и нет отдельного потока, по которому он мог бы отправлять уведомления. Каждый запрос по инициативе сервера выбрасывает `NoBackChannelError`: `ctx.elicit()`, отправленные на покой вызовы сэмплирования (sampling) и корневых каталогов (roots) (**[Устаревшие возможности](../deprecated.md)**) и — да — `Resolve`, задающий свой вопрос клиенту *старого поколения*. Уведомления не получают даже ошибки: они молча отбрасываются. + +!!! note + `json_response=True` — не тот переключатель, но половину той же цены он берёт с *каждой* + сессии старого поколения: у `POST`, на который отвечают одним JSON-телом, нет потока для + канала, привязанного к запросу, поэтому `ctx.elicit()` посреди запроса выбрасывает ту же + `NoBackChannelError`, а уведомления, связанные с запросом, отбрасываются. Отдельный поток + сессии не затронут: не связанные с запросом уведомления по-прежнему приходят. + +!!! check + Сделайте заведомо неправильно. `reserve` — тот самый инструмент, который только что обслужил + оба клиента. Разверните его с `stateless_http=True`, подключите те же два клиента по HTTP и + вызовите его из каждого. + + Современный клиент по-прежнему получает `Reserved 2 of 'Dune'.` Современная ветка не изменилась. + + Вызов клиента старого поколения не возвращается результатом с `is_error`, который модель + могла бы прочитать. Падает весь запрос — ошибкой протокола верхнего уровня: + + ```text + mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests. + ``` + + `Resolve` вас не спас. На подключении `2025-11-25` он *обязан* отправить `elicitation/create`, + а нужный ему канал — ровно то, что отдал `stateless_http=True`. Код, переносимый между + поколениями, — это не код без обратного канала (back-channel). + +Так что это настоящий компромисс, и существует он только на ветке старого поколения: **с сессиями и привязкой — или без состояния и в одну сторону.** Если ваши инструменты никогда не обращаются обратно к клиенту, `stateless_http=True` ничего не стоит, и его стоит включить. Если обращаются — оставьте сессии и сохраните липкую маршрутизацию. + +## Где код действительно ветвится {#where-your-code-actually-forks} + +Почти нигде. + +Инструменты, ресурсы, промпты, структурированный вывод, прогресс, ошибки — никому из них нет дела до того, какое поколение вызвало. Рукопожатие `initialize`, `Mcp-Session-Id`, отдельный поток, `DELETE`, завершающий сессию, — всем этим владеет SDK, и обработчик ничего из этого не видит. Интерактивный ввод — *то самое* место, где поколения по-настоящему расходятся в передаваемых данных, и `Resolve` существует именно для того, чтобы это было не вашей заботой: вы только что видели, как один инструмент обслужил оба. + +Остаётся ровно одно — **уведомления об изменениях**, потому что два поколения слушают разные каналы: + +* Клиент `2026-07-28` открывает поток `subscriptions/listen` и читает шину подписок. `ctx.notify_resource_updated()` (а также `notify_tools_changed()`, `notify_prompts_changed()`, `notify_resources_changed()`) публикуют туда, и *только* туда. Подробнее — на странице **[Подписки](../handlers/subscriptions.md)**. +* Клиент старого поколения читает отдельный поток, который держит открытым его сессия. `ctx.session.send_resource_updated()` (а также `send_tool_list_changed()` и остальные) пишут в то *подключение*, по которому пришёл запрос: для сессии старого поколения это её отдельный поток. У современного подключения места для этого нет: по HTTP такого канала не существует, а по stdio четыре вида уведомлений об изменениях ходят только по потокам `subscriptions/listen`, так что на современном подключении уведомление молча отбрасывается. + +По HTTP ни один из вызовов не доходит до клиентов другого поколения. Чтобы известить всех, вызывайте оба: + +```python title="server.py" hl_lines="19-20" +--8<-- "docs_src/legacy_clients/tutorial003.py" +``` + +Две строки, никакого `if`, никакой проверки версии — и готово. Это полный список того, что обработчик делает иначе из-за существования клиентов старого поколения. + +## Итоги {#recap} + +* Одно приложение `streamable_http_app()` обслуживает оба поколения протокола. SDK маршрутизирует каждый запрос по заголовку `MCP-Protocol-Version`; настраивать нечего, и переключателя поколений искать не нужно. +* Клиент старого поколения обходится вам в сессию: запись `Mcp-Session-Id` внутри процесса без распределённого хранилища за ней. Больше одного рабочего процесса — значит **липкая маршрутизация**, иначе не тот процесс ответит `404 Session not found`. Подробнее о нескольких рабочих процессах — на странице **[Развёртывание и масштабирование](deploy.md)**. +* `stateless_http=True` — единственный переключатель, и действует он **только на ветку старого поколения**. Он даёт клиентам старого поколения свободную балансировку нагрузки ценой обоих каналов от сервера к клиенту на этой ветке: запросы по инициативе сервера выбрасывают `NoBackChannelError` (на клиенте — ошибка верхнего уровня, а не результат с `is_error`), а уведомления отбрасываются. +* Подключение `2026-07-28` бессессионное в любом случае. `stateless_http` его никогда не затрагивает. +* Код обработчика ветвится по поколению ровно в одном месте: уведомления об изменениях. `ctx.notify_*` доходит до клиентов `subscriptions/listen`; `ctx.session.send_*` — до сессий старого поколения. Вызывайте оба. +* Всё остальное (включая запрос ввода у пользователя через `Resolve`) переносимо между поколениями по построению. Напишите современный вариант один раз. diff --git a/i18n/ru/pages/run/opentelemetry.md b/i18n/ru/pages/run/opentelemetry.md new file mode 100644 index 0000000000..e025bf0bc3 --- /dev/null +++ b/i18n/ru/pages/run/opentelemetry.md @@ -0,0 +1,85 @@ +--- +translation: + sections: [bc0227014724fa49, 15738c2f7fd67d86, a2c17bbe3f707e2f, d0d853376f162c06, b6368643fcc1c8d8, 902e33e17564a607] + tool: 1 +--- +# OpenTelemetry {#opentelemetry} + +Ваш сервер уже трассируется. Добавлять ничего не нужно. + +Каждый созданный вами сервер порождает спан [OpenTelemetry](https://opentelemetry.io/) для каждого обработанного сообщения. Вы этого не писали и не импортируете. Это появляется в тот момент, когда вы вызываете `MCPServer(...)`. + +```python title="server.py" +--8<-- "docs_src/opentelemetry/tutorial001.py" +``` + +Это уже готовый сервер с трассировкой. Вызовите `search_books` — и для него будет создан спан. То же самое верно для низкоуровневого `Server`: трассировка есть в обоих. + +## Что вы получаете {#what-you-get} + +Каждое входящее сообщение становится спаном `SERVER`, названным по методу и его цели. Так, `tools/call` для `search_books` даёт спан `tools/call search_books`, а просто `tools/list` — спан `tools/list`. + +Каждый спан несёт несколько атрибутов: + +* `mcp.method.name` и `mcp.protocol.version` — на каждом спане. +* `jsonrpc.request.id` — на запросе (у уведомления его нет). +* Обработчик, выбросивший исключение, переводит статус спана в ошибку. То же делает результат инструмента с `is_error=True`. + +А поскольку трассировать вызов инструмента хочется особенно часто, спаны `tools/call` следуют [семантическим соглашениям GenAI](https://opentelemetry.io/docs/specs/semconv/gen-ai/) из OpenTelemetry: + +* `gen_ai.operation.name` со значением `"execute_tool"`. +* `gen_ai.tool.name` с именем вызываемого инструмента. + +Спан `prompts/get` в том же духе получает `gen_ai.prompt.name`. Методы списков не несут ключей `gen_ai.*`, потому что называть там нечего. + +!!! tip + Именно благодаря этим атрибутам GenAI интерфейс трассировки группирует ваши вызовы инструментов так же, как вызовы любого другого агента. Эта группировка достаётся даром, без дополнительного кода. + +## Это ничего не стоит, пока вам не понадобится {#it-costs-nothing-until-you-want-it} + +Вот что делает «включено по умолчанию» комфортным вариантом по умолчанию. + +SDK зависит только от `opentelemetry-api` — лёгкой половины OpenTelemetry. Пока не установлены ни SDK OpenTelemetry, ни экспортёр, создание спана ничего не делает. Так что спаны, которые ваш сервер порождает прямо сейчас, почти ничего не стоят, и никто их не собирает. + +В тот день, когда захочется их *увидеть*, установите вторую половину и направьте её куда-нибудь: + +```console +uv add opentelemetry-sdk opentelemetry-exporter-otlp +``` + +Настройте экспортёр обычным для OpenTelemetry способом — и все спаны, которые SDK тихо создавал, станут видны. Код сервера не меняется. Ни одной строки. + +!!! info + [Pydantic Logfire](https://logfire.pydantic.dev/) — один из таких бэкендов, и он берёт настройку на себя: `pip install logfire`, `logfire.configure()` — и ваши MCP-спаны появляются в живом просмотре. Он построен на OpenTelemetry, поэтому всё сказанное ниже относится и к нему. + +## Трассы, пересекающие сеть {#traces-that-cross-the-wire} + +Трасса полезнее всего, когда она сопровождает запрос от клиента до сервера в одной связной картине. + +Когда и клиент, и сервер работают на SDK, эта связь возникает автоматически. Клиент внедряет в запрос [контекст трассировки W3C](https://www.w3.org/TR/trace-context/), а сервер считывает его обратно, так что серверный спан вкладывается в клиентский в рамках одной трассы. Это [SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414), и вы получаете его, ничего не запрашивая. + +Если входящее сообщение не несёт контекста трассировки — например, запрос от клиента, который не является SDK, — серверный спан просто становится дочерним к тому спану, который уже текущий на сервере, а не начинает совершенно новую трассу-сироту. + +## Как отключить {#turning-it-off} + +Трассировка — это middleware, первый в списке вашего сервера. Если действительно нужен сервер, который не порождает спанов, уберите его: + +```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 + В этом импорте есть ведущее подчёркивание, и это намеренно. Класс предварительный, так же как предварителен [`Server.middleware`](../advanced/middleware.md), поэтому будьте готовы к тому, что путь импорта изменится. Это почти никогда не нужно: без установленного экспортёра спаны бесплатны, так что обычный ответ — оставить их включёнными и не устанавливать экспортёр. + +## Итоги {#recap} + +* Каждый `MCPServer` и каждый низкоуровневый `Server` по умолчанию порождает один спан `SERVER` на каждое входящее сообщение. Вы ничего не пишете. +* Спаны несут `mcp.method.name` и `mcp.protocol.version`; `tools/call` и `prompts/get` дополнительно несут атрибуты GenAI, так что ваши вызовы инструментов группируются как у любого другого агента. +* Это ничего не стоит, пока вы не установите SDK OpenTelemetry и экспортёр, — а затем всё становится видно без изменений в сервере. +* Контекст трассировки от клиента к серверу распространяется автоматически, когда обе стороны работают на SDK. + +Решает, будет ли запрос выполнен вообще, **[Авторизация](authorization.md)**. diff --git a/i18n/ru/pages/servers/completions.md b/i18n/ru/pages/servers/completions.md new file mode 100644 index 0000000000..6b6af771ce --- /dev/null +++ b/i18n/ru/pages/servers/completions.md @@ -0,0 +1,130 @@ +--- +translation: + sections: [72f9c964769076dd, 9a2c14e10935b515, 235299eb78ab12d7, 8aee1e78c8237fb8, 9bd86acd4112138f, 55343cb7f250dc7b] + tool: 1 +--- +# Автодополнение {#completions} + +Клиент, который строит пользовательский интерфейс поверх вашего сервера, хочет дополнять значения аргументов прямо по мере ввода: названия языков, имена репозиториев, пути к файлам. + +**Автодополнение** (completions) — это способ, которым сервер выдаёт такие подсказки. + +## Что стоит дополнять {#something-worth-completing} + +Автодополнение применяется ровно к двум вещам: к аргументам **промпта** и к параметрам **шаблона ресурса**. Поэтому начнём с сервера, в котором есть и то и другое: + +```python title="server.py" hl_lines="6 12" +--8<-- "docs_src/completions/tutorial001.py" +``` + +Пока здесь нет ничего про автодополнение. + +* `review_code` принимает `language`. Пользователь не должен угадывать, какие варианты написания вы принимаете. +* `github_repo` принимает `owner` и `repo`. Два поля для свободного ввода — плохая форма. + +## Обработчик автодополнения {#the-completion-handler} + +Добавьте **одну** функцию с декоратором `@mcp.completion()`: + +```python title="server.py" hl_lines="21-29" +--8<-- "docs_src/completions/tutorial002.py" +``` + +* Обработчик на сервер один. Все запросы автодополнения попадают сюда, а дальше вы ветвитесь в зависимости от того, что именно дополняется. +* Он должен быть `async def`: SDK вызывает его через await. +* Он получает три аргумента: + * `ref`: *какой* промпт или шаблон ресурса — в виде `PromptReference` или `ResourceTemplateReference`. Различить их можно через `isinstance`. + * `argument`: `argument.name` — дополняемый аргумент, `argument.value` — то, что пользователь уже успел набрать. + * `context`: уже определённые аргументы. Пока не обращайте на него внимания. +* Верните `Completion(values=[...])` или `None`, если предложить нечего. + +!!! tip + `argument.value` — это префикс, который набрал пользователь. SDK **не** фильтрует за вас: что + положите в `values`, то интерфейс и покажет. `startswith` пишете вы сами. + +### Попробуйте сами {#try-it} + +Проверьте его с помощью `Client` в памяти со страницы **[Тестирование](../get-started/testing.md)**. Вызовите +`client.complete()` с `ref=PromptReference(name="review_code")` и +`argument={"name": "language", "value": "py"}`: + +```python +result.completion.values # ['python'] +``` + +* `ref` — тот же ссылочный тип, что получает обработчик. +* `argument` — обычный словарь ровно с двумя ключами: `name` и `value`. + +Отправьте пустое `value` — и вернётся весь список. `lang.startswith("")` истинно для любого языка: + +```python +result.completion.values # ['go', 'javascript', 'python', 'rust', 'typescript'] +``` + +Спросите про `code` (аргумент, который обработчик не знает) — он вернёт `None`, а SDK превратит его в пустой список: + +```python +result.completion.values # [] +``` + +`None` означает *«подсказок нет»* и никогда не ошибку. Интерфейс откатывается к обычному текстовому полю. + +## Возможность, которую вы не объявляли {#a-capability-you-never-declared} + +Регистрация обработчика и есть объявление. Подключите клиент и посмотрите: + +```python +client.server_capabilities.completions # CompletionsCapability() +``` + +Вы нигде не указывали `completions`. SDK увидел обработчик и объявил возможность за вас. Так работает каждая *необязательная* возможность: обработчик — это и есть объявление. (Три примитива к необязательным не относятся: `MCPServer` объявляет их всегда, есть обработчики или нет.) + +!!! check + Вернитесь к первому `server.py` (тому, где обработчика нет) и всё равно спросите. Вызов завершится + ошибкой JSON-RPC: + + ```text + Method not found + ``` + + А `client.server_capabilities.completions` равно `None`. В этом и смысл возможности: + корректный клиент проверяет её и никогда не отправляет запрос, на который вы не можете ответить. + +## Зависимые аргументы {#dependent-arguments} + +У `github://repos/{owner}/{repo}` два параметра, и полезные значения для `repo` зависят от того, какой `owner` выбрали первым. + +Для этого и нужен `context`. Он несёт аргументы, которые пользователь **уже определил**: + +```python title="server.py" hl_lines="8-11 34-38" +--8<-- "docs_src/completions/tutorial003.py" +``` + +* Новая ветка срабатывает для параметра `repo` шаблона. +* `context.arguments` — это `dict[str, str] | None` со значениями, выбранными к этому моменту (здесь `owner`). +* Нет `owner` — нет и осмысленных подсказок, поэтому обработчик возвращает `None`. + +Клиент отправляет эти определённые значения через `context_arguments=`. На этот раз `ref` — это +`ResourceTemplateReference(uri="github://repos/{owner}/{repo}")`. Запросите `repo` с +пустым `value` и передайте `context_arguments={"owner": "modelcontextprotocol"}`: + +```python +result.completion.values # ['python-sdk', 'typescript-sdk', 'inspector'] +``` + +Уберите `context_arguments=` — и тот же вызов вернёт `[]`. Обработчик не может знать, какие репозитории предложить, пока не знает владельца. + +!!! info + `Completion` принимает также `total=` и `has_more=`. Задавайте их, когда `values` — лишь часть + более длинного списка, чтобы интерфейс мог показать *«и ещё 200»*. Большинству обработчиков они не нужны. + +## Итоги {#recap} + +* Автодополнение — это подсказки для **аргументов промптов** и **параметров шаблонов ресурсов**. И ничего больше. +* `@mcp.completion()` регистрирует единственный обработчик. Это `async def (ref, argument, context) -> Completion | None`. +* Ветвитесь по `isinstance(ref, ...)` и по `argument.name`. Фильтруйте по `argument.value` сами. +* `None` превращается в пустой список. Это никогда не ошибка. +* В `context.arguments` лежат уже определённые значения; клиент передаёт их как `context_arguments=`. +* Возможность `completions` появляется, как только вы регистрируете обработчик. Без него ответ на запрос — `Method not found`. + +Подсказки помогают, пока пользователь ещё *заполняет* промпт или шаблон; чтобы задать ему вопрос *посреди* вызова инструмента, нужна **[элицитация](../handlers/elicitation.md)** (elicitation). Всё, что инструмент может вернуть помимо текста, — на странице **[Изображения, аудио и значки](media.md)**. diff --git a/i18n/ru/pages/servers/handling-errors.md b/i18n/ru/pages/servers/handling-errors.md new file mode 100644 index 0000000000..e64a1c9fa7 --- /dev/null +++ b/i18n/ru/pages/servers/handling-errors.md @@ -0,0 +1,139 @@ +--- +translation: + sections: [e33d441f12d50535, 7099694c603e0f5f, c1df4cf9673433e6, c9cd294541422e6e, 6cec073617bfd037, efa92b8f99e908c8, 6a22a29e27fb4601] + tool: 1 +--- +# Обработка ошибок {#handling-errors} + +Инструмент может завершиться неудачей двумя способами, и SDK обрабатывает их совершенно по-разному. + +Выбросьте обычное исключение — и его увидит **модель**. Выбросьте `MCPError` — и его увидит **протокол**. + +Эта страница о том, как выбрать. + +## Ошибка, которую модель может исправить {#an-error-the-model-can-fix} + +Возьмём инструмент, который что-то ищет, и пусть поиск ничего не найдёт: + +```python title="server.py" hl_lines="11-12" +--8<-- "docs_src/handling_errors/tutorial001.py" +``` + +В этих двух строках нет ничего специфичного для MCP. `get_author` выбрасывает обычный `ValueError`, как любая функция на Python. + +Вызовите его с названием, которого нет в каталоге, и посмотрите на результат: + +```python +result.is_error # True +result.content # [TextContent(text="Error executing tool get_author: No book titled 'Nothing' in the catalog.")] +result.structured_content # None +``` + +* Запрос **выполнен успешно**. Результат есть; на вызывающей стороне ничего не выброшено. +* `is_error` равен `True`, а сообщение вашего исключения (с префиксом в виде имени инструмента) лежит в `content` — ровно там, где читает модель. +* `structured_content` равен `None`. У неудачного вызова нет возвращаемого значения, которое можно было бы структурировать. + +Это **ошибка инструмента**, и так по умолчанию обрабатывается *любое* исключение, выброшенное инструментом. И почти всегда это именно то, что нужно. + +Ваш инструмент вызывает модель. Она же выбрала аргументы. Поэтому ошибка инструмента — это реплика в диалоге: модель читает *«No book titled 'Nothing' in the catalog.»*, понимает, что ошиблась с названием, и вызывает инструмент снова с более подходящим. Вы написали один `raise` и получили агента, который исправляет себя сам. + +!!! tip + Никогда не возвращайте сообщение об ошибке из инструмента через `return`. У возвращённой строки + `is_error=False`, поэтому для модели (и для любого клиентского интерфейса) всё выглядит так, будто + инструмент сработал, а эта строка и есть ответ. Используйте `raise`. Сигналом служит флаг. + +## Ошибка, которую модель исправить не может {#an-error-the-model-cannot-fix} + +Теперь замените `ValueError` на `MCPError`. + +```python title="server.py" hl_lines="1 3 14" +--8<-- "docs_src/handling_errors/tutorial002.py" +``` + +`MCPError` — это **ошибка протокола** в SDK. Это единственное исключение, которое обёртка инструмента *не* перехватывает: оно проходит дальше, и весь запрос `tools/call` завершается ошибкой JSON-RPC вместо результата. + +```json +{ + "code": -32602, + "message": "No book titled 'Nothing' in the catalog." +} +``` + +* Результата **нет**. Ни `content`, ни `is_error` — модели нечего читать. +* Вместо этого ошибку получает приложение-**хост** — так же, как если бы инструмента не существовало вовсе. +* `code`, `message` и `data` доходят без изменений. `INVALID_PARAMS` — это `-32602`; модуль `mcp.types` экспортирует его и остальные коды ошибок JSON-RPC (`INVALID_REQUEST`, `INTERNAL_ERROR`, ...) как константы, чтобы никогда не приходилось набирать магическое число. + +!!! check + Тот же поиск, тот же промах, но теперь вызов на стороне клиента *выбрасывает исключение* вместо того, чтобы вернуть результат: + + ```text + mcp.shared.exceptions.MCPError: No book titled 'Nothing' in the catalog. + ``` + + Первая версия передавала модели фразу, на которую та могла отреагировать. Эта не передаёт ничего. + Для `get_author` это однозначно хуже — о чём и следующий раздел. + +## Что выбрасывать {#which-one-to-raise} + +Два пути отвечают на два разных вопроса. + +* **Выбрасывайте любое исключение** при сбое *выполнения*: то, что инструмент пытался сделать, не получилось. Вызов выбрала модель, значит, модель и должна увидеть последствия и получить шанс исправиться. Опечатка в названии, тайм-аут внешнего API, несуществующая строка в таблице — всё это ошибки инструмента. +* **Выбрасывайте `MCPError`**, когда отклонить нужно *сам запрос*: у клиента нет возможности, от которой зависит инструмент, сервер не в состоянии обслуживать кого бы то ни было, вызывающая сторона пропустила обязательный шаг. Никакая повторная попытка модели ничего из этого не исправит, так что передавать ей сообщение бессмысленно. + +Решает один вопрос: **могла бы более умная модель этого избежать?** Да -> обычное исключение. Нет -> `MCPError`. + +По этому критерию вторая версия `get_author` выбрала неверно: правильное название всё исправляет, значит, модель заслуживала увидеть сообщение. Она здесь, чтобы показать механизм, а не чтобы его рекомендовать. + +!!! info + `MCPError` импортируется как `from mcp import MCPError` и принимает `code`, `message` и необязательную + полезную нагрузку `data`. Что бы вы в них ни положили, именно это и получит клиент: SDK передаёт + выброшенный `MCPError` дословно, не очищая его. + +## Ресурс, которого не существует {#a-resource-that-doesnt-exist} + +Ресурсы проводят ту же границу и для частого случая поставляются с одним именованным исключением. + +```python title="server.py" hl_lines="2 13" +--8<-- "docs_src/handling_errors/tutorial003.py" +``` + +`books://{title}` — это **шаблон**. Он совпадает с *любым* названием, поэтому «URI корректен» и «книга существует» — два разных вопроса, и на второй может ответить только ваша функция. + +Когда ответить она не может, выбрасывайте `ResourceNotFoundError`. SDK превращает его в ошибку протокола, которую спецификация назначает отсутствующему ресурсу: `-32602` с запрошенным URI в `data`, чтобы клиент знал, *какое именно* чтение не удалось. + +```json +{ + "code": -32602, + "message": "No book titled 'Nothing' in the catalog.", + "data": {"uri": "books://Nothing"} +} +``` + +Обратите внимание: здесь нет полурезультата с `is_error=True`. Чтение ресурса либо возвращает содержимое, либо завершается ошибкой — у ресурсов есть только протокольный путь. Шаблоны и всё остальное о ресурсах — на странице **[Ресурсы](resources.md)**. + +## Ошибки, которые вы никогда не выбрасываете {#errors-you-never-raise} + +Некорректный аргумент никогда не доходит до вашей функции. + +Передайте `get_author` значение `title`, которое не является строкой, и SDK отклонит его по входной схеме **до** вызова функции — в виде такой же ошибки инструмента с `is_error=True`, которую модель может прочитать и исправить. На странице **[Инструменты](tools.md)** показано такое же отклонение с ограничением `Field(le=50)`. + +Это целый класс операторов `raise`, которые писать не нужно: не проверяйте повторно собственные аннотации типов. + +!!! info + Всё на этой странице — это то, что видит **клиент**, и `Client` в памяти, с которым вы будете + писать тесты, видит ровно то же самое. Даже `raise_exceptions=True` не превращает ошибку инструмента + обратно в трассировку: к моменту, когда этот флаг мог бы сработать, ваше исключение уже стало + результатом с `is_error=True`. Проверяйте результат. Этот приём описан на странице **[Тестирование](../get-started/testing.md)**. + +## Итоги {#recap} + +* Выбрасываете **любое исключение** в инструменте -> вызов возвращает `is_error=True` с вашим сообщением в `content`. Модель читает его и может повторить попытку. Это поведение по умолчанию. +* Выбрасываете **`MCPError`** -> сам вызов завершается ошибкой JSON-RPC. Модель ничего не видит; разбирается хост. `code`, `message` и `data` доходят без изменений. +* Решающий вопрос: *могла бы более умная модель этого избежать?* Да -> исключение. Нет -> `MCPError`. +* `ResourceNotFoundError` из обработчика ресурса -> протокольный `-32602` с URI в `data`. +* Некорректные аргументы отклоняются по схеме до запуска вашей функции; `raise` для них не нужен. +* `from mcp import MCPError`; константы кодов ошибок — из `mcp.types`. + +С ошибками разобрались. Это всё, что сервер *предоставляет*. Что каждый обработчик может прочитать и что сделать в сторону клиента во время выполнения — в следующем разделе: **[Внутри обработчика](../handlers/index.md)**. + +Точный текст ошибок SDK, которые встретятся чаще всего, смысл каждой и исправление в одно действие — на странице **[Устранение неполадок](../troubleshooting.md)**. diff --git a/i18n/ru/pages/servers/index.md b/i18n/ru/pages/servers/index.md new file mode 100644 index 0000000000..fc6f3b3d54 --- /dev/null +++ b/i18n/ru/pages/servers/index.md @@ -0,0 +1,22 @@ +--- +translation: + sections: [09defc170a0da89d] + tool: 1 +--- +# Серверы {#servers} + +`MCPServer` предоставляет подключённому клиенту три примитива. Они различаются тем, кто решает их использовать: + +* **[Инструмент](tools.md)** — это действие, которое выбирает и вызывает *модель*. Именно эта страница нужна большинству в первую очередь, а её справочный спутник — **[Структурированный вывод](structured-output.md)**: всё о форме того, что возвращает инструмент. +* **[Ресурс](resources.md)** — это данные только для чтения, которые решает прочитать *приложение*. Его справочный спутник — **[Шаблоны URI](uri-templates.md)**: полный синтаксис адресации и правила безопасности путей. +* **[Промпт](prompts.md)** — это шаблон сообщения, который *человек* вызывает по имени, из меню или слэш-командой. + +Вокруг трёх примитивов — всё остальное, что объявляет сервер: + +* **[Автодополнение](completions.md)** — серверное автодополнение аргументов промптов и шаблонов ресурсов. +* **[Изображения, аудио и значки](media.md)** — всё, что инструмент может вернуть помимо текста, а также значки, которые клиент показывает рядом с вашим сервером. +* **[Обработка ошибок](handling-errors.md)** объясняет разницу между ошибкой, после которой модель может восстановиться, и ошибкой, которую она не должна увидеть никогда. + +Каждая страница здесь самодостаточна; переходите сразу к нужной. Если сервер ещё не написан, начните вместо этого с **[Первых шагов](../get-started/first-steps.md)**. + +Что происходит *внутри* регистрируемых функций (объект `Context`, внедрение зависимостей, запрос у пользователя дополнительного ввода посреди вызова) — тема следующего раздела, **[Внутри обработчика](../handlers/index.md)**. diff --git a/i18n/ru/pages/servers/media.md b/i18n/ru/pages/servers/media.md new file mode 100644 index 0000000000..3cd5cca98c --- /dev/null +++ b/i18n/ru/pages/servers/media.md @@ -0,0 +1,122 @@ +--- +translation: + sections: [496394d24d221bf1, 4ceb4591180dc6c3, 0fd63e4682d02e0c, 969ede0bd3686a16, 043f526230dd243d, 6ee3e9bcfd24047a] + tool: 1 +--- +# Медиа {#media} + +Текст — не единственное, что может вернуть инструмент. + +В SDK есть два вспомогательных класса для двоичных результатов (**`Image`** и **`Audio`**) и тип **`Icon`**, который даёт серверу, инструментам, ресурсам и промптам лицо в интерфейсе клиента. + +## Возврат изображения {#returning-an-image} + +Укажите `Image` в аннотации возвращаемого типа, передайте путь к файлу и верните результат: + +```python title="server.py" hl_lines="8 12 14" +--8<-- "docs_src/media/tutorial001.py" +``` + +* `Image` принимает ровно один из параметров: `path` (файл для чтения) или `data` (сырые байты). +* MIME-тип, который увидит клиент, определяется по расширению: `logo.png` объявляется как `image/png`. +* В логотипах нет ничего особенного. Подойдёт любой PNG рядом с `server.py`: график, который построил ваш код, диаграмма, фотография. + +`Image` — это удобство SDK, а не тип протокола. В передаваемых данных возвращаемое значение превращается в блок **`ImageContent`** (байты файла в кодировке base64 плюс MIME-тип): + +```python +result.content # [ImageContent(type="image", data="iVBORw0KGgoAAAANSUhEUg...", mime_type="image/png")] +result.structured_content # None +``` + +Обратите внимание на две вещи: + +* `data` — это base64. К байтам вы не прикасались: SDK прочитал файл и закодировал его сам. +* `structured_content` равно `None`. `Image` — это содержимое, на которое смотрит модель, а не данные, которые разбирает приложение: схемы выходных данных нет. (Сравните со страницей **[Структурированный вывод](structured-output.md)**, где аннотация возвращаемого типа и *есть* схема.) + +!!! info + `ImageContent` и `AudioContent` находятся в `mcp.types`, рядом с `TextContent`, + в который превращается обычный результат типа `str` (**[Инструменты](tools.md)**). Результат инструмента — это список блоков содержимого; `Image` и `Audio` — + самый короткий способ получить два двоичных вида. + +### Попробуйте сами {#try-it} + +Положите любой PNG рядом с `server.py`, назовите его `logo.png` и запустите: + +```console +uv run mcp dev server.py +``` + +Откройте вкладку **Tools** и вызовите `logo`. Результат — не строка, а блок содержимого `image`, и Inspector показывает вашу картинку. Всё, что произошло между файлом на диске и пикселями на экране, сделал SDK. + +## Возврат аудио {#returning-audio} + +`Audio` устроен так же. Оставьте `logo.png` на месте и положите рядом любой WAV под именем `chime.wav`: + +```python title="server.py" hl_lines="18-21" +--8<-- "docs_src/media/tutorial002.py" +``` + +Результат — блок **`AudioContent`**: + +```python +result.content # [AudioContent(type="audio", data="UklGR...", mime_type="audio/wav")] +result.structured_content # None +``` + +Всё то же самое: на входе файл на диске, на выходе base64 и MIME-тип, схемы выходных данных нет. + +## Байты или файл {#bytes-or-a-file} + +Оба класса принимают и `data=` (сырые байты) вместо `path=`. Этот режим — для байтов, у которых никогда не было собственного файла: столбец базы данных, HTTP-ответ, то, что только что нарисовал Pillow: + +```python title="server.py" hl_lines="14 15" +--8<-- "docs_src/media/tutorial003.py" +``` + +С `path=` объявлять нечего: файл читается при сборке результата, а MIME-тип определяется по расширению: + +* `Image`: `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`. +* `Audio`: `.wav`, `.mp3`, `.ogg`, `.flac`, `.aac`, `.m4a`. + +Для неизвестного расширения используется `application/octet-stream`. + +!!! check + С `data=` имени файла нет, и угадывать не по чему. Забудете `format=` — + и SDK возьмёт значение по умолчанию: `image/png` для изображений, `audio/wav` для аудио. Соберите так + `Audio` из байтов MP3 — и клиенту сообщат `mime_type="audio/wav"`, после чего + он честно не сможет это декодировать. Передаёте `data=` — передавайте и `format=`. + +## Иконки {#icons} + +`Icon` — это метаданные, а не содержимое. Изображение он не несёт: он указывает на него через URI, а клиент может загрузить его и показать рядом с именем сервера, инструментом, ресурсом или промптом. + +```python title="server.py" hl_lines="4-5 7 10 16" +--8<-- "docs_src/media/tutorial004.py" +``` + +* `src` — это URI, который клиент может разрешить: `https:` или `data:`, если нужно встроить иконку без дополнительной загрузки. +* `mime_type` и `sizes` (`"48x48"` или `"any"` для масштабируемого формата) позволяют клиенту выбрать подходящую иконку, когда вы предлагаете несколько. +* `theme="light"` или `theme="dark"` помечает иконку для одной цветовой схемы. + +Тот же именованный аргумент `icons=[...]` принимают `MCPServer(...)`, `@mcp.tool()`, `@mcp.resource()` и `@mcp.prompt()`. + +### Где их видит клиент {#where-a-client-sees-them} + +Иконки путешествуют вместе с тем, что они украшают. Иконки сервера приходят при подключении клиента, в `client.server_info` (на подключениях поколения 2026 это поле необязательное, поэтому сначала сузьте тип): + +```python +assert client.server_info is not None # python-sdk servers identify themselves by default +client.server_info.icons # [Icon(src="https://example.com/brand-kit.png", mime_type="image/png", sizes=["48x48"])] +``` + +Иконки инструмента находятся в объекте `Tool` из `tools/list`, ресурса — в `Resource` из `resources/list`, промпта — в `Prompt` из `prompts/list`. Поле всегда называется `icons`. + +## Итоги {#recap} + +* Верните `Image` или `Audio` из инструмента — и клиент получит блок `ImageContent` / `AudioContent`: ваши байты в кодировке base64 с MIME-типом. +* Собирайте их из `path=`, и тогда MIME-тип определит расширение, или из данных в памяти через `data=` с явным `format=`. +* У медиарезультатов нет ни `structured_content`, ни схемы выходных данных. +* `Icon` — это указатель: URI в `src` плюс необязательные `mime_type`, `sizes` и `theme`. +* `icons=[...]` работает на сервере, инструментах, ресурсах и промптах, а клиенты находят их в соответствующих объектах. + +Это всё, что инструмент может положить *в* результат. Что происходит, когда инструмент *завершается ошибкой* (и кто должен об этом узнать), — на странице **[Обработка ошибок](handling-errors.md)**. diff --git a/i18n/ru/pages/servers/prompts.md b/i18n/ru/pages/servers/prompts.md new file mode 100644 index 0000000000..3ee0fbec2a --- /dev/null +++ b/i18n/ru/pages/servers/prompts.md @@ -0,0 +1,156 @@ +--- +translation: + sections: [d65c098f37f5b6c3, dd0c2724d6f2877e, 6835bb3570c6714c, ffe823cb0fedd488, f33651add1b59094] + tool: 1 +--- +# Промпты {#prompts} + +**Промпт** — это шаблон сообщения, который выбирает пользователь. + +Инструменты предназначены для модели. Промпт — наоборот: пользователь выбирает его из меню в своём клиенте (слэш-команда, кнопка), заполняет аргументы, и отрендеренные сообщения попадают в диалог так, будто он набрал их сам. + +Чтобы объявить промпт, поставьте `@mcp.prompt()` над функцией, которая возвращает текст. + +## Первый промпт {#your-first-prompt} + +```python title="server.py" hl_lines="6-9" +--8<-- "docs_src/prompts/tutorial001.py" +``` + +SDK читает те же три вещи, что и у инструмента: + +* **Имя** — это имя функции: `review_code`. +* **Описание**, которое показывает клиент, — это строка документации: `Review a piece of code.` +* **Аргументы** берутся из параметров. У `code` нет значения по умолчанию, поэтому он обязательный. + +Вот что клиент получает в ответ на `prompts/list`: + +```json +{ + "name": "review_code", + "description": "Review a piece of code.", + "arguments": [ + {"name": "code", "required": true} + ] +} +``` + +Никакой JSON Schema здесь нет. Аргументы промпта — это плоский список **именованных строковых значений**: форма, которую заполняет человек, а не полезная нагрузка, которую конструирует модель. + +### Рендеринг {#rendering-it} + +Клиент рендерит шаблон через `prompts/get`, передавая аргументы. Функция выполняется, и возвращённая `str` становится **одним сообщением пользователя**: + +```json +{ + "description": "Review a piece of code.", + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "Please review this code:\n\ndef add(a, b): return a + b" + } + } + ], + "resultType": "complete" +} +``` + +Вот и вся жизнь промпта: перечислен по имени, отрендерен по запросу, отправлен в чат. + +!!! check + `required` проверяется до запуска функции. Попробуйте отрендерить `review_code` без `code` — + сам запрос завершится ошибкой JSON-RPC (код `-32603`): + + ```text + mcp.shared.exceptions.MCPError: Internal server error + ``` + + Результата с ошибкой в стиле инструмента, который можно было бы вернуть модели, нет, потому что + модели в этой цепочке нет: вызов выбрасывает исключение. Причина (`Missing required arguments: {'code'}`) + попадает в лог сервера. + +### Попробуйте сами {#try-it} + +Запустите сервер с MCP Inspector: + +```console +uv run mcp dev server.py +``` + +Откройте вкладку **Prompts** и выберите `review_code`. Inspector нарисует форму с одним обязательным полем `code`. Заполните его, отрендерите — и в ответ придёт ровно то сообщение пользователя, что показано выше. + +## Больше одного сообщения {#more-than-one-message} + +Ревью кода — это одно сообщение. Сессия отладки — это диалог, и промпт может задать его целиком. + +Верните список сообщений вместо `str`: + +```python title="server.py" hl_lines="2 13-20" +--8<-- "docs_src/prompts/tutorial002.py" +``` + +* `UserMessage` и `AssistantMessage` находятся в `mcp.server.mcpserver.prompts.base`. Передайте им `str`, и они сами обернут её в `TextContent`. Роль — это имя класса. +* `Message` — их общий базовый класс. Используйте его как аннотацию возвращаемого типа. + +Теперь `debug_error` при рендеринге даёт три сообщения по порядку: + +```json +{ + "description": "Start a debugging conversation.", + "messages": [ + {"role": "user", "content": {"type": "text", "text": "I'm seeing this error:"}}, + {"role": "user", "content": {"type": "text", "text": "TypeError: 'int' object is not iterable"}}, + { + "role": "assistant", + "content": {"type": "text", "text": "I'll help debug that. What have you tried so far?"} + } + ], + "resultType": "complete" +} +``` + +Обратите внимание на последнее. Заранее заполненная реплика `assistant` — это способ направить *следующий* ответ модели, не заставляя пользователя набирать эти указания самостоятельно. + +## Заголовки и описания аргументов {#titles-and-argument-descriptions} + +`review_code` — имя функции, а не подпись. Дайте клиенту что-нибудь получше для надписи на кнопке и опишите каждый аргумент, чтобы форма была понятна сама по себе: + +```python title="server.py" hl_lines="10-13" +--8<-- "docs_src/prompts/tutorial003.py" +``` + +* `title="Code review"` — человекочитаемое имя, ровно как `title` у инструмента. +* `Annotated[str, Field(description=...)]` — тот же приём, которым **[Инструменты](tools.md)** описывают параметры инструмента. Здесь описание попадает в аргумент, а не в схему. +* У `language` есть значение по умолчанию, поэтому он перестаёт быть обязательным. + +Запись в `prompts/list` теперь содержит всё, что нужно клиенту, чтобы нарисовать хорошую форму: + +```json +{ + "name": "review_code", + "title": "Code review", + "description": "Review a piece of code.", + "arguments": [ + {"name": "code", "description": "The code to review.", "required": true}, + {"name": "language", "description": "The language the code is written in.", "required": false} + ] +} +``` + +!!! info + Если вы читали страницу **[Инструменты](tools.md)**, то уже знаете всё, что здесь написано. Тот же декоратор, + та же строка документации в роли описания, те же `Annotated`/`Field`. Меняется только то, кто + запускает промпт (пользователь) и куда идёт результат (в диалог). + +## Итоги {#recap} + +* `@mcp.prompt()` над функцией делает её промптом. Имя — из функции, описание — из строки документации. +* Промпты **управляются пользователем**: клиент их перечисляет, пользователь выбирает один и заполняет аргументы. +* Аргументы — плоский список именованных строк (без схемы). Параметр со значением по умолчанию необязателен. +* Верните `str` — и она станет одним сообщением пользователя. Верните список `UserMessage` / `AssistantMessage`, чтобы задать многоходовой диалог. +* `title=` и `Field(description=...)` — это то, что клиент показывает в интерфейсе. +* Отсутствующий обязательный аргумент проваливает весь запрос. Отдельного результата с ошибкой у промпта нет. + +Автодополнение аргументов промпта (или шаблона ресурса) на стороне сервера — на странице **[Автодополнение](completions.md)**. diff --git a/i18n/ru/pages/servers/resources.md b/i18n/ru/pages/servers/resources.md new file mode 100644 index 0000000000..35a7eef7a3 --- /dev/null +++ b/i18n/ru/pages/servers/resources.md @@ -0,0 +1,146 @@ +--- +translation: + sections: [09df998c2a799f78, 0cf131146d16d4f9, 4e6b91e3f8025346, 8fe4eef576db17ed, 0d0d1ed43e3d0a53] + tool: 1 +--- +# Ресурсы {#resources} + +**Ресурс** — это данные, которые вы открываете приложению для чтения. + +В этом вся разница. Инструмент — это то, что решает вызвать **модель**. Ресурс — это то, что решает загрузить **приложение** (файл конфигурации, запись, документ) и передать модели в качестве контекста. + +Чтобы объявить ресурс, поставьте `@mcp.resource(uri)` над обычной функцией Python. + +## Первый ресурс {#your-first-resource} + +```python title="server.py" hl_lines="6-8" +--8<-- "docs_src/resources/tutorial001.py" +``` + +По форме это то же, что инструмент, плюс одна деталь: **URI**. К ресурсам обращаются по адресу, а не по имени. Клиент запрашивает `config://app`, а не `get_config`. + +Всё остальное SDK по-прежнему берёт из функции: + +* **Имя** — это имя функции: `get_config`. +* **Описание**, которое видит клиент, — это docstring. +* **Содержимое** — это то, что вы возвращаете. + +В ответ на `resources/list` клиент получает вот это: + +```json +{ + "name": "get_config", + "uri": "config://app", + "description": "The active shop configuration.", + "mimeType": "text/plain" +} +``` + +А когда он читает `config://app`, выполняется ваша функция, и возвращённое значение приходит обратно как текст: + +```python +result.contents # [TextResourceContents(uri="config://app", mime_type="text/plain", text="theme=dark\nlanguage=en")] +``` + +!!! tip + Перечисление ничего не стоит. Ваша функция **не** вызывается при `resources/list` — только + при `resources/read` и только для запрошенного URI. Откройте хоть тысячу ресурсов — + платить придётся лишь за те, которые кто-то откроет. + +### Попробуйте сами {#try-it} + +Запустите сервер с MCP Inspector: + +```console +uv run mcp dev server.py +``` + +Откройте URL, который он выведет, и перейдите на вкладку **Resources**. В списке есть `config://app` с описанием. Щёлкните по нему — Inspector прочитает ресурс, и вы увидите свои две строки конфигурации. + +## Шаблоны ресурсов {#resource-templates} + +По одному URI на запись — это не масштабируется. Поместите в URI **плейсхолдер**, а в функцию — соответствующий параметр: + +```python title="server.py" hl_lines="12-13" +--8<-- "docs_src/resources/tutorial002.py" +``` + +`{user_id}` в URI, `user_id: str` у функции. Вот и весь контракт. + +Теперь это **шаблон ресурса**, и он переезжает: исчезает из `resources/list` и появляется в `resources/templates/list` — уже как паттерн, а не адрес: + +```json +{ + "name": "get_user_profile", + "uriTemplate": "users://{user_id}/profile", + "description": "A customer's profile.", + "mimeType": "text/plain" +} +``` + +Клиент подставляет значение вместо плейсхолдера и читает конкретный URI: `users://42/profile`, `users://ada/profile`. На все эти запросы отвечает одна функция, а совпавшее значение передаётся ей как `user_id`: + +```python +result.contents # [TextResourceContents(uri="users://42/profile", text="User 42: 12 orders since 2021.")] +``` + +Обратите внимание на `uri` в результате. Это **конкретный** URI, который запросил клиент, а не шаблон. + +!!! check + Плейсхолдеры и параметры должны совпадать. Переименуйте параметр функции в + `user`, оставив в URI `{user_id}`, и декоратор откажется работать **уже при импорте**, + задолго до того, как к серверу подключится клиент: + + ```text + ValueError: Mismatch between URI parameters {'user_id'} and function parameters {'user'} + ``` + + Такое несовпадение может быть только ошибкой, поэтому SDK не даёт запустить с ним сервер. + +Синтаксис плейсхолдеров описан в [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570): `{+path}` для значений из нескольких сегментов, `{?q,lang}` для необязательных параметров запроса и многое другое. Кроме того, SDK по умолчанию проверяет извлечённые значения на безопасность путей. Полный справочник — на странице **[Шаблоны URI и безопасность путей](uri-templates.md)**. + +`get_user_profile` может также принимать параметр с аннотацией `Context`. SDK внедряет его, никогда не считая параметром URI, а о том, что он даёт, рассказывает страница **[Объект Context](../handlers/context.md)**. + +## Что возвращать {#what-you-return} + +Вы не ограничены `str`. Задайте каждому ресурсу `mime_type` и возвращайте то, что подходит: + +```python title="server.py" hl_lines="8-9 14-15 20-21" +--8<-- "docs_src/resources/tutorial003.py" +``` + +* `readme` возвращает `str`, поэтому строка отправляется как есть. Это типичный случай. +* `catalog_stats` возвращает `dict`, и SDK сериализует его в **текст JSON** за вас: + + ```json + { + "books": 1204, + "authors": 391 + } + ``` + +* `placeholder_cover` возвращает `bytes`, поэтому клиент получает `BlobResourceContents` вместо `TextResourceContents` — с вашими байтами в поле `blob`, закодированными в base64. + +То же правило действует для всего остального, что сериализуется в JSON: список, модель Pydantic, dataclass. Если это не `str` и не `bytes`, оно превращается в JSON. + +`mime_type` объявляете вы сами; по умолчанию это `text/plain`. SDK никогда не анализирует возвращаемое значение, чтобы угадать тип, поэтому ресурс с `dict`, который вы не пометили, по-прежнему объявляется как обычный текст. + +!!! tip + `@mcp.resource()` принимает также `name=`, `title=` и `description=`, если вы не хотите + выводить их из функции. А когда функцию писать вообще не нужно, в + `mcp.server.mcpserver.resources` есть готовые классы `Resource` (`TextResource`, + `BinaryResource`, `FileResource`, `HttpResource`, `DirectoryResource`), которые регистрируются + через `mcp.add_resource(...)`. + +Клиент может также **подписаться** на ресурс и получать уведомления о его изменениях; это клиентская половина истории, и она описана на странице **[Клиент](../client/index.md)**. + +## Итоги {#recap} + +* `@mcp.resource(uri)` над функцией делает её ресурсом. URI — это адрес, возвращаемое значение — содержимое, docstring — описание. +* `{placeholder}` в URI превращает его в **шаблон**: он перечисляется в `resources/templates/list`, и одна функция обслуживает все подходящие URI. +* Имена плейсхолдеров должны совпадать с именами параметров функции. Ошибётесь — узнаете об этом при импорте, а не в продакшене. +* Ваша функция выполняется, когда ресурс **читают**, а не когда его перечисляют. +* `str` становится текстом, `bytes` — blob-объектом в base64, всё остальное — текстом JSON. Пометить тип помогает `mime_type=`. +* Инструменты нужны модели, чтобы действовать. Ресурсы нужны приложению, чтобы читать. + +Третий примитив, тот, что человек выбирает из меню, — это **[Промпты](prompts.md)**. diff --git a/i18n/ru/pages/servers/structured-output.md b/i18n/ru/pages/servers/structured-output.md new file mode 100644 index 0000000000..043fca2e90 --- /dev/null +++ b/i18n/ru/pages/servers/structured-output.md @@ -0,0 +1,250 @@ +--- +translation: + sections: [a838d57f003aed44, 857d03886a0137ed, 42d9efcb9f542867, 2290ff08435b5573, e866c192e11d1c14, 6cdbad079f7b47f0, d4b607372fb28b51, 18dbf726ac45e0b7, c6f7d2a148aa49f4, c851964bb3301907, d715db6f8dccc9cc, ef86634aa70498a7] + tool: 1 +--- +# Структурированный вывод {#structured-output} + +Инструмент, возвращающий обычную строку `str`, выдаёт результат дважды: как текст в `content` и как `{"result": "..."}` в `structured_content`. + +Эта страница посвящена второму каналу: откуда он берётся, какие формы может принимать и как SDK следит за его корректностью. + +Если коротко: **аннотация возвращаемого типа и есть выходная схема**. Вы её уже написали. + +## Выходная схема {#the-output-schema} + +```python title="server.py" hl_lines="9" +--8<-- "docs_src/structured_output/tutorial001.py" +``` + +Важна строка с сигнатурой: `-> int`. + +Благодаря ей инструмент, который SDK отправляет в ответ на `tools/list`, несёт `output_schema` рядом с входной схемой, построенной по параметрам (о ней — на странице **[Инструменты](tools.md)**): + +```json +{ + "properties": { + "result": {"title": "Result", "type": "integer"} + }, + "required": ["result"], + "title": "get_temperatureOutput", + "type": "object" +} +``` + +Голое значение `int` — не JSON-объект, поэтому SDK **оборачивает** его в `{"result": ...}`. Вызовите инструмент — и оба канала заполнены: + +```python +result.content # [TextContent(text="17")] +result.structured_content # {"result": 17} +``` + +Ту же обёртку получает любой скаляр: `str`, `int`, `float`, `bool`, `bytes`, `None`. + +## Два канала {#two-channels} + +Зачем отправлять одно и то же значение дважды? + +* `content` — для **модели**. Языковая модель читает текст; это единственная часть результата, которую она видит. +* `structured_content` — для **приложения**, внутри которого работает модель: для кода, которому нужно `17`, а не предложение, содержащее «17». +* `output_schema` — контракт между ними, опубликованный ещё до первого вызова инструмента. + +Вы возвращаете одно значение Python. SDK заполняет все три. + +## Возврат модели {#return-a-model} + +Объявите форму как Pydantic `BaseModel` и верните экземпляр: + +```python title="server.py" hl_lines="8-11 15" +--8<-- "docs_src/structured_output/tutorial002.py" +``` + +Теперь схема — **это** `WeatherData`. Ни обёртки, ни ключа `result`: + +```json +{ + "properties": { + "temperature": {"description": "Degrees Celsius.", "title": "Temperature", "type": "number"}, + "humidity": {"description": "Relative humidity, 0 to 1.", "title": "Humidity", "type": "number"}, + "conditions": {"title": "Conditions", "type": "string"} + }, + "required": ["temperature", "humidity", "conditions"], + "title": "WeatherData", + "type": "object" +} +``` + +`structured_content` — это сам объект, поле за полем: + +```python +result.structured_content # {"temperature": 16.2, "humidity": 0.83, "conditions": "Overcast"} +``` + +И модель не остаётся в стороне. SDK сериализует тот же объект в JSON-текст для `content`: + +```json +{ + "temperature": 16.2, + "humidity": 0.83, + "conditions": "Overcast" +} +``` + +Обратите внимание: `Field(description=...)` у `temperature` и `humidity` попали в схему. Тот же `Field`, который описывал **входы**, описывает и выходы. + +!!! info + Если вы пользовались `response_model` в FastAPI, вам это уже знакомо: модель Pydantic как объявленный + ответ, который за вас сериализуется и документируется. Единственное отличие — здесь всё объявление + сводится к аннотации возвращаемого типа. + +## `TypedDict` {#a-typeddict} + +Не каждая форма заслуживает класса. `TypedDict` даёт ту же схему: + +```python title="server.py" hl_lines="8" +--8<-- "docs_src/structured_output/tutorial003.py" +``` + +Во время выполнения `TypedDict` — обычный `dict`, его вы и собираете и возвращаете. Схема, валидация и `structured_content` идентичны варианту с `BaseModel` (за вычетом описаний, которые в `TypedDict` разместить негде). + +## Dataclass {#a-dataclass} + +Dataclass тоже подходят, как и любой обычный класс, атрибуты которого снабжены аннотациями типов. SDK незаметно строит модель Pydantic по этим аннотациям. + +```python title="server.py" hl_lines="8-9" +--8<-- "docs_src/structured_output/tutorial004.py" +``` + +Три способа записи — одна схема. Используйте тот, что уже принят в вашей кодовой базе. + +## Списки {#lists} + +`list[...]` тоже не JSON-объект, поэтому получает обёртку `{"result": ...}`, а тип элемента попадает внутрь как ссылка `$defs`: + +```python title="server.py" hl_lines="15" +--8<-- "docs_src/structured_output/tutorial005.py" +``` + +```json +{ + "$defs": { + "WeatherData": { + "properties": { + "temperature": {"title": "Temperature", "type": "number"}, + "humidity": {"title": "Humidity", "type": "number"}, + "conditions": {"title": "Conditions", "type": "string"} + }, + "required": ["temperature", "humidity", "conditions"], + "title": "WeatherData", + "type": "object" + } + }, + "properties": { + "result": {"items": {"$ref": "#/$defs/WeatherData"}, "title": "Result", "type": "array"} + }, + "required": ["result"], + "title": "get_forecastOutput", + "type": "object" +} +``` + +Запросите прогноз на два дня — и `structured_content` будет `{"result": [{...}, {...}]}`. `content` превращается в **два** блока `TextContent`, по одному на элемент: для модели список раскладывается поэлементно, а не сваливается в одну строку. + +`tuple[...]`, объединения и `Optional[...]` оборачиваются так же. + +## Словари {#dictionaries} + +`dict[str, ...]` — единственный дженерик, который уже *является* JSON-объектом, поэтому он не оборачивается: + +```python title="server.py" hl_lines="9" +--8<-- "docs_src/structured_output/tutorial006.py" +``` + +```json +{ + "additionalProperties": {"type": "number"}, + "title": "get_temperaturesDictOutput", + "type": "object" +} +``` + +```python +result.structured_content # {"London": 16.2, "Reykjavik": 4.4} +``` + +Ключи должны быть `str`. `dict[int, float]` не может быть JSON-объектом, поэтому для него применяется запасной вариант — обёртка `{"result": ...}`. + +## Валидация {#validation} + +`output_schema` — не документация. Всё, что возвращает функция, **проверяется на соответствие схеме** до того, как покинет сервер. + +Пока значение собирается вручную, этого не замечаешь: Pydantic уже позаботился о том, чтобы `WeatherData` был `WeatherData`. Заметно становится в тот день, когда данные приходят из источника, который вы не контролируете: + +```python title="server.py" hl_lines="9 21" +--8<-- "docs_src/structured_output/tutorial007.py" +``` + +Аннотация обещает `WeatherData`. Ответ вышестоящего сервиса перестал присылать `humidity`. + +!!! check + Вызовите `get_weather` — и он не передаст клиенту молча полупустой объект. Вызов завершается ошибкой, + и первые же строки ошибки называют поле: + + ```text + Error executing tool get_weather: 1 validation error for WeatherData + humidity + Field required [type=missing, input_value={'temperature': 16.2, 'conditions': 'Overcast'}, input_type=dict] + ``` + + Этот текст возвращается как результат инструмента с `is_error=True`, так что модель знает, что вызов + не удался, а не уверенно читает погоду, которой нет. + +Кстати, вернуть обычный `dict` из инструмента с `-> WeatherData` вполне допустимо. Именно это и выдал `json.loads`. Проверяется значение, а не тип Python. + +## Отказ от структурированного вывода {#opting-out} + +Иногда аннотация возвращаемого типа нужна для проверки типов, а не для протокола. Передайте `structured_output=False` — и инструмент станет чисто текстовым: + +```python title="server.py" hl_lines="6" +--8<-- "docs_src/structured_output/tutorial008.py" +``` + +Ни `output_schema`, ни обёртки, ни валидации. `structured_content` равен `None`, а `content` — строка, которую вы вернули. + +Обратный вариант, `structured_output=True`, превращает автоматическое определение в требование: инструмент, по возвращаемому типу которого нельзя построить схему, выбрасывает исключение при импорте, а не переключается на текст. + +## Класс без аннотаций типов {#a-class-without-type-hints} + +Есть один способ оказаться без структурированного вывода, не прося об этом: вернуть класс, в **теле которого нет аннотаций**. + +```python title="server.py" hl_lines="6-9" +--8<-- "docs_src/structured_output/tutorial009.py" +``` + +`Station` задаёт `name` и `online` внутри `__init__`, но сам *класс* ничего не объявляет. SDK читает аннотации класса, не находит ни одной и сдаётся. + +!!! warning + Сдаётся он **молча**. `output_schema` равен `None`, `structured_content` равен `None`, а текст, + который читает модель, — это `repr` объекта: + + ```text + "" + ``` + + Ни ошибки, ни предупреждения — бесполезный инструмент. Перенесите аннотации в тело класса или передайте + `structured_output=True`, что превратит это в жёсткую ошибку в момент импорта модуля: + `Function get_station: return type is not serializable for structured output`. + +!!! tip + Нужен полный контроль (собирать `CallToolResult` самостоятельно или прикреплять `_meta`, которые + видит приложение, но не модель)? Это **[Низкоуровневый Server](../advanced/low-level-server.md)**. + +## Итоги {#recap} + +* **Аннотация возвращаемого типа** — это выходная схема. Она публикуется в `tools/list` как `output_schema`. +* Скаляры, списки, кортежи и объединения оборачиваются в `{"result": ...}`. Модели, `TypedDict`, dataclass, классы с аннотациями и `dict[str, ...]` уже являются объектами и остаются как есть. +* Каждый результат несёт `content` (текст, для модели) **и** `structured_content` (данные, для приложения). +* Возвращаемое значение проверяется на соответствие схеме. Несоответствие — это ошибка инструмента, а не испорченный результат. +* `structured_output=False` отключает структурированный вывод для инструмента. Класс без аннотаций типов отключает его молча — следите за этим. + +Теперь вы владеете всем, что инструмент может сказать в ответ. Дальше — второй примитив: **[Ресурсы](resources.md)**. diff --git a/i18n/ru/pages/servers/tools.md b/i18n/ru/pages/servers/tools.md new file mode 100644 index 0000000000..83266ea0c4 --- /dev/null +++ b/i18n/ru/pages/servers/tools.md @@ -0,0 +1,177 @@ +--- +translation: + sections: [e4cc390d56573409, 8566e2b68594e9ad, 2c97b9f888398951, 048e5471dfa71aea, 3076b1e16ad95950, edbedf2a16e71311, 3d8ef8da89fa87c1, f6c0e02e6ea5a363] + tool: 1 +--- +# Инструменты {#tools} + +**Инструмент** — это функция, которую может вызвать модель. + +Чтобы объявить инструмент, достаточно повесить `@mcp.tool()` на обычную функцию Python. Вот и весь API. + +## Ваш первый инструмент {#your-first-tool} + +```python title="server.py" hl_lines="6-8" +--8<-- "docs_src/tools/tutorial001.py" +``` + +Посмотрите, что получилось. Ни схем, ни JSON, ни протокола — просто функция. SDK извлекает из неё три вещи: + +* **Имя** инструмента — это имя функции: `search_books`. +* **Описание**, которое видит модель, — это строка документации: `Search the catalog by title or author.` +* **Аргументы**, которые модели разрешено передавать, берутся из аннотаций типов: `query: str` и `limit: int`. + +### Входная схема {#the-input-schema} + +По этим аннотациям типов SDK генерирует JSON Schema и отправляет её клиенту в ответе на `tools/list`: + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"title": "Limit", "type": "integer"} + }, + "required": ["query", "limit"], + "title": "search_booksArguments" +} +``` + +Оба аргумента попали в `required`, потому что ни у одного нет значения по умолчанию. Сейчас это исправим. (Ключи `title` — артефакты Pydantic; контракт составляют свойства, их типы и `required`.) + +!!! tip + Аннотации типов здесь не документация. Это и есть **контракт**. Если клиент пришлёт `"limit": "ten"`, + SDK отклонит вызов ещё до того, как запустится функция. + +### Что получает модель в ответ {#what-the-model-gets-back} + +Вызовите инструмент с `{"query": "dune", "limit": 5}` — результат состоит из двух частей: + +```python +result.content # [TextContent(text="Found 3 books matching 'dune' (showing up to 5).")] +result.structured_content # {'result': "Found 3 books matching 'dune' (showing up to 5)."} +``` + +`content` — это текст, который читает **модель**. `structured_content` — типизированные данные для **клиентского приложения**. Они появились потому, что тип возвращаемого значения объявлен как `-> str`. + +Пока не думайте о `structured_content`. Возвращайте из инструментов настоящие объекты Python, и всё сработает как надо; этому целиком посвящена страница **[Структурированный вывод](structured-output.md)**. + +### Попробуйте сами {#try-it} + +Запустите сервер через MCP Inspector: + +```console +uv run mcp dev server.py +``` + +Откройте URL, который он напечатает, перейдите на вкладку **Tools** и вызовите `search_books`. + +Inspector отрисует форму с обязательным текстовым полем `query` и обязательным числовым полем `limit`. Эту форму он построил по аннотациям типов. Так же поступит любой другой MCP-клиент. + +## Необязательные аргументы {#optional-arguments} + +Дайте параметру значение по умолчанию, и он перестанет быть обязательным. Вот и всё. Это обычный Python. + +```python title="server.py" hl_lines="7" +--8<-- "docs_src/tools/tutorial002.py" +``` + +Схема меняется соответственно: + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"default": 10, "title": "Limit", "type": "integer"} + }, + "required": ["query"], + "title": "search_booksArguments" +} +``` + +`limit` ушёл из `required` и получил `"default": 10`. Клиент, который его не укажет, получит `10` — ровно так же, как в Python. + +## Более подробные схемы с `Field` {#richer-schemas-with-field} + +Аннотации типов дают очень многое, но иногда аргумент хочется *описать* или ограничить. + +Оберните тип в `Annotated` и добавьте `Field` из Pydantic: + +```python title="server.py" hl_lines="12-14" +--8<-- "docs_src/tools/tutorial003.py" +``` + +Три нововведения, и все на параметрах: + +* `Field(description=...)`: описание отдельного аргумента, которое модель читает вместе со строкой документации. +* `Field(ge=1, le=50)`: числовые границы. В схему они попадают как `"minimum": 1, "maximum": 50`. +* `Literal["fiction", "non-fiction", "poetry"]`: перечисление. Модель может выбрать только одно из этих значений. + +!!! check + Ограничения — не украшение. Вызовите инструмент с `limit=999`, и SDK ответит + ошибкой инструмента **ещё до запуска функции**: + + ```text + Input should be less than or equal to 50 + ``` + + Эта ошибка возвращается модели как результат инструмента, модель её читает и повторяет вызов + с допустимым значением. Вы один раз написали `le=50` и бесплатно получили самокорректирующихся агентов. + +!!! info + Если вы работали с FastAPI или Pydantic, всё это вам уже знакомо. Это тот же `Field`, + тот же `Annotated`, та же валидация. Ничего специфичного для MCP здесь учить не нужно. + +## Модель в качестве параметра {#a-model-as-a-parameter} + +Когда инструмент принимает больше пары аргументов, сгруппируйте их в модель Pydantic: + +```python title="server.py" hl_lines="8-11 15" +--8<-- "docs_src/tools/tutorial004.py" +``` + +Схема `Book` вкладывается во входную схему инструмента (как ссылка в `$defs`), модель заполняет её как JSON-объект, а функция получает **настоящий экземпляр `Book`**, уже проверенный, с атрибутами `.title`, `.author` и `.year`. + +Можно сочетать как угодно: обычные параметры рядом с параметрами-моделями, вложенные модели, списки моделей. Везде один и тот же Pydantic. + +## `async def` {#async-def} + +Если инструмент занимается вводом-выводом (вызывает API, читает файл, обращается к базе данных), объявите его через `async def` и используйте `await` внутри. SDK дождётся его выполнения. + +Инструмент с обычным `def` тоже работает: SDK запускает его в отдельном потоке, так что сервер он не блокирует. + +Больше ничего настраивать не нужно. + +## Имена, заголовки и аннотации {#names-titles-and-annotations} + +Всё, что SDK выводит сам, можно переопределить в декораторе: + +```python title="server.py" hl_lines="7-10" +--8<-- "docs_src/tools/tutorial005.py" +``` + +* `title` — понятное человеку имя для интерфейсов. Клиенты покажут *«Search the catalog»* вместо `search_books`. +* `annotations` — поведенческие **подсказки** для клиента: + * `read_only_hint=True`: этот инструмент ничего не меняет. + * `open_world_hint=False`: он работает с закрытым набором объектов (этим каталогом), а не с открытым интернетом. + * Две другие, `destructive_hint` и `idempotent_hint`, описывают инструмент, который *пишет*: может ли он + что-то удалить и равносилен ли двойной вызов одному? Спецификация определяет обе + только для инструментов не только для чтения, так что на `search_books` они ничего бы не значили. + +Добросовестный клиент использует их, чтобы решать вопросы вроде *«нужно ли спросить пользователя, прежде чем это запускать?»*. Это подсказки, а не средство безопасности. Никогда не полагайтесь на то, что клиент будет их соблюдать. + +!!! tip + `@mcp.tool()` также принимает `name=` и `description=`, если не хочется выводить их + из имени функции и строки документации. Чаще всего хочется. + +## Итоги {#recap} + +* `@mcp.tool()` на функции делает её инструментом. Имя — от функции, описание — из строки документации. +* Аннотации типов **и есть** входная схема. Значения по умолчанию делают аргументы необязательными. +* `Annotated[..., Field(...)]` добавляет описания и ограничения; `Literal` добавляет перечисления. +* Параметр — модель Pydantic — это способ принять структурированное «тело». +* Неправильные аргументы отклоняются за вас, с ошибкой, которую модель может прочитать и исправиться. +* `async def` для ввода-вывода, обычный `def` для всего остального. + +О том, что происходит со значением, которое вы возвращаете через `return`, — на странице **[Структурированный вывод](structured-output.md)**. diff --git a/i18n/ru/pages/servers/uri-templates.md b/i18n/ru/pages/servers/uri-templates.md new file mode 100644 index 0000000000..1e19a5ad80 --- /dev/null +++ b/i18n/ru/pages/servers/uri-templates.md @@ -0,0 +1,285 @@ +--- +translation: + sections: [4a7033e1ed8ad602, 55dcbfff0c6271bf, 101ef9d14bf4ec46, 4b6c4a845438abc7, f98b46bafbee4acd] + tool: 1 +--- +# Шаблоны URI и безопасность путей {#uri-templates-and-path-safety} + +Это справочник по синтаксису шаблонов URI, который принимает +[`@mcp.resource`](resources.md), и по политике безопасности путей, +которую SDK применяет к извлечённым значениям. Чтобы разобраться, +что такое ресурсы и когда их использовать, начните со страницы +**[Ресурсы](resources.md)**; здесь предполагается, что вы уже уверенно объявляете +ресурсы и хотите получить полный набор операторов, настройки безопасности +или низкоуровневую реализацию. + +Синтаксис шаблонов — это [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570). +SDK поддерживает подмножество, подобранное для сопоставления входящих URI +в `resources/read`, плюс слой безопасности, который отклоняет значения, +ведущие за пределы каталога, который вы собираетесь отдавать. Подробности +уровня протокола (форматы сообщений, жизненный цикл, пагинация) описаны в +[спецификации ресурсов MCP](https://modelcontextprotocol.io/specification/latest/server/resources). + +## Полный набор операторов {#the-full-operator-set} + +Простой заполнитель `{user_id}` — тот, что представлен на странице **[Ресурсы](resources.md)**. Есть ещё +четыре формы операторов; вот они на одном сервере, чтобы их можно было +сравнить: + +```python title="server.py" hl_lines="16-17 22-23 28-29 34-35 40-41" +--8<-- "docs_src/uri_templates/tutorial001.py" +``` + +Каждый выделенный декоратор по-своему разбирает URI. +Разделы ниже разбирают их сверху вниз. + +### Простое раскрытие: `{name}` {#simple-expansion-name} + +`books://{isbn}` — обычная, повседневная форма. Заполнитель отображается +на параметр `isbn`, поэтому клиент, читающий `books://978-0441172719`, +вызывает `get_book("978-0441172719")`. + +Простой `{name}` останавливается на первом `/`. `books://978/extra` не +совпадает: слэш после `978` завершает захват, а `/extra` остаётся +лишним. + +### Преобразование типов {#type-conversion} + +Извлечённые значения приходят строками, но можно объявить более +конкретный тип, и SDK выполнит преобразование. `orders://{order_id}` +попадает в функцию с параметром `order_id: int`, поэтому чтение +`orders://12345` вызывает `get_order(12345)`, а не `get_order("12345")`. +Обработчик выполняет с ним арифметику (`order_id + 1`) без приведения типа. + +### Многосегментные пути: `{+name}` {#multi-segment-paths-name} + +Чтобы захватить значение со слэшами, используйте `{+name}`. Для +`manuals://{+path}`: + +* `manuals://returns.md` даёт `path = "returns.md"` +* `manuals://printing/setup.md` даёт `path = "printing/setup.md"` + +Используйте `{+name}` всякий раз, когда значение иерархическое: пути в +файловой системе, вложенные ключи объектов, проксируемые пути URL. + +### Параметры запроса: `{?a,b,c}` {#query-parameters-abc} + +`reviews://{isbn}{?limit,sort}` помещает `limit` и `sort` после `?`. +Путь определяет, *какую* книгу читать; параметры запроса настраивают, +*как* её читать. + +Параметры запроса сопоставляются нестрого: порядок не важен, лишние +игнорируются, а пропущенные берутся из значений по умолчанию вашей +функции. Так `reviews://978-0441172719` использует `limit=10, sort="newest"`, +а `reviews://978-0441172719?sort=top` переопределяет только `sort`. + +### Сегменты пути списком: `{/name*}` {#path-segments-as-a-list-name} + +Если нужен каждый сегмент пути отдельным элементом списка, а не одной +строкой со слэшами, используйте `{/name*}`. Для `shelves://browse{/path*}` +клиент, читающий `shelves://browse/fiction/sci-fi`, вызывает +`browse_shelf(["fiction", "sci-fi"])`. + +### Справочник по шаблонам {#template-reference} + +Самые частые варианты: + +| Шаблон | Пример ввода | Результат | +|--------------|-----------------------|-------------------------| +| `{name}` | `alice` | `"alice"` | +| `{name}` | `docs/intro.md` | *нет совпадения* (останавливается на `/`) | +| `{+path}` | `docs/intro.md` | `"docs/intro.md"` | +| `{.ext}` | `.json` | `"json"` | +| `{/segment}` | `/v2` | `"v2"` | +| `{?key}` | `?key=value` | `"value"` | +| `{?a,b}` | `?a=1&b=2` | `"1"`, `"2"` | +| `{/path*}` | `/a/b/c` | `["a", "b", "c"]` | + +### Что отклоняет парсер {#what-the-parser-rejects} + +Некоторые формы шаблонов отлавливаются заранее, а не падают на первом +запросе. `@mcp.resource` разбирает шаблон при выполнении декоратора, +поэтому ни одна из них не доходит до работающего сервера. + +`UriTemplate.parse()` выбрасывает `InvalidUriTemplate` в таких случаях: + +* **Две переменные без разделителя между ними.** `manuals://{+path}{ext}` + отклоняется: при сопоставлении невозможно понять, где кончается `path` + и начинается `ext`. Поставьте между ними литерал + (`manuals://{+path}/{ext}`) или используйте оператор, который сам даёт + разделитель. `manuals://{+path}{.ext}` принимается, потому что `{.ext}` + сам вносит `.`. +* **Больше одной многосегментной переменной.** В шаблоне допускается не + более одной из `{+var}`, `{#var}` или раскрываемой переменной + (`{/var*}`, `{.var*}`, `{;var*}`). Две такие переменные неоднозначны + по своей природе: нет обоснованного способа решить, какая из них + заберёт лишний сегмент. +* **Обычные синтаксические ошибки**: незакрытая фигурная скобка, дважды + использованное имя переменной или возможность RFC 6570, которую SDK не + поддерживает, например модификатор префикса `{var:3}` или раскрытие в + запросе `{?vars*}`. + +Кроме того, `@mcp.resource` выбрасывает `ValueError`, если параметр +обработчика привязан к переменной запроса в завершающей группе +`{?...}`/`{&...}` шаблона, но не имеет значения по умолчанию в Python. +Эти переменные сопоставляются нестрого (клиент может опустить любую из +них), поэтому параметр без значения по умолчанию проявился бы лишь как +непонятная внутренняя ошибка на первом запросе, где он опущен. +`reviews://{isbn}{?limit,sort}` на сервере выше — корректный вариант: +и `limit`, и `sort` имеют значения по умолчанию. + +## Безопасность {#security} + +Параметры шаблона приходят от клиента. Если они без проверки попадают в +операции с файловой системой или базой данных, значения вроде +`../../etc/passwd` могут вести за пределы каталога, который вы собирались +отдавать. + +### Что SDK проверяет по умолчанию {#what-the-sdk-checks-by-default} + +Прежде чем запустить ваш обработчик, SDK отклоняет любой параметр, который: + +* выходит из начального каталога через компоненты `..` +* выглядит как абсолютный путь (`/etc/passwd`, `C:\Windows`) или путь + относительно диска в Windows (`C:foo`). Значение относительно диска и + идентификатор с пространством имён вроде `x:y` неразличимы как строки, + поэтому любое значение вида «одна буква плюс двоеточие» по умолчанию + отклоняется; исключите параметр из проверки, если он законно получает + такие значения +* содержит нулевой байт (`\x00`) + +Проверка на `..` работает покомпонентно, а не как поиск подстроки. +Значения вроде `v1.0..v2.0` или `HEAD~3..HEAD` проходят, потому что `..` +там не отдельный сегмент пути. + +Эти проверки применяются к декодированному значению, поэтому ловят +обход каталогов независимо от того, как он закодирован в URI (`../etc`, +`..%2Fetc`, `%2E%2E/etc`, `..%5Cetc`, `%00` — всё отлавливается). + +!!! check + Прочитайте `manuals://../etc/passwd` с сервера выше, и запрос будет + отклонён сразу: сопоставление шаблонов останавливается на первой + неудаче, поэтому никакой последующий (возможно, более мягкий) шаблон + не пробуется как запасной. Клиент видит ту же ошибку `-32602` + «Unknown resource», что и для URI, не совпадающего ни с одним + шаблоном, а `read_manual` так и не запускается. + +### Обработчики файловой системы: используйте safe_join {#filesystem-handlers-use-safe_join} + +Встроенные проверки отсекают типичные случаи, но не знают границ вашей +песочницы. Для доступа к файловой системе используйте `safe_join`, чтобы +разрешить путь и убедиться, что он остаётся внутри базового каталога: + +```python title="server.py" hl_lines="4 14" +--8<-- "docs_src/uri_templates/tutorial002.py" +``` + +`safe_join` ловит выход через символические ссылки, последовательности +`..` и трюки с абсолютными путями, которые простая строковая проверка +пропустила бы. Если разрешённый путь выходит за `DOCS_ROOT`, функция +выбрасывает `PathEscapeError`, которое доходит до клиента как +`ResourceError`. + +### Когда настройки по умолчанию мешают {#when-the-defaults-get-in-the-way} + +Иногда проверки блокируют законные значения. Инструмент импорта каталога +может намеренно получать абсолютный путь, или параметр может быть +относительной ссылкой вроде `../sibling`, которую обработчик безопасно +интерпретирует, не обращаясь к файловой системе. Исключите этот параметр +из проверки или ослабьте политику для всего сервера: + +```python title="server.py" hl_lines="9 16-19" +--8<-- "docs_src/uri_templates/tutorial003.py" +``` + +* `security=ResourceSecurity(exempt_params={"source"})` в декораторе + отключает проверки для одного этого параметра на одном этом ресурсе. + Остальной сервер сохраняет политику по умолчанию. +* `resource_security=` в конструкторе `MCPServer` задаёт значение по + умолчанию для каждого ресурса. Здесь `relaxed` полностью отключает + проверку на `..`. + +Настраиваемые проверки: + +| Параметр | По умолчанию | Что делает | +|-------------------------|---------|-------------------------------------| +| `reject_path_traversal` | `True` | Отклоняет последовательности `..`, выходящие из начального каталога | +| `reject_absolute_paths` | `True` | Отклоняет `/foo`, `C:\foo`, UNC-пути и относительные к диску `C:foo` (также ловит `x:y`) | +| `reject_null_bytes` | `True` | Отклоняет значения, содержащие `\x00` | +| `exempt_params` | пусто | Имена параметров, для которых проверки пропускаются | + +Эти проверки — эвристический предварительный фильтр; для доступа к +файловой системе границей изоляции остаётся `safe_join`. + +!!! tip + Если обработчик не может выполнить запрос (файла нет, идентификатор + неизвестен), выбросьте исключение. SDK превратит его в ответ с + ошибкой. О разнице между ошибкой протокола и ошибкой инструмента + см. **[Обработка ошибок](handling-errors.md)**. + +## Ресурсы на низкоуровневом Server {#resources-on-the-low-level-server} + +Если вы строите на низкоуровневом классе `Server` (см. **[Низкоуровневый +Server](../advanced/low-level-server.md)**), обработчики для методов протокола `resources/list` и +`resources/read` регистрируются напрямую. Декоратора нет; протокольные +типы возвращаются вручную. + +### Статические ресурсы {#static-resources} + +Для фиксированных URI ведите реестр и диспетчеризуйте по точному совпадению: + +```python title="server.py" hl_lines="17 21 27" +--8<-- "docs_src/uri_templates/tutorial004.py" +``` + +Обработчик списка сообщает клиентам, что доступно; обработчик чтения +отдаёт содержимое. Сначала проверьте реестр, затем перейдите к шаблонам +(ниже), если они есть, а для всего остального выбрасывайте исключение. + +### Шаблоны {#templates} + +Движок шаблонов, который использует `MCPServer`, находится в +`mcp.shared.uri_template` и работает сам по себе. Разбор и сопоставление +те же; маршрутизацию и политику безопасности вы подключаете сами. + +```python title="server.py" hl_lines="13-16 22-25 29 33 45" +--8<-- "docs_src/uri_templates/tutorial005.py" +``` + +В выделенных строках происходят три вещи: + +* **Разбор один раз, сопоставление на каждый запрос.** `UriTemplate.parse()` + строит шаблон; `template.match(uri)` возвращает извлечённые переменные + как `dict` или `None`, если URI не подходит. Декодирование URL + происходит внутри `match()`; декодированные значения возвращаются как + есть, без проверки безопасности путей. Значения приходят строками: + преобразуйте их сами (`int(matched["id"])`, `Path(matched["path"])`). +* **Проверки безопасности применяйте сами.** Проверки на `..` и + абсолютные пути, которые `MCPServer` выполняет по умолчанию, находятся в + `mcp.shared.path_security`. `read_manual_safely` вызывает их перед + обращением к `MANUALS`. Если параметр не является путём в файловой + системе (ISBN, поисковый запрос), пропустите проверки для этого + значения: политикой вы управляете в каждом обработчике, а не через + объект конфигурации. +* **Список шаблонов из того же источника.** Клиенты обнаруживают шаблоны + через `resources/templates/list`. `str(template)` возвращает исходную + строку шаблона, поэтому у списка и у механизма сопоставления один + источник истины. + +## Итоги {#recap} + +* `{name}` совпадает с одним сегментом; `{+name}` сохраняет слэши; `{?a,b}` + берёт значения из строки запроса; `{/name*}` разбивает сегменты в список. +* Две переменные без разделителя между ними или вторая многосегментная + переменная отклоняются на этапе разбора. Параметр, привязанный к + завершающей переменной запроса `{?...}`/`{&...}`, должен объявлять + значение по умолчанию в Python. +* Аннотируйте параметр (`order_id: int`), и SDK выполнит преобразование. +* Политика безопасности по умолчанию отклоняет `..`, абсолютные пути и + нулевые байты до запуска обработчика; переопределите её для отдельного + ресурса через `security=ResourceSecurity(...)` или для всего сервера + через `resource_security=`. +* Для доступа к файловой системе границей изоляции служит `safe_join`. +* На низкоуровневом `Server` разбирайте с помощью `UriTemplate.parse()`, + сопоставляйте через `.match()` и применяйте `mcp.shared.path_security` + сами. diff --git a/i18n/ru/pages/translations.md b/i18n/ru/pages/translations.md new file mode 100644 index 0000000000..04f5654030 --- /dev/null +++ b/i18n/ru/pages/translations.md @@ -0,0 +1,30 @@ +--- +translation: + sections: [f671b445b16e4f99, 3983a560eb2cece7, 004b3ee918529d8c, c6e2debf1da06eb7, 81d412ed5f399f94] + tool: 1 +--- +# Переводы {#translations} + +Эта документация написана на английском языке. Чтобы она была полезна большему числу людей, мы публикуем и её машинные переводы. На этой странице рассказано, что это значит для вас и как помочь их улучшить. + +## Что доступно {#whats-available} + +Переведённая документация сейчас доступна в виде **предварительной версии** на двенадцати языках: Deutsch, español, français, हिन्दी, 日本語, 한국어, português (Brasil), русский язык, Türkçe, українська мова, 简体中文 и 繁體中文. Выберите нужный в переключателе языков вверху любой страницы. Другие языки могут появиться позже, когда эти себя оправдают. + +Справочник по API не переводится: переведённый сайт ссылается на единый английский. + +## Источник истины — английская версия {#english-is-the-source-of-truth} + +Если переведённая страница расходится с английским оригиналом, верна английская. Каждая страница переведённого сайта открывается одной из трёх пометок, показывающих её состояние: + +- **Машинный перевод** — страница переведена автоматически и ссылается на английский оригинал. +- **Перевод отстаёт от английской страницы** — английский оригинал изменился после перевода, поэтому отдельные части могут быть неактуальны, пока перевод не обновится. +- **Показано на английском** — актуального перевода страницы нет, поэтому вы читаете английский текст. + +## Как делаются переводы {#how-the-translations-are-made} + +Переведённые страницы генерирует инструмент из этого репозитория на основе английских страниц в каталоге `docs/`. Для каждого языка он опирается на два материала, написанных людьми: руководство по стилю (регистр, тон, типографика, обращение с шутками и идиомами) и глоссарий (какие термины остаются на английском, а для остальных — обязательные и запрещённые варианты перевода). Сгенерированный текст никогда не правится вручную. Все улучшения вносятся в эти материалы, поэтому они сохраняются при следующей генерации страниц. + +## Как сообщить о проблеме с переводом {#reporting-a-translation-problem} + +Нашли неверный термин, неуклюжую фразу или перевод, который говорит не то, что английский текст? [Создайте issue](https://github.com/modelcontextprotocol/python-sdk/issues), указав язык, страницу и фрагмент; сообщения от носителей языка особенно ценны. Если знаете, как исправить, предложите правку напрямую — пул-реквестом в руководство по стилю (`instructions.md`) или глоссарий (`glossary.json`) этого языка в каталоге [`i18n/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/i18n). Тогда исправление попадёт на все затронутые страницы при следующей генерации переводов. Проблемы в самом английском тексте исправляются в страницах каталога `docs/`, как и любые другие изменения документации. diff --git a/i18n/ru/pages/troubleshooting.md b/i18n/ru/pages/troubleshooting.md new file mode 100644 index 0000000000..8632798445 --- /dev/null +++ b/i18n/ru/pages/troubleshooting.md @@ -0,0 +1,420 @@ +--- +translation: + sections: [2efaecdef109a5c5, fcacd3e66b8635a4, 25323d737dcf0261, 4835ed1772f1d113, 137454d469c867f5, 6392596bd6df54f0, 41126fa9c4fe432f, 480b6d7897e30ab4, d83bb682e708dde0, ebbed3449c499db4, 323ef84f6b4bebde, 30fd31be74169d9a, 656943c6cb567218, c2dc3b1007d2e987, 7cf5386b997d04e9, 0b59feed8384456e, 0cba47bae78d04eb, 954dc21efdb532a3] + tool: 1 +--- +# Устранение неполадок {#troubleshooting} + +Каждый заголовок на этой странице — точный текст ошибки, которую выдаёт SDK, а под ним — что она означает и как её исправить одним действием. Найдите здесь последнюю строку своей трассировки (или лога сервера) поиском по странице в браузере и читайте только эту запись. + +Несколько записей опираются на один и тот же сервер. Один инструмент и один шаблонный ресурс, каждый из которых выбрасывает исключение для города, которого не знает: + +```python title="server.py" +--8<-- "docs_src/troubleshooting/tutorial001.py" +``` + +Ошибки, которые цитирует эта страница, настоящие: собственный набор тестов SDK воспроизводит каждую из них. + +## `ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)` {#exceptiongroup-unhandled-errors-in-a-taskgroup-1-sub-exception} + +Это не ошибка MCP. Это шум от anyio, а настоящая ошибка — **последняя строка** вывода. + +`Client.__aenter__` запускает группу задач. anyio оборачивает всё, что покидает группу задач, в `ExceptionGroup`, поэтому *любое* исключение, вышедшее за пределы блока `async with Client(...)`, каким бы оно ни было, приходит внутри такой группы: + +```python +async def main() -> None: + async with Client(mcp) as client: + await client.read_resource("weather://Atlantis") +``` + +```text + + Exception Group Traceback (most recent call last): + | ... + | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception) + +-+---------------- 1 ---------------- + | Exception Group Traceback (most recent call last): + | ... + | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception) + +-+---------------- 1 ---------------- + | Traceback (most recent call last): + | ... + | mcp.shared.exceptions.MCPError: No forecast for 'Atlantis'. + +------------------------------------ +``` + +С этим нужно сделать две вещи: + +1. **Читайте снизу.** `MCPError: No forecast for 'Atlantis'.` — это и есть сбой; ищите на этой странице *его* текст. +2. **Перехватывайте внутри блока.** `ExceptionGroup` появляется только тогда, когда исключение *покидает* `async with`. Если перехватить его внутри, тот же сбой — обычный `MCPError`, без всякой группы: + +```python +async def main() -> None: + async with Client(mcp) as client: + try: + await client.read_resource("weather://Atlantis") + except MCPError as e: + print(e) # No forecast for 'Atlantis'. +``` + +!!! tip + Сбой во время *подключения* (неверный URL, незапущенный сервер, `421` ниже + на этой странице) выходит из самого `async with`, так что никакого «внутри», где его можно + было бы перехватить, нет. В таких случаях читайте низ группы. + +## `RuntimeError: Client must be used within an async context manager` {#runtimeerror-client-must-be-used-within-an-async-context-manager} + +`Client(...)` лишь создаёт объект. До `async with` ничего не подключается, поэтому каждый метод отказывает: + +```python +async def main() -> None: + client = Client(mcp) + tools = await client.list_tools() # RuntimeError +``` + +Войдите в него. `__aenter__` — это и есть подключение: + +```python +async def main() -> None: + async with Client(mcp) as client: + tools = await client.list_tools() +``` + +`__aexit__` — это отключение, и именно поэтому нет `client.close()`, который можно забыть вызвать. Страница **[Тестирование](get-started/testing.md)** построена ровно на этом шаблоне. + +## `Error executing tool : ` и `Unknown tool: ` {#error-executing-tool-name-message-and-unknown-tool-name} + +Перед вами **результат**, а не исключение. `call_tool` ничего не выбросил и никогда не выбросит для инструмента, завершившегося с ошибкой. + +Вызовите `forecast` для города, которого сервер не знает, — и исключение, которое он выбрасывает, вернётся вместе с запросом, помеченным как *успешный*: + +```python +result.is_error # True +result.content # [TextContent(text="Error executing tool forecast: No forecast for 'Atlantis'.")] +result.structured_content # None +``` + +`Unknown tool: get_forecast` — та же форма для имени, которое сервер никогда не регистрировал, а неправильный аргумент отклоняется так же — по входной схеме инструмента, ещё до того, как ваша функция запустится. + +Исправление — на стороне клиента: **проверяйте `result.is_error`**. `try/except` вокруг `call_tool` не поймает ничего из этого, потому что ловить нечего. Так задумано, и это самая полезная мысль на всей странице, которую стоит усвоить: вызов выбрала *модель*, поэтому именно модель получает сообщение и шанс попробовать снова. Подробнее — на странице **[Обработка ошибок](servers/handling-errors.md)**, включая путь через `MCPError`, который *действительно* выбрасывает исключение. + +## `TypeError: The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool` {#typeerror-the-tool-decorator-was-used-incorrectly-did-you-forget-to-call-it-use-tool-instead-of-tool} + +Вы написали `@mcp.tool` вместо `@mcp.tool()`. `tool()` — это *фабрика* декораторов: без скобок Python передаёт вашу функцию в её параметр `name=`. + +```python +@mcp.tool # <- missing () +def forecast(city: str) -> str: + """Today's forecast for one city.""" + return f"{city}: Rain." +``` + +```text +TypeError: The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool +``` + +Добавьте скобки. `@mcp.resource(...)` и `@mcp.prompt()` говорят то же самое при той же описке. + +!!! note + Исключение выбрасывается при **импорте** модуля, ещё до подключения любого клиента. Поэтому + у хоста, который показывает ваш сервер как *не запустившийся* (или *отключённый*), а не как + подключённый с нулём инструментов, именно эта картина: запустите `python server.py` сами и + прочитайте трассировку. Проверка типов тоже это ловит: функция — недопустимое значение для `name=`. + +## `Tool already exists: ` {#tool-already-exists-name} + +Две регистрации использовали одно и то же имя инструмента. Побеждает **первая**, вторая молча отбрасывается, и единственный сигнал — это предупреждение в *логе сервера*: + +```python title="server.py" hl_lines="6 12" +--8<-- "docs_src/troubleshooting/tutorial002.py" +``` + +```text +WARNING mcp.server.mcpserver.tools.tool_manager: Tool already exists: forecast +``` + +`tools/list` сообщает об одном `forecast`, и это `forecast_today`. Переименуйте один из них. `MCPServer(..., warn_on_duplicate_tools=False)` заглушает предупреждение, не меняя исхода, так что оставьте его включённым. Для ресурсов и промптов действует то же правило и та же строка лога (`Resource already exists:`, `Prompt already exists:`). + +## Хост показывает ноль инструментов {#my-host-lists-zero-tools} + +Строки ошибки для этого нет, и именно поэтому это трудно искать. SDK никогда не выбрасывает зарегистрированный инструмент из `tools/list`, так что двигайтесь от сервера наружу: + +* **Запустился ли сервер вообще?** `@mcp.tool` без скобок выбрасывает исключение при импорте, а упавший сервер в некоторых хостах очень похож на пустой. Запустите `python server.py` сами. +* **Находится ли инструмент на том `mcp`, который запускает хост?** Второй `MCPServer(...)` в другом модуле — это другой, пустой сервер. Проверьте, какой объект на самом деле импортирует команда хоста. +* **Не совпали ли имена у двух инструментов?** Тогда один из них пропал. Ищите `Tool already exists:` в логе сервера. +* **Не устарел ли список у хоста?** Инструмент, добавленный после запуска, доходит только до клиентов, которые обрабатывают `notifications/tools/list_changed`. Грубое, но действенное решение — перезапустить хост. +* **Не записало ли что-нибудь в `stdout` вне окна перенаправления?** Пока сервер обслуживает запросы, SDK перенаправляет *сброшенный из буфера* посторонний вывод stdout в stderr (по возможности: среда, которая подменяет стандартные потоки, обслуживается как есть), но вывод, сброшенный в stdout раньше (эхо скрипта-обёртки, `print()` при импорте в небуферизованном процессе), или буферизованный `print()`, слитый при выходе интерпретатора, попадает в поток протокола, а одной мусорной строки достаточно, чтобы хост разорвал соединение — что некоторые хосты отображают как сервер, в котором ничего нет. Пишите логи через модуль `logging`. Остальной чек-лист на стороне хоста — на странице **[Подключение к настоящему хосту](get-started/real-host.md)**. + +«Недопустимого» имени инструмента в этом списке *нет*: имя, не соответствующее правилам, пишет предупреждение в лог, но инструмент всё равно регистрируется и попадает в список. + +## `MCPError: Server returned an error response` {#mcperror-server-returned-an-error-response} + +Сервер сразу отклонил HTTP-запрос, причём тело ответа — не JSON-RPC, поэтому `Client` на Python не может показать ничего лучше этой заглушки. + +Самая частая причина с большим отрывом — только что развёрнутый сервер Streamable HTTP. `streamable_http_app()` (и `mcp.run("streamable-http")`) без `transport_security=` по умолчанию включает **защиту от DNS-rebinding**: принимаются только запросы, у которых заголовок `Host` — localhost. Это правильное значение по умолчанию на ноутбуке и неправильное за настоящим именем хоста: + +```python title="server.py" hl_lines="12" +--8<-- "docs_src/troubleshooting/tutorial003.py" +``` + +Разверните это, направьте на него клиент — и подключение провалится на рукопожатии: + +```python +async with Client("https://mcp.example.com/mcp") as client: + ... +``` + +```text +mcp.shared.exceptions.MCPError: Server returned an error response +``` + +Слова, которые сервер на самом деле отправил, — `421` и `Invalid Host header` — до вас не доходят: у тела ответа 421 нет `Content-Type: application/json`, поэтому клиент не может его разобрать. Они есть в **логе сервера**, куда и стоит заглянуть дальше: + +```text +WARNING mcp.server.transport_security: Invalid Host header: mcp.example.com +``` + +Исправление — `transport_security=`. Внесите в список разрешённых то имя хоста, которое вы действительно обслуживаете: + +```python title="server.py" hl_lines="14-17" +--8<-- "docs_src/troubleshooting/tutorial004.py" +``` + +!!! check + Вот и всё изменение. Тот же самый клиент теперь подключается, согласовывает `2026-07-28` и + вызывает `forecast`. + +На странице **[Развёртывание и масштабирование](run/deploy.md)** рассказано, что означает каждое поле, разобран случай с обратным прокси и всё остальное, что меняется при развёртывании. А `421 Misdirected Request` / `Invalid Host header`, сразу ниже, — тот же сбой, увиденный с другой стороны. + +## `421 Misdirected Request` / `Invalid Host header` {#421-misdirected-request-invalid-host-header} + +Это `Server returned an error response`, увиденный из чего угодно, кроме `Client` на Python: curl, вкладка сети в браузере, журнал доступа обратного прокси или другой SDK. + +```bash +curl -i https://mcp.example.com/mcp \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}' +``` + +```text +HTTP/1.1 421 Misdirected Request + +Invalid Host header +``` + +`421 Misdirected Request` — это собственная поясняющая фраза HTTP для этого статуса; `Invalid Host header` — тело ответа SDK; а `Client` на Python отображает то же событие как `Server returned an error response`. Все три — один и тот же отказ. Проверка выполняется по **заголовку `Host`, который несёт запрос**, а не по адресу, к которому привязан сервер, поэтому обратный прокси, пересылающий публичное имя хоста, натыкается на неё точно так же, как прямой клиент. + +Исправление — тот же `transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])`, показанный в разделе `Server returned an error response`. Два его пограничных момента стоит назвать: + +* Элемент `allowed_hosts` — это точная строка. `"mcp.example.com"` совпадает с заголовком `Host` без порта, а `"mcp.example.com:*"` — с любым явно указанным портом. Укажите оба. +* `403` с телом `Invalid Origin header` — родственная проверка заголовка `Origin`. Она срабатывает только для браузеров (больше ничто не отправляет `Origin`), а `allowed_origins=` — её список разрешённых. + +Подробнее — на странице **[Развёртывание и масштабирование](run/deploy.md)**, в том числе о том, когда отключить проверку — это честная конфигурация. + +## `RuntimeError: Task group is not initialized. Make sure to use run().` {#runtimeerror-task-group-is-not-initialized-make-sure-to-use-run} + +Ваше MCP-приложение смонтировано внутри другого ASGI-приложения, и ничто не запустило его **менеджер сессий**. + +`mcp.streamable_http_app()` возвращает Starlette-приложение, чей собственный жизненный цикл (lifespan) запускает менеджер, а `uvicorn server:app` выполняет этот жизненный цикл за вас. Но Starlette **никогда не запускает жизненный цикл смонтированного подприложения**, поэтому, как только приложение оказывается внутри `Mount`, менеджер так и не стартует, и первый же запрос взрывается: + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/troubleshooting/tutorial005.py" +``` + +Сервер запускается. Маршрут разрешается. А затем `uvicorn` печатает это на каждый запрос: + +```text +ERROR: Exception in ASGI application +Traceback (most recent call last): + ... +RuntimeError: Task group is not initialized. Make sure to use run(). +``` + +Клиент видит 500. Исправление — жизненный цикл на приложении-**хосте**, который входит в `mcp.session_manager.run()`: + +```python +@asynccontextmanager +async def lifespan(app: Starlette) -> AsyncIterator[None]: + async with mcp.session_manager.run(): + yield + + +app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lifespan) +``` + +Этому посвящена страница **[Добавление в существующее приложение](run/asgi.md)**, включая несколько серверов в одном приложении и FastAPI. Две соседние строки из того же класса: + +* `StreamableHTTPSessionManager .run() can only be called once per instance. Create a new instance if you need to run again.` Менеджер одноразовый; двойной вход в жизненный цикл одного и того же приложения натыкается на неё. +* `mcp.session_manager` существует только **после** вызова `streamable_http_app()`, поэтому сначала постройте маршруты, а к менеджеру обращайтесь только внутри жизненного цикла. + +## `MCPError: Session not found` {#mcperror-session-not-found} + +Сервер не узнаёт `Mcp-Session-Id`, который отправил клиент, — почти всегда потому, что сервер **перезапустился** (или вас направили на другой экземпляр). Сессии живут в памяти одного этого процесса. + +Искать ошибку в сервере незачем. HTTP-ответ — `404`, тело которого — *настоящий* JSON-RPC, поэтому, в отличие от `421` выше, `Client` на Python показывает его дословно: + +```json +{"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "Session not found"}} +``` + +Исправление — переподключиться: выйти из блока `async with Client(...)` и войти в новый, который согласует свежую сессию. Для долгоживущего клиента это означает перехватывать `MCPError` вокруг вызовов и переподключаться по этому сообщению, а не повторять попытки внутри мёртвой сессии. + +Если это происходит *без* перезапуска, значит, у вас больше одного воркера без закрепления сессий за ними: каждый воркер держит собственную таблицу сессий, поэтому запрос, направленный не на тот воркер, оказывается здесь. Эта история и два её решения (маршрутизация с привязкой сессий или `stateless_http=True`) — на страницах **[Развёртывание и масштабирование](run/deploy.md)** и **[Обслуживание клиентов старого поколения](run/legacy-clients.md)**. + +Для оператора сервера соответствующая строка лога — `Rejected request with unknown or expired session ID: `. Она пишется на уровне `INFO`, поэтому при обычном пороге `WARNING` её не видно. Видеть её пачками сразу после развёртывания — нормально: все подключённые клиенты переподключаются. + +## `MCPError: Method not found` {#mcperror-method-not-found} + +Одна сторона отправила JSON-RPC-запрос, для которого у другой нет обработчика, и `e.error.data` называет метод. Обычная причина — **несовпадение поколений**: метод, который есть в одной ревизии протокола и отсутствует в другой, отправлен собеседнику не того поколения — например, `resources/subscribe` поколения `2025`, пришедший на подключение `2026-07-28`, или `subscriptions/listen`, существующий только в `2026`, отправленный клиентом, закреплённым на `mode="legacy"`. Карта того, какая сторона на чём говорит, — на странице **[Версии протокола](protocol-versions.md)**, а другая честная причина (необязательная возможность, для которой вы так и не зарегистрировали обработчик) — на странице **[Автодополнение](servers/completions.md)**. + +Одна вещь эту ошибку **не** вызывает, хотя и представляет собой запрос, который современный протокол удалил: инструмент, вызывающий `ctx.elicit()` на подключении `2026-07-28`. Сервер вообще отказывается *отправлять* этот запрос, так что вместо этого вы получаете `Cannot send 'elicitation/create': ...`, ниже на этой странице. + +## `MCPError: Client did not declare the form elicitation capability required by resolver ''` {#mcperror-client-did-not-declare-the-form-elicitation-capability-required-by-resolver-name} + +Сервер хочет что-то спросить у пользователя, а этот клиент никогда не говорил, что его можно спрашивать. + +Резолвер элицитации (elicitation) отказывает заранее, если подключённый клиент не объявил элицитацию через формы, и `e.error.data` называет ровно то, чего не хватает: + +```json +{ + "code": -32021, + "message": "Client did not declare the form elicitation capability required by resolver 'server:ask_to_confirm'", + "data": {"requiredCapabilities": {"elicitation": {"form": {}}}} +} +``` + +Передайте `elicitation_callback=` в `Client(...)`. Регистрация колбэка *и есть* объявление возможности; второго переключателя нет: + +```python +async def main() -> None: + async with Client(mcp, elicitation_callback=handle_elicitation) as client: + result = await client.call_tool("book_table", {"date": "Friday"}) +``` + +На странице **[Колбэки клиента](client/callbacks.md)** перечислены остальные (`sampling_callback`, `list_roots_callback`), каждый из которых точно так же служит объявлением. + +!!! info + `-32021` — это `MISSING_REQUIRED_CLIENT_CAPABILITY`, один из трёх кодов ошибок, которые + добавляет спецификация 2026-07-28. Ни один из них не класс исключения: все они приходят как + `MCPError`, и смотреть нужно в `e.error.code`. Константы экспортирует `mcp.types`. Два других — + `-32020` `HEADER_MISMATCH` (HTTP-заголовок расходится с телом запроса, которое он сопровождает) + и `-32022` `UNSUPPORTED_PROTOCOL_VERSION` (запрос назвал версию, на которой этот сервер не + говорит). Соответствующий спецификации SDK-клиент не может выдать ни одну из них, так что, + если вы такую видите, ищите то, что переписывает запросы между вашим клиентом и вашим сервером. + +## `MCPError: Elicitation not supported` {#mcperror-elicitation-not-supported} + +Тот же пробел, что и `Client did not declare the form elicitation capability ...`, но в формулировке тех путей, которые не проверяют заранее: серверу нужен был ответ на элицитацию, а подключённый клиент не зарегистрировал `elicitation_callback`. + +Это сообщение приходит от `ctx.elicit()` на подключении старого поколения, а на любом подключении вообще — от возвращённого многораундового (multi-round-trip) вопроса (**[Многораундовые запросы](handlers/multi-round-trip.md)**), который дошёл до клиента без колбэка, способного на него ответить. Исправление то же: передайте `elicitation_callback=` в `Client(...)`. Не существует варианта «пользователя не спросили», который ваш инструмент получил бы как `decline`; клиент, которого нельзя спросить, — это провалившийся вызов, так что проектируйте инструменты с расчётом на это. + +## `MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests.` {#mcperror-cannot-send-elicitationcreate-this-transport-context-has-no-back-channel-for-server-initiated-requests} + +Обработчик попытался обратиться к клиенту посреди запроса на подключении, где у вызова нет канала, способного донести запрос от сервера. В такое положение вызов ставят три конфигурации сервера. + +**Подключение `2026-07-28`: любой транспорт, всегда.** В современном протоколе вообще нет запросов, инициируемых сервером, поэтому сервер отказывает ещё до того, как что-либо отправлено. `ctx.elicit()` внутри инструмента — классический способ с этим столкнуться (в самом первом тесте в памяти, поскольку `Client(server)` согласовывает `2026-07-28`, не спрашивая), и передача `elicitation_callback=` ничего не меняет: никакой запрос до клиента не доходит, так что отвечать ему не на что: + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/troubleshooting/tutorial006.py" +``` + +```python +async def main() -> None: + async with Client(mcp) as client: + await client.call_tool("book_table", {"date": "Friday"}) +``` + +```text +mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests. +``` + +**Подключение старого поколения к серверу с `stateless_http=True`.** Отсутствие состояния означает, что каждый запрос — отдельный мир: ни сессии, ни потока от сервера к клиенту, а значит, `elicitation/create` (как и `sampling/createMessage` или `roots/list`) отправить некуда даже для того поколения, в котором они есть: + +```python title="server.py" hl_lines="16 23" +--8<-- "docs_src/troubleshooting/tutorial008.py" +``` + +**Подключение старого поколения к серверу с `json_response=True`.** На `POST` отвечают одним JSON-телом, а одно тело несёт только ответ, поэтому потока, привязанного к запросу, который нужен `ctx.elicit()` посреди запроса, здесь тоже нет. Сессия, её `Mcp-Session-Id` и её отдельный поток по-прежнему на месте; исчез только канал, привязанный к запросу. + +Сообщение называет метод, который не удалось отправить. Сервер выбрасывает класс `NoBackChannelError`, но по сети передаётся только базовый `MCPError`, поэтому последняя строка вашей трассировки — приведённое выше предложение, а не имя класса. + +Для клиента `2026-07-28` исправление во всех трёх случаях одно: не обращайтесь к клиенту посреди вызова. Перенесите вопрос в **резолвер** (или сами верните `InputRequiredResult`) — и он станет частью *ответа*, который способно донести любое подключение: + +```python title="server.py" hl_lines="15-17 21" +--8<-- "docs_src/troubleshooting/tutorial007.py" +``` + +Тот же вопрос, тот же `elicitation_callback` на клиенте. Разница внутри: резолвер позволяет серверу *вернуть* вопрос из вызова, а не проталкивать его, так что от сервера к клиенту ничего никогда не идёт. Этого достаточно для любого клиента `2026-07-28`, в какой бы из трёх конфигураций ни был сервер. Клиенту *старого поколения* одной лишь переделки мало: в `2025-11-25` нет способа вернуть вопрос, поэтому на подключении старого поколения резолвер по-прежнему отправляет `elicitation/create` по каналу, привязанному к запросу, и по-прежнему нуждается в сервере, который этот канал сохраняет, — без `stateless_http=True` и без `json_response=True`. Резолверы описаны на странице **[Элицитация](handlers/elicitation.md)**; что происходит в передаваемых данных — на странице **[Многораундовые запросы](handlers/multi-round-trip.md)**. + +!!! check + Инструмент с `ctx.elicit()` не ошибочный — он *из поколения до 2026*. Подключитесь с `mode="legacy"` + (классическое рукопожатие `initialize`, спецификация `2025-11-25` и более ранние) к серверу без + `stateless_http=True` и без `json_response=True` — и он заработает, потому что там канал от + сервера к клиенту существует. + Что есть в каждой версии — на странице **[Версии протокола](protocol-versions.md)**. + +## `MCPError: Invalid or expired requestState` {#mcperror-invalid-or-expired-requeststate} + +Сервер не смог проверить токен `requestState`, который клиент вернул ему обратно, и отклонил раунд. + +`requestState` — непрозрачный токен возобновления, который **[многораундовый](handlers/multi-round-trip.md)** вызов несёт между этапами. `MCPServer` запечатывает его на выходе и проверяет каждый возврат, причём проверяет *каждый* входящий `request_state` в `tools/call`, `prompts/get` и `resources/read`, даже для обработчика, который сам никогда его не выпускает. Поэтому токен, который этот процесс не запечатывал, отклоняется, куда бы он ни попал: + +```python +async def main() -> None: + async with Client(mcp) as client: + await client.call_tool("forecast", {"city": "London"}, request_state="round-1-from-worker-a") +``` + +```text +mcp.shared.exceptions.MCPError: Invalid or expired requestState +``` + +Сообщение намеренно неизменно: по сети никогда не раскрывается, какая проверка не прошла. Причина уходит в **лог сервера**, и прочитать его — вот и вся диагностика: + +```text +WARNING mcp.server.request_state: requestState rejected on tools/call: malformed +``` + +Причины, которые вы реально увидите: + +* **`unknown key`** — та, что важна. Ключ запечатывания по умолчанию генерируется при запуске процесса, поэтому повторная попытка, попавшая на **другой воркер**, на другой экземпляр за балансировщиком нагрузки или на тот же сервер **после перезапуска**, была запечатана ключом, которого у этого процесса никогда не было. Это не злоумышленник; это значение по умолчанию столкнулось с более чем одним процессом. +* **`audience`**: токен запечатан экземпляром с *другим именем сервера*. Имя по умолчанию служит в печати значением audience, поэтому у всего парка серверов должно совпадать имя (или быть задан явный `RequestStateSecurity(audience=...)`), а не только ключи. +* **`expired`**: раунд занял больше, чем `ttl` печати — 600 секунд, причём на раунд, а не на вызов. +* **`malformed`** / **`codec error`**: токен изменили при передаче, или он вовсе никогда не был запечатанным токеном. +* **`request binding`**: токен вернулся с другим инструментом, другими аргументами или другим методом. + +Исправление для нескольких процессов — один аргумент (*одни и те же* `keys` на каждом экземпляре) плюс одна вещь, которая вовсе не аргумент: одно и то же *имя* сервера (или явный общий `audience=`). + +```python +mcp = MCPServer("Weather", request_state_security=RequestStateSecurity(keys=[key])) +``` + +`keys[0]` запечатывает; проверяет каждый ключ из списка — именно это делает возможной ротацию без простоя. На странице **[Многораундовые запросы](handlers/multi-round-trip.md#protecting-requeststate)** объясняется, что защищает печать, и приведена последовательность ротации, а на странице **[Развёртывание и масштабирование](run/deploy.md)** разобран весь сбой с двумя воркерами и его исправление из двух частей. + +!!! tip + `keys=[...]` сразу отклоняет слабый ключ, причём с необычно полезным сообщением: + + ```text + ValueError: request-state keys must be at least 32 bytes of secret randomness; keys[0] is 7 bytes. Generate one with: python -c "import secrets; print(secrets.token_hex(32))" + ``` + + Сделайте, как сказано. + +## Всё ещё не получается? {#still-stuck} + +* Если сообщения, которое выдал SDK, на этой странице нет, это ошибка документации, о которой стоит сообщить отдельно. +* Поищите в [трекере задач](https://github.com/modelcontextprotocol/python-sdk/issues): большинство строк ошибок, которые там встречаются, кто-то уже подробно описал. +* Ничего не нашли? [Откройте задачу](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml) с полной трассировкой или спросите в [#python-sdk-dev на Discord-сервере MCP Contributors](https://discord.gg/6CSzBmMkjX). + +## Итоги {#recap} + +* `ExceptionGroup: unhandled errors in a TaskGroup` — никогда не сама ошибка. Читайте **последнюю строку**; перехват `MCPError` *внутри* блока `async with Client(...)` полностью избавляет от обёртки. +* `call_tool` не выбрасывает исключение для инструмента, завершившегося с ошибкой. `Error executing tool ...` и `Unknown tool: ...` — это результаты: проверяйте `result.is_error`. +* `Client must be used within an async context manager` -> используйте `async with`. `Use @tool() instead of @tool` -> добавьте скобки. +* `Tool already exists:` в логе сервера — единственный признак того, что два одноимённых инструмента схлопнулись в один. +* Один 421, три написания: `Server returned an error response` (`Client` на Python), `421 Misdirected Request` / `Invalid Host header` (всё остальное), `Invalid Host header: ` (лог сервера). Исправление: `transport_security=TransportSecuritySettings(allowed_hosts=[...])`. +* `Task group is not initialized` -> смонтированное приложение, жизненный цикл хоста которого так и не вошёл в `mcp.session_manager.run()`. +* `Session not found` -> сервер перезапустился; переподключитесь. +* `Cannot send 'elicitation/create': ... no back-channel ...` -> `ctx.elicit()` нужен канал от сервера к клиенту: у подключения `2026-07-28` его не бывает никогда, `stateless_http=True` отнимает его у подключений старого поколения, а `json_response=True` отнимает канал, привязанный к запросу. Используйте резолвер (клиенту старого поколения к тому же нужен сервер, который сохраняет канал). Соседнее `Method not found` — это запрос метода, которого нет в ревизии протокола другой стороны. +* `Client did not declare the form elicitation capability ...` и `Elicitation not supported` -> у клиента не хватает `elicitation_callback=`. +* `Invalid or expired requestState` никогда не говорит по сети, почему. Лог сервера говорит; `unknown key` означает, что `RequestStateSecurity(keys=[...])` нужно сделать общим для всех воркеров. diff --git a/i18n/ru/pages/whats-new.md b/i18n/ru/pages/whats-new.md new file mode 100644 index 0000000000..7eff12cc2c --- /dev/null +++ b/i18n/ru/pages/whats-new.md @@ -0,0 +1,214 @@ +--- +translation: + sections: [cfe01c0c5863dfa2, 11d93f1fa09eadf5, a7392996acf1ad8f, 875eb2889263424e] + tool: 1 +--- +# Что нового в v2 {#whats-new-in-v2} + +В v2 одновременно произошли две перемены. **SDK перестроен**: новый движок под клиентом и под сервером, полноценный `Client` и набор переименований, с которыми кодовая база на v1 сталкивается при первом же импорте. И **протокол ушёл вперёд**: v2 говорит на ревизии MCP 2026-07-28, которая убирает рукопожатие при подключении, сессию и все запросы, инициируемые сервером, — не оставляя при этом за бортом клиенты, которые у вас уже есть. + +Эта страница — обзор обеих половин: по разделу на каждую главную новость, и каждый заканчивается ссылкой на страницу, которой принадлежит тема. Это не руководство по переносу. Им служит **[Руководство по миграции](migration.md)**: все ломающие изменения с кодом до и после. + +!!! note "v2 — стабильная ветка" + `pip install mcp` устанавливает 2.x, а на странице **[Установка](get-started/installation.md)** есть + готовая строка установки для копирования. Если что-то в v2 ломается, удивляет или тормозит вас, + [сообщите нам](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml). + +## SDK: от v1 к v2 {#the-sdk-v1-to-v2} + +### `FastMCP` теперь `MCPServer` {#fastmcp-is-now-mcpserver} + +Высокоуровневый класс сервера переименован, а вместе с ним и его модуль. Это первое, на что натыкается каждый сервер на v1, потому что старый путь импорта удалён, а не объявлен устаревшим: + +```python +from mcp.server import MCPServer # v1: from mcp.server.fastmcp import FastMCP + +mcp = MCPServer("Demo") # v1: FastMCP("Demo") +``` + +Для сервера, собранного на декораторах, это заодно и почти весь перенос. `@mcp.tool()`, `@mcp.resource()` и `@mcp.prompt()` принимают то же, что принимали в v1 (`@mcp.resource()` добавляет один необязательный именованный аргумент `security=`), а входная схема по-прежнему строится по аннотациям типов. По мелочам: всё, что лежало под `mcp.server.fastmcp.*`, теперь живёт под `mcp.server.mcpserver.*`, `ctx.fastmcp` стал `ctx.mcp_server`, `get_context()` удалён (вместо него объявите параметр `ctx: Context`), а базовый класс исключений `FastMCPError` теперь `MCPServerError`. Таблица импортов — в **[Руководстве по миграции](migration.md#fastmcp-renamed-to-mcpserver)**. + +### `Resolve`: новый способ запросить ввод у пользователя {#resolve-the-new-way-to-ask-the-user-for-input} + +Не всё, что нужно инструменту, должно приходить от модели. Новое в v2: параметр инструмента с аннотацией `Resolve(fn)` заполняет функция, которую пишете вы, незаметно для модели, и эта функция может вернуть `Elicit(...)`, чтобы задать вопрос пользователю. Это предпочтительный способ получить что-либо от клиента посреди вызова: SDK передаёт вопрос тем механизмом, который поддерживает подключение, — живой запрос элицитации (elicitation) для клиента старого поколения или многораундовый запрос (multi-round-trip) на 2026-07-28, — так что одно тело инструмента обслуживает оба поколения. Подробнее — на странице **[Зависимости](handlers/dependencies.md)**. + +!!! note + Две другие формы остаются на случай, когда они нужны: `ctx.elicit()` по-прежнему работает для клиентов на + подключениях старого поколения (**[Элицитация](handlers/elicitation.md)**), а обработчик может сам вернуть + `InputRequiredResult` и вести раунды вручную — именно так на 2026-07-28 путешествуют и запросы + сэмплирования (sampling) и корневых каталогов (roots) (**[Многораундовые запросы](handlers/multi-round-trip.md)**). + +### Полноценный `Client` {#a-first-class-client} + +v1 выдавала три вложенных слоя: контекстный менеджер транспорта, отдающий сырые потоки, обёрнутый вокруг них `ClientSession` и вызываемый вручную `await session.initialize()`. В v2 объект один: + +```python title="client.py" hl_lines="14-18" +--8<-- "docs_src/client/tutorial001.py" +``` + +`Client` принимает объект сервера (в памяти, без транспорта — это сценарий для тестов), URL (Streamable HTTP) или любой контекстный менеджер транспорта, например `stdio_client(...)`. Вход в `async with` подключается и согласует версию протокола, на каком бы поколении ни говорил сервер; после этого `client.server_capabilities` и `client.protocol_version` просто доступны, как и `client.server_info`, когда сервер себя идентифицирует (теперь это `Implementation | None`, поскольку в поколении 2026 идентификация необязательна). Колбэки сэмплирования и элицитации, зарегистрированные в v1, по-прежнему работают (их тела затрагивает то же переименование атрибутов в snake_case, что и всё остальное на этой странице), теперь они ещё и отвечают на запросы внутри результатов в стиле 2026 (см. ниже) и выполняются параллельно, а не по одному. `ClientSession` по-прежнему лежит в основе для тех, кому нужна низкоуровневая поверхность, и `client.session` её отдаёт; она тоже изменилась (работает на новом движке-диспетчере, и некоторые её собственные сигнатуры поменялись), так что прежде чем спускаться на этот уровень, прочитайте **[Руководство по миграции](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)**. + +Страница **[Объект Client](client/index.md)** знакомит с ним, **[Транспорты клиента](client/transports.md)** описывает три формы подключения, **[Колбэки клиента](client/callbacks.md)** — сами колбэки, а **[Тестирование](get-started/testing.md)** показывает шаблон работы в памяти, который заменяет вспомогательную функцию `create_connected_server_and_client_session()` из v1. + +### Низкоуровневый `Server` перестроен, а не переименован {#the-low-level-server-was-rebuilt-not-renamed} + +Если вы работаете на уровне JSON-RPC, это та часть v2, где «всё по-другому». Вот один и тот же сервер с одним инструментом в обоих вариантах; нажмите на маркеры, чтобы увидеть, что изменилось. + + + +```python title="v1" +from typing import Any + +import mcp.types as types +from mcp.server.lowlevel import Server + +server = Server("Bookshop") + + +@server.list_tools() # (1)! +async def list_tools() -> list[types.Tool]: + return [ # (2)! + types.Tool( + name="search_books", + description="Search the catalog by title or author.", + inputSchema={ # (3)! + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + }, + ) + ] + + +@server.call_tool() +async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentBlock]: # (4)! + if name != "search_books": + raise ValueError(f"Unknown tool: {name}") # (5)! + ctx = server.request_context # (6)! + return [types.TextContent(type="text", text=f"Found 3 books matching {arguments['query']!r}.")] # (7)! +``` + +1. Обработчики регистрируются декораторами (вызываемыми, со скобками) в любой момент после создания сервера. +2. Возвращается голый `list[Tool]`, а SDK оборачивает его в `ListToolsResult`. +3. Поля в Python — в camelCase, а схема **применяется принудительно**: SDK проверяет по ней аргументы `call_tool` через jsonschema до запуска вашей функции, поэтому обращение `arguments["query"]` ниже безопасно. +4. Один обработчик `call_tool` обслуживает все инструменты и получает имя инструмента и уже проверенные аргументы — распакованные и никогда не `None`. +5. Исключение — так инструмент в v1 сообщает о неудаче: любое исключение перехватывается и возвращается как `CallToolResult(isError=True)` с текстом `str(e)`, так что вызывающая модель читает это сообщение и может повторить попытку. +6. Контекст берётся из фоновой ContextVar, к которой посреди запроса обращаются через объект сервера. +7. Голые блоки содержимого оборачиваются в `CallToolResult` за вас. + +```python title="v2" +--8<-- "docs_src/whats_new/tutorial001.py" +``` + +1. Поля теперь в snake_case, а схема **объявляется, но никогда не применяется**: до запуска обработчика аргументы ничто не проверяет. +2. У всех обработчиков одна форма: `async (ctx, params) -> result`. Контекст — первый аргумент (на нём живут `ctx.session`, `ctx.request_id`, `ctx.protocol_version`); сюда и переехал `server.request_context`. +3. Полный `ListToolsResult` вы собираете сами. Возврат голого списка теперь даёт `TypeError` на стороне сервера, а не оборачивается SDK. +4. На входе — типизированные параметры (`params.name`, `params.arguments`), на выходе — полный результат. Ничего не распаковывается, не оборачивается и не преобразуется за вас. +5. Та же проверка, другой глагол. `ValueError` здесь дошёл бы до модели как непрозрачный `-32603` (см. ниже), поэтому намеренная ошибка уровня протокола выбрасывается как `MCPError`: она проходит насквозь с нетронутыми кодом и сообщением, а `-32602` с этим текстом — ответ на неизвестный инструмент, прописанный в самой спецификации. +6. `params.arguments` может быть `None`; v1 подставляла `{}` ещё до того, как ваш код его видел. Раз перед обработчиком нет проверки, без этой строки не обойтись. +7. Неожиданное исключение, выброшенное здесь, становится **очищенной** ошибкой протокола, `-32603` `"Internal server error"`: модель никогда не увидит сообщения. Для неудачи, которую модель должна прочитать и на которую должна отреагировать, возвращайте `CallToolResult(is_error=True, ...)`. +8. Обработчики — аргументы конструктора, так что поверхность сервера полна в момент его создания; `add_request_handler()` — запасной выход после создания и дверь к пользовательским методам. + +Пример и есть шаблон. В общем виде: у всех обработчиков одна форма — типизированные параметры на входе и полный тип результата на выходе; прежней проверки аргументов инструмента через jsonschema больше нет; исключение — это ошибка протокола и никогда не результат инструмента с `is_error=True`; фоновой ContextVar `server.request_context` больше нет. Пользовательские методы в пространстве имён поставщика полноценно поддерживаются через `add_request_handler(method, params_type, handler)`, который проверяет входящие параметры по вашей модели до запуска обработчика. А список `middleware` (намеренно помеченный как предварительный) оборачивает каждое входящее сообщение, заменяя приватные методы `_handle_*`, которые раньше переопределяли. + +Внутри приёмный цикл `BaseSession` из v1 заменён движком-диспетчером, который теперь общий для клиента и сервера, и именно он делает верными сразу несколько утверждений этой страницы: один объект `Server` обслуживает оба поколения протокола, `Client(server)` диспетчеризует внутри процесса без JSON-RPC-обрамления, а запрос клиента, у которого истёк таймаут, теперь действительно отменяет обработчик на стороне сервера. + +Подробнее — на странице **[Низкоуровневый Server](advanced/low-level-server.md)**; **[Руководство по миграции](migration.md#lowlevel-server-decorator-based-handlers-replaced-with-constructor-on_-params)** проходит по каждому удалённому хуку. Если вы никогда не спускались ниже `MCPServer`, ничто из этого вас не касается. + +### Типы протокола переехали в `mcp-types`, и все поля теперь в snake_case {#the-wire-types-moved-to-mcp-types-and-every-field-is-snake_case} + +Типы протокола теперь живут в собственном дистрибутиве `mcp-types`. Он не зависит ни от чего, кроме pydantic и typing-extensions, так что шлюз, прокси или генератор кода может использовать формы передаваемых MCP данных, не устанавливая HTTP-стек: такой проект устанавливает `mcp-types` и импортирует `mcp_types`. Сам `mcp` зависит от этого пакета с точной версией и реэкспортирует его, так что код, зависящий от SDK, по-прежнему пишет `import mcp.types as types` и `from mcp.types import Tool` (постоянный псевдоним, каждое имя — тот же объект) и объявляет только одну свою настоящую зависимость, `mcp`. Правило простое: импортируйте через тот пакет, от которого действительно зависите. + +У этих типов каждый атрибут в Python теперь в snake_case: `result.is_error`, `tool.input_schema`, `listing.next_cursor`. JSON в передаваемых данных — в camelCase, ровно как раньше; изменилось только написание атрибутов. Заодно появились два более строгих значения по умолчанию: неизвестные поля игнорируются, а не возвращаются обратно (дополнительное кладите в `_meta`), и обе стороны проверяют трафик по согласованной версии протокола. Таблица переименований — в **[Руководстве по миграции](migration.md#field-names-changed-from-camelcase-to-snake_case)**. + +### Настройка транспорта переехала в `run()` {#transport-configuration-moved-to-run} + +`MCPServer(...)` описывает, чем ваш сервер *является*: имя, инструкции, жизненный цикл (lifespan), авторизацию. То, как он *обслуживается*, теперь относится к `run()` и сборщикам приложений — туда ушли `host`, `port`, `stateless_http`, `json_response`, пути эндпоинтов и `transport_security` (`MCPServer("x", port=9000)` — это `TypeError`). Перегрузки типизированы по транспортам, так что редактор подскажет, какие параметры принимает `stdio`, а какие — `streamable-http`. Одно удаление стоит знать: `mount_path` больше нет; поддерживаемый способ обслуживать под префиксом — монтировать ASGI-приложение. + +Параметры описаны на странице **[Запуск сервера](run/index.md)**; монтирование — на странице **[Добавление в существующее приложение](run/asgi.md)**. + +### Поведение, которое меняется без ошибки импорта {#behavior-that-changes-without-an-import-error} + +Переименования заявляют о себе сами. А вот эти изменения — нет: + +* **Синхронные функции выполняются в рабочем потоке.** Инструмент (а также ресурс, промпт или резолвер), объявленный через `def`, больше не блокирует цикл событий; плата за это — его тело больше не выполняется *в* потоке цикла событий, что важно для кода, привязанного к потоку. Обработчики `async def` не затронуты. **[Руководство по миграции](migration.md#sync-handler-functions-now-run-on-a-worker-thread)**. +* **`MCPError` (`McpError` в v1), выброшенный внутри инструмента, теперь ошибка протокола.** Модель его никогда не видит. Любое другое исключение по-прежнему становится результатом с `is_error=True`, который модель может прочитать и на который может отреагировать. Разграничение — на странице **[Обработка ошибок](servers/handling-errors.md)**. +* **Результаты проверяются перед отправкой.** Собранный вручную `Tool`, у которого `input_schema` равна `{}`, теперь проваливает `tools/list` (спецификация требует `"type": "object"`). Серверы, построенные на `@mcp.tool()`, с этим не сталкиваются: их схемы пишет SDK. +* **Ваш клиент проверяет то, что получает.** `list_tools()` и `call_tool()` сверяют ответ сервера с согласованной версией протокола, так что не совсем валидный сервер, который терпел снисходительный разбор v1, теперь вызывает `pydantic.ValidationError`. Если подключаетесь к серверам, которые не контролируете, будьте готовы оказаться тем, кто их обнаружит; подробности — в **[Руководстве по миграции](migration.md#client-validates-inbound-traffic-against-the-protocol-schema)**. +* **Шаблоны URI теперь настоящий RFC 6570.** `{+path}`, `{?query}` и им подобные работают, сопоставление точное, а не приблизительное через регулярные выражения, и обход путей в извлечённых значениях по умолчанию отклоняется. Более строгие шаблоны падают в момент декорирования, а не на первом запросе. **[Шаблоны URI](servers/uri-templates.md)**. +* **Жизненный цикл streamable HTTP выполняется один раз**, при запуске, и его состояние общее для всех сессий и запросов. В v1 он выполнялся один раз на сессию, а при `stateless_http=True` — один раз на запрос. Пулы и кэши, созданные в жизненном цикле, становятся радикально дешевле; всё, что захватывало там ресурс на одно подключение, теперь относится к телу обработчика. **[Жизненный цикл](handlers/lifespan.md)**. +* **`mcp dev` и `mcp install` закрепляют порождаемое окружение** за установленной у вас версией SDK. Обе команды запускают сервер в свежем окружении `uv run --with ...`, которое раньше разрешало `mcp` в новейший стабильный выпуск, а не в версию, против которой вы разрабатываете. **[Руководство по миграции](migration.md#mcp-dev-and-mcp-install-pin-the-spawned-environment-to-your-sdk-version)**. +* **HTTP-клиент теперь `httpx2`, а не `httpx`.** Смена зависимости меняет то, что ваш код перехватывает и передаёт (`httpx2.AsyncClient`, `httpx2.ConnectError`), и меняет способ проверки TLS-сертификатов: `httpx2` проверяет через `truststore` по хранилищу доверия операционной системы, а не по встроенному списку УЦ из certifi. Большинство окружений ничего не заметят; минимальный контейнер без системного хранилища УЦ или частный УЦ, о котором знал только набор certifi, начинает проваливать TLS-рукопожатие. Задайте `SSL_CERT_FILE`/`SSL_CERT_DIR` или передайте клиенту `verify=ssl_context`. **[Руководство по миграции](migration.md#httpx-and-httpx-sse-replaced-by-httpx2)**. + +### Удалено полностью {#removed-outright} + +Каждому пункту посвящён раздел в **[Руководстве по миграции](migration.md)**: + +* **Транспорт WebSocket** с обеих сторон и дополнение `mcp[ws]`. Он никогда не входил в спецификацию MCP. +* API **экспериментальных Tasks** (`mcp.*.experimental`). 2026-07-28 выносит задачи из ядра протокола в официальное расширение ([SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663)), которое этот SDK пока не реализует. +* `mcp.shared.version`, `mcp.shared.progress` и `mcp.shared.session` (с заглушкой `RequestResponder`, которую импортировали аннотации `message_handler` в v1) как пути импорта. (`mcp.types` **не** удалён: он остаётся постоянным псевдонимом отдельного пакета `mcp_types`.) +* Устаревшее написание `streamablehttp_client` и колбэк `get_session_id` у `streamable_http_client` (который теперь отдаёт ровно два потока). +* `McpError`, переименованный в **`MCPError`** с прямым конструктором `(code, message, data)`. +* `MCPServer.get_context()`, `mount_path=`, а также методы-декораторы, ContextVar и словари обработчиков низкоуровневого `Server`. + +## Протокол: от 2025-11-25 к 2026-07-28 {#the-protocol-2025-11-25-to-2026-07-28} + +v2 реализует ревизию 2026-07-28 и обслуживает **обе** ревизии одновременно: одно и то же `streamable_http_app()` (и один и тот же stdio-сервер) отвечает на `initialize` клиента поколения 2025 и на запросы клиента поколения 2026 — ничего не нужно настраивать, переключать флаг или разворачивать отдельно. Обслуживание новой ревизии не оставляет за бортом клиент на старой. Дальше — о том, что меняет сама новая ревизия. + +### Ни рукопожатия, ни сессии {#no-handshake-no-session} + +Клиент 2026-07-28 не открывает подключение, не договаривается и лишь потом говорит. Каждый запрос несёт версию протокола, сведения о клиенте и возможности клиента в `_meta`, а единственный вызов обнаружения, `server/discover`, — обычный запрос, как любой другой. `Client` по умолчанию поступает правильно: один раз пробует `server/discover` и откатывается к рукопожатию `initialize`, если сервер старше. + +По Streamable HTTP на пути 2026 нет `Mcp-Session-Id`, и это главная эксплуатационная новость: **ничто не привязывает современный запрос к воркеру**, так что ответить на него может любая реплика за обычным балансировщиком с round-robin. Две честные оговорки. Ваши клиенты поколения 2025 (сегодня это большинство клиентов) по-прежнему открывают сессии и по-прежнему требуют той же привязки, что требовали на v1; для них ничего не меняется. А единственное, что повтор *многораундового* запроса должен перенести между воркерами, — это его запечатанный `request_state`, ключ для которого по умолчанию создаётся на каждый процесс, поэтому масштабированное развёртывание передаёт `RequestStateSecurity(keys=[...])`. (`stateless_http=True` тут ни при чём: он влияет только на обслуживание клиентов поколения 2025, и трафик 2026 его никогда не читает; если вы уже задали его в v1, ничего не меняется.) + +Клиентская сторона этого — на странице **[Версии протокола](protocol-versions.md)**, чек-лист оператора (список разрешённых Host, ключ `request_state`, уведомления между репликами) — **[Развёртывание и масштабирование](run/deploy.md)**, а история об обоих поколениях сразу — **[Обслуживание клиентов старого поколения](run/legacy-clients.md)**. + +### Сервер не может вызывать клиент: многораундовые запросы {#the-server-cannot-call-the-client-multi-round-trip-requests} + +На 2026-07-28 исчезли все запросы, инициируемые сервером: push-элицитация, сэмплирование, `roots/list`. На подключении 2026 для них нет обратного канала (back-channel), поэтому `ctx.elicit()` и `ctx.session.create_message()` там падают с `NoBackChannelError` (для клиентов старого поколения они по-прежнему работают). + +Замена разворачивает вызов. Инструмент, которому что-то нужно от пользователя, *возвращает* вопрос (`InputRequiredResult`), клиент отвечает на него теми же колбэками, что были всегда, и вызов повторяется с приложенными ответами. `Client` ведёт этот цикл за вас. На сервере вы редко собираете результат сами, потому что это делает **[зависимость](handlers/dependencies.md)**: аннотируйте параметр `Resolve(ask_quantity)`, где `ask_quantity` — обычная функция, которую вы пишете, и SDK спросит тем механизмом, который поддерживает подключение: живым запросом элицитации на сессии старого поколения или многораундовым запросом на 2026. Одно тело инструмента, оба поколения: + +```python title="dual_era.py" hl_lines="24 37-38" +--8<-- "docs_src/legacy_clients/tutorial001.py" +``` + +В этом файле вся идея собрана в одном месте: один сервер, один инструмент на `Resolve`, а также клиент старого поколения и современный клиент, оба получающие свой ответ, — всё в памяти. **[Многораундовые запросы](handlers/multi-round-trip.md)** объясняет механизм (включая `request_state`, который SDK запечатывает и проверяет за вас); **[Элицитация](handlers/elicitation.md)** описывает, как спрашивать. + +!!! warning "Это единственное место, где перенесённый сервер v1 меняет поведение" + Первыми на это натыкаются ваши собственные тесты: `Client(mcp)` по умолчанию согласует 2026-07-28 с вашим + сервером v2, так что инструмент, вызывающий `ctx.elicit()`, падает в тесте, который проходил на v1. Перенесите + вопрос в параметр `Resolve(...)` (переносимо между поколениями) или закрепите тестовый клиент на + `mode="legacy"`, если push-поведение вам действительно нужно. + +### Корневые каталоги, сэмплирование и протокольное логирование объявлены устаревшими; `ping` удалён {#roots-sampling-and-protocol-logging-are-deprecated-ping-is-removed} + +[SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) объявляет устаревшими три *возможности* целиком, на всех версиях протокола: корневые каталоги, сэмплирование и логирование на уровне MCP (`ctx.info()` и ему подобные). Это отдельная ось, не связанная с отсутствующим обратным каналом выше; статус устаревшего — рекомендательный, всё продолжает работать с сессиями поколения 2025, и в передаваемых данных ничего не меняется. Заметите вы `MCPDeprecationWarning` — это `UserWarning`, поэтому он выводится по умолчанию; ожидайте, что первый же `ctx.info(...)` после обновления об этом сообщит. + +С `ping` строже: он удалён из протокола, а не объявлен устаревшим. Так же на 2026-07-28 удалены два отдельных метода устаревших возможностей — `logging/setLevel` и клиентское `notifications/roots/list_changed`, — а уведомления о ходе выполнения теперь идут только от сервера к клиенту. + +Полная таблица, замена для каждого пункта и однострочный фильтр на случай, если нужен тихий лог, пока вы обслуживаете клиенты старого поколения, — на странице **[Устаревшие возможности](deprecated.md)**. + +### Уведомления об изменениях становятся одним потоком {#change-notifications-become-one-stream} + +На 2026-07-28 отдельный поток HTTP GET и `resources/subscribe` заменены на `subscriptions/listen`: клиент открывает один долгоживущий поток и называет виды уведомлений, которые хочет получать. `MCPServer` обслуживает его по умолчанию; публикуете вы через `await ctx.notify_resource_updated(uri)` (а также `notify_tools_changed()` и так далее), middleware может отклонить запрос на прослушивание для конкретного вызывающего, а развёртывания с несколькими репликами подключают общую `SubscriptionBus`. На клиенте поток открывает `async with client.listen(...)`: фильтр передаётся именованными аргументами, обратно приходят типизированные события изменений, а `sub.honored` — подмножество, которое сервер согласился доставлять. + +Публикация и обслуживание — на странице **[Подписки](handlers/subscriptions.md)**, наблюдающая сторона — на **[её клиентской паре](client/subscriptions.md)**, а шина — на странице **[Развёртывание и масштабирование](run/deploy.md)**. + +### Остальное, вкратце {#the-rest-quickly} + +* **Идентификация — необязательные метаданные каждого сообщения.** Ключ `clientInfo` в `_meta` на стороне запроса необязателен (обязательная пара — `protocolVersion` + `clientCapabilities`), а `serverInfo` ушёл из тела результата `server/discover`: вместо этого серверы проставляют его в `_meta` каждого результата поколения 2026 ([spec #3002](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3002)). SDK проставляет всегда; `client.server_info` равен `None`, когда сервер себя не идентифицирует (например, middleware убрал ключ). **[Низкоуровневый Server](advanced/low-level-server.md)** показывает эту отметку в передаваемых данных. +* **Запросы маршрутизируются без разбора тел.** Современные HTTP-запросы несут `Mcp-Method` (а для трёх инструментоподобных вызовов — ещё и `Mcp-Name`); свойство входной схемы инструмента с аннотацией `x-mcp-header` дублируется в заголовок `Mcp-Param-*` и перекрёстно проверяется сервером ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243)). Шлюзы и ограничители частоты могут маршрутизировать по одним заголовкам; правила — в **[Руководстве по миграции](migration.md#servers-validate-mcp-param-headers-against-the-request-body-sep-2243)**. +* **Результаты несут подсказки кэширования.** Результаты списков и чтения объявляют `ttlMs` и `cacheScope` ([SEP-2549](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549)); вы задаёте их по методам через `cache_hints=`, а `Client` учитывает их встроенным кэшем ответов. Сервер, не отправляющий подсказок (любой сервер до 2026), видит идентичный, некэшированный трафик. **[Подсказки кэширования](client/caching.md)**. +* **Расширения поддерживаются полноценно.** Серверы и клиенты объявляют необязательные наборы возможностей под идентификаторами в обратной DNS-нотации ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)); встроенное расширение `Apps` (MCP Apps) служит эталоном. **[Расширения](advanced/extensions.md)** и **[MCP Apps](advanced/apps.md)**. +* **Коды ошибок стандартизированы.** Отсутствующий ресурс — это `-32602` с URI в `error.data`, а новые коды, зарезервированные спецификацией, появляются как `-32020` (несовпадение заголовка), `-32021` (отсутствует обязательная возможность) и `-32022` (неподдерживаемая версия протокола). **[Устранение неполадок](troubleshooting.md)** построено по точным сообщениям. +* **Авторизацию стало сложнее использовать неправильно.** Клиент проверяет `iss`, возвращаемый вместе с кодом авторизации ([RFC 9207](https://datatracker.ietf.org/doc/html/rfc9207); ваш `callback_handler` теперь возвращает `AuthorizationCodeResult`), отправляет `application_type` при регистрации и никогда не воспроизводит учётные данные на другом сервере авторизации. Новое в корпоративном углу: поток подтверждения идентичности из [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990). Все изменения OAuth перечислены в **[Руководстве по миграции](migration.md)**; страницы — **[OAuth для клиентов](client/oauth-clients.md)** и **[Подтверждение идентичности](client/identity-assertion.md)**. +* **Каждый сервер трассируется.** OpenTelemetry включён по умолчанию как middleware: каждый запрос получает серверный спан, и это ничего не стоит, пока процесс не настроит экспортёр. Когда на SDK работают обе стороны, клиент также распространяет контекст трассировки W3C в `_meta`, так что трассы соединяются. **[OpenTelemetry](run/opentelemetry.md)**. + +## Переходите с v1? {#upgrading-from-v1} + +* **[Руководство по миграции](migration.md)** — полный и точный список того, что менять; эта страница объясняла зачем. +* **v1.x никуда не денется.** Она переходит на поддержку, продолжает получать критические исправления и патчи безопасности, и ничто в выпуске спецификации 2026-07-28 её не ломает; её документация живёт по адресу [/v1/](https://py.sdk.modelcontextprotocol.io/v1/). Если вы публикуете библиотеку, зависящую от `mcp`, и не готовы мигрировать, оставьте верхнюю границу (например, `mcp>=1.28,<2`), чтобы незакреплённое разрешение зависимостей оставалось на 1.x. +* Что-то сырое, непонятное или сломанное? **[Оставьте отзыв о v2](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml)** — читают всё. diff --git a/i18n/tr/glossary.json b/i18n/tr/glossary.json new file mode 100644 index 0000000000..62ea658345 --- /dev/null +++ b/i18n/tr/glossary.json @@ -0,0 +1,258 @@ +{ + "keep": [ + "MCP", + "Model Context Protocol", + "MCPServer", + "FastMCP", + "ClientSession", + "Context", + "ctx", + "stdio", + "Streamable HTTP", + "SSE", + "JSON-RPC", + "JSON", + "OAuth", + "PKCE", + "JWT", + "CIMD", + "HTTP", + "HTTPS", + "TLS", + "CORS", + "URI", + "URL", + "ASGI", + "WebSocket", + "API", + "SDK", + "CLI", + "IDE", + "LLM", + "SEP", + "RFC", + "Python", + "TypeScript", + "Node.js", + "PyPI", + "Pydantic", + "Starlette", + "FastAPI", + "uvicorn", + "httpx", + "anyio", + "asyncio", + "trio", + "pytest", + "OpenTelemetry", + "Inspector", + "Claude", + "GitHub", + "VS Code", + "Windows", + "macOS", + "Linux", + "llms.txt", + "2026-07-28", + "2025-11-25", + "2025-06-18", + "2025-03-26" + ], + "terms": [ + { + "source": "tool", + "target": "araç", + "note": "MCP protocol noun (a server exposes tools): araç / araçlar, aracı in the accusative. Standard rendering. Wire identifiers such as `tools/call` and the `@mcp.tool()` decorator are code and stay Latin; the Inspector's **Tools** tab is a UI label and stays English." + }, + { + "source": "resource", + "target": "kaynak", + "note": "MCP protocol noun (data a server exposes for reading), and also the general noun (a pool acquired in a lifespan is still kaynak). Standard rendering; context keeps it apart from kaynak kodu (source code). `resources/read` and `@mcp.resource()` are code." + }, + { + "source": "prompt", + "target": "prompt", + "note": "The MCP feature (a reusable message template a server exposes) and the everyday LLM sense; kept in English as Turkish AI writing does, lower-case, suffixed with an apostrophe: prompt'u, prompt'a, prompt'lar. Not komut istemi (a shell prompt). `prompts/get` and `@mcp.prompt()` are code. Provisional pending native review; istem is the native alternative to weigh." + }, + { + "source": "sampling", + "target": "örnekleme", + "note": "The (deprecated) client feature that lets a server borrow the client's model for a completion. Gloss the English on first use per page — örnekleme (sampling) — so the reader maps it to `sampling/createMessage`, which is code. Provisional pending native review." + }, + { + "source": "roots", + "target": "kök dizinler", + "note": "The (deprecated) client feature listing the workspace directories a client exposes. Descriptive rendering with the English glossed on first use per page — kök dizinler (roots); a single root is kök dizin. `roots/list` and the `Root` type are code and stay Latin. Provisional pending native review; keeping roots in English is the open alternative." + }, + { + "source": "elicitation", + "target": "elicitation", + "note": "The mechanism by which a server asks the user a question through the client mid-request. No Turkish term exists, so the English word is kept, lower-case, suffixed with an apostrophe (elicitation'ı, elicitation'a, elicitation'lar), and may carry a Turkish explanation on its first appearance per page — elicitation (kullanıcıdan bilgi isteme). `elicitation/create`, `ctx.elicit()` and the `Elicit` class are code. Provisional pending native review." + }, + { + "source": "capability", + "target": "yetenek", + "note": "A negotiated protocol capability (what a client or server declared it supports): sunucu yetenekleri, \"capability negotiation\" → yetenek anlaşması. Not özellik, which is a feature. The `capabilities` field and keys such as `sampling.tools` stay Latin. Provisional pending native review." + }, + { + "source": "transport", + "target": "aktarım", + "note": "The connection mechanism (\"every standard transport\" → tüm standart aktarımlar; \"transport layer\" → aktarım katmanı). Not taşıma or ulaşım, which are physical transportation. The transport names stdio, Streamable HTTP and SSE stay in English: stdio aktarımı, Streamable HTTP aktarımı. Provisional pending native review." + }, + { + "source": "session", + "target": "oturum", + "note": "An MCP session (the negotiated connection state): oturum, oturum kimliği for \"session ID\". Standard rendering. `session` objects, `ClientSession` and `ServerSession` are code and stay Latin." + }, + { + "source": "handler", + "target": "işleyici", + "note": "The tool, resource or prompt function you register, and request handlers generally (nav section \"Inside your handler\" → İşleyicinin içinde): işleyici / işleyiciler. Provisional pending native review; keeping handler in English is the alternative many Turkish developers use in speech." + }, + { + "source": "dependency", + "target": "bağımlılık", + "note": "Both package dependencies and the SDK's parameter-injection feature (the \"Dependencies\" page → Bağımlılıklar; \"dependency injection\" → bağımlılık enjeksiyonu). Standard rendering. The `Resolve` marker class stays Latin." + }, + { + "source": "resolver", + "target": "çözümleyici", + "note": "The plain function attached to a parameter with `Resolve(...)` that computes or asks for its value: çözümleyici, çözümleyici fonksiyon where the kind needs naming. Provisional pending native review; keeping resolver in English is the alternative. The `Resolve` class stays Latin." + }, + { + "source": "client", + "target": "istemci", + "note": "An MCP client, and the client side of a connection: istemci. Standard rendering; müşteri is a customer and is only right where the English says customer. The `Client` class and the `mcp.client` module are code and stay Latin (`Client`'ı, `Client`'a)." + }, + { + "source": "server", + "target": "sunucu", + "note": "An MCP server (the program you build): sunucu, MCP sunucusu. Standard rendering. The `MCPServer`, `Server` and `ServerSession` classes are code and stay Latin." + }, + { + "source": "host", + "target": "host", + "note": "The MCP host — the application the user talks to (Claude Desktop, an IDE, an agent runtime) — kept in English as Turkish developers say it, lower-case, suffixed with an apostrophe: host'u, host'a, host'lar; host uygulama where the kind helps. ana bilgisayar is the network-machine sense (ana bilgisayar adı for a hostname is fine) and never names the MCP host. Provisional pending native review." + }, + { + "source": "context", + "target": "bağlam", + "note": "The generic lower-case word (\"provide context to LLMs\" → LLM'lere bağlam sağlamak). The capitalised `Context` is the SDK object injected as `ctx`; it is on the keep list and stays Latin in prose (\"The Context\" page title → Context nesnesi). Standard rendering." + }, + { + "source": "request", + "target": "istek", + "note": "A JSON-RPC or HTTP request (\"the initialize request\" → initialize isteği; HTTP isteği). Standard term; not talep, and not the English request in prose. `Request` types in code font stay Latin." + }, + { + "source": "response", + "target": "yanıt", + "note": "A JSON-RPC or HTTP response (HTTP yanıtı, yanıt gövdesi). Pinned over cevap so one word is used throughout; never the English response in prose. `Response` types in code font stay Latin. Provisional pending native review." + }, + { + "source": "notification", + "target": "bildirim", + "note": "A JSON-RPC notification (a message that expects no response): bildirim göndermek, ilerleme bildirimi. Standard term. Method strings such as `notifications/tools/list_changed` stay Latin." + }, + { + "source": "callback", + "target": "callback", + "note": "Client callbacks and OAuth redirect callbacks alike (the \"Client callbacks\" page → İstemci callback'leri): kept in English, lower-case, suffixed with an apostrophe — callback'i, callback'e, callback'ler. Provisional pending native review; geri çağırma (işlevi) is the native alternative and may serve as a one-time gloss. Parameter names such as `sampling_callback` stay Latin." + }, + { + "source": "decorator", + "target": "dekoratör", + "note": "The Python decorators the SDK is built on: dekoratör / dekoratörler. `@mcp.tool()` and its siblings are code and stay untouched. Standard rendering." + }, + { + "source": "type hint", + "target": "tür ipucu", + "note": "Python type hints (\"from your type hints\" → tür ipuçlarınızdan; plural tür ipuçları). Pinned with tür, matching veri türü and dönüş türü; do not alternate with tip ipucu on the same site. Provisional pending native review." + }, + { + "source": "exception", + "target": "istisna", + "note": "A raised Python exception: istisna; \"raises an exception\" → bir istisna fırlatır. Pinned over özel durum. Exception class names stay Latin. Provisional pending native review." + }, + { + "source": "async", + "target": "asenkron", + "note": "The prose adjective (\"the async runtime\" → asenkron çalışma zamanı, \"an async callback\" → asenkron callback); pinned over eşzamansız. The `async` and `await` keywords in code font stay Latin. Provisional pending native review." + }, + { + "source": "by default", + "target": "varsayılan olarak", + "note": "\"By default\" → varsayılan olarak; \"the default value\" → varsayılan değer; \"defaults to X\" → varsayılan olarak X. Standard rendering, pinned over öntanımlı." + }, + { + "source": "authorization", + "target": "yetkilendirme", + "note": "Security sense: yetkilendirme (yetkilendirme sunucusu, yetkilendirme kodu), distinct from authentication → kimlik doğrulama. The `Authorization` header and code identifiers stay Latin. Standard rendering." + }, + { + "source": "deploy", + "target": "dağıtım", + "note": "The noun is dağıtım and the verb dağıtmak (\"Deploy & scale\" → Dağıtım ve ölçekleme; \"deploy it behind a proxy\" → bir vekil sunucunun arkasına dağıtın). Never deploylamak; deploy etmek is speech, not documentation. Provisional pending native review." + }, + { + "source": "lifespan", + "target": "lifespan", + "note": "The server's startup/shutdown scope (the \"Lifespan\" page, as in the ASGI lifespan); kept in English so the prose matches the `lifespan=` parameter, lower-case, suffixed with an apostrophe (lifespan'i, lifespan'e). May carry a Turkish explanation on first appearance per page — lifespan (yaşam döngüsü). Provisional pending native review; translating it as yaşam döngüsü throughout is the open alternative." + }, + { + "source": "back-channel", + "target": "geri kanal", + "note": "This documentation's term for the server calling back into the client during a request, which exists only on legacy connections. Gloss the English on first use per page — geri kanal (back-channel) — so the reader can connect it to `NoBackChannelError`, which is code. Provisional pending native review." + }, + { + "source": "multi-round-trip", + "target": "çok turlu", + "note": "The 2026-07-28 request pattern: \"Multi-round-trip requests\" → Çok turlu istekler, glossed on first use per page — çok turlu istekler (multi-round-trip). A single \"round trip\" is tur or gidiş-dönüş by context. The abbreviation MRTR stays Latin. Provisional coinage pending native review; çok gidiş-dönüşlü is the literal alternative." + }, + { + "source": "deprecated", + "target": "kullanım dışı", + "note": "Advisory status: still works, scheduled for removal later — kullanım dışı / kullanım dışı bırakılmış (\"Deprecated features\" → Kullanım dışı özellikler; \"deprecation warning\" → kullanım dışı bırakma uyarısı; \"X is deprecated\" → X kullanım dışı bırakıldı). \"Removed\" is a different word (kaldırıldı); the corpus contrasts the two, so never render deprecated as kaldırıldı. The `MCPDeprecationWarning` class stays Latin. Provisional pending native review." + }, + { + "source": "legacy", + "target": "eski nesil", + "note": "\"A legacy connection / client\" = one negotiated at spec version 2025-11-25 or earlier → eski nesil bağlantı, eski nesil istemci (the page \"Serving legacy clients\" → Eski nesil istemcilere hizmet verme). Pairs with \"era\" → nesil and keeps kullanım dışı free for \"deprecated\". Provisional pending native review." + }, + { + "source": "era", + "target": "nesil", + "note": "\"Protocol era\" (\"a 2025-era client\", \"whatever era the client speaks\") → protokol nesli, 2025 neslinden bir istemci. Not the literal çağ or dönem. Provisional pending native review." + }, + { + "source": "handshake", + "target": "el sıkışma", + "note": "The initialization handshake (\"the classic handshake\" → klasik el sıkışma; başlatma el sıkışması). el sıkışma is the standard Turkish networking term (as in TLS el sıkışması), so the literal word is correct here. Standard rendering." + }, + { + "source": "middleware", + "target": "middleware", + "note": "Kept in English, lower-case in running text, suffixed with an apostrophe (middleware'i, middleware'ler); the \"Middleware\" page title stays Middleware. May carry the one-time explanation middleware (ara katman). Provisional pending native review; ara yazılım is the formal alternative." + }, + { + "source": "Get started", + "target": "Başlarken", + "note": "The nav section that opens the guide, and the title of its index page. \"First steps\" is a separate page inside that section (İlk adımlar), so the two need distinct renderings or the sidebar shows the same title twice. Provisional pending native review." + }, + { + "source": "First steps", + "target": "İlk adımlar", + "note": "The tutorial page inside the \"Get started\" section, spelled with the dotted capital İ; never reuse this rendering for the section itself (see that entry). Provisional pending native review." + }, + { + "source": "Recap", + "target": "Özet", + "note": "Recurring section heading that closes most pages; one rendering everywhere, not Özet on some pages and Toparlayalım or Sonuç on others. Provisional pending native review." + }, + { + "source": "Try it", + "target": "Deneyin", + "note": "Recurring section heading above a runnable example; one rendering everywhere, not Deneyin on some pages and Kendiniz deneyin or Deneme on others. Provisional pending native review." + } + ] +} diff --git a/i18n/tr/instructions.md b/i18n/tr/instructions.md new file mode 100644 index 0000000000..8884720cbc --- /dev/null +++ b/i18n/tr/instructions.md @@ -0,0 +1,170 @@ +# Turkish (tr) — translation instructions + +Target language: Turkish (Türkçe), directory and URL code `tr`, page language +tag `tr`. This file is sent verbatim with every translation request for this +language, on top of the shared rules in `../general-prompt.md`. The termbase +in `glossary.json` is sent alongside it and wins any terminology conflict with +this file. + +## 1. Register + +Write the clear, instructional Turkish of good developer documentation: polite +but not ceremonial, addressed to a colleague. + +- The reader is siz, almost always left implicit. Steps and instructions take + the polite-plural imperative in -in / -ın / -un / -ün by vowel harmony + (çalıştırın, kurun, ekleyin, açın): "Install the SDK, then run the server" → + SDK'yı kurun, ardından sunucuyu çalıştırın. Never the over-formal -iniz + (çalıştırınız), never the bare sen imperative (çalıştır), never a mix. +- Statements about what code does use the aorist: "The SDK does the rest" → + Gerisini SDK halleder; "You can pass a schema" → Bir şema geçirebilirsiniz. + Not the bureaucratic -mektedir / -maktadır, not a needless -ecektir. "Your + server" is usually just sunucu; sunucunuz only where ownership is the point; + siz as an explicit subject only when the sentence contrasts actors. +- Headings, table headers and content-tab labels are noun phrases in sentence + case, typically the -ma / -me verbal noun, with no final punctuation: + "Running your server" → Sunucunuzu çalıştırma, "Handling errors" → Hataları + ele alma, "Inside your handler" → İşleyicinin içinde. Not an imperative + (Sunucuyu çalıştırın) and not a question unless the English heading is one. +- A first-person-plural aside (bir bakalım) is fine where the English says + "let's", not for plain instructions. One page, one register: drifting + between -in and -iniz, or into -mektedir, is wrong even if each sentence is + fine alone. + +## 2. Voice + +The English is warm, direct and confident: short sentences, second person, the +occasional one-line payoff ("That's the whole API."). Rewrite it as natural +Turkish, as if the page had been written in Turkish, keeping every claim exact. + +- Follow Turkish word order; split a long English sentence into two rather + than mirroring its clause chain, and use everyday connectives (Ancak, Yani, + Bu yüzden) where they help. Never merge, drop or reorder the claims. +- Use concrete verbs (çalıştırın, geçirin, döndürür, bildirir, engeller) and + the active voice: "The tool is called by the model" → Aracı model çağırır, + not Araç model tarafından çağrılır. Keep the payoff lines short: "That's a + complete MCP server." → Bu, eksiksiz bir MCP sunucusu. +- Avoid officialese: -mektedir chains, gerçekleştirmek + noun (çalıştırma + işlemini gerçekleştirin → çalıştırın), söz konusu, işbu, tarafınızca, and + bulunmak as padding (yer almaktadır → var). Avoid word-for-word English too: + bir before every noun, o / onlar pronoun crutches, possessive chains + (sunucunuzun aracının şemasının), sahip olmak for every "has" (Sunucu üç + araca sahiptir → Sunucuda üç araç var). +- No hedging the English does not have ("don't" is kullanmayın, not + kaçınmanız iyi olabilir) — and no over-correction either: no sen, no chat + tone (hadi, süper, falan), no smileys, no Turkish verb endings on English + words (deploylamak — see §5). + +Example — English: "You don't construct it and you don't configure it. You ask +for it." + +- Not this (officialese): Söz konusu nesnenin oluşturulması ve yapılandırılması + tarafınızca gerçekleştirilmemektedir; yalnızca talep edilmesi gerekmektedir. +- Not this either (sen, chatty): Onu sen oluşturmuyorsun, ayarlamıyorsun da. + İstiyorsun, o kadar. +- This: Onu siz oluşturmazsınız, yapılandırmazsınız da. Yalnızca istersiniz. + +## 3. Humour and idioms + +- Translate the intent of a joke, aside or idiom, never its words: recast it + as a short, natural Turkish sentence in the same register, or keep it brief + where it carries nothing. Never drop the technical content around it. +- Recurring English tags get fixed renderings: "**[X](…)** has the whole + story" / "The whole story is in **[X](…)**" → Ayrıntıların tamamı + **[X](…)** sayfasında.; "That's the whole API." / "That's the whole + protocol." → API'nin tamamı bu. / Protokolün tamamı bu.; "That's it. It's + just Python." → Hepsi bu. Bildiğiniz Python.; "You get `3` back. ✨" → + Geriye `3` döner. ✨ +- Idioms take the plain meaning, not the picture: "Out of the box the app + answers **only** requests addressed to localhost." → Varsayılan olarak + uygulama **yalnızca** localhost'a gönderilen istekleri yanıtlar. — not + kutudan çıktığı gibi; "under the hood" → arka planda, not kaputun altında; + "on the wire" → iletilen veride / ağ üzerinde, never kabloda. +- Keep an exclamation mark only where the English is a genuine exclamation of + encouragement — never after a warning or a step, never doubled, never in a + heading. Reproduce an emoji only where the English has one, in the same + place (two payoff lines end in ✨); never add one. + +## 4. Typography + +- Quotation marks are the double quotes the source uses ("…"), nested quotes + single ('…'); no «…», no „…“. When the English quotes a word the example + code prints or a UI label, it stays exactly as emitted: "Tools" sekmesi. +- Suffixes on Latin-script words. A proper name, keep-list term, acronym, + number, kept English word or inline code span takes its suffix after an + apostrophe, following vowel harmony for the word **as pronounced**: + - English words and names by their English sound: Python'ı, Python'da; + Claude'u, Claude'a; GitHub'ı; `Client`'ı, `Client`'a, `Client`'ta; + `Context`'i, `Context`'e; token'ı, token'lar; callback'i, callback'ler; + prompt'u, prompt'lar; localhost'a, localhost'ta. + - Acronyms letter by letter in Turkish: API'yi, API'ye, API'nin; SDK'yı, + SDK'nın, SDK'lar; MCP'yi, MCP'de; HTTP'nin; URL'yi, URL'ler; LLM'lere; + SSE'yi — except acronyms read as a word: JSON'u, JSON'a, JSON'da. + - After a voiceless final sound (p, ç, t, k, f, h, s, ş) the suffix + consonant hardens (`dict`'te, stdout'ta, `Client`'tan); a vowel-final word + takes the buffer letter (stdio'yu, stdio'da, anyio'nun). + - On a code span the apostrophe and suffix sit directly after the closing + backtick, never inside it, never after a space: `call_tool()`'u çağırın, + `ctx`'i isteyin. Suffixes stack the normal way: token'ları, prompt'larda. + - Never respell, re-case or hyphenate a term to suit the suffix. Where the + pronunciation is unclear (symbols, flags, paths, mixed digits), let a + Turkish noun carry the suffix: `--port` seçeneğini, `server.py` dosyasını, + `greeting://{name}` kaynağını, 8000 numaralı port. +- Dotted and dotless i. Turkish words follow Turkish casing — İstemci, İlk + adımlar; the capital of i is İ, the lowercase of I is ı. Words that stay in + English keep their letters untouched in every position: Inspector (never + İnspector), API (never APİ), `id`. Never re-case an English word or an + identifier yourself; a heading that starts with one leaves it as spelled. +- Sentence case everywhere: headings, admonition titles, tab labels and table + headers capitalise the first word and proper nouns only (Sunucunuzu + çalıştırma, not Sunucunuzu Çalıştırma); language names stay capitalised + (İngilizce). +- Digits stay ASCII. Protocol revision strings such as `2026-07-28` are + identifiers, copied byte for byte — never 28.07.2026, never 28 Temmuz 2026. + Version numbers, ports, status codes, RFC and SEP numbers are copied exactly. +- Prose quantities take the decimal comma only when nothing but the separator + changes (2.5 seconds → 2,5 saniye); when in doubt keep the number as written. + The percent sign precedes the number (%100); a unit follows a space (100 MB). +- e.g. → örneğin; i.e. → yani; etc. → vb.; "&" → ve. Emphasis lands on the + same words the source emphasises; a bolded "**not**" becomes a bolded değil + or negated verb (**does not** raise → hata **fırlatmaz**). Kept English words + are set in plain type, no italics or quotes. An em-dash aside usually becomes + a comma pair, parentheses or its own sentence; colons before lists stay. + +## 5. Terminology pointer + +The glossary (`glossary.json`) is injected separately and overrides this file +on every term it covers; each entry marks its choice as standard or provisional +and says whether it takes a first-use gloss. Its renderings assume: + +- Identifiers stay in Latin script exactly as written: class, function, + method, parameter, module and header names, protocol method strings such as + `tools/call`, and everything in code font. So do the keep-list terms, + acronyms and product names, which drop the English plural "s" and take a + Turkish one where needed: "the SDKs" → SDK'lar. +- Two tracks, and the glossary decides per term. Translate where Turkish + developers use the Turkish word: sunucu, istemci, araç, kaynak, istek, + yanıt, bildirim, oturum, bağımlılık, işleyici, bağlam, şema, istisna, + yetkilendirme, kimlik doğrulama, varsayılan, sürüm, dağıtım. Keep the English + word — lower-case, plain type, suffixed with an apostrophe — where that is + what Turkish developers say: token, callback, middleware, endpoint, host, + prompt, lifespan, commit, log. Nouns are borrowed, verbs are not: commit + etmek, dağıtmak for "deploy" — never commitlemek, deploylamak. +- Text quoted from what the example code prints or displays — an output line, + a log message, an Inspector tab or button label — stays exactly as the code + emits it (usually English), in or out of code font; never translate it. +- First-use gloss, both ways, as the glossary marks it: a translated concept + carries the English once per page — örnekleme (sampling) — and a kept + English one may carry a Turkish explanation once — elicitation (kullanıcıdan + bilgi isteme). A glossary word used as an identifier in code font stays as + written: "the `sampling` capability" → `sampling` yeteneği. +- One rendering per term per page: the glossary target, every time. Do not + alternate yanıt and cevap, or istemci and client, for the same source term. + +## 6. Provisional note + +Every decision in this file, and every entry in `glossary.json`, is +provisional pending review by native Turkish-speaking developers. To propose a +change — a better rendering, a suffix rule that produces wrong forms, a term +that should switch tracks — edit this file or `glossary.json` in a pull +request; never edit the generated `pages/` or `notices.md`. diff --git a/i18n/tr/notices.md b/i18n/tr/notices.md new file mode 100644 index 0000000000..dd68ba2cdb --- /dev/null +++ b/i18n/tr/notices.md @@ -0,0 +1,20 @@ +--- +translation: + sections: [aff1b3e872b7876a, 4d80558ad052d586, 0bb81f1e62062d26, d5c35dcec50156bc] + tool: 1 +--- +# Çeviri bildirimleri {#translation-notices} + +Çevrilmiş bir dokümantasyon sitesinin her sayfasının başında bu notlardan biri yer alır. + +## Makine çevirisi {#translated} + +Bu sayfa İngilizce dokümantasyondan otomatik olarak çevrildi; esas alınması gereken sürüm [İngilizce sayfadır](ENGLISH_PAGE). Yanlış görünen bir şey varsa, nasıl bildireceğinizi [Çeviriler](TRANSLATIONS_PAGE) sayfası açıklar. + +## İngilizce sayfanın gerisinde kalmış çeviri {#outdated} + +Bu çeviri yapıldıktan sonra İngilizce sayfa değişti; bu yüzden bazı bölümleri güncel olmayabilir. Şüpheye düştüğünüzde [İngilizce sayfayı](ENGLISH_PAGE) okuyun; çevrilmiş dokümantasyonun nasıl işlediğini [Çeviriler](TRANSLATIONS_PAGE) sayfası açıklar. + +## İngilizce gösteriliyor {#english} + +Bu sayfanın güncel bir çevirisi yok; bu yüzden onu İngilizce okuyorsunuz. Çevrilmiş dokümantasyonun nasıl işlediğini [Çeviriler](TRANSLATIONS_PAGE) sayfası açıklar. diff --git a/i18n/tr/pages/advanced/apps.md b/i18n/tr/pages/advanced/apps.md new file mode 100644 index 0000000000..4f2582c9d0 --- /dev/null +++ b/i18n/tr/pages/advanced/apps.md @@ -0,0 +1,123 @@ +--- +translation: + sections: [0355618e5f4d5fe4, 1821eaf50f2d0b64, 82e0b28ebd3abf5a, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487] + tool: 1 +--- +# MCP Apps {#mcp-apps} + +Bir **MCP App**, yüzü olan bir araçtır: araç, verisinin yanında host'un etkileşimli bir yüzey olarak çizdiği bir HTML belgesine de işaret eder. + +İki parça, her zaman iki parça: + +1. İşi yapan ve veri döndüren **bir araç**, tıpkı diğer araçlar gibi. +2. Host'un onun için gösterdiği HTML'i içeren **bir `ui://` kaynağı**. + +Araç, kaynağa işaret eden bir `_meta.ui.resourceUri` referansı taşır. Host onu `resources/read` ile getirir, **korumalı (sandboxed) bir iframe** içinde çizer ve aracın sonucunu `postMessage` aracılığıyla bu iframe'e iter. Sunucu hiçbir `ui/*` mesajı göndermez ve almaz: bu trafik host ile iframe arasındadır. Siz bir araç ve bir HTML belgesi sunarsınız; gösteriyi host sahneler. + +SDK bunu yerleşik `Apps` uzantısı (`io.modelcontextprotocol/ui`) olarak sunar. [Uzantılar](extensions.md) size yeniyse önce o sayfaya göz atın. Bir dakika, sonra geri dönün. + +## Yüzü olan bir saat {#a-clock-with-a-face} + +```python title="server.py" hl_lines="19 22 30 32" +--8<-- "docs_src/apps/tutorial001.py" +``` + +Dört hamle: + +* `Apps()`: tek bir örnek, UI'ya bağlı araçlarınızı ve onların kaynaklarını tutar. +* `@apps.tool(resource_uri="ui://clock/app.html")`: sıradan bir araç, artı `_meta.ui.resourceUri` damgası. `@mcp.tool()`'un kabul ettiği her şey (name, title, description, ...) aynen geçer. +* `apps.add_html_resource("ui://clock/app.html", CLOCK_HTML)`: eşleşen kaynak, `text/html;profile=mcp-app` olarak sunulur. Bir host'a "bu bir uygulama, çiz" diyen şey tam olarak bu MIME türüdür. +* `MCPServer("clock", extensions=[apps])`: katılımı açın. Sunucu artık `capabilities.extensions` altında `io.modelcontextprotocol/ui` duyurur. + +HTML'in kendisi host'un `postMessage`'ını dinler ve sonucu gösterir. Gerçek uygulamalar için HTML'inizin içinde resmi [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps) tarayıcı SDK'sını kullanın. Ham mesaj olayları yerine size `ontoolresult`, `callServerTool`, `getHostContext` ve `onhostcontextchanged` verir. + +## Zarifçe geri çekilme {#graceful-degradation} + +Her istemci uygulamaları çizmez. Şartname bunun sizin için ne anlama geldiğini açıkça söyler: + +> UI mevcut olsa bile araçlar anlamlı bir `content` dizisi **döndürmek ZORUNDADIR**. + +Model `content`'i okur; iframe insanlar içindir. UI destekli bir host yine de metin sonucunu modele iletir, yalnızca metin destekleyen bir istemci ise *sadece* onu alır. Yani kanonik desen tek araç, iki yanıttır. `get_time`'a bir daha bakın: + +```python title="server.py" hl_lines="23-27" +--8<-- "docs_src/apps/tutorial001.py" +``` + +`client_supports_apps(ctx)` yalnızca istemci `io.modelcontextprotocol/ui` uzantısını beyan ettiğinde **ve** `mimeTypes` ayarlarında `text/html;profile=mcp-app`'i listelediğinde `True` olur. Alan zorunludur, bu yüzden onu atlayan bir istemci sayılmaz. Aynı dosyadaki `main()` tam olarak bunu beyan eder: anlaşmanın istemci tarafı, ve zengin yanıt geri gelir. + +!!! warning + Tek içerik olarak asla `"[Rendered UI]"` gibi bir yer tutucu döndürmeyin. Yedek metin işe yaramazsa araç, yalnızca metin destekleyen her istemci için ve modelin kendisi için işe yaramaz. O cümleyi yazın. + +## iframe'i kilitleme {#locking-the-iframe-down} + +Güvenlik metaverisini kaynak tarafı taşır: iframe'in neleri yükleyebileceği, hangi tarayıcı izinlerini istediği, nasıl çerçevelenmek istediği: + +```python title="server.py" hl_lines="9 19-22" +--8<-- "docs_src/apps/tutorial002.py" +``` + +`csp` ve `permissions` sunucu davranışı değil, **host'a yapılan isteklerdir**. Host, iframe'in Content-Security-Policy ve Permissions-Policy değerlerini bunlardan oluşturur ve reddedebilir. İznin verildiğini varsaymak yerine JS kodunuzda özellik algılaması yapın. + +`ResourceCsp`, alan alan (Python adı, iletilen verideki anahtar, host'un onunla ne yaptığı): + +| Python | İletilen veri (`_meta.ui.csp`) | Denetlediği | +|---|---|---| +| `connect_domains` | `connectDomains` | `connect-src`: `fetch`/XHR nereye gidebilir | +| `resource_domains` | `resourceDomains` | `img-src`, `style-src`, ...: statik varlıklar | +| `frame_domains` | `frameDomains` | `frame-src`: iç içe iframe'ler | +| `base_uri_domains` | `baseUriDomains` | `base-uri`: `` nereye işaret edebilir | + +`ResourcePermissions`: her alan iframe için bir tarayıcı izni ister. + +| Python | İletilen veri (`_meta.ui.permissions`) | +|---|---| +| `camera` | `camera` | +| `microphone` | `microphone` | +| `geolocation` | `geolocation` | +| `clipboard_write` | `clipboardWrite` | + +!!! note + CSP ve izinler **kaynak** üzerinde yaşar, asla araç üzerinde değil. Şartnamenin araç metaverisinde bunlar için bir yer yoktur ve host'lar orada onları yok sayar. SDK bu hatayı ifade edilemez kılar: `@apps.tool()`'un `csp` parametresi yoktur. + +### Görünürlük {#visibility} + +Bir araçtaki `visibility=["app"]`, "bu, model için değil iframe için var" der: + +* `"model"`: model onu çağırabilir. +* `"app"`: iframe onu çağırabilir (`callServerTool` aracılığıyla). +* Belirtilmezse: ikisi de, varsayılan budur. + +Filtreleme **host'un** işidir. Sunucu yalnızca uygulamaya özel araçları `tools/list` içinde diğerleri gibi listeler; host onları modelden gizler. Sunucu tarafında filtrelemeyin. + +## SDK'nın uyguladığı kurallar {#the-rules-the-sdk-enforces} + +Bunların hepsi üretimde değil, başlangıçta hata verir: + +* `ui://...` olmayan bir `resource_uri` veya kaynak URI'si, dekoratör/kayıt anında bir `ValueError`'dır. +* **Eşleşen kayıtlı bir kaynağı olmayan** bir URI'ye bağlanmış araç, `MCPServer(extensions=[apps])` uzantıyı tükettiğinde bir `ValueError`'dır. `resources/read`'de 404 dönen bir HTML duyuran araç bir yanlış yapılandırmadır, bu yüzden oluşturmayı reddeder. +* `@apps.tool()` üzerinde `meta={"ui": ...}` bir `ValueError`'dır. `_meta["ui"]` dekoratöre aittir; bunu `resource_uri=` ve `visibility=` ile söyleyin. Diğer `meta=` anahtarları yanına sorunsuzca birleşir. + +Bugün ne TypeScript ext-apps SDK'sı ne de FastMCP bunların herhangi birini yakalar; bir host'tan önce sizin öğrenmenizi tercih ederiz. + +## Satır içi HTML'in ötesi {#beyond-inline-html} + +`add_html_resource` yaygın durumu karşılar: bir HTML dizesi. Bunun dışındaki her şey için (diskteki HTML veya üretilen içerik) kaynağı kendiniz oluşturup teslim edin: + +```python title="server.py" hl_lines="12 18" +--8<-- "docs_src/apps/tutorial003.py" +``` + +`add_resource`, kaynak açıkça bir MIME türü belirtmediğinde `text/html;profile=mcp-app` MIME türünü doldurur ve açık bir uyuşmazlığı reddeder: başka herhangi bir MIME türü altındaki `ui://` kaynağını hiçbir host çizmez. + +!!! tip + Hâlâ kullanım dışı bırakılmış düz `_meta["ui/resourceUri"]` anahtarını okuyan GA öncesi bir host'u mu hedefliyorsunuz? Kendiniz birleştirin: + `@apps.tool(resource_uri="ui://x", meta={"ui/resourceUri": "ui://x"})`. + İç içe `ui` nesnesi şartnamedeki biçimdir; düz anahtar kaldırılma yolunda. + +## Çalışırken görün {#see-it-run} + +`examples/stories/` içindeki `apps` hikâyesi, bu sayfanın çalıştırılabilir bir çift hâlidir: UI'ya bağlı bir saat aracı olan bir sunucu ve Apps anlaşmasını yapan, aracın `_meta.ui.resourceUri` değerini okuyan, HTML'i getiren ve aracı çağıran bir istemci. + +```bash +uv run python -m stories.apps.client +``` diff --git a/i18n/tr/pages/advanced/extensions.md b/i18n/tr/pages/advanced/extensions.md new file mode 100644 index 0000000000..0403a29f9e --- /dev/null +++ b/i18n/tr/pages/advanced/extensions.md @@ -0,0 +1,172 @@ +--- +translation: + sections: [05891e7cc1938a13, b3c01a6af28c51ee, 7ffc91f5e38bdfe0, 717d3f235a8333a7, f471a13b2fe5d737, ed6af2df4b656dff] + tool: 1 +--- +# Uzantılar {#extensions} + +**Uzantı**, tek bir tanımlayıcının arkasında toplanmış, isteğe bağlı olarak etkinleştirilen bir MCP davranışı paketidir. + +Sunucuda araç, kaynak ve yeni istek metotları katkısında bulunabilir, `tools/call` isteğini sarmalayabilir. İstemcide ek `tools/call` sonuç biçimlerini sahiplenebilir ve satıcıya özgü bildirimleri gözlemleyebilir. Her iki taraf da kendi `capabilities.extensions` alanı altında duyuru yapar ve bunu istememiş hiç kimse için hiçbir şey değişmez. Sözleşme budur ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)) ve tek bir altın kuralı var: **uzantılar varsayılan olarak kapalıdır**. + +## Bir uzantı kullanma {#using-an-extension} + +Örnekleri oluşturma sırasında geçirin: + +```python title="server.py" +--8<-- "docs_src/extensions/tutorial001.py" +``` + +Bu kadar. Sunucu artık `capabilities.extensions` altında `io.modelcontextprotocol/ui` duyurur ve uzantının katkıda bulunduğu her şeyi sunar. + +`Apps` yerleşik başvuru uzantısıdır ve kendi sayfası var: **[MCP Apps](apps.md)**. + +!!! note + Uzantılar oluşturma sırasında sabitlenir. Sonradan çağrılacak bir `add_extension` yoktur: istemciler bağlıyken bir sunucunun yetenek eşlemesi değişmemelidir. + +Yetenek eşlemesi `server/discover` ile taşınır; bu da bir **2026-07-28** yoludur. Eski nesil `initialize` el sıkışmasında onu koyacak bir yer yoktur, bu yüzden eski nesil bir istemci uzantıyı görmez. Tasarımınızı buna göre yapın: bir uzantı sunucuyu *zenginleştirir*; sunucunun kullanılabilir olmasının tek yolu olmamalıdır. + +## Kendi uzantınızı yazma {#writing-your-own} + +`Extension`'dan alt sınıf türetin ve yalnızca ihtiyaç duyduklarınızı geçersiz kılın. Her metodun bir varsayılanı var. + +### Tanımlayıcı {#the-identifier} + +```python +--8<-- "docs_src/extensions/tutorial002.py" +``` + +Tanımlayıcı, spesifikasyonun `_meta` anahtar dilbilgisini izleyen bir `vendor-prefix/name` dizesidir: noktayla ayrılmış etiketler (her biri bir harfle başlar, bir harf veya rakamla biter), bir eğik çizgi, ardından ad. **Sınıf tanımlandığında** doğrulanır; yani bir yazım hatası sunucunun açılmasını beklemez: + +```text +TypeError: Stamps.identifier must be a `vendor-prefix/name` string +(reverse-DNS prefix required), got 'stamps' +``` + +Önek olarak denetiminizdeki bir alan adı kullanın. `io.modelcontextprotocol/*`, MCP projesinin kendisinin belirlediği uzantılar içindir. + +### Araç katkısında bulunma {#contributing-tools} + +İşe yarar en küçük uzantı, bir araç ve bir ayarlar eşlemesidir: + +```python title="server.py" hl_lines="17 19-20 22-23 26" +--8<-- "docs_src/extensions/tutorial003.py" +``` + +* `tools()`, `ToolBinding`'ler döndürür. Sunucu her birini, `mcp.add_tool(...)` çağrısını kendiniz yapmışsınız gibi kaydeder: aynı şema üretimi, aynı `Context` enjeksiyonu, her şey aynı. +* `settings()`, `capabilities.extensions["com.example/stamps"]` konumunda duyurulan değerdir. Uzantıyı ayarsız duyurmak için `{}` (varsayılan) döndürün. +* Uzantı sunucuyu hiçbir zaman almaz. Katkıları veri olarak beyan eder; bunları `MCPServer` tüketir. Değiştirilecek bir `self.server` yoktur. + +Kanıtı da `main()`: doğrudan `mcp`'ye bağlanan bellek içi bir istemci: + +```python title="server.py" hl_lines="29-34" +--8<-- "docs_src/extensions/tutorial003.py" +``` + +### Kendi metotlarınızı sunma {#serving-your-own-methods} + +Bir uzantı **yeni istek metotları** kaydedebilir: spesifikasyonunkilerin yanında sunulan kendi fiilleri: + +```python title="server.py" hl_lines="16-22 31 40-48" +--8<-- "docs_src/extensions/tutorial004.py" +``` + +* `SearchParams`, `RequestParams`'tan türer; böylece 2026 `_meta` zarfı tek biçimde ayrıştırılır ve işleyiciniz ham bir dict değil, her zaman doğrulanmış parametreler alır. İstemcinin denetlediği şeyi sınırlayın: `Field(ge=1, le=100)`, kodunuz onun için herhangi bir şey ayırmadan önce saçma bir `limit` değerini reddeder. +* `require_client_extension(ctx, EXTENSION_ID)` kontrol noktasıdır: uzantıyı beyan etmemiş bir istemci, spesifikasyonun istediği makine tarafından okunabilir `requiredCapabilities` yüküyle birlikte `-32021` (gerekli istemci yeteneği eksik) hatasını alır. +* `protocol_versions=frozenset({"2026-07-28"})` metodu tek bir protokol sürümüne sabitler. Başka herhangi bir sürümde istemci `METHOD_NOT_FOUND` alır; sanki metot orada hiç yokmuş gibi. O istemci için gerçekten de yoktur. + +Metotlar **yalnızca ekleme niteliğindedir**. SDK bunu çalışma zamanında değil, oluşturma sırasında uygular: + +* Spesifikasyonda tanımlı bir metot (`tools/list`, `completion/complete`, ...) için bir `MethodBinding`, bağlama oluşturulurken `ValueError` fırlatır. Çekirdek fiiller sunucuya aittir. +* Aynı metodu bağlayan iki uzantı, ikincisi kaydolurken hata fırlatır. Son yazan kazanır yaklaşımı, eklentilerin birbirini bozmasının yoludur; biz bunu yapmayız. +* Boş bir `protocol_versions` kümesi de hata fırlatır: hiçbir zaman sunulamayacak bir metot bir yapılandırma değil, bir hatadır. + +### İstemci tarafı {#the-client-side} + +Aynı dosyadaki `main()`, istemci tarafının tamamıdır; iki yarısıyla birlikte: + +```python title="server.py" hl_lines="54-58" +--8<-- "docs_src/extensions/tutorial004.py" +``` + +* `Client(..., extensions=[advertise(EXTENSION_ID)])` uzantıyı beyan eder. Beyanlar `ClientCapabilities.extensions` hâline gelir: 2026-07-28 bağlantısında eşleme istek başına `_meta` zarfında taşınır, böylece sunucu onu **her** istekte görür; eski nesil bir bağlantıda `initialize` el sıkışmasıyla taşınır. Sunucu kodu hangisi olduğuyla ilgilenmez: `require_client_extension(ctx, ...)` ve `ctx.session.check_client_capability(...)` her iki yolda da doğru kaynağı okur. +* Satıcıya özgü metotlar bir katman aşağıya, `client.session.send_request(...)` düzeyine iner; `Client` yalnızca spesifikasyon fiilleri için birinci sınıf metotlar kazanır. `send_request` herhangi bir `Request` alt sınıfını kabul eder, bu yüzden satıcıya özgü istek olduğu gibi geçer. + +### `tools/call` isteğini yakalama {#intercepting-toolscall} + +Yakalayıcı nitelikteki tek kanca. Bir araç çağrısını gözlemlemek, kısa devre yapmak veya veto etmek için `intercept_tool_call`'u geçersiz kılın: + +```python title="server.py" hl_lines="17-24" +--8<-- "docs_src/extensions/tutorial005.py" +``` + +* `params`, doğrulanmış `CallToolRequestParams` nesnesidir: ham JSON'a dokunmadan `params.name` ve `params.arguments` elinizdedir. Hangi araç çağrısının çalışacağına karar veren de odur: `call_next` üzerinden yeniden yazılmış bir bağlam geçirmek, araç çağrısını değil, işleyicinin `ctx` üzerinde gözlemlediğini değiştirir. İletim düzeyinde istek yeniden yazımı [Middleware](middleware.md) sayfasının konusudur. +* `call_next(ctx)` zincirin geri kalanını çalıştırır ve işleyicinin sonucunu döndürür. Onu değiştirmeden döndürün (gözlemleme), başka bir şey döndürün (değiştirme) ya da bir `MCPError` fırlatın (reddetme). Ne döndürürseniz döndürün, 2026 neslinin `serverInfo` kimlik damgası dâhil, herhangi bir işleyici sonucu gibi serileştirilir; bu yüzden kısa devre yapan bir yakalayıcı hiçbir zaman anonim veya şema dışı bir yanıt üretmez. +* Birden fazla uzantı olduğunda yakalayıcılar kayıt sırasına göre iç içe geçer: `extensions=[...]` içindeki ilk uzantı en dıştadır. +* Varsayılan gerçekleştirim doğrudan geçirir; uzantıları bu kancayı hiç geçersiz kılmayan bir sunucu, yalın `tools/call` işleyicisini olduğu gibi korur. Kullanmadığınız şeyin bedelini ödemezsiniz. + +Kanca `tools/call` isteğini sarmalar, başka hiçbir şeyi değil. Her iletiyi ilgilendiren konular için [Middleware](middleware.md) kullanın. Onun işi budur. + +## Bir istemci uzantısı kullanma {#using-a-client-extension} + +**İstemci uzantısı**, aynı sözleşmenin tüketen taraftan görünüşüdür: tek bir tanımlayıcının arkasında toplanmış bir istemci tarafı davranış paketi. Örnekleri `Client(extensions=[...])` ile geçirin ve araçları normal şekilde çağırın: + +```python title="client.py" hl_lines="66-68" +--8<-- "docs_src/extensions/tutorial006.py" +``` + +`call_tool("buy", ...)`, diğer her çağrı gibi düz bir `CallToolResult` döndürür. Uzantının değiştirdiği şu: sunucu artık `buy` çağrısını nihai bir sonuç yerine `receipt` adlı bir **sonuç biçimiyle** yanıtlayabilir ve `Receipts`, `call_tool` dönmeden önce onu tamamlar (burada makbuzu bir takip çağrısıyla kullanarak). Çağrı yerinde hiçbir şey değişmez. + +Uzantıyı çıkarırsanız bunların hiçbiri olmaz: sunucunun kontrol noktası onu beyan etmemiş bir istemciyi reddeder (hata -32021) ve kontrolü atlayan bir sunucudan gelen sahiplenilmiş bir biçim, spesifikasyonun tanınmayan bir `resultType` için gerektirdiği gibi doğrulamadan geçemez. Bağlantının her iki ucunda da varsayılan olarak kapalı. + +İstemci tarafında **hiçbir** davranışı olmayan bir tanımlayıcıyı duyurmak için (sunucu yeteneği kontrol eder, istemci hiçbir şey yapmaz; yukarıdaki arama istemcisinde olduğu gibi) `advertise()` kullanın: + +```python +from mcp.client import advertise + +client = Client(mcp, extensions=[advertise("com.example/search")]) +``` + +## İstemci uzantısı yazma {#writing-a-client-extension} + +`ClientExtension`'dan alt sınıf türetin ve yalnızca ihtiyaç duyduklarınızı geçersiz kılın. Her birinin varsayılanı olan üç katkı türü var: `settings()`, `claims()` ve `notifications()`. + +```python title="client.py" hl_lines="17-18 43-44 46-47" +--8<-- "docs_src/extensions/tutorial006.py" +``` + +* Tanımlayıcı, sunucununkiyle aynı dilbilgisini izler ve sınıf tanımlandığında doğrulanır. +* `claims()`, `ResultClaim`'ler döndürür: iletilen veride bir etiket, onu ayrıştıran model ve onu tamamlayan çözümleyici. Model, etiketi `result_type: Literal["receipt"]` ile sabitlemelidir ve fiilin çekirdek sonuç türlerinden türememelidir; her ikisi de sahiplenme oluşturulurken uygulanır. `receipt_token` gibi satıcıya özgü alanlar ağ üzerinde olduğu gibi iletilir: yerine geçen bir biçim istemciye aynen ulaşır. +* Çözümleyici, ayrıştırılmış modeli ve bir `ClaimContext` alır; `ctx.session`, `client.session` ile aynı herkese açık tutamaçtır, bu yüzden takip çağrıları sıradan oturum çağrılarıdır. Fiilin normal `CallToolResult`'ını döndürür. +* `settings()`, `ClientCapabilities.extensions[identifier]` konumunda duyurulan değerdir; `Client` oluşturulurken bir kez okunur. + +`notifications()`, gözlemlenecek satıcıya özgü sunucu bildirimlerini beyan eder: + +```python +def notifications(self) -> Sequence[NotificationBinding[Any]]: + return [NotificationBinding(method="notifications/receipts", params_type=ReceiptEvent, handler=self.on_receipt)] +``` + +İşleyici doğrulanmış parametreleri gönderim sırasına göre tek tek alır. Gözlemler; veto edemez ve yanıt veremez. + +İki sessiz kural. Sahiplenmeler yalnızca 2026-07-28 bağlantılarında etkindir ve yetenek duyurusu da onları izler: eski nesil bir bağlantıda sahiplenmeler ortadan kalkar, tanımlayıcı da onlarla birlikte duyurudan düşer; böylece istemci, biçimlerini reddedeceği bir uzantıyı asla duyurmaz. Sahiplenilen biçimi çözümleyici yerine kendiniz istediğinizde ise `client.session.call_tool(..., allow_claimed=True)` çağırın; bu bayrak olmadan, oturum katmanındaki bir çağırana ulaşan sahiplenilmiş bir biçim `UnexpectedClaimedResult` fırlatır. + +### Uzantı fiilleri {#extension-verbs} + +Bir uzantının kendi istek metotları istemci tarafında kayıt gerektirmez. Satıcıya özgü bir istek türü `mcp.types.Request`'ten türer ve [Kendi metotlarınızı sunma](#serving-your-own-methods) bölümündeki gibi `client.session.send_request` üzerinden gider. Tek bir ekleme var: bir params anahtarının `Mcp-Name` başlığında taşınması gerektiğinde (tasks gibi uzantı spesifikasyonları kendi fiilleri için bunu şart koşar) istek türü `name_param` beyan eder: + +```python title="client.py" hl_lines="22-25 46-47" +--8<-- "docs_src/extensions/tutorial007.py" +``` + +Oturum, `params["jobId"]` değerini her gönderim yolunda `Mcp-Name` başlığına yansıtır; eksik bir değer ise gerekli bir başlığı sessizce atlamak yerine açıkça hata verir. + +## Bir uzantının yapamayacakları {#what-an-extension-cannot-do} + +Katkı yüzeyi bilerek **kapalıdır**. Sunucuda: ayarlar, araçlar, kaynaklar, metotlar, bir `tools/call` yakalayıcısı. İstemcide: ayarlar, sonuç sahiplenmeleri, bildirim bağlamaları. Bir uzantı şunları yapamaz: + +* **Barındıran nesneye erişemez.** Veri beyan eder; sunucu veya istemci referansı tutmaz. +* **Çekirdek davranışın yerine geçemez.** Spesifikasyon metotları ve çekirdek sonuç etiketleri oluşturma sırasında reddedilir (`initialize` doğrudan çalıştırıcı tarafından ayrılmıştır); çekirdek söz dağarcığının gölgelediği bir bildirim bağlaması ise bir uyarıyla sessizce devre dışı kalır. +* **Geç kayıt olamaz.** `MCPServer(...)` veya `Client(...)` döndükten sonra uzantı kümesi neyse odur. + +Bu duvarlarla boğuşuyorsanız bir uzantı yazmıyorsunuz. Bir fork yazıyorsunuz. Duvarlar özelliğin ta kendisidir: `extensions=[Apps(), Stamps()]` satırını okuyan bir kullanıcı, bu ikisinin dokunmuş olabileceği *her şeyi* bilir. diff --git a/i18n/tr/pages/advanced/index.md b/i18n/tr/pages/advanced/index.md new file mode 100644 index 0000000000..fd8a58424c --- /dev/null +++ b/i18n/tr/pages/advanced/index.md @@ -0,0 +1,34 @@ +--- +translation: + sections: [ca6988b7503cd2d3] + tool: 1 +--- +# İleri düzey {#advanced} + +Sıradan bir sunucunun ya da istemcinin ihtiyaç duyduğu her şeyin yukarıdaki bölümlerde konusuna göre bir yeri var. +Bu bölüm ise `MCPServer`'ın kolaylık katmanı size engel olduğunda başvuracağınız +kaçış yollarını içerir: + +* **[Alt düzey Server](low-level-server.md)**: `MCPServer`'ın üzerine kurulduğu sınıf. + Elle yazılmış şemalar, `on_*` işleyicileri, sizin yerinize denetlenen hiçbir şey yok + ve kendinize ait özel JSON-RPC metotları. +* **[Sayfalama](pagination.md)** ve **[Middleware](middleware.md)**: *yalnızca* + alt düzey `Server` üzerinde yapabileceğiniz iki şey. +* **[Uzantılar](extensions.md)** ve **[MCP Apps](apps.md)**: protokolün uzantı + yüzeyi. Uzantı paketlerini bir sunucuda bir araya getirin ya da kendinizinkini yazın. + +Burada aramanız gayet doğal olan birkaç konu ise aslında onları kullanacağınız yerde +duruyor: + +* **Yetkilendirme**, **[Sunucunuzu çalıştırma](../run/index.md)** altında; çünkü + bir sunucuyu dağıttığınız yerde korursunuz. +* **OAuth**, **kimlik beyanı**, **birden çok sunucuya** bağlanma ve yanıt + **önbelleği**, hepsi **[İstemciler](../client/index.md)** altında. +* **Çok turlu istekler** (multi-round-trip) ve **Abonelikler**, + **[İşleyicinin içinde](../handlers/index.md)** altında; çünkü ikisi de bir + işleyicinin *yaptığı* şeyler. +* **URI şablonları**, **[Sunucular](../servers/index.md)** altında, Kaynaklar'ın hemen yanında. +* **[Protokol sürümleri](../protocol-versions.md)** ve + **[Kullanım dışı özellikler](../deprecated.md)** sayfalarının her birinin kendi üst düzey sayfası var. + +Bu bölüme ihtiyacınız olup olmadığından emin değilseniz, yok demektir. diff --git a/i18n/tr/pages/advanced/low-level-server.md b/i18n/tr/pages/advanced/low-level-server.md new file mode 100644 index 0000000000..f49dd2a98c --- /dev/null +++ b/i18n/tr/pages/advanced/low-level-server.md @@ -0,0 +1,207 @@ +--- +translation: + sections: [2c79b6338e09b7ac, 7edc43b3fae11314, 1086e77ce561cd7f, a3f71823df5efc31, 9fc7109f72201cae, 7bf25983df655b66, 6330e1f4c6029683, 2f1749c8c133fa1c, b3530fcf4d11fd56, ebc33704fbd74262, cd0e9c933350390e] + tool: 1 +--- +# Düşük seviyeli Server {#the-low-level-server} + +`@mcp.tool()` bir katmandır. Altında ham MCP konuşan ikinci bir sunucu sınıfı, `Server`, vardır: protokol nesnelerini ona verirsiniz, o da hiç dokunmadan ağ üzerinden gönderir. + +`MCPServer` onun üzerine kuruludur. Kolaylık katmanı size engel olduğunda alt katmana inersiniz: + +* Python imzasından türetilmiş bir şema değil, **birebir** belirli bir şema (dosyadan yüklenen, veritabanından üretilen) yayımlamanız gerekir. +* Sonuç üzerinde tam denetim gerekir: `_meta`, `is_error`, `structured_content`'in her anahtarı. +* MCP'nin tanımlamadığı bir metodu ele almanız gerekir. + +Geri kalan her şey için `MCPServer`'da kalın. + +## Aynı araç, elle {#the-same-tool-by-hand} + +Bu, **[Araçlar](../servers/tools.md)** sayfasında dokuz satır `@mcp.tool()` ile yazılan `search_books` aracının kolaylıklardan arındırılmış hali: + +```python title="server.py" hl_lines="22 26 32" +--8<-- "docs_src/lowlevel/tutorial001.py" +``` + +Üç şey değişti ve düşük seviyeli API'nin tamamı bu üçü: + +* **İşleyiciler yapıcı parametreleridir.** `on_list_tools=` ve `on_call_tool=`, `Server(...)` çağrısına gider. Burada dekoratör yoktur ve her işleyici aynı biçimdedir: `async (ctx, params) -> result`. +* **Girdi şemasını siz yazarsınız.** `Tool.input_schema`, düz bir JSON Schema `dict`'idir. Kimse onu tür ipuçlarından türetmez, çünkü türetilecek tür ipucu yoktur. +* **Sonucu siz oluşturursunuz.** `CallToolResult(content=[TextContent(...)])`, elle. Hiçbir şey sarmalanmaz, dönüştürülmez ya da bir dönüş anotasyonundan çıkarsanmaz. + +`params` ayrıştırılmış istektir: `CallToolRequestParams` size `.name` ve `.arguments` verir. `ctx` bir `ServerRequestContext`'tir: istemciyle geri konuşmak için `ctx.session`, `ctx.lifespan_context`, `ctx.request_id` ve isteğin gelen `_meta`'sı olan `ctx.meta`. + +!!! info + FastAPI kullandıysanız bu ilişkiyi zaten biliyorsunuz. `MCPServer`, dekoratörler ve tür ipuçları katmanıdır; `Server` ise alttaki Starlette'tir. Rakip değiller: `MCPServer` bir `Server` oluşturur ve üzerine tam da bunlar gibi işleyiciler kaydeder. + +### Deneyin {#try-it} + +Bunun için Inspector yok: `mcp dev` ve `mcp run` yalnızca `MCPServer` kabul eder. Bellek içi `Client` bunu umursamaz; düşük seviyeli bir `Server`'ı tıpkı bir `MCPServer`'ı aldığı gibi alır: + +```python title="main.py" +import asyncio + +from mcp import Client + +from server import server + + +async def main() -> None: + async with Client(server) as client: + result = await client.call_tool("search_books", {"query": "dune", "limit": 5}) + print(result.content) + + +asyncio.run(main()) +``` + +```text +[TextContent(type='text', text="Found 3 books matching 'dune' (showing up to 5).", annotations=None, meta=None)] +``` + +`@mcp.tool()` sürümünün ürettiği metnin aynısı. İki gerçek fark var: + +* `result.structured_content` değeri `None`. Yüksek seviyeli sunucu `-> str` dönüş türünü sizin yerinize `{"result": ...}` içine sarmalar; burada sizin oluşturmadığınızı kimse oluşturmaz. +* `list_tools`, **sizin** yazdığınız şemayı karakteri karakterine döndürür. Yüksek seviyeli sürümde her özellikte `"title": "Query"`, kökte de `"title": "search_booksArguments"` vardı: Pydantic'in bıraktığı izler. Burada ise ağa giden bir şey varsa onu oraya siz koymuşsunuzdur. + +## Sizin yerinize hiçbir şey denetlenmez {#nothing-is-checked-for-you} + +`MCPServer`, çağrıyı kendi ürettiği şemaya göre doğrulayarak hatalı bir argümanı fonksiyonunuz daha çalışmadan reddeder (**[Araçlar](../servers/tools.md)**). + +`Server` bunu yapmaz. `input_schema`'nız istemciye *duyurulur*; `params.arguments`'a asla *uygulanmaz*. + +!!! check + `search_books`'u `limit` olmadan çağırın; `args["limit"]` ifadeniz `KeyError` fırlatır. İstemci şunu görür: + + ```text + MCPError: Internal server error + ``` + + `-32603` kodlu, mesajı kasıtlı olarak genel tutulmuş bir JSON-RPC hatası: SDK, traceback'inizi uzaktaki bir çağırana sızdırmaz. Model neyi yanlış yaptığını asla öğrenemez, bu yüzden yeniden deneyemez. (Testte `raise_exceptions=True` bunun yerine gerçek istisnayı yüzeye çıkarır; bkz. **[Test etme](../get-started/testing.md)**.) + +Bu genellenebilir. Düşük seviyeli bir işleyiciden fırlatılan istisna **her zaman** bir protokol hatasıdır, asla `is_error=True` taşıyan bir araç sonucu değildir. Modelin hatayı okuyup toparlanmasını istiyorsanız `params.arguments`'ı kendiniz doğrulayın ve `CallToolResult(content=[TextContent(...)], is_error=True)` döndürün. Bu iki hata türü **[Hataları ele alma](../servers/handling-errors.md)** sayfasının konusu. + +## İki araç, tek işleyici {#two-tools-one-handler} + +`on_call_tool`, sunucudaki her araç için tek giriş noktasıdır. Yönlendirmeyi `params.name`'e göre yaparsınız: + +```python title="server.py" hl_lines="38-43" +--8<-- "docs_src/lowlevel/tutorial002.py" +``` + +* `list_tools` ikisini de duyurur. `call_tool` ada göre yönlendirir. +* `else` dalı önemlidir: `Server`, hiç listelemediğiniz bir ad için gelen `tools/call` isteğini hiç sorgulamadan doğrudan işleyicinize iletir. Orada istisna fırlatmak çağrıyı yukarıdakiyle aynı `-32603` hatasına çevirir. + +## Yapılandırılmış çıktı, elle {#structured-output-by-hand} + +`Tool` üzerinde `output_schema` bildirin ve sonuca `structured_content` koyun. İkisi de sizin: + +```python title="server.py" hl_lines="19-23 36" +--8<-- "docs_src/lowlevel/tutorial003.py" +``` + +Çağırın; sonuç iki gösterimi de taşır: + +```json +{ + "content": [{"type": "text", "text": "Found 3 books matching 'dune'."}], + "structuredContent": {"matches": 3, "query": "dune"}, + "isError": false, + "resultType": "complete", + "_meta": {"io.modelcontextprotocol/serverInfo": {"name": "Bookshop", "version": "2.0.0"}} +} +``` + +`_meta` bloğu sunucunun kimlik damgasıdır: SDK bunu 2026 neslinden her sonuca, yapıcıdan gelen `version` ile birlikte ekler (hiç sürüm belirtmeyen bir sunucu boş bir dize bildirir). Kendini tanıtmaması gereken bir sunucu bu anahtarı bir middleware (ara katman) ile çıkarabilir; middleware döndürdüğü sonuçların sahibidir. + +Sunucu bu iki alanı asla karşılaştırmaz. Bu SDK'nın `Client`'ı karşılaştırır: bildirdiğiniz `output_schema`'yı karşılamayan bir `structured_content` döndürün, `call_tool` `Invalid structured content returned by tool search_books` ile başlayıp `jsonschema` hatasını alıntılayarak devam eden bir `RuntimeError` fırlatır. Bir şema vaat etmek ucuzdur; sözünüzü tutmak size kalır. Dönüş türleri ve şemaların tüm basamakları **[Yapılandırılmış çıktı](../servers/structured-output.md)** sayfasında. + +## `_meta`: model için değil, uygulama için {#\_meta-for-the-application-not-the-model} + +`content`, yanıtın modelin okuduğu kısmıdır. `structured_content`, aynı yanıtın tür bilgisi taşıyan veri halidir. `_meta` üçüncü kanaldır: yanıtın hiçbir şekilde parçası olmadan, **istemci uygulama** için sonuçla birlikte yolculuk eden veri. + +Kayıt kimlikleri, iz kimlikleri, kullanıcı arayüzünüzün ihtiyaç duyup prompt'unuzun duymadığı her şey için kullanın: + +```python title="server.py" hl_lines="37" +--8<-- "docs_src/lowlevel/tutorial004.py" +``` + +* Onu ağ üzerindeki adıyla, `_meta=` olarak oluşturursunuz. İstemci onu `result.meta` olarak geri okur. +* Anahtarlarınıza ad alanı verin (`bookshop/record_ids`). `io.modelcontextprotocol/*` anahtarları protokole ayrılmıştır. + +!!! warning + `_meta`, sizinle istemci uygulama arasındaki bir uzlaşıdır; modele neyin ulaştığına dair + bir garanti değildir. Neyi göstereceğine host karar verir. Bir araç sonucunun hiçbir yerine asla sır koymayın. + +## Yetenekler işleyicilerinizi izler {#capabilities-follow-your-handlers} + +Bir `Server`, tam olarak işleyici verdiğiniz metot ailelerini duyurur. Yukarıdaki `Bookshop`, `on_list_tools` ile `on_call_tool`'u geçirir, başka hiçbir şey geçirmez; dolayısıyla ona bağlanan bir istemci şunu görür: + +```json +{"tools": {"listChanged": false}} +``` + +`resources` yok, `prompts` yok: arkalarında duracak bir şey yok. `on_list_prompts` geçirin, `prompts` belirir; `on_completion` geçirin, `completions` belirir. + +`MCPServer`, siz kaydetmiş olun olmayın araçları, kaynakları ve prompt'ları her zaman duyurur; çünkü yöneticileri her zaman vardır. Burada ise beyan, yapıcı çağrısının *ta kendisidir*. + +## Lifespan jenerik parametresi {#the-lifespan-generic} + +`Server`, lifespan'inin (yaşam döngüsü) ürettiği türe göre jeneriktir. Bir kez tür açıklaması ekleyin; nesne ortaya çıktığı her yerde tür bilgisi taşır: + +```python title="server.py" hl_lines="24-26 44-45 50" +--8<-- "docs_src/lowlevel/tutorial005.py" +``` + +* Lifespan, `Callable[[Server[Catalog]], AbstractAsyncContextManager[Catalog]]` türündedir; bir `async` üreteç üzerindeki `@asynccontextmanager` size tam olarak bunu verir. +* `yield` ettiği her neyse `ctx.lifespan_context` olur; işleyiciler `ServerRequestContext[Catalog]` olarak açıklandığı için de `.search(...)` otomatik tamamlanır ve tür denetiminden geçer. +* Sunucu başlarken bir kez girilir, dururken bir kez çıkılır. Başlatma, kapatma ve aynı fikrin `MCPServer` sürümü **[Lifespan](../handlers/lifespan.md)** sayfasında. + +`lifespan=` olmadan `ctx.lifespan_context` boş bir `dict`'tir. + +## Kendinize ait bir metot {#a-method-of-your-own} + +Yapıcı, MCP'nin tanımladığı metotları kapsar. `add_request_handler` geri kalan her şeyi kapsar: + +```python title="server.py" hl_lines="35-36 39-40 43-44 48" +--8<-- "docs_src/lowlevel/tutorial006.py" +``` + +* İlk argüman metot dizesidir. Bildirimlerin bir ikizi vardır: `add_notification_handler`. +* `params_type`, gelen `params`'ın işleyiciniz çalışmadan **önce** doğrulandığı modeldir; yani özel metotlar, araçların almadığı doğrulamayı *alır*. `_meta` alanının diğer her metotta olduğu gibi ayrıştırılması için `RequestParams`'tan alt sınıf türetin. +* İşleyici bir `BaseModel`, bir `dict` ya da `None` döndürür. SDK bunu JSON-RPC sonucuna serileştirir. + +Dürüst bir uyarı: yüksek seviyeli `Client`'ta yalnızca MCP'nin tanımladığı metotlar için fiiller vardır, yani `client.reindex()` diye bir şey yoktur. Satıcıya özel bir metot, varlığından zaten haberdar olan bir eş içindir: sizin de dağıttığınız bir istemci ya da JSON-RPC konuşan başka bir servisiniz. + +Sahiplenemeyeceğiniz tek bir metot var: + +```text +ValueError: 'initialize' is handled by the server runner and cannot be overridden; +use Server.middleware to observe or wrap initialization +``` + +El sıkışma çalıştırıcıya aittir. `server/discover`, `ping` ve diğer tüm yerleşik metotları dilediğiniz gibi değiştirebilirsiniz. + +!!! tip + O hatada adı geçen `Server.middleware`, `initialize` dahil gelen **her** mesajı sarmalar. İstediğiniz yeni bir metodu yanıtlamak değil de trafiği gözlemlemek ya da yeniden yazmaksa **[Middleware](middleware.md)** sayfasından başlayın. + +## Diğer işleyiciler {#the-other-handlers} + +Bunların her biri, artık kavramlarını bildiğiniz birer fikir; her birinin kendi sayfası var. + +* `on_call_tool`, `on_get_prompt` ve `on_read_resource`, çağrıyı duraklatıp istemciden girdi istemek için normal sonuçları yerine bir `InputRequiredResult` döndürebilir; bkz. **[Çok turlu istekler](../handlers/multi-round-trip.md)** (multi-round-trip). Bu katmanın ruhuna uygun olarak sizin için hiçbir şey kurulmaz: `MCPServer` varsayılan olarak `requestState`'i mühürlerken burada ayarladığınız `request_state`, siz `server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[...]), default_audience=server.name))` ile katılana kadar ağı tam yazıldığı gibi geçer: `MCPServer`'ın yaptığı mühürleme ve doğrulamanın aynısı için tek satır (iki ad da `mcp.server.request_state`'ten içe aktarılır) (**[`requestState`'i koruma](../handlers/multi-round-trip.md#protecting-requeststate)**). +* `on_list_resources`, `on_read_resource`, `on_list_prompts`, `on_get_prompt`, `on_completion`, diğer ilkel öğeler için aynı `(ctx, params) -> result` biçimidir. +* `on_subscriptions_listen`, 2026-07-28 `subscriptions/listen` akışını sunar. Bir `SubscriptionBus` üzerine kurulu bir `ListenHandler` geçirin ve olayları diğer işleyicilerinizden veri yoluna yayımlayın; bileşimin tamamı için bkz. **[Abonelikler](../handlers/subscriptions.md)**. +* `server.streamable_http_app()`, `MCPServer`'ınkiyle aynı Starlette uygulamasını döndürür; onu **[Sunucunuzu çalıştırma](../run/index.md)** sayfasının herhangi bir ASGI uygulamasını dağıttığı gibi dağıtın. Burada `server.run(transport=...)` yoktur: `server.run(read_stream, write_stream, server.create_initialization_options())` bir akış çifti üzerinden tek bir bağlantıyı yürütür ve bu tek satır işin tamamıdır. + +## Özet {#recap} + +* Düşük seviyeli `Server`, işleyicilerini `on_*` **yapıcı parametreleri** olarak alır; her işleyici `async (ctx, params) -> result` biçimindedir. +* `input_schema` sözlüğünü siz yazar, `CallToolResult`'ı siz oluşturursunuz. Sizin yerinize hiçbir şey türetilmez, sarmalanmaz ya da doğrulanmaz. +* İşleyicideki bir istisna `-32603` protokol hatasıdır. Modelin okuyabileceği bir araç hatası, **sizin** döndürdüğünüz `is_error=True` taşıyan bir `CallToolResult`'tır. +* Sonuçtaki `_meta` modele değil, istemci uygulamaya yöneliktir. +* `Server[T]`, lifespan'inin ürettiği şeye göre jeneriktir; `ctx.lifespan_context` tür bilgisi taşıyan bir `T`'dir. +* `add_request_handler(method, params_type, handler)` her metodu sunar. `initialize` ayrılmıştır. +* Bir `Server`'ın duyurduğu yetenekler, hangi işleyicileri kaydettiğinizden türetilir. + +`Client(server)` iki sunucuya da aynı davrandı, çünkü ikisi aynı protokolün *ta kendisi*; bütün mesele de bu. Bir alt katman ise bir sınıf bile değil: **[Middleware](middleware.md)**. diff --git a/i18n/tr/pages/advanced/middleware.md b/i18n/tr/pages/advanced/middleware.md new file mode 100644 index 0000000000..203b1e421c --- /dev/null +++ b/i18n/tr/pages/advanced/middleware.md @@ -0,0 +1,122 @@ +--- +translation: + sections: [6048b4f308edbb8c, 068bda0f21ee9c1b, c3e565b61acd75c5, c62422b159c6ed09, 47204fab253cc45c] + tool: 1 +--- +# Middleware {#middleware} + +**Middleware** (ara katman), sunucunun aldığı her mesajı saran tek bir asenkron fonksiyondur. + +Onu `async (ctx, call_next)` biçiminde yazar ve `server.middleware` listesine eklersiniz. API'nin tamamı bu. + +!!! warning + Middleware listesi kaynak kodda **geçici (provisional)** olarak işaretlidir: imzası ve anlamı + bir 2.x ara sürümünde değişebilir. Onu mesajları *gözlemlemek* (zamanlama, log tutma, izleme) ve + *reddetmek* için kullanın; sunucunuzun üzerinde durduğu temel haline getirmeyin. + +`MCPServer` listeyi oluşturulurken alır (`MCPServer(name, middleware=[...])`) ve onu +`mcp.middleware` olarak sunar; alt düzey `Server` aynı listeyi `server.middleware` olarak sunar. Aşağıdaki +örnek alt düzey `Server`'ı kullanır; `Server(name, on_call_tool=...)` size yeniyse önce +**[Alt düzey Server](low-level-server.md)** sayfasını okuyun. + +## Bir zamanlama middleware'i {#a-timing-middleware} + +Bir sunucu, bir araç ve her mesajın ne kadar sürdüğünü loglayan bir middleware: + +```python title="server.py" hl_lines="39-45 49" +--8<-- "docs_src/middleware/tutorial001.py" +``` + +* `ctx`, işleyicilerinizin aldığı `ServerRequestContext`'in aynısıdır. `ctx.method` ham + metot dizgesidir; `ctx.params` ise herhangi bir doğrulamadan **önceki** ham parametrelerdir. +* `call_next(ctx)` zincirin geri kalanını çalıştırır: doğrulama, işleyici araması, işleyiciniz. + Onun döndürdüğünü döndürürseniz yanıta dokunulmaz. +* `try`/`finally` bilinçli bir tercihtir: istisna fırlatan bir işleyicinin de süresi ölçülür, çünkü hata + middleware'inize `call_next`'ten çıkan istisna olarak ulaşır. +* `server.middleware.append(...)` onu kaydeder. Liste dıştan içe doğru çalışır, yani + `middleware[0]` ağ tarafına en yakın olandır. + +### Deneyin {#try-it} + +Bir istemci bağlayın, araçları listeleyin, birini çağırın. Logunuzda **üç** satır var: + +```text +server/discover took 18.3 ms +tools/list took 0.1 ms +tools/call took 0.1 ms +``` + +İki çağrı yaptınız ve üç satır elde ettiniz. İlki `server/discover`: siz herhangi bir şey +istemeden önce, istemcinin bağlantıyı kurmak için gönderdiği istek. + +İşin özü de bu. Middleware gelen **her** mesajı sarar: + +* Bağlantı kurulumu: `server/discover` ya da eski nesil bir oturumda `initialize` ve + `notifications/initialized`. +* Her istek ve her bildirim. Bir bildirimde `ctx.request_id is None` olur, + `call_next(ctx)` `None` döndürür ve sizin döndürdüğünüz her şey atılır. +* Sunucunun işleyicisi olmayan bir metot bile: `call_next`, + `MCPError(-32601, "Method not found")` istisnasını istemciye giderken middleware'inizin *içinden* fırlatır. + +## İçinde neler yapabilirsiniz {#what-you-can-do-inside-one} + +Ne kadar tereddüt etmeniz gerektiğine göre artan sırayla: + +* **Gözlemleyin.** Süresini ölçün, sayın, loglayın. Yukarıdaki örnek. +* **Reddedin.** `call_next(ctx)`'i çağırmak *yerine* bir `MCPError` fırlatın; o tek mesaj + bir JSON-RPC hatasıyla yanıtlanır. Bağlantı ayakta kalır; sonraki mesaj geçer. Bir sunucu + `subscriptions/listen`'ı çağıran başına böyle denetler: + Abonelikler sayfasındaki **[Kimin izleyebileceğine karar verme](../handlers/subscriptions.md#deciding-who-may-watch)** bölümü + bunu adım adım anlatır. +* **Yeniden yazın.** `ctx` bir dataclass'tır: `await call_next(dataclasses.replace(ctx, params=...))` + zincirin geri kalanına istemcinin gönderdiğinden farklı parametreler verir. Bunu `initialize` + için asla yapmayın: istemcinin geri aldığı sonuç sizin yeniden yazdığınız parametrelerden oluşturulur, ancak + sunucu bağlantı durumunu ağdan gelen özgün parametrelere göre kaydeder. İki taraf + el sıkışmayı neyi müzakere ettikleri konusunda anlaşamadan bitirebilir. +* **Yanıtlayın.** `call_next(ctx)`'i çağırmadan bir sonuç döndürün; bu sonuç istemciye sizin + yanıtınız olarak gider. `call_next` size tamamlanmış iletim biçimini verir ve işlem hattı döndürdüğünüzü + asla yamalamaz; bu yüzden zarfın tamamı sizindir: 2026 neslinden bir bağlantıda buna + `serverInfo` `_meta` damgası da dahildir. SDK bu damgayı işleyici sonuçlarına ekler, sizinkilere eklemez. + +!!! check + `initialize`, middleware'in sardığı şeylerden biridir ve onun için elinizdeki *tek* kanca + budur. Onu `add_request_handler` ile devralmaya çalışırsanız SDK reddeder: + + ```text + ValueError: 'initialize' is handled by the server runner and cannot be overridden; + use Server.middleware to observe or wrap initialization + ``` + +!!! warning + `initialize` satır içinde ele alınır: middleware zinciriniz dönene kadar sunucu başka gelen + mesaj okumaz. Bu yüzden `initialize`'ı işlerken sunucudan istemciye bir isteği (`ctx.session.send_request(...)`, + bir elicitation) beklemek **bağlantıyı kilitler**: beklediğiniz + yanıt asla okunamaz. Gönderip unutulan bildirimlerde sorun yoktur. + +## Varsayılan olarak açık gelen tek middleware {#the-one-middleware-that-ships-on-by-default} + +SDK tam olarak bir middleware ile gelir ve o zaten sunucunuzun listesindedir: her mesaj için +bir OpenTelemetry span'i yayan middleware. Onu siz eklemezsiniz ve çoğu zaman +aklınıza bile gelmez. Bir exporter kurana kadar hiçbir şey yapmaz ve kendi sayfası vardır: +**[OpenTelemetry](../run/opentelemetry.md)**. + +!!! info + ASGI middleware'i yazdıysanız bu yapıyı zaten biliyorsunuz. Starlette'in + `(scope, receive, send)` üçlüsü `(ctx, call_next)` oldu ve aktarımdan *sonra*, ham + HTTP isteği yerine çözülmüş mesaj üzerinde çalışır. İkisi birlikte kullanılabilir: `streamable_http_app()` + üzerindeki Starlette middleware'i HTTP'yi görür; bu ise MCP'yi görür. + +## Özet {#recap} + +* Bir middleware `async (ctx, call_next) -> result` biçimindedir; `MCPServer(middleware=[...])` olarak geçirilir (ya da + `mcp.middleware` listesine eklenir), alt düzey `Server`'da ise `server.middleware` listesine eklenir. +* Gelen **her** mesajı sarar (`server/discover`, `initialize`, istekler, bildirimler, + bilinmeyen metotlar) ve dıştan içe doğru çalışır. +* Bir bildirimi bir istekten `ctx.request_id is None` ile ayırt edersiniz. +* Tek bir mesajı reddetmek için `call_next`'i çağırmak yerine istisna fırlatın; bağlantı ayakta kalır. +* SDK'nın kendi OpenTelemetry izlemesi de bir middleware'dir ve zaten listededir. Bkz. + **[OpenTelemetry](../run/opentelemetry.md)**. +* Yüzeyin tamamı geçicidir. Onunla gözlemleyin; üzerine inşa etmeyin. + +Bir isteği saran her şey bu kadar. İsteğin çalışıp çalışmayacağına karar veren ise +**[Yetkilendirme](../run/authorization.md)**. diff --git a/i18n/tr/pages/advanced/pagination.md b/i18n/tr/pages/advanced/pagination.md new file mode 100644 index 0000000000..e686ca1b67 --- /dev/null +++ b/i18n/tr/pages/advanced/pagination.md @@ -0,0 +1,85 @@ +--- +translation: + sections: [a9aba7a026c7bd85, ed32bda7ba9ae33a, 7e64cc5646abb91f, 22a0129ee78b3c63, d875373c06d8d2f9] + tool: 1 +--- +# Sayfalama {#pagination} + +Çoğu sunucunun buna hiç ihtiyacı olmaz. + +`MCPServer`, her `list_*` isteğini elindeki her şeyle, tek sayfada, `next_cursor=None` ile yanıtlar. Birkaç düzine araç, kaynak veya prompt için doğru yanıt budur ve yapılandıracak bir şey yoktur. + +Sayfalama, kaynak listesi aslında bir veritabanı olan sunucu içindir: tek yanıtta serileştirmeyi reddettiği binlerce satır. Protokolün buna yanıtı **imleç**tir (cursor): sunucu bir sayfa ile birlikte opak bir token döndürür, istemci de sonraki sayfayı almak için bu token'ı geri gönderir. + +`@mcp.resource()`'ta bunların hiçbiri için bir kanca yoktur. Sayfalamak için liste işleyicisini **[düşük seviyeli Server](low-level-server.md)** üzerinde kendiniz yazarsınız. + +## Sayfalayan bir sunucu {#a-server-that-pages} + +```python title="server.py" hl_lines="12 15-16" +--8<-- "docs_src/pagination/tutorial001.py" +``` + +* Düşük seviyeli bir `Server`'da işleyiciler dekoratör değil, kurucu argümanlarıdır. `on_list_resources` her `resources/list` isteğini yanıtlar; bağlantının tamamı bu. +* Sayfalanan her işleyicinin türü `params: PaginatedRequestParams | None`'dır ve örnek ikisini de kabul eder. Ancak bir bağlantı üzerinden SDK size hiçbir zaman `None` vermez (`params` üyesi olmayan bir istek, işleyiciye varsayılan değerleriyle model olarak ulaşır); bu yüzden önemli olan sinyal `params.cursor is None`'dır: **en baştan başla**. +* Bir imlecin *ne olduğuna* siz karar verirsiniz. Burada dizge olarak yazılmış bir ofsettir. Bir zaman damgası, bir birincil anahtar, bir base64 blob'u: çıkışta üretebileceğiniz ve dönüşte tanıyabileceğiniz herhangi bir şey. +* `next_cursor=None`, "bu son sayfaydı" demenin yoludur. Sayaç yok, toplam yok, `has_more` yok. Sinyalin tamamı `None`'dır. + +!!! tip + 10'luk bir `PAGE_SIZE` örneği okunur kılar. Kendinizinkini endpoint başına seçin: + tek satırlık kaynaklardan oluşan bir liste 500'lük bir sayfayı kaldırır; şişkin prompt + şablonlarından oluşan bir liste kaldıramaz. İstemcinin bu konuda söz hakkı yoktur ve bu bilinçli bir tasarımdır. + +### Deneyin {#try-it} + +`Client(server)`, düşük seviyeli bir `Server`'a bellek içinde, bir `MCPServer`'a bağlandığı gibi bağlanır. + +`list_resources()`'ı argümansız çağırın. `book-1`'den `book-10`'a kadar on kaynak alırsınız ve `next_cursor`, `"10"` dizgesidir. + +Bunu `list_resources(cursor="10")` ile geri verin; ilk kaynak `book-11`, yeni `next_cursor` ise `"20"` olur. + +Onuncu sayfa, `next_cursor` değeri `None` olarak döner. Bitti. + +## İstemci döngüsü {#the-client-loop} + +`Client` üzerindeki her `list_*` metodu (`list_tools`, `list_resources`, `list_resource_templates`, `list_prompts`) bir `cursor=` anahtar kelimesi alır. Sayfalanmış bir listeyi sonuna kadar okumak tek bir `while True`'dur: + +```python title="client.py" hl_lines="26-32" +--8<-- "docs_src/pagination/tutorial002.py" +``` + +* `cursor`, `None` olarak başlar; bu yüzden ilk istek imleç taşımaz. +* `next_cursor`'a bakmadan **önce** listeyi genişletin: son sayfada da kaynaklar vardır. +* Çıkış koşulu `next_cursor is None`'dır. Bunun dışındaki her şey, dokunulmadan doğrudan `cursor=`'a geri gider. + +`main()`'ini çalıştırın; `100 resources` yazdırır: on tane onluk sayfa, on sayfa olduğundan hiç haberi olmayan bir döngü tarafından birleştirilmiş. + +Bu, **[İstemci](../client/index.md)** sayfasının her `list_*` fiili için gösterdiği döngünün aynısıdır ve sayfalamayan bir sunucuya karşı hiçbir maliyeti yoktur: ilk yanıtta `next_cursor`, `None` olur ve döngü bir kez çalışır. + +## Üç kural {#the-three-rules} + +**İmleçler opaktır.** Bir istemci bir imleci asla ayrıştırmamalı, oluşturmamalı veya tahmin etmemelidir. Bir imlecin tek meşru kaynağı, bir önceki sayfanın `next_cursor`'ıdır; harfi harfine. + +**Sayfa boyutunu sunucu seçer.** Protokolde `limit=` yoktur. Farklı bir sayfa boyutuna ihtiyacınız varsa sunucuyu değiştirirsiniz. + +**Sayfalamayı yok sayan bir istemci yine de çalışır.** `list_resources()`'ı bir kez çağırır, ilk onu alır ve attığı `next_cursor`'ı hiç fark etmez. Hiçbir şey bozulmaz; yalnızca daha azını görür. + +!!! check + Opak, opak demektir. Bir imleç uydurursanız (`list_resources(cursor="page-2")`) protokolün + sizin için yapabileceği hiçbir şey yoktur. Bu sunucu `int("page-2")`'yi dener, işleyici istisna fırlatır + ve istemciye dönen şudur: + + ```text + MCPError(-32603, 'Internal server error', None) + ``` + + Sunucudan almadığınız bir imleç bir hatadır, bir özellik isteği değil. + +## Özet {#recap} + +* `MCPServer` her şeyi tek sayfada döndürür. Sayfalama isteğe bağlıdır ve buna düşük seviyeli `Server` üzerinde geçersiniz. +* `on_list_resources` (ve `on_list_tools`, `on_list_prompts`, `on_list_resource_templates`) `PaginatedRequestParams | None` alır; ilk sayfa için `params.cursor`, `None`'dır. +* Bir sayfa ile birlikte `next_cursor` döndürürsünüz: sonradan tanıyacağınız herhangi bir dizge ya da geriye bir şey kalmadığında `None`. +* İstemci döngüsü: `cursor=` geçirin, biriktirin, `next_cursor is None` olana kadar tekrarlayın. +* İmleçler opaktır, sayfa boyutu sunucunundur ve sayfalamayan bir istemci yine de birinci sayfayı alır. + +Elle yazılan `Server` API'sinin geri kalanı (`on_call_tool`, `input_schema` dict'leri, `_meta`) **[Düşük seviyeli Server](low-level-server.md)** sayfasında. diff --git a/i18n/tr/pages/client/caching.md b/i18n/tr/pages/client/caching.md new file mode 100644 index 0000000000..359fbe25a9 --- /dev/null +++ b/i18n/tr/pages/client/caching.md @@ -0,0 +1,122 @@ +--- +translation: + sections: [9e7b9a1710e5aeba, b74ca4c1d2ddddee, fa8714e61bf90c5a, 04db67a886b7271c, 857690fb8f876800] + tool: 1 +--- +# Önbellekleme ipuçları {#caching-hints} + +2026-07-28 protokolünde bir sunucunun `tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read` ve `server/discover` için döndürdüğü her sonuç iki alan taşır: `ttlMs`, istemcinin sonucu kaç milisaniye boyunca taze sayabileceğini; `cacheScope` ise önbelleğe alınmış bir sonucun kullanıcılar arasında paylaşılıp paylaşılamayacağını (`"public"`) yoksa tek bir yetkilendirme bağlamına mı ait olduğunu (`"private"`) belirtir. + +Sunucu hiçbir şeyi önbelleğe almaz. Bu alanlar bir *beyandır*: "bu araç listesi herkes için aynı ve bir dakika boyunca değişmeyecek." Bunun üzerine bir istemci (ya da önünüzdeki bir ağ geçidi) turu atlayabilir. İpuçlarına uymak istemcinin tercihidir; onları yayımlamak sunucunun işidir ve bunu sizin yerinize SDK yapar. + +Varsayılan olarak her sonuç `ttlMs: 0, cacheScope: "private"` der: anında bayat, asla paylaşılmaz. Bu her zaman güvenli ve her zaman uyumludur. Listeleriniz gerçekten kararlıysa ve tüm çağıranlar için aynıysa, bunu oluşturma sırasında belirtin: + +```python title="server.py" hl_lines="5-8" +--8<-- "docs_src/caching/tutorial001.py" +``` + +* Eşleme **yöntem adına** göre anahtarlanır ve geçerli anahtarlar yalnızca önbelleğe alınabilir altı yöntemdir. Parametrenin türü `Mapping[CacheableMethod, CacheHint]` olduğundan düzenleyiciniz anahtarları otomatik tamamlar ve bir yazım hatasını siz çalıştırmadan önce işaretler; tür denetleyicisinden kaçan her şey oluşturma sırasında istisna fırlatır. +* Anmadığınız bir yöntem varsayılanları korur. Eşleme bir manifesto değil, bir geçersiz kılma kümesidir. +* `CacheHint(ttl_ms=5_000)` `scope`'u ayarlamadı, bu yüzden `"private"` kalır: çağıran başına beş saniyelik tazelik. Kapsam ve TTL birbirinden bağımsız kararlardır. +* `"server/discover"` da geçerli bir anahtardır, çünkü keşif sonucu herhangi bir liste gibi önbelleğe alınabilir. + +!!! warning + `cacheScope: "public"`, önbelleğe alınmış yanıtınızın *herkese* sunulabileceği anlamına gelir. + Paylaşımlı bir ağ geçidi, istek kimliği doğrulanmış olsa bile, bir kullanıcının sonucunu başka + birine rahatlıkla verir. Bir sonucu yalnızca her çağıran için aynı olduğunda `"public"` olarak + işaretleyin ve `cacheScope`'u asla erişim denetimi olarak kullanmayın: o bir etikettir, kilit değil. + +## İşleyici başına geçersiz kılma {#per-handler-override} + +Alt düzey `Server`'da işleyiciler sonuçlarını elle oluşturur ve `ttl_ms` / `cache_scope` sonuç modellerindeki sıradan alanlardır. Bunları açıkça ayarlayan bir işleyici, alan alan, her zaman oluşturucu eşlemesine üstün gelir: + +```python title="server.py" hl_lines="10 16" +--8<-- "docs_src/caching/tutorial002.py" +``` + +İşleyici `ttl_ms=1_000` dedi, kapsam hakkında ise hiçbir şey söylemedi. İletilen veride: `ttlMs: 1000` (eşlemenin `60_000`'i değil, işleyicininki) ve `cacheScope: "public"` (eşlemeninki, çünkü işleyici onu ayarlamadı). Açık olan yapılandırılanı, yapılandırılan da varsayılanı yener. Bu alan başına geçerlidir; yani bir işleyici bir alanı sabitleyip diğerini sunucu genelindeki politikaya bırakabilir. + +Bu aynı zamanda oluşturucunun bilemeyeceği dinamikler için kaçış kapısıdır: `resources/read`'i kullanıcıya göre filtreleyen bir işleyici, geri kalanı public olan bir sunucudan tek bir URI için `cache_scope="private"` döndürebilir. + +Sayfalandırılmış listelerle ilgili bir uyarı: protokol bir listenin **her sayfasında aynı `cacheScope`'u** şart koşar. Oluşturucu eşlemesi bunu yapısı gereği sağlar, çünkü sayfaya değil yönteme göre anahtarlanır. Ancak kapsamı kendisi geçersiz kılan bir işleyici bu tutarlılıktan kendisi sorumludur: kapsamı *her* sayfada geçersiz kılın, asla yalnızca bir imleç varken değil; yoksa birinci sayfa ile ikinci sayfa çelişir. + +## İstemcinin gördükleri {#what-the-client-sees} + +2026-07-28 oturumunda `Client` ipuçlarına sizin yerinize uyar: varsayılan olarak açık, yerleşik bir yanıt önbelleği vardır. `ttlMs` taşıyarak gelen bir sonuç saklanır ve o TTL içinde yapılan özdeş bir çağrı hiç tur atılmadan önbellekten sunulur. *Hiç* ipucu taşımayan bir sonuç önbelleğe alınmaz: ipucu taşımayan sonuçlar `CacheConfig.default_ttl_ms` değerini alır, bu da varsayılan olarak `0`'dır (anında bayat); dolayısıyla hiçbir şey beyan etmeyen bir sunucu, her zaman gördüğü çağrı başına bir istek trafiğinin aynısını görür. + +```python title="client.py" hl_lines="33 35 38" +--8<-- "docs_src/caching/tutorial003.py" +``` + +Dört çağrı, üç getirme. İkinci çağrı taze bir girdi buldu ve sunucuya hiç ulaşmadı; (enjekte edilen) saati TTL'nin ötesine ilerletmek üçüncünün yeniden getirmesine yol açtı; dördüncü `cache_mode="refresh"` dedi. Bu anahtar sözcük argümanı önbellekleme yapan beş fiilde bulunur (`list_tools`, `list_prompts`, `list_resources`, `list_resource_templates`, `read_resource`): + +* `"use"` (varsayılan) varsa taze bir girdiyi sunar, yoksa getirilen sonucu saklar. +* `"refresh"` asla önbellekten sunmaz: sonucu getirir ve saklar, önbellekte ne varsa onun yerine koyar. +* `"bypass"` önbelleğe hiç dokunmadan turu atar: ne okuma ne yazma. + +`"use"`'un üzerinde bir kural vardır: **`meta` taşıyan çağrılar her zaman sunucuya ulaşır.** `meta` ayarlanmış bir istek (bir ilerleme token'ı, izleme alanları) ağ üzerinde gerçek bir istek bekler; bu yüzden `cache_mode="use"` altında `"refresh"` gibi ele alınır: önbellek okuması atlanır ve getirilen sonuç yine de önbellekteki girdinin yerine geçer. `"bypass"` ve açık bir `"refresh"` her zamanki gibi davranır. + +Önbelleklemeyi tamamen kapatmak için `Client(server, cache=None)` ile oluşturun: her çağrı yeniden bir tur olur ve `cache_mode` hâlâ kabul edilse de hiçbir şey yapmaz. + +Kapsama da otomatik olarak uyulur: `"private"` girdiler önbelleğin *bölümüne* (partition, aşağıda) göre anahtarlanır, `"public"` olanlar ise daha geniş paylaşıma katılmayı seçebilir. Ayrıca **bildirimler TTL'yi yener**, ama yalnızca tam olarak adlandırdıkları girdiler için: bir `list_changed` bildirimi eşleşen önbellekteki listeyi çıkarır, `resources/updated` ise tam olarak kendi URI'si altında saklanan önbellekteki okumayı çıkarır; ne kadar taze olurlarsa olsunlar. 2026-07-28 bağlantısında bu bildirimler `client.listen(...)` ile açtığınız bir `subscriptions/listen` akışı üzerinden gelir ve çıkarma, izleyiciniz olayı görmeden önce tamamlanır; bunun sayfası **[Abonelikler](subscriptions.md)**. + +`resources/updated` ile ilgili bir uyarı: çıkarma yalnızca tam URI eşleşmesiyle olur. Depo sözleşmesinde listeleme ya da tarama işlemi yoktur (referans TypeScript gerçekleştirimiyle aynı); bu yüzden bir *alt* kaynak URI'si taşıyan bir bildirim, üst kaynağının önbellekteki okumasını çıkarmaz. Sunucunuz alt kaynakları bu şekilde bildiriyorsa üst kaynağı `cache_mode="refresh"` ile yeniden getirin. + +### Yapılandırma: `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`: girdilerin yaşadığı yer. Varsayılan, istemci başına yeni bir bellek içi depodur; bir önbelleği istemciler ya da süreçler arasında paylaşmak için kendi `ResponseCacheStore` gerçekleştiriminizi (örneğin Redis destekli) geçirin. Sözleşme türleri (`ResponseCacheStore`, `CacheKey`, `CacheEntry` ve varsayılan `InMemoryResponseCacheStore`) `mcp.client`'tan içe aktarılabilir. Bir arama depoya art arda en fazla iki `get` gönderebilir (önce private kol, sonra public olan); uzak bir deponun gecikme beklentilerini buna göre belirleyin. Özel bir depo açık bir `partition` **gerektirir**. +* `partition`: paylaşımlı bir depo içinde bir principal'ın `"private"` girdilerinin başka birine sunulmasını engelleyen yetkilendirme bağlamı etiketi. +* `target_id`: özel aktarımlar ve süreç içi sunucular için açık sunucu kimliği (aşağıda). +* `default_ttl_ms`: `ttlMs` ipucu taşımayan sonuçlara uygulanan TTL. Varsayılan `0`, ipucu taşımayan sonuçları önbelleğe almadan bırakır. +* `share_public`: sunucunun `"public"` olarak bildirdiği girdileri bölümler arasında sunar (aşağıda). Varsayılan olarak kapalıdır. +* `clock`: epoch saniyesi cinsinden duvar saati kaynağı. Yukarıdaki örnekte olduğu gibi bir tane enjekte edin; böylece süre dolumu testlerinde uyumaya gerek kalmaz. + +!!! warning "Partition = doğrulanmış principal" + `partition`'ı, doğrulanmış bir token'ın subject'i gibi **doğrulanmış bir kimlik bilgisinden** türetin. Onu asla istekle gelen veriden türetmeyin, sunucu URL'sinden de asla (sunucu kimliği ayrı bir anahtar eksenidir). SDK kendi kimlik doğrulaması olmayan bir kütüphanedir: güven çıpası `CacheConfig`'i kim oluşturuyorsa odur; bu da kiracı değil, dağıtımdır. Çok kiracılı bir ağ geçidi, kimliği doğrulanmış her principal için bir `CacheConfig` üretir. + + Bölüm ayrıca `Client`'ın ömrü boyunca sabittir. Bağlantının yetkilendirme bağlamı oturum ortasında değişirse (örneğin farklı bir principal olarak yeniden kimlik doğrulama), önbellek bunu takip etmez; yeni principal için yeni bir `Client` oluşturun. + +Önbellek anahtarları ayrıca **sunucunun kimliğini** de taşır: bağlandığınız URL dizesi, varsa `user:pass@` kullanıcı bilgisi çıkarılmış ve bunun dışında bayt bayt aynı hâliyle. Büyük/küçük harf katlama yok, sorgu yeniden sıralama yok, sondaki eğik çizgi temizliği yok. Az normalleştirmek yalnızca paylaşımdan ödün verir, aşırı normalleştirmek ise iki kiracıyı birleştirebilir (`?tenant=a` ve `?tenant=b`); bu yüzden yüzeysel olarak farklı URL'ler girdi paylaşmaz, o kadar. URL olmadığında (süreç içi bir sunucu ya da bir `Transport` örneği) istemci bunun yerine örnek başına rastgele bir kimlik alır; sunucuya ad vermek için `CacheConfig.target_id`'yi ayarlayın (özel bir depoyla bu zorunludur ve oluşturma bunu söyler). Kimlik, anahtar malzemesine girmeden önce sha256 ile özetlenir; dolayısıyla sorgu dizesinde sır taşıyan bir URL depo anahtarlarında asla görünmez. Özet öncesi hâlini siz de loglamayın. + +!!! warning "`share_public` sunucuya tüm filo genelinde güvenir" + Varsayılan olarak `"public"` girdiler bile kendi bölümlerinde kalır. `share_public=True`, sunucunun `cacheScope: "public"` olarak işaretlediği girdileri depoyu kullanan **her** bölüme sunar; sunucunun sınıflandırmasına hepsi adına güvenir. Kiracıya özgü veriye (hata ya da kötü niyet sonucu) `"public"` damgası vuran bir sunucu, o zaman bir kiracının yanıtını diğerlerine sızdırır. Bayrak bilerek yalnızca oluşturucu düzeyindedir: çağrı başına `cache_mode` önbelleklemeyi daraltabilir, ama çağrı başına hiçbir şey paylaşımı genişletemez. + +### Önbelleğin asla yapmadıkları {#what-the-cache-never-does} + +* **Oturum katmanındaki çağrılar onu atlar.** `client.session.list_tools()` ve benzerleri her zaman turu atar; önbellek `Client` fiillerinde yaşar. +* **`server/discover` bunun dışında kalır.** Keşif sonucu bir kez, bağlanırken teslim edilir ve `ttlMs` taşısa bile asla yanıt önbelleğine girmez. Yeniden bağlanma yoklamasını atlamak için birini kendiniz kalıcı olarak saklarsanız ([`prior_discover`](../protocol-versions.md#reconnecting-with-prior_discover)), tazeliğinin takibi size kalır: `DiscoverResult` tam da bu amaçla, zaten ayrıştırılmış `ttl_ms` ve `cache_scope` alanlarını taşır. +* **Devam sayfaları asla önbelleğe alınmaz.** Yalnızca imleçsiz çağrılar katılır. Süresi dolmuş bir imleç nedeniyle reddedilen bir devam sayfası ise önbellekteki listeyi *çıkarır*, çünkü liste onun altında değişmiştir. +* **Çok turlu (multi-round-trip) okumalar asla önbelleğe alınmaz.** `input_responses`/`request_state` ile tohumlanan ya da girdi turları üzerinden çözümlenen bir `read_resource` asla önbelleğe girmez (belirtimde bir MUST). +* **Bildirimle çıkarma için bildirim gerekir.** Çıkarma ancak aktarımın teslimi kadar iyidir ve modern süreç içi yol (varsayılan `mode="auto"` ile `Client(server)`) bugün bağımsız bildirimleri teslim etmez. +* **Çıkarma anlık değil, er geç gerçekleşir.** Ağ yolundan gelen bildirimler ayrı başlatılan görevlerden dağıtılır; bu yüzden bir bildirimin gelişiyle yarışan bir çağrıya çıkarma öncesi girdi bir kez daha sunulabilir. Pencere dağıtım gecikmesiyle sınırlıdır ve çıkarma yine de gerçekleşir. +* **stale-if-error yok.** Süresi dolmuş bir girdi, yeniden getirme başarısız oldu diye asla sunulmaz; hata yayılır. +* **Erken yeniden getirme yok.** Saklanan bir girdi TTL'si dolana kadar sunulur ve ondan sonraki ilk çağrı turun bedelini öder; arka planda hiçbir şey yenilenmez. +* **Birleştirme yok.** Eşzamanlı iki özdeş çağrı iki getirme demektir. +* **24 saati aşan TTL yok.** Daha büyük bir `ttlMs`, ister sunucudan gelsin ister yapılandırılmış olsun, saklanırken kırpılır (`mcp.client.caching.MAX_TTL_MS`); bu da ipucu ne kadar cömert olursa olsun herhangi bir girdinin ne kadar süre sunulabileceğini sınırlar. +* **Paylaşımlı bir depoda** istemciler birbirleriyle yarışır. Her istemci, bir çıkarma yoldaki getirmeyi geçtiğinde kendi yazmasını düşürür; ancak *komşu kiracı* bir istemci, hiç görmediği bir çıkarmanın kaldırdığı bir girdiyi yine de geri yazabilir. Bu yarış takibinin kendisi de sınırlıdır: izlenen 4096 anahtarı geçince önce en eski anahtarın koruması düşürülür. Her iki pencere de kabul edilmiştir ve yukarıdaki TTL üst sınırıyla kapatılır. +* **Protokol nesilleri arasında sunum yok.** Girdiler anlaşılan protokol sürümüyle kapsamlanır: paylaşımlı kalıcı bir depoda bir oturum, farklı bir anlaşılan sürüm altında yazılmış bir girdiyi asla sunmaz (aynı liste nesle göre gerçekten farklıdır, çünkü SDK eski oturumlar için 2026 alanlarını çıkarır). Çıkarma da aynı şekilde yalnızca geçerli neslin girdilerine dokunur; başka bir neslin girdileri TTL ile kendiliğinden eskiyip gider. + +### İpuçlarını kendiniz okuma {#reading-the-hints-yourself} + +Yerleşik önbelleğin üzerine (ya da yerine) kendi takibinizi katmanlamak isterseniz, ipuçları her önbelleğe alınabilir sonuçta ayrıca düz alanlar olarak da bulunur (`result.ttl_ms` ve `result.cache_scope`, zaten ayrıştırılmış). + +**Daha eski bir sunucuya** karşı (2026 öncesi protokol) alanlar iletilen veride yoktur, o kadar; modeller de ihtiyatlı varsayılanlarını gösterir: `ttl_ms == 0` ve `cache_scope == "private"`, bayat ve paylaşılmamış; hiçbir şey beyan etmemiş bir sunucu için doğru varsayım. Önbellek eski nesil bir oturumu aynı şekilde ele alır: orada ipuçlarına asla bakılmaz (iletilen veride hangi anahtarlar görünürse görünsün), yalnızca `default_ttl_ms` uygulanır ve onun varsayılanı `0` hiçbir şeyi önbelleğe almaz; böylece 2026 öncesi bir bağlantı tam olarak önbellek var olmadan önceki gibi davranır. "Sunucu 0 dedi" ile "sunucu hiçbir şey demedi" arasında ayrım yapmanız gerekiyorsa `"ttl_ms" in result.model_fields_set` ifadesini kontrol edin: yalnızca alan gerçekten geldiğinde ayarlıdır. + +## Daha eski istemciler {#older-clients} + +2026 öncesi protokol sürümlerindeki istemciler iki alanı da asla görmez; SDK bu bağlantılar için onları serileştirme sırasında çıkarır. İpuçlarınızı bir kez yapılandırın; sürüme özgü yazılacak hiçbir şey yok. + +## Özet {#recap} + +* Altı yöntem `ttlMs`/`cacheScope` taşır; SDK bunları varsayılan olarak `0`/`"private"` yapar: bayat ve paylaşılmamış, her zaman güvenli. +* Oluşturma sırasında `cache_hints={method: CacheHint(...)}` (hem `MCPServer` hem `Server`) yöntem başına sunucu genelinde değerler ayarlar. +* Alanları sonucunda ayarlayan bir işleyici eşlemeyi alan bazında geçersiz kılar. +* `"public"`, sonucun her çağıran için aynı olduğuna dair bir sözdür. Erişim denetimi değildir. +* `Client` ipuçlarına otomatik olarak uyar: yanıt önbelleği varsayılan olarak açıktır, yeniden getirmek yerine taze girdileri sunar ve ipucu sağlamayan sunucular (ya da oturumlar) için hiçbir şeyi önbelleğe almaz. +* Çağrı başına `cache_mode="refresh"` yeniden getirir, `"bypass"` önbelleği atlar; oluşturma sırasında `cache=None` onu tamamen kapatır. diff --git a/i18n/tr/pages/client/callbacks.md b/i18n/tr/pages/client/callbacks.md new file mode 100644 index 0000000000..8cb234ac4a --- /dev/null +++ b/i18n/tr/pages/client/callbacks.md @@ -0,0 +1,155 @@ +--- +translation: + sections: [adf3c545b5be46b6, 916cd3ab1c03f461, e9be7a8d0eb0a456, 565890a636288ecf, 6af7e49db9129ec3, 06b0238c174186af, 90c6043be435fcb0] + tool: 1 +--- +# İstemci callback'leri {#client-callbacks} + +MCP'de neredeyse her istek tek yöne gider: istemciden sunucuya. + +Sunucu da **istemciden** bir şeyler isteyebilir: kullanıcıya soru sormasını, kullanıcının modelinden örnekleme yapmasını, kullanıcının çalışma alanı klasörlerini listelemesini. Bu istekleri `Client(...)`'a **callback'ler** (geri çağırma işlevleri) geçirerek yanıtlarsınız. + +## Soru soran bir sunucu {#a-server-that-asks} + +İşte aracı kendi başına tamamlanamayan bir sunucu: + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/client_callbacks/tutorial001.py" +``` + +* `ctx.elicit(...)`, **istemciye** bir `elicitation/create` isteği gönderir ve bekler. +* Araç, birisi (form dolduran bir kişi ya da kodunuz) bir `name` sağlayana kadar dönmez. + +Bu, işin sunucu tarafı; ona **[Elicitation](../handlers/elicitation.md)** sayfası bakar. Bu sayfa ise hattın öteki ucu. + +## Elicitation callback'i {#the-elicitation-callback} + +```python title="client.py" hl_lines="6-10 16-17" +--8<-- "docs_src/client_callbacks/tutorial002.py" +``` + +* Bir elicitation (kullanıcıdan bilgi isteme) callback'i `async (context, params) -> ElicitResult` biçimindedir. +* `params.message` sorudur. `params.requested_schema`, sunucunun beklediği yanıtın JSON Schema'sıdır. Gerçek bir istemci bundan bir form üretir; buradaki ise otomatik doldurur. +* `ElicitResult(action="accept", content={...})` döndürürsünüz; ya da `action="decline"` veya `action="cancel"`. Bunların dışındaki tek seçenek, isteği reddedip çağrının tamamını başarısız kılan `ErrorData(...)`'dır. +* `context` bir `ClientRequestContext`'tir: canlı `session`, sunucunun `request_id`'si ve eklediği her türlü `meta`. + +!!! tip + `params`, iki elicitation kipinin birleşimidir (union). Burada `params.mode` değeri `"form"`; bir `"url"` + isteği ise şema yerine `params.url` taşır. Tek callback ikisini de karşılar; `params.mode` üzerinden dallanın. + Kalıbın tamamı **[Elicitation](../handlers/elicitation.md)** sayfasında. + +### Deneyin {#try-it} + +`issue_card`'ı çağırın ve iki ucu da izleyin. + +Callback'iniz sunucunun sorusunu hazır ayrıştırılmış olarak alır: + +```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'} +``` + +Yanıt verir, `ctx.elicit(...)` aracın içinde kaldığı yerden devam eder ve araç tamamlanır: + +```python +result.content # [TextContent(type='text', text='Card issued to Ada Lovelace.')] +``` + +Sizden tek bir `tools/call`, sunucudan geriye tek bir `elicitation/create`, onu yanıtlayan da sizin fonksiyonunuz; hepsi tek bir araç çağrısının içinde. + +!!! info + `Client(...)` çağrısındaki `mode="legacy"` gerçekten iş yapıyor. Varsayılan olarak `Client(...)` modern + protokol yolunu müzakere eder ve o yolda sunucudan istemciye gelen istekler için bir geri kanal (back-channel) + yoktur: `ctx.elicit`, callback'iniz daha çalışmadan başarısız olur. Buna aktarım karar vermez; müzakere edilen + protokol karar verir, bellek içinde de bir URL üzerinden de aynı şekilde. İstemcinizin böyle bir isteği + yanıtlaması gerektiğinde `mode="legacy"`'yi sabitleyin; bu sayfanın arkasındaki her test bunu yapar. + Ayrıntıların tamamı **[Protokol sürümleri](../protocol-versions.md)** sayfasında. + + Bir 2026-07-28 oturumunda callback ölü değildir, yalnızca farklı beslenir: bir araç, `ElicitRequest` taşıyan bir + `InputRequiredResult` döndürdüğünde `Client` o girdiyi aynı `elicitation_callback`'e yönlendirir ve çağrıyı + sizin adınıza yeniden dener. Bu akış **[Çok turlu istekler](../handlers/multi-round-trip.md)** sayfasında. + +## Callback bir yetenektir {#a-callback-is-a-capability} + +İstemcinizin elicitation isteklerini yanıtlayabildiğini sunucuya hiç söylemediniz. SDK söyledi. + +Bir istemci bağlandığında `capabilities`'ini, yani sunucununkinin aynadaki yansımasını bildirir. O nesneyi siz yazmazsınız. **Callback'i kaydetmek bildirimin ta kendisidir.** + +| geçirdiğiniz | istemcinin bildirdiği | +| --- | --- | +| `elicitation_callback=` | `"elicitation": {"form": {}, "url": {}}` | +| `sampling_callback=` | `"sampling": {}` | +| `list_roots_callback=` | `"roots": {"listChanged": true}` | +| hiçbiri | `{}` | + +Tek ince ayar örnekleme alt yetenekleridir: örnekleyiciniz `tools` / `tool_choice` parametrelerini işliyorsa `sampling_callback`'in yanında `sampling_capabilities=SamplingCapability(tools=SamplingToolsCapability())` geçirin. Sunucular bunları gönderebilmek için önce `sampling.tools`'un bildirildiğini görmelidir. + +`logging_callback` ve `message_handler` tabloda yok. Onlar bildirimleri işler ve bildirimler yetenek gerektirmez. + +Sunucu bildirimi `ctx.session.check_client_capability(...)` ile geri okur. Bunu yapan bir araç ekleyin: + +```python title="server.py" hl_lines="23-31" +--8<-- "docs_src/client_callbacks/tutorial003.py" +``` + +Yalnızca `elicitation_callback` ile bağlanın ve aracı çağırın: + +```python +result.structured_content # {'result': ['elicitation']} +``` + +Üç callback'i de geçirirseniz `['elicitation', 'sampling', 'roots']` alırsınız. Hiçbirini geçirmezseniz `[]` alırsınız. + +!!! check + Şimdi yanlış olanı yapın: `elicitation_callback` **olmadan** bağlanın ve yine de `issue_card`'ı çağırın. + + Sunucunun `elicitation/create` isteği yine istemcinize ulaşır ve SDK onu sizin yerinize yanıtlar; ama bir hatayla, + çünkü bunu karşılayabileceğinizi hiç söylemediniz. O hata çağrının tamamını batırır. + `call_tool` bir `is_error` sonucu döndürmez; istisna fırlatır: + + ```text + MCPError: Elicitation not supported + ``` + + Bu bir araç hatası değil, bir protokol hatasıdır (`-32600`, *invalid request*): modelin okuyup yeniden + deneyebileceği bir şey yoktur. `client_features`'ın değerli olmasının nedeni de bu: uslu bir sunucu + sormadan önce kontrol eder. + +## Kullanım dışı ikili {#the-deprecated-pair} + +`sampling_callback`, `sampling/createMessage`'ı yanıtlar: sunucunun *sizin* modelinizden bir şeyi tamamlamasını istemesi. `list_roots_callback`, `roots/list`'i yanıtlar: sunucunun hangi dizinlerde çalışabileceğini sorması. + +İkisi de çalışır. İkisi de yukarıdaki kurala uyar. Ve ikisi de **2026-07-28 spesifikasyonunun kaldırdığı** RPC'lere hizmet eder: modern bir sunucu istek ortasında istemcinizi geri çağırmaz, isteği araç sonucunun bir parçası olarak size geri verir (**[Çok turlu istekler](../handlers/multi-round-trip.md)**). Callback'lerin kendisi ölü değildir. Bir `InputRequiredResult`, `CreateMessageRequest` veya `ListRootsRequest` taşıdığında `Client`'ın otomatik döngüsü onu burada kaydettiğiniz aynı `sampling_callback` veya `list_roots_callback`'e yönlendirir. Listenin tamamı **[Kullanım dışı özellikler](../deprecated.md)** sayfasında. + +Henüz geçiş yapmamış sunucularla konuşmak için callback'lere hâlâ ihtiyacınız var. İmzalar: + +```python title="client.py" +--8<-- "docs_src/client_callbacks/tutorial004.py" +``` + +* Bir örnekleme (sampling) callback'i `CreateMessageRequestParams`'ın tamamını (`messages`, `model_preferences`, `max_tokens`) alır ve bir `CreateMessageResult` döndürür. Modeli *siz* çalıştırırsınız, nasıl isterseniz öyle; SDK yalnızca isteği taşır. +* Bir kök dizinler (roots) callback'i hiç parametre almaz ve bir `ListRootsResult` döndürür. +* Her ikisi de reddetmek için bunun yerine `ErrorData(...)` döndürebilir. + +Bunları `Client(...)`'a tıpkı `elicitation_callback` gibi geçirin. + +## Bildirim callback'leri {#the-notification-callbacks} + +İki tane daha. Hiçbiri bir şey bildirmez. + +`logging_callback`, sunucunun gönderdiği `notifications/message`'ı `LoggingMessageNotificationParams` (`level`, `logger`, `data`) olarak alır. Protokol log'lamasının kendisi 2026-07-28 spesifikasyonuyla kullanım dışı bırakıldı (yerine ne yapılacağı **[Log kaydı](../handlers/logging.md)** sayfasında); bu yüzden bu callback, hâlâ bunu yayan sunucular için var. 2026 neslinden bir bağlantıda callback tek başına size hiçbir şey kazandırmaz, çünkü 2026 sunucuları log mesajlarını yalnızca bunu talep eden isteklere gönderir: bu talebi her isteğe damgalamak ve o düzey ile üstünü almak için `Client(...)`'a `log_level="info"` (veya başka bir düzey) geçirin. 2026 öncesi sunucular bunu yok sayar ve `logging/setLevel` davranışlarını sürdürür. + +`message_handler` her şeyi yakalayandır: oturumun yüzeye çıkardığı her sunucu bildirimi ona ulaşır (kendi özel callback'inin yanı sıra), akış tabanlı bir aktarımda aktarım düzeyindeki her `Exception` da öyle. İkisi asla ulaşmaz: `notifications/cancelled` yüzeye çıkarılmak yerine SDK tarafından uygulanır ve canlı bir `listen()` akışının abonelik onayı o akış tarafından tüketilir. Parametreye `IncomingMessage` (`ServerNotification | Exception`, `mcp.client`'tan dışa aktarılır) tür ipucunu verin. Bilmeye değer tek kalıp `if isinstance(message, Exception): raise message`'dır; böylece kopan bir bağlantı sessizce kaybolmak yerine gürültüyle başarısız olur. + +## Özet {#recap} + +* Sunucu istemciye istek gönderebilir. Bunları `Client(...)`'a geçirdiğiniz callback'lerle yanıtlarsınız. +* Güncel olan elicitation callback'idir: `async (context, params) -> ElicitResult`, hem form hem URL kipi için tek fonksiyon. +* **Callback'i kaydetmek yeteneği bildirmektir.** O olmadan SDK sunucunun isteğini sizin adınıza reddeder ve çağrının tamamı `MCPError` ile başarısız olur. +* Sunucu, sormadan önce `ctx.session.check_client_capability(...)` ile öğrenir. +* `sampling_callback` ve `list_roots_callback` aynı şekilde çalışır ama kullanım dışı özelliklere hizmet eder; modern sunucular bunun yerine çok turlu istekler (multi-round-trip) kullanır. +* `logging_callback` ve `message_handler` bildirimleri alır. Hiçbir şey bildirmezler. + +`Client(...)`'ın ilk argümanı bir aktarım nesnesidir. **[İstemci aktarımları](transports.md)** her türünü ele alır. diff --git a/i18n/tr/pages/client/identity-assertion.md b/i18n/tr/pages/client/identity-assertion.md new file mode 100644 index 0000000000..1ab72e8610 --- /dev/null +++ b/i18n/tr/pages/client/identity-assertion.md @@ -0,0 +1,155 @@ +--- +translation: + sections: [a91322c46111d16d, 8e6fd6d6f59bb568, e7828fd2729b2c9d, a03ec26bfc678b65, 1034c653c0bcf1b0] + tool: 1 +--- +# Kimlik beyanı {#identity-assertion} + +Sıradan bir OAuth sağlayıcısı (**[OAuth istemcileri](oauth-clients.md)**) işe MCP sunucusuna bir soru sorarak başlar: *hangi yetkilendirme sunucusuna güveniyorsun?* Yanıt nereyi gösteriyorsa oraya gider; ardından ya bir kişi oturum açar ya da önceden paylaşılmış bir gizli anahtar onun yerini tutar. + +Bir kurum ise bunların hiçbirinin sunucu başına kararlaştırılmasını istemez. Zaten bir kimlik sağlayıcısı işletir (Okta, Microsoft Entra ID, kendi yazdığınız); kullanıcı ona bu sabah zaten oturum açmıştır ve güvenlik ekibinin kimin neye erişebileceğine karar vermek istediği tek yer orasıdır. **Enterprise-Managed Authorization** uzantısı olan [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990), kararı oraya taşır. IdP kısa ömürlü bir JWT imzalar: bir **Identity Assertion JWT Authorization Grant**, kısaca **ID-JAG**. Bu, *şu kullanıcının*, *şu istemci* aracılığıyla *şu MCP sunucusuna* erişebileceğini söyleyen bir beyandır. İstemci onu sıradan bir erişim token'ıyla takas eder. Tarayıcı yok, onay ekranı yok, dinamik kayıt yok. + +Bu sayfa o takasın iki ucunu da anlatır. MCP sunucusunun kendisi hiç değişmez: hâlâ **[Yetkilendirme](../run/authorization.md)** sayfasındaki kaynak sunucusudur ve önüne hangi token gelirse onu denetler. + +## İki token isteği {#two-token-requests} + +İşin içinde iki farklı otorite var ve bu sayfayı anlamanın büyük kısmı ikisini ayrı adlarla anmaktan geçer. **Kurumsal IdP**, kuruluşunuzun kimlik sağlayıcısıdır: çalışanın kim olduğunu bilir, politikanın bulunduğu yerdir ve ID-JAG'i o düzenler. SDK onunla hiç konuşmaz. **MCP yetkilendirme sunucusu** ise **[Yetkilendirme](../run/authorization.md)** sayfasındaki aynı taraftır: MCP sunucusunun meta verisinde adı geçen issuer, o MCP sunucusunun kabul ettiği token'ları basan şey. Sıradan bir OAuth akışında bu iki rol genellikle tek bir kutudur. Burada iki ayrı kutudur ve grant'in tamamı, ikincisinin birincisine güvenmeyi kabul etmesinden ibarettir. + +İstemci her birine birer token isteği gönderir. + +1. **Kurumsal IdP'ye.** İstemci, kullanıcının oturum açma bilgisini (OpenID Connect ID token'ını) ID-JAG ile takas eder. Bu bir [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) token takasıdır, tamamen IdP'nizin API'sidir ve **bu isteği SDK yapmaz**. Siz yaparsınız, tek bir asenkron callback'in içinde. Politika kararı da burada verilir: hayır diyen bir IdP ID-JAG'i hiç düzenlemez ve ortada sunulacak bir şey kalmaz. +2. **MCP yetkilendirme sunucusuna.** İstemci ID-JAG'i [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) `jwt-bearer` grant'i kapsamında sunar (`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`, ID-JAG de `assertion` olarak) ve erişim token'ını alır. **SDK'nın yaptığı istek budur** ve bu sayfanın bir yetkilendirme sunucusuna eklediği tek şey de onu kabul etmektir. + +Aşağıdaki her şey ikinci istekle ilgilidir: onu gönderen istemci ve yanıtlayan yetkilendirme sunucusu. + +## İstemci {#the-client} + +**`IdentityAssertionOAuthProvider`**, `mcp.client.auth.extensions.identity_assertion` modülünde bulunur. **[OAuth istemcileri](oauth-clients.md)** sayfasındaki her sağlayıcı gibi o da bir `httpx2.Auth` nesnesidir: bir tane oluşturun, `auth=` parametresine verin, `httpx2.AsyncClient`'ı aktarıma teslim edin. + +```python title="client.py" hl_lines="49-50 53-61" +--8<-- "docs_src/identity_assertion/tutorial001.py" +``` + +Aşağıdan yukarıya okuyun. + +* `main()`, standart OAuth istemcisi `main()`'idir (**[OAuth istemcileri](oauth-clients.md)**), satırı satırına aynı. Mesele de bu: sağlayıcı bir kez var olduktan sonra, akışın devamındaki hiçbir şey token'ı hangi grant'in ürettiğini bilmez. +* Sağlayıcı, diğer sağlayıcıların keşfedemeyeceği şeyleri alır: birinin yetkilendirme sunucusuna **önceden kaydettirdiği** bir `client_id` ve `client_secret`, o yetkilendirme sunucusunun `issuer`'ı ve `assertion_provider`, yani istendiğinde taze bir ID-JAG döndüren asenkron bir callback. +* `storage` aynı `TokenStorage` protokolüdür. Yalnızca iki token metodu çağrılır; burada dinamik kayıt olmadığından hatırlanacak bir `client_info` da yoktur. + +### Beyan sağlayıcı {#the-assertion-provider} + +Yazdığınız tek kod `fetch_id_jag(audience, resource)` fonksiyonudur. Her token takasında bir kez await edilir; oluşturma sırasında asla, ve ancak yetkilendirme sunucusunun meta verisi alınıp doğrulandıktan *sonra*. Böylece yanlış yapılandırılmış bir issuer hiçbir zaman bir beyan sızdırmaz. İki argümanı, ID-JAG'in basılırken taşıması gereken claim'lerden ikisidir: `audience` yetkilendirme sunucusunun issuer'ıdır (ID-JAG'deki `aud`), `resource` ise MCP sunucusunun kanonik tanımlayıcısıdır (ID-JAG'deki `resource`). Üçüncüsü zaten elinizde: ID-JAG'in `client_id` claim'i, sağlayıcıya verdiğiniz `client_id`'yi göstermelidir; yoksa yetkilendirme sunucusu takası reddeder. + +Onun üstündeki `idp_issue_id_jag` **sizin kodunuz değildir**. Kimlik sağlayıcısının yerini tutar; dosya eksiksiz olsun ve bir ID-JAG'in taşıdığı her claim'i okuyabilesiniz diye beyanı süreç içinde imzalar. Gerçek bir `fetch_id_jag` ise bunun yerine önceki bölümdeki ilk token isteğini yapar: IdP'nize karşı bir [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) token takası. Bu takası, [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) belgesinin profil olarak daralttığı Identity Assertion JWT Authorization Grant taslağı tanımlar. Oturum açmış kullanıcının ID token'ı `subject_token` olarak girer, `requested_token_type` ID-JAG'in kendi URN'idir (`urn:ietf:params:oauth:token-type:id-jag`), `audience` ve `resource` olduğu gibi aktarılır ve yanıt ID-JAG'i taşır. IdP'nizin belgelerinde aramanız gereken şey, bu adlarla anılan bu takastır. + +!!! tip + Her takas için taze bir ID-JAG istenir ve amaç da budur: tek kullanımlık, ömrü dakikalarla + ölçülen bir grant'tir ve bu sayfadaki yetkilendirme sunucusu aynısını ikinci kez kabul etmez. + Onu önbelleğe almayın. Yeniden kullanılan şey, size kazandırdığı erişim token'ıdır. + +### Yapılandırma olarak issuer {#the-issuer-is-configuration} + +Tersine çevirme işte burada. `OAuthClientProvider`, kaynak sunucusuna hangi yetkilendirme sunucusunu kullanacağını sorar ve yanıt nereyi gösteriyorsa oraya gider. Bu sağlayıcı bunu reddeder: `issuer` zorunludur, [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) meta verisi o issuer'ın kendi well-known yolundan alınır, token endpoint'i o issuer'ın origin'inde olmalıdır ve kaynak sunucusuna hiçbir şey sorulmaz. + +Uzantı bunu şart koşmaz; bu, bilerek yapılmış daha katı bir tercihtir. Bu istemci çalınmaya değer iki şey taşır: önceden kaydedilmiş bir gizli anahtar ve audience'a bağlı bir beyan. Ele geçirilmiş bir MCP sunucusunun kendisini saldırganın yetkilendirme sunucusuna yönlendirmesine izin veren bir istemci, ikisini de oraya gönderirdi. Oluşturma sırasında issuer'ı sabitlemek bu konuşmayı ortadan kaldırır. + +!!! warning + Yapılandırılan `issuer`, meta veri belgesinin `issuer` alanıyla RFC 8414 §3.3'teki basit dize + karşılaştırmasıyla karşılaştırılır: karakter karakter, sondaki eğik çizgi dahil, normalleştirme + olmadan. Tahmin etmeyin. Yetkilendirme sunucunuzdan `/.well-known/oauth-authorization-server` + belgesini alın ve döndürdüğü `issuer` değerini kopyalayın. Bu sayfadaki yetkilendirme sunucusu + için bu değer, eğik çizgisiyle birlikte `https://auth.example.com/` adresidir; çünkü issuer'ı + bir pydantic URL nesnesinden oluşturulmuştur. Bir uyuşmazlık, tek bir kimlik bilgisi ya da + beyan gönderilmeden akışı `OAuthFlowError: Authorization server metadata issuer + mismatch` hatasında durdurur. + +### Gizli istemci {#a-confidential-client} + +`client_secret` zorunludur; yapıcı onsuz `ValueError` fırlatır. [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) belgesinin dayandığı IETF profili bu grant'i gizli istemcilere ayırır, SEP-990 istemcinin kimliğini doğrulamasını şart koşar ve bu SDK, paylaşılan bir gizli anahtarda ısrar ederek ikisini de uygular. `token_endpoint_auth_method`, anahtarın nereden gideceğini seçer: `client_secret_post` (varsayılan, form gövdesinde) veya `client_secret_basic` (bir HTTP Basic başlığı). Profil `private_key_jwt` yöntemine de izin verir; bu sağlayıcı onu desteklemez. + +!!! tip + `client_secret`'ı ortam değişkenlerinden veya bir gizli anahtar yöneticisinden okuyun, asla + kaynak kod deposundan değil. + +### Sağlayıcının sizin için yaptıkları {#what-the-provider-does-for-you} + +İlk istek kimlik doğrulaması olmadan gider ve sunucunun `401` yanıtı akışı başlatır. + +1. **Keşif.** Yetkilendirme sunucusu meta verisini yapılandırılan issuer'ın [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) well-known yolundan alır, belgenin `issuer` alanının eşleştiğini denetler ve token endpoint'inin issuer'ın origin'inde olduğunu denetler. +2. **Beyan.** `assertion_provider`'ınızı await eder. +3. **Takas.** `jwt-bearer` grant'ini token endpoint'ine POST eder, `OAuthToken`'ı saklar ve özgün isteğinizi `Authorization: Bearer ...` ile yeniden gönderir. + +`WWW-Authenticate` başlığında `insufficient_scope` geçen bir `403`, 2. ve 3. adımları sizin `scope`'unuz ile yanıtın istediği kapsamın birleşimiyle yeniden çalıştırır. (`scope` yalnızca bir istektir; bu sayfadaki yetkilendirme sunucusu ID-JAG ne diyorsa onu verir, başka bir şey değil.) Bunun hiçbir yerinde yenileme token'ı yoktur: erişim token'ının süresi dolduğunda bir sonraki `401` taze bir ID-JAG bastırır ve takas yeniden yapılır; IdP'nin elinde tuttuğu kaldıraç işte *budur*. Hatalar, **[OAuth istemcileri](oauth-clients.md)** sayfasının geri kalanındaki aynı iki istisnadır: keşif ve doğrulama için `OAuthFlowError`, token endpoint'i hayır dediğinde onun alt sınıfı `OAuthTokenError`. + +## Yetkilendirme sunucusu {#the-authorization-server} + +Çoğu zaman burada durursunuz. MCP yetkilendirme sunucusu başkasının ürünüdür, ID-JAG kabul etmek o ürünün açılacak bir yapılandırmasıdır ve SDK'nın [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) içindeki payı yukarıdaki istemcidir. + +SDK yetkilendirme sunucusunun kendisi de *olabilir*: `create_auth_routes`, yetkilendirme sunucusunun route'larını herhangi bir Starlette uygulamasının bağlayabileceği bir liste olarak döndürür; depodaki `examples/servers/simple-auth/` da bir tanesini böyle çalıştırır. SEP-990 bu yüzeye bir bayrak ve bir metot ekler: + +```python title="auth_server.py" hl_lines="48-50 105-107" +--8<-- "docs_src/identity_assertion/tutorial002.py" +``` + +* `identity_assertion_enabled=True` her şeyin kapısıdır. Kapalıyken (varsayılan budur) `/token`, hook'u uygulamış olsanız bile bu grant'e `unsupported_grant_type` ile yanıt verir ve meta veri ondan söz etmez. Açıkken meta veri `jwt-bearer` grant türünü kazanır ve uzantının desteği duyurmak için kullandığı alan olan `authorization_grant_profiles_supported` içinde `urn:ietf:params:oauth:grant-profile:id-jag` değerini listeler. (Bu SDK'nın istemcisi onu hiç okumaz: tek bir issuer için hazırlanmıştır ve doğrudan sorar.) +* **`exchange_identity_assertion`** hook'un kendisidir. O çalışmadan önce SDK istemcinin kimliğini doğrulamış, açık (public) istemcileri reddetmiş ve kaydında bu grant'in listelenmediği istemcileri reddetmiştir. Size bir `IdentityAssertionParams` gelir (ham `assertion`, istenen `scopes` ve `resource`) ve düz bir `OAuthToken` döndürürsünüz. +* Dinamik istemci kaydı bu grant'i koşulsuz reddeder; bu yüzden buradaki `get_client` elle hazırlanmış bir istemci sunar. Bir ID-JAG istemcisi kendi kendini kaydederek var olamaz. +* Sınıfın yarısı retlerden oluşur. `OAuthAuthorizationServerProvider` yetkilendirme sunucusunun *tamamıdır*, bu yüzden yetkilendirme kodu akışını da ister; kullanıcılara oturum da açtıran bir sunucu onları gerçekten uygular, bunun ise tam olarak tek bir kapısı var. + +!!! warning + SDK beyanın kodunu hiçbir zaman çözmez: hangi IdP'ye güvendiğini ve o IdP'nin hangi + anahtarları yayımladığını yalnızca sizin dağıtımınız bilir; bu yüzden + `exchange_identity_assertion` içindeki her şey yük taşır. İmzayı IdP'nin yayımladığı + anahtarlara karşı (JWKS'i; buradaki paylaşılan gizli anahtar demoya aittir), `iss` ve `exp` + değerlerini de [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) §3 uyarınca doğrulayın. JWT başlığındaki `typ` değerinin + `oauth-id-jag+jwt` olmasını şart koşun; bu, profilin başka bir JWT'nin grant olarak yeniden + oynatılmasına karşı koyduğu korumadır. `aud` değerinin kendi issuer'ınız olmasını şart koşun. + ID-JAG'in `client_id` claim'inin işleyicinin kimliğini doğruladığı istemciye eşit olmasını, + `resource` claim'inin de gerçekten sunduğunuz bir kaynağı göstermesini şart koşun. Beyan + yalnızca bir kez kabul edilsin diye `jti` değerini beyanın `exp` süresine kadar takip edin. + Verilen kapsamları ve her şeyden önce düzenlenen token'ın `resource` değerini istekten değil, + doğrulanmış ID-JAG'den alın: `params.resource` istemci ne yazdıysa odur. İşleme kurallarının + tamamı [Enterprise-Managed Authorization spesifikasyonunda](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization) yer alır. + +Kötü bir beyanı `TokenError("invalid_grant", ...)` ile reddedin. Bu akıştaki diğer hata kodu `invalid_target`'tır: sunmadığınız bir kaynağı gösteren bir ID-JAG onunla reddedilir; bu sunucunun başkasının kaynağı için token basmasını engelleyen de budur. Verilen kapsamlar ise ID-JAG'in `scope` claim'inden gelir (bu claim'i olmayan bir beyan da reddedilir); sizinki bunun yerine kullanıcının gruplarını eşleyebilir. + +Döndürülen `OAuthToken`'ın ne taşımadığına da dikkat edin: bir yenileme token'ı. IdP, bir sonraki ID-JAG'i düzenleyip düzenlemeyeceğine karar vererek bu kullanıcının erişimi ne kadar süre koruyacağına karar verir. Burada basılacak bir yenileme token'ı o kararı sessizce geri teslim ederdi. + +!!! info + Yetkilendirme sunucusunu hâlâ `auth_server_provider=` ile içine gömen bir sunucu, aynı koda + `AuthSettings(identity_assertion_enabled=True)` üzerinden ulaşır. Yeni sunucuların neden oradan + başlamaması gerektiğini **[Yetkilendirme](../run/authorization.md)** sayfası açıklar. + +!!! check + Bu sayfadaki iki dosyayı birbirine bağlayın; grant'in tamamı tek bir `POST /token` olur: + + ```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"} + ``` + + `/authorize` yok, `/register` yok, korumalı kaynak meta verisi isteği yok. Ağ üzerindeki tek + istekler `401`'i çeken istek, well-known isteği, bu takas ve ardından bearer eklenmiş sıradan + MCP trafiğidir. Doğrulayıcınızın ID-JAG'den okuduğu `sub` da bir aracın içinde + `get_access_token().subject`'in bildirdiği değerin ta kendisidir. + +### Deneyin {#try-it} + +SDK deposundaki `examples/stories/identity_assertion/`, bu sayfanın gerçekten çalışan hâlidir: aynı `exchange_identity_assertion` doğrulayıcısı, onun token'larıyla korunan bir MCP sunucusu, yerine geçen bir IdP ve istemci; hepsi kendi kendini denetleyen tek bir programda. `uv run python -m stories.identity_assertion.client --http` takasın tamamını çalıştırır ve IdP'nin adını verdiği kullanıcının, aracın gördüğü kullanıcı olduğunu doğrular. + +## Özet {#recap} + +* [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990), bir istemcinin hangi MCP sunucularına erişebileceğine son kullanıcının değil, kurumsal kimlik sağlayıcısının karar vermesini sağlar. IdP o kararı imzalayıp bir **ID-JAG** içine koyar. +* ID-JAG'i elde etmek *IdP'nize* karşı yapılan bir [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) token takasıdır ve SDK bunu yapmaz. Onu MCP yetkilendirme sunucusuna sunmak [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) `jwt-bearer` grant'idir ve SDK bunun iki tarafını da üstlenir. +* `IdentityAssertionOAuthProvider` bir başka `httpx2.Auth` nesnesidir: önceden kaydedilmiş gizli bir istemci, sabitlenmiş bir `issuer` ve tek bir `assertion_provider(audience, resource)` callback'i. Tarayıcı yok, kayıt yok, yenileme token'ı yok. +* Yetkilendirme sunucusu hiçbir zaman kaynak sunucusundan keşfedilmez. `issuer`'ı, meta veri belgesinin sunduğu dizenin tıpatıp aynısı olarak yapılandırın; karşılaştırma karakter karakter yapılır. +* Sunucu tarafında `identity_assertion_enabled=True` artı `exchange_identity_assertion`. SDK istemcinin kimliğini doğrular ve grant'in kapısını tutar; ID-JAG'i doğrulamak tamamen size aittir ve düzenlenen token isteğin değil, ID-JAG'in `resource` değerine bağlanır. + +Bu sayfanın hiç dokunmadığı tek taraf MCP sunucusudur. Az önce bastığınız token'la ne yapıyorsa, onu **[Yetkilendirme](../run/authorization.md)** sayfasında zaten yapıyordu. diff --git a/i18n/tr/pages/client/index.md b/i18n/tr/pages/client/index.md new file mode 100644 index 0000000000..45c9503bd6 --- /dev/null +++ b/i18n/tr/pages/client/index.md @@ -0,0 +1,217 @@ +--- +translation: + sections: [ebef1e7a0df854f4, a4c687d3d627d516, 8e79141fc2985342, b345dd05b9c3c7ab, 80ce41579825a6fa, 5f0fa90494de8f65, 83d10514eaa62fa5, 9190555aa39a5d28, 84a4c9d8bf14dddb, 927d71cf40b58c30] + tool: 1 +--- +# İstemci {#the-client} + +**`Client`**, bir Python programının bir MCP sunucusuyla konuşmasını sağlayan nesnedir. + +Tek bir yaşam döngüsü olan tek bir nesnedir: oluşturun, `async with` bloğuna girin, yöntemleri çağırın. Her protokol fiili (araçları listeleme, birini çağırma, bir kaynağı okuma, bir prompt'u oluşturma) bu nesne üzerinde, türü belirli bir sonuç döndüren bir `async` yöntemdir. + +## İlk istemciniz {#your-first-client} + +```python title="client.py" hl_lines="14-18" +--8<-- "docs_src/client/tutorial001.py" +``` + +Üstteki sunucu yalnızca bağlanacak bir şeyiniz olsun diye orada. İstemci, vurgulanan beş satırdan ibaret. + +* `Client(mcp)` çağrısına **sunucu nesnesinin kendisi** verilir. Bu, bellek içi aktarımdır: alt süreç yok, port yok, HTTP yok. Bu sayfadaki her örnek ve yazdığınız her test böyle bağlanır. +* `async with` **yaşam döngüsüdür**. Bloğa girdiğinizde bağlantı kurulur ve anlaşma yapılır; çıktığınızda bağlantı kesilir. `connect()` / `close()` çifti yoktur ve blok bittikten sonra bir `Client` yeniden kullanılamaz. +* Bloğun içinde bağlantı bilgileri düz özellikler olarak zaten hazırdır. + +### `Client`'a geçirebilecekleriniz {#what-you-can-pass-to-client} + +`Client` tek bir konumsal argüman alır ve aktarımı onun türünden çözümler: + +* Bir `MCPServer` (veya düşük seviyeli `Server`) örneği: **süreç içinde** bağlanır. +* Bir URL dizesi (`Client("http://localhost:8000/mcp")`): Streamable HTTP, yani üretim yolu. +* Bir **aktarım**: `async with ... as (read, write)` ile kullanabileceğiniz herhangi bir şey; örneğin bir alt süreci saran `stdio_client(...)`. + +Bu sayfadaki geri kalan her şey üçünde de aynıdır. Başlıklar, alt süreçler, zaman aşımları ve `Transport` protokolünün kendi sayfası var: **[İstemci aktarımları](transports.md)**. + +### Bağlı bir istemcide bulunanlar {#whats-on-a-connected-client} + +Bloğa girdiğiniz anda doldurulan dört salt okunur özellik: + +* `client.server_info`: sunucunun kimliği; kimlik bildirmeyen 2026 neslinden bir sunucu için `None` (python-sdk sunucuları varsayılan olarak bildirir). Burada `server_info.name` `"Bookshop"`, `server_info.version` ise sunucu ne bildiriyorsa odur. +* `client.server_capabilities`: sunucunun neler yapabildiği (`tools`, `resources`, `prompts`, `completions`, ...). Sunucuda olmayan bir yetenek `None` olur. +* `client.protocol_version`: iki tarafın üzerinde anlaştığı protokol sürümü. Burada `"2026-07-28"`. +* `client.instructions`: sunucunun `instructions=` dizesi; sunucu bir tane ayarlamadıysa `None`. + +Hiç protokol sürümü seçmediniz. Varsayılan olarak `Client` sunucuyu yoklar ve eski sunucularda klasik el sıkışmaya geri döner; böylece tek bir istemci her nesilden sunucuyla çalışır. Bunu denetlemeniz gerektiğinde ayrıntıların tamamı **[Protokol sürümleri](../protocol-versions.md)** sayfasında. + +!!! tip + `client.session`, alttaki `ClientSession`'dır; düşük seviyeli kaçış kapısı. + Bu sayfadaki hiçbir şey için ona ihtiyacınız olmaz. + +## Araçları listeleme {#listing-tools} + +```python title="client.py" hl_lines="15-20" +--8<-- "docs_src/client/tutorial002.py" +``` + +`list_tools()` bir `ListToolsResult` döndürür; araçlar `.tools` içindedir. Her biri, bir host'un modele vereceği eksiksiz tanımdır: + +```python +tool.name # 'search_books' +tool.title # 'Search the catalog' +tool.description # 'Search the catalog by title or author.' +``` + +`tool.input_schema` ise sunucunun fonksiyonun tür ipuçlarından türettiği JSON Schema'dır: + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"default": 10, "title": "Limit", "type": "integer"} + }, + "required": ["query"], + "title": "search_booksArguments" +} +``` + +Bu şema, bir arayüzün argüman formu oluşturması için gereken her şeydir; bir modelin geçerli argümanlar üretmesi için gereken her şey de odur. + +!!! tip + `title` isteğe bağlıdır; bu yüzden araçları bir insana gösteren arayüzün seçim yapması gerekir: varsa `title`, + yoksa `name`. `from mcp.shared.metadata_utils import get_display_name` tam olarak bunu yapar; + araçlar, kaynaklar, kaynak şablonları ve prompt'lar için. + +## Bir aracı çağırma {#calling-a-tool} + +`call_tool(name, arguments)` aracı çalıştırır ve size bir `CallToolResult` geri verir. + +```python title="client.py" hl_lines="26-33" +--8<-- "docs_src/client/tutorial003.py" +``` + +Sunucunun `lookup_book` aracı bir Pydantic `Book` döndürür. İstemcinin gördüğü şudur: + +```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 +``` + +Tek dönüş değeri, okunacak üç şey. Her birinin tüketicisi farklı. + +### `content`: modelin okuduğu {#content-what-the-model-reads} + +`content`, **içerik bloklarından** oluşan bir `list`'tir ve bir içerik bloğu bir birleşim (union) türüdür: `TextContent`, `ImageContent`, `AudioContent`, `ResourceLink` veya `EmbeddedResource`. Bir araç farklı türlerden birkaç tane döndürebilir. + +`main`'in `block.text`'e dokunmadan önce `isinstance(block, TextContent)` ile türü daraltmasının nedeni budur. `isinstance` dışında hiç `.text` olmadığına dikkat edin: tür denetleyicisi buna izin vermez, çünkü `ImageContent`'te `.text` değil `.data` vardır. Birleşim türü, bir aracın size ne gönderebileceği konusunda dürüsttür; kodunuz da öyle olmalı. + +### `structured_content`: uygulamanızın okuduğu {#structured_content-what-your-application-reads} + +`structured_content`, aracın JSON olarak dönüş değeridir ve aracın bildirdiği `output_schema` ile eşleşir. Dize ayrıştırma yok, tahmin yürütme yok. + +İkisi de varsa aynı şeyi bilerek iki kez söylerler: `content` model için, `structured_content` kod içindir. Yapılandırılmış yarının nereden geldiği ve nasıl denetleneceği **[Yapılandırılmış çıktı](../servers/structured-output.md)** sayfasında. + +### `is_error`: aracın başarısız olup olmadığı {#is_error-whether-the-tool-failed} + +İstisna fırlatan bir araç, istemcinizde istisna **fırlatmaz**. `is_error=True` taşıyan sıradan bir sonuç olarak geri döner. + +!!! check + `lookup_book`'tan `"Solaris"`'i isteyin (katalogda olmayan bir başlık); fonksiyon + `ValueError` fırlatır. Çağrı yine de normal biçimde döner: + + ```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 + ``` + + İstisnanın mesajı `content`'e düştü; **model** onu orada okuyup yeniden deneyebilir. Bu + kasıtlıdır: bir araç hatası çökme değil, konuşmanın bir parçasıdır. `structured_content`'e + güvenmeden önce her zaman `is_error`'a bakın. + +!!! warning + `is_error=True`, kendi `raise`'inizden fazlasını kapsar. Sunucuda hiç olmayan bir araç isteyin + (`call_tool("does_not_exist", {})`); hiçbir şey fırlatılmaz. Aynı şekil geri gelir: + `content`'te `Unknown tool: does_not_exist` ile birlikte `is_error=True`. Bir `Client` yöntemi + yalnızca sunucu sonuç yerine bir JSON-RPC **hatası** ile yanıt verdiğinde `MCPError` fırlatır; + sunucunun hangisini ne zaman ürettiği **[Hataları ele alma](../servers/handling-errors.md)** sayfasında. + +## Kaynaklar {#resources} + +Kaynak fiilleri çift gelir: listelemenin iki yolu, okumanın tek yolu. + +```python title="client.py" hl_lines="22-31" +--8<-- "docs_src/client/tutorial004.py" +``` + +* `list_resources()` **somut** kaynakları, yani sabit URI'si olanları döndürür. Burada: `['catalog://genres']`. +* `list_resource_templates()` **parametreli** olanları döndürür. Burada: `['catalog://genres/{genre}']`. İki ayrı liste olmalarının nedeni, bir şablonun siz onu doldurana kadar okunabilir olmamasıdır. +* `read_resource(uri)` düz bir `str` URI alır ve ikisinde de çalışır: `"catalog://genres/poetry"` geçirin, sunucu onu şablonla eşleştirir. + +`read_resource`, `TextResourceContents` veya `BlobResourceContents` öğelerinden oluşan bir liste olan `contents` döndürür. Araç içeriğiyle aynı fikir: `isinstance` ile daraltın, sonra `.text`'i (veya `.blob`'u) okuyun. + +Bir istemciye bir kaynağın ne zaman değiştiği de bildirilebilir. 2025 neslinden bağlantılarda bu, `subscribe_resource(uri)` / `unsubscribe_resource(uri)` çiftidir; `MCPServer`'ın uygulamadığı bir yöntem çifti olduğundan, 2026-07-28 sürümündeki bağlantıda (bu fiillerin artık var olmadığı yerde) istek `-32601`, *Method not found* ile yanıtlanır. 2026'daki karşılığı, `MCPServer`'ın gerçekten *sunduğu* bir `subscriptions/listen` akışıdır (orada `server_capabilities.resources.subscribe` değeri `True`'dur) ve onu `client.listen(...)` ile tüketmek bu bölümün **[Abonelikler](subscriptions.md)** sayfasının konusudur. + +## Prompt'lar {#prompts} + +```python title="client.py" hl_lines="15-20" +--8<-- "docs_src/client/tutorial005.py" +``` + +`list_prompts()` size sunucunun neler sunduğunu ve her prompt'un neye ihtiyaç duyduğunu söyler: + +```python +prompt.name # 'recommend' +prompt.title # 'Recommend a book' +prompt.arguments # [PromptArgument(name='genre', required=True)] +``` + +`get_prompt(name, arguments)` onu oluşturur. Argümanlar sözlüğü `str -> str` biçimindedir: prompt argümanları her zaman dizedir. Sonuç `messages`'dır; her biri bir `role` ve bir `content` bloğu taşıyan `PromptMessage` öğelerinden oluşan bir liste: + +```python +message.role # 'user' +message.content # TextContent(type='text', text='Recommend one poetry book from the catalog and say why.') +``` + +Host bu mesajları doğrudan modele verir. Özelliğin tamamı bu. + +## Tamamlamalar {#completions} + +Tamamlama işleyicisi olan bir sunucu, kullanıcı yazdıkça prompt ve kaynak şablonu argümanlarını otomatik tamamlayabilir. + +```python title="client.py" hl_lines="27-31" +--8<-- "docs_src/client/tutorial006.py" +``` + +* `ref`, *hangi* prompt'u veya şablonu doldurduğunuzu söyler: bir `PromptReference` ya da `ResourceTemplateReference`. +* `argument`, `{"name": ..., "value": ...}` biçimindedir: argüman ve kullanıcının şimdiye kadar yazdığı. + +Yanıt `result.completion.values` içindedir. `"p"` yazın, sunucu `['poetry']` ile döner. Sunucu tarafı ve bir işleyicinin önerilerini daraltmak için önceden doldurulmuş *diğer* argümanları nasıl kullandığı **[Tamamlamalar](../servers/completions.md)** sayfasında. + +## Sayfalama {#pagination} + +Her `list_*` yöntemi bir `cursor=` anahtar sözcüğü alır ve her sonuç bir `next_cursor` taşır. `next_cursor` `None` olduğunda her şeyi almışsınız demektir. + +```python title="client.py" hl_lines="22-30" +--8<-- "docs_src/client/tutorial007.py" +``` + +Bu döngü her sunucuya karşı doğrudur. `MCPServer` her şeyi tek sayfada döndürür; bu yüzden `next_cursor` `None` olur ve döngü bir kez çalışır. Çoğu kodun bunu hiç yazmamasının nedeni budur. Gerçekten sayfalayan sunucular ve imleçlerin uyduğu kurallar **[Sayfalama](../advanced/pagination.md)** sayfasında. + +## Testlerde {#in-tests} + +Süreç ve port olmadan `Client(mcp)`, sunucunuz için zaten bir test düzeneğidir. + +Bunun için yapılmış tek bir kurucu bayrağı var: `Client(mcp, raise_exceptions=True)`. Yalnızca bellek içi bağlantılarda etkisi olur; onu açıklayan ve bütün kalıbı onun etrafında kuran sayfa ise **[Test etme](../get-started/testing.md)**. + +## Özet {#recap} + +* `Client(x)` bir sunucu nesnesine bellek içinden, bir URL dizesine Streamable HTTP üzerinden, geri kalan her şeye de bir aktarım aracılığıyla bağlanır. +* `async with` yaşam döngüsünün tamamıdır. İçinde `server_capabilities` ve `protocol_version` zaten doludur; sunucu sağladığında `server_info` ve `instructions` da öyle. +* `list_tools()` size her aracın `name`, `title`, `description` ve `input_schema` değerlerini verir. +* `call_tool()` model için `content`, kodunuz için `structured_content` ve `is_error` döndürür. İstisna fırlatan bir araç istisna değil, sonuçtur. +* `content` blok türlerinin bir birleşimidir; okumadan önce `isinstance` ile daraltın. +* `list_resources` / `list_resource_templates` / `read_resource`, `list_prompts` / `get_prompt` ve `complete` fiilleri tamamlar. +* Her `list_*` `cursor=` alır; `next_cursor` `None` olana kadar döngüye devam edin. + +Bir sunucunun *istemciden* isteyebilecekleri ve bunları nasıl yanıtlayacağınız **[İstemci callback'leri](callbacks.md)** sayfasında. diff --git a/i18n/tr/pages/client/oauth-clients.md b/i18n/tr/pages/client/oauth-clients.md new file mode 100644 index 0000000000..640f85a339 --- /dev/null +++ b/i18n/tr/pages/client/oauth-clients.md @@ -0,0 +1,153 @@ +--- +translation: + sections: [c6899d3892bd9fa0, 79372cff3cc48a88, 63878d29e87c3e73, 13175843d3588af4, e7e2b9fd516f77de, 758f06399b513c1f, a05d7278487d610b] + tool: 1 +--- +# OAuth istemcileri {#oauth-clients} + +Bazı MCP sunucuları korumalıdır. Onlara token'sız bir istek gönderin, `401 Unauthorized` yanıtını verirler. + +Token'ı edinmenin yolu **`OAuthClientProvider`**'dır. Bu bir MCP nesnesi bile değildir. Bir `httpx2.Auth`'tur; httpx2'nin "her isteğe bir şey yap" için sunduğu standart kancadır. Onu bir `httpx2.AsyncClient`'a takarsınız, o istemciyi Streamable HTTP aktarımına verirsiniz ve konuyu unutursunuz. + +Bu sayfa istemci tarafını anlatır. Kendi sunucunuzun token talep etmesini sağlamak **[Yetkilendirme](../run/authorization.md)** sayfasının konusudur. + +## Sağlayıcı {#the-provider} + +```python title="client.py" hl_lines="44-54" +--8<-- "docs_src/oauth_clients/tutorial001.py" +``` + +Ona dört şey verirsiniz: + +* `server_url`: bağlandığınız MCP endpoint'i. Sağlayıcı geri kalan her şeyi buradan keşfeder. +* `client_metadata`: bir yetkilendirme sunucusunun "uygulama kaydet" formuna yazacağınız bilgiler. +* `storage`: token'ların çalıştırmalar arasında saklandığı yer. +* `redirect_handler` ve `callback_handler`: bir insanın devreye girdiği iki an. + +Dosyada OAuth'tan söz eden başka hiçbir şey yok. `main()` hiçbir zaman bir token görmez. + +### İstemci metadatası {#client-metadata} + +`OAuthClientMetadata`, gerçek [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591) kayıt belgesinin Pydantic modeli hâlidir. + +Üç alan ayarlarsınız. Gerisini varsayılanlar doldurur: `grant_types` zaten `["authorization_code", "refresh_token"]`, `response_types` ise zaten `["code"]`; bu sağlayıcının çalıştırdığı akış da tam olarak budur. + +!!! check + Bir Pydantic modeli olduğu için doğrulamayı **ağa tek bir bayt bile gitmeden** yapar. + `redirect_uris` alanını atlarsanız oluşturma, alanın adını veren bir `ValidationError` ile + anında başarısız olur: + + ```text + redirect_uris + Field required [type=missing, input_value={'client_name': 'Bookshop Agent'}, input_type=dict] + ``` + + Ne bir tarayıcı açılır ne de yetkilendirme sunucusunda yarım kalmış bir kayıt bırakılır. + +### Token deposu {#token-storage} + +**`TokenStorage`**, dört asenkron metodu olan bir `Protocol`'dür. Hiçbir şeyden kalıtım almazsınız; metotları yazın, herhangi bir sınıf bir token deposu olur: + +* `get_tokens` / `set_tokens`, `OAuthToken`'ı tutar: erişim token'ı, yenileme token'ı, geçerlilik süresi, kapsam. +* `get_client_info` / `set_client_info`, sağlayıcı sizi kaydettiğinde yetkilendirme sunucusunun verdiği `OAuthClientInformationFull`'u tutar; `client_id`'niz de bunun içindedir. + +Yukarıdaki bellek içi sürüm çalışır. Ancak süreç sona erdiğinde her şeyi unutur; bu yüzden bir sonraki çalıştırma bütün süreci baştan yapar. Onu bir dosyada ya da platformunuzun anahtarlığında kalıcı hâle getirin, bir sonraki çalıştırma sessiz geçer. + +!!! tip + Yalnızca token'ları değil, `client_info`'yu da saklayın. Sağlayıcı, depoda `client_info` + bulamadığı ilk seferde dinamik olarak kayıt yaptırır. Onu atarsanız her çalıştırmada yeni bir + kayıt üretirsiniz. + +### İki işleyici {#the-two-handlers} + +Yetkilendirme kodu akışı bir insana tam olarak bir kez ihtiyaç duyar: birinin oturum açıp "allow" düğmesine tıklaması gerekir. + +* **`redirect_handler`**, tamamen hazırlanmış yetkilendirme URL'siyle await edilir. `client_id`, `redirect_uri`, `state` ve PKCE challenge'ı zaten içindedir. Tek işiniz bir tarayıcıyı oraya götürmektir. Bir masaüstü uygulaması `webbrowser.open`'ı çağırır; bu dosya URL'yi yazdırır. +* Ardından **`callback_handler`** await edilir. Kullanıcı `redirect_uri`'nize geri dönene kadar bekler ve o yönlendirmenin sorgu parametrelerini bir `AuthorizationCodeResult` olarak döndürür. + +Gerçek bir istemci `input()` çağırmak yerine yönlendirme URI'si üzerinde küçük bir yerel HTTP sunucusu çalıştırır. Biçim aynıdır: yönlendirilin, `code`, `state` ve `iss` değerlerini geri verin. + +!!! warning + `state` ve `iss` değerlerini tam geldikleri gibi aktarın. Sağlayıcı `state`'i kendi ürettiğiyle, + `iss`'i de keşfettiği yayıncıyla karşılaştırır ve uyuşmazlığı reddeder. Bunlar CSRF ve + sunucu karışıklığı (mix-up) savunmalarıdır. + +### `Client`'a bağlama {#into-the-client} + +`main()`'e bakın. Sağlayıcı **httpx2 istemcisine** takılır, httpx2 istemcisi `streamable_http_client(url, http_client=...)`'a verilir, bu aktarım da `Client`'a gider. + +`streamable_http_client`'ın `auth=` diye bir anahtar sözcük argümanı yoktur. HTTP düzeyindeki her şey (kimlik doğrulama, başlıklar, zaman aşımları, vekil sunucular) sizin getirdiğiniz `httpx2.AsyncClient`'a aittir. Bu katmanlama **[İstemci aktarımları](transports.md)** sayfasında anlatılır. + +## Sağlayıcının sizin için yaptıkları {#what-the-provider-does-for-you} + +`Client` ilk kez bir istek gönderdiğinde sunucu `401` yanıtını verir. Sağlayıcı devralır: + +1. **Keşif.** `WWW-Authenticate` başlığını okur, sunucunun Protected Resource Metadata belgesini `/.well-known/oauth-protected-resource` adresinden alır, bu kaynağı hangi yetkilendirme sunucusunun koruduğunu öğrenir ve *o* sunucunun metadatasını alır. +2. **Kayıt.** Depoda bir şey yok mu? `OAuthClientMetadata`'nızla sizi dinamik olarak kaydeder ve sonucu saklar. +3. **Yetkilendirme.** PKCE çiftini ve bir `state` üretir, yetkilendirme URL'sini oluşturur, `redirect_handler`'ınızı await eder, ardından kod için `callback_handler`'ınızı await eder. +4. **Değişim.** Kodu bir `OAuthToken` ile takas eder, onu saklar ve özgün isteğinizi `Authorization: Bearer ...` ile yeniden gönderir. + +Bundan sonra sessizdir. Token'lar depodan gelir, süresi dolmuş bir erişim token'ı yenileme token'ıyla yenilenir ve ancak bunların hiçbiri işe yaramadığında akışı yeniden çalıştırır. + +Bunların hiçbirini siz yazmadınız. Geriye iki anahtar sözcük argümanı kalır (`client_metadata_url` ve `validate_resource_url`) ve bu dosyanın ikisine de ihtiyacı yoktur. Bilmeye değer olanı `client_metadata_url`'dir; aşağıda kendi bölümü var. + +### Deneyin {#try-it} + +Bu belgelerdeki örneklerin çoğunu bellek içi bir `Client(server)` ile sınayabilirsiniz. Bunu değil: akışın bütün amacı bir HTTP `401`'idir ve bellek içi bir istemci ile sunucusu arasında HTTP yoktur. + +Depo canlı sürümü içerir. `examples/servers/simple-auth/` bağımsız bir yetkilendirme sunucusu ile korumalı bir MCP sunucusu çalıştırır; `examples/clients/simple-auth-client/` ise bu sayfadaki istemcinin küçük bir CLI'a dönüşmüş hâlidir. README'sinde iki komut var: sunucuları başlatın, istemciyi onlara karşı çalıştırın ve dört adımın geçişini izleyin. + +## Client ID Metadata Documents {#client-id-metadata-documents} + +Belirtimin 2026-07-28 sürümü, dinamik istemci kaydını **Client ID Metadata Documents** (CIMD) lehine kullanım dışı bırakır. İstemciniz karşılaştığı her yetkilendirme sunucusuna yeni bir kayıt POST etmek yerine, kendisi hakkında tek bir JSON belgesini kararlı bir HTTPS URL'sinde yayımlar ve `client_id`'si bu URL'nin *ta kendisidir*. Belgeyi yetkilendirme sunucusu alır; sağlayıcı ona hiç dokunmaz. + +SDK bunu zaten destekler: sağlayıcıyı oluştururken URL'yi `client_metadata_url=` olarak geçirin. Yetkilendirme sunucusunun metadatası `client_id_metadata_document_supported: true` bildiriyorsa sağlayıcı `/register` isteğini tamamen atlar: URL akışa `client_id` olarak girer ve `client_secret` yoktur. Sunucu bunu bildirmiyorsa (çoğu henüz bildirmiyor) ya da hiç URL geçirmediyseniz sağlayıcı **sessizce** dinamik kayda geri döner ve yukarıdaki her şey tam anlatıldığı gibi çalışır. Saklanmış `client_info` yine de ikisinin de önüne geçer. + +URL, kök olmayan bir yola sahip HTTPS olmalıdır; başka her şey, herhangi bir ağ trafiği olmadan oluşturma sırasında bir `ValueError`'dır. Depodaki `examples/clients/simple-auth-client/` bunu `MCP_CLIENT_METADATA_URL` ortam değişkeni olarak alır. + +## Makineden makineye {#machine-to-machine} + +Bir gece görevi, bir CI adımı, başka bir servis. Tarayıcı yok, "allow" düğmesine tıklayacak kimse de yok. Bu **client credentials** yetkilendirme türüdür: elinizde zaten bir `client_id` ve bir `client_secret` vardır, akışın tamamı da token endpoint'idir. + +`ClientCredentialsOAuthProvider` aynı `httpx2.Auth`'tur, insan hariç: + +```python title="client.py" hl_lines="4 27-33" +--8<-- "docs_src/oauth_clients/tutorial002.py" +``` + +Neler değişti: + +* `OAuthClientMetadata` yok, işleyiciler yok. `client_id` ve `client_secret` geçirirsiniz; sağlayıcı bunların etrafında asgari bir `client_credentials` kaydı oluşturur ve dinamik kaydı tamamen atlar. +* `scope`, boşlukla ayrılmış bir dizedir; OAuth'un iletilen verideki biçimi budur. +* Bundan sonraki her şey aynıdır: aynı `TokenStorage`, aynı `httpx2.AsyncClient(auth=...)`, aynı `streamable_http_client`. + +Varsayılan olarak sır, token isteğinde HTTP Basic kimlik doğrulaması olarak gider (`client_secret_basic`). Onu bunun yerine form gövdesine koymak için `token_endpoint_auth_method="client_secret_post"` geçirin. Bazı yetkilendirme sunucuları ikisinden yalnızca birini kabul eder. + +!!! tip + `client_secret`'ı ortamdan ya da bir sır yöneticisinden okuyun, asla kaynak kontrolünden değil. + +!!! info + `mcp.client.auth.extensions.client_credentials` içinde bir sağlayıcı daha var: + paylaşılan bir sır yerine JWT ile kimlik doğrulayan istemciler için **`PrivateKeyJWTOAuthProvider`** + (`private_key_jwt`; anahtar çifti ve iş yükü kimliği türü). Aynı kalıbı izler: + bir tane oluşturun, `auth=`'a koyun. Aynı modül, onun assertion'ını oluşturan iki yardımcıyı da + sunar: `SignedJWTParameters` ve `static_assertion_provider`. + +İnsansız bir durum daha var: istemci, hangi MCP sunucularına erişebileceğine kullanıcının değil kimlik sağlayıcısının karar verdiği bir kuruluşa aittir. Bu, kendi güven modeli ve kendi sayfası olan farklı bir yetkilendirme türüdür: **[Kimlik beyanı](identity-assertion.md)**. + +## Başarısız olduğunda {#when-it-fails} + +OAuth akışı ters gittiğinde sağlayıcı, `mcp.client.auth` içinden bir `OAuthFlowError` fırlatır. İki alt sınıfı vardır. `OAuthRegistrationError`, kaydın kullanabileceğiniz bir istemci üretmediği anlamına gelir: yetkilendirme sunucusu sizi kaydetmeyi reddetti ya da kaydetti ama bu akışın kullanamayacağı kimlik bilgileriyle (örneğin uygulamadığı bir kimlik doğrulama yöntemiyle). `OAuthTokenError` ise bir token alınamadığı anlamına gelir: token endpoint'i hayır dedi ya da saklanan bir istemci kaydı bu istemcinin uygulayamayacağı bir kimlik doğrulama yöntemi taşıyor; bu durum gönderilmek yerine token isteği oluşturulurken bildirilir. Tek bir `except OAuthFlowError:` keşfi, kaydı, yetkilendirmeyi ve değişimi kapsar. + +Her şey bir akış hatası değildir. Ağ yine de başarısız olabilir; bunlar sıradan `httpx2` istisnalarıdır ve dokunulmadan geçer. + +## Özet {#recap} + +* `OAuthClientProvider` bir `httpx2.Auth`'tur. Onu bir `httpx2.AsyncClient`'a koyun, bunu `streamable_http_client(url, http_client=...)`'a geçirin; `Client` OAuth'un gerçekleştiğini hiç bilmez. +* Dört şey sağlarsınız: sunucu URL'si, bir `OAuthClientMetadata`, bir `TokenStorage` ve redirect/callback işleyici çifti. +* `TokenStorage` bir `Protocol`'dür: dört asenkron metot, taban sınıf yok. Token'ların yanı sıra `client_info`'yu da kalıcı hâle getirin. +* Keşif, kayıt (dinamik ya da bir **Client ID Metadata Document** aracılığıyla), PKCE, `state` ve `iss` denetimleri ile token yenileme sağlayıcının işidir, sizin değil. +* `ClientCredentialsOAuthProvider` insansız sürümdür: `client_id` + `client_secret`, işleyici yok, tarayıcı yok. +* Her OAuth hatası bir `OAuthFlowError`'dır; `OAuthRegistrationError` ve `OAuthTokenError` onun alt sınıflarıdır. + +Bu el sıkışmanın diğer yarısı, yani *sunucunuzun* token talep etmesini sağlamak **[Yetkilendirme](../run/authorization.md)** sayfasındadır. diff --git a/i18n/tr/pages/client/session-groups.md b/i18n/tr/pages/client/session-groups.md new file mode 100644 index 0000000000..25e965c497 --- /dev/null +++ b/i18n/tr/pages/client/session-groups.md @@ -0,0 +1,87 @@ +--- +translation: + sections: [09c857a25a9dc37a, 43bc6a76a243a50e, 0a716022a88768df, 4b7f78042bfcfff7, c112662e61b03315, 58974ba1f489a8b4, d18adbdbb835ea73] + tool: 1 +--- +# Oturum grupları {#session-groups} + +Bir `Client` tek bir sunucuya bağlanır. Gerçek uygulamalar ise çoğu zaman birden fazlasını ister (bir arama sunucusu, bir veritabanı sunucusu, dahili bir API) ve her biri için ayrı bir bağlantı ile ayrı bir araç listesiyle uğraşmak zorunda kalır. + +**`ClientSessionGroup`**, birçok bağlantıyı tutan ve bunların sunduğu her şeyi tek bir görünümde birleştiren tek bir nesnedir. + +## İki sunucu {#two-servers} + +İki sıradan sunucuyla başlayın. Birbirleriyle hiçbir ilgileri yok, bu yüzden ikisi de doğal olarak aracına `search` adını vermiş: + +```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" +``` + +## Tek grup {#one-group} + +Bir `ClientSessionGroup` oluşturun ve her sunucu için bir kez **`connect_to_server`**'ı çağırın: + +```python title="client.py" hl_lines="10-12" +--8<-- "docs_src/session_groups/tutorial003.py" +``` + +* `connect_to_server` bir sunucu nesnesi değil, aktarım parametreleri alır: bir alt süreç başlatmak için `StdioServerParameters` (`mcp`'den) ya da zaten bir URL'de dinleyen bir sunucu için `StreamableHttpParameters` / `SseServerParameters` (`mcp.client.session_group`'tan). +* `group.tools`, bağlı tüm sunucuların araçlarını içeren bir `dict[str, Tool]`'dur. `group.resources` ve `group.prompts` da aynı biçimdedir. +* `group.call_tool(name, arguments)` adı arar, ona sahip olan oturumu bulur ve çağrıyı iletir. Hangi sunucu olduğunu hiçbir zaman söylemezsiniz. + +!!! check + `client.py` dosyasını iki sunucunun yanına koyun ve çalıştırın. İkinci `connect_to_server` reddeder: + + ```text + mcp.shared.exceptions.MCPError: {'search'} already exist in group tools. + ``` + + Bu, ikinci sunucudan herhangi bir şey kaydedilmeden önce fırlatılan bir `MCPError`'dır. Bir ad + grubun **tamamında** benzersiz olmalıdır ve sizin denetiminizde olmayan iki sunucu eninde sonunda çakışır. + +## `component_name_hook` {#component_name_hook} + +Bunu sunucularda değil, grupta düzeltirsiniz. `(name, server_info)` alan bir fonksiyon geçirin; grup, kaydettiği her ad üzerinde onu çalıştırır: + +```python title="client.py" hl_lines="7-8 15" +--8<-- "docs_src/session_groups/tutorial004.py" +``` + +Yeniden çalıştırın. `print(sorted(group.tools))` artık ikisini de gösterir: + +```text +['Library.search', 'Web.search'] +``` + +* **Anahtar** sizindir. `by_server` onu `server_info.name`'den, yani her `MCPServer(...)`'ın oluşturulduğu addan üretti. +* İçindeki `Tool`'a dokunulmaz: `group.tools["Web.search"].name` hâlâ `"search"`'tür ve `call_tool`'un ağ üzerinde gönderdiği ad budur. Önek hiçbir zaman sürecinizin dışına çıkmaz. +* Bu yalnızca araçlarla sınırlı değil. Kütüphanenin `hours` kaynağı `Library.hours` olarak kaydedilir. + +!!! tip + Kanca yalnızca çakışmalarda değil, **her** sunucudan gelen **her** ad üzerinde çalışır: yalnızca + çakışmada önek ekleyen bir kip yoktur. Bir şema seçin ve her yerde uygulanmasına izin verin. + +## Sunucu ekleme ve kaldırma {#adding-and-removing-servers} + +`connect_to_server`, açtığı `ClientSession`'ı döndürür. O sunucuyu bir gün kaldırmak isterseniz bunu saklayın: `await group.disconnect_from_server(session)` sunucunun araçlarını, kaynaklarını ve prompt'larını gruptan kaldırır. + +Elinizde zaten bağlı bir `ClientSession` varsa (`Client.session` bunlardan biridir), yeni bir aktarım açmak yerine onu `await group.connect_with_session(server_info, session)`'a verin. Aynı şekilde birleştirilir. Grup, kendisinin açmadığı bir oturumu hiçbir zaman kapatmaz. `server_info`, bileşen önekleri için sunucuya ad verir; 2026 neslinden bir bağlantıda `client.server_info` `None` olabilir (kimlik isteğe bağlıdır), bu durumda kendi `Implementation(name=..., version=...)`'ınızı geçirin. + +## Klasik el sıkışma {#the-classic-handshake} + +`ClientSessionGroup`, `Client` üzerine değil `ClientSession` üzerine kuruludur. Her `connect_to_server` klasik `initialize` el sıkışmasını yürütür. **[Protokol sürümleri](../protocol-versions.md)** sayfasında anlatılan `server/discover` yoklamasını hiçbir zaman göndermez. Her MCP sunucusu bu el sıkışmayı anlar; bu yüzden uyumluluk açısından hiçbir şey kaybetmezsiniz. Bunun tek anlamı, grubun daha iyisini yapabilecek bir sunucuya giderken daha eski ve daha yavaş yolu izlemesidir. + +## Özet {#recap} + +* `ClientSessionGroup` birçok sunucu bağlantısını tutar ve bunların araçlarını, kaynaklarını ve prompt'larını birer `dict`'te birleştirir. +* Her sunucu için `connect_to_server(params)`. Aktarım parametreleri alır; bir `Client`'ın aldığı sunucu nesnesini ya da URL'yi asla almaz. +* `group.call_tool(name, arguments)` çağrıyı sizin yerinize sahibi olan sunucuya yönlendirir. +* Adlar grubun tamamında benzersiz olmalıdır; `search` aracı olan iki sunucu kendi hâllerine bırakılırsa bir arada bulunamaz. +* `component_name_hook=` kaydedilen her adı yeniden yazar. Sözlük anahtarı değişir, ağ üzerindeki ad değişmez. +* `connect_with_session` elinizde zaten olan bir oturumu ekler; `disconnect_from_server` bir oturumu kaldırır. + +Bir grubun konuştuğu el sıkışma (ve bir `Client`'ın tercih ettiği daha hızlı olanı), **[Protokol sürümleri](../protocol-versions.md)** sayfasının konusudur. diff --git a/i18n/tr/pages/client/subscriptions.md b/i18n/tr/pages/client/subscriptions.md new file mode 100644 index 0000000000..44b5fcc7e7 --- /dev/null +++ b/i18n/tr/pages/client/subscriptions.md @@ -0,0 +1,91 @@ +--- +translation: + sections: [8f9558e57f29eee1, a88c587739e0465c, 46ebfd5b325ed041, 4d10b00b57ce4bd9, 2cdb0edd1f59b3e2] + tool: 1 +--- +# Abonelikler {#subscriptions} + +Bir sunucunun kataloğu sabit değildir. Çalışma zamanında yeni araçlar ortaya çıkar, bir kaynak URI'sinin arkasındaki içerik değişir. İstemci bundan `client.listen(...)` aracılığıyla haberdar olur: yanıtı akışın *kendisi* olan tek bir `subscriptions/listen` isteği. Akış açık kalır ve istemcinin istediği değişiklik bildirimlerini taşır. + +Bu sayfa işin istemci tarafını anlatır: akışı açma, ana iş akışınızın yanında izleme ve sonlanmalarını ele alma. Değişiklikleri yayımlama, filtreleme ve yöntemi sunma ise hikâyenin sunucu tarafıdır; *İşleyicinin içinde* bölümündeki **[Abonelikler](../handlers/subscriptions.md)** sayfasında anlatılır. Buradaki örnekler orada kurulan sprint panosu sunucusuyla konuşur. + +## Akışı izleme {#watching-the-stream} + +Bir abonelik tek bir bağlam yöneticisidir. İçine girmek isteği gönderir (anahtar sözcük argümanlarınız abonelik filtresi olur) ve sunucunun onayını bekler; böylece blok başladığında akış canlıdır. + +```python title="client.py" hl_lines="15 18 28" +--8<-- "docs_src/subscriptions/tutorial003.py" +``` + +Yineleme türü belli dört olay üretir: `ToolsListChanged`, `PromptsListChanged`, `ResourcesListChanged` ve `ResourceUpdated(uri=...)`. + +Bir olay *neyin* değiştiğini söyler, asla *nasıl* değiştiğini değil. `follow_board`'un `read_resource` ve `list_tools`'u çağırmasının nedeni budur: olay, yeniden getirmek için bir işarettir. Hangi kaynağın değiştiğini varsaymak yerine `event.uri` alanını okuyun: bir filtre birkaç URI sayabilir ve sunucu bunlardan birinin alt kaynağındaki bir değişikliği bildirebilir. + +Tüketilmeyi bekleyen yinelenen olaylar tek bir olaya indirgenir; yeniden getirdiğinizde yine güncel durumu alırsınız. Yalnızca özdeş olaylar birleşir: farklı URI'ler için iki `ResourceUpdated`, iki ayrı olaydır. + +Tutamacın (handle) iki özelliği daha var: + +* `sub.honored`, sunucunun onayladığı filtredir: geçirdiğiniz alanları taşıyan ve öznitelik olarak okunan bir `SubscriptionFilter` (`sub.honored.prompts_list_changed`). `MCPServer` istediğiniz her türü kabul eder, bu yüzden isteğinizi olduğu gibi geri yansıtır. Daha az tür destekleyen bir sunucu daha azını onaylar; onaylanmış bir tür yine de hiç tetiklenmeyebilir. Sunucu isteği onaylamak yerine tümüyle reddedebilir de (sunucu sayfasındaki [Kimin izleyebileceğine karar verme](../handlers/subscriptions.md#deciding-who-may-watch) bölümüne bakın); bu, isteğin hatası olarak yüzeye çıkar. +* `sub.subscription_id`, listen isteğinin kimliğidir; bu akışın her çerçevesine damgalanan kimlik budur. Aynı anda birkaç abonelik açık olabilir; her biri kendi kimliğiyle ayrıştırılır. + +## Engellemeden izleme {#watching-without-blocking} + +`follow_board`, sunucu akışı kapatana kadar çalışır, ki bu hiç olmayabilir; bu yüzden tek başına bırakıldığında programınızı ele geçirir. Gerçek istemciler izleyiciyi ana iş akışının *yanında* ister: bir izleyici bir önbelleği ya da arayüzü güncel tutarken ajan araçları çağırır. + +Önce aboneliği açın, ardından izleyiciyi başlatın ve işinize devam edin. + +=== "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`, `BOARD` ve `read_board`'u ilk örnekten içe aktarır; bu depo o örneği + `tutorial003.py` olarak saklar. Oluşturulan dosyaları `client.py` ve `app.py` adlarıyla yan yana + kaydederseniz bunun yerine `from client import BOARD, read_board` yazın. Aşağıdaki `watch.py` + örneği de `read_board`'u aynı şekilde içe aktarır. + +Önemli olan sıradır. Hiçbir şey yeniden oynatılmaz; bu yüzden akışınız var olmadan önce yayımlanan bir olay kaçar. `client.listen(...)` bloğuna girmek onayı bekler; dolayısıyla o andan itibaren her değişiklik izleyicinize ulaşır ve blok içinde aldığınız anlık görüntü hiçbirini kaçıramaz. + +Açık bir akışın yanında istekler, izleyici görevinden de başka herhangi bir görevden de, aynı istemci üzerinde serbestçe çalışır. Tüketilmemiş *yinelenen* olaylar birleştiği için, yoğun bir ana iş akışı üç yerine tek bir yeniden getirmeyle sonuçlanabilir. Farklı olaylar birleşmez: çok sayıda URI sayan bir filtre, URI başına bir bekleyen olayı kuyruğa alır. + +İzlemeyi bırakmak için bloktan çıkın: `unsubscribe` diye bir çağrı yoktur. Bloğun sahibi olan görevi iptal etmek bunu sizin yerinize yapar; SDK da listen isteğini aktarımın beklediği biçimde iptal eder: Streamable HTTP üzerinde, o isteğin akışını kapatarak. Uygulamanızın ömrü boyunca çalışan bir izleyici kendiliğinden asla dönmez; bu yüzden kapanışta onu ya da görev grubunun kapsamını iptal edin. + +## Akışların sona ermesi {#streams-end} + +Bir akış iki yoldan biriyle sona erer; ikisi de sıradan denetim akışıdır. Sunucunun düzgün bir kapatması `async for` döngüsünü bitirir; ani bir kopma `SubscriptionLost` fırlatır. + +Aradaki fark tanı amaçlıdır, sonra ne yapılacağıyla ilgili değildir: akış gitmiştir, hiçbir şey yeniden oynatılmamıştır ve hâlâ ilgilenen bir izleyici yeniden dinler ve yeniden getirir. + +```python title="watch.py" hl_lines="16 20" +--8<-- "docs_src/subscriptions/tutorial005.py" +``` + +Sunucular akışları kendi gerekçeleriyle düzgünce kapatır; birikimi fazla büyüyen bir aboneyi bırakmak da bunlardan biridir. Bu yüzden temiz bir sonlanma, izlemeyi bırakma işareti değildir. Yeniden dinlemeden önce biraz bekleyin. + +`SubscriptionLost`'un yerel bir nedeni de vardır. İstemci en fazla 1024 tüketilmemiş olay tutar; bu kadar geride kalan bir tüketici, sınırsızca büyümek yerine aboneliği kaybeder. `async for` gövdesini kısa tutun, yavaş işleri başka yerde yapın. + +`keep_following` yalnızca `SubscriptionLost`'u yakalar. `listen()`'a girmek ayrıca `MCPError` (bağlantı başarısız oldu ya da sunucu yöntemi sunmuyor), `TimeoutError` (onay gelmedi) ve `ListenNotSupportedError` (2026 öncesi bir bağlantı) da fırlatabilir. İzleyicinizin bunlardan hangilerini yeniden denemesi gerektiğine karar verin: sonuncusu asla düzelmez. + +## Özet {#recap} + +* `async with client.listen(...)` bloğuna girin; giriş onayı bekler, bu yüzden ondan sonra yayımlanan hiçbir şey kaçmaz. +* `async for event in sub` ile yineleyin. Olaylar yeniden getirmek için birer işarettir, asla yük (payload) değildir. +* Aboneliği açın, ardından izleyiciyi bir görev olarak çalıştırın; araç çağrıları onun yanında akmaya devam eder. +* Temiz bir sonlanma döngüyü durdurur; kopma `SubscriptionLost` fırlatır. Her iki durumda da: yeniden dinleyin, yeniden getirin, ama önce biraz bekleyin. +* Bloktan çıkmak abonelikten çıkmaktır. + +Bu olayları yayımlamak, filtreyi daraltmak ve tek bir sürecin ötesine ölçeklemek sunucunun hikâyesidir: **[Abonelikler](../handlers/subscriptions.md)**. Aynı olaylar istemci tarafındaki bir önbelleği de dürüst tutar; sıradaki sayfa **[Önbellekleme](caching.md)**. diff --git a/i18n/tr/pages/client/transports.md b/i18n/tr/pages/client/transports.md new file mode 100644 index 0000000000..967b5a73d8 --- /dev/null +++ b/i18n/tr/pages/client/transports.md @@ -0,0 +1,127 @@ +--- +translation: + sections: [9cac816674181eb0, 0700f337babcd4dd, 2bde0dd58cdf00f5, ff7401df479af877, 3d0832f39b0d7059, d4bf7e4479637768, 05e20c0a798860e7] + tool: 1 +--- +# İstemci aktarımları {#client-transports} + +Her `Client`, sunucusuyla bir **aktarım** üzerinden konuşur: mesajları fiilen taşıyan şey budur. + +Aktarımı hiçbir zaman ayrıca yapılandırmazsınız. `Client` tek bir konumsal argüman alır ve aktarımı bu argümanın türünden çıkarır. + +Her birinin *sunucu* tarafı (`mcp.run()`'ın ne yaptığı ve neyi dağıttığınız) **[Sunucunuzu çalıştırma](../run/index.md)** sayfasında. + +## Bellek içinde {#in-memory} + +Sunucu nesnesinin kendisini geçirin: + +```python title="client.py" hl_lines="14" +--8<-- "docs_src/client_transports/tutorial001.py" +``` + +Alt süreç yok, port yok, ağ üzerinde tek bir bayt yok. İstemci ve sunucu aynı süreçteki iki nesnedir; yine de çağrı gerçek protokol katmanından geçer: `search_books`, HTTP üzerinden nasıl olacaksa tam olarak öyle listelenir, doğrulanır ve çağrılır. + +Bu, onu aynı anda iki şey yapar: + +* **Bir test düzeneği.** Bu belgelerdeki her örnek bu şekilde çalıştırılır ve **[Test etme](../get-started/testing.md)** sayfası tüm deseni bunun üzerine kurar. +* **Bir gömme API'si.** Sunucuyu oluşturan bir uygulamanın, araçlarını çağırmak için ağ üzerinden bir sıçrama yapmasına gerek yoktur. + +## Streamable HTTP {#streamable-http} + +Bir URL dizesi geçirin; arkasına dağıtım yaptığınız aktarım olan **Streamable HTTP**'yi elde edersiniz: + +```python title="client.py" hl_lines="5" +--8<-- "docs_src/client_transports/tutorial002.py" +``` + +Üretim istemcisinin tamamı bu. `Client`, URL'yi sizin için `streamable_http_client(...)` ile sarar; bunu da MCP'nin gerektirdiği şekilde yapılandırılmış bir `httpx2.AsyncClient` üzerine kurar: `follow_redirects=True`, connect/write/pool için 30 saniyelik zaman aşımı ve sunucu bir yanıt akışını açık tutabileceği için 300 saniyelik okuma zaman aşımı. + +!!! check + Oluşturduğunuz bir `Client` bağlı **değildir**. Oluşturma yalnızca aktarımı seçer; + onu açan `async with`'tir. İçine girmeden bağlantıya uzanırsanız SDK bunu size söyler: + + ```text + RuntimeError: Client must be used within an async context manager + ``` + + `Client("http://...")` yazdığınızda hiçbir şey çözümlenmedi, getirilmedi ya da başlatılmadı. O satır bedava. + +### Kendi `httpx2.AsyncClient`'ınızı getirme {#bring-your-own-httpx2asyncclient} + +Bir `Authorization` başlığına, bir çereze, bir vekil sunucuya, mTLS'e ya da farklı bir zaman aşımına ihtiyaç duyduğunuz anda `httpx2.AsyncClient`'ı kendiniz oluşturun ve `streamable_http_client`'a verin: + +```python title="client.py" hl_lines="8-14" +--8<-- "docs_src/client_transports/tutorial003.py" +``` + +Dikkat edilecek iki şey: + +* `httpx2.AsyncClient`'ın sahibi sizsiniz, bu yüzden içine **siz** girer ve **siz** çıkarsınız. SDK, kendi oluşturmadığı bir istemciyi asla kapatmaz. +* `streamable_http_client(url, http_client=...)` bir aktarım döndürür ve `Client(transport)` onu diğer her şey gibi kabul eder. + +TLS ile ilgili bir not: `httpx2`, sertifikaları paketle gelen bir CA listesine göre değil, işletim sisteminin güven deposuna göre doğrular ( +[`truststore`](https://pypi.org/project/truststore/) aracılığıyla). Kullanılabilir bir sistem CA deposu olmayan bir ortamda (bazı minimal kapsayıcılar) standart `SSL_CERT_FILE`/`SSL_CERT_DIR` +ortam değişkenlerini ayarlayın ya da `httpx2.AsyncClient`'ınıza açıkça bir `verify=ssl_context` geçirin +(arka plan bilgisi için +[`httpx` ve `httpx-sse`'nin yerini `httpx2` aldı](../migration.md#httpx-and-httpx-sse-replaced-by-httpx2)). + +!!! warning + `streamable_http_client` eskiden `headers=` ve `timeout=` parametrelerini doğrudan alırdı. Artık almıyor: + tek parametreleri `url`, `http_client` ve `terminate_on_close`. Alışkanlıkla `headers=`'a + uzanırsanız şunu alırsınız: + + ```text + TypeError: streamable_http_client() got an unexpected keyword argument 'headers' + ``` + + HTTP'yle ilgili her şey artık geçirdiğiniz o tek `httpx2.AsyncClient` üzerinde bulunur. + +!!! info + `httpx2`, tanıdık `httpx` API'sini korur; yani `httpx`'i biliyorsanız kimlik doğrulama, + vekil sunucular, olay kancaları, yeniden denemeler ve bağlantı sınırlarının burada nasıl yapılacağını zaten biliyorsunuz. SDK üzerine hiçbir şey eklemez, + hiçbir şeyi de eksiltmez. OAuth'un takıldığı yer de burası: + `httpx2.AsyncClient(auth=OAuthClientProvider(...))`. Bu akışın tamamı **[OAuth istemcileri](oauth-clients.md)** sayfasında. + +## stdio {#stdio} + +Bir **stdio** sunucusu bir alt süreçtir. İstemci onu başlatır, stdin'ine JSON-RPC yazar ve stdout'undan JSON-RPC okur. Bir masaüstü host'un makinenizde bir sunucuyu çalıştırma biçimi budur: bir host, bu kod artı bir kullanıcı arayüzü*dür* ve **[Gerçek bir host'a bağlanma](../get-started/real-host.md)**, aynı ilişkinin host'un tarafından, bir yapılandırma dosyası olarak görülen halidir. + +Süreci `StdioServerParameters` ile tanımlayın, `stdio_client` ile bir aktarıma dönüştürün ve `Client`'a *onu* verin: + +```python title="client.py" hl_lines="4-8 12" +--8<-- "docs_src/client_transports/tutorial004.py" +``` + +`Client`, parametre nesnesini tek başına kabul etmez. `StdioServerParameters` yapılandırmadır; `stdio_client(server)` ise ondan bir süreç başlatmayı bilen aktarımdır. Her zaman sarın. + +`async with` bloğundan çıkmak alt süreci de kapatır: stdin'i kapat, bekle, oyalanıyorsa sonlandır. Onu hiçbir zaman kendiniz temizlemezsiniz. + +!!! warning + Alt süreç ortamınızı **devralmaz**. Minimal bir izin listesi alır (POSIX'te `HOME`, `LOGNAME`, + `PATH`, `SHELL`, `TERM` ve `USER`); böylece sizin yazmamış olabileceğiniz bir sürece hassas hiçbir şey + sızmaz. + + Bir API anahtarına ihtiyaç duyan bir sunucu onu orada bulamaz. `env=` ile açıkça geçirin; bu + değişkenler izin listesinin üstüne birleştirilir. Yukarıda `BOOKSHOP_API_KEY`'in yaptığı budur. + +## SSE {#sse} + +`mcp.client.sse` içindeki `sse_client(url)`, Streamable HTTP'nin yerini aldığı HTTP aktarımıdır. Hâlâ onu konuşan bir sunucuyla konuşmak için aynı şekilde sarın, `Client(sse_client("http://localhost:8000/sse"))`, ve üzerine yeni hiçbir şey kurmayın. + +## `Transport` protokolü {#the-transport-protocol} + +`Client` için yukarıdakilerin hepsi aynı şeydir. + +Bir **aktarım**, `(read, write)` mesaj akışı çifti veren herhangi bir asenkron bağlam yöneticisidir: resmi olarak `mcp.client` içindeki `Transport` protokolü. `Client`, argümanını türüne göre çözümler: bir sunucu nesnesi süreç içinde bağlanır, bir `str` `streamable_http_client(url)` olur ve geri kalan her şeye doğrudan bir aktarım olarak girilir. `stdio_client(...)`, `streamable_http_client(...)` ve `sse_client(...)`'in hepsinin aynı yuvaya oturmasının ve kendinizinkini yazabilmenizin nedeni bu son kuraldır. + +## Özet {#recap} + +* `Client(mcp)` (sunucu nesnesi) bellek içinde bağlanır. Testler ve gömme için kullanın. +* `Client("http://.../mcp")` (bir URL), üretim aktarımı olan Streamable HTTP üzerinden bağlanır. +* Başlıklar, kimlik doğrulama, vekil sunucular ve zaman aşımları, `streamable_http_client(url, http_client=...)`'a geçirdiğiniz bir `httpx2.AsyncClient` üzerinde yer alır. `headers=` anahtar sözcüğü yoktur. +* stdio `Client(stdio_client(StdioServerParameters(...)))`'tır; asla tek başına parametre nesnesi değil. +* Alt süreç sizinkini değil, izin listesine göre oluşturulmuş bir ortam alır; `env=` buna ekleme yapar. +* Bir aktarım, `async with x as (read, write)` yapabildiğiniz herhangi bir şeydir. `Client`, sunucu nesnesi ya da URL olmayan her şeyi doğrudan bu protokole verir. +* Bir `Client` oluşturmak aktarımı seçer. Onu `async with` açar. + +Aktarım açıldıktan sonra iki tarafın bir protokol sürümünde anlaşması gerekir. Normalde bunu hiç düşünmezsiniz; düşünmeniz gerektiğinde gidilecek sayfa **[Protokol sürümleri](../protocol-versions.md)**'dir. diff --git a/i18n/tr/pages/deprecated.md b/i18n/tr/pages/deprecated.md new file mode 100644 index 0000000000..75acd06964 --- /dev/null +++ b/i18n/tr/pages/deprecated.md @@ -0,0 +1,96 @@ +--- +translation: + sections: [20541a40dbdd5980, 01262a123ad9501d, 429db5b574a2ac08, 56b2d49da412cb28, 6a1717123fe4513c] + tool: 1 +--- +# Kullanım dışı özellikler {#deprecated-features} + +2026-07-28 spesifikasyonu beş şeyi emekliye ayırıyor. SDK hâlâ hepsini uygular ve artık her biri bir **kullanım dışı bırakma uyarısı** taşır. + +Aşağıdaki tablo kullanım dışı bırakılan her özelliği, neden gittiğini ve yerine neyin üzerine inşa etmeniz gerektiğini gösterir. + +## Neler kullanım dışı {#what-is-deprecated} + +| Kullanım dışı | Neden | Bunun yerine ne yaparsınız | +|---|---|---| +| **Kök dizinler (roots)**: `ctx.session.list_roots()`, `client.send_roots_list_changed()`, `Client(...)`'a geçirdiğiniz `list_roots_callback=` | [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) bu yeteneği emekliye ayırıyor. | Yolları sıradan araç argümanları veya kaynak URI'leri olarak alın ya da bir `InputRequiredResult` içine bir `ListRootsRequest` gömün (bkz. **[Çok turlu istekler (multi-round-trip)](handlers/multi-round-trip.md)**). | +| **Sunucunun başlattığı örnekleme (sampling)**: `ctx.session.create_message()`, `Client(...)`'a geçirdiğiniz `sampling_callback=` | SEP-2577 bu yeteneği emekliye ayırıyor. | `InputRequiredResult` döndürün ve çağrıyı istemcinin yeniden denemesine bırakın (bkz. **[Çok turlu istekler](handlers/multi-round-trip.md)**). | +| **Protokol üzerinden log tutma**: `ctx.log()`, `ctx.debug()`, `ctx.info()`, `ctx.warning()`, `ctx.error()`, `ctx.session.send_log_message()`, `client.set_logging_level()` | SEP-2577 bu yeteneği emekliye ayırıyor. Protokol içinde yerine geçen bir şey yok. | stderr'e yazan sıradan `import logging` (bkz. **[Log tutma](handlers/logging.md)**). | +| **`ping`**: `client.send_ping()` | Yalnızca kullanım dışı bırakılmadı, protokolden **kaldırıldı**. 2026-07-28 sürümünde `ping` yöntemi yok. | Hiçbir şey. Yalnızca `mode="legacy"` bağlantısında çalışır. | +| **İstemciden sunucuya ilerleme**: `client.send_progress_notification()` | 2026-07-28 ilerlemeyi yalnızca sunucudan istemciye yönlü yapar. | Gönderecek bir şey yok. İlerlemeyi *sunucunuz* `ctx.report_progress()` ile bildirir (bkz. **[İlerleme](handlers/progress.md)**). | + +Bu tablodan üç şey çıkar: + +* Kök dizinler, örnekleme ve log tutma bir arada gider. Tek bir öneri, **SEP-2577**, üç yeteneği birden kullanım dışı bırakır. +* Örnekleme ve kök dizinler daha derin bir sorunu paylaşır: bunlar bir **sunucunun** **istemciye** **istek** gönderdiği yerlerdir. 2026-07-28 sürümünün **[Çok turlu istekler](handlers/multi-round-trip.md)** ile değiştirdiği şey tam da bu yöndür. Giden, bağımsız RPC yöntemleridir (`sampling/createMessage`, `roots/list` ve push tarzı `elicitation/create`); `CreateMessageRequest` / `ListRootsRequest` / `ElicitRequest` yük türleri `InputRequiredResult.input_requests` içine gömülü olarak yaşamaya devam eder ve istemcide aynı callback'lere ulaşır. +* `ping` diğerlerinden ayrılır. Protokol onu kullanım dışı bırakmaz, kaldırır. SDK yöntemi yine de uyarır (mesajı *deprecated* değil *removed* der) ve modern bir bağlantıda çağrıldığında *"Method not found"* yanıtı gelir. + +## Kullanım dışı bırakma tavsiye niteliğindedir {#deprecated-is-advisory} + +Bugün hiçbir şey bozulmaz. + +Yukarıdaki her yöntem, **2025-11-25 veya daha eski** bir sürümle anlaşmış her oturumda çalışmaya devam eder. İstemcide `mode="legacy"` sabitleyin, 2026 öncesi davranışın aynısını elde edersiniz. İletilen veride hiçbir değişiklik yoktur ve yetenek anlaşması aynıdır. + +Değişen şey, her biri ilk kez çalıştığında görünür bir uyarı almanızdır: + +```text +MCPDeprecationWarning: The logging capability is deprecated as of 2026-07-28 (SEP-2577). +``` + +`MCPDeprecationWarning`, `DeprecationWarning`'in **değil**, `UserWarning`'in alt sınıfıdır. Bu kasıtlıdır: Python'ın varsayılan filtresi `DeprecationWarning`'i yalnızca doğrudan `__main__` olarak çalıştırılan kodda gösterir; kütüphaneler bir şeyleri böyle kullanım dışı bırakır ve iki yıl boyunca kimse fark etmez. Bu uyarı ise her yerde, `-W` bayrağı olmadan görünür. + +!!! warning + "Tavsiye niteliği" iletilen veriye gelince biter. Örnekleme ve kök dizinler sunucudan + istemciye giden *isteklerdir* ve 2026-07-28 oturumunda bunları taşıyacak bir kanal yoktur. + Modern bir bağlantıda bir aracın içinde `ctx.session.create_message()`'ı çağırın: uyarı + yine tetiklenir, ardından gönderim bir hatayla başarısız olur: + + ```text + Cannot send 'sampling/createMessage': this transport context has no back-channel + for server-initiated requests. + ``` + + Bu sırayla iki sinyal. `MCPDeprecationWarning`, yöntemi çağırdığınız anda, her + bağlantıda tetiklenir. Hata ise SDK ardından göndermeyi denediğinde geri dönen şeydir. + Bu ikisi yalnızca, istemcisi eşleşen callback'i kaydetmiş bir `mode="legacy"` + bağlantısında uçtan uca çalışır. + +## Uyarıyı susturma {#silencing-the-warning} + +Yeni kodda susturmayın. + +Ancak bakımını yaptığınız ve gerçekten 2026 öncesi istemcilere hizmet veren bir sunucunun sessiz bir log'a sonuna kadar hakkı vardır. Kategoriyi, ilk kullanım dışı çağrı çalışmadan önce filtreleyin: + +```python +import warnings + +from mcp import MCPDeprecationWarning + +warnings.filterwarnings("ignore", category=MCPDeprecationWarning) +``` + +API'nin tamamı bu. Yöntem başına bir anahtar yok, zaten istemezsiniz de: tek kategori olmasının anlamı, tek satırın onu susturması ve tek satırın geri getirmesidir. + +!!! check + Filtreyi ters yönde çalıştırın, bedava bir regresyon testi elde edersiniz. pytest + yapılandırmanızdaki `filterwarnings` ayarına `"error::mcp.MCPDeprecationWarning"` + ekleyin; kullanım dışı çağrı uyarmak yerine **istisna fırlatır**. Hâlâ `ctx.info()`'yu + çağıran `old_log` adlı bir araç artık geçmez ve şunu bildirmeye başlar: + + ```text + Error executing tool old_log: The logging capability is deprecated as of 2026-07-28 (SEP-2577). + ``` + + Tek satır pytest yapılandırmasıyla, kullanım dışı bir çağrı bir testi başarısız kılmadan + kod tabanınıza bir daha asla sızamaz. + +## Özet {#recap} + +* 2026-07-28 spesifikasyonu **kök dizinleri**, sunucunun başlattığı **örneklemeyi** ve protokol üzerinden **log tutmayı** kullanım dışı bırakır (hepsi [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), **ilerlemeyi** sunucudan istemciye yönle sınırlar ve **`ping`**'i kaldırır. +* Yerine geçenler sütunu sizi ileriye yönlendirir: örnekleme ve kök dizinler için **[Çok turlu istekler](handlers/multi-round-trip.md)**, log tutma için **[Log tutma](handlers/logging.md)**, ilerleme için **[İlerleme](handlers/progress.md)**. `ping` için hiçbir şey gerekmez. +* Kullanım dışı bırakma tavsiye niteliğindedir: iletilen veride değişiklik yok, her şey 2026 öncesi oturumlarda çalışmaya devam eder ve görünür bir `MCPDeprecationWarning` alırsınız (bir `UserWarning`, dolayısıyla varsayılan olarak açık). +* Örnekleme ve kök dizinler ayrıca, 2026-07-28 oturumunda bulunmayan bir geri kanala (back-channel) ihtiyaç duyar. Modern bir bağlantıda önce uyarır, sonra istisna fırlatırlar. +* `warnings.filterwarnings("ignore", category=MCPDeprecationWarning)` tüm kategoriyi susturur; pytest'te `"error::mcp.MCPDeprecationWarning"` bunu bir test hatasına dönüştürür. +* Yeni kod bunların hiçbiri üzerine kurulmamalıdır. + +Bu belgelerdeki diğer tüm sayfalar güncel API'yi anlatır. diff --git a/i18n/tr/pages/get-started/first-steps.md b/i18n/tr/pages/get-started/first-steps.md new file mode 100644 index 0000000000..e7e9ec7495 --- /dev/null +++ b/i18n/tr/pages/get-started/first-steps.md @@ -0,0 +1,144 @@ +--- +translation: + sections: [0d6c05bcbf836bf3, 59a7b14eeefc68c1, 7114d8d6daba203f, e8bbb56a98ba7bc9, 5138010f6159901c, f78da7c7c363d4c6, 220a939cab348686] + tool: 1 +--- +# İlk adımlar {#first-steps} + +**[Giriş sayfası](../index.md)** hızlı ilerler: bir sunucu yazın, çalıştırın, bir araç çağırın. + +Bu sayfa ise ağırdan alır: bir sunucunun sunabileceği üç şeyin hepsini ele alır ve yol boyunca her şeye bir ad verir. + +## Host, istemci ve sunucu {#host-client-and-server} + +Bundan sonraki her sayfada göreceğiniz üç sözcük: + +* **Host**, LLM uygulamasıdır: Claude, bir IDE, bir ajan çalışma zamanı. Kullanıcının konuştuğu şey odur. +* **İstemci**, host'un içinde yaşar ve MCP konuşur. Host, bağlandığı her sunucu için bir istemci çalıştırır. +* **Sunucu**, bu SDK ile sizin oluşturduğunuz şeydir. İstemcilere bir şeyler sunar. Modelle hiçbir zaman doğrudan konuşmaz. + +Sunucuyu siz yazarsınız. Host'lar başkasının ürünüdür. SDK size bir de `Client` verir. Onu sunucularınızı test etmek için kullanırsınız; bu sayfanın ilerisinde karşınıza çıkar. + +## Üç temel öğe {#the-three-primitives} + +Bir sunucu tam olarak üç tür şey sunar. Onları birbirinden ayıran, **kullanılmalarına kimin karar verdiğidir**: + +| Temel öğe | Kontrol eden | Nedir | Örnek | +|----------------|-----------------|---------------------------------------------------------------|-----------------------------------------------| +| **Araçlar** | Model | Modelin bir eylemde bulunmak için çağırdığı fonksiyon | Bir API çağrısı, bir veritabanı yazma işlemi | +| **Kaynaklar** | Uygulama | Host'un modelin bağlamına yüklediği veri | Bir dosyanın içeriği, bir API yanıtı | +| **Prompt'lar** | Kullanıcı | Kullanıcının adıyla çağırdığı, yeniden kullanılabilir mesaj şablonu | Bir slash komutu, bir menü girdisi | + +"Kontrol eden", bu ayrımın özüdür. Bir araç, **model** onu çağırmaya karar verdiği için çalışır. Bir kaynak, **uygulama** modelin ona ihtiyacı olduğuna karar verdiği için eklenir. Bir prompt, **kullanıcı** onu seçtiği için çalışır. + +!!! info + Daha önce bir web API'si geliştirdiyseniz sezginin çoğu zaten sizde var: **kaynak** bir `GET`'tir + (veri yükler, hiçbir şeyi değiştirmez), **araç** ise bir `POST`'tur (iş yapar ve yan etkileri + olabilir). **Prompt**'un HTTP'de karşılığı yoktur; kullanıcının adıyla çalıştırdığı kayıtlı bir + sorguya daha yakındır. + +## Tek sunucu, üçü birden {#one-server-all-three} + +```python title="server.py" hl_lines="6 12 18" +--8<-- "docs_src/first_steps/tutorial001.py" +``` + +Üç sade fonksiyon, üç dekoratör. Her dekoratör kaydın tamamıdır: + +* `@mcp.tool()`, `add`'i bir **araç** yapar. +* `@mcp.resource("greeting://{name}")`, `greeting`'i bir **kaynak şablonu** yapar: URI içindeki `{name}`, fonksiyonun parametresidir. +* `@mcp.prompt()`, `summarize`'ı bir **prompt** yapar. Döndürdüğü dize bir kullanıcı mesajına dönüşür. + +Geri kalan her şeyi (adı, açıklamayı, argüman şemasını) SDK fonksiyonun kendisinden okur: adından, docstring'inden, tür ipuçlarından. Hiçbirini ayrıca bildirmediniz. + +!!! tip + SDK'nın iki yarısının iki ayrı import yolu vardır: `from mcp import Client` ve + `from mcp.server import MCPServer`. `from mcp import MCPServer` diye bir şey yoktur. + +### Deneyin {#try-it} + +MCP Inspector ile çalıştırın: + +```console +uv run mcp dev server.py +``` + +Yazdırdığı URL'yi açın. Inspector'da her temel öğe için bir sekme var; sırayla üzerinden geçin. + +**Tools.** Tek bir girdi: `add`, açıklaması *Add two numbers.* Formda `a` için zorunlu bir tamsayı alanı, `b` için de bir tane daha var. Doldurun, çağırın; sonuç `3`. Inspector bu formu `a: int, b: int` ifadesinden oluşturdu. Diğer tüm istemciler de öyle yapar. + +**Resources.** *Resources* listesi boş. `greeting`, **Resource Templates** altında; çünkü `greeting://{name}` bir parametre içerir: biri bir `name` verene kadar listelenecek tek bir kaynak yoktur. Ona `World` verin ve okuyun: + +```text +Hello, World! +``` + +**Prompts.** Tek bir girdi: tek bir zorunlu `text` argümanı olan `summarize`. Biraz metinle getirin; `role: user` taşıyan ve içeriği işlenmiş dizeniz olan tek bir mesaj alırsınız. Bir prompt'un hepsi budur: mesaj oluşturan bir fonksiyon. + +Inspector sunucunuzu **stdio** üzerinden çalıştırdı; bu, bir MCP sunucusunun konuşabileceği aktarımlardan biridir. Henüz bir tane seçmiyorsunuz; bunun sayfası **[Sunucunuzu çalıştırma](../run/index.md)**. + +## Yetenekler {#capabilities} + +Inspector'da üç sekme gördünüz. Üç tane olduğunu nereden bildi? + +Bir istemci bağlandığında sunucu **yeteneklerini** beyan eder: hangi istek ailelerini yanıtlayacağını. İstemci, neyi isteyeceğine karar vermek için bu beyanı kullanır. Bunu siz hiç yazmadınız; `MCPServer` sizin yerinize beyan eder. + +Kendiniz bakın. SDK'nın `Client`'ı sunucu nesnesini doğrudan kabul eder ve ona **bellek içinde** bağlanır (alt süreç yok, port yok): + +```python +import asyncio + +from mcp import Client + +from server import mcp + + +async def main() -> None: + async with Client(mcp) as client: + print(client.server_capabilities.model_dump(exclude_none=True)) + + +asyncio.run(main()) +``` + +```text +{'prompts': {'list_changed': True}, 'resources': {'subscribe': True, 'list_changed': True}, 'tools': {'list_changed': True}} +``` + +Bu sözlük, sunucunuzun beyan ettiği **yeteneklerdir**. Bağlanan her istemcinin öğrendiği ilk şey budur: + +| Yetenek | İstemci artık şunları çağırabilir | +|-------------|----------------------------------------------------------------| +| `tools` | `tools/list`, `tools/call` | +| `resources` | `resources/list`, `resources/templates/list`, `resources/read` | +| `prompts` | `prompts/list`, `prompts/get` | + +`MCPServer` üç temel öğenin hepsini sunar; bu yüzden üçü de her zaman beyan edilir. + +Orada ne olmadığına dikkat edin. `completions` (kaynak şablonları ve prompt'lar için argüman otomatik tamamlama) sizin yazacağınız bir işleyici gerektirir; bu sunucuda yok, dolayısıyla yetenek de yok ve uslu bir istemci sormaz. İsteğe bağlı her şey için kural budur: şeyi kaydedin, yetenek belirir; **[Tamamlamalar](../servers/completions.md)** bunu kanıtlar. + +!!! info + `Client(mcp)`, bu belgelerdeki her örneğin test edildiği aynı bellek içi istemcidir; + sizinkileri de böyle test edeceksiniz. Kendine ait koca bir sayfası var: **[Test etme](testing.md)**. + +## Yazmadıklarınız {#what-you-did-not-write} + +Bu sayfaya dönüp bir bakın. Üç küçük Python fonksiyonu yazdınız. Şunları **yazmadınız**: + +* Bir JSON Schema. `a: int, b: int`, `add` şemasının *ta kendisidir*. +* Bir istek işleyici. `tools/list`, `resources/read`, `prompts/get`: hepsi sizin yerinize sunulur. +* Bir yetenek beyanı. `MCPServer` onu sizin yerinize yaptı. +* Tek satır protokol. Sürüm anlaşması, JSON-RPC çerçevelemesi, yetenek değiş tokuşu: hepsi `mcp dev` ve `Client(mcp)` içinde oldu ve siz hiçbirini görmediniz. + +SDK'nın bütün meselesi bu oran. + +## Özet {#recap} + +* **Host** LLM uygulamasıdır, **istemci** onun MCP konuşan yarısıdır, **sunucu** ise sizin oluşturduğunuz şeydir. +* Araçları **model**, kaynakları **uygulama**, prompt'ları **kullanıcı** kontrol eder. +* Her temel öğe için bir dekoratör: `@mcp.tool()`, `@mcp.resource(uri)`, `@mcp.prompt()`. Ad, açıklama ve şema fonksiyondan gelir. +* İçinde `{param}` olan bir URI, somut kaynaklardan ayrı listelenen bir kaynak **şablonu** oluşturur. +* Sunucunun **yetenekleri** sizin yerinize beyan edilir ve bir istemci yalnızca sunucunun beyan ettiklerini ister. +* `Client(mcp)` sunucu nesnesine bellek içinde bağlanır: ilk günden test düzeneğiniz. + +Sırada **[Gerçek bir host'a bağlanma](real-host.md)** var: bu sunucu, gerçekten, Claude Desktop'ın ya da bir IDE'nin içinde. Ardından **[Test etme](testing.md)**: bir sayfa, bir bellek içi istemci ve çalışıp çalışmadığını bir daha asla tahmin etmek zorunda kalmazsınız. Ondan sonra her temel öğenin kendi sayfası var; modelin yönettiğiyle başlıyoruz: **[Araçlar](../servers/tools.md)**. diff --git a/i18n/tr/pages/get-started/index.md b/i18n/tr/pages/get-started/index.md new file mode 100644 index 0000000000..9685ff868b --- /dev/null +++ b/i18n/tr/pages/get-started/index.md @@ -0,0 +1,57 @@ +--- +translation: + sections: [ed4a756b4c53c585, 97e2fb315b7fe398, 4d04f1c6f4bf6c1d, 577d73078fc62baf] + tool: 1 +--- +# Başlarken {#get-started} + +MCP'de ya da bu SDK'da yeni misiniz? Buradan başlayın. Bu sayfalar sizi sıfırdan +çalışan, test edilmiş bir sunucuya götürür: [SDK'yı kurun](installation.md), +[ilk sunucunuzu](first-steps.md) yazın, [onu gerçek bir host'a bağlayın](real-host.md) ve +bellek içi bir istemciyle [test edin](testing.md). + +## Kodu çalıştırma {#run-the-code} + +Kod bloklarının tamamı doğrudan kopyalanıp kullanılabilir: hepsi eksiksiz, çalışan dosyalardır. + +Takip etmek için bir bloğu `server.py` dosyasına yapıştırın ve MCP Inspector'da açın: + +```console +uv run mcp dev server.py +``` + +Kodu yazmanız (ya da kopyalamanız), düzenlemeniz ve yerelde çalıştırmanız **ŞİDDETLE önerilir**. Asıl meseleyi kendi editörünüzde kullanırken görürsünüz: ne kadar az kod yazdığınızı, otomatik tamamlamayı, daha hiçbir şeyi çalıştırmadan hataları yakalayan tür denetimlerini. + +## Tahmin yürütmeyeceksiniz {#you-will-not-be-guessing} + +Bu belgelerdeki her örnek, SDK'nın kendi deposunda [`docs_src/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/docs_src) altında duran eksiksiz bir dosyadır ve her biri SDK'nın test paketi tarafından **bellek içi bir istemci** aracılığıyla çalıştırılır: + +```python +import pytest +from mcp import Client + +from server import mcp + + +@pytest.mark.anyio +async def test_add() -> None: + async with Client(mcp) as client: + result = await client.call_tool("add", {"a": 1, "b": 2}) + assert result.structured_content == {"result": 3} +``` + +Alt süreç yok, port yok, aktarım yok. `Client(mcp)` sunucu nesnesine doğrudan bağlanır. + +SDK'daki bir değişiklik bu sayfalardan birindeki örneği bozarsa, sayfadan önce CI kırmızıya döner. Burada okuduğunuz kod, çalışan kodun ta kendisidir. + +Bunu [Test etme](testing.md) sayfasında kendiniz de kullanacaksınız; kendi sunucularınızı da böyle test edersiniz. + +## Bundan sonra nereye {#where-to-go-next} + +Bir sunucuyu çalıştırdıktan sonra bu belgelerin geri kalanı bir kurs değil, bir başvuru kaynağıdır. +Her sayfa kendi başına ayakta durur; bu yüzden doğrudan ihtiyacınız olana atlayın: + +* Bir sunucunun ne sunduğu (araçlar, kaynaklar, prompt'lar) **[Sunucular](../servers/index.md)** bölümünde. +* Kaydettiğiniz fonksiyonların içinde nelerin kullanılabildiği **[İşleyicinin içinde](../handlers/index.md)** bölümünde. +* Sunucuyu istemcilerin önüne çıkarma (stdio, HTTP, mevcut FastAPI uygulamanız) **[Sunucunuzu çalıştırma](../run/index.md)** bölümünde. +* Diğer tarafı, yani MCP sunucularını *kullanan* bir uygulamayı oluşturma **[İstemciler](../client/index.md)** bölümünde. diff --git a/i18n/tr/pages/get-started/installation.md b/i18n/tr/pages/get-started/installation.md new file mode 100644 index 0000000000..b696c2ca50 --- /dev/null +++ b/i18n/tr/pages/get-started/installation.md @@ -0,0 +1,47 @@ +--- +translation: + sections: [6e2f9bab94d5ed36, 8cf653388f69e28b, 6fd9ea2f65de0df6] + tool: 1 +--- +# Kurulum {#installation} + +Python SDK, PyPI'da [`mcp`](https://pypi.org/project/mcp/) adıyla yayımlanır. **Python 3.10+** gerektirir. + +Bu belgeler, güncel kararlı sürüm hattı olan **v2**'yi anlatır: + +=== "uv" + + ```bash + uv add "mcp[cli]" + ``` + +=== "pip" + + ```bash + pip install "mcp[cli]" + ``` + +!!! note "v1'den mi geliyorsunuz?" + v2, geriye dönük uyumsuz değişiklikler içeren bir ana sürümdür; **[Geçiş kılavuzu](../migration.md)** + bunların her birini ele alır. *Paketiniz* `mcp`'ye bağımlıysa ve henüz geçişe hazır değilse, + sabitlenmemiş bir çözümlemenin 1.x hattında kalması için `<2` üst sınırını koruyun (örneğin `mcp>=1.28,<2`). + +## Neler kurulur {#what-gets-installed} + +SDK'yı kullanmak için bunların hiçbirini bilmeniz gerekmez. Yine de her bağımlılığın ne işe yaradığını merak ediyorsanız: + +* `mcp-types`: tüm protokol türleri (istekler, sonuçlar, içerik blokları), SDK ile birebir aynı sürüm numarasıyla yayımlanan ayrı bir paket olarak gelir. `mcp`'ye bağımlı kod bunu `mcp.types` takma adı üzerinden içe aktarır (bu belgelerdeki her `from mcp.types import ...` satırı böyledir); `mcp_types`'ı doğrudan yalnızca `mcp-types`'ı SDK olmadan kuran bir projede içe aktarın. +* [`anyio`](https://anyio.readthedocs.io/): asenkron çalışma zamanı. SDK'nın tamamı anyio üzerine yazıldığı için hem `asyncio` hem de `trio` üzerinde çalışır. +* [`pydantic`](https://docs.pydantic.dev/): her `mcp.types` modelinin temeli; ayrıca tüm şema üretimi ve doğrulaması. +* [`httpx2`](https://pypi.org/project/httpx2/): Streamable HTTP ve SSE *istemci* aktarımlarının arkasındaki HTTP istemcisi; server-sent events desteği yerleşik olarak gelir. +* [`starlette`](https://www.starlette.io/), [`uvicorn`](https://www.uvicorn.org/), [`sse-starlette`](https://pypi.org/project/sse-starlette/) ve [`python-multipart`](https://pypi.org/project/python-multipart/): HTTP *sunucu* aktarımları. +* [`jsonschema`](https://pypi.org/project/jsonschema/): bir aracın yapılandırılmış çıktısını, bildirdiği çıktı şemasına göre doğrular. +* [`pyjwt[crypto]`](https://pyjwt.readthedocs.io/): yetkilendirme için OAuth token işleme. +* [`opentelemetry-api`](https://opentelemetry-python.readthedocs.io/): yalnızca hafif API; bu sayede siz bir OpenTelemetry SDK'sı ve dışa aktarıcı kurmadıkça SDK'nın izleme middleware'inin hiçbir maliyeti olmaz. +* [`typing-extensions`](https://typing-extensions.readthedocs.io/) ve [`typing-inspection`](https://pypi.org/project/typing-inspection/): Python 3.10'da modern tür özellikleri. +* [`pywin32`](https://pypi.org/project/pywin32/): yalnızca Windows'ta, `stdio` alt süreç yönetimi için kullanılır. + +## İsteğe bağlı ekler {#optional-extras} + +* `mcp[cli]`, `mcp` komut satırı aracı (`mcp dev`, `mcp run`, `mcp install`) için [`typer`](https://typer.tiangolo.com/) ve [`python-dotenv`](https://pypi.org/project/python-dotenv/) paketlerini ekler. Geliştirme sırasında bunu istersiniz; dağıtılmış bir sunucuda gerekmeyebilir. +* `mcp[rich]`, daha okunaklı sunucu log'ları için [`rich`](https://rich.readthedocs.io/) paketini ekler. diff --git a/i18n/tr/pages/get-started/real-host.md b/i18n/tr/pages/get-started/real-host.md new file mode 100644 index 0000000000..e940100e44 --- /dev/null +++ b/i18n/tr/pages/get-started/real-host.md @@ -0,0 +1,183 @@ +--- +translation: + sections: [3c4f2f06b4e978b6, 22520eecae3d1961, f4e1709db18d635a, 2eb57992049671d9, 1ba83e9af37cc1b4, 4822586344b08d9e, 1c93afef72478992, b6b448f9eddd51dc, fe55370fd931815b] + tool: 1 +--- +# Gerçek bir host'a bağlanma {#connect-to-a-real-host} + +**Host**, sunucunuzun sonunda içine girdiği uygulamadır: Claude Desktop, Claude Code, bir IDE. Kullanıcının konuştuğu şey host'tur. Onun içinde bir MCP **istemcisi** sunucunuzu bir alt süreç olarak başlatır ve onunla o sürecin stdin'i ve stdout'u üzerinden konuşur. + +Yani bir host'a bağlanmak tek bir eylemdir: ona **sunucunuzu başlatan komutu** söylersiniz. Bu sayfadaki her şey (iki CLI komutu, üç JSON dosyası) aynı komutu koyacağınız farklı bir yerdir. + +## Tek sunucu, her host {#one-server-every-host} + +```python title="server.py" hl_lines="3 33-34" +--8<-- "docs_src/real_host/tutorial001.py" +``` + +İki araç ve bir kaynak, tek dosya. Bu dosyayla ilgili üç şey aşağıdaki her host için önemlidir: + +* Argümansız `mcp.run()` bir **stdio** sunucusu başlatır: bloklar, protokol mesajlarını stdin'den okur ve stdout'a yazar. Bu sayfadaki her host'un konuştuğu aktarım budur. Host dosyanızı bir alt süreç olarak başlatır ve bu iki kanalın sahibidir; bağlanmanın her zaman yalnızca "işte komut" olmasının nedeni de budur. Hiçbir zaman port seçmezsiniz ve hiçbir şey bir portu dinlemez. +* `run()`, `if __name__ == "__main__":` altındadır. Aşağıdaki her şey bu dosyayı çalıştırmak yerine **import eder**; bu yüzden korumasız bir `run()`, modülü herhangi bir şey yüklediği anda bir sunucu başlatırdı. +* Sunucu nesnesi, `mcp` adında modül düzeyinde bir globaldir. `mcp run`'ın aradığı ad budur (`server` ve `app` de olur). Başka bir ad verirseniz açıkça belirtirsiniz: `mcp run server.py:bookshop`. + +Bu, bu sayfadaki son Python satırı. Buradan aşağısı tamamen host yapılandırması. + +## Başlatma komutu {#the-launch-command} + +Aşağıdaki her host aynı komutu alır: + +```bash +uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py +``` + +Hepsi için tek komut, çünkü `uv run --with` SDK'yı anında yeni bir ortama çözümler: herhangi bir dizinden çalışır, ne bir projeye ne de etkinleştirilecek bir sanal ortama ihtiyaç duyar. Bu, burada başka her yerden daha önemlidir; çünkü host sunucunuzu sizin kabuğunuzdan değil, *kendi* çalışma dizininden ve neredeyse boş bir ortamla başlatır. + +Bu aynı zamanda `mcp install`'un sizin için Claude Desktop'ın yapılandırmasına yazdığı komuttur (aşağıda). Böylece elle yazdığınız ile aracın ürettiği, aracın eklediği tam sürüm sabitlemesi dışında örtüşür. + +!!! tip "Host `uv`'yi bulamazsa" + Host sunucunuzu asgari bir `PATH` ile başlatır ve `uv` bunun üzerinde olmayabilir. Yalın + `uv`'yi `which uv` (macOS/Linux) veya `where uv` (Windows) çıktısındaki mutlak yolla değiştirin. + `mcp install`'un yazdığı da tam olarak budur. + +!!! note "Bu sayfa yerel senaryoyu anlatır" + Buradaki her şey sunucunuzu host'un bulunduğu makinede çalıştırır: host dosyanızı stdio + üzerinden başlatır. Kişisel ya da tek makinelik bir araç için bu tam olarak doğru olandır. + Dosyanıza sahip *olmayan* insanlara bir sunucu vermek için komut değil **URL** dağıtırsınız: + aynı `mcp` nesnesi, Streamable HTTP üzerinden sunulur. **[Sunucunuzu çalıştırma](../run/index.md)** + bu kararı tek bir tabloda verir, **[Dağıtım ve ölçekleme](../run/deploy.md)** ise oradan + gerçek bir ana bilgisayar adına giden yoldur. + + Ve host, içinde bir MCP istemcisi olan bir uygulamadan başka bir şey değildir; bu yüzden kendi + Python kodunuz host rolünü oynayabilir: **[İstemci aktarımları](../client/transports.md)** + bu aynı dosyayı `stdio_client(...)` ile bir alt süreç olarak başlatır, **[Test etme](testing.md)** + ise ona hiç süreç olmadan bellek içinde bağlanır. + +## Claude Desktop {#claude-desktop} + +SDK'nın sizin için yapılandırabildiği tek host: + +```bash +uv run mcp install server.py +``` + +Hepsi bu. `mcp install` sunucunun adını okumak için dosyayı import eder, Claude Desktop'ın yapılandırma dosyasını bulur ve başlatma komutunu içine yazar. Bu arada yolunuzu mutlak bir yola çevirir, sizin yapmanıza gerek kalmaz. + +Kafa karıştıracak bir şey yok. Yazdığı kayıt şu: + +```json +{ + "mcpServers": { + "Bookshop": { + "command": "/absolute/path/to/uv", + "args": [ + "run", + "--frozen", + "--with", + "mcp[cli]==2.0.0", + "mcp", + "run", + "/absolute/path/to/server.py" + ] + } + } +} +``` + +Bu, yukarıdaki bölümdeki başlatma komutunun üç eklemeli hâli: `uv`'nin mutlak yolu, `uv` yakınında bulunduğu bir kilit dosyasını asla yeniden yazmasın diye `--frozen` ve kurulu `mcp` sürümüne tam bir sabitleme. Şurada bulunan `claude_desktop_config.json` dosyasına yazılır: + +* **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` +* **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` + +Bu dosyayı elle yazabilirsiniz. `mcp install`, bunu yaparken klasik hatayı (göreli yol) yapmayın diye vardır. + +Claude Desktop'tan tamamen çıkın (yalnızca penceresini kapatmayın) ve yeniden açın. + +!!! warning + Claude Desktop'ın yapılandırma *dizini* henüz yoksa `mcp install`, `Claude app not found` + hatasıyla başarısız olur. Claude Desktop'ı kurun ve bir kez çalıştırın: dizini oluşturan budur. + +!!! tip + Claude Desktop sunucunuzu kendi sürecinde başlatır; bu yüzden kabuğunuzun ortam değişkenleri + orada yoktur. `uv run mcp install server.py -v API_KEY=abc123` (veya `-f .env`) bunları kaydın + `env` alanına işler. `--name` kayıt adını geçersiz kılar; varsayılan olarak sunucunun `name` + değeridir. + +## Claude Code {#claude-code} + +Düzenlenecek dosya yok. Sunucuyu `claude` CLI ile kaydedin; `--` sonrasındaki her şey başlatma komutudur. + +```bash +claude mcp add bookshop -- uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py +``` + +`bookshop`'un bağlı olduğunu ve araçlarının listelendiğini doğrulamak için bir Claude Code oturumunda `/mcp` çalıştırın. + +## Cursor {#cursor} + +Proje kök dizininizde `.cursor/mcp.json` dosyasını oluşturun. + +```json +{ + "mcpServers": { + "bookshop": { + "command": "uv", + "args": ["run", "--with", "mcp[cli]", "mcp", "run", "/absolute/path/to/server.py"] + } + } +} +``` + +Aynı `command` artı `args`, Claude Desktop'ın kullandığı aynı `mcpServers` anahtarı altında. Sunucu, Cursor'ın MCP ayarlarında iki araç da listelenmiş olarak görünür. + +## VS Code {#vs-code} + +Proje kök dizininizde `.vscode/mcp.json` dosyasını oluşturun. + +```json +{ + "servers": { + "bookshop": { + "type": "stdio", + "command": "uv", + "args": ["run", "--with", "mcp[cli]", "mcp", "run", "/absolute/path/to/server.py"] + } + } +} +``` + +Cursor'ın dosyasından iki fark var ve yalnızca bu ikisi: sarmalayıcı anahtar `mcpServers` değil `servers`'tır ve her kayıt `type`'ını bildirir. Güven iletişim kutusunu onaylayın; ardından Command Palette'teki **MCP: List Servers**, `bookshop`'u çalışır durumda gösterir. + +!!! note + **GitHub Copilot** eklentisiyle oturum açılmış VS Code 1.99 veya üzeri gerekir (Copilot Free + yeterli) ve Copilot Chat **Agent** modunda olmalıdır; çünkü başka hiçbir mod araç çağırmaz. + +## Görünmüyor {#it-doesnt-show-up} + +Herhangi bir host yapılandırmasına dokunmadan önce başlatma komutunu kendiniz çalıştırın: + +```bash +uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py +``` + +Hiçbir şey yazdırmaz ve geri dönmez. Bu sessizlik doğrudur: stdio sunucusu, bir host'un stdin'de ilk konuşan taraf olmasını bekler (durdurmak için `Ctrl-C`). Asıl hata bir traceback ya da anında çıkıştır; artık onu bir host üzerinden tahmin etmeye çalışmak yerine okuyabilirsiniz. + +Bu komut oturup beklediğinde, geriye kalan neredeyse her zaman üç şeyden biridir: + +* **Göreli yol.** Host sunucunuzu kaydı yaptığınız dizinden değil, *kendi* çalışma dizininden başlatır. `/absolute/path/to/server.py` gereken yerde `server.py` yazmak, açık ara en yaygın hatadır. Host `uv`'yi de bulamıyorsa o yol da mutlak olmalıdır. +* **Host hâlâ eski yapılandırmasını çalıştırıyor.** Host'lar yapılandırmalarını başlarken okur. Özellikle Claude Desktop'tan, `claude_desktop_config.json` üzerindeki bir düzenleme etkili olmadan önce *tamamen çıkılması* (yalnızca penceresinin kapatılması değil) ve yeniden açılması gerekir. +* **Yönlendirilen pencerenin dışında stdout'a bir şey ulaştı.** stdio'da stdout protokolün *ta kendisidir*. SDK, hizmet verirken flush edilmiş başıboş çıktıyı stderr'e yönlendirir; ancak o andan önce stdout'a flush edilen çıktı (bir sarmalayıcı betiğin echo'su, tamponsuz bir süreçte import anında bir `print()`) ya da yorumlayıcı çıkışında boşaltılan tamponlanmış bir `print()`, host'a bozuk bir mesaj verir ve host bağlantıyı keser. stderr işleyicisi her kaydı flush eden varsayılan `logging` yapılandırmasıyla log tutun; özel işleyiciler de stdout'tan uzak durmalıdır. Ayrıntıların tamamı **[Logging](../handlers/logging.md)** sayfasında. + +Claude Desktop her sunucu için bir log tutar: `mcp-server-.log` sunucunuzun stderr'idir, bağlantılar için `mcp.log`'un yanında; macOS'te `~/Library/Logs/Claude`, Windows'ta `%APPDATA%\Claude\logs` altında. + +Bu üçünün ötesindeki her şey için doğru sayfa **[Sorun giderme](../troubleshooting.md)**. + +## Özet {#recap} + +* **Host** (Claude Desktop, bir IDE), sunucunuzu stdio üzerinden bir alt süreç olarak başlatan bir MCP istemcisi çalıştırır. Bağlanmak, ona tek bir başlatma komutu vermek demektir. +* O komut `uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py`: etkinleştirilecek venv yok, her dizinden çalışır. +* **Claude Desktop**, `mcp install`'un sizin için yapılandırdığı tek host'tur. Aynı komutu (artı `uv`'nin mutlak yolu, `--frozen` ve kurulu sürüme tam bir sabitleme) `claude_desktop_config.json` dosyasına yazar; böylece sizin yapmanıza hiç gerek kalmaz. +* **Claude Code** için `claude mcp add bookshop -- `. **Cursor** için `mcpServers` altında `.cursor/mcp.json`. **VS Code** için `servers` altında `.vscode/mcp.json`, her kayıtta bir `type` ile. +* Her yerde mutlak yollar, yapılandırmasını düzenledikten sonra host'u yeniden başlatın ve SDK dışında hiçbir şeyin stdout'a yazmasına izin vermeyin. + +Bu sayfadaki her host aynı dosyaya, aynı komutla bağlandı. O dosyanın neler *sunabileceği* ise bu belgelerin geri kalanı: **[Araçlar](../servers/tools.md)**, **[Kaynaklar](../servers/resources.md)** ve stdio dışındaki tüm aktarımlar için **[Sunucunuzu çalıştırma](../run/index.md)**. diff --git a/i18n/tr/pages/get-started/testing.md b/i18n/tr/pages/get-started/testing.md new file mode 100644 index 0000000000..5683ee57c5 --- /dev/null +++ b/i18n/tr/pages/get-started/testing.md @@ -0,0 +1,116 @@ +--- +translation: + sections: ['4926721070127497', c52a1de2b6b32f40, 2e410b412c25f314, 627195f7159e24ef] + tool: 1 +--- +# Test etme {#testing} + +Python SDK, **bellek içi aktarıma** sahip bir `Client` sınıfıyla gelir: ona sunucu nesnenizi geçirirsiniz, o da doğrudan bağlanır. + +Alt süreç yok. Port yok. Hiç aktarım yok. FastAPI'nin `TestClient`'ıyla aynı fikir. + +## Temel kullanım {#basic-usage} + +Tek bir aracı olan basit bir sunucunuz olduğunu varsayalım: + +```python title="server.py" +--8<-- "docs_src/testing/tutorial001.py" +``` + +Aşağıdaki testi çalıştırmak için iki ek (geliştirme) bağımlılığına ihtiyacınız var: + +=== "uv" + + ```bash + uv add --dev pytest inline-snapshot + ``` + +=== "pip" + + ```bash + pip install pytest inline-snapshot + ``` + +!!! info + Bu belgeler [`pytest`](https://docs.pytest.org/en/stable/)'i zaten bildiğinizi varsayar. + + Aşağıdaki test, sonuç nesnesinin tamamını tek satırda doğrulamak için + [`inline-snapshot`](https://15r10nk.github.io/inline-snapshot/latest/) kullanır. Bir testin + çıktısını, gördüğünüz `snapshot(...)` değişmezi olarak kaydeder. Kullanmak istemezseniz + import satırını silin ve herhangi bir testte olduğu gibi ilgilendiğiniz alanları doğrulayın + (`result.content[0].text == "3"`). + +Şimdi test: + +```python title="test_server.py" +import pytest +from inline_snapshot import snapshot +from mcp import Client +from mcp.types import CallToolResult, TextContent + +from server import mcp + + +@pytest.fixture +def anyio_backend(): # (1)! + return "asyncio" + + +@pytest.fixture +async def client(): # (2)! + async with Client(mcp, raise_exceptions=True) as c: + yield c + + +@pytest.mark.anyio +async def test_call_add_tool(client: Client): + result = await client.call_tool("add", {"a": 1, "b": 2}) + # Drop the server identity stamp in `_meta`; it is not what this test is about. + result.meta = None + assert result == snapshot( + CallToolResult( + content=[TextContent(type="text", text="3")], + structured_content={"result": 3}, + ) + ) +``` + +1. `trio` kullanıyorsanız bunun yerine `"trio"` döndürün. Ayrıntılar için [anyio belgelerine](https://anyio.readthedocs.io/en/stable/testing.html#specifying-the-backends-to-run-on) bakın. +2. Fixture, bağlı bir istemci üretir. `client` alan her test, aynı sunucuya yeni bir bellek içi bağlantı alır. + +İşte bu kadar! Artık testlerinizi daha fazla senaryoyu kapsayacak şekilde genişletebilirsiniz. + +## Neden `raise_exceptions=True`? {#why-raise_exceptionstrue} + +İki farklı şey ters gidebilir ve bu bayrak yalnızca birine dokunur. + +**Araçlarınızdan** birinin içindeki bir istisna, protokol hatası değildir. `is_error=True` taşıyan +normal bir sonuca dönüşür ve model mesajı okur. `raise_exceptions` bunu değiştirmez: onunla da +onsuz da `call_tool` aynı `is_error=True` sonucunu döndürür. Bu konuda ayrı bir sayfa var: +**[Hataları ele alma](../servers/handling-errors.md)**. + +Araç gövdesinin **dışındaki** bir hata ise farklıdır. `Client(mcp)`'nin size verdiği bağlantıda +sunucu, istemci görmeden önce onu genel bir `"Internal server error"` mesajına dönüştürerek +temizler. Beklenmedik bir çökmenin ayrıntılarını uzak bir çağırana asla sızdırmamalısınız. Bir +testte ise tam olarak *istemediğiniz* şey budur ve `raise_exceptions=True`'nun değiştirdiği de +budur: testiniz temizlenmiş mesaj yerine gerçek mesajı görür. + +Testlerde açık bırakın. Üretim kodunda bir anlamı yoktur. + +## Varsayılan olarak süreç içi {#in-process-by-default} + +!!! note + `Client(mcp)` süreç içinde bağlanır ve varsayılan olarak **nesilden bağımsızdır**: sunucuyu + yoklar ve uygun protokol yolunu seçer. Testiniz eski nesle özgü anlamları (örnekleme (sampling) + veya elicitation (kullanıcıdan bilgi isteme) itmesi, `message_handler`) sınıyorsa `mode="legacy"` + olarak sabitleyin ve orada `raise_exceptions=True`'yu kaldırın: eski nesil bir bağlantı zaten + hiçbir zaman temizleme yapmaz ve bayrak, hatayı testinizde değil sunucu görevinin içinde + yeniden fırlatır. + +Bu belgelerin, örneklerinin çalıştığı sözünü verebilmesinin nedeni de o tek satırdır: her örnek +dosya SDK'nın kendi test paketinde çalıştırılır, neredeyse hepsi tam olarak bu istemci +üzerinden. SDK'nın kendi üzerinde kullandığı aracın aynısını kullanıyorsunuz. + +Çalışan, test edilmiş bir sunucunuz var. Onu gerçek bir uygulamanın (Claude Desktop, bir IDE) +içine koymak **[Gerçek bir host'a bağlanma](real-host.md)** sayfasında; sunmanın diğer tüm +yolları ise **[Sunucunuzu çalıştırma](../run/index.md)** sayfasında. diff --git a/i18n/tr/pages/handlers/context.md b/i18n/tr/pages/handlers/context.md new file mode 100644 index 0000000000..682b150dc5 --- /dev/null +++ b/i18n/tr/pages/handlers/context.md @@ -0,0 +1,134 @@ +--- +translation: + sections: [b50152f05c81e786, b302059b22fb7cb4, 85682a1bf561243a, 53fc48838eb6837a, b24190e0842786ec, 85f93e150fc9b240] + tool: 1 +--- +# Context nesnesi {#the-context} + +Bir aracın argümanları modelden gelir. Geri kalan her şey (hizmet verdiğiniz istek, içinde yaşadığınız sunucu, istemciye geri konuşmanın bir yolu) tek bir nesneden gelir: **`Context`**. + +Onu siz oluşturmazsınız, yapılandırmazsınız da. Yalnızca istersiniz. + +## İsteyin {#ask-for-it} + +Herhangi bir araca `Context` ile işaretlenmiş bir parametre ekleyin: + +```python title="server.py" hl_lines="2 8" +--8<-- "docs_src/context/tutorial001.py" +``` + +* SDK her istek için yeni bir `Context` oluşturur ve onu içeri geçirir. +* Parametrenin **adı önemli değildir**. `ctx`, `context`, `c`: SDK onu tür işaretinden bulur. +* Kaynaklar ve prompt'lar da aynı şekilde bir tane bildirebilir. +* `ctx.request_id`, fonksiyonunuzun şu anda hizmet verdiği isteğin kimliğidir. + +!!! info + FastAPI kullandıysanız bu hareketi görmüşsünüzdür: bir parametreyi çatının kendi türüyle + (orada `Request`, burada `Context`) bildirirsiniz ve çatı onu sağlar. Kaydedilecek bir şey yok, + yapılandırılacak bir şey yok: mekanizmanın tamamı tür işaretinden ibarettir. + +### Model için görünmez {#invisible-to-the-model} + +İçselleştirilmesi gereken kısım burası. `tools/list`'in `search_books` için bildirdiği girdi şeması şöyle: + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"} + }, + "required": ["query"], + "title": "search_booksArguments" +} +``` + +Tek bir özellik. `ctx` bir argüman değildir: şemada asla görünmez, modele asla söylenmez ve hiçbir istemci onu dolduramaz. Sizinle SDK arasındaki bir sözleşmedir, iletilen veride görünmez. + +### Deneyin {#try-it} + +Sunucuyu MCP Inspector ile çalıştırın: + +```console +uv run mcp dev server.py +``` + +`search_books` formunda tek bir `query` alanı var. Onu `dune` ile çağırın: + +```text +[request 3] Found 3 books matching 'dune'. +``` + +Sayı, bu isteğin denk geldiği sıra numarasıdır. Aracı yeniden çağırın, değişir: her istek kendi `Context`'ini alır. + +## Size ne sağlar {#what-it-gives-you} + +Enjekte edilen nesne küçüktür. `request_id` dışında: + +* `await ctx.read_resource(uri)`: bir aracın içinden sunucunun **kendi** kaynaklarından birini okur. Bir sonraki bölüm. +* `await ctx.report_progress(progress, total, message)`: uzun bir çağrı sırasında çağırana ilerlemeyi akış halinde bildirir. Ayrıntıların tamamı **[İlerleme](progress.md)** sayfasında. +* `await ctx.elicit(message, schema)` ve `await ctx.elicit_url(...)`: aracı duraklatır ve kullanıcıya bir soru sorar. Bu da **[Elicitation](elicitation.md)**. +* `ctx.session`: bu istemciyle konuşmanın sunucu tarafı. İstemciye gönderdiğiniz bildirimler burada yaşar; son bölüm onu kullanır. +* `ctx.headers`: aktarımın taşıdığı istek başlıkları, stdio'da ise `None`. Özel bir başlığı `(ctx.headers or {}).get("x-...")` ile okuyun. Başlıklar istemcinin sağladığı girdidir; bir yerel ayar ya da özellik bayrağı için uygundur, kimlik için asla. +* `ctx.request_context`: istek başına tutulan ham kayıt. Elinizin gideceği alan `lifespan_context`'tir, yani başlangıç kodunuzun yield ettiği nesne (bkz. **[Lifespan](lifespan.md)**). + +Log tutma bu listede bilerek yok. Bir sunucu, diğer her Python programı gibi Python'ın `logging` modülüyle log tutar. **[Log tutma](logging.md)** bunun nedenini anlatan kısa sayfadır. + +!!! tip + Enjeksiyon yalnızca kaydettiğiniz fonksiyon için gerçekleşir. Aracınızın çağırdığı bir yardımcı + fonksiyon kendi `Context`'ini almaz; `ctx`'i sıradan bir argüman olarak aşağıya geçirin. Başka bir + yerden alınabilecek ortamda asılı bir "geçerli bağlam" yoktur. + +## Kendi kaynaklarınızı okuma {#read-your-own-resources} + +Bir sunucunun kaynakları yalnızca istemciler için değildir. Bir araç da onları okuyabilir: + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/context/tutorial002.py" +``` + +`ctx.read_resource`, URI'yi `resources/read`'e hizmet veren aynı kayıt defteri üzerinden çözümler; böylece araç, istemcinin alacağının aynısını alır: içerik bloğu başına bir tane olmak üzere `ReadResourceContents` öğelerinden oluşan yinelenebilir bir nesne. Bu URI için bir tane var: + +```python +contents.content # 'fiction, non-fiction, poetry' +contents.mime_type # 'text/plain' +``` + +* `content`, `genres()`'in döndürdüğünün ta kendisidir. Tek bir doğruluk kaynağı: istemci kaynağa göz atar, araçlarınız onu tüketir, kimse dizgeyi kopyalamaz. +* `describe_catalog`'un tek parametresi `Context`'tir, bu yüzden girdi şemasında **hiçbir özellik yoktur**. Model onu `{}` ile çağırır. + +## İstemciye listenin değiştiğini söyleme {#tell-the-client-the-list-changed} + +Bir sunucunun sundukları içe aktarma anında sabitlenmez. Çalışma zamanında bir araç kaydedin, ardından istemciye söyleyin: + +```python title="server.py" hl_lines="15-16" +--8<-- "docs_src/context/tutorial003.py" +``` + +* `mcp.add_tool(recommend_book)` düz bir fonksiyonu araç olarak kaydeder: ad, açıklama ve şema tam olarak `@mcp.tool()`'un yapacağı gibi türetilir. +* `await ctx.session.send_tool_list_changed()`, `notifications/tools/list_changed` bildirimini gönderir. Onu alan bir istemci `tools/list`'i yeniden çağırır ve `recommend_book`'u görür. + +Kardeşleri `send_resource_list_changed()`, `send_prompt_list_changed()` ve belirli tek bir kaynaktaki değişiklik için `send_resource_updated(uri)`'dir. + +Bir 2026-07-28 bağlantısında istemciler değişiklik bildirimlerini yalnızca kendilerinin açtığı bir `subscriptions/listen` akışı üzerinden alır; bu yüzden yukarıdaki `send_*` yöntemleri o akışlara ulaşmaz. `Context`'in yayımlama yöntemleri abone olunmuş tüm akışlara aynı anda iletir: `await ctx.notify_tools_changed()`, `await ctx.notify_prompts_changed()`, `await ctx.notify_resources_changed()` ve `await ctx.notify_resource_updated(uri)`. Kopyalar arasında ölçekleme dahil ayrıntıların tamamı **[Abonelikler](subscriptions.md)** sayfasında. + +!!! check + Kimse `enable_recommendations`'ı çalıştırmadan önce, vaat ettiğiniz araç mevcut değildir. Yine de + çağırın; sonuç, modelin okuyabileceği bir hatadır: + + ```text + Unknown tool: recommend_book + ``` + + `enable_recommendations`'ı çalıştırın, aynı çağrı bu kez başarılı olur. Araç listesi gerçekten + dinamiktir: `tools/list`, *tam şu anda* ne kayıtlıysa onu yansıtır. + +## Özet {#recap} + +* Bir parametreyi `Context` ile işaretleyin (bir araçta, kaynakta ya da prompt'ta), SDK onu enjekte eder. Ad size kalmış. +* Model için görünmezdir: girdi şeması yalnızca gerçek argümanlarınızı içerir. +* `ctx.request_id` isteği tanımlar; `ctx.request_context.lifespan_context` başlangıç kodunuzun yield ettiği şeydir. +* `await ctx.read_resource(uri)`, bir aracın sunucunun kendi kaynaklarını okumasını sağlar. +* `ctx.session` istemciye giden geri kanaldır: `send_tool_list_changed()` ve kardeşleri, değiştirdiğiniz bir listeyi yeniden çekmesini söyler. +* İlerleme bildirme ve elicitation da `Context`'ten başlar; her birinin kendi sayfası var. + +Modelin asla görmediği, kendi fonksiyonlarınızın doldurduğu parametreler **[Bağımlılıklar](dependencies.md)** sayfasında. diff --git a/i18n/tr/pages/handlers/dependencies.md b/i18n/tr/pages/handlers/dependencies.md new file mode 100644 index 0000000000..c7184c8b83 --- /dev/null +++ b/i18n/tr/pages/handlers/dependencies.md @@ -0,0 +1,168 @@ +--- +translation: + sections: [b0389403e98d25ad, e2cf58b43b285e86, a363e1a38e1a5971, 6cfac078feb18013, b4535bd61df337e6, e97ed44207f929fd] + tool: 1 +--- +# Bağımlılıklar {#dependencies} + +Bir aracın argümanları modelden gelir. Bazı değerlerse asla modelden gelmemelidir: kayıtlarınızdan bakılan bir fiyat, yalnızca bir insanın verebileceği bir onay, modelin uydurarak yanlış yapabileceği her şey. + +**Bağımlılıklar**, kendi fonksiyonlarınızın doldurduğu parametrelerdir. Parametreye tür açıklamasını eklersiniz, fonksiyonu belirtirsiniz; SDK da araç çalışmadan önce onu çağırır. + +## Bir bağımlılık bildirme {#declare-one} + +Parametrenin türünü `Annotated[...]` içine sarın ve `Resolve(fn)` ekleyin: + +```python title="server.py" hl_lines="18-19 23" +--8<-- "docs_src/dependencies/tutorial001.py" +``` + +* `check_stock` bir **çözümleyicidir**: SDK'nın `reserve_book`'tan önce çalıştırdığı, dönüş değeri `stock` argümanı hâline gelen sıradan bir fonksiyon. +* `title` parametresi, aracın kendi `title` argümanıdır ve **ada göre** eşleştirilir. Çözümleyici, araç gövdesinin göreceği doğrulanmış değerin aynısını görür. +* Araç gövdesi, zaten var olan bir `Stock` ile işe başlar. Araçta arama kodu yok, "ya yoksa" diye başlayan bir giriş yok. + +!!! info + FastAPI kullandıysanız bu, `Depends`'in karşılığıdır. Aynı hamle, aynı gerekçe: fonksiyon + neye ihtiyacı olduğunu bildirir, framework bunu sağlar ve bağlantı tür açıklamasında durur. + +### Modele görünmez {#invisible-to-the-model} + +`tools/list`'in `reserve_book` için bildirdiği giriş şeması şöyle: + +```json +{ + "type": "object", + "properties": { + "title": {"title": "Title", "type": "string"} + }, + "required": ["title"], + "title": "reserve_bookArguments" +} +``` + +Tek bir özellik. **[Context nesnesi](context.md)** sayfasındaki `Context` gibi, çözümlenmiş bir parametre de sizinle SDK arasındaki bir sözleşmedir: `stock` şemada yer almaz, modele ondan hiç söz edilmez ve yine de bir `stock` değeri gönderen istemci yok sayılır. Aracınızın alabileceği tek değer çözümleyicinin değeridir. + +Asıl mesele de bu son kısım. Modelin sağlayamadığı bir parametre, modelin yanlış yapamayacağı bir parametredir. + +### Deneyin {#try-it} + +Sunucuyu MCP Inspector ile çalıştırın: + +```console +uv run mcp dev server.py +``` + +`reserve_book` formunda tek bir `title` alanı var. `stock` hiçbir yerinde yok. `Dune` ile çağırın: + +```text +Reserved 'Dune' (6 copies left). +``` + +Araç gövdesi hiçbir şey aramadı: önce `check_stock` çalıştı, döndürdüğü `Stock` da argüman olarak geldi. `Neuromancer`'ı deneyin; aynı çözümleyici araca sıfır verir. + +!!! tip + Araç gövdesinde doğrudan `check_stock(title)` da çağırabilirdiniz. Değer bir yardımcı fonksiyon + çağrısından fazlasını hak ettiğinde onu bağımlılık olarak bildirin: stok bilgisine ihtiyaç duyan + her araç aynı parametreyi bildirir ve kaç tanesi bildirirse bildirsin SDK çözümleyiciyi çağrı + başına en fazla bir kez çalıştırır. Sonraki bölümler geri kalanını ekler: birbirine bağımlı + çözümleyiciler ve kullanıcıya soru soran çözümleyiciler. + +## Bağımlılıkların bağımlılıkları {#dependencies-of-dependencies} + +Bir çözümleyici, aynı tür açıklamasıyla kendi bağımlılıklarını bildirebilir: + +```python title="server.py" hl_lines="22 29-30" +--8<-- "docs_src/dependencies/tutorial002.py" +``` + +* `estimate_delivery`, `check_stock`'a bağımlıdır. SDK grafiği sırayla çalıştırır: önce stok, sonra tahmin, sonra araç. +* Hem `stock` hem `delivery` sonuçta `check_stock`'a ihtiyaç duyar, ama o **çağrı başına bir kez** çalışır. Tek bir envanter sorgusu, iki tüketici. +* Kaydedilecek hiçbir şey yok. Grafik, tür açıklamalarının *ta kendisidir*. + +!!! check + Çağrı başına bir kez çalıştığına körü körüne inanmayın. `check_stock`'un içine bir `print` koyun + ve Inspector'dan `order_book`'u çağırın: çağrı başına tek satır. İki tüketici, tek sorgu. + +SDK grafiği araç çağrıldığında değil, kaydedildiğinde analiz eder. Sınıflandıramadığı bir parametre (ne `Context`, ne `Resolve(...)`, ne de bir araç argümanının adı) ve çözümleyiciler arasındaki bir döngü, her ikisi de başlangıçta `InvalidSignature` fırlatır. Sunucu, daha hiçbir istemci bağlanmadan başarısız olur; hataya yol açan parametre veya çözümleyici hata mesajında adıyla belirtilir. + +Bir çözümleyicinin parametreleri tıpkı bir aracınkiler gibi çözümlenir: başka bir `Resolve(...)`, ada göre aracın kendi argümanları veya `Context` (`ctx.headers`, lifespan (yaşam döngüsü) nesnesi, hepsi). + +!!! warning + HTTP aktarımlarında `Context`, `ctx.headers`'ı da içerir. Başlıklar, her araç argümanı gibi + **istemcinin sağladığı girdidir**: bir yerel ayar veya özellik bayrağı için uygundur, kimlik için + asla. Çağıranın kim olduğu, herkesin ayarlayabileceği bir başlıktan değil, yetkilendirme + katmanınızdan gelir (**[Yetkilendirme](../run/authorization.md)**). + +!!! tip + *Çağrı başına bir kez* tam olarak bunu ifade eder: bir sonraki `tools/call`, `check_stock`'u + yeniden çalıştırır. Bir istekten uzun yaşaması gereken bir kaynağın (bir veritabanı havuzu, bir + HTTP istemcisi) yeri **[Lifespan](lifespan.md)** sayfasıdır; bir çözümleyici ona + `ctx.request_context.lifespan_context` üzerinden ulaşabilir. + +## Yalnızca gerektiğinde sormak {#ask-when-you-must} + +Bir çözümleyici yanıtı bilmek zorunda değildir. `Elicit(message, Model)` döndürebilir; SDK da kullanıcıya sorar. Bu, sizin yerinize çalıştırılan **[Elicitation](elicitation.md)** (kullanıcıdan bilgi isteme) mekanizmasıdır: + +```python title="server.py" hl_lines="26-32 39" +--8<-- "docs_src/dependencies/tutorial003.py" +``` + +* Stokta varsa: `confirm_backorder` doğrudan bir `Backorder` döndürür. **Soru yok, gidiş-dönüş yok.** Kullanıcı yalnızca yanıtı önemli olduğunda rahatsız edilir. +* Stokta yoksa: SDK elicitation'ı gönderir, yanıtı `Backorder`'a göre doğrular ve enjekte eder. Çözümleyiciniz protokole hiç dokunmaz. +* Araç, `backorder.confirm`'ü diğer argümanlar gibi okur. **Hayır** yanıtı da bir yanıttır: elicitation `confirm=False` ile kabul edilir, araç çalışır ve sipariş verilmez. Sormak, araç gövdesindeki bir tesisat işi değil, bir ön koşul hâline geldi. + +Peki ya kullanıcı hiç yanıt vermezse, yani soruyu reddeder veya iptal ederse? + +!!! check + `Neuromancer` için `order_book`'u çalıştırın ve soruyu reddedin. Tür açıklaması + `Annotated[Backorder, Resolve(...)]` biçiminde yazıldığında araç gövdesi hiç çalışmaz; çağrı, + modelin okuyabileceği bir hata sonucuyla başarısız olur: + + ```text + Error executing tool order_book: Resolver for parameter 'backorder' could not resolve: elicitation was decline + ``` + +Bir ön koşul için doğru varsayılan budur: yanıt yoksa sipariş de yok. Reddetme, aracınızın ele almak istediği bir sonuçsa (ön siparişi atlayıp yine de başka bir kitap önermek gibi) tür açıklamasını bunun yerine `ElicitationResult[Backorder]` olarak yazın; araç, dallanabileceği tam accept/decline/cancel sonucunu alır. **[Elicitation](elicitation.md)** sayfası bu biçimi ve sormaya dair diğer her şeyi gösterir: şema kuralları, üç yanıt, konuşmanın istemci tarafı. + +!!! info + Framework, sorunun aktarımını anlaşılan protokol sürümüne göre seçer; yukarıdaki kod her + ikisinde de aynıdır. **2026-07-28** ve sonrasında soru, çok turlu (multi-round-trip) bir + `tools/call` içinde taşınır: sunucu soruyu döndürür, istemcinin `elicitation_callback`'i + yanıtlar ve `Client` çağrıyı sizin yerinize yeniden dener + (**[Çok turlu istekler](multi-round-trip.md)**). **2025-11-25** ve öncesinde ise çağrının + ortasında senkron bir elicitation isteğidir. Her soru çağrı başına tam olarak bir kez sorulur; + bu, çözümleyici hakkında değil soru hakkında bir garantidir. Çok turlu biçimde, çağrı bir sorudan + sonra her sürdüğünde herhangi bir çözümleyici yeniden çalışabilir; dolayısıyla + `return Elicit(...)` satırından önceki kod bu turların her birinde çalışır. Kaydedilmiş yanıt, + tekrarlanan soruyu kullanıcıya yeniden sormadan karşılar. Kaydedilmiş bir yanıta yalnızca + çözümleyici soru sorduğunda başvurulur; `check_stock` gibi *sormadan* yanıt veren bir + çözümleyici her zaman kendi hesapladığı değeri sağlar. Her yanıt kendi sorusuyla + eşleştirildiğinden, elicitation yapan bir çözümleyici sorusunu aracın argümanlarından ve önceki + yanıtlardan deterministik olarak türetmelidir. Çağrı başına üretilen bir değer (bir + `default_factory` kimliği, bir zaman damgası) her turda yeniden türetilir ve yanıtın bağlanması + gereken bir soruda yer almamalıdır. Bu tür uçucu verilerden kurulan bir soru, kaydedilmiş her + yanıtı bayat gösterir; bu yüzden sunucu, istemcinin tur sınırı çağrıyı sonlandırana kadar + soruyu her turda yeniden sorar. + +## Kullanıcıya değil, istemciye sormak {#ask-the-client-not-the-user} + +Elicitation, bir çözümleyicinin sorabileceği üç sorudan biridir ve çok turlu akış başkasına izin vermez. Diğer ikisi kullanıcıya değil **istemciye** gider: istemci üzerinden bir LLM çağrısı çalıştırmak için `Sample(...)` (bir `sampling/createMessage` isteği), istemcinin güncel kök dizinlerini (roots) almak için `ListRoots()` döndürün. Hiçbirinin accept/decline sonucu yoktur; tüketici, tür açıklaması olarak doğrudan sonuç türünü yazar: `CreateMessageResult` (istek `tools` veya `tool_choice` taşıdığında `CreateMessageResultWithTools`) ya da `ListRootsResult`: + +```python title="server.py" hl_lines="10-15 21" +--8<-- "docs_src/dependencies/tutorial004.py" +``` + +* Framework bunları tıpkı `Elicit` gibi yönlendirir: **2026-07-28** sürümünde çok turlu `tools/call` içinde, **2025-11-25** sürümünde bağımsız sunucu->istemci isteği üzerinden. Bildirilmemiş bir yetenek, çağrıyı `-32021` protokol hatasıyla reddeder (`sampling`, `roots`, form kipinde `elicitation`; istek `tools` veya `tool_choice` taşıdığında `sampling.tools`). +* Yukarıdaki bilgi kutusunun sorular hakkında söylediği her şey aynen geçerlidir: bir `Sample` isteği, kaydedilmiş sonucuyla birebir gösterimine göre eşleştirilir; bu yüzden onu aracın argümanlarından ve önceki yanıtlardan deterministik olarak kurun. Böylece istemci, LLM çağrısının bedelini tur başına değil araç çağrısı başına bir kez öder. Kaydedilmiş sonuç çağrının geri kalanında `request_state` içinde taşınır; bu nedenle çok büyük bir tamamlama, kalan her gidiş-dönüşü ağırlaştırır. +* Bağımsız örnekleme (sampling) ve kök dizinler *özellikleri* 2026-07-28 itibarıyla kullanım dışı bırakıldı (SEP-2577). İstemcinin modeline ihtiyaç duyan yeni sunucular bu taşıyıcı üzerinden sorar; duymayanlar doğrudan bir LLM sağlayıcısıyla entegre olmalıdır. `"none"` dışındaki `include_context` değerlerinin kendisi de kullanım dışıdır; bunları kullanmayın. + +## Özet {#recap} + +* Bir araç parametresinde `Annotated[T, Resolve(fn)]`: SDK `fn`'i çalıştırır ve dönüş değerini enjekte eder. +* Çözümlenmiş bir parametre modele görünmez; istemci de onu sağlayamaz. Modelin uydurmaması gereken değerlerin (fiyatlar, kimlikler, izinler) yeri burasıdır. +* Bir çözümleyicinin parametreleri de aynı şekilde çözümlenir: `Context`, başka bir `Resolve(...)` veya ada göre bir araç argümanı. Grafik, kaç tüketicisi olursa olsun her çözümleyiciyi tur başına en fazla bir kez çalıştırır; her soru tam olarak bir kez sorulur ve çağrı bir sorudan sonra sürdüğünde herhangi bir çözümleyici yeniden çalışabilir. +* Hatalı grafikler çağrı ortasında değil, kayıt sırasında `InvalidSignature` ile başarısız olur. +* Kullanıcıya sormak için, yalnızca mecbur kaldığınızda, `Elicit(message, Model)` döndürün. Sarmalanmamış tür açıklamaları reddedildiğinde çağrıyı iptal eder; `ElicitationResult[T]` aracın dallanmasına izin verir. +* İstemciden bir LLM tamamlaması veya kök dizin listesi istemek için `Sample(...)` ya da `ListRoots()` döndürün; yalın sonuç enjekte edilir. + +Sunucunuzun başlangıçta bir kez kurduğu durum ve bir işleyicinin ona nasıl ulaştığı **[Lifespan](lifespan.md)** sayfasının konusudur. diff --git a/i18n/tr/pages/handlers/elicitation.md b/i18n/tr/pages/handlers/elicitation.md new file mode 100644 index 0000000000..9202175190 --- /dev/null +++ b/i18n/tr/pages/handlers/elicitation.md @@ -0,0 +1,191 @@ +--- +translation: + sections: [335ca2a0b266f003, d1ad562d3fe87bc0, 0bb1396c86daeba4, d1cb1235bb9ee267, 833179c09d239c83, e5d6dec2d2e655e8] + tool: 1 +--- +# Elicitation {#elicitation} + +İşinin yarısına gelmiş ve tek bir yanıtı eksik olan bir aracın başarısız olması gerekmez. + +**Elicitation** (kullanıcıdan bilgi isteme) onun sormasını sağlar. Araç çağrısının ortasında kullanıcıya bir soru gelir ve verdiği yanıt aynı fonksiyon çağrısına geri döner. + +İki mod var: + +* **Form modu**: bir değere ihtiyacınız vardır (bir onay, bir tarih, bir miktar). Alanları siz tanımlarsınız, formu istemci çizer. +* **URL modu**: kullanıcının başka bir yere gitmesi gerekir (bir OAuth onay ekranı, bir ödeme sayfası). Orada yaptığı hiçbir şey protokolden geçmez. + +Sormanın da iki yolu var. İlk başvurmanız gereken bir **çözümleyicidir**: soruyu bir parametreye asarsınız ve SDK sorar; hangi bağlantı olursa olsun, istemci hangi protokol neslini konuşursa konuşsun. Doğrudan yol olan `await ctx.elicit(...)`, *sunucudan* *istemciye* giden bir istektir; bu kanal yalnızca eski nesil bir bağlantıdaki (spesifikasyon sürümü 2025-11-25 veya öncesi) istemciler için vardır. İkisi de bu sayfada; çözümleyiciyle başlayın. + +## Çözümleyiciyle sorma {#ask-with-a-resolver} + +Aracın tamamının önünde duran bir soru (*emin misiniz? eşleşen üç hesaptan hangisi?*) araç gövdesinden çıkarılıp bir **çözümleyiciye** taşınabilir; soruyu sizin yerinize framework sorar. + +`Annotated[T, Resolve(fn)]` ile işaretlenmiş bir parametre, araç gövdesinden önce `fn` çalıştırılarak doldurulur. Çözümleyici değeri zaten biliyorsa doğrudan döndürür; framework'ün sormasını istiyorsa `Elicit(...)` döndürür: + +```python title="server.py" hl_lines="24-30 35-36" +--8<-- "docs_src/elicitation/tutorial004.py" +``` + +* `confirm_delete`, aracın kendi `path` argümanını adıyla okur, klasörü listeler ve **yalnızca gerektiğinde sorar**: boş bir klasör, istemciye hiç gidip dönmeden `Confirm(ok=True)` olarak çözümlenir. +* `delete_folder`, `ElicitationResult[Confirm]` tür ipucunu kullanır; bu yüzden framework sonucun tamamını enjekte eder ve araç her durumu `match` ile ele alır: kabul edip onaylama, kabul edip tutma (`ok=False`), reddetme, iptal. +* `confirm` parametresi aracın girdi şemasında hiç görünmez: `path`'i istemci sağlar, `confirm`'ü çözümleyici. + +Aracın dallanması gerekmiyorsa bunun yerine sarmalanmamış modeli işaretleyin (`Annotated[Confirm, Resolve(confirm_delete)]`): kabulde modeli alır, ret veya iptalde ise çağrı bir hatayla sonlanır. + +Çözümleyici **her** bağlantıda çalışır. Eski nesil bağlantıdaki bir istemciye SDK soruyu doğrudan gönderir; **2026-07-28** bağlantısında ise SDK soruyu çağrıdan *döndürür* ve istemcinin bir sonraki denemesi yanıtı taşır. Çözümleyiciniz aradaki farkı hiçbir zaman bilmez; arka planda olan biten **[Çok turlu istekler](multi-round-trip.md)** (multi-round-trip) sayfasında. + +Sormak, bir çözümleyicinin yapabileceklerinden yalnızca biri. Genel mekanizma (sormadan hesaplayan bağımlılıklar, bağımlılıkların bağımlılıkları, modelin neyi sağlayıp neyi sağlayamayacağı) **[Bağımlılıklar](dependencies.md)** sayfasında. + +## Aracın içinden sorma {#ask-from-inside-the-tool} + +Bir araç kendi gövdesinin ortasında durup da sorabilir. + +!!! warning + `ctx.elicit()` ve `ctx.elicit_url()`, *sunucudan* *istemciye* giden isteklerdir; bu kanal + yalnızca eski nesil bir bağlantıdaki (spesifikasyon sürümü **2025-11-25** veya öncesi) + istemciler için vardır. **2026-07-28** bağlantısında sunucunun başlattığı istek yoktur, + bu yüzden bu çağrılar başarısız olur. Çözümleyici ikisinde de çalışır. Ayrıntıların tamamı + **[Protokol sürümleri](../protocol-versions.md)** sayfasında. + +`await ctx.elicit()` bir mesaj ve bir Pydantic modeli alır: + +```python title="server.py" hl_lines="9-11 20-23 25" +--8<-- "docs_src/elicitation/tutorial001.py" +``` + +* Size `ctx.elicit`'i veren **`Context`** parametresidir; her araç bir tane alabilir. Bu nesnenin kendi sayfası var: **[Context nesnesi](context.md)**. +* `AlternativeDate`, istediğiniz yanıtın **şemasıdır**. +* Araç `async def`. Öyle olmak zorunda: ortada durup bir insanı bekler. +* Başka herhangi bir tarihte araç hemen döner. Yalnızca mecbur kaldığında sorar. +* Kullanıcının kabul ettiği tarih yine `book_table`'ın kendisinden geçer. Yanıt da diğerleri gibi bir girdidir: kendisi de tamamen dolu olan bir alternatif körlemesine onaylanmaz, yeniden sorulur. + +### İstemcinin aldığı {#what-the-client-receives} + +İstemci mesajınızı ve yanında modelden üretilmiş bir JSON Schema alır: + +```json +{ + "properties": { + "accept_alternative": { + "description": "Try another date?", + "title": "Accept Alternative", + "type": "boolean" + }, + "date": { + "default": "2025-12-26", + "description": "Alternative date (YYYY-MM-DD)", + "title": "Date", + "type": "string" + } + }, + "required": ["accept_alternative"], + "title": "AlternativeDate", + "type": "object" +} +``` + +Bu şema formun kendisidir. `Field(description=...)` etikettir; bir varsayılan değer girdiyi önceden doldurur ve alanı isteğe bağlı yapar. Bu, **[Araçlar](../servers/tools.md)** sayfasının bir aracın argümanları için anlattığı Pydantic'ten JSON Schema'ya dönüşüm mekanizmasının aynısıdır. + +!!! warning + Bir elicitation şeması, bir aracın girdi şeması kadar ifade gücüne sahip değildir. Yalnızca + düz, ilkel alanlar: `str`, `int`, `float`, `bool` veya dizelerden oluşan bir `Literal` + (bir `enum`'a dönüşür). Modelin içine bir model koyarsanız `ctx.elicit`, istemciye hiçbir + şey gönderilmeden önce istisna fırlatır: + + ```text + TypeError: Elicitation schema field 'address' rendered as {'$ref': '#/$defs/Address'}, which is not a valid PrimitiveSchemaDefinition + ``` + + Bir insanı işinin ortasında bölüyorsunuz. Yanıt iç içe yapı gerektiriyorsa, o zaten + aracın bir argümanı olmalıydı. + +### Üç yanıt {#the-three-answers} + +`result.action` kullanıcının ne yaptığını söyler ve tam olarak üç olasılık vardır: + +* `"accept"`: formu gönderdi. `result.data`, zaten doğrulanmış bir `AlternativeDate` örneğidir. +* `"decline"`: hayır dedi. +* `"cancel"`: seçim yapmadan soruyu kapattı. + +`result.data` yalnızca `"accept"` durumunda vardır; örneğin önce `result.action`'ı denetlemesinin nedeni budur. Tür denetleyiciniz bu sırayı zorunlu kılar: `result.action == "accept"` sonrasında `result.data` bir `AlternativeDate`'tir; öncesinde `.data` diye bir şey hiç yoktur. + +Ret bir hata değildir. Reddetmenin ne anlama geldiğine araç karar verir (burada: rezervasyon yok) ve modele normal şekilde yanıt verir. + +!!! tip + Yanıt, kodunuz görmeden önce modelinize göre doğrulanır. Bir `bool` için `"maybe"` gönderen + bir istemci rezervasyonunuzu bozmaz: çağrı bir şema uyuşmazlığı hatasıyla başarısız olur, + `if`'iniz hiç çalışmaz. + +## Kullanıcıyı bir URL'ye gönderme {#send-the-user-to-a-url} + +Bazı şeyler modelden veya istemciden geçmemelidir: kimlik bilgileri, kart numaraları, OAuth onayı. Bunlar için veri istemezsiniz; kullanıcıdan bir yere gitmesini istersiniz: + +```python title="server.py" hl_lines="10-14 23" +--8<-- "docs_src/elicitation/tutorial002.py" +``` + +* `ctx.elicit_url()`; mesajı, ziyaret edilecek **URL**'yi ve sizin seçtiğiniz bir `elicitation_id`'yi alır: sunucunuz içinde bu elicitation'ı tanımlayan herhangi bir dize. +* Sonuçta bir eylem vardır, başka hiçbir şey yoktur. `"accept"`, kullanıcının URL'yi açmayı kabul ettiği anlamına gelir; öbür taraftaki işi bitirdiği anlamına **gelmez**. +* Ödeme bant dışında, kullanıcının tarayıcısı ile ödeme sağlayıcınız arasında gerçekleşir. MCP üzerinden hiçbir içerik geri gelmez. + +İkinci araca bakın. Sunucunuz bant dışı akışın bittiğini öğrendiğinde (bir webhook, bir yoklama; burada ikinci bir araç olarak modellenmiş), `ctx.session.send_elicit_complete(...)` aynı `elicitation_id` ile `notifications/elicitation/complete` gönderir. İstemci, *"ödeme bekleniyor..."* göstermeyi bırakabileceğini böyle anlar. Bu olmadan istemci yalnızca tahmin yürütebilir. + +## İstemci tarafı {#the-client-side} + +Sunucular sorar. İstemciler `Client(...)`'a bir **`elicitation_callback`** geçirerek yanıtlar: + +```python title="client.py" hl_lines="6-7 18" +--8<-- "docs_src/elicitation/tutorial003.py" +``` + +* Tek bir callback iki modu da ele alır. `params`, `ElicitRequestFormParams` ile `ElicitRequestURLParams`'ın bir birleşimidir; dallanma `isinstance` ile yapılır. +* URL için `params.url`'yi kullanıcıya gösterir ve seçtiği eylemi döndürürsünüz. Asla `content` yok. +* Form için gerçek bir uygulama `params.requested_schema`'yı çizer ve kullanıcının girdisini `content` olarak döndürür. Buradaki ise hazır bir yanıtla her zaman evet der; bir testte tam da istediğiniz callback budur. +* Callback'i geçirmek aynı zamanda **yetenek bildirimidir**: sunucu bu istemciye soru sorulabileceğini böyle öğrenir. Bir istemcinin sunucu adına yanıtlayabileceği diğer şeyler **[İstemci callback'leri](../client/callbacks.md)** sayfasında. + +!!! info + Elicitation *sunucudan* *istemciye* giden bir istektir ve bunlar yalnızca klasik + el sıkışmalı bir oturumda vardır; bu istemcinin `mode="legacy"` geçirmesinin nedeni budur. + **2026-07-28** bağlantısında bir araç bunun yerine soruyu çağrıdan *döndürerek* sorar; + o akış **[Çok turlu istekler](multi-round-trip.md)** sayfasında. + +### Deneyin {#try-it} + +Form modundaki `ctx.elicit` kullanan `server.py` dosyasını (`book_table` olanı) Streamable HTTP üzerinde başlatın (tek satırlık komut **[Sunucunuzu çalıştırma](../run/index.md)** sayfasında), ardından istemcinin `main()` fonksiyonunu çalıştırın ve `book_table`'dan Noel günü için rezervasyon isteyin. + +Callback kendisine gönderilen soruyu yazdırır: + +```text +No tables for 2 on 2025-12-25. Would you like to try another date? +``` + +`{"accept_alternative": True, "date": "2025-12-27"}` ile yanıt verir ve bunca zamandır `await ctx.elicit(...)` içinde bekleyen araç rezervasyonu tamamlar: + +```text +Booked a table for 2 on 2025-12-27. +``` + +Şimdi URL modundaki `server.py` dosyasına geçin ve aynı `main()`'i `pay_deposit`'e yöneltin: aynı callback diğer dala girer, ödeme bağlantısını yazdırır ve araç *"Complete the payment in your browser."* ile geri döner. Çağrının ortasında, iki yönde de tek bir tur. + +!!! check + Şimdi `Client`'tan `elicitation_callback=` parametresini kaldırın ve `book_table`'ı Noel günü + için yeniden çağırın. Çağrının tamamı bir protokol hatasıyla başarısız olur: + + ```text + Elicitation not supported + ``` + + Hiç callback kaydetmemiş bir istemci `elicitation` yeteneğini hiç bildirmemiştir, dolayısıyla + soracak kimse yoktur. Aracınız `"decline"` almadı; bir istisna aldı. Buna göre tasarlayın: + her elicitation'ın "ya soramazsam?" sorusuna mantıklı bir yanıtı olmalı. + +## Özet {#recap} + +* `Annotated[T, Resolve(fn)]` ile işaretlenmiş bir parametreyi bir çözümleyici doldurur; çözümleyici sorması gerektiğinde `Elicit(...)` döndürür. Her bağlantıda çalışır. +* Şema düz bir Pydantic modelidir: yalnızca ilkel alanlar, dönüşte doğrulanır. +* `result.action`; `"accept"`, `"decline"` veya `"cancel"` olur; `result.data` yalnızca kabulde vardır. +* `await ctx.elicit(message, schema=Model)` araç gövdesinin içinden sorar; `await ctx.elicit_url(message, url, elicitation_id)` ise modelden geçmemesi gereken her şey içindir (`ctx.session.send_elicit_complete(elicitation_id)` bant dışı kısmın bittiğini söyler). İkisi de sunucudan istemciye giden isteklerdir: istemcinin eski nesil bir bağlantıda olmasını gerektirirler. +* İstemci, params türüne göre dallanan tek bir `elicitation_callback` ile yanıtlar; yeteneği bildiren şey onu kaydetmektir. +* 2026-07-28 bağlantısında sunucu soruyu itmek yerine döndürür; aynı callback'i **[Çok turlu istekler](multi-round-trip.md)** besler. + +O dönüşün altında yatan her şey (yeniden deneme döngüsü, `requestState`'i koruma, akışı kendiniz yürütme) **[Çok turlu istekler](multi-round-trip.md)** sayfasında. diff --git a/i18n/tr/pages/handlers/index.md b/i18n/tr/pages/handlers/index.md new file mode 100644 index 0000000000..5922e7b7d4 --- /dev/null +++ b/i18n/tr/pages/handlers/index.md @@ -0,0 +1,37 @@ +--- +translation: + sections: [424930166c4bc6f3] + tool: 1 +--- +# İşleyicinin içinde {#inside-your-handler} + +Bir işleyicinin argümanları istemciden gelir. Okuyabildiği *diğer* her şey ve +çalışırken yapabildiği her şey burada. + +Okuyabildikleri: + +* **[Context nesnesi](context.md)**, herhangi bir işleyicinin isteyebileceği + tek ek parametredir: canlı istek, başlıkları, oturumu, ayrıca ilerleme ve + değişiklik bildirimi eylemleri. +* **[Bağımlılıklar](dependencies.md)**, modelin hiç görmediği + parametrelerdir; değerlerini `Resolve` ile kendi fonksiyonlarınız doldurur. +* **[Lifespan](lifespan.md)** (yaşam döngüsü), sunucunun başlangıçta bir kez + oluşturduğu durumu ve bir işleyicinin bu duruma `Context` üzerinden nasıl + ulaştığını ele alır. + +Çalışırken yapabildikleri: + +* **[Elicitation](elicitation.md)** (kullanıcıdan bilgi isteme) ve onu taşıyan + 2026-07-28 deseni olan **[Çok turlu istekler](multi-round-trip.md)** + (multi-round-trip) ile kullanıcıdan ek girdi istemek. +* Kullanım dışı bırakılmış ama hâlâ sunulan + **[Örnekleme (sampling) ve kök dizinler (roots)](sampling-and-roots.md)** + ile istemciden bir LLM tamamlaması ya da çalışma alanı klasörlerini istemek. +* Yavaş bir işte **[İlerleme](progress.md)** bildirmek. +* **[Log tutma](logging.md)** ile log yazmak (sunucuyu kim işletiyorsa onun + için, standart hataya). +* **[Abonelikler](subscriptions.md)** ile abone olmuş istemcilere bir şeyin + değiştiğini bildirmek. + +Henüz bir işleyici kaydetmediyseniz **[Araçlar](../servers/tools.md)** +sayfasıyla başlayın. Buradaki her sayfa bir işleyiciniz olduğunu varsayar. diff --git a/i18n/tr/pages/handlers/lifespan.md b/i18n/tr/pages/handlers/lifespan.md new file mode 100644 index 0000000000..4287eaa04d --- /dev/null +++ b/i18n/tr/pages/handlers/lifespan.md @@ -0,0 +1,107 @@ +--- +translation: + sections: [f3ca8ac5f90f2dfa, 85a1ef3588ba0736, 563346d4d5804933, 9e3528340d0bab53] + tool: 1 +--- +# Lifespan {#lifespan} + +Gerçek sunucuların çoğu, ömürleri boyunca bir şeyi elde tutar: bir veritabanı havuzu, bir HTTP istemcisi, yüklenmiş bir model. + +Bunu her çağrıda yeniden kurmak istemezsiniz, ama düzgünce kapatmak istersiniz. İşte **lifespan** (yaşam döngüsü) bunun için var. + +## Türü belirli bir lifespan {#a-typed-lifespan} + +Lifespan, sunucuyu alan ve **tek bir nesne** `yield` eden bir `@asynccontextmanager`'dır. Yield ettiğiniz şey, sunucu çalıştığı sürece her işleyicinin erişimindedir. + +```python title="server.py" hl_lines="25-31 34 38 40" +--8<-- "docs_src/lifespan/tutorial001.py" +``` + +Aşağıdan yukarıya okuyun: + +* `app_lifespan`, `Database`'i `yield`'den **önce** bağlar, **sonra** da bir `finally` içinde bağlantısını keser. İşte başlatma ve kapatma. +* Bir `AppContext` yield eder: kurduğunuz şeyleri tutan düz bir dataclass. Bugün bir alan, yarın on. +* Bağlamanın tamamı `MCPServer("Bookshop", lifespan=app_lifespan)` satırından ibaret. +* Aracın içinde, yield edilen nesne `ctx.request_context.lifespan_context`'tir. + +Lifespan **bir kez** çalışır. Sunucu başladığında (ilk istekten önce) içine girilir, sunucu durduğunda içinden çıkılır. Aradaki her istek aynı `AppContext`'i paylaşır. + +!!! info + Daha önce bir FastAPI `lifespan`'i yazdıysanız bunu zaten biliyorsunuz. Aynı dekoratör, aynı `yield`, aynı `finally`. + +### Modelin gördüğü {#what-the-model-sees} + +Yeni bir şey yok. `ctx` bir **Context** parametresidir; bu yüzden SDK onu enjekte eder ve girdi şemasına hiç ulaşmaz: + +```json +{ + "type": "object", + "properties": { + "genre": {"title": "Genre", "type": "string"} + }, + "required": ["genre"], + "title": "count_booksArguments" +} +``` + +Modelin geçirebileceği tek argüman `genre`. Lifespan sunucunuzun kendi işidir. + +`@mcp.resource()` ve `@mcp.prompt()` fonksiyonları da `ctx` parametresi alabilir; bir sonraki bölümün açıklayacağı bir nedenle bu parametre yalın `Context` olarak yazılır. `ctx`'in taşıdığı her şey **[Context nesnesi](context.md)** sayfasında. + +### Gerçekten türü belirli {#it-really-is-typed} + +Tür açıklamasına bir daha bakın: `ctx: Context[AppContext]`. + +Tür denetleyiciniz için `ctx.request_context.lifespan_context`'in bir `AppContext` **olmasını** sağlayan işte bu tek tür parametresidir. `.db` otomatik tamamlanır; `.dbb` ise daha sunucuyu çalıştırmadan hata verir. + +Bunun yerine yalın `Context` yazarsanız `lifespan_context`'in türü `dict[str, Any]` olur: tür denetleyicisinin, lifespan'inizin ne yield ettiğini bilmesinin yolu yoktur. Nesne çalışma zamanında yine oradadır; yalnızca yardımı kaybedersiniz. + +!!! warning + `Context[AppContext]` **yalnızca araçlara özgü** bir yazımdır. Bunu bir `@mcp.resource()` ya da + `@mcp.prompt()` fonksiyonuna koyarsanız o işleyiciye yapılan her çağrı başarısız olur. İstemciye bir hata döner, + sunucu log'u da nedenini gösterir: + + ```text + Context is not available outside of a request + ``` + + Kaynaklarda ve prompt'larda yalın `ctx: Context` yazın. Lifespan'inizin yield ettiği nesne + çalışma zamanında yine `ctx.request_context.lifespan_context`'tir; vazgeçtiğiniz şey nesne değil, + tür parametresidir. + +!!! tip + Her zaman bir lifespan vardır. Siz bir tane geçirmezseniz SDK'nın varsayılanı boş bir `dict` yield eder; + dolayısıyla `ctx.request_context.lifespan_context` `{}` olur, asla `None` değil. Yalın `Context`'in + onu `dict[str, Any]` olarak türlendirmesinin nedeni de bu varsayılandır. + +## İşleyişi gözlemleme {#watch-it-happen} + +"Başlatma ilk istekten önce çalışır" cümlesi, körü körüne inanmak zorunda kalmamanız gereken türden bir cümle. + +Sunucuyu yaşam döngüsüne kadar sadeleştirin: `Database`'e bir `connected` bayrağı verin, `connect()` ve `disconnect()` içinde değiştirin ve onu bildiren bir araç ekleyin. + +```python title="server.py" hl_lines="11 14 17 25 44" +--8<-- "docs_src/lifespan/tutorial002.py" +``` + +`database`'in modül düzeyinde durmasının tek bir nedeni var: ona sunucunun *dışından* bakabilmeniz. + +!!! check + Üç an, üç değer: + + * Sunucu başlamadan önce `database.connected` `False`'tur. Modülü içe aktarmak hiçbir şeyi bağlamadı. + * Çalışırken `database_status` aracını çağırın; sonuç `"connected"` olur. + * Sunucuyu durdurun, `finally` bloğu çalışır: `database.connected` yeniden `False` olur. + + İş tam olarak koyduğunuz yerde yapıldı: `yield`'in etrafında; ne içe aktarma sırasında ne de istek başına. + +## Özet {#recap} + +* `lifespan=`, sunucuyu alan ve tek bir nesne `yield` eden bir `@asynccontextmanager` alır. +* `yield`'den önceki kod başlatmadır. Sonrasındaki `finally` kapatmadır. +* İstek başına değil, sunucunun tüm ömrü boyunca bir kez çalışır. +* `yield` ettiğiniz şey her araçta, kaynakta ve prompt'ta `ctx.request_context.lifespan_context` olur. +* `ctx: Context[AppContext]` bu erişimi araçlarda tam tür bilgisiyle donatır. Kaynaklar ve prompt'lar yalın `Context` alır. +* `lifespan=` yoksa boş bir `dict` gelir, asla `None` değil. + +Çağrının ortasında durup kullanıcıya yalnızca onun bildiği bir şeyi soran işleyici, **[Elicitation](elicitation.md)** (kullanıcıdan bilgi isteme) sayfasının konusu. diff --git a/i18n/tr/pages/handlers/logging.md b/i18n/tr/pages/handlers/logging.md new file mode 100644 index 0000000000..55cd045cc0 --- /dev/null +++ b/i18n/tr/pages/handlers/logging.md @@ -0,0 +1,87 @@ +--- +translation: + sections: [c93a3e1aefd77955, 7851abd5ec54393b, f49d1ca2f330f9cd, c03764bd9dfeef7b, 4a0391691a674ae4, 2df5cd279eabf9f5] + tool: 1 +--- +# Log tutma {#logging} + +Bir araçtan log yazmak, başka herhangi bir Python fonksiyonundan log yazmaktan farksızdır: standart kütüphaneyle. + +MCP'de protokol düzeyinde bir **logging yeteneği** vardır: bir sunucu, `Context` nesnesindeki metotlar aracılığıyla log mesajlarını istemciye bildirim olarak gönderebilir. Spesifikasyonun 2026-07-28 sürümü **bu yeteneği kullanım dışı bırakır ve yerine bir şey koymaz**; bu yüzden bu belgeler onu öğretmez. Nelerin kullanım dışı bırakıldığının ve bunların yerine ne yapılacağının tam listesi **[Kullanım dışı özellikler](../deprecated.md)** sayfasında. + +Bunun yerine yapacağınız şey, diğer her Python programında yaptığınızdır: standart kütüphane. + +## Log yazan bir araç {#a-tool-that-logs} + +```python title="server.py" hl_lines="1 5 13" +--8<-- "docs_src/logging/tutorial001.py" +``` + +* `logging.getLogger(__name__)` size modülünüzün adını taşıyan bir logger verir. Onu bir kez, en üstte oluşturun. +* Aracın içinde, başka herhangi bir fonksiyonda olduğu gibi `logger.info(...)`'yu çağırırsınız. Enjekte edilecek bir şey yok, `await` edilecek bir şey yok, MCP'ye özgü bir şey yok. + +!!! check + Aracı çağırın ve sonucun tamamına bakın: + + ```python + result.content # [TextContent(text="Found 3 books matching 'dune'.")] + result.structured_content # {'result': "Found 3 books matching 'dune'."} + ``` + + Log satırı bunun hiçbir yerinde yok. Log tutma **sizin** içindir; sunucuyu işleten kişi için. Model + onu asla görmez. Modelin okuması gereken bir şey varsa onu `return` edin. + +## Nereye gider {#where-it-goes} + +Bir **stdio** sunucusu için bu soru her zamankinden daha önemlidir. Host, sunucunuzu bir alt süreç olarak başlattı ve MCP mesajlarını sunucunun **stdout** akışından okuyor. Standart hata sizindir. + +Standart kütüphane zaten doğru olanı yapar: log çıktısı varsayılan olarak `sys.stderr`'e gider. `logger.info(...)` satırlarınız terminale (ya da host alt sürecin stderr'ini nereye topluyorsa oraya) düşer ve protokol akışı temiz kalır. + +!!! tip + Bir stdio sunucusunda `print()` kullanmayın. `print`, **stdout**'a yazar ve stdout protokole aittir. + SDK, hizmet verirken gerçekten *flush edilen* stdout çıktısını stderr'e yönlendirir; bu yüzden + iletilen veriyi bozamaz. Ancak blok tamponlamalı bir süreçte `print()` çıktısı genellikle flush + edilmeden `sys.stdout`'un tamponunda bekler; yorumlayıcı çıkışta tamponu boşaltınca da doğrudan + protokol akışına dökülür. Yönlendirildiğinde bile satır, log çıktısının arasına ham hâlde düşer: + düzeyi yoktur, logger adı yoktur, onu filtrelemenin bir yolu yoktur. + + `logger.debug("got here")` de aynı tek satırlık çabadır ve doğru yere gider. + +## Düzey {#the-level} + +`logging.basicConfig()`'i kendiniz çağırmanız gerekmez. Bir `MCPServer` oluşturmak bunu zaten yaptı: standart hataya yönlendirilmiş bir işleyiciyle, `log_level=` olarak geçirdiğiniz düzeyde. Yani `logger.debug(...)` satırlarınızı görmek için `MCPServer("Bookshop", log_level="DEBUG")` yeterlidir. + +Varsayılan değer `"INFO"`. + +`logging.basicConfig()` hâlihazırda var olan işleyicileri asla değiştirmez. Sunucuyu oluşturmadan önce log yapılandırmasını kendiniz yaparsanız sizin yapılandırmanız geçerli olur. + +## Deneyin {#try-it} + +Sunucuyu MCP Inspector ile çalıştırın: + +```console +uv run mcp dev server.py +``` + +**Tools** sekmesinden `search_books`'u çağırın. Inspector size sonucu gösterir: yalnızca dönüş değeri. Şu satır + +```text +Searching for 'dune' +``` + +standart hataya gitti: terminale, iletilen veriye değil. + +!!! info + Asıl istediğiniz *izleme* (tracing) ise (her istek, ne kadar sürdüğü, başarısız olup olmadığı), + log satırları değil span'ler istersiniz. Sunucunuz bunları zaten üretir: SDK varsayılan olarak her + mesajı OpenTelemetry ile izler. **[OpenTelemetry](../run/opentelemetry.md)** sayfasına bakın. + +## Özet {#recap} + +* MCP protokolünün logging yeteneği 2026-07-28 spesifikasyonuyla kullanım dışı bırakıldı ve yerine bir şey konmadı. Üzerine bir şey inşa etmeyin. +* Modül düzeyinde `logger = logging.getLogger(__name__)`, aracın içinde `logger.info(...)`. Kalıbın tamamı bu. +* Log çıktısı modele asla ulaşmaz. Yalnızca `return` ettiğiniz değer ulaşır. +* Standart hata sizindir; stdout protokole aittir. SDK hizmet verirken flush edilmiş başıboş stdout çıktısını stderr'e yönlendirir, ancak flush edilmemiş bir `print()` yine de çıkışta iletilen veriye dökülebilir ve yönlendirilen satırlar etiketsiz gelir; her kaydı flush eden bir işleyicisi olan `logging`'i kullanın. +* `MCPServer(..., log_level="DEBUG")` düzeyi ayarlar; önceden yaptığınız bir log yapılandırmasına ise dokunulmaz. + +Bağlı istemcilere sunucunuzda bir şeyin (araç listesi, bir kaynak) değiştiğini bildirmek **[Abonelikler](subscriptions.md)** sayfasının konusu. diff --git a/i18n/tr/pages/handlers/multi-round-trip.md b/i18n/tr/pages/handlers/multi-round-trip.md new file mode 100644 index 0000000000..e4fe64c15c --- /dev/null +++ b/i18n/tr/pages/handlers/multi-round-trip.md @@ -0,0 +1,193 @@ +--- +translation: + sections: [74011e683045eea9, 9b64cc175c18b6a9, 4b41be4824030397, e3b1502da786ec33, 71e41161f143c6a9, 9ec2c1eeb8c36378, 8dd027377d46448b, f81491125dcbfe8b] + tool: 1 +--- +# Çok turlu istekler {#multi-round-trip-requests} + +Bazen bir araç işini tek turda bitiremez. Yalnızca kullanıcıda olan bir şeye ihtiyaç duyar: bir seçim, bir onay, bir kimlik bilgisi. + +2026-07-28 öncesinde sunucu bunu **geri** çağırarak elde ederdi: asıl isteği işlemenin tam ortasında istemciye kendi isteğini, örneğin bir elicitation (kullanıcıdan bilgi isteme) ya da bir örnekleme (sampling) çağrısını açardı. 2026-07-28 spesifikasyonu bu geri kanalı (back-channel) emekliye ayırıyor. + +Bunun yerine sunucu **döndürür**. + +## Geri çağırmak yerine döndürme {#return-dont-call-back} + +Sunucu `tools/call` isteğini `CallToolResult` yerine bir **`InputRequiredResult`** ile yanıtlar. İşi iki alanı yapar: + +* **`input_requests`**: sunucunun hâlâ ihtiyaç duyduğu şeyler; anahtarları sunucunun seçtiği adlar olan bir dict. Her değer bir `ElicitRequest`, bir `CreateMessageRequest` ya da bir `ListRootsRequest` olur. +* **`request_state`**: opak bir token. İstemci yeniden denemede onu olduğu gibi geri yollar. Onu okuyan tek şey sunucunuzdur. + +İstemci her isteği karşılar, ardından yanıtlarını `input_responses` içinde, token'ı da `request_state` içinde taşıyarak **aynı aracı yeniden** çağırır. Sunucunun eksiği artık tamamdır ve normal bir `CallToolResult` döndürür. + +Protokolün tamamı bu. Her adım istemciden sunucuya giden sıradan bir istektir. Hiçbir şey ters yönde akmaz. + +## Sunucu tarafı {#the-server-side} + +`@mcp.tool()` üzerinde bunu elle kurmanız nadiren gerekir: kullanıcıya soran (`Elicit`), istemcinin LLM'inden örnekleme yapan (`Sample`) veya kök dizinlerini (roots) listeleyen (`ListRoots`) bir bağımlılık bildirin; SDK `InputRequiredResult`'ı sizin yerinize döndürür. Bu biçim **[Bağımlılıklar](dependencies.md)** sayfasının konusudur. İki biçim bir arada kullanılamaz: bir çağrının tek bir `input_responses`/`request_state` kanalı vardır, bu yüzden `Resolve(...)` parametreleri kullanan bir araç gövdesinden ayrıca `InputRequiredResult` döndüremez. Bildirilmiş bir `InputRequiredResult` dönüş türü kayıt sırasında reddedilir (`InvalidSignature`); bildirilmemiş olanı ise çağrıyı çalışma zamanında başarısız kılar. Elle kurulan biçim **düşük seviyeli** `Server`'dır; onun `on_call_tool` işleyicisi iki sonuç türünden herhangi birini döndürebilir: + +```python title="server.py" hl_lines="43-46" +--8<-- "docs_src/mrtr/tutorial001.py" +``` + +* `on_call_tool`'un tür ipucu `-> CallToolResult | InputRequiredResult` şeklindedir. İkincisini döndürmek sunucu tarafı API'sinin tamamıdır. +* İlk çağrıda `params.input_responses` değeri `None`'dır; bu yüzden koruma koşulu devreye girer ve işleyici yanıtlamak yerine sorar. +* Yeniden denemede, istemcinin gönderdiği `ElicitResult`, sunucunun `input_requests` içinde kullandığı **aynı anahtarın** (`"region"`) altında durur. + +O dosyadaki geri kalan her şey (açık `input_schema`, elle kurulan `CallToolResult`) sıradan düşük seviyeli `Server`'dır ve **[Düşük seviyeli Server](../advanced/low-level-server.md)** sayfasında anlatılır. Bu sayfa yalnızca ikinci dönüş türünü ekler. + +## Araçların ötesi {#beyond-tools} + +`tools/call` özel değildir: 2026-07-28 sürümünde bir sunucu `prompts/get` ve `resources/read` isteklerini de aynı şekilde yanıtlayabilir. `MCPServer` üzerinde bir `@mcp.prompt()` fonksiyonu (ya da bir `@mcp.resource()` **şablon** fonksiyonu) `InputRequiredResult`'ı kendisi döndürür ve yeniden denemenin yanıtlarını bağlamdan okur: + +```python title="server.py" hl_lines="20 22 24" +--8<-- "docs_src/mrtr/tutorial004.py" +``` + +* İlk tur `InputRequiredResult`'ı döndürür. Yeniden denemede `ctx.input_responses` yanıtları aynı anahtarlar altında tutar ve fonksiyon olağan sonucunu döndürür: burada prompt mesajları, bir şablon kaynak için kaynak içeriği. +* Ayarladığınız bir `request_state`, sunucudaki diğer her şey gibi, ağa çıkmadan önce mühürlenir ve geri geldiğinde doğrulanır; aşağıdaki **[`requestState`'i koruma](#protecting-requeststate)** bölümü mührün size ne sağladığını ve anahtarları ne zaman yapılandırmanız gerektiğini anlatır. +* Bağımlılık biçimi uymadığında bir `@mcp.tool()` fonksiyonu da sonucu aynı şekilde doğrudan döndürebilir. +* Statik `@mcp.resource()` fonksiyonları buna katılmaz: `Context` almazlar, dolayısıyla yeniden denemeyi hiçbir zaman okuyamazlar. Yalnızca şablon kaynaklar soru sorabilir. +* Aşağıdaki nesil kuralları aynen geçerlidir: 2026 öncesi bir oturumda `InputRequiredResult` döndürmek, uyarının anlattığı aynı `-32603` hatasıdır. + +## İstemci tarafı {#the-client-side} + +Döngüyü sizin yerinize `Client` çalıştırır. + +Sunucunun isteyebileceği callback'leri (`elicitation_callback`, `sampling_callback`, `list_roots_callback`) kaydedin ve aracı çağırın. Bir `InputRequiredResult` geldiğinde `Client`, `input_requests` içindeki her girdiyi eşleşen callback'e yönlendirir, yanıtlar ve geri yollanan `request_state` ile yeniden dener ve bir `CallToolResult` dönene kadar devam eder: + +```python title="client.py" hl_lines="11 12" +--8<-- "docs_src/mrtr/tutorial003.py" +``` + +* Bu `elicitation_callback`, 2026 öncesi bir sunucunun geri kanal üzerinden gönderdiği `elicitation/create` isteğinin ulaşacağı callback'in aynısıdır. `sampling/createMessage` ile `sampling_callback`, `roots/list` ile `list_roots_callback` için de aynısı geçerlidir: 2026-07-28 sürümünde bağımsız sunucu->istemci RPC'leri artık yoktur, ancak birebir aynı `ElicitRequest` / `CreateMessageRequest` / `ListRootsRequest` yükleri `input_requests` içinde taşınır ve aynı üç callback'e yönlendirilir. Tek bir callback seti her iki nesle de hizmet verir. +* `call_tool` düz bir `CallToolResult` döndürür. Aradaki turlar çağırana görünmez. +* `get_prompt` ve `read_resource` aynı döngüyü yürütür. + +!!! check + Callback'i kaydetmezseniz döngü ilk turda başarısız olur: SDK'nın yedek callback'i her + elicitation'ı bir hatayla yanıtlar ve `call_tool`, *"Elicitation not supported"* mesajıyla + `MCPError` fırlatır. + +Döngü sınırlıdır. Varsayılan üst sınır `Client(..., input_required_max_rounds=10)` değeridir; bunu aştıktan sonra hâlâ `InputRequiredResult` döndüren bir sunucu `call_tool`'un hata fırlatmasına yol açar. Bir tur yalnızca `request_state` taşıyıp hiç `input_requests` taşımıyorsa `Client` yeniden denemeden önce kısa bir süre bekler (50 ms'den başlayıp iki katına çıkarak 250 ms tavanına ulaşır); böylece yalnızca *"henüz bitmedi"* diyen bir sunucu sürekli yoklanmaz. + +### Döngüyü kendiniz yürütme {#driving-the-loop-yourself} + +Otomatik döngü tek süreçli bir istemci için yeterlidir. Şu durumlarda döngüyü kendiniz üstlenin: + +* İstemciniz **dağıtık** yapıdaysa: soruyu kullanıcıya gösteren süreç `call_tool`'u çağıran süreç değildir, bu yüzden yeniden denemeyi başka bir worker gönderir. `request_state`, kendi depolamanız üzerinden bu sınırın ötesine taşıdığınız kalıcı saklanabilir token'dır; `input_responses` ise karşı tarafın onunla birlikte geri gönderdiği şeydir. +* Her turu **incelemek** istiyorsanız: her `input_requests` girdisini loglamak ya da denetlemek, belirli istek türlerini reddetmek veya adımlar arasında kendi bekleme (backoff) stratejinizi uygulamak. +* Tur sayısına değil **gerçek süreye** dayalı bir sınır istiyorsanız: `input_required_max_rounds`'a güvenmek yerine kendi döngünüzü `anyio.fail_after(...)` içine sarın. + +Alttaki oturuma inin; orada `allow_input_required=True` size birleşim türünü doğrudan verir: + +```python title="client.py" hl_lines="12 13 19" +--8<-- "docs_src/mrtr/tutorial002.py" +``` + +* `client.session.call_tool(..., allow_input_required=True)` dönüş türünü `CallToolResult | InputRequiredResult` olarak genişletir. Onu yeniden daraltan `isinstance` denetimidir. +* `request_state` artık sizin elinizde. Adımlar arasında onu bir yere yazın; konuşma yepyeni bir süreçten kaldığı yerden sürebilir. +* `input_requests` içindeki her girdi için `input_responses` içine **aynı anahtarla** bir `InputResponse` koyarsınız. Kullanıcı arayüzünüzün yeri `fulfil`'dir; buradaki, yanıtı sabit kodlar. +* Her adımda aynı araç adı, aynı `arguments`. Yeniden deneme yeni bir yöntem değil, asıl çağrının yeniden yapılmasıdır. + +## `requestState`'i koruma {#protecting-requeststate} + +Yukarıdaki her şey `request_state`'i bir yankı olarak ele alır; ağ üzerinde de bundan ibarettir. Ancak istemci onu adımlar arasında elinde tutar (süreçler arasında bir yere yazmak tam da önceki bölümün onayladığı şeydir), dolayısıyla geri gelen şey **istemcinin sağladığı girdidir**: değiştirilmiş, süresi dolmuş ya da bambaşka bir çağrıdan alınmış olabilir. Spesifikasyon, durumun yetkilendirmeyi, kaynak erişimini veya iş mantığını etkileyebildiği her yerde sunucuların bu durumun bütünlüğünü korumasını ve doğrulama başarısız olduğunda turu reddetmesini zorunlu kılar. + +`MCPServer` onu varsayılan olarak korur. Her sunucu, süreç başlarken üretilen bir anahtar altında giden `requestState`'i mühürler ve gelen her yankıyı (çözümleyici durumunu da elle kurulan durumu da) doğrular. Hiçbir şey yapılandırmaz, düz metin yazar ve düz metin okursunuz; ağ üzerinde yalnızca opak, şifreli bir token taşınır. + +Varsayılan anahtar süreçle birlikte doğar ve ölür; tek bir sürecin ötesine dağıtım yapmadan önce bilmeniz gereken tek şey budur: + +```python +from mcp.server.mcpserver import MCPServer, RequestStateSecurity + +# Multi-instance or restart-surviving: one or more shared secret keys (>= 32 bytes each). +mcp = MCPServer("fleet", request_state_security=RequestStateSecurity(keys=[key])) +``` + +* **Varsayılan (yapılandırma yok)** tek bir sürece uygundur: stdio ya da tam olarak bir HTTP worker'ı. Başka bir worker'a, yük dengeleyici arkasındaki başka bir örneğe ya da yeniden başlatma sonrası aynı sunucuya düşen bir yeniden deneme, o sürecin elinde olmayan bir anahtarla mühürlenmiştir; istemci aşağıdaki sabit ret yanıtını alır ve akışa baştan başlamak zorundadır. +* **`keys=[...]`**, bir yeniden denemenin **başka bir örneğe** ulaşabildiği (çok worker'lı `uvicorn`, yük dengelemeli HTTP) ya da yeniden başlatmalardan sağ çıkması gerektiği her durumda zorunludur: her örnek, herhangi bir kardeşinin ürettiğini doğrular. Aynı mekanizma; üretilmiş bir anahtar yerine sizin gizli anahtarınız. +* Kendi kriptografiniz için (örneğin bir KMS ya da mevcut bir token servisi) `keys` yerine `RequestStateSecurity(codec=...)` geçirin; aşağıdaki **[Kendi kriptografinizi getirme](#bring-your-own-crypto)** bölümü sözleşmeyi anlatır. + +### Mührün taşıdıkları {#what-the-seal-carries} + +Varsayılan da olsa yapılandırılmış da olsa, ağ üzerindeki `requestState` şifreli ve kimliği doğrulanmış bir token'dır. Kodunuz onu hiç görmez: işleyiciler ve çözümleyiciler düz metin yazar, düz metin okur (`ctx.request_state`); SDK çıkışta mühürler, girişte doğrular. Bütünlüğün ötesinde her token şunlara bağlanır: + +* **Bir zaman penceresi.** Her tur yeni bir son kullanma süresiyle yeniden mühürler; bu yüzden `RequestStateSecurity(ttl=...)` (varsayılan 600 saniye) akışın tamamını değil, tur başına düşünme süresini sınırlar. +* **Kimliği doğrulanmış principal.** İstek, SDK'nın doğruladığı bir OAuth erişim token'ı taşıdığında durum, token'ın istemcisine, yayımcısına (issuer) ve öznesine (subject) bağlanır: bir kullanıcı için üretilmiş durum, iki kullanıcı aynı OAuth istemcisini paylaşsa bile başka bir kullanıcı altında başarısız olur. Özne sağlamayan bir doğrulayıcı, bağlamayı yalnızca istemci kimliğine indirger; URL tabanlı istemci kimliklerinde bu kimliği o istemci yazılımının tüm kullanıcıları paylaşır. Kimlik doğrulama SDK dışında sonlandırıldığında (öndeki bir vekil sunucu) ya da aktarımda kimlik doğrulama yoksa bağlanacak bir principal yoktur ve `RequestStateSecurity(bind_principal=...)` kendi kimlik sinyalinizden bir tane sağlamadıkça bu denetim etkisizdir. Token doğrulayıcınız hangi bileşenleri sağlıyorsa bunları tutarlı biçimde sağlamalıdır: bazı isteklerde özneyi ekleyip bazılarında atlayan bir doğrulayıcı, principal'ı akışın ortasında değiştirir ve süren turlar reddedilir. +* **Kaynaklandığı istek.** Yöntem, araç ya da prompt adı (veya kaynak URI'si) ve argümanların bir özeti (digest). Başka bir araca, başka argümanlara ya da başka bir yönteme karşı yeniden oynatılan bir token başarısız olur. +* **Sorulan sorunun ta kendisi.** Her çözümleyici yanıtı, hem ilk geldiği turda hem de kaydedilmiş bir yanıt sonradan yeniden kullanıldığında, istemciye gösterilen oluşturulmuş soruya sabitlenir. Mesajı yeniden yazılmış ya da şeması değişmiş bir sürümü dağıtırsanız sunucu bayat bir yanıtı tüketmek yerine yeniden sorar. Aynı sabitleme ters yönde de işler: mesajları çağrıya özgü verilerden değil, aracın argümanlarından türetin. Bir zaman damgasından ya da canlı bir kurdan kurulan mesaj her turda farklı oluşur; bu yüzden kaydedilmiş her yanıt bayat görünür ve sunucu, istemcinin tur sınırı çağrıyı sonlandırana kadar yeniden sorar. + +Bunların hepsi SDK'nın işidir; sizin değil, kendinizinkini getirseniz bile codec'in de değil. + +### Anahtar rotasyonu {#rotating-keys} + +Yeni durumu `keys[0]` mühürler; listedeki her anahtar doğrular. Kesintisiz rotasyon, her biri bir sonrakinden önce tamamen yayılmış üç aşamadan oluşur: + +```python +RequestStateSecurity(keys=[OLD, NEW]) # 1: every instance learns to verify NEW; OLD still mints +RequestStateSecurity(keys=[NEW, OLD]) # 2: NEW mints; in-flight OLD state keeps verifying +RequestStateSecurity(keys=[NEW]) # 3: one ttl after phase 2 is fully out, retire OLD +``` + +Asla önce üreten anahtarı terfi ettirmeyin: bazı örneklerin henüz doğrulayamadığı bir anahtarla üretmek, yayılımın ortasında süren turları düşürür. + +Anahtarların kapsamı tek bir servistir. Mühürlü zarf ayrıca sunucunun adını bir audience claim'i olarak taşır; bu yüzden tesadüfen aynı gizli anahtarı paylaşan başka bir servisin ürettiği token zaten reddedilir. Claim ancak ad kadar ayırt edicidir; bu yüzden açık bir politika verilen sunucunun gerçek bir adı olmalı ya da `RequestStateSecurity(audience=...)` ayarlamalıdır: adsız bir sunucu oluşturulurken istisna fırlatır. `audience=` ayrıca bir servisin başka bir servisin ürettiği durumu kabul etmesi gereken, bilinçli kurulmuş çok servisli topolojilere de hizmet eder. (Yapılandırmasız varsayılan muaftır: anahtarı süreçten hiç çıkmaz, dolayısıyla audience claim'inin ekleyeceği bir şey yoktur.) + +### Kendi kriptografinizi getirme {#bring-your-own-crypto} + +`RequestStateSecurity(codec=...)`, `seal(bytes) -> str` ve `unseal(str) -> bytes` yöntemleri olan ve kendisinin üretmediği her token için `InvalidRequestState` fırlatan herhangi bir şeyi kabul eder. Klasik biçim, bir KMS üzerinden zarf şifrelemedir: başlangıçta bir veri anahtarını bir kez açar ve token başına kriptografiyi yerel tutarsınız: + +```python title="server.py" hl_lines="12 26-27 34-35 38" +--8<-- "docs_src/mrtr/tutorial005.py" +``` + +TTL, principal bağlama ve istek bağlama codec'in işi **değildir**: SDK bunları her codec için `seal`'den önce yüke damgalar ve `unseal`'den sonra yeniden doğrular. Bir codec'in tek yükümlülüğü bütünlük (kurcalanmışsa istisna fırlatmak) ve ideal olarak gizliliktir. + +### Doğrulama başarısız olduğunda {#when-verification-fails} + +Gelen her başarısızlık (kurcalanmış, süresi dolmuş, başka bir isteğe ya da principal'a karşı yeniden oynatılmış veya bu sunucunun bilmediği bir anahtarla mühürlenmiş olsun) aynı yanıtı alır: + +```json +{"code": -32602, "message": "Invalid or expired requestState"} +``` + +Her neden için tek bir sabit mesaj; böylece ağ üzerinden hangi denetimin başarısız olduğu asla açığa çıkmaz, gerçek neden sunucu log'una gider. `tools/call`, `prompts/get` ve `resources/read` üzerinden gelen her `requestState` denetlenir; hiç durum üretmeyen bir işleyiciye gelen de buna dahildir. Pratikte en sık görülen ret bir saldırgan değildir: varsayılan, sürece özel anahtarın bir yeniden başlatma öncesinden ya da başka bir örnekten gelen bir yeniden denemeyle karşılaşmasıdır. İstemci akışı yeniden başlatır; bu önemli olduğunda çözüm `keys=[...]` kullanmaktır. + +### Elle kurulan durum {#hand-built-state} + +Kendiniz ayarladığınız bir `request_state`'i (bir araç, prompt ya da kaynak şablonu fonksiyonundan `InputRequiredResult` döndürerek), çözümleyici durumunu işleyen aynı mekanizma tek satır kod değişmeden mühürler ve doğrular: düz metin yazın, düz metin okuyun; yukarıdaki her bağlama geçerlidir. + +SDK'nın, yapılandırılmış olsa bile sizin yerinize sabitleyemeyeceği tek şey soru kimliğidir: durumunuzdaki bir yanıtın *sizin* sorularınızdan hangisine ait olduğunu bilmez. Yanıtları soruya göre anahtarlayarak saklıyorsanız duruma kendi soru tanımlayıcınızı ekleyin ve yeniden denemede onu denetleyin. + +Düşük seviyeli `Server` hiçbir şeyin hazır gelmediği katmandır: `MCPServer`'ın aksine, sınırı kendiniz ekleyene kadar hiçbir şey mühürlenmez ve bunu yapana kadar `request_state` ağ üzerinden tam yazıldığı gibi geçer. Tek satırlık katılım **[Düşük seviyeli Server](../advanced/low-level-server.md#the-other-handlers)** sayfasında gösterilir. + +## 2026-07-28 sürümüne özgü bir sonuç {#a-2026-07-28-result} + +`InputRequiredResult` yalnızca **2026-07-28** protokol sürümünde vardır. Bellek içi `Client(server)` onu sizin yerinize anlaşarak belirler; ağ üzerinde `mode="auto"` keşfeder. Bağlandıktan sonra `client.protocol_version` size ne elde ettiğinizi söyler. + +!!! warning + 2026 öncesi bir oturumda `InputRequiredResult` koyacak bir yer yoktur. `mode="legacy"` bir + bağlantıda işleyicinizden bir tane döndürürseniz çalıştırıcı onu anlaşılan sürüme + serileştiremez; istemciye `-32603` *"Handler returned an invalid result"* hatası döner. Her iki + nesle de hizmet veren bir sunucu, ona el atmadan önce `ctx.protocol_version` değerini + denetlemelidir. + +!!! info + **URL kipinde elicitation**, 2026 bağlantısında tam olarak bu mekanizmayı kullanır. + `input_requests` içindeki girdi, parametreleri `ElicitRequestURLParams` olan bir + `ElicitRequest`'tir; kullanıcı bant dışı akışı tamamlar ve istemciniz çağrıyı yeniden dener. + Aynı döngü, yeni API yok. Üst seviyeli sunucu tarafı **[Elicitation](elicitation.md)** + sayfasındadır. + +## Özet {#recap} + +* 2026-07-28 sürümünde, çağrının ortasında girdiye ihtiyaç duyan bir sunucu `InputRequiredResult` **döndürür**. İstemciye asla istek açmaz. +* `input_requests` ihtiyaç duyduklarıdır. `request_state` yalnızca sunucunun okuduğu opak bir devam token'ıdır. +* Yeniden deneme döngüsünü `Client` sizin yerinize çalıştırır: `elicitation_callback` / `sampling_callback` / `list_roots_callback` kaydedin, `call_tool` düz bir `CallToolResult` döndürür. `input_required_max_rounds` (varsayılan 10) onu sınırlar. +* Turları incelemek ya da kalıcı saklamak için `client.session.call_tool(..., allow_input_required=True)` kullanın ve `while isinstance(result, InputRequiredResult)` döngüsünü kendiniz üstlenin. +* `@mcp.tool()` üzerinde, kullanıcıya soran bir bağımlılık bu sonucu sizin yerinize üretir (**[Bağımlılıklar](dependencies.md)**); elle kurulan biçim **düşük seviyeli** `Server`'dır. +* Prompt'lar ve kaynaklar da katılır: bir `@mcp.prompt()` ya da şablon `@mcp.resource()` fonksiyonu `InputRequiredResult`'ı kendisi döndürür ve yeniden denemede `ctx.input_responses`'ı okur. +* `requestState` istemcinin sağladığı girdi olarak geri gelir; bu yüzden `MCPServer` onu (çözümleyici durumunu da elle kurulan durumu da) varsayılan olarak sürece özel bir anahtar altında mühürler. Çok örnekli dağıtımlar, her örneğin bir kardeşinin ürettiğini doğrulayabilmesi için `RequestStateSecurity(keys=[...])` (ya da özel bir codec) geçirir. Mühür her token'ı bir zaman penceresine, kaynaklandığı isteğe ve (istek SDK'nın doğruladığı kimlik doğrulama bilgisini taşıdığında ya da `bind_principal=` kendi kimlik sinyalinizi sağladığında) kimliği doğrulanmış principal'a bağlar (**[`requestState`'i koruma](#protecting-requeststate)**). + +Sunucunun başlattığı örneklemenin ve itme tarzı geri kanalın geri kalanının yerini alan mekanizma budur; **[Kullanım dışı özellikler](../deprecated.md)** sayfasına bakın. diff --git a/i18n/tr/pages/handlers/progress.md b/i18n/tr/pages/handlers/progress.md new file mode 100644 index 0000000000..373b63941e --- /dev/null +++ b/i18n/tr/pages/handlers/progress.md @@ -0,0 +1,122 @@ +--- +translation: + sections: [5315262fe26b33e1, 9d8e98840f1b78f0, 0284b215e85366c4, 8534d8dbb4053a70, 2966fac6fe697007] + tool: 1 +--- +# İlerleme {#progress} + +Otuz saniye süren ve otuz saniye boyunca hiçbir şey söylemeyen bir araç bozuk görünür. + +**İlerleme bildirimleri** bunu çözer. Araç ne kadar ilerlediğini bildirir; bununla ne çizeceğine istemci karar verir: bir çubuk, dönen bir simge, bir log satırı. + +## Araçtan bildirme {#report-it-from-the-tool} + +Bir **`Context`** parametresi alın ve `report_progress`'i çağırın: + +```python title="server.py" hl_lines="8 11" +--8<-- "docs_src/progress/tutorial001.py" +``` + +Üç argüman var ve ne anlama geldiklerine siz karar verirsiniz: + +* `progress`: ne kadar ilerlediğiniz. Spesifikasyon bunun her bildirimde **artmasını** şart koşar; asla bir değeri tekrarlamayın veya geriye gitmeyin. +* `total`: biliyorsanız, toplamda ne kadar iş olduğu. İsteğe bağlı. +* `message`: *bu* adım hakkında insanların okuyabileceği tek bir satır. İsteğe bağlı. + +`ctx` tür ipucu sayesinde enjekte edilir ve model onu asla görmez: `import_catalog`'un girdi şemasında tek bir özellik var, `urls`. **[Context nesnesi](context.md)** sayfası baştan sona bu nesneyi anlatır; ilerleme, onun size sunduklarından biridir. + +## İstemciden dinleme {#listen-for-it-from-the-client} + +İstemci, `call_tool`'a `progress_callback=` geçirerek **çağrı başına** dahil olur: + +```python title="client.py" hl_lines="7 16" +import anyio +from mcp import Client + +from server import mcp + + +async def show(progress: float, total: float | None, message: str | None) -> None: + print(f"{message} ({progress}/{total})") + + +async def main() -> None: + async with Client(mcp) as client: + result = await client.call_tool( + "import_catalog", + {"urls": ["https://example.com/a.json", "https://example.com/b.json"]}, + progress_callback=show, + ) + print(result.structured_content) + + +anyio.run(main) +``` + +Callback, sunucunun bildirdiklerini olduğu gibi alan `async` bir fonksiyondur: `progress`, `total`, `message`. + +!!! info + `Client(mcp)` doğrudan sunucu nesnesine, bellek içinde bağlanır; **[Test etme](../get-started/testing.md)** + sayfasının üzerine kurulduğu istemcinin aynısıdır. `Client` hangi aktarımı kullanırsa kullansın + `progress_callback` aynı parametredir; birazdan göreceğiniz *zamanlama* ise bellek içi bağlantıya + özgüdür. Bu bağlantı callback'inizi satır içinde çalıştırır, bu yüzden her bildirim `call_tool` + dönmeden önce ulaşır. Gerçek bir aktarım üzerinde bildirimler sonuçla yarışır ve yavaş bir callback, + `call_tool` döndükten sonra hâlâ çalışıyor olabilir. + +### Deneyin {#try-it} + +`client.py` dosyasını `server.py` dosyasının yanına koyun ve çalıştırın: + +```console +python client.py +``` + +```text +Imported https://example.com/a.json (1/2) +Imported https://example.com/b.json (2/2) +{'result': 'Imported 2 records.'} +``` + +Sunucudaki her `await ctx.report_progress(...)`, istemcide sırasıyla bir `show` çağrısına dönüştü ve her iki satır da `call_tool` dönmeden **önce** yazdırıldı. İlerleme sonucun içine paketlenmez; araç hâlâ çalışırken akar. + +!!! warning + `progress_callback` `Client`'a değil, **çağrıya** aittir. Bunun için bir kurucu argümanı yoktur, + çünkü farklı çağrılar farklı callback'ler ister: biri bir indirme çubuğunu sürer, sonraki bir + log satırını. + +!!! check + Şimdi `progress_callback=show` kısmını silin ve yeniden çalıştırın: + + ```text + {'result': 'Imported 2 records.'} + ``` + + Hata yok, uyarı yok, sonuç aynı. `report_progress`, **çağıran taraf ilerleme istemediğinde hiçbir + şey yapmaz**; bu yüzden koşulsuz bildirirsiniz ve birinin dinleyip dinlemediğini asla merak etmeniz + gerekmez. + +## Toplamı bilmediğinizde {#when-you-dont-know-the-total} + +`total`, paydayı bildiğiniz durumlar içindir. Çoğu zaman bilmezsiniz: bir akışı boşaltıyor, bir imleç üzerinde ilerliyor ya da uzunluk başlığı olmayan bir şey indiriyorsunuzdur. + +Belirtmeyin: + +```python title="server.py" hl_lines="20" +--8<-- "docs_src/progress/tutorial002.py" +``` + +Callback `total=None` alır. İstemci yine de *etkinlik* gösterebilir ("şimdiye kadar 3 tane içe aktarıldı...") ama yüzde gösteremez. Daha güzel bir çubuk için toplam uydurmayın. + +!!! tip + `progress`'in belirli bir şeyi sayması gerekmez. Bayt, satır, sayfa: kullanıcının tanıyacağı + birimi seçin ve yalnızca tutabileceğiniz bir `total` sözü verin. + +## Özet {#recap} + +* `Context` alan herhangi bir araçtan `await ctx.report_progress(progress, total=None, message=None)`. +* İstemci `call_tool`'a `progress_callback=` geçirir: çağrı başına, asla `Client` üzerinde değil. +* Callback `async (progress, total, message) -> None` biçimindedir ve araç hâlâ çalışırken tetiklenir. +* Çağrıda callback yoksa `report_progress` hiçbir şey yapmaz. Koşulsuz bildirin. +* Bilmediğinizde `total`'ı vermeyin; callback `None` alır. + +İlerleme, çalışan bir aracın *kullanıcıya* gösterdiği şeydir. *Sizin* için, yani sunucuyu işleten kişi için yazdığı log satırları ise ayrı bir kanaldır: **[Log tutma](logging.md)**. diff --git a/i18n/tr/pages/handlers/sampling-and-roots.md b/i18n/tr/pages/handlers/sampling-and-roots.md new file mode 100644 index 0000000000..0e6b1ef3d9 --- /dev/null +++ b/i18n/tr/pages/handlers/sampling-and-roots.md @@ -0,0 +1,51 @@ +--- +translation: + sections: [5c82b20cbd65ded0, 9dc22632be79a533, 1fb8f452e990c456, 42666ab914ff0cb1, c4e0cb3667fd5ff9] + tool: 1 +--- +# Örnekleme ve kök dizinler {#sampling-and-roots} + +Bir işleyici, bağlı istemciden iki şey daha isteyebilir: istemcinin kendi modelinden bir tamamlama (**örnekleme (sampling)**) ve istemcinin çalışma alanı klasörleri (**kök dizinler (roots)**). + +İkisi de SDK'nın konuştuğu her protokol sürümünde hâlâ çalışır. Ancak tasarımınızı bunların üzerine kurmadan önce uyarıyı okuyun: + +!!! warning "2026-07-28 spesifikasyonuyla kullanım dışı bırakıldı" + Örnekleme ve kök dizinler `2026-07-28` itibarıyla kullanım dışı bırakıldı ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2577)). Tamamen işlevsel olmaya devam ederler ve kaldırılmaya aday hâle gelmeden önce en az on iki ay boyunca spesifikasyonda kalırlar; yine de yeni uygulamalar bunların üzerine kurulmamalıdır. Önerilen geçiş yolları: örnekleme yerine doğrudan LLM sağlayıcınızın API'siyle entegre olun; kök dizinler yerine dizinleri araç parametreleri, kaynak URI'leri veya sunucu yapılandırması üzerinden geçirin. SDK genelindeki liste **[Kullanım dışı özellikler](../deprecated.md)** sayfasında. + +## Örnekleme: istemcinin modelini ödünç alma {#sampling-borrow-the-clients-model} + +Bir çözümleyici `Sample(...)` döndürür ve araç tamamlamayı alır; bu, **[Bağımlılıklar](dependencies.md)** sayfasında `Elicit`'i çalıştıran bağımlılık mekanizmasının aynısıdır: + +```python title="server.py" hl_lines="10-15 19" +--8<-- "docs_src/sampling_and_roots/tutorial001.py" +``` + +* `Sample(messages, max_tokens=...)`, `sampling/createMessage` parametrelerini yansıtır. Enjekte edilen değer istemcinin `CreateMessageResult`'ıdır; `tools` veya `tool_choice` geçirirseniz bunun yerine bir `CreateMessageResultWithTools` olur. +* İstemcinin `sampling` yeteneğini bildirmiş olması gerekir (`tools` veya `tool_choice` geçiriyorsanız `sampling.tools`). Bildirmediyse çağrı, istemcinin işleyemeyeceği bir istek göndermek yerine `-32021` protokol hatasıyla başarısız olur. Geri kanalı (back-channel) olmayan 2026 öncesi bir oturum, gönderecek bir yer olmadığından her zamanki geri-kanal-yok hatasıyla başarısız olur. +* `2026-07-28`'de istek, çok turlu (multi-round-trip) akışın içinde iletilir (**[Çok turlu istekler](multi-round-trip.md)**); `2025-11-25`'te ise istemciye gönderilen bağımsız bir istektir. Kod her iki durumda da aynıdır, ancak çok turlu kurala dikkat edin: istek, yeniden deneme turları boyunca birebir aynı şekilde oluşmalıdır. Bu yüzden onu yalnızca aracın argümanlarından ve diğer kararlı verilerden oluşturun. +* `include_context`'e dokunmayın: `"none"` dışındaki değerlerin kendisi de kullanım dışı bırakıldı (SEP-2596) ve neredeyse hiçbir istemcinin bildirmediği bir yetenek gerektirir. + +## Kök dizinler: bu nereye gitmeli? {#roots-where-should-this-go} + +Kök dizinler, istemcinin sunucunun üzerinde çalışabileceğini söylediği klasörlerdir. Bilgilendirme amaçlı bir yönlendirmedir, erişim denetimi mekanizması değil. Bir çözümleyici `ListRoots()` döndürür: + +```python title="server.py" hl_lines="10-11 15" +--8<-- "docs_src/sampling_and_roots/tutorial002.py" +``` + +* Enjekte edilen `ListRootsResult`, bir `Root` listesi taşır: her biri bir `file://` URI'si ve isteğe bağlı bir görünen ad. +* Denetim örneklemeyle aynıdır: bildirilmiş bir `roots` yeteneği yoksa çağrı, isteği göndermek yerine `-32021` ile başarısız olur. + +Bağlantının diğer ucunda istemci, her iki isteği de zaten sahip olduğu callback'lerle yanıtlar: **[İstemci callback'leri](../client/callbacks.md)** sayfasında anlatılan `sampling_callback` ve `list_roots_callback`. + +## 2025 neslinden bağlantılarda {#on-2025-era-connections} + +`ctx.session.create_message(...)` ve `ctx.session.list_roots()`, oturumu doğrudan yöneten kod için hâlâ mevcuttur. Yalnızca bir geri kanalın bulunduğu yerde (2025 neslinden, durumsuz olmayan bağlantılarda) çalışırlar ve çağrıldıklarında kullanım dışı bırakma uyarısı verirler. Desteklenen biçim yukarıdaki çözümleyici işaretçileridir: iletim yolunu anlaşılan sürüme göre seçerler ve uyarı vermezler. + +## Özet {#recap} + +* Bir çözümleyiciden `Sample(...)` veya `ListRoots()` döndürün; araç `CreateMessageResult`'ı veya `ListRootsResult`'ı diğer bağımlılıklar gibi alır. +* İstemcinin eşleşen yeteneği bildirmesi gerekir; aksi hâlde çağrı, bir istek gönderilmek yerine `-32021` ile başarısız olur. +* İki özellik de `2026-07-28`'de kullanım dışı bırakıldı: şimdilik tamamen işlevsel, ancak yeni tasarımlar için yanlış tercih. Örnekleme yerine sağlayıcı API'lerini, kök dizinler yerine açık parametreleri tercih edin. + +Yavaş bir aracın ne kadar ilerlediğini bildirme: **[İlerleme](progress.md)**. diff --git a/i18n/tr/pages/handlers/subscriptions.md b/i18n/tr/pages/handlers/subscriptions.md new file mode 100644 index 0000000000..2336b92d8e --- /dev/null +++ b/i18n/tr/pages/handlers/subscriptions.md @@ -0,0 +1,152 @@ +--- +translation: + sections: [60a9de8a0bdaa531, 317bbe7e4355cdcc, a61d660c8029e04a, 8f7e82fcb88df8a9, b165db51249ff8ed, 266f56fb798068a4, 7c0e57030b622139, df18d7c2417a9883] + tool: 1 +--- +# Abonelikler {#subscriptions} + +Bir sunucunun kataloğu sabit değildir. Çalışma zamanında yeni araçlar ortaya çıkar, bir kaynak URI'sinin ardındaki içerik değişir. + +İstemci bunlardan **abonelikler** sayesinde haberdar olur. İstemci tek bir `subscriptions/listen` isteği gönderir ve bu isteğin yanıtı akışın *ta kendisidir*: açık kalır ve istemcinin istediği değişiklik bildirimlerini taşır. + +## Değişikliği araçtan yayımlama {#publish-it-from-the-tool} + +Size düşen tek satır: değişikliği yayımlayın. + +```python title="server.py" hl_lines="20 32" +--8<-- "docs_src/subscriptions/tutorial001.py" +``` + +* `await ctx.notify_resource_updated("board://sprint")` bu URI'ye abone olmuş her açık akışa ulaşır. Başka kimseye değil. +* `await ctx.notify_tools_changed()` araç listesi değişikliklerini isteyen her akışa ulaşır. Bunu alan istemci `tools/list`'i yeniden çağırır ve artık `sprint_report`'u görür. +* Kardeş metotlar `notify_prompts_changed()` ve `notify_resources_changed()`. +* Abone yoksa iş de yok. Boştaki bir sunucuda yayımlamak hiçbir şey yapmaz; bu yüzden kimsenin dinleyip dinlemediğini asla kontrol etmezsiniz. Neyin değiştiğini bildirirsiniz, o kadar. + +`MCPServer`, `subscriptions/listen`'ı sizin yerinize sunar. Protokol düzeyindeki yükümlülükler (ilk çerçeve olarak onay, akış başına filtreleme, her çerçevede abonelik kimliği) SDK'nın işidir. + +!!! check + Ağ üzerinde, filtresinde `board://sprint` geçen bir akış `complete_task` çalıştıktan sonra şöyle görünür: + + ```json + {"method": "notifications/subscriptions/acknowledged", + "params": {"notifications": {"resourceSubscriptions": ["board://sprint"]}, "_meta": {"io.modelcontextprotocol/subscriptionId": "listen-1"}}} + + {"method": "notifications/resources/updated", + "params": {"uri": "board://sprint", "_meta": {"io.modelcontextprotocol/subscriptionId": "listen-1"}}} + ``` + + Güncellemenin neyi *taşımadığına* dikkat edin: panonun kendisini. Her çerçeve, listen isteğinin JSON-RPC kimliğini `_meta` altında taşır ve bu kimlik abonelik kimliğidir. Onu istemci üretir: Python `Client`'ı `"listen-1"` gibi dizeler kullanır; başka istemciler tamsayı kullanabilir. + +## Yalnızca istenenler {#only-what-was-asked-for} + +Filtre bir sözleşmedir. Araç listesi değişikliklerini ve tek bir kaynak URI'sini isteyen bir akış bu iki türü alır, başka hiçbir şeyi almaz. Bir prompt değişikliği yayımlarsanız o akış sessiz kalır. + +`MCPServer` kaynak URI'lerini birebir dize olarak eşleştirir; bu yüzden `board://sprint` URI'sini belirten bir akış `board://sprint/tasks/1` hakkında hiçbir şey duymaz. Belirtim, sunucunun abone olunan bir URI'nin alt kaynağındaki değişikliği bildirmesine izin verir; `MCPServer` bunu hiç yapmaz ama istemciler bunu bekleyecek şekilde yazılmıştır. + +Akışın *olmadığı* iki şey: + +* **Bir yeniden oynatma log'u değildir.** Kopan bir akış gitmiştir; kimse bağlı değilken yayımlanan olaylar kuyruğa alınmaz. İstemciler yeniden dinler ve yeniden getirir. +* **2025 yolu değildir.** `resources/subscribe` çağırmış istemcilere `ctx.session.send_resource_updated(uri)` hizmet verir. `notify_*` metotları yalnızca `subscriptions/listen` akışlarına ulaşır. + +## Kimin izleyebileceğine karar verme {#deciding-who-may-watch} + +Varsayılan olarak istenen her tür ve URI kabul edilir: her çağıran, yayımladığınız her URI'yi izleyebilir. Okuma işleyicinize hiçbir şey danışmaz, çünkü kimse okumuyordur. `files://{name}` işleyicinizin geri çevireceği bir çağıran yine de `files://payroll.csv` üzerinde bir akış açıp onun değiştiğini, hem de ne zaman değiştiğini öğrenebilir. İçeriği asla öğrenemez ve neyin var olduğunu yoklayamaz; çünkü bilinmeyen bir URI de kabul edilir ve yalnızca hiç tetiklenmez. Dar ama gerçek bir açık; bu yüzden çok kiracılı bir sunucudan kullanıcıya özel URI'ler yayımlamadan önce erişimi denetleyin. + +Bu denetimi bir middleware (ara katman) üstlenir. `subscriptions/listen` isteğini SDK onaylamadan önce görür ve çağıran okuyamayacağı bir şey istediğinde isteği reddeder: + +```python title="server.py" hl_lines="19-26 29" +--8<-- "docs_src/subscriptions/tutorial006.py" +``` + +* `ctx.params` ham istektir; bu yüzden middleware onu `SubscriptionsListenRequestParams` olarak kendisi doğrular ve istemcinin istediği filtreyi okur. +* Reddetmek, `call_next(ctx)`'ten önce fırlatılan bir `MCPError` demektir: istemci o hatayı alır, akış almaz ve bağlantı devam eder. Mesajı tek tip tutun ve hiçbir URI adı vermeyin; böylece bir ret hangi URI'lerin korunduğunu asla doğrulamaz. +* Tek bir `can_access(user, uri)` her iki soruyu da yanıtlar. Kaynak işleyicisi ona `resources/read` sırasında sorar; middleware ise `subscriptions/listen` sırasında. Tabloyu bir veritabanıyla ya da RBAC sisteminizle değiştirin, ikisi de uyumlu kalır. +* Karar akışın ömrü boyunca geçerlidir. Olay başına yeniden denetim yoktur; bu yüzden bir çağıranın erişimi akış ortasında sona erebiliyorsa (süresi dolan bir token gibi), sona erdiğinde o çağıranın bağlantısını kapatın. + +Middleware sözleşmesinin tamamı, başka neleri sardığı ve neden geçici (provisional) olarak işaretlendiği de dahil, **[Middleware](../advanced/middleware.md)** sayfasında. + +## İstemci tarafı {#the-client-end} + +İşte o akışın diğer ucunda, panoyu takip eden bir istemci: + +```python title="client.py" hl_lines="15" +--8<-- "docs_src/subscriptions/tutorial003.py" +``` + +`client.listen(...)`'a girmek isteği gönderir ve sizin onayınızı bekler; yani blok başladığında akış canlıdır ve türü belirli her olay bir yeniden getirme işaretidir, asla bir yük (payload) değildir. Sözleşmenin tamamı tek bir ekranda bu. İstemci tarafıyla ilgili geri kalan her şey kendi sayfasında: ana akışın yanında izleme, akış sonlanmaları ve yeniden dinleme. *İstemciler* altındaki **[Abonelikler](../client/subscriptions.md)** sayfasına bakın. + +## Tek sürecin ötesine ölçekleme {#scaling-past-one-process} + +Yayımlar, işleyicinizden açık akışlara bir `SubscriptionBus` üzerinden gider. Varsayılanı bellek içidir: tek süreç, içindeki tüm akışlar. Bir yük dengeleyicinin arkasında replikalar çalıştırana kadar doğru yanıt budur; çünkü o noktada bir istemcinin akışı tek bir replikaya bağlı kalır ve başka bir replikadaki yayımın ona ulaşması gerekir. + +Bu birleşim noktasını siz uygularsınız: pub/sub arka ucunuzun üzerinde iki metot. + +```python +from collections.abc import Callable + +from redis.asyncio import Redis + +from mcp.server.mcpserver import MCPServer +from mcp.server.subscriptions import ServerEvent # SubscriptionBus is a Protocol: no base class + + +class RedisSubscriptionBus: + def __init__(self, redis: Redis) -> None: + self._redis = redis + self._listeners: dict[object, Callable[[ServerEvent], None]] = {} + + async def publish(self, event: ServerEvent) -> None: + await self._redis.publish("mcp-events", encode(event)) # to every replica + + def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]: + token = object() + self._listeners[token] = listener + + def unsubscribe() -> None: + self._listeners.pop(token, None) + + return unsubscribe + + +mcp = MCPServer("Sprint Board", subscriptions=RedisSubscriptionBus(redis)) +``` + +`encode` size aittir; her replikada gelen mesajların kodunu çözüp kayıtlı her dinleyiciyi çağıran okuyucu görev de öyle. Dinleyiciler senkrondur, istisna fırlatmamalıdır ve sunucunun olay döngüsünde çalışır. + +Veri yolu türü belirli `ServerEvent` değerleri taşır (dört küçük dataclass), asla JSON-RPC değil. Damgalama, filtreleme ve akış yaşam döngüleri SDK'da kalır; bu yüzden bir veri yolu uygulaması protokolü bozamaz. Yalnızca olayları süreçler arasında taşıyabilir. + +Bir isteğin dışından yayımlamak için veri yolunu kendiniz oluşturun ki referansı elinizde olsun. Hiçbir şey geçirmediğinizde `MCPServer` içeride bir tane kurar ve onu dışarı açmaz. + +```python +from mcp.server.subscriptions import InMemorySubscriptionBus, ToolsListChanged + +bus = InMemorySubscriptionBus() +mcp = MCPServer("Sprint Board", subscriptions=bus) + + +async def tools_reloaded() -> None: + await bus.publish(ToolsListChanged()) # from a lifespan task, a webhook, anywhere +``` + +## Düşük düzeyli bileşim {#the-low-level-composition} + +Düşük düzeyli `Server`'da önceden bağlanmış hiçbir şey yoktur; aynı parçalar üç satırda bir araya gelir: + +```python title="server.py" hl_lines="8-9 47" +--8<-- "docs_src/subscriptions/tutorial002.py" +``` + +* Veri yolu sizindir; bu yüzden doğrudan ona yayımlarsınız: `await bus.publish(ResourceUpdated(uri=...))`. İşleyicilerinizin erişebileceği bir yere koyun: burada modül kapsamı, daha büyük bir uygulamada lifespan (yaşam döngüsü). +* `ListenHandler(bus)`, `MCPServer`'ın kaydettiği işleyicinin aynısıdır ve `on_subscriptions_listen=` sıradan bir işleyici yuvasıdır. Farklı bir anlam için o yuvaya kendi çağrılabilir nesnenizi koyun; o zaman belirtim yükümlülükleri size geçer: önce onaylayın, her çerçeveyi abonelik kimliğiyle damgalayın, filtrenin dışında hiçbir şey iletmeyin. +* `ListenHandler.close()` her açık akışı düzgünce sonlandırır. Her biri son çerçevesi olarak listen isteğinin sonucunu alır; bu, belirtimin sunucunun aboneliği bilerek sonlandırdığını söyleme biçimidir. Metot, bu akışlar boşaltmayı bitirmeden döner; bu yüzden aktarımı kapatmadan önce onlara kısa bir süre tanıyın. Onsuz, akışlar istemci bağlantıyı kestiğinde sona erer. + +## Özet {#recap} + +* İstemci tek bir `subscriptions/listen` isteğiyle katılır ve yanıt akışın kendisidir. Bunu sunmak yerleşiktir. +* `ctx.notify_*` ile yayımlarsınız; damgalama, filtreleme ve yaşam döngüsü işini SDK yapar. +* Olaylar işarettir, yük değil. Her iki uç da yeniden getirir. +* İstemci tarafı `async with client.listen(...)` bloğudur: ayrıntıları *İstemciler* altındaki **[Abonelikler](../client/subscriptions.md)** sayfasında. +* Düşük düzeyli `Server`'da aynı parçaları kendiniz birleştirirsiniz: bir veri yolu, `ListenHandler(bus)`, `on_subscriptions_listen` yuvası. +* Yatay ölçekleme, `SubscriptionBus`'ı (iki metot) uygulamak ve onu `MCPServer(subscriptions=...)` olarak geçirmek demektir. + +Tüm bunları sunan sunucuyu ister tek replikanın ister yirmisinin arkasında çalıştırma konusu **[Dağıtım ve ölçekleme](../run/deploy.md)** sayfasında. diff --git a/i18n/tr/pages/index.md b/i18n/tr/pages/index.md new file mode 100644 index 0000000000..1faafb7785 --- /dev/null +++ b/i18n/tr/pages/index.md @@ -0,0 +1,102 @@ +--- +translation: + sections: [154c4309937b9f85, 3ad8fc6caa76a9b0, a07f3f5b151ab746, bf6e476b712930c0, cf0b1f13978c6623] + tool: 1 +--- +# MCP Python SDK {#mcp-python-sdk} + +!!! info "Bu belgeler, mevcut kararlı sürüm hattı olan v2'yi anlatır" + v2'ye yeni mi başladınız, yoksa v1'den mi geliyorsunuz? **[v2'deki yenilikler](whats-new.md)** nelerin değiştiğine beş dakikalık bir bakış sunar, **[Geçiş kılavuzu](migration.md)** ise uyumluluğu bozan her değişikliği ele alır. + Hâlâ v1.x'te misiniz? Onun belgeleri [v1.x belgeleri](https://py.sdk.modelcontextprotocol.io/v1/) adresinde. + Pürüzlü ya da kafa karıştırıcı bir şey mi var? [Bize bildirin](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml). + +**Model Context Protocol (MCP)**, uygulamaların LLM'lere standart bir biçimde bağlam sağlamasına olanak tanır; bağlam *sağlama* işini LLM etkileşiminin kendisinden ayırır. + +Bu, onun resmi Python SDK'sı. Bununla şunları yapabilirsiniz: + +* Herhangi bir MCP host'una araç, kaynak ve prompt sunan **MCP sunucuları oluşturun**. +* Herhangi bir MCP sunucusuna bağlanan **MCP istemcileri oluşturun**. +* Tüm standart aktarımları konuşun: stdio, Streamable HTTP ve SSE. + +## Gereksinimler {#requirements} + +Python 3.10+. + +## Kurulum {#installation} + +=== "uv" + + ```bash + uv add "mcp[cli]" + ``` + +=== "pip" + + ```bash + pip install "mcp[cli]" + ``` + +`[cli]` eki size `mcp` komutunu kazandırır; geliştirme sırasında buna ihtiyacınız olacak. +Her bağımlılığın ne işe yaradığını görmek için [Kurulum](get-started/installation.md) sayfasına bakın. + +## Örnek {#example} + +### Oluşturun {#create-it} + +`server.py` adında bir dosya oluşturun: + +```python title="server.py" +--8<-- "docs_src/index/tutorial001.py" +``` + +Bu, eksiksiz bir MCP sunucusu. + +Bir **araç** (`add`) ve bir şablonlu **kaynak** (`greeting://{name}`) sunar. + +### Çalıştırın {#run-it} + +```console +uv run mcp dev server.py +``` + +Bu komut sunucuyu başlatır ve onu kurcalamanız için etkileşimli bir arayüz olan [MCP Inspector](https://github.com/modelcontextprotocol/inspector)'ı açar. Yazdırdığı URL'yi açın. + +!!! note + Inspector bir Node.js uygulaması olduğundan `mcp dev`, `PATH`'inizde `npx` bulunmasını gerektirir. + +### Deneyin {#try-it} + +Inspector'da **Tools** sekmesine gidin ve `add` aracını `a=1`, `b=2` ile çağırın. + +Geriye `3` döner. ✨ + +Inspector bu formu (`a` için zorunlu bir tamsayı alanı, `b` için bir diğeri) tür ipuçlarınızdan oluşturdu. Claude da, diğer tüm MCP host'ları da aynısını yapar. + +Şimdi **Resources** sekmesine gidin ve `greeting://World` kaynağını okuyun: + +```text +Hello, World! +``` + +### Özet {#recap} + +Neleri **yazmadığınıza** bir daha bakın: + +* JSON Schema yok. `a: int, b: int` şemanın *ta kendisi*. +* İstek ayrıştırma yok, serileştirme yok, doğrulama kodu yok. +* Protokol işleme hiç yok. + +Tür ipuçları ve bir docstring içeren iki Python fonksiyonu yazdınız. Gerisini SDK halleder. + +## Sırada ne var {#where-to-go-next} + +* **[Başlarken](get-started/index.md)** sizi kurulumdan çalışan, test edilmiş bir sunucuya götürür. +* MCP sunucularını *kullanan* bir uygulama mı geliştiriyorsunuz? **[İstemciler](client/index.md)** ile başlayın. +* Hâlihazırda bir FastAPI veya Starlette uygulamanız mı var? **[Mevcut bir uygulamaya ekleme](run/asgi.md)** sayfası içine bir MCP sunucusu bağlar. +* Belirli bir hata mesajının peşinde misiniz? **[Sorun giderme](troubleshooting.md)** sayfası birebir metne göre düzenlenmiştir. +* v2'de nelerin değiştiğini mi merak ediyorsunuz? **[v2'deki yenilikler](whats-new.md)** beş dakikalık bir tur. +* v1'den mi geçiyorsunuz? **[Geçiş kılavuzu](migration.md)** ile başlayın. +* Belirli bir imzanın peşinde misiniz? **[API referansı](api/mcp/index.md)** kaynak koddan üretilir. +* Bir LLM ile mi okuyorsunuz? Bu belgeler [llms.txt](https://llmstxt.org/) biçiminde de yayımlanır: + [llms.txt](https://py.sdk.modelcontextprotocol.io/llms.txt) sayfaların bir dizinidir, + [llms-full.txt](https://py.sdk.modelcontextprotocol.io/llms-full.txt) ise tüm sayfaları tek bir dosyada içerir. diff --git a/i18n/tr/pages/protocol-versions.md b/i18n/tr/pages/protocol-versions.md new file mode 100644 index 0000000000..b1fbd7664a --- /dev/null +++ b/i18n/tr/pages/protocol-versions.md @@ -0,0 +1,132 @@ +--- +translation: + sections: [478fd619e5f90ef8, aef094a00e44e248, bab8cbf3449fa7e9, df1809b15a58335b, 5f9d8c2336ed0239, f54974398e43ddef, b24443dd78584870] + tool: 1 +--- +# Protokol sürümleri {#protocol-versions} + +MCP'nin iki nesli var. + +2026-07-28'den önce yayımlanan sunucular her bağlantıyı **`initialize` el sıkışmasıyla** açar: istemci bir sürüm önerir, sunucu karşı teklif verir, istemci onaylar ve bunların hepsi ilk işe yarar istekten önce olur. **2026-07-28** neslindeki sunucular el sıkışmayı bırakır. İstemci tek bir **`server/discover`** sorgusu gönderir, sunucu da her şeyi tek bir sonuç içinde yanıtlar. + +Bununla neredeyse hiç ilgilenmeniz gerekmez, çünkü anlaşmayı sizin yerinize `Client` yapar. Bu sayfa, bunu denetleyen tek yapıcı argümanı, yani `mode=` parametresini ve onu değiştireceğiniz üç durumu anlatır. + +## `mode="auto"` {#modeauto} + +```python title="client.py" hl_lines="14-15" +--8<-- "docs_src/protocol_versions/tutorial001.py" +``` + +`mode` geçirmediniz, bu yüzden varsayılanı aldınız: `"auto"`. `async with` bloğuna girmek, bu SDK'nın konuştuğu en yeni sürümde tek bir `server/discover` sorgusu gönderir. Sonra: + +* **Modern bir sunucu** sorguyu yanıtlar. İstemci sonucu benimser. Tek tur, iş biter. +* **Daha eski bir sunucu** `server/discover` diye bir şey duymamıştır ve hata döndürür. İstemci klasik `initialize` el sıkışmasına geri döner ve onun anlaştığı sürüm neyse onu alır. + +Her iki durumda da bağlanmış olarak çıkarsınız ve hangisinin gerçekleştiğini `client.protocol_version` söyler: + +```text +2026-07-28 +``` + +Özelliğin tamamı bu. Tek bir `Client`, her nesilden sunucu, kodunuzda dallanma yok. + +!!! info + `MCPServer`, `server/discover` isteğini her aktarımda yanıtlar (bellek içi, stdio, Streamable + HTTP); bu yüzden kendi sunucunuza karşı `auto` her zaman `2026-07-28`'e ulaşır. Geri dönüş + yalnızca gerçek bir 2026 öncesi sunucuya karşı devreye girer, ki tam da o zaman bunu istersiniz. + +## `mode="legacy"` {#modelegacy} + +```python title="client.py" hl_lines="14" +--8<-- "docs_src/protocol_versions/tutorial002.py" +``` + +`mode="legacy"` hiçbir zaman sorgu göndermez. `initialize` el sıkışmasını çalıştırır; 2026 öncesi bir istemcinin açtığı bağlantının aynısını açar. + +```text +2025-11-25 +``` + +Aynı sunucu. `2026-07-28`'i gayet iyi konuşur; sormamasını istemciye siz söylediniz. + +Bunu **push tarzı** özellikler için istersiniz. + +Sunucunun başlattığı bir istek, sunucunun *sizi* çağırmasıdır: `ctx.elicit(...)` kullanıcınızın önüne bir form koyar, örnekleme (sampling) bir araç çağrısının ortasında modelinizden bir tamamlama ister. Bu kanal yalnızca el sıkışma neslinden bir oturumda vardır. + +2026-07-28'de bu kanal yok. Sunucu sorularını *döndürür*, siz de çağrıyı yanıtlarla yeniden denersiniz (**[Çok turlu istekler](handlers/multi-round-trip.md)** (multi-round-trip)). + +`mode="auto"` size yalnızca sunucu başka hiçbir şey için fazla eski olduğunda el sıkışma verir. `mode="legacy"` ise el sıkışmayı garanti eder. `Client(...)`'a bir `sampling_callback`, istek olarak yürütülmesini istediğiniz bir `elicitation_callback` ya da bir `message_handler` verdiğinizde buna başvurun. **[İstemci callback'leri](client/callbacks.md)** sayfası her birini tek tek ele alır. + +## Sürümü sabitleme {#pinning-a-version} + +`mode`, modern bir protokol sürümü dizgesini de kabul eder. Bugün bu küme tam olarak `["2026-07-28"]`. + +```python title="client.py" hl_lines="14" +--8<-- "docs_src/protocol_versions/tutorial003.py" +``` + +Sabitleme **hiçbir şey** göndermez. Sorgu yok, el sıkışma yok. İstemci `2026-07-28`'i yerel olarak benimser ve `async with` döndüğü anda bağlantı canlıdır. + +Sabitleme *sizin* verdiğiniz bir sözdür: sunucunun o sürümü konuştuğunu zaten biliyorsunuzdur. İstemci kontrol etmez. + +!!! check + Sabitleme bir keşif değildir. `client.server_info` değerini yazdırın, bedeli hemen görürsünüz: + + ```text + None + ``` + + İstemci sunucuya kim olduğunu hiç sormadı, bu yüzden `server_info` değeri `None`. `client.server_capabilities` + için de durum aynı: her yetenek `None`. Araç çağrıları yine çalışır (protokolün bunların hiçbirine ihtiyacı yoktur); + ne sunacağına karar vermek için `server_capabilities` okuyan kod ise çalışmaz. + + Çözüm bir sonraki bölümde. + +Yalnızca modern sürümler sabitlenebilir. El sıkışma neslinden bir dizge, herhangi bir G/Ç yapılmadan önce, yapıcıda reddedilir ve hata size bunun yerine ne yazmanız gerektiğini söyler: + +```text +ValueError: mode must be 'legacy', 'auto', or one of ['2026-07-28']; got '2025-06-18' ('2025-06-18' is a handshake-era version; use mode='legacy') +``` + +## `prior_discover` ile yeniden bağlanma {#reconnecting-with-prior_discover} + +Sorgu ucuzdur, ancak yine de her yeniden bağlanmada ödediğiniz bir turdur ve yanıt neredeyse hiç değişmez. + +Öyleyse saklayın. Bir `auto` bağlantısından sonra `client.session.discover_result`, sunucunun gönderdiği `DiscoverResult`'ı olduğu gibi tutar: `supported_versions`, `capabilities`, `instructions` ve sunucunun sonucun `_meta` alanına işlediği kimlik. Bir sonraki sefer bunu `prior_discover=` olarak geri verin: + +```python title="client.py" hl_lines="15 17" +--8<-- "docs_src/protocol_versions/tutorial004.py" +``` + +```text +2026-07-28 +Bookshop +``` + +İkinci bağlantı **sıfır** anlaşma turu yaptı ve yine de kiminle konuştuğunu tam olarak biliyor. Sabitlenmiş modun doğru yapılmış hali budur: `mode=` sürümü adlandırır, `prior_discover=` kimliği sağlar. ✨ + +`DiscoverResult` bir Pydantic modelidir. `saved.model_dump_json()` bir dosyaya ya da önbelleğe gider; `DiscoverResult.model_validate_json(...)` onu bir sonraki süreçte geri getirir. + +!!! tip + `prior_discover=` yalnızca `mode` bir sürüm sabitlemesi olduğunda bir işe yarar. `"auto"` altında + istemci sunucuyu zaten sorgular, `"legacy"` altında ise yok sayılır. + +## Dört mod {#the-four-modes} + +| Yazdığınız | Anlaşma trafiği | Elde ettiğiniz | +| --- | --- | --- | +| `Client(target)` | tek bir `server/discover` sorgusu; başarısız olursa `initialize` el sıkışması | her iki tarafın da konuştuğu en yeni sürüm, hangi nesilden olursa olsun | +| `Client(target, mode="legacy")` | `initialize` el sıkışması | el sıkışma neslinden bir sürüm; sunucunun başlattığı istekler çalışır | +| `Client(target, mode="2026-07-28")` | yok | o sürüm, sabitlenmiş, `server_info` değeri `None` | +| `Client(target, mode="2026-07-28", prior_discover=saved)` | yok | o sürüm, sabitlenmiş, *ve* geçen sefer kaydettiğiniz kimlik | + +## Özet {#recap} + +* MCP'nin bir el sıkışma nesli (`2025-11-25`'e kadar, `initialize` el sıkışması) ve bir modern nesli (`2026-07-28`, `server/discover`) var. `Client` ikisi arasında köprü kurar. +* `mode="auto"` varsayılandır: sorgula, geri dön. Diğer üç satırdan biri sizi anlatmıyorsa dokunmayın. +* "Ne elde ettim?" sorusunun yanıtı her zaman `client.protocol_version`. +* `mode="legacy"` el sıkışmayı zorunlu kılar. Sunucunun başlattığı istekler için gereken budur: örnekleme, push tarzı elicitation, `message_handler`. +* Sürüm sabitlemesi (`mode="2026-07-28"`) hiç anlaşma trafiği göndermez; bedeli `client.server_info` değerinin `None` olmasıdır. +* `prior_discover=` bu bedeli geri öder: `client.session.discover_result`'ı kaydedin, onunla yeniden bağlanın, ikisini de elde edin. + +Modern bir bağlantıda push kanalı yok; peki bir 2026 sunucusu çağrının ortasında size nasıl soru sorar? Soruyu döndürür: **[Çok turlu istekler](handlers/multi-round-trip.md)**. diff --git a/i18n/tr/pages/run/asgi.md b/i18n/tr/pages/run/asgi.md new file mode 100644 index 0000000000..7ed6be8dc3 --- /dev/null +++ b/i18n/tr/pages/run/asgi.md @@ -0,0 +1,146 @@ +--- +translation: + sections: [1062ef792791488a, 4be2b831547184a9, 374b049e770385f2, b72f6947089e6de0, b172c9db7831bb31, 70b9ece244ca1b0c, cba78e052898c3f6, f06bdb541cb0b469, fb82d526320b7cc3] + tool: 1 +--- +# Mevcut bir uygulamaya ekleme {#add-to-an-existing-app} + +`mcp.run("streamable-http")` sizin için bir web sunucusu başlatır. Bazen bunu istemezsiniz: MCP sunucunuz daha büyük bir web uygulamasının bir parçasıdır ya da zaten bir ASGI dağıtımınız vardır. + +Bunun için `mcp.streamable_http_app()` bir **Starlette uygulaması** döndürür. + +Starlette uygulaması bir ASGI uygulamasıdır; dolayısıyla ASGI barındırabilen her şey (uvicorn, Hypercorn, başka bir Starlette, FastAPI) MCP sunucunuzu da barındırabilir. + +## Uygulama {#the-app} + +```python title="server.py" hl_lines="12" +--8<-- "docs_src/asgi/tutorial001.py" +``` + +`app` sıradan bir ASGI uygulamasıdır. Herhangi bir ASGI sunucusuna verin: + +```console +uvicorn server:app +``` + +MCP endpoint'i `/mcp` yolundadır; yani istemci `http://127.0.0.1:8000/mcp` adresine bağlanır. + +Uygulama hâlihazırda iki şey taşır: + +* Tek bir rota, `/mcp`: Streamable HTTP endpoint'i. +* `mcp.session_manager`'ı başlatan bir **lifespan** (yaşam döngüsü); bu nesne, canlı her oturumun arka plan işlerinin sahibidir. + +Uygulamayı tek başına çalıştırın (`uvicorn server:app`), ikisini de hiç düşünmeniz gerekmez. + +!!! tip + `streamable_http_app()`, `mcp.run("streamable-http", ...)` ile aynı anahtar sözcük argümanlarını + alır; `port` hariç: port, uygulamayı sunan şeye aittir. `host` hâlâ kabul edilir ama burada + hiçbir şeye bağlanmaz; gerçekte neyi denetlediğini **[Dağıtım ve ölçekleme](deploy.md)** açıklar. + Seçeneklerin kendisi **[Sunucunuzu çalıştırma](index.md)** sayfasında. + +`mcp.sse_app()` aynısını, yerini yenisine bırakmış SSE aktarımı için yapar. + +## Siz aksini söyleyene kadar yalnızca localhost {#localhost-only-until-you-say-otherwise} + +Varsayılan olarak uygulama **yalnızca** localhost'a gönderilen istekleri yanıtlar. `streamable_http_app()` +hangi ana bilgisayar adının arkasında sunulacağını bilemez; bu yüzden DNS rebinding korumasını +olabilecek en güvenli izin listesiyle etkinleştirir. Kendi makinenizde bu tam olarak doğru olandır. +Gerçek bir ana bilgisayar adının arkasına dağıtıldığında ise, `transport_security=` parametresine +gerçekte sunduğunuz adların izin listesini geçirene kadar **her istek `421 Misdirected Request` ile +reddedilir** demektir. Sizin yazdığınız hiçbir şeye önce danışılmaz bile. Bu izin listesi ve çalışan +bir uygulama ile gerçek bir ana bilgisayar adı arasındaki diğer her şey +**[Dağıtım ve ölçekleme](deploy.md)** sayfasında. + +## Mount etme {#mounting-it} + +MCP sunucusu daha büyük bir uygulamanın *parçası* olduğu anda uygulamayı bir `Mount` içine koyarsınız. Bunu yaptığınız anda da lifespan sizin sorununuz olur: + +```python title="server.py" hl_lines="18-21 25-26" +--8<-- "docs_src/asgi/tutorial002.py" +``` + +* `Mount("/", ...)` ile varsayılan `/mcp` yolu birlikte endpoint'i `/mcp` yolunda tutar. Starlette rotaları sırayla dener ve `Mount("/")` **her** yolla eşleşir; bu yüzden kendi rotalarınız listede ondan *önce* gelir. Ondan sonraki hiçbir şeye ulaşılamaz. +* `lifespan` fonksiyonu, **ana** uygulamanın ömrü boyunca `mcp.session_manager.run()` içine girer. Herkesin unuttuğu satır budur. +* `mcp.session_manager` ancak `streamable_http_app()` çağrıldıktan *sonra* var olur. Rotaların modül düzeyinde kurulmasının ve yöneticiye yalnızca lifespan içinde dokunulmasının nedeni budur. + +Starlette'in `Host` rotası aynı şekilde çalışır: yola göre değil ana bilgisayar adına göre yönlendirmek için `Mount("/", ...)` yerine `Host("mcp.example.com", ...)` koyun. Lifespan kuralı değişmez, aktarım güvenliği kuralı da. `Host("mcp.example.com", ...)` rotası yalnızca o ana bilgisayar adına gönderilen istekleri alır, ancak aktarımın kendi Host izin listesi (**[Dağıtım ve ölçekleme](deploy.md)**) yine de önce çalışır. Listede `"mcp.example.com"` yoksa bu rota o isteklerin her birini `421` ile yanıtlar. + +!!! warning "Ana uygulama lifespan'in sahibidir" + `streamable_http_app()`, `session_manager.run()`'ı döndürdüğü Starlette'in lifespan'ine bağlar; + ancak **mount edilmiş bir alt uygulamanın lifespan'i hiçbir zaman çalışmaz**. Uygulamayı mount + edin, o yerleşik lifespan ölü kod olur. ASGI yığınınızın en üstünde hangi uygulama duruyorsa, + kendi lifespan'inde `mcp.session_manager.run()` içine girmelidir. + +!!! check + `lifespan=lifespan` satırını silin ve sunucuyu başlatın. Başlar. Rota çözülür. + Sonra `/mcp` yoluna gelen ilk istek şu hatayla başarısız olur: + + ```text + RuntimeError: Task group is not initialized. Make sure to use run(). + ``` + + Oturum yöneticisini kendi `run()`'ından başka hiçbir şey başlatmaz. + +## İki sunucu, tek uygulama {#two-servers-one-app} + +Her `MCPServer`, kendi oturum yöneticisi olan ayrı bir uygulamadır. İstediğiniz kadarını mount edin; her yöneticiye tek ana lifespan'den girin: + +```python title="server.py" hl_lines="27-30 35-36" +--8<-- "docs_src/asgi/tutorial003.py" +``` + +* `AsyncExitStack` iki yöneticiye de girer; birlikte başlar, ters sırada kapanırlar. +* Endpoint'ler `/notes/mcp` ve `/tasks/mcp`: mount öneki artı varsayılan yol. + +## Yolu değiştirme {#changing-the-path} + +Sondaki o `/mcp`, `streamable_http_path` değeridir. Bunu `"/"` yapın, mount öneki genel yolun tamamı olur: + +```python title="server.py" hl_lines="25" +--8<-- "docs_src/asgi/tutorial004.py" +``` + +Artık istemciler `/notes/mcp` yoluna değil `/notes` yoluna bağlanır. + +## Tarayıcı istemcileri için CORS {#cors-for-browser-clients} + +Tarayıcı tabanlı bir istemcinin sizden iki izne ihtiyacı vardır: MCP istek başlıklarını **göndermek** ve MCP'nin geri gönderdiği başlığı **okumak**. İkisi de ana uygulamadaki CORS yapılandırmasıdır ve yukarıdaki aktarım güvenliği izin listesinin bununla uyuşması gerekir: + +```python title="server.py" hl_lines="27-30 33 35-49" +--8<-- "docs_src/asgi/tutorial005.py" +``` + +* `allow_headers` herkesin unuttuğu yarıdır. Tarayıcı her MCP isteği için **preflight** yapar; çünkü `Content-Type: application/json` ve `Mcp-*` istek başlıkları CORS güvenli listesinde değildir ve preflight'ın izin vermediği bir başlık, tarayıcının asla göndermediği bir istek demektir. (`allow_headers=["*"]` da çalışır: Starlette bir preflight'ı ne istediyse onunla yanıtlar.) +* `expose_headers=["Mcp-Session-Id"]` okuma yarısıdır. Streamable HTTP oturum kimliğini bu yanıt başlığında döndürür ve tarayıcılar, CORS adlarıyla açığa çıkarmadıkça yanıt başlıklarını JavaScript'ten gizler. Bu olmadan istemci ikinci isteğini asla yapamaz. +* `allow_origins` MCP'nin değil sizin kararınızdır. Kesin olun ve yukarıdaki `allowed_origins=` ile birebir eşleştirin: CORS'u tarayıcı uygular, ama sunucu `Origin`'i kendisi de denetler ve aktarımın güvenmediği bir origin, temiz bir preflight'tan sonra bile `403` alır. +* `allow_methods` Streamable HTTP'nin kullandığı üç yöntemi listeler: ileti göndermek için `POST`, sunucudan istemciye akışı açmak için `GET`, oturumu sonlandırmak için `DELETE`. + +## Özel rotalar {#custom-routes} + +`@mcp.custom_route()` aynı uygulamada düz bir HTTP endpoint'i kaydeder; dağıtılan her servisin ihtiyaç duyduğu ama MCP ile hiçbir ilgisi olmayan şeyler için: sağlık denetimi, OAuth callback'i. + +```python title="server.py" hl_lines="15-17" +--8<-- "docs_src/asgi/tutorial006.py" +``` + +* İşleyici düz Starlette'tir: `Request`'ten `Response`'a bir `async` fonksiyon. +* `streamable_http_app()` her özel rotayı alır. `app.routes` artık `/mcp` ve `/health`. +* `GET /health`, ortada hiç MCP olmadan `{"status": "ok"}` yanıtını verir. + +!!! warning + Özel rotalar, sunucunun geri kalanı doğrulansa bile **hiçbir zaman kimlik doğrulamasından + geçmez**. Bu kasıtlıdır: sağlık denetimleri ve OAuth callback'leri herhangi bir token var + olmadan önce erişilebilir olmak zorundadır. Bunların arkasına özel hiçbir şey koymayın. + +## Özet {#recap} + +* `mcp.streamable_http_app()` tek rotası `/mcp` olan bir Starlette uygulaması döndürür. Herhangi bir ASGI sunucusu onu çalıştırabilir. +* Varsayılan olarak uygulama yalnızca localhost'a gönderilen istekleri yanıtlar; gerçek bir ana bilgisayar adının arkasında ise `transport_security=` parametresine bir izin listesi geçirene kadar her şeyi `421` ile reddeder. Bu konu ve üretime giden yolun geri kalanı **[Dağıtım ve ölçekleme](deploy.md)** sayfasında. +* `Mount` (veya `Host`) onu daha büyük bir Starlette ya da FastAPI uygulamasının içine koyar. +* **Mount etmek yerleşik lifespan'i devre dışı bırakır.** Ana uygulamanın lifespan'i `mcp.session_manager.run()` içine girmelidir, yoksa ilk istek başarısız olur. +* Tek uygulamada birden fazla sunucu, birden fazla mount ve her oturum yöneticisine giren tek bir lifespan demektir. +* `streamable_http_path="/"` endpoint'i mount önekinin kendisine taşır. +* Tarayıcı istemcilerinin CORS'a ihtiyacı vardır: `Mcp-*` istek başlıkları için `allow_headers`, yanıt için `expose_headers=["Mcp-Session-Id"]`. +* `@mcp.custom_route()`, `/mcp`'nin yanına düz, kimlik doğrulaması olmayan HTTP endpoint'leri ekler. + +Sunucu gerçek bir URL'den erişilebilir olduğunda **[İstemci](../client/index.md)** ona bir sunucu nesnesi yerine o URL ile bağlanır. diff --git a/i18n/tr/pages/run/authorization.md b/i18n/tr/pages/run/authorization.md new file mode 100644 index 0000000000..c04c1eefeb --- /dev/null +++ b/i18n/tr/pages/run/authorization.md @@ -0,0 +1,130 @@ +--- +translation: + sections: [d62c13457fc4a534, 80e73abaca6e0652, d1dc4c54cd00ec9c, 14ad3bc7904036bb, 5225f127bc1b9c77, fe1626fdd5aad1da, 4556cb7ea1a04a31] + tool: 1 +--- +# Yetkilendirme {#authorization} + +Streamable HTTP üzerinden MCP sunucunuz sıradan bir web hizmetidir ve onu her web hizmetini koruduğunuz gibi korursunuz: OAuth 2.1 bearer token'larıyla. + +OAuth terimleriyle sunucunuz bir **kaynak sunucusudur**. Hiç kimsenin oturumunu açmaz ve hiçbir zaman token vermez. Tek bir şey yapar: her istekteki `Authorization` başlığına bakar ve içindeki token'ın geçerli olup olmadığına karar verir. + +Bu sayfa sunucu tarafını anlatır. Yetkilendirme sunucunuzu keşfeden ve token'ı alan istemci ise **[OAuth istemcileri](../client/oauth-clients.md)** sayfasında. + +## Üç taraf {#the-three-parties} + +* **Yetkilendirme sunucusu** kullanıcıların oturumunu açar ve erişim token'ları verir. Bunu siz yazmazsınız. Kimlik sağlayıcınızdır (Auth0, Keycloak, Entra ya da kendinizinki). +* **Kaynak sunucusu** MCP sunucunuzdur. Her istekte token'ı doğrular. +* **İstemci** hangi yetkilendirme sunucusuna güvendiğinizi keşfeder, ondan bir token alır ve size `Authorization: Bearer ` olarak geri gönderir. + +Üçgenin tamamı bu. Bu sayfadaki her şey ortadaki maddeyle ilgili. + +## Token doğrulayıcı {#a-token-verifier} + +SDK'nın geçerli bir token'ın neye benzediği konusunda bir fikri yoktur. Bunu **`TokenVerifier`**'ı uygulayarak siz söylersiniz: + +```python title="server.py" hl_lines="12-14 19-24" +--8<-- "docs_src/authorization/tutorial001.py" +``` + +* `TokenVerifier` tek bir asenkron metodu olan bir protokoldür. `verify_token`, `Authorization` başlığındaki ham token'ı alır; geçerliyse bir **`AccessToken`**, değilse `None` döndürür. Uygulanacak başka bir şey yok. +* Buradaki, token'ı bir tabloda arar. Gerçek bir doğrulayıcı JWT imzasını doğrular ya da yetkilendirme sunucusunun token-introspection endpoint'ini çağırır. O kod sizindir; SDK onu yalnızca çağırır. +* `token_verifier=` ve `auth=` her zaman birlikte kullanılır. Birini diğeri olmadan geçirirseniz `MCPServer(...)` daha tek bir istek sunmadan `ValueError` fırlatır. + +`AuthSettings`, kaynak sunucunuzun dışa dönük yüzüdür: + +* `issuer_url`: token'larınızı veren yetkilendirme sunucusu. +* `resource_server_url`: bu MCP endpoint'inin herkese açık URL'si. Bir token'ın *hangi* kaynak için olduğunu belirtir ve keşif belgesi burada bulunur. +* `required_scopes`: her token bunların hepsini taşımalıdır. + +!!! tip + SDK deposundaki `examples/servers/simple-auth/` dizininde, gerçek bir yetkilendirme sunucusunun + [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662) endpoint'ini çağıran bir `IntrospectionTokenVerifier` var. Üretimdeki çoğu doğrulayıcı bu biçimdedir. + +## HTTP üzerinden elinize geçenler {#what-you-get-over-http} + +Yetkilendirme HTTP başlıklarında yaşar; bu yüzden yalnızca HTTP aktarımlarında vardır. Dağıttığınız aktarımda çalıştırın: `mcp.run(transport="streamable-http")` onu `http://127.0.0.1:8000/mcp` adresinde sunar; gerisi **[Sunucunuzu çalıştırma](index.md)** sayfasında. Uygulamanın artık iki rotası var: + +```text +/mcp +/.well-known/oauth-protected-resource/mcp +``` + +Siz tek bir araç kaydettiniz. İkinci rota SDK'nındır. + +### Keşif {#discovery} + +Bu well-known yoluna `GET` isteği gönderin; doğrudan `AuthSettings` değerlerinizden oluşturulmuş **[RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) Protected Resource Metadata** belgesini alırsınız: + +```json +{ + "resource": "http://127.0.0.1:8000/mcp", + "authorization_servers": ["https://auth.example.com/"], + "scopes_supported": ["notes:read"], + "bearer_methods_supported": ["header"] +} +``` + +Sunucunuzu hiç duymamış bir istemci içeri giden yolu bu belgeyle bulur: `authorization_servers` alanını okur ve token almak için oraya gider. Bunun hiçbirini siz yazmadınız. + +!!! check + `/mcp` yolunu token olmadan (ya da doğrulayıcınızın `None` döndürdüğü bir token'la) çağırın; istek + kapıda durdurulur: + + ```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"} + ``` + + Hiçbir şey ayrıştırılmadı, hiçbir araç çalışmadı. `WWW-Authenticate` içindeki o `resource_metadata` + işaretçisi de keşfi otomatik hale getiren şeydir: 401 -> meta veri belgesi -> yetkilendirme sunucusu -> token -> yeniden deneme. + +!!! warning + Bunların hiçbiri `stdio`'yu korumaz. Bir pipe'ın `Authorization` başlığı yoktur; bu yüzden orada + `token_verifier`'a hiç danışılmaz. Bir `stdio` sunucusunun güvenlik sınırı, onu başlatan süreçtir. + Aynısı testlerde kullandığınız bellek içi `Client(mcp)` için de geçerlidir: doğrudan sunucu nesnesine + bağlanır ve yetkilendirme dahil HTTP katmanını atlar. + +## Çağıranın kimliği {#the-callers-identity} + +Herhangi bir işleyicinin içinde **`get_access_token()`**, doğrulayıcınızın geçerli istek için döndürdüğü `AccessToken`'dır: + +```python title="server.py" hl_lines="4 32-35" +--8<-- "docs_src/authorization/tutorial002.py" +``` + +* Araçlarda, kaynaklarda ve prompt'larda çalışır ve elden ele geçirilecek bir şey yoktur: auth middleware'i onu istek başına bir bağlam değişkeninde saklar. +* Geriye **doğrulayıcınızın oluşturduğu nesnenin aynısı** döner: `client_id`, `scopes`, `subject`, `expires_at` ve eklediğiniz ek `claims`. Araç başına kurallar için kanca budur: kapsamları okuyun ve reddedin. +* Kimliği doğrulanmış bir HTTP isteğinin dışında `None` döndürür. Bellek içinde ve `stdio` üzerinden her zaman `None`'dır. + +`whoami` aracını `Authorization: Bearer alice-token` ile çağırın; model şunu okur: + +```text +alice (scopes: notes:read) +``` + +## SDK'nın üstlenmediği yarı {#the-half-the-sdk-doesnt-do} + +SDK size kaynak sunucusu yarısını verir: doğrula, duyur, reddet. Size bir giriş sayfası, bir onay ekranı ya da bir token vermez. + +Üç tarafın birden hareketini izlemek için SDK deposundaki `examples/servers/simple-auth/` örneğini çalıştırın (küçük bir yetkilendirme sunucusu ve tam bu sayfadaki gibi kurulmuş bir kaynak sunucusu), ardından keşif ve token akışının tamamını görmek için `examples/clients/simple-auth-client/` istemcisini ona yönlendirin. + +!!! info + İkinci bir kurucu argümanı daha var: `auth_server_provider=`. MCP sunucunuzun içine eksiksiz bir + yetkilendirme sunucusu gömer. MCP yetkilendirme spesifikasyonunun üzerine kurulduğu AS/RS ayrımından + daha eskidir. Yeni sunucular onu kullanmamalıdır. + +Bir yetkilendirme sunucusu, kullanıcının onay ekranından tıklayarak geçmesi yerine kurumsal bir kimlik sağlayıcının imzalı beyanını da kabul edebilir; SDK bu alışverişin iki tarafını da destekler. Bu grant ve onu sunan istemci **[Kimlik beyanı](../client/identity-assertion.md)** sayfasında. + +## Özet {#recap} + +* Streamable HTTP üzerinden sunucunuz bir OAuth 2.1 **kaynak sunucusudur**: token'ları doğrular, asla vermez. +* `TokenVerifier` entegrasyon yüzeyinin tamamıdır: tek bir asenkron metot, token girer, `AccessToken | None` çıkar. +* `token_verifier=` ve `auth=AuthSettings(issuer_url=..., resource_server_url=..., required_scopes=[...])` her zaman birlikte kullanılır. +* SDK, [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) Protected Resource Metadata belgesini `/.well-known/oauth-protected-resource/...` altında yayımlar ve kimliği doğrulanmamış istekleri, `WWW-Authenticate` başlığı ona işaret eden bir 401 ile yanıtlar. Keşif hikâyesinin tamamı bu. +* Herhangi bir işleyicide `get_access_token()`, kimin çağırdığını söyler. +* Yetkilendirme bir HTTP meselesidir. `stdio` ve bellek içi istemci onu hiç görmez. + +İstemci yarısı (yetkilendirme sunucunuzu keşfetme ve token'ı sizin yerinize alma) **[OAuth istemcileri](../client/oauth-clients.md)** sayfasında. Kullanıcıdan kimlik istemek yerine bir kimliği *beyan eden* istemci ise **[Kimlik beyanı](../client/identity-assertion.md)** sayfasında. diff --git a/i18n/tr/pages/run/deploy.md b/i18n/tr/pages/run/deploy.md new file mode 100644 index 0000000000..3155935d68 --- /dev/null +++ b/i18n/tr/pages/run/deploy.md @@ -0,0 +1,180 @@ +--- +translation: + sections: [28221886b198784f, f88ea1f1614f3a1d, ce926d686730b6d0, 3be24f8ad8bb5ab9, 3fad24032b2224ff, f25a7f860e579ecb, e758745df6fb7b0a] + tool: 1 +--- +# Dağıtım ve ölçekleme {#deploy-scale} + +Sunucunuz çalışıyor. Şimdi ona gerçek bir ana bilgisayar adı ve arkasında birden fazla worker gerekiyor. + +Bunların neredeyse hiçbiri MCP'nin işi değil. ASGI sunucusunu, süreç yöneticisini, yük dengeleyiciyi siz getirirsiniz. Bu sayfada olan, gerçekten MCP'nin işi *olan* şeylerin kısa listesi: her dağıtımın önünde duran tek bir ayar ve "birden fazla worker" ifadesinin SDK'nın davranışını değiştirdiği iki yer. + +## Her şeyden önce: Host izin listesi {#before-anything-else-the-host-allowlist} + +`streamable_http_app()` hangi ana bilgisayar adının arkasında sunulacağını bilemez, bu yüzden en güvenli yanıtı varsayar: localhost. `transport_security=` verilmediğinde uygulama **DNS-rebinding korumasını** açar ve bir isteği yalnızca `Host` başlığı `127.0.0.1:`, `localhost:` veya `[::1]:` ise kabul eder. `Origin` başlığı varsa, aynısının `http://` biçimi olmak zorundadır. Kendi makinenizde bu tam olarak doğru davranıştır: kötü niyetli bir web sayfasının, `127.0.0.1`'e yeniden bağladığı bir DNS adı üzerinden yerel sunucunuzu yönetmesini engeller. + +Gerçek bir ana bilgisayar adının arkasına dağıtıldığında, aynı varsayılan siz aksini söyleyene kadar **her isteği** reddeder. Denetim, MCP'ye benzeyen herhangi bir şey çalışmadan önce yapılır; yani sizin yazdığınız hiçbir şeye danışılmaz bile: + +```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 +``` + +Çözüm `transport_security=`. Gerçekten sunduğunuz şeyi izin listesine alın: + +```python title="server.py" hl_lines="2 13-17" +--8<-- "docs_src/deploy/tutorial001.py" +``` + +* `allowed_hosts` girdileri tam eşleşen dizgelerdir: `"mcp.example.com"` yalın bir `Host` başlığıyla, `"mcp.example.com:*"` ise herhangi bir portla eşleşir. İkisini de listeleyin. +* `allowed_origins` yalnızca tarayıcılar için önemlidir, çünkü başka hiçbir şey `Origin` göndermez. **[Mevcut bir uygulamaya ekleme](asgi.md)** sayfasındaki CORS yapılandırmasının sunucu tarafındaki ikizidir. +* `Host` başlığını zaten denetleyen bir ters vekil sunucunun arkasında, dürüst yapılandırma denetimi kapatmaktır: `TransportSecuritySettings(enable_dns_rebinding_protection=False)`. +* localhost dışında bir `host=` geçirmek (örneğin `host="mcp.example.com"`) o ana bilgisayar adını izin listesine **almaz**. Yalnızca localhost varsayılanının korumayı devreye sokmasını engeller; bu da her Host ve Origin'in kabul edilmesi demektir. Bunun yerine ne demek istediğinizi `transport_security=` ile söyleyin. + +!!! check + `transport_security=security` argümanını silin ve uygulamayı yine de dağıtın. Başlar, `/mcp` + yönlendirilir ve her istek (düz bir `curl` dahil) şöyle döner: + + ```text + HTTP/1.1 421 Misdirected Request + + Invalid Host header + ``` + + Bu sözcükleri istemci tarafında bulamazsınız. `421`, bir JSON-RPC hatası değil, düz metin bir + HTTP yanıtıdır; bu yüzden MCP istemcisi genel bir aktarım hatası fırlatır. Beğenmediği ana + bilgisayar adı yalnızca **sunucunun** log'unda, tek bir uyarı olarak görünür. Yeni dağıtılmış ve + her bağlantıyı reddeden bir sunucu, aksi kanıtlanana kadar bir Host izin listesi sorunudur. + **[Sorun giderme](../troubleshooting.md)** de buradan başlar. + +## Worker'lar ve kimin yapışkan olması gerektiği {#workers-and-who-has-to-be-sticky} + +Ana bilgisayar adı yanıt vermeye başladıktan sonra, arkasına birden fazla worker koyun. Bunun için SDK'da bir ayar yoktur; bir Starlette uygulamasını, herhangi bir ASGI uygulamasını ölçeklediğiniz gibi ölçeklersiniz: nesneyi, fork etmeyi bilen bir şeye verirsiniz: + +```console +uvicorn server:app --workers 4 +``` + +Dört süreç, tek bir soket. Ve şimdi her dağıtımın yanıtlaması gereken soru: **bir isteğin, bir öncekini gören worker'a ulaşması gerekiyor mu?** + +**2026-07-28** protokolünü konuşan bir istemci için, hayır. Modern bir istek, kendi içinde eksiksiz tek bir POST'tur: önünde `initialize` el sıkışması yok, yanıtta `Mcp-Session-Id` yok, ikinci bir isteğin geri *döneceği* hiçbir şey yok. Herhangi bir worker'a yönlendirin. + +Bu, açtığınız bir kip değildir. `stateless_http=True` öyle olmalıymış gibi görünür, ancak aktarım `MCP-Protocol-Version` istek başlığına göre yönlendirme yapar, modern bir isteği modern işleyiciye verir ve **döner**. `stateless_http`'yi okuyan satır bu dönüşten *sonra* gelir. Mesele bayrağın 2026-07-28 yolunda yok sayılması değil; o satıra hiç ulaşılmamasıdır. `stateless_http` yalnızca **eski nesil** bacak için bir ayardır; modern yol ise yapısı gereği oturumsuzdur. + +Spesifikasyonun 2025-11-25 veya daha eski bir sürümündeki eski nesil bir istemci için yanıt o bayrağa bağlıdır: + +| İstemcinin protokol sürümü | Oturum | Yük dengeleyicinin yapması gereken | +| --- | --- | --- | +| **2026-07-28** | Yok. `Mcp-Session-Id` hiçbir zaman ayarlanmaz. | Hiçbir şey. Herhangi bir worker herhangi bir isteğe hizmet verir. | +| **2025-11-25 ve öncesi** (varsayılan) | `Mcp-Session-Id`, tek bir worker'ın belleğinde tutulur. | **Yapışkan oturumlar.** Farklı bir worker'a ulaşan bir devam isteği `404` *"Session not found"* alır. | +| **2025-11-25 ve öncesi**, `stateless_http=True` ile | Yok. | Hiçbir şey. Bedeli, sunucudan istemciye geri kanal (back-channel) (örnekleme (sampling), itmeli elicitation, `roots/list`) ve devam ettirilebilirliktir. | + +Yapışkan oturumlar ve eski nesil bacağın bedeli kendi sayfasında: **[Eski nesil istemcilere hizmet verme](legacy-clients.md)**; iki neslin kendisi ise **[Protokol sürümleri](../protocol-versions.md)** sayfasında. Burada önemli olan yanıtın biçimi: *2026-07-28'de zaten durumsuzsunuz ve yapılandırılacak hiçbir şey yok.* + +Sayfanın geri kalanı, durumsuz olmanın size **sağlamadığı** iki şey. + +## Worker'lar arasında `requestState` {#requeststate-across-workers} + +**[Çok turlu](../handlers/multi-round-trip.md)** (multi-round-trip) bir araç, istemcinin gidip alması gereken bir şeye (bir onay, bir seçim, bir kimlik bilgisi) ihtiyaç duyar; bu yüzden bir yanıt yerine bir soru döndürür ve yeniden denemede işini bitirir. İki tur arasında istemci, sunucunun bastığı opak bir `request_state` token'ı tutar. Yeniden denemede sunucunun o token'ı yeniden açması gerekir. + +*Hangi anahtarla mühürlenmiş?* Varsayılan olarak, sunucunun oluşturulurken `os.urandom(32)` ile ürettiği bir anahtarla. `--workers 4` altında bu, dört süreçte dört oluşturma demektir: dört farklı anahtar, hiçbir yere yazılmamış, hiç paylaşılmamış, yeniden başlatmada kaybolan. + +İşte hiçbir şey yapılandırmayan bir sunucuda, harekete geçmeden önce soran bir araç: + +```python title="server.py" hl_lines="14 20" +--8<-- "docs_src/deploy/tutorial002.py" +``` + +İlk tur worker A'ya ulaşır. Worker A, `refund:120` değerini **kendi** anahtarıyla mühürler ve token'ı döndürür. İstemci soruyu bir insanın önüne koyar, evet yanıtını alır ve yeniden dener. Yeniden deneme yepyeni bir HTTP isteğidir. + +!!! check + O yeniden denemenin worker B'ye ulaşmasına izin verin. B, kendisinin basmadığı bir token'ın + mührünü açmaya çalışır, açamaz ve turun tamamını reddeder. `refund` hiç çağrılmaz; istemci bir + JSON-RPC hatası alır: + + ```json + { + "code": -32602, + "message": "Invalid or expired requestState", + "data": {"reason": "invalid_request_state"} + } + ``` + + Bu mesaj **sabittir**. Süresi dolmuş, kurcalanmış, farklı argümanlara karşı yeniden oynatılmış + ya da (gerçek bir dağıtımda açık ara en yaygın neden) kardeş bir worker tarafından mühürlenmiş + olsun: istemciye her seferinde aynı şey söylenir, böylece iletilen veri hangi denetimin başarısız + olduğunu asla açığa vurmaz. Gerçek neden, sunucunun log'unda tek bir `WARNING` satırıdır: + + ```text + requestState rejected on tools/call: unknown key + ``` + + Tek worker'la çalışıp ikide *ara sıra* başarısız olmaya başlayan çok turlu bir araç budur. İki + turun yine de aynı sürece ulaşması gerekir; bu yüzden tam olarak yük dengeleyicinizin onları + ayırdığı sıklıkta başarısız olur. + +İki tur iki bağımsız HTTP isteğidir ve onları birbirinden ayıran birçok sıradan şey vardır: istek başına dengeleyen bir vekil sunucu, arada kopan bir bağlantı, bir dağıtım ya da yeniden başlatma, `request_state`'i kalıcı olarak saklamış ve bambaşka bir süreçten devam eden bir istemci (**[Döngüyü kendiniz yürütme](../handlers/multi-round-trip.md#driving-the-loop-yourself)**). Bunların her biri "farklı bir worker" demektir. + +Çözüm tek bir argüman. Ancak **iki** yarısı var. + +```python title="server.py" hl_lines="1 12 14" +--8<-- "docs_src/deploy/tutorial003.py" +``` + +* **`keys=[...]`** herkesin bulduğu yarıdır. Her örneğe aynı gizli anahtarı (en az 32 bayt) verin; böylece her örnek, herhangi bir kardeşinin bastığı şeyin mührünü açabilir. `keys[0]` mühürler, listedeki her anahtar mühür açar; bu döndürme halkasıdır. Onu kesinti olmadan nasıl çevireceğiniz **[Anahtarları döndürme](../handlers/multi-round-trip.md#rotating-keys)** bölümünde. +* **Sunucunun adı** neredeyse kimsenin bulamadığı yarıdır ve anahtarı paylaştıktan sonra örnekler arası yeniden denemelerin hâlâ başarısız olmasının nedenidir. Her mühürlü token, sunucunun `name` değerini bir **audience claim** olarak taşır ve dönüşte katı biçimde denetlenir. Aynı koddan oluşturulmuş iki örneğin adı aynıdır ve bunu hiç fark etmezler. Onlara farklı adlar verin (`MCPServer(f"billing-{POD}")` iyi bir gözlemlenebilirlik alışkanlığı gibi okunur) ve her örnekler arası yeniden deneme, anahtar paylaşılmış olsun olmasın, tam olarak yukarıdaki gibi reddedilir. Log `unknown key` yerine `audience` der; istemci aradaki farkı anlayamaz. + +Gizli anahtarı bir kez basın ve her örneğe aynı değeri verin. 32 bayttan az geçirirseniz SDK'nın kendi hata mesajının çalıştırmanızı söylediği komut budur: + +```console +python -c "import secrets; print(secrets.token_hex(32))" +``` + +!!! warning "Aynı anahtarlar *ve* aynı ad" + Çok örnekli bir dağıtım ikisini de paylaşmak zorundadır. Örnek başına adlar sizin için + vazgeçilmezse, filoya bunun yerine tek bir açık audience verin: `RequestStateSecurity(keys=[...], audience="billing")`. + Böylece her örnek, adı ne olursa olsun `"billing"` altında basar ve kabul eder. + +Mühürle ilgili geri kalan her şey **[`requestState`'i koruma](../handlers/multi-round-trip.md#protecting-requeststate)** bölümünde: neyi bağladığı, tur başına `ttl` (varsayılan olarak 600 saniye), kendi codec'inizi getirme, yapılandırılmamış varsayılanın `stdio` üzerinde neden tam olarak doğru olduğu. Bu sayfanın tüm katkısı iki maddelik bir denetim listesi: *aynı anahtarlar, aynı ad.* + +!!! info + Hiç `InputRequiredResult` yazmamış olsanız bile bu yoldasınız. Parametreleri `Resolve(...)` + kullanan bir araç (**[Bağımlılıklar](../handlers/dependencies.md)**) çok turlu bir araçtır ve + SDK onun `request_state`'ini onun adına basar ve mühürler. Aynı varsayılan anahtar, worker'lar + arasında aynı başarısızlık, aynı çözüm. + +## Replikalar arasında değişiklik bildirimleri {#change-notifications-across-replicas} + +Bir istemcinin `subscriptions/listen` akışı uzun ömürlü tek bir yanıttır; bu yüzden tüm ömrü boyunca tek bir replikaya bağlı kalır. **Farklı** bir replikada yayımlanan bir `ctx.notify_resource_updated(...)` çağrısının ona ulaşması gerekir. + +İkisi arasındaki bağlantı noktası `SubscriptionBus`'tır. Bir sunucuya hangi bus'ı verirseniz, her yayının gittiği ve her açık akışın dinlediği bus odur; bu yüzden her replikaya aynı bus'ı verin: + +```python title="server.py" hl_lines="2 7 9" +--8<-- "docs_src/deploy/tutorial004.py" +``` + +Dağıtım (fan-out) tarafında hiçbir şey, bir akışın hangi sunucu nesnesine bağlı olduğuyla ilgilenmez. Tek bir `InMemorySubscriptionBus` tutan iki sunucu zaten böyle davranır: birinde bir listen akışı açın, diğerinde `edit_note`'u çağırın ve akış bundan haberdar olur. O bellek içi bus yalnızca tek bir süreç içindeki sunucu nesnelerini kapsar; bu da onu dağıtım değil, model yapar: + +* Gerçek süreçler arasında **SDK size yardımcı olabilecek hiçbir bus sunmaz.** `SubscriptionBus`, kendi pub/sub altyapınız (Redis, NATS, zaten çalıştırdığınız her neyse) üzerinde gerçeklediğiniz ve `MCPServer(subscriptions=...)` olarak geçirdiğiniz iki metotlu bir `Protocol`'dür (`publish` ve `subscribe`). Taslak ve sözleşme **[Abonelikler](../handlers/subscriptions.md#scaling-past-one-process)** sayfasında. +* Bus dört küçük tipli olay taşır, asla JSON-RPC taşımaz. Onaylama, filtreleme ve akış yaşam döngüsü SDK'da kalır; bu yüzden bus'ınız protokolü bozamaz, yalnızca olayları süreçler arasında taşıyabilir. +* Akışlar devam ettirilebilir **değildir** ve olaylar yeniden **oynatılmaz**. Bir replikayı kaybetmek akışlarını düşürür; istemciler yeniden dinler ve yeniden getirir. Paylaşılacak bir olay deposu ve yapılandırılacak başka bir şey yoktur. Ölçeklemenin gerçekten yalnızca aynısının fazlası olduğu tek yer burası. + +## SDK'nın size vermedikleri {#what-the-sdk-does-not-give-you} + +Bir `MCPServer` bir uygulama sunucusu değil, bir protokol gerçeklemesidir. Bundan sonra aramaya çıkacağınız dağıtım ayarları bilerek eksiktir: + +* **`workers=` yok.** `mcp.run("streamable-http")` tam olarak bir uvicorn süreci başlatır ve başlatacağı tek şey de odur. Çoklu süreç, `streamable_http_app()`'in ASGI'yi zaten neyle dağıtıyorsanız ona verilmesidir: `uvicorn --workers`, gunicorn, platformunuzun süreç yöneticisi. Bu sayfa bilerek onların hiçbiri için bir öğretici değildir; kendi belgeleri, buradaki bir kopyanın olacağından daha iyidir. +* **Sağlık denetimi rotası yok.** `@mcp.custom_route("/health", methods=["GET"])` yanıtın tamamıdır ve sunucunun geri kalanı kimlik doğrulamalı olsa bile bu rota asla kimlik doğrulaması yapmaz. Bu, bir canlılık yoklaması için doğru, özel olan herhangi bir şey için yanlıştır. **[Mevcut bir uygulamaya ekleme](asgi.md#custom-routes)** bir örnek gösterir. +* **Üretim ayarları nesnesi yok.** `MCPServer` üzerinde zaman aşımlarını, TLS'yi, zarif kapanmayı ya da bağlantı sınırlarını yazabileceğiniz bir yer yoktur, çünkü bunların hiçbiri onun işi değildir. ASGI sunucunuza aittirler ve onları orada yapılandırırsınız. Yapıcının *aldığı* bir avuç ayar **[Sunucunuzu çalıştırma](index.md)** sayfasında. +* **Sunulan bir `EventStore` yok, 2026-07-28'de buna gerek de yok.** Devam ettirilebilirlik, eski nesil durumlu bacağın bir özelliğidir; modern bir alışveriş tek bir POST, tek bir yanıt ve devam ettirilecek hiçbir şeydir. + +## Özet {#recap} + +* Varsayılan olarak uygulama yalnızca localhost'a gönderilen istekleri yanıtlar. `transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])` yayına çıkış kapısıdır: onu geçirene kadar gerçek bir ana bilgisayar adının arkasındaki her istek bir `421`'dir ve nedeni yalnızca sunucunun log'undadır. +* 2026-07-28'de oturum yoktur ve bir yük dengeleyicinin yapışacağı hiçbir şey yoktur. `stateless_http=True` yalnızca eski nesle ait bir ayardır, çünkü modern bir istek o bayrak hiç okunmadan yönlendirilir ve yanıtlanır. +* Varsayılan `requestState` anahtarı, süreç başına basılan `os.urandom(32)`'dir. Farklı bir worker'a ulaşan çok turlu bir yeniden deneme `-32602` *"Invalid or expired requestState"* ile başarısız olur. +* Çözüm `RequestStateSecurity(keys=[...])` **ve** her örnekte aynı sunucu adıdır. Ad, token'ın varsayılan audience claim'idir. Aynı anahtarlar, aynı ad. +* Değişiklik bildirimleri replikalar arasında paylaşılan tek bir `SubscriptionBus` üzerinden geçer. SDK'nın tek gerçeklemesi süreç içidir; kendi pub/sub'ınız üzerindeki iki metotlu `Protocol`'ü yazmak size düşer. +* `workers=` yok, sağlık rotası yok, üretim ayarları nesnesi yok. Kendi ASGI sunucunuzu getirin. + +Gerçek bir ana bilgisayar adının önünde gereken diğer şey bir token: **[Yetkilendirme](authorization.md)**. diff --git a/i18n/tr/pages/run/index.md b/i18n/tr/pages/run/index.md new file mode 100644 index 0000000000..541ab79cc7 --- /dev/null +++ b/i18n/tr/pages/run/index.md @@ -0,0 +1,156 @@ +--- +translation: + sections: [fea8d769ff9edeba, ce8e2ad42f29ef71, 0d705efb19cf99c2, 7a53ead3e704a7f0, 9adc400e8c88e854, 318893ad8e2e9924, 6b63ab96b34476c0] + tool: 1 +--- +# Sunucunuzu çalıştırma {#running-your-server} + +`mcp.run()` sunucuyu başlatır. + +Vermeniz gereken tek karar **aktarım**: sunucunuzla istemcisi arasındaki baytların gerçekte nasıl taşındığı. + +## Aktarım seçme {#pick-a-transport} + +| Aktarım | Ne olduğu | Ne zaman | +|---|---|---| +| `stdio` | Host, dosyanızı bir alt süreç olarak başlatır ve onunla stdin ve stdout'u üzerinden konuşur. | Yerel sunucular. Varsayılan. | +| `streamable-http` | Bir portu dinleyen gerçek bir HTTP sunucusu. | Dağıttığınız her şey. | +| `sse` | Eski HTTP aktarımı. | Hiçbir zaman. | + +!!! warning + SSE, 2025-03-26 protokol sürümünde yerini Streamable HTTP'ye bıraktı. + `mcp.run(transport="sse")` kendi `sse_path=` ve `message_path=` seçenekleriyle hâlâ çalışır, + ancak yalnızca henüz geçiş yapmamış istemciler için vardır. Üzerine yeni bir şey inşa etmeyin. + +## `mcp.run()` {#mcprun} + +```python title="server.py" hl_lines="12-13" +--8<-- "docs_src/run/tutorial001.py" +``` + +* `run()` senkrondur. Sunucunun ömrü boyunca bloke kalır. +* Argüman verilmezse aktarım `stdio` olur. +* `if __name__ == "__main__":` altında durur, çünkü sunucunuzu yükleyen her şey (`mcp dev`, `mcp run`, `mcp install`, testleriniz) bu dosyayı **içe aktarır**. Bu koruma, bir içe aktarmanın çalışan bir sunucuya dönüşmesini engeller. + +### stdio {#stdio} + +Yapılandırılacak hiçbir şey yok. Host, dosyanızı bir alt süreç olarak başlatır, istekleri stdin'ine yazar ve yanıtları stdout'undan okur. + +Kendiniz çalıştırın, sonucunu görürsünüz: + +```console +python server.py +``` + +Hiçbir şey yazdırmaz ve geri dönmez. Bir host'un ilk sözü söylemesini stdin'de bekliyordur. + +Bu aynı zamanda stdout'un **iletişim hattının ta kendisi** olduğu anlamına gelir. Hizmet verirken SDK bu hattı özel bir dosya tanımlayıcısına taşır ve stdout'a *flush edilen* çıktıyı (miras aldığı stdout'a yazan bir alt süreç, flush edilmiş bir `print()`) akışı bozamayacağı stderr'e yönlendirir. Hizmet başlamadan *önce* stdout'a flush edilen çıktı (echo yapan bir sarmalayıcı betik, import sırasında tamponlanmadan yapılan bir print) yine hatta düşer; yorumlayıcı çıkışta boşaltana kadar tamponda kalan bir `print()` de öyle. Gerçekten istediğiniz çıktı için doğru araç `logging` modülüdür: işleyicisi her kaydı oluştuğu anda stderr'e flush eder. Ayrıntıların tamamı **[Log tutma](../handlers/logging.md)** sayfasında. + +### Deneyin {#try-it} + +```console +uv run mcp dev server.py +``` + +Inspector, gerçek bir host'un yaptığının aynısını yapar: `server.py` dosyasını bir alt süreç olarak başlatır ve ona stdio üzerinden bağlanır. + +Ona hiç port vermediniz. Zaten yok. + +## Streamable HTTP {#streamable-http} + +Aynı sunucuyu bunun yerine bir porta koymak için aktarımı (ve seçeneklerini) `run()` içinde belirtin: + +```python title="server.py" hl_lines="13" +--8<-- "docs_src/run/tutorial002.py" +``` + +Bu tek satır bir Starlette uygulaması kurar ve onu uvicorn ile sunar. İstemciler `http://127.0.0.1:3001/mcp` adresine bağlanır. + +Her aktarımın kendi anahtar sözcük argümanları vardır ve hepsi `run()` üzerindedir: + +* `host` / `port`: nerede dinleneceği. Varsayılanlar `127.0.0.1` ve `8000`. +* `streamable_http_path`: MCP endpoint'inin bulunduğu yol. Varsayılan `/mcp`. +* `json_response=True`: her POST'a SSE akışı yerine tek bir JSON gövdesiyle yanıt verir. Bu gövdede yanıttan başka hiçbir şeye yer yoktur; bu yüzden istek sırasında istemciye geri çağrı yapan bir araç (`ctx.elicit()`, örnekleme (sampling)) bu ayakta `NoBackChannelError` fırlatır ve sürmekte olan çağrıya bağlı bildirimler (`ctx.report_progress()` ile bildirilen ilerleme, çağrıya özel log mesajları) düşürülür; bağımsız `GET` akışı ilgisiz olanları taşımaya devam eder. +* `stateless_http=True`: istek başına yeni bir aktarım, oturum takibi yok. +* `max_request_body_size`: bayt cinsinden kabul edilen en büyük POST gövdesi. Varsayılan olarak 4 MiB; + daha büyük istekler, ayrıştırma veya oturum oluşturma öncesinde HTTP 413 alır. Bunu yalnızca meşru + MCP mesajları bu boyutu aştığında yükseltin. +* `event_store`, `retry_interval`, `transport_security`: kaldığı yerden devam edebilme ve DNS rebinding koruması. localhost dışında bir yere dağıtım yapana kadar bekleyebilirler; `transport_security` konusunu **[Dağıtım ve ölçekleme](deploy.md)** ele alır. + +!!! warning + Aktarım seçenekleri `run()`'a gider, `MCPServer(...)`'a **değil**. Kurucu, sunucunuzun ne + *olduğunu* tanımlar: ad, sürüm, talimatlar. `run()` ise nasıl sunulduğunu tanımlar. Bunu + tersine çevirirseniz, daha MCP devreye bile girmeden Python yanıt verir: + + ```text + TypeError: MCPServer.__init__() got an unexpected keyword argument 'port' + ``` + +`run()` kısa yoldur. Daha fazlasına ihtiyaç duyduğunuz an (sunucunuzun mevcut bir uygulamanın içine mount edilmesi, tek süreçte iki sunucu, tarayıcı istemcileri için CORS) ASGI uygulamasını kendiniz kurar ve herhangi bir ASGI sunucusuna teslim edersiniz. Bu da **[Mevcut bir uygulamaya ekleme](asgi.md)** sayfasının konusu. + +## Sunucu ayarları {#server-settings} + +Çalıştırmayla ilgili birkaç şey aktarımla ilgili değildir. Bunlar kurucu argümanlarıdır: + +```python title="server.py" hl_lines="3" +--8<-- "docs_src/run/tutorial003.py" +``` + +* `log_level`: `MCPServer(...)` kurulduğu anda `logging.basicConfig()` fonksiyonuna verilir. Bu, **kök** logger'ı yapılandırır; dolayısıyla yalnızca SDK'nınkilerin değil, kendi logger'larınızın düzeyini de belirler. Varsayılan `"INFO"`. +* `debug`: HTTP aktarımlarının kurduğu Starlette uygulamasına iletilir. Varsayılan `False`. + +Her ikisi de çalışma zamanında geri okuyabileceğiniz `mcp.settings` üzerine yerleşir. + +## `mcp` komutu {#the-mcp-command} + +`[cli]` ekstrası tüm bunların etrafına küçük bir komut satırı aracı kurar. + +`mcp dev`, sunucunuzu **MCP Inspector** altında çalıştırır: + +```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`, kurduğu ortama paket ekler; `--with-editable` kendi paketinizi o ortama kurar. `PATH` değişkeninizde `npx` bulunmalıdır: Inspector bir Node.js uygulamasıdır. + +`mcp run` dosyayı içe aktarır, sunucu nesnesini (modül düzeyinde bir `mcp`, `server` veya `app`) bulur ve üzerinde `run()` çağırır: + +```console +uv run mcp run server.py +uv run mcp run server.py:bookshop +``` + +`:` soneki, nesnenin adı `mcp`, `server` veya `app` olmadığında onu belirtir. + +`if __name__ == "__main__":` bloğunuz burada hiç çalışmaz: `mcp run`, `run()`'ı kendisi çağırır ve ilettiği tek seçenek `--transport` seçeneğidir. + +`mcp install` sunucuyu **Claude Desktop**'a kaydeder; böylece uygulama onu sizin için başlatır: + +```console +uv run mcp install server.py --name "Bookshop" +uv run mcp install server.py -v API_KEY=abc123 -f .env +``` + +`-v KEY=VALUE` ve `-f .env`, ortam değişkenlerini bu kayda işler. Claude Desktop sunucunuzu kendi sürecinde başlatır. Kabuğunuzun ortamı orada yoktur. + +Claude Desktop, `mcp install` komutunun bildiği tek host'tur. Diğer tüm host'lar (Claude Code, Cursor, VS Code) aynı başlatma komutunu kendi yapılandırma dosyalarında alır; her biri **[Gerçek bir host'a bağlanma](../get-started/real-host.md)** sayfasında var. + +`mcp version` kurulu SDK sürümünü yazdırır. + +!!! tip + `mcp dev` ve `mcp run` yalnızca `MCPServer`'ı anlar. Düşük seviyeli `Server` ile geliştiriyorsanız + onu kendiniz çalıştırırsınız. Bkz. **[Düşük seviyeli Server](../advanced/low-level-server.md)**. + +## Özet {#recap} + +* **Aktarım**, baytların sunucunuza nasıl ulaştığıdır: yerel bir alt süreç için `stdio`, bir port için `streamable-http`. SSE'nin yerini yenisi aldı. +* `mcp.run()` aktarımı seçer. Argümansız `stdio`'dur ve bloke kalır. +* Her aktarım seçeneği (`host`, `port`, `streamable_http_path`, ...) `run()`'a verilen bir argümandır, asla `MCPServer(...)`'a değil. +* `run()`'ı `if __name__ == "__main__":` altında tutun. Sunucunuzu yükleyen her şey önce dosyayı içe aktarır. +* `log_level=` ve `debug=` kurucu argümanlarıdır; `mcp.settings` üzerine yerleşirler. +* Inspector için `mcp dev`, bir dosyayı çalıştırmak için `mcp run`, Claude Desktop için `mcp install`, sürüm için `mcp version`. +* Aktarım, sunucunuzun ne *olduğunu* asla değiştirmez: bu sayfadaki üç dosya da birebir aynı aracı sunar. + +Sınır `run()`'ın kendisi olduğunda (sunucunuz zaten var olan bir uygulamanın içindeyse) adres **[Mevcut bir uygulamaya ekleme](asgi.md)**. Gerçek bir ana bilgisayar adı ve birden fazla worker **[Dağıtım ve ölçekleme](deploy.md)** sayfasında. İstemcilerinizden bazıları hâlâ 2025-11-25 veya daha eski bir spesifikasyon sürümündeyse, iyi haber **[Eski nesil istemcilere hizmet verme](legacy-clients.md)** sayfasında. diff --git a/i18n/tr/pages/run/legacy-clients.md b/i18n/tr/pages/run/legacy-clients.md new file mode 100644 index 0000000000..da62841d35 --- /dev/null +++ b/i18n/tr/pages/run/legacy-clients.md @@ -0,0 +1,137 @@ +--- +translation: + sections: [3d1663c18edc824c, d4fd37009a13f03d, af9f398a5a8b679a, 470c2dd144294d69, 8e45827e6d24e8c8, 91dfd0ce98ebb03c] + tool: 1 +--- +# Eski nesil istemcilere hizmet verme {#serving-legacy-clients} + +MCP'nin iki protokol nesli var: `2025-11-25` spesifikasyon sürümüne kadar uzanan `initialize` el sıkışması nesli ve modern nesil olan `2026-07-28`. Bu ayrımın kendisini anlatan sayfa **[Protokol sürümleri](../protocol-versions.md)**. + +Bu sayfa o ayrımın sunucu tarafını ele alır ve yanıt tek bir cümleye sığar: **zaten dağıttığınız `streamable_http_app()` her ikisine de hizmet verir.** + +SDK her isteği `MCP-Protocol-Version` başlığına göre yönlendirir. `2026-07-28` belirten bir istek modern işleyiciye gider. El sıkışması neslinden bir sürüm belirten ya da hiç başlık taşımayan bir istek (2026 öncesi bir istemcinin `initialize` isteği tam da böyle gelir), o istemcilerin beklediği aktarıma gider: `initialize` el sıkışması, oturumlar, hepsi. Bu, istek başına, kodunuzdan önce ve o tek uygulama üzerinde olur. + +Yani eski nesil istemci, *ona göre* bir şey inşa ettiğiniz bir hedef değil. Zaten yazdığınız sunucuya *bağlanan* bir şey. Hiçbir şey yapılandırmazsınız. + +!!! note + Kelimenin tam anlamıyla hiçbir şey. `legacy=` diye bir seçenek yok, sürüm izin listesi yok, + bir nesli reddetmenin ya da devre dışı bırakmanın yolu yok: ne `streamable_http_app()` + üzerinde, ne `run()` üzerinde, ne de oturum yöneticisinde. İki nesil de her zaman açık. O + imzada nesle özgü bir anahtara en yakın şey `stateless_http`, ve bu sayfanın büyük kısmı da + ondan ibaret. + +## Tek işleyici, iki nesil {#one-handler-both-eras} + +İşte kullanıcıya bir şey sorması gereken bir araç ve onu çağıran her iki nesilden istemci: + +```python title="server.py" hl_lines="24 37-38" +--8<-- "docs_src/legacy_clients/tutorial001.py" +``` + +`reserve`, modelin sağlamadığı tek bir şeye ihtiyaç duyar: kaç kopya. Bir araç bunu `Annotated[..., Resolve(ask_quantity)]` ile bildirir (ayrıntıların tamamı **[Bağımlılıklar](../handlers/dependencies.md)** sayfasında). `reserve` içinde hiçbir şey bir sürüm adı vermez, bir yetenek kontrol etmez ya da dallanmaz. + +İki istemci **aynı anda**, aynı `mcp` nesnesi üzerinde açıktır. `mode="legacy"`, `initialize` el sıkışmasını çalıştırır: 2026 öncesi bir istemcinin açtığı bağlantının ta kendisi. Diğeri varsayılanı alır ve `2026-07-28` sürümünde karar kılar. + +```text +2025-11-25 {'result': "Reserved 2 of 'Dune'."} +2026-07-28 {'result': "Reserved 2 of 'Dune'."} +``` + +Aynı sunucu, aynı işleyici, aynı yanıt. Özelliğin tamamı bu. + +*Nasıl* olduğu üzerinde durmaya değer, çünkü iki istemciye aynı soru bambaşka iki yoldan soruldu. `2026-07-28` bağlantısında sunucunun istek gönderebileceği bir kanal yoktur; bu yüzden `Resolve` soruyu araç sonucunun içinde döndürdü ve istemci çağrıyı yanıtla birlikte yeniden denedi (**[Çok turlu istekler (multi-round-trip)](../handlers/multi-round-trip.md)**). `2025-11-25` bağlantısında böyle bir şey yoktur; orada `Resolve`, çağrının ortasında canlı bir `elicitation/create` isteği gönderdi ve bekledi. İkisini de siz yazmadınız. `Resolve` bağlantının anlaşılan sürümünü okur ve seçer; araç gövdeniz her iki durumda da bir `AcceptedElicitation` görür. + +!!! tip + Nesiller arası bu taşınabilirlik, `Resolve`'un üzerine inşa edilecek API olmasının + *nedenidir*. Eski kardeşi `ctx.elicit()` + (**[Elicitation (kullanıcıdan bilgi isteme)](../handlers/elicitation.md)**) yalnızca + `elicitation/create` gönderir; dolayısıyla yalnızca eski nesil bir bağlantıda çalışır. + `2026-07-28` bağlantısında çağrı başarısız olur. Bir araç hâlâ onu kullanıyorsa çözüm bir + sürüm kontrolü değil, yukarıda gördüğünüzdür. + +## Eski nesil bir oturumun size maliyeti {#what-a-legacy-session-costs-you} + +Yönlendirme bedava. Oturum değil. + +`2026-07-28` bağlantısı **oturumsuzdur**: her istek tek başına durur ve modern işleyici asla `Mcp-Session-Id` vermez. Eski nesil bağlantı bunun tam tersidir. 2026 öncesi bir istemci `initialize` gönderdiği anda SDK bir `Mcp-Session-Id` üretir, onu bir yanıt başlığında döndürür ve istemcinin sonraki isteklerinin bulabilmesi için arkasında canlı bir kayıt tutar: anlaşılan sürüm, açık akışlar, oturumu yürüten bir arka plan görevi. + +Bu kayıt **süreç içi, düz bir `dict`'tir**. Dağıtık bir oturum deposu yoktur ve bir tane takmanın yolu da yoktur. + +Tek worker'da bu görünmez. İki worker'da ise sorunun tamamı budur: `Mcp-Session-Id` taşıyan ve onu üretmemiş bir worker'a düşen bir istek o dict'te hiçbir şey bulamaz ve yanıt araç sonucu değil, bir `404` (`Session not found`) olur. Yani birden fazla worker çalıştırdığınız anda **eski nesil istemciler yapışkan yönlendirmeye (sticky routing) ihtiyaç duyar**: bir oturumdaki her istek, onu başlatan sürece ulaşmak zorundadır. Modern istemcilerin buna hiç ihtiyacı olmaz; yapışacakları bir oturumları yoktur. Yapışkanlığı ve bunlardan birden fazlasını çalıştırmaya dair geri kalan her şeyi **[Dağıtım ve ölçekleme](deploy.md)** sayfası ele alır. + +!!! warning + `event_store=` çözüm gibi görünür ama değildir. O bir oturum deposu değil, + **devam ettirilebilirliktir** (kaçırılan SSE olaylarını *aynı* oturuma yeniden bağlanan bir + istemciye yeniden oynatmak). Bir oturumu asla başka bir süreçten erişilebilir kılmaz. + +## Tek ayar düğmesi: `stateless_http` {#the-one-knob-stateless_http} + +Yapışkanlık ödemeyi reddettiğiniz bir bedelse, değiştirebileceğiniz tam olarak tek bir şey var. + +```python title="server.py" hl_lines="28" +--8<-- "docs_src/legacy_clients/tutorial002.py" +``` + +Bu, sayfanın başındaki sunucuya tek bir anahtar sözcük eklenmiş hali. `stateless_http=True`, eski nesil kolun bunun yerine istek başına, kullan-at bir oturum kurmasını sağlar: `Mcp-Session-Id` verilmez, istekler arasında hiçbir şey hatırlanmaz; böylece herhangi bir worker herhangi bir isteğe hizmet verebilir ve yük dengeleyici canı ne isterse onu yapabilir. + +Onunla ilgili iki şey, ne yaptığından daha önemli. + +**Yalnızca eski nesil kola dokunur.** İstekler, `stateless_http` okunmadan *önce* sürüm başlığına göre yönlendirilir; bu yüzden modern yol onu hiç görmez. `2026-07-28` bağlantısı zaten oturumsuzdur ve her iki değerde de tıpatıp aynıdır. + +**O kolda sunucudan istemciye giden her iki kanala da mal olur.** Tek bir `POST` boyunca yaşayan bir oturumun, sunucunun istek itebileceği bir akışı da bildirim itebileceği bağımsız bir akışı da yoktur. Sunucunun başlattığı her istek `NoBackChannelError` fırlatır: `ctx.elicit()`, emekliye ayrılmış örnekleme (sampling) ve kök dizinler (roots) çağrıları (**[Kullanım dışı özellikler](../deprecated.md)**) ve evet, *eski nesil* bir istemciye sorusunu soran `Resolve` da. Bildirimler bir hata bile almaz; sessizce düşürülür. + +!!! note + `json_response=True` o düğme değildir ama aynı bedelin yarısını *her* eski nesil oturumda + öder: tek bir JSON gövdesiyle yanıtlanan bir `POST`'un istek kapsamlı kanal için akışı + yoktur; bu yüzden istek ortasındaki bir `ctx.elicit()` aynı `NoBackChannelError` istisnasını + fırlatır ve istekle ilişkili bildirimler düşürülür. Oturumun bağımsız akışına dokunulmaz: + ilgisiz bildirimler gelmeye devam eder. + +!!! check + Yanlış olanı yapın. `reserve`, az önce iki istemciye de hizmet veren aracın ta kendisi. Onu + `stateless_http=True` ile dağıtın, aynı iki istemciyi HTTP üzerinden bağlayın ve her birinden + çağırın. + + Modern istemci hâlâ `Reserved 2 of 'Dune'.` alır. Modern kol değişmedi. + + Eski nesil istemcinin çağrısı, modelin okuyabileceği bir `is_error` sonucu olarak geri + dönmez. İsteğin tamamı, üst düzey bir protokol hatası olarak başarısız olur: + + ```text + mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests. + ``` + + `Resolve` sizi kurtarmadı. `2025-11-25` bağlantısında `elicitation/create` göndermek + *zorundadır* ve ihtiyaç duyduğu kanal, `stateless_http=True`'nun elden çıkardığı şeyin ta + kendisidir. Nesiller arası taşınabilir kod, geri kanala (back-channel) ihtiyaç duymayan kod + demek değildir. + +Yani bu gerçek bir ödünleşmedir ve yalnızca eski nesil kolda vardır: **oturumlu ve yapışkan, ya da durumsuz ve tek yönlü.** Araçlarınız hiçbir zaman istemciye geri çağrı yapmıyorsa `stateless_http=True` bedavadır ve almalısınız. Yapıyorlarsa oturumları koruyun ve yönlendirmeyi yapışkan tutun. + +## Kodunuzun gerçekten çatallandığı yer {#where-your-code-actually-forks} + +Neredeyse hiçbir yerde. + +Araçlar, kaynaklar, prompt'lar, yapılandırılmış çıktı, ilerleme, hatalar: hiçbiri hangi neslin çağırdığını umursamaz. `initialize` el sıkışması, `Mcp-Session-Id`, bağımsız akış, bir oturumu bitiren `DELETE`: hepsinin sahibi SDK'dır ve bir işleyici bunların hiçbirini görmez. Etkileşimli girdi, nesillerin iletilen veride gerçekten ayrıştığı *tek* yerdir ve `Resolve` bunun sizin sorununuz olmaması için vardır: az önce tek bir aracın ikisine de hizmet verdiğini izlediniz. + +Geriye tam olarak tek bir şey kalıyor, o da **değişiklik bildirimleri**; çünkü iki nesil farklı borulardan dinler: + +* `2026-07-28` istemcisi bir `subscriptions/listen` akışı açar ve abonelik veri yolunu okur. `ctx.notify_resource_updated()` (ve `notify_tools_changed()`, `notify_prompts_changed()`, `notify_resources_changed()`) oraya, ve *yalnızca* oraya yayımlar. Bunun sayfası **[Abonelikler](../handlers/subscriptions.md)**. +* Eski nesil bir istemci, oturumunun açık tuttuğu bağımsız akışı okur. `ctx.session.send_resource_updated()` (ve `send_tool_list_changed()` ile benzerleri) isteği taşıyan *bağlantıya* yazar: eski nesil bir oturum için bu, onun bağımsız akışıdır. Modern bir bağlantıda bunun yeri yoktur: HTTP üzerinde böyle bir kanal yoktur, stdio üzerinde ise dört değişiklik bildirimi türü yalnızca `subscriptions/listen` akışlarında taşınır; bu yüzden modern bir bağlantıda bildirim sessizce düşürülür. + +HTTP üzerinde iki çağrı da diğer neslin istemcilerine ulaşmaz. Herkese haber vermek için ikisini de çağırın: + +```python title="server.py" hl_lines="19-20" +--8<-- "docs_src/legacy_clients/tutorial003.py" +``` + +İki satır, `if` yok, sürüm kontrolü yok, ve işiniz bitti. Eski nesil bir istemci var diye bir işleyicinin farklı yaptığı şeylerin listesinin tamamı bu. + +## Özet {#recap} + +* Tek bir `streamable_http_app()` iki protokol nesline de hizmet verir. SDK her isteği `MCP-Protocol-Version` başlığına göre yönlendirir; yapılandırılacak bir şey ve aranacak bir nesil düğmesi yoktur. +* Eski nesil bir istemci size bir oturuma mal olur: arkasında dağıtık bir depo olmayan, süreç içi bir `Mcp-Session-Id` kaydı. Birden fazla worker **yapışkan yönlendirme** demektir; aksi halde yanlış worker `404 Session not found` yanıtını verir. Çoklu worker'a dair ayrıntıların tamamı **[Dağıtım ve ölçekleme](deploy.md)** sayfasında. +* Tek düğme `stateless_http=True`'dur ve **yalnızca eski nesil kolu etkiler**. Eski nesil istemciler için bedava yük dengelemeyi, o koldaki sunucudan istemciye giden her iki kanal pahasına satın alır: sunucunun başlattığı istekler `NoBackChannelError` fırlatır (istemcide `is_error` sonucu değil, üst düzey bir hata) ve bildirimler düşürülür. +* `2026-07-28` bağlantısı her durumda oturumsuzdur. `stateless_http` ona hiç dokunmaz. +* İşleyici kodunuz nesle göre tam olarak tek bir yerde çatallanır: değişiklik bildirimleri. `ctx.notify_*` `subscriptions/listen` istemcilerine ulaşır; `ctx.session.send_*` eski nesil oturumlara ulaşır. İkisini de çağırın. +* Geri kalan her şey (`Resolve` aracılığıyla kullanıcıdan girdi istemek dahil) tasarımı gereği nesiller arası taşınabilirdir. Modern olanı bir kez yazın. diff --git a/i18n/tr/pages/run/opentelemetry.md b/i18n/tr/pages/run/opentelemetry.md new file mode 100644 index 0000000000..df37467046 --- /dev/null +++ b/i18n/tr/pages/run/opentelemetry.md @@ -0,0 +1,85 @@ +--- +translation: + sections: [bc0227014724fa49, 15738c2f7fd67d86, a2c17bbe3f707e2f, d0d853376f162c06, b6368643fcc1c8d8, 902e33e17564a607] + tool: 1 +--- +# OpenTelemetry {#opentelemetry} + +Sunucunuz zaten izleniyor. Hiçbir şey eklemeniz gerekmez. + +Oluşturduğunuz her sunucu, işlediği her mesaj için bir [OpenTelemetry](https://opentelemetry.io/) span'ı üretir. Bunu siz yazmadınız, içe de aktarmıyorsunuz. `MCPServer(...)`'ı çağırdığınız anda oradadır. + +```python title="server.py" +--8<-- "docs_src/opentelemetry/tutorial001.py" +``` + +Bu, eksiksiz ve izlenen bir sunucu. `search_books`'u çağırın, onun için bir span oluşturulur. Aynısı düşük seviyeli `Server` için de geçerlidir: izleme her ikisinde de bulunur. + +## Elde ettikleriniz {#what-you-get} + +Gelen her mesaj, adını yöntemden ve hedefinden alan bir `SERVER` span'ına dönüşür. Yani `search_books` için yapılan bir `tools/call`, `tools/call search_books` span'ıdır; yalın bir `tools/list` ise yalnızca `tools/list` olur. + +Her span birkaç öznitelik taşır: + +* `mcp.method.name` ve `mcp.protocol.version`, her span'da. +* `jsonrpc.request.id`, isteklerde (bildirimlerde yoktur). +* İstisna fırlatan bir işleyici span durumunu hata olarak ayarlar. `is_error=True` içeren bir araç sonucu da öyle. + +Araç çağrılarını izlemek çok sık istenen bir şey olduğundan, `tools/call` span'ları OpenTelemetry'nin [GenAI anlamsal kurallarına](https://opentelemetry.io/docs/specs/semconv/gen-ai/) uyar: + +* `gen_ai.operation.name`, `"execute_tool"` olarak ayarlanır. +* `gen_ai.tool.name`, çağrılan aracın adına ayarlanır. + +Aynı mantıkla bir `prompts/get` span'ı `gen_ai.prompt.name` alır. Listeleme yöntemleri hiçbir `gen_ai.*` anahtarı taşımaz, çünkü adlandırılacak bir şey yoktur. + +!!! tip + Bir izleme arayüzünün araç çağrılarınızı başka herhangi bir ajanınkileri grupladığı gibi gruplamasının nedeni bu GenAI öznitelikleridir. Bu gruplama size bedelsiz gelir; fazladan kod gerekmez. + +## Siz isteyene kadar hiçbir maliyeti yok {#it-costs-nothing-until-you-want-it} + +"Varsayılan olarak açık" tercihini rahat bir varsayılan yapan kısım burası. + +SDK yalnızca OpenTelemetry'nin hafif yarısı olan `opentelemetry-api` paketine bağımlıdır. OpenTelemetry SDK'sı ve bir exporter kurulu değilken span oluşturmak etkisiz bir işlemdir. Yani sunucunuzun şu anda ürettiği span'ların size maliyeti neredeyse sıfırdır ve onları kimse toplamıyor. + +Onları *görmek* istediğiniz gün diğer yarıyı kurar ve bir yere yönlendirirsiniz: + +```console +uv add opentelemetry-sdk opentelemetry-exporter-otlp +``` + +Bir exporter'ı alışıldık OpenTelemetry yöntemiyle yapılandırın; SDK'nın sessizce oluşturduğu her span görünür hale gelir. Sunucu kodunuz değişmez. Tek bir satır bile. + +!!! info + [Pydantic Logfire](https://logfire.pydantic.dev/) bu tür arka uçlardan biridir ve yapılandırmayı sizin yerinize yapar: `pip install logfire`, `logfire.configure()`, ardından MCP span'larınız canlı görünümde belirir. OpenTelemetry üzerine kuruludur, bu yüzden aşağıdaki her şey onun için de geçerlidir. + +## Ağı aşan izler {#traces-that-cross-the-wire} + +Bir iz en çok, bir isteği istemciden sunucunun içine kadar tek ve bağlantılı bir resimde takip ettiğinde işe yarar. + +İstemci de sunucu da SDK'yı çalıştırıyorsa bu bağlantı otomatik kurulur. İstemci [W3C iz bağlamını](https://www.w3.org/TR/trace-context/) isteğe ekler, sunucu da onu geri okur; böylece sunucu span'ı aynı iz içinde istemci span'ının altına yerleşir. Bunun adı [SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414) ve siz istemeden gelir. + +Gelen mesaj iz bağlamı taşımıyorsa, örneğin SDK olmayan bir istemciden gelen bir istekte, sunucu span'ı yepyeni ve sahipsiz bir iz başlatmak yerine sunucuda o an geçerli olan span'ın altına bağlanır. + +## Kapatma {#turning-it-off} + +İzleme bir middleware'dir (ara katman); sunucunuzun listesindeki ilk middleware. Hiç span üretmeyen bir sunucuyu gerçekten istiyorsanız onu listeden çıkarın: + +```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 + Bu içe aktarmanın başında bir alt çizgi var ve bu bilerek böyle. Sınıf, tıpkı [`Server.middleware`](../advanced/middleware.md) gibi geçicidir; bu yüzden içe aktarma yolunun değişmesini beklemelisiniz. Buna neredeyse hiç ihtiyacınız olmaz: exporter kurulu değilken span'lar bedavadır, bu yüzden olağan yanıt onları açık bırakıp exporter kurmamaktır. + +## Özet {#recap} + +* Her `MCPServer` ve her düşük seviyeli `Server`, varsayılan olarak gelen mesaj başına bir `SERVER` span'ı üretir. Siz hiçbir şey yazmazsınız. +* Span'lar `mcp.method.name` ve `mcp.protocol.version` taşır; `tools/call` ve `prompts/get` ayrıca GenAI öznitelikleri taşır, böylece araç çağrılarınız başka herhangi bir ajanınkiler gibi gruplanır. +* Bir OpenTelemetry SDK'sı ve bir exporter kurana kadar hiçbir maliyeti yoktur; kurduğunuzda ise sunucunuzda hiçbir değişiklik olmadan görünür hale gelir. +* Her iki taraf da SDK'yı çalıştırdığında iz bağlamı istemciden sunucuya otomatik olarak yayılır. + +Bir isteğin çalışıp çalışmayacağına karar veren şey ise **[Yetkilendirme](authorization.md)**. diff --git a/i18n/tr/pages/servers/completions.md b/i18n/tr/pages/servers/completions.md new file mode 100644 index 0000000000..e1437941da --- /dev/null +++ b/i18n/tr/pages/servers/completions.md @@ -0,0 +1,131 @@ +--- +translation: + sections: [72f9c964769076dd, 9a2c14e10935b515, 235299eb78ab12d7, 8aee1e78c8237fb8, 9bd86acd4112138f, 55343cb7f250dc7b] + tool: 1 +--- +# Tamamlamalar {#completions} + +Sunucunuzun üzerine bir arayüz kuran bir istemci, kullanıcı yazdıkça argüman değerlerini otomatik tamamlamak ister: dil adları, depo adları, dosya yolları. + +**Tamamlamalar**, sunucunuzun bu önerileri sağlama yoludur. + +## Tamamlamaya değer bir şey {#something-worth-completing} + +Tamamlamalar tam olarak iki şeye uygulanır: bir **prompt**'un argümanlarına ve bir **kaynak şablonunun** parametrelerine. O halde her birinden birer tane içeren bir sunucuyla başlayın: + +```python title="server.py" hl_lines="6 12" +--8<-- "docs_src/completions/tutorial001.py" +``` + +Burada henüz tamamlamalarla ilgili hiçbir şey yok. + +* `review_code` bir `language` alır. Kullanıcı hangi yazımları kabul ettiğinizi tahmin etmek zorunda kalmamalı. +* `github_repo` bir `owner` ve bir `repo` alır. İkisi için de serbest metin kutuları kötü bir form olur. + +## Tamamlama işleyicisi {#the-completion-handler} + +`@mcp.completion()` ile dekore edilmiş **tek** bir fonksiyon ekleyin: + +```python title="server.py" hl_lines="21-29" +--8<-- "docs_src/completions/tutorial002.py" +``` + +* Sunucu başına tek bir işleyici vardır. Her tamamlama isteği buraya düşer; neyin tamamlandığına göre siz dallanırsınız. +* `async def` olmak zorundadır: SDK onu await eder. +* Üç argüman alır: + * `ref`: *hangi* prompt veya kaynak şablonu olduğu; bir `PromptReference` ya da `ResourceTemplateReference` olarak gelir. İkisini `isinstance` ile ayırt edersiniz. + * `argument`: `argument.name` tamamlanmakta olan argüman, `argument.value` ise kullanıcının şu ana kadar yazdığıdır. + * `context`: hâlihazırda çözümlenmiş argümanlar. Şimdilik görmezden gelin. +* Bir `Completion(values=[...])` döndürürsünüz; sunacak bir şeyiniz yoksa `None`. + +!!! tip + `argument.value`, kullanıcının yazdığı ön ektir. SDK sizin yerinize filtreleme **yapmaz**: + `values` içine ne koyarsanız arayüz onu gösterir. `startswith`'i yazmak size düşer. + +### Deneyin {#try-it} + +**[Test etme](../get-started/testing.md)** sayfasındaki bellek içi `Client` ile çalıştırın. +`client.complete()`'i `ref=PromptReference(name="review_code")` ve +`argument={"name": "language", "value": "py"}` ile çağırın: + +```python +result.completion.values # ['python'] +``` + +* `ref`, işleyicinizin aldığı referans türünün aynısıdır. +* `argument`, tam olarak iki anahtarı (`name` ve `value`) olan düz bir dict'tir. + +Boş bir `value` gönderin, listenin tamamı geri döner. `lang.startswith("")` her dil için doğrudur: + +```python +result.completion.values # ['go', 'javascript', 'python', 'rust', 'typescript'] +``` + +`code` hakkında sorun (işleyicinizin tanımadığı bir argüman); `None` döndürür, SDK da bunu boş bir listeye çevirir: + +```python +result.completion.values # [] +``` + +`None` *"öneri yok"* demektir, asla bir hata değildir. Arayüz düz bir metin kutusuna geri döner. + +## Hiç bildirmediğiniz bir yetenek {#a-capability-you-never-declared} + +İşleyiciyi kaydetmek bildirimin ta kendisidir. Bir istemci bağlayın ve bakın: + +```python +client.server_capabilities.completions # CompletionsCapability() +``` + +`completions`'ı hiçbir yerde listelemediniz. SDK işleyiciyi gördü ve yeteneği sizin yerinize bildirdi. *İsteğe bağlı* her yetenek böyle çalışır: işleyici bildirimin kendisidir. (Üç temel yapı isteğe bağlı değildir: `MCPServer` işleyici olsun olmasın bunları her zaman bildirir.) + +!!! check + İlk `server.py` dosyasına (işleyicisi olmayana) dönün ve yine de sorun. Çağrı bir JSON-RPC + hatasıyla başarısız olur: + + ```text + Method not found + ``` + + Ve `client.server_capabilities.completions` `None` olur. Yeteneğin anlamı budur: düzgün + davranan bir istemci bunu kontrol eder ve yanıtlayamayacağınız isteği hiç göndermez. + +## Bağımlı argümanlar {#dependent-arguments} + +`github://repos/{owner}/{repo}` kaynağının iki parametresi var ve `repo` için işe yarar değerler önce hangi `owner`'ın seçildiğine bağlı. + +`context` tam da bunun için var. Kullanıcının **hâlihazırda çözümlediği** argümanları taşır: + +```python title="server.py" hl_lines="8-11 34-38" +--8<-- "docs_src/completions/tutorial003.py" +``` + +* Yeni dal, şablonun `repo` parametresi için devreye girer. +* `context.arguments`, şu ana kadar seçilen değerlerin (burada `owner`) bir `dict[str, str] | None`'ıdır. +* Henüz `owner` yoksa mantıklı öneri de yoktur; bu yüzden işleyici `None` döndürür. + +İstemci bu çözümlenmiş değerleri `context_arguments=` ile gönderir. Bu kez `ref` bir +`ResourceTemplateReference(uri="github://repos/{owner}/{repo}")` olur. Boş bir `value` ile +`repo`'yu isteyin ve `context_arguments={"owner": "modelcontextprotocol"}` geçirin: + +```python +result.completion.values # ['python-sdk', 'typescript-sdk', 'inspector'] +``` + +`context_arguments=`'ı kaldırın, aynı çağrı `[]` döndürür. İşleyici, sahibi bilmeden hangi depoları önereceğini bilemez. + +!!! info + `Completion` ayrıca `total=` ve `has_more=` de alır. `values` daha uzun bir listenin bir dilimi + olduğunda bunları ayarlayın; böylece arayüz *"ve 200 tane daha"* gösterebilir. Çoğu işleyicinin + bunlara hiç ihtiyacı olmaz. + +## Özet {#recap} + +* Tamamlamalar, **prompt argümanları** ve **kaynak şablonu parametreleri** için önerilerdir. Başka bir şey değil. +* `@mcp.completion()` tek işleyiciyi kaydeder. İmzası `async def (ref, argument, context) -> Completion | None`'dır. +* `isinstance(ref, ...)` ve `argument.name` üzerinden dallanın. `argument.value`'ya göre filtrelemeyi kendiniz yapın. +* `None` boş bir listeye dönüşür. Asla bir hata değildir. +* `context.arguments` hâlihazırda çözümlenmiş değerleri tutar; istemci bunları `context_arguments=` olarak sağlar. +* `completions` yeteneği, işleyiciyi kaydettiğiniz anda ortaya çıkar. O olmadan istek `Method not found` olur. + +Öneriler, kullanıcı bir prompt'u veya şablonu hâlâ *doldururken* işe yarar; bir araç çağrısının *ortasında* kullanıcıya soru sormak için **[Elicitation](../handlers/elicitation.md)** gerekir. Bir aracın metin dışında döndürebileceği her şey ise **[Görseller, ses ve simgeler](media.md)** sayfasında. diff --git a/i18n/tr/pages/servers/handling-errors.md b/i18n/tr/pages/servers/handling-errors.md new file mode 100644 index 0000000000..79fe2a8639 --- /dev/null +++ b/i18n/tr/pages/servers/handling-errors.md @@ -0,0 +1,139 @@ +--- +translation: + sections: [e33d441f12d50535, 7099694c603e0f5f, c1df4cf9673433e6, c9cd294541422e6e, 6cec073617bfd037, efa92b8f99e908c8, 6a22a29e27fb4601] + tool: 1 +--- +# Hataları ele alma {#handling-errors} + +Bir araç iki şekilde başarısız olabilir ve SDK bu ikisini çok farklı ele alır. + +Sıradan bir istisna fırlatırsanız bunu **model** görür. `MCPError` fırlatırsanız bunu **protokol** görür. + +Bu sayfa, hangisini seçeceğinizle ilgili. + +## Modelin düzeltebileceği bir hata {#an-error-the-model-can-fix} + +Bir şeyi arayıp bulan bir araç düşünün; arama sonuçsuz kalsın: + +```python title="server.py" hl_lines="11-12" +--8<-- "docs_src/handling_errors/tutorial001.py" +``` + +Bu iki satırda MCP'ye özgü hiçbir şey yok. `get_author`, herhangi bir Python fonksiyonunun yapacağı gibi düz bir `ValueError` fırlatır. + +Katalogda olmayan bir başlıkla çağırın ve sonuca bakın: + +```python +result.is_error # True +result.content # [TextContent(text="Error executing tool get_author: No book titled 'Nothing' in the catalog.")] +result.structured_content # None +``` + +* İstek **başarılı oldu**. Ortada bir sonuç var; çağıran tarafta hiçbir şey fırlatılmadı. +* `is_error` değeri `True`; istisnanızın mesajı (başına araç adı eklenmiş olarak) `content`'te, tam da modelin okuduğu yerde. +* `structured_content` değeri `None`. Başarısız bir çağrının yapılandırılacak bir dönüş değeri yoktur. + +Bu bir **araç hatasıdır** ve aracınızın fırlattığı *her* istisna için varsayılan davranış budur. Neredeyse her zaman istediğiniz şey de budur. + +Aracınızı çağıran modeldir. Argümanları o seçti. Bu yüzden araç hatası, konuşmada bir tur demektir: model *"No book titled 'Nothing' in the catalog."* mesajını okur, başlığı yanlış tahmin ettiğini anlar ve daha iyi bir başlıkla tekrar çağırır. Tek bir `raise` yazdınız ve kendi kendini düzelten bir ajan elde ettiniz. + +!!! tip + Bir araçtan hata mesajını asla `return` ile döndürmeyin. Döndürülen bir dizenin `is_error=False` + değeri vardır; bu yüzden modele (ve her istemci arayüzüne) araç çalışmış ve yanıt o dizeymiş gibi görünür. + `raise` kullanın. Sinyali veren bayraktır. + +## Modelin düzeltemeyeceği bir hata {#an-error-the-model-cannot-fix} + +Şimdi `ValueError` yerine `MCPError` koyun. + +```python title="server.py" hl_lines="1 3 14" +--8<-- "docs_src/handling_errors/tutorial002.py" +``` + +`MCPError`, SDK'nın **protokol hatasıdır**. Araç sarmalayıcısının *yakalamadığı* tek istisna budur: yayılır ve `tools/call` isteğinin tamamı bir sonuç yerine JSON-RPC hatasıyla başarısız olur. + +```json +{ + "code": -32602, + "message": "No book titled 'Nothing' in the catalog." +} +``` + +* **Sonuç yoktur**. `content` yok, `is_error` yok: modelin okuyacağı hiçbir şey yok. +* Hatayı bunun yerine **host** uygulama alır; tıpkı araç hiç var olmasaydı alacağı gibi. +* `code`, `message` ve `data` bozulmadan ulaşır. `INVALID_PARAMS` sabiti `-32602` değerini taşır; `mcp.types` onu ve diğer JSON-RPC hata kodlarını (`INVALID_REQUEST`, `INTERNAL_ERROR`, ...) sabit olarak dışa aktarır, böylece hiçbir zaman sihirli bir sayı yazmazsınız. + +!!! check + Aynı arama, aynı sonuçsuzluk; ama bu kez çağrı istemci tarafında döndürmek yerine *fırlatır*: + + ```text + mcp.shared.exceptions.MCPError: No book titled 'Nothing' in the catalog. + ``` + + İlk sürüm modele tepki verebileceği bir cümle vermişti. Bu sürüm ona hiçbir şey vermez. + `get_author` için bu kesinlikle daha kötüdür; bir sonraki bölümün konusu da budur. + +## Hangisini fırlatmalı {#which-one-to-raise} + +İki yol, iki farklı soruyu yanıtlar. + +* *Yürütme* başarısızlığı için **herhangi bir istisna fırlatın**: aracınızın yapmaya çalıştığı şey işe yaramadı. Çağrıyı model seçti, bu yüzden sonucunu da model görmeli ve toparlanma şansı bulmalı. Yanlış yazılmış bir başlık, zaman aşımına uğrayan bir dış API, var olmayan bir satır: hepsi araç hatası. +* *İsteğin kendisi* reddedilmesi gerektiğinde **`MCPError` fırlatın**: istemcide aracınızın bağımlı olduğu bir yetenek eksik, sunucu kimseye hizmet verecek durumda değil, çağıran taraf zorunlu bir adımı atlamış. Modelin hiçbir yeniden denemesi bunları düzeltmez; bu yüzden mesajı ona vermenin bir kazancı yok. + +Kararı tek bir soru verir: **daha akıllı bir model bundan kaçınabilir miydi?** Evet -> sıradan istisna. Hayır -> `MCPError`. + +Bu ölçüte göre `get_author`'ın ikinci sürümü yanlış seçim yaptı: daha iyi bir başlık sorunu çözer, yani model mesajı görmeyi hak ediyordu. O sürüm size mekanizmayı göstermek için orada, onu önermek için değil. + +!!! info + `MCPError`, `from mcp import MCPError` ile içe aktarılır ve `code`, `message` ile isteğe bağlı + bir `data` yükü alır. Bunlara ne koyarsanız istemci onu alır: SDK, fırlatılan bir + `MCPError`'ı temizlemek yerine olduğu gibi iletir. + +## Var olmayan bir kaynak {#a-resource-that-doesnt-exist} + +Kaynaklar da aynı çizgiyi çeker ve yaygın durum için adlandırılmış bir istisna sunar. + +```python title="server.py" hl_lines="2 13" +--8<-- "docs_src/handling_errors/tutorial003.py" +``` + +`books://{title}` bir **şablondur**. *Her* başlıkla eşleşir; bu yüzden "URI düzgün biçimli" ile "kitap var" iki farklı sorudur ve ikincisini yalnızca fonksiyonunuz yanıtlayabilir. + +Yanıtlayamadığında `ResourceNotFoundError` fırlatın. SDK bunu, spesifikasyonun eksik bir kaynağa atadığı protokol hatasına dönüştürür: `data`'da istenen URI ile birlikte `-32602`; böylece istemci *hangi* okumanın başarısız olduğunu bilir. + +```json +{ + "code": -32602, + "message": "No book titled 'Nothing' in the catalog.", + "data": {"uri": "books://Nothing"} +} +``` + +Burada `is_error=True` taşıyan yarım bir sonuç olmadığına dikkat edin. Bir kaynak okuması ya içerik döndürür ya da başarısız olur: kaynakların yalnızca protokol yolu vardır. Şablonlar ve kaynaklarla ilgili diğer her şey **[Kaynaklar](resources.md)** sayfasında. + +## Hiç fırlatmadığınız hatalar {#errors-you-never-raise} + +Hatalı bir argüman fonksiyonunuza asla ulaşmaz. + +`get_author`'a dize olmayan bir `title` gönderin; SDK sizi çağırmadan **önce** onu girdi şemasına göre reddeder. Bu da modelin okuyup düzeltebileceği türden, aynı `is_error=True` araç hatasıdır. **[Araçlar](tools.md)** sayfası aynı reddi bir `Field(le=50)` kısıtıyla gösterir. + +Bu, yazmadığınız koca bir `raise` ifadesi sınıfı demektir: kendi tür ipuçlarınızı yeniden doğrulamayın. + +!!! info + Bu sayfadaki her şey bir **istemcinin** gördüğüdür; testleri yazarken kullanacağınız bellek içi + `Client` da tam olarak aynı şeyi görür. `raise_exceptions=True` bile bir araç hatasını tekrar + traceback'e çevirmez: o bayrak devreye girebilecek noktaya geldiğinde istisnanız çoktan + `is_error=True` sonucuna dönüşmüştür. Doğrulamayı sonuç üzerinde yapın. **[Test etme](../get-started/testing.md)** sayfası bu kalıbı anlatır. + +## Özet {#recap} + +* Bir araçta **herhangi bir istisna** fırlatın -> çağrı, mesajınız `content`'te olacak şekilde `is_error=True` döndürür. Model bunu okur ve yeniden deneyebilir. Varsayılan budur. +* **`MCPError`** fırlatın -> çağrının kendisi bir JSON-RPC hatasıyla başarısız olur. Model hiçbir şey görmez; bununla host ilgilenir. `code`, `message` ve `data` bozulmadan ulaşır. +* Belirleyici soru: *daha akıllı bir model bundan kaçınabilir miydi?* Evet -> istisna. Hayır -> `MCPError`. +* Bir kaynak işleyicisinden `ResourceNotFoundError` -> protokolün `-32602` kodu, URI `data`'da. +* Hatalı argümanlar, fonksiyonunuz çalışmadan önce şemaya göre reddedilir; bunlar için `raise` yazmazsınız. +* `from mcp import MCPError`; hata kodu sabitleri `mcp.types`'tan gelir. + +Hatalar halloldu. Bir sunucunun *sunduğu* her şey bu kadar. Her işleyicinin çalışırken neleri okuyabildiği ve istemciye geri neler yapabildiği bir sonraki bölümde: **[İşleyicinin içinde](../handlers/index.md)**. + +En sık karşılaşacağınız SDK hatalarının tam metni, her birinin ne anlama geldiği ve her biri için tek hamlelik çözüm **[Sorun giderme](../troubleshooting.md)** sayfasında. diff --git a/i18n/tr/pages/servers/index.md b/i18n/tr/pages/servers/index.md new file mode 100644 index 0000000000..2f222380b3 --- /dev/null +++ b/i18n/tr/pages/servers/index.md @@ -0,0 +1,35 @@ +--- +translation: + sections: [09defc170a0da89d] + tool: 1 +--- +# Sunucular {#servers} + +Bir `MCPServer`, bağlı bir istemciye üç temel yapı taşı sunar. Aralarındaki fark, onları kullanmaya kimin karar verdiğidir: + +* **[Araç](tools.md)**, *modelin* seçip çağırdığı bir eylemdir. Çoğu kişinin + önce görmek istediği sayfa budur; + **[Yapılandırılmış çıktı](structured-output.md)** ise onun başvuru + eşlikçisidir: bir aracın döndürdüğü şeyin biçimiyle ilgili her şey orada. +* **[Kaynak](resources.md)**, *uygulamanın* okumayı seçtiği salt okunur + veridir. **[URI şablonları](uri-templates.md)** onun başvuru + eşlikçisidir: adresleme sözdiziminin tamamı ve yol güvenliği kuralları. +* **[Prompt](prompts.md)**, bir *kişinin* menüden ya da eğik çizgi + komutuyla adıyla çağırdığı bir mesaj şablonudur. + +Bu üç yapı taşının etrafında, bir sunucunun bildirdiği diğer her şey yer alır: + +* **[Tamamlamalar](completions.md)**, prompt ve kaynak şablonu argümanları + için sunucu tarafında otomatik tamamlamadır. +* **[Görseller, ses ve simgeler](media.md)**, bir aracın metin dışında + döndürebileceği her şeyi ve bir istemcinin sunucunuzun yanında gösterdiği + simgeleri kapsar. +* **[Hataları ele alma](handling-errors.md)**, modelin toparlayabileceği bir + hata ile asla görmemesi gereken bir hata arasındaki farkı açıklar. + +Buradaki her sayfa kendi başına okunabilir; doğrudan ihtiyacınız olana geçin. Henüz +bir sunucu oluşturmadıysanız bunun yerine **[İlk adımlar](../get-started/first-steps.md)** sayfasıyla başlayın. + +Kaydettiğiniz fonksiyonların *içinde* olup bitenler (`Context`, bağımlılık enjeksiyonu, +çağrının ortasında kullanıcıdan ek girdi isteme) bir sonraki bölümün konusu: +**[İşleyicinin içinde](../handlers/index.md)**. diff --git a/i18n/tr/pages/servers/media.md b/i18n/tr/pages/servers/media.md new file mode 100644 index 0000000000..95fcb7ac5d --- /dev/null +++ b/i18n/tr/pages/servers/media.md @@ -0,0 +1,122 @@ +--- +translation: + sections: [496394d24d221bf1, 4ceb4591180dc6c3, 0fd63e4682d02e0c, 969ede0bd3686a16, 043f526230dd243d, 6ee3e9bcfd24047a] + tool: 1 +--- +# Medya {#media} + +Bir aracın döndürebileceği tek şey metin değildir. + +SDK, ikili sonuçlar için iki yardımcı (**`Image`** ve **`Audio`**) ile sunucunuza, araçlarınıza, kaynaklarınıza ve prompt'larınıza istemcinin arayüzünde bir yüz kazandıran **`Icon`** türünü sunar. + +## Görsel döndürme {#returning-an-image} + +Dönüş türünü `Image` olarak belirtin, bir dosyaya yönlendirin ve döndürün: + +```python title="server.py" hl_lines="8 12 14" +--8<-- "docs_src/media/tutorial001.py" +``` + +* `Image`, `path` (okunacak bir dosya) veya `data` (ham baytlar) argümanlarından tam olarak birini alır. +* İstemcinin gördüğü MIME türü dosya uzantısından tahmin edilir: `logo.png`, `image/png` olarak bildirilir. +* Burada logolara özgü hiçbir şey yok. `server.py` dosyasının yanındaki herhangi bir PNG iş görür: kodunuzun çizdiği bir grafik, bir diyagram, bir fotoğraf. + +`Image` bir protokol türü değil, SDK'nın sağladığı bir kolaylıktır. İletilen veride dönüş değeriniz bir **`ImageContent`** bloğuna dönüşür (dosyanın base64 ile kodlanmış baytları ve MIME türü): + +```python +result.content # [ImageContent(type="image", data="iVBORw0KGgoAAAANSUhEUg...", mime_type="image/png")] +result.structured_content # None +``` + +Dikkat edilecek iki nokta: + +* `data` base64'tür. Baytlara hiç dokunmadınız; dosyayı SDK okudu ve kodlamayı yaptı. +* `structured_content` değeri `None`. Bir `Image`, uygulamanın ayrıştıracağı veri değil, modelin bakacağı içeriktir: çıktı şeması yoktur. (Dönüş tür ipucunun şemanın *ta kendisi* olduğu **[Yapılandırılmış çıktı](structured-output.md)** sayfasıyla karşılaştırın.) + +!!! info + `ImageContent` ve `AudioContent`, `mcp.types` modülünde, düz bir `str` sonucunun dönüştüğü + `TextContent`'in hemen yanında yer alır (**[Araçlar](tools.md)**). Bir araç sonucu, içerik bloklarından oluşan bir listedir; `Image` ve `Audio` + iki ikili türü üretmenin en kısa yoludur. + +### Deneyin {#try-it} + +`server.py` dosyasının yanına herhangi bir PNG koyun, adını `logo.png` yapın ve çalıştırın: + +```console +uv run mcp dev server.py +``` + +**Tools** sekmesini açın ve `logo` aracını çağırın. Sonuç bir dize değil: bir `image` içerik bloğu ve Inspector resminizi görüntülüyor. Diskteki dosya ile ekrandaki pikseller arasındaki her şeyi SDK yaptı. + +## Ses döndürme {#returning-audio} + +`Audio` da aynı biçimdedir. `logo.png` dosyasını yerinde bırakın ve yanına herhangi bir WAV dosyasını `chime.wav` adıyla koyun: + +```python title="server.py" hl_lines="18-21" +--8<-- "docs_src/media/tutorial002.py" +``` + +Sonuç bir **`AudioContent`** bloğudur: + +```python +result.content # [AudioContent(type="audio", data="UklGR...", mime_type="audio/wav")] +result.structured_content # None +``` + +Aynı düzen: diskteki bir dosya girer, base64 ve bir MIME türü çıkar, çıktı şeması yok. + +## Baytlar veya dosya {#bytes-or-a-file} + +Her iki yardımcı da `path=` yerine `data=` (ham baytlar) kabul eder. Bu, hiçbir zaman kendi dosyasından gelmemiş baytlar içindir: bir veritabanı sütunu, bir HTTP yanıtı, Pillow'un az önce çizdiği bir şey: + +```python title="server.py" hl_lines="14 15" +--8<-- "docs_src/media/tutorial003.py" +``` + +`path=` ile bildirilecek bir şey yoktur: dosya, sonuç oluşturulurken okunur ve MIME türü uzantıdan tahmin edilir: + +* `Image`: `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`. +* `Audio`: `.wav`, `.mp3`, `.ogg`, `.flac`, `.aac`, `.m4a`. + +Tanımadığı bir uzantı `application/octet-stream`'e geri düşer. + +!!! check + `data=` ile bir dosya adı yoktur, dolayısıyla tahmin yapılacak bir şey de yoktur. `format=` + argümanını unutursanız SDK bir varsayılana geri düşer: görseller için `image/png`, ses için `audio/wav`. + MP3 baytlarından bu şekilde bir `Audio` oluşturursanız istemciye `mime_type="audio/wav"` + söylenir ve o da sadakatle çözmeyi başaramaz. `data=` geçirdiğinizde `format=` da geçirin. + +## Simgeler {#icons} + +`Icon` içerik değil, meta veridir. Görseli taşımaz; bir URI ile ona işaret eder ve istemci onu getirip sunucunuzun adının, bir aracın, bir kaynağın veya bir prompt'un yanında gösterebilir. + +```python title="server.py" hl_lines="4-5 7 10 16" +--8<-- "docs_src/media/tutorial004.py" +``` + +* `src`, istemcinin çözümleyebileceği bir URI'dir: `https:` veya simgeyi ek bir getirme olmadan gömmek isterseniz bir `data:` URI'si. +* `mime_type` ve `sizes` (`"48x48"` ya da ölçeklenebilir bir biçim için `"any"`), birkaç tane sunduğunuzda istemcinin doğru olanı seçmesini sağlar. +* `theme="light"` veya `theme="dark"`, bir simgeyi tek bir renk şeması için işaretler. + +Aynı `icons=[...]` anahtar sözcüğünü `MCPServer(...)`, `@mcp.tool()`, `@mcp.resource()` ve `@mcp.prompt()` kabul eder. + +### İstemcinin bunları gördüğü yer {#where-a-client-sees-them} + +Simgeler, süsledikleri şeyle birlikte yolculuk eder. Sunucununkiler istemci bağlandığında `client.server_info` üzerinde gelir (2026 neslinden bağlantılarda isteğe bağlıdır, bu yüzden önce türünü daraltın): + +```python +assert client.server_info is not None # python-sdk servers identify themselves by default +client.server_info.icons # [Icon(src="https://example.com/brand-kit.png", mime_type="image/png", sizes=["48x48"])] +``` + +Bir aracın simgeleri `tools/list`'ten gelen `Tool` nesnesinde, bir kaynağınkiler `resources/list`'ten gelen `Resource`'ta, bir prompt'unkiler `prompts/list`'ten gelen `Prompt`'ta bulunur. Alanın adı her zaman `icons`'tur. + +## Özet {#recap} + +* Bir araçtan `Image` veya `Audio` döndürün; istemci bir `ImageContent` / `AudioContent` bloğu alır: base64 ile kodlanmış baytlarınız ve bir MIME türü. +* Bunu bir `path=` ile oluşturup MIME türünü uzantının belirlemesine bırakın ya da bellekteki `data=` ile açık bir `format=` kullanın. +* Medya sonuçları `structured_content` ve çıktı şeması taşımaz. +* `Icon` bir işaretçidir: bir `src` URI'si ile isteğe bağlı `mime_type`, `sizes` ve `theme`. +* `icons=[...]` sunucuda, araçlarda, kaynaklarda ve prompt'larda çalışır; istemciler bunları eşleşen nesnelerde bulur. + +Bir aracın bir sonuca *koyabileceği* her şey bu kadar. Bir araç *başarısız olduğunda* ne olacağı (ve bundan kimin haberi olması gerektiği) **[Hataları ele alma](handling-errors.md)** sayfasında. diff --git a/i18n/tr/pages/servers/prompts.md b/i18n/tr/pages/servers/prompts.md new file mode 100644 index 0000000000..5a3f2ac472 --- /dev/null +++ b/i18n/tr/pages/servers/prompts.md @@ -0,0 +1,155 @@ +--- +translation: + sections: [d65c098f37f5b6c3, dd0c2724d6f2877e, 6835bb3570c6714c, ffe823cb0fedd488, f33651add1b59094] + tool: 1 +--- +# Prompt'lar {#prompts} + +**Prompt**, kullanıcının seçtiği bir mesaj şablonudur. + +Araçlar model içindir. Prompt ise tam tersi: kullanıcı istemcisindeki bir menüden (bir slash komutu, bir düğme) birini seçer, argümanlarını doldurur ve ortaya çıkan mesajlar sanki kendisi yazmış gibi konuşmaya eklenir. + +Metni döndüren bir fonksiyonun üzerine `@mcp.prompt()` koyarak bir prompt tanımlarsınız. + +## İlk prompt'unuz {#your-first-prompt} + +```python title="server.py" hl_lines="6-9" +--8<-- "docs_src/prompts/tutorial001.py" +``` + +SDK, bir araçtan okuduğu aynı üç şeyi okur: + +* **Ad**, fonksiyonun adıdır: `review_code`. +* İstemcinin gösterdiği **açıklama** docstring'dir: `Review a piece of code.` +* **Argümanlar** parametrelerden gelir. `code` için varsayılan değer yok, bu yüzden zorunludur. + +Bir istemci `prompts/list` çağrısından şunu alır: + +```json +{ + "name": "review_code", + "description": "Review a piece of code.", + "arguments": [ + {"name": "code", "required": true} + ] +} +``` + +Burada JSON Schema yok. Prompt argümanları **adlandırılmış dize değerlerinden** oluşan düz bir listedir: bir modelin kurduğu bir veri yükü değil, bir insanın doldurduğu bir form. + +### Şablonu işleme {#rendering-it} + +İstemci, argümanları geçirerek şablonu `prompts/get` ile işler. Fonksiyonunuz çalışır ve döndürdüğünüz `str` **tek bir kullanıcı mesajına** dönüşür: + +```json +{ + "description": "Review a piece of code.", + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "Please review this code:\n\ndef add(a, b): return a + b" + } + } + ], + "resultType": "complete" +} +``` + +Bir prompt'un tüm yaşamı bu: adıyla listelenir, istendiğinde işlenir, sohbete bırakılır. + +!!! check + `required`, fonksiyonunuz çalışmadan önce uygulanır. `review_code`'u `code` olmadan işleyin; + isteğin kendisi bir JSON-RPC hatasıyla (kod `-32603`) başarısız olur: + + ```text + mcp.shared.exceptions.MCPError: Internal server error + ``` + + Bir modele geri verilecek araç tarzı bir hata sonucu yoktur, çünkü döngüde bir model yoktur: + çağrı bir istisna fırlatır. Nedeni (`Missing required arguments: {'code'}`) sunucunuzun log'una düşer. + +### Deneyin {#try-it} + +Sunucuyu MCP Inspector ile çalıştırın: + +```console +uv run mcp dev server.py +``` + +**Prompts** sekmesini açın ve `review_code`'u seçin. Inspector, tek bir zorunlu `code` alanı olan bir form çizer. Doldurun, işleyin; geriye tam olarak yukarıdaki kullanıcı mesajı döner. + +## Birden fazla mesaj {#more-than-one-message} + +Bir kod incelemesi tek bir mesajdır. Bir hata ayıklama oturumu ise bir konuşmadır ve bir prompt bu konuşmanın tamamının temelini atabilir. + +`str` yerine bir mesaj listesi döndürün: + +```python title="server.py" hl_lines="2 13-20" +--8<-- "docs_src/prompts/tutorial002.py" +``` + +* `UserMessage` ve `AssistantMessage`, `mcp.server.mcpserver.prompts.base` modülünden gelir. Onlara bir `str` verin, sizin için `TextContent` içine sararlar. Rol, sınıfın adıdır. +* `Message` ortak temel sınıflarıdır. Dönüş tür açıklaması olarak onu kullanın. + +`debug_error` işlendiğinde artık sırasıyla üç mesaj üretilir: + +```json +{ + "description": "Start a debugging conversation.", + "messages": [ + {"role": "user", "content": {"type": "text", "text": "I'm seeing this error:"}}, + {"role": "user", "content": {"type": "text", "text": "TypeError: 'int' object is not iterable"}}, + { + "role": "assistant", + "content": {"type": "text", "text": "I'll help debug that. What have you tried so far?"} + } + ], + "resultType": "complete" +} +``` + +Sonuncusuna dikkat edin. Bir `assistant` turunu önceden doldurmak, yönlendirmeyi kullanıcıya yazdırmadan modelin *bir sonraki* yanıtını yönlendirmenin yoludur. + +## Başlıklar ve argüman açıklamaları {#titles-and-argument-descriptions} + +`review_code` bir etiket değil, bir fonksiyon adıdır. İstemciye düğmeye koyacak daha iyi bir şey verin ve formun kendini açıklaması için her argümanı tanımlayın: + +```python title="server.py" hl_lines="10-13" +--8<-- "docs_src/prompts/tutorial003.py" +``` + +* `title="Code review"` insan tarafından okunabilir addır; tıpkı bir aracın `title`'ı gibi. +* `Annotated[str, Field(description=...)]`, **[Araçlar](tools.md)** sayfasının bir aracın parametrelerini açıklamak için kullandığı kalıbın aynısıdır. Burada açıklama bir şemaya değil, argümanın üzerine düşer. +* `language` için bir varsayılan değer var, bu yüzden artık zorunlu değildir. + +`prompts/list` girdisi artık bir istemcinin iyi bir form çizmek için ihtiyaç duyduğu her şeyi taşır: + +```json +{ + "name": "review_code", + "title": "Code review", + "description": "Review a piece of code.", + "arguments": [ + {"name": "code", "description": "The code to review.", "required": true}, + {"name": "language", "description": "The language the code is written in.", "required": false} + ] +} +``` + +!!! info + **[Araçlar](tools.md)** sayfasını okuduysanız bu sayfadaki her şeyi zaten biliyorsunuz. Aynı dekoratör, + açıklama olarak aynı docstring, aynı `Annotated`/`Field`. Değişen tek şey onu kimin + tetiklediği (kullanıcı) ve sonucun nereye gittiğidir (konuşmaya). + +## Özet {#recap} + +* Bir fonksiyonun üzerindeki `@mcp.prompt()` onu bir prompt yapar. Ad fonksiyondan, açıklama docstring'den gelir. +* Prompt'lar **kullanıcı denetimindedir**: istemci bunları listeler, kullanıcı birini seçer ve argümanları doldurur. +* Argümanlar adlandırılmış dizelerden oluşan düz bir listedir (şema yok). Varsayılanı olan bir parametre isteğe bağlıdır. +* Bir `str` döndürün, tek bir kullanıcı mesajına dönüşür. Çok turlu bir konuşmanın temelini atmak için `UserMessage` / `AssistantMessage` listesi döndürün. +* `title=` ve `Field(description=...)`, bir istemcinin arayüzüne koyduğu şeylerdir. +* Eksik bir zorunlu argüman isteğin tamamını başarısız kılar. Prompt'a özgü bir hata sonucu yoktur. + +Bir prompt'un (veya bir kaynak şablonunun) argümanları için sunucu tarafı otomatik tamamlama **[Tamamlamalar](completions.md)** sayfasındadır. diff --git a/i18n/tr/pages/servers/resources.md b/i18n/tr/pages/servers/resources.md new file mode 100644 index 0000000000..6c7468a350 --- /dev/null +++ b/i18n/tr/pages/servers/resources.md @@ -0,0 +1,146 @@ +--- +translation: + sections: [09df998c2a799f78, 0cf131146d16d4f9, 4e6b91e3f8025346, 8fe4eef576db17ed, 0d0d1ed43e3d0a53] + tool: 1 +--- +# Kaynaklar {#resources} + +**Kaynak**, uygulamanın okuması için sunduğunuz veridir. + +Ayrım bu. Araç, **modelin** çağırmaya karar verdiği şeydir. Kaynak ise **uygulamanın** yüklemeye (bir yapılandırma dosyası, bir kayıt, bir belge) ve bağlam olarak modelin önüne koymaya karar verdiği şeydir. + +Bir kaynağı, sıradan bir Python fonksiyonunun üzerine `@mcp.resource(uri)` koyarak bildirirsiniz. + +## İlk kaynağınız {#your-first-resource} + +```python title="server.py" hl_lines="6-8" +--8<-- "docs_src/resources/tutorial001.py" +``` + +Şekli bir araçla aynı, bir fazlası var: **URI**. Kaynaklara adla değil, adresle erişilir. İstemci `config://app` ister, asla `get_config` istemez. + +SDK geri kalanını yine fonksiyondan okur: + +* **Ad**, fonksiyonun adıdır: `get_config`. +* İstemcinin gördüğü **açıklama**, docstring'dir. +* **İçerik**, ne döndürürseniz odur. + +`resources/list` sırasında istemci şunu alır: + +```json +{ + "name": "get_config", + "uri": "config://app", + "description": "The active shop configuration.", + "mimeType": "text/plain" +} +``` + +`config://app` kaynağını okuduğunda ise fonksiyonunuz çalışır ve dönüş değeri metin olarak geri gelir: + +```python +result.contents # [TextResourceContents(uri="config://app", mime_type="text/plain", text="theme=dark\nlanguage=en")] +``` + +!!! tip + Listeleme ucuzdur. Fonksiyonunuz `resources/list` sırasında **çağrılmaz**; yalnızca + `resources/read` sırasında ve yalnızca istenen URI için çağrılır. Bin kaynak sunun, + bedelini yalnızca birinin açtıkları için ödersiniz. + +### Deneyin {#try-it} + +Sunucuyu MCP Inspector ile çalıştırın: + +```console +uv run mcp dev server.py +``` + +Yazdırdığı URL'yi açın ve **Resources** sekmesine gidin. `config://app`, açıklamasıyla birlikte listede. Tıklayın, Inspector onu okur: iki satırlık yapılandırmanız karşınızda. + +## Kaynak şablonları {#resource-templates} + +Kayıt başına bir URI ölçeklenmez. URI'ye bir **yer tutucu**, fonksiyona da onunla eşleşen bir parametre koyun: + +```python title="server.py" hl_lines="12-13" +--8<-- "docs_src/resources/tutorial002.py" +``` + +URI'de `{user_id}`, fonksiyonda `user_id: str`. Sözleşmenin tamamı bu. + +Bu artık bir **kaynak şablonu** ve yeri değişir: `resources/list` yanıtından çıkar, onun yerine `resources/templates/list` yanıtında görünür; bir adres olarak değil, bir desen olarak: + +```json +{ + "name": "get_user_profile", + "uriTemplate": "users://{user_id}/profile", + "description": "A customer's profile.", + "mimeType": "text/plain" +} +``` + +İstemci yer tutucuyu doldurur ve somut bir URI okur: `users://42/profile`, `users://ada/profile`. Hepsine tek bir fonksiyon yanıt verir; eşleşen değer `user_id` olarak geçirilir: + +```python +result.contents # [TextResourceContents(uri="users://42/profile", text="User 42: 12 orders since 2021.")] +``` + +Sonuçtaki `uri` alanına dikkat edin. Bu, şablon değil, istemcinin istediği **somut** URI'dir. + +!!! check + Yer tutucular ile parametreler uyuşmak zorunda. URI hâlâ `{user_id}` derken fonksiyon + parametresinin adını `user` olarak değiştirirseniz dekoratör, herhangi bir istemci yanına + bile yaklaşmadan, **içe aktarma sırasında** reddeder: + + ```text + ValueError: Mismatch between URI parameters {'user_id'} and function parameters {'user'} + ``` + + Bir uyuşmazlık ancak bir hata olabilir; bu yüzden SDK, sunucuyu böyle bir hatayla başlatmayı imkânsız kılar. + +Yer tutucu sözdizimi [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570) standardıdır: çok parçalı değerler için `{+path}`, isteğe bağlı sorgu parametreleri için `{?q,lang}` ve dahası. SDK ayrıca çıkarılan değerlere varsayılan olarak yol güvenliği denetimleri uygular. Tam başvuru için **[URI şablonları ve yol güvenliği](uri-templates.md)** sayfasına bakın. + +`get_user_profile`, `Context` ile işaretlenmiş bir parametre de alabilir. SDK onu hiçbir zaman URI parametresi saymadan enjekte eder; size neler sağladığını **[Context nesnesi](../handlers/context.md)** sayfası anlatır. + +## Döndürdükleriniz {#what-you-return} + +`str` ile sınırlı değilsiniz. Her kaynağa bir `mime_type` verin ve neyi uygun görüyorsanız onu döndürün: + +```python title="server.py" hl_lines="8-9 14-15 20-21" +--8<-- "docs_src/resources/tutorial003.py" +``` + +* `readme` bir `str` döndürür, bu yüzden olduğu gibi gönderilir. Yaygın durum budur. +* `catalog_stats` bir `dict` döndürür, bu yüzden SDK onu sizin için **JSON metnine** serileştirir: + + ```json + { + "books": 1204, + "authors": 391 + } + ``` + +* `placeholder_cover` `bytes` döndürür, bu yüzden istemci `TextResourceContents` yerine bir `BlobResourceContents` alır; baytlarınız `blob` alanında base64 ile kodlanmış olarak yer alır. + +Aynı kural JSON'a serileştirilebilen başka her şey için de geçerlidir: bir liste, bir Pydantic modeli, bir dataclass. `str` değilse ve `bytes` değilse JSON olur. + +`mime_type`'ı siz bildirirsiniz; varsayılan olarak `text/plain`. SDK, bunu tahmin etmek için döndürdüğünüz şeyi asla incelemez; bu yüzden etiketlemediğiniz bir `dict` kaynağı yine düz metin olarak duyurulur. + +!!! tip + Bunları fonksiyondan türetmek istemediğinizde `@mcp.resource()`, `name=`, `title=` ve + `description=` parametrelerini de kabul eder. Yazacak bir fonksiyon hiç olmadığında ise + `mcp.server.mcpserver.resources` içinde, `mcp.add_resource(...)` ile kaydedeceğiniz hazır + `Resource` sınıfları var (`TextResource`, `BinaryResource`, `FileResource`, `HttpResource`, + `DirectoryResource`). + +İstemci bir kaynağa **abone** de olabilir ve kaynak değiştiğinde bildirim alabilir; bu, hikâyenin istemci tarafı ve **[İstemci](../client/index.md)** sayfasında anlatılır. + +## Özet {#recap} + +* Bir fonksiyonun üzerindeki `@mcp.resource(uri)` onu kaynak yapar. URI adrestir, dönüş değeri içeriktir, docstring açıklamadır. +* URI'deki bir `{placeholder}` onu **şablon** yapar: `resources/templates/list` altında listelenir ve eşleşen her URI'ye tek bir fonksiyon hizmet verir. +* Yer tutucu adları fonksiyonun parametre adlarıyla aynı olmalıdır. Yanlış yaparsanız bunu üretimde değil, içe aktarma sırasında öğrenirsiniz. +* Fonksiyonunuz kaynak listelendiğinde değil, **okunduğunda** çalışır. +* `str` metin olur, `bytes` base64 blob olur, geri kalan her şey JSON metni olur. Etiketi `mime_type=` ile koyarsınız. +* Araçlar modelin eyleme geçmesi içindir. Kaynaklar uygulamanın okuması içindir. + +Üçüncü temel yapı taşı, yani bir kişinin menüden seçtiği, **[Prompt'lar](prompts.md)**. diff --git a/i18n/tr/pages/servers/structured-output.md b/i18n/tr/pages/servers/structured-output.md new file mode 100644 index 0000000000..37153dbd16 --- /dev/null +++ b/i18n/tr/pages/servers/structured-output.md @@ -0,0 +1,250 @@ +--- +translation: + sections: [a838d57f003aed44, 857d03886a0137ed, 42d9efcb9f542867, 2290ff08435b5573, e866c192e11d1c14, 6cdbad079f7b47f0, d4b607372fb28b51, 18dbf726ac45e0b7, c6f7d2a148aa49f4, c851964bb3301907, d715db6f8dccc9cc, ef86634aa70498a7] + tool: 1 +--- +# Yapılandırılmış çıktı {#structured-output} + +Düz bir `str` döndüren bir araç, sonucu iki kez üretir: `content` içinde metin olarak ve `structured_content` içinde `{"result": "..."}` olarak. + +Bu sayfa o ikinci kanalla ilgili: nereden geldiği, alabileceği her biçim ve SDK'nın onu nasıl tutarlı tuttuğu. + +Kısaca: **dönüş türü açıklaması (annotation) çıktı şemasıdır**. Onu zaten yazdınız. + +## Çıktı şeması {#the-output-schema} + +```python title="server.py" hl_lines="9" +--8<-- "docs_src/structured_output/tutorial001.py" +``` + +Önemli olan satır imza: `-> int`. + +Bu sayede SDK'nın `tools/list` sırasında gönderdiği araç, parametrelerinizden oluşturduğu girdi şemasının yanında (onu **[Araçlar](tools.md)** sayfası anlatır) bir de `output_schema` taşır: + +```json +{ + "properties": { + "result": {"title": "Result", "type": "integer"} + }, + "required": ["result"], + "title": "get_temperatureOutput", + "type": "object" +} +``` + +Tek başına bir `int` JSON nesnesi değildir, bu yüzden SDK onu `{"result": ...}` içine **sarar**. Aracı çağırdığınızda iki kanal da dolar: + +```python +result.content # [TextContent(text="17")] +result.structured_content # {"result": 17} +``` + +Her skaler aynı sarmalayıcıyı alır: `str`, `int`, `float`, `bool`, `bytes`, `None`. + +## İki kanal {#two-channels} + +Neden aynı değer iki kez gönderiliyor? + +* `content` **model** içindir. Bir dil modeli metin okur; sonucun gördüğü tek kısmı budur. +* `structured_content`, modelin içinde çalıştığı **uygulama** içindir: "17" geçen bir cümle değil, `17` isteyen kod. +* `output_schema` ikisi arasındaki sözleşmedir ve araç daha hiç çağrılmadan yayımlanır. + +Siz tek bir Python değeri döndürürsünüz. Üçünü de SDK doldurur. + +## Bir model döndürme {#return-a-model} + +Biçimi bir Pydantic `BaseModel` olarak bildirin ve bir örneğini döndürün: + +```python title="server.py" hl_lines="8-11 15" +--8<-- "docs_src/structured_output/tutorial002.py" +``` + +Artık şema `WeatherData`'nın **kendisi**. Sarmalayıcı yok, `result` anahtarı yok: + +```json +{ + "properties": { + "temperature": {"description": "Degrees Celsius.", "title": "Temperature", "type": "number"}, + "humidity": {"description": "Relative humidity, 0 to 1.", "title": "Humidity", "type": "number"}, + "conditions": {"title": "Conditions", "type": "string"} + }, + "required": ["temperature", "humidity", "conditions"], + "title": "WeatherData", + "type": "object" +} +``` + +`structured_content` alan alan o nesnedir: + +```python +result.structured_content # {"temperature": 16.2, "humidity": 0.83, "conditions": "Overcast"} +``` + +Model de dışarıda kalmaz. SDK aynı nesneyi `content` için JSON metnine serileştirir: + +```json +{ + "temperature": 16.2, + "humidity": 0.83, + "conditions": "Overcast" +} +``` + +`temperature` ve `humidity` üzerindeki `Field(description=...)` bilgisinin şemaya düştüğüne dikkat edin. **Girdilerinizi** tanımlayan aynı `Field`, çıktılarınızı da tanımlar. + +!!! info + FastAPI'nin `response_model`'ını kullandıysanız bunu zaten biliyorsunuz: bildirilen yanıt olarak + bir Pydantic modeli, sizin yerinize serileştirilir ve belgelenir. Tek fark, burada bildirimin + tamamının dönüş açıklaması olmasıdır. + +## Bir `TypedDict` {#a-typeddict} + +Her biçim bir sınıfı hak etmez. Bir `TypedDict` aynı şemayı üretir: + +```python title="server.py" hl_lines="8" +--8<-- "docs_src/structured_output/tutorial003.py" +``` + +`TypedDict` çalışma zamanında düz bir `dict`'tir; siz de onu oluşturup döndürürsünüz. Şema, doğrulama ve `structured_content`, `BaseModel` sürümüyle birebir aynıdır (`TypedDict`'te yeri olmayan açıklamalar hariç). + +## Bir dataclass {#a-dataclass} + +Dataclass'lar da çalışır; öznitelikleri tür ipucu taşıyan herhangi bir sıradan sınıf da öyle. SDK arka planda açıklamalardan bir Pydantic modeli oluşturur. + +```python title="server.py" hl_lines="8-9" +--8<-- "docs_src/structured_output/tutorial004.py" +``` + +Üç yazım, tek şema. Kod tabanınızda hangisi varsa onu kullanın. + +## Listeler {#lists} + +Bir `list[...]` de JSON nesnesi değildir, bu yüzden `{"result": ...}` sarmalayıcısını alır; öğe türünüz içinde bir `$defs` başvurusu olarak yer alır: + +```python title="server.py" hl_lines="15" +--8<-- "docs_src/structured_output/tutorial005.py" +``` + +```json +{ + "$defs": { + "WeatherData": { + "properties": { + "temperature": {"title": "Temperature", "type": "number"}, + "humidity": {"title": "Humidity", "type": "number"}, + "conditions": {"title": "Conditions", "type": "string"} + }, + "required": ["temperature", "humidity", "conditions"], + "title": "WeatherData", + "type": "object" + } + }, + "properties": { + "result": {"items": {"$ref": "#/$defs/WeatherData"}, "title": "Result", "type": "array"} + }, + "required": ["result"], + "title": "get_forecastOutput", + "type": "object" +} +``` + +İki günlük bir tahmin istediğinizde `structured_content`, `{"result": [{...}, {...}]}` olur. `content` ise öğe başına bir tane olmak üzere **iki** `TextContent` bloğuna dönüşür: liste, model için tek bir dizge olarak dökülmek yerine düzleştirilir. + +`tuple[...]`, union'lar ve `Optional[...]` aynı şekilde sarılır. + +## Sözlükler {#dictionaries} + +`dict[str, ...]` zaten bir JSON nesnesi *olan* tek generic türdür, bu yüzden sarılmaz: + +```python title="server.py" hl_lines="9" +--8<-- "docs_src/structured_output/tutorial006.py" +``` + +```json +{ + "additionalProperties": {"type": "number"}, + "title": "get_temperaturesDictOutput", + "type": "object" +} +``` + +```python +result.structured_content # {"London": 16.2, "Reykjavik": 4.4} +``` + +Anahtarlar `str` olmalıdır. Bir `dict[int, float]` JSON nesnesi olamaz, bu yüzden `{"result": ...}` sarmalayıcısına geri düşer. + +## Doğrulama {#validation} + +`output_schema` belgeleme değildir. Fonksiyonunuz ne döndürürse döndürsün, sunucudan çıkmadan önce **ona göre doğrulanır**. + +Değeri elle oluşturduğunuz sürece bunu fark etmezsiniz: Pydantic, `WeatherData`'nızın bir `WeatherData` olduğundan zaten emin olmuştur. Bunu, verinin sizin denetlemediğiniz bir yerden geldiği gün fark edersiniz: + +```python title="server.py" hl_lines="9 21" +--8<-- "docs_src/structured_output/tutorial007.py" +``` + +Açıklama `WeatherData` vaat ediyor. Üst servisin yanıtı `humidity` göndermeyi bırakmış. + +!!! check + `get_weather`'ı çağırdığınızda istemciye sessizce yarı boş bir nesne vermez. Çağrı başarısız + olur ve hatanın ilk satırları alanın adını verir: + + ```text + Error executing tool get_weather: 1 validation error for WeatherData + humidity + Field required [type=missing, input_value={'temperature': 16.2, 'conditions': 'Overcast'}, input_type=dict] + ``` + + Bu metin, `is_error=True` ile araç sonucu olarak geri döner; böylece model, var olmayan bir hava + durumunu kendinden emin biçimde okumak yerine çağrının başarısız olduğunu bilir. + +Bu arada, `-> WeatherData` bir araçtan düz bir `dict` döndürmek sorun değil. `json.loads`'un ürettiği tam olarak buydu. Doğrulama Python türüne değil, değere uygulanır. + +## Devre dışı bırakma {#opting-out} + +Bazen dönüş açıklaması protokol için değil, tür denetleyiciniz içindir. `structured_output=False` geçirin; araç yalnızca metin üretir: + +```python title="server.py" hl_lines="6" +--8<-- "docs_src/structured_output/tutorial008.py" +``` + +`output_schema` yok, sarmalama yok, doğrulama yok. `structured_content` `None`'dır ve `content` döndürdüğünüz dizgedir. + +Tersi olan `structured_output=True`, otomatik algılamayı bir zorunluluğa çevirir: dönüş türü şema üretemeyen bir araç, metne geri düşmek yerine içe aktarma anında istisna fırlatır. + +## Tür ipucu olmayan bir sınıf {#a-class-without-type-hints} + +İstemeden yapılandırılmamış sonuca varmanın bir yolu vardır: **gövdesinde hiç açıklama olmayan** bir sınıf döndürmek. + +```python title="server.py" hl_lines="6-9" +--8<-- "docs_src/structured_output/tutorial009.py" +``` + +`Station`, `name` ve `online` değerlerini `__init__` içinde atar, ama *sınıf* hiçbir şey bildirmez. SDK sınıf açıklamalarını okur, hiçbir şey bulamaz ve vazgeçer. + +!!! warning + **Sessizce** vazgeçer. `output_schema` `None`'dır, `structured_content` `None`'dır ve modelin + okuduğu metin nesnenin `repr`'idir: + + ```text + "" + ``` + + Hata yok, uyarı yok, işe yaramaz bir araç. Açıklamaları sınıf gövdesine taşıyın ya da + `structured_output=True` geçirin; bu, modül içe aktarıldığı anda durumu kesin bir hataya çevirir: + `Function get_station: return type is not serializable for structured output`. + +!!! tip + Tam denetim mi gerekiyor (`CallToolResult`'ı kendiniz oluşturmak ya da uygulamanın görüp + modelin göremediği bir `_meta` eklemek)? Bunun yeri **[Düşük seviyeli Server](../advanced/low-level-server.md)**. + +## Özet {#recap} + +* **Dönüş türü açıklaması** çıktı şemasıdır. `tools/list` içinde `output_schema` olarak yayımlanır. +* Skalerler, listeler, tuple'lar ve union'lar `{"result": ...}` içine sarılır. Modeller, `TypedDict`'ler, dataclass'lar, açıklamalı sınıflar ve `dict[str, ...]` zaten nesnedir ve oldukları gibi kalırlar. +* Her sonuç hem `content` (metin, model için) **hem de** `structured_content` (veri, uygulama için) taşır. +* Döndürdüğünüz şey şemaya göre doğrulanır. Uyuşmazlık bozuk bir sonuç değil, bir araç hatasıdır. +* `structured_output=False` bir aracı devre dışı bırakır. Tür ipucu olmayan bir sınıf sessizce devre dışı kalır; buna dikkat edin. + +Artık bir aracın geri söyleyebileceği her şeye hâkimsiniz. Sırada ikinci ilkel yapı var: **[Kaynaklar](resources.md)**. diff --git a/i18n/tr/pages/servers/tools.md b/i18n/tr/pages/servers/tools.md new file mode 100644 index 0000000000..b12947a61c --- /dev/null +++ b/i18n/tr/pages/servers/tools.md @@ -0,0 +1,177 @@ +--- +translation: + sections: [e4cc390d56573409, 8566e2b68594e9ad, 2c97b9f888398951, 048e5471dfa71aea, 3076b1e16ad95950, edbedf2a16e71311, 3d8ef8da89fa87c1, f6c0e02e6ea5a363] + tool: 1 +--- +# Araçlar {#tools} + +**Araç**, modelin çağırabildiği bir fonksiyondur. + +Sıradan bir Python fonksiyonunun üstüne `@mcp.tool()` koyarak bir araç tanımlarsınız. API'nin tamamı bu. + +## İlk aracınız {#your-first-tool} + +```python title="server.py" hl_lines="6-8" +--8<-- "docs_src/tools/tutorial001.py" +``` + +Yazdığınıza bir bakın. Şema yok, JSON yok, protokol yok; yalnızca bir fonksiyon. SDK ondan üç şey okur: + +* Aracın **adı** fonksiyonun adıdır: `search_books`. +* Modelin gördüğü **açıklama** docstring'dir: `Search the catalog by title or author.` +* Modelin geçirmesine izin verilen **argümanlar** tür ipuçlarından gelir: `query: str` ve `limit: int`. + +### Girdi şeması {#the-input-schema} + +SDK bu tür ipuçlarından bir JSON Schema üretir ve `tools/list` sırasında istemciye gönderir: + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"title": "Limit", "type": "integer"} + }, + "required": ["query", "limit"], + "title": "search_booksArguments" +} +``` + +Hiçbirinin varsayılan değeri olmadığı için iki argüman da `required` içinde. Bunu birazdan düzelteceksiniz. (`title` anahtarları Pydantic'in ürettiği kalıntılardır; sözleşmeyi oluşturan şey özellikler, türleri ve `required`'dır.) + +!!! tip + Tür ipuçları burada dokümantasyon değildir. **Sözleşmenin ta kendisidir**. Bir istemci `"limit": "ten"` + gönderirse SDK bunu, fonksiyonunuz daha çalışmadan reddeder. + +### Modele dönen sonuç {#what-the-model-gets-back} + +Aracı `{"query": "dune", "limit": 5}` ile çağırın; sonuç iki parçadan oluşur: + +```python +result.content # [TextContent(text="Found 3 books matching 'dune' (showing up to 5).")] +result.structured_content # {'result': "Found 3 books matching 'dune' (showing up to 5)."} +``` + +`content`, **modelin** okuduğu metindir. `structured_content` ise **istemci uygulama** için tür bilgisi taşıyan veridir. Dönüş türünü `-> str` olarak bildirdiğiniz için oradadır. + +`structured_content`'i şimdilik dert etmeyin. Araçlarınızdan gerçek Python nesneleri döndürün, gerisi doğru şekilde halledilir; **[Yapılandırılmış çıktı](structured-output.md)** sayfası tamamen bununla ilgili. + +### Deneyin {#try-it} + +Sunucuyu MCP Inspector ile çalıştırın: + +```console +uv run mcp dev server.py +``` + +Yazdırdığı URL'yi açın, **Tools** sekmesine gidin ve `search_books`'u çağırın. + +Inspector, zorunlu bir `query` metin alanı ve zorunlu bir `limit` sayı alanı içeren bir form gösterir. Bu formu tür ipuçlarınızdan oluşturdu. Diğer tüm MCP istemcileri de aynısını yapar. + +## İsteğe bağlı argümanlar {#optional-arguments} + +Bir parametreye varsayılan değer verin, zorunlu olmaktan çıkar. Hepsi bu. Bildiğiniz Python. + +```python title="server.py" hl_lines="7" +--8<-- "docs_src/tools/tutorial002.py" +``` + +Şema da buna uyar: + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"default": 10, "title": "Limit", "type": "integer"} + }, + "required": ["query"], + "title": "search_booksArguments" +} +``` + +`limit`, `required` listesinden çıktı ve `"default": 10` kazandı. Onu göndermeyen bir istemci, tıpkı Python'da olacağı gibi `10` alır. + +## `Field` ile daha zengin şemalar {#richer-schemas-with-field} + +Tür ipuçları sizi epey ileri götürür, ancak bazen bir argümanı *açıklamak* ya da kısıtlamak istersiniz. + +Türü `Annotated` içine sarın ve bir Pydantic `Field` ekleyin: + +```python title="server.py" hl_lines="12-14" +--8<-- "docs_src/tools/tutorial003.py" +``` + +Üç yeni şey var, hepsi parametrelerin üzerinde: + +* `Field(description=...)`: modelin docstring'le birlikte okuduğu, argümana özel bir açıklama. +* `Field(ge=1, le=50)`: sayısal sınırlar. Şemaya `"minimum": 1, "maximum": 50` olarak yansırlar. +* `Literal["fiction", "non-fiction", "poetry"]`: bir enum. Model yalnızca bunlardan birini seçebilir. + +!!! check + Kısıtlamalar süs değildir. Aracı `limit=999` ile çağırın; SDK, **fonksiyonunuz çalışmadan önce** + bir araç hatasıyla yanıt verir: + + ```text + Input should be less than or equal to 50 + ``` + + Bu hata araç sonucu olarak modele geri döner; model onu okur ve geçerli bir değerle yeniden dener. + `le=50` ifadesini bir kez yazdınız ve kendi kendini düzelten ajanları bedavaya elde ettiniz. + +!!! info + FastAPI veya Pydantic kullandıysanız bunların hepsini zaten biliyorsunuz. Aynı `Field`, + aynı `Annotated`, aynı doğrulama. Burada MCP'ye özgü öğrenilecek hiçbir şey yok. + +## Parametre olarak model {#a-model-as-a-parameter} + +Bir araç birkaç taneden fazla argüman aldığında bunları bir Pydantic modelinde toplayın: + +```python title="server.py" hl_lines="8-11 15" +--8<-- "docs_src/tools/tutorial004.py" +``` + +`Book` şeması aracın girdi şemasının içine (bir `$defs` referansı olarak) yerleştirilir, model onu bir JSON nesnesi olarak doldurur ve fonksiyonunuz zaten doğrulanmış, `.title`, `.author` ve `.year` öznitelikleri olan **gerçek bir `Book` örneği** alır. + +Dilediğiniz gibi karıştırabilirsiniz: model parametrelerinin yanında sıradan parametreler, iç içe modeller, model listeleri. Baştan sona Pydantic. + +## `async def` {#async-def} + +Bir araç G/Ç yapıyorsa (bir API çağırıyor, dosya okuyor, veritabanı sorguluyorsa) onu `async def` olarak bildirin ve içinde `await` kullanın. SDK onu await eder. + +Sıradan bir `def` araç da çalışır: SDK onu bir iş parçacığında çalıştırır, böylece sunucuyu asla engellemez. + +Yapılandırılacak başka bir şey yok. + +## Adlar, başlıklar ve annotation'lar {#names-titles-and-annotations} + +SDK'nın çıkarsadığı her şeyi dekoratörde geçersiz kılabilirsiniz: + +```python title="server.py" hl_lines="7-10" +--8<-- "docs_src/tools/tutorial005.py" +``` + +* `title`, arayüzler için insanların okuyabileceği bir addır. İstemciler `search_books` yerine *"Search the catalog"* gösterir. +* `annotations`, istemci için davranışsal **ipuçlarıdır**: + * `read_only_hint=True`: bu araç hiçbir şeyi değiştirmez. + * `open_world_hint=False`: açık web üzerinde değil, kapalı bir şeyler kümesi (bu katalog) üzerinde çalışır. + * Diğer ikisi, `destructive_hint` ve `idempotent_hint`, *yazan* bir aracı tanımlar: bir şeyi + silebilir mi, ve onu iki kez çağırmak bir kez çağırmakla aynı şey mi? Spesifikasyon her ikisini de + yalnızca salt okunur olmayan araçlar için tanımlar; bu yüzden `search_books` üzerinde hiçbir şey ifade etmezler. + +Kurallara uyan bir istemci bunları *"bunu çalıştırmadan önce kullanıcıya sormam gerekir mi?"* gibi kararlar vermek için kullanır. Bunlar ipucudur, güvenlik değil. Bir istemcinin bunlara uyacağına asla güvenmeyin. + +!!! tip + Adı ve açıklamayı fonksiyon adından ve docstring'den türetmek istemiyorsanız `@mcp.tool()` + `name=` ve `description=` de kabul eder. Çoğu zaman türetmek istersiniz. + +## Özet {#recap} + +* Bir fonksiyonun üstündeki `@mcp.tool()` onu araç yapar. Ad fonksiyondan, açıklama docstring'den gelir. +* Tür ipuçları girdi şemasının **ta kendisidir**. Varsayılan değerler argümanları isteğe bağlı yapar. +* `Annotated[..., Field(...)]` açıklama ve kısıtlama ekler; `Literal` enum ekler. +* Yapılandırılmış bir "gövde" almanın yolu Pydantic model parametresidir. +* Hatalı argümanlar sizin yerinize reddedilir; hem de modelin okuyup toparlanabileceği bir hatayla. +* G/Ç için `async def`, geri kalan her şey için sıradan `def`. + +`return` ettiğiniz değerin başına neler geldiği **[Yapılandırılmış çıktı](structured-output.md)** sayfasında. diff --git a/i18n/tr/pages/servers/uri-templates.md b/i18n/tr/pages/servers/uri-templates.md new file mode 100644 index 0000000000..b24445fc74 --- /dev/null +++ b/i18n/tr/pages/servers/uri-templates.md @@ -0,0 +1,281 @@ +--- +translation: + sections: [4a7033e1ed8ad602, 55dcbfff0c6271bf, 101ef9d14bf4ec46, 4b6c4a845438abc7, f98b46bafbee4acd] + tool: 1 +--- +# URI şablonları ve yol güvenliği {#uri-templates-and-path-safety} + +Bu sayfa, [`@mcp.resource`](resources.md) dekoratörünün kabul ettiği URI +şablonu sözdiziminin ve SDK'nın çıkarılan değerlere uyguladığı yol +güvenliği politikasının başvuru kaynağıdır. Kaynakların ne olduğuna ve +ne zaman kullanılacağına dair bir giriş için **[Kaynaklar](resources.md)** +sayfasıyla başlayın; bu sayfa, kaynak bildirmeye zaten alışkın olduğunuzu +ve operatör setinin tamamını, güvenlik ayarlarını ya da düşük seviyeli +bağlantıları aradığınızı varsayar. + +Şablon sözdizimi [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570) +standardıdır. SDK, gelen `resources/read` URI'lerini eşleştirmek için +seçilmiş bir alt kümeyi destekler; buna ek olarak, sunmayı amaçladığınız +dizinin dışına çözümlenecek değerleri reddeden bir güvenlik katmanı vardır. +Protokol düzeyindeki ayrıntılar (mesaj biçimleri, yaşam döngüsü, sayfalama) +için [MCP kaynaklar belirtimine](https://modelcontextprotocol.io/specification/latest/server/resources) +bakın. + +## Operatör setinin tamamı {#the-full-operator-set} + +Düz yer tutucu `{user_id}`, **[Kaynaklar](resources.md)** sayfasının tanıttığı +biçimdir. Dört operatör biçimi daha var; yan yana görebilmeniz için hepsi tek +bir sunucuda: + +```python title="server.py" hl_lines="16-17 22-23 28-29 34-35 40-41" +--8<-- "docs_src/uri_templates/tutorial001.py" +``` + +Vurgulanan her dekoratör, URI'yi parçalamanın farklı bir yoludur. +Aşağıdaki bölümler bunları yukarıdan aşağıya ele alır. + +### Basit genişletme: `{name}` {#simple-expansion-name} + +`books://{isbn}` düz, gündelik biçimdir. Yer tutucu `isbn` parametresine +eşlenir; yani `books://978-0441172719` okuyan bir istemci +`get_book("978-0441172719")` çağrısına yol açar. + +Düz bir `{name}` ilk `/` karakterinde durur. `books://978/extra` eşleşmez +çünkü `978`'den sonraki eğik çizgi yakalamayı bitirir ve `/extra` artar. + +### Tür dönüşümü {#type-conversion} + +Çıkarılan değerler dize olarak gelir, ancak daha belirli bir tür +bildirebilirsiniz; SDK dönüştürür. `orders://{order_id}`, parametresi +`order_id: int` olan bir fonksiyona düşer; dolayısıyla `orders://12345` +okumak `get_order("12345")` değil `get_order(12345)` çağrısını yapar. +İşleyici, tür dönüştürme yapmadan üzerinde aritmetik işlem yapar +(`order_id + 1`). + +### Çok segmentli yollar: `{+name}` {#multi-segment-paths-name} + +Eğik çizgi içeren bir değeri yakalamak için `{+name}` kullanın. +`manuals://{+path}` ile: + +* `manuals://returns.md`, `path = "returns.md"` verir +* `manuals://printing/setup.md`, `path = "printing/setup.md"` verir + +Değer hiyerarşik olduğunda `{+name}` biçimine başvurun: dosya sistemi +yolları, iç içe nesne anahtarları, vekillik ettiğiniz URL yolları. + +### Sorgu parametreleri: `{?a,b,c}` {#query-parameters-abc} + +`reviews://{isbn}{?limit,sort}`, `limit` ve `sort` parametrelerini `?` +işaretinin ardına koyar. Yol *hangi* kitap olduğunu belirler; sorgu onu +*nasıl* okuduğunuzu ayarlar. + +Sorgu parametreleri esnek eşleştirilir: sıra önemli değildir, fazlalıklar +yok sayılır ve verilmeyen parametreler fonksiyonunuzun varsayılanlarına +düşer. Yani `reviews://978-0441172719`, `limit=10, sort="newest"` kullanır; +`reviews://978-0441172719?sort=top` ise yalnızca `sort` değerini geçersiz +kılar. + +### Liste olarak yol segmentleri: `{/name*}` {#path-segments-as-a-list-name} + +Her yol segmentini eğik çizgili tek bir dize yerine ayrı birer liste öğesi +olarak istiyorsanız `{/name*}` kullanın. `shelves://browse{/path*}` ile, +`shelves://browse/fiction/sci-fi` okuyan bir istemci +`browse_shelf(["fiction", "sci-fi"])` çağrısına yol açar. + +### Şablon başvurusu {#template-reference} + +En yaygın kalıplar: + +| Kalıp | Örnek girdi | Elde ettiğiniz | +|--------------|-----------------------|-------------------------| +| `{name}` | `alice` | `"alice"` | +| `{name}` | `docs/intro.md` | *eşleşme yok* (`/` karakterinde durur) | +| `{+path}` | `docs/intro.md` | `"docs/intro.md"` | +| `{.ext}` | `.json` | `"json"` | +| `{/segment}` | `/v2` | `"v2"` | +| `{?key}` | `?key=value` | `"value"` | +| `{?a,b}` | `?a=1&b=2` | `"1"`, `"2"` | +| `{/path*}` | `/a/b/c` | `["a", "b", "c"]` | + +### Ayrıştırıcının reddettikleri {#what-the-parser-rejects} + +Birkaç şablon biçimi, ilk istekte başarısız olmak yerine en baştan +yakalanır. `@mcp.resource`, şablonu dekoratör çalıştığında ayrıştırır; +bu yüzden bunların hiçbiri çalışan bir sunucuya ulaşmaz. + +`UriTemplate.parse()`, şu durumlarda `InvalidUriTemplate` fırlatır: + +* **Aralarında hiçbir şey olmayan iki değişken.** `manuals://{+path}{ext}` + reddedilir: eşleştirme, `path` değişkeninin nerede bitip `ext` + değişkeninin nerede başladığını ayırt edemez. Aralarına bir sabit + koyun (`manuals://{+path}/{ext}`) ya da kendi ayırıcısını sağlayan bir + operatör kullanın. `manuals://{+path}{.ext}` kabul edilir, çünkü `{.ext}` + `.` karakterini kendisi getirir. +* **Birden fazla çok segmentli değişken.** Şablon başına en fazla bir + `{+var}`, `{#var}` ya da patlatılmış (exploded) değişken (`{/var*}`, + `{.var*}`, `{;var*}`). İki tanesi doğası gereği belirsizdir: fazladan + bir segmenti hangisinin yutacağına karar vermenin ilkeli bir yolu yoktur. +* **Olağan sözdizimi hataları**: kapatılmamış bir süslü parantez, iki kez + kullanılan bir değişken adı ya da SDK'nın desteklemediği bir RFC 6570 + özelliği, örneğin `{var:3}` önek değiştiricisi veya `{?vars*}` sorgu + patlatması. + +Bunun üstüne, bir işleyici parametresi şablonun sondaki `{?...}`/`{&...}` +dizisindeki bir sorgu değişkenine bağlı olup Python varsayılanı yoksa +`@mcp.resource` `ValueError` fırlatır. Bu değişkenler esnek eşleştirilir +(istemci herhangi birini atlayabilir); bu yüzden varsayılanı olmayan bir +parametre, onu atlayan ilk istekte yalnızca anlaşılmaz bir iç hata olarak +ortaya çıkardı. Yukarıdaki sunucudaki `reviews://{isbn}{?limit,sort}` düzgün +biçimli sürümdür: `limit` ve `sort` varsayılan taşır. + +## Güvenlik {#security} + +Şablon parametreleri istemciden gelir. Denetlenmeden dosya sistemi veya +veritabanı işlemlerine akarlarsa, `../../etc/passwd` gibi değerler sunmayı +amaçladığınız dizinin dışına çözümlenebilir. + +### SDK'nın varsayılan olarak denetledikleri {#what-the-sdk-checks-by-default} + +İşleyiciniz çalışmadan önce SDK, şu özelliklere sahip her parametreyi +reddeder: + +* `..` bileşenleriyle başlangıç dizininden kaçacak olanlar +* mutlak yol (`/etc/passwd`, `C:\Windows`) ya da Windows sürücüye göreli + yol (`C:foo`) gibi görünenler. Sürücüye göreli bir değer ile `x:y` gibi + ad alanlı bir tanımlayıcı dize olarak ayırt edilemez; bu yüzden tek + harf artı iki nokta üst üste biçimindeki her değer varsayılan olarak + reddedilir. Parametre meşru olarak böyle değerler alıyorsa onu muaf tutun +* null bayt (`\x00`) içerenler + +`..` denetimi alt dize taraması değil, bileşen tabanlıdır. `v1.0..v2.0` ya +da `HEAD~3..HEAD` gibi değerler geçer, çünkü orada `..` tek başına bir yol +segmenti değildir. + +Bu denetimler kodu çözülmüş değere uygulanır; dolayısıyla URI içinde nasıl +kodlanmış olursa olsun dizin geçişini yakalarlar (`../etc`, `..%2Fetc`, +`%2E%2E/etc`, `..%5Cetc`, `%00` hepsi yakalanır). + +!!! check + Yukarıdaki sunucudan `manuals://../etc/passwd` okuyun; istek doğrudan + reddedilir: şablon eşleştirme ilk başarısızlıkta durur, bu yüzden + sonraki (muhtemelen daha gevşek) hiçbir şablon yedek olarak denenmez. + İstemci, hiçbir şablonla eşleşmeyen bir URI için göreceği `-32602` + "Unknown resource" hatasının aynısını görür ve `read_manual` hiç + çalışmaz. + +### Dosya sistemi işleyicileri: safe_join kullanın {#filesystem-handlers-use-safe_join} + +Yerleşik denetimler yaygın durumları durdurur ama sizin sandbox sınırınızı +bilemez. Dosya sistemi erişimi için yolu çözümlemek ve temel dizininizin +içinde kaldığını doğrulamak üzere `safe_join` kullanın: + +```python title="server.py" hl_lines="4 14" +--8<-- "docs_src/uri_templates/tutorial002.py" +``` + +`safe_join`, basit bir dize denetiminin kaçıracağı sembolik bağlantı +kaçışlarını, `..` dizilerini ve mutlak yol hilelerini yakalar. Çözümlenen +yol `DOCS_ROOT` dışına çıkarsa `PathEscapeError` fırlatır; bu, istemciye +`ResourceError` olarak yansır. + +### Varsayılanlar engel olduğunda {#when-the-defaults-get-in-the-way} + +Bazen denetimler meşru değerleri engeller. Bir katalog içe aktarma aracı +bilerek mutlak bir yol alabilir ya da bir parametre, işleyicinizin dosya +sistemine dokunmadan güvenle yorumladığı `../sibling` gibi göreli bir +başvuru olabilir. O parametreyi muaf tutun ya da politikayı tüm sunucu için +gevşetin: + +```python title="server.py" hl_lines="9 16-19" +--8<-- "docs_src/uri_templates/tutorial003.py" +``` + +* Dekoratördeki `security=ResourceSecurity(exempt_params={"source"})`, + denetimleri yalnızca o kaynaktaki o tek parametre için atlar. Sunucunun + geri kalanı varsayılan politikayı korur. +* `MCPServer` kurucusundaki `resource_security=`, her kaynak için + varsayılanı belirler. Burada `relaxed`, `..` denetimini tamamen kapatır. + +Yapılandırılabilir denetimler: + +| Ayar | Varsayılan | Ne yapar | +|-------------------------|------------|-------------------------------------| +| `reject_path_traversal` | `True` | Başlangıç dizininden kaçan `..` dizilerini reddeder | +| `reject_absolute_paths` | `True` | `/foo`, `C:\foo`, UNC yollarını ve sürücüye göreli `C:foo` değerini reddeder (`x:y` de yakalanır) | +| `reject_null_bytes` | `True` | `\x00` içeren değerleri reddeder | +| `exempt_params` | boş | Denetimlerin atlanacağı parametre adları | + +Bu denetimler sezgisel bir ön süzgeçtir; dosya sistemi erişimi için +kapsama sınırı `safe_join` olmaya devam eder. + +!!! tip + İşleyiciniz isteği karşılayamıyorsa (dosya yok, kimlik bilinmiyor) bir + istisna fırlatın. SDK bunu bir hata yanıtına dönüştürür. Protokol hatası + ile araç hatası arasındaki fark için **[Hataları ele alma](handling-errors.md)** + sayfasına bakın. + +## Düşük seviyeli Server üzerinde kaynaklar {#resources-on-the-low-level-server} + +Düşük seviyeli `Server` üzerine inşa ediyorsanız (bkz. **[Düşük seviyeli +Server](../advanced/low-level-server.md)**), `resources/list` ve +`resources/read` protokol metotları için işleyicileri doğrudan kaydedersiniz. +Dekoratör yoktur; protokol türlerini kendiniz döndürürsünüz. + +### Statik kaynaklar {#static-resources} + +Sabit URI'ler için bir kayıt defteri tutun ve tam eşleşmeye göre yönlendirin: + +```python title="server.py" hl_lines="17 21 27" +--8<-- "docs_src/uri_templates/tutorial004.py" +``` + +list işleyicisi istemcilere nelerin mevcut olduğunu bildirir; read işleyicisi +içeriği sunar. Önce kayıt defterinizi denetleyin, varsa şablonlara +(aşağıda) geçin, geri kalan her şey için istisna fırlatın. + +### Şablonlar {#templates} + +`MCPServer`'ın kullandığı şablon motoru `mcp.shared.uri_template` içinde +yaşar ve tek başına çalışır. Aynı ayrıştırma ve eşleştirmeyi alırsınız; +yönlendirmeyi ve güvenlik politikasını kendiniz kurarsınız. + +```python title="server.py" hl_lines="13-16 22-25 29 33 45" +--8<-- "docs_src/uri_templates/tutorial005.py" +``` + +Vurgulanan satırlarda üç şey oluyor: + +* **Bir kez ayrıştırın, istek başına eşleştirin.** `UriTemplate.parse()` + şablonu oluşturur; `template.match(uri)` çıkarılan değişkenleri `dict` + olarak, URI uymuyorsa `None` döndürür. URL kod çözme `match()` içinde + olur; kodu çözülmüş değerler yol güvenliği doğrulaması yapılmadan olduğu + gibi döndürülür. Değerler dize olarak çıkar: kendiniz dönüştürün + (`int(matched["id"])`, `Path(matched["path"])`). +* **Güvenlik denetimlerini kendiniz uygulayın.** `MCPServer`'ın varsayılan + olarak çalıştırdığı `..` ve mutlak yol denetimleri + `mcp.shared.path_security` içinde yaşar. `read_manual_safely`, + `MANUALS`'a dokunmadan önce bunları çağırır. Bir parametre dosya sistemi + yolu değilse (ISBN, arama sorgusu), o değer için denetimleri atlayın: + politikayı bir yapılandırma nesnesi üzerinden değil, işleyici başına siz + denetlersiniz. +* **Şablonları aynı kaynaktan listeleyin.** İstemciler şablonları + `resources/templates/list` üzerinden keşfeder. `str(template)` özgün + şablon dizesini geri verir; böylece listeleme ile eşleştirici tek bir + doğruluk kaynağını paylaşır. + +## Özet {#recap} + +* `{name}` tek bir segmentle eşleşir; `{+name}` eğik çizgileri korur; + `{?a,b}` sorgu dizesinden çeker; `{/name*}` segmentleri bir listeye böler. +* Aralarında hiçbir şey olmayan iki değişken ya da ikinci bir çok segmentli + değişken ayrıştırma anında reddedilir. Sondaki bir `{?...}`/`{&...}` + sorgu değişkenine bağlı parametre bir Python varsayılanı bildirmelidir. +* Parametreye tür ipucu verin (`order_id: int`); SDK dönüştürür. +* Varsayılan güvenlik politikası `..`, mutlak yolları ve null baytları + işleyiciniz çalışmadan önce reddeder; kaynak başına + `security=ResourceSecurity(...)` ile, sunucu genelinde + `resource_security=` ile geçersiz kılın. +* Dosya sistemi erişimi için kapsama sınırı `safe_join`'dur. +* Düşük seviyeli `Server` üzerinde `UriTemplate.parse()` ile ayrıştırın, + `.match()` ile eşleştirin ve `mcp.shared.path_security`'yi kendiniz + uygulayın. diff --git a/i18n/tr/pages/translations.md b/i18n/tr/pages/translations.md new file mode 100644 index 0000000000..aa4793aa74 --- /dev/null +++ b/i18n/tr/pages/translations.md @@ -0,0 +1,30 @@ +--- +translation: + sections: [f671b445b16e4f99, 3983a560eb2cece7, 004b3ee918529d8c, c6e2debf1da06eb7, 81d412ed5f399f94] + tool: 1 +--- +# Çeviriler {#translations} + +Bu belgeler İngilizce yazılmıştır. Daha fazla kişinin yararlanabilmesi için makine çevirisiyle hazırlanmış sürümlerini de yayımlıyoruz. Bu sayfa, bunun sizin için ne anlama geldiğini ve çevirileri iyileştirmeye nasıl yardımcı olabileceğinizi açıklar. + +## Mevcut çeviriler {#whats-available} + +Çevrilmiş belgeler şu anda on iki dilde **önizleme** aşamasındadır: Deutsch, español, français, हिन्दी, 日本語, 한국어, português (Brasil), русский язык, Türkçe, українська мова, 简体中文 ve 繁體中文. Herhangi bir sayfanın üst kısmındaki dil seçiciden birini seçin. Bunlar kendini kanıtladıktan sonra başka diller de eklenebilir. + +API başvurusu çevrilmez: çevrilmiş site, tek olan İngilizce başvuruya bağlantı verir. + +## Esas alınan metin İngilizcedir {#english-is-the-source-of-truth} + +Çevrilmiş bir sayfa ile İngilizce aslı çelişirse doğru olan İngilizce sayfadır. Çevrilmiş bir sitenin her sayfası, sayfanın durumunu belirten üç nottan biriyle başlar: + +- **Makine çevirisi** — sayfa otomatik olarak çevrilmiştir ve İngilizce aslına bağlantı verir. +- **İngilizce sayfanın gerisinde kalan çeviri** — İngilizce aslı, sayfa çevrildikten sonra değişmiştir; bu yüzden çeviri yetişene kadar bazı bölümleri güncel olmayabilir. +- **İngilizce gösteriliyor** — sayfanın güncel bir çevirisi yoktur; bu yüzden İngilizce metni okuyorsunuz. + +## Çevirilerin hazırlanışı {#how-the-translations-are-made} + +Çevrilmiş sayfaları, bu depodaki bir araç `docs/` altındaki İngilizce sayfalardan otomatik olarak üretir. Araca her dil için insan eliyle yazılmış iki girdi yol gösterir: bir stil kılavuzu (dil düzeyi, ton, tipografi, şakaların ve deyimlerin nasıl ele alınacağı) ve bir sözlükçe (hangi terimlerin İngilizce kalacağı, geri kalanlar için zorunlu ve yasak karşılıklar). Üretilen metin hiçbir zaman elle düzenlenmez. Her iyileştirme bunun yerine bu girdilere işlenir; böylece sayfalar bir sonraki kez yeniden üretildiğinde kaybolmaz. + +## Çeviri sorunu bildirme {#reporting-a-translation-problem} + +Yanlış bir terim, tuhaf bir cümle ya da İngilizcede olmayan bir şey söyleyen bir çeviri mi buldunuz? Dili, sayfayı ve ilgili bölümü belirterek [bir issue açın](https://github.com/modelcontextprotocol/python-sdk/issues); ana dili konuşanlardan gelen bildirimler özellikle değerlidir. Düzeltmeyi biliyorsanız, doğrudan [`i18n/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/i18n) altındaki ilgili dilin stil kılavuzuna (`instructions.md`) veya sözlükçesine (`glossary.json`) bir pull request olarak önerin. Düzeltme, çeviriler bir sonraki kez yeniden üretildiğinde etkilenen tüm sayfalara ulaşır. İngilizce metnin kendisindeki sorunlar ise diğer belge değişiklikleri gibi `docs/` altındaki sayfalarda düzeltilir. diff --git a/i18n/tr/pages/troubleshooting.md b/i18n/tr/pages/troubleshooting.md new file mode 100644 index 0000000000..03aecb12e5 --- /dev/null +++ b/i18n/tr/pages/troubleshooting.md @@ -0,0 +1,421 @@ +--- +translation: + sections: [2efaecdef109a5c5, fcacd3e66b8635a4, 25323d737dcf0261, 4835ed1772f1d113, 137454d469c867f5, 6392596bd6df54f0, 41126fa9c4fe432f, 480b6d7897e30ab4, d83bb682e708dde0, ebbed3449c499db4, 323ef84f6b4bebde, 30fd31be74169d9a, 656943c6cb567218, c2dc3b1007d2e987, 7cf5386b997d04e9, 0b59feed8384456e, 0cba47bae78d04eb, 954dc21efdb532a3] + tool: 1 +--- +# Sorun giderme {#troubleshooting} + +Bu sayfadaki her başlık, SDK'nın ürettiği bir hatanın birebir metnidir; ardından ne anlama geldiği ve tek hamlelik çözümü gelir. Traceback'inizin (veya sunucu log'unuzun) son satırını tarayıcınızın sayfada bul özelliğiyle burada arayın ve yalnızca o girdiyi okuyun. + +Girdilerin birkaçı şu tek sunucuya karşı çalışır. Bir araç ve bir şablonlu kaynak; her biri tanımadığı bir şehir için istisna fırlatır: + +```python title="server.py" +--8<-- "docs_src/troubleshooting/tutorial001.py" +``` + +Bu sayfanın alıntıladığı hatalar gerçektir: SDK'nın kendi test paketi her birini yeniden üretir. + +## `ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)` {#exceptiongroup-unhandled-errors-in-a-taskgroup-1-sub-exception} + +Bu bir MCP hatası değil. anyio gürültüsüdür ve asıl hatanız yapıştırdığınız metnin **son satırıdır**. + +`Client.__aenter__` bir görev grubu başlatır. anyio, görev grubundan çıkan her şeyi bir `ExceptionGroup` içine sarar; bu yüzden bir `async with Client(...)` bloğundan kaçan *her* istisna, ne olursa olsun, böyle bir grubun içinde gelir: + +```python +async def main() -> None: + async with Client(mcp) as client: + await client.read_resource("weather://Atlantis") +``` + +```text + + Exception Group Traceback (most recent call last): + | ... + | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception) + +-+---------------- 1 ---------------- + | Exception Group Traceback (most recent call last): + | ... + | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception) + +-+---------------- 1 ---------------- + | Traceback (most recent call last): + | ... + | mcp.shared.exceptions.MCPError: No forecast for 'Atlantis'. + +------------------------------------ +``` + +Bununla yapılacak iki şey var: + +1. **En altı okuyun.** Hata `MCPError: No forecast for 'Atlantis'.` satırıdır; bu sayfada *onun* metnini arayın. +2. **Bloğun içinde yakalayın.** `ExceptionGroup` yalnızca istisna `async with` bloğundan *çıktığında* ortaya çıkar. İçeride yakalandığında aynı hata düz bir `MCPError`'dır; ortada hiçbir grup yoktur: + +```python +async def main() -> None: + async with Client(mcp) as client: + try: + await client.read_resource("weather://Atlantis") + except MCPError as e: + print(e) # No forecast for 'Atlantis'. +``` + +!!! tip + *Bağlantı* sırasındaki bir hata (yanlış bir URL, çalışmayan bir sunucu, bu sayfanın + ilerisindeki `421`) `async with`'in kendisinden kaçar; dolayısıyla onu yakalayacak bir + "içerisi" yoktur. Bunlar için grubun en altını okuyun. + +## `RuntimeError: Client must be used within an async context manager` {#runtimeerror-client-must-be-used-within-an-async-context-manager} + +`Client(...)` yalnızca nesneyi kurar. `async with`'e kadar hiçbir şey bağlanmaz; bu yüzden her yöntem reddeder: + +```python +async def main() -> None: + client = Client(mcp) + tools = await client.list_tools() # RuntimeError +``` + +İçine girin. Bağlantının kendisi `__aenter__`'dır: + +```python +async def main() -> None: + async with Client(mcp) as client: + tools = await client.list_tools() +``` + +`__aexit__` ise bağlantının kesilmesidir; unutulacak bir `client.close()` olmamasının nedeni de budur. **[Test etme](get-started/testing.md)** tam olarak bu kalıp üzerine kuruludur. + +## `Error executing tool : ` ve `Unknown tool: ` {#error-executing-tool-name-message-and-unknown-tool-name} + +Okuduğunuz şey bir istisna değil, bir **sonuç**. `call_tool` istisna fırlatmadı ve başarısız olan bir araç için hiçbir zaman fırlatmaz. + +`forecast`'i sunucunun tanımadığı bir şehir için çağırın; fırlattığı istisna, istek *başarılı* olarak işaretlenmiş halde geri döner: + +```python +result.is_error # True +result.content # [TextContent(text="Error executing tool forecast: No forecast for 'Atlantis'.")] +result.structured_content # None +``` + +`Unknown tool: get_forecast`, sunucunun hiç kaydetmediği bir ad için aynı biçimdir; hatalı bir argüman da aynı şekilde, fonksiyonunuz daha hiç çalışmadan, aracın girdi şemasına göre reddedilir. + +Çözüm istemcinizde: **`result.is_error`'ı kontrol edin**. `call_tool` etrafındaki bir `try/except` bunların hiçbirini yakalamaz, çünkü yakalanacak bir şey yoktur. Bu kasıtlıdır ve bu sayfada içselleştirilecek en yararlı tek şeydir: çağrıyı *model* seçti, bu yüzden mesajı ve yeniden deneme şansını da model alır. Ayrıntıların tamamı, *gerçekten* istisna fırlatan `MCPError` yolu dahil, **[Hataları ele alma](servers/handling-errors.md)** sayfasında. + +## `TypeError: The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool` {#typeerror-the-tool-decorator-was-used-incorrectly-did-you-forget-to-call-it-use-tool-instead-of-tool} + +`@mcp.tool()` yerine `@mcp.tool` yazdınız. `tool()` bir dekoratör *fabrikasıdır*: parantezler olmadan Python, fonksiyonunuzu onun `name=` parametresine verir. + +```python +@mcp.tool # <- missing () +def forecast(city: str) -> str: + """Today's forecast for one city.""" + return f"{city}: Rain." +``` + +```text +TypeError: The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool +``` + +Parantezleri ekleyin. `@mcp.resource(...)` ve `@mcp.prompt()` de aynı sürçme için aynı şeyi söyler. + +!!! note + Bu, herhangi bir istemci bağlanmadan önce, modül **içe aktarıldığında** fırlatılır. Yani + sunucunuzu sıfır araçla bağlı olarak değil de *başlatılamadı* (veya *bağlantı kesildi*) + olarak gösteren bir host bu biçimdedir: `python server.py` komutunu kendiniz çalıştırın ve + traceback'i okuyun. Bir tür denetleyicisi de bunu yakalar: bir fonksiyon geçerli bir + `name=` değildir. + +## `Tool already exists: ` {#tool-already-exists-name} + +İki kayıt aynı araç adını kullandı. **İlki** kazanır, ikincisi sessizce düşürülür ve *sunucu log'undaki* bu uyarı tek işarettir: + +```python title="server.py" hl_lines="6 12" +--8<-- "docs_src/troubleshooting/tutorial002.py" +``` + +```text +WARNING mcp.server.mcpserver.tools.tool_manager: Tool already exists: forecast +``` + +`tools/list` tek bir `forecast` bildirir ve o da `forecast_today`'dir. Birinin adını değiştirin. `MCPServer(..., warn_on_duplicate_tools=False)` sonucu değiştirmeden uyarıyı susturur; bu yüzden açık bırakın. Kaynaklar ve prompt'lar için de aynı kural ve aynı log satırı geçerlidir (`Resource already exists:`, `Prompt already exists:`). + +## Host'um sıfır araç listeliyor {#my-host-lists-zero-tools} + +Bunun bir hata metni yoktur; aranmasının zor olmasının nedeni de tam olarak budur. SDK kayıtlı bir aracı `tools/list`'ten asla düşürmez; bu yüzden içeriden dışarıya doğru ilerleyin: + +* **Sunucu hiç başladı mı?** Parantezsiz `@mcp.tool` içe aktarma sırasında fırlatır ve çökmüş bir sunucu bazı host'larda boş bir sunucuya çok benzer. `python server.py` komutunu kendiniz çalıştırın. +* **Araç, host'un çalıştırdığı `mcp` üzerinde mi?** Başka bir modüldeki ikinci bir `MCPServer(...)` farklı, boş bir sunucudur. Host'un komutunun gerçekte hangi nesneyi içe aktardığını kontrol edin. +* **İki araç aynı adı mı paylaştı?** O zaman biri gitmiştir. Sunucu log'unda `Tool already exists:` satırını arayın. +* **Host'un listesi eski mi?** Başlangıçtan sonra eklenen bir araç yalnızca `notifications/tools/list_changed` bildirimini işleyen istemcilere ulaşır. Host'u yeniden başlatmak kaba ama kesin çözümdür. +* **Yönlendirilen pencerenin dışında bir şey `stdout`'a mı yazdı?** SDK hizmet verirken başıboş ve *flush edilmiş* stdout çıktısını stderr'e yönlendirir (elinden geldiğince: standart akışları değiştiren bir ortama olduğu gibi hizmet verilir). Ancak daha önce stdout'a flush edilmiş çıktı (echo yapan bir sarmalayıcı betik, tamponsuz bir süreçte içe aktarma sırasında çalışan bir `print()`) veya yorumlayıcı çıkışında boşaltılan tamponlanmış bir `print()` protokol akışına düşer ve tek bir çöp satır host'un bağlantıyı kesmesine yol açabilir; bazı host'lar bunu içinde hiçbir şey olmayan bir sunucu olarak gösterir. Bunun yerine `logging` modülüyle log tutun. Host tarafı kontrol listesinin geri kalanı **[Gerçek bir host'a bağlanma](get-started/real-host.md)** sayfasında. + +"Geçersiz" bir araç adı bu listede *değildir*: kurala uymayan bir ad log'a bir uyarı yazar, ancak araç yine de kaydedilir ve listelenir. + +## `MCPError: Server returned an error response` {#mcperror-server-returned-an-error-response} + +Sunucu HTTP isteğini, JSON-RPC olmayan bir gövdeyle doğrudan reddetti; bu yüzden python `Client`'ın size gösterebileceği bu yer tutucudan daha iyi bir şey yok. + +Açık ara en yaygın neden, yeni dağıtılmış bir Streamable HTTP sunucusudur. `transport_security=` verilmeyen `streamable_http_app()` (ve `mcp.run("streamable-http")`) varsayılan olarak **DNS rebinding koruması** uygular: yalnızca `Host` başlığı localhost olan istekleri kabul eder. Bu, dizüstü bilgisayarınızda doğru varsayılandır; gerçek bir ana bilgisayar adının arkasında ise yanlış: + +```python title="server.py" hl_lines="12" +--8<-- "docs_src/troubleshooting/tutorial003.py" +``` + +Bunu dağıtın, bir istemciyi ona yönlendirin; bağlantı el sıkışmada başarısız olur: + +```python +async with Client("https://mcp.example.com/mcp") as client: + ... +``` + +```text +mcp.shared.exceptions.MCPError: Server returned an error response +``` + +Sunucunun gerçekte gönderdiği sözcükler, `421` ve `Invalid Host header`, size asla ulaşmaz: 421 gövdesinde `Content-Type: application/json` yoktur, bu yüzden istemci onu ayrıştıramaz. Bunlar **sunucunun log'undadır**; bir sonraki bakılacak yer de orasıdır: + +```text +WARNING mcp.server.transport_security: Invalid Host header: mcp.example.com +``` + +Çözüm `transport_security=`. Gerçekte hizmet verdiğiniz ana bilgisayar adını izin listesine ekleyin: + +```python title="server.py" hl_lines="14-17" +--8<-- "docs_src/troubleshooting/tutorial004.py" +``` + +!!! check + Değişikliğin tamamı bu. Aynı istemci artık bağlanır, `2026-07-28` üzerinde anlaşır ve + `forecast`'i çağırır. + +**[Dağıtım ve ölçekleme](run/deploy.md)** her alanın ne anlama geldiğini, ters vekil sunucu durumunu ve dağıtım sırasında değişen diğer her şeyi ele alır. Hemen aşağıdaki `421 Misdirected Request` / `Invalid Host header` ise aynı hatanın öbür taraftan görünüşüdür. + +## `421 Misdirected Request` / `Invalid Host header` {#421-misdirected-request-invalid-host-header} + +Bu, python `Client` *olmayan* herhangi bir yerden görülen `Server returned an error response`'tır: curl, bir tarayıcının ağ sekmesi, bir ters vekil sunucunun erişim log'u veya başka bir SDK. + +```bash +curl -i https://mcp.example.com/mcp \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}' +``` + +```text +HTTP/1.1 421 Misdirected Request + +Invalid Host header +``` + +`421 Misdirected Request`, HTTP'nin bu durum kodu için kendi gerekçe ifadesidir; `Invalid Host header` SDK'nın yanıt gövdesidir; python `Client` ise aynı olayı `Server returned an error response` olarak gösterir. Üçü de tek bir rettir. Denetim, sunucunun bağlandığı adrese değil, **isteğin taşıdığı `Host` başlığına** karşı çalışır; bu yüzden genel ana bilgisayar adını ileten bir ters vekil sunucu, ona tıpkı doğrudan bir istemci gibi takılır. + +Çözüm, `Server returned an error response` altında gösterilen aynı `transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])`. İki ince noktasını adlandırmaya değer: + +* Bir `allowed_hosts` girdisi birebir bir dizedir. `"mcp.example.com"` yalın bir `Host` başlığıyla, `"mcp.example.com:*"` ise açıkça belirtilmiş herhangi bir portla eşleşir. İkisini de listeleyin. +* Gövdesi `Invalid Origin header` olan bir `403`, `Origin` başlığı üzerindeki kardeş denetimdir. Yalnızca tarayıcılar için tetiklenir (başka hiçbir şey `Origin` göndermez) ve onun izin listesi de `allowed_origins=` parametresidir. + +Denetimi kapatmanın dürüst yapılandırma olduğu durumlar dahil, konunun tamamı **[Dağıtım ve ölçekleme](run/deploy.md)** sayfasında. + +## `RuntimeError: Task group is not initialized. Make sure to use run().` {#runtimeerror-task-group-is-not-initialized-make-sure-to-use-run} + +MCP uygulamanız başka bir ASGI uygulamasının içine bağlanmış (mount edilmiş) ve **oturum yöneticisini** hiçbir şey başlatmamış. + +`mcp.streamable_http_app()`, kendi lifespan'i (yaşam döngüsü) yöneticiyi başlatan bir Starlette uygulaması döndürür ve `uvicorn server:app` bu lifespan'i sizin için çalıştırır. Ancak Starlette **bağlanmış bir alt uygulamanın lifespan'ini asla çalıştırmaz**; bu yüzden uygulama bir `Mount` içine girdiği anda yönetici hiç başlamaz ve ilk istek patlar: + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/troubleshooting/tutorial005.py" +``` + +Sunucu başlar. Rota çözümlenir. Ardından `uvicorn` her istek için şunu yazdırır: + +```text +ERROR: Exception in ASGI application +Traceback (most recent call last): + ... +RuntimeError: Task group is not initialized. Make sure to use run(). +``` + +İstemci bir 500 görür. Çözüm, **ana** uygulamada `mcp.session_manager.run()`'a giren bir lifespan'dir: + +```python +@asynccontextmanager +async def lifespan(app: Starlette) -> AsyncIterator[None]: + async with mcp.session_manager.run(): + yield + + +app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lifespan) +``` + +Bunun sayfası, tek uygulamada birden fazla sunucu ve FastAPI dahil, **[Mevcut bir uygulamaya ekleme](run/asgi.md)**. Aynı sınıftan iki komşu metin: + +* `StreamableHTTPSessionManager .run() can only be called once per instance. Create a new instance if you need to run again.` Yönetici tek kullanımlıktır; aynı uygulamanın lifespan'ine iki kez girmek buna çarpar. +* `mcp.session_manager` yalnızca `streamable_http_app()` çağrıldıktan **sonra** var olur; bu yüzden önce rotaları kurun ve yöneticiye yalnızca lifespan'in içinde dokunun. + +## `MCPError: Session not found` {#mcperror-session-not-found} + +Sunucu, istemcinizin gönderdiği `Mcp-Session-Id`'yi tanımıyor; bunun nedeni neredeyse her zaman sunucunun **yeniden başlamış** olmasıdır (ya da farklı bir örneğe yönlendirilmişsinizdir). Oturumlar o tek sürecin belleğinde yaşar. + +Bulunacak bir sunucu hatası yok. HTTP yanıtı, gövdesi JSON-RPC *olan* bir `404`'tür; bu yüzden yukarıdaki `421`'in aksine python `Client` bunu size birebir gösterir: + +```json +{"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "Session not found"}} +``` + +Çözüm yeniden bağlanmaktır: `async with Client(...)` bloğundan çıkın ve yeni bir oturum üzerinde anlaşan yeni bir bloğa girin. Uzun ömürlü bir istemci için bu, çağrılarınızın etrafında `MCPError`'ı yakalamak ve ölü bir oturumun içinde yeniden denemek yerine bu mesajda yeniden bağlanmak demektir. + +Bu, yeniden başlatma *olmadan* oluyorsa, yapışkan oturumlar olmadan birden fazla worker çalıştırıyorsunuz demektir: her worker kendi oturum tablosunu tutar, bu yüzden yanlış olana yönlendirilen bir istek buraya düşer. Bu konu ve iki çözümü (yapışkan yönlendirme veya `stateless_http=True`) **[Dağıtım ve ölçekleme](run/deploy.md)** ile **[Eski nesil istemcilere hizmet verme](run/legacy-clients.md)** sayfalarında. + +Sunucu operatörü için eşleşen log satırı `Rejected request with unknown or expired session ID: `'dir. `INFO` düzeyinde log'a yazılır; bu yüzden olağan `WARNING` eşiğinde görünmez. Bir dağıtımın hemen ardından bunu öbekler halinde görmek normaldir; bağlı her istemci yeniden bağlanıyordur. + +## `MCPError: Method not found` {#mcperror-method-not-found} + +Bir taraf, diğer tarafın işleyicisi olmayan bir JSON-RPC isteği gönderdi ve `e.error.data` yöntemin adını verir. Olağan neden bir **nesil uyuşmazlığıdır**: bir protokol sürümünde olup diğerinde olmayan bir yöntemin yanlış sürümdeki bir eşe gönderilmesi; örneğin `2025` neslinden bir `resources/subscribe`'ın bir `2026-07-28` bağlantısına ulaşması ya da `mode="legacy"` değerine sabitlenmiş bir istemcinin yalnızca `2026`'da var olan `subscriptions/listen`'ı göndermesi. Hangi tarafın ne konuştuğunun haritası **[Protokol sürümleri](protocol-versions.md)** sayfasıdır; diğer dürüst neden (hiç işleyici kaydetmediğiniz isteğe bağlı bir yetenek) ise **[Tamamlamalar](servers/completions.md)** sayfasında. + +Modern protokolün kaldırdığı bir istek olmasına rağmen bu hatayı **üretmeyen** bir şey var: bir `2026-07-28` bağlantısında `ctx.elicit()` çağıran bir araç. Sunucu o isteği *göndermeyi* baştan reddeder; bu yüzden bunun yerine, bu sayfanın ilerisindeki `Cannot send 'elicitation/create': ...` hatasını alırsınız. + +## `MCPError: Client did not declare the form elicitation capability required by resolver ''` {#mcperror-client-did-not-declare-the-form-elicitation-capability-required-by-resolver-name} + +Sunucunuz kullanıcıya bir şey sormak istiyor ve bu istemci kendisine soru sorulabileceğini hiç söylemedi. + +Bir elicitation (kullanıcıdan bilgi isteme) çözümleyicisi, bağlı istemci form elicitation'ı bildirmediğinde baştan reddeder ve `e.error.data` tam olarak neyin eksik olduğunu adlandırır: + +```json +{ + "code": -32021, + "message": "Client did not declare the form elicitation capability required by resolver 'server:ask_to_confirm'", + "data": {"requiredCapabilities": {"elicitation": {"form": {}}}} +} +``` + +`Client(...)`'a `elicitation_callback=` geçirin. Callback'i kaydetmek yetenek bildiriminin *ta kendisidir*; ikinci bir anahtar yoktur: + +```python +async def main() -> None: + async with Client(mcp, elicitation_callback=handle_elicitation) as client: + result = await client.call_tool("book_table", {"date": "Friday"}) +``` + +**[İstemci callback'leri](client/callbacks.md)** diğerlerini listeler (`sampling_callback`, `list_roots_callback`); her biri aynı şekilde bir bildirimdir. + +!!! info + `-32021`, `MISSING_REQUIRED_CLIENT_CAPABILITY`'dir; 2026-07-28 spesifikasyonunun eklediği + üç hata kodundan biridir. Hiçbiri bir istisna sınıfı değildir: hepsi `MCPError` olarak + gelir ve bakılacak yer `e.error.code`'dur. Sabitleri `mcp.types` dışa aktarır. Diğer ikisi + `-32020` `HEADER_MISMATCH` (bir HTTP başlığı eşlik ettiği istek gövdesiyle uyuşmuyor) ve + `-32022` `UNSUPPORTED_PROTOCOL_VERSION`'dır (istek, bu sunucunun konuşmadığı bir sürümü + belirtmiş). Uyumlu bir SDK istemcisi ikisini de üretemez; bu yüzden birini görürseniz, + istemcinizle sunucunuz arasında istekleri yeniden yazan şey her neyse ona bakın. + +## `MCPError: Elicitation not supported` {#mcperror-elicitation-not-supported} + +`Client did not declare the form elicitation capability ...` ile aynı boşluk; bu kez baştan denetim yapmayan yolların ifadesiyle: sunucunun bir elicitation'ın yanıtlanmasına ihtiyacı vardı ve bağlı istemci hiçbir `elicitation_callback` kaydetmemişti. + +Bunu eski nesil bir bağlantıda `ctx.elicit()`'ten görürsünüz; herhangi bir bağlantıda ise onu yanıtlayacak callback'i olmayan bir istemciye ulaşan, döndürülmüş bir çok turlu (multi-round-trip) sorudan (**[Çok turlu istekler](handlers/multi-round-trip.md)**). Çözüm aynıdır: `Client(...)`'a `elicitation_callback=` geçirin. "Kullanıcıya sorulmadı" durumunun, aracınıza `decline` olarak ulaşan bir hâli yoktur; soru sorulamayan bir istemci başarısız bir çağrı demektir, araçlarınızı buna göre tasarlayın. + +## `MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests.` {#mcperror-cannot-send-elicitationcreate-this-transport-context-has-no-back-channel-for-server-initiated-requests} + +İşleyiciniz, isteğin ortasında istemciye ulaşmaya çalıştı; hem de çağrısının sunucudan gelen bir isteği taşıyabilecek hiçbir kanalı olmayan bir bağlantıda. Bir çağrıyı bu duruma sokan üç sunucu yapılandırması var. + +**Bir `2026-07-28` bağlantısı: her aktarımda, her zaman.** Modern protokolde sunucunun başlattığı istek diye bir şey hiç yoktur; bu yüzden sunucu daha hiçbir şey gönderilmeden reddeder. Bununla karşılaşmanın klasik yolu bir aracın içindeki `ctx.elicit()`'tir (hem de daha ilk bellek içi testte, çünkü `Client(server)` sorulmadan `2026-07-28` üzerinde anlaşır) ve `elicitation_callback=` geçirmek hiçbir şeyi değiştirmez, çünkü istemciye yanıtlayacağı bir istek hiç ulaşmaz: + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/troubleshooting/tutorial006.py" +``` + +```python +async def main() -> None: + async with Client(mcp) as client: + await client.call_tool("book_table", {"date": "Friday"}) +``` + +```text +mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests. +``` + +**`stateless_http=True` bir sunucuda eski nesil bir bağlantı.** Durumsuzluk, her isteğin kendi dünyası olması demektir: oturum yok, sunucudan istemciye akış yok; dolayısıyla bunlara sahip olan nesil için bile bir `elicitation/create` (veya `sampling/createMessage` ya da `roots/list`) gönderecek hiçbir yer yok: + +```python title="server.py" hl_lines="16 23" +--8<-- "docs_src/troubleshooting/tutorial008.py" +``` + +**`json_response=True` bir sunucuda eski nesil bir bağlantı.** `POST` tek bir JSON gövdesiyle yanıtlanır ve tek bir gövde yalnızca yanıtı taşır; bu yüzden isteğin ortasındaki bir `ctx.elicit()`'in ihtiyaç duyduğu istek kapsamlı akış burada da yoktur. Oturum, onun `Mcp-Session-Id`'si ve bağımsız akışı hâlâ yerindedir; giden yalnızca istek kapsamlı kanaldır. + +Mesaj, gönderemediği yöntemin adını verir. Sunucunun fırlattığı sınıf `NoBackChannelError`'dır, ancak ağ üzerinden yalnızca temel `MCPError` taşınır; bu yüzden traceback'inizin son satırı sınıf adı değil, yukarıdaki cümledir. + +Bir `2026-07-28` istemcisi için çözüm üçünde de aynıdır: çağrının ortasında geriye uzanmayın. Soruyu bir **çözümleyiciye** taşıyın (ya da kendiniz bir `InputRequiredResult` döndürün); böylece soru, her bağlantının taşıyabildiği *yanıtın* bir parçası olur: + +```python title="server.py" hl_lines="15-17 21" +--8<-- "docs_src/troubleshooting/tutorial007.py" +``` + +Aynı soru, istemcide aynı `elicitation_callback`. Fark arka plandadır: çözümleyici, sunucunun soruyu itmek yerine çağrıdan *döndürmesini* sağlar; böylece sunucudan istemciye hiçbir şey akmaz. Bu, sunucu üç yapılandırmanın hangisinde olursa olsun her `2026-07-28` istemcisini kurtarır. *Eski nesil* bir istemciyi ise tek başına bu yeniden yazım kurtarmaz: `2025-11-25`'te bir soruyu döndürmenin yolu yoktur; bu yüzden eski nesil bir bağlantıda çözümleyici `elicitation/create`'i yine istek kapsamlı kanaldan gönderir ve yine bu kanalı koruyan bir sunucuya ihtiyaç duyar: ne `stateless_http=True` ne de `json_response=True`. Çözümleyicileri **[Elicitation](handlers/elicitation.md)** sayfası, ağ üzerinde neler olduğunu ise **[Çok turlu istekler](handlers/multi-round-trip.md)** sayfası ele alır. + +!!! check + `ctx.elicit()` kullanan araç yanlış değil, *2026 öncesi*. Ne `stateless_http=True` ne de + `json_response=True` olan bir sunucuya `mode="legacy"` ile (klasik `initialize` el + sıkışması, spesifikasyon `2025-11-25` ve öncesi) bağlanın; çalışır, çünkü orada sunucudan + istemciye kanal vardır. + Her sürümde nelerin olduğunu anlatan sayfa **[Protokol sürümleri](protocol-versions.md)**. + +## `MCPError: Invalid or expired requestState` {#mcperror-invalid-or-expired-requeststate} + +Sunucu, istemcinizin geri yansıttığı `requestState` token'ını doğrulayamadı; bu yüzden turu reddetti. + +`requestState`, **[çok turlu](handlers/multi-round-trip.md)** bir çağrının ayaklar arasında taşıdığı opak devam token'ıdır. `MCPServer` onu çıkışta mühürler ve her yansımayı doğrular; üstelik `tools/call`, `prompts/get` ve `resources/read` üzerindeki gelen *her* `request_state`'i, hiç token üretmeyen bir işleyici için bile doğrular. Bu yüzden bu sürecin mühürlemediği bir token nereye düşerse düşsün reddedilir: + +```python +async def main() -> None: + async with Client(mcp) as client: + await client.call_tool("forecast", {"city": "London"}, request_state="round-1-from-worker-a") +``` + +```text +mcp.shared.exceptions.MCPError: Invalid or expired requestState +``` + +Mesaj kasıtlı olarak sabittir: ağ üzerinden hangi denetimin başarısız olduğu asla açığa çıkmaz. Neden **sunucu log'una** gider ve onu okumak teşhisin tamamıdır: + +```text +WARNING mcp.server.request_state: requestState rejected on tools/call: malformed +``` + +Gerçekte göreceğiniz nedenler: + +* **`unknown key`** önemli olandır. Varsayılan mühürleme anahtarı süreç başlangıcında üretilir; bu yüzden **farklı bir worker'a**, yük dengeleyici arkasındaki farklı bir örneğe ya da **yeniden başlatma sonrası** aynı sunucuya düşen bir yeniden deneme, bu sürecin hiç sahip olmadığı bir anahtarla mühürlenmiştir. Bu bir saldırgan değildir; varsayılanın birden fazla süreçle karşılaşmasıdır. +* **`audience`**: token'ı *farklı bir sunucu adına* sahip bir örnek mühürlemiş. Ad, mührün varsayılan audience claim'idir; bu yüzden bir filonun anahtarların yanı sıra adı da paylaşması (ya da açık bir `RequestStateSecurity(audience=...)` ayarlaması) gerekir. +* **`expired`**: tur, mührün `ttl` süresinden uzun sürdü; bu süre 600 saniyedir ve çağrı başına değil, tur başınadır. +* **`malformed`** / **`codec error`**: token yolda değiştirilmiş ya da hiçbir zaman mühürlü bir token olmamış. +* **`request binding`**: token farklı bir araçla, farklı argümanlarla ya da farklı bir yöntemle geri geldi. + +Çok süreçli çözüm tek bir argüman (her örnekte *aynı* `keys`) artı argüman bile olmayan bir şeydir: aynı sunucu *adı* (ya da açıkça paylaşılan bir `audience=`). + +```python +mcp = MCPServer("Weather", request_state_security=RequestStateSecurity(keys=[key])) +``` + +`keys[0]` mühürler; listedeki her anahtar doğrular; kesintisiz rotasyonu mümkün kılan da budur. Mührün neyi koruduğunu ve rotasyon sırasını **[Çok turlu istekler](handlers/multi-round-trip.md#protecting-requeststate)** açıklar; **[Dağıtım ve ölçekleme](run/deploy.md)** ise iki worker'lı hatanın tamamını ve iki parçalı çözümünü adım adım anlatır. + +!!! tip + `keys=[...]` zayıf bir anahtarı, alışılmadık derecede yardımcı bir mesajla hemen reddeder: + + ```text + ValueError: request-state keys must be at least 32 bytes of secret randomness; keys[0] is 7 bytes. Generate one with: python -c "import secrets; print(secrets.token_hex(32))" + ``` + + Dediğini yapın. + +## Hâlâ takıldınız mı? {#still-stuck} + +* SDK'nın ürettiği bir mesaj bu sayfada yoksa, bu başlı başına bildirmeye değer bir dokümantasyon hatasıdır. +* [Issue tracker](https://github.com/modelcontextprotocol/python-sdk/issues)'da arama yapın; orada görünen hata metinlerinin çoğunu birileri çoktan yazıya dökmüştür. +* Hiçbir şey bulamadınız mı? Tam traceback ile [bir issue açın](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml) ya da [MCP Contributors Discord'undaki #python-sdk-dev kanalında](https://discord.gg/6CSzBmMkjX) sorun. + +## Özet {#recap} + +* `ExceptionGroup: unhandled errors in a TaskGroup` hiçbir zaman asıl hata değildir. **Son satırı** okuyun; `MCPError`'ı `async with Client(...)` bloğunun *içinde* yakalamak sarmalamayı tamamen atlar. +* `call_tool` başarısız olan bir araç için istisna fırlatmaz. `Error executing tool ...` ve `Unknown tool: ...` birer sonuçtur: `result.is_error`'ı kontrol edin. +* `Client must be used within an async context manager` -> `async with` kullanın. `Use @tool() instead of @tool` -> parantezleri ekleyin. +* Sunucu log'undaki `Tool already exists:`, aynı adlı iki aracın teke indiğinin tek işaretidir. +* Tek 421, üç yazım: `Server returned an error response` (python `Client`), `421 Misdirected Request` / `Invalid Host header` (geri kalan her şey), `Invalid Host header: ` (sunucu log'u). Çözüm: `transport_security=TransportSecuritySettings(allowed_hosts=[...])`. +* `Task group is not initialized` -> ana uygulamanın lifespan'i `mcp.session_manager.run()`'a hiç girmemiş, bağlanmış bir uygulama. +* `Session not found` -> sunucu yeniden başladı; yeniden bağlanın. +* `Cannot send 'elicitation/create': ... no back-channel ...` -> `ctx.elicit()` sunucudan istemciye bir kanala ihtiyaç duyar: bir `2026-07-28` bağlantısında hiç yoktur, `stateless_http=True` eski nesil olanı, `json_response=True` ise istek kapsamlı olanı ortadan kaldırır. Bir çözümleyici kullanın (eski nesil bir istemci için ayrıca kanalı koruyan bir sunucu gerekir). Komşusu `Method not found`, karşı tarafın protokol sürümünde olmayan bir yöntem için yapılmış bir istektir. +* `Client did not declare the form elicitation capability ...` ve `Elicitation not supported` -> istemcide `elicitation_callback=` eksik. +* `Invalid or expired requestState` nedenini ağ üzerinde asla söylemez. Sunucu log'u söyler; `unknown key`, `RequestStateSecurity(keys=[...])`'i worker'lar arasında paylaşın demektir. diff --git a/i18n/tr/pages/whats-new.md b/i18n/tr/pages/whats-new.md new file mode 100644 index 0000000000..e7c27be4f6 --- /dev/null +++ b/i18n/tr/pages/whats-new.md @@ -0,0 +1,214 @@ +--- +translation: + sections: [cfe01c0c5863dfa2, 11d93f1fa09eadf5, a7392996acf1ad8f, 875eb2889263424e] + tool: 1 +--- +# v2'deki yenilikler {#whats-new-in-v2} + +v2'de iki şey aynı anda oldu. **SDK yeniden inşa edildi**: hem istemcinin hem sunucunun altında yeni bir motor, birinci sınıf bir `Client` ve bir v1 kod tabanının daha ilk import'unda karşılaştığı bir dizi yeniden adlandırma. Ve **protokol ilerledi**: v2, MCP'nin 2026-07-28 revizyonunu konuşur; bu revizyon bağlantı el sıkışmasını, oturumu ve sunucunun başlattığı her isteği kaldırır, üstelik hâlihazırda sahip olduğunuz istemcileri yarı yolda bırakmadan. + +Bu sayfa her iki yarının da turu: her başlık için bir bölüm, her biri konunun asıl sahibi olan sayfaya çıkar. Taşıma el kitabı değildir. O, **[Geçiş kılavuzu](migration.md)**: uyumluluğu bozan her değişiklik, öncesi ve sonrası koduyla. + +!!! note "v2 kararlı sürüm hattıdır" + `pip install mcp` 2.x sürümünü kurar; kopyalayıp yapıştırabileceğiniz kurulum satırı + **[Kurulum](get-started/installation.md)** sayfasında. v2'de herhangi bir şey bozulur, sizi şaşırtır + ya da yavaşlatırsa [bize bildirin](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml). + +## SDK: v1'den v2'ye {#the-sdk-v1-to-v2} + +### `FastMCP` artık `MCPServer` {#fastmcp-is-now-mcpserver} + +Üst düzey sunucu sınıfının adı değişti, modülü de onunla birlikte. Her v1 sunucusunun ilk çarptığı şey budur; çünkü eski import yolu kullanım dışı bırakılmadı, doğrudan kaldırıldı: + +```python +from mcp.server import MCPServer # v1: from mcp.server.fastmcp import FastMCP + +mcp = MCPServer("Demo") # v1: FastMCP("Demo") +``` + +Dekoratörlerle kurulmuş bir sunucu için taşıma işinin büyük kısmı da budur. `@mcp.tool()`, `@mcp.resource()` ve `@mcp.prompt()` v1'de ne kabul ediyorsa onu kabul eder (`@mcp.resource()` isteğe bağlı bir `security=` anahtar sözcüğü ekler) ve girdi şeması hâlâ tür ipuçlarınızdan gelir. Kenarda köşede kalanlar: `mcp.server.fastmcp.*` altındaki her şey artık `mcp.server.mcpserver.*` altında, `ctx.fastmcp` artık `ctx.mcp_server`, `get_context()` kaldırıldı (yerine bir `ctx: Context` parametresi bildirin) ve istisna taban sınıfı `FastMCPError` artık `MCPServerError`. Import tablosu **[Geçiş kılavuzu](migration.md#fastmcp-renamed-to-mcpserver)** sayfasında. + +### `Resolve`: kullanıcıdan girdi istemenin yeni yolu {#resolve-the-new-way-to-ask-the-user-for-input} + +Bir aracın ihtiyaç duyduğu her şey modelden gelmek zorunda değil. v2 ile gelen yenilik: `Resolve(fn)` ile işaretlenmiş bir araç parametresini, modele görünmeden, sizin yazdığınız bir fonksiyon doldurur ve bu fonksiyon kullanıcının önüne bir soru koymak için `Elicit(...)` döndürebilir. Çağrı ortasında istemciden herhangi bir şey almanın tercih edilen yolu budur: SDK soruyu bağlantının desteklediği mekanizma hangisiyse onun üzerinden taşır (eski nesil bir istemci için canlı bir elicitation (kullanıcıdan bilgi isteme) isteği, 2026-07-28'de çok turlu (multi-round-trip) bir istek); böylece tek bir araç gövdesi her iki nesle de hizmet eder. İlgili sayfa **[Bağımlılıklar](handlers/dependencies.md)**. + +!!! note + Diğer iki biçim, ihtiyaç duyduğunuzda hâlâ yerinde: `ctx.elicit()` eski nesil bağlantılardaki + istemciler için çalışmaya devam eder (**[Elicitation](handlers/elicitation.md)**) ve bir işleyici + `InputRequiredResult`'ı kendisi döndürüp turları elle yürütebilir; örnekleme (sampling) ve + kök dizinler (roots) istekleri de 2026-07-28'de bu yoldan gider (**[Çok turlu istekler](handlers/multi-round-trip.md)**). + +### Birinci sınıf bir `Client` {#a-first-class-client} + +v1 size iç içe üç katman veriyordu: ham akışlar üreten bir aktarım bağlam yöneticisi, bunların etrafına sarılmış bir `ClientSession` ve elle çağrılan bir `await session.initialize()`. v2'de tek bir nesne var: + +```python title="client.py" hl_lines="14-18" +--8<-- "docs_src/client/tutorial001.py" +``` + +`Client` bir sunucu nesnesi (bellek içi, aktarım yok: test senaryosu), bir URL (Streamable HTTP) ya da `stdio_client(...)` gibi herhangi bir aktarım bağlam yöneticisi alır. `async with` bloğuna girmek bağlantıyı kurar ve sunucu hangi nesli konuşuyorsa ona göre protokol sürümünde anlaşır; ardından `client.server_capabilities` ve `client.protocol_version` hazırdır, sunucu kendini tanıttığında `client.server_info` da öyle (artık `Implementation | None` türünde, çünkü 2026 neslinde kimlik isteğe bağlı). v1'de kaydettiğiniz örnekleme ve elicitation callback'leri hâlâ çalışır (gövdeleri, bu sayfadaki her şey gibi aynı snake_case öznitelik yeniden adlandırmasını görür); artık 2026 tarzı sonuç-içinde-isteklere de (aşağıda) yanıt verirler ve teker teker değil eşzamanlı çalışırlar. Düşük düzey yüzeyi isteyenler için `ClientSession` hâlâ altta duruyor ve `client.session` onu size verir; o da taşındı (yeni dispatcher motoru üzerinde çalışır ve kendi imzalarından bazıları değişti), bu yüzden aşağı inmeden önce **[Geçiş kılavuzu](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)** sayfasını okuyun. + +**[Client](client/index.md)** sayfası onu tanıtır, **[İstemci aktarımları](client/transports.md)** üç bağlantı biçimini anlatır, **[İstemci callback'leri](client/callbacks.md)** callback'lerin kendisini ele alır ve **[Test etme](get-started/testing.md)** v1'in `create_connected_server_and_client_session()` yardımcısının yerini alan bellek içi kalıbı gösterir. + +### Düşük düzey `Server` yeniden adlandırılmadı, yeniden inşa edildi {#the-low-level-server-was-rebuilt-not-renamed} + +JSON-RPC katmanında çalışıyorsanız, v2'nin "her şey farklı" kısmı burası. İşte tek araçlı aynı sunucunun iki hâli; nelerin değiştiğini görmek için işaretçilere tıklayın. + + + +```python title="v1" +from typing import Any + +import mcp.types as types +from mcp.server.lowlevel import Server + +server = Server("Bookshop") + + +@server.list_tools() # (1)! +async def list_tools() -> list[types.Tool]: + return [ # (2)! + types.Tool( + name="search_books", + description="Search the catalog by title or author.", + inputSchema={ # (3)! + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + }, + ) + ] + + +@server.call_tool() +async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentBlock]: # (4)! + if name != "search_books": + raise ValueError(f"Unknown tool: {name}") # (5)! + ctx = server.request_context # (6)! + return [types.TextContent(type="text", text=f"Found 3 books matching {arguments['query']!r}.")] # (7)! +``` + +1. İşleyiciler dekoratörlerle (parantezli, çağrılarak) kaydedilir; sunucu var olduktan sonra herhangi bir zamanda. +2. Yalın bir `list[Tool]` döndürürsünüz, SDK onu bir `ListToolsResult` içine sarar. +3. Alanlar Python'da camelCase'tir ve şema **zorunlu tutulur**: SDK, fonksiyonunuz çalışmadan önce `call_tool` argümanlarını jsonschema ile bu şemaya göre doğrular; aşağıdaki `arguments["query"]` bu yüzden güvenlidir. +4. Tek bir `call_tool` işleyicisi tüm araçlara hizmet eder; araç adını ve zaten doğrulanmış argümanları açılmış hâlde alır, asla `None` değildir. +5. Bir v1 aracı başarısızlığı istisna fırlatarak bildirir: her istisna yakalanır ve metni `str(e)` olan bir `CallToolResult(isError=True)` olarak döndürülür; çağıran model bu mesajı okur ve yeniden deneyebilir. +6. Bağlam, istek ortasında sunucu nesnesi üzerinden erişilen ortamdaki bir ContextVar'dan gelir. +7. Yalın içerik blokları sizin için bir `CallToolResult` içine sarılır. + +```python title="v2" +--8<-- "docs_src/whats_new/tutorial001.py" +``` + +1. Alanlar artık snake_case ve şema **ilan edilir ama asla uygulanmaz**: işleyiciniz çalışmadan önce argümanları hiçbir şey denetlemez. +2. Her işleyici aynı biçimdedir: `async (ctx, params) -> result`. Bağlam ilk argümandır (`ctx.session`, `ctx.request_id`, `ctx.protocol_version` onun üzerinde yaşar); `server.request_context` buraya taşındı. +3. Tam `ListToolsResult`'ı kendiniz kurarsınız. Yalın bir liste döndürmek artık SDK'nın sardığı bir şey değil, sunucu tarafında bir `TypeError`. +4. Tipli parametreler girer (`params.name`, `params.arguments`), tam bir sonuç çıkar. Sizin için hiçbir şey açılmaz, sarılmaz ya da dönüştürülmez. +5. Aynı denetim, farklı fiil. Buradaki bir `ValueError` modele opak bir `-32603` olarak ulaşırdı (aşağıya bakın); bu yüzden kasıtlı bir protokol hatası `MCPError` olarak fırlatılır: kodu ve mesajı bozulmadan geçer ve bu metinle `-32602`, bilinmeyen bir araç için spesifikasyonun kendi yanıtıdır. +6. `params.arguments` `None` olabilir; v1 onu kodunuz görmeden önce varsayılan olarak `{}` yapıyordu. İşleyicinin önünde doğrulama olmadığından bu satır yük taşır. +7. Burada fırlatılan beklenmedik bir istisna **arındırılmış** bir protokol hatasına, `-32603` `"Internal server error"`'a dönüşür: model mesajı asla görmez. Modelin okuyup tepki vermesi gereken bir başarısızlık için `CallToolResult(is_error=True, ...)` döndürün. +8. İşleyiciler kurucu argümanlarıdır; bu yüzden sunucunun yüzeyi var olduğu anda tamamdır. `add_request_handler()` kuruluş sonrası kaçış kapağı ve özel metotlara açılan kapıdır. + +Örnek, kalıbın ta kendisi. Daha genel olarak: her işleyici aynı biçimdedir, tipli parametreler girer ve tam bir sonuç türü çıkar; araç argümanlarının eski jsonschema denetimi kalktı; bir istisna protokol hatasıdır, asla `is_error=True` bir araç sonucu değildir; ortamdaki `server.request_context` ContextVar'ı da kalktı. Sağlayıcı ad alanlı özel metotlar, gelen parametreleri işleyiciniz çalışmadan önce modelinize göre doğrulayan `add_request_handler(method, params_type, handler)` sayesinde birinci sınıftır. Ve (bilerek geçici olarak işaretlenmiş) bir `middleware` listesi gelen her mesajı sarar; eskiden insanların ezdiği özel `_handle_*` metotlarının yerini alır. + +Altta, v1'in `BaseSession` alma döngüsünün yerini artık istemci ile sunucunun paylaştığı bir dispatcher motoru aldı; bu sayfadaki birkaç şeyi aynı anda doğru kılan da odur: tek bir `Server` nesnesi her iki protokol nesline de hizmet eder, `Client(server)` JSON-RPC çerçevelemesi olmadan süreç içinde yönlendirir ve zaman aşımına uğrayan bir istemci isteği artık sunucu tarafındaki işleyiciyi gerçekten iptal eder. + +İlgili sayfa **[Düşük düzey Server](advanced/low-level-server.md)**; **[Geçiş kılavuzu](migration.md#lowlevel-server-decorator-based-handlers-replaced-with-constructor-on_-params)** kaldırılan her kancayı tek tek anlatır. `MCPServer`'ın altına hiç inmediyseniz bunların hiçbiri sizi etkilemez. + +### Protokol türleri `mcp-types` paketine taşındı, her alan artık snake_case {#the-wire-types-moved-to-mcp-types-and-every-field-is-snake_case} + +Protokol türleri artık kendi dağıtım paketlerinde, `mcp-types` içinde yaşıyor. pydantic ve typing-extensions dışında hiçbir şeye bağımlı değildir; bu yüzden bir ağ geçidi, vekil sunucu ya da kod üreteci bir HTTP yığını kurmadan MCP'nin protokol veri biçimlerini tüketebilir: böyle bir proje `mcp-types` paketini kurar ve `mcp_types`'ı import eder. `mcp`'nin kendisi bu pakete tam sürümle bağımlıdır ve onu yeniden dışa açar; dolayısıyla SDK'ya bağımlı kod `import mcp.types as types` ve `from mcp.types import Tool` yazmaya devam eder (kalıcı bir takma ad, her ad aynı nesne) ve yalnızca tek gerçek bağımlılığını, `mcp`'yi bildirir. Pratik kural: hangi pakete gerçekten bağımlıysanız onun üzerinden import edin. + +Bu türlerde her Python özniteliği artık snake_case: `result.is_error`, `tool.input_schema`, `listing.next_cursor`. İletilen JSON tam eskisi gibi camelCase; yalnızca özniteliklerin yazımı değişti. İki sıkı varsayılan da beraberinde gelir: bilinmeyen alanlar geri döndürülmek yerine yok sayılır (fazlalıkları `_meta`'ya koyun) ve her iki taraf da trafiği üzerinde anlaştıkları protokol sürümüne göre doğrular. Yeniden adlandırma tablosu için **[Geçiş kılavuzu](migration.md#field-names-changed-from-camelcase-to-snake_case)** sayfasına bakın. + +### Aktarım yapılandırması `run()`'a taşındı {#transport-configuration-moved-to-run} + +`MCPServer(...)` sunucunuzun *ne olduğuyla* ilgilidir: adı, talimatları, lifespan'i (yaşam döngüsü), kimlik doğrulaması. Nasıl *sunulduğu* artık `run()`'a ve uygulama kurucularına ait; `host`, `port`, `stateless_http`, `json_response`, endpoint yolları ve `transport_security` oraya gitti (`MCPServer("x", port=9000)` bir `TypeError`'dır). Aşırı yüklemeler aktarıma göre tiplendirilmiştir; böylece editörünüz `stdio`'nun hangi seçenekleri aldığını, `streamable-http`'nin hangilerini aldığını söyler. Bilmeye değer bir kaldırma: `mount_path` gitti; bir önek altında sunmanın desteklenen yolu ASGI uygulamasını bağlamaktır (mount). + +Seçenekleri **[Sunucunuzu çalıştırma](run/index.md)**, bağlamayı **[Mevcut bir uygulamaya ekleme](run/asgi.md)** sayfası anlatır. + +### Import hatası vermeden değişen davranışlar {#behavior-that-changes-without-an-import-error} + +Yeniden adlandırmalar kendini belli eder. Bunlar etmez: + +* **Senkron fonksiyonlar bir işçi iş parçacığında çalışır.** Bir `def` aracı (ya da kaynağı, prompt'u veya çözümleyicisi) artık olay döngüsünü engellemez; bunun bedeli, gövdesinin artık olay döngüsü iş parçacığının *üzerinde* çalışmamasıdır ve bu, iş parçacığına bağlı kod için önemlidir. `async def` işleyicilere dokunulmadı. **[Geçiş kılavuzu](migration.md#sync-handler-functions-now-run-on-a-worker-thread)**. +* **Bir aracın içinde fırlatılan `MCPError` (v1'deki `McpError`) artık bir protokol hatasıdır.** Model onu asla görmez. Diğer her istisna hâlâ modelin okuyup tepki verebileceği `is_error=True` bir sonuca dönüşür. Ayrım **[Hataları ele alma](servers/handling-errors.md)** sayfasında. +* **Sonuçlar çıkmadan önce doğrulanır.** `input_schema`'sı `{}` olan elle kurulmuş bir `Tool` artık `tools/list` çağrısında başarısız olur (spesifikasyon `"type": "object"` gerektirir). `@mcp.tool()` üzerine kurulu sunucular bunu asla görmez; şemalarını SDK yazar. +* **İstemciniz aldığını doğrular.** `list_tools()` ve `call_tool()` sunucunun yanıtını üzerinde anlaşılan protokol sürümüne göre denetler; bu yüzden v1'in hoşgörülü ayrıştırmasının idare ettiği tam geçerli olmayan bir sunucu artık `pydantic.ValidationError` fırlatır. Kontrol etmediğiniz sunuculara bağlanıyorsanız onları bulan kişi olmayı bekleyin; ayrıntılar **[Geçiş kılavuzu](migration.md#client-validates-inbound-traffic-against-the-protocol-schema)** sayfasında. +* **URI şablonları artık gerçek RFC 6570.** `{+path}`, `{?query}` ve benzerleri çalışır, eşleştirme regex gevşekliğinde değil birebirdir ve çıkarılan değerlerdeki yol geçişi (path traversal) varsayılan olarak reddedilir. Daha sıkı şablonlar ilk istekte değil, dekoratör uygulanırken başarısız olur. **[URI şablonları](servers/uri-templates.md)**. +* **Streamable HTTP lifespan'i bir kez çalışır**, başlangıçta; durumu da her oturum ve istek tarafından paylaşılır. v1'de oturum başına bir kez, `stateless_http=True` altında ise istek başına bir kez çalışıyordu. Bir lifespan'de kurulan havuzlar ve önbellekler çarpıcı biçimde ucuzlar; orada bağlantı başına bir kaynak edinen her şeyin yeri artık işleyici gövdesi. **[Lifespan](handlers/lifespan.md)**. +* **`mcp dev` ve `mcp install` başlattıkları ortamı** kurulu SDK sürümünüze sabitler. Her iki komut da sunucunuzu yeni bir `uv run --with ...` ortamında çalıştırır; bu ortam eskiden `mcp`'yi geliştirme yaptığınız sürüme değil en yeni kararlı sürüme çözümlerdi. **[Geçiş kılavuzu](migration.md#mcp-dev-and-mcp-install-pin-the-spawned-environment-to-your-sdk-version)**. +* **HTTP istemcisi artık `httpx` değil, `httpx2`.** Bağımlılık değişimi kodunuzun neyi yakalayıp neyi geçirdiğini (`httpx2.AsyncClient`, `httpx2.ConnectError`) ve TLS sertifikalarının nasıl doğrulandığını değiştirir: `httpx2`, certifi'nin paketlenmiş CA listesi yerine `truststore` üzerinden işletim sisteminin güven deposuna göre doğrular. Çoğu ortam bunu hiç fark etmez; sistem CA deposu olmayan minimal bir konteyner ya da yalnızca certifi paketinin bildiği özel bir CA, TLS el sıkışmasında başarısız olmaya başlar. `SSL_CERT_FILE`/`SSL_CERT_DIR` ayarlayın veya istemcinize `verify=ssl_context` geçirin. **[Geçiş kılavuzu](migration.md#httpx-and-httpx-sse-replaced-by-httpx2)**. + +### Tamamen kaldırılanlar {#removed-outright} + +Bunların her biri **[Geçiş kılavuzu](migration.md)** içinde bir bölüm: + +* **WebSocket aktarımı**, iki tarafta da, ve `mcp[ws]` ekstrası. Hiçbir zaman MCP spesifikasyonunun parçası olmadı. +* **Deneysel Tasks** API'si (`mcp.*.experimental`). 2026-07-28, görevleri çekirdek protokolden çıkarıp resmi bir uzantıya taşır ([SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663)); bu SDK onu henüz uygulamıyor. +* Import yolu olarak `mcp.shared.version`, `mcp.shared.progress` ve `mcp.shared.session` (v1 `message_handler` tür açıklamalarının import ettiği `RequestResponder` taslağıyla birlikte). (`mcp.types` *kaldırılmadı*: bağımsız `mcp_types` paketi için kalıcı bir takma ad olarak kalır.) +* Kullanım dışı `streamablehttp_client` yazımı ve `streamable_http_client`'tan `get_session_id` callback'i (artık tam olarak iki akış üretir). +* `McpError`; doğrudan `(code, message, data)` kurucusuyla **`MCPError`** olarak yeniden adlandırıldı. +* `MCPServer.get_context()`, `mount_path=` ve düşük düzey `Server`'ın dekoratör metotları, ContextVar'ı ve işleyici dict'leri. + +## Protokol: 2025-11-25'ten 2026-07-28'e {#the-protocol-2025-11-25-to-2026-07-28} + +v2, 2026-07-28 revizyonunu uygular ve **her iki** revizyona birden hizmet verir: aynı `streamable_http_app()` (ve aynı stdio sunucusu) yapılandırılacak hiçbir şey, çevrilecek bir bayrak ve ayrı bir dağıtım olmadan hem 2025 neslinden bir istemcinin `initialize` isteğini hem de 2026 neslinden bir istemcinin isteklerini yanıtlar. Yeni revizyonu sunmak eskisindeki bir istemciyi yarı yolda bırakmaz. Aşağıda yeni revizyonun kendisinin neleri değiştirdiği var. + +### El sıkışma yok, oturum yok {#no-handshake-no-session} + +Bir 2026-07-28 istemcisi bağlantı açıp anlaşıp sonra konuşmaz. Her istek protokol sürümünü, istemci bilgisini ve istemci yeteneklerini `_meta` içinde taşır; tek keşif çağrısı olan `server/discover` da diğerleri gibi düz bir istektir. `Client` varsayılan olarak doğru olanı yapar: `server/discover`'ı bir kez yoklar ve sunucu daha eskiyse `initialize` el sıkışmasına geri döner. + +Streamable HTTP üzerinde 2026 yolunda `Mcp-Session-Id` yoktur; operasyonel manşet de budur: **modern bir isteği bir işçiye bağlayan hiçbir şey yok**, dolayısıyla düz bir round-robin yük dengeleyicinin arkasındaki herhangi bir kopya onu yanıtlayabilir. İki dürüst çekince. 2025 neslinden istemcileriniz (bugün istemcilerin çoğu bu) hâlâ oturum açar ve v1'de ne kadar yapışkanlığa ihtiyaç duyuyorlarsa o kadarına hâlâ ihtiyaç duyar; onlar için hiçbir şey değişmez. Ve *çok turlu* bir yeniden denemenin işçiler arasında taşıması gereken tek şey mühürlü `request_state`'idir; varsayılan anahtarı süreç başına üretildiğinden ölçeklenmiş bir dağıtım `RequestStateSecurity(keys=[...])` geçirir. (`stateless_http=True` bununla ilgisiz: yalnızca 2025 neslinden istemcilere nasıl hizmet verildiğini etkiler ve 2026 trafiği onu asla okumaz; v1'de zaten ayarladıysanız hiçbir şey değişmez.) + +Bunun istemci tarafı **[Protokol sürümleri](protocol-versions.md)** sayfasında, işletmecinin denetim listesi (Host izin listesi, `request_state` anahtarı, kopyalar arası bildirimler) **[Dağıtım ve ölçekleme](run/deploy.md)** sayfasında, iki nesle birden hizmet verme hikâyesi ise **[Eski nesil istemcilere hizmet verme](run/legacy-clients.md)** sayfasında. + +### Sunucu istemciyi çağıramaz: çok turlu istekler {#the-server-cannot-call-the-client-multi-round-trip-requests} + +2026-07-28'de sunucunun başlattığı her istek kalktı: itme (push) tarzı elicitation, örnekleme, `roots/list`. 2026 bağlantısında bunlar için bir kanal yoktur; bu yüzden `ctx.elicit()` ve `ctx.session.create_message()` orada `NoBackChannelError` ile başarısız olur (eski nesil istemciler için hâlâ çalışırlar). + +Yerine gelen çözüm çağrıyı tersine çevirir. Kullanıcıdan bir şeye ihtiyaç duyan araç soruyu *döndürür* (`InputRequiredResult`), istemci onu her zamanki callback'leriyle yanıtlar ve çağrı yanıtlar eklenmiş hâlde yeniden denenir. Bu döngüyü sizin için `Client` yürütür. Sunucuda sonucu nadiren kendiniz kurarsınız, çünkü bunu bir **[bağımlılık](handlers/dependencies.md)** yapar: bir parametreyi `Resolve(ask_quantity)` ile işaretleyin (`ask_quantity` sizin yazdığınız sıradan bir fonksiyondur), SDK de bağlantının desteklediği mekanizma hangisiyse onun üzerinden sorar: eski nesil bir oturumda canlı bir elicitation isteği, 2026'da çok turlu bir istek. Tek araç gövdesi, iki nesil birden: + +```python title="dual_era.py" hl_lines="24 37-38" +--8<-- "docs_src/legacy_clients/tutorial001.py" +``` + +Bu dosya tüm vaadin tek yerde özeti: bir sunucu, `Resolve` destekli bir araç ve ikisi de yanıtını bellek içinde alan bir eski nesil istemci ile bir modern istemci. **[Çok turlu istekler](handlers/multi-round-trip.md)** mekanizmayı açıklar (SDK'nın sizin için mühürleyip doğruladığı `request_state` dâhil); sorma kısmı **[Elicitation](handlers/elicitation.md)** sayfasında. + +!!! warning "Taşınmış bir v1 sunucusunun davranış değiştirdiği tek yer burası" + Buna ilk sizin testleriniz çarpar: `Client(mcp)` v2 sunucunuzla varsayılan olarak 2026-07-28 + üzerinde anlaşır; bu yüzden `ctx.elicit()` çağıran bir araç, v1'de geçen bir testte başarısız olur. + Soruyu bir `Resolve(...)` parametresine taşıyın (nesiller arası taşınabilir) ya da itme davranışını + gerçekten istiyorsanız test istemcisini `mode="legacy"` ile sabitleyin. + +### Kök dizinler, örnekleme ve protokol log kaydı kullanım dışı; `ping` kaldırıldı {#roots-sampling-and-protocol-logging-are-deprecated-ping-is-removed} + +[SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) üç *yeteneği* bütünüyle, her protokol sürümünde kullanım dışı bırakır: kök dizinler, örnekleme ve MCP düzeyinde log kaydı (`ctx.info()` ve benzerleri). Bu, yukarıdaki eksik geri kanaldan (back-channel) ayrı bir eksen; kullanım dışı olmak tavsiye niteliğindedir, her şey 2025 neslinden oturumlara karşı çalışmaya devam eder ve iletilen veride hiçbir şey değişmez. Fark edeceğiniz şey `MCPDeprecationWarning`'dir; bir `UserWarning` olduğu için varsayılan olarak yazdırılır. Yükseltmeden sonraki ilk `ctx.info(...)` çağrınızın bunu söylemesini bekleyin. + +`ping` daha katı: kullanım dışı bırakılmadı, protokolden kaldırıldı. Kullanım dışı özelliklerin bağımsız metotlarından ikisi, `logging/setLevel` ve istemcinin `notifications/roots/list_changed` bildirimi, 2026-07-28'de aynı şekilde kaldırıldı; ilerleme bildirimleri de artık yalnızca sunucudan istemciye gider. + +Tablonun tamamı, her birinin yerine geçen çözüm ve eski nesil istemcilere hizmet verirken sessiz bir log'a ihtiyacınız varsa tek satırlık filtre **[Kullanım dışı özellikler](deprecated.md)** sayfasında. + +### Değişiklik bildirimleri tek bir akışa dönüşüyor {#change-notifications-become-one-stream} + +2026-07-28'de bağımsız HTTP GET akışının ve `resources/subscribe`'ın yerini `subscriptions/listen` alır: istemci uzun ömürlü tek bir akış açar ve istediği bildirim türlerini adlandırır. `MCPServer` bunu varsayılan olarak sunar; `await ctx.notify_resource_updated(uri)` ile (ve `notify_tools_changed()` vb. ile) yayımlarsınız, bir middleware (ara katman) dinleme isteğini çağıran bazında reddedebilir ve çok kopyalı dağıtımlar paylaşılan bir `SubscriptionBus` takar. İstemcide `async with client.listen(...)` akışı açar: filtre anahtar sözcük argümanları olarak girer, tipli değişiklik olayları geri gelir ve `sub.honored` sunucunun teslim etmeyi kabul ettiği alt kümedir. + +Yayımlama ve sunma **[Abonelikler](handlers/subscriptions.md)** sayfasında, izleyen uç **[istemci tarafındaki ikizinde](client/subscriptions.md)**, bus ise **[Dağıtım ve ölçekleme](run/deploy.md)** sayfasında. + +### Geri kalanlar, kısaca {#the-rest-quickly} + +* **Kimlik isteğe bağlı, mesaj başına bir üstveridir.** İstek tarafındaki `clientInfo` `_meta` anahtarı isteğe bağlıdır (zorunlu ikili `protocolVersion` + `clientCapabilities`) ve `serverInfo`, `server/discover` sonuç gövdesinden çıktı: sunucular artık onu 2026 neslinden her sonucun `_meta`'sına damgalar ([spec #3002](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3002)). SDK her zaman damgalar; bir sunucu kendini tanıtmadığında (örneğin bir middleware anahtarı çıkardığında) `client.server_info` `None` olur. Damganın iletilen verideki hâlini **[Düşük düzey Server](advanced/low-level-server.md)** gösterir. +* **İstekler gövde ayrıştırılmadan yönlendirilebilir.** Modern HTTP istekleri `Mcp-Method` taşır (ve araç benzeri üç çağrı için `Mcp-Name`); `x-mcp-header` ile işaretlenmiş bir araç girdi şeması özelliği bir `Mcp-Param-*` başlığına yansıtılır ve sunucu bunu çapraz denetler ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243)). Ağ geçitleri ve hız sınırlayıcılar yalnızca başlıklara bakarak yönlendirebilir; kurallar **[Geçiş kılavuzu](migration.md#servers-validate-mcp-param-headers-against-the-request-body-sep-2243)** sayfasında. +* **Sonuçlar önbellek ipuçları taşır.** Listeleme ve okuma sonuçları `ttlMs` ve `cacheScope` bildirir ([SEP-2549](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549)); bunları metot başına `cache_hints=` ile ayarlarsınız, `Client` da yerleşik bir yanıt önbelleğiyle onlara uyar. Hiç ipucu göndermeyen bir sunucu (2026 öncesi her sunucu) birebir aynı, önbelleksiz trafik görür. **[Önbellek ipuçları](client/caching.md)**. +* **Uzantılar birinci sınıf.** Sunucular ve istemciler ters DNS tanımlayıcıları altında isteğe bağlı yetenek paketleri bildirir ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)); yerleşik `Apps` uzantısı (MCP Apps) referans örnektir. **[Uzantılar](advanced/extensions.md)** ve **[MCP Apps](advanced/apps.md)**. +* **Hata kodları standartlaştı.** Eksik bir kaynak, URI `error.data` içinde olmak üzere `-32602`'dir; spesifikasyonun ayırdığı yeni kodlar da `-32020` (başlık uyuşmazlığı), `-32021` (gerekli yetenek eksik) ve `-32022` (desteklenmeyen protokol sürümü) olarak görünür. **[Sorun giderme](troubleshooting.md)** tam mesaj metinlerine göre düzenlenmiştir. +* **Yetkilendirmeyi yanlış kullanmak zorlaştı.** İstemci, yetkilendirme koduyla dönen `iss` değerini doğrular ([RFC 9207](https://datatracker.ietf.org/doc/html/rfc9207); `callback_handler`'ınız artık bir `AuthorizationCodeResult` döndürür), kayıt olurken `application_type` gönderir ve kimlik bilgilerini asla farklı bir yetkilendirme sunucusuna karşı yeniden oynatmaz. Kurumsal köşedeki yenilik: [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) kimlik beyanı (identity assertion) akışı. **[Geçiş kılavuzu](migration.md)** her OAuth değişikliğini listeler; ilgili sayfalar **[İstemciler için OAuth](client/oauth-clients.md)** ve **[Kimlik beyanı](client/identity-assertion.md)**. +* **Her sunucu izlenebilir.** OpenTelemetry varsayılan olarak açık, middleware biçiminde gelir: her istek bir sunucu span'i alır ve süreç bir dışa aktarıcı (exporter) yapılandırana kadar hiçbir maliyeti yoktur. İki uç da SDK'yı çalıştırdığında istemci W3C izleme bağlamını `_meta` içinde de yayar; böylece izler birleşir. **[OpenTelemetry](run/opentelemetry.md)**. + +## v1'den mi yükseltiyorsunuz? {#upgrading-from-v1} + +* Neyi değiştireceğinizin eksiksiz ve kesin listesi **[Geçiş kılavuzu](migration.md)**; bu sayfa nedenini anlattı. +* **v1.x bir yere gitmiyor.** Bakım moduna geçer, kritik düzeltmeleri ve güvenlik yamalarını almaya devam eder ve 2026-07-28 spesifikasyon sürümündeki hiçbir şey onu bozmaz; belgeleri [/v1/](https://py.sdk.modelcontextprotocol.io/v1/) adresinde. `mcp`'ye bağımlı bir kütüphane yayımlıyor ve geçişe hazır değilseniz bir üst sınır koruyun (örneğin `mcp>=1.28,<2`); böylece sabitlenmemiş bir çözümleme 1.x'te kalır. +* Pürüzlü, kafa karıştırıcı ya da bozuk bir şey mi var? **[v2 geri bildirimi gönderin](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml)**; hepsi okunuyor. diff --git a/i18n/uk/glossary.json b/i18n/uk/glossary.json new file mode 100644 index 0000000000..f2f6553b78 --- /dev/null +++ b/i18n/uk/glossary.json @@ -0,0 +1,280 @@ +{ + "keep": [ + "MCP", + "Model Context Protocol", + "MCPServer", + "FastMCP", + "ClientSession", + "Context", + "ctx", + "stdio", + "Streamable HTTP", + "SSE", + "JSON-RPC", + "JSON", + "OAuth", + "PKCE", + "JWT", + "CIMD", + "HTTP", + "HTTPS", + "TLS", + "CORS", + "URI", + "URL", + "ASGI", + "WebSocket", + "API", + "SDK", + "CLI", + "IDE", + "LLM", + "SEP", + "RFC", + "Python", + "TypeScript", + "Node.js", + "PyPI", + "Pydantic", + "Starlette", + "FastAPI", + "uvicorn", + "httpx", + "anyio", + "asyncio", + "trio", + "pytest", + "OpenTelemetry", + "Inspector", + "Claude", + "GitHub", + "VS Code", + "Windows", + "macOS", + "Linux", + "llms.txt", + "2026-07-28", + "2025-11-25", + "2025-06-18", + "2025-03-26" + ], + "terms": [ + { + "source": "tool", + "target": "інструмент", + "note": "MCP protocol noun (a server exposes tools): інструмент / інструменти. Standard rendering. Wire identifiers such as `tools/call` and the `@mcp.tool()` decorator are code and stay Latin; the Inspector's **Tools** tab is a UI label and stays English." + }, + { + "source": "resource", + "target": "ресурс", + "note": "MCP protocol noun (data a server exposes for reading), and also the general noun (a pool acquired in a lifespan is still ресурс). Standard rendering. `resources/read` and `@mcp.resource()` are code." + }, + { + "source": "prompt", + "target": "промпт", + "note": "The MCP feature (a reusable message template a server exposes) and the everyday LLM sense; the loanword промпт (masculine, declined: промпту, промпти) is what Ukrainian AI writing uses. Not підказка (a hint or tooltip) and not запрошення (a command-line prompt), which are other senses. `prompts/get` and `@mcp.prompt()` are code. Provisional pending native review." + }, + { + "source": "sampling", + "target": "семплювання", + "note": "The (deprecated) client feature that lets a server borrow the client's model for a completion. Gloss the English on first use per page — семплювання (sampling) — so the reader maps it to `sampling/createMessage`, which is code. Not вибірка (a statistical sample, the wrong sense). Provisional pending native review; семплінг is the competing form." + }, + { + "source": "roots", + "target": "кореневі каталоги", + "note": "The (deprecated) client feature listing the workspace directories a client exposes. Descriptive rendering with the English glossed on first use per page — кореневі каталоги (roots); a single root is кореневий каталог. `roots/list` and the `Root` type are code and stay Latin. Provisional pending native review; keeping roots in Latin script is the open alternative." + }, + { + "source": "elicitation", + "target": "еліцитація", + "note": "The mechanism by which a server asks the user a question through the client mid-request. There is no settled Ukrainian term; pinned to еліцитація (feminine, declinable), glossed on first use per page — еліцитація (elicitation). Do not alternate with запит на введення or уточнення on the same page. `elicitation/create`, `ctx.elicit()` and the `Elicit` class stay Latin. Provisional pending native review." + }, + { + "source": "capability", + "target": "можливість", + "note": "A negotiated protocol capability (what a client or server declared it supports): можливості сервера, узгодження можливостей. Not здатність or спроможність. The `capabilities` field and keys such as `sampling.tools` stay Latin. Provisional pending native review." + }, + { + "source": "transport", + "target": "транспорт", + "note": "The connection mechanism (\"every standard transport\" → усі стандартні транспорти; транспортний рівень for \"transport layer\"). Standard networking usage. The transport names stdio, Streamable HTTP and SSE stay in English: транспорт stdio, stdio-транспорт." + }, + { + "source": "session", + "target": "сесія", + "note": "An MCP session (the negotiated connection state): сесія, ідентифікатор сесії. Pinned over сеанс for consistency with prevailing developer usage; do not alternate the two. `session` objects, `ClientSession` and `ServerSession` are code. Provisional pending native review." + }, + { + "source": "handler", + "target": "обробник", + "note": "The tool, resource or prompt function you register, and request handlers generally (nav section \"Inside your handler\" → Усередині обробника). Standard term; never the slang хендлер, and not the Russian обработчик." + }, + { + "source": "dependency", + "target": "залежність", + "note": "Both package dependencies and the SDK's parameter-injection feature (the \"Dependencies\" page → Залежності; \"dependency injection\" → впровадження залежностей). Standard rendering. The `Resolve` marker class stays Latin." + }, + { + "source": "resolver", + "target": "резолвер", + "note": "The plain function attached to a parameter with `Resolve(...)` that computes or asks for its value: резолвер (masculine, declinable), or функція-резолвер where the kind needs naming. Provisional pending native review; функція розв'язання is the descriptive alternative. The `Resolve` class stays Latin." + }, + { + "source": "client", + "target": "клієнт", + "note": "An MCP client, and the client side of a connection. Standard rendering. The `Client` class and the `mcp.client` module are code and stay Latin." + }, + { + "source": "server", + "target": "сервер", + "note": "An MCP server (the program you build): сервер, MCP-сервер. Standard rendering. The `MCPServer`, `Server` and `ServerSession` classes are code and stay Latin." + }, + { + "source": "host", + "target": "хост", + "note": "The MCP host — the application the user talks to (Claude Desktop, an IDE, an agent runtime) — and also a network host; хост in both senses. Standard loanword; never господар or хазяїн." + }, + { + "source": "context", + "target": "контекст", + "note": "The generic lower-case word (\"provide context to LLMs\" → надавати контекст LLM). The capitalised `Context` is the SDK object injected as `ctx`; it is on the keep list and stays Latin in prose (\"The Context\" page title → Об'єкт Context). Standard rendering." + }, + { + "source": "application", + "target": "застосунок", + "note": "A software application, including the MCP host app and an ASGI app: застосунок (masculine: застосунку, застосунки; ASGI-застосунок). Not додаток, which is an add-on or an appendix and reads as a calque of the Russian word; not програма unless the English says program. Provisional pending native review." + }, + { + "source": "by default", + "target": "за замовчуванням", + "note": "\"By default\" / \"defaults to\" → за замовчуванням; \"the default value\" → значення за замовчуванням or типове значення. по замовчуванню is a calque and never correct. Provisional pending native review; типово / усталено are the purist alternatives.", + "avoid": ["по замовчуванню"] + }, + { + "source": "next (following)", + "target": "наступний", + "note": "\"The next step / the following example\" → наступний крок, такий приклад / приклад нижче. слідуючий is a calque and never correct in modern Ukrainian (not on the checked avoid list only because наслідуючи contains the same letters)." + }, + { + "source": "is (linking verb)", + "target": "є", + "note": "The copula: \"A host is the LLM application\" → Хост — це LLM-застосунок, or Хостом є …. являється / являються as a linking verb is a calque of the Russian and never correct; in Ukrainian являтися means to appear in a dream. (Not on the checked avoid list only because з'являється contains the same letters.)" + }, + { + "source": "settings", + "target": "налаштування", + "note": "Settings / configuration → налаштування (also the verb: налаштувати); параметри where the English says options or parameters. настройка / настройки is Russian and never correct in Ukrainian text.", + "avoid": ["настройк", "настройок"] + }, + { + "source": "cancel", + "target": "скасувати", + "note": "Request cancellation and cancelling a task: скасувати, скасування (\"the client cancelled the request\" → клієнт скасував запит). Not відмінити / відміна, which modern usage treats as a calque for this sense. Provisional pending native review." + }, + { + "source": "request", + "target": "запит", + "note": "A JSON-RPC or HTTP request (\"the initialize request\" → запит initialize / запит ініціалізації; HTTP-запит). Standard term, never реквест. `Request` types in code font stay Latin." + }, + { + "source": "response", + "target": "відповідь", + "note": "A JSON-RPC or HTTP response (HTTP-відповідь, тіло відповіді). Standard term, never респонс. `Response` types in code font stay Latin." + }, + { + "source": "notification", + "target": "сповіщення", + "note": "A JSON-RPC notification (a message that expects no response): надіслати сповіщення, сповіщення про перебіг виконання. Not нотифікація, not повідомлення (which is a message generally) and not the calque увідомлення. Method strings such as `notifications/tools/list_changed` stay Latin. Provisional pending native review." + }, + { + "source": "callback", + "target": "колбек", + "note": "Client callbacks and OAuth redirect callbacks alike (the \"Client callbacks\" page → Колбеки клієнта): колбек, masculine, declinable, spelled this way throughout. Provisional pending native review; функція зворотного виклику is the formal alternative and may serve as a one-time gloss. Parameter names such as `sampling_callback` stay Latin." + }, + { + "source": "decorator", + "target": "декоратор", + "note": "The Python decorators the SDK is built on; `@mcp.tool()` and its siblings are code and stay untouched. Standard rendering." + }, + { + "source": "type hint", + "target": "анотація типів", + "note": "Python type hints (\"from your type hints\" → з анотацій типів). Pinned over підказки типів; use one rendering throughout. Provisional pending native review." + }, + { + "source": "exception", + "target": "виняток", + "note": "A raised Python exception: виняток / винятки; \"raises an exception\" → викидає виняток (or генерує виняток). Not виключення, which means exclusion. Exception class names stay Latin. Provisional pending native review." + }, + { + "source": "async", + "target": "асинхронний", + "note": "The prose adjective (\"the async runtime\" → асинхронне середовище виконання, \"an async callback\" → асинхронний колбек); the `async` and `await` keywords in code font stay Latin. Standard rendering." + }, + { + "source": "lifespan", + "target": "життєвий цикл", + "note": "The server's startup/shutdown scope (the \"Lifespan\" page, as in the ASGI lifespan) → життєвий цикл, glossed on first use per page — життєвий цикл (lifespan) — so the reader maps it to the `lifespan=` parameter, which is code and stays Latin, as does the function passed to it when named in code font. Not тривалість життя. Provisional pending native review; keeping lifespan in Latin script throughout is the open alternative." + }, + { + "source": "back-channel", + "target": "зворотний канал", + "note": "This documentation's term for the server calling back into the client during a request, which exists only on legacy connections. Gloss the English on first use per page — зворотний канал (back-channel) — so the reader can connect it to `NoBackChannelError`, which is code. Provisional pending native review." + }, + { + "source": "multi-round-trip", + "target": "багатораундовий", + "note": "The 2026-07-28 request pattern: \"Multi-round-trip requests\" → Багатораундові запити, glossed on first use per page — багатораундові запити (multi-round-trip). A single \"round trip\" is раунд обміну or один цикл «запит — відповідь» by context, never a literal поїздка туди й назад. The abbreviation MRTR stays Latin. Provisional coinage pending native review; багатоходові запити is the alternative to weigh." + }, + { + "source": "deprecated", + "target": "застарілий", + "note": "Advisory status: still works, scheduled for removal later — застарілий / оголошений застарілим (\"Deprecated features\" → Застарілі можливості; \"deprecation warning\" → попередження про застарілість). \"Removed\" is a different word (вилучений / видалений); the corpus contrasts the two. The `MCPDeprecationWarning` class stays Latin. Provisional pending native review." + }, + { + "source": "legacy", + "target": "старого покоління", + "note": "\"A legacy connection / client\" = one negotiated at spec version 2025-11-25 or earlier → з'єднання старого покоління, клієнт старого покоління (the page \"Serving legacy clients\" → Обслуговування клієнтів старого покоління). Pairs with \"era\" → покоління and keeps застарілий free for \"deprecated\". Never the slang легасі. Provisional pending native review; попередніх версій is the alternative." + }, + { + "source": "era", + "target": "покоління", + "note": "\"Protocol era\" (\"a 2025-era client\", \"whatever era the client speaks\") → покоління протоколу, клієнт покоління 2025. Not the literal ера or епоха. Provisional pending native review." + }, + { + "source": "handshake", + "target": "рукостискання", + "note": "The initialization handshake (\"the classic handshake\" → класичне рукостискання). рукостискання is the standard Ukrainian networking term (as in TLS-рукостискання), so the literal word is correct here. Standard rendering." + }, + { + "source": "middleware", + "target": "middleware", + "note": "Kept in Latin script, indeclinable, lower-case in running text (the \"Middleware\" page title stays Middleware); name the kind where a case is needed: шар middleware, функція middleware. May take the one-time gloss middleware (проміжний шар). Provisional pending native review; проміжне ПЗ is the formal alternative." + }, + { + "source": "authorization", + "target": "авторизація", + "note": "Security sense: авторизація (сервер авторизації, код авторизації), distinct from authentication → автентифікація (not аутентифікація). The `Authorization` header and code identifiers stay Latin. Provisional pending native review." + }, + { + "source": "Get started", + "target": "Початок роботи", + "note": "The nav section that opens the guide, and the title of its index page. \"First steps\" is a separate page inside that section (Перші кроки), so the two need distinct renderings or the sidebar shows the same title twice. Provisional pending native review." + }, + { + "source": "First steps", + "target": "Перші кроки", + "note": "The tutorial page inside the \"Get started\" section; never reuse this rendering for the section itself (see that entry). Provisional pending native review." + }, + { + "source": "Recap", + "target": "Підсумки", + "note": "Recurring section heading that closes most pages; one rendering everywhere, not Підсумки on some pages and Резюме or Підіб'ємо підсумки on others. Provisional pending native review." + }, + { + "source": "Try it", + "target": "Спробуйте самі", + "note": "Recurring section heading above a runnable example; one rendering everywhere, not Спробуйте on some pages and Перевірка on others. Provisional pending native review." + } + ] +} diff --git a/i18n/uk/instructions.md b/i18n/uk/instructions.md new file mode 100644 index 0000000000..d1a3b5728a --- /dev/null +++ b/i18n/uk/instructions.md @@ -0,0 +1,170 @@ +# Ukrainian (uk) — translation instructions + +Target language: Ukrainian (українська мова), directory and URL code `uk`, +page language tag `uk`. This file is sent verbatim with every translation +request for this language, on top of the shared rules in +`../general-prompt.md`. The termbase in `glossary.json` is sent alongside it +and wins any terminology conflict with this file. + +## 1. Register + +Write modern, natural Ukrainian as today's Ukrainian developer community +writes it: literate and plain, neither officialese nor chat. + +- The reader is «ви», always lowercase mid-sentence: ви, вас, вам, ваш. + Capitalised Ви / Ваш belongs in a personal letter to one person and is wrong + here. Never ти, never a mix. +- Reach for the pronoun rarely; prefer constructions that need no subject: + "You can pass a schema" → Можна передати схему; "If you need the raw + result" → Якщо потрібен сам результат; "You get a `CallToolResult`" → + Повертається `CallToolResult`. Three ви in one paragraph is a signal to + rephrase. Never replace "you" with користувач — the user is the person + talking to the host, not the reader. +- Steps and instructions are plain imperatives in the ви form: "Install the + SDK, then run the server" → Встановіть SDK і запустіть сервер. A purpose + clause is the other natural shape: "To run it: …" → Щоб запустити: …. Not + Вам необхідно встановити, not Слід здійснити встановлення. +- Headings, table headers and content-tab labels are noun phrases in sentence + case with no final punctuation: "Running your server" → Запуск сервера, + "Handling errors" → Обробка помилок, "Inside your handler" → Усередині + обробника. "How to …" becomes Як + infinitive; a heading the English phrases + as a question may stay a question. +- The authorial "we" is fine where the English has it (Радимо …), but no + давайте. One page, one register: a page that drifts between imperatives and + officialese, or between ви and Ви, is wrong even if each sentence is fine. + +## 2. Voice + +The English is warm, direct and confident: short sentences, second person, the +occasional one-line payoff ("That's the whole API."). Carry that into Ukrainian. + +- Use concrete verbs: запустити, передати, повернути, оголосити, заблокувати. + Prefer the active voice: "The tool is called by the model" → Модель + викликає інструмент, not Інструмент викликається моделлю. Keep the payoff + lines short: "That's a complete MCP server." → Це вже готовий MCP-сервер. +- Split long English sentences and follow Ukrainian word order, but never + merge, drop or reorder the technical claims themselves. +- Avoid канцелярит: даний → цей; здійснювати / виконувати + noun → the verb + itself (здійснює надсилання → надсилає); з метою → щоб; у випадку якщо → + якщо; no chains of verbal nouns (для забезпечення можливості запуску → щоб + запустити). Avoid active participles in -учий / -ючий, which read as + calques: існуючий → наявний or що існує; працюючий сервер → сервер, що + працює. +- Avoid russianisms and суржик of every kind — vocabulary, calqued phrases, + and Russian letters (ы, э, ъ, ё never appear in Ukrainian text; ґ, є, і, ї + do where the orthography requires). §5 pins the common traps. +- No hedging the English does not have ("don't" is не використовуйте, not + можливо, варто утриматися) — and no over-correction either: no ти, no slang + (юзати, тулза, дефолтний, задеплоїти), no smileys. + +Example — English: "You don't construct it and you don't configure it. You ask +for it." + +- Not this (officialese): Користувачу не потрібно здійснювати його створення + та конфігурування. Необхідно лише виконати відповідний запит. +- Not this either (familiar): Ти його не створюєш і не налаштовуєш. Просто + просиш. +- This: Його не потрібно ні створювати, ні налаштовувати. Достатньо попросити. + +## 3. Humour and idioms + +- Translate the intent of a joke, aside or idiom, never its words: recast it + as a short, natural Ukrainian sentence in the same register, or keep it + brief where it carries nothing. Never drop the technical content around it. +- Recurring English tags get fixed renderings: "**[X](…)** has the whole + story" / "The whole story is in **[X](…)**" → Докладніше — на сторінці + **[X](…)**.; "That's the whole API." / "That's the whole protocol." → Оце й + увесь API. / Оце й увесь протокол.; "That's it. It's just Python." → От і + все. Це звичайний Python.; "You get `3` back. ✨" → У відповідь приходить + `3`. ✨ +- Idioms take the plain meaning, not the picture: "Out of the box the app + answers **only** requests addressed to localhost." → За замовчуванням + застосунок відповідає **лише** на запити, адресовані localhost. — not з + коробки; "under the hood" → усередині, not під капотом; "on the wire" → у + переданих даних / мережею, never по дроту. +- Keep an exclamation mark only where the English is a genuine exclamation of + encouragement — never after a warning or a step, never doubled, never in a + heading. Reproduce an emoji only where the English has one, in the same + place (two payoff lines end in ✨); never add one. + +## 4. Typography + +- Quotation marks in Ukrainian prose are «лапки-ялинки»; a quote nested inside + them takes „…“. Straight quotes inside code spans, code blocks, commands and + URLs stay untouched. When the English quotes a word the example code prints + or a UI label, the text inside stays exactly as emitted and only the marks + change: вкладка «Tools», кнопка «Connect». +- The apostrophe is part of Ukrainian spelling and is never dropped or spaced: + об'єкт, з'єднання, під'єднати, пам'ять, ім'я, комп'ютер, зв'язок, + обов'язковий, п'ять — never обєкт. Write it as the plain character `'` + (U+0027) on every page, rather than ’ (U+2019) or ʼ (U+02BC); this choice + of character is provisional, apply it uniformly. It never glues an ending + onto a Latin word (see §5). +- Dashes: the grammatical dash is an em dash with a space on each side (Хост — + це застосунок, з яким говорить користувач); a hyphen only joins compounds + (MCP-сервер, HTTP-запит); numeric ranges use an en dash (3.10–3.14) or від + 3.10 до 3.14. An English em-dash aside may also become a comma pair, + parentheses or its own sentence. +- Sentence case everywhere: headings, admonition titles, tab labels and table + headers capitalise the first word and proper nouns only. No capital after a + colon. Language names, weekdays and months are lowercase (у липні). +- Digits stay ASCII. Protocol revision strings such as `2026-07-28` and + `2025-11-25` are identifiers, copied byte for byte — never 28.07.2026, never + 28 липня 2026 р. Version numbers, ports, HTTP status codes, error codes, RFC + and SEP numbers are copied exactly. +- Prose quantities take the decimal comma only when nothing but the separator + changes (2.5 seconds → 2,5 секунди); when in doubt keep the number as + written. A space separates a number from its unit (100 МБ, 30 секунд, 5 с); + % attaches with no space (100%). Numerals govern the noun the Ukrainian way: + 1 інструмент, 3 інструменти, 5 інструментів. +- e.g. → наприклад; i.e. → тобто; etc. → тощо; "&" → і / та. Emphasis lands on + the same words the source emphasises, and a bolded negation ("**not**" → + **не**) stays bold. Loanwords and Latin-script names are set in plain type — + no italics, no quotes around them. Keep the source's colons and parentheses; + a colon before a list or code block is natural Ukrainian too. + +## 5. Terminology pointer + +The glossary (`glossary.json`) is injected separately and overrides this file +on every term it covers; each entry marks its choice as standard or provisional +and says whether it takes a first-use gloss. Its renderings assume: + +- Identifiers stay in Latin script exactly as written: class, function, + method, parameter, module, environment-variable and header names, protocol + method strings such as `tools/call`, and everything in code font. So do the + keep-list terms, acronyms and product and protocol names, always without + the English plural "s": "the SDKs" → SDK or пакети SDK. +- Never decline a Latin-script word with an apostrophe or a glued ending + (API'шка, SDK-а). Let a Ukrainian word carry the case instead: a hyphenated + head noun (MCP-сервер, HTTP-запит, JSON-об'єкт, ASGI-застосунок) or the kind + of thing in front of code (клас `Context`, параметр `lifespan=`, метод + `client.call_tool()`). Adjectives and verbs agree with that Ukrainian word. +- Russianism traps, pinned: application / app → застосунок (додаток is an + add-on or an appendix); "by default" → за замовчуванням (never по + замовчуванню; "default value" → типове значення); next → наступний (never + слідуючий); the linking verb "is" → є or a dash (never являється); settings + → налаштування (never настройки); cancel → скасувати (not відмінити); + exception → виняток (виключення means exclusion); authentication → + автентифікація (not аутентифікація); environment → середовище (not + оточення); get → отримати; delete → видалити. +- Where an established Ukrainian term exists, use it, not the anglicism: + обробник (not хендлер), сповіщення (not нотифікація), екземпляр (not + інстанс), розгортання (not деплой), середовище виконання (not рантайм). + Settled loanwords stay: сервер, клієнт, хост, токен, сесія, схема, + декоратор, промпт, репозиторій, фреймворк, плагін, лог. +- Text quoted from what the example code prints or displays — an output line, + a log message, an Inspector tab or button label — stays exactly as the code + emits it (usually English), in or out of code font; never translate it. +- First-use gloss: a term the glossary marks for it carries the English in + parentheses on its first appearance in a page — еліцитація (elicitation) — + and appears alone after that. A glossary word used as a wire identifier or + a key in code font stays Latin: "the `sampling` capability" → можливість + `sampling`. +- One rendering per term per page: the glossary target, every time. + +## 6. Provisional note + +Every decision in this file, and every entry in `glossary.json`, is +provisional pending review by native Ukrainian-speaking developers. To propose +a change, edit this file or `glossary.json` in a pull request, ideally with a +short good/bad example; never edit the generated `pages/` or `notices.md`. diff --git a/i18n/uk/notices.md b/i18n/uk/notices.md new file mode 100644 index 0000000000..e191ea0e03 --- /dev/null +++ b/i18n/uk/notices.md @@ -0,0 +1,20 @@ +--- +translation: + sections: [aff1b3e872b7876a, 4d80558ad052d586, 0bb81f1e62062d26, d5c35dcec50156bc] + tool: 1 +--- +# Примітки про переклад {#translation-notices} + +Одна з цих приміток з'являється вгорі кожної сторінки перекладеного сайту документації. + +## Машинний переклад {#translated} + +Цю сторінку перекладено автоматично з англомовної документації, і основною версією є [англомовна сторінка](ENGLISH_PAGE). Якщо щось читається неправильно, на сторінці [Переклади](TRANSLATIONS_PAGE) пояснено, як про це повідомити. + +## Переклад відстає від англомовної сторінки {#outdated} + +Англомовна сторінка змінилася після того, як було зроблено цей переклад, тож окремі його частини можуть бути застарілими. Якщо є сумніви, читайте [англомовну сторінку](ENGLISH_PAGE); на сторінці [Переклади](TRANSLATIONS_PAGE) пояснено, як влаштована перекладена документація. + +## Показано англійською {#english} + +Актуального перекладу цієї сторінки немає, тому ви читаєте її англійською. На сторінці [Переклади](TRANSLATIONS_PAGE) пояснено, як влаштована перекладена документація. diff --git a/i18n/uk/pages/advanced/apps.md b/i18n/uk/pages/advanced/apps.md new file mode 100644 index 0000000000..8929b7aec2 --- /dev/null +++ b/i18n/uk/pages/advanced/apps.md @@ -0,0 +1,123 @@ +--- +translation: + sections: [0355618e5f4d5fe4, 1821eaf50f2d0b64, 82e0b28ebd3abf5a, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487] + tool: 1 +--- +# MCP Apps {#mcp-apps} + +**MCP App** — це інструмент із власним обличчям: поряд із даними інструмент указує на HTML-документ, який хост відображає як інтерактивну поверхню. + +Дві частини, завжди дві частини: + +1. **Інструмент**, який виконує роботу й повертає дані, як будь-який інший інструмент. +2. **Ресурс `ui://`** з HTML, який хост показує для нього. + +Інструмент несе посилання на ресурс у `_meta.ui.resourceUri`. Хост отримує його через `resources/read`, відображає в **ізольованому iframe (sandbox)** і передає результат інструмента в цей iframe через `postMessage`. Ваш сервер ніколи не надсилає й не отримує жодних повідомлень `ui/*`: цей обмін відбувається між хостом та iframe. Ви віддаєте інструмент і HTML-документ, а всю виставу ставить хост. + +SDK постачає це як вбудоване розширення `Apps` (`io.modelcontextprotocol/ui`). Якщо [розширення](extensions.md) для вас новинка, спершу прогляньте ту сторінку. Одна хвилина — і повертайтеся. + +## Годинник із циферблатом {#a-clock-with-a-face} + +```python title="server.py" hl_lines="19 22 30 32" +--8<-- "docs_src/apps/tutorial001.py" +``` + +Чотири кроки: + +* `Apps()`: один екземпляр тримає ваші інструменти з прив'язаним UI та їхні ресурси. +* `@apps.tool(resource_uri="ui://clock/app.html")`: звичайний інструмент плюс позначка `_meta.ui.resourceUri`. Усе, що приймає `@mcp.tool()` (name, title, description, ...), передається далі. +* `apps.add_html_resource("ui://clock/app.html", CLOCK_HTML)`: відповідний ресурс, який віддається як `text/html;profile=mcp-app`. Саме цей MIME-тип каже хосту: «це застосунок, відобрази його». +* `MCPServer("clock", extensions=[apps])`: увімкнення. Тепер сервер оголошує `io.modelcontextprotocol/ui` у `capabilities.extensions`. + +Сам HTML слухає `postMessage` від хоста й показує результат. Для справжніх застосунків використовуйте всередині HTML офіційний браузерний SDK [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps). Він дає `ontoolresult`, `callServerTool`, `getHostContext` і `onhostcontextchanged` замість сирих подій повідомлень. + +## Плавна деградація {#graceful-degradation} + +Не кожен клієнт відображає застосунки. Специфікація прямо каже, що це означає для вас: + +> Інструменти **МУСЯТЬ** повертати змістовний масив `content`, навіть коли UI доступний. + +Модель читає `content`; iframe — для людей. Хост із підтримкою UI все одно передає текстовий результат моделі, а суто текстовий клієнт отримує *лише* його. Тож канонічний шаблон — один інструмент, дві відповіді. Погляньте на `get_time` ще раз: + +```python title="server.py" hl_lines="23-27" +--8<-- "docs_src/apps/tutorial001.py" +``` + +`client_supports_apps(ctx)` дорівнює `True` лише тоді, коли клієнт оголосив розширення `io.modelcontextprotocol/ui` **і** вказав `text/html;profile=mcp-app` у своїх налаштуваннях `mimeTypes`. Поле обов'язкове, тож клієнт, який його пропустив, не зараховується. Саме це й оголошує `main()` у тому самому файлі: клієнтську половину узгодження — і у відповідь приходить розширений варіант. + +!!! warning + Ніколи не повертайте заглушку на кшталт `"[Rendered UI]"` як єдиний вміст. Якщо резервний текст марний, інструмент марний для кожного суто текстового клієнта й для самої моделі. Напишіть нормальне речення. + +## Обмеження iframe {#locking-the-iframe-down} + +Метадані безпеки несе ресурс: що iframe може завантажувати, які дозволи браузера йому потрібні, як його бажано вбудовувати: + +```python title="server.py" hl_lines="9 19-22" +--8<-- "docs_src/apps/tutorial002.py" +``` + +`csp` і `permissions` — це **запити до хоста**, а не поведінка сервера. Хост будує з них Content-Security-Policy і Permissions-Policy для iframe й може відмовити. Перевіряйте наявність можливостей у своєму JS, а не припускайте, що дозвіл надано. + +`ResourceCsp`, поле за полем (ім'я в Python, ключ у переданих даних, що з ним робить хост): + +| Python | У переданих даних (`_meta.ui.csp`) | Керує | +|---|---|---| +| `connect_domains` | `connectDomains` | `connect-src`: куди можуть звертатися `fetch`/XHR | +| `resource_domains` | `resourceDomains` | `img-src`, `style-src`, ...: статичні ресурси | +| `frame_domains` | `frameDomains` | `frame-src`: вкладені iframe | +| `base_uri_domains` | `baseUriDomains` | `base-uri`: на що може вказувати `` | + +`ResourcePermissions`: кожне поле запитує для iframe дозвіл браузера. + +| Python | У переданих даних (`_meta.ui.permissions`) | +|---|---| +| `camera` | `camera` | +| `microphone` | `microphone` | +| `geolocation` | `geolocation` | +| `clipboard_write` | `clipboardWrite` | + +!!! note + CSP і дозволи живуть на **ресурсі**, ніколи на інструменті. У метаданих інструмента за специфікацією для них немає місця, і хости їх там ігнорують. SDK робить цю помилку неможливою: `@apps.tool()` просто не має параметра `csp`. + +### Видимість {#visibility} + +`visibility=["app"]` на інструменті означає «це існує для iframe, а не для моделі»: + +* `"model"`: інструмент може викликати модель. +* `"app"`: інструмент може викликати iframe (через `callServerTool`). +* Не вказано: обидва, це значення за замовчуванням. + +Фільтрація — справа **хоста**. Ваш сервер перелічує інструменти лише для застосунку в `tools/list`, як і будь-які інші; хост приховує їх від моделі. Не фільтруйте на боці сервера. + +## Правила, які контролює SDK {#the-rules-the-sdk-enforces} + +Усе це падає під час запуску, а не в продакшені: + +* `resource_uri` або URI ресурсу, що не має вигляду `ui://...`, дає `ValueError` під час декорування чи реєстрації. +* Інструмент, прив'язаний до URI **без відповідного зареєстрованого ресурсу**, дає `ValueError`, коли `MCPServer(extensions=[apps])` приймає розширення. Інструмент, що оголошує HTML, який повертає 404 на `resources/read`, — це помилка конфігурації, тож сервер відмовляється створюватися. +* `meta={"ui": ...}` у `@apps.tool()` дає `ValueError`. Декоратор володіє `_meta["ui"]`; висловлюйте це через `resource_uri=` і `visibility=`. Інші ключі `meta=` спокійно зливаються поруч. + +Ані TypeScript SDK ext-apps, ані FastMCP сьогодні нічого з цього не ловлять; краще дізнатися про це раніше, ніж дізнається хост. + +## Не тільки вбудований HTML {#beyond-inline-html} + +`add_html_resource` покриває типовий випадок: рядок з HTML. Для всього іншого — HTML на диску чи згенерованого вмісту — побудуйте ресурс самі й передайте його: + +```python title="server.py" hl_lines="12 18" +--8<-- "docs_src/apps/tutorial003.py" +``` + +`add_resource` підставляє MIME-тип `text/html;profile=mcp-app`, коли ресурс не задає його явно, і відхиляє явну невідповідність: ресурс `ui://` з будь-яким іншим MIME-типом не відобразить жоден хост. + +!!! tip + Орієнтуєтеся на хост до GA-версії, який досі читає застарілий плоский ключ `_meta["ui/resourceUri"]`? Додайте його самі: + `@apps.tool(resource_uri="ui://x", meta={"ui/resourceUri": "ui://x"})`. + Вкладений об'єкт `ui` — це форма за специфікацією; плоский ключ доживає своє. + +## Приклад у дії {#see-it-run} + +Історія `apps` в `examples/stories/` — це ця сторінка у вигляді пари, яку можна запустити: сервер з інструментом-годинником із прив'язаним UI та клієнт, який узгоджує Apps, читає `_meta.ui.resourceUri` інструмента, отримує HTML і викликає інструмент. + +```bash +uv run python -m stories.apps.client +``` diff --git a/i18n/uk/pages/advanced/extensions.md b/i18n/uk/pages/advanced/extensions.md new file mode 100644 index 0000000000..9e73f6b622 --- /dev/null +++ b/i18n/uk/pages/advanced/extensions.md @@ -0,0 +1,172 @@ +--- +translation: + sections: [05891e7cc1938a13, b3c01a6af28c51ee, 7ffc91f5e38bdfe0, 717d3f235a8333a7, f471a13b2fe5d737, ed6af2df4b656dff] + tool: 1 +--- +# Розширення {#extensions} + +**Розширення** — це набір поведінки MCP, який вмикається лише на явний запит і стоїть за одним ідентифікатором. + +На сервері воно може додавати інструменти, ресурси й нові методи запитів, а також обгортати `tools/call`. На клієнті — заявляти додаткові форми результату `tools/call` і спостерігати за вендорськими сповіщеннями. Кожна сторона оголошує розширення у власному `capabilities.extensions`, і для тих, хто про це не просив, нічого не змінюється. Такий контракт ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)), і в нього одне золоте правило: **розширення за замовчуванням вимкнені**. + +## Використання розширення {#using-an-extension} + +Передайте екземпляри під час створення: + +```python title="server.py" +--8<-- "docs_src/extensions/tutorial001.py" +``` + +Готово. Тепер сервер оголошує `io.modelcontextprotocol/ui` у `capabilities.extensions` і обслуговує все, що додає розширення. + +`Apps` — вбудоване еталонне розширення, і йому присвячено окрему сторінку: **[MCP Apps](apps.md)**. + +!!! note + Розширення фіксуються під час створення. Методу `add_extension`, який можна було б викликати пізніше, немає: карта можливостей сервера не повинна змінюватися, поки до нього під'єднані клієнти. + +Карта можливостей передається через `server/discover`, а це шлях версії **2026-07-28**. Рукостисканню `initialize` старого покоління нікуди її покласти, тож клієнт старого покоління просто не бачить розширення. Проєктуйте з урахуванням цього: розширення *доповнює* сервер і не повинно бути єдиним способом ним користуватися. + +## Написання власного розширення {#writing-your-own} + +Успадкуйте `Extension` і перевизначте лише те, що потрібно. Кожен метод має типову реалізацію. + +### Ідентифікатор {#the-identifier} + +```python +--8<-- "docs_src/extensions/tutorial002.py" +``` + +Ідентифікатор — це рядок вигляду `vendor-prefix/name`, що відповідає граматиці ключів `_meta` зі специфікації: розділені крапками мітки (кожна починається з літери й закінчується літерою або цифрою), скісна риска, потім ім'я. Він перевіряється **під час визначення класу**, тож друкарська помилка не чекає, поки сервер запуститься: + +```text +TypeError: Stamps.identifier must be a `vendor-prefix/name` string +(reverse-DNS prefix required), got 'stamps' +``` + +Як префікс використовуйте домен, яким ви керуєте. `io.modelcontextprotocol/*` призначено для розширень, які специфікує сам проєкт MCP. + +### Додавання інструментів {#contributing-tools} + +Найменше корисне розширення — один інструмент і карта налаштувань: + +```python title="server.py" hl_lines="17 19-20 22-23 26" +--8<-- "docs_src/extensions/tutorial003.py" +``` + +* `tools()` повертає об'єкти `ToolBinding`. Сервер реєструє кожен із них точно так, ніби ви самі викликали `mcp.add_tool(...)`: те саме генерування схеми, те саме впровадження `Context`, усе те саме. +* `settings()` — це значення, що оголошується в `capabilities.extensions["com.example/stamps"]`. Поверніть `{}` (типове значення), щоб оголосити розширення без налаштувань. +* Розширення ніколи не отримує сервер. Воно оголошує свій внесок як дані; `MCPServer` їх споживає. Ніякого `self.server`, який можна було б змінювати, немає. + +А `main()` — доказ: клієнт у пам'яті, під'єднаний безпосередньо до `mcp`: + +```python title="server.py" hl_lines="29-34" +--8<-- "docs_src/extensions/tutorial003.py" +``` + +### Обслуговування власних методів {#serving-your-own-methods} + +Розширення може реєструвати **нові методи запитів**: власні дієслова, які обслуговуються поруч із методами специфікації: + +```python title="server.py" hl_lines="16-22 31 40-48" +--8<-- "docs_src/extensions/tutorial004.py" +``` + +* `SearchParams` успадковує `RequestParams`, тож конверт `_meta` версії 2026 розбирається однаково, а обробник отримує перевірені параметри, а не сирий словник. Обмежуйте те, чим керує клієнт: `Field(ge=1, le=100)` відхиляє безглуздий `limit`, перш ніж ваш код щось під нього виділить. +* `require_client_extension(ctx, EXTENSION_ID)` — це шлагбаум: клієнт, який не оголосив розширення, отримує помилку `-32021` (відсутня обов'язкова можливість клієнта) з машиночитаним корисним навантаженням `requiredCapabilities`, якого вимагає специфікація. +* `protocol_versions=frozenset({"2026-07-28"})` прив'язує метод до однієї версії протоколу. На будь-якій іншій версії клієнт отримує `METHOD_NOT_FOUND` — точно так, ніби методу там не існує. Для цього клієнта він і не існує. + +Методи **лише додаються**. SDK забезпечує це під час створення, а не під час виконання: + +* `MethodBinding` для методу, визначеного специфікацією (`tools/list`, `completion/complete`, ...), викидає `ValueError` під час створення прив'язки. Базові дієслова належать серверу. +* Два розширення, що прив'язують той самий метод, викидають виняток, коли реєструється друге. «Перемагає останній запис» — саме так плагіни псують одне одного; ми цього не робимо. +* Порожня множина `protocol_versions` теж викидає виняток: метод, який ніколи не може бути обслужений, — це помилка, а не конфігурація. + +### Клієнтська сторона {#the-client-side} + +`main()` у тому самому файлі — це вся клієнтська частина, обидві її половини: + +```python title="server.py" hl_lines="54-58" +--8<-- "docs_src/extensions/tutorial004.py" +``` + +* `Client(..., extensions=[advertise(EXTENSION_ID)])` оголошує розширення. Оголошення стають `ClientCapabilities.extensions`: на з'єднанні версії 2026-07-28 карта подорожує в конверті `_meta` кожного запиту, тож сервер бачить її в **кожному** запиті; на з'єднанні старого покоління вона передається з рукостисканням `initialize`. Серверному коду байдуже, який саме шлях: `require_client_extension(ctx, ...)` і `ctx.session.check_client_capability(...)` читають правильне джерело в обох випадках. +* Вендорські методи опускаються на один шар нижче, до `client.session.send_request(...)`; повноцінні методи в `Client` з'являються лише для дієслів специфікації. `send_request` приймає будь-який підклас `Request`, тож вендорський запит проходить як є. + +### Перехоплення `tools/call` {#intercepting-toolscall} + +Єдиний хук-перехоплювач. Перевизначте `intercept_tool_call`, щоб спостерігати за викликом інструмента, завершувати його достроково або забороняти: + +```python title="server.py" hl_lines="17-24" +--8<-- "docs_src/extensions/tutorial005.py" +``` + +* `params` — це перевірений `CallToolRequestParams`: `params.name` і `params.arguments` доступні без роботи із сирим JSON. Саме він визначає, який виклик інструмента виконується: якщо передати через `call_next` переписаний контекст, зміниться те, що обробник бачить у `ctx`, а не сам виклик інструмента. Переписування запитів на рівні переданих даних — справа [Middleware](middleware.md). +* `call_next(ctx)` виконує решту ланцюжка й повертає результат обробника. Поверніть його без змін (спостереження), поверніть щось інше (заміна) або викиньте `MCPError` (відмова). Усе, що ви повернете, серіалізується як будь-який результат обробника, разом зі штампом ідентичності `serverInfo` покоління 2026, тож перехоплювач, який завершує виклик достроково, ніколи не видасть анонімної відповіді чи відповіді не за схемою. +* Коли розширень кілька, перехоплювачі вкладаються в порядку реєстрації: перше розширення в `extensions=[...]` — зовнішнє. +* Типова реалізація просто пропускає виклик далі, а сервер, чиї розширення не перевизначають цей хук, зберігає голий обробник `tools/call` недоторканим. За те, чим не користуєтеся, не платите. + +Хук обгортає `tools/call` і нічого більше. Для того, що стосується кожного повідомлення, використовуйте [Middleware](middleware.md). Саме для цього воно й існує. + +## Використання клієнтського розширення {#using-a-client-extension} + +**Клієнтське розширення** — той самий контракт з боку споживача: набір клієнтської поведінки за одним ідентифікатором. Передайте екземпляри в `Client(extensions=[...])` і викликайте інструменти як зазвичай: + +```python title="client.py" hl_lines="66-68" +--8<-- "docs_src/extensions/tutorial006.py" +``` + +`call_tool("buy", ...)` повертає звичайний `CallToolResult`, як і будь-який інший виклик. Що змінило розширення: тепер сервер може відповісти на `buy` **формою результату** `receipt` замість остаточного результату, а `Receipts` доводить її до кінця (тут — погашаючи квитанцію наступним викликом), перш ніж `call_tool` поверне значення. У місці виклику нічого не змінюється. + +Приберіть розширення — і нічого з цього не існує: шлагбаум сервера відмовляє клієнту, який його не оголосив (помилка -32021), а заявлена форма від сервера, що обходить шлагбаум, не проходить перевірку — точно так, як специфікація вимагає для нерозпізнаного `resultType`. Вимкнено за замовчуванням, з обох кінців з'єднання. + +Щоб оголосити ідентифікатор **без** жодної клієнтської поведінки (сервер перевіряє можливість, клієнт нічого не робить, як у клієнті пошуку вище), використовуйте `advertise()`: + +```python +from mcp.client import advertise + +client = Client(mcp, extensions=[advertise("com.example/search")]) +``` + +## Написання клієнтського розширення {#writing-a-client-extension} + +Успадкуйте `ClientExtension` і перевизначте лише те, що потрібно. Три види внеску, кожен із типовою реалізацією: `settings()`, `claims()` і `notifications()`. + +```python title="client.py" hl_lines="17-18 43-44 46-47" +--8<-- "docs_src/extensions/tutorial006.py" +``` + +* Ідентифікатор підпорядковується тій самій граматиці, що й на сервері, і перевіряється під час визначення класу. +* `claims()` повертає об'єкти `ResultClaim`: тег у переданих даних, модель, яка його розбирає, і резолвер, який доводить його до кінця. Модель мусить зафіксувати тег через `result_type: Literal["receipt"]` і не повинна успадковувати базові типи результатів дієслова; обидві умови перевіряються під час створення заявки. Вендорські поля на кшталт `receipt_token` передаються мережею як є: підставлена форма доходить до клієнта дослівно. +* Резолвер отримує розібрану модель і `ClaimContext`; `ctx.session` — той самий публічний дескриптор, що й `client.session`, тож подальші виклики — це звичайні виклики сесії. Він повертає звичайний для дієслова `CallToolResult`. +* `settings()` — значення, що оголошується в `ClientCapabilities.extensions[identifier]`; воно читається один раз під час створення `Client`. + +`notifications()` оголошує вендорські сповіщення сервера, за якими слід спостерігати: + +```python +def notifications(self) -> Sequence[NotificationBinding[Any]]: + return [NotificationBinding(method="notifications/receipts", params_type=ReceiptEvent, handler=self.on_receipt)] +``` + +Обробник отримує перевірені параметри по одному, у порядку диспетчеризації. Він спостерігає; накласти вето чи відповісти він не може. + +Два негучні правила. Заявки діють лише на з'єднаннях версії 2026-07-28, і оголошення можливостей іде за ними: на з'єднанні старого покоління заявки розчиняються, а разом із ними з оголошення випадає й ідентифікатор, тож клієнт ніколи не оголошує розширення, чиї форми він би відхилив. А коли заявлена форма потрібна вам самим, а не резолверу, викликайте `client.session.call_tool(..., allow_claimed=True)`; без цього прапорця заявлена форма, що доходить до виклику на рівні сесії, викидає `UnexpectedClaimedResult`. + +### Дієслова розширень {#extension-verbs} + +Власні методи запитів розширення не потребують реєстрації на клієнті. Тип вендорського запиту успадковує `mcp.types.Request` і проходить через `client.session.send_request`, як у розділі [Обслуговування власних методів](#serving-your-own-methods). Одне доповнення: коли ключ параметрів мусить передаватися в заголовку `Mcp-Name` (специфікації розширень, як-от tasks, вимагають цього для своїх дієслів), тип запиту оголошує `name_param`: + +```python title="client.py" hl_lines="22-25 46-47" +--8<-- "docs_src/extensions/tutorial007.py" +``` + +Сесія дзеркалить `params["jobId"]` у `Mcp-Name` на кожному шляху надсилання, а відсутнє значення дає гучну помилку замість того, щоб мовчки пропустити обов'язковий заголовок. + +## Чого розширення не може {#what-an-extension-cannot-do} + +Поверхня внеску **закрита** навмисно. На сервері: налаштування, інструменти, ресурси, методи, один перехоплювач `tools/call`. На клієнті: налаштування, заявки на результати, прив'язки сповіщень. Розширення не може: + +* **Лізти в хост.** Воно оголошує дані; посилання на сервер чи клієнт у нього немає. +* **Замінювати базову поведінку.** Методи специфікації та базові теги результатів відхиляються під час створення (`initialize` цілком зарезервовано за виконавцем); прив'язка сповіщення, перекрита базовим словником, натомість замовкає з попередженням. +* **Реєструватися із запізненням.** Після того як `MCPServer(...)` чи `Client(...)` повернув керування, набір розширень уже такий, який є. + +Якщо ви воюєте з цими стінами, ви пишете не розширення. Ви пишете форк. Стіни — це й є головна перевага: користувач, що читає `extensions=[Apps(), Stamps()]`, знає *все*, чого ці двоє могли торкнутися. diff --git a/i18n/uk/pages/advanced/index.md b/i18n/uk/pages/advanced/index.md new file mode 100644 index 0000000000..d2d78e6174 --- /dev/null +++ b/i18n/uk/pages/advanced/index.md @@ -0,0 +1,34 @@ +--- +translation: + sections: [ca6988b7503cd2d3] + tool: 1 +--- +# Розширені можливості {#advanced} + +Усе, що потрібно звичайному серверу чи клієнту, має своє тематичне місце в розділах вище. +Цей розділ — це запасні виходи, до яких звертаються, коли зручний шар `MCPServer` +починає заважати: + +* **[Низькорівневий Server](low-level-server.md)**: клас, на якому побудовано `MCPServer`. + Схеми, написані вручну, обробники `on_*`, жодних перевірок за вас і власні + JSON-RPC-методи. +* **[Пагінація](pagination.md)** і **[Middleware](middleware.md)**: дві речі, які + можна зробити *лише* на низькорівневому `Server`. +* **[Розширення](extensions.md)** і **[Застосунки MCP](apps.md)**: поверхня розширень + протоколу. Скомпонуйте пакети розширень у сервер або напишіть власні. + +Кілька речей, які цілком логічно шукати тут, натомість розміщено там, де ними +справді користуються: + +* **Авторизація** — у розділі **[Запуск сервера](../run/index.md)**, бо сервер + захищають там, де його розгортають. +* **OAuth**, **підтвердження ідентичності**, під'єднання до **кількох серверів** і + **кеш** відповідей — усе це в розділі **[Клієнти](../client/index.md)**. +* **Багатораундові запити** і **Підписки** — у розділі + **[Усередині обробника](../handlers/index.md)**, бо і те, й інше обробник + саме *робить*. +* **Шаблони URI** — у розділі **[Сервери](../servers/index.md)**, поруч із ресурсами. +* **[Версії протоколу](../protocol-versions.md)** і + **[Застарілі можливості](../deprecated.md)** мають власні сторінки верхнього рівня. + +Якщо ви не впевнені, чи потрібен вам цей розділ, — він вам не потрібен. diff --git a/i18n/uk/pages/advanced/low-level-server.md b/i18n/uk/pages/advanced/low-level-server.md new file mode 100644 index 0000000000..92fa509c24 --- /dev/null +++ b/i18n/uk/pages/advanced/low-level-server.md @@ -0,0 +1,207 @@ +--- +translation: + sections: [2c79b6338e09b7ac, 7edc43b3fae11314, 1086e77ce561cd7f, a3f71823df5efc31, 9fc7109f72201cae, 7bf25983df655b66, 6330e1f4c6029683, 2f1749c8c133fa1c, b3530fcf4d11fd56, ebc33704fbd74262, cd0e9c933350390e] + tool: 1 +--- +# Низькорівневий Server {#the-low-level-server} + +`@mcp.tool()` — це шар. Під ним лежить другий серверний клас, `Server`, який говорить «сирим» MCP: ви передаєте йому об'єкти протоколу, а він надсилає їх мережею без змін. + +`MCPServer` побудований поверх нього. Спускатися на рівень нижче варто тоді, коли зручний шар заважає: + +* Потрібно віддати **точну** схему (завантажену з файлу, згенеровану з бази даних), а не виведену з сигнатури Python. +* Потрібен повний контроль над результатом: `_meta`, `is_error`, кожен ключ `structured_content`. +* Потрібно обробити метод, якого MCP не визначає. + +Для всього іншого залишайтеся на `MCPServer`. + +## Той самий інструмент, вручну {#the-same-tool-by-hand} + +Це інструмент `search_books`, який сторінка **[Інструменти](../servers/tools.md)** пише дев'ятьма рядками `@mcp.tool()`, — тільки без синтаксичного цукру: + +```python title="server.py" hl_lines="22 26 32" +--8<-- "docs_src/lowlevel/tutorial001.py" +``` + +Змінилися три речі, і вони й складають увесь низькорівневий API: + +* **Обробники — це параметри конструктора.** `on_list_tools=` і `on_call_tool=` передаються в `Server(...)`. Декораторів тут немає, і кожен обробник має однакову форму: `async (ctx, params) -> result`. +* **Вхідну схему пишете ви.** `Tool.input_schema` — це звичайний `dict` із JSON Schema. Ніхто не виводить її з анотацій типів, бо анотацій типів, з яких можна було б її вивести, немає. +* **Результат будуєте ви.** `CallToolResult(content=[TextContent(...)])`, вручну. Нічого не загортається, не перетворюється й не виводиться з анотації значення, що повертається. + +`params` — це розібраний запит: `CallToolRequestParams` дає `.name` і `.arguments`. `ctx` — це `ServerRequestContext`: `ctx.session`, щоб звертатися назад до клієнта, `ctx.lifespan_context`, `ctx.request_id` і `ctx.meta` — вхідні `_meta` запиту. + +!!! info + Якщо ви працювали з FastAPI, це співвідношення вам уже знайоме. `MCPServer` — це шар декораторів і анотацій типів; `Server` — це Starlette під ним. Вони не суперники: `MCPServer` створює `Server` і реєструє на ньому саме такі обробники. + +### Спробуйте самі {#try-it} + +Inspector тут не допоможе: `mcp dev` і `mcp run` приймають лише `MCPServer`. `Client`, що працює в пам'яті, до цього байдужий: низькорівневий `Server` він приймає так само, як і `MCPServer`: + +```python title="main.py" +import asyncio + +from mcp import Client + +from server import server + + +async def main() -> None: + async with Client(server) as client: + result = await client.call_tool("search_books", {"query": "dune", "limit": 5}) + print(result.content) + + +asyncio.run(main()) +``` + +```text +[TextContent(type='text', text="Found 3 books matching 'dune' (showing up to 5).", annotations=None, meta=None)] +``` + +Той самий текст, що його видавала версія з `@mcp.tool()`. Дві чесні відмінності: + +* `result.structured_content` дорівнює `None`. Високорівневий сервер загортає `-> str` у `{"result": ...}` за вас; тут ніхто не будує того, чого не побудували ви. +* `list_tools` повертає схему, яку набрали **ви**, символ у символ. У високорівневій версії на кожній властивості було `"title": "Query"`, а в корені — `"title": "search_booksArguments"`: артефакти Pydantic. Тут, якщо щось є в переданих даних, — це ви його туди поклали. + +## Ніхто нічого не перевіряє за вас {#nothing-is-checked-for-you} + +`MCPServer` відхиляє хибний аргумент ще до того, як ваша функція запуститься, перевіряючи виклик за схемою, яку сам згенерував (**[Інструменти](../servers/tools.md)**). + +`Server` цього не робить. Ваша `input_schema` *оголошується* клієнтові; вона ніколи не *застосовується* до `params.arguments`. + +!!! check + Викличте `search_books` без `limit` — і ваш `args["limit"]` викине `KeyError`. Клієнт побачить: + + ```text + MCPError: Internal server error + ``` + + Помилка JSON-RPC з кодом `-32603` і навмисно загальним повідомленням: SDK не видасть ваш traceback віддаленій стороні, що викликає. Модель так і не дізнається, що зробила не так, тож не зможе повторити спробу. (У тесті `raise_exceptions=True` натомість показує справжній виняток; див. **[Тестування](../get-started/testing.md)**.) + +Це узагальнюється. Виняток, викинутий із низькорівневого обробника, — це **завжди** помилка протоколу й ніколи — результат інструмента з `is_error=True`. Якщо потрібно, щоб модель прочитала про невдачу й відновилася, перевіряйте `params.arguments` самі й повертайте `CallToolResult(content=[TextContent(...)], is_error=True)`. Обом видам невдач присвячена сторінка **[Обробка помилок](../servers/handling-errors.md)**. + +## Два інструменти, один обробник {#two-tools-one-handler} + +`on_call_tool` — єдина точка входу для всіх інструментів сервера. Маршрутизуєте за `params.name`: + +```python title="server.py" hl_lines="38-43" +--8<-- "docs_src/lowlevel/tutorial002.py" +``` + +* `list_tools` оголошує обидва. `call_tool` диспетчеризує за іменем. +* Гілка `else` важлива: `Server` спокійно передасть `tools/call` з іменем, якого ви ніколи не оголошували, просто у ваш обробник. Виняток там перетворює виклик на ту саму `-32603`, що й вище. + +## Структурований вивід, вручну {#structured-output-by-hand} + +Оголосіть `output_schema` на `Tool` і покладіть `structured_content` у результат. І те, й інше — ваше: + +```python title="server.py" hl_lines="19-23 36" +--8<-- "docs_src/lowlevel/tutorial003.py" +``` + +Викличте його — і результат міститиме обидва подання: + +```json +{ + "content": [{"type": "text", "text": "Found 3 books matching 'dune'."}], + "structuredContent": {"matches": 3, "query": "dune"}, + "isError": false, + "resultType": "complete", + "_meta": {"io.modelcontextprotocol/serverInfo": {"name": "Bookshop", "version": "2.0.0"}} +} +``` + +Блок `_meta` — це ідентифікаційна позначка сервера: SDK додає його до кожного результату покоління 2026 разом із `version` з конструктора (сервер, який її не задав, повідомляє порожній рядок). Сервер, який не повинен себе ідентифікувати, може прибрати цей ключ за допомогою middleware, який володіє результатами, що повертає. + +Сервер ніколи не порівнює ці два поля. `Client` цього SDK — порівнює: поверніть `structured_content`, що не відповідає оголошеній вами `output_schema`, і `call_tool` викине `RuntimeError`, який починається з `Invalid structured content returned by tool search_books` і далі цитує помилку `jsonschema`. Пообіцяти схему легко; дотримати її — ваша справа. Уся драбина типів повернення та схем — на сторінці **[Структурований вивід](../servers/structured-output.md)**. + +## `_meta`: для застосунку, а не для моделі {#\_meta-for-the-application-not-the-model} + +`content` — це частина відповіді, яку читає модель. `structured_content` — та сама відповідь у вигляді типізованих даних. `_meta` — третій канал: дані, що їдуть разом із результатом для **клієнтського застосунку** і взагалі не є частиною відповіді. + +Використовуйте його для ідентифікаторів записів, ідентифікаторів трасування — усього, що потрібно вашому UI, але не потрібно промпту: + +```python title="server.py" hl_lines="37" +--8<-- "docs_src/lowlevel/tutorial004.py" +``` + +* Конструюєте його як `_meta=` — це ім'я в переданих даних. Клієнт зчитує його як `result.meta`. +* Додавайте до ключів простір імен (`bookshop/record_ids`). Ключі `io.modelcontextprotocol/*` зарезервовано протоколом. + +!!! warning + `_meta` — це домовленість між вами й клієнтським застосунком, а не гарантія того, що дійде + до моделі. Що показувати, вирішує хост. Ніколи не кладіть секрет у жодну частину результату інструмента. + +## Можливості випливають з обробників {#capabilities-follow-your-handlers} + +`Server` оголошує рівно ті сімейства методів, для яких ви дали йому обробники. `Bookshop` вище передає `on_list_tools` і `on_call_tool` і більше нічого, тож клієнт, що до нього під'єднується, бачить: + +```json +{"tools": {"listChanged": false}} +``` + +Жодних `resources`, жодних `prompts`: за ними нічого не стоїть. Передайте `on_list_prompts` — і з'явиться `prompts`; передайте `on_completion` — і з'явиться `completions`. + +`MCPServer` завжди оголошує інструменти, ресурси й промпти, зареєстрували ви щось чи ні, бо його менеджери існують завжди. Тут же оголошення — це *і є* виклик конструктора. + +## Життєвий цикл як параметр типу {#the-lifespan-generic} + +`Server` узагальнений за типом, який видає його життєвий цикл (lifespan). Анотуйте його один раз — і об'єкт буде типізованим усюди, де з'являється: + +```python title="server.py" hl_lines="24-26 44-45 50" +--8<-- "docs_src/lowlevel/tutorial005.py" +``` + +* Життєвий цикл — це `Callable[[Server[Catalog]], AbstractAsyncContextManager[Catalog]]`; `@asynccontextmanager` над `async`-генератором дає саме це. +* Усе, що він видає через `yield`, стає `ctx.lifespan_context`, а оскільки обробники анотовано як `ServerRequestContext[Catalog]`, `.search(...)` автодоповнюється й проходить перевірку типів. +* У нього входять один раз під час старту сервера й виходять один раз під час зупинки. Запуск, завершення та версія тієї самої ідеї в `MCPServer` — на сторінці **[Життєвий цикл](../handlers/lifespan.md)**. + +Без `lifespan=` `ctx.lifespan_context` — порожній `dict`. + +## Власний метод {#a-method-of-your-own} + +Конструктор покриває методи, які визначає MCP. `add_request_handler` покриває все інше: + +```python title="server.py" hl_lines="35-36 39-40 43-44 48" +--8<-- "docs_src/lowlevel/tutorial006.py" +``` + +* Перший аргумент — рядок методу. Для сповіщень є близнюк — `add_notification_handler`. +* `params_type` — це модель, за якою вхідні `params` перевіряються **до** запуску вашого обробника, тож власні методи *отримують* перевірку, якої інструменти не мають. Успадковуйтеся від `RequestParams`, щоб поле `_meta` розбиралося так само, як у кожного іншого методу. +* Обробник повертає `BaseModel`, `dict` або `None`. SDK серіалізує це в результат JSON-RPC. + +Одне чесне застереження: високорівневий `Client` має дієслова лише для методів, які визначає MCP, тож `client.reindex()` немає. Вендорний метод — для сторони, яка вже знає про його існування: клієнта, який ви теж постачаєте, або іншого вашого сервісу, що говорить JSON-RPC. + +Один метод забрати собі не можна: + +```text +ValueError: 'initialize' is handled by the server runner and cannot be overridden; +use Server.middleware to observe or wrap initialization +``` + +Рукостискання належить засобу запуску сервера. `server/discover`, `ping` та всі інші вбудовані методи можна замінювати. + +!!! tip + `Server.middleware`, згаданий у цій помилці, обгортає **кожне** вхідне повідомлення, включно з `initialize`. Якщо мета — спостерігати за трафіком чи переписувати його, а не відповідати на новий метод, почніть із **[Middleware](middleware.md)**. + +## Інші обробники {#the-other-handlers} + +Кожен із них — одна ідея, для якої у вас тепер є словник; кожна має власну сторінку. + +* `on_call_tool`, `on_get_prompt` і `on_read_resource` можуть повернути `InputRequiredResult` замість звичайного результату, щоб призупинити виклик і попросити клієнта про введення; див. **[Багатораундові запити](../handlers/multi-round-trip.md)** (multi-round-trip). Як і годиться цьому рівню, нічого не встановлюється за вас: якщо `MCPServer` за замовчуванням запечатує `requestState`, то тут заданий вами `request_state` передається мережею точно так, як написано, доки ви не ввімкнете захист через `server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[...]), default_audience=server.name))`: один рядок (обидва імені імпортуються з `mcp.server.request_state`) — і отримуєте те саме запечатування й перевірку, що їх виконує `MCPServer` (**[Захист `requestState`](../handlers/multi-round-trip.md#protecting-requeststate)**). +* `on_list_resources`, `on_read_resource`, `on_list_prompts`, `on_get_prompt`, `on_completion` — та сама форма `(ctx, params) -> result` для інших примітивів. +* `on_subscriptions_listen` обслуговує потік `subscriptions/listen` версії 2026-07-28. Передайте `ListenHandler`, побудований поверх `SubscriptionBus`, і публікуйте події в шину з інших обробників; повну композицію див. на сторінці **[Підписки](../handlers/subscriptions.md)**. +* `server.streamable_http_app()` повертає той самий Starlette-застосунок, що й у `MCPServer`; розгортайте його так, як **[Запуск сервера](../run/index.md)** розгортає будь-який інший ASGI-застосунок. `server.run(transport=...)` тут немає: `server.run(read_stream, write_stream, server.create_initialization_options())` веде одне з'єднання через пару потоків, і цей один рядок — оце й усе. + +## Підсумки {#recap} + +* Низькорівневий `Server` приймає обробники як **параметри конструктора** `on_*`; кожен обробник — це `async (ctx, params) -> result`. +* Ви пишете словник `input_schema` і будуєте `CallToolResult`. Нічого не виводиться, не загортається й не перевіряється за вас. +* Виняток в обробнику — це помилка протоколу `-32603`. Помилка інструмента, яку може прочитати модель, — це `CallToolResult` з `is_error=True`, який повертаєте **ви**. +* `_meta` в результаті адресовано клієнтському застосунку, а не моделі. +* `Server[T]` узагальнений за тим, що видає його життєвий цикл; `ctx.lifespan_context` — це типізований `T`. +* `add_request_handler(method, params_type, handler)` обслуговує будь-який метод. `initialize` зарезервовано. +* Можливості, які оголошує `Server`, виводяться з того, які обробники ви зареєстрували. + +`Client(server)` поводився з обома серверами однаково, бо вони *і є* тим самим протоколом — у цьому й увесь сенс. Наступний шар нижче — взагалі не клас: це **[Middleware](middleware.md)**. diff --git a/i18n/uk/pages/advanced/middleware.md b/i18n/uk/pages/advanced/middleware.md new file mode 100644 index 0000000000..a51773410b --- /dev/null +++ b/i18n/uk/pages/advanced/middleware.md @@ -0,0 +1,128 @@ +--- +translation: + sections: [6048b4f308edbb8c, 068bda0f21ee9c1b, c3e565b61acd75c5, c62422b159c6ed09, 47204fab253cc45c] + tool: 1 +--- +# Middleware {#middleware} + +**Middleware** — це одна асинхронна функція, що огортає кожне повідомлення, яке отримує сервер. + +Її пишуть у формі `async (ctx, call_next)` і додають до `server.middleware`. Оце й увесь API. + +!!! warning + Список middleware у вихідному коді позначено як **попередній** (provisional): його сигнатура + й семантика можуть змінитися в мінорному випуску 2.x. Використовуйте його, щоб + *спостерігати* (час виконання, логування, трасування) і щоб *відхиляти* повідомлення; не + робіть його фундаментом, на якому тримається сервер. + +`MCPServer` приймає список у конструкторі (`MCPServer(name, middleware=[...])`) і надає доступ +до нього як `mcp.middleware`; низькорівневий `Server` надає той самий список як +`server.middleware`. Приклад нижче використовує низькорівневий `Server`; якщо +`Server(name, on_call_tool=...)` вам незнайомий, спершу прочитайте сторінку +**[Низькорівневий Server](low-level-server.md)**. + +## Middleware для вимірювання часу {#a-timing-middleware} + +Один сервер, один інструмент, один шар middleware, який записує в лог, скільки часу забрало кожне повідомлення: + +```python title="server.py" hl_lines="39-45 49" +--8<-- "docs_src/middleware/tutorial001.py" +``` + +* `ctx` — той самий `ServerRequestContext`, що його отримують обробники. `ctx.method` — сирий + рядок методу; `ctx.params` — сирі параметри, **до** будь-якої валідації. +* `call_next(ctx)` запускає решту ланцюжка: валідацію, пошук обробника, сам обробник. + Поверніть те, що він повернув, — і відповідь залишиться недоторканою. +* `try`/`finally` тут навмисно: обробник, що викидає виняток, однаково буде заміряно, бо збій + доходить до middleware як виняток із `call_next`. +* `server.middleware.append(...)` реєструє її. Список виконується від зовнішнього до + внутрішнього, тож `middleware[0]` — найближча до мережі. + +### Спробуйте самі {#try-it} + +Під'єднайте клієнт, отримайте список інструментів, викличте один. У лозі буде **три** рядки: + +```text +server/discover took 18.3 ms +tools/list took 0.1 ms +tools/call took 0.1 ms +``` + +Ви зробили два виклики, а рядків три. Перший — `server/discover`: запит, який клієнт надіслав, +щоб установити з'єднання, ще до того, як ви щось попросили. + +У цьому й суть. Middleware огортає **кожне** вхідне повідомлення: + +* Установлення з'єднання: `server/discover`, або `initialize` і `notifications/initialized` + у сесії старого покоління. +* Кожен запит і кожне сповіщення. Для сповіщення `ctx.request_id is None`, + `call_next(ctx)` повертає `None`, а все, що повернете ви, відкидається. +* Навіть метод, для якого сервер не має обробника: `call_next` викидає + `MCPError(-32601, "Method not found")` *крізь* ваш middleware на шляху до клієнта. + +## Що можна робити всередині {#what-you-can-do-inside-one} + +У порядку зростання того, наскільки варто вагатися: + +* **Спостерігати.** Заміряти час, рахувати, логувати. Приклад вище. +* **Відхиляти.** Викиньте `MCPError` *замість* виклику `call_next(ctx)` — і саме на це + повідомлення клієнт отримає помилку JSON-RPC. З'єднання лишається живим; наступне + повідомлення проходить. Саме так сервер обмежує `subscriptions/listen` для окремих + викликачів: розділ **[Хто має право стежити](../handlers/subscriptions.md#deciding-who-may-watch)** + на сторінці про підписки показує це крок за кроком. +* **Переписувати.** `ctx` — це dataclass: `await call_next(dataclasses.replace(ctx, params=...))` + передає решті ланцюжка інші параметри, ніж надіслав клієнт. Ніколи не робіть цього з + `initialize`: результат, який отримає клієнт, будується з переписаних параметрів, але сервер + фіксує стан з'єднання за первинними параметрами з мережі. Обидві сторони можуть завершити + рукостискання, не погоджуючись щодо того, про що вони домовилися. +* **Відповідати.** Поверніть результат, не викликаючи `call_next(ctx)`, — і він піде клієнтові + як ваша відповідь. `call_next` віддає вам готову форму для передавання мережею, а конвеєр + ніколи не латає те, що ви повертаєте, тож уся обгортка — ваша відповідальність: на з'єднанні + покоління 2026 сюди входить і позначка `_meta` з `serverInfo`, яку SDK додає до результатів + обробників, але не до ваших. + +!!! check + `initialize` — одна з речей, які огортає middleware, і це *єдиний* гачок, який ви для нього + маєте. Спробуйте перехопити його через `add_request_handler` — і SDK відмовить: + + ```text + ValueError: 'initialize' is handled by the server runner and cannot be overridden; + use Server.middleware to observe or wrap initialization + ``` + +!!! warning + `initialize` обробляється на місці: сервер не читає наступних вхідних повідомлень, доки + ланцюжок middleware не поверне керування. Тому очікування запиту від сервера до клієнта + (`ctx.session.send_request(...)`, еліцитація (elicitation)) під час обробки `initialize` + **заблокує з'єднання намертво**: відповідь, на яку ви чекаєте, ніколи не буде прочитано. + Сповіщення за принципом «надіслав і забув» — без проблем. + +## Єдиний middleware, увімкнений за замовчуванням {#the-one-middleware-that-ships-on-by-default} + +SDK постачає рівно один шар middleware, і він уже є в списку вашого сервера: той, що створює +спан OpenTelemetry для кожного повідомлення. Його не потрібно додавати, і здебільшого про нього +не доводиться думати. Він нічого не робить, доки ви не встановите експортер, і має власну +сторінку: **[OpenTelemetry](../run/opentelemetry.md)**. + +!!! info + Якщо ви писали ASGI-middleware, ця форма вам уже знайома. `(scope, receive, send)` зі + Starlette перетворилося на `(ctx, call_next)` і виконується *після* транспорту — над + декодованим повідомленням, а не над сирим HTTP-запитом. Обидва поєднуються: middleware + Starlette на `streamable_http_app()` бачить HTTP, а цей — MCP. + +## Підсумки {#recap} + +* Middleware — це `async (ctx, call_next) -> result`; його передають як + `MCPServer(middleware=[...])` (або додають до `mcp.middleware`), а в низькорівневому `Server` + додають до `server.middleware`. +* Він огортає **кожне** вхідне повідомлення (`server/discover`, `initialize`, запити, + сповіщення, невідомі методи) і виконується від зовнішнього до внутрішнього. +* `ctx.request_id is None` — так відрізняють сповіщення від запиту. +* Викиньте виняток замість виклику `call_next`, щоб відхилити одне повідомлення; з'єднання + вціліє. +* Власне трасування OpenTelemetry у SDK — теж middleware, і воно вже в списку. Див. + **[OpenTelemetry](../run/opentelemetry.md)**. +* Уся ця поверхня попередня. Спостерігайте через неї; не будуйте на ній. + +Це все, що огортає запит. **[Авторизація](../run/authorization.md)** — те, що вирішує, чи +запит узагалі буде виконано. diff --git a/i18n/uk/pages/advanced/pagination.md b/i18n/uk/pages/advanced/pagination.md new file mode 100644 index 0000000000..01888ab4d2 --- /dev/null +++ b/i18n/uk/pages/advanced/pagination.md @@ -0,0 +1,85 @@ +--- +translation: + sections: [a9aba7a026c7bd85, ed32bda7ba9ae33a, 7e64cc5646abb91f, 22a0129ee78b3c63, d875373c06d8d2f9] + tool: 1 +--- +# Пагінація {#pagination} + +Більшості серверів вона не знадобиться ніколи. + +`MCPServer` відповідає на кожен запит `list_*` усім, що має, однією сторінкою з `next_cursor=None`. Для кількох десятків інструментів, ресурсів чи промптів це правильна відповідь, і налаштовувати нічого не потрібно. + +Пагінація — для сервера, у якого список ресурсів насправді є базою даних: тисячі рядків, які він відмовляється серіалізувати в одну відповідь. Відповідь протоколу — **курсор**: сервер повертає сторінку плюс непрозорий токен, а клієнт надсилає цей токен назад, щоб отримати наступну сторінку. + +У `@mcp.resource()` немає жодного гачка для цього. Щоб розбивати на сторінки, обробник списку пишуть власноруч, на **[низькорівневому Server](low-level-server.md)**. + +## Сервер зі сторінками {#a-server-that-pages} + +```python title="server.py" hl_lines="12 15-16" +--8<-- "docs_src/pagination/tutorial001.py" +``` + +* На низькорівневому `Server` обробники — це аргументи конструктора, а не декоратори. `on_list_resources` відповідає на кожен запит `resources/list`; оце й усе під'єднання. +* Кожен обробник зі сторінками має тип `params: PaginatedRequestParams | None`, і приклад приймає обидва варіанти. Утім, через з'єднання SDK ніколи не передає `None` (запит без члена `params` доходить до обробника як модель із типовими значеннями), тож важливий сигнал — `params.cursor is None`: **починайте з початку**. +* Ви вирішуєте, чим курсор *є*. Тут це зсув, записаний як рядок. Мітка часу, первинний ключ, base64-блоб — будь-що, що можна видати на виході й упізнати на вході. +* `next_cursor=None` — спосіб сказати «це була остання сторінка». Немає ні лічильника, ні загальної кількості, ні `has_more`. `None` — це весь сигнал. + +!!! tip + `PAGE_SIZE` у 10 робить приклад читабельним. Свій розмір обирайте для кожної кінцевої точки окремо: список + однорядкових ресурсів може дозволити собі сторінку на 500; список важких шаблонів промптів — ні. + Клієнт на це не впливає, і так задумано. + +### Спробуйте самі {#try-it} + +`Client(server)` під'єднується до низькорівневого `Server` у пам'яті так само, як і до `MCPServer`. + +Викличте `list_resources()` без аргументів. Повертається десять ресурсів, від `book-1` до `book-10`, а `next_cursor` — рядок `"10"`. + +Передайте його назад через `list_resources(cursor="10")` — і перший ресурс уже `book-11`, а новий `next_cursor` — `"20"`. + +Десята сторінка приходить із `next_cursor`, що дорівнює `None`. Готово. + +## Цикл на клієнті {#the-client-loop} + +Кожен метод `list_*` класу `Client` (`list_tools`, `list_resources`, `list_resource_templates`, `list_prompts`) приймає іменований параметр `cursor=`. Вичерпати список зі сторінками — це один `while True`: + +```python title="client.py" hl_lines="26-32" +--8<-- "docs_src/pagination/tutorial002.py" +``` + +* `cursor` починається з `None`, тому перший запит курсора не несе. +* Розширюйте список **до** того, як дивитися на `next_cursor`: на останній сторінці теж є ресурси. +* `next_cursor is None` — це вихід. Усе інше йде прямо назад у `cursor=`, без змін. + +Запустіть його `main()` — і він надрукує `100 resources`: десять сторінок по десять, зшитих циклом, який так і не дізнався, що сторінок було десять. + +Це той самий цикл, який **[Клієнт](../client/index.md)** показує для кожного дієслова `list_*`, і проти сервера без сторінок він нічого не коштує: `next_cursor` дорівнює `None` вже в першій відповіді, і цикл виконується один раз. + +## Три правила {#the-three-rules} + +**Курсори непрозорі.** Клієнт ніколи не повинен розбирати, будувати чи вгадувати курсор. Єдине законне джерело курсора — `next_cursor` попередньої сторінки, дослівно. + +**Розмір сторінки обирає сервер.** У протоколі немає `limit=`. Якщо потрібен інший розмір сторінки, змінюють сервер. + +**Клієнт, що ігнорує пагінацію, усе одно працює.** Він викликає `list_resources()` один раз, отримує перші десять і навіть не помічає `next_cursor`, який викинув. Нічого не ламається; він просто бачить менше. + +!!! check + Непрозорий означає непрозорий. Вигадайте курсор (`list_resources(cursor="page-2")`) — і + протокол нічим не зможе допомогти. Цей сервер пробує `int("page-2")`, обробник викидає виняток, + а до клієнта повертається: + + ```text + MCPError(-32603, 'Internal server error', None) + ``` + + Курсор, отриманий не від сервера, — це помилка, а не запит на нову можливість. + +## Підсумки {#recap} + +* `MCPServer` повертає все однією сторінкою. Пагінацію вмикають свідомо, і роблять це на низькорівневому `Server`. +* `on_list_resources` (а також `on_list_tools`, `on_list_prompts`, `on_list_resource_templates`) отримує `PaginatedRequestParams | None`; для першої сторінки `params.cursor` дорівнює `None`. +* Повертається сторінка плюс `next_cursor`: будь-який рядок, який ви згодом упізнаєте, або `None`, коли більше нічого не лишилося. +* Цикл на клієнті: передати `cursor=`, накопичити, повторювати, доки `next_cursor is None`. +* Курсори непрозорі, розмір сторінки належить серверу, а клієнт без пагінації все одно отримує першу сторінку. + +Решта написаного власноруч API `Server` (`on_call_tool`, словники `input_schema`, `_meta`) — на сторінці **[Низькорівневий Server](low-level-server.md)**. diff --git a/i18n/uk/pages/client/caching.md b/i18n/uk/pages/client/caching.md new file mode 100644 index 0000000000..95ba40ece2 --- /dev/null +++ b/i18n/uk/pages/client/caching.md @@ -0,0 +1,122 @@ +--- +translation: + sections: [9e7b9a1710e5aeba, b74ca4c1d2ddddee, fa8714e61bf90c5a, 04db67a886b7271c, 857690fb8f876800] + tool: 1 +--- +# Підказки щодо кешування {#caching-hints} + +Кожен результат, який сервер повертає для `tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read` і `server/discover`, у протоколі 2026-07-28 містить два поля: `ttlMs` — скільки мілісекунд клієнт може вважати результат свіжим, і `cacheScope` — чи можна ділитися закешованим результатом між користувачами (`"public"`), чи він належить одному контексту авторизації (`"private"`). + +Сервер нічого не кешує. Ці поля — *декларація*: «цей список інструментів однаковий для всіх і не зміниться протягом хвилини». Тоді клієнт (або шлюз перед вами) може пропустити звернення до сервера. Дотримуватися підказок чи ні — вибір клієнта; видавати їх — робота сервера, і SDK робить це за вас. + +За замовчуванням кожен результат каже `ttlMs: 0, cacheScope: "private"`: одразу застарілий, ніколи не спільний. Це завжди безпечно і завжди відповідає специфікації. Якщо ваші списки справді стабільні й однакові для всіх викликачів, скажіть про це під час створення: + +```python title="server.py" hl_lines="5-8" +--8<-- "docs_src/caching/tutorial001.py" +``` + +* Ключами мапи є **імена методів**, і шість кешованих методів — єдині допустимі ключі. Параметр має тип `Mapping[CacheableMethod, CacheHint]`, тож редактор доповнює ключі автоматично й позначає описку ще до запуску; усе, що проскочить повз перевірку типів, викине виняток під час створення. +* Метод, який ви не згадали, зберігає типові значення. Мапа — це набір перевизначень, а не маніфест. +* `CacheHint(ttl_ms=5_000)` залишив `scope` незаданим, тож він лишається `"private"`: п'ять секунд свіжості, окремо для кожного викликача. Область і TTL — незалежні рішення. +* `"server/discover"` — теж допустимий ключ, бо результат discovery кешується так само, як будь-який список. + +!!! warning + `cacheScope: "public"` означає, що вашу закешовану відповідь можуть віддати *будь-кому*. + Спільний шлюз охоче передасть результат одного користувача іншому, навіть якщо запит був + автентифікований. Позначайте результат як `"public"` лише тоді, коли він однаковий для кожного + викликача, і ніколи не використовуйте `cacheScope` як контроль доступу: це мітка, а не замок. + +## Перевизначення в обробнику {#per-handler-override} + +На низькорівневому `Server` обробники будують результати вручну, а `ttl_ms` / `cache_scope` — звичайні поля моделей результатів. Обробник, який задає їх явно, завжди перемагає мапу конструктора, поле за полем: + +```python title="server.py" hl_lines="10 16" +--8<-- "docs_src/caching/tutorial002.py" +``` + +Обробник сказав `ttl_ms=1_000` і нічого про область. У переданих даних: `ttlMs: 1000` (значення обробника, а не `60_000` з мапи) і `cacheScope: "public"` (з мапи, бо обробник залишив поле незаданим). Явне перемагає налаштоване, а налаштоване перемагає типове. Це діє для кожного поля окремо, тож обробник може зафіксувати одне поле, а інше залишити загальносерверній політиці. + +Це також запасний вихід для динаміки, про яку конструктор знати не може: обробник, що фільтрує `resources/read` для кожного користувача, може повернути `cache_scope="private"` для одного URI на сервері, який загалом публічний. + +Одне застереження щодо списків із пагінацією: протокол вимагає **однакового `cacheScope` на кожній сторінці** одного списку. Мапа конструктора забезпечує це за побудовою, бо її ключі — методи, а не сторінки. Але обробник, який перевизначає область сам, відповідає за цю узгодженість: перевизначайте її на *кожній* сторінці, а не лише коли є курсор, інакше перша й друга сторінки розійдуться. + +## Що бачить клієнт {#what-the-client-sees} + +У сесії 2026-07-28 `Client` дотримується підказок за вас: він має вбудований кеш відповідей, увімкнений за замовчуванням. Результат, що прийшов із `ttlMs`, зберігається, і ідентичний виклик у межах цього TTL обслуговується з кешу без звернення до сервера. Результат *без* підказки не кешується: результати без підказок отримують `CacheConfig.default_ttl_ms`, типове значення якого `0` (одразу застарілий), тож сервер, який нічого не оголошує, бачить рівно той самий трафік виклик-за-викликом, що й завжди. + +```python title="client.py" hl_lines="33 35 38" +--8<-- "docs_src/caching/tutorial003.py" +``` + +Чотири виклики, три отримання. Другий виклик знайшов свіжий запис і не дійшов до сервера; переведення (впровадженого) годинника за межу TTL змусило третій знову звернутися до сервера; четвертий сказав `cache_mode="refresh"`. Цей іменований аргумент є в п'яти методів із кешуванням (`list_tools`, `list_prompts`, `list_resources`, `list_resource_templates`, `read_resource`): + +* `"use"` (за замовчуванням) віддає свіжий запис, якщо він є, а якщо ні — звертається до сервера і зберігає результат. +* `"refresh"` ніколи не віддає з кешу: звертається до сервера і зберігає результат, замінюючи те, що було закешовано. +* `"bypass"` звертається до сервера, взагалі не торкаючись кешу: ні читання, ні запису. + +Одне правило стоїть над `"use"`: **виклики з `meta` завжди доходять до сервера.** Запит із заданим `meta` (токен перебігу виконання, поля трасування) очікує запиту мережею, тому за `cache_mode="use"` його обробляють як `"refresh"`: читання з кешу пропускається, а отриманий результат усе одно замінює закешований запис. `"bypass"` і явний `"refresh"` поводяться як завжди. + +Щоб повністю вимкнути кешування, створіть клієнт як `Client(server, cache=None)`: кожен виклик знову стає зверненням до сервера, а `cache_mode`, хоч і приймається далі, нічого не робить. + +Область також дотримується автоматично: записи `"private"` прив'язані до *розділу* (partition) кешу (нижче), тоді як записи `"public"` можуть за бажанням ділитися ширше. А **сповіщення перемагають TTL** для конкретних записів, які вони називають: сповіщення `list_changed` витісняє відповідний закешований список, а `resources/updated` витісняє закешоване читання, збережене рівно під його URI, хоч би якими свіжими вони були. На з'єднанні 2026-07-28 ці сповіщення надходять потоком `subscriptions/listen`, який відкривають через `client.listen(...)`, і витіснення завершується до того, як ваш спостерігач побачить подію; докладніше — на сторінці **[Підписки](subscriptions.md)**. + +Одне застереження щодо `resources/updated`: витіснення працює лише за точним URI. Контракт сховища не має операції перелічення чи сканування (так само, як еталонна реалізація на TypeScript), тож сповіщення з URI *під*ресурсу не витісняє закешоване читання його батьківського ресурсу. Якщо ваш сервер сигналізує про підресурси таким чином, перечитайте батьківський ресурс із `cache_mode="refresh"`. + +### Налаштування: `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`: де живуть записи. За замовчуванням — свіже сховище в пам'яті для кожного клієнта; передайте власну реалізацію `ResponseCacheStore` (скажімо, на Redis), щоб ділити кеш між клієнтами або процесами. Типи контракту (`ResponseCacheStore`, `CacheKey`, `CacheEntry` і типовий `InMemoryResponseCacheStore`) імпортуються з `mcp.client`. Один пошук може виконати до двох послідовних `get` у сховищі (приватна гілка, потім публічна), тож розраховуйте очікувану затримку віддаленого сховища відповідно. Власне сховище **вимагає** явного `partition`. +* `partition`: мітка контексту авторизації, яка не дає віддати записи `"private"` одного принципала іншому в межах спільного сховища. +* `target_id`: явна ідентичність сервера, для власних транспортів і серверів у тому самому процесі (нижче). +* `default_ttl_ms`: TTL, що застосовується до результатів без підказки `ttlMs`. Типове `0` залишає результати без підказок незакешованими. +* `share_public`: віддавати записи, які сервер оголосив `"public"`, між розділами (нижче). За замовчуванням вимкнено. +* `clock`: джерело реального часу, у секундах епохи. Впровадьте його, як у прикладі вище, і тестам на закінчення терміну не доведеться засинати. + +!!! warning "Розділ = перевірений принципал" + Виводьте `partition` з **перевіреного облікового посвідчення**, наприклад із суб'єкта провалідованого токена. Ніколи не виводьте його з даних, наданих у запиті, і ніколи з URL сервера (ідентичність сервера — окрема вісь ключа). SDK — це бібліотека без власної автентифікації: якорем довіри є той, хто створює `CacheConfig`, тобто розгортання, а не орендар. Багатоорендний шлюз створює по одному `CacheConfig` на кожного автентифікованого принципала. + + Розділ також фіксований на весь час життя `Client`. Якщо контекст авторизації з'єднання змінюється посеред сесії (скажімо, повторна автентифікація як інший принципал), кеш за цим не стежить; створіть новий `Client` для нового принципала. + +Ключі кешу також містять **ідентичність сервера**: рядок URL, до якого ви під'єдналися, з вилученим userinfo `user:pass@` і в усьому іншому побайтово точний. Жодного зведення регістру, жодного переставляння параметрів запиту, жодного прибирання кінцевої косої риски. Недостатня нормалізація коштує лише втраченого спільного використання, тоді як надмірна могла б злити двох орендарів (`?tenant=a` проти `?tenant=b`), тому поверхово різні URL просто не ділять записи. Коли URL немає (сервер у тому самому процесі або екземпляр `Transport`), клієнт натомість отримує випадкову ідентичність для кожного екземпляра; задайте `CacheConfig.target_id`, щоб назвати сервер (із власним сховищем це обов'язково, і створення про це скаже). Ідентичність хешується sha256 перед тим, як потрапити до матеріалу ключа, тож URL із секретами в рядку запиту ніколи не з'являється в ключах сховища. І самі не логуйте дохешовану форму. + +!!! warning "`share_public` довіряє серверу для всього парку" + За замовчуванням навіть записи `"public"` лишаються в межах свого розділу. `share_public=True` віддає записи, які сервер позначив `cacheScope: "public"`, **кожному** розділу, що користується сховищем, довіряючи класифікації сервера від імені їх усіх. Сервер, який ставить `"public"` на дані окремого орендаря (через помилку чи зі злого наміру), тоді витікає відповіддю одного орендаря до інших. Прапорець навмисно існує лише на рівні конструктора: `cache_mode` для окремого виклику може звузити кешування, але ніщо на рівні виклику не може розширити спільний доступ. + +### Чого кеш ніколи не робить {#what-the-cache-never-does} + +* **Виклики рівня сесії його оминають.** `client.session.list_tools()` і подібні завжди звертаються до сервера; кеш живе на методах `Client`. +* **`server/discover` тримається осторонь.** Результат discover доставляється один раз, під час під'єднання, і ніколи не потрапляє до кешу відповідей, навіть якщо містить `ttlMs`. Якщо ви зберігаєте його самі, щоб пропустити пробу під час повторного під'єднання ([`prior_discover`](../protocol-versions.md#reconnecting-with-prior_discover)), його свіжість — ваш облік: `DiscoverResult` містить `ttl_ms` і `cache_scope`, уже розібрані, саме для цього. +* **Сторінки продовження ніколи не кешуються.** Участь беруть лише виклики без курсора. Сторінка продовження, відхилена через протермінований курсор, таки *витісняє* закешований список, бо список під нею змінився. +* **Багатораундові читання (multi-round-trip) ніколи не кешуються.** `read_resource`, засіяний `input_responses`/`request_state`, або такий, що розв'язується через раунди введення, ніколи не потрапляє до кешу (MUST специфікації). +* **Витіснення за сповіщеннями потребує сповіщень.** Витіснення працює рівно настільки добре, наскільки транспорт їх доставляє, а сучасний шлях у тому самому процесі (`Client(server)` з типовим `mode="auto"`) сьогодні не доставляє окремих сповіщень. +* **Витіснення відбувається зрештою, а не миттєво.** Сповіщення мережевого шляху розсилаються з породжених задач, тож виклик, що змагається з надходженням сповіщення, може ще раз отримати запис до витіснення; вікно обмежене затримкою розсилання, і витіснення все одно відбувається. +* **Жодного stale-if-error.** Протермінований запис ніколи не віддається через те, що повторне отримання не вдалося; помилка поширюється далі. +* **Жодного раннього повторного отримання.** Збережений запис віддається, доки не спливе його TTL, а наступний після цього виклик платить зверненням до сервера; нічого не оновлюється у фоновому режимі. +* **Жодного об'єднання.** Два одночасні ідентичні виклики — це два отримання. +* **Жодного TTL понад 24 години.** Більший `ttlMs`, надісланий сервером чи налаштований, обрізається під час збереження (`mcp.client.caching.MAX_TTL_MS`), обмежуючи, як довго можна віддавати будь-який запис, хоч би якою щедрою була підказка. +* У **спільному сховищі** клієнти змагаються між собою. Кожен клієнт відкидає власний запис, коли витіснення випередило отримання в польоті, але клієнт-*співорендар* усе ще може записати назад запис, який прибрало витіснення, якого він не бачив; і сам облік цих перегонів обмежений: понад 4096 відстежуваних ключів захист найстарішого ключа відкидається першим. Обидва вікна прийнятні й закриваються обмеженням TTL вище. +* **Жодного обслуговування між поколіннями протоколу.** Записи прив'язані до узгодженої версії протоколу: у спільному постійному сховищі сесія ніколи не віддає запис, записаний за іншої узгодженої версії (той самий список справді відрізняється залежно від покоління, бо SDK прибирає поля 2026 для старіших сесій). Витіснення так само торкається лише записів поточного покоління; записи іншого покоління просто старіють за TTL. + +### Читання підказок самостійно {#reading-the-hints-yourself} + +Підказки — це також звичайні поля на кожному кешованому результаті (`result.ttl_ms` і `result.cache_scope`, уже розібрані), на випадок якщо ви хочете накласти власний облік поверх вбудованого кешу (або замість нього). + +Зі **старішим сервером** (протокол до 2026) полів у переданих даних просто немає, і моделі показують свої обережні типові значення: `ttl_ms == 0` і `cache_scope == "private"`, застарілий і неспільний — правильне припущення для сервера, який нічого не оголосив. Кеш ставиться до сесії старого покоління так само: підказки там ніколи не враховуються (хоч би які ключі з'явилися в переданих даних), застосовується лише `default_ttl_ms`, а його типове `0` нічого не кешує, тож з'єднання до 2026 поводиться рівно так, як до появи кешу. Якщо потрібно відрізнити «сервер сказав 0» від «сервер нічого не сказав», перевірте `"ttl_ms" in result.model_fields_set`: воно задане лише тоді, коли поле справді прийшло. + +## Старіші клієнти {#older-clients} + +Клієнти на версіях протоколу до 2026 ніколи не бачать жодного з двох полів; SDK прибирає їх під час серіалізації для таких з'єднань. Налаштуйте підказки один раз; нічого специфічного для версії писати не потрібно. + +## Підсумки {#recap} + +* Шість методів містять `ttlMs`/`cacheScope`; SDK за замовчуванням ставить `0`/`"private"` — застарілий і неспільний, завжди безпечно. +* `cache_hints={method: CacheHint(...)}` під час створення (і `MCPServer`, і `Server`) задає загальносерверні значення для кожного методу. +* Обробник, що задає поля на своєму результаті, перевизначає мапу, поле за полем. +* `"public"` — це обіцянка, що результат однаковий для кожного викликача. Це не контроль доступу. +* `Client` дотримується підказок автоматично: його кеш відповідей увімкнений за замовчуванням, віддає свіжі записи замість повторного отримання і нічого не кешує для серверів (або сесій), які не надають підказок. +* Для окремого виклику `cache_mode="refresh"` отримує заново, а `"bypass"` оминає кеш; `cache=None` під час створення вимикає його повністю. diff --git a/i18n/uk/pages/client/callbacks.md b/i18n/uk/pages/client/callbacks.md new file mode 100644 index 0000000000..73dc532851 --- /dev/null +++ b/i18n/uk/pages/client/callbacks.md @@ -0,0 +1,154 @@ +--- +translation: + sections: [adf3c545b5be46b6, 916cd3ab1c03f461, e9be7a8d0eb0a456, 565890a636288ecf, 6af7e49db9129ec3, 06b0238c174186af, 90c6043be435fcb0] + tool: 1 +--- +# Колбеки клієнта {#client-callbacks} + +Майже кожен запит у MCP іде в один бік: від клієнта до сервера. + +Сервер теж може дещо попросити в **клієнта**: поставити запитання користувачеві, скористатися моделлю користувача для семплювання (sampling), отримати список папок його робочого простору. На ці запити відповідають **колбеки**, які передаються в `Client(...)`. + +## Сервер, який запитує {#a-server-that-asks} + +Ось сервер, інструмент якого не може завершитися самотужки: + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/client_callbacks/tutorial001.py" +``` + +* `ctx.elicit(...)` надсилає запит `elicitation/create` **клієнтові** й чекає. +* Інструмент не повертає результат, доки хтось (людина у формі або ваш код) не надасть `name`. + +Це серверна половина, і вона належить сторінці **[Еліцитація](../handlers/elicitation.md)**. Ця сторінка — про інший кінець з'єднання. + +## Колбек еліцитації {#the-elicitation-callback} + +```python title="client.py" hl_lines="6-10 16-17" +--8<-- "docs_src/client_callbacks/tutorial002.py" +``` + +* Колбек еліцитації (elicitation) — це `async (context, params) -> ElicitResult`. +* `params.message` — це запитання. `params.requested_schema` — JSON Schema відповіді, яку хоче отримати сервер. Справжній клієнт будує з неї форму; цей заповнює її автоматично. +* Повертається `ElicitResult(action="accept", content={...})`, або `action="decline"`, або `action="cancel"`. Єдиний інший варіант — `ErrorData(...)`: він відхиляє запит, і весь виклик завершується помилкою. +* `context` — це `ClientRequestContext`: активна `session`, `request_id` сервера та будь-які `meta`, які він додав. + +!!! tip + `params` — об'єднання двох режимів еліцитації. Тут `params.mode` дорівнює `"form"`; запит `"url"` + замість схеми несе `params.url`. Обидва обробляє один колбек; розгалужуйтеся за `params.mode`. + Повний шаблон показано на сторінці **[Еліцитація](../handlers/elicitation.md)**. + +### Спробуйте самі {#try-it} + +Викличте `issue_card` і простежте за обома кінцями. + +Колбек отримує запитання сервера, уже розібране: + +```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'} +``` + +Він відповідає, `ctx.elicit(...)` усередині інструмента відновлює роботу, й інструмент завершується: + +```python +result.content # [TextContent(type='text', text='Card issued to Ada Lovelace.')] +``` + +Один `tools/call` від вас, один `elicitation/create` у відповідь від сервера, на який відповіла ваша функція, — і все це всередині одного виклику інструмента. + +!!! info + `mode="legacy"` у виклику `Client(...)` стоїть не просто так. За замовчуванням `Client(...)` узгоджує сучасний + шлях протоколу, а на ньому немає зворотного каналу (back-channel) для запитів від сервера до клієнта: `ctx.elicit` + завершується помилкою ще до того, як запуститься колбек. Вирішує це не транспорт, а узгоджений + протокол — однаково і в пам'яті, і за URL. Фіксуйте `mode="legacy"` щоразу, коли клієнт має + відповідати на такий запит; так робить кожен тест за цією сторінкою. Докладніше — на сторінці **[Версії протоколу](../protocol-versions.md)**. + + У сесії 2026-07-28 колбек не зникає, він просто отримує дані інакше: коли інструмент повертає + `InputRequiredResult` з `ElicitRequest` усередині, `Client` передає цей запис тому самому + `elicitation_callback` і повторює виклик за вас. Цей сценарій описано на сторінці **[Багатораундові запити](../handlers/multi-round-trip.md)** (multi-round-trip). + +## Колбек — це можливість {#a-callback-is-a-capability} + +Ви ніде не повідомляли серверу, що ваш клієнт уміє відповідати на запити еліцитації. Це зробив SDK. + +Під'єднуючись, клієнт оголошує свої `capabilities` — дзеркальне відображення серверних. Цей об'єкт ви не пишете. **Реєстрація колбека і є оголошенням.** + +| що передається | що оголошує клієнт | +| --- | --- | +| `elicitation_callback=` | `"elicitation": {"form": {}, "url": {}}` | +| `sampling_callback=` | `"sampling": {}` | +| `list_roots_callback=` | `"roots": {"listChanged": true}` | +| жодного з них | `{}` | + +Єдине уточнення — підможливості семплювання: передавайте `sampling_capabilities=SamplingCapability(tools=SamplingToolsCapability())` разом із `sampling_callback`, якщо ваш семплер обробляє параметри `tools` / `tool_choice`. Сервери мають побачити оголошену `sampling.tools`, перш ніж надсилати їх. + +`logging_callback` і `message_handler` у таблиці немає. Вони обробляють сповіщення, а сповіщенням можливість не потрібна. + +Сервер зчитує оголошення методом `ctx.session.check_client_capability(...)`. Додайте інструмент, який це робить: + +```python title="server.py" hl_lines="23-31" +--8<-- "docs_src/client_callbacks/tutorial003.py" +``` + +Під'єднайтеся лише з `elicitation_callback` і викличте його: + +```python +result.structured_content # {'result': ['elicitation']} +``` + +Передайте всі три колбеки — отримаєте `['elicitation', 'sampling', 'roots']`. Не передайте жодного — отримаєте `[]`. + +!!! check + Тепер зробіть неправильно: під'єднайтеся **без** `elicitation_callback` і все одно викличте `issue_card`. + + Запит сервера `elicitation/create` все одно доходить до клієнта, і SDK відповідає на нього за + вас — помилкою, бо ви ніде не сказали, що можете його обробити. Ця помилка топить весь виклик. + `call_tool` не повертає результат із `is_error`; він викидає виняток: + + ```text + MCPError: Elicitation not supported + ``` + + Це помилка протоколу (`-32600`, *invalid request*), а не помилка інструмента: моделі тут нічого + прочитати й повторити. Саме тому `client_features` варто мати: чемний сервер + перевіряє, перш ніж питати. + +## Застаріла пара {#the-deprecated-pair} + +`sampling_callback` відповідає на `sampling/createMessage`: сервер просить *вашу* модель щось доповнити. `list_roots_callback` відповідає на `roots/list`: сервер питає, у яких каталогах йому можна працювати. + +Обидва працюють. Обидва дотримуються правила вище. І обидва обслуговують RPC, які **специфікація 2026-07-28 вилучає**: сучасний сервер не звертається до клієнта посеред запиту, а повертає запит вам як частину результату інструмента (**[Багатораундові запити](../handlers/multi-round-trip.md)**). Самі колбеки нікуди не зникають. Коли `InputRequiredResult` несе `CreateMessageRequest` або `ListRootsRequest`, автоматичний цикл `Client` передає його тому самому `sampling_callback` чи `list_roots_callback`, який ви зареєстрували тут. Повний список — на сторінці **[Застарілі можливості](../deprecated.md)**. + +Колбеки досі потрібні, щоб спілкуватися із серверами, які ще не перейшли. Сигнатури: + +```python title="client.py" +--8<-- "docs_src/client_callbacks/tutorial004.py" +``` + +* Колбек семплювання отримує повний `CreateMessageRequestParams` (`messages`, `model_preferences`, `max_tokens`) і повертає `CreateMessageResult`. Модель запускаєте *ви*, як завгодно; SDK лише переносить запит. +* Колбек кореневих каталогів (roots) не приймає жодних параметрів і повертає `ListRootsResult`. +* Кожен із них натомість може повернути `ErrorData(...)`, щоб відмовити. + +Передавайте їх у `Client(...)` так само, як `elicitation_callback`. + +## Колбеки сповіщень {#the-notification-callbacks} + +Ще два. Жоден нічого не оголошує. + +`logging_callback` отримує `notifications/message`, які надсилає сервер, у вигляді `LoggingMessageNotificationParams` (`level`, `logger`, `data`). Протокольне логування саме оголошене застарілим у специфікації 2026-07-28 (що робити натомість — на сторінці **[Логування](../handlers/logging.md)**), тож цей колбек існує для серверів, які досі його надсилають. На з'єднанні покоління 2026 самого колбека недостатньо, бо сервери 2026 надсилають лог-повідомлення лише у відповідь на запити, які на це погодилися: передайте `log_level="info"` (або інший рівень) у `Client(...)`, щоб проставляти цю згоду в кожному запиті й отримувати повідомлення цього рівня та вище. Сервери до 2026 ігнорують її й зберігають свою поведінку `logging/setLevel`. + +`message_handler` — універсальний приймач: до нього доходить кожне сповіщення сервера, яке сесія передає назовні (на додачу до свого спеціального колбека), а на транспорті на основі потоку — ще й кожен `Exception` транспортного рівня. Два ніколи не доходять: `notifications/cancelled` SDK застосовує сам, а не передає назовні, а підтвердження підписки для активного потоку `listen()` споживає сам цей потік. Анотуйте параметр типом `IncomingMessage` (`ServerNotification | Exception`, експортується з `mcp.client`). Єдиний шаблон, який варто знати, — `if isinstance(message, Exception): raise message`, щоб розірване з'єднання падало гучно, а не зникало безслідно. + +## Підсумки {#recap} + +* Сервер може надсилати запити клієнтові. Відповідають на них колбеки, передані в `Client(...)`. +* Колбек еліцитації — актуальний: `async (context, params) -> ElicitResult`, одна функція і для режиму форми, і для режиму URL. +* **Реєстрація колбека — це оголошення можливості.** Без нього SDK відхиляє запит сервера від вашого імені, і весь виклик завершується з `MCPError`. +* Сервер дізнається про це ще до запиту за допомогою `ctx.session.check_client_capability(...)`. +* `sampling_callback` і `list_roots_callback` працюють так само, але обслуговують застарілі можливості; сучасні сервери натомість використовують багатораундові запити. +* `logging_callback` і `message_handler` отримують сповіщення. Вони нічого не оголошують. + +Перший аргумент `Client(...)` — об'єкт транспорту. Усі його різновиди описано на сторінці **[Транспорти клієнта](transports.md)**. diff --git a/i18n/uk/pages/client/identity-assertion.md b/i18n/uk/pages/client/identity-assertion.md new file mode 100644 index 0000000000..1820d1e610 --- /dev/null +++ b/i18n/uk/pages/client/identity-assertion.md @@ -0,0 +1,152 @@ +--- +translation: + sections: [a91322c46111d16d, 8e6fd6d6f59bb568, e7828fd2729b2c9d, a03ec26bfc678b65, 1034c653c0bcf1b0] + tool: 1 +--- +# Твердження про ідентичність {#identity-assertion} + +Звичайний OAuth-провайдер (**[OAuth-клієнти](oauth-clients.md)**) починає з запитання до MCP-сервера: *якому серверу авторизації ти довіряєш?* Він іде за відповіддю, куди б вона не вказувала, а далі або людина входить у систему, або замість неї це робить заздалегідь узгоджений секрет. + +Підприємство не хоче, щоб ані те, ані інше вирішувалося для кожного сервера окремо. У нього вже працює провайдер ідентичності (Okta, Microsoft Entra ID, власний); користувач уже ввійшов у нього сьогодні вранці; і саме там команда безпеки хоче вирішувати, хто й до чого має доступ. [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990), розширення **Enterprise-Managed Authorization**, переносить це рішення туди. IdP підписує короткоживучий JWT — **Identity Assertion JWT Authorization Grant**, або **ID-JAG**: твердження, що *цей користувач* через *цей клієнт* може звертатися до *цього MCP-сервера*. Клієнт обмінює його на звичайний токен доступу. Жодного браузера, жодного екрана згоди, жодної динамічної реєстрації. + +Ця сторінка описує обидва боки цього обміну. Сам MCP-сервер не змінюється взагалі: це й далі сервер ресурсів зі сторінки **[Авторизація](../run/authorization.md)**, який перевіряє будь-який токен, що надходить. + +## Два запити токена {#two-token-requests} + +Тут діють дві різні інстанції, і навчитися розрізняти їх за назвою — це вже більша частина розуміння цієї сторінки. **Корпоративний IdP** — це провайдер ідентичності вашої організації: він знає, хто цей працівник, у ньому живе політика, і він видає ID-JAG. SDK ніколи з ним не спілкується. **Сервер авторизації MCP** — та сама сторона, що й на сторінці **[Авторизація](../run/authorization.md)**: емітент, названий у метаданих MCP-сервера, те, що випускає токени, які цей MCP-сервер приймає. У звичайному OAuth-потоці ці дві ролі зазвичай поєднані в одному вузлі. Тут їх два, і весь грант зводиться до того, що другий погоджується довіряти першому. + +Клієнт робить по одному запиту токена до кожного. + +1. **До корпоративного IdP.** Клієнт обмінює вхід користувача (його ID-токен OpenID Connect) на ID-JAG. Це обмін токенів за [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693), це цілком API вашого IdP, і **SDK його не виконує**. Це робите ви — всередині одного асинхронного колбека. Саме тут також ухвалюється рішення політики: IdP, який каже «ні», ніколи не видає ID-JAG, і пред'являти просто нічого. +2. **До сервера авторизації MCP.** Клієнт пред'являє ID-JAG за грантом `jwt-bearer` з [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) (`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`, ID-JAG як `assertion`) й отримує токен доступу. **Саме цей запит робить SDK**, і приймати його — єдине, що ця сторінка додає до сервера авторизації. + +Усе нижче стосується другого запиту: клієнта, який його надсилає, і сервера авторизації, який на нього відповідає. + +## Клієнт {#the-client} + +**`IdentityAssertionOAuthProvider`** живе в `mcp.client.auth.extensions.identity_assertion`. Як і кожен провайдер зі сторінки **[OAuth-клієнти](oauth-clients.md)**, це `httpx2.Auth`: створіть його, передайте в `auth=`, віддайте `httpx2.AsyncClient` транспорту. + +```python title="client.py" hl_lines="49-50 53-61" +--8<-- "docs_src/identity_assertion/tutorial001.py" +``` + +Читайте знизу вгору. + +* `main()` — стандартний `main()` OAuth-клієнта (**[OAuth-клієнти](oauth-clients.md)**), без жодних змін, рядок у рядок. У цьому й суть: щойно провайдер існує, нічого далі за течією не знає, який грант породив токен. +* Провайдер приймає те, чого інші провайдери не можуть виявити самі: `client_id` і `client_secret`, які хтось **заздалегідь зареєстрував** на сервері авторизації, `issuer` цього сервера авторизації та `assertion_provider` — асинхронний колбек, що повертає свіжий ID-JAG на вимогу. +* `storage` — той самий протокол `TokenStorage`. Викликаються лише два методи для токенів; динамічної реєстрації тут немає, тож немає й `client_info`, який треба пам'ятати. + +### Провайдер твердження {#the-assertion-provider} + +`fetch_id_jag(audience, resource)` — єдиний код, який ви пишете. Його очікують (await) один раз на кожен обмін токенів, ніколи під час створення провайдера, і лише *після* того, як метадані сервера авторизації отримано й перевірено, тож неправильно налаштований емітент ніколи не призведе до витоку твердження. Два його аргументи — це два з полів (claims), з якими має бути випущено ID-JAG: `audience` — це емітент сервера авторизації (поле `aud` в ID-JAG), а `resource` — канонічний ідентифікатор MCP-сервера (поле `resource` в ID-JAG). Третє ви вже маєте: поле `client_id` в ID-JAG має називати той `client_id`, який передано провайдеру, інакше сервер авторизації відмовить в обміні. + +`idp_issue_id_jag` над нею — **не ваш код**. Вона заміняє провайдера ідентичності, підписуючи твердження в тому самому процесі, щоб файл був повним і можна було прочитати кожне поле, яке несе ID-JAG. Справжня `fetch_id_jag` натомість робить перший запит токена з попереднього розділу: обмін токенів за [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) з вашим IdP, визначений чернеткою Identity Assertion JWT Authorization Grant, яку профілює [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990). ID-токен користувача, що ввійшов, передається як `subject_token`, `requested_token_type` — власний URN ID-JAG (`urn:ietf:params:oauth:token-type:id-jag`), `audience` і `resource` проходять без змін, а відповідь містить ID-JAG. Саме цей обмін, під цими назвами, і варто шукати в документації свого IdP. + +!!! tip + Свіжий ID-JAG запитується для кожного обміну, і в цьому суть: це одноразовий грант, що живе + лічені хвилини, і сервер авторизації на цій сторінці відмовляється приймати той самий двічі. + Не кешуйте його. Повторно використовується токен доступу, який за нього отримано. + +### Емітент як налаштування {#the-issuer-is-configuration} + +Ось де інверсія. `OAuthClientProvider` питає сервер ресурсів, який сервер авторизації використовувати, і йде за відповіддю, куди б вона не вказувала. Цей провайдер відмовляється так робити: `issuer` обов'язковий, метадані за [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) отримуються з власного well-known-шляху цього емітента, кінцева точка токена має належати до origin цього емітента, а сервер ресурсів ніколи ні про що не питають. + +Розширення цього не вимагає; це свідомо суворіший вибір. Цей клієнт несе дві речі, які варто вкрасти: заздалегідь зареєстрований секрет і твердження, прив'язане до аудиторії, — і клієнт, який дозволив би скомпрометованому MCP-серверу скерувати себе на сервер авторизації зловмисника, надіслав би туди обидві. Фіксація емітента під час створення прибирає цю розмову взагалі. + +!!! warning + Налаштований `issuer` порівнюється з полем `issuer` документа метаданих простим порівнянням + рядків за RFC 8414 §3.3: символ за символом, разом із кінцевою скісною рискою, без + нормалізації. Не вгадуйте його. Отримайте `/.well-known/oauth-authorization-server` зі свого + сервера авторизації й скопіюйте значення `issuer`, яке він повертає. Для сервера авторизації + на цій сторінці це `https://auth.example.com/`, зі скісною рискою, бо його емітент побудовано + з URL-об'єкта pydantic. Розбіжність зупиняє потік на `OAuthFlowError: Authorization server metadata issuer + mismatch` ще до того, як буде надіслано бодай одні облікові дані чи твердження. + +### Конфіденційний клієнт {#a-confidential-client} + +`client_secret` обов'язковий; без нього конструктор викидає `ValueError`. Профіль IETF, на якому ґрунтується [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990), відводить цей грант лише конфіденційним клієнтам, SEP-990 вимагає, щоб клієнт автентифікувався, а цей SDK забезпечує і те, і те, наполягаючи на спільному секреті. `token_endpoint_auth_method` визначає, де він передається: `client_secret_post` (за замовчуванням, у тілі форми) або `client_secret_basic` (заголовок HTTP Basic). Профіль також дозволяє `private_key_jwt`; цей провайдер його не підтримує. + +!!! tip + Читайте `client_secret` із середовища або менеджера секретів, ніколи — із системи керування версіями. + +### Що провайдер робить за вас {#what-the-provider-does-for-you} + +Перший запит іде без автентифікації, і відповідь сервера `401` запускає потік. + +1. **Виявлення.** Він отримує метадані сервера авторизації з well-known-шляху за [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) налаштованого емітента, перевіряє, що `issuer` документа збігається, і перевіряє, що кінцева точка токена належить до origin емітента. +2. **Твердження.** Він очікує (await) ваш `assertion_provider`. +3. **Обмін.** Він надсилає грант `jwt-bearer` методом POST на кінцеву точку токена, зберігає `OAuthToken` і повторює ваш початковий запит із `Authorization: Bearer ...`. + +`403`, чий `WWW-Authenticate` називає `insufficient_scope`, знову виконує кроки 2 і 3 з об'єднанням вашого `scope` і того, що вимагається у виклику. (`scope` — це завжди лише прохання; сервер авторизації цієї сторінки надає те, що сказано в ID-JAG, і нічого більше.) Токена оновлення тут немає ніде: коли токен доступу спливає, наступний `401` випускає свіжий ID-JAG і обмінює знову, і *саме це* — важіль, який тримає IdP. Збої — ті самі два винятки, що й на решті сторінки **[OAuth-клієнти](oauth-clients.md)**: `OAuthFlowError` для виявлення й перевірки, його підклас `OAuthTokenError`, коли кінцева точка токена каже «ні». + +## Сервер авторизації {#the-authorization-server} + +Здебільшого на цьому можна зупинитися. Сервер авторизації MCP — це чийсь чужий продукт, приймання ID-JAG — налаштування, яке вмикається в ньому, а частина [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990), що належить SDK, — це клієнт вище. + +SDK також може *бути* сервером авторизації: `create_auth_routes` повертає маршрути сервера авторизації списком, який може змонтувати будь-який Starlette-застосунок, — саме так `examples/servers/simple-auth/` у репозиторії його запускає. SEP-990 додає до цієї поверхні один прапорець і один метод: + +```python title="auth_server.py" hl_lines="48-50 105-107" +--8<-- "docs_src/identity_assertion/tutorial002.py" +``` + +* `identity_assertion_enabled=True` керує всім. Якщо вимкнено (а це за замовчуванням), `/token` відповідає на цей грант `unsupported_grant_type`, навіть якщо хук реалізовано, а метадані про нього не згадують. Якщо ввімкнено, метадані отримують тип гранту `jwt-bearer` і вказують `urn:ietf:params:oauth:grant-profile:id-jag` у `authorization_grant_profiles_supported` — полі, яким розширення оголошує підтримку. (Клієнт цього SDK його ніколи не читає: він налаштований на одного емітента й просто запитує.) +* **`exchange_identity_assertion`** — це хук. До його запуску SDK вже автентифікував клієнта, відмовив публічним клієнтам і відмовив клієнтам, чия реєстрація не містить цього гранту. Повертається `IdentityAssertionParams` (сире `assertion`, запитані `scopes` і `resource`), а ви повертаєте звичайний `OAuthToken`. +* Динамічна реєстрація клієнтів відхиляє цей грант безумовно, тож `get_client` тут обслуговує клієнта, заведеного вручну. ID-JAG-клієнт не може зареєструвати сам себе з нічого. +* Половина класу — відмови. `OAuthAuthorizationServerProvider` — це *весь* сервер авторизації, тож він вимагає й потоку з кодом авторизації; сервер, який також виконує вхід користувачів, реалізує його по-справжньому, а в цього рівно одні двері. + +!!! warning + SDK ніколи не декодує твердження: лише ваше розгортання знає, якому IdP воно довіряє і які + ключі той IdP публікує, тож усе всередині `exchange_identity_assertion` критично важливе. + Перевірте підпис за опублікованими ключами IdP (його JWKS; спільний секрет тут — лише для + демонстрації), а також `iss` і `exp` згідно з [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) §3. Вимагайте, щоб `typ` у заголовку + JWT дорівнював `oauth-id-jag+jwt` — це запобіжник профілю проти повторного пред'явлення + якогось іншого JWT як гранту. Вимагайте, щоб `aud` був вашим власним емітентом. Вимагайте, + щоб поле `client_id` в ID-JAG дорівнювало клієнту, якого автентифікував обробник, а його поле + `resource` називало ресурс, який ви справді обслуговуєте. Відстежуйте `jti` до `exp` + твердження, щоб воно приймалося лише раз. І беріть надані області доступу та, насамперед, + `resource` випущеного токена з перевіреного ID-JAG, а не із запиту: `params.resource` — це те, + що набрав клієнт. Повні правила обробки — у + [специфікації Enterprise-Managed Authorization](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization). + +Відхиляйте погане твердження через `TokenError("invalid_grant", ...)`. Інший код помилки в цьому потоці — `invalid_target`: ним відхиляється ID-JAG, що називає ресурс, якого ви не обслуговуєте, — саме це не дає цьому серверу випускати токени для чужого. А надані області доступу беруться з поля `scope` в ID-JAG (твердження без нього теж відхиляється); у вашому випадку вони можуть натомість відображати групи користувача. + +І зверніть увагу, чого повернений `OAuthToken` не містить: токена оновлення. IdP вирішує, як довго цей користувач зберігає доступ, вирішуючи, чи видавати наступний ID-JAG. Токен оновлення, випущений тут, тихо повернув би це рішення назад. + +!!! info + Сервер, який досі вбудовує свій сервер авторизації через `auth_server_provider=`, потрапляє до + того самого коду через `AuthSettings(identity_assertion_enabled=True)`. На сторінці + **[Авторизація](../run/authorization.md)** пояснено, чому новим серверам не варто з цього починати. + +!!! check + З'єднайте два файли цієї сторінки — і весь грант зведеться до одного `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"} + ``` + + Жодного `/authorize`, жодного `/register`, жодного отримання protected-resource-metadata. + Єдині запити в мережі — той, що отримав `401`, запит well-known, цей обмін, а далі звичайний + MCP-трафік із прикріпленим bearer-токеном. А `sub`, який ваш валідатор зчитав з ID-JAG, — + саме те, що `get_access_token().subject` показує всередині інструмента. + +### Спробуйте самі {#try-it} + +`examples/stories/identity_assertion/` у репозиторії SDK — це ця сторінка в дії: той самий валідатор `exchange_identity_assertion`, MCP-сервер, захищений його токенами, IdP-замінник і клієнт — в одній програмі, що перевіряє сама себе. `uv run python -m stories.identity_assertion.client --http` виконує весь обмін і перевіряє (assert), що користувач, якого назвав IdP, — це користувач, якого бачить інструмент. + +## Підсумки {#recap} + +* [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) дає змогу корпоративному провайдеру ідентичності, а не кінцевому користувачу, вирішувати, до яких MCP-серверів клієнт може звертатися. IdP підписує це рішення у вигляді **ID-JAG**. +* Отримання ID-JAG — це обмін токенів за [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) з *вашим IdP*, і SDK його не виконує. Пред'явлення його серверу авторизації MCP — це грант `jwt-bearer` за [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523), і SDK реалізує обидва його боки. +* `IdentityAssertionOAuthProvider` — ще один `httpx2.Auth`: заздалегідь зареєстрований конфіденційний клієнт, зафіксований `issuer` і один колбек `assertion_provider(audience, resource)`. Жодного браузера, жодної реєстрації, жодного токена оновлення. +* Сервер авторизації ніколи не виявляється через сервер ресурсів. Налаштуйте `issuer` точно тим рядком, який віддає його документ метаданих; порівняння — символ за символом. +* На боці сервера — `identity_assertion_enabled=True` плюс `exchange_identity_assertion`. SDK автентифікує клієнта й контролює доступ до гранту; перевірка ID-JAG — цілком ваша, а випущений токен прив'язаний до `resource` з ID-JAG, а не із запиту. + +Єдина сторона, якої ця сторінка не торкнулася, — MCP-сервер. Те, що він робить із токеном, який ви щойно випустили, він уже робив на сторінці **[Авторизація](../run/authorization.md)**. diff --git a/i18n/uk/pages/client/index.md b/i18n/uk/pages/client/index.md new file mode 100644 index 0000000000..d967bc571f --- /dev/null +++ b/i18n/uk/pages/client/index.md @@ -0,0 +1,217 @@ +--- +translation: + sections: [ebef1e7a0df854f4, a4c687d3d627d516, 8e79141fc2985342, b345dd05b9c3c7ab, 80ce41579825a6fa, 5f0fa90494de8f65, 83d10514eaa62fa5, 9190555aa39a5d28, 84a4c9d8bf14dddb, 927d71cf40b58c30] + tool: 1 +--- +# Клієнт {#the-client} + +**`Client`** — це те, через що програма на Python спілкується з MCP-сервером. + +Це один об'єкт з одним життєвим циклом: створіть його, увійдіть в `async with`, викликайте методи. Кожна дія протоколу (перелічити інструменти, викликати один із них, прочитати ресурс, відрендерити промпт) — це його `async`-метод, що повертає типізований результат. + +## Перший клієнт {#your-first-client} + +```python title="client.py" hl_lines="14-18" +--8<-- "docs_src/client/tutorial001.py" +``` + +Сервер угорі потрібен лише для того, щоб було до чого під'єднатися. Клієнт — це п'ять виділених рядків. + +* `Client(mcp)` отримує **сам об'єкт сервера**. Це транспорт у пам'яті: без підпроцесу, без порту, без HTTP. Саме так під'єднується кожен приклад на цій сторінці й кожен тест, який ви напишете. +* `async with` — це **життєвий цикл**. Вхід у блок під'єднує й узгоджує параметри; вихід — від'єднує. Пари `connect()` / `close()` немає, а `Client` не можна використати повторно після завершення блоку. +* Усередині блоку відомості про з'єднання вже доступні як звичайні властивості. + +### Що можна передати в `Client` {#what-you-can-pass-to-client} + +`Client` приймає один позиційний аргумент і визначає транспорт за його типом: + +* Екземпляр `MCPServer` (або низькорівневого `Server`): під'єднання **в межах процесу**. +* Рядок з URL (`Client("http://localhost:8000/mcp")`): Streamable HTTP, шлях для робочого розгортання. +* **Транспорт**: будь-що, що можна використати як `async with ... as (read, write)`, наприклад `stdio_client(...)`, що обгортає підпроцес. + +Усе інше на цій сторінці однакове для всіх трьох. Заголовки, підпроцеси, тайм-аути та протокол `Transport` мають власну сторінку: **[Транспорти клієнта](transports.md)**. + +### Що є в під'єднаного клієнта {#whats-on-a-connected-client} + +Чотири властивості лише для читання, заповнені в мить входу в блок: + +* `client.server_info`: ідентичність сервера або `None` для сервера покоління 2026, який її не повідомляє (сервери python-sdk за замовчуванням повідомляють). `server_info.name` тут — `"Bookshop"`, а `server_info.version` — те, що повідомить сервер. +* `client.server_capabilities`: що вміє сервер (`tools`, `resources`, `prompts`, `completions`, ...). Можливість, якої сервер не має, дорівнює `None`. +* `client.protocol_version`: версія протоколу, про яку домовилися обидві сторони. Тут це `"2026-07-28"`. +* `client.instructions`: рядок `instructions=` сервера або `None`, якщо сервер його не задав. + +Версію протоколу ви не обирали. За замовчуванням `Client` зондує сервер і на старіших повертається до класичного рукостискання, тож один клієнт працює із сервером будь-якого покоління. Якщо потрібно цим керувати, докладніше — на сторінці **[Версії протоколу](../protocol-versions.md)**. + +!!! tip + `client.session` — це базова `ClientSession`, низькорівневий запасний вихід. + Для жодної задачі на цій сторінці вона не знадобиться. + +## Перелік інструментів {#listing-tools} + +```python title="client.py" hl_lines="15-20" +--8<-- "docs_src/client/tutorial002.py" +``` + +`list_tools()` повертає `ListToolsResult`; інструменти лежать у `.tools`. Кожен із них — повне означення, яке хост передав би моделі: + +```python +tool.name # 'search_books' +tool.title # 'Search the catalog' +tool.description # 'Search the catalog by title or author.' +``` + +а `tool.input_schema` — це JSON Schema, яку сервер вивів з анотацій типів функції: + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"default": 10, "title": "Limit", "type": "integer"} + }, + "required": ["query"], + "title": "search_booksArguments" +} +``` + +Ця схема — усе, що потрібно UI, щоб показати форму аргументів, і все, що потрібно моделі, щоб сформувати коректні аргументи. + +!!! tip + `title` необов'язковий, тож UI, що показує інструменти людині, має обирати: `title`, якщо він є, + і `name`, якщо немає. `from mcp.shared.metadata_utils import get_display_name` робить саме це — + для інструментів, ресурсів, шаблонів ресурсів і промптів. + +## Виклик інструмента {#calling-a-tool} + +`call_tool(name, arguments)` запускає інструмент і повертає `CallToolResult`. + +```python title="client.py" hl_lines="26-33" +--8<-- "docs_src/client/tutorial003.py" +``` + +Серверний `lookup_book` повертає Pydantic-модель `Book`. Ось що бачить клієнт: + +```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 +``` + +Одне повернене значення, три речі для читання. У кожної свій споживач. + +### `content`: що читає модель {#content-what-the-model-reads} + +`content` — це `list` **блоків вмісту**, а блок вмісту — це об'єднання типів: `TextContent`, `ImageContent`, `AudioContent`, `ResourceLink` або `EmbeddedResource`. Інструмент може повернути кілька блоків, різних видів. + +Саме тому `main` звужує тип через `isinstance(block, TextContent)`, перш ніж звертатися до `block.text`. Зверніть увагу: поза `isinstance` немає жодного `.text` — перевірка типів цього не дозволить, бо `ImageContent` має `.data`, а не `.text`. Об'єднання чесно показує, що інструменту дозволено вам надіслати; ваш код має бути таким самим чесним. + +### `structured_content`: що читає ваш застосунок {#structured_content-what-your-application-reads} + +`structured_content` — це повернене значення інструмента у вигляді JSON, що відповідає оголошеній `output_schema` інструмента. Жодного розбору рядків, жодних здогадок. + +Коли є обидва, вони навмисно кажуть те саме двічі: `content` — для моделі, `structured_content` — для коду. Звідки береться структурована половина і як нею керувати — на сторінці **[Структурований вивід](../servers/structured-output.md)**. + +### `is_error`: чи завершився інструмент помилкою {#is_error-whether-the-tool-failed} + +Інструмент, що викидає виняток, **не** викидає його у вашому клієнті. Він повертається як звичайний результат з `is_error=True`. + +!!! check + Попросіть у `lookup_book` `"Solaris"` (назву, якої немає в каталозі) — і функція викине + `ValueError`. Виклик усе одно повернеться нормально: + + ```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 + ``` + + Повідомлення винятку потрапило в `content`, де його може прочитати **модель** і спробувати ще раз. Це + навмисно: помилка інструмента — частина розмови, а не аварія. Завжди дивіться на `is_error`, + перш ніж довіряти `structured_content`. + +!!! warning + `is_error=True` охоплює більше, ніж ваш власний `raise`. Попросіть інструмент, якого в сервера + взагалі немає (`call_tool("does_not_exist", {})`), — і нічого не викидається. Повертається та сама форма: + `is_error=True` з `Unknown tool: does_not_exist` у `content`. Метод `Client` викидає + `MCPError` лише тоді, коли сервер відповідає **помилкою** JSON-RPC замість результату, а коли + сервер повертає що саме — описано на сторінці **[Обробка помилок](../servers/handling-errors.md)**. + +## Ресурси {#resources} + +Дії з ресурсами йдуть парами: два способи перелічити, один спосіб прочитати. + +```python title="client.py" hl_lines="22-31" +--8<-- "docs_src/client/tutorial004.py" +``` + +* `list_resources()` повертає **конкретні** ресурси — ті, що мають фіксований URI. Тут: `['catalog://genres']`. +* `list_resource_templates()` повертає **параметризовані**. Тут: `['catalog://genres/{genre}']`. Це два різні списки, бо шаблон не можна прочитати, доки його не заповнено. +* `read_resource(uri)` приймає URI як звичайний `str` і працює з обома: передайте `"catalog://genres/poetry"` — і сервер зіставить його з шаблоном. + +`read_resource` повертає `contents` — список `TextResourceContents` або `BlobResourceContents`. Та сама ідея, що й із вмістом інструментів: звузьте тип через `isinstance`, потім читайте `.text` (або `.blob`). + +Клієнта також можна сповіщати про зміни ресурсу. На з'єднаннях покоління 2025 це `subscribe_resource(uri)` / `unsubscribe_resource(uri)` — пара методів, яку `MCPServer` не реалізує, тож у протоколі 2026-07-28 (де цих дій уже немає) запит повертає `-32601`, *Method not found*. Заміна у версії 2026 — потік `subscriptions/listen`, який `MCPServer` *таки* обслуговує — `server_capabilities.resources.subscribe` там дорівнює `True` — а як споживати його через `client.listen(...)`, описано на сторінці **[Підписки](subscriptions.md)** цього розділу. + +## Промпти {#prompts} + +```python title="client.py" hl_lines="15-20" +--8<-- "docs_src/client/tutorial005.py" +``` + +`list_prompts()` повідомляє, що пропонує сервер і що потрібно кожному промпту: + +```python +prompt.name # 'recommend' +prompt.title # 'Recommend a book' +prompt.arguments # [PromptArgument(name='genre', required=True)] +``` + +`get_prompt(name, arguments)` рендерить його. Словник аргументів — `str -> str`: аргументи промпту завжди рядки. Результат — `messages`, список `PromptMessage`, кожне з `role` і блоком `content`: + +```python +message.role # 'user' +message.content # TextContent(type='text', text='Recommend one poetry book from the catalog and say why.') +``` + +Хост передає ці повідомлення прямо моделі. Оце й уся можливість. + +## Автодоповнення {#completions} + +Сервер з обробником автодоповнення може доповнювати аргументи промптів і шаблонів ресурсів, поки користувач друкує. + +```python title="client.py" hl_lines="27-31" +--8<-- "docs_src/client/tutorial006.py" +``` + +* `ref` вказує, *який* промпт чи шаблон ви заповнюєте: `PromptReference` або `ResourceTemplateReference`. +* `argument` — це `{"name": ..., "value": ...}`: аргумент і те, що користувач уже встиг набрати. + +Відповідь — у `result.completion.values`. Наберіть `"p"` — і сервер поверне `['poetry']`. Серверний бік, а також те, як обробник використовує *інші*, уже заповнені аргументи, щоб звузити свої пропозиції, — на сторінці **[Автодоповнення](../servers/completions.md)**. + +## Пагінація {#pagination} + +Кожен метод `list_*` приймає іменований аргумент `cursor=`, а кожен результат містить `next_cursor`. Коли `next_cursor` дорівнює `None`, у вас є все. + +```python title="client.py" hl_lines="22-30" +--8<-- "docs_src/client/tutorial007.py" +``` + +Цей цикл коректний для будь-якого сервера. `MCPServer` повертає все однією сторінкою, тож `next_cursor` дорівнює `None` і цикл виконується один раз — саме тому більшість коду його ніколи не пише. Сервери, що справді розбивають результати на сторінки, і правила, яким підкоряються курсори, — на сторінці **[Пагінація](../advanced/pagination.md)**. + +## У тестах {#in-tests} + +`Client(mcp)` без процесу й без порту — це вже тестова обв'язка для вашого сервера. + +Саме для цього є один прапорець конструктора: `Client(mcp, raise_exceptions=True)`. Він діє лише на з'єднаннях у пам'яті, а пояснює його й будує навколо нього весь підхід сторінка **[Тестування](../get-started/testing.md)**. + +## Підсумки {#recap} + +* `Client(x)` під'єднується в пам'яті до об'єкта сервера, через Streamable HTTP — до рядка з URL і через транспорт — до всього іншого. +* `async with` — це весь життєвий цикл. Усередині нього `server_capabilities` і `protocol_version` уже заповнені; `server_info` та `instructions` — теж, якщо сервер їх надає. +* `list_tools()` дає `name`, `title`, `description` та `input_schema` кожного інструмента. +* `call_tool()` повертає `content` для моделі, `structured_content` для вашого коду та `is_error`. Інструмент, що викидає виняток, — це результат, а не виняток. +* `content` — об'єднання типів блоків; звужуйте тип через `isinstance`, перш ніж читати. +* `list_resources` / `list_resource_templates` / `read_resource`, `list_prompts` / `get_prompt` і `complete` доповнюють набір дій. +* Кожен `list_*` приймає `cursor=`; повторюйте цикл, доки `next_cursor` не стане `None`. + +Про що сервер може попросити *клієнта* і як на це відповідати — на сторінці **[Колбеки клієнта](callbacks.md)**. diff --git a/i18n/uk/pages/client/oauth-clients.md b/i18n/uk/pages/client/oauth-clients.md new file mode 100644 index 0000000000..e39d21846b --- /dev/null +++ b/i18n/uk/pages/client/oauth-clients.md @@ -0,0 +1,152 @@ +--- +translation: + sections: [c6899d3892bd9fa0, 79372cff3cc48a88, 63878d29e87c3e73, 13175843d3588af4, e7e2b9fd516f77de, 758f06399b513c1f, a05d7278487d610b] + tool: 1 +--- +# OAuth-клієнти {#oauth-clients} + +Деякі MCP-сервери захищені. Надішліть їм запит без токена — і у відповідь прийде `401 Unauthorized`. + +Отримати токен допомагає **`OAuthClientProvider`**. Це взагалі не об'єкт MCP. Це `httpx2.Auth` — стандартний хук httpx2 на кшталт «зроби щось із кожним запитом». Його під'єднують до `httpx2.AsyncClient`, передають цей клієнт транспорту Streamable HTTP — і більше про нього не згадують. + +Ця сторінка — про клієнтський бік. Як змусити власний сервер вимагати токен — на сторінці **[Авторизація](../run/authorization.md)**. + +## Провайдер {#the-provider} + +```python title="client.py" hl_lines="44-54" +--8<-- "docs_src/oauth_clients/tutorial001.py" +``` + +Йому передають чотири речі: + +* `server_url`: MCP-ендпоінт, до якого ви під'єднуєтеся. Усе інше провайдер виявляє за ним сам. +* `client_metadata`: те, що ви ввели б у форму «зареєструвати застосунок» на сервері авторизації. +* `storage`: де токени зберігаються між запусками. +* `redirect_handler` і `callback_handler`: два моменти, коли потрібна участь людини. + +Більше ніде у файлі OAuth не згадується. `main()` ніколи не бачить токена. + +### Метадані клієнта {#client-metadata} + +`OAuthClientMetadata` — це справжній реєстраційний документ із [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591), оформлений як модель Pydantic. + +Ви задаєте три поля. Решту заповнюють типові значення: `grant_types` уже дорівнює `["authorization_code", "refresh_token"]`, а `response_types` — `["code"]`, і це саме той потік, який виконує цей провайдер. + +!!! check + Оскільки це модель Pydantic, вона проходить валідацію **ще до того, як у мережу піде бодай один байт**. + Пропустіть `redirect_uris` — і створення об'єкта одразу завершиться `ValidationError`, який + називає поле: + + ```text + redirect_uris + Field required [type=missing, input_value={'client_name': 'Bookshop Agent'}, input_type=dict] + ``` + + Браузер не відкрився, на сервері авторизації не залишилося напівзавершеної реєстрації. + +### Сховище токенів {#token-storage} + +**`TokenStorage`** — це `Protocol` із чотирма асинхронними методами. Успадковуватися ні від чого не потрібно: напишіть ці методи — і будь-який клас стає сховищем токенів: + +* `get_tokens` / `set_tokens` зберігають `OAuthToken`: токен доступу, токен оновлення, термін дії, область дії. +* `get_client_info` / `set_client_info` зберігають `OAuthClientInformationFull`, який сервер авторизації видав, коли провайдер вас зареєстрував, — разом із вашим `client_id`. + +Наведена вище версія в пам'яті працює. Але вона все забуває, щойно процес завершується, тож наступний запуск повторює всю процедуру з початку. Збережіть дані у файл або в системне сховище ключів вашої платформи — і наступний запуск пройде тихо. + +!!! tip + Зберігайте `client_info`, а не лише токени. Провайдер реєструється динамічно, коли вперше + не знаходить збереженого `client_info`. Викиньте його — і кожен запуск створюватиме нову реєстрацію. + +### Два обробники {#the-two-handlers} + +Потоку authorization code людина потрібна рівно один раз: хтось має увійти й натиснути «Дозволити». + +* **`redirect_handler`** викликається через await із повністю зібраним URL авторизації. `client_id`, `redirect_uri`, `state` і PKCE challenge уже в ньому. Ваше єдине завдання — відкрити його в браузері. Настільний застосунок викликає `webbrowser.open`; цей файл просто друкує його. +* **`callback_handler`** очікується наступним. Він чекає, доки користувач повернеться на ваш `redirect_uri`, і повертає параметри запиту цього перенаправлення як `AuthorizationCodeResult`. + +Справжній клієнт замість виклику `input()` запускає невеликий локальний HTTP-сервер на redirect URI. Форма та сама: отримати перенаправлення, повернути `code`, `state` та `iss`. + +!!! warning + Передавайте `state` та `iss` далі точно такими, якими вони надійшли. Провайдер порівнює `state` + з тим, що згенерував сам, а `iss` — із виявленим видавцем, і відхиляє розбіжність. Це захист + від CSRF і від атак із підміною сервера (mix-up). + +### Усередину `Client` {#into-the-client} + +Погляньте на `main()`. Провайдер чіпляється до **клієнта httpx2**, клієнт httpx2 передається у `streamable_http_client(url, http_client=...)`, а цей транспорт — у `Client`. + +У `streamable_http_client` немає іменованого аргументу `auth=`. Усе, що стосується рівня HTTP (автентифікація, заголовки, тайм-аути, проксі), належить до `httpx2.AsyncClient`, який ви приносите самі. Про це розшарування — на сторінці **[Транспорти клієнта](transports.md)**. + +## Що провайдер робить за вас {#what-the-provider-does-for-you} + +Коли `Client` уперше надсилає запит, сервер відповідає `401`. Далі справу бере на себе провайдер: + +1. **Виявлення.** Він читає заголовок `WWW-Authenticate`, завантажує Protected Resource Metadata сервера з `/.well-known/oauth-protected-resource`, дізнається, який сервер авторизації захищає цей ресурс, і завантажує метадані *того* сервера. +2. **Реєстрація.** У сховищі порожньо? Він динамічно реєструє вас із вашими `OAuthClientMetadata` і зберігає результат. +3. **Авторизація.** Він генерує пару PKCE і `state`, будує URL авторизації, очікує ваш `redirect_handler`, а потім — ваш `callback_handler`, щоб отримати код. +4. **Обмін.** Він міняє код на `OAuthToken`, зберігає його й повторює ваш початковий запит із `Authorization: Bearer ...`. + +Після цього все тихо. Токени беруться зі сховища, прострочений токен доступу оновлюється за допомогою токена оновлення, і лише коли ніщо з цього не спрацьовує, провайдер запускає потік знову. + +Нічого з цього ви не писали. Лишаються ще два іменовані аргументи (`client_metadata_url` і `validate_resource_url`), і цьому файлу не потрібен жоден. Знати варто про `client_metadata_url`; йому присвячено окремий розділ нижче. + +### Спробуйте самі {#try-it} + +Більшість прикладів у цій документації можна перевірити за допомогою `Client(server)` у пам'яті. Цей — ні: уся суть потоку в HTTP-відповіді `401`, а між клієнтом у пам'яті та його сервером HTTP немає. + +У репозиторії є жива версія. `examples/servers/simple-auth/` запускає окремий сервер авторизації та захищений MCP-сервер; `examples/clients/simple-auth-client/` — це клієнт із цієї сторінки, що виріс у невеликий CLI. У його README є дві команди: запустіть сервери, запустіть клієнт проти них — і спостерігайте, як проходять усі чотири кроки. + +## Client ID Metadata Documents {#client-id-metadata-documents} + +Ревізія специфікації 2026-07-28 оголошує динамічну реєстрацію клієнтів застарілою на користь **Client ID Metadata Documents** (CIMD). Замість надсилати POST із новою реєстрацією на кожен сервер авторизації, що трапляється на шляху, ваш клієнт публікує один JSON-документ про себе за стабільним HTTPS URL, і цей URL *і є* його `client_id`. Документ завантажує сервер авторизації; провайдер його ніколи не торкається. + +SDK це вже підтримує: передайте URL як `client_metadata_url=`, коли створюєте провайдер. Якщо метадані сервера авторизації оголошують `client_id_metadata_document_supported: true`, провайдер повністю пропускає запит `/register`: URL іде в потік як `client_id`, а `client_secret` немає взагалі. Якщо сервер цього не оголошує (більшість поки що ні) або ви так і не передали URL, провайдер **мовчки** повертається до динамічної реєстрації, і все описане вище працює саме так, як описано. Збережений `client_info` і далі має пріоритет над обома варіантами. + +URL має бути HTTPS із некореневим шляхом; усе інше — це `ValueError` під час створення, ще до будь-якого звернення до мережі. Приклад `examples/clients/simple-auth-client/` із репозиторію приймає його через змінну середовища `MCP_CLIENT_METADATA_URL`. + +## Взаємодія між машинами {#machine-to-machine} + +Нічне завдання, крок CI, інший сервіс. Браузера немає, і натиснути «Дозволити» нікому. Це грант **client credentials**: у вас уже є `client_id` і `client_secret`, а весь потік зводиться до ендпоінта токенів. + +`ClientCredentialsOAuthProvider` — це той самий `httpx2.Auth`, тільки без людини: + +```python title="client.py" hl_lines="4 27-33" +--8<-- "docs_src/oauth_clients/tutorial002.py" +``` + +Що змінилося: + +* Немає `OAuthClientMetadata`, немає обробників. Ви передаєте `client_id` і `client_secret`; провайдер будує навколо них мінімальну реєстрацію `client_credentials` і повністю пропускає динамічну реєстрацію. +* `scope` — це рядок, розділений пробілами: такий формат OAuth використовує в переданих даних. +* Усе далі ідентичне: той самий `TokenStorage`, той самий `httpx2.AsyncClient(auth=...)`, той самий `streamable_http_client`. + +За замовчуванням секрет передається як HTTP Basic auth у запиті на токен (`client_secret_basic`). Передайте `token_endpoint_auth_method="client_secret_post"`, щоб натомість помістити його в тіло форми. Деякі сервери авторизації приймають лише один із двох варіантів. + +!!! tip + Читайте `client_secret` зі змінних середовища або менеджера секретів, ніколи не з системи контролю версій. + +!!! info + Ще один провайдер живе в `mcp.client.auth.extensions.client_credentials`: + **`PrivateKeyJWTOAuthProvider`** — для клієнтів, які автентифікуються за допомогою JWT замість + спільного секрету (`private_key_jwt`, варіант із парою ключів та workload identity). Він дотримується + тієї ж схеми: створіть екземпляр і передайте в `auth=`. У тому ж модулі є + `SignedJWTParameters` і `static_assertion_provider` — два допоміжні засоби, що будують для нього assertion. + +Є ще одна ситуація без людини: клієнт належить підприємству, де провайдер ідентичності, а не користувач, вирішує, до яких MCP-серверів він може звертатися. Це інший грант із власною моделлю довіри та власною сторінкою — **[Твердження про ідентичність](identity-assertion.md)**. + +## Коли щось іде не так {#when-it-fails} + +Коли в OAuth-потоці щось іде не так, провайдер викидає `OAuthFlowError` з `mcp.client.auth`. У нього два підкласи. `OAuthRegistrationError` означає, що реєстрація не дала клієнта, яким можна скористатися: сервер авторизації відмовився вас реєструвати або таки зареєстрував, але з обліковими даними, які цей потік не може використати (наприклад, із методом автентифікації, якого він не реалізує). `OAuthTokenError` означає, що токен отримати не вдалося: ендпоінт токенів відмовив, або збережений запис клієнта містить метод автентифікації, який цей клієнт не може застосувати, — про це повідомляється ще під час побудови запиту на токен, а не після надсилання. Один `except OAuthFlowError:` охоплює виявлення, реєстрацію, авторизацію та обмін. + +Не все є помилкою потоку. Мережа, як і раніше, може підвести; це звичайні винятки `httpx2`, і вони проходять наскрізь без змін. + +## Підсумки {#recap} + +* `OAuthClientProvider` — це `httpx2.Auth`. Під'єднайте його до `httpx2.AsyncClient`, передайте той у `streamable_http_client(url, http_client=...)` — і `Client` ніколи не дізнається, що відбувся OAuth. +* Ви надаєте чотири речі: URL сервера, `OAuthClientMetadata`, `TokenStorage` і пару обробників redirect/callback. +* `TokenStorage` — це `Protocol`: чотири асинхронні методи, жодного базового класу. Зберігайте `client_info` так само, як і токени. +* Виявлення, реєстрація (динамічна або через **Client ID Metadata Document**), PKCE, перевірки `state` та `iss` і оновлення токенів — робота провайдера, а не ваша. +* `ClientCredentialsOAuthProvider` — версія без людини: `client_id` + `client_secret`, без обробників, без браузера. +* Кожен збій OAuth — це `OAuthFlowError`; `OAuthRegistrationError` і `OAuthTokenError` — його підкласи. + +Друга половина цього рукостискання — змусити ваш *сервер* вимагати токен — на сторінці **[Авторизація](../run/authorization.md)**. diff --git a/i18n/uk/pages/client/session-groups.md b/i18n/uk/pages/client/session-groups.md new file mode 100644 index 0000000000..db219633ce --- /dev/null +++ b/i18n/uk/pages/client/session-groups.md @@ -0,0 +1,87 @@ +--- +translation: + sections: [09c857a25a9dc37a, 43bc6a76a243a50e, 0a716022a88768df, 4b7f78042bfcfff7, c112662e61b03315, 58974ba1f489a8b4, d18adbdbb835ea73] + tool: 1 +--- +# Групи сесій {#session-groups} + +`Client` під'єднується до одного сервера. Реальним застосункам часто потрібно кілька (сервер пошуку, сервер бази даних, внутрішній API), і зрештою доводиться жонглювати окремим з'єднанням і списком інструментів для кожного. + +**`ClientSessionGroup`** — це один об'єкт, який тримає багато з'єднань і зводить усе, що вони надають, в єдине представлення. + +## Два сервери {#two-servers} + +Почнімо з двох звичайних серверів. Вони ніяк не пов'язані між собою, тож обидва, природно, назвали свій інструмент `search`: + +```python title="library_server.py" hl_lines="7" +--8<-- "docs_src/session_groups/tutorial001.py" +``` + +```python title="web_server.py" hl_lines="7" +--8<-- "docs_src/session_groups/tutorial002.py" +``` + +## Одна група {#one-group} + +Створіть `ClientSessionGroup` і викличте **`connect_to_server`** один раз для кожного сервера: + +```python title="client.py" hl_lines="10-12" +--8<-- "docs_src/session_groups/tutorial003.py" +``` + +* `connect_to_server` приймає параметри транспорту, а не об'єкт сервера: `StdioServerParameters` (з `mcp`), щоб запустити підпроцес, або `StreamableHttpParameters` / `SseServerParameters` (з `mcp.client.session_group`) для сервера, що вже слухає на якомусь URL. +* `group.tools` — це `dict[str, Tool]` з інструментами всіх під'єднаних серверів. `group.resources` і `group.prompts` мають таку саму форму. +* `group.call_tool(name, arguments)` шукає ім'я, знаходить сесію, якій воно належить, і пересилає виклик. Указувати сервер не потрібно ніколи. + +!!! check + Покладіть `client.py` поруч із двома серверами й запустіть його. Другий `connect_to_server` відмовляє: + + ```text + mcp.shared.exceptions.MCPError: {'search'} already exist in group tools. + ``` + + Це `MCPError`, викинутий ще до того, як щось із другого сервера буде зареєстровано. Ім'я має + бути унікальним у межах **усієї** групи, а два сервери, які ви не контролюєте, рано чи пізно зіткнуться. + +## `component_name_hook` {#component_name_hook} + +Виправляти це слід у групі, а не на серверах. Передайте функцію від `(name, server_info)`, і група застосує її до кожного імені, яке реєструє: + +```python title="client.py" hl_lines="7-8 15" +--8<-- "docs_src/session_groups/tutorial004.py" +``` + +Запустіть знову. `print(sorted(group.tools))` тепер показує обидва: + +```text +['Library.search', 'Web.search'] +``` + +* **Ключ** — ваш. `by_server` побудував його з `server_info.name` — імені, з яким було створено кожен `MCPServer(...)`. +* `Tool` усередині лишається незмінним: `group.tools["Web.search"].name` — усе ще `"search"`, і саме це ім'я `call_tool` надсилає мережею. Префікс ніколи не виходить за межі вашого процесу. +* Це стосується не лише інструментів. Ресурс бібліотеки `hours` зареєстровано як `Library.hours`. + +!!! tip + Хук виконується для **кожного** імені з **кожного** сервера, а не лише за конфліктів: режиму + «префікс лише за зіткнення» немає. Оберіть одну схему й дайте їй діяти всюди. + +## Додавання й видалення серверів {#adding-and-removing-servers} + +`connect_to_server` повертає `ClientSession`, яку він відкрив. Збережіть її, якщо колись захочете позбутися цього сервера: `await group.disconnect_from_server(session)` видаляє його інструменти, ресурси й промпти з групи. + +Якщо вже маєте під'єднану `ClientSession` (наприклад, `Client.session`), передайте її в `await group.connect_with_session(server_info, session)` замість того, щоб відкривати новий транспорт. Агрегація відбувається так само. Група ніколи не закриває сесію, яку не відкривала. `server_info` задає ім'я сервера для префіксів компонентів; на з'єднанні покоління 2026 `client.server_info` може бути `None` (ідентичність необов'язкова), тож у такому разі передайте власний `Implementation(name=..., version=...)`. + +## Класичне рукостискання {#the-classic-handshake} + +`ClientSessionGroup` побудовано на `ClientSession`, а не на `Client`. Кожен `connect_to_server` виконує класичне рукостискання `initialize`. Він ніколи не надсилає зонд `server/discover`, описаний на сторінці **[Версії протоколу](../protocol-versions.md)**. Це рукостискання розуміє кожен MCP-сервер, тож сумісності це ні з чим не коштує; це лише означає, що група йде старішим і повільнішим шляхом до сервера, який міг би краще. + +## Підсумки {#recap} + +* `ClientSessionGroup` тримає багато з'єднань із серверами й зводить їхні інструменти, ресурси й промпти в один `dict` кожного виду. +* `connect_to_server(params)` для кожного сервера. Він приймає параметри транспорту й ніколи — об'єкт сервера чи URL, як `Client`. +* `group.call_tool(name, arguments)` сам спрямовує виклик на сервер-власник. +* Імена мають бути унікальними в межах усієї групи; два сервери з інструментом `search` самі по собі співіснувати не можуть. +* `component_name_hook=` переписує кожне зареєстроване ім'я. Змінюється ключ словника, а не ім'я в переданих даних. +* `connect_with_session` додає сесію, яку ви вже маєте; `disconnect_from_server` видаляє сесію. + +Рукостисканню, яким говорить група (і швидшому, якому віддає перевагу `Client`), присвячено сторінку **[Версії протоколу](../protocol-versions.md)**. diff --git a/i18n/uk/pages/client/subscriptions.md b/i18n/uk/pages/client/subscriptions.md new file mode 100644 index 0000000000..2f0553b391 --- /dev/null +++ b/i18n/uk/pages/client/subscriptions.md @@ -0,0 +1,91 @@ +--- +translation: + sections: [8f9558e57f29eee1, a88c587739e0465c, 46ebfd5b325ed041, 4d10b00b57ce4bd9, 2cdb0edd1f59b3e2] + tool: 1 +--- +# Підписки {#subscriptions} + +Каталог сервера не є сталим. Інструменти з'являються під час роботи, а вміст за URI ресурсу змінюється. Клієнт дізнається про це через `client.listen(...)`: один запит `subscriptions/listen`, відповідь на який *і є* потоком. Він лишається відкритим і несе сповіщення про зміни, які клієнт попросив. + +Ця сторінка — про клієнтський бік: як відкрити потік, стежити за ним поруч з основним потоком виконання й обробляти його завершення. Публікація змін, фільтрація та обслуговування методу — серверний бік історії, розказаний на сторінці **[Підписки](../handlers/subscriptions.md)** у розділі *Усередині обробника*. Приклади тут спілкуються із сервером спринт-дошки, побудованим там. + +## Стеження за потоком {#watching-the-stream} + +Підписка — це один контекстний менеджер. Вхід у нього надсилає запит із вашими іменованими аргументами як фільтром підписки й чекає на підтвердження від сервера, тож до початку блока потік уже активний. + +```python title="client.py" hl_lines="15 18 28" +--8<-- "docs_src/subscriptions/tutorial003.py" +``` + +Ітерація повертає чотири типізовані події: `ToolsListChanged`, `PromptsListChanged`, `ResourcesListChanged` і `ResourceUpdated(uri=...)`. + +Подія каже, *що* змінилося, і ніколи — *як*. Саме тому `follow_board` викликає `read_resource` і `list_tools`: подія — це сигнал перечитати дані. Читайте `event.uri`, а не припускайте, який ресурс змінився: фільтр може називати кілька URI, а сервер може повідомити про зміну підресурсу одного з них. + +Дублікати подій, що чекають на споживання, згортаються в одну, а повторне читання все одно дає поточний стан. Згортаються лише ідентичні події: два `ResourceUpdated` для різних URI — це дві події. + +Ще дві властивості дескриптора: + +* `sub.honored` — фільтр, який підтвердив сервер: `SubscriptionFilter` із полями, що ви передали, доступними як атрибути (`sub.honored.prompts_list_changed`). `MCPServer` задовольняє кожен вид, який ви просите, тож повертає ваш запит як є. Сервер, що підтримує менше видів, підтверджує менше, а підтверджений вид усе одно може ніколи не спрацювати. Сервер також може відхилити весь запит замість того, щоб підтвердити його (див. [Хто може стежити](../handlers/subscriptions.md#deciding-who-may-watch) на серверній сторінці), що проявляється як помилка запиту. +* `sub.subscription_id` — ідентифікатор запиту listen, той самий, що проставлений на кожному кадрі цього потоку. Одночасно може бути відкрито кілька підписок, і кожна демультиплексується за власним ідентифікатором. + +## Стеження без блокування {#watching-without-blocking} + +`follow_board` працює, доки сервер не закриє потік, а цього може не статися ніколи, тож сама по собі вона захоплює всю програму. Реальним клієнтам спостерігач потрібен *поруч* з основним потоком виконання: агент викликає інструменти, а спостерігач тим часом підтримує кеш чи інтерфейс актуальними. + +Спершу відкрийте підписку, потім запустіть спостерігача й продовжуйте свою роботу. + +=== "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` імпортує `BOARD` і `read_board` з першого прикладу, який у цьому репозиторії + збережено як `tutorial003.py`. Якщо ви зберігаєте показані файли поруч як `client.py` і `app.py`, + напишіть натомість `from client import BOARD, read_board`. Приклад `watch.py` нижче + імпортує `read_board` так само. + +Уся суть — у порядку. Нічого не відтворюється повторно, тож подію, опубліковану до появи вашого потоку, буде пропущено. Вхід у `client.listen(...)` чекає на підтвердження, тому кожна зміна від цієї миті доходить до спостерігача, а знімок, зроблений усередині блока, не може жодної пропустити. + +Запити вільно виконуються поруч із відкритим потоком — із завдання спостерігача чи будь-якого іншого, на тому самому клієнті. Оскільки *дублікати* неспожитих подій зливаються, завантажений основний потік виконання може дати одне повторне читання замість трьох. Події, що відрізняються, не зливаються: фільтр, який називає багато URI, ставить у чергу по одній відкладеній події на кожен URI. + +Щоб припинити стеження, вийдіть із блока: виклику `unsubscribe` немає. Скасування завдання, якому належить блок, робить це за вас, а SDK скасовує запит listen так, як очікує транспорт: через Streamable HTTP — закриваючи потік цього запиту. Спостерігач, що працює весь час життя застосунку, сам ніколи не повертається, тож скасуйте його або область його групи завдань під час завершення роботи. + +## Потоки закінчуються {#streams-end} + +Потік закінчується одним із двох способів, і обидва — звичайний хід виконання. Коректне закриття з боку сервера завершує `async for`; раптовий обрив викидає `SubscriptionLost`. + +Різниця — діагностична, а не в тому, що робити далі: потоку вже немає, нічого не відтворено повторно, і спостерігач, якому це досі важливо, підписується знову й перечитує дані. + +```python title="watch.py" hl_lines="16 20" +--8<-- "docs_src/subscriptions/tutorial005.py" +``` + +Сервери коректно закривають потоки з власних причин, зокрема щоб позбутися підписника, чий беклог занадто виріс, тож чисте завершення — не сигнал припиняти стеження. Витримайте паузу, перш ніж підписуватися знову. + +`SubscriptionLost` має й одну локальну причину. Клієнт тримає щонайбільше 1024 неспожиті події, і споживач, який відстав настільки, втрачає підписку, замість того щоб рости без меж. Тримайте тіло `async for` коротким, а повільну роботу виконуйте деінде. + +`keep_following` перехоплює лише `SubscriptionLost`. Вхід у `listen()` може також викинути `MCPError` (з'єднання не вдалося або сервер не обслуговує цей метод), `TimeoutError` (підтвердження не надійшло) і `ListenNotSupportedError` (з'єднання до версії 2026). Вирішіть, які з них ваш спостерігач має повторювати: остання ніколи не минає сама. + +## Підсумки {#recap} + +* Увійдіть у `async with client.listen(...)`; вхід чекає на підтвердження, тож нічого опублікованого після нього не буде пропущено. +* Ітеруйте через `async for event in sub`. Події — це сигнали перечитати дані, а не корисне навантаження. +* Відкрийте підписку, потім запустіть спостерігача як завдання — і виклики інструментів ідуть далі поруч із ним. +* Чисте завершення зупиняє цикл; обрив викидає `SubscriptionLost`. У будь-якому разі: підпишіться знову, перечитайте дані, але спершу витримайте паузу. +* Вихід із блока — це і є відписка. + +Публікація цих подій, звуження фільтра та масштабування за межі одного процесу — історія сервера: **[Підписки](../handlers/subscriptions.md)**. Ці самі події також підтримують актуальність клієнтського кешу, і наступна сторінка — **[Кешування](caching.md)**. diff --git a/i18n/uk/pages/client/transports.md b/i18n/uk/pages/client/transports.md new file mode 100644 index 0000000000..d3b8f3edb6 --- /dev/null +++ b/i18n/uk/pages/client/transports.md @@ -0,0 +1,128 @@ +--- +translation: + sections: [9cac816674181eb0, 0700f337babcd4dd, 2bde0dd58cdf00f5, ff7401df479af877, 3d0832f39b0d7059, d4bf7e4479637768, 05e20c0a798860e7] + tool: 1 +--- +# Транспорти клієнта {#client-transports} + +Кожен `Client` спілкується зі своїм сервером через **транспорт** — те, що власне й переносить повідомлення. + +Окремо його налаштовувати не доводиться. `Client` приймає один позиційний аргумент і визначає транспорт за його типом. + +*Серверний* бік кожного з них (що робить `mcp.run()` і що ви розгортаєте) описано на сторінці **[Запуск сервера](../run/index.md)**. + +## У пам'яті {#in-memory} + +Передайте сам об'єкт сервера: + +```python title="client.py" hl_lines="14" +--8<-- "docs_src/client_transports/tutorial001.py" +``` + +Жодного підпроцесу, жодного порту, жодних байтів у мережі. Клієнт і сервер — це два об'єкти в одному процесі, а виклик усе одно проходить через справжній протокольний рівень: `search_books` перелічується, валідується й викликається точнісінько так само, як це було б через HTTP. + +Тож це одразу дві речі: + +* **Тестовий стенд.** Кожен приклад у цій документації перевіряється саме так, а сторінка **[Тестування](../get-started/testing.md)** будує довкола цього весь підхід. +* **API для вбудовування.** Застосунку, який сам створює сервер, не потрібен мережевий перехід, щоб викликати його інструменти. + +## Streamable HTTP {#streamable-http} + +Передайте рядок з URL — і отримаєте **Streamable HTTP**, транспорт, за яким розгортають сервер: + +```python title="client.py" hl_lines="5" +--8<-- "docs_src/client_transports/tutorial002.py" +``` + +Оце й увесь продакшен-клієнт. `Client` сам загортає URL у `streamable_http_client(...)` поверх `httpx2.AsyncClient`, налаштованого так, як потрібно MCP: `follow_redirects=True`, 30-секундний тайм-аут на connect/write/pool і 300-секундний тайм-аут на читання, бо сервер може тримати потік відповіді відкритим. + +!!! check + Створений `Client` ще **не** під'єднаний. Конструктор лише обирає транспорт; + відкриває його `async with`. Спробуйте звернутися до з'єднання до входу в блок — і SDK про це скаже: + + ```text + RuntimeError: Client must be used within an async context manager + ``` + + Коли ви написали `Client("http://...")`, нічого не розв'язувалося, не завантажувалося й не запускалося. Цей рядок нічого не коштує. + +### Власний `httpx2.AsyncClient` {#bring-your-own-httpx2asyncclient} + +Щойно знадобиться заголовок `Authorization`, cookie, проксі, mTLS чи інший тайм-аут — створіть `httpx2.AsyncClient` самостійно й передайте його в `streamable_http_client`: + +```python title="client.py" hl_lines="8-14" +--8<-- "docs_src/client_transports/tutorial003.py" +``` + +Зверніть увагу на дві речі: + +* `httpx2.AsyncClient` належить вам, тож саме **ви** входите в нього й виходите з нього. SDK ніколи не закриває клієнт, якого не створював. +* `streamable_http_client(url, http_client=...)` повертає транспорт, а `Client(transport)` приймає його, як і будь-що інше. + +Одне зауваження щодо TLS: `httpx2` перевіряє сертифікати за сховищем довіри операційної системи (через +[`truststore`](https://pypi.org/project/truststore/)), а не за вбудованим списком CA. У середовищі +без придатного системного сховища CA (деякі мінімальні контейнери) задайте стандартні змінні середовища `SSL_CERT_FILE`/`SSL_CERT_DIR` +або передайте явний `verify=ssl_context` у свій `httpx2.AsyncClient` +(подробиці — у розділі +[`httpx` і `httpx-sse` замінено на `httpx2`](../migration.md#httpx-and-httpx-sse-replaced-by-httpx2)). + +!!! warning + Раніше `streamable_http_client` приймав `headers=` і `timeout=` напряму. Більше ні: + його єдині параметри — `url`, `http_client` і `terminate_on_close`. Напишете `headers=` + за звичкою — і отримаєте: + + ```text + TypeError: streamable_http_client() got an unexpected keyword argument 'headers' + ``` + + Усе, що стосується HTTP, тепер живе на тому єдиному `httpx2.AsyncClient`, який ви передаєте. + +!!! info + `httpx2` зберігає знайомий API `httpx`, тож якщо ви знаєте `httpx`, то вже вмієте робити тут автентифікацію, + проксі, хуки подій, повторні спроби й обмеження з'єднань. SDK нічого не додає зверху й нічого не + забирає. Саме сюди під'єднується й OAuth: + `httpx2.AsyncClient(auth=OAuthClientProvider(...))`. Увесь цей процес описано на сторінці **[OAuth-клієнти](oauth-clients.md)**. + +## stdio {#stdio} + +Сервер **stdio** — це підпроцес. Клієнт запускає його, пише JSON-RPC в його stdin і читає JSON-RPC з його stdout. Саме так десктопний хост запускає сервер на вашій машині: хост і *є* цим кодом плюс UI, а сторінка **[Під'єднання до справжнього хоста](../get-started/real-host.md)** показує ті самі стосунки з боку хоста — як конфігураційний файл. + +Опишіть процес за допомогою `StdioServerParameters`, перетворіть його на транспорт через `stdio_client` і передайте *це* в `Client`: + +```python title="client.py" hl_lines="4-8 12" +--8<-- "docs_src/client_transports/tutorial004.py" +``` + +`Client` не приймає сам об'єкт параметрів. `StdioServerParameters` — це конфігурація; `stdio_client(server)` — транспорт, який уміє запустити з неї процес. Завжди загортайте. + +Вихід із блоку `async with` також завершує підпроцес: закриває stdin, чекає, вбиває, якщо той затримується. Прибирати за ним самостійно ніколи не доведеться. + +!!! warning + Дочірній процес **не** успадковує ваше середовище. Він отримує мінімальний список дозволених змінних (`HOME`, `LOGNAME`, + `PATH`, `SHELL`, `TERM` і `USER` на POSIX), щоб нічого чутливого не просочилося в процес, + який, можливо, писали не ви. + + Сервер, якому потрібен ключ API, там його не знайде. Передайте його явно через `env=`; ці + змінні накладаються поверх списку дозволених. Саме це й робить `BOOKSHOP_API_KEY` вище. + +## SSE {#sse} + +`sse_client(url)` з `mcp.client.sse` — це HTTP-транспорт, на зміну якому прийшов Streamable HTTP. Загортайте його так само, `Client(sse_client("http://localhost:8000/sse"))`, щоб говорити із сервером, який досі ним користується, — і не будуйте на ньому нічого нового. + +## Протокол `Transport` {#the-transport-protocol} + +Для `Client` усе перелічене вище — одне й те саме. + +**Транспорт** — це будь-який асинхронний контекстний менеджер, що повертає пару потоків повідомлень `(read, write)`: формально — протокол `Transport` у `mcp.client`. `Client` розв'язує свій аргумент за типом: об'єкт сервера під'єднується в межах процесу, `str` стає `streamable_http_client(url)`, а в будь-що інше він входить безпосередньо як у транспорт. Саме завдяки останньому правилу `stdio_client(...)`, `streamable_http_client(...)` і `sse_client(...)` стають на одне й те саме місце — і саме тому можна написати власний. + +## Підсумки {#recap} + +* `Client(mcp)` (об'єкт сервера) під'єднується в пам'яті. Використовуйте для тестів і для вбудовування. +* `Client("http://.../mcp")` (URL) під'єднується через Streamable HTTP, продакшен-транспорт. +* Заголовки, автентифікація, проксі й тайм-аути належать `httpx2.AsyncClient`, який ви передаєте в `streamable_http_client(url, http_client=...)`. Іменованого аргументу `headers=` немає. +* stdio — це `Client(stdio_client(StdioServerParameters(...)))`, ніколи не сам об'єкт параметрів. +* Підпроцес отримує середовище зі списку дозволених, а не ваше; `env=` його доповнює. +* Транспорт — це будь-що, з чим можна зробити `async with x as (read, write)`. Усе, що не є об'єктом сервера чи URL, `Client` передає прямо цьому протоколу. +* Створення `Client` обирає транспорт. `async with` його відкриває. + +Щойно транспорт відкрито, обидві сторони мають домовитися про версію протоколу. Зазвичай про це не думаєш; а коли доводиться — є сторінка **[Версії протоколу](../protocol-versions.md)**. diff --git a/i18n/uk/pages/deprecated.md b/i18n/uk/pages/deprecated.md new file mode 100644 index 0000000000..298e2a4237 --- /dev/null +++ b/i18n/uk/pages/deprecated.md @@ -0,0 +1,98 @@ +--- +translation: + sections: [20541a40dbdd5980, 01262a123ad9501d, 429db5b574a2ac08, 56b2d49da412cb28, 6a1717123fe4513c] + tool: 1 +--- +# Застарілі можливості {#deprecated-features} + +Специфікація 2026-07-28 виводить з ужитку п'ять речей. SDK і далі реалізує кожну з них, і кожна тепер супроводжується **попередженням про застарілість**. + +Таблиця нижче називає кожну застарілу можливість, пояснює, чому вона зникає, і вказує заміну, на яку варто спиратися. + +## Що застаріло {#what-is-deprecated} + +| Застаріле | Чому | Що робити натомість | +|---|---|---| +| **Кореневі каталоги (roots)**: `ctx.session.list_roots()`, `client.send_roots_list_changed()`, колбек `list_roots_callback=`, який передають у `Client(...)` | [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) виводить цю можливість з ужитку. | Приймайте шляхи як звичайні аргументи інструмента чи URI ресурсів або вбудуйте `ListRootsRequest` в `InputRequiredResult` (див. **[Багатораундові запити (multi-round-trip)](handlers/multi-round-trip.md)**). | +| **Семплювання (sampling) з ініціативи сервера**: `ctx.session.create_message()`, колбек `sampling_callback=`, який передають у `Client(...)` | SEP-2577 виводить цю можливість з ужитку. | Повертайте `InputRequiredResult`, і нехай клієнт повторить виклик (див. **[Багатораундові запити](handlers/multi-round-trip.md)**). | +| **Протокольне логування**: `ctx.log()`, `ctx.debug()`, `ctx.info()`, `ctx.warning()`, `ctx.error()`, `ctx.session.send_log_message()`, `client.set_logging_level()` | SEP-2577 виводить цю можливість з ужитку. У протоколі її ніщо не замінює. | Звичайний `import logging` у stderr (див. **[Логування](handlers/logging.md)**). | +| **`ping`**: `client.send_ping()` | **Вилучено** з протоколу, а не просто оголошено застарілим. У 2026-07-28 методу `ping` немає. | Нічого. Він працює лише на з'єднанні з `mode="legacy"`. | +| **Перебіг виконання від клієнта до сервера**: `client.send_progress_notification()` | У 2026-07-28 перебіг виконання передається лише від сервера до клієнта. | Надсилати нічого. Про перебіг виконання звітує ваш *сервер* через `ctx.report_progress()` (див. **[Перебіг виконання](handlers/progress.md)**). | + +З цієї таблиці випливають три речі: + +* Кореневі каталоги, семплювання й логування йдуть разом. Одна пропозиція, **SEP-2577**, оголошує застарілими всі три можливості одразу. +* Семплювання й кореневі каталоги мають спільну глибшу проблему: це місця, де **сервер** надсилає **запит** **клієнту**. Саме цей напрямок цілком 2026-07-28 замінює на **[багатораундові запити](handlers/multi-round-trip.md)**. Зникли окремі RPC-методи (`sampling/createMessage`, `roots/list` і push-варіант `elicitation/create`); типи корисного навантаження `CreateMessageRequest` / `ListRootsRequest` / `ElicitRequest` залишаються — вбудовані в `InputRequiredResult.input_requests`, а на клієнті вони потрапляють у ті самі колбеки. +* `ping` стоїть осторонь. Протокол не оголошує його застарілим, а вилучає. Метод SDK і далі попереджає (у його повідомленні сказано *removed*, а не *deprecated*), а виклик на сучасному з'єднанні отримує у відповідь *«Method not found»*. + +## Застарілість має рекомендаційний характер {#deprecated-is-advisory} + +Сьогодні нічого не ламається. + +Кожен із наведених методів і далі працює з будь-якою сесією, що узгодила **2025-11-25 або ранішу версію**. Зафіксуйте `mode="legacy"` на клієнті — і отримаєте точнісінько ту поведінку, що була до 2026. У переданих даних нічого не змінюється, узгодження можливостей теж без змін. + +Змінюється те, що під час першого виконання кожного з них з'являється помітне попередження: + +```text +MCPDeprecationWarning: The logging capability is deprecated as of 2026-07-28 (SEP-2577). +``` + +`MCPDeprecationWarning` успадковує `UserWarning`, а **не** `DeprecationWarning`. Це навмисно: типовий фільтр Python показує `DeprecationWarning` лише в коді, запущеному безпосередньо як `__main__`, — саме так бібліотеки оголошують щось застарілим, і два роки ніхто цього не помічає. Це попередження видно всюди, без жодного прапорця `-W`. + +!!! warning + «Рекомендаційний характер» закінчується на рівні переданих даних. Семплювання й кореневі + каталоги — це *запити* від сервера до клієнта, а сесія 2026-07-28 не має каналу, яким + їх можна передати. Викличте `ctx.session.create_message()` усередині інструмента на + сучасному з'єднанні — попередження все одно спрацює, а потім надсилання завершиться + помилкою: + + ```text + Cannot send 'sampling/createMessage': this transport context has no back-channel + for server-initiated requests. + ``` + + Два сигнали, саме в такому порядку. `MCPDeprecationWarning` спрацьовує тієї ж миті, коли + ви викликаєте метод, на будь-якому з'єднанні. Помилка — це те, що повертається, коли SDK + потім намагається надіслати запит. Ці два методи працюють від початку до кінця лише на + з'єднанні з `mode="legacy"`, клієнт якого зареєстрував відповідний колбек. + +## Приглушення попередження {#silencing-the-warning} + +У новому коді — не робіть цього. + +Але сервер, який ви підтримуєте і який справді обслуговує клієнтів до 2026, має повне право на тихий лог. Відфільтруйте категорію до того, як виконається перший застарілий виклик: + +```python +import warnings + +from mcp import MCPDeprecationWarning + +warnings.filterwarnings("ignore", category=MCPDeprecationWarning) +``` + +Оце й увесь API. Перемикача для окремих методів немає, і він вам не потрібен: сенс однієї категорії в тому, що один рядок її приглушує, а один рядок повертає. + +!!! check + Розверніть фільтр у зворотний бік — і отримаєте безкоштовний регресійний тест. Додайте + `"error::mcp.MCPDeprecationWarning"` до налаштування `filterwarnings` у конфігурації + pytest — і застарілий виклик **викидатиме виняток** замість попередження. Інструмент + з назвою `old_log`, який досі викликає `ctx.info()`, перестає проходити тест і починає + повідомляти: + + ```text + Error executing tool old_log: The logging capability is deprecated as of 2026-07-28 (SEP-2577). + ``` + + Один рядок конфігурації pytest — і застарілий виклик більше ніколи не прокрадеться назад + у вашу кодову базу, не проваливши тест. + +## Підсумки {#recap} + +* Специфікація 2026-07-28 оголошує застарілими **кореневі каталоги**, **семплювання** з ініціативи сервера та протокольне **логування** (усе — [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), обмежує **перебіг виконання** напрямком від сервера до клієнта й вилучає **`ping`**. +* Стовпець із замінами вказує, куди рухатися далі: **[Багатораундові запити](handlers/multi-round-trip.md)** для семплювання й кореневих каталогів, **[Логування](handlers/logging.md)** для логування, **[Перебіг виконання](handlers/progress.md)** для перебігу виконання. `ping` не потребує взагалі нічого. +* Застарілість має рекомендаційний характер: жодних змін у переданих даних, усе й далі працює із сесіями до 2026, а ви отримуєте помітне попередження `MCPDeprecationWarning` (це `UserWarning`, тож воно ввімкнене за замовчуванням). +* Семплювання й кореневі каталоги додатково потребують зворотного каналу (back-channel), якого сесія 2026-07-28 не має. На сучасному з'єднанні вони попереджають, а потім викидають виняток. +* `warnings.filterwarnings("ignore", category=MCPDeprecationWarning)` приглушує всю категорію; `"error::mcp.MCPDeprecationWarning"` у pytest перетворює її на провал тесту. +* Новий код не варто будувати на жодній із цих можливостей. + +Усі інші сторінки цієї документації навчають чинного API. diff --git a/i18n/uk/pages/get-started/first-steps.md b/i18n/uk/pages/get-started/first-steps.md new file mode 100644 index 0000000000..2b711b43e7 --- /dev/null +++ b/i18n/uk/pages/get-started/first-steps.md @@ -0,0 +1,144 @@ +--- +translation: + sections: [0d6c05bcbf836bf3, 59a7b14eeefc68c1, 7114d8d6daba203f, e8bbb56a98ba7bc9, 5138010f6159901c, f78da7c7c363d4c6, 220a939cab348686] + tool: 1 +--- +# Перші кроки {#first-steps} + +**[Головна сторінка](../index.md)** рухається швидко: написати сервер, запустити його, викликати інструмент. + +Ця сторінка йде повільніше: усі три речі, які може надавати сервер, і назва для всього, що трапиться дорогою. + +## Хост, клієнт і сервер {#host-client-and-server} + +Три слова, які ви бачитимете на кожній сторінці відтепер: + +* **Хост** — це LLM-застосунок: Claude, IDE, середовище виконання агентів. Це те, з чим говорить користувач. +* **Клієнт** живе всередині хоста й говорить мовою MCP. Хост запускає по одному клієнту на кожен сервер, до якого під'єднаний. +* **Сервер** — це те, що ви створюєте за допомогою цього SDK. Він надає речі клієнтам. Він ніколи не говорить із моделлю напряму. + +Ви пишете сервер. Хости — це чийсь інший продукт. SDK також дає вам `Client`. Ним ви тестуватимете свої сервери, і він з'явиться далі на цій сторінці. + +## Три примітиви {#the-three-primitives} + +Сервер надає рівно три види речей. Відрізняє їх те, **хто вирішує їх використати**: + +| Примітив | Хто керує | Що це таке | Приклад | +|---------------|-----------------|-----------------------------------------------------------------|------------------------------------| +| **Інструменти** | Модель | Функція, яку модель викликає, щоб виконати дію | Виклик API, запис у базу даних | +| **Ресурси** | Застосунок | Дані, які хост завантажує в контекст моделі | Вміст файлу, відповідь API | +| **Промпти** | Користувач | Багаторазовий шаблон повідомлення, який користувач викликає за назвою | Слеш-команда, пункт меню | + +«Хто керує» — у цьому й увесь сенс поділу. Інструмент запускається, бо **модель** вирішила його викликати. Ресурс долучається, бо **застосунок** вирішив, що він потрібен моделі. Промпт запускається, бо його обрав **користувач**. + +!!! info + Якщо ви вже створювали веб-API, більша частина інтуїції у вас є: **ресурс** — це `GET` + (завантажує дані й нічого не змінює), а **інструмент** — це `POST` (виконує роботу й може мати + побічні ефекти). **Промпт** не має HTTP-аналога; він ближчий до збереженого запиту, який + користувач запускає за назвою. + +## Один сервер, усі три {#one-server-all-three} + +```python title="server.py" hl_lines="6 12 18" +--8<-- "docs_src/first_steps/tutorial001.py" +``` + +Три звичайні функції, три декоратори. Кожен декоратор — це вся реєстрація: + +* `@mcp.tool()` робить `add` **інструментом**. +* `@mcp.resource("greeting://{name}")` робить `greeting` **шаблоном ресурсу**: `{name}` в URI — це параметр функції. +* `@mcp.prompt()` робить `summarize` **промптом**. Рядок, який він повертає, стає повідомленням користувача. + +Усе інше (назву, опис, схему аргументів) SDK зчитує із самої функції: її назви, докстрингу, анотацій типів. Ви нічого з цього не оголошували окремо. + +!!! tip + Дві половини SDK мають два шляхи імпорту: `from mcp import Client` і + `from mcp.server import MCPServer`. Шляху `from mcp import MCPServer` не існує. + +### Спробуйте самі {#try-it} + +Запустіть його за допомогою MCP Inspector: + +```console +uv run mcp dev server.py +``` + +Відкрийте URL, який він надрукує. В Inspector є по одній вкладці на кожен примітив; пройдіть їх по черзі. + +**Tools.** Один запис: `add` з описом *Add two numbers.* Форма має обов'язкове цілочислове поле для `a` і ще одне для `b`. Заповніть їх, викличте інструмент, і результатом буде `3`. Inspector побудував цю форму з `a: int, b: int`. Так само робить і будь-який інший клієнт. + +**Resources.** Список *Resources* порожній. `greeting` розташований у **Resource Templates**, бо `greeting://{name}` має параметр: немає жодного конкретного ресурсу, який можна показати в списку, поки хтось не вкаже `name`. Передайте `World` і прочитайте: + +```text +Hello, World! +``` + +**Prompts.** Один запис: `summarize` з єдиним обов'язковим аргументом `text`. Отримайте його з якимось текстом — і повернеться одне повідомлення з `role: user` та вашим відрендереним рядком як вмістом. Оце й увесь промпт: функція, яка будує повідомлення. + +Inspector запустив ваш сервер через **stdio**, один із транспортів, якими може говорити MCP-сервер. Поки що обирати транспорт не потрібно; для цього є сторінка **[Запуск сервера](../run/index.md)**. + +## Можливості {#capabilities} + +В Inspector ви бачили три вкладки. Звідки він знав, що їх три? + +Коли клієнт під'єднується, сервер оголошує свої **можливості**: на які сімейства запитів він відповідатиме. Клієнт використовує це оголошення, щоб вирішити, про що взагалі просити. Ви його не писали; `MCPServer` оголошує його за вас. + +Подивіться самі. `Client` з SDK приймає об'єкт сервера напряму й під'єднується до нього **в пам'яті** (без підпроцесу, без порту): + +```python +import asyncio + +from mcp import Client + +from server import mcp + + +async def main() -> None: + async with Client(mcp) as client: + print(client.server_capabilities.model_dump(exclude_none=True)) + + +asyncio.run(main()) +``` + +```text +{'prompts': {'list_changed': True}, 'resources': {'subscribe': True, 'list_changed': True}, 'tools': {'list_changed': True}} +``` + +Цей словник — оголошені **можливості** вашого сервера. Це перше, про що дізнається кожен клієнт, що під'єднується: + +| Можливість | Клієнт тепер може викликати | +|-------------|------------------------------------------------------------| +| `tools` | `tools/list`, `tools/call` | +| `resources` | `resources/list`, `resources/templates/list`, `resources/read` | +| `prompts` | `prompts/list`, `prompts/get` | + +`MCPServer` обслуговує всі три примітиви, тож усі три завжди оголошено. + +Зверніть увагу на те, чого там немає. `completions` (автодоповнення аргументів для шаблонів ресурсів і промптів) потребує обробника, який пишете ви; у цього сервера його немає, тож можливість відсутня, і коректний клієнт про неї не проситиме. Це правило для всього необов'язкового: зареєструйте річ — і можливість з'явиться; **[Автодоповнення](../servers/completions.md)** це доводить. + +!!! info + `Client(mcp)` — той самий клієнт у пам'яті, яким протестовано кожен приклад у цій документації, і + саме ним ви тестуватимете свої. Йому присвячено цілу сторінку: **[Тестування](testing.md)**. + +## Чого ви не писали {#what-you-did-not-write} + +Озирніться на цю сторінку. Ви написали три невеликі функції Python. Ви **не** писали: + +* JSON Schema. `a: int, b: int` *і є* схема для `add`. +* Обробник запитів. `tools/list`, `resources/read`, `prompts/get`: усе обслуговується за вас. +* Оголошення можливостей. `MCPServer` зробив його за вас. +* Жодного рядка протоколу. Узгодження версії, обрамлення JSON-RPC, обмін можливостями: усе це відбулося всередині `mcp dev` і `Client(mcp)`, і ви цього не бачили. + +У цьому співвідношенні й увесь сенс SDK. + +## Підсумки {#recap} + +* **Хост** — це LLM-застосунок, **клієнт** — його половина, що говорить мовою MCP, **сервер** — те, що ви створюєте. +* Інструментами керує **модель**, ресурсами — **застосунок**, промптами — **користувач**. +* Один декоратор на примітив: `@mcp.tool()`, `@mcp.resource(uri)`, `@mcp.prompt()`. Назва, опис і схема беруться з функції. +* URI з `{param}` створює **шаблон** ресурсу, який показується окремо від конкретних ресурсів. +* **Можливості** сервера оголошуються за вас, а клієнт просить лише те, що сервер оголосив. +* `Client(mcp)` під'єднується до об'єкта сервера в пам'яті: ваш тестовий стенд із першого дня. + +Далі — **[Під'єднання до справжнього хоста](real-host.md)**: цей сервер усередині Claude Desktop або IDE, по-справжньому. Потім **[Тестування](testing.md)**: одна сторінка, один клієнт у пам'яті — і більше ніколи не доведеться гадати, чи воно працює. Після цього кожен примітив отримує власну сторінку, починаючи з того, яким керує модель: **[Інструменти](../servers/tools.md)**. diff --git a/i18n/uk/pages/get-started/index.md b/i18n/uk/pages/get-started/index.md new file mode 100644 index 0000000000..f5a13e8445 --- /dev/null +++ b/i18n/uk/pages/get-started/index.md @@ -0,0 +1,53 @@ +--- +translation: + sections: [ed4a756b4c53c585, 97e2fb315b7fe398, 4d04f1c6f4bf6c1d, 577d73078fc62baf] + tool: 1 +--- +# Початок роботи {#get-started} + +Уперше маєте справу з MCP або з цим SDK? Почніть звідси. Ці сторінки проведуть від нуля до робочого, протестованого сервера: [встановіть SDK](installation.md), напишіть свій [перший сервер](first-steps.md), [під'єднайте його до справжнього хоста](real-host.md) і [протестуйте](testing.md) за допомогою клієнта в пам'яті. + +## Запуск коду {#run-the-code} + +Усі блоки коду можна копіювати й використовувати як є: це повні робочі файли. + +Щоб іти слідом за викладом, вставте блок у файл `server.py` і відкрийте його в MCP Inspector: + +```console +uv run mcp dev server.py +``` + +**НАПОЛЕГЛИВО радимо** набрати (або скопіювати) код, відредагувати його й запустити локально. Саме робота у власному редакторі показує суть: як мало доводиться писати, автодоповнення, перевірка типів, що ловить помилки ще до запуску. + +## Гадати не доведеться {#you-will-not-be-guessing} + +Кожен приклад у цій документації — повний файл у каталозі [`docs_src/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/docs_src) репозиторію самого SDK, і кожен із них проганяється тестовим набором SDK через **клієнт у пам'яті**: + +```python +import pytest +from mcp import Client + +from server import mcp + + +@pytest.mark.anyio +async def test_add() -> None: + async with Client(mcp) as client: + result = await client.call_tool("add", {"a": 1, "b": 2}) + assert result.structured_content == {"result": 3} +``` + +Ні підпроцесу, ні порту, ні транспорту. `Client(mcp)` під'єднується безпосередньо до об'єкта сервера. + +Якщо зміна в SDK зламає приклад на одній із цих сторінок, CI почервоніє раніше, ніж сторінка. Код, який ви тут читаєте, — це код, який виконується. + +Ви самі скористаєтеся цим на сторінці [Тестування](testing.md): власні сервери тестують так само. + +## Куди далі {#where-to-go-next} + +Щойно сервер запрацює, решта цієї документації — довідник, а не курс. Кожна сторінка самодостатня, тож переходьте одразу до потрібного: + +* Те, що сервер надає назовні (інструменти, ресурси, промпти), — це **[Сервери](../servers/index.md)**. +* Те, що доступне всередині функцій, які ви реєструєте, — це **[Усередині обробника](../handlers/index.md)**. +* Як донести сервер до клієнтів (stdio, HTTP, ваш наявний FastAPI-застосунок) — це **[Запуск сервера](../run/index.md)**. +* Побудова іншої сторони — застосунку, що *використовує* MCP-сервери, — це **[Клієнти](../client/index.md)**. diff --git a/i18n/uk/pages/get-started/installation.md b/i18n/uk/pages/get-started/installation.md new file mode 100644 index 0000000000..e560423d7b --- /dev/null +++ b/i18n/uk/pages/get-started/installation.md @@ -0,0 +1,47 @@ +--- +translation: + sections: [6e2f9bab94d5ed36, 8cf653388f69e28b, 6fd9ea2f65de0df6] + tool: 1 +--- +# Встановлення {#installation} + +Python SDK опубліковано на PyPI як [`mcp`](https://pypi.org/project/mcp/). Потрібен **Python 3.10+**. + +Ця документація описує **v2** — поточну стабільну лінійку випусків: + +=== "uv" + + ```bash + uv add "mcp[cli]" + ``` + +=== "pip" + + ```bash + pip install "mcp[cli]" + ``` + +!!! note "Переходите з v1?" + v2 — мажорна версія з несумісними змінами; **[Посібник з міграції](../migration.md)** + описує кожну з них. Якщо ваш *пакет* залежить від `mcp` і ще не готовий до міграції, залиште + верхню межу `<2` (наприклад, `mcp>=1.28,<2`), щоб розв'язання залежностей без фіксованої версії залишалося на лінійці 1.x. + +## Що встановлюється {#what-gets-installed} + +Щоб користуватися SDK, нічого з цього знати не потрібно, але якщо цікаво, навіщо кожна залежність: + +* `mcp-types`: усі типи протоколу (запити, результати, блоки вмісту) окремим пакетом, версія якого йде в ногу з SDK. Код, що залежить від `mcp`, імпортує його через псевдонім `mcp.types` (кожне `from mcp.types import ...` у цій документації); імпортуйте `mcp_types` напряму лише в проєкті, який встановлює `mcp-types` без SDK. +* [`anyio`](https://anyio.readthedocs.io/): асинхронне середовище виконання. Увесь SDK написано поверх anyio, тож він працює і на `asyncio`, і на `trio`. +* [`pydantic`](https://docs.pydantic.dev/): основа кожної моделі в `mcp.types`, а також уся генерація схем і валідація. +* [`httpx2`](https://pypi.org/project/httpx2/): HTTP-клієнт, на якому працюють *клієнтські* транспорти Streamable HTTP і SSE, із вбудованою підтримкою server-sent events. +* [`starlette`](https://www.starlette.io/), [`uvicorn`](https://www.uvicorn.org/), [`sse-starlette`](https://pypi.org/project/sse-starlette/) і [`python-multipart`](https://pypi.org/project/python-multipart/): *серверні* HTTP-транспорти. +* [`jsonschema`](https://pypi.org/project/jsonschema/): перевіряє структурований вивід інструмента на відповідність оголошеній схемі виводу. +* [`pyjwt[crypto]`](https://pyjwt.readthedocs.io/): робота з OAuth-токенами для авторизації. +* [`opentelemetry-api`](https://opentelemetry-python.readthedocs.io/): лише легкий API, тож middleware трасування в SDK нічого не коштує, доки ви самі не встановите OpenTelemetry SDK і експортер. +* [`typing-extensions`](https://typing-extensions.readthedocs.io/) і [`typing-inspection`](https://pypi.org/project/typing-inspection/): сучасні можливості типізації на Python 3.10. +* [`pywin32`](https://pypi.org/project/pywin32/): лише для Windows, використовується для керування підпроцесами `stdio`. + +## Необов'язкові доповнення {#optional-extras} + +* `mcp[cli]` додає [`typer`](https://typer.tiangolo.com/) і [`python-dotenv`](https://pypi.org/project/python-dotenv/) для інструмента командного рядка `mcp` (`mcp dev`, `mcp run`, `mcp install`). Під час розробки він знадобиться; на розгорнутому сервері може бути зайвим. +* `mcp[rich]` додає [`rich`](https://rich.readthedocs.io/) для охайніших логів сервера. diff --git a/i18n/uk/pages/get-started/real-host.md b/i18n/uk/pages/get-started/real-host.md new file mode 100644 index 0000000000..f9ad946903 --- /dev/null +++ b/i18n/uk/pages/get-started/real-host.md @@ -0,0 +1,184 @@ +--- +translation: + sections: [3c4f2f06b4e978b6, 22520eecae3d1961, f4e1709db18d635a, 2eb57992049671d9, 1ba83e9af37cc1b4, 4822586344b08d9e, 1c93afef72478992, b6b448f9eddd51dc, fe55370fd931815b] + tool: 1 +--- +# Підключення до справжнього хоста {#connect-to-a-real-host} + +**Хост** — це застосунок, усередині якого зрештою опиняється ваш сервер: Claude Desktop, Claude Code, IDE. Саме з хостом говорить користувач. Усередині нього MCP-**клієнт** запускає ваш сервер як дочірній процес і спілкується з ним через stdin і stdout цього процесу. + +Отже, підключення до хоста — це одна дія: ви повідомляєте йому **команду, яка запускає ваш сервер**. Усе на цій сторінці (дві команди CLI, три JSON-файли) — це різні місця, куди вписати ту саму команду. + +## Один сервер, усі хости {#one-server-every-host} + +```python title="server.py" hl_lines="3 33-34" +--8<-- "docs_src/real_host/tutorial001.py" +``` + +Два інструменти й один ресурс в одному файлі. Для кожного хоста нижче в цьому файлі важливі три речі: + +* `mcp.run()` без аргументів запускає **stdio**-сервер: він блокує виконання, читає повідомлення протоколу зі stdin і пише їх у stdout. Саме цим транспортом говорить кожен хост на цій сторінці. Хост запускає ваш файл як дочірній процес і володіє цими двома каналами, тому підключення завжди зводиться до «ось команда». Порт обирати не потрібно, і ніщо на порту не слухає. +* `run()` стоїть під `if __name__ == "__main__":`. Усе, що нижче, **імпортує** цей файл, а не виконує його, тож незахищений `run()` запустив би сервер, щойно будь-що завантажить модуль. +* Об'єкт сервера — глобальна змінна рівня модуля з іменем `mcp`. Саме це ім'я шукає `mcp run` (`server` і `app` теж підходять). Назвете інакше — вкажіть ім'я явно: `mcp run server.py:bookshop`. + +Це останній рядок Python на цій сторінці. Далі — лише налаштування хостів. + +## Команда запуску {#the-launch-command} + +Кожен хост нижче отримує ту саму команду: + +```bash +uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py +``` + +Одна команда для всіх, бо `uv run --with` розв'язує SDK у свіже середовище на місці: вона працює з будь-якого каталогу й не потребує ні проєкту, ні віртуального середовища, яке треба активувати. Тут це важливіше, ніж деінде, бо хост запускає ваш сервер зі *свого* робочого каталогу з майже порожнім середовищем, а не з вашої оболонки. + +Це також команда, яку `mcp install` записує за вас у конфігурацію Claude Desktop (нижче), тож те, що ви набираєте вручну, і те, що генерує утиліта, збігаються — за винятком точної фіксації версії, яку додає утиліта. + +!!! tip "Якщо хост не може знайти `uv`" + Хост породжує ваш сервер із мінімальним `PATH`, і `uv` у ньому може не бути. Замініть + просто `uv` абсолютним шляхом з `which uv` (macOS/Linux) або `where uv` (Windows). Саме це + й записує `mcp install`. + +!!! note "Ця сторінка — про локальний сценарій" + Усе тут запускає ваш сервер на тій самій машині, де працює хост: хост запускає ваш + файл через stdio. Для особистого інструмента або інструмента на одній машині це саме те, + що треба. Щоб дати сервер людям, у яких *немає* вашого файлу, роздають **URL**, а не + команду: той самий об'єкт `mcp`, що обслуговується через Streamable HTTP. **[Запуск сервера](../run/index.md)** + зводить це рішення до однієї таблиці, а **[Розгортання й масштабування](../run/deploy.md)** — + це шлях звідти до справжнього імені хоста. + + А хост — це не більше ніж застосунок з MCP-клієнтом усередині, тож роль хоста може + зіграти й ваш власний Python: сторінка **[Транспорти клієнта](../client/transports.md)** запускає + цей самий файл як підпроцес через `stdio_client(...)`, а **[Тестування](testing.md)** + підключається до нього в пам'яті взагалі без процесу. + +## Claude Desktop {#claude-desktop} + +Єдиний хост, який SDK може налаштувати за вас: + +```bash +uv run mcp install server.py +``` + +От і все. `mcp install` імпортує файл, щоб прочитати ім'я сервера, знаходить файл конфігурації Claude Desktop і записує в нього команду запуску. Дорогою вона перетворює ваш шлях на абсолютний, тож робити це самим не потрібно. + +Жодної магії тут немає. Ось запис, який вона створює: + +```json +{ + "mcpServers": { + "Bookshop": { + "command": "/absolute/path/to/uv", + "args": [ + "run", + "--frozen", + "--with", + "mcp[cli]==2.0.0", + "mcp", + "run", + "/absolute/path/to/server.py" + ] + } + } +} +``` + +Це команда запуску з розділу вище з трьома доповненнями: абсолютний шлях до `uv`, `--frozen`, щоб `uv` ніколи не переписував lock-файл, який випадково опиниться поруч, і точна фіксація встановленої у вас версії `mcp`. Запис потрапляє в `claude_desktop_config.json`, який лежить тут: + +* **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` +* **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` + +Цей файл можна написати й вручну. `mcp install` існує, щоб ви не припустилися класичної помилки (відносного шляху), поки це робите. + +Повністю завершіть Claude Desktop (а не лише закрийте вікно) і відкрийте знову. + +!!! warning + `mcp install` завершується помилкою `Claude app not found`, якщо *каталогу* конфігурації + Claude Desktop ще немає. Встановіть Claude Desktop і запустіть його один раз: саме це й + створює каталог. + +!!! tip + Claude Desktop запускає ваш сервер у власному процесі, тож змінних середовища вашої + оболонки там немає. `uv run mcp install server.py -v API_KEY=abc123` (або `-f .env`) записує + їх у поле `env` запису. `--name` перевизначає ім'я запису; за замовчуванням це `name` сервера. + +## Claude Code {#claude-code} + +Файлу для редагування немає. Зареєструйте сервер через CLI `claude`; усе після `--` — це команда запуску. + +```bash +claude mcp add bookshop -- uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py +``` + +Виконайте `/mcp` у сесії Claude Code, щоб переконатися, що `bookshop` підключено, а його інструменти перелічено. + +## Cursor {#cursor} + +Створіть `.cursor/mcp.json` у корені проєкту. + +```json +{ + "mcpServers": { + "bookshop": { + "command": "uv", + "args": ["run", "--with", "mcp[cli]", "mcp", "run", "/absolute/path/to/server.py"] + } + } +} +``` + +Ті самі `command` та `args` під тим самим ключем `mcpServers`, що й у Claude Desktop. Сервер з'являється в налаштуваннях MCP у Cursor з обома інструментами в списку. + +## VS Code {#vs-code} + +Створіть `.vscode/mcp.json` у корені проєкту. + +```json +{ + "servers": { + "bookshop": { + "type": "stdio", + "command": "uv", + "args": ["run", "--with", "mcp[cli]", "mcp", "run", "/absolute/path/to/server.py"] + } + } +} +``` + +Дві відмінності від файлу Cursor, і це єдині дві: ключ-обгортка — `servers`, а не `mcpServers`, і кожен запис оголошує свій `type`. Підтвердьте запит про довіру, і команда **MCP: List Servers** у палітрі команд покаже, що `bookshop` працює. + +!!! note + Потрібен VS Code 1.99 або новіший із розширенням **GitHub Copilot**, у якому виконано вхід + (достатньо Copilot Free), а Copilot Chat має бути в режимі **Agent**, бо жоден інший режим + не викликає інструменти. + +## Сервер не з'являється {#it-doesnt-show-up} + +Перш ніж чіпати конфігурацію будь-якого хоста, виконайте команду запуску самі: + +```bash +uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py +``` + +Нічого не виводиться, і команда не завершується. Ця тиша правильна: stdio-сервер чекає, доки хост першим заговорить у stdin (`Ctrl-C`, щоб зупинити). Справжня помилка — це трасування стека або негайний вихід, і тепер її можна прочитати, а не вгадувати через хост. + +Коли ця команда сидить і чекає, залишається майже завжди одне з трьох: + +* **Відносний шлях.** Хост запускає ваш сервер зі *свого* робочого каталогу, а не з того, з якого ви його реєстрували. `server.py` там, де потрібен `/absolute/path/to/server.py`, — найпоширеніша причина збою. Якщо хост не знаходить ще й `uv`, цей шлях теж має бути абсолютним. +* **Хост досі працює зі старою конфігурацією.** Хости читають конфігурацію під час запуску. Зокрема Claude Desktop треба *повністю завершити* (а не лише закрити вікно) і відкрити знову, перш ніж зміна в `claude_desktop_config.json` набуде чинності. +* **Щось потрапило в stdout поза проміжком перенаправлення.** У stdio stdout — це *і є* протокол. Під час обслуговування SDK перенаправляє скинутий (flushed) сторонній вивід у stderr, але вивід, скинутий у stdout до того (скрипт-обгортка, що робить echo, `print()` на етапі імпорту в небуферизованому процесі), або буферизований `print()`, що зливається під час завершення інтерпретатора, передає хосту зіпсоване повідомлення, і той розриває з'єднання. Пишіть логи зі стандартною конфігурацією `logging`, чий обробник stderr скидає кожен запис; власні обробники теж мають уникати stdout. Докладніше — на сторінці **[Логування](../handlers/logging.md)**. + +Claude Desktop веде окремий лог для кожного сервера: `mcp-server-.log` — це stderr вашого сервера, поруч із `mcp.log` для з'єднань, у `~/Library/Logs/Claude` на macOS і `%APPDATA%\Claude\logs` на Windows. + +Для всього, що виходить за межі цих трьох випадків, є сторінка **[Усунення несправностей](../troubleshooting.md)**. + +## Підсумки {#recap} + +* **Хост** (Claude Desktop, IDE) виконує MCP-клієнт, який запускає ваш сервер як дочірній процес через stdio. Підключитися означає дати йому одну команду запуску. +* Ця команда — `uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py`: жодного venv для активації, працює з будь-якого каталогу. +* **Claude Desktop** — єдиний хост, який `mcp install` налаштовує за вас. Вона записує ту саму команду (плюс абсолютний шлях до `uv`, `--frozen` і точну фіксацію встановленої у вас версії) у `claude_desktop_config.json`, тож вам цього робити не доведеться. +* **Claude Code** — це `claude mcp add bookshop -- `. **Cursor** — `.cursor/mcp.json` з ключем `mcpServers`. **VS Code** — `.vscode/mcp.json` з ключем `servers`, кожен запис із `type`. +* Скрізь абсолютні шляхи, перезапуск хоста після редагування конфігурації, і ніщо, крім SDK, ніколи не пише в stdout. + +Кожен хост на цій сторінці підключився до того самого файлу тією самою командою. Про те, що цей файл може *надавати*, — решта цієї документації: **[Інструменти](../servers/tools.md)**, **[Ресурси](../servers/resources.md)** і всі транспорти, крім stdio, на сторінці **[Запуск сервера](../run/index.md)**. diff --git a/i18n/uk/pages/get-started/testing.md b/i18n/uk/pages/get-started/testing.md new file mode 100644 index 0000000000..6ec0797e4c --- /dev/null +++ b/i18n/uk/pages/get-started/testing.md @@ -0,0 +1,114 @@ +--- +translation: + sections: ['4926721070127497', c52a1de2b6b32f40, 2e410b412c25f314, 627195f7159e24ef] + tool: 1 +--- +# Тестування {#testing} + +Python SDK містить клас `Client` із **транспортом у пам'яті**: передайте йому об'єкт сервера — і він під'єднається до нього напряму. + +Жодного підпроцесу. Жодного порту. Узагалі жодного транспорту. Та сама ідея, що й `TestClient` у FastAPI. + +## Базове використання {#basic-usage} + +Припустімо, є простий сервер з одним інструментом: + +```python title="server.py" +--8<-- "docs_src/testing/tutorial001.py" +``` + +Щоб запустити тест нижче, знадобляться дві додаткові залежності (для розробки): + +=== "uv" + + ```bash + uv add --dev pytest inline-snapshot + ``` + +=== "pip" + + ```bash + pip install pytest inline-snapshot + ``` + +!!! info + Ця документація припускає, що ви вже знайомі з [`pytest`](https://docs.pytest.org/en/stable/). + + [`inline-snapshot`](https://15r10nk.github.io/inline-snapshot/latest/) — це те, чим тест нижче + перевіряє весь об'єкт результату одним рядком. Він записує вивід тесту у вигляді + літерала `snapshot(...)`, який ви бачите. Якщо не хочете ним користуватися, приберіть імпорт і + перевіряйте потрібні поля (`result.content[0].text == "3"`), як у будь-якому іншому тесті. + +Тепер сам тест: + +```python title="test_server.py" +import pytest +from inline_snapshot import snapshot +from mcp import Client +from mcp.types import CallToolResult, TextContent + +from server import mcp + + +@pytest.fixture +def anyio_backend(): # (1)! + return "asyncio" + + +@pytest.fixture +async def client(): # (2)! + async with Client(mcp, raise_exceptions=True) as c: + yield c + + +@pytest.mark.anyio +async def test_call_add_tool(client: Client): + result = await client.call_tool("add", {"a": 1, "b": 2}) + # Drop the server identity stamp in `_meta`; it is not what this test is about. + result.meta = None + assert result == snapshot( + CallToolResult( + content=[TextContent(type="text", text="3")], + structured_content={"result": 3}, + ) + ) +``` + +1. Якщо ви використовуєте `trio`, поверніть натомість `"trio"`. Подробиці — у [документації anyio](https://anyio.readthedocs.io/en/stable/testing.html#specifying-the-backends-to-run-on). +2. Фікстура віддає під'єднаного клієнта. Кожен тест, що приймає `client`, отримує нове з'єднання в пам'яті з тим самим сервером. + +Готово! Тепер можна розширювати тести, щоб охопити більше сценаріїв. + +## Навіщо `raise_exceptions=True`? {#why-raise_exceptionstrue} + +Піти не так можуть дві різні речі, і цей прапорець стосується лише однієї з них. + +Виняток усередині одного з **ваших інструментів** — це не збій протоколу. Він стає звичайним результатом +з `is_error=True`, і модель читає повідомлення. `raise_exceptions` цього не змінює: з ним чи +без нього `call_tool` повертає той самий результат з `is_error=True`. Про це є ціла сторінка: +**[Обробка помилок](../servers/handling-errors.md)**. + +Збій **поза** тілом інструмента — інша річ. На з'єднанні, яке дає `Client(mcp)`, сервер +замінює його загальним `"Internal server error"`, перш ніж його побачить клієнт. Ніколи не слід +розкривати подробиці неочікуваного падіння віддаленій стороні, що викликає. У тесті це саме те, +чого ви *не* хочете, і саме це змінює `raise_exceptions=True`: тест бачить справжнє повідомлення +замість узагальненого. + +Залишайте його ввімкненим у тестах. У робочому коді він не має сенсу. + +## У тому самому процесі за замовчуванням {#in-process-by-default} + +!!! note + `Client(mcp)` під'єднується в межах процесу й за замовчуванням **нейтральний щодо покоління**: він зондує сервер і + обирає відповідний шлях протоколу. Зафіксуйте `mode="legacy"`, якщо тест перевіряє семантику, специфічну для + старого покоління — push семплювання (sampling) чи еліцитації (elicitation), `message_handler`, — і приберіть там `raise_exceptions=True`: + з'єднання старого покоління взагалі нічого не узагальнює, а прапорець повторно викидає + збій усередині завдання сервера, а не у вашому тесті. + +Цей один рядок — ще й причина, чому ця документація може обіцяти, що її приклади працюють: кожен +файл прикладу проганяється власним набором тестів SDK, майже всі — саме через цей +клієнт. Ви користуєтеся тим самим інструментом, яким SDK перевіряє сам себе. + +У вас є робочий, протестований сервер. Як помістити його в справжній застосунок (Claude Desktop, +IDE) — на сторінці **[Під'єднання до справжнього хоста](real-host.md)**; усі інші способи його запустити — +у розділі **[Запуск сервера](../run/index.md)**. diff --git a/i18n/uk/pages/handlers/context.md b/i18n/uk/pages/handlers/context.md new file mode 100644 index 0000000000..16af347de5 --- /dev/null +++ b/i18n/uk/pages/handlers/context.md @@ -0,0 +1,134 @@ +--- +translation: + sections: [b50152f05c81e786, b302059b22fb7cb4, 85682a1bf561243a, 53fc48838eb6837a, b24190e0842786ec, 85f93e150fc9b240] + tool: 1 +--- +# Об'єкт Context {#the-context} + +Аргументи інструмента надходять від моделі. Усе інше (запит, який ви обслуговуєте, сервер, у якому живе інструмент, спосіб звернутися назад до клієнта) надходить з одного об'єкта: **`Context`**. + +Його не потрібно ні створювати, ні налаштовувати. Достатньо попросити. + +## Як його попросити {#ask-for-it} + +Додайте до будь-якого інструмента параметр з анотацією `Context`: + +```python title="server.py" hl_lines="2 8" +--8<-- "docs_src/context/tutorial001.py" +``` + +* SDK створює новий `Context` для кожного запиту й передає його у функцію. +* **Ім'я параметра не має значення**. `ctx`, `context`, `c`: SDK знаходить його за анотацією. +* Ресурси та промпти теж можуть оголосити такий параметр, у той самий спосіб. +* `ctx.request_id` — ідентифікатор запиту, який ваша функція обслуговує просто зараз. + +!!! info + Якщо ви працювали з FastAPI, цей прийом вам знайомий: оголошуєте параметр із власним типом + фреймворку (`Request` там, `Context` тут), і фреймворк його підставляє. Нічого реєструвати, + нічого налаштовувати: анотація типу — це й увесь механізм. + +### Невидимий для моделі {#invisible-to-the-model} + +Це те, що варто добре засвоїти. Ось схема вхідних даних, яку `tools/list` повідомляє для `search_books`: + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"} + }, + "required": ["query"], + "title": "search_booksArguments" +} +``` + +Одна властивість. `ctx` — не аргумент: він ніколи не з'являється у схемі, моделі про нього ніколи не повідомляють, і жоден клієнт не може його заповнити. Це домовленість між вами та SDK, якої не видно в переданих даних. + +### Спробуйте самі {#try-it} + +Запустіть сервер у MCP Inspector: + +```console +uv run mcp dev server.py +``` + +Форма для `search_books` має єдине поле `query`. Викличте інструмент зі значенням `dune`: + +```text +[request 3] Found 3 books matching 'dune'. +``` + +Число показує, яким за ліком виявився цей запит. Викличте інструмент ще раз — і воно зміниться: кожен запит отримує власний `Context`. + +## Що він дає {#what-it-gives-you} + +Впроваджений об'єкт невеликий. Окрім `request_id`: + +* `await ctx.read_resource(uri)`: прочитати один із **власних** ресурсів сервера зсередини інструмента. Про це — наступний розділ. +* `await ctx.report_progress(progress, total, message)`: передавати перебіг виконання тому, хто викликав, упродовж тривалого виклику. Докладніше — на сторінці **[Перебіг виконання](progress.md)**. +* `await ctx.elicit(message, schema)` та `await ctx.elicit_url(...)`: призупинити інструмент і поставити користувачеві запитання. Це **[Еліцитація](elicitation.md)** (elicitation). +* `ctx.session`: серверний бік розмови з цим клієнтом. Тут живуть сповіщення, які ви надсилаєте клієнтові; останній розділ ним користується. +* `ctx.headers`: заголовки запиту, які передав транспорт, або `None` для stdio. Прочитати власний заголовок можна так: `(ctx.headers or {}).get("x-...")`. Заголовки — це дані від клієнта: вони годяться для локалі чи прапорця функції, але ніколи — для ідентифікації особи. +* `ctx.request_context`: сирий запис про поточний запит. Поле, яке вам знадобиться, — `lifespan_context`, об'єкт, який ваш код запуску віддав через yield (див. **[Життєвий цикл (lifespan)](lifespan.md)**). + +Логування навмисно немає в цьому списку. Сервер пише логи модулем `logging` Python, як і будь-яка інша програма на Python. **[Логування](logging.md)** — коротка сторінка про те, чому саме так. + +!!! tip + Впровадження відбувається лише для функції, яку ви зареєстрували. Допоміжна функція, яку + викликає ваш інструмент, не отримує власного `Context`; передавайте `ctx` далі як звичайний + аргумент. Жодного фонового «поточного контексту», який можна було б дістати звідкись іще, немає. + +## Читання власних ресурсів {#read-your-own-resources} + +Ресурси сервера — не лише для клієнтів. Інструмент теж може їх читати: + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/context/tutorial002.py" +``` + +`ctx.read_resource` розв'язує URI через той самий реєстр, що обслуговує `resources/read`, тож інструмент отримує те саме, що отримав би клієнт: ітерований об'єкт із `ReadResourceContents`, по одному на кожен блок вмісту. Для цього URI він один: + +```python +contents.content # 'fiction, non-fiction, poetry' +contents.mime_type # 'text/plain' +``` + +* `content` — це рівно те, що повернула `genres()`. Одне джерело істини: клієнт переглядає ресурс, ваші інструменти його споживають, ніхто не копіює рядок. +* Єдиний параметр `describe_catalog` — це `Context`, тож його схема вхідних даних **не має жодної властивості**. Модель викликає його з `{}`. + +## Сповіщення клієнта про зміну списку {#tell-the-client-the-list-changed} + +Те, що пропонує сервер, не зафіксовано на момент імпорту. Зареєструйте інструмент під час виконання, а потім повідомте клієнта: + +```python title="server.py" hl_lines="15-16" +--8<-- "docs_src/context/tutorial003.py" +``` + +* `mcp.add_tool(recommend_book)` реєструє звичайну функцію як інструмент: ім'я, опис і схема виводяться точно так само, як це зробив би `@mcp.tool()`. +* `await ctx.session.send_tool_list_changed()` надсилає `notifications/tools/list_changed`. Клієнт, що його отримав, знову викликає `tools/list` і бачить `recommend_book`. + +Споріднені методи — `send_resource_list_changed()`, `send_prompt_list_changed()` і `send_resource_updated(uri)` для зміни одного конкретного ресурсу. + +На з'єднанні покоління 2026-07-28 клієнти отримують сповіщення про зміни лише в потоці `subscriptions/listen`, який вони самі відкрили, тож наведені вище методи `send_*` до цих потоків не доходять. Методи публікації `Context` доставляють сповіщення в усі підписані потоки одразу: `await ctx.notify_tools_changed()`, `await ctx.notify_prompts_changed()`, `await ctx.notify_resources_changed()` та `await ctx.notify_resource_updated(uri)`. Докладніше, зокрема про масштабування на кілька реплік, — на сторінці **[Підписки](subscriptions.md)**. + +!!! check + Поки ніхто не запустив `enable_recommendations`, обіцяного інструмента не існує. Викличте його + все одно — і результатом буде помилка, яку модель може прочитати: + + ```text + Unknown tool: recommend_book + ``` + + Запустіть `enable_recommendations` — і той самий виклик спрацює. Список інструментів справді + динамічний: `tools/list` відображає те, що зареєстровано *саме зараз*. + +## Підсумки {#recap} + +* Анотуйте параметр типом `Context` (в інструменті, ресурсі чи промпті) — і SDK його впровадить. Ім'я обираєте ви. +* Для моделі він невидимий: схема вхідних даних завжди містить лише ваші справжні аргументи. +* `ctx.request_id` ідентифікує запит; `ctx.request_context.lifespan_context` — те, що ваш код запуску віддав через yield. +* `await ctx.read_resource(uri)` дає інструменту змогу читати власні ресурси сервера. +* `ctx.session` — канал назад до клієнта: `send_tool_list_changed()` та споріднені методи кажуть йому заново отримати список, який ви змінили. +* Звітування про перебіг виконання та еліцитація теж починаються з `Context`; кожному присвячено окрему сторінку. + +Параметри, яких модель ніколи не бачить і які заповнюють ваші власні функції, — це **[Залежності](dependencies.md)**. diff --git a/i18n/uk/pages/handlers/dependencies.md b/i18n/uk/pages/handlers/dependencies.md new file mode 100644 index 0000000000..f95bb81f47 --- /dev/null +++ b/i18n/uk/pages/handlers/dependencies.md @@ -0,0 +1,168 @@ +--- +translation: + sections: [b0389403e98d25ad, e2cf58b43b285e86, a363e1a38e1a5971, 6cfac078feb18013, b4535bd61df337e6, e97ed44207f929fd] + tool: 1 +--- +# Залежності {#dependencies} + +Аргументи інструмента надходять від моделі. Деякі значення звідти надходити не повинні ніколи: ціна, знайдена у ваших записах; підтвердження, яке може дати лише людина; усе, що модель могла б зіпсувати, якби вигадала сама. + +**Залежності** — це параметри, які заповнюють ваші власні функції. Ви анотуєте параметр, указуєте функцію, а SDK викликає її до того, як запуститься інструмент. + +## Оголошення залежності {#declare-one} + +Загорніть тип параметра в `Annotated[...]` і додайте `Resolve(fn)`: + +```python title="server.py" hl_lines="18-19 23" +--8<-- "docs_src/dependencies/tutorial001.py" +``` + +* `check_stock` — це **резолвер**: звичайна функція, яку SDK запускає перед `reserve_book` і чиє повернене значення стає аргументом `stock`. +* Її параметр `title` — це власний аргумент `title` інструмента, зіставлений **за ім'ям**. Резолвер бачить рівно те саме валідоване значення, яке побачить тіло інструмента. +* Тіло інструмента починає з уже готового `Stock`. Жодного коду пошуку в інструменті, жодної преамбули на кшталт «а якщо його немає». + +!!! info + Якщо ви працювали з FastAPI, це `Depends`. Той самий хід, та сама причина: функція оголошує, + що їй потрібно, фреймворк це надає, а зв'язування живе в анотації типу. + +### Невидимий для моделі {#invisible-to-the-model} + +Ось вхідна схема, яку `tools/list` повідомляє для `reserve_book`: + +```json +{ + "type": "object", + "properties": { + "title": {"title": "Title", "type": "string"} + }, + "required": ["title"], + "title": "reserve_bookArguments" +} +``` + +Одна властивість. Як і `Context` на сторінці **[Об'єкт Context](context.md)**, параметр із резолвером — це контракт між вами й SDK: `stock` у схемі немає, моделі про нього ніколи не повідомляють, а клієнта, який усе одно надсилає значення `stock`, ігнорують. Значення від резолвера — єдине, яке може отримати ваш інструмент. + +У цьому останньому й суть. Параметр, який модель не може передати, — це параметр, у якому модель не може помилитися. + +### Спробуйте самі {#try-it} + +Запустіть сервер з MCP Inspector: + +```console +uv run mcp dev server.py +``` + +Форма для `reserve_book` має єдине поле `title`. `stock` на ній ніде немає. Викличте інструмент із `Dune`: + +```text +Reserved 'Dune' (6 copies left). +``` + +Тіло інструмента нічого не шукало: спершу виконався `check_stock`, і повернений ним `Stock` прийшов як аргумент. Спробуйте `Neuromancer` — той самий резолвер передасть інструменту нуль. + +!!! tip + Можна було б просто викликати `check_stock(title)` у тілі інструмента. Оголошуйте залежність + тоді, коли значення заслуговує на більше, ніж виклик допоміжної функції: кожен інструмент, якому + потрібен залишок, оголошує той самий параметр, а SDK запускає резолвер щонайбільше один раз на + виклик, незалежно від того, скільки разів його оголошено. Наступні розділи додають решту: + резолвери, що залежать один від одного, і резолвери, що запитують користувача. + +## Залежності залежностей {#dependencies-of-dependencies} + +Резолвер може оголошувати власні залежності тією самою анотацією: + +```python title="server.py" hl_lines="22 29-30" +--8<-- "docs_src/dependencies/tutorial002.py" +``` + +* `estimate_delivery` залежить від `check_stock`. SDK виконує граф по порядку: спершу залишок, потім оцінка, потім інструмент. +* І `stock`, і `delivery` зрештою потребують `check_stock`, але він виконується **один раз на виклик**. Один пошук у складських залишках, два споживачі. +* Реєструвати нічого не потрібно. Анотації — це *і є* граф. + +!!! check + Не вірте в «один раз на виклик» на слово. Додайте `print` у `check_stock` і викличте + `order_book` з Inspector: один рядок на виклик. Два споживачі, один пошук. + +SDK аналізує граф під час реєстрації інструмента, а не під час виклику. Параметр, який не вдається класифікувати (не `Context`, не `Resolve(...)`, не ім'я аргументу інструмента), і цикл резолверів однаково викидають `InvalidSignature` під час запуску. Сервер падає ще до того, як під'єднається бодай один клієнт, а в помилці названо проблемний параметр чи резолвер. + +Параметри резолвера розв'язуються точно так само, як параметри інструмента: інший `Resolve(...)`, власні аргументи інструмента за ім'ям або `Context` — `ctx.headers`, об'єкт життєвого циклу (lifespan), усе разом. + +!!! warning + На HTTP-транспортах `Context` містить `ctx.headers`. Заголовки — це **вхідні дані від клієнта**, + як і будь-який аргумент інструмента: вони годяться для локалі чи прапорця функції, але ніколи — + для ідентичності. Хто саме викликає, визначає ваш шар авторизації + (**[Авторизація](../run/authorization.md)**), а не заголовок, який може встановити будь-хто. + +!!! tip + *Один раз на виклик* означає саме це: наступний `tools/call` знову запускає `check_stock`. + Ресурсу, що має пережити запит (пул бази даних, HTTP-клієнт), місце на сторінці + **[Життєвий цикл](lifespan.md)**, а резолвер може дістатися до нього через + `ctx.request_context.lifespan_context`. + +## Запитання лише за потреби {#ask-when-you-must} + +Резолвер не зобов'язаний знати відповідь. Він може повернути `Elicit(message, Model)`, і SDK запитає користувача — це механізм **[еліцитації](elicitation.md)** (elicitation), запущений за вас: + +```python title="server.py" hl_lines="26-32 39" +--8<-- "docs_src/dependencies/tutorial003.py" +``` + +* Є в наявності: `confirm_backorder` повертає `Backorder` напряму. **Ні запитання, ні зайвого раунду обміну.** Користувача переривають лише тоді, коли його відповідь має значення. +* Немає в наявності: SDK надсилає еліцитацію, валідує відповідь за моделлю `Backorder` і впроваджує її. Ваш резолвер ніколи не торкається протоколу. +* Інструмент читає `backorder.confirm`, як будь-який інший аргумент. Відповідь **ні** — теж відповідь: еліцитацію прийнято з `confirm=False`, інструмент виконується, і замовлення не оформлюється. Запитання стало передумовою, а не службовим кодом у тілі інструмента. + +А якщо користувач узагалі не відповість — відхилить запитання або скасує його? + +!!! check + Запустіть `order_book` для `Neuromancer` і відхиліть запитання. Якщо анотацію записано як + `Annotated[Backorder, Resolve(...)]`, тіло інструмента не виконується взагалі; виклик + завершується результатом-помилкою, який може прочитати модель: + + ```text + Error executing tool order_book: Resolver for parameter 'backorder' could not resolve: elicitation was decline + ``` + +Це правильна поведінка за замовчуванням для передумови: немає відповіді — немає замовлення. Коли відмова — це результат, який інструмент хоче обробити (пропустити відкладене замовлення, але все ж запропонувати іншу книжку), анотуйте натомість `ElicitationResult[Backorder]`, і інструмент отримає повний результат accept/decline/cancel, за яким можна розгалузитися. Сторінка **[Еліцитація](elicitation.md)** показує цю форму й усе інше про запитання: правила схеми, три відповіді, бік клієнта в цій розмові. + +!!! info + Фреймворк обирає транспорт для запитання за узгодженою версією протоколу; наведений вище код + однаковий для обох. На **2026-07-28** і пізніших запитання їде всередині багатораундового + (multi-round-trip) `tools/call`: сервер повертає його, `elicitation_callback` клієнта + відповідає, а `Client` повторює виклик за вас (**[Багатораундові запити](multi-round-trip.md)**). На + **2025-11-25** і раніших це синхронний запит еліцитації посеред виклику. Кожне запитання + ставиться рівно один раз на виклик — це гарантія щодо запитання, а не резолвера. У + багатораундовій формі будь-який резолвер може виконатися знову щоразу, коли виклик + відновлюється після запитання, тож код перед `return Elicit(...)` виконується в кожному з цих + раундів; записана відповідь тоді задовольняє повторне запитання, не турбуючи користувача ще + раз. До записаної відповіді звертаються лише тоді, коли резолвер запитує; резолвер, що + відповідає *без* запитання, як-от `check_stock`, завжди надає власне обчислене значення. + Оскільки кожна відповідь зіставляється зі своїм запитанням, резолвер з еліцитацією мусить + виводити запитання детерміновано з аргументів інструмента та попередніх відповідей. Значення, + що генерується для кожного виклику (ідентифікатор із `default_factory`, мітка часу), + виводиться заново в кожному раунді й не повинне потрапляти в запитання, до якого має + прив'язатися відповідь. Запитання, побудоване з таких мінливих даних, робить кожну записану + відповідь застарілою на вигляд, тож сервер ставить його знову в кожному раунді, доки обмеження + клієнта на кількість раундів не завершить виклик. + +## Запитання до клієнта, а не до користувача {#ask-the-client-not-the-user} + +Еліцитація — одне з трьох запитань, які може поставити резолвер, і багатораундовий потік інших не дозволяє. Два інші адресовано **клієнту**, а не користувачу: поверніть `Sample(...)`, щоб виконати виклик LLM через клієнта (запит `sampling/createMessage`), або `ListRoots()`, щоб отримати поточні кореневі каталоги (roots) клієнта. Жодне з них не має результату accept/decline; споживач анотує тип результату напряму — `CreateMessageResult` (`CreateMessageResultWithTools`, коли запит містить `tools` або `tool_choice`) або `ListRootsResult`: + +```python title="server.py" hl_lines="10-15 21" +--8<-- "docs_src/dependencies/tutorial004.py" +``` + +* Фреймворк маршрутизує їх точно так само, як `Elicit`: усередині багатораундового `tools/call` на **2026-07-28**, через окремий запит сервер->клієнт на **2025-11-25**. Неоголошена можливість відхиляє виклик помилкою протоколу `-32021` (`sampling`, `roots`, `elicitation` у режимі форми; `sampling.tools`, коли запит містить `tools` або `tool_choice`). +* Усе, що сказано про запитання в інформаційному блоці вище, застосовується без змін: запит `Sample` зіставляється зі своїм записаним результатом за точним поданням, тож будуйте його детерміновано з аргументів інструмента та попередніх відповідей; тоді клієнт платить за виклик LLM один раз на виклик інструмента, а не один раз на раунд. Записаний результат мандрує в `request_state` до кінця виклику, тому дуже велика відповідь моделі робить кожен подальший раунд обміну важчим. +* Окремі *можливості* семплювання (sampling) та кореневих каталогів оголошено застарілими у 2026-07-28 (SEP-2577). Нові сервери, яким потрібна модель клієнта, запитують через цей носій; сервери, яким вона не потрібна, мають інтегруватися з постачальником LLM напряму. Значення `include_context`, відмінні від `"none"`, самі є застарілими; уникайте їх. + +## Підсумки {#recap} + +* `Annotated[T, Resolve(fn)]` на параметрі інструмента: SDK запускає `fn` і впроваджує повернене значення. +* Параметр із резолвером невидимий для моделі, і клієнт не може його передати. Значенням, які модель не повинна вигадувати (ціни, ідентичності, дозволи), місце саме тут. +* Параметри резолвера розв'язуються так само: `Context`, інший `Resolve(...)` або аргумент інструмента за ім'ям. Граф запускає кожен резолвер щонайбільше один раз на раунд, хоч скільки в нього споживачів; кожне запитання ставиться рівно один раз, а будь-який резолвер може виконатися знову, коли виклик відновлюється після запитання. +* Хибні графи падають під час реєстрації з `InvalidSignature`, а не посеред виклику. +* Повертайте `Elicit(message, Model)`, щоб запитати користувача, — лише коли без цього ніяк. Анотації без обгортки переривають виклик у разі відмови; `ElicitationResult[T]` дає інструменту змогу розгалузитися. +* Повертайте `Sample(...)` або `ListRoots()`, щоб попросити в клієнта відповідь LLM або список кореневих каталогів; впроваджується сам результат. + +Про стан, який сервер будує один раз під час запуску, і про те, як обробник до нього дістається, — сторінка **[Життєвий цикл](lifespan.md)**. diff --git a/i18n/uk/pages/handlers/elicitation.md b/i18n/uk/pages/handlers/elicitation.md new file mode 100644 index 0000000000..0152e48b7d --- /dev/null +++ b/i18n/uk/pages/handlers/elicitation.md @@ -0,0 +1,190 @@ +--- +translation: + sections: [335ca2a0b266f003, d1ad562d3fe87bc0, 0bb1396c86daeba4, d1cb1235bb9ee267, 833179c09d239c83, e5d6dec2d2e655e8] + tool: 1 +--- +# Еліцитація {#elicitation} + +Інструмент, який уже наполовину виконав свою роботу й не має однієї відповіді, не мусить завершуватися помилкою. + +**Еліцитація** (elicitation) дає йому змогу запитати. Посеред виклику інструмента користувач отримує запитання, а його відповідь повертається в той самий виклик функції. + +Є два режими: + +* **Режим форми**: потрібне значення (підтвердження, дата, кількість). Ви описуєте поля, клієнт відображає форму. +* **Режим URL**: потрібно, щоб користувач перейшов кудись іще (екран згоди OAuth, сторінка оплати). Ніщо з того, що він там робить, не проходить через протокол. + +І є два способи запитати. Той, до якого варто звертатися насамперед, — **резолвер**: запитання навішується на параметр, а питає SDK — на будь-якому з'єднанні, хоч би якого покоління протоколу дотримувався клієнт. Прямий спосіб, `await ctx.elicit(...)`, — це запит від *сервера* до *клієнта*, канал, що існує лише для клієнта на з'єднанні старого покоління (версія специфікації 2025-11-25 або раніша). Обидва способи є на цій сторінці; почніть із резолвера. + +## Запитання через резолвер {#ask-with-a-resolver} + +Запитання, від якого залежить увесь інструмент, — *ви впевнені? який із трьох знайдених облікових записів?* — можна винести з тіла інструмента в **резолвер**, і фреймворк поставить його за вас. + +Параметр з анотацією `Annotated[T, Resolve(fn)]` заповнюється запуском `fn` перед тілом інструмента. Резолвер повертає значення безпосередньо, коли вже його знає, або повертає `Elicit(...)`, щоб запитав фреймворк: + +```python title="server.py" hl_lines="24-30 35-36" +--8<-- "docs_src/elicitation/tutorial004.py" +``` + +* `confirm_delete` читає власний аргумент інструмента `path` за іменем, переглядає вміст папки й **питає лише тоді, коли мусить** — порожня папка розв'язується в `Confirm(ok=True)` без жодного раунду обміну з клієнтом. +* `delete_folder` анотує `ElicitationResult[Confirm]`, тож фреймворк впроваджує весь результат цілком, і інструмент через `match` обробляє кожен випадок: прийняти й підтвердити, прийняти, але залишити (`ok=False`), відхилити, скасувати. +* Параметр `confirm` ніколи не з'являється у вхідній схемі інструмента — клієнт надає `path`, резолвер надає `confirm`. + +Коли інструменту не потрібне розгалуження, анотуйте натомість розгорнуту модель (`Annotated[Confirm, Resolve(confirm_delete)]`): у разі прийняття він отримує модель, а в разі відхилення чи скасування виклик переривається з помилкою. + +Резолвер працює на **кожному** з'єднанні. Клієнтові на з'єднанні старого покоління SDK надсилає запитання напряму; на з'єднанні **2026-07-28** SDK *повертає* запитання з виклику, а наступна спроба клієнта несе відповідь. Резолвер ніколи не помічає різниці; те, що відбувається всередині, описано на сторінці **[Багатораундові запити](multi-round-trip.md)** (multi-round-trip). + +Запитувати — лише одне з того, що вміє резолвер. Загальний механізм — залежності, що обчислюються без запитань, залежності залежностей, що модель може й чого не може надати — описано на сторінці **[Залежності](dependencies.md)**. + +## Запитання зсередини інструмента {#ask-from-inside-the-tool} + +Інструмент також може зупинитися посеред власного тіла й запитати. + +!!! warning + `ctx.elicit()` і `ctx.elicit_url()` — це запити від *сервера* до *клієнта*, канал, + що існує лише для клієнта на з'єднанні старого покоління (версія специфікації **2025-11-25** + або раніша). На з'єднанні **2026-07-28** запитів, ініційованих сервером, немає, тож + ці виклики завершуються помилкою. Резолвер працює в обох випадках. Докладніше — на сторінці + **[Версії протоколу](../protocol-versions.md)**. + +`await ctx.elicit()` приймає повідомлення й модель Pydantic: + +```python title="server.py" hl_lines="9-11 20-23 25" +--8<-- "docs_src/elicitation/tutorial001.py" +``` + +* Параметр **`Context`** — це те, що дає `ctx.elicit`; будь-який інструмент може його приймати. Цей об'єкт має власну сторінку: **[Об'єкт Context](context.md)**. +* `AlternativeDate` — це **схема** відповіді, яку ви хочете отримати. +* Інструмент оголошено як `async def`. Інакше не можна: він зупиняється посередині й чекає на людину. +* Для будь-якої іншої дати інструмент повертає результат одразу. Він питає лише тоді, коли мусить. +* Дата, яку приймає користувач, знову проходить через сам `book_table`. Відповідь — це такі самі вхідні дані, як і будь-які інші: про альтернативу, яка теж повністю заброньована, запитають знову, а не підтвердять наосліп. + +### Що отримує клієнт {#what-the-client-receives} + +Клієнт отримує ваше повідомлення, а поруч із ним — JSON Schema, згенеровану з моделі: + +```json +{ + "properties": { + "accept_alternative": { + "description": "Try another date?", + "title": "Accept Alternative", + "type": "boolean" + }, + "date": { + "default": "2025-12-26", + "description": "Alternative date (YYYY-MM-DD)", + "title": "Date", + "type": "string" + } + }, + "required": ["accept_alternative"], + "title": "AlternativeDate", + "type": "object" +} +``` + +Ця схема і є формою. `Field(description=...)` — це підпис; значення за замовчуванням попередньо заповнює поле введення й робить поле необов'язковим. Це той самий механізм перетворення Pydantic на JSON Schema, який сторінка **[Інструменти](../servers/tools.md)** описує для аргументів інструмента. + +!!! warning + Схема еліцитації не така виразна, як вхідна схема інструмента. Лише пласкі примітивні поля: + `str`, `int`, `float`, `bool` або `Literal` з рядків (він стає `enum`). + Покладіть модель усередину моделі — і `ctx.elicit` викине виняток ще до того, як щось буде надіслано клієнтові: + + ```text + TypeError: Elicitation schema field 'address' rendered as {'$ref': '#/$defs/Address'}, which is not a valid PrimitiveSchemaDefinition + ``` + + Ви перериваєте людину посеред справи. Якщо відповідь потребує вкладеності, вона мала б бути + аргументом інструмента. + +### Три відповіді {#the-three-answers} + +`result.action` повідомляє, що зробив користувач, і можливостей рівно три: + +* `"accept"`: він надіслав форму. `result.data` — це екземпляр `AlternativeDate`, уже провалідований. +* `"decline"`: він відмовив. +* `"cancel"`: він закрив запитання, нічого не вибравши. + +`result.data` існує лише для `"accept"`, тому приклад спершу перевіряє `result.action`. Засіб перевірки типів стежить за порядком: після `result.action == "accept"` `result.data` — це `AlternativeDate`; до того `.data` немає взагалі. + +Відмова — не помилка. Інструмент сам вирішує, що означає відхилення (тут — бронювання немає), і відповідає моделі як звичайно. + +!!! tip + Відповідь валідується за вашою моделлю, перш ніж її побачить ваш код. Клієнт, що надсилає + `"maybe"` для `bool`, не зіпсує бронювання: виклик завершується помилкою + невідповідності схемі, а ваш `if` так і не виконується. + +## Перенаправлення користувача на URL {#send-the-user-to-a-url} + +Деякі речі не повинні проходити ні через модель, ні через клієнта: облікові дані, номери карток, згода OAuth. Для них ви не просите даних; ви просите користувача кудись перейти: + +```python title="server.py" hl_lines="10-14 23" +--8<-- "docs_src/elicitation/tutorial002.py" +``` + +* `ctx.elicit_url()` приймає повідомлення, **URL**, який треба відвідати, і `elicitation_id`, який ви обираєте самі: будь-який рядок, що ідентифікує цю еліцитацію в межах вашого сервера. +* Результат містить дію й більше нічого. `"accept"` означає, що користувач погодився відкрити URL, а **не** те, що він завершив усе на тому боці. +* Оплата відбувається поза протоколом, між браузером користувача й вашим платіжним провайдером. Жоден вміст ніколи не повертається через MCP. + +Погляньте на другий інструмент. Коли сервер дізнається, що зовнішній процес завершено (вебхук, опитування; тут це змодельовано як другий інструмент), `ctx.session.send_elicit_complete(...)` надсилає `notifications/elicitation/complete` з тим самим `elicitation_id`. Саме так клієнт дізнається, що можна припинити показувати *«очікуємо оплату...»*. Без цього клієнтові залишається лише здогадуватися. + +## На боці клієнта {#the-client-side} + +Сервери питають. Клієнти відповідають, передаючи **`elicitation_callback`** у `Client(...)`: + +```python title="client.py" hl_lines="6-7 18" +--8<-- "docs_src/elicitation/tutorial003.py" +``` + +* Один колбек обробляє обидва режими. `params` — це об'єднання `ElicitRequestFormParams` і `ElicitRequestURLParams`; розгалуження робиться через `isinstance`. +* Для URL ви показуєте користувачеві `params.url` і повертаєте дію, яку він обрав. Ніколи жодного `content`. +* Для форми справжній застосунок відображає `params.requested_schema` і повертає введене користувачем як `content`. Цей завжди каже «так» із заготовленою відповіддю — саме такий колбек і потрібен у тесті. +* Передавання колбека — це водночас і **оголошення можливості**: саме так сервер дізнається, що цього клієнта можна питати. Інше, на що клієнт може відповідати серверові, описано на сторінці **[Колбеки клієнта](../client/callbacks.md)**. + +!!! info + Еліцитація — це запит від *сервера* до *клієнта*, а такі існують лише + в сесії з класичним рукостисканням, тому цей клієнт передає `mode="legacy"`. + На з'єднанні **2026-07-28** інструмент натомість питає, *повертаючи* запитання з виклику; + цей потік описано на сторінці **[Багатораундові запити](multi-round-trip.md)**. + +### Спробуйте самі {#try-it} + +Запустіть `server.py` у режимі форми з `ctx.elicit` (той, що з `book_table`) через Streamable HTTP (потрібний однорядковий приклад є на сторінці **[Запуск сервера](../run/index.md)**), потім виконайте `main()` клієнта й викличте `book_table` на день Різдва. + +Колбек виводить запитання, яке йому надіслали: + +```text +No tables for 2 on 2025-12-25. Would you like to try another date? +``` + +Він відповідає `{"accept_alternative": True, "date": "2025-12-27"}`, а інструмент, який увесь цей час чекав усередині `await ctx.elicit(...)`, завершує бронювання: + +```text +Booked a table for 2 on 2025-12-27. +``` + +Тепер підставте `server.py` у режимі URL і спрямуйте ту саму `main()` на `pay_deposit`: той самий колбек піде іншою гілкою, виведе посилання на оплату, а інструмент повернеться з *«Complete the payment in your browser.»*. Один раунд обміну, посеред виклику, в обох напрямках. + +!!! check + Тепер приберіть `elicitation_callback=` із `Client` і знову викличте `book_table` на день Різдва. + Увесь виклик завершується помилкою протоколу: + + ```text + Elicitation not supported + ``` + + Клієнт, що не зареєстрував колбека, ніколи не оголошував можливості `elicitation`, тож питати + нікого. Інструмент не отримав `"decline"`; він отримав виняток. Проєктуйте з огляду на це: кожна + еліцитація потребує розумної відповіді на запитання «а що, як запитати не можна?». + +## Підсумки {#recap} + +* Параметр з анотацією `Annotated[T, Resolve(fn)]` заповнює резолвер, який повертає `Elicit(...)`, коли мусить запитати. Це працює на кожному з'єднанні. +* Схема — пласка модель Pydantic: лише примітивні поля, валідовані на зворотному шляху. +* `result.action` — це `"accept"`, `"decline"` або `"cancel"`; `result.data` існує лише в разі прийняття. +* `await ctx.elicit(message, schema=Model)` питає зсередини тіла інструмента, а `await ctx.elicit_url(message, url, elicitation_id)` — для всього, що не повинно проходити через модель (`ctx.session.send_elicit_complete(elicitation_id)` повідомляє, що зовнішню частину завершено). Обидва — запити від сервера до клієнта: їм потрібен клієнт на з'єднанні старого покоління. +* Клієнт відповідає одним `elicitation_callback`, розгалужуючись за типом params; його реєстрація й оголошує можливість. +* На з'єднанні 2026-07-28 сервер повертає запитання замість того, щоб надсилати його; той самий колбек отримує дані через **[Багатораундові запити](multi-round-trip.md)**. + +Усе, що стоїть за цим поверненням (цикл повторних спроб, захист `requestState`, самостійне керування), описано на сторінці **[Багатораундові запити](multi-round-trip.md)**. diff --git a/i18n/uk/pages/handlers/index.md b/i18n/uk/pages/handlers/index.md new file mode 100644 index 0000000000..de4ba53fbf --- /dev/null +++ b/i18n/uk/pages/handlers/index.md @@ -0,0 +1,38 @@ +--- +translation: + sections: [424930166c4bc6f3] + tool: 1 +--- +# Усередині обробника {#inside-your-handler} + +Аргументи обробника надходять від клієнта. Усе *інше*, що він може прочитати, +і все, що він може робити під час виконання, зібрано тут. + +Що він може читати: + +* **[Об'єкт Context](context.md)** — єдиний додатковий параметр, який може + попросити будь-який обробник: поточний запит, його заголовки, його сесія, + а також методи для звітування про перебіг виконання й сповіщення про зміни. +* **[Залежності](dependencies.md)** — параметри, яких модель ніколи не бачить; + їх заповнюють ваші власні функції через `Resolve`. +* **[Життєвий цикл](lifespan.md)** (lifespan) — стан, який сервер будує один + раз під час запуску, і те, як обробник дістається до нього через `Context`. + +Що він може робити під час виконання: + +* Запитати в користувача додаткові дані за допомогою + **[Еліцитації](elicitation.md)** (elicitation) та + **[Багатораундових запитів](multi-round-trip.md)** (multi-round-trip) — + патерну редакції 2026-07-28, через який вона працює. +* Попросити в клієнта доповнення від LLM або список папок його робочого + простору — **[Семплювання та кореневі каталоги](sampling-and-roots.md)** + (sampling і roots); ці можливості застарілі, але досі обслуговуються. +* Звітувати про **[Перебіг виконання](progress.md)** повільної операції. +* Писати логи (у стандартний потік помилок, для того, хто експлуатує сервер) — + **[Логування](logging.md)**. +* Повідомляти підписаним клієнтам, що щось змінилося, — + **[Підписки](subscriptions.md)**. + +Якщо ви ще не зареєстрували жодного обробника, почніть зі сторінки +**[Інструменти](../servers/tools.md)**. Кожна сторінка тут передбачає, що він +у вас уже є. diff --git a/i18n/uk/pages/handlers/lifespan.md b/i18n/uk/pages/handlers/lifespan.md new file mode 100644 index 0000000000..33d38a46cd --- /dev/null +++ b/i18n/uk/pages/handlers/lifespan.md @@ -0,0 +1,107 @@ +--- +translation: + sections: [f3ca8ac5f90f2dfa, 85a1ef3588ba0736, 563346d4d5804933, 9e3528340d0bab53] + tool: 1 +--- +# Життєвий цикл {#lifespan} + +Більшість справжніх серверів тримають щось упродовж усього свого життя: пул з'єднань із базою даних, HTTP-клієнт, завантажену модель. + +Створювати це під час кожного виклику не хочеться, а от коректно закрити наприкінці — треба. Саме для цього й існує **життєвий цикл (lifespan)**. + +## Типізований життєвий цикл {#a-typed-lifespan} + +Життєвий цикл — це `@asynccontextmanager`, який отримує сервер і через `yield` віддає **один об'єкт**. Те, що ви віддали, доступне кожному обробнику, поки сервер працює. + +```python title="server.py" hl_lines="25-31 34 38 40" +--8<-- "docs_src/lifespan/tutorial001.py" +``` + +Читайте знизу вгору: + +* `app_lifespan` під'єднує `Database` **до** `yield` і від'єднує її **після**, у блоці `finally`. Це і є запуск та зупинка. +* Він віддає `AppContext` — звичайний dataclass, що містить усе налаштоване під час запуску. Сьогодні одне поле, завтра десять. +* `MCPServer("Bookshop", lifespan=app_lifespan)` — оце й усе підключення. +* Усередині інструмента відданий об'єкт доступний як `ctx.request_context.lifespan_context`. + +Життєвий цикл виконується **один раз**. Вхід у нього відбувається, коли сервер стартує (до першого запиту), а вихід — коли сервер зупиняється. Усі запити між цими моментами спільно користуються тим самим `AppContext`. + +!!! info + Якщо ви вже писали `lifespan` для FastAPI, то все це вам знайоме. Той самий декоратор, той самий `yield`, той самий `finally`. + +### Що бачить модель {#what-the-model-sees} + +Нічого нового. `ctx` — це параметр **Context**, тож SDK впроваджує його сам, і він ніколи не потрапляє до вхідної схеми: + +```json +{ + "type": "object", + "properties": { + "genre": {"title": "Genre", "type": "string"} + }, + "required": ["genre"], + "title": "count_booksArguments" +} +``` + +`genre` — єдиний аргумент, який модель може передати. Життєвий цикл — це справа самого сервера. + +Функції `@mcp.resource()` і `@mcp.prompt()` теж можуть приймати параметр `ctx`, записаний як простий `Context` — з причини, до якої дійде наступний розділ. Усе, що несе в собі `ctx`, описано на сторінці **[Об'єкт Context](context.md)**. + +### Він справді типізований {#it-really-is-typed} + +Погляньте на анотацію ще раз: `ctx: Context[AppContext]`. + +Саме завдяки цьому одному параметру типу `ctx.request_context.lifespan_context` **є** `AppContext` для вашого засобу перевірки типів. `.db` підставляється автодоповненням; `.dbb` — помилка ще до того, як ви запустите сервер. + +Напишіть натомість простий `Context` — і `lifespan_context` отримає тип `dict[str, Any]`: засіб перевірки типів ніяк не може дізнатися, що віддав ваш життєвий цикл. Під час виконання об'єкт нікуди не зникає; ви лише втрачаєте допомогу. + +!!! warning + `Context[AppContext]` — запис **лише для інструментів**. Поставте його на функцію `@mcp.resource()` чи + `@mcp.prompt()` — і кожен виклик цього обробника завершиться помилкою. Клієнт отримає помилку у відповідь, + а лог сервера покаже причину: + + ```text + Context is not available outside of a request + ``` + + У ресурсах і промптах пишіть простий `ctx: Context`. Об'єкт, який віддав ваш життєвий цикл, + під час виконання так само доступний як `ctx.request_context.lifespan_context`; ви відмовляєтеся від параметра типу, а не + від об'єкта. + +!!! tip + Життєвий цикл є завжди. Якщо його не передати, типовий варіант від SDK віддає порожній `dict`, + тож `ctx.request_context.lifespan_context` дорівнює `{}` і ніколи не буває `None`. Саме через цей типовий варіант + простий `Context` типізує його як `dict[str, Any]`. + +## Подивіться, як це працює {#watch-it-happen} + +«Запуск виконується до першого запиту» — речення з тих, які не варто брати на віру. + +Скоротіть сервер до самого життєвого циклу: додайте до `Database` прапорець `connected`, перемикайте його в `connect()` і `disconnect()` та додайте інструмент, що повідомляє його стан. + +```python title="server.py" hl_lines="11 14 17 25 44" +--8<-- "docs_src/lifespan/tutorial002.py" +``` + +`database` живе на рівні модуля з однієї причини: щоб на неї можна було глянути *ззовні* сервера. + +!!! check + Три моменти — три значення: + + * До запуску сервера `database.connected` дорівнює `False`. Імпорт модуля нічого не під'єднав. + * Поки сервер працює, викличте `database_status` — і результатом буде `"connected"`. + * Зупиніть сервер, і виконається блок `finally`: `database.connected` знову `False`. + + Робота відбулася саме там, де ви її розмістили: навколо `yield`, а не під час імпорту й не на кожен запит. + +## Підсумки {#recap} + +* `lifespan=` приймає `@asynccontextmanager`, який отримує сервер і через `yield` віддає один об'єкт. +* Код до `yield` — це запуск. Блок `finally` після нього — зупинка. +* Він виконується один раз, навколо всього життя сервера, а не на кожен запит. +* Усе, що ви віддаєте через `yield`, стає `ctx.request_context.lifespan_context` у кожному інструменті, ресурсі та промпті. +* `ctx: Context[AppContext]` робить цей доступ повністю типізованим в інструментах. Ресурси й промпти приймають простий `Context`. +* Без `lifespan=` буде порожній `dict`, і ніколи не `None`. + +Обробник, що зупиняється посеред виклику, аби запитати в користувача щось відоме лише йому, — це **[Еліцитація](elicitation.md)**. diff --git a/i18n/uk/pages/handlers/logging.md b/i18n/uk/pages/handlers/logging.md new file mode 100644 index 0000000000..7bca678198 --- /dev/null +++ b/i18n/uk/pages/handlers/logging.md @@ -0,0 +1,87 @@ +--- +translation: + sections: [c93a3e1aefd77955, 7851abd5ec54393b, f49d1ca2f330f9cd, c03764bd9dfeef7b, 4a0391691a674ae4, 2df5cd279eabf9f5] + tool: 1 +--- +# Логування {#logging} + +Пишіть логи з інструмента так само, як із будь-якої іншої функції Python: стандартною бібліотекою. + +У MCP є **можливість логування** на рівні протоколу: сервер міг надсилати свої записи логу клієнту як сповіщення через методи об'єкта `Context`. Редакція специфікації 2026-07-28 **оголошує цю можливість застарілою і нічим її не замінює**, тому ця документація її не описує. Повний перелік застарілого і того, що робити натомість, — на сторінці **[Застарілі можливості](../deprecated.md)**. + +Натомість робіть те саме, що й у будь-якій іншій програмі на Python: користуйтеся стандартною бібліотекою. + +## Інструмент, що пише логи {#a-tool-that-logs} + +```python title="server.py" hl_lines="1 5 13" +--8<-- "docs_src/logging/tutorial001.py" +``` + +* `logging.getLogger(__name__)` повертає логер, названий за вашим модулем. Створіть його один раз, угорі файлу. +* Усередині інструмента викликайте `logger.info(...)`, як у будь-якій іншій функції. Нічого не треба впроваджувати, нічого не треба чекати через `await`, нічого специфічного для MCP. + +!!! check + Викличте інструмент і подивіться на весь результат: + + ```python + result.content # [TextContent(text="Found 3 books matching 'dune'.")] + result.structured_content # {'result': "Found 3 books matching 'dune'."} + ``` + + Рядка логу в ньому ніде немає. Логи — для **вас**, людини, яка керує сервером. Модель + їх ніколи не бачить. Якщо модель має щось прочитати, поверніть це через `return`. + +## Куди це потрапляє {#where-it-goes} + +Для **stdio**-сервера це питання важливіше, ніж зазвичай. Хост запустив ваш сервер як підпроцес і читає MCP-повідомлення з його **stdout**. Стандартний потік помилок — ваш. + +Стандартна бібліотека вже робить усе правильно: за замовчуванням вивід логів іде в `sys.stderr`. Рядки `logger.info(...)` потрапляють у термінал (або туди, куди хост збирає stderr підпроцесу), а потік протоколу лишається чистим. + +!!! tip + Не використовуйте `print()` у stdio-сервері. `print` пише в **stdout**, а stdout належить протоколу. + Під час обслуговування SDK перенаправляє в stderr той stdout, який справді *скинуто з буфера*, тож + пошкодити потік протоколу він не може, але `print()` у процесі з блоковою буферизацією зазвичай лежить + нескинутим у буфері `sys.stdout`, доки інтерпретатор не спорожнить його під час виходу — просто + в потік протоколу. Навіть коли його перенаправлено, рядок потрапляє у вивід логів сирим: без рівня, + без імені логера і без можливості його відфільтрувати. + + `logger.debug("got here")` — той самий один рядок зусиль, але він іде куди треба. + +## Рівень {#the-level} + +Викликати `logging.basicConfig()` самостійно не потрібно. Конструктор `MCPServer` уже це зробив: з обробником, спрямованим у стандартний потік помилок, на рівні, який передано як `log_level=`, тож `MCPServer("Bookshop", log_level="DEBUG")` — це все, що потрібно, щоб побачити рядки `logger.debug(...)`. + +Типове значення — `"INFO"`. + +`logging.basicConfig()` ніколи не замінює обробники, що вже існують. Якщо налаштувати логування самостійно до створення сервера, ваше налаштування має перевагу. + +## Спробуйте самі {#try-it} + +Запустіть сервер з MCP Inspector: + +```console +uv run mcp dev server.py +``` + +Викличте `search_books` на вкладці **Tools**. Inspector покаже результат: лише повернене значення. Рядок + +```text +Searching for 'dune' +``` + +пішов у стандартний потік помилок: у термінал, а не в потік протоколу. + +!!! info + Якщо насправді потрібне *трасування* (кожен запит, скільки він тривав, чи завершився помилкою), + потрібні не рядки логу, а спани. Ваш сервер уже їх генерує: SDK за замовчуванням трасує кожне + повідомлення за допомогою OpenTelemetry. Див. **[OpenTelemetry](../run/opentelemetry.md)**. + +## Підсумки {#recap} + +* Можливість логування протоколу MCP оголошена застарілою специфікацією 2026-07-28 і нічим не замінена. Не будуйте на ній. +* `logger = logging.getLogger(__name__)` на рівні модуля, `logger.info(...)` в інструменті. Оце й увесь шаблон. +* Вивід логів ніколи не доходить до моделі. Доходить лише значення, яке ви повертаєте через `return`. +* Стандартний потік помилок — ваш; stdout належить протоколу. Під час обслуговування SDK перенаправляє скинутий з буфера сторонній stdout у stderr, але нескинутий `print()` усе ще може вилитися в потік протоколу під час виходу, а перенаправлені рядки приходять без позначок; використовуйте `logging`, чий обробник скидає кожен запис. +* `MCPServer(..., log_level="DEBUG")` задає рівень, а налаштування логування, зроблене раніше, лишається недоторканим. + +Про те, як повідомити під'єднаним клієнтам, що на сервері щось змінилося (список інструментів, ресурс), — на сторінці **[Підписки](subscriptions.md)**. diff --git a/i18n/uk/pages/handlers/multi-round-trip.md b/i18n/uk/pages/handlers/multi-round-trip.md new file mode 100644 index 0000000000..68c6ba20ce --- /dev/null +++ b/i18n/uk/pages/handlers/multi-round-trip.md @@ -0,0 +1,191 @@ +--- +translation: + sections: [74011e683045eea9, 9b64cc175c18b6a9, 4b41be4824030397, e3b1502da786ec33, 71e41161f143c6a9, 9ec2c1eeb8c36378, 8dd027377d46448b, f81491125dcbfe8b] + tool: 1 +--- +# Багатораундові запити {#multi-round-trip-requests} + +Інколи інструмент не може завершити роботу за один раунд обміну. Йому потрібне щось, що є лише в користувача: вибір, підтвердження, облікові дані. + +До версії 2026-07-28 сервер отримував це через **зворотний** виклик: відкривав власний запит до клієнта — еліцитацію (elicitation) чи виклик семплювання (sampling) — просто посеред обробки початкового запиту. Специфікація 2026-07-28 цей зворотний канал (back-channel) прибирає. + +Натомість сервер **повертає результат**. + +## Повернення замість зворотного виклику {#return-dont-call-back} + +На `tools/call` сервер відповідає **`InputRequiredResult`** замість `CallToolResult`. Усю роботу виконують два його поля: + +* **`input_requests`**: те, чого серверу ще бракує, у вигляді словника з ключами-іменами, які обрав сам сервер. Кожне значення — це `ElicitRequest`, `CreateMessageRequest` або `ListRootsRequest`. +* **`request_state`**: непрозорий токен. Під час повторної спроби клієнт повертає його дослівно. Читає його лише ваш сервер. + +Клієнт виконує кожен запит, а потім викликає **той самий інструмент ще раз**, передаючи відповіді в `input_responses`, а токен — у `request_state`. Тепер сервер має те, чого йому бракувало, і повертає звичайний `CallToolResult`. + +Оце й увесь протокол. Кожен етап — це звичайний запит від клієнта до сервера. У зворотному напрямку не йде нічого. + +## Серверна частина {#the-server-side} + +З `@mcp.tool()` збирати це вручну доводиться рідко: оголосіть залежність — `Elicit`, щоб запитати користувача, `Sample`, щоб виконати семплювання через LLM клієнта, або `ListRoots`, щоб отримати перелік його кореневих каталогів (roots), — і SDK сам поверне `InputRequiredResult`; цю форму описано на сторінці **[Залежності](dependencies.md)**. Ці дві форми не поєднуються: у виклику є лише один канал `input_responses`/`request_state`, тож інструмент із параметрами `Resolve(...)` не може ще й повертати `InputRequiredResult` зі свого тіла. Оголошений тип повернення `InputRequiredResult` відхиляється під час реєстрації (`InvalidSignature`), а неоголошений провалює виклик під час виконання. Ручна форма — це **низькорівневий** `Server`, чий обробник `on_call_tool` може повертати будь-який із двох типів результату: + +```python title="server.py" hl_lines="43-46" +--8<-- "docs_src/mrtr/tutorial001.py" +``` + +* `on_call_tool` має анотацію `-> CallToolResult | InputRequiredResult`. Повернути другий із них — оце й увесь серверний API. +* Під час першого виклику `params.input_responses` дорівнює `None`, тож спрацьовує перевірка й обробник запитує замість того, щоб відповідати. +* Під час повторної спроби `ElicitResult`, який надіслав клієнт, лежить під **тим самим ключем** (`"region"`), який сервер використав у `input_requests`. + +Усе інше в цьому файлі (явна `input_schema`, зібраний вручну `CallToolResult`) — звичайний низькорівневий `Server`, описаний на сторінці **[Низькорівневий Server](../advanced/low-level-server.md)**. Ця сторінка додає лише другий тип повернення. + +## Не лише інструменти {#beyond-tools} + +`tools/call` нічим не особливий: у версії 2026-07-28 сервер може так само відповідати на `prompts/get` і `resources/read`. У `MCPServer` функція `@mcp.prompt()` — або функція-**шаблон** `@mcp.resource()` — сама повертає `InputRequiredResult` і зчитує відповіді повторної спроби з контексту: + +```python title="server.py" hl_lines="20 22 24" +--8<-- "docs_src/mrtr/tutorial004.py" +``` + +* Перший раунд повертає `InputRequiredResult`. Під час повторної спроби `ctx.input_responses` містить відповіді під тими самими ключами, і функція повертає свій звичайний результат — тут це повідомлення промпту, а для шаблонного ресурсу — вміст ресурсу. +* Заданий вами `request_state` запечатується, перш ніж потрапити в мережу, і перевіряється, коли повертається, як і все інше на сервері; розділ **[Захист `requestState`](#protecting-requeststate)** нижче пояснює, що дає запечатування і коли потрібно налаштовувати ключі. +* Функція `@mcp.tool()` може так само повертати результат напряму, коли форма із залежностями не підходить. +* Статичні функції `@mcp.resource()` участі не беруть: вони не приймають `Context`, тож ніяк не могли б прочитати повторну спробу. Запитувати можуть лише шаблонні ресурси. +* Правила поколінь, наведені нижче, діють без змін: повернення `InputRequiredResult` у сесії, старшій за 2026, дає ту саму помилку `-32603`, яку описує попередження. + +## Клієнтська частина {#the-client-side} + +`Client` виконує цикл за вас. + +Зареєструйте колбеки, які можуть знадобитися серверу (`elicitation_callback`, `sampling_callback`, `list_roots_callback`), і викличте інструмент. Коли надходить `InputRequiredResult`, `Client` передає кожен запис із `input_requests` відповідному колбеку, повторює виклик із відповідями та повернутим `request_state` і продовжує, доки не надійде `CallToolResult`: + +```python title="client.py" hl_lines="11 12" +--8<-- "docs_src/mrtr/tutorial003.py" +``` + +* Цей `elicitation_callback` — той самий, у який влучив би `elicitation/create` зворотного каналу сервера до 2026. Те саме стосується `sampling_callback` для `sampling/createMessage` і `list_roots_callback` для `roots/list`: у версії 2026-07-28 окремих RPC від сервера до клієнта вже немає, але ідентичні корисні навантаження `ElicitRequest` / `CreateMessageRequest` / `ListRootsRequest` їдуть усередині `input_requests` і потрапляють до тих самих трьох колбеків. Один набір колбеків обслуговує обидва покоління. +* `call_tool` повертає звичайний `CallToolResult`. Проміжні раунди для того, хто викликає, невидимі. +* `get_prompt` і `read_resource` запускають той самий цикл. + +!!! check + Не зареєструйте колбек — і цикл зламається вже на першому раунді: колбек-заглушка SDK + відповідає на кожну еліцитацію помилкою, а `call_tool` викидає `MCPError` з повідомленням + *«Elicitation not supported»*. + +Цикл обмежений. `Client(..., input_required_max_rounds=10)` — це ліміт за замовчуванням; якщо сервер і після нього продовжує повертати `InputRequiredResult`, `call_tool` викидає виняток. Якщо раунд містить лише `request_state` без `input_requests`, `Client` робить коротку паузу (50 мс, що подвоюється до стелі 250 мс) перед повторною спробою, щоб сервер, який лише каже *«ще не готово»*, не засипали безперервними опитуваннями. + +### Керування циклом власноруч {#driving-the-loop-yourself} + +Автоматичного циклу достатньо для клієнта в одному процесі. Беріть цикл у свої руки, коли: + +* Клієнт **розподілений**: процес, що показує запитання користувачеві, — не той процес, що викликав `call_tool`, тож повторну спробу надсилає інший робочий процес. `request_state` — це придатний до зберігання токен, який ви переносите через цю межу у власному сховищі, а `input_responses` — те, що інша сторона надсилає разом із ним. +* Потрібно **перевіряти** кожен раунд: логувати чи аудіювати кожен запис `input_requests`, відхиляти певні види запитів або застосовувати власну затримку між етапами. +* Потрібне обмеження за **реальним часом**, а не за кількістю раундів: обгорніть власний цикл у `anyio.fail_after(...)` замість того, щоб покладатися на `input_required_max_rounds`. + +Спустіться до сесії рівнем нижче, де `allow_input_required=True` віддає вам об'єднання типів напряму: + +```python title="client.py" hl_lines="12 13 19" +--8<-- "docs_src/mrtr/tutorial002.py" +``` + +* `client.session.call_tool(..., allow_input_required=True)` розширює тип повернення до `CallToolResult | InputRequiredResult`. Звужує його назад саме `isinstance`. +* `request_state` тепер у ваших руках. Збережіть його між етапами — і розмову можна продовжити з нового процесу. +* Для кожного запису в `input_requests` ви кладете `InputResponse` під **тим самим ключем** в `input_responses`. `fulfil` — місце для вашого UI; тут відповідь жорстко закодована. +* Та сама назва інструмента, ті самі `arguments` на кожному етапі. Повторна спроба — це той самий початковий виклик, виконаний ще раз, а не новий метод. + +## Захист `requestState` {#protecting-requeststate} + +Усе сказане вище трактує `request_state` як відлуння, і в переданих даних це справді все, чим він є. Але між етапами його тримає клієнт (а зберігати його між процесами — саме те, що схвалив попередній розділ), тож назад приходять **дані, надані клієнтом**: їх могли змінити, вони могли прострочитися або взагалі бути взяті з іншого виклику. Специфікація вимагає, щоб сервери захищали цілісність цього стану й відхиляли раунд, якщо перевірка не пройшла, — щоразу, коли стан може впливати на авторизацію, доступ до ресурсів або бізнес-логіку. + +`MCPServer` захищає його за замовчуванням. Кожен сервер запечатує вихідний `requestState` і перевіряє кожне відлуння — і стан резолверів, і стан, зібраний вручну, — ключем, згенерованим під час запуску процесу. Ви нічого не налаштовуєте, пишете відкритий текст і читаєте відкритий текст; мережею завжди передається лише непрозорий зашифрований токен. + +Ключ за замовчуванням живе й помирає разом із процесом — і це єдине, що треба знати перед розгортанням поза межами одного процесу: + +```python +from mcp.server.mcpserver import MCPServer, RequestStateSecurity + +# Multi-instance or restart-surviving: one or more shared secret keys (>= 32 bytes each). +mcp = MCPServer("fleet", request_state_security=RequestStateSecurity(keys=[key])) +``` + +* **Варіант за замовчуванням (без налаштування)** підходить для одного процесу: stdio або рівно один робочий процес HTTP. Повторна спроба, що потрапляє на інший робочий процес, інший екземпляр за балансувальником навантаження чи на той самий сервер після перезапуску, запечатана ключем, якого цей процес не має, — клієнт отримує незмінну відмову, наведену нижче, і мусить почати процедуру спочатку. +* **`keys=[...]`** обов'язковий щоразу, коли повторна спроба може дістатися **іншого екземпляра** (`uvicorn` із кількома робочими процесами, HTTP за балансувальником) або має переживати перезапуски: кожен екземпляр перевіряє те, що випустив будь-який інший. Той самий механізм, лише ваш секрет замість згенерованого. +* Для власної криптографії, наприклад KMS чи наявного сервісу токенів, передайте `RequestStateSecurity(codec=...)` замість `keys`; контракт описано нижче в розділі **[Власна криптографія](#bring-your-own-crypto)**. + +### Що містить запечатаний токен {#what-the-seal-carries} + +За замовчуванням чи з налаштуванням, у переданих даних `requestState` — це зашифрований автентифікований токен. Ваш код його ніколи не бачить: обробники й резолвери пишуть відкритий текст і читають відкритий текст (`ctx.request_state`); SDK запечатує на виході й перевіряє на вході. Окрім цілісності, кожен токен прив'язаний до: + +* **Часового вікна.** Кожен раунд запечатує заново зі свіжим терміном дії, тож `RequestStateSecurity(ttl=...)` (за замовчуванням 600 секунд) обмежує час на роздуми в межах одного раунду, а не всю процедуру. +* **Автентифікованого принципала.** Коли запит містить токен доступу OAuth, який перевірив SDK, стан прив'язується до клієнта, видавця й суб'єкта токена: стан, випущений для одного користувача, не пройде перевірку в іншого, навіть якщо обидва користувачі мають спільний OAuth-клієнт. Верифікатор, що не надає суб'єкта, послаблює прив'язку до самої лише ідентичності клієнта, яку за URL-ідентифікаторів клієнтів поділяють усі користувачі цього клієнтського ПЗ. Коли автентифікація завершується поза SDK (на проксі попереду) або транспорт не автентифікований, прив'язувати немає до кого, і ця перевірка бездіяльна — хіба що `RequestStateSecurity(bind_principal=...)` надасть принципала з вашого власного сигналу ідентичності. Хай які компоненти надає ваш верифікатор токенів, він має надавати їх послідовно: верифікатор, що додає суб'єкта в одних запитах і пропускає в інших, змінює принципала посеред процедури, і незавершені раунди відхиляються. +* **Початкового запиту.** Метод, назва інструмента чи промпту (або URI ресурсу) і дайджест аргументів. Токен, відтворений для іншого інструмента, інших аргументів чи іншого методу, не проходить перевірку. +* **Точного поставленого запитання.** Кожна відповідь резолвера прикріплена до сформованого запитання, яке показали клієнту, — і в раунді, коли вона щойно надійшла, і коли записану відповідь використовують повторно пізніше. Розгорніть нову версію з переформульованим повідомленням чи зміненою схемою — і сервер перепитає, замість того щоб спожити застарілу відповідь. Та сама прив'язка працює й у зворотний бік: виводьте повідомлення з аргументів інструмента, а не з даних конкретного виклику. Повідомлення, побудоване з мітки часу чи поточного курсу, формується по-різному в кожному раунді, тож кожна записана відповідь здається застарілою, і сервер перепитує, доки ліміт раундів клієнта не завершить виклик. + +Усе це — робота SDK, а не ваша і не кодека, якщо ви приносите власний. + +### Ротація ключів {#rotating-keys} + +`keys[0]` запечатує новий стан; перевіряє кожен ключ зі списку. Ротація без простою — це три фази, кожну з яких повністю розгортають перед наступною: + +```python +RequestStateSecurity(keys=[OLD, NEW]) # 1: every instance learns to verify NEW; OLD still mints +RequestStateSecurity(keys=[NEW, OLD]) # 2: NEW mints; in-flight OLD state keeps verifying +RequestStateSecurity(keys=[NEW]) # 3: one ttl after phase 2 is fully out, retire OLD +``` + +Ніколи не ставте новий ключ на випуск першим кроком: випуск під ключем, який котрийсь екземпляр іще не вміє перевіряти, обриває незавершені раунди посеред розгортання. + +Ключі обмежені одним сервісом. Запечатаний конверт також несе назву сервера як твердження про аудиторію (audience claim), тож токен, випущений іншим сервісом, який випадково має той самий секрет, усе одно відхиляється. Твердження розрізняє сервіси рівно настільки, наскільки розрізняються назви, тож сервер з явно заданою політикою мусить мати справжню назву або задати `RequestStateSecurity(audience=...)` — безіменний викидає виняток під час створення. `audience=` також слугує навмисним багатосервісним топологіям, де один сервіс має приймати стан, випущений іншим. (На варіант за замовчуванням без налаштування це не поширюється: його ключ ніколи не покидає процес, тож твердженню про аудиторію нема чого додати.) + +### Власна криптографія {#bring-your-own-crypto} + +`RequestStateSecurity(codec=...)` приймає будь-що з методами `seal(bytes) -> str` і `unseal(str) -> bytes`, що викидає `InvalidRequestState` для будь-якого токена, якого не випускало. Класична форма — конвертне шифрування з KMS, коли ви один раз розгортаєте ключ даних під час запуску, а криптографію для кожного токена виконуєте локально: + +```python title="server.py" hl_lines="12 26-27 34-35 38" +--8<-- "docs_src/mrtr/tutorial005.py" +``` + +TTL, прив'язка до принципала й прив'язка до запиту — **не** робота кодека: SDK вписує їх у корисне навантаження перед `seal` і перевіряє заново після `unseal` для кожного кодека. Єдині обов'язки кодека — цілісність (підроблено — отже, виняток) і, в ідеалі, конфіденційність. + +### Коли перевірка не проходить {#when-verification-fails} + +Кожна вхідна невдача — підробка, прострочення, відтворення для іншого запиту чи принципала або запечатування невідомим цьому серверу ключем — отримує ту саму відповідь: + +```json +{"code": -32602, "message": "Invalid or expired requestState"} +``` + +Одне незмінне повідомлення на всі причини, щоб передані дані ніколи не виказували, яка саме перевірка не пройшла; справжня причина потрапляє в лог сервера. Перевіряється кожен вхідний `requestState` у `tools/call`, `prompts/get` і `resources/read`, зокрема й той, що надходить для обробника, який ніколи не випускає стан. Найчастіша відмова на практиці — не зловмисник, а локальний для процесу ключ за замовчуванням, який зустрічає повторну спробу з-перед перезапуску чи з іншого екземпляра; клієнт починає процедуру спочатку, а `keys=[...]` — виправлення на випадок, коли це важливо. + +### Стан, зібраний вручну {#hand-built-state} + +`request_state`, який ви задаєте самі (повертаючи `InputRequiredResult` з функції інструмента, промпту чи шаблону ресурсу), запечатує й перевіряє той самий механізм, що й стан резолверів, без жодних змін у коді: пишете відкритий текст, читаєте відкритий текст, і всі наведені вище прив'язки діють. + +Єдине, що SDK не може закріпити за вас навіть із налаштуванням, — це ідентичність запитання: він не знає, якому з *ваших* запитань належить відповідь у вашому стані. Якщо ви зберігаєте відповіді з ключами-запитаннями, додайте до стану власний ідентифікатор запитання й перевіряйте його під час повторної спроби. + +Низькорівневий `Server` — це рівень без батарейок у комплекті: на відміну від `MCPServer`, тут нічого не запечатується, доки ви самі не додасте межу, а до того ваш `request_state` передається мережею рівно так, як написаний. Однорядкове ввімкнення показано на сторінці **[Низькорівневий Server](../advanced/low-level-server.md#the-other-handlers)**. + +## Результат версії 2026-07-28 {#a-2026-07-28-result} + +`InputRequiredResult` існує лише у версії протоколу **2026-07-28**. `Client(server)` у пам'яті узгоджує її за вас; мережею її виявляє `mode="auto"`. Після під'єднання `client.protocol_version` покаже, що саме ви отримали. + +!!! warning + У сесії, старшій за 2026, `InputRequiredResult` просто нікуди покласти. Поверніть його з обробника на + з'єднанні `mode="legacy"` — і виконавець не зможе серіалізувати його в узгоджену версію; + клієнт отримає помилку `-32603` *«Handler returned an invalid result»*. Сервер, що обслуговує + обидва покоління, мусить перевірити `ctx.protocol_version`, перш ніж братися за нього. + +!!! info + **Еліцитація в режимі URL** на з'єднанні 2026 їде саме цим механізмом. Запис у + `input_requests` — це `ElicitRequest`, чиї params є `ElicitRequestURLParams`; користувач + завершує позасмугову процедуру, і ваш клієнт повторює виклик. Той самий цикл, жодного нового API. + Половина про високорівневий сервер — на сторінці **[Еліцитація](elicitation.md)**. + +## Підсумки {#recap} + +* У версії 2026-07-28 сервер, якому посеред виклику потрібні дані, **повертає** `InputRequiredResult`. Він ніколи не відкриває запит до клієнта. +* `input_requests` — це те, що йому потрібно. `request_state` — непрозорий токен відновлення, який читає лише сервер. +* `Client` виконує цикл повторних спроб за вас: зареєструйте `elicitation_callback` / `sampling_callback` / `list_roots_callback` — і `call_tool` повертає звичайний `CallToolResult`. Обмежує його `input_required_max_rounds` (за замовчуванням 10). +* Щоб перевіряти або зберігати раунди, використовуйте `client.session.call_tool(..., allow_input_required=True)` і ведіть цикл `while isinstance(result, InputRequiredResult)` власноруч. +* З `@mcp.tool()` цей результат за вас створює залежність, яка запитує користувача (**[Залежності](dependencies.md)**); ручна форма — **низькорівневий** `Server`. +* Промпти й ресурси теж беруть участь: функція `@mcp.prompt()` або шаблонна `@mcp.resource()` сама повертає `InputRequiredResult` і читає `ctx.input_responses` під час повторної спроби. +* `requestState` повертається як дані, надані клієнтом, тож `MCPServer` за замовчуванням запечатує його — і стан резолверів, і стан, зібраний вручну, — локальним для процесу ключем; у розгортаннях із кількома екземплярами передавайте `RequestStateSecurity(keys=[...])` (або власний кодек), щоб кожен екземпляр міг перевірити те, що випустив інший. Запечатування прив'язує кожен токен до часового вікна, початкового запиту й автентифікованого принципала — коли запит містить автентифікацію, яку перевірив SDK, або коли `bind_principal=` надає ваш власний сигнал ідентичності (**[Захист `requestState`](#protecting-requeststate)**). + +Саме цей механізм замінює ініційоване сервером семплювання та решту зворотного каналу в стилі push; див. **[Застарілі можливості](../deprecated.md)**. diff --git a/i18n/uk/pages/handlers/progress.md b/i18n/uk/pages/handlers/progress.md new file mode 100644 index 0000000000..e6d9f4cb19 --- /dev/null +++ b/i18n/uk/pages/handlers/progress.md @@ -0,0 +1,122 @@ +--- +translation: + sections: [5315262fe26b33e1, 9d8e98840f1b78f0, 0284b215e85366c4, 8534d8dbb4053a70, 2966fac6fe697007] + tool: 1 +--- +# Перебіг виконання {#progress} + +Інструмент, який працює тридцять секунд і всі тридцять секунд мовчить, здається зламаним. + +**Сповіщення про перебіг виконання** це виправляють. Інструмент повідомляє, скільки вже зроблено, а клієнт вирішує, що з цього намалювати: смужку, спінер чи рядок у лозі. + +## Надсилання з інструмента {#report-it-from-the-tool} + +Додайте параметр **`Context`** і викличте `report_progress`: + +```python title="server.py" hl_lines="8 11" +--8<-- "docs_src/progress/tutorial001.py" +``` + +Три аргументи, а їхній зміст визначаєте ви: + +* `progress`: скільки вже зроблено. Специфікація вимагає, щоб значення **зростало** з кожним звітом; ніколи не повторюйте значення й не зменшуйте його. +* `total`: скільки роботи всього, якщо це відомо. Необов'язковий. +* `message`: один зрозумілий людині рядок про *цей* крок. Необов'язковий. + +`ctx` впроваджується завдяки анотації типів, і модель його ніколи не бачить: у вхідній схемі `import_catalog` є лише одна властивість — `urls`. Сторінка **[Об'єкт Context](context.md)** цілком присвячена цьому об'єкту; звітування про перебіг — лише одна з його функцій. + +## Отримання на клієнті {#listen-for-it-from-the-client} + +Клієнт підписується **окремо для кожного виклику**, передаючи `progress_callback=` у `call_tool`: + +```python title="client.py" hl_lines="7 16" +import anyio +from mcp import Client + +from server import mcp + + +async def show(progress: float, total: float | None, message: str | None) -> None: + print(f"{message} ({progress}/{total})") + + +async def main() -> None: + async with Client(mcp) as client: + result = await client.call_tool( + "import_catalog", + {"urls": ["https://example.com/a.json", "https://example.com/b.json"]}, + progress_callback=show, + ) + print(result.structured_content) + + +anyio.run(main) +``` + +Колбек — це `async`-функція, яка приймає рівно те, що повідомив сервер: `progress`, `total`, `message`. + +!!! info + `Client(mcp)` під'єднується безпосередньо до об'єкта сервера, у пам'яті, — це той самий клієнт, на якому + побудована сторінка **[Тестування](../get-started/testing.md)**. Параметр `progress_callback` однаковий + незалежно від транспорту, який використовує `Client`; а от *хронометраж*, який ви зараз побачите, + властивий саме з'єднанню в пам'яті. Воно запускає колбек одразу на місці, тож кожен звіт надходить до + того, як `call_tool` поверне результат. На справжньому транспорті сповіщення змагаються з результатом, + і повільний колбек може ще виконуватися після того, як `call_tool` уже повернув результат. + +### Спробуйте самі {#try-it} + +Покладіть `client.py` поруч із `server.py` і запустіть: + +```console +python client.py +``` + +```text +Imported https://example.com/a.json (1/2) +Imported https://example.com/b.json (2/2) +{'result': 'Imported 2 records.'} +``` + +Кожен `await ctx.report_progress(...)` на сервері перетворився на один виклик `show` на клієнті, у тому самому порядку, і обидва рядки надрукувалися **до** того, як `call_tool` повернув результат. Перебіг не пакується в результат — він надходить потоком, поки інструмент іще працює. + +!!! warning + `progress_callback` належить **виклику**, а не `Client`. Аргументу конструктора для нього немає, + бо різним викликам потрібні різні колбеки: один рухає смужку завантаження, наступний — пише + рядок у лог. + +!!! check + Тепер видаліть `progress_callback=show` і запустіть знову: + + ```text + {'result': 'Imported 2 records.'} + ``` + + Ні помилки, ні попередження, той самий результат. `report_progress` **нічого не робить, коли той, + хто викликає, не просив звітів про перебіг**, тож звітуйте безумовно й ніколи не замислюйтеся, + чи хтось слухає. + +## Коли загальний обсяг невідомий {#when-you-dont-know-the-total} + +`total` — для випадків, коли знаменник відомий. Часто це не так: ви вичерпуєте стрічку, проходите курсором, завантажуєте щось без заголовка довжини. + +Просто не вказуйте його: + +```python title="server.py" hl_lines="20" +--8<-- "docs_src/progress/tutorial002.py" +``` + +Колбек отримує `total=None`. Клієнт усе ще може показувати *активність* («уже імпортовано 3...»), але не відсоток. Не вигадуйте загальний обсяг заради гарнішої смужки. + +!!! tip + `progress` не мусить рахувати щось конкретне. Байти, рядки, сторінки — оберіть одиницю, яку + впізнає користувач, і обіцяйте лише той `total`, якого зможете дотриматися. + +## Підсумки {#recap} + +* `await ctx.report_progress(progress, total=None, message=None)` з будь-якого інструмента, що приймає `Context`. +* Клієнт передає `progress_callback=` у `call_tool`: для кожного виклику окремо, ніколи не в `Client`. +* Колбек має вигляд `async (progress, total, message) -> None` і спрацьовує, поки інструмент іще виконується. +* Немає колбека у виклику — `report_progress` нічого не робить. Звітуйте безумовно. +* Не вказуйте `total`, коли він невідомий; колбек отримає `None`. + +Перебіг виконання — це те, що інструмент під час роботи показує *користувачеві*. Рядки, які він записує в лог для *вас*, людини, що експлуатує сервер, — це інший канал: **[Логування](logging.md)**. diff --git a/i18n/uk/pages/handlers/sampling-and-roots.md b/i18n/uk/pages/handlers/sampling-and-roots.md new file mode 100644 index 0000000000..650676b992 --- /dev/null +++ b/i18n/uk/pages/handlers/sampling-and-roots.md @@ -0,0 +1,51 @@ +--- +translation: + sections: [5c82b20cbd65ded0, 9dc22632be79a533, 1fb8f452e990c456, 42666ab914ff0cb1, c4e0cb3667fd5ff9] + tool: 1 +--- +# Семплювання та кореневі каталоги {#sampling-and-roots} + +Обробник може попросити в під'єднаного клієнта ще дві речі: завершення від власної моделі клієнта (**семплювання** (sampling)) і робочі теки клієнта (**кореневі каталоги** (roots)). + +Обидві можливості досі працюють на кожній версії протоколу, яку підтримує SDK. Але перш ніж будувати на них дизайн, прочитайте попередження: + +!!! warning "Оголошено застарілими у специфікації 2026-07-28" + Семплювання та кореневі каталоги є застарілими починаючи з `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2577)). Вони лишаються повністю працездатними й залишатимуться у специфікації щонайменше дванадцять місяців, перш ніж їх можна буде вилучити, але нові реалізації не повинні на них спиратися. Запропоновані шляхи міграції: замість семплювання інтегруйтеся безпосередньо з API вашого постачальника LLM, а замість кореневих каталогів передавайте каталоги через параметри інструментів, URI ресурсів або конфігурацію сервера. Повний перелік для всього SDK — на сторінці **[Застарілі можливості](../deprecated.md)**. + +## Семплювання: позичити модель клієнта {#sampling-borrow-the-clients-model} + +Резолвер повертає `Sample(...)`, а інструмент отримує завершення — через той самий механізм залежностей, що виконує `Elicit` на сторінці **[Залежності](dependencies.md)**: + +```python title="server.py" hl_lines="10-15 19" +--8<-- "docs_src/sampling_and_roots/tutorial001.py" +``` + +* `Sample(messages, max_tokens=...)` повторює параметри `sampling/createMessage`. Впроваджене значення — це `CreateMessageResult` від клієнта; передайте `tools` або `tool_choice`, і натомість воно стане `CreateMessageResultWithTools`. +* Клієнт мусить оголосити можливість `sampling` (`sampling.tools`, якщо передаєте `tools` або `tool_choice`). Якщо він цього не зробив, виклик завершується помилкою протоколу `-32021`, замість того щоб надсилати запит, який клієнт не здатен обробити. Сесія до 2026 року без зворотного каналу (back-channel) завершується своєю звичною помилкою про відсутність зворотного каналу, бо надсилати нема куди. +* На `2026-07-28` запит доставляється всередині багатораундового потоку (**[Багатораундові запити](multi-round-trip.md)**); на `2025-11-25` це окремий запит до клієнта. Код однаковий в обох випадках, але пам'ятайте правило багатораундових запитів: запит має відтворюватися ідентично в усіх раундах повторних спроб, тож будуйте його лише з аргументів інструмента та інших стабільних даних. +* Не чіпайте `include_context`: значення, відмінні від `"none"`, самі є застарілими (SEP-2596) і потребують можливості, яку майже жоден клієнт не оголошує. + +## Кореневі каталоги: куди це покласти? {#roots-where-should-this-go} + +Кореневі каталоги — це теки, над якими, за словами клієнта, сервер може працювати. Це довідкова підказка, а не механізм контролю доступу. Резолвер повертає `ListRoots()`: + +```python title="server.py" hl_lines="10-11 15" +--8<-- "docs_src/sampling_and_roots/tutorial002.py" +``` + +* Впроваджений `ListRootsResult` містить список об'єктів `Root`: URI `file://` і необов'язкове відображуване ім'я. +* Перевірка та сама, що й для семплювання: без оголошеної можливості `roots` виклик завершується помилкою `-32021` замість надсилання запиту. + +По той бік з'єднання клієнт відповідає на обидва запити колбеками, які в нього вже є: `sampling_callback` і `list_roots_callback`, описаними на сторінці **[Колбеки клієнта](../client/callbacks.md)**. + +## На з'єднаннях покоління 2025 {#on-2025-era-connections} + +`ctx.session.create_message(...)` і `ctx.session.list_roots()` досі існують для коду, що керує сесією напряму. Вони працюють лише там, де є зворотний канал (з'єднання покоління 2025, не безстанові), а їх виклик викидає попередження про застарілість. Маркери резолверів, описані вище, — це підтримувана форма: вони обирають спосіб доставки за узгодженою версією й не попереджають. + +## Підсумки {#recap} + +* Повертайте `Sample(...)` або `ListRoots()` з резолвера; інструмент отримує `CreateMessageResult` або `ListRootsResult`, як і будь-яку іншу залежність. +* Клієнт мусить оголосити відповідну можливість, інакше виклик завершується помилкою `-32021` замість надсилання запиту. +* Обидві можливості оголошено застарілими у `2026-07-28`: поки що повністю працездатні, але непридатні для нових проєктів. Надавайте перевагу API постачальника над семплюванням і явним параметрам над кореневими каталогами. + +Як повідомляти, наскільки просунувся повільний інструмент: **[Перебіг виконання](progress.md)**. diff --git a/i18n/uk/pages/handlers/subscriptions.md b/i18n/uk/pages/handlers/subscriptions.md new file mode 100644 index 0000000000..f98d01ff19 --- /dev/null +++ b/i18n/uk/pages/handlers/subscriptions.md @@ -0,0 +1,152 @@ +--- +translation: + sections: [60a9de8a0bdaa531, 317bbe7e4355cdcc, a61d660c8029e04a, 8f7e82fcb88df8a9, b165db51249ff8ed, 266f56fb798068a4, 7c0e57030b622139, df18d7c2417a9883] + tool: 1 +--- +# Підписки {#subscriptions} + +Каталог сервера не є незмінним. Інструменти з'являються під час роботи, а вміст за URI ресурсу змінюється. + +**Підписки** — це спосіб, у який клієнт про це дізнається. Клієнт надсилає один запит `subscriptions/listen`, і відповідь на цей запит *і є* потоком: він залишається відкритим і несе сповіщення про зміни, про які попросив клієнт. + +## Публікація з інструмента {#publish-it-from-the-tool} + +Ваша частина роботи — один рядок: опублікувати зміну. + +```python title="server.py" hl_lines="20 32" +--8<-- "docs_src/subscriptions/tutorial001.py" +``` + +* `await ctx.notify_resource_updated("board://sprint")` доходить до кожного відкритого потоку, підписаного на цей URI. І більше ні до кого. +* `await ctx.notify_tools_changed()` доходить до кожного потоку, який попросив про зміни списку інструментів. Клієнт, що його отримав, знову викликає `tools/list` і тепер бачить `sprint_report`. +* Споріднені методи — `notify_prompts_changed()` і `notify_resources_changed()`. +* Немає підписників — немає роботи. Публікація на сервері без слухачів нічого не робить, тож перевіряти, чи хтось слухає, ніколи не потрібно. Ви лише повідомляєте, що змінилося. + +`MCPServer` обслуговує `subscriptions/listen` за вас. Зобов'язання на рівні протоколу (підтвердження першим кадром, фільтрація для кожного потоку, ідентифікатор підписки в кожному кадрі) — справа SDK. + +!!! check + У переданих даних потік, у фільтрі якого вказано `board://sprint`, після виконання `complete_task` має такий вигляд: + + ```json + {"method": "notifications/subscriptions/acknowledged", + "params": {"notifications": {"resourceSubscriptions": ["board://sprint"]}, "_meta": {"io.modelcontextprotocol/subscriptionId": "listen-1"}}} + + {"method": "notifications/resources/updated", + "params": {"uri": "board://sprint", "_meta": {"io.modelcontextprotocol/subscriptionId": "listen-1"}}} + ``` + + Зверніть увагу, чого оновлення *не* несе: самої дошки. Кожен кадр містить JSON-RPC-ідентифікатор запиту listen у `_meta`, і цей ідентифікатор є ідентифікатором підписки. Його створює клієнт: `Client` на Python використовує рядки на кшталт `"listen-1"`; інші клієнти можуть використовувати цілі числа. + +## Лише те, про що попросили {#only-what-was-asked-for} + +Фільтр — це контракт. Потік, що запросив зміни списку інструментів і один URI ресурсу, отримує ці два різновиди й нічого більше. Опублікуйте зміну промпту — і цей потік мовчатиме. + +`MCPServer` зіставляє URI ресурсів як точні рядки, тож потік, у якому вказано `board://sprint`, нічого не почує про `board://sprint/tasks/1`. Специфікація дозволяє серверу повідомляти про зміну підресурсу URI, на який оформлено підписку; `MCPServer` цього ніколи не робить, але клієнти побудовані так, щоб на це очікувати. + +Дві речі, якими потік *не* є: + +* **Це не журнал для повторного відтворення.** Обірваний потік зникає, а події, опубліковані, поки ніхто не був під'єднаний, у чергу не ставляться. Клієнти знову починають слухати й заново отримують дані. +* **Це не шлях покоління 2025.** Клієнтів, що викликали `resources/subscribe`, обслуговує `ctx.session.send_resource_updated(uri)`. Методи `notify_*` доходять лише до потоків `subscriptions/listen`. + +## Хто має право спостерігати {#deciding-who-may-watch} + +За замовчуванням задовольняється кожен запитаний різновид і URI: будь-хто може спостерігати за будь-яким URI, який ви публікуєте. Ваш обробник читання ніхто не викликає, бо ніхто нічого не читає — той, кого ваш обробник `files://{name}` відхилив би, усе одно може відкрити потік на `files://payroll.csv` і дізнатися, що файл змінився і коли. Вмісту він ніколи не дізнається й не може з'ясувати, що існує, бо невідомий URI теж задовольняється й просто ніколи не спрацьовує. Витік вузький, але реальний, тож поставте перевірку доступу, перш ніж публікувати URI окремих користувачів із сервера з кількома орендарями. + +Ця перевірка — middleware (проміжний шар). Воно бачить запит `subscriptions/listen` до того, як SDK його підтвердить, і відмовляє, коли хтось просить те, чого не має права читати: + +```python title="server.py" hl_lines="19-26 29" +--8<-- "docs_src/subscriptions/tutorial006.py" +``` + +* `ctx.params` — це сирий запит, тому middleware саме перевіряє його в `SubscriptionsListenRequestParams` і читає фільтр, який запросив клієнт. +* Відмова — це викинутий `MCPError` перед `call_next(ctx)`: клієнт отримує цю помилку й жодного потоку, а з'єднання живе далі. Тримайте повідомлення однаковим, без згадки URI, щоб відмова ніколи не підтверджувала, які саме URI захищені. +* Одна функція `can_access(user, uri)` відповідає на обидва питання. Обробник ресурсу викликає її на `resources/read`; middleware — на `subscriptions/listen`. Замініть таблицю базою даних або своєю системою RBAC, і обидва шляхи залишаться узгодженими. +* Рішення діє протягом усього життя потоку. Повторної перевірки для кожної події немає, тож якщо доступ може минути посеред потоку (токен, строк дії якого спливає), розірвіть з'єднання цього клієнта, коли це станеться. + +Повний контракт middleware, зокрема що ще воно обгортає і чому позначене як попереднє, описано на сторінці **[Middleware](../advanced/middleware.md)**. + +## Клієнтський бік {#the-client-end} + +Ось клієнт на іншому кінці цього потоку, що стежить за дошкою: + +```python title="client.py" hl_lines="15" +--8<-- "docs_src/subscriptions/tutorial003.py" +``` + +Вхід у `client.listen(...)` надсилає запит і чекає на ваше підтвердження, тож на початку блоку потік уже працює, а кожна типізована подія — це сигнал отримати дані заново, а не корисне навантаження. Оце й увесь контракт на одному екрані. Усе інше про клієнтський бік живе на окремій сторінці: спостереження поруч з основним потоком виконання, завершення потоків і повторне прослуховування. Див. **[Підписки](../client/subscriptions.md)** у розділі *Клієнти*. + +## Масштабування за межі одного процесу {#scaling-past-one-process} + +Публікації йдуть від обробника до відкритих потоків через `SubscriptionBus`. Типова шина живе в пам'яті: один процес і всі потоки в ньому. Це правильна відповідь, доки ви не запускаєте репліки за балансувальником навантаження, бо тоді потік клієнта прив'язаний до однієї репліки, а публікація на іншій репліці має до нього дійти. + +Цей стик реалізуєте ви: два методи поверх вашого бекенда pub/sub. + +```python +from collections.abc import Callable + +from redis.asyncio import Redis + +from mcp.server.mcpserver import MCPServer +from mcp.server.subscriptions import ServerEvent # SubscriptionBus is a Protocol: no base class + + +class RedisSubscriptionBus: + def __init__(self, redis: Redis) -> None: + self._redis = redis + self._listeners: dict[object, Callable[[ServerEvent], None]] = {} + + async def publish(self, event: ServerEvent) -> None: + await self._redis.publish("mcp-events", encode(event)) # to every replica + + def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]: + token = object() + self._listeners[token] = listener + + def unsubscribe() -> None: + self._listeners.pop(token, None) + + return unsubscribe + + +mcp = MCPServer("Sprint Board", subscriptions=RedisSubscriptionBus(redis)) +``` + +`encode` — ваша функція, як і задача-читач на кожній репліці, що декодує вхідні повідомлення й викликає кожного зареєстрованого слухача. Слухачі синхронні, не повинні викидати винятків і виконуються в циклі подій сервера. + +Шина несе типізовані значення `ServerEvent` — чотири невеликі dataclass-класи, ніколи не JSON-RPC. Проставлення ідентифікаторів, фільтрація та життєві цикли потоків залишаються в SDK, тож реалізація шини не може зламати протокол. Вона може лише переносити події між процесами. + +Щоб публікувати поза запитом, створіть шину самі, аби мати на неї посилання. `MCPServer` будує її всередині, коли ви нічого не передаєте, і не надає до неї доступу. + +```python +from mcp.server.subscriptions import InMemorySubscriptionBus, ToolsListChanged + +bus = InMemorySubscriptionBus() +mcp = MCPServer("Sprint Board", subscriptions=bus) + + +async def tools_reloaded() -> None: + await bus.publish(ToolsListChanged()) # from a lifespan task, a webhook, anywhere +``` + +## Низькорівнева композиція {#the-low-level-composition} + +На низькорівневому `Server` нічого заздалегідь не під'єднано, і ті самі частини збираються в три рядки: + +```python title="server.py" hl_lines="8-9 47" +--8<-- "docs_src/subscriptions/tutorial002.py" +``` + +* Шина належить вам, тож ви публікуєте в неї напряму: `await bus.publish(ResourceUpdated(uri=...))`. Розмістіть її там, де до неї дістануться обробники: тут — на рівні модуля, у більшому застосунку — у життєвому циклі (lifespan). +* `ListenHandler(bus)` — той самий обробник, що його реєструє `MCPServer`, а `on_subscriptions_listen=` — звичайний слот обробника. Поставте в цей слот власний викликний об'єкт для іншої семантики, і зобов'язання специфікації переходять до вас: спочатку підтвердити, проставити в кожному кадрі ідентифікатор підписки, не доставляти нічого поза фільтром. +* `ListenHandler.close()` коректно завершує кожен відкритий потік. Кожен отримує результат запиту listen останнім кадром — так специфікація каже, що сервер свідомо завершив підписку. Метод повертається раніше, ніж ці потоки встигнуть усе надіслати, тож дайте їм мить, перш ніж згортати транспорт. Без нього потоки завершуються, коли клієнт від'єднується. + +## Підсумки {#recap} + +* Клієнт погоджується одним запитом `subscriptions/listen`, і відповідь — це потік. Його обслуговування вбудоване. +* Ви публікуєте через `ctx.notify_*`, а SDK проставляє ідентифікатори, фільтрує й керує життєвим циклом. +* Події — це сигнали, а не корисне навантаження. Обидва кінці отримують дані заново. +* Клієнтський бік — це `async with client.listen(...)`: докладніше — на сторінці **[Підписки](../client/subscriptions.md)** у розділі *Клієнти*. +* На низькорівневому `Server` ті самі частини ви збираєте самі: шина, `ListenHandler(bus)`, слот `on_subscriptions_listen`. +* Горизонтальне масштабування — це реалізація `SubscriptionBus` (два методи) і передавання її як `MCPServer(subscriptions=...)`. + +Як запустити сервер, що все це обслуговує, за однією реплікою чи за двадцятьма, — на сторінці **[Розгортання й масштабування](../run/deploy.md)**. diff --git a/i18n/uk/pages/index.md b/i18n/uk/pages/index.md new file mode 100644 index 0000000000..0089a48c47 --- /dev/null +++ b/i18n/uk/pages/index.md @@ -0,0 +1,102 @@ +--- +translation: + sections: [154c4309937b9f85, 3ad8fc6caa76a9b0, a07f3f5b151ab746, bf6e476b712930c0, cf0b1f13978c6623] + tool: 1 +--- +# MCP Python SDK {#mcp-python-sdk} + +!!! info "Це документація до v2, поточної стабільної лінійки випусків" + Уперше працюєте з v2 або переходите з v1? **[Що нового у v2](whats-new.md)** — п'ятихвилинний огляд змін, а **[Посібник з міграції](migration.md)** описує кожну несумісну зміну. + Досі на v1.x? Її документація — на сторінці [документації v1.x](https://py.sdk.modelcontextprotocol.io/v1/). + Щось незручне чи незрозуміле? [Розкажіть нам](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml). + +**Model Context Protocol (MCP)** дає застосункам змогу надавати контекст LLM у стандартизований спосіб, відокремлюючи *надання* контексту від самої взаємодії з LLM. + +Це офіційний Python SDK для нього. З ним можна: + +* **Створювати MCP-сервери**, що надають інструменти, ресурси та промпти будь-якому MCP-хосту. +* **Створювати MCP-клієнти**, що під'єднуються до будь-якого MCP-сервера. +* Працювати з усіма стандартними транспортами: stdio, Streamable HTTP і SSE. + +## Вимоги {#requirements} + +Python 3.10+. + +## Встановлення {#installation} + +=== "uv" + + ```bash + uv add "mcp[cli]" + ``` + +=== "pip" + + ```bash + pip install "mcp[cli]" + ``` + +Додатковий набір `[cli]` дає команду `mcp` — вона знадобиться для розробки. +Для чого потрібна кожна залежність, описано на сторінці [Встановлення](get-started/installation.md). + +## Приклад {#example} + +### Створення {#create-it} + +Створіть файл `server.py`: + +```python title="server.py" +--8<-- "docs_src/index/tutorial001.py" +``` + +Це вже готовий MCP-сервер. + +Він надає один **інструмент**, `add`, і один шаблонний **ресурс**, `greeting://{name}`. + +### Запуск {#run-it} + +```console +uv run mcp dev server.py +``` + +Ця команда запускає сервер і відкриває [MCP Inspector](https://github.com/modelcontextprotocol/inspector) — інтерактивний інтерфейс, щоб його дослідити. Відкрийте URL, який вона надрукує. + +!!! note + Inspector — це застосунок на Node.js, тому `mcp dev` потребує `npx` у вашому `PATH`. + +### Спробуйте самі {#try-it} + +В Inspector перейдіть на вкладку **Tools** і викличте `add` з `a=1`, `b=2`. + +У відповідь приходить `3`. ✨ + +Цю форму (обов'язкове цілочислове поле для `a` та ще одне для `b`) Inspector побудував з ваших анотацій типів. Так само зробить Claude і будь-який інший MCP-хост. + +Тепер перейдіть на вкладку **Resources** і прочитайте `greeting://World`: + +```text +Hello, World! +``` + +### Підсумки {#recap} + +Погляньте ще раз на те, чого ви **не** писали: + +* Жодної JSON Schema. `a: int, b: int` — це *і є* схема. +* Жодного розбору запитів, серіалізації чи коду валідації. +* Жодної обробки протоколу взагалі. + +Ви написали дві функції Python з анотаціями типів і рядком документації. Решту робить SDK. + +## Що далі {#where-to-go-next} + +* **[Початок роботи](get-started/index.md)** проведе від встановлення до робочого, протестованого сервера. +* Створюєте застосунок, що *використовує* MCP-сервери? Почніть із розділу **[Клієнти](client/index.md)**. +* Уже маєте застосунок на FastAPI чи Starlette? Сторінка **[Додавання до наявного застосунку](run/asgi.md)** показує, як змонтувати в нього MCP-сервер. +* Шукаєте точне повідомлення про помилку? **[Усунення неполадок](troubleshooting.md)** упорядковано за дослівним текстом. +* Цікаво, що змінилося у v2? **[Що нового у v2](whats-new.md)** — п'ятихвилинний огляд. +* Переходите з v1? Почніть із **[Посібника з міграції](migration.md)**. +* Шукаєте точну сигнатуру? **[Довідник API](api/mcp/index.md)** згенеровано з вихідного коду. +* Читаєте разом з LLM? Цю документацію також опубліковано у форматі [llms.txt](https://llmstxt.org/): + [llms.txt](https://py.sdk.modelcontextprotocol.io/llms.txt) — це покажчик сторінок, а + [llms-full.txt](https://py.sdk.modelcontextprotocol.io/llms-full.txt) містить усі сторінки в одному файлі. diff --git a/i18n/uk/pages/protocol-versions.md b/i18n/uk/pages/protocol-versions.md new file mode 100644 index 0000000000..fcbb234723 --- /dev/null +++ b/i18n/uk/pages/protocol-versions.md @@ -0,0 +1,132 @@ +--- +translation: + sections: [478fd619e5f90ef8, aef094a00e44e248, bab8cbf3449fa7e9, df1809b15a58335b, 5f9d8c2336ed0239, f54974398e43ddef, b24443dd78584870] + tool: 1 +--- +# Версії протоколу {#protocol-versions} + +У MCP є два покоління. + +Сервери, випущені до 2026-07-28, відкривають кожне з'єднання **рукостисканням `initialize`**: клієнт пропонує версію, сервер відповідає своєю, клієнт підтверджує — і все це до першого корисного запиту. Сервери версії **2026-07-28** обходяться без рукостискання. Клієнт надсилає один пробний запит **`server/discover`**, а сервер відповідає на нього всім одразу в єдиному результаті. + +Перейматися цим майже ніколи не доводиться, бо `Client` домовляється за вас. Ця сторінка — про єдиний аргумент конструктора, який цим керує, `mode=`, і про три випадки, коли його змінюють. + +## `mode="auto"` {#modeauto} + +```python title="client.py" hl_lines="14-15" +--8<-- "docs_src/protocol_versions/tutorial001.py" +``` + +`mode` не передано, тож діє значення за замовчуванням: `"auto"`. Вхід в `async with` надсилає один пробний запит `server/discover` найновішої версії, якою володіє цей SDK. Далі: + +* **Сучасний сервер** відповідає на нього. Клієнт приймає результат. Один раунд обміну — і готово. +* **Старіший сервер** ніколи не чув про `server/discover` і повертає помилку. Клієнт переходить до класичного рукостискання `initialize` і бере те, про що воно домовиться. + +Так чи так, з'єднання встановлено, а `client.protocol_version` підкаже, який варіант спрацював: + +```text +2026-07-28 +``` + +Оце й уся можливість. Один `Client`, сервер будь-якого покоління, жодних розгалужень у вашому коді. + +!!! info + `MCPServer` відповідає на `server/discover` на кожному транспорті — in-memory, stdio, streamable + HTTP — тож із власним сервером `auto` завжди зупиняється на `2026-07-28`. Запасний шлях + спрацьовує лише проти справжнього сервера, випущеного до 2026, — саме тоді, коли він і потрібен. + +## `mode="legacy"` {#modelegacy} + +```python title="client.py" hl_lines="14" +--8<-- "docs_src/protocol_versions/tutorial002.py" +``` + +`mode="legacy"` ніколи не надсилає пробного запиту. Він виконує рукостискання `initialize` — таке саме з'єднання, яке відкриває клієнт, випущений до 2026. + +```text +2025-11-25 +``` + +Той самий сервер. Він чудово говорить `2026-07-28`; це ви сказали клієнту не питати. + +Це потрібно для можливостей у **push-стилі**. + +Запит, ініційований сервером, — це коли сервер викликає *вас*: `ctx.elicit(...)` показує форму вашому користувачу, семплювання (sampling) просить вашу модель про завершення посеред виклику інструмента. Цей канал існує лише в сесії покоління з рукостисканням. + +У 2026-07-28 його вже немає. Сервер *повертає* свої запитання, а ви повторюєте виклик із відповідями (**[Багатораундові запити (multi-round-trip)](handlers/multi-round-trip.md)**). + +`mode="auto"` дає рукостискання лише тоді, коли сервер застарий для чогось іншого. `mode="legacy"` його гарантує. Беріться за нього щоразу, коли передаєте в `Client(...)` параметр `sampling_callback`, `elicitation_callback`, який має оброблятися як запит, або `message_handler`. Кожен із них розібрано на сторінці **[Колбеки клієнта](client/callbacks.md)**. + +## Фіксація версії {#pinning-a-version} + +`mode` також приймає рядок сучасної версії протоколу. Сьогодні ця множина — рівно `["2026-07-28"]`. + +```python title="client.py" hl_lines="14" +--8<-- "docs_src/protocol_versions/tutorial003.py" +``` + +Фіксація не надсилає **нічого**. Ні пробного запиту, ні рукостискання. Клієнт локально приймає `2026-07-28`, і з'єднання готове тієї ж миті, коли завершується вхід в `async with`. + +Фіксація — це обіцянка, яку даєте *ви*: ви вже знаєте, що сервер говорить цією версією. Клієнт не перевіряє. + +!!! check + Фіксація — не виявлення. Виведіть `client.server_info` — і ціну одразу видно: + + ```text + None + ``` + + Клієнт ніколи не питав сервер, хто він, тож `server_info` дорівнює `None`. З `client.server_capabilities` + та сама історія: кожна можливість — `None`. Виклики інструментів усе ще працюють (протоколу нічого з цього не потрібно); + код, який читає `server_capabilities`, щоб вирішити, що пропонувати, — ні. + + Наступний розділ це виправляє. + +Фіксувати можна лише сучасні версії. Рядок покоління з рукостисканням відхиляється ще під час створення об'єкта, до будь-якого вводу-виводу, а помилка підказує, що написати натомість: + +```text +ValueError: mode must be 'legacy', 'auto', or one of ['2026-07-28']; got '2025-06-18' ('2025-06-18' is a handshake-era version; use mode='legacy') +``` + +## Повторне під'єднання з `prior_discover` {#reconnecting-with-prior_discover} + +Пробний запит дешевий, але це все одно раунд обміну, за який доводиться платити при кожному повторному під'єднанні, а відповідь майже ніколи не змінюється. + +Тож збережіть її. Після з'єднання в режимі `auto` `client.session.discover_result` містить точний `DiscoverResult`, який надіслав сервер: його `supported_versions`, `capabilities`, `instructions` і відомості про себе, які сервер записав у `_meta` результату. Наступного разу передайте його назад як `prior_discover=`: + +```python title="client.py" hl_lines="15 17" +--8<-- "docs_src/protocol_versions/tutorial004.py" +``` + +```text +2026-07-28 +Bookshop +``` + +Друге з'єднання зробило **нуль** раундів узгодження й усе одно точно знає, з ким розмовляє. Оце і є зафіксований режим, зроблений як слід: `mode=` називає версію, `prior_discover=` надає відомості про сервер. ✨ + +`DiscoverResult` — модель Pydantic. `saved.model_dump_json()` іде у файл або кеш; `DiscoverResult.model_validate_json(...)` відновлює його в наступному процесі. + +!!! tip + `prior_discover=` щось робить лише тоді, коли `mode` — це зафіксована версія. За `"auto"` клієнт + усе одно надсилає серверу пробний запит, а за `"legacy"` параметр ігнорується. + +## Чотири режими {#the-four-modes} + +| Що пишете | Трафік узгодження | Що отримуєте | +| --- | --- | --- | +| `Client(target)` | один пробний запит `server/discover`; рукостискання `initialize`, якщо він не вдався | найновіша версія, якою володіють обидві сторони, будь-якого покоління | +| `Client(target, mode="legacy")` | рукостискання `initialize` | версія покоління з рукостисканням; запити, ініційовані сервером, працюють | +| `Client(target, mode="2026-07-28")` | немає | ця версія, зафіксована, а `server_info` дорівнює `None` | +| `Client(target, mode="2026-07-28", prior_discover=saved)` | немає | ця версія, зафіксована, *і* відомості про сервер, збережені минулого разу | + +## Підсумки {#recap} + +* У MCP є покоління з рукостисканням (до `2025-11-25` включно, рукостискання `initialize`) і сучасне покоління (`2026-07-28`, `server/discover`). `Client` з'єднує їх. +* `mode="auto"` — значення за замовчуванням: пробний запит, потім запасний шлях. Не чіпайте його, якщо жоден з інших трьох рядків не про вас. +* `client.protocol_version` — завжди відповідь на питання «що я отримав?». +* `mode="legacy"` примусово вмикає рукостискання. Саме це потрібно для запитів, ініційованих сервером: семплювання, push-еліцитація (elicitation), `message_handler`. +* Фіксація версії (`mode="2026-07-28"`) взагалі не надсилає трафіку узгодження — ціною того, що `client.server_info` дорівнює `None`. +* `prior_discover=` повертає цю ціну: збережіть `client.session.discover_result`, під'єднайтеся з ним знову — і отримаєте обидва. + +Сучасне з'єднання не має push-каналу, то як сервер 2026 ставить вам запитання посеред виклику? Він його повертає: **[Багатораундові запити](handlers/multi-round-trip.md)**. diff --git a/i18n/uk/pages/run/asgi.md b/i18n/uk/pages/run/asgi.md new file mode 100644 index 0000000000..8525330fea --- /dev/null +++ b/i18n/uk/pages/run/asgi.md @@ -0,0 +1,145 @@ +--- +translation: + sections: [1062ef792791488a, 4be2b831547184a9, 374b049e770385f2, b72f6947089e6de0, b172c9db7831bb31, 70b9ece244ca1b0c, cba78e052898c3f6, f06bdb541cb0b469, fb82d526320b7cc3] + tool: 1 +--- +# Додавання до наявного застосунку {#add-to-an-existing-app} + +`mcp.run("streamable-http")` запускає вебсервер за вас. Інколи це не те, що потрібно: MCP-сервер — лише частина більшого вебзастосунку, або ASGI-розгортання у вас уже є. + +Для цього `mcp.streamable_http_app()` повертає **застосунок Starlette**. + +Застосунок Starlette — це ASGI-застосунок, тож будь-що, що вміє розміщувати ASGI (uvicorn, Hypercorn, інший Starlette, FastAPI), може розмістити й ваш MCP-сервер. + +## Застосунок {#the-app} + +```python title="server.py" hl_lines="12" +--8<-- "docs_src/asgi/tutorial001.py" +``` + +`app` — звичайний ASGI-застосунок. Передайте його будь-якому ASGI-серверу: + +```console +uvicorn server:app +``` + +Кінцева точка MCP розташована на `/mcp`, тож клієнт під'єднується до `http://127.0.0.1:8000/mcp`. + +Застосунок уже містить дві речі: + +* Один маршрут, `/mcp` — кінцева точка Streamable HTTP. +* **Життєвий цикл** (lifespan), який запускає `mcp.session_manager` — об'єкт, що відповідає за фонову роботу кожної активної сесії. + +Запустіть застосунок окремо (`uvicorn server:app`) — і про жодне з них думати не доведеться. + +!!! tip + `streamable_http_app()` приймає ті самі іменовані аргументи, що й `mcp.run("streamable-http", ...)`, + окрім `port`: порт належить тому, що обслуговує застосунок. `host` усе ще приймається, але тут + нічого не прив'язує; що він насправді контролює, пояснює **[Розгортання та масштабування](deploy.md)**. + Самі параметри описано на сторінці **[Запуск сервера](index.md)**. + +`mcp.sse_app()` робить те саме для транспорту SSE, який уже витіснено новішим. + +## Лише localhost, доки не вкажете інше {#localhost-only-until-you-say-otherwise} + +За замовчуванням застосунок відповідає **лише** на запити, адресовані localhost. `streamable_http_app()` +не може знати, за яким іменем хоста його обслуговуватимуть, тому вмикає захист від DNS rebinding із +найбезпечнішим можливим списком дозволених хостів; на вашій машині це саме те, що треба. За розгортання +за справжнім іменем хоста це означає, що **кожен запит відхиляється з `421 Misdirected Request`**, доки +ви не передасте в `transport_security=` список того, що справді обслуговуєте. До нічого з написаного вами +справа навіть не доходить. Цей список, як і все інше на шляху від робочого застосунку до справжнього +імені хоста, — на сторінці **[Розгортання та масштабування](deploy.md)**. + +## Монтування {#mounting-it} + +Щойно MCP-сервер стає *частиною* більшого застосунку, ви кладете застосунок усередину `Mount`. І щойно ви це робите, життєвий цикл стає вашим клопотом: + +```python title="server.py" hl_lines="18-21 25-26" +--8<-- "docs_src/asgi/tutorial002.py" +``` + +* `Mount("/", ...)` разом із типовим шляхом `/mcp` залишає кінцеву точку на `/mcp`. Starlette перебирає маршрути по черзі, а `Mount("/")` збігається з **кожним** шляхом, тож власні маршрути ставте в списку *перед* ним. Усе, що після нього, недосяжне. +* Функція `lifespan` входить у `mcp.session_manager.run()` на весь час життя **хост**-застосунку. Це той рядок, про який усі забувають. +* `mcp.session_manager` існує лише *після* виклику `streamable_http_app()`. Саме тому маршрути будуються на рівні модуля, а до менеджера звертаються лише всередині життєвого циклу. + +Маршрут `Host` у Starlette працює так само: замініть `Mount("/", ...)` на `Host("mcp.example.com", ...)`, щоб маршрутизувати за іменем хоста, а не за шляхом. Правило про життєвий цикл не змінюється, як і правило про безпеку транспорту. Маршрут `Host("mcp.example.com", ...)` отримує лише запити, адресовані цьому імені хоста, але власний список дозволених хостів транспорту (**[Розгортання та масштабування](deploy.md)**) усе одно спрацьовує першим. Без `"mcp.example.com"` у ньому цей маршрут відповідає на кожен із них кодом `421`. + +!!! warning "Життєвим циклом володіє хост-застосунок" + `streamable_http_app()` під'єднує `session_manager.run()` до життєвого циклу застосунку Starlette, + який повертає, але **життєвий цикл змонтованого підзастосунку ніколи не виконується**. Змонтуйте + застосунок — і цей вбудований життєвий цикл стане мертвим кодом. Той застосунок, що стоїть на + вершині вашого ASGI-стека, мусить увійти в `mcp.session_manager.run()` у власному життєвому циклі. + +!!! check + Видаліть рядок `lifespan=lifespan` і запустіть сервер. Він запускається. Маршрут розв'язується. + А потім перший запит до `/mcp` завершується помилкою: + + ```text + RuntimeError: Task group is not initialized. Make sure to use run(). + ``` + + Ніщо не запускає менеджер сесій, окрім його `run()`. + +## Два сервери, один застосунок {#two-servers-one-app} + +Кожен `MCPServer` — це окремий застосунок з окремим менеджером сесій. Монтуйте скільки завгодно; входьте в кожен менеджер з одного життєвого циклу хоста: + +```python title="server.py" hl_lines="27-30 35-36" +--8<-- "docs_src/asgi/tutorial003.py" +``` + +* `AsyncExitStack` входить в обидва менеджери; вони стартують разом і завершуються у зворотному порядку. +* Кінцеві точки — `/notes/mcp` і `/tasks/mcp`: префікс монтування плюс типовий шлях. + +## Зміна шляху {#changing-the-path} + +Цей кінцевий `/mcp` — це `streamable_http_path`. Задайте йому `"/"` — і префікс монтування стане повним публічним шляхом: + +```python title="server.py" hl_lines="25" +--8<-- "docs_src/asgi/tutorial004.py" +``` + +Тепер клієнти під'єднуються до `/notes`, а не до `/notes/mcp`. + +## CORS для браузерних клієнтів {#cors-for-browser-clients} + +Браузерному клієнту від вас потрібні два дозволи: **надсилати** свої заголовки MCP-запитів і **читати** той, що MCP надсилає у відповідь. І те, і те — налаштування CORS на хост-застосунку, і наведений вище список дозволених значень безпеки транспорту має з ними узгоджуватися: + +```python title="server.py" hl_lines="27-30 33 35-49" +--8<-- "docs_src/asgi/tutorial005.py" +``` + +* `allow_headers` — та половина, про яку всі забувають. Браузер надсилає **preflight-запит** перед кожним MCP-запитом, бо `Content-Type: application/json` і заголовки запиту `Mcp-*` не входять до безпечного списку CORS, а заголовок, якого preflight не дозволив, — це запит, який браузер ніколи не надішле. (`allow_headers=["*"]` теж працює: Starlette відповідає на preflight усім, про що той попросив.) +* `expose_headers=["Mcp-Session-Id"]` — половина для читання. Streamable HTTP повертає ідентифікатор сесії в цьому заголовку відповіді, а браузери ховають заголовки відповіді від JavaScript, якщо CORS не розкриває їх поіменно. Без нього клієнт ніколи не зможе зробити другий запит. +* `allow_origins` — ваше рішення, а не MCP. Будьте точні й віддзеркальте його в `allowed_origins=` вище: дотримання CORS забезпечує браузер, але сервер перевіряє `Origin` сам, і джерело, якому транспорт не довіряє, отримує `403` навіть після бездоганного preflight. +* `allow_methods` перелічує три методи, які використовує Streamable HTTP: `POST`, щоб надсилати повідомлення, `GET`, щоб відкрити потік від сервера до клієнта, `DELETE`, щоб завершити сесію. + +## Власні маршрути {#custom-routes} + +`@mcp.custom_route()` реєструє звичайну HTTP-кінцеву точку в тому самому застосунку — для речей, які потрібні кожному розгорнутому сервісу й не мають нічого спільного з MCP: перевірка стану, колбек OAuth. + +```python title="server.py" hl_lines="15-17" +--8<-- "docs_src/asgi/tutorial006.py" +``` + +* Обробник — звичайний Starlette: `async`-функція з `Request` у `Response`. +* `streamable_http_app()` підхоплює кожен власний маршрут. `app.routes` тепер — `/mcp` і `/health`. +* `GET /health` відповідає `{"status": "ok"}` — і жодного MCP поблизу. + +!!! warning + Власні маршрути **ніколи не автентифікуються**, навіть коли решта сервера — так. Це навмисно: + перевірки стану й колбеки OAuth мають бути досяжні ще до появи будь-якого токена. + Не ховайте за ними нічого приватного. + +## Підсумки {#recap} + +* `mcp.streamable_http_app()` повертає застосунок Starlette з одним маршрутом, `/mcp`. Його може запустити будь-який ASGI-сервер. +* За замовчуванням застосунок відповідає лише на запити, адресовані localhost, а за справжнім іменем хоста відхиляє все кодом `421`, доки ви не передасте в `transport_security=` список дозволених хостів. Цим, як і рештою шляху до робочого середовища, опікується **[Розгортання та масштабування](deploy.md)**. +* `Mount` (або `Host`) розміщує його всередині більшого застосунку Starlette чи FastAPI. +* **Монтування вимикає вбудований життєвий цикл.** Життєвий цикл хост-застосунку мусить увійти в `mcp.session_manager.run()`, інакше перший запит завершиться помилкою. +* Кілька серверів в одному застосунку — це кілька монтувань і один життєвий цикл, що входить у кожен менеджер сесій. +* `streamable_http_path="/"` переносить кінцеву точку на сам префікс монтування. +* Браузерним клієнтам потрібен CORS: `allow_headers` для заголовків запиту `Mcp-*`, `expose_headers=["Mcp-Session-Id"]` для відповіді. +* `@mcp.custom_route()` додає звичайні HTTP-кінцеві точки без автентифікації поруч із `/mcp`. + +Щойно сервер стане досяжним за справжньою URL-адресою, **[Клієнт](../client/index.md)** під'єднається до нього за цією URL-адресою, а не через об'єкт сервера. diff --git a/i18n/uk/pages/run/authorization.md b/i18n/uk/pages/run/authorization.md new file mode 100644 index 0000000000..d293bd321c --- /dev/null +++ b/i18n/uk/pages/run/authorization.md @@ -0,0 +1,130 @@ +--- +translation: + sections: [d62c13457fc4a534, 80e73abaca6e0652, d1dc4c54cd00ec9c, 14ad3bc7904036bb, 5225f127bc1b9c77, fe1626fdd5aad1da, 4556cb7ea1a04a31] + tool: 1 +--- +# Авторизація {#authorization} + +Через Streamable HTTP ваш MCP-сервер — це звичайний вебсервіс, і захищати його слід так само, як будь-який інший вебсервіс: bearer-токенами OAuth 2.1. + +У термінах OAuth ваш сервер — це **сервер ресурсів** (resource server). Він ніколи нікого не автентифікує й ніколи не видає токенів. Він робить одне: дивиться на заголовок `Authorization` у кожному запиті й вирішує, чи придатний токен у ньому. + +Ця сторінка — про серверний бік. Клієнт, який знаходить ваш сервер авторизації й отримує токен, описано на сторінці **[Клієнти OAuth](../client/oauth-clients.md)**. + +## Три сторони {#the-three-parties} + +* **Сервер авторизації** автентифікує людей і видає токени доступу. Ви його не пишете. Це ваш постачальник ідентичності (Auth0, Keycloak, Entra, ваш власний). +* **Сервер ресурсів** — це ваш MCP-сервер. Він перевіряє токен у кожному запиті. +* **Клієнт** з'ясовує, якому серверу авторизації ви довіряєте, отримує від нього токен і надсилає його вам як `Authorization: Bearer `. + +Оце й увесь трикутник. Усе на цій сторінці стосується середнього пункту. + +## Верифікатор токенів {#a-token-verifier} + +SDK не має власної думки про те, який вигляд має дійсний токен. Це визначаєте ви, реалізувавши **`TokenVerifier`**: + +```python title="server.py" hl_lines="12-14 19-24" +--8<-- "docs_src/authorization/tutorial001.py" +``` + +* `TokenVerifier` — це протокол з одним асинхронним методом. `verify_token` отримує сирий токен із заголовка `Authorization` і повертає **`AccessToken`**, якщо він дійсний, або `None`, якщо ні. Більше нічого реалізовувати не треба. +* Цей верифікатор шукає токен у таблиці. Справжній перевіряє підпис JWT або звертається до кінцевої точки інтроспекції токенів сервера авторизації. Цей код — ваш; SDK лише викликає його. +* `token_verifier=` і `auth=` завжди йдуть разом. Передайте одне без іншого — і `MCPServer(...)` викине `ValueError` ще до того, як обслужить хоч один запит. + +`AuthSettings` — це публічне обличчя вашого сервера ресурсів: + +* `issuer_url`: сервер авторизації, що видає ваші токени. +* `resource_server_url`: публічний URL цієї MCP-кінцевої точки. Він указує, для *якого* ресурсу призначено токен, і саме тут розміщено документ виявлення. +* `required_scopes`: кожен токен мусить містити їх усі. + +!!! tip + `examples/servers/simple-auth/` у репозиторії SDK містить `IntrospectionTokenVerifier`, який звертається + до кінцевої точки [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662) справжнього сервера авторизації. Саме таку форму мають більшість продакшн-верифікаторів. + +## Що з'являється через HTTP {#what-you-get-over-http} + +Авторизація живе в HTTP-заголовках, тож існує лише на HTTP-транспортах. Запустіть сервер на тому, який розгортаєте: `mcp.run(transport="streamable-http")` розміщує його на `http://127.0.0.1:8000/mcp`, а решта — на сторінці **[Запуск сервера](index.md)**. Тепер застосунок має два маршрути: + +```text +/mcp +/.well-known/oauth-protected-resource/mcp +``` + +Ви зареєстрували один інструмент. Другий маршрут належить SDK. + +### Виявлення {#discovery} + +Зробіть `GET` на цей well-known-шлях — і отримаєте **[RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) Protected Resource Metadata**, побудовані безпосередньо з вашого `AuthSettings`: + +```json +{ + "resource": "http://127.0.0.1:8000/mcp", + "authorization_servers": ["https://auth.example.com/"], + "scopes_supported": ["notes:read"], + "bearer_methods_supported": ["header"] +} +``` + +Саме завдяки цьому документу клієнт, який ніколи не чув про ваш сервер, знаходить дорогу: він читає `authorization_servers` і йде туди по токен. Ви не написали з нього жодного рядка. + +!!! check + Зверніться до `/mcp` без токена (або з таким, для якого ваш верифікатор повернув `None`) — і запит + зупинять на порозі: + + ```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"} + ``` + + Нічого не було розібрано, і жоден інструмент не виконався. А вказівник `resource_metadata` у `WWW-Authenticate` — + саме те, що робить виявлення автоматичним: 401 -> документ метаданих -> сервер авторизації -> токен -> повторна спроба. + +!!! warning + Ніщо з цього не захищає `stdio`. Канал не має заголовка `Authorization`, тож `token_verifier` там ніколи + не викликається. Межа безпеки `stdio`-сервера — це процес, який його запустив. Те саме + стосується `Client(mcp)` у пам'яті, який ви використовуєте в тестах: він під'єднується безпосередньо до об'єкта сервера + й оминає HTTP-рівень, разом з авторизацією. + +## Ідентичність того, хто викликає {#the-callers-identity} + +Усередині будь-якого обробника **`get_access_token()`** — це `AccessToken`, який ваш верифікатор повернув для поточного запиту: + +```python title="server.py" hl_lines="4 32-35" +--8<-- "docs_src/authorization/tutorial002.py" +``` + +* Це працює в інструментах, ресурсах і промптах, і нічого нікуди передавати не треба: middleware авторизації зберігає його в контекстній змінній для кожного запиту. +* Повертається **той самий об'єкт, який побудував ваш верифікатор**: `client_id`, `scopes`, `subject`, `expires_at` і будь-які додаткові `claims`, які ви додали. Це й є зачіпка для правил на рівні окремих інструментів: прочитайте scopes і відмовте. +* Поза автентифікованим HTTP-запитом він повертає `None`. У пам'яті й через `stdio` це завжди `None`. + +Викличте `whoami` з `Authorization: Bearer alice-token` — і модель прочитає: + +```text +alice (scopes: notes:read) +``` + +## Половина, якої SDK не робить {#the-half-the-sdk-doesnt-do} + +SDK дає вам половину сервера ресурсів: перевірити, оголосити, відмовити. Він не дає сторінки входу, екрана згоди чи токена. + +Щоб побачити всі три сторони в русі, запустіть `examples/servers/simple-auth/` з репозиторію SDK (невеликий сервер авторизації та сервер ресурсів, налаштований точно як на цій сторінці), а потім спрямуйте на нього `examples/clients/simple-auth-client/`, щоб пройти повний шлях виявлення й отримання токена. + +!!! info + Є другий аргумент конструктора, `auth_server_provider=`, який вбудовує повноцінний сервер + авторизації всередину вашого MCP-сервера. Він з'явився ще до розділення AS/RS, навколо якого + побудовано специфікацію авторизації MCP. Новим серверам не слід до нього вдаватися. + +Сервер авторизації також може прийняти підписане твердження корпоративного постачальника ідентичності замість того, щоб користувач проходив екран згоди, і SDK підтримує обидва боки цього обміну. Про цей grant і клієнта, що його пред'являє, — на сторінці **[Твердження ідентичності](../client/identity-assertion.md)**. + +## Підсумки {#recap} + +* Через Streamable HTTP ваш сервер — це **сервер ресурсів** OAuth 2.1: він перевіряє токени й ніколи їх не видає. +* `TokenVerifier` — це вся поверхня інтеграції: один асинхронний метод, на вході токен, на виході `AccessToken | None`. +* `token_verifier=` і `auth=AuthSettings(issuer_url=..., resource_server_url=..., required_scopes=[...])` завжди йдуть разом. +* SDK публікує [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) Protected Resource Metadata за адресою `/.well-known/oauth-protected-resource/...` і відповідає на неавтентифіковані запити кодом 401, чий заголовок `WWW-Authenticate` вказує на них. Оце й уся історія виявлення. +* `get_access_token()` у будь-якому обробнику — це той, хто викликає. +* Авторизація — справа HTTP. `stdio` та клієнт у пам'яті ніколи її не бачать. + +Клієнтська половина (виявлення вашого сервера авторизації й отримання токена за вас) — на сторінці **[Клієнти OAuth](../client/oauth-clients.md)**. А клієнт, який *стверджує* ідентичність замість того, щоб запитувати її в користувача, — на сторінці **[Твердження ідентичності](../client/identity-assertion.md)**. diff --git a/i18n/uk/pages/run/deploy.md b/i18n/uk/pages/run/deploy.md new file mode 100644 index 0000000000..fe83a33e31 --- /dev/null +++ b/i18n/uk/pages/run/deploy.md @@ -0,0 +1,179 @@ +--- +translation: + sections: [28221886b198784f, f88ea1f1614f3a1d, ce926d686730b6d0, 3be24f8ad8bb5ab9, 3fad24032b2224ff, f25a7f860e579ecb, e758745df6fb7b0a] + tool: 1 +--- +# Розгортання та масштабування {#deploy-scale} + +Сервер працює. Тепер йому потрібні справжнє ім'я хоста і більше ніж один робочий процес за ним. + +Майже нічого з цього не стосується MCP. ASGI-сервер, менеджер процесів, балансувальник навантаження — усе це приносите ви. На цій сторінці лише короткий список того, що *стосується* MCP: одне налаштування, від якого залежить кожне розгортання, і два місця, де «більше ніж один робочий процес» змінює поведінку SDK. + +## Насамперед: список дозволених Host {#before-anything-else-the-host-allowlist} + +`streamable_http_app()` не може знати, за яким іменем хоста його обслуговуватимуть, тому припускає найбезпечнішу відповідь: localhost. Без `transport_security=` застосунок вмикає **захист від DNS rebinding** і приймає запит лише тоді, коли його заголовок `Host` — це `127.0.0.1:`, `localhost:` або `[::1]:`. Заголовок `Origin`, якщо він є, має бути `http://`-формою того самого значення. На вашій машині це саме те, що треба: так зловмисна вебсторінка не зможе керувати локальним сервером через DNS-ім'я, яке вона переприв'язала до `127.0.0.1`. + +Розгорнутий за справжнім іменем хоста, той самий типовий режим відхиляє **кожен запит**, доки ви не скажете інакше. Перевірка виконується раніше за будь-що, пов'язане з MCP, тож до вашого коду справа навіть не доходить: + +```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=`. Додайте до списку дозволених те, що справді обслуговуєте: + +```python title="server.py" hl_lines="2 13-17" +--8<-- "docs_src/deploy/tutorial001.py" +``` + +* Елементи `allowed_hosts` — точні рядки: `"mcp.example.com"` відповідає заголовку `Host` без порту, а `"mcp.example.com:*"` — будь-якому порту. Укажіть обидва. +* `allowed_origins` має значення лише для браузерів, бо більше ніхто не надсилає `Origin`. Це серверний двійник конфігурації CORS зі сторінки **[Додавання до наявного застосунку](asgi.md)**. +* За зворотним проксі, який уже контролює заголовок `Host`, чесна конфігурація — вимкнути перевірку: `TransportSecuritySettings(enable_dns_rebinding_protection=False)`. +* Передати `host=`, відмінний від localhost (наприклад, `host="mcp.example.com"`), **не** означає додати це ім'я хоста до списку дозволених. Це лише не дає типовому значенню localhost увімкнути захист, через що приймаються будь-які Host і Origin. Натомість скажіть, що маєте на увазі, через `transport_security=`. + +!!! check + Видаліть аргумент `transport_security=security` і все одно розгорніть застосунок. Він запускається, `/mcp` + маршрутизується, і на кожен запит (зокрема зі звичайного `curl`) повертається: + + ```text + HTTP/1.1 421 Misdirected Request + + Invalid Host header + ``` + + На боці клієнта цих слів ви не знайдете. `421` — це HTTP-відповідь простим текстом, а не + помилка JSON-RPC, тому MCP-клієнт викидає загальну помилку транспорту; ім'я хоста, яке + не сподобалося серверу, з'являється лише в лозі **сервера**, одним попередженням. Щойно + розгорнутий сервер, який відхиляє кожне з'єднання, — це список дозволених Host, доки не доведено протилежне. + **[Усунення несправностей](../troubleshooting.md)** теж починається звідси. + +## Робочі процеси, і для кого потрібні липкі сесії {#workers-and-who-has-to-be-sticky} + +Щойно ім'я хоста відповідає, поставте за ним більше ніж один робочий процес. Для цього в SDK немає жодного перемикача; Starlette-застосунок масштабують так само, як і будь-який ASGI-застосунок, — передають об'єкт чомусь, що вміє створювати дочірні процеси: + +```console +uvicorn server:app --workers 4 +``` + +Чотири процеси, один сокет. І тепер питання, на яке має відповісти кожне розгортання: **чи повинен запит потрапити до того робочого процесу, який бачив попередній?** + +Для клієнта, що говорить протоколом **2026-07-28**, — ні. Сучасний запит — це один самодостатній POST: жодного рукостискання `initialize` перед ним, жодного `Mcp-Session-Id` у відповіді, нічого, до чого другий запит мав би *повертатися*. Спрямовуйте його на будь-який робочий процес. + +Це не режим, який вмикають. `stateless_http=True` має такий вигляд, ніби мав би ним бути, але транспорт маршрутизує за заголовком запиту `MCP-Protocol-Version`, передає сучасний запит сучасному обробнику і **повертає керування**. Рядок, який читає `stateless_http`, стоїть *після* цього повернення. Річ не в тім, що прапорець ігнорується на шляху 2026-07-28; до нього просто ніколи не доходить. `stateless_http` — це перемикач лише для гілки **старого покоління**, а сучасний шлях безсесійний за побудовою. + +Для клієнта старого покоління зі специфікацією версії 2025-11-25 або ранішої відповідь залежить від цього прапорця: + +| Версія протоколу клієнта | Сесія | Що має робити балансувальник навантаження | +| --- | --- | --- | +| **2026-07-28** | Немає. `Mcp-Session-Id` ніколи не встановлюється. | Нічого. Будь-який робочий процес обслуговує будь-який запит. | +| **2025-11-25 і раніші** (за замовчуванням) | `Mcp-Session-Id`, що зберігається в пам'яті одного робочого процесу. | **Липкі сесії.** Наступний запит, що потрапив до іншого робочого процесу, отримує `404` *«Session not found»*. | +| **2025-11-25 і раніші**, з `stateless_http=True` | Немає. | Нічого. Ціна — зворотний канал (back-channel) від сервера до клієнта: семплювання (sampling), push-еліцитація (elicitation), `roots/list`, — а також відновлюваність. | + +Липким сесіям і ціні гілки старого покоління присвячено окрему сторінку — **[Обслуговування клієнтів старого покоління](legacy-clients.md)**; самим двом поколінням — **[Версії протоколу](../protocol-versions.md)**. Тут важлива форма відповіді: *на 2026-07-28 ви вже працюєте без стану, і налаштовувати нічого.* + +Решта сторінки — про дві речі, яких відсутність стану вам **не** дає. + +## `requestState` між робочими процесами {#requeststate-across-workers} + +Інструменту з **[багатораундовими запитами](../handlers/multi-round-trip.md)** (multi-round-trip) потрібне щось, по що клієнт має сходити (підтвердження, вибір, облікові дані), тому він повертає запитання замість відповіді й завершує роботу на повторній спробі. Між двома раундами клієнт тримає непрозорий токен `request_state`, який випустив сервер. На повторній спробі сервер має знову відкрити цей токен. + +*Запечатаний яким ключем?* За замовчуванням — тим, який сервер згенерував через `os.urandom(32)` під час створення. З `--workers 4` це чотири створення в чотирьох процесах: чотири різні ключі, ніде не записані, нікому не передані, втрачені після перезапуску. + +Ось інструмент, який запитує, перш ніж діяти, на сервері, що нічого не налаштовує: + +```python title="server.py" hl_lines="14 20" +--8<-- "docs_src/deploy/tutorial002.py" +``` + +Перший раунд потрапляє до робочого процесу A. Процес A запечатує `refund:120` **своїм** ключем і повертає токен. Клієнт показує запитання людині, отримує «так» і повторює спробу. Повторна спроба — це цілком новий HTTP-запит. + +!!! check + Нехай ця повторна спроба потрапить до робочого процесу B. B намагається розпечатати токен, який не випускав, не може — + і відхиляє весь раунд. `refund` так і не викликається; клієнт отримує помилку JSON-RPC: + + ```json + { + "code": -32602, + "message": "Invalid or expired requestState", + "data": {"reason": "invalid_request_state"} + } + ``` + + Це повідомлення **незмінне**. Прострочений, підроблений, відтворений з іншими аргументами чи (найпоширеніша + причина в реальному розгортанні, з великим відривом) запечатаний сусіднім робочим процесом — клієнтові щоразу + кажуть те саме, тож передані дані ніколи не видають, яка саме перевірка не пройшла. Справжня причина — один + запис `WARNING` у лозі сервера: + + ```text + requestState rejected on tools/call: unknown key + ``` + + Багатораундовий інструмент, який працював з одним робочим процесом і почав збоїти *час від часу* з + двома, — це саме воно. Обидва раунди все ще мають потрапити до того самого процесу, тож збій трапляється рівно так часто, + як балансувальник навантаження їх розводить. + +Два раунди — це два незалежні HTTP-запити, і розділити їх може кілька буденних речей: проксі, що балансує кожен запит окремо; з'єднання, яке обірвалося між ними; розгортання чи перезапуск; клієнт, який зберіг `request_state` і відновлює роботу взагалі з іншого процесу (**[Керування циклом самостійно](../handlers/multi-round-trip.md#driving-the-loop-yourself)**). Будь-що з цього — «інший робочий процес». + +Виправлення — один аргумент. У нього **дві** половини. + +```python title="server.py" hl_lines="1 12 14" +--8<-- "docs_src/deploy/tutorial003.py" +``` + +* **`keys=[...]`** — половина, яку знаходять усі. Дайте кожному екземпляру той самий секрет (щонайменше 32 байти), і кожен екземпляр зможе розпечатати те, що випустив будь-який сусід. `keys[0]` запечатує, а розпечатує кожен ключ зі списку — це кільце ротації; як прокрутити його без простою, описано в розділі **[Ротація ключів](../handlers/multi-round-trip.md#rotating-keys)**. +* **Ім'я сервера** — половина, яку не знаходить майже ніхто, і причина, з якої повторні спроби між екземплярами й далі збоять після того, як ви поділилися ключем. Кожен запечатаний токен несе `name` сервера як **твердження про аудиторію (audience claim)**, яке суворо перевіряється на зворотному шляху. Два екземпляри, зібрані з одного коду, мають однакове ім'я і ніколи цього не помічають. Назвіть їх по-різному (`MCPServer(f"billing-{POD}")` виглядає як хороша гігієна спостережуваності) — і кожна повторна спроба між екземплярами відхиляється точно так, як вище, зі спільним ключем чи без. У лозі замість `unknown key` буде `audience`; клієнт різниці не бачить. + +Випустіть секрет один раз і передайте те саме значення кожному екземпляру. Саме цю команду пропонує виконати повідомлення про помилку самого SDK, якщо передати йому менше ніж 32 байти: + +```console +python -c "import secrets; print(secrets.token_hex(32))" +``` + +!!! warning "Ті самі ключі *і* те саме ім'я" + Багатоекземплярне розгортання має поділяти і те, і інше. Якщо окремі імена екземплярів для вас принципові, + натомість дайте всьому парку одну явну аудиторію: `RequestStateSecurity(keys=[...], audience="billing")`. + Тоді кожен екземпляр випускає і приймає токени під `"billing"`, хай як він називається. + +Усе інше про запечатування — у розділі **[Захист `requestState`](../handlers/multi-round-trip.md#protecting-requeststate)**: що саме воно прив'язує, `ttl` на раунд (600 секунд за замовчуванням), власний кодек, чому неналаштований типовий варіант — саме те, що треба, на `stdio`. Увесь внесок цієї сторінки — контрольний список із двох пунктів: *ті самі ключі, те саме ім'я.* + +!!! info + Ви на цьому шляху, навіть якщо ніколи не набирали `InputRequiredResult`. Інструмент, параметри якого + використовують `Resolve(...)` (**[Залежності](../handlers/dependencies.md)**), — це багатораундовий інструмент, + і SDK випускає та запечатує його `request_state` за нього. Той самий типовий ключ, той самий збій між + робочими процесами, те саме виправлення. + +## Сповіщення про зміни між репліками {#change-notifications-across-replicas} + +Потік `subscriptions/listen` клієнта — це одна довготривала відповідь, тож він прив'язаний до однієї репліки на все своє життя. `ctx.notify_resource_updated(...)`, опублікований на **іншій** репліці, має до нього дійти. + +Шов між ними — `SubscriptionBus`. Яку б шину ви не дали серверу, саме в неї йде кожна публікація і саме її слухає кожен відкритий потік, тож передайте ту саму шину кожній репліці: + +```python title="server.py" hl_lines="2 7 9" +--8<-- "docs_src/deploy/tutorial004.py" +``` + +Розсиланню байдуже, до якого об'єкта сервера приєднано потік. Два сервери з одним `InMemorySubscriptionBus` уже поводяться так: відкрийте потік listen на одному, виконайте `edit_note` на іншому — і потік про це почує. Ця шина в пам'яті охоплює лише об'єкти серверів у межах одного процесу, тож це модель, а не розгортання: + +* Між справжніми процесами **SDK не постачає жодної шини, яка могла б допомогти.** `SubscriptionBus` — це `Protocol` із двох методів (`publish` і `subscribe`), який ви реалізуєте поверх власного pub/sub-бекенда (Redis, NATS, що завгодно, що у вас уже працює) і передаєте як `MCPServer(subscriptions=...)`. Начерк і контракт — на сторінці **[Підписки](../handlers/subscriptions.md#scaling-past-one-process)**. +* Шина переносить чотири невеликі типізовані події, ніколи не JSON-RPC. Підтвердження, фільтрація та життєвий цикл потоку залишаються в SDK, тож ваша шина не може зламати протокол; вона може лише переміщувати події між процесами. +* Потоки **не** відновлювані, а події **не** відтворюються повторно. Втрата репліки обриває її потоки; клієнти знову підписуються на прослуховування і знову отримують дані. Немає сховища подій, яке треба поділяти, і більше нічого налаштовувати. Це єдине місце, де горизонтальне масштабування — справді просто більше того самого. + +## Чого SDK вам не дає {#what-the-sdk-does-not-give-you} + +`MCPServer` — це реалізація протоколу, а не сервер застосунків. Перемикачів розгортання, які ви шукатимете далі, немає навмисно: + +* **Немає `workers=`.** `mcp.run("streamable-http")` запускає рівно один процес uvicorn, і нічого більше він ніколи не запустить. Багатопроцесність — це `streamable_http_app()`, переданий тому, чим ви вже розгортаєте ASGI: `uvicorn --workers`, gunicorn, менеджеру процесів вашої платформи. Ця сторінка свідомо не є підручником з жодного з них; їхня документація краща, ніж була б її копія тут. +* **Немає маршруту перевірки стану.** `@mcp.custom_route("/health", methods=["GET"])` — ось і вся відповідь, і він ніколи не вимагає автентифікації, навіть коли решта сервера вимагає. Це правильно для проби життєздатності й неправильно для будь-чого приватного. Приклад є на сторінці **[Додавання до наявного застосунку](asgi.md#custom-routes)**. +* **Немає об'єкта production-налаштувань.** На `MCPServer` ніде записати тайм-аути, TLS, коректне завершення роботи чи ліміти з'єднань, бо нічого з цього не є його роботою. Це справа вашого ASGI-сервера, і налаштовуєте ви це там. Жменьку налаштувань, які конструктор *таки* приймає, описано на сторінці **[Запуск сервера](index.md)**. +* **Немає готового `EventStore`, а на 2026-07-28 і потреби в ньому.** Відновлюваність — це особливість гілки старого покоління зі станом; сучасний обмін — це один POST, одна відповідь і нічого відновлювати. + +## Підсумки {#recap} + +* За замовчуванням застосунок відповідає лише на запити, адресовані localhost. `transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])` — це ворота виходу в світ: доки його не передати, кожен запит за справжнім іменем хоста — це `421`, а причина є лише в лозі сервера. +* На 2026-07-28 немає сесії і нічого, до чого балансувальник навантаження мав би прив'язуватися. `stateless_http=True` — перемикач лише для старого покоління, бо сучасний запит маршрутизується й отримує відповідь ще до того, як цей прапорець узагалі прочитають. +* Типовий ключ `requestState` — це `os.urandom(32)`, випущений окремо в кожному процесі. Багатораундова повторна спроба, що потрапила до іншого робочого процесу, збоїть з `-32602` *«Invalid or expired requestState»*. +* Виправлення — `RequestStateSecurity(keys=[...])` **і** те саме ім'я сервера на кожному екземплярі. Ім'я — типове твердження про аудиторію токена. Ті самі ключі, те саме ім'я. +* Сповіщення про зміни переходять між репліками через одну спільну `SubscriptionBus`. Єдина реалізація в SDK — внутрішньопроцесна; `Protocol` із двох методів поверх власного pub/sub писати вам. +* Немає `workers=`, немає маршруту перевірки стану, немає об'єкта production-налаштувань. ASGI-сервер приносите ви. + +Інше, що потрібно перед справжнім іменем хоста, — це токен: **[Авторизація](authorization.md)**. diff --git a/i18n/uk/pages/run/index.md b/i18n/uk/pages/run/index.md new file mode 100644 index 0000000000..bfe000839a --- /dev/null +++ b/i18n/uk/pages/run/index.md @@ -0,0 +1,156 @@ +--- +translation: + sections: [fea8d769ff9edeba, ce8e2ad42f29ef71, 0d705efb19cf99c2, 7a53ead3e704a7f0, 9adc400e8c88e854, 318893ad8e2e9924, 6b63ab96b34476c0] + tool: 1 +--- +# Запуск сервера {#running-your-server} + +`mcp.run()` запускає сервер. + +Єдине рішення, яке доводиться ухвалити, — це **транспорт**: як саме рухаються байти між сервером і його клієнтом. + +## Вибір транспорту {#pick-a-transport} + +| Транспорт | Що це | Коли | +|---|---|---| +| `stdio` | Хост запускає ваш файл як підпроцес і спілкується через його stdin та stdout. | Локальні сервери. Значення за замовчуванням. | +| `streamable-http` | Справжній HTTP-сервер, що слухає порт. | Усе, що ви розгортаєте. | +| `sse` | Старіший HTTP-транспорт. | Ніколи. | + +!!! warning + У ревізії протоколу 2025-03-26 SSE поступився місцем Streamable HTTP. + `mcp.run(transport="sse")` досі працює, зі своїми параметрами `sse_path=` і `message_path=`, + але існує лише для клієнтів, які ще не перейшли. Не будуйте на ньому нічого нового. + +## `mcp.run()` {#mcprun} + +```python title="server.py" hl_lines="12-13" +--8<-- "docs_src/run/tutorial001.py" +``` + +* `run()` синхронний. Він блокує виконання на весь час життя сервера. +* Без аргументу транспорт — `stdio`. +* Він стоїть під `if __name__ == "__main__":`, бо все, що завантажує сервер (`mcp dev`, `mcp run`, `mcp install`, ваші тести), **імпортує** цей файл. Ця умова не дає імпорту перетворитися на запущений сервер. + +### stdio {#stdio} + +Налаштовувати нічого. Хост запускає ваш файл як дочірній процес, пише запити в його stdin і читає відповіді з його stdout. + +Запустіть його самі — і побачите наслідок: + +```console +python server.py +``` + +Нічого не виводиться, і керування не повертається. Сервер чекає на stdin, поки хост заговорить першим. + +Це також означає, що stdout **і є каналом передачі даних**. Під час обслуговування SDK переносить цей канал на приватний дескриптор, а вивід, що *скидається* (flush) у stdout (підпроцес, який пише в успадкований stdout, `print()` зі скиданням буфера), перенаправляє в stderr, де він не може зіпсувати потік. Вивід, скинутий у stdout *до* початку обслуговування (скрипт-обгортка з echo, небуферизований print під час імпорту), усе одно потрапляє в канал — як і `print()`, що лишається в буфері, доки інтерпретатор не спорожнить його під час завершення. Для виводу, який справді потрібен, правильний інструмент — модуль `logging`: його обробник скидає кожен запис у stderr одразу, щойно той з'являється. Докладніше — на сторінці **[Логування](../handlers/logging.md)**. + +### Спробуйте самі {#try-it} + +```console +uv run mcp dev server.py +``` + +Inspector робить рівно те саме, що й справжній хост: запускає `server.py` як підпроцес і під'єднується до нього через stdio. + +Порт ви йому не вказували. Його й немає. + +## Streamable HTTP {#streamable-http} + +Щоб натомість виставити той самий сервер на порт, вкажіть транспорт (і його параметри) в `run()`: + +```python title="server.py" hl_lines="13" +--8<-- "docs_src/run/tutorial002.py" +``` + +Цей один рядок будує застосунок Starlette і обслуговує його через uvicorn. Клієнти під'єднуються до `http://127.0.0.1:3001/mcp`. + +Кожен транспорт має власні іменовані аргументи, усі — в `run()`: + +* `host` / `port`: де слухати. За замовчуванням `127.0.0.1` і `8000`. +* `streamable_http_path`: де розташована кінцева точка MCP. За замовчуванням `/mcp`. +* `json_response=True`: відповідати на кожен POST одним JSON-тілом замість SSE-потоку. У такому тілі є місце для відповіді й ні для чого іншого, тож інструмент, який посеред запиту звертається назад до клієнта (`ctx.elicit()`, семплювання (sampling)), на цьому відрізку викидає `NoBackChannelError`, а сповіщення, прив'язані до поточного виклику (перебіг виконання від `ctx.report_progress()`, повідомлення журналу окремого виклику), відкидаються; окремий потік `GET` і далі несе не пов'язані з ним. +* `stateless_http=True`: новий транспорт на кожен запит, без відстеження сесій. +* `max_request_body_size`: найбільший прийнятний розмір тіла POST у байтах. За замовчуванням 4 МіБ; більші запити + отримують HTTP 413 ще до розбору чи створення сесії. Збільшуйте його лише тоді, коли легітимні MCP-повідомлення + перевищують цей розмір. +* `event_store`, `retry_interval`, `transport_security`: відновлюваність і захист від DNS-rebinding. Вони можуть зачекати, доки ви не розгорнете сервер деінде, крім localhost; `transport_security` описано на сторінці **[Розгортання та масштабування](deploy.md)**. + +!!! warning + Параметри транспорту передаються в `run()`, а **не** в `MCPServer(...)`. Конструктор описує, чим + ваш сервер *є*: ім'я, версія, інструкції. `run()` описує, як його обслуговувати. Переплутайте — + і Python відповість ще до того, як у справу взагалі втрутиться MCP: + + ```text + TypeError: MCPServer.__init__() got an unexpected keyword argument 'port' + ``` + +`run()` — це короткий шлях. Щойно знадобиться більше (сервер, змонтований у наявний застосунок, два сервери в одному процесі, CORS для браузерних клієнтів), ви будуєте ASGI-застосунок самі й віддаєте його будь-якому ASGI-хосту. Про це — **[Додавання до наявного застосунку](asgi.md)**. + +## Налаштування сервера {#server-settings} + +Кілька речей, що стосуються запуску, до транспорту не належать. Це аргументи конструктора: + +```python title="server.py" hl_lines="3" +--8<-- "docs_src/run/tutorial003.py" +``` + +* `log_level`: передається в `logging.basicConfig()` тієї ж миті, коли створюється `MCPServer(...)`. Це налаштовує **кореневий** логер, тож задає рівень і для ваших власних логерів, а не лише для логерів SDK. За замовчуванням `"INFO"`. +* `debug`: передається далі в застосунок Starlette, який будують HTTP-транспорти. За замовчуванням `False`. + +Обидва потрапляють у `mcp.settings`, звідки їх можна прочитати під час виконання. + +## Команда `mcp` {#the-mcp-command} + +Необов'язковий набір залежностей `[cli]` встановлює невеличкий інструмент командного рядка поверх усього цього. + +`mcp dev` запускає сервер під **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` додає пакети до середовища, яке вона будує; `--with-editable` встановлює в нього ваш власний пакет. Потрібен `npx` у `PATH`: Inspector — це застосунок на Node.js. + +`mcp run` імпортує файл, знаходить об'єкт сервера (`mcp`, `server` або `app` на рівні модуля) і викликає на ньому `run()`: + +```console +uv run mcp run server.py +uv run mcp run server.py:bookshop +``` + +Суфікс після `:` називає об'єкт, якщо його ім'я не `mcp`, `server` чи `app`. + +Блок `if __name__ == "__main__":` тут ніколи не виконується: `mcp run` викликає `run()` сама, і єдиний параметр, який вона передає далі, — `--transport`. + +`mcp install` реєструє сервер у **Claude Desktop**, щоб застосунок запускав його за вас: + +```console +uv run mcp install server.py --name "Bookshop" +uv run mcp install server.py -v API_KEY=abc123 -f .env +``` + +`-v KEY=VALUE` і `-f .env` записують змінні середовища в цей запис. Claude Desktop запускає ваш сервер у власному процесі. Середовища вашої оболонки там немає. + +Claude Desktop — єдиний хост, який знає `mcp install`. Кожен інший хост (Claude Code, Cursor, VS Code) приймає ту саму команду запуску у власному файлі конфігурації; кожен із них описано на сторінці **[Під'єднання до справжнього хоста](../get-started/real-host.md)**. + +`mcp version` виводить версію встановленого SDK. + +!!! tip + `mcp dev` і `mcp run` розуміють лише `MCPServer`. Якщо ви будуєте на низькорівневому `Server`, + запускати його доведеться самостійно. Див. **[Низькорівневий Server](../advanced/low-level-server.md)**. + +## Підсумки {#recap} + +* **Транспорт** — це спосіб, у який байти дістаються сервера: `stdio` для локального підпроцесу, `streamable-http` для порту. SSE замінено. +* `mcp.run()` обирає транспорт. Без аргументу це `stdio`, і виклик блокує виконання. +* Кожен параметр транспорту (`host`, `port`, `streamable_http_path`, ...) — це аргумент `run()` і ніколи не `MCPServer(...)`. +* Тримайте `run()` під `if __name__ == "__main__":`. Усе, що завантажує сервер, спершу імпортує файл. +* `log_level=` і `debug=` — аргументи конструктора; вони потрапляють у `mcp.settings`. +* `mcp dev` для Inspector, `mcp run` щоб виконати файл, `mcp install` для Claude Desktop, `mcp version` для версії. +* Транспорт ніколи не змінює того, чим ваш сервер *є*: усі три файли на цій сторінці надають ідентичний інструмент. + +Коли обмеженням стає сам `run()` (сервер усередині застосунку, що вже існує), — це **[Додавання до наявного застосунку](asgi.md)**. Справжнє ім'я хоста й більше ніж один робочий процес — це **[Розгортання та масштабування](deploy.md)**. А якщо частина ваших клієнтів досі на версії специфікації 2025-11-25 чи ранішій, добра новина — на сторінці **[Обслуговування клієнтів старого покоління](legacy-clients.md)**. diff --git a/i18n/uk/pages/run/legacy-clients.md b/i18n/uk/pages/run/legacy-clients.md new file mode 100644 index 0000000000..099aa9d32c --- /dev/null +++ b/i18n/uk/pages/run/legacy-clients.md @@ -0,0 +1,136 @@ +--- +translation: + sections: [3d1663c18edc824c, d4fd37009a13f03d, af9f398a5a8b679a, 470c2dd144294d69, 8e45827e6d24e8c8, 91dfd0ce98ebb03c] + tool: 1 +--- +# Обслуговування клієнтів старого покоління {#serving-legacy-clients} + +У MCP є два покоління протоколу: покоління рукостискання `initialize` — до версії специфікації `2025-11-25` включно — і сучасне покоління, `2026-07-28`. Самому цьому поділу присвячена сторінка **[Версії протоколу](../protocol-versions.md)**. + +Ця сторінка — про серверний бік цього поділу, і відповідь уміщується в одне речення: **`streamable_http_app()`, який ви вже розгортаєте, обслуговує обидва.** + +SDK маршрутизує кожен запит за його заголовком `MCP-Protocol-Version`. Запит, що називає `2026-07-28`, потрапляє до сучасного обробника. Запит, що називає версію покоління рукостискання або взагалі не має заголовка (саме так приходить `initialize` від клієнта до 2026 року), потрапляє до транспорту, якого ці клієнти й очікують: з рукостисканням `initialize`, сесіями й усім іншим. Це відбувається для кожного запиту окремо, ще до вашого коду, в одному й тому самому застосунку. + +Тож клієнт старого покоління — це не те, *під що* ви щось будуєте. Це те, що *під'єднується* до сервера, який ви вже написали. Налаштовувати нічого не потрібно. + +!!! note + Нічого — буквально. Немає параметра `legacy=`, немає списку дозволених версій, немає + способу відхилити чи вимкнути покоління: ні в `streamable_http_app()`, ні в `run()`, ні в + менеджері сесій. Обидва покоління завжди ввімкнені. Найближче до перемикача за поколіннями + в цій сигнатурі — `stateless_http`, і йому присвячено більшу частину цієї сторінки. + +## Один обробник, обидва покоління {#one-handler-both-eras} + +Ось інструмент, якому треба дещо запитати в користувача, і клієнти обох поколінь, що його викликають: + +```python title="server.py" hl_lines="24 37-38" +--8<-- "docs_src/legacy_clients/tutorial001.py" +``` + +`reserve` потребує однієї речі, якої модель не надала: скільки примірників. Через `Annotated[..., Resolve(ask_quantity)]` інструмент це й оголошує (докладніше — на сторінці **[Залежності](../handlers/dependencies.md)**). Ніщо в `reserve` не називає версію, не перевіряє можливість і не розгалужується. + +Обидва клієнти відкриті **одночасно**, на тому самому об'єкті `mcp`. `mode="legacy"` виконує рукостискання `initialize` — саме те з'єднання, яке відкриває клієнт до 2026 року. Другий клієнт бере значення за замовчуванням і потрапляє на `2026-07-28`. + +```text +2025-11-25 {'result': "Reserved 2 of 'Dune'."} +2026-07-28 {'result': "Reserved 2 of 'Dune'."} +``` + +Той самий сервер, той самий обробник, та сама відповідь. Оце й увесь механізм. + +Варто зупинитися на тому, *як* саме, бо обом клієнтам поставили те саме запитання двома зовсім різними шляхами передачі. У з'єднанні `2026-07-28` сервер не має каналу, яким міг би надіслати запит, тож `Resolve` повернув запитання всередині результату інструмента, а клієнт повторив виклик уже з відповіддю (**[Багатораундові запити (multi-round-trip)](../handlers/multi-round-trip.md)**). У з'єднанні `2025-11-25` нічого подібного немає; там `Resolve` надіслав живий запит `elicitation/create` просто посеред виклику й чекав. Ви не писали ні того, ні іншого. `Resolve` читає узгоджену версію з'єднання й обирає сам; тіло інструмента в обох випадках отримує `AcceptedElicitation`. + +!!! tip + Саме ця переносність між поколіннями — причина, *чому* будувати варто на `Resolve`. Його + старший родич `ctx.elicit()` (**[Еліцитація (elicitation)](../handlers/elicitation.md)**) + завжди надсилає лише `elicitation/create`, а отже працює лише на з'єднанні старого + покоління. На з'єднанні `2026-07-28` виклик завершується помилкою. Якщо інструмент досі + ним користується, виправлення — те, що показано вище, а не перевірка версії. + +## Чого коштує сесія старого покоління {#what-a-legacy-session-costs-you} + +Маршрутизація безкоштовна. Сесія — ні. + +З'єднання `2026-07-28` **без сесій**: кожен запит самодостатній, а сучасний обробник ніколи не видає `Mcp-Session-Id`. З'єднання старого покоління — повна протилежність. Щойно клієнт до 2026 року надсилає `initialize`, SDK створює `Mcp-Session-Id`, повертає його в заголовку відповіді й зберігає за ним живий запис, який знайдуть подальші запити клієнта: узгоджену версію, відкриті потоки, фонове завдання, що веде сесію. + +Цей запис — **звичайний `dict` у пам'яті процесу**. Розподіленого сховища сесій немає, і під'єднати його неможливо. + +На одному робочому процесі цього не видно. На двох — у цьому вся проблема: запит із `Mcp-Session-Id`, що потрапив на робочий процес, який його не видавав, нічого не знайде в тому словнику, і відповіддю буде `404` (`Session not found`), а не результат інструмента. Тож щойно робочих процесів більше одного, **клієнтам старого покоління потрібна липка маршрутизація** (sticky routing): кожен запит у межах сесії має дістатися процесу, який її почав. Сучасним клієнтам це не потрібно ніколи: у них немає сесії, до якої треба прилипати. Про липкість і все інше, що стосується запуску кількох таких процесів, — на сторінці **[Розгортання та масштабування](deploy.md)**. + +!!! warning + `event_store=` схожий на вирішення, але ним не є. Це **відновлюваність** (повторне + надсилання пропущених SSE-подій клієнту, що перепід'єднується до *тієї самої* сесії), а + не сховище сесій. Він ніколи не робить сесію досяжною з іншого процесу. + +## Єдиний перемикач: `stateless_http` {#the-one-knob-stateless_http} + +Якщо липкість — ціна, яку ви платити не готові, змінити можна рівно одну річ. + +```python title="server.py" hl_lines="28" +--8<-- "docs_src/legacy_clients/tutorial002.py" +``` + +Це сервер із початку сторінки плюс один іменований аргумент. З `stateless_http=True` гілка старого покоління натомість створює одноразову сесію на кожен запит: `Mcp-Session-Id` не видається, між запитами нічого не запам'ятовується, тож будь-який робочий процес може обслужити будь-який запит, а балансувальник навантаження може робити що завгодно. + +Дві речі про нього важливіші за те, що саме він робить. + +**Він зачіпає лише гілку старого покоління.** Запити маршрутизуються за заголовком версії *до того*, як читається `stateless_http`, тож сучасний шлях його ніколи не бачить. З'єднання `2026-07-28` і так без сесій і поводиться однаково за будь-якого значення. + +**Він коштує обох каналів від сервера до клієнта на цій гілці.** Сесія, що живе один `POST`, не має потоку, яким сервер міг би надіслати запит, і не має окремого потоку, яким міг би надсилати сповіщення. Кожен запит, ініційований сервером, викидає `NoBackChannelError`: `ctx.elicit()`, виведені з ужитку виклики семплювання (sampling) і кореневих каталогів (roots) (**[Застарілі можливості](../deprecated.md)**) і, так, `Resolve`, що ставить своє запитання клієнтові *старого покоління*. Сповіщення не отримують навіть помилки — їх мовчки відкидають. + +!!! note + `json_response=True` — не той перемикач, але він стягує половину тієї самої ціни з + *кожної* сесії старого покоління: `POST`, на який відповідають одним JSON-тілом, не має + потоку для каналу, прив'язаного до запиту, тож `ctx.elicit()` посеред запиту викидає той + самий `NoBackChannelError`, а сповіщення, пов'язані із запитом, відкидаються. Окремий + потік сесії це не зачіпає: не пов'язані із запитом сповіщення й далі надходять. + +!!! check + Зробіть неправильно. `reserve` — той самий інструмент, що щойно обслужив обох клієнтів. + Розгорніть його зі `stateless_http=True`, під'єднайте тих самих двох клієнтів через HTTP + і викличте його з кожного. + + Сучасний клієнт, як і раніше, отримує `Reserved 2 of 'Dune'.` Сучасна гілка не змінилася. + + Виклик клієнта старого покоління не повертається результатом `is_error`, який могла б + прочитати модель. Увесь запит завершується помилкою — протокольною помилкою верхнього + рівня: + + ```text + mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests. + ``` + + `Resolve` вас не врятував. На з'єднанні `2025-11-25` він *мусить* надіслати + `elicitation/create`, а потрібний йому канал — саме те, від чого відмовився + `stateless_http=True`. Код, переносний між поколіннями, — це ще не код без зворотного + каналу (back-channel). + +Тож це справжній компроміс, і існує він лише на гілці старого покоління: **із сесіями та липкістю або без стану й в один бік.** Якщо ваші інструменти ніколи не звертаються назад до клієнта, `stateless_http=True` нічого не коштує, і його варто ввімкнути. Якщо звертаються — залиште сесії й залиште маршрутизацію липкою. + +## Де ваш код справді розгалужується {#where-your-code-actually-forks} + +Майже ніде. + +Інструменти, ресурси, промпти, структурований вивід, перебіг виконання, помилки — жодному з них не важливо, яке покоління викликало. Рукостискання `initialize`, `Mcp-Session-Id`, окремий потік, `DELETE`, що завершує сесію, — усім цим володіє SDK, і обробник нічого з цього ніколи не бачить. Інтерактивне введення — *єдине* місце, де покоління справді відрізняються в переданих даних, і `Resolve` існує саме для того, щоб це не було вашою проблемою: ви щойно бачили, як один інструмент обслужив обидва. + +Лишається рівно одне — **сповіщення про зміни**, бо два покоління слухають різні канали: + +* Клієнт `2026-07-28` відкриває потік `subscriptions/listen` і читає шину підписок. `ctx.notify_resource_updated()` (а також `notify_tools_changed()`, `notify_prompts_changed()`, `notify_resources_changed()`) публікують туди, і *лише* туди. Докладніше — на сторінці **[Підписки](../handlers/subscriptions.md)**. +* Клієнт старого покоління читає окремий потік, який його сесія тримає відкритим. `ctx.session.send_resource_updated()` (а також `send_tool_list_changed()` і подібні) пишуть у *з'єднання*, яким прийшов запит: для сесії старого покоління це її окремий потік. У сучасному з'єднанні для цього немає місця: через HTTP такого каналу немає, а через stdio чотири види сповіщень про зміни ходять лише потоками `subscriptions/listen`, тож на сучасному з'єднанні сповіщення тихо відкидається. + +Через HTTP жоден із викликів не дістається клієнтів іншого покоління. Щоб повідомити всіх, викликайте обидва: + +```python title="server.py" hl_lines="19-20" +--8<-- "docs_src/legacy_clients/tutorial003.py" +``` + +Два рядки, жодного `if`, жодної перевірки версії — і готово. Оце й увесь список того, що обробник робить інакше через існування клієнтів старого покоління. + +## Підсумки {#recap} + +* Один `streamable_http_app()` обслуговує обидва покоління протоколу. SDK маршрутизує кожен запит за заголовком `MCP-Protocol-Version`; налаштовувати нічого не треба, і перемикача поколінь шукати не варто. +* Клієнт старого покоління коштує вам сесії: запису `Mcp-Session-Id` у пам'яті процесу без розподіленого сховища за ним. Більше одного робочого процесу означає **липку маршрутизацію**, інакше не той робочий процес відповість `404 Session not found`. Докладніше про кілька робочих процесів — на сторінці **[Розгортання та масштабування](deploy.md)**. +* `stateless_http=True` — єдиний перемикач, і він стосується **лише гілки старого покоління**. Він купує вільне балансування навантаження для клієнтів старого покоління ціною обох каналів від сервера до клієнта на цій гілці: запити, ініційовані сервером, викидають `NoBackChannelError` (помилка верхнього рівня на боці клієнта, а не результат `is_error`), а сповіщення відкидаються. +* З'єднання `2026-07-28` без сесій у будь-якому разі. `stateless_http` його ніколи не зачіпає. +* Код обробника розгалужується за поколінням рівно в одному місці: сповіщення про зміни. `ctx.notify_*` дістається клієнтів `subscriptions/listen`; `ctx.session.send_*` дістається сесій старого покоління. Викликайте обидва. +* Усе інше (зокрема запит введення в користувача через `Resolve`) переносне між поколіннями за побудовою. Напишіть сучасний варіант один раз. diff --git a/i18n/uk/pages/run/opentelemetry.md b/i18n/uk/pages/run/opentelemetry.md new file mode 100644 index 0000000000..fcc4a6b309 --- /dev/null +++ b/i18n/uk/pages/run/opentelemetry.md @@ -0,0 +1,112 @@ +--- +translation: + sections: [bc0227014724fa49, 15738c2f7fd67d86, a2c17bbe3f707e2f, d0d853376f162c06, b6368643fcc1c8d8, 902e33e17564a607] + tool: 1 +--- +# OpenTelemetry {#opentelemetry} + +Ваш сервер уже трасується. Нічого додавати не потрібно. + +Кожен створений вами сервер генерує спан [OpenTelemetry](https://opentelemetry.io/) для кожного +повідомлення, яке обробляє. Ви цього не писали й нічого не імпортуєте. Воно з'являється тієї ж миті, +коли ви викликаєте `MCPServer(...)`. + +```python title="server.py" +--8<-- "docs_src/opentelemetry/tutorial001.py" +``` + +Це вже готовий сервер із трасуванням. Викличте `search_books` — і для нього створиться спан. Те саме +стосується низькорівневого `Server`: трасування є в обох. + +## Що отримуєте {#what-you-get} + +Кожне вхідне повідомлення стає спаном `SERVER`, названим за методом і його ціллю. Тож +`tools/call` для `search_books` — це спан `tools/call search_books`, а простий `tools/list` — +це просто `tools/list`. + +Кожен спан має кілька атрибутів: + +* `mcp.method.name` і `mcp.protocol.version` — на кожному спані. +* `jsonrpc.request.id` — на запиті (у сповіщення його немає). +* Обробник, що викидає виняток, встановлює для спана статус помилки. Так само діє результат інструмента з `is_error=True`. + +А оскільки трасувати виклики інструментів хочеться дуже часто, спани `tools/call` дотримуються +[семантичних угод GenAI](https://opentelemetry.io/docs/specs/semconv/gen-ai/) від OpenTelemetry: + +* `gen_ai.operation.name` зі значенням `"execute_tool"`. +* `gen_ai.tool.name` з назвою інструмента, який викликають. + +У тому ж дусі спан `prompts/get` отримує `gen_ai.prompt.name`. Методи списків не мають жодних +ключів `gen_ai.*`, бо називати там нічого. + +!!! tip + Саме завдяки цим атрибутам GenAI інтерфейс трасування групує ваші виклики інструментів так само, + як і виклики будь-якого іншого агента. Це групування дістається задарма, без додаткового коду. + +## Це нічого не коштує, поки вам це не знадобиться {#it-costs-nothing-until-you-want-it} + +Ось чому «увімкнено за замовчуванням» — зручне типове значення. + +SDK залежить лише від `opentelemetry-api`, легкої половини OpenTelemetry. Якщо не встановлено +ні SDK, ні експортера, створення спана — порожня операція. Тож спани, які ваш сервер генерує просто +зараз, майже нічого не коштують, і ніхто їх не збирає. + +Того дня, коли ви захочете їх *побачити*, встановіть другу половину й спрямуйте її кудись: + +```console +uv add opentelemetry-sdk opentelemetry-exporter-otlp +``` + +Налаштуйте експортер у звичний для OpenTelemetry спосіб — і кожен спан, який SDK досі тихо +створював, стане видимим. Код сервера не змінюється. Ні на рядок. + +!!! info + [Pydantic Logfire](https://logfire.pydantic.dev/) — один із таких бекендів, і він бере + налаштування на себе: `pip install logfire`, `logfire.configure()` — і ваші MCP-спани з'являються + в живому перегляді. Він побудований на OpenTelemetry, тож усе сказане нижче стосується і його. + +## Трасування, що перетинає мережу {#traces-that-cross-the-wire} + +Трасування найкорисніше, коли воно супроводжує запит від клієнта до сервера в одній +зв'язній картині. + +Коли й клієнт, і сервер працюють на SDK, цей зв'язок утворюється автоматично. Клієнт вставляє +в запит [контекст трасування W3C](https://www.w3.org/TR/trace-context/), а сервер +зчитує його назад, тож спан сервера вкладається під спан клієнта в тому самому трасуванні. Це +[SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414), і ви отримуєте його, +не просячи. + +Якщо вхідне повідомлення не містить контексту трасування, наприклад запит від клієнта, який не є +SDK, спан сервера просто стає дочірнім до того спана, який уже є поточним на сервері, замість +того щоб починати нове осиротіле трасування. + +## Вимкнення {#turning-it-off} + +Трасування — це middleware, перше у списку вашого сервера. Якщо справді потрібен сервер, що +не генерує жодних спанів, приберіть його: + +```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 + Цей імпорт починається з підкреслення, і це навмисно. Клас попередній, так само як + попереднім є [`Server.middleware`](../advanced/middleware.md), тож варто очікувати, що шлях імпорту + зміниться. Це майже ніколи не потрібно: без встановленого експортера спани безкоштовні, тому + звична відповідь — залишити їх увімкненими й не встановлювати експортер. + +## Підсумки {#recap} + +* Кожен `MCPServer` і кожен низькорівневий `Server` за замовчуванням генерує один спан `SERVER` + на кожне вхідне повідомлення. Ви нічого не пишете. +* Спани містять `mcp.method.name` і `mcp.protocol.version`; `tools/call` і `prompts/get` також + містять атрибути GenAI, тож ваші виклики інструментів групуються, як у будь-якого іншого агента. +* Це нічого не коштує, доки ви не встановите OpenTelemetry SDK і експортер, а тоді все вмикається + без жодних змін у сервері. +* Контекст трасування від клієнта до сервера передається автоматично, коли обидві сторони працюють на SDK. + +Чи виконуватиметься запит узагалі, вирішує **[Авторизація](authorization.md)**. diff --git a/i18n/uk/pages/servers/completions.md b/i18n/uk/pages/servers/completions.md new file mode 100644 index 0000000000..a97f5d9e39 --- /dev/null +++ b/i18n/uk/pages/servers/completions.md @@ -0,0 +1,130 @@ +--- +translation: + sections: [72f9c964769076dd, 9a2c14e10935b515, 235299eb78ab12d7, 8aee1e78c8237fb8, 9bd86acd4112138f, 55343cb7f250dc7b] + tool: 1 +--- +# Автодоповнення {#completions} + +Клієнт, що будує інтерфейс поверх вашого сервера, хоче автоматично доповнювати значення аргументів, поки користувач їх вводить: назви мов, назви репозиторіїв, шляхи до файлів. + +**Автодоповнення** (completions) — це спосіб, у який сервер надає такі підказки. + +## Що варто доповнювати {#something-worth-completing} + +Автодоповнення стосується рівно двох речей: аргументів **промпту** і параметрів **шаблону ресурсу**. Тож почніть із сервера, де є по одному з них: + +```python title="server.py" hl_lines="6 12" +--8<-- "docs_src/completions/tutorial001.py" +``` + +Тут поки нічого про автодоповнення. + +* `review_code` приймає `language`. Користувач не повинен вгадувати, які варіанти написання ви приймаєте. +* `github_repo` приймає `owner` і `repo`. Два поля вільного введення — це погана форма. + +## Обробник автодоповнення {#the-completion-handler} + +Додайте **одну** функцію з декоратором `@mcp.completion()`: + +```python title="server.py" hl_lines="21-29" +--8<-- "docs_src/completions/tutorial002.py" +``` + +* Обробник один на сервер. Кожен запит на автодоповнення потрапляє сюди, а ви розгалужуєте логіку залежно від того, що саме доповнюється. +* Він має бути `async def`: SDK викликає його через await. +* Він отримує три аргументи: + * `ref`: *який саме* промпт або шаблон ресурсу — як `PromptReference` або `ResourceTemplateReference`. Розрізняють їх через `isinstance`. + * `argument`: `argument.name` — аргумент, що доповнюється, `argument.value` — те, що користувач уже встиг ввести. + * `context`: уже визначені аргументи. Поки що ігноруйте його. +* Повертаєте `Completion(values=[...])` або `None`, коли запропонувати нічого. + +!!! tip + `argument.value` — це префікс, який ввів користувач. SDK **не** фільтрує за вас: що покладете + у `values`, те й покаже інтерфейс. `startswith` пишете ви самі. + +### Спробуйте самі {#try-it} + +Перевірте його за допомогою `Client` у пам'яті зі сторінки **[Тестування](../get-started/testing.md)**. Викличте +`client.complete()` з `ref=PromptReference(name="review_code")` і +`argument={"name": "language", "value": "py"}`: + +```python +result.completion.values # ['python'] +``` + +* `ref` — той самий тип посилання, що його отримує обробник. +* `argument` — звичайний словник із рівно двома ключами, `name` і `value`. + +Надішліть порожнє `value` — і повернеться весь список. `lang.startswith("")` істинне для кожної мови: + +```python +result.completion.values # ['go', 'javascript', 'python', 'rust', 'typescript'] +``` + +Запитайте про `code` (аргумент, якого обробник не знає) — він поверне `None`, а SDK перетворить його на порожній список: + +```python +result.completion.values # [] +``` + +`None` означає *«підказок немає»*, а не помилку. Інтерфейс просто показує звичайне текстове поле. + +## Можливість, яку ви не оголошували {#a-capability-you-never-declared} + +Реєстрація обробника і є оголошенням. Під'єднайте клієнт і погляньте: + +```python +client.server_capabilities.completions # CompletionsCapability() +``` + +Ви ніде не вказували `completions`. SDK побачив обробник і оголосив можливість за вас. Так працює кожна *необов'язкова* можливість: обробник і є оголошенням. (Три примітиви не є необов'язковими: `MCPServer` оголошує їх завжди, з обробниками чи без.) + +!!! check + Поверніться до першого `server.py` (того, що без обробника) і все одно надішліть запит. Виклик + завершиться помилкою JSON-RPC: + + ```text + Method not found + ``` + + А `client.server_capabilities.completions` дорівнює `None`. У цьому й сенс можливості: + коректний клієнт перевіряє її й ніколи не надсилає запит, на який ви не можете відповісти. + +## Залежні аргументи {#dependent-arguments} + +`github://repos/{owner}/{repo}` має два параметри, і корисні значення для `repo` залежать від того, якого `owner` обрали спершу. + +Саме для цього є `context`. Він містить аргументи, які користувач **уже визначив**: + +```python title="server.py" hl_lines="8-11 34-38" +--8<-- "docs_src/completions/tutorial003.py" +``` + +* Нова гілка спрацьовує для параметра `repo` шаблону. +* `context.arguments` — це `dict[str, str] | None` зі значеннями, вибраними досі (тут — `owner`). +* Немає `owner` — немає й осмислених підказок, тож обробник повертає `None`. + +Клієнт надсилає ці визначені значення через `context_arguments=`. Цього разу `ref` — це +`ResourceTemplateReference(uri="github://repos/{owner}/{repo}")`. Запитайте `repo` з +порожнім `value` і передайте `context_arguments={"owner": "modelcontextprotocol"}`: + +```python +result.completion.values # ['python-sdk', 'typescript-sdk', 'inspector'] +``` + +Приберіть `context_arguments=` — і той самий виклик поверне `[]`. Обробник не може знати, які репозиторії пропонувати, доки не знає власника. + +!!! info + `Completion` також приймає `total=` і `has_more=`. Задавайте їх, коли `values` — лише зріз + довшого списку, щоб інтерфейс міг показати *«і ще 200»*. Більшості обробників вони ніколи не знадобляться. + +## Підсумки {#recap} + +* Автодоповнення — це підказки для **аргументів промптів** і **параметрів шаблонів ресурсів**. Ні для чого іншого. +* `@mcp.completion()` реєструє єдиний обробник. Це `async def (ref, argument, context) -> Completion | None`. +* Розгалужуйтеся за `isinstance(ref, ...)` та за `argument.name`. Фільтруйте за `argument.value` самостійно. +* `None` стає порожнім списком. Це ніколи не помилка. +* `context.arguments` містить уже визначені значення; клієнт передає їх як `context_arguments=`. +* Можливість `completions` з'являється, щойно ви реєструєте обробник. Без нього відповідь на запит — `Method not found`. + +Підказки допомагають, поки користувач ще *заповнює* промпт чи шаблон; щоб поставити йому запитання *посеред* виклику інструмента, потрібна **[Еліцитація](../handlers/elicitation.md)** (elicitation). Усе, що інструмент може повернути, крім тексту, — на сторінці **[Зображення, аудіо та значки](media.md)**. diff --git a/i18n/uk/pages/servers/handling-errors.md b/i18n/uk/pages/servers/handling-errors.md new file mode 100644 index 0000000000..af68db9283 --- /dev/null +++ b/i18n/uk/pages/servers/handling-errors.md @@ -0,0 +1,139 @@ +--- +translation: + sections: [e33d441f12d50535, 7099694c603e0f5f, c1df4cf9673433e6, c9cd294541422e6e, 6cec073617bfd037, efa92b8f99e908c8, 6a22a29e27fb4601] + tool: 1 +--- +# Обробка помилок {#handling-errors} + +Інструмент може завершитися невдачею двома способами, і SDK обробляє їх зовсім по-різному. + +Викиньте звичайний виняток — і його побачить **модель**. Викиньте `MCPError` — і його побачить **протокол**. + +Ця сторінка — про те, як вибрати. + +## Помилка, яку модель може виправити {#an-error-the-model-can-fix} + +Візьмімо інструмент, який щось шукає, і нехай пошук нічого не знайде: + +```python title="server.py" hl_lines="11-12" +--8<-- "docs_src/handling_errors/tutorial001.py" +``` + +У цих двох рядках немає нічого специфічного для MCP. `get_author` викидає звичайний `ValueError`, як це зробила б будь-яка функція Python. + +Викличте його з назвою, якої немає в каталозі, і подивіться на результат: + +```python +result.is_error # True +result.content # [TextContent(text="Error executing tool get_author: No book titled 'Nothing' in the catalog.")] +result.structured_content # None +``` + +* Запит **виконався успішно**. Результат є; на боці того, хто викликав, нічого не викинуто. +* `is_error` дорівнює `True`, а повідомлення вашого винятку (з назвою інструмента на початку) лежить у `content` — саме там, де читає модель. +* `structured_content` дорівнює `None`. У невдалого виклику немає значення, яке можна було б структурувати. + +Це **помилка інструмента**, і так за замовчуванням обробляється *будь-який* виняток, який викидає ваш інструмент. І майже завжди це саме те, що потрібно. + +Ваш інструмент викликає саме модель. Це вона обрала аргументи. Тож помилка інструмента — це репліка в розмові: модель читає *«No book titled 'Nothing' in the catalog.»*, розуміє, що не вгадала назву, і викликає знову з кращою. Один `raise` — і маєте агента, що сам виправляє свої помилки. + +!!! tip + Ніколи не повертайте повідомлення про помилку з інструмента через `return`. Повернутий рядок має `is_error=False`, тож для + моделі (і для кожного клієнтського інтерфейсу) це виглядає так, ніби інструмент спрацював і цей рядок і є відповіддю. + Пишіть `raise`. Сигнал — саме прапорець. + +## Помилка, яку модель не може виправити {#an-error-the-model-cannot-fix} + +Тепер замініть `ValueError` на `MCPError`. + +```python title="server.py" hl_lines="1 3 14" +--8<-- "docs_src/handling_errors/tutorial002.py" +``` + +`MCPError` — це **помилка протоколу** в SDK. Це єдиний виняток, який обгортка інструмента *не* перехоплює: він поширюється далі, і весь запит `tools/call` завершується помилкою JSON-RPC замість результату. + +```json +{ + "code": -32602, + "message": "No book titled 'Nothing' in the catalog." +} +``` + +* **Результату немає**. Ні `content`, ні `is_error` — моделі нема чого читати. +* Натомість помилку отримує застосунок-**хост** — так само, як отримав би, якби інструмента взагалі не існувало. +* `code`, `message` і `data` доходять без змін. `INVALID_PARAMS` — це `-32602`; `mcp.types` експортує його та інші коди помилок JSON-RPC (`INVALID_REQUEST`, `INTERNAL_ERROR`, ...) як константи, тож набирати магічні числа вручну не доведеться. + +!!! check + Той самий пошук, той самий промах, але тепер виклик на боці клієнта *викидає виняток* замість того, щоб повернути результат: + + ```text + mcp.shared.exceptions.MCPError: No book titled 'Nothing' in the catalog. + ``` + + Перша версія давала моделі речення, на яке вона могла відреагувати. Ця не дає їй нічого. + Для `get_author` це однозначно гірше — і саме про це наступний розділ. + +## Який із них викидати {#which-one-to-raise} + +Ці два шляхи відповідають на два різні запитання. + +* **Викидайте будь-який виняток** у разі збою *виконання*: те, що інструмент намагався зробити, не вдалося. Виклик обрала модель, тож саме модель має побачити наслідок і отримати шанс виправитися. Назва з помилкою, зовнішній API, що не відповів вчасно, рядок, якого не існує, — усе це помилки інструмента. +* **Викидайте `MCPError`**, коли слід відхилити *сам запит*: клієнтові бракує можливості, від якої залежить інструмент, сервер не в тому стані, щоб обслуговувати будь-кого, той, хто викликає, пропустив обов'язковий крок. Жодна повторна спроба моделі нічого з цього не виправить, тож передавати їй повідомлення немає сенсу. + +Вирішує одне запитання: **чи могла б розумніша модель цього уникнути?** Так -> звичайний виняток. Ні -> `MCPError`. + +За цим критерієм друга версія `get_author` зробила хибний вибір: краща назва все виправляє, тож модель заслуговувала побачити повідомлення. Ця версія тут, щоб показати механізм, а не щоб його рекомендувати. + +!!! info + `MCPError` імпортується як `from mcp import MCPError` і приймає `code`, `message` та необов'язкове + корисне навантаження `data`. Усе, що ви в них покладете, клієнт і отримає: SDK пересилає викинутий + `MCPError` дослівно, не очищуючи його. + +## Ресурс, якого не існує {#a-resource-that-doesnt-exist} + +Ресурси проводять ту саму межу й мають один іменований виняток для типового випадку. + +```python title="server.py" hl_lines="2 13" +--8<-- "docs_src/handling_errors/tutorial003.py" +``` + +`books://{title}` — це **шаблон**. Він збігається з *будь-якою* назвою, тому «URI коректний» і «книга існує» — два різні запитання, і відповісти на друге може лише ваша функція. + +Коли не може — викиньте `ResourceNotFoundError`. SDK перетворює його на помилку протоколу, яку специфікація призначає для відсутнього ресурсу: `-32602` із запитаним URI в `data`, тож клієнт знає, *яке саме* читання не вдалося. + +```json +{ + "code": -32602, + "message": "No book titled 'Nothing' in the catalog.", + "data": {"uri": "books://Nothing"} +} +``` + +Зверніть увагу: тут немає напіврезультату з `is_error=True`. Читання ресурсу або повертає вміст, або завершується помилкою: у ресурсів є лише протокольний шлях. Про шаблони й усе інше, що стосується ресурсів, — на сторінці **[Ресурси](resources.md)**. + +## Помилки, які ви ніколи не викидаєте {#errors-you-never-raise} + +Некоректний аргумент ніколи не доходить до вашої функції. + +Надішліть `get_author` параметр `title`, що не є рядком, — і SDK відхилить його за вхідною схемою **ще до** виклику вашої функції, як таку саму помилку інструмента з `is_error=True`, яку модель може прочитати й виправити. На сторінці **[Інструменти](tools.md)** показано таке саме відхилення з обмеженням `Field(le=50)`. + +Це означає цілий клас інструкцій `raise`, які писати не треба: не перевіряйте повторно власні анотації типів. + +!!! info + Усе на цій сторінці — те, що бачить **клієнт**, і `Client` у пам'яті, з яким ви писатимете + тести, бачить рівно те саме. Навіть `raise_exceptions=True` не перетворює помилку інструмента + назад на трасування: до моменту, коли цей прапорець міг би спрацювати, ваш виняток уже став + результатом з `is_error=True`. Перевіряйте результат через assert. Цей підхід описано на сторінці **[Тестування](../get-started/testing.md)**. + +## Підсумки {#recap} + +* Викиньте **будь-який виняток** в інструменті -> виклик повертає `is_error=True` з вашим повідомленням у `content`. Модель читає його й може повторити спробу. Це поведінка за замовчуванням. +* Викиньте **`MCPError`** -> сам виклик завершується помилкою JSON-RPC. Модель нічого не бачить; розбирається хост. `code`, `message` і `data` доходять без змін. +* Вирішальне запитання: *чи могла б розумніша модель цього уникнути?* Так -> виняток. Ні -> `MCPError`. +* `ResourceNotFoundError` з обробника ресурсу -> протокольний `-32602` з URI в `data`. +* Некоректні аргументи відхиляються за схемою ще до запуску вашої функції; для них `raise` не потрібен. +* `from mcp import MCPError`; константи кодів помилок — з `mcp.types`. + +З помилками розібралися. Це все, що сервер *надає назовні*. Про те, що кожен обробник може читати і що робити у відповідь клієнтові під час роботи, — наступний розділ: **[Усередині обробника](../handlers/index.md)**. + +Точний текст помилок SDK, з якими ви найімовірніше зіткнетеся, що кожна з них означає і як виправити кожну одним рухом, — на сторінці **[Усунення несправностей](../troubleshooting.md)**. diff --git a/i18n/uk/pages/servers/index.md b/i18n/uk/pages/servers/index.md new file mode 100644 index 0000000000..31092009d8 --- /dev/null +++ b/i18n/uk/pages/servers/index.md @@ -0,0 +1,37 @@ +--- +translation: + sections: [09defc170a0da89d] + tool: 1 +--- +# Сервери {#servers} + +`MCPServer` надає під'єднаному клієнтові три примітиви. Вони різняться тим, +хто вирішує їх використати: + +* **[Інструмент](tools.md)** — це дія, яку обирає й викликає *модель*. Саме + ця сторінка зазвичай потрібна першою, а + **[Структурований вивід](structured-output.md)** — її довідковий супутник: + усе про форму того, що повертає інструмент. +* **[Ресурс](resources.md)** — це дані лише для читання, які вирішує + прочитати *застосунок*. **[URI-шаблони](uri-templates.md)** — його + довідковий супутник: повний синтаксис адресації та правила безпеки шляхів. +* **[Промпт](prompts.md)** — це шаблон повідомлення, який *людина* викликає + за іменем, з меню або через слеш-команду. + +Довкола цих трьох примітивів — решта того, що оголошує сервер: + +* **[Доповнення](completions.md)** — серверне автодоповнення аргументів + промптів і шаблонів ресурсів. +* **[Зображення, аудіо та іконки](media.md)** охоплює все, що інструмент + може повернути, окрім тексту, а також іконки, які клієнт показує поруч із + вашим сервером. +* **[Обробка помилок](handling-errors.md)** пояснює різницю між помилкою, + після якої модель може відновитися, і помилкою, яку вона ніколи не має + побачити. + +Кожна сторінка тут самодостатня; переходьте одразу до потрібної. Якщо сервер +ще не створено, почніть натомість із **[Перших кроків](../get-started/first-steps.md)**. + +Те, що відбувається *всередині* зареєстрованих функцій (`Context`, впровадження +залежностей, запит додаткового вводу від користувача посеред виклику), — це +наступний розділ, **[Усередині обробника](../handlers/index.md)**. diff --git a/i18n/uk/pages/servers/media.md b/i18n/uk/pages/servers/media.md new file mode 100644 index 0000000000..b8581446b5 --- /dev/null +++ b/i18n/uk/pages/servers/media.md @@ -0,0 +1,122 @@ +--- +translation: + sections: [496394d24d221bf1, 4ceb4591180dc6c3, 0fd63e4682d02e0c, 969ede0bd3686a16, 043f526230dd243d, 6ee3e9bcfd24047a] + tool: 1 +--- +# Медіа {#media} + +Текст — не єдине, що може повернути інструмент. + +SDK містить два допоміжні класи для двійкових результатів (**`Image`** і **`Audio`**) та тип **`Icon`**, що дає серверу, інструментам, ресурсам і промптам власне обличчя в інтерфейсі клієнта. + +## Повернення зображення {#returning-an-image} + +Оголосіть тип результату як `Image`, вкажіть файл і поверніть об'єкт: + +```python title="server.py" hl_lines="8 12 14" +--8<-- "docs_src/media/tutorial001.py" +``` + +* `Image` приймає рівно один із двох аргументів: `path` (файл, який треба прочитати) або `data` (сирі байти). +* MIME-тип, який бачить клієнт, визначається за розширенням: `logo.png` оголошується як `image/png`. +* У логотипах тут немає нічого особливого. Підійде будь-який PNG поруч із `server.py`: графік, який побудував ваш код, діаграма, фото. + +`Image` — це зручність SDK, а не тип протоколу. У переданих даних повернене значення стає блоком **`ImageContent`** (байти файлу в кодуванні base64 плюс MIME-тип): + +```python +result.content # [ImageContent(type="image", data="iVBORw0KGgoAAAANSUhEUg...", mime_type="image/png")] +result.structured_content # None +``` + +Зверніть увагу на дві речі: + +* `data` — це base64. Байтів ви не торкалися: SDK прочитав файл і закодував його сам. +* `structured_content` дорівнює `None`. `Image` — це вміст, на який дивиться модель, а не дані, які розбирає застосунок: схеми виводу немає. (Порівняйте зі **[структурованим виводом](structured-output.md)**, де анотація результату *і є* схемою.) + +!!! info + `ImageContent` і `AudioContent` містяться в `mcp.types`, поруч із `TextContent`, + на який перетворюється звичайний результат `str` (**[Інструменти](tools.md)**). Результат інструмента — це список блоків вмісту; `Image` і `Audio` — + найкоротший спосіб отримати два двійкові різновиди. + +### Спробуйте самі {#try-it} + +Покладіть будь-який PNG поруч із `server.py`, назвіть його `logo.png` і запустіть: + +```console +uv run mcp dev server.py +``` + +Відкрийте вкладку **Tools** і викличте `logo`. Результат — не рядок: це блок вмісту `image`, і Inspector показує ваше зображення. Усе між файлом на диску й пікселями на екрані зробив SDK. + +## Повернення аудіо {#returning-audio} + +`Audio` має ту саму форму. Залиште `logo.png` на місці й покладіть поруч будь-який WAV під назвою `chime.wav`: + +```python title="server.py" hl_lines="18-21" +--8<-- "docs_src/media/tutorial002.py" +``` + +Результат — блок **`AudioContent`**: + +```python +result.content # [AudioContent(type="audio", data="UklGR...", mime_type="audio/wav")] +result.structured_content # None +``` + +Те саме: на вході — файл на диску, на виході — base64 і MIME-тип, без схеми виводу. + +## Байти чи файл {#bytes-or-a-file} + +Обидва допоміжні класи приймають також `data=` (сирі байти) замість `path=`. Це режим для байтів, які ніколи не були окремим файлом: стовпець бази даних, HTTP-відповідь, щось щойно намальоване в Pillow: + +```python title="server.py" hl_lines="14 15" +--8<-- "docs_src/media/tutorial003.py" +``` + +Із `path=` оголошувати нічого не потрібно: файл читається під час побудови результату, а MIME-тип визначається за розширенням: + +* `Image`: `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`. +* `Audio`: `.wav`, `.mp3`, `.ogg`, `.flac`, `.aac`, `.m4a`. + +Нерозпізнане розширення дає `application/octet-stream`. + +!!! check + Із `data=` імені файлу немає, тож визначати тип немає з чого. Забудете `format=` — + і SDK візьме типове значення: `image/png` для зображень, `audio/wav` для аудіо. Створіть + так `Audio` з байтів MP3 — і клієнту повідомлять `mime_type="audio/wav"`, після чого + він сумлінно не зможе це декодувати. Передаєте `data=` — передавайте й `format=`. + +## Іконки {#icons} + +`Icon` — це метадані, а не вміст. Він не містить зображення, а вказує на нього через URI, і клієнт може завантажити його й показати поруч із назвою сервера, інструментом, ресурсом чи промптом. + +```python title="server.py" hl_lines="4-5 7 10 16" +--8<-- "docs_src/media/tutorial004.py" +``` + +* `src` — це URI, який клієнт може розв'язати: `https:` або `data:`, якщо потрібно вбудувати іконку без додаткового запиту. +* `mime_type` і `sizes` (`"48x48"` або `"any"` для масштабованого формату) дають клієнту змогу вибрати потрібну, коли ви пропонуєте кілька. +* `theme="light"` або `theme="dark"` позначає іконку для однієї колірної схеми. + +Той самий іменований аргумент `icons=[...]` приймають `MCPServer(...)`, `@mcp.tool()`, `@mcp.resource()` і `@mcp.prompt()`. + +### Де їх бачить клієнт {#where-a-client-sees-them} + +Іконки передаються разом із тим, що вони прикрашають. Іконки сервера надходять під час підключення клієнта, у `client.server_info` (на з'єднаннях покоління 2026 це поле необов'язкове, тож спершу звузьте тип): + +```python +assert client.server_info is not None # python-sdk servers identify themselves by default +client.server_info.icons # [Icon(src="https://example.com/brand-kit.png", mime_type="image/png", sizes=["48x48"])] +``` + +Іконки інструмента містяться в об'єкті `Tool` з `tools/list`, ресурсу — в `Resource` з `resources/list`, промпту — в `Prompt` з `prompts/list`. Поле завжди називається `icons`. + +## Підсумки {#recap} + +* Поверніть з інструмента `Image` або `Audio` — і клієнт отримає блок `ImageContent` / `AudioContent`: ваші байти в кодуванні base64 з MIME-типом. +* Створюйте їх із `path=`, і тоді MIME-тип визначить розширення, або з `data=` у пам'яті плюс явний `format=`. +* Медіарезультати не мають ні `structured_content`, ні схеми виводу. +* `Icon` — це вказівник: URI `src` плюс необов'язкові `mime_type`, `sizes` і `theme`. +* `icons=[...]` працює на сервері, інструментах, ресурсах і промптах, а клієнти знаходять їх у відповідних об'єктах. + +Це все, що інструмент може покласти *в* результат. Що відбувається, коли інструмент *зазнає невдачі* (і хто має про це дізнатися), — на сторінці **[Обробка помилок](handling-errors.md)**. diff --git a/i18n/uk/pages/servers/prompts.md b/i18n/uk/pages/servers/prompts.md new file mode 100644 index 0000000000..8fedcf3147 --- /dev/null +++ b/i18n/uk/pages/servers/prompts.md @@ -0,0 +1,155 @@ +--- +translation: + sections: [d65c098f37f5b6c3, dd0c2724d6f2877e, 6835bb3570c6714c, ffe823cb0fedd488, f33651add1b59094] + tool: 1 +--- +# Промпти {#prompts} + +**Промпт** — це шаблон повідомлення, який обирає користувач. + +Інструменти призначені для моделі. Промпт — навпаки: користувач обирає його з меню у своєму клієнті (слеш-команда, кнопка), заповнює аргументи, і згенеровані повідомлення потрапляють у розмову так, ніби він набрав їх сам. + +Щоб оголосити промпт, поставте `@mcp.prompt()` над функцією, яка повертає текст. + +## Ваш перший промпт {#your-first-prompt} + +```python title="server.py" hl_lines="6-9" +--8<-- "docs_src/prompts/tutorial001.py" +``` + +SDK зчитує ті самі три речі, що й з інструмента: + +* **Ім'я** — це ім'я функції: `review_code`. +* **Опис**, який показує клієнт, — це docstring: `Review a piece of code.` +* **Аргументи** беруться з параметрів. `code` не має типового значення, тому він обов'язковий. + +Ось що клієнт отримує у відповідь на `prompts/list`: + +```json +{ + "name": "review_code", + "description": "Review a piece of code.", + "arguments": [ + {"name": "code", "required": true} + ] +} +``` + +Тут немає JSON Schema. Аргументи промпту — це плоский список **іменованих рядкових значень**: форма, яку заповнює людина, а не дані, які конструює модель. + +### Генерування {#rendering-it} + +Клієнт генерує повідомлення за шаблоном через `prompts/get`, передаючи аргументи. Ваша функція виконується, і повернутий `str` стає **одним повідомленням користувача**: + +```json +{ + "description": "Review a piece of code.", + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "Please review this code:\n\ndef add(a, b): return a + b" + } + } + ], + "resultType": "complete" +} +``` + +Оце й усе життя промпту: його показують у списку за іменем, генерують на вимогу і вставляють у чат. + +!!! check + `required` перевіряється ще до запуску вашої функції. Згенеруйте `review_code` без `code` — + і сам запит завершиться помилкою JSON-RPC (код `-32603`): + + ```text + mcp.shared.exceptions.MCPError: Internal server error + ``` + + Результату з помилкою на кшталт інструмента, який можна було б передати моделі, тут немає, бо моделі в цьому ланцюжку немає взагалі: + виклик викидає виняток. Причина (`Missing required arguments: {'code'}`) потрапляє в лог вашого сервера. + +### Спробуйте самі {#try-it} + +Запустіть сервер із MCP Inspector: + +```console +uv run mcp dev server.py +``` + +Відкрийте вкладку **Prompts** і виберіть `review_code`. Inspector намалює форму з одним обов'язковим полем `code`. Заповніть його, згенеруйте промпт — і отримаєте точно те повідомлення користувача, що наведене вище. + +## Більше ніж одне повідомлення {#more-than-one-message} + +Рев'ю коду — це одне повідомлення. Сеанс налагодження — це розмова, і промпт може закласти її цілком. + +Поверніть список повідомлень замість `str`: + +```python title="server.py" hl_lines="2 13-20" +--8<-- "docs_src/prompts/tutorial002.py" +``` + +* `UserMessage` і `AssistantMessage` імпортуються з `mcp.server.mcpserver.prompts.base`. Передайте їм `str`, і вони самі загорнуть його в `TextContent`. Роль — це ім'я класу. +* `Message` — їхній спільний базовий клас. Використовуйте його як анотацію типу результату. + +Генерування `debug_error` тепер дає три повідомлення по порядку: + +```json +{ + "description": "Start a debugging conversation.", + "messages": [ + {"role": "user", "content": {"type": "text", "text": "I'm seeing this error:"}}, + {"role": "user", "content": {"type": "text", "text": "TypeError: 'int' object is not iterable"}}, + { + "role": "assistant", + "content": {"type": "text", "text": "I'll help debug that. What have you tried so far?"} + } + ], + "resultType": "complete" +} +``` + +Зверніть увагу на останнє. Заздалегідь заповнена репліка `assistant` — це спосіб спрямувати *наступну* відповідь моделі, не змушуючи користувача набирати ці настанови самому. + +## Заголовки та описи аргументів {#titles-and-argument-descriptions} + +`review_code` — це ім'я функції, а не підпис. Дайте клієнту щось краще для напису на кнопці й опишіть кожен аргумент, щоб форма пояснювала себе сама: + +```python title="server.py" hl_lines="10-13" +--8<-- "docs_src/prompts/tutorial003.py" +``` + +* `title="Code review"` — це зрозуміла людині назва, точно як `title` в інструмента. +* `Annotated[str, Field(description=...)]` — той самий шаблон, яким на сторінці **[Інструменти](tools.md)** описано параметри інструмента. Тут опис потрапляє на аргумент, а не в схему. +* `language` має типове значення, тому перестає бути обов'язковим. + +Запис у `prompts/list` тепер містить усе, що потрібно клієнту, щоб намалювати хорошу форму: + +```json +{ + "name": "review_code", + "title": "Code review", + "description": "Review a piece of code.", + "arguments": [ + {"name": "code", "description": "The code to review.", "required": true}, + {"name": "language", "description": "The language the code is written in.", "required": false} + ] +} +``` + +!!! info + Якщо ви читали сторінку **[Інструменти](tools.md)**, то вже знаєте все, що є на цій. Той самий декоратор, той самий + docstring як опис, ті самі `Annotated`/`Field`. Змінюється лише те, хто + його запускає (користувач) і куди йде результат (у розмову). + +## Підсумки {#recap} + +* `@mcp.prompt()` над функцією робить її промптом. Ім'я — з функції, опис — з docstring. +* Промптами **керує користувач**: клієнт показує їхній список, користувач обирає один і заповнює аргументи. +* Аргументи — це плоский список іменованих рядків (без схеми). Параметр із типовим значенням необов'язковий. +* Поверніть `str` — і він стане одним повідомленням користувача. Поверніть список `UserMessage` / `AssistantMessage`, щоб закласти багатоходову розмову. +* `title=` і `Field(description=...)` — це те, що клієнт показує у своєму інтерфейсі. +* Відсутній обов'язковий аргумент провалює весь запит. Окремого результату з помилкою для промпту немає. + +Серверне автодоповнення аргументів промпту (або шаблону ресурсу) — це **[Автодоповнення](completions.md)**. diff --git a/i18n/uk/pages/servers/resources.md b/i18n/uk/pages/servers/resources.md new file mode 100644 index 0000000000..b52414c248 --- /dev/null +++ b/i18n/uk/pages/servers/resources.md @@ -0,0 +1,146 @@ +--- +translation: + sections: [09df998c2a799f78, 0cf131146d16d4f9, 4e6b91e3f8025346, 8fe4eef576db17ed, 0d0d1ed43e3d0a53] + tool: 1 +--- +# Ресурси {#resources} + +**Ресурс** — це дані, які ви надаєте застосунку для читання. + +У цьому й полягає розмежування. Інструмент — це те, що вирішує викликати **модель**. Ресурс — це те, що вирішує завантажити **застосунок** (файл конфігурації, запис, документ) і покласти перед моделлю як контекст. + +Щоб оголосити ресурс, повісьте `@mcp.resource(uri)` на звичайну функцію Python. + +## Ваш перший ресурс {#your-first-resource} + +```python title="server.py" hl_lines="6-8" +--8<-- "docs_src/resources/tutorial001.py" +``` + +Форма та сама, що й в інструмента, плюс одна річ: **URI**. Ресурси мають адресу, а не ім'я. Клієнт запитує `config://app`, а не `get_config`. + +Решту SDK, як і раніше, зчитує з функції: + +* **Ім'я** — це ім'я функції: `get_config`. +* **Опис**, який бачить клієнт, — це docstring. +* **Вміст** — те, що ви повертаєте. + +Під час `resources/list` клієнт отримує ось що: + +```json +{ + "name": "get_config", + "uri": "config://app", + "description": "The active shop configuration.", + "mimeType": "text/plain" +} +``` + +А коли він читає `config://app`, виконується ваша функція, і повернене значення приходить назад як текст: + +```python +result.contents # [TextResourceContents(uri="config://app", mime_type="text/plain", text="theme=dark\nlanguage=en")] +``` + +!!! tip + Перелік коштує дешево. Ваша функція **не** викликається під час `resources/list` — лише + під час `resources/read`, і лише для запитаного URI. Надайте тисячу ресурсів — + і платитимете тільки за ті, які хтось відкриє. + +### Спробуйте самі {#try-it} + +Запустіть сервер у MCP Inspector: + +```console +uv run mcp dev server.py +``` + +Відкрийте URL, який він виведе, і перейдіть на вкладку **Resources**. `config://app` є в списку разом з описом. Клацніть його — Inspector його прочитає: ось ваші два рядки конфігурації. + +## Шаблони ресурсів {#resource-templates} + +Один URI на запис не масштабується. Додайте в URI **заповнювач** і відповідний параметр у функцію: + +```python title="server.py" hl_lines="12-13" +--8<-- "docs_src/resources/tutorial002.py" +``` + +`{user_id}` в URI, `user_id: str` у функції. Оце й увесь контракт. + +Тепер це **шаблон ресурсу**, і він переїжджає: зникає з `resources/list` і натомість з'являється в `resources/templates/list` — як зразок, а не як адреса: + +```json +{ + "name": "get_user_profile", + "uriTemplate": "users://{user_id}/profile", + "description": "A customer's profile.", + "mimeType": "text/plain" +} +``` + +Клієнт підставляє значення замість заповнювача й читає конкретний URI: `users://42/profile`, `users://ada/profile`. На всі відповідає одна функція, а зіставлене значення передається як `user_id`: + +```python +result.contents # [TextResourceContents(uri="users://42/profile", text="User 42: 12 orders since 2021.")] +``` + +Зверніть увагу на `uri` в результаті. Це **конкретний** URI, який запитав клієнт, а не шаблон. + +!!! check + Заповнювачі та параметри мають збігатися. Перейменуйте параметр функції на + `user`, поки в URI досі вказано `{user_id}`, — і декоратор відмовить **під час імпорту**, + задовго до того, як до нього наблизиться будь-який клієнт: + + ```text + ValueError: Mismatch between URI parameters {'user_id'} and function parameters {'user'} + ``` + + Розбіжність може бути лише помилкою, тож SDK робить неможливим запуск сервера з нею. + +Синтаксис заповнювачів — [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570): `{+path}` для багатосегментних значень, `{?q,lang}` для необов'язкових параметрів запиту тощо. Крім того, SDK за замовчуванням застосовує до видобутих значень перевірки безпеки шляхів. Повний довідник — на сторінці **[Шаблони URI та безпека шляхів](uri-templates.md)**. + +`get_user_profile` також може приймати параметр з анотацією `Context`. SDK впроваджує його, ніколи не трактуючи як параметр URI, а що саме він дає — описано на сторінці **[Об'єкт Context](../handlers/context.md)**. + +## Що повертати {#what-you-return} + +Ви не обмежені типом `str`. Задайте кожному ресурсу `mime_type` і повертайте те, що пасує: + +```python title="server.py" hl_lines="8-9 14-15 20-21" +--8<-- "docs_src/resources/tutorial003.py" +``` + +* `readme` повертає `str`, тож його надсилають як є. Це найпоширеніший випадок. +* `catalog_stats` повертає `dict`, тому SDK серіалізує його в **JSON-текст** за вас: + + ```json + { + "books": 1204, + "authors": 391 + } + ``` + +* `placeholder_cover` повертає `bytes`, тож клієнт отримує `BlobResourceContents` замість `TextResourceContents`, а ваші байти закодовано в base64 у полі `blob`. + +Те саме правило стосується всього, що серіалізується в JSON: списку, моделі Pydantic, dataclass. Якщо це не `str` і не `bytes`, воно стає JSON. + +`mime_type` ви оголошуєте самі, і за замовчуванням це `text/plain`. SDK ніколи не аналізує повернене значення, щоб його вгадати, тож ресурс із `dict`, який ви не позначили, усе одно оголошується як звичайний текст. + +!!! tip + `@mcp.resource()` також приймає `name=`, `title=` і `description=`, коли їх не хочеться + виводити з функції. А коли функцію взагалі писати не треба, + у `mcp.server.mcpserver.resources` є готові класи `Resource` (`TextResource`, + `BinaryResource`, `FileResource`, `HttpResource`, `DirectoryResource`), які реєструють + через `mcp.add_resource(...)`. + +Клієнт також може **підписатися** на ресурс і отримувати сповіщення про його зміни; це клієнтська половина історії, і описана вона на сторінці **[Клієнт](../client/index.md)**. + +## Підсумки {#recap} + +* `@mcp.resource(uri)` на функції робить її ресурсом. URI — це адреса, повернене значення — вміст, docstring — опис. +* `{placeholder}` в URI перетворює його на **шаблон**: він потрапляє в `resources/templates/list`, і одна функція обслуговує всі URI, що збігаються. +* Імена заповнювачів мають дорівнювати іменам параметрів функції. Помилитеся — і дізнаєтеся про це під час імпорту, а не в продакшені. +* Ваша функція виконується, коли ресурс **читають**, а не коли його перелічують. +* `str` стає текстом, `bytes` — base64-блобом, усе інше — JSON-текстом. `mime_type=` — це те, як ви його позначаєте. +* Інструменти — щоб модель діяла. Ресурси — щоб застосунок читав. + +Третій примітив, той, що його людина вибирає з меню, — це **[Промпти](prompts.md)**. diff --git a/i18n/uk/pages/servers/structured-output.md b/i18n/uk/pages/servers/structured-output.md new file mode 100644 index 0000000000..585204430d --- /dev/null +++ b/i18n/uk/pages/servers/structured-output.md @@ -0,0 +1,250 @@ +--- +translation: + sections: [a838d57f003aed44, 857d03886a0137ed, 42d9efcb9f542867, 2290ff08435b5573, e866c192e11d1c14, 6cdbad079f7b47f0, d4b607372fb28b51, 18dbf726ac45e0b7, c6f7d2a148aa49f4, c851964bb3301907, d715db6f8dccc9cc, ef86634aa70498a7] + tool: 1 +--- +# Структурований вивід {#structured-output} + +Інструмент, що повертає звичайний `str`, видає результат двічі: як текст у `content` і як `{"result": "..."}` у `structured_content`. + +Ця сторінка — про той другий канал: звідки він береться, яких форм може набувати і як SDK стежить, щоб він не брехав. + +Коротко: **анотація типу, що повертається, і є схемою виводу**. Ви її вже написали. + +## Схема виводу {#the-output-schema} + +```python title="server.py" hl_lines="9" +--8<-- "docs_src/structured_output/tutorial001.py" +``` + +Важливий рядок — сигнатура: `-> int`. + +Завдяки їй інструмент, який SDK надсилає під час `tools/list`, несе `output_schema` поруч зі схемою вводу, побудованою з параметрів (її описано на сторінці **[Інструменти](tools.md)**): + +```json +{ + "properties": { + "result": {"title": "Result", "type": "integer"} + }, + "required": ["result"], + "title": "get_temperatureOutput", + "type": "object" +} +``` + +Голий `int` — це не JSON-об'єкт, тому SDK **загортає** його в `{"result": ...}`. Викличте інструмент — і обидва канали заповнені: + +```python +result.content # [TextContent(text="17")] +result.structured_content # {"result": 17} +``` + +Таку саму обгортку отримує кожен скаляр: `str`, `int`, `float`, `bool`, `bytes`, `None`. + +## Два канали {#two-channels} + +Навіщо надсилати те саме значення двічі? + +* `content` — для **моделі**. Мовна модель читає текст; це єдина частина результату, яку вона бачить. +* `structured_content` — для **застосунку**, усередині якого працює модель: коду, якому потрібне `17`, а не речення зі словом «17». +* `output_schema` — це контракт між ними, опублікований ще до першого виклику інструмента. + +Ви повертаєте одне значення Python. SDK заповнює всі три. + +## Повернення моделі {#return-a-model} + +Оголосіть форму як `BaseModel` з Pydantic і поверніть екземпляр: + +```python title="server.py" hl_lines="8-11 15" +--8<-- "docs_src/structured_output/tutorial002.py" +``` + +Тепер схемою **є** сам `WeatherData`. Без обгортки, без ключа `result`: + +```json +{ + "properties": { + "temperature": {"description": "Degrees Celsius.", "title": "Temperature", "type": "number"}, + "humidity": {"description": "Relative humidity, 0 to 1.", "title": "Humidity", "type": "number"}, + "conditions": {"title": "Conditions", "type": "string"} + }, + "required": ["temperature", "humidity", "conditions"], + "title": "WeatherData", + "type": "object" +} +``` + +`structured_content` — це сам об'єкт, поле в поле: + +```python +result.structured_content # {"temperature": 16.2, "humidity": 0.83, "conditions": "Overcast"} +``` + +І модель не лишається осторонь. SDK серіалізує той самий об'єкт у JSON-текст для `content`: + +```json +{ + "temperature": 16.2, + "humidity": 0.83, + "conditions": "Overcast" +} +``` + +Зверніть увагу: `Field(description=...)` на `temperature` і `humidity` потрапили до схеми. Той самий `Field`, що описував **вхідні дані**, описує й вихідні. + +!!! info + Якщо ви користувалися `response_model` у FastAPI, це вам знайомо: модель Pydantic як оголошена + відповідь, серіалізована й задокументована за вас. Єдина відмінність — тут усе оголошення + вичерпується анотацією типу, що повертається. + +## `TypedDict` {#a-typeddict} + +Не кожна форма заслуговує на клас. `TypedDict` дає таку саму схему: + +```python title="server.py" hl_lines="8" +--8<-- "docs_src/structured_output/tutorial003.py" +``` + +Під час виконання `TypedDict` — це звичайний `dict`, тож саме його ви будуєте й повертаєте. Схема, валідація і `structured_content` ідентичні версії з `BaseModel` (за винятком описів, для яких у `TypedDict` немає місця). + +## Dataclass {#a-dataclass} + +Dataclass теж підходять, як і будь-який звичайний клас, атрибути якого мають анотації типів. SDK усередині будує з анотацій модель Pydantic. + +```python title="server.py" hl_lines="8-9" +--8<-- "docs_src/structured_output/tutorial004.py" +``` + +Три записи — одна схема. Беріть той, що вже є у вашій кодовій базі. + +## Списки {#lists} + +`list[...]` — теж не JSON-об'єкт, тому він отримує обгортку `{"result": ...}`, а тип елемента всередині неї — посилання на `$defs`: + +```python title="server.py" hl_lines="15" +--8<-- "docs_src/structured_output/tutorial005.py" +``` + +```json +{ + "$defs": { + "WeatherData": { + "properties": { + "temperature": {"title": "Temperature", "type": "number"}, + "humidity": {"title": "Humidity", "type": "number"}, + "conditions": {"title": "Conditions", "type": "string"} + }, + "required": ["temperature", "humidity", "conditions"], + "title": "WeatherData", + "type": "object" + } + }, + "properties": { + "result": {"items": {"$ref": "#/$defs/WeatherData"}, "title": "Result", "type": "array"} + }, + "required": ["result"], + "title": "get_forecastOutput", + "type": "object" +} +``` + +Запитайте прогноз на два дні — і `structured_content` буде `{"result": [{...}, {...}]}`. `content` перетворюється на **два** блоки `TextContent`, по одному на елемент: список для моделі розгортається, а не зливається в один рядок. + +`tuple[...]`, об'єднання типів і `Optional[...]` загортаються так само. + +## Словники {#dictionaries} + +`dict[str, ...]` — єдиний узагальнений тип, що вже *є* JSON-об'єктом, тому він не загортається: + +```python title="server.py" hl_lines="9" +--8<-- "docs_src/structured_output/tutorial006.py" +``` + +```json +{ + "additionalProperties": {"type": "number"}, + "title": "get_temperaturesDictOutput", + "type": "object" +} +``` + +```python +result.structured_content # {"London": 16.2, "Reykjavik": 4.4} +``` + +Ключі мають бути `str`. `dict[int, float]` не може бути JSON-об'єктом, тож він повертається до обгортки `{"result": ...}`. + +## Валідація {#validation} + +`output_schema` — це не документація. Усе, що повертає функція, **перевіряється на відповідність їй** перед тим, як залишити сервер. + +Поки значення будується вручну, цього не помічаєш: Pydantic уже подбав, щоб `WeatherData` був `WeatherData`. Помічаєш того дня, коли дані приходять звідкись, що ви не контролюєте: + +```python title="server.py" hl_lines="9 21" +--8<-- "docs_src/structured_output/tutorial007.py" +``` + +Анотація обіцяє `WeatherData`. Відповідь зовнішнього сервісу перестала надсилати `humidity`. + +!!! check + Викличте `get_weather` — і він не передасть клієнту тихцем напівпорожній об'єкт. Виклик завершується помилкою, + і перші рядки помилки називають поле: + + ```text + Error executing tool get_weather: 1 validation error for WeatherData + humidity + Field required [type=missing, input_value={'temperature': 16.2, 'conditions': 'Overcast'}, input_type=dict] + ``` + + Цей текст повертається як результат інструмента з `is_error=True`, тож модель знає, що виклик не вдався, + замість того щоб упевнено читати погоду, якої немає. + +До речі, повертати звичайний `dict` з інструмента з `-> WeatherData` цілком можна. Саме це й видав `json.loads`. Перевіряється значення, а не тип Python. + +## Відмова від структурованого виводу {#opting-out} + +Іноді анотація типу, що повертається, призначена для перевірки типів, а не для протоколу. Передайте `structured_output=False` — і інструмент стане лише текстовим: + +```python title="server.py" hl_lines="6" +--8<-- "docs_src/structured_output/tutorial008.py" +``` + +Ні `output_schema`, ні обгортки, ні валідації. `structured_content` дорівнює `None`, а `content` — рядок, який ви повернули. + +Протилежне, `structured_output=True`, перетворює автоматичне визначення на вимогу: інструмент, чий тип повернення не може дати схему, викидає виняток під час імпорту замість відкоту до тексту. + +## Клас без анотацій типів {#a-class-without-type-hints} + +Є один спосіб опинитися без структурованого виводу, не просивши про це: повернути клас, у **тілі якого немає анотацій**. + +```python title="server.py" hl_lines="6-9" +--8<-- "docs_src/structured_output/tutorial009.py" +``` + +`Station` задає `name` і `online` всередині `__init__`, але сам *клас* нічого не оголошує. SDK читає анотації класу, не знаходить жодної й здається. + +!!! warning + Здається він **мовчки**. `output_schema` — `None`, `structured_content` — `None`, а текст, + який читає модель, — це `repr` об'єкта: + + ```text + "" + ``` + + Ні помилки, ні попередження — непридатний інструмент. Перенесіть анотації в тіло класу або передайте + `structured_output=True`, що перетворює це на жорстку помилку в момент імпорту модуля: + `Function get_station: return type is not serializable for structured output`. + +!!! tip + Потрібен повний контроль (самостійно збудувати `CallToolResult` або додати `_meta`, які + застосунок бачить, а модель — ні)? Це — **[Низькорівневий Server](../advanced/low-level-server.md)**. + +## Підсумки {#recap} + +* **Анотація типу, що повертається**, — це схема виводу. Вона публікується в `tools/list` як `output_schema`. +* Скаляри, списки, кортежі й об'єднання типів загортаються в `{"result": ...}`. Моделі, `TypedDict`, dataclass, анотовані класи й `dict[str, ...]` — уже об'єкти й лишаються як є. +* Кожен результат несе `content` (текст, для моделі) **і** `structured_content` (дані, для застосунку). +* Те, що ви повертаєте, перевіряється на відповідність схемі. Невідповідність — це помилка інструмента, а не зіпсований результат. +* `structured_output=False` вимикає це для інструмента. Клас без анотацій типів вимикає це мовчки; пильнуйте. + +Тепер ви володієте всім, що інструмент може сказати у відповідь. Далі — другий примітив: **[Ресурси](resources.md)**. diff --git a/i18n/uk/pages/servers/tools.md b/i18n/uk/pages/servers/tools.md new file mode 100644 index 0000000000..d41dfd6386 --- /dev/null +++ b/i18n/uk/pages/servers/tools.md @@ -0,0 +1,177 @@ +--- +translation: + sections: [e4cc390d56573409, 8566e2b68594e9ad, 2c97b9f888398951, 048e5471dfa71aea, 3076b1e16ad95950, edbedf2a16e71311, 3d8ef8da89fa87c1, f6c0e02e6ea5a363] + tool: 1 +--- +# Інструменти {#tools} + +**Інструмент** — це функція, яку може викликати модель. + +Щоб оголосити інструмент, додайте `@mcp.tool()` до звичайної Python-функції. Оце й увесь API. + +## Ваш перший інструмент {#your-first-tool} + +```python title="server.py" hl_lines="6-8" +--8<-- "docs_src/tools/tutorial001.py" +``` + +Погляньте, що ви написали. Жодних схем, жодного JSON, жодного протоколу — просто функція. SDK зчитує з неї три речі: + +* **Ім'я** інструмента — це ім'я функції: `search_books`. +* **Опис**, який бачить модель, — це docstring: `Search the catalog by title or author.` +* **Аргументи**, які дозволено передавати моделі, беруться з анотацій типів: `query: str` і `limit: int`. + +### Вхідна схема {#the-input-schema} + +З цих анотацій типів SDK генерує JSON Schema і надсилає її клієнту під час `tools/list`: + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"title": "Limit", "type": "integer"} + }, + "required": ["query", "limit"], + "title": "search_booksArguments" +} +``` + +Обидва аргументи потрапили в `required`, бо жоден не має типового значення. За мить ви це виправите. (Ключі `title` — артефакти Pydantic; контракт складають властивості, їхні типи та `required`.) + +!!! tip + Анотації типів тут — не документація. Вони і є **контракт**. Якщо клієнт надішле `"limit": "ten"`, + SDK відхилить запит ще до того, як ваша функція запуститься. + +### Що отримує модель у відповідь {#what-the-model-gets-back} + +Викличте інструмент із `{"query": "dune", "limit": 5}` — і результат матиме дві частини: + +```python +result.content # [TextContent(text="Found 3 books matching 'dune' (showing up to 5).")] +result.structured_content # {'result': "Found 3 books matching 'dune' (showing up to 5)."} +``` + +`content` — це текст, який читає **модель**. `structured_content` — типізовані дані для **клієнтського застосунку**. Вони там, бо ви оголосили тип повернення як `-> str`. + +Поки що не переймайтеся `structured_content`. Повертайте з інструментів справжні Python-об'єкти — і все відбудеться правильно; цьому цілком присвячена сторінка **[Структурований вивід](structured-output.md)**. + +### Спробуйте самі {#try-it} + +Запустіть сервер через MCP Inspector: + +```console +uv run mcp dev server.py +``` + +Відкрийте URL, який він надрукує, перейдіть на вкладку **Tools** і викличте `search_books`. + +Inspector покаже форму з обов'язковим текстовим полем `query` та обов'язковим числовим полем `limit`. Цю форму він побудував із ваших анотацій типів. Так само зробить і будь-який інший MCP-клієнт. + +## Необов'язкові аргументи {#optional-arguments} + +Дайте параметру типове значення — і він перестане бути обов'язковим. От і все. Це звичайний Python. + +```python title="server.py" hl_lines="7" +--8<-- "docs_src/tools/tutorial002.py" +``` + +Схема змінюється відповідно: + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"default": 10, "title": "Limit", "type": "integer"} + }, + "required": ["query"], + "title": "search_booksArguments" +} +``` + +`limit` зник із `required` і отримав `"default": 10`. Клієнт, який його пропустить, отримає `10` — точнісінько як у Python. + +## Багатші схеми з `Field` {#richer-schemas-with-field} + +Анотації типів дають чимало, але іноді хочеться *описати* аргумент або обмежити його. + +Загорніть тип в `Annotated` і додайте `Field` із Pydantic: + +```python title="server.py" hl_lines="12-14" +--8<-- "docs_src/tools/tutorial003.py" +``` + +Три нові речі, усі — на параметрах: + +* `Field(description=...)`: опис окремого аргументу, який модель читає разом із docstring. +* `Field(ge=1, le=50)`: числові межі. У схемі вони стають `"minimum": 1, "maximum": 50`. +* `Literal["fiction", "non-fiction", "poetry"]`: перелік (enum). Модель може вибрати лише одне з цих значень. + +!!! check + Обмеження — не прикраса. Викличте інструмент із `limit=999` — і SDK відповість + помилкою інструмента **ще до запуску вашої функції**: + + ```text + Input should be less than or equal to 50 + ``` + + Ця помилка повертається моделі як результат інструмента; модель її читає і повторює виклик + із коректним значенням. Ви один раз написали `le=50` і задарма отримали агентів, що самі себе виправляють. + +!!! info + Якщо ви користувалися FastAPI чи Pydantic, то все це вже знаєте. Той самий `Field`, + той самий `Annotated`, та сама валідація. Нічого специфічного для MCP тут вчити не треба. + +## Модель як параметр {#a-model-as-a-parameter} + +Коли інструмент приймає більше ніж кілька аргументів, згрупуйте їх у Pydantic-модель: + +```python title="server.py" hl_lines="8-11 15" +--8<-- "docs_src/tools/tutorial004.py" +``` + +Схема `Book` вкладається у вхідну схему інструмента (як посилання `$defs`), модель заповнює її як JSON-об'єкт, а ваша функція отримує **справжній екземпляр `Book`**, уже провалідований, з атрибутами `.title`, `.author` і `.year`. + +Можна поєднувати як завгодно: звичайні параметри поруч із параметрами-моделями, вкладені моделі, списки моделей. Це Pydantic аж до самого низу. + +## `async def` {#async-def} + +Якщо інструмент виконує ввід-вивід (викликає API, читає файл, робить запит до бази даних), оголосіть його як `async def` і використовуйте `await` всередині. SDK його дочекається. + +Інструмент зі звичайним `def` теж працює: SDK запускає його в окремому потоці, тож він ніколи не блокує сервер. + +Більше нічого налаштовувати не потрібно. + +## Імена, заголовки й анотації {#names-titles-and-annotations} + +Усе, що SDK виводить сам, можна перевизначити в декораторі: + +```python title="server.py" hl_lines="7-10" +--8<-- "docs_src/tools/tutorial005.py" +``` + +* `title` — зрозуміла людині назва для інтерфейсів. Клієнти показують *«Search the catalog»* замість `search_books`. +* `annotations` — поведінкові **підказки** для клієнта: + * `read_only_hint=True`: цей інструмент нічого не змінює. + * `open_world_hint=False`: він працює із замкненою множиною речей (цим каталогом), а не з відкритим вебом. + * Дві інші, `destructive_hint` та `idempotent_hint`, описують інструмент, який *пише*: чи може він + щось видалити, і чи два виклики дають те саме, що й один? Специфікація визначає обидві + лише для інструментів, що не є read-only, тож на `search_books` вони нічого б не сказали. + +Чемний клієнт використовує їх, щоб вирішувати на кшталт *«чи треба спитати користувача, перш ніж це запускати?»*. Це підказки, а не механізм безпеки. Ніколи не покладайтеся на те, що клієнт їх дотримається. + +!!! tip + `@mcp.tool()` також приймає `name=` і `description=`, якщо ви не хочете виводити їх + з імені функції та docstring. Зазвичай хочете. + +## Підсумки {#recap} + +* `@mcp.tool()` на функції робить її інструментом. Ім'я — від функції, опис — із docstring. +* Анотації типів **і є** вхідною схемою. Типові значення роблять аргументи необов'язковими. +* `Annotated[..., Field(...)]` додає описи й обмеження; `Literal` додає переліки. +* Параметр — Pydantic-модель — це спосіб прийняти структуроване «тіло». +* Некоректні аргументи відхиляються за вас, із помилкою, яку модель може прочитати й після якої здатна відновитися. +* `async def` для вводу-виводу, звичайний `def` для всього іншого. + +Що відбувається зі значенням, яке ви повертаєте через `return`, — на сторінці **[Структурований вивід](structured-output.md)**. diff --git a/i18n/uk/pages/servers/uri-templates.md b/i18n/uk/pages/servers/uri-templates.md new file mode 100644 index 0000000000..258d230635 --- /dev/null +++ b/i18n/uk/pages/servers/uri-templates.md @@ -0,0 +1,283 @@ +--- +translation: + sections: [4a7033e1ed8ad602, 55dcbfff0c6271bf, 101ef9d14bf4ec46, 4b6c4a845438abc7, f98b46bafbee4acd] + tool: 1 +--- +# URI-шаблони та безпека шляхів {#uri-templates-and-path-safety} + +Це довідник із синтаксису URI-шаблонів, який приймає +[`@mcp.resource`](resources.md), і з політики безпеки шляхів, +яку SDK застосовує до видобутих значень. Щоб дізнатися, що таке +ресурси й коли їх використовувати, почніть зі сторінки +**[Ресурси](resources.md)**; ця сторінка передбачає, що ви вже впевнено оголошуєте +ресурси й хочете побачити повний набір операторів, параметри безпеки або +низькорівневу інтеграцію. + +Синтаксис шаблонів описано в [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570). +SDK підтримує підмножину, відібрану для зіставлення вхідних URI запитів +`resources/read`, плюс шар безпеки, який відхиляє значення, що вказували б +за межі каталогу, який ви збираєтеся обслуговувати. Подробиці +протокольного рівня (формати повідомлень, життєвий цикл, пагінація) +дивіться в +[специфікації ресурсів MCP](https://modelcontextprotocol.io/specification/latest/server/resources). + +## Повний набір операторів {#the-full-operator-set} + +Простий заповнювач `{user_id}` — це той, який представлено на сторінці **[Ресурси](resources.md)**. Є ще чотири +форми операторів; ось вони всі на одному сервері, щоб їх можна було +порівняти поруч: + +```python title="server.py" hl_lines="16-17 22-23 28-29 34-35 40-41" +--8<-- "docs_src/uri_templates/tutorial001.py" +``` + +Кожен виділений декоратор — це окремий спосіб розібрати URI на частини. +Розділи нижче розглядають їх згори донизу. + +### Проста підстановка: `{name}` {#simple-expansion-name} + +`books://{isbn}` — проста, повсякденна форма. Заповнювач відповідає +параметру `isbn`, тож клієнт, який читає `books://978-0441172719`, викликає +`get_book("978-0441172719")`. + +Простий `{name}` зупиняється на першому `/`. `books://978/extra` не +збігається, бо скісна риска після `978` завершує захоплення, а `/extra` +лишається зайвим. + +### Перетворення типів {#type-conversion} + +Видобуті значення надходять як рядки, але можна оголосити точніший +тип, і SDK перетворить значення. `orders://{order_id}` потрапляє у функцію +з параметром `order_id: int`, тож читання `orders://12345` викликає +`get_order(12345)`, а не `get_order("12345")`. Обробник виконує над ним +арифметику (`order_id + 1`) без приведення типу. + +### Багатосегментні шляхи: `{+name}` {#multi-segment-paths-name} + +Щоб захопити значення, що містить скісні риски, використовуйте `{+name}`. З +шаблоном `manuals://{+path}`: + +* `manuals://returns.md` дає `path = "returns.md"` +* `manuals://printing/setup.md` дає `path = "printing/setup.md"` + +Беріть `{+name}` щоразу, коли значення ієрархічне: шляхи файлової +системи, вкладені ключі об'єктів, URL-шляхи, які ви проксіюєте. + +### Параметри запиту: `{?a,b,c}` {#query-parameters-abc} + +`reviews://{isbn}{?limit,sort}` розміщує `limit` і `sort` після `?`. +Шлях визначає, *яку* книжку читати; рядок запиту уточнює, *як* саме. + +Параметри запиту зіставляються поблажливо: порядок не має значення, зайві +ігноруються, а пропущені беруться з типових значень функції. Тож +`reviews://978-0441172719` використовує `limit=10, sort="newest"`, а +`reviews://978-0441172719?sort=top` перевизначає лише `sort`. + +### Сегменти шляху як список: `{/name*}` {#path-segments-as-a-list-name} + +Якщо кожен сегмент шляху потрібен як окремий елемент списку, а не як один +рядок зі скісними рисками, використовуйте `{/name*}`. З шаблоном +`shelves://browse{/path*}` клієнт, який читає `shelves://browse/fiction/sci-fi`, +викликає `browse_shelf(["fiction", "sci-fi"])`. + +### Довідник із шаблонів {#template-reference} + +Найуживаніші шаблони: + +| Шаблон | Приклад вхідних даних | Результат | +|--------------|-----------------------|-------------------------| +| `{name}` | `alice` | `"alice"` | +| `{name}` | `docs/intro.md` | *немає збігу* (зупиняється на `/`) | +| `{+path}` | `docs/intro.md` | `"docs/intro.md"` | +| `{.ext}` | `.json` | `"json"` | +| `{/segment}` | `/v2` | `"v2"` | +| `{?key}` | `?key=value` | `"value"` | +| `{?a,b}` | `?a=1&b=2` | `"1"`, `"2"` | +| `{/path*}` | `/a/b/c` | `["a", "b", "c"]` | + +### Що відхиляє парсер {#what-the-parser-rejects} + +Деякі форми шаблонів виявляються наперед, а не збоять на першому +запиті. `@mcp.resource` розбирає шаблон під час виконання декоратора, +тож жодна з них ніколи не дістанеться сервера, що працює. + +`UriTemplate.parse()` викидає `InvalidUriTemplate` у таких випадках: + +* **Дві змінні без нічого між ними.** `manuals://{+path}{ext}` + відхиляється: під час зіставлення неможливо визначити, де закінчується + `path` і починається `ext`. Поставте між ними літерал + (`manuals://{+path}/{ext}`) або скористайтеся оператором, який сам + дає роздільник. `manuals://{+path}{.ext}` приймається, бо `{.ext}` + сам додає `.`. +* **Більш ніж одна багатосегментна змінна.** Щонайбільше одна з `{+var}`, + `{#var}` або змінна з explode-модифікатором (`{/var*}`, `{.var*}`, `{;var*}`) + на шаблон. Дві — за своєю природою неоднозначні: немає обґрунтованого + способу вирішити, яка з них поглине зайвий сегмент. +* **Звичайні синтаксичні помилки**: незакрита фігурна дужка, двічі + використане ім'я змінної або можливість RFC 6570, яку SDK не підтримує, + як-от модифікатор префікса `{var:3}` чи explode у параметрах запиту + `{?vars*}`. + +Крім того, `@mcp.resource` викидає `ValueError`, коли параметр обробника +прив'язаний до змінної запиту в кінцевій групі `{?...}`/`{&...}` шаблону, +але не має типового значення в Python. Ці змінні зіставляються поблажливо +(клієнт може пропустити будь-яку з них), тож параметр без типового +значення проявився б лише як незрозуміла внутрішня помилка на першому +запиті, що його пропускає. `reviews://{isbn}{?limit,sort}` на сервері +вище — правильно сформована версія: і `limit`, і `sort` мають типові +значення. + +## Безпека {#security} + +Параметри шаблону надходять від клієнта. Якщо вони без перевірки +потрапляють в операції з файловою системою чи базою даних, значення на +кшталт `../../etc/passwd` можуть вказати за межі каталогу, який ви +збиралися обслуговувати. + +### Що SDK перевіряє за замовчуванням {#what-the-sdk-checks-by-default} + +Перш ніж запуститься обробник, SDK відхиляє будь-який параметр, що: + +* виходив би за межі початкового каталогу через компоненти `..` +* схожий на абсолютний шлях (`/etc/passwd`, `C:\Windows`) або шлях + Windows відносно диска (`C:foo`). Значення відносно диска та + ідентифікатор із простором імен на кшталт `x:y` як рядки нерозрізненні, + тому будь-яке значення «одна літера плюс двокрапка» за замовчуванням + відхиляється; звільніть параметр від перевірки, якщо він закономірно + отримує такі значення +* містить нульовий байт (`\x00`) + +Перевірка на `..` працює з компонентами шляху, а не шукає підрядок. +Значення на кшталт `v1.0..v2.0` чи `HEAD~3..HEAD` проходять, бо `..` там +не є окремим сегментом шляху. + +Ці перевірки застосовуються до декодованого значення, тож вони ловлять +обхід каталогів незалежно від того, як його закодовано в URI (`../etc`, +`..%2Fetc`, `%2E%2E/etc`, `..%5Cetc`, `%00` — усе це виявляється). + +!!! check + Прочитайте `manuals://../etc/passwd` із сервера вище — і запит буде + відхилено одразу: зіставлення шаблонів зупиняється на першій невдачі, + тож жоден наступний (потенційно поблажливіший) шаблон не випробовується + як запасний. Клієнт бачить ту саму помилку `-32602` «Unknown resource», + що й для URI, який не збігається з жодним шаблоном, а `read_manual` + ніколи не запускається. + +### Обробники файлової системи: використовуйте safe_join {#filesystem-handlers-use-safe_join} + +Вбудовані перевірки зупиняють типові випадки, але не можуть знати меж +вашої пісочниці. Для доступу до файлової системи використовуйте +`safe_join`, щоб розв'язати шлях і впевнитися, що він лишається всередині +базового каталогу: + +```python title="server.py" hl_lines="4 14" +--8<-- "docs_src/uri_templates/tutorial002.py" +``` + +`safe_join` ловить виходи через символьні посилання, послідовності `..` +і трюки з абсолютними шляхами, які проста перевірка рядка пропустила б. +Якщо розв'язаний шлях виходить за межі `DOCS_ROOT`, функція викидає +`PathEscapeError`, який дістається клієнта як `ResourceError`. + +### Коли типова поведінка заважає {#when-the-defaults-get-in-the-way} + +Іноді перевірки блокують закономірні значення. Інструмент імпорту +каталогу може навмисно отримувати абсолютний шлях, або параметр може бути +відносним посиланням на кшталт `../sibling`, яке обробник безпечно +тлумачить, не торкаючись файлової системи. Звільніть такий параметр від +перевірки або послабте політику для всього сервера: + +```python title="server.py" hl_lines="9 16-19" +--8<-- "docs_src/uri_templates/tutorial003.py" +``` + +* `security=ResourceSecurity(exempt_params={"source"})` на декораторі + пропускає перевірки для цього одного параметра цього одного ресурсу. + Решта сервера зберігає типову політику. +* `resource_security=` у конструкторі `MCPServer` задає типову політику + для кожного ресурсу. Тут `relaxed` повністю вимикає перевірку на `..`. + +Налаштовувані перевірки: + +| Налаштування | За замовчуванням | Що робить | +|-------------------------|---------|-------------------------------------| +| `reject_path_traversal` | `True` | Відхиляє послідовності `..`, що виходять за межі початкового каталогу | +| `reject_absolute_paths` | `True` | Відхиляє `/foo`, `C:\foo`, UNC-шляхи та відносний щодо диска `C:foo` (також ловить `x:y`) | +| `reject_null_bytes` | `True` | Відхиляє значення, що містять `\x00` | +| `exempt_params` | порожньо | Імена параметрів, для яких перевірки пропускаються | + +Ці перевірки — евристичний попередній фільтр; для доступу до файлової +системи межею ізоляції лишається `safe_join`. + +!!! tip + Якщо обробник не може виконати запит (файл не існує, ідентифікатор + невідомий), викиньте виняток. SDK перетворить його на відповідь з + помилкою. Про різницю між помилкою протоколу та помилкою інструмента — + на сторінці **[Обробка помилок](handling-errors.md)**. + +## Ресурси на низькорівневому Server {#resources-on-the-low-level-server} + +Якщо ви будуєте на низькорівневому `Server` (див. **[Низькорівневий +Server](../advanced/low-level-server.md)**), обробники для протокольних методів +`resources/list` і `resources/read` реєструються напряму. Декоратора +немає; типи протоколу ви повертаєте самі. + +### Статичні ресурси {#static-resources} + +Для фіксованих URI тримайте реєстр і диспетчеризуйте за точним збігом: + +```python title="server.py" hl_lines="17 21 27" +--8<-- "docs_src/uri_templates/tutorial004.py" +``` + +Обробник списку повідомляє клієнтам, що доступно; обробник читання віддає +вміст. Спершу перевірте реєстр, далі перейдіть до шаблонів (нижче), якщо +вони є, а для всього іншого викидайте виняток. + +### Шаблони {#templates} + +Рушій шаблонів, який використовує `MCPServer`, міститься в +`mcp.shared.uri_template` і працює самостійно. Розбір і зіставлення ті +самі; маршрутизацію та політику безпеки ви під'єднуєте самі. + +```python title="server.py" hl_lines="13-16 22-25 29 33 45" +--8<-- "docs_src/uri_templates/tutorial005.py" +``` + +У виділених рядках відбуваються три речі: + +* **Розбір один раз, зіставлення на кожен запит.** `UriTemplate.parse()` + будує шаблон; `template.match(uri)` повертає видобуті змінні як `dict` + або `None`, якщо URI не підходить. Декодування URL відбувається всередині + `match()`; декодовані значення повертаються як є, без перевірки безпеки + шляхів. Значення виходять рядками: перетворюйте їх самі + (`int(matched["id"])`, `Path(matched["path"])`). +* **Застосовуйте перевірки безпеки самі.** Перевірки на `..` та абсолютні + шляхи, які `MCPServer` виконує за замовчуванням, містяться в + `mcp.shared.path_security`. `read_manual_safely` викликає їх, перш ніж + торкнутися `MANUALS`. Якщо параметр не є шляхом файлової системи (ISBN, + пошуковий запит), пропустіть перевірки для цього значення: політикою ви + керуєте в кожному обробнику окремо, а не через об'єкт конфігурації. +* **Список шаблонів із того самого джерела.** Клієнти дізнаються про + шаблони через `resources/templates/list`. `str(template)` повертає + початковий рядок шаблону, тож список і зіставлювач мають одне джерело + істини. + +## Підсумки {#recap} + +* `{name}` збігається з одним сегментом; `{+name}` зберігає скісні риски; + `{?a,b}` бере значення з рядка запиту; `{/name*}` розбиває сегменти на + список. +* Дві змінні без нічого між ними або друга багатосегментна змінна + відхиляються під час розбору. Параметр, прив'язаний до змінної запиту в + кінцевій групі `{?...}`/`{&...}`, мусить оголошувати типове значення в + Python. +* Анотуйте параметр (`order_id: int`) — і SDK перетворить значення. +* Типова політика безпеки відхиляє `..`, абсолютні шляхи та нульові + байти, перш ніж запуститься обробник; перевизначайте для окремого + ресурсу через `security=ResourceSecurity(...)` або для всього сервера + через `resource_security=`. +* Для доступу до файлової системи межею ізоляції є `safe_join`. +* На низькорівневому `Server` розбирайте через `UriTemplate.parse()`, + зіставляйте через `.match()` і застосовуйте `mcp.shared.path_security` + самі. diff --git a/i18n/uk/pages/translations.md b/i18n/uk/pages/translations.md new file mode 100644 index 0000000000..9d31cd3ff0 --- /dev/null +++ b/i18n/uk/pages/translations.md @@ -0,0 +1,30 @@ +--- +translation: + sections: [f671b445b16e4f99, 3983a560eb2cece7, 004b3ee918529d8c, c6e2debf1da06eb7, 81d412ed5f399f94] + tool: 1 +--- +# Переклади {#translations} + +Цю документацію написано англійською. Щоб вона стала в пригоді більшій кількості людей, ми також публікуємо її машинні переклади, і ця сторінка пояснює, що це означає для вас і як допомогти їх покращити. + +## Що доступно {#whats-available} + +Перекладена документація наразі є **попередньою версією** дванадцятьма мовами: Deutsch, español, français, हिन्दी, 日本語, 한국어, português (Brasil), русский язык, Türkçe, українська мова, 简体中文 і 繁體中文. Виберіть потрібну в перемикачі мов угорі будь-якої сторінки. Інші мови можуть з'явитися пізніше, коли ці себе виправдають. + +Довідник API не перекладено: перекладений сайт посилається на єдину англійську версію. + +## Англійська — джерело істини {#english-is-the-source-of-truth} + +Якщо перекладена сторінка та її англійський оригінал розходяться, правильною є англійська сторінка. Кожна сторінка перекладеного сайту починається з однієї з трьох приміток, що пояснюють її стан: + +- **Машинний переклад** — сторінку перекладено автоматично, і вона посилається на англійський оригінал. +- **Переклад відстає від англійської сторінки** — англійський оригінал змінився після того, як сторінку переклали, тож деякі її частини можуть бути неактуальними, доки переклад не наздожене. +- **Показано англійською** — актуального перекладу сторінки немає, тож ви читаєте англійський текст. + +## Як створюються переклади {#how-the-translations-are-made} + +Перекладені сторінки генерує машинно інструмент із цього репозиторію з англійських сторінок у `docs/`, керуючись двома написаними людьми вхідними файлами для кожної мови: настановами зі стилю (регістр, тон, типографіка, як поводитися з жартами та ідіомами) і глосарієм (які терміни лишаються англійською, а також обов'язкові й заборонені відповідники для решти). Згенерований текст ніколи не редагують вручну. Натомість кожне покращення вносять у ці вхідні файли, щоб воно збереглося під час наступної генерації сторінок. + +## Повідомлення про проблему з перекладом {#reporting-a-translation-problem} + +Знайшли неправильний термін, незграбне речення або переклад, який стверджує те, чого немає в англійській версії? [Створіть issue](https://github.com/modelcontextprotocol/python-sdk/issues), вказавши мову, сторінку та уривок; повідомлення від носіїв мови особливо цінні. Якщо знаєте, як виправити, запропонуйте це одразу як pull request до настанов зі стилю (`instructions.md`) або глосарія (`glossary.json`) відповідної мови в [`i18n/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/i18n) — тоді виправлення потрапить на кожну сторінку, якої воно стосується, під час наступної генерації перекладів. Проблеми з самим англійським текстом виправляють на сторінках у `docs/`, як і будь-яку іншу зміну в документації. diff --git a/i18n/uk/pages/troubleshooting.md b/i18n/uk/pages/troubleshooting.md new file mode 100644 index 0000000000..ce0584c1b0 --- /dev/null +++ b/i18n/uk/pages/troubleshooting.md @@ -0,0 +1,421 @@ +--- +translation: + sections: [2efaecdef109a5c5, fcacd3e66b8635a4, 25323d737dcf0261, 4835ed1772f1d113, 137454d469c867f5, 6392596bd6df54f0, 41126fa9c4fe432f, 480b6d7897e30ab4, d83bb682e708dde0, ebbed3449c499db4, 323ef84f6b4bebde, 30fd31be74169d9a, 656943c6cb567218, c2dc3b1007d2e987, 7cf5386b997d04e9, 0b59feed8384456e, 0cba47bae78d04eb, 954dc21efdb532a3] + tool: 1 +--- +# Усунення несправностей {#troubleshooting} + +Кожен заголовок на цій сторінці — це точний текст помилки, яку видає SDK; під ним — що вона означає і як її виправити одним рухом. Знайдіть тут останній рядок свого трасування (або лога сервера) пошуком на сторінці у браузері й читайте лише цей пункт. + +Кілька пунктів працюють із цим одним сервером. Один інструмент і один шаблонний ресурс, кожен викидає виняток для міста, якого не знає: + +```python title="server.py" +--8<-- "docs_src/troubleshooting/tutorial001.py" +``` + +Помилки, які цитує ця сторінка, справжні: власний набір тестів SDK відтворює кожну з них. + +## `ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)` {#exceptiongroup-unhandled-errors-in-a-taskgroup-1-sub-exception} + +Це не помилка MCP. Це шум від anyio, а справжня помилка — **останній рядок** виводу. + +`Client.__aenter__` запускає групу завдань. anyio загортає все, що виходить із групи завдань, в `ExceptionGroup`, тож *кожен* виняток, що залишає блок `async with Client(...)`, хай який він, приходить усередині групи: + +```python +async def main() -> None: + async with Client(mcp) as client: + await client.read_resource("weather://Atlantis") +``` + +```text + + Exception Group Traceback (most recent call last): + | ... + | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception) + +-+---------------- 1 ---------------- + | Exception Group Traceback (most recent call last): + | ... + | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception) + +-+---------------- 1 ---------------- + | Traceback (most recent call last): + | ... + | mcp.shared.exceptions.MCPError: No forecast for 'Atlantis'. + +------------------------------------ +``` + +Із цим варто зробити дві речі: + +1. **Читайте знизу.** `MCPError: No forecast for 'Atlantis'.` — це і є збій; шукайте на цій сторінці *його* текст. +2. **Перехоплюйте всередині блоку.** `ExceptionGroup` з'являється лише тоді, коли виняток *виходить* за межі `async with`. Перехоплений усередині, той самий збій — це звичайний `MCPError`, без жодної групи: + +```python +async def main() -> None: + async with Client(mcp) as client: + try: + await client.read_resource("weather://Atlantis") + except MCPError as e: + print(e) # No forecast for 'Atlantis'. +``` + +!!! tip + Збій під час *з'єднання* (неправильний URL, сервер, який не запущено, `421` нижче + на цій сторінці) виходить із самого `async with`, тож «всередині», де його можна було б + перехопити, немає. У таких випадках читайте низ групи. + +## `RuntimeError: Client must be used within an async context manager` {#runtimeerror-client-must-be-used-within-an-async-context-manager} + +`Client(...)` лише створює об'єкт. До `async with` нічого не під'єднується, тому кожен метод відмовляє: + +```python +async def main() -> None: + client = Client(mcp) + tools = await client.list_tools() # RuntimeError +``` + +Увійдіть у нього. `__aenter__` — це і є з'єднання: + +```python +async def main() -> None: + async with Client(mcp) as client: + tools = await client.list_tools() +``` + +`__aexit__` — це від'єднання, тому й немає `client.close()`, про який можна забути. Сторінка **[Тестування](get-started/testing.md)** побудована саме на цьому шаблоні. + +## `Error executing tool : ` і `Unknown tool: ` {#error-executing-tool-name-message-and-unknown-tool-name} + +Перед вами **результат**, а не виняток. `call_tool` нічого не викинув і ніколи не викине для інструмента, що завершився збоєм. + +Викличте `forecast` для міста, якого сервер не знає, — і виняток, який він викидає, повертається із запитом, позначеним як *успішний*: + +```python +result.is_error # True +result.content # [TextContent(text="Error executing tool forecast: No forecast for 'Atlantis'.")] +result.structured_content # None +``` + +`Unknown tool: get_forecast` має ту саму форму для імені, яке сервер ніколи не реєстрував, а неправильний аргумент відхиляється так само — за вхідною схемою інструмента, ще до того, як ваша функція запуститься. + +Виправлення — на боці клієнта: **перевіряйте `result.is_error`**. `try/except` навколо `call_tool` не перехопить жодного з цих випадків, бо перехоплювати нічого. Це зроблено навмисно, і це найкорисніше, що варто засвоїти з цієї сторінки: виклик обрала *модель*, тож саме модель отримує повідомлення й шанс спробувати знову. Докладніше — на сторінці **[Обробка помилок](servers/handling-errors.md)**, зокрема про шлях через `MCPError`, який *таки* викидає виняток. + +## `TypeError: The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool` {#typeerror-the-tool-decorator-was-used-incorrectly-did-you-forget-to-call-it-use-tool-instead-of-tool} + +Ви написали `@mcp.tool` замість `@mcp.tool()`. `tool()` — це *фабрика* декораторів: без дужок Python передає вашу функцію в її параметр `name=`. + +```python +@mcp.tool # <- missing () +def forecast(city: str) -> str: + """Today's forecast for one city.""" + return f"{city}: Rain." +``` + +```text +TypeError: The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool +``` + +Додайте дужки. `@mcp.resource(...)` і `@mcp.prompt()` кажуть те саме про ту саму описку. + +!!! note + Цей виняток викидається під час **імпорту** модуля, до того як під'єднається будь-який + клієнт. Тож хост, який показує ваш сервер як такий, що *не запустився* (або *від'єднався*), + а не як під'єднаний із нулем інструментів, має саме цю форму: запустіть `python server.py` + самі й прочитайте трасування. Перевірка типів теж це ловить: функція — не дійсне значення + для `name=`. + +## `Tool already exists: ` {#tool-already-exists-name} + +Дві реєстрації використали те саме ім'я інструмента. Перемагає **перша**, другу мовчки відкидають, і це попередження в *лозі сервера* — єдиний сигнал: + +```python title="server.py" hl_lines="6 12" +--8<-- "docs_src/troubleshooting/tutorial002.py" +``` + +```text +WARNING mcp.server.mcpserver.tools.tool_manager: Tool already exists: forecast +``` + +`tools/list` повідомляє про один `forecast`, і це `forecast_today`. Перейменуйте один із них. `MCPServer(..., warn_on_duplicate_tools=False)` глушить попередження, не змінюючи результату, тож залишайте його ввімкненим. Для ресурсів і промптів діє те саме правило й той самий рядок у лозі (`Resource already exists:`, `Prompt already exists:`). + +## Мій хост показує нуль інструментів {#my-host-lists-zero-tools} + +Для цього немає рядка помилки, і саме тому це важко знайти пошуком. SDK ніколи не викидає зареєстрований інструмент із `tools/list`, тож рухайтеся від центру назовні: + +* **Чи взагалі запустився сервер?** `@mcp.tool` без дужок викидає виняток під час імпорту, а сервер, що впав, у деяких хостах дуже схожий на порожній. Запустіть `python server.py` самі. +* **Чи інструмент на тому `mcp`, який запускає хост?** Другий `MCPServer(...)` в іншому модулі — це інший, порожній сервер. Перевірте, який об'єкт насправді імпортує команда хоста. +* **Чи не мають два інструменти одне ім'я?** Тоді одного з них немає. Шукайте `Tool already exists:` у лозі сервера. +* **Чи не застарів список у хоста?** Інструмент, доданий після запуску, доходить лише до клієнтів, які обробляють `notifications/tools/list_changed`. Перезапуск хоста — грубе, але дієве рішення. +* **Чи не записало щось у `stdout` поза вікном перенаправлення?** Під час обслуговування SDK перенаправляє *скинутий* (flushed) сторонній stdout у stderr (наскільки можливо: середовище, яке підміняє стандартні потоки, обслуговується як є), але вивід, скинутий у stdout раніше (скрипт-обгортка, що щось виводить, `print()` під час імпорту в небуферизованому процесі), або буферизований `print()`, злитий під час завершення інтерпретатора, потрапляє в потік протоколу, і одного сміттєвого рядка досить, щоб хост розірвав з'єднання, — а деякі хости показують це як сервер, у якому нічого немає. Натомість пишіть лог через модуль `logging`. Решта контрольного списку на боці хоста — на сторінці **[Під'єднання до справжнього хоста](get-started/real-host.md)**. + +«Недійсного» імені інструмента в цьому списку *немає*: невідповідне ім'я записує попередження в лог, але інструмент однаково реєструється й потрапляє до списку. + +## `MCPError: Server returned an error response` {#mcperror-server-returned-an-error-response} + +Сервер одразу відхилив HTTP-запит із тілом, яке не є JSON-RPC, тож python `Client` не має нічого кращого, ніж показати цю заглушку. + +Найпоширеніша причина з великим відривом — щойно розгорнутий сервер Streamable HTTP. `streamable_http_app()` (і `mcp.run("streamable-http")`) без `transport_security=` за замовчуванням вмикає **захист від DNS-rebinding**: приймаються лише запити, у яких заголовок `Host` — localhost. Це правильне типове значення на вашому ноутбуці й неправильне за справжнім іменем хоста: + +```python title="server.py" hl_lines="12" +--8<-- "docs_src/troubleshooting/tutorial003.py" +``` + +Розгорніть це, спрямуйте на нього клієнт — і з'єднання падає на рукостисканні: + +```python +async with Client("https://mcp.example.com/mcp") as client: + ... +``` + +```text +mcp.shared.exceptions.MCPError: Server returned an error response +``` + +Слова, які сервер насправді надіслав, — `421` і `Invalid Host header` — до вас не доходять: тіло відповіді 421 не має `Content-Type: application/json`, тому клієнт не може його розібрати. Вони є в **лозі сервера**, і саме туди варто дивитися далі: + +```text +WARNING mcp.server.transport_security: Invalid Host header: mcp.example.com +``` + +Виправлення — `transport_security=`. Додайте до списку дозволених ім'я хоста, яке ви справді обслуговуєте: + +```python title="server.py" hl_lines="14-17" +--8<-- "docs_src/troubleshooting/tutorial004.py" +``` + +!!! check + Оце й уся зміна. Той самий клієнт тепер під'єднується, узгоджує `2026-07-28` і + викликає `forecast`. + +На сторінці **[Розгортання й масштабування](run/deploy.md)** описано, що означає кожне поле, випадок зі зворотним проксі та все інше, що змінюється під час розгортання. А `421 Misdirected Request` / `Invalid Host header`, одразу нижче, — це той самий збій, побачений з іншого боку. + +## `421 Misdirected Request` / `Invalid Host header` {#421-misdirected-request-invalid-host-header} + +Це `Server returned an error response`, побачене з будь-чого, що *не* є python `Client`: curl, вкладки мережі у браузері, журналу доступу зворотного проксі чи іншого SDK. + +```bash +curl -i https://mcp.example.com/mcp \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}' +``` + +```text +HTTP/1.1 421 Misdirected Request + +Invalid Host header +``` + +`421 Misdirected Request` — власна фраза-пояснення HTTP для цього статусу; `Invalid Host header` — тіло відповіді SDK; а python `Client` показує ту саму подію як `Server returned an error response`. Усі три — одна відмова. Перевірка виконується за **заголовком `Host`, який несе запит**, а не за адресою, до якої прив'язано сервер, тому зворотний проксі, що пересилає публічне ім'я хоста, спрацьовує на ній так само, як і прямий клієнт. + +Виправлення те саме — `transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])`, показане під `Server returned an error response`. Два його нюанси варто назвати: + +* Запис в `allowed_hosts` — це точний рядок. `"mcp.example.com"` відповідає заголовку `Host` без порту, а `"mcp.example.com:*"` — будь-якому явному порту. Вкажіть обидва. +* `403` із тілом `Invalid Origin header` — це споріднена перевірка заголовка `Origin`. Вона спрацьовує лише для браузерів (ніщо інше не надсилає `Origin`), а `allowed_origins=` — її список дозволених. + +Докладніше — на сторінці **[Розгортання й масштабування](run/deploy.md)**, зокрема про те, коли вимкнути перевірку — чесна конфігурація. + +## `RuntimeError: Task group is not initialized. Make sure to use run().` {#runtimeerror-task-group-is-not-initialized-make-sure-to-use-run} + +Ваш MCP-застосунок змонтовано всередині іншого ASGI-застосунку, і ніщо не запустило його **менеджер сесій**. + +`mcp.streamable_http_app()` повертає застосунок Starlette, чий власний життєвий цикл (lifespan) запускає менеджер, а `uvicorn server:app` виконує цей життєвий цикл за вас. Але Starlette **ніколи не виконує життєвий цикл змонтованого підзастосунку**, тож щойно застосунок опиняється всередині `Mount`, менеджер не запускається, і перший же запит вибухає: + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/troubleshooting/tutorial005.py" +``` + +Сервер запускається. Маршрут розв'язується. А потім `uvicorn` друкує це на кожен запит: + +```text +ERROR: Exception in ASGI application +Traceback (most recent call last): + ... +RuntimeError: Task group is not initialized. Make sure to use run(). +``` + +Клієнт бачить 500. Виправлення — життєвий цикл на **хост**-застосунку, який входить у `mcp.session_manager.run()`: + +```python +@asynccontextmanager +async def lifespan(app: Starlette) -> AsyncIterator[None]: + async with mcp.session_manager.run(): + yield + + +app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lifespan) +``` + +Про це — сторінка **[Додавання до наявного застосунку](run/asgi.md)**, зокрема про кілька серверів в одному застосунку та FastAPI. Два сусідні рядки з того самого класу: + +* `StreamableHTTPSessionManager .run() can only be called once per instance. Create a new instance if you need to run again.` Менеджер одноразовий; подвійний вхід у життєвий цикл того самого застосунку натрапляє на це. +* `mcp.session_manager` існує лише **після** виклику `streamable_http_app()`, тож спершу побудуйте маршрути й торкайтеся менеджера лише всередині життєвого циклу. + +## `MCPError: Session not found` {#mcperror-session-not-found} + +Сервер не впізнає `Mcp-Session-Id`, який надіслав ваш клієнт, майже завжди тому, що сервер **перезапустився** (або вас спрямували на інший екземпляр). Сесії живуть у пам'яті того одного процесу. + +Помилки в сервері тут немає. HTTP-відповідь — це `404`, тіло якого *є* JSON-RPC, тож, на відміну від `421` вище, python `Client` показує це повідомлення дослівно: + +```json +{"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "Session not found"}} +``` + +Виправлення — перепід'єднатися: вийдіть із блоку `async with Client(...)` і ввійдіть у новий, який узгодить свіжу сесію. Для довгоживучого клієнта це означає перехоплювати `MCPError` навколо викликів і перепід'єднуватися на це повідомлення, а не повторювати спроби всередині мертвої сесії. + +Якщо це трапляється *без* перезапуску, у вас працює більше одного робочого процесу без липких сесій: кожен робочий процес тримає власну таблицю сесій, тож запит, спрямований не на той, опиняється тут. Про це та про два виправлення (липка маршрутизація або `stateless_http=True`) — сторінки **[Розгортання й масштабування](run/deploy.md)** і **[Обслуговування клієнтів старого покоління](run/legacy-clients.md)**. + +Для оператора сервера відповідний рядок у лозі — `Rejected request with unknown or expired session ID: `. Він пишеться на рівні `INFO`, тож за звичного порога `WARNING` його не видно. Бачити його сплесками одразу після розгортання — нормально: кожен під'єднаний клієнт перепід'єднується. + +## `MCPError: Method not found` {#mcperror-method-not-found} + +Одна сторона надіслала JSON-RPC-запит, для якого інша не має обробника, а `e.error.data` називає метод. Звична причина — **невідповідність поколінь**: метод, що існує в одній ревізії протоколу й відсутній в іншій, надісланий співрозмовнику не того покоління, — наприклад, `resources/subscribe` покоління `2025`, що приходить на з'єднання `2026-07-28`, або `subscriptions/listen`, який є лише у `2026`, надісланий клієнтом, закріпленим на `mode="legacy"`. Сторінка **[Версії протоколу](protocol-versions.md)** — це мапа того, яка сторона що розуміє, а інша чесна причина (необов'язкова можливість, для якої ви так і не зареєстрували обробник) — на сторінці **[Автодоповнення](servers/completions.md)**. + +Одна річ цієї помилки **не** спричиняє, хоч і є запитом, який сучасний протокол вилучив: інструмент, що викликає `ctx.elicit()` на з'єднанні `2026-07-28`. Сервер узагалі відмовляється *надсилати* цей запит, тож натомість ви отримуєте `Cannot send 'elicitation/create': ...`, описане нижче на цій сторінці. + +## `MCPError: Client did not declare the form elicitation capability required by resolver ''` {#mcperror-client-did-not-declare-the-form-elicitation-capability-required-by-resolver-name} + +Ваш сервер хоче про щось запитати користувача, а цей клієнт ніколи не казав, що його можна питати. + +Резолвер еліцитації (elicitation) відмовляє одразу, якщо під'єднаний клієнт не оголосив еліцитацію через форму, а `e.error.data` називає, чого саме бракує: + +```json +{ + "code": -32021, + "message": "Client did not declare the form elicitation capability required by resolver 'server:ask_to_confirm'", + "data": {"requiredCapabilities": {"elicitation": {"form": {}}}} +} +``` + +Передайте `elicitation_callback=` у `Client(...)`. Реєстрація колбека — це і *є* оголошення можливості; другого перемикача немає: + +```python +async def main() -> None: + async with Client(mcp, elicitation_callback=handle_elicitation) as client: + result = await client.call_tool("book_table", {"date": "Friday"}) +``` + +На сторінці **[Колбеки клієнта](client/callbacks.md)** перелічено інші (`sampling_callback`, `list_roots_callback`), кожен із яких так само є оголошенням. + +!!! info + `-32021` — це `MISSING_REQUIRED_CLIENT_CAPABILITY`, один із трьох кодів помилок, які додає + специфікація 2026-07-28. Жоден із них не є класом винятку: усі приходять як `MCPError`, а + дивитися треба в `e.error.code`. Константи експортує `mcp.types`. Інші два — + `-32020` `HEADER_MISMATCH` (HTTP-заголовок суперечить тілу запиту, який він супроводжує) + і `-32022` `UNSUPPORTED_PROTOCOL_VERSION` (запит назвав версію, якою цей сервер не + говорить). Клієнт SDK, що відповідає специфікації, не може видати жодного з них, тож якщо ви + такий бачите, шукайте те, що переписує запити між вашим клієнтом і сервером. + +## `MCPError: Elicitation not supported` {#mcperror-elicitation-not-supported} + +Та сама прогалина, що й `Client did not declare the form elicitation capability ...`, тільки у формулюванні шляхів, які не перевіряють заздалегідь: серверу потрібна була відповідь на еліцитацію, а під'єднаний клієнт не зареєстрував `elicitation_callback`. + +Це повідомлення приходить від `ctx.elicit()` на з'єднанні старого покоління, а на будь-якому з'єднанні взагалі — від повернутого багатораундового (multi-round-trip) запитання (**[Багатораундові запити](handlers/multi-round-trip.md)**), що доходить до клієнта без колбека, який міг би відповісти. Виправлення ідентичне: передайте `elicitation_callback=` у `Client(...)`. Не існує варіанта «користувача не запитали», який ваш інструмент отримав би як `decline`; клієнт, якого не можна запитати, — це невдалий виклик, тож проєктуйте інструменти з огляду на це. + +## `MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests.` {#mcperror-cannot-send-elicitationcreate-this-transport-context-has-no-back-channel-for-server-initiated-requests} + +Ваш обробник спробував звернутися до клієнта посеред запиту на з'єднанні, де виклик не має каналу, здатного нести запит від сервера. Є три конфігурації сервера, за яких виклик опиняється в такому становищі. + +**З'єднання `2026-07-28`: будь-який транспорт, завжди.** Сучасний протокол узагалі не має запитів, ініційованих сервером, тож сервер відмовляє ще до того, як щось надіслано. `ctx.elicit()` усередині інструмента — класичний спосіб на це натрапити (у найпершому ж тесті в пам'яті, бо `Client(server)` узгоджує `2026-07-28`, навіть якщо його про це не просили), і передавання `elicitation_callback=` нічого не змінює, бо до клієнта ніколи не доходить запит, на який він міг би відповісти: + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/troubleshooting/tutorial006.py" +``` + +```python +async def main() -> None: + async with Client(mcp) as client: + await client.call_tool("book_table", {"date": "Friday"}) +``` + +```text +mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests. +``` + +**З'єднання старого покоління на сервері зі `stateless_http=True`.** Відсутність стану означає, що кожен запит — окремий світ: ні сесії, ні потоку від сервера до клієнта, а отже, нікуди надсилати `elicitation/create` (чи `sampling/createMessage`, чи `roots/list`) навіть для покоління, яке їх має: + +```python title="server.py" hl_lines="16 23" +--8<-- "docs_src/troubleshooting/tutorial008.py" +``` + +**З'єднання старого покоління на сервері з `json_response=True`.** На `POST` відповідають одним JSON-тілом, а одне тіло несе лише відповідь, тож потоку в межах запиту, потрібного `ctx.elicit()` посеред запиту, тут теж немає. Сесія, її `Mcp-Session-Id` і її окремий потік — усе на місці; зник лише канал у межах запиту. + +Повідомлення називає метод, який не вдалося надіслати. `NoBackChannelError` — клас, який викидає сервер, але мережею передається лише базовий `MCPError`, тож останній рядок вашого трасування — це речення вище, а не ім'я класу. + +Для клієнта `2026-07-28` виправлення однакове в усіх трьох випадках: не звертайтеся назад посеред виклику. Перенесіть запитання в **резолвер** (або поверніть `InputRequiredResult` самі) — і воно стає частиною *відповіді*, яку здатне нести будь-яке з'єднання: + +```python title="server.py" hl_lines="15-17 21" +--8<-- "docs_src/troubleshooting/tutorial007.py" +``` + +Те саме запитання, той самий `elicitation_callback` на клієнті. Різниця — усередині: резолвер дає серверу *повернути* запитання з виклику замість проштовхувати його, тож від сервера до клієнта ніколи нічого не тече. Це рятує кожного клієнта `2026-07-28`, у якій би з трьох конфігурацій не був сервер. Клієнта *старого покоління* саме лише переписування не рятує: `2025-11-25` не має способу повернути запитання, тож на з'єднанні старого покоління резолвер і далі надсилає `elicitation/create` каналом у межах запиту й далі потребує сервера, який його зберігає, — тобто без `stateless_http=True` і без `json_response=True`. Про резолвери — сторінка **[Еліцитація](handlers/elicitation.md)**; про те, що відбувається в переданих даних, — **[Багатораундові запити](handlers/multi-round-trip.md)**. + +!!! check + Інструмент із `ctx.elicit()` не помилковий, він *до-2026*. Під'єднайтеся з `mode="legacy"` + (класичне рукостискання `initialize`, специфікація `2025-11-25` і раніші) до сервера, який не + має ні `stateless_http=True`, ні `json_response=True`, — і він працює, бо там канал від + сервера до клієнта існує. + Про те, що є в кожній версії, — сторінка **[Версії протоколу](protocol-versions.md)**. + +## `MCPError: Invalid or expired requestState` {#mcperror-invalid-or-expired-requeststate} + +Сервер не зміг перевірити токен `requestState`, який ваш клієнт повернув у відповідь, тому відхилив раунд. + +`requestState` — непрозорий токен відновлення, який **[багатораундовий](handlers/multi-round-trip.md)** виклик несе між етапами. `MCPServer` запечатує його на виході й перевіряє кожне повернення, і перевіряє *кожен* вхідний `request_state` у `tools/call`, `prompts/get` і `resources/read`, навіть для обробника, який сам ніколи його не випускає. Тож токен, який цей процес не запечатував, відхиляється, хай куди він потрапить: + +```python +async def main() -> None: + async with Client(mcp) as client: + await client.call_tool("forecast", {"city": "London"}, request_state="round-1-from-worker-a") +``` + +```text +mcp.shared.exceptions.MCPError: Invalid or expired requestState +``` + +Повідомлення навмисно незмінне: передані дані ніколи не розкривають, яка саме перевірка не пройшла. Причина йде в **лог сервера**, і прочитати його — оце й уся діагностика: + +```text +WARNING mcp.server.request_state: requestState rejected on tools/call: malformed +``` + +Причини, які ви справді побачите: + +* **`unknown key`** — та, що має значення. Типовий ключ запечатування генерується під час запуску процесу, тож повторна спроба, що потрапляє на **інший робочий процес**, інший екземпляр за балансувальником навантаження чи на той самий сервер **після перезапуску**, була запечатана ключем, якого цей процес ніколи не мав. Це не зловмисник; це типове значення, що зіткнулося з більш ніж одним процесом. +* **`audience`**: токен запечатав екземпляр з *іншим іменем сервера*. Ім'я — типове значення audience у печатці, тож флот має мати спільне ім'я (або явний `RequestStateSecurity(audience=...)`), а не лише спільні ключі. +* **`expired`**: раунд тривав довше за `ttl` печатки, а це 600 секунд, причому на раунд, а не на виклик. +* **`malformed`** / **`codec error`**: токен змінили під час передавання, або він узагалі ніколи не був запечатаним токеном. +* **`request binding`**: токен повернувся з іншим інструментом, іншими аргументами чи іншим методом. + +Виправлення для кількох процесів — один аргумент (*ті самі* `keys` на кожному екземплярі) плюс одна річ, яка взагалі не є аргументом: те саме *ім'я* сервера (або явний спільний `audience=`). + +```python +mcp = MCPServer("Weather", request_state_security=RequestStateSecurity(keys=[key])) +``` + +`keys[0]` запечатує; перевіряє кожен ключ зі списку — саме це робить можливою ротацію без простою. На сторінці **[Багатораундові запити](handlers/multi-round-trip.md#protecting-requeststate)** пояснено, що захищає печатка, і послідовність ротації, а **[Розгортання й масштабування](run/deploy.md)** розбирає весь збій із двома робочими процесами та його виправлення з двох частин. + +!!! tip + `keys=[...]` одразу відхиляє слабкий ключ, із напрочуд корисним повідомленням: + + ```text + ValueError: request-state keys must be at least 32 bytes of secret randomness; keys[0] is 7 bytes. Generate one with: python -c "import secrets; print(secrets.token_hex(32))" + ``` + + Зробіть, як воно каже. + +## Досі не виходить? {#still-stuck} + +* Якщо повідомлення, яке видав SDK, немає на цій сторінці, це помилка в документації, про яку варто повідомити окремо. +* Пошукайте в [трекері задач](https://github.com/modelcontextprotocol/python-sdk/issues); більшість рядків помилок, що там трапляються, хтось уже описав. +* Нічого не знайшли? [Відкрийте issue](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml) з повним трасуванням або запитайте в [#python-sdk-dev на Discord-сервері MCP Contributors](https://discord.gg/6CSzBmMkjX). + +## Підсумки {#recap} + +* `ExceptionGroup: unhandled errors in a TaskGroup` ніколи не є самою помилкою. Читайте **останній рядок**; перехоплення `MCPError` *всередині* блоку `async with Client(...)` повністю оминає обгортання. +* `call_tool` не викидає виняток для інструмента, що завершився збоєм. `Error executing tool ...` і `Unknown tool: ...` — це результати: перевіряйте `result.is_error`. +* `Client must be used within an async context manager` -> використовуйте `async with`. `Use @tool() instead of @tool` -> додайте дужки. +* `Tool already exists:` у лозі сервера — єдина ознака того, що два однойменні інструменти злилися в один. +* Один 421, три написання: `Server returned an error response` (python `Client`), `421 Misdirected Request` / `Invalid Host header` (усе інше), `Invalid Host header: ` (лог сервера). Виправлення: `transport_security=TransportSecuritySettings(allowed_hosts=[...])`. +* `Task group is not initialized` -> змонтований застосунок, чий хост-застосунок у своєму життєвому циклі так і не ввійшов у `mcp.session_manager.run()`. +* `Session not found` -> сервер перезапустився; перепід'єднайтеся. +* `Cannot send 'elicitation/create': ... no back-channel ...` -> `ctx.elicit()` потребує каналу від сервера до клієнта: з'єднання `2026-07-28` його ніколи не має, `stateless_http=True` забирає канал старого покоління, а `json_response=True` — канал у межах запиту. Використовуйте резолвер (клієнту старого покоління також потрібен сервер, що зберігає канал). Сусідня помилка `Method not found` — це запит методу, якого немає в ревізії протоколу іншої сторони. +* `Client did not declare the form elicitation capability ...` і `Elicitation not supported` -> клієнту бракує `elicitation_callback=`. +* `Invalid or expired requestState` ніколи не пояснює причину в переданих даних. Лог сервера — пояснює; `unknown key` означає, що треба зробити `RequestStateSecurity(keys=[...])` спільним для всіх робочих процесів. diff --git a/i18n/uk/pages/whats-new.md b/i18n/uk/pages/whats-new.md new file mode 100644 index 0000000000..d501c066ee --- /dev/null +++ b/i18n/uk/pages/whats-new.md @@ -0,0 +1,214 @@ +--- +translation: + sections: [cfe01c0c5863dfa2, 11d93f1fa09eadf5, a7392996acf1ad8f, 875eb2889263424e] + tool: 1 +--- +# Що нового у v2 {#whats-new-in-v2} + +У v2 відбулися одразу дві речі. **SDK перебудовано**: новий рушій під клієнтом і під сервером, повноцінний `Client` і низка перейменувань, на які кодова база v1 натрапляє з першим же імпортом. І **протокол змінився**: v2 говорить редакцією MCP 2026-07-28, яка прибирає рукостискання з'єднання, сесію та всі запити, ініційовані сервером, не кидаючи напризволяще клієнтів, які у вас уже є. + +Ця сторінка — огляд обох половин: по одному розділу на кожну головну новину, і кожен закінчується посиланням на сторінку, якій належить тема. Це не посібник із перенесення. Ним є **[Посібник з міграції](migration.md)**: кожна несумісна зміна, з кодом до і після. + +!!! note "v2 — стабільна гілка" + `pip install mcp` встановлює 2.x, а на сторінці **[Встановлення](get-started/installation.md)** є + рядок встановлення, який можна просто скопіювати. Якщо щось у v2 ламається, дивує чи гальмує роботу, + [повідомте нам](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml). + +## SDK: від v1 до v2 {#the-sdk-v1-to-v2} + +### `FastMCP` тепер `MCPServer` {#fastmcp-is-now-mcpserver} + +Високорівневий клас сервера перейменовано, а разом із ним і його модуль. Це перше, на що натрапляє кожен сервер v1, бо старий шлях імпорту вилучено, а не оголошено застарілим: + +```python +from mcp.server import MCPServer # v1: from mcp.server.fastmcp import FastMCP + +mcp = MCPServer("Demo") # v1: FastMCP("Demo") +``` + +Для сервера, побудованого на декораторах, це водночас і більша частина перенесення. `@mcp.tool()`, `@mcp.resource()` і `@mcp.prompt()` приймають те саме, що й у v1 (`@mcp.resource()` додає один необов'язковий іменований аргумент `security=`), а вхідна схема, як і раніше, будується з анотацій типів. По краях: усе з `mcp.server.fastmcp.*` тепер живе в `mcp.server.mcpserver.*`, `ctx.fastmcp` став `ctx.mcp_server`, `get_context()` вилучено (натомість оголосіть параметр `ctx: Context`), а базовий клас винятків `FastMCPError` тепер `MCPServerError`. Таблиця імпортів — у **[Посібнику з міграції](migration.md#fastmcp-renamed-to-mcpserver)**. + +### `Resolve`: новий спосіб запитати щось у користувача {#resolve-the-new-way-to-ask-the-user-for-input} + +Не все, що потрібно інструменту, має надходити від моделі. Нове у v2: параметр інструмента, анотований `Resolve(fn)`, натомість заповнює функція, яку ви пишете самі, непомітно для моделі, і ця функція може повернути `Elicit(...)`, щоб поставити запитання користувачу. Це бажаний спосіб отримати будь-що від клієнта посеред виклику: SDK передає запитання тим механізмом, який підтримує з'єднання (живий запит еліцитації (elicitation) для клієнта старого покоління, багатораундовий обмін (multi-round-trip) на 2026-07-28), тож одне тіло інструмента обслуговує обидва покоління. Докладніше — на сторінці **[Залежності](handlers/dependencies.md)**. + +!!! note + Дві інші форми залишаються на випадок, коли вони потрібні: `ctx.elicit()` і далі працює для клієнтів на + з'єднаннях старого покоління (**[Еліцитація](handlers/elicitation.md)**), а обробник може сам повернути + `InputRequiredResult` і керувати раундами вручну — саме так на 2026-07-28 передаються також запити + семплювання (sampling) і кореневих каталогів (roots) (**[Багатораундові запити](handlers/multi-round-trip.md)**). + +### Повноцінний `Client` {#a-first-class-client} + +v1 давав три вкладені шари: контекстний менеджер транспорту, що видає сирі потоки, обгорнуту навколо них `ClientSession` і викликаний вручну `await session.initialize()`. У v2 є один об'єкт: + +```python title="client.py" hl_lines="14-18" +--8<-- "docs_src/client/tutorial001.py" +``` + +`Client` приймає об'єкт сервера (у пам'яті, без транспорту: це сценарій тестування), URL (Streamable HTTP) або будь-який контекстний менеджер транспорту, як-от `stdio_client(...)`. Вхід в `async with` під'єднує та узгоджує версію протоколу, хай яким поколінням говорить сервер; після цього `client.server_capabilities` і `client.protocol_version` просто є, як і `client.server_info`, коли сервер себе ідентифікує (тепер це `Implementation | None`, бо ідентичність у поколінні 2026 необов'язкова). Колбеки семплювання й еліцитації, зареєстровані у v1, і далі працюють (їхні тіла зазнають того самого перейменування атрибутів у snake_case, що й усе інше на цій сторінці), тепер вони також відповідають на запити всередині результатів у стилі 2026 (нижче) і виконуються паралельно, а не по одному. `ClientSession` досі лежить під сподом для тих, кому потрібна низькорівнева поверхня, і `client.session` її віддає; вона теж змінилася (працює на новому рушії диспетчера, і деякі її власні сигнатури змінилися), тож прочитайте **[Посібник з міграції](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)**, перш ніж спускатися нижче. + +**[Клієнт](client/index.md)** знайомить із ним, **[Транспорти клієнта](client/transports.md)** описує три форми під'єднання, **[Колбеки клієнта](client/callbacks.md)** — самі колбеки, а **[Тестування](get-started/testing.md)** показує шаблон роботи в пам'яті, що замінює допоміжну функцію `create_connected_server_and_client_session()` з v1. + +### Низькорівневий `Server` перебудовано, а не перейменовано {#the-low-level-server-was-rebuilt-not-renamed} + +Якщо ви працюєте на рівні JSON-RPC, це та частина v2, де «усе інакше». Ось той самий сервер з одним інструментом в обох варіантах; натискайте маркери, щоб побачити, що куди поділося. + + + +```python title="v1" +from typing import Any + +import mcp.types as types +from mcp.server.lowlevel import Server + +server = Server("Bookshop") + + +@server.list_tools() # (1)! +async def list_tools() -> list[types.Tool]: + return [ # (2)! + types.Tool( + name="search_books", + description="Search the catalog by title or author.", + inputSchema={ # (3)! + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + }, + ) + ] + + +@server.call_tool() +async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentBlock]: # (4)! + if name != "search_books": + raise ValueError(f"Unknown tool: {name}") # (5)! + ctx = server.request_context # (6)! + return [types.TextContent(type="text", text=f"Found 3 books matching {arguments['query']!r}.")] # (7)! +``` + +1. Обробники реєструються декораторами (викликаними, з дужками) у будь-який момент після того, як сервер уже існує. +2. Ви повертаєте голий `list[Tool]`, а SDK загортає його в `ListToolsResult`. +3. Поля в Python у camelCase, а схема **застосовується примусово**: SDK перевіряє за нею аргументи `call_tool` через jsonschema до запуску вашої функції, і саме тому `arguments["query"]` нижче безпечний. +4. Один обробник `call_tool` обслуговує всі інструменти й отримує ім'я інструмента та вже перевірені аргументи, розпаковані й ніколи не `None`. +5. Викинутий виняток — так інструмент v1 сигналізує про збій: будь-який виняток перехоплюється й повертається як `CallToolResult(isError=True)` з `str(e)` як текстом, тож модель, що викликає, читає це повідомлення й може повторити спробу. +6. Контекст береться з фонової ContextVar, до якої посеред запиту звертаються через об'єкт сервера. +7. Голі блоки вмісту загортаються в `CallToolResult` за вас. + +```python title="v2" +--8<-- "docs_src/whats_new/tutorial001.py" +``` + +1. Поля тепер у snake_case, а схема **оголошується, але ніколи не застосовується**: ніщо не перевіряє аргументи до запуску обробника. +2. Усі обробники мають однакову форму: `async (ctx, params) -> result`. Контекст — перший аргумент (на ньому живуть `ctx.session`, `ctx.request_id`, `ctx.protocol_version`); саме сюди перейшов `server.request_context`. +3. Повний `ListToolsResult` ви будуєте самі. Повернення голого списку тепер дає `TypeError` на сервері, а не щось, що SDK загорне. +4. На вході типізовані параметри (`params.name`, `params.arguments`), на виході повний результат. Нічого не розпаковується, не загортається й не перетворюється за вас. +5. Та сама перевірка, інше дієслово. `ValueError` тут дійшов би до моделі як непрозорий `-32603` (див. нижче), тож навмисну помилку протоколу викидають як `MCPError`: вона проходить наскрізь із кодом і повідомленням без змін, а `-32602` з цим текстом — відповідь на невідомий інструмент, яку дає сама специфікація. +6. `params.arguments` може бути `None`; v1 підставляв `{}` за замовчуванням ще до того, як ваш код його бачив. Перевірки перед обробником немає, тож на цьому рядку справді все тримається. +7. Неочікуваний виняток, викинутий тут, стає **очищеною** помилкою протоколу, `-32603` `"Internal server error"`: модель ніколи не бачить повідомлення. Для збою, який модель має прочитати й на який має відреагувати, повертайте `CallToolResult(is_error=True, ...)`. +8. Обробники — це аргументи конструктора, тож поверхня сервера повна в момент його створення; `add_request_handler()` — запасний вихід після конструювання і водночас двері до власних методів. + +Цей приклад і є загальний шаблон. У ширшому сенсі: усі обробники мають однакову форму — типізовані параметри на вході, повний тип результату на виході; старої перевірки аргументів інструмента через jsonschema більше немає; виняток — це помилка протоколу й ніколи не результат інструмента з `is_error=True`; а фонову ContextVar `server.request_context` вилучено. Власні методи у просторі імен постачальника стали повноцінними завдяки `add_request_handler(method, params_type, handler)`, який перевіряє вхідні параметри за вашою моделлю до запуску обробника. А список `middleware` (навмисно позначений як попередній) обгортає кожне вхідне повідомлення, замінюючи приватні методи `_handle_*`, які раніше перевизначали. + +Усередині цикл приймання `BaseSession` із v1 замінено рушієм диспетчера, який клієнт і сервер тепер ділять між собою, і саме завдяки йому кілька тверджень на цій сторінці істинні водночас: один об'єкт `Server` обслуговує обидва покоління протоколу, `Client(server)` диспетчеризує всередині процесу без обрамлення JSON-RPC, а клієнтський запит, час очікування якого вичерпано, тепер справді скасовує обробник на боці сервера. + +Докладніше — на сторінці **[Низькорівневий Server](advanced/low-level-server.md)**; **[Посібник з міграції](migration.md#lowlevel-server-decorator-based-handlers-replaced-with-constructor-on_-params)** розбирає кожну вилучену точку розширення. Якщо ви ніколи не спускалися нижче `MCPServer`, ніщо з цього вас не зачіпає. + +### Типи протоколу переїхали в `mcp-types`, а всі поля тепер у snake_case {#the-wire-types-moved-to-mcp-types-and-every-field-is-snake_case} + +Типи протоколу тепер живуть у власному дистрибутиві, `mcp-types`. Він не залежить ні від чого, крім pydantic і typing-extensions, тож шлюз, проксі чи генератор коду можуть споживати форми даних MCP, не встановлюючи HTTP-стек: такий проєкт встановлює `mcp-types` та імпортує `mcp_types`. Сам `mcp` залежить від цього пакета з точною версією й повторно його експонує, тож код, що залежить від SDK, і далі пише `import mcp.types as types` та `from mcp.types import Tool` (постійний псевдонім, кожне ім'я — той самий об'єкт) і оголошує лише одну свою справжню залежність, `mcp`. Просте правило: імпортуйте через той пакет, від якого ви насправді залежите. + +У цих типах кожен Python-атрибут тепер у snake_case: `result.is_error`, `tool.input_schema`, `listing.next_cursor`. JSON у переданих даних, як і раніше, у camelCase; змінилося лише написання атрибутів. Разом із цим приходять дві суворіші поведінки за замовчуванням: невідомі поля ігноруються, а не передаються далі без змін (додаткове кладіть у `_meta`), і обидві сторони перевіряють трафік за версією протоколу, яку узгодили. Таблиця перейменувань — у **[Посібнику з міграції](migration.md#field-names-changed-from-camelcase-to-snake_case)**. + +### Налаштування транспорту переїхало в `run()` {#transport-configuration-moved-to-run} + +`MCPServer(...)` описує, чим ваш сервер *є*: його ім'я, інструкції, життєвий цикл (lifespan), авторизацію. Те, як його *обслуговують*, тепер належить `run()` і побудовникам застосунків — саме туди перейшли `host`, `port`, `stateless_http`, `json_response`, шляхи кінцевих точок і `transport_security` (`MCPServer("x", port=9000)` — це `TypeError`). Перевантаження типізовані для кожного транспорту окремо, тож редактор підкаже, які параметри приймає `stdio`, а які `streamable-http`. Одне вилучення, про яке варто знати: `mount_path` більше немає; монтування ASGI-застосунку — підтримуваний спосіб обслуговувати під префіксом. + +**[Запуск сервера](run/index.md)** описує параметри; **[Додавання до наявного застосунку](run/asgi.md)** — монтування. + +### Поведінка, що змінюється без помилки імпорту {#behavior-that-changes-without-an-import-error} + +Перейменування заявляють про себе самі. А оце — ні: + +* **Синхронні функції виконуються в робочому потоці.** Інструмент, оголошений через `def` (або ресурс, промпт чи резолвер), більше не блокує цикл подій; плата за це — його тіло більше не виконується *в* потоці циклу подій, що важливо для коду, прив'язаного до потоку. Обробники `async def` не зачеплено. **[Посібник з міграції](migration.md#sync-handler-functions-now-run-on-a-worker-thread)**. +* **`MCPError` (`McpError` у v1), викинутий усередині інструмента, тепер є помилкою протоколу.** Модель його ніколи не бачить. Будь-який інший виняток, як і раніше, стає результатом з `is_error=True`, який модель може прочитати й на який може відреагувати. Розмежування — на сторінці **[Обробка помилок](servers/handling-errors.md)**. +* **Результати перевіряються перед відправленням.** Зібраний вручну `Tool`, у якого `input_schema` дорівнює `{}`, тепер провалює `tools/list` (специфікація вимагає `"type": "object"`). Сервери, побудовані на `@mcp.tool()`, цього ніколи не бачать: їхні схеми пише SDK. +* **Ваш клієнт перевіряє те, що отримує.** `list_tools()` і `call_tool()` звіряють відповідь сервера з узгодженою версією протоколу, тож не зовсім валідний сервер, який поблажливий розбір v1 терпів, тепер викидає `pydantic.ValidationError`. Якщо ви під'єднуєтеся до серверів, яких не контролюєте, готуйтеся бути тим, хто їх знайде; подробиці — у **[Посібнику з міграції](migration.md#client-validates-inbound-traffic-against-the-protocol-schema)**. +* **URI-шаблони тепер — справжній RFC 6570.** `{+path}`, `{?query}` та подібні працюють, зіставлення точне, а не приблизне за регулярним виразом, а обхід шляху у видобутих значеннях за замовчуванням відхиляється. Суворіші шаблони падають під час декорування, а не на першому запиті. **[URI-шаблони](servers/uri-templates.md)**. +* **Життєвий цикл streamable HTTP виконується один раз**, під час запуску, і його стан спільний для всіх сесій і запитів. У v1 він виконувався раз на сесію, а з `stateless_http=True` — раз на запит. Пули й кеші, побудовані в життєвому циклі, різко дешевшають; усе, що отримувало там ресурс на одне з'єднання, тепер належить тілу обробника. **[Життєвий цикл](handlers/lifespan.md)**. +* **`mcp dev` і `mcp install` фіксують середовище, яке породжують,** на встановленій у вас версії SDK. Обидві команди запускають ваш сервер у свіжому середовищі `uv run --with ...`, яке раніше розв'язувало `mcp` до найновішого стабільного випуску, а не до версії, з якою ви розробляєте. **[Посібник з міграції](migration.md#mcp-dev-and-mcp-install-pin-the-spawned-environment-to-your-sdk-version)**. +* **HTTP-клієнт тепер `httpx2`, а не `httpx`.** Заміна залежності змінює те, що ваш код перехоплює й передає (`httpx2.AsyncClient`, `httpx2.ConnectError`), і змінює спосіб перевірки TLS-сертифікатів: `httpx2` перевіряє через `truststore` за сховищем довіри операційної системи, а не за вбудованим списком CA від certifi. Більшість середовищ цього не помітять; мінімальний контейнер без системного сховища CA або приватний CA, про який знав лише набір certifi, почне провалювати TLS-рукостискання. Задайте `SSL_CERT_FILE`/`SSL_CERT_DIR` або передайте клієнту `verify=ssl_context`. **[Посібник з міграції](migration.md#httpx-and-httpx-sse-replaced-by-httpx2)**. + +### Вилучено повністю {#removed-outright} + +Кожному з цих пунктів присвячено розділ у **[Посібнику з міграції](migration.md)**: + +* **Транспорт WebSocket**, з обох боків, і екстра `mcp[ws]`. Він ніколи не був частиною специфікації MCP. +* **Експериментальний API Tasks** (`mcp.*.experimental`). 2026-07-28 виносить задачі з ядра протоколу в офіційне розширення ([SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663)), яке цей SDK поки не реалізує. +* `mcp.shared.version`, `mcp.shared.progress` і `mcp.shared.session` (із заглушкою `RequestResponder`, яку імпортували анотації `message_handler` у v1) як шляхи імпорту. (`mcp.types` *не* вилучено: він залишається постійним псевдонімом окремого пакета `mcp_types`.) +* Застаріле написання `streamablehttp_client` і колбек `get_session_id` зі `streamable_http_client` (який тепер видає рівно два потоки). +* `McpError`, перейменований на **`MCPError`** із прямим конструктором `(code, message, data)`. +* `MCPServer.get_context()`, `mount_path=`, а також методи-декоратори, ContextVar і словники обробників низькорівневого `Server`. + +## Протокол: від 2025-11-25 до 2026-07-28 {#the-protocol-2025-11-25-to-2026-07-28} + +v2 реалізує редакцію 2026-07-28 і обслуговує **обидві** редакції водночас: той самий `streamable_http_app()` (і той самий stdio-сервер) відповідає на `initialize` клієнта покоління 2025 і на запити клієнта покоління 2026 — без жодного налаштування, без прапорця, який треба перемкнути, і без окремого розгортання. Обслуговування нової редакції не кидає напризволяще клієнта на старій. Далі — про те, що змінює сама нова редакція. + +### Без рукостискання, без сесії {#no-handshake-no-session} + +Клієнт 2026-07-28 не відкриває з'єднання, не веде перемовин і лише потім говорить. Кожен запит несе свою версію протоколу, відомості про клієнта й можливості клієнта в `_meta`, а єдиний виклик виявлення, `server/discover`, — звичайний запит, як будь-який інший. `Client` за замовчуванням робить усе правильно: один раз зондує `server/discover` і відступає до рукостискання `initialize`, якщо сервер старіший. + +У Streamable HTTP на шляху 2026 немає `Mcp-Session-Id`, і це головна новина для експлуатації: **ніщо не прив'язує сучасний запит до робочого процесу**, тож відповісти на нього може будь-яка репліка за звичайним балансувальником із циклічним розподілом. Два чесні застереження. Ваші клієнти покоління 2025 (а сьогодні це більшість клієнтів) і далі відкривають сесії й потребують тієї самої прив'язки, що й на v1; для них нічого не змінюється. А єдине, що *багатораундова* повторна спроба мусить перенести між робочими процесами, — це її запечатаний `request_state`, типовий ключ якого карбується окремо в кожному процесі, тож горизонтально масштабоване розгортання передає `RequestStateSecurity(keys=[...])`. (`stateless_http=True` тут ні до чого: він впливає лише на обслуговування клієнтів покоління 2025, а трафік 2026 його ніколи не читає; якщо ви вже задали його у v1, нічого не змінюється.) + +**[Версії протоколу](protocol-versions.md)** — клієнтський бік цього, **[Розгортання й масштабування](run/deploy.md)** — контрольний список оператора (список дозволених Host, ключ `request_state`, сповіщення між репліками), а **[Обслуговування клієнтів старого покоління](run/legacy-clients.md)** — розповідь про обидва покоління водночас. + +### Сервер не може викликати клієнта: багатораундові запити {#the-server-cannot-call-the-client-multi-round-trip-requests} + +На 2026-07-28 зникли всі запити, ініційовані сервером: push-еліцитація, семплювання, `roots/list`. На з'єднанні 2026 для них немає каналу, тож `ctx.elicit()` і `ctx.session.create_message()` там завершуються помилкою `NoBackChannelError` (для клієнтів старого покоління вони й далі працюють). + +Заміна розвертає виклик у зворотний бік. Інструмент, якому щось потрібно від користувача, *повертає* запитання (`InputRequiredResult`), клієнт відповідає на нього тими самими колбеками, які мав завжди, і виклик повторюється з прикріпленими відповідями. `Client` веде цей цикл за вас. На сервері ви рідко будуєте результат самі, бо це робить **[залежність](handlers/dependencies.md)**: анотуйте параметр `Resolve(ask_quantity)`, де `ask_quantity` — звичайна функція, яку ви пишете, і SDK запитає тим механізмом, який підтримує з'єднання: живим запитом еліцитації на сесії старого покоління або багатораундовим обміном на 2026. Одне тіло інструмента, обидва покоління: + +```python title="dual_era.py" hl_lines="24 37-38" +--8<-- "docs_src/legacy_clients/tutorial001.py" +``` + +Цей файл — уся ідея в одному місці: один сервер, один інструмент на основі `Resolve`, і клієнт старого покоління разом із сучасним клієнтом, які обидва отримують свою відповідь, у пам'яті. **[Багатораундові запити](handlers/multi-round-trip.md)** пояснює механізм (зокрема `request_state`, який SDK запечатує й перевіряє за вас); **[Еліцитація](handlers/elicitation.md)** — саме запитування. + +!!! warning "Це єдине місце, де перенесений сервер v1 змінює поведінку" + Першими на це натрапляють ваші власні тести: `Client(mcp)` за замовчуванням узгоджує з вашим сервером v2 + версію 2026-07-28, тож інструмент, що викликає `ctx.elicit()`, падає в тесті, який на v1 проходив. Перенесіть + запитання в параметр `Resolve(...)` (працює в обох поколіннях) або зафіксуйте тестовий клієнт на + `mode="legacy"`, якщо вам справді потрібна push-поведінка. + +### Кореневі каталоги, семплювання та протокольне логування застарілі; `ping` вилучено {#roots-sampling-and-protocol-logging-are-deprecated-ping-is-removed} + +[SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) оголошує застарілими три цілі *можливості* на всіх версіях протоколу: кореневі каталоги, семплювання та логування рівня MCP (`ctx.info()` і подібні). Це окрема вісь щодо відсутнього зворотного каналу (back-channel) вище; статус застарілого — рекомендаційний, усе й далі працює із сесіями покоління 2025, і в переданих даних нічого не змінюється. Помітите ви `MCPDeprecationWarning`, який є `UserWarning`, тож виводиться за замовчуванням; очікуйте, що перший же `ctx.info(...)` після оновлення про це повідомить. + +Із `ping` суворіше: його вилучено з протоколу, а не оголошено застарілим. Так само на 2026-07-28 вилучено два окремі методи застарілих можливостей, `logging/setLevel` і клієнтський `notifications/roots/list_changed`, а сповіщення про перебіг виконання тепер ідуть лише від сервера до клієнта. + +На сторінці **[Застарілі можливості](deprecated.md)** — повна таблиця, заміна для кожної й однорядковий фільтр, якщо потрібен тихий лог, поки ви обслуговуєте клієнтів старого покоління. + +### Сповіщення про зміни стають одним потоком {#change-notifications-become-one-stream} + +На 2026-07-28 окремий потік HTTP GET і `resources/subscribe` замінено на `subscriptions/listen`: клієнт відкриває один довготривалий потік і називає види сповіщень, які хоче отримувати. `MCPServer` обслуговує його за замовчуванням; ви публікуєте через `await ctx.notify_resource_updated(uri)` (і `notify_tools_changed()` тощо), middleware може відмовити в запиті на прослуховування залежно від того, хто викликає, а розгортання з кількома репліками під'єднують спільну `SubscriptionBus`. На клієнті потік відкриває `async with client.listen(...)`: фільтр передається іменованими аргументами, назад приходять типізовані події змін, а `sub.honored` — підмножина, яку сервер погодився доставляти. + +**[Підписки](handlers/subscriptions.md)** описує публікацію й обслуговування, **[сторінка-близнюк у розділі про клієнт](client/subscriptions.md)** — бік спостереження, а **[Розгортання й масштабування](run/deploy.md)** — шину. + +### Решта, коротко {#the-rest-quickly} + +* **Ідентичність — необов'язкові метадані кожного повідомлення.** Ключ `_meta` `clientInfo` на боці запиту необов'язковий (обов'язкова пара — `protocolVersion` + `clientCapabilities`), а `serverInfo` переїхав із тіла результату `server/discover`: натомість сервери проставляють його в `_meta` кожного результату покоління 2026 ([spec #3002](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3002)). SDK проставляє завжди; `client.server_info` дорівнює `None`, коли сервер себе не ідентифікує (наприклад, middleware прибрав ключ). **[Низькорівневий Server](advanced/low-level-server.md)** показує цю позначку в переданих даних. +* **Запити можна маршрутизувати, не розбираючи тіл.** Сучасні HTTP-запити несуть `Mcp-Method` (а для трьох викликів на кшталт інструментів — ще й `Mcp-Name`); властивість вхідної схеми інструмента, анотована `x-mcp-header`, дублюється в заголовок `Mcp-Param-*` і звіряється сервером ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243)). Шлюзи й обмежувачі частоти можуть маршрутизувати лише за заголовками; правила — у **[Посібнику з міграції](migration.md#servers-validate-mcp-param-headers-against-the-request-body-sep-2243)**. +* **Результати несуть підказки кешування.** Результати списків і читання оголошують `ttlMs` і `cacheScope` ([SEP-2549](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549)); ви задаєте їх для кожного методу через `cache_hints=`, а `Client` дотримується їх завдяки вбудованому кешу відповідей. Сервер, який не надсилає підказок (тобто будь-який сервер до 2026), бачить ідентичний, некешований трафік. **[Підказки кешування](client/caching.md)**. +* **Розширення стали повноцінними.** Сервери й клієнти оголошують необов'язкові набори можливостей під ідентифікаторами у форматі зворотного DNS ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)); вбудоване розширення `Apps` (MCP Apps) — еталонне. **[Розширення](advanced/extensions.md)** і **[MCP Apps](advanced/apps.md)**. +* **Коди помилок стандартизовано.** Відсутній ресурс — це `-32602` з URI в `error.data`, а нові зарезервовані специфікацією коди з'являються як `-32020` (невідповідність заголовка), `-32021` (відсутня обов'язкова можливість) і `-32022` (непідтримувана версія протоколу). **[Усунення несправностей](troubleshooting.md)** упорядковано за точними повідомленнями. +* **Авторизацію стало важче використати неправильно.** Клієнт перевіряє `iss`, повернутий разом із кодом авторизації ([RFC 9207](https://datatracker.ietf.org/doc/html/rfc9207); ваш `callback_handler` тепер повертає `AuthorizationCodeResult`), надсилає `application_type` під час реєстрації й ніколи не відтворює облікові дані на іншому сервері авторизації. Нове в корпоративному куточку: потік підтвердження ідентичності [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990). **[Посібник з міграції](migration.md)** перелічує всі зміни OAuth; відповідні сторінки — **[OAuth для клієнтів](client/oauth-clients.md)** та **[Підтвердження ідентичності](client/identity-assertion.md)**. +* **Кожен сервер трасується.** OpenTelemetry увімкнено за замовчуванням як middleware: кожен запит отримує серверний спан, і це нічого не коштує, доки процес не налаштує експортер. Коли на обох кінцях працює SDK, клієнт також передає контекст трасування W3C у `_meta`, тож траси з'єднуються. **[OpenTelemetry](run/opentelemetry.md)**. + +## Оновлюєтеся з v1? {#upgrading-from-v1} + +* **[Посібник з міграції](migration.md)** — повний і точний перелік того, що змінити; ця сторінка пояснювала чому. +* **v1.x нікуди не зникає.** Вона переходить у режим підтримки, і далі отримує критичні виправлення та латки безпеки, і ніщо у випуску специфікації 2026-07-28 її не ламає; її документація живе за адресою [/v1/](https://py.sdk.modelcontextprotocol.io/v1/). Якщо ви публікуєте бібліотеку, що залежить від `mcp`, і не готові мігрувати, залиште верхню межу (наприклад, `mcp>=1.28,<2`), щоб розв'язання без фіксації версії залишалося на 1.x. +* Щось сире, незрозуміле чи зламане? **[Надішліть відгук про v2](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml)**; ми читаємо все. diff --git a/i18n/zh-hant/glossary.json b/i18n/zh-hant/glossary.json new file mode 100644 index 0000000000..7e86b7b63c --- /dev/null +++ b/i18n/zh-hant/glossary.json @@ -0,0 +1,411 @@ +{ + "keep": [ + "MCP", + "Model Context Protocol", + "MCPServer", + "FastMCP", + "ClientSession", + "Context", + "ctx", + "stdio", + "Streamable HTTP", + "SSE", + "JSON-RPC", + "JSON", + "OAuth", + "PKCE", + "JWT", + "CIMD", + "HTTP", + "HTTPS", + "TLS", + "CORS", + "URI", + "URL", + "ASGI", + "WebSocket", + "API", + "SDK", + "CLI", + "IDE", + "LLM", + "SEP", + "RFC", + "Python", + "TypeScript", + "Node.js", + "PyPI", + "Pydantic", + "Starlette", + "FastAPI", + "uvicorn", + "httpx", + "anyio", + "asyncio", + "trio", + "pytest", + "OpenTelemetry", + "Inspector", + "Claude", + "GitHub", + "VS Code", + "Windows", + "macOS", + "Linux", + "llms.txt", + "2026-07-28", + "2025-11-25", + "2025-06-18", + "2025-03-26" + ], + "terms": [ + { + "source": "tool", + "target": "工具", + "note": "MCP protocol noun (a server exposes tools); \"tool call\" → 工具呼叫. Wire identifiers such as `tools/call` and `tools/list` are code and stay Latin. Provisional pending native review." + }, + { + "source": "resource", + "target": "資源", + "note": "MCP protocol noun; \"resource template\" → 資源範本 (not 資源模板). `resources/read` stays Latin. Provisional pending native review." + }, + { + "source": "prompt", + "target": "提示詞", + "note": "The MCP feature: a reusable prompt a server exposes (`prompts/get` stays Latin), and the everyday LLM word. Provisional pending native review: 提示詞 rather than the bare 提示, which reads as \"hint\" and is what a `tip` admonition title becomes; several Taiwan vendor docs use 提示 for prompt, so this is an open choice — but one rendering per page." + }, + { + "source": "sampling", + "target": "取樣", + "note": "The (deprecated) client feature that lets a server borrow the client's model. Gloss the English on first use per page: 取樣(sampling). Taiwan signal-processing usage is 取樣; 抽樣 is statistical sampling and the wrong sense, 采样 is Simplified. The `sampling` capability key and `sampling/createMessage` stay Latin. Provisional pending native review.", + "avoid": ["采样", "抽樣"] + }, + { + "source": "roots", + "target": "根目錄", + "note": "The (deprecated) client feature listing workspace folders; a `Root` object in code font stays Latin. Gloss the English on first use per page: 根目錄(roots). Never the bare 根 and never 根節點 (a tree node). Provisional pending native review.", + "avoid": ["根节点", "根節點"] + }, + { + "source": "elicitation", + "target": "徵詢", + "note": "OPEN QUESTION for native review: there is no settled Taiwan term for the server asking the user a question mid-request; candidates seen are 徵詢, 引導, 詢問 and 誘導 (the last carries a manipulative overtone and is not wanted). Provisionally pinned to 徵詢, glossed with the English on first use per page: 徵詢(elicitation); the verb \"elicit\" is 向使用者徵詢. Write it with 徵, never the Simplified or mis-converted 征. `elicitation/create` and the `Elicit` class stay Latin.", + "avoid": ["征询", "征詢"] + }, + { + "source": "capability", + "target": "能力", + "note": "A negotiated protocol capability (宣告了 `sampling` 能力; \"capability negotiation\" → 能力協商). The `capabilities` field and keys such as `sampling.tools` stay Latin. Not 功能, which means \"feature\" — the corpus uses \"feature\" as a separate word. Provisional pending native review." + }, + { + "source": "transport", + "target": "傳輸", + "note": "As a countable noun use 傳輸方式 (\"three transports\" → 三種傳輸方式; the \"Transports\" page → 傳輸方式). The transport names stdio, Streamable HTTP and SSE stay in English. Provisional pending native review." + }, + { + "source": "session", + "target": "工作階段", + "note": "An MCP session (the negotiated connection state). Provisional pending native review: 工作階段 is the established Taiwan localisation; gloss the English on first use per page — 工作階段(session). 會話 is the Mainland rendering and in Taiwan means a language-conversation class; `session` and `ctx.session` in code font stay Latin, as does the class name ClientSession. Open question: many Taiwanese developers simply write session in English.", + "avoid": ["会话", "會話"] + }, + { + "source": "handler", + "target": "處理函式", + "note": "The tool, resource or prompt function you register (nav section \"Inside your handler\" → 在處理函式內部). Provisional pending native review: 處理函式 rather than the vendor form 處理常式 or the Mainland 處理器/處理函數; pick-one-and-never-mix applies within a page." + }, + { + "source": "dependency", + "target": "相依性", + "note": "The SDK's parameter-injection feature (the \"Dependencies\" page → 相依性); \"dependency injection\" → 相依性注入; a package dependency in installation contexts → 相依套件. Provisional pending native review: 依賴/依賴注入 is the widespread community alternative and is not wrong in Taiwan, but do not mix the two families on one page. The `Resolve` marker class stays Latin." + }, + { + "source": "client", + "target": "用戶端", + "note": "An MCP client, and the client side of a connection (\"client-side\" → 用戶端). The `Client` class name stays Latin in code font. Provisional pending native review: 用戶端 is the form Taiwan platform vendors use; 客戶端 is also heard in Taiwan but is pinned out here so a page never mixes the two, and 客户端 is Simplified.", + "avoid": ["客户端", "客戶端"] + }, + { + "source": "server", + "target": "伺服器", + "note": "An MCP server (the program you build); \"server-side\" → 伺服器端. The low-level `Server` class and `MCPServer` stay Latin in code font. 服務器/服务器 is the Mainland word and never appears on a Taiwan page. Provisional pending native review.", + "avoid": ["服务器", "服務器"] + }, + { + "source": "host", + "target": "主機", + "note": "The MCP host: the application that embeds the client and drives the model (Claude Desktop, an IDE). Gloss the English on first use per page when it is this protocol role — MCP 主機(host) — because 主機 alone first suggests a machine; a network host or hostname is also 主機/主機名稱. When \"the host app\" means the outer ASGI application an MCP server is mounted into, write 外層應用程式 (or 主應用程式), not 主機. Never 宿主, the Mainland docs convention. Provisional pending native review.", + "avoid": ["宿主"] + }, + { + "source": "context", + "target": "上下文", + "note": "The generic lower-case word (\"provide context to LLMs\" → 為 LLM 提供上下文). Provisional pending native review: 上下文 is widely understood in Taiwan developer writing; 脈絡 and 情境 are the candidates a reviewer may prefer. The capitalised `Context` is the SDK object injected as `ctx`; both are on the keep list and stay Latin in prose (\"The Context\" → Context). The idiom \"in this context\" is not this term and may be recast (在這裡, 這種情況下)." + }, + { + "source": "request", + "target": "請求", + "note": "A JSON-RPC or HTTP request (\"the initialize request\" → initialize 請求; \"Multi-round-trip requests\" → 多輪往返請求). Provisional pending native review: some Taiwan vendor docs write 要求 for an HTTP request; 請求 is pinned here. `Request` types in code font stay Latin." + }, + { + "source": "response", + "target": "回應", + "note": "A JSON-RPC or HTTP response; the verb \"respond\" is also 回應. Never 響應 (Mainland; in Taiwan it means answering a call to action) — it is not on the avoid list only because the characters also occur inside 影響應用程式. `Response` types in code font stay Latin. Provisional pending native review.", + "avoid": ["响应"] + }, + { + "source": "callback", + "target": "回呼", + "note": "Client callbacks and OAuth redirect callbacks alike (\"the elicitation callback\" → 徵詢回呼; \"Callbacks\" page → 回呼); 回呼函式 where a countable noun reads better. Never the Mainland 回調. Parameter names such as `sampling_callback` stay Latin. Provisional pending native review.", + "avoid": ["回调"] + }, + { + "source": "decorator", + "target": "裝飾器", + "note": "The Python decorators the SDK is built on; `@mcp.tool()` and its siblings are code and stay untouched. Provisional pending native review." + }, + { + "source": "type hint", + "target": "型別提示", + "note": "Python type hints (\"from your type hints\" → 從型別提示; \"type annotation\" → 型別註記). Taiwan usage is 型別 for a data type; 類型 is the general word for \"kind\" and stays available for that sense. Provisional pending native review.", + "avoid": ["类型提示", "類型提示"] + }, + { + "source": "notification", + "target": "通知", + "note": "A JSON-RPC notification (a message that expects no response); \"change notifications\" → 變更通知. Method strings such as `notifications/tools/list_changed` stay Latin. Provisional pending native review." + }, + { + "source": "multi-round-trip", + "target": "多輪往返", + "note": "The 2026-07-28 request pattern (\"Multi-round-trip requests\" → 多輪往返請求); a single \"round trip\" → 往返 (一次往返). Provisional coinage pending native review: gloss the English on first use per page — 多輪往返(multi-round-trip). The abbreviation MRTR stays Latin." + }, + { + "source": "lifespan", + "target": "生命週期", + "note": "The server's startup/shutdown scope (the \"Lifespan\" page → 生命週期), written with Taiwan's 週, not 周. The neighbouring word \"lifecycle\" also renders 生命週期 and the overlap is accepted. The `lifespan` parameter name stays Latin in code font. 壽命 is the biological sense and wrong here. Provisional pending native review; keeping the English word lifespan in prose is the alternative a reviewer may prefer.", + "avoid": ["生命周期"] + }, + { + "source": "back-channel", + "target": "反向通道", + "note": "The server-to-client request channel that exists only on 2025-era, non-stateless connections. Provisional coinage pending native review: gloss the English on first use per page — 反向通道(back-channel). A generic \"channel\" is 通道 or 管道, never the Mainland 渠道." + }, + { + "source": "deprecated", + "target": "已棄用", + "note": "Advisory status: still works, scheduled for removal later. \"X is deprecated\" → X 已棄用; \"deprecation warning\" → 棄用警告; \"Deprecated features\" → 已棄用的功能; \"removed\" is a different word (已移除) and the corpus contrasts the two. Provisional pending native review: 已淘汰 is the rendering several Taiwan platform docs use and is the alternative to confirm against; do not mix the two families on one page. The `MCPDeprecationWarning` class stays Latin." + }, + { + "source": "resolver", + "target": "解析器", + "note": "The function attached to a parameter with `Resolve(...)` that computes or asks for its value (\"an elicitation resolver\" → 徵詢解析器). The `Resolve` class stays Latin. Provisional pending native review, together with the handler entry." + }, + { + "source": "wire", + "target": "線路", + "note": "The corpus's light metaphor for the byte stream between client and server (\"stdout is the wire\" → stdout 就是線路本身; \"invisible on the wire\" → 在線路上看不到; \"the JSON on the wire\" → 實際傳輸的 JSON). Never a literal 電線. Provisional pending native review.", + "avoid": ["电线", "電線"] + }, + { + "source": "era", + "target": "世代", + "note": "\"Protocol era\" (\"a 2025-era client\", \"whatever era the client speaks\") → 協定世代, 2025 世代的用戶端. Provisional pending native review; not the literal 時代." + }, + { + "source": "legacy", + "target": "舊版", + "note": "\"A legacy connection / client\" = one negotiated at spec version 2025-11-25 or earlier → 舊版連線, 舊版用戶端; \"Serving legacy clients\" → 服務舊版用戶端. Provisional pending native review." + }, + { + "source": "handshake", + "target": "交握", + "note": "The initialization handshake (\"the classic handshake\" → 傳統的交握; \"three-way handshake\" is 三向交握 in Taiwan networking texts). Never the Mainland/literal 握手 — it is kept off the avoid list only because the characters also occur inside 掌握手動. Provisional pending native review." + }, + { + "source": "protocol", + "target": "協定", + "note": "\"the protocol\" → 協定 (通訊協定 where the fuller form reads better); \"Protocol versions\" → 協定版本. The name Model Context Protocol is on the keep list and stays English. Never the Mainland 協議, which in Taiwan means an agreement. Provisional pending native review.", + "avoid": ["协议"] + }, + { + "source": "extension", + "target": "擴充功能", + "note": "A protocol extension (the \"Extensions\" page, SEP-numbered extensions) → 擴充功能; \"declare an extension\" → 宣告一個擴充功能. A file extension is 副檔名; a pip extra such as `[cli]` is an extra, not this term. 擴展 stays available as the verb \"to scale / expand\" (\"Deploy & scale\" → 部署與擴展). Provisional pending native review.", + "avoid": ["扩展"] + }, + { + "source": "completion", + "target": "自動完成", + "note": "The MCP feature that suggests values for prompt and resource-template arguments (the \"Completions\" page → 自動完成; `completion/complete` stays Latin). When \"completion\" means the text a model generates during sampling, write 生成結果 instead. Never the Mainland 補全. Provisional pending native review." + }, + { + "source": "middleware", + "target": "中介軟體", + "note": "The \"Middleware\" page and `server.middleware` (code stays Latin). Provisional pending native review: 中介軟體 is the Taiwan form; 中間件 is Mainland; many Taiwanese developers keep middleware in English, which is the alternative to confirm against.", + "avoid": ["中间件"] + }, + { + "source": "authorization", + "target": "授權", + "note": "OAuth authorization → 授權 (授權伺服器, 授權碼); authentication is a different word → 驗證/身分驗證, and identity is written 身分. The `Authorization` header and code identifiers stay Latin. Provisional pending native review." + }, + { + "source": "exception", + "target": "例外", + "note": "A raised Python exception (\"raises an exception\" → 引發例外; \"catch\" → 攔截/捕捉). Never 異常, which in Taiwan means \"abnormal\". Exception class names stay Latin. Provisional pending native review." + }, + { + "source": "argument", + "target": "引數", + "note": "A value passed in a call → 引數; the declared parameter → 參數 (\"tool arguments\" → 工具引數; \"a parameter with a default\" → 有預設值的參數). Provisional pending native review: everyday Taiwan writing often says 參數 for both; keep the distinction where the English makes it." + }, + { + "source": "return value", + "target": "回傳值", + "note": "A function's return value; the verb \"return\" (a value) → 回傳. Use 回傳 consistently rather than alternating with 傳回. 返回 means \"go back\" (返回上一頁) and is never used for returning a value. The `return` keyword is code. Provisional pending native review." + }, + { + "source": "async", + "target": "非同步", + "note": "The prose adjective (\"the async runtime\" → 非同步執行環境, \"an async callback\" → 非同步回呼); synchronous → 同步. Never the Mainland 異步. The `async` and `await` keywords in code font stay Latin. Provisional pending native review.", + "avoid": ["异步"] + }, + { + "source": "function", + "target": "函式", + "note": "A Python function → 函式 (\"a plain Python function\" → 一個普通的 Python 函式). Provisional pending native review: 函數 is the mathematics word and common in older Taiwan programming texts; 函式 is pinned here and the two are never mixed on a page.", + "avoid": ["函数"] + }, + { + "source": "object", + "target": "物件", + "note": "A Python or JSON object → 物件. Never 對象, which in Taiwan means a target audience or counterpart, not a programming object. Provisional pending native review.", + "avoid": ["对象"] + }, + { + "source": "variable", + "target": "變數", + "note": "\"environment variable\" → 環境變數; a template variable → 變數. Never the Mainland 變量. Provisional pending native review.", + "avoid": ["变量"] + }, + { + "source": "string", + "target": "字串", + "note": "A text string → 字串; a character → 字元. Never 字符串/字符. Provisional pending native review.", + "avoid": ["字符串"] + }, + { + "source": "default", + "target": "預設", + "note": "\"the default\" → 預設值; \"by default\" / \"out of the box\" → 預設情況下; \"defaults to X\" → 預設為 X. Never 默認, which in Taiwan means \"to tacitly admit\". Provisional pending native review.", + "avoid": ["默认", "默認"] + }, + { + "source": "data", + "target": "資料", + "note": "Computing data → 資料 (資料庫, 資料結構, 中繼資料 for metadata). 數據 in Taiwan means numeric figures or statistics and is not the general word; 数据 is Simplified. Provisional pending native review.", + "avoid": ["数据", "數據庫"] + }, + { + "source": "file", + "target": "檔案", + "note": "A file on disk → 檔案 (\"Create a file `server.py`\" → 建立 `server.py` 檔案; \"config file\" → 設定檔). 文件 in Taiwan means a document or documentation (說明文件) and is used only in that sense. Provisional pending native review." + }, + { + "source": "code", + "target": "程式碼", + "note": "Source code → 程式碼; a program → 程式; \"no validation code\" → 不用寫驗證程式碼. An error or status code is 錯誤碼/狀態碼. Never 代碼 for source code. Provisional pending native review.", + "avoid": ["代码"] + }, + { + "source": "software", + "target": "軟體", + "note": "軟體, never the Mainland/Hong Kong 軟件; likewise 硬體 for hardware. Provisional pending native review.", + "avoid": ["软件", "軟件"] + }, + { + "source": "network", + "target": "網路", + "note": "網路 (the Internet → 網際網路), never 網絡, which is the Mainland/Hong Kong form. Provisional pending native review.", + "avoid": ["网络", "網絡"] + }, + { + "source": "memory", + "target": "記憶體", + "note": "RAM / process memory → 記憶體; \"in-memory\" (transport, client, token storage) → 記憶體內 (記憶體內傳輸, 存在記憶體內). Never the Mainland 內存. Provisional pending native review.", + "avoid": ["内存"] + }, + { + "source": "message", + "target": "訊息", + "note": "A JSON-RPC, log or error message → 訊息; \"information\" → 資訊 (an `info` admonition title, when translated, is 資訊). Never 信息 (Mainland for both) and never 消息, which in Taiwan means news. Provisional pending native review.", + "avoid": ["信息"] + }, + { + "source": "support", + "target": "支援", + "note": "Technical support of a feature or version (\"supports Python 3.10\" → 支援 Python 3.10; \"not supported\" → 不支援). In Taiwan 支持 means to endorse or back someone and is wrong for this sense; it is kept off the avoid list only because the characters also occur inside 分支持續. Provisional pending native review." + }, + { + "source": "user", + "target": "使用者", + "note": "The person at the keyboard → 使用者 (\"ask the user\" → 詢問使用者). Not 用戶, which Taiwan reserves for an account holder or subscriber — except inside the fixed term 用戶端 (client). Provisional pending native review.", + "avoid": ["用户"] + }, + { + "source": "cache", + "target": "快取", + "note": "Noun and verb (the \"Caching\" page → 快取; \"cached for 60 seconds\" → 快取 60 秒). Never the Mainland 緩存. Provisional pending native review.", + "avoid": ["缓存"] + }, + { + "source": "logging", + "target": "記錄", + "note": "The activity and the \"Logging\" page → 記錄; \"the server log\" → 伺服器記錄; \"a log message\" → 記錄訊息; \"log level\" → 記錄層級. The `logging` module and logger names stay Latin. Provisional pending native review: 日誌 is common in Taiwan too and is the alternative to confirm against; do not mix the two on one page. 日志 is Simplified.", + "avoid": ["日志"] + }, + { + "source": "schema", + "target": "schema", + "note": "Keep the English word in Chinese prose, singular and lower-case (輸入 schema, 從型別提示產生 schema; JSON Schema as a proper name keeps its capitals). Provisional pending native review: Taiwanese developers overwhelmingly say schema; 結構描述 is the formal vendor rendering a reviewer may prefer. Never 模式 (which means \"mode\", as in 無狀態模式) and never 架構 (architecture) for this word." + }, + { + "source": "run", + "target": "執行", + "note": "\"Run the server\" → 執行伺服器; \"Running your server\" (nav section) → 執行伺服器; \"at runtime\" → 執行時. Never the Mainland 運行. A colloquial 跑 is acceptable once in a light aside, not as the standard verb. Provisional pending native review.", + "avoid": ["运行"] + }, + { + "source": "call", + "target": "呼叫", + "note": "To call a tool or function → 呼叫 (\"call `add`\" → 呼叫 `add`; \"tool call\" → 工具呼叫; \"the caller\" → 呼叫端). Never the Mainland 調用 — it is kept off the avoid list only because the characters also occur inside 協調用戶端. Provisional pending native review.", + "avoid": ["调用"] + }, + { + "source": "you", + "target": "你", + "note": "The register rule from instructions.md made machine-checkable: 您 must never appear on a page. Prefer dropping the pronoun; when one is needed it is 你.", + "avoid": ["您"] + }, + { + "source": "Get started", + "target": "開始使用", + "note": "The nav section that opens the guide, and the title of its index page. \"First steps\" is a separate page inside that section (第一步), so the two need distinct renderings or the sidebar shows the same title twice. Provisional pending native review; 快速入門 is the alternative for the section." + }, + { + "source": "First steps", + "target": "第一步", + "note": "The tutorial page inside the \"Get started\" section; never reuse this rendering for the section itself (see that entry). Provisional pending native review." + }, + { + "source": "Recap", + "target": "重點回顧", + "note": "Recurring section heading that closes most pages; one rendering everywhere, not 重點回顧 on some pages and 小結 or 回顧 on others. Provisional pending native review." + }, + { + "source": "Try it", + "target": "試試看", + "note": "Recurring section heading above a runnable example; one rendering everywhere, not 試一試 or 動手試試 on some pages. Provisional pending native review." + } + ] +} diff --git a/i18n/zh-hant/instructions.md b/i18n/zh-hant/instructions.md new file mode 100644 index 0000000000..4d8cf74c98 --- /dev/null +++ b/i18n/zh-hant/instructions.md @@ -0,0 +1,170 @@ +# Traditional Chinese, Taiwan (zh-hant) — translation instructions + +Target language: Traditional Chinese as written for readers in Taiwan (繁體中文,台灣用語), +directory and URL code `zh-hant`, page language tag `zh-Hant`. This file is sent verbatim +with every translation request for this language, on top of the shared translation rules +in `../general-prompt.md`. The termbase in `glossary.json` is sent alongside it and wins +any terminology conflict with this file. + +This is a language target in its own right, translated directly from the English — never +a character conversion of a Simplified Chinese text. Taiwan and Mainland usage differ in +hundreds of everyday computing words (伺服器/服务器, 程式碼/代码, 預設/默认, 物件/对象); +a converted page reads as foreign in Taiwan even when every character is Traditional. + +## 1. Register + +Write the casual-neutral register that developer documentation in Taiwan uses: plain, +even, close to how an engineer explains something aloud, without being chatty. + +- Address the reader as 你. Never the honorific 您, never 您們, and never a mix. The rule + holds in body prose, headings, admonition titles, table cells and link text. +- Prefer no pronoun at all when the sentence stays clear: "You can pass a schema" → + 可以傳入一個 schema. Reach for 你 only where the sentence would otherwise be ambiguous + about who acts. "Your server" is 伺服器, or 你的伺服器 only when ownership is the point. +- Steps and instructions are bare imperatives without a subject: "Run the server" → + 執行伺服器, not 請您執行伺服器. A single 請 is fine where it reads naturally; a 請 in + front of every step is not, and neither is 若要……,請…… opening every paragraph. +- The register is uniform across a page. A page that drifts between 你 and 您, or between + plain sentences and stiff officialese, is wrong even when each sentence is fine alone. + +## 2. Voice + +Aim for the voice of an experienced Taiwanese engineer walking a colleague through a +library: warm, direct, professional, compact. The English is built on short declarative +payoff sentences ("That's a complete MCP server."); keep them short — +這就是一個完整的 MCP 伺服器。 + +Do: + +- Follow Chinese word order and rhythm. Break one long English sentence into two Chinese + ones instead of mirroring its clause structure, and use concrete verbs (執行, 傳入, + 回傳, 宣告, 阻塞) rather than nominal chains: 進行設定 → 設定; 對……進行處理 → 處理……. +- Keep the source's directness. Where the English says "don't", the Chinese says 不要, + not a hedge like 或許可以考慮避免. +- Use Taiwan function words: 透過 for "via / through" (通過 means "to pass" a check), + 和/與 for "and", 如果/若 for "if", 即可/就好 to close an instruction lightly. + +Avoid — these are the marks of a machine, converted or customer-service translation: + +- 您 and its whole register: 溫馨提示, 敬請, 感謝您的耐心, 親愛的使用者. +- Mainland computing vocabulary and colloquialisms, even in Traditional characters: + 服務器, 數據庫, 默認, 信息, 視頻, 質量 (for quality), 反饋, 渠道, 激活, 立馬, 挺好. +- Formal padding (進行……操作, 對……進行……) and document-speak (本文件旨在, 使用者應, 茲). +- English-shaped Chinese: possessive chains (你的伺服器的工具的 schema), 被 passives + where a topic–comment sentence is natural ("The tool is called by the model" → + 模型會呼叫這個工具), a translated connective (然而, 因此, 此外) at the start of every + sentence, and 它 standing in for every "it". +- Internet slang from either side of the strait (神器, 保姆級, 給力, 超好用, 就醬), and + sentence-final particles 喔/囉/啦/耶. + +Example — English: "You don't construct it and you don't configure it. You ask for it." + +- Not this (您 + officialese): 您無需對其進行建構及配置,僅需提出請求即可。 +- Not this either (Mainland casual): 你不用创建它也不用配置它,直接要就完事了。 +- This: 不需要自己建立,也不需要設定,只要開口要就好。 + +## 3. Humour and idioms + +- The English is friendly and dry rather than jokey; carry the friendliness, recast the + idioms. Never translate a pun, idiom or aside literally: say what it means as a short, + natural sentence in the same register. If an aside carries no information you may drop + it — but never drop a technical caveat that happens to be phrased lightly. +- Recurring English tags get fixed renderings: "**[X](…)** has the whole story" / "The + whole story is in **[X](…)**" → 完整說明請見 **[X](…)**; "That's the whole API." / + "That's the whole protocol." → 整個 API 就這樣。/ 整個協定就這樣。; "That's it. It's + just Python." → 就這樣,就只是 Python 而已。; "That's a complete MCP server." → + 這就是一個完整的 MCP 伺服器。 +- Idioms take the plain meaning, not the picture: "Out of the box the app answers + **only** requests addressed to localhost." → 預設情況下,這個應用程式**只**回應送往 + localhost 的請求。— not the literal 開箱即用. +- Emoji and exclamation marks: keep the source's rare, deliberately placed emoji exactly + where they are — two payoff lines end in ✨ ("You get `3` back. ✨") — and never add + new ones. Do not add an exclamation mark to a plain payoff sentence; where one is kept + it is the full-width !, never doubled, never in a heading. +- Worked examples (source → good / bad): "You get `3` back. ✨" → 得到的結果是 `3`。✨ + / 您將獲得3!✨ (您, stiff 獲得, missing Han–Latin spacing, added exclamation). "Give a + parameter a default value and it stops being required. That's it. It's just Python." + → 替參數加上預設值,它就不再是必填。就這樣,就只是 Python 而已。/ + 給一個參數一個默認值,然後它就停止是必需的了。就是它。它只是Python而已!(Mainland 默認, + English-shaped 它 chain, missing spacing, added exclamation). + +## 4. Typography + +- Chinese prose takes full-width punctuation: ,。:;!?、() with the dash —— and + the ellipsis ……; enumerations use 、 ("a, b, and c" → a、b 和 c). Parentheses are + full-width () even around Latin text, as in the first-use gloss 取樣(sampling). + Punctuation inside code spans, code blocks, commands, URLs and quoted English text + stays half-width and untouched. An English em-dash aside is usually better recast with + ,, () or a second sentence than kept as ——; a colon introducing a code block, list + or example becomes : or a full sentence ending in 。. +- Quotation marks are the corner brackets 「」, with 『』 nested inside; never “ ” or + ‘ ’ in Chinese text, and never 「」 around a code span. Titles of works take 《》. +- Put one half-width space between Han characters and any run of Latin letters or + digits — an English word, a number, an inline code span, a link whose text is Latin + (使用 Streamable HTTP 傳輸; 需要 Python 3.10+; 會收到 `Context`); put no space between + a full-width punctuation mark and adjacent Latin text (設定好 stdio。). Keep the spaces + around Markdown markers (`**…**`, links) exactly as the source has them. +- No italics in Chinese text. Where the source italicises a word for emphasis, use + **bold**; where it italicises an example utterance or a hypothetical question the user + might see, wrap it in 「」 instead. Keep bold on the same words the source bolds — a + bolded negation ("**not**" → **不是** / **不會**) stays bold. Emphasis markers around + text that stays in English are copied as-is. +- Digits stay half-width Arabic numerals (3 個工具, not 三個). Protocol revision strings + such as `2026-07-28` and `2025-11-25` are identifiers, copied byte-for-byte — never + 2026 年 7 月 28 日, never 2026/07/28. Version numbers, HTTP status codes, ports, error + codes, and RFC and SEP numbers are copied exactly. A Latin unit takes a space (10 MB, + 30 s); % attaches with none; a Chinese unit or measure word needs none (5 秒, 3 個). +- Line breaks: never put a newline between two Chinese characters (Han or full-width + punctuation), not even after 。 — the renderer turns it into a stray space. Where the + English wraps a paragraph, list item or admonition body over several lines, or gives + each sentence its own line, write the Chinese on one line, sentence after sentence; + block structure and indentation otherwise stay as in the source. The home page's + opening note puts "New to v2…", "Still on v1.x?…" and "Something rough or confusing?…" + on three lines; in Chinese that body is the single indented line + 剛接觸 v2……破壞性變更。還在用 v1.x?……。哪裡卡住或看不懂?…… — three indented lines + there are wrong. A prose line ending in a Chinese character followed by a line of the + same block starting with one is always a defect: join the two. + +## 5. Terminology pointer + +The termbase `glossary.json` is injected separately and overrides anything written here. +This section fixes the conventions it assumes and the Taiwan forms it does not pin: + +- Terms in the glossary's keep list, and any other English word left in Latin script, + are copied exactly as spelled, always singular, with no article and no plural "s": + "the URIs" → URI, "schemas" → schema. Everything in code font, plus API names, class, + function and parameter names, protocol method and message strings (`tools/call`, + `notifications/...`), header names, error codes, SEP numbers and product names, stays + in Latin script inline. A glossary term used as a code-font identifier stays Latin + even though its prose noun is translated: "the `sampling` capability" → `sampling` 能力. +- Text quoted from what the example code prints or displays — an output line, a log + message, an error string, a UI label such as the Inspector's **Tools** tab — stays + exactly as the code emits it (usually English), in or out of code font; do not + translate it or add a Chinese reading. +- First-use gloss: a translated MCP concept the reader may need to map back to the + English specification carries the English in full-width parentheses on its first + occurrence on a page — 取樣(sampling), 徵詢(elicitation) — and appears alone after + that. Each glossary entry's note says whether the term takes the gloss. +- One rendering per term per page: the glossary target, every time — also where an + entry's note marks the choice as open or provisional; never pick per sentence. +- Taiwan forms for words the glossary leaves unpinned (the bracketed Mainland form is + not used): 程式 program〔程序, which means "procedure" in Taiwan〕, 應用程式 + application〔never bare 應用 as a noun〕, 資料庫 database〔數據庫〕, 欄位 field〔字段〕, + 範例 example〔示例〕, 範本 template〔模板〕, 文件/說明文件 documentation〔文檔〕, 設定 + configure, settings〔配置, which means "allocate"〕, 建立 create〔創建〕, 啟用/停用 + enable, disable〔激活, 禁用〕, 介面 interface〔接口〕, 連結 link〔鏈接〕, 登入 log + in〔登錄〕, 標頭 header〔請求頭〕, 逾時 timeout〔超時〕, 連接埠 port〔端口〕, 執行緒 + thread〔線程〕, 處理程序 process〔進程〕, 權杖 token in the OAuth sense〔令牌〕, 身分 + identity, 疑難排解 troubleshooting〔故障排除, 排查〕, 偵錯 debug〔調試〕, 印出 + print〔打印〕, 圖示 icon〔圖標〕, 音訊 audio〔音頻〕, 型別 type in the data-type + sense〔類型 is fine for "kind of"〕, 套件 package〔包〕, 相容 compatible〔兼容〕, 串流 + stream〔流〕, 對話 conversation〔會話〕. In a table, a row is 列 and a column is 欄 — + the reverse of Mainland usage. + +## 6. Provisional note + +The register, voice and terminology decisions above, and every entry in `glossary.json`, +are provisional pending review by native readers in Taiwan. To propose a change — a +better rendering, a rule that produces awkward Chinese, a missing or wrong Taiwan form — +edit this file or `glossary.json` in a pull request, ideally with a short good/bad +example; never edit the generated `pages/` or `notices.md`, which the next run overwrites. diff --git a/i18n/zh-hant/notices.md b/i18n/zh-hant/notices.md new file mode 100644 index 0000000000..5203c49aef --- /dev/null +++ b/i18n/zh-hant/notices.md @@ -0,0 +1,20 @@ +--- +translation: + sections: [aff1b3e872b7876a, 4d80558ad052d586, 0bb81f1e62062d26, d5c35dcec50156bc] + tool: 1 +--- +# 翻譯說明 {#translation-notices} + +翻譯版說明文件網站的每一頁頂端都會出現以下其中一則說明。 + +## 機器翻譯 {#translated} + +本頁是從英文說明文件自動翻譯而來,以[英文頁面](ENGLISH_PAGE)為準。如果哪裡讀起來不對勁,[翻譯](TRANSLATIONS_PAGE)有說明如何回報。 + +## 翻譯落後於英文頁面 {#outdated} + +這份翻譯完成之後,英文頁面又有更動,因此部分內容可能已經過時。有疑問時請讀[英文頁面](ENGLISH_PAGE);[翻譯](TRANSLATIONS_PAGE)有說明翻譯版說明文件的運作方式。 + +## 以英文顯示 {#english} + +本頁目前沒有翻譯,所以你讀到的是英文版。[翻譯](TRANSLATIONS_PAGE)有說明翻譯版說明文件的運作方式。 diff --git a/i18n/zh-hant/pages/advanced/apps.md b/i18n/zh-hant/pages/advanced/apps.md new file mode 100644 index 0000000000..42fe76af67 --- /dev/null +++ b/i18n/zh-hant/pages/advanced/apps.md @@ -0,0 +1,121 @@ +--- +translation: + sections: [0355618e5f4d5fe4, 1821eaf50f2d0b64, 82e0b28ebd3abf5a, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487] + tool: 1 +--- +# MCP Apps {#mcp-apps} + +**MCP App** 是一個有門面的工具:除了資料之外,工具還會指向一份 HTML 文件,由 MCP 主機(host)把它繪製成可互動的介面。 + +兩個部分,永遠都是兩個部分: + +1. **一個工具**,負責做事並回傳資料,跟任何其他工具一樣。 +2. **一個 `ui://` 資源**,裡面裝著主機要為它顯示的 HTML。 + +工具帶有一個指向該資源的 `_meta.ui.resourceUri` 參照。主機用 `resources/read` 取得它,在**沙箱化的 iframe** 裡繪製,再透過 `postMessage` 把工具的結果推進那個 iframe。伺服器從頭到尾不會送出或收到任何 `ui/*` 訊息:那些流量是主機和 iframe 之間的事。你提供一個工具和一份 HTML 文件,場面由主機負責。 + +SDK 以內建的 `Apps` 擴充功能(`io.modelcontextprotocol/ui`)提供這項功能。如果還不熟悉[擴充功能](extensions.md),先快速看過那一頁。一分鐘就好,看完再回來。 + +## 有錶面的時鐘 {#a-clock-with-a-face} + +```python title="server.py" hl_lines="19 22 30 32" +--8<-- "docs_src/apps/tutorial001.py" +``` + +四個動作: + +* `Apps()`:一個實例容納所有綁定 UI 的工具和它們的資源。 +* `@apps.tool(resource_uri="ui://clock/app.html")`:一個普通的工具,外加 `_meta.ui.resourceUri` 標記。`@mcp.tool()` 接受的所有東西(name、title、description……)都會原樣傳下去。 +* `apps.add_html_resource("ui://clock/app.html", CLOCK_HTML)`:對應的資源,以 `text/html;profile=mcp-app` 提供。正是這個 MIME 型別告訴主機「這是個 app,把它繪製出來」。 +* `MCPServer("clock", extensions=[apps])`:選擇加入。伺服器現在會在 `capabilities.extensions` 底下宣告 `io.modelcontextprotocol/ui`。 + +HTML 本身會監聽主機的 `postMessage` 並顯示結果。真正的 app 請在 HTML 裡使用官方的 [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps) 瀏覽器 SDK。它提供 `ontoolresult`、`callServerTool`、`getHostContext` 和 `onhostcontextchanged`,不用自己處理原始的訊息事件。 + +## 優雅降級 {#graceful-degradation} + +不是每個用戶端都會繪製 app。規格對這代表什麼講得很直白: + +> Tools **MUST** return a meaningful `content` array even when UI is available. + +模型讀的是 `content`;iframe 是給人看的。支援 UI 的主機照樣會把文字結果餵給模型,而純文字用戶端**只**會拿到那個。所以標準做法是一個工具,兩種答案。再看一次 `get_time`: + +```python title="server.py" hl_lines="23-27" +--8<-- "docs_src/apps/tutorial001.py" +``` + +只有當用戶端宣告了 `io.modelcontextprotocol/ui` 擴充功能,**而且**在它的 `mimeTypes` 設定裡列出 `text/html;profile=mcp-app` 時,`client_supports_apps(ctx)` 才會是 `True`。這個欄位是必填的,所以省略它的用戶端不算數。同一個檔案裡的 `main()` 宣告的正是這些:協商的用戶端那一半,於是回來的是豐富版的答案。 + +!!! warning + 絕對不要把 `"[Rendered UI]"` 這類佔位文字當成唯一的內容回傳。如果後備文字沒有用,這個工具對每個純文字用戶端、對模型本身就都沒有用。好好寫那句話。 + +## 把 iframe 鎖緊 {#locking-the-iframe-down} + +安全相關的中繼資料放在資源這一側:iframe 可以載入什麼、想要哪些瀏覽器權限、希望怎麼被嵌入: + +```python title="server.py" hl_lines="9 19-22" +--8<-- "docs_src/apps/tutorial002.py" +``` + +`csp` 和 `permissions` 是**對主機的請求**,不是伺服器的行為。主機用它們建構 iframe 的 Content-Security-Policy 和 Permissions-Policy,而且可能拒絕。在 JS 裡做功能偵測,不要假設一定會獲准。 + +`ResourceCsp` 逐欄位說明(Python 名稱、線路上的鍵、主機拿它做什麼): + +| Python | 線路(`_meta.ui.csp`) | 控制 | +|---|---|---| +| `connect_domains` | `connectDomains` | `connect-src`:`fetch`/XHR 可以連去哪裡 | +| `resource_domains` | `resourceDomains` | `img-src`、`style-src`……:靜態資產 | +| `frame_domains` | `frameDomains` | `frame-src`:巢狀 iframe | +| `base_uri_domains` | `baseUriDomains` | `base-uri`:`` 可以指向哪裡 | + +`ResourcePermissions`:每個欄位替 iframe 請求一項瀏覽器權限。 + +| Python | 線路(`_meta.ui.permissions`) | +|---|---| +| `camera` | `camera` | +| `microphone` | `microphone` | +| `geolocation` | `geolocation` | +| `clipboard_write` | `clipboardWrite` | + +!!! note + CSP 和權限放在**資源**上,永遠不放在工具上。規格的工具中繼資料沒有它們的位置,放在那裡主機也會忽略。SDK 讓這個錯誤根本寫不出來:`@apps.tool()` 就是沒有 `csp` 參數。 + +### 可見性 {#visibility} + +工具上的 `visibility=["app"]` 表示「這是為 iframe 存在的,不是為模型」: + +* `"model"`:模型可以呼叫它。 +* `"app"`:iframe 可以呼叫它(透過 `callServerTool`)。 +* 省略:兩者皆可,這是預設值。 + +過濾是**主機**的工作。伺服器在 `tools/list` 裡照常列出僅限 app 的工具;主機負責對模型隱藏它們。不要在伺服器端過濾。 + +## SDK 強制執行的規則 {#the-rules-the-sdk-enforces} + +這些全都在啟動時就失敗,不會等到上線: + +* `resource_uri` 或資源 URI 不是 `ui://...`,會在裝飾/註冊時引發 `ValueError`。 +* 工具綁定到一個**沒有對應已註冊資源**的 URI,會在 `MCPServer(extensions=[apps])` 取用這個擴充功能時引發 `ValueError`。一個宣稱有 HTML、`resources/read` 卻 404 的工具是設定錯誤,所以它拒絕建構。 +* `@apps.tool()` 上的 `meta={"ui": ...}` 是 `ValueError`。`_meta["ui"]` 歸裝飾器管;要表達請用 `resource_uri=` 和 `visibility=`。其他的 `meta=` 鍵可以正常一起合併。 + +目前 TypeScript 的 ext-apps SDK 和 FastMCP 都不會攔下這些;我們寧可讓你比主機早一步發現。 + +## 不只是行內 HTML {#beyond-inline-html} + +`add_html_resource` 涵蓋常見情況:一段 HTML 字串。其他情況,像是磁碟上的 HTML 或動態產生的內容,就自己建立資源再交出去: + +```python title="server.py" hl_lines="12 18" +--8<-- "docs_src/apps/tutorial003.py" +``` + +資源沒有明確設定 MIME 型別時,`add_resource` 會補上 `text/html;profile=mcp-app`;明確設定卻不相符的則會拒絕:掛在任何其他 MIME 型別底下的 `ui://` 資源,沒有任何主機會繪製。 + +!!! tip + 目標是某個 GA 前的主機,還在讀已棄用的扁平 `_meta["ui/resourceUri"]` 鍵?自己合併進去:`@apps.tool(resource_uri="ui://x", meta={"ui/resourceUri": "ui://x"})`。巢狀的 `ui` 物件才是規格的形狀;扁平鍵正在退場。 + +## 看它跑起來 {#see-it-run} + +`examples/stories/` 裡的 `apps` 故事就是這一頁的可執行版本,成對出現:一個帶有綁定 UI 時鐘工具的伺服器,以及一個會協商 Apps、讀取工具的 `_meta.ui.resourceUri`、取得 HTML 並呼叫工具的用戶端。 + +```bash +uv run python -m stories.apps.client +``` diff --git a/i18n/zh-hant/pages/advanced/extensions.md b/i18n/zh-hant/pages/advanced/extensions.md new file mode 100644 index 0000000000..d90e61c289 --- /dev/null +++ b/i18n/zh-hant/pages/advanced/extensions.md @@ -0,0 +1,172 @@ +--- +translation: + sections: [05891e7cc1938a13, b3c01a6af28c51ee, 7ffc91f5e38bdfe0, 717d3f235a8333a7, f471a13b2fe5d737, ed6af2df4b656dff] + tool: 1 +--- +# 擴充功能 {#extensions} + +**擴充功能**是掛在單一識別碼之下、需要主動啟用的一組 MCP 行為。 + +在伺服器上,它可以貢獻工具、資源和新的請求方法,也可以包裹 `tools/call`。在用戶端上,它可以認領額外的 `tools/call` 結果形狀,並觀察廠商通知。兩端各自在自己的 `capabilities.extensions` 底下宣告,對沒有要求它的人來說什麼都不會改變。這就是契約([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)),而它有一條黃金法則:**擴充功能預設是關閉的**。 + +## 使用擴充功能 {#using-an-extension} + +在建構時傳入實例: + +```python title="server.py" +--8<-- "docs_src/extensions/tutorial001.py" +``` + +完成。伺服器現在會在 `capabilities.extensions` 底下宣告 `io.modelcontextprotocol/ui`,並提供這個擴充功能貢獻的所有內容。 + +`Apps` 是內建的參考擴充功能,它有自己的專頁:**[MCP Apps](apps.md)**。 + +!!! note + 擴充功能在建構時就固定了。沒有之後可以呼叫的 `add_extension`:伺服器的能力對映表在用戶端連線期間不應該改變。 + +能力對映表透過 `server/discover` 傳遞,而這是 **2026-07-28** 的路徑。舊版的 `initialize` 交握沒有地方可以放它,所以舊版用戶端根本看不到這個擴充功能。設計時要考慮到這一點:擴充功能是用來**增強**伺服器的,不能成為伺服器唯一可用的方式。 + +## 撰寫自己的擴充功能 {#writing-your-own} + +繼承 `Extension`,只覆寫需要的部分。每個方法都有預設實作。 + +### 識別碼 {#the-identifier} + +```python +--8<-- "docs_src/extensions/tutorial002.py" +``` + +識別碼是遵循規格 `_meta` 鍵語法的 `vendor-prefix/name` 字串:以點分隔的標籤(每個標籤以字母開頭,以字母或數字結尾)、一個斜線,接著是名稱。它在**類別定義時**就會驗證,所以打錯字不用等到伺服器啟動才發現: + +```text +TypeError: Stamps.identifier must be a `vendor-prefix/name` string +(reverse-DNS prefix required), got 'stamps' +``` + +前綴請用你能掌控的網域。`io.modelcontextprotocol/*` 保留給 MCP 專案本身制定的擴充功能。 + +### 貢獻工具 {#contributing-tools} + +最小的有用擴充功能是一個工具加上一個設定對映表: + +```python title="server.py" hl_lines="17 19-20 22-23 26" +--8<-- "docs_src/extensions/tutorial003.py" +``` + +* `tools()` 回傳 `ToolBinding`。伺服器註冊每一個的方式,和你自己呼叫 `mcp.add_tool(...)` 完全一樣:同樣的 schema 產生、同樣的 `Context` 注入,全部都一樣。 +* `settings()` 是在 `capabilities.extensions["com.example/stamps"]` 宣告的值。回傳 `{}`(預設值)表示宣告這個擴充功能但不帶任何設定。 +* 擴充功能永遠不會拿到伺服器。它以資料的形式宣告貢獻,由 `MCPServer` 取用。沒有 `self.server` 可以修改。 + +而 `main()` 就是證明,一個記憶體內用戶端直接連上 `mcp`: + +```python title="server.py" hl_lines="29-34" +--8<-- "docs_src/extensions/tutorial003.py" +``` + +### 提供自己的方法 {#serving-your-own-methods} + +擴充功能可以註冊**新的請求方法**:屬於它自己的動詞,和規格定義的方法並列提供: + +```python title="server.py" hl_lines="16-22 31 40-48" +--8<-- "docs_src/extensions/tutorial004.py" +``` + +* `SearchParams` 繼承 `RequestParams`,所以 2026 的 `_meta` 信封能以一致的方式解析,處理函式拿到的是驗證過的參數,永遠不會是原始 dict。對用戶端能控制的東西設下界限:`Field(ge=1, le=100)` 會在你的程式碼為它配置任何東西之前,就拒絕離譜的 `limit`。 +* `require_client_extension(ctx, EXTENSION_ID)` 是關卡:沒有宣告這個擴充功能的用戶端會收到 `-32021`(缺少必要的用戶端能力)錯誤,附帶規格要求的機器可讀 `requiredCapabilities` 內容。 +* `protocol_versions=frozenset({"2026-07-28"})` 把這個方法釘在單一線路版本上。在其他任何版本,用戶端會收到 `METHOD_NOT_FOUND`,就像這個方法在那裡不存在一樣。對那個用戶端來說,它確實不存在。 + +方法是**嚴格附加的**。SDK 在建構時就強制這一點,而不是在執行時: + +* 為規格定義的方法(`tools/list`、`completion/complete`……)建立的 `MethodBinding`,在繫結建構時就會引發 `ValueError`。核心動詞屬於伺服器。 +* 兩個擴充功能繫結同一個方法時,第二個註冊時會引發例外。後寫者勝出正是外掛互相搞壞對方的方式;我們不這麼做。 +* 空的 `protocol_versions` 集合也會引發例外:一個永遠無法被提供的方法是 bug,不是設定。 + +### 用戶端這一側 {#the-client-side} + +同一個檔案的 `main()` 就是完整的用戶端故事,兩半都在裡面: + +```python title="server.py" hl_lines="54-58" +--8<-- "docs_src/extensions/tutorial004.py" +``` + +* `Client(..., extensions=[advertise(EXTENSION_ID)])` 宣告這個擴充功能。這些宣告會變成 `ClientCapabilities.extensions`:在 2026-07-28 連線上,這個對映表隨著每個請求的 `_meta` 信封傳送,所以伺服器在**每一個**請求上都看得到它;在舊版連線上,它則搭著 `initialize` 交握傳送。伺服器程式碼不用在意是哪一種:`require_client_extension(ctx, ...)` 和 `ctx.session.check_client_capability(...)` 在兩條路徑上都會讀取正確的來源。 +* 廠商方法要往下一層用 `client.session.send_request(...)`;`Client` 只會為規格動詞長出一級方法。`send_request` 接受任何 `Request` 子類別,所以廠商請求原樣傳遞即可。 + +### 攔截 `tools/call` {#intercepting-toolscall} + +唯一一個攔截式的掛鉤。覆寫 `intercept_tool_call` 來觀察、短路或否決工具呼叫: + +```python title="server.py" hl_lines="17-24" +--8<-- "docs_src/extensions/tutorial005.py" +``` + +* `params` 是驗證過的 `CallToolRequestParams`:不用碰原始 JSON 就能拿到 `params.name` 和 `params.arguments`。決定執行哪個工具呼叫的也是它:透過 `call_next` 傳入改寫過的上下文,改變的是處理函式在 `ctx` 上觀察到的東西,而不是工具的呼叫本身。線路層級的請求改寫屬於[中介軟體](middleware.md)的範疇。 +* `call_next(ctx)` 執行鏈的其餘部分並回傳處理函式的結果。原樣回傳(觀察)、回傳別的東西(取代),或引發 `MCPError`(拒絕)。不管回傳什麼,都會像任何處理函式結果一樣序列化,包括 2026 世代的 `serverInfo` 身分戳記,所以短路的攔截器永遠不會產生匿名或不符 schema 的回應。 +* 有多個擴充功能時,攔截器依註冊順序巢狀套疊:`extensions=[...]` 裡的第一個擴充功能在最外層。 +* 預設實作是直接放行,而擴充功能從未覆寫這個掛鉤的伺服器,會保持原本的 `tools/call` 處理函式不動。沒用到的東西不用付出代價。 + +這個掛鉤只包裹 `tools/call`,別無其他。需要處理每一則訊息的事情,請用[中介軟體](middleware.md)。那正是它的用途。 + +## 使用用戶端擴充功能 {#using-a-client-extension} + +**用戶端擴充功能**是從使用端看的同一份契約:掛在單一識別碼之下的一組用戶端行為。把實例傳給 `Client(extensions=[...])`,然後照常呼叫工具: + +```python title="client.py" hl_lines="66-68" +--8<-- "docs_src/extensions/tutorial006.py" +``` + +`call_tool("buy", ...)` 回傳普通的 `CallToolResult`,和其他所有呼叫一樣。擴充功能改變的是:伺服器現在可以用 `receipt` **結果形狀**來回應 `buy`,而不是最終結果,而 `Receipts` 會在 `call_tool` 回傳之前把它完成(這裡是透過後續呼叫兌換收據)。呼叫端的程式碼完全不用動。 + +拿掉這個擴充功能,這一切就不存在:伺服器的關卡會拒絕沒有宣告它的用戶端(錯誤 -32021),而來自跳過關卡的伺服器的認領形狀會驗證失敗,完全符合規格對無法辨識的 `resultType` 的要求。預設關閉,線路的兩端都是。 + +要宣告一個**沒有**任何用戶端行為的識別碼(伺服器以這個能力為關卡,用戶端什麼都不做,就像上面的搜尋用戶端),使用 `advertise()`: + +```python +from mcp.client import advertise + +client = Client(mcp, extensions=[advertise("com.example/search")]) +``` + +## 撰寫用戶端擴充功能 {#writing-a-client-extension} + +繼承 `ClientExtension`,只覆寫需要的部分。三種貢獻類型,各有預設實作:`settings()`、`claims()` 和 `notifications()`。 + +```python title="client.py" hl_lines="17-18 43-44 46-47" +--8<-- "docs_src/extensions/tutorial006.py" +``` + +* 識別碼遵循和伺服器相同的語法,在類別定義時驗證。 +* `claims()` 回傳 `ResultClaim`:一個線路標籤、解析它的模型,以及完成它的解析器。模型必須用 `result_type: Literal["receipt"]` 釘住標籤,而且不可以繼承該動詞的核心結果型別;兩者都在認領建構時強制檢查。像 `receipt_token` 這樣的廠商欄位原樣走線路:被替換的形狀會一字不差地抵達用戶端。 +* 解析器會收到解析過的模型和一個 `ClaimContext`;`ctx.session` 和 `client.session` 是同一個公開控制柄,所以後續動作就是一般的工作階段(session)呼叫。它回傳該動詞正常的 `CallToolResult`。 +* `settings()` 是在 `ClientCapabilities.extensions[identifier]` 宣告的值,在 `Client` 建構時讀取一次。 + +`notifications()` 宣告要觀察的廠商伺服器通知: + +```python +def notifications(self) -> Sequence[NotificationBinding[Any]]: + return [NotificationBinding(method="notifications/receipts", params_type=ReceiptEvent, handler=self.on_receipt)] +``` + +處理函式一次收到一個驗證過的參數,依分派順序。它只觀察;不能否決或回覆。 + +兩條低調的規則。認領只在 2026-07-28 連線上生效,而能力宣告跟著它們走:在舊版連線上,認領會消失,識別碼也會跟著從宣告中移除,所以用戶端永遠不會宣告一個它會拒絕其形狀的擴充功能。另外,當你想自己拿到認領的形狀而不是交給解析器時,呼叫 `client.session.call_tool(..., allow_claimed=True)`;沒有這個旗標時,認領形狀抵達工作階段層的呼叫端會引發 `UnexpectedClaimedResult`。 + +### 擴充功能動詞 {#extension-verbs} + +擴充功能自己的請求方法不需要用戶端註冊。廠商請求型別繼承 `mcp.types.Request`,並透過 `client.session.send_request` 送出,如[提供自己的方法](#serving-your-own-methods)所示。多一件事:當某個參數鍵必須搭上 `Mcp-Name` 標頭時(像 tasks 這類擴充功能規格對它們的動詞有此要求),請求型別要宣告 `name_param`: + +```python title="client.py" hl_lines="22-25 46-47" +--8<-- "docs_src/extensions/tutorial007.py" +``` + +工作階段會在每一條送出路徑上把 `params["jobId"]` 鏡射到 `Mcp-Name`,而缺少值時會明確失敗,而不是默默省略必要的標頭。 + +## 擴充功能不能做的事 {#what-an-extension-cannot-do} + +貢獻介面是刻意**封閉**的。在伺服器上:設定、工具、資源、方法、一個 `tools/call` 攔截器。在用戶端上:設定、結果認領、通知繫結。擴充功能不能: + +* **伸手進外層的伺服器或用戶端。**它只宣告資料,不持有任何伺服器或用戶端的參考。 +* **取代核心行為。**規格方法和核心結果標籤在建構時就會被拒絕(`initialize` 直接由執行器保留);被核心詞彙遮蔽的通知繫結則會靜默並發出警告。 +* **延後註冊。**`MCPServer(...)` 或 `Client(...)` 回傳之後,擴充功能集合就定了。 + +如果你在跟這些牆對抗,你寫的就不是擴充功能,而是 fork。這些牆本身就是功能:讀到 `extensions=[Apps(), Stamps()]` 的使用者,就知道這兩個東西**所有**可能碰過的地方。 diff --git a/i18n/zh-hant/pages/advanced/index.md b/i18n/zh-hant/pages/advanced/index.md new file mode 100644 index 0000000000..bef6cd1cde --- /dev/null +++ b/i18n/zh-hant/pages/advanced/index.md @@ -0,0 +1,22 @@ +--- +translation: + sections: [ca6988b7503cd2d3] + tool: 1 +--- +# 進階 {#advanced} + +一般的伺服器或用戶端需要的東西,在前面的章節裡都有對應的主題可循。這一節是當 `MCPServer` 的便利層反而礙事時,可以拿來用的逃生門: + +* **[低階 Server](low-level-server.md)**:`MCPServer` 建構於其上的類別。手寫的 schema、`on_*` 處理函式、沒有任何東西會幫你檢查,還可以加上自訂的 JSON-RPC 方法。 +* **[分頁](pagination.md)** 和 **[中介軟體](middleware.md)**:兩件**只**能在低階 `Server` 上做的事。 +* **[擴充功能](extensions.md)** 和 **[MCP Apps](apps.md)**:協定的擴充介面。把擴充功能套件組合進伺服器,或自己寫一個。 + +有幾樣東西你可能理所當然會來這裡找,但它們其實放在實際會用到的地方: + +* **授權** 放在 **[執行伺服器](../run/index.md)** 底下,因為伺服器部署在哪裡,就在哪裡保護它。 +* **OAuth**、**身分斷言**、連接 **多個伺服器**,以及回應 **快取**,都在 **[用戶端](../client/index.md)** 底下。 +* **多輪往返(multi-round-trip)請求** 和 **訂閱** 放在 **[在處理函式內部](../handlers/index.md)** 底下,因為兩者都是處理函式 **會做** 的事。 +* **URI 範本** 放在 **[伺服器](../servers/index.md)** 底下,就在資源旁邊。 +* **[協定版本](../protocol-versions.md)** 和 **[已棄用的功能](../deprecated.md)** 則各有自己的頂層頁面。 + +如果不確定自己需不需要這一節,那就是不需要。 diff --git a/i18n/zh-hant/pages/advanced/low-level-server.md b/i18n/zh-hant/pages/advanced/low-level-server.md new file mode 100644 index 0000000000..a619753879 --- /dev/null +++ b/i18n/zh-hant/pages/advanced/low-level-server.md @@ -0,0 +1,206 @@ +--- +translation: + sections: [2c79b6338e09b7ac, 7edc43b3fae11314, 1086e77ce561cd7f, a3f71823df5efc31, 9fc7109f72201cae, 7bf25983df655b66, 6330e1f4c6029683, 2f1749c8c133fa1c, b3530fcf4d11fd56, ebc33704fbd74262, cd0e9c933350390e] + tool: 1 +--- +# 低階 Server {#the-low-level-server} + +`@mcp.tool()` 是一層包裝。底下還有第二個伺服器類別 `Server`,講的是原始的 MCP:把協定物件交給它,它就原封不動地放上線路。 + +`MCPServer` 就是建構在它之上。當便利層礙事時,才往下走: + +* 需要送出**精確**的 schema(從檔案載入、從資料庫產生),而不是從 Python 簽章推導出來的。 +* 需要完全掌控結果:`_meta`、`is_error`、`structured_content` 的每一個鍵。 +* 需要處理 MCP 沒有定義的方法。 + +其他情況,就留在 `MCPServer`。 + +## 同一個工具,手工打造 {#the-same-tool-by-hand} + +這是 **[工具](../servers/tools.md)** 用九行 `@mcp.tool()` 寫出的 `search_books` 工具,拿掉語法糖之後的樣子: + +```python title="server.py" hl_lines="22 26 32" +--8<-- "docs_src/lowlevel/tutorial001.py" +``` + +改了三件事,而這三件事就是整個低階 API: + +* **處理函式是建構子參數。** `on_list_tools=` 和 `on_call_tool=` 傳進 `Server(...)`。這一層沒有裝飾器,而且每個處理函式的形狀都一樣:`async (ctx, params) -> result`。 +* **輸入 schema 自己寫。** `Tool.input_schema` 是普通的 JSON Schema `dict`。沒有人會從型別提示推導它,因為根本沒有型別提示可以推導。 +* **結果自己組。** `CallToolResult(content=[TextContent(...)])`,手動建立。沒有任何東西會被包裝、轉換,或從回傳註記推斷出來。 + +`params` 是解析後的請求:`CallToolRequestParams` 提供 `.name` 和 `.arguments`。`ctx` 是 `ServerRequestContext`:`ctx.session` 用來回頭和用戶端溝通,還有 `ctx.lifespan_context`、`ctx.request_id`,以及 `ctx.meta`,也就是請求傳入的 `_meta`。 + +!!! info + 如果用過 FastAPI,這個關係你早就認識了。`MCPServer` 是裝飾器加型別提示的那一層;`Server` 是底下的 Starlette。兩者不是競爭對手:`MCPServer` 會建立一個 `Server`,並在上面註冊和這些一模一樣的處理函式。 + +### 試試看 {#try-it} + +這個沒有 Inspector 可用:`mcp dev` 和 `mcp run` 只接受 `MCPServer`。記憶體內的 `Client` 則不在乎;它接收低階 `Server` 的方式和接收 `MCPServer` 完全一樣: + +```python title="main.py" +import asyncio + +from mcp import Client + +from server import server + + +async def main() -> None: + async with Client(server) as client: + result = await client.call_tool("search_books", {"query": "dune", "limit": 5}) + print(result.content) + + +asyncio.run(main()) +``` + +```text +[TextContent(type='text', text="Found 3 books matching 'dune' (showing up to 5).", annotations=None, meta=None)] +``` + +和 `@mcp.tool()` 版本產生的文字一模一樣。坦白說有兩個差異: + +* `result.structured_content` 是 `None`。高階伺服器會幫你把 `-> str` 包成 `{"result": ...}`;在這裡,你沒建的東西,沒有人會替你建。 +* `list_tools` 回傳的是**你**打出來的 schema,一字不差。高階版本每個屬性上都有 `"title": "Query"`,根部還有一個 `"title": "search_booksArguments"`:那是 Pydantic 的產物。在這一層,線路上有的東西,都是你放上去的。 + +## 沒有人替你檢查 {#nothing-is-checked-for-you} + +`MCPServer` 會在函式執行之前就拒絕錯誤的引數,依照它產生的 schema 驗證這次呼叫(**[工具](../servers/tools.md)**)。 + +`Server` 不做這件事。你的 `input_schema` 是**公告**給用戶端看的;從來不會**套用**到 `params.arguments` 上。 + +!!! check + 呼叫 `search_books` 時不帶 `limit`,`args["limit"]` 就會引發 `KeyError`。用戶端看到的是: + + ```text + MCPError: Internal server error + ``` + + 一個 JSON-RPC 錯誤,錯誤碼 `-32603`,訊息刻意寫得很籠統:SDK 不會把你的 traceback 洩漏給遠端呼叫端。模型永遠不知道自己哪裡做錯,所以無法重試。(在測試中,`raise_exceptions=True` 會改為浮現真正的例外;請見 **[測試](../get-started/testing.md)**。) + +這可以推而廣之。從低階處理函式引發的例外**永遠**是協定錯誤,絕不會是 `is_error=True` 的工具結果。如果希望模型讀到失敗並恢復,就自己驗證 `params.arguments`,然後回傳 `CallToolResult(content=[TextContent(...)], is_error=True)`。這兩種失敗正是 **[處理錯誤](../servers/handling-errors.md)** 的主題。 + +## 兩個工具,一個處理函式 {#two-tools-one-handler} + +`on_call_tool` 是伺服器上所有工具唯一的進入點。依 `params.name` 分派: + +```python title="server.py" hl_lines="38-43" +--8<-- "docs_src/lowlevel/tutorial002.py" +``` + +* `list_tools` 公告兩者。`call_tool` 依名稱分派。 +* `else` 分支很重要:就算是你從沒列出過的名稱,`Server` 也會照樣把它的 `tools/call` 直接轉進你的處理函式。在那裡引發例外,這次呼叫就會變成和上面一樣的 `-32603`。 + +## 結構化輸出,手工打造 {#structured-output-by-hand} + +在 `Tool` 上宣告 `output_schema`,並在結果上放 `structured_content`。兩者都由你負責: + +```python title="server.py" hl_lines="19-23 36" +--8<-- "docs_src/lowlevel/tutorial003.py" +``` + +呼叫它,結果會同時帶著兩種表示法: + +```json +{ + "content": [{"type": "text", "text": "Found 3 books matching 'dune'."}], + "structuredContent": {"matches": 3, "query": "dune"}, + "isError": false, + "resultType": "complete", + "_meta": {"io.modelcontextprotocol/serverInfo": {"name": "Bookshop", "version": "2.0.0"}} +} +``` + +`_meta` 區塊是伺服器的身分戳記:SDK 會把它加到每個 2026 世代的結果上,`version` 取自建構子(沒設定的伺服器會回報空字串)。不能表明身分的伺服器可以用中介軟體把這個鍵拿掉,中介軟體擁有它回傳的結果。 + +伺服器從不比對這兩個欄位。這個 SDK 的 `Client` 會:回傳的 `structured_content` 如果不符合你宣告的 `output_schema`,`call_tool` 就會引發 `RuntimeError`,訊息以 `Invalid structured content returned by tool search_books` 開頭,接著引用 `jsonschema` 的失敗內容。承諾一個 schema 很便宜;守住承諾是你的事。回傳型別與 schema 的完整階梯請見 **[結構化輸出](../servers/structured-output.md)**。 + +## `_meta`:給應用程式,不是給模型 {#\_meta-for-the-application-not-the-model} + +`content` 是答案中模型會讀的部分。`structured_content` 是同一個答案的型別化資料。`_meta` 是第三個管道:跟著結果一起送給**用戶端應用程式**的資料,完全不屬於答案的一部分。 + +用它放紀錄 ID、追蹤 ID,任何 UI 需要而提示詞不需要的東西: + +```python title="server.py" hl_lines="37" +--8<-- "docs_src/lowlevel/tutorial004.py" +``` + +* 建構時寫成 `_meta=`,也就是線路上的名稱。用戶端讀回來時是 `result.meta`。 +* 替鍵加上命名空間(`bookshop/record_ids`)。`io.modelcontextprotocol/*` 這些鍵由協定保留。 + +!!! warning + `_meta` 是你和用戶端應用程式之間的約定,不保證什麼會送到模型。要呈現什麼由 MCP 主機(host)決定。永遠不要在工具結果的任何部分放機密。 + +## 能力跟著處理函式走 {#capabilities-follow-your-handlers} + +`Server` 公告的方法族群,恰好就是你給了處理函式的那些。上面的 `Bookshop` 只傳了 `on_list_tools` 和 `on_call_tool`,其他什麼都沒有,所以連上它的用戶端會看到: + +```json +{"tools": {"listChanged": false}} +``` + +沒有 `resources`,沒有 `prompts`:背後沒有東西支撐它們。傳入 `on_list_prompts`,`prompts` 就會出現;傳入 `on_completion`,`completions` 就會出現。 + +`MCPServer` 不管你有沒有註冊,都一律公告工具、資源和提示詞,因為它的管理器永遠存在。在這一層,宣告**就是**那個建構子呼叫。 + +## 生命週期泛型 {#the-lifespan-generic} + +`Server` 對其生命週期 yield 出的型別是泛型的。註記一次,這個物件在每個出現的地方都有型別: + +```python title="server.py" hl_lines="24-26 44-45 50" +--8<-- "docs_src/lowlevel/tutorial005.py" +``` + +* 生命週期是一個 `Callable[[Server[Catalog]], AbstractAsyncContextManager[Catalog]]`;在 `async` 產生器上套 `@asynccontextmanager` 就正好得到這個。 +* 它 `yield` 出的東西會變成 `ctx.lifespan_context`,而因為處理函式註記為 `ServerRequestContext[Catalog]`,`.search(...)` 可以自動完成,也能通過型別檢查。 +* 伺服器啟動時進入一次,停止時離開一次。啟動、收尾,以及 `MCPServer` 對同一個概念的版本,請見 **[生命週期](../handlers/lifespan.md)**。 + +沒有 `lifespan=` 的話,`ctx.lifespan_context` 是一個空的 `dict`。 + +## 自己的方法 {#a-method-of-your-own} + +建構子涵蓋 MCP 定義的方法。其他的一切由 `add_request_handler` 負責: + +```python title="server.py" hl_lines="35-36 39-40 43-44 48" +--8<-- "docs_src/lowlevel/tutorial006.py" +``` + +* 第一個引數是方法字串。通知有個孿生的 `add_notification_handler`。 +* `params_type` 是傳入的 `params` 在處理函式執行**之前**用來驗證的模型,所以自訂方法**確實**享有工具沒有的驗證。繼承 `RequestParams`,讓 `_meta` 欄位和其他方法一樣解析。 +* 處理函式回傳 `BaseModel`、`dict` 或 `None`。SDK 會把它序列化成 JSON-RPC 結果。 + +一個坦白的提醒:高階 `Client` 只有對應 MCP 定義方法的動詞,所以沒有 `client.reindex()`。廠商方法是給已經知道它存在的對端用的:你同時發佈的用戶端,或是你自己另一個講 JSON-RPC 的服務。 + +有一個方法你不能占用: + +```text +ValueError: 'initialize' is handled by the server runner and cannot be overridden; +use Server.middleware to observe or wrap initialization +``` + +交握屬於執行器。`server/discover`、`ping`,以及其他所有內建方法,都可以替換。 + +!!! tip + 那則錯誤裡提到的 `Server.middleware` 會包住**每一則**傳入訊息,包括 `initialize`。如果想做的是觀察或改寫流量,而不是回應新方法,請從 **[中介軟體](middleware.md)** 開始。 + +## 其他處理函式 {#the-other-handlers} + +下面每一項都是一個你現在已經有詞彙可以理解的概念;每一項都有自己的頁面。 + +* `on_call_tool`、`on_get_prompt` 和 `on_read_resource` 可以回傳 `InputRequiredResult` 取代正常結果,暫停呼叫並向用戶端要求輸入;請見 **[多輪往返(multi-round-trip)請求](../handlers/multi-round-trip.md)**。忠於這一層的風格,沒有任何東西會替你裝好:`MCPServer` 預設會封裝 `requestState`,在這裡你設定的 `request_state` 會一字不差地跨過線路,直到你用 `server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[...]), default_audience=server.name))` 選擇加入:一行(兩個名稱都從 `mcp.server.request_state` 匯入)就能得到和 `MCPServer` 完全相同的封裝與驗證(**[保護 `requestState`](../handlers/multi-round-trip.md#protecting-requeststate)**)。 +* `on_list_resources`、`on_read_resource`、`on_list_prompts`、`on_get_prompt`、`on_completion` 是其他基本元件的同一個 `(ctx, params) -> result` 形狀。 +* `on_subscriptions_listen` 負責 2026-07-28 的 `subscriptions/listen` 串流。傳入一個建構在 `SubscriptionBus` 之上的 `ListenHandler`,並從其他處理函式把事件發佈到 bus;完整的組合方式請見 **[訂閱](../handlers/subscriptions.md)**。 +* `server.streamable_http_app()` 回傳的 Starlette 應用程式和 `MCPServer` 的一樣;照 **[執行伺服器](../run/index.md)** 部署其他 ASGI 應用程式的方式部署它。這一層沒有 `server.run(transport=...)`:`server.run(read_stream, write_stream, server.create_initialization_options())` 透過一對串流驅動一條連線,而這一行就是全部。 + +## 重點回顧 {#recap} + +* 低階 `Server` 以 `on_*` **建構子參數**接收處理函式;每個處理函式都是 `async (ctx, params) -> result`。 +* `input_schema` dict 自己寫,`CallToolResult` 自己組。沒有任何東西會替你推導、包裝或驗證。 +* 處理函式裡的例外是 `-32603` 協定錯誤。模型讀得到的工具錯誤,是**你**回傳的 `is_error=True` 的 `CallToolResult`。 +* 結果上的 `_meta` 是給用戶端應用程式的,不是給模型的。 +* `Server[T]` 對其生命週期 yield 出的東西是泛型的;`ctx.lifespan_context` 是有型別的 `T`。 +* `add_request_handler(method, params_type, handler)` 可以服務任何方法。`initialize` 被保留。 +* `Server` 公告的能力,由你註冊了哪些處理函式推導而來。 + +`Client(server)` 對兩種伺服器一視同仁,因為它們**就是**同一個協定,這正是重點所在。再往下一層根本不是類別:是 **[中介軟體](middleware.md)**。 diff --git a/i18n/zh-hant/pages/advanced/middleware.md b/i18n/zh-hant/pages/advanced/middleware.md new file mode 100644 index 0000000000..a590a79bc7 --- /dev/null +++ b/i18n/zh-hant/pages/advanced/middleware.md @@ -0,0 +1,84 @@ +--- +translation: + sections: [6048b4f308edbb8c, 068bda0f21ee9c1b, c3e565b61acd75c5, c62422b159c6ed09, 47204fab253cc45c] + tool: 1 +--- +# 中介軟體 {#middleware} + +**中介軟體(middleware)**是一個非同步函式,包住伺服器收到的每一則訊息。 + +寫成 `async (ctx, call_next)` 的形式,再附加到 `server.middleware` 就好。整個 API 就這樣。 + +!!! warning + 中介軟體清單在原始碼裡標示為**暫定(provisional)**:它的簽章和語意可能在 2.x 的小版本中變動。用它來**觀察**(計時、記錄、追蹤)和**拒絕**訊息;不要把它當成伺服器賴以運作的基礎。 + +`MCPServer` 在建構時接收這份清單(`MCPServer(name, middleware=[...])`),並以 `mcp.middleware` 公開;低階的 `Server` 則以 `server.middleware` 公開同一份清單。下面的範例使用低階的 `Server`;如果還沒見過 `Server(name, on_call_tool=...)`,請先讀 **[低階 Server](low-level-server.md)**。 + +## 一個計時中介軟體 {#a-timing-middleware} + +一個伺服器、一個工具、一個中介軟體,記錄每則訊息花了多久: + +```python title="server.py" hl_lines="39-45 49" +--8<-- "docs_src/middleware/tutorial001.py" +``` + +* `ctx` 就是處理函式收到的同一個 `ServerRequestContext`。`ctx.method` 是原始的方法字串;`ctx.params` 是原始的參數,尚未經過**任何**驗證。 +* `call_next(ctx)` 會執行鏈上剩下的部分:驗證、查找處理函式、你的處理函式。把它的回傳值原樣回傳,回應就不會被動到。 +* `try`/`finally` 是刻意的:引發例外的處理函式一樣會被計時,因為失敗會以 `call_next` 拋出的例外形式抵達你的中介軟體。 +* `server.middleware.append(...)` 完成註冊。清單由最外層開始執行,所以 `middleware[0]` 是最靠近線路的那一個。 + +### 試試看 {#try-it} + +連上一個用戶端,列出工具,呼叫其中一個。記錄裡會有**三**行: + +```text +server/discover took 18.3 ms +tools/list took 0.1 ms +tools/call took 0.1 ms +``` + +呼叫了兩次,卻得到三行。第一行是 `server/discover`:這是用戶端為了建立連線而送出的請求,早在你要求任何東西之前。 + +重點就在這裡。中介軟體包住**每一則**傳入的訊息: + +* 連線建立階段:`server/discover`,或在舊版工作階段(session)上的 `initialize` 和 `notifications/initialized`。 +* 每一個請求和每一則通知。對通知而言,`ctx.request_id is None`,`call_next(ctx)` 回傳 `None`,而你回傳的任何東西都會被丟棄。 +* 連伺服器沒有處理函式的方法也一樣:`call_next` 會引發 `MCPError(-32601, "Method not found")`,**穿過**你的中介軟體一路送到用戶端。 + +## 在裡面能做什麼 {#what-you-can-do-inside-one} + +依照該有的猶豫程度,由低到高排列: + +* **觀察。**計時、計數、記錄。就是上面的範例。 +* **拒絕。**不呼叫 `call_next(ctx)`,**改為**引發 `MCPError`,那一則訊息就會以 JSON-RPC 錯誤回應。連線不會斷;下一則訊息照常通過。伺服器就是這樣依呼叫端控管 `subscriptions/listen` 的:訂閱頁面的 **[決定誰可以觀看](../handlers/subscriptions.md#deciding-who-may-watch)** 有逐步說明。 +* **改寫。**`ctx` 是一個 dataclass:`await call_next(dataclasses.replace(ctx, params=...))` 會把和用戶端送來的不同的參數交給鏈上剩下的部分。絕對不要對 `initialize` 這麼做:用戶端拿到的結果是根據你改寫後的參數建立的,但伺服器提交連線狀態時用的是線路上原本的參數。雙方可能在交握結束時,對彼此協商出的內容認知不一致。 +* **回答。**不呼叫 `call_next(ctx)` 就直接回傳一個結果,它會作為你的回應送到用戶端。`call_next` 交給你的是完成的線路格式,而管線絕不會修補你回傳的東西,所以整個封包都由你負責:在 2026 世代的連線上,這包括 `serverInfo` 的 `_meta` 戳記,SDK 會替處理函式的結果加上它,但不會替你的加。 + +!!! check + `initialize` 是中介軟體包住的東西之一,而且這是它**唯一**的掛鉤點。試著用 `add_request_handler` 接管它,SDK 會拒絕: + + ```text + ValueError: 'initialize' is handled by the server runner and cannot be overridden; + use Server.middleware to observe or wrap initialization + ``` + +!!! warning + `initialize` 是就地處理的:在你的中介軟體鏈回傳之前,伺服器不會再讀取任何傳入的訊息。因此在處理 `initialize` 時等待一個伺服器對用戶端的請求(`ctx.session.send_request(...)`、一次徵詢(elicitation)),會**讓連線死結**:你在等的回應永遠讀不到。射後不理的通知則沒問題。 + +## 唯一一個預設就啟用的中介軟體 {#the-one-middleware-that-ships-on-by-default} + +SDK 只附帶一個中介軟體,而且它已經在伺服器的清單上了:為每則訊息發出一個 OpenTelemetry span 的那一個。不需要自己附加,大多數時候也不用去想它。在安裝匯出器之前它什麼都不做,而且有自己的頁面:**[OpenTelemetry](../run/opentelemetry.md)**。 + +!!! info + 如果寫過 ASGI 中介軟體,這個形狀你已經認得。Starlette 的 `(scope, receive, send)` 變成了 `(ctx, call_next)`,而且它在傳輸**之後**執行,處理的是解碼後的訊息而不是原始的 HTTP 請求。兩者可以組合:掛在 `streamable_http_app()` 上的 Starlette 中介軟體看到的是 HTTP;這裡看到的是 MCP。 + +## 重點回顧 {#recap} + +* 中介軟體是 `async (ctx, call_next) -> result`,以 `MCPServer(middleware=[...])` 傳入(或附加到 `mcp.middleware`),在低階的 `Server` 上則附加到 `server.middleware`。 +* 它包住**每一則**傳入的訊息(`server/discover`、`initialize`、請求、通知、未知的方法),並由最外層開始執行。 +* 用 `ctx.request_id is None` 區分通知和請求。 +* 不呼叫 `call_next` 改為引發例外,就能拒絕一則訊息;連線會存活下來。 +* SDK 自己的 OpenTelemetry 追蹤也是一個中介軟體,已經在清單上。請見 **[OpenTelemetry](../run/opentelemetry.md)**。 +* 整個介面都是暫定的。用它來觀察;不要在它上面蓋東西。 + +以上就是包住請求的一切。至於請求到底能不能執行,則由 **[授權](../run/authorization.md)** 決定。 diff --git a/i18n/zh-hant/pages/advanced/pagination.md b/i18n/zh-hant/pages/advanced/pagination.md new file mode 100644 index 0000000000..aa46b278c3 --- /dev/null +++ b/i18n/zh-hant/pages/advanced/pagination.md @@ -0,0 +1,81 @@ +--- +translation: + sections: [a9aba7a026c7bd85, ed32bda7ba9ae33a, 7e64cc5646abb91f, 22a0129ee78b3c63, d875373c06d8d2f9] + tool: 1 +--- +# 分頁 {#pagination} + +大多數伺服器永遠用不到這個。 + +`MCPServer` 回應每個 `list_*` 請求時,都把手上所有東西一次給完:一頁,`next_cursor=None`。對幾十個工具、資源或提示詞來說,這就是正確的答案,沒有什麼需要設定。 + +分頁是給資源清單其實是資料庫的那種伺服器用的:成千上萬列,它拒絕在一個回應裡全部序列化。協定的答案是**游標(cursor)**:伺服器回傳一頁加上一個不透明的 token,用戶端把這個 token 送回去,就能拿到下一頁。 + +`@mcp.resource()` 沒有任何掛鉤可以做這些事。要分頁,就得在 **[低階 Server](low-level-server.md)** 上自己寫清單處理函式。 + +## 會分頁的伺服器 {#a-server-that-pages} + +```python title="server.py" hl_lines="12 15-16" +--8<-- "docs_src/pagination/tutorial001.py" +``` + +* 在低階 `Server` 上,處理函式是建構子引數,不是裝飾器。`on_list_resources` 回應每一個 `resources/list` 請求;整個接線就這樣。 +* 每個分頁處理函式的型別都是 `params: PaginatedRequestParams | None`,範例兩種都接受。不過在實際連線上,SDK 永遠不會交給你 `None`(沒有 `params` 成員的請求抵達處理函式時,會是帶著預設值的模型),所以真正重要的訊號是 `params.cursor is None`:**從頭開始**。 +* 游標**是**什麼由你決定。這裡是轉成字串的偏移量。時間戳記、主鍵、base64 blob:任何送出時能產生、送回來時認得出的東西都可以。 +* `next_cursor=None` 就是「那是最後一頁」的說法。沒有計數、沒有總數、沒有 `has_more`。`None` 就是全部的訊號。 + +!!! tip + `PAGE_SIZE` 設成 10 是為了讓範例好讀。依端點各自挑選:一行就講完的資源清單,一頁 500 個也負擔得起;一堆肥大的提示詞範本清單就不行。用戶端對此沒有發言權,這是刻意的設計。 + +### 試試看 {#try-it} + +`Client(server)` 在記憶體內連線到低階 `Server` 的方式,和連到 `MCPServer` 完全一樣。 + +不帶引數呼叫 `list_resources()`。會拿到十個資源,`book-1` 到 `book-10`,而 `next_cursor` 是字串 `"10"`。 + +用 `list_resources(cursor="10")` 把它交回去,第一個資源就是 `book-11`,新的 `next_cursor` 是 `"20"`。 + +第十頁回來時 `next_cursor` 是 `None`。結束。 + +## 用戶端迴圈 {#the-client-loop} + +`Client` 上的每個 `list_*` 方法(`list_tools`、`list_resources`、`list_resource_templates`、`list_prompts`)都接受 `cursor=` 關鍵字引數。把分頁清單抓完只要一個 `while True`: + +```python title="client.py" hl_lines="26-32" +--8<-- "docs_src/pagination/tutorial002.py" +``` + +* `cursor` 一開始是 `None`,所以第一個請求不帶游標。 +* 先 extend,**再**看 `next_cursor`:最後一頁也有資源。 +* `next_cursor is None` 就是出口。其他任何值都原封不動直接放回 `cursor=`。 + +執行它的 `main()`,會印出 `100 resources`:十頁、每頁十個,由一個從頭到尾不知道有十頁的迴圈接起來。 + +這和 **[用戶端](../client/index.md)** 為每個 `list_*` 動詞示範的迴圈是同一個,而且對不分頁的伺服器也沒有任何代價:第一個回應的 `next_cursor` 就是 `None`,迴圈只執行一次。 + +## 三條規則 {#the-three-rules} + +**游標是不透明的。** 用戶端絕不能解析、組裝或猜測游標。游標唯一合法的來源,是上一頁的 `next_cursor`,一字不改。 + +**頁面大小由伺服器決定。** 協定裡沒有 `limit=`。如果需要不同的頁面大小,改的是伺服器。 + +**忽略分頁的用戶端照樣能用。** 它呼叫一次 `list_resources()`,拿到前十個,永遠不會注意到被它丟掉的 `next_cursor`。什麼都沒壞,只是看到的比較少。 + +!!! check + 不透明就是不透明。自己發明一個游標(`list_resources(cursor="page-2")`),協定也幫不了你。這個伺服器會嘗試 `int("page-2")`,處理函式引發例外,回到用戶端的是: + + ```text + MCPError(-32603, 'Internal server error', None) + ``` + + 不是從伺服器拿到的游標是 bug,不是功能需求。 + +## 重點回顧 {#recap} + +* `MCPServer` 一頁回傳全部。分頁是選擇性啟用的,而啟用的地方是低階 `Server`。 +* `on_list_resources`(以及 `on_list_tools`、`on_list_prompts`、`on_list_resource_templates`)收到 `PaginatedRequestParams | None`;第一頁時 `params.cursor` 是 `None`。 +* 回傳一頁加上 `next_cursor`:任何之後認得出的字串,或在沒有東西剩下時回傳 `None`。 +* 用戶端迴圈:傳入 `cursor=`、累積、重複,直到 `next_cursor is None`。 +* 游標是不透明的,頁面大小歸伺服器管,不分頁的用戶端還是拿得到第一頁。 + +手寫 `Server` API 的其餘部分(`on_call_tool`、`input_schema` dict、`_meta`)在 **[低階 Server](low-level-server.md)**。 diff --git a/i18n/zh-hant/pages/client/caching.md b/i18n/zh-hant/pages/client/caching.md new file mode 100644 index 0000000000..bdd63b73ab --- /dev/null +++ b/i18n/zh-hant/pages/client/caching.md @@ -0,0 +1,119 @@ +--- +translation: + sections: [9e7b9a1710e5aeba, b74ca4c1d2ddddee, fa8714e61bf90c5a, 04db67a886b7271c, 857690fb8f876800] + tool: 1 +--- +# 快取提示 {#caching-hints} + +在 2026-07-28 協定上,伺服器為 `tools/list`、`prompts/list`、`resources/list`、`resources/templates/list`、`resources/read` 和 `server/discover` 回傳的每個結果都帶有兩個欄位:`ttlMs`,表示用戶端可以把這個結果視為新鮮的毫秒數;`cacheScope`,表示快取的結果可以跨使用者共用(`"public"`),還是只屬於某一個授權上下文(`"private"`)。 + +伺服器本身什麼都不快取。這兩個欄位是一種**宣告**:「這份工具清單對所有人都一樣,而且一分鐘內不會變。」用戶端(或擋在你前面的閘道)就可以省掉這次往返。要不要遵守這些提示,由用戶端決定;送出這些提示則是伺服器的工作,而 SDK 會替你處理。 + +預設情況下,每個結果都是 `ttlMs: 0, cacheScope: "private"`:立刻過期、永不共用。這永遠安全,也永遠符合規範。如果你的清單確實穩定,而且對所有呼叫端都相同,就在建構時說清楚: + +```python title="server.py" hl_lines="5-8" +--8<-- "docs_src/caching/tutorial001.py" +``` + +* 這個對應表以**方法名稱**為鍵,而且只有這六個可快取的方法是合法的鍵。參數的型別是 `Mapping[CacheableMethod, CacheHint]`,所以編輯器會自動完成這些鍵,並在執行前標出拼字錯誤;任何躲過型別檢查器的錯誤,都會在建構時引發例外。 +* 沒提到的方法就維持預設值。這個對應表是一組覆寫,不是完整清單。 +* `CacheHint(ttl_ms=5_000)` 沒有設定 `scope`,所以維持 `"private"`:每個呼叫端各自享有五秒的新鮮期。範圍和 TTL 是兩個各自獨立的決定。 +* `"server/discover"` 也是合法的鍵,因為探索結果和任何清單一樣可以快取。 + +!!! warning + `cacheScope: "public"` 的意思是**任何人**都可能收到你快取的回應。共用的閘道會毫不猶豫地把某個使用者的結果交給另一個使用者,即使請求經過身分驗證也一樣。只有在結果對每個呼叫端都完全相同時,才把它標成 `"public"`;也絕對不要把 `cacheScope` 當成存取控制:它是標籤,不是鎖。 + +## 個別處理函式的覆寫 {#per-handler-override} + +在低階的 `Server` 上,處理函式自己手動組出結果,而 `ttl_ms` / `cache_scope` 只是結果模型上的欄位。明確設定這些欄位的處理函式,永遠勝過建構子的對應表,而且是逐欄位比較: + +```python title="server.py" hl_lines="10 16" +--8<-- "docs_src/caching/tutorial002.py" +``` + +處理函式指定了 `ttl_ms=1_000`,但對範圍隻字未提。線路上的結果是:`ttlMs: 1000`(來自處理函式,不是對應表的 `60_000`)和 `cacheScope: "public"`(來自對應表,因為處理函式沒設定)。明確指定的勝過建構時設定的,建構時設定的又勝過預設值。這條規則是逐欄位套用的,所以處理函式可以釘住一個欄位,把另一個欄位交給全伺服器的政策。 + +這也是應付建構子無從得知的動態情況的出口:一個依使用者過濾 `resources/read` 的處理函式,可以在其他部分都是 public 的伺服器上,針對某一個 URI 回傳 `cache_scope="private"`。 + +分頁清單有一點要注意:協定要求同一份清單的**每一頁都要有相同的 `cacheScope`**。建構子的對應表天生就滿足這一點,因為它以方法為鍵,而不是以頁為鍵。但自行覆寫範圍的處理函式,就得自己負責這份一致性:要在**每一**頁都覆寫,絕不能只在有 cursor 時才覆寫,否則第一頁和第二頁會對不上。 + +## 用戶端看到什麼 {#what-the-client-sees} + +在 2026-07-28 的工作階段(session)上,`Client` 會替你遵守這些提示:它內建一個回應快取,預設開啟。帶著 `ttlMs` 抵達的結果會被存起來,在 TTL 內完全相同的呼叫會直接由快取提供,不需要往返。**沒有**帶提示的結果不會被快取:沒有提示的結果會套用 `CacheConfig.default_ttl_ms`,它預設為 `0`(立刻過期),所以什麼都沒宣告的伺服器,看到的流量和以往一模一樣,一次呼叫就一次請求。 + +```python title="client.py" hl_lines="33 35 38" +--8<-- "docs_src/caching/tutorial003.py" +``` + +四次呼叫,三次抓取。第二次呼叫找到新鮮的項目,根本沒送到伺服器;把(注入的)時鐘撥過 TTL 之後,第三次又重新抓取;第四次則指定了 `cache_mode="refresh"`。這個關鍵字引數存在於五個會快取的動詞上(`list_tools`、`list_prompts`、`list_resources`、`list_resource_templates`、`read_resource`): + +* `"use"`(預設)如果有新鮮的項目就直接提供,沒有的話就抓取並存起來。 +* `"refresh"` 從不由快取提供:它會抓取並儲存結果,取代原本快取的內容。 +* `"bypass"` 直接往返,完全不碰快取:不讀、也不寫。 + +有一條規則凌駕於 `"use"` 之上:**帶有 `meta` 的呼叫一定會送到伺服器。**設定了 `meta` 的請求(進度 token、追蹤欄位)期待的是一個實際送上線路的請求,所以在 `cache_mode="use"` 下會被當成 `"refresh"` 處理:跳過快取讀取,而抓取回來的結果仍然會取代快取中的項目。`"bypass"` 和明確指定的 `"refresh"` 行為照舊。 + +要完全關掉快取,就用 `Client(server, cache=None)` 建構:每次呼叫又都變回一次往返,而 `cache_mode` 雖然仍可接受,但不會有任何作用。 + +範圍也會自動遵守:`"private"` 項目綁定在快取的**分區(partition)**上(見下文),而 `"public"` 項目則可以選擇更廣的共用。此外,對通知點名的那些項目來說,**通知勝過 TTL**:`list_changed` 通知會逐出對應的快取清單,`resources/updated` 則會逐出恰好存在該 URI 下的快取讀取結果,不管它們有多新鮮。在 2026-07-28 連線上,這些通知是透過你用 `client.listen(...)` 開啟的 `subscriptions/listen` 串流送達的,而且逐出會在你的監看程式看到事件之前完成;詳情請見 **[訂閱](subscriptions.md)**。 + +`resources/updated` 有一點要注意:逐出只比對完全相同的 URI。存放區的契約沒有列舉或掃描的操作(和參考的 TypeScript 實作一樣),所以帶著**子**資源 URI 的通知不會逐出其父資源的快取讀取結果。如果你的伺服器是用這種方式通知子資源的變動,就用 `cache_mode="refresh"` 重新抓取父資源。 + +### 設定方式:`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`:項目存放的地方。預設是每個用戶端各自一個全新的記憶體內存放區;傳入你自己的 `ResponseCacheStore` 實作(例如以 Redis 為後端)就能跨用戶端或跨處理程序共用快取。契約型別(`ResponseCacheStore`、`CacheKey`、`CacheEntry`,以及預設的 `InMemoryResponseCacheStore`)都可以從 `mcp.client` 匯入。一次查詢最多可能對存放區連續發出兩次 `get`(先查 private 分支,再查 public 分支),所以遠端存放區的延遲預期要據此估算。自訂存放區**必須**搭配明確的 `partition`。 +* `partition`:授權上下文的標籤,用來避免在共用存放區中把某個主體的 `"private"` 項目提供給另一個主體。 +* `target_id`:明確的伺服器身分,用於自訂傳輸和同處理程序內的伺服器(見下文)。 +* `default_ttl_ms`:套用在沒有帶 `ttlMs` 提示的結果上的 TTL。預設的 `0` 讓沒有提示的結果不被快取。 +* `share_public`:跨分區提供伺服器宣稱為 `"public"` 的項目(見下文)。預設關閉。 +* `clock`:牆上時鐘的來源,以 epoch 秒為單位。像上面的範例那樣注入一個,過期測試就不需要 sleep。 + +!!! warning "分區 = 經過驗證的主體" + `partition` 要從**經過驗證的憑證**推導出來,例如已驗證權杖的 subject。絕不要從請求提供的資料推導,也絕不要從伺服器 URL 推導(伺服器身分是另一條獨立的鍵軸)。SDK 是一個函式庫,本身沒有任何身分驗證:信任的錨點是建構 `CacheConfig` 的人,也就是部署方,而不是租戶。多租戶閘道要為每個已驗證的主體各建立一個 `CacheConfig`。 + + 分區在 `Client` 的整個存活期間也是固定的。如果連線的授權上下文在工作階段中途改變(例如重新驗證成另一個主體),快取不會跟著變;請為新的主體建構一個新的 `Client`。 + +快取鍵也帶有**伺服器的身分**:你連線的 URL 字串,去掉任何 `user:pass@` 使用者資訊,其餘逐位元組保留。不做大小寫摺疊、不重排查詢參數、不清理結尾斜線。正規化不足只會損失共用的機會,過度正規化卻可能把兩個租戶合併在一起(`?tenant=a` 對 `?tenant=b`),所以表面上不同的 URL 就是不共用項目。沒有 URL 的時候(同處理程序內的伺服器,或 `Transport` 實例),用戶端會改拿到一個每個實例隨機產生的身分;設定 `CacheConfig.target_id` 來替伺服器命名(使用自訂存放區時這是必要的,建構時也會這麼告訴你)。身分在進入鍵的材料之前會先經過 sha256 雜湊,所以查詢字串裡帶著機密的 URL 永遠不會出現在存放區的鍵中。你自己也不要把雜湊前的形式記錄下來。 + +!!! warning "`share_public` 代表信任伺服器,而且是整個機群一起信任" + 預設情況下,即使是 `"public"` 項目也會留在自己的分區內。`share_public=True` 會把伺服器標成 `cacheScope: "public"` 的項目提供給使用該存放區的**每一個**分區,等於代替它們全體信任伺服器的分類。如果伺服器(因為 bug 或惡意)把 `"public"` 蓋在各租戶專屬的資料上,某個租戶的回應就會洩漏給其他租戶。這個旗標刻意只放在建構子層級:每次呼叫的 `cache_mode` 可以縮小快取範圍,但沒有任何每次呼叫層級的東西可以擴大共用。 + +### 快取絕不會做的事 {#what-the-cache-never-does} + +* **工作階段層級的呼叫會繞過它。** `client.session.list_tools()` 這一類的呼叫一定會往返;快取是掛在 `Client` 的動詞上。 +* **`server/discover` 不參與。** discover 結果只在連線時送達一次,永遠不會進入回應快取,即使它帶著 `ttlMs` 也一樣。如果你自己把它保存下來以跳過重新連線時的探測([`prior_discover`](../protocol-versions.md#reconnecting-with-prior_discover)),它的新鮮度就由你自己記帳:`DiscoverResult` 帶有已解析好的 `ttl_ms` 和 `cache_scope`,正是為了這個用途。 +* **後續分頁永遠不會被快取。** 只有不帶 cursor 的呼叫會參與。因為 cursor 過期而被拒絕的後續分頁倒是會**逐出**快取的清單,因為清單已經在底下變了。 +* **多輪往返(multi-round-trip)的讀取永遠不會被快取。** 以 `input_responses`/`request_state` 起頭的 `read_resource`,或是經過輸入回合才解析出結果的讀取,都永遠不會進入快取(這是規範的 MUST)。 +* **靠通知逐出,就得有通知。** 逐出的效果取決於傳輸能不能把通知送到,而現代的同處理程序內路徑(`Client(server)` 搭配預設的 `mode="auto"`)目前不會遞送獨立的通知。 +* **逐出是最終發生,不是立即發生。** 走線路的通知是從衍生出來的 task 分派的,所以和通知抵達搶時間的呼叫,可能會再被提供一次逐出前的項目;這個空窗受分派延遲所限,而逐出終究會生效。 +* **沒有 stale-if-error。** 過期的項目絕不會因為重新抓取失敗就被拿出來提供;錯誤會往上傳遞。 +* **沒有提前重新抓取。** 已存的項目會一直提供到 TTL 過期為止,過期後的下一次呼叫要付出往返的代價;背景不會有任何東西在更新。 +* **沒有合併。** 兩個同時發出的相同呼叫就是兩次抓取。 +* **TTL 不會超過 24 小時。** 更大的 `ttlMs`,不論是伺服器送來的還是設定的,在存入時都會被壓到上限(`mcp.client.caching.MAX_TTL_MS`),這限制了任何項目能被提供的時間,不管它的提示有多大方。 +* 在**共用存放區**上,用戶端之間會互相競爭。當逐出搶在進行中的抓取之前發生時,每個用戶端會丟棄自己的寫入,但**共用同一存放區的其他**用戶端仍然可能把一個項目寫回去,而那個項目其實已經被一次它沒看到的逐出移除了;這份競爭的記帳本身也有上限:追蹤的鍵超過 4096 個時,最舊那個鍵的防護會先被丟掉。這兩個空窗都是可接受的,並且由上面的 TTL 上限收尾。 +* **不會跨協定世代提供。** 項目的範圍限定在協商出來的協定版本:在共用的持久性存放區上,工作階段絕不會提供在另一個協商版本下寫入的項目(同一份清單在不同世代確實不一樣,因為 SDK 會替較舊的工作階段剝掉 2026 的欄位)。逐出同樣只碰目前世代的項目;其他世代的項目就靠 TTL 自然老化淘汰。 + +### 自己讀取提示 {#reading-the-hints-yourself} + +這些提示也是每個可快取結果上的普通欄位(`result.ttl_ms` 和 `result.cache_scope`,已解析好),如果你想在內建快取之上(或取而代之)疊上自己的記帳機制,可以直接用。 + +面對**較舊的伺服器**(2026 之前的協定),這些欄位在線路上根本不存在,模型會顯示保守的預設值:`ttl_ms == 0` 和 `cache_scope == "private"`,過期且不共用,對一個什麼都沒宣告的伺服器來說是正確的假設。快取對待舊版工作階段的方式也一樣:在那裡永遠不參考提示(不管線路上出現什麼鍵),只套用 `default_ttl_ms`,而它的預設值 `0` 什麼都不快取,所以 2026 之前的連線行為和快取存在之前一模一樣。如果需要區分「伺服器說了 0」和「伺服器什麼都沒說」,就檢查 `"ttl_ms" in result.model_fields_set`:只有欄位真的送達時它才會被設定。 + +## 較舊的用戶端 {#older-clients} + +使用 2026 之前協定版本的用戶端永遠看不到這兩個欄位;SDK 在為這些連線序列化時就把它們剝掉了。提示只要設定一次;沒有任何需要針對版本另外寫的東西。 + +## 重點回顧 {#recap} + +* 六個方法帶有 `ttlMs`/`cacheScope`;SDK 把它們預設為 `0`/`"private"`,過期且不共用,永遠安全。 +* 建構時的 `cache_hints={method: CacheHint(...)}`(`MCPServer` 和 `Server` 都有)會為每個方法設定全伺服器的值。 +* 在結果上設定這些欄位的處理函式會逐欄位覆寫對應表。 +* `"public"` 是一個承諾:結果對每個呼叫端都完全相同。它不是存取控制。 +* `Client` 會自動遵守提示:它的回應快取預設開啟,會提供新鮮的項目而不重新抓取,而對沒有提供提示的伺服器(或工作階段)則什麼都不快取。 +* 每次呼叫可用 `cache_mode="refresh"` 重新抓取、用 `"bypass"` 跳過快取;建構時傳入 `cache=None` 則會完全關掉它。 diff --git a/i18n/zh-hant/pages/client/callbacks.md b/i18n/zh-hant/pages/client/callbacks.md new file mode 100644 index 0000000000..db0ad2f5a2 --- /dev/null +++ b/i18n/zh-hant/pages/client/callbacks.md @@ -0,0 +1,142 @@ +--- +translation: + sections: [adf3c545b5be46b6, 916cd3ab1c03f461, e9be7a8d0eb0a456, 565890a636288ecf, 6af7e49db9129ec3, 06b0238c174186af, 90c6043be435fcb0] + tool: 1 +--- +# 用戶端回呼 {#client-callbacks} + +MCP 裡幾乎每一個請求都是單向的:從用戶端到伺服器。 + +伺服器也可以反過來向**用戶端**要東西:向使用者提問、對使用者的模型取樣(sampling)、列出使用者的工作區資料夾。要回應這些請求,就把**回呼**傳給 `Client(...)`。 + +## 會發問的伺服器 {#a-server-that-asks} + +下面這個伺服器的工具沒辦法自己完成: + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/client_callbacks/tutorial001.py" +``` + +* `ctx.elicit(...)` 會**向用戶端**送出一個 `elicitation/create` 請求,然後等待。 +* 在有人(填表單的人,或是你的程式碼)提供 `name` 之前,這個工具不會回傳。 + +那是伺服器那一半,由 **[徵詢(elicitation)](../handlers/elicitation.md)** 頁面負責說明。這一頁講的是線路的另一端。 + +## 徵詢回呼 {#the-elicitation-callback} + +```python title="client.py" hl_lines="6-10 16-17" +--8<-- "docs_src/client_callbacks/tutorial002.py" +``` + +* 徵詢回呼的形式是 `async (context, params) -> ElicitResult`。 +* `params.message` 是問題本身。`params.requested_schema` 是伺服器想要的答案的 JSON Schema。真正的用戶端會依它繪製出表單;這個範例則是自動填入。 +* 回傳 `ElicitResult(action="accept", content={...})`,或 `action="decline"`,或 `action="cancel"`。除此之外唯一的選項是 `ErrorData(...)`,它會拒絕這個請求,讓整個呼叫失敗。 +* `context` 是一個 `ClientRequestContext`:包含目前的 `session`、伺服器的 `request_id`,以及它附上的任何 `meta`。 + +!!! tip + `params` 是兩種徵詢模式的聯集。這裡的 `params.mode` 是 `"form"`;`"url"` 請求帶的是 `params.url` 而不是 schema。同一個回呼處理兩種模式,依 `params.mode` 分支即可。完整的寫法請見 **[徵詢](../handlers/elicitation.md)**。 + +### 試試看 {#try-it} + +呼叫 `issue_card`,觀察兩端的情況。 + +回呼會收到伺服器的問題,而且已經解析好了: + +```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'} +``` + +它回答之後,`ctx.elicit(...)` 在工具內部恢復執行,工具隨即完成: + +```python +result.content # [TextContent(type='text', text='Card issued to Ada Lovelace.')] +``` + +你送出一個 `tools/call`,伺服器回送一個 `elicitation/create`,由你的函式回答,全都發生在同一次工具呼叫之內。 + +!!! info + `Client(...)` 呼叫上的 `mode="legacy"` 是真的有作用。預設情況下 `Client(...)` 會協商出現代的協定路徑,而這條路徑沒有讓伺服器向用戶端發請求的反向通道(back-channel):`ctx.elicit` 在你的回呼有機會執行之前就失敗了。決定這件事的不是傳輸方式,而是協商出來的協定,記憶體內和透過 URL 連線都一樣。只要用戶端必須回應這類請求,就固定用 `mode="legacy"`;這一頁背後的每個測試都是這樣做的。完整說明請見 **[協定版本](../protocol-versions.md)**。 + + 在 2026-07-28 的工作階段(session)上,回呼並沒有失效,只是餵給它的方式不同:當工具回傳帶有 `ElicitRequest` 的 `InputRequiredResult` 時,`Client` 會把那個項目分派給同一個 `elicitation_callback`,並替你重試這次呼叫。這個流程就是 **[多輪往返(multi-round-trip)請求](../handlers/multi-round-trip.md)**。 + +## 回呼就是能力 {#a-callback-is-a-capability} + +你從來沒有告訴伺服器你的用戶端能回應徵詢請求。是 SDK 說的。 + +用戶端連線時會宣告自己的 `capabilities`,正好是伺服器那一份的鏡像。這個物件不用你寫。**註冊回呼就是宣告。** + +| 你傳入 | 用戶端宣告 | +| --- | --- | +| `elicitation_callback=` | `"elicitation": {"form": {}, "url": {}}` | +| `sampling_callback=` | `"sampling": {}` | +| `list_roots_callback=` | `"roots": {"listChanged": true}` | +| 一個都不傳 | `{}` | + +取樣的子能力是唯一需要細分的地方:如果你的取樣器會處理 `tools` / `tool_choice` 參數,就在 `sampling_callback` 旁邊一併傳入 `sampling_capabilities=SamplingCapability(tools=SamplingToolsCapability())`。伺服器必須先看到 `sampling.tools` 被宣告,才能送出這些參數。 + +`logging_callback` 和 `message_handler` 不在表中。它們處理的是通知,而通知不需要能力。 + +伺服器用 `ctx.session.check_client_capability(...)` 把這份宣告讀回來。加一個這樣做的工具: + +```python title="server.py" hl_lines="23-31" +--8<-- "docs_src/client_callbacks/tutorial003.py" +``` + +只帶 `elicitation_callback` 連線並呼叫它: + +```python +result.structured_content # {'result': ['elicitation']} +``` + +三個回呼都傳,會得到 `['elicitation', 'sampling', 'roots']`。一個都不傳,會得到 `[]`。 + +!!! check + 現在故意做錯:**不帶** `elicitation_callback` 連線,照樣呼叫 `issue_card`。 + + 伺服器的 `elicitation/create` 請求還是會送到你的用戶端,而 SDK 會替你回應,用的是錯誤,因為你從沒說過自己能處理它。這個錯誤會拖垮整個呼叫。`call_tool` 不會回傳 `is_error` 結果,而是引發例外: + + ```text + MCPError: Elicitation not supported + ``` + + 這是協定錯誤(`-32600`,*invalid request*),不是工具錯誤:沒有任何東西可以讓模型讀了再重試。這就是 `client_features` 值得有的原因:行為良好的伺服器會先檢查再發問。 + +## 已棄用的那一對 {#the-deprecated-pair} + +`sampling_callback` 回應 `sampling/createMessage`:伺服器請**你的**模型生成一些內容。`list_roots_callback` 回應 `roots/list`:伺服器詢問它可以在哪些目錄裡工作。 + +兩個都能用。兩個都遵守上面的規則。而兩個服務的 RPC 都是 **2026-07-28 規格移除的**:現代的伺服器不會在請求途中回頭呼叫你的用戶端,而是把請求當成工具結果的一部分交還給你(**[多輪往返請求](../handlers/multi-round-trip.md)**)。回呼本身並沒有失效。當 `InputRequiredResult` 帶著 `CreateMessageRequest` 或 `ListRootsRequest` 時,`Client` 的自動迴圈會把它分派給你在這裡註冊的同一個 `sampling_callback` 或 `list_roots_callback`。完整清單請見 **[已棄用的功能](../deprecated.md)**。 + +要和還沒升級的伺服器溝通,你仍然需要這些回呼。簽章如下: + +```python title="client.py" +--8<-- "docs_src/client_callbacks/tutorial004.py" +``` + +* 取樣回呼會收到完整的 `CreateMessageRequestParams`(`messages`、`model_preferences`、`max_tokens`),並回傳 `CreateMessageResult`。模型由**你**來執行,怎麼執行都行;SDK 只負責傳遞請求。 +* 根目錄(roots)回呼完全不接受參數,回傳 `ListRootsResult`。 +* 兩者都可以改為回傳 `ErrorData(...)` 來拒絕。 + +把它們傳給 `Client(...)` 的方式和 `elicitation_callback` 完全一樣。 + +## 通知回呼 {#the-notification-callbacks} + +還有兩個。兩個都不宣告任何東西。 + +`logging_callback` 會收到伺服器送出的 `notifications/message`,型別是 `LoggingMessageNotificationParams`(`level`、`logger`、`data`)。協定記錄本身已被 2026-07-28 規格棄用(該怎麼改做請見 **[記錄](../handlers/logging.md)**),所以這個回呼是為了還在送出它的伺服器而存在。在 2026 世代的連線上,光有回呼什麼都收不到,因為 2026 的伺服器只會把記錄訊息送給主動選擇接收的請求:把 `log_level="info"`(或其他層級)傳給 `Client(...)`,就會在每個請求上蓋上這個選擇,並收到該層級以上的訊息。2026 之前的伺服器會忽略它,維持原本的 `logging/setLevel` 行為。 + +`message_handler` 是總攬一切的那個:工作階段浮現的每一個伺服器通知都會送到它(同時也送到各自專屬的回呼),在以串流為基礎的傳輸方式上,每一個傳輸層級的 `Exception` 也會。有兩種永遠不會:`notifications/cancelled` 由 SDK 直接套用而不浮現,而正在運作的 `listen()` 串流的訂閱確認則由那個串流自己消化。把這個參數註記為 `IncomingMessage`(`ServerNotification | Exception`,從 `mcp.client` 匯出)。唯一值得知道的寫法是 `if isinstance(message, Exception): raise message`,這樣連線斷掉時會大聲失敗,而不是悄悄消失。 + +## 重點回顧 {#recap} + +* 伺服器可以向用戶端送出請求。用傳給 `Client(...)` 的回呼來回應它們。 +* 徵詢回呼是現行的那一個:`async (context, params) -> ElicitResult`,一個函式同時處理 form 和 URL 模式。 +* **註冊回呼就是宣告能力。**沒有它,SDK 會替你拒絕伺服器的請求,整個呼叫以 `MCPError` 失敗。 +* 伺服器在發問之前用 `ctx.session.check_client_capability(...)` 先確認。 +* `sampling_callback` 和 `list_roots_callback` 的運作方式相同,但服務的是已棄用的功能;現代的伺服器改用多輪往返請求。 +* `logging_callback` 和 `message_handler` 接收通知。它們不宣告任何東西。 + +`Client(...)` 的第一個引數是一個傳輸物件。**[用戶端傳輸方式](transports.md)** 涵蓋了每一種。 diff --git a/i18n/zh-hant/pages/client/identity-assertion.md b/i18n/zh-hant/pages/client/identity-assertion.md new file mode 100644 index 0000000000..4a47926efa --- /dev/null +++ b/i18n/zh-hant/pages/client/identity-assertion.md @@ -0,0 +1,129 @@ +--- +translation: + sections: [a91322c46111d16d, 8e6fd6d6f59bb568, e7828fd2729b2c9d, a03ec26bfc678b65, 1034c653c0bcf1b0] + tool: 1 +--- +# 身分斷言 {#identity-assertion} + +一般的 OAuth provider(**[OAuth 用戶端](oauth-clients.md)**)一開始會先問 MCP 伺服器一個問題:「你信任哪一個授權伺服器?」答案指向哪裡它就跟到哪裡,接著要嘛有人登入,要嘛用預先共享的密鑰代替。 + +企業兩者都不想交給每台伺服器各自決定。它早就有一個身分提供者(Okta、Microsoft Entra ID,或你自己的);使用者今天早上就已經登入過了;而且安全團隊希望只在這一個地方決定誰能存取什麼。[SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990),也就是 **Enterprise-Managed Authorization** 擴充功能,把決定權移到那裡。IdP 會簽署一個短效的 JWT,**Identity Assertion JWT Authorization Grant**,簡稱 **ID-JAG**:宣告**這位使用者**透過**這個用戶端**可以存取**這台 MCP 伺服器**。用戶端拿它換一個普通的存取權杖。沒有瀏覽器、沒有同意畫面、沒有動態註冊。 + +這一頁講的是這筆交換的兩端。MCP 伺服器本身完全不變:它仍然是 **[授權](../run/authorization.md)** 裡的資源伺服器,檢查送上門的任何權杖。 + +## 兩個權杖請求 {#two-token-requests} + +這裡有兩個不同的權威機構在運作,把它們分開命名,幾乎就等於讀懂這一頁。**企業 IdP** 是你所屬組織的身分提供者:它知道員工是誰,政策放在它那裡,ID-JAG 也由它簽發。SDK 從不跟它對話。**MCP 授權伺服器**和 **[授權](../run/authorization.md)** 裡是同一個角色:MCP 伺服器中繼資料裡指名的簽發者,負責簽發該 MCP 伺服器接受的權杖。在一般的 OAuth 流程裡,這兩個角色通常是同一台機器。這裡它們是兩台,而整個授權流程就是後者同意信任前者。 + +用戶端對兩者各發一個權杖請求。 + +1. **對企業 IdP。** 用戶端拿使用者的登入結果(他們的 OpenID Connect ID 權杖)換 ID-JAG。這是一次 [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) 權杖交換,完全是你 IdP 的 API,而且 **SDK 不會發這個請求**。由你在一個非同步回呼裡完成。政策決定也發生在這裡:IdP 如果拒絕,就根本不會簽發 ID-JAG,也就沒有東西可以出示。 +2. **對 MCP 授權伺服器。** 用戶端以 [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) 的 `jwt-bearer` 授權類型出示 ID-JAG(`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`,ID-JAG 放在 `assertion`),然後收到存取權杖。**這是 SDK 會發的請求**,而接受它,就是這一頁替授權伺服器加上的唯一一件事。 + +以下全部都是第二個請求:送出它的用戶端,以及回應它的授權伺服器。 + +## 用戶端 {#the-client} + +**`IdentityAssertionOAuthProvider`** 位於 `mcp.client.auth.extensions.identity_assertion`。和 **[OAuth 用戶端](oauth-clients.md)** 裡的每個 provider 一樣,它是一個 `httpx2.Auth`:建立一個,放到 `auth=`,再把 `httpx2.AsyncClient` 交給傳輸。 + +```python title="client.py" hl_lines="49-50 53-61" +--8<-- "docs_src/identity_assertion/tutorial001.py" +``` + +從下往上讀。 + +* `main()` 就是標準 OAuth 用戶端的 `main()`(**[OAuth 用戶端](oauth-clients.md)**),一行都沒改。重點正是這個:一旦 provider 存在,下游沒有任何東西知道權杖是哪一種授權類型產生的。 +* 這個 provider 接收其他 provider 無法自行探索到的東西:有人事先向授權伺服器**預先註冊**好的 `client_id` 和 `client_secret`、該授權伺服器的 `issuer`,以及 `assertion_provider`,一個依需求回傳全新 ID-JAG 的非同步回呼。 +* `storage` 是同一個 `TokenStorage` 協定。只會呼叫那兩個權杖方法;這裡沒有動態註冊,所以沒有 `client_info` 需要記住。 + +### 斷言提供者 {#the-assertion-provider} + +`fetch_id_jag(audience, resource)` 是你唯一要寫的程式碼。每次權杖交換會 await 它一次,建立時從不呼叫,而且只在授權伺服器的中繼資料已取回並驗證**之後**才呼叫,所以設定錯誤的 issuer 永遠不會洩漏斷言。它的兩個引數是簽發 ID-JAG 時必須帶有的其中兩個 claim:`audience` 是授權伺服器的簽發者(ID-JAG 的 `aud`),`resource` 是 MCP 伺服器的正規識別碼(ID-JAG 的 `resource`)。第三個你手上已經有了:ID-JAG 的 `client_id` claim 必須指名你交給 provider 的那個 `client_id`,否則授權伺服器會拒絕交換。 + +上面的 `idp_issue_id_jag` **不是你的程式碼**。它代替身分提供者,在處理程序內簽署斷言,讓這個檔案能完整執行,你也能讀到 ID-JAG 帶有的每一個 claim。真正的 `fetch_id_jag` 會改發上一節的第一個權杖請求:對你的 IdP 做一次 [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) 權杖交換,定義在 Identity Assertion JWT Authorization Grant 草案裡,[SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) 則是該草案的 profile。已登入使用者的 ID 權杖作為 `subject_token` 送進去,`requested_token_type` 是 ID-JAG 自己的 URN(`urn:ietf:params:oauth:token-type:id-jag`),`audience` 和 `resource` 原樣傳過去,回應裡就帶著 ID-JAG。到你 IdP 的說明文件裡要找的,就是這些名稱底下的這個交換。 + +!!! tip + 每次交換都會請求一個全新的 ID-JAG,而這正是重點:它是單次使用、只活幾分鐘的授權,而且這一頁的授權伺服器拒絕接受同一個兩次。不要快取它。會被重複使用的,是它幫你換來的存取權杖。 + +### issuer 是設定值 {#the-issuer-is-configuration} + +反轉的地方在這裡。`OAuthClientProvider` 會問資源伺服器該用哪一個授權伺服器,答案指向哪裡就跟到哪裡。這個 provider 拒絕這麼做:`issuer` 是必填,[RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) 中繼資料從該 issuer 自己的 well-known 路徑取回,權杖端點必須位於該 issuer 的 origin 上,而且從不向資源伺服器詢問任何事。 + +擴充功能並沒有要求這樣做;這是刻意更嚴格的選擇。這個用戶端帶著兩樣值得偷的東西,一個預先註冊的密鑰和一個綁定 audience 的斷言,而如果用戶端任由遭入侵的 MCP 伺服器把它導向攻擊者的授權伺服器,這兩樣都會 POST 過去。在建立時就釘死 issuer,等於把這段對話整個刪掉。 + +!!! warning + 設定的 `issuer` 會依 RFC 8414 §3.3 的簡單字串比對,和中繼資料文件的 `issuer` 欄位比較:逐字元比對,結尾斜線也算,沒有任何正規化。不要用猜的。從你的授權伺服器抓 `/.well-known/oauth-authorization-server`,把它回傳的 `issuer` 值複製過來。以這一頁的授權伺服器來說是 `https://auth.example.com/`,帶斜線,因為它的 issuer 是從 pydantic 的 URL 物件建出來的。不相符的話,流程會在送出任何一個憑證或斷言之前,就停在 `OAuthFlowError: Authorization server metadata issuer + mismatch`。 + +### 機密用戶端 {#a-confidential-client} + +`client_secret` 是必填;沒有它,建構子會引發 `ValueError`。[SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) 底下的 IETF profile 把這種授權類型保留給機密用戶端,SEP-990 要求用戶端必須驗證身分,而這個 SDK 以堅持要有共享密鑰的方式同時強制這兩點。`token_endpoint_auth_method` 決定它走哪裡:`client_secret_post`(預設,放在表單主體)或 `client_secret_basic`(HTTP Basic 標頭)。profile 也允許 `private_key_jwt`;這個 provider 不支援。 + +!!! tip + 從環境變數或密鑰管理服務讀取 `client_secret`,永遠不要從版本控制裡讀。 + +### provider 替你做的事 {#what-the-provider-does-for-you} + +第一個請求不帶驗證就送出,伺服器的 `401` 啟動整個流程。 + +1. **探索。** 從設定的 issuer 的 [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) well-known 路徑取回授權伺服器中繼資料,檢查文件的 `issuer` 相符,並檢查權杖端點位於 issuer 的 origin 上。 +2. **斷言。** await 你的 `assertion_provider`。 +3. **交換。** 把 `jwt-bearer` 授權 POST 到權杖端點,儲存 `OAuthToken`,然後帶著 `Authorization: Bearer ...` 重送你原本的請求。 + +`WWW-Authenticate` 指名 `insufficient_scope` 的 `403`,會用你的 `scope` 和被質疑的 scope 的聯集,再跑一次步驟 2 和 3。(`scope` 永遠只是請求;這一頁的授權伺服器只核發 ID-JAG 上寫的,其他一概不給。)整個過程裡沒有任何更新權杖:存取權杖過期時,下一個 `401` 會簽發一個全新的 ID-JAG 再交換一次,而**那**正是 IdP 握在手上的控制桿。失敗和 **[OAuth 用戶端](oauth-clients.md)** 其他部分一樣是那兩個例外:探索和驗證用 `OAuthFlowError`,權杖端點拒絕時則是它的子類別 `OAuthTokenError`。 + +## 授權伺服器 {#the-authorization-server} + +大多數時候你到這裡就停了。MCP 授權伺服器是別人的產品,接受 ID-JAG 是它要開啟的設定,而 SDK 在 [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) 裡負責的那一半,就是上面的用戶端。 + +SDK 也可以自己**當**授權伺服器:`create_auth_routes` 以任何 Starlette 應用程式都能掛載的清單形式回傳授權伺服器的路由,儲存庫裡的 `examples/servers/simple-auth/` 就是這樣跑起一個的。SEP-990 在這個介面上加了一個旗標和一個方法: + +```python title="auth_server.py" hl_lines="48-50 105-107" +--8<-- "docs_src/identity_assertion/tutorial002.py" +``` + +* `identity_assertion_enabled=True` 管控一切。關閉時(這是預設),即使你實作了 hook,`/token` 也會以 `unsupported_grant_type` 回應這種授權類型,中繼資料也不會提到它。開啟時,中繼資料會多出 `jwt-bearer` 授權類型,並在 `authorization_grant_profiles_supported` 裡列出 `urn:ietf:params:oauth:grant-profile:id-jag`,也就是擴充功能用來宣傳支援的欄位。(這個 SDK 的用戶端從不讀它:它只為一個 issuer 佈建,直接開口問就是了。) +* **`exchange_identity_assertion`** 就是那個 hook。在它執行之前,SDK 已經驗證了用戶端、拒絕了公開用戶端,也拒絕了註冊資料裡沒列出這種授權類型的用戶端。你會拿到一個 `IdentityAssertionParams`(原始的 `assertion`、請求的 `scopes` 和 `resource`),回傳一個普通的 `OAuthToken`。 +* 動態用戶端註冊無條件拒絕這種授權類型,所以這裡的 `get_client` 提供的是手動佈建的用戶端。ID-JAG 用戶端沒辦法靠自己註冊而存在。 +* 這個類別有一半是拒絕。`OAuthAuthorizationServerProvider` 是**整個**授權伺服器,所以它也要求授權碼流程;同時讓使用者登入的伺服器會真的實作那些,而這一台只有一扇門。 + +!!! warning + SDK 從不解碼斷言:只有你的部署知道它信任哪一個 IdP、那個 IdP 發布哪些金鑰,所以 `exchange_identity_assertion` 裡的每一行都至關重要。依 [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) §3,用 IdP 發布的金鑰(它的 JWKS;這裡的共享密鑰只是示範用)驗證簽章,以及 `iss` 和 `exp`。要求 JWT 標頭的 `typ` 是 `oauth-id-jag+jwt`,這是 profile 防止其他 JWT 被拿來重播成授權的防線。要求 `aud` 是你自己的 issuer。要求 ID-JAG 的 `client_id` claim 等於處理函式驗證過的用戶端,且它的 `resource` claim 指名的是你確實有提供的資源。追蹤 `jti` 直到斷言的 `exp`,讓它只被接受一次。還有,核發的 scope,以及最重要的、簽發權杖的 `resource`,都要從驗證過的 ID-JAG 取得,永遠不要從請求取得:`params.resource` 是用戶端隨便打的東西。完整的處理規則在 [Enterprise-Managed Authorization 規格](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization)裡。 + +用 `TokenError("invalid_grant", ...)` 拒絕不合格的斷言。這個流程裡另一個錯誤碼是 `invalid_target`:指名了你沒提供的資源的 ID-JAG 會用它拒絕,這正是阻止這台伺服器替別人的資源簽發權杖的機制。而核發的 scope 來自 ID-JAG 的 `scope` claim(沒有這個 claim 的斷言也會被拒絕);你的實作也許會改成對應使用者的群組。 + +再注意回傳的 `OAuthToken` 沒有帶的東西:更新權杖。IdP 透過決定是否簽發下一個 ID-JAG,來決定這位使用者能保有存取權多久。在這裡簽發更新權杖,等於悄悄把這個決定權交回去。 + +!!! info + 仍以 `auth_server_provider=` 內嵌授權伺服器的伺服器,透過 `AuthSettings(identity_assertion_enabled=True)` 走到同一段程式碼。**[授權](../run/authorization.md)** 說明了為什麼新的伺服器不該從那裡開始。 + +!!! check + 把這一頁的兩個檔案接在一起,整個授權流程就是一個 `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"} + ``` + + 沒有 `/authorize`、沒有 `/register`、沒有抓 protected-resource 中繼資料。線路上僅有的請求是引來 `401` 的那一個、well-known 的抓取、這次交換,然後就是帶著 bearer 的普通 MCP 流量。而你的驗證器從 ID-JAG 讀出的 `sub`,正是工具裡 `get_access_token().subject` 回報的值。 + +### 試試看 {#try-it} + +SDK 儲存庫裡的 `examples/stories/identity_assertion/` 就是這一頁的實際執行版:同一個 `exchange_identity_assertion` 驗證器、一台以它的權杖把關的 MCP 伺服器、一個替身 IdP,和用戶端,全放在一個會自我檢查的程式裡。`uv run python -m stories.identity_assertion.client --http` 會跑完整個交換,並斷言 IdP 指名的使用者就是工具看到的使用者。 + +## 重點回顧 {#recap} + +* [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) 讓企業身分提供者(而不是終端使用者)決定用戶端可以存取哪些 MCP 伺服器。IdP 把這個決定簽進一個 **ID-JAG** 裡。 +* 取得 ID-JAG 是對**你的 IdP** 做的 [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) 權杖交換,SDK 不做這件事。把它出示給 MCP 授權伺服器是 [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) 的 `jwt-bearer` 授權類型,SDK 兩端都做。 +* `IdentityAssertionOAuthProvider` 是另一個 `httpx2.Auth`:一個預先註冊的機密用戶端、一個釘死的 `issuer`,和一個 `assertion_provider(audience, resource)` 回呼。沒有瀏覽器、沒有註冊、沒有更新權杖。 +* 授權伺服器從不透過資源伺服器探索。把 `issuer` 設定成和它中繼資料文件提供的字串一模一樣;比對是逐字元的。 +* 伺服器端是 `identity_assertion_enabled=True` 加上 `exchange_identity_assertion`。SDK 驗證用戶端並管控授權類型;驗證 ID-JAG 完全是你的事,而簽發的權杖綁定的是 ID-JAG 的 `resource`,不是請求的。 + +這一頁唯一沒碰過的角色是 MCP 伺服器。它拿你剛簽發的權杖做什麼,早在 **[授權](../run/authorization.md)** 裡就已經在做了。 diff --git a/i18n/zh-hant/pages/client/index.md b/i18n/zh-hant/pages/client/index.md new file mode 100644 index 0000000000..f535d5ad0c --- /dev/null +++ b/i18n/zh-hant/pages/client/index.md @@ -0,0 +1,207 @@ +--- +translation: + sections: [ebef1e7a0df854f4, a4c687d3d627d516, 8e79141fc2985342, b345dd05b9c3c7ab, 80ce41579825a6fa, 5f0fa90494de8f65, 83d10514eaa62fa5, 9190555aa39a5d28, 84a4c9d8bf14dddb, 927d71cf40b58c30] + tool: 1 +--- +# 用戶端 {#the-client} + +Python 程式要和 MCP 伺服器對話,靠的就是 **`Client`**。 + +它是一個物件,只有一套生命週期:建立它、進入 `async with`、呼叫方法。每個協定動作(列出工具、呼叫其中一個、讀取資源、算繪提示詞)都是它上面的一個 `async` 方法,回傳有型別的結果。 + +## 你的第一個用戶端 {#your-first-client} + +```python title="client.py" hl_lines="14-18" +--8<-- "docs_src/client/tutorial001.py" +``` + +最上面的伺服器只是讓你有東西可以連而已。用戶端就是標示出來的那五行。 + +* `Client(mcp)` 拿到的是**伺服器物件本身**。這就是記憶體內傳輸:沒有子處理程序、沒有連接埠、沒有 HTTP。這一頁的每個範例,以及你寫的每個測試,都是這樣連線的。 +* `async with` 就是**生命週期**。進入時連線並協商;離開時斷線。沒有 `connect()` / `close()` 這種成對的方法,而且區塊結束後 `Client` 不能再重複使用。 +* 在區塊內,連線的各項資訊已經以普通屬性的形式準備好了。 + +### 可以傳什麼給 `Client` {#what-you-can-pass-to-client} + +`Client` 接受一個位置引數,並依它的型別決定傳輸方式: + +* `MCPServer`(或低階的 `Server`)實例:在**同一個處理程序內**連線。 +* URL 字串(`Client("http://localhost:8000/mcp")`):Streamable HTTP,也就是正式環境的路徑。 +* 一個**傳輸**:任何可以 `async with ... as (read, write)` 的東西,例如包住子處理程序的 `stdio_client(...)`。 + +這一頁其餘的內容在這三種情況下完全相同。標頭、子處理程序、逾時,以及 `Transport` 協定另外有專屬的頁面:**[用戶端傳輸方式](transports.md)**。 + +### 連線後的用戶端上有什麼 {#whats-on-a-connected-client} + +四個唯讀屬性,一進入區塊就填好了: + +* `client.server_info`:伺服器的身分;如果是不回報身分的 2026 世代伺服器,則為 `None`(python-sdk 伺服器預設會回報)。這裡的 `server_info.name` 是 `"Bookshop"`,`server_info.version` 則是伺服器回報的值。 +* `client.server_capabilities`:伺服器能做什麼(`tools`、`resources`、`prompts`、`completions`……)。伺服器沒有的能力會是 `None`。 +* `client.protocol_version`:雙方談妥的協定版本。這裡是 `"2026-07-28"`。 +* `client.instructions`:伺服器的 `instructions=` 字串;如果沒有設定則為 `None`。 + +你從頭到尾都沒有挑過協定版本。預設情況下 `Client` 會先探測伺服器,遇到較舊的伺服器就退回傳統的交握,所以同一個用戶端可以對應任何世代的伺服器。需要自己掌控時,完整說明請見 **[協定版本](../protocol-versions.md)**。 + +!!! tip + `client.session` 是底層的 `ClientSession`,也就是低階的逃生出口。這一頁的任何內容都用不到它。 + +## 列出工具 {#listing-tools} + +```python title="client.py" hl_lines="15-20" +--8<-- "docs_src/client/tutorial002.py" +``` + +`list_tools()` 回傳一個 `ListToolsResult`;工具在 `.tools` 裡。每一個都是 MCP 主機(host)會交給模型的完整定義: + +```python +tool.name # 'search_books' +tool.title # 'Search the catalog' +tool.description # 'Search the catalog by title or author.' +``` + +而 `tool.input_schema` 是伺服器從函式的型別提示推導出來的 JSON Schema: + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"default": 10, "title": "Limit", "type": "integer"} + }, + "required": ["query"], + "title": "search_booksArguments" +} +``` + +這份 schema 就是 UI 算繪引數表單所需的一切,也是模型產生合法引數所需的一切。 + +!!! tip + `title` 是選填的,所以把工具顯示給人看的 UI 得自己挑:有的話就用 `title`,沒有就用 `name`。`from mcp.shared.metadata_utils import get_display_name` 做的正是這件事,適用於工具、資源、資源範本和提示詞。 + +## 呼叫工具 {#calling-a-tool} + +`call_tool(name, arguments)` 會執行工具,並回傳一個 `CallToolResult`。 + +```python title="client.py" hl_lines="26-33" +--8<-- "docs_src/client/tutorial003.py" +``` + +伺服器的 `lookup_book` 回傳一個 Pydantic 的 `Book`。用戶端看到的是: + +```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 +``` + +一個回傳值,三樣東西可讀。各自有不同的使用對象。 + +### `content`:給模型讀的 {#content-what-the-model-reads} + +`content` 是一個**內容區塊**的 `list`,而內容區塊是一個聯集:`TextContent`、`ImageContent`、`AudioContent`、`ResourceLink` 或 `EmbeddedResource`。一個工具可以回傳好幾個,而且種類各異。 + +這就是為什麼 `main` 在碰 `block.text` 之前,先用 `isinstance(block, TextContent)` 縮窄型別。注意 `isinstance` 之外完全沒有出現 `.text`:型別檢查器不會放行,因為 `ImageContent` 有的是 `.data`,不是 `.text`。這個聯集誠實地表達了工具可以送什麼給你;你的程式碼也應該如此。 + +### `structured_content`:給應用程式讀的 {#structured_content-what-your-application-reads} + +`structured_content` 是工具回傳值的 JSON 形式,符合工具宣告的 `output_schema`。不用剖析字串,不用猜。 + +兩者同時存在時,是刻意把同一件事講兩遍:`content` 給模型,`structured_content` 給程式碼。結構化的那一半從哪裡來、又該怎麼控制,請見 **[結構化輸出](../servers/structured-output.md)** 頁面。 + +### `is_error`:工具有沒有失敗 {#is_error-whether-the-tool-failed} + +會引發例外的工具,在用戶端這邊**不會**引發例外。它會以一個普通的結果回來,帶著 `is_error=True`。 + +!!! check + 向 `lookup_book` 要 `"Solaris"`(目錄裡沒有的書名),函式會引發 `ValueError`。呼叫仍然正常回傳: + + ```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 + ``` + + 例外的訊息落在 `content` 裡,**模型**可以讀到它並再試一次。這是刻意的設計:工具錯誤是對話的一部分,不是當機。在信任 `structured_content` 之前,一定要先看 `is_error`。 + +!!! warning + `is_error=True` 涵蓋的不只是你自己的 `raise`。要一個伺服器根本沒有的工具(`call_tool("does_not_exist", {})`),也不會引發任何例外。你會拿回同樣的形狀:`is_error=True`,`content` 裡是 `Unknown tool: does_not_exist`。只有在伺服器回的是 JSON-RPC **錯誤**而不是結果時,`Client` 的方法才會引發 `MCPError`;伺服器什麼時候產生哪一種,請見 **[處理錯誤](../servers/handling-errors.md)**。 + +## 資源 {#resources} + +資源的動作是成組的:兩種列出的方式,一種讀取的方式。 + +```python title="client.py" hl_lines="22-31" +--8<-- "docs_src/client/tutorial004.py" +``` + +* `list_resources()` 回傳**具體**的資源,也就是 URI 固定的那些。這裡是:`['catalog://genres']`。 +* `list_resource_templates()` 回傳**參數化**的那些。這裡是:`['catalog://genres/{genre}']`。它們分成兩個清單,因為範本在填好之前是不能讀的。 +* `read_resource(uri)` 接受一個普通的 `str` URI,兩種都適用:傳入 `"catalog://genres/poetry"`,伺服器會把它比對到範本。 + +`read_resource` 回傳 `contents`,一個由 `TextResourceContents` 或 `BlobResourceContents` 組成的清單。跟工具內容是同樣的概念:用 `isinstance` 縮窄,再讀 `.text`(或 `.blob`)。 + +用戶端也可以在資源變更時收到通知。在 2025 世代的連線上,這是 `subscribe_resource(uri)` / `unsubscribe_resource(uri)`——一組 `MCPServer` 沒有實作的方法,所以在 2026-07-28 的線路上(那裡已經沒有這些動作了),請求得到的回應是 `-32601`,「Method not found」。2026 的替代方案是 `subscriptions/listen` 串流,這個 `MCPServer` **有**提供——在那裡 `server_capabilities.resources.subscribe` 是 `True`——而用 `client.listen(...)` 來消費它,就是本節的 **[訂閱](subscriptions.md)** 頁面。 + +## 提示詞 {#prompts} + +```python title="client.py" hl_lines="15-20" +--8<-- "docs_src/client/tutorial005.py" +``` + +`list_prompts()` 告訴你伺服器提供什麼,以及每個提示詞需要什麼: + +```python +prompt.name # 'recommend' +prompt.title # 'Recommend a book' +prompt.arguments # [PromptArgument(name='genre', required=True)] +``` + +`get_prompt(name, arguments)` 負責算繪它。引數 dict 是 `str -> str`:提示詞引數永遠是字串。結果是 `messages`,一個 `PromptMessage` 的清單,每個都有 `role` 和一個 `content` 區塊: + +```python +message.role # 'user' +message.content # TextContent(type='text', text='Recommend one poetry book from the catalog and say why.') +``` + +主機會把這些訊息直接交給模型。整個功能就這樣。 + +## 自動完成 {#completions} + +有自動完成處理函式的伺服器,可以在使用者輸入時自動完成提示詞和資源範本的引數。 + +```python title="client.py" hl_lines="27-31" +--8<-- "docs_src/client/tutorial006.py" +``` + +* `ref` 指出你正在填的是**哪一個**提示詞或範本:`PromptReference` 或 `ResourceTemplateReference`。 +* `argument` 是 `{"name": ..., "value": ...}`:引數本身,以及使用者目前為止輸入的內容。 + +答案在 `result.completion.values` 裡。輸入 `"p"`,伺服器回的是 `['poetry']`。伺服器端的部分,以及處理函式如何利用**其他**已填好的引數來縮小建議範圍,請見 **[自動完成](../servers/completions.md)** 頁面。 + +## 分頁 {#pagination} + +每個 `list_*` 方法都接受 `cursor=` 關鍵字引數,每個結果都帶有 `next_cursor`。當 `next_cursor` 是 `None`,表示全部拿到了。 + +```python title="client.py" hl_lines="22-30" +--8<-- "docs_src/client/tutorial007.py" +``` + +這個迴圈對任何伺服器都正確。`MCPServer` 會一頁回傳全部,所以 `next_cursor` 是 `None`,迴圈只跑一次,這也是為什麼大部分程式碼從來不寫它。真正會分頁的伺服器,以及游標遵守的規則,請見 **[分頁](../advanced/pagination.md)**。 + +## 在測試中 {#in-tests} + +不需要處理程序、不需要連接埠的 `Client(mcp)`,本身就已經是伺服器的測試工具了。 + +有一個建構子旗標是專為此設計的:`Client(mcp, raise_exceptions=True)`。它只對記憶體內連線有作用,而 **[測試](../get-started/testing.md)** 頁面會解釋它,並圍繞它建立整套模式。 + +## 重點回顧 {#recap} + +* `Client(x)` 對伺服器物件以記憶體內方式連線,對 URL 字串透過 Streamable HTTP 連線,其他情況則透過傳輸連線。 +* `async with` 就是整個生命週期。在裡面,`server_capabilities` 和 `protocol_version` 已經填好;伺服器有提供時,`server_info` 和 `instructions` 也是。 +* `list_tools()` 給你每個工具的 `name`、`title`、`description` 和 `input_schema`。 +* `call_tool()` 回傳給模型的 `content`、給程式碼的 `structured_content`,以及 `is_error`。會引發例外的工具是一個結果,不是例外。 +* `content` 是區塊型別的聯集;讀取前先用 `isinstance` 縮窄。 +* `list_resources` / `list_resource_templates` / `read_resource`、`list_prompts` / `get_prompt`,以及 `complete` 補齊了其餘的動作。 +* 每個 `list_*` 都接受 `cursor=`;一直迴圈到 `next_cursor` 是 `None` 為止。 + +伺服器可以向**用戶端**要求的東西,以及你如何回應,請見 **[用戶端回呼](callbacks.md)**。 diff --git a/i18n/zh-hant/pages/client/oauth-clients.md b/i18n/zh-hant/pages/client/oauth-clients.md new file mode 100644 index 0000000000..fd0cb76aa6 --- /dev/null +++ b/i18n/zh-hant/pages/client/oauth-clients.md @@ -0,0 +1,143 @@ +--- +translation: + sections: [c6899d3892bd9fa0, 79372cff3cc48a88, 63878d29e87c3e73, 13175843d3588af4, e7e2b9fd516f77de, 758f06399b513c1f, a05d7278487d610b] + tool: 1 +--- +# OAuth 用戶端 {#oauth-clients} + +有些 MCP 伺服器是受保護的。不帶權杖送請求過去,得到的回應是 `401 Unauthorized`。 + +**`OAuthClientProvider`** 就是取得權杖的方式。它根本不是 MCP 物件,而是一個 `httpx2.Auth`,也就是 httpx2 用來「對每個請求做點什麼」的標準掛鉤。把它掛在 `httpx2.AsyncClient` 上,再把那個用戶端交給 Streamable HTTP 傳輸,之後就不用再管它了。 + +這一頁講的是用戶端這一側。要讓自己的伺服器要求權杖,請見 **[授權](../run/authorization.md)**。 + +## Provider {#the-provider} + +```python title="client.py" hl_lines="44-54" +--8<-- "docs_src/oauth_clients/tutorial001.py" +``` + +要給它四樣東西: + +* `server_url`:要連線的 MCP 端點。其餘的一切 provider 都會從這裡自行探索出來。 +* `client_metadata`:就是你會在授權伺服器的「註冊應用程式」表單裡填的內容。 +* `storage`:權杖在兩次執行之間存放的地方。 +* `redirect_handler` 和 `callback_handler`:需要人介入的兩個時刻。 + +檔案裡其他地方都沒有提到 OAuth。`main()` 從頭到尾都看不到權杖。 + +### 用戶端中繼資料 {#client-metadata} + +`OAuthClientMetadata` 就是貨真價實的 [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591) 註冊文件,以 Pydantic 模型的形式呈現。 + +你設定三個欄位,其餘由預設值補上:`grant_types` 已經是 `["authorization_code", "refresh_token"]`,`response_types` 已經是 `["code"]`,正好就是這個 provider 執行的流程。 + +!!! check + 因為它是 Pydantic 模型,所以**在任何一個位元組送上網路之前**就會先驗證。漏掉 `redirect_uris`,建構當場就會失敗,引發一個指名該欄位的 `ValidationError`: + + ```text + redirect_uris + Field required [type=missing, input_value={'client_name': 'Bookshop Agent'}, input_type=dict] + ``` + + 不會開啟瀏覽器,也不會在授權伺服器上留下做到一半的註冊。 + +### 權杖儲存 {#token-storage} + +**`TokenStorage`** 是一個有四個非同步方法的 `Protocol`。不需要繼承任何東西;把這些方法寫出來,任何類別都能當權杖儲存庫: + +* `get_tokens` / `set_tokens` 保存 `OAuthToken`:存取權杖、重新整理權杖、到期時間、範圍。 +* `get_client_info` / `set_client_info` 保存授權伺服器在 provider 幫你註冊時發給你的 `OAuthClientInformationFull`,其中包含你的 `client_id`。 + +上面那個存在記憶體內的版本可以用。但處理程序結束時它就什麼都忘了,所以下次執行又得整套流程重來一遍。把它持久化到檔案或平台的鑰匙圈裡,下次執行就會安安靜靜。 + +!!! tip + 要存 `client_info`,不要只存權杖。provider 第一次找不到已儲存的 `client_info` 時會動態註冊。把它丟掉,每次執行就會產生一筆全新的註冊。 + +### 兩個處理函式 {#the-two-handlers} + +授權碼流程只需要人介入一次:得有人登入並按下「允許」。 + +* **`redirect_handler`** 會帶著組裝完整的授權 URL 被 await。`client_id`、`redirect_uri`、`state` 和 PKCE challenge 都已經在裡面。你唯一的工作是讓瀏覽器開到那裡。桌面應用程式會呼叫 `webbrowser.open`;這個檔案則把它印出來。 +* **`callback_handler`** 接著被 await。它會等到使用者回到你的 `redirect_uri`,再把那次重新導向的查詢參數以 `AuthorizationCodeResult` 回傳。 + +真正的用戶端會在重新導向 URI 上跑一個小型本機 HTTP 伺服器,而不是呼叫 `input()`。形狀完全一樣:接收重新導向,交回 `code`、`state` 和 `iss`。 + +!!! warning + `state` 和 `iss` 要原封不動地傳回去。provider 會拿 `state` 與自己產生的那個比對,拿 `iss` 與探索到的 issuer 比對,不一致就拒絕。它們分別是 CSRF 與伺服器混淆攻擊的防線。 + +### 放進 `Client` {#into-the-client} + +看看 `main()`。provider 掛在 **httpx2 用戶端**上,httpx2 用戶端放進 `streamable_http_client(url, http_client=...)`,那個傳輸再放進 `Client`。 + +`streamable_http_client` 沒有 `auth=` 關鍵字引數。凡是 HTTP 層級的東西(驗證、標頭、逾時、代理)都屬於你自備的 `httpx2.AsyncClient`。這種分層的說明請見 **[用戶端傳輸方式](transports.md)**。 + +## Provider 幫你做的事 {#what-the-provider-does-for-you} + +`Client` 第一次送出請求時,伺服器回應 `401`。provider 接手: + +1. **探索。** 讀取 `WWW-Authenticate` 標頭,從 `/.well-known/oauth-protected-resource` 抓取伺服器的 Protected Resource Metadata,得知是哪個授權伺服器在保護這個資源,再去抓取**那個**伺服器的中繼資料。 +2. **註冊。** 儲存庫裡什麼都沒有?它會用你的 `OAuthClientMetadata` 動態註冊,並把結果存起來。 +3. **授權。** 產生 PKCE 配對和一個 `state`,組出授權 URL,await 你的 `redirect_handler`,接著 await 你的 `callback_handler` 取得授權碼。 +4. **交換。** 拿授權碼換得 `OAuthToken`,存起來,然後帶著 `Authorization: Bearer ...` 重送你原本的請求。 + +之後它就很安靜。權杖從儲存庫拿出來用,過期的存取權杖用重新整理權杖更新,只有這些都行不通時才會重跑整個流程。 + +這些你一行都沒寫。還剩兩個關鍵字引數(`client_metadata_url` 和 `validate_resource_url`),這個檔案兩個都用不到。值得認識的是 `client_metadata_url`,下面有它專屬的一節。 + +### 試試看 {#try-it} + +這份文件裡的大多數範例都能用記憶體內的 `Client(server)` 檢驗。這個不行:整個流程的重點就是一個 HTTP `401`,而記憶體內的用戶端和它的伺服器之間根本沒有 HTTP。 + +儲存庫裡附有實際運作的版本。`examples/servers/simple-auth/` 會執行一個獨立的授權伺服器和一個受保護的 MCP 伺服器;`examples/clients/simple-auth-client/` 則是這一頁的用戶端長成的一個小型 CLI。它的 README 有那兩個指令:啟動伺服器、對著它們執行用戶端,就能看著上面四個步驟依序發生。 + +## Client ID Metadata Documents {#client-id-metadata-documents} + +規格的 2026-07-28 修訂版已棄用動態用戶端註冊,改用 **Client ID Metadata Documents**(CIMD)。用戶端不再對遇到的每個授權伺服器 POST 一筆新的註冊,而是在一個穩定的 HTTPS URL 上發布一份描述自己的 JSON 文件,而那個 URL **就是**它的 `client_id`。文件由授權伺服器去抓取;provider 完全不碰它。 + +SDK 已經支援:建構 provider 時把那個 URL 以 `client_metadata_url=` 傳入即可。當授權伺服器的中繼資料宣告 `client_id_metadata_document_supported: true` 時,provider 會完全跳過 `/register` 請求:URL 以 `client_id` 的身分進入流程,而且沒有 `client_secret`。當伺服器沒有宣告(目前多數都還沒有),或者你根本沒傳 URL,provider 會**默默地**退回動態註冊,上面的一切照原樣運作。已儲存的 `client_info` 仍然優先於這兩者。 + +URL 必須是 HTTPS 且路徑不能是根路徑;否則在建構時就會引發 `ValueError`,不會發生任何網路動作。隨附的 `examples/clients/simple-auth-client/` 透過 `MCP_CLIENT_METADATA_URL` 環境變數接收它。 + +## 機器對機器 {#machine-to-machine} + +夜間排程、CI 步驟、另一個服務。沒有瀏覽器,也沒有人可以按「允許」。這就是 **client credentials** 授權類型:你手上已經有 `client_id` 和 `client_secret`,權杖端點就是整個流程。 + +`ClientCredentialsOAuthProvider` 是同一個 `httpx2.Auth`,只是少了人: + +```python title="client.py" hl_lines="4 27-33" +--8<-- "docs_src/oauth_clients/tutorial002.py" +``` + +改變的地方: + +* 沒有 `OAuthClientMetadata`,沒有處理函式。傳入 `client_id` 和 `client_secret`;provider 會圍繞它們建出一筆最精簡的 `client_credentials` 註冊,並完全跳過動態註冊。 +* `scope` 是以空格分隔的字串,也就是 OAuth 的線路格式。 +* 下游的一切完全相同:同樣的 `TokenStorage`、同樣的 `httpx2.AsyncClient(auth=...)`、同樣的 `streamable_http_client`。 + +預設情況下,secret 在權杖請求中以 HTTP Basic 驗證傳送(`client_secret_basic`)。傳入 `token_endpoint_auth_method="client_secret_post"` 可改放進表單主體。有些授權伺服器只接受兩者其中之一。 + +!!! tip + `client_secret` 要從環境變數或祕密管理工具讀取,絕對不要放進版本控制。 + +!!! info + `mcp.client.auth.extensions.client_credentials` 裡還有一個 provider:**`PrivateKeyJWTOAuthProvider`**,給用 JWT 而非共用 secret 來驗證的用戶端使用(`private_key_jwt`,也就是金鑰對與工作負載身分那一類)。它遵循同樣的模式:建構一個,放到 `auth=` 上。同一個模組還附了 `SignedJWTParameters` 和 `static_assertion_provider`,兩個用來建出其 assertion 的輔助工具。 + +還有一種無人介入的情境:用戶端屬於某個企業,由企業的身分提供者(而非使用者)決定它可以連到哪些 MCP 伺服器。那是另一種授權類型,有自己的信任模型,也有自己的頁面:**[身分斷言](identity-assertion.md)**。 + +## 失敗的時候 {#when-it-fails} + +OAuth 流程出錯時,provider 會引發來自 `mcp.client.auth` 的 `OAuthFlowError`。它有兩個子類別。`OAuthRegistrationError` 表示註冊沒有產生可用的用戶端:授權伺服器拒絕替你註冊,或者有註冊,但給的憑證是這個流程用不了的(例如它沒有實作的驗證方法)。`OAuthTokenError` 表示無法取得權杖:權杖端點拒絕了,或者已儲存的用戶端紀錄帶著這個用戶端無法套用的驗證方法——這會在組裝權杖請求時就回報,而不是送出之後。一個 `except OAuthFlowError:` 就能涵蓋探索、註冊、授權與交換。 + +不是所有問題都是流程錯誤。網路還是可能出錯;那些是一般的 `httpx2` 例外,會原封不動地往外傳遞。 + +## 重點回顧 {#recap} + +* `OAuthClientProvider` 是一個 `httpx2.Auth`。放到 `httpx2.AsyncClient` 上,再把它傳給 `streamable_http_client(url, http_client=...)`,`Client` 永遠不會知道發生過 OAuth。 +* 你提供四樣東西:伺服器 URL、一個 `OAuthClientMetadata`、一個 `TokenStorage`,以及 redirect/callback 這一對處理函式。 +* `TokenStorage` 是一個 `Protocol`:四個非同步方法,沒有基底類別。除了權杖,也要持久化 `client_info`。 +* 探索、註冊(動態的,或透過 **Client ID Metadata Document**)、PKCE、`state` 與 `iss` 檢查,以及權杖重新整理,都是 provider 的工作,不是你的。 +* `ClientCredentialsOAuthProvider` 是無人介入的版本:`client_id` + `client_secret`,沒有處理函式,沒有瀏覽器。 +* 每一種 OAuth 失敗都是 `OAuthFlowError`;`OAuthRegistrationError` 和 `OAuthTokenError` 是它的子類別。 + +這次交握的另一半,也就是讓你的**伺服器**要求權杖,請見 **[授權](../run/authorization.md)**。 diff --git a/i18n/zh-hant/pages/client/session-groups.md b/i18n/zh-hant/pages/client/session-groups.md new file mode 100644 index 0000000000..504fd1c1ab --- /dev/null +++ b/i18n/zh-hant/pages/client/session-groups.md @@ -0,0 +1,85 @@ +--- +translation: + sections: [09c857a25a9dc37a, 43bc6a76a243a50e, 0a716022a88768df, 4b7f78042bfcfff7, c112662e61b03315, 58974ba1f489a8b4, d18adbdbb835ea73] + tool: 1 +--- +# 工作階段群組 {#session-groups} + +一個 `Client` 只連到一台伺服器。實際的應用程式往往需要好幾台(搜尋伺服器、資料庫伺服器、內部 API),結果得替每一台各自管理一條連線和一份工具清單。 + +**`ClientSessionGroup`** 是單一物件,裡面握有多條連線,並把它們公開的所有東西合併成一個統一的檢視。 + +## 兩台伺服器 {#two-servers} + +先從兩台普通的伺服器開始。它們彼此毫無關係,所以很自然地都把自己的工具取名為 `search`: + +```python title="library_server.py" hl_lines="7" +--8<-- "docs_src/session_groups/tutorial001.py" +``` + +```python title="web_server.py" hl_lines="7" +--8<-- "docs_src/session_groups/tutorial002.py" +``` + +## 一個群組 {#one-group} + +建立一個 `ClientSessionGroup`,然後對每台伺服器各呼叫一次 **`connect_to_server`**: + +```python title="client.py" hl_lines="10-12" +--8<-- "docs_src/session_groups/tutorial003.py" +``` + +* `connect_to_server` 接受的是傳輸參數,不是伺服器物件:用 `StdioServerParameters`(來自 `mcp`)啟動子處理程序,或用 `StreamableHttpParameters` / `SseServerParameters`(來自 `mcp.client.session_group`)連到已經在某個 URL 上監聽的伺服器。 +* `group.tools` 是一個 `dict[str, Tool]`,收集所有已連線伺服器的工具。`group.resources` 和 `group.prompts` 的結構相同。 +* `group.call_tool(name, arguments)` 會查詢名稱、找出擁有它的工作階段(session),再把呼叫轉送過去。你永遠不需要指明是哪台伺服器。 + +!!! check + 把 `client.py` 放在兩台伺服器旁邊執行。第二次 `connect_to_server` 會拒絕: + + ```text + mcp.shared.exceptions.MCPError: {'search'} already exist in group tools. + ``` + + 這是一個 `MCPError`,在第二台伺服器的任何東西被登記之前就引發了。名稱在**整個**群組內必須唯一,而兩台你無法掌控的伺服器遲早會撞名。 + +## `component_name_hook` {#component_name_hook} + +這個問題要在群組這邊解決,而不是在伺服器端。傳入一個接收 `(name, server_info)` 的函式,群組會對它登記的每個名稱執行這個函式: + +```python title="client.py" hl_lines="7-8 15" +--8<-- "docs_src/session_groups/tutorial004.py" +``` + +再執行一次。`print(sorted(group.tools))` 現在兩個都會顯示: + +```text +['Library.search', 'Web.search'] +``` + +* **鍵**是你自己決定的。`by_server` 用 `server_info.name` 組出來,也就是每個 `MCPServer(...)` 建構時傳入的名稱。 +* 裡面的 `Tool` 完全沒動:`group.tools["Web.search"].name` 仍然是 `"search"`,而這也是 `call_tool` 放上線路的名稱。前綴永遠不會離開你的處理程序。 +* 不只工具如此。圖書館的 `hours` 資源登記為 `Library.hours`。 + +!!! tip + 這個 hook 會對**每台**伺服器的**每個**名稱執行,不只在衝突時才執行:沒有所謂「撞名才加前綴」的模式。選定一套命名規則,讓它套用到所有地方。 + +## 新增與移除伺服器 {#adding-and-removing-servers} + +`connect_to_server` 會回傳它開啟的 `ClientSession`。如果之後可能想拿掉那台伺服器,就把它留著:`await group.disconnect_from_server(session)` 會把它的工具、資源和提示詞從群組中移除。 + +如果手上已經有一個連線中的 `ClientSession`(`Client.session` 就是一個),改把它交給 `await group.connect_with_session(server_info, session)`,不必另開新的傳輸。它彙整的方式相同。群組永遠不會關閉不是它自己開啟的工作階段。`server_info` 用來替伺服器命名,供元件前綴使用;在 2026 世代的連線上,`client.server_info` 可能是 `None`(身分是選填的),這種情況下就傳入你自己的 `Implementation(name=..., version=...)`。 + +## 傳統的交握 {#the-classic-handshake} + +`ClientSessionGroup` 建立在 `ClientSession` 之上,而不是 `Client`。每次 `connect_to_server` 都會執行傳統的 `initialize` 交握,從不送出 **[協定版本](../protocol-versions.md)** 裡描述的 `server/discover` 探測。每台 MCP 伺服器都懂這套交握,所以這不會讓你犧牲任何相容性;只是代表群組面對一台本來能做得更好的伺服器時,走的是比較舊、比較慢的路徑。 + +## 重點回顧 {#recap} + +* `ClientSessionGroup` 握有多條伺服器連線,並把它們的工具、資源和提示詞各自合併成一個 `dict`。 +* 每台伺服器呼叫一次 `connect_to_server(params)`。它接受傳輸參數,絕不是 `Client` 接受的伺服器物件或 URL。 +* `group.call_tool(name, arguments)` 會替你轉送到擁有該工具的伺服器。 +* 名稱在整個群組內必須唯一;兩台都有 `search` 工具的伺服器無法原樣共存。 +* `component_name_hook=` 會改寫每個登記的名稱。dict 的鍵會變,線路上的名稱不變。 +* `connect_with_session` 加入你已經握有的工作階段;`disconnect_from_server` 移除一個。 + +群組使用的交握(以及 `Client` 偏好的那套更快的交握)是 **[協定版本](../protocol-versions.md)** 的主題。 diff --git a/i18n/zh-hant/pages/client/subscriptions.md b/i18n/zh-hant/pages/client/subscriptions.md new file mode 100644 index 0000000000..fc9e4f6a40 --- /dev/null +++ b/i18n/zh-hant/pages/client/subscriptions.md @@ -0,0 +1,88 @@ +--- +translation: + sections: [8f9558e57f29eee1, a88c587739e0465c, 46ebfd5b325ed041, 4d10b00b57ce4bd9, 2cdb0edd1f59b3e2] + tool: 1 +--- +# 訂閱 {#subscriptions} + +伺服器的目錄不是固定的。工具會在執行時出現,資源 URI 背後的內容也會改變。用戶端透過 `client.listen(...)` 得知這些變化:一個 `subscriptions/listen` 請求,它的回應**就是**串流。這條串流會一直開著,承載用戶端所要求的變更通知。 + +這一頁講的是用戶端這一端:開啟串流、在主流程旁邊監看它,以及處理它的結束。發布變更、篩選和提供這個方法,則是伺服器那一邊的事,寫在「在處理函式內部」底下的 **[訂閱](../handlers/subscriptions.md)**。這裡的範例對接的是在那一頁建立的衝刺看板(sprint-board)伺服器。 + +## 監看串流 {#watching-the-stream} + +一個訂閱就是一個上下文管理器。進入它會送出請求,把你的關鍵字引數當作訂閱的篩選條件,並等待伺服器的確認,所以區塊開始時串流已經是活的了。 + +```python title="client.py" hl_lines="15 18 28" +--8<-- "docs_src/subscriptions/tutorial003.py" +``` + +迭代會產生四種有型別的事件:`ToolsListChanged`、`PromptsListChanged`、`ResourcesListChanged` 和 `ResourceUpdated(uri=...)`。 + +事件只說**什麼**變了,從不說**怎麼**變的。這就是 `follow_board` 會呼叫 `read_resource` 和 `list_tools` 的原因:事件是重新擷取的信號。讀 `event.uri`,不要自己假設是哪個資源變動了:篩選條件可以列出好幾個 URI,伺服器也可能回報其中某個 URI 的子資源有變更。 + +等著被取用的重複事件會合併成一個,而重新擷取仍然能拿到目前的狀態。只有完全相同的事件才會合併:兩個 URI 不同的 `ResourceUpdated` 是兩個事件。 + +這個訂閱物件還有兩個屬性: + +* `sub.honored` 是伺服器確認的篩選條件:一個 `SubscriptionFilter`,帶有你傳入的欄位,以屬性的方式讀取(`sub.honored.prompts_list_changed`)。`MCPServer` 會接受你要求的每一種,所以它會把你的請求原樣回傳。支援較少種類的伺服器確認的也較少,而且被接受的種類仍可能永遠不會觸發。伺服器也可能拒絕整個請求而不是確認它(見伺服器那一頁的[決定誰可以監看](../handlers/subscriptions.md#deciding-who-may-watch)),這會以該請求的錯誤呈現。 +* `sub.subscription_id` 是 listen 請求的 id,也就是蓋在這條串流每個訊框上的那個 id。可以同時開著好幾個訂閱,各自靠自己的 id 解多工。 + +## 監看而不阻塞 {#watching-without-blocking} + +`follow_board` 會一直執行到伺服器關閉串流為止,而這可能永遠不會發生,所以單獨執行時它會佔據你的整個程式。實際的用戶端希望監看器在主流程**旁邊**執行:代理程式呼叫工具的同時,監看器讓快取或 UI 保持最新。 + +先開啟訂閱,再啟動監看器,然後繼續做你的事。 + +=== "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` 從第一個範例匯入 `BOARD` 和 `read_board`,這個 repo 把它存成 `tutorial003.py`。如果你把產生出來的檔案並排存成 `client.py` 和 `app.py`,就改寫成 `from client import BOARD, read_board`。更下面的 `watch.py` 範例也用同樣的方式匯入 `read_board`。 + +重點在於順序。沒有任何東西會重播,所以在你的串流存在之前發布的事件就錯過了。進入 `client.listen(...)` 會等待確認,所以從那一刻起的每個變更都會送到監看器,而你在區塊內取得的快照不會漏掉任何一個。 + +串流開著的時候,請求可以自由地在旁邊執行,不管來自監看器任務還是其他任務,都在同一個用戶端上。因為**重複**的未取用事件會合併,忙碌的主流程可能只產生一次重新擷取,而不是三次。不同的事件不會合併:列出許多 URI 的篩選條件會為每個 URI 各排一個待處理事件。 + +要停止監看,離開區塊就好:沒有 `unsubscribe` 呼叫。取消擁有該區塊的任務就會幫你做到這件事,SDK 會依傳輸方式預期的方法取消 listen 請求:在 Streamable HTTP 上,就是關閉該請求的串流。在應用程式整個存活期間執行的監看器永遠不會自己結束,所以在關閉時取消它,或取消它所屬任務群組的範圍。 + +## 串流會結束 {#streams-end} + +串流的結束方式有兩種,兩種都是一般的控制流程。伺服器優雅地關閉會結束 `async for`;突然中斷則會引發 `SubscriptionLost`。 + +兩者的差別在於診斷,而不在於接下來該做什麼:串流沒了,沒有任何東西會重播,還在意的監看器就重新 listen 並重新擷取。 + +```python title="watch.py" hl_lines="16 20" +--8<-- "docs_src/subscriptions/tutorial005.py" +``` + +伺服器會因為自己的理由優雅地關閉串流,包括甩掉積壓太多的訂閱者,所以乾淨的結束並不是該停止監看的信號。重新 listen 之前先退避一下。 + +`SubscriptionLost` 也有一個本地端的成因。用戶端最多保留 1024 個未取用的事件,落後到這種程度的取用端會失去訂閱,而不是無限制地膨脹。讓 `async for` 的本體保持簡短,慢的工作放到別處做。 + +`keep_following` 只攔截 `SubscriptionLost`。進入 `listen()` 也可能引發 `MCPError`(連線失敗,或伺服器不提供這個方法)、`TimeoutError`(沒有收到確認)和 `ListenNotSupportedError`(2026 之前的連線)。決定其中哪些是監看器該重試的:最後一種永遠不會自己好。 + +## 重點回顧 {#recap} + +* 進入 `async with client.listen(...)`;進入時會等待確認,所以之後發布的東西都不會漏掉。 +* 用 `async for event in sub` 迭代。事件是重新擷取的信號,從來不是承載內容。 +* 先開啟訂閱,再把監看器當成任務執行,工具呼叫就能在旁邊持續進行。 +* 乾淨的結束會讓迴圈停下;中斷則引發 `SubscriptionLost`。不管哪一種:重新 listen、重新擷取,但先退避。 +* 離開區塊就是取消訂閱。 + +發布這些事件、縮小篩選條件,以及擴展到超過一個處理程序,是伺服器那一邊的事:**[訂閱](../handlers/subscriptions.md)**。同樣這些事件也能讓用戶端快取保持正確,而 **[快取](caching.md)** 就是下一頁。 diff --git a/i18n/zh-hant/pages/client/transports.md b/i18n/zh-hant/pages/client/transports.md new file mode 100644 index 0000000000..7c262749bb --- /dev/null +++ b/i18n/zh-hant/pages/client/transports.md @@ -0,0 +1,114 @@ +--- +translation: + sections: [9cac816674181eb0, 0700f337babcd4dd, 2bde0dd58cdf00f5, ff7401df479af877, 3d0832f39b0d7059, d4bf7e4479637768, 05e20c0a798860e7] + tool: 1 +--- +# 用戶端傳輸方式 {#client-transports} + +每個 `Client` 都透過一種**傳輸**(transport)和伺服器溝通:也就是實際承載訊息的那個東西。 + +你從來不需要單獨設定它。`Client` 只接受一個位置引數,並依據它的型別判斷要用哪一種傳輸方式。 + +每種傳輸方式的**伺服器**端(`mcp.run()` 做的事,以及你部署的東西)請見 **[執行伺服器](../run/index.md)**。 + +## 記憶體內 {#in-memory} + +直接傳入伺服器物件本身: + +```python title="client.py" hl_lines="14" +--8<-- "docs_src/client_transports/tutorial001.py" +``` + +沒有子處理程序,沒有連接埠,線路上也沒有任何位元組。用戶端和伺服器是同一個處理程序裡的兩個物件,但呼叫仍然會經過真正的協定層:`search_books` 被列出、驗證、呼叫的方式,和透過 HTTP 時完全一樣。 + +所以它同時是兩樣東西: + +* **測試工具。** 這份說明文件裡的每個範例都是這樣跑過的,而 **[測試](../get-started/testing.md)** 那一頁整個模式就是圍繞它建立的。 +* **嵌入用的 API。** 自己建立伺服器的應用程式,不需要繞一圈網路就能呼叫它的工具。 + +## Streamable HTTP {#streamable-http} + +傳入一個 URL 字串,得到的就是 **Streamable HTTP**,也就是部署時使用的那種傳輸方式: + +```python title="client.py" hl_lines="5" +--8<-- "docs_src/client_transports/tutorial002.py" +``` + +這就是完整的正式環境用戶端。`Client` 會替你把 URL 包進 `streamable_http_client(...)`,底下是一個依 MCP 需求設定好的 `httpx2.AsyncClient`:`follow_redirects=True`、connect/write/pool 的逾時為 30 秒,read 逾時則是 300 秒,因為伺服器可能會讓回應串流一直開著。 + +!!! check + 建立好的 `Client` **還沒有**連線。建立只是選定傳輸方式;真正開啟它的是 `async with`。在進入之前就去拿連線,SDK 會直接告訴你: + + ```text + RuntimeError: Client must be used within an async context manager + ``` + + 寫下 `Client("http://...")` 的時候,沒有解析、抓取或啟動任何東西。那一行是零成本的。 + +### 自備 `httpx2.AsyncClient` {#bring-your-own-httpx2asyncclient} + +一旦需要 `Authorization` 標頭、cookie、proxy、mTLS,或不同的逾時,就自己建立 `httpx2.AsyncClient`,再交給 `streamable_http_client`: + +```python title="client.py" hl_lines="8-14" +--8<-- "docs_src/client_transports/tutorial003.py" +``` + +有兩件事要注意: + +* `httpx2.AsyncClient` 是你的,所以由**你**負責進入和離開它。SDK 永遠不會關閉不是它自己建立的用戶端。 +* `streamable_http_client(url, http_client=...)` 回傳的是一個傳輸,而 `Client(transport)` 和接受其他東西一樣接受它。 + +關於 TLS 有一點要提:`httpx2` 是對照作業系統的信任存放區驗證憑證(透過 [`truststore`](https://pypi.org/project/truststore/)),而不是內建的 CA 清單。在沒有可用系統 CA 存放區的環境(某些精簡容器)裡,請設定標準的 `SSL_CERT_FILE`/`SSL_CERT_DIR` 環境變數,或明確傳入 `verify=ssl_context` 給你的 `httpx2.AsyncClient`(背景說明請見 [`httpx` 和 `httpx-sse` 已由 `httpx2` 取代](../migration.md#httpx-and-httpx-sse-replaced-by-httpx2))。 + +!!! warning + `streamable_http_client` 以前可以直接接受 `headers=` 和 `timeout=`。現在不行了:它僅有的參數是 `url`、`http_client` 和 `terminate_on_close`。如果習慣性地寫了 `headers=`,會得到: + + ```text + TypeError: streamable_http_client() got an unexpected keyword argument 'headers' + ``` + + 所有跟 HTTP 有關的設定,現在都放在你傳入的那一個 `httpx2.AsyncClient` 上。 + +!!! info + `httpx2` 保留了熟悉的 `httpx` API,所以只要會用 `httpx`,就已經知道這裡的驗證、proxy、事件掛鉤、重試和連線數限制該怎麼做。SDK 沒有在上面加任何東西,也沒有拿掉任何東西。OAuth 也是從這裡接上的:`httpx2.AsyncClient(auth=OAuthClientProvider(...))`。整個流程請見 **[OAuth 用戶端](oauth-clients.md)**。 + +## stdio {#stdio} + +**stdio** 伺服器是一個子處理程序。用戶端啟動它,把 JSON-RPC 寫進它的 stdin,再從它的 stdout 讀取 JSON-RPC。桌面版 MCP 主機(host)就是這樣在你的機器上執行伺服器的:主機**就是**這段程式碼加上一個 UI,而 **[連接到真正的主機](../get-started/real-host.md)** 則是從主機那一側、以設定檔的形式看同一個關係。 + +用 `StdioServerParameters` 描述這個處理程序,用 `stdio_client` 把它變成傳輸,再把**那個**交給 `Client`: + +```python title="client.py" hl_lines="4-8 12" +--8<-- "docs_src/client_transports/tutorial004.py" +``` + +`Client` 不接受單獨的參數物件。`StdioServerParameters` 是設定;`stdio_client(server)` 才是知道怎麼依據它啟動處理程序的傳輸。一定要包起來。 + +離開 `async with` 區塊時,子處理程序也會一併關閉:關掉 stdin、等待、拖太久就強制終止。你永遠不需要自己清理。 + +!!! warning + 子處理程序**不會**繼承你的環境。它拿到的是一份精簡的允許清單(POSIX 上是 `HOME`、`LOGNAME`、`PATH`、`SHELL`、`TERM` 和 `USER`),這樣敏感的東西才不會洩漏到一個可能不是你寫的處理程序裡。 + + 需要 API 金鑰的伺服器在那裡是找不到的。請用 `env=` 明確傳入;這些變數會疊加在允許清單之上。上面的 `BOOKSHOP_API_KEY` 做的就是這件事。 + +## SSE {#sse} + +`mcp.client.sse` 裡的 `sse_client(url)` 是被 Streamable HTTP 取代的那個 HTTP 傳輸。要和還在講它的伺服器溝通,用同樣的方式包起來即可:`Client(sse_client("http://localhost:8000/sse"))`,但不要在它上面蓋任何新東西。 + +## `Transport` 協定 {#the-transport-protocol} + +對 `Client` 來說,上面這些全都是同一種東西。 + +**傳輸**是任何會產出一對 `(read, write)` 訊息串流的非同步 context manager:正式地說,就是 `mcp.client` 裡的 `Transport` 協定。`Client` 依型別解析它的引數:伺服器物件就在處理程序內連線,`str` 會變成 `streamable_http_client(url)`,其他任何東西則直接當成傳輸進入。最後這條規則就是為什麼 `stdio_client(...)`、`streamable_http_client(...)` 和 `sse_client(...)` 都能放進同一個位置,也是為什麼你可以自己寫一個。 + +## 重點回顧 {#recap} + +* `Client(mcp)`(伺服器物件)在記憶體內連線。用在測試和嵌入。 +* `Client("http://.../mcp")`(URL)透過 Streamable HTTP 連線,也就是正式環境用的傳輸方式。 +* 標頭、驗證、proxy 和逾時都放在你傳給 `streamable_http_client(url, http_client=...)` 的 `httpx2.AsyncClient` 上。沒有 `headers=` 這個關鍵字引數。 +* stdio 是 `Client(stdio_client(StdioServerParameters(...)))`,絕對不是單獨的參數物件。 +* 子處理程序拿到的是允許清單上的環境,不是你的環境;`env=` 會往上加。 +* 傳輸就是任何可以 `async with x as (read, write)` 的東西。只要不是伺服器物件或 URL,`Client` 就會直接交給那個協定處理。 +* 建立 `Client` 是選定傳輸方式。`async with` 才是開啟它。 + +傳輸開啟之後,兩邊得對協定版本達成一致。平常根本不需要去想這件事;真的需要的時候,請看 **[協定版本](../protocol-versions.md)**。 diff --git a/i18n/zh-hant/pages/deprecated.md b/i18n/zh-hant/pages/deprecated.md new file mode 100644 index 0000000000..473448d80c --- /dev/null +++ b/i18n/zh-hant/pages/deprecated.md @@ -0,0 +1,86 @@ +--- +translation: + sections: [20541a40dbdd5980, 01262a123ad9501d, 429db5b574a2ac08, 56b2d49da412cb28, 6a1717123fe4513c] + tool: 1 +--- +# 已棄用的功能 {#deprecated-features} + +2026-07-28 規格讓五樣東西退場。SDK 仍然實作了其中每一項,而每一項現在都帶有**棄用警告**。 + +下表列出每一項已棄用的功能、它為什麼要退場,以及應該改用的替代做法。 + +## 哪些已棄用 {#what-is-deprecated} + +| 已棄用項目 | 原因 | 替代做法 | +|---|---|---| +| **根目錄(roots)**:`ctx.session.list_roots()`、`client.send_roots_list_changed()`、傳給 `Client(...)` 的 `list_roots_callback=` | [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) 讓這項能力退場。 | 把路徑當成一般的工具引數或資源 URI 來接收,或是在 `InputRequiredResult` 裡嵌入一個 `ListRootsRequest`(請見 **[多輪往返(multi-round-trip)請求](handlers/multi-round-trip.md)**)。 | +| **伺服器發起的取樣(sampling)**:`ctx.session.create_message()`、傳給 `Client(...)` 的 `sampling_callback=` | SEP-2577 讓這項能力退場。 | 回傳 `InputRequiredResult`,讓用戶端重試這次呼叫(請見 **[多輪往返請求](handlers/multi-round-trip.md)**)。 | +| **協定記錄**:`ctx.log()`、`ctx.debug()`、`ctx.info()`、`ctx.warning()`、`ctx.error()`、`ctx.session.send_log_message()`、`client.set_logging_level()` | SEP-2577 讓這項能力退場。協定內沒有任何東西取代它。 | 用一般的 `import logging` 輸出到 stderr(請見 **[記錄](handlers/logging.md)**)。 | +| **`ping`**:`client.send_ping()` | 從協定中**移除**,而不只是棄用。2026-07-28 裡沒有 `ping` 方法。 | 什麼都不用。它只在 `mode="legacy"` 的連線上有效。 | +| **用戶端到伺服器的進度**:`client.send_progress_notification()` | 2026-07-28 讓進度只能從伺服器送往用戶端。 | 沒有東西要送。**伺服器**用 `ctx.report_progress()` 回報進度(請見 **[進度](handlers/progress.md)**)。 | + +從這張表可以看出三件事: + +* 根目錄、取樣和記錄是一起的。同一份提案 **SEP-2577** 一次棄用了這三項能力。 +* 取樣和根目錄有個更深層的共同問題:它們都是**伺服器**向**用戶端**送出**請求**的地方。2026-07-28 用 **[多輪往返請求](handlers/multi-round-trip.md)** 取代的正是這整個方向。消失的是那些獨立的 RPC 方法(`sampling/createMessage`、`roots/list`,以及推送式的 `elicitation/create`);`CreateMessageRequest`/`ListRootsRequest`/`ElicitRequest` 這些酬載型別則保留下來,嵌在 `InputRequiredResult.input_requests` 裡,在用戶端會觸發同樣的回呼。 +* `ping` 是特例。協定不是棄用它,而是移除它。SDK 的方法仍然會發出警告(訊息寫的是 *removed*,不是 *deprecated*),在現代連線上呼叫它,得到的回應是 *"Method not found"*。 + +## 棄用只是勸告性質 {#deprecated-is-advisory} + +今天什麼都不會壞。 + +上面每個方法,在任何協商到 **2025-11-25 或更早版本**的工作階段(session)上都能繼續運作。在用戶端固定 `mode="legacy"`,就能得到和 2026 之前完全一樣的行為。線路上沒有任何變更,能力協商也維持不變。 + +改變的是,每個方法第一次執行時,你會看到一則明顯的警告: + +```text +MCPDeprecationWarning: The logging capability is deprecated as of 2026-07-28 (SEP-2577). +``` + +`MCPDeprecationWarning` 繼承自 `UserWarning`,**不是** `DeprecationWarning`。這是刻意的:Python 的預設過濾器只會在直接以 `__main__` 執行的程式碼裡顯示 `DeprecationWarning`,這就是為什麼函式庫棄用了某樣東西,卻兩年都沒人注意到。這個警告到處都會出現,不需要 `-W` 旗標。 + +!!! warning + 「勸告性質」到線路為止。取樣和根目錄是伺服器對用戶端的**請求**,而 2026-07-28 的工作階段沒有通道可以承載它。在現代連線上於工具內呼叫 `ctx.session.create_message()`,警告照樣會發出,接著傳送會失敗並出現錯誤: + + ```text + Cannot send 'sampling/createMessage': this transport context has no back-channel + for server-initiated requests. + ``` + + 兩個訊號,依這個順序出現。`MCPDeprecationWarning` 在呼叫方法的那一刻就會發出,任何連線都一樣。錯誤則是 SDK 接著嘗試傳送時回傳來的東西。這兩者只有在用戶端註冊了對應回呼的 `mode="legacy"` 連線上,才能從頭到尾正常運作。 + +## 讓警告靜音 {#silencing-the-warning} + +新程式碼裡,不要這麼做。 + +但如果你維護的伺服器確實在服務 2026 之前的用戶端,它完全有權保持記錄乾淨。在第一個已棄用的呼叫執行之前,先過濾掉這個類別: + +```python +import warnings + +from mcp import MCPDeprecationWarning + +warnings.filterwarnings("ignore", category=MCPDeprecationWarning) +``` + +整個 API 就這樣。沒有逐方法的開關,你也不會想要:只用一個類別的意義在於,一行就能關掉它,一行就能把它叫回來。 + +!!! check + 把過濾器反過來用,就免費得到一個回歸測試。在 pytest 設定的 `filterwarnings` 裡加上 `"error::mcp.MCPDeprecationWarning"`,已棄用的呼叫就會**引發例外**而不是發出警告。一個名為 `old_log`、還在呼叫 `ctx.info()` 的工具會不再通過,開始回報: + + ```text + Error executing tool old_log: The logging capability is deprecated as of 2026-07-28 (SEP-2577). + ``` + + 一行 pytest 設定,已棄用的呼叫就再也沒辦法在不讓測試失敗的情況下溜回程式碼庫。 + +## 重點回顧 {#recap} + +* 2026-07-28 規格棄用了**根目錄**、伺服器發起的**取樣**和協定**記錄**(全部來自 [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)),把**進度**限制為只能從伺服器到用戶端,並移除了 **`ping`**。 +* 替代做法那一欄指引你接下來往哪走:取樣和根目錄看 **[多輪往返請求](handlers/multi-round-trip.md)**,記錄看 **[記錄](handlers/logging.md)**,進度看 **[進度](handlers/progress.md)**。`ping` 什麼都不需要。 +* 棄用只是勸告性質:線路沒有變更,一切在 2026 之前的工作階段上都能繼續運作,而且你會看到明顯的 `MCPDeprecationWarning`(它是 `UserWarning`,所以預設就會顯示)。 +* 取樣和根目錄還額外需要一條反向通道(back-channel),而 2026-07-28 的工作階段沒有。在現代連線上,它們會先警告,再引發例外。 +* `warnings.filterwarnings("ignore", category=MCPDeprecationWarning)` 會讓整個類別靜音;pytest 裡的 `"error::mcp.MCPDeprecationWarning"` 則把它變成測試失敗。 +* 新程式碼不應該建立在這些東西之上。 + +這份說明文件的其他每一頁教的都是目前的 API。 diff --git a/i18n/zh-hant/pages/get-started/first-steps.md b/i18n/zh-hant/pages/get-started/first-steps.md new file mode 100644 index 0000000000..af48ee7a48 --- /dev/null +++ b/i18n/zh-hant/pages/get-started/first-steps.md @@ -0,0 +1,139 @@ +--- +translation: + sections: [0d6c05bcbf836bf3, 59a7b14eeefc68c1, 7114d8d6daba203f, e8bbb56a98ba7bc9, 5138010f6159901c, f78da7c7c363d4c6, 220a939cab348686] + tool: 1 +--- +# 第一步 {#first-steps} + +**[首頁](../index.md)** 的節奏很快:寫一個伺服器、執行它、呼叫一個工具。 + +這一頁放慢腳步,把伺服器能公開的三種東西都走過一遍,沿途每樣東西都給個名字。 + +## 主機、用戶端與伺服器 {#host-client-and-server} + +接下來每一頁都會看到這三個詞: + +* **主機**(host)是 LLM 應用程式:Claude、IDE、代理執行環境。使用者對話的就是它。 +* **用戶端**位於主機內部,負責講 MCP。主機每連上一個伺服器,就執行一個用戶端。 +* **伺服器**是你用這個 SDK 打造的東西。它把東西公開給用戶端,從不直接和模型溝通。 + +伺服器由你來寫,主機是別人的產品。SDK 也提供一個 `Client`,用來測試你的伺服器,這一頁稍後就會出現。 + +## 三種基本元件 {#the-three-primitives} + +伺服器公開的東西正好只有三種。區分它們的關鍵是**誰決定要用**: + +| 基本元件 | 由誰控制 | 是什麼 | 範例 | +|---------------|-----------------|-----------------------------------------------------|------------------------------------| +| **工具** | 模型 | 模型呼叫來執行動作的函式 | API 呼叫、寫入資料庫 | +| **資源** | 應用程式 | 主機載入到模型上下文的資料 | 檔案內容、API 回應 | +| **提示詞** | 使用者 | 使用者依名稱叫用的可重複使用訊息範本 | 斜線指令、選單項目 | + +「由誰控制」正是這樣拆分的重點所在。工具會執行,是因為**模型**決定呼叫它。資源會被附上,是因為**應用程式**判斷模型需要它。提示詞會執行,是因為**使用者**選了它。 + +!!! info + 如果做過 web API,大部分直覺你已經有了:**資源**是 `GET`(載入資料,不改變任何東西),**工具**是 `POST`(做事,可能有副作用)。**提示詞**沒有 HTTP 的對應物,比較像使用者依名稱執行的已儲存查詢。 + +## 一個伺服器,三種齊備 {#one-server-all-three} + +```python title="server.py" hl_lines="6 12 18" +--8<-- "docs_src/first_steps/tutorial001.py" +``` + +三個普通函式,三個裝飾器。每個裝飾器就是完整的註冊動作: + +* `@mcp.tool()` 讓 `add` 成為**工具**。 +* `@mcp.resource("greeting://{name}")` 讓 `greeting` 成為**資源範本**:URI 裡的 `{name}` 就是函式的參數。 +* `@mcp.prompt()` 讓 `summarize` 成為**提示詞**。它回傳的字串會變成一則使用者訊息。 + +其他一切(名稱、描述、引數 schema)SDK 都從函式本身讀取:函式名稱、docstring、型別提示。這些你從來沒有另外宣告過。 + +!!! tip + SDK 的兩半各有自己的匯入路徑:`from mcp import Client` 和 `from mcp.server import MCPServer`。沒有 `from mcp import MCPServer` 這種寫法。 + +### 試試看 {#try-it} + +用 MCP Inspector 執行它: + +```console +uv run mcp dev server.py +``` + +開啟它印出的 URL。Inspector 每種基本元件各有一個分頁,依序走過一遍。 + +**Tools。**一個項目:`add`,描述為 *Add two numbers.*。表單有一個必填的整數欄位 `a`,另一個是 `b`。填好、呼叫,結果是 `3`。Inspector 是從 `a: int, b: int` 建出那張表單的,其他每個用戶端也一樣。 + +**Resources。**這裡的 *Resources* 清單是空的。`greeting` 在 **Resource Templates** 底下,因為 `greeting://{name}` 帶有參數:在有人提供 `name` 之前,沒有單一資源可以列出。給它 `World` 然後讀取: + +```text +Hello, World! +``` + +**Prompts。**一個項目:`summarize`,只有一個必填的 `text` 引數。帶一段文字去取得它,會收到一則 `role: user` 的訊息,內容就是你組好的字串。提示詞就只是這樣:一個組出訊息的函式。 + +Inspector 透過 **stdio** 執行你的伺服器,這是 MCP 伺服器能使用的傳輸方式之一。現在還不用選;那是 **[執行伺服器](../run/index.md)** 那一頁的事。 + +## 能力 {#capabilities} + +在 Inspector 裡看到了三個分頁。它怎麼知道有三個? + +用戶端連線時,伺服器會宣告自己的**能力**:它會回應哪幾類請求。用戶端據此決定究竟該要求什麼。這份宣告不是你寫的,`MCPServer` 替你宣告好了。 + +自己看看吧。SDK 的 `Client` 直接接受伺服器物件,並在**記憶體內**與它連線(沒有子處理程序,沒有連接埠): + +```python +import asyncio + +from mcp import Client + +from server import mcp + + +async def main() -> None: + async with Client(mcp) as client: + print(client.server_capabilities.model_dump(exclude_none=True)) + + +asyncio.run(main()) +``` + +```text +{'prompts': {'list_changed': True}, 'resources': {'subscribe': True, 'list_changed': True}, 'tools': {'list_changed': True}} +``` + +那個字典就是伺服器宣告的**能力**,也是每個連線進來的用戶端最先得知的事: + +| 能力 | 用戶端現在可以呼叫 | +|-------------|------------------------------------------------------------| +| `tools` | `tools/list`, `tools/call` | +| `resources` | `resources/list`, `resources/templates/list`, `resources/read` | +| `prompts` | `prompts/list`, `prompts/get` | + +`MCPServer` 三種基本元件都提供,所以三者永遠都會宣告。 + +注意少了什麼。`completions`(資源範本和提示詞的引數自動完成)需要你寫一個處理函式,這個伺服器沒有,所以這項能力不存在,守規矩的用戶端也不會去問。所有選用的東西都照這條規則:註冊了,能力就出現;**[自動完成](../servers/completions.md)** 會證明這一點。 + +!!! info + `Client(mcp)` 就是這份文件裡每個範例測試時用的同一個記憶體內用戶端,你也會用它來測試自己的。它有專屬的一整頁:**[測試](testing.md)**。 + +## 你沒寫的東西 {#what-you-did-not-write} + +回頭看這一頁。你寫了三個小小的 Python 函式。你**沒有**寫: + +* JSON Schema。`a: int, b: int` **就是** `add` 的 schema。 +* 請求處理函式。`tools/list`、`resources/read`、`prompts/get`:全都替你處理好了。 +* 能力宣告。`MCPServer` 替你做了。 +* 任何一行協定。版本協商、JSON-RPC 訊框、能力交換:全都發生在 `mcp dev` 和 `Client(mcp)` 裡面,你完全沒看到。 + +這個比例正是 SDK 的意義所在。 + +## 重點回顧 {#recap} + +* **主機**是 LLM 應用程式,**用戶端**是它講 MCP 的那一半,**伺服器**是你打造的東西。 +* 工具由**模型**控制,資源由**應用程式**控制,提示詞由**使用者**控制。 +* 每種基本元件一個裝飾器:`@mcp.tool()`、`@mcp.resource(uri)`、`@mcp.prompt()`。名稱、描述和 schema 都來自函式。 +* 帶 `{param}` 的 URI 會產生資源**範本**,和具體資源分開列出。 +* 伺服器的**能力**會替你宣告好,而用戶端只會要求伺服器宣告過的東西。 +* `Client(mcp)` 在記憶體內連上伺服器物件:從第一天起就是你的測試工具。 + +接下來是 **[連接真正的主機](real-host.md)**:把這個伺服器真的放進 Claude Desktop 或 IDE 裡。然後是 **[測試](testing.md)**:一頁、一個記憶體內用戶端,從此不用猜它到底能不能動。再之後,每種基本元件各有自己的一頁,從模型主導的那個開始:**[工具](../servers/tools.md)**。 diff --git a/i18n/zh-hant/pages/get-started/index.md b/i18n/zh-hant/pages/get-started/index.md new file mode 100644 index 0000000000..534adce26e --- /dev/null +++ b/i18n/zh-hant/pages/get-started/index.md @@ -0,0 +1,53 @@ +--- +translation: + sections: [ed4a756b4c53c585, 97e2fb315b7fe398, 4d04f1c6f4bf6c1d, 577d73078fc62baf] + tool: 1 +--- +# 開始使用 {#get-started} + +剛接觸 MCP,或剛接觸這個 SDK?從這裡開始。這幾頁會帶你從零開始,做出一個能運作、經過測試的伺服器:[安裝 SDK](installation.md)、建立[第一個伺服器](first-steps.md)、[把它接上真正的 MCP 主機(host)](real-host.md),再用記憶體內用戶端[測試它](testing.md)。 + +## 執行程式碼 {#run-the-code} + +所有程式碼區塊都可以直接複製使用:每一個都是完整、可運作的檔案。 + +想跟著做的話,把程式碼區塊貼進 `server.py`,然後用 MCP Inspector 開啟: + +```console +uv run mcp dev server.py +``` + +**強烈建議**自己寫下(或複製)程式碼、動手修改,並在本機執行。在自己的編輯器裡用過,才真正看得出重點在哪:要寫的東西有多少、自動完成的體驗,以及型別檢查在執行之前就幫你抓出錯誤。 + +## 不需要猜 {#you-will-not-be-guessing} + +這份說明文件裡的每個範例,都是 SDK 自己的儲存庫中 [`docs_src/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/docs_src) 底下的完整檔案,而且每一個都由 SDK 的測試套件透過**記憶體內用戶端**實際跑過: + +```python +import pytest +from mcp import Client + +from server import mcp + + +@pytest.mark.anyio +async def test_add() -> None: + async with Client(mcp) as client: + result = await client.call_tool("add", {"a": 1, "b": 2}) + assert result.structured_content == {"result": 3} +``` + +沒有子處理程序、沒有連接埠、沒有傳輸方式。`Client(mcp)` 直接連到伺服器物件。 + +如果 SDK 的某個改動弄壞了這幾頁上的範例,CI 會比頁面先亮紅燈。你在這裡讀到的程式碼,就是實際執行的程式碼。 + +在[測試](testing.md)那一頁你會自己用到這個做法;測試自己的伺服器時也是這樣做。 + +## 接下來往哪裡走 {#where-to-go-next} + +伺服器跑起來之後,這份說明文件的其他部分是參考資料,不是課程。每一頁都可以獨立閱讀,直接跳到需要的地方即可: + +* 伺服器對外提供什麼(工具、資源、提示詞),請見 **[伺服器](../servers/index.md)**。 +* 註冊的函式裡有哪些東西可用,請見 **[在處理函式內部](../handlers/index.md)**。 +* 怎麼把伺服器交到用戶端面前(stdio、HTTP、現有的 FastAPI 應用程式),請見 **[執行伺服器](../run/index.md)**。 +* 打造另一端,也就是**使用** MCP 伺服器的應用程式,請見 **[用戶端](../client/index.md)**。 diff --git a/i18n/zh-hant/pages/get-started/installation.md b/i18n/zh-hant/pages/get-started/installation.md new file mode 100644 index 0000000000..c81972db1e --- /dev/null +++ b/i18n/zh-hant/pages/get-started/installation.md @@ -0,0 +1,45 @@ +--- +translation: + sections: [6e2f9bab94d5ed36, 8cf653388f69e28b, 6fd9ea2f65de0df6] + tool: 1 +--- +# 安裝 {#installation} + +Python SDK 在 PyPI 上的套件名稱是 [`mcp`](https://pypi.org/project/mcp/),需要 **Python 3.10+**。 + +這份文件描述的是 **v2**,也就是目前的穩定版本線: + +=== "uv" + + ```bash + uv add "mcp[cli]" + ``` + +=== "pip" + + ```bash + pip install "mcp[cli]" + ``` + +!!! note "從 v1 過來的嗎?" + v2 是含有破壞性變更的主要版本,**[遷移指南](../migration.md)**逐一說明了每一項變更。如果你的**套件**依賴 `mcp` 而且還沒準備好遷移,請保留 `<2` 的版本上限(例如 `mcp>=1.28,<2`),這樣沒有鎖定版本的解析結果就會停留在 1.x 版本線。 + +## 會安裝哪些東西 {#what-gets-installed} + +使用 SDK 不需要知道這些,但如果你好奇每個相依套件的用途: + +* `mcp-types`:所有協定型別(請求、結果、內容區塊)獨立成一個套件,版本與 SDK 同步。依賴 `mcp` 的程式碼透過 `mcp.types` 這個別名匯入(這份文件裡每一個 `from mcp.types import ...` 都是如此);只有在安裝了 `mcp-types` 但沒有安裝 SDK 的專案裡,才直接匯入 `mcp_types`。 +* [`anyio`](https://anyio.readthedocs.io/):非同步執行環境。整個 SDK 都是基於 anyio 寫的,所以在 `asyncio` 或 `trio` 上都能執行。 +* [`pydantic`](https://docs.pydantic.dev/):每個 `mcp.types` 模型的基礎,也負責所有 schema 的產生與驗證。 +* [`httpx2`](https://pypi.org/project/httpx2/):Streamable HTTP 和 SSE **用戶端**傳輸背後的 HTTP 用戶端,內建 server-sent events 支援。 +* [`starlette`](https://www.starlette.io/)、[`uvicorn`](https://www.uvicorn.org/)、[`sse-starlette`](https://pypi.org/project/sse-starlette/) 和 [`python-multipart`](https://pypi.org/project/python-multipart/):HTTP **伺服器**傳輸。 +* [`jsonschema`](https://pypi.org/project/jsonschema/):依照工具宣告的輸出 schema 驗證它的結構化輸出。 +* [`pyjwt[crypto]`](https://pyjwt.readthedocs.io/):授權用的 OAuth 權杖處理。 +* [`opentelemetry-api`](https://opentelemetry-python.readthedocs.io/):只有輕量的 API,所以除非你自己安裝 OpenTelemetry SDK 和匯出器,否則 SDK 的追蹤中介軟體不會帶來任何負擔。 +* [`typing-extensions`](https://typing-extensions.readthedocs.io/) 和 [`typing-inspection`](https://pypi.org/project/typing-inspection/):讓 Python 3.10 也能使用新的型別功能。 +* [`pywin32`](https://pypi.org/project/pywin32/):僅限 Windows,用於 `stdio` 子處理程序管理。 + +## 選用的 extra {#optional-extras} + +* `mcp[cli]` 會加裝 [`typer`](https://typer.tiangolo.com/) 和 [`python-dotenv`](https://pypi.org/project/python-dotenv/),供 `mcp` 命令列工具使用(`mcp dev`、`mcp run`、`mcp install`)。開發期間會用到;部署後的伺服器可能就不需要了。 +* `mcp[rich]` 會加裝 [`rich`](https://rich.readthedocs.io/),讓伺服器記錄更好看。 diff --git a/i18n/zh-hant/pages/get-started/real-host.md b/i18n/zh-hant/pages/get-started/real-host.md new file mode 100644 index 0000000000..0b356c8876 --- /dev/null +++ b/i18n/zh-hant/pages/get-started/real-host.md @@ -0,0 +1,168 @@ +--- +translation: + sections: [3c4f2f06b4e978b6, 22520eecae3d1961, f4e1709db18d635a, 2eb57992049671d9, 1ba83e9af37cc1b4, 4822586344b08d9e, 1c93afef72478992, b6b448f9eddd51dc, fe55370fd931815b] + tool: 1 +--- +# 連接到真正的主機 {#connect-to-a-real-host} + +**主機(host)** 指的是伺服器最後會被放進去的那個應用程式:Claude Desktop、Claude Code、IDE。使用者直接面對、互動的就是主機。在主機內部,MCP **用戶端**會把你的伺服器當成子處理程序啟動,並透過該處理程序的 stdin 和 stdout 與它溝通。 + +也就是說,連接到主機只有一個動作:告訴它**啟動伺服器的指令**。這一頁上的所有內容(兩個 CLI 指令、三個 JSON 檔案),都只是放這同一道指令的不同位置。 + +## 一個伺服器,所有主機 {#one-server-every-host} + +```python title="server.py" hl_lines="3 33-34" +--8<-- "docs_src/real_host/tutorial001.py" +``` + +兩個工具加一個資源,全在一個檔案裡。這個檔案有三件事對下面每個主機都很重要: + +* `mcp.run()` 不帶引數時會啟動 **stdio** 伺服器:它會阻塞,從 stdin 讀取協定訊息,並把訊息寫到 stdout。這一頁上每個主機說的都是這種傳輸方式。主機把你的檔案當成子處理程序啟動,並掌管這兩條管道,所以連接永遠只是「指令在這裡」。不需要挑連接埠,也沒有任何東西在監聽連接埠。 +* `run()` 放在 `if __name__ == "__main__":` 底下。下面所有做法都是**匯入**這個檔案而不是執行它,所以沒有這層保護的 `run()` 會在任何東西載入模組的那一刻就啟動伺服器。 +* 伺服器物件是模組層級的全域變數,名稱是 `mcp`。`mcp run` 找的就是這個名稱(`server` 和 `app` 也可以)。如果取別的名字,就要明確指定:`mcp run server.py:bookshop`。 + +這是這一頁最後一行 Python。從這裡往下全都是主機設定。 + +## 啟動指令 {#the-launch-command} + +下面每個主機拿到的都是同一道指令: + +```bash +uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py +``` + +所有主機共用一道指令,是因為 `uv run --with` 會當場把 SDK 解析進一個全新的環境:從任何目錄都能執行,不需要專案,也不需要啟用虛擬環境。這一點在這裡比任何地方都重要,因為主機是從**它自己**的工作目錄、帶著幾乎空白的環境來啟動伺服器,而不是從你的 shell。 + +這也是 `mcp install` 替你寫進 Claude Desktop 設定檔的指令(見下文),所以手動輸入的和工具產生的會一致,差別只在工具多加了精確的版本鎖定。 + +!!! tip "如果主機找不到 `uv`" + 主機用極簡的 `PATH` 產生你的伺服器處理程序,`uv` 可能不在裡面。把單獨的 `uv` 換成 `which uv`(macOS/Linux)或 `where uv`(Windows)給出的絕對路徑。`mcp install` 寫的正是這個。 + +!!! note "這一頁講的是本機情境" + 這裡的一切都是在主機所在的那台機器上執行伺服器:主機透過 stdio 啟動你的檔案。對個人用或單機工具來說,這完全正確。要把伺服器交給**沒有**你這個檔案的人,給出去的是 **URL** 而不是指令:同一個 `mcp` 物件,改用 Streamable HTTP 提供服務。**[執行伺服器](../run/index.md)** 用一張表講清楚這個抉擇,**[部署與擴展](../run/deploy.md)** 則是從那裡走到真正主機名稱的路。 + + 而主機不過就是內含 MCP 用戶端的應用程式,所以你自己的 Python 也能扮演主機的角色:**[用戶端傳輸方式](../client/transports.md)** 用 `stdio_client(...)` 把同一個檔案當成子處理程序啟動,**[測試](testing.md)** 則在記憶體內連接它,完全不需要處理程序。 + +## Claude Desktop {#claude-desktop} + +SDK 唯一能替你設定的主機: + +```bash +uv run mcp install server.py +``` + +就這樣。`mcp install` 會匯入檔案來讀取伺服器名稱,找到 Claude Desktop 的設定檔,然後把啟動指令寫進去。過程中它會把你的路徑轉成絕對路徑,不用自己動手。 + +沒什麼神祕的。它寫進去的項目長這樣: + +```json +{ + "mcpServers": { + "Bookshop": { + "command": "/absolute/path/to/uv", + "args": [ + "run", + "--frozen", + "--with", + "mcp[cli]==2.0.0", + "mcp", + "run", + "/absolute/path/to/server.py" + ] + } + } +} +``` + +這就是上一節的啟動指令,外加三樣東西:`uv` 的絕對路徑、`--frozen`(讓 `uv` 永遠不會改寫它剛好碰到的 lockfile),以及精確鎖定在你已安裝的 `mcp` 版本。它會寫進 `claude_desktop_config.json`,這個檔案位於: + +* **macOS**:`~/Library/Application Support/Claude/claude_desktop_config.json` +* **Windows**:`%APPDATA%\Claude\claude_desktop_config.json` + +這個檔案可以手寫。`mcp install` 存在的意義,是讓你手寫時不會犯那個經典錯誤(相對路徑)。 + +完全結束 Claude Desktop(不只是關掉視窗),再重新開啟。 + +!!! warning + 如果 Claude Desktop 的設定**目錄**還不存在,`mcp install` 會以 `Claude app not found` 失敗。安裝 Claude Desktop 並執行一次:目錄就是這樣建立的。 + +!!! tip + Claude Desktop 在它自己的處理程序裡啟動你的伺服器,所以 shell 的環境變數不會在那裡。`uv run mcp install server.py -v API_KEY=abc123`(或 `-f .env`)會把它們記錄在項目的 `env` 欄位裡。`--name` 可以覆寫項目名稱;預設為伺服器的 `name`。 + +## Claude Code {#claude-code} + +沒有檔案要編輯。用 `claude` CLI 註冊伺服器;`--` 之後的全部都是啟動指令。 + +```bash +claude mcp add bookshop -- uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py +``` + +在 Claude Code 工作階段裡執行 `/mcp`,確認 `bookshop` 已連線且列出了它的工具。 + +## Cursor {#cursor} + +在專案根目錄建立 `.cursor/mcp.json`。 + +```json +{ + "mcpServers": { + "bookshop": { + "command": "uv", + "args": ["run", "--with", "mcp[cli]", "mcp", "run", "/absolute/path/to/server.py"] + } + } +} +``` + +同樣的 `command` 加 `args`,放在 Claude Desktop 也在用的同一個 `mcpServers` 鍵底下。伺服器會出現在 Cursor 的 MCP 設定裡,兩個工具都會列出來。 + +## VS Code {#vs-code} + +在專案根目錄建立 `.vscode/mcp.json`。 + +```json +{ + "servers": { + "bookshop": { + "type": "stdio", + "command": "uv", + "args": ["run", "--with", "mcp[cli]", "mcp", "run", "/absolute/path/to/server.py"] + } + } +} +``` + +和 Cursor 的檔案只有兩處不同,就這兩處:外層的鍵是 `servers` 而不是 `mcpServers`,而且每個項目都要宣告 `type`。確認信任提示後,在命令選擇區執行 **MCP: List Servers**,就會看到 `bookshop` 正在執行。 + +!!! note + 需要 VS Code 1.99 以上,並安裝已登入的 **GitHub Copilot** 延伸模組(Copilot Free 就夠了),而且 Copilot Chat 必須在 **Agent** 模式,因為其他模式都不會呼叫工具。 + +## 沒有出現 {#it-doesnt-show-up} + +動任何主機設定之前,先自己執行一次啟動指令: + +```bash +uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py +``` + +什麼都不會印出,也不會結束返回。這種沉默是正確的:stdio 伺服器正在等主機先從 stdin 開口(按 `Ctrl-C` 停止)。出現 traceback 或立刻結束才是真正的 bug,而現在可以直接讀到它,不用隔著主機瞎猜。 + +一旦這道指令乖乖停在那裡等,剩下的問題幾乎一定是這三件事之一: + +* **相對路徑。** 主機是從**它自己**的工作目錄啟動伺服器,不是你註冊時所在的目錄。該寫 `/absolute/path/to/server.py` 卻寫成 `server.py`,是最常見的失敗原因。如果主機也找不到 `uv`,那個路徑也得是絕對路徑。 +* **主機還在用舊的設定。** 主機在啟動時讀取設定。特別是 Claude Desktop,必須**完全結束**(不只是關掉視窗)再重新開啟,對 `claude_desktop_config.json` 的修改才會生效。 +* **有東西在轉向的時段之外寫到了 stdout。** 在 stdio 上,stdout **就是**協定。SDK 在提供服務期間會把已 flush 的雜散輸出轉到 stderr,但在那之前就 flush 到 stdout 的輸出(包裝腳本的 echo、未緩衝處理程序中匯入階段的 `print()`),或是在直譯器結束時才排出的緩衝 `print()`,都會交給主機一則損壞的訊息,主機就會斷線。用預設的 `logging` 設定來記錄,它的 stderr handler 會逐筆 flush;自訂 handler 也必須避開 stdout。完整說明請見 **[記錄](../handlers/logging.md)**。 + +Claude Desktop 會為每個伺服器各留一份記錄:`mcp-server-.log` 是伺服器的 stderr,旁邊的 `mcp.log` 記錄連線,macOS 在 `~/Library/Logs/Claude` 底下,Windows 在 `%APPDATA%\Claude\logs`。 + +超出這三件事的問題,請見 **[疑難排解](../troubleshooting.md)**。 + +## 重點回顧 {#recap} + +* **主機**(Claude Desktop、IDE)執行一個 MCP 用戶端,透過 stdio 把你的伺服器當成子處理程序啟動。連接就是給它一道啟動指令。 +* 這道指令是 `uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py`:不用啟用 venv,從任何目錄都能執行。 +* **Claude Desktop** 是 `mcp install` 唯一能替你設定的主機。它把同一道指令(加上 `uv` 的絕對路徑、`--frozen`,以及精確鎖定你已安裝的版本)寫進 `claude_desktop_config.json`,你永遠不必自己動手。 +* **Claude Code** 是 `claude mcp add bookshop -- `。**Cursor** 是 `.cursor/mcp.json`,放在 `mcpServers` 底下。**VS Code** 是 `.vscode/mcp.json`,放在 `servers` 底下,每個項目都有 `type`。 +* 到處都用絕對路徑,改完設定後重新啟動主機,而且除了 SDK 之外,絕不讓任何東西寫到 stdout。 + +這一頁上每個主機都連到同一個檔案,用的是同一道指令。這個檔案能**公開**什麼,就是這份文件其餘的內容:**[工具](../servers/tools.md)**、**[資源](../servers/resources.md)**,以及 **[執行伺服器](../run/index.md)** 裡 stdio 以外的每一種傳輸方式。 diff --git a/i18n/zh-hant/pages/get-started/testing.md b/i18n/zh-hant/pages/get-started/testing.md new file mode 100644 index 0000000000..a67a364dc9 --- /dev/null +++ b/i18n/zh-hant/pages/get-started/testing.md @@ -0,0 +1,96 @@ +--- +translation: + sections: ['4926721070127497', c52a1de2b6b32f40, 2e410b412c25f314, 627195f7159e24ef] + tool: 1 +--- +# 測試 {#testing} + +Python SDK 附帶一個 `Client` 類別,內建**記憶體內傳輸**:把伺服器物件傳給它,它就會直接連上去。 + +沒有子處理程序,沒有連接埠,根本沒有傳輸層。概念和 FastAPI 的 `TestClient` 一樣。 + +## 基本用法 {#basic-usage} + +假設有一個簡單的伺服器,只有一個工具: + +```python title="server.py" +--8<-- "docs_src/testing/tutorial001.py" +``` + +要執行下面的測試,需要兩個額外的(開發用)相依套件: + +=== "uv" + + ```bash + uv add --dev pytest inline-snapshot + ``` + +=== "pip" + + ```bash + pip install pytest inline-snapshot + ``` + +!!! info + 這份說明文件假設你已經會用 [`pytest`](https://docs.pytest.org/en/stable/)。 + + 下面的測試用 [`inline-snapshot`](https://15r10nk.github.io/inline-snapshot/latest/) 來在一行內對整個結果物件做斷言。它會把測試的輸出記錄成你看到的 `snapshot(...)` 字面值。如果不想用它,拿掉 import,像其他測試一樣對你在意的欄位做斷言(`result.content[0].text == "3"`)即可。 + +接著是測試: + +```python title="test_server.py" +import pytest +from inline_snapshot import snapshot +from mcp import Client +from mcp.types import CallToolResult, TextContent + +from server import mcp + + +@pytest.fixture +def anyio_backend(): # (1)! + return "asyncio" + + +@pytest.fixture +async def client(): # (2)! + async with Client(mcp, raise_exceptions=True) as c: + yield c + + +@pytest.mark.anyio +async def test_call_add_tool(client: Client): + result = await client.call_tool("add", {"a": 1, "b": 2}) + # Drop the server identity stamp in `_meta`; it is not what this test is about. + result.meta = None + assert result == snapshot( + CallToolResult( + content=[TextContent(type="text", text="3")], + structured_content={"result": 3}, + ) + ) +``` + +1. 如果用的是 `trio`,改成回傳 `"trio"`。細節請見 [anyio 說明文件](https://anyio.readthedocs.io/en/stable/testing.html#specifying-the-backends-to-run-on)。 +2. 這個 fixture 會 yield 一個已連線的用戶端。每個接收 `client` 的測試都會拿到一條連到同一個伺服器的全新記憶體內連線。 + +這樣就完成了!現在可以擴充測試,涵蓋更多情境。 + +## 為什麼要 `raise_exceptions=True`? {#why-raise_exceptionstrue} + +可能出錯的地方有兩種,而這個旗標只影響其中一種。 + +**你的工具**內部引發的例外不算協定失敗。它會變成一個帶有 `is_error=True` 的正常結果,模型會讀到那則訊息。`raise_exceptions` 不會改變這一點:不管有沒有設定,`call_tool` 都回傳同樣的 `is_error=True` 結果。這部分有一整頁的說明:**[處理錯誤](../servers/handling-errors.md)**。 + +發生在工具本體**之外**的失敗就不一樣了。在 `Client(mcp)` 給你的連線上,伺服器會先把它淨化成通用的 `"Internal server error"`,用戶端才看得到。意外當掉的細節本來就不該洩漏給遠端呼叫端。但在測試裡,這正是你**不**想要的,也正是 `raise_exceptions=True` 改變的地方:測試會看到真正的訊息,而不是淨化過的版本。 + +測試裡就開著它。在正式環境的程式碼裡它沒有任何意義。 + +## 預設為處理程序內連線 {#in-process-by-default} + +!!! note + `Client(mcp)` 以處理程序內的方式連線,而且預設是**世代中立**的:它會探測伺服器,選出合適的協定路徑。如果測試要驗證舊版特有的語意(取樣(sampling)或徵詢(elicitation)的推送、`message_handler`),就固定用 `mode="legacy"`,並且在那裡拿掉 `raise_exceptions=True`:舊版連線本來就不會淨化,而這個旗標會讓失敗在伺服器任務裡重新引發,而不是在你的測試裡。 + +也正是因為這一行,這份說明文件才敢保證範例都能跑:每個範例檔案都由 SDK 自己的測試套件實際執行過,幾乎全部都是透過這個用戶端。你用的工具和 SDK 用在自己身上的是同一個。 + +現在有了一個可以運作、也測試過的伺服器。要把它放進真正的應用程式(Claude Desktop、IDE)裡,請見 **[連接真實的主機(host)](real-host.md)**;其他所有提供服務的方式請見 **[執行伺服器](../run/index.md)**。 diff --git a/i18n/zh-hant/pages/handlers/context.md b/i18n/zh-hant/pages/handlers/context.md new file mode 100644 index 0000000000..1f6ba5f969 --- /dev/null +++ b/i18n/zh-hant/pages/handlers/context.md @@ -0,0 +1,128 @@ +--- +translation: + sections: [b50152f05c81e786, b302059b22fb7cb4, 85682a1bf561243a, 53fc48838eb6837a, b24190e0842786ec, 85f93e150fc9b240] + tool: 1 +--- +# Context {#the-context} + +工具的引數來自模型。其他的一切(正在處理的請求、所在的伺服器、與用戶端溝通的方式)都來自同一個物件:**`Context`**。 + +不需要自己建立,也不需要設定,只要開口要就好。 + +## 開口要它 {#ask-for-it} + +在任何工具上加一個以 `Context` 註記的參數: + +```python title="server.py" hl_lines="2 8" +--8<-- "docs_src/context/tutorial001.py" +``` + +* SDK 會為每個請求建立一個全新的 `Context` 並傳進來。 +* 參數的**名稱不重要**。`ctx`、`context`、`c` 都可以:SDK 是靠型別註記找到它的。 +* 資源和提示詞也可以用同樣的方式宣告一個。 +* `ctx.request_id` 是函式此刻正在處理的那個請求的 id。 + +!!! info + 如果用過 FastAPI,這一招應該不陌生:用框架自己的型別宣告一個參數(那邊是 `Request`,這邊是 `Context`),框架就會幫你補上。不用註冊,不用設定:型別註記就是整套機制。 + +### 模型看不到它 {#invisible-to-the-model} + +這是要記在心裡的部分。以下是 `tools/list` 針對 `search_books` 回報的輸入 schema: + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"} + }, + "required": ["query"], + "title": "search_booksArguments" +} +``` + +只有一個屬性。`ctx` 不是引數:它永遠不會出現在 schema 裡,模型永遠不會知道它的存在,也沒有任何用戶端能填入它。這是你和 SDK 之間的約定,在線路上看不到。 + +### 試試看 {#try-it} + +用 MCP Inspector 執行伺服器: + +```console +uv run mcp dev server.py +``` + +`search_books` 的表單只有一個 `query` 欄位。用 `dune` 呼叫它: + +```text +[request 3] Found 3 books matching 'dune'. +``` + +數字是這次剛好輪到的請求編號。再呼叫一次工具,數字就會變:每個請求都有自己的 `Context`。 + +## 它給你什麼 {#what-it-gives-you} + +注入的物件很小。除了 `request_id` 之外: + +* `await ctx.read_resource(uri)`:在工具內部讀取伺服器**自己的**資源。下一節會介紹。 +* `await ctx.report_progress(progress, total, message)`:在長時間的呼叫期間,把進度串流回傳給呼叫端。完整說明請見 **[進度](progress.md)**。 +* `await ctx.elicit(message, schema)` 和 `await ctx.elicit_url(...)`:暫停工具,向使用者問一個問題。那是 **[徵詢(elicitation)](elicitation.md)**。 +* `ctx.session`:伺服器這一側與這個用戶端的對話。要送給用戶端的通知都在這裡;最後一節會用到它。 +* `ctx.headers`:傳輸方式帶過來的請求標頭,在 stdio 上則是 `None`。用 `(ctx.headers or {}).get("x-...")` 讀取自訂標頭。標頭是用戶端提供的輸入,拿來傳語系或功能旗標沒問題,但絕不能當作身分。 +* `ctx.request_context`:原始的每請求紀錄。最常用到的欄位是 `lifespan_context`,也就是啟動程式碼 yield 出來的物件(見 **[生命週期](lifespan.md)**)。 + +記錄刻意不在這張清單上。伺服器和其他 Python 程式一樣,用 Python 的 `logging` 模組記錄。**[記錄](logging.md)** 這一頁簡短說明了原因。 + +!!! tip + 注入只發生在你註冊的那個函式上。工具呼叫的輔助函式不會拿到自己的 `Context`;把 `ctx` 當作普通引數往下傳就好。沒有什麼環境中的「目前 context」可以從別處取得。 + +## 讀取自己的資源 {#read-your-own-resources} + +伺服器的資源不只是給用戶端用的。工具也可以讀取: + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/context/tutorial002.py" +``` + +`ctx.read_resource` 透過和 `resources/read` 同一套登錄機制解析 URI,所以工具拿到的東西和用戶端拿到的一樣:一個 `ReadResourceContents` 的可迭代物件,每個內容區塊一個。這個 URI 只有一個: + +```python +contents.content # 'fiction, non-fiction, poetry' +contents.mime_type # 'text/plain' +``` + +* `content` 正是 `genres()` 回傳的內容。單一事實來源:用戶端瀏覽這個資源,工具取用它,沒有人需要複製那個字串。 +* `describe_catalog` 唯一的參數是 `Context`,所以它的輸入 schema **完全沒有屬性**。模型用 `{}` 呼叫它。 + +## 告訴用戶端清單變了 {#tell-the-client-the-list-changed} + +伺服器提供的內容並不是在 import 時就固定下來的。可以在執行時註冊工具,然後告訴用戶端: + +```python title="server.py" hl_lines="15-16" +--8<-- "docs_src/context/tutorial003.py" +``` + +* `mcp.add_tool(recommend_book)` 把一個普通函式註冊成工具:名稱、描述和 schema 的推導方式與 `@mcp.tool()` 完全相同。 +* `await ctx.session.send_tool_list_changed()` 會送出 `notifications/tools/list_changed`。收到它的用戶端會再次呼叫 `tools/list`,然後看到 `recommend_book`。 + +同系列的還有 `send_resource_list_changed()`、`send_prompt_list_changed()`,以及針對某個特定資源變更的 `send_resource_updated(uri)`。 + +在 2026-07-28 連線上,用戶端只會在自己開啟的 `subscriptions/listen` 串流上收到變更通知,所以上面的 `send_*` 方法到不了那些串流。`Context` 的發布方法會一次送達所有已訂閱的串流:`await ctx.notify_tools_changed()`、`await ctx.notify_prompts_changed()`、`await ctx.notify_resources_changed()` 和 `await ctx.notify_resource_updated(uri)`。完整說明(包括跨副本橫向擴展)請見 **[訂閱](subscriptions.md)**。 + +!!! check + 在有人執行 `enable_recommendations` 之前,你承諾的那個工具並不存在。硬是呼叫它,結果會是模型讀得懂的錯誤: + + ```text + Unknown tool: recommend_book + ``` + + 執行 `enable_recommendations` 之後,一模一樣的呼叫就成功了。工具清單是真正動態的:`tools/list` 反映的是**此刻**註冊了什麼。 + +## 重點回顧 {#recap} + +* 用 `Context` 註記一個參數(在工具、資源或提示詞裡),SDK 就會注入它。名稱隨你取。 +* 模型看不到它:輸入 schema 永遠只包含真正的引數。 +* `ctx.request_id` 標識請求;`ctx.request_context.lifespan_context` 是啟動時 yield 出來的東西。 +* `await ctx.read_resource(uri)` 讓工具讀取伺服器自己的資源。 +* `ctx.session` 是回到用戶端的通道:`send_tool_list_changed()` 和同系列的方法會通知它重新抓取你改過的清單。 +* 進度回報和徵詢也都從 `Context` 開始;各有自己的頁面。 + +模型永遠看不到、由你自己的函式填入的參數,就是 **[相依性](dependencies.md)**。 diff --git a/i18n/zh-hant/pages/handlers/dependencies.md b/i18n/zh-hant/pages/handlers/dependencies.md new file mode 100644 index 0000000000..57a017a968 --- /dev/null +++ b/i18n/zh-hant/pages/handlers/dependencies.md @@ -0,0 +1,137 @@ +--- +translation: + sections: [b0389403e98d25ad, e2cf58b43b285e86, a363e1a38e1a5971, 6cfac078feb18013, b4535bd61df337e6, e97ed44207f929fd] + tool: 1 +--- +# 相依性 {#dependencies} + +工具的引數來自模型。但有些值根本不該由模型提供:從自己的紀錄查出來的價格、只有真人能給的確認,以及任何模型一旦憑空捏造就可能出錯的東西。 + +**相依性**是由你自己的函式填入的參數。在參數上加註記、指名函式,SDK 就會在工具執行前呼叫它。 + +## 宣告一個 {#declare-one} + +把參數的型別包進 `Annotated[...]`,再加上 `Resolve(fn)`: + +```python title="server.py" hl_lines="18-19 23" +--8<-- "docs_src/dependencies/tutorial001.py" +``` + +* `check_stock` 是一個**解析器**:一個普通函式,SDK 會在 `reserve_book` 之前執行它,回傳值就成為 `stock` 引數。 +* 它的 `title` 參數就是工具本身的 `title` 引數,**依名稱**比對。解析器看到的值,和工具本體將看到的驗證後的值完全相同。 +* 工具本體一開始就拿到一個現成的 `Stock`。工具裡沒有查詢程式碼,也沒有「萬一找不到怎麼辦」的開場白。 + +!!! info + 如果用過 FastAPI,這就是 `Depends`。同樣的做法,同樣的理由:函式宣告自己需要什麼,框架負責供應,接線全寫在型別註記裡。 + +### 模型看不到 {#invisible-to-the-model} + +這是 `tools/list` 為 `reserve_book` 回報的輸入 schema: + +```json +{ + "type": "object", + "properties": { + "title": {"title": "Title", "type": "string"} + }, + "required": ["title"], + "title": "reserve_bookArguments" +} +``` + +只有一個屬性。和 **[Context](context.md)** 裡的 `Context` 一樣,解析出來的參數是你和 SDK 之間的約定:`stock` 不在 schema 裡,模型從頭到尾不知道它的存在,用戶端就算硬是送來 `stock` 值也會被忽略。解析器的值是工具唯一可能收到的值。 + +最後這點正是重點。模型無法提供的參數,就是模型無法弄錯的參數。 + +### 試試看 {#try-it} + +用 MCP Inspector 執行伺服器: + +```console +uv run mcp dev server.py +``` + +`reserve_book` 的表單只有一個 `title` 欄位,完全沒有 `stock`。用 `Dune` 呼叫它: + +```text +Reserved 'Dune' (6 copies left). +``` + +工具本體什麼都沒查:`check_stock` 先執行,它回傳的 `Stock` 以引數的形式送了進來。試試 `Neuromancer`,同一個解析器會交給工具一個零。 + +!!! tip + 其實可以直接在工具本體裡呼叫 `check_stock(title)`。當這個值值得比一個輔助函式呼叫更鄭重的對待時,再把它宣告成相依性:每個需要庫存的工具都宣告同一個參數,而且不管有多少個工具宣告它,SDK 每次呼叫最多只執行解析器一次。接下來幾節補上其餘部分:彼此相依的解析器,以及會詢問使用者的解析器。 + +## 相依性的相依性 {#dependencies-of-dependencies} + +解析器可以用同樣的註記宣告自己的相依性: + +```python title="server.py" hl_lines="22 29-30" +--8<-- "docs_src/dependencies/tutorial002.py" +``` + +* `estimate_delivery` 相依於 `check_stock`。SDK 依序執行這張圖:先是庫存,再來是預估,最後是工具。 +* `stock` 和 `delivery` 最終都需要 `check_stock`,但它**每次呼叫只執行一次**。一次庫存查詢,兩個取用端。 +* 不需要註冊任何東西。這張圖**就是**那些註記。 + +!!! check + 別光憑信任就接受「每次呼叫一次」。在 `check_stock` 裡放一個 `print`,再從 Inspector 呼叫 `order_book`:每次呼叫印出一行。兩個取用端,一次查詢。 + +SDK 在工具註冊時分析這張圖,而不是在呼叫時。無法歸類的參數(不是 `Context`、不是 `Resolve(...)`、也不是工具引數的名稱)以及解析器之間的循環,都會在啟動時引發 `InvalidSignature`。伺服器在任何用戶端連上之前就會失敗,錯誤訊息裡會指出出問題的參數或解析器。 + +解析器的參數解析方式和工具的完全一樣:另一個 `Resolve(...)`、依名稱對應的工具引數,或是 `Context`:`ctx.headers`、生命週期物件,全部都拿得到。 + +!!! warning + 在 HTTP 傳輸上,`Context` 包含 `ctx.headers`。標頭和任何工具引數一樣,是**用戶端提供的輸入**:拿來放語系或功能旗標沒問題,但絕不能當作身分。呼叫端是誰,要由授權層(**[授權](../run/authorization.md)**)決定,而不是任何人都能設定的標頭。 + +!!! tip + 「每次呼叫一次」就是字面上的意思:下一次 `tools/call` 會再執行一次 `check_stock`。應該活得比單一請求久的資源(資料庫連線池、HTTP 用戶端)屬於 **[生命週期](lifespan.md)** 的範疇,解析器可以透過 `ctx.request_context.lifespan_context` 取得它。 + +## 非問不可時才問 {#ask-when-you-must} + +解析器不一定要知道答案。它可以回傳 `Elicit(message, Model)`,SDK 就會去問使用者,動用的是 **[徵詢(elicitation)](elicitation.md)** 機制,替你代勞: + +```python title="server.py" hl_lines="26-32 39" +--8<-- "docs_src/dependencies/tutorial003.py" +``` + +* 有庫存:`confirm_backorder` 直接回傳一個 `Backorder`。**不提問,不往返。**只有在使用者的答案有影響時才會打擾他們。 +* 沒庫存:SDK 送出徵詢,依 `Backorder` 驗證答案,再注入進來。解析器完全不碰協定。 +* 工具像讀其他引數一樣讀取 `backorder.confirm`。回答**不要**也算是一種回答:徵詢以 `confirm=False` 被接受,工具照樣執行,只是不下訂單。提問變成了前置條件,而不是塞在工具本體裡的管線程式碼。 + +那如果使用者根本不回答,拒絕了這個問題或是取消它呢? + +!!! check + 對 `Neuromancer` 執行 `order_book`,然後拒絕這個問題。註記寫成 `Annotated[Backorder, Resolve(...)]` 時,工具本體根本不會執行;呼叫會失敗,回傳模型讀得懂的錯誤結果: + + ```text + Error executing tool order_book: Resolver for parameter 'backorder' could not resolve: elicitation was decline + ``` + +對前置條件來說,這是正確的預設行為:沒有答案,就沒有訂單。如果拒絕是工具想自己處理的結果(跳過缺貨預訂,但仍然推薦另一本書),就改註記成 `ElicitationResult[Backorder]`,工具會收到完整的接受/拒絕/取消結果,可以據此分支。**[徵詢](elicitation.md)** 示範了那種寫法,以及關於提問的其他一切:schema 規則、三種回答、對話中用戶端那一側。 + +!!! info + 框架依協商出的協定版本決定問題走哪種傳輸;上面的程式碼在兩種情況下完全相同。在 **2026-07-28** 及之後,問題搭在一個多輪往返(multi-round-trip)的 `tools/call` 裡:伺服器回傳問題,用戶端的 `elicitation_callback` 回答它,`Client` 替你重試這次呼叫(**[多輪往返請求](multi-round-trip.md)**)。在 **2025-11-25** 及更早,則是呼叫途中的一個同步徵詢請求。每個問題在每次呼叫中恰好問一次,這是對問題的保證,不是對解析器的保證。在多輪往返的形式下,每當呼叫在提問後恢復,任何解析器都可能再執行一次,所以 `return Elicit(...)` 之前的程式碼在每一輪都會執行;已記錄的答案接著會滿足重複出現的問題,不會再次詢問使用者。已記錄的答案只在解析器提問時才會被查閱;像 `check_stock` 這樣**不**提問就給出答案的解析器,永遠提供自己計算出的值。因為每個答案都要對回它的問題,會徵詢的解析器必須從工具的引數和先前的答案確定性地推導出它的問題。每次呼叫才產生的值(`default_factory` 產生的 id、時間戳記)在每一輪都會重新推導,不能出現在答案要綁定的問題裡。用這種易變資料組出的問題會讓每個已記錄的答案看起來都過期,於是伺服器每一輪都重問一次,直到用戶端的輪數上限結束這次呼叫。 + +## 問用戶端,不是問使用者 {#ask-the-client-not-the-user} + +徵詢是解析器能問的三種問題之一,而多輪往返流程不允許其他種類。另外兩種是問**用戶端**而不是使用者:回傳 `Sample(...)` 透過用戶端執行一次 LLM 呼叫(一個 `sampling/createMessage` 請求),或回傳 `ListRoots()` 取得用戶端目前的根目錄(roots)。兩者都沒有接受/拒絕的結果;取用端直接註記結果型別,`CreateMessageResult`(請求帶有 `tools` 或 `tool_choice` 時是 `CreateMessageResultWithTools`)或 `ListRootsResult`: + +```python title="server.py" hl_lines="10-15 21" +--8<-- "docs_src/dependencies/tutorial004.py" +``` + +* 框架替它們安排路徑的方式和 `Elicit` 完全一樣:在 **2026-07-28** 上走多輪往返的 `tools/call` 內部,在 **2025-11-25** 上走獨立的伺服器→用戶端請求。未宣告的能力會以 `-32021` 協定錯誤拒絕這次呼叫(`sampling`、`roots`、表單模式的 `elicitation`;請求帶有 `tools` 或 `tool_choice` 時是 `sampling.tools`)。 +* 上面資訊框裡關於問題的一切原封不動地適用:`Sample` 請求是以它精確的呈現內容對應到已記錄的結果,所以要從工具的引數和先前的答案確定性地建構它;這樣用戶端每次工具呼叫只付一次 LLM 呼叫的代價,而不是每一輪一次。已記錄的結果在這次呼叫剩餘的過程中都搭在 `request_state` 上,所以非常大的生成結果會讓剩下的每次往返都變得更重。 +* 獨立的取樣(sampling)和根目錄**功能**在 2026-07-28 已棄用(SEP-2577)。需要用戶端模型的新伺服器透過這個載體來問;不需要的伺服器應該直接整合 LLM 供應商。`"none"` 以外的 `include_context` 值本身也已棄用;避免使用。 + +## 重點回顧 {#recap} + +* 在工具參數上寫 `Annotated[T, Resolve(fn)]`:SDK 執行 `fn` 並注入它的回傳值。 +* 解析出來的參數模型看不到,用戶端也無法提供。模型不該自己捏造的值(價格、身分、權限)就屬於這裡。 +* 解析器的參數用同樣的方式解析:`Context`、另一個 `Resolve(...)`,或依名稱對應的工具引數。不管有多少取用端,這張圖每一輪最多執行每個解析器一次;每個問題恰好問一次,而呼叫在提問後恢復時,任何解析器都可能再執行一次。 +* 有問題的圖在註冊時就以 `InvalidSignature` 失敗,而不是呼叫到一半才出錯。 +* 回傳 `Elicit(message, Model)` 來詢問使用者,而且只在非問不可時才問。未包裝的註記遇到拒絕會中止;`ElicitationResult[T]` 讓工具可以分支。 +* 回傳 `Sample(...)` 或 `ListRoots()` 向用戶端要一個 LLM 生成結果或根目錄清單;注入的是單純的結果。 + +伺服器在啟動時建立一次的狀態,以及處理函式如何取得它,請見 **[生命週期](lifespan.md)** 頁面。 diff --git a/i18n/zh-hant/pages/handlers/elicitation.md b/i18n/zh-hant/pages/handlers/elicitation.md new file mode 100644 index 0000000000..fa42fe5f14 --- /dev/null +++ b/i18n/zh-hant/pages/handlers/elicitation.md @@ -0,0 +1,175 @@ +--- +translation: + sections: [335ca2a0b266f003, d1ad562d3fe87bc0, 0bb1396c86daeba4, d1cb1235bb9ee267, 833179c09d239c83, e5d6dec2d2e655e8] + tool: 1 +--- +# 徵詢 {#elicitation} + +工具做到一半、只差一個答案時,不一定要就此失敗。 + +**徵詢(elicitation)** 讓它可以開口問。在工具呼叫進行到一半時,使用者會收到一個問題,而他們的回答會回到同一次函式呼叫裡。 + +有兩種模式: + +* **表單模式**:需要一個值(確認、日期、數量)。你描述欄位,用戶端負責呈現表單。 +* **URL 模式**:需要使用者到別的地方去(OAuth 同意畫面、付款頁面)。他們在那裡做的任何事都不會經過協定。 + +問的方式也有兩種。該優先採用的是**解析器**:把問題掛在參數上,由 SDK 來問,不論哪種連線、不論用戶端說的是哪個協定世代都行。直接的方式 `await ctx.elicit(...)` 是從**伺服器**發往**用戶端**的請求,這條通道只有在舊版連線(規格版本 2025-11-25 或更早)上的用戶端才有。兩種本頁都會介紹,先從解析器開始。 + +## 用解析器來問 {#ask-with-a-resolver} + +決定整個工具能否繼續的問題(「確定嗎?三個符合的帳號要哪一個?」)可以從工具本體抽出來放進**解析器**,由框架替你問。 + +標註為 `Annotated[T, Resolve(fn)]` 的參數,會在工具本體執行前先執行 `fn` 來填入。解析器已經知道答案時就直接回傳值,否則回傳 `Elicit(...)` 讓框架去問: + +```python title="server.py" hl_lines="24-30 35-36" +--8<-- "docs_src/elicitation/tutorial004.py" +``` + +* `confirm_delete` 依名稱讀取工具自己的 `path` 引數,列出資料夾內容,而且**只在必要時才徵詢**:空資料夾直接解析為 `Confirm(ok=True)`,完全不需要與用戶端往返。 +* `delete_folder` 標註的是 `ElicitationResult[Confirm]`,所以框架會注入完整的結果,工具再用 `match` 處理每一種情況:接受並確認、接受但保留(`ok=False`)、拒絕、取消。 +* `confirm` 參數永遠不會出現在工具的輸入 schema 裡:`path` 由用戶端提供,`confirm` 由解析器提供。 + +如果工具不需要分支處理,改為標註未包裝的模型(`Annotated[Confirm, Resolve(confirm_delete)]`)即可:接受時工具會收到模型,拒絕或取消時整個呼叫會以錯誤中止。 + +解析器在**每一種**連線上都能用。對舊版連線上的用戶端,SDK 會直接把問題送過去;在 **2026-07-28** 連線上,SDK 會把問題從呼叫中**回傳**出去,用戶端下一次嘗試時再帶著答案回來。解析器完全不知道其中的差別;底下發生的事請見**[多輪往返(multi-round-trip)請求](multi-round-trip.md)**。 + +問問題只是解析器能做的事情之一。通用的機制(不用問就能算出值的相依性、相依性的相依性、模型能提供與不能提供什麼)請見**[相依性](dependencies.md)**頁面。 + +## 在工具內部問 {#ask-from-inside-the-tool} + +工具也可以在自己的本體執行到一半時停下來問。 + +!!! warning + `ctx.elicit()` 和 `ctx.elicit_url()` 是從**伺服器**發往**用戶端**的請求,這條通道只有在舊版連線(規格版本 **2025-11-25** 或更早)上的用戶端才有。在 **2026-07-28** 連線上沒有由伺服器發起的請求,所以這些呼叫會失敗。解析器則兩種都能用。完整說明請見**[協定版本](../protocol-versions.md)**。 + +`await ctx.elicit()` 接受一則訊息和一個 Pydantic 模型: + +```python title="server.py" hl_lines="9-11 20-23 25" +--8<-- "docs_src/elicitation/tutorial001.py" +``` + +* **`Context`** 參數就是讓你能用 `ctx.elicit` 的東西;任何工具都可以接收一個。這個物件有自己的頁面:**[Context](context.md)**。 +* `AlternativeDate` 是你想要的答案的 **schema**。 +* 這個工具是 `async def`。非如此不可:它會在中途停下來等一個人回答。 +* 其他任何日期,工具都會直接回傳。只有在必要時才問。 +* 使用者接受的日期會再經過 `book_table` 本身處理一次。回答和其他輸入沒有兩樣:如果替代日期也訂滿了,會再問一次,而不是盲目確認。 + +### 用戶端收到什麼 {#what-the-client-receives} + +用戶端會收到你的訊息,旁邊附上一份從模型產生的 JSON Schema: + +```json +{ + "properties": { + "accept_alternative": { + "description": "Try another date?", + "title": "Accept Alternative", + "type": "boolean" + }, + "date": { + "default": "2025-12-26", + "description": "Alternative date (YYYY-MM-DD)", + "title": "Date", + "type": "string" + } + }, + "required": ["accept_alternative"], + "title": "AlternativeDate", + "type": "object" +} +``` + +那份 schema 就是表單。`Field(description=...)` 是標籤;預設值會預先填入輸入框,並讓該欄位變成選填。這和**[工具](../servers/tools.md)**頁面描述工具引數時用的是同一套 Pydantic 轉 JSON Schema 機制。 + +!!! warning + 徵詢用的 schema 表達能力不如工具的輸入 schema。只能用扁平的基本型別欄位:`str`、`int`、`float`、`bool`,或是字串組成的 `Literal`(會變成 `enum`)。如果在模型裡再放一個模型,`ctx.elicit` 會在送出任何東西給用戶端之前就引發例外: + + ```text + TypeError: Elicitation schema field 'address' rendered as {'$ref': '#/$defs/Address'}, which is not a valid PrimitiveSchemaDefinition + ``` + + 你是在打斷一個正在做事的人。如果答案需要巢狀結構,它本來就該是工具的引數。 + +### 三種回答 {#the-three-answers} + +`result.action` 告訴你使用者做了什麼,可能性恰好只有三種: + +* `"accept"`:他們送出了表單。`result.data` 是一個 `AlternativeDate` 實例,已經驗證過。 +* `"decline"`:他們說不要。 +* `"cancel"`:他們沒有選擇就關掉了問題。 + +`result.data` 只在 `"accept"` 時存在,這就是範例先檢查 `result.action` 的原因。型別檢查器會強制這個順序:在 `result.action == "accept"` 之後,`result.data` 是 `AlternativeDate`;在那之前,根本沒有 `.data`。 + +拒絕不是錯誤。拒絕代表什麼由工具決定(這裡是不訂位),然後照常回答模型。 + +!!! tip + 回答在你的程式碼看到之前,就會先依照模型驗證。用戶端若在 `bool` 欄位送來 `"maybe"`,也不會弄壞你的訂位:呼叫會以 schema 不符的錯誤失敗,你的 `if` 根本不會執行。 + +## 把使用者送往一個 URL {#send-the-user-to-a-url} + +有些東西絕對不能經過模型或用戶端:憑證、卡號、OAuth 同意。遇到這些,你不是要資料,而是請使用者到某個地方去: + +```python title="server.py" hl_lines="10-14 23" +--8<-- "docs_src/elicitation/tutorial002.py" +``` + +* `ctx.elicit_url()` 接受訊息、要造訪的 **URL**,以及一個你自己選的 `elicitation_id`:任何能在伺服器內識別這次徵詢的字串都行。 +* 結果只有一個 action,沒有別的。`"accept"` 代表使用者同意開啟 URL,**不是**代表他們完成了另一頭的事。 +* 付款在頻外進行,發生在使用者的瀏覽器和你的金流服務商之間。沒有任何內容會透過 MCP 回來。 + +看看第二個工具。當伺服器得知頻外流程完成時(webhook、輪詢;這裡用第二個工具來模擬),`ctx.session.send_elicit_complete(...)` 會以同一個 `elicitation_id` 送出 `notifications/elicitation/complete`。用戶端就是靠這個知道可以停止顯示「等待付款中……」。少了它,用戶端只能猜。 + +## 用戶端這一邊 {#the-client-side} + +伺服器負責問。用戶端回答的方式,是把一個 **`elicitation_callback`** 傳給 `Client(...)`: + +```python title="client.py" hl_lines="6-7 18" +--8<-- "docs_src/elicitation/tutorial003.py" +``` + +* 一個回呼處理兩種模式。`params` 是 `ElicitRequestFormParams` 和 `ElicitRequestURLParams` 的聯集;用 `isinstance` 來分支。 +* 若是 URL,把 `params.url` 顯示給使用者,回傳他們選的 action。絕不帶任何 `content`。 +* 若是表單,真正的應用程式會呈現 `params.requested_schema`,並把使用者的輸入當作 `content` 回傳。這裡的回呼永遠用一個固定答案說好,正是測試裡想要的那種回呼。 +* 傳入回呼同時也是**能力宣告**:伺服器就是靠這個得知這個用戶端可以被問。用戶端還能替伺服器回答的其他事情,請見**[用戶端回呼](../client/callbacks.md)**。 + +!!! info + 徵詢是從**伺服器**發往**用戶端**的請求,而這種請求只存在於傳統交握的工作階段(session)上,這就是這個用戶端傳入 `mode="legacy"` 的原因。在 **2026-07-28** 連線上,工具改為把問題從呼叫中**回傳**出去來問;那個流程請見**[多輪往返請求](multi-round-trip.md)**。 + +### 試試看 {#try-it} + +用 Streamable HTTP 啟動 `ctx.elicit` 表單模式的 `server.py`(有 `book_table` 的那個;一行指令請見**[執行伺服器](../run/index.md)**),然後執行用戶端的 `main()`,向 `book_table` 訂聖誕節當天。 + +回呼會印出它收到的問題: + +```text +No tables for 2 on 2025-12-25. Would you like to try another date? +``` + +它回答 `{"accept_alternative": True, "date": "2025-12-27"}`,而一直在 `await ctx.elicit(...)` 裡等著的工具便完成訂位: + +```text +Booked a table for 2 on 2025-12-27. +``` + +現在換成 URL 模式的 `server.py`,讓同一個 `main()` 改呼叫 `pay_deposit`:同一個回呼會走另一個分支,印出付款連結,工具則回傳「Complete the payment in your browser.」。呼叫途中的一次往返,雙向都走過了。 + +!!! check + 現在把 `elicitation_callback=` 從 `Client` 拿掉,再向 `book_table` 訂一次聖誕節當天。整個呼叫會以協定錯誤失敗: + + ```text + Elicitation not supported + ``` + + 沒有註冊回呼的用戶端從來沒有宣告 `elicitation` 能力,所以沒有人可以問。你的工具拿到的不是 `"decline"`,而是例外。設計時要考慮到這點:每一個徵詢都需要對「如果沒辦法問怎麼辦?」有個合理的答案。 + +## 重點回顧 {#recap} + +* 標註為 `Annotated[T, Resolve(fn)]` 的參數由解析器填入,解析器必須問的時候就回傳 `Elicit(...)`。在每一種連線上都能用。 +* schema 是一個扁平的 Pydantic 模型:只能有基本型別欄位,回來的路上會驗證。 +* `result.action` 是 `"accept"`、`"decline"` 或 `"cancel"`;`result.data` 只在 accept 時存在。 +* `await ctx.elicit(message, schema=Model)` 從工具本體內部問,`await ctx.elicit_url(message, url, elicitation_id)` 則用於所有絕對不能經過模型的東西(`ctx.session.send_elicit_complete(elicitation_id)` 表示頻外的部分完成了)。兩者都是伺服器對用戶端的請求:需要用戶端在舊版連線上。 +* 用戶端用一個 `elicitation_callback` 回答,依 params 的型別分支;註冊它就是宣告能力。 +* 在 2026-07-28 連線上,伺服器是回傳問題而不是推送問題;同一個回呼改由**[多輪往返請求](multi-round-trip.md)**餵入。 + +那個回傳底下的一切(重試迴圈、保護 `requestState`、自己驅動它)請見**[多輪往返請求](multi-round-trip.md)**。 diff --git a/i18n/zh-hant/pages/handlers/index.md b/i18n/zh-hant/pages/handlers/index.md new file mode 100644 index 0000000000..defab844ca --- /dev/null +++ b/i18n/zh-hant/pages/handlers/index.md @@ -0,0 +1,24 @@ +--- +translation: + sections: [424930166c4bc6f3] + tool: 1 +--- +# 在處理函式內部 {#inside-your-handler} + +處理函式的引數來自用戶端。除此之外它能讀到的**其他**一切,以及執行時能做的一切,都在這裡。 + +它能讀到什麼: + +* **[Context](context.md)** 是任何處理函式都能額外要求的那一個參數:進行中的請求、它的標頭、它的工作階段(session),以及進度與變更通知的操作。 +* **[相依性](dependencies.md)** 是模型永遠看不到的參數,由你自己的函式透過 `Resolve` 填入。 +* **[生命週期](lifespan.md)** 說明伺服器在啟動時只建立一次的狀態,以及處理函式如何透過 `Context` 取得它。 + +它執行時能做什麼: + +* 用 **[徵詢](elicitation.md)**(elicitation)向使用者要求更多輸入,以及承載它的 2026-07-28 模式 **[多輪往返請求](multi-round-trip.md)**(multi-round-trip)。 +* 用 **[取樣與根目錄](sampling-and-roots.md)**(sampling 與 roots)向用戶端要求 LLM 生成結果或它的工作區資料夾,這兩者已棄用但仍然提供。 +* 對耗時的工作回報 **[進度](progress.md)**。 +* 用 **[記錄](logging.md)** 寫入記錄(寫到標準錯誤輸出,給負責維運伺服器的人看)。 +* 用 **[訂閱](subscriptions.md)** 告訴已訂閱的用戶端有東西變了。 + +如果還沒有註冊處理函式,請從 **[工具](../servers/tools.md)** 開始。這裡的每一頁都假設你已經有一個。 diff --git a/i18n/zh-hant/pages/handlers/lifespan.md b/i18n/zh-hant/pages/handlers/lifespan.md new file mode 100644 index 0000000000..42ef56b8d8 --- /dev/null +++ b/i18n/zh-hant/pages/handlers/lifespan.md @@ -0,0 +1,101 @@ +--- +translation: + sections: [f3ca8ac5f90f2dfa, 85a1ef3588ba0736, 563346d4d5804933, 9e3528340d0bab53] + tool: 1 +--- +# 生命週期 {#lifespan} + +大多數真實的伺服器在整個生命期間都會持有某些東西:資料庫連線池、HTTP 用戶端、載入好的模型。 + +你不會想在每次呼叫時都重新建立它,卻會想乾淨地把它關閉。這就是**生命週期**(lifespan)的用途。 + +## 有型別的生命週期 {#a-typed-lifespan} + +生命週期是一個 `@asynccontextmanager`,它接收伺服器並 `yield` **一個物件**。不論 yield 出什麼,只要伺服器還在執行,每個處理函式都能取用它。 + +```python title="server.py" hl_lines="25-31 34 38 40" +--8<-- "docs_src/lifespan/tutorial001.py" +``` + +由下往上讀: + +* `app_lifespan` 在 `yield` **之前**連接 `Database`,並在**之後**於 `finally` 區塊中斷開連線。這就是啟動與關閉。 +* 它 yield 出一個 `AppContext`,一個普通的 dataclass,裝著你設定好的東西。今天一個欄位,明天十個。 +* `MCPServer("Bookshop", lifespan=app_lifespan)` 就是全部的接線。 +* 在工具內部,yield 出來的物件就是 `ctx.request_context.lifespan_context`。 + +生命週期只會執行**一次**。伺服器啟動時(第一個請求之前)進入,伺服器停止時離開。其間的每個請求都共用同一個 `AppContext`。 + +!!! info + 如果你寫過 FastAPI 的 `lifespan`,這些你早就會了。同樣的裝飾器、同樣的 `yield`、同樣的 `finally`。 + +### 模型看到什麼 {#what-the-model-sees} + +沒有新東西。`ctx` 是一個 **Context** 參數,所以 SDK 會注入它,它永遠不會進到輸入 schema: + +```json +{ + "type": "object", + "properties": { + "genre": {"title": "Genre", "type": "string"} + }, + "required": ["genre"], + "title": "count_booksArguments" +} +``` + +`genre` 是模型唯一能傳入的引數。生命週期是伺服器自己的事。 + +`@mcp.resource()` 和 `@mcp.prompt()` 函式也可以接收 `ctx` 參數,寫成不帶型別參數的 `Context`,原因下一節會說明。`ctx` 所攜帶的一切,請見 **[Context](context.md)**。 + +### 它真的有型別 {#it-really-is-typed} + +再看一次型別註記:`ctx: Context[AppContext]`。 + +就是這一個型別參數,讓型別檢查器把 `ctx.request_context.lifespan_context` **當作** `AppContext`。`.db` 會自動完成;`.dbb` 在你執行伺服器之前就是錯誤。 + +如果改寫成不帶型別參數的 `Context`,`lifespan_context` 的型別就是 `dict[str, Any]`:型別檢查器無從得知你的生命週期 yield 了什麼。執行時物件還在;你失去的是協助。 + +!!! warning + `Context[AppContext]` 是**只限工具**的寫法。把它放在 `@mcp.resource()` 或 `@mcp.prompt()` 函式上,對該處理函式的每次呼叫都會失敗。用戶端會收到錯誤,伺服器記錄會顯示原因: + + ```text + Context is not available outside of a request + ``` + + 在資源和提示詞裡,寫不帶型別參數的 `ctx: Context`。生命週期 yield 出來的物件在執行時仍然是 `ctx.request_context.lifespan_context`;放棄的只是型別參數,不是物件。 + +!!! tip + 生命週期永遠存在。如果不傳入,SDK 的預設會 yield 一個空的 `dict`,所以 `ctx.request_context.lifespan_context` 是 `{}`,永遠不會是 `None`。這個預設也是為什麼不帶型別參數的 `Context` 會把它的型別定為 `dict[str, Any]`。 + +## 親眼看它發生 {#watch-it-happen} + +「啟動會在第一個請求之前執行」這種句子,不該只能憑信心接受。 + +把伺服器精簡到只剩生命週期:替 `Database` 加一個 `connected` 旗標,在 `connect()` 和 `disconnect()` 裡切換它,再加一個回報它的工具。 + +```python title="server.py" hl_lines="11 14 17 25 44" +--8<-- "docs_src/lifespan/tutorial002.py" +``` + +`database` 放在模組層級只有一個原因:讓你能從伺服器**外部**觀察它。 + +!!! check + 三個時刻,三個值: + + * 伺服器啟動前,`database.connected` 是 `False`。匯入模組什麼都沒連接。 + * 執行中,呼叫 `database_status`,結果是 `"connected"`。 + * 停止伺服器,`finally` 區塊就會執行:`database.connected` 又變回 `False`。 + + 工作正好發生在你放它的地方:圍繞著 `yield`,不是在匯入時,也不是每個請求一次。 + +## 重點回顧 {#recap} + +* `lifespan=` 接收一個 `@asynccontextmanager`,它接收伺服器並 `yield` 一個物件。 +* `yield` 之前的程式碼是啟動。之後的 `finally` 是關閉。 +* 它只執行一次,涵蓋伺服器的整個生命,而不是每個請求一次。 +* 不論 `yield` 什麼,在每個工具、資源和提示詞裡都是 `ctx.request_context.lifespan_context`。 +* `ctx: Context[AppContext]` 讓工具裡的這種存取完全有型別。資源和提示詞則用不帶型別參數的 `Context`。 +* 沒有 `lifespan=` 代表一個空的 `dict`,永遠不會是 `None`。 + +在呼叫途中停下來、向使用者詢問只有他們才知道的事的處理函式,就是 **[徵詢(elicitation)](elicitation.md)**。 diff --git a/i18n/zh-hant/pages/handlers/logging.md b/i18n/zh-hant/pages/handlers/logging.md new file mode 100644 index 0000000000..16ff218743 --- /dev/null +++ b/i18n/zh-hant/pages/handlers/logging.md @@ -0,0 +1,79 @@ +--- +translation: + sections: [c93a3e1aefd77955, 7851abd5ec54393b, f49d1ca2f330f9cd, c03764bd9dfeef7b, 4a0391691a674ae4, 2df5cd279eabf9f5] + tool: 1 +--- +# 記錄 {#logging} + +在工具裡寫記錄的方式,和在其他任何 Python 函式裡一樣:用標準函式庫。 + +MCP 有一個協定層級的 **logging 能力**:伺服器可以透過 `Context` 物件上的方法,把記錄訊息以通知的形式推送給用戶端。規格的 2026-07-28 修訂版**將這個能力標為已棄用,而且沒有提供替代方案**,所以這份說明文件不教它。哪些東西已棄用、該改用什麼,完整清單請見 **[已棄用的功能](../deprecated.md)**。 + +該改用的做法,就是在其他任何 Python 程式裡會用的做法:標準函式庫。 + +## 會寫記錄的工具 {#a-tool-that-logs} + +```python title="server.py" hl_lines="1 5 13" +--8<-- "docs_src/logging/tutorial001.py" +``` + +* `logging.getLogger(__name__)` 會給你一個以模組名稱命名的 logger。在檔案最上方建立一次就好。 +* 在工具裡呼叫 `logger.info(...)`,就跟在其他任何函式裡一樣。不用注入什麼、不用 `await` 什麼,也沒有任何 MCP 專屬的東西。 + +!!! check + 呼叫這個工具,看看完整的結果: + + ```python + result.content # [TextContent(text="Found 3 books matching 'dune'.")] + result.structured_content # {'result': "Found 3 books matching 'dune'."} + ``` + + 記錄那一行完全不在裡面。記錄是寫給**你**看的,也就是負責維運伺服器的人。模型永遠看不到它。如果有東西該讓模型讀到,就 `return` 它。 + +## 記錄去了哪裡 {#where-it-goes} + +對 **stdio** 伺服器來說,這個問題比平常更重要。主機把你的伺服器當成子處理程序啟動,並從它的 **stdout** 讀取 MCP 訊息。標準錯誤是你的。 + +標準函式庫本來就做對了:記錄輸出預設寫到 `sys.stderr`。你的 `logger.info(...)` 那些行會出現在終端機(或主機收集子處理程序 stderr 的地方),協定串流則保持乾淨。 + +!!! tip + 不要在 stdio 伺服器裡用 `print()`。`print` 寫到 **stdout**,而 stdout 屬於協定。服務期間,SDK 會把實際**被 flush** 的 stdout 輸出轉向 stderr,所以它不會弄壞線路;但在區塊緩衝的處理程序裡,`print()` 的內容通常會一直留在 `sys.stdout` 的緩衝區裡沒被 flush,直到直譯器在結束時把它排空,直接倒進協定串流。就算被轉向了,那一行也是原封不動地混在記錄輸出之中,沒有層級、沒有 logger 名稱,也沒辦法過濾。 + + `logger.debug("got here")` 一樣只是一行的功夫,而且會去到對的地方。 + +## 層級 {#the-level} + +不需要自己呼叫 `logging.basicConfig()`。建立 `MCPServer` 時就已經呼叫過了:它裝上一個指向標準錯誤的 handler,層級就是你以 `log_level=` 傳入的值,所以只要 `MCPServer("Bookshop", log_level="DEBUG")` 就能看到 `logger.debug(...)` 那些行。 + +預設值是 `"INFO"`。 + +`logging.basicConfig()` 永遠不會取代已經存在的 handler。如果在建立伺服器之前就自己設定好記錄,以你的設定為準。 + +## 試試看 {#try-it} + +用 MCP Inspector 執行伺服器: + +```console +uv run mcp dev server.py +``` + +從 **Tools** 分頁呼叫 `search_books`。Inspector 會顯示結果:只有回傳值。至於這一行 + +```text +Searching for 'dune' +``` + +則去了標準錯誤:終端機,而不是線路。 + +!!! info + 如果你真正想要的是**追蹤**(每個請求、花了多久、有沒有失敗),那你要的不是記錄行,而是 span。你的伺服器已經在送出它們了:SDK 預設就用 OpenTelemetry 追蹤每一則訊息。請見 **[OpenTelemetry](../run/opentelemetry.md)**。 + +## 重點回顧 {#recap} + +* MCP 協定的 logging 能力已被 2026-07-28 規格棄用,且沒有替代方案。不要以它為基礎開發。 +* 模組層級寫 `logger = logging.getLogger(__name__)`,工具裡寫 `logger.info(...)`。整個模式就這樣。 +* 記錄輸出永遠到不了模型。只有 `return` 的值會。 +* 標準錯誤是你的;stdout 屬於協定。服務期間,SDK 會把被 flush 的零星 stdout 輸出轉向 stderr,但沒被 flush 的 `print()` 仍可能在結束時排空到線路上,而被轉向的行送達時也沒有任何標示;改用 `logging`,它的 handler 每一筆記錄都會 flush。 +* `MCPServer(..., log_level="DEBUG")` 設定層級,而你先做好的記錄設定不會被動到。 + +要告訴已連線的用戶端伺服器上有東西變了(工具清單、某個資源),請見 **[訂閱](subscriptions.md)**。 diff --git a/i18n/zh-hant/pages/handlers/multi-round-trip.md b/i18n/zh-hant/pages/handlers/multi-round-trip.md new file mode 100644 index 0000000000..2d9c5a094d --- /dev/null +++ b/i18n/zh-hant/pages/handlers/multi-round-trip.md @@ -0,0 +1,183 @@ +--- +translation: + sections: [74011e683045eea9, 9b64cc175c18b6a9, 4b41be4824030397, e3b1502da786ec33, 71e41161f143c6a9, 9ec2c1eeb8c36378, 8dd027377d46448b, f81491125dcbfe8b] + tool: 1 +--- +# 多輪往返請求 {#multi-round-trip-requests} + +有時候工具沒辦法在一次往返內完成。它需要某個只有使用者手上才有的東西:一個選擇、一個確認、一組憑證。 + +在 2026-07-28 之前,伺服器靠**回頭呼叫**來取得:在處理原本那個請求的途中,自己對用戶端開一個請求(一次徵詢(elicitation)、一次取樣(sampling)呼叫)。2026-07-28 規格淘汰了這條反向通道(back-channel)。 + +現在,伺服器改成**回傳**。 + +## 回傳,不要回頭呼叫 {#return-dont-call-back} + +伺服器用 **`InputRequiredResult`** 而不是 `CallToolResult` 來回應 `tools/call`。其中兩個欄位負責主要的工作: + +* **`input_requests`**:伺服器還需要什麼,以 dict 表示,鍵是伺服器自己取的名稱。每個值是 `ElicitRequest`、`CreateMessageRequest` 或 `ListRootsRequest`。 +* **`request_state`**:一個不透明的權杖。用戶端在重試時原封不動地送回來。會讀它的只有你的伺服器。 + +用戶端逐一滿足這些請求,然後**再次呼叫同一個工具**,把答案放在 `input_responses`,權杖放在 `request_state`。這時伺服器拿到了原本缺的東西,回傳一般的 `CallToolResult`。 + +整個協定就這樣。每一段都是用戶端送往伺服器的普通請求,從來沒有東西反方向流動。 + +## 伺服器端 {#the-server-side} + +在 `@mcp.tool()` 上很少需要自己動手組這個:宣告一個相依性來詢問使用者(`Elicit`)、對用戶端的 LLM 取樣(`Sample`),或列出它的根目錄(roots,`ListRoots`),SDK 就會替你回傳 `InputRequiredResult`;這種寫法請見 **[相依性](dependencies.md)** 頁面。兩種寫法不能混用:一次呼叫只有一條 `input_responses`/`request_state` 通道,所以用了 `Resolve(...)` 參數的工具,不能再從函式本體回傳 `InputRequiredResult`。宣告了 `InputRequiredResult` 回傳型別的會在註冊時被拒絕(`InvalidSignature`),沒宣告卻回傳的則會在執行時讓呼叫失敗。手動的寫法是**低階** `Server`,它的 `on_call_tool` 處理函式可以回傳兩種結果型別中的任一種: + +```python title="server.py" hl_lines="43-46" +--8<-- "docs_src/mrtr/tutorial001.py" +``` + +* `on_call_tool` 的型別標註是 `-> CallToolResult | InputRequiredResult`。回傳第二種,就是伺服器端全部的 API。 +* 第一次呼叫時 `params.input_responses` 是 `None`,所以守衛條件成立,處理函式改成提問而不是作答。 +* 重試時,用戶端送來的 `ElicitResult` 就放在伺服器當初在 `input_requests` 裡用的**同一個鍵**(`"region"`)底下。 + +那個檔案裡其他的東西(明確寫出的 `input_schema`、手動組出的 `CallToolResult`)都是一般的低階 `Server`,在 **[低階 Server](../advanced/low-level-server.md)** 有說明。這一頁只多加了第二種回傳型別。 + +## 不只是工具 {#beyond-tools} + +`tools/call` 並不特別:在 2026-07-28,伺服器也可以用同樣的方式回應 `prompts/get` 和 `resources/read`。在 `MCPServer` 上,`@mcp.prompt()` 函式(或 `@mcp.resource()` 的**範本**函式)自己回傳 `InputRequiredResult`,並在重試時從 Context 讀取答案: + +```python title="server.py" hl_lines="20 22 24" +--8<-- "docs_src/mrtr/tutorial004.py" +``` + +* 第一輪回傳 `InputRequiredResult`。重試時,`ctx.input_responses` 在同樣的鍵底下放著答案,函式就回傳它平常的結果——這裡是提示詞訊息,若是範本資源則是資源內容。 +* 你設定的 `request_state` 在上線路之前會先密封,回送時會驗證,和伺服器上其他東西一樣;下方的 **[保護 `requestState`](#protecting-requeststate)** 說明密封帶來什麼保障,以及什麼時候需要設定金鑰。 +* `@mcp.tool()` 函式在相依性寫法不合用時,也可以用同樣方式直接回傳這個結果。 +* 靜態的 `@mcp.resource()` 函式不參與:它們不接收 `Context`,所以永遠讀不到重試的內容。只有範本資源可以提問。 +* 下方的世代規則照樣適用:在 2026 之前的工作階段(session)上回傳 `InputRequiredResult`,就是那則警告所描述的 `-32603`。 + +## 用戶端 {#the-client-side} + +`Client` 會替你執行這個迴圈。 + +註冊伺服器可能會用到的回呼(`elicitation_callback`、`sampling_callback`、`list_roots_callback`),然後呼叫工具。收到 `InputRequiredResult` 時,`Client` 把 `input_requests` 裡的每一筆分派給對應的回呼,帶著答案和原樣送回的 `request_state` 重試,一直持續到拿回 `CallToolResult` 為止: + +```python title="client.py" hl_lines="11 12" +--8<-- "docs_src/mrtr/tutorial003.py" +``` + +* 那個 `elicitation_callback`,和 2026 之前的伺服器透過反向通道送出 `elicitation/create` 時會觸發的是同一個。`sampling_callback` 之於 `sampling/createMessage`、`list_roots_callback` 之於 `roots/list` 也一樣:在 2026-07-28,獨立的伺服器→用戶端 RPC 已經不存在,但完全相同的 `ElicitRequest`/`CreateMessageRequest`/`ListRootsRequest` 酬載改搭在 `input_requests` 裡,分派到同樣這三個回呼。一組回呼同時服務兩個世代。 +* `call_tool` 回傳的是普通的 `CallToolResult`。中間那幾輪對呼叫端來說是看不到的。 +* `get_prompt` 和 `read_resource` 驅動的也是同一個迴圈。 + +!!! check + 不註冊回呼的話,迴圈在第一輪就會失敗:SDK 的替身回呼對每個徵詢都回以錯誤,`call_tool` 會引發 `MCPError`,訊息是「Elicitation not supported」。 + +迴圈是有上限的。`Client(..., input_required_max_rounds=10)` 是預設的上限;伺服器若超過這個次數還繼續回傳 `InputRequiredResult`,`call_tool` 就會引發例外。如果某一輪只帶 `request_state` 而沒有 `input_requests`,`Client` 會先稍微睡一下(從 50 ms 開始加倍,最多 250 ms)再重試,這樣只是在說「還沒好」的伺服器就不會被忙碌輪詢。 + +### 自己驅動迴圈 {#driving-the-loop-yourself} + +對單一處理程序的用戶端來說,自動迴圈就夠了。遇到以下情況則改成自己掌握迴圈: + +* 用戶端是**分散式**的:把問題呈現給使用者的處理程序,和呼叫 `call_tool` 的不是同一個,所以重試是由另一個 worker 發出。`request_state` 就是你經由自己的儲存機制、帶著跨越那條邊界的可持久化權杖,而 `input_responses` 則是另一邊連同它一起送回來的東西。 +* 想要**檢視**每一輪:記錄或稽核每一筆 `input_requests`、拒絕某些種類的請求,或在各段之間套用自己的退避策略。 +* 想要的是**實際時間**上限而不是輪數上限:用 `anyio.fail_after(...)` 包住自己的迴圈,而不是依賴 `input_required_max_rounds`。 + +往下改用底層的工作階段,在那裡 `allow_input_required=True` 會直接把聯集型別交給你: + +```python title="client.py" hl_lines="12 13 19" +--8<-- "docs_src/mrtr/tutorial002.py" +``` + +* `client.session.call_tool(..., allow_input_required=True)` 把回傳型別放寬成 `CallToolResult | InputRequiredResult`。`isinstance` 負責把它收窄回來。 +* `request_state` 現在在你手上。在各段之間把它寫下來,對話就能從全新的處理程序接續。 +* 對 `input_requests` 裡的每一筆,都要在 `input_responses` 的**同一個鍵**底下放一個 `InputResponse`。`fulfil` 是放你的 UI 的地方;這個範例把答案寫死了。 +* 每一段都是同一個工具名稱、同樣的 `arguments`。重試是把原本的呼叫再做一次,不是新的方法。 + +## 保護 `requestState` {#protecting-requeststate} + +上面所有內容都把 `request_state` 當作一個回音,在線路上它也確實只是如此。但用戶端在各段之間持有它(跨處理程序把它寫下來,正是上一節所認可的做法),所以送回來的東西是**用戶端提供的輸入**:它可能被修改、已經過期,或根本是從另一次呼叫挪過來的。只要這個狀態會影響授權、資源存取或商業邏輯,規格就要求伺服器對它做完整性保護,並在驗證失敗時拒絕該輪。 + +`MCPServer` 預設就會保護它。每個伺服器都用處理程序啟動時產生的金鑰,密封送出的 `requestState` 並驗證每一個回音,解析器的狀態和手動組的狀態都一樣。不需要設定任何東西,寫明文、讀明文;線路上永遠只帶一個不透明的加密權杖。 + +預設金鑰與處理程序同生共死,這是部署到超過單一處理程序之前唯一必須知道的事: + +```python +from mcp.server.mcpserver import MCPServer, RequestStateSecurity + +# Multi-instance or restart-surviving: one or more shared secret keys (>= 32 bytes each). +mcp = MCPServer("fleet", request_state_security=RequestStateSecurity(keys=[key])) +``` + +* **預設(不做設定)**適合單一處理程序:stdio,或剛好一個 HTTP worker。重試如果落到另一個 worker、負載平衡器後的另一個實例,或重新啟動後的同一台伺服器,密封它的金鑰是那個處理程序沒有的——用戶端會收到下方那則固定的拒絕訊息,必須從頭開始整個流程。 +* 只要重試可能到達**另一個實例**(多 worker 的 `uvicorn`、負載平衡的 HTTP)或必須撐過重新啟動,就必須設定 **`keys=[...]`**:每個實例都能驗證任何兄弟實例簽發的東西。機制相同,只是用你的祕密金鑰取代自動產生的。 +* 若要用自己的加密機制,例如 KMS 或既有的權杖服務,改傳 `RequestStateSecurity(codec=...)` 而不是 `keys`;下方的 **[自備加密](#bring-your-own-crypto)** 說明其契約。 + +### 密封裡帶了什麼 {#what-the-seal-carries} + +不論是預設還是自行設定,線路上的 `requestState` 都是經過加密與認證的權杖。你的程式碼永遠看不到它:處理函式和解析器寫明文、讀明文(`ctx.request_state`);SDK 在送出時密封、收進來時驗證。除了完整性之外,每個權杖還綁定到: + +* **一段時間窗口。** 每一輪都會用新的到期時間重新密封,所以 `RequestStateSecurity(ttl=...)`(預設 600 秒)限制的是每一輪的思考時間,而不是整個流程。 +* **經過驗證的主體。** 當請求帶有 SDK 驗證過的 OAuth 存取權杖時,狀態會綁定到權杖的用戶端、簽發者和 subject:為某個使用者簽發的狀態換到另一個使用者底下就會失敗,即使兩個使用者共用同一個 OAuth 用戶端。驗證器若不提供 subject,綁定就退化成只剩用戶端身分,而在以 URL 為基礎的用戶端 ID 之下,這個身分是該用戶端軟體的所有使用者共用的。當驗證在 SDK 之外終結(前置代理),或傳輸未經驗證時,沒有主體可綁,這項檢查就不起作用,除非 `RequestStateSecurity(bind_principal=...)` 從你自己的身分訊號提供一個。不論權杖驗證器提供哪些組成,都必須前後一致地提供:驗證器如果在某些請求附上 subject、在其他請求省略,主體就在流程中途改變,進行中的各輪會被拒絕。 +* **原始請求。** 方法、工具或提示詞名稱(或資源 URI),以及引數的摘要。把權杖拿去對另一個工具、不同的引數或不同的方法重放,都會失敗。 +* **問出的確切問題。** 每個解析器的答案都釘在用戶端當時看到的、已轉譯好的問題上,無論是答案剛送達的那一輪,還是之後重用已記錄的答案時。重新部署時若改了訊息措辭或改了 schema,伺服器會重新提問,而不是吃下一個過時的答案。同樣的釘法也會反過來作用:訊息要從工具的引數推導,不要從每次呼叫各異的資料推導。用時間戳記或即時匯率組出來的訊息每一輪轉譯出來都不一樣,於是每個已記錄的答案看起來都過時,伺服器會一直重新提問,直到用戶端的輪數上限結束這次呼叫。 + +這些全都是 SDK 的工作,不是你的;如果自備 codec,也不是 codec 的。 + +### 輪替金鑰 {#rotating-keys} + +`keys[0]` 負責密封新的狀態;清單裡的每一把金鑰都能驗證。零停機輪替分三個階段,每一階段都要完全推出後才進入下一個: + +```python +RequestStateSecurity(keys=[OLD, NEW]) # 1: every instance learns to verify NEW; OLD still mints +RequestStateSecurity(keys=[NEW, OLD]) # 2: NEW mints; in-flight OLD state keeps verifying +RequestStateSecurity(keys=[NEW]) # 3: one ttl after phase 2 is fully out, retire OLD +``` + +千萬不要先升格簽發用的金鑰:用某些實例還無法驗證的金鑰簽發,會在推出途中讓進行中的各輪掉落。 + +金鑰的作用範圍是單一服務。密封的信封也帶著伺服器名稱作為 audience 宣告,所以由恰好共用同一個祕密的另一個服務所簽發的權杖,照樣會被拒絕。這個宣告的辨識度取決於名稱,所以給了明確策略的伺服器必須有真正的名稱,或設定 `RequestStateSecurity(audience=...)`——沒有名稱的會在建構時引發例外。`audience=` 也適用於刻意設計的多服務拓撲,也就是某個服務必須接受另一個服務簽發的狀態的情形。(不做設定的預設情況不受此限:它的金鑰從不離開處理程序,audience 宣告沒什麼可補充的。) + +### 自備加密 {#bring-your-own-crypto} + +`RequestStateSecurity(codec=...)` 接受任何具有 `seal(bytes) -> str` 與 `unseal(str) -> bytes`、且對任何不是自己簽發的權杖會引發 `InvalidRequestState` 的物件。典型的形式是搭配 KMS 的信封加密:啟動時解包一次資料金鑰,之後每個權杖的加解密都留在本機: + +```python title="server.py" hl_lines="12 26-27 34-35 38" +--8<-- "docs_src/mrtr/tutorial005.py" +``` + +TTL、主體綁定和請求綁定都**不是** codec 的工作:不論哪個 codec,SDK 都會在 `seal` 之前把它們蓋進酬載,在 `unseal` 之後重新驗證。codec 唯一的義務是完整性(被竄改就引發例外),以及最好還有機密性。 + +### 驗證失敗時 {#when-verification-fails} + +每一個進來的失敗,不論是被竄改、過期、對不同的請求或主體重放,還是用這台伺服器不認得的金鑰密封,都得到同一個回答: + +```json +{"code": -32602, "message": "Invalid or expired requestState"} +``` + +所有原因都是同一則固定訊息,所以線路上永遠看不出是哪項檢查失敗;真正的原因寫進伺服器記錄。`tools/call`、`prompts/get` 和 `resources/read` 上每一個進來的 `requestState` 都會檢查,連送往從不簽發狀態的處理函式的也包括在內。實務上最常見的拒絕不是攻擊者,而是預設的處理程序內金鑰碰上重新啟動之前或來自另一個實例的重試;用戶端重新開始流程,而在這件事要緊時,`keys=[...]` 就是解法。 + +### 手動組的狀態 {#hand-built-state} + +你自己設定的 `request_state`(從工具、提示詞或資源範本函式回傳 `InputRequiredResult`)和解析器狀態由同一套機制密封與驗證,程式碼一行都不用改:寫明文、讀明文,上面每一項綁定都適用。 + +即使設定好了,SDK 唯一無法替你釘住的是問題的身分:它不知道你狀態裡的某個答案屬於**你的**哪一個問題。如果你以問題為鍵存放答案,就在狀態裡放進自己的問題識別碼,並在重試時檢查它。 + +低階 `Server` 是不附電池的那一層:和 `MCPServer` 不同,在你自己加上那道邊界之前什麼都不會密封,而在那之前你的 `request_state` 會照寫出來的樣子原封不動上線路。那一行的選用寫法請見 **[低階 Server](../advanced/low-level-server.md#the-other-handlers)**。 + +## 2026-07-28 的結果型別 {#a-2026-07-28-result} + +`InputRequiredResult` 只存在於協定版本 **2026-07-28**。記憶體內的 `Client(server)` 會替你協商;走線路時,`mode="auto"` 會探知它。連線之後,`client.protocol_version` 會告訴你拿到的是什麼。 + +!!! warning + 2026 之前的工作階段沒有地方放 `InputRequiredResult`。在 `mode="legacy"` 的連線上從處理函式回傳一個,runner 無法把它序列化成協商好的版本;用戶端會拿回 `-32603`「Handler returned an invalid result」錯誤。同時服務兩個世代的伺服器,必須先檢查 `ctx.protocol_version` 再動用它。 + +!!! info + **URL 模式的徵詢**在 2026 連線上正是搭著這套機制。`input_requests` 裡的那一筆是一個 params 為 `ElicitRequestURLParams` 的 `ElicitRequest`;使用者完成帶外流程後,用戶端重試這次呼叫。同一個迴圈,沒有新的 API。高階伺服器那一半請見 **[徵詢](elicitation.md)**。 + +## 重點回顧 {#recap} + +* 在 2026-07-28,呼叫途中需要輸入的伺服器會**回傳** `InputRequiredResult`,從不向用戶端開請求。 +* `input_requests` 是它需要的東西。`request_state` 是只有伺服器會讀的不透明接續權杖。 +* `Client` 替你執行重試迴圈:註冊 `elicitation_callback`/`sampling_callback`/`list_roots_callback`,`call_tool` 就回傳普通的 `CallToolResult`。`input_required_max_rounds`(預設 10)替它設上限。 +* 要檢視或持久化各輪,用 `client.session.call_tool(..., allow_input_required=True)`,自己掌握 `while isinstance(result, InputRequiredResult)` 迴圈。 +* 在 `@mcp.tool()` 上,會詢問使用者的相依性替你產出這個結果(**[相依性](dependencies.md)**);**低階** `Server` 是手動的寫法。 +* 提示詞和資源也參與:`@mcp.prompt()` 或範本 `@mcp.resource()` 函式自己回傳 `InputRequiredResult`,重試時讀 `ctx.input_responses`。 +* `requestState` 回來時是用戶端提供的輸入,所以 `MCPServer` 預設就用處理程序內的金鑰密封它——解析器狀態和手動組的狀態都一樣;多實例部署要傳入 `RequestStateSecurity(keys=[...])`(或自訂 codec),好讓每個實例都能驗證兄弟實例簽發的東西。密封把每個權杖綁定到一段時間窗口、原始請求,以及經過驗證的主體——條件是請求帶有 SDK 驗證過的驗證資訊,或由 `bind_principal=` 提供你自己的身分訊號(**[保護 `requestState`](#protecting-requeststate)**)。 + +這就是取代伺服器主動發起的取樣、以及其餘推送式反向通道的機制;請見 **[已棄用的功能](../deprecated.md)**。 diff --git a/i18n/zh-hant/pages/handlers/progress.md b/i18n/zh-hant/pages/handlers/progress.md new file mode 100644 index 0000000000..de2ba68825 --- /dev/null +++ b/i18n/zh-hant/pages/handlers/progress.md @@ -0,0 +1,112 @@ +--- +translation: + sections: [5315262fe26b33e1, 9d8e98840f1b78f0, 0284b215e85366c4, 8534d8dbb4053a70, 2966fac6fe697007] + tool: 1 +--- +# 進度 {#progress} + +一個要跑三十秒、而這三十秒內毫無動靜的工具,看起來就像壞了。 + +**進度通知**就是用來解決這件事。工具回報自己做到哪裡;用戶端決定拿它畫什麼:進度條、轉圈圈的圖示,或一行記錄。 + +## 從工具回報 {#report-it-from-the-tool} + +接收一個 **`Context`** 參數,然後呼叫 `report_progress`: + +```python title="server.py" hl_lines="8 11" +--8<-- "docs_src/progress/tutorial001.py" +``` + +三個引數,意義由你決定: + +* `progress`:做到哪裡了。規格要求它每次回報都要**遞增**;不要重複同一個值,也不要倒退。 +* `total`:總共有多少,如果知道的話。可省略。 +* `message`:描述**這一步**的一行人類可讀文字。可省略。 + +`ctx` 是因為型別提示而被注入的,模型永遠看不到它:`import_catalog` 的輸入 schema 只有一個屬性,`urls`。**[Context](context.md)** 那一頁專門講這個物件;進度只是它提供的功能之一。 + +## 從用戶端監聽 {#listen-for-it-from-the-client} + +用戶端是**逐次呼叫**選擇加入的,做法是把 `progress_callback=` 傳給 `call_tool`: + +```python title="client.py" hl_lines="7 16" +import anyio +from mcp import Client + +from server import mcp + + +async def show(progress: float, total: float | None, message: str | None) -> None: + print(f"{message} ({progress}/{total})") + + +async def main() -> None: + async with Client(mcp) as client: + result = await client.call_tool( + "import_catalog", + {"urls": ["https://example.com/a.json", "https://example.com/b.json"]}, + progress_callback=show, + ) + print(result.structured_content) + + +anyio.run(main) +``` + +回呼是一個 `async` 函式,接收的正是伺服器回報的內容:`progress`、`total`、`message`。 + +!!! info + `Client(mcp)` 在記憶體內直接連上伺服器物件,和 **[測試](../get-started/testing.md)** 那一頁用的是同一個用戶端。不管 `Client` 用哪種傳輸方式,`progress_callback` 都是同一個參數;接下來看到的**時序**則是記憶體內連線的。它會就地執行回呼,所以每一筆回報都會在 `call_tool` 回傳之前送達。換成真正的傳輸方式,通知會和結果競速,一個慢的回呼在 `call_tool` 回傳之後可能還在執行。 + +### 試試看 {#try-it} + +把 `client.py` 放在 `server.py` 旁邊,然後執行: + +```console +python client.py +``` + +```text +Imported https://example.com/a.json (1/2) +Imported https://example.com/b.json (2/2) +{'result': 'Imported 2 records.'} +``` + +伺服器上的每一個 `await ctx.report_progress(...)` 都變成用戶端上對 `show` 的一次呼叫,依序發生,而且兩行都在 `call_tool` 回傳**之前**印出。進度不會打包進結果裡;它在工具還在執行時就持續串流過來。 + +!!! warning + `progress_callback` 屬於那一次**呼叫**,不屬於 `Client`。沒有對應的建構子引數,因為不同的呼叫想要不同的回呼:這一次驅動下載進度條,下一次是一行記錄。 + +!!! check + 現在刪掉 `progress_callback=show`,再執行一次: + + ```text + {'result': 'Imported 2 records.'} + ``` + + 沒有錯誤、沒有警告,結果一樣。**呼叫端沒有要求進度時,`report_progress` 什麼都不做**,所以無條件回報就好,永遠不必去猜有沒有人在聽。 + +## 不知道總量的時候 {#when-you-dont-know-the-total} + +`total` 是給知道分母時用的。常常並不知道:正在消化一個 feed、沿著游標往下走,或下載一個沒有長度標頭的東西。 + +那就省略它: + +```python title="server.py" hl_lines="20" +--8<-- "docs_src/progress/tutorial002.py" +``` + +回呼會收到 `total=None`。用戶端還是可以顯示**有在動**(「3 imported so far...」),但沒辦法顯示百分比。不要為了讓進度條好看一點就捏造一個總量。 + +!!! tip + `progress` 不一定要數某個特定的東西。位元組、資料列、頁數:挑使用者認得的單位,而且只承諾做得到的 `total`。 + +## 重點回顧 {#recap} + +* 在任何接收 `Context` 的工具裡呼叫 `await ctx.report_progress(progress, total=None, message=None)`。 +* 用戶端把 `progress_callback=` 傳給 `call_tool`:逐次呼叫,永遠不是設在 `Client` 上。 +* 回呼的形式是 `async (progress, total, message) -> None`,在工具還在執行時就會觸發。 +* 呼叫時沒有回呼,`report_progress` 就什麼都不做。無條件回報就好。 +* 不知道 `total` 就省略;回呼會收到 `None`。 + +進度是執行中的工具給**使用者**看的。它為**你**(操作伺服器的人)記下的那些行,是另一條通道:**[記錄](logging.md)**。 diff --git a/i18n/zh-hant/pages/handlers/sampling-and-roots.md b/i18n/zh-hant/pages/handlers/sampling-and-roots.md new file mode 100644 index 0000000000..30be506c12 --- /dev/null +++ b/i18n/zh-hant/pages/handlers/sampling-and-roots.md @@ -0,0 +1,51 @@ +--- +translation: + sections: [5c82b20cbd65ded0, 9dc22632be79a533, 1fb8f452e990c456, 42666ab914ff0cb1, c4e0cb3667fd5ff9] + tool: 1 +--- +# 取樣與根目錄 {#sampling-and-roots} + +處理函式還可以向連線的用戶端多要兩樣東西:由用戶端自己的模型產生的生成結果,也就是**取樣**(sampling);以及用戶端的工作區資料夾,也就是**根目錄**(roots)。 + +兩者在 SDK 支援的每個協定版本上都還能用。但在以它們為基礎做設計之前,先讀一下這段警告: + +!!! warning "已於 2026-07-28 規格中棄用" + 取樣和根目錄自 `2026-07-28` 起已棄用([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2577))。它們仍然完全可用,並且會在規格中至少保留 12 個月,之後才可能被移除;但新的實作不應該建立在它們之上。建議的遷移方式:不要用取樣,改為直接整合 LLM 供應商的 API;不要用根目錄,改為透過工具參數、資源 URI 或伺服器設定來傳入目錄。整個 SDK 的清單在 **[已棄用的功能](../deprecated.md)**。 + +## 取樣:借用用戶端的模型 {#sampling-borrow-the-clients-model} + +解析器回傳 `Sample(...)`,工具就會收到生成結果,走的是和 **[相依性](dependencies.md)** 中執行 `Elicit` 相同的相依性機制: + +```python title="server.py" hl_lines="10-15 19" +--8<-- "docs_src/sampling_and_roots/tutorial001.py" +``` + +* `Sample(messages, max_tokens=...)` 對應 `sampling/createMessage` 的參數。注入的值是用戶端的 `CreateMessageResult`;如果傳入 `tools` 或 `tool_choice`,則會變成 `CreateMessageResultWithTools`。 +* 用戶端必須宣告了 `sampling` 能力(如果傳入 `tools` 或 `tool_choice`,則是 `sampling.tools`)。如果沒有,呼叫會以 `-32021` 協定錯誤失敗,而不是送出一個用戶端無法處理的請求。沒有反向通道(back-channel)的 2026 之前的工作階段(session)則會以它一貫的「沒有反向通道」錯誤失敗,因為根本沒有通道可送。 +* 在 `2026-07-28`,請求是在多輪往返(multi-round-trip)流程中傳遞的(**[多輪往返請求](multi-round-trip.md)**);在 `2025-11-25` 則是對用戶端發出的獨立請求。兩種情況下程式碼都一樣,但要注意多輪往返的規則:請求在各輪重試之間必須呈現得完全相同,所以只能用工具的引數和其他穩定的資料來建構它。 +* 不要動 `include_context`:`"none"` 以外的值本身也已棄用(SEP-2596),而且需要一個幾乎沒有用戶端會宣告的能力。 + +## 根目錄:這個該放哪裡? {#roots-where-should-this-go} + +根目錄是用戶端表示伺服器可以操作的資料夾。它們是參考用的指引,不是存取控制機制。解析器回傳 `ListRoots()`: + +```python title="server.py" hl_lines="10-11 15" +--8<-- "docs_src/sampling_and_roots/tutorial002.py" +``` + +* 注入的 `ListRootsResult` 帶有一個 `Root` 清單:每個包含一個 `file://` URI 和一個選填的顯示名稱。 +* 把關條件和取樣相同:沒有宣告 `roots` 能力時,呼叫會以 `-32021` 失敗,而不會送出請求。 + +在線路的另一端,用戶端用它已有的回呼來回應這兩種請求:`sampling_callback` 和 `list_roots_callback`,說明見 **[用戶端回呼](../client/callbacks.md)**。 + +## 在 2025 世代的連線上 {#on-2025-era-connections} + +`ctx.session.create_message(...)` 和 `ctx.session.list_roots()` 仍然存在,供直接操作工作階段的程式碼使用。它們只在有反向通道的地方才能運作(2025 世代、非無狀態的連線),而且呼叫時會引發棄用警告。上面的解析器標記才是受支援的形式:它們會依協商出的版本挑選傳遞方式,也不會發出警告。 + +## 重點回顧 {#recap} + +* 從解析器回傳 `Sample(...)` 或 `ListRoots()`;工具會像收到其他相依性一樣收到 `CreateMessageResult` 或 `ListRootsResult`。 +* 用戶端必須宣告對應的能力,否則呼叫會以 `-32021` 失敗,而不會送出請求。 +* 兩項功能在 `2026-07-28` 都已棄用:目前完全可用,但不適合新設計。優先選擇供應商 API 而非取樣,優先選擇明確的參數而非根目錄。 + +回報慢速工具的進度:**[進度](progress.md)**。 diff --git a/i18n/zh-hant/pages/handlers/subscriptions.md b/i18n/zh-hant/pages/handlers/subscriptions.md new file mode 100644 index 0000000000..24449c645d --- /dev/null +++ b/i18n/zh-hant/pages/handlers/subscriptions.md @@ -0,0 +1,152 @@ +--- +translation: + sections: [60a9de8a0bdaa531, 317bbe7e4355cdcc, a61d660c8029e04a, 8f7e82fcb88df8a9, b165db51249ff8ed, 266f56fb798068a4, 7c0e57030b622139, df18d7c2417a9883] + tool: 1 +--- +# 訂閱 {#subscriptions} + +伺服器的目錄不是固定的。工具會在執行時出現,資源 URI 背後的內容也會改變。 + +**訂閱**是用戶端得知這些變化的方式。用戶端送出一個 `subscriptions/listen` 請求,而這個請求的回應**就是**串流:它會保持開啟,並傳送用戶端要求的變更通知。 + +## 從工具發布 {#publish-it-from-the-tool} + +你這邊要做的只有一行:發布變更。 + +```python title="server.py" hl_lines="20 32" +--8<-- "docs_src/subscriptions/tutorial001.py" +``` + +* `await ctx.notify_resource_updated("board://sprint")` 會送達每一個訂閱了該 URI 的開啟串流,其他人都不會收到。 +* `await ctx.notify_tools_changed()` 會送達每一個要求工具清單變更的串流。收到它的用戶端會再次呼叫 `tools/list`,這時就看得到 `sprint_report`。 +* 同系列的還有 `notify_prompts_changed()` 和 `notify_resources_changed()`。 +* 沒有訂閱者,就沒有工作。對閒置的伺服器發布是空操作,所以永遠不必檢查有沒有人在聽,只要說明什麼變了。 + +`MCPServer` 會替你服務 `subscriptions/listen`。線路上的義務(第一個訊框是確認、逐串流過濾、每個訊框都帶訂閱 id)是 SDK 的工作。 + +!!! check + 在線路上,一個過濾條件指名 `board://sprint` 的串流,在 `complete_task` 執行之後看起來像這樣: + + ```json + {"method": "notifications/subscriptions/acknowledged", + "params": {"notifications": {"resourceSubscriptions": ["board://sprint"]}, "_meta": {"io.modelcontextprotocol/subscriptionId": "listen-1"}}} + + {"method": "notifications/resources/updated", + "params": {"uri": "board://sprint", "_meta": {"io.modelcontextprotocol/subscriptionId": "listen-1"}}} + ``` + + 注意更新**沒有**帶什麼:看板本身。每個訊框都在 `_meta` 底下帶著 listen 請求的 JSON-RPC id,而那個 id 就是訂閱 id。它由用戶端產生:Python 的 `Client` 使用像 `"listen-1"` 這樣的字串;其他用戶端可能使用整數。 + +## 只給要求的內容 {#only-what-was-asked-for} + +過濾條件是一份契約。一個要求了工具清單變更和一個資源 URI 的串流,只會收到這兩種,別的都不會。發布一個提示詞變更,那個串流會保持安靜。 + +`MCPServer` 以完全相同的字串比對資源 URI,所以指名 `board://sprint` 的串流完全不會收到 `board://sprint/tasks/1` 的任何動靜。規格允許伺服器回報已訂閱 URI 的子資源變更;`MCPServer` 從不這麼做,但用戶端的設計會預期這種情況。 + +串流**不是**的兩件事: + +* **它不是重播記錄。** 斷掉的串流就沒了,沒人連線時發布的事件也不會排入佇列。用戶端會重新 listen 並重新擷取。 +* **它不是 2025 的路徑。** 呼叫了 `resources/subscribe` 的用戶端由 `ctx.session.send_resource_updated(uri)` 服務。`notify_*` 方法只會送達 `subscriptions/listen` 串流。 + +## 決定誰可以觀看 {#deciding-who-may-watch} + +預設情況下,每一種要求的類型和 URI 都會被接受:任何呼叫端都可以觀看你發布的任何 URI。沒有任何東西會去問你的讀取處理函式,因為沒有人在讀取。一個會被 `files://{name}` 處理函式拒絕的呼叫端,仍然可以對 `files://payroll.csv` 開啟串流,得知它變了、什麼時候變的。它永遠不會得知內容,也無法探測有哪些東西存在,因為未知的 URI 一樣會被接受,只是永遠不會觸發。範圍很窄但確實存在,所以在從多租戶伺服器發布每位使用者各自的 URI 之前,先加上把關。 + +把關用的是中介軟體。它會在 SDK 確認之前看到 `subscriptions/listen` 請求,並在呼叫端要求任何他們無權讀取的東西時拒絕: + +```python title="server.py" hl_lines="19-26 29" +--8<-- "docs_src/subscriptions/tutorial006.py" +``` + +* `ctx.params` 是原始請求,所以中介軟體自己把它驗證成 `SubscriptionsListenRequestParams`,再讀出用戶端要求的過濾條件。 +* 拒絕的方式是在 `call_next(ctx)` 之前引發 `MCPError`:用戶端會收到那個錯誤而沒有串流,連線則繼續。訊息要保持一致、不指名任何 URI,這樣拒絕就永遠不會證實哪些 URI 受到保護。 +* 一個 `can_access(user, uri)` 回答兩個問題。資源處理函式在 `resources/read` 時問它;中介軟體在 `subscriptions/listen` 時問它。把那張表換成資料庫或你的 RBAC 系統,兩邊依然同步。 +* 這個決定在串流的整個存續期間都有效。沒有逐事件的重新檢查,所以如果呼叫端的存取權可能在串流中途失效(權杖過期),就在失效時結束那個呼叫端的連線。 + +完整的中介軟體契約,包括它還包裝了什麼、以及為什麼標示為暫定,請見 **[中介軟體](../advanced/middleware.md)**。 + +## 用戶端那一端 {#the-client-end} + +以下是串流另一端的用戶端,正在追蹤看板: + +```python title="client.py" hl_lines="15" +--8<-- "docs_src/subscriptions/tutorial003.py" +``` + +進入 `client.listen(...)` 會送出請求並等待你的確認,所以區塊開始時串流已經接通,而每個有型別的事件都是重新擷取的訊號,從來不是酬載。整份契約一個畫面就講完了。用戶端那一端的其他一切都在它自己的頁面上:在主流程旁邊觀看、串流的結束,以及重新 listen。請見「用戶端」章節下的 **[訂閱](../client/subscriptions.md)**。 + +## 擴展到單一處理程序之外 {#scaling-past-one-process} + +發布的內容透過 `SubscriptionBus` 從處理函式送到開啟中的串流。預設是記憶體內的:一個處理程序,所有串流都在裡面。在你於負載平衡器後面執行多個副本之前,這都是正確答案;因為到那時,用戶端的串流會固定在某一個副本上,而另一個副本上的發布必須送得到它。 + +那個接縫由你實作:在你的 pub/sub 後端上實作兩個方法。 + +```python +from collections.abc import Callable + +from redis.asyncio import Redis + +from mcp.server.mcpserver import MCPServer +from mcp.server.subscriptions import ServerEvent # SubscriptionBus is a Protocol: no base class + + +class RedisSubscriptionBus: + def __init__(self, redis: Redis) -> None: + self._redis = redis + self._listeners: dict[object, Callable[[ServerEvent], None]] = {} + + async def publish(self, event: ServerEvent) -> None: + await self._redis.publish("mcp-events", encode(event)) # to every replica + + def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]: + token = object() + self._listeners[token] = listener + + def unsubscribe() -> None: + self._listeners.pop(token, None) + + return unsubscribe + + +mcp = MCPServer("Sprint Board", subscriptions=RedisSubscriptionBus(redis)) +``` + +`encode` 是你的,每個副本上負責解碼送達的訊息並呼叫每個已註冊監聽器的讀取任務也是你的。監聽器是同步的,不可以引發例外,並且在伺服器的事件迴圈上執行。 + +匯流排承載的是有型別的 `ServerEvent` 值(四個小小的 dataclass),從來不是 JSON-RPC。加註、過濾和串流生命週期都留在 SDK 裡,所以匯流排的實作不可能破壞協定,只能在處理程序之間搬運事件。 + +要從請求之外發布,就自己建構匯流排,這樣你才握有參考。什麼都不傳時 `MCPServer` 會在內部建立一個,而且不會公開它。 + +```python +from mcp.server.subscriptions import InMemorySubscriptionBus, ToolsListChanged + +bus = InMemorySubscriptionBus() +mcp = MCPServer("Sprint Board", subscriptions=bus) + + +async def tools_reloaded() -> None: + await bus.publish(ToolsListChanged()) # from a lifespan task, a webhook, anywhere +``` + +## 低階組合方式 {#the-low-level-composition} + +在低階的 `Server` 上沒有任何預先接好的東西,同樣的零件三行就能組起來: + +```python title="server.py" hl_lines="8-9 47" +--8<-- "docs_src/subscriptions/tutorial002.py" +``` + +* 匯流排是你的,所以直接對它發布:`await bus.publish(ResourceUpdated(uri=...))`。把它放在處理函式搆得到的地方:這裡是模組範圍,較大的應用程式則放在生命週期裡。 +* `ListenHandler(bus)` 就是 `MCPServer` 註冊的同一個處理函式,而 `on_subscriptions_listen=` 是一個普通的處理函式插槽。想要不同的語意,就把你自己的 callable 放進那個插槽,規格上的義務就轉到你身上:先確認、每個訊框加註訂閱 id、過濾條件之外的一律不送。 +* `ListenHandler.close()` 會優雅地結束每一個開啟的串流。每一個都會收到 listen 請求的結果作為最後一個訊框,這是規格用來表示伺服器刻意結束訂閱的方式。它會在那些串流清空完畢之前回傳,所以在拆掉傳輸之前給它們一點時間。沒有它,串流會在用戶端斷線時結束。 + +## 重點回顧 {#recap} + +* 用戶端用一個 `subscriptions/listen` 請求選擇加入,而回應就是串流。服務它的功能是內建的。 +* 用 `ctx.notify_*` 發布,SDK 負責加註、過濾和生命週期的工作。 +* 事件是訊號,不是酬載。兩端都重新擷取。 +* 用戶端那一端是 `async with client.listen(...)`:完整說明請見「用戶端」章節下的 **[訂閱](../client/subscriptions.md)**。 +* 在低階的 `Server` 上,同樣的零件自己組:一個匯流排、`ListenHandler(bus)`、`on_subscriptions_listen` 插槽。 +* 橫向擴展代表實作 `SubscriptionBus`(兩個方法),然後以 `MCPServer(subscriptions=...)` 傳入。 + +執行提供這一切的伺服器,不管是一個副本還是 20 個,請見 **[部署與擴展](../run/deploy.md)**。 diff --git a/i18n/zh-hant/pages/index.md b/i18n/zh-hant/pages/index.md new file mode 100644 index 0000000000..f94c586b96 --- /dev/null +++ b/i18n/zh-hant/pages/index.md @@ -0,0 +1,97 @@ +--- +translation: + sections: [154c4309937b9f85, 3ad8fc6caa76a9b0, a07f3f5b151ab746, bf6e476b712930c0, cf0b1f13978c6623] + tool: 1 +--- +# MCP Python SDK {#mcp-python-sdk} + +!!! info "這裡是 v2 的說明文件,也就是目前的穩定發行版本" + 剛接觸 v2,或是從 v1 過來?**[v2 的新功能](whats-new.md)** 用五分鐘帶你看過有哪些改變,**[遷移指南](migration.md)** 則涵蓋每一項破壞性變更。還在用 v1.x?它的說明文件在 [v1.x 文件](https://py.sdk.modelcontextprotocol.io/v1/)。哪裡卡住或看不懂?[告訴我們](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml)。 + +**Model Context Protocol(MCP)** 讓應用程式能以標準化的方式為 LLM 提供上下文,把**提供**上下文這件事和與 LLM 的互動本身分開。 + +這是它的官方 Python SDK。有了它,你可以: + +* **建立 MCP 伺服器**,向任何 MCP 主機(host)公開工具、資源和提示詞。 +* **建立 MCP 用戶端**,連線到任何 MCP 伺服器。 +* 支援每一種標準傳輸方式:stdio、Streamable HTTP 和 SSE。 + +## 環境需求 {#requirements} + +Python 3.10+。 + +## 安裝 {#installation} + +=== "uv" + + ```bash + uv add "mcp[cli]" + ``` + +=== "pip" + + ```bash + pip install "mcp[cli]" + ``` + +`[cli]` extra 會提供 `mcp` 指令,開發時會用到。每個相依套件的用途請見[安裝](get-started/installation.md)。 + +## 範例 {#example} + +### 建立 {#create-it} + +建立 `server.py` 檔案: + +```python title="server.py" +--8<-- "docs_src/index/tutorial001.py" +``` + +這就是一個完整的 MCP 伺服器。 + +它公開了一個**工具** `add`,以及一個範本化的**資源** `greeting://{name}`。 + +### 執行 {#run-it} + +```console +uv run mcp dev server.py +``` + +這會啟動伺服器並開啟 [MCP Inspector](https://github.com/modelcontextprotocol/inspector),一個可以動手操作伺服器的互動式介面。打開它印出的 URL 即可。 + +!!! note + Inspector 是 Node.js 應用程式,所以 `mcp dev` 需要 `PATH` 上找得到 `npx`。 + +### 試試看 {#try-it} + +在 Inspector 中前往 **Tools**,用 `a=1`、`b=2` 呼叫 `add`。 + +得到的結果是 `3`。✨ + +那張表單(`a` 一個必填整數欄位、`b` 另一個)是 Inspector 從型別提示建出來的。Claude 和其他所有 MCP 主機也都會這麼做。 + +接著前往 **Resources**,讀取 `greeting://World`: + +```text +Hello, World! +``` + +### 重點回顧 {#recap} + +再看一次你**沒有**寫的東西: + +* 沒有 JSON Schema。`a: int, b: int` **就是** schema。 +* 沒有請求解析、沒有序列化、不用寫驗證程式碼。 +* 完全不用處理協定。 + +你寫了兩個帶型別提示和 docstring 的 Python 函式,剩下的交給 SDK。 + +## 接下來 {#where-to-go-next} + +* **[開始使用](get-started/index.md)** 帶你從安裝一路走到一個可運作、經過測試的伺服器。 +* 要打造**使用** MCP 伺服器的應用程式?從 **[用戶端](client/index.md)** 開始。 +* 已經有 FastAPI 或 Starlette 應用程式?**[加入現有應用程式](run/asgi.md)** 教你把 MCP 伺服器掛載進去。 +* 在找某個確切的錯誤訊息?**[疑難排解](troubleshooting.md)** 以原文字串為索引。 +* 想知道 v2 改了什麼?**[v2 的新功能](whats-new.md)** 是五分鐘導覽。 +* 從 v1 遷移?從 **[遷移指南](migration.md)** 開始。 +* 在找確切的函式簽章?**[API 參考](api/mcp/index.md)** 是從原始碼產生的。 +* 和 LLM 一起閱讀?這份說明文件也以 [llms.txt](https://llmstxt.org/) 格式發布:[llms.txt](https://py.sdk.modelcontextprotocol.io/llms.txt) 是各頁面的索引,[llms-full.txt](https://py.sdk.modelcontextprotocol.io/llms-full.txt) 則把每一頁放進單一檔案。 diff --git a/i18n/zh-hant/pages/protocol-versions.md b/i18n/zh-hant/pages/protocol-versions.md new file mode 100644 index 0000000000..459a6b4102 --- /dev/null +++ b/i18n/zh-hant/pages/protocol-versions.md @@ -0,0 +1,127 @@ +--- +translation: + sections: [478fd619e5f90ef8, aef094a00e44e248, bab8cbf3449fa7e9, df1809b15a58335b, 5f9d8c2336ed0239, f54974398e43ddef, b24443dd78584870] + tool: 1 +--- +# 協定版本 {#protocol-versions} + +MCP 有兩個世代。 + +2026-07-28 之前發佈的伺服器,每次連線都以 **`initialize` 交握**開場:用戶端提出一個版本,伺服器回一個版本,用戶端確認,這一切都發生在第一個真正有用的請求之前。**2026-07-28** 的伺服器拿掉了交握。用戶端送出一個 **`server/discover`** 探測,伺服器用單一結果一次回答全部內容。 + +你幾乎不需要在意這件事,因為 `Client` 會替你協商。這一頁談的是控制這件事的那一個建構子引數 `mode=`,以及需要改動它的三種情況。 + +## `mode="auto"` {#modeauto} + +```python title="client.py" hl_lines="14-15" +--8<-- "docs_src/protocol_versions/tutorial001.py" +``` + +沒有傳入 `mode`,所以拿到的是預設值:`"auto"`。進入 `async with` 時,會以這個 SDK 支援的最新版本送出一個 `server/discover` 探測。接著: + +* **新世代伺服器**會回答。用戶端採用結果。一次往返,完成。 +* **較舊的伺服器**從沒聽過 `server/discover`,回傳錯誤。用戶端退回傳統的 `initialize` 交握,接受它協商出的結果。 + +不管哪一種,最後都會連上線,而 `client.protocol_version` 會告訴你是哪一種: + +```text +2026-07-28 +``` + +整個功能就這樣。一個 `Client`,任何世代的伺服器,程式碼裡不用分支。 + +!!! info + `MCPServer` 在每一種傳輸方式上都會回答 `server/discover`(記憶體內、stdio、Streamable HTTP),所以對你自己的伺服器,`auto` 永遠會落在 `2026-07-28`。退回機制只會在面對真正的 2026 之前的伺服器時觸發,而那正是你希望它觸發的時候。 + +## `mode="legacy"` {#modelegacy} + +```python title="client.py" hl_lines="14" +--8<-- "docs_src/protocol_versions/tutorial002.py" +``` + +`mode="legacy"` 從不探測。它執行 `initialize` 交握,也就是 2026 之前的用戶端會開啟的那種連線。 + +```text +2025-11-25 +``` + +同一個伺服器。它完全能說 `2026-07-28`;是你叫用戶端不要問的。 + +**推送式**的功能需要這個。 + +伺服器發起的請求,是伺服器反過來呼叫**你**:`ctx.elicit(...)` 把表單擺到你的使用者面前,取樣(sampling)在工具呼叫進行到一半時向你的模型要一段生成結果。這個通道只存在於交握世代的工作階段(session)上。 + +到了 2026-07-28 它就沒了。伺服器改成**回傳**它的問題,你帶著答案重試呼叫(**[多輪往返(multi-round-trip)請求](handlers/multi-round-trip.md)**)。 + +`mode="auto"` 只有在伺服器舊到別無選擇時才會給你交握。`mode="legacy"` 則保證有交握。只要你交給 `Client(...)` 一個 `sampling_callback`、一個想以請求方式驅動的 `elicitation_callback`,或一個 `message_handler`,就用它。**[用戶端回呼](client/callbacks.md)** 逐一說明。 + +## 釘選版本 {#pinning-a-version} + +`mode` 也接受新世代的協定版本字串。目前這個集合剛好就是 `["2026-07-28"]`。 + +```python title="client.py" hl_lines="14" +--8<-- "docs_src/protocol_versions/tutorial003.py" +``` + +釘選**什麼都不送**。沒有探測,沒有交握。用戶端在本機直接採用 `2026-07-28`,`async with` 一回傳,連線就是通的。 + +釘選是**你**做出的承諾:你已經知道伺服器說那個版本。用戶端不會檢查。 + +!!! check + 釘選不是探索。印出 `client.server_info`,代價就擺在眼前: + + ```text + None + ``` + + 用戶端從沒問過伺服器它是誰,所以 `server_info` 是 `None`。`client.server_capabilities` 也一樣:每個能力都是 `None`。工具呼叫照常運作(協定完全不需要這些);讀取 `server_capabilities` 來決定要提供什麼的程式碼就不行了。 + + 下一節就是解法。 + +只有新世代的版本可以釘選。交握世代的字串在建構時就會被拒絕,早於任何 I/O,而錯誤訊息會告訴你該改寫成什麼: + +```text +ValueError: mode must be 'legacy', 'auto', or one of ['2026-07-28']; got '2025-06-18' ('2025-06-18' is a handshake-era version; use mode='legacy') +``` + +## 用 `prior_discover` 重新連線 {#reconnecting-with-prior_discover} + +探測很便宜,但它仍然是每次重新連線都要付的一次往返,而答案幾乎從來不變。 + +所以把它留下來。`auto` 連線之後,`client.session.discover_result` 保存著伺服器送來的那份 `DiscoverResult`:它的 `supported_versions`、`capabilities`、`instructions`,以及伺服器蓋進結果 `_meta` 裡的身分。下次把它作為 `prior_discover=` 交回去: + +```python title="client.py" hl_lines="15 17" +--8<-- "docs_src/protocol_versions/tutorial004.py" +``` + +```text +2026-07-28 +Bookshop +``` + +第二次連線做了**零**次協商往返,卻仍然確切知道對方是誰。這才是釘選模式的正確用法:`mode=` 指定版本,`prior_discover=` 提供身分。✨ + +`DiscoverResult` 是 Pydantic 模型。`saved.model_dump_json()` 存進檔案或快取;`DiscoverResult.model_validate_json(...)` 在下一個處理程序裡把它讀回來。 + +!!! tip + `prior_discover=` 只有在 `mode` 是版本釘選時才有作用。在 `"auto"` 下用戶端反正會探測伺服器,在 `"legacy"` 下則會被忽略。 + +## 四種模式 {#the-four-modes} + +| 你寫的 | 協商流量 | 你得到的 | +| --- | --- | --- | +| `Client(target)` | 一個 `server/discover` 探測;失敗的話改走 `initialize` 交握 | 雙方都支援的最新版本,不論哪個世代 | +| `Client(target, mode="legacy")` | `initialize` 交握 | 交握世代的版本;伺服器發起的請求可以運作 | +| `Client(target, mode="2026-07-28")` | 無 | 那個版本,已釘選,`server_info` 為 `None` | +| `Client(target, mode="2026-07-28", prior_discover=saved)` | 無 | 那個版本,已釘選,**而且**還有上次存下來的身分 | + +## 重點回顧 {#recap} + +* MCP 有交握世代(到 `2025-11-25` 為止,`initialize` 交握)和新世代(`2026-07-28`,`server/discover`)。`Client` 銜接兩者。 +* `mode="auto"` 是預設:先探測,不行再退回。除非其他三列有一列說的是你,否則不要動它。 +* `client.protocol_version` 永遠能回答「我拿到的是什麼?」。 +* `mode="legacy"` 強制交握。伺服器發起的請求需要它:取樣、推送式徵詢(elicitation)、`message_handler`。 +* 版本釘選(`mode="2026-07-28"`)完全不送協商流量,代價是 `client.server_info` 為 `None`。 +* `prior_discover=` 把這個代價補回來:存下 `client.session.discover_result`,帶著它重新連線,兩者兼得。 + +新世代連線沒有推送通道,那 2026 的伺服器要怎麼在呼叫進行到一半時問你問題?它把問題回傳:**[多輪往返請求](handlers/multi-round-trip.md)**。 diff --git a/i18n/zh-hant/pages/run/asgi.md b/i18n/zh-hant/pages/run/asgi.md new file mode 100644 index 0000000000..32c34aeb0a --- /dev/null +++ b/i18n/zh-hant/pages/run/asgi.md @@ -0,0 +1,130 @@ +--- +translation: + sections: [1062ef792791488a, 4be2b831547184a9, 374b049e770385f2, b72f6947089e6de0, b172c9db7831bb31, 70b9ece244ca1b0c, cba78e052898c3f6, f06bdb541cb0b469, fb82d526320b7cc3] + tool: 1 +--- +# 加到現有的應用程式中 {#add-to-an-existing-app} + +`mcp.run("streamable-http")` 會幫你啟動一個網頁伺服器。有時候你不想要這樣:MCP 伺服器只是較大網頁應用程式的其中一塊,或者你早就有 ASGI 部署了。 + +這種情況下,`mcp.streamable_http_app()` 會回傳一個 **Starlette 應用程式**。 + +Starlette 應用程式就是 ASGI 應用程式,所以任何能承載 ASGI 的東西(uvicorn、Hypercorn、另一個 Starlette、FastAPI)都能承載你的 MCP 伺服器。 + +## 應用程式 {#the-app} + +```python title="server.py" hl_lines="12" +--8<-- "docs_src/asgi/tutorial001.py" +``` + +`app` 是普通的 ASGI 應用程式。交給任何 ASGI 伺服器即可: + +```console +uvicorn server:app +``` + +MCP 端點在 `/mcp`,所以用戶端要連到 `http://127.0.0.1:8000/mcp`。 + +這個應用程式已經自帶兩樣東西: + +* 一條路由 `/mcp`:Streamable HTTP 端點。 +* 一個**生命週期**(lifespan),負責啟動 `mcp.session_manager`,也就是掌管每個進行中工作階段(session)背景工作的那個物件。 + +單獨執行這個應用程式(`uvicorn server:app`),這兩件事你完全不用操心。 + +!!! tip + `streamable_http_app()` 接受的關鍵字引數和 `mcp.run("streamable-http", ...)` 一樣,只是少了 `port`:連接埠屬於負責提供這個應用程式的那一層。`host` 仍然接受,但在這裡不會綁定任何東西;它實際控制什麼,**[部署與擴展](deploy.md)** 有說明。選項本身則請見 **[執行伺服器](index.md)**。 + +`mcp.sse_app()` 對已被取代的 SSE 傳輸做同樣的事。 + +## 只限 localhost,除非你另有指定 {#localhost-only-until-you-say-otherwise} + +預設情況下,這個應用程式**只**回應送往 localhost 的請求。`streamable_http_app()` 無從得知自己會在哪個主機名稱後面提供服務,所以它用最保險的允許清單啟用 DNS 重新綁定防護;在你自己的機器上,這正好合適。部署到真正的主機名稱後面,就代表**每個請求都會以 `421 Misdirected Request` 被拒絕**,直到你透過 `transport_security=` 傳入一份你實際提供服務的主機允許清單為止。在那之前,請求根本到不了你寫的任何東西。這份允許清單,以及從一個能動的應用程式到真正主機名稱之間的其他一切,都在 **[部署與擴展](deploy.md)**。 + +## 掛載 {#mounting-it} + +當 MCP 伺服器成為更大應用程式的**一部分**時,就要把這個應用程式放進 `Mount` 裡。而一旦這麼做,生命週期就成了你的責任: + +```python title="server.py" hl_lines="18-21 25-26" +--8<-- "docs_src/asgi/tutorial002.py" +``` + +* `Mount("/", ...)` 加上預設的 `/mcp` 路徑,端點仍然在 `/mcp`。Starlette 依序嘗試路由,而 `Mount("/")` 會比對到**每一個**路徑,所以你自己的路由要放在清單中它的**前面**。放在它後面的都到不了。 +* `lifespan` 函式會在**外層**應用程式的整個存活期間進入 `mcp.session_manager.run()`。這就是大家都會忘記的那一行。 +* `mcp.session_manager` 要在呼叫過 `streamable_http_app()` **之後**才存在。這就是為什麼路由在模組層級建立,而管理器只在生命週期裡才會碰到。 + +Starlette 的 `Host` 路由用法相同:把 `Mount("/", ...)` 換成 `Host("mcp.example.com", ...)`,就改成依主機名稱而非路徑來路由。生命週期的規則不變,傳輸安全的規則也不變。`Host("mcp.example.com", ...)` 路由只會收到送往該主機名稱的請求,但傳輸本身的 Host 允許清單(**[部署與擴展](deploy.md)**)仍然會先執行。清單裡沒有 `"mcp.example.com"` 的話,那條路由對每一個請求的回應都是 `421`。 + +!!! warning "生命週期歸外層應用程式管" + `streamable_http_app()` 把 `session_manager.run()` 接進它回傳的那個 Starlette 的生命週期裡,但**被掛載的子應用程式,其生命週期永遠不會執行**。一旦掛載,內建的生命週期就成了死程式碼。位於 ASGI 堆疊最頂端的那個應用程式,必須在自己的生命週期裡進入 `mcp.session_manager.run()`。 + +!!! check + 刪掉 `lifespan=lifespan` 那一行再啟動伺服器。能啟動,路由也能解析。然後第一個送往 `/mcp` 的請求會失敗: + + ```text + RuntimeError: Task group is not initialized. Make sure to use run(). + ``` + + 除了它自己的 `run()`,沒有任何東西會啟動工作階段管理器。 + +## 兩個伺服器,一個應用程式 {#two-servers-one-app} + +每個 `MCPServer` 都是各自獨立的應用程式,有自己的工作階段管理器。想掛載幾個都可以;在外層那一個生命週期裡進入每一個管理器: + +```python title="server.py" hl_lines="27-30 35-36" +--8<-- "docs_src/asgi/tutorial003.py" +``` + +* `AsyncExitStack` 會進入兩個管理器;它們一起啟動,並以相反順序關閉。 +* 端點是 `/notes/mcp` 和 `/tasks/mcp`:掛載前綴加上預設路徑。 + +## 更改路徑 {#changing-the-path} + +結尾那個 `/mcp` 就是 `streamable_http_path`。把它設成 `"/"`,掛載前綴就成了完整的對外路徑: + +```python title="server.py" hl_lines="25" +--8<-- "docs_src/asgi/tutorial004.py" +``` + +現在用戶端連到 `/notes`,而不是 `/notes/mcp`。 + +## 給瀏覽器用戶端的 CORS {#cors-for-browser-clients} + +以瀏覽器為基礎的用戶端需要你給兩項許可:**送出**它的 MCP 請求標頭,以及**讀取** MCP 回傳的那一個標頭。兩者都是外層應用程式上的 CORS 設定,而上面的傳輸安全允許清單必須和它一致: + +```python title="server.py" hl_lines="27-30 33 35-49" +--8<-- "docs_src/asgi/tutorial005.py" +``` + +* `allow_headers` 是大家都會忘的那一半。瀏覽器對每個 MCP 請求都會做**預檢**,因為 `Content-Type: application/json` 和 `Mcp-*` 請求標頭不在 CORS 安全清單上,而預檢沒放行的標頭,就等於瀏覽器永遠不會送出的請求。(`allow_headers=["*"]` 也行:預檢要求什麼,Starlette 就回什麼。) +* `expose_headers=["Mcp-Session-Id"]` 是讀取那一半。Streamable HTTP 在那個回應標頭中回傳工作階段 ID,而除非 CORS 指名公開,瀏覽器會對 JavaScript 隱藏回應標頭。少了它,用戶端永遠發不出第二個請求。 +* `allow_origins` 是你的決定,不是 MCP 的。要精確,並在上面的 `allowed_origins=` 中照樣設定:CORS 由瀏覽器強制執行,但伺服器自己也會檢查 `Origin`,傳輸不信任的來源即使預檢順利通過,仍會收到 `403`。 +* `allow_methods` 列出 Streamable HTTP 用到的三個方法:`POST` 送出訊息、`GET` 開啟伺服器到用戶端的串流、`DELETE` 結束工作階段。 + +## 自訂路由 {#custom-routes} + +`@mcp.custom_route()` 在同一個應用程式上註冊一個普通的 HTTP 端點,給每個部署的服務都需要、但和 MCP 毫無關係的東西用:健康檢查、OAuth 回呼。 + +```python title="server.py" hl_lines="15-17" +--8<-- "docs_src/asgi/tutorial006.py" +``` + +* 處理函式就是普通的 Starlette:一個從 `Request` 到 `Response` 的 `async` 函式。 +* `streamable_http_app()` 會收進每一條自訂路由。`app.routes` 現在是 `/mcp` 和 `/health`。 +* `GET /health` 回應 `{"status": "ok"}`,完全看不到 MCP 的影子。 + +!!! warning + 自訂路由**永遠不會經過驗證**,即使伺服器的其他部分有。這是刻意的:健康檢查和 OAuth 回呼必須在任何權杖存在之前就能連到。不要把任何私密的東西放在它後面。 + +## 重點回顧 {#recap} + +* `mcp.streamable_http_app()` 回傳一個只有一條路由 `/mcp` 的 Starlette 應用程式。任何 ASGI 伺服器都能執行它。 +* 預設情況下,這個應用程式只回應送往 localhost 的請求;放在真正的主機名稱後面時,在你透過 `transport_security=` 傳入允許清單之前,它會以 `421` 拒絕一切。這件事,以及通往正式環境的其餘路程,都歸 **[部署與擴展](deploy.md)** 管。 +* `Mount`(或 `Host`)把它放進更大的 Starlette 或 FastAPI 應用程式裡。 +* **掛載會停用內建的生命週期。**外層應用程式的生命週期必須進入 `mcp.session_manager.run()`,否則第一個請求就會失敗。 +* 一個應用程式裡放多個伺服器,代表多個掛載,加上一個會進入每個工作階段管理器的生命週期。 +* `streamable_http_path="/"` 把端點移到掛載前綴本身。 +* 瀏覽器用戶端需要 CORS:`allow_headers` 給 `Mcp-*` 請求標頭用,`expose_headers=["Mcp-Session-Id"]` 給回應用。 +* `@mcp.custom_route()` 在 `/mcp` 旁邊加上普通、不經驗證的 HTTP 端點。 + +一旦伺服器能透過真正的 URL 連到,**[用戶端](../client/index.md)** 就會用那個 URL 而不是伺服器物件來連線。 diff --git a/i18n/zh-hant/pages/run/authorization.md b/i18n/zh-hant/pages/run/authorization.md new file mode 100644 index 0000000000..1618cddc3c --- /dev/null +++ b/i18n/zh-hant/pages/run/authorization.md @@ -0,0 +1,122 @@ +--- +translation: + sections: [d62c13457fc4a534, 80e73abaca6e0652, d1dc4c54cd00ec9c, 14ad3bc7904036bb, 5225f127bc1b9c77, fe1626fdd5aad1da, 4556cb7ea1a04a31] + tool: 1 +--- +# 授權 {#authorization} + +透過 Streamable HTTP,MCP 伺服器就是一個普通的 Web 服務,保護它的方式也和保護任何 Web 服務一樣:用 OAuth 2.1 bearer 權杖。 + +以 OAuth 的術語來說,你的伺服器是**資源伺服器(resource server)**。它從不讓任何人登入,也從不發出權杖。它只做一件事:查看每個請求上的 `Authorization` 標頭,判斷裡面的權杖是否有效。 + +這一頁講的是伺服器端。會探索授權伺服器並取得權杖的用戶端,請見 **[OAuth 用戶端](../client/oauth-clients.md)**。 + +## 三方角色 {#the-three-parties} + +* **授權伺服器**負責讓人登入並發出存取權杖。這個不用你寫,它就是你的身分提供者(Auth0、Keycloak、Entra,或你自己的)。 +* **資源伺服器**就是你的 MCP 伺服器。它在每個請求上驗證權杖。 +* **用戶端**會探索你信任哪個授權伺服器,從那裡取得權杖,再以 `Authorization: Bearer ` 的形式送回來給你。 + +整個三角關係就這樣。這一頁的所有內容都是中間那一項。 + +## 權杖驗證器 {#a-token-verifier} + +有效的權杖長什麼樣子,SDK 沒有任何預設立場。由你來告訴它,方法是實作 **`TokenVerifier`**: + +```python title="server.py" hl_lines="12-14 19-24" +--8<-- "docs_src/authorization/tutorial001.py" +``` + +* `TokenVerifier` 是只有一個非同步方法的 protocol。`verify_token` 會拿到 `Authorization` 標頭裡的原始權杖,有效就回傳 **`AccessToken`**,無效就回傳 `None`。沒有別的需要實作。 +* 這個範例是在一張表裡查權杖。真實的實作會驗證 JWT 簽章,或呼叫授權伺服器的權杖內省(token introspection)端點。那段程式碼是你的,SDK 只負責呼叫它。 +* `token_verifier=` 和 `auth=` 永遠成對出現。只傳其中一個,`MCPServer(...)` 在服務任何請求之前就會引發 `ValueError`。 + +`AuthSettings` 是資源伺服器對外的門面: + +* `issuer_url`:發出權杖的授權伺服器。 +* `resource_server_url`:這個 MCP 端點的公開 URL。它指明權杖是給**哪一個**資源用的,也是探索文件所在的位置。 +* `required_scopes`:每個權杖都必須帶有全部這些 scope。 + +!!! tip + SDK 儲存庫裡的 `examples/servers/simple-auth/` 有一個 `IntrospectionTokenVerifier`,會呼叫真實授權伺服器的 [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662) 端點。大多數正式環境的驗證器都是這個樣子。 + +## 透過 HTTP 會得到什麼 {#what-you-get-over-http} + +授權存在於 HTTP 標頭裡,所以只存在於 HTTP 傳輸方式上。在你要部署的那一種上執行它:`mcp.run(transport="streamable-http")` 會把它放在 `http://127.0.0.1:8000/mcp`,其餘內容請見 **[執行伺服器](index.md)**。應用程式現在有兩個路由: + +```text +/mcp +/.well-known/oauth-protected-resource/mcp +``` + +你註冊了一個工具。第二個路由是 SDK 的。 + +### 探索 {#discovery} + +對那個 well-known 路徑發 `GET`,會得到 **[RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) Protected Resource Metadata**,直接從你的 `AuthSettings` 產生: + +```json +{ + "resource": "http://127.0.0.1:8000/mcp", + "authorization_servers": ["https://auth.example.com/"], + "scopes_supported": ["notes:read"], + "bearer_methods_supported": ["header"] +} +``` + +從沒聽過你伺服器的用戶端,就是靠這份文件找到門路的:它讀取 `authorization_servers`,再去那裡拿權杖。這些你一行都沒寫。 + +!!! check + 不帶權杖呼叫 `/mcp`(或帶一個驗證器回傳 `None` 的權杖),請求會被擋在門口: + + ```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"} + ``` + + 什麼都沒被解析,也沒有工具執行。而 `WWW-Authenticate` 裡那個 `resource_metadata` 指標,正是讓探索自動化的關鍵:401 -> 中繼資料文件 -> 授權伺服器 -> 權杖 -> 重試。 + +!!! warning + 這些都不會保護 `stdio`。管道沒有 `Authorization` 標頭,所以在那裡永遠不會詢問 `token_verifier`。`stdio` 伺服器的安全邊界是啟動它的那個處理程序。測試裡用的記憶體內 `Client(mcp)` 也一樣:它直接連到伺服器物件,跳過了 HTTP 層,授權也一併跳過。 + +## 呼叫端的身分 {#the-callers-identity} + +在任何處理函式內,**`get_access_token()`** 就是驗證器為目前請求回傳的那個 `AccessToken`: + +```python title="server.py" hl_lines="4 32-35" +--8<-- "docs_src/authorization/tutorial002.py" +``` + +* 在工具、資源和提示詞裡都能用,也不需要傳來傳去:驗證中介軟體會依請求把它存在一個上下文變數裡。 +* 拿回來的是**驗證器建立的同一個物件**:`client_id`、`scopes`、`subject`、`expires_at`,以及你附加的任何額外 `claims`。這就是逐工具規則的著力點:讀取 scope,然後拒絕。 +* 在已驗證的 HTTP 請求之外,它回傳 `None`。記憶體內和透過 `stdio` 時,它永遠是 `None`。 + +用 `Authorization: Bearer alice-token` 呼叫 `whoami`,模型會讀到: + +```text +alice (scopes: notes:read) +``` + +## SDK 不做的那一半 {#the-half-the-sdk-doesnt-do} + +SDK 給你的是資源伺服器這一半:驗證、公告、拒絕。它不提供登入頁面、同意畫面,也不提供權杖。 + +想看三方實際互動,可以執行 SDK 儲存庫裡的 `examples/servers/simple-auth/`(一個小型授權伺服器,加上一個設定方式和這一頁完全相同的資源伺服器),再把 `examples/clients/simple-auth-client/` 指向它,跑一遍完整的探索與取得權杖流程。 + +!!! info + 還有第二個建構子引數 `auth_server_provider=`,會把完整的授權伺服器嵌進你的 MCP 伺服器裡。它出現的時間早於 MCP 授權規範所依據的 AS/RS 分離設計。新的伺服器不應該使用它。 + +授權伺服器也可以接受企業身分提供者簽署的斷言,取代使用者點選同意畫面的步驟,而 SDK 支援這種交換的兩端。這種授權方式,以及提出它的用戶端,請見 **[身分斷言](../client/identity-assertion.md)**。 + +## 重點回顧 {#recap} + +* 透過 Streamable HTTP,你的伺服器是 OAuth 2.1 的**資源伺服器**:它驗證權杖,從不發出權杖。 +* `TokenVerifier` 就是整個整合介面:一個非同步方法,權杖進去,`AccessToken | None` 出來。 +* `token_verifier=` 和 `auth=AuthSettings(issuer_url=..., resource_server_url=..., required_scopes=[...])` 永遠成對出現。 +* SDK 會在 `/.well-known/oauth-protected-resource/...` 發布 [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) Protected Resource Metadata,並以 401 回應未驗證的請求,其 `WWW-Authenticate` 標頭會指向這份文件。整個探索機制就這樣。 +* 在任何處理函式裡,`get_access_token()` 就是誰在呼叫。 +* 授權是 HTTP 層的事。`stdio` 和記憶體內用戶端永遠看不到它。 + +用戶端那一半(探索你的授權伺服器並替你取得權杖)請見 **[OAuth 用戶端](../client/oauth-clients.md)**。至於不問使用者、而是直接**斷言**身分的用戶端,請見 **[身分斷言](../client/identity-assertion.md)**。 diff --git a/i18n/zh-hant/pages/run/deploy.md b/i18n/zh-hant/pages/run/deploy.md new file mode 100644 index 0000000000..01f1ed3ad9 --- /dev/null +++ b/i18n/zh-hant/pages/run/deploy.md @@ -0,0 +1,163 @@ +--- +translation: + sections: [28221886b198784f, f88ea1f1614f3a1d, ce926d686730b6d0, 3be24f8ad8bb5ab9, 3fad24032b2224ff, f25a7f860e579ecb, e758745df6fb7b0a] + tool: 1 +--- +# 部署與擴展 {#deploy-scale} + +伺服器可以運作了。現在它需要一個真正的主機名稱,後面還要有不只一個 worker。 + +這些事幾乎都不歸 MCP 管。ASGI 伺服器、處理程序管理器、負載平衡器都由你自備。這一頁只列出少數**確實**歸 MCP 管的事:一個擋在每次部署前面的設定,以及「不只一個 worker」會改變 SDK 行為的兩個地方。 + +## 先做這件事:Host 允許清單 {#before-anything-else-the-host-allowlist} + +`streamable_http_app()` 無從得知自己會掛在哪個主機名稱後面提供服務,所以它假設最安全的答案:localhost。沒有傳入 `transport_security=` 時,應用程式會啟用 **DNS 重新綁定防護**,只接受 `Host` 標頭為 `127.0.0.1:`、`localhost:` 或 `[::1]:` 的請求。若有 `Origin` 標頭,它必須是同一位址的 `http://` 形式。在你自己的機器上這完全正確:它能阻止惡意網頁透過重新綁定到 `127.0.0.1` 的 DNS 名稱來操控本機伺服器。 + +部署到真正的主機名稱後面時,同樣的預設值會拒絕**每一個請求**,直到你另行指定。這項檢查在任何 MCP 相關的東西執行之前就先跑完,所以你寫的東西根本不會被問到: + +```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=` 就是解法。把實際提供服務的名稱加進允許清單: + +```python title="server.py" hl_lines="2 13-17" +--8<-- "docs_src/deploy/tutorial001.py" +``` + +* `allowed_hosts` 的項目是精確比對的字串:`"mcp.example.com"` 比對不帶連接埠的 `Host` 標頭,`"mcp.example.com:*"` 比對任何連接埠。兩個都列上。 +* `allowed_origins` 只對瀏覽器有意義,因為其他東西都不會送 `Origin`。它是 **[加入現有應用程式](asgi.md)** 裡 CORS 設定在伺服器端的對應。 +* 在已經掌控 `Host` 標頭的反向代理後面,把檢查關掉才是誠實的設定:`TransportSecuritySettings(enable_dns_rebinding_protection=False)`。 +* 傳入非 localhost 的 `host=`(例如 `host="mcp.example.com"`)**不會**把那個主機名稱加入允許清單。它只是讓 localhost 預設值不再啟動防護,結果是每個 Host 和 Origin 都照單全收。想表達什麼,就用 `transport_security=` 明說。 + +!!! check + 刪掉 `transport_security=security` 引數,照樣部署應用程式。它會啟動,`/mcp` 路由正常,而每個請求(包括單純的 `curl`)都會得到: + + ```text + HTTP/1.1 421 Misdirected Request + + Invalid Host header + ``` + + 在用戶端找不到這幾個字。`421` 是純文字的 HTTP 回應,不是 JSON-RPC 錯誤,所以 MCP 用戶端只會引發一個籠統的傳輸錯誤;它不喜歡的主機名稱只會出現在**伺服器**的記錄裡,就一則警告。剛部署好卻拒絕所有連線的伺服器,在證明是別的原因之前,就是 Host 允許清單的問題。**[疑難排解](../troubleshooting.md)** 也從這裡開始。 + +## Worker,以及誰需要黏性 {#workers-and-who-has-to-be-sticky} + +主機名稱能回應之後,就在後面放不只一個 worker。SDK 沒有這方面的設定;擴展 Starlette 應用程式的方式跟擴展任何 ASGI 應用程式一樣,把物件交給懂得 fork 的東西: + +```console +uvicorn server:app --workers 4 +``` + +四個處理程序,一個 socket。接著是每次部署都得回答的問題:**請求是否必須送到看過上一個請求的那個 worker?** + +對使用 **2026-07-28** 協定的用戶端來說,不用。現代請求是一個自成一體的 POST:前面沒有 `initialize` 交握,回應上沒有 `Mcp-Session-Id`,第二個請求沒有什麼可以「回去找」的對象。送到任何一個 worker 都行。 + +這不是一個要你開啟的模式。`stateless_http=True` 看起來像是,但傳輸層依 `MCP-Protocol-Version` 請求標頭分流,把現代請求交給現代處理函式,然後就**回傳**了。讀取 `stateless_http` 的那一行在那個 return **之後**。並不是這個旗標在 2026-07-28 路徑上被忽略,而是根本執行不到。`stateless_http` 只是**舊版**那一支的開關,現代路徑在設計上就沒有工作階段(session)。 + +對規格版本 2025-11-25 或更早的舊版用戶端,答案取決於那個旗標: + +| 用戶端的協定版本 | 工作階段 | 負載平衡器必須做的事 | +| --- | --- | --- | +| **2026-07-28** | 無。永遠不會設定 `Mcp-Session-Id`。 | 不用做什麼。任何 worker 都能服務任何請求。 | +| **2025-11-25 及更早**(預設) | `Mcp-Session-Id`,保存在某一個 worker 的記憶體內。 | **黏性工作階段。**後續請求若送到不同的 worker,會得到 `404`「Session not found」。 | +| **2025-11-25 及更早**,搭配 `stateless_http=True` | 無。 | 不用做什麼。代價是伺服器到用戶端的反向通道(back-channel),也就是取樣(sampling)、推送式徵詢(elicitation)、`roots/list`,以及可續傳能力。 | + +黏性工作階段和舊版那一支的代價自有專頁:**[服務舊版用戶端](legacy-clients.md)**;兩個世代本身則見 **[協定版本](../protocol-versions.md)**。這裡重要的是答案的樣子:**在 2026-07-28 上你本來就是無狀態的,沒有任何東西要設定。** + +本頁剩下的內容,是無狀態**沒有**幫你解決的兩件事。 + +## 跨 worker 的 `requestState` {#requeststate-across-workers} + +**[多輪往返(multi-round-trip)](../handlers/multi-round-trip.md)** 工具需要某樣用戶端得去取得的東西(一個確認、一個選擇、一個憑證),所以它回傳的是問題而不是答案,並在重試時完成。兩輪之間,用戶端持有一個伺服器鑄造的不透明 `request_state` 權杖。重試時,伺服器得再把那個權杖打開。 + +「用哪一把金鑰封裝的?」預設是伺服器在建構時用 `os.urandom(32)` 產生的那一把。在 `--workers 4` 之下,那是四次建構、四個處理程序:四把不同的金鑰,從沒寫到任何地方、從不共用,重新啟動就消失。 + +下面是一個先問再做的工具,放在一台什麼都沒設定的伺服器上: + +```python title="server.py" hl_lines="14 20" +--8<-- "docs_src/deploy/tutorial002.py" +``` + +第一輪送到 worker A。Worker A 用**它自己的**金鑰封裝 `refund:120` 並回傳權杖。用戶端把問題呈現給某個人,得到同意,然後重試。這次重試是一個全新的 HTTP 請求。 + +!!! check + 讓那次重試送到 worker B。B 試著解封一個不是它鑄造的權杖,辦不到,於是拒絕整輪。`refund` 根本沒被呼叫;用戶端收到一個 JSON-RPC 錯誤: + + ```json + { + "code": -32602, + "message": "Invalid or expired requestState", + "data": {"reason": "invalid_request_state"} + } + ``` + + 那則訊息是**固定不變**的。過期、被竄改、拿不同的引數重播,或者(在真實部署裡遠遠最常見的原因)由兄弟 worker 封裝:用戶端每次被告知的都是同一句話,所以線路上永遠看不出是哪一項檢查失敗。真正的原因是伺服器記錄裡的一則 `WARNING`: + + ```text + requestState rejected on tools/call: unknown key + ``` + + 一個 worker 時正常、兩個 worker 時開始**偶爾**失敗的多輪往返工具,就是這個問題。兩輪仍然必須送到同一個處理程序,所以負載平衡器把它們拆開的頻率有多高,它失敗的頻率就有多高。 + +兩輪是兩個獨立的 HTTP 請求,好幾種再平常不過的情況都會把它們拆開:逐請求平衡的代理、中間斷掉的連線、一次部署或重新啟動、把 `request_state` 存下來並從完全不同的處理程序恢復的用戶端(**[自己驅動迴圈](../handlers/multi-round-trip.md#driving-the-loop-yourself)**)。任何一種都算「不同的 worker」。 + +解法是一個引數。它有**兩**半。 + +```python title="server.py" hl_lines="1 12 14" +--8<-- "docs_src/deploy/tutorial003.py" +``` + +* **`keys=[...]`** 是大家都找得到的那一半。給每個執行個體同一個祕密(至少 32 個位元組),每個執行個體就能解封任何兄弟鑄造的東西。`keys[0]` 負責封裝,清單裡每把金鑰都能解封,這就是輪替環;**[輪替金鑰](../handlers/multi-round-trip.md#rotating-keys)** 說明如何不停機地轉動它。 +* **伺服器的名稱**是幾乎沒人找得到的那一半,也是共用金鑰之後跨執行個體重試仍然失敗的原因。每個封裝的權杖都帶著伺服器的 `name` 作為 **audience 宣告**,回來時嚴格檢查。用同一份程式碼建出的兩個執行個體名稱相同,永遠不會察覺這件事。替它們取不同的名字(`MCPServer(f"billing-{POD}")` 看起來像是良好的可觀測性習慣),每次跨執行個體重試就會像上面那樣被拒絕,不管有沒有共用金鑰。記錄裡寫的是 `audience` 而不是 `unknown key`;用戶端分不出差別。 + +祕密只鑄造一次,把同一個值交給每個執行個體。如果傳入少於 32 個位元組,SDK 自己的錯誤訊息就會叫你執行這條指令: + +```console +python -c "import secrets; print(secrets.token_hex(32))" +``` + +!!! warning "相同的金鑰,**還有**相同的名稱" + 多執行個體部署必須兩者都共用。如果各執行個體的名稱對你來說不可或缺,就改給整個機群一個明確的 audience:`RequestStateSecurity(keys=[...], audience="billing")`。這樣每個執行個體不管叫什麼,都用 `"billing"` 鑄造和接受。 + +封裝的其他一切都在 **[保護 `requestState`](../handlers/multi-round-trip.md#protecting-requeststate)**:它綁定什麼、每輪的 `ttl`(預設 600 秒)、自備編解碼器、為什麼未設定的預設值在 `stdio` 上完全正確。本頁的全部貢獻就是一張兩項的檢查清單:**相同的金鑰,相同的名稱。** + +!!! info + 就算從沒打過 `InputRequiredResult`,你也在這條路徑上。參數用了 `Resolve(...)`(**[相依性](../handlers/dependencies.md)**)的工具就是多輪往返工具,SDK 會替它鑄造並封裝 `request_state`。同樣的預設金鑰,跨 worker 同樣的失敗,同樣的解法。 + +## 跨副本的變更通知 {#change-notifications-across-replicas} + +用戶端的 `subscriptions/listen` 串流是一個長時間存活的回應,所以它整個生命週期都釘在同一個副本上。在**另一個**副本上發布的 `ctx.notify_resource_updated(...)` 必須送得到它。 + +兩者之間的接縫是 `SubscriptionBus`。給伺服器什麼 bus,每次發布就進到那個 bus,每個開著的串流也都在上面聽,所以把同一個 bus 交給每個副本: + +```python title="server.py" hl_lines="2 7 9" +--8<-- "docs_src/deploy/tutorial004.py" +``` + +扇出完全不在乎串流掛在哪個伺服器物件上。兩個伺服器共用一個 `InMemorySubscriptionBus` 本來就是這樣運作:在其中一個上開啟 listen 串流,在另一個上 `edit_note`,串流就會聽到。那個記憶體內的 bus 只能跨越同一個處理程序裡的伺服器物件,所以它是模型,不是部署方案: + +* 跨真正的處理程序時,**SDK 沒有附任何幫得上忙的 bus。**`SubscriptionBus` 是一個只有兩個方法的 `Protocol`(`publish` 和 `subscribe`),由你在自己的 pub/sub 後端(Redis、NATS,或你已經在跑的任何東西)上實作,再以 `MCPServer(subscriptions=...)` 傳入。草稿與契約請見 **[訂閱](../handlers/subscriptions.md#scaling-past-one-process)**。 +* bus 載的是四種小型的有型別事件,從來不是 JSON-RPC。確認、過濾和串流生命週期都留在 SDK 裡,所以你的 bus 不可能破壞協定;它只能在處理程序之間搬運事件。 +* 串流**不能**續傳,事件也**不會**重播。失去一個副本就丟掉它的串流;用戶端會重新 listen、重新抓取。沒有要共用的事件儲存區,也沒有別的要設定。這是唯一一個向外擴展真的只是「多幾台一樣的」的地方。 + +## SDK 不提供的東西 {#what-the-sdk-does-not-give-you} + +`MCPServer` 是協定實作,不是應用程式伺服器。接下來你會去找的部署選項是刻意不放的: + +* **沒有 `workers=`。**`mcp.run("streamable-http")` 啟動剛好一個 uvicorn 處理程序,而且永遠只會啟動這一個。多處理程序就是把 `streamable_http_app()` 交給你本來就拿來部署 ASGI 的東西:`uvicorn --workers`、gunicorn、平台的處理程序管理器。本頁刻意不當其中任何一個的教學;它們的說明文件比在這裡抄一份要好。 +* **沒有健康檢查路由。**`@mcp.custom_route("/health", methods=["GET"])` 就是全部答案,而且即使伺服器其他部分需要驗證,它也永遠不需要。這對存活探測是對的,對任何私密的東西是錯的。**[加入現有應用程式](asgi.md#custom-routes)** 有一個範例。 +* **沒有正式環境設定物件。**`MCPServer` 上沒有地方寫下逾時、TLS、優雅關閉或連線上限,因為這些都不是它的工作。它們屬於你的 ASGI 伺服器,在那裡設定。**[執行伺服器](index.md)** 涵蓋建構子**確實**接受的那幾個設定。 +* **沒有附 `EventStore`,而且在 2026-07-28 上也用不著。**可續傳是舊版有狀態那一支的功能;現代的交換就是一個 POST、一個回應,沒有什麼要續傳。 + +## 重點回顧 {#recap} + +* 預設情況下,這個應用程式只回應送往 localhost 的請求。`transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])` 是上線的關卡:在傳入它之前,真正主機名稱後面的每個請求都是 `421`,原因只在伺服器記錄裡。 +* 在 2026-07-28 上沒有工作階段,負載平衡器也沒有東西可黏。`stateless_http=True` 是只給舊版用的開關,因為現代請求在那個旗標被讀到之前就已經分流並回應了。 +* 預設的 `requestState` 金鑰是 `os.urandom(32)`,每個處理程序各自鑄造。送到不同 worker 的多輪往返重試會以 `-32602`「Invalid or expired requestState」失敗。 +* 解法是 `RequestStateSecurity(keys=[...])` **加上**每個執行個體相同的伺服器名稱。名稱是權杖預設的 audience 宣告。相同的金鑰,相同的名稱。 +* 變更通知透過一個共用的 `SubscriptionBus` 跨越副本。SDK 唯一的實作是處理程序內的;在你自己的 pub/sub 上寫那個兩方法的 `Protocol` 是你的事。 +* 沒有 `workers=`、沒有健康檢查路由、沒有正式環境設定物件。自備 ASGI 伺服器。 + +真正的主機名稱前面需要的另一樣東西是權杖:**[授權](authorization.md)**。 diff --git a/i18n/zh-hant/pages/run/index.md b/i18n/zh-hant/pages/run/index.md new file mode 100644 index 0000000000..3b1574dccf --- /dev/null +++ b/i18n/zh-hant/pages/run/index.md @@ -0,0 +1,149 @@ +--- +translation: + sections: [fea8d769ff9edeba, ce8e2ad42f29ef71, 0d705efb19cf99c2, 7a53ead3e704a7f0, 9adc400e8c88e854, 318893ad8e2e9924, 6b63ab96b34476c0] + tool: 1 +--- +# 執行伺服器 {#running-your-server} + +`mcp.run()` 會啟動伺服器。 + +唯一要做的決定是**傳輸方式**:伺服器和用戶端之間的位元組實際上怎麼移動。 + +## 選一種傳輸方式 {#pick-a-transport} + +| 傳輸方式 | 是什麼 | 何時用 | +|---|---|---| +| `stdio` | MCP 主機(host)把你的檔案當成子處理程序啟動,透過它的 stdin 和 stdout 溝通。 | 本機伺服器。預設值。 | +| `streamable-http` | 真正的 HTTP 伺服器,監聽一個連接埠。 | 任何要部署的東西。 | +| `sse` | 較舊的 HTTP 傳輸方式。 | 不要用。 | + +!!! warning + SSE 在 2025-03-26 協定修訂版中已被 Streamable HTTP 取代。`mcp.run(transport="sse")` 仍然可用,也有自己的 `sse_path=` 和 `message_path=` 選項,但它是為了還沒搬過去的用戶端而留著的。不要在它上面建任何新東西。 + +## `mcp.run()` {#mcprun} + +```python title="server.py" hl_lines="12-13" +--8<-- "docs_src/run/tutorial001.py" +``` + +* `run()` 是同步的。伺服器活著多久,它就阻塞多久。 +* 不帶引數時,傳輸方式是 `stdio`。 +* 它放在 `if __name__ == "__main__":` 底下,因為所有會載入伺服器的東西(`mcp dev`、`mcp run`、`mcp install`、你的測試)都是 **import** 這個檔案。這道防護讓 import 不會變成一個正在執行的伺服器。 + +### stdio {#stdio} + +沒有什麼要設定的。主機把你的檔案當成子處理程序啟動,把請求寫進它的 stdin,再從它的 stdout 讀回應。 + +自己執行看看就知道後果: + +```console +python server.py +``` + +什麼都不會印出,也不會結束。它在 stdin 上等主機先開口。 + +這也表示 stdout **就是線路本身**。服務期間,SDK 會把線路移到一個私有的檔案描述元,並把 **flush** 到 stdout 的輸出(子處理程序寫入它繼承來的 stdout、flush 過的 `print()`)改導到 stderr,在那裡不會弄壞串流。在開始服務**之前**就 flush 到 stdout 的輸出(包裝指令稿的 echo、匯入時未緩衝的 print)仍然會落到線路上;一直緩衝到直譯器結束時才清空的 `print()` 也一樣。真正想要的輸出,用 `logging` 模組才是正確的工具:它的 handler 會在每筆記錄發生時就 flush 到 stderr。完整說明請見 **[記錄](../handlers/logging.md)**。 + +### 試試看 {#try-it} + +```console +uv run mcp dev server.py +``` + +Inspector 做的事和真正的主機一模一樣:把 `server.py` 當成子處理程序啟動,透過 stdio 連上它。 + +你從來沒給它連接埠。根本沒有。 + +## Streamable HTTP {#streamable-http} + +要改把同一個伺服器放到連接埠上,就在 `run()` 裡指名傳輸方式(和它的選項): + +```python title="server.py" hl_lines="13" +--8<-- "docs_src/run/tutorial002.py" +``` + +這一行會建立一個 Starlette 應用程式,並用 uvicorn 提供服務。用戶端連到 `http://127.0.0.1:3001/mcp`。 + +每種傳輸方式都有自己的關鍵字引數,全都在 `run()` 上: + +* `host` / `port`:在哪裡監聽。預設為 `127.0.0.1` 和 `8000`。 +* `streamable_http_path`:MCP 端點的位置。預設為 `/mcp`。 +* `json_response=True`:每個 POST 都用單一 JSON 本體回應,而不是 SSE 串流。那個本體只裝得下回應本身,別的都沒有,所以在請求中途回頭呼叫用戶端的工具(`ctx.elicit()`、取樣(sampling))在這一段會引發 `NoBackChannelError`,而綁在進行中呼叫上的通知(`ctx.report_progress()` 的進度、每次呼叫的記錄訊息)會被丟棄;獨立的 `GET` 串流仍會承載不相關的那些。 +* `stateless_http=True`:每個請求一個全新的傳輸,不追蹤工作階段(session)。 +* `max_request_body_size`:可接受的最大 POST 本體,以位元組計。預設為 4 MiB;更大的請求在解析或建立工作階段之前就會收到 HTTP 413。只有在合法的 MCP 訊息超過這個大小時才調高它。 +* `event_store`、`retry_interval`、`transport_security`:可續傳性與 DNS 重新綁定防護。這些可以先放著,等到部署到 localhost 以外的地方再說;`transport_security` 在 **[部署與擴展](deploy.md)** 有說明。 + +!!! warning + 傳輸選項是給 `run()` 的,**不是**給 `MCPServer(...)`。建構子描述伺服器**是什麼**:名稱、版本、說明文字(instructions)。`run()` 描述它怎麼被提供服務。弄反了,Python 在 MCP 根本還沒介入之前就會回你: + + ```text + TypeError: MCPServer.__init__() got an unexpected keyword argument 'port' + ``` + +`run()` 是捷徑。一旦需要更多(伺服器掛載在現有的應用程式裡、一個處理程序裡兩個伺服器、給瀏覽器用戶端的 CORS),就自己建立 ASGI 應用程式,再交給任何一個 ASGI 伺服器執行。那是 **[加入現有應用程式](asgi.md)**。 + +## 伺服器設定 {#server-settings} + +關於執行,有幾件事和傳輸無關。它們是建構子引數: + +```python title="server.py" hl_lines="3" +--8<-- "docs_src/run/tutorial003.py" +``` + +* `log_level`:在建構 `MCPServer(...)` 的當下就交給 `logging.basicConfig()`。那會設定 **root** logger,所以也會設定你自己 logger 的層級,不只是 SDK 的。預設為 `"INFO"`。 +* `debug`:轉交給 HTTP 傳輸建立的 Starlette 應用程式。預設為 `False`。 + +兩者都會落在 `mcp.settings` 上,執行時可以讀回來。 + +## `mcp` 命令 {#the-mcp-command} + +`[cli]` extra 會安裝一個把這些包起來的小命令列工具。 + +`mcp dev` 在 **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` 把套件加進它建立的環境;`--with-editable` 把你自己的套件安裝進去。它需要 `PATH` 上有 `npx`:Inspector 是 Node.js 應用程式。 + +`mcp run` 會匯入檔案、找出伺服器物件(模組層級的 `mcp`、`server` 或 `app`),然後對它呼叫 `run()`: + +```console +uv run mcp run server.py +uv run mcp run server.py:bookshop +``` + +物件不叫 `mcp`、`server` 或 `app` 時,用 `:` 後綴指名它。 + +你的 `if __name__ == "__main__":` 區塊在這裡永遠不會執行:`mcp run` 自己呼叫 `run()`,而它唯一轉交的選項是 `--transport`。 + +`mcp install` 把伺服器註冊到 **Claude Desktop**,讓那個應用程式替你啟動它: + +```console +uv run mcp install server.py --name "Bookshop" +uv run mcp install server.py -v API_KEY=abc123 -f .env +``` + +`-v KEY=VALUE` 和 `-f .env` 會把環境變數記錄在那筆項目裡。Claude Desktop 在它自己的處理程序裡啟動伺服器。你的 shell 環境不在那裡。 + +`mcp install` 只認得 Claude Desktop 這一個主機。其他每個主機(Claude Code、Cursor、VS Code)都在自己的設定檔裡接受同樣的啟動命令,**[連接真正的主機](../get-started/real-host.md)** 每一個都有。 + +`mcp version` 印出已安裝的 SDK 版本。 + +!!! tip + `mcp dev` 和 `mcp run` 只懂 `MCPServer`。如果用低階的 `Server` 來建,就要自己執行它。請見 **[低階 Server](../advanced/low-level-server.md)**。 + +## 重點回顧 {#recap} + +* **傳輸方式**是位元組抵達伺服器的方式:本機子處理程序用 `stdio`,連接埠用 `streamable-http`。SSE 已被取代。 +* `mcp.run()` 選擇傳輸方式。不帶引數就是 `stdio`,而且會阻塞。 +* 每個傳輸選項(`host`、`port`、`streamable_http_path`……)都是 `run()` 的引數,絕不是 `MCPServer(...)` 的。 +* 把 `run()` 放在 `if __name__ == "__main__":` 底下。所有載入伺服器的東西都會先 import 這個檔案。 +* `log_level=` 和 `debug=` 是建構子引數;它們落在 `mcp.settings` 上。 +* `mcp dev` 開 Inspector,`mcp run` 執行檔案,`mcp install` 給 Claude Desktop,`mcp version` 看版本。 +* 傳輸方式永遠不會改變伺服器**是什麼**:這一頁的三個檔案公開的是一模一樣的工具。 + +當 `run()` 本身成了限制(伺服器在一個已經存在的應用程式裡),就看 **[加入現有應用程式](asgi.md)**。真正的主機名稱和不只一個 worker,是 **[部署與擴展](deploy.md)**。如果有些用戶端還停在規格版本 2025-11-25 或更早,**[服務舊版用戶端](legacy-clients.md)** 有好消息。 diff --git a/i18n/zh-hant/pages/run/legacy-clients.md b/i18n/zh-hant/pages/run/legacy-clients.md new file mode 100644 index 0000000000..edfbce63ec --- /dev/null +++ b/i18n/zh-hant/pages/run/legacy-clients.md @@ -0,0 +1,116 @@ +--- +translation: + sections: [3d1663c18edc824c, d4fd37009a13f03d, af9f398a5a8b679a, 470c2dd144294d69, 8e45827e6d24e8c8, 91dfd0ce98ebb03c] + tool: 1 +--- +# 服務舊版用戶端 {#serving-legacy-clients} + +MCP 有兩個協定世代:`initialize` 交握世代,到規格版本 `2025-11-25` 為止;以及現代世代 `2026-07-28`。專門講這個分界的頁面是 **[協定版本](../protocol-versions.md)**。 + +這一頁談的是這個分界的伺服器端,而答案一句話就講完:**你已經部署的 `streamable_http_app()` 兩個世代都能服務。** + +SDK 依每個請求的 `MCP-Protocol-Version` 標頭來路由。標明 `2026-07-28` 的請求交給現代處理路徑。標明交握世代版本的請求,或是根本沒帶標頭的請求(2026 之前的用戶端送來的 `initialize` 就是這樣到達的),則交給那些用戶端預期的傳輸:`initialize` 交握、工作階段(session),一樣不少。這一切逐請求發生,在你的程式碼之前,在同一個應用程式上。 + +所以,舊版用戶端不是你要特地**為它**打造什麼東西;它只是會**連上**你已經寫好的伺服器。什麼都不用設定。 + +!!! note + 真的是什麼都沒有。沒有 `legacy=` 選項,沒有版本允許清單,沒有任何方法可以拒絕或停用某個世代:`streamable_http_app()` 上沒有、`run()` 上沒有、工作階段管理器上也沒有。兩個世代永遠都開著。那個簽章裡最接近「依世代切換」的東西是 `stateless_http`,而這一頁大半都在講它。 + +## 一個處理函式,兩個世代 {#one-handler-both-eras} + +下面是一個必須問使用者問題的工具,以及兩個世代的用戶端呼叫它: + +```python title="server.py" hl_lines="24 37-38" +--8<-- "docs_src/legacy_clients/tutorial001.py" +``` + +`reserve` 需要一樣模型沒有提供的東西:要幾本。`Annotated[..., Resolve(ask_quantity)]` 就是工具宣告這件事的方式(完整說明請見 **[相依性](../handlers/dependencies.md)**)。`reserve` 裡沒有任何地方指名版本、檢查能力或做分支。 + +兩個用戶端**同時**開著,連到同一個 `mcp` 物件。`mode="legacy"` 會執行 `initialize` 交握:正是 2026 之前的用戶端會開啟的那種連線。另一個用預設值,落在 `2026-07-28`。 + +```text +2025-11-25 {'result': "Reserved 2 of 'Dune'."} +2026-07-28 {'result': "Reserved 2 of 'Dune'."} +``` + +同一個伺服器、同一個處理函式、同一個答案。整個功能就這樣。 + +值得停下來看看**怎麼做到的**,因為這兩個用戶端是透過兩條完全不同的線路被問了同一個問題。`2026-07-28` 連線沒有讓伺服器送出請求的通道,所以 `Resolve` 把問題放在工具結果裡回傳,用戶端再帶著答案重試這次呼叫(**[多輪往返請求(multi-round-trip)](../handlers/multi-round-trip.md)**)。`2025-11-25` 連線沒有這種機制;在那裡,`Resolve` 在呼叫途中送出即時的 `elicitation/create` 請求並等待。兩者你都沒寫。`Resolve` 讀取連線協商出的版本然後挑選;不管哪一種,工具本體看到的都是 `AcceptedElicitation`。 + +!!! tip + 這種跨世代可攜性正是 `Resolve` **為什麼**是該拿來當基礎的 API。它的前輩 `ctx.elicit()`(**[徵詢(elicitation)](../handlers/elicitation.md)**)永遠只會送 `elicitation/create`,所以永遠只在舊版連線上有效。在 `2026-07-28` 連線上,這個呼叫會失敗。如果某個工具還在用它,修正方法就是上面看到的那樣,而不是加版本檢查。 + +## 舊版工作階段的代價 {#what-a-legacy-session-costs-you} + +路由是免費的。工作階段不是。 + +`2026-07-28` 連線是**無工作階段**的:每個請求各自獨立,現代處理路徑從不發出 `Mcp-Session-Id`。舊版連線正好相反。2026 之前的用戶端一送出 `initialize`,SDK 就鑄造一個 `Mcp-Session-Id`,放在回應標頭裡回傳,並在背後保留一筆活的紀錄,讓用戶端之後的請求找得到:協商出的版本、開著的串流、一個驅動工作階段的背景任務。 + +那筆紀錄是一個**普通的、處理程序內的 `dict`**。沒有分散式工作階段儲存區,也沒有辦法外掛一個。 + +只有一個 worker 時,這完全看不出來。有兩個時,這就是全部的問題所在:帶著 `Mcp-Session-Id` 的請求如果落到不是鑄造它的那個 worker 上,在那個 dict 裡什麼都找不到,得到的答案是 `404`(`Session not found`),而不是工具結果。所以只要執行超過一個 worker,**舊版用戶端就需要黏性路由**:一個工作階段裡的每個請求都必須抵達開啟它的那個處理程序。現代用戶端永遠不需要;它們沒有工作階段可黏。黏性和其他關於執行多個實例的一切,請見 **[部署與擴展](deploy.md)**。 + +!!! warning + `event_store=` 看起來像解法,但不是。它是**可恢復性**(把漏掉的 SSE 事件重播給重新連回**同一個**工作階段的用戶端),不是工作階段儲存區。它永遠不會讓工作階段能從另一個處理程序存取到。 + +## 唯一的開關:`stateless_http` {#the-one-knob-stateless_http} + +如果黏性是你不願付的代價,能改的東西剛好只有一樣。 + +```python title="server.py" hl_lines="28" +--8<-- "docs_src/legacy_clients/tutorial002.py" +``` + +這是頁面最上方的那個伺服器再加一個關鍵字。`stateless_http=True` 讓舊版路徑改為建立用過即丟、每個請求一個的工作階段:不發 `Mcp-Session-Id`,請求之間什麼都不記,所以任何 worker 都能服務任何請求,負載平衡器想怎麼分就怎麼分。 + +關於它,有兩件事比它做什麼更重要。 + +**它只影響舊版路徑。** 請求在讀取 `stateless_http` **之前**就已依版本標頭路由好了,所以現代路徑根本看不到它。`2026-07-28` 連線本來就無工作階段,在兩種值下完全一樣。 + +**它的代價是那條路徑上的兩個伺服器到用戶端通道。** 只活一次 `POST` 的工作階段,沒有串流讓伺服器推送請求,也沒有獨立串流讓它推送通知。每個伺服器發起的請求都會引發 `NoBackChannelError`:`ctx.elicit()`、已退役的取樣(sampling)與根目錄(roots)呼叫(**[已棄用的功能](../deprecated.md)**),還有,沒錯,`Resolve` 向**舊版**用戶端提問時也一樣。通知甚至連錯誤都沒有,就默默被丟掉。 + +!!! note + `json_response=True` 不是那個開關,但它在**每個**舊版工作階段上都會付出一半同樣的代價:用單一 JSON 本體回應的 `POST` 沒有串流可供請求範圍的通道使用,所以請求途中的 `ctx.elicit()` 會引發同樣的 `NoBackChannelError`,綁在該請求上的通知則被丟掉。工作階段的獨立串流不受影響:不相關的通知照樣送達。 + +!!! check + 故意做錯一次。`reserve` 正是剛剛服務了兩個用戶端的那個工具。用 `stateless_http=True` 部署它,透過 HTTP 連上同樣的兩個用戶端,從各自呼叫它。 + + 現代用戶端還是得到 `Reserved 2 of 'Dune'.`,現代路徑沒變。 + + 舊版用戶端的呼叫不會以模型讀得到的 `is_error` 結果回來。整個請求失敗,成為頂層的協定錯誤: + + ```text + mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests. + ``` + + `Resolve` 沒救到你。在 `2025-11-25` 連線上它**必須**送出 `elicitation/create`,而它需要的通道正是 `stateless_http=True` 放棄掉的東西。跨世代可攜的程式碼,不等於不需要反向通道(back-channel)的程式碼。 + +所以這是真實的取捨,而且只存在於舊版路徑上:**有工作階段且黏性,或無狀態且單向。** 如果你的工具從不回頭呼叫用戶端,`stateless_http=True` 就是免費的,應該採用。如果會,就保留工作階段,並讓路由保持黏性。 + +## 你的程式碼真正分岔的地方 {#where-your-code-actually-forks} + +幾乎沒有。 + +工具、資源、提示詞、結構化輸出、進度、錯誤:沒有一個在乎是哪個世代呼叫的。`initialize` 交握、`Mcp-Session-Id`、獨立串流、結束工作階段的 `DELETE`:全部由 SDK 掌管,處理函式一個都看不到。互動式輸入是兩個世代在線路上**真正**不同的那個地方,而 `Resolve` 的存在就是為了讓它不成為你的問題:你剛剛才看到一個工具同時服務兩者。 + +剩下的剛好只有一件事,就是**變更通知**,因為兩個世代聽的是不同的管道: + +* `2026-07-28` 用戶端開啟一條 `subscriptions/listen` 串流並讀取訂閱匯流排。`ctx.notify_resource_updated()`(以及 `notify_tools_changed()`、`notify_prompts_changed()`、`notify_resources_changed()`)發佈到那裡,而且**只**發到那裡。那一頁是 **[訂閱](../handlers/subscriptions.md)**。 +* 舊版用戶端讀的是它的工作階段保持開啟的獨立串流。`ctx.session.send_resource_updated()`(以及 `send_tool_list_changed()` 等)寫到承載該請求的**連線**:對舊版工作階段來說,就是它的獨立串流。現代連線沒有地方放它:透過 HTTP 時沒有這種通道,透過 stdio 時這四種變更通知只搭 `subscriptions/listen` 串流,所以在現代連線上這個通知會被默默丟掉。 + +透過 HTTP,兩個呼叫都到不了另一個世代的用戶端。要通知所有人,兩個都呼叫: + +```python title="server.py" hl_lines="19-20" +--8<-- "docs_src/legacy_clients/tutorial003.py" +``` + +兩行,沒有 `if`,沒有版本檢查,就完成了。這就是處理函式因為舊版用戶端存在而要做得不一樣的事的完整清單。 + +## 重點回顧 {#recap} + +* 一個 `streamable_http_app()` 服務兩個協定世代。SDK 依每個請求的 `MCP-Protocol-Version` 標頭路由;沒有東西要設定,也沒有世代開關可找。 +* 舊版用戶端的代價是一個工作階段:一筆處理程序內的 `Mcp-Session-Id` 紀錄,背後沒有分散式儲存區。超過一個 worker 就表示要**黏性路由**,否則錯的 worker 會回 `404 Session not found`。多 worker 的完整說明請見 **[部署與擴展](deploy.md)**。 +* `stateless_http=True` 是唯一的開關,而且**只作用於舊版路徑**。它用那條路徑上的兩個伺服器到用戶端通道,換來舊版用戶端的自由負載平衡:伺服器發起的請求會引發 `NoBackChannelError`(在用戶端是頂層錯誤,不是 `is_error` 結果),通知則被丟掉。 +* `2026-07-28` 連線不管怎樣都無工作階段。`stateless_http` 永遠碰不到它。 +* 處理函式的程式碼只在一個地方依世代分岔:變更通知。`ctx.notify_*` 到得了 `subscriptions/listen` 用戶端;`ctx.session.send_*` 到得了舊版工作階段。兩個都呼叫。 +* 其他一切(包括透過 `Resolve` 向使用者要輸入)在設計上就是跨世代可攜的。現代的寫法寫一次就好。 diff --git a/i18n/zh-hant/pages/run/opentelemetry.md b/i18n/zh-hant/pages/run/opentelemetry.md new file mode 100644 index 0000000000..84ee1140d3 --- /dev/null +++ b/i18n/zh-hant/pages/run/opentelemetry.md @@ -0,0 +1,85 @@ +--- +translation: + sections: [bc0227014724fa49, 15738c2f7fd67d86, a2c17bbe3f707e2f, d0d853376f162c06, b6368643fcc1c8d8, 902e33e17564a607] + tool: 1 +--- +# OpenTelemetry {#opentelemetry} + +伺服器已經有追蹤了,什麼都不用加。 + +你建立的每個伺服器,都會為它處理的每則訊息發出一個 [OpenTelemetry](https://opentelemetry.io/) span。這不是你寫的,也不需要匯入。呼叫 `MCPServer(...)` 的那一刻它就在了。 + +```python title="server.py" +--8<-- "docs_src/opentelemetry/tutorial001.py" +``` + +這就是一個完整、帶追蹤的伺服器。呼叫 `search_books`,就會替它建立一個 span。低階的 `Server` 也一樣:兩者都內建追蹤。 + +## 你會得到什麼 {#what-you-get} + +每則傳入訊息都會變成一個 `SERVER` span,名稱取自方法和它的目標。所以對 `search_books` 的 `tools/call` 就是 `tools/call search_books` 這個 span,而單純的 `tools/list` 就只是 `tools/list`。 + +每個 span 帶有幾個屬性: + +* `mcp.method.name` 和 `mcp.protocol.version`,每個 span 都有。 +* `jsonrpc.request.id`,請求才有(通知沒有)。 +* 處理函式引發例外時,會把 span 狀態設為 error。`is_error=True` 的工具結果也一樣。 + +而因為追蹤工具呼叫是很常見的需求,`tools/call` span 採用 OpenTelemetry 的 [GenAI 語意慣例](https://opentelemetry.io/docs/specs/semconv/gen-ai/): + +* `gen_ai.operation.name`,設為 `"execute_tool"`。 +* `gen_ai.tool.name`,設為被呼叫的工具。 + +`prompts/get` span 同理會有 `gen_ai.prompt.name`。list 類方法不帶 `gen_ai.*` 鍵,因為沒有東西可命名。 + +!!! tip + 追蹤 UI 之所以會把你的工具呼叫和其他 agent 的工具呼叫用同樣方式分組,靠的就是這些 GenAI 屬性。這個分組是免費得到的,不用寫任何額外程式碼。 + +## 想用之前,完全沒有成本 {#it-costs-nothing-until-you-want-it} + +下面這一點,是「預設開啟」能讓人放心當預設的原因。 + +SDK 只依賴 `opentelemetry-api`,也就是 OpenTelemetry 輕量的那一半。沒有安裝 SDK 也沒有安裝 exporter 時,建立 span 是 no-op。所以伺服器現在發出的那些 span 幾乎不花你任何成本,也沒有人在收集。 + +哪天想**看到**它們,就安裝另一半,再把它指向某個地方: + +```console +uv add opentelemetry-sdk opentelemetry-exporter-otlp +``` + +照一般 OpenTelemetry 的方式設定 exporter,SDK 一直默默建立的每個 span 就全都亮起來了。伺服器程式碼不用改,一行都不用。 + +!!! info + [Pydantic Logfire](https://logfire.pydantic.dev/) 就是這類後端之一,而且會幫你把設定做好:`pip install logfire`、`logfire.configure()`,你的 MCP span 就會出現在即時檢視中。它建構在 OpenTelemetry 之上,所以下面的內容也都適用。 + +## 跨越線路的追蹤 {#traces-that-cross-the-wire} + +追蹤最有用的時候,是它能跟著一個請求從用戶端一路進到伺服器,呈現成一張連貫的圖。 + +當用戶端和伺服器都執行這個 SDK 時,這種串接是自動的。用戶端把 [W3C 追蹤上下文(trace context)](https://www.w3.org/TR/trace-context/) 注入請求,伺服器再把它讀出來,於是伺服器 span 會巢狀在同一條追蹤裡的用戶端 span 底下。這就是 [SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414),不用開口就有。 + +如果傳入訊息不帶追蹤上下文,例如來自非本 SDK 用戶端的請求,伺服器 span 就直接掛在伺服器端目前的 span 底下,而不是另起一條全新的孤立追蹤。 + +## 關掉它 {#turning-it-off} + +追蹤是一個中介軟體,排在伺服器清單的第一個。如果真的想要一個完全不發出 span 的伺服器,把它拿掉: + +```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 + 那個 import 開頭有底線,這是刻意的。這個類別是暫定的,就像 [`Server.middleware`](../advanced/middleware.md) 是暫定的一樣,所以應該預期匯入路徑會改變。你幾乎不會需要這樣做:沒安裝 exporter 時 span 是免費的,所以通常的做法是讓它開著、不安裝 exporter 就好。 + +## 重點回顧 {#recap} + +* 每個 `MCPServer` 和每個低階 `Server` 預設都會為每則傳入訊息發出一個 `SERVER` span。你什麼都不用寫。 +* span 帶有 `mcp.method.name` 和 `mcp.protocol.version`;`tools/call` 和 `prompts/get` 另外帶有 GenAI 屬性,讓你的工具呼叫和其他 agent 的一樣分組。 +* 在安裝 OpenTelemetry SDK 和 exporter 之前完全沒有成本,裝了之後就會亮起來,伺服器不用任何改動。 +* 兩端都執行這個 SDK 時,用戶端到伺服器的追蹤上下文會自動傳播。 + +至於決定一個請求到底能不能執行的,是 **[授權](authorization.md)**。 diff --git a/i18n/zh-hant/pages/servers/completions.md b/i18n/zh-hant/pages/servers/completions.md new file mode 100644 index 0000000000..cbda8f9425 --- /dev/null +++ b/i18n/zh-hant/pages/servers/completions.md @@ -0,0 +1,122 @@ +--- +translation: + sections: [72f9c964769076dd, 9a2c14e10935b515, 235299eb78ab12d7, 8aee1e78c8237fb8, 9bd86acd4112138f, 55343cb7f250dc7b] + tool: 1 +--- +# 自動完成 {#completions} + +用戶端如果在你的伺服器之上做一個 UI,會希望在使用者輸入時自動補上引數的值:語言名稱、儲存庫名稱、檔案路徑。 + +**自動完成**就是伺服器提供這些建議的方式。 + +## 值得自動完成的東西 {#something-worth-completing} + +自動完成只適用於兩樣東西:**提示詞**的引數,以及**資源範本**的參數。所以先準備一個兩者各有一個的伺服器: + +```python title="server.py" hl_lines="6 12" +--8<-- "docs_src/completions/tutorial001.py" +``` + +這裡還沒有任何跟自動完成有關的東西。 + +* `review_code` 接受一個 `language`。使用者不該得去猜你接受哪些拼法。 +* `github_repo` 接受 `owner` 和 `repo`。兩個都放自由輸入的文字框,表單會很難用。 + +## 自動完成處理函式 {#the-completion-handler} + +加上**一個**以 `@mcp.completion()` 裝飾的函式: + +```python title="server.py" hl_lines="21-29" +--8<-- "docs_src/completions/tutorial002.py" +``` + +* 每個伺服器只有一個處理函式。所有自動完成請求都會送到這裡,再依正在完成的對象分支處理。 +* 必須是 `async def`:SDK 會 await 它。 +* 它會收到三個引數: + * `ref`:**哪一個**提示詞或資源範本,型別是 `PromptReference` 或 `ResourceTemplateReference`。用 `isinstance` 分辨兩者。 + * `argument`:`argument.name` 是正在完成的引數,`argument.value` 是使用者目前輸入的內容。 + * `context`:已經解析完成的引數。現在先不用管它。 +* 回傳 `Completion(values=[...])`,沒有東西可建議時回傳 `None`。 + +!!! tip + `argument.value` 是使用者已輸入的前綴。SDK **不會**替你過濾:放進 `values` 的是什麼,UI 就顯示什麼。`startswith` 要自己寫。 + +### 試試看 {#try-it} + +用 **[測試](../get-started/testing.md)** 裡的記憶體內 `Client` 來操作。以 `ref=PromptReference(name="review_code")` 和 `argument={"name": "language", "value": "py"}` 呼叫 `client.complete()`: + +```python +result.completion.values # ['python'] +``` + +* `ref` 跟處理函式收到的參照型別相同。 +* `argument` 是個普通的 dict,剛好兩個鍵:`name` 和 `value`。 + +送出空的 `value`,就會拿回整份清單。`lang.startswith("")` 對每種語言都成立: + +```python +result.completion.values # ['go', 'javascript', 'python', 'rust', 'typescript'] +``` + +詢問 `code`(處理函式不認得的引數),它會回傳 `None`,SDK 會把它轉成空清單: + +```python +result.completion.values # [] +``` + +`None` 的意思是「沒有建議」,永遠不是錯誤。UI 會退回一般的文字框。 + +## 一個你從沒宣告過的能力 {#a-capability-you-never-declared} + +註冊處理函式本身就是宣告。連上用戶端看看: + +```python +client.server_capabilities.completions # CompletionsCapability() +``` + +你沒有在任何地方列出 `completions`。SDK 看到處理函式,就替你宣告了這項能力。每一項**可選**能力都是這樣運作的:處理函式就是宣告。(三個基本元件不是可選的:不管有沒有處理函式,`MCPServer` 一律會宣告它們。) + +!!! check + 回到第一個 `server.py`(沒有處理函式的那個),照樣問它一次。呼叫會失敗,得到 JSON-RPC 錯誤: + + ```text + Method not found + ``` + + 而且 `client.server_capabilities.completions` 是 `None`。這正是能力的用意:行為良好的用戶端會先檢查它,絕不會送出你無法回答的請求。 + +## 相依的引數 {#dependent-arguments} + +`github://repos/{owner}/{repo}` 有兩個參數,而 `repo` 的合理值取決於先選了哪個 `owner`。 + +這就是 `context` 的用途。它帶著使用者**已經解析完成**的引數: + +```python title="server.py" hl_lines="8-11 34-38" +--8<-- "docs_src/completions/tutorial003.py" +``` + +* 新的分支在範本的 `repo` 參數上觸發。 +* `context.arguments` 是 `dict[str, str] | None`,存放目前已選的值(這裡是 `owner`)。 +* 還沒有 `owner` 就沒有合理的建議,所以處理函式回傳 `None`。 + +用戶端用 `context_arguments=` 送出那些已解析的值。這次 `ref` 是 `ResourceTemplateReference(uri="github://repos/{owner}/{repo}")`。以空的 `value` 詢問 `repo`,並傳入 `context_arguments={"owner": "modelcontextprotocol"}`: + +```python +result.completion.values # ['python-sdk', 'typescript-sdk', 'inspector'] +``` + +拿掉 `context_arguments=`,同樣的呼叫會回傳 `[]`。處理函式在知道 owner 之前,沒辦法知道該建議哪些儲存庫。 + +!!! info + `Completion` 也接受 `total=` 和 `has_more=`。當 `values` 只是更長清單的一部分時設定它們,UI 就能顯示「還有 200 個」。大多數處理函式用不到。 + +## 重點回顧 {#recap} + +* 自動完成是給**提示詞引數**和**資源範本參數**的建議,僅此而已。 +* `@mcp.completion()` 註冊那唯一的處理函式。它是 `async def (ref, argument, context) -> Completion | None`。 +* 依 `isinstance(ref, ...)` 和 `argument.name` 分支。自己用 `argument.value` 過濾。 +* `None` 會變成空清單,永遠不是錯誤。 +* `context.arguments` 存放已解析的值;用戶端以 `context_arguments=` 提供它們。 +* 一註冊處理函式,`completions` 能力就會出現。沒有它,請求會得到 `Method not found`。 + +建議是在使用者還在**填寫**提示詞或範本時幫忙;如果要在工具呼叫**進行到一半**時問使用者問題,要用的是 **[徵詢(elicitation)](../handlers/elicitation.md)**。工具除了文字之外還能回傳的所有東西,請見 **[圖片、音訊與圖示](media.md)**。 diff --git a/i18n/zh-hant/pages/servers/handling-errors.md b/i18n/zh-hant/pages/servers/handling-errors.md new file mode 100644 index 0000000000..031cfa7557 --- /dev/null +++ b/i18n/zh-hant/pages/servers/handling-errors.md @@ -0,0 +1,131 @@ +--- +translation: + sections: [e33d441f12d50535, 7099694c603e0f5f, c1df4cf9673433e6, c9cd294541422e6e, 6cec073617bfd037, efa92b8f99e908c8, 6a22a29e27fb4601] + tool: 1 +--- +# 處理錯誤 {#handling-errors} + +工具失敗的方式有兩種,而 SDK 對待它們的方式截然不同。 + +引發一般的例外,看到的是**模型**。引發 `MCPError`,看到的是**協定**。 + +這一頁談的是怎麼選。 + +## 模型能修正的錯誤 {#an-error-the-model-can-fix} + +拿一個查東西的工具來說,讓查詢落空: + +```python title="server.py" hl_lines="11-12" +--8<-- "docs_src/handling_errors/tutorial001.py" +``` + +這兩行跟 MCP 一點關係也沒有。`get_author` 引發的是普通的 `ValueError`,任何 Python 函式都會這麼做。 + +用一個不在目錄裡的書名呼叫它,看看結果: + +```python +result.is_error # True +result.content # [TextContent(text="Error executing tool get_author: No book titled 'Nothing' in the catalog.")] +result.structured_content # None +``` + +* 請求**成功**了。有結果;呼叫端沒有引發任何東西。 +* `is_error` 是 `True`,而例外的訊息(前面加上工具名稱)就在 `content` 裡,正是模型讀取的地方。 +* `structured_content` 是 `None`。失敗的呼叫沒有回傳值可以結構化。 + +這是**工具錯誤**,也是工具引發**任何**例外時的預設行為。而且幾乎總是你想要的。 + +呼叫工具的是模型,引數也是它選的。所以工具錯誤就是對話中的一個回合:模型讀到「No book titled 'Nothing' in the catalog.」,發現自己猜錯了書名,就換個更好的再呼叫一次。只寫了一個 `raise`,就得到一個會自我修正的 agent。 + +!!! tip + 永遠不要從工具 `return` 錯誤訊息。回傳的字串 `is_error=False`,所以在模型(以及每個用戶端 UI)看來,工具是成功的,那個字串就是答案。要用 `raise`。那個旗標才是訊號。 + +## 模型無法修正的錯誤 {#an-error-the-model-cannot-fix} + +現在把 `ValueError` 換成 `MCPError`。 + +```python title="server.py" hl_lines="1 3 14" +--8<-- "docs_src/handling_errors/tutorial002.py" +``` + +`MCPError` 是 SDK 的**協定錯誤**。它是工具包裝層唯一**不會**攔截的例外:它會往外傳播,整個 `tools/call` 請求以 JSON-RPC 錯誤失敗,而不是回傳結果。 + +```json +{ + "code": -32602, + "message": "No book titled 'Nothing' in the catalog." +} +``` + +* **沒有結果**。沒有 `content`,沒有 `is_error`:模型沒有東西可讀。 +* 錯誤改由**主機(host)**應用程式收到,跟工具根本不存在時一模一樣。 +* `code`、`message` 和 `data` 原封不動地送達。`INVALID_PARAMS` 是 `-32602`;`mcp.types` 把它和其他 JSON-RPC 錯誤碼(`INVALID_REQUEST`、`INTERNAL_ERROR`……)都匯出成常數,所以永遠不用手打魔術數字。 + +!!! check + 同樣的查詢、同樣落空,但現在呼叫在用戶端**引發**例外,而不是回傳: + + ```text + mcp.shared.exceptions.MCPError: No book titled 'Nothing' in the catalog. + ``` + + 第一個版本交給模型一句它能回應的話。這個版本什麼都沒給。對 `get_author` 來說這絕對更糟,而這正是下一節的重點。 + +## 該引發哪一個 {#which-one-to-raise} + +兩條路徑回答的是兩個不同的問題。 + +* **引發任何例外**,用於**執行**上的失敗:工具想做的事沒做成。呼叫是模型選的,所以模型應該看到後果,並有機會補救。拼錯的書名、逾時的上游 API、不存在的資料列:都是工具錯誤。 +* **引發 `MCPError`**,用於**請求本身**就該被拒絕的情況:用戶端缺少工具所依賴的能力、伺服器處於無法服務任何人的狀態、呼叫端跳過了必要的步驟。模型再怎麼重試也修不好這些,所以把訊息交給它毫無益處。 + +一個問題就能決定:**更聰明的模型能避開這個錯誤嗎?**能 -> 一般的例外。不能 -> `MCPError`。 + +照這個標準,第二版的 `get_author` 選錯了:換個更好的書名就能解決,所以模型理應看到訊息。放在那裡是為了示範機制,不是建議這麼做。 + +!!! info + `MCPError` 位於 `from mcp import MCPError`,接受 `code`、`message` 和選用的 `data` 承載。放進去什麼,用戶端就收到什麼:SDK 會把引發的 `MCPError` 原封不動地轉送,不會加以清理。 + +## 不存在的資源 {#a-resource-that-doesnt-exist} + +資源也畫了同一條線,並為常見情況提供了一個具名的例外。 + +```python title="server.py" hl_lines="2 13" +--8<-- "docs_src/handling_errors/tutorial003.py" +``` + +`books://{title}` 是一個**範本**。它能比對**任何**書名,所以「URI 格式正確」和「這本書存在」是兩個不同的問題,而只有你的函式能回答第二個。 + +回答不了的時候,引發 `ResourceNotFoundError`。SDK 會把它轉成規格指派給缺漏資源的協定錯誤:`-32602`,並把請求的 URI 放在 `data` 裡,讓用戶端知道是**哪一次**讀取失敗。 + +```json +{ + "code": -32602, + "message": "No book titled 'Nothing' in the catalog.", + "data": {"uri": "books://Nothing"} +} +``` + +注意這裡沒有 `is_error=True` 那種半成品結果。資源讀取要嘛回傳內容,要嘛失敗:資源只有協定這條路。範本以及資源的其他一切都在 **[資源](resources.md)**。 + +## 永遠不用引發的錯誤 {#errors-you-never-raise} + +錯誤的引數永遠到不了你的函式。 + +傳給 `get_author` 一個不是字串的 `title`,SDK 會在呼叫你**之前**就依輸入 schema 拒絕它,同樣是模型能讀懂並修正的那種 `is_error=True` 工具錯誤。**[工具](tools.md)** 用 `Field(le=50)` 限制示範了同樣的拒絕。 + +這表示有一整類 `raise` 陳述式不用寫:不要重新驗證自己的型別提示。 + +!!! info + 這一頁的一切都是**用戶端**看到的東西,而寫測試用的記憶體內 `Client` 看到的完全一樣。就算是 `raise_exceptions=True` 也不會把工具錯誤變回 traceback:等到那個旗標能起作用時,你的例外早已是 `is_error=True` 的結果。對結果做斷言。**[測試](../get-started/testing.md)** 說明了這個模式。 + +## 重點回顧 {#recap} + +* 在工具裡引發**任何例外** -> 呼叫回傳 `is_error=True`,訊息在 `content` 裡。模型讀到後可以重試。這是預設行為。 +* 引發 **`MCPError`** -> 呼叫本身以 JSON-RPC 錯誤失敗。模型什麼都看不到;由主機處理。`code`、`message` 和 `data` 完整保留。 +* 決定性的問題:「更聰明的模型能避開這個錯誤嗎?」能 -> 例外。不能 -> `MCPError`。 +* 資源處理函式引發的 `ResourceNotFoundError` -> 協定的 `-32602`,URI 在 `data` 裡。 +* 錯誤的引數會在函式執行前依 schema 被拒絕;這些不用 `raise`。 +* `from mcp import MCPError`;錯誤碼常數來自 `mcp.types`。 + +錯誤處理完畢。這就是伺服器**公開**的全部內容。每個處理函式在執行時能讀到什麼、又能反過來對用戶端做什麼,是下一節的主題:**[在處理函式內部](../handlers/index.md)**。 + +最常碰到的 SDK 錯誤的確切文字、各自的意思,以及每一個的一步修正法,都在 **[疑難排解](../troubleshooting.md)**。 diff --git a/i18n/zh-hant/pages/servers/index.md b/i18n/zh-hant/pages/servers/index.md new file mode 100644 index 0000000000..afdd150db0 --- /dev/null +++ b/i18n/zh-hant/pages/servers/index.md @@ -0,0 +1,22 @@ +--- +translation: + sections: [09defc170a0da89d] + tool: 1 +--- +# 伺服器 {#servers} + +`MCPServer` 向已連線的用戶端公開三種基本元件,差別在於由**誰**決定使用它們: + +* **[工具](tools.md)** 是由**模型**挑選並呼叫的動作。這是大多數人最先想看的頁面,而 **[結構化輸出](structured-output.md)** 是它的參考配套頁面:關於工具回傳內容的形狀,全部都在那裡。 +* **[資源](resources.md)** 是由**應用程式**選擇讀取的唯讀資料。**[URI 範本](uri-templates.md)** 是它的參考配套頁面:完整的定址語法與路徑安全規則。 +* **[提示詞](prompts.md)** 是由**人**依名稱叫用的訊息範本,可以從選單或斜線指令觸發。 + +在這三種基本元件之外,伺服器還會宣告這些: + +* **[自動完成](completions.md)** 是針對提示詞與資源範本引數的伺服器端自動完成功能。 +* **[圖片、音訊與圖示](media.md)** 涵蓋工具除了文字之外能回傳的一切,以及用戶端顯示在伺服器旁邊的圖示。 +* **[處理錯誤](handling-errors.md)** 說明模型能從中復原的錯誤,與模型絕對不能看到的錯誤,兩者之間的差別。 + +這裡的每一頁都各自獨立,直接跳到需要的那一頁即可。如果還沒建過伺服器,請先從 **[第一步](../get-started/first-steps.md)** 開始。 + +至於註冊的函式**內部**會發生什麼事(`Context`、相依性注入、在呼叫途中向使用者要求更多輸入),是下一節 **[在處理函式內部](../handlers/index.md)** 的內容。 diff --git a/i18n/zh-hant/pages/servers/media.md b/i18n/zh-hant/pages/servers/media.md new file mode 100644 index 0000000000..0117075f8d --- /dev/null +++ b/i18n/zh-hant/pages/servers/media.md @@ -0,0 +1,117 @@ +--- +translation: + sections: [496394d24d221bf1, 4ceb4591180dc6c3, 0fd63e4682d02e0c, 969ede0bd3686a16, 043f526230dd243d, 6ee3e9bcfd24047a] + tool: 1 +--- +# 媒體 {#media} + +工具能回傳的不只是文字。 + +SDK 內建兩個處理二進位結果的輔助工具(**`Image`** 和 **`Audio`**),以及一個 **`Icon`** 型別,讓伺服器、工具、資源和提示詞在用戶端的 UI 裡有張臉。 + +## 回傳圖片 {#returning-an-image} + +把回傳型別註記為 `Image`,指向一個檔案,然後回傳它: + +```python title="server.py" hl_lines="8 12 14" +--8<-- "docs_src/media/tutorial001.py" +``` + +* `Image` 只接受 `path`(要讀取的檔案)或 `data`(原始位元組)其中之一。 +* 用戶端看到的 MIME 型別是從副檔名猜出來的:`logo.png` 會宣告為 `image/png`。 +* 這裡跟 logo 沒有特別關係。`server.py` 旁邊的任何 PNG 都行:程式碼繪製的圖表、示意圖、照片都可以。 + +`Image` 是 SDK 提供的便利工具,不是協定型別。實際傳輸時,回傳值會變成一個 **`ImageContent`** 區塊(檔案的位元組經 base64 編碼,加上 MIME 型別): + +```python +result.content # [ImageContent(type="image", data="iVBORw0KGgoAAAANSUhEUg...", mime_type="image/png")] +result.structured_content # None +``` + +有兩點值得注意: + +* `data` 是 base64。你完全沒碰過位元組;SDK 讀了檔案並完成編碼。 +* `structured_content` 是 `None`。`Image` 是給模型看的內容,不是給應用程式解析的資料:沒有輸出 schema。(對照 **[結構化輸出](structured-output.md)**,那裡的回傳註記**就是** schema。) + +!!! info + `ImageContent` 和 `AudioContent` 位於 `mcp.types`,就在普通 `str` 結果會變成的 `TextContent` 旁邊(**[工具](tools.md)**)。工具結果是一串內容區塊的清單;`Image` 和 `Audio` 是產生這兩種二進位區塊最簡短的方式。 + +### 試試看 {#try-it} + +把任何一張 PNG 放在 `server.py` 旁邊,命名為 `logo.png`,然後執行: + +```console +uv run mcp dev server.py +``` + +打開 **Tools** 分頁並呼叫 `logo`。結果不是字串:它是一個 `image` 內容區塊,Inspector 會把圖片顯示出來。從磁碟上的檔案到螢幕上的像素,中間的一切都是 SDK 做的。 + +## 回傳音訊 {#returning-audio} + +`Audio` 的形式一模一樣。`logo.png` 留在原位,再放一個 WAV 在旁邊,命名為 `chime.wav`: + +```python title="server.py" hl_lines="18-21" +--8<-- "docs_src/media/tutorial002.py" +``` + +結果是一個 **`AudioContent`** 區塊: + +```python +result.content # [AudioContent(type="audio", data="UklGR...", mime_type="audio/wav")] +result.structured_content # None +``` + +同樣的道理:磁碟上的檔案進去,base64 和 MIME 型別出來,沒有輸出 schema。 + +## 位元組或檔案 {#bytes-or-a-file} + +兩個輔助工具也都接受 `data=`(原始位元組)來取代 `path=`。這是給那些本來就不是來自檔案的位元組用的模式,例如資料庫欄位、HTTP 回應,或 Pillow 剛畫好的東西: + +```python title="server.py" hl_lines="14 15" +--8<-- "docs_src/media/tutorial003.py" +``` + +用 `path=` 時什麼都不用宣告:建立結果時才讀取檔案,MIME 型別從副檔名猜出來: + +* `Image`:`.png`、`.jpg`、`.jpeg`、`.gif`、`.webp`。 +* `Audio`:`.wav`、`.mp3`、`.ogg`、`.flac`、`.aac`、`.m4a`。 + +認不出來的副檔名會退回 `application/octet-stream`。 + +!!! check + 用 `data=` 時沒有檔名,也就沒有東西可以猜。忘了 `format=`,SDK 就會退回預設值:圖片是 `image/png`,音訊是 `audio/wav`。這樣用 MP3 位元組建立 `Audio`,用戶端會被告知 `mime_type="audio/wav"`,然後老老實實地解碼失敗。傳 `data=` 的時候,就一起傳 `format=`。 + +## 圖示 {#icons} + +`Icon` 是中繼資料,不是內容。它不帶圖片本身,而是用一個 URI 指向圖片;用戶端可以去抓取並顯示在伺服器名稱、工具、資源或提示詞旁邊。 + +```python title="server.py" hl_lines="4-5 7 10 16" +--8<-- "docs_src/media/tutorial004.py" +``` + +* `src` 是用戶端能解析的 URI:`https:`,或是想把圖示直接內嵌、省掉額外抓取的話,用 `data:` URI。 +* `mime_type` 和 `sizes`(`"48x48"`,可縮放格式則用 `"any"`)讓用戶端在你提供多個圖示時挑出合適的那一個。 +* `theme="light"` 或 `theme="dark"` 標記圖示適用於哪一種配色。 + +同樣的 `icons=[...]` 關鍵字在 `MCPServer(...)`、`@mcp.tool()`、`@mcp.resource()` 和 `@mcp.prompt()` 都能用。 + +### 用戶端在哪裡看到它們 {#where-a-client-sees-them} + +圖示會跟著它所裝飾的東西一起傳送。伺服器的圖示在用戶端連線時送達,放在 `client.server_info` 上(在 2026 世代的連線上是選用的,所以先做型別收窄): + +```python +assert client.server_info is not None # python-sdk servers identify themselves by default +client.server_info.icons # [Icon(src="https://example.com/brand-kit.png", mime_type="image/png", sizes=["48x48"])] +``` + +工具的圖示在 `tools/list` 回傳的 `Tool` 物件上,資源的在 `resources/list` 的 `Resource` 上,提示詞的在 `prompts/list` 的 `Prompt` 上。欄位一律叫做 `icons`。 + +## 重點回顧 {#recap} + +* 從工具回傳 `Image` 或 `Audio`,用戶端就會收到一個 `ImageContent`/`AudioContent` 區塊:位元組經 base64 編碼,附上 MIME 型別。 +* 可以用 `path=` 建立並讓副檔名決定 MIME 型別,或用記憶體內的 `data=` 加上明確的 `format=`。 +* 媒體結果沒有 `structured_content`,也沒有輸出 schema。 +* `Icon` 是個指標:一個 `src` URI,加上選用的 `mime_type`、`sizes` 和 `theme`。 +* `icons=[...]` 在伺服器、工具、資源和提示詞上都能用,用戶端會在對應的物件上找到它們。 + +以上就是工具能放**進**結果裡的全部東西。工具**失敗**時會發生什麼事(以及誰該知道),請見 **[處理錯誤](handling-errors.md)**。 diff --git a/i18n/zh-hant/pages/servers/prompts.md b/i18n/zh-hant/pages/servers/prompts.md new file mode 100644 index 0000000000..d59fe14ab0 --- /dev/null +++ b/i18n/zh-hant/pages/servers/prompts.md @@ -0,0 +1,151 @@ +--- +translation: + sections: [d65c098f37f5b6c3, dd0c2724d6f2877e, 6835bb3570c6714c, ffe823cb0fedd488, f33651add1b59094] + tool: 1 +--- +# 提示詞 {#prompts} + +**提示詞**是使用者挑選的訊息範本。 + +工具是給模型用的。提示詞正好相反:使用者從用戶端的選單(斜線指令、按鈕)裡選一個,填好引數,算繪出來的訊息就會進入對話,就像是使用者自己打的一樣。 + +宣告的方式是在回傳文字的函式上加 `@mcp.prompt()`。 + +## 第一個提示詞 {#your-first-prompt} + +```python title="server.py" hl_lines="6-9" +--8<-- "docs_src/prompts/tutorial001.py" +``` + +SDK 讀取的三樣東西和工具一樣: + +* **名稱**是函式名稱:`review_code`。 +* 用戶端顯示的**描述**是 docstring:`Review a piece of code.` +* **引數**來自參數。`code` 沒有預設值,所以是必填。 + +這就是用戶端從 `prompts/list` 拿回來的內容: + +```json +{ + "name": "review_code", + "description": "Review a piece of code.", + "arguments": [ + {"name": "code", "required": true} + ] +} +``` + +這裡沒有 JSON Schema。提示詞的引數是一串扁平的**具名字串值**:是給人填的表單,不是給模型組出來的 payload。 + +### 算繪 {#rendering-it} + +用戶端用 `prompts/get` 算繪範本,並傳入引數。函式會執行,回傳的 `str` 變成**一則使用者訊息**: + +```json +{ + "description": "Review a piece of code.", + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "Please review this code:\n\ndef add(a, b): return a + b" + } + } + ], + "resultType": "complete" +} +``` + +提示詞的一生就這樣:依名稱列出、需要時算繪、丟進聊天裡。 + +!!! check + `required` 會在函式執行前就強制檢查。算繪 `review_code` 時不給 `code`,請求本身就會以 JSON-RPC 錯誤(錯誤碼 `-32603`)失敗: + + ```text + mcp.shared.exceptions.MCPError: Internal server error + ``` + + 這裡沒有工具那種可以交回給模型的錯誤結果,因為根本沒有模型參與:呼叫會直接引發例外。原因(`Missing required arguments: {'code'}`)會記在伺服器記錄裡。 + +### 試試看 {#try-it} + +用 MCP Inspector 執行伺服器: + +```console +uv run mcp dev server.py +``` + +打開 **Prompts** 分頁並選擇 `review_code`。Inspector 會畫出一個表單,裡面有一個必填的 `code` 欄位。填好、算繪,拿回來的就是上面那則使用者訊息。 + +## 不只一則訊息 {#more-than-one-message} + +程式碼審查是一則訊息。偵錯則是一段對話,而提示詞可以替整段對話起頭。 + +改成回傳訊息清單,而不是 `str`: + +```python title="server.py" hl_lines="2 13-20" +--8<-- "docs_src/prompts/tutorial002.py" +``` + +* `UserMessage` 和 `AssistantMessage` 來自 `mcp.server.mcpserver.prompts.base`。交給它們一個 `str`,它們會幫你包成 `TextContent`。角色就是類別名稱。 +* `Message` 是它們共同的基底類別,用它當作回傳型別註記。 + +現在算繪 `debug_error` 會依序產生三則訊息: + +```json +{ + "description": "Start a debugging conversation.", + "messages": [ + {"role": "user", "content": {"type": "text", "text": "I'm seeing this error:"}}, + {"role": "user", "content": {"type": "text", "text": "TypeError: 'int' object is not iterable"}}, + { + "role": "assistant", + "content": {"type": "text", "text": "I'll help debug that. What have you tried so far?"} + } + ], + "resultType": "complete" +} +``` + +注意最後一則。預先填好一輪 `assistant` 的回合,就是引導模型**下一個**回覆的方法,不必讓使用者自己打出引導的話。 + +## 標題與引數描述 {#titles-and-argument-descriptions} + +`review_code` 是函式名稱,不是標籤。給用戶端更適合放在按鈕上的文字,並描述每個引數,讓表單自己說明清楚: + +```python title="server.py" hl_lines="10-13" +--8<-- "docs_src/prompts/tutorial003.py" +``` + +* `title="Code review"` 是給人看的名稱,和工具的 `title` 完全一樣。 +* `Annotated[str, Field(description=...)]` 和 **[工具](tools.md)** 用來描述工具參數的寫法相同。在這裡描述會落在引數上,而不是 schema 裡。 +* `language` 有預設值,所以不再是必填。 + +`prompts/list` 的項目現在帶齊了用戶端畫出好表單所需的一切: + +```json +{ + "name": "review_code", + "title": "Code review", + "description": "Review a piece of code.", + "arguments": [ + {"name": "code", "description": "The code to review.", "required": true}, + {"name": "language", "description": "The language the code is written in.", "required": false} + ] +} +``` + +!!! info + 如果讀過 **[工具](tools.md)**,這一頁的內容你都已經會了。同樣的裝飾器、同樣以 docstring 當描述、同樣的 `Annotated`/`Field`。唯一不同的是由誰觸發(使用者),以及結果去哪裡(進入對話)。 + +## 重點回顧 {#recap} + +* 在函式上加 `@mcp.prompt()`,它就成為提示詞。名稱取自函式,描述取自 docstring。 +* 提示詞**由使用者控制**:用戶端列出來,使用者挑一個並填入引數。 +* 引數是一串扁平的具名字串(沒有 schema)。有預設值的參數就是選填。 +* 回傳 `str` 會變成一則使用者訊息。回傳 `UserMessage`/`AssistantMessage` 的清單,可以替多輪對話起頭。 +* `title=` 和 `Field(description=...)` 是用戶端放在 UI 上的內容。 +* 缺少必填引數會讓整個請求失敗,沒有個別提示詞的錯誤結果。 + +伺服器端替提示詞(或資源範本)引數做自動完成,請見 **[自動完成](completions.md)**。 diff --git a/i18n/zh-hant/pages/servers/resources.md b/i18n/zh-hant/pages/servers/resources.md new file mode 100644 index 0000000000..8f5afec1bc --- /dev/null +++ b/i18n/zh-hant/pages/servers/resources.md @@ -0,0 +1,138 @@ +--- +translation: + sections: [09df998c2a799f78, 0cf131146d16d4f9, 4e6b91e3f8025346, 8fe4eef576db17ed, 0d0d1ed43e3d0a53] + tool: 1 +--- +# 資源 {#resources} + +**資源**是你公開給應用程式讀取的資料。 + +分界就在這裡。工具是**模型**決定要呼叫的東西;資源是**應用程式**決定要載入的東西(一個設定檔、一筆紀錄、一份文件),再放到模型面前當作上下文。 + +在一個普通的 Python 函式上加上 `@mcp.resource(uri)`,就宣告了一個資源。 + +## 第一個資源 {#your-first-resource} + +```python title="server.py" hl_lines="6-8" +--8<-- "docs_src/resources/tutorial001.py" +``` + +形狀和工具一樣,只多了一樣東西:**URI**。資源靠位址定位,而不是靠名稱。用戶端要的是 `config://app`,從來不是 `get_config`。 + +其餘的部分,SDK 照樣從函式讀出來: + +* **名稱**就是函式名稱:`get_config`。 +* 用戶端看到的**描述**是 docstring。 +* **內容**就是你回傳的東西。 + +在 `resources/list` 期間,用戶端會收到: + +```json +{ + "name": "get_config", + "uri": "config://app", + "description": "The active shop configuration.", + "mimeType": "text/plain" +} +``` + +當它讀取 `config://app` 時,函式會執行,回傳值以文字形式送回: + +```python +result.contents # [TextResourceContents(uri="config://app", mime_type="text/plain", text="theme=dark\nlanguage=en")] +``` + +!!! tip + 列出資源的成本很低。函式在 `resources/list` 期間**不會**執行,只有在 `resources/read` 時才會,而且只針對用戶端要求的那個 URI。就算公開了一千個資源,也只需要為有人打開的那幾個付出代價。 + +### 試試看 {#try-it} + +用 MCP Inspector 執行伺服器: + +```console +uv run mcp dev server.py +``` + +打開它印出的 URL,切到 **Resources** 分頁。`config://app` 會連同描述一起出現在清單裡。點一下,Inspector 就會讀取它:那兩行設定就在眼前。 + +## 資源範本 {#resource-templates} + +一筆紀錄一個 URI 沒辦法擴展。在 URI 裡放一個**佔位符**,函式上加一個對應的參數: + +```python title="server.py" hl_lines="12-13" +--8<-- "docs_src/resources/tutorial002.py" +``` + +URI 裡有 `{user_id}`,函式上有 `user_id: str`。整個約定就這樣。 + +這樣就成了**資源範本**,而且會搬家:它離開 `resources/list`,改出現在 `resources/templates/list`,以樣式而不是位址的形式呈現: + +```json +{ + "name": "get_user_profile", + "uriTemplate": "users://{user_id}/profile", + "description": "A customer's profile.", + "mimeType": "text/plain" +} +``` + +用戶端填入佔位符,讀取一個具體的 URI:`users://42/profile`、`users://ada/profile`。同一個函式回應所有這些 URI,比對到的值會以 `user_id` 傳入: + +```python +result.contents # [TextResourceContents(uri="users://42/profile", text="User 42: 12 orders since 2021.")] +``` + +注意結果裡的 `uri`。那是用戶端要求的**具體** URI,不是範本。 + +!!! check + 佔位符和參數必須一致。如果把函式參數改名為 `user`,URI 卻還寫著 `{user_id}`,裝飾器會在**匯入時**就拒絕,任何用戶端都還來不及靠近: + + ```text + ValueError: Mismatch between URI parameters {'user_id'} and function parameters {'user'} + ``` + + 不一致只可能是 bug,所以 SDK 讓帶著這種錯誤的伺服器根本啟動不了。 + +佔位符語法遵循 [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570):`{+path}` 用於多段的值,`{?q,lang}` 用於選用的查詢參數,還有更多。SDK 預設也會對擷取出來的值做路徑安全檢查。完整參考請見 **[URI 範本與路徑安全](uri-templates.md)**。 + +`get_user_profile` 也可以接受一個註記為 `Context` 的參數。SDK 會注入它,而且絕不會把它當成 URI 參數;它能提供什麼,**[Context](../handlers/context.md)** 頁面有說明。 + +## 回傳什麼 {#what-you-return} + +不限於 `str`。替每個資源指定 `mime_type`,回傳合適的東西即可: + +```python title="server.py" hl_lines="8-9 14-15 20-21" +--8<-- "docs_src/resources/tutorial003.py" +``` + +* `readme` 回傳 `str`,所以原樣送出。這是最常見的情況。 +* `catalog_stats` 回傳 `dict`,所以 SDK 會替你序列化成 **JSON 文字**: + + ```json + { + "books": 1204, + "authors": 391 + } + ``` + +* `placeholder_cover` 回傳 `bytes`,所以用戶端收到的是 `BlobResourceContents` 而不是 `TextResourceContents`,位元組以 base64 編碼後放在 `blob` 欄位裡。 + +同樣的規則適用於其他任何可序列化為 JSON 的東西:list、Pydantic 模型、dataclass。只要不是 `str` 也不是 `bytes`,就會變成 JSON。 + +`mime_type` 由你宣告,預設為 `text/plain`。SDK 從不會檢查回傳的內容來猜測它,所以沒標示的 `dict` 資源仍然會以純文字對外宣告。 + +!!! tip + 不想從函式推導時,`@mcp.resource()` 也接受 `name=`、`title=` 和 `description=`。如果根本沒有函式要寫,`mcp.server.mcpserver.resources` 裡有現成的 `Resource` 類別(`TextResource`、`BinaryResource`、`FileResource`、`HttpResource`、`DirectoryResource`),用 `mcp.add_resource(...)` 註冊即可。 + +用戶端也可以**訂閱**資源,在它變更時收到通知;那是用戶端那一半的事,寫在 **[用戶端](../client/index.md)** 裡。 + +## 重點回顧 {#recap} + +* 在函式上加 `@mcp.resource(uri)`,它就成了資源。URI 是位址,回傳值是內容,docstring 是描述。 +* URI 裡有 `{placeholder}` 就成了**範本**:它列在 `resources/templates/list` 底下,同一個函式服務所有符合的 URI。 +* 佔位符名稱必須等於函式的參數名稱。弄錯的話,匯入時就會知道,不用等到正式環境。 +* 函式在資源被**讀取**時執行,而不是被列出時。 +* `str` 變成文字,`bytes` 變成 base64 blob,其他的都變成 JSON 文字。用 `mime_type=` 來標示。 +* 工具讓模型採取行動,資源讓應用程式讀取。 + +第三種基本元件,由人從選單裡挑選的那種,是 **[提示詞](prompts.md)**。 diff --git a/i18n/zh-hant/pages/servers/structured-output.md b/i18n/zh-hant/pages/servers/structured-output.md new file mode 100644 index 0000000000..f46e303001 --- /dev/null +++ b/i18n/zh-hant/pages/servers/structured-output.md @@ -0,0 +1,242 @@ +--- +translation: + sections: [a838d57f003aed44, 857d03886a0137ed, 42d9efcb9f542867, 2290ff08435b5573, e866c192e11d1c14, 6cdbad079f7b47f0, d4b607372fb28b51, 18dbf726ac45e0b7, c6f7d2a148aa49f4, c851964bb3301907, d715db6f8dccc9cc, ef86634aa70498a7] + tool: 1 +--- +# 結構化輸出 {#structured-output} + +回傳普通 `str` 的工具會把結果產生兩份:一份是 `content` 裡的文字,一份是 `structured_content` 裡的 `{"result": "..."}`。 + +這一頁談的就是第二個通道:它從哪裡來、可以有哪些形狀,以及 SDK 如何確保它名副其實。 + +簡單說:**回傳型別註記就是輸出 schema**。你早就寫好了。 + +## 輸出 schema {#the-output-schema} + +```python title="server.py" hl_lines="9" +--8<-- "docs_src/structured_output/tutorial001.py" +``` + +重要的是簽章那一行:`-> int`。 + +因為有它,SDK 在 `tools/list` 送出的工具,除了從參數建出的輸入 schema(這部分在 **[工具](tools.md)** 說明),旁邊還帶了一個 `output_schema`: + +```json +{ + "properties": { + "result": {"title": "Result", "type": "integer"} + }, + "required": ["result"], + "title": "get_temperatureOutput", + "type": "object" +} +``` + +單獨一個 `int` 不是 JSON 物件,所以 SDK 會把它**包**進 `{"result": ...}`。呼叫這個工具,兩個通道都會填上: + +```python +result.content # [TextContent(text="17")] +result.structured_content # {"result": 17} +``` + +每種純量都會套上同樣的包裝:`str`、`int`、`float`、`bool`、`bytes`、`None`。 + +## 兩個通道 {#two-channels} + +為什麼同一個值要送兩次? + +* `content` 是給**模型**的。語言模型讀的是文字,結果裡它只看得到這個部分。 +* `structured_content` 是給模型所在的**應用程式**的:那些程式碼要的是 `17`,不是一句包含「17」的話。 +* `output_schema` 是兩者之間的合約,在工具被呼叫之前就已經公布。 + +你回傳一個 Python 值,SDK 把三者都填好。 + +## 回傳一個模型 {#return-a-model} + +把形狀宣告成 Pydantic `BaseModel`,再回傳一個實例: + +```python title="server.py" hl_lines="8-11 15" +--8<-- "docs_src/structured_output/tutorial002.py" +``` + +現在 `WeatherData` **就是** schema。沒有包裝,也沒有 `result` 鍵: + +```json +{ + "properties": { + "temperature": {"description": "Degrees Celsius.", "title": "Temperature", "type": "number"}, + "humidity": {"description": "Relative humidity, 0 to 1.", "title": "Humidity", "type": "number"}, + "conditions": {"title": "Conditions", "type": "string"} + }, + "required": ["temperature", "humidity", "conditions"], + "title": "WeatherData", + "type": "object" +} +``` + +`structured_content` 就是這個物件,一個欄位都不差: + +```python +result.structured_content # {"temperature": 16.2, "humidity": 0.83, "conditions": "Overcast"} +``` + +模型也沒被冷落。SDK 會把同一個物件序列化成 JSON 文字放進 `content`: + +```json +{ + "temperature": 16.2, + "humidity": 0.83, + "conditions": "Overcast" +} +``` + +注意 `temperature` 和 `humidity` 上的 `Field(description=...)` 進到了 schema 裡。用來描述**輸入**的那個 `Field`,同樣可以描述輸出。 + +!!! info + 如果用過 FastAPI 的 `response_model`,這一套你早就認識了:把 Pydantic 模型宣告為回應,序列化和文件都幫你做好。唯一的差別是,這裡的回傳註記就是全部的宣告。 + +## `TypedDict` {#a-typeddict} + +不是每個形狀都值得寫一個類別。`TypedDict` 會產生同樣的 schema: + +```python title="server.py" hl_lines="8" +--8<-- "docs_src/structured_output/tutorial003.py" +``` + +`TypedDict` 在執行時就是普通的 `dict`,所以建立並回傳的就是它。schema、驗證和 `structured_content` 都跟 `BaseModel` 版本一模一樣(少了描述,因為 `TypedDict` 沒有地方放)。 + +## dataclass {#a-dataclass} + +dataclass 也可以,任何屬性帶有型別提示的普通類別也都可以。SDK 會在背後用這些註記建出一個 Pydantic 模型。 + +```python title="server.py" hl_lines="8-9" +--8<-- "docs_src/structured_output/tutorial004.py" +``` + +三種寫法,同一個 schema。程式碼庫裡已經用哪一種,就用哪一種。 + +## 串列 {#lists} + +`list[...]` 同樣不是 JSON 物件,所以也會套上 `{"result": ...}` 包裝,元素型別則以 `$defs` 參照的形式放在裡面: + +```python title="server.py" hl_lines="15" +--8<-- "docs_src/structured_output/tutorial005.py" +``` + +```json +{ + "$defs": { + "WeatherData": { + "properties": { + "temperature": {"title": "Temperature", "type": "number"}, + "humidity": {"title": "Humidity", "type": "number"}, + "conditions": {"title": "Conditions", "type": "string"} + }, + "required": ["temperature", "humidity", "conditions"], + "title": "WeatherData", + "type": "object" + } + }, + "properties": { + "result": {"items": {"$ref": "#/$defs/WeatherData"}, "title": "Result", "type": "array"} + }, + "required": ["result"], + "title": "get_forecastOutput", + "type": "object" +} +``` + +要兩天的預報,`structured_content` 就是 `{"result": [{...}, {...}]}`。`content` 則變成**兩個** `TextContent` 區塊,每個元素一個:串列會為模型攤平,而不是整個倒成一個字串。 + +`tuple[...]`、union 和 `Optional[...]` 也用同樣的方式包裝。 + +## 字典 {#dictionaries} + +`dict[str, ...]` 是唯一本身**就是** JSON 物件的泛型,所以不會被包裝: + +```python title="server.py" hl_lines="9" +--8<-- "docs_src/structured_output/tutorial006.py" +``` + +```json +{ + "additionalProperties": {"type": "number"}, + "title": "get_temperaturesDictOutput", + "type": "object" +} +``` + +```python +result.structured_content # {"London": 16.2, "Reykjavik": 4.4} +``` + +鍵必須是 `str`。`dict[int, float]` 沒辦法成為 JSON 物件,所以會退回 `{"result": ...}` 包裝。 + +## 驗證 {#validation} + +`output_schema` 不是寫來看的說明文件。函式回傳的任何東西,在離開伺服器之前都會**拿它來驗證**。 + +自己手動建值的時候感覺不到:Pydantic 早就確保 `WeatherData` 確實是 `WeatherData`。等到哪天資料來自你無法掌控的地方,就會感覺到了: + +```python title="server.py" hl_lines="9 21" +--8<-- "docs_src/structured_output/tutorial007.py" +``` + +註記承諾的是 `WeatherData`,但上游回應不再送 `humidity` 了。 + +!!! check + 呼叫 `get_weather`,它不會默默把一個半空的物件交給用戶端。呼叫會失敗,錯誤的頭幾行就點名了那個欄位: + + ```text + Error executing tool get_weather: 1 validation error for WeatherData + humidity + Field required [type=missing, input_value={'temperature': 16.2, 'conditions': 'Overcast'}, input_type=dict] + ``` + + 這段文字會以 `is_error=True` 的工具結果回傳,所以模型知道呼叫失敗了,而不是信心滿滿地讀一份根本不存在的天氣。 + +順帶一提,從 `-> WeatherData` 的工具回傳普通的 `dict` 沒問題,`json.loads` 產生的正是這個。驗證看的是值,不是 Python 型別。 + +## 選擇退出 {#opting-out} + +有時候回傳註記是寫給型別檢查器看的,不是給協定用的。傳入 `structured_output=False`,工具就只有文字: + +```python title="server.py" hl_lines="6" +--8<-- "docs_src/structured_output/tutorial008.py" +``` + +沒有 `output_schema`、沒有包裝、沒有驗證。`structured_content` 是 `None`,`content` 就是你回傳的字串。 + +反過來,`structured_output=True` 會把自動偵測變成硬性要求:回傳型別產生不出 schema 的工具,會在匯入時引發例外,而不是退回文字。 + +## 沒有型別提示的類別 {#a-class-without-type-hints} + +有一種情況會在沒有要求的前提下變成非結構化:回傳一個**本體上沒有任何註記**的類別。 + +```python title="server.py" hl_lines="6-9" +--8<-- "docs_src/structured_output/tutorial009.py" +``` + +`Station` 在 `__init__` 裡設定了 `name` 和 `online`,但**類別**本身什麼都沒宣告。SDK 讀取類別註記,什麼都沒找到,於是放棄。 + +!!! warning + 而且是**默默**放棄。`output_schema` 是 `None`,`structured_content` 是 `None`,模型讀到的文字是物件的 `repr`: + + ```text + "" + ``` + + 沒有錯誤、沒有警告,只有一個沒用的工具。把註記移到類別本體上,或者傳入 `structured_output=True`,後者會在模組匯入的那一刻把這件事變成硬性錯誤:`Function get_station: return type is not serializable for structured output`。 + +!!! tip + 需要完全掌控(自己建構 `CallToolResult`,或附上應用程式看得到但模型看不到的 `_meta`)?請見 **[低階 Server](../advanced/low-level-server.md)**。 + +## 重點回顧 {#recap} + +* **回傳型別註記**就是輸出 schema,會在 `tools/list` 裡以 `output_schema` 公布。 +* 純量、串列、tuple 和 union 會包進 `{"result": ...}`。模型、`TypedDict`、dataclass、帶註記的類別和 `dict[str, ...]` 本來就是物件,維持原樣。 +* 每個結果都帶有 `content`(文字,給模型)**和** `structured_content`(資料,給應用程式)。 +* 回傳的東西會拿 schema 驗證。不符合就是工具錯誤,不會是一份壞掉的結果。 +* `structured_output=False` 讓工具退出。沒有型別提示的類別會默默退出,要留意。 + +工具能回覆的一切,現在都掌握在你手上了。接下來是第二個基本元件:**[資源](resources.md)**。 diff --git a/i18n/zh-hant/pages/servers/tools.md b/i18n/zh-hant/pages/servers/tools.md new file mode 100644 index 0000000000..7aea2c3fde --- /dev/null +++ b/i18n/zh-hant/pages/servers/tools.md @@ -0,0 +1,170 @@ +--- +translation: + sections: [e4cc390d56573409, 8566e2b68594e9ad, 2c97b9f888398951, 048e5471dfa71aea, 3076b1e16ad95950, edbedf2a16e71311, 3d8ef8da89fa87c1, f6c0e02e6ea5a363] + tool: 1 +--- +# 工具 {#tools} + +**工具**是模型可以呼叫的函式。 + +在一個普通的 Python 函式上加上 `@mcp.tool()`,就宣告了一個工具。整個 API 就這樣。 + +## 你的第一個工具 {#your-first-tool} + +```python title="server.py" hl_lines="6-8" +--8<-- "docs_src/tools/tutorial001.py" +``` + +看看剛才寫的東西。沒有 schema、沒有 JSON、沒有協定,就只是一個函式。SDK 從中讀取三樣東西: + +* 工具的**名稱**就是函式名稱:`search_books`。 +* 模型看到的**描述**就是 docstring:`Search the catalog by title or author.` +* 模型可以傳入的**引數**來自型別提示:`query: str` 和 `limit: int`。 + +### 輸入 schema {#the-input-schema} + +SDK 從這些型別提示產生一份 JSON Schema,並在 `tools/list` 時送給用戶端: + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"title": "Limit", "type": "integer"} + }, + "required": ["query", "limit"], + "title": "search_booksArguments" +} +``` + +兩個引數都在 `required` 裡,因為都沒有預設值。等一下就會修正這點。(`title` 鍵是 Pydantic 產生的附帶產物;屬性、它們的型別和 `required` 才是契約。) + +!!! tip + 這裡的型別提示不是說明文件,而是**契約**。如果用戶端送來 `"limit": "ten"`,SDK 會在函式執行之前就拒絕它。 + +### 模型收到什麼 {#what-the-model-gets-back} + +用 `{"query": "dune", "limit": 5}` 呼叫這個工具,結果有兩個部分: + +```python +result.content # [TextContent(text="Found 3 books matching 'dune' (showing up to 5).")] +result.structured_content # {'result': "Found 3 books matching 'dune' (showing up to 5)."} +``` + +`content` 是**模型**讀取的文字。`structured_content` 是給**用戶端應用程式**的型別化資料。它之所以存在,是因為你把回傳型別宣告成 `-> str`。 + +先不用管 `structured_content`。從工具回傳真正的 Python 物件,該發生的事就會發生;**[結構化輸出](structured-output.md)**那一頁專門講這件事。 + +### 試試看 {#try-it} + +用 MCP Inspector 執行伺服器: + +```console +uv run mcp dev server.py +``` + +打開它印出的 URL,切到 **Tools** 分頁,呼叫 `search_books`。 + +Inspector 會呈現一個表單,裡面有一個必填的 `query` 文字欄位和一個必填的 `limit` 數字欄位。這個表單是從你的型別提示建出來的。其他每一個 MCP 用戶端也都會這麼做。 + +## 選填引數 {#optional-arguments} + +替參數加上預設值,它就不再是必填。就這樣,就只是 Python 而已。 + +```python title="server.py" hl_lines="7" +--8<-- "docs_src/tools/tutorial002.py" +``` + +schema 跟著變: + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"default": 10, "title": "Limit", "type": "integer"} + }, + "required": ["query"], + "title": "search_booksArguments" +} +``` + +`limit` 離開了 `required`,多了 `"default": 10`。省略它的用戶端會得到 `10`,和 Python 的行為一模一樣。 + +## 用 `Field` 寫出更豐富的 schema {#richer-schemas-with-field} + +型別提示已經能做很多事,但有時候會想**描述**一個引數,或是替它加上限制。 + +把型別包進 `Annotated`,再加上 Pydantic 的 `Field`: + +```python title="server.py" hl_lines="12-14" +--8<-- "docs_src/tools/tutorial003.py" +``` + +三樣新東西,全都在參數上: + +* `Field(description=...)`:每個引數各自的描述,模型會和 docstring 一起讀。 +* `Field(ge=1, le=50)`:數值範圍。在 schema 裡會變成 `"minimum": 1, "maximum": 50`。 +* `Literal["fiction", "non-fiction", "poetry"]`:列舉。模型只能從中挑一個。 + +!!! check + 限制條件不是裝飾。用 `limit=999` 呼叫這個工具,SDK 會**在函式執行之前**就回應一個工具錯誤: + + ```text + Input should be less than or equal to 50 + ``` + + 這個錯誤會當作工具結果回到模型手上,模型讀了之後會用合法的值重試。你只寫了一次 `le=50`,就平白得到會自我修正的 agent。 + +!!! info + 如果用過 FastAPI 或 Pydantic,這些你早就會了。同一個 `Field`、同一個 `Annotated`、同一套驗證。這裡沒有任何 MCP 特有的東西要學。 + +## 以模型作為參數 {#a-model-as-a-parameter} + +當工具的引數超過兩三個,就把它們整理成一個 Pydantic 模型: + +```python title="server.py" hl_lines="8-11 15" +--8<-- "docs_src/tools/tutorial004.py" +``` + +`Book` 的 schema 會巢狀放進工具的輸入 schema(以 `$defs` 參照的形式),模型把它填成一個 JSON 物件,而你的函式收到的是一個**真正的 `Book` 實例**,已經驗證完畢,有 `.title`、`.author` 和 `.year` 屬性。 + +可以自由搭配:一般參數和模型參數並列、巢狀模型、模型的 list。從頭到尾都是 Pydantic。 + +## `async def` {#async-def} + +如果工具會做 I/O(呼叫 API、讀檔案、查資料庫),就宣告成 `async def`,在裡面 `await`。SDK 會 await 它。 + +一般的 `def` 工具也可以:SDK 會在執行緒裡執行它,所以永遠不會阻塞伺服器。 + +沒有其他要設定的東西。 + +## 名稱、標題與 annotations {#names-titles-and-annotations} + +SDK 推斷出來的所有東西,都可以在裝飾器裡覆寫: + +```python title="server.py" hl_lines="7-10" +--8<-- "docs_src/tools/tutorial005.py" +``` + +* `title` 是給 UI 用、方便人閱讀的名稱。用戶端會顯示「Search the catalog」而不是 `search_books`。 +* `annotations` 是給用戶端的行為**提示**: + * `read_only_hint=True`:這個工具不會改變任何東西。 + * `open_world_hint=False`:它操作的是一組封閉的東西(這份目錄),不是開放的網路。 + * 另外兩個 `destructive_hint` 和 `idempotent_hint` 描述的是會**寫入**的工具:它可能刪除東西嗎?呼叫兩次和呼叫一次的結果一樣嗎?規格只針對非唯讀的工具定義這兩個,所以放在 `search_books` 上沒有意義。 + +守規矩的用戶端會用它們來判斷像「執行這個之前需要先問使用者嗎?」這類事情。它們是提示,不是安全機制。永遠不要指望用戶端一定會遵守。 + +!!! tip + 如果不想從函式名稱和 docstring 推導,`@mcp.tool()` 也接受 `name=` 和 `description=`。大多數時候用推導的就好。 + +## 重點回顧 {#recap} + +* 在函式上加 `@mcp.tool()` 就把它變成工具。名稱來自函式,描述來自 docstring。 +* 型別提示**就是**輸入 schema。預設值讓引數變成選填。 +* `Annotated[..., Field(...)]` 加上描述和限制;`Literal` 加上列舉。 +* 要接收結構化的「body」,就用 Pydantic 模型參數。 +* 不合法的引數會替你擋下來,並附上模型讀得懂、能據以修正的錯誤。 +* I/O 用 `async def`,其他一律用一般的 `def`。 + +**[結構化輸出](structured-output.md)**講的是你 `return` 的值接下來會發生什麼事。 diff --git a/i18n/zh-hant/pages/servers/uri-templates.md b/i18n/zh-hant/pages/servers/uri-templates.md new file mode 100644 index 0000000000..8a36f06c56 --- /dev/null +++ b/i18n/zh-hant/pages/servers/uri-templates.md @@ -0,0 +1,167 @@ +--- +translation: + sections: [4a7033e1ed8ad602, 55dcbfff0c6271bf, 101ef9d14bf4ec46, 4b6c4a845438abc7, f98b46bafbee4acd] + tool: 1 +--- +# URI 範本與路徑安全 {#uri-templates-and-path-safety} + +這一頁是參考文件,涵蓋 [`@mcp.resource`](resources.md) 接受的 URI 範本語法,以及 SDK 套用在擷取值上的路徑安全策略。想先了解資源是什麼、什麼時候該用,請從 **[資源](resources.md)** 開始;這一頁假設你已經能自在地宣告資源,想要的是完整的運算子集合、安全相關的設定選項,或低階的接線方式。 + +範本語法是 [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570)。SDK 支援其中一個子集,挑選的依據是用來比對傳入的 `resources/read` URI,另外再加上一層安全機制,會拒絕解析後落在預定服務目錄之外的值。協定層級的細節(訊息格式、生命週期、分頁)請見 [MCP 資源規格](https://modelcontextprotocol.io/specification/latest/server/resources)。 + +## 完整的運算子集合 {#the-full-operator-set} + +最基本的佔位符 `{user_id}` 是 **[資源](resources.md)** 介紹過的那一種。另外還有四種運算子形式;下面把它們放在同一個伺服器上,方便並排比較: + +```python title="server.py" hl_lines="16-17 22-23 28-29 34-35 40-41" +--8<-- "docs_src/uri_templates/tutorial001.py" +``` + +每個標示出來的裝飾器都是切分 URI 的不同方式。以下各節從上到下逐一說明。 + +### 簡單展開:`{name}` {#simple-expansion-name} + +`books://{isbn}` 是最平常的基本形式。佔位符對應到 `isbn` 參數,所以用戶端讀取 `books://978-0441172719` 時會呼叫 `get_book("978-0441172719")`。 + +單純的 `{name}` 遇到第一個 `/` 就停。`books://978/extra` 不會比對成功,因為 `978` 後面的斜線結束了擷取,剩下 `/extra` 沒有去處。 + +### 型別轉換 {#type-conversion} + +擷取出來的值一開始都是字串,但可以宣告更明確的型別,SDK 會幫忙轉換。`orders://{order_id}` 對應的函式參數是 `order_id: int`,所以讀取 `orders://12345` 會呼叫 `get_order(12345)`,而不是 `get_order("12345")`。處理函式直接拿它做算術(`order_id + 1`),不必轉型。 + +### 多段路徑:`{+name}` {#multi-segment-paths-name} + +要擷取含有斜線的值,用 `{+name}`。以 `manuals://{+path}` 為例: + +* `manuals://returns.md` 得到 `path = "returns.md"` +* `manuals://printing/setup.md` 得到 `path = "printing/setup.md"` + +只要值是階層式的,就用 `{+name}`:檔案系統路徑、巢狀物件的鍵、代理轉發的 URL 路徑。 + +### 查詢參數:`{?a,b,c}` {#query-parameters-abc} + +`reviews://{isbn}{?limit,sort}` 把 `limit` 和 `sort` 放在 `?` 後面。路徑指出是**哪一本**書;查詢則調整**怎麼**讀它。 + +查詢參數採寬鬆比對:順序無所謂,多出來的會被忽略,省略的參數則落回函式的預設值。所以 `reviews://978-0441172719` 會用 `limit=10, sort="newest"`,而 `reviews://978-0441172719?sort=top` 只覆寫 `sort`。 + +### 路徑段轉成清單:`{/name*}` {#path-segments-as-a-list-name} + +如果希望每個路徑段各自成為清單中的一個項目,而不是一個帶斜線的字串,用 `{/name*}`。以 `shelves://browse{/path*}` 為例,用戶端讀取 `shelves://browse/fiction/sci-fi` 會呼叫 `browse_shelf(["fiction", "sci-fi"])`。 + +### 範本速查 {#template-reference} + +最常見的樣式: + +| 樣式 | 範例輸入 | 得到 | +|--------------|-----------------------|-------------------------| +| `{name}` | `alice` | `"alice"` | +| `{name}` | `docs/intro.md` | **不相符**(停在 `/`) | +| `{+path}` | `docs/intro.md` | `"docs/intro.md"` | +| `{.ext}` | `.json` | `"json"` | +| `{/segment}` | `/v2` | `"v2"` | +| `{?key}` | `?key=value` | `"value"` | +| `{?a,b}` | `?a=1&b=2` | `"1"`, `"2"` | +| `{/path*}` | `/a/b/c` | `["a", "b", "c"]` | + +### 剖析器會拒絕什麼 {#what-the-parser-rejects} + +有幾種範本寫法會在一開始就被擋下來,而不是等到第一個請求才失敗。`@mcp.resource` 在裝飾器執行時就剖析範本,所以這些情況都不會進到執行中的伺服器。 + +`UriTemplate.parse()` 在下列情況會引發 `InvalidUriTemplate`: + +* **兩個變數之間沒有任何東西。** `manuals://{+path}{ext}` 會被拒絕:比對時無法判斷 `path` 在哪裡結束、`ext` 從哪裡開始。在它們之間放一個字面字元(`manuals://{+path}/{ext}`),或改用自帶分隔符號的運算子。`manuals://{+path}{.ext}` 可以接受,因為 `{.ext}` 自己提供了 `.`。 +* **超過一個多段變數。** 每個範本最多只能有一個 `{+var}`、`{#var}` 或展開變數(`{/var*}`、`{.var*}`、`{;var*}`)。兩個就先天有歧義:沒有合理的依據決定哪一個該吸收多出來的段。 +* **一般的語法錯誤**:沒關上的大括號、重複使用的變數名稱,或 SDK 不支援的 RFC 6570 功能,例如 `{var:3}` 前綴修飾詞或 `{?vars*}` 查詢展開。 + +除此之外,當處理函式的某個參數綁定到範本尾端 `{?...}`/`{&...}` 區段裡的查詢變數,卻沒有 Python 預設值時,`@mcp.resource` 會引發 `ValueError`。這些變數是寬鬆比對的(用戶端可以省略其中任何一個),所以沒有預設值的參數只會在第一個省略它的請求上,以一個看不出原因的內部錯誤浮現。上面伺服器裡的 `reviews://{isbn}{?limit,sort}` 就是寫對的版本:`limit` 和 `sort` 都有預設值。 + +## 安全性 {#security} + +範本參數來自用戶端。如果未經檢查就流入檔案系統或資料庫操作,像 `../../etc/passwd` 這樣的值可能會解析到預定服務目錄之外。 + +### SDK 預設檢查什麼 {#what-the-sdk-checks-by-default} + +在處理函式執行之前,SDK 會拒絕任何符合下列條件的參數: + +* 透過 `..` 元件跳出起始目錄 +* 看起來像絕對路徑(`/etc/passwd`、`C:\Windows`)或 Windows 磁碟機相對路徑(`C:foo`)。磁碟機相對路徑的值和 `x:y` 這類帶命名空間的識別碼,從字串上無法區分,所以任何「單一字母加冒號」的值預設都會被拒絕;如果該參數確實會合法地收到這種值,就把它設為豁免 +* 含有 null 位元組(`\x00`) + +`..` 的檢查是以路徑元件為單位,不是子字串掃描。`v1.0..v2.0` 或 `HEAD~3..HEAD` 這類值會通過,因為其中的 `..` 並不是獨立的路徑段。 + +這些檢查套用在解碼後的值上,所以不管在 URI 裡怎麼編碼,都抓得到路徑穿越(`../etc`、`..%2Fetc`、`%2E%2E/etc`、`..%5Cetc`、`%00` 全都會被攔下)。 + +!!! check + 從上面的伺服器讀取 `manuals://../etc/passwd`,請求會直接被拒絕:範本比對在第一次失敗時就停止,所以不會退而嘗試後面(可能更寬鬆)的範本。用戶端看到的是和完全不符合任何範本的 URI 一樣的 `-32602`「Unknown resource」錯誤,而 `read_manual` 根本不會執行。 + +### 檔案系統處理函式:使用 safe_join {#filesystem-handlers-use-safe_join} + +內建檢查擋得住常見情況,但無從得知你的沙箱邊界。存取檔案系統時,用 `safe_join` 解析路徑,並確認它仍在基底目錄之內: + +```python title="server.py" hl_lines="4 14" +--8<-- "docs_src/uri_templates/tutorial002.py" +``` + +`safe_join` 抓得到符號連結跳脫、`..` 序列,以及簡單字串檢查會漏掉的絕對路徑伎倆。如果解析後的路徑跳出 `DOCS_ROOT`,它會引發 `PathEscapeError`,在用戶端會以 `ResourceError` 的形式呈現。 + +### 預設值礙事的時候 {#when-the-defaults-get-in-the-way} + +有時候這些檢查會擋掉合法的值。目錄匯入工具可能就是要接收絕對路徑,或者某個參數是像 `../sibling` 這樣的相對參照,處理函式會安全地解讀它而不碰檔案系統。把那個參數設為豁免,或放寬整個伺服器的策略: + +```python title="server.py" hl_lines="9 16-19" +--8<-- "docs_src/uri_templates/tutorial003.py" +``` + +* 裝飾器上的 `security=ResourceSecurity(exempt_params={"source"})` 只對那一個資源的那一個參數跳過檢查。伺服器其餘部分維持預設策略。 +* `MCPServer` 建構子上的 `resource_security=` 設定所有資源的預設值。這裡的 `relaxed` 把 `..` 檢查整個關掉。 + +可設定的檢查: + +| 設定 | 預設值 | 作用 | +|-------------------------|---------|-------------------------------------| +| `reject_path_traversal` | `True` | 拒絕跳出起始目錄的 `..` 序列 | +| `reject_absolute_paths` | `True` | 拒絕 `/foo`、`C:\foo`、UNC 路徑和磁碟機相對的 `C:foo`(也會抓到 `x:y`) | +| `reject_null_bytes` | `True` | 拒絕含有 `\x00` 的值 | +| `exempt_params` | 空 | 要跳過檢查的參數名稱 | + +這些檢查只是啟發式的前置過濾;存取檔案系統時,`safe_join` 仍然是真正的隔離邊界。 + +!!! tip + 如果處理函式無法完成請求(檔案不存在、id 不認識),就引發例外。SDK 會把它轉成錯誤回應。協定錯誤和工具錯誤的差別請見 **[處理錯誤](handling-errors.md)**。 + +## 低階 Server 上的資源 {#resources-on-the-low-level-server} + +如果是在低階 `Server` 上開發(見 **[低階 Server](../advanced/low-level-server.md)**),就直接為 `resources/list` 和 `resources/read` 這兩個協定方法註冊處理函式。沒有裝飾器;協定型別要自己回傳。 + +### 靜態資源 {#static-resources} + +固定的 URI 就維護一份登錄表,依完全相符來分派: + +```python title="server.py" hl_lines="17 21 27" +--8<-- "docs_src/uri_templates/tutorial004.py" +``` + +list 處理函式告訴用戶端有哪些可用;read 處理函式提供內容。先查登錄表,如果有範本(見下)就接著落到範本,其餘一律引發例外。 + +### 範本 {#templates} + +`MCPServer` 用的範本引擎位於 `mcp.shared.uri_template`,可以獨立使用。剖析和比對完全一樣;路由和安全策略要自己接線。 + +```python title="server.py" hl_lines="13-16 22-25 29 33 45" +--8<-- "docs_src/uri_templates/tutorial005.py" +``` + +標示出來的幾行做了三件事: + +* **剖析一次,每個請求比對一次。** `UriTemplate.parse()` 建立範本;`template.match(uri)` 以 `dict` 回傳擷取出的變數,URI 不符則回傳 `None`。URL 解碼在 `match()` 內部進行;解碼後的值原樣回傳,不做路徑安全驗證。出來的值都是字串:自己轉換(`int(matched["id"])`、`Path(matched["path"])`)。 +* **自己套用安全檢查。** `MCPServer` 預設執行的 `..` 和絕對路徑檢查位於 `mcp.shared.path_security`。`read_manual_safely` 在碰 `MANUALS` 之前會先呼叫它們。如果某個參數不是檔案系統路徑(ISBN、搜尋查詢),就跳過那個值的檢查:策略是逐個處理函式控制,而不是透過設定物件。 +* **從同一個來源列出範本。** 用戶端透過 `resources/templates/list` 探索範本。`str(template)` 會還原出原本的範本字串,所以清單和比對器共用同一個事實來源。 + +## 重點回顧 {#recap} + +* `{name}` 比對一段;`{+name}` 保留斜線;`{?a,b}` 從查詢字串取值;`{/name*}` 把各段拆成清單。 +* 兩個變數之間沒有任何東西,或出現第二個多段變數,都會在剖析時被拒絕。綁定到尾端 `{?...}`/`{&...}` 查詢變數的參數必須宣告 Python 預設值。 +* 替參數加上註記(`order_id: int`),SDK 就會轉換。 +* 預設的安全策略會在處理函式執行前拒絕 `..`、絕對路徑和 null 位元組;用 `security=ResourceSecurity(...)` 針對個別資源覆寫,或用 `resource_security=` 套用到整個伺服器。 +* 存取檔案系統時,`safe_join` 是隔離邊界。 +* 在低階 `Server` 上,用 `UriTemplate.parse()` 剖析、用 `.match()` 比對,並自己套用 `mcp.shared.path_security`。 diff --git a/i18n/zh-hant/pages/translations.md b/i18n/zh-hant/pages/translations.md new file mode 100644 index 0000000000..feb103c5f6 --- /dev/null +++ b/i18n/zh-hant/pages/translations.md @@ -0,0 +1,30 @@ +--- +translation: + sections: [f671b445b16e4f99, 3983a560eb2cece7, 004b3ee918529d8c, c6e2debf1da06eb7, 81d412ed5f399f94] + tool: 1 +--- +# 翻譯 {#translations} + +這份說明文件是以英文撰寫的。為了讓更多人能用得上,我們也發佈了機器翻譯的版本。這一頁說明這對你代表什麼,以及如何協助改善這些翻譯。 + +## 目前提供的語言 {#whats-available} + +翻譯版說明文件目前是**預覽版**,共有十二種語言:Deutsch、español、français、हिन्दी、日本語、한국어、português (Brasil)、русский язык、Türkçe、українська мова、简体中文和繁體中文。從任何頁面頂端的語言切換器選一個即可。等這些語言站穩腳步之後,可能會再加入更多語言。 + +API 參考文件沒有翻譯:翻譯版網站會連結到唯一的英文版。 + +## 以英文為準 {#english-is-the-source-of-truth} + +如果翻譯頁面和英文原文有出入,以英文頁面為準。翻譯版網站的每一頁開頭都會有以下三種說明之一,標示該頁的狀態: + +- **機器翻譯**——這一頁是自動翻譯的,並附上英文原文的連結。 +- **翻譯落後於英文頁面**——英文原文在這一頁翻譯完成後有更動,所以在翻譯跟上之前,部分內容可能已經過時。 +- **以英文顯示**——這一頁目前沒有翻譯,所以你讀到的是英文內容。 + +## 翻譯是怎麼產生的 {#how-the-translations-are-made} + +翻譯頁面是由這個儲存庫裡的工具,根據 `docs/` 底下的英文頁面機器產生的,每種語言各有兩份人工撰寫的輸入來引導:一份風格指南(語域、語氣、排版,以及如何處理笑話和慣用語),和一份詞彙表(哪些術語保留英文,其餘術語規定與禁用的譯法)。產生出來的文字從不手動編輯。所有改進都是改在這些輸入裡,這樣下次重新產生頁面時才不會消失。 + +## 回報翻譯問題 {#reporting-a-translation-problem} + +發現用錯的術語、彆扭的句子,或是翻譯說了英文沒說的東西?請[開一個 issue](https://github.com/modelcontextprotocol/python-sdk/issues),附上語言、頁面和那段文字;母語人士的回報特別有價值。如果你知道怎麼修正,可以直接對 [`i18n/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/i18n) 底下該語言的風格指南(`instructions.md`)或詞彙表(`glossary.json`)發 pull request,這樣下次重新產生翻譯時,修正就會套用到所有受影響的頁面。英文內容本身的問題則和其他說明文件的變更一樣,在 `docs/` 底下的頁面修正。 diff --git a/i18n/zh-hant/pages/troubleshooting.md b/i18n/zh-hant/pages/troubleshooting.md new file mode 100644 index 0000000000..855672d8b8 --- /dev/null +++ b/i18n/zh-hant/pages/troubleshooting.md @@ -0,0 +1,404 @@ +--- +translation: + sections: [2efaecdef109a5c5, fcacd3e66b8635a4, 25323d737dcf0261, 4835ed1772f1d113, 137454d469c867f5, 6392596bd6df54f0, 41126fa9c4fe432f, 480b6d7897e30ab4, d83bb682e708dde0, ebbed3449c499db4, 323ef84f6b4bebde, 30fd31be74169d9a, 656943c6cb567218, c2dc3b1007d2e987, 7cf5386b997d04e9, 0b59feed8384456e, 0cba47bae78d04eb, 954dc21efdb532a3] + tool: 1 +--- +# 疑難排解 {#troubleshooting} + +這一頁的每個標題都是 SDK 產生的錯誤原文,底下說明它代表什麼,以及一步到位的修正方式。用瀏覽器的頁內搜尋,在這裡找到 traceback(或伺服器記錄)的最後一行,然後只讀那一則就好。 + +有好幾則都是針對同一個伺服器執行的。一個工具和一個範本資源,各自在遇到不認識的城市時引發例外: + +```python title="server.py" +--8<-- "docs_src/troubleshooting/tutorial001.py" +``` + +這一頁引用的錯誤都是真的:SDK 自己的測試套件會重現每一個。 + +## `ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)` {#exceptiongroup-unhandled-errors-in-a-taskgroup-1-sub-exception} + +這不是 MCP 的錯誤,而是 anyio 的雜訊,真正的錯誤在貼出內容的**最後一行**。 + +`Client.__aenter__` 會啟動一個 task group。anyio 會把任何離開 task group 的東西包進 `ExceptionGroup`,所以**每一個**逃出 `async with Client(...)` 區塊的例外,不管是什麼,都會包在裡面送到你手上: + +```python +async def main() -> None: + async with Client(mcp) as client: + await client.read_resource("weather://Atlantis") +``` + +```text + + Exception Group Traceback (most recent call last): + | ... + | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception) + +-+---------------- 1 ---------------- + | Exception Group Traceback (most recent call last): + | ... + | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception) + +-+---------------- 1 ---------------- + | Traceback (most recent call last): + | ... + | mcp.shared.exceptions.MCPError: No forecast for 'Atlantis'. + +------------------------------------ +``` + +對此有兩件事要做: + +1. **讀最底下。** `MCPError: No forecast for 'Atlantis'.` 才是失敗本身;在這一頁找**它的**文字。 +2. **在區塊內攔截。** 只有當例外**離開** `async with` 時才會出現 `ExceptionGroup`。在裡面攔截的話,同樣的失敗就是單純的 `MCPError`,哪裡都沒有 group: + +```python +async def main() -> None: + async with Client(mcp) as client: + try: + await client.read_resource("weather://Atlantis") + except MCPError as e: + print(e) # No forecast for 'Atlantis'. +``` + +!!! tip + **連線**期間的失敗(URL 錯了、伺服器沒在執行、這一頁後面的 `421`)是從 `async with` 本身逃出來的,所以沒有「裡面」可以攔截。遇到這些,就讀 group 的最底下。 + +## `RuntimeError: Client must be used within an async context manager` {#runtimeerror-client-must-be-used-within-an-async-context-manager} + +`Client(...)` 只是建立物件。在 `async with` 之前什麼都不會連線,所以每個方法都會拒絕: + +```python +async def main() -> None: + client = Client(mcp) + tools = await client.list_tools() # RuntimeError +``` + +進入它。`__aenter__` 就是連線: + +```python +async def main() -> None: + async with Client(mcp) as client: + tools = await client.list_tools() +``` + +`__aexit__` 就是斷線,這也是為什麼沒有 `client.close()` 可以忘記。**[測試](get-started/testing.md)** 正是建立在這個模式上。 + +## `Error executing tool : ` 與 `Unknown tool: ` {#error-executing-tool-name-message-and-unknown-tool-name} + +你讀到的是**結果**,不是例外。`call_tool` 沒有引發例外,而且遇到失敗的工具它永遠不會引發。 + +用伺服器不認識的城市呼叫 `forecast`,它引發的例外會跟著一個標記為**成功**的請求一起回來: + +```python +result.is_error # True +result.content # [TextContent(text="Error executing tool forecast: No forecast for 'Atlantis'.")] +result.structured_content # None +``` + +對於伺服器從未註冊的名稱,`Unknown tool: get_forecast` 也是同樣的形狀;錯誤的引數也一樣,在你的函式執行之前,就會依工具的輸入 schema 遭到拒絕。 + +修正在用戶端:**檢查 `result.is_error`**。包在 `call_tool` 外面的 `try/except` 一個都攔不到,因為根本沒有東西可以攔。這是刻意的設計,也是這一頁最值得內化的一件事:是**模型**選擇了這個呼叫,所以訊息交給模型,讓它有機會再試一次。完整說明請見 **[處理錯誤](servers/handling-errors.md)**,包括**確實會**引發例外的 `MCPError` 路徑。 + +## `TypeError: The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool` {#typeerror-the-tool-decorator-was-used-incorrectly-did-you-forget-to-call-it-use-tool-instead-of-tool} + +你寫了 `@mcp.tool` 而不是 `@mcp.tool()`。`tool()` 是裝飾器**工廠**:少了括號,Python 會把你的函式交給它的 `name=` 參數。 + +```python +@mcp.tool # <- missing () +def forecast(city: str) -> str: + """Today's forecast for one city.""" + return f"{city}: Rain." +``` + +```text +TypeError: The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool +``` + +加上括號。同樣的手誤,`@mcp.resource(...)` 和 `@mcp.prompt()` 也會說同樣的話。 + +!!! note + 這在模組**匯入**時就會引發,早於任何用戶端連線。所以如果主機(host)把伺服器顯示成「failed to start」(或「disconnected」),而不是已連線但零個工具,就是這種情況:自己執行 `python server.py`,讀 traceback。型別檢查器也抓得到:函式不是合法的 `name=`。 + +## `Tool already exists: ` {#tool-already-exists-name} + +兩次註冊用了同一個工具名稱。**第一個**勝出,第二個會被默默丟掉,而**伺服器記錄**裡的這則警告是唯一的訊號: + +```python title="server.py" hl_lines="6 12" +--8<-- "docs_src/troubleshooting/tutorial002.py" +``` + +```text +WARNING mcp.server.mcpserver.tools.tool_manager: Tool already exists: forecast +``` + +`tools/list` 只回報一個 `forecast`,而且是 `forecast_today`。把其中一個改名。`MCPServer(..., warn_on_duplicate_tools=False)` 會讓警告安靜,但結果不變,所以保持開著。資源和提示詞有同樣的規則和同樣的記錄行(`Resource already exists:`、`Prompt already exists:`)。 + +## 主機列出零個工具 {#my-host-lists-zero-tools} + +這個沒有錯誤字串,正因如此才難搜尋。SDK 從不會把已註冊的工具從 `tools/list` 丟掉,所以從內往外一層層檢查: + +* **伺服器到底有沒有啟動?** 沒有括號的 `@mcp.tool` 會在匯入時引發例外,而當掉的伺服器在某些主機裡看起來很像空的伺服器。自己執行 `python server.py`。 +* **工具是在主機執行的那個 `mcp` 上嗎?** 另一個模組裡的第二個 `MCPServer(...)` 是另一個空的伺服器。確認主機的指令實際匯入的是哪個物件。 +* **有兩個工具同名嗎?** 那其中一個就不見了。在伺服器記錄裡找 `Tool already exists:`。 +* **主機的清單過期了嗎?** 啟動後才新增的工具,只會送達會處理 `notifications/tools/list_changed` 的用戶端。重新啟動主機是最直接的解法。 +* **有東西在轉向區間之外寫入 `stdout` 嗎?** 服務期間,SDK 會把**已 flush** 的雜散 stdout 轉到 stderr(盡力而為:會替換標準串流的環境就照原樣服務),但更早就 flush 到 stdout 的輸出(包裝腳本的 echo、無緩衝處理程序裡匯入時的 `print()`),或是在直譯器結束時才排出的緩衝 `print()`,都會落到協定串流上,而一行垃圾就可能讓主機斷線,有些主機會把這呈現成一個空無一物的伺服器。改用 `logging` 模組記錄。其餘的主機端檢查清單在 **[連接真正的主機](get-started/real-host.md)**。 + +「無效的」工具名稱**不在**這份清單上:不合規範的名稱會記錄一則警告,但工具照樣會註冊並列出。 + +## `MCPError: Server returned an error response` {#mcperror-server-returned-an-error-response} + +伺服器直接拒絕了這個 HTTP 請求,而且本文不是 JSON-RPC,所以 python `Client` 沒有更好的東西可以顯示,只能給這個替代訊息。 + +最常見的原因,遠遠超過其他的,是剛部署好的 Streamable HTTP 伺服器。沒有 `transport_security=` 的 `streamable_http_app()`(以及 `mcp.run("streamable-http")`)預設為 **DNS rebinding 防護**:只接受 `Host` 標頭是 localhost 的請求。在筆電上這是對的預設值,放在真正的主機名稱後面就錯了: + +```python title="server.py" hl_lines="12" +--8<-- "docs_src/troubleshooting/tutorial003.py" +``` + +把它部署出去,讓用戶端指向它,連線會在交握時失敗: + +```python +async with Client("https://mcp.example.com/mcp") as client: + ... +``` + +```text +mcp.shared.exceptions.MCPError: Server returned an error response +``` + +伺服器實際送出的字眼 `421` 和 `Invalid Host header` 永遠到不了你手上:421 的本文沒有 `Content-Type: application/json`,所以用戶端無法解析。它們在**伺服器的記錄**裡,那就是下一步該看的地方: + +```text +WARNING mcp.server.transport_security: Invalid Host header: mcp.example.com +``` + +修正是 `transport_security=`。把實際服務的主機名稱加入允許清單: + +```python title="server.py" hl_lines="14-17" +--8<-- "docs_src/troubleshooting/tutorial004.py" +``` + +!!! check + 整個改動就這樣。一模一樣的用戶端現在連得上,協商出 `2026-07-28`,並呼叫 `forecast`。 + +**[部署與擴展](run/deploy.md)** 說明每個欄位的意義、反向代理的情況,以及其他所有在部署時會變的東西。而緊接在下面的 `421 Misdirected Request` / `Invalid Host header`,是從另一邊看到的同一個失敗。 + +## `421 Misdirected Request` / `Invalid Host header` {#421-misdirected-request-invalid-host-header} + +這就是 `Server returned an error response`,只是從**不是** python `Client` 的任何東西看到的:curl、瀏覽器的網路分頁、反向代理的存取記錄,或是另一個 SDK。 + +```bash +curl -i https://mcp.example.com/mcp \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}' +``` + +```text +HTTP/1.1 421 Misdirected Request + +Invalid Host header +``` + +`421 Misdirected Request` 是 HTTP 自己對這個狀態碼的原因短語;`Invalid Host header` 是 SDK 的回應本文;而 python `Client` 把同一個事件呈現為 `Server returned an error response`。三者是同一次拒絕。檢查的對象是**請求帶的 `Host` 標頭**,不是伺服器綁定的位址,所以轉送公開主機名稱的反向代理會和直連的用戶端一模一樣地觸發它。 + +修正和 `Server returned an error response` 底下示範的一樣:`transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])`。有兩個邊界情況值得一提: + +* `allowed_hosts` 的項目是完全比對的字串。`"mcp.example.com"` 比對不帶連接埠的 `Host` 標頭,`"mcp.example.com:*"` 比對任何明確寫出的連接埠。兩個都列。 +* 本文為 `Invalid Origin header` 的 `403` 是針對 `Origin` 標頭的姊妹檢查。它只對瀏覽器觸發(別的東西都不送 `Origin`),而 `allowed_origins=` 是它的允許清單。 + +完整說明請見 **[部署與擴展](run/deploy.md)**,包括什麼時候把檢查關掉才是老實的設定。 + +## `RuntimeError: Task group is not initialized. Make sure to use run().` {#runtimeerror-task-group-is-not-initialized-make-sure-to-use-run} + +你的 MCP 應用程式掛載在另一個 ASGI 應用程式裡,卻沒有任何東西啟動它的**工作階段管理器**(session manager)。 + +`mcp.streamable_http_app()` 回傳一個 Starlette 應用程式,它自己的生命週期會啟動這個管理器,而 `uvicorn server:app` 會替你執行那個生命週期。但 Starlette **從不執行被掛載的子應用程式的生命週期**,所以應用程式一放進 `Mount`,管理器就永遠不會啟動,第一個請求就炸開: + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/troubleshooting/tutorial005.py" +``` + +伺服器啟動了。路由也解析得到。然後 `uvicorn` 對每個請求都印出這個: + +```text +ERROR: Exception in ASGI application +Traceback (most recent call last): + ... +RuntimeError: Task group is not initialized. Make sure to use run(). +``` + +用戶端看到 500。修正是在**外層**應用程式上加一個會進入 `mcp.session_manager.run()` 的生命週期: + +```python +@asynccontextmanager +async def lifespan(app: Starlette) -> AsyncIterator[None]: + async with mcp.session_manager.run(): + yield + + +app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lifespan) +``` + +這件事的專頁是 **[加入既有的應用程式](run/asgi.md)**,包括一個應用程式裡放好幾個伺服器以及 FastAPI 的情況。同一個類別還有兩個相鄰的字串: + +* `StreamableHTTPSessionManager .run() can only be called once per instance. Create a new instance if you need to run again.` 管理器只能用一次;同一個應用程式的生命週期進入兩次就會撞上它。 +* `mcp.session_manager` 要等呼叫過 `streamable_http_app()` **之後**才存在,所以先建好路由,只在生命週期裡面碰管理器。 + +## `MCPError: Session not found` {#mcperror-session-not-found} + +伺服器不認得用戶端送來的 `Mcp-Session-Id`,幾乎都是因為伺服器**重新啟動了**(或是你被導到另一個實例)。工作階段存在那一個處理程序的記憶體內。 + +沒有伺服器的 bug 可找。HTTP 回應是 `404`,而它的本文**就是** JSON-RPC,所以和上面的 `421` 不同,python `Client` 會原封不動地把這個顯示給你: + +```json +{"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "Session not found"}} +``` + +修正是重新連線:離開 `async with Client(...)` 區塊,進入一個新的,它會協商出新的工作階段。對於長時間存活的用戶端,這表示在呼叫外面攔截 `MCPError`,遇到這個訊息就重新連線,而不是在已經死掉的工作階段裡重試。 + +如果**沒有**重新啟動也發生,代表你跑了不只一個 worker 卻沒有黏性工作階段(sticky session):每個 worker 都有自己的工作階段表,所以導到錯誤 worker 的請求就會落到這裡。這件事和它的兩種修正(黏性路由,或 `stateless_http=True`)請見 **[部署與擴展](run/deploy.md)** 和 **[服務舊版用戶端](run/legacy-clients.md)**。 + +對伺服器維運人員來說,對應的記錄行是 `Rejected request with unknown or expired session ID: `。它以 `INFO` 層級記錄,所以在常用的 `WARNING` 門檻下看不到。剛部署完看到它一陣陣冒出來是正常的;每個已連線的用戶端都在重新連線。 + +## `MCPError: Method not found` {#mcperror-method-not-found} + +某一邊送出了另一邊沒有處理函式的 JSON-RPC 請求,`e.error.data` 會寫出是哪個方法。常見原因是**世代不合**:某個方法存在於一個協定修訂版而不在另一個,卻送給了講錯版本的對端,例如 `2025` 世代的 `resources/subscribe` 送到 `2026-07-28` 連線,或是固定在 `mode="legacy"` 的用戶端送出只有 `2026` 才有的 `subscriptions/listen`。哪一邊講什麼的對照圖在 **[協定版本](protocol-versions.md)**,而另一個正當的原因(你從未替它註冊處理函式的選用能力)在 **[自動完成](servers/completions.md)**。 + +有一件事**不會**產生這個錯誤,儘管它是現代協定已移除的請求:工具在 `2026-07-28` 連線上呼叫 `ctx.elicit()`。伺服器根本拒絕**送出**那個請求,所以你得到的反而是這一頁後面的 `Cannot send 'elicitation/create': ...`。 + +## `MCPError: Client did not declare the form elicitation capability required by resolver ''` {#mcperror-client-did-not-declare-the-form-elicitation-capability-required-by-resolver-name} + +伺服器想問使用者一件事,而這個用戶端從沒說過它可以被問。 + +徵詢(elicitation)解析器在已連線的用戶端沒有宣告表單徵詢時,會一開始就拒絕,而 `e.error.data` 會精確寫出缺了什麼: + +```json +{ + "code": -32021, + "message": "Client did not declare the form elicitation capability required by resolver 'server:ask_to_confirm'", + "data": {"requiredCapabilities": {"elicitation": {"form": {}}}} +} +``` + +把 `elicitation_callback=` 傳給 `Client(...)`。註冊回呼**就是**能力宣告;沒有第二個開關: + +```python +async def main() -> None: + async with Client(mcp, elicitation_callback=handle_elicitation) as client: + result = await client.call_tool("book_table", {"date": "Friday"}) +``` + +**[用戶端回呼](client/callbacks.md)** 列出其他的(`sampling_callback`、`list_roots_callback`),每一個同樣都是宣告。 + +!!! info + `-32021` 是 `MISSING_REQUIRED_CLIENT_CAPABILITY`,是 2026-07-28 規格新增的三個錯誤碼之一。它們都不是例外類別:全部以 `MCPError` 送達,要看的是 `e.error.code`。`mcp.types` 匯出了這些常數。另外兩個是 `-32020` `HEADER_MISMATCH`(HTTP 標頭和它伴隨的請求本文不一致)和 `-32022` `UNSUPPORTED_PROTOCOL_VERSION`(請求指定了這個伺服器不會講的版本)。符合規範的 SDK 用戶端兩者都產生不了,所以如果看到其中一個,去查是什麼東西在用戶端和伺服器之間改寫請求。 + +## `MCPError: Elicitation not supported` {#mcperror-elicitation-not-supported} + +和 `Client did not declare the form elicitation capability ...` 是同一個缺口,只是出自那些不會事先檢查的路徑:伺服器需要有人回答一個徵詢,而已連線的用戶端沒有註冊 `elicitation_callback`。 + +在舊版連線上的 `ctx.elicit()` 會看到它;而在任何連線上,只要回傳的多輪往返(multi-round-trip)問題(**[多輪往返請求](handlers/multi-round-trip.md)**)送到了沒有回呼可以回答的用戶端,也會看到它。修正一模一樣:把 `elicitation_callback=` 傳給 `Client(...)`。沒有任何一種「使用者沒被問到」會以 `decline` 的形式送到你的工具;問不了的用戶端就是一次失敗的呼叫,所以設計工具時要考慮這點。 + +## `MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests.` {#mcperror-cannot-send-elicitationcreate-this-transport-context-has-no-back-channel-for-server-initiated-requests} + +處理函式試圖在請求途中聯繫用戶端,但這條連線上的這次呼叫沒有能承載伺服器發出請求的通道。有三種伺服器設定會讓呼叫落到這種處境。 + +**`2026-07-28` 連線:任何傳輸方式,一律如此。** 現代協定完全沒有伺服器發起的請求,所以伺服器在送出任何東西之前就拒絕。工具裡的 `ctx.elicit()` 是遇到這個的典型方式(就在第一次記憶體內測試時,因為 `Client(server)` 不用交代就會協商出 `2026-07-28`),而傳入 `elicitation_callback=` 什麼都不會改變,因為根本沒有請求送到用戶端讓它回答: + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/troubleshooting/tutorial006.py" +``` + +```python +async def main() -> None: + async with Client(mcp) as client: + await client.call_tool("book_table", {"date": "Friday"}) +``` + +```text +mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests. +``` + +**`stateless_http=True` 伺服器上的舊版連線。** 無狀態表示每個請求都自成一個世界:沒有工作階段、沒有伺服器到用戶端的串流,所以即使是有這些方法的世代,也無處可送 `elicitation/create`(或 `sampling/createMessage`、或 `roots/list`): + +```python title="server.py" hl_lines="16 23" +--8<-- "docs_src/troubleshooting/tutorial008.py" +``` + +**`json_response=True` 伺服器上的舊版連線。** `POST` 是以一個 JSON 本文回應的,而一個本文只裝得下回應,所以請求途中的 `ctx.elicit()` 需要的請求範圍串流在這裡也不存在。工作階段、它的 `Mcp-Session-Id` 和它的獨立串流都還在;只有請求範圍的通道不見了。 + +訊息會寫出它送不出去的方法。伺服器引發的類別是 `NoBackChannelError`,但線路上只載得了基底的 `MCPError`,所以 traceback 的最後一行是上面那句話,而不是類別名稱。 + +對 `2026-07-28` 用戶端來說,三種情況的修正都一樣:不要在呼叫途中回頭聯繫。把問題移進**解析器**(或自己回傳一個 `InputRequiredResult`),它就變成**回應**的一部分,而每條連線都載得了回應: + +```python title="server.py" hl_lines="15-17 21" +--8<-- "docs_src/troubleshooting/tutorial007.py" +``` + +同樣的問題,用戶端上同樣的 `elicitation_callback`。差別在底層:解析器讓伺服器從呼叫中**回傳**問題,而不是推送出去,所以從頭到尾沒有任何東西從伺服器流向用戶端。這救得了每一個 `2026-07-28` 用戶端,不管伺服器是三種設定中的哪一種。**舊版**用戶端光靠改寫救不了:`2025-11-25` 沒有辦法回傳問題,所以在舊版連線上,解析器還是會沿著請求範圍的通道送出 `elicitation/create`,也還是需要一個保留這條通道的伺服器,既不是 `stateless_http=True` 也不是 `json_response=True`。解析器請見 **[徵詢](handlers/elicitation.md)**;線路上發生什麼事請見 **[多輪往返請求](handlers/multi-round-trip.md)**。 + +!!! check + 用 `ctx.elicit()` 的工具沒有錯,它只是 **2026 之前**的寫法。用 `mode="legacy"`(傳統的 `initialize` 交握,規格 `2025-11-25` 及更早)連到一個既不是 `stateless_http=True` 也不是 `json_response=True` 的伺服器,它就能運作,因為那裡有伺服器到用戶端的通道。每個版本有什麼請見 **[協定版本](protocol-versions.md)**。 + +## `MCPError: Invalid or expired requestState` {#mcperror-invalid-or-expired-requeststate} + +伺服器無法驗證用戶端回送的 `requestState` 權杖,所以拒絕了這一輪。 + +`requestState` 是 **[多輪往返](handlers/multi-round-trip.md)** 呼叫在各段之間攜帶的不透明續接權杖。`MCPServer` 在送出時密封它,並驗證每一次回送;而且它會驗證 `tools/call`、`prompts/get` 和 `resources/read` 上**每一個**進來的 `request_state`,就算處理函式從不產生權杖也一樣。所以不是這個處理程序密封的權杖,不管落在哪裡都會被拒絕: + +```python +async def main() -> None: + async with Client(mcp) as client: + await client.call_tool("forecast", {"city": "London"}, request_state="round-1-from-worker-a") +``` + +```text +mcp.shared.exceptions.MCPError: Invalid or expired requestState +``` + +這則訊息是刻意固定不變的:線路上永遠不會透露是哪一項檢查失敗。原因會寫進**伺服器記錄**,讀它就是全部的診斷: + +```text +WARNING mcp.server.request_state: requestState rejected on tools/call: malformed +``` + +實際上會看到的原因: + +* **`unknown key`** 是最要緊的一個。預設的密封金鑰在處理程序啟動時產生,所以落到**另一個 worker**、負載平衡器後面另一個實例,或是**重新啟動後**的同一台伺服器上的重試,當初是用這個處理程序從沒有過的金鑰密封的。那不是攻擊者;是預設值遇上了不只一個處理程序。 +* **`audience`**:權杖是由**伺服器名稱不同**的實例密封的。名稱是密封預設的 audience claim,所以一整批實例除了金鑰之外,也必須共用名稱(或設定明確的 `RequestStateSecurity(audience=...)`)。 +* **`expired`**:這一輪花的時間超過密封的 `ttl`,它是 600 秒,而且是每輪計算,不是每次呼叫。 +* **`malformed`** / **`codec error`**:權杖在傳輸途中被改過,或者根本從來不是密封過的權杖。 +* **`request binding`**:權杖回來時帶的是不同的工具、不同的引數,或不同的方法。 + +多處理程序的修正是一個引數(每個實例上**相同**的 `keys`)加上一個根本不是引數的東西:相同的伺服器**名稱**(或明確共用的 `audience=`)。 + +```python +mcp = MCPServer("Weather", request_state_security=RequestStateSecurity(keys=[key])) +``` + +`keys[0]` 負責密封;清單裡的每一把金鑰都能驗證,這正是零停機輪替得以實現的原因。密封保護了什麼以及輪替順序,請見 **[多輪往返請求](handlers/multi-round-trip.md#protecting-requeststate)**;整個雙 worker 失敗情境和它的兩段式修正,**[部署與擴展](run/deploy.md)** 會完整走一遍。 + +!!! tip + `keys=[...]` 會立刻拒絕太弱的金鑰,訊息格外貼心: + + ```text + ValueError: request-state keys must be at least 32 bytes of secret randomness; keys[0] is 7 bytes. Generate one with: python -c "import secrets; print(secrets.token_hex(32))" + ``` + + 照它說的做就好。 + +## 還是卡住? {#still-stuck} + +* 如果 SDK 產生的某則訊息不在這一頁上,那本身就是值得回報的文件 bug。 +* 搜尋 [issue tracker](https://github.com/modelcontextprotocol/python-sdk/issues);出現在那裡的錯誤字串,大多已經有人寫過紀錄了。 +* 什麼都沒找到?附上完整的 traceback [開一個 issue](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml),或到 [MCP Contributors Discord 的 #python-sdk-dev](https://discord.gg/6CSzBmMkjX) 發問。 + +## 重點回顧 {#recap} + +* `ExceptionGroup: unhandled errors in a TaskGroup` 永遠不是錯誤本身。讀**最後一行**;在 `async with Client(...)` 區塊**裡面**攔截 `MCPError` 就完全跳過包裝。 +* `call_tool` 不會因為工具失敗而引發例外。`Error executing tool ...` 和 `Unknown tool: ...` 是結果:檢查 `result.is_error`。 +* `Client must be used within an async context manager` -> 用 `async with`。`Use @tool() instead of @tool` -> 加上括號。 +* 伺服器記錄裡的 `Tool already exists:` 是兩個同名工具合併成一個的唯一跡象。 +* 一個 421,三種寫法:`Server returned an error response`(python `Client`)、`421 Misdirected Request` / `Invalid Host header`(其他所有東西)、`Invalid Host header: `(伺服器記錄)。修正:`transport_security=TransportSecuritySettings(allowed_hosts=[...])`。 +* `Task group is not initialized` -> 掛載的應用程式,其外層生命週期從未進入 `mcp.session_manager.run()`。 +* `Session not found` -> 伺服器重新啟動了;重新連線。 +* `Cannot send 'elicitation/create': ... no back-channel ...` -> `ctx.elicit()` 需要伺服器到用戶端的通道:`2026-07-28` 連線從來沒有,`stateless_http=True` 拿走了舊版的那條,`json_response=True` 拿走了請求範圍的那條。改用解析器(舊版用戶端還需要一個保留通道的伺服器)。它的鄰居 `Method not found` 則是請求了一個對方的協定修訂版沒有的方法。 +* `Client did not declare the form elicitation capability ...` 和 `Elicitation not supported` -> 用戶端少了 `elicitation_callback=`。 +* `Invalid or expired requestState` 在線路上從不說原因。伺服器記錄會說;`unknown key` 表示要在各 worker 之間共用 `RequestStateSecurity(keys=[...])`。 diff --git a/i18n/zh-hant/pages/whats-new.md b/i18n/zh-hant/pages/whats-new.md new file mode 100644 index 0000000000..96690f8567 --- /dev/null +++ b/i18n/zh-hant/pages/whats-new.md @@ -0,0 +1,206 @@ +--- +translation: + sections: [cfe01c0c5863dfa2, 11d93f1fa09eadf5, a7392996acf1ad8f, 875eb2889263424e] + tool: 1 +--- +# v2 的新功能 {#whats-new-in-v2} + +v2 同時發生了兩件事。**SDK 重寫了**:用戶端和伺服器底下都換了新引擎,有了一等公民的 `Client`,還有一組重新命名,v1 的程式碼在第一次 import 時就會碰上。**協定也往前走了**:v2 講的是 MCP 的 2026-07-28 修訂版,它拿掉了連線交握、工作階段(session)以及所有由伺服器發起的請求,卻不會把你現有的用戶端丟下不管。 + +這一頁帶你走過這兩半,每個重點一節,每節最後都指向負責該主題的頁面。它不是移植手冊。移植手冊是 **[遷移指南](migration.md)**:列出每一項破壞性變更,附上修改前後的程式碼。 + +!!! note "v2 是穩定版本線" + `pip install mcp` 會安裝 2.x,**[安裝](get-started/installation.md)** 有可以直接複製貼上的安裝指令。如果 v2 有任何地方壞掉、出乎意料或拖慢你的腳步,請[告訴我們](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml)。 + +## SDK:從 v1 到 v2 {#the-sdk-v1-to-v2} + +### `FastMCP` 現在叫 `MCPServer` {#fastmcp-is-now-mcpserver} + +高階伺服器類別改了名字,模組也跟著改。這是每個 v1 伺服器最先碰到的事,因為舊的 import 路徑是直接移除,而不是已棄用: + +```python +from mcp.server import MCPServer # v1: from mcp.server.fastmcp import FastMCP + +mcp = MCPServer("Demo") # v1: FastMCP("Demo") +``` + +對一個用裝飾器建起來的伺服器來說,這也幾乎就是移植的全部。`@mcp.tool()`、`@mcp.resource()` 和 `@mcp.prompt()` 接受的東西跟 v1 一樣(`@mcp.resource()` 多了一個選用的 `security=` 關鍵字),輸入 schema 仍然從型別提示產生。邊角的部分:`mcp.server.fastmcp.*` 底下的所有東西現在都在 `mcp.server.mcpserver.*` 底下,`ctx.fastmcp` 變成 `ctx.mcp_server`,`get_context()` 移除了(改為宣告一個 `ctx: Context` 參數),例外基底類別 `FastMCPError` 變成 `MCPServerError`。import 對照表請見 **[遷移指南](migration.md#fastmcp-renamed-to-mcpserver)**。 + +### `Resolve`:向使用者要輸入的新方法 {#resolve-the-new-way-to-ask-the-user-for-input} + +工具需要的東西不該全部都來自模型。v2 新增:標註了 `Resolve(fn)` 的工具參數改由你寫的函式填入,模型看不到,而那個函式可以回傳 `Elicit(...)`,把問題擺到使用者面前。這是在呼叫途中向用戶端取得任何東西的首選做法:SDK 會用該連線支援的機制把問題帶過去,對舊版用戶端是即時的徵詢(elicitation)請求,在 2026-07-28 上則是多輪往返(multi-round-trip),因此同一個工具本體兩個世代都能服務。完整說明請見 **[相依性](handlers/dependencies.md)**。 + +!!! note + 需要時另外兩種形式仍然可用:`ctx.elicit()` 對舊版連線上的用戶端依然有效(**[徵詢](handlers/elicitation.md)**),處理函式也可以自己回傳 `InputRequiredResult`,手動驅動每一輪,這也是取樣(sampling)和根目錄(roots)請求在 2026-07-28 上傳遞的方式(**[多輪往返請求](handlers/multi-round-trip.md)**)。 + +### 一等公民的 `Client` {#a-first-class-client} + +v1 交給你的是三層巢狀結構:一個產出原始串流的傳輸 context manager、包在外面的 `ClientSession`,再加上手動呼叫的 `await session.initialize()`。v2 只有一個物件: + +```python title="client.py" hl_lines="14-18" +--8<-- "docs_src/client/tutorial001.py" +``` + +`Client` 接受一個伺服器物件(記憶體內、沒有傳輸,也就是測試的做法)、一個 URL(Streamable HTTP),或任何傳輸 context manager,例如 `stdio_client(...)`。進入 `async with` 就會連線並協商協定版本,不管伺服器講的是哪個世代;之後 `client.server_capabilities` 和 `client.protocol_version` 就直接在那裡,伺服器有表明身分時 `client.server_info` 也在(它現在是 `Implementation | None`,因為 2026 世代的身分是選用的)。在 v1 註冊的取樣和徵詢回呼仍然有效(回呼本體會遇到跟本頁其他地方一樣的 snake_case 屬性改名),現在也會回應 2026 風格的「結果中夾帶請求」(見下文),而且是並行執行,不再一次一個。想要低階介面的人,`ClientSession` 仍在底下,`client.session` 會把它交給你;它也有變動(跑在新的分派器引擎上,自己的部分簽章也改了),所以往下鑽之前先讀 **[遷移指南](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)**。 + +**[用戶端](client/index.md)** 介紹它,**[用戶端傳輸方式](client/transports.md)** 說明三種連線形式,**[用戶端回呼](client/callbacks.md)** 說明回呼本身,**[測試](get-started/testing.md)** 示範取代 v1 `create_connected_server_and_client_session()` 輔助函式的記憶體內模式。 + +### 低階 `Server` 是重寫,不是改名 {#the-low-level-server-was-rebuilt-not-renamed} + +如果你在 JSON-RPC 層工作,這就是 v2 裡「什麼都不一樣了」的部分。下面是同一個單一工具伺服器的兩種寫法;點一下標記看看哪些東西搬了家。 + + + +```python title="v1" +from typing import Any + +import mcp.types as types +from mcp.server.lowlevel import Server + +server = Server("Bookshop") + + +@server.list_tools() # (1)! +async def list_tools() -> list[types.Tool]: + return [ # (2)! + types.Tool( + name="search_books", + description="Search the catalog by title or author.", + inputSchema={ # (3)! + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + }, + ) + ] + + +@server.call_tool() +async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentBlock]: # (4)! + if name != "search_books": + raise ValueError(f"Unknown tool: {name}") # (5)! + ctx = server.request_context # (6)! + return [types.TextContent(type="text", text=f"Found 3 books matching {arguments['query']!r}.")] # (7)! +``` + +1. 處理函式用裝飾器註冊(要加括號呼叫),伺服器存在之後任何時候都可以。 +2. 回傳一個裸的 `list[Tool]`,SDK 會把它包成 `ListToolsResult`。 +3. 欄位在 Python 裡是 camelCase,而且 schema 是**強制套用**的:SDK 會在函式執行前用 jsonschema 對照它驗證 `call_tool` 的引數,所以下面的 `arguments["query"]` 是安全的。 +4. 一個 `call_tool` 處理函式服務所有工具,它收到的是工具名稱和已經驗證過的引數,已解開、永遠不會是 `None`。 +5. v1 工具用引發例外來表示失敗:任何例外都會被攔截,並以 `CallToolResult(isError=True)` 回傳,文字是 `str(e)`,所以呼叫端的模型讀得到這則訊息,也可以重試。 +6. 上下文來自環境中的 ContextVar,在請求途中透過伺服器物件取得。 +7. 裸的內容區塊會替你包成 `CallToolResult`。 + +```python title="v2" +--8<-- "docs_src/whats_new/tutorial001.py" +``` + +1. 欄位現在是 snake_case,而 schema 是**只公告、從不套用**:處理函式執行前沒有任何東西檢查引數。 +2. 每個處理函式形狀都一樣:`async (ctx, params) -> result`。上下文是第一個引數(`ctx.session`、`ctx.request_id`、`ctx.protocol_version` 都在上面);`server.request_context` 就是搬到這裡。 +3. 完整的 `ListToolsResult` 要自己建。回傳裸的 list 現在是伺服器端的 `TypeError`,SDK 不會替你包。 +4. 進來的是有型別的 params(`params.name`、`params.arguments`),出去的是完整的結果。沒有任何東西會替你解開、包裝或轉換。 +5. 同樣的檢查,不同的動詞。這裡如果用 `ValueError`,到模型那邊會變成看不出內容的 `-32603`(見下文),所以刻意的線路錯誤改用 `MCPError` 引發:它會帶著原本的錯誤碼和訊息原封不動地傳過去,而帶這段文字的 `-32602` 正是規格對未知工具的標準回答。 +6. `params.arguments` 可能是 `None`;v1 會在你的程式碼看到之前就把它預設為 `{}`。處理函式前面沒有驗證,這一行是不可或缺的。 +7. 這裡引發的非預期例外會變成**消毒過的**協定錯誤,`-32603` `"Internal server error"`:模型永遠看不到訊息。若是模型應該讀到並做出反應的失敗,就回傳 `CallToolResult(is_error=True, ...)`。 +8. 處理函式是建構子引數,所以伺服器一存在,它的介面就是完整的;`add_request_handler()` 是建構之後的逃生口,也是通往自訂方法的門。 + +這個範例就是模式本身。更一般地說:每個處理函式形狀都一樣,有型別的 params 進、完整的結果型別出;舊的工具引數 jsonschema 檢查拿掉了;例外就是協定錯誤,絕不會是 `is_error=True` 的工具結果;環境中的 `server.request_context` ContextVar 也拿掉了。帶廠商命名空間的自訂方法透過 `add_request_handler(method, params_type, handler)` 成為一等公民,它會在處理函式執行前用你的模型驗證傳入的 params。還有一個 `middleware` 清單(刻意標為暫定)包住每一則傳入訊息,取代大家以前會覆寫的私有 `_handle_*` 方法。 + +在底層,v1 的 `BaseSession` 接收迴圈換成了用戶端和伺服器現在共用的分派器引擎,本頁好幾件事能同時成立靠的就是它:一個 `Server` 物件服務兩個協定世代、`Client(server)` 在處理程序內直接分派而不經 JSON-RPC 封裝、逾時的用戶端請求現在真的會取消伺服器端的處理函式。 + +完整說明請見 **[低階 Server](advanced/low-level-server.md)**;**[遷移指南](migration.md#lowlevel-server-decorator-based-handlers-replaced-with-constructor-on_-params)** 逐一走過每個移除的掛鉤。如果你從沒往下用到 `MCPServer` 以下的層級,這些都與你無關。 + +### 線路型別搬到 `mcp-types`,每個欄位都是 snake_case {#the-wire-types-moved-to-mcp-types-and-every-field-is-snake_case} + +協定型別現在有自己的發行套件 `mcp-types`。它只依賴 pydantic 和 typing-extensions,所以閘道、代理或程式碼產生器不必安裝 HTTP 堆疊就能取用 MCP 線路上的資料形狀:這類專案安裝 `mcp-types`,然後 import `mcp_types`。`mcp` 本身以精確版本依賴那個套件並重新公開它,所以依賴 SDK 的程式碼繼續寫 `import mcp.types as types` 和 `from mcp.types import Tool`(永久的別名,每個名稱都是同一個物件),並且只宣告它唯一真正的相依套件 `mcp`。經驗法則:透過你實際依賴的那個套件來 import。 + +在這些型別上,每個 Python 屬性現在都是 snake_case:`result.is_error`、`tool.input_schema`、`listing.next_cursor`。實際傳輸的 JSON 仍是 camelCase,跟以前完全一樣;只有屬性的拼法變了。另外跟著來的是兩個更嚴格的預設:未知欄位會被忽略而不是原樣往返(額外的東西放進 `_meta`),而且兩端都會用協商好的協定版本驗證流量。改名對照表請見 **[遷移指南](migration.md#field-names-changed-from-camelcase-to-snake_case)**。 + +### 傳輸設定搬到 `run()` {#transport-configuration-moved-to-run} + +`MCPServer(...)` 管的是你的伺服器**是什麼**:名稱、instructions、生命週期、授權。它**怎麼提供服務**現在歸 `run()` 和各個 app 建構器管,`host`、`port`、`stateless_http`、`json_response`、端點路徑和 `transport_security` 都搬到那裡去了(`MCPServer("x", port=9000)` 是 `TypeError`)。多載依傳輸方式各自有型別,所以編輯器會告訴你 `stdio` 接受哪些選項、`streamable-http` 接受哪些。有一項移除值得知道:`mount_path` 沒了;要在前綴底下提供服務,支援的做法是掛載 ASGI 應用程式。 + +**[執行伺服器](run/index.md)** 說明這些選項;**[加入現有的應用程式](run/asgi.md)** 說明掛載。 + +### 不會出現 import 錯誤的行為變更 {#behavior-that-changes-without-an-import-error} + +改名會自己跳出來提醒你。下面這些不會: + +* **同步函式在工作執行緒上執行。** `def` 的工具(或資源、提示詞、解析器)不再阻塞事件迴圈;代價是它的本體不再**在**事件迴圈執行緒上執行,這對綁定執行緒的程式碼有影響。`async def` 處理函式不受影響。**[遷移指南](migration.md#sync-handler-functions-now-run-on-a-worker-thread)**。 +* **在工具裡引發的 `MCPError`(v1 的 `McpError`)現在是協定錯誤。** 模型永遠看不到它。其他所有例外仍然會變成模型讀得到、能做出反應的 `is_error=True` 結果。兩者的分界請見 **[處理錯誤](servers/handling-errors.md)**。 +* **結果送出前會先驗證。** 手動建立、`input_schema` 為 `{}` 的 `Tool` 現在會讓 `tools/list` 失敗(規格要求 `"type": "object"`)。用 `@mcp.tool()` 建的伺服器不會遇到;它們的 schema 是 SDK 寫的。 +* **用戶端會驗證收到的東西。** `list_tools()` 和 `call_tool()` 會用協商好的協定版本檢查伺服器的回答,所以 v1 寬鬆解析還能容忍的不太合規伺服器,現在會引發 `pydantic.ValidationError`。如果連到的是自己無法控制的伺服器,要有心理準備,發現問題的人會是你;細節請見 **[遷移指南](migration.md#client-validates-inbound-traffic-against-the-protocol-schema)**。 +* **URI 範本現在是真正的 RFC 6570。** `{+path}`、`{?query}` 這些都能用,比對是精確的而不是正規表示式那種寬鬆,擷取出的值若含路徑穿越,預設會被拒絕。更嚴格的範本會在裝飾時就失敗,而不是等到第一個請求。**[URI 範本](servers/uri-templates.md)**。 +* **Streamable HTTP 的生命週期只執行一次**,在啟動時,它的狀態由所有工作階段和請求共用。v1 是每個工作階段執行一次,在 `stateless_http=True` 下則是每個請求一次。在生命週期裡建立的連線池和快取因此便宜非常多;以前在那裡取得每連線資源的東西,現在該放進處理函式本體。**[生命週期](handlers/lifespan.md)**。 +* **`mcp dev` 和 `mcp install` 會把它們產生的環境釘在**你安裝的 SDK 版本上。這兩個命令都在全新的 `uv run --with ...` 環境裡執行伺服器,以前那會把 `mcp` 解析成最新的穩定版,而不是你正在開發所用的版本。**[遷移指南](migration.md#mcp-dev-and-mcp-install-pin-the-spawned-environment-to-your-sdk-version)**。 +* **HTTP 用戶端現在是 `httpx2`,不是 `httpx`。** 相依套件的更換改變了程式碼要攔截和傳入的東西(`httpx2.AsyncClient`、`httpx2.ConnectError`),也改變了 TLS 憑證的驗證方式:`httpx2` 透過 `truststore` 以作業系統的信任存放區驗證,而不是 certifi 內附的 CA 清單。大多數環境完全不會察覺;沒有系統 CA 存放區的極簡容器,或只有 certifi 套件包知道的私有 CA,會開始在 TLS 交握時失敗。設定 `SSL_CERT_FILE`/`SSL_CERT_DIR`,或對用戶端傳入 `verify=ssl_context`。**[遷移指南](migration.md#httpx-and-httpx-sse-replaced-by-httpx2)**。 + +### 直接移除 {#removed-outright} + +下面每一項在 **[遷移指南](migration.md)** 裡都有一節: + +* **WebSocket 傳輸**,兩端都是,以及 `mcp[ws]` extra。它從來不是 MCP 規格的一部分。 +* **實驗性的 Tasks** API(`mcp.*.experimental`)。2026-07-28 把 tasks 從核心協定移到官方擴充功能([SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663)),這個 SDK 還沒實作。 +* `mcp.shared.version`、`mcp.shared.progress` 和 `mcp.shared.session`(連同 v1 `message_handler` 型別註記會 import 的 `RequestResponder` 殘留類別)作為 import 路徑。(`mcp.types` **沒有**移除:它保留為獨立 `mcp_types` 套件的永久別名。) +* 已棄用的 `streamablehttp_client` 拼法,以及 `streamable_http_client` 的 `get_session_id` 回呼(它現在正好 yield 兩個串流)。 +* `McpError`,改名為 **`MCPError`**,有直接的 `(code, message, data)` 建構子。 +* `MCPServer.get_context()`、`mount_path=`,以及低階 `Server` 的裝飾器方法、ContextVar 和處理函式 dict。 + +## 協定:從 2025-11-25 到 2026-07-28 {#the-protocol-2025-11-25-to-2026-07-28} + +v2 實作 2026-07-28 修訂版,而且**兩個**修訂版同時服務:同一個 `streamable_http_app()`(和同一個 stdio 伺服器)既回應 2025 世代用戶端的 `initialize`,也回應 2026 世代用戶端的請求,不用設定任何東西、不用切任何旗標、不用分開部署。服務新修訂版不會把停在舊版的用戶端丟下。接下來說的是新修訂版本身改了什麼。 + +### 沒有交握,沒有工作階段 {#no-handshake-no-session} + +2026-07-28 的用戶端不會先開連線、協商、然後才講話。每個請求都在 `_meta` 裡帶著協定版本、用戶端資訊和用戶端能力,而唯一的探索呼叫 `server/discover` 就是跟其他請求一樣的普通請求。`Client` 預設就會做對的事:它探測一次 `server/discover`,如果伺服器比較舊,就退回 `initialize` 交握。 + +在 Streamable HTTP 上,2026 路徑沒有 `Mcp-Session-Id`,這是維運面的頭條:**沒有任何東西把現代請求綁在某個 worker 上**,所以普通輪詢式負載平衡器後面的任何副本都能回應。老實說有兩個但書。2025 世代的用戶端(今天大多數用戶端都是)仍然會開工作階段,在 v1 需要什麼黏著性現在還是需要;對它們來說什麼都沒變。另外,**多輪往返**的重試唯一必須跨 worker 帶著走的,是密封過的 `request_state`,它的預設金鑰是每個處理程序各自產生的,所以橫向擴展的部署要傳入 `RequestStateSecurity(keys=[...])`。(`stateless_http=True` 與此無關:它只影響怎麼服務 2025 世代的用戶端,2026 的流量從不讀它;如果你在 v1 就設了,什麼都不會變。) + +**[協定版本](protocol-versions.md)** 是這件事的用戶端那一面,**[部署與擴展](run/deploy.md)** 是維運人員的檢查清單(Host 允許清單、`request_state` 金鑰、跨副本的通知),**[服務舊版用戶端](run/legacy-clients.md)** 則是兩個世代同時服務的完整說明。 + +### 伺服器不能呼叫用戶端:多輪往返請求 {#the-server-cannot-call-the-client-multi-round-trip-requests} + +所有由伺服器發起的請求在 2026-07-28 都拿掉了:推送式徵詢、取樣、`roots/list`。2026 連線上沒有供它們使用的通道,所以 `ctx.elicit()` 和 `ctx.session.create_message()` 在那裡會以 `NoBackChannelError` 失敗(對舊版用戶端仍然有效)。 + +替代方案把呼叫反過來。需要向使用者要東西的工具**回傳**那個問題(`InputRequiredResult`),用戶端用一直都有的那些回呼回答它,然後帶著答案重試這次呼叫。那個迴圈 `Client` 會替你驅動。在伺服器上很少需要自己建那個結果,因為 **[相依性](handlers/dependencies.md)** 會做:用 `Resolve(ask_quantity)` 標註一個參數,其中 `ask_quantity` 是你寫的普通函式,SDK 就會用連線支援的機制去問,在舊版工作階段上是即時的徵詢請求,在 2026 上是多輪往返。一個工具本體,兩個世代: + +```python title="dual_era.py" hl_lines="24 37-38" +--8<-- "docs_src/legacy_clients/tutorial001.py" +``` + +這個檔案把整個賣點集中在一處:一個伺服器、一個以 `Resolve` 為後盾的工具,以及一個舊版用戶端加一個現代用戶端都拿到答案,全在記憶體內。**[多輪往返請求](handlers/multi-round-trip.md)** 解釋機制(包括 SDK 替你密封和驗證的 `request_state`);**[徵詢](handlers/elicitation.md)** 說明怎麼問。 + +!!! warning "這是移植後的 v1 伺服器唯一會改變行為的地方" + 你自己的測試最先碰到:`Client(mcp)` 對 v2 伺服器預設協商 2026-07-28,所以呼叫 `ctx.elicit()` 的工具在 v1 通過的測試裡會失敗。把問題搬進 `Resolve(...)` 參數(跨世代可攜),或者如果真的想要推送行為,就把測試用戶端釘在 `mode="legacy"`。 + +### 根目錄、取樣和協定記錄已棄用;`ping` 已移除 {#roots-sampling-and-protocol-logging-are-deprecated-ping-is-removed} + +[SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) 在每個協定版本上棄用三整項**能力**:根目錄、取樣,以及 MCP 層級的記錄(`ctx.info()` 那一類)。這跟上面缺少反向通道(back-channel)是不同的軸線;已棄用只是建議性質,對 2025 世代的工作階段一切照常運作,在線路上什麼都沒變。你會注意到的是 `MCPDeprecationWarning`,它是 `UserWarning`,所以預設會印出來;升級後第一次 `ctx.info(...)` 就會這麼告訴你。 + +`ping` 更嚴格:是從協定移除,不是棄用。已棄用功能的兩個獨立方法在 2026-07-28 也以同樣方式移除,`logging/setLevel` 和用戶端的 `notifications/roots/list_changed`,而進度通知現在只有伺服器到用戶端這個方向。 + +**[已棄用的功能](deprecated.md)** 有完整的表格、每一項的替代做法,以及在服務舊版用戶端期間想讓記錄安靜下來時可用的單行篩選器。 + +### 變更通知變成一條串流 {#change-notifications-become-one-stream} + +在 2026-07-28,獨立的 HTTP GET 串流和 `resources/subscribe` 由 `subscriptions/listen` 取代:用戶端開一條長效串流,並指名想要的通知種類。`MCPServer` 預設就會服務它;用 `await ctx.notify_resource_updated(uri)`(以及 `notify_tools_changed()` 等等)發布,中介軟體可以依呼叫端拒絕 listen 請求,多副本部署則接上共用的 `SubscriptionBus`。在用戶端,`async with client.listen(...)` 開啟串流:篩選條件以關鍵字引數傳入,回來的是有型別的變更事件,`sub.honored` 則是伺服器同意傳送的子集。 + +**[訂閱](handlers/subscriptions.md)** 說明發布和服務,**[用戶端那邊對應的頁面](client/subscriptions.md)** 說明監看的一端,**[部署與擴展](run/deploy.md)** 說明 bus。 + +### 其餘的,快速帶過 {#the-rest-quickly} + +* **身分是選用的、逐訊息的中繼資料。** 請求端的 `clientInfo` `_meta` 鍵是選用的(必要的一對是 `protocolVersion` + `clientCapabilities`),而 `serverInfo` 搬出了 `server/discover` 的結果本體:伺服器改為把它蓋進每個 2026 世代結果的 `_meta`([spec #3002](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3002))。SDK 一定會蓋;伺服器沒有表明身分時(例如中介軟體拿掉了那個鍵),`client.server_info` 是 `None`。**[低階 Server](advanced/low-level-server.md)** 展示線路上的這個戳記。 +* **請求不必解析本體就能路由。** 現代 HTTP 請求帶有 `Mcp-Method`(三個類似工具的呼叫還帶 `Mcp-Name`);標註了 `x-mcp-header` 的工具輸入 schema 屬性會鏡射到 `Mcp-Param-*` 標頭,並由伺服器交叉核對([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243))。閘道和限流器光靠標頭就能路由;規則請見 **[遷移指南](migration.md#servers-validate-mcp-param-headers-against-the-request-body-sep-2243)**。 +* **結果帶有快取提示。** 列表和讀取結果會宣告 `ttlMs` 和 `cacheScope`([SEP-2549](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549));用 `cache_hints=` 逐方法設定,`Client` 則用內建的回應快取遵守它們。不送提示的伺服器(所有 2026 以前的伺服器)看到的是一模一樣、沒有快取的流量。**[快取提示](client/caching.md)**。 +* **擴充功能是一等公民。** 伺服器和用戶端在反向 DNS 識別碼底下宣告選用的能力組合([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133));內建的 `Apps` 擴充功能(MCP Apps)是參考範例。**[擴充功能](advanced/extensions.md)** 和 **[MCP Apps](advanced/apps.md)**。 +* **錯誤碼標準化了。** 找不到的資源是 `-32602`,URI 放在 `error.data`,新的規格保留碼則是 `-32020`(標頭不符)、`-32021`(缺少必要能力)和 `-32022`(不支援的協定版本)。**[疑難排解](troubleshooting.md)** 以確切的訊息為索引。 +* **授權更不容易用錯了。** 用戶端會驗證隨授權碼回傳的 `iss`([RFC 9207](https://datatracker.ietf.org/doc/html/rfc9207);`callback_handler` 現在回傳 `AuthorizationCodeResult`),註冊時送出 `application_type`,而且絕不會對不同的授權伺服器重送憑證。企業那一角的新東西:[SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) 身分斷言流程。**[遷移指南](migration.md)** 列出每一項 OAuth 變更;完整說明請見 **[用戶端的 OAuth](client/oauth-clients.md)** 和 **[身分斷言](client/identity-assertion.md)**。 +* **每個伺服器都可追蹤。** OpenTelemetry 以中介軟體的形式預設啟用:每個請求都有一個伺服器 span,在處理程序設定 exporter 之前完全沒有成本。兩端都跑 SDK 時,用戶端還會在 `_meta` 裡傳播 W3C trace context,所以追蹤會接起來。**[OpenTelemetry](run/opentelemetry.md)**。 + +## 從 v1 升級? {#upgrading-from-v1} + +* **[遷移指南](migration.md)** 是完整、精確的修改清單;本頁說的是為什麼。 +* **v1.x 哪裡都不會去。** 它轉入維護,持續收到重大修正和安全性修補,2026-07-28 規格發布也沒有任何地方會弄壞它;它的說明文件在 [/v1/](https://py.sdk.modelcontextprotocol.io/v1/)。如果你發布的函式庫依賴 `mcp` 且還沒準備好遷移,保留一個上限(例如 `mcp>=1.28,<2`),讓未釘版本的解析停在 1.x。 +* 哪裡卡住、看不懂或壞了?**[回報 v2 意見](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml)**;每一則都會有人讀。 diff --git a/i18n/zh/glossary.json b/i18n/zh/glossary.json new file mode 100644 index 0000000000..d9bbf5582b --- /dev/null +++ b/i18n/zh/glossary.json @@ -0,0 +1,209 @@ +{ + "keep": [ + "MCP", + "Model Context Protocol", + "MCPServer", + "FastMCP", + "ClientSession", + "Context", + "ctx", + "stdio", + "Streamable HTTP", + "SSE", + "JSON-RPC", + "JSON", + "OAuth", + "PKCE", + "JWT", + "CIMD", + "HTTP", + "HTTPS", + "TLS", + "CORS", + "URI", + "URL", + "ASGI", + "WebSocket", + "API", + "SDK", + "CLI", + "IDE", + "LLM", + "SEP", + "RFC", + "Python", + "TypeScript", + "Node.js", + "PyPI", + "Pydantic", + "Starlette", + "FastAPI", + "uvicorn", + "httpx", + "anyio", + "asyncio", + "trio", + "pytest", + "OpenTelemetry", + "Inspector", + "Claude", + "GitHub", + "VS Code", + "Windows", + "macOS", + "Linux", + "llms.txt", + "2026-07-28", + "2025-11-25", + "2025-06-18", + "2025-03-26" + ], + "terms": [ + { + "source": "tool", + "target": "工具", + "note": "MCP protocol noun (a server exposes tools). Wire identifiers such as `tools/call` and `tools/list` are code and stay Latin. Provisional pending native review." + }, + { + "source": "resource", + "target": "资源", + "note": "MCP protocol noun; \"resource template\" → 资源模板. `resources/read` stays Latin. Provisional pending native review." + }, + { + "source": "prompt", + "target": "提示词", + "note": "The MCP feature: a reusable prompt a server exposes (`prompts/get` stays Latin). Not the bare 提示, which reads as \"hint\" and is the standard rendering of the `tip` admonition title. Provisional pending native review." + }, + { + "source": "sampling", + "target": "采样", + "note": "The (deprecated) client feature that lets a server borrow the client's model. Gloss the English on first use per page: 采样(sampling). The `sampling` capability key and `sampling/createMessage` stay Latin. 抽样/取样 mean statistical sampling and are the wrong sense here.", + "avoid": ["抽样", "取样"] + }, + { + "source": "roots", + "target": "根目录", + "note": "The (deprecated) client feature listing workspace folders; a `Root` object in code font stays Latin. Gloss the English on first use per page: 根目录(roots). Never the bare 根 and never 根节点 (a tree node). Provisional pending native review.", + "avoid": ["根节点"] + }, + { + "source": "elicitation", + "target": "征询", + "note": "OPEN QUESTION for native review: existing Chinese material renders this concept as 引导, 引导获取, 询问 or 征询, and even our own drafts disagree between 征询 and 引导. Provisionally pinned to 征询, glossed with the English on first use per page: 征询(elicitation). Use it consistently within a page whichever way the review settles. `elicitation/create` and the `Elicit` class stay Latin." + }, + { + "source": "capability", + "target": "能力", + "note": "A negotiated protocol capability (声明了 `sampling` 能力). The `capabilities` field and keys such as `sampling.tools` stay Latin. Not 功能, which means \"feature\" — the corpus uses \"feature\" as a separate word. Provisional pending native review." + }, + { + "source": "transport", + "target": "传输", + "note": "As a countable noun use 传输方式 (\"three transports\" → 三种传输方式). The transport names stdio, Streamable HTTP and SSE stay in English. Provisional pending native review." + }, + { + "source": "session", + "target": "会话", + "note": "An MCP session (the negotiated connection state); `session` objects in code font stay Latin. Not 会议 (a meeting). Provisional pending native review.", + "avoid": ["会议"] + }, + { + "source": "handler", + "target": "处理函数", + "note": "The tool, resource or prompt function you register (nav section \"Inside your handler\" → 在处理函数内部). Open question for native review: 处理器 is the competing rendering; pick one and never mix within a page. Provisional." + }, + { + "source": "dependency", + "target": "依赖", + "note": "The SDK's dependency-injection feature (the \"Dependencies\" page); use 依赖项 where a countable noun is needed. The `Resolve` marker class stays Latin. Provisional pending native review." + }, + { + "source": "client", + "target": "客户端", + "note": "An MCP client, and the client side of a connection. The `Client` class name stays Latin in code font. Not 客户 (a customer). Provisional pending native review." + }, + { + "source": "server", + "target": "服务器", + "note": "An MCP server (the program you build). \"server-side\" → 服务器端. The low-level `Server` class stays Latin in code font. Not 伺服器 (the zh-TW term); do not mix in 服务端 for the same noun. Provisional pending native review.", + "avoid": ["伺服器"] + }, + { + "source": "host", + "target": "宿主", + "note": "The MCP host: the application that embeds the client and drives the model. Not 主机 (a machine or hostname). Provisional pending native review." + }, + { + "source": "resolver", + "target": "解析器", + "note": "The SDK's dependency-resolver mechanism (\"an elicitation resolver\" → elicitation 解析器 in glossary terms: 征询解析器). The `Resolve` class stays Latin. Provisional pending native review, together with the handler entry." + }, + { + "source": "lifespan", + "target": "生命周期", + "note": "The server's startup/shutdown scope (the \"Lifespan\" page). The `lifespan` parameter name stays Latin in code font. 寿命 is the biological sense and is wrong here. Provisional pending native review." + }, + { + "source": "callback", + "target": "回调", + "note": "Client callbacks; parameter names such as `sampling_callback` stay Latin. Provisional pending native review." + }, + { + "source": "notification", + "target": "通知", + "note": "A JSON-RPC notification (a message that expects no response); method strings such as `notifications/tools/list_changed` stay Latin. Provisional pending native review." + }, + { + "source": "back-channel", + "target": "反向通道", + "note": "The server-to-client request channel that exists only on 2025-era, non-stateless connections. Provisional coinage: gloss the English on first use per page — 反向通道(back-channel). Pending native review." + }, + { + "source": "wire", + "target": "线路", + "note": "The corpus's light metaphor for the transport stream (\"on the wire\" → 在线路上; \"nothing changes on the wire\" → 线路上没有任何变化). Never 电线 (a physical cable). Provisional pending native review.", + "avoid": ["电线"] + }, + { + "source": "deprecated", + "target": "已弃用", + "note": "Also \"deprecation warning\" → 弃用警告 and \"X is deprecated\" → X 已弃用. Pin the 弃用 family throughout; do not switch to 废弃 or 淘汰 for the same concept. Provisional pending native review." + }, + { + "source": "context", + "target": "上下文", + "note": "The generic lower-case word (\"provide context to LLMs\" → 为 LLM 提供上下文). The capitalised `Context` is the SDK object injected as `ctx`; both are on the keep list and stay in English in prose (\"The Context\" → Context). Provisional pending native review." + }, + { + "source": "multi-round-trip", + "target": "多轮往返", + "note": "The 2026-07-28 request pattern (\"Multi-round-trip requests\" → 多轮往返请求); \"round-trip\" alone → 往返. Provisional coinage: gloss the English on first use per page — 多轮往返(multi-round-trip). Pending native review." + }, + { + "source": "you", + "target": "你", + "note": "The register rule from instructions.md made machine-checkable: 您 must never appear on a page. Prefer dropping the pronoun; when one is needed it is 你.", + "avoid": ["您"] + }, + { + "source": "Get started", + "target": "快速开始", + "note": "The nav section that opens the guide, and the title of its index page. \"First steps\" is a separate page inside that section (第一步), so the two need distinct renderings or the sidebar shows the same title twice. Provisional pending native review. 入门 is the alternative for the section." + }, + { + "source": "First steps", + "target": "第一步", + "note": "The tutorial page inside the \"Get started\" section; never reuse this rendering for the section itself (see that entry). Provisional pending native review." + }, + { + "source": "Recap", + "target": "回顾", + "note": "Recurring section heading that closes most pages; one rendering everywhere, not 回顾 on some pages and 小结 on others. Provisional pending native review." + }, + { + "source": "Try it", + "target": "试一试", + "note": "Recurring section heading above a runnable example; one rendering everywhere. Provisional pending native review." + } + ] +} diff --git a/i18n/zh/instructions.md b/i18n/zh/instructions.md new file mode 100644 index 0000000000..e3c8af4d08 --- /dev/null +++ b/i18n/zh/instructions.md @@ -0,0 +1,158 @@ +# Simplified Chinese (zh) — translation instructions + +Target language: Simplified Chinese (简体中文), directory and URL code +`zh`, page language tag `zh-Hans`. This file is sent verbatim with every +translation request for this language, on top of the shared translation rules +in `../general-prompt.md`. The termbase in `glossary.json` is sent alongside +it and wins any terminology conflict with this file. + +## 1. Register + +Write the casual-neutral written register that Chinese developer +documentation uses: plain, matter-of-fact, and even. + +- Address the reader as 你. Never use the honorific 您, and never mix the + two. The rule holds in body prose, headings, admonition titles, table + cells and link text. +- Prefer no pronoun at all when the sentence stays clear — Chinese + instructions read naturally without a subject: "You can pass a schema" → + 可以传入一个模式. Reach for 你 only where the sentence would otherwise be + ambiguous about who acts. +- Steps and instructions are bare imperatives without a subject: + "Run the server" → 运行服务器, not 请您运行服务器. A single 请 is fine where + it reads natural; a 请 in front of every step is not. +- The register is uniform across a page. A page that drifts between 你 and + 您, or between plain and formal sentence endings, is wrong even when each + sentence is acceptable on its own. + +## 2. Voice + +Aim for the voice of an experienced Chinese-speaking engineer explaining a +library to a colleague: warm, direct, professional, compact. The English is +built on short declarative payoff sentences ("That's a complete MCP +server."); keep them short — 这就是一个完整的 MCP 服务器。 + +Do: + +- Follow Chinese word order and rhythm. Break one long English sentence + into two Chinese ones instead of mirroring its clause structure. +- Use concrete verbs (运行, 传入, 返回, 声明, 阻塞) rather than nominal chains. +- Keep the source's directness. Where the English says "don't", the + Chinese says 不要, not a hedge like 也许可以考虑避免. + +Avoid — these are the marks of a machine or customer-service translation: + +- 您, and its whole register: 温馨提示, 亲, 敬请, 感谢您的耐心等待. +- Formal padding: 进行……操作, 对……进行处理, and 可能像下面这样, where a plain + 是这样的 does the job. +- English-shaped Chinese: possessive chains (你的服务器的工具的模式), 被 passives + where a topic-comment sentence is natural, and a translated connective + (然而, 因此, 此外) at the start of every sentence. +- Marketing hype and internet slang: 神器, 保姆级, 给力, 强大到没朋友. + +## 3. Humour and idioms + +The English is friendly and dry rather than jokey: short payoff sentences, +a few stock phrases, the rare emoji. Carry the friendliness; recast the +idioms. + +- Never translate a pun, idiom or aside literally. Say what it means as a + short, natural Chinese sentence in the same register. If an aside carries + no information you may drop it — but never drop a technical caveat that + happens to be phrased lightly. +- Recurring English tags get fixed renderings: "**[X](…)** has the whole + story" / "The whole story is in **[X](…)**" → 详见 **[X](…)**; + "That's the whole API." / "That's the whole protocol." → 整个 API 就这些。 + / 整个协议就是这样。; "That's it. It's just Python." → 就这样,只是普通的 + Python。 +- Idioms take the plain meaning, not the picture: "Out of the box the app + answers **only** requests addressed to localhost." → 默认情况下,这个应用 + **只**响应发往 localhost 的请求。— not the literal 开箱即用地. +- Emoji: keep the source's rare, deliberately placed emoji exactly where + they are — two payoff lines end in ✨ ("You get `3` back. ✨"). Never + add new ones. +- Exclamation marks are rare in Chinese technical prose and the English + hardly uses them; do not add one to a plain payoff sentence. + +Worked examples (source → good / bad): + +- "You get `3` back. ✨" → good: 返回值是 `3`。✨ / bad: 你会得到3!✨ + (missing Han–Latin spacing, added exclamation). +- "Give a parameter a default value and it stops being required. That's + it. It's just Python." → good: 给参数设一个默认值,它就不再是必填参数。 + 就这样,只是普通的 Python。/ bad: 给一个参数一个默认值,然后它就停止是必需的了。 + 就是它。它只是Python而已!(English-shaped 它 chain, missing Han–Latin + spacing, added exclamation). + +## 4. Typography + +- Chinese prose takes full-width punctuation: ,。:;!?、()“” with ‘’ + nested inside “”, the dash —— and the ellipsis ……. Punctuation inside + code spans, code blocks, commands, URLs and quoted English text stays + half-width and untouched. +- Enumerations in prose use the enumeration comma 、: "a, b, and c" → + a、b 和 c, not a,b,和 c. +- Put one half-width space between Han characters and any run of Latin + letters or digits (使用 Streamable HTTP 传输; 需要 Python 3.10+); put no + space between a full-width punctuation mark and adjacent Latin text + (配置好 stdio。). Keep the spaces around Markdown markers (`**…**`, + links) exactly as the source has them. +- No italics in Chinese text. Where the source italicises a word for + emphasis, use **bold**; where the source italicises an example utterance + or a hypothetical question the user might see, wrap it in “” instead. + Keep bold on the same words the source bolds — a bolded negation + ("**not**" → "**不是**" / "**不会**") stays bold. +- Digits stay half-width Arabic numerals. Protocol revision strings such + as `2026-07-28` and `2025-11-25` are identifiers, copied byte-for-byte — + never 2026年7月28日, never 2026/07/28. Other dates keep the source's format. +- Numbers and units: half-width digits with a space before a Latin unit + (10 MB); % and ° attach with no space; a Chinese unit needs no space + (5 秒). +- Line breaks: never put a newline between two Chinese characters (Han or + full-width punctuation), not even after 。 — the renderer turns it into a + stray space. Where the English wraps a paragraph, list item or admonition + body over several lines, or gives each sentence its own line, write the + Chinese on one line, sentence after sentence; block structure and + indentation otherwise stay as in the source. The home page's opening + note puts "New to v2…", "Still on v1.x?…" and "Something rough or + confusing?…" on three lines; in Chinese that body is the single indented + line 刚接触 v2……破坏性变更。还在用 v1.x?……。哪里不顺手或看不明白?…… and + three indented lines there are wrong. A prose line that ends in a Chinese + character followed by a line of the same block that starts with one is + always a defect to fix — join the two. + +## 5. Terminology pointer + +The termbase is `glossary.json` next to this file. It is injected into the +prompt separately and its renderings override anything written here. This +section only fixes the conventions the glossary assumes: + +- Terms in the glossary's keep list, and any other English word left in + Latin script, are copied exactly as spelled and always in the singular, + with no article and no English plural "s": "the URIs" → URI, "children" → + child. They are never transliterated or re-cased; the spacing rule in §4 + sets them off from the surrounding Han text. +- Everything in code font, plus API names, class, function and parameter + names, protocol method and message strings (`tools/call`, + `notifications/...`), header names, error codes, SEP numbers and product + names, stays in Latin script inline. A glossary term used as a code-font + identifier stays Latin even though its prose noun is translated: + "the `sampling` capability" → `sampling` 能力. +- Text quoted from what the example code prints or displays — an output + line, a log message, a UI label — stays exactly as the code emits it + (usually English), in or out of code font. +- First-use gloss: a translated MCP concept the reader may need to map back + to the English specification carries the English in full-width parentheses + on its first occurrence on a page — 采样(sampling) — and appears alone + after that. Each glossary entry's note says whether the term takes the + gloss. +- One rendering per term per page: the glossary target, every time. Where an + entry's note marks the choice as open or provisional, still use the listed + target consistently rather than picking per sentence. + +## 6. Provisional note + +The register, voice and terminology decisions above are provisional, +pending review by native Chinese-speaking readers. To propose a change, edit +this file or `glossary.json` in a pull request; never edit the generated +pages under `pages/`, which the next translation run overwrites. diff --git a/i18n/zh/notices.md b/i18n/zh/notices.md new file mode 100644 index 0000000000..817f93d7a0 --- /dev/null +++ b/i18n/zh/notices.md @@ -0,0 +1,20 @@ +--- +translation: + sections: [aff1b3e872b7876a, 4d80558ad052d586, 0bb81f1e62062d26, d5c35dcec50156bc] + tool: 1 +--- +# 翻译说明 {#translation-notices} + +翻译版文档站的每一页顶部都会显示以下说明之一。 + +## 机器翻译 {#translated} + +本页由英文文档自动翻译而来,以[英文页面](ENGLISH_PAGE)为准。如果有读起来不对的地方,[翻译](TRANSLATIONS_PAGE)页面说明了如何反馈。 + +## 译文落后于英文页面 {#outdated} + +英文页面在本译文生成之后有过改动,因此部分内容可能已过时。如有疑问,请阅读[英文页面](ENGLISH_PAGE);[翻译](TRANSLATIONS_PAGE)页面说明了翻译版文档的运作方式。 + +## 以英文显示 {#english} + +本页目前没有可用的译文,因此显示的是英文原文。[翻译](TRANSLATIONS_PAGE)页面说明了翻译版文档的运作方式。 diff --git a/i18n/zh/pages/advanced/apps.md b/i18n/zh/pages/advanced/apps.md new file mode 100644 index 0000000000..f3bb1d4f9a --- /dev/null +++ b/i18n/zh/pages/advanced/apps.md @@ -0,0 +1,121 @@ +--- +translation: + sections: [0355618e5f4d5fe4, 1821eaf50f2d0b64, 82e0b28ebd3abf5a, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487] + tool: 1 +--- +# MCP Apps {#mcp-apps} + +**MCP App** 是带界面的工具:除了返回数据,这个工具还指向一个 HTML 文档,由宿主渲染成可交互的界面。 + +两个部分,永远是两个部分: + +1. **一个工具**,负责干活并返回数据,和其他工具一样。 +2. **一个 `ui://` 资源**,包含宿主为它展示的 HTML。 + +工具通过 `_meta.ui.resourceUri` 引用这个资源。宿主用 `resources/read` 获取它,在**沙箱化的 iframe** 里渲染,再通过 `postMessage` 把工具的结果推送进 iframe。你的服务器从不收发任何 `ui/*` 消息:那些流量只在宿主和 iframe 之间往来。你提供一个工具和一份 HTML 文档,展示的事由宿主包办。 + +SDK 把它作为内置的 `Apps` 扩展(`io.modelcontextprotocol/ui`)提供。如果还不熟悉[扩展](extensions.md),先浏览一下那一页。一分钟就够,然后回来。 + +## 一个带界面的时钟 {#a-clock-with-a-face} + +```python title="server.py" hl_lines="19 22 30 32" +--8<-- "docs_src/apps/tutorial001.py" +``` + +四步: + +* `Apps()`:一个实例容纳所有绑定 UI 的工具及其资源。 +* `@apps.tool(resource_uri="ui://clock/app.html")`:一个普通工具,外加 `_meta.ui.resourceUri` 标记。`@mcp.tool()` 接受的所有参数(name、title、description……)都会原样传递。 +* `apps.add_html_resource("ui://clock/app.html", CLOCK_HTML)`:与之对应的资源,以 `text/html;profile=mcp-app` 提供。正是这个 MIME 类型告诉宿主“这是一个 app,渲染它”。 +* `MCPServer("clock", extensions=[apps])`:选择启用。服务器现在会在 `capabilities.extensions` 下声明 `io.modelcontextprotocol/ui`。 + +HTML 本身监听宿主的 `postMessage` 并显示结果。真正的应用请在 HTML 里使用官方的 [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps) 浏览器 SDK。它提供 `ontoolresult`、`callServerTool`、`getHostContext` 和 `onhostcontextchanged`,不用再处理原始的 message 事件。 + +## 优雅降级 {#graceful-degradation} + +不是每个客户端都能渲染 app。这对你意味着什么,规范说得很直白: + +> 即使有 UI 可用,工具也**必须**返回有意义的 `content` 数组。 + +模型读的是 `content`;iframe 是给人看的。支持 UI 的宿主照样会把文本结果交给模型,而纯文本客户端**只**拿到那部分。所以标准模式是一个工具、两种回答。再看一遍 `get_time`: + +```python title="server.py" hl_lines="23-27" +--8<-- "docs_src/apps/tutorial001.py" +``` + +只有当客户端声明了 `io.modelcontextprotocol/ui` 扩展,**并且**在其 `mimeTypes` 设置里列出了 `text/html;profile=mcp-app` 时,`client_supports_apps(ctx)` 才为 `True`。这个字段是必填的,省略它的客户端不算数。同一文件里的 `main()` 声明的正是这些:协商中客户端的那一半,于是富结果就返回了。 + +!!! warning + 绝不要把 `"[Rendered UI]"` 这样的占位符当作唯一的内容返回。如果回退文本没用,这个工具对所有纯文本客户端乃至模型本身就都没用。把那句话写出来。 + +## 给 iframe 上锁 {#locking-the-iframe-down} + +安全相关的元数据放在资源一侧:iframe 可以加载什么、想要哪些浏览器权限、希望被怎样嵌入: + +```python title="server.py" hl_lines="9 19-22" +--8<-- "docs_src/apps/tutorial002.py" +``` + +`csp` 和 `permissions` 是**向宿主提出的请求**,不是服务器的行为。宿主据此构建 iframe 的 Content-Security-Policy 和 Permissions-Policy,也可以拒绝。在 JS 里做特性检测,不要假定已经获准。 + +`ResourceCsp` 逐字段说明(Python 名、线路上的键、宿主拿它做什么): + +| Python | 线路(`_meta.ui.csp`) | 控制 | +|---|---|---| +| `connect_domains` | `connectDomains` | `connect-src`:`fetch`/XHR 可以访问哪里 | +| `resource_domains` | `resourceDomains` | `img-src`、`style-src`……:静态资源 | +| `frame_domains` | `frameDomains` | `frame-src`:嵌套的 iframe | +| `base_uri_domains` | `baseUriDomains` | `base-uri`:`` 可以指向哪里 | + +`ResourcePermissions`:每个字段为 iframe 请求一项浏览器权限。 + +| Python | 线路(`_meta.ui.permissions`) | +|---|---| +| `camera` | `camera` | +| `microphone` | `microphone` | +| `geolocation` | `geolocation` | +| `clipboard_write` | `clipboardWrite` | + +!!! note + CSP 和权限放在**资源**上,绝不放在工具上。规范的工具元数据里没有它们的位置,放在那里宿主也会忽略。SDK 让这个错误根本无从表达:`@apps.tool()` 压根没有 `csp` 参数。 + +### 可见性 {#visibility} + +工具上的 `visibility=["app"]` 表示“这是给 iframe 用的,不是给模型用的”: + +* `"model"`:模型可以调用它。 +* `"app"`:iframe 可以调用它(通过 `callServerTool`)。 +* 省略:两者都可以,这也是默认值。 + +过滤是**宿主**的事。服务器在 `tools/list` 里照常列出仅限 app 的工具;宿主负责对模型隐藏它们。不要在服务器端过滤。 + +## SDK 强制执行的规则 {#the-rules-the-sdk-enforces} + +这些都会在启动时失败,而不是在生产环境里: + +* 不是 `ui://...` 的 `resource_uri` 或资源 URI,在装饰/注册时抛出 `ValueError`。 +* 绑定到某个 URI 却**没有对应的已注册资源**的工具,在 `MCPServer(extensions=[apps])` 消费这个扩展时抛出 `ValueError`。一个声明了 HTML、却在 `resources/read` 上 404 的工具属于配置错误,所以直接拒绝构造。 +* 在 `@apps.tool()` 上传入 `meta={"ui": ...}` 会抛出 `ValueError`。`_meta["ui"]` 归装饰器管;用 `resource_uri=` 和 `visibility=` 来表达。其他 `meta=` 键可以正常一并合并。 + +TypeScript 的 ext-apps SDK 和 FastMCP 目前都不检查这些;我们宁愿你先于宿主发现问题。 + +## 内联 HTML 之外 {#beyond-inline-html} + +`add_html_resource` 覆盖常见情况:一段 HTML 字符串。其他情况,比如磁盘上的 HTML 或生成的内容,自己构建资源再交给它: + +```python title="server.py" hl_lines="12 18" +--8<-- "docs_src/apps/tutorial003.py" +``` + +资源没有显式设置 MIME 类型时,`add_resource` 会填上 `text/html;profile=mcp-app`;显式设置了不匹配的类型则会拒绝:用其他任何 MIME 类型的 `ui://` 资源,没有宿主会渲染。 + +!!! tip + 目标宿主是 GA 之前的版本,还在读取已弃用的扁平键 `_meta["ui/resourceUri"]`?自己合并进去:`@apps.tool(resource_uri="ui://x", meta={"ui/resourceUri": "ui://x"})`。嵌套的 `ui` 对象才是规范规定的形态;扁平键正在退出。 + +## 运行看看 {#see-it-run} + +`examples/stories/` 里的 `apps` story 就是本页的可运行版本,由一对程序组成:一个带有绑定 UI 的时钟工具的服务器,和一个协商 Apps、读取工具的 `_meta.ui.resourceUri`、获取 HTML 并调用工具的客户端。 + +```bash +uv run python -m stories.apps.client +``` diff --git a/i18n/zh/pages/advanced/extensions.md b/i18n/zh/pages/advanced/extensions.md new file mode 100644 index 0000000000..13be45f45d --- /dev/null +++ b/i18n/zh/pages/advanced/extensions.md @@ -0,0 +1,172 @@ +--- +translation: + sections: [05891e7cc1938a13, b3c01a6af28c51ee, 7ffc91f5e38bdfe0, 717d3f235a8333a7, f471a13b2fe5d737, ed6af2df4b656dff] + tool: 1 +--- +# 扩展 {#extensions} + +**扩展**是一组归在同一个标识符之下、需要主动启用的 MCP 行为。 + +在服务器上,它可以贡献工具、资源和新的请求方法,还可以包裹 `tools/call`。在客户端上,它可以认领额外的 `tools/call` 结果形态,并观察厂商通知。两端各自在自己的 `capabilities.extensions` 下声明,对没有要求它的人来说一切照旧。这就是约定([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)),它只有一条铁律:**扩展默认关闭**。 + +## 使用扩展 {#using-an-extension} + +在构造时传入实例: + +```python title="server.py" +--8<-- "docs_src/extensions/tutorial001.py" +``` + +完成。服务器现在会在 `capabilities.extensions` 下声明 `io.modelcontextprotocol/ui`,并提供该扩展贡献的一切。 + +`Apps` 是内置的参考扩展,它有自己的页面:**[MCP Apps](apps.md)**。 + +!!! note + 扩展在构造时就固定下来。没有可以事后调用的 `add_extension`:客户端连着的时候,服务器的能力映射不应该变。 + +能力映射随 `server/discover` 传递,这是 **2026-07-28** 的路径。旧版 `initialize` 握手没有地方放它,所以旧版客户端根本看不到这个扩展。设计时要考虑到这一点:扩展是对服务器的**增强**,绝不能成为服务器唯一可用的途径。 + +## 编写自己的扩展 {#writing-your-own} + +继承 `Extension`,只重写需要的部分。每个方法都有默认实现。 + +### 标识符 {#the-identifier} + +```python +--8<-- "docs_src/extensions/tutorial002.py" +``` + +标识符是一个 `vendor-prefix/name` 字符串,遵循规范中 `_meta` 键的语法:用点分隔的标签(每个以字母开头,以字母或数字结尾),一个斜杠,然后是名称。它在**类定义时**就会被校验,所以拼写错误不会等到服务器启动才暴露: + +```text +TypeError: Stamps.identifier must be a `vendor-prefix/name` string +(reverse-DNS prefix required), got 'stamps' +``` + +用你控制的域名作前缀。`io.modelcontextprotocol/*` 留给 MCP 项目自己规范的扩展。 + +### 贡献工具 {#contributing-tools} + +最小的有用扩展就是一个工具加一份设置映射: + +```python title="server.py" hl_lines="17 19-20 22-23 26" +--8<-- "docs_src/extensions/tutorial003.py" +``` + +* `tools()` 返回 `ToolBinding`。服务器注册每一个的方式与你自己调用 `mcp.add_tool(...)` 完全一样:同样的模式生成,同样的 `Context` 注入,一切都一样。 +* `settings()` 是在 `capabilities.extensions["com.example/stamps"]` 处声明的值。返回 `{}`(默认值)表示声明该扩展但不带任何设置。 +* 扩展永远拿不到服务器。它以数据的形式声明贡献,由 `MCPServer` 消费。没有可供修改的 `self.server`。 + +`main()` 就是证明:一个直接对着 `mcp` 的内存客户端: + +```python title="server.py" hl_lines="29-34" +--8<-- "docs_src/extensions/tutorial003.py" +``` + +### 提供自己的方法 {#serving-your-own-methods} + +扩展可以注册**新的请求方法**:它自己的动词,与规范定义的方法并列提供: + +```python title="server.py" hl_lines="16-22 31 40-48" +--8<-- "docs_src/extensions/tutorial004.py" +``` + +* `SearchParams` 继承 `RequestParams`,因此 2026 的 `_meta` 信封能统一解析,处理函数拿到的是校验过的参数,而不是原始 dict。对客户端能控制的东西加上限制:`Field(ge=1, le=100)` 会在你的代码为它分配任何东西之前就拒绝离谱的 `limit`。 +* `require_client_extension(ctx, EXTENSION_ID)` 是门槛:没有声明该扩展的客户端会收到 `-32021`(缺少必需的客户端能力)错误,并附带规范要求的机器可读 `requiredCapabilities` 载荷。 +* `protocol_versions=frozenset({"2026-07-28"})` 把该方法固定在一个线路版本上。在其他任何版本下,客户端得到 `METHOD_NOT_FOUND`,就跟这个方法在那里不存在一样。对那个客户端而言,它确实不存在。 + +方法是**严格增量**的。SDK 在构造时而不是运行时强制这一点: + +* 为规范定义的方法(`tools/list`、`completion/complete`……)创建 `MethodBinding`,会在构造该绑定时抛出 `ValueError`。核心动词属于服务器。 +* 两个扩展绑定同一个方法,第二个注册时抛出异常。“后写者胜”正是插件互相破坏的方式,我们不这么做。 +* 空的 `protocol_versions` 集合同样抛出异常:一个永远无法提供的方法是 bug,不是配置。 + +### 客户端一侧 {#the-client-side} + +同一个文件的 `main()` 就是客户端的全部内容,两半都在: + +```python title="server.py" hl_lines="54-58" +--8<-- "docs_src/extensions/tutorial004.py" +``` + +* `Client(..., extensions=[advertise(EXTENSION_ID)])` 声明该扩展。这些声明会变成 `ClientCapabilities.extensions`:在 2026-07-28 连接上,该映射随每个请求的 `_meta` 信封传递,所以服务器在**每个**请求上都能看到它;在旧版连接上,它随 `initialize` 握手传递。服务器代码不用关心是哪一种:`require_client_extension(ctx, ...)` 和 `ctx.session.check_client_capability(...)` 在两条路径上都会读取正确的来源。 +* 厂商方法要往下一层,用 `client.session.send_request(...)`;`Client` 只为规范动词提供一等方法。`send_request` 接受任何 `Request` 子类,所以厂商请求原样传入即可。 + +### 拦截 `tools/call` {#intercepting-toolscall} + +唯一的拦截型钩子。重写 `intercept_tool_call` 来观察、短路或否决一次工具调用: + +```python title="server.py" hl_lines="17-24" +--8<-- "docs_src/extensions/tutorial005.py" +``` + +* `params` 是校验过的 `CallToolRequestParams`:不用碰原始 JSON 就能拿到 `params.name` 和 `params.arguments`。决定运行哪个工具调用的也是它:通过 `call_next` 传入一个改写过的 context,改变的是处理函数在 `ctx` 上看到的内容,而不是工具调用本身。线路层面的请求改写属于[中间件](middleware.md)的事。 +* `call_next(ctx)` 运行链上剩余的部分并返回处理函数的结果。原样返回它(观察)、返回别的东西(替换),或者抛出 `MCPError`(拒绝)。无论返回什么,都会像任何处理函数结果一样被序列化,包括 2026 时代的 `serverInfo` 身份标记,所以短路的拦截器永远不会产生匿名或不符合模式的响应。 +* 有多个扩展时,拦截器按注册顺序嵌套:`extensions=[...]` 里的第一个扩展在最外层。 +* 默认实现是直通。如果服务器的扩展都没有重写这个钩子,裸 `tools/call` 处理函数就保持原封不动。不用的东西不用付出代价。 + +这个钩子只包裹 `tools/call`,别无其他。涉及每条消息的事情,用[中间件](middleware.md)。它就是干这个的。 + +## 使用客户端扩展 {#using-a-client-extension} + +**客户端扩展**是从消费一侧看的同一份约定:一组归在同一个标识符之下的客户端行为。把实例传给 `Client(extensions=[...])`,然后照常调用工具: + +```python title="client.py" hl_lines="66-68" +--8<-- "docs_src/extensions/tutorial006.py" +``` + +`call_tool("buy", ...)` 返回一个普通的 `CallToolResult`,和其他任何调用一样。扩展改变的是:服务器现在可以用 `receipt` **结果形态**而不是最终结果来回答 `buy`,`Receipts` 会在 `call_tool` 返回之前把它完成(这里是用一次后续调用兑换收据)。调用处什么都不用动。 + +去掉这个扩展,这一切就都不存在:服务器的门槛会拒绝没有声明它的客户端(错误 -32021),而跳过门槛的服务器发来的被认领形态会校验失败,正如规范对无法识别的 `resultType` 所要求的那样。默认关闭,线路两端都是。 + +要声明一个**没有**任何客户端行为的标识符(服务器按该能力设门槛,客户端什么都不做,就像上面的 search 客户端那样),用 `advertise()`: + +```python +from mcp.client import advertise + +client = Client(mcp, extensions=[advertise("com.example/search")]) +``` + +## 编写客户端扩展 {#writing-a-client-extension} + +继承 `ClientExtension`,只重写需要的部分。贡献分三类,各有默认实现:`settings()`、`claims()` 和 `notifications()`。 + +```python title="client.py" hl_lines="17-18 43-44 46-47" +--8<-- "docs_src/extensions/tutorial006.py" +``` + +* 标识符遵循与服务器端相同的语法,在类定义时校验。 +* `claims()` 返回 `ResultClaim`:一个线路标签、解析它的模型,以及完成它的解析器。模型必须用 `result_type: Literal["receipt"]` 固定该标签,且不得继承该动词的核心结果类型;两者都在构造认领时强制检查。像 `receipt_token` 这样的厂商字段在线路上原样传输:被替换的形态会逐字到达客户端。 +* 解析器接收解析后的模型和一个 `ClaimContext`;`ctx.session` 与 `client.session` 是同一个公开句柄,所以后续操作就是普通的会话调用。它返回该动词正常的 `CallToolResult`。 +* `settings()` 是在 `ClientCapabilities.extensions[identifier]` 处声明的值,在构造 `Client` 时读取一次。 + +`notifications()` 声明要观察的厂商服务器通知: + +```python +def notifications(self) -> Sequence[NotificationBinding[Any]]: + return [NotificationBinding(method="notifications/receipts", params_type=ReceiptEvent, handler=self.on_receipt)] +``` + +处理函数按分发顺序逐个接收校验过的参数。它只观察,不能否决,也不能回复。 + +两条不起眼的规则。认领只在 2026-07-28 连接上生效,能力声明随之变化:在旧版连接上,认领会消失,标识符也随之从声明中去掉,所以客户端永远不会声明一个其形态自己会拒绝的扩展。另外,如果想自己拿到被认领的形态而不交给解析器,调用 `client.session.call_tool(..., allow_claimed=True)`;没有这个标志时,被认领的形态到达会话层调用方会抛出 `UnexpectedClaimedResult`。 + +### 扩展动词 {#extension-verbs} + +扩展自己的请求方法不需要在客户端注册。厂商请求类型继承 `mcp.types.Request`,通过 `client.session.send_request` 发送,如[提供自己的方法](#serving-your-own-methods)所示。补充一点:当某个参数键必须放进 `Mcp-Name` 头(tasks 之类的扩展规范对其动词有此要求)时,请求类型要声明 `name_param`: + +```python title="client.py" hl_lines="22-25 46-47" +--8<-- "docs_src/extensions/tutorial007.py" +``` + +会话在每条发送路径上都会把 `params["jobId"]` 镜像到 `Mcp-Name` 中,值缺失时会明确报错,而不是悄悄漏掉一个必需的头。 + +## 扩展不能做什么 {#what-an-extension-cannot-do} + +贡献面是有意**封闭**的。服务器端:设置、工具、资源、方法、一个 `tools/call` 拦截器。客户端:设置、结果认领、通知绑定。扩展不能: + +* **伸手进宿主内部。**它只声明数据,不持有服务器或客户端的引用。 +* **替换核心行为。**规范方法和核心结果标签在构造时就被拒绝(`initialize` 更是被运行器直接保留);被核心词汇遮蔽的通知绑定则会安静失效并给出一条警告。 +* **延迟注册。**`MCPServer(...)` 或 `Client(...)` 返回之后,扩展集合就定了。 + +如果你在跟这些墙较劲,那你写的不是扩展,而是一个 fork。墙本身就是特性:用户读到 `extensions=[Apps(), Stamps()]`,就知道这两者可能触碰过的**一切**。 diff --git a/i18n/zh/pages/advanced/index.md b/i18n/zh/pages/advanced/index.md new file mode 100644 index 0000000000..f21e83f298 --- /dev/null +++ b/i18n/zh/pages/advanced/index.md @@ -0,0 +1,22 @@ +--- +translation: + sections: [ca6988b7503cd2d3] + tool: 1 +--- +# 进阶 {#advanced} + +普通服务器或客户端需要的一切,在上面的各节里都有对应的专题位置。这一节是在 `MCPServer` 的便利层碍事时才用得上的后门: + +* **[底层 Server](low-level-server.md)**:`MCPServer` 构建于其上的类。手写模式、`on_*` 处理函数、没有任何替你做的检查,还可以定义你自己的 JSON-RPC 方法。 +* **[分页](pagination.md)** 和 **[中间件](middleware.md)**:两件**只能**在底层 `Server` 上做的事。 +* **[扩展](extensions.md)** 和 **[MCP Apps](apps.md)**:协议的扩展面。把扩展包组合进服务器,或者自己写一个。 + +有几样东西你可能理所当然地想在这里找,但它们其实放在实际用到它们的地方: + +* **授权**在 **[运行服务器](../run/index.md)** 下,因为服务器是在部署的地方加以保护的。 +* **OAuth**、**身份断言**、连接**多个服务器**以及响应**缓存**都在 **[客户端](../client/index.md)** 下。 +* **多轮往返(multi-round-trip)请求**和**订阅**在 **[在处理函数内部](../handlers/index.md)** 下,因为两者都是处理函数**做**的事。 +* **URI 模板**在 **[服务器](../servers/index.md)** 下,挨着资源。 +* **[协议版本](../protocol-versions.md)** 和 **[已弃用功能](../deprecated.md)** 各有自己的顶层页面。 + +如果不确定自己需不需要这一节,那就是不需要。 diff --git a/i18n/zh/pages/advanced/low-level-server.md b/i18n/zh/pages/advanced/low-level-server.md new file mode 100644 index 0000000000..51096d1022 --- /dev/null +++ b/i18n/zh/pages/advanced/low-level-server.md @@ -0,0 +1,206 @@ +--- +translation: + sections: [2c79b6338e09b7ac, 7edc43b3fae11314, 1086e77ce561cd7f, a3f71823df5efc31, 9fc7109f72201cae, 7bf25983df655b66, 6330e1f4c6029683, 2f1749c8c133fa1c, b3530fcf4d11fd56, ebc33704fbd74262, cd0e9c933350390e] + tool: 1 +--- +# 底层 Server {#the-low-level-server} + +`@mcp.tool()` 是一层封装。它下面还有第二个服务器类 `Server`,说的是原始的 MCP:你把协议对象交给它,它原封不动地放到线路上。 + +`MCPServer` 就构建在它之上。当便利层碍事时,才需要下沉到这一层: + +* 需要发出一个**精确**的模式(从文件加载、从数据库生成),而不是从 Python 签名推导出来的模式。 +* 需要完全掌控结果:`_meta`、`is_error`、`structured_content` 的每一个键。 +* 需要处理一个 MCP 没有定义的方法。 + +其他情况,留在 `MCPServer` 上。 + +## 同一个工具,手写版 {#the-same-tool-by-hand} + +这是 **[工具](../servers/tools.md)** 用九行 `@mcp.tool()` 写出的 `search_books` 工具,去掉语法糖之后的样子: + +```python title="server.py" hl_lines="22 26 32" +--8<-- "docs_src/lowlevel/tutorial001.py" +``` + +变了三件事,而它们就是整个底层 API: + +* **处理函数是构造函数参数。** `on_list_tools=` 和 `on_call_tool=` 传进 `Server(...)`。这一层没有装饰器,每个处理函数的形状都一样:`async (ctx, params) -> result`。 +* **输入模式自己写。** `Tool.input_schema` 是一个普通的 JSON Schema `dict`。没人从类型注解推导它,因为根本没有类型注解可供推导。 +* **结果自己构建。** `CallToolResult(content=[TextContent(...)])`,手写。没有包装、没有转换,也不会从返回值注解推断任何东西。 + +`params` 是解析后的请求:`CallToolRequestParams` 提供 `.name` 和 `.arguments`。`ctx` 是一个 `ServerRequestContext`:`ctx.session` 用来回头和客户端通信,还有 `ctx.lifespan_context`、`ctx.request_id`,以及 `ctx.meta`——请求传入的 `_meta`。 + +!!! info + 如果用过 FastAPI,这层关系你已经熟悉了。`MCPServer` 是装饰器加类型注解的那一层;`Server` 是底下的 Starlette。它们不是竞争关系:`MCPServer` 会构造一个 `Server`,并在上面注册和这里一模一样的处理函数。 + +### 试一试 {#try-it} + +这个没有 Inspector 可用:`mcp dev` 和 `mcp run` 只接受 `MCPServer`。内存中的 `Client` 不在乎;它接收底层 `Server` 的方式和接收 `MCPServer` 完全一样: + +```python title="main.py" +import asyncio + +from mcp import Client + +from server import server + + +async def main() -> None: + async with Client(server) as client: + result = await client.call_tool("search_books", {"query": "dune", "limit": 5}) + print(result.content) + + +asyncio.run(main()) +``` + +```text +[TextContent(type='text', text="Found 3 books matching 'dune' (showing up to 5).", annotations=None, meta=None)] +``` + +和 `@mcp.tool()` 版本产生的文本一样。两处实实在在的差别: + +* `result.structured_content` 是 `None`。高层服务器会替你把 `-> str` 包装成 `{"result": ...}`;在这里,你没构建的东西没人替你构建。 +* `list_tools` 返回的是**你**敲进去的模式,一字不差。高层版本在每个属性上都有 `"title": "Query"`,根上还有一个 `"title": "search_booksArguments"`:Pydantic 的产物。在这一层,线路上有什么,都是你放上去的。 + +## 没有替你做任何检查 {#nothing-is-checked-for-you} + +`MCPServer` 会在你的函数运行之前拒绝错误的参数,按它生成的模式校验调用(**[工具](../servers/tools.md)**)。 + +`Server` 不做这件事。你的 `input_schema` 只是向客户端**公布**;它从不会被**应用**到 `params.arguments` 上。 + +!!! check + 调用 `search_books` 时不传 `limit`,你的 `args["limit"]` 就会抛出 `KeyError`。客户端看到的是: + + ```text + MCPError: Internal server error + ``` + + 一个 JSON-RPC 错误,代码 `-32603`,消息故意写得很笼统:SDK 不会把你的 traceback 泄露给远程调用方。模型永远不知道自己哪里做错了,所以也没法重试。(在测试里,`raise_exceptions=True` 会把真实的异常暴露出来;见 **[测试](../get-started/testing.md)**。) + +这一点可以推广。从底层处理函数抛出的异常**永远**是协议错误,绝不会是 `is_error=True` 的工具结果。如果想让模型读到失败信息并恢复,就自己校验 `params.arguments`,然后返回 `CallToolResult(content=[TextContent(...)], is_error=True)`。这两种失败是 **[处理错误](../servers/handling-errors.md)** 的主题。 + +## 两个工具,一个处理函数 {#two-tools-one-handler} + +`on_call_tool` 是服务器上所有工具的唯一入口。按 `params.name` 路由: + +```python title="server.py" hl_lines="38-43" +--8<-- "docs_src/lowlevel/tutorial002.py" +``` + +* `list_tools` 公布两个工具。`call_tool` 按名字分发。 +* `else` 分支很重要:对于一个你从未列出的名字,`Server` 照样会把 `tools/call` 直接转发进你的处理函数。在那里抛异常,调用就会变成和上面一样的 `-32603`。 + +## 结构化输出,手写版 {#structured-output-by-hand} + +在 `Tool` 上声明 `output_schema`,在结果上放 `structured_content`。两者都归你管: + +```python title="server.py" hl_lines="19-23 36" +--8<-- "docs_src/lowlevel/tutorial003.py" +``` + +调用它,结果同时携带两种表示: + +```json +{ + "content": [{"type": "text", "text": "Found 3 books matching 'dune'."}], + "structuredContent": {"matches": 3, "query": "dune"}, + "isError": false, + "resultType": "complete", + "_meta": {"io.modelcontextprotocol/serverInfo": {"name": "Bookshop", "version": "2.0.0"}} +} +``` + +`_meta` 块是服务器的身份标记:SDK 会把它加到每个 2026 版的结果上,`version` 取自构造函数(没设置的服务器会报告空字符串)。不能暴露身份的服务器可以用中间件把这个键去掉,中间件拥有它返回的结果。 + +服务器从不比较这两个字段。本 SDK 的 `Client` 会:返回的 `structured_content` 不满足你声明的 `output_schema` 时,`call_tool` 会抛出一个 `RuntimeError`,开头是 `Invalid structured content returned by tool search_books`,后面引用 `jsonschema` 的失败信息。承诺一个模式很便宜;信守它是你的事。返回类型和模式的完整阶梯详见 **[结构化输出](../servers/structured-output.md)**。 + +## `_meta`:给应用程序,不是给模型 {#\_meta-for-the-application-not-the-model} + +`content` 是答案里模型读取的部分。`structured_content` 是同一个答案的类型化数据形式。`_meta` 是第三条通道:随结果一起传递、面向**客户端应用程序**的数据,根本不属于答案的一部分。 + +用它放记录 ID、追踪 ID,以及任何 UI 需要而提示词不需要的东西: + +```python title="server.py" hl_lines="37" +--8<-- "docs_src/lowlevel/tutorial004.py" +``` + +* 构造时写作 `_meta=`,也就是线路上的名字。客户端读回来是 `result.meta`。 +* 给键加命名空间(`bookshop/record_ids`)。`io.modelcontextprotocol/*` 键由协议保留。 + +!!! warning + `_meta` 是你和客户端应用程序之间的约定,不是对哪些内容会到达模型的保证。宿主决定渲染什么。永远不要把秘密放进工具结果的任何部分。 + +## 能力跟着处理函数走 {#capabilities-follow-your-handlers} + +`Server` 公布的恰好是你给了处理函数的那些方法族。上面的 `Bookshop` 只传了 `on_list_tools` 和 `on_call_tool`,别的都没有,所以连接它的客户端看到的是: + +```json +{"tools": {"listChanged": false}} +``` + +没有 `resources`,没有 `prompts`:没有东西支撑它们。传入 `on_list_prompts`,`prompts` 就出现;传入 `on_completion`,`completions` 就出现。 + +`MCPServer` 总是公布工具、资源和提示词,不管你有没有注册,因为它的管理器总是存在。在这一层,声明**就是**那次构造函数调用。 + +## 生命周期泛型 {#the-lifespan-generic} + +`Server` 在生命周期产出的类型上是泛型的。注解一次,这个对象在出现的每个地方都有类型: + +```python title="server.py" hl_lines="24-26 44-45 50" +--8<-- "docs_src/lowlevel/tutorial005.py" +``` + +* 生命周期是一个 `Callable[[Server[Catalog]], AbstractAsyncContextManager[Catalog]]`;在 `async` 生成器上加 `@asynccontextmanager` 正好得到它。 +* 它 `yield` 的东西成为 `ctx.lifespan_context`,又因为处理函数注解为 `ServerRequestContext[Catalog]`,`.search(...)` 能自动补全并通过类型检查。 +* 服务器启动时进入一次,停止时退出一次。启动、清理,以及 `MCPServer` 对同一思路的实现,详见 **[生命周期](../handlers/lifespan.md)**。 + +没有 `lifespan=` 时,`ctx.lifespan_context` 是一个空 `dict`。 + +## 自己的方法 {#a-method-of-your-own} + +构造函数覆盖 MCP 定义的方法。`add_request_handler` 覆盖其余所有: + +```python title="server.py" hl_lines="35-36 39-40 43-44 48" +--8<-- "docs_src/lowlevel/tutorial006.py" +``` + +* 第一个参数是方法字符串。通知有一个对应的 `add_notification_handler`。 +* `params_type` 是传入的 `params` 在处理函数运行**之前**校验所依据的模型,所以自定义方法**确实**得到了工具没有的校验。继承 `RequestParams`,这样 `_meta` 字段的解析方式和其他方法一样。 +* 处理函数返回 `BaseModel`、`dict` 或 `None`。SDK 把它序列化进 JSON-RPC 结果。 + +一个实实在在的提醒:高层 `Client` 只为 MCP 定义的方法提供了动词,所以没有 `client.reindex()`。厂商方法是给已经知道它存在的对端用的:你同时发布的客户端,或者你自己说 JSON-RPC 的另一个服务。 + +有一个方法你不能占用: + +```text +ValueError: 'initialize' is handled by the server runner and cannot be overridden; +use Server.middleware to observe or wrap initialization +``` + +握手归运行器所有。`server/discover`、`ping` 以及其他所有内置方法都可以替换。 + +!!! tip + 那条错误里提到的 `Server.middleware` 会包裹**每一条**入站消息,包括 `initialize`。如果想要的是观察或改写流量,而不是响应一个新方法,从 **[中间件](middleware.md)** 开始。 + +## 其他处理函数 {#the-other-handlers} + +下面每一项都是一个你现在已经有词汇去理解的概念;每一项都有自己的页面。 + +* `on_call_tool`、`on_get_prompt` 和 `on_read_resource` 可以返回 `InputRequiredResult` 而不是正常结果,来暂停调用并向客户端索要输入;见 **[多轮往返(multi-round-trip)请求](../handlers/multi-round-trip.md)**。符合这一层的风格,没有任何东西替你装好:`MCPServer` 默认会密封 `requestState`,而在这里,你设置的 `request_state` 按原样穿过线路,直到你用 `server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[...]), default_audience=server.name))` 主动启用:一行代码(两个名字都从 `mcp.server.request_state` 导入),得到和 `MCPServer` 完全相同的密封与验证(**[保护 `requestState`](../handlers/multi-round-trip.md#protecting-requeststate)**)。 +* `on_list_resources`、`on_read_resource`、`on_list_prompts`、`on_get_prompt`、`on_completion` 是针对其他原语的同样 `(ctx, params) -> result` 形状。 +* `on_subscriptions_listen` 提供 2026-07-28 的 `subscriptions/listen` 流。传入一个构建在 `SubscriptionBus` 之上的 `ListenHandler`,并从其他处理函数向总线发布事件;完整的组合方式见 **[订阅](../handlers/subscriptions.md)**。 +* `server.streamable_http_app()` 返回的 Starlette 应用和 `MCPServer` 的一样;按 **[运行你的服务器](../run/index.md)** 部署任何其他 ASGI 应用的方式部署它。这一层没有 `server.run(transport=...)`:`server.run(read_stream, write_stream, server.create_initialization_options())` 在一对流上驱动一个连接,整件事就是这一行。 + +## 回顾 {#recap} + +* 底层 `Server` 以 `on_*` **构造函数参数**接收处理函数;每个处理函数都是 `async (ctx, params) -> result`。 +* `input_schema` 字典自己写,`CallToolResult` 自己构建。没有任何东西替你推导、包装或校验。 +* 处理函数里的异常是 `-32603` 协议错误。模型能读到的工具错误是**你**返回的 `is_error=True` 的 `CallToolResult`。 +* 结果上的 `_meta` 面向客户端应用程序,不是模型。 +* `Server[T]` 在生命周期产出的东西上是泛型的;`ctx.lifespan_context` 是有类型的 `T`。 +* `add_request_handler(method, params_type, handler)` 提供任意方法。`initialize` 是保留的。 +* `Server` 公布的能力由你注册了哪些处理函数推导而来。 + +`Client(server)` 对两种服务器一视同仁,因为它们**就是**同一个协议,这正是关键所在。再往下一层根本不是一个类:它是 **[中间件](middleware.md)**。 diff --git a/i18n/zh/pages/advanced/middleware.md b/i18n/zh/pages/advanced/middleware.md new file mode 100644 index 0000000000..319820f342 --- /dev/null +++ b/i18n/zh/pages/advanced/middleware.md @@ -0,0 +1,84 @@ +--- +translation: + sections: [6048b4f308edbb8c, 068bda0f21ee9c1b, c3e565b61acd75c5, c62422b159c6ed09, 47204fab253cc45c] + tool: 1 +--- +# 中间件 {#middleware} + +**中间件**(middleware)是一个异步函数,它包裹服务器收到的每一条消息。 + +把它写成 `async (ctx, call_next)`,再追加到 `server.middleware` 里。整个 API 就这些。 + +!!! warning + 中间件列表在源码中标记为**临时性**(provisional):它的签名和语义可能在某个 2.x 次版本中改变。用它来**观察**(计时、记录日志、追踪)和**拒绝**消息;不要把它当作服务器赖以立足的根基。 + +`MCPServer` 在构造时接收这个列表(`MCPServer(name, middleware=[...])`),并以 `mcp.middleware` 暴露出来;低层 `Server` 把同一个列表暴露为 `server.middleware`。下面的示例用的是低层 `Server`;如果你还没见过 `Server(name, on_call_tool=...)`,先读 **[低层 Server](low-level-server.md)**。 + +## 一个计时中间件 {#a-timing-middleware} + +一个服务器、一个工具、一个中间件,记录每条消息花了多长时间: + +```python title="server.py" hl_lines="39-45 49" +--8<-- "docs_src/middleware/tutorial001.py" +``` + +* `ctx` 就是处理函数收到的那个 `ServerRequestContext`。`ctx.method` 是原始的方法字符串;`ctx.params` 是原始参数,**尚未**经过任何校验。 +* `call_next(ctx)` 运行链条剩下的部分:校验、查找处理函数、你的处理函数。把它的返回值原样返回,响应就不会被改动。 +* `try`/`finally` 是有意为之:抛出异常的处理函数照样会被计时,因为失败会以 `call_next` 抛出的异常的形式到达你的中间件。 +* `server.middleware.append(...)` 完成注册。列表按从外到内的顺序执行,所以 `middleware[0]` 是离线路最近的那一个。 + +### 试一试 {#try-it} + +连接一个客户端,列出工具,调用其中一个。日志里有**三**行: + +```text +server/discover took 18.3 ms +tools/list took 0.1 ms +tools/call took 0.1 ms +``` + +你发了两次调用,却得到三行。第一行是 `server/discover`:客户端为建立连接而发送的请求,在你提出任何要求之前就发出了。 + +这正是关键所在。中间件包裹**每一条**入站消息: + +* 连接建立:`server/discover`,或者旧版会话上的 `initialize` 和 `notifications/initialized`。 +* 每一个请求和每一个通知。对于通知,`ctx.request_id is None`,`call_next(ctx)` 返回 `None`,而你返回的任何东西都会被丢弃。 +* 甚至包括服务器没有处理函数的方法:`call_next` 会抛出 `MCPError(-32601, "Method not found")`,**穿过**你的中间件送往客户端。 + +## 在中间件里能做什么 {#what-you-can-do-inside-one} + +按你应当犹豫的程度递增排列: + +* **观察。**计时、计数、记录日志。就是上面的例子。 +* **拒绝。**抛出一个 `MCPError` 来**代替**调用 `call_next(ctx)`,这一条消息就会以 JSON-RPC 错误作答。连接保持不断;下一条消息照常通过。服务器就是这样按调用方对 `subscriptions/listen` 设限的:订阅页面的 **[决定谁可以监听](../handlers/subscriptions.md#deciding-who-may-watch)** 一节有完整的讲解。 +* **改写。**`ctx` 是一个 dataclass:`await call_next(dataclasses.replace(ctx, params=...))` 会把与客户端所发不同的参数交给链条剩下的部分。永远不要对 `initialize` 这样做:客户端拿到的结果是根据你改写后的参数构建的,但服务器提交连接状态时依据的是线路上的原始参数。两端可能在握手结束时对协商结果各执一词。 +* **作答。**不调用 `call_next(ctx)` 而直接返回一个结果,它就会作为你的响应发给客户端。`call_next` 交给你的是最终的线路形式,而流水线从不修补你返回的内容,所以整个信封都由你负责:在 2026 年代的连接上,这包括 `serverInfo` 的 `_meta` 戳记——SDK 会给处理函数的结果加上它,但不会给你的结果加。 + +!!! check + `initialize` 也是中间件包裹的对象之一,而且中间件是你能拿到的**唯一**钩子。试图用 `add_request_handler` 接管它,SDK 会拒绝: + + ```text + ValueError: 'initialize' is handled by the server runner and cannot be overridden; + use Server.middleware to observe or wrap initialization + ``` + +!!! warning + `initialize` 是内联处理的:在你的中间件链返回之前,服务器不会再读取任何入站消息。因此,在处理 `initialize` 期间等待一个服务器到客户端的请求(`ctx.session.send_request(...)`、一次征询(elicitation))会**让连接死锁**:你在等待的响应永远无法被读到。发后即忘的通知没有问题。 + +## 唯一一个默认启用的中间件 {#the-one-middleware-that-ships-on-by-default} + +SDK 自带的中间件恰好只有一个,而且已经在你服务器的列表上了:为每条消息发出一个 OpenTelemetry span 的那个。你不用追加它,大多数时候也不用去想它。在你安装导出器之前它什么都不做,它有自己的页面:**[OpenTelemetry](../run/opentelemetry.md)**。 + +!!! info + 如果你写过 ASGI 中间件,这个形状你已经认识了。Starlette 的 `(scope, receive, send)` 变成了 `(ctx, call_next)`,而且它运行在传输**之后**,作用于解码后的消息而不是原始 HTTP 请求。两者可以组合:挂在 `streamable_http_app()` 上的 Starlette 中间件看到的是 HTTP;这里看到的是 MCP。 + +## 回顾 {#recap} + +* 中间件是 `async (ctx, call_next) -> result`,以 `MCPServer(middleware=[...])` 传入(或追加到 `mcp.middleware`),在低层 `Server` 上则追加到 `server.middleware`。 +* 它包裹**每一条**入站消息(`server/discover`、`initialize`、请求、通知、未知方法),按从外到内的顺序执行。 +* 用 `ctx.request_id is None` 区分通知和请求。 +* 抛出异常而不调用 `call_next` 即可拒绝一条消息;连接不受影响。 +* SDK 自己的 OpenTelemetry 追踪也是一个中间件,已经在列表上了。见 **[OpenTelemetry](../run/opentelemetry.md)**。 +* 整个接口都是临时性的。用它来观察;不要在它之上构建。 + +包裹请求的东西就这些了。**[授权](../run/authorization.md)** 决定的则是请求究竟能不能运行。 diff --git a/i18n/zh/pages/advanced/pagination.md b/i18n/zh/pages/advanced/pagination.md new file mode 100644 index 0000000000..b15bf41159 --- /dev/null +++ b/i18n/zh/pages/advanced/pagination.md @@ -0,0 +1,81 @@ +--- +translation: + sections: [a9aba7a026c7bd85, ed32bda7ba9ae33a, 7e64cc5646abb91f, 22a0129ee78b3c63, d875373c06d8d2f9] + tool: 1 +--- +# 分页 {#pagination} + +大多数服务器永远用不到这个。 + +`MCPServer` 对每个 `list_*` 请求都一次返回它拥有的全部内容,只有一页,`next_cursor=None`。对于几十个工具、资源或提示词来说,这就是正确答案,没有什么需要配置的。 + +分页是给那种资源列表其实是一个数据库的服务器准备的:几千行数据,它不肯在一个响应里全部序列化。协议给出的答案是**游标(cursor)**:服务器返回一页数据外加一个不透明的令牌,客户端把这个令牌发回去,就能拿到下一页。 + +`@mcp.resource()` 没有为这些提供任何钩子。要分页,就得在 **[底层 Server](low-level-server.md)** 上自己写 list 处理函数。 + +## 会分页的服务器 {#a-server-that-pages} + +```python title="server.py" hl_lines="12 15-16" +--8<-- "docs_src/pagination/tutorial001.py" +``` + +* 在底层 `Server` 上,处理函数是构造函数参数,而不是装饰器。`on_list_resources` 响应每一个 `resources/list` 请求;整个接入就这些。 +* 每个分页处理函数的类型标注都是 `params: PaginatedRequestParams | None`,示例对两种情况都做了处理。不过在连接上,SDK 永远不会交给你 `None`(没有 `params` 成员的请求到达处理函数时,是带默认值的模型实例),所以真正重要的信号是 `params.cursor is None`:**从头开始**。 +* 游标**是**什么由你决定。这里是转成字符串的偏移量。时间戳、主键、base64 数据块:只要发出去时能生成、收回来时能认出,什么都行。 +* `next_cursor=None` 表示“那是最后一页”。没有计数,没有总数,没有 `has_more`。`None` 就是全部信号。 + +!!! tip + `PAGE_SIZE` 设为 10 是为了让示例好读。按端点选你自己的:一行一个的资源列表,一页放 500 个也负担得起;一堆臃肿的提示词模板列表就不行。客户端对此没有发言权,这是有意为之。 + +### 试一试 {#try-it} + +`Client(server)` 在内存中连接底层 `Server` 的方式,和连接 `MCPServer` 完全一样。 + +不带参数调用 `list_resources()`。得到十个资源,从 `book-1` 到 `book-10`,`next_cursor` 是字符串 `"10"`。 + +用 `list_resources(cursor="10")` 把它交回去,第一个资源就是 `book-11`,新的 `next_cursor` 是 `"20"`。 + +第十页回来时 `next_cursor` 为 `None`。结束。 + +## 客户端循环 {#the-client-loop} + +`Client` 上的每个 `list_*` 方法(`list_tools`、`list_resources`、`list_resource_templates`、`list_prompts`)都接受一个 `cursor=` 关键字参数。取完一个分页列表只需要一个 `while True`: + +```python title="client.py" hl_lines="26-32" +--8<-- "docs_src/pagination/tutorial002.py" +``` + +* `cursor` 起始为 `None`,所以第一个请求不带游标。 +* 先 extend,**再**看 `next_cursor`:最后一页也有资源。 +* `next_cursor is None` 是出口。其他任何值都原封不动地直接塞回 `cursor=`。 + +运行它的 `main()`,会打印 `100 resources`:十页、每页十个,由一个从头到尾都不知道有十页的循环拼在一起。 + +这和 **[客户端](../client/index.md)** 为每个 `list_*` 动词展示的是同一个循环,而且面对不分页的服务器也没有任何代价:第一个响应里 `next_cursor` 就是 `None`,循环只跑一次。 + +## 三条规则 {#the-three-rules} + +**游标是不透明的。** 客户端绝不能解析、构造或猜测游标。游标唯一合法的来源是上一页的 `next_cursor`,一字不改。 + +**页大小由服务器决定。** 协议里没有 `limit=`。需要不同的页大小,就改服务器。 + +**忽略分页的客户端照样能用。** 它调用一次 `list_resources()`,拿到前十个,从没注意到自己扔掉的 `next_cursor`。什么都没坏;只是看到的少一些。 + +!!! check + 不透明就是不透明。自己编一个游标(`list_resources(cursor="page-2")`),协议帮不了你任何忙。这个服务器会尝试 `int("page-2")`,处理函数抛出异常,回到客户端的是: + + ```text + MCPError(-32603, 'Internal server error', None) + ``` + + 不是从服务器拿到的游标是 bug,不是功能请求。 + +## 回顾 {#recap} + +* `MCPServer` 把所有内容放在一页里返回。分页需要主动启用,启用的地方是底层 `Server`。 +* `on_list_resources`(以及 `on_list_tools`、`on_list_prompts`、`on_list_resource_templates`)接收 `PaginatedRequestParams | None`;第一页时 `params.cursor` 为 `None`。 +* 返回一页外加 `next_cursor`:任何以后能认出来的字符串,或者在没有剩余内容时返回 `None`。 +* 客户端循环:传入 `cursor=`,累积,重复直到 `next_cursor is None`。 +* 游标不透明,页大小归服务器管,不分页的客户端仍然能拿到第一页。 + +手写 `Server` API 的其余部分(`on_call_tool`、`input_schema` 字典、`_meta`)见 **[底层 Server](low-level-server.md)**。 diff --git a/i18n/zh/pages/client/caching.md b/i18n/zh/pages/client/caching.md new file mode 100644 index 0000000000..07c40df6cf --- /dev/null +++ b/i18n/zh/pages/client/caching.md @@ -0,0 +1,119 @@ +--- +translation: + sections: [9e7b9a1710e5aeba, b74ca4c1d2ddddee, fa8714e61bf90c5a, 04db67a886b7271c, 857690fb8f876800] + tool: 1 +--- +# 缓存提示 {#caching-hints} + +在 2026-07-28 协议下,服务器为 `tools/list`、`prompts/list`、`resources/list`、`resources/templates/list`、`resources/read` 和 `server/discover` 返回的每个结果都带有两个字段:`ttlMs`,即客户端可以把结果视为新鲜的毫秒数;`cacheScope`,即缓存的结果可以在用户之间共享(`"public"`),还是只属于一个授权上下文(`"private"`)。 + +服务器本身不缓存任何东西。这些字段只是一种**声明**:“这个工具列表对所有人都一样,一分钟内不会变。”客户端(或挡在你前面的网关)随后可以省掉这次往返。是否遵从这些提示由客户端决定;发出它们是服务器的职责,而 SDK 替你做了这件事。 + +默认情况下,每个结果都是 `ttlMs: 0, cacheScope: "private"`:立即过期,从不共享。这永远安全,也永远合规。如果你的列表确实稳定、对所有调用者都相同,就在构造时声明: + +```python title="server.py" hl_lines="5-8" +--8<-- "docs_src/caching/tutorial001.py" +``` + +* 这个映射以**方法名**为键,六个可缓存方法是唯一合法的键。参数类型是 `Mapping[CacheableMethod, CacheHint]`,所以编辑器会自动补全键名,并在运行前标出拼写错误;逃过类型检查器的值会在构造时抛出异常。 +* 没有提到的方法保留默认值。这个映射是一组覆盖项,不是清单。 +* `CacheHint(ttl_ms=5_000)` 没有设置 `scope`,所以它仍是 `"private"`:五秒的新鲜期,按调用者各自计算。作用域和 TTL 是相互独立的决定。 +* `"server/discover"` 也是合法的键,因为发现结果和任何列表一样可以缓存。 + +!!! warning + `cacheScope: "public"` 意味着**任何人**都可能拿到你缓存的响应。共享网关会毫不犹豫地把一个用户的结果交给另一个用户,哪怕请求经过了认证。只有当结果对每个调用者都完全相同时才标记为 `"public"`,并且绝不要把 `cacheScope` 当作访问控制:它是标签,不是锁。 + +## 按处理函数覆盖 {#per-handler-override} + +在底层 `Server` 上,处理函数手动构建结果,`ttl_ms` / `cache_scope` 只是结果模型上的字段。显式设置了它们的处理函数总是逐字段地优先于构造函数映射: + +```python title="server.py" hl_lines="10 16" +--8<-- "docs_src/caching/tutorial002.py" +``` + +处理函数指定了 `ttl_ms=1_000`,对作用域只字未提。线路上是:`ttlMs: 1000`(处理函数的值,而不是映射里的 `60_000`)和 `cacheScope: "public"`(映射的值,因为处理函数没有设置)。显式优先于配置,配置优先于默认。这条规则按字段生效,所以处理函数可以固定一个字段,把另一个留给服务器范围的策略。 + +这也是构造函数无法预知的动态情况的出口:一个按用户过滤 `resources/read` 的处理函数,可以在其他方面都是 public 的服务器上为某个 URI 返回 `cache_scope="private"`。 + +关于分页列表有一点要注意:协议要求同一列表的**每一页 `cacheScope` 相同**。构造函数映射天然满足这一点,因为它按方法而不是按页作键。但自行覆盖作用域的处理函数要自己负责这种一致性:在**每一**页都覆盖,绝不要只在有游标时覆盖,否则第一页和第二页会不一致。 + +## 客户端看到什么 {#what-the-client-sees} + +在 2026-07-28 会话上,`Client` 替你遵从这些提示:它内置了响应缓存,默认开启。带着 `ttlMs` 到达的结果会被存起来,在 TTL 内的相同调用直接由缓存提供,不发生往返。**不**带提示的结果不会被缓存:无提示的结果使用 `CacheConfig.default_ttl_ms`,它默认为 `0`(立即过期),所以什么都没声明的服务器看到的流量和以前一模一样,一次调用对应一次请求。 + +```python title="client.py" hl_lines="33 35 38" +--8<-- "docs_src/caching/tutorial003.py" +``` + +四次调用,三次抓取。第二次调用找到了新鲜条目,根本没到服务器;把(注入的)时钟拨过 TTL 让第三次重新抓取;第四次指定了 `cache_mode="refresh"`。这个关键字参数存在于五个缓存动词上(`list_tools`、`list_prompts`、`list_resources`、`list_resource_templates`、`read_resource`): + +* `"use"`(默认)有新鲜条目就返回它,没有就抓取并存储。 +* `"refresh"` 从不返回缓存:它抓取并存储结果,替换掉原有缓存。 +* `"bypass"` 照常往返但完全不碰缓存:不读,不写。 + +有一条规则凌驾于 `"use"` 之上:**带 `meta` 的调用总会到达服务器。** 设置了 `meta`(进度令牌、追踪字段)的请求期望产生一次线路请求,所以在 `cache_mode="use"` 下它被当作 `"refresh"` 处理:跳过缓存读取,抓取到的结果仍会替换缓存条目。`"bypass"` 和显式的 `"refresh"` 行为照旧。 + +要完全关闭缓存,用 `Client(server, cache=None)` 构造:每次调用重新变成一次往返,`cache_mode` 虽然仍被接受,但不起作用。 + +作用域同样自动遵从:`"private"` 条目按缓存的**分区**(见下文)作键,而 `"public"` 条目可以选择更大范围的共享。并且对于通知点名的那些条目,**通知优先于 TTL**:`list_changed` 通知会驱逐对应的已缓存列表,`resources/updated` 会驱逐恰好存在其 URI 下的已缓存读取结果,不管它们多新鲜。在 2026-07-28 连接上,这些通知通过你用 `client.listen(...)` 打开的 `subscriptions/listen` 流到达,驱逐会在你的观察者看到事件之前完成;详见 **[订阅](subscriptions.md)**。 + +关于 `resources/updated` 有一点要注意:驱逐只针对精确匹配的 URI。存储契约没有枚举或扫描操作(与参考的 TypeScript 实现相同),所以携带**子**资源 URI 的通知不会驱逐其父资源的已缓存读取结果。如果你的服务器用这种方式通知子资源变化,就用 `cache_mode="refresh"` 重新抓取父资源。 + +### 配置:`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`:条目存放的位置。默认是每个客户端一个全新的内存存储;传入你自己的 `ResponseCacheStore` 实现(比如基于 Redis 的)即可在多个客户端或进程之间共享缓存。契约类型(`ResponseCacheStore`、`CacheKey`、`CacheEntry` 以及默认的 `InMemoryResponseCacheStore`)都可以从 `mcp.client` 导入。一次查找最多会对存储发出两次顺序的 `get`(先是 private 分支,然后是 public 分支),所以远程存储的延迟预期要据此估算。自定义存储**必须**显式指定 `partition`。 +* `partition`:授权上下文标签,防止在共享存储中把一个主体的 `"private"` 条目提供给另一个主体。 +* `target_id`:显式的服务器标识,用于自定义传输方式和进程内服务器(见下文)。 +* `default_ttl_ms`:应用于不带 `ttlMs` 提示的结果的 TTL。默认的 `0` 让无提示结果不被缓存。 +* `share_public`:跨分区提供服务器声明为 `"public"` 的条目(见下文)。默认关闭。 +* `clock`:墙上时钟来源,以纪元秒为单位。像上面的例子那样注入一个,过期测试就不需要 sleep 了。 + +!!! warning "分区 = 已验证的主体" + 从**已验证的凭证**派生 `partition`,比如经过校验的令牌的 subject。绝不要从请求提供的数据派生,也绝不要从服务器 URL 派生(服务器标识是单独的键维度)。SDK 是一个库,自身不做认证:信任锚点是构造 `CacheConfig` 的一方,也就是部署方,而不是租户。多租户网关为每个已认证主体创建一个 `CacheConfig`。 + + 分区在 `Client` 的整个生命周期内也是固定的。如果连接的授权上下文在会话中途改变(比如以另一个主体重新认证),缓存不会跟着变;为新的主体构造一个新的 `Client`。 + +缓存键还携带**服务器的标识**:你连接的 URL 字符串,去掉其中的 `user:pass@` 用户信息,其余逐字节保持原样。不做大小写折叠,不重排查询参数,不清理末尾斜杠。规范化不足只会损失一些共享,而过度规范化可能合并两个租户(`?tenant=a` 对 `?tenant=b`),所以表面上不同的 URL 干脆不共享条目。没有 URL 时(进程内服务器,或 `Transport` 实例),客户端改用每个实例随机生成的标识;设置 `CacheConfig.target_id` 来给服务器命名(使用自定义存储时这是必需的,构造时会报错提示)。标识在进入键材料之前会经过 sha256 哈希,所以查询字符串里带有机密的 URL 永远不会出现在存储键中。你自己也不要记录哈希前的形式。 + +!!! warning "`share_public` 信任服务器,而且是全集群范围" + 默认情况下,即使是 `"public"` 条目也留在各自的分区内。`share_public=True` 会把服务器标记为 `cacheScope: "public"` 的条目提供给使用该存储的**每一个**分区,代表所有分区信任服务器的分类。如果服务器(因为 bug 或恶意)给按租户区分的数据打上 `"public"`,一个租户的响应就会泄漏给其他租户。这个标志刻意只放在构造函数层面:逐调用的 `cache_mode` 可以收窄缓存,但没有任何逐调用的方式能放宽共享。 + +### 缓存绝不会做的事 {#what-the-cache-never-does} + +* **会话层的调用绕过它。** `client.session.list_tools()` 及同类方法总是发生往返;缓存位于 `Client` 的动词方法上。 +* **`server/discover` 不参与。** 发现结果在连接时交付一次,永远不进入响应缓存,即使它带有 `ttlMs`。如果你自己持久化一份来跳过重连探测([`prior_discover`](../protocol-versions.md#reconnecting-with-prior_discover)),它的新鲜度由你自己记账:`DiscoverResult` 正是为此携带了已解析好的 `ttl_ms` 和 `cache_scope`。 +* **续页永不缓存。** 只有不带游标的调用参与。因游标过期而被拒绝的续页确实会**驱逐**已缓存的列表,因为列表在它底下发生了变化。 +* **多轮往返(multi-round-trip)读取永不缓存。** 用 `input_responses`/`request_state` 作种子的 `read_resource`,或经过输入轮次才解析完成的读取,永远不进入缓存(规范中的 MUST)。 +* **通知驱逐需要通知。** 驱逐的效果取决于传输的投递能力,而现代的进程内路径(`Client(server)` 配合默认的 `mode="auto"`)目前不投递独立通知。 +* **驱逐是最终一致的,不是即时的。** 线路路径的通知由派生的任务分发,所以与通知到达竞态的调用可能再被提供一次驱逐前的条目;这个窗口受分发延迟限制,驱逐最终仍会生效。 +* **没有 stale-if-error。** 过期条目绝不会因为重新抓取失败而被提供;错误会向上传播。 +* **没有提前重新抓取。** 已存储的条目一直提供到 TTL 过期,之后的下一次调用承担往返开销;没有任何后台刷新。 +* **没有合并。** 两个并发的相同调用就是两次抓取。 +* **TTL 不超过 24 小时。** 更大的 `ttlMs`,无论是服务器发送的还是配置的,存储时都会被钳制(`mcp.client.caching.MAX_TTL_MS`),从而限制任何条目(无论提示多慷慨)能被提供多久。 +* 在**共享存储**上,客户端之间会相互竞态。当驱逐赶在进行中的抓取之前发生时,每个客户端会丢弃自己的写入,但**同租**的客户端仍可能把一个被它从未见过的驱逐移除的条目写回去;而这套竞态记账本身也有上限:跟踪的键超过 4096 个后,最旧的键的保护先被丢弃。这两个窗口都是可接受的,并由上面的 TTL 上限兜底关闭。 +* **不跨协议时代提供。** 条目按协商的协议版本划定范围:在共享的持久存储上,会话绝不会提供在另一个协商版本下写入的条目(同一份列表在不同时代确实不同,因为 SDK 会为旧会话剥离 2026 的字段)。驱逐同样只触及当前时代的条目;其他时代的条目只是随 TTL 自然过期。 + +### 自己读取提示 {#reading-the-hints-yourself} + +这些提示也是每个可缓存结果上的普通字段(`result.ttl_ms` 和 `result.cache_scope`,已解析好),方便你在内置缓存之上(或代替它)叠加自己的记账逻辑。 + +面对**旧服务器**(2026 之前的协议),这些字段在线路上根本不存在,模型显示的是保守的默认值:`ttl_ms == 0` 和 `cache_scope == "private"`,过期且不共享,对于什么都没声明的服务器这正是正确的假设。缓存对旧会话一视同仁:在那里从不参考提示(不管线路上出现什么键),只有 `default_ttl_ms` 生效,而它的默认值 `0` 什么都不缓存,所以 2026 之前的连接行为和缓存出现之前完全一样。如果需要区分“服务器说了 0”和“服务器什么都没说”,检查 `"ttl_ms" in result.model_fields_set`:只有字段确实到达时它才会被设置。 + +## 旧客户端 {#older-clients} + +使用 2026 之前协议版本的客户端永远看不到这两个字段;SDK 会在序列化时为这些连接剥离它们。提示只需配置一次,没有任何版本相关的代码要写。 + +## 回顾 {#recap} + +* 六个方法携带 `ttlMs`/`cacheScope`;SDK 默认它们为 `0`/`"private"`,过期且不共享,永远安全。 +* 构造时的 `cache_hints={method: CacheHint(...)}`(`MCPServer` 和 `Server` 都支持)按方法设置服务器范围的值。 +* 在结果上设置了这些字段的处理函数会逐字段覆盖映射。 +* `"public"` 是一个承诺:结果对每个调用者都相同。它不是访问控制。 +* `Client` 自动遵从提示:它的响应缓存默认开启,提供新鲜条目而不是重新抓取,对不提供提示的服务器(或会话)什么都不缓存。 +* 逐调用地,`cache_mode="refresh"` 重新抓取,`"bypass"` 跳过缓存;构造时 `cache=None` 则完全关闭它。 diff --git a/i18n/zh/pages/client/callbacks.md b/i18n/zh/pages/client/callbacks.md new file mode 100644 index 0000000000..201c937c08 --- /dev/null +++ b/i18n/zh/pages/client/callbacks.md @@ -0,0 +1,142 @@ +--- +translation: + sections: [adf3c545b5be46b6, 916cd3ab1c03f461, e9be7a8d0eb0a456, 565890a636288ecf, 6af7e49db9129ec3, 06b0238c174186af, 90c6043be435fcb0] + tool: 1 +--- +# 客户端回调 {#client-callbacks} + +MCP 里几乎所有请求都是单向的:从客户端发往服务器。 + +服务器也可以反过来向**客户端**要东西:向用户提一个问题、借用用户的模型做采样(sampling)、列出用户的工作区文件夹。要回答这些请求,把**回调**传给 `Client(...)` 即可。 + +## 一个会提问的服务器 {#a-server-that-asks} + +下面这个服务器的工具没法独立完成任务: + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/client_callbacks/tutorial001.py" +``` + +* `ctx.elicit(...)` 向**客户端**发送一个 `elicitation/create` 请求,然后等待。 +* 在有人(填表单的人,或者你的代码)给出 `name` 之前,这个工具不会返回。 + +这是服务器那一半,归 **[征询](../handlers/elicitation.md)** 页面管。本页讲的是线路的另一端。 + +## 征询回调 {#the-elicitation-callback} + +```python title="client.py" hl_lines="6-10 16-17" +--8<-- "docs_src/client_callbacks/tutorial002.py" +``` + +* 征询(elicitation)回调的签名是 `async (context, params) -> ElicitResult`。 +* `params.message` 是问题本身。`params.requested_schema` 是服务器想要的答案的 JSON Schema。真正的客户端会据此渲染一个表单;这里的客户端直接自动填好。 +* 返回 `ElicitResult(action="accept", content={...})`,或者 `action="decline"`,或者 `action="cancel"`。除此之外唯一的选择是 `ErrorData(...)`,它会拒绝这个请求,并让整个调用失败。 +* `context` 是一个 `ClientRequestContext`:包含活动的 `session`、服务器的 `request_id`,以及它附带的 `meta`(如果有)。 + +!!! tip + `params` 是两种征询模式的联合类型。这里 `params.mode` 是 `"form"`;`"url"` 请求携带的是 `params.url` 而不是模式(schema)。一个回调同时处理两种情况,按 `params.mode` 分支即可。完整写法见 **[征询](../handlers/elicitation.md)**。 + +### 试一试 {#try-it} + +调用 `issue_card`,观察两端。 + +你的回调收到服务器的问题,已经解析好了: + +```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'} +``` + +回调给出回答,`ctx.elicit(...)` 在工具内部恢复执行,工具随即完成: + +```python +result.content # [TextContent(type='text', text='Card issued to Ada Lovelace.')] +``` + +你发出一个 `tools/call`,服务器回过来一个 `elicitation/create`,由你的函数作答——全部发生在一次工具调用之内。 + +!!! info + `Client(...)` 调用里的 `mode="legacy"` 是真正起作用的。默认情况下 `Client(...)` 协商的是现代协议路径,而那条路径没有供服务器向客户端发请求的反向通道(back-channel):`ctx.elicit` 会在你的回调运行之前就失败。决定这一点的不是传输方式,而是协商出的协议,内存传输和 URL 传输都一样。只要你的客户端需要回答这类请求,就固定使用 `mode="legacy"`;本页背后的每个测试都是这么做的。详见 **[协议版本](../protocol-versions.md)**。 + + 在 2026-07-28 会话上,这个回调并没有失效,只是触发方式不同:当工具返回一个携带 `ElicitRequest` 的 `InputRequiredResult` 时,`Client` 会把该条目分派给同一个 `elicitation_callback`,并替你重试这次调用。那个流程见 **[多轮往返请求](../handlers/multi-round-trip.md)**。 + +## 回调就是能力 {#a-callback-is-a-capability} + +你从没告诉服务器你的客户端能回答征询请求。是 SDK 说的。 + +客户端连接时会声明自己的 `capabilities`,和服务器的能力互为镜像。这个对象不用你写。**注册回调就是声明。** + +| 你传入 | 客户端声明 | +| --- | --- | +| `elicitation_callback=` | `"elicitation": {"form": {}, "url": {}}` | +| `sampling_callback=` | `"sampling": {}` | +| `list_roots_callback=` | `"roots": {"listChanged": true}` | +| 一个都不传 | `{}` | + +采样的子能力是唯一需要细化的地方:如果你的采样器能处理 `tools` / `tool_choice` 参数,就在传入 `sampling_callback` 的同时传入 `sampling_capabilities=SamplingCapability(tools=SamplingToolsCapability())`。服务器必须先看到 `sampling.tools` 已声明,才能发送这些参数。 + +`logging_callback` 和 `message_handler` 不在表里。它们处理的是通知,而通知不需要能力。 + +服务器用 `ctx.session.check_client_capability(...)` 读回这份声明。加一个这样的工具: + +```python title="server.py" hl_lines="23-31" +--8<-- "docs_src/client_callbacks/tutorial003.py" +``` + +只带 `elicitation_callback` 连接并调用它: + +```python +result.structured_content # {'result': ['elicitation']} +``` + +三个回调都传,返回 `['elicitation', 'sampling', 'roots']`。一个都不传,返回 `[]`。 + +!!! check + 现在故意做错:**不带** `elicitation_callback` 连接,照样调用 `issue_card`。 + + 服务器的 `elicitation/create` 请求仍然会到达你的客户端,而 SDK 会替你作答——用一个错误,因为你从没说过自己能处理它。这个错误会拖垮整个调用。`call_tool` 不会返回一个 `is_error` 结果,而是直接抛出异常: + + ```text + MCPError: Elicitation not supported + ``` + + 这是协议错误(`-32600`,“invalid request”),不是工具错误:没有任何东西可供模型读取并重试。这正是 `client_features` 值得拥有的原因:行为规范的服务器会先检查再提问。 + +## 已弃用的那一对 {#the-deprecated-pair} + +`sampling_callback` 回答 `sampling/createMessage`:服务器请求**你的**模型补全一些内容。`list_roots_callback` 回答 `roots/list`:服务器询问它可以在哪些目录(根目录(roots))里工作。 + +两者都能用,也都遵循上面的规则。但两者服务的 RPC 都被 **2026-07-28 规范移除了**:现代服务器不会在请求中途回调你的客户端,而是把请求作为工具结果的一部分交还给你(**[多轮往返请求](../handlers/multi-round-trip.md)**,即多轮往返(multi-round-trip))。回调本身并没有失效。当 `InputRequiredResult` 携带 `CreateMessageRequest` 或 `ListRootsRequest` 时,`Client` 的自动循环会把它分派给你在这里注册的同一个 `sampling_callback` 或 `list_roots_callback`。完整清单见 **[已弃用的功能](../deprecated.md)**。 + +要和还没迁移的服务器通信,仍然需要这些回调。签名如下: + +```python title="client.py" +--8<-- "docs_src/client_callbacks/tutorial004.py" +``` + +* 采样回调收到完整的 `CreateMessageRequestParams`(`messages`、`model_preferences`、`max_tokens`),返回一个 `CreateMessageResult`。模型由**你**来运行,怎么运行都行;SDK 只负责传递请求。 +* 根目录回调完全不接收参数,返回一个 `ListRootsResult`。 +* 两者都可以改为返回 `ErrorData(...)` 来表示拒绝。 + +把它们传给 `Client(...)`,方式和 `elicitation_callback` 完全一样。 + +## 通知回调 {#the-notification-callbacks} + +还有两个。它们都不声明任何东西。 + +`logging_callback` 接收服务器发送的 `notifications/message`,形式是 `LoggingMessageNotificationParams`(`level`、`logger`、`data`)。协议日志本身已被 2026-07-28 规范弃用(替代做法见 **[日志](../handlers/logging.md)**),所以这个回调是为仍在发出这类消息的服务器准备的。在 2026 年代的连接上,单有回调什么也收不到,因为 2026 服务器只向主动选择接收的请求发送日志消息:给 `Client(...)` 传入 `log_level="info"`(或其他级别),就会在每个请求上打上这个选择标记,并收到该级别及以上的消息。2026 之前的服务器会忽略它,保持原有的 `logging/setLevel` 行为。 + +`message_handler` 是兜底的:会话浮现出来的每一个服务器通知都会到达它(同时也到达各自专门的回调),在基于流的传输上,每一个传输层的 `Exception` 也是如此。有两种永远不会到达:`notifications/cancelled` 由 SDK 直接应用而不浮现出来;针对活动 `listen()` 流的订阅确认则由该流自己消费。给这个参数标注 `IncomingMessage` 类型(`ServerNotification | Exception`,从 `mcp.client` 导出)。唯一值得记住的写法是 `if isinstance(message, Exception): raise message`,这样断开的连接会大声报错,而不是悄悄消失。 + +## 回顾 {#recap} + +* 服务器可以向客户端发送请求。用传给 `Client(...)` 的回调来回答它们。 +* 征询回调是当前仍在使用的那个:`async (context, params) -> ElicitResult`,一个函数同时处理表单模式和 URL 模式。 +* **注册回调就是声明能力。**没有它,SDK 会替你拒绝服务器的请求,整个调用以 `MCPError` 失败。 +* 服务器用 `ctx.session.check_client_capability(...)` 在提问前先查明。 +* `sampling_callback` 和 `list_roots_callback` 的工作方式相同,但服务的是已弃用的功能;现代服务器改用多轮往返请求。 +* `logging_callback` 和 `message_handler` 接收通知。它们不声明任何东西。 + +`Client(...)` 的第一个参数是传输对象。**[客户端传输](transports.md)** 涵盖了每一种。 diff --git a/i18n/zh/pages/client/identity-assertion.md b/i18n/zh/pages/client/identity-assertion.md new file mode 100644 index 0000000000..db91643eb5 --- /dev/null +++ b/i18n/zh/pages/client/identity-assertion.md @@ -0,0 +1,129 @@ +--- +translation: + sections: [a91322c46111d16d, 8e6fd6d6f59bb568, e7828fd2729b2c9d, a03ec26bfc678b65, 1034c653c0bcf1b0] + tool: 1 +--- +# 身份断言 {#identity-assertion} + +普通的 OAuth provider(**[OAuth 客户端](oauth-clients.md)**)一开始会问 MCP 服务器一个问题:“你信任哪个授权服务器?”答案指向哪里,它就跟到哪里,然后要么有人登录,要么用一个预共享密钥代替人登录。 + +企业不希望这两件事按服务器逐个决定。它已经在运行一个身份提供方(Okta、Microsoft Entra ID,或者自建的);用户今天早上已经登录过它了;而且安全团队只想在这一个地方决定谁能访问什么。[SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990),即 **Enterprise-Managed Authorization** 扩展,把这个决定移到了那里。IdP 签发一个短期有效的 JWT,即 **Identity Assertion JWT Authorization Grant**,简称 **ID-JAG**:它表明**这个用户**经由**这个客户端**可以访问**这个 MCP 服务器**。客户端用它换取一个普通的访问令牌。没有浏览器,没有同意页面,没有动态注册。 + +本页讲的是这笔交换的两端。MCP 服务器本身完全不变:它仍然是 **[授权](../run/authorization.md)** 里的那个资源服务器,检查收到的任何令牌。 + +## 两次令牌请求 {#two-token-requests} + +这里涉及两个不同的权威方,把它们区分清楚,这一页就懂了大半。**企业 IdP** 是你所在组织的身份提供方:它知道员工是谁,策略定在它那里,ID-JAG 由它签发。SDK 从不和它通信。**MCP 授权服务器**还是 **[授权](../run/authorization.md)** 里的那个角色:MCP 服务器元数据里指明的 issuer,负责铸造这个 MCP 服务器接受的令牌。在普通的 OAuth 流程里,这两个角色通常是同一个系统。在这里它们是两个,而整个授权许可的核心,就是后者同意信任前者。 + +客户端向它们各发一次令牌请求。 + +1. **发给企业 IdP。** 客户端用用户的登录(他们的 OpenID Connect ID token)换取 ID-JAG。这是一次 [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) 令牌交换,完全是你 IdP 的 API,**SDK 不发这个请求**。由你来发,在一个异步回调里。策略决定也发生在这里:IdP 说不,就根本不会签发 ID-JAG,也就没有东西可出示。 +2. **发给 MCP 授权服务器。** 客户端按 [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) 的 `jwt-bearer` 授权许可出示 ID-JAG(`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`,ID-JAG 作为 `assertion`),拿到访问令牌。**这是 SDK 发的请求**,而接受它,就是本页给授权服务器加的唯一一样东西。 + +下面的内容全是第二个请求:发送它的客户端,和回应它的授权服务器。 + +## 客户端 {#the-client} + +**`IdentityAssertionOAuthProvider`** 位于 `mcp.client.auth.extensions.identity_assertion`。和 **[OAuth 客户端](oauth-clients.md)** 里的每个 provider 一样,它是一个 `httpx2.Auth`:构造一个,放到 `auth=` 上,把 `httpx2.AsyncClient` 交给传输。 + +```python title="client.py" hl_lines="49-50 53-61" +--8<-- "docs_src/identity_assertion/tutorial001.py" +``` + +从下往上读。 + +* `main()` 就是标准的 OAuth 客户端 `main()`(**[OAuth 客户端](oauth-clients.md)**),一行都没改。重点就在这:一旦 provider 存在,下游没有任何东西知道令牌是哪种授权许可产生的。 +* provider 接收的是其他 provider 无法自行发现的东西:有人在授权服务器上**预先注册**好的 `client_id` 和 `client_secret`、该授权服务器的 `issuer`,以及 `assertion_provider`——一个按需返回新鲜 ID-JAG 的异步回调。 +* `storage` 还是那个 `TokenStorage` 协议。只会调用那两个令牌方法;这里没有动态注册,所以也没有 `client_info` 需要记住。 + +### 断言提供函数 {#the-assertion-provider} + +`fetch_id_jag(audience, resource)` 是唯一需要你写的代码。每次令牌交换时 await 一次,构造时绝不会调用,而且只在授权服务器的元数据取回并校验通过**之后**才调用,所以配错的 issuer 永远不会泄露断言。它的两个参数是铸造 ID-JAG 时必须带上的两个声明(claim):`audience` 是授权服务器的 issuer(ID-JAG 的 `aud`),`resource` 是 MCP 服务器的规范标识符(ID-JAG 的 `resource`)。第三个你手里已经有了:ID-JAG 的 `client_id` 声明必须写明你传给 provider 的那个 `client_id`,否则授权服务器会拒绝交换。 + +它上面的 `idp_issue_id_jag` **不是你的代码**。它替身份提供方出场,在进程内签发断言,这样文件是完整的,你也能读到 ID-JAG 携带的每一个声明。真正的 `fetch_id_jag` 发的是上一节里的第一个令牌请求:对你的 IdP 做一次 [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) 令牌交换,由 Identity Assertion JWT Authorization Grant 草案定义,[SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) 正是该草案的一个 profile。已登录用户的 ID token 作为 `subject_token` 传入,`requested_token_type` 是 ID-JAG 自己的 URN(`urn:ietf:params:oauth:token-type:id-jag`),`audience` 和 `resource` 原样透传,响应里带回 ID-JAG。在你 IdP 的文档里要找的,就是这些名字下的这次交换。 + +!!! tip + 每次交换都会请求一个新的 ID-JAG,这正是设计意图:它是一次性的、只活几分钟的授权许可,本页的授权服务器拒绝接受同一个 ID-JAG 两次。不要缓存它。该复用的是它换来的访问令牌。 + +### issuer 是配置项 {#the-issuer-is-configuration} + +反转就在这里。`OAuthClientProvider` 会问资源服务器该用哪个授权服务器,答案指向哪里就跟到哪里。这个 provider 拒绝这么做:`issuer` 是必填的,[RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) 元数据从这个 issuer 自己的 well-known 路径获取,令牌端点必须在这个 issuer 的源(origin)上,而且从不向资源服务器询问任何事。 + +扩展本身并不要求这样;这是刻意做得更严格的选择。这个客户端带着两样值得偷的东西:一个预注册的密钥和一个绑定了 audience 的断言。如果客户端任由一个被攻破的 MCP 服务器把它引向攻击者的授权服务器,这两样就都会 POST 过去。在构造时钉死 issuer,这段对话就不存在了。 + +!!! warning + 配置的 `issuer` 会按 RFC 8414 §3.3 的简单字符串比较与元数据文档的 `issuer` 字段对比:逐字符比较,末尾斜杠算在内,不做任何规范化。不要猜。从你的授权服务器获取 `/.well-known/oauth-authorization-server`,把它返回的 `issuer` 值照抄过来。对本页的授权服务器来说,这个值是 `https://auth.example.com/`,带斜杠,因为它的 issuer 是从 Pydantic 的 URL 对象构建的。不匹配的话,流程会停在 `OAuthFlowError: Authorization server metadata issuer + mismatch`,此时一条凭据或断言都还没有发出。 + +### 机密客户端 {#a-confidential-client} + +`client_secret` 是必填的;没有它,构造函数会抛出 `ValueError`。[SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) 底下的 IETF profile 把这种授权许可留给机密客户端,SEP-990 要求客户端进行身份认证,而这个 SDK 通过坚持要求共享密钥来同时落实这两点。`token_endpoint_auth_method` 决定它走哪条路:`client_secret_post`(默认,放在表单体里)或 `client_secret_basic`(HTTP Basic 头)。该 profile 还允许 `private_key_jwt`;这个 provider 不支持。 + +!!! tip + 从环境变量或密钥管理器读取 `client_secret`,永远不要从源码仓库里读。 + +### provider 替你做了什么 {#what-the-provider-does-for-you} + +第一个请求不带认证发出,服务器的 `401` 启动整个流程。 + +1. **发现。** 从配置的 issuer 的 [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) well-known 路径获取授权服务器元数据,检查文档的 `issuer` 是否匹配,并检查令牌端点是否在 issuer 的源上。 +2. **断言。** await 你的 `assertion_provider`。 +3. **交换。** 把 `jwt-bearer` 授权许可 POST 到令牌端点,存下 `OAuthToken`,然后带上 `Authorization: Bearer ...` 重放你原来的请求。 + +如果收到的 `403` 的 `WWW-Authenticate` 指明 `insufficient_scope`,会用你的 `scope` 与质询中的 scope 的并集重新执行第 2、3 步。(`scope` 从来都只是请求;本页的授权服务器只授予 ID-JAG 写明的内容,别的一概不给。)整个过程里没有刷新令牌:访问令牌过期后,下一个 `401` 会铸造一个新的 ID-JAG 再次交换,**这**正是 IdP 手里握着的杠杆。失败时的异常和 **[OAuth 客户端](oauth-clients.md)** 其余部分一样是那两个:发现和校验阶段是 `OAuthFlowError`,令牌端点拒绝时是它的子类 `OAuthTokenError`。 + +## 授权服务器 {#the-authorization-server} + +大多数时候到这里就可以停了。MCP 授权服务器是别人的产品,接受 ID-JAG 是它那边要打开的配置,SDK 负责的那一半 [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) 就是上面的客户端。 + +SDK 也可以自己**充当**授权服务器:`create_auth_routes` 以列表形式返回授权服务器的路由,任何 Starlette 应用都能挂载,仓库里的 `examples/servers/simple-auth/` 就是这样跑起一个的。SEP-990 给这个接口面加了一个开关和一个方法: + +```python title="auth_server.py" hl_lines="48-50 105-107" +--8<-- "docs_src/identity_assertion/tutorial002.py" +``` + +* `identity_assertion_enabled=True` 是总开关。关闭时(这是默认),即使你实现了钩子,`/token` 对这种授权许可也回答 `unsupported_grant_type`,元数据里也不会提到它。打开后,元数据会多出 `jwt-bearer` 授权类型,并在 `authorization_grant_profiles_supported` 里列出 `urn:ietf:params:oauth:grant-profile:id-jag`,这是扩展用来宣告支持的字段。(这个 SDK 的客户端从不读它:它只为一个 issuer 配置,直接发请求就是了。) +* **`exchange_identity_assertion`** 就是那个钩子。它运行之前,SDK 已经认证了客户端,拒绝了公开客户端,也拒绝了注册信息里没有列出该授权许可的客户端。你拿到一个 `IdentityAssertionParams`(原始的 `assertion`、请求的 `scopes` 和 `resource`),返回一个普通的 `OAuthToken`。 +* 动态客户端注册无条件拒绝这种授权许可,所以这里的 `get_client` 提供的是一个手工配置的客户端。ID-JAG 客户端没法靠自我注册凭空出现。 +* 这个类有一半是拒绝。`OAuthAuthorizationServerProvider` 是**整个**授权服务器,所以它也要求实现授权码流程;一个同时让用户登录的服务器会真正实现那些方法,而这一个只开一扇门。 + +!!! warning + SDK 从不解码断言:只有你的部署知道它信任哪个 IdP、那个 IdP 发布哪些密钥,所以 `exchange_identity_assertion` 里的每一步都是承重的。按 [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) §3,用 IdP 发布的密钥(它的 JWKS;这里的共享密钥只是演示用的)验证签名,并校验 `iss` 和 `exp`。要求 JWT 头的 `typ` 为 `oauth-id-jag+jwt`,这是 profile 防止别的 JWT 被当作授权许可重放的防护。要求 `aud` 是你自己的 issuer。要求 ID-JAG 的 `client_id` 声明等于处理函数认证过的那个客户端,它的 `resource` 声明指明一个你确实提供的资源。跟踪 `jti` 直到断言的 `exp`,保证它只被接受一次。授予的 scope,尤其是所签发令牌的 `resource`,要取自校验过的 ID-JAG,绝不取自请求:`params.resource` 是客户端随手填的。完整的处理规则见 [Enterprise-Managed Authorization 规范](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization)。 + +用 `TokenError("invalid_grant", ...)` 拒绝不合格的断言。这个流程里另一个错误码是 `invalid_target`:指明了你不提供的资源的 ID-JAG 就用它拒绝,正是它阻止了这个服务器为别人的资源铸造令牌。授予的 scope 来自 ID-JAG 的 `scope` 声明(没有这个声明的断言同样会被拒绝);你的实现也许会改为映射用户所属的组。 + +再注意返回的 `OAuthToken` 里没有什么:刷新令牌。IdP 通过决定是否签发下一个 ID-JAG 来决定这个用户能访问多久。在这里铸造刷新令牌,等于悄悄把这个决定权交了回去。 + +!!! info + 仍然用 `auth_server_provider=` 内嵌授权服务器的服务器,通过 `AuthSettings(identity_assertion_enabled=True)` 走到同一段代码。**[授权](../run/authorization.md)** 解释了为什么新服务器不应该从那里起步。 + +!!! check + 把本页的两个文件接在一起,整个授权许可就是一次 `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"} + ``` + + 没有 `/authorize`,没有 `/register`,没有获取 protected resource metadata。线路上仅有的请求是引出 `401` 的那个、well-known 获取、这次交换,然后就是带着 bearer 的普通 MCP 流量。而你的校验器从 ID-JAG 里读出的 `sub`,正是工具内部 `get_access_token().subject` 报告的值。 + +### 试一试 {#try-it} + +SDK 仓库里的 `examples/stories/identity_assertion/` 就是本页真实跑起来的样子:同一个 `exchange_identity_assertion` 校验器、一个靠它的令牌把关的 MCP 服务器、一个替身 IdP,还有客户端,都在一个自检程序里。`uv run python -m stories.identity_assertion.client --http` 会跑完整个交换,并断言 IdP 指明的用户就是工具看到的用户。 + +## 回顾 {#recap} + +* [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) 让企业身份提供方而不是最终用户来决定客户端可以访问哪些 MCP 服务器。IdP 把这个决定签进一个 **ID-JAG**。 +* 获取 ID-JAG 是对**你的 IdP** 做的一次 [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) 令牌交换,SDK 不做这一步。向 MCP 授权服务器出示它是 [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) 的 `jwt-bearer` 授权许可,这一步的两端 SDK 都做。 +* `IdentityAssertionOAuthProvider` 又是一个 `httpx2.Auth`:一个预注册的机密客户端、一个钉死的 `issuer`,加一个 `assertion_provider(audience, resource)` 回调。没有浏览器,没有注册,没有刷新令牌。 +* 授权服务器永远不会从资源服务器发现。把 `issuer` 配置成与它的元数据文档提供的字符串完全一致;比较是逐字符的。 +* 服务器端是 `identity_assertion_enabled=True` 加 `exchange_identity_assertion`。SDK 认证客户端并为授权许可把关;校验 ID-JAG 完全是你的事,签发的令牌绑定到 ID-JAG 的 `resource`,而不是请求里的。 + +本页唯一没碰过的一方是 MCP 服务器。它怎么处理你刚铸造的令牌,在 **[授权](../run/authorization.md)** 里早就在做了。 diff --git a/i18n/zh/pages/client/index.md b/i18n/zh/pages/client/index.md new file mode 100644 index 0000000000..5abdc0c5a8 --- /dev/null +++ b/i18n/zh/pages/client/index.md @@ -0,0 +1,207 @@ +--- +translation: + sections: [ebef1e7a0df854f4, a4c687d3d627d516, 8e79141fc2985342, b345dd05b9c3c7ab, 80ce41579825a6fa, 5f0fa90494de8f65, 83d10514eaa62fa5, 9190555aa39a5d28, 84a4c9d8bf14dddb, 927d71cf40b58c30] + tool: 1 +--- +# Client {#the-client} + +**`Client`** 是 Python 程序与 MCP 服务器对话的方式。 + +它是一个对象,只有一套生命周期:构造它,进入 `async with`,然后调用方法。每个协议动词(列出工具、调用工具、读取资源、渲染提示词)都是它上面的一个 `async` 方法,返回带类型的结果。 + +## 你的第一个客户端 {#your-first-client} + +```python title="client.py" hl_lines="14-18" +--8<-- "docs_src/client/tutorial001.py" +``` + +顶部的服务器只是为了让你有东西可连。客户端就是高亮的那五行。 + +* `Client(mcp)` 接收的是**服务器对象本身**。这是内存传输:没有子进程,没有端口,没有 HTTP。本页的每个示例,以及你写的每个测试,都是这样连接的。 +* `async with` 就是**生命周期**。进入时连接并协商;离开时断开。没有 `connect()` / `close()` 这样的配对方法,而且 `Client` 在代码块结束后不能复用。 +* 在代码块内部,连接相关的信息已经作为普通属性摆在那里了。 + +### 可以传给 `Client` 什么 {#what-you-can-pass-to-client} + +`Client` 接收一个位置参数,并根据它的类型确定传输方式: + +* `MCPServer`(或低层 `Server`)实例:**进程内**连接。 +* URL 字符串(`Client("http://localhost:8000/mcp")`):Streamable HTTP,生产环境的路径。 +* **传输**:任何可以 `async with ... as (read, write)` 的对象,比如包装子进程的 `stdio_client(...)`。 + +本页其余内容在这三种方式下完全相同。请求头、子进程、超时以及 `Transport` 协议另有专页:**[客户端传输](transports.md)**。 + +### 已连接的客户端上有什么 {#whats-on-a-connected-client} + +四个只读属性,进入代码块的那一刻就已填好: + +* `client.server_info`:服务器的身份信息;对于不报告身份的 2026 时代服务器则为 `None`(python-sdk 服务器默认会报告)。这里 `server_info.name` 是 `"Bookshop"`,`server_info.version` 是服务器报告的版本。 +* `client.server_capabilities`:服务器能做什么(`tools`、`resources`、`prompts`、`completions`……)。服务器没有的能力是 `None`。 +* `client.protocol_version`:双方商定的协议版本。这里是 `"2026-07-28"`。 +* `client.instructions`:服务器的 `instructions=` 字符串,没设置则为 `None`。 + +你从没选过协议版本。默认情况下,`Client` 会探测服务器,遇到较老的服务器就回退到经典握手,所以一个客户端能对接任何时代的服务器。需要控制这一点时,详见 **[协议版本](../protocol-versions.md)**。 + +!!! tip + `client.session` 是底层的 `ClientSession`,即低层的逃生出口。本页的任何内容都用不到它。 + +## 列出工具 {#listing-tools} + +```python title="client.py" hl_lines="15-20" +--8<-- "docs_src/client/tutorial002.py" +``` + +`list_tools()` 返回 `ListToolsResult`;工具在 `.tools` 里。每一个都是宿主会交给模型的完整定义: + +```python +tool.name # 'search_books' +tool.title # 'Search the catalog' +tool.description # 'Search the catalog by title or author.' +``` + +而 `tool.input_schema` 是服务器从函数类型注解推导出的 JSON Schema: + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"default": 10, "title": "Limit", "type": "integer"} + }, + "required": ["query"], + "title": "search_booksArguments" +} +``` + +UI 渲染参数表单所需的一切,以及模型生成合法参数所需的一切,都在这个模式里。 + +!!! tip + `title` 是可选的,所以把工具展示给人看的 UI 必须做选择:有 `title` 就用它,没有就用 `name`。`from mcp.shared.metadata_utils import get_display_name` 做的正是这件事,适用于工具、资源、资源模板和提示词。 + +## 调用工具 {#calling-a-tool} + +`call_tool(name, arguments)` 运行工具,返回 `CallToolResult`。 + +```python title="client.py" hl_lines="26-33" +--8<-- "docs_src/client/tutorial003.py" +``` + +服务器的 `lookup_book` 返回一个 Pydantic `Book`。客户端看到的是这样的: + +```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 +``` + +一个返回值,三样东西要读。各自有不同的使用者。 + +### `content`:模型读的内容 {#content-what-the-model-reads} + +`content` 是一个**内容块**的 `list`,而内容块是一个联合类型:`TextContent`、`ImageContent`、`AudioContent`、`ResourceLink` 或 `EmbeddedResource`。一个工具可以返回多个不同种类的块。 + +这就是为什么 `main` 在碰 `block.text` 之前先用 `isinstance(block, TextContent)` 收窄类型。注意 `isinstance` 之外没有出现 `.text`:类型检查器不允许,因为 `ImageContent` 有的是 `.data`,不是 `.text`。这个联合类型如实表达了工具可以发给你什么;你的代码也应该如此。 + +### `structured_content`:应用程序读的内容 {#structured_content-what-your-application-reads} + +`structured_content` 是工具返回值的 JSON 形式,符合工具声明的 `output_schema`。不用解析字符串,不用猜。 + +两者同时存在时,是有意把同一件事说两遍:`content` 给模型,`structured_content` 给代码。结构化这一半从哪里来、如何控制,见 **[结构化输出](../servers/structured-output.md)** 页面。 + +### `is_error`:工具是否失败 {#is_error-whether-the-tool-failed} + +抛出异常的工具**不会**在客户端里抛出异常。它作为一个普通结果返回,带 `is_error=True`。 + +!!! check + 向 `lookup_book` 查询 `"Solaris"`(目录里没有的书名),函数会抛出 `ValueError`。调用仍然正常返回: + + ```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 + ``` + + 异常消息落在了 `content` 里,**模型**可以读到它并重试。这是有意为之:工具错误是对话的一部分,不是崩溃。在相信 `structured_content` 之前,务必先看 `is_error`。 + +!!! warning + `is_error=True` 涵盖的不只是你自己的 `raise`。请求一个服务器根本没有的工具(`call_tool("does_not_exist", {})`),什么异常都不会抛出。返回的形状相同:`is_error=True`,`content` 里是 `Unknown tool: does_not_exist`。只有当服务器回复的是 JSON-RPC **错误**而不是结果时,`Client` 方法才会抛出 `MCPError`;服务器在什么情况下产生哪一种,见 **[处理错误](../servers/handling-errors.md)**。 + +## 资源 {#resources} + +资源动词成对出现:两种列出方式,一种读取方式。 + +```python title="client.py" hl_lines="22-31" +--8<-- "docs_src/client/tutorial004.py" +``` + +* `list_resources()` 返回**具体**资源,即 URI 固定的那些。这里是 `['catalog://genres']`。 +* `list_resource_templates()` 返回**参数化**的资源。这里是 `['catalog://genres/{genre}']`。它们是两个不同的列表,因为模板在填好之前是不可读的。 +* `read_resource(uri)` 接收一个普通的 `str` URI,对两者都适用:传入 `"catalog://genres/poetry"`,服务器会把它匹配到模板上。 + +`read_resource` 返回 `contents`,一个由 `TextResourceContents` 或 `BlobResourceContents` 组成的列表。思路和工具内容一样:用 `isinstance` 收窄,再读 `.text`(或 `.blob`)。 + +客户端还可以在资源变化时收到通知。在 2025 时代的连接上,这是 `subscribe_resource(uri)` / `unsubscribe_resource(uri)`——`MCPServer` 没有实现这对方法,所以在 2026-07-28 线路上(这些动词已不存在),请求会回复 `-32601`,即“Method not found”。2026 的替代方案是 `subscriptions/listen` 流,`MCPServer` **确实**提供它——那里 `server_capabilities.resources.subscribe` 为 `True`——用 `client.listen(...)` 消费它的方法见本节的 **[订阅](subscriptions.md)** 页面。 + +## 提示词 {#prompts} + +```python title="client.py" hl_lines="15-20" +--8<-- "docs_src/client/tutorial005.py" +``` + +`list_prompts()` 告诉你服务器提供什么,以及每个提示词需要什么: + +```python +prompt.name # 'recommend' +prompt.title # 'Recommend a book' +prompt.arguments # [PromptArgument(name='genre', required=True)] +``` + +`get_prompt(name, arguments)` 渲染它。参数字典是 `str -> str`:提示词参数永远是字符串。结果是 `messages`,一个 `PromptMessage` 列表,每个带有 `role` 和一个 `content` 块: + +```python +message.role # 'user' +message.content # TextContent(type='text', text='Recommend one poetry book from the catalog and say why.') +``` + +宿主把这些消息直接交给模型。整个功能就这些。 + +## 补全 {#completions} + +带有补全处理函数的服务器可以在用户输入时自动补全提示词和资源模板的参数。 + +```python title="client.py" hl_lines="27-31" +--8<-- "docs_src/client/tutorial006.py" +``` + +* `ref` 指明正在填写**哪个**提示词或模板:`PromptReference` 或 `ResourceTemplateReference`。 +* `argument` 是 `{"name": ..., "value": ...}`:参数名以及用户目前输入的内容。 + +答案在 `result.completion.values` 里。输入 `"p"`,服务器返回 `['poetry']`。服务器端的写法,以及处理函数如何利用**其他**已填好的参数来缩小建议范围,见 **[补全](../servers/completions.md)** 页面。 + +## 分页 {#pagination} + +每个 `list_*` 方法都接收 `cursor=` 关键字参数,每个结果都带 `next_cursor`。`next_cursor` 为 `None` 时,说明已经拿全了。 + +```python title="client.py" hl_lines="22-30" +--8<-- "docs_src/client/tutorial007.py" +``` + +这个循环对任何服务器都正确。`MCPServer` 一页返回全部内容,所以 `next_cursor` 是 `None`,循环只跑一次,这也是为什么大多数代码从来不写它。真正分页的服务器,以及游标遵守的规则,见 **[分页](../advanced/pagination.md)**。 + +## 在测试中 {#in-tests} + +没有进程、没有端口的 `Client(mcp)`,本身就是服务器的测试工具。 + +有一个构造参数专为此而设:`Client(mcp, raise_exceptions=True)`。它只对内存连接生效,**[测试](../get-started/testing.md)** 页面会解释它,并围绕它搭建完整的模式。 + +## 回顾 {#recap} + +* `Client(x)` 传入服务器对象时走内存连接,传入 URL 字符串时走 Streamable HTTP,其他情况通过传输连接。 +* `async with` 就是全部生命周期。在它内部,`server_capabilities` 和 `protocol_version` 已经填好;服务器提供时,`server_info` 和 `instructions` 也已填好。 +* `list_tools()` 给出每个工具的 `name`、`title`、`description` 和 `input_schema`。 +* `call_tool()` 返回给模型的 `content`、给代码的 `structured_content`,以及 `is_error`。抛异常的工具是一个结果,不是异常。 +* `content` 是块类型的联合;读取前先用 `isinstance` 收窄。 +* `list_resources` / `list_resource_templates` / `read_resource`、`list_prompts` / `get_prompt` 和 `complete` 补齐了全部动词。 +* 每个 `list_*` 都接收 `cursor=`;循环到 `next_cursor` 为 `None` 为止。 + +服务器可以向**客户端**请求什么,以及你如何回应,见 **[客户端回调](callbacks.md)**。 diff --git a/i18n/zh/pages/client/oauth-clients.md b/i18n/zh/pages/client/oauth-clients.md new file mode 100644 index 0000000000..c57558d345 --- /dev/null +++ b/i18n/zh/pages/client/oauth-clients.md @@ -0,0 +1,143 @@ +--- +translation: + sections: [c6899d3892bd9fa0, 79372cff3cc48a88, 63878d29e87c3e73, 13175843d3588af4, e7e2b9fd516f77de, 758f06399b513c1f, a05d7278487d610b] + tool: 1 +--- +# OAuth 客户端 {#oauth-clients} + +有些 MCP 服务器是受保护的。不带令牌向它们发送请求,它们会回答 `401 Unauthorized`。 + +**`OAuthClientProvider`** 就是获取令牌的办法。它根本不是 MCP 对象,而是一个 `httpx2.Auth`,也就是 httpx2 中“对每个请求做点什么”的标准钩子。把它挂到 `httpx2.AsyncClient` 上,把这个客户端交给 Streamable HTTP 传输,然后就不用再管它了。 + +本页讲的是客户端一侧。让你自己的服务器要求令牌,见 **[授权](../run/authorization.md)**。 + +## 提供者 {#the-provider} + +```python title="client.py" hl_lines="44-54" +--8<-- "docs_src/oauth_clients/tutorial001.py" +``` + +需要给它四样东西: + +* `server_url`:要连接的 MCP 端点。提供者从它出发发现其余一切。 +* `client_metadata`:你会在授权服务器的“注册应用”表单里填写的内容。 +* `storage`:令牌在多次运行之间存放的地方。 +* `redirect_handler` 和 `callback_handler`:需要人参与的两个时刻。 + +文件里其他地方都没有提到 OAuth。`main()` 从头到尾看不到令牌。 + +### 客户端元数据 {#client-metadata} + +`OAuthClientMetadata` 就是真正的 [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591) 注册文档,以 Pydantic 模型的形式存在。 + +只需设置三个字段,其余由默认值补齐:`grant_types` 已经是 `["authorization_code", "refresh_token"]`,`response_types` 已经是 `["code"]`,正好是这个提供者运行的流程。 + +!!! check + 因为它是 Pydantic 模型,所以**在任何一个字节发到网络之前**就会校验。漏掉 `redirect_uris`,构造当场失败,抛出的 `ValidationError` 会点名该字段: + + ```text + redirect_uris + Field required [type=missing, input_value={'client_name': 'Bookshop Agent'}, input_type=dict] + ``` + + 没有打开浏览器,也不会在授权服务器上留下注册了一半的记录。 + +### 令牌存储 {#token-storage} + +**`TokenStorage`** 是一个带四个异步方法的 `Protocol`。不用继承任何东西;写出这些方法,任何类就都是令牌存储: + +* `get_tokens` / `set_tokens` 保存 `OAuthToken`:访问令牌、刷新令牌、过期时间、作用域。 +* `get_client_info` / `set_client_info` 保存提供者替你注册时授权服务器颁发的 `OAuthClientInformationFull`,其中包含你的 `client_id`。 + +上面的内存版本可以工作。但进程退出时它会忘掉一切,于是下一次运行又要把整套流程重走一遍。把它持久化到文件或平台的密钥环里,下一次运行就悄无声息了。 + +!!! tip + 要存 `client_info`,而不只是令牌。提供者在第一次找不到已存的 `client_info` 时会动态注册。把它扔掉,每次运行都会生成一个全新的注册。 + +### 两个处理函数 {#the-two-handlers} + +授权码流程恰好需要人参与一次:得有人登录并点击“允许”。 + +* **`redirect_handler`** 会以构建完整的授权 URL 为参数被 await。`client_id`、`redirect_uri`、`state` 和 PKCE challenge 都已经在里面了。你唯一要做的是让浏览器打开它。桌面应用调用 `webbrowser.open`;这个文件把它打印出来。 +* **`callback_handler`** 紧接着被 await。它一直等到用户回到你的 `redirect_uri`,然后把那次重定向的查询参数作为 `AuthorizationCodeResult` 返回。 + +真实的客户端会在重定向 URI 上运行一个小型本地 HTTP 服务器,而不是调用 `input()`。形式完全一样:被重定向,交回 `code`、`state` 和 `iss`。 + +!!! warning + `state` 和 `iss` 要原样传递,收到什么就交回什么。提供者会把 `state` 与自己生成的值比较,把 `iss` 与发现到的颁发者比较,不匹配就拒绝。它们分别是 CSRF 防御和服务器混淆防御。 + +### 接入 `Client` {#into-the-client} + +看一下 `main()`。提供者挂在 **httpx2 客户端**上,httpx2 客户端传入 `streamable_http_client(url, http_client=...)`,这个传输再传入 `Client`。 + +`streamable_http_client` 没有 `auth=` 关键字。所有 HTTP 层面的东西(认证、请求头、超时、代理)都属于你自己带来的 `httpx2.AsyncClient`。这种分层详见 **[客户端传输](transports.md)**。 + +## 提供者替你做了什么 {#what-the-provider-does-for-you} + +`Client` 第一次发送请求时,服务器回答 `401`。提供者接手: + +1. **发现。** 它读取 `WWW-Authenticate` 头,从 `/.well-known/oauth-protected-resource` 获取服务器的受保护资源元数据,得知是哪个授权服务器在保护这个资源,再去获取**那个**服务器的元数据。 +2. **注册。** 存储里什么都没有?它用你的 `OAuthClientMetadata` 动态注册,并把结果存起来。 +3. **授权。** 它生成 PKCE 对和一个 `state`,构建授权 URL,await 你的 `redirect_handler`,然后 await 你的 `callback_handler` 拿到授权码。 +4. **交换。** 它用授权码换来 `OAuthToken`,存起来,再带上 `Authorization: Bearer ...` 重放你最初的请求。 + +之后它就安静了。令牌从存储里取出,过期的访问令牌用刷新令牌刷新,只有这些都行不通时才会重新跑一遍流程。 + +这些你一行都没写。还剩两个关键字参数(`client_metadata_url` 和 `validate_resource_url`),这个文件都用不到。值得了解的是 `client_metadata_url`,下面单独有一节讲它。 + +### 试一试 {#try-it} + +这份文档里的大多数示例都可以用内存中的 `Client(server)` 验证。这个不行:整个流程的核心就是一个 HTTP `401`,而内存中的客户端和它的服务器之间没有 HTTP。 + +仓库里附带了可实际运行的版本。`examples/servers/simple-auth/` 运行一个独立的授权服务器和一个受保护的 MCP 服务器;`examples/clients/simple-auth-client/` 是本页的客户端扩展成的一个小 CLI。它的 README 里有两条命令:启动服务器,对着它们运行客户端,就能看到这四个步骤依次走过。 + +## Client ID Metadata Documents {#client-id-metadata-documents} + +规范的 2026-07-28 修订版弃用了动态客户端注册,改用 **Client ID Metadata Documents**(CIMD)。客户端不再向遇到的每个授权服务器 POST 一份新的注册,而是在一个稳定的 HTTPS URL 上发布一份描述自己的 JSON 文档,这个 URL **就是**它的 `client_id`。授权服务器去获取这份文档;提供者从不碰它。 + +SDK 已经支持它:构造提供者时把这个 URL 作为 `client_metadata_url=` 传入。当授权服务器的元数据声明了 `client_id_metadata_document_supported: true` 时,提供者会完全跳过 `/register` 请求:URL 作为 `client_id` 进入流程,没有 `client_secret`。当服务器没有声明它(目前大多数还没有),或者你没有传 URL 时,提供者会**悄悄地**回退到动态注册,上面的一切照常工作。已存的 `client_info` 仍然优先于这两者。 + +URL 必须是 HTTPS 且路径不能是根路径;否则在构造时就是 `ValueError`,不会发生任何网络请求。附带的 `examples/clients/simple-auth-client/` 通过环境变量 `MCP_CLIENT_METADATA_URL` 接收它。 + +## 机器对机器 {#machine-to-machine} + +夜间任务、CI 步骤、另一个服务。没有浏览器,也没人来点“允许”。这就是 **client credentials** 授权方式:你手里已经有 `client_id` 和 `client_secret`,令牌端点就是整个流程。 + +`ClientCredentialsOAuthProvider` 是同一个 `httpx2.Auth`,只是去掉了人: + +```python title="client.py" hl_lines="4 27-33" +--8<-- "docs_src/oauth_clients/tutorial002.py" +``` + +变了什么: + +* 没有 `OAuthClientMetadata`,没有处理函数。传入 `client_id` 和 `client_secret`;提供者围绕它们构建一个最小的 `client_credentials` 注册,完全跳过动态注册。 +* `scope` 是空格分隔的字符串,即 OAuth 的线路格式。 +* 下游的一切完全相同:同样的 `TokenStorage`、同样的 `httpx2.AsyncClient(auth=...)`、同样的 `streamable_http_client`。 + +默认情况下,密钥在令牌请求里以 HTTP Basic 认证的方式传送(`client_secret_basic`)。传入 `token_endpoint_auth_method="client_secret_post"` 可以改为把它放进表单体。有些授权服务器只接受两者之一。 + +!!! tip + 从环境变量或密钥管理器读取 `client_secret`,绝不要从源码版本控制里读。 + +!!! info + `mcp.client.auth.extensions.client_credentials` 里还有一个提供者:**`PrivateKeyJWTOAuthProvider`**,用于以 JWT 而非共享密钥进行认证的客户端(`private_key_jwt`,即密钥对和工作负载身份那一类)。它遵循同样的模式:构造一个,放到 `auth=` 上。同一个模块还附带 `SignedJWTParameters` 和 `static_assertion_provider`,两个用来构建其断言的辅助工具。 + +还有一种没有人参与的情形:客户端属于某个企业,由企业的身份提供者而不是用户来决定它可以访问哪些 MCP 服务器。那是另一种授权方式,有自己的信任模型和自己的页面,**[身份断言](identity-assertion.md)**。 + +## 出错时 {#when-it-fails} + +OAuth 流程出错时,提供者会抛出 `mcp.client.auth` 里的 `OAuthFlowError`。它有两个子类。`OAuthRegistrationError` 表示注册没有产生一个可用的客户端:授权服务器拒绝为你注册,或者它确实注册了,但给出的凭据这个流程用不了(比如它没有实现的认证方法)。`OAuthTokenError` 表示无法获取令牌:令牌端点拒绝了,或者已存的客户端记录带有这个客户端无法应用的认证方法,这种情况在构建令牌请求时就会报告,而不会发送出去。一个 `except OAuthFlowError:` 就覆盖了发现、注册、授权和交换。 + +并非一切都是流程错误。网络仍然可能失败;那些是普通的 `httpx2` 异常,会原样透传。 + +## 回顾 {#recap} + +* `OAuthClientProvider` 是一个 `httpx2.Auth`。把它放到 `httpx2.AsyncClient` 上,再把后者传给 `streamable_http_client(url, http_client=...)`,`Client` 永远不知道发生过 OAuth。 +* 你提供四样东西:服务器 URL、一个 `OAuthClientMetadata`、一个 `TokenStorage`,以及 redirect/callback 处理函数对。 +* `TokenStorage` 是一个 `Protocol`:四个异步方法,没有基类。除了令牌,也要持久化 `client_info`。 +* 发现、注册(动态注册,或通过 **Client ID Metadata Document**)、PKCE、`state` 和 `iss` 检查,以及令牌刷新,都是提供者的事,不是你的。 +* `ClientCredentialsOAuthProvider` 是无人参与的版本:`client_id` + `client_secret`,没有处理函数,没有浏览器。 +* 每个 OAuth 失败都是 `OAuthFlowError`;`OAuthRegistrationError` 和 `OAuthTokenError` 是它的子类。 + +这次握手的另一半,让你的**服务器**要求令牌,见 **[授权](../run/authorization.md)**。 diff --git a/i18n/zh/pages/client/session-groups.md b/i18n/zh/pages/client/session-groups.md new file mode 100644 index 0000000000..1f68793f41 --- /dev/null +++ b/i18n/zh/pages/client/session-groups.md @@ -0,0 +1,85 @@ +--- +translation: + sections: [09c857a25a9dc37a, 43bc6a76a243a50e, 0a716022a88768df, 4b7f78042bfcfff7, c112662e61b03315, 58974ba1f489a8b4, d18adbdbb835ea73] + tool: 1 +--- +# 会话组 {#session-groups} + +一个 `Client` 连接一个服务器。实际应用往往需要好几个(一个搜索服务器、一个数据库服务器、一个内部 API),结果要为每个服务器各管一条连接和一份工具列表。 + +**`ClientSessionGroup`** 是一个对象,它持有多条连接,并把它们公开的所有内容合并成一个统一视图。 + +## 两个服务器 {#two-servers} + +先看两个普通的服务器。它们彼此毫无关联,所以很自然地都把自己的工具命名为 `search`: + +```python title="library_server.py" hl_lines="7" +--8<-- "docs_src/session_groups/tutorial001.py" +``` + +```python title="web_server.py" hl_lines="7" +--8<-- "docs_src/session_groups/tutorial002.py" +``` + +## 一个组 {#one-group} + +创建一个 `ClientSessionGroup`,对每个服务器调用一次 **`connect_to_server`**: + +```python title="client.py" hl_lines="10-12" +--8<-- "docs_src/session_groups/tutorial003.py" +``` + +* `connect_to_server` 接受的是传输参数,而不是服务器对象:用 `StdioServerParameters`(来自 `mcp`)启动子进程,或用 `StreamableHttpParameters` / `SseServerParameters`(来自 `mcp.client.session_group`)连接已在某个 URL 上监听的服务器。 +* `group.tools` 是一个 `dict[str, Tool]`,包含所有已连接服务器的工具。`group.resources` 和 `group.prompts` 形式相同。 +* `group.call_tool(name, arguments)` 查找名称,找到拥有它的会话,然后转发调用。不需要指明是哪个服务器。 + +!!! check + 把 `client.py` 放在两个服务器旁边运行。第二次 `connect_to_server` 会被拒绝: + + ```text + mcp.shared.exceptions.MCPError: {'search'} already exist in group tools. + ``` + + 这是一个 `MCPError`,在第二个服务器的任何内容注册之前就抛出了。名称必须在**整个**组内唯一,而两个不受你控制的服务器迟早会冲突。 + +## `component_name_hook` {#component_name_hook} + +这个问题在组这一层解决,而不是在服务器上。传入一个接受 `(name, server_info)` 的函数,组会对它注册的每个名称都运行这个函数: + +```python title="client.py" hl_lines="7-8 15" +--8<-- "docs_src/session_groups/tutorial004.py" +``` + +再运行一次。`print(sorted(group.tools))` 现在两个都显示了: + +```text +['Library.search', 'Web.search'] +``` + +* **键**由你决定。`by_server` 用 `server_info.name` 构造它,也就是每个 `MCPServer(...)` 构造时传入的名称。 +* 里面的 `Tool` 原封不动:`group.tools["Web.search"].name` 仍然是 `"search"`,这也是 `call_tool` 发到线路上的名称。前缀永远不会离开你的进程。 +* 不只是工具。library 的 `hours` 资源注册为 `Library.hours`。 + +!!! tip + 这个 hook 对**每个**服务器的**每个**名称都会运行,而不仅限于冲突的名称:没有"仅在冲突时加前缀"的模式。选定一种方案,让它处处生效。 + +## 添加和移除服务器 {#adding-and-removing-servers} + +`connect_to_server` 返回它打开的 `ClientSession`。如果以后想移除这个服务器,就保留它:`await group.disconnect_from_server(session)` 会把它的工具、资源和提示词从组中移除。 + +如果手上已经有一个已连接的 `ClientSession`(`Client.session` 就是一个),把它交给 `await group.connect_with_session(server_info, session)`,而不用打开新的传输。聚合方式相同。组永远不会关闭不是它自己打开的会话。`server_info` 为组件前缀提供服务器名称;在 2026 年代的连接上,`client.server_info` 可能是 `None`(身份是可选的),这种情况下传入你自己的 `Implementation(name=..., version=...)`。 + +## 经典握手 {#the-classic-handshake} + +`ClientSessionGroup` 构建在 `ClientSession` 之上,而不是 `Client`。每次 `connect_to_server` 都运行经典的 `initialize` 握手。它从不发送 **[协议版本](../protocol-versions.md)** 中描述的 `server/discover` 探测。每个 MCP 服务器都理解这种握手,所以这不会损失任何兼容性;它只意味着,面对一个本可以做得更好的服务器,组走的是较旧、较慢的路径。 + +## 回顾 {#recap} + +* `ClientSessionGroup` 持有多条服务器连接,并把它们的工具、资源和提示词各自合并成一个 `dict`。 +* 每个服务器调用一次 `connect_to_server(params)`。它接受传输参数,从不接受 `Client` 所接受的服务器对象或 URL。 +* `group.call_tool(name, arguments)` 替你路由到拥有该工具的服务器。 +* 名称必须在整个组内唯一;两个都有 `search` 工具的服务器无法直接共存。 +* `component_name_hook=` 改写每个注册的名称。改变的是 dict 的键,线路上的名称不变。 +* `connect_with_session` 添加一个你已持有的会话;`disconnect_from_server` 移除一个。 + +组所用的握手(以及 `Client` 更倾向的那种更快的握手)详见 **[协议版本](../protocol-versions.md)**。 diff --git a/i18n/zh/pages/client/subscriptions.md b/i18n/zh/pages/client/subscriptions.md new file mode 100644 index 0000000000..ed76f94674 --- /dev/null +++ b/i18n/zh/pages/client/subscriptions.md @@ -0,0 +1,88 @@ +--- +translation: + sections: [8f9558e57f29eee1, a88c587739e0465c, 46ebfd5b325ed041, 4d10b00b57ce4bd9, 2cdb0edd1f59b3e2] + tool: 1 +--- +# 订阅 {#subscriptions} + +服务器的目录不是固定的。工具会在运行时出现,资源 URI 背后的内容也会变化。客户端通过 `client.listen(...)` 获知这些变化:一个 `subscriptions/listen` 请求,它的响应**就是**流本身。流保持打开,承载客户端要求的那些变更通知。 + +本页讲的是客户端这一端:打开流、在主流程旁边监听它,以及处理它的各种结束方式。发布变更、过滤以及提供该方法的服务,是服务器那一侧的内容,见“在处理函数内部”下的 **[订阅](../handlers/subscriptions.md)**。这里的示例连接的是在那里构建的 sprint-board 服务器。 + +## 监听流 {#watching-the-stream} + +一个订阅就是一个上下文管理器。进入它会发送请求,把你传入的关键字参数作为订阅过滤器,并等待服务器的确认,所以代码块开始执行时流已经是活动的。 + +```python title="client.py" hl_lines="15 18 28" +--8<-- "docs_src/subscriptions/tutorial003.py" +``` + +迭代会产出四种带类型的事件:`ToolsListChanged`、`PromptsListChanged`、`ResourcesListChanged` 和 `ResourceUpdated(uri=...)`。 + +事件只说明**什么**变了,从不说明**怎么**变的。这就是 `follow_board` 调用 `read_resource` 和 `list_tools` 的原因:事件是重新获取的信号。读取 `event.uri`,不要假定是哪个资源变了:一个过滤器可以列出多个 URI,服务器也可能报告其中某个 URI 的子资源发生了变化。 + +等待消费的重复事件会合并成一个,重新获取拿到的依然是当前状态。只有完全相同的事件才会合并:两个针对不同 URI 的 `ResourceUpdated` 是两个事件。 + +这个句柄还有两个属性: + +* `sub.honored` 是服务器确认的过滤器:一个 `SubscriptionFilter`,带有你传入的字段,以属性方式读取(`sub.honored.prompts_list_changed`)。`MCPServer` 会满足你要求的每一种类型,所以它会把你的请求原样回显。支持类型较少的服务器确认的也更少,而一个被确认的类型也可能永远不会触发。服务器还可能拒绝整个请求而不是确认它(见服务器页面上的[决定谁可以监听](../handlers/subscriptions.md#deciding-who-may-watch)),这会以请求错误的形式出现。 +* `sub.subscription_id` 是 listen 请求的 id,也就是印在这个流每一帧上的那个 id。可以同时打开多个订阅,各自按自己的 id 解复用。 + +## 不阻塞地监听 {#watching-without-blocking} + +`follow_board` 会一直运行到服务器关闭流为止,而这可能永远不会发生,所以单独使用时它会占据你的整个程序。真实的客户端希望监听任务运行在主流程**旁边**:智能体调用工具的同时,监听任务让缓存或 UI 保持最新。 + +先打开订阅,再启动监听任务,然后继续做自己的事。 + +=== "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` 从第一个示例导入 `BOARD` 和 `read_board`,本仓库把那个示例保存为 `tutorial003.py`。如果你把渲染出的文件并排保存为 `client.py` 和 `app.py`,就改写成 `from client import BOARD, read_board`。更下面的 `watch.py` 示例也以同样的方式导入 `read_board`。 + +顺序是关键。没有任何内容会重放,所以在你的流存在之前发布的事件会被错过。进入 `client.listen(...)` 会等待确认,所以从那一刻起的每一个变更都会到达你的监听任务,而你在代码块内获取的快照不可能漏掉任何一个。 + +在打开的流旁边,请求可以自由运行,无论来自监听任务还是其他任何任务,都在同一个客户端上。因为**重复**的未消费事件会合并,一个繁忙的主流程可能只产生一次重新获取而不是三次。不同的事件不会合并:一个列出许多 URI 的过滤器会为每个 URI 排队一个待处理事件。 + +要停止监听,离开代码块即可:没有 `unsubscribe` 调用。取消拥有该代码块的任务会替你做到这一点,SDK 会按传输期望的方式取消 listen 请求:在 Streamable HTTP 上,就是关闭该请求的流。一个随应用整个生命周期运行的监听任务永远不会自行返回,所以在关闭时取消它,或者取消它所在任务组的作用域。 + +## 流会结束 {#streams-end} + +流以两种方式之一结束,两者都是普通的控制流。服务器优雅关闭会结束 `async for`;突然断开会抛出 `SubscriptionLost`。 + +两者的区别在于诊断意义,而不在于接下来该做什么:流没了,没有任何内容会重放,仍然关心的监听任务就重新 listen 并重新获取。 + +```python title="watch.py" hl_lines="16 20" +--8<-- "docs_src/subscriptions/tutorial005.py" +``` + +服务器会出于自己的原因优雅地关闭流,包括甩掉积压过多的订阅者,所以干净的结束并不是停止监听的信号。重新 listen 之前先退避。 + +`SubscriptionLost` 也有一个本地原因。客户端最多保存 1024 个未消费事件,落后到这个程度的消费者会失去订阅,而不是无限制地增长。让 `async for` 的循环体保持简短,把耗时的工作放到别处。 + +`keep_following` 只捕获 `SubscriptionLost`。进入 `listen()` 还可能抛出 `MCPError`(连接失败,或服务器不提供该方法)、`TimeoutError`(没有收到确认)和 `ListenNotSupportedError`(2026 之前的连接)。决定你的监听任务应该对其中哪些重试:最后一个永远不会自愈。 + +## 回顾 {#recap} + +* 进入 `async with client.listen(...)`;进入时会等待确认,所以之后发布的任何内容都不会漏掉。 +* 用 `async for event in sub` 迭代。事件是重新获取的信号,从来不是载荷。 +* 先打开订阅,再把监听任务作为任务运行,工具调用在旁边照常进行。 +* 干净的结束会停止循环;断开会抛出 `SubscriptionLost`。无论哪种:重新 listen、重新获取,先退避。 +* 离开代码块就是取消订阅。 + +发布这些事件、收窄过滤器以及扩展到单进程之外,是服务器那一侧的内容:**[订阅](../handlers/subscriptions.md)**。同样这些事件也能让客户端缓存保持可信,下一页是 **[缓存](caching.md)**。 diff --git a/i18n/zh/pages/client/transports.md b/i18n/zh/pages/client/transports.md new file mode 100644 index 0000000000..7b0c610ef7 --- /dev/null +++ b/i18n/zh/pages/client/transports.md @@ -0,0 +1,117 @@ +--- +translation: + sections: [9cac816674181eb0, 0700f337babcd4dd, 2bde0dd58cdf00f5, ff7401df479af877, 3d0832f39b0d7059, d4bf7e4479637768, 05e20c0a798860e7] + tool: 1 +--- +# 客户端传输 {#client-transports} + +每个 `Client` 都通过一种**传输**与它的服务器通信:真正承载消息的那一层。 + +你从来不需要单独配置它。`Client` 只接受一个位置参数,并根据它的类型推断出传输方式。 + +每种传输的**服务器**一侧(`mcp.run()` 做什么、你部署什么)见 **[运行你的服务器](../run/index.md)**。 + +## 内存中 {#in-memory} + +传入服务器对象本身: + +```python title="client.py" hl_lines="14" +--8<-- "docs_src/client_transports/tutorial001.py" +``` + +没有子进程,没有端口,线路上没有任何字节。客户端和服务器是同一个进程里的两个对象,而调用仍然走真实的协议层:`search_books` 的列出、校验和调用,和走 HTTP 时完全一样。 + +这让它同时具有两种用途: + +* **测试支架。** 本文档中的每个示例都是这样跑通的,**[测试](../get-started/testing.md)** 页面围绕它构建了整套模式。 +* **嵌入 API。** 自己构造服务器的应用不需要经过网络就能调用它的工具。 + +## Streamable HTTP {#streamable-http} + +传入一个 URL 字符串,得到的就是 **Streamable HTTP**,也就是部署时用的传输方式: + +```python title="client.py" hl_lines="5" +--8<-- "docs_src/client_transports/tutorial002.py" +``` + +这就是完整的生产环境客户端。`Client` 替你把 URL 包进 `streamable_http_client(...)`,底层是一个按 MCP 的需要配置好的 `httpx2.AsyncClient`:`follow_redirects=True`,connect/write/pool 超时 30 秒,读超时 300 秒,因为服务器可能会一直保持响应流打开。 + +!!! check + 构造出来的 `Client` **并未**连接。构造只是选定传输方式;打开它的是 `async with`。在进入之前就去取连接,SDK 会明确告诉你: + + ```text + RuntimeError: Client must be used within an async context manager + ``` + + 写下 `Client("http://...")` 时,没有解析任何东西,没有获取任何东西,也没有启动任何进程。这一行没有任何开销。 + +### 自带 `httpx2.AsyncClient` {#bring-your-own-httpx2asyncclient} + +一旦需要 `Authorization` 头、cookie、代理、mTLS 或不同的超时,就自己构建 `httpx2.AsyncClient`,再把它交给 `streamable_http_client`: + +```python title="client.py" hl_lines="8-14" +--8<-- "docs_src/client_transports/tutorial003.py" +``` + +注意两点: + +* `httpx2.AsyncClient` 归你所有,所以由**你**进入和退出它。SDK 从不关闭不是它自己创建的客户端。 +* `streamable_http_client(url, http_client=...)` 返回一个传输,`Client(transport)` 像接受其他任何东西一样接受它。 + +关于 TLS 的一点说明:`httpx2` 依据操作系统的信任库(通过 +[`truststore`](https://pypi.org/project/truststore/))校验证书,而不是自带的 CA 列表。在没有可用系统 CA 库的环境(某些精简容器)中,设置标准的 `SSL_CERT_FILE`/`SSL_CERT_DIR` +环境变量,或者给你的 `httpx2.AsyncClient` 显式传入 `verify=ssl_context`(背景见 +[`httpx` 和 `httpx-sse` 被 `httpx2` 取代](../migration.md#httpx-and-httpx-sse-replaced-by-httpx2))。 + +!!! warning + `streamable_http_client` 过去可以直接接受 `headers=` 和 `timeout=`。现在不行了:它只有 `url`、`http_client` 和 `terminate_on_close` 三个参数。习惯性地去用 `headers=`,会得到: + + ```text + TypeError: streamable_http_client() got an unexpected keyword argument 'headers' + ``` + + 所有 HTTP 层面的东西现在都放在你传入的那一个 `httpx2.AsyncClient` 上。 + +!!! info + `httpx2` 保留了熟悉的 `httpx` API,所以只要会 `httpx`,就已经知道在这里怎么做认证、代理、事件钩子、重试和连接限制。SDK 既不在上面加东西,也不拿走什么。OAuth 也是在这里接入的:`httpx2.AsyncClient(auth=OAuthClientProvider(...))`。整个流程见 **[OAuth 客户端](oauth-clients.md)**。 + +## stdio {#stdio} + +**stdio** 服务器是一个子进程。客户端启动它,向它的 stdin 写 JSON-RPC,从它的 stdout 读 JSON-RPC。桌面宿主就是这样在你的机器上运行服务器的:宿主**就是**这段代码加上一个 UI,而 **[连接到真实宿主](../get-started/real-host.md)** 是从宿主一侧、以配置文件的形式看到的同一种关系。 + +用 `StdioServerParameters` 描述进程,用 `stdio_client` 把它变成传输,再把**它**交给 `Client`: + +```python title="client.py" hl_lines="4-8 12" +--8<-- "docs_src/client_transports/tutorial004.py" +``` + +`Client` 不接受单独的参数对象。`StdioServerParameters` 是配置;`stdio_client(server)` 才是知道如何据此启动进程的传输。一定要包一层。 + +离开 `async with` 块也会关停子进程:关闭 stdin,等待,如果它迟迟不退出就杀掉。你从来不需要自己清理。 + +!!! warning + 子进程**不会**继承你的环境。它只拿到一个最小的允许列表(POSIX 上是 `HOME`、`LOGNAME`、`PATH`、`SHELL`、`TERM` 和 `USER`),这样敏感信息就不会泄漏进一个可能不是你写的进程。 + + 需要 API key 的服务器在那里找不到它。用 `env=` 显式传入;这些变量会合并到允许列表之上。上面的 `BOOKSHOP_API_KEY` 做的就是这件事。 + +## SSE {#sse} + +`sse_client(url)` 来自 `mcp.client.sse`,是被 Streamable HTTP 取代的那个 HTTP 传输。用同样的方式包一层,`Client(sse_client("http://localhost:8000/sse"))`,就能和仍在使用它的服务器通信;不要在它之上构建任何新东西。 + +## `Transport` 协议 {#the-transport-protocol} + +对 `Client` 来说,上面这些都是同一种东西。 + +**传输**是任何能产出一对 `(read, write)` 消息流的异步上下文管理器:正式地说,就是 `mcp.client` 中的 `Transport` 协议。`Client` 按类型解析它的参数:服务器对象在进程内连接,`str` 变成 `streamable_http_client(url)`,其他任何东西都直接作为传输进入。正是最后这条规则让 `stdio_client(...)`、`streamable_http_client(...)` 和 `sse_client(...)` 都能放进同一个位置,也让你可以自己写一个。 + +## 回顾 {#recap} + +* `Client(mcp)`(服务器对象)在内存中连接。用于测试和嵌入。 +* `Client("http://.../mcp")`(URL)通过 Streamable HTTP 连接,即生产环境的传输方式。 +* 请求头、认证、代理和超时应放在 `httpx2.AsyncClient` 上,再传给 `streamable_http_client(url, http_client=...)`。没有 `headers=` 关键字参数。 +* stdio 是 `Client(stdio_client(StdioServerParameters(...)))`,绝不是单独的参数对象。 +* 子进程拿到的是允许列表里的环境,不是你的环境;`env=` 往里添加。 +* 传输就是任何可以 `async with x as (read, write)` 的东西。凡不是服务器对象或 URL 的参数,`Client` 都直接交给这个协议。 +* 构造 `Client` 选定传输方式。`async with` 打开它。 + +传输打开之后,两边必须就协议版本达成一致。通常根本不用考虑它;需要考虑的时候,去看 **[协议版本](../protocol-versions.md)**。 diff --git a/i18n/zh/pages/deprecated.md b/i18n/zh/pages/deprecated.md new file mode 100644 index 0000000000..eab92f5d65 --- /dev/null +++ b/i18n/zh/pages/deprecated.md @@ -0,0 +1,86 @@ +--- +translation: + sections: [20541a40dbdd5980, 01262a123ad9501d, 429db5b574a2ac08, 56b2d49da412cb28, 6a1717123fe4513c] + tool: 1 +--- +# 已弃用的功能 {#deprecated-features} + +2026-07-28 规范让五项内容退役。SDK 仍然实现了其中每一项,而且每一项现在都带有**弃用警告**。 + +下表列出了每一项已弃用的功能、它为什么要退场,以及应该改用的替代方案。 + +## 弃用了什么 {#what-is-deprecated} + +| 已弃用 | 原因 | 替代做法 | +|---|---|---| +| **根目录(roots)**:`ctx.session.list_roots()`、`client.send_roots_list_changed()`、传给 `Client(...)` 的 `list_roots_callback=` | [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) 弃用了这一能力。 | 把路径作为普通的工具参数或资源 URI 传入,或者在 `InputRequiredResult` 中嵌入一个 `ListRootsRequest`(见 **[多轮往返(multi-round-trip)请求](handlers/multi-round-trip.md)**)。 | +| **服务器发起的采样(sampling)**:`ctx.session.create_message()`、传给 `Client(...)` 的 `sampling_callback=` | SEP-2577 弃用了这一能力。 | 返回 `InputRequiredResult`,让客户端重试该调用(见 **[多轮往返请求](handlers/multi-round-trip.md)**)。 | +| **协议日志**:`ctx.log()`、`ctx.debug()`、`ctx.info()`、`ctx.warning()`、`ctx.error()`、`ctx.session.send_log_message()`、`client.set_logging_level()` | SEP-2577 弃用了这一能力。协议内没有任何替代。 | 用普通的 `import logging` 输出到 stderr(见 **[日志](handlers/logging.md)**)。 | +| **`ping`**:`client.send_ping()` | 从协议中**移除**,而不仅仅是弃用。2026-07-28 中没有 `ping` 方法。 | 无。它只在 `mode="legacy"` 连接上有效。 | +| **客户端->服务器进度**:`client.send_progress_notification()` | 2026-07-28 规定进度只能由服务器发往客户端。 | 没有什么可发送的。你的**服务器**用 `ctx.report_progress()` 报告进度(见 **[进度](handlers/progress.md)**)。 | + +从这张表可以看出三点: + +* 根目录、采样和日志是一起的。一份提案 **SEP-2577** 一次性弃用了这三项能力。 +* 采样和根目录有一个更深层的共同问题:它们都是**服务器**向**客户端**发送**请求**的地方。2026-07-28 用 **[多轮往返请求](handlers/multi-round-trip.md)** 取代的正是这整个方向。消失的是独立的 RPC 方法(`sampling/createMessage`、`roots/list` 和推送式的 `elicitation/create`);`CreateMessageRequest` / `ListRootsRequest` / `ElicitRequest` 这些载荷类型保留了下来,嵌入在 `InputRequiredResult.input_requests` 中,在客户端它们触发的还是同样的回调。 +* `ping` 是个例外。协议不是弃用它,而是移除它。SDK 的方法仍会发出警告(消息里写的是“removed”,而不是“deprecated”),在现代连接上调用它会得到“Method not found”的回应。 + +## 弃用只是建议性的 {#deprecated-is-advisory} + +今天什么都不会坏。 + +上面的每个方法在任何协商为 **2025-11-25 或更早版本**的会话上都能继续工作。在客户端固定 `mode="legacy"`,得到的就是 2026 之前的行为,分毫不差。线路上没有任何变化,能力协商也没有变。 + +变化在于,每个方法第一次运行时你会看到一条醒目的警告: + +```text +MCPDeprecationWarning: The logging capability is deprecated as of 2026-07-28 (SEP-2577). +``` + +`MCPDeprecationWarning` 继承自 `UserWarning`,而**不是** `DeprecationWarning`。这是有意为之:Python 的默认过滤器只在直接作为 `__main__` 运行的代码中显示 `DeprecationWarning`,库就是这样弃用东西、然后两年都没人注意到的。这个警告到处都会显示,不需要 `-W` 标志。 + +!!! warning + “建议性”止于线路。采样和根目录是服务器发往客户端的**请求**,而 2026-07-28 会话没有承载这类请求的通道。在现代连接上的工具里调用 `ctx.session.create_message()`,警告照样触发,然后发送失败并报错: + + ```text + Cannot send 'sampling/createMessage': this transport context has no back-channel + for server-initiated requests. + ``` + + 两个信号,按这个顺序。`MCPDeprecationWarning` 在你调用方法的那一刻触发,任何连接上都是如此。错误是 SDK 随后尝试发送时返回的结果。这两个功能只有在 `mode="legacy"` 连接上、且客户端注册了对应回调时,才能端到端地工作。 + +## 屏蔽警告 {#silencing-the-warning} + +新代码里不要这样做。 + +但如果你维护的服务器确实在为 2026 之前的客户端提供服务,它完全有理由要一份安静的日志。在第一个已弃用调用运行之前过滤掉这个类别: + +```python +import warnings + +from mcp import MCPDeprecationWarning + +warnings.filterwarnings("ignore", category=MCPDeprecationWarning) +``` + +整个 API 就这些。没有按方法的开关,你也不需要:只用一个类别的意义就在于,一行代码让它静音,一行代码把它恢复。 + +!!! check + 反过来用这个过滤器,就白得一个回归测试。在 pytest 配置的 `filterwarnings` 设置里加上 `"error::mcp.MCPDeprecationWarning"`,已弃用的调用就会**抛出异常**而不是发出警告。一个名为 `old_log`、仍在调用 `ctx.info()` 的工具不再通过,转而报告: + + ```text + Error executing tool old_log: The logging capability is deprecated as of 2026-07-28 (SEP-2577). + ``` + + 一行 pytest 配置,已弃用的调用就再也不可能悄悄溜回你的代码库而不让测试失败。 + +## 回顾 {#recap} + +* 2026-07-28 规范弃用了**根目录**、服务器发起的**采样**和协议**日志**(都出自 [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)),把**进度**限制为只能由服务器发往客户端,并移除了 **`ping`**。 +* 替代做法那一列为你指明了去处:采样和根目录看 **[多轮往返请求](handlers/multi-round-trip.md)**,日志看 **[日志](handlers/logging.md)**,进度看 **[进度](handlers/progress.md)**。`ping` 什么都不需要。 +* 弃用只是建议性的:线路上没有变化,在 2026 之前的会话上一切照常工作,你会看到一条醒目的 `MCPDeprecationWarning`(它是 `UserWarning`,所以默认开启)。 +* 采样和根目录还需要一条反向通道(back-channel),而 2026-07-28 会话没有。在现代连接上,它们先警告,然后抛出异常。 +* `warnings.filterwarnings("ignore", category=MCPDeprecationWarning)` 让整个类别静音;pytest 中的 `"error::mcp.MCPDeprecationWarning"` 把它变成测试失败。 +* 新代码不应建立在其中任何一项之上。 + +本文档的其他每一页讲的都是当前的 API。 diff --git a/i18n/zh/pages/get-started/first-steps.md b/i18n/zh/pages/get-started/first-steps.md new file mode 100644 index 0000000000..a93641a426 --- /dev/null +++ b/i18n/zh/pages/get-started/first-steps.md @@ -0,0 +1,139 @@ +--- +translation: + sections: [0d6c05bcbf836bf3, 59a7b14eeefc68c1, 7114d8d6daba203f, e8bbb56a98ba7bc9, 5138010f6159901c, f78da7c7c363d4c6, 220a939cab348686] + tool: 1 +--- +# 第一步 {#first-steps} + +**[首页](../index.md)** 节奏很快:写一个服务器,运行它,调用一个工具。 + +这一页慢慢来:服务器能暴露的三样东西全都讲到,沿途遇到的每个概念也都给出名字。 + +## 宿主、客户端和服务器 {#host-client-and-server} + +从这里开始,每一页都会见到这三个词: + +* **宿主** 是 LLM 应用:Claude、IDE、智能体运行时。用户与之对话的就是它。 +* **客户端** 位于宿主内部,讲 MCP。宿主每连接一个服务器,就运行一个客户端。 +* **服务器** 是你用这个 SDK 构建的东西。它向客户端暴露内容,从不直接和模型对话。 + +你写的是服务器。宿主是别人的产品。SDK 还提供了一个 `Client`,你会用它来测试自己的服务器,本页后面就会用到。 + +## 三种原语 {#the-three-primitives} + +服务器暴露的东西恰好有三种。区分它们的标准是 **谁来决定使用它们**: + +| 原语 | 由谁控制 | 是什么 | 示例 | +|------------|----------|--------------------------------|---------------------------| +| **工具** | 模型 | 模型为执行操作而调用的函数 | 一次 API 调用、一次数据库写入 | +| **资源** | 应用 | 宿主加载进模型上下文的数据 | 文件内容、API 响应 | +| **提示词** | 用户 | 用户按名称调用的可复用消息模板 | 斜杠命令、菜单项 | + +“由谁控制”正是这样划分的全部意义。工具会运行,是因为 **模型** 决定调用它。资源会被附加进来,是因为 **应用** 认为模型需要它。提示词会运行,是因为 **用户** 选了它。 + +!!! info + 如果你做过 Web API,大部分直觉其实已经有了:**资源** 相当于 `GET`(加载数据,什么都不改),**工具** 相当于 `POST`(干活,可能有副作用)。**提示词** 在 HTTP 里没有对应物,它更接近一个用户按名称运行的已保存查询。 + +## 一个服务器,三样俱全 {#one-server-all-three} + +```python title="server.py" hl_lines="6 12 18" +--8<-- "docs_src/first_steps/tutorial001.py" +``` + +三个普通函数,三个装饰器。每个装饰器就是注册的全部: + +* `@mcp.tool()` 把 `add` 变成 **工具**。 +* `@mcp.resource("greeting://{name}")` 把 `greeting` 变成 **资源模板**:URI 里的 `{name}` 就是函数的参数。 +* `@mcp.prompt()` 把 `summarize` 变成 **提示词**。它返回的字符串会成为一条用户消息。 + +其余的一切(名称、描述、参数模式),SDK 都从函数本身读取:函数名、文档字符串、类型注解。这些你都没有单独声明过。 + +!!! tip + SDK 的两半各有一条导入路径:`from mcp import Client` 和 `from mcp.server import MCPServer`。不存在 `from mcp import MCPServer` 这种写法。 + +### 试一试 {#try-it} + +用 MCP Inspector 运行它: + +```console +uv run mcp dev server.py +``` + +打开它打印出来的 URL。Inspector 为每种原语各设一个标签页,按顺序逐个看一遍。 + +**工具。** 只有一项:`add`,描述是“Add two numbers.”。表单里有一个必填的整数字段 `a`,另一个是 `b`。填好后调用,结果是 `3`。这张表单是 Inspector 根据 `a: int, b: int` 生成的。其他所有客户端也都这样做。 + +**资源。** “Resources”列表是空的。`greeting` 在 **Resource Templates** 下面,因为 `greeting://{name}` 带有参数:在有人给出 `name` 之前,没有哪个具体的资源可以列出。填入 `World` 并读取: + +```text +Hello, World! +``` + +**提示词。** 只有一项:`summarize`,带一个必填参数 `text`。传一段文本去获取它,会收到一条 `role: user` 的消息,内容就是你渲染出的字符串。提示词就是这么回事:一个构建消息的函数。 + +Inspector 是通过 **stdio** 运行你的服务器的,这是 MCP 服务器可用的传输方式之一。现在还不用选;**[运行服务器](../run/index.md)** 专门讲这个。 + +## 能力 {#capabilities} + +你在 Inspector 里看到了三个标签页。它怎么知道有三个? + +客户端连接时,服务器会声明自己的 **能力**:它会响应哪几类请求。客户端根据这份声明来决定该请求什么。这份声明你从没写过;是 `MCPServer` 替你声明的。 + +自己看一下。SDK 的 `Client` 可以直接接受服务器对象,并在 **内存中** 与之连接(没有子进程,没有端口): + +```python +import asyncio + +from mcp import Client + +from server import mcp + + +async def main() -> None: + async with Client(mcp) as client: + print(client.server_capabilities.model_dump(exclude_none=True)) + + +asyncio.run(main()) +``` + +```text +{'prompts': {'list_changed': True}, 'resources': {'subscribe': True, 'list_changed': True}, 'tools': {'list_changed': True}} +``` + +这个字典就是你的服务器所声明的 **能力**。每个连接上来的客户端最先得知的就是它: + +| 能力 | 客户端现在可以调用 | +|-------------|------------------------------------------------------------| +| `tools` | `tools/list`, `tools/call` | +| `resources` | `resources/list`, `resources/templates/list`, `resources/read` | +| `prompts` | `prompts/list`, `prompts/get` | + +`MCPServer` 三种原语都提供,所以这三项始终都会声明。 + +注意这里缺了什么。`completions`(资源模板和提示词的参数自动补全)需要一个由你编写的处理函数,而这个服务器没有,所以这项能力不会出现,行为规范的客户端也就不会去问。所有可选项都遵循这条规则:注册了对应的东西,能力就出现;**[补全](../servers/completions.md)** 会证明这一点。 + +!!! info + `Client(mcp)` 正是这些文档里每个示例测试时所用的那个内存客户端,你测试自己的服务器也会用它。它有整整一页:**[测试](testing.md)**。 + +## 你没有写的东西 {#what-you-did-not-write} + +回头看看这一页。你写了三个小小的 Python 函数。你 **没有** 写: + +* JSON Schema。`a: int, b: int` **就是** `add` 的模式。 +* 请求处理函数。`tools/list`、`resources/read`、`prompts/get`:全都替你处理好了。 +* 能力声明。`MCPServer` 替你生成了。 +* 一行协议代码。版本协商、JSON-RPC 分帧、能力交换:全都发生在 `mcp dev` 和 `Client(mcp)` 内部,你一眼都没见到。 + +这个比例,正是这个 SDK 的意义所在。 + +## 回顾 {#recap} + +* **宿主** 是 LLM 应用,**客户端** 是它讲 MCP 的那一半,**服务器** 是你构建的东西。 +* 工具由 **模型** 控制,资源由 **应用** 控制,提示词由 **用户** 控制。 +* 每种原语一个装饰器:`@mcp.tool()`、`@mcp.resource(uri)`、`@mcp.prompt()`。名称、描述和模式都来自函数本身。 +* 带 `{param}` 的 URI 生成的是资源 **模板**,与具体资源分开列出。 +* 服务器的 **能力** 会替你声明好,而客户端只会请求服务器声明过的内容。 +* `Client(mcp)` 在内存中连接服务器对象:从第一天起,它就是你的测试工具。 + +接下来是 **[连接到真实宿主](real-host.md)**:把这个服务器真正放进 Claude Desktop 或 IDE 里。然后是 **[测试](testing.md)**:一页内容,一个内存客户端,从此不用再猜它到底能不能用。再之后,每种原语各有自己的一页,从模型驱动的那一种开始:**[工具](../servers/tools.md)**。 diff --git a/i18n/zh/pages/get-started/index.md b/i18n/zh/pages/get-started/index.md new file mode 100644 index 0000000000..6b7b6a857a --- /dev/null +++ b/i18n/zh/pages/get-started/index.md @@ -0,0 +1,53 @@ +--- +translation: + sections: [ed4a756b4c53c585, 97e2fb315b7fe398, 4d04f1c6f4bf6c1d, 577d73078fc62baf] + tool: 1 +--- +# 快速开始 {#get-started} + +刚接触 MCP,或者刚接触这个 SDK?从这里开始。这几页会带你从零开始,做出一个能用、经过测试的服务器:[安装 SDK](installation.md)、构建[第一个服务器](first-steps.md)、[把它接入真实的宿主](real-host.md),然后用内存客户端[测试它](testing.md)。 + +## 运行代码 {#run-the-code} + +所有代码块都可以直接复制使用:它们都是完整、可运行的文件。 + +想跟着做,就把某个代码块粘贴到 `server.py` 里,再用 MCP Inspector 打开: + +```console +uv run mcp dev server.py +``` + +**强烈建议**亲手写(或者复制)这些代码,改一改,然后在本地运行。只有在自己的编辑器里真正用上,才能体会到关键所在:要写的代码非常少,有自动补全,还没运行,类型检查就已经把错误找出来了。 + +## 不用靠猜 {#you-will-not-be-guessing} + +这些文档中的每个示例,都是 SDK 自身仓库 [`docs_src/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/docs_src) 目录下的完整文件;SDK 的测试套件会通过**内存客户端**把它们逐一运行一遍: + +```python +import pytest +from mcp import Client + +from server import mcp + + +@pytest.mark.anyio +async def test_add() -> None: + async with Client(mcp) as client: + result = await client.call_tool("add", {"a": 1, "b": 2}) + assert result.structured_content == {"result": 3} +``` + +没有子进程,不占端口,也不经过任何传输。`Client(mcp)` 直接连到服务器对象上。 + +如果对 SDK 的某次改动弄坏了这些页面上的某个示例,CI 会在页面出问题之前先变红。这里读到的代码,就是实际运行的代码。 + +在[测试](testing.md)中你会亲手用到它;测试自己的服务器,用的也是这个方法。 + +## 接下来去哪里 {#where-to-go-next} + +服务器跑起来之后,其余文档就是参考手册,而不是课程。每一页都自成一体,需要什么就直接跳过去看: + +* 服务器对外暴露什么(工具、资源、提示词),见 **[服务器](../servers/index.md)**。 +* 注册的函数内部有什么可用,见 **[在处理函数内部](../handlers/index.md)**。 +* 怎样把它送到客户端面前(stdio、HTTP、现有的 FastAPI 应用),见 **[运行服务器](../run/index.md)**。 +* 构建另一端,也就是**使用** MCP 服务器的应用,见 **[客户端](../client/index.md)**。 diff --git a/i18n/zh/pages/get-started/installation.md b/i18n/zh/pages/get-started/installation.md new file mode 100644 index 0000000000..5bf588d5c5 --- /dev/null +++ b/i18n/zh/pages/get-started/installation.md @@ -0,0 +1,45 @@ +--- +translation: + sections: [6e2f9bab94d5ed36, 8cf653388f69e28b, 6fd9ea2f65de0df6] + tool: 1 +--- +# 安装 {#installation} + +Python SDK 在 PyPI 上的包名是 [`mcp`](https://pypi.org/project/mcp/),需要 **Python 3.10+**。 + +本文档描述的是 **v2**,也就是当前的稳定版本系列: + +=== "uv" + + ```bash + uv add "mcp[cli]" + ``` + +=== "pip" + + ```bash + pip install "mcp[cli]" + ``` + +!!! note "从 v1 迁移过来?" + v2 是包含破坏性变更的主版本,**[迁移指南](../migration.md)** 涵盖了其中每一处。如果你的**包**依赖 `mcp` 且还没准备好迁移,请保留 `<2` 的版本上限(例如 `mcp>=1.28,<2`),这样在未锁定版本的情况下,依赖解析仍会停留在 1.x 系列。 + +## 安装了什么 {#what-gets-installed} + +使用 SDK 并不需要了解这些,不过如果你好奇每个依赖是做什么的: + +* `mcp-types`:所有协议类型(请求、结果、内容块)独立成一个包,版本与 SDK 同步发布。依赖 `mcp` 的代码通过 `mcp.types` 这个别名导入它(本文档里每一处 `from mcp.types import ...` 都是这样);只有在安装了 `mcp-types` 却没有安装 SDK 的项目里,才直接导入 `mcp_types`。 +* [`anyio`](https://anyio.readthedocs.io/):异步运行时。整个 SDK 都基于 anyio 编写,因此既能跑在 `asyncio` 上,也能跑在 `trio` 上。 +* [`pydantic`](https://docs.pydantic.dev/):每个 `mcp.types` 模型都构建在它之上,所有的模式生成和校验也由它完成。 +* [`httpx2`](https://pypi.org/project/httpx2/):支撑 Streamable HTTP 和 SSE **客户端**传输方式的 HTTP 客户端,内置对 server-sent events 的支持。 +* [`starlette`](https://www.starlette.io/)、[`uvicorn`](https://www.uvicorn.org/)、[`sse-starlette`](https://pypi.org/project/sse-starlette/) 和 [`python-multipart`](https://pypi.org/project/python-multipart/):HTTP **服务器端**传输方式。 +* [`jsonschema`](https://pypi.org/project/jsonschema/):对照工具声明的输出模式,校验工具的结构化输出。 +* [`pyjwt[crypto]`](https://pyjwt.readthedocs.io/):授权所需的 OAuth 令牌处理。 +* [`opentelemetry-api`](https://opentelemetry-python.readthedocs.io/):仅包含轻量级的 API,所以除非你自己安装 OpenTelemetry SDK 和导出器,否则 SDK 的追踪中间件不会带来任何开销。 +* [`typing-extensions`](https://typing-extensions.readthedocs.io/) 和 [`typing-inspection`](https://pypi.org/project/typing-inspection/):在 Python 3.10 上提供现代的类型标注特性。 +* [`pywin32`](https://pypi.org/project/pywin32/):仅 Windows 需要,用于 `stdio` 子进程管理。 + +## 可选附加依赖 {#optional-extras} + +* `mcp[cli]` 会额外安装 [`typer`](https://typer.tiangolo.com/) 和 [`python-dotenv`](https://pypi.org/project/python-dotenv/),供 `mcp` 命令行工具(`mcp dev`、`mcp run`、`mcp install`)使用。开发期间会用到它;部署后的服务器里未必需要。 +* `mcp[rich]` 会额外安装 [`rich`](https://rich.readthedocs.io/),让服务器日志更美观。 diff --git a/i18n/zh/pages/get-started/real-host.md b/i18n/zh/pages/get-started/real-host.md new file mode 100644 index 0000000000..69c84bf335 --- /dev/null +++ b/i18n/zh/pages/get-started/real-host.md @@ -0,0 +1,168 @@ +--- +translation: + sections: [3c4f2f06b4e978b6, 22520eecae3d1961, f4e1709db18d635a, 2eb57992049671d9, 1ba83e9af37cc1b4, 4822586344b08d9e, 1c93afef72478992, b6b448f9eddd51dc, fe55370fd931815b] + tool: 1 +--- +# 连接到真实的宿主 {#connect-to-a-real-host} + +**宿主** 是你的服务器最终所处的应用程序:Claude Desktop、Claude Code、IDE。用户与之对话的是宿主。在宿主内部,一个 MCP **客户端** 把你的服务器作为子进程启动,并通过该进程的 stdin 和 stdout 与它通信。 + +也就是说,连接到宿主只需要做一件事:告诉它 **启动服务器的命令**。本页的所有内容(两条 CLI 命令、三个 JSON 文件)都只是放置同一条命令的不同位置。 + +## 一个服务器,所有宿主 {#one-server-every-host} + +```python title="server.py" hl_lines="3 33-34" +--8<-- "docs_src/real_host/tutorial001.py" +``` + +两个工具加一个资源,全在一个文件里。关于这个文件,有三点对下面每个宿主都很重要: + +* 不带参数的 `mcp.run()` 启动的是 **stdio** 服务器:它会阻塞,从 stdin 读取协议消息,再把消息写到 stdout。本页每个宿主用的都是这种传输方式。宿主把你的文件作为子进程启动,并掌管这两个管道,所以连接从来都只是“把命令告诉它”这一件事。你永远不用选端口,也没有任何东西在端口上监听。 +* `run()` 放在 `if __name__ == "__main__":` 之下。下文的所有方式都是 **导入** 这个文件而不是执行它,所以不加这层保护的 `run()` 会在模块被任何东西加载的那一刻就启动服务器。 +* 服务器对象是一个名为 `mcp` 的模块级全局变量。这是 `mcp run` 要找的名字(`server` 和 `app` 也行)。如果起了别的名字,就得显式指定:`mcp run server.py:bookshop`。 + +这是本页最后一行 Python。从这里往下全是宿主配置。 + +## 启动命令 {#the-launch-command} + +下面每个宿主拿到的都是同一条命令: + +```bash +uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py +``` + +所有宿主共用一条命令,是因为 `uv run --with` 会当场把 SDK 解析进一个全新的环境:在任何目录下都能用,既不需要项目,也不需要激活虚拟环境。这一点在这里比在别处都更要紧,因为宿主是从 **它自己的** 工作目录、带着几乎为空的环境启动你的服务器,而不是从你的 shell。 + +它也是 `mcp install` 替你写进 Claude Desktop 配置的那条命令(见下文),所以手敲的和工具生成的是一致的,差别只在工具额外加上的精确版本锁定。 + +!!! tip "如果宿主找不到 `uv`" + 宿主启动你的服务器时只带一个极简的 `PATH`,`uv` 可能不在其中。把不带路径的 `uv` 换成 `which uv`(macOS/Linux)或 `where uv`(Windows)给出的绝对路径。`mcp install` 写入的正是这个。 + +!!! note "本页讲的是本地场景" + 这里的一切都是在宿主所在的那台机器上运行你的服务器:宿主通过 stdio 启动你的文件。对个人工具或单机工具来说,这样做完全合适。要把服务器交给 **没有** 你这个文件的人,给出去的是 **URL** 而不是命令:同一个 `mcp` 对象,通过 Streamable HTTP 提供服务。**[运行服务器](../run/index.md)** 用一张表讲清这个决策,**[部署与扩展](../run/deploy.md)** 则是从那里走到真实主机名的路线。 + + 而且宿主不过是内置了 MCP 客户端的应用程序,所以你自己的 Python 也能扮演宿主的角色:**[客户端传输方式](../client/transports.md)** 用 `stdio_client(...)` 把同一个文件作为子进程启动,**[测试](testing.md)** 则一个进程都不起,直接在内存中连接它。 + +## Claude Desktop {#claude-desktop} + +唯一一个 SDK 能替你配置的宿主: + +```bash +uv run mcp install server.py +``` + +就这样。`mcp install` 导入该文件以读取服务器的名字,找到 Claude Desktop 的配置文件,然后把启动命令写进去。过程中它会把你的路径转换成绝对路径,省得你自己动手。 + +这里没有什么玄机。它写入的条目是这样的: + +```json +{ + "mcpServers": { + "Bookshop": { + "command": "/absolute/path/to/uv", + "args": [ + "run", + "--frozen", + "--with", + "mcp[cli]==2.0.0", + "mcp", + "run", + "/absolute/path/to/server.py" + ] + } + } +} +``` + +这就是上一节的启动命令,外加三样东西:`uv` 的绝对路径、`--frozen`(让 `uv` 永远不会改写它碰巧挨着的锁文件),以及对你已安装的 `mcp` 版本的精确锁定。它最终写进 `claude_desktop_config.json`,该文件位于: + +* **macOS**:`~/Library/Application Support/Claude/claude_desktop_config.json` +* **Windows**:`%APPDATA%\Claude\claude_desktop_config.json` + +这个文件可以手写。`mcp install` 存在的意义,就是让你手写时不会犯那个经典错误(相对路径)。 + +完全退出 Claude Desktop(不只是关掉窗口),再重新打开。 + +!!! warning + 如果 Claude Desktop 的配置 **目录** 还不存在,`mcp install` 会失败并报 `Claude app not found`。安装 Claude Desktop 并运行一次:目录正是这一步创建的。 + +!!! tip + Claude Desktop 在它自己的进程中启动你的服务器,所以那里没有你 shell 里的环境变量。`uv run mcp install server.py -v API_KEY=abc123`(或 `-f .env`)会把它们记到条目的 `env` 字段里。`--name` 用来覆盖条目名;默认取服务器的 `name`。 + +## Claude Code {#claude-code} + +没有文件要编辑。用 `claude` CLI 注册服务器;`--` 之后的所有内容就是启动命令。 + +```bash +claude mcp add bookshop -- uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py +``` + +在 Claude Code 会话中运行 `/mcp`,确认 `bookshop` 已连接,且它的工具已列出。 + +## Cursor {#cursor} + +在项目根目录创建 `.cursor/mcp.json`。 + +```json +{ + "mcpServers": { + "bookshop": { + "command": "uv", + "args": ["run", "--with", "mcp[cli]", "mcp", "run", "/absolute/path/to/server.py"] + } + } +} +``` + +同样的 `command` 加 `args`,放在 Claude Desktop 也在用的 `mcpServers` 键下。服务器会出现在 Cursor 的 MCP 设置里,两个工具都已列出。 + +## VS Code {#vs-code} + +在项目根目录创建 `.vscode/mcp.json`。 + +```json +{ + "servers": { + "bookshop": { + "type": "stdio", + "command": "uv", + "args": ["run", "--with", "mcp[cli]", "mcp", "run", "/absolute/path/to/server.py"] + } + } +} +``` + +和 Cursor 的文件有两处不同,也仅此两处:外层键是 `servers` 而不是 `mcpServers`,而且每个条目都声明自己的 `type`。确认信任提示之后,在命令面板中执行 **MCP: List Servers**,会看到 `bookshop` 正在运行。 + +!!! note + 需要 VS Code 1.99 或更高版本,安装 **GitHub Copilot** 扩展并登录(Copilot Free 就够),而且 Copilot Chat 必须处于 **Agent** 模式,因为别的模式都不会调用工具。 + +## 服务器没有出现 {#it-doesnt-show-up} + +在改动任何宿主配置之前,先自己运行一遍启动命令: + +```bash +uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py +``` + +什么都不打印,也不返回。这种沉默是正确的:stdio 服务器正在等宿主先在 stdin 上开口(按 `Ctrl-C` 停止)。出现 traceback 或者立刻退出,那才是真正的 bug;现在可以直接读到它,而不用隔着宿主去猜。 + +一旦这条命令能停在那里等待,剩下的问题几乎总是下面三种之一: + +* **相对路径。** 宿主从 **它自己的** 工作目录启动你的服务器,而不是你注册时所在的目录。在需要 `/absolute/path/to/server.py` 的地方写成了 `server.py`,是所有失败里最常见的一个。如果宿主连 `uv` 也找不到,那个路径同样必须是绝对路径。 +* **宿主还在跑旧配置。** 宿主在启动时读取配置。尤其是 Claude Desktop,必须 **完全退出**(不只是关掉窗口)再重新打开,对 `claude_desktop_config.json` 的修改才会生效。 +* **有东西在重定向窗口期之外写到了 stdout。** 在 stdio 上,stdout **就是** 协议。SDK 在提供服务期间会把已刷新的杂散输出重定向到 stderr,但在那之前就刷新到 stdout 的输出(包装脚本的回显、无缓冲进程里导入期间的 `print()`),或者直到解释器退出才排空的带缓冲 `print()`,都会递给宿主一条损坏的消息,宿主随即断开连接。用默认的 `logging` 配置记日志,它的 stderr handler 每条记录都会刷新;自定义 handler 同样必须避开 stdout。详见 **[日志](../handlers/logging.md)**。 + +Claude Desktop 为每个服务器各留一份日志:`mcp-server-.log` 是你服务器的 stderr,和记录连接情况的 `mcp.log` 放在一起,macOS 上在 `~/Library/Logs/Claude` 下,Windows 上在 `%APPDATA%\Claude\logs` 下。 + +这三种之外的任何问题,去看 **[故障排查](../troubleshooting.md)**。 + +## 回顾 {#recap} + +* **宿主**(Claude Desktop、IDE)运行一个 MCP 客户端,由它通过 stdio 把你的服务器作为子进程启动。连接就是给它一条启动命令。 +* 这条命令是 `uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py`:无需激活 venv,在任何目录下都能用。 +* **Claude Desktop** 是唯一一个 `mcp install` 能替你配置的宿主。它把同一条命令(外加 `uv` 的绝对路径、`--frozen`,以及对已安装版本的精确锁定)写进 `claude_desktop_config.json`,你永远不必自己动手。 +* **Claude Code** 用 `claude mcp add bookshop -- `。**Cursor** 用 `.cursor/mcp.json`,放在 `mcpServers` 下。**VS Code** 用 `.vscode/mcp.json`,放在 `servers` 下,每个条目带一个 `type`。 +* 处处使用绝对路径,改完配置后重启宿主,并且绝不让 SDK 以外的任何东西写入 stdout。 + +本页每个宿主都用同一条命令连接到了同一个文件。至于这个文件能 **暴露** 什么,就是这套文档余下的内容:**[工具](../servers/tools.md)**、**[资源](../servers/resources.md)**,以及 **[运行服务器](../run/index.md)** 中 stdio 之外的每一种传输方式。 diff --git a/i18n/zh/pages/get-started/testing.md b/i18n/zh/pages/get-started/testing.md new file mode 100644 index 0000000000..cca6253f83 --- /dev/null +++ b/i18n/zh/pages/get-started/testing.md @@ -0,0 +1,96 @@ +--- +translation: + sections: ['4926721070127497', c52a1de2b6b32f40, 2e410b412c25f314, 627195f7159e24ef] + tool: 1 +--- +# 测试 {#testing} + +Python SDK 提供了一个带**内存传输**的 `Client` 类:把服务器对象传给它,它就会直接连接上去。 + +不用子进程,不占端口,根本不走任何传输。思路和 FastAPI 的 `TestClient` 一样。 + +## 基本用法 {#basic-usage} + +假设有一个简单的服务器,只有一个工具: + +```python title="server.py" +--8<-- "docs_src/testing/tutorial001.py" +``` + +要运行下面的测试,还需要两个额外的(开发)依赖项: + +=== "uv" + + ```bash + uv add --dev pytest inline-snapshot + ``` + +=== "pip" + + ```bash + pip install pytest inline-snapshot + ``` + +!!! info + 本文档假设你已经熟悉 [`pytest`](https://docs.pytest.org/en/stable/)。 + + 下面的测试用 [`inline-snapshot`](https://15r10nk.github.io/inline-snapshot/latest/) 在一行里对整个结果对象做断言。它会把测试的输出记录成你看到的 `snapshot(...)` 字面量。如果不想用它,去掉这行 import,像其他任何测试一样对关心的字段做断言(`result.content[0].text == "3"`)即可。 + +下面是测试: + +```python title="test_server.py" +import pytest +from inline_snapshot import snapshot +from mcp import Client +from mcp.types import CallToolResult, TextContent + +from server import mcp + + +@pytest.fixture +def anyio_backend(): # (1)! + return "asyncio" + + +@pytest.fixture +async def client(): # (2)! + async with Client(mcp, raise_exceptions=True) as c: + yield c + + +@pytest.mark.anyio +async def test_call_add_tool(client: Client): + result = await client.call_tool("add", {"a": 1, "b": 2}) + # Drop the server identity stamp in `_meta`; it is not what this test is about. + result.meta = None + assert result == snapshot( + CallToolResult( + content=[TextContent(type="text", text="3")], + structured_content={"result": 3}, + ) + ) +``` + +1. 如果用的是 `trio`,就改为返回 `"trio"`。详见 [anyio 文档](https://anyio.readthedocs.io/en/stable/testing.html#specifying-the-backends-to-run-on)。 +2. 这个 fixture 产出一个已连接的客户端。每个接收 `client` 的测试,都会拿到一条连向同一服务器的全新内存连接。 + +这样就行了。接下来可以扩展测试,覆盖更多场景。 + +## 为什么用 `raise_exceptions=True`? {#why-raise_exceptionstrue} + +可能出错的情况有两种,而这个标志只管其中一种。 + +**你的工具**内部抛出的异常不算协议失败。它会变成一个带 `is_error=True` 的普通结果,模型会读到其中的消息。`raise_exceptions` 不会改变这一点:不管有没有它,`call_tool` 返回的都是同一个 `is_error=True` 结果。有一整页专门讲这个:**[处理错误](../servers/handling-errors.md)**。 + +工具函数体**之外**的失败则不同。在 `Client(mcp)` 提供的这条连接上,服务器会先把它脱敏成一条笼统的 `"Internal server error"`,客户端才会看到。意外崩溃的细节绝不应该泄露给远程调用方。但在测试里,这恰恰是你**不**想要的,也正是 `raise_exceptions=True` 所改变的:测试看到的是真实的消息,而不是脱敏后的那条。 + +测试里就让它开着。它在生产代码中没有意义。 + +## 默认在进程内 {#in-process-by-default} + +!!! note + `Client(mcp)` 在进程内连接,默认**不区分协议时代**:它会先探测服务器,再选择合适的协议路径。如果测试要验证旧版(legacy)特有的语义(采样(sampling)或征询(elicitation)的推送、`message_handler`),就固定使用 `mode="legacy"`,并在这种情况下去掉 `raise_exceptions=True`:旧版连接本来就不做脱敏,而这个标志会让失败在服务器任务内部重新抛出,而不是抛到你的测试里。 + +也正是因为这一行,本文档才敢保证其中的示例都能跑通:每个示例文件都会在 SDK 自己的测试套件里跑一遍,而且几乎全都正是通过这个客户端。你用的,就是 SDK 用来测试自己的同一个工具。 + +现在你有了一个可用且经过测试的服务器。要把它接入真实的应用(Claude Desktop、IDE),见 **[连接到真实宿主](real-host.md)**;以其他任何方式对外提供它,见 **[运行服务器](../run/index.md)**。 diff --git a/i18n/zh/pages/handlers/context.md b/i18n/zh/pages/handlers/context.md new file mode 100644 index 0000000000..81f4ce285b --- /dev/null +++ b/i18n/zh/pages/handlers/context.md @@ -0,0 +1,128 @@ +--- +translation: + sections: [b50152f05c81e786, b302059b22fb7cb4, 85682a1bf561243a, 53fc48838eb6837a, b24190e0842786ec, 85f93e150fc9b240] + tool: 1 +--- +# Context {#the-context} + +工具的参数来自模型。其余的一切(正在处理的请求、所在的服务器、与客户端对话的途径)都来自同一个对象:**`Context`**。 + +你不需要构造它,也不需要配置它。只需要声明它。 + +## 声明它 {#ask-for-it} + +给任意工具加一个用 `Context` 标注的参数: + +```python title="server.py" hl_lines="2 8" +--8<-- "docs_src/context/tutorial001.py" +``` + +* SDK 为每个请求构建一个新的 `Context` 并传进来。 +* 参数**名字无关紧要**。`ctx`、`context`、`c` 都行:SDK 靠注解找到它。 +* 资源和提示词也可以用同样的方式声明一个。 +* `ctx.request_id` 是函数当前正在处理的请求的 id。 + +!!! info + 如果用过 FastAPI,这一招应该不陌生:用框架自己的类型声明一个参数(那边是 `Request`,这边是 `Context`),框架就会把它传进来。不需要注册,不需要配置:类型注解就是全部机制。 + +### 对模型不可见 {#invisible-to-the-model} + +这一点要牢记。下面是 `tools/list` 为 `search_books` 报告的输入模式: + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"} + }, + "required": ["query"], + "title": "search_booksArguments" +} +``` + +只有一个属性。`ctx` 不是参数:它从不出现在模式里,模型从不会得知它的存在,也没有客户端能填写它。这是你和 SDK 之间的约定,在线路上不可见。 + +### 试一试 {#try-it} + +用 MCP Inspector 运行服务器: + +```console +uv run mcp dev server.py +``` + +`search_books` 的表单只有一个 `query` 字段。用 `dune` 调用它: + +```text +[request 3] Found 3 books matching 'dune'. +``` + +这个数字就是这次请求碰巧的编号。再调用一次工具,它就会变:每个请求都有自己的 `Context`。 + +## 它提供什么 {#what-it-gives-you} + +注入的对象很小。除了 `request_id`: + +* `await ctx.read_resource(uri)`:在工具内部读取服务器**自己的**资源。见下一节。 +* `await ctx.report_progress(progress, total, message)`:在长时间调用期间把进度流式发回调用方。详见 **[进度](progress.md)**。 +* `await ctx.elicit(message, schema)` 和 `await ctx.elicit_url(...)`:暂停工具,向用户提一个问题。这是 **[征询](elicitation.md)**。 +* `ctx.session`:服务器与这个客户端对话的这一端。发给客户端的通知都在这里;最后一节会用到它。 +* `ctx.headers`:传输携带的请求头,stdio 上为 `None`。用 `(ctx.headers or {}).get("x-...")` 读取自定义请求头。请求头是客户端提供的输入——用来传语言区域或功能开关没问题,但绝不能用作身份。 +* `ctx.request_context`:原始的每请求记录。你会用到的字段是 `lifespan_context`,也就是启动代码 yield 出来的对象(见 **[生命周期](lifespan.md)**)。 + +日志有意不在这个列表里。服务器用 Python 的 `logging` 模块记录日志,和任何其他 Python 程序一样。**[日志](logging.md)** 这一页简短地解释了原因。 + +!!! tip + 注入只发生在你注册的那个函数上。工具调用的辅助函数不会得到自己的 `Context`;把 `ctx` 当作普通参数传下去。不存在可以从别处获取的环境“当前上下文”。 + +## 读取自己的资源 {#read-your-own-resources} + +服务器的资源不只是给客户端用的。工具也可以读取它们: + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/context/tutorial002.py" +``` + +`ctx.read_resource` 通过为 `resources/read` 提供服务的同一个注册表解析 URI,所以工具拿到的和客户端拿到的一样:一个 `ReadResourceContents` 的可迭代对象,每个内容块一个。这个 URI 只有一个: + +```python +contents.content # 'fiction, non-fiction, poetry' +contents.mime_type # 'text/plain' +``` + +* `content` 正是 `genres()` 返回的内容。单一事实来源:客户端浏览资源,你的工具消费它,没人复制字符串。 +* `describe_catalog` 唯一的参数是 `Context`,所以它的输入模式**完全没有属性**。模型用 `{}` 调用它。 + +## 告诉客户端列表变了 {#tell-the-client-the-list-changed} + +服务器提供的内容并不是在导入时就固定的。在运行时注册一个工具,然后告诉客户端: + +```python title="server.py" hl_lines="15-16" +--8<-- "docs_src/context/tutorial003.py" +``` + +* `mcp.add_tool(recommend_book)` 把一个普通函数注册为工具:名称、描述和模式的推导方式与 `@mcp.tool()` 完全一致。 +* `await ctx.session.send_tool_list_changed()` 发送 `notifications/tools/list_changed`。收到它的客户端会再次调用 `tools/list`,并看到 `recommend_book`。 + +同类方法还有 `send_resource_list_changed()`、`send_prompt_list_changed()`,以及针对某个特定资源变化的 `send_resource_updated(uri)`。 + +在 2026-07-28 连接上,客户端只在自己打开的 `subscriptions/listen` 流上接收变更通知,所以上面的 `send_*` 方法到不了这些流。`Context` 的发布方法会一次性投递到所有已订阅的流:`await ctx.notify_tools_changed()`、`await ctx.notify_prompts_changed()`、`await ctx.notify_resources_changed()` 和 `await ctx.notify_resource_updated(uri)`。完整说明,包括跨副本横向扩展,详见 **[订阅](subscriptions.md)**。 + +!!! check + 在有人运行 `enable_recommendations` 之前,你承诺的那个工具并不存在。照样调用它,结果是一条模型能读懂的错误: + + ```text + Unknown tool: recommend_book + ``` + + 运行 `enable_recommendations`,同样的调用就会成功。工具列表是真正动态的:`tools/list` 反映的是**此刻**注册了什么。 + +## 回顾 {#recap} + +* 用 `Context` 标注一个参数(在工具、资源或提示词里),SDK 就会注入它。名字随你定。 +* 它对模型不可见:输入模式永远只包含你真正的参数。 +* `ctx.request_id` 标识请求;`ctx.request_context.lifespan_context` 是启动代码 yield 出来的对象。 +* `await ctx.read_resource(uri)` 让工具读取服务器自己的资源。 +* `ctx.session` 是回到客户端的通道:`send_tool_list_changed()` 及其同类方法告诉客户端重新获取你改动过的列表。 +* 进度报告和征询同样从 `Context` 开始;它们各有自己的页面。 + +模型永远看不到、由你自己的函数填充的参数,就是 **[依赖](dependencies.md)**。 diff --git a/i18n/zh/pages/handlers/dependencies.md b/i18n/zh/pages/handlers/dependencies.md new file mode 100644 index 0000000000..c01ef737e5 --- /dev/null +++ b/i18n/zh/pages/handlers/dependencies.md @@ -0,0 +1,137 @@ +--- +translation: + sections: [b0389403e98d25ad, e2cf58b43b285e86, a363e1a38e1a5971, 6cfac078feb18013, b4535bd61df337e6, e97ed44207f929fd] + tool: 1 +--- +# 依赖 {#dependencies} + +工具的参数来自模型。但有些值绝不该由模型提供:从你的记录里查出来的价格、只有人才能给出的确认,以及任何模型一旦凭空编造就会出错的东西。 + +**依赖**是由你自己的函数填充的参数。给参数加上注解,指明函数,SDK 就会在工具运行之前调用它。 + +## 声明一个依赖 {#declare-one} + +把参数类型包进 `Annotated[...]`,再加上 `Resolve(fn)`: + +```python title="server.py" hl_lines="18-19 23" +--8<-- "docs_src/dependencies/tutorial001.py" +``` + +* `check_stock` 是一个**解析器**(resolver):一个普通函数,SDK 在 `reserve_book` 之前运行它,它的返回值就成了 `stock` 参数。 +* 它的 `title` 参数就是工具自己的 `title` 参数,**按名称**匹配。解析器看到的值和工具函数体看到的一模一样,都是校验过的值。 +* 工具函数体一开始就拿到一个现成的 `Stock`。工具里没有查询代码,也没有“万一查不到怎么办”的铺垫。 + +!!! info + 如果用过 FastAPI,这就是 `Depends`。同样的做法,同样的理由:函数声明自己需要什么,框架负责提供,接线逻辑放在类型注解里。 + +### 对模型不可见 {#invisible-to-the-model} + +这是 `tools/list` 为 `reserve_book` 报告的输入模式: + +```json +{ + "type": "object", + "properties": { + "title": {"title": "Title", "type": "string"} + }, + "required": ["title"], + "title": "reserve_bookArguments" +} +``` + +只有一个属性。和 **[Context](context.md)** 里的 `Context` 一样,被解析的参数是你和 SDK 之间的约定:`stock` 不在模式里,模型从不会知道它的存在,客户端即便硬塞一个 `stock` 值过来也会被忽略。工具能收到的只有解析器给出的值。 + +最后这一点才是关键。模型无法提供的参数,就是模型无法弄错的参数。 + +### 试一试 {#try-it} + +用 MCP Inspector 运行服务器: + +```console +uv run mcp dev server.py +``` + +`reserve_book` 的表单只有一个 `title` 字段,哪儿都找不到 `stock`。用 `Dune` 调用它: + +```text +Reserved 'Dune' (6 copies left). +``` + +工具函数体什么都没查:`check_stock` 先运行,它返回的 `Stock` 作为参数传了进来。换成 `Neuromancer` 试试,同一个解析器会给工具一个零。 + +!!! tip + 你当然可以直接在工具函数体里调用 `check_stock(title)`。如果这个值不只是一次辅助函数调用那么简单,就把它声明为依赖:每个需要库存的工具都声明同一个参数,而不管有多少个工具声明它,SDK 每次调用最多只运行一次解析器。后面几节补上其余内容:相互依赖的解析器,以及会去问用户的解析器。 + +## 依赖的依赖 {#dependencies-of-dependencies} + +解析器可以用同样的注解声明自己的依赖: + +```python title="server.py" hl_lines="22 29-30" +--8<-- "docs_src/dependencies/tutorial002.py" +``` + +* `estimate_delivery` 依赖 `check_stock`。SDK 按顺序运行这张图:先查库存,再算预估,最后才是工具。 +* `stock` 和 `delivery` 最终都需要 `check_stock`,但它**每次调用只运行一次**。一次库存查询,两个使用者。 +* 不需要注册任何东西。注解**本身就是**这张图。 + +!!! check + 别轻信“每次调用一次”这句话。在 `check_stock` 里放一个 `print`,然后从 Inspector 调用 `order_book`:每次调用打印一行。两个使用者,一次查询。 + +SDK 在工具注册时分析这张图,而不是在调用时。遇到它无法归类的参数——既不是 `Context`,也不是 `Resolve(...)`,也不是某个工具参数的名字——或者解析器之间出现环,都会在启动时抛出 `InvalidSignature`。服务器在任何客户端连上来之前就会失败,错误里会点名出问题的参数或解析器。 + +解析器的参数和工具的参数按完全相同的方式解析:另一个 `Resolve(...)`、按名称匹配的工具自身参数,或者 `Context`——`ctx.headers`、生命周期对象,全都可以。 + +!!! warning + 在 HTTP 传输上,`Context` 包含 `ctx.headers`。请求头是**客户端提供的输入**,和任何工具参数一样:用来传区域设置或功能开关没问题,但绝不能当作身份。调用者是谁由你的授权层决定(**[授权](../run/authorization.md)**),而不是一个谁都能设置的请求头。 + +!!! tip + “每次调用一次”就是字面意思:下一次 `tools/call` 会再次运行 `check_stock`。需要比单次请求活得更久的资源——数据库连接池、HTTP 客户端——应该放在**[生命周期](lifespan.md)**里,解析器可以通过 `ctx.request_context.lifespan_context` 拿到它。 + +## 必要时才问 {#ask-when-you-must} + +解析器不一定要知道答案。它可以返回 `Elicit(message, Model)`,SDK 会去问用户——也就是**[征询](elicitation.md)**(elicitation)机制,由 SDK 替你运行: + +```python title="server.py" hl_lines="26-32 39" +--8<-- "docs_src/dependencies/tutorial003.py" +``` + +* 有货:`confirm_backorder` 直接返回一个 `Backorder`。**不提问,不往返。**只有当用户的回答真正有用时才会打扰他们。 +* 缺货:SDK 发出征询,按 `Backorder` 校验回答,然后注入。解析器完全不碰协议。 +* 工具像读取其他参数一样读取 `backorder.confirm`。回答**否**也算回答:征询以 `confirm=False` 被接受,工具照常运行,但不会下单。提问变成了前置条件,而不是塞在工具函数体里的管道代码。 + +那如果用户干脆不回答——拒绝这个问题,或者取消它呢? + +!!! check + 对 `Neuromancer` 运行 `order_book` 并拒绝回答。注解写成 `Annotated[Backorder, Resolve(...)]` 时,工具函数体根本不会运行;调用失败并返回一个模型能读懂的错误结果: + + ```text + Error executing tool order_book: Resolver for parameter 'backorder' could not resolve: elicitation was decline + ``` + +对前置条件来说这是正确的默认行为:没有回答,就没有订单。如果拒绝是工具想要自行处理的一种结果——跳过缺货预订,但仍然推荐另一本书——就改为注解 `ElicitationResult[Backorder]`,工具会收到完整的接受/拒绝/取消结果并据此分支。**[征询](elicitation.md)**展示了这种写法,以及关于提问的其他一切:模式规则、三种回答、客户端一侧的对话。 + +!!! info + 框架根据协商出的协议版本选择问题走哪种传输方式;上面的代码在两种情况下完全相同。在 **2026-07-28** 及之后,问题搭载在一次多轮往返(multi-round-trip)的 `tools/call` 里——服务器返回问题,客户端的 `elicitation_callback` 回答它,`Client` 替你重试调用(**[多轮往返请求](multi-round-trip.md)**)。在 **2025-11-25** 及之前,它是调用中途的一次同步征询请求。每个问题在每次调用中恰好被问一次——这是对问题的保证,而不是对解析器的保证。在多轮往返形式下,每当调用在一个问题之后恢复,任何解析器都可能再次运行,所以 `return Elicit(...)` 之前的代码在每一轮都会执行;随后记录下的回答会满足重复出现的问题,而不会再次打扰用户。只有当解析器提问时才会去查记录下的回答;像 `check_stock` 这样**不**提问就给出答案的解析器,永远提供它自己算出来的值。因为每个回答都要匹配回它的问题,会发起征询的解析器必须根据工具的参数和先前的回答确定性地推导出问题。每次调用生成的值(`default_factory` 生成的 id、时间戳)在每一轮都会重新推导,绝不能出现在需要绑定回答的问题里。用这种易变数据构造的问题会让每个记录下的回答看起来都已过期,于是服务器每一轮都会重新提问,直到客户端的轮数上限终止这次调用。 + +## 问客户端,而不是用户 {#ask-the-client-not-the-user} + +征询是解析器能问的三种问题之一,多轮往返流程不允许其他问题。另外两种问的是**客户端**而不是用户:返回 `Sample(...)` 通过客户端发起一次 LLM 调用(一个 `sampling/createMessage` 请求),或者返回 `ListRoots()` 获取客户端当前的根目录(roots)。这两者都没有接受/拒绝的结果;使用者直接注解结果类型,`CreateMessageResult`(请求带有 `tools` 或 `tool_choice` 时为 `CreateMessageResultWithTools`)或 `ListRootsResult`: + +```python title="server.py" hl_lines="10-15 21" +--8<-- "docs_src/dependencies/tutorial004.py" +``` + +* 框架对它们的路由方式和 `Elicit` 完全一样:在 **2026-07-28** 上走多轮往返的 `tools/call`,在 **2025-11-25** 上走独立的服务器->客户端请求。未声明的能力会以 `-32021` 协议错误拒绝调用(`sampling`、`roots`、表单模式的 `elicitation`;请求带有 `tools` 或 `tool_choice` 时为 `sampling.tools`)。 +* 上面 info 框里关于问题的所有内容原样适用:`Sample` 请求按其精确的渲染结果匹配到记录下的结果,所以要根据工具的参数和先前的回答确定性地构造它;这样客户端为 LLM 调用付出的代价是每次工具调用一次,而不是每轮一次。记录下的结果在本次调用剩余时间里都搭载在 `request_state` 上,所以一个非常大的补全会让后面每次往返都更重。 +* 独立的采样(sampling)和根目录**功能**在 2026-07-28 已弃用(SEP-2577)。需要客户端模型的新服务器应通过这个载体提问;不需要的服务器应直接对接 LLM 提供商。`include_context` 取 `"none"` 以外的值本身也已弃用,不要用。 + +## 回顾 {#recap} + +* 在工具参数上写 `Annotated[T, Resolve(fn)]`:SDK 运行 `fn` 并注入它的返回值。 +* 被解析的参数对模型不可见,客户端也无法提供。模型绝不能编造的值——价格、身份、权限——就该放在这里。 +* 解析器的参数按同样的方式解析:`Context`、另一个 `Resolve(...)`,或按名称匹配的工具参数。不管有多少使用者,这张图每一轮最多运行每个解析器一次;每个问题恰好问一次,而调用在一个问题之后恢复时,任何解析器都可能再次运行。 +* 有问题的图在注册时就以 `InvalidSignature` 失败,而不是在调用中途。 +* 返回 `Elicit(message, Model)` 去问用户,只在必要时才问。未包装的注解在拒绝时中止;`ElicitationResult[T]` 让工具自行分支。 +* 返回 `Sample(...)` 或 `ListRoots()` 向客户端要一次 LLM 补全或根目录列表;注入的是原始结果。 + +服务器在启动时一次性构建的状态,以及处理函数如何拿到它,见 **[生命周期](lifespan.md)** 页面。 diff --git a/i18n/zh/pages/handlers/elicitation.md b/i18n/zh/pages/handlers/elicitation.md new file mode 100644 index 0000000000..c9d234a4e0 --- /dev/null +++ b/i18n/zh/pages/handlers/elicitation.md @@ -0,0 +1,175 @@ +--- +translation: + sections: [335ca2a0b266f003, d1ad562d3fe87bc0, 0bb1396c86daeba4, d1cb1235bb9ee267, 833179c09d239c83, e5d6dec2d2e655e8] + tool: 1 +--- +# 征询 {#elicitation} + +一个工具活干到一半、只差一个答案,不必因此失败。 + +**征询**(elicitation)让它可以开口问。在一次工具调用的中途,用户会收到一个问题,他们的回答会回到同一次函数调用里。 + +有两种模式: + +* **表单模式**:你需要一个值(一次确认、一个日期、一个数量)。你描述字段,客户端渲染表单。 +* **URL 模式**:你需要用户去别的地方(OAuth 授权页面、支付页面)。他们在那里做的任何事都不经过协议。 + +提问的方式也有两种。首选的是**解析器**:把问题挂在一个参数上,SDK 负责去问——在任何连接上都行,不管客户端说的是哪个时代的协议。直接的方式是 `await ctx.elicit(...)`,它是一个从**服务器**发往**客户端**的请求,而这条通道只对处于旧版连接(规范版本 2025-11-25 或更早)的客户端存在。本页两种都讲,先从解析器开始。 + +## 用解析器提问 {#ask-with-a-resolver} + +一个把关整个工具的问题——“确定吗?三个匹配的账户里选哪个?”——可以从工具函数体里提出来放进**解析器**,由框架替你去问。 + +标注为 `Annotated[T, Resolve(fn)]` 的参数,会在工具函数体执行之前通过运行 `fn` 来填充。解析器已经知道值时直接返回它;否则返回 `Elicit(...)`,让框架去问: + +```python title="server.py" hl_lines="24-30 35-36" +--8<-- "docs_src/elicitation/tutorial004.py" +``` + +* `confirm_delete` 按名字读取工具自己的 `path` 参数,列出文件夹内容,并且**只在必须时才征询**——空文件夹直接解析为 `Confirm(ok=True)`,不需要和客户端往返。 +* `delete_folder` 标注的是 `ElicitationResult[Confirm]`,所以框架注入完整的结果,工具用 `match` 处理每一种情况:接受并确认、接受但保留(`ok=False`)、拒绝、取消。 +* `confirm` 参数永远不会出现在工具的输入模式里——客户端提供 `path`,解析器提供 `confirm`。 + +如果工具不需要分支,就改为标注解包后的模型(`Annotated[Confirm, Resolve(confirm_delete)]`):接受时它收到模型,拒绝或取消时调用以错误中止。 + +解析器在**每一种**连接上都能工作。对旧版连接上的客户端,SDK 直接把问题发给它;在 **2026-07-28** 连接上,SDK 把问题从这次调用里**返回**出去,客户端的下一次尝试会带上答案。你的解析器感觉不到区别;底层发生的事情是 **[多轮往返请求](multi-round-trip.md)**(multi-round-trip)。 + +提问只是解析器能做的事情之一。通用机制——不提问直接算出值的依赖、依赖的依赖、模型能提供什么不能提供什么——见 **[依赖](dependencies.md)** 页面。 + +## 在工具内部提问 {#ask-from-inside-the-tool} + +工具也可以在自己的函数体中途停下来提问。 + +!!! warning + `ctx.elicit()` 和 `ctx.elicit_url()` 是从**服务器**发往**客户端**的请求——这条通道只对处于旧版连接(规范版本 **2025-11-25** 或更早)的客户端存在。在 **2026-07-28** 连接上没有服务器发起的请求,所以这些调用会失败。解析器在两者上都能用。详见 **[协议版本](../protocol-versions.md)**。 + +`await ctx.elicit()` 接受一条消息和一个 Pydantic 模型: + +```python title="server.py" hl_lines="9-11 20-23 25" +--8<-- "docs_src/elicitation/tutorial001.py" +``` + +* **`Context`** 参数就是提供 `ctx.elicit` 的东西;任何工具都可以接收一个。这个对象有自己的页面:**[Context](context.md)**。 +* `AlternativeDate` 是你想要的答案的**模式**。 +* 工具是 `async def`。必须是:它会在中途停下来等一个人。 +* 其他任何日期,工具直接返回。只在必须时才问。 +* 用户接受的日期会重新走一遍 `book_table` 本身。答案和其他输入一样是输入:如果替代日期也订满了,会再问一次,而不是盲目确认。 + +### 客户端收到什么 {#what-the-client-receives} + +客户端拿到你的消息,旁边还有一个由模型生成的 JSON Schema: + +```json +{ + "properties": { + "accept_alternative": { + "description": "Try another date?", + "title": "Accept Alternative", + "type": "boolean" + }, + "date": { + "default": "2025-12-26", + "description": "Alternative date (YYYY-MM-DD)", + "title": "Date", + "type": "string" + } + }, + "required": ["accept_alternative"], + "title": "AlternativeDate", + "type": "object" +} +``` + +这个模式就是表单。`Field(description=...)` 是标签;默认值会预填输入框,并让该字段变成可选。这和 **[工具](../servers/tools.md)** 里描述的工具参数用的是同一套 Pydantic 转 JSON Schema 的机制。 + +!!! warning + 征询的模式不如工具的输入模式表达力强。只能是扁平的原始类型字段:`str`、`int`、`float`、`bool`,或字符串的 `Literal`(会变成 `enum`)。在模型里再放一个模型,`ctx.elicit` 会在任何东西发给客户端之前抛出异常: + + ```text + TypeError: Elicitation schema field 'address' rendered as {'$ref': '#/$defs/Address'}, which is not a valid PrimitiveSchemaDefinition + ``` + + 你是在打断一个正在做事的人。如果答案需要嵌套,它本该是工具的参数。 + +### 三种答案 {#the-three-answers} + +`result.action` 告诉你用户做了什么,恰好只有三种可能: + +* `"accept"`:他们提交了表单。`result.data` 是一个 `AlternativeDate` 实例,已经验证过。 +* `"decline"`:他们说了不。 +* `"cancel"`:他们没做选择就关掉了问题。 + +`result.data` 只在 `"accept"` 时存在,这就是示例先检查 `result.action` 的原因。类型检查器会强制这个顺序:在 `result.action == "accept"` 之后,`result.data` 是 `AlternativeDate`;在那之前根本没有 `.data`。 + +拒绝不是错误。由工具决定拒绝意味着什么(这里是不订位),然后正常回答模型。 + +!!! tip + 答案在你的代码看到之前就已按你的模型验证过。一个给 `bool` 字段发来 `"maybe"` 的客户端不会弄坏你的订位:调用以模式不匹配的错误失败,你的 `if` 根本不会执行。 + +## 把用户引到一个 URL {#send-the-user-to-a-url} + +有些东西绝不能经过模型或客户端:凭据、卡号、OAuth 授权。对这些,你不索要数据,而是请用户去一个地方: + +```python title="server.py" hl_lines="10-14 23" +--8<-- "docs_src/elicitation/tutorial002.py" +``` + +* `ctx.elicit_url()` 接受消息、要访问的 **URL**,以及一个你自己选的 `elicitation_id`:任何能在你的服务器内标识这次征询的字符串。 +* 结果只有一个 action,别无其他。`"accept"` 表示用户同意打开这个 URL,**不是**表示他们完成了另一头的事情。 +* 支付在带外进行,发生在用户的浏览器和你的支付提供商之间。没有任何内容会通过 MCP 回来。 + +看第二个工具。当你的服务器得知带外流程结束了(一个 webhook、一次轮询;这里建模成第二个工具),`ctx.session.send_elicit_complete(...)` 会用同一个 `elicitation_id` 发送 `notifications/elicitation/complete`。客户端就是这样知道可以不再显示“waiting for payment...”的。没有它,客户端只能猜。 + +## 客户端一侧 {#the-client-side} + +服务器提问。客户端通过给 `Client(...)` 传一个 **`elicitation_callback`** 来回答: + +```python title="client.py" hl_lines="6-7 18" +--8<-- "docs_src/elicitation/tutorial003.py" +``` + +* 一个回调处理两种模式。`params` 是 `ElicitRequestFormParams` 和 `ElicitRequestURLParams` 的联合类型;用 `isinstance` 分支。 +* 对 URL,把 `params.url` 展示给用户,返回他们选的 action。永远不带任何 `content`。 +* 对表单,真实的应用会渲染 `params.requested_schema`,把用户的输入作为 `content` 返回。这个回调总是用一个固定答案说“是”,这正是测试里想要的回调。 +* 传入回调同时也是**能力声明**:服务器就是这样得知这个客户端可以被提问。客户端能替服务器回答的其他东西在 **[客户端回调](../client/callbacks.md)**。 + +!!! info + 征询是从**服务器**发往**客户端**的请求,而这类请求只存在于经典握手的会话上,这就是这个客户端传 `mode="legacy"` 的原因。在 **2026-07-28** 连接上,工具改为把问题从调用里**返回**出去来提问;那个流程见 **[多轮往返请求](multi-round-trip.md)**。 + +### 试一试 {#try-it} + +用 Streamable HTTP 启动 `ctx.elicit` 表单模式的 `server.py`(`book_table` 那个)(那条一行命令见 **[运行服务器](../run/index.md)**),然后运行客户端的 `main()`,向 `book_table` 要圣诞节当天的位子。 + +回调会打印它收到的问题: + +```text +No tables for 2 on 2025-12-25. Would you like to try another date? +``` + +它回答 `{"accept_alternative": True, "date": "2025-12-27"}`,而一直在 `await ctx.elicit(...)` 里等着的工具完成订位: + +```text +Booked a table for 2 on 2025-12-27. +``` + +现在换成 URL 模式的 `server.py`,让同一个 `main()` 去调 `pay_deposit`:同一个回调走另一条分支,打印支付链接,工具返回“Complete the payment in your browser.”。一次往返,调用中途,双向都有。 + +!!! check + 现在从 `Client` 里去掉 `elicitation_callback=`,再为圣诞节当天调一次 `book_table`。整个调用以协议错误失败: + + ```text + Elicitation not supported + ``` + + 没注册回调的客户端从未声明 `elicitation` 能力,所以没人可问。你的工具收到的不是 `"decline"`,而是一个异常。要为此设计:每一次征询都需要对“要是问不了怎么办?”有一个合理的答案。 + +## 回顾 {#recap} + +* 标注为 `Annotated[T, Resolve(fn)]` 的参数由解析器填充,解析器需要提问时返回 `Elicit(...)`。它在每种连接上都能用。 +* 模式是一个扁平的 Pydantic 模型:只能有原始类型字段,回来时会验证。 +* `result.action` 是 `"accept"`、`"decline"` 或 `"cancel"`;`result.data` 只在 accept 时存在。 +* `await ctx.elicit(message, schema=Model)` 在工具函数体内部提问,`await ctx.elicit_url(message, url, elicitation_id)` 用于一切绝不能经过模型的东西(`ctx.session.send_elicit_complete(elicitation_id)` 表示带外部分已完成)。两者都是服务器到客户端的请求:需要客户端处于旧版连接。 +* 客户端用一个 `elicitation_callback` 回答,按 params 类型分支;注册它就是声明能力。 +* 在 2026-07-28 连接上,服务器返回问题而不是推送问题;同一个回调的输入来自 **[多轮往返请求](multi-round-trip.md)**。 + +那次返回之下的一切(重试循环、保护 `requestState`、自己驱动它)见 **[多轮往返请求](multi-round-trip.md)**。 diff --git a/i18n/zh/pages/handlers/index.md b/i18n/zh/pages/handlers/index.md new file mode 100644 index 0000000000..e788547e9c --- /dev/null +++ b/i18n/zh/pages/handlers/index.md @@ -0,0 +1,24 @@ +--- +translation: + sections: [424930166c4bc6f3] + tool: 1 +--- +# 在处理函数内部 {#inside-your-handler} + +处理函数的参数来自客户端。除此之外它能读到的**其他**一切,以及它运行期间能做的一切,都在这里。 + +它能读到什么: + +* **[Context](context.md)** 是任何处理函数都可以额外要求的那一个参数:当前请求、它的标头、它的会话,以及进度和变更通知这些动作。 +* **[依赖](dependencies.md)** 是模型永远看不到的参数,由你自己的函数通过 `Resolve` 填入。 +* **[生命周期](lifespan.md)** 讲的是服务器在启动时只构建一次的状态,以及处理函数如何通过 `Context` 拿到它。 + +它运行期间能做什么: + +* 用 **[征询(elicitation)](elicitation.md)** 向用户请求更多输入,以及承载它的 2026-07-28 模式 **[多轮往返请求](multi-round-trip.md)**(multi-round-trip)。 +* 用 **[采样(sampling)与根目录(roots)](sampling-and-roots.md)** 向客户端请求一次 LLM 补全或它的工作区文件夹——已弃用,但仍然提供。 +* 对耗时的操作报告 **[进度](progress.md)**。 +* 用 **[日志](logging.md)** 写日志(写到标准错误,给运维服务器的人看)。 +* 用 **[订阅](subscriptions.md)** 告诉已订阅的客户端有东西变了。 + +如果还没注册过处理函数,先看 **[工具](../servers/tools.md)**。这里的每一页都假设你已经有一个了。 diff --git a/i18n/zh/pages/handlers/lifespan.md b/i18n/zh/pages/handlers/lifespan.md new file mode 100644 index 0000000000..155d578df6 --- /dev/null +++ b/i18n/zh/pages/handlers/lifespan.md @@ -0,0 +1,101 @@ +--- +translation: + sections: [f3ca8ac5f90f2dfa, 85a1ef3588ba0736, 563346d4d5804933, 9e3528340d0bab53] + tool: 1 +--- +# 生命周期 {#lifespan} + +大多数真实的服务器在整个运行期间都会持有某样东西:数据库连接池、HTTP 客户端、加载好的模型。 + +你不想每次调用都重新构建它,又希望能干净地关闭它。这就是**生命周期(lifespan)**的用途。 + +## 带类型的生命周期 {#a-typed-lifespan} + +生命周期是一个 `@asynccontextmanager`,它接收服务器并 `yield` **一个对象**。无论 yield 出什么,只要服务器在运行,每个处理函数都能用到它。 + +```python title="server.py" hl_lines="25-31 34 38 40" +--8<-- "docs_src/lifespan/tutorial001.py" +``` + +从下往上读: + +* `app_lifespan` 在 `yield` **之前**连接 `Database`,并在**之后**的 `finally` 里断开连接。这就是启动和关闭。 +* 它 yield 一个 `AppContext`,一个普通的 dataclass,装着你准备好的东西。今天是一个字段,明天可能是十个。 +* `MCPServer("Bookshop", lifespan=app_lifespan)` 就是全部的接线。 +* 在工具内部,yield 出的对象是 `ctx.request_context.lifespan_context`。 + +生命周期只运行**一次**。服务器启动时(第一个请求之前)进入,服务器停止时退出。其间的每个请求共享同一个 `AppContext`。 + +!!! info + 如果你写过 FastAPI 的 `lifespan`,这些你已经会了。同样的装饰器,同样的 `yield`,同样的 `finally`。 + +### 模型看到什么 {#what-the-model-sees} + +没有新东西。`ctx` 是一个 **Context** 参数,所以 SDK 会注入它,它永远不会进入输入模式: + +```json +{ + "type": "object", + "properties": { + "genre": {"title": "Genre", "type": "string"} + }, + "required": ["genre"], + "title": "count_booksArguments" +} +``` + +`genre` 是模型唯一能传入的参数。生命周期是服务器自己的事。 + +`@mcp.resource()` 和 `@mcp.prompt()` 函数也可以接收 `ctx` 参数,只是要写成裸的 `Context`,原因下一节会讲到。`ctx` 携带的所有内容详见 **[Context](context.md)**。 + +### 它确实带类型 {#it-really-is-typed} + +再看一眼那个注解:`ctx: Context[AppContext]`。 + +正是这一个类型参数,让 `ctx.request_context.lifespan_context` 在类型检查器眼里**就是**一个 `AppContext`。`.db` 能自动补全;`.dbb` 在你运行服务器之前就会报错。 + +如果改写成裸的 `Context`,`lifespan_context` 的类型就是 `dict[str, Any]`:类型检查器无从知道你的生命周期 yield 了什么。运行时对象还在,只是失去了类型上的帮助。 + +!!! warning + `Context[AppContext]` 是**仅限工具**的写法。把它放在 `@mcp.resource()` 或 `@mcp.prompt()` 函数上,对该处理函数的每次调用都会失败。客户端会收到一个错误,服务器日志会说明原因: + + ```text + Context is not available outside of a request + ``` + + 在资源和提示词里,写裸的 `ctx: Context`。生命周期 yield 出的对象在运行时仍然是 `ctx.request_context.lifespan_context`;你放弃的是类型参数,不是对象。 + +!!! tip + 生命周期总是存在。如果你不传,SDK 的默认实现会 yield 一个空 `dict`,所以 `ctx.request_context.lifespan_context` 是 `{}`,绝不会是 `None`。也正是因为这个默认值,裸的 `Context` 才把它的类型定为 `dict[str, Any]`。 + +## 亲眼看它发生 {#watch-it-happen} + +“启动在第一个请求之前运行”这种话,不该只凭信任接受。 + +把服务器精简到只剩生命周期:给 `Database` 加一个 `connected` 标志,在 `connect()` 和 `disconnect()` 里翻转它,再加一个报告它的工具。 + +```python title="server.py" hl_lines="11 14 17 25 44" +--8<-- "docs_src/lifespan/tutorial002.py" +``` + +`database` 放在模块级别只有一个原因:这样就能从服务器**外部**观察它。 + +!!! check + 三个时刻,三个值: + + * 服务器启动前,`database.connected` 是 `False`。导入模块什么也没连接。 + * 运行期间,调用 `database_status`,结果是 `"connected"`。 + * 停止服务器,`finally` 块运行:`database.connected` 又变回 `False`。 + + 工作恰好发生在你放的位置:围绕 `yield`,不在导入时,也不是每个请求一次。 + +## 回顾 {#recap} + +* `lifespan=` 接收一个 `@asynccontextmanager`,它接收服务器并 `yield` 一个对象。 +* `yield` 之前的代码是启动。之后的 `finally` 是关闭。 +* 它只运行一次,围绕服务器的整个生命,而不是每个请求一次。 +* 无论 `yield` 出什么,它在每个工具、资源和提示词里都是 `ctx.request_context.lifespan_context`。 +* `ctx: Context[AppContext]` 让这种访问在工具里完全带类型。资源和提示词用裸的 `Context`。 +* 不传 `lifespan=` 意味着一个空 `dict`,绝不会是 `None`。 + +在调用中途停下来,向用户询问只有他们知道的事情的处理函数,详见 **[征询(elicitation)](elicitation.md)**。 diff --git a/i18n/zh/pages/handlers/logging.md b/i18n/zh/pages/handlers/logging.md new file mode 100644 index 0000000000..b01d8342e6 --- /dev/null +++ b/i18n/zh/pages/handlers/logging.md @@ -0,0 +1,79 @@ +--- +translation: + sections: [c93a3e1aefd77955, 7851abd5ec54393b, f49d1ca2f330f9cd, c03764bd9dfeef7b, 4a0391691a674ae4, 2df5cd279eabf9f5] + tool: 1 +--- +# 日志 {#logging} + +在工具里记录日志,和在其他任何 Python 函数里一样:用标准库。 + +MCP 在协议层面有一个**日志能力**(logging capability):服务器可以通过 `Context` 对象上的方法,把自己的日志消息作为通知推送给客户端。规范的 2026-07-28 修订版**弃用了这个能力,而且没有提供替代方案**,所以本文档不讲它。哪些内容已弃用、该用什么代替,完整清单见 **[已弃用的功能](../deprecated.md)**。 + +取而代之的做法,就是你在其他所有 Python 程序里的做法:标准库。 + +## 一个会记录日志的工具 {#a-tool-that-logs} + +```python title="server.py" hl_lines="1 5 13" +--8<-- "docs_src/logging/tutorial001.py" +``` + +* `logging.getLogger(__name__)` 返回一个以模块名命名的 logger。在文件顶部创建一次即可。 +* 在工具内部调用 `logger.info(...)`,和在其他任何函数里一样。不用注入什么,不用 `await` 什么,也没有任何 MCP 特有的东西。 + +!!! check + 调用这个工具,看看完整的结果: + + ```python + result.content # [TextContent(text="Found 3 books matching 'dune'.")] + result.structured_content # {'result': "Found 3 books matching 'dune'."} + ``` + + 里面哪儿都没有那行日志。日志是给**你**——运维这个服务器的人——看的。模型永远看不到它。如果某些内容应该让模型读到,就 `return` 它。 + +## 日志去哪了 {#where-it-goes} + +对 **stdio** 服务器来说,这个问题比平时更要紧。宿主把你的服务器作为子进程启动,并从它的 **stdout** 读取 MCP 消息。标准错误才是你的。 + +标准库默认就做对了:日志输出默认写到 `sys.stderr`。你的 `logger.info(...)` 会落在终端里(或者宿主收集子进程 stderr 的任何地方),协议流保持干净。 + +!!! tip + 不要在 stdio 服务器里 `print()`。`print` 写的是 **stdout**,而 stdout 属于协议。在服务期间,SDK 会把真正被**刷新**(flush)出去的 stdout 转到 stderr,所以它不会破坏线路;但在块缓冲的进程里,`print()` 的内容通常会一直留在 `sys.stdout` 的缓冲区里没有刷新,直到解释器在退出时把它排空——直接排到协议流上。即使被转走了,这一行也是原样混在日志输出当中,没有级别、没有 logger 名称,也没办法过滤。 + + `logger.debug("got here")` 同样只是一行的功夫,而且会去到正确的地方。 + +## 日志级别 {#the-level} + +不需要自己调用 `logging.basicConfig()`。构造 `MCPServer` 时已经调用过了:配了一个指向标准错误的 handler,级别就是你通过 `log_level=` 传入的值。所以只要 `MCPServer("Bookshop", log_level="DEBUG")`,就能看到你的 `logger.debug(...)` 输出。 + +默认值是 `"INFO"`。 + +`logging.basicConfig()` 永远不会替换已经存在的 handler。如果你在创建服务器之前自己配置了日志,以你的配置为准。 + +## 试一试 {#try-it} + +用 MCP Inspector 运行服务器: + +```console +uv run mcp dev server.py +``` + +在 **Tools** 标签页调用 `search_books`。Inspector 显示的结果只有返回值。这一行 + +```text +Searching for 'dune' +``` + +去了标准错误:终端,而不是线路。 + +!!! info + 如果你真正想要的是**追踪**(每个请求、耗时多久、是否失败),那你要的不是日志行,而是 span。你的服务器已经在产出它们了:SDK 默认就用 OpenTelemetry 追踪每一条消息。见 **[OpenTelemetry](../run/opentelemetry.md)**。 + +## 回顾 {#recap} + +* MCP 协议的日志能力已被 2026-07-28 规范弃用,且没有替代。不要基于它构建。 +* 模块级写 `logger = logging.getLogger(__name__)`,工具里写 `logger.info(...)`。整个模式就这些。 +* 日志输出永远到不了模型那里。只有你 `return` 的值才会。 +* 标准错误是你的;stdout 属于协议。服务期间 SDK 会把已刷新的零散 stdout 转到 stderr,但没刷新的 `print()` 仍可能在退出时排到线路上,而且被转走的行没有任何标记;用 `logging`,它的 handler 每条记录都会刷新。 +* `MCPServer(..., log_level="DEBUG")` 设置级别;你先做好的日志配置不会被改动。 + +告诉已连接的客户端服务器上有东西变了(工具列表、某个资源),见 **[订阅](subscriptions.md)**。 diff --git a/i18n/zh/pages/handlers/multi-round-trip.md b/i18n/zh/pages/handlers/multi-round-trip.md new file mode 100644 index 0000000000..2c5b0e7f14 --- /dev/null +++ b/i18n/zh/pages/handlers/multi-round-trip.md @@ -0,0 +1,183 @@ +--- +translation: + sections: [74011e683045eea9, 9b64cc175c18b6a9, 4b41be4824030397, e3b1502da786ec33, 71e41161f143c6a9, 9ec2c1eeb8c36378, 8dd027377d46448b, f81491125dcbfe8b] + tool: 1 +--- +# 多轮往返(multi-round-trip)请求 {#multi-round-trip-requests} + +有时一个工具没法在一次往返内完成。它需要只有用户才有的东西:一个选择、一次确认、一份凭据。 + +在 2026-07-28 之前,服务器靠**回调**拿到它:在处理原请求的中途,自己向客户端发起一个请求——一次征询(elicitation)、一次采样(sampling)调用。2026-07-28 规范移除了这条反向通道(back-channel)。 + +取而代之的是,服务器**返回**。 + +## 返回,而不是回调 {#return-dont-call-back} + +服务器用 **`InputRequiredResult`** 而不是 `CallToolResult` 来响应 `tools/call`。起作用的是其中两个字段: + +* **`input_requests`**:服务器还需要什么,形式是一个 dict,键是服务器自己选的名字。每个值是一个 `ElicitRequest`、`CreateMessageRequest` 或 `ListRootsRequest`。 +* **`request_state`**:一个不透明的令牌。客户端在重试时原样回传。只有你的服务器会读它。 + +客户端满足每个请求,然后**再次调用同一个工具**,把答案放在 `input_responses` 里,令牌放在 `request_state` 里。服务器这时拿到了缺的东西,返回一个普通的 `CallToolResult`。 + +整个协议就是这样。每一轮都是客户端发给服务器的普通请求,没有任何东西反方向流动。 + +## 服务器端 {#the-server-side} + +在 `@mcp.tool()` 上很少需要手动构造它:声明一个向用户提问(`Elicit`)、对客户端的 LLM 采样(`Sample`)或列出客户端根目录(roots,`ListRoots`)的依赖,SDK 就会替你返回 `InputRequiredResult`;这种形式见 **[依赖](dependencies.md)** 页面。两种形式不能混用:一次调用只有一条 `input_responses`/`request_state` 通道,所以使用 `Resolve(...)` 参数的工具不能再从函数体返回 `InputRequiredResult`。声明了 `InputRequiredResult` 返回类型的会在注册时被拒绝(`InvalidSignature`),没声明的则在运行时让调用失败。手动形式是**低层** `Server`,它的 `on_call_tool` 处理函数可以返回两种结果类型中的任意一种: + +```python title="server.py" hl_lines="43-46" +--8<-- "docs_src/mrtr/tutorial001.py" +``` + +* `on_call_tool` 的类型标注是 `-> CallToolResult | InputRequiredResult`。返回后者就是服务器端的全部 API。 +* 第一次调用时 `params.input_responses` 是 `None`,于是守卫条件成立,处理函数提问而不是回答。 +* 重试时,客户端发来的 `ElicitResult` 就在服务器在 `input_requests` 里用过的**同一个键**(`"region"`)下。 + +那个文件里的其他内容(显式的 `input_schema`、手工构造的 `CallToolResult`)都是普通的低层 `Server`,详见 **[低层 Server](../advanced/low-level-server.md)**。本页只是多加了第二种返回类型。 + +## 不止于工具 {#beyond-tools} + +`tools/call` 并不特殊:在 2026-07-28 下,服务器可以用同样的方式响应 `prompts/get` 和 `resources/read`。在 `MCPServer` 上,`@mcp.prompt()` 函数——或 `@mcp.resource()` **模板**函数——自己返回 `InputRequiredResult`,并从上下文里读取重试带来的答案: + +```python title="server.py" hl_lines="20 22 24" +--8<-- "docs_src/mrtr/tutorial004.py" +``` + +* 第一轮返回 `InputRequiredResult`。重试时,`ctx.input_responses` 在同样的键下保存着答案,函数返回它的普通结果——这里是提示词消息,对模板资源来说是资源内容。 +* 你设置的 `request_state` 在上线路之前会被密封,回传时会被校验,和服务器上的其他状态一样;下面的 **[保护 `requestState`](#protecting-requeststate)** 说明密封带来了什么、什么时候需要配置密钥。 +* 当依赖形式不合适时,`@mcp.tool()` 函数也可以用同样的方式直接返回这个结果。 +* 静态的 `@mcp.resource()` 函数不参与:它们不接收 `Context`,所以永远读不到重试。只有模板资源能提问。 +* 下文关于协议时代的规则原样适用:在 2026 之前的会话上返回 `InputRequiredResult`,就是警告里描述的那个 `-32603`。 + +## 客户端 {#the-client-side} + +`Client` 替你跑这个循环。 + +注册服务器可能用到的回调(`elicitation_callback`、`sampling_callback`、`list_roots_callback`),然后调用工具。`InputRequiredResult` 到达时,`Client` 把 `input_requests` 里的每一项分派给对应的回调,带着答案和回传的 `request_state` 重试,一直持续到拿回 `CallToolResult`: + +```python title="client.py" hl_lines="11 12" +--8<-- "docs_src/mrtr/tutorial003.py" +``` + +* 那个 `elicitation_callback` 正是 2026 之前的服务器通过反向通道发出的 `elicitation/create` 会命中的那个。`sampling_callback` 之于 `sampling/createMessage`、`list_roots_callback` 之于 `roots/list` 也一样:在 2026-07-28 下,独立的服务器->客户端 RPC 没有了,但完全相同的 `ElicitRequest` / `CreateMessageRequest` / `ListRootsRequest` 载荷搭在 `input_requests` 里,分派给同样的三个回调。一套回调服务两个时代。 +* `call_tool` 返回普通的 `CallToolResult`。中间的轮次对调用方不可见。 +* `get_prompt` 和 `read_resource` 驱动同一个循环。 + +!!! check + 去掉回调,循环在第一轮就会失败:SDK 的占位回调会用错误回答每一次征询,`call_tool` 抛出 `MCPError`,消息是“Elicitation not supported”。 + +循环是有界的。`Client(..., input_required_max_rounds=10)` 是默认上限;服务器超过上限还在返回 `InputRequiredResult`,`call_tool` 就会抛出异常。如果某一轮只带 `request_state` 而没有 `input_requests`,`Client` 会在重试前短暂休眠(50 ms 起翻倍,上限 250 ms),这样一个只是在说“还没好”的服务器不会被忙轮询。 + +### 自己驱动循环 {#driving-the-loop-yourself} + +自动循环对单进程客户端已经够用。遇到以下情况,改为自己掌控循环: + +* 客户端是**分布式**的:把问题呈现给用户的进程不是调用 `call_tool` 的进程,所以重试由另一个 worker 发出。`request_state` 是跨越这条边界、经由你自己的存储携带的可持久化令牌;`input_responses` 是另一侧连同它一起发回的东西。 +* 想**检查**每一轮:记录或审计每一个 `input_requests` 项,拒绝某些类型的请求,或在两轮之间应用自己的退避策略。 +* 想要**挂钟时间**的上限而不是轮数上限:把自己的循环包在 `anyio.fail_after(...)` 里,而不是依赖 `input_required_max_rounds`。 + +下探到底层 session,在那里 `allow_input_required=True` 直接把联合类型交给你: + +```python title="client.py" hl_lines="12 13 19" +--8<-- "docs_src/mrtr/tutorial002.py" +``` + +* `client.session.call_tool(..., allow_input_required=True)` 把返回类型放宽为 `CallToolResult | InputRequiredResult`。`isinstance` 负责把它重新收窄。 +* `request_state` 现在在你手上。两轮之间把它记下来,对话就能从一个全新的进程恢复。 +* 对 `input_requests` 里的每一项,在 `input_responses` 的**同一个键**下放一个 `InputResponse`。`fulfil` 是放你的 UI 的地方;这个例子把答案写死了。 +* 每一轮都是同一个工具名、同样的 `arguments`。重试是把原调用再执行一遍,不是一个新方法。 + +## 保护 `requestState` {#protecting-requeststate} + +上面一直把 `request_state` 当作回传,在线路上它也确实只是这样。但客户端在两轮之间持有它(跨进程记下来正是上一节认可的做法),所以回来的东西是**客户端提供的输入**:它可能被改动、过期,或者干脆是从另一次调用里搬来的。规范要求,只要这个状态能影响授权、资源访问或业务逻辑,服务器就必须对它做完整性保护,并在校验失败时拒绝这一轮。 + +`MCPServer` 默认就保护它。每个服务器都会用进程启动时生成的密钥密封发出的 `requestState`,并校验每一次回传——解析器状态和手工构造的状态都一样。你什么都不用配置,写的是明文,读的也是明文;线路上只会出现一个不透明的加密令牌。 + +默认密钥与进程同生共死,这是部署到单进程之外前必须知道的一件事: + +```python +from mcp.server.mcpserver import MCPServer, RequestStateSecurity + +# Multi-instance or restart-surviving: one or more shared secret keys (>= 32 bytes each). +mcp = MCPServer("fleet", request_state_security=RequestStateSecurity(keys=[key])) +``` + +* **默认(不配置)**适合单进程:stdio,或恰好一个 HTTP worker。落到另一个 worker、负载均衡器后面的另一个实例、或重启后的同一服务器上的重试,是用那个进程没有的密钥密封的——客户端会收到下面那条固定的拒绝,必须从头开始这个流程。 +* 只要重试可能到达**另一个实例**(多 worker 的 `uvicorn`、负载均衡的 HTTP)或必须熬过重启,就需要 **`keys=[...]`**:每个实例都能校验任何同伴签发的东西。同样的机制,只是用你的密钥替代生成的密钥。 +* 要用自己的加密方案,比如 KMS 或已有的令牌服务,传 `RequestStateSecurity(codec=...)` 而不是 `keys`;下面的 **[自带加密](#bring-your-own-crypto)** 说明了契约。 + +### 密封里带了什么 {#what-the-seal-carries} + +无论默认还是配置过,线路上的 `requestState` 都是一个加密且经过认证的令牌。你的代码永远看不到它:处理函数和解析器写明文、读明文(`ctx.request_state`);SDK 在发出时密封,在收到时校验。除了完整性,每个令牌还绑定到: + +* **一个时间窗口。** 每一轮都用新的过期时间重新密封,所以 `RequestStateSecurity(ttl=...)`(默认 600 秒)限制的是每轮的思考时间,而不是整个流程。 +* **已认证的主体。** 当请求携带一个经 SDK 校验的 OAuth 访问令牌时,状态绑定到该令牌的客户端、颁发者和 subject:为一个用户签发的状态在另一个用户下会失败,即使两个用户共用一个 OAuth 客户端。不提供 subject 的校验器会让绑定退化为仅客户端身份,而在基于 URL 的客户端 ID 下,这个身份由该客户端软件的所有用户共享。当认证在 SDK 之外终结(前置代理),或传输未经认证时,没有主体可绑定,这项检查不起作用,除非 `RequestStateSecurity(bind_principal=...)` 从你自己的身份信号提供一个。无论你的令牌校验器提供哪些组成部分,都必须一致地提供:一个在某些请求上包含 subject、在另一些请求上省略它的校验器会在流程中途改变主体,进行中的轮次会被拒绝。 +* **发起的请求。** 方法、工具或提示词名称(或资源 URI),以及参数的摘要。针对不同工具、不同参数或不同方法重放的令牌会失败。 +* **所问的确切问题。** 每个解析器答案都钉在客户端看到的那个渲染后的问题上,无论是它第一次到达的那一轮,还是之后复用已记录答案的时候。换了措辞的消息或改过的 schema 重新部署后,服务器会重新提问,而不是吞下一个过期的答案。同样的钉住也有反面:要从工具的参数派生消息,而不是从每次调用的数据派生。用时间戳或实时汇率构造的消息每一轮渲染都不一样,于是每个已记录的答案看起来都过期了,服务器一直重新提问,直到客户端的轮数上限结束这次调用。 + +这些全是 SDK 的工作,不是你的;如果你自带 codec,也不是 codec 的。 + +### 轮换密钥 {#rotating-keys} + +`keys[0]` 密封新状态;列表里的每个密钥都参与校验。零停机轮换分三个阶段,每个阶段完全铺开后再进入下一个: + +```python +RequestStateSecurity(keys=[OLD, NEW]) # 1: every instance learns to verify NEW; OLD still mints +RequestStateSecurity(keys=[NEW, OLD]) # 2: NEW mints; in-flight OLD state keeps verifying +RequestStateSecurity(keys=[NEW]) # 3: one ttl after phase 2 is fully out, retire OLD +``` + +永远不要先提升签发密钥:用某个实例还不能校验的密钥签发,会在铺开途中丢掉进行中的轮次。 + +密钥的作用域是单个服务。密封的信封还把服务器的名字作为 audience 声明带上,所以另一个恰好共用密钥的服务签发的令牌照样会被拒绝。这个声明的区分度取决于名字,所以被赋予显式策略的服务器必须有一个真实的名字,或者设置 `RequestStateSecurity(audience=...)`——没有名字的会在构造时抛出异常。`audience=` 也服务于有意为之的多服务拓扑,即一个服务必须接受另一个服务签发的状态。(不配置的默认情形不受此限:它的密钥从不离开进程,audience 声明没有什么可补充的。) + +### 自带加密 {#bring-your-own-crypto} + +`RequestStateSecurity(codec=...)` 接受任何带有 `seal(bytes) -> str` 和 `unseal(str) -> bytes`、并对任何不是自己签发的令牌抛出 `InvalidRequestState` 的对象。典型形态是基于 KMS 的信封加密:启动时解包一次数据密钥,每个令牌的加解密留在本地: + +```python title="server.py" hl_lines="12 26-27 34-35 38" +--8<-- "docs_src/mrtr/tutorial005.py" +``` + +TTL、主体绑定和请求绑定**不是** codec 的工作:对每个 codec,SDK 都在 `seal` 之前把它们印进载荷,在 `unseal` 之后重新校验。codec 唯一的义务是完整性(被篡改就抛出异常),以及最好有机密性。 + +### 校验失败时 {#when-verification-fails} + +每一个入站失败,无论是被篡改、过期、针对不同请求或主体重放,还是用本服务器不认识的密钥密封的,得到的都是同一个回答: + +```json +{"code": -32602, "message": "Invalid or expired requestState"} +``` + +所有原因都是同一条固定消息,这样线路上永远不会泄露哪项检查失败了;真正的原因写进服务器日志。`tools/call`、`prompts/get` 和 `resources/read` 上每一个入站的 `requestState` 都会被检查,包括发给一个从不签发状态的处理函数的。实践中最常见的拒绝不是攻击者——而是默认的进程本地密钥遇上了来自重启之前或另一个实例的重试;客户端重新开始流程,需要在意时 `keys=[...]` 就是解法。 + +### 手工构造的状态 {#hand-built-state} + +你自己设置的 `request_state`(从工具、提示词或资源模板函数返回 `InputRequiredResult`)由与解析器状态相同的机制密封和校验,代码一行不用改:写明文、读明文,上面的每一项绑定都适用。 + +即使配置过,SDK 唯一无法替你钉住的是问题的身份:它不知道你状态里的某个答案属于**你的**哪一个问题。如果按问题为键存答案,就在状态里放进你自己的问题标识符,并在重试时检查它。 + +低层 `Server` 是什么都不自带的那一层:和 `MCPServer` 不同,在你自己加上这道边界之前什么都不会被密封,在那之前你的 `request_state` 按原样跨越线路。一行代码的启用方式见 **[低层 Server](../advanced/low-level-server.md#the-other-handlers)**。 + +## 一个 2026-07-28 的结果 {#a-2026-07-28-result} + +`InputRequiredResult` 只存在于协议版本 **2026-07-28**。内存中的 `Client(server)` 替你协商它;走线路时,`mode="auto"` 会发现它。连接之后,`client.protocol_version` 告诉你拿到的是什么。 + +!!! warning + 2026 之前的会话没有地方放 `InputRequiredResult`。在 `mode="legacy"` 连接上从处理函数返回一个,运行器无法把它序列化到协商好的版本;客户端收到的是 `-32603`“Handler returned an invalid result”错误。同时服务两个时代的服务器在用它之前必须检查 `ctx.protocol_version`。 + +!!! info + **URL 模式的征询**在 2026 连接上走的正是这套机制。`input_requests` 里的那一项是一个 params 为 `ElicitRequestURLParams` 的 `ElicitRequest`;用户完成带外流程,你的客户端重试调用。同一个循环,没有新 API。高层服务器那一半见 **[征询](elicitation.md)**。 + +## 回顾 {#recap} + +* 在 2026-07-28 下,调用中途需要输入的服务器**返回**一个 `InputRequiredResult`。它从不向客户端发起请求。 +* `input_requests` 是它需要的东西。`request_state` 是只有服务器会读的不透明恢复令牌。 +* `Client` 替你跑重试循环:注册 `elicitation_callback` / `sampling_callback` / `list_roots_callback`,`call_tool` 就返回普通的 `CallToolResult`。`input_required_max_rounds`(默认 10)给它设了上限。 +* 要检查或持久化轮次,用 `client.session.call_tool(..., allow_input_required=True)`,自己掌控 `while isinstance(result, InputRequiredResult)` 循环。 +* 在 `@mcp.tool()` 上,一个向用户提问的依赖会替你产生这个结果(**[依赖](dependencies.md)**);**低层** `Server` 是手动形式。 +* 提示词和资源也参与:`@mcp.prompt()` 或模板 `@mcp.resource()` 函数自己返回 `InputRequiredResult`,重试时读取 `ctx.input_responses`。 +* `requestState` 回来时是客户端提供的输入,所以 `MCPServer` 默认用进程本地密钥密封它——解析器状态和手工构造的状态都一样;多实例部署传入 `RequestStateSecurity(keys=[...])`(或自定义 codec),让每个实例都能校验同伴签发的东西。密封把每个令牌绑定到一个时间窗口、发起的请求,以及已认证的主体——当请求携带经 SDK 校验的认证信息,或 `bind_principal=` 提供了你自己的身份信号时(**[保护 `requestState`](#protecting-requeststate)**)。 + +这就是取代服务器发起的采样以及其余推送式反向通道的机制;见 **[已弃用的功能](../deprecated.md)**。 diff --git a/i18n/zh/pages/handlers/progress.md b/i18n/zh/pages/handlers/progress.md new file mode 100644 index 0000000000..a39d3be2e0 --- /dev/null +++ b/i18n/zh/pages/handlers/progress.md @@ -0,0 +1,112 @@ +--- +translation: + sections: [5315262fe26b33e1, 9d8e98840f1b78f0, 0284b215e85366c4, 8534d8dbb4053a70, 2966fac6fe697007] + tool: 1 +--- +# 进度 {#progress} + +一个要跑三十秒的工具,如果这三十秒里一声不吭,看起来就像坏了。 + +**进度通知**解决的就是这个问题。工具报告自己做到哪了;客户端决定拿它画什么:进度条、旋转指示器,还是一行日志。 + +## 从工具里报告 {#report-it-from-the-tool} + +接收一个 **`Context`** 参数,然后调用 `report_progress`: + +```python title="server.py" hl_lines="8 11" +--8<-- "docs_src/progress/tutorial001.py" +``` + +三个参数,含义由你决定: + +* `progress`:做到哪了。规范要求它每次报告都**递增**;不要重复同一个值,也不要倒退。 +* `total`:总共有多少,如果你知道的话。可选。 +* `message`:描述**这一步**的一行人类可读文字。可选。 + +`ctx` 是因为类型注解被注入的,模型永远看不到它:`import_catalog` 的输入模式只有一个属性 `urls`。**[Context](context.md)** 页面专门讲这个对象;进度只是它提供的功能之一。 + +## 从客户端监听 {#listen-for-it-from-the-client} + +客户端**按调用**选择接收,方法是给 `call_tool` 传 `progress_callback=`: + +```python title="client.py" hl_lines="7 16" +import anyio +from mcp import Client + +from server import mcp + + +async def show(progress: float, total: float | None, message: str | None) -> None: + print(f"{message} ({progress}/{total})") + + +async def main() -> None: + async with Client(mcp) as client: + result = await client.call_tool( + "import_catalog", + {"urls": ["https://example.com/a.json", "https://example.com/b.json"]}, + progress_callback=show, + ) + print(result.structured_content) + + +anyio.run(main) +``` + +回调是一个 `async` 函数,接收的正是服务器报告的内容:`progress`、`total`、`message`。 + +!!! info + `Client(mcp)` 直接在内存中连接到服务器对象,和 **[测试](../get-started/testing.md)** 页面所用的是同一个客户端。无论 `Client` 用哪种传输方式,`progress_callback` 都是同一个参数;接下来看到的**时序**则是内存连接特有的。它以内联方式运行你的回调,所以每条报告都在 `call_tool` 返回之前送达。换成真实的传输方式,通知会和结果竞速,`call_tool` 已经返回之后,一个慢的回调可能还在运行。 + +### 试一试 {#try-it} + +把 `client.py` 放在 `server.py` 旁边,然后运行: + +```console +python client.py +``` + +```text +Imported https://example.com/a.json (1/2) +Imported https://example.com/b.json (2/2) +{'result': 'Imported 2 records.'} +``` + +服务器上的每一次 `await ctx.report_progress(...)` 都变成了客户端上对 `show` 的一次调用,顺序不变,而且两行都在 `call_tool` 返回**之前**打印了出来。进度不会打包进结果里;它在工具还在干活的时候就流式送出。 + +!!! warning + `progress_callback` 属于**调用**,而不是 `Client`。没有对应的构造函数参数,因为不同的调用想要不同的回调:这一次驱动下载进度条,下一次是一行日志。 + +!!! check + 现在删掉 `progress_callback=show`,再运行一次: + + ```text + {'result': 'Imported 2 records.'} + ``` + + 没有错误,没有警告,结果相同。`report_progress` **在调用方没有请求进度时是空操作**,所以可以无条件地报告,永远不用操心有没有人在听。 + +## 不知道总量时 {#when-you-dont-know-the-total} + +`total` 用在知道分母的时候。很多时候并不知道:你在消费一个 feed、遍历一个游标、下载一个没有长度头的东西。 + +那就省略它: + +```python title="server.py" hl_lines="20" +--8<-- "docs_src/progress/tutorial002.py" +``` + +回调收到的是 `total=None`。客户端仍然可以显示**有动静**(“目前已导入 3 条……”),但显示不了百分比。不要为了进度条好看而编造一个总量。 + +!!! tip + `progress` 不一定非得数某样特定的东西。字节、行、页:选用户认得出的单位,并且只承诺你能兑现的 `total`。 + +## 回顾 {#recap} + +* 在任何接收 `Context` 的工具里调用 `await ctx.report_progress(progress, total=None, message=None)`。 +* 客户端给 `call_tool` 传 `progress_callback=`:按调用传,永远不在 `Client` 上设。 +* 回调的形式是 `async (progress, total, message) -> None`,在工具还在运行时就会触发。 +* 调用上没有回调,`report_progress` 就什么都不做。无条件地报告即可。 +* 不知道 `total` 就省略;回调拿到的是 `None`。 + +进度是运行中的工具展示给**用户**看的。它为**你**——运维这台服务器的人——记录的那些日志行走的是另一条通道:**[日志](logging.md)**。 diff --git a/i18n/zh/pages/handlers/sampling-and-roots.md b/i18n/zh/pages/handlers/sampling-and-roots.md new file mode 100644 index 0000000000..889c15e9f7 --- /dev/null +++ b/i18n/zh/pages/handlers/sampling-and-roots.md @@ -0,0 +1,51 @@ +--- +translation: + sections: [5c82b20cbd65ded0, 9dc22632be79a533, 1fb8f452e990c456, 42666ab914ff0cb1, c4e0cb3667fd5ff9] + tool: 1 +--- +# 采样与根目录 {#sampling-and-roots} + +处理函数还可以向已连接的客户端索取两样东西:一是用客户端自己的模型生成一次补全,即**采样**(sampling);二是客户端的工作区文件夹,即**根目录**(roots)。 + +两者在 SDK 支持的每个协议版本上都仍然可用。但在围绕它们做设计之前,先读下面的警告: + +!!! warning "已被 2026-07-28 规范弃用" + 采样和根目录自 `2026-07-28` 起已弃用([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2577))。它们仍然完全可用,并且会在规范中至少保留十二个月才有可能被移除,但新的实现不应再建立在它们之上。建议的迁移方式:直接对接 LLM 提供商的 API,而不是使用采样;通过工具参数、资源 URI 或服务器配置传递目录,而不是使用根目录。SDK 范围内的完整清单见 **[已弃用的功能](../deprecated.md)**。 + +## 采样:借用客户端的模型 {#sampling-borrow-the-clients-model} + +解析器返回 `Sample(...)`,工具就会收到补全结果,走的是和 **[依赖](dependencies.md)** 中运行 `Elicit` 相同的依赖机制: + +```python title="server.py" hl_lines="10-15 19" +--8<-- "docs_src/sampling_and_roots/tutorial001.py" +``` + +* `Sample(messages, max_tokens=...)` 与 `sampling/createMessage` 的参数一一对应。注入的值是客户端的 `CreateMessageResult`;如果传入 `tools` 或 `tool_choice`,注入的就是 `CreateMessageResultWithTools`。 +* 客户端必须声明了 `sampling` 能力(如果传入 `tools` 或 `tool_choice`,则需要 `sampling.tools`)。如果没有声明,调用会以 `-32021` 协议错误失败,而不会发出一个客户端无法处理的请求。没有反向通道(back-channel)的 2026 年之前的会话,会照常以无反向通道的错误失败,因为根本没有可以发送的通道。 +* 在 `2026-07-28` 上,请求在多轮往返(multi-round-trip)流程中送达(见 **[多轮往返请求](multi-round-trip.md)**);在 `2025-11-25` 上,它是发给客户端的一个独立请求。两种情况下代码都一样,但要注意多轮往返的规则:请求在各轮重试中必须渲染得完全一致,所以只能用工具的参数和其他稳定数据来构造它。 +* 不要动 `include_context`:`"none"` 以外的值本身也已弃用(SEP-2596),而且需要一个几乎没有客户端会声明的能力。 + +## 根目录:这个该放哪儿? {#roots-where-should-this-go} + +根目录是客户端声明服务器可以操作的文件夹。它们只是参考信息,不是访问控制机制。解析器返回 `ListRoots()`: + +```python title="server.py" hl_lines="10-11 15" +--8<-- "docs_src/sampling_and_roots/tutorial002.py" +``` + +* 注入的 `ListRootsResult` 带有一个 `Root` 列表:每项是一个 `file://` URI 和一个可选的显示名称。 +* 门槛和采样一样:没有声明 `roots` 能力时,调用会以 `-32021` 失败,而不会发出请求。 + +在线路的另一端,客户端用它已有的回调来响应这两种请求:`sampling_callback` 和 `list_roots_callback`,详见 **[客户端回调](../client/callbacks.md)**。 + +## 在 2025 年代的连接上 {#on-2025-era-connections} + +`ctx.session.create_message(...)` 和 `ctx.session.list_roots()` 仍然存在,供直接驱动会话的代码使用。它们只在存在反向通道的地方有效(2025 年代、非无状态的连接),并且调用它们会触发弃用警告。上面的解析器标记才是受支持的形式:它们根据协商出的版本选择投递方式,也不会发出警告。 + +## 回顾 {#recap} + +* 从解析器返回 `Sample(...)` 或 `ListRoots()`;工具会像接收其他任何依赖项一样收到 `CreateMessageResult` 或 `ListRootsResult`。 +* 客户端必须声明对应的能力,否则调用会以 `-32021` 失败,而不会发出请求。 +* 两个功能在 `2026-07-28` 上都已弃用:目前完全可用,但不适合新的设计。优先使用提供商 API 而非采样,优先使用显式参数而非根目录。 + +报告一个耗时工具的进度:**[进度](progress.md)**。 diff --git a/i18n/zh/pages/handlers/subscriptions.md b/i18n/zh/pages/handlers/subscriptions.md new file mode 100644 index 0000000000..2932921fba --- /dev/null +++ b/i18n/zh/pages/handlers/subscriptions.md @@ -0,0 +1,152 @@ +--- +translation: + sections: [60a9de8a0bdaa531, 317bbe7e4355cdcc, a61d660c8029e04a, 8f7e82fcb88df8a9, b165db51249ff8ed, 266f56fb798068a4, 7c0e57030b622139, df18d7c2417a9883] + tool: 1 +--- +# 订阅 {#subscriptions} + +服务器的目录不是固定的。工具会在运行时出现,资源 URI 背后的内容也会变化。 + +**订阅(subscriptions)**就是客户端得知这些变化的方式。客户端发送一个 `subscriptions/listen` 请求,而这个请求的响应**就是**流本身:它保持打开,承载客户端要求的变更通知。 + +## 在工具里发布变更 {#publish-it-from-the-tool} + +你这一边只需要一行:发布变更。 + +```python title="server.py" hl_lines="20 32" +--8<-- "docs_src/subscriptions/tutorial001.py" +``` + +* `await ctx.notify_resource_updated("board://sprint")` 会送达每一个订阅了该 URI 的打开中的流。其他人收不到。 +* `await ctx.notify_tools_changed()` 会送达每一个要求接收工具列表变更的流。收到它的客户端会再次调用 `tools/list`,这时就能看到 `sprint_report`。 +* 同类方法还有 `notify_prompts_changed()` 和 `notify_resources_changed()`。 +* 没有订阅者,就没有开销。向空闲的服务器发布是空操作,所以永远不需要检查有没有人在听。只管声明什么变了。 + +`MCPServer` 替你处理 `subscriptions/listen`。线路上的义务(第一帧是确认、按流过滤、每一帧都带订阅 id)是 SDK 的事。 + +!!! check + 在线路上,一个过滤器里指定了 `board://sprint` 的流,在 `complete_task` 运行之后是这样的: + + ```json + {"method": "notifications/subscriptions/acknowledged", + "params": {"notifications": {"resourceSubscriptions": ["board://sprint"]}, "_meta": {"io.modelcontextprotocol/subscriptionId": "listen-1"}}} + + {"method": "notifications/resources/updated", + "params": {"uri": "board://sprint", "_meta": {"io.modelcontextprotocol/subscriptionId": "listen-1"}}} + ``` + + 注意这条更新**没有**携带什么:看板本身。每一帧都在 `_meta` 下携带 listen 请求的 JSON-RPC id,这个 id 就是订阅 id。它由客户端生成:Python 的 `Client` 用 `"listen-1"` 这样的字符串;其他客户端可能用整数。 + +## 只给要求的内容 {#only-what-was-asked-for} + +过滤器是一份契约。一个请求了工具列表变更和一个资源 URI 的流,只会收到这两类,别的什么都没有。发布一条提示词变更,那个流保持沉默。 + +`MCPServer` 把资源 URI 当作精确字符串来匹配,所以指定了 `board://sprint` 的流听不到任何关于 `board://sprint/tasks/1` 的消息。规范允许服务器报告已订阅 URI 的子资源上的变更;`MCPServer` 从不这么做,但客户端被设计为要能应对这种情况。 + +流**不是**的两样东西: + +* **它不是重放日志。** 断掉的流就没了,没人连接时发布的事件不会排队。客户端要重新 listen 并重新获取。 +* **它不是 2025 的路径。** 调用了 `resources/subscribe` 的客户端由 `ctx.session.send_resource_updated(uri)` 服务。`notify_*` 方法只送达 `subscriptions/listen` 流。 + +## 决定谁可以观察 {#deciding-who-may-watch} + +默认情况下,请求的每一种类别和 URI 都会被接受:任何调用方都可以观察你发布的任何 URI。没有任何东西会去查你的读取处理函数,因为没人在读取——一个会被你的 `files://{name}` 处理函数拒之门外的调用方,仍然可以在 `files://payroll.csv` 上打开一个流,得知它变了,以及什么时候变的。它永远拿不到内容,也无法探测哪些东西存在,因为未知的 URI 同样会被接受,只是永远不会触发。范围窄,但确实存在,所以在多租户服务器发布按用户区分的 URI 之前,先加上门控。 + +门控是一个中间件。它在 SDK 确认之前看到 `subscriptions/listen` 请求,当调用方要求了任何它无权读取的东西时就拒绝: + +```python title="server.py" hl_lines="19-26 29" +--8<-- "docs_src/subscriptions/tutorial006.py" +``` + +* `ctx.params` 是原始请求,所以中间件自己把它校验成 `SubscriptionsListenRequestParams`,再读取客户端要求的过滤器。 +* 拒绝就是在 `call_next(ctx)` 之前抛出 `MCPError`:客户端收到这个错误而没有流,连接照常继续。让消息保持统一、不点名任何 URI,这样拒绝永远不会证实哪些 URI 是受保护的。 +* 一个 `can_access(user, uri)` 同时回答两个问题。资源处理函数在 `resources/read` 时问它;中间件在 `subscriptions/listen` 时问它。把这张表换成数据库或你的 RBAC 系统,两边依然保持一致。 +* 这个决定在流的整个生命周期内有效。没有逐事件的重新检查,所以如果调用方的访问权限可能在流途中失效(令牌过期),就在失效时结束该调用方的连接。 + +完整的中间件契约,包括它还包裹了什么、为什么被标记为暂定,见 **[中间件](../advanced/middleware.md)**。 + +## 客户端这一端 {#the-client-end} + +下面是流另一侧的一个客户端,跟踪着看板: + +```python title="client.py" hl_lines="15" +--8<-- "docs_src/subscriptions/tutorial003.py" +``` + +进入 `client.listen(...)` 会发送请求并等待你的确认,所以代码块开始时流已经是活的,每个带类型的事件都是重新获取的信号,从来不是载荷。这就是一屏之内的整份契约。关于客户端这一端的其他所有内容都在它自己的页面上:在主流程旁边观察、流的结束、以及重新 listen。见“客户端”下的 **[订阅](../client/subscriptions.md)**。 + +## 扩展到多个进程 {#scaling-past-one-process} + +发布通过一个 `SubscriptionBus` 从你的处理函数传到打开的流。默认是内存内的:一个进程,里面的每一个流。在你把多个副本放到负载均衡器后面之前,这就是正确答案;因为到那时,客户端的流被固定在一个副本上,而另一个副本上的发布必须能到达它。 + +这个接缝由你来实现:在你的 pub/sub 后端之上写两个方法。 + +```python +from collections.abc import Callable + +from redis.asyncio import Redis + +from mcp.server.mcpserver import MCPServer +from mcp.server.subscriptions import ServerEvent # SubscriptionBus is a Protocol: no base class + + +class RedisSubscriptionBus: + def __init__(self, redis: Redis) -> None: + self._redis = redis + self._listeners: dict[object, Callable[[ServerEvent], None]] = {} + + async def publish(self, event: ServerEvent) -> None: + await self._redis.publish("mcp-events", encode(event)) # to every replica + + def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]: + token = object() + self._listeners[token] = listener + + def unsubscribe() -> None: + self._listeners.pop(token, None) + + return unsubscribe + + +mcp = MCPServer("Sprint Board", subscriptions=RedisSubscriptionBus(redis)) +``` + +`encode` 由你来写,每个副本上负责解码到达的消息并调用每个已注册 listener 的读取任务也是。listener 是同步的,不得抛出异常,并且在服务器的事件循环上运行。 + +总线承载的是带类型的 `ServerEvent` 值,四个小的 dataclass,从来不是 JSON-RPC。打标、过滤和流的生命周期都留在 SDK 里,所以总线实现无法破坏协议。它只能在进程之间搬运事件。 + +要在请求之外发布,就自己构造总线,这样你手里就有它的引用。不传任何东西时 `MCPServer` 会在内部建一个,并且不会暴露它。 + +```python +from mcp.server.subscriptions import InMemorySubscriptionBus, ToolsListChanged + +bus = InMemorySubscriptionBus() +mcp = MCPServer("Sprint Board", subscriptions=bus) + + +async def tools_reloaded() -> None: + await bus.publish(ToolsListChanged()) # from a lifespan task, a webhook, anywhere +``` + +## 低层组合 {#the-low-level-composition} + +在低层的 `Server` 上没有任何预先接好的东西,同样的部件三行就能组装起来: + +```python title="server.py" hl_lines="8-9 47" +--8<-- "docs_src/subscriptions/tutorial002.py" +``` + +* 总线归你所有,所以直接向它发布:`await bus.publish(ResourceUpdated(uri=...))`。把它放在处理函数够得着的地方:这里是模块作用域,更大的应用里是生命周期。 +* `ListenHandler(bus)` 就是 `MCPServer` 注册的那个处理函数,`on_subscriptions_listen=` 是一个普通的处理函数槽位。在这个槽位里放你自己的可调用对象来实现不同的语义,规范上的义务就转到你身上:先确认,每一帧都打上订阅 id,不投递过滤器之外的任何东西。 +* `ListenHandler.close()` 优雅地结束每一个打开的流。每个流收到 listen 请求的结果作为最后一帧,这是规范表达“服务器有意结束了订阅”的方式。它在这些流完成刷新之前就返回,所以在拆掉传输之前给它们一点时间。没有它,流会在客户端断开时结束。 + +## 回顾 {#recap} + +* 客户端用一个 `subscriptions/listen` 请求选择加入,响应就是流。服务它是内置的。 +* 你用 `ctx.notify_*` 发布,SDK 负责打标、过滤和生命周期。 +* 事件是信号,不是载荷。两端都重新获取。 +* 客户端这一端是 `async with client.listen(...)`:详见“客户端”下的 **[订阅](../client/subscriptions.md)**。 +* 在低层的 `Server` 上你自己组装同样的部件:一个总线、`ListenHandler(bus)`、`on_subscriptions_listen` 槽位。 +* 横向扩展意味着实现 `SubscriptionBus`,两个方法,然后作为 `MCPServer(subscriptions=...)` 传入。 + +运行提供这一切的服务器,不管是一个副本还是二十个,见 **[部署与扩展](../run/deploy.md)**。 diff --git a/i18n/zh/pages/index.md b/i18n/zh/pages/index.md new file mode 100644 index 0000000000..7de9370fd3 --- /dev/null +++ b/i18n/zh/pages/index.md @@ -0,0 +1,97 @@ +--- +translation: + sections: [154c4309937b9f85, 3ad8fc6caa76a9b0, a07f3f5b151ab746, bf6e476b712930c0, cf0b1f13978c6623] + tool: 1 +--- +# MCP Python SDK {#mcp-python-sdk} + +!!! info "本文档对应 v2,即当前的稳定版本系列" + 刚接触 v2,或者从 v1 过来?**[v2 新特性](whats-new.md)** 用五分钟带你了解有哪些变化,**[迁移指南](migration.md)** 则涵盖每一项破坏性变更。还在用 v1.x?它的文档在 [v1.x 文档](https://py.sdk.modelcontextprotocol.io/v1/)。哪里不顺手或看不明白?[告诉我们](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml)。 + +**Model Context Protocol (MCP)** 让应用程序以标准化的方式为 LLM 提供上下文,把 **提供** 上下文这一关注点与 LLM 交互本身分离开来。 + +这是 MCP 的官方 Python SDK。用它可以: + +* **构建 MCP 服务器**,向任意 MCP 宿主暴露工具、资源和提示词。 +* **构建 MCP 客户端**,连接到任意 MCP 服务器。 +* 支持所有标准传输方式:stdio、Streamable HTTP 和 SSE。 + +## 环境要求 {#requirements} + +需要 Python 3.10+。 + +## 安装 {#installation} + +=== "uv" + + ```bash + uv add "mcp[cli]" + ``` + +=== "pip" + + ```bash + pip install "mcp[cli]" + ``` + +`[cli]` 附加项提供 `mcp` 命令,开发时会用到它。各个依赖的用途见 [安装](get-started/installation.md)。 + +## 示例 {#example} + +### 创建 {#create-it} + +创建文件 `server.py`: + +```python title="server.py" +--8<-- "docs_src/index/tutorial001.py" +``` + +这就是一个完整的 MCP 服务器。 + +它暴露了一个 **工具** `add`,以及一个模板化的 **资源** `greeting://{name}`。 + +### 运行 {#run-it} + +```console +uv run mcp dev server.py +``` + +这会启动你的服务器并打开 [MCP Inspector](https://github.com/modelcontextprotocol/inspector),一个用来摆弄服务器的交互式界面。打开它打印出的 URL。 + +!!! note + Inspector 是一个 Node.js 应用,所以 `mcp dev` 需要 `PATH` 里有 `npx`。 + +### 试一试 {#try-it} + +在 Inspector 里进入 **Tools**,用 `a=1`、`b=2` 调用 `add`。 + +返回值是 `3`。✨ + +那个表单(一个给 `a` 的必填整数字段,另一个给 `b`)是 Inspector 根据你的类型提示生成的。Claude 也会这样做,其他所有 MCP 宿主也一样。 + +现在进入 **Resources**,读取 `greeting://World`: + +```text +Hello, World! +``` + +### 回顾 {#recap} + +回头再看看你 **没有** 写的东西: + +* 没有 JSON Schema。`a: int, b: int` **就是** 模式。 +* 没有请求解析,没有序列化,也没有校验代码。 +* 完全没有协议处理。 + +你写了两个带类型提示和文档字符串的 Python 函数。剩下的由 SDK 完成。 + +## 下一步 {#where-to-go-next} + +* **[快速开始](get-started/index.md)** 带你从安装一直走到一个可用、经过测试的服务器。 +* 在构建一个 **使用** MCP 服务器的应用?从 **[客户端](client/index.md)** 开始。 +* 已经有 FastAPI 或 Starlette 应用了?**[添加到现有应用](run/asgi.md)** 会把 MCP 服务器挂载到其中。 +* 在找某条确切的错误信息?**[故障排查](troubleshooting.md)** 按报错原文逐字编排索引。 +* 想知道 v2 改了什么?**[v2 新特性](whats-new.md)** 是一份五分钟导览。 +* 从 v1 迁移?从 **[迁移指南](migration.md)** 开始。 +* 在找某个确切的签名?**[API 参考](api/mcp/index.md)** 由源码生成。 +* 借助 LLM 阅读?本文档也以 [llms.txt](https://llmstxt.org/) 格式发布:[llms.txt](https://py.sdk.modelcontextprotocol.io/llms.txt) 是各页面的索引,[llms-full.txt](https://py.sdk.modelcontextprotocol.io/llms-full.txt) 则把所有页面放在单个文件中。 diff --git a/i18n/zh/pages/protocol-versions.md b/i18n/zh/pages/protocol-versions.md new file mode 100644 index 0000000000..c2be647799 --- /dev/null +++ b/i18n/zh/pages/protocol-versions.md @@ -0,0 +1,127 @@ +--- +translation: + sections: [478fd619e5f90ef8, aef094a00e44e248, bab8cbf3449fa7e9, df1809b15a58335b, 5f9d8c2336ed0239, f54974398e43ddef, b24443dd78584870] + tool: 1 +--- +# 协议版本 {#protocol-versions} + +MCP 有两个时代。 + +在 2026-07-28 之前发布的服务器,每个连接都以 **`initialize` 握手**开场:客户端提出一个版本,服务器回应,客户端确认,这一切都发生在第一个真正有用的请求之前。**2026-07-28** 的服务器去掉了握手。客户端发送一次 **`server/discover`** 探测,服务器用一个结果一次性回答全部内容。 + +你几乎不需要关心这些,因为 `Client` 会替你协商。本页讲的是控制这一行为的唯一一个构造参数 `mode=`,以及需要改动它的三种情形。 + +## `mode="auto"` {#modeauto} + +```python title="client.py" hl_lines="14-15" +--8<-- "docs_src/protocol_versions/tutorial001.py" +``` + +没有传 `mode`,所以用的是默认值:`"auto"`。进入 `async with` 时,会以本 SDK 支持的最新版本发送一次 `server/discover` 探测。然后: + +* **新版服务器**会回答它。客户端采纳结果。一次往返,完事。 +* **旧版服务器**从没听说过 `server/discover`,返回一个错误。客户端回退到经典的 `initialize` 握手,接受握手协商出的结果。 + +无论哪种情况,结束时连接都已建立,`client.protocol_version` 会告诉你走的是哪条路: + +```text +2026-07-28 +``` + +整个功能就这些。一个 `Client`,任意时代的服务器,代码里不需要分支。 + +!!! info + `MCPServer` 在每种传输方式上都会回答 `server/discover`——内存、stdio、Streamable HTTP——所以连接你自己的服务器时,`auto` 总是落在 `2026-07-28`。回退只会在面对真正的 2026 年之前的服务器时触发,而那正是你需要它的时候。 + +## `mode="legacy"` {#modelegacy} + +```python title="client.py" hl_lines="14" +--8<-- "docs_src/protocol_versions/tutorial002.py" +``` + +`mode="legacy"` 从不探测。它执行 `initialize` 握手,打开的连接和 2026 年之前的客户端一样。 + +```text +2025-11-25 +``` + +同一个服务器。它完全能讲 `2026-07-28`;是你告诉客户端不要去问。 + +**推送式**功能需要这个模式。 + +服务器发起的请求,就是服务器调用**你**:`ctx.elicit(...)` 在你的用户面前弹出一个表单,采样(sampling)在工具调用中途向你的模型请求补全。这条通道只存在于握手时代的会话上。 + +到了 2026-07-28,它就没有了。服务器把问题**返回**给你,你带着答案重试这次调用(**[多轮往返(multi-round-trip)请求](handlers/multi-round-trip.md)**)。 + +`mode="auto"` 只有在服务器旧到别无选择时才会给你握手。`mode="legacy"` 则保证有握手。只要给 `Client(...)` 传了 `sampling_callback`、希望以请求方式驱动的 `elicitation_callback`,或者 `message_handler`,就用它。**[客户端回调](client/callbacks.md)** 会逐一讲解。 + +## 固定版本 {#pinning-a-version} + +`mode` 也接受一个新版协议版本字符串。目前这个集合正好是 `["2026-07-28"]`。 + +```python title="client.py" hl_lines="14" +--8<-- "docs_src/protocol_versions/tutorial003.py" +``` + +固定版本**什么都不**发送。没有探测,没有握手。客户端在本地采纳 `2026-07-28`,`async with` 一返回连接就可用。 + +固定版本是**你**做出的承诺:你已经知道服务器讲这个版本。客户端不会检查。 + +!!! check + 固定版本不是发现。打印 `client.server_info`,代价一目了然: + + ```text + None + ``` + + 客户端从没问过服务器它是谁,所以 `server_info` 是 `None`。`client.server_capabilities` 也是一样:每项能力都是 `None`。工具调用照常工作(协议不需要这些信息);而那些读取 `server_capabilities` 来决定提供什么的代码就不行了。 + + 下一节就是解决办法。 + +只有新版版本可以固定。握手时代的字符串在构造时就会被拒绝,在任何 I/O 之前,错误信息会告诉你该怎么写: + +```text +ValueError: mode must be 'legacy', 'auto', or one of ['2026-07-28']; got '2025-06-18' ('2025-06-18' is a handshake-era version; use mode='legacy') +``` + +## 用 `prior_discover` 重连 {#reconnecting-with-prior_discover} + +探测很便宜,但它仍然是每次重连都要付出的一次往返,而答案几乎从不改变。 + +所以把它存下来。一次 `auto` 连接之后,`client.session.discover_result` 保存着服务器发来的那个 `DiscoverResult` 原样:它的 `supported_versions`、它的 `capabilities`、它的 `instructions`,以及服务器写进结果 `_meta` 里的身份信息。下次把它作为 `prior_discover=` 传回去: + +```python title="client.py" hl_lines="15 17" +--8<-- "docs_src/protocol_versions/tutorial004.py" +``` + +```text +2026-07-28 +Bookshop +``` + +第二次连接的协商往返为**零**,却依然清楚地知道对方是谁。这才是固定模式的正确用法:`mode=` 指定版本,`prior_discover=` 提供身份。✨ + +`DiscoverResult` 是一个 Pydantic 模型。`saved.model_dump_json()` 可以写进文件或缓存;`DiscoverResult.model_validate_json(...)` 在下一个进程里把它取回来。 + +!!! tip + `prior_discover=` 只有在 `mode` 是版本固定时才起作用。在 `"auto"` 下客户端照样会探测服务器,在 `"legacy"` 下它会被忽略。 + +## 四种模式 {#the-four-modes} + +| 你写的 | 协商流量 | 你得到的 | +| --- | --- | --- | +| `Client(target)` | 一次 `server/discover` 探测;失败则执行 `initialize` 握手 | 双方都支持的最新版本,不论哪个时代 | +| `Client(target, mode="legacy")` | `initialize` 握手 | 一个握手时代的版本;服务器发起的请求可用 | +| `Client(target, mode="2026-07-28")` | 无 | 该版本,已固定,`server_info` 为 `None` | +| `Client(target, mode="2026-07-28", prior_discover=saved)` | 无 | 该版本,已固定,**外加**你上次保存的身份 | + +## 回顾 {#recap} + +* MCP 有一个握手时代(到 `2025-11-25` 为止,`initialize` 握手)和一个新时代(`2026-07-28`,`server/discover`)。`Client` 在两者之间架桥。 +* `mode="auto"` 是默认值:先探测,再回退。除非另外三行之一说的是你,否则不用动它。 +* `client.protocol_version` 永远能回答“我得到的是什么?”。 +* `mode="legacy"` 强制握手。服务器发起的请求需要它:采样、推送式征询(elicitation)、`message_handler`。 +* 版本固定(`mode="2026-07-28"`)完全不发送协商流量,代价是 `client.server_info` 为 `None`。 +* `prior_discover=` 把这个代价补回来:保存 `client.session.discover_result`,用它重连,两者兼得。 + +新版连接没有推送通道,那么 2026 的服务器在调用中途怎么向你提问?它把问题返回:**[多轮往返请求](handlers/multi-round-trip.md)**。 diff --git a/i18n/zh/pages/run/asgi.md b/i18n/zh/pages/run/asgi.md new file mode 100644 index 0000000000..40d01513df --- /dev/null +++ b/i18n/zh/pages/run/asgi.md @@ -0,0 +1,130 @@ +--- +translation: + sections: [1062ef792791488a, 4be2b831547184a9, 374b049e770385f2, b72f6947089e6de0, b172c9db7831bb31, 70b9ece244ca1b0c, cba78e052898c3f6, f06bdb541cb0b469, fb82d526320b7cc3] + tool: 1 +--- +# 添加到现有应用 {#add-to-an-existing-app} + +`mcp.run("streamable-http")` 会替你启动一个 Web 服务器。有时你并不想这样:MCP 服务器只是一个更大的 Web 应用的一部分,或者你已经有现成的 ASGI 部署。 + +为此,`mcp.streamable_http_app()` 会返回一个 **Starlette 应用**。 + +Starlette 应用就是 ASGI 应用,所以任何能承载 ASGI 的东西(uvicorn、Hypercorn、另一个 Starlette、FastAPI)都能承载你的 MCP 服务器。 + +## 应用 {#the-app} + +```python title="server.py" hl_lines="12" +--8<-- "docs_src/asgi/tutorial001.py" +``` + +`app` 是一个普通的 ASGI 应用。把它交给任意 ASGI 服务器即可: + +```console +uvicorn server:app +``` + +MCP 端点位于 `/mcp`,所以客户端连接的是 `http://127.0.0.1:8000/mcp`。 + +这个应用已经自带两样东西: + +* 一条路由 `/mcp`:Streamable HTTP 端点。 +* 一个**生命周期**,用来启动 `mcp.session_manager`——这个对象掌管每个活跃会话的后台工作。 + +单独运行这个应用(`uvicorn server:app`)时,这两样都不用你操心。 + +!!! tip + `streamable_http_app()` 接受与 `mcp.run("streamable-http", ...)` 相同的关键字参数,只是少了 `port`:端口归负责承载该应用的那一方管。`host` 仍然可以传,但在这里不绑定任何东西;它实际控制什么,**[部署与扩展](deploy.md)** 有说明。各选项本身见 **[运行服务器](index.md)**。 + +`mcp.sse_app()` 为已被取代的 SSE 传输做同样的事。 + +## 默认只响应 localhost,除非你另行指定 {#localhost-only-until-you-say-otherwise} + +默认情况下,这个应用**只**响应发往 localhost 的请求。`streamable_http_app()` 无从知道自己会被部署在哪个主机名后面,所以它以最保守的允许列表启用 DNS 重绑定防护;在你自己的机器上,这正合适。部署到真实主机名后面时,这意味着**每个请求都会被以 `421 Misdirected Request` 拒绝**,直到你通过 `transport_security=` 传入一份你实际提供服务的主机名允许列表。在那之前,你写的任何东西都不会被调用。这份允许列表,以及从一个能跑的应用到真实主机名之间的其他一切,详见 **[部署与扩展](deploy.md)**。 + +## 挂载 {#mounting-it} + +一旦 MCP 服务器成为更大应用的**一部分**,就要把这个应用放进一个 `Mount` 里。而一旦这么做,生命周期就成了你的事: + +```python title="server.py" hl_lines="18-21 25-26" +--8<-- "docs_src/asgi/tutorial002.py" +``` + +* `Mount("/", ...)` 加上默认的 `/mcp` 路径,端点仍在 `/mcp`。Starlette 按顺序尝试路由,而 `Mount("/")` 会匹配**所有**路径,所以你自己的路由要放在列表里它的**前面**。排在它后面的都无法访问。 +* `lifespan` 函数在**宿主**应用的整个生命周期内进入 `mcp.session_manager.run()`。这是人人都会忘的那一行。 +* `mcp.session_manager` 只有在调用过 `streamable_http_app()` **之后**才存在。所以路由在模块层面就构建好,而会话管理器只在生命周期函数内部才去访问。 + +Starlette 的 `Host` 路由用法相同:把 `Mount("/", ...)` 换成 `Host("mcp.example.com", ...)`,就改为按主机名而不是按路径来路由。生命周期的规则不变,传输安全的规则也不变。`Host("mcp.example.com", ...)` 路由只会收到发往该主机名的请求,但传输自身的 Host 允许列表(**[部署与扩展](deploy.md)**)仍然先执行。列表里没有 `"mcp.example.com"` 的话,这条路由对每一个请求都回以 `421`。 + +!!! warning "生命周期归宿主应用管" + `streamable_http_app()` 把 `session_manager.run()` 接入了它返回的 Starlette 的生命周期,但**被挂载的子应用的生命周期永远不会运行**。一旦挂载,这个内置的生命周期就成了死代码。无论哪个应用位于 ASGI 栈的最顶层,都必须在自己的生命周期里进入 `mcp.session_manager.run()`。 + +!!! check + 删掉 `lifespan=lifespan` 这一行再启动服务器。能启动,路由也能解析。然后对 `/mcp` 的第一个请求会失败: + + ```text + RuntimeError: Task group is not initialized. Make sure to use run(). + ``` + + 除了它的 `run()`,没有别的东西会启动会话管理器。 + +## 两个服务器,一个应用 {#two-servers-one-app} + +每个 `MCPServer` 都是独立的应用,带有自己的会话管理器。想挂载多少就挂载多少;在同一个宿主生命周期里进入每一个管理器: + +```python title="server.py" hl_lines="27-30 35-36" +--8<-- "docs_src/asgi/tutorial003.py" +``` + +* `AsyncExitStack` 进入两个管理器;它们一起启动,按相反顺序关闭。 +* 端点是 `/notes/mcp` 和 `/tasks/mcp`:挂载前缀加默认路径。 + +## 更改路径 {#changing-the-path} + +末尾的那个 `/mcp` 就是 `streamable_http_path`。把它设为 `"/"`,挂载前缀就成了完整的公开路径: + +```python title="server.py" hl_lines="25" +--8<-- "docs_src/asgi/tutorial004.py" +``` + +现在客户端连接 `/notes`,而不是 `/notes/mcp`。 + +## 面向浏览器客户端的 CORS {#cors-for-browser-clients} + +基于浏览器的客户端需要你给两项许可:**发送**它的 MCP 请求头,以及**读取** MCP 返回的那个响应头。两者都是宿主应用上的 CORS 配置,而且上面的传输安全允许列表必须与之一致: + +```python title="server.py" hl_lines="27-30 33 35-49" +--8<-- "docs_src/asgi/tutorial005.py" +``` + +* `allow_headers` 是人人都会忘的那一半。浏览器会对每个 MCP 请求做**预检**,因为 `Content-Type: application/json` 和 `Mcp-*` 请求头都不在 CORS 安全列表里,而预检没有放行的头,就意味着浏览器根本不会发出这个请求。(`allow_headers=["*"]` 也行:Starlette 会按预检请求所要求的内容原样应答。) +* `expose_headers=["Mcp-Session-Id"]` 是读取那一半。Streamable HTTP 在这个响应头里返回会话 ID,而浏览器会对 JavaScript 隐藏响应头,除非 CORS 按名称公开它们。没有它,客户端永远发不出第二个请求。 +* `allow_origins` 由你决定,不归 MCP 管。写得精确些,并在上面的 `allowed_origins=` 里保持一致:CORS 由浏览器强制执行,但服务器自己也会检查 `Origin`,传输不信任的来源即使预检顺利通过,也会得到 `403`。 +* `allow_methods` 列出 Streamable HTTP 用到的三个方法:`POST` 发送消息,`GET` 打开服务器到客户端的流,`DELETE` 结束会话。 + +## 自定义路由 {#custom-routes} + +`@mcp.custom_route()` 在同一个应用上注册一个普通的 HTTP 端点,用于每个部署出去的服务都需要、却与 MCP 无关的东西:健康检查、OAuth 回调。 + +```python title="server.py" hl_lines="15-17" +--8<-- "docs_src/asgi/tutorial006.py" +``` + +* 处理函数就是普通的 Starlette:一个从 `Request` 到 `Response` 的 `async` 函数。 +* `streamable_http_app()` 会收进每一条自定义路由。`app.routes` 现在是 `/mcp` 和 `/health`。 +* `GET /health` 应答 `{"status": "ok"}`,完全不涉及 MCP。 + +!!! warning + 自定义路由**永远不做认证**,即使服务器的其余部分做了。这是有意为之:健康检查和 OAuth 回调必须在任何令牌存在之前就能访问。不要把任何私密内容放在它后面。 + +## 回顾 {#recap} + +* `mcp.streamable_http_app()` 返回一个只有一条路由 `/mcp` 的 Starlette 应用。任何 ASGI 服务器都能运行它。 +* 默认情况下这个应用只响应发往 localhost 的请求;部署在真实主机名后面时,在你通过 `transport_security=` 传入允许列表之前,它会以 `421` 拒绝一切。这件事,以及通往生产环境的其余路程,都归 **[部署与扩展](deploy.md)** 管。 +* `Mount`(或 `Host`)把它放进更大的 Starlette 或 FastAPI 应用。 +* **挂载会让内置生命周期失效。**宿主应用的生命周期必须进入 `mcp.session_manager.run()`,否则第一个请求就会失败。 +* 一个应用里放多个服务器,意味着多个挂载,加上一个进入每个会话管理器的生命周期。 +* `streamable_http_path="/"` 把端点移到挂载前缀本身。 +* 浏览器客户端需要 CORS:`allow_headers` 放行 `Mcp-*` 请求头,`expose_headers=["Mcp-Session-Id"]` 公开响应头。 +* `@mcp.custom_route()` 在 `/mcp` 旁边添加普通的、不做认证的 HTTP 端点。 + +服务器一旦能通过真实 URL 访问,**[客户端](../client/index.md)** 就可以用这个 URL 而不是服务器对象来连接它。 diff --git a/i18n/zh/pages/run/authorization.md b/i18n/zh/pages/run/authorization.md new file mode 100644 index 0000000000..5b7de60d04 --- /dev/null +++ b/i18n/zh/pages/run/authorization.md @@ -0,0 +1,122 @@ +--- +translation: + sections: [d62c13457fc4a534, 80e73abaca6e0652, d1dc4c54cd00ec9c, 14ad3bc7904036bb, 5225f127bc1b9c77, fe1626fdd5aad1da, 4556cb7ea1a04a31] + tool: 1 +--- +# 授权 {#authorization} + +通过 Streamable HTTP 运行时,你的 MCP 服务器就是一个普通的 Web 服务,保护它的方式也和保护其他 Web 服务一样:用 OAuth 2.1 bearer token。 + +用 OAuth 的术语说,你的服务器是**资源服务器**。它从不负责任何人的登录,也从不签发 token。它只做一件事:查看每个请求的 `Authorization` 头,判断其中的 token 是否有效。 + +本页讲的是服务器端。负责发现你的授权服务器并获取 token 的客户端,见 **[OAuth 客户端](../client/oauth-clients.md)**。 + +## 三方角色 {#the-three-parties} + +* **授权服务器**负责用户登录并签发访问 token。这部分不用你写,它就是你的身份提供方(Auth0、Keycloak、Entra,或者你自己的)。 +* **资源服务器**就是你的 MCP 服务器。它在每个请求上验证 token。 +* **客户端**发现你信任的是哪个授权服务器,从那里拿到 token,再以 `Authorization: Bearer ` 的形式发回给你。 + +整个三角关系就是这样。本页所有内容都是中间那一条。 + +## Token 验证器 {#a-token-verifier} + +有效的 token 长什么样,SDK 没有任何预设。这由你来决定,方式是实现 **`TokenVerifier`**: + +```python title="server.py" hl_lines="12-14 19-24" +--8<-- "docs_src/authorization/tutorial001.py" +``` + +* `TokenVerifier` 是一个只有一个异步方法的协议。`verify_token` 接收 `Authorization` 头里的原始 token,有效时返回一个 **`AccessToken`**,无效时返回 `None`。除此之外没有别的要实现。 +* 这个例子是在一张表里查找 token。真实的实现会验证 JWT 签名,或者调用授权服务器的 token 自省端点。那部分代码是你的,SDK 只负责调用它。 +* `token_verifier=` 和 `auth=` 永远成对出现。只传其中一个,`MCPServer(...)` 会在处理任何请求之前就抛出 `ValueError`。 + +`AuthSettings` 是你的资源服务器对外的门面: + +* `issuer_url`:签发你的 token 的授权服务器。 +* `resource_server_url`:这个 MCP 端点的公开 URL。它指明 token 是针对**哪一个**资源的,发现文档也位于这里。 +* `required_scopes`:每个 token 都必须携带其中全部 scope。 + +!!! tip "提示" + SDK 仓库中的 `examples/servers/simple-auth/` 有一个 `IntrospectionTokenVerifier`,它会调用真实授权服务器的 [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662) 端点。大多数生产环境的验证器都是这个样子。 + +## 通过 HTTP 能得到什么 {#what-you-get-over-http} + +授权信息存在于 HTTP 头中,所以它只存在于 HTTP 传输方式上。在你部署用的那一种上运行它:`mcp.run(transport="streamable-http")` 会把它放在 `http://127.0.0.1:8000/mcp`,其余内容详见 **[运行你的服务器](index.md)**。现在这个应用有两个路由: + +```text +/mcp +/.well-known/oauth-protected-resource/mcp +``` + +你注册了一个工具。第二个路由是 SDK 的。 + +### 发现 {#discovery} + +对那个 well-known 路径发 `GET` 请求,会得到 **[RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) Protected Resource Metadata**,直接由你的 `AuthSettings` 构建而来: + +```json +{ + "resource": "http://127.0.0.1:8000/mcp", + "authorization_servers": ["https://auth.example.com/"], + "scopes_supported": ["notes:read"], + "bearer_methods_supported": ["header"] +} +``` + +一个从没听说过你服务器的客户端就是靠这份文档找到入口的:它读取 `authorization_servers`,然后去那里获取 token。这些一行都不是你写的。 + +!!! check "检查" + 不带 token(或者带一个你的验证器返回了 `None` 的 token)调用 `/mcp`,请求会被挡在门外: + + ```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"} + ``` + + 什么都没有被解析,也没有工具运行。而 `WWW-Authenticate` 里那个 `resource_metadata` 指针,正是让发现过程自动完成的关键:401 -> 元数据文档 -> 授权服务器 -> token -> 重试。 + +!!! warning "警告" + 这些都保护不了 `stdio`。管道没有 `Authorization` 头,所以在那里永远不会询问 `token_verifier`。`stdio` 服务器的安全边界是启动它的那个进程。测试中使用的内存内 `Client(mcp)` 也一样:它直接连接到服务器对象,跳过了 HTTP 层,授权也包括在内。 + +## 调用者的身份 {#the-callers-identity} + +在任何处理函数内部,**`get_access_token()`** 就是你的验证器为当前请求返回的那个 `AccessToken`: + +```python title="server.py" hl_lines="4 32-35" +--8<-- "docs_src/authorization/tutorial002.py" +``` + +* 它在工具、资源和提示词中都能用,而且不需要传递任何东西:认证中间件按请求把它存在一个上下文变量里。 +* 你拿回的是**你的验证器构建的同一个对象**:`client_id`、`scopes`、`subject`、`expires_at`,以及你附加的任何额外 `claims`。这就是按工具制定规则的切入点:读取 scope,然后拒绝。 +* 在经过认证的 HTTP 请求之外,它返回 `None`。在内存内和通过 `stdio` 时,它永远是 `None`。 + +带上 `Authorization: Bearer alice-token` 调用 `whoami`,模型会读到: + +```text +alice (scopes: notes:read) +``` + +## SDK 不做的那一半 {#the-half-the-sdk-doesnt-do} + +SDK 给你的是资源服务器这一半:验证、公布、拒绝。它不提供登录页、同意授权页,也不提供 token。 + +想看三方如何协作,可以运行 SDK 仓库里的 `examples/servers/simple-auth/`(一个小型授权服务器,加上一个配置与本页完全相同的资源服务器),再把 `examples/clients/simple-auth-client/` 指向它,走一遍完整的发现与获取 token 的流程。 + +!!! info "信息" + 还有第二个构造函数参数 `auth_server_provider=`,它会在你的 MCP 服务器内部嵌入一个完整的授权服务器。它早于 MCP 授权规范所围绕的 AS/RS 分离。新的服务器不应该去用它。 + +授权服务器也可以接受企业身份提供方签名的断言,代替用户点击同意授权页,SDK 对这个交换的两端都提供支持。这种授权方式以及出示它的客户端,见 **[身份断言](../client/identity-assertion.md)**。 + +## 回顾 {#recap} + +* 通过 Streamable HTTP 运行时,你的服务器是 OAuth 2.1 **资源服务器**:它验证 token,从不签发 token。 +* `TokenVerifier` 是全部的集成接口:一个异步方法,传入 token,返回 `AccessToken | None`。 +* `token_verifier=` 和 `auth=AuthSettings(issuer_url=..., resource_server_url=..., required_scopes=[...])` 永远成对出现。 +* SDK 在 `/.well-known/oauth-protected-resource/...` 发布 [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) Protected Resource Metadata,并对未认证的请求回应 401,其 `WWW-Authenticate` 头指向该文档。整个发现过程就是这样。 +* 在任何处理函数里,`get_access_token()` 就是调用者是谁。 +* 授权是 HTTP 层面的事。`stdio` 和内存内客户端永远看不到它。 + +客户端那一半(发现你的授权服务器并替你获取 token)见 **[OAuth 客户端](../client/oauth-clients.md)**。而一个**断言**身份、而不是向用户索要身份的客户端,见 **[身份断言](../client/identity-assertion.md)**。 diff --git a/i18n/zh/pages/run/deploy.md b/i18n/zh/pages/run/deploy.md new file mode 100644 index 0000000000..fedd4a5d15 --- /dev/null +++ b/i18n/zh/pages/run/deploy.md @@ -0,0 +1,163 @@ +--- +translation: + sections: [28221886b198784f, f88ea1f1614f3a1d, ce926d686730b6d0, 3be24f8ad8bb5ab9, 3fad24032b2224ff, f25a7f860e579ecb, e758745df6fb7b0a] + tool: 1 +--- +# 部署与扩展 {#deploy-scale} + +你的服务器已经能跑了。现在它需要一个真实的主机名,后面还要挂不止一个 worker。 + +这些事几乎都不归 MCP 管。ASGI 服务器、进程管理器、负载均衡器都由你自己带。这一页只讲确实归 MCP 管的那几件事:一个卡住所有部署的设置,以及"不止一个 worker"会改变 SDK 行为的两个地方。 + +## 首先:Host 白名单 {#before-anything-else-the-host-allowlist} + +`streamable_http_app()` 无从知道自己会被放在哪个主机名后面,所以它假设最安全的答案:localhost。没有传 `transport_security=` 时,应用会开启 **DNS 重绑定防护**,只接受 `Host` 头为 `127.0.0.1:`、`localhost:` 或 `[::1]:` 的请求。如果有 `Origin` 头,它必须是同一地址的 `http://` 形式。在你自己的机器上这正合适:它能阻止恶意网页通过一个重绑定到 `127.0.0.1` 的 DNS 名称操纵你的本地服务器。 + +部署到真实主机名后面,同样的默认值会拒绝**所有请求**,直到你另有说明。这项检查在任何 MCP 逻辑之前运行,所以你写的东西根本不会被调用: + +```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=`。把你实际对外服务的地址加入白名单: + +```python title="server.py" hl_lines="2 13-17" +--8<-- "docs_src/deploy/tutorial001.py" +``` + +* `allowed_hosts` 的条目是精确字符串:`"mcp.example.com"` 匹配不带端口的 `Host` 头,`"mcp.example.com:*"` 匹配任意端口。两个都要列上。 +* `allowed_origins` 只对浏览器有意义,因为别的客户端不发 `Origin`。它是 **[添加到现有应用](asgi.md)** 中 CORS 配置在服务器端的对应项。 +* 如果前面有一个已经控制 `Host` 头的反向代理,直接关掉这项检查才是诚实的配置:`TransportSecuritySettings(enable_dns_rebinding_protection=False)`。 +* 传一个非 localhost 的 `host=`(例如 `host="mcp.example.com"`)并**不会**把该主机名加入白名单。它只是让 localhost 默认值不再触发防护,结果是所有 Host 和 Origin 都被接受。想表达什么,就用 `transport_security=` 明确说出来。 + +!!! check + 删掉 `transport_security=security` 参数,照样部署这个应用。它能启动,`/mcp` 能路由,而每一个请求(包括一个普通的 `curl`)都会返回: + + ```text + HTTP/1.1 421 Misdirected Request + + Invalid Host header + ``` + + 在客户端那边你找不到这几个字。`421` 是纯文本的 HTTP 响应,不是 JSON-RPC 错误,所以 MCP 客户端抛出的是一个泛泛的传输错误;它不认可的那个主机名只出现在**服务器**的日志里,是一条警告。一个刚部署好、拒绝所有连接的服务器,在证明是别的原因之前,就是 Host 白名单的问题。**[故障排查](../troubleshooting.md)** 也从这里讲起。 + +## Worker,以及谁需要粘性 {#workers-and-who-has-to-be-sticky} + +主机名能响应之后,就在后面放不止一个 worker。SDK 没有这方面的开关;扩展一个 Starlette 应用和扩展任何 ASGI 应用一样,把对象交给一个会 fork 的东西: + +```console +uvicorn server:app --workers 4 +``` + +四个进程,一个套接字。接下来是每个部署都必须回答的问题:**一个请求是否必须到达处理了上一个请求的那个 worker?** + +对使用 **2026-07-28** 协议的客户端来说,不需要。现代请求是一个自包含的 POST:前面没有 `initialize` 握手,响应上没有 `Mcp-Session-Id`,第二个请求没有任何东西需要"回到"。路由到任意 worker 即可。 + +这不是一个需要打开的模式。`stateless_http=True` 看起来像是,但传输层按 `MCP-Protocol-Version` 请求头路由,把现代请求交给现代处理函数,然后就**返回**了。读取 `stateless_http` 的那一行在这个返回**之后**。不是这个标志在 2026-07-28 路径上被忽略,而是根本走不到它。`stateless_http` 只是**旧版**那一支的开关,现代路径从构造上就是无会话的。 + +对使用规范版本 2025-11-25 或更早的旧版客户端,答案取决于这个标志: + +| 客户端的协议版本 | 会话 | 负载均衡器必须做什么 | +| --- | --- | --- | +| **2026-07-28** | 无。`Mcp-Session-Id` 从不设置。 | 什么都不用。任意 worker 处理任意请求。 | +| **2025-11-25 及更早**(默认) | `Mcp-Session-Id`,保存在某一个 worker 的内存里。 | **粘性会话。** 后续请求到达另一个 worker 会得到 `404` "Session not found"。 | +| **2025-11-25 及更早**,加上 `stateless_http=True` | 无。 | 什么都不用。代价是服务器到客户端的反向通道(back-channel)(采样(sampling)、推送式征询(elicitation)、`roots/list`)和可恢复性。 | + +粘性会话以及旧版那一支的代价单独有一页:**[服务旧版客户端](legacy-clients.md)**;两个时代本身见 **[协议版本](../protocol-versions.md)**。这里重要的是答案的形状:**在 2026-07-28 上你已经是无状态的,没有什么需要配置。** + +这一页剩下的部分,是无状态**并不能**帮你解决的两件事。 + +## 跨 worker 的 `requestState` {#requeststate-across-workers} + +**[多轮往返(multi-round-trip)](../handlers/multi-round-trip.md)** 工具需要客户端去取某样东西(一次确认、一个选择、一份凭据),所以它返回一个问题而不是答案,在重试时完成。两轮之间,客户端持有服务器铸造的一个不透明的 `request_state` 令牌。重试时服务器必须重新打开这个令牌。 + +**用什么密钥封存的?** 默认是服务器在构造时用 `os.urandom(32)` 生成的那一个。在 `--workers 4` 下就是四次构造、四个进程:四把不同的密钥,没写到任何地方,互不共享,重启即失。 + +下面是一个先问后做的工具,所在的服务器什么都没配置: + +```python title="server.py" hl_lines="14 20" +--8<-- "docs_src/deploy/tutorial002.py" +``` + +第一轮到达 worker A。worker A 用**它的**密钥封存 `refund:120` 并返回令牌。客户端把问题摆到人面前,得到一个"是",然后重试。重试是一个全新的 HTTP 请求。 + +!!! check + 让这次重试到达 worker B。B 尝试解封一个不是它铸造的令牌,做不到,于是拒绝整轮请求。`refund` 从未被调用;客户端得到一个 JSON-RPC 错误: + + ```json + { + "code": -32602, + "message": "Invalid or expired requestState", + "data": {"reason": "invalid_request_state"} + } + ``` + + 这条消息是**固定的**。过期、被篡改、针对不同参数重放,或者(真实部署中最常见的原因)由兄弟 worker 封存:客户端每次收到的都一样,线路上从不透露是哪项检查失败了。真正的原因是服务器日志里的一条 `WARNING`: + + ```text + requestState rejected on tools/call: unknown key + ``` + + 一个在单 worker 下正常、到两个 worker 时开始**时而**失败的多轮往返工具,就是这个问题。两轮仍然必须到达同一个进程,所以它失败的频率恰好等于负载均衡器把它们分开的频率。 + +两轮是两个独立的 HTTP 请求,好几种平常的情况都会把它们分开:按请求均衡的代理、中途断开的连接、一次部署或重启、一个持久化了 `request_state` 并从完全不同的进程恢复的客户端(**[自己驱动循环](../handlers/multi-round-trip.md#driving-the-loop-yourself)**)。这些都算"另一个 worker"。 + +解决办法是一个参数。它有**两**半。 + +```python title="server.py" hl_lines="1 12 14" +--8<-- "docs_src/deploy/tutorial003.py" +``` + +* **`keys=[...]`** 是大家都能找到的那一半。给每个实例同一个密钥(至少 32 字节),每个实例就能解封任何兄弟实例铸造的令牌。`keys[0]` 封存,列表中的每一把都能解封,这就是轮换环;**[轮换密钥](../handlers/multi-round-trip.md#rotating-keys)** 讲的是如何不停机地转动它。 +* **服务器的名字**是几乎没人找得到的那一半,也是共享了密钥之后跨实例重试仍然失败的原因。每个封存的令牌都把服务器的 `name` 作为 **audience 声明**带上,解封时严格校验。从同一份代码构建的两个实例名字相同,永远不会察觉这一点。把它们命名区分开(`MCPServer(f"billing-{POD}")` 看上去像是良好的可观测性习惯),每一次跨实例重试就会和上面一模一样地被拒绝,不管有没有共享密钥。日志里写的是 `audience` 而不是 `unknown key`;客户端分辨不出区别。 + +密钥铸造一次,把同一个值交给每个实例。这就是 SDK 自己的错误消息在你传入不足 32 字节时让你运行的命令: + +```console +python -c "import secrets; print(secrets.token_hex(32))" +``` + +!!! warning "相同的密钥,**以及**相同的名字" + 多实例部署两者都必须共享。如果每实例的名字对你来说不可或缺,那就给整个集群一个显式的 audience:`RequestStateSecurity(keys=[...], audience="billing")`。这样每个实例无论叫什么,都在 `"billing"` 下铸造和接受令牌。 + +关于封存的其余一切见 **[保护 `requestState`](../handlers/multi-round-trip.md#protecting-requeststate)**:它绑定什么、每轮的 `ttl`(默认 600 秒)、自带编解码器、为什么未配置的默认值在 `stdio` 上正合适。这一页的全部贡献是一张两项的清单:**相同的密钥,相同的名字。** + +!!! info + 即使你从没写过 `InputRequiredResult`,你也在这条路径上。参数里用了 `Resolve(...)`(**[依赖](../handlers/dependencies.md)**)的工具就是多轮往返工具,SDK 替它铸造并封存 `request_state`。同样的默认密钥,同样的跨 worker 失败,同样的修复办法。 + +## 跨副本的变更通知 {#change-notifications-across-replicas} + +客户端的 `subscriptions/listen` 流是一个长时间存活的响应,所以它整个生命期都钉在一个副本上。在**另一个**副本上发布的 `ctx.notify_resource_updated(...)` 必须能到达它。 + +两者之间的接缝是 `SubscriptionBus`。你给服务器的总线就是所有发布进入、所有打开的流监听的那一个,所以把同一个总线交给每个副本: + +```python title="server.py" hl_lines="2 7 9" +--8<-- "docs_src/deploy/tutorial004.py" +``` + +扇出的过程完全不关心一个流挂在哪个服务器对象上。持有同一个 `InMemorySubscriptionBus` 的两个服务器已经是这样:在其中一个上打开监听流,在另一个上 `edit_note`,流就能收到。这个内存总线只能跨同一进程内的服务器对象,所以它是模型,不是部署方案: + +* 跨真正的进程时,**SDK 没有提供任何能帮上忙的总线。** `SubscriptionBus` 是一个两方法的 `Protocol`(`publish` 和 `subscribe`),你在自己的 pub/sub 后端(Redis、NATS,或任何你已经在跑的东西)上实现它,并作为 `MCPServer(subscriptions=...)` 传入。**[订阅](../handlers/subscriptions.md#scaling-past-one-process)** 有示意代码和契约。 +* 总线承载的是四种小的有类型事件,从来不是 JSON-RPC。确认、过滤和流的生命周期都留在 SDK 里,所以你的总线不可能破坏协议;它只能在进程之间搬运事件。 +* 流**不可**恢复,事件**不会**重放。丢失一个副本就丢掉它的流;客户端重新监听、重新获取。没有需要共享的事件存储,也没有别的需要配置。这是横向扩展真正只是"多来几份"的唯一一处。 + +## SDK 不提供什么 {#what-the-sdk-does-not-give-you} + +`MCPServer` 是一个协议实现,不是应用服务器。你接下来会去找的那些部署开关是故意缺席的: + +* **没有 `workers=`。** `mcp.run("streamable-http")` 启动恰好一个 uvicorn 进程,也永远只会启动一个。多进程就是把 `streamable_http_app()` 交给你本来部署 ASGI 用的东西:`uvicorn --workers`、gunicorn、你平台的进程管理器。这一页刻意不做它们任何一个的教程;它们自己的文档比这里照抄一份要好。 +* **没有健康检查路由。** `@mcp.custom_route("/health", methods=["GET"])` 就是全部答案,而且即使服务器其余部分有认证,它也从不认证。这对存活探针是对的,对任何私密内容是错的。**[添加到现有应用](asgi.md#custom-routes)** 有一个示例。 +* **没有生产设置对象。** `MCPServer` 上没有地方写超时、TLS、优雅关闭或连接数限制,因为这些都不是它的职责。它们属于你的 ASGI 服务器,在那里配置。**[运行你的服务器](index.md)** 讲了构造函数**确实**接受的那几个设置。 +* **没有自带的 `EventStore`,在 2026-07-28 上也用不着。** 可恢复性是旧版有状态那一支的特性;现代交换是一个 POST、一个响应,没有什么可恢复的。 + +## 回顾 {#recap} + +* 默认情况下,这个应用只响应发往 localhost 的请求。`transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])` 是上线的关卡:在你传入它之前,真实主机名后面的每个请求都是 `421`,原因只在服务器日志里。 +* 在 2026-07-28 上没有会话,负载均衡器没有什么可粘的。`stateless_http=True` 是只对旧版有效的开关,因为现代请求在读到这个标志之前就已经被路由并响应了。 +* 默认的 `requestState` 密钥是 `os.urandom(32)`,按进程铸造。到达另一个 worker 的多轮往返重试会以 `-32602` “Invalid or expired requestState” 失败。 +* 修复办法是 `RequestStateSecurity(keys=[...])` **并且**每个实例使用相同的服务器名字。名字是令牌默认的 audience 声明。相同的密钥,相同的名字。 +* 变更通知通过一个共享的 `SubscriptionBus` 跨副本传递。SDK 唯一的实现是进程内的;在你自己的 pub/sub 上实现那个两方法的 `Protocol` 要由你来写。 +* 没有 `workers=`,没有健康路由,没有生产设置对象。自带 ASGI 服务器。 + +真实主机名前面还需要的另一样东西是令牌:**[授权](authorization.md)**。 diff --git a/i18n/zh/pages/run/index.md b/i18n/zh/pages/run/index.md new file mode 100644 index 0000000000..5ccfe0791f --- /dev/null +++ b/i18n/zh/pages/run/index.md @@ -0,0 +1,149 @@ +--- +translation: + sections: [fea8d769ff9edeba, ce8e2ad42f29ef71, 0d705efb19cf99c2, 7a53ead3e704a7f0, 9adc400e8c88e854, 318893ad8e2e9924, 6b63ab96b34476c0] + tool: 1 +--- +# 运行服务器 {#running-your-server} + +`mcp.run()` 启动服务器。 + +唯一需要做的决定是**传输方式**:服务器和客户端之间的字节究竟如何流动。 + +## 选择传输方式 {#pick-a-transport} + +| 传输方式 | 是什么 | 何时使用 | +|---|---|---| +| `stdio` | 宿主把你的文件作为子进程启动,通过它的 stdin 和 stdout 通信。 | 本地服务器。默认值。 | +| `streamable-http` | 真正的 HTTP 服务器,监听一个端口。 | 任何要部署的东西。 | +| `sse` | 较旧的 HTTP 传输方式。 | 不要用。 | + +!!! warning + SSE 在 2025-03-26 协议修订版中已被 Streamable HTTP 取代。`mcp.run(transport="sse")` 仍然可用,也有自己的 `sse_path=` 和 `message_path=` 选项,但它只是为还没迁移的客户端留着的。不要在它之上构建任何新东西。 + +## `mcp.run()` {#mcprun} + +```python title="server.py" hl_lines="12-13" +--8<-- "docs_src/run/tutorial001.py" +``` + +* `run()` 是同步的。服务器存活多久,它就阻塞多久。 +* 不带参数时,传输方式是 `stdio`。 +* 它放在 `if __name__ == "__main__":` 之下,因为所有加载服务器的东西(`mcp dev`、`mcp run`、`mcp install`、你的测试)都会**导入**这个文件。这个保护条件防止一次导入变成一个运行中的服务器。 + +### stdio {#stdio} + +没有什么需要配置的。宿主把你的文件作为子进程启动,把请求写入它的 stdin,再从它的 stdout 读取响应。 + +自己运行一下,就能看出这意味着什么: + +```console +python server.py +``` + +什么也不打印,也不返回。它在 stdin 上等着宿主先开口。 + +这也意味着 stdout **就是线路**。服务期间,SDK 把线路移到一个私有描述符上,并把**刷新**到 stdout 的输出(子进程写入它继承的 stdout、刷新过的 `print()`)转到 stderr,在那里不会破坏数据流。在开始服务**之前**就刷新到 stdout 的输出(包装脚本的 echo、导入时的无缓冲 print)仍然会落到线路上;一直缓冲到解释器退出时才排空的 `print()` 也一样。对于真正想要的输出,`logging` 模块才是正确的工具:它的 handler 会在每条记录产生时就把它刷新到 stderr。详见 **[日志记录](../handlers/logging.md)**。 + +### 试一试 {#try-it} + +```console +uv run mcp dev server.py +``` + +Inspector 做的事和真实宿主完全一样:它把 `server.py` 作为子进程启动,通过 stdio 连接它。 + +你从没给过它端口。根本就没有端口。 + +## Streamable HTTP {#streamable-http} + +要把同一个服务器放到端口上,在 `run()` 里指明传输方式(及其选项): + +```python title="server.py" hl_lines="13" +--8<-- "docs_src/run/tutorial002.py" +``` + +这一行会构建一个 Starlette 应用并用 uvicorn 提供服务。客户端连接到 `http://127.0.0.1:3001/mcp`。 + +每种传输方式都有自己的关键字参数,全都在 `run()` 上: + +* `host` / `port`:监听的位置。默认 `127.0.0.1` 和 `8000`。 +* `streamable_http_path`:MCP 端点所在的路径。默认 `/mcp`。 +* `json_response=True`:用单个 JSON 正文回应每个 POST,而不是 SSE 流。这个正文只容得下响应本身,别的什么都放不下,所以在请求中途回调客户端的工具(`ctx.elicit()`、采样(sampling))会在这一段抛出 `NoBackChannelError`;与进行中的调用绑定的通知(`ctx.report_progress()` 的进度、每次调用的日志消息)会被丢弃;独立的 `GET` 流仍然承载与之无关的通知。 +* `stateless_http=True`:每个请求一个全新的传输,不跟踪会话。 +* `max_request_body_size`:接受的最大 POST 正文大小,单位为字节。默认 4 MiB;更大的请求在解析或创建会话之前就会收到 HTTP 413。只有当合法的 MCP 消息确实超过这个大小时才调高它。 +* `event_store`、`retry_interval`、`transport_security`:可恢复性和 DNS 重绑定防护。它们可以先放一放,等部署到 localhost 以外的地方再说;**[部署与扩展](deploy.md)** 介绍了 `transport_security`。 + +!!! warning + 传输选项传给 `run()`,**不是** `MCPServer(...)`。构造函数描述服务器**是什么**:名称、版本、说明。`run()` 描述它如何对外提供服务。搞反了,Python 在 MCP 介入之前就会报错: + + ```text + TypeError: MCPServer.__init__() got an unexpected keyword argument 'port' + ``` + +`run()` 是捷径。一旦需要更多(把服务器挂载到现有应用里、一个进程里跑两个服务器、为浏览器客户端配置 CORS),就要自己构建 ASGI 应用,再交给任意 ASGI 服务器运行。这就是 **[添加到现有应用](asgi.md)** 的内容。 + +## 服务器设置 {#server-settings} + +运行方面有几件事与传输方式无关。它们是构造函数参数: + +```python title="server.py" hl_lines="3" +--8<-- "docs_src/run/tutorial003.py" +``` + +* `log_level`:在构造 `MCPServer(...)` 的那一刻传给 `logging.basicConfig()`。它配置的是**根** logger,所以也会设置你自己的 logger 的级别,而不只是 SDK 的。默认 `"INFO"`。 +* `debug`:转发给 HTTP 传输构建的 Starlette 应用。默认 `False`。 + +两者都落在 `mcp.settings` 上,可以在运行时读回。 + +## `mcp` 命令 {#the-mcp-command} + +`[cli]` 附加依赖会安装一个把这些都包起来的小型命令行工具。 + +`mcp dev` 在 **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` 往它构建的环境里添加包;`--with-editable` 把你自己的包安装进去。它需要 `PATH` 上有 `npx`:Inspector 是一个 Node.js 应用。 + +`mcp run` 导入文件,找到服务器对象(模块级的 `mcp`、`server` 或 `app`),然后对它调用 `run()`: + +```console +uv run mcp run server.py +uv run mcp run server.py:bookshop +``` + +当对象不叫 `mcp`、`server` 或 `app` 时,用 `:` 后缀指明它的名字。 + +在这里,`if __name__ == "__main__":` 块永远不会执行:`mcp run` 自己调用 `run()`,它唯一转发的选项是 `--transport`。 + +`mcp install` 把服务器注册到 **Claude Desktop**,让这个应用替你启动它: + +```console +uv run mcp install server.py --name "Bookshop" +uv run mcp install server.py -v API_KEY=abc123 -f .env +``` + +`-v KEY=VALUE` 和 `-f .env` 把环境变量记录在该条目里。Claude Desktop 在它自己的进程里启动你的服务器,你 shell 里的环境变量那里没有。 + +Claude Desktop 是 `mcp install` 唯一认识的宿主。其他宿主(Claude Code、Cursor、VS Code)都在各自的配置文件里接受同样的启动命令,**[连接到真实的宿主](../get-started/real-host.md)** 逐一介绍了它们。 + +`mcp version` 打印已安装的 SDK 版本。 + +!!! tip + `mcp dev` 和 `mcp run` 只认 `MCPServer`。如果用底层的 `Server` 构建,就要自己运行它。见 **[底层 Server](../advanced/low-level-server.md)**。 + +## 回顾 {#recap} + +* **传输方式**是字节到达服务器的方式:本地子进程用 `stdio`,端口用 `streamable-http`。SSE 已被取代。 +* `mcp.run()` 选择传输方式。不带参数时是 `stdio`,并且会阻塞。 +* 每个传输选项(`host`、`port`、`streamable_http_path`……)都是 `run()` 的参数,绝不是 `MCPServer(...)` 的。 +* 把 `run()` 放在 `if __name__ == "__main__":` 之下。所有加载服务器的东西都会先导入这个文件。 +* `log_level=` 和 `debug=` 是构造函数参数;它们落在 `mcp.settings` 上。 +* `mcp dev` 用于 Inspector,`mcp run` 执行文件,`mcp install` 用于 Claude Desktop,`mcp version` 查看版本。 +* 传输方式永远不会改变服务器**是什么**:本页的三个文件暴露的是完全相同的工具。 + +当 `run()` 本身成了限制(服务器要放进一个已经存在的应用里),看 **[添加到现有应用](asgi.md)**。需要真正的主机名和不止一个 worker,看 **[部署与扩展](deploy.md)**。如果有些客户端还停留在 2025-11-25 或更早的规范版本,**[为旧版客户端提供服务](legacy-clients.md)** 有好消息。 diff --git a/i18n/zh/pages/run/legacy-clients.md b/i18n/zh/pages/run/legacy-clients.md new file mode 100644 index 0000000000..2fe246dcb5 --- /dev/null +++ b/i18n/zh/pages/run/legacy-clients.md @@ -0,0 +1,116 @@ +--- +translation: + sections: [3d1663c18edc824c, d4fd37009a13f03d, af9f398a5a8b679a, 470c2dd144294d69, 8e45827e6d24e8c8, 91dfd0ce98ebb03c] + tool: 1 +--- +# 服务旧版客户端 {#serving-legacy-clients} + +MCP 有两个协议时代:`initialize` 握手时代(到规范版本 `2025-11-25` 为止)和现代时代(`2026-07-28`)。**[协议版本](../protocol-versions.md)** 专门讲这一划分本身。 + +本页讲的是这一划分的服务器端,答案一句话就能说完:**你已经部署的 `streamable_http_app()` 同时服务两者。** + +SDK 按 `MCP-Protocol-Version` 头路由每个请求。声明 `2026-07-28` 的请求交给现代一侧处理。声明握手时代版本的请求,或者根本不带这个头的请求(2026 之前的客户端的 `initialize` 就是这样到达的),则走这些客户端期望的传输方式:`initialize` 握手、会话,一应俱全。这一切按请求发生,在你的代码之前,就在这一个应用上。 + +所以旧版客户端不是你要**专门为之**构建什么的对象,而是会**连接到**你已经写好的服务器的东西。什么都不用配置。 + +!!! note + 真的什么都没有。没有 `legacy=` 选项,没有版本白名单,也没有办法拒绝或禁用某个时代:`streamable_http_app()` 上没有,`run()` 上没有,会话管理器上也没有。两个时代始终开启。那个签名里最接近按时代开关的东西是 `stateless_http`,本页大部分内容都在讲它。 + +## 一个处理函数,两个时代 {#one-handler-both-eras} + +下面是一个需要向用户提问的工具,以及两个时代的客户端分别调用它: + +```python title="server.py" hl_lines="24 37-38" +--8<-- "docs_src/legacy_clients/tutorial001.py" +``` + +`reserve` 需要一样模型没有提供的东西:要几本。工具用 `Annotated[..., Resolve(ask_quantity)]` 来声明这一点(详见 **[依赖](../handlers/dependencies.md)**)。`reserve` 里没有任何地方提到版本、检查能力或做分支。 + +两个客户端**同时**打开,连的是同一个 `mcp` 对象。`mode="legacy"` 会执行 `initialize` 握手:这正是 2026 之前的客户端打开的那种连接。另一个取默认值,落在 `2026-07-28` 上。 + +```text +2025-11-25 {'result': "Reserved 2 of 'Dune'."} +2026-07-28 {'result': "Reserved 2 of 'Dune'."} +``` + +同一个服务器,同一个处理函数,同一个答案。整个功能就是这样。 + +值得停下来看看它是**怎么**做到的,因为这两个客户端是在两条完全不同的线路上被问到同一个问题的。`2026-07-28` 连接没有供服务器发送请求的通道,所以 `Resolve` 把问题放在工具结果里返回,客户端带着答案重试了这次调用(**[多轮往返(multi-round-trip)请求](../handlers/multi-round-trip.md)**)。`2025-11-25` 连接没有这种机制;在那里,`Resolve` 在调用中途发出一个实时的 `elicitation/create` 请求并等待。两种你都没写。`Resolve` 读取连接协商出的版本并做选择;无论哪种,工具函数体看到的都是一个 `AcceptedElicitation`。 + +!!! tip + 这种跨时代可移植性正是应该基于 `Resolve` 这个 API 来构建的**原因**。它的前辈 `ctx.elicit()`(**[征询(elicitation)](../handlers/elicitation.md)**)永远只发送 `elicitation/create`,所以永远只在旧版连接上有效。在 `2026-07-28` 连接上这个调用会失败。如果某个工具还在用它,修复办法就是上面看到的那样,而不是加版本检查。 + +## 旧版会话的代价 {#what-a-legacy-session-costs-you} + +路由是免费的,会话不是。 + +`2026-07-28` 连接是**无会话**的:每个请求各自独立,现代一侧从不签发 `Mcp-Session-Id`。旧版连接正好相反。2026 之前的客户端一发送 `initialize`,SDK 就会生成一个 `Mcp-Session-Id`,在响应头里返回,并在它背后保留一条活的记录,供该客户端之后的请求查找:协商出的版本、打开的流、一个驱动会话的后台任务。 + +这条记录就是一个**普通的进程内 `dict`**。没有分布式会话存储,也没办法接入一个。 + +只有一个 worker 时这一点看不出来。有两个时,它就是全部问题所在:一个带着 `Mcp-Session-Id` 的请求落到没有生成它的 worker 上,在那个 dict 里什么也找不到,得到的回答是 `404`(`Session not found`),而不是工具结果。所以一旦运行多于一个 worker,**旧版客户端就需要粘性路由**:会话里的每个请求都必须到达发起这个会话的那个进程。现代客户端从不需要;它们没有会话可粘。**[部署与扩展](deploy.md)** 讲了粘性以及运行多个实例的其他一切。 + +!!! warning + `event_store=` 看起来像是解决办法,其实不是。它是**可恢复性**(向重连到**同一个**会话的客户端重放错过的 SSE 事件),不是会话存储。它永远不会让一个会话能从另一个进程访问到。 + +## 唯一的开关:`stateless_http` {#the-one-knob-stateless_http} + +如果粘性是你不愿付的代价,那么恰好有一样东西可以改。 + +```python title="server.py" hl_lines="28" +--8<-- "docs_src/legacy_clients/tutorial002.py" +``` + +这就是页面开头的那个服务器,加上一个关键字参数。`stateless_http=True` 让旧版这一路改为每个请求建一个用完即弃的会话:不签发 `Mcp-Session-Id`,请求之间什么都不记,所以任何 worker 都能服务任何请求,负载均衡器想怎么分就怎么分。 + +关于它,有两点比它做了什么更重要。 + +**它只影响旧版这一路。**请求在读取 `stateless_http` **之前**就已经按版本头路由了,所以现代路径根本看不到它。`2026-07-28` 连接本来就是无会话的,两种取值下完全一样。 + +**它会让这一路失去两条服务器到客户端的通道。**只活一个 `POST` 的会话,没有供服务器推送请求的流,也没有供它推送通知的独立流。每个服务器发起的请求都会抛出 `NoBackChannelError`:`ctx.elicit()`、已退役的采样(sampling)和根目录(roots)调用(**[已弃用的功能](../deprecated.md)**),以及——没错——`Resolve` 向**旧版**客户端提问。通知连错误都没有;它们被悄悄丢弃。 + +!!! note + `json_response=True` 不是那个开关,但它在**每一个**旧版会话上都要付一半同样的代价:用一个 JSON 正文回答的 `POST` 没有供请求范围通道使用的流,所以请求中途的 `ctx.elicit()` 会抛出同样的 `NoBackChannelError`,与该请求绑定的通知会被丢弃。会话的独立流不受影响:无关的通知仍然能到达。 + +!!! check + 故意做错一次。`reserve` 就是刚才同时服务两个客户端的那个工具。用 `stateless_http=True` 部署它,通过 HTTP 连上同样的两个客户端,分别调用它。 + + 现代客户端仍然收到 `Reserved 2 of 'Dune'.`,现代这一路没变。 + + 旧版客户端的调用不会以模型能读到的 `is_error` 结果返回。整个请求失败了,是一个顶层协议错误: + + ```text + mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests. + ``` + + `Resolve` 没能救你。在 `2025-11-25` 连接上它**必须**发送 `elicitation/create`,而它需要的通道正是 `stateless_http=True` 放弃掉的东西。跨时代可移植的代码不等于不需要反向通道(back-channel)的代码。 + +所以这是一个实实在在的取舍,而且只存在于旧版这一路:**有会话且粘性,或者无状态且单向。**如果你的工具从不回调客户端,`stateless_http=True` 就是免费的,应该用它。如果会回调,就保留会话,保持路由粘性。 + +## 你的代码真正分叉的地方 {#where-your-code-actually-forks} + +几乎没有。 + +工具、资源、提示词、结构化输出、进度、错误:它们都不在乎是哪个时代调用的。`initialize` 握手、`Mcp-Session-Id`、独立流、结束会话的 `DELETE`:全归 SDK 管,处理函数一个都看不到。交互式输入是两个时代在线路上**真正**不同的地方,而 `Resolve` 的存在就是为了让它不成为你的问题:你刚刚看过一个工具同时服务两者。 + +只剩下恰好一件事,就是**变更通知**,因为两个时代在不同的管道上监听: + +* `2026-07-28` 客户端打开一个 `subscriptions/listen` 流并读取订阅总线。`ctx.notify_resource_updated()`(以及 `notify_tools_changed()`、`notify_prompts_changed()`、`notify_resources_changed()`)发布到那里,而且**只**发布到那里。详见 **[订阅](../handlers/subscriptions.md)**。 +* 旧版客户端读取它的会话保持打开的独立流。`ctx.session.send_resource_updated()`(以及 `send_tool_list_changed()` 等)写入承载这次请求的**连接**:对旧版会话来说,就是它的独立流。现代连接没有地方放它:通过 HTTP 没有这样的通道,通过 stdio 这四类变更通知只走 `subscriptions/listen` 流,所以在现代连接上这条通知会被悄悄丢弃。 + +通过 HTTP,两个调用都到不了另一个时代的客户端。要通知所有人,两个都调用: + +```python title="server.py" hl_lines="19-20" +--8<-- "docs_src/legacy_clients/tutorial003.py" +``` + +两行,没有 `if`,没有版本检查,就完事了。因为旧版客户端存在而让处理函数做法不同的事情,全部清单就这些。 + +## 回顾 {#recap} + +* 一个 `streamable_http_app()` 服务两个协议时代。SDK 按 `MCP-Protocol-Version` 头路由每个请求;没有什么要配置,也没有什么时代开关可找。 +* 旧版客户端的代价是一个会话:一条进程内的 `Mcp-Session-Id` 记录,背后没有分布式存储。多于一个 worker 就意味着**粘性路由**,否则错的 worker 会回答 `404 Session not found`。多 worker 的情况详见 **[部署与扩展](deploy.md)**。 +* `stateless_http=True` 是唯一的开关,而且**只作用于旧版这一路**。它为旧版客户端换来自由的负载均衡,代价是这一路上两条服务器到客户端的通道:服务器发起的请求抛出 `NoBackChannelError`(在客户端是顶层错误,不是 `is_error` 结果),通知被丢弃。 +* `2026-07-28` 连接无论如何都是无会话的。`stateless_http` 永远碰不到它。 +* 处理函数代码只在恰好一个地方按时代分叉:变更通知。`ctx.notify_*` 送达 `subscriptions/listen` 客户端;`ctx.session.send_*` 送达旧版会话。两个都调用。 +* 其他一切(包括通过 `Resolve` 向用户要输入)在构造上就是跨时代可移植的。把现代的写法写一次就够了。 diff --git a/i18n/zh/pages/run/opentelemetry.md b/i18n/zh/pages/run/opentelemetry.md new file mode 100644 index 0000000000..79634bfc88 --- /dev/null +++ b/i18n/zh/pages/run/opentelemetry.md @@ -0,0 +1,85 @@ +--- +translation: + sections: [bc0227014724fa49, 15738c2f7fd67d86, a2c17bbe3f707e2f, d0d853376f162c06, b6368643fcc1c8d8, 902e33e17564a607] + tool: 1 +--- +# OpenTelemetry {#opentelemetry} + +你的服务器已经自带追踪,什么都不用加。 + +你创建的每个服务器都会为它处理的每条消息发出一个 [OpenTelemetry](https://opentelemetry.io/) span。这不是你写的,也不需要你导入。调用 `MCPServer(...)` 的那一刻,它就在了。 + +```python title="server.py" +--8<-- "docs_src/opentelemetry/tutorial001.py" +``` + +这就是一个完整的、带追踪的服务器。调用 `search_books`,就会为它创建一个 span。低层的 `Server` 也一样:追踪在两者上都有。 + +## 你能得到什么 {#what-you-get} + +每条入站消息都会变成一个 `SERVER` span,名字由方法及其目标组成。所以针对 `search_books` 的 `tools/call` 对应的 span 是 `tools/call search_books`,而单独的 `tools/list` 就是 `tools/list`。 + +每个 span 带有几个属性: + +* `mcp.method.name` 和 `mcp.protocol.version`,每个 span 上都有。 +* `jsonrpc.request.id`,请求上才有(通知没有)。 +* 处理函数抛出异常会把 span 状态设为 error。`is_error=True` 的工具结果也一样。 + +由于追踪工具调用是非常常见的需求,`tools/call` span 遵循 OpenTelemetry 的 [GenAI 语义约定](https://opentelemetry.io/docs/specs/semconv/gen-ai/): + +* `gen_ai.operation.name`,设为 `"execute_tool"`。 +* `gen_ai.tool.name`,设为被调用的工具。 + +`prompts/get` span 同理带有 `gen_ai.prompt.name`。list 类方法不带 `gen_ai.*` 键,因为没有东西可命名。 + +!!! tip + 正是这些 GenAI 属性,让追踪 UI 能像对待其他任何 agent 一样对你的工具调用分组。这种分组是白送的,不需要额外代码。 + +## 想用之前零成本 {#it-costs-nothing-until-you-want-it} + +这一点让“默认开启”成为一个让人放心的默认值。 + +SDK 只依赖 `opentelemetry-api`,也就是 OpenTelemetry 轻量的那一半。没有安装 SDK 和 exporter 时,创建 span 是空操作。所以你的服务器此刻发出的 span 几乎没有任何开销,也没有人在收集它们。 + +等到哪天想**看到**它们,就装上另一半,并把它指向某个地方: + +```console +uv add opentelemetry-sdk opentelemetry-exporter-otlp +``` + +按 OpenTelemetry 的常规方式配置一个 exporter,SDK 一直在默默创建的每个 span 就都亮起来了。服务器代码不用改,一行都不用。 + +!!! info + [Pydantic Logfire](https://logfire.pydantic.dev/) 就是这样一个后端,而且它替你把配置做了:`pip install logfire`、`logfire.configure()`,你的 MCP span 就会出现在实时视图里。它构建在 OpenTelemetry 之上,所以下面的内容对它同样适用。 + +## 跨越线路的 trace {#traces-that-cross-the-wire} + +trace 最有用的时候,是它能在一幅连贯的图景里跟随请求从客户端一路进入服务器。 + +当客户端和服务器都运行本 SDK 时,这种关联是自动的。客户端把 [W3C trace context](https://www.w3.org/TR/trace-context/) 注入请求,服务器再把它读出来,于是服务器 span 嵌套在同一个 trace 的客户端 span 之下。这就是 [SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414),不用开口就能得到。 + +如果入站消息没有携带 trace context,比如请求来自一个不是本 SDK 的客户端,服务器 span 就直接以服务器上当前已有的 span 为父,而不是另起一个全新的孤立 trace。 + +## 关掉它 {#turning-it-off} + +追踪是一个中间件,排在服务器中间件列表的第一个。如果确实想要一个不发出任何 span 的服务器,把它拿掉: + +```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 + 这个导入带前导下划线,这是故意的。这个类是临时性的,和 [`Server.middleware`](../advanced/middleware.md) 一样是临时性的,所以要预期导入路径会变。你几乎永远用不到这个:没装 exporter 时 span 不花钱,所以通常的做法是让它们开着,不装 exporter 就行。 + +## 回顾 {#recap} + +* 每个 `MCPServer` 和每个低层 `Server` 默认都会为每条入站消息发出一个 `SERVER` span。你什么都不用写。 +* span 带有 `mcp.method.name` 和 `mcp.protocol.version`;`tools/call` 和 `prompts/get` 还带有 GenAI 属性,让你的工具调用像其他任何 agent 的一样分组。 +* 在安装 OpenTelemetry SDK 和 exporter 之前零成本,装上之后就会亮起来,服务器一行都不用改。 +* 两端都运行本 SDK 时,客户端到服务器的 trace context 自动传播。 + +决定一个请求到底能不能运行的,是 **[授权](authorization.md)**。 diff --git a/i18n/zh/pages/servers/completions.md b/i18n/zh/pages/servers/completions.md new file mode 100644 index 0000000000..270d0c3b8c --- /dev/null +++ b/i18n/zh/pages/servers/completions.md @@ -0,0 +1,122 @@ +--- +translation: + sections: [72f9c964769076dd, 9a2c14e10935b515, 235299eb78ab12d7, 8aee1e78c8237fb8, 9bd86acd4112138f, 55343cb7f250dc7b] + tool: 1 +--- +# 补全 {#completions} + +在你的服务器之上构建 UI 的客户端,会想在用户输入时自动补全参数值:语言名称、仓库名称、文件路径。 + +**补全**(completion)就是服务器提供这些建议的方式。 + +## 值得补全的东西 {#something-worth-completing} + +补全只适用于两样东西:**提示词**的参数和**资源模板**的参数。所以先写一个两者各有一个的服务器: + +```python title="server.py" hl_lines="6 12" +--8<-- "docs_src/completions/tutorial001.py" +``` + +这里还没有任何与补全相关的内容。 + +* `review_code` 接受一个 `language`。用户不该靠猜来知道你接受哪些写法。 +* `github_repo` 接受 `owner` 和 `repo`。两个都用自由文本框,这个表单会很难用。 + +## 补全处理函数 {#the-completion-handler} + +添加**一个**用 `@mcp.completion()` 装饰的函数: + +```python title="server.py" hl_lines="21-29" +--8<-- "docs_src/completions/tutorial002.py" +``` + +* 每个服务器只有一个处理函数。所有补全请求都会落到这里,由你根据正在补全的对象分支处理。 +* 它必须是 `async def`:SDK 会 await 它。 +* 它接收三个参数: + * `ref`:是**哪一个**提示词或资源模板,类型为 `PromptReference` 或 `ResourceTemplateReference`。用 `isinstance` 区分两者。 + * `argument`:`argument.name` 是正在补全的参数,`argument.value` 是用户目前已输入的内容。 + * `context`:已经确定的参数。暂时忽略它。 +* 返回一个 `Completion(values=[...])`;没有可提供的建议时返回 `None`。 + +!!! tip + `argument.value` 是用户已输入的前缀。SDK **不会**替你过滤:放进 `values` 的是什么,UI 显示的就是什么。`startswith` 得你自己写。 + +### 试一试 {#try-it} + +用 **[测试](../get-started/testing.md)** 中的内存 `Client` 来驱动它。调用 `client.complete()`,传入 `ref=PromptReference(name="review_code")` 和 `argument={"name": "language", "value": "py"}`: + +```python +result.completion.values # ['python'] +``` + +* `ref` 与处理函数收到的引用类型相同。 +* `argument` 是一个普通的 dict,只有 `name` 和 `value` 两个键。 + +发送空的 `value`,会拿回整个列表。`lang.startswith("")` 对每种语言都为真: + +```python +result.completion.values # ['go', 'javascript', 'python', 'rust', 'typescript'] +``` + +询问 `code`(一个处理函数不认识的参数),它会返回 `None`,SDK 会把它变成空列表: + +```python +result.completion.values # [] +``` + +`None` 表示“没有建议”,绝不是错误。UI 会退回到普通的文本框。 + +## 一项你从未声明过的能力 {#a-capability-you-never-declared} + +注册处理函数本身就是声明。连接一个客户端看看: + +```python +client.server_capabilities.completions # CompletionsCapability() +``` + +你没有在任何地方列出 `completions`。SDK 看到处理函数,就替你声明了这项能力。每一项**可选**能力都是这样:处理函数就是声明。(三种原语不是可选的:无论有没有处理函数,`MCPServer` 总会声明它们。) + +!!! check + 回到第一个 `server.py`(没有处理函数的那个),照样向它发请求。调用会失败,并返回一个 JSON-RPC 错误: + + ```text + Method not found + ``` + + 而且 `client.server_capabilities.completions` 是 `None`。这正是能力的意义所在:行为规范的客户端会先检查它,绝不会发出你无法响应的请求。 + +## 有依赖关系的参数 {#dependent-arguments} + +`github://repos/{owner}/{repo}` 有两个参数,而 `repo` 的有用取值取决于先选了哪个 `owner`。 + +这就是 `context` 的用处。它携带用户**已经确定**的参数: + +```python title="server.py" hl_lines="8-11 34-38" +--8<-- "docs_src/completions/tutorial003.py" +``` + +* 新分支针对模板的 `repo` 参数触发。 +* `context.arguments` 是 `dict[str, str] | None`,保存目前已选定的值(这里是 `owner`)。 +* 还没有 `owner`,就没有合理的建议可给,所以处理函数返回 `None`。 + +客户端通过 `context_arguments=` 发送这些已确定的值。这次 `ref` 是 `ResourceTemplateReference(uri="github://repos/{owner}/{repo}")`。用空的 `value` 请求补全 `repo`,并传入 `context_arguments={"owner": "modelcontextprotocol"}`: + +```python +result.completion.values # ['python-sdk', 'typescript-sdk', 'inspector'] +``` + +去掉 `context_arguments=`,同样的调用会返回 `[]`。不知道 owner,处理函数就无从知道该提供哪些仓库。 + +!!! info + `Completion` 还接受 `total=` 和 `has_more=`。当 `values` 只是更长列表中的一段时设置它们,这样 UI 就能显示“另有 200 项”。大多数处理函数用不到它们。 + +## 回顾 {#recap} + +* 补全是针对**提示词参数**和**资源模板参数**的建议。仅此而已。 +* `@mcp.completion()` 注册这唯一的处理函数。它的形式是 `async def (ref, argument, context) -> Completion | None`。 +* 根据 `isinstance(ref, ...)` 和 `argument.name` 分支。按 `argument.value` 过滤要自己写。 +* `None` 会变成空列表。它绝不是错误。 +* `context.arguments` 保存已确定的值;客户端通过 `context_arguments=` 提供它们。 +* 一注册处理函数,`completions` 能力就会出现。没有它,请求的结果就是 `Method not found`。 + +建议在用户还在**填写**提示词或模板时有用;想在工具调用**中途**向用户提问,需要的是 **[征询(elicitation)](../handlers/elicitation.md)**。工具除了文本还能返回什么,见 **[图像、音频和图标](media.md)**。 diff --git a/i18n/zh/pages/servers/handling-errors.md b/i18n/zh/pages/servers/handling-errors.md new file mode 100644 index 0000000000..1cb707522f --- /dev/null +++ b/i18n/zh/pages/servers/handling-errors.md @@ -0,0 +1,131 @@ +--- +translation: + sections: [e33d441f12d50535, 7099694c603e0f5f, c1df4cf9673433e6, c9cd294541422e6e, 6cec073617bfd037, efa92b8f99e908c8, 6a22a29e27fb4601] + tool: 1 +--- +# 错误处理 {#handling-errors} + +工具失败有两种方式,SDK 对它们的处理截然不同。 + +抛出普通异常,看到它的是**模型**。抛出 `MCPError`,看到它的是**协议**。 + +这一页讲的就是怎么选。 + +## 模型能纠正的错误 {#an-error-the-model-can-fix} + +拿一个查东西的工具来说,让它查不到: + +```python title="server.py" hl_lines="11-12" +--8<-- "docs_src/handling_errors/tutorial001.py" +``` + +这两行没有任何 MCP 特有的东西。`get_author` 抛出一个普通的 `ValueError`,和任何 Python 函数一样。 + +用一个书目里没有的书名去调用它,看看结果: + +```python +result.is_error # True +result.content # [TextContent(text="Error executing tool get_author: No book titled 'Nothing' in the catalog.")] +result.structured_content # None +``` + +* 请求**成功了**。有一个结果;调用方这边什么也没抛出。 +* `is_error` 为 `True`,你的异常消息(前面加了工具名)就在 `content` 里,正是模型读取的位置。 +* `structured_content` 为 `None`。失败的调用没有返回值可供结构化。 + +这就是**工具错误**,也是工具抛出的**任何**异常的默认归宿。而且它几乎总是你想要的效果。 + +调用工具的是模型,参数也是它挑的。所以工具错误就是对话里的一个回合:模型读到“No book titled 'Nothing' in the catalog.”,发现自己猜错了书名,就换个更好的再调一次。你只写了一个 `raise`,就得到了一个会自我纠正的智能体。 + +!!! tip + 永远不要从工具里 `return` 错误消息。返回的字符串带的是 `is_error=False`,所以在模型(以及每个客户端 UI)看来,工具运行正常,那个字符串就是答案。要 `raise`。这个标志才是信号。 + +## 模型纠正不了的错误 {#an-error-the-model-cannot-fix} + +现在把 `ValueError` 换成 `MCPError`。 + +```python title="server.py" hl_lines="1 3 14" +--8<-- "docs_src/handling_errors/tutorial002.py" +``` + +`MCPError` 是 SDK 的**协议错误**。它是工具包装层唯一**不**捕获的异常:它会向上传播,整个 `tools/call` 请求以一个 JSON-RPC 错误失败,而不是返回结果。 + +```json +{ + "code": -32602, + "message": "No book titled 'Nothing' in the catalog." +} +``` + +* **没有结果**。没有 `content`,没有 `is_error`:模型没有任何东西可读。 +* 收到这个错误的是**宿主**应用,和工具根本不存在时的情形一样。 +* `code`、`message` 和 `data` 原封不动地送达。`INVALID_PARAMS` 就是 `-32602`;`mcp.types` 把它和其他 JSON-RPC 错误码(`INVALID_REQUEST`、`INTERNAL_ERROR`……)作为常量导出,这样你永远不用手写魔法数字。 + +!!! check + 同样的查找,同样没查到,但这次调用在客户端一侧**抛出了异常**,而不是返回: + + ```text + mcp.shared.exceptions.MCPError: No book titled 'Nothing' in the catalog. + ``` + + 第一个版本递给模型一句它能据此应对的话。这个版本什么也没给。对 `get_author` 来说这只会更糟,而这正是下一节要讲的重点。 + +## 该抛哪一个 {#which-one-to-raise} + +两条路径回答的是两个不同的问题。 + +* **抛出任意异常**,对应**执行**层面的失败:工具想做的事没做成。调用是模型选的,所以后果也该让模型看到,给它补救的机会。拼错的书名、超时的上游 API、不存在的数据行:全是工具错误。 +* **抛出 `MCPError`**,对应**请求本身**就该被拒绝的情况:客户端缺少工具所依赖的某项能力,服务器当前的状态没法为任何人服务,调用方跳过了某个必需步骤。这些问题模型怎么重试都修不好,所以把消息交给它没有任何好处。 + +一个问题就能定夺:**换个更聪明的模型,能避免这个问题吗?** 能 -> 普通异常。不能 -> `MCPError`。 + +按这个标准,第二版 `get_author` 选错了:换个更好的书名就能解决,所以模型理应看到那条消息。它放在这里是为了让你看清机制,而不是推荐这种写法。 + +!!! info + `MCPError` 通过 `from mcp import MCPError` 导入,接受 `code`、`message` 和可选的 `data` 载荷。你往里放什么,客户端就收到什么:SDK 会把抛出的 `MCPError` 原样转发,不做任何清理。 + +## 不存在的资源 {#a-resource-that-doesnt-exist} + +资源也划出同样的界线,并为常见情况自带了一个具名异常。 + +```python title="server.py" hl_lines="2 13" +--8<-- "docs_src/handling_errors/tutorial003.py" +``` + +`books://{title}` 是一个**模板**。它能匹配**任何**书名,所以“URI 格式正确”和“这本书存在”是两个不同的问题,而第二个只有你的函数能回答。 + +答案为否时,抛出 `ResourceNotFoundError`。SDK 会把它转成规范为缺失资源指定的那个协议错误:`-32602`,请求的 URI 放在 `data` 里,让客户端知道失败的是**哪一次**读取。 + +```json +{ + "code": -32602, + "message": "No book titled 'Nothing' in the catalog.", + "data": {"uri": "books://Nothing"} +} +``` + +注意这里没有 `is_error=True` 式的“半个结果”。资源读取要么返回内容,要么失败:资源只有协议这一条路径。模板以及资源的其他方方面面,详见 **[资源](resources.md)**。 + +## 你永远不用抛的错误 {#errors-you-never-raise} + +不合法的参数根本到不了你的函数。 + +给 `get_author` 传一个不是字符串的 `title`,SDK 会在调用你**之前**就对照输入模式把它拒掉,得到的同样是模型能读懂并改正的那种 `is_error=True` 工具错误。**[工具](tools.md)** 用一个 `Field(le=50)` 约束演示了同样的拒绝。 + +这意味着有一整类 `raise` 语句不用你写:不要重复校验自己的类型注解。 + +!!! info + 这一页上的一切都是**客户端**看到的样子,而你写测试时用的内存中的 `Client` 看到的也一模一样。就连 `raise_exceptions=True` 也不会把工具错误变回 traceback:等那个标志能起作用的时候,你的异常早已是 `is_error=True` 的结果了。对结果做断言。这个模式详见 **[测试](../get-started/testing.md)**。 + +## 回顾 {#recap} + +* 在工具里抛出**任意异常** -> 调用返回 `is_error=True`,你的消息在 `content` 里。模型读到后可以重试。这是默认行为。 +* 抛出 **`MCPError`** -> 调用本身以 JSON-RPC 错误失败。模型什么也看不到;由宿主处理。`code`、`message` 和 `data` 原封不动地保留。 +* 决定性的问题:“换个更聪明的模型,能避免这个问题吗?”能 -> 异常。不能 -> `MCPError`。 +* 资源处理函数抛出 `ResourceNotFoundError` -> 协议的 `-32602`,URI 在 `data` 里。 +* 不合法的参数在你的函数运行之前就会对照模式被拒掉;这些不用你 `raise`。 +* `from mcp import MCPError`;错误码常量来自 `mcp.types`。 + +错误处理完毕。服务器**对外暴露**的内容就是这些。每个处理函数在运行期间能读到什么、又能反过来对客户端做什么,是下一部分的内容:**[在处理函数内部](../handlers/index.md)**。 + +你最有可能碰到的那些 SDK 错误的原文、各自的含义,以及每个错误一步到位的修复方法,详见 **[故障排查](../troubleshooting.md)**。 diff --git a/i18n/zh/pages/servers/index.md b/i18n/zh/pages/servers/index.md new file mode 100644 index 0000000000..ef997ed704 --- /dev/null +++ b/i18n/zh/pages/servers/index.md @@ -0,0 +1,22 @@ +--- +translation: + sections: [09defc170a0da89d] + tool: 1 +--- +# 服务器 {#servers} + +`MCPServer` 向已连接的客户端暴露三种原语。三者的区别在于由谁决定使用它们: + +* **[工具](tools.md)** 是由 **模型** 挑选并调用的动作。大多数人最先想看的就是这一页,而 **[结构化输出](structured-output.md)** 是与之配套的参考页:关于工具返回值结构的一切。 +* **[资源](resources.md)** 是由 **应用** 选择读取的只读数据。**[URI 模板](uri-templates.md)** 是与之配套的参考页:完整的寻址语法和路径安全规则。 +* **[提示词](prompts.md)** 是由 **人** 通过菜单或斜杠命令按名称调用的消息模板。 + +除了这三种原语,服务器声明的其余内容如下: + +* **[补全](completions.md)** 是针对提示词和资源模板参数的服务器端自动补全。 +* **[图像、音频与图标](media.md)** 涵盖工具除文本之外能返回的一切,以及客户端显示在你的服务器旁边的图标。 +* **[处理错误](handling-errors.md)** 解释两类错误的区别:一类模型能够从中恢复,另一类绝不能让模型看到。 + +这里的每一页都自成一体,直接跳到需要的那一页即可。如果还没构建过服务器,先从 **[第一步](../get-started/first-steps.md)** 开始。 + +你注册的函数 **内部** 会发生什么(`Context`、依赖注入、调用中途向用户索要更多输入),是下一节 **[在处理函数内部](../handlers/index.md)** 的内容。 diff --git a/i18n/zh/pages/servers/media.md b/i18n/zh/pages/servers/media.md new file mode 100644 index 0000000000..9291710b07 --- /dev/null +++ b/i18n/zh/pages/servers/media.md @@ -0,0 +1,117 @@ +--- +translation: + sections: [496394d24d221bf1, 4ceb4591180dc6c3, 0fd63e4682d02e0c, 969ede0bd3686a16, 043f526230dd243d, 6ee3e9bcfd24047a] + tool: 1 +--- +# 媒体 {#media} + +工具能返回的不只是文本。 + +SDK 自带两个用于二进制结果的辅助类型(**`Image`** 和 **`Audio`**),以及一个 **`Icon`** 类型,用来让服务器、工具、资源和提示词在客户端 UI 中有自己的图标。 + +## 返回图片 {#returning-an-image} + +把返回类型标注为 `Image`,让它指向一个文件,然后返回: + +```python title="server.py" hl_lines="8 12 14" +--8<-- "docs_src/media/tutorial001.py" +``` + +* `Image` 接受 `path`(要读取的文件)或 `data`(原始字节),二者只能取其一。 +* 客户端看到的 MIME 类型根据后缀推断:`logo.png` 会被声明为 `image/png`。 +* logo 在这里并不特殊。`server.py` 旁边的任何 PNG 都可以:代码渲染出的图表、示意图、照片都行。 + +`Image` 是 SDK 提供的便利类型,不是协议类型。在线路上,返回值会变成一个 **`ImageContent`** 块(文件字节经 base64 编码,再加上 MIME 类型): + +```python +result.content # [ImageContent(type="image", data="iVBORw0KGgoAAAANSUhEUg...", mime_type="image/png")] +result.structured_content # None +``` + +有两点值得注意: + +* `data` 是 base64。你完全没碰过字节;文件是 SDK 读的,编码也是 SDK 做的。 +* `structured_content` 是 `None`。`Image` 是给模型看的内容,不是给应用解析的数据:没有输出模式。(对比 **[结构化输出](structured-output.md)**,那里的返回标注**就是**模式。) + +!!! info + `ImageContent` 和 `AudioContent` 位于 `mcp.types` 中,紧挨着普通 `str` 结果所变成的那个 `TextContent`(**[工具](tools.md)**)。工具结果是一个内容块列表;`Image` 和 `Audio` 是产出这两种二进制内容块的最简方式。 + +### 试一试 {#try-it} + +把任意一张 PNG 放到 `server.py` 旁边,命名为 `logo.png`,然后运行: + +```console +uv run mcp dev server.py +``` + +打开 **Tools** 标签页,调用 `logo`。结果不是字符串:它是一个 `image` 内容块,Inspector 会把图片渲染出来。从磁盘上的文件到屏幕上的像素,中间的一切都是 SDK 做的。 + +## 返回音频 {#returning-audio} + +`Audio` 的用法完全一样。`logo.png` 留在原处,再在旁边放任意一个 WAV 文件,命名为 `chime.wav`: + +```python title="server.py" hl_lines="18-21" +--8<-- "docs_src/media/tutorial002.py" +``` + +结果是一个 **`AudioContent`** 块: + +```python +result.content # [AudioContent(type="audio", data="UklGR...", mime_type="audio/wav")] +result.structured_content # None +``` + +一样的道理:进去的是磁盘上的文件,出来的是 base64 和 MIME 类型,没有输出模式。 + +## 字节还是文件 {#bytes-or-a-file} + +两个辅助类型也都接受 `data=`(原始字节)来代替 `path=`。这种方式适用于本来就不是来自某个文件的字节——数据库的一列、一个 HTTP 响应、Pillow 刚画出来的东西: + +```python title="server.py" hl_lines="14 15" +--8<-- "docs_src/media/tutorial003.py" +``` + +用 `path=` 时什么都不用声明:文件在构建结果时读取,MIME 类型根据后缀推断: + +* `Image`:`.png`、`.jpg`、`.jpeg`、`.gif`、`.webp`。 +* `Audio`:`.wav`、`.mp3`、`.ogg`、`.flac`、`.aac`、`.m4a`。 + +识别不了的后缀会回退到 `application/octet-stream`。 + +!!! check + 用 `data=` 时没有文件名,也就无从推断。漏掉 `format=`,SDK 就会回退到默认值:图片是 `image/png`,音频是 `audio/wav`。照这样用 MP3 字节构建一个 `Audio`,客户端会被告知 `mime_type="audio/wav"`,然后老老实实地解码失败。传 `data=` 时,就要一并传 `format=`。 + +## 图标 {#icons} + +`Icon` 是元数据,不是内容。它不携带图片本身,而是用一个 URI 指向图片;客户端可以获取它,并显示在服务器名称、某个工具、资源或提示词旁边。 + +```python title="server.py" hl_lines="4-5 7 10 16" +--8<-- "docs_src/media/tutorial004.py" +``` + +* `src` 是客户端能解析的 URI:`https:`,或者如果想把图标内嵌、免去一次额外获取,就用 `data:` URI。 +* `mime_type` 和 `sizes`(`"48x48"`,可缩放格式用 `"any"`)让客户端在你提供多个图标时挑出合适的那个。 +* `theme="light"` 或 `theme="dark"` 把图标标记为适用于某一种配色方案。 + +`MCPServer(...)`、`@mcp.tool()`、`@mcp.resource()` 和 `@mcp.prompt()` 都接受同一个 `icons=[...]` 关键字参数。 + +### 客户端在哪里看到它们 {#where-a-client-sees-them} + +图标跟着它们所装饰的对象一起传递。服务器的图标在客户端连接时送达,挂在 `client.server_info` 上(该字段在 2026 版连接上是可选的,所以先收窄类型): + +```python +assert client.server_info is not None # python-sdk servers identify themselves by default +client.server_info.icons # [Icon(src="https://example.com/brand-kit.png", mime_type="image/png", sizes=["48x48"])] +``` + +工具的图标在 `tools/list` 返回的 `Tool` 对象上,资源的在 `resources/list` 返回的 `Resource` 上,提示词的在 `prompts/list` 返回的 `Prompt` 上。字段一律叫 `icons`。 + +## 回顾 {#recap} + +* 从工具返回 `Image` 或 `Audio`,客户端就会收到一个 `ImageContent` / `AudioContent` 块:字节经 base64 编码,附带 MIME 类型。 +* 可以用 `path=` 构建,让后缀决定 MIME 类型;也可以用内存中的 `data=` 加上显式的 `format=` 构建。 +* 媒体结果不带 `structured_content`,也没有输出模式。 +* `Icon` 是一个指针:一个 `src` URI,加上可选的 `mime_type`、`sizes` 和 `theme`。 +* `icons=[...]` 可用于服务器、工具、资源和提示词,客户端在对应的对象上就能找到它们。 + +这就是工具能放**进**结果里的全部内容。工具**失败**时会发生什么(以及该让谁知道),见 **[处理错误](handling-errors.md)**。 diff --git a/i18n/zh/pages/servers/prompts.md b/i18n/zh/pages/servers/prompts.md new file mode 100644 index 0000000000..f1cd70677e --- /dev/null +++ b/i18n/zh/pages/servers/prompts.md @@ -0,0 +1,151 @@ +--- +translation: + sections: [d65c098f37f5b6c3, dd0c2724d6f2877e, 6835bb3570c6714c, ffe823cb0fedd488, f33651add1b59094] + tool: 1 +--- +# 提示词 {#prompts} + +**提示词**是由用户挑选的消息模板。 + +工具是给模型用的。提示词正好相反:用户在客户端的菜单里(比如斜杠命令或按钮)选一个,填好参数,渲染出来的消息就进入对话,就像是用户自己打出来的一样。 + +在一个返回文本的函数上加 `@mcp.prompt()`,就声明了一个提示词。 + +## 第一个提示词 {#your-first-prompt} + +```python title="server.py" hl_lines="6-9" +--8<-- "docs_src/prompts/tutorial001.py" +``` + +SDK 从中读取的三样东西和工具一样: + +* **名称**就是函数名:`review_code`。 +* 客户端显示的**描述**是 docstring:`Review a piece of code.` +* **参数**来自函数的形参。`code` 没有默认值,所以是必填的。 + +客户端从 `prompts/list` 拿到的就是这些: + +```json +{ + "name": "review_code", + "description": "Review a piece of code.", + "arguments": [ + {"name": "code", "required": true} + ] +} +``` + +这里没有 JSON Schema。提示词的参数是一个扁平的**具名字符串值**列表:是给人填的表单,而不是由模型构造的载荷。 + +### 渲染 {#rendering-it} + +客户端用 `prompts/get` 渲染模板,并传入参数。你的函数运行后,返回的 `str` 会变成**一条用户消息**: + +```json +{ + "description": "Review a piece of code.", + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "Please review this code:\n\ndef add(a, b): return a + b" + } + } + ], + "resultType": "complete" +} +``` + +提示词的完整流程就是这样:按名称列出,按需渲染,放进对话。 + +!!! check + `required` 的检查发生在你的函数运行之前。渲染 `review_code` 时不传 `code`,请求本身就会失败,并返回一个 JSON-RPC 错误(错误码 `-32603`): + + ```text + mcp.shared.exceptions.MCPError: Internal server error + ``` + + 这里没有工具那种可以交回给模型的错误结果,因为整个环节里根本没有模型:调用会直接抛出异常。原因(`Missing required arguments: {'code'}`)会记在服务器的日志里。 + +### 试一试 {#try-it} + +用 MCP Inspector 运行服务器: + +```console +uv run mcp dev server.py +``` + +打开 **Prompts** 标签页,选择 `review_code`。Inspector 会画出一个表单,带一个必填的 `code` 字段。填好、渲染,返回的正是上面那条用户消息。 + +## 不止一条消息 {#more-than-one-message} + +代码审查只要一条消息。调试则是一段对话,而提示词可以把整段对话的开头都铺好。 + +把返回值从 `str` 换成消息列表: + +```python title="server.py" hl_lines="2 13-20" +--8<-- "docs_src/prompts/tutorial002.py" +``` + +* `UserMessage` 和 `AssistantMessage` 来自 `mcp.server.mcpserver.prompts.base`。给它们一个 `str`,它们会替你包装成 `TextContent`。角色由类名决定。 +* `Message` 是它们的公共基类。用它作返回值注解。 + +现在渲染 `debug_error` 会按顺序产生三条消息: + +```json +{ + "description": "Start a debugging conversation.", + "messages": [ + {"role": "user", "content": {"type": "text", "text": "I'm seeing this error:"}}, + {"role": "user", "content": {"type": "text", "text": "TypeError: 'int' object is not iterable"}}, + { + "role": "assistant", + "content": {"type": "text", "text": "I'll help debug that. What have you tried so far?"} + } + ], + "resultType": "complete" +} +``` + +注意最后一条。预先填入一轮 `assistant` 发言,就能引导模型的**下一条**回复,而不用让用户自己把引导的话敲出来。 + +## 标题和参数描述 {#titles-and-argument-descriptions} + +`review_code` 是函数名,不是标签。给客户端一个更适合放在按钮上的名字,并给每个参数加上描述,让表单一目了然: + +```python title="server.py" hl_lines="10-13" +--8<-- "docs_src/prompts/tutorial003.py" +``` + +* `title="Code review"` 是给人看的名称,和工具的 `title` 一模一样。 +* `Annotated[str, Field(description=...)]` 和 **[工具](tools.md)** 用来描述工具参数的是同一种写法。这里描述直接落在参数上,而不是写进模式里。 +* `language` 有默认值,所以不再是必填参数。 + +现在 `prompts/list` 里的这一项包含了客户端画好一个表单所需的全部信息: + +```json +{ + "name": "review_code", + "title": "Code review", + "description": "Review a piece of code.", + "arguments": [ + {"name": "code", "description": "The code to review.", "required": true}, + {"name": "language", "description": "The language the code is written in.", "required": false} + ] +} +``` + +!!! info + 如果读过 **[工具](tools.md)**,这一页的内容你其实都已经会了。装饰器一样,用 docstring 作描述一样,`Annotated`/`Field` 也一样。变的只有两点:由谁触发(用户),以及结果去哪儿(进入对话)。 + +## 回顾 {#recap} + +* 在函数上加 `@mcp.prompt()`,它就成了提示词。名称取自函数名,描述取自 docstring。 +* 提示词由**用户控制**:客户端列出它们,用户选一个并填好参数。 +* 参数是一个扁平的具名字符串列表(没有模式)。有默认值的形参是可选的。 +* 返回 `str`,它就变成一条用户消息。返回 `UserMessage` / `AssistantMessage` 的列表,可以为多轮对话铺好开头。 +* `title=` 和 `Field(description=...)` 是客户端放进 UI 里的内容。 +* 缺少必填参数会让整个请求失败。没有针对单个提示词的错误结果。 + +要在服务器端为提示词(或资源模板)的参数提供自动补全,见 **[补全](completions.md)**。 diff --git a/i18n/zh/pages/servers/resources.md b/i18n/zh/pages/servers/resources.md new file mode 100644 index 0000000000..a89bd3bd74 --- /dev/null +++ b/i18n/zh/pages/servers/resources.md @@ -0,0 +1,138 @@ +--- +translation: + sections: [09df998c2a799f78, 0cf131146d16d4f9, 4e6b91e3f8025346, 8fe4eef576db17ed, 0d0d1ed43e3d0a53] + tool: 1 +--- +# 资源 {#resources} + +**资源**是你暴露出来、供应用程序读取的数据。 + +区别就在这里。工具是由**模型**决定调用的东西。资源是由**应用程序**决定加载的东西(一个配置文件、一条记录、一份文档),加载后作为上下文放到模型面前。 + +在一个普通的 Python 函数上加 `@mcp.resource(uri)`,就声明了一个资源。 + +## 第一个资源 {#your-first-resource} + +```python title="server.py" hl_lines="6-8" +--8<-- "docs_src/resources/tutorial001.py" +``` + +形式和工具一样,只多了一样东西:**URI**。资源靠地址定位,而不是靠名称。客户端请求的是 `config://app`,从来不是 `get_config`。 + +其余信息,SDK 照样从函数里读取: + +* **名称**是函数名:`get_config`。 +* 客户端看到的**描述**是 docstring。 +* **内容**就是你返回的东西。 + +`resources/list` 期间,客户端拿到的是: + +```json +{ + "name": "get_config", + "uri": "config://app", + "description": "The active shop configuration.", + "mimeType": "text/plain" +} +``` + +当它读取 `config://app` 时,你的函数运行,返回值以文本形式返回: + +```python +result.contents # [TextResourceContents(uri="config://app", mime_type="text/plain", text="theme=dark\nlanguage=en")] +``` + +!!! tip + 列出资源的开销很小。`resources/list` 期间**不会**调用你的函数,只在 `resources/read` 期间调用,而且只针对被请求的那个 URI。哪怕暴露一千个资源,也只为有人打开的那些付出开销。 + +### 试一试 {#try-it} + +用 MCP Inspector 运行服务器: + +```console +uv run mcp dev server.py +``` + +打开它打印出来的 URL,进入 **Resources** 标签页。`config://app` 连同它的描述就在列表里。点一下,Inspector 就会读取它:你的两行配置就出来了。 + +## 资源模板 {#resource-templates} + +每条记录一个 URI,这种做法扩展不了。在 URI 里放一个**占位符**,再给函数加一个对应的参数: + +```python title="server.py" hl_lines="12-13" +--8<-- "docs_src/resources/tutorial002.py" +``` + +URI 里写 `{user_id}`,函数上写 `user_id: str`。整个约定就这些。 + +它现在是一个**资源模板**,位置也变了:它离开 `resources/list`,改为出现在 `resources/templates/list` 里,不再是一个地址,而是一个模式: + +```json +{ + "name": "get_user_profile", + "uriTemplate": "users://{user_id}/profile", + "description": "A customer's profile.", + "mimeType": "text/plain" +} +``` + +客户端填上占位符,读取一个具体的 URI:`users://42/profile`、`users://ada/profile`。所有这些都由同一个函数应答,匹配到的值作为 `user_id` 传入: + +```python +result.contents # [TextResourceContents(uri="users://42/profile", text="User 42: 12 orders since 2021.")] +``` + +注意结果里的 `uri`。它是客户端请求的那个**具体** URI,不是模板。 + +!!! check + 占位符和参数必须对得上。把函数参数改名为 `user`,而 URI 里仍写着 `{user_id}`,装饰器就会在**导入时**拒绝,那时还没有任何客户端接触到它: + + ```text + ValueError: Mismatch between URI parameters {'user_id'} and function parameters {'user'} + ``` + + 不匹配只可能是 bug,所以 SDK 让带着这种不匹配的服务器根本无法启动。 + +占位符语法是 [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570):`{+path}` 表示跨多个分段的值,`{?q,lang}` 表示可选的查询参数,等等。SDK 默认还会对提取出的值做路径安全检查。完整参考见 **[URI 模板与路径安全](uri-templates.md)**。 + +`get_user_profile` 还可以接收一个注解为 `Context` 的参数。SDK 会把它注入进来,而绝不会把它当成 URI 参数;它能给你什么,见 **[Context](../handlers/context.md)** 页面。 + +## 返回什么 {#what-you-return} + +不只限于 `str`。给每个资源一个 `mime_type`,返回合适的内容即可: + +```python title="server.py" hl_lines="8-9 14-15 20-21" +--8<-- "docs_src/resources/tutorial003.py" +``` + +* `readme` 返回 `str`,因此原样发送。这是最常见的情况。 +* `catalog_stats` 返回 `dict`,因此 SDK 替你把它序列化成 **JSON 文本**: + + ```json + { + "books": 1204, + "authors": 391 + } + ``` + +* `placeholder_cover` 返回 `bytes`,因此客户端拿到的是 `BlobResourceContents` 而不是 `TextResourceContents`,你的字节经 base64 编码后放在它的 `blob` 字段里。 + +同样的规则适用于其他任何可 JSON 序列化的东西:列表、Pydantic 模型、dataclass。只要既不是 `str` 也不是 `bytes`,就变成 JSON。 + +`mime_type` 由你来声明,默认是 `text/plain`。SDK 从不检查你返回的内容去猜它,所以一个没标注的 `dict` 资源仍然会以纯文本的类型对外宣告。 + +!!! tip + 不想从函数推导时,`@mcp.resource()` 也接受 `name=`、`title=` 和 `description=`。而当根本没有函数可写时,`mcp.server.mcpserver.resources` 里有现成的 `Resource` 类(`TextResource`、`BinaryResource`、`FileResource`、`HttpResource`、`DirectoryResource`),用 `mcp.add_resource(...)` 注册即可。 + +客户端还可以**订阅**一个资源,在它变化时收到通知;那是客户端那一半的事,详见 **[客户端](../client/index.md)**。 + +## 回顾 {#recap} + +* 在函数上加 `@mcp.resource(uri)`,它就成了资源。URI 是地址,返回值是内容,docstring 是描述。 +* URI 里有 `{placeholder}`,它就成了**模板**:列在 `resources/templates/list` 下,一个函数服务所有匹配的 URI。 +* 占位符名必须和函数的参数名一致。写错了,导入时就会发现,而不是等到生产环境。 +* 你的函数在资源被**读取**时运行,而不是在列出时。 +* `str` 变成文本,`bytes` 变成 base64 blob,其他一切变成 JSON 文本。用 `mime_type=` 给它标注类型。 +* 工具供模型采取行动。资源供应用程序读取。 + +第三种原语,也就是由人从菜单里挑选的那一种,是 **[提示词](prompts.md)**。 diff --git a/i18n/zh/pages/servers/structured-output.md b/i18n/zh/pages/servers/structured-output.md new file mode 100644 index 0000000000..ea2e3840ec --- /dev/null +++ b/i18n/zh/pages/servers/structured-output.md @@ -0,0 +1,242 @@ +--- +translation: + sections: [a838d57f003aed44, 857d03886a0137ed, 42d9efcb9f542867, 2290ff08435b5573, e866c192e11d1c14, 6cdbad079f7b47f0, d4b607372fb28b51, 18dbf726ac45e0b7, c6f7d2a148aa49f4, c851964bb3301907, d715db6f8dccc9cc, ef86634aa70498a7] + tool: 1 +--- +# 结构化输出 {#structured-output} + +返回普通 `str` 的工具会把结果产出两次:一次是 `content` 里的文本,一次是 `structured_content` 里的 `{"result": "..."}`。 + +本页讲的就是这第二个通道:它从哪里来、可能有哪些形态,以及 SDK 如何保证它货真价实。 + +一句话概括:**返回类型注解就是输出模式(output schema)**。你其实已经写好了。 + +## 输出模式 {#the-output-schema} + +```python title="server.py" hl_lines="9" +--8<-- "docs_src/structured_output/tutorial001.py" +``` + +重要的是签名那一行:`-> int`。 + +有了它,SDK 在 `tools/list` 时发出的工具除了根据参数构建的输入模式(详见 **[工具](tools.md)**),还会带上一个 `output_schema`: + +```json +{ + "properties": { + "result": {"title": "Result", "type": "integer"} + }, + "required": ["result"], + "title": "get_temperatureOutput", + "type": "object" +} +``` + +单独一个 `int` 不是 JSON 对象,所以 SDK 把它**包装**进 `{"result": ...}`。调用这个工具,两个通道都有内容: + +```python +result.content # [TextContent(text="17")] +result.structured_content # {"result": 17} +``` + +所有标量都是同样的包装:`str`、`int`、`float`、`bool`、`bytes`、`None`。 + +## 两个通道 {#two-channels} + +为什么同一个值要发两次? + +* `content` 是给**模型**看的。语言模型读的是文本;整个结果里它只看得到这一部分。 +* `structured_content` 是给模型所在的**应用程序**用的:代码想要的是 `17`,而不是一句含有“17”的话。 +* `output_schema` 是二者之间的契约,早在工具被调用之前就已发布。 + +你只返回一个 Python 值,SDK 把这三样全部填好。 + +## 返回模型 {#return-a-model} + +用 Pydantic `BaseModel` 声明形状,并返回一个实例: + +```python title="server.py" hl_lines="8-11 15" +--8<-- "docs_src/structured_output/tutorial002.py" +``` + +现在 `WeatherData` **就是**模式。没有包装,也没有 `result` 键: + +```json +{ + "properties": { + "temperature": {"description": "Degrees Celsius.", "title": "Temperature", "type": "number"}, + "humidity": {"description": "Relative humidity, 0 to 1.", "title": "Humidity", "type": "number"}, + "conditions": {"title": "Conditions", "type": "string"} + }, + "required": ["temperature", "humidity", "conditions"], + "title": "WeatherData", + "type": "object" +} +``` + +`structured_content` 就是这个对象,字段逐一对应: + +```python +result.structured_content # {"temperature": 16.2, "humidity": 0.83, "conditions": "Overcast"} +``` + +语言模型也没有被落下。SDK 把同一个对象序列化为 JSON 文本,放进 `content`: + +```json +{ + "temperature": 16.2, + "humidity": 0.83, + "conditions": "Overcast" +} +``` + +注意,`temperature` 和 `humidity` 上的 `Field(description=...)` 进入了模式。描述**输入**的那个 `Field`,同样描述了输出。 + +!!! info + 如果用过 FastAPI 的 `response_model`,这一套你已经熟悉:把 Pydantic 模型声明为响应,序列化和文档都替你做好。唯一的不同是,在这里返回注解就是全部的声明。 + +## `TypedDict` {#a-typeddict} + +不是每种形状都值得专门写一个类。`TypedDict` 产出的模式完全一样: + +```python title="server.py" hl_lines="8" +--8<-- "docs_src/structured_output/tutorial003.py" +``` + +`TypedDict` 在运行时就是普通的 `dict`,所以构建并返回的也就是它。模式、校验和 `structured_content` 都与 `BaseModel` 版本完全相同(只是少了描述,`TypedDict` 里没有地方写)。 + +## dataclass {#a-dataclass} + +dataclass 也行,任何属性带类型提示的普通类同样可以。SDK 会在幕后根据注解构建出一个 Pydantic 模型。 + +```python title="server.py" hl_lines="8-9" +--8<-- "docs_src/structured_output/tutorial004.py" +``` + +三种写法,一个模式。代码库里本来用哪种,就用哪种。 + +## 列表 {#lists} + +`list[...]` 也不是 JSON 对象,所以同样套上 `{"result": ...}` 包装,元素类型以 `$defs` 引用的形式放在里面: + +```python title="server.py" hl_lines="15" +--8<-- "docs_src/structured_output/tutorial005.py" +``` + +```json +{ + "$defs": { + "WeatherData": { + "properties": { + "temperature": {"title": "Temperature", "type": "number"}, + "humidity": {"title": "Humidity", "type": "number"}, + "conditions": {"title": "Conditions", "type": "string"} + }, + "required": ["temperature", "humidity", "conditions"], + "title": "WeatherData", + "type": "object" + } + }, + "properties": { + "result": {"items": {"$ref": "#/$defs/WeatherData"}, "title": "Result", "type": "array"} + }, + "required": ["result"], + "title": "get_forecastOutput", + "type": "object" +} +``` + +请求两天的预报,`structured_content` 就是 `{"result": [{...}, {...}]}`。`content` 则变成**两个** `TextContent` 块,每个元素一个:列表会为模型逐项展开,而不是整个转储成一个字符串。 + +`tuple[...]`、联合类型和 `Optional[...]` 的包装方式相同。 + +## 字典 {#dictionaries} + +`dict[str, ...]` 是唯一一个本身**就是** JSON 对象的泛型,所以不会被包装: + +```python title="server.py" hl_lines="9" +--8<-- "docs_src/structured_output/tutorial006.py" +``` + +```json +{ + "additionalProperties": {"type": "number"}, + "title": "get_temperaturesDictOutput", + "type": "object" +} +``` + +```python +result.structured_content # {"London": 16.2, "Reykjavik": 4.4} +``` + +键必须是 `str`。`dict[int, float]` 成不了 JSON 对象,所以会退回到 `{"result": ...}` 包装。 + +## 校验 {#validation} + +`output_schema` 并非只是文档。函数返回的任何内容,在离开服务器之前都会**对照它校验**。 + +手工构建值的时候你察觉不到:Pydantic 早已保证你的 `WeatherData` 确实是 `WeatherData`。等到哪天数据来自你控制不了的地方,你就会察觉了: + +```python title="server.py" hl_lines="9 21" +--8<-- "docs_src/structured_output/tutorial007.py" +``` + +注解承诺的是 `WeatherData`,上游响应却不再发送 `humidity` 了。 + +!!! check + 调用 `get_weather`,它不会悄悄把一个缺了一半的对象递给客户端。调用会失败,错误的头几行直接点名那个字段: + + ```text + Error executing tool get_weather: 1 validation error for WeatherData + humidity + Field required [type=missing, input_value={'temperature': 16.2, 'conditions': 'Overcast'}, input_type=dict] + ``` + + 这段文本作为工具结果返回,并带着 `is_error=True`,于是模型知道调用失败了,而不会信心十足地去读根本不存在的天气数据。 + +顺带一提,从 `-> WeatherData` 的工具里返回普通 `dict` 完全没问题。`json.loads` 产出的正是它。校验针对的是值,而不是 Python 类型。 + +## 选择退出 {#opting-out} + +有时返回注解是写给类型检查器看的,而不是给协议的。传入 `structured_output=False`,工具就变成纯文本: + +```python title="server.py" hl_lines="6" +--8<-- "docs_src/structured_output/tutorial008.py" +``` + +没有 `output_schema`,没有包装,没有校验。`structured_content` 为 `None`,`content` 就是你返回的字符串。 + +反过来,`structured_output=True` 会把自动检测变成硬性要求:返回类型产不出模式的工具会在导入时直接抛错,而不是退回到纯文本。 + +## 没有类型提示的类 {#a-class-without-type-hints} + +有一种情况,你没有要求也会落得非结构化:返回一个**类体上没有任何注解**的类。 + +```python title="server.py" hl_lines="6-9" +--8<-- "docs_src/structured_output/tutorial009.py" +``` + +`Station` 在 `__init__` 里设置了 `name` 和 `online`,但**类**本身什么都没声明。SDK 去读类注解,一个也没找到,于是放弃。 + +!!! warning + 而且是**悄无声息地**放弃。`output_schema` 是 `None`,`structured_content` 是 `None`,模型读到的文本是这个对象的 `repr`: + + ```text + "" + ``` + + 没有报错,没有警告,只剩一个没用的工具。把注解挪到类体上,或者传入 `structured_output=True`——后者会在模块导入的那一刻就让它直接报错:`Function get_station: return type is not serializable for structured output`。 + +!!! tip + 需要完全掌控(自己构建 `CallToolResult`,或者附加应用程序看得见、模型看不见的 `_meta`)?详见 **[底层 Server](../advanced/low-level-server.md)**。 + +## 回顾 {#recap} + +* **返回类型注解**就是输出模式,在 `tools/list` 中以 `output_schema` 发布。 +* 标量、列表、元组和联合类型会被包装进 `{"result": ...}`。模型、`TypedDict`、dataclass、带注解的类以及 `dict[str, ...]` 本身已是对象,保持原样。 +* 每个结果都同时带有 `content`(文本,给模型)**和** `structured_content`(数据,给应用程序)。 +* 返回的内容会对照模式校验。不匹配就是工具错误,而不是一个损坏的结果。 +* `structured_output=False` 让工具退出结构化输出。没有类型提示的类会悄无声息地退出;要当心。 + +至此,工具能回传的一切都由你掌控。接下来是第二种原语:**[资源](resources.md)**。 diff --git a/i18n/zh/pages/servers/tools.md b/i18n/zh/pages/servers/tools.md new file mode 100644 index 0000000000..2947fe4fbe --- /dev/null +++ b/i18n/zh/pages/servers/tools.md @@ -0,0 +1,170 @@ +--- +translation: + sections: [e4cc390d56573409, 8566e2b68594e9ad, 2c97b9f888398951, 048e5471dfa71aea, 3076b1e16ad95950, edbedf2a16e71311, 3d8ef8da89fa87c1, f6c0e02e6ea5a363] + tool: 1 +--- +# 工具 {#tools} + +**工具**是模型可以调用的函数。 + +在一个普通的 Python 函数上加上 `@mcp.tool()`,就声明了一个工具。整个 API 就这些。 + +## 第一个工具 {#your-first-tool} + +```python title="server.py" hl_lines="6-8" +--8<-- "docs_src/tools/tutorial001.py" +``` + +看看刚才写的代码。没有模式、没有 JSON、没有协议,只是一个函数。SDK 从中读出三样东西: + +* 工具的**名称**就是函数名:`search_books`。 +* 模型看到的**描述**就是文档字符串:`Search the catalog by title or author.` +* 模型可以传入的**参数**来自类型提示:`query: str` 和 `limit: int`。 + +### 输入模式 {#the-input-schema} + +SDK 根据这些类型提示生成一份 JSON Schema,并在 `tools/list` 时发给客户端: + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"title": "Limit", "type": "integer"} + }, + "required": ["query", "limit"], + "title": "search_booksArguments" +} +``` + +两个参数都在 `required` 里,因为都没有默认值。这一点马上就会改。(`title` 键是 Pydantic 附带生成的;属性、属性的类型和 `required` 才是契约。) + +!!! tip + 类型提示在这里不是文档,而是**契约**。如果客户端发来 `"limit": "ten"`,SDK 会在你的函数运行之前就把它拒掉。 + +### 模型会收到什么 {#what-the-model-gets-back} + +用 `{"query": "dune", "limit": 5}` 调用这个工具,结果有两部分: + +```python +result.content # [TextContent(text="Found 3 books matching 'dune' (showing up to 5).")] +result.structured_content # {'result': "Found 3 books matching 'dune' (showing up to 5)."} +``` + +`content` 是给**模型**读的文本。`structured_content` 是给**客户端应用**的带类型数据。之所以有它,是因为你把返回类型声明成了 `-> str`。 + +先不用操心 `structured_content`。从工具里返回真正的 Python 对象,结果自然是对的;**[结构化输出](structured-output.md)** 页面专门讲这件事。 + +### 试一试 {#try-it} + +用 MCP Inspector 运行服务器: + +```console +uv run mcp dev server.py +``` + +打开它打印出来的 URL,切到 **Tools** 标签页,调用 `search_books`。 + +Inspector 会渲染出一个表单,里面有一个必填的 `query` 文本字段和一个必填的 `limit` 数字字段。这个表单是它根据你的类型提示生成的。其他所有 MCP 客户端也会这样做。 + +## 可选参数 {#optional-arguments} + +给参数设一个默认值,它就不再是必填参数。就这样,只是普通的 Python。 + +```python title="server.py" hl_lines="7" +--8<-- "docs_src/tools/tutorial002.py" +``` + +模式也随之改变: + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"default": 10, "title": "Limit", "type": "integer"} + }, + "required": ["query"], + "title": "search_booksArguments" +} +``` + +`limit` 从 `required` 里移了出来,并多了 `"default": 10`。省略它的客户端会拿到 `10`,和 Python 的行为一模一样。 + +## 用 `Field` 写出更丰富的模式 {#richer-schemas-with-field} + +类型提示已经很够用了,但有时还想**描述**某个参数,或者给它加约束。 + +把类型包进 `Annotated`,再加一个 Pydantic 的 `Field`: + +```python title="server.py" hl_lines="12-14" +--8<-- "docs_src/tools/tutorial003.py" +``` + +新东西有三样,全在参数上: + +* `Field(description=...)`:单个参数的描述,模型会把它和文档字符串一起读。 +* `Field(ge=1, le=50)`:数值上下界。它们在模式里变成 `"minimum": 1, "maximum": 50`。 +* `Literal["fiction", "non-fiction", "poetry"]`:枚举。模型只能从中选一个。 + +!!! check + 约束不是摆设。用 `limit=999` 调用这个工具,SDK 会**在你的函数运行之前**就回复一个工具错误: + + ```text + Input should be less than or equal to 50 + ``` + + 这个错误会作为工具结果回到模型那里,模型读到后会换一个合法的值重试。只写了一次 `le=50`,就免费得到了会自我纠错的智能体。 + +!!! info + 如果用过 FastAPI 或 Pydantic,这些你全都已经会了。同一个 `Field`、同一个 `Annotated`、同一套校验。这里没有任何 MCP 特有的东西要学。 + +## 用模型作参数 {#a-model-as-a-parameter} + +当工具的参数不止两三个时,把它们归进一个 Pydantic 模型: + +```python title="server.py" hl_lines="8-11 15" +--8<-- "docs_src/tools/tutorial004.py" +``` + +`Book` 的模式嵌套在工具的输入模式里(以 `$defs` 引用的形式),模型把它当作一个 JSON 对象填写,而你的函数收到的是一个**真正的 `Book` 实例**,已经校验过,带有 `.title`、`.author` 和 `.year` 属性。 + +可以随意搭配:普通参数和模型参数并列、嵌套模型、模型列表。从里到外都是 Pydantic。 + +## `async def` {#async-def} + +如果工具要做 I/O(调用 API、读文件、查数据库),就把它声明为 `async def`,并在里面 `await`。SDK 会 await 它。 + +普通的 `def` 工具也可以:SDK 会在线程里运行它,所以它永远不会阻塞服务器。 + +没有别的需要配置。 + +## 名称、标题与注解 {#names-titles-and-annotations} + +SDK 推断出来的一切,都可以在装饰器里覆盖: + +```python title="server.py" hl_lines="7-10" +--8<-- "docs_src/tools/tutorial005.py" +``` + +* `title` 是给 UI 用的人类可读名称。客户端会显示“Search the catalog”,而不是 `search_books`。 +* `annotations` 是给客户端的行为**提示**: + * `read_only_hint=True`:这个工具不会改动任何东西。 + * `open_world_hint=False`:它针对的是一个封闭的集合(这份书目),而不是开放的互联网。 + * 另外两个,`destructive_hint` 和 `idempotent_hint`,描述的是会**写入**的工具:它会不会删除东西?调用两次和调用一次是不是一样?规范只为非只读工具定义了这两项,所以它们放在 `search_books` 上什么也说明不了。 + +守规矩的客户端会用它们来决定诸如“运行它之前要不要先问用户?”之类的事。它们是提示,不是安全机制。永远不要指望客户端一定会遵守。 + +!!! tip + 如果不想从函数名和文档字符串推导名称和描述,`@mcp.tool()` 也接受 `name=` 和 `description=`。大多数时候,直接推导就够了。 + +## 回顾 {#recap} + +* 在函数上加 `@mcp.tool()`,它就成了工具。名称来自函数名,描述来自文档字符串。 +* 类型提示**就是**输入模式。默认值让参数变为可选。 +* `Annotated[..., Field(...)]` 添加描述和约束;`Literal` 添加枚举。 +* 要接收结构化的“请求体”,就用 Pydantic 模型参数。 +* 错误的参数会替你拒掉,并附带一条模型能读懂、也能据此纠正的错误信息。 +* I/O 用 `async def`,其他一律用普通的 `def`。 + +**[结构化输出](structured-output.md)** 讲的是你 `return` 的值之后会怎样。 diff --git a/i18n/zh/pages/servers/uri-templates.md b/i18n/zh/pages/servers/uri-templates.md new file mode 100644 index 0000000000..858593875d --- /dev/null +++ b/i18n/zh/pages/servers/uri-templates.md @@ -0,0 +1,167 @@ +--- +translation: + sections: [4a7033e1ed8ad602, 55dcbfff0c6271bf, 101ef9d14bf4ec46, 4b6c4a845438abc7, f98b46bafbee4acd] + tool: 1 +--- +# URI 模板与路径安全 {#uri-templates-and-path-safety} + +本页是 [`@mcp.resource`](resources.md) 所接受的 URI 模板语法的参考,也涵盖 SDK 对提取出的值应用的路径安全策略。想了解资源是什么、什么时候该用,请先看 **[资源](resources.md)**;本页假设你已经熟悉如何声明资源,想要的是完整的运算符集合、安全方面的配置项,或者底层的接线方式。 + +模板语法是 [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570)。SDK 支持其中一个子集,专为匹配传入的 `resources/read` URI 而选,另外还加了一层安全检查,会拒绝那些会解析到你打算提供的目录之外的值。协议层面的细节(消息格式、生命周期、分页)见 [MCP 资源规范](https://modelcontextprotocol.io/specification/latest/server/resources)。 + +## 完整的运算符集合 {#the-full-operator-set} + +普通占位符 `{user_id}` 就是 **[资源](resources.md)** 一页介绍过的那种。除此之外还有四种运算符形式;下面把它们放在同一个服务器上,方便并排对照: + +```python title="server.py" hl_lines="16-17 22-23 28-29 34-35 40-41" +--8<-- "docs_src/uri_templates/tutorial001.py" +``` + +每个高亮的装饰器都是切分 URI 的一种不同方式。下面各节从上到下依次讲解。 + +### 简单展开:`{name}` {#simple-expansion-name} + +`books://{isbn}` 是最普通、最常用的形式。占位符映射到 `isbn` 参数,所以客户端读取 `books://978-0441172719` 时会调用 `get_book("978-0441172719")`。 + +普通的 `{name}` 在第一个 `/` 处停止。`books://978/extra` 不匹配,因为 `978` 后面的斜杠终止了捕获,`/extra` 就多出来了。 + +### 类型转换 {#type-conversion} + +提取出的值都是字符串,但可以声明更具体的类型,SDK 会负责转换。`orders://{order_id}` 对应的函数参数是 `order_id: int`,所以读取 `orders://12345` 会调用 `get_order(12345)`,而不是 `get_order("12345")`。处理函数直接对它做算术运算(`order_id + 1`),不需要强制转换。 + +### 多段路径:`{+name}` {#multi-segment-paths-name} + +要捕获包含斜杠的值,用 `{+name}`。以 `manuals://{+path}` 为例: + +* `manuals://returns.md` 得到 `path = "returns.md"` +* `manuals://printing/setup.md` 得到 `path = "printing/setup.md"` + +只要值是层级结构的,就用 `{+name}`:文件系统路径、嵌套对象的键、你正在代理的 URL 路径。 + +### 查询参数:`{?a,b,c}` {#query-parameters-abc} + +`reviews://{isbn}{?limit,sort}` 把 `limit` 和 `sort` 放在 `?` 之后。路径确定读**哪一本**书;查询参数调整**怎么**读它。 + +查询参数的匹配是宽松的:顺序无所谓,多余的会被忽略,省略的则落到函数的默认值上。所以 `reviews://978-0441172719` 使用 `limit=10, sort="newest"`,而 `reviews://978-0441172719?sort=top` 只覆盖 `sort`。 + +### 把路径段作为列表:`{/name*}` {#path-segments-as-a-list-name} + +如果想让每个路径段成为列表里独立的一项,而不是一个带斜杠的字符串,用 `{/name*}`。以 `shelves://browse{/path*}` 为例,客户端读取 `shelves://browse/fiction/sci-fi` 会调用 `browse_shelf(["fiction", "sci-fi"])`。 + +### 模板速查 {#template-reference} + +最常见的模式: + +| 模式 | 示例输入 | 得到的值 | +|--------------|-----------------------|-------------------------| +| `{name}` | `alice` | `"alice"` | +| `{name}` | `docs/intro.md` | **不匹配**(在 `/` 处停止) | +| `{+path}` | `docs/intro.md` | `"docs/intro.md"` | +| `{.ext}` | `.json` | `"json"` | +| `{/segment}` | `/v2` | `"v2"` | +| `{?key}` | `?key=value` | `"value"` | +| `{?a,b}` | `?a=1&b=2` | `"1"`, `"2"` | +| `{/path*}` | `/a/b/c` | `["a", "b", "c"]` | + +### 解析器会拒绝什么 {#what-the-parser-rejects} + +有几种模板形式会在一开始就被拦下,而不是等到第一个请求时才失败。`@mcp.resource` 在装饰器运行时就解析模板,所以这些问题都不会进入运行中的服务器。 + +`UriTemplate.parse()` 在以下情况抛出 `InvalidUriTemplate`: + +* **两个变量之间什么都没有。** `manuals://{+path}{ext}` 会被拒绝:匹配时无法判断 `path` 在哪里结束、`ext` 从哪里开始。在它们之间放一个字面量(`manuals://{+path}/{ext}`),或者使用自带分隔符的运算符。`manuals://{+path}{.ext}` 可以接受,因为 `{.ext}` 自己提供了 `.`。 +* **不止一个多段变量。** 每个模板最多只能有一个 `{+var}`、`{#var}` 或展开变量(`{/var*}`、`{.var*}`、`{;var*}`)。两个就有本质上的歧义:没有合理的办法决定多出来的段该归哪一个。 +* **常见的语法错误**:花括号没闭合、变量名重复使用,或者用了 SDK 不支持的 RFC 6570 特性,比如 `{var:3}` 前缀修饰符或 `{?vars*}` 查询展开。 + +此外,如果处理函数的某个参数绑定到模板末尾 `{?...}`/`{&...}` 段里的查询变量,却没有 Python 默认值,`@mcp.resource` 会抛出 `ValueError`。这些变量的匹配是宽松的(客户端可以省略其中任何一个),所以没有默认值的参数只会在第一个省略它的请求上以一个含糊的内部错误暴露出来。上面服务器里的 `reviews://{isbn}{?limit,sort}` 就是规范的写法:`limit` 和 `sort` 都带默认值。 + +## 安全 {#security} + +模板参数来自客户端。如果不加检查就流入文件系统或数据库操作,像 `../../etc/passwd` 这样的值就可能解析到你原本打算提供的目录之外。 + +### SDK 默认检查什么 {#what-the-sdk-checks-by-default} + +在处理函数运行之前,SDK 会拒绝任何符合以下情况的参数: + +* 通过 `..` 路径组件逃出起始目录 +* 看起来像绝对路径(`/etc/passwd`、`C:\Windows`)或 Windows 盘符相对路径(`C:foo`)。盘符相对值和 `x:y` 这样带命名空间的标识符作为字符串无法区分,所以任何由单个字母加冒号构成的值默认都会被拒绝;如果该参数确实会合法地收到这类值,就把它设为豁免 +* 包含空字节(`\x00`) + +`..` 检查是基于路径组件的,不是子串扫描。`v1.0..v2.0` 或 `HEAD~3..HEAD` 这样的值能通过,因为其中的 `..` 并不是独立的路径段。 + +这些检查作用于解码后的值,所以无论路径穿越在 URI 里是怎么编码的都能抓到(`../etc`、`..%2Fetc`、`%2E%2E/etc`、`..%5Cetc`、`%00` 全都会被拦下)。 + +!!! check + 从上面的服务器读取 `manuals://../etc/passwd`,请求会被直接拒绝:模板匹配在第一次失败时就停止,所以不会再把后面(可能更宽松)的模板当作后备去尝试。客户端看到的是 `-32602` “Unknown resource” 错误,和一个完全不匹配任何模板的 URI 一样,而 `read_manual` 根本不会运行。 + +### 文件系统处理函数:使用 safe_join {#filesystem-handlers-use-safe_join} + +内置检查能拦住常见情况,但无从知道你的沙箱边界在哪。访问文件系统时,用 `safe_join` 解析路径并确认它仍在基础目录之内: + +```python title="server.py" hl_lines="4 14" +--8<-- "docs_src/uri_templates/tutorial002.py" +``` + +`safe_join` 能抓到符号链接逃逸、`..` 序列,以及简单字符串检查会漏掉的绝对路径花招。如果解析后的路径逃出了 `DOCS_ROOT`,它会抛出 `PathEscapeError`,在客户端那边表现为 `ResourceError`。 + +### 默认设置碍事的时候 {#when-the-defaults-get-in-the-way} + +有时这些检查会挡住合法的值。一个书目导入工具可能本来就要接收绝对路径,或者某个参数是 `../sibling` 这样的相对引用,处理函数会在不碰文件系统的前提下安全地解释它。可以豁免那个参数,或者放宽整个服务器的策略: + +```python title="server.py" hl_lines="9 16-19" +--8<-- "docs_src/uri_templates/tutorial003.py" +``` + +* 装饰器上的 `security=ResourceSecurity(exempt_params={"source"})` 只对这一个资源的这一个参数跳过检查。服务器的其余部分保持默认策略。 +* `MCPServer` 构造函数上的 `resource_security=` 为每个资源设置默认值。这里的 `relaxed` 把 `..` 检查整个关掉了。 + +可配置的检查项: + +| 设置 | 默认值 | 作用 | +|-------------------------|---------|-------------------------------------| +| `reject_path_traversal` | `True` | 拒绝逃出起始目录的 `..` 序列 | +| `reject_absolute_paths` | `True` | 拒绝 `/foo`、`C:\foo`、UNC 路径和盘符相对的 `C:foo`(也会拦下 `x:y`) | +| `reject_null_bytes` | `True` | 拒绝包含 `\x00` 的值 | +| `exempt_params` | 空 | 要跳过检查的参数名 | + +这些检查只是启发式的预过滤;访问文件系统时,`safe_join` 仍然是真正的隔离边界。 + +!!! tip + 如果处理函数无法完成请求(文件不存在、id 未知),就抛出异常。SDK 会把它变成错误响应。协议错误和工具错误的区别见 **[处理错误](handling-errors.md)**。 + +## 底层 Server 上的资源 {#resources-on-the-low-level-server} + +如果你是基于底层 `Server` 构建(见 **[底层 Server](../advanced/low-level-server.md)**),就要直接为 `resources/list` 和 `resources/read` 这两个协议方法注册处理函数。没有装饰器;协议类型由你自己返回。 + +### 静态资源 {#static-resources} + +对于固定的 URI,维护一个注册表,按精确匹配分发: + +```python title="server.py" hl_lines="17 21 27" +--8<-- "docs_src/uri_templates/tutorial004.py" +``` + +列表处理函数告诉客户端有哪些可用;读取处理函数提供内容。先查注册表,如果有模板(见下)就接着落到模板上,其他情况一律抛异常。 + +### 模板 {#templates} + +`MCPServer` 使用的模板引擎位于 `mcp.shared.uri_template`,可以独立使用。解析和匹配完全一样;路由和安全策略由你自己接上。 + +```python title="server.py" hl_lines="13-16 22-25 29 33 45" +--8<-- "docs_src/uri_templates/tutorial005.py" +``` + +高亮的几行里发生了三件事: + +* **解析一次,每个请求匹配一次。** `UriTemplate.parse()` 构建模板;`template.match(uri)` 以 `dict` 形式返回提取出的变量,URI 不符合时返回 `None`。URL 解码在 `match()` 内部完成;解码后的值原样返回,不做路径安全校验。值都是字符串:自己转换(`int(matched["id"])`、`Path(matched["path"])`)。 +* **自己应用安全检查。** `MCPServer` 默认运行的 `..` 和绝对路径检查位于 `mcp.shared.path_security`。`read_manual_safely` 在碰 `MANUALS` 之前调用它们。如果某个参数不是文件系统路径(ISBN、搜索查询),就跳过对该值的检查:策略由你按处理函数逐个控制,而不是通过配置对象。 +* **从同一来源列出模板。** 客户端通过 `resources/templates/list` 发现模板。`str(template)` 返回原始模板字符串,所以列表和匹配器共用同一份事实来源。 + +## 回顾 {#recap} + +* `{name}` 匹配一段;`{+name}` 保留斜杠;`{?a,b}` 从查询字符串取值;`{/name*}` 把各段拆成列表。 +* 两个变量之间什么都没有,或者出现第二个多段变量,都会在解析时被拒绝。绑定到末尾 `{?...}`/`{&...}` 查询变量的参数必须声明 Python 默认值。 +* 给参数加上类型注解(`order_id: int`),SDK 就会转换。 +* 默认安全策略在处理函数运行之前拒绝 `..`、绝对路径和空字节;用 `security=ResourceSecurity(...)` 按资源覆盖,或用 `resource_security=` 在整个服务器范围内覆盖。 +* 访问文件系统时,`safe_join` 是隔离边界。 +* 在底层 `Server` 上,用 `UriTemplate.parse()` 解析,用 `.match()` 匹配,并自己应用 `mcp.shared.path_security`。 diff --git a/i18n/zh/pages/translations.md b/i18n/zh/pages/translations.md new file mode 100644 index 0000000000..d468035ef4 --- /dev/null +++ b/i18n/zh/pages/translations.md @@ -0,0 +1,30 @@ +--- +translation: + sections: [f671b445b16e4f99, 3983a560eb2cece7, 004b3ee918529d8c, c6e2debf1da06eb7, 81d412ed5f399f94] + tool: 1 +--- +# 翻译 {#translations} + +本文档以英文撰写。为了让更多人用得上,我们也发布了机器翻译的版本。本页说明这对你意味着什么,以及如何帮助改进这些译文。 + +## 目前提供的语言 {#whats-available} + +翻译文档目前是**预览版**,提供十二种语言:Deutsch、español、français、हिन्दी、日本語、한국어、português (Brasil)、русский язык、Türkçe、українська мова、简体中文和繁體中文。在任意页面顶部的语言切换器中选择即可。等这些语言经过验证后,可能会增加更多语言。 + +API 参考不做翻译:翻译站点会链接到唯一的英文版。 + +## 以英文为准 {#english-is-the-source-of-truth} + +如果翻译页面和英文原文不一致,以英文页面为准。翻译站点的每个页面开头都有以下三种说明之一,标明该页面的状态: + +- **机器翻译**——该页面由机器自动翻译,并链接到英文原文。 +- **译文落后于英文页面**——英文原文在该页面翻译之后有过改动,在译文跟上之前,部分内容可能已经过时。 +- **以英文显示**——该页面目前没有可用的译文,所以你读到的是英文原文。 + +## 译文是如何生成的 {#how-the-translations-are-made} + +翻译页面由本仓库中的一个工具根据 `docs/` 下的英文页面机器生成,每种语言由两份人工编写的输入指导:一份风格指南(语体、语气、排版,以及如何处理玩笑和习语)和一份术语表(哪些术语保留英文,其余术语的规定译法和禁用译法)。生成的文本从不手工编辑。所有改进都写进这两份输入,这样下次重新生成页面时改进依然有效。 + +## 报告翻译问题 {#reporting-a-translation-problem} + +发现了错误的术语、别扭的句子,或者译文说了英文原文没有的意思?请[提交 issue](https://github.com/modelcontextprotocol/python-sdk/issues),写明语言、页面和具体段落;母语读者的报告尤其有价值。如果你知道怎么改,可以直接向 [`i18n/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/i18n) 下对应语言的风格指南(`instructions.md`)或术语表(`glossary.json`)提交 pull request——这样下次重新生成译文时,修正就会覆盖所有受影响的页面。英文原文本身的问题则和其他文档改动一样,在 `docs/` 下的页面中修复。 diff --git a/i18n/zh/pages/troubleshooting.md b/i18n/zh/pages/troubleshooting.md new file mode 100644 index 0000000000..3636a87111 --- /dev/null +++ b/i18n/zh/pages/troubleshooting.md @@ -0,0 +1,404 @@ +--- +translation: + sections: [2efaecdef109a5c5, fcacd3e66b8635a4, 25323d737dcf0261, 4835ed1772f1d113, 137454d469c867f5, 6392596bd6df54f0, 41126fa9c4fe432f, 480b6d7897e30ab4, d83bb682e708dde0, ebbed3449c499db4, 323ef84f6b4bebde, 30fd31be74169d9a, 656943c6cb567218, c2dc3b1007d2e987, 7cf5386b997d04e9, 0b59feed8384456e, 0cba47bae78d04eb, 954dc21efdb532a3] + tool: 1 +--- +# 故障排查 {#troubleshooting} + +本页的每个标题都是 SDK 产生的某条错误的原文,后面是它的含义和一步到位的修复方法。用浏览器的页内查找在这里搜索 traceback(或服务器日志)的最后一行,只读那一条就够了。 + +有好几条都基于同一个服务器:一个工具加一个模板化资源,各自遇到不认识的城市都会抛异常: + +```python title="server.py" +--8<-- "docs_src/troubleshooting/tutorial001.py" +``` + +本页引用的错误都是真实的:SDK 自己的测试套件复现了其中每一条。 + +## `ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)` {#exceptiongroup-unhandled-errors-in-a-taskgroup-1-sub-exception} + +这不是 MCP 错误,而是 anyio 的噪音。真正的错误在粘贴内容的**最后一行**。 + +`Client.__aenter__` 会启动一个 task group。anyio 会把所有离开 task group 的东西包进 `ExceptionGroup`,所以**每一个**从 `async with Client(...)` 块逃逸出去的异常,不管是什么,都会装在这样一个 group 里到达: + +```python +async def main() -> None: + async with Client(mcp) as client: + await client.read_resource("weather://Atlantis") +``` + +```text + + Exception Group Traceback (most recent call last): + | ... + | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception) + +-+---------------- 1 ---------------- + | Exception Group Traceback (most recent call last): + | ... + | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception) + +-+---------------- 1 ---------------- + | Traceback (most recent call last): + | ... + | mcp.shared.exceptions.MCPError: No forecast for 'Atlantis'. + +------------------------------------ +``` + +对此有两件事要做: + +1. **读最底下。** `MCPError: No forecast for 'Atlantis'.` 才是失败原因;在本页查找**它**的文字。 +2. **在块内捕获。** 只有异常**离开** `async with` 时才会出现 `ExceptionGroup`。在块内捕获的话,同一个失败就是普通的 `MCPError`,哪里都没有 group: + +```python +async def main() -> None: + async with Client(mcp) as client: + try: + await client.read_resource("weather://Atlantis") + except MCPError as e: + print(e) # No forecast for 'Atlantis'. +``` + +!!! tip + **连接**阶段的失败(URL 写错、服务器没在运行、本页后面的 `421`)是从 `async with` 本身逃逸出来的,不存在可以捕获它的“块内”。这类情况就读 group 的最底下。 + +## `RuntimeError: Client must be used within an async context manager` {#runtimeerror-client-must-be-used-within-an-async-context-manager} + +`Client(...)` 只是构建对象。在进入 `async with` 之前什么都没连接,所以每个方法都会拒绝执行: + +```python +async def main() -> None: + client = Client(mcp) + tools = await client.list_tools() # RuntimeError +``` + +进入它。`__aenter__` 就是连接: + +```python +async def main() -> None: + async with Client(mcp) as client: + tools = await client.list_tools() +``` + +`__aexit__` 就是断开连接,所以不存在会忘记调用的 `client.close()`。**[测试](get-started/testing.md)** 正是建立在这个模式之上。 + +## `Error executing tool : ` 和 `Unknown tool: ` {#error-executing-tool-name-message-and-unknown-tool-name} + +你看到的是一个**结果**,不是异常。`call_tool` 没有抛异常,而且对于失败的工具它永远不会抛。 + +用服务器不认识的城市调用 `forecast`,它抛出的异常会随着一个标记为**成功**的请求返回: + +```python +result.is_error # True +result.content # [TextContent(text="Error executing tool forecast: No forecast for 'Atlantis'.")] +result.structured_content # None +``` + +`Unknown tool: get_forecast` 是同样的形式,对应服务器从未注册过的名字;错误的参数也以同样的方式被拒绝——对照工具的输入模式校验,在你的函数运行之前。 + +修复在客户端:**检查 `result.is_error`**。包在 `call_tool` 外面的 `try/except` 一个也抓不到,因为根本没有东西可抓。这是有意为之,也是本页最值得记住的一点:调用是**模型**选的,所以消息交给模型,让它有机会重试。详见 **[处理错误](servers/handling-errors.md)**,包括**确实**会抛异常的 `MCPError` 路径。 + +## `TypeError: The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool` {#typeerror-the-tool-decorator-was-used-incorrectly-did-you-forget-to-call-it-use-tool-instead-of-tool} + +你写的是 `@mcp.tool` 而不是 `@mcp.tool()`。`tool()` 是一个装饰器**工厂**:没有括号的话,Python 会把你的函数传给它的 `name=` 参数。 + +```python +@mcp.tool # <- missing () +def forecast(city: str) -> str: + """Today's forecast for one city.""" + return f"{city}: Rain." +``` + +```text +TypeError: The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool +``` + +加上括号。同样的手误下,`@mcp.resource(...)` 和 `@mcp.prompt()` 也会报同样的话。 + +!!! note + 这个异常在模块**被导入**时抛出,早于任何客户端连接。所以如果宿主把你的服务器显示为“启动失败”(或“已断开”),而不是已连接但零个工具,就是这种情形:自己运行 `python server.py`,读 traceback。类型检查器也能抓到它:函数不是合法的 `name=`。 + +## `Tool already exists: ` {#tool-already-exists-name} + +两次注册用了同一个工具名。**第一个**胜出,第二个被悄悄丢弃,**服务器日志**里的这条警告是唯一的信号: + +```python title="server.py" hl_lines="6 12" +--8<-- "docs_src/troubleshooting/tutorial002.py" +``` + +```text +WARNING mcp.server.mcpserver.tools.tool_manager: Tool already exists: forecast +``` + +`tools/list` 报告一个 `forecast`,而它是 `forecast_today`。给其中一个改名。`MCPServer(..., warn_on_duplicate_tools=False)` 会压掉警告但不改变结果,所以保持开启。资源和提示词有同样的规则和同样的日志行(`Resource already exists:`、`Prompt already exists:`)。 + +## 宿主列出了零个工具 {#my-host-lists-zero-tools} + +这种情况没有错误字符串,正因如此才难搜。SDK 永远不会从 `tools/list` 里丢掉已注册的工具,所以由内向外排查: + +* **服务器到底启动了没有?** 不带括号的 `@mcp.tool` 会在导入时抛异常,而在某些宿主里崩掉的服务器和空服务器看起来很像。自己运行 `python server.py`。 +* **工具在宿主运行的那个 `mcp` 上吗?** 另一个模块里的第二个 `MCPServer(...)` 是另一个空服务器。检查宿主的命令实际导入的是哪个对象。 +* **有没有两个工具同名?** 那其中一个就没了。在服务器日志里找 `Tool already exists:`。 +* **宿主的列表过期了吗?** 启动后新增的工具只会到达处理 `notifications/tools/list_changed` 的客户端。重启宿主是简单粗暴的修复。 +* **有没有东西在被转移的窗口之外写了 `stdout`?** 服务期间,SDK 会把**已刷新**的杂散 stdout 转移到 stderr(尽力而为:替换了标准流的环境会原样服务),但更早刷新到 stdout 的输出(包装脚本的 echo、无缓冲进程里导入时的 `print()`),或者在解释器退出时才排空的带缓冲 `print()`,都会落到协议流上。一行垃圾就可能让宿主断开连接,而有些宿主会把这渲染成一个空空如也的服务器。改用 `logging` 模块记录日志。宿主侧检查清单的其余部分见 **[连接到真实宿主](get-started/real-host.md)**。 + +“无效”的工具名**不**在这个清单上:不合规范的名字会记一条警告,但工具照样注册、照样列出。 + +## `MCPError: Server returned an error response` {#mcperror-server-returned-an-error-response} + +服务器直接拒绝了这个 HTTP 请求,响应体不是 JSON-RPC,所以 python `Client` 除了这个占位消息没有更好的东西可以展示。 + +最常见的原因远超其他:刚部署的 Streamable HTTP 服务器。不带 `transport_security=` 的 `streamable_http_app()`(以及 `mcp.run("streamable-http")`)默认启用 **DNS 重绑定防护**:只接受 `Host` 头为 localhost 的请求。在笔记本上这是正确的默认值,在真实主机名后面就是错误的: + +```python title="server.py" hl_lines="12" +--8<-- "docs_src/troubleshooting/tutorial003.py" +``` + +部署它,让客户端指向它,连接会在握手时失败: + +```python +async with Client("https://mcp.example.com/mcp") as client: + ... +``` + +```text +mcp.shared.exceptions.MCPError: Server returned an error response +``` + +服务器实际发送的词——`421` 和 `Invalid Host header`——永远到不了你这里:421 的响应体没有 `Content-Type: application/json`,所以客户端无法解析。它们在**服务器日志**里,下一步就该看那里: + +```text +WARNING mcp.server.transport_security: Invalid Host header: mcp.example.com +``` + +修复是 `transport_security=`。把实际对外服务的主机名加入允许列表: + +```python title="server.py" hl_lines="14-17" +--8<-- "docs_src/troubleshooting/tutorial004.py" +``` + +!!! check + 改动就这些。完全相同的客户端现在可以连接、协商 `2026-07-28` 并调用 `forecast`。 + +**[部署与扩展](run/deploy.md)** 讲了每个字段的含义、反向代理的情形,以及部署时其他所有会变的东西。而紧接在下面的 `421 Misdirected Request` / `Invalid Host header` 是从另一侧看到的同一个失败。 + +## `421 Misdirected Request` / `Invalid Host header` {#421-misdirected-request-invalid-host-header} + +这就是 `Server returned an error response`,只不过是从任何**不是** python `Client` 的地方看到的:curl、浏览器的网络面板、反向代理的访问日志,或者别的 SDK。 + +```bash +curl -i https://mcp.example.com/mcp \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}' +``` + +```text +HTTP/1.1 421 Misdirected Request + +Invalid Host header +``` + +`421 Misdirected Request` 是 HTTP 对这个状态码自带的原因短语;`Invalid Host header` 是 SDK 的响应体;而 python `Client` 把同一事件渲染成 `Server returned an error response`。三者是同一次拒绝。检查针对的是**请求携带的 `Host` 头**,不是服务器绑定的地址,所以转发公网主机名的反向代理会和直连客户端一样触发它。 + +修复和 `Server returned an error response` 下面展示的一样:`transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])`。它有两个边界情况值得点名: + +* `allowed_hosts` 的条目是精确字符串。`"mcp.example.com"` 匹配裸 `Host` 头,`"mcp.example.com:*"` 匹配任意显式端口。两个都列上。 +* 响应体为 `Invalid Origin header` 的 `403` 是针对 `Origin` 头的姊妹检查。它只对浏览器触发(别的东西都不发 `Origin`),`allowed_origins=` 是它的允许列表。 + +完整的讨论见 **[部署与扩展](run/deploy.md)**,包括什么情况下关掉这个检查才是诚实的配置。 + +## `RuntimeError: Task group is not initialized. Make sure to use run().` {#runtimeerror-task-group-is-not-initialized-make-sure-to-use-run} + +你的 MCP 应用挂载在另一个 ASGI 应用里,而没有任何东西启动它的**会话管理器**。 + +`mcp.streamable_http_app()` 返回一个 Starlette 应用,它自己的生命周期会启动管理器,而 `uvicorn server:app` 会替你运行那个生命周期。但 Starlette **从不运行被挂载的子应用的生命周期**,所以应用一旦放进 `Mount`,管理器就永远不会启动,第一个请求就炸了: + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/troubleshooting/tutorial005.py" +``` + +服务器启动了。路由解析了。然后 `uvicorn` 对每个请求都打印这个: + +```text +ERROR: Exception in ASGI application +Traceback (most recent call last): + ... +RuntimeError: Task group is not initialized. Make sure to use run(). +``` + +客户端看到的是 500。修复是在**宿主**应用上加一个进入 `mcp.session_manager.run()` 的生命周期: + +```python +@asynccontextmanager +async def lifespan(app: Starlette) -> AsyncIterator[None]: + async with mcp.session_manager.run(): + yield + + +app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lifespan) +``` + +这个问题的专门页面是 **[添加到现有应用](run/asgi.md)**,包括一个应用里放多个服务器以及 FastAPI 的情形。同一个类里还有两条相邻的字符串: + +* `StreamableHTTPSessionManager .run() can only be called once per instance. Create a new instance if you need to run again.` 管理器是一次性的;两次进入同一个应用的生命周期就会撞上它。 +* `mcp.session_manager` 只在调用过 `streamable_http_app()` **之后**才存在,所以先构建路由,只在生命周期内部碰管理器。 + +## `MCPError: Session not found` {#mcperror-session-not-found} + +服务器不认识客户端发来的 `Mcp-Session-Id`,几乎总是因为服务器**重启了**(或者你被路由到了另一个实例)。会话存活在那一个进程的内存里。 + +没有服务器 bug 可找。HTTP 响应是 `404`,它的响应体**是** JSON-RPC,所以和上面的 `421` 不同,python `Client` 会把这一条原样展示出来: + +```json +{"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "Session not found"}} +``` + +修复是重连:离开 `async with Client(...)` 块,进入一个新的,它会协商一个全新的会话。对于长时间运行的客户端,这意味着在调用外面捕获 `MCPError`,遇到这条消息就重连,而不是在死掉的会话里重试。 + +如果**没有**重启也发生,说明你跑了不止一个 worker 却没有粘性会话:每个 worker 持有自己的会话表,所以路由到错误 worker 的请求就落到这里。这件事以及它的两种修复(粘性路由,或 `stateless_http=True`)归 **[部署与扩展](run/deploy.md)** 和 **[服务旧版客户端](run/legacy-clients.md)** 管。 + +对服务器运维方来说,对应的日志行是 `Rejected request with unknown or expired session ID: `。它以 `INFO` 级别记录,所以在常用的 `WARNING` 阈值下看不到。部署后马上成批出现是正常的;每个已连接的客户端都在重连。 + +## `MCPError: Method not found` {#mcperror-method-not-found} + +一侧发送了一个 JSON-RPC 请求,另一侧没有对应的处理函数,`e.error.data` 会给出方法名。常见原因是**时代错配**:某个方法在一个协议修订版里有、在另一个里没有,却发给了处在错误修订版上的对端。比如 `2025` 时代的 `resources/subscribe` 到达 `2026-07-28` 连接,或者固定在 `mode="legacy"` 的客户端发送了 `2026` 独有的 `subscriptions/listen`。哪一侧说什么话的地图在 **[协议版本](protocol-versions.md)**;另一个正当原因(某个可选能力你从没注册处理函数)见 **[补全](servers/completions.md)**。 + +有一件事**不会**产生这个错误,尽管它是现代协议已移除的请求:工具在 `2026-07-28` 连接上调用 `ctx.elicit()`。服务器根本拒绝**发送**那个请求,所以你得到的是本页后面的 `Cannot send 'elicitation/create': ...`。 + +## `MCPError: Client did not declare the form elicitation capability required by resolver ''` {#mcperror-client-did-not-declare-the-form-elicitation-capability-required-by-resolver-name} + +服务器想问用户点什么,而这个客户端从没说过自己可以被问。 + +征询(elicitation)解析器在已连接的客户端没有声明表单征询时会一开始就拒绝,`e.error.data` 会准确指出缺了什么: + +```json +{ + "code": -32021, + "message": "Client did not declare the form elicitation capability required by resolver 'server:ask_to_confirm'", + "data": {"requiredCapabilities": {"elicitation": {"form": {}}}} +} +``` + +给 `Client(...)` 传入 `elicitation_callback=`。注册回调**就是**能力声明;没有第二个开关: + +```python +async def main() -> None: + async with Client(mcp, elicitation_callback=handle_elicitation) as client: + result = await client.call_tool("book_table", {"date": "Friday"}) +``` + +**[客户端回调](client/callbacks.md)** 列出了其余几个(`sampling_callback`、`list_roots_callback`),每一个同样都是一种声明。 + +!!! info + `-32021` 是 `MISSING_REQUIRED_CLIENT_CAPABILITY`,2026-07-28 规范新增的三个错误码之一。它们都不是异常类:全部以 `MCPError` 的形式到达,要看的是 `e.error.code`。`mcp.types` 导出了这些常量。另外两个是 `-32020` `HEADER_MISMATCH`(某个 HTTP 头和它随附的请求体不一致)和 `-32022` `UNSUPPORTED_PROTOCOL_VERSION`(请求指定了这个服务器不会说的版本)。符合规范的 SDK 客户端产生不了这两个,所以如果看到了,去查在客户端和服务器之间改写请求的那个东西。 + +## `MCPError: Elicitation not supported` {#mcperror-elicitation-not-supported} + +和 `Client did not declare the form elicitation capability ...` 是同一个缺口,只是出自那些不做前置检查的路径:服务器需要一个征询得到回答,而已连接的客户端没有注册 `elicitation_callback`。 + +在旧版连接上的 `ctx.elicit()` 会见到这一条;在任意连接上,一个被返回的多轮往返(multi-round-trip)问题(**[多轮往返请求](handlers/multi-round-trip.md)**)到达了没有回调来回答它的客户端,也会见到。修复完全一样:给 `Client(...)` 传入 `elicitation_callback=`。不存在哪种“用户没被问到”会以 `decline` 的形式交给你的工具;不能被问的客户端就是一次失败的调用,设计工具时要考虑到这一点。 + +## `MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests.` {#mcperror-cannot-send-elicitationcreate-this-transport-context-has-no-back-channel-for-server-initiated-requests} + +处理函数试图在请求中途联系客户端,而在这条连接上,这次调用没有任何能承载服务器发出请求的通道。有三种服务器配置会把调用置于这种境地。 + +**`2026-07-28` 连接:任何传输方式,永远如此。** 现代协议根本没有服务器发起的请求,所以服务器在发送任何东西之前就拒绝了。工具里的 `ctx.elicit()` 是遇到它的经典方式(就在第一次内存测试里,因为 `Client(server)` 不用要求就会协商 `2026-07-28`),而传入 `elicitation_callback=` 什么也改变不了,因为根本没有请求到达客户端让它去回答: + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/troubleshooting/tutorial006.py" +``` + +```python +async def main() -> None: + async with Client(mcp) as client: + await client.call_tool("book_table", {"date": "Friday"}) +``` + +```text +mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests. +``` + +**`stateless_http=True` 服务器上的旧版连接。** 无状态意味着每个请求自成一个世界:没有会话,没有服务器到客户端的流,于是即使是拥有这些方法的时代,也没有地方可以发送 `elicitation/create`(或 `sampling/createMessage`、`roots/list`): + +```python title="server.py" hl_lines="16 23" +--8<-- "docs_src/troubleshooting/tutorial008.py" +``` + +**`json_response=True` 服务器上的旧版连接。** `POST` 以一个 JSON 响应体作答,而一个响应体只承载响应本身,所以请求中途的 `ctx.elicit()` 需要的请求级流在这里同样不存在。会话、它的 `Mcp-Session-Id` 以及它的独立流都还在;只有请求级通道没了。 + +消息会给出它没能发送的方法名。`NoBackChannelError` 是服务器抛出的类,但线路上只承载基类 `MCPError`,所以 traceback 的最后一行是上面这句话,而不是类名。 + +对 `2026-07-28` 客户端,三种情形的修复都一样:不要在调用中途往回伸手。把问题移进一个**解析器**(或者自己返回一个 `InputRequiredResult`),它就成了**响应**的一部分,而响应是每条连接都能承载的: + +```python title="server.py" hl_lines="15-17 21" +--8<-- "docs_src/troubleshooting/tutorial007.py" +``` + +同样的问题,客户端上同样的 `elicitation_callback`。区别在底层:解析器让服务器从调用中**返回**问题而不是推送它,所以从头到尾没有任何东西从服务器流向客户端。这能救下每一个 `2026-07-28` 客户端,不管服务器处于三种配置中的哪一种。**旧版**客户端单靠这次改写救不了:`2025-11-25` 没有办法返回问题,所以在旧版连接上解析器仍然通过请求级通道发送 `elicitation/create`,也仍然需要一个保留该通道的服务器——既不是 `stateless_http=True` 也不是 `json_response=True`。解析器见 **[征询](handlers/elicitation.md)**;线路上发生了什么见 **[多轮往返请求](handlers/multi-round-trip.md)**。 + +!!! check + 用 `ctx.elicit()` 的工具没有错,它只是 **2026 之前**的写法。用 `mode="legacy"`(经典的 `initialize` 握手,规范 `2025-11-25` 及更早)连接到一个既不是 `stateless_http=True` 也不是 `json_response=True` 的服务器,它就能工作,因为那里存在服务器到客户端的通道。每个版本有什么,见 **[协议版本](protocol-versions.md)**。 + +## `MCPError: Invalid or expired requestState` {#mcperror-invalid-or-expired-requeststate} + +服务器无法验证客户端回传的 `requestState` 令牌,所以拒绝了这一轮。 + +`requestState` 是 **[多轮往返](handlers/multi-round-trip.md)** 调用在各段之间携带的不透明恢复令牌。`MCPServer` 在发出时密封它,对每次回传都做验证,而且会验证 `tools/call`、`prompts/get` 和 `resources/read` 上**每一个**入站的 `request_state`,即使处理函数从不生成令牌。所以不是本进程密封的令牌,落到哪里都会被拒绝: + +```python +async def main() -> None: + async with Client(mcp) as client: + await client.call_tool("forecast", {"city": "London"}, request_state="round-1-from-worker-a") +``` + +```text +mcp.shared.exceptions.MCPError: Invalid or expired requestState +``` + +消息是刻意固定的:线路上永远不会透露是哪项检查失败。原因写进**服务器日志**,读它就是全部的诊断: + +```text +WARNING mcp.server.request_state: requestState rejected on tools/call: malformed +``` + +实际会看到的原因: + +* **`unknown key`** 是要紧的那一个。默认的密封密钥在进程启动时生成,所以落到**另一个 worker**、负载均衡器后面的另一个实例,或者**重启后**的同一台服务器上的重试,是用本进程从未拥有过的密钥密封的。那不是攻击者;那是默认配置遇上了多于一个进程。 +* **`audience`**:令牌由**服务器名不同**的实例密封。名字是密封默认的 audience 声明,所以一组服务器除了共享密钥,还必须共享名字(或显式设置 `RequestStateSecurity(audience=...)`)。 +* **`expired`**:这一轮花的时间超过了密封的 `ttl`,即 600 秒,按轮计而不是按调用计。 +* **`malformed`** / **`codec error`**:令牌在传输途中被改动,或者压根就不是密封令牌。 +* **`request binding`**:令牌回来时带着不同的工具、不同的参数或不同的方法。 + +多进程的修复是一个参数(每个实例上**相同**的 `keys`)加上一件根本不是参数的事:相同的服务器**名字**(或显式共享的 `audience=`)。 + +```python +mcp = MCPServer("Weather", request_state_security=RequestStateSecurity(keys=[key])) +``` + +`keys[0]` 负责密封;列表里的每个密钥都参与验证,这正是零停机轮换得以实现的原因。密封保护了什么以及轮换顺序,见 **[多轮往返请求](handlers/multi-round-trip.md#protecting-requeststate)**;**[部署与扩展](run/deploy.md)** 则完整走一遍双 worker 失败及其两部分的修复。 + +!!! tip + `keys=[...]` 会立即拒绝弱密钥,并给出一条格外有用的消息: + + ```text + ValueError: request-state keys must be at least 32 bytes of secret randomness; keys[0] is 7 bytes. Generate one with: python -c "import secrets; print(secrets.token_hex(32))" + ``` + + 照它说的做。 + +## 还是卡住了? {#still-stuck} + +* 如果 SDK 产生的某条消息不在本页,那本身就是一个值得单独报告的文档 bug。 +* 搜索 [issue 跟踪器](https://github.com/modelcontextprotocol/python-sdk/issues);出现在那里的大多数错误字符串已经有人写过了。 +* 什么都没找到?带上完整的 traceback [提一个 issue](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml),或者在 [MCP Contributors Discord 的 #python-sdk-dev](https://discord.gg/6CSzBmMkjX) 里问。 + +## 回顾 {#recap} + +* `ExceptionGroup: unhandled errors in a TaskGroup` 从来都不是真正的错误。读**最后一行**;在 `async with Client(...)` 块**内部**捕获 `MCPError` 可以完全跳过这层包装。 +* `call_tool` 不会因为工具失败而抛异常。`Error executing tool ...` 和 `Unknown tool: ...` 是结果:检查 `result.is_error`。 +* `Client must be used within an async context manager` -> 用 `async with`。`Use @tool() instead of @tool` -> 加上括号。 +* 服务器日志里的 `Tool already exists:` 是两个同名工具合并成一个的唯一迹象。 +* 一个 421,三种写法:`Server returned an error response`(python `Client`)、`421 Misdirected Request` / `Invalid Host header`(其他所有地方)、`Invalid Host header: `(服务器日志)。修复:`transport_security=TransportSecuritySettings(allowed_hosts=[...])`。 +* `Task group is not initialized` -> 被挂载的应用,其宿主生命周期从未进入 `mcp.session_manager.run()`。 +* `Session not found` -> 服务器重启了;重连。 +* `Cannot send 'elicitation/create': ... no back-channel ...` -> `ctx.elicit()` 需要一条服务器到客户端的通道:`2026-07-28` 连接永远没有,`stateless_http=True` 拿走了旧版的那条,`json_response=True` 拿走了请求级的那条。用解析器(旧版客户端还需要一个保留该通道的服务器)。它的邻居 `Method not found` 是请求了对方协议修订版里没有的方法。 +* `Client did not declare the form elicitation capability ...` 和 `Elicitation not supported` -> 客户端缺少 `elicitation_callback=`。 +* `Invalid or expired requestState` 在线路上从不说明原因。服务器日志会说;`unknown key` 意味着要在各 worker 间共享 `RequestStateSecurity(keys=[...])`。 diff --git a/i18n/zh/pages/whats-new.md b/i18n/zh/pages/whats-new.md new file mode 100644 index 0000000000..d29a319050 --- /dev/null +++ b/i18n/zh/pages/whats-new.md @@ -0,0 +1,206 @@ +--- +translation: + sections: [cfe01c0c5863dfa2, 11d93f1fa09eadf5, a7392996acf1ad8f, 875eb2889263424e] + tool: 1 +--- +# v2 的新变化 {#whats-new-in-v2} + +v2 里同时发生了两件事。一是 **SDK 重建了**:客户端和服务器底下都换了新引擎,`Client` 成了一等公民,还有一批重命名,v1 代码库在第一次 import 时就会碰上。二是 **协议变了**:v2 讲的是 MCP 的 2026-07-28 修订版,这一版去掉了连接握手、会话和所有由服务器发起的请求,同时不会抛下你已有的客户端。 + +本页把这两半都带你过一遍,每个要点一节,每节末尾指向专门讲该主题的页面。它不是移植手册。移植手册是 **[迁移指南](migration.md)**:每一项破坏性变更,附改动前后的代码。 + +!!! note "v2 是稳定版本" + `pip install mcp` 安装的是 2.x,**[安装](get-started/installation.md)** 里有可以直接复制粘贴的安装命令。如果 v2 里有什么东西坏了、让你意外或者拖慢了你,请[告诉我们](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml)。 + +## SDK:从 v1 到 v2 {#the-sdk-v1-to-v2} + +### `FastMCP` 现在叫 `MCPServer` {#fastmcp-is-now-mcpserver} + +高层服务器类改了名,它所在的模块也一起改了。这是每个 v1 服务器碰到的第一件事,因为旧的 import 路径是直接没了,而不是标记为已弃用: + +```python +from mcp.server import MCPServer # v1: from mcp.server.fastmcp import FastMCP + +mcp = MCPServer("Demo") # v1: FastMCP("Demo") +``` + +对用装饰器构建的服务器来说,这也就是移植工作的大头。`@mcp.tool()`、`@mcp.resource()` 和 `@mcp.prompt()` 接受的东西和 v1 一样(`@mcp.resource()` 多了一个可选的 `security=` 关键字参数),输入模式仍然来自你的类型提示。边边角角的地方:`mcp.server.fastmcp.*` 下的所有内容现在都在 `mcp.server.mcpserver.*` 下,`ctx.fastmcp` 变成了 `ctx.mcp_server`,`get_context()` 没有了(改为声明一个 `ctx: Context` 参数),异常基类 `FastMCPError` 变成了 `MCPServerError`。import 对照表见 **[迁移指南](migration.md#fastmcp-renamed-to-mcpserver)**。 + +### `Resolve`:向用户索要输入的新方式 {#resolve-the-new-way-to-ask-the-user-for-input} + +工具需要的东西并不都该由模型提供。v2 新增:用 `Resolve(fn)` 注解的工具参数改由你写的函数来填充,模型看不到它,而这个函数可以返回 `Elicit(...)`,把一个问题摆到用户面前。这是在调用中途从客户端获取任何东西的首选方式:SDK 会通过连接所支持的机制把问题送过去——对旧版客户端是一次实时的征询(elicitation)请求,在 2026-07-28 上是一次多轮往返(multi-round-trip)——所以同一个工具函数体同时适用于新旧两代协议。详见 **[依赖](handlers/dependencies.md)**。 + +!!! note + 另外两种形式在需要时仍然可用:对旧版连接上的客户端,`ctx.elicit()` 照样能用(**[征询](handlers/elicitation.md)**);处理函数也可以自己返回 `InputRequiredResult` 并手动驱动各轮往返,这也是 2026-07-28 上采样(sampling)和根目录(roots)请求的传递方式(**[多轮往返请求](handlers/multi-round-trip.md)**)。 + +### 一等公民 `Client` {#a-first-class-client} + +v1 交给你的是三层嵌套:一个产出原始流的传输上下文管理器,包在外面的 `ClientSession`,再加上手动调用的 `await session.initialize()`。v2 只有一个对象: + +```python title="client.py" hl_lines="14-18" +--8<-- "docs_src/client/tutorial001.py" +``` + +`Client` 接受一个服务器对象(内存直连,没有传输:这就是测试的做法)、一个 URL(Streamable HTTP),或者任意传输上下文管理器,比如 `stdio_client(...)`。进入 `async with` 就会建立连接并协商协议版本,不管服务器讲的是哪一代协议;之后 `client.server_capabilities` 和 `client.protocol_version` 直接就在那里,服务器表明身份时 `client.server_info` 也一样(它现在是 `Implementation | None`,因为 2026 版的身份信息是可选的)。你在 v1 注册的采样和征询回调仍然能用(它们的函数体会看到和本页其他地方一样的 snake_case 属性重命名),现在还会回答 2026 风格的、嵌在结果里的请求(见下文),并且是并发运行而不是一次一个。想要底层接口的人仍然可以用底下的 `ClientSession`,`client.session` 会把它交给你;它也变了(运行在新的调度器引擎上,自身的一些签名也改了),所以下探之前先读 **[迁移指南](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)**。 + +**[Client](client/index.md)** 介绍它,**[客户端传输](client/transports.md)** 讲三种连接形式,**[客户端回调](client/callbacks.md)** 讲回调本身,**[测试](get-started/testing.md)** 展示取代 v1 `create_connected_server_and_client_session()` 辅助函数的内存模式。 + +### 底层 `Server` 是重建,不是改名 {#the-low-level-server-was-rebuilt-not-renamed} + +如果你在 JSON-RPC 层工作,这就是 v2 里“什么都不一样了”的那部分。下面是同一个单工具服务器的两种写法;点击标记查看哪些东西变了。 + + + +```python title="v1" +from typing import Any + +import mcp.types as types +from mcp.server.lowlevel import Server + +server = Server("Bookshop") + + +@server.list_tools() # (1)! +async def list_tools() -> list[types.Tool]: + return [ # (2)! + types.Tool( + name="search_books", + description="Search the catalog by title or author.", + inputSchema={ # (3)! + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + }, + ) + ] + + +@server.call_tool() +async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentBlock]: # (4)! + if name != "search_books": + raise ValueError(f"Unknown tool: {name}") # (5)! + ctx = server.request_context # (6)! + return [types.TextContent(type="text", text=f"Found 3 books matching {arguments['query']!r}.")] # (7)! +``` + +1. 处理函数用装饰器注册(装饰器要调用,带括号),服务器创建之后随时都可以。 +2. 返回一个裸的 `list[Tool]`,SDK 会把它包成 `ListToolsResult`。 +3. 字段在 Python 里是 camelCase,而且模式 **会被强制执行**:SDK 在你的函数运行之前用 jsonschema 按它校验 `call_tool` 的参数,所以下面的 `arguments["query"]` 是安全的。 +4. 一个 `call_tool` 处理函数服务所有工具,它收到的是工具名和已经校验过的参数,已解包,且永远不会是 `None`。 +5. v1 工具用抛异常来表示失败:任何异常都会被捕获并作为 `CallToolResult(isError=True)` 返回,文本是 `str(e)`,所以发起调用的模型能读到这条消息并可以重试。 +6. 上下文来自一个环境 ContextVar,在请求处理中途通过服务器对象拿到。 +7. 裸的内容块会替你包成 `CallToolResult`。 + +```python title="v2" +--8<-- "docs_src/whats_new/tutorial001.py" +``` + +1. 字段现在是 snake_case,而模式 **只对外公布、从不实际应用**:处理函数运行之前没有任何东西检查参数。 +2. 每个处理函数的形状都一样:`async (ctx, params) -> result`。上下文是第一个参数(`ctx.session`、`ctx.request_id`、`ctx.protocol_version` 都在它上面);`server.request_context` 就是搬到了这里。 +3. 完整的 `ListToolsResult` 由你自己构建。现在返回裸列表会在服务器端得到 `TypeError`,SDK 不会再替你包装。 +4. 进来的是带类型的 params(`params.name`、`params.arguments`),出去的是完整的结果。没有任何东西替你解包、包装或转换。 +5. 同样的检查,抛出的异常不同。这里抛 `ValueError` 会以一个不透明的 `-32603` 到达模型(见下文),所以要故意返回线路错误就抛 `MCPError`:它会带着自己的错误码和消息原样穿过去,而带这段文本的 `-32602` 正是规范自己对未知工具给出的答复。 +6. `params.arguments` 可能是 `None`;v1 会在你的代码看到它之前把它默认成 `{}`。处理函数前面没有校验了,所以这一行必不可少。 +7. 这里抛出的意外异常会变成一个 **脱敏后的** 协议错误,`-32603` `"Internal server error"`:模型永远看不到那条消息。对于模型应该读到并做出反应的失败,返回 `CallToolResult(is_error=True, ...)`。 +8. 处理函数是构造函数参数,所以服务器一创建出来,它的接口就已经完整;`add_request_handler()` 是构造之后的应急出口,也是通往自定义方法的入口。 + +这个例子就是模式本身。更一般地说:每个处理函数的形状都一样,带类型的 params 进来,完整的结果类型出去;以前对工具参数的 jsonschema 检查没有了;异常就是协议错误,永远不会是 `is_error=True` 的工具结果;环境里的 `server.request_context` ContextVar 也没有了。带厂商命名空间的自定义方法通过 `add_request_handler(method, params_type, handler)` 成为一等公民,它会在处理函数运行之前按你的模型校验入站 params。另外还有一个 `middleware` 列表(特意标记为暂定)包裹每一条入站消息,取代了以前人们去重写的私有 `_handle_*` 方法。 + +在底层,v1 的 `BaseSession` 接收循环换成了一个调度器引擎,客户端和服务器现在共用它,本页上好几件事能同时成立靠的就是它:同一个 `Server` 对象同时服务两代协议,`Client(server)` 在进程内直接分发、没有 JSON-RPC 封帧,客户端请求超时现在会真的取消服务器端的处理函数。 + +详见 **[底层 Server](advanced/low-level-server.md)**;**[迁移指南](migration.md#lowlevel-server-decorator-based-handlers-replaced-with-constructor-on_-params)** 逐一讲解每个被移除的钩子。如果你从没下探到 `MCPServer` 以下,这些都不影响你。 + +### 线路类型搬到了 `mcp-types`,每个字段都是 snake_case {#the-wire-types-moved-to-mcp-types-and-every-field-is-snake_case} + +协议类型现在有了自己的发行包 `mcp-types`。它除了 pydantic 和 typing-extensions 之外什么都不依赖,所以网关、代理或代码生成器不用安装 HTTP 栈就能使用 MCP 的线路结构:这样的项目安装 `mcp-types`,然后 import `mcp_types`。`mcp` 本身以精确版本依赖那个包并把它重新暴露出来,所以依赖 SDK 的代码继续写 `import mcp.types as types` 和 `from mcp.types import Tool`(永久别名,每个名字都是同一个对象),并且只声明它唯一真正的依赖 `mcp`。经验法则:通过你实际依赖的那个包来 import。 + +在这些类型上,每个 Python 属性现在都是 snake_case:`result.is_error`、`tool.input_schema`、`listing.next_cursor`。线路上的 JSON 仍然是 camelCase,和以前完全一样;变的只是属性的拼写。同时附带两个更严格的默认行为:未知字段会被忽略而不是原样往返(额外的东西放进 `_meta`),并且两端都会按协商好的协议版本校验流量。重命名对照表见 **[迁移指南](migration.md#field-names-changed-from-camelcase-to-snake_case)**。 + +### 传输配置搬到了 `run()` {#transport-configuration-moved-to-run} + +`MCPServer(...)` 关心的是你的服务器 **是什么**:它的名称、instructions、生命周期、认证。至于它 **怎样对外提供服务**,现在归 `run()` 和应用构建函数管,`host`、`port`、`stateless_http`、`json_response`、端点路径和 `transport_security` 都搬到了那里(`MCPServer("x", port=9000)` 会得到 `TypeError`)。各个重载按传输方式分别标注了类型,所以编辑器会告诉你 `stdio` 接受哪些选项、`streamable-http` 接受哪些。有一处移除值得知道:`mount_path` 没有了;要在某个前缀下提供服务,受支持的做法是挂载 ASGI 应用。 + +选项见 **[运行服务器](run/index.md)**;挂载见 **[添加到现有应用](run/asgi.md)**。 + +### 行为变了但不会报 import 错误的地方 {#behavior-that-changes-without-an-import-error} + +重命名会自己跳出来提醒你。下面这些不会: + +* **同步函数在工作线程上运行。** `def` 定义的工具(或资源、提示词、解析器)不再阻塞事件循环;代价是它的函数体不再 **在** 事件循环线程上运行,这对有线程亲和性的代码有影响。`async def` 处理函数不受影响。**[迁移指南](migration.md#sync-handler-functions-now-run-on-a-worker-thread)**。 +* **在工具内部抛出的 `MCPError`(v1 的 `McpError`)现在是协议错误。** 模型永远看不到它。其他所有异常仍然会变成模型能读到并做出反应的 `is_error=True` 结果。两者的划分见 **[错误处理](servers/handling-errors.md)**。 +* **结果在发出之前会被校验。** 手工构建的 `Tool` 如果 `input_schema` 是 `{}`,现在会让 `tools/list` 失败(规范要求 `"type": "object"`)。基于 `@mcp.tool()` 构建的服务器永远不会遇到这个;它们的模式是 SDK 写的。 +* **你的客户端会校验收到的东西。** `list_tools()` 和 `call_tool()` 会按协商好的协议版本检查服务器的答复,所以 v1 宽松解析能容忍的不太合规的服务器,现在会抛 `pydantic.ValidationError`。如果你连接的是自己不控制的服务器,要做好由你来发现它们的准备;细节见 **[迁移指南](migration.md#client-validates-inbound-traffic-against-the-protocol-schema)**。 +* **URI 模板现在是真正的 RFC 6570。** `{+path}`、`{?query}` 之类都能用,匹配是精确的而不是正则式的宽松匹配,提取出的值里的路径穿越默认会被拒绝。更严格的模板在装饰时就失败,而不是等到第一个请求。**[URI 模板](servers/uri-templates.md)**。 +* **Streamable HTTP 的生命周期只运行一次**,在启动时运行,它的状态由所有会话和请求共享。在 v1 里它每个会话运行一次,`stateless_http=True` 下则是每个请求一次。在生命周期里建的连接池和缓存会便宜得多;以前在那里获取每连接资源的做法,现在应该放进处理函数体里。**[生命周期](handlers/lifespan.md)**。 +* **`mcp dev` 和 `mcp install` 会把它们启动的环境固定** 到你已安装的 SDK 版本。这两个命令在一个全新的 `uv run --with ...` 环境里运行你的服务器,以前这个环境会把 `mcp` 解析成最新的稳定版,而不是你开发所针对的版本。**[迁移指南](migration.md#mcp-dev-and-mcp-install-pin-the-spawned-environment-to-your-sdk-version)**。 +* **HTTP 客户端现在是 `httpx2`,不是 `httpx`。** 这次依赖替换改变了你的代码要捕获和传递的东西(`httpx2.AsyncClient`、`httpx2.ConnectError`),也改变了 TLS 证书的校验方式:`httpx2` 通过 `truststore` 按操作系统的信任库校验,而不是 certifi 自带的 CA 列表。大多数环境根本察觉不到;没有系统 CA 库的极简容器,或者只有 certifi 的证书包才认识的私有 CA,会开始在 TLS 握手时失败。设置 `SSL_CERT_FILE`/`SSL_CERT_DIR`,或者给客户端传 `verify=ssl_context`。**[迁移指南](migration.md#httpx-and-httpx-sse-replaced-by-httpx2)**。 + +### 彻底移除的内容 {#removed-outright} + +下面每一项在 **[迁移指南](migration.md)** 里都有一节: + +* **WebSocket 传输**,两端都是,以及 `mcp[ws]` extra。它从来不是 MCP 规范的一部分。 +* **实验性的 Tasks** API(`mcp.*.experimental`)。2026-07-28 把任务从核心协议里移出去,放进了一个官方扩展([SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663)),本 SDK 尚未实现它。 +* 作为 import 路径的 `mcp.shared.version`、`mcp.shared.progress` 和 `mcp.shared.session`(连同 v1 `message_handler` 注解会 import 的 `RequestResponder` 桩)。(`mcp.types` **没有** 被移除:它作为独立 `mcp_types` 包的永久别名保留。) +* 已弃用的 `streamablehttp_client` 拼写,以及 `streamable_http_client` 的 `get_session_id` 回调(它现在恰好产出两个流)。 +* `McpError`,改名为 **`MCPError`**,带一个直接的 `(code, message, data)` 构造函数。 +* `MCPServer.get_context()`、`mount_path=`,以及底层 `Server` 的装饰器方法、ContextVar 和处理函数字典。 + +## 协议:从 2025-11-25 到 2026-07-28 {#the-protocol-2025-11-25-to-2026-07-28} + +v2 实现了 2026-07-28 修订版,并且同时服务 **两个** 修订版:同一个 `streamable_http_app()`(以及同一个 stdio 服务器)既回答 2025 版客户端的 `initialize`,也回答 2026 版客户端的请求,不需要配置任何东西,不需要开什么开关,也不需要单独部署。服务新修订版不会抛下还在旧版上的客户端。下面讲的是新修订版本身改变了什么。 + +### 没有握手,没有会话 {#no-handshake-no-session} + +2026-07-28 的客户端不会先打开连接、协商、然后再说话。每个请求都在 `_meta` 里携带自己的协议版本、客户端信息和客户端能力,而唯一的发现调用 `server/discover` 也是和其他请求一样的普通请求。`Client` 默认就会做正确的事:它探测一次 `server/discover`,如果服务器比较旧,就回退到 `initialize` 握手。 + +在 Streamable HTTP 上,2026 路径没有 `Mcp-Session-Id`,这是运维层面的头条:**没有任何东西把新版请求绑在某个工作进程上**,所以普通轮询负载均衡器后面的任何副本都能回答它。有两点要如实说明。你的 2025 版客户端(今天来说,也就是大多数客户端)仍然会打开会话,仍然需要它们在 v1 上需要的那种会话粘滞;对它们来说什么都没变。而 **多轮往返** 重试唯一需要跨工作进程携带的东西是它密封好的 `request_state`,它的默认密钥是每个进程各自生成的,所以横向扩展的部署要传入 `RequestStateSecurity(keys=[...])`。(`stateless_http=True` 与此无关:它只影响 2025 版客户端如何被服务,2026 的流量从不读取它;如果你在 v1 里已经设置了它,什么都不变。) + +这件事的客户端一侧见 **[协议版本](protocol-versions.md)**,运维人员的检查清单见 **[部署与扩展](run/deploy.md)**(Host 允许列表、`request_state` 密钥、跨副本的通知),同时服务两代协议的做法见 **[服务旧版客户端](run/legacy-clients.md)**。 + +### 服务器不能调用客户端:多轮往返请求 {#the-server-cannot-call-the-client-multi-round-trip-requests} + +在 2026-07-28 上,所有由服务器发起的请求都没有了:推送式征询、采样、`roots/list`。2026 连接上没有给它们用的通道,所以 `ctx.elicit()` 和 `ctx.session.create_message()` 在那里会以 `NoBackChannelError` 失败(对旧版客户端它们仍然能用)。 + +替代方案把调用反了过来。需要从用户那里拿东西的工具把问题 **返回** 出去(`InputRequiredResult`),客户端用它一直都有的那些回调来回答,然后调用会带着答案重试。`Client` 替你驱动这个循环。在服务器上你很少自己构建这个结果,因为 **[依赖](handlers/dependencies.md)** 会做这件事:用 `Resolve(ask_quantity)` 注解一个参数,其中 `ask_quantity` 是你写的普通函数,SDK 就会通过连接所支持的机制去问——在旧版会话上是实时的征询请求,在 2026 上是多轮往返。一个工具函数体,两代协议: + +```python title="dual_era.py" hl_lines="24 37-38" +--8<-- "docs_src/legacy_clients/tutorial001.py" +``` + +这个文件把卖点集中在了一处:一个服务器,一个由 `Resolve` 支撑的工具,一个旧版客户端加一个新版客户端都拿到了各自的答案,全在内存里。**[多轮往返请求](handlers/multi-round-trip.md)** 解释这个机制(包括 `request_state`,SDK 会替你密封并验证它);**[征询](handlers/elicitation.md)** 讲提问的部分。 + +!!! warning "这是移植过来的 v1 服务器唯一会改变行为的地方" + 你自己的测试会最先碰到它:`Client(mcp)` 默认会和你的 v2 服务器协商出 2026-07-28,所以调用 `ctx.elicit()` 的工具会在一个 v1 上能通过的测试里失败。把问题挪进一个 `Resolve(...)` 参数(两代通用),或者如果你确实想要推送行为,就把测试客户端固定为 `mode="legacy"`。 + +### 根目录、采样和协议日志已弃用;`ping` 已移除 {#roots-sampling-and-protocol-logging-are-deprecated-ping-is-removed} + +[SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) 弃用了整整三项 **能力**,而且是在所有协议版本上:根目录、采样和 MCP 层面的日志(`ctx.info()` 之类)。这和上面缺少反向通道(back-channel)是两个不同的维度;弃用只是建议性的,针对 2025 版会话一切照常工作,线路上没有任何变化。你会注意到的是 `MCPDeprecationWarning`,它是一个 `UserWarning`,所以默认会打印出来;升级之后你的第一个 `ctx.info(...)` 大概就会这么说。 + +`ping` 更严格:是从协议里移除,不是弃用。已弃用功能里有两个独立方法在 2026-07-28 也同样被移除,`logging/setLevel` 和客户端的 `notifications/roots/list_changed`,而进度通知现在只能从服务器发往客户端。 + +完整的表格、每一项的替代方案,以及在服务旧版客户端期间想让日志安静下来时用的那一行过滤器,都见 **[已弃用功能](deprecated.md)**。 + +### 变更通知合并为一条流 {#change-notifications-become-one-stream} + +在 2026-07-28 上,独立的 HTTP GET 流和 `resources/subscribe` 被 `subscriptions/listen` 取代:客户端打开一条长连接流,并指明它想要的通知种类。`MCPServer` 默认就能服务它;用 `await ctx.notify_resource_updated(uri)`(以及 `notify_tools_changed()` 等等)来发布,中间件可以按调用方拒绝某个 listen 请求,多副本部署则接入一个共享的 `SubscriptionBus`。在客户端,`async with client.listen(...)` 打开这条流:过滤条件以关键字参数传入,带类型的变更事件传回来,`sub.honored` 是服务器同意投递的那个子集。 + +发布和服务见 **[订阅](handlers/subscriptions.md)**,监听一端见 **[客户端部分的姊妹篇](client/subscriptions.md)**,总线见 **[部署与扩展](run/deploy.md)**。 + +### 其余变化速览 {#the-rest-quickly} + +* **身份信息是可选的、按消息携带的元数据。** 请求侧的 `clientInfo` `_meta` 键是可选的(必需的一对是 `protocolVersion` + `clientCapabilities`),`serverInfo` 则从 `server/discover` 的结果体里搬了出来:服务器改为把它盖进每个 2026 版结果的 `_meta` 里([规范 #3002](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3002))。SDK 总是会盖;服务器不表明身份时(比如某个中间件剥掉了这个键),`client.server_info` 就是 `None`。**[底层 Server](advanced/low-level-server.md)** 展示了线路上的这个印记。 +* **请求不用解析请求体就能路由。** 新版 HTTP 请求带有 `Mcp-Method`(对三个类似工具的调用,还有 `Mcp-Name`);用 `x-mcp-header` 注解的工具输入模式属性会被镜像成一个 `Mcp-Param-*` 头,并由服务器交叉核对([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243))。网关和限流器单凭请求头就能路由;规则见 **[迁移指南](migration.md#servers-validate-mcp-param-headers-against-the-request-body-sep-2243)**。 +* **结果带有缓存提示。** 列表和读取结果声明 `ttlMs` 和 `cacheScope`([SEP-2549](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549));用 `cache_hints=` 按方法设置它们,`Client` 则用内置的响应缓存来遵守它们。不发送提示的服务器(所有 2026 之前的服务器)看到的是完全相同、未经缓存的流量。**[缓存提示](client/caching.md)**。 +* **扩展是一等公民。** 服务器和客户端在反向 DNS 标识符下声明可选的能力包([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133));内置的 `Apps` 扩展(MCP Apps)是参考实现。**[扩展](advanced/extensions.md)** 和 **[MCP Apps](advanced/apps.md)**。 +* **错误码标准化了。** 不存在的资源是 `-32602`,URI 放在 `error.data` 里,新的规范保留码有 `-32020`(头不匹配)、`-32021`(缺少必需的能力)和 `-32022`(不支持的协议版本)。**[故障排查](troubleshooting.md)** 按确切的消息文本编排。 +* **授权更难用错了。** 客户端会校验随授权码返回的 `iss`([RFC 9207](https://datatracker.ietf.org/doc/html/rfc9207);你的 `callback_handler` 现在返回一个 `AuthorizationCodeResult`),注册时会发送 `application_type`,并且永远不会把凭据重放给另一个授权服务器。企业场景的新东西:[SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) 身份断言流程。**[迁移指南](migration.md)** 列出了每一项 OAuth 变更;相关页面是 **[客户端 OAuth](client/oauth-clients.md)** 和 **[身份断言](client/identity-assertion.md)**。 +* **每个服务器都可追踪。** OpenTelemetry 作为中间件默认开启:每个请求都有一个服务器 span,在进程配置 exporter 之前没有任何开销。两端都运行本 SDK 时,客户端还会在 `_meta` 里传播 W3C trace context,所以两边的 trace 能接上。**[OpenTelemetry](run/opentelemetry.md)**。 + +## 要从 v1 升级? {#upgrading-from-v1} + +* **[迁移指南](migration.md)** 是完整、精确的改动清单;本页讲的是为什么。 +* **v1.x 不会消失。** 它转入维护状态,继续获得关键修复和安全补丁,2026-07-28 规范的发布不会破坏它的任何东西;它的文档在 [/v1/](https://py.sdk.modelcontextprotocol.io/v1/)。如果你发布了一个依赖 `mcp` 的库而且还没准备好迁移,保留一个版本上限(比如 `mcp>=1.28,<2`),这样未固定版本的解析就会停留在 1.x。 +* 有什么粗糙、让人困惑或者坏掉的地方?**[提交 v2 反馈](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml)**;每一条都会有人读。 diff --git a/mkdocs.yml b/mkdocs.yml index 06b293f876..a75053326f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -68,6 +68,7 @@ nav: - Extensions: advanced/extensions.md - MCP Apps: advanced/apps.md - Troubleshooting: troubleshooting.md + - Translations: translations.md - Migration Guide: migration.md - API Reference: api/ @@ -124,6 +125,10 @@ theme: extra_css: - extra.css +# Keeps the language switcher on the current page (see the script). +extra_javascript: + - js/language-switch.js + markdown_extensions: - tables - abbr diff --git a/pyproject.toml b/pyproject.toml index 3c814106d1..58c2586fc1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -96,13 +96,19 @@ docs = [ # unregistered-autorefs KeyError under Zensical. "mkdocstrings==1.0.4", "mkdocstrings-python==2.0.5", - # scripts/docs/build_config.py and llms_txt.py read mkdocs.yml directly. + # scripts/docs/build_config.py, llms_txt.py and translations.py read + # mkdocs.yml directly; translations.py also drives Zensical's own Markdown + # stack (its `markdown`/`pymdownx` dependencies) to compute heading ids. "pyyaml>=6.0.2", # gen_ref_pages.py imports griffe directly. griffelib is not a typo: it is # griffe's successor distribution (same author) and still imports as # `griffe`; the old `griffe` distribution is the incompatible 1.x line. "griffelib==2.1.0", ] +# Only `scripts/docs/translations.py translate` calls the Claude API; kept out +# of the default groups so no other environment installs the client (or the +# legacy httpx it depends on). See i18n/README.md. +translate = ["anthropic>=0.121.0"] codegen = ["datamodel-code-generator==0.57.0"] [build-system] @@ -160,6 +166,8 @@ include = [ "examples/servers", "examples/snippets", "examples/clients", + "scripts/docs/build_config.py", + "scripts/docs/translations.py", ] venvPath = "." venv = ".venv" @@ -175,6 +183,7 @@ executionEnvironments = [ { root = "tests", extraPaths = [ ".", "examples", + "scripts/docs", ], reportUnusedFunction = false, reportPrivateUsage = false }, { root = "examples/stories", extraPaths = [ "examples", @@ -222,6 +231,8 @@ max-complexity = 24 # Default is 10 # Generated by scripts/gen_surface_types.py: raw datamodel-codegen output (TID251 lifts the repo-wide RootModel ban for these generated validators). "src/mcp-types/mcp_types/_v*/__init__.py" = ["D212", "E501", "I001", "TID251", "UP007", "UP037"] "tests/server/mcpserver/test_func_metadata.py" = ["E501"] +# Inline snapshots of the translation tool's output carry long status/prompt lines verbatim. +"tests/docs/test_translations.py" = ["E501"] "tests/shared/test_progress_notifications.py" = ["PLW0603"] [tool.ruff.lint.pylint] @@ -243,6 +254,8 @@ strict-no-cover = { git = "https://github.com/pydantic/strict-no-cover" } [tool.pytest.ini_options] log_cli = true xfail_strict = true +# tests/docs/ imports the docs tooling, top-level modules under scripts/docs/. +pythonpath = ["scripts/docs"] markers = [ "requirement(id): links a test to the entry in tests/interaction/_requirements.py it exercises", ] diff --git a/scripts/docs/build.sh b/scripts/docs/build.sh index 8f545761bc..fe36c010d7 100755 --- a/scripts/docs/build.sh +++ b/scripts/docs/build.sh @@ -1,18 +1,26 @@ #!/usr/bin/env bash # -# Build the v2 documentation site for this checkout into `site/`. +# Build the v2 documentation site for this checkout into `site/`: the English +# site at the root, then one translated site per language in +# i18n/languages.yml under `site//`. # -# Zensical runs no MkDocs plugins or hooks, so the build is three steps: -# materialise the API reference pages and the concrete config, build the -# site strictly (plus the order-independence and cross-reference checks +# Zensical runs no MkDocs plugins or hooks, so the English build is three +# steps: materialise the API reference pages and the concrete config, build +# the site strictly (plus the order-independence and cross-reference checks # Zensical doesn't do itself), then generate llms.txt and the per-page -# markdown renditions. This script is the single owner of that recipe, dependency -# sync included — CI (shared.yml, docs-preview.yml) and scripts/build-docs.sh -# all call it. The toolchain detection in docs-preview.yml and build-docs.sh -# keys on this file's path and expects the site under site/. +# markdown renditions. A language site is lighter: the translation tool stages +# a docs tree (English pages overlaid with that language's translations), +# build_config.py writes its config, and Zensical builds it (non-strict: a +# dead link or anchor in a translation is a warning, counted at the end) +# straight into site// — no API reference (it links the English one), +# so no render-order or cross-reference checks. This script is the single +# owner of that recipe, dependency sync included — CI (shared.yml, +# docs-preview.yml) and scripts/build-docs.sh all call it. The toolchain +# detection in docs-preview.yml and build-docs.sh keys on this file's path and +# expects the site under site/. # # Usage: -# scripts/docs/build.sh +# scripts/docs/build.sh (DOCS_LANGUAGES=en-only skips the language sites) # set -euo pipefail @@ -26,8 +34,8 @@ uv sync --frozen --group docs # Zensical's incremental cache is unsound: a warm rebuild where only some # pages re-render silently drops cross-references to cache-hit pages, and # HTML for since-deleted pages lingers in site/. Build cold so the output -# (and the checks below) are deterministic. -rm -rf .cache site +# (and the checks below) are deterministic. Staged language trees likewise. +rm -rf .cache site .build/i18n uv run --frozen --no-sync python scripts/docs/build_config.py uv run --frozen --no-sync zensical build -f mkdocs.gen.yml --strict @@ -44,3 +52,24 @@ uv run --frozen --no-sync python scripts/docs/check_render_order.py uv run --frozen --no-sync python scripts/docs/check_crossrefs.py --site-dir site uv run --frozen --no-sync python scripts/docs/llms_txt.py --site-dir site + +# Language sites build after English: `zensical build` clears its site_dir, so +# the English build (site_dir site/) would wipe every site//, while a +# language build (site_dir site//) leaves its parent alone. All the +# language trees are staged in one pass, which reads the English pages once. +languages="" +if [[ "${DOCS_LANGUAGES:-}" != "en-only" ]]; then + languages="$(PYTHONPATH=scripts/docs uv run --frozen --no-sync python -c \ + 'import build_config; print(*(language.code for language in build_config.load_registry().languages))')" + uv run --frozen --no-sync python scripts/docs/translations.py stage +fi + +for lang in $languages; do + echo "=== Building language site: ${lang} ===" + uv run --frozen --no-sync python scripts/docs/build_config.py --lang "$lang" + rm -rf .cache + log=".build/i18n/${lang}/build.log" + uv run --frozen --no-sync zensical build -f "mkdocs.${lang}.gen.yml" 2>&1 | tee "$log" + # Zensical reports each dead link/anchor as a "Warning:" diagnostic on stderr. + echo "${lang}: $(grep -c 'Warning:' "$log" || true) warnings" +done diff --git a/scripts/docs/build_config.py b/scripts/docs/build_config.py index daba648344..47a544f7ad 100644 --- a/scripts/docs/build_config.py +++ b/scripts/docs/build_config.py @@ -6,42 +6,145 @@ gen_ref_pages) and writes `mkdocs.gen.yml` with the real API nav spliced in — that generated file is what `zensical build`/`serve` consumes. +With `--lang CODE` it writes `mkdocs.CODE.gen.yml` for one translated site +instead: built from the tree `scripts/docs/translations.py stage` assembled +under `.build/i18n/CODE/docs/` into `site/CODE/`, with no API reference of its +own (its nav entry links the English one) and nav titles taken from the staged +pages (the headings `stage` recorded beside the tree). Every config, English +included, carries the same language switcher (`extra.alternate`) built from +`i18n/languages.yml`, which this module also loads for the translation tool. + Usage: - python scripts/docs/build_config.py + python scripts/docs/build_config.py [--lang CODE] """ from __future__ import annotations +import argparse +import json import posixpath import re +from dataclasses import dataclass from pathlib import Path +from typing import Any # Both scripts live in this directory, which Python puts on sys.path[0] when # `build_config.py` is run directly (its documented invocation). import gen_ref_pages import yaml +from gen_ref_pages import NavItem ROOT = Path(__file__).parent.parent.parent +LANGUAGES_FILE = "i18n/languages.yml" + +# A language site carries no API reference; its nav entry links the English +# one, which opens on the first package's index (see gen_ref_pages). +API_REFERENCE_URL = "/api/mcp/" + +# A nav value with a URL scheme (https:, mailto:, ...) or a leading `/` is a +# link, not a page under docs_dir (MkDocs' own classification). +_LINK = re.compile(r"[a-zA-Z][a-zA-Z0-9+.-]*:|/") + + +@dataclass(frozen=True) +class Language: + """One translated site from `i18n/languages.yml`.""" + + code: str + name: str + theme: str + hreflang: str + + +@dataclass(frozen=True) +class Registry: + """The parsed `i18n/languages.yml`.""" + + model: str + exclude: list[str] + languages: list[Language] -# A scheme-prefixed nav value (https:, mailto:, ...) is an external link, not -# a page path (same classifier as llms_txt.py; a `://` test would misread -# scheme-only URIs as pages). -_EXTERNAL = re.compile(r"[a-zA-Z][a-zA-Z0-9+.-]*:") +def load_registry(root: Path = ROOT) -> Registry: + """Parse `i18n/languages.yml` under repository `root`. -def _nav_pages(nav: list) -> set[str]: - """Collect every local page reference in the nav (external links excluded).""" - pages: set[str] = set() + Raises: + ValueError: The file is missing, unparsable, or not the shape of `Registry`. + """ + try: + raw = yaml.safe_load((root / LANGUAGES_FILE).read_text(encoding="utf-8")) + languages = [Language(**entry) for entry in raw["languages"]] + return Registry(str(raw["model"]), [str(pattern) for pattern in raw["exclude"]], languages) + except (OSError, yaml.YAMLError, TypeError, KeyError) as exc: + raise ValueError(f"{LANGUAGES_FILE}: {exc!r}") from exc + + +def staged_docs_dir(code: str, root: Path = ROOT) -> Path: + """Where `translations.py stage` assembles a language's docs tree before its site is built.""" + return root / ".build" / "i18n" / code / "docs" + + +def staged_titles_file(code: str, root: Path = ROOT) -> Path: + """Where `translations.py stage` records each staged page's `#` heading text, keyed by page path.""" + return root / ".build" / "i18n" / code / "titles.json" + + +def nav_page_paths(nav: list[NavItem]) -> list[str]: + """Every local page path in the nav, depth first in nav order (link entries excluded).""" + paths: list[str] = [] for entry in nav: value = next(iter(entry.values())) if isinstance(entry, dict) else entry if isinstance(value, list): - pages |= _nav_pages(value) - elif not _EXTERNAL.match(value): - pages.add(value) - return pages + paths.extend(nav_page_paths(value)) + elif not _LINK.match(value): + paths.append(value) + return paths + + +def language_nav(nav: list[NavItem], titles: dict[str, str]) -> list[NavItem]: + """The nav of a language site: every title taken from the staged pages (`titles` maps page path to H1). + + Page labels are dropped, so Zensical titles each page from its (translated) + H1, and a section is titled with the H1 of the index page that leads it, so + the sidebar cannot drift from the pages. A link entry, and a section that + does not lead with a titled page, keeps its English label. + """ + entries: list[NavItem] = [] + for entry in nav: + if isinstance(entry, str): + entries.append(entry) + continue + ((label, value),) = entry.items() + if isinstance(value, list): + title = titles.get(value[0]) if value and isinstance(value[0], str) else None + entries.append({title or label: language_nav(value, titles)}) + else: + entries.append({label: value} if _LINK.match(value) else value) + return entries + + +def alternate(languages: list[Language]) -> list[dict[str, str]]: + """The `extra.alternate` switcher: English at the site root, then each language site. + + Each label leads with the site's code (`ja - 日本語`); links are path-only + so they follow whatever host serves the build. + """ + entries = [{"name": "en - English", "link": "/", "lang": "en"}] + entries += [ + {"name": f"{lang.code} - {lang.name}", "link": f"/{lang.code}/", "lang": lang.hreflang} for lang in languages + ] + return entries -def _validate_nav(nav: list, docs_dir: Path) -> None: +def _api_entry(nav: list[NavItem]) -> dict[str, str | list[NavItem]]: + """The `mkdocs.yml` placeholder entry the API reference is spliced into.""" + for entry in nav: + if isinstance(entry, dict) and "API Reference" in entry: + return entry + raise SystemExit("build_config: no 'API Reference' entry found in mkdocs.yml nav") + + +def _validate_nav(nav: list[NavItem], docs_dir: Path) -> None: """Fail on nav/page drift in either direction. Zensical (0.0.48) ships a nav entry for a nonexistent page as a broken @@ -52,39 +155,79 @@ def _validate_nav(nav: list, docs_dir: Path) -> None: exempt from the orphan check: its nav is spliced in from the same generator that writes the files, so it cannot drift. """ - pages = _nav_pages(nav) + pages = set(nav_page_paths(nav)) # Containment before existence: `docs_dir / page` would happily resolve - # an absolute value or a `../` escape against the wrong root. - if escaping := sorted(p for p in pages if p.startswith("/") or posixpath.normpath(p).startswith("..")): - raise SystemExit(f"build_config: nav references pages outside docs/: {escaping}") + # a `../` escape against the wrong root. + if escaping := sorted(page for page in pages if posixpath.normpath(page).startswith("..")): + raise SystemExit(f"build_config: nav references pages outside {docs_dir}: {escaping}") if missing := sorted(page for page in pages if not (docs_dir / page).is_file()): - raise SystemExit(f"build_config: nav references pages that don't exist under docs/: {missing}") + raise SystemExit(f"build_config: nav references pages that don't exist under {docs_dir}: {missing}") # Dot-directories (e.g. `.overrides` theme files) are not pages: the site # builder ignores them, so the orphan check must too. relative = (page.relative_to(docs_dir) for page in docs_dir.rglob("*.md")) on_disk = {page.as_posix() for page in relative if not any(part.startswith(".") for part in page.parts)} if orphaned := sorted(page for page in on_disk - pages if not page.startswith("api/")): - raise SystemExit(f"build_config: pages under docs/ that no nav entry reaches: {orphaned}") + raise SystemExit(f"build_config: pages under {docs_dir} that no nav entry reaches: {orphaned}") -def build_config() -> None: - config = yaml.safe_load((ROOT / "mkdocs.yml").read_text(encoding="utf-8")) +def build_config(lang: str | None = None, root: Path = ROOT) -> Path: + """Write the English config, or with `lang` that language site's config; returns the file written. - api_nav = gen_ref_pages.generate() - if not api_nav: - raise SystemExit("build_config: gen_ref_pages produced no API pages — did the src/ layout move?") - for entry in config["nav"]: - if isinstance(entry, dict) and "API Reference" in entry: - entry["API Reference"] = api_nav - break + `root` is the repository the config is read from and written to (a + scratch tree in tests); the English API reference is always generated + from this checkout's `src/`. + """ + config: dict[str, Any] = yaml.safe_load((root / "mkdocs.yml").read_text(encoding="utf-8")) + try: + # No registry yet means English is the only site there is. + no_languages = lang is None and not (root / LANGUAGES_FILE).is_file() + languages = [] if no_languages else load_registry(root).languages + except ValueError as exc: + raise SystemExit(f"build_config: {exc}") from exc + if languages: # a switcher listing English alone is noise + config.setdefault("extra", {})["alternate"] = alternate(languages) + + if lang is None: + api_nav: list[NavItem] = gen_ref_pages.generate() + if not api_nav: + raise SystemExit("build_config: gen_ref_pages produced no API pages — did the src/ layout move?") + _api_entry(config["nav"])["API Reference"] = api_nav + docs_dir = root / "docs" + output = root / "mkdocs.gen.yml" else: - raise SystemExit("build_config: no 'API Reference' entry found in mkdocs.yml nav") + language = next((candidate for candidate in languages if candidate.code == lang), None) + if language is None: + raise SystemExit(f"build_config: unknown language {lang!r} (see {LANGUAGES_FILE})") + docs_dir, titles_file = staged_docs_dir(lang, root), staged_titles_file(lang, root) + try: # written last by `stage`, so its presence means the tree beside it is complete + titles: dict[str, str] = json.loads(titles_file.read_text(encoding="utf-8")) + except OSError as exc: + raise SystemExit( + f"build_config: cannot read {titles_file} (run translations.py stage --lang {lang})" + ) from exc + _api_entry(config["nav"])["API Reference"] = API_REFERENCE_URL + config["nav"] = language_nav(config["nav"], titles) + # No API reference on a language site, so no mkdocstrings pass either. + plugins: list[str | dict[str, Any]] = config["plugins"] + config["plugins"] = [p for p in plugins if (next(iter(p)) if isinstance(p, dict) else p) != "mkdocstrings"] + config["theme"]["language"] = language.theme + # Zensical resolves docs_dir/site_dir against the config file and + # rejects absolute paths. + config["docs_dir"] = docs_dir.relative_to(root).as_posix() + config["site_dir"] = f"site/{lang}" + config["site_url"] = config["site_url"].rstrip("/") + f"/{lang}/" + output = root / f"mkdocs.{lang}.gen.yml" + + _validate_nav(config["nav"], docs_dir) + output.write_text(yaml.safe_dump(config, sort_keys=False, allow_unicode=True), encoding="utf-8") + return output - _validate_nav(config["nav"], ROOT / "docs") - output = ROOT / "mkdocs.gen.yml" - output.write_text(yaml.safe_dump(config, sort_keys=False, allow_unicode=True), encoding="utf-8") +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--lang", metavar="CODE", help="write the config of this language site instead of English") + build_config(parser.parse_args().lang) if __name__ == "__main__": - build_config() + main() diff --git a/scripts/docs/gen_ref_pages.py b/scripts/docs/gen_ref_pages.py index 26916e8c39..42c75616a2 100644 --- a/scripts/docs/gen_ref_pages.py +++ b/scripts/docs/gen_ref_pages.py @@ -14,13 +14,14 @@ import shutil from pathlib import Path +from typing import TypeAlias import griffe # A MkDocs/Zensical nav is a list of entries, each either `{title: url}` for a # page or `{title: [children]}` for a section (a bare `url` string attaches # a section index page, courtesy of the `navigation.indexes` feature). -NavItem = "str | dict[str, str | list[NavItem]]" +NavItem: TypeAlias = "str | dict[str, str | list[NavItem]]" ROOT = Path(__file__).parent.parent.parent API_DIR = ROOT / "docs" / "api" diff --git a/scripts/docs/llms_txt.py b/scripts/docs/llms_txt.py index 614690f3e3..0ac09399a8 100644 --- a/scripts/docs/llms_txt.py +++ b/scripts/docs/llms_txt.py @@ -77,9 +77,9 @@ # rendition), never under-validated; and span pairing is bounded by blank # lines rather than full block structure. _FENCE = re.compile(r"^[ \t]*(`{3,}|~{3,})") -_CODE_SPAN = re.compile(r"(?s)(?.*?)^(?:---|\.\.\.)[ \t]*(?:\n|\Z)", flags=re.MULTILINE | re.DOTALL) +FRONT_MATTER = re.compile(r"\A---[ \t]*\n(?P.*?)^(?:---|\.\.\.)[ \t]*(?:\n|\Z)", flags=re.MULTILINE | re.DOTALL) class _BuildError(Exception): @@ -108,7 +108,7 @@ def _split_frontmatter(text: str) -> tuple[dict[str, Any], str]: isn't a YAML mapping is page content, not frontmatter; an empty block is frontmatter with no meta. """ - match = _FRONTMATTER.match(text) + match = FRONT_MATTER.match(text) if match is None: return {}, text try: @@ -225,7 +225,7 @@ def _code_intervals(markdown: str) -> list[tuple[int, int]]: previous_end = 0 for fence_start, fence_end in [*fences, (len(markdown), len(markdown))]: segment = markdown[previous_end:fence_start] - for pattern in (_CODE_SPAN, _HTML_COMMENT): + for pattern in (CODE_SPAN, _HTML_COMMENT): intervals += [(previous_end + m.start(), previous_end + m.end()) for m in pattern.finditer(segment)] previous_end = fence_end return intervals diff --git a/scripts/docs/translations.py b/scripts/docs/translations.py new file mode 100644 index 0000000000..a53ea28191 --- /dev/null +++ b/scripts/docs/translations.py @@ -0,0 +1,1095 @@ +"""Machine-translate the documentation and stage each language site for the build. + +English under `docs/` is the source of truth. Every language in +`i18n/languages.yml` gets generated pages under `i18n//pages/`, driven by +that language's hand-written `instructions.md` and `glossary.json` plus the +shared `i18n/general-prompt.md`. Corrections go into those inputs, never into +the generated pages. Each generated page records, in its own front matter, the +English section hashes it reflects; nothing else tracks state. + +Usage (from the repository root): + python scripts/docs/translations.py status [--lang CODE] + python scripts/docs/translations.py translate --lang CODE [--pages PATH ...] + python scripts/docs/translations.py stage [--lang CODE] + +Only `translate` calls the model (credentials come from the environment, e.g. +`ANTHROPIC_API_KEY`) and needs the `translate` dependency group; +`DOCS_TRANSLATE_MODEL`, if set, replaces the registry's `model` for that run. +Exit codes: 0 done, 1 some page failed, 2 configuration or credential error. +""" + +import argparse +import hashlib +import importlib +import json +import os +import posixpath +import re +import shutil +import sys +from collections import Counter +from collections.abc import Callable, Iterator, Sequence +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Literal, Protocol, cast, get_args + +import markdown +import yaml +import zensical.config +from build_config import ROOT, Language, Registry, load_registry, nav_page_paths, staged_docs_dir, staged_titles_file +from llms_txt import CODE_SPAN, FRONT_MATTER, page_url +from zensical.config import ConfigurationError + +# Zensical annotates its config loader `-> dict`; this is the shape its own renderer relies on. +parse_mkdocs_config = cast("Callable[[str], dict[str, Any]]", getattr(zensical.config, "parse_mkdocs_config")) + +# Bumped only when the generated-file contract changes; older files then read as missing. +TOOL_VERSION = 1 +# `max_tokens` per request: several times the longest page, leaving room for +# any thinking the model does, while inside the ceiling streaming allows. +OUTPUT_TOKEN_BUDGET = 64_000 +# Repair turns fed back to the model after the first reply before a page fails. +MAX_REPAIRS = 2 +NOTICES_PAGE = "i18n/notices.md" +# The nav page the notices link to for how the translations are made. +TRANSLATIONS_DOC = "translations.md" +API_DIR = "api" + +Status = Literal["missing", "outdated", "current"] +NoticeKind = Literal["translated", "outdated", "english"] + + +class ConfigError(Exception): + """Unusable configuration, inputs or credentials (exit code 2).""" + + +class PageError(Exception): + """One page cannot be translated; the run continues with the next page.""" + + +# ---- Markdown structure: fences, headings, code spans, links ---- + +# An ATX heading the way the renderer reads it: hashes at column 0, no space +# required after them, an optional closing hash run, backslash escapes honoured. +HEADING = re.compile(r"^(?P#{1,6})(?!#)(?P(?:\\.|[^\\\n])*?)#*[ \t]*$") +# A heading's trailing attr_list block(s), matched with or without the whitespace +# attr_list itself needs, so blocks the model glued to CJK text or doubled are +# still seen; `body` is the last block's, the one attr_list reads. Each block +# parses one way only (`{` to the next `}`), so no run of them can backtrack. +HEADING_ATTRS = re.compile(r"(?:[ \t]*\{(?P[^}\n]*)\})+[ \t]*$") +ESCAPE = re.compile(r"\\(?P[!\"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~])") +# An underscore that is not word-internal turns into emphasis before attr_list +# reads the block, so it must be written escaped inside `{#...}`. +_BOUNDARY_UNDERSCORE = re.compile(r"(?`{3,}|~{3,})(?P[^\n]*)$") +LINK = re.compile(r"!?\[[^\]]*\]\((?P[^)\s]*)[^)]*\)") +_URL = re.compile(r"[a-zA-Z][a-zA-Z0-9+.-]*://(?:(?![<>)\]])[!-~])+") +_TAG = re.compile(r"\n]*>|", re.DOTALL) +_MARKER = re.compile(r"^[ \t]*(?P!!!|\?\?\?\+?|===)[ \t]+(?P[\w-]+)?") +# A bullet or ordered item at any depth, and a table row: block structure whose +# count a faithful translation keeps, whatever the language. +_LIST_ITEM = re.compile(r"^[ \t]*(?:[-*+]|\d{1,9}[.)])(?:[ \t]|$)") +_TABLE_ROW = re.compile(r"^[ \t]*\|") +_WRAPPER = re.compile( + r"\A[ \t]*\n*(?P`{3,}|~{3,})[^\n]*\n(?P.*)\n(?P`{3,}|~{3,})[ \t]*\n*\Z", re.DOTALL +) +# Bracketed text followed by `(` or `[` is a link label, never a placeholder. +_PLACEHOLDERS = ( + re.compile(r"\[\s*(?:translation|rest of|remaining)[^\]]*\](?![(\[])", re.IGNORECASE), + re.compile(r"\((?:content )?omitted[^)]*\)", re.IGNORECASE), + re.compile(r"\[\s*\.\.\.\s*\](?![(\[])"), +) +_COMMENT = re.compile(r"", re.DOTALL) +_ABRIDGED = re.compile(r"\b(?:omitted|continues|truncated|abridged|remaining|rest of)\b", re.IGNORECASE) +# A language site builds no API reference; links into `api/` go to the English one. +_API_LINK = re.compile(r"\]\((?:\.\./)*(?Papi/[^)#\s]+\.md)") + + +@dataclass(frozen=True) +class Heading: + """One ATX heading; `text` excludes any trailing `{...}` attr block.""" + + line: int + level: int + text: str + anchor: str | None + + +@dataclass(frozen=True) +class FenceLines: + """Line indices of one fenced block; an unclosed fence runs to the last line.""" + + opener: int + closer: int + closed: bool + + +def split_front_matter(text: str) -> tuple[str | None, str]: + """`(front matter YAML, body)`; the YAML is None when the page has no front matter block.""" + match = FRONT_MATTER.match(text) + return (match["block"], text[match.end() :]) if match else (None, text) + + +def fence_ranges(lines: Sequence[str]) -> list[FenceLines]: + """The fenced blocks of `lines`, in order (CommonMark opener/closer rules).""" + found: list[FenceLines] = [] + opener: re.Match[str] | None = None + start = 0 + for index, line in enumerate(lines): + match = _FENCE.match(line) + if opener is None: + if match and not (match["run"][0] == "`" and "`" in match["info"]): + opener, start = match, index + continue + run = opener["run"] + if match and match["run"][0] == run[0] and len(match["run"]) >= len(run) and not match["info"].strip(): + found.append(FenceLines(start, index, closed=True)) + opener = None + if opener is not None: + found.append(FenceLines(start, len(lines) - 1, closed=False)) + return found + + +def _blank(text: str, spans: list[tuple[int, int]]) -> str: + """Replace each span with same-length spaces, keeping newlines so positions and lines survive.""" + chars = list(text) + for start, end in spans: + for index in range(start, end): + if chars[index] != "\n": + chars[index] = " " + return "".join(chars) + + +def _mask_fences(text: str) -> str: + """Blank every fenced block, marker lines included (same length, same lines).""" + lines = text.split("\n") + offsets = [0] + for line in lines: + offsets.append(offsets[-1] + len(line) + 1) + return _blank(text, [(offsets[fence.opener], offsets[fence.closer + 1] - 1) for fence in fence_ranges(lines)]) + + +def parse_headings(text: str) -> list[Heading]: + """The ATX headings of `text` in order, with the id any trailing `{#...}` block pins.""" + found: list[Heading] = [] + for index, line in enumerate(_mask_fences(text).split("\n")): + if not (match := HEADING.match(line)): + continue + heading, anchor = match["text"].strip(), None + if attrs := HEADING_ATTRS.search(heading): + tokens = attrs["body"].removeprefix(":").split() # `{: ...}` is attr_list's other spelling + ids = [token[1:] for token in tokens if token.startswith("#") and len(token) > 1] + anchor = ESCAPE.sub(r"\g", ids[-1]) if ids else None + heading = heading[: attrs.start()].rstrip() + found.append(Heading(index, len(match["hashes"]), heading, anchor)) + return found + + +def anchor_source_form(anchor: str) -> str: + """The rendered id as it must be written inside `{#...}` to survive the emphasis pass.""" + return _BOUNDARY_UNDERSCORE.sub(r"\\_", anchor) + + +def mask_code(text: str) -> str: + """Blank fenced blocks and inline code spans (same length, same lines).""" + masked = _mask_fences(text) + return _blank(masked, [match.span() for match in CODE_SPAN.finditer(masked)]) + + +def mask(text: str) -> str: + """Blank everything that is not translatable prose: code, link targets, bare URLs, HTML tags.""" + masked = mask_code(text) + spans = [match.span("target") for match in LINK.finditer(masked)] + spans += [match.span() for pattern in (_URL, _TAG) for match in pattern.finditer(masked)] + return _blank(masked, spans) + + +def code_spans(text: str) -> Counter[str]: + """The inline code spans of the prose (fenced code excluded), counted by content.""" + return Counter(match.group(2).strip() for match in CODE_SPAN.finditer(_mask_fences(text))) + + +def link_targets(text: str) -> Counter[str]: + """The targets of the markdown links and images written in the prose of `text`, counted.""" + return Counter(match["target"] for match in LINK.finditer(mask_code(text))) + + +def markers(text: str) -> list[str]: + """The admonition (`!!!`/`???`) markers with their type keyword, and the tab (`===`) markers, in order.""" + matches = [match for line in _mask_fences(text).split("\n") if (match := _MARKER.match(line))] + return ["===" if match["marker"] == "===" else f"{match['marker']} {match['kind'] or '?'}" for match in matches] + + +def block_counts(text: str) -> dict[str, int]: + """How many list items and table rows the prose of `text` has (fenced code excluded).""" + lines = mask_code(text).split("\n") + patterns = {"list items": _LIST_ITEM, "table rows": _TABLE_ROW} + return {kind: sum(1 for line in lines if pattern.match(line)) for kind, pattern in patterns.items()} + + +def abridgements(text: str) -> Counter[str]: + """Placeholder phrases and "omitted" comments of the kind a model leaves where it cut a page short.""" + prose = mask(text) + found = [match.group(0) for pattern in _PLACEHOLDERS for match in pattern.finditer(prose)] + found += [c.group(0) for c in _COMMENT.finditer(mask_code(text)) if _ABRIDGED.search(c["body"])] + return Counter(found) + + +# ---- Sections, hashes and the provenance front matter of a generated page ---- + + +def sections(body: str) -> list[str]: + """The page as intro + one string per `##` section; `"".join(sections(body)) == body`. + + Blank lines just above a `##` heading belong to that heading's section, so + a section's bytes never depend on the section after it. + """ + lines = body.split("\n") + offsets = [0] + for line in lines: + offsets.append(offsets[-1] + len(line) + 1) + starts = [0] + for heading in parse_headings(body): + if heading.level != 2: + continue + first = heading.line + while first > 0 and not lines[first - 1].strip(): + first -= 1 + starts.append(offsets[first]) + return [body[start:end] for start, end in zip(starts, [*starts[1:], len(body)])] + + +def section_label(section: str, index: int) -> str: + """How a section is named in prompts and status lines.""" + if index == 0: + return "the introduction (everything before the first `##` heading)" + return section.strip("\n").split("\n", 1)[0].strip() + + +def section_hashes(body: str) -> list[str]: + """The first 16 hex digits of each section's sha256.""" + return [hashlib.sha256(section.encode("utf-8")).hexdigest()[:16] for section in sections(body)] + + +def with_provenance(body: str, hashes: Sequence[str]) -> str: + """The generated file: the English section hashes in front matter, then the translated body.""" + record = {"translation": {"sections": list(hashes), "tool": TOOL_VERSION}} + header = yaml.safe_dump(record, sort_keys=False, default_flow_style=None, width=2**16) + return f"---\n{header}---\n{body}" + + +def read_provenance(front_matter: str | None) -> tuple[str, ...] | None: + """The section hashes a generated page records, or None if absent, unreadable or from another tool version.""" + try: + record = yaml.safe_load(front_matter or "")["translation"] + return tuple(str(value) for value in record["sections"]) if record["tool"] == TOOL_VERSION else None + except (yaml.YAMLError, TypeError, KeyError): + return None + + +# ---- The repository: registry, nav pages, prompt inputs and the renderer's heading ids ---- + + +@dataclass(frozen=True) +class Term: + source: str + target: str + note: str = "" + avoid: Sequence[str] = () + + +@dataclass(frozen=True) +class Glossary: + keep: Sequence[str] + terms: Sequence[Term] + + +def load_glossary(path: Path) -> Glossary: + """Parse `glossary.json`: `keep` strings and `terms` objects with the `Term` fields. + + Raises: + ConfigError: The file is missing, not JSON, or not that shape. + """ + try: + raw = json.loads(path.read_text(encoding="utf-8")) + return Glossary(tuple(raw["keep"]), tuple(Term(**entry) for entry in raw["terms"])) + except (OSError, json.JSONDecodeError, TypeError, KeyError) as exc: + raise ConfigError(f"{path}: {exc!r}") from exc + + +@dataclass(frozen=True) +class Inputs: + """One language's prompt inputs.""" + + language: Language + general_prompt: str + instructions: str + glossary: Glossary + + +@dataclass(frozen=True) +class Page: + """A translatable page: its display key, English source file and generated translation file.""" + + key: str + source: Path + target: Path + + +@dataclass(frozen=True) +class Notice: + title: str + body: str + + +def parse_notices(body: str) -> dict[str, Notice]: + """The `##` sections of a notices page keyed by their pinned id.""" + lines = body.split("\n") + headings = [heading for heading in parse_headings(body) if heading.level == 2] + found: dict[str, Notice] = {} + for position, heading in enumerate(headings): + end = headings[position + 1].line if position + 1 < len(headings) else len(lines) + if heading.anchor: + found[heading.anchor] = Notice(heading.text, "\n".join(lines[heading.line + 1 : end]).strip("\n")) + return found + + +@dataclass +class Repo: + """Everything the commands read from a checkout rooted at `root`.""" + + root: Path + docs: Path + i18n: Path + registry: Registry + prose_pages: list[str] + translatable: list[str] + renderer: markdown.Markdown + rendered_ids: dict[str, list[str]] = field(init=False, default_factory=dict[str, list[str]], repr=False) + + def language(self, code: str) -> Language: + for language in self.registry.languages: + if language.code == code: + return language + known = ", ".join(language.code for language in self.registry.languages) + raise ConfigError(f"unknown language {code!r} (i18n/languages.yml has: {known})") + + def inputs(self, language: Language) -> Inputs: + try: + general = (self.i18n / "general-prompt.md").read_text(encoding="utf-8") + instructions = (self.i18n / language.code / "instructions.md").read_text(encoding="utf-8") + except OSError as exc: + raise ConfigError(str(exc)) from exc + return Inputs(language, general, instructions, load_glossary(self.i18n / language.code / "glossary.json")) + + def pages(self, language: Language) -> list[Page]: + """The language's translatable pages in nav order, then the notices page.""" + generated = self.i18n / language.code / "pages" + pages = [Page(key, self.docs / key, generated / key) for key in self.translatable] + return [*pages, Page(NOTICES_PAGE, self.root / NOTICES_PAGE, self.i18n / language.code / "notices.md")] + + def heading_ids(self, body: str) -> list[str]: + """The ids the site renderer gives the page's headings, paired one-to-one with `parse_headings(body)`. + + Rendered once per distinct body: every language stages the same English pages. + + Raises: + PageError: The renderer sees headings the source scan does not (setext, indented, HTML). + """ + if body not in self.rendered_ids: + self.renderer.reset() + self.renderer.convert(body) + tokens = cast("list[dict[str, Any]]", getattr(self.renderer, "toc_tokens", [])) + ids, found = [str(token["id"]) for token in _flatten(tokens)], parse_headings(body) + if len(ids) != len(found): + raise PageError(f"the page renders {len(ids)} headings but {len(found)} are ATX headings at column 0") + self.rendered_ids[body] = ids + return self.rendered_ids[body] + + +def _flatten(tokens: list[dict[str, Any]]) -> Iterator[dict[str, Any]]: + for token in tokens: + yield token + yield from _flatten(cast("list[dict[str, Any]]", token["children"])) + + +def _renderer(root: Path) -> markdown.Markdown: + """A python-markdown instance configured with the extensions the site build uses.""" + try: + config = parse_mkdocs_config(str(root / "mkdocs.yml")) + except (OSError, yaml.YAMLError, ConfigurationError) as exc: + raise ConfigError(f"cannot load {root / 'mkdocs.yml'}: {exc}") from exc + configs = cast("dict[str, dict[str, Any]]", config["mdx_configs"]) + # Snippet paths resolve against the build's working directory, the + # repository root; only heading ids are read here, so a missing one is not + # this renderer's failure. + snippets = configs.setdefault("pymdownx.snippets", {}) + snippets["base_path"] = [str(root / base) for base in cast("list[str]", snippets.get("base_path", ["."]))] + snippets["check_paths"] = False + return markdown.Markdown(extensions=config["markdown_extensions"], extension_configs=configs) + + +def _excluded(page: str, patterns: Sequence[str]) -> bool: + """`dir/**` excludes a subtree; any other pattern is an exact page path.""" + return any(page.startswith(p.removesuffix("**")) if p.endswith("/**") else page == p for p in patterns) + + +def read_english(path: Path) -> str: + """An English page's body; any front matter is dropped here, once, so no later step ever sees it. + + Raises: + ConfigError: The file cannot be read. + """ + try: + return split_front_matter(path.read_text(encoding="utf-8"))[1] + except OSError as exc: + raise ConfigError(f"cannot read {path}: {exc}") from exc + + +def load_repo(root: Path) -> Repo: + """Read the registry and the nav of the checkout at `root`, and check its English notices. + + Raises: + ConfigError: Any of them is missing or malformed. + """ + try: + registry = load_registry(root) + config: object = yaml.safe_load((root / "mkdocs.yml").read_text(encoding="utf-8")) + except (OSError, yaml.YAMLError, ValueError) as exc: # build_config reports registry problems as ValueError + raise ConfigError(str(exc)) from exc + nav = cast("dict[str, Any]", config).get("nav") if isinstance(config, dict) else None + if not isinstance(nav, list): + raise ConfigError(f"{root / 'mkdocs.yml'}: no nav list") + prose = [p for p in nav_page_paths(cast("list[Any]", nav)) if p.endswith(".md") and not p.startswith(f"{API_DIR}/")] + notices = parse_notices(read_english(root / NOTICES_PAGE)) + if missing := [kind for kind in get_args(NoticeKind) if kind not in notices]: + raise ConfigError(f"{root / NOTICES_PAGE}: missing `## ... {{#id}}` sections for {missing}") + translatable = [page for page in prose if not _excluded(page, registry.exclude)] + return Repo(root, root / "docs", root / "i18n", registry, prose, translatable, _renderer(root)) + + +# ---- Page status ---- + + +@dataclass(frozen=True) +class Translation: + """A generated page: the English section hashes it records (one per section of its body) and its body.""" + + sections: tuple[str, ...] + body: str + + +def recorded_sections(translation: Translation) -> dict[str, str]: + """A generated page's sections keyed by the English section hash each records.""" + return dict(zip(translation.sections, sections(translation.body), strict=True)) + + +@dataclass(frozen=True) +class PageState: + """One page classified against the current English text; `translation` is None unless usable.""" + + page: Page + english: str + hashes: list[str] + translation: Translation | None + status: Status + changed: list[int] = field(default_factory=list[int]) + note: str = "" + + +def classify(page: Page) -> PageState: + """Compare the page's recorded section hashes with the current English ones. + + Raises: + ConfigError: The English source cannot be read. + """ + english = read_english(page.source) + hashes = section_hashes(english) + if not page.target.is_file(): + return PageState(page, english, hashes, None, "missing") + front_matter, body = split_front_matter(page.target.read_text(encoding="utf-8")) + recorded = read_provenance(front_matter) + if recorded is None or len(recorded) != len(sections(body)): + return PageState(page, english, hashes, None, "missing", note="unreadable front matter, retranslated whole") + translation = Translation(recorded, body) + if tuple(hashes) == recorded: + return PageState(page, english, hashes, translation, "current") + changed = [index for index, value in enumerate(hashes) if value not in set(recorded)] + labels = ", ".join(section_label(sections(english)[index], index) for index in changed) + note = f"English changed in: {labels}" if changed else "English sections removed or reordered" + return PageState(page, english, hashes, translation, "outdated", changed, note) + + +def removable(repo: Repo, language: Language) -> list[str]: + """Generated pages whose English page left the translatable set (the fix is `git rm`).""" + generated = repo.i18n / language.code / "pages" + on_disk = sorted(path.relative_to(generated).as_posix() for path in generated.rglob("*.md")) + return [page for page in on_disk if page not in set(repo.translatable)] + + +# ---- Prompts and the model client ---- + + +@dataclass(frozen=True) +class Message: + role: Literal["user", "assistant"] + content: str + + +@dataclass +class Usage: + input_tokens: int = 0 + output_tokens: int = 0 + cache_write_tokens: int = 0 + cache_read_tokens: int = 0 + + def add(self, other: "Usage") -> None: + self.input_tokens += other.input_tokens + self.output_tokens += other.output_tokens + self.cache_write_tokens += other.cache_write_tokens + self.cache_read_tokens += other.cache_read_tokens + + def __str__(self) -> str: + return ( + f"{self.input_tokens} input / {self.output_tokens} output / " + f"{self.cache_write_tokens} cache-write / {self.cache_read_tokens} cache-read tokens" + ) + + +@dataclass(frozen=True) +class Completion: + text: str + usage: Usage + stop_reason: str | None = "end_turn" + + +class Translator(Protocol): + """Anything that answers a conversation (`ConfigError`: credentials rejected; `PageError`: request failed).""" + + def complete(self, *, model: str, system: str, messages: Sequence[Message], max_tokens: int) -> Completion: ... + + +def anthropic_translator() -> Translator: + """The Claude Messages API client, streaming, with the system prompt as one cached block. + + `anthropic` lives in the non-default `translate` dependency group, so it is + imported here, by name: offline commands and type checking never need it. + + Raises: + ConfigError: The `translate` dependency group is not installed, or no credentials are configured. + """ + try: + sdk = importlib.import_module("anthropic") + except ImportError as exc: + raise ConfigError( + "the anthropic package is not installed; run with `uv run --frozen --group translate`" + ) from exc + # The SDK resolves every credential source it knows at construction; fail + # here, before any page work, rather than on the first request. + try: + client = sdk.Anthropic() + except sdk.AnthropicError as exc: # e.g. a credential profile it was pointed at is unreadable + raise ConfigError(f"cannot set up the API client: {exc}") from exc + if not (client.api_key or client.auth_token or client.credentials): + raise ConfigError("no API credentials: set ANTHROPIC_API_KEY") + + class AnthropicTranslator: + def complete(self, *, model: str, system: str, messages: Sequence[Message], max_tokens: int) -> Completion: + prefix = [{"type": "text", "text": system, "cache_control": {"type": "ephemeral", "ttl": "1h"}}] + turns = [{"role": message.role, "content": message.content} for message in messages] + try: + with client.messages.stream(model=model, max_tokens=max_tokens, system=prefix, messages=turns) as s: + reply = s.get_final_message() + except (sdk.AuthenticationError, sdk.PermissionDeniedError) as exc: + raise ConfigError(f"the API rejected the credentials: {exc.message}") from exc + except sdk.APIError as exc: + raise PageError(f"API request failed: {exc.message}") from exc + usage = Usage( + reply.usage.input_tokens, + reply.usage.output_tokens, + reply.usage.cache_creation_input_tokens or 0, + reply.usage.cache_read_input_tokens or 0, + ) + text = "".join(block.text for block in reply.content if block.type == "text") + return Completion(text, usage, reply.stop_reason) + + return AnthropicTranslator() + + +def glossary_prompt(glossary: Glossary) -> str: + lines = ["## Glossary", "", "These terms always stay in English, spelled exactly like this:", ""] + lines += [f"- {term}" for term in glossary.keep] + if glossary.terms: + lines += ["", "Use these renderings; the notes are binding:", ""] + for term in glossary.terms: + entry = f"- {term.source} → {term.target}" + if term.avoid: + entry += f" (never: {', '.join(term.avoid)})" + if term.note: + entry += f". {term.note}" + lines.append(entry) + return "\n".join(lines) + + +def system_prompt(inputs: Inputs) -> str: + """The cacheable prefix shared by every page of a language: rules, instructions, glossary.""" + header = f"# Target language: {inputs.language.name} (`{inputs.language.code}`)" + parts = (inputs.general_prompt, header, inputs.instructions, glossary_prompt(inputs.glossary)) + return "\n\n".join(part.strip() for part in parts) + + +def translate_request(english: str) -> str: + return ( + "Translate the following Markdown page. Return only the translated page.\n\n" + f"\n{english}\n" + ) + + +def update_request(english: str, changed: Sequence[str], previous: str) -> str: + listed = "\n".join(f"- {label}" for label in changed) + return ( + "This page was translated before. Retranslate it: translate the sections listed below\n" + "afresh from the current English, applying the current language instructions and glossary\n" + "(their previous wording may be outdated); everywhere else, reproduce the previous\n" + "translation line by line, changing nothing. Keep the retranslated sections consistent in\n" + "terminology and tone with their surroundings. A section is the introduction before the\n" + "first `##` heading, or one `##` heading with everything under it.\n\n" + f"Sections to retranslate:\n\n{listed}\n\n" + f"Current English page:\n\n\n{english}\n\n\n" + f"Previous translation of the page:\n\n\n{previous}\n\n\n" + "Return only the full translated page." + ) + + +def repair_request(findings: Sequence[str]) -> str: + listed = "\n".join(f"- {finding}" for finding in findings) + return ( + "Your translation broke the following structural rules. Fix each problem and return the\n" + f"full corrected page, changing nothing else:\n\n{listed}" + ) + + +# ---- Re-imposing the English structure on a reply, and validating what cannot be re-imposed ---- + + +@dataclass(frozen=True) +class Mismatch: + """A reply whose structure differs from the English; each finding says what to fix.""" + + findings: list[str] + + +def unwrap(english: str, reply: str) -> str: + """Drop a code fence wrapping the whole reply, and match the English trailing newline.""" + match = None if english.startswith(("```", "~~~")) else _WRAPPER.match(reply) + if match and match["close"][0] == match["open"][0] and len(match["close"]) >= len(match["open"]): + reply = match["body"] + if english.endswith("\n") and not reply.endswith("\n"): + reply += "\n" + return reply + + +def reimpose(english: str, ids: Sequence[str], reply: str) -> str | Mismatch: + """Copy over the reply what the model must never change, and check the links it placed. + + `ids` are the renderer's ids for the English headings (`Repo.heading_ids`). + Fences are copied opener-through-closer within each section and `{#id}` + blocks are pinned onto the translated headings positionally; each needs + matching counts. Link and image targets are never moved (a translation may + reorder links): each section must carry the same targets as its English, + however placed. Checks go section by section, so any assembly of passing + sections passes too. + """ + findings: list[str] = [] + text = _restore_fences(english, reply, findings) + text = _pin_headings(english, ids, text, findings) + _check_targets(english, text, findings) + return Mismatch(findings) if findings else text + + +def _paired_sections(english: str, text: str) -> list[tuple[str, str, str]]: + """`(label, English section, its counterpart)` per section, or the pages whole if their counts differ.""" + want, got = sections(english), sections(text) + if len(want) != len(got): # the heading finding says so already + return [("the page", english, text)] + return [(section_label(source, index), source, output) for index, (source, output) in enumerate(zip(want, got))] + + +def _restore_fences(english: str, reply: str, findings: list[str]) -> str: + if unclosed := [fence for fence in fence_ranges(reply.split("\n")) if not fence.closed]: + findings.append(f"the code fence opened on line {unclosed[0].opener + 1} is never closed") + return reply + restored: list[str] = [] + for label, source, output in _paired_sections(english, reply): + kept, lines = source.split("\n"), output.split("\n") + want, got = fence_ranges(kept), fence_ranges(lines) + if len(want) != len(got): + findings.append( + f"{label}: {len(got)} code fences vs {len(want)} in the English: keep each where it is, add none" + ) + restored.append(output) + continue + result: list[str] = [] + cursor = 0 + for expected, found in zip(want, got): + result += lines[cursor : found.opener] + result += kept[expected.opener : expected.closer + 1] + cursor = found.closer + 1 + restored.append("\n".join([*result, *lines[cursor:]])) + return "".join(restored) + + +def _pin_headings(english: str, ids: Sequence[str], text: str, findings: list[str]) -> str: + want, got = parse_headings(english), parse_headings(text) + if len(want) != len(got): + findings.append(f"{len(got)} headings vs {len(want)} in the English: keep every heading, and no others") + return text + if wrong := [(w, g) for w, g in zip(want, got) if w.level != g.level]: + findings.extend(f"`{g.text}` is a level-{g.level} heading but `{w.text}` is level {w.level}" for w, g in wrong) + return text + lines = text.split("\n") + for heading, anchor in zip(got, ids, strict=True): # `Repo.heading_ids` pairs ids with these headings + lines[heading.line] = f"{'#' * heading.level} {heading.text} {{#{anchor_source_form(anchor)}}}" + return "\n".join(lines) + + +def _check_targets(english: str, text: str, findings: list[str]) -> None: + missing: Counter[str] = Counter() + extra: Counter[str] = Counter() + for _, source, output in _paired_sections(english, text): + expected, found = link_targets(source), link_targets(output) + missing += expected - found + extra += found - expected + if missing: + findings.append(f"missing links to {sorted(missing.elements())}: keep every link of the English where it is") + if extra: + findings.append(f"unexpected links to {sorted(extra.elements())}: add no links of your own") + + +def validate(english: str, output: str, glossary: Glossary, label: str = "the page") -> list[str]: + """Findings for what re-imposition cannot fix (an empty list means `output`, called `label`, passes).""" + findings: list[str] = [] + want, got = code_spans(english), code_spans(output) + if missing := sorted((want - got).elements()): + findings.append(f"missing inline code {missing}: copy every `code span` of the English") + if extra := sorted((got - want).elements()): + findings.append(f"unexpected inline code {extra}: use only the English `code spans`") + if markers(english) != markers(output): + findings.append( + f"block markers {markers(output)} vs {markers(english)} in the English:" + " keep each `!!!`/`???`/`===` line and its type" + ) + counted = block_counts(output) + findings.extend( + f"{label}: {counted[kind]} {kind} vs {count} in the English: translate them one for one, dropping none" + for kind, count in block_counts(english).items() + if counted[kind] != count + ) + folded = mask(output).casefold() + findings.extend( + f"banned rendering {avoid!r} of {term.source!r} appears: use {term.target!r}" + for term in glossary.terms + for avoid in term.avoid + if avoid.casefold() in folded + ) + # Whatever the English itself carries is content, not an abridgement. + placeholders = sorted((abridgements(output) - abridgements(english)).elements()) + findings.extend(f"placeholder {found!r}: translate the whole page, never abridge it" for found in placeholders) + return findings + + +# ---- translate ---- + + +@dataclass(frozen=True) +class Job: + """One page selected for translation: which sections the model rewrites, and the prior text to keep.""" + + state: PageState + open: list[int] + previous: Translation | None + + +def select_jobs(states: Sequence[PageState], pages: Sequence[str]) -> list[Job]: + """The missing and outdated pages with their changed sections open, or exactly `pages`, whole and fresh. + + Raises: + ConfigError: A `pages` entry is not a translatable page. + """ + if unknown := [page for page in pages if page not in {state.page.key for state in states}]: + raise ConfigError(f"not translatable pages (nav paths such as servers/tools.md): {unknown}") + if pages: # a previous translation would carry nothing forward and only anchor the model on it + return [_fresh(state) for state in states if state.page.key in pages] + return [ + _fresh(state) if state.translation is None else Job(state, state.changed, state.translation) + for state in states + if state.status != "current" + ] + + +def _fresh(state: PageState) -> Job: + """The whole page from the English alone.""" + return Job(state, list(range(len(state.hashes))), None) + + +def build_messages(job: Job) -> list[Message]: + english = job.state.english + if job.previous is None: + return [Message("user", translate_request(english))] + labels = [section_label(text, index) for index, text in enumerate(sections(english)) if index in job.open] + return [Message("user", update_request(english, labels, job.previous.body))] + + +def carry_forward(job: Job, output: str) -> str: + """Overwrite every section the model was not asked to rewrite with its previous translation. + + A section left closed is one whose English hash the previous file records + (that is how `classify` closes it), so the lookup cannot miss. + """ + if job.previous is None: + return output + prior = recorded_sections(job.previous) + paired = zip(job.state.hashes, sections(output), strict=True) + return "".join(text if index in job.open else prior[value] for index, (value, text) in enumerate(paired)) + + +def reassemble(repo: Repo, job: Job) -> str: + """The page rebuilt from its recorded translations alone, for a job with no open section (no model call). + + Raises: + PageError: The rebuilt page no longer fits the English structure. + """ + english = job.state.english + result = reimpose(english, repo.heading_ids(english), carry_forward(job, english)) + if isinstance(result, Mismatch): + raise PageError("; ".join(result.findings)) + return result + + +def _validate_open(english: str, body: str, job: Job, glossary: Glossary) -> list[str]: + """`validate` over the sections this run rewrites; a carried section is published text, not this run's to fix.""" + paired = enumerate(zip(sections(english), sections(body), strict=True)) + rewritten = [ + (section_label(source, index), source, output) for index, (source, output) in paired if index in job.open + ] + return [finding for label, source, output in rewritten for finding in validate(source, output, glossary, label)] + + +def translate_page(repo: Repo, inputs: Inputs, job: Job, translator: Translator, model: str, usage: Usage) -> str: + """Translate one page and return its body; token usage accumulates into `usage`. + + Raises: + PageError: The page could not be produced (API failure, refusal, or unrepairable structure). + ConfigError: The credentials were rejected. + """ + if not job.open: + return reassemble(repo, job) + english = job.state.english + ids = repo.heading_ids(english) + system, messages = system_prompt(inputs), build_messages(job) + findings: list[str] = [] + for _ in range(1 + MAX_REPAIRS): + completion = translator.complete(model=model, system=system, messages=messages, max_tokens=OUTPUT_TOKEN_BUDGET) + usage.add(completion.usage) + if completion.stop_reason == "max_tokens": + raise PageError(f"the reply was cut off at {OUTPUT_TOKEN_BUDGET} output tokens") + if completion.stop_reason == "refusal": + raise PageError("the model declined to translate this page") + result = reimpose(english, ids, unwrap(english, completion.text)) + if isinstance(result, str): # aligned, so it can be assembled: carry sections forward, pin today's ids on them + result = reimpose(english, ids, carry_forward(job, result)) + if isinstance(result, Mismatch): + findings = result.findings + elif not (findings := _validate_open(english, result, job, inputs.glossary)): # checked as it would be written + return result + messages += [Message("assistant", completion.text), Message("user", repair_request(findings))] + raise PageError(f"unfixed after {MAX_REPAIRS} repairs: " + "; ".join(findings)) + + +def command_translate(repo: Repo, args: argparse.Namespace, translator: Translator | None) -> int: + language = repo.language(args.lang) + inputs = repo.inputs(language) + jobs = select_jobs([classify(page) for page in repo.pages(language)], args.pages) + if not jobs: + print(f"{language.code}: nothing to translate") + return 0 + model = os.environ.get("DOCS_TRANSLATE_MODEL") or repo.registry.model # never recorded in the generated files + # Only a job with open sections calls the model; a run without one needs no client and no + # credentials. Otherwise both are set up here, so bad credentials fail before any page work. + if translator is None and any(job.open for job in jobs): + translator = anthropic_translator() + usage, failed = Usage(), False + for job in jobs: + page = job.state.page + try: + body = translate_page(repo, inputs, job, translator, model, usage) if translator else reassemble(repo, job) + except PageError as exc: + failed = True + print(f"error: {page.key}: {exc}", file=sys.stderr) + continue + page.target.parent.mkdir(parents=True, exist_ok=True) + page.target.write_text(with_provenance(body, job.state.hashes), encoding="utf-8", newline="\n") + print(f"translated: {page.key} ({len(job.open)} of {len(job.state.hashes)} sections)", flush=True) + print(f"usage: {usage}") + return 1 if failed else 0 + + +# ---- stage ---- + + +def serve(repo: Repo, state: PageState) -> tuple[str, NoticeKind]: + """What a language site shows for a page: its translation with today's English structure, else English.""" + if state.translation is None: + return state.english, "english" + if state.changed: # an edited section has no translation: today's structure goes onto the stored page as it is + body = state.translation.body + else: # sections only removed or reordered, if that: each keeps its translation, laid out in today's order + stored = recorded_sections(state.translation) + body = "".join(stored[value] for value in state.hashes) + try: + result = reimpose(state.english, repo.heading_ids(state.english), body) + except PageError as exc: + result = Mismatch([str(exc)]) + if isinstance(result, Mismatch): + print(f"{state.page.key}: staged in English ({'; '.join(result.findings)})", file=sys.stderr) + return state.english, "english" + return result, "translated" if state.status == "current" else "outdated" + + +def english_site(page: str) -> str: + """The English site root as a link from `page` on a language site reads it. + + The renderer resolves a page's relative links against its source path, so + this climbs out of the page's directory, then out of the language site. + """ + return "../" * page.count("/") + "../" + + +def render_notice(notice: Notice, kind: NoticeKind, page: str) -> str: + """The notice as an admonition (collapsed for translated pages) with its placeholder links filled. + + Links are relative to the staged page, so they hold under whatever path the + sites are served: the English page is the same path one site up, and the + translations page is this site's own `translations.md`. + """ + body = notice.body.replace("(ENGLISH_PAGE)", f"({english_site(page)}{page_url(page)})") + body = body.replace("(TRANSLATIONS_PAGE)", f"({posixpath.relpath(TRANSLATIONS_DOC, posixpath.dirname(page))})") + marker = "???" if kind == "translated" else "!!!" + title = notice.title.replace('"', "'") + lines = [f'{marker} note "{title}"', "", *(f" {line}" if line.strip() else "" for line in body.split("\n"))] + return "\n".join(lines) + + +def page_title(body: str) -> Heading | None: + """The page's first `#` heading, if it has one.""" + return next((heading for heading in parse_headings(body) if heading.level == 1), None) + + +def inject_notice(body: str, notice: str) -> str: + """Place the notice right after the page's first `#` heading, or first if there is none.""" + lines = body.split("\n") + title = page_title(body) + if title is None: + return f"{notice}\n\n{body}" + rest = "\n".join(lines[title.line + 1 :]).lstrip("\n") + return "\n".join([*lines[: title.line + 1], "", notice, "", rest]) + + +def stage(repo: Repo, language: Language) -> Path: + """Build `.build/i18n//docs`: the English tree minus `api/`, translations overlaid, notices injected. + + Beside it, `titles.json` records each staged page's `#` heading for + `build_config.py --lang` to title nav sections with. Only English and the + generated pages are read (never the prompt inputs), so nothing a language + maintainer edits by hand can break a site build. + """ + states = {state.page.key: state for state in map(classify, repo.pages(language))} + notices = parse_notices(serve(repo, states.pop(NOTICES_PAGE))[0]) + target, titles_file = staged_docs_dir(language.code, repo.root), staged_titles_file(language.code, repo.root) + # The titles file goes last, so it only ever sits beside a complete tree. + titles_file.unlink(missing_ok=True) + shutil.rmtree(target, ignore_errors=True) + + def left_out(directory: str, names: list[str]) -> list[str]: + return [n for n in names if n.startswith(".") or (n == API_DIR and Path(directory) == repo.docs)] + + shutil.copytree(repo.docs, target, ignore=left_out) + titles: dict[str, str] = {} + for page in repo.prose_pages: # a page excluded from translation is staged as its English page + body, kind = serve(repo, states[page]) if page in states else (read_english(repo.docs / page), "english") + # The one API reference is the English site's. + body = _API_LINK.sub(lambda match: f"]({english_site(page)}{page_url(match['page'])}", body) + if title := page_title(body): + titles[page] = title.text + body = inject_notice(body, render_notice(notices[kind], kind, page)) + (target / page).write_text(body, encoding="utf-8", newline="\n") + listing = json.dumps(titles, ensure_ascii=False, indent=0, sort_keys=True) + titles_file.write_text(listing + "\n", encoding="utf-8", newline="\n") + return target + + +def command_stage(repo: Repo, args: argparse.Namespace) -> int: + for language in [repo.language(args.lang)] if args.lang else repo.registry.languages: + target = stage(repo, language) + print(f"staged {language.code} at {target.relative_to(repo.root).as_posix()}", flush=True) + return 0 + + +# ---- status ---- + + +def command_status(repo: Repo, args: argparse.Namespace) -> int: + languages = [repo.language(args.lang)] if args.lang else repo.registry.languages + for language in languages: + states = [classify(page) for page in repo.pages(language)] + strays = removable(repo, language) + counts = Counter(state.status for state in states) + print( + f"{language.code} ({language.name}): {counts['missing']} missing, {counts['outdated']} outdated," + f" {counts['current']} current, {len(strays)} removable" + ) + for state in states: + if state.status != "current": + print(f" {state.status:<9} {state.page.key}" + (f" ({state.note})" if state.note else "")) + for page in strays: + print(f" {'removable':<9} {page} (git rm i18n/{language.code}/pages/{page})") + return 0 + + +# ---- Command line ---- + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="translations.py", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + commands = parser.add_subparsers(dest="command", required=True) + status = commands.add_parser("status", help="what each language is missing") + status.add_argument("--lang", metavar="CODE") + translate = commands.add_parser("translate", help="translate missing and outdated pages (calls the model)") + translate.add_argument("--lang", metavar="CODE", required=True) + translate.add_argument( + "--pages", nargs="+", metavar="PATH", default=[], help="re-translate exactly these pages from scratch" + ) + staged = commands.add_parser("stage", help="assemble .build/i18n/CODE/docs for the site build") + staged.add_argument("--lang", metavar="CODE", help="stage this language only (default: every language)") + return parser + + +def main(argv: Sequence[str] | None = None, *, root: Path = ROOT, translator: Translator | None = None) -> int: + """Run one command against the checkout at `root`; returns the exit code.""" + args = _parser().parse_args(argv) + try: + repo = load_repo(root) + if args.command == "status": + return command_status(repo, args) + if args.command == "translate": + return command_translate(repo, args, translator) + return command_stage(repo, args) + except ConfigError as exc: + print(f"translations: {exc}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/serve-docs.sh b/scripts/serve-docs.sh index 1418719bcd..df32e915cf 100755 --- a/scripts/serve-docs.sh +++ b/scripts/serve-docs.sh @@ -1,10 +1,13 @@ #!/usr/bin/env bash # -# Serve the v2 documentation locally with live reload. +# Serve the v2 documentation locally with live reload (the English site only). # # Regenerates the API reference and the concrete Zensical config, then serves # it. Re-run the script to pick up changes to `src/` (the API reference) or the -# nav; edits to prose pages under `docs/` are picked up by live reload. +# nav; edits to prose pages under `docs/` are picked up by live reload. The +# translated sites are never served here, so the language switcher's other +# entries 404 locally; preview them with `scripts/docs/build.sh` and any static +# file server over site/. # # Usage: # scripts/serve-docs.sh [...] diff --git a/tests/docs/__init__.py b/tests/docs/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/docs/test_build_config.py b/tests/docs/test_build_config.py new file mode 100644 index 0000000000..a0f285c2a3 --- /dev/null +++ b/tests/docs/test_build_config.py @@ -0,0 +1,125 @@ +"""The language-site parts of the docs build config generator (`scripts/docs/build_config.py`).""" + +import json +from pathlib import Path +from typing import Any, cast + +import build_config +import pytest +import yaml +from inline_snapshot import snapshot + +NAV: list[build_config.NavItem] = [ + {"MCP Python SDK": "index.md"}, + {"Servers": ["servers/index.md", {"Tools": "servers/tools.md"}]}, + {"Elsewhere": [{"Spec": "https://modelcontextprotocol.io/"}]}, + "troubleshooting.md", + {"API Reference": "api/"}, +] + +MKDOCS: dict[str, Any] = { + "site_name": "Test docs", + "site_url": "https://docs.example/", + "nav": NAV, + "theme": {"name": "material"}, + "plugins": ["search", {"mkdocstrings": {"handlers": {"python": {"paths": ["src"]}}}}], +} + +LANGUAGES = """\ +model: some-model +exclude: [] +languages: + - {code: ja, name: 日本語, theme: ja, hreflang: ja} +""" + +# What `translations.py stage` records beside the staged tree: each staged page's H1 (a page without one is +# absent), which is all the config generator knows of the pages' content. +TITLES = {"index.md": "MCP Python SDK", "servers/index.md": "サーバー", "servers/tools.md": "ツール"} + + +def write_repo(root: Path) -> None: + (root / "i18n").mkdir(parents=True) + (root / "mkdocs.yml").write_text(yaml.safe_dump(MKDOCS, sort_keys=False), encoding="utf-8") + (root / "i18n" / "languages.yml").write_text(LANGUAGES, encoding="utf-8") + staged = build_config.staged_docs_dir("ja", root) + for page in build_config.nav_page_paths(NAV)[:-1]: # every page but the `api/` placeholder + (staged / page).parent.mkdir(parents=True, exist_ok=True) + (staged / page).write_text("staged page\n", encoding="utf-8") + build_config.staged_titles_file("ja", root).write_text(json.dumps(TITLES, ensure_ascii=False), encoding="utf-8") + + +def test_language_nav_titles_sections_from_the_recorded_page_titles_but_keeps_link_labels() -> None: + """Tool-defined: on a language site page labels go (Zensical then shows each page's translated H1) and a + section is titled with the recorded H1 of the index page leading it; a link entry, and a section that + leads with no titled page, keeps its English label.""" + nav = build_config.language_nav(NAV, TITLES) + + assert nav == snapshot( + [ + "index.md", + {"サーバー": ["servers/index.md", "servers/tools.md"]}, + {"Elsewhere": [{"Spec": "https://modelcontextprotocol.io/"}]}, + "troubleshooting.md", + "api/", + ] + ) + + +def test_alternate_lists_english_at_the_root_then_each_language_as_code_labelled_path_only_links() -> None: + """Tool-defined: the switcher is the same on every site, labels each entry `code - name`, links by the + code and announces the hreflang, and never names a host, so previews work.""" + languages = [build_config.Language("zh", "简体中文", "zh", "zh-Hans")] + assert build_config.alternate(languages) == snapshot( + [ + {"name": "en - English", "link": "/", "lang": "en"}, + {"name": "zh - 简体中文", "link": "/zh/", "lang": "zh-Hans"}, + ] + ) + + +def test_lang_config_builds_the_staged_tree_into_site_code_without_an_api_reference(tmp_path: Path) -> None: + """Tool-defined: `--lang ja` writes mkdocs.ja.gen.yml pointing Zensical at the staged tree (repo-relative), + building into site/ja/ under the ja URL prefix and theme language, with mkdocstrings dropped, the API + entry turned into a link to the English reference, section titles from the `titles.json` staged beside + the tree, and the shared switcher.""" + write_repo(tmp_path) + + written = build_config.build_config("ja", tmp_path) + + config = cast(dict[str, Any], yaml.safe_load(written.read_text(encoding="utf-8"))) + picked = {key: config[key] for key in ("docs_dir", "site_dir", "site_url", "theme", "plugins", "nav", "extra")} + assert (written.name, picked) == snapshot( + ( + "mkdocs.ja.gen.yml", + { + "docs_dir": ".build/i18n/ja/docs", + "site_dir": "site/ja", + "site_url": "https://docs.example/ja/", + "theme": {"name": "material", "language": "ja"}, + "plugins": ["search"], + "nav": [ + "index.md", + {"サーバー": ["servers/index.md", "servers/tools.md"]}, + {"Elsewhere": [{"Spec": "https://modelcontextprotocol.io/"}]}, + "troubleshooting.md", + {"API Reference": "/api/mcp/"}, + ], + "extra": { + "alternate": [ + {"name": "en - English", "link": "/", "lang": "en"}, + {"name": "ja - 日本語", "link": "/ja/", "lang": "ja"}, + ] + }, + }, + ) + ) + + +def test_lang_config_for_a_language_not_in_the_registry_exits_with_a_message(tmp_path: Path) -> None: + """Tool-defined: an unknown code is a usage error naming the registry, not a traceback.""" + write_repo(tmp_path) + + with pytest.raises(SystemExit) as excinfo: + build_config.build_config("xx", tmp_path) + + assert excinfo.value.code == snapshot("build_config: unknown language 'xx' (see i18n/languages.yml)") diff --git a/tests/docs/test_translations.py b/tests/docs/test_translations.py new file mode 100644 index 0000000000..906f40b100 --- /dev/null +++ b/tests/docs/test_translations.py @@ -0,0 +1,1222 @@ +"""The documentation translation tool (`scripts/docs/translations.py`). + +Everything here is tool-defined behaviour: what the model must never change is +re-imposed from the English page (or, for links, checked against it), unchanged +sections survive re-translation byte for byte, a page's state lives in its own +front matter, and a language site is the English tree with translations laid +over it and a notice on every page. The model is a scripted fake and the +repository a `tmp_path` tree, so every test is offline and deterministic. +""" + +import json +from collections.abc import Sequence +from pathlib import Path + +import pytest +import translations as t +from inline_snapshot import snapshot + +MKDOCS = """\ +site_name: Test docs +nav: + - Home: index.md + - Tools: tools.md + - Translations: translations.md + - Migration: migration.md + - API Reference: api/ +markdown_extensions: + - admonition + - attr_list + - pymdownx.details + - pymdownx.superfences + - pymdownx.tabbed: + alternate_style: true + - pymdownx.snippets: + check_paths: true +""" + +LANGUAGES = """\ +model: test-model +exclude: [migration.md] +languages: + - code: ja + name: 日本語 + theme: ja + hreflang: ja +""" + +NOTICES = """\ +# Notices + +## Machine translation {#translated} + +Machine translated; the [English page](ENGLISH_PAGE) is authoritative. See [Translations](TRANSLATIONS_PAGE). + +## Translation behind the English page {#outdated} + +Parts may be out of date; compare the [English page](ENGLISH_PAGE). + +## Shown in English {#english} + +Not translated yet; [Translations](TRANSLATIONS_PAGE) explains why. +""" + +NOTICES_JA = """\ +# お知らせ + +## 機械翻訳 {#translated} + +機械翻訳です。正式版は[英語版](ENGLISH_PAGE)です。[翻訳について](TRANSLATIONS_PAGE)を参照。 + +## 英語版より古い翻訳 {#outdated} + +一部が古い可能性があります。[英語版](ENGLISH_PAGE)と比べてください。 + +## 英語で表示 {#english} + +未翻訳です。理由は[翻訳について](TRANSLATIONS_PAGE)を参照。 +""" + +GLOSSARY = { + "keep": ["MCP", "Python"], + "terms": [ + {"source": "tool", "target": "ツール", "avoid": ["道具"]}, + {"source": "server", "target": "サーバー", "note": "Katakana, long vowel kept."}, + ], +} + +INDEX = """\ +# Home + +Welcome to MCP. Read about [tools](tools.md#errors) or the [API](api/mcp/index.md). + +## Install + +Run `pip install mcp`, then read the [Python](https://www.python.org/) docs. +""" + +TOOLS = """\ +# Tools + +A **tool** is a function the model can call; start at [home](index.md#install). + +## Your first tool + +```python title="server.py" +--8<-- "docs_src/server.py" +``` + +!!! note "Heads up" + Every tool is `async` friendly. + +## Errors + +Raise to signal a failure. +""" + +# English front matter (even with a `#` comment line in it) is dropped wherever the page is read. +TRANSLATIONS = ( + "---\ndescription: About the translated sites.\n# not a heading\n---\n# Translations\n\nHow this works.\n" +) + +# Faithful model replies: prose translated, code, link targets and markers untouched, no `{#id}` pins. +INDEX_JA = """\ +# ホーム + +MCP へようこそ。[ツール](tools.md#errors)または [API](api/mcp/index.md) を参照してください。 + +## インストール + +`pip install mcp` を実行し、[Python](https://www.python.org/) のドキュメントを読みます。 +""" + +TRANSLATIONS_JA = "# 翻訳について\n\n仕組みの説明です。\n" + +TOOLS_JA = """\ +# ツール + +**ツール**はモデルが呼び出せる関数です。[ホーム](index.md#install)から始めましょう。 + +## 最初のツール + +```python title="server.py" +--8<-- "docs_src/server.py" +``` + +!!! note "注意" + どのツールも `async` に対応しています。 + +## エラー + +失敗を伝えるには例外を送出します。 +""" + + +def write(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8", newline="\n") + + +def make_repo(tmp_path: Path) -> Path: + """A repository with three English prose pages, an excluded page and the ja inputs, nothing translated.""" + root = tmp_path / "repo" + write(root / "mkdocs.yml", MKDOCS) + write(root / "docs" / "index.md", INDEX) + write(root / "docs" / "tools.md", TOOLS) + write(root / "docs" / "translations.md", TRANSLATIONS) + write(root / "docs" / "migration.md", "# Migration\n\nSee [errors](tools.md#errors).\n") + write(root / "docs" / "img" / "logo.svg", "\n") + write(root / "docs_src" / "server.py", "print('hi')\n") + write(root / "i18n" / "languages.yml", LANGUAGES) + write(root / "i18n" / "general-prompt.md", "General rules.\n") + write(root / "i18n" / "notices.md", NOTICES) + write(root / "i18n" / "ja" / "instructions.md", "Japanese rules.\n") + write(root / "i18n" / "ja" / "glossary.json", json.dumps(GLOSSARY, ensure_ascii=False)) + return root + + +class FakeTranslator: + """Answers each call with the next scripted reply (raising it if it is an exception) and records the calls.""" + + def __init__(self, replies: Sequence[str | t.Completion | Exception]) -> None: + self.replies = list(replies) + self.models: list[str] = [] + self.systems: list[str] = [] + self.conversations: list[list[t.Message]] = [] + + def complete(self, *, model: str, system: str, messages: Sequence[t.Message], max_tokens: int) -> t.Completion: + self.models.append(model) + self.systems.append(system) + self.conversations.append(list(messages)) + reply = self.replies.pop(0) + if isinstance(reply, Exception): + raise reply + return reply if isinstance(reply, t.Completion) else t.Completion(reply, t.Usage(1000, 400, 900, 100)) + + +def run( + capsys: pytest.CaptureFixture[str], root: Path, *argv: str, translator: t.Translator | None = None +) -> tuple[int, str, str]: + code = t.main(list(argv), root=root, translator=translator) + captured = capsys.readouterr() + return code, captured.out, captured.err + + +def translate_all(capsys: pytest.CaptureFixture[str], root: Path) -> None: + """Publish faithful translations of the three pages and the notices through the real command.""" + fake = FakeTranslator([INDEX_JA, TOOLS_JA, TRANSLATIONS_JA, NOTICES_JA]) + assert run(capsys, root, "translate", "--lang", "ja", translator=fake)[0] == 0 + + +def test_sections_tile_the_page_and_blank_lines_belong_to_the_heading_after_them() -> None: + """Tool-defined: a page splits into intro + one string per `##` (a `##` inside a fence is code), the parts + join back to the page, and appending a section leaves every earlier section's hash unchanged.""" + page = "# Title\n\nIntro.\n\n\n## One\n\n```md\n## not a heading\n```\n\n## Two\n\nEnd.\n" + + assert t.sections(page) == snapshot( + [ + """\ +# Title + +Intro. +""", + """\ + + +## One + +```md +## not a heading +``` +""", + """\ + +## Two + +End. +""", + ] + ) + assert "".join(t.sections(page)) == page + assert t.section_hashes(page + "\n## Three\n\nMore.\n")[:3] == t.section_hashes(page) + + +def test_provenance_front_matter_round_trips_and_keeps_all_digit_hashes_as_strings() -> None: + """Tool-defined: the generated file is front matter + body; a hash of digits only must not come back as + a number, and a block from another tool version or without the record reads as no provenance at all.""" + hashes = ("1234567890123456", "00ff00ff00ff00ff") + text = t.with_provenance("# 本文\n", hashes) + + assert text == snapshot("""\ +--- +translation: + sections: ['1234567890123456', 00ff00ff00ff00ff] + tool: 1 +--- +# 本文 +""") + front_matter, body = t.split_front_matter(text) + assert (t.read_provenance(front_matter), body) == (hashes, "# 本文\n") + assert t.read_provenance("translation: {sections: [], tool: 99}\n") is None + assert t.read_provenance("title: Just a page\n") is None + + +def test_heading_ids_are_the_ones_the_site_renderer_produces(tmp_path: Path) -> None: + """Tool-defined: ids come from the real markdown stack (dedupe suffixes, punctuation, `__init__`, explicit + ids, inline code, no space after `##`), and pinning them in escaped source form renders the same ids.""" + repo = t.load_repo(make_repo(tmp_path)) + page = ( + "# What's new?\n\n## Step\n\n## Step\n\n## The `__init__` hook\n\n## Custom {#my-id}\n\n" + "##Glued\n\n## Über `Config.load()` & friends!\n\n## 日本語\n" + ) + + ids = repo.heading_ids(page) + + assert ids == snapshot( + ["whats-new", "step", "step_1", "the-__init__-hook", "my-id", "glued", "uber-configload-friends", "_1"] + ) + pinned = t.reimpose(page, ids, page) + assert isinstance(pinned, str) + assert pinned.split("\n")[6] == snapshot("## The `__init__` hook {#the-\\_\\_init\\_\\_-hook}") + assert repo.heading_ids(pinned) == ids + + +def test_heading_the_source_scan_cannot_pin_makes_the_page_an_error(tmp_path: Path) -> None: + """Tool-defined: a setext heading renders but is not an ATX heading at column 0, so ids cannot be paired + positionally; the page is refused rather than mis-pinned.""" + repo = t.load_repo(make_repo(tmp_path)) + + with pytest.raises(t.PageError) as excinfo: + repo.heading_ids("# Title\n\nSetext\n------\n") + + assert str(excinfo.value) == snapshot("the page renders 2 headings but 1 are ATX headings at column 0") + + +def test_many_attribute_blocks_pin_the_last_id_only_when_they_end_the_heading() -> None: + """Tool-defined: a run of `{...}` blocks ending the line pins the last block's id, in either attr_list + spelling; with text after it the run stays heading text and pins nothing, and the scan still returns + promptly however many blocks (colon-led ones too) it has.""" + blocks, colons = "{ a } " * 20, "{: #a}" * 20 + page = f"## Pinned {blocks}{{#last}}\n## Prose {blocks}end\n## Colons {colons} tail\n## A {{:#a}}\n## B {{: #a }}\n" + + assert t.parse_headings(page) == [ + t.Heading(0, 2, "Pinned", "last"), + t.Heading(1, 2, f"Prose {blocks}end", None), + t.Heading(2, 2, f"Colons {colons} tail", None), + t.Heading(3, 2, "A", "a"), + t.Heading(4, 2, "B", "a"), + ] + + +ENGLISH = """\ +# Guide + +See [tools](tools.md#errors), the [spec](https://spec.example/) and ![logo](img/logo.svg). + +## The `__init__` hook + +```python title="app.py" hl_lines="1" +--8<-- "docs_src/server.py" +def main(): ... # (1)! +``` + +## Step + +## Step +""" + + +def test_reimpose_restores_fences_pins_ids_and_leaves_reordered_links_where_the_translation_put_them() -> None: + """Tool-defined: the wrapper fence is dropped, each fence comes back opener-through-closer, ids are pinned + positionally in escaped form (a `{#...}` glued to CJK text, or doubled, is replaced), and links the + translation reordered within a section keep their own targets: nothing is moved back by position.""" + reply = ( + "```markdown\n# ガイド\n\n![ロゴ](img/logo.svg)、[仕様](https://spec.example/)、[ツール](tools.md#errors)。\n\n" + "## `__init__` フック{#init}\n\n```py\ndef メイン(): ...\n```\n\n## 手順\n\n## 手順 {#wrong} {: #twice }\n```" + ) + + result = t.reimpose(ENGLISH, ["guide", "the-__init__-hook", "step", "step_1"], t.unwrap(ENGLISH, reply)) + + assert result == snapshot("""\ +# ガイド {#guide} + +![ロゴ](img/logo.svg)、[仕様](https://spec.example/)、[ツール](tools.md#errors)。 + +## `__init__` フック {#the-\\_\\_init\\_\\_-hook} + +```python title="app.py" hl_lines="1" +--8<-- "docs_src/server.py" +def main(): ... # (1)! +``` + +## 手順 {#step} + +## 手順 {#step_1} +""") + + +@pytest.mark.parametrize( + ("reply", "findings"), + [ + pytest.param( + ENGLISH.replace("## Step\n\n## Step\n", "## Step\n"), + snapshot(["3 headings vs 4 in the English: keep every heading, and no others"]), + id="heading-dropped", + ), + pytest.param( + ENGLISH.replace("## Step\n\n", "### Step\n\n"), + snapshot(["`Step` is a level-3 heading but `Step` is level 2"]), + id="heading-level", + ), + pytest.param( + ENGLISH.replace('hl_lines="1"\n', 'hl_lines="1"\n```\n\n```text\n'), + snapshot(["## The `__init__` hook: 2 code fences vs 1 in the English: keep each where it is, add none"]), + id="fence-added", + ), + pytest.param( + ENGLISH.replace("## Step\n\n## Step\n", "## Step\n\n```python\npass\n```\n\n## Step\n").replace( + '```python title="app.py" hl_lines="1"\n--8<-- "docs_src/server.py"\ndef main(): ... # (1)!\n```\n', "" + ), + snapshot( + [ + "## The `__init__` hook: 0 code fences vs 1 in the English: keep each where it is, add none", + "## Step: 1 code fences vs 0 in the English: keep each where it is, add none", + ] + ), + id="fence-moved-to-another-section", + ), + pytest.param( + ENGLISH.replace("\n```\n\n## Step", "\n\n## Step"), + snapshot( + [ + "the code fence opened on line 7 is never closed", + "2 headings vs 4 in the English: keep every heading, and no others", + ] + ), + id="fence-unclosed", + ), + pytest.param( + ENGLISH.replace("tools.md#errors", "tools.md#エラー"), + snapshot( + [ + "missing links to ['tools.md#errors']: keep every link of the English where it is", + "unexpected links to ['tools.md#エラー']: add no links of your own", + ] + ), + id="target-mangled", + ), + pytest.param( + ENGLISH.replace("See [tools](tools.md#errors), the", "See the").replace( + "## Step\n\n## Step\n", "## Step\n\nSee [tools](tools.md#errors).\n\n## Step\n" + ), + snapshot( + [ + "missing links to ['tools.md#errors']: keep every link of the English where it is", + "unexpected links to ['tools.md#errors']: add no links of your own", + ] + ), + id="link-moved-to-another-section", + ), + ], +) +def test_reimpose_names_each_structural_mismatch(reply: str, findings: list[str]) -> None: + """Tool-defined: a heading count or level the reply gets wrong, or a section's fence count (so a code + block moved under another `##` counts twice), cannot be repaired positionally, and a link target its + English section lacks (mangled, or moved across sections) is never rewritten, so each becomes a finding + for the repair turn.""" + result = t.reimpose(ENGLISH, ["guide", "the-__init__-hook", "step", "step_1"], reply) + + assert result == t.Mismatch(findings) + + +def test_validate_flags_code_spans_markers_banned_terms_and_abridgement() -> None: + """Tool-defined: what re-imposition cannot fix is reported for the repair turn; a banned rendering + inside code is not prose, and a link label or anything the English itself says is not an abridgement.""" + glossary = t.Glossary(("MCP",), (t.Term("tool", "ツール", "", ("道具",)),)) + english = '# T\n\nUse `ctx` and `run()`. See [Translations](t.md), not [...].\n\n!!! tip "Hint"\n A table.\n' + faithful = ( + "# T\n\n`ctx` と `run()` を使います。`道具` はコード。[Translations](t.md) 参照、[...] 以外。\n\n" + '!!! tip "ヒント"\n 表。\n' + ) + broken = ( + "# T\n\n`ctx` と `run` を使う道具です。[translation continues below]\n\n" + '!!! note "ヒント"\n \n' + ) + + assert t.validate(english, english, glossary) == [] + assert t.validate(english, faithful.replace("`道具` はコード。", ""), glossary) == [] + assert t.validate(english, faithful, glossary) == snapshot( + ["unexpected inline code ['道具']: use only the English `code spans`"] + ) + assert t.validate(english, broken, glossary) == snapshot( + [ + "missing inline code ['run()']: copy every `code span` of the English", + "unexpected inline code ['run']: use only the English `code spans`", + "block markers ['!!! note'] vs ['!!! tip'] in the English: keep each `!!!`/`???`/`===` line and its type", + "banned rendering '道具' of 'tool' appears: use 'ツール'", + "placeholder '': translate the whole page, never abridge it", + "placeholder '[translation continues below]': translate the whole page, never abridge it", + ] + ) + + +LISTED = """\ +# Translations + +How this works: + +- one +- two + 1. nested +* three + +| Note | Meaning | +|------|---------| +| a | b | +| c | d | + +```text +- not an item +| not | a row | +``` +""" + +LISTED_JA = """\ +# 翻訳について + +仕組みの説明です。 + +- いち +- に + 1. 入れ子 +* さん + +| お知らせ | 意味 | +|------|---------| +| a | b | +| c | d | + +```text +- not an item +| not | a row | +``` +""" + + +def test_validate_counts_list_items_and_table_rows_whatever_the_language_of_a_placeholder() -> None: + """Tool-defined: a reply that drops list items (at any depth, any bullet) or table rows is a finding + naming the part of the page, even when the note it leaves in their place is not English; lines inside + fenced code do not count.""" + glossary = t.Glossary((), ()) + shortened = LISTED_JA.replace(" 1. 入れ子\n* さん\n", "(以下同様)\n").replace("| c | d |\n", "") + + assert t.block_counts(LISTED) == {"list items": 4, "table rows": 4} + assert t.validate(LISTED, LISTED_JA, glossary) == [] + assert t.validate(LISTED, shortened, glossary, "## Some section") == snapshot( + [ + "## Some section: 2 list items vs 4 in the English: translate them one for one, dropping none", + "## Some section: 3 table rows vs 4 in the English: translate them one for one, dropping none", + ] + ) + + +def test_glossary_of_the_wrong_shape_stops_translate_with_exit_2_but_never_breaks_stage( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Tool-defined: a glossary entry with a key `Term` lacks stops the command that prompts with it (exit 2, + naming the file); the site build's `stage` never reads prompt inputs, so a broken one cannot fail a build.""" + root = make_repo(tmp_path) + glossary = root / "i18n" / "ja" / "glossary.json" + write(glossary, json.dumps({"keep": [], "terms": [{"source": "a", "target": "b", "enforce": True}]})) + + code, out, err = run(capsys, root, "translate", "--lang", "ja", translator=FakeTranslator([])) + + assert (code, out) == (2, "") + assert err.startswith(f"translations: {glossary}: TypeError(") # the rest is the interpreter's wording + assert run(capsys, root, "stage", "--lang", "ja") == (0, "staged ja at .build/i18n/ja/docs\n", "") + + +def test_translate_writes_pages_with_provenance_and_a_second_run_makes_no_calls( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Tool-defined: missing pages are translated whole and written with pinned ids and provenance front + matter; once every page is current a run selects nothing and never builds a client.""" + root = make_repo(tmp_path) + fake = FakeTranslator([INDEX_JA, TOOLS_JA, TRANSLATIONS_JA, NOTICES_JA]) + + code, out, err = run(capsys, root, "translate", "--lang", "ja", translator=fake) + + assert (code, err) == (0, "") + assert out == snapshot("""\ +translated: index.md (2 of 2 sections) +translated: tools.md (3 of 3 sections) +translated: translations.md (1 of 1 sections) +translated: i18n/notices.md (4 of 4 sections) +usage: 4000 input / 1600 output / 3600 cache-write / 400 cache-read tokens +""") + assert (root / "i18n" / "ja" / "pages" / "tools.md").read_text(encoding="utf-8") == snapshot("""\ +--- +translation: + sections: [66b1e7a79f363f39, 0c72bd9638620faf, 21db181e57737c09] + tool: 1 +--- +# ツール {#tools} + +**ツール**はモデルが呼び出せる関数です。[ホーム](index.md#install)から始めましょう。 + +## 最初のツール {#your-first-tool} + +```python title="server.py" +--8<-- "docs_src/server.py" +``` + +!!! note "注意" + どのツールも `async` に対応しています。 + +## エラー {#errors} + +失敗を伝えるには例外を送出します。 +""") + assert fake.conversations[1] == [t.Message("user", t.translate_request(TOOLS))] + assert fake.systems[0] == snapshot("""\ +General rules. + +# Target language: 日本語 (`ja`) + +Japanese rules. + +## Glossary + +These terms always stay in English, spelled exactly like this: + +- MCP +- Python + +Use these renderings; the notes are binding: + +- tool → ツール (never: 道具) +- server → サーバー. Katakana, long vowel kept.\ +""") + + code, out, err = run(capsys, root, "translate", "--lang", "ja") + + assert (code, out, err) == (0, "ja: nothing to translate\n", "") + assert run(capsys, root, "status") == snapshot( + (0, "ja (日本語): 0 missing, 0 outdated, 4 current, 0 removable\n", "") + ) + + +def test_docs_translate_model_overrides_the_registry_model_for_the_run( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """Tool-defined: calls use the registry's model unless `DOCS_TRANSLATE_MODEL` names another one to + trial, and neither is ever written into the generated page.""" + root = make_repo(tmp_path) + monkeypatch.delenv("DOCS_TRANSLATE_MODEL", raising=False) + fake = FakeTranslator([INDEX_JA, INDEX_JA]) + assert run(capsys, root, "translate", "--lang", "ja", "--pages", "index.md", translator=fake)[0] == 0 + monkeypatch.setenv("DOCS_TRANSLATE_MODEL", "trial-model") + + code, _, _ = run(capsys, root, "translate", "--lang", "ja", "--pages", "index.md", translator=fake) + + assert (code, fake.models) == (0, ["test-model", "trial-model"]) + assert "model" not in (root / "i18n" / "ja" / "pages" / "index.md").read_text(encoding="utf-8") + + +def test_translate_pages_retranslates_the_named_pages_from_scratch_even_when_a_translation_exists( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Tool-defined: `--pages` sends exactly the request a missing page gets (the English alone, no previous + translation to anchor on) although the page is current, publishes the result with provenance, and a page + outside the nav is exit 2.""" + root = make_repo(tmp_path) + translate_all(capsys, root) + fake = FakeTranslator([INDEX_JA.replace("へようこそ", "へようこそ!")]) + + code, out, _ = run(capsys, root, "translate", "--lang", "ja", "--pages", "index.md", translator=fake) + + assert (code, out.split("\n")[0]) == (0, "translated: index.md (2 of 2 sections)") + assert fake.conversations[0] == [t.Message("user", t.translate_request(INDEX))] + assert "MCP へようこそ!" in (root / "i18n" / "ja" / "pages" / "index.md").read_text(encoding="utf-8") + assert run(capsys, root, "status")[1] == snapshot("ja (日本語): 0 missing, 0 outdated, 4 current, 0 removable\n") + + code, _, err = run(capsys, root, "translate", "--lang", "ja", "--pages", "nope.md", translator=fake) + + assert (code, err) == snapshot( + (2, "translations: not translatable pages (nav paths such as servers/tools.md): ['nope.md']\n") + ) + + +def test_outdated_page_retranslates_the_changed_section_and_carries_the_rest_forward( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Tool-defined: editing one English section marks the page outdated; the prompt carries the previous + translation and names that section for retranslation, and every other section keeps its previous bytes + even though the model re-rendered them.""" + root = make_repo(tmp_path) + translate_all(capsys, root) + write(root / "docs" / "tools.md", TOOLS.replace("Raise to signal a failure.", "Raise `ToolError` to fail.")) + reply = TOOLS_JA.replace("**ツール**は", "気まぐれな言い換え:**ツール**は").replace( + "失敗を伝えるには例外を送出します。", "失敗するには `ToolError` を送出します。" + ) + fake = FakeTranslator([reply]) + + code, out, _ = run(capsys, root, "translate", "--lang", "ja", translator=fake) + + assert (code, out.split("\n")[0]) == (0, "translated: tools.md (1 of 3 sections)") + assert fake.conversations[0][0].content == snapshot("""\ +This page was translated before. Retranslate it: translate the sections listed below +afresh from the current English, applying the current language instructions and glossary +(their previous wording may be outdated); everywhere else, reproduce the previous +translation line by line, changing nothing. Keep the retranslated sections consistent in +terminology and tone with their surroundings. A section is the introduction before the +first `##` heading, or one `##` heading with everything under it. + +Sections to retranslate: + +- ## Errors + +Current English page: + + +# Tools + +A **tool** is a function the model can call; start at [home](index.md#install). + +## Your first tool + +```python title="server.py" +--8<-- "docs_src/server.py" +``` + +!!! note "Heads up" + Every tool is `async` friendly. + +## Errors + +Raise `ToolError` to fail. + + + +Previous translation of the page: + + +# ツール {#tools} + +**ツール**はモデルが呼び出せる関数です。[ホーム](index.md#install)から始めましょう。 + +## 最初のツール {#your-first-tool} + +```python title="server.py" +--8<-- "docs_src/server.py" +``` + +!!! note "注意" + どのツールも `async` に対応しています。 + +## エラー {#errors} + +失敗を伝えるには例外を送出します。 + + + +Return only the full translated page.\ +""") + body = t.split_front_matter((root / "i18n" / "ja" / "pages" / "tools.md").read_text(encoding="utf-8"))[1] + assert "気まぐれ" not in body + assert body.endswith("## エラー {#errors}\n\n失敗するには `ToolError` を送出します。\n") + + +def test_banned_rendering_is_a_finding_in_a_retranslated_section_but_not_in_a_carried_one( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Tool-defined: validation reads the page as it will be written, and only the sections this run rewrites. + A carried section using a word the glossary has since banned is published text (`--pages` redoes it), so + it is neither a finding nor touched; the same word in the retranslated section is repaired as ever.""" + root = make_repo(tmp_path) + translate_all(capsys, root) + banned = {"source": "function", "target": "ファンクション", "avoid": ["関数"]} # the published intro says 関数 + write(root / "i18n" / "ja" / "glossary.json", json.dumps({**GLOSSARY, "terms": [*GLOSSARY["terms"], banned]})) + write(root / "docs" / "tools.md", TOOLS.replace("Raise to signal a failure.", "Raise from the function.")) + slipped = TOOLS_JA.replace("失敗を伝えるには例外を送出します。", "関数から送出します。") + repaired = TOOLS_JA.replace("失敗を伝えるには例外を送出します。", "ファンクションから送出します。") + fake = FakeTranslator([slipped, repaired]) + + code, out, err = run(capsys, root, "translate", "--lang", "ja", translator=fake) + + assert (code, err, fake.replies, out.split("\n")[0]) == (0, "", [], "translated: tools.md (1 of 3 sections)") + assert fake.conversations[1][2].content == snapshot("""\ +Your translation broke the following structural rules. Fix each problem and return the +full corrected page, changing nothing else: + +- banned rendering '関数' of 'function' appears: use 'ファンクション'\ +""") + body = t.split_front_matter((root / "i18n" / "ja" / "pages" / "tools.md").read_text(encoding="utf-8"))[1] + assert t.sections(body)[0] == t.sections(TOOLS_JA)[0].replace("# ツール\n", "# ツール {#tools}\n") + assert body.endswith("## エラー {#errors}\n\nファンクションから送出します。\n") + + +def test_removed_english_section_is_reassembled_with_no_client_but_an_edited_one_needs_credentials_first( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """Tool-defined, in three runs: (1) when English sections are only removed or reordered, every remaining + section still has its recorded translation, so the page is rebuilt from them with no model call and no + client, hence no credentials; (2) once some page has an edited section the run calls the model, and + missing credentials stop it before any page, rebuildable ones included, is written; (3) with credentials + that run rebuilds the one page and retranslates the edited section of the other.""" + root = make_repo(tmp_path) + translate_all(capsys, root) + + def no_credentials() -> t.Translator: + raise t.ConfigError("no API credentials: set ANTHROPIC_API_KEY") + + monkeypatch.setattr(t, "anthropic_translator", no_credentials) + write(root / "docs" / "tools.md", TOOLS.split("## Errors")[0].rstrip("\n") + "\n") + + code, out, _ = run(capsys, root, "translate", "--lang", "ja") + + assert (code, out.split("\n")[0]) == (0, "translated: tools.md (0 of 2 sections)") + body = t.split_front_matter((root / "i18n" / "ja" / "pages" / "tools.md").read_text(encoding="utf-8"))[1] + previous = TOOLS_JA.split("## エラー")[0].rstrip("\n") + "\n" + assert body == previous.replace("# ツール\n", "# ツール {#tools}\n").replace( + "## 最初のツール\n", "## 最初のツール {#your-first-tool}\n" + ) + assert run(capsys, root, "status")[1] == snapshot("ja (日本語): 0 missing, 0 outdated, 4 current, 0 removable\n") + + write(root / "docs" / "tools.md", TOOLS.split("## Your first tool")[0].rstrip("\n") + "\n") + write(root / "docs" / "index.md", INDEX.replace("Welcome to MCP.", "Welcome!")) + code, out, err = run(capsys, root, "translate", "--lang", "ja") + + assert (code, out, err) == snapshot((2, "", "translations: no API credentials: set ANTHROPIC_API_KEY\n")) + assert run(capsys, root, "status", "--lang", "ja")[1] == snapshot("""\ +ja (日本語): 0 missing, 2 outdated, 2 current, 0 removable + outdated index.md (English changed in: the introduction (everything before the first `##` heading)) + outdated tools.md (English sections removed or reordered) +""") + + fake = FakeTranslator([INDEX_JA.replace("MCP へようこそ。", "ようこそ!")]) + code, out, err = run(capsys, root, "translate", "--lang", "ja", translator=fake) + + assert (code, err, fake.replies, out.split("\n")[:2]) == snapshot( + (0, "", [], ["translated: index.md (1 of 2 sections)", "translated: tools.md (0 of 1 sections)"]) + ) + + +def test_a_failing_page_does_not_stop_the_run_and_the_exit_code_is_1( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Tool-defined: an API error, a refusal or a truncated reply fails that page only; later pages are still + written, the failures are reported on stderr, usage is totalled, and the run exits 1.""" + root = make_repo(tmp_path) + refusal = t.Completion("", t.Usage(10, 0, 0, 0), "refusal") + truncated = t.Completion(TOOLS_JA[:40], t.Usage(10, 64_000, 0, 0), "max_tokens") + fake = FakeTranslator([t.PageError("API request failed: overloaded"), truncated, refusal, NOTICES_JA]) + + code, out, err = run(capsys, root, "translate", "--lang", "ja", translator=fake) + + assert (code, out, err) == snapshot( + ( + 1, + """\ +translated: i18n/notices.md (4 of 4 sections) +usage: 1020 input / 64400 output / 900 cache-write / 100 cache-read tokens +""", + """\ +error: index.md: API request failed: overloaded +error: tools.md: the reply was cut off at 64000 output tokens +error: translations.md: the model declined to translate this page +""", + ) + ) + assert not (root / "i18n" / "ja" / "pages").exists() + assert (root / "i18n" / "ja" / "notices.md").is_file() + + +def test_rejected_credentials_stop_the_run_with_exit_2(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + """Tool-defined: an authentication failure is configuration, not a page problem: nothing more is tried.""" + root = make_repo(tmp_path) + fake = FakeTranslator([t.ConfigError("the API rejected the credentials: invalid x-api-key"), INDEX_JA]) + + code, out, err = run(capsys, root, "translate", "--lang", "ja", translator=fake) + + assert (code, out, err) == snapshot((2, "", "translations: the API rejected the credentials: invalid x-api-key\n")) + assert fake.replies == [INDEX_JA] + + +def test_repair_turn_feeds_the_findings_back_and_accepts_the_fixed_reply( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Tool-defined: a reply that drops a code span and mistypes an admonition gets its findings appended to + the conversation; the corrected second reply is published.""" + root = make_repo(tmp_path) + broken = TOOLS_JA.replace("`async` に", "非同期に").replace("!!! note", "!!! warning") + fake = FakeTranslator([INDEX_JA, broken, TOOLS_JA, TRANSLATIONS_JA, NOTICES_JA]) + + code, _, err = run(capsys, root, "translate", "--lang", "ja", translator=fake) + + assert (code, err, fake.replies) == (0, "", []) + assert [message.role for message in fake.conversations[2]] == ["user", "assistant", "user"] + assert fake.conversations[2][1] == t.Message("assistant", broken) + assert fake.conversations[2][2].content == snapshot("""\ +Your translation broke the following structural rules. Fix each problem and return the +full corrected page, changing nothing else: + +- missing inline code ['async']: copy every `code span` of the English +- block markers ['!!! warning'] vs ['!!! note'] in the English: keep each `!!!`/`???`/`===` line and its type\ +""") + + +FENCE_JA = '```python title="server.py"\n--8<-- "docs_src/server.py"\n```\n\n' +# The page's fence count kept, but the code block moved from its own `##` section into the next one. +FENCE_MOVED_JA = TOOLS_JA.replace(FENCE_JA, "").replace("## エラー\n\n", "## エラー\n\n" + FENCE_JA) + + +def test_code_block_moved_into_another_section_is_repaired_not_published( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Tool-defined: fences are counted section by section, so a reply that keeps every code block but puts + one under the wrong `##` heading gets a finding per section for the repair turn instead of the English + code spliced under the wrong prose; the corrected reply is published.""" + root = make_repo(tmp_path) + fake = FakeTranslator([FENCE_MOVED_JA, TOOLS_JA]) + + code, _, err = run(capsys, root, "translate", "--lang", "ja", "--pages", "tools.md", translator=fake) + + assert (code, err, fake.replies) == (0, "", []) + assert fake.conversations[1][2].content == snapshot("""\ +Your translation broke the following structural rules. Fix each problem and return the +full corrected page, changing nothing else: + +- ## Your first tool: 0 code fences vs 1 in the English: keep each where it is, add none +- ## Errors: 1 code fences vs 0 in the English: keep each where it is, add none\ +""") + body = t.split_front_matter((root / "i18n" / "ja" / "pages" / "tools.md").read_text(encoding="utf-8"))[1] + assert body.endswith("## エラー {#errors}\n\n失敗を伝えるには例外を送出します。\n") + + +def test_code_block_moved_out_of_a_retranslated_section_gets_repair_turns_like_any_other_finding( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Tool-defined: on an update the same slip (the retranslated section's reply swallows the code block of + the carried section before it) is fed back for repair rather than failing the page outright, and the + fixed reply is assembled with the carried sections.""" + root = make_repo(tmp_path) + translate_all(capsys, root) + write(root / "docs" / "tools.md", TOOLS.replace("Raise to signal a failure.", "Raise `ToolError` to fail.")) + fixed = TOOLS_JA.replace("失敗を伝えるには例外を送出します。", "失敗するには `ToolError` を送出します。") + moved = FENCE_MOVED_JA.replace("失敗を伝えるには例外を送出します。", "失敗するには `ToolError` を送出します。") + fake = FakeTranslator([moved, fixed]) + + code, out, err = run(capsys, root, "translate", "--lang", "ja", translator=fake) + + assert (code, err, fake.replies, out.split("\n")[0]) == (0, "", [], "translated: tools.md (1 of 3 sections)") + assert fake.conversations[1][2].content == snapshot("""\ +Your translation broke the following structural rules. Fix each problem and return the +full corrected page, changing nothing else: + +- ## Your first tool: 0 code fences vs 1 in the English: keep each where it is, add none +- ## Errors: 1 code fences vs 0 in the English: keep each where it is, add none\ +""") + body = t.split_front_matter((root / "i18n" / "ja" / "pages" / "tools.md").read_text(encoding="utf-8"))[1] + assert body == TOOLS_JA.replace("# ツール\n", "# ツール {#tools}\n").replace( + "## 最初のツール\n", "## 最初のツール {#your-first-tool}\n" + ).replace( + "## エラー\n\n失敗を伝えるには例外を送出します。", + "## エラー {#errors}\n\n失敗するには `ToolError` を送出します。", + ) + + +def test_shortened_list_and_table_are_fed_back_for_repair_before_the_page_is_published( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Tool-defined: a reply that cuts a list short behind a note in the target language and drops a table + row passes every other check, so the item and row counts are what send it back; the full reply is + published.""" + root = make_repo(tmp_path) + write(root / "docs" / "translations.md", LISTED) + shortened = LISTED_JA.replace(" 1. 入れ子\n* さん\n", "(以下同様)\n").replace("| c | d |\n", "") + fake = FakeTranslator([shortened, LISTED_JA]) + + code, _, err = run(capsys, root, "translate", "--lang", "ja", "--pages", "translations.md", translator=fake) + + assert (code, err, fake.replies) == (0, "", []) + assert fake.conversations[1][2].content == snapshot("""\ +Your translation broke the following structural rules. Fix each problem and return the +full corrected page, changing nothing else: + +- the introduction (everything before the first `##` heading): 2 list items vs 4 in the English: translate them one for one, dropping none +- the introduction (everything before the first `##` heading): 3 table rows vs 4 in the English: translate them one for one, dropping none\ +""") + body = t.split_front_matter((root / "i18n" / "ja" / "pages" / "translations.md").read_text(encoding="utf-8"))[1] + assert body == LISTED_JA.replace("# 翻訳について\n", "# 翻訳について {#translations}\n") + + +def test_page_still_broken_after_two_repair_turns_fails_and_keeps_the_previous_translation( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Tool-defined: three structurally wrong replies (first try + two repairs) fail the page; the file that + was published before stays byte for byte.""" + root = make_repo(tmp_path) + translate_all(capsys, root) + before = (root / "i18n" / "ja" / "pages" / "tools.md").read_text(encoding="utf-8") + missing_fence = TOOLS_JA.replace('```python title="server.py"\n--8<-- "docs_src/server.py"\n```\n\n', "") + fake = FakeTranslator([missing_fence] * 3) + + code, _, err = run(capsys, root, "translate", "--lang", "ja", "--pages", "tools.md", translator=fake) + + assert (code, fake.replies) == (1, []) + assert err == snapshot( + "error: tools.md: unfixed after 2 repairs: ## Your first tool: 0 code fences vs 1 in the English: keep each where it is, add none\n" + ) + assert (root / "i18n" / "ja" / "pages" / "tools.md").read_text(encoding="utf-8") == before + + +def test_status_classifies_each_page_against_the_current_english_and_lists_removable_files( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Tool-defined: a mangled provenance block reads as missing (with the reason, so the next run redoes the + page whole), an edited English section as outdated (naming it), and a generated file whose page left the + translatable set (excluded here; dropped from the nav works the same) as removable, with its `git rm`.""" + root = make_repo(tmp_path) + translate_all(capsys, root) + index = root / "i18n" / "ja" / "pages" / "index.md" + write(index, index.read_text(encoding="utf-8").replace(" tool: 1\n", "")) + write(root / "docs" / "tools.md", TOOLS.replace("Raise to signal a failure.", "Raise `ToolError` to fail.")) + write(root / "i18n" / "languages.yml", LANGUAGES.replace("[migration.md]", "[migration.md, translations.md]")) + + assert run(capsys, root, "status", "--lang", "ja") == snapshot( + ( + 0, + """\ +ja (日本語): 1 missing, 1 outdated, 1 current, 1 removable + missing index.md (unreadable front matter, retranslated whole) + outdated tools.md (English changed in: ## Errors) + removable translations.md (git rm i18n/ja/pages/translations.md) +""", + "", + ) + ) + + +def staged_page(root: Path, page: str) -> str: + return (root / ".build" / "i18n" / "ja" / "docs" / page).read_text(encoding="utf-8") + + +def test_stage_overlays_translations_injects_notices_and_rewrites_api_links( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Tool-defined: the ja tree is the English docs minus `api/`; a current page is served translated with + the collapsed machine-translation notice under its H1 (English wording while the notices page itself is + untranslated), an untranslated or excluded page is English (front matter dropped) with the + shown-in-English notice, links into `api/` go to the English site's reference, page-relative like the + notice links so they hold under any path prefix, and assets ride along.""" + root = make_repo(tmp_path) + fake = FakeTranslator([INDEX_JA]) + assert run(capsys, root, "translate", "--lang", "ja", "--pages", "index.md", translator=fake)[0] == 0 + write(root / "docs" / "api" / "mcp" / "index.md", "# API stub\n") + + code, out, err = run(capsys, root, "stage", "--lang", "ja") + + assert (code, out, err) == (0, "staged ja at .build/i18n/ja/docs\n", "") + assert staged_page(root, "index.md") == snapshot("""\ +# ホーム {#home} + +??? note "Machine translation" + + Machine translated; the [English page](../) is authoritative. See [Translations](translations.md). + +MCP へようこそ。[ツール](tools.md#errors)または [API](../api/mcp/) を参照してください。 + +## インストール {#install} + +`pip install mcp` を実行し、[Python](https://www.python.org/) のドキュメントを読みます。 +""") + assert staged_page(root, "tools.md") == snapshot("""\ +# Tools + +!!! note "Shown in English" + + Not translated yet; [Translations](translations.md) explains why. + +A **tool** is a function the model can call; start at [home](index.md#install). + +## Your first tool + +```python title="server.py" +--8<-- "docs_src/server.py" +``` + +!!! note "Heads up" + Every tool is `async` friendly. + +## Errors + +Raise to signal a failure. +""") + assert staged_page(root, "migration.md") == snapshot("""\ +# Migration + +!!! note "Shown in English" + + Not translated yet; [Translations](translations.md) explains why. + +See [errors](tools.md#errors). +""") + assert staged_page(root, "translations.md") == snapshot("""\ +# Translations + +!!! note "Shown in English" + + Not translated yet; [Translations](translations.md) explains why. + +How this works. +""") + assert staged_page(root, "img/logo.svg") == "\n" + assert not (root / ".build" / "i18n" / "ja" / "docs" / "api").exists() + assert json.loads((root / ".build" / "i18n" / "ja" / "titles.json").read_text(encoding="utf-8")) == snapshot( + {"index.md": "ホーム", "migration.md": "Migration", "tools.md": "Tools", "translations.md": "Translations"} + ) + + +def test_stage_serves_an_outdated_translation_with_todays_structure_and_english_when_it_no_longer_fits( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Tool-defined: after English-only edits under published translations, a page whose English prose changed + keeps its translation under the translated "outdated" notice, a page whose English structure changed (a + heading added, so ids cannot be pinned) is staged in English, and the tool says so.""" + root = make_repo(tmp_path) + translate_all(capsys, root) + write(root / "docs" / "tools.md", TOOLS.replace("Raise to signal a failure.", "Raise `ToolError` to fail.")) + write(root / "docs" / "index.md", INDEX + "\n## More\n\nText.\n") + + code, out, err = run(capsys, root, "stage", "--lang", "ja") + + assert (code, out, err) == snapshot( + ( + 0, + "staged ja at .build/i18n/ja/docs\n", + "index.md: staged in English (2 headings vs 3 in the English: keep every heading, and no others)\n", + ) + ) + staged = sorted((root / ".build" / "i18n" / "ja" / "docs").glob("*.md")) + notice_lines = {path.name: path.read_text(encoding="utf-8").split("\n")[2] for path in staged} + assert notice_lines == snapshot( + { + "index.md": '!!! note "英語で表示"', + "migration.md": '!!! note "英語で表示"', + "tools.md": '!!! note "英語版より古い翻訳"', + "translations.md": '??? note "機械翻訳"', + } + ) + assert staged_page(root, "tools.md").endswith("## エラー {#errors}\n\n失敗を伝えるには例外を送出します。\n") + + +def test_notice_links_climb_from_the_staged_page_to_the_english_page_and_this_sites_translations_page() -> None: + """Tool-defined: links are written relative to the page's source path, as the renderer reads them: the + English page is the same path one site up (out of the page's directory, then out of the language site) + and the translations page is this site's own `translations.md`, so no link names a host or a path prefix + and a mirrored copy of the sites keeps working.""" + notice = t.Notice('The "outdated" one', "Compare the [English page](ENGLISH_PAGE); see [why](TRANSLATIONS_PAGE).") + + assert t.render_notice(notice, "outdated", "servers/deep/page.md") == snapshot("""\ +!!! note "The 'outdated' one" + + Compare the [English page](../../../servers/deep/page/); see [why](../../translations.md).\ +""") + assert t.render_notice(notice, "translated", "servers/index.md") == snapshot("""\ +??? note "The 'outdated' one" + + Compare the [English page](../../servers/); see [why](../translations.md).\ +""") + + +def test_stage_lays_out_reordered_and_removed_sections_by_their_recorded_hashes_never_by_position( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Tool-defined: swapping two English `##` sections (each with its own code block) or removing one leaves + every remaining section its recorded translation, so the staged page follows today's order with each code + block under its own prose and each id on its own heading, under the "outdated" notice.""" + root = make_repo(tmp_path) + tools = TOOLS.replace( + "Raise to signal a failure.\n", "Raise to signal a failure:\n\n```python\nraise ValueError\n```\n" + ) + tools_ja = TOOLS_JA.replace("例外を送出します。\n", "例外を送出します:\n\n```python\nraise ValueError\n```\n") + write(root / "docs" / "tools.md", tools) + fake = FakeTranslator([INDEX_JA, tools_ja, TRANSLATIONS_JA, NOTICES_JA]) + assert run(capsys, root, "translate", "--lang", "ja", translator=fake)[0] == 0 + intro, first_tool, errors = t.sections(tools) + write(root / "docs" / "tools.md", intro + errors + first_tool) + write(root / "docs" / "index.md", t.sections(INDEX)[0]) + + code, out, err = run(capsys, root, "stage", "--lang", "ja") + + assert (code, out, err) == (0, "staged ja at .build/i18n/ja/docs\n", "") + assert staged_page(root, "tools.md") == snapshot("""\ +# ツール {#tools} + +!!! note "英語版より古い翻訳" + + 一部が古い可能性があります。[英語版](../tools/)と比べてください。 + +**ツール**はモデルが呼び出せる関数です。[ホーム](index.md#install)から始めましょう。 + +## エラー {#errors} + +失敗を伝えるには例外を送出します: + +```python +raise ValueError +``` + +## 最初のツール {#your-first-tool} + +```python title="server.py" +--8<-- "docs_src/server.py" +``` + +!!! note "注意" + どのツールも `async` に対応しています。 +""") + assert staged_page(root, "index.md") == snapshot("""\ +# ホーム {#home} + +!!! note "英語版より古い翻訳" + + 一部が古い可能性があります。[英語版](../)と比べてください。 + +MCP へようこそ。[ツール](tools.md#errors)または [API](../api/mcp/) を参照してください。 +""") + + +def test_stage_that_stops_part_way_leaves_no_titles_file_from_an_earlier_run( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Tool-defined: `titles.json` tells `build_config.py --lang` the tree beside it is complete, so a run + that fails midway (here an excluded page vanished from `docs/`) must not leave the previous run's behind.""" + root = make_repo(tmp_path) + assert run(capsys, root, "stage", "--lang", "ja")[0] == 0 + titles = root / ".build" / "i18n" / "ja" / "titles.json" + listed_before = titles.is_file() + (root / "docs" / "migration.md").unlink() + + code, out, err = run(capsys, root, "stage", "--lang", "ja") + + assert (listed_before, code, out, titles.exists()) == (True, 2, "", False) + assert err.startswith(f"translations: cannot read {root / 'docs' / 'migration.md'}: ") # then the OS's wording + + +def test_stage_without_lang_stages_every_language_in_the_registry( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Tool-defined: one invocation assembles every language's tree (the site build's single staging pass); + a language with no prompt inputs yet still stages, in English.""" + root = make_repo(tmp_path) + write( + root / "i18n" / "languages.yml", LANGUAGES + " - code: ko\n name: 한국어\n theme: ko\n hreflang: ko\n" + ) + + code, out, err = run(capsys, root, "stage") + + assert (code, out, err) == (0, "staged ja at .build/i18n/ja/docs\nstaged ko at .build/i18n/ko/docs\n", "") + listed = [(root / ".build" / "i18n" / code / "titles.json").is_file() for code in ("ja", "ko")] + assert listed == [True, True] diff --git a/uv.lock b/uv.lock index b992b278aa..a391152f0e 100644 --- a/uv.lock +++ b/uv.lock @@ -49,6 +49,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "anthropic" +version = "0.121.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "docstring-parser" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/ca/3cb2c20ee729736fbd4546d5d8b67e818288529fe70cb7a80dbf80aef70b/anthropic-0.121.0.tar.gz", hash = "sha256:e79d6e08ab3376602fc9a70d4d5ea3540817c76cf7e16658bed790834e1833d6", size = 1013292, upload-time = "2026-08-07T17:11:07.241Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/91/b3d41643f1f639927e8c5fb02c3bd8bffe6f1f29e219b3bd4c61e267b15c/anthropic-0.121.0-py3-none-any.whl", hash = "sha256:6048713fa441e59e1cba8363171cd2a86273b25bd213e9c7ac70a523af88b011", size = 1035493, upload-time = "2026-08-07T17:11:08.508Z" }, +] + [[package]] name = "anyio" version = "4.10.0" @@ -513,6 +532,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/0c/03cc99bf3b6328604b10829de3460f2b2ad3373200c45665c38508e550c6/dirty_equals-0.9.0-py3-none-any.whl", hash = "sha256:ff4d027f5cfa1b69573af00f7ba9043ea652dbdce3fe5cbe828e478c7346db9c", size = 28226, upload-time = "2025-01-11T23:23:37.489Z" }, ] +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.0" @@ -594,6 +631,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + [[package]] name = "httpcore2" version = "2.5.0" @@ -607,6 +657,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c9/a1/7564199d1a8728fe737b0a72e5b3f8d92dfe085a74ddf7cdd83bce5f206d/httpcore2-2.5.0-py3-none-any.whl", hash = "sha256:5ce35188de461d31e8d000bfb8ef8bf22c6c16587a211e5571deaa5e9bdf842a", size = 80330, upload-time = "2026-06-25T14:16:53.634Z" }, ] +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "httpx2" version = "2.5.0" @@ -703,6 +768,105 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "jiter" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/1f/10936e16d8860c70698a1aa939a46aa0224813b782bce4e000e637da0b2d/jiter-0.16.0.tar.gz", hash = "sha256:7b24c3492c5f4f84a37946ad9cf504910cf6a782d6a4e0689b6673c5894b4a1c", size = 176431, upload-time = "2026-06-29T13:05:13.657Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/d8/b959609e44012a42b1f3e5ba98ea3b33c7e41e6d4b77cd8f00fd19b1d3ad/jiter-0.16.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:c5fc4f8def331036a7b8e981b4347ebe409981edbc8308a5ea842b8c3614fa6c", size = 310082, upload-time = "2026-06-29T13:02:31.356Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3d/4d7f5667ea0e0548534ba880b84bb3d12924fd133aa83ad6c6c80fca3d76/jiter-0.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5a71d0d2014c3275043e1170bf3d4e771493cb0dcf07be54c567155f4d8ee64b", size = 315643, upload-time = "2026-06-29T13:02:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/9b/83/bed2dcb5c9f3e1ccfcbc67dda48265fe7d5ad0c9cadda5fe95f6e3b87f94/jiter-0.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:741eed508c233a76313a1c7b001f8f21b82f14327e9196ae8bd29a2cc164ae84", size = 341363, upload-time = "2026-06-29T13:02:34.853Z" }, + { url = "https://files.pythonhosted.org/packages/f4/2f/6bb3c3dda668ebc0445689c81a2b0f26a82b10843d67ed9c9b2c3edc177f/jiter-0.16.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3fb7bc819187b56dc48aa5c833aaf92257da8e07efdb9306156667bd2eeb491c", size = 365483, upload-time = "2026-06-29T13:02:36.295Z" }, + { url = "https://files.pythonhosted.org/packages/92/35/8a045ccb39164e70dcdae696413b661771f148b68b12b175c3a04d901937/jiter-0.16.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c9610fd25ebccb43fca584136f5c2fbb26802447eccd430dfdbab95a0fd5126", size = 461219, upload-time = "2026-06-29T13:02:38.116Z" }, + { url = "https://files.pythonhosted.org/packages/e7/99/22292dbbf0ed0c610cfe5ddc7f3bd67237a412f121318f865196e62a07bd/jiter-0.16.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4a1d68ff7ca1d3b5dee20a97a3decda7d5f15003823bf6d140c81f8561d3bc5c", size = 374905, upload-time = "2026-06-29T13:02:40.357Z" }, + { url = "https://files.pythonhosted.org/packages/29/ac/2f55ccb1f0eeafa6d89d24caf52f6f0944a59290ee199e9ade62177dca42/jiter-0.16.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb08c276dd02dac3a284acdd02cacc630d2e3cd6572a4b85519f35cbd133c3de", size = 348320, upload-time = "2026-06-29T13:02:41.923Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/7d88b9174c40064fabc07c84a9b62e6b10f5644562ec0e0a29392edbe978/jiter-0.16.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:8fc4d94713c4697347e38faf7d6ef91547c142219bdcfc7220c4870879974244", size = 356519, upload-time = "2026-06-29T13:02:43.436Z" }, + { url = "https://files.pythonhosted.org/packages/27/57/c4a33aeef513a9d5e26e31534e0bcc752d6ea0e54c94ddb7b68bade669c2/jiter-0.16.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a0f05e229edb29e68cdd0ccb83cea13b64263416120cf943767a6fd72e6787f", size = 394204, upload-time = "2026-06-29T13:02:44.987Z" }, + { url = "https://files.pythonhosted.org/packages/9d/70/c6c23e76ebb3766b111bc399437bbc9f870a76e2a92e10b2a5f561d57372/jiter-0.16.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:2c842cbf374a8daf50b2c04212995bee34ca2ac2cdc29a901b4cdb072c9c4131", size = 521477, upload-time = "2026-06-29T13:02:46.724Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d3/0001c8c0c5976af2625bb1cfb1895e8ec693b6589fe4574b8e6fc2c85501/jiter-0.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5ed466aee31294d7cdcd4d37dfe5c42c97bc29d9a5f00eacf24504358309cb9b", size = 552187, upload-time = "2026-06-29T13:02:48.144Z" }, + { url = "https://files.pythonhosted.org/packages/f6/76/311b718e07e85740e48619c0632b36f7e0b8d113984499e436452ed13a9a/jiter-0.16.0-cp310-cp310-win32.whl", hash = "sha256:b42e9ff5376819c053da25809a8d4b6fa6e473b4856ebe42e298ac958be3d7f9", size = 206513, upload-time = "2026-06-29T13:02:49.515Z" }, + { url = "https://files.pythonhosted.org/packages/db/7f/ac680eeb0777dc0eb7dc824800ba27880d7f6bc712e362d34ad8ee559f36/jiter-0.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:10438939205546132189c8e74a2d536a707841f3a25cd7c74ee91fe503407a26", size = 199505, upload-time = "2026-06-29T13:02:50.829Z" }, + { url = "https://files.pythonhosted.org/packages/4e/3f/fae6cc967d120ec89e31c5418a51176d8278b3087fbb384a9176754f353c/jiter-0.16.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:67fddeda1688f0cce2d2ae83ccf8a80f79936f2d2997d6cc2261f82fdb54a4d3", size = 309289, upload-time = "2026-06-29T13:02:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e3/97c6c3562c077f6247d6e6ce5c82562500b6316c0d928e97e106b7a1321a/jiter-0.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c90c0f63df322be920eda6ce622e3083d8906ba267f8220fe7873213b8b4430e", size = 315181, upload-time = "2026-06-29T13:02:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/7b/89/d8d073f8aa2667e46c6c0873f86fe4a512bba4293cc730f626a076211a62/jiter-0.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64c0203212098470032aabcde9356fc168f377aade3e43def61dfe17e92f2037", size = 340939, upload-time = "2026-06-29T13:02:55.412Z" }, + { url = "https://files.pythonhosted.org/packages/87/c9/db4fda3ed73fb864139305e935e5b8b38a5a24692a5a9dd356c22f1b9c8d/jiter-0.16.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12288303c9844e61e1651d02a9a6f6633e47d39f897d6991d1427161ce6b746e", size = 364932, upload-time = "2026-06-29T13:02:57.28Z" }, + { url = "https://files.pythonhosted.org/packages/a2/74/52b5e86241057f52ddd7c9a580f90effb51f9d06239f6fc612279b91a838/jiter-0.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cf109d010b4b05a105afb3d43be36a21322d345ad3111e13d15f680afef0e5b", size = 461132, upload-time = "2026-06-29T13:02:58.994Z" }, + { url = "https://files.pythonhosted.org/packages/a9/87/544a700f7447c1f31c5d7833821a4daa5683165c2d5a094fbf5b5800c3dc/jiter-0.16.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62c1b7fe1f77925acf5af68b6140b8810fa87dfd4dc0a9c8568ec2fa2a10429c", size = 374857, upload-time = "2026-06-29T13:03:00.455Z" }, + { url = "https://files.pythonhosted.org/packages/40/cd/0fcc3f7d39183674d5bfa9ec640faaeb506c60be7c8f94625dfba366e37c/jiter-0.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8597d23c87f59294f83bcb6229b9ed1fccee13dbba967b46930d2f1759466fee", size = 347053, upload-time = "2026-06-29T13:03:02.045Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ae/c7e64e7932ad597fa395b61440b249ada6366716e25c6e08dd2afbd021e6/jiter-0.16.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:3126a5dbad56401989ac769aca0cb56005bfb3e2366eea0ca99d1a91c3c1ee03", size = 356153, upload-time = "2026-06-29T13:03:03.706Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/1c719044f14da814e1a060191ab19b96f3e99207bc5b4bfc6d6be34b3f80/jiter-0.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c4b4717bdb35ae456f831a6b08d01880fff399887a6bbc526a583a406e484eea", size = 393956, upload-time = "2026-06-29T13:03:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/3b/dc/7b2f303a2847207e265503853a2d964a55354cffd62a5f2936c155486798/jiter-0.16.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:adff21bc78edfe086c15eb495b900306076de378dc2337c132401fc39bd79c91", size = 521081, upload-time = "2026-06-29T13:03:06.886Z" }, + { url = "https://files.pythonhosted.org/packages/c2/5f/501cf6e1e09caeb420195179ffc6f62aca603f1220ec53fd80d0d70b3e56/jiter-0.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:dab907db06fc593645e73109acf4581ba5b548897d28b9348dc41ddc8343b2d3", size = 552085, upload-time = "2026-06-29T13:03:08.339Z" }, + { url = "https://files.pythonhosted.org/packages/79/54/aa5be86520113b79455c3877f3d1f07a348098df4083ba3688e9537e52dd/jiter-0.16.0-cp311-cp311-win32.whl", hash = "sha256:560b2cf3fb03240cd34f27409a238547488708f05b7c3924f571a60422251ec7", size = 206755, upload-time = "2026-06-29T13:03:09.653Z" }, + { url = "https://files.pythonhosted.org/packages/64/ec/2feb893eb330bd69b413866f4d5daada33c3962f1c6f270c91ca2d87fdf9/jiter-0.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:e431cfc9caf44c1d5459ff77d4e64cbf85fddb6a35dad836a15c6a9ec23087c1", size = 199155, upload-time = "2026-06-29T13:03:10.979Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9c/ca040d94415048a3666fc237774df8151c96f8d2b661cbe3b184acc95876/jiter-0.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:2a8e9e39cf083016137aa5cadafe3188adc2ba6ba1fbf1e5d18889ad3e9ad056", size = 194403, upload-time = "2026-06-29T13:03:12.341Z" }, + { url = "https://files.pythonhosted.org/packages/83/2b/52ace16ed031354f0539749a49e4bf33797d82bea5137910835fa4b09793/jiter-0.16.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:67c3bc1760f8c99d805dcab4e644027142a53b1d5d861f18780ebdbd5d40b72a", size = 306943, upload-time = "2026-06-29T13:03:14.035Z" }, + { url = "https://files.pythonhosted.org/packages/94/2e/34957c2c1b661c252ba9bcc60ae0bddc27e0f7202c6073326a13c5390eec/jiter-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5af7780e4a26bd7d0d989592bf9ef12ebf806b74ab709223ecca37c749872ea9", size = 307779, upload-time = "2026-06-29T13:03:15.418Z" }, + { url = "https://files.pythonhosted.org/packages/88/6c/59bd309cab4460c54cf1079f3eb7fe7af6a4c895c5c957a53378693bad2b/jiter-0.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5bf78d0e05e45cfdd66558893938d59afe3d1b1a824a202039b20e607d25a72", size = 335826, upload-time = "2026-06-29T13:03:17.11Z" }, + { url = "https://files.pythonhosted.org/packages/3b/8c/f5ef7b65f0df47afa16596969defb281ebb86e96df346d62be6fd853d620/jiter-0.16.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4444a83f946605990c98f625cdd3d2725bfb818158760c5748c653170a20e0e", size = 362573, upload-time = "2026-06-29T13:03:18.781Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0b/ace4354da061ee38844a0c27dc2c21eecd27aea119e8da324bea987522d0/jiter-0.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3a23f0e4f957e1be65752d2dfac9a5a06b1917af8dc85deb639c3b9d02e31290", size = 457979, upload-time = "2026-06-29T13:03:20.293Z" }, + { url = "https://files.pythonhosted.org/packages/55/40/c0253d3772eb9dcd8e6606ee9b2d53ec8e5b814589c47f140aa585f21eaa/jiter-0.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c22a488f7b9218e245a0025a9ba6b100e2e54700831cf4cf16833a27fba3ad01", size = 372302, upload-time = "2026-06-29T13:03:21.739Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d2/4839422241aa12860ce597b20068727094ba0bc480723c74924ca5bad483/jiter-0.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46add52f4ad47a08bfb1219f3e673da972191489a33016edefdb5ea55bfa8c48", size = 343805, upload-time = "2026-06-29T13:03:23.384Z" }, + { url = "https://files.pythonhosted.org/packages/e2/59/e196888a05befdda7dbe299b722d56f2f6eec65402bc34c0a3306d595feb/jiter-0.16.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:9c8a956fd72c2cf1e730d01ea080341f13aa0a97a4a33b51abebe725b7ae9ca9", size = 351107, upload-time = "2026-06-29T13:03:24.815Z" }, + { url = "https://files.pythonhosted.org/packages/ec/74/4cd9e0fca65232136400354b630fbfcd2de634e22ccbb96567725981b548/jiter-0.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:561926e0573ffe4a32498420a76d64b16c513e1ab413b9d28158a8764ac701e5", size = 388441, upload-time = "2026-06-29T13:03:26.266Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8c/554691e48bc711299c0a293dd8a6179e24b2d66a54dc295421fcf64569c0/jiter-0.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:44d019fa8cdaf89bf29c71b39e3712143fdd0ac76725c6ef954f9957a5ea8730", size = 516354, upload-time = "2026-06-29T13:03:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/a4/cb/01e9d69dc2cc6759d4f91e230b34489c4fdb2518992650633f9e20bece89/jiter-0.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0df91907609837f33341b8e6fe73b95991fdaa57caf1a0fbd343dffe826f386f", size = 547880, upload-time = "2026-06-29T13:03:29.534Z" }, + { url = "https://files.pythonhosted.org/packages/79/70/2953195f1c6ad00f49fa67e13df7e60acb3dd4f387101bc15abccddd905e/jiter-0.16.0-cp312-cp312-win32.whl", hash = "sha256:51d7b836acb0108d7c77df1742332cac2a1fa04a74d6dacec46e7091f0e91274", size = 203473, upload-time = "2026-06-29T13:03:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/2d/05/2909a8b10699a4d560f8c502b6b2c5f3991b682b1922c1eedda242b225bd/jiter-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:1878349266f8ee36ecb1375cc5ba2f115f35fd9f0a1a4119e725e379126647f7", size = 196905, upload-time = "2026-06-29T13:03:32.472Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a9/6b82bb1c8d7790d602489b967b982a909e5d092875a6c2ade96444c8dfc5/jiter-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:2ed5738ae4af18271a51a528b8811b0cbfa4a1858de9d83359e4169855d6a331", size = 190618, upload-time = "2026-06-29T13:03:34.672Z" }, + { url = "https://files.pythonhosted.org/packages/91/c0/555fc60473d30d66894ba825e63615e3be7524fac23858356afa7a38906c/jiter-0.16.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:41977aa5654023948c2dae2a81cbf9c43343954bef1cd59a154dd15a4d84c195", size = 306203, upload-time = "2026-06-29T13:03:36.243Z" }, + { url = "https://files.pythonhosted.org/packages/d0/2b/c3eaf16f5d7c9bad66ea32f40a95bd169b29a91217fcc7f081375157e99c/jiter-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d28bb3c26762358dadf3e5bf0bccd29ae987d65e6988d2e6f49829c76b003c09", size = 306489, upload-time = "2026-06-29T13:03:37.846Z" }, + { url = "https://files.pythonhosted.org/packages/96/3f/02fdfc6705cad96127d883af5c34e4867f554f29ec7705ec1a46156400a9/jiter-0.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0542a7189c26920778658fc8fcf2af8bae05bae9924577f71804acef37996536", size = 335453, upload-time = "2026-06-29T13:03:39.221Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a6/e4bda5920d4b0d7c5dfb7174ce4a6b2e4d3e11c9162c452ef0eab4cdbdbd/jiter-0.16.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8fb8de1e23a0cb2a7f53c335049c7b72b6db41aa6227cdcc0972a1de5cb39450", size = 361625, upload-time = "2026-06-29T13:03:40.597Z" }, + { url = "https://files.pythonhosted.org/packages/b7/97/4e6b59b2c6e55cbb3e183595f81ad65dcfb21c915fee5e19e335df21bc55/jiter-0.16.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b72d0b2990ca754a9102779ac98d8597b7cb31678958562214a007f909eab78e", size = 456958, upload-time = "2026-06-29T13:03:42.074Z" }, + { url = "https://files.pythonhosted.org/packages/15/e0/97e9557686d2f94f4b93786eccb7eed28e9228ad132ea8237f44727314a7/jiter-0.16.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f91b1c27fc22a57993d5a5cb8a627cb8ed4b10502716fac1ffbfe1d19d84e8", size = 372017, upload-time = "2026-06-29T13:03:43.658Z" }, + { url = "https://files.pythonhosted.org/packages/0f/94/db768b6938e0df35c86beeba3dfbbb025c9ee5c19e1aa271f2396e50864d/jiter-0.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c682bea068a90b764577bdb78a60a4c1d1606daf9cd4c893832a37c7cc9d9026", size = 343320, upload-time = "2026-06-29T13:03:45.226Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d6/5a59d938244a30735fe62d9433fd325f9021ea29d89780ea4596ea93bc89/jiter-0.16.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:8d031aabecc4f1b6276adfb42e3aabb77c89d468bf616600e8d3a11328929053", size = 350520, upload-time = "2026-06-29T13:03:46.671Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/c4a857f49c9af125f6bbcac7e3eee7f7978ed89682833062e2dbf62576b1/jiter-0.16.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eab2cd170150e70153de16896a1774e3a1dca80154c56b54d7a812c479a7165e", size = 387550, upload-time = "2026-06-29T13:03:48.361Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d6/5fbc2f7d6b67b754caa61a993a2e626e815dec47ffc2f9e35f01adfebec7/jiter-0.16.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6edb63a46e65a82c26800a868e49b2cac30dd5a4218b88d74bc2c848c8ad60bb", size = 515424, upload-time = "2026-06-29T13:03:49.881Z" }, + { url = "https://files.pythonhosted.org/packages/ed/54/284f0164b64a5fed915fea6ba7e9ba9b3d8d37c67d59cf2e3bb99d45cdfe/jiter-0.16.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:659039cc50b5addcc35fcc87ae2c1833b7c0a8e5326ef631a75e4478447bcf84", size = 546981, upload-time = "2026-06-29T13:03:51.363Z" }, + { url = "https://files.pythonhosted.org/packages/13/c5/2a467585a576594384e1d2c43e1224deaafc085f24e243529cf98beef8e1/jiter-0.16.0-cp313-cp313-win32.whl", hash = "sha256:c9c53be232c2e206ef9cdbad81a48bfa74c3d3f08bcf8124630a8a748aad993e", size = 202853, upload-time = "2026-06-29T13:03:53.015Z" }, + { url = "https://files.pythonhosted.org/packages/88/6a/de61d04b9eec69c71719968d2f716532a3bc121170c44a39e14979c6be81/jiter-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:baad945ed47f163ad833314f8e3288c396118934f94e7bbb9e243ce4b341a4fd", size = 196160, upload-time = "2026-06-29T13:03:54.447Z" }, + { url = "https://files.pythonhosted.org/packages/19/4b/b390ed59bafb3f31d008d1218578f10327714484b334439947f7e5b11e7f/jiter-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:3c1fd2dbe1b0af19e987f03fe66c5f5bd105a2229c1aff4ab14890b24f41d21a", size = 189862, upload-time = "2026-06-29T13:03:55.754Z" }, + { url = "https://files.pythonhosted.org/packages/a7/89/bc4f1b57d5da938fd344a466396541e586d161320d70bffd929aaafcd8f4/jiter-0.16.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b2c61484666ad42726029af0c00ef4541f0f3b5cdc550221f56c2343208018ee", size = 308239, upload-time = "2026-06-29T13:03:57.205Z" }, + { url = "https://files.pythonhosted.org/packages/65/7a/c415453e5213001bf3b411ff65dec3d303b0e76a4a2cfea9768cd4960994/jiter-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:63efadc657488f45db1c676d81e704cac2abf3fdb892def1faea61db053127e2", size = 308928, upload-time = "2026-06-29T13:03:58.643Z" }, + { url = "https://files.pythonhosted.org/packages/11/fc/1f4fb7ebf9a724c7741994f4aae18fba1e2f3133df14521a79194952c34a/jiter-0.16.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf0d73f50e7b6935677854f6e8e31d499ca7064dd24734f703e060f5b237d883", size = 336998, upload-time = "2026-06-29T13:04:00.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/8d/72cadaac05ccfa7cc3a0a2232862e6c72443ca40cf300ba8b57f9f18b69b/jiter-0.16.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3ea07d9bc8e7d03a9fbc051295462e6dbc295b894fd72457c3136e3e43d898", size = 362112, upload-time = "2026-06-29T13:04:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/58/4a/c4b0d5f651fda90a24ffce9f8d56cde462a2e09d31ae3de3c68cef34c04e/jiter-0.16.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:26798522707abb47d767db536e4148ceac1b14446bf028ee85e579a2e043cfe5", size = 459807, upload-time = "2026-06-29T13:04:03.214Z" }, + { url = "https://files.pythonhosted.org/packages/80/58/ef77879ea9aa56b50824edc5a445e226422c7a8d211f3fd2a56bcb9493cf/jiter-0.16.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bc837c1b9631be10abfe0191537fe8009838204cec7e44827401ace390ddb567", size = 373181, upload-time = "2026-06-29T13:04:04.629Z" }, + { url = "https://files.pythonhosted.org/packages/49/2e/ffbc3f254e4d8a66da3062c624a7df4b7c2b2cf9e1fe43cf394b3e104041/jiter-0.16.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49060fd70737fad59d33ba9dcc0d83247dc9e77187de26053a19c16c9f32bd69", size = 344927, upload-time = "2026-06-29T13:04:06.067Z" }, + { url = "https://files.pythonhosted.org/packages/9a/f6/0be5dc6d64a89f80aa8fec984f94dedb2973e251edcae55841d60786d578/jiter-0.16.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:adbb8edeadd431bc4477879d5d371ece7cb1334486584e0f252656dd7ffada29", size = 352754, upload-time = "2026-06-29T13:04:07.477Z" }, + { url = "https://files.pythonhosted.org/packages/da/6e/7d31243b3b91cd261dd19e9d3557fc3251a80883d3d8049c86174e7ab7af/jiter-0.16.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:31aaee5b80f672c1dc21272bcfb9cbdcfc1ea04ff50f00ed5af500b80c44fa93", size = 390553, upload-time = "2026-06-29T13:04:08.92Z" }, + { url = "https://files.pythonhosted.org/packages/25/33/51ae371fde3c88897520f62b4d5f8b27ad7103e2bb10812ff52195609853/jiter-0.16.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:6722bcef4ffc86c835574b1b2fac6b33b9fb4a889c781e67950e891591f3c55a", size = 516900, upload-time = "2026-06-29T13:04:10.407Z" }, + { url = "https://files.pythonhosted.org/packages/a0/45/6449b3d123ea439ba79507c657288f461d55049e7bcbdc2cf8eb8210f491/jiter-0.16.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:5ab4f50ff971b611d656554ea10b75f80097392c827bc32923c6eeb6386c8b00", size = 548754, upload-time = "2026-06-29T13:04:12.046Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e7/fd2fb11ae3e2649333da3aa170d04d7b3000bbdc3b270f6513382fdf4e04/jiter-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:710cc51d4ebdcd3c1f70b232c1db1ea1344a075770422bbd4bede5708335acbe", size = 122381, upload-time = "2026-06-29T13:04:13.413Z" }, + { url = "https://files.pythonhosted.org/packages/26/80/f0b147a62c315a164ed2168908286ca302310824c218d3aae52b06c0c9a9/jiter-0.16.0-cp314-cp314-win32.whl", hash = "sha256:57b37fc887a32d44798e4d8ebfa7c9683ff3da1d5bf38f08d1bb3573ccb39106", size = 204578, upload-time = "2026-06-29T13:04:14.813Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e6/4758a14304b4523a6f5adb2419340086aa3593bd4327c2b25b5948a90548/jiter-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:cbd18dd5e2df96b580487b5745adf57ef64ad89ba2d9662fc3c19386acce7db8", size = 198154, upload-time = "2026-06-29T13:04:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/26/be/41fa54a2e7ea41d6c99f1dc5b1f0fd4cb474680304b5d268dd518e81da3a/jiter-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:a32d2027a9fa67f109ff245a3252ece3ccc32cc56703e1deab6cc846a59e0585", size = 191458, upload-time = "2026-06-29T13:04:17.707Z" }, + { url = "https://files.pythonhosted.org/packages/81/6b/59127338b86d9fe4d99418f5a15118bea778103ee0fe9d9dd7e0af174e95/jiter-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2577196f4474ef3fc4779a088a23b0897bbf86f9ea3679c372d45b8383b43207", size = 316739, upload-time = "2026-06-29T13:04:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/2d/95/49461034d5388196d3dabf98748935f017b7785d8f3f5349f834bcc4ed0d/jiter-0.16.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:616e89e008a93c01104161c75b4988e58716b01d62307ebfe161e52a56d2a818", size = 340911, upload-time = "2026-06-29T13:04:21.257Z" }, + { url = "https://files.pythonhosted.org/packages/cd/97/a4369f2fb82cb3dda13b98622f31249b2e014b223fe64ee534413ad72294/jiter-0.16.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e2e9efbe042210df657bade597f66d6d75723e3d8f45a12ea6d8167ff8bbce3", size = 361747, upload-time = "2026-06-29T13:04:22.677Z" }, + { url = "https://files.pythonhosted.org/packages/28/51/49b6ed456261646e1906016a6760367a28aacd3c24805e4e5fe64116c1db/jiter-0.16.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f4d9e473a5ce7d27fef8b848df4dc16e283893d3f53b4a585e72c9595f3c284", size = 460225, upload-time = "2026-06-29T13:04:24.441Z" }, + { url = "https://files.pythonhosted.org/packages/33/b5/5689aff4f66c5b60be63106e591dbfcba2190df97d2c9c7cf052361ddb98/jiter-0.16.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d30a4a1c87713060c8d1cc59a7b6c8fb6b8ef0a6900368014c76c87922a2929", size = 373169, upload-time = "2026-06-29T13:04:25.884Z" }, + { url = "https://files.pythonhosted.org/packages/a2/96/3ae1b85ee0d6d6cab254fb7f8da018272b932bbf2d69b07e98aa2a96c746/jiter-0.16.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bae96332410f866e5900d809298b1ed82735932986c672495f9701daacd80620", size = 350332, upload-time = "2026-06-29T13:04:27.302Z" }, + { url = "https://files.pythonhosted.org/packages/15/32/c99d7bafd78986556c95bf60ce84c6cc98786eac56066c12d7f828bb6747/jiter-0.16.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:da3d7ec75dc83bb18bca888b5edfae0656a26849056c59e05a7728badd17e7af", size = 353377, upload-time = "2026-06-29T13:04:28.731Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/f99a8e571287c3dec766bcc18528bbe8e8fb5365522ab5e6d64c93e87066/jiter-0.16.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ee6162b77d49a9939229df666dfa8af3e656b6701b54c4c84966d740e189264e", size = 387746, upload-time = "2026-06-29T13:04:30.319Z" }, + { url = "https://files.pythonhosted.org/packages/75/69/c78a5b3f71040e34eb5917df26fb7ae9a2174cad1ccbf277512507c53a6e/jiter-0.16.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:63ffdbdae7d4499f4cda14eadc12ddcabef0fc0c081191bdc2247489cb698077", size = 517292, upload-time = "2026-06-29T13:04:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f7/095b38eda4c70d03651c403f29a5590f16d12ddc5d544aac9f9cddf72277/jiter-0.16.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a111256a7193bea0759267b10385e5870949c239ed7b6ddbaaf57573edb38734", size = 549259, upload-time = "2026-06-29T13:04:33.721Z" }, + { url = "https://files.pythonhosted.org/packages/2e/c5/6a0207d90e5f656d95af98ebd0934f382d37674416f215aeda2ff8063e51/jiter-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:de5ba8763e56b793561f43bed197c9ea55776daa5e9a6b91eed68a909bc9cdbf", size = 206523, upload-time = "2026-06-29T13:04:35.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/31/c757d5f30a8980fd945ce7b98be10be9e4ff59c7c42f5fd86804c2e87db8/jiter-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b8a3f9a6008048fe9def7bf465180564a6e458047d2ce499149cfbe73c3ae9db", size = 200366, upload-time = "2026-06-29T13:04:36.61Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a2/d88de6d313d734a544a7901353ad5db67cb38dcfcd91713b7979dafc345d/jiter-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0fa25b09b13075c46f5bc174f2690525a925a4fc2f7c82969a2bbabff22386ce", size = 190516, upload-time = "2026-06-29T13:04:38.004Z" }, + { url = "https://files.pythonhosted.org/packages/06/d3/8e278946d43eeca2585b4dd0834a887cd71136329b837f3a16ed86a8b4b0/jiter-0.16.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:850ccb1d7eedb4200f4014b1c0e8a577de114fc3cd88faad646dcc9bc4bb12ad", size = 304518, upload-time = "2026-06-29T13:05:00.172Z" }, + { url = "https://files.pythonhosted.org/packages/72/43/28d4ef495028bf0506a413d4db3f4eb3e7288a382e0f065f306a17bbeb5e/jiter-0.16.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:e34e97bda77eb63242a410243c071e28ac7e0d8c0948c5ee658498690a4b2f2f", size = 310207, upload-time = "2026-06-29T13:05:02.123Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ca/c366b1012da1d640de975d9683acd44e4d150d9068845d0ca2610435253f/jiter-0.16.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7dc85ea77d4abbae8bad0d3538678aedee75bceec4e2f6c8dfb1c74772e5aa5", size = 342771, upload-time = "2026-06-29T13:05:03.55Z" }, + { url = "https://files.pythonhosted.org/packages/16/52/50cc4056fc1ae02e7154704e7ecc89df0afb8300222cfe8a52d3f67e4730/jiter-0.16.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17ca7fae79f6d99cd9a042b75f917eaada7b895cfc7dd2ee3a16089dcaec7a85", size = 346468, upload-time = "2026-06-29T13:05:05.452Z" }, + { url = "https://files.pythonhosted.org/packages/98/ab/664fd8c4be028b2bedd3d2ff08769c4ede23d0dbc87a77c62384a0515b5d/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:f17d61a28b4b3e0e3e2ba98490c70501403b4d196f78732439160e7fd3678127", size = 303106, upload-time = "2026-06-29T13:05:07.118Z" }, + { url = "https://files.pythonhosted.org/packages/1a/07/421f1d5b65493a76e16027b848aba6a7d28073ae75944fa4289cc914d39f/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:96e38eea538c8ddf853a35727c7be0741c76c13f04148ac5c116222f50ece3b3", size = 304658, upload-time = "2026-06-29T13:05:08.708Z" }, + { url = "https://files.pythonhosted.org/packages/0a/db/bba1155f01a01c3c37a89425d571da751bbedf5c54247b831a04cb971798/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d284fb8d94d5855d60c44fefcab4bf966f1da6fada73992b01f6f0c9bc0c6702", size = 339719, upload-time = "2026-06-29T13:05:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/78/f7/18a1afcd64f35314b68c1f23afcd9994d0bc13e65cc77517afff4e83986d/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d613743df53199b1aa256a7d328340da6d7078aac7705a7db9d7a791e9cfd2", size = 343885, upload-time = "2026-06-29T13:05:12.087Z" }, +] + [[package]] name = "jsonschema" version = "4.25.1" @@ -889,6 +1053,9 @@ docs = [ { name = "pyyaml" }, { name = "zensical" }, ] +translate = [ + { name = "anthropic" }, +] [package.metadata] requires-dist = [ @@ -944,6 +1111,7 @@ docs = [ { name = "pyyaml", specifier = ">=6.0.2" }, { name = "zensical", specifier = "==0.0.50" }, ] +translate = [{ name = "anthropic", specifier = ">=0.121.0" }] [[package]] name = "mcp-everything-server"