diff --git a/.gitattributes b/.gitattributes index 0ab3744850..0ac8e0fe36 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,5 @@ # Generated uv.lock linguist-generated=true +i18n/languages/*/pages/** linguist-generated=true +i18n/languages/*/state.json linguist-generated=true +i18n/languages/*/nav.yml linguist-generated=true diff --git a/.github/ISSUE_TEMPLATE/translation.yaml b/.github/ISSUE_TEMPLATE/translation.yaml new file mode 100644 index 0000000000..9342fe89cf --- /dev/null +++ b/.github/ISSUE_TEMPLATE/translation.yaml @@ -0,0 +1,57 @@ +name: 🌐 Translation problem +description: Report a wrong, awkward, or misleading passage in a translated docs page +labels: ["translation"] + +body: + - type: markdown + attributes: + value: | + The translated docs are machine-generated from the English pages; https://py.sdk.modelcontextprotocol.io/translations/ explains how. + Fixes never go into the translated text directly. They go into that language's glossary or style guide under `i18n/languages/`, so you can also open a PR there instead of an issue. + + - type: dropdown + id: language + attributes: + label: Language + options: + - Simplified Chinese (zh-CN) + - Japanese (ja) + - Korean (ko) + - Brazilian Portuguese (pt-BR) + validations: + required: true + + - type: input + id: page + attributes: + label: Page URL + description: The translated page where you found the problem. + placeholder: https://py.sdk.modelcontextprotocol.io/ja/servers/tools/ + validations: + required: true + + - type: textarea + id: passage + attributes: + label: The passage + description: Quote the translated text that's wrong, and the English it corresponds to if you have it. + validations: + required: true + + - type: textarea + id: problem + attributes: + label: What's wrong, or how it should read + description: A wrong term, awkward phrasing, meaning that drifted from the English, tone that's off. If you know the better rendering, give it. + validations: + required: true + + - type: dropdown + id: native-speaker + attributes: + label: Are you a native or fluent speaker of this language? + options: + - "Yes" + - "No" + validations: + required: true diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 334ba818c8..87f8cba904 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, banners and the language registry ship in the site + - i18n/** - mkdocs.yml - src/mcp/** - src/mcp-types/** diff --git a/.github/workflows/docs-preview.yml b/.github/workflows/docs-preview.yml index 6f9ec2cc34..d674ea6ed7 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 @@ -137,7 +138,16 @@ jobs: # /preview-docs, still build with MkDocs. Both arms must write the site # to site/. Keep the detection in sync with build_site() in # scripts/build-docs.sh. - - run: | + # + # DOCS_SITE_URL is the preview's Cloudflare branch-alias host (deploy + # publishes to `--branch=pr-`, served at pr-..pages.dev), + # so the absolute links the build bakes point at the preview instead of + # production; empty when no Pages project is configured, which makes + # build.sh fall back to the production site_url. + - env: + DOCS_SITE_URL: >- + ${{ vars.CLOUDFLARE_PAGES_PROJECT && format('https://pr-{0}.{1}.pages.dev', needs.authorize.outputs.pr_number, vars.CLOUDFLARE_PAGES_PROJECT) || '' }} + run: | if [ -f scripts/docs/build.sh ]; then bash scripts/docs/build.sh else diff --git a/.github/workflows/shared.yml b/.github/workflows/shared.yml index 541fc7bb54..16a1b7d392 100644 --- a/.github/workflows/shared.yml +++ b/.github/workflows/shared.yml @@ -139,7 +139,9 @@ jobs: - name: Check README snippets are up to date run: uv run --frozen scripts/update_readme_snippets.py --check - # `scripts/docs/build.sh` is the whole gauntlet: build_config.py fails on + # `scripts/docs/build.sh` is the whole gauntlet: it opens with the fast, + # no-network gates (a prose heading without a pinned anchor, an invalid + # translation config or committed translation), then build_config.py fails on # nav entries without a page and pages without a nav entry, `zensical build # --strict` fails on broken .md links, `pymdownx.snippets: check_paths: # true` fails on a deleted `docs_src/` include, and the post-build steps diff --git a/.gitignore b/.gitignore index 2e788e71d8..1334196169 100644 --- a/.gitignore +++ b/.gitignore @@ -144,10 +144,15 @@ 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). +# Local scratch notes are never part of the repo. +/notes/ +# Generated at build time by scripts/docs/ (the API reference tree, the +# concrete Zensical configs spliced from mkdocs.yml, and the staged per-language +# docs trees). /docs/api/ /mkdocs.gen.yml +/mkdocs.*.gen.yml +/.build/ # mypy .mypy_cache/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 321b60bc52..05404dae8f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,6 +11,9 @@ repos: hooks: - id: prettier types_or: [yaml, json5] + # The translation tool writes each language's state.json and nav.yml + # in its own stable format; a formatter rewrite would fight the generator. + exclude: ^i18n/languages/[^/]+/(state\.json|nav\.yml)$ - repo: https://github.com/igorshubovych/markdownlint-cli rev: v0.45.0 @@ -25,6 +28,10 @@ repos: "/tool/markdown/lint", ] types: [markdown] + # Machine-translated pages are generated artefacts: corrections flow + # through i18n/languages//{instructions.md,glossary.json}, never + # through hand or linter edits to the pages themselves. + exclude: ^i18n/languages/[^/]+/pages/ - repo: local hooks: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b0fb9fa57b..8da38e982a 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/languages//`. Never edit the generated pages under `i18n/languages//pages/`—the next translation run overwrites them. 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 the fix 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/advanced/apps.md b/docs/advanced/apps.md index a60c997b42..b386a56a90 100644 --- a/docs/advanced/apps.md +++ b/docs/advanced/apps.md @@ -1,4 +1,4 @@ -# MCP Apps +# MCP Apps {#mcp-apps} An **MCP App** is a tool with a face: alongside its data, the tool points at an HTML document the host renders as an interactive surface. @@ -18,7 +18,7 @@ The SDK ships this as the built-in `Apps` extension (`io.modelcontextprotocol/ui If [Extensions](extensions.md) are new to you, skim that page first. One minute, then come back. -## A clock with a face +## A clock with a face {#a-clock-with-a-face} ```python title="server.py" hl_lines="19 22 30 32" --8<-- "docs_src/apps/tutorial001.py" @@ -41,7 +41,7 @@ apps, use the official [`@modelcontextprotocol/ext-apps`](https://github.com/mod browser SDK inside your HTML. It gives you `ontoolresult`, `callServerTool`, `getHostContext`, and `onhostcontextchanged` instead of raw message events. -## Graceful degradation +## Graceful degradation {#graceful-degradation} Not every client renders apps. The spec is blunt about what that means for you: @@ -66,7 +66,7 @@ client half of the negotiation, and the rich answer comes back. fallback text is useless, the tool is useless to every text-only client and to the model itself. Write the sentence. -## Locking the iframe down +## Locking the iframe down {#locking-the-iframe-down} The resource side carries the security metadata: what the iframe may load, which browser permissions it wants, how it would like to be framed: @@ -102,7 +102,7 @@ may refuse. Feature-detect in your JS rather than assuming a grant. metadata has no slot for them, and hosts ignore them there. The SDK makes the mistake unrepresentable: `@apps.tool()` simply has no `csp` parameter. -### Visibility +### Visibility {#visibility} `visibility=["app"]` on a tool says "this exists for the iframe, not the model": @@ -113,7 +113,7 @@ may refuse. Feature-detect in your JS rather than assuming a grant. Filtering is the **host's** job. Your server lists app-only tools in `tools/list` like any other; the host hides them from the model. Don't filter server-side. -## The rules the SDK enforces +## The rules the SDK enforces {#the-rules-the-sdk-enforces} All of these fail at startup, not in production: @@ -130,7 +130,7 @@ All of these fail at startup, not in production: Neither the TypeScript ext-apps SDK nor FastMCP catches any of these today; we'd rather you find out before a host does. -## Beyond inline HTML +## Beyond inline HTML {#beyond-inline-html} `add_html_resource` covers the common case: a string of HTML. For anything else, HTML on disk or generated content, build the resource yourself and hand it over: @@ -149,7 +149,7 @@ under any other MIME type is one no host will render. `@apps.tool(resource_uri="ui://x", meta={"ui/resourceUri": "ui://x"})`. The nested `ui` object is the spec shape; the flat key is on its way out. -## See it run +## See it run {#see-it-run} The `apps` story in `examples/stories/` is this page as a runnable pair: a server with a UI-bound clock tool and a client that negotiates Apps, reads the tool's diff --git a/docs/advanced/extensions.md b/docs/advanced/extensions.md index de7937fc75..aa19f20da9 100644 --- a/docs/advanced/extensions.md +++ b/docs/advanced/extensions.md @@ -1,4 +1,4 @@ -# Extensions +# Extensions {#extensions} An **extension** is an opt-in bundle of MCP behaviour behind one identifier. @@ -8,7 +8,7 @@ notifications. Each side advertises under its own `capabilities.extensions`, and changes for anyone who didn't ask for it. That is the contract ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)), and it has one golden rule: **extensions are off by default**. -## Using an extension +## Using an extension {#using-an-extension} Pass instances at construction: @@ -30,11 +30,11 @@ The capability map rides `server/discover`, which is a **2026-07-28** path. A le the extension. Design for that: an extension *augments* a server, it must not be the only way the server is usable. -## Writing your own +## Writing your own {#writing-your-own} Subclass `Extension` and override only what you need. Every method has a default. -### The identifier +### The identifier {#the-identifier} ```python --8<-- "docs_src/extensions/tutorial002.py" @@ -53,7 +53,7 @@ TypeError: Stamps.identifier must be a `vendor-prefix/name` string Use a domain you control as the prefix. `io.modelcontextprotocol/*` is for extensions specified by the MCP project itself. -### Contributing tools +### Contributing tools {#contributing-tools} The smallest useful extension is one tool and a settings map: @@ -75,7 +75,7 @@ And `main()` is the proof, an in-memory client straight against `mcp`: --8<-- "docs_src/extensions/tutorial003.py" ``` -### Serving your own methods +### Serving your own methods {#serving-your-own-methods} An extension can register **new request methods**: its own verbs, served next to the spec's: @@ -105,7 +105,7 @@ runtime: * An empty `protocol_versions` set raises too: a method that can never be served is a bug, not a configuration. -### The client side +### The client side {#the-client-side} The same file's `main()` is the whole client story, both halves of it: @@ -123,7 +123,7 @@ The same file's `main()` is the whole client story, both halves of it: only grows first-class methods for spec verbs. `send_request` accepts any `Request` subclass, so the vendor request passes as-is. -### Intercepting `tools/call` +### Intercepting `tools/call` {#intercepting-toolscall} The one interceptive hook. Override `intercept_tool_call` to observe, short-circuit, or veto a tool call: @@ -152,7 +152,7 @@ or veto a tool call: The hook wraps `tools/call` and nothing else. For every-message concerns, use [Middleware](middleware.md). That is what it is for. -## Using a client extension +## Using a client extension {#using-a-client-extension} A **client extension** is the same contract from the consuming side: a bundle of client-side behaviour behind one identifier. Pass instances to @@ -183,7 +183,7 @@ from mcp.client import advertise client = Client(mcp, extensions=[advertise("com.example/search")]) ``` -## Writing a client extension +## Writing a client extension {#writing-a-client-extension} Subclass `ClientExtension` and override only what you need. Three contribution kinds, each with a default: `settings()`, `claims()`, and `notifications()`. @@ -223,7 +223,7 @@ would reject. And when you want the claimed shape yourself instead of the resolv call `client.session.call_tool(..., allow_claimed=True)`; without that flag, a claimed shape reaching a session-tier caller raises `UnexpectedClaimedResult`. -### Extension verbs +### Extension verbs {#extension-verbs} An extension's own request methods need no client-side registration. A vendor request type subclasses `mcp.types.Request` and goes through `client.session.send_request`, @@ -238,7 +238,7 @@ this for their verbs), the request type declares `name_param`: The session mirrors `params["jobId"]` into `Mcp-Name` on every send path, and a missing value fails loudly rather than silently omitting a required header. -## What an extension cannot do +## What an extension cannot do {#what-an-extension-cannot-do} The contribution surface is **closed** on purpose. On the server: settings, tools, resources, methods, one `tools/call` interceptor. On the client: settings, result diff --git a/docs/advanced/index.md b/docs/advanced/index.md index 92af6d1782..6d09a87845 100644 --- a/docs/advanced/index.md +++ b/docs/advanced/index.md @@ -1,4 +1,4 @@ -# Advanced +# Advanced {#advanced} Everything an ordinary server or client needs has a topical home in the sections above. This section is the escape hatches you reach for when `MCPServer`'s convenience diff --git a/docs/advanced/low-level-server.md b/docs/advanced/low-level-server.md index 083e03cd61..867898d544 100644 --- a/docs/advanced/low-level-server.md +++ b/docs/advanced/low-level-server.md @@ -1,4 +1,4 @@ -# The low-level Server +# The low-level Server {#the-low-level-server} `@mcp.tool()` is a layer. Underneath it is a second server class, `Server`, that speaks raw MCP: you hand it the protocol objects and it puts them on the wire, unchanged. @@ -10,7 +10,7 @@ For everything else, stay on `MCPServer`. -## The same tool, by hand +## The same tool, by hand {#the-same-tool-by-hand} This is the `search_books` tool that **[Tools](../servers/tools.md)** writes in nine lines of `@mcp.tool()`, with the sugar removed: @@ -29,7 +29,7 @@ Three things changed, and they are the whole low-level API: !!! info If you've used FastAPI, you already know this relationship. `MCPServer` is the decorators-and-type-hints layer; `Server` is the Starlette underneath. They are not rivals: `MCPServer` constructs a `Server` and registers handlers exactly like these on it. -### Try it +### Try it {#try-it} There is no Inspector for this one: `mcp dev` and `mcp run` only accept an `MCPServer`. The in-memory `Client` doesn't care; it takes a low-level `Server` exactly like it takes an `MCPServer`: @@ -59,7 +59,7 @@ The same text the `@mcp.tool()` version produced. Two honest differences: * `result.structured_content` is `None`. The high-level server wraps a `-> str` into `{"result": ...}` for you; here nobody builds what you didn't build. * `list_tools` returns the schema **you** typed, character for character. The high-level version had `"title": "Query"` on every property and a `"title": "search_booksArguments"` at the root: Pydantic artifacts. Down here, if it's on the wire, you put it there. -## Nothing is checked for you +## Nothing is checked for you {#nothing-is-checked-for-you} `MCPServer` rejects a bad argument before your function ever runs, validating the call against the schema it generated (**[Tools](../servers/tools.md)**). @@ -76,7 +76,7 @@ The same text the `@mcp.tool()` version produced. Two honest differences: That generalises. An exception raised from a low-level handler is **always** a protocol error, never an `is_error=True` tool result. If you want the model to read the failure and recover, validate `params.arguments` yourself and return `CallToolResult(content=[TextContent(...)], is_error=True)`. The two kinds of failure are the subject of **[Handling errors](../servers/handling-errors.md)**. -## Two tools, one handler +## Two tools, one handler {#two-tools-one-handler} `on_call_tool` is the single entry point for every tool on the server. You route on `params.name`: @@ -87,7 +87,7 @@ That generalises. An exception raised from a low-level handler is **always** a p * `list_tools` advertises both. `call_tool` dispatches on the name. * The `else` branch matters: `Server` will happily forward a `tools/call` for a name you never listed straight into your handler. Raising there turns the call into the same `-32603` as above. -## Structured output, by hand +## Structured output, by hand {#structured-output-by-hand} Declare `output_schema` on the `Tool` and put `structured_content` on the result. Both are yours: @@ -111,7 +111,7 @@ The `_meta` block is the server's identity stamp: the SDK adds it to every 2026- The server never compares the two fields. This SDK's `Client` does: return `structured_content` that doesn't satisfy the `output_schema` you declared and `call_tool` raises a `RuntimeError` that starts with `Invalid structured content returned by tool search_books` and goes on to quote the `jsonschema` failure. Promising a schema is cheap; keeping it is on you. The whole ladder of return types and schemas is in **[Structured Output](../servers/structured-output.md)**. -## `_meta`: for the application, not the model +## `_meta`: for the application, not the model {#\_meta-for-the-application-not-the-model} `content` is the part of the answer the model reads. `structured_content` is the same answer as typed data. `_meta` is the third channel: data that rides along with the result for the **client application**, without being part of the answer at all. @@ -128,7 +128,7 @@ Use it for record IDs, trace IDs, anything your UI needs and your prompt doesn't `_meta` is a convention between you and the client application, not a guarantee about what reaches the model. The host decides what it renders. Never put a secret in any part of a tool result. -## Capabilities follow your handlers +## Capabilities follow your handlers {#capabilities-follow-your-handlers} A `Server` advertises exactly the method families you gave it handlers for. The `Bookshop` above passes `on_list_tools` and `on_call_tool` and nothing else, so a client connecting to it sees: @@ -140,7 +140,7 @@ No `resources`, no `prompts`: there is nothing to back them. Pass `on_list_promp `MCPServer` always advertises tools, resources and prompts, whether you registered any or not, because its managers always exist. Down here the declaration *is* the constructor call. -## The lifespan generic +## The lifespan generic {#the-lifespan-generic} `Server` is generic in the type its lifespan yields. Annotate it once and the object is typed everywhere it surfaces: @@ -154,7 +154,7 @@ No `resources`, no `prompts`: there is nothing to back them. Pass `on_list_promp Without a `lifespan=`, `ctx.lifespan_context` is an empty `dict`. -## A method of your own +## A method of your own {#a-method-of-your-own} The constructor covers the methods MCP defines. `add_request_handler` covers everything else: @@ -180,7 +180,7 @@ The handshake belongs to the runner. `server/discover`, `ping`, and every other !!! tip `Server.middleware`, mentioned in that error, wraps **every** inbound message, including `initialize`. If what you want is to observe or rewrite traffic rather than answer a new method, start at **[Middleware](middleware.md)**. -## The other handlers +## The other handlers {#the-other-handlers} Each of these is one idea you now have the vocabulary for; each has its own page. @@ -189,7 +189,7 @@ Each of these is one idea you now have the vocabulary for; each has its own page * `on_subscriptions_listen` serves the 2026-07-28 `subscriptions/listen` stream. Pass a `ListenHandler` built over a `SubscriptionBus` and publish events to the bus from your other handlers; see **[Subscriptions](../handlers/subscriptions.md)** for the full composition. * `server.streamable_http_app()` returns the same Starlette app `MCPServer`'s does; deploy it the way **[Running your server](../run/index.md)** deploys any other ASGI app. There is no `server.run(transport=...)` down here: `server.run(read_stream, write_stream, server.create_initialization_options())` drives one connection over a pair of streams, and that one line is the whole story. -## Recap +## Recap {#recap} * The low-level `Server` takes its handlers as `on_*` **constructor parameters**; every handler is `async (ctx, params) -> result`. * You write the `input_schema` dict and you build the `CallToolResult`. Nothing is derived, wrapped, or validated for you. diff --git a/docs/advanced/middleware.md b/docs/advanced/middleware.md index 5d80927441..4dc5f31bcb 100644 --- a/docs/advanced/middleware.md +++ b/docs/advanced/middleware.md @@ -1,4 +1,4 @@ -# Middleware +# Middleware {#middleware} A **middleware** is one async function that wraps every message your server receives. @@ -14,7 +14,7 @@ You write it as `async (ctx, call_next)` and append it to `server.middleware`. T below uses the low-level `Server`; if `Server(name, on_call_tool=...)` is new to you, read **[The low-level Server](low-level-server.md)** first. -## A timing middleware +## A timing middleware {#a-timing-middleware} One server, one tool, one middleware that logs how long each message took: @@ -31,7 +31,7 @@ One server, one tool, one middleware that logs how long each message took: * `server.middleware.append(...)` registers it. The list runs outermost-first, so `middleware[0]` is the one closest to the wire. -### Try it +### Try it {#try-it} Connect a client, list the tools, call one. Your log has **three** lines: @@ -53,7 +53,7 @@ That is the point. Middleware wraps **every** inbound message: * Even a method the server has no handler for: `call_next` raises the `MCPError(-32601, "Method not found")` *through* your middleware on its way to the client. -## What you can do inside one +## What you can do inside one {#what-you-can-do-inside-one} In increasing order of how much you should hesitate: @@ -88,7 +88,7 @@ In increasing order of how much you should hesitate: an elicitation) while handling `initialize` therefore **deadlocks the connection**: the response you are waiting for can never be read. Fire-and-forget notifications are fine. -## The one middleware that ships on by default +## The one middleware that ships on by default {#the-one-middleware-that-ships-on-by-default} The SDK ships exactly one middleware, and it is already on your server's list: the one that emits an OpenTelemetry span for every message. You don't append it, and most of the time you @@ -101,7 +101,7 @@ don't think about it. It is a no-op until you install an exporter, and it has it the decoded message instead of the raw HTTP request. The two compose: Starlette middleware on `streamable_http_app()` sees HTTP; this sees MCP. -## Recap +## Recap {#recap} * A middleware is `async (ctx, call_next) -> result`, passed as `MCPServer(middleware=[...])` (or appended to `mcp.middleware`), and appended to `server.middleware` on the low-level `Server`. diff --git a/docs/advanced/pagination.md b/docs/advanced/pagination.md index 9f807a8e61..4d15aab816 100644 --- a/docs/advanced/pagination.md +++ b/docs/advanced/pagination.md @@ -1,4 +1,4 @@ -# Pagination +# Pagination {#pagination} Most servers never need this. @@ -8,7 +8,7 @@ Pagination is for the server whose resource list is really a database: thousands `@mcp.resource()` has no hook for any of that. To page, you write the list handler yourself, on the **[low-level Server](low-level-server.md)**. -## A server that pages +## A server that pages {#a-server-that-pages} ```python title="server.py" hl_lines="12 15-16" --8<-- "docs_src/pagination/tutorial001.py" @@ -24,7 +24,7 @@ Pagination is for the server whose resource list is really a database: thousands one-line resources can afford a page of 500; a list of fat prompt templates cannot. The client has no say in it, and that is by design. -### Try it +### Try it {#try-it} `Client(server)` connects to a low-level `Server` in memory exactly as it connects to an `MCPServer`. @@ -34,7 +34,7 @@ Hand it back with `list_resources(cursor="10")` and the first resource is `book- The tenth page comes back with `next_cursor` set to `None`. Done. -## The client loop +## The client loop {#the-client-loop} Every `list_*` method on `Client` (`list_tools`, `list_resources`, `list_resource_templates`, `list_prompts`) takes a `cursor=` keyword. Draining a paged list is one `while True`: @@ -50,7 +50,7 @@ Run its `main()` and it prints `100 resources`: ten pages of ten, stitched toget This is the same loop **[The Client](../client/index.md)** shows for every `list_*` verb, and it costs nothing against a server that doesn't page: `next_cursor` is `None` on the first response and the loop runs once. -## The three rules +## The three rules {#the-three-rules} **Cursors are opaque.** A client must never parse, build, or guess one. The only legal source of a cursor is the previous page's `next_cursor`, verbatim. @@ -69,7 +69,7 @@ This is the same loop **[The Client](../client/index.md)** shows for every `list A cursor you didn't get from the server is a bug, not a feature request. -## Recap +## Recap {#recap} * `MCPServer` returns everything in one page. Pagination is opt-in, and you opt in on the low-level `Server`. * `on_list_resources` (and `on_list_tools`, `on_list_prompts`, `on_list_resource_templates`) receives `PaginatedRequestParams | None`; `params.cursor` is `None` for the first page. diff --git a/docs/client/caching.md b/docs/client/caching.md index 5d83cfa92a..0495ead05e 100644 --- a/docs/client/caching.md +++ b/docs/client/caching.md @@ -1,4 +1,4 @@ -# Caching hints +# Caching hints {#caching-hints} Every result a server returns for `tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read` and `server/discover` carries two fields on the 2026-07-28 protocol: `ttlMs`, how many milliseconds a client may treat the result as fresh, and `cacheScope`, whether a cached result may be shared across users (`"public"`) or belongs to one authorization context (`"private"`). @@ -21,7 +21,7 @@ Out of the box every result says `ttlMs: 0, cacheScope: "private"`: immediately authenticated. Mark a result `"public"` only when it is identical for every caller, and never use `cacheScope` as access control: it is a label, not a lock. -## Per-handler override +## Per-handler override {#per-handler-override} On the low-level `Server`, handlers build their results by hand, and `ttl_ms` / `cache_scope` are just fields on the result models. A handler that sets them explicitly always wins over the constructor map, field by field: @@ -35,7 +35,7 @@ This is also the escape hatch for dynamics the constructor can't know: a handler One caveat on paginated lists: the protocol requires the **same `cacheScope` on every page** of one list. The constructor map satisfies that by construction, since it's keyed by method, not by page. But a handler that overrides the scope itself owns that consistency: override it on *every* page, never only when a cursor is present, or page one and page two will disagree. -## What the client sees +## What the client sees {#what-the-client-sees} On a 2026-07-28 session, `Client` honors the hints for you: it has a built-in response cache, on by default. A result that arrives carrying a `ttlMs` is stored, and an identical call within that TTL is served from the cache with no round trip. A result that carries *no* hint is not cached: hint-less results get `CacheConfig.default_ttl_ms`, which defaults to `0` (immediately stale), so a server that declares nothing sees exactly the call-for-call traffic it always did. @@ -57,7 +57,7 @@ Scope is honored automatically too: `"private"` entries are keyed to the cache's One caveat on `resources/updated`: eviction is exact-URI only. The store contract has no enumerate or scan operation (same as the reference TypeScript implementation), so a notification carrying a *sub*-resource URI does not evict a cached read of its parent. If your server signals sub-resources this way, refetch the parent with `cache_mode="refresh"`. -### Configuring it: `CacheConfig` +### Configuring it: `CacheConfig` {#configuring-it-cacheconfig} ```python from mcp.client import CacheConfig @@ -82,7 +82,7 @@ Cache keys also carry the **server's identity**: the URL string you dialed, with !!! warning "`share_public` trusts the server, fleet-wide" By default even `"public"` entries stay within their partition. `share_public=True` serves entries the server marked `cacheScope: "public"` to **every** partition using the store, trusting the server's classification on behalf of all of them. A server that stamps `"public"` on per-tenant data (by bug or by malice) then leaks one tenant's response to the others. The flag is deliberately constructor-level only: the per-call `cache_mode` can narrow caching, but nothing per-call can widen sharing. -### What the cache never does +### What the cache never does {#what-the-cache-never-does} * **Session-tier calls bypass it.** `client.session.list_tools()` and friends always make the round trip; the cache lives on the `Client` verbs. * **`server/discover` stays out of it.** The discover result is delivered once, at connect, and never enters the response cache, even when it carries a `ttlMs`. If you persist one yourself to skip the reconnect probe ([`prior_discover`](../protocol-versions.md#reconnecting-with-prior_discover)), its freshness is your bookkeeping: `DiscoverResult` carries `ttl_ms` and `cache_scope`, already parsed, for exactly that purpose. @@ -97,17 +97,17 @@ Cache keys also carry the **server's identity**: the URL string you dialed, with * On a **shared store**, clients race each other. Each client drops its own write when an eviction overtook the fetch in flight, but a *co-tenant* client can still write back an entry that an eviction it never saw had removed; and that race bookkeeping is itself bounded: past 4096 tracked keys the oldest key's guard is dropped first. Both windows are accepted, and closed by the TTL cap above. * **No serving across protocol eras.** Entries are scoped to the negotiated protocol version: on a shared persistent store, a session never serves an entry written under a different negotiated version (the same listing genuinely differs by era, since the SDK strips the 2026 fields for older sessions). Eviction likewise touches only the current era's entries; another era's entries simply age out by TTL. -### Reading the hints yourself +### Reading the hints yourself {#reading-the-hints-yourself} The hints are also plain fields on every cacheable result (`result.ttl_ms` and `result.cache_scope`, already parsed), in case you want to layer your own bookkeeping on top of (or instead of) the built-in cache. Against an **older server** (pre-2026 protocol), the fields are simply absent from the wire, and the models show their conservative defaults: `ttl_ms == 0` and `cache_scope == "private"`, stale and unshared, the right assumption for a server that declared nothing. The cache treats a legacy session the same way: hints are never consulted there (whatever keys appear on the wire), only `default_ttl_ms` applies, and its default of `0` caches nothing, so a pre-2026 connection behaves exactly as it did before the cache existed. If you need to distinguish "the server said 0" from "the server said nothing", check `"ttl_ms" in result.model_fields_set`: it's only set when the field actually arrived. -## Older clients +## Older clients {#older-clients} Clients on pre-2026 protocol versions never see either field; the SDK strips them at serialization for those connections. Configure your hints once; there is nothing version-specific to write. -## Recap +## Recap {#recap} * Six methods carry `ttlMs`/`cacheScope`; the SDK defaults them to `0`/`"private"`, stale and unshared, always safe. * `cache_hints={method: CacheHint(...)}` at construction (both `MCPServer` and `Server`) sets server-wide values per method. diff --git a/docs/client/callbacks.md b/docs/client/callbacks.md index 5f1dd1948a..ab169fce5c 100644 --- a/docs/client/callbacks.md +++ b/docs/client/callbacks.md @@ -1,10 +1,10 @@ -# Client callbacks +# Client callbacks {#client-callbacks} Nearly every request in MCP goes one way: client to server. A server can also ask the **client** for things: to put a question to the user, to sample the user's model, to list the user's workspace folders. You answer those requests by passing **callbacks** to `Client(...)`. -## A server that asks +## A server that asks {#a-server-that-asks} Here is a server whose tool can't finish on its own: @@ -17,7 +17,7 @@ Here is a server whose tool can't finish on its own: That is the server half, and the **[Elicitation](../handlers/elicitation.md)** page owns it. This page is the other end of the wire. -## The elicitation callback +## The elicitation callback {#the-elicitation-callback} ```python title="client.py" hl_lines="6-10 16-17" --8<-- "docs_src/client_callbacks/tutorial002.py" @@ -33,7 +33,7 @@ That is the server half, and the **[Elicitation](../handlers/elicitation.md)** p carries `params.url` instead of a schema. One callback handles both; branch on `params.mode`. **[Elicitation](../handlers/elicitation.md)** shows the full pattern. -### Try it +### Try it {#try-it} Call `issue_card` and watch both ends. @@ -65,7 +65,7 @@ One `tools/call` from you, one `elicitation/create` back from the server, answer `InputRequiredResult` carrying an `ElicitRequest`, `Client` dispatches that entry to the same `elicitation_callback` and retries the call for you. That flow is **[Multi-round-trip requests](../handlers/multi-round-trip.md)**. -## A callback is a capability +## A callback is a capability {#a-callback-is-a-capability} You never told the server that your client can answer elicitation requests. The SDK did. @@ -111,7 +111,7 @@ Pass all three callbacks and you get `['elicitation', 'sampling', 'roots']`. Pas the model to read and retry. It's why `client_features` is worth having: a well-behaved server checks before it asks. -## The deprecated pair +## The deprecated pair {#the-deprecated-pair} `sampling_callback` answers `sampling/createMessage`: the server asking *your* model to complete something. `list_roots_callback` answers `roots/list`: the server asking which directories it may work in. @@ -129,7 +129,7 @@ You still need the callbacks to talk to servers that haven't moved. The signatur Pass them to `Client(...)` exactly like `elicitation_callback`. -## The notification callbacks +## The notification callbacks {#the-notification-callbacks} Two more. Neither declares anything. @@ -137,7 +137,7 @@ Two more. Neither declares anything. `message_handler` is the catch-all: every server notification the session surfaces reaches it (as well as its specific callback), and on a stream-backed transport so does every transport-level `Exception`. Two never do: `notifications/cancelled` is applied by the SDK rather than surfaced, and a subscription acknowledgment for a live `listen()` stream is consumed by that stream. Annotate the parameter with `IncomingMessage` (`ServerNotification | Exception`, exported from `mcp.client`). The one pattern worth knowing is `if isinstance(message, Exception): raise message`, so a broken connection fails loudly instead of vanishing. -## Recap +## Recap {#recap} * A server can send requests to the client. You answer them with callbacks passed to `Client(...)`. * The elicitation callback is the current one: `async (context, params) -> ElicitResult`, one function for both form and URL mode. diff --git a/docs/client/identity-assertion.md b/docs/client/identity-assertion.md index 9e18bcbfe5..337989eaec 100644 --- a/docs/client/identity-assertion.md +++ b/docs/client/identity-assertion.md @@ -1,4 +1,4 @@ -# Identity assertion +# Identity assertion {#identity-assertion} An ordinary OAuth provider (**[OAuth clients](oauth-clients.md)**) starts by asking the MCP server a question: *which authorization server do you trust?* It follows the answer wherever it points, and then either a person signs in or a pre-shared secret stands in for one. @@ -6,7 +6,7 @@ An enterprise wants neither decided per server. It already runs an identity prov This page is both ends of that trade. The MCP server itself never changes: it is still the resource server from **[Authorization](../run/authorization.md)**, checking whatever token shows up. -## Two token requests +## Two token requests {#two-token-requests} Two different authorities are in play, and naming them apart is most of understanding this page. The **enterprise IdP** is your organization's identity provider: it knows who the employee is, it is where policy lives, and it issues the ID-JAG. The SDK never talks to it. The **MCP authorization server** is the same party it was in **[Authorization](../run/authorization.md)**: the issuer named in the MCP server's metadata, the thing that mints the tokens that MCP server accepts. In an ordinary OAuth flow, those two roles are usually one box. Here they are two, and the whole grant is the second agreeing to trust the first. @@ -17,7 +17,7 @@ The client makes one token request to each. Everything below is the second request: the client that sends it and the authorization server that answers it. -## The client +## The client {#the-client} **`IdentityAssertionOAuthProvider`** lives in `mcp.client.auth.extensions.identity_assertion`. Like every provider in **[OAuth clients](oauth-clients.md)** it is an `httpx2.Auth`: construct one, put it on `auth=`, hand the `httpx2.AsyncClient` to the transport. @@ -31,7 +31,7 @@ Read it from the bottom. * The provider takes what the other providers cannot discover: a `client_id` and `client_secret` somebody **pre-registered** with the authorization server, that authorization server's `issuer`, and `assertion_provider`, an async callback that returns a fresh ID-JAG on demand. * `storage` is the same `TokenStorage` protocol. Only the two token methods are ever called; there is no dynamic registration here, so there is no `client_info` to remember. -### The assertion provider +### The assertion provider {#the-assertion-provider} `fetch_id_jag(audience, resource)` is the only code you write. It is awaited once per token exchange, never at construction, and only *after* the authorization server's metadata has been fetched and validated, so a misconfigured issuer never leaks an assertion. Its two arguments are two of the claims the ID-JAG must be minted with: `audience` is the authorization server's issuer (the ID-JAG `aud`) and `resource` is the MCP server's canonical identifier (the ID-JAG `resource`). The third is one you already hold: the ID-JAG's `client_id` claim must name the `client_id` you gave the provider, or the authorization server refuses the exchange. @@ -42,7 +42,7 @@ Read it from the bottom. minutes-lived grant, and the authorization server on this page refuses to accept the same one twice. Do not cache it. The access token it buys you is the thing that gets reused. -### The issuer is configuration +### The issuer is configuration {#the-issuer-is-configuration} Here is the inversion. `OAuthClientProvider` asks the resource server which authorization server to use and follows the answer wherever it points. This provider refuses to: `issuer` is required, the [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) metadata is fetched from that issuer's own well-known path, the token endpoint must be on that issuer's origin, and the resource server is never asked anything. @@ -57,14 +57,14 @@ The extension does not demand this; it is a deliberately stricter choice. This c object. A mismatch stops the flow at `OAuthFlowError: Authorization server metadata issuer mismatch` before a single credential or assertion is sent. -### A confidential client +### A confidential client {#a-confidential-client} `client_secret` is required; the constructor raises `ValueError` without one. The IETF profile underneath [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) reserves this grant for confidential clients, SEP-990 requires the client to authenticate, and this SDK enforces both by insisting on a shared secret. `token_endpoint_auth_method` picks where it travels: `client_secret_post` (the default, in the form body) or `client_secret_basic` (an HTTP Basic header). The profile also permits `private_key_jwt`; this provider does not support it. !!! tip Read `client_secret` from the environment or a secret manager, never from source control. -### What the provider does for you +### What the provider does for you {#what-the-provider-does-for-you} The first request goes out unauthenticated, and the server's `401` starts the flow. @@ -74,7 +74,7 @@ The first request goes out unauthenticated, and the server's `401` starts the fl A `403` whose `WWW-Authenticate` names `insufficient_scope` runs steps 2 and 3 again with the union of your `scope` and the challenged one. (`scope` is only ever a request; this page's authorization server grants what the ID-JAG says and nothing else.) There is no refresh token anywhere in this: when the access token expires, the next `401` mints a fresh ID-JAG and exchanges again, and *that* is the lever the IdP holds. Failures are the same two exceptions as the rest of **[OAuth clients](oauth-clients.md)**: `OAuthFlowError` for discovery and validation, its subclass `OAuthTokenError` when the token endpoint says no. -## The authorization server +## The authorization server {#the-authorization-server} Most of the time you stop here. The MCP authorization server is somebody else's product, accepting ID-JAGs is its configuration to turn on, and the SDK's half of [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) is the client above. @@ -131,11 +131,11 @@ And notice what the returned `OAuthToken` does not carry: a refresh token. The I MCP traffic with the bearer attached. And the `sub` your validator read out of the ID-JAG is exactly what `get_access_token().subject` reports inside a tool. -### Try it +### Try it {#try-it} `examples/stories/identity_assertion/` in the SDK repository is this page running for real: the same `exchange_identity_assertion` validator, an MCP server gated on its tokens, a stand-in IdP, and the client, in one self-checking program. `uv run python -m stories.identity_assertion.client --http` runs the whole exchange and asserts that the user the IdP named is the user the tool sees. -## Recap +## Recap {#recap} * [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) lets the enterprise identity provider, not the end user, decide which MCP servers a client may reach. The IdP signs that decision into an **ID-JAG**. * Obtaining the ID-JAG is an [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) token exchange against *your IdP*, and the SDK does not make it. Presenting it to the MCP authorization server is the [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) `jwt-bearer` grant, and the SDK does both sides of that. diff --git a/docs/client/index.md b/docs/client/index.md index 1e1df3c01b..875229e5ba 100644 --- a/docs/client/index.md +++ b/docs/client/index.md @@ -1,10 +1,10 @@ -# The Client +# The Client {#the-client} A **`Client`** is how a Python program talks to an MCP server. It is one object with one lifecycle: construct it, enter `async with`, call methods. Every protocol verb (list the tools, call one, read a resource, render a prompt) is an `async` method on it that returns a typed result. -## Your first client +## Your first client {#your-first-client} ```python title="client.py" hl_lines="14-18" --8<-- "docs_src/client/tutorial001.py" @@ -16,7 +16,7 @@ The server at the top is only there so you have something to connect to. The cli * `async with` is the **lifecycle**. Entering it connects and negotiates; leaving it disconnects. There is no `connect()` / `close()` pair, and a `Client` cannot be reused after the block ends. * Inside the block the connection facts are already there as plain properties. -### What you can pass to `Client` +### What you can pass to `Client` {#what-you-can-pass-to-client} `Client` takes one positional argument and resolves the transport from its type: @@ -26,7 +26,7 @@ The server at the top is only there so you have something to connect to. The cli Everything else on this page is identical across all three. Headers, subprocesses, timeouts, and the `Transport` protocol get their own page: **[Client transports](transports.md)**. -### What's on a connected client +### What's on a connected client {#whats-on-a-connected-client} Four read-only properties, populated the moment you enter the block: @@ -41,7 +41,7 @@ You never picked a protocol version. By default the `Client` probes the server a `client.session` is the underlying `ClientSession`, the low-level escape hatch. You won't need it for anything on this page. -## Listing tools +## Listing tools {#listing-tools} ```python title="client.py" hl_lines="15-20" --8<-- "docs_src/client/tutorial002.py" @@ -76,7 +76,7 @@ That schema is everything a UI needs to render an argument form, and everything the `name` if not. `from mcp.shared.metadata_utils import get_display_name` does exactly that, for tools, resources, resource templates and prompts. -## Calling a tool +## Calling a tool {#calling-a-tool} `call_tool(name, arguments)` runs the tool and gives you back a `CallToolResult`. @@ -94,19 +94,19 @@ result.is_error # False One return value, three things to read. Each has a different consumer. -### `content`: what the model reads +### `content`: what the model reads {#content-what-the-model-reads} `content` is a `list` of **content blocks**, and a content block is a union: `TextContent`, `ImageContent`, `AudioContent`, `ResourceLink`, or `EmbeddedResource`. A tool can return several, of different kinds. That is why `main` narrows with `isinstance(block, TextContent)` before touching `block.text`. Notice there is no `.text` outside the `isinstance`: the type checker won't allow it, because `ImageContent` has `.data`, not `.text`. The union is honest about what a tool is allowed to send you; your code should be too. -### `structured_content`: what your application reads +### `structured_content`: what your application reads {#structured_content-what-your-application-reads} `structured_content` is the tool's return value as JSON, matching the tool's declared `output_schema`. No string parsing, no guessing. When both are present they say the same thing twice on purpose: `content` is for a model, `structured_content` is for code. Where the structured half comes from, and how to control it, is the **[Structured Output](../servers/structured-output.md)** page. -### `is_error`: whether the tool failed +### `is_error`: whether the tool failed {#is_error-whether-the-tool-failed} A tool that raises does **not** raise in your client. It comes back as an ordinary result with `is_error=True`. @@ -131,7 +131,7 @@ A tool that raises does **not** raise in your client. It comes back as an ordina `MCPError` only when the server answers with a JSON-RPC **error** instead of a result, and **[Handling errors](../servers/handling-errors.md)** covers when a server produces which. -## Resources +## Resources {#resources} The resource verbs come in pairs: two ways to list, one way to read. @@ -147,7 +147,7 @@ The resource verbs come in pairs: two ways to list, one way to read. A client can also be told when a resource changes. On 2025-era connections that is `subscribe_resource(uri)` / `unsubscribe_resource(uri)` - a method pair `MCPServer` doesn't implement, so on the 2026-07-28 wire (where those verbs no longer exist) the request answers `-32601`, *Method not found*. The 2026 replacement is a `subscriptions/listen` stream, which `MCPServer` *does* serve - `server_capabilities.resources.subscribe` is `True` there - and consuming it with `client.listen(...)` is this section's **[Subscriptions](subscriptions.md)** page. -## Prompts +## Prompts {#prompts} ```python title="client.py" hl_lines="15-20" --8<-- "docs_src/client/tutorial005.py" @@ -170,7 +170,7 @@ message.content # TextContent(type='text', text='Recommend one poetry book from A host hands those messages straight to the model. That is the whole feature. -## Completions +## Completions {#completions} A server with a completion handler can autocomplete prompt and resource-template arguments as the user types. @@ -183,7 +183,7 @@ A server with a completion handler can autocomplete prompt and resource-template The answer is in `result.completion.values`. Type `"p"` and the server comes back with `['poetry']`. The server side, and how a handler uses the *other* already-filled arguments to narrow its suggestions, is the **[Completions](../servers/completions.md)** page. -## Pagination +## Pagination {#pagination} Every `list_*` method takes a `cursor=` keyword and every result carries a `next_cursor`. When `next_cursor` is `None`, you have everything. @@ -193,13 +193,13 @@ Every `list_*` method takes a `cursor=` keyword and every result carries a `next This loop is correct against every server. `MCPServer` returns everything in one page, so `next_cursor` is `None` and the loop runs once, which is why most code never writes it. Servers that genuinely page, and the rules cursors obey, are in **[Pagination](../advanced/pagination.md)**. -## In tests +## In tests {#in-tests} `Client(mcp)` with no process and no port is already a test harness for your server. There is one constructor flag built for that: `Client(mcp, raise_exceptions=True)`. It only has an effect on in-memory connections, and **[Testing](../get-started/testing.md)** is the page that explains it and builds the whole pattern around it. -## Recap +## Recap {#recap} * `Client(x)` connects in-memory to a server object, over Streamable HTTP to a URL string, and over anything else via a transport. * `async with` is the whole lifecycle. Inside it, `server_capabilities` and `protocol_version` are already populated; `server_info` and `instructions` are too when the server provides them. diff --git a/docs/client/oauth-clients.md b/docs/client/oauth-clients.md index cd7de35626..f01a24c9c0 100644 --- a/docs/client/oauth-clients.md +++ b/docs/client/oauth-clients.md @@ -1,4 +1,4 @@ -# OAuth clients +# OAuth clients {#oauth-clients} Some MCP servers are protected. Send them a request without a token and they answer `401 Unauthorized`. @@ -6,7 +6,7 @@ Some MCP servers are protected. Send them a request without a token and they ans This page is the client side. Making your own server demand a token is **[Authorization](../run/authorization.md)**. -## The provider +## The provider {#the-provider} ```python title="client.py" hl_lines="44-54" --8<-- "docs_src/oauth_clients/tutorial001.py" @@ -21,7 +21,7 @@ You give it four things: Nothing else in the file mentions OAuth. `main()` never sees a token. -### Client metadata +### Client metadata {#client-metadata} `OAuthClientMetadata` is the real [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591) registration document, as a Pydantic model. @@ -39,7 +39,7 @@ You set three fields. The defaults fill in the rest: `grant_types` is already `[ No browser opened, no half-finished registration left behind on the authorization server. -### Token storage +### Token storage {#token-storage} **`TokenStorage`** is a `Protocol` with four async methods. You don't inherit from anything; write the methods and any class is a token store: @@ -52,7 +52,7 @@ The in-memory version above works. It also forgets everything when the process e Store `client_info`, not only the tokens. The provider registers dynamically the first time it finds no stored `client_info`. Throw it away and you mint a fresh registration on every run. -### The two handlers +### The two handlers {#the-two-handlers} The authorization code flow needs a human exactly once: someone has to sign in and click "allow". @@ -66,13 +66,13 @@ A real client runs a small local HTTP server on the redirect URI instead of call it generated and `iss` to the issuer it discovered, and refuses a mismatch. They are the CSRF and server-mix-up defences. -### Into the `Client` +### Into the `Client` {#into-the-client} Look at `main()`. The provider goes on the **httpx2 client**, the httpx2 client goes into `streamable_http_client(url, http_client=...)`, and that transport goes into `Client`. `streamable_http_client` has no `auth=` keyword. Anything HTTP-level (auth, headers, timeouts, proxies) belongs on the `httpx2.AsyncClient` you bring. That layering is **[Client transports](transports.md)**. -## What the provider does for you +## What the provider does for you {#what-the-provider-does-for-you} The first time `Client` sends a request, the server answers `401`. The provider takes over: @@ -85,13 +85,13 @@ After that it is quiet. Tokens come out of storage, an expired access token is r You wrote none of it. Two keyword arguments remain (`client_metadata_url` and `validate_resource_url`), and this file needs neither. `client_metadata_url` is the one worth knowing about; it gets its own section below. -### Try it +### Try it {#try-it} Most examples in these docs you can check with an in-memory `Client(server)`. Not this: the whole point of the flow is an HTTP `401`, and there is no HTTP between an in-memory client and its server. The repository ships the live version. `examples/servers/simple-auth/` runs a standalone authorization server and a protected MCP server; `examples/clients/simple-auth-client/` is this page's client grown into a small CLI. Its README has the two commands: start the servers, run the client against them, and you watch the four steps go by. -## Client ID Metadata Documents +## Client ID Metadata Documents {#client-id-metadata-documents} The 2026-07-28 revision of the spec deprecates dynamic client registration in favor of **Client ID Metadata Documents** (CIMD). Instead of POSTing a fresh registration to every authorization server it meets, your client publishes one JSON document about itself at a stable HTTPS URL, and that URL *is* its `client_id`. The authorization server fetches the document; the provider never touches it. @@ -99,7 +99,7 @@ The SDK already speaks it: pass the URL as `client_metadata_url=` when you const The URL must be HTTPS with a non-root path; anything else is a `ValueError` at construction, before any network happens. The shipped `examples/clients/simple-auth-client/` takes it as the `MCP_CLIENT_METADATA_URL` environment variable. -## Machine to machine +## Machine to machine {#machine-to-machine} A nightly job, a CI step, another service. There is no browser and nobody to click "allow". That is the **client credentials** grant: you already hold a `client_id` and a `client_secret`, and the token endpoint is the whole flow. @@ -129,13 +129,13 @@ By default the secret travels as HTTP Basic auth on the token request (`client_s There is one more no-human situation: the client belongs to an enterprise whose identity provider, not the user, decides which MCP servers it may reach. That is a different grant with its own trust model and its own page, **[Identity assertion](identity-assertion.md)**. -## When it fails +## When it fails {#when-it-fails} When the OAuth flow goes wrong, the provider raises an `OAuthFlowError` from `mcp.client.auth`. It has two subclasses. `OAuthRegistrationError` means registration did not yield a client you can use: the authorization server refused to register you, or it did register you but with credentials this flow cannot use (for instance an authentication method it does not implement). `OAuthTokenError` means a token could not be obtained: the token endpoint said no, or a stored client record carries an authentication method this client cannot apply, which is reported while building the token request rather than sent. One `except OAuthFlowError:` covers discovery, registration, authorization, and exchange. Not everything is a flow error. The network can still fail; those are ordinary `httpx2` exceptions and pass through untouched. -## Recap +## Recap {#recap} * `OAuthClientProvider` is an `httpx2.Auth`. Put it on an `httpx2.AsyncClient`, pass that to `streamable_http_client(url, http_client=...)`, and `Client` never knows OAuth happened. * You supply four things: the server URL, an `OAuthClientMetadata`, a `TokenStorage`, and the redirect/callback handler pair. diff --git a/docs/client/session-groups.md b/docs/client/session-groups.md index 70ac02e859..d45339da4d 100644 --- a/docs/client/session-groups.md +++ b/docs/client/session-groups.md @@ -1,10 +1,10 @@ -# Session groups +# Session groups {#session-groups} A `Client` connects to one server. Real applications often want several (a search server, a database server, an internal API) and end up juggling a connection and a tool list for each. **`ClientSessionGroup`** is one object that holds many connections and merges everything they expose into a single view. -## Two servers +## Two servers {#two-servers} Start with two ordinary servers. They have nothing to do with each other, so both naturally called their tool `search`: @@ -16,7 +16,7 @@ Start with two ordinary servers. They have nothing to do with each other, so bot --8<-- "docs_src/session_groups/tutorial002.py" ``` -## One group +## One group {#one-group} Create a `ClientSessionGroup` and call **`connect_to_server`** once per server: @@ -38,7 +38,7 @@ Create a `ClientSessionGroup` and call **`connect_to_server`** once per server: That is an `MCPError`, raised before anything from the second server is registered. A name must be unique across the **whole** group, and two servers you don't control will collide eventually. -## `component_name_hook` +## `component_name_hook` {#component_name_hook} You fix this at the group, not at the servers. Pass a function of `(name, server_info)` and the group runs it on every name it registers: @@ -60,17 +60,17 @@ Run it again. `print(sorted(group.tools))` now shows both: The hook runs on **every** name from **every** server, not only on conflicts: there is no prefix-on-collision mode. Pick one scheme and let it apply everywhere. -## Adding and removing servers +## Adding and removing servers {#adding-and-removing-servers} `connect_to_server` returns the `ClientSession` it opened. Keep it if you ever want that server gone: `await group.disconnect_from_server(session)` removes its tools, resources, and prompts from the group. If you already hold a connected `ClientSession` (`Client.session` is one), hand it to `await group.connect_with_session(server_info, session)` instead of opening a new transport. It aggregates the same way. The group never closes a session it didn't open. `server_info` names the server for component prefixes; on a 2026-era connection `client.server_info` can be `None` (identity is optional), so pass your own `Implementation(name=..., version=...)` in that case. -## The classic handshake +## The classic handshake {#the-classic-handshake} `ClientSessionGroup` is built on `ClientSession`, not on `Client`. Each `connect_to_server` runs the classic `initialize` handshake. It never sends the `server/discover` probe described in **[Protocol versions](../protocol-versions.md)**. Every MCP server understands that handshake, so this costs you compatibility with nothing; it only means a group takes the older, slower path to a server that could do better. -## Recap +## Recap {#recap} * `ClientSessionGroup` holds many server connections and merges their tools, resources, and prompts into one `dict` each. * `connect_to_server(params)` per server. It takes transport parameters, never the server object or URL a `Client` takes. diff --git a/docs/client/subscriptions.md b/docs/client/subscriptions.md index bf5b0a36d8..9360989ce7 100644 --- a/docs/client/subscriptions.md +++ b/docs/client/subscriptions.md @@ -1,10 +1,10 @@ -# Subscriptions +# Subscriptions {#subscriptions} A server's catalog is not fixed. Tools appear at runtime, and the content behind a resource URI changes. A client hears about it through `client.listen(...)`: one `subscriptions/listen` request whose response *is* the stream. It stays open and carries the change notifications the client asked for. This page is the client end: opening the stream, watching it beside your main flow, and handling its endings. Publishing changes, filtering, and serving the method are the server's side of the story, told in **[Subscriptions](../handlers/subscriptions.md)** under *Inside your handler*. The examples here talk to the sprint-board server built there. -## Watching the stream +## Watching the stream {#watching-the-stream} A subscription is one context manager. Entering it sends the request, with your keyword arguments as the subscription filter, and waits for the server's acknowledgment, so the stream is live by the time the block starts. @@ -23,7 +23,7 @@ Two more properties of the handle: * `sub.honored` is the filter the server acknowledged: a `SubscriptionFilter` with the fields you passed, read as attributes (`sub.honored.prompts_list_changed`). `MCPServer` honors every kind you ask for, so it echoes your request back. A server that supports fewer kinds acknowledges less, and an honored kind may still never fire. A server may also refuse the whole request rather than acknowledge it (see [Deciding who may watch](../handlers/subscriptions.md#deciding-who-may-watch) on the server page), which surfaces as the request's error. * `sub.subscription_id` is the listen request's id, the one stamped on every frame of this stream. Several subscriptions can be open at once, each demultiplexed by its own id. -## Watching without blocking +## Watching without blocking {#watching-without-blocking} `follow_board` runs until the server closes the stream, which may be never, so on its own it owns your program. Real clients want the watcher *beside* the main flow: an agent calls tools while a watcher keeps a cache or a UI current. @@ -57,9 +57,9 @@ The order is the point. Nothing is replayed, so an event published before your s Requests run freely beside an open stream, from the watcher task or any other, on the same client. Because *duplicate* unconsumed events coalesce, a busy main flow may produce one refetch rather than three. Events that differ do not coalesce: a filter naming many URIs queues one pending event per URI. -To stop watching, leave the block: there is no `unsubscribe` call. Cancelling the task that owns the block does that for you, and the SDK cancels the listen request the way the transport expects: over streamable HTTP, by closing that request's stream. A watcher that runs for the life of your app never returns on its own, so cancel it, or its task group's scope, at shutdown. +To stop watching, leave the block: there is no `unsubscribe` call. Cancelling the task that owns the block does that for you, and the SDK cancels the listen request the way the transport expects: over Streamable HTTP, by closing that request's stream. A watcher that runs for the life of your app never returns on its own, so cancel it, or its task group's scope, at shutdown. -## Streams end +## Streams end {#streams-end} A stream ends in one of two ways, both ordinary control flow. A graceful server close ends the `async for`; an abrupt drop raises `SubscriptionLost`. @@ -75,7 +75,7 @@ Servers close streams gracefully for their own reasons, including shedding a sub `keep_following` catches only `SubscriptionLost`. Entering `listen()` can also raise `MCPError` (the connection failed, or the server does not serve the method), `TimeoutError` (no acknowledgment arrived), and `ListenNotSupportedError` (a pre-2026 connection). Decide which of those your watcher should retry: the last never heals. -## Recap +## Recap {#recap} * Enter `async with client.listen(...)`; entering waits for the acknowledgment, so nothing published after it is missed. * Iterate with `async for event in sub`. Events are cues to refetch, never payloads. diff --git a/docs/client/transports.md b/docs/client/transports.md index b453100655..fe0077ca1d 100644 --- a/docs/client/transports.md +++ b/docs/client/transports.md @@ -1,4 +1,4 @@ -# Client transports +# Client transports {#client-transports} Every `Client` talks to its server over a **transport**: the thing that actually carries the messages. @@ -6,7 +6,7 @@ You never configure one separately. `Client` takes a single positional argument The *server* side of each (what `mcp.run()` does and what you deploy) is **[Running your server](../run/index.md)**. -## In memory +## In memory {#in-memory} Pass the server object itself: @@ -21,7 +21,7 @@ That makes it two things at once: * **A test harness.** Every example in this documentation is exercised this way, and the **[Testing](../get-started/testing.md)** page builds the whole pattern around it. * **An embedding API.** An application that constructs the server doesn't need a network hop to call its tools. -## Streamable HTTP +## Streamable HTTP {#streamable-http} Pass a URL string and you get **Streamable HTTP**, the transport you deploy behind: @@ -41,7 +41,7 @@ That is the whole production client. `Client` wraps the URL in `streamable_http_ Nothing was resolved, fetched or spawned when you wrote `Client("http://...")`. That line is free. -### Bring your own `httpx2.AsyncClient` +### Bring your own `httpx2.AsyncClient` {#bring-your-own-httpx2asyncclient} The moment you need an `Authorization` header, a cookie, a proxy, mTLS, or a different timeout, build the `httpx2.AsyncClient` yourself and hand it to `streamable_http_client`: @@ -78,7 +78,7 @@ environment variables or pass an explicit `verify=ssl_context` to your `httpx2.A nothing away. It is also where OAuth plugs in: `httpx2.AsyncClient(auth=OAuthClientProvider(...))`. That whole flow is **[OAuth clients](oauth-clients.md)**. -## stdio +## stdio {#stdio} A **stdio** server is a subprocess. The client launches it, writes JSON-RPC to its stdin and reads JSON-RPC from its stdout. It is how a desktop host runs a server on your machine: a host *is* this code plus a UI, and **[Connect to a real host](../get-started/real-host.md)** is the same relationship seen from the host's side, as a config file. @@ -100,17 +100,17 @@ Leaving the `async with` block also shuts the subprocess down: close stdin, wait A server that needs an API key won't find it there. Pass it explicitly with `env=`; those variables are merged on top of the allow-list. That is what `BOOKSHOP_API_KEY` is doing above. -## SSE +## SSE {#sse} `sse_client(url)`, from `mcp.client.sse`, is the HTTP transport that Streamable HTTP superseded. Wrap it the same way, `Client(sse_client("http://localhost:8000/sse"))`, to talk to a server that still speaks it, and don't build anything new on it. -## The `Transport` protocol +## The `Transport` protocol {#the-transport-protocol} To `Client`, all of the above are the same thing. A **transport** is any async context manager that yields a `(read, write)` pair of message streams: formally, the `Transport` protocol in `mcp.client`. `Client` resolves its argument by type: a server object connects in-process, a `str` becomes `streamable_http_client(url)`, and anything else is entered as a transport directly. That last rule is why `stdio_client(...)`, `streamable_http_client(...)` and `sse_client(...)` all drop into the same slot, and why you can write your own. -## Recap +## Recap {#recap} * `Client(mcp)` (the server object) connects in memory. Use it for tests and for embedding. * `Client("http://.../mcp")` (a URL) connects over Streamable HTTP, the production transport. diff --git a/docs/deprecated.md b/docs/deprecated.md index 9b879f1f6f..7c79c77bad 100644 --- a/docs/deprecated.md +++ b/docs/deprecated.md @@ -1,10 +1,10 @@ -# Deprecated features +# Deprecated features {#deprecated-features} The 2026-07-28 spec retires five things. The SDK still implements every one of them, and every one of them now carries a **deprecation warning**. The table below names each deprecated feature, why it is going away, and the replacement to build on. -## What is deprecated +## What is deprecated {#what-is-deprecated} | Deprecated | Why | What you do instead | |---|---|---| @@ -20,7 +20,7 @@ Three things fall out of that table: * Sampling and roots share a deeper problem: they are places a **server** sends a **request** to the **client**. That whole direction is what 2026-07-28 replaces with **[Multi-round-trip requests](handlers/multi-round-trip.md)**. It is the standalone RPC methods (`sampling/createMessage`, `roots/list`, and push-style `elicitation/create`) that are gone; the `CreateMessageRequest` / `ListRootsRequest` / `ElicitRequest` payload types survive, embedded in `InputRequiredResult.input_requests`, and on the client they hit the same callbacks. * `ping` is the odd one out. The protocol does not deprecate it, it removes it. The SDK method still warns (its message says *removed*, not *deprecated*) and calling it on a modern connection answers with *"Method not found"*. -## Deprecated is advisory +## Deprecated is advisory {#deprecated-is-advisory} Nothing breaks today. @@ -50,7 +50,7 @@ MCPDeprecationWarning: The logging capability is deprecated as of 2026-07-28 (SE send. These two only work end-to-end on a `mode="legacy"` connection whose client registered the matching callback. -## Silencing the warning +## Silencing the warning {#silencing-the-warning} Don't, in new code. @@ -79,7 +79,7 @@ That is the whole API. There is no per-method switch, and you don't want one: th One line of pytest configuration, and a deprecated call can never sneak back into your codebase without failing a test. -## Recap +## Recap {#recap} * The 2026-07-28 spec deprecates **roots**, server-initiated **sampling**, and protocol **logging** (all [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), restricts **progress** to server-to-client, and removes **`ping`**. * The replacement column points you onward: **[Multi-round-trip requests](handlers/multi-round-trip.md)** for sampling and roots, **[Logging](handlers/logging.md)** for logging, **[Progress](handlers/progress.md)** for progress. `ping` needs nothing at all. diff --git a/docs/get-started/first-steps.md b/docs/get-started/first-steps.md index ad2f4c63fd..e180f0cfcf 100644 --- a/docs/get-started/first-steps.md +++ b/docs/get-started/first-steps.md @@ -1,10 +1,10 @@ -# First steps +# First steps {#first-steps} The **[landing page](../index.md)** moves fast: write a server, run it, call a tool. This page takes it slowly, with all three things a server can expose, and a name for everything along the way. -## Host, client, and server +## Host, client, and server {#host-client-and-server} Three words you'll see on every page from here on: @@ -14,7 +14,7 @@ Three words you'll see on every page from here on: You write the server. Hosts are someone else's product. The SDK also gives you a `Client`. You'll use it to test your servers, and it shows up later on this page. -## The three primitives +## The three primitives {#the-three-primitives} A server exposes exactly three kinds of thing. What separates them is **who decides to use them**: @@ -32,7 +32,7 @@ A server exposes exactly three kinds of thing. What separates them is **who deci side effects). A **prompt** has no HTTP analogue; it's closer to a saved query the user runs by name. -## One server, all three +## One server, all three {#one-server-all-three} ```python title="server.py" hl_lines="6 12 18" --8<-- "docs_src/first_steps/tutorial001.py" @@ -50,7 +50,7 @@ Everything else (the name, the description, the argument schema) the SDK reads f The two halves of the SDK have two import paths: `from mcp import Client` and `from mcp.server import MCPServer`. There is no `from mcp import MCPServer`. -### Try it +### Try it {#try-it} Run it with the MCP Inspector: @@ -72,7 +72,7 @@ Hello, World! The Inspector ran your server over **stdio**, one of the transports an MCP server can speak. You don't pick one yet; **[Running your server](../run/index.md)** is the page for that. -## Capabilities +## Capabilities {#capabilities} You saw three tabs in the Inspector. How did it know there were three? @@ -116,7 +116,7 @@ Notice what isn't there. `completions` (argument autocomplete for resource templ `Client(mcp)` is the same in-memory client every example in these docs is tested with, and it's how you'll test yours. It gets a whole page: **[Testing](testing.md)**. -## What you did not write +## What you did not write {#what-you-did-not-write} Look back over this page. You wrote three small Python functions. You did **not** write: @@ -127,7 +127,7 @@ Look back over this page. You wrote three small Python functions. You did **not* That ratio is the whole point of the SDK. -## Recap +## Recap {#recap} * A **host** is the LLM app, a **client** is its MCP-speaking half, a **server** is what you build. * Tools are **model**-controlled, resources are **application**-controlled, prompts are **user**-controlled. diff --git a/docs/get-started/index.md b/docs/get-started/index.md index 6a317692bf..90a8dd6290 100644 --- a/docs/get-started/index.md +++ b/docs/get-started/index.md @@ -1,11 +1,11 @@ -# Get started +# Get started {#get-started} New to MCP, or new to this SDK? Start here. These pages take you from nothing to a working, tested server: [install the SDK](installation.md), build your [first server](first-steps.md), [connect it to a real host](real-host.md), and [test it](testing.md) with an in-memory client. -## Run the code +## Run the code {#run-the-code} All the code blocks can be copied and used directly: they are complete, working files. @@ -17,7 +17,7 @@ uv run mcp dev server.py It is **HIGHLY encouraged** that you write (or copy) the code, edit it, and run it locally. Using it in your own editor is what really shows you the point: how little you write, the autocompletion, the type checks catching mistakes before you run anything. -## You will not be guessing +## You will not be guessing {#you-will-not-be-guessing} Every example in these docs is a complete file under [`docs_src/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/docs_src) in the SDK's own repository, and every one of them is exercised by the SDK's test suite through an **in-memory client**: @@ -41,7 +41,7 @@ If a change to the SDK breaks an example on one of these pages, CI goes red befo You'll use this yourself in [Testing](testing.md); it's how you test your own servers, too. -## Where to go next +## Where to go next {#where-to-go-next} Once you have a server running, the rest of these docs are a reference, not a course. Every page stands on its own, so jump straight to what you need: diff --git a/docs/get-started/installation.md b/docs/get-started/installation.md index 728a86cfd0..c1e08f224c 100644 --- a/docs/get-started/installation.md +++ b/docs/get-started/installation.md @@ -1,4 +1,4 @@ -# Installation +# Installation {#installation} The Python SDK is on PyPI as [`mcp`](https://pypi.org/project/mcp/). It requires **Python 3.10+**. @@ -21,7 +21,7 @@ These docs describe **v2**, the current stable release line: covers every one. If your *package* depends on `mcp` and isn't ready to migrate, keep a `<2` upper bound (for example `mcp>=1.28,<2`) so an unpinned resolve stays on the 1.x line. -## What gets installed +## What gets installed {#what-gets-installed} You don't need to know any of this to use the SDK, but if you're wondering what each dependency is for: @@ -36,7 +36,7 @@ You don't need to know any of this to use the SDK, but if you're wondering what * [`typing-extensions`](https://typing-extensions.readthedocs.io/) and [`typing-inspection`](https://pypi.org/project/typing-inspection/): modern typing features on Python 3.10. * [`pywin32`](https://pypi.org/project/pywin32/): Windows only, used for `stdio` subprocess management. -## Optional extras +## Optional extras {#optional-extras} * `mcp[cli]` adds [`typer`](https://typer.tiangolo.com/) and [`python-dotenv`](https://pypi.org/project/python-dotenv/) for the `mcp` command-line tool (`mcp dev`, `mcp run`, `mcp install`). You'll want this during development; you may not need it in a deployed server. * `mcp[rich]` adds [`rich`](https://rich.readthedocs.io/) for nicer server logs. diff --git a/docs/get-started/real-host.md b/docs/get-started/real-host.md index d31fb5caea..1d76be878c 100644 --- a/docs/get-started/real-host.md +++ b/docs/get-started/real-host.md @@ -1,10 +1,10 @@ -# Connect to a real host +# Connect to a real host {#connect-to-a-real-host} A **host** is the application your server ends up inside: Claude Desktop, Claude Code, an IDE. The host is what the user talks to. Inside it, an MCP **client** launches your server as a child process and speaks to it over that process's stdin and stdout. Which means connecting to a host is one act: you tell it **the command that starts your server**. Everything on this page (two CLI commands, three JSON files) is a different place to put that same command. -## One server, every host +## One server, every host {#one-server-every-host} ```python title="server.py" hl_lines="3 33-34" --8<-- "docs_src/real_host/tutorial001.py" @@ -18,7 +18,7 @@ Two tools and a resource, one file. Three things about that file matter to every That is the last line of Python on this page. From here down it is all host configuration. -## The launch command +## The launch command {#the-launch-command} Every host below gets the same command: @@ -48,7 +48,7 @@ It is also the command `mcp install` writes into Claude Desktop's config for you this same file as a subprocess with `stdio_client(...)`, and **[Testing](testing.md)** connects to it in memory with no process at all. -## Claude Desktop +## Claude Desktop {#claude-desktop} The one host the SDK can configure for you: @@ -97,7 +97,7 @@ Fully quit Claude Desktop (not just its window) and reopen it. not there. `uv run mcp install server.py -v API_KEY=abc123` (or `-f .env`) records them in the entry's `env` field. `--name` overrides the entry name; it defaults to the server's `name`. -## Claude Code +## Claude Code {#claude-code} There is no file to edit. Register the server with the `claude` CLI; everything after `--` is the launch command. @@ -107,7 +107,7 @@ claude mcp add bookshop -- uv run --with "mcp[cli]" mcp run /absolute/path/to/se Run `/mcp` inside a Claude Code session to confirm `bookshop` is connected and its tools are listed. -## Cursor +## Cursor {#cursor} Create `.cursor/mcp.json` in your project root. @@ -124,7 +124,7 @@ Create `.cursor/mcp.json` in your project root. The same `command` plus `args`, under the same `mcpServers` key Claude Desktop uses. The server appears in Cursor's MCP settings with both tools listed. -## VS Code +## VS Code {#vs-code} Create `.vscode/mcp.json` in your project root. @@ -146,7 +146,7 @@ Two differences from Cursor's file, and they are the only two: the wrapper key i You need VS Code 1.99 or later with the **GitHub Copilot** extension signed in (Copilot Free is enough), and Copilot Chat must be in **Agent** mode, because no other mode calls tools. -## It doesn't show up +## It doesn't show up {#it-doesnt-show-up} Before you touch any host config, run the launch command yourself: @@ -166,7 +166,7 @@ Claude Desktop keeps a log per server: `mcp-server-.log` is your server's For anything past those three, **[Troubleshooting](../troubleshooting.md)** is the page. -## Recap +## Recap {#recap} * A **host** (Claude Desktop, an IDE) runs an MCP client that launches your server as a child process over stdio. Connecting means giving it one launch command. * That command is `uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py`: no venv to activate, works from any directory. diff --git a/docs/get-started/testing.md b/docs/get-started/testing.md index 9abd281ceb..77b199613d 100644 --- a/docs/get-started/testing.md +++ b/docs/get-started/testing.md @@ -1,10 +1,10 @@ -# Testing +# Testing {#testing} The Python SDK ships a `Client` class with an **in-memory transport**: pass it your server object and it connects to it directly. No subprocess. No port. No transport at all. It's the same idea as FastAPI's `TestClient`. -## Basic usage +## Basic usage {#basic-usage} Let's assume you have a simple server with a single tool: @@ -74,7 +74,7 @@ async def test_call_add_tool(client: Client): There you go! You can now extend your tests to cover more scenarios. -## Why `raise_exceptions=True`? +## Why `raise_exceptions=True`? {#why-raise_exceptionstrue} Two different things can go wrong, and this flag only touches one of them. @@ -91,7 +91,7 @@ instead of the sanitised one. Leave it on in tests. It has no meaning in production code. -## In-process by default +## In-process by default {#in-process-by-default} !!! note `Client(mcp)` connects in-process and is **era-neutral** by default: it probes the server and diff --git a/docs/handlers/context.md b/docs/handlers/context.md index f43521aa05..90b8cd36bd 100644 --- a/docs/handlers/context.md +++ b/docs/handlers/context.md @@ -1,10 +1,10 @@ -# The Context +# The Context {#the-context} A tool's arguments come from the model. Everything else (the request you are serving, the server you live in, a way to talk back to the client) comes from one object: the **`Context`**. You don't construct it and you don't configure it. You ask for it. -## Ask for it +## Ask for it {#ask-for-it} Add a parameter annotated with `Context` to any tool: @@ -22,7 +22,7 @@ Add a parameter annotated with `Context` to any tool: (`Request` there, `Context` here) and the framework supplies it. Nothing to register, nothing to configure: the type annotation is the whole mechanism. -### Invisible to the model +### Invisible to the model {#invisible-to-the-model} This is the part to internalise. Here is the input schema `tools/list` reports for `search_books`: @@ -39,7 +39,7 @@ This is the part to internalise. Here is the input schema `tools/list` reports f One property. `ctx` is not an argument: it never appears in the schema, the model is never told about it, and no client can fill it in. It's a contract between you and the SDK, invisible on the wire. -### Try it +### Try it {#try-it} Run the server with the MCP Inspector: @@ -55,7 +55,7 @@ The form for `search_books` has a single `query` field. Call it with `dune`: The number is whichever request this happened to be. Call the tool again and it changes: every request gets its own `Context`. -## What it gives you +## What it gives you {#what-it-gives-you} The injected object is small. Besides `request_id`: @@ -73,7 +73,7 @@ Logging is deliberately not on that list. A server logs with Python's `logging` its own `Context`; pass `ctx` down as an ordinary argument. There is no ambient "current context" to fetch from somewhere else. -## Read your own resources +## Read your own resources {#read-your-own-resources} A server's resources aren't only for clients. A tool can read them too: @@ -91,7 +91,7 @@ contents.mime_type # 'text/plain' * `content` is exactly what `genres()` returned. One source of truth: the client browses the resource, your tools consume it, nobody copies the string. * `describe_catalog`'s only parameter is the `Context`, so its input schema has **no properties at all**. The model calls it with `{}`. -## Tell the client the list changed +## Tell the client the list changed {#tell-the-client-the-list-changed} What a server offers is not fixed at import time. Register a tool at runtime, then tell the client: @@ -117,7 +117,7 @@ On a 2026-07-28 connection, clients receive change notifications only on a `subs Run `enable_recommendations`, and the very same call succeeds. The tool list is genuinely dynamic: `tools/list` reflects whatever is registered *right now*. -## Recap +## Recap {#recap} * Annotate a parameter with `Context` (in a tool, a resource, or a prompt) and the SDK injects it. The name is yours. * It is invisible to the model: the input schema only ever contains your real arguments. diff --git a/docs/handlers/dependencies.md b/docs/handlers/dependencies.md index b347e1b7d6..1441222ecd 100644 --- a/docs/handlers/dependencies.md +++ b/docs/handlers/dependencies.md @@ -1,10 +1,10 @@ -# Dependencies +# Dependencies {#dependencies} A tool's arguments come from the model. Some values never should: a price looked up from your records, a confirmation only a person can give, anything the model could get wrong by inventing it. **Dependencies** are parameters filled by your own functions. You annotate the parameter, name the function, and the SDK calls it before your tool runs. -## Declare one +## Declare one {#declare-one} Wrap the parameter's type in `Annotated[...]` and add `Resolve(fn)`: @@ -20,7 +20,7 @@ Wrap the parameter's type in `Annotated[...]` and add `Resolve(fn)`: If you've used FastAPI, this is `Depends`. Same move, same reason: the function declares what it needs, the framework supplies it, and the wiring lives in the type annotation. -### Invisible to the model +### Invisible to the model {#invisible-to-the-model} Here is the input schema `tools/list` reports for `reserve_book`: @@ -39,7 +39,7 @@ One property. Like the `Context` in **[The Context](context.md)**, a resolved pa That last part is the point. A parameter the model cannot supply is a parameter the model cannot get wrong. -### Try it +### Try it {#try-it} Run the server with the MCP Inspector: @@ -61,7 +61,7 @@ The tool body never looked anything up: `check_stock` ran first, and the `Stock` and the SDK runs the resolver at most once per call, no matter how many declare it. The next sections add the rest: resolvers that depend on each other, and resolvers that ask the user. -## Dependencies of dependencies +## Dependencies of dependencies {#dependencies-of-dependencies} A resolver can declare its own dependencies, with the same annotation: @@ -91,7 +91,7 @@ A resolver's parameters resolve exactly like a tool's: another `Resolve(...)`, t that should outlive a request - a database pool, an HTTP client - belongs in **[Lifespan](lifespan.md)**, and a resolver can reach it through `ctx.request_context.lifespan_context`. -## Ask when you must +## Ask when you must {#ask-when-you-must} A resolver doesn't have to know the answer. It can return `Elicit(message, Model)` and the SDK asks the user - the **[Elicitation](elicitation.md)** machinery, run for you: @@ -134,7 +134,7 @@ That's the right default for a precondition: no answer, no order. When declining to bind to. A question built from such volatile data makes every recorded answer look stale, so the server re-asks it on every round until the client's round limit ends the call. -## Ask the client, not the user +## Ask the client, not the user {#ask-the-client-not-the-user} Elicitation is one of the three questions a resolver can ask, and the multi-round-trip flow allows no others. The other two go to the **client** rather than the user: return `Sample(...)` to run an LLM call through the client (a `sampling/createMessage` request), or `ListRoots()` to fetch the client's current roots. Neither has an accept/decline outcome; the consumer annotates the result type directly, `CreateMessageResult` (`CreateMessageResultWithTools` when the request carries `tools` or `tool_choice`) or `ListRootsResult`: @@ -146,7 +146,7 @@ Elicitation is one of the three questions a resolver can ask, and the multi-roun * Everything the info box above says about questions applies unchanged: a `Sample` request is matched to its recorded result by its exact rendering, so build it deterministically from the tool's arguments and earlier answers; the client then pays for the LLM call once per tool call, not once per round. The recorded result rides `request_state` for the rest of the call, so a very large completion makes every remaining round-trip heavier. * The standalone sampling and roots *features* are deprecated at 2026-07-28 (SEP-2577). New servers that need the client's model ask through this carrier; servers that don't should integrate with an LLM provider directly. `include_context` values other than `"none"` are themselves deprecated; avoid them. -## Recap +## Recap {#recap} * `Annotated[T, Resolve(fn)]` on a tool parameter: the SDK runs `fn` and injects its return value. * A resolved parameter is invisible to the model and cannot be supplied by a client. Values the model must not invent - prices, identities, permissions - belong here. diff --git a/docs/handlers/elicitation.md b/docs/handlers/elicitation.md index c9a0a4fabc..e24d9aa3cc 100644 --- a/docs/handlers/elicitation.md +++ b/docs/handlers/elicitation.md @@ -1,4 +1,4 @@ -# Elicitation +# Elicitation {#elicitation} A tool that is halfway through its job and missing one answer doesn't have to fail. @@ -11,7 +11,7 @@ There are two modes: And there are two ways to ask. The one to reach for is a **resolver**: you hang the question on a parameter, and the SDK asks - on any connection, whatever protocol era the client speaks. The direct way, `await ctx.elicit(...)`, is a request from the *server* to the *client*, a channel that only exists for a client on a legacy connection (spec version 2025-11-25 or earlier). Both are on this page; start with the resolver. -## Ask with a resolver +## Ask with a resolver {#ask-with-a-resolver} A question that gates the whole tool - *are you sure? which of the three matching accounts?* - can be lifted out of the tool body into a **resolver**, and the framework asks it for you. @@ -31,7 +31,7 @@ A resolver works on **every** connection. For a client on a legacy connection th Asking is only one thing a resolver can do. The general mechanism - dependencies that compute without asking, dependencies of dependencies, what the model can and cannot supply - is the **[Dependencies](dependencies.md)** page. -## Ask from inside the tool +## Ask from inside the tool {#ask-from-inside-the-tool} A tool can also stop in the middle of its own body and ask. @@ -54,7 +54,7 @@ A tool can also stop in the middle of its own body and ask. * On any other date the tool returns straight away. It only asks when it has to. * The date the user accepts goes back through `book_table` itself. An answer is input like any other: an alternative that is also fully booked gets asked about again, not confirmed blind. -### What the client receives +### What the client receives {#what-the-client-receives} The client gets your message and, next to it, a JSON Schema generated from the model: @@ -93,7 +93,7 @@ That schema is the form. `Field(description=...)` is the label; a default pre-fi You are interrupting a person mid-task. If the answer needs nesting, it should have been an argument to the tool. -### The three answers +### The three answers {#the-three-answers} `result.action` tells you what the user did, and there are exactly three possibilities: @@ -110,7 +110,7 @@ A refusal is not an error. The tool decides what declining means (here, no booki `"maybe"` for a `bool` doesn't corrupt your booking: the call fails with a schema-mismatch error, your `if` never runs. -## Send the user to a URL +## Send the user to a URL {#send-the-user-to-a-url} Some things must not go through the model or the client: credentials, card numbers, OAuth consent. For those you don't ask for data; you ask the user to go somewhere: @@ -124,7 +124,7 @@ Some things must not go through the model or the client: credentials, card numbe Look at the second tool. When your server learns the out-of-band flow finished (a webhook, a poll; here it's modelled as a second tool), `ctx.session.send_elicit_complete(...)` sends `notifications/elicitation/complete` with the same `elicitation_id`. That is how the client knows it can stop showing *"waiting for payment..."*. Without it, the client can only guess. -## The client side +## The client side {#the-client-side} Servers ask. Clients answer by passing an **`elicitation_callback`** to `Client(...)`: @@ -143,7 +143,7 @@ Servers ask. Clients answer by passing an **`elicitation_callback`** to `Client( On a **2026-07-28** connection a tool asks by *returning* the question from the call instead; that flow is **[Multi-round-trip requests](multi-round-trip.md)**. -### Try it +### Try it {#try-it} Start the `ctx.elicit` form-mode `server.py` (the `book_table` one) on Streamable HTTP (**[Running your server](../run/index.md)** has the one-liner), then run the client's `main()` and ask `book_table` for Christmas day. @@ -173,7 +173,7 @@ Now swap in the URL-mode `server.py` and point the same `main()` at `pay_deposit nobody to ask. Your tool didn't get a `"decline"`; it got an exception. Design for it: every elicitation needs a sensible answer to "what if I can't ask?". -## Recap +## Recap {#recap} * A parameter annotated `Annotated[T, Resolve(fn)]` is filled by a resolver, which returns `Elicit(...)` when it has to ask. It works on every connection. * The schema is a flat Pydantic model: primitive fields only, validated on the way back. diff --git a/docs/handlers/index.md b/docs/handlers/index.md index daf9fde19a..f119747d4d 100644 --- a/docs/handlers/index.md +++ b/docs/handlers/index.md @@ -1,4 +1,4 @@ -# Inside your handler +# Inside your handler {#inside-your-handler} A handler's arguments come from the client. Everything *else* it can read, and everything it can do while it runs, is here. diff --git a/docs/handlers/lifespan.md b/docs/handlers/lifespan.md index 35b9bd0803..eaab65e580 100644 --- a/docs/handlers/lifespan.md +++ b/docs/handlers/lifespan.md @@ -1,10 +1,10 @@ -# Lifespan +# Lifespan {#lifespan} Most real servers hold something for their whole life: a database pool, an HTTP client, a loaded model. You don't want to build it on every call, and you do want to close it cleanly. That's what the **lifespan** is for. -## A typed lifespan +## A typed lifespan {#a-typed-lifespan} A lifespan is an `@asynccontextmanager` that receives the server and `yield`s **one object**. Whatever you yield is available to every handler for as long as the server runs. @@ -24,7 +24,7 @@ The lifespan runs **once**. It is entered when the server starts (before the fir !!! info If you've written a FastAPI `lifespan`, you already know this. Same decorator, same `yield`, same `finally`. -### What the model sees +### What the model sees {#what-the-model-sees} Nothing new. `ctx` is a **Context** parameter, so the SDK injects it and it never reaches the input schema: @@ -43,7 +43,7 @@ Nothing new. `ctx` is a **Context** parameter, so the SDK injects it and it neve `@mcp.resource()` and `@mcp.prompt()` functions can take a `ctx` parameter too, written as a bare `Context` for a reason the next section gets to. Everything `ctx` carries is in **[The Context](context.md)**. -### It really is typed +### It really is typed {#it-really-is-typed} Look at the annotation again: `ctx: Context[AppContext]`. @@ -69,7 +69,7 @@ Write a bare `Context` instead and `lifespan_context` is typed as `dict[str, Any so `ctx.request_context.lifespan_context` is `{}`, never `None`. That default is also why a bare `Context` types it as `dict[str, Any]`. -## Watch it happen +## Watch it happen {#watch-it-happen} "Startup runs before the first request" is the kind of sentence you should not have to take on faith. @@ -90,7 +90,7 @@ Strip the server down to the lifecycle: give `Database` a `connected` flag, flip The work happened exactly where you put it: around the `yield`, not at import time and not per request. -## Recap +## Recap {#recap} * `lifespan=` takes an `@asynccontextmanager` that receives the server and `yield`s one object. * Code before the `yield` is startup. The `finally` after it is shutdown. diff --git a/docs/handlers/logging.md b/docs/handlers/logging.md index 6f6c839314..aa886a91d2 100644 --- a/docs/handlers/logging.md +++ b/docs/handlers/logging.md @@ -1,4 +1,4 @@ -# Logging +# Logging {#logging} Log from a tool the way you log from any other Python function: with the standard library. @@ -6,7 +6,7 @@ MCP has a protocol-level **logging capability**: a server could push its log mes What you do instead is what you do in every other Python program: the standard library. -## A tool that logs +## A tool that logs {#a-tool-that-logs} ```python title="server.py" hl_lines="1 5 13" --8<-- "docs_src/logging/tutorial001.py" @@ -26,7 +26,7 @@ What you do instead is what you do in every other Python program: the standard l The log line is nowhere in it. Logging is for **you**, the person operating the server. The model never sees it. If the model should read something, `return` it. -## Where it goes +## Where it goes {#where-it-goes} For a **stdio** server this question matters more than usual. The host launched your server as a subprocess and is reading MCP messages from its **stdout**. Standard error is yours. @@ -41,7 +41,7 @@ The standard library already does the right thing: log output goes to `sys.stder `logger.debug("got here")` is the same one line of effort and goes to the right place. -## The level +## The level {#the-level} You don't have to call `logging.basicConfig()` yourself. Constructing an `MCPServer` already did, with a handler pointed at standard error, at the level you pass as `log_level=`, so `MCPServer("Bookshop", log_level="DEBUG")` is all it takes to see your `logger.debug(...)` lines. @@ -49,7 +49,7 @@ The default is `"INFO"`. `logging.basicConfig()` never replaces handlers that already exist. If you configure logging yourself before creating the server, your configuration wins. -## Try it +## Try it {#try-it} Run the server with the MCP Inspector: @@ -70,7 +70,7 @@ went to standard error: the terminal, not the wire. don't want log lines, you want spans. Your server already emits them: the SDK traces every message with OpenTelemetry out of the box. See **[OpenTelemetry](../run/opentelemetry.md)**. -## Recap +## Recap {#recap} * The MCP protocol's logging capability is deprecated by the 2026-07-28 spec and not replaced. Don't build on it. * `logger = logging.getLogger(__name__)` at module level, `logger.info(...)` in the tool. That's the whole pattern. diff --git a/docs/handlers/multi-round-trip.md b/docs/handlers/multi-round-trip.md index 1d5b9f52c6..a1ab6ff789 100644 --- a/docs/handlers/multi-round-trip.md +++ b/docs/handlers/multi-round-trip.md @@ -1,4 +1,4 @@ -# Multi-round-trip requests +# Multi-round-trip requests {#multi-round-trip-requests} Sometimes a tool can't finish in one round trip. It needs something only the user has: a choice, a confirmation, a credential. @@ -6,7 +6,7 @@ Before 2026-07-28 the server got it by calling **back**: opening its own request Instead, the server **returns**. -## Return, don't call back +## Return, don't call back {#return-dont-call-back} The server answers `tools/call` with an **`InputRequiredResult`** instead of a `CallToolResult`. Two of its fields do the work: @@ -17,7 +17,7 @@ The client fulfils each request, then calls the **same tool again**, carrying it That's the whole protocol. Every leg is an ordinary request from the client to the server. Nothing ever flows the other way. -## The server side +## The server side {#the-server-side} On `@mcp.tool()` you rarely build this by hand: declare a dependency that asks the user (`Elicit`), samples the client's LLM (`Sample`), or lists its roots (`ListRoots`) and the SDK returns the `InputRequiredResult` for you; that form is the **[Dependencies](dependencies.md)** page. The two forms don't mix: a call has one `input_responses`/`request_state` channel, so a tool that uses `Resolve(...)` parameters cannot also return `InputRequiredResult` from its body. A declared `InputRequiredResult` return is rejected at registration (`InvalidSignature`), and an undeclared one fails the call at runtime. The manual form is the **low-level** `Server`, whose `on_call_tool` handler is allowed to return either result type: @@ -31,7 +31,7 @@ On `@mcp.tool()` you rarely build this by hand: declare a dependency that asks t Everything else in that file (the explicit `input_schema`, the hand-built `CallToolResult`) is the ordinary low-level `Server`, covered in **[The low-level Server](../advanced/low-level-server.md)**. This page only adds the second return type. -## Beyond tools +## Beyond tools {#beyond-tools} `tools/call` is not special: at 2026-07-28 a server may answer `prompts/get` and `resources/read` the same way. On `MCPServer`, an `@mcp.prompt()` function — or an `@mcp.resource()` **template** function — returns the `InputRequiredResult` itself and reads the retry's answers off the context: @@ -45,7 +45,7 @@ Everything else in that file (the explicit `input_schema`, the hand-built `CallT * Static `@mcp.resource()` functions don't participate: they take no `Context`, so they could never read the retry. Only template resources can ask. * The era rules below apply unchanged: returning an `InputRequiredResult` on a pre-2026 session is the same `-32603` the warning describes. -## The client side +## The client side {#the-client-side} `Client` runs the loop for you. @@ -66,7 +66,7 @@ Register the callbacks the server might ask for (`elicitation_callback`, `sampli The loop is bounded. `Client(..., input_required_max_rounds=10)` is the default cap; a server that keeps returning `InputRequiredResult` past it makes `call_tool` raise. If a round carries only `request_state` and no `input_requests`, `Client` sleeps briefly (50ms doubling to a 250ms ceiling) before retrying, so a server that is just saying *"not done yet"* isn't busy-polled. -### Driving the loop yourself +### Driving the loop yourself {#driving-the-loop-yourself} The auto-loop is enough for a single-process client. Own the loop instead when: @@ -85,7 +85,7 @@ Drop to the underlying session, where `allow_input_required=True` hands you the * For every entry in `input_requests` you put an `InputResponse` under the **same key** in `input_responses`. `fulfil` is where your UI goes; this one hard-codes the answer. * Same tool name, same `arguments`, every leg. The retry is the original call carried out again, not a new method. -## Protecting `requestState` +## Protecting `requestState` {#protecting-requeststate} Everything above treats `request_state` as an echo, and on the wire that is all it is. But the client holds it between legs (writing it down across processes is exactly what the previous section blessed), so what comes back is **client-supplied input**: it can be modified, expired, or lifted from a different call entirely. The spec requires servers to integrity-protect this state and reject the round when verification fails, whenever the state can influence authorization, resource access, or business logic. @@ -104,7 +104,7 @@ mcp = MCPServer("fleet", request_state_security=RequestStateSecurity(keys=[key]) * **`keys=[...]`** is required whenever a retry can reach a **different instance** (multi-worker `uvicorn`, load-balanced HTTP) or must survive restarts: every instance verifies what any sibling minted. Same machinery, your secret instead of a generated one. * For your own crypto, such as a KMS or an existing token service, pass `RequestStateSecurity(codec=...)` instead of `keys`; **[Bring your own crypto](#bring-your-own-crypto)** below covers the contract. -### What the seal carries +### What the seal carries {#what-the-seal-carries} Default or configured, `requestState` on the wire is an encrypted, authenticated token. Your code never sees it: handlers and resolvers write plaintext and read plaintext (`ctx.request_state`); the SDK seals on the way out and verifies on the way in. Beyond integrity, each token is bound to: @@ -115,7 +115,7 @@ Default or configured, `requestState` on the wire is an encrypted, authenticated All of that is the SDK's job, not yours, and not the codec's if you bring your own. -### Rotating keys +### Rotating keys {#rotating-keys} `keys[0]` seals new state; every key in the list verifies. Zero-downtime rotation is three phases, each fully rolled out before the next: @@ -129,7 +129,7 @@ Never promote the minter first: minting under a key some instance can't yet veri Keys are scoped to one service. The sealed envelope also carries the server's name as an audience claim, so a token minted by a different service that happens to share a secret is rejected anyway. The claim is only as distinctive as the name, so a server given an explicit policy must have a real name or set `RequestStateSecurity(audience=...)` — an unnamed one raises at construction. `audience=` also serves deliberate multi-service topologies where one service must accept state another minted. (The no-configuration default is exempt: its key never leaves the process, so the audience claim has nothing to add.) -### Bring your own crypto +### Bring your own crypto {#bring-your-own-crypto} `RequestStateSecurity(codec=...)` takes anything with `seal(bytes) -> str` and `unseal(str) -> bytes` that raises `InvalidRequestState` for any token it did not mint. The classic shape is envelope encryption against a KMS, where you unwrap a data key once at startup and keep the per-token crypto local: @@ -139,7 +139,7 @@ Keys are scoped to one service. The sealed envelope also carries the server's na TTL, principal binding, and request binding are **not** the codec's job: the SDK stamps them into the payload before `seal` and re-verifies them after `unseal`, for every codec. A codec's only obligations are integrity (tampered means raise) and, ideally, confidentiality. -### When verification fails +### When verification fails {#when-verification-fails} Every inbound failure, whether tampered, expired, replayed against a different request or principal, or sealed under a key this server doesn't know, gets the same answer: @@ -149,7 +149,7 @@ Every inbound failure, whether tampered, expired, replayed against a different r One frozen message for every cause, so the wire never reveals which check failed; the real reason goes to the server log. Every inbound `requestState` on `tools/call`, `prompts/get`, and `resources/read` is checked, including one arriving for a handler that never mints state. The most common rejection in practice isn't an attacker — it's the default process-local key meeting a retry from before a restart or from another instance; the client restarts the flow, and `keys=[...]` is the fix when that matters. -### Hand-built state +### Hand-built state {#hand-built-state} A `request_state` you set yourself (returning `InputRequiredResult` from a tool, prompt, or resource-template function) is sealed and verified by the same machinery as resolver state, with zero code changes: write plaintext, read plaintext, and every binding above applies. @@ -157,7 +157,7 @@ The one thing the SDK cannot pin for you, even when configured, is question iden The low-level `Server` is the no-batteries tier: unlike `MCPServer`, nothing is sealed until you append the boundary yourself, and your `request_state` crosses the wire exactly as written until you do. The one-line opt-in is shown in **[The low-level Server](../advanced/low-level-server.md#the-other-handlers)**. -## A 2026-07-28 result +## A 2026-07-28 result {#a-2026-07-28-result} `InputRequiredResult` only exists at protocol version **2026-07-28**. The in-memory `Client(server)` negotiates it for you; over the wire, `mode="auto"` discovers it. After connecting, `client.protocol_version` tells you what you got. @@ -173,7 +173,7 @@ The low-level `Server` is the no-batteries tier: unlike `MCPServer`, nothing is finishes the out-of-band flow and your client retries the call. Same loop, no new API. The high-level server half is in **[Elicitation](elicitation.md)**. -## Recap +## Recap {#recap} * At 2026-07-28 a server that needs input mid-call **returns** an `InputRequiredResult`. It never opens a request to the client. * `input_requests` is what it needs. `request_state` is an opaque resume token only the server reads. diff --git a/docs/handlers/progress.md b/docs/handlers/progress.md index 57bbb59e03..01c66a9673 100644 --- a/docs/handlers/progress.md +++ b/docs/handlers/progress.md @@ -1,10 +1,10 @@ -# Progress +# Progress {#progress} A tool that takes thirty seconds and says nothing for thirty seconds looks broken. **Progress notifications** fix that. The tool reports how far along it is; the client decides what to draw with it: a bar, a spinner, a log line. -## Report it from the tool +## Report it from the tool {#report-it-from-the-tool} Take a **`Context`** parameter and call `report_progress`: @@ -20,7 +20,7 @@ Three arguments, and you decide what they mean: `ctx` is injected because of its type hint and the model never sees it: `import_catalog`'s input schema has a single property, `urls`. **[The Context](context.md)** page is all about that object; progress is one of the things it gives you. -## Listen for it from the client +## Listen for it from the client {#listen-for-it-from-the-client} The client opts in **per call**, by passing `progress_callback=` to `call_tool`: @@ -58,7 +58,7 @@ The callback is an `async` function taking exactly what the server reported: `pr notifications race the result, and a slow callback can still be running after `call_tool` has returned. -### Try it +### Try it {#try-it} Put `client.py` next to `server.py` and run it: @@ -90,7 +90,7 @@ Every `await ctx.report_progress(...)` on the server became one call to `show` o for progress**, so you report unconditionally and never have to wonder whether anyone is listening. -## When you don't know the total +## When you don't know the total {#when-you-dont-know-the-total} `total` is for when you know the denominator. Often you don't: you're draining a feed, walking a cursor, downloading something with no length header. @@ -106,7 +106,7 @@ The callback receives `total=None`. A client can still show *activity* ("3 impor `progress` doesn't have to count anything in particular. Bytes, rows, pages: pick the unit the user would recognise, and only promise a `total` you can keep. -## Recap +## Recap {#recap} * `await ctx.report_progress(progress, total=None, message=None)` from any tool that takes a `Context`. * The client passes `progress_callback=` to `call_tool`: per call, never on the `Client`. diff --git a/docs/handlers/sampling-and-roots.md b/docs/handlers/sampling-and-roots.md index f7c192f05b..d3c9fd0c56 100644 --- a/docs/handlers/sampling-and-roots.md +++ b/docs/handlers/sampling-and-roots.md @@ -1,4 +1,4 @@ -# Sampling and roots +# Sampling and roots {#sampling-and-roots} A handler can ask the connected client for two more things: a completion from the client's own model (**sampling**), and the client's workspace folders (**roots**). @@ -7,7 +7,7 @@ Both still work, on every protocol version the SDK speaks. But read the warning !!! warning "Deprecated by the 2026-07-28 specification" Sampling and roots are deprecated as of `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2577)). They remain fully functional and stay in the specification for at least twelve months before becoming eligible for removal, but new implementations should not build on them. The suggested migrations: integrate directly with your LLM provider's API instead of sampling, and pass directories via tool parameters, resource URIs, or server configuration instead of roots. The SDK-wide list is in **[Deprecated features](../deprecated.md)**. -## Sampling: borrow the client's model +## Sampling: borrow the client's model {#sampling-borrow-the-clients-model} A resolver returns `Sample(...)` and the tool receives the completion, through the same dependency mechanism that runs `Elicit` in **[Dependencies](dependencies.md)**: @@ -20,7 +20,7 @@ A resolver returns `Sample(...)` and the tool receives the completion, through t * At `2026-07-28` the request is delivered inside the multi-round-trip flow (**[Multi-round-trip requests](multi-round-trip.md)**); on `2025-11-25` it is a standalone request to the client. The code is the same either way, but mind the multi-round-trip rule: the request must render identically across retry rounds, so build it only from the tool's arguments and other stable data. * Leave `include_context` alone: values other than `"none"` are themselves deprecated (SEP-2596) and need a capability almost no client declares. -## Roots: where should this go? +## Roots: where should this go? {#roots-where-should-this-go} Roots are the folders the client says the server may operate on. They are informational guidance, not an access-control mechanism. A resolver returns `ListRoots()`: @@ -33,11 +33,11 @@ Roots are the folders the client says the server may operate on. They are inform On the other side of the wire, the client answers both requests with the callbacks it already has: `sampling_callback` and `list_roots_callback`, covered in **[Client callbacks](../client/callbacks.md)**. -## On 2025-era connections +## On 2025-era connections {#on-2025-era-connections} `ctx.session.create_message(...)` and `ctx.session.list_roots()` still exist for code that drives the session directly. They only work where a back-channel exists (2025-era, non-stateless connections), and calling them raises a deprecation warning. The resolver markers above are the supported form: they pick the delivery from the negotiated version and don't warn. -## Recap +## Recap {#recap} * Return `Sample(...)` or `ListRoots()` from a resolver; the tool receives the `CreateMessageResult` or `ListRootsResult` like any other dependency. * The client must declare the matching capability, or the call fails with `-32021` instead of a request being sent. diff --git a/docs/handlers/subscriptions.md b/docs/handlers/subscriptions.md index 4fbf7e9ffb..a1ad56e752 100644 --- a/docs/handlers/subscriptions.md +++ b/docs/handlers/subscriptions.md @@ -1,10 +1,10 @@ -# Subscriptions +# Subscriptions {#subscriptions} A server's catalog is not fixed. Tools appear at runtime, and the content behind a resource URI changes. **Subscriptions** are how a client hears about it. The client sends one `subscriptions/listen` request, and the response to that request *is* the stream: it stays open and carries the change notifications the client asked for. -## Publish it from the tool +## Publish it from the tool {#publish-it-from-the-tool} Your side of it is one line: publish the change. @@ -32,7 +32,7 @@ Your side of it is one line: publish the change. Note what the update does *not* carry: the board. Every frame carries the listen request's JSON-RPC id under `_meta`, and that id is the subscription id. The client mints it: the Python `Client` uses strings like `"listen-1"`; other clients may use integers. -## Only what was asked for +## Only what was asked for {#only-what-was-asked-for} The filter is a contract. A stream that requested tool-list changes and one resource URI receives those two kinds and nothing else. Publish a prompt change and that stream stays silent. @@ -43,7 +43,7 @@ Two things the stream is *not*: * **It is not a replay log.** A dropped stream is gone, and events published while nobody was connected are not queued. Clients re-listen and refetch. * **It is not the 2025 path.** Clients that called `resources/subscribe` are served by `ctx.session.send_resource_updated(uri)`. The `notify_*` methods reach `subscriptions/listen` streams only. -## Deciding who may watch +## Deciding who may watch {#deciding-who-may-watch} By default every requested kind and URI is honored: any caller may watch any URI you publish. Nothing consults your read handler, because nobody is reading — a caller your `files://{name}` handler would turn away can still open a stream on `files://payroll.csv` and learn that it changed, and when. It never learns content, and it cannot probe what exists, because an unknown URI is honored too and simply never fires. Narrow but real, so gate it before you publish per-user URIs from a multi-tenant server. @@ -60,7 +60,7 @@ The gate is a middleware. It sees the `subscriptions/listen` request before the The full middleware contract, including what else it wraps and why it is marked provisional, is on **[Middleware](../advanced/middleware.md)**. -## The client end +## The client end {#the-client-end} Here is a client on the other side of that stream, following the board: @@ -70,7 +70,7 @@ Here is a client on the other side of that stream, following the board: Entering `client.listen(...)` sends the request and waits for your acknowledgment, so the stream is live when the block starts, and each typed event is a cue to refetch, never a payload. That is the whole contract in one screen. Everything else about the client end lives on its own page: watching beside a main flow, stream endings, and re-listening. See **[Subscriptions](../client/subscriptions.md)** under *Clients*. -## Scaling past one process +## Scaling past one process {#scaling-past-one-process} Publishes travel from your handler to the open streams over a `SubscriptionBus`. The default is in-memory: one process, every stream in it. That is the right answer until you run replicas behind a load balancer, because then a client's stream is pinned to one replica, and a publish on another replica has to reach it. @@ -123,7 +123,7 @@ async def tools_reloaded() -> None: await bus.publish(ToolsListChanged()) # from a lifespan task, a webhook, anywhere ``` -## The low-level composition +## The low-level composition {#the-low-level-composition} Down on the low-level `Server` there is no pre-wired anything, and the same parts assemble in three lines: @@ -135,7 +135,7 @@ Down on the low-level `Server` there is no pre-wired anything, and the same part * `ListenHandler(bus)` is the same handler `MCPServer` registers, and `on_subscriptions_listen=` is an ordinary handler slot. Put your own callable in that slot for different semantics, and the spec obligations move to you: acknowledge first, stamp every frame with the subscription id, deliver nothing outside the filter. * `ListenHandler.close()` ends every open stream gracefully. Each one receives the listen request's result as its final frame, which is the spec's way of saying the server ended the subscription deliberately. It returns before those streams finish flushing, so give them a moment before you tear the transport down. Without it, streams end when the client disconnects. -## Recap +## Recap {#recap} * A client opts in with one `subscriptions/listen` request, and the response is the stream. Serving it is built in. * You publish with `ctx.notify_*`, and the SDK does the stamping, filtering, and lifecycle work. diff --git a/docs/index.md b/docs/index.md index 3d10fc9bca..97dc2e341c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,4 +1,4 @@ -# MCP Python SDK +# MCP Python SDK {#mcp-python-sdk} !!! info "This documents v2, the current stable release line" New to v2, or coming from v1? **[What's new in v2](whats-new.md)** is the five-minute tour of what changed, and the **[Migration Guide](migration.md)** covers every breaking change. @@ -13,11 +13,11 @@ This is the official Python SDK for it. With it you can: * **Build MCP clients** that connect to any MCP server. * Speak every standard transport: stdio, Streamable HTTP, and SSE. -## Requirements +## Requirements {#requirements} Python 3.10+. -## Installation +## Installation {#installation} === "uv" @@ -34,9 +34,9 @@ Python 3.10+. The `[cli]` extra gives you the `mcp` command; you'll want it for development. See [Installation](get-started/installation.md) for what each dependency is for. -## Example +## Example {#example} -### Create it +### Create it {#create-it} Create a file `server.py`: @@ -48,7 +48,7 @@ That's a complete MCP server. It exposes one **tool**, `add`, and one templated **resource**, `greeting://{name}`. -### Run it +### Run it {#run-it} ```console uv run mcp dev server.py @@ -59,7 +59,7 @@ This starts your server and opens the [MCP Inspector](https://github.com/modelco !!! note The Inspector is a Node.js app, so `mcp dev` needs `npx` on your `PATH`. -### Try it +### Try it {#try-it} In the Inspector, go to **Tools** and call `add` with `a=1`, `b=2`. @@ -73,7 +73,7 @@ Now go to **Resources** and read `greeting://World`: Hello, World! ``` -### Recap +### Recap {#recap} Look again at what you did **not** write: @@ -83,7 +83,7 @@ Look again at what you did **not** write: You wrote two Python functions with type hints and a docstring. The SDK does the rest. -## Where to go next +## Where to go next {#where-to-go-next} * **[Get started](get-started/index.md)** takes you from install to a working, tested server. * Building an application that *uses* MCP servers? Start with **[Clients](client/index.md)**. diff --git a/docs/migration.md b/docs/migration.md index b094d79f84..3f6052ca67 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1,4 +1,4 @@ -# Migration Guide: v1 to v2 +# Migration Guide: v1 to v2 {#migration-guide-v1-to-v2} This guide covers the breaking changes introduced in v2 of the MCP Python SDK and how to update your code. @@ -9,11 +9,11 @@ Version 2 of the MCP Python SDK introduces several breaking changes to improve t documentation is at [/v1/](https://py.sdk.modelcontextprotocol.io/v1/). If your package depends on `mcp`, keep a `<2` upper bound until you've migrated. -## Find your changes +## Find your changes {#find-your-changes} Every section heading below names the API it affects, so searching this page for the symbol your code uses is the fastest route to the change that broke it. The guide lists changes only: an SDK API not mentioned here behaves as it did in v1, and the "what did not change" summaries — [`MCPServer`](#what-is-unchanged-on-mcpserver), [lowlevel `Server`](#lowlevel-server-what-did-not-change), and [auth](#unchanged-auth-surfaces) — spell out the surfaces most migrators stop to check. -### Changes almost every project hits +### Changes almost every project hits {#changes-almost-every-project-hits} | Change | First symptom | Section | |---|---|---| @@ -33,7 +33,7 @@ Every section heading below names the API it affects, so searching this page for | Lowlevel tool exceptions no longer become `isError: true` results | clients raise a JSON-RPC error instead of seeing the error text | [tool exceptions](#lowlevel-server-tool-handler-exceptions-no-longer-become-calltoolresultis_errortrue) | | Roots, Sampling, and Logging deprecated (SEP-2577) | `MCPDeprecationWarning` at call sites | [SEP-2577](#roots-sampling-and-logging-methods-deprecated-sep-2577) | -### Find your area +### Find your area {#find-your-area} | If you... | Read | |---|---| @@ -49,7 +49,7 @@ Every section heading below names the API it affects, so searching this page for | use roots, sampling, logging, or client-to-server progress | [Deprecations](#deprecations) | | operate servers that 2026-era clients will also connect to | [Notes for 2026-era connections](#notes-for-2026-era-connections) | -## Suggested migration order +## Suggested migration order {#suggested-migration-order} 1. Update your dependency pins and CLI usage: [Packaging, dependencies, and CLI](#packaging-dependencies-and-cli). 2. Apply the mechanical renames and import moves: [Types and wire format](#types-and-wire-format). @@ -59,9 +59,9 @@ Every section heading below names the API it affects, so searching this page for 6. Run your tests and check anything that now errors against [Stricter protocol validation and wire behavior](#stricter-protocol-validation-and-wire-behavior) and [Testing utilities](#testing-utilities). 7. Address deprecation warnings: [Deprecations](#deprecations). -## Packaging, dependencies, and CLI +## Packaging, dependencies, and CLI {#packaging-dependencies-and-cli} -### Dependency floors raised and new required dependencies +### Dependency floors raised and new required dependencies {#dependency-floors-raised-and-new-required-dependencies} v2 raises the minimum versions of several shared dependencies and adds new required ones. A project that pins any of these below the new floor fails dependency resolution before anything installs (uv reports "No solution found when resolving dependencies"; pip fails similarly). @@ -99,7 +99,7 @@ dependencies = [ Relax or bump any conflicting pins when upgrading. sse-starlette jumps two majors, so a project that imports `sse_starlette` itself must also work through that library's own breaking changes to co-install with mcp v2. `opentelemetry-api` is a new hard dependency because every outbound request now carries a `_meta` envelope used for OpenTelemetry trace propagation; see [Every outbound request now carries a `_meta` envelope](#every-outbound-request-now-carries-a-_meta-envelope-opentelemetry-is-on-by-default). `mcp-types` is exact-pinned to the SDK version; nothing in a v1 tree can conflict with it, but do not pin `mcp-types` independently of `mcp`. -### `httpx` and `httpx-sse` replaced by `httpx2` +### `httpx` and `httpx-sse` replaced by `httpx2` {#httpx-and-httpx-sse-replaced-by-httpx2} The SDK now depends on [`httpx2`](https://pypi.org/project/httpx2/) instead of `httpx` and `httpx-sse`. `httpx2` is the next-generation HTTP client (a fork of @@ -187,7 +187,7 @@ explicit `verify=ssl_context` to your `httpx2.AsyncClient`. Passing a CA bundle path as `verify="ca.pem"` or using the `cert=` parameter is deprecated in `httpx2`; build an `ssl.SSLContext` and configure it instead. -### `mcp dev` and `mcp install` pin the spawned environment to your SDK version +### `mcp dev` and `mcp install` pin the spawned environment to your SDK version {#mcp-dev-and-mcp-install-pin-the-spawned-environment-to-your-sdk-version} Both commands run your server through a fresh `uv run --with ...` environment. In v1 the `mcp` requirement in that command was unpinned, so the spawned environment resolved to the @@ -197,9 +197,9 @@ Both commands now pin the requirement to the version you are running (`mcp==`). Source builds and other unpublished versions, which have nothing on PyPI to pin to, keep the unpinned form. -## Types and wire format +## Types and wire format {#types-and-wire-format} -### `mcp.types` moved to the `mcp-types` package +### `mcp.types` moved to the `mcp-types` package {#mcptypes-moved-to-the-mcp-types-package} The protocol wire types now live in a standalone distribution, `mcp-types` (import package `mcp_types`). Its only runtime dependencies are `pydantic` and `typing-extensions`, so code @@ -255,7 +255,7 @@ from mcp_types import Tool, Resource from mcp_types.version import LATEST_PROTOCOL_VERSION ``` -### Removed type aliases and classes +### Removed type aliases and classes {#removed-type-aliases-and-classes} The following type aliases and classes have been removed from the protocol types (`mcp.types` / `mcp_types`): @@ -284,7 +284,7 @@ from mcp.types import ContentBlock, ResourceTemplateReference # Use `str` instead of `Cursor` for pagination cursors ``` -### Field names changed from camelCase to snake_case +### Field names changed from camelCase to snake_case {#field-names-changed-from-camelcase-to-snake_case} All Pydantic model fields in the protocol types now use snake_case names for Python attribute access. The JSON wire format is unchanged — traffic the SDK sends still uses camelCase via Pydantic aliases, but your own `model_dump()` calls now need `by_alias=True` to produce it. @@ -339,7 +339,7 @@ tool.model_dump(by_alias=True, mode="json") # {"name": ..., "inputSchema": Parsing is unaffected: `model_validate()` accepts both camelCase wire JSON and snake_case dumps. -### Extra fields on MCP types are no longer preserved +### Extra fields on MCP types are no longer preserved {#extra-fields-on-mcp-types-are-no-longer-preserved} In v1, MCP protocol types were configured with `extra="allow"`: unknown fields passed to a constructor or received from a peer were kept on the model and re-serialized on output. @@ -365,7 +365,7 @@ params = CallToolRequestParams( If you relied on extra fields round-tripping through MCP types, move that data into `_meta`. -### Resource URI type changed from `AnyUrl` to `str` +### Resource URI type changed from `AnyUrl` to `str` {#resource-uri-type-changed-from-anyurl-to-str} The `uri` field on resource-related types now uses `str` instead of Pydantic's `AnyUrl`. This aligns with the [MCP specification schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2025-11-25/schema.ts) which defines URIs as plain strings (`uri: string`) without strict URL validation. This change allows relative paths like `users/me` that were previously rejected. @@ -422,7 +422,7 @@ await client.read_resource(str(my_any_url)) URI values you read back are also plain strings now. In v1, fields like `Resource.uri` and `ResourceContents.uri` were `AnyUrl` objects, so attribute access such as `uri.scheme` or `uri.host` worked; in v2 that code raises `AttributeError`. Use `urllib.parse` if you need to parse them. Note that v1 also normalized URIs during validation (for example `https://example.com` became `https://example.com/`), while v2 preserves the string exactly as given, so URIs sent on the wire may differ byte-for-byte from what v1 sent. -### Replace `RootModel` by union types with `TypeAdapter` validation +### Replace `RootModel` by union types with `TypeAdapter` validation {#replace-rootmodel-by-union-types-with-typeadapter-validation} The following union types are no longer `RootModel` subclasses: @@ -514,7 +514,7 @@ if isinstance(message, LoggingMessageNotification): Custom transports and `EventStore` implementations follow the same rule: `mcp.shared.message.SessionMessage` takes the member directly (`SessionMessage(JSONRPCNotification(...))`, not `SessionMessage(JSONRPCMessage(JSONRPCNotification(...)))`), and raw JSON parses with `jsonrpc_message_adapter.validate_json(raw)` instead of `JSONRPCMessage.model_validate_json(raw)`. -### `RequestParams.Meta` replaced with `RequestParamsMeta` TypedDict +### `RequestParams.Meta` replaced with `RequestParamsMeta` TypedDict {#requestparamsmeta-replaced-with-requestparamsmeta-typeddict} The nested `RequestParams.Meta` Pydantic model class has been replaced with a top-level `RequestParamsMeta` TypedDict. This affects the `ctx.meta` field in request handlers and any code that imports or references this type. @@ -549,13 +549,13 @@ now a plain `dict[str, Any]`: pass a dict when constructing params and read extras with dictionary access (`params.meta["traceparent"]`) instead of attribute access. The JSON wire format is unchanged. -### `SUPPORTED_PROTOCOL_VERSIONS` deprecated; `LATEST_PROTOCOL_VERSION` changed meaning +### `SUPPORTED_PROTOCOL_VERSIONS` deprecated; `LATEST_PROTOCOL_VERSION` changed meaning {#supported_protocol_versions-deprecated-latest_protocol_version-changed-meaning} `SUPPORTED_PROTOCOL_VERSIONS` is deprecated — it's now the union of `HANDSHAKE_PROTOCOL_VERSIONS` (initialize-handshake versions) and `MODERN_PROTOCOL_VERSIONS` (per-request-envelope versions). If you were using it to mean "versions the initialize handshake accepts", switch to `HANDSHAKE_PROTOCOL_VERSIONS`. Named scalars derived from these tuples are now exported alongside them — `LATEST_HANDSHAKE_VERSION`, `LATEST_MODERN_VERSION`, `OLDEST_SUPPORTED_VERSION` — so prefer those over indexing the tuples directly. All of these live in `mcp.types.version` (an alias of `mcp_types.version`; previously `mcp.shared.version`): `from mcp.types.version import HANDSHAKE_PROTOCOL_VERSIONS`. `LATEST_PROTOCOL_VERSION` also changed value and meaning. In v1 it was `"2025-11-25"`, the version the client offered during initialization. In v2 it is the newest revision the SDK speaks in any era, currently `"2026-07-28"`, which the initialize handshake cannot negotiate. If you offered it in a hand-built `initialize` request or compared the negotiated version against it, use `LATEST_HANDSHAKE_VERSION` instead. These tuples really are tuples now (`SUPPORTED_PROTOCOL_VERSIONS` was a `list` in v1), so list-only operations such as concatenating with a list raise `TypeError`. -### `McpError` renamed to `MCPError` +### `McpError` renamed to `MCPError` {#mcperror-renamed-to-mcperror} The `McpError` exception class has been renamed to `MCPError` for consistent naming with the MCP acronym style used throughout the SDK. @@ -609,7 +609,7 @@ raise MCPError(INVALID_REQUEST, "bad input") raise MCPError.from_error_data(error_data) ``` -### `JSONRPCError.id` is now `RequestId | None` +### `JSONRPCError.id` is now `RequestId | None` {#jsonrpcerrorid-is-now-requestid-none} In v1 `JSONRPCError.id` was typed `str | int`, so an error response with `"id": null` failed validation even though JSON-RPC 2.0 allows it (the id is null when the receiver could not determine the request id, e.g. a parse error). In v2 the field is `RequestId | None`: still required, but `None` is accepted, and `jsonrpc_message_adapter` parses a null-id error into a `JSONRPCError`. @@ -640,9 +640,9 @@ JSONRPCError(jsonrpc="2.0", id=None, error=ErrorData(code=PARSE_ERROR, message=" Delete any shim that accepted or synthesized null-id error responses. Code that assumed `error.id` was always a `str | int` must now handle `None`, and tests that pinned v1's rejection of `"id": null` now fail because validation succeeds. -## MCPServer (formerly FastMCP) +## MCPServer (formerly FastMCP) {#mcpserver-formerly-fastmcp} -### `FastMCP` renamed to `MCPServer` +### `FastMCP` renamed to `MCPServer` {#fastmcp-renamed-to-mcpserver} The `FastMCP` class has been renamed to `MCPServer` to better reflect its role as the main server class in the SDK. Beyond the name and import path, the changes to the class are covered in the sections that follow, and [What is unchanged on `MCPServer`](#what-is-unchanged-on-mcpserver) lists the everyday surface that carries over as-is. @@ -672,7 +672,7 @@ All submodules under `mcp.server.fastmcp.*` are now under `mcp.server.mcpserver. - `ToolError`, `ResourceError` — from `mcp.server.mcpserver.exceptions` - `MCPServerError` (renamed from `FastMCPError`) — from `mcp.server.mcpserver.exceptions` -### What is unchanged on `MCPServer` +### What is unchanged on `MCPServer` {#what-is-unchanged-on-mcpserver} Beyond the changes covered in this section, the everyday `FastMCP` surface carries over to `MCPServer` as-is: @@ -684,7 +684,7 @@ Beyond the changes covered in this section, the everyday `FastMCP` surface carri - **Tool internals.** `Tool`, `Tool.from_function()`, `FuncMetadata`, `ArgModelBase`, and `func_metadata()` keep their v1 shapes; the one change is the now-required `context` argument to `Tool.run()`, described [below](#mcpservercall_tool-read_resource-get_prompt-now-accept-a-context-parameter). - **Auxiliary import paths.** `TransportSecuritySettings` (`mcp.server.transport_security`) and `AcceptedElicitation`/`DeclinedElicitation`/`CancelledElicitation` (`mcp.server.elicitation`) have not moved; the server auth surface is inventoried under [Unchanged auth surfaces](#unchanged-auth-surfaces). -### Default server name changed from `FastMCP` to `mcp-server` +### Default server name changed from `FastMCP` to `mcp-server` {#default-server-name-changed-from-fastmcp-to-mcp-server} A server constructed without a name now defaults to `mcp-server` instead of `FastMCP`. This is the name reported to clients as `serverInfo.name` in the initialize result, so it is visible in client UIs, logs, and monitoring. Nothing raises when this changes; the migrated server simply reports a different identity. @@ -706,7 +706,7 @@ mcp = MCPServer() # serverInfo.name == "mcp-server" If test suites assert on the initialize result, or anything keys configuration or allow-lists off `serverInfo.name`, pass a name explicitly: `MCPServer("FastMCP")` preserves the old value, though a real name for your server is better. -### `MCPServer` constructor: `title`, `description`, and `version` added to the positional parameters +### `MCPServer` constructor: `title`, `description`, and `version` added to the positional parameters {#mcpserver-constructor-title-description-and-version-added-to-the-positional-parameters} The constructor's positional parameter order changed. v2 inserts `title` and `description` before `instructions`, and `version` after `icons`, so the order is now `name`, `title`, `description`, `instructions`, `website_url`, `icons`, `version`. In v1 the order was `name`, `instructions`, `website_url`, `icons`. @@ -731,7 +731,7 @@ mcp = MCPServer("Demo", instructions="You answer questions about the weather.") Keep `name` positional and pass everything else by keyword. -### Unversioned servers report an empty version +### Unversioned servers report an empty version {#unversioned-servers-report-an-empty-version} In v1, a server constructed without a `version` reported the installed `mcp` package's version as its own in the `initialize` result's `serverInfo`. In v2 @@ -739,13 +739,13 @@ it reports an empty string instead: the SDK's version is not your server's version. Pass `version="..."` to `Server(...)` or `MCPServer(...)` to identify your server properly. The field is display-only; nothing breaks either way. -### `mount_path` parameter removed from MCPServer +### `mount_path` parameter removed from MCPServer {#mount_path-parameter-removed-from-mcpserver} The `mount_path` parameter has been removed from `MCPServer.__init__()`, `MCPServer.run()`, `MCPServer.run_sse_async()`, and `MCPServer.sse_app()`. It was also removed from the `Settings` class. This parameter was redundant because the SSE transport already handles sub-path mounting via ASGI's standard `root_path` mechanism. When using Starlette's `Mount("/path", app=mcp.sse_app())`, Starlette automatically sets `root_path` in the ASGI scope, and the `SseServerTransport` uses this to construct the correct message endpoint path. -### Transport-specific parameters moved from MCPServer constructor to run()/app methods +### Transport-specific parameters moved from MCPServer constructor to run()/app methods {#transport-specific-parameters-moved-from-mcpserver-constructor-to-runapp-methods} Transport-specific parameters have been moved off the `MCPServer` constructor and onto `run()`, `sse_app()`, and `streamable_http_app()`, so transport configuration is passed when starting or building the server. The rest of the constructor is unchanged: identity (`name`, `instructions`, `website_url`, `icons`, plus the newly added positional `title`, `description`, and `version` covered [above](#mcpserver-constructor-title-description-and-version-added-to-the-positional-parameters)), authentication (`auth`, `token_verifier`, `auth_server_provider`), `lifespan`, `dependencies`, `tools`, `debug`, `log_level`, and the `warn_on_duplicate_*` flags; the new keyword-only parameters (`resources`, `extensions`, `resource_security`, `request_state_security`, `cache_hints`, `subscriptions`, `middleware`) are additive. @@ -829,7 +829,7 @@ v2 has no settings object that carries transport configuration, so if the module `Settings` (what `mcp.settings` holds) now has only the constructor-owned fields: `debug`, `log_level`, the `warn_on_duplicate_*` flags, `dependencies`, `lifespan`, and `auth`. If you were mutating transport values via `mcp.settings` after construction (e.g. `mcp.settings.port = 9000`), pass them to `run()` / `sse_app()` / `streamable_http_app()` instead: assigning a removed field now raises `ValueError: "Settings" object has no field "port"`. `settings.lifespan` is read once, at construction, so reassigning it afterwards has no effect. Once `streamable_http_app()` has been called, the values it was built with live on the runtime objects (e.g. `mcp.session_manager.stateless`, `mcp.session_manager.json_response`). -### `MCP_*` environment variables and `.env` files are no longer read +### `MCP_*` environment variables and `.env` files are no longer read {#mcp\_-environment-variables-and-env-files-are-no-longer-read} The `Settings` docstring advertised configuration via `MCP_*` environment variables and a `.env` file (e.g. `MCP_DEBUG=true`), but constructor arguments have always taken precedence, so those environment variables never took effect. `Settings` is now a plain Pydantic model rather than a `pydantic-settings` `BaseSettings`, and `pydantic-settings` is no longer a dependency of the SDK. @@ -845,7 +845,7 @@ mcp = MCPServer("Demo", debug=os.environ.get("MCP_DEBUG") == "true") If your own code uses `pydantic-settings`, add it to your project's dependencies directly. -### Streamable HTTP request bodies are limited to 4 MiB +### Streamable HTTP request bodies are limited to 4 MiB {#streamable-http-request-bodies-are-limited-to-4-mib} V2 applies a 4 MiB default limit to Streamable HTTP POST bodies and returns HTTP 413 before parsing the JSON or creating a session when that limit is exceeded. @@ -860,13 +860,13 @@ mcp.run(transport="streamable-http", max_request_body_size=8 * 1024 * 1024) The limit must be positive and applies to both legacy session-based requests and V2's modern single-exchange requests. Keep the smallest value your application actually needs. -### Streamable HTTP: lifespan now entered once at manager startup +### Streamable HTTP: lifespan now entered once at manager startup {#streamable-http-lifespan-now-entered-once-at-manager-startup} When serving streamable HTTP (stateful or `stateless_http=True`), the server's `lifespan` context manager is now entered once when `StreamableHTTPSessionManager.run()` starts, and the resulting state is shared across all sessions and requests. Previously each session (stateful) or each request (stateless) entered and exited `lifespan` independently. Lifespans that set up process-wide state (connection pools, caches, background tasks) are unaffected — they now run once instead of per session/request. If your lifespan was acquiring per-connection resources, move that acquisition into the handler body; per-connection cleanup belongs on the connection's `exit_stack` (a public way to reach it from high-level `@mcp.tool()` handlers is planned). -### Streamable HTTP: session manager, `EventStore`, and stateless mode unchanged +### Streamable HTTP: session manager, `EventStore`, and stateless mode unchanged {#streamable-http-session-manager-eventstore-and-stateless-mode-unchanged} Beyond the constructor parameters that moved to `run()`/`streamable_http_app()` and the lifespan change above, the server-side Streamable HTTP machinery is as in v1: @@ -877,7 +877,7 @@ Beyond the constructor parameters that moved to `run()`/`streamable_http_app()` Only private attributes moved: `mcp._mcp_server` is now `mcp._lowlevel_server` (see [Registering lowlevel handlers from `MCPServer`](#registering-lowlevel-handlers-from-mcpserver)), and `_session_manager` now lives on that lowlevel `Server`. Prefer the public `mcp.session_manager` property to either. -### `MCPServer.get_context()` removed +### `MCPServer.get_context()` removed {#mcpserverget_context-removed} `MCPServer.get_context()` has been removed. Context is now injected by the framework and passed explicitly — there is no ambient ContextVar to read from. @@ -904,7 +904,7 @@ async def my_tool(x: int, ctx: Context) -> str: return str(x) ``` -### Sync handler functions now run on a worker thread +### Sync handler functions now run on a worker thread {#sync-handler-functions-now-run-on-a-worker-thread} In v1, a synchronous (`def`) tool, resource, or prompt function was called inline on the event loop, so a body that blocked (an HTTP call with a sync client, `time.sleep()`, heavy @@ -924,7 +924,7 @@ running on the event-loop thread: Declare the handler `async def` to keep it on the event loop. -### `MCPServer.call_tool()` returns `CallToolResult` +### `MCPServer.call_tool()` returns `CallToolResult` {#mcpservercall_tool-returns-calltoolresult} `MCPServer.call_tool()` now returns a `CallToolResult` (or an `InputRequiredResult` when a multi-round tool requests further input). It previously @@ -936,7 +936,7 @@ If you call `MCPServer.call_tool()` directly, read `.content` and `.structured_content` off the returned `CallToolResult` instead of branching on the result type. -### `MCPServer.get_prompt()` and `read_resource()` may return `InputRequiredResult` +### `MCPServer.get_prompt()` and `read_resource()` may return `InputRequiredResult` {#mcpserverget_prompt-and-read_resource-may-return-inputrequiredresult} Like `call_tool()` above, `MCPServer.get_prompt()` now returns `GetPromptResult | InputRequiredResult` and `MCPServer.read_resource()` returns @@ -952,13 +952,13 @@ resource functions never return one). `Prompt.render()` and `ctx.read_resource()` inside a handler is unchanged: it still returns content, and raises `RuntimeError` if the resource requests input. -### `MCPServer.call_tool()`, `read_resource()`, `get_prompt()` now accept a `context` parameter +### `MCPServer.call_tool()`, `read_resource()`, `get_prompt()` now accept a `context` parameter {#mcpservercall_tool-read_resource-get_prompt-now-accept-a-context-parameter} `MCPServer.call_tool()`, `MCPServer.read_resource()`, and `MCPServer.get_prompt()` now accept an optional `context: Context | None = None` parameter. The framework passes this automatically during normal request handling. If you call these methods directly and omit `context`, a Context with no active request is constructed for you — tools that don't use `ctx` work normally, but any attempt to use `ctx.session`, `ctx.request_id`, etc. will raise. The internal layers (`ToolManager.call_tool`, `Tool.run`, `Prompt.render`, `ResourceTemplate.create_resource`, etc.) now require `context` as a positional argument. -### Resolver-routed requests require the client capability on every protocol version +### Resolver-routed requests require the client capability on every protocol version {#resolver-routed-requests-require-the-client-capability-on-every-protocol-version} A v1 server could send elicitation, sampling, and roots requests to clients that never declared the matching capability; only tools-bearing sampling was @@ -979,7 +979,7 @@ set, and `sampling.tools` needs an explicit `ctx.elicit()` and `ctx.session.*` calls outside resolvers keep their previous behavior, including the pre-existing tools check on `create_message`. -### `MCPError` raised from an `@mcp.tool()` handler now surfaces as a JSON-RPC error +### `MCPError` raised from an `@mcp.tool()` handler now surfaces as a JSON-RPC error {#mcperror-raised-from-an-mcptool-handler-now-surfaces-as-a-json-rpc-error} Raising `MCPError` (or any subclass) inside an `@mcp.tool()` handler now produces a top-level JSON-RPC error response with the raised `code`, `message`, @@ -1014,17 +1014,17 @@ except MCPError as e: ... # e.code, e.message, e.data ``` -### Resource not found returns `-32602` and resource lookups raise typed exceptions (SEP-2164) +### Resource not found returns `-32602` and resource lookups raise typed exceptions (SEP-2164) {#resource-not-found-returns-32602-and-resource-lookups-raise-typed-exceptions-sep-2164} Reading a missing resource now returns JSON-RPC error code `-32602` (invalid params) with the requested URI in `error.data` (`{"uri": ...}`), per [SEP-2164](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2164). Previously the server returned code `0` with no `data`. Clients can now reliably distinguish not-found from other errors; a template handler that raises `ResourceNotFoundError` (from `mcp.server.mcpserver.exceptions`) produces this same response. The underlying lookups now raise typed exceptions instead of `ValueError`. `ResourceManager.get_resource()` raises `ResourceNotFoundError` when no resource or template matches the URI, and `ResourceTemplate.create_resource()` raises `ResourceError` when the template function fails. Neither subclasses `ValueError`, so callers catching `ValueError` should switch to `ResourceNotFoundError` / `ResourceError` (both importable from `mcp.server.mcpserver.exceptions`; `ResourceNotFoundError` subclasses `ResourceError`). -### `Resource` classes reject unknown keyword arguments +### `Resource` classes reject unknown keyword arguments {#resource-classes-reject-unknown-keyword-arguments} The `Resource` base class now sets `extra="forbid"`, so every resource class — `TextResource`, `BinaryResource`, `FunctionResource`, `FileResource`, `HttpResource`, `DirectoryResource`, and your own subclasses — raises `ValidationError` on an unrecognised keyword argument instead of silently dropping it. Previously a typo'd or since-removed parameter (such as `FileResource(is_binary=...)`, below) was accepted and ignored. Remove any stray keyword arguments; if a subclass needs to accept arbitrary extras, set its own `model_config = ConfigDict(extra="allow")`. -### `FileResource.is_binary` replaced by `encoding` +### `FileResource.is_binary` replaced by `encoding` {#fileresourceis_binary-replaced-by-encoding} `FileResource` used to take `is_binary: bool` and guess its default from `mime_type` (`text/*` → text, anything else → bytes). Two problems fell out of that: `is_binary=False` could not actually be set — `False` doubled as the "not given" sentinel, so `mime_type="application/json"` always came back as a base64 blob — and text reads used `Path.read_text()` with no encoding, i.e. the platform locale (cp1252 on Windows). @@ -1051,7 +1051,7 @@ FileResource(uri="file:///data.json", path=data, mime_type="application/json") Pass `encoding=None` to force a blob, or `encoding="latin-1"` (etc.) to decode a text file that isn't UTF-8. -### Resource templates: matching behavior changes +### Resource templates: matching behavior changes {#resource-templates-matching-behavior-changes} Resource template matching has been rewritten with [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570) support. Several behaviors have changed: @@ -1136,7 +1136,7 @@ Such a resource is advertised by `resources/templates/list` rather than See [URI templates](servers/uri-templates.md) for the full template syntax, security configuration, and filesystem safety utilities. -### `MCPServer`'s `Context` logging: `message` renamed to `data`, `extra` removed +### `MCPServer`'s `Context` logging: `message` renamed to `data`, `extra` removed {#mcpservers-context-logging-message-renamed-to-data-extra-removed} On the high-level `Context` object (`mcp.server.mcpserver.Context`), `log()`, `.debug()`, `.info()`, `.warning()`, and `.error()` now take `data: Any` instead of `message: str`, matching the MCP spec's `LoggingMessageNotificationParams.data` field which allows any JSON-serializable value. The `extra` parameter has been removed from the convenience-method signatures. Note that `extra` never worked at runtime in v1 (the kwargs were forwarded to `log()`, which did not accept them, raising `TypeError`), so this only affects code that type-checked but never exercised that path. Pass structured data directly as `data`. @@ -1158,7 +1158,7 @@ Positional calls (`await ctx.info("hello")`) are unaffected. These helpers are themselves deprecated by [SEP-2577](#roots-sampling-and-logging-methods-deprecated-sep-2577) and emit `mcp.MCPDeprecationWarning` on every call, so treat the rename as a keep-it-working fix rather than a migration target: nothing in-protocol replaces pushing log messages to the client, so log with the standard `logging` module instead (see [Logging](handlers/logging.md)) and use `ctx.report_progress()` for progress the client should see. -### `Context.client_id` removed +### `Context.client_id` removed {#contextclient_id-removed} `Context.client_id` has been removed. It never returned an authenticated client identity: it echoed a non-standard `client_id` key from the request's `_meta`, which nothing in the SDK or the MCP spec populates, so it was `None` unless a caller injected `meta={"client_id": ...}` by hand. The name also collided with the OAuth `client_id`, which is what callers usually mean by "the client". @@ -1179,7 +1179,7 @@ token = get_access_token() client_id = token.client_id if token else None ``` -### `ProgressContext` and `progress()` context manager removed +### `ProgressContext` and `progress()` context manager removed {#progresscontext-and-progress-context-manager-removed} The `mcp.shared.progress` module (`ProgressContext`, `Progress`, and the `progress()` context manager) has been removed. This module had no real-world adoption — all users send progress notifications via `Context.report_progress()` or `session.send_progress_notification()` directly. @@ -1216,13 +1216,13 @@ await ctx.session.report_progress(50, 100, message="halfway") `ctx.session.report_progress()` also works on the in-process `Client(server)` path (see [Testing utilities](#testing-utilities)); `ctx.session.send_progress_notification(progress_token, progress, total, message)` remains for code that reads `ctx.meta["progress_token"]` itself, and takes the same absolute-value `progress`. -### `Context.elicit()` schema gate validates the rendered schema +### `Context.elicit()` schema gate validates the rendered schema {#contextelicit-schema-gate-validates-the-rendered-schema} `Context.elicit()` (and `elicit_with_validation()`) now render the schema first and validate each property against the spec's `PrimitiveSchemaDefinition`, raising `TypeError` at the call site for anything outside it. `Optional[T]` fields render as `{"type": ...}` with the field omitted from `required` (previously the non-spec `anyOf` shape). A bare `list[str]` field is rejected because it renders without the required enum items; use `list[Literal[...]]` or `list[str]` with `json_schema_extra` supplying the items. Unions of multiple primitives (e.g. `int | str`) and nested models are rejected. A schema-mismatched *accepted* answer also fails differently: the call now raises `ValueError` with a stable message ("Received an accepted elicitation whose content does not match the requested schema") instead of letting pydantic's `ValidationError` escape with its internals. Code that caught `ValidationError` around `ctx.elicit()` should catch `ValueError` (or rely on the tool's error result). -### `isinstance()` checks against `ElicitationResult` raise `TypeError` +### `isinstance()` checks against `ElicitationResult` raise `TypeError` {#isinstance-checks-against-elicitationresult-raise-typeerror} `ElicitationResult` is now a `TypeAliasType` instead of a plain union, so `ElicitationResult[Confirm]` works as an annotation (resolver dependency injection consumes it that way - see [Dependencies](handlers/dependencies.md)). The members are unchanged: `AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation`. @@ -1236,7 +1236,7 @@ if isinstance(result, AcceptedElicitation): Narrowing on `result.action` (`"accept"` / `"decline"` / `"cancel"`) is unaffected. The `TypeError` is specific to `TypeAliasType` aliases like `ElicitationResult`; the `mcp.types` message unions (`ClientRequest`, `ServerNotification`, `JSONRPCMessage`, ...) are ordinary unions and stay `isinstance`-compatible (see [Replace `RootModel` by union types with `TypeAdapter` validation](#replace-rootmodel-by-union-types-with-typeadapter-validation)). -### Registering lowlevel handlers from `MCPServer` +### Registering lowlevel handlers from `MCPServer` {#registering-lowlevel-handlers-from-mcpserver} `MCPServer` does not expose public APIs for `subscribe_resource`, `unsubscribe_resource`, or `set_logging_level` handlers. In v1, the workaround was to reach into the private lowlevel server and use its decorator methods: @@ -1275,9 +1275,9 @@ mcp._lowlevel_server.add_request_handler("resources/subscribe", SubscribeRequest `_lowlevel_server` is private and may change. A public way to register these handlers on `MCPServer` is planned; until then, use this workaround or use the lowlevel `Server` directly. -## Lowlevel Server +## Lowlevel Server {#lowlevel-server} -### Lowlevel `Server`: what did not change +### Lowlevel `Server`: what did not change {#lowlevel-server-what-did-not-change} Handler registration, signatures, and return values changed (the sections below); the serving scaffolding around them keeps its v1 import paths and call shapes: @@ -1300,7 +1300,7 @@ async def main() -> None: ) ``` -### Lowlevel `Server`: decorator-based handlers replaced with constructor `on_*` params +### Lowlevel `Server`: decorator-based handlers replaced with constructor `on_*` params {#lowlevel-server-decorator-based-handlers-replaced-with-constructor-on\_-params} The lowlevel `Server` class no longer uses decorator methods for handler registration. Instead, handlers are passed as `on_*` keyword arguments to the constructor. @@ -1391,7 +1391,7 @@ server = Server("my-server", on_progress=handle_progress) Registering `on_progress` emits a deprecation warning because the 2026-07-28 spec deprecates client-to-server progress; see [Client-to-server progress deprecated (2026-07-28)](#client-to-server-progress-deprecated-2026-07-28). -### Lowlevel `Server`: automatic return value wrapping removed +### Lowlevel `Server`: automatic return value wrapping removed {#lowlevel-server-automatic-return-value-wrapping-removed} The old decorator-based handlers performed significant automatic wrapping of return values. This magic has been removed — handlers now return fully constructed result types. If you want these conveniences, use `MCPServer` (previously `FastMCP`) instead of the lowlevel `Server`. @@ -1487,7 +1487,7 @@ async def handle(ctx: ServerRequestContext, params: PaginatedRequestParams | Non If you prefer the convenience of automatic wrapping, use `MCPServer` which still provides these features through its `@mcp.tool()`, `@mcp.resource()`, and `@mcp.prompt()` decorators. The lowlevel `Server` is intentionally minimal — it provides no magic and gives you full control over the MCP protocol types. -### Lowlevel `Server`: tool handler exceptions no longer become `CallToolResult(is_error=True)` +### Lowlevel `Server`: tool handler exceptions no longer become `CallToolResult(is_error=True)` {#lowlevel-server-tool-handler-exceptions-no-longer-become-calltoolresultis_errortrue} The v1 `@server.call_tool()` decorator caught any exception raised by the handler and returned it to the client as an error-flagged tool result (`isError: true`), so the calling LLM saw the error text as a tool result and could self-correct. In v2, `on_call_tool` is registered with no exception wrapping: a non-`MCPError` exception propagates to the dispatcher and is answered as a top-level JSON-RPC **error response** with `code=0` and `message=str(exc)`. Typical clients (including the SDK's own) raise on a protocol error instead of returning a result, so the error text is no longer LLM-visible. The server also logs a `handler for 'tools/call' raised` traceback that v1 never emitted. @@ -1521,7 +1521,7 @@ server = Server("my-server", on_call_tool=handle_call_tool) Raise `MCPError` only when the request itself should be rejected as a protocol error; that path is deliberate in v2. Alternatively, use `MCPServer`, whose `@mcp.tool()` wrapper still converts generic exceptions into `is_error=True` results (see [`MCPError` raised from an `@mcp.tool()` handler now surfaces as a JSON-RPC error](#mcperror-raised-from-an-mcptool-handler-now-surfaces-as-a-json-rpc-error)). -### Lowlevel `Server`: constructor parameters are now keyword-only +### Lowlevel `Server`: constructor parameters are now keyword-only {#lowlevel-server-constructor-parameters-are-now-keyword-only} All parameters after `name` are now keyword-only. If you were passing `version` or other parameters positionally, use keyword arguments instead: @@ -1533,7 +1533,7 @@ server = Server("my-server", "1.0") server = Server("my-server", version="1.0") ``` -### Lowlevel `Server`: type parameter reduced from 2 to 1 +### Lowlevel `Server`: type parameter reduced from 2 to 1 {#lowlevel-server-type-parameter-reduced-from-2-to-1} The `Server` class previously had two type parameters: `Server[LifespanResultT, RequestT]`. The `RequestT` parameter has been removed. In v1 it typed the transport-level request object exposed as `server.request_context.request`, not anything handlers received directly. @@ -1553,7 +1553,7 @@ from mcp.server import Server server: Server[dict[str, Any]] = Server(...) ``` -### Lowlevel `Server`: `request_handlers` and `notification_handlers` attributes removed +### Lowlevel `Server`: `request_handlers` and `notification_handlers` attributes removed {#lowlevel-server-request_handlers-and-notification_handlers-attributes-removed} The public `server.request_handlers` and `server.notification_handlers` dictionaries have been removed. Handler registration is now done through constructor `on_*` keyword arguments, or through the public `add_request_handler` / `add_notification_handler` methods. @@ -1575,11 +1575,11 @@ if server.get_request_handler("tools/list") is not None: If you need to check whether a handler is registered, use `server.get_request_handler(method)` or `server.get_notification_handler(method)`, which return the registered entry or `None`. Note the lookup key is now the method string (for example `"tools/list"`), not the request type. -### Lowlevel `Server`: `subscribe` capability now correctly reported +### Lowlevel `Server`: `subscribe` capability now correctly reported {#lowlevel-server-subscribe-capability-now-correctly-reported} Previously, the lowlevel `Server` hardcoded `subscribe=False` in resource capabilities even when a `subscribe_resource()` handler was registered. The `subscribe` capability is now dynamically set to `True` when an `on_subscribe_resource` handler is provided. Clients that previously didn't see `subscribe: true` in capabilities will now see it when a handler is registered, which may change client behavior. -### Lowlevel `Server`: private `_handle_*` dispatch methods removed +### Lowlevel `Server`: private `_handle_*` dispatch methods removed {#lowlevel-server-private-\_handle\_-dispatch-methods-removed} `Server._handle_message`, `_handle_request`, and `_handle_notification` have been removed. The receive loop and per-message dispatch now live in `JSONRPCDispatcher` and `ServerRunner`, which `Server.run()` drives internally. @@ -1607,13 +1607,13 @@ The method and the raw inbound params are `ctx.method` and `ctx.params` (`params **Note:** `Server.middleware` and the `ServerMiddleware` / `CallNext` / `HandlerResult` types in `mcp.server.context` are marked provisional in the source — their signature and semantics may change — so use middleware to observe (log, time, trace) rather than as a foundation. See [Middleware](advanced/middleware.md). -### Lowlevel `Server.run(raise_exceptions=True)`: transport errors no longer re-raised +### Lowlevel `Server.run(raise_exceptions=True)`: transport errors no longer re-raised {#lowlevel-serverrunraise_exceptionstrue-transport-errors-no-longer-re-raised} `raise_exceptions=True` now only governs handler exceptions: an exception raised by an `on_*` handler propagates out of `run()`. The JSON-RPC error response is still written to the client first, regardless of the flag. Previously it also re-raised exceptions yielded by the transport onto the read stream (e.g. JSON parse errors). Those are now debug-logged and dropped regardless of `raise_exceptions`. If you relied on `run()` exiting on a transport-level parse error, that no longer happens. -### Cancelled requests are no longer answered +### Cancelled requests are no longer answered {#cancelled-requests-are-no-longer-answered} In v1, when the peer sent `notifications/cancelled` for an in-flight request, the receiving side interrupted the handler and answered the request anyway with a JSON-RPC error, `{"code": 0, "message": "Request cancelled"}` - and `0` is not a defined JSON-RPC error code. The 2026-07-28 transport specifications (stdio, streamable HTTP) say a server **MUST NOT** send any further messages for a cancelled request; the older cancellation pattern already said it **SHOULD NOT**. The sender is expected to stop waiting once it cancels, so that error response has been removed: a cancelled request now produces no response at all - no result, and no error - even if the handler runs to completion or fails afterwards. This applies to both seats (the server for cancelled client requests, and the client for cancelled server-initiated requests such as sampling or elicitation). @@ -1621,13 +1621,13 @@ The one deliberate exception is the 2025-era streamable HTTP transport (`Streama Nothing changes for callers of the built-in client: abandoning a call (cancelling the awaiting task, or a per-request timeout) never waited for that response. If you send `notifications/cancelled` by hand while still awaiting the call, the call now receives nothing on most transports (over 2025-era streamable HTTP it fails with `REQUEST_CANCELLED`); stop awaiting it yourself, or use a per-request timeout. -### `Server.run()` no longer takes a `stateless` flag +### `Server.run()` no longer takes a `stateless` flag {#serverrun-no-longer-takes-a-stateless-flag} The `stateless: bool` parameter on the lowlevel `Server.run()` has been removed. Stateless serving is now a property of how the connection is constructed (the streamable-HTTP manager builds a born-ready `Connection` per request), not a flag the loop driver inspects. Server-initiated requests that have no channel to travel on — a legacy session against a `stateless_http=True` server, the request-scoped channel of a stateful legacy session against a `json_response=True` server, or any connection negotiated at 2026-07-28 — now raise `NoBackChannelError` instead of stalling as they did in v1 (the transport silently dropped the outbound message), so a stateless-HTTP or JSON-mode `ctx.elicit()` that used to hang now fails fast; see [Server-initiated sampling, elicitation, and roots raise `NoBackChannelError`](#server-initiated-sampling-elicitation-and-roots-raise-nobackchannelerror) for the exception and the migration paths. -### Lowlevel `Server`: `request_context` property removed +### Lowlevel `Server`: `request_context` property removed {#lowlevel-server-request_context-property-removed} The `server.request_context` property has been removed. Request context is now passed directly to handlers as the first argument (`ctx`). The `request_ctx` module-level contextvar has been removed entirely. @@ -1658,7 +1658,7 @@ async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestPar ) ``` -### `RequestContext` type parameters simplified +### `RequestContext` type parameters simplified {#requestcontext-type-parameters-simplified} `RequestContext` has been removed from `mcp.shared.context` (importing it now raises `ImportError`; the module now holds an unrelated internal class). It is split into `ClientRequestContext` (in `mcp.client.context`) and `ServerRequestContext` (in `mcp.server.context`). @@ -1714,7 +1714,7 @@ async def my_tool(ctx: Context[MyLifespanState]) -> str: ... The parametrized `Context[MyLifespanState]` annotation currently works only on `@mcp.tool()` handlers. On `@mcp.prompt()` and templated `@mcp.resource("scheme://{param}")` handlers, annotate the parameter as bare `Context` for now: these handlers are wrapped in `pydantic.validate_call`, which re-validates the injected `Context` into a fresh `Context[MyLifespanState]` detached from the request, so the first access to `ctx.request_id`, `ctx.session`, or `ctx.request_context` raises `ValueError: Context is not available outside of a request` (the client sees an internal server error, or `Error creating resource from template ...`). Bare `Context` still exposes `ctx.request_context.lifespan_context`; only its static type is lost. -### `ServerSession` is now a thin proxy (no longer a `BaseSession`) +### `ServerSession` is now a thin proxy (no longer a `BaseSession`) {#serversession-is-now-a-thin-proxy-no-longer-a-basesession} `ServerSession` no longer subclasses `BaseSession`. It is now a small per-request proxy that exposes `send_request`, `send_notification`, the typed convenience helpers — `create_message`, `elicit` / `elicit_form` / `elicit_url`, `send_elicit_complete`, `list_roots`, `send_log_message`, `send_resource_updated`, `send_resource_list_changed` / `send_tool_list_changed` / `send_prompt_list_changed`, `send_ping`, `send_progress_notification`, and the new `report_progress` — plus `check_client_capability` and the read-only `client_params`, `client_capabilities`, `protocol_version`, and `can_send_request` properties. The receive loop, `initialize` handling, and per-request task isolation that previously lived in `ServerSession` have moved to `JSONRPCDispatcher` and `ServerRunner`. @@ -1749,7 +1749,7 @@ In practice, replace direct `ServerSession` use with `Server.run(read_stream, wr - `ServerSession.__aenter__` / `__aexit__` — `ServerSession` is no longer an async context manager. - The private `_receive_loop`, `_received_request`, `_received_notification`, and `_handle_incoming` overrides — there is nothing to override on `ServerSession` anymore. To intercept inbound messages, use `Server.middleware` (see [Lowlevel `Server`: private `_handle_*` dispatch methods removed](#lowlevel-server-private-_handle_-dispatch-methods-removed)). -### `ServerSession.elicit()` and `elicit_form()` take `requested_schema`, not `requestedSchema` +### `ServerSession.elicit()` and `elicit_form()` take `requested_schema`, not `requestedSchema` {#serversessionelicit-and-elicit_form-take-requested_schema-not-requestedschema} The schema parameter of `ServerSession.elicit()` and `ServerSession.elicit_form()` was renamed from `requestedSchema` to `requested_schema`. This is a plain method parameter, so the `populate_by_name` alias support that keeps camelCase field names working on Pydantic models does not apply here. Keyword callers raise on every call, before any wire traffic (nothing fails at import; the client sees a tool error): @@ -1785,9 +1785,9 @@ result = await ctx.session.elicit_form( Positional callers (`session.elicit_form(message, schema)`) are unaffected, and so are the return types: `elicit()`, `elicit_form()`, and `elicit_url()` still return `ElicitResult` (`action` of `"accept"`/`"decline"`/`"cancel"` plus `content`), and `create_message()` still returns `CreateMessageResult` (or `CreateMessageResultWithTools` when `tools`/`tool_choice` are passed). `elicit_url()` already used snake_case parameters in v1; only `elicit()` and `elicit_form()` changed. -## Clients +## Clients {#clients} -### `Client` defaults to `mode='auto'` +### `Client` defaults to `mode='auto'` {#client-defaults-to-modeauto} In v1, connecting to a server always performed the `initialize` handshake. In v2, `Client` defaults to `mode='auto'`: on enter it probes `server/discover` and, if the server doesn't support it, falls back to the `initialize` handshake. Pass `mode='legacy'` to force the initialize handshake and reproduce v1's pre-2026 connection sequence (the per-request wire shape still differs from v1; see [Every outbound request now carries a `_meta` envelope](#every-outbound-request-now-carries-a-_meta-envelope-opentelemetry-is-on-by-default)), or pass a modern protocol-version string (e.g. `mode='2026-07-28'`) to pin a version without probing. @@ -1797,7 +1797,7 @@ For an in-process `Client(server)` (where `server` is a `Server` or `MCPServer` `Client.send_ping()` is deprecated (ping is removed in 2026-07-28) and emits `mcp.MCPDeprecationWarning` when called; pin `mode='legacy'` if you need it. The lowlevel `ClientSession.send_ping()` carries no deprecation marker. -### `ClientSession.get_server_capabilities()` replaced by era-neutral accessors +### `ClientSession.get_server_capabilities()` replaced by era-neutral accessors {#clientsessionget_server_capabilities-replaced-by-era-neutral-accessors} `ClientSession` now exposes the negotiated server metadata as properties: `server_capabilities`, `server_info`, `instructions`, and `protocol_version`. These are populated by whichever connection step ran (`initialize()` for ≤2025-11-25 servers, `discover()` for 2026-07-28+), and are `None` if none has — matching v1's `get_server_capabilities()`. The `get_server_capabilities()` method has been removed. @@ -1821,7 +1821,7 @@ The raw handshake result is also retained: `session.initialize_result` is set af On the high-level `Client`, `client.server_capabilities` and `client.protocol_version` are non-nullable inside the context manager. `client.instructions` remains `str | None` since the server may omit it, and `client.server_info` is `Implementation | None`: on 2026-era connections identity is optional wire metadata, so a server that does not report it reads as `None`. (The lowlevel `ClientSession` still lets you call methods before any handshake, as in v1; `Client` always connects on enter — by default it probes `server/discover` and falls back to the initialize handshake.) -### `cursor` parameter removed from `ClientSession` list methods +### `cursor` parameter removed from `ClientSession` list methods {#cursor-parameter-removed-from-clientsession-list-methods} The deprecated `cursor` parameter has been removed from the following `ClientSession` methods: @@ -1862,7 +1862,7 @@ while True: The high-level `Client` (including the `Client(server)` replacement described under [Testing utilities](#testing-utilities)) does not accept `params=` — passing it raises `TypeError`. Its list methods keep pagination as a plain keyword, `await client.list_tools(cursor="next_page_token")`, alongside `meta=` and a per-call `cache_mode=` (`"use"` by default, or `"refresh"`/`"bypass"`) for the client's built-in [response cache](client/caching.md); `client.session.list_tools(params=...)` reaches the underlying `ClientSession` if you want the `params` form. -### `args` parameter removed from `ClientSessionGroup.call_tool()` +### `args` parameter removed from `ClientSessionGroup.call_tool()` {#args-parameter-removed-from-clientsessiongroupcall_tool} The deprecated `args` parameter has been removed from `ClientSessionGroup.call_tool()`. Use `arguments` instead. @@ -1878,7 +1878,7 @@ result = await session_group.call_tool("my_tool", args={"key": "value"}) result = await session_group.call_tool("my_tool", arguments={"key": "value"}) ``` -### Timeouts take `float` seconds instead of `timedelta` +### Timeouts take `float` seconds instead of `timedelta` {#timeouts-take-float-seconds-instead-of-timedelta} Every timeout parameter that took a `datetime.timedelta` in v1 now takes plain seconds as a `float`: @@ -1928,7 +1928,7 @@ The same change applies server-side: `ServerSession.send_request(request_read_ti To migrate, replace `timedelta(...)` with plain seconds, or mechanically append `.total_seconds()` to an existing timedelta value. -### Client request timeouts now raise `-32001` (`REQUEST_TIMEOUT`) instead of `408` +### Client request timeouts now raise `-32001` (`REQUEST_TIMEOUT`) instead of `408` {#client-request-timeouts-now-raise-32001-request_timeout-instead-of-408} A client request that exceeds `read_timeout_seconds` still raises the SDK's protocol error (`MCPError`, previously `McpError`), but the error code changed from the HTTP status `408` (`httpx.codes.REQUEST_TIMEOUT`) to the JSON-RPC code `-32001` (`REQUEST_TIMEOUT`, importable from `mcp.types`), matching the TypeScript SDK. The message changed too: v1 said `"Timed out while waiting for response to ClientRequest. Waited 5.0 seconds."`, v2 says `"Request 'tools/call' timed out"`. `MCPError.error` still exists, so a migrated `e.error.code == 408` check runs without error and silently never matches; timeouts fall through to whatever generic-error handling follows. Code that matched on the old message text breaks too. Compare against `REQUEST_TIMEOUT` instead. @@ -1964,7 +1964,7 @@ except MCPError as e: `e.error.code` also still works; `e.code` is the v2 convenience property. The constant is importable from `mcp.types` (or from `mcp_types` in a project that uses that package without the SDK). The example uses the high-level `Client`; `ClientSession.call_tool()` raises the same `MCPError`. -### `ClientSession` now runs on `JSONRPCDispatcher`; `BaseSession` removed +### `ClientSession` now runs on `JSONRPCDispatcher`; `BaseSession` removed {#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed} `ClientSession`'s public surface is unchanged — same constructor apart from timeout parameters (see [Timeouts take `float` seconds instead of `timedelta`](#timeouts-take-float-seconds-instead-of-timedelta)), typed methods, manual `initialize()`, and async context-manager lifecycle — but `BaseSession`, the v1 receive loop underneath it, is removed with no shim. The engine now lives in `JSONRPCDispatcher` (`mcp.shared.jsonrpc_dispatcher`). To customize client behavior, use the `ClientSession` constructor callbacks, or pass a pre-built dispatcher via the new keyword-only `dispatcher=` constructor argument (e.g. a `DirectDispatcher` for in-process embedding). Passing one of the SDK's own dispatchers (`JSONRPCDispatcher`, or `DirectDispatcher` from `mcp.shared.direct_dispatcher`) is the supported use; the `Dispatcher` protocol's `run()` lifecycle (`mcp.shared.dispatcher`) is documented as provisional, so treat a hand-written implementation as experimental. @@ -2006,7 +2006,7 @@ async def elicitation_callback( ) -> ElicitResult | ErrorData: ... ``` -### Experimental Tasks support removed +### Experimental Tasks support removed {#experimental-tasks-support-removed} Tasks ([SEP-1686](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1686)) have been removed from the MCP specification and are no longer part of this SDK. The `mcp.client.experimental`, `mcp.server.experimental`, `mcp.shared.experimental`, and `mcp.server.lowlevel.experimental` modules have been removed, along with the `experimental` properties on `ClientSession`, `ServerSession`, `Server`, and `ServerRequestContext`. The corresponding `Task*` types remain in `mcp.types` as types-only definitions, except the `TaskExecutionMode` alias, whose literal is now inlined on `ToolExecution.task_support`. @@ -2056,11 +2056,11 @@ result = await client.call_tool("long_running_task", {}, progress_callback=on_pr Also drop `execution=ToolExecution(taskSupport=types.TASK_REQUIRED)` from tool definitions: the `TASK_REQUIRED` / `TASK_OPTIONAL` / `TASK_FORBIDDEN` constants are gone from `mcp.types` (`ToolExecution.task_support` takes the plain `"required"` / `"optional"` / `"forbidden"` literal), and no v2 client or server reads the field. -## Transports +## Transports {#transports} Server-side transport entry points (`stdio_server()`, `SseServerTransport`, `StreamableHTTPSessionManager`) keep their v1 import paths and signatures (see [Lowlevel `Server`: what did not change](#lowlevel-server-what-did-not-change)), so the sections below are client-side apart from [`stdio_server` keeps the protocol streams on private descriptors](#stdio_server-keeps-the-protocol-streams-on-private-descriptors); the other server-side transport changes ([lifespan entered once](#streamable-http-lifespan-now-entered-once-at-manager-startup), the [4 MiB request-body limit](#streamable-http-request-bodies-are-limited-to-4-mib)) sit under MCPServer. -### `streamablehttp_client` removed +### `streamablehttp_client` removed {#streamablehttp_client-removed} The deprecated `streamablehttp_client` function has been removed. Use `streamable_http_client` instead. @@ -2111,7 +2111,7 @@ v1's internal client set `follow_redirects=True`; set it explicitly when supplyi Client-side stream resumption is also unchanged: the transport reconnects a dropped GET stream with `Last-Event-ID` on its own, and `session.send_request(..., metadata=ClientMessageMetadata(resumption_token=..., on_resumption_token_update=...))` (from `mcp.shared.message`) works as in v1. -### `get_session_id` callback removed from `streamable_http_client` +### `get_session_id` callback removed from `streamable_http_client` {#get_session_id-callback-removed-from-streamable_http_client} The `get_session_id` callback (third element of the returned tuple) has been removed from `streamable_http_client`. The function now returns a 2-tuple `(read_stream, write_stream)` instead of a 3-tuple. @@ -2165,19 +2165,19 @@ The hook fires on every response the client sees, so `captured_session_ids` gain `terminate_on_close` still defaults to `True`, so `streamable_http_client` sends its own `DELETE` for the session on exit; if your test deletes the session itself, pass `terminate_on_close=False`, or the transport's follow-up `DELETE` hits an already-terminated session and logs a `Session termination failed: 404` warning. -### `StreamableHTTPTransport` parameters removed +### `StreamableHTTPTransport` parameters removed {#streamablehttptransport-parameters-removed} The `headers`, `timeout`, `sse_read_timeout`, and `auth` parameters have been removed from `StreamableHTTPTransport`. Configure these on the `httpx2.AsyncClient` instead (see example above). `sse_client` is unchanged apart from the `httpx2` retyping: it still takes `url`, `headers`, `timeout`, `sse_read_timeout`, `httpx_client_factory` (which must now return an `httpx2.AsyncClient`), `auth` (now `httpx2.Auth | None`), and `on_session_created`. Only the streamable HTTP transport dropped its transport-level parameters; `StreamableHTTPTransport(url)` now takes just the URL. -### `StreamableHTTPTransport.protocol_version` attribute removed +### `StreamableHTTPTransport.protocol_version` attribute removed {#streamablehttptransportprotocol_version-attribute-removed} The transport no longer holds per-connection protocol state; era-dependent headers (e.g. `MCP-Protocol-Version`) are now supplied per-message by the session. If you were reading `transport.protocol_version` to learn the negotiated version, read `session.protocol_version` (or `client.protocol_version` on the high-level `Client`) instead. The `MCP_PROTOCOL_VERSION` header-name constant has moved: import `MCP_PROTOCOL_VERSION_HEADER` from `mcp.shared.inbound` instead of `MCP_PROTOCOL_VERSION` from `mcp.client.streamable_http`. -### Streamable HTTP: non-2xx responses now surface as per-request JSON-RPC errors +### Streamable HTTP: non-2xx responses now surface as per-request JSON-RPC errors {#streamable-http-non-2xx-responses-now-surface-as-per-request-json-rpc-errors} In v1, a non-2xx response to a message POST (other than 404) raised `httpx.HTTPStatusError` inside the transport's task group, so it escaped the `streamable_http_client` context as an `ExceptionGroup` and failed every pending request; a 404 raised `McpError` with the positive literal code `32600`. In v2 the transport no longer raises for HTTP status errors: the failing request gets a JSON-RPC error, raised as `MCPError` from that one call, and the connection stays usable. After a 500 fails one `tools/list`, the next call on the same session succeeds. @@ -2232,7 +2232,7 @@ async with streamable_http_client(url) as (read, write): Move HTTP-status failure handling from around the transport context to around the individual calls, catching `MCPError` (see [`McpError` renamed to `MCPError`](#mcperror-renamed-to-mcperror)). Connect-level failures such as `httpx2.ConnectError` still escape the transport context as before; keep context-level handling for those only. -### `terminate_windows_process` removed +### `terminate_windows_process` removed {#terminate_windows_process-removed} The deprecated `mcp.os.win32.utilities.terminate_windows_process` function has been removed. Process termination is handled internally by the `stdio_client` context @@ -2240,7 +2240,7 @@ manager; there is no replacement API. The Windows tree-termination helper `terminate_windows_process_tree` no longer accepts a `timeout_seconds` argument — the value was never used (Job Object termination is immediate). -### `stdio_client` shutdown reworked: a gracefully-exited server's children are left alive on POSIX +### `stdio_client` shutdown reworked: a gracefully-exited server's children are left alive on POSIX {#stdio_client-shutdown-reworked-a-gracefully-exited-servers-children-are-left-alive-on-posix} When a server exits on its own after `stdio_client` closes its stdin, background child processes the server leaves behind are deliberately left alive on POSIX: @@ -2274,7 +2274,7 @@ group (spawned with `start_new_session=True`); the `getpgid()` lookup and the per-process terminate/kill fallback are gone. The win32 utilities logger is now named `mcp.os.win32.utilities` (was `client.stdio.win32`). -### `stdio_server` keeps the protocol streams on private descriptors +### `stdio_server` keeps the protocol streams on private descriptors {#stdio_server-keeps-the-protocol-streams-on-private-descriptors} While serving, the stdio transport moves the wire to private descriptors and points fd 0 at the null device and fd 1 at stderr, restoring both on exit. Subprocesses and @@ -2304,13 +2304,13 @@ stdout now streams it into the client's stderr channel. Capture output you do no want in the client's logs, and be aware that a client which never drains its stderr pipe applies back-pressure to the server (true of stderr logging on v1 as well). -### WebSocket transport removed +### WebSocket transport removed {#websocket-transport-removed} The WebSocket transport has been removed: `mcp.client.websocket.websocket_client`, `mcp.server.websocket.websocket_server`, and the `ws` optional dependency extra (`mcp[ws]`) no longer exist. WebSocket was never part of the MCP specification. Use the streamable HTTP transport instead (`mcp.client.streamable_http.streamable_http_client` on the client, `streamable_http_app()` on the server), which supports bidirectional communication with server-to-client streaming over standard HTTP. -## OAuth and server auth +## OAuth and server auth {#oauth-and-server-auth} -### Unchanged auth surfaces +### Unchanged auth surfaces {#unchanged-auth-surfaces} Most of the auth API carries over from v1; if a survey of your `mcp.client.auth` / `mcp.server.auth` usage only turns up the changes documented in the sections below, that is @@ -2361,7 +2361,7 @@ expected. In particular: default). The `mcp.shared.auth` metadata models keep their fields, with the additions covered in the sections below. -### `RFC7523OAuthClientProvider` and `JWTParameters` removed +### `RFC7523OAuthClientProvider` and `JWTParameters` removed {#rfc7523oauthclientprovider-and-jwtparameters-removed} `RFC7523OAuthClientProvider` (deprecated since 1.23.0) and its `JWTParameters` model have been removed from `mcp.client.auth.extensions.client_credentials`. The provider implemented the @@ -2388,7 +2388,7 @@ client authentication on the token exchange — has no replacement and is intent was never exercised by the test suite and no MCP auth extension specifies it. If you depended on it, open an issue describing the deployment. -### OAuth metadata URLs no longer gain a trailing slash +### OAuth metadata URLs no longer gain a trailing slash {#oauth-metadata-urls-no-longer-gain-a-trailing-slash} `OAuthMetadata`, `ProtectedResourceMetadata`, and `OAuthClientMetadata` now set `url_preserve_empty_path=True` (Pydantic 2.12+). A path-less URL parsed from the wire keeps its @@ -2414,7 +2414,7 @@ issuer inconsistent with what clients compare against under RFC 8414 / RFC 9207. already-built `AnyHttpUrl` object still normalizes at construction; pass a string to get the preserved form. -### OAuth `callback_handler` returns `AuthorizationCodeResult` +### OAuth `callback_handler` returns `AuthorizationCodeResult` {#oauth-callback_handler-returns-authorizationcoderesult} The `callback_handler` passed to `OAuthClientProvider` now returns an `AuthorizationCodeResult` instead of a `tuple[str, str | None]` of `(code, state)`. The new object adds an `iss` field so the client can validate the [RFC 9207](https://datatracker.ietf.org/doc/html/rfc9207) authorization-response issuer ([SEP-2468](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2468)): when the redirect carries an `iss` query parameter it must match the authorization server's issuer, and a missing `iss` is rejected when the server advertised `authorization_response_iss_parameter_supported`. @@ -2443,7 +2443,7 @@ async def callback_handler() -> AuthorizationCodeResult: Forward the `iss` query parameter from the redirect so the validation can run: omitting it makes the flow fail with `OAuthFlowError` against servers that advertise `authorization_response_iss_parameter_supported`, and silently skips the check for servers that send `iss` without advertising it. -### `scopes=` renamed to `scope=` on the client-credentials providers +### `scopes=` renamed to `scope=` on the client-credentials providers {#scopes-renamed-to-scope-on-the-client-credentials-providers} `ClientCredentialsOAuthProvider` and `PrivateKeyJWTOAuthProvider` took the requested scope as a keyword named `scopes`, even though the value is a single space-separated string, not a list. The parameter is now `scope`, matching the RFC 6749 wire parameter, `OAuthClientMetadata.scope`, and the newer `IdentityAssertionOAuthProvider`. @@ -2459,7 +2459,7 @@ ClientCredentialsOAuthProvider(..., scopes="read write") ClientCredentialsOAuthProvider(..., scope="read write") ``` -### `client_secret_post` token requests now include `client_id` +### `client_secret_post` token requests now include `client_id` {#client_secret_post-token-requests-now-include-client_id} With `token_endpoint_auth_method="client_secret_post"`, the token request body now carries both `client_id` and `client_secret`, as [RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749) §2.3.1 requires; v1 sent only `client_secret`. The authorization-code and refresh requests already carried `client_id`, so the observable difference is the `client_credentials` exchange sent by `ClientCredentialsOAuthProvider(..., token_endpoint_auth_method="client_secret_post")` (plus `resource`/`scope` when configured): @@ -2472,7 +2472,7 @@ grant_type=client_credentials&client_id=CLIENT_ID&client_secret=SECRET Authorization servers that require both parameters answered the v1 request with `401 invalid_client`, so under v1 this provider effectively only worked with the default `client_secret_basic`. Drop any manual `client_id` injection or a test that pinned the 401 — the exchange now succeeds as configured. -### `timeout` parameter removed from `OAuthClientProvider` +### `timeout` parameter removed from `OAuthClientProvider` {#timeout-parameter-removed-from-oauthclientprovider} `OAuthClientProvider` no longer accepts a `timeout` argument, and `OAuthContext.timeout` is gone. The value was stored but never read, so it never bounded anything — removing it changes nothing at runtime. @@ -2490,7 +2490,7 @@ provider = OAuthClientProvider(server_url, client_metadata, storage) If you passed `timeout` to bound how long you wait for the user to complete authorization, apply that bound where you actually wait — inside your `redirect_handler`/`callback_handler`, e.g. `with anyio.fail_after(120): ...`. The full v2 constructor (v1's parameters minus `timeout`, plus a new optional `validate_resource_url` callback) is listed under [Unchanged auth surfaces](#unchanged-auth-surfaces). -### Client rejects authorization server metadata with a mismatched `issuer` +### Client rejects authorization server metadata with a mismatched `issuer` {#client-rejects-authorization-server-metadata-with-a-mismatched-issuer} During OAuth discovery, `OAuthClientProvider` now validates that the authorization server metadata's `issuer` exactly matches the authorization server URL advertised in the protected @@ -2518,7 +2518,7 @@ There is no client-side override. Fix the deployment instead: make the authoriza list. See [OAuth metadata URLs no longer gain a trailing slash](#oauth-metadata-urls-no-longer-gain-a-trailing-slash) for how v2 preserves the exact string form of these URLs. -### OAuth client requests `offline_access` and adds `prompt=consent` when the authorization server supports it ([SEP-2207](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2207)) +### OAuth client requests `offline_access` and adds `prompt=consent` when the authorization server supports it ([SEP-2207](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2207)) {#oauth-client-requests-offline_access-and-adds-promptconsent-when-the-authorization-server-supports-it-sep-2207} The OAuth client now augments its requested scope with `offline_access` whenever the authorization server's metadata advertises that scope in `scopes_supported` and the client's @@ -2561,15 +2561,15 @@ Note this also registers the client without the `refresh_token` grant, so token disabled; there is no knob for refresh tokens without the forced consent screen, since `prompt=consent` is keyed off the final scope. -### OAuth client credentials are bound to their authorization server ([SEP-2352](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2352)) +### OAuth client credentials are bound to their authorization server ([SEP-2352](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2352)) {#oauth-client-credentials-are-bound-to-their-authorization-server-sep-2352} Persisted OAuth client credentials are now bound to the authorization server that issued them: `OAuthClientInformationFull` records an `issuer`, set by the SDK after registration. When a server's protected resource metadata later points at a different authorization server, the client discards the bound credentials (and the old tokens) and re-registers with the new server instead of presenting one server's `client_id` to another. URL-based client IDs (CIMD) are portable and unaffected; credentials with no recorded issuer (pre-registered, or stored before this change) are left as-is. No API change for existing `TokenStorage` implementations - the `issuer` round-trips through the unchanged `get_client_info`/`set_client_info`. -### Step-up authorization unions previously requested scopes ([SEP-2350](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2350)) +### Step-up authorization unions previously requested scopes ([SEP-2350](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2350)) {#step-up-authorization-unions-previously-requested-scopes-sep-2350} When a `403 insufficient_scope` challenge triggers step-up re-authorization, the OAuth client now requests the union of the previously requested scopes and the newly challenged scopes, instead of replacing the scope with only the challenged ones. This keeps permissions granted for earlier operations from being dropped when a later operation escalates. No API change; the wider scope is sent automatically on the re-authorization request. -### OAuth Dynamic Client Registration sends `application_type` ([SEP-837](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/837)) +### OAuth Dynamic Client Registration sends `application_type` ([SEP-837](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/837)) {#oauth-dynamic-client-registration-sends-application_type-sep-837} `OAuthClientMetadata` now carries an `application_type` field that is sent during Dynamic Client Registration. It defaults to `"native"`, which suits MCP clients that use loopback redirect URIs (CLI and desktop apps); browser-based clients served from a non-local host should set it to `"web"`: @@ -2584,7 +2584,7 @@ client_metadata = OAuthClientMetadata( Under OIDC, omitting `application_type` defaults to `"web"`, which an authorization server may reject for the `localhost` redirect URIs native clients use; sending `"native"` avoids that. Non-OIDC servers ignore the parameter. -### `OAuthClientInformationFull` no longer subclasses `OAuthClientMetadata`, and parses server-substituted metadata +### `OAuthClientInformationFull` no longer subclasses `OAuthClientMetadata`, and parses server-substituted metadata {#oauthclientinformationfull-no-longer-subclasses-oauthclientmetadata-and-parses-server-substituted-metadata} `OAuthClientMetadata` is the registration request a client sends; `OAuthClientInformationFull` is the authorization server's record of a registered client, parsed from its Dynamic Client Registration response. In v1 the second inherited from the first, which typed the response as though it had to be a request this SDK would send. It does not: [RFC 7591 §3.2.1](https://datatracker.ietf.org/doc/html/rfc7591#section-3.2.1) lets the server "reject or replace any of the client's requested metadata values submitted during the registration and substitute them with suitable values", and real servers return an `application_type` outside OIDC Registration's `web`/`native`, an explicit `null`, a `token_endpoint_auth_method` the SDK does not implement, or an empty `redirect_uris`. The inherited strict types turned each of those into a `ValidationError` on a 2xx response - after the server had already provisioned the client, so the registration was discarded and orphaned. @@ -2605,7 +2605,7 @@ A registration response the server sends is no longer rejected on these fields: The SDK's own registration endpoint now returns all registered metadata in its 201 response (RFC 7591 §3.2.1) - including the client's `application_type`, which v1 dropped from the echo (silently reporting the default in place of a client's `"web"`), and `client_secret_expires_at` (`0` when the secret never expires) whenever a `client_secret` is issued. It also now answers a `private_key_jwt` registration with `400 invalid_client_metadata` rather than confirming a method it authenticates no requests with. -### Stricter client authentication at `/token` and `/revoke` +### Stricter client authentication at `/token` and `/revoke` {#stricter-client-authentication-at-token-and-revoke} v2 hardens client authentication on SDK-hosted authorization servers (`create_auth_routes`) in two ways. Both apply automatically; server code only needs changing if you hand-provision client records. @@ -2645,23 +2645,23 @@ LEGACY_CLIENT = OAuthClientInformationFull( ) ``` -## Stricter protocol validation and wire behavior +## Stricter protocol validation and wire behavior {#stricter-protocol-validation-and-wire-behavior} -### Server handler results are validated against the protocol schema +### Server handler results are validated against the protocol schema {#server-handler-results-are-validated-against-the-protocol-schema} Results returned from server handlers are now validated against the negotiated protocol version's schema before being sent. A result that does not conform raises on the server side and the client receives an `INTERNAL_ERROR` response. The case most existing code will hit is `Tool.inputSchema`: the spec requires it to contain `"type": "object"`, so an empty `{}` is now rejected. Validation runs when the result is serialized onto the wire, not when the model is constructed: `Tool(name="t", input_schema={})` still constructs, so a fixture that builds such a tool only fails once a `tools/list` handler returns it. Your handler returns normally; the server then logs the `pydantic.ValidationError` (`handler for 'tools/list' returned an invalid result`) and answers the request with `INTERNAL_ERROR`, so the failure shows up on the client, not at the line that built the model. -### Client validates inbound traffic against the protocol schema +### Client validates inbound traffic against the protocol schema {#client-validates-inbound-traffic-against-the-protocol-schema} `ClientSession` now validates server requests, notifications, and results against the negotiated protocol version's schema before parsing them into `mcp.types` models. Spec-invalid server output that the previous monolith parse tolerated may now raise `pydantic.ValidationError` from `list_tools()`, `call_tool()`, and similar calls. `_meta` remains the sanctioned place for result extras (and `experimental` for capability extras). -### Unknown request methods now return `-32601` (Method not found) +### Unknown request methods now return `-32601` (Method not found) {#unknown-request-methods-now-return-32601-method-not-found} In v1, a request for a method the SDK didn't recognize failed request-union validation and was answered with `-32602` (`"Invalid request parameters"`, empty `data`). Any method the receiver doesn't serve — unrecognized on either side, or a spec method the server has no registered handler for — is now answered with the JSON-RPC-specified `-32601` (`"Method not found"`), with the method name in `data`, in every initialization state. Clients still decline sampling, elicitation, and roots requests with `-32600` when no callback is registered, as in v1. Update anything that matched on the old code for this case. -### Every outbound request now carries a `_meta` envelope; OpenTelemetry is on by default +### Every outbound request now carries a `_meta` envelope; OpenTelemetry is on by default {#every-outbound-request-now-carries-a-\_meta-envelope-opentelemetry-is-on-by-default} v2 sends `"_meta": {}` in the params of every request it emits, at every negotiated protocol version. Requests that had no params in v1, such as `ping` and `tools/list`, now carry `"params": {"_meta": {}}`; server-initiated requests get the same envelope. This is spec-valid and accepted by all peers, but wire traffic differs from v1 on every call, and no configuration restores the v1 wire shape. Update any test or tooling that asserts on raw outbound request bytes. @@ -2683,9 +2683,9 @@ The envelope exists for OpenTelemetry trace propagation ([SEP-414](https://githu The SDK's new `opentelemetry-api` runtime dependency is covered under [Packaging, dependencies, and CLI](#packaging-dependencies-and-cli). -## Testing utilities +## Testing utilities {#testing-utilities} -### `create_connected_server_and_client_session` removed +### `create_connected_server_and_client_session` removed {#create_connected_server_and_client_session-removed} The `create_connected_server_and_client_session` helper in `mcp.shared.memory` has been removed. Use `mcp.client.Client` instead — it accepts a `Server` or `MCPServer` instance directly and handles the in-memory transport and session setup for you. @@ -2733,7 +2733,7 @@ One behavioral caveat when moving progress-reporting handlers onto `Client(serve `ctx.report_progress(progress, total, message)` works on every dispatcher: it sends a progress notification when a token is present and routes the update through the dispatcher's progress channel otherwise, no-opping only when the caller did not request progress at all (see also [Client-to-server progress deprecated](#client-to-server-progress-deprecated-2026-07-28)). `session.send_progress_notification(progress_token, ...)` is unchanged and still works on JSON-RPC transports for code that already holds a token. -## Deprecations +## Deprecations {#deprecations} Every deprecation below is a runtime warning as well as a type-checker one: deprecated methods and helpers emit `mcp.MCPDeprecationWarning` on each call, and the deprecated `Server(...)` constructor parameters (`on_set_logging_level`, `on_roots_list_changed`, `on_progress`) emit it at construction time. The category subclasses `UserWarning`, not `DeprecationWarning`, so it is visible by default; [Deprecated features](deprecated.md) has the full list and each replacement. @@ -2749,7 +2749,7 @@ filterwarnings = [ Use `"ignore::mcp.MCPDeprecationWarning"` (or the `warnings.filterwarnings` call [below](#roots-sampling-and-logging-methods-deprecated-sep-2577)) to silence them instead, and wrap a test that deliberately exercises a deprecated path in `pytest.warns(MCPDeprecationWarning)`. -### Client resource-subscription methods deprecated (SEP-2575) +### Client resource-subscription methods deprecated (SEP-2575) {#client-resource-subscription-methods-deprecated-sep-2575} [SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2575) removes `resources/subscribe` and `resources/unsubscribe` from the 2026-07-28 wire; per-URI subscriptions travel in the `subscriptions/listen` filter instead. The client verbs now carry `typing_extensions.deprecated`: @@ -2766,7 +2766,7 @@ async with client.listen(resource_subscriptions=["board://sprint"]) as sub: On a bare `ClientSession` (no high-level `Client`), the same stream is `listen(session, resource_subscriptions=[...])` from `mcp.client.subscriptions` — the function `Client.listen()` wraps — which requires a 2026-07-28 connection and raises `ListenNotSupportedError` on an older one. See the [Subscriptions](client/subscriptions.md#watching-the-stream) page under Clients for the full client-side contract (typed events, the honored filter, clean end vs `SubscriptionLost`). -### Roots, Sampling, and Logging methods deprecated (SEP-2577) +### Roots, Sampling, and Logging methods deprecated (SEP-2577) {#roots-sampling-and-logging-methods-deprecated-sep-2577} [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) deprecates the Roots, Sampling, and Logging features as of the 2026-07-28 spec. The deprecation is advisory only: there are no wire-level changes, capability negotiation is unchanged, and every method keeps working for sessions negotiating 2025-11-25 and earlier. @@ -2791,13 +2791,13 @@ warnings.filterwarnings("ignore", category=MCPDeprecationWarning) No migration is required during the deprecation window. New code should avoid building on these features, since they may be removed in a future spec version. -### Client-to-server progress deprecated (2026-07-28) +### Client-to-server progress deprecated (2026-07-28) {#client-to-server-progress-deprecated-2026-07-28} The 2026-07-28 spec restricts `notifications/progress` to the server-to-client direction only — `ProgressNotification` is no longer in the spec's `ClientNotification`. `Client.send_progress_notification()` and `ClientSession.send_progress_notification()` now carry `typing_extensions.deprecated` and emit `mcp.MCPDeprecationWarning` at runtime. They continue to work against servers negotiating 2025-11-25 or earlier. Registering a lowlevel `Server` `on_progress` handler is deprecated the same way as the SEP-2577 handler parameters above: it sits in the `typing_extensions.deprecated` `Server.__init__` overload and passing it emits `mcp.MCPDeprecationWarning` at construction time. On the server side, prefer the new dispatcher-agnostic `ServerSession.report_progress(progress, total, message)` (and `Context.report_progress()` on `MCPServer`) over the raw `ServerSession.send_progress_notification(progress_token, …)`. `report_progress` encapsulates the "no-op when the caller did not request progress" rule and works on every dispatcher; the raw token-taking form remains for handlers that read `_meta.progressToken` directly. -## Notes for 2026-era connections +## Notes for 2026-era connections {#notes-for-2026-era-connections} Everything below this heading describes behavior that only activates on connections negotiated at protocol 2026-07-28 or later. Migrated v1 code talking to 2025-11-25 (or @@ -2805,7 +2805,7 @@ earlier) peers is unaffected — the notable exception being an in-process `Clie which negotiates 2026-07-28 by default (first subsection below). It is collected here so the rest of this guide stays focused on the v1-to-v2 upgrade itself. -### Server-initiated sampling, elicitation, and roots raise `NoBackChannelError` +### Server-initiated sampling, elicitation, and roots raise `NoBackChannelError` {#server-initiated-sampling-elicitation-and-roots-raise-nobackchannelerror} The 2026-07-28 protocol has no server-initiated requests, so a handler that reaches back to the client mid-request — `ctx.elicit()`, `ctx.elicit_url()`, `ctx.session.create_message()`, `ctx.session.list_roots()`, or any other `ServerSession` request helper — raises `NoBackChannelError` on such a connection instead of sending. An in-process `Client(server)` negotiates 2026-07-28 by default (see [`Client` defaults to `mode='auto'`](#client-defaults-to-modeauto)), so the first smoke test of an unchanged v1 sampling or elicitation tool fails, and setting `sampling_callback=` / `elicitation_callback=` on the client changes nothing because no request ever reaches the client. @@ -2848,7 +2848,7 @@ async def book_table(date: str, answer: Annotated[Confirmation, Resolve(ask_to_c The client's same `elicitation_callback` answers both; the resolver lets the server *return* the question instead of pushing it. -### Log messages are delivered only to requests that opt in +### Log messages are delivered only to requests that opt in {#log-messages-are-delivered-only-to-requests-that-opt-in} At 2026-07-28 the deprecated logging capability changes shape: `logging/setLevel` is gone, and log delivery becomes a per-request opt-in. A server MUST NOT send `notifications/message` for a request whose `_meta` lacks `io.modelcontextprotocol/logLevel`, and when the key is present it sends only entries at or above that level, on that request's own stream. So on a 2026-era connection the request-scoped log calls — `ctx.info(...)` and friends on `MCPServer`'s `Context`, `ctx.session.send_log_message(...)`, `Context.log(...)` — are silently dropped (debug-logged) unless the request opted in, and dropped when they fall below the requested level; `Connection.log(...)`, which has no request to opt in, never sends there. Nothing changes on 2025-11-25 and earlier connections. @@ -2861,13 +2861,13 @@ async with Client(server, logging_callback=on_log, log_level="info") as client: `log_level=None` (the default) means no opt-in — a `logging_callback` alone is not one — and a single request can override the client-wide default by supplying the key in its own `meta=` (e.g. `meta={LOG_LEVEL_META_KEY: "debug"}` from `mcp_types`). The opt-in is what the spec calls for on 2026-era servers generally, not just this SDK's. Because 2026 log delivery is request-scoped by construction, `related_request_id` on `send_log_message` no longer selects the standalone stream there: whatever is delivered rides the requesting stream. -### Change notifications travel only on `subscriptions/listen` streams +### Change notifications travel only on `subscriptions/listen` streams {#change-notifications-travel-only-on-subscriptionslisten-streams} On a 2026-07-28 connection, `notifications/tools/list_changed`, `notifications/prompts/list_changed`, `notifications/resources/list_changed`, and `notifications/resources/updated` reach a client only through a `subscriptions/listen` stream it opened — the spec forbids sending a notification type a subscription did not request. The v1-style session helpers (`ctx.session.send_tool_list_changed()`, `send_prompt_list_changed()`, `send_resource_list_changed()`, `send_resource_updated(uri)`) push a bare copy onto the connection's standalone channel instead, so on such a connection they are dropped with a debug log: silently on streamable HTTP (there is no standalone channel), and on stdio, where earlier v2 releases wrote the bare notification to the shared pipe, it is now dropped too. On pre-2026 connections the helpers behave as in v1. Migrate to publishing on the subscription bus, which stamps and filters per stream: `await ctx.notify_tools_changed()`, `notify_prompts_changed()`, `notify_resources_changed()`, and `notify_resource_updated(uri)` on `MCPServer`'s `Context`, or `await bus.publish(...)` on a low-level `Server`'s own `SubscriptionBus` — see [Subscriptions](handlers/subscriptions.md). A stream only ever receives the kinds and URIs the server acknowledged for it; to gate per caller which subscriptions may be opened, refuse `subscriptions/listen` in a middleware (`MCPServer(middleware=[...])`), covered on the same page. -### Servers validate `Mcp-Param-*` headers against the request body ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243)) +### Servers validate `Mcp-Param-*` headers against the request body ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243)) {#servers-validate-mcp-param-headers-against-the-request-body-sep-2243} On the 2026-07-28 Streamable HTTP path, a `tools/call` whose tool declares `x-mcp-header` annotations is validated before dispatch — each annotated argument and its mirroring `Mcp-Param-*` header must be present together and agree (after base64-sentinel decoding; integers compare numerically), or absent together. A violation is rejected with HTTP 400 and JSON-RPC error `-32020` (`HeaderMismatch`), as the spec requires. A client that sends an annotated argument *without* its header — for example one that never listed the tool — is therefore rejected instead of silently served; the spec's recovery is to re-list and retry. On the client side, `ClientSession.call_tool` emits these headers automatically for annotated arguments of any tool it has listed; list the tool first, and note that pre-2026 connections and non-HTTP transports never emit them. @@ -2875,7 +2875,7 @@ There is nothing to configure. The server resolves the called tool's schema thro Base64-sentinel decoding is strict everywhere it applies, including the `Mcp-Name` header: a `=?base64?...?=` value whose payload is not canonical base64 (wrong padding, stray characters, non-zero trailing bits) or not valid UTF-8 is rejected as malformed rather than leniently decoded. -## Need Help? +## Need Help? {#need-help} If you encounter issues during migration: diff --git a/docs/protocol-versions.md b/docs/protocol-versions.md index 9ef19a7cf7..02598c7719 100644 --- a/docs/protocol-versions.md +++ b/docs/protocol-versions.md @@ -1,4 +1,4 @@ -# Protocol versions +# Protocol versions {#protocol-versions} MCP has two eras. @@ -6,7 +6,7 @@ Servers released before 2026-07-28 open every connection with the **`initialize` You almost never have to care, because `Client` negotiates for you. This page is about the one constructor argument that controls it, `mode=`, and the three times you change it. -## `mode="auto"` +## `mode="auto"` {#modeauto} ```python title="client.py" hl_lines="14-15" --8<-- "docs_src/protocol_versions/tutorial001.py" @@ -30,7 +30,7 @@ That is the whole feature. One `Client`, any era of server, no branching in your HTTP — so against your own server `auto` always lands on `2026-07-28`. The fallback only ever fires against a real pre-2026 server, which is exactly when you want it to. -## `mode="legacy"` +## `mode="legacy"` {#modelegacy} ```python title="client.py" hl_lines="14" --8<-- "docs_src/protocol_versions/tutorial002.py" @@ -52,7 +52,7 @@ At 2026-07-28 it is gone. The server *returns* its questions and you retry the c `mode="auto"` only gives you a handshake when the server is too old for anything else. `mode="legacy"` guarantees one. Reach for it whenever you hand `Client(...)` a `sampling_callback`, an `elicitation_callback` you want driven as a request, or a `message_handler`. **[Client callbacks](client/callbacks.md)** goes through each. -## Pinning a version +## Pinning a version {#pinning-a-version} `mode` also accepts a modern protocol version string. Today that set is exactly `["2026-07-28"]`. @@ -83,7 +83,7 @@ Only modern versions are pinnable. A handshake-era string is rejected at constru 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') ``` -## Reconnecting with `prior_discover` +## Reconnecting with `prior_discover` {#reconnecting-with-prior_discover} The probe is cheap, but it is still a round trip you pay on every reconnect, and the answer almost never changes. @@ -106,7 +106,7 @@ The second connection made **zero** negotiation round trips and still knows exac `prior_discover=` only does anything when `mode` is a version pin. Under `"auto"` the client probes the server anyway, and under `"legacy"` it is ignored. -## The four modes +## The four modes {#the-four-modes} | You write | Negotiation traffic | You get | | --- | --- | --- | @@ -115,7 +115,7 @@ The second connection made **zero** negotiation round trips and still knows exac | `Client(target, mode="2026-07-28")` | none | that version, pinned, with `server_info` as `None` | | `Client(target, mode="2026-07-28", prior_discover=saved)` | none | that version, pinned, *and* the identity you saved last time | -## Recap +## Recap {#recap} * MCP has a handshake era (up to `2025-11-25`, the `initialize` handshake) and a modern era (`2026-07-28`, `server/discover`). `Client` bridges them. * `mode="auto"` is the default: probe, fall back. Leave it alone unless one of the other three rows describes you. diff --git a/docs/run/asgi.md b/docs/run/asgi.md index 2eca9273cd..84ffe165b3 100644 --- a/docs/run/asgi.md +++ b/docs/run/asgi.md @@ -1,4 +1,4 @@ -# Add to an existing app +# Add to an existing app {#add-to-an-existing-app} `mcp.run("streamable-http")` starts a web server for you. Sometimes you don't want that: your MCP server is one piece of a larger web application, or you already have an ASGI deployment. @@ -6,7 +6,7 @@ For that, `mcp.streamable_http_app()` returns a **Starlette application**. A Starlette app is an ASGI app, so anything that hosts ASGI (uvicorn, Hypercorn, another Starlette, FastAPI) can host your MCP server. -## The app +## The app {#the-app} ```python title="server.py" hl_lines="12" --8<-- "docs_src/asgi/tutorial001.py" @@ -35,7 +35,7 @@ Run the app on its own (`uvicorn server:app`) and you never think about either. `mcp.sse_app()` does the same for the superseded SSE transport. -## Localhost only, until you say otherwise +## Localhost only, until you say otherwise {#localhost-only-until-you-say-otherwise} Out of the box the app answers **only** requests addressed to localhost. `streamable_http_app()` cannot know which hostname it will be served behind, so it arms DNS-rebinding protection with the @@ -45,7 +45,7 @@ it means **every request is rejected with `421 Misdirected Request`** until you consulted first. That allowlist, and everything else between a working app and a real hostname, is **[Deploy & scale](deploy.md)**. -## Mounting it +## Mounting it {#mounting-it} The moment the MCP server is *part* of a bigger application, you put the app inside a `Mount`. And the moment you do that, the lifespan becomes your problem: @@ -75,7 +75,7 @@ Starlette's `Host` route works the same way: swap `Mount("/", ...)` for `Host("m Nothing starts the session manager except its `run()`. -## Two servers, one app +## Two servers, one app {#two-servers-one-app} Each `MCPServer` is its own app with its own session manager. Mount as many as you like; enter every manager from the one host lifespan: @@ -86,7 +86,7 @@ Each `MCPServer` is its own app with its own session manager. Mount as many as y * `AsyncExitStack` enters both managers; they start together and shut down in reverse order. * The endpoints are `/notes/mcp` and `/tasks/mcp`: the mount prefix plus the default path. -## Changing the path +## Changing the path {#changing-the-path} That trailing `/mcp` is `streamable_http_path`. Set it to `"/"` and the mount prefix becomes the whole public path: @@ -96,7 +96,7 @@ That trailing `/mcp` is `streamable_http_path`. Set it to `"/"` and the mount pr Now clients connect to `/notes`, not `/notes/mcp`. -## CORS for browser clients +## CORS for browser clients {#cors-for-browser-clients} A browser-based client needs two permissions from you: to **send** its MCP request headers, and to **read** the one MCP sends back. Both are CORS configuration on the host app, and the transport-security allowlist above has to agree with it: @@ -109,7 +109,7 @@ A browser-based client needs two permissions from you: to **send** its MCP reque * `allow_origins` is your decision, not MCP's. Be precise, and mirror it in `allowed_origins=` above: the browser enforces CORS, but the server checks `Origin` itself, and an origin the transport doesn't trust gets a `403` even after a clean preflight. * `allow_methods` lists the three methods Streamable HTTP uses: `POST` to send messages, `GET` to open the server-to-client stream, `DELETE` to end the session. -## Custom routes +## Custom routes {#custom-routes} `@mcp.custom_route()` registers a plain HTTP endpoint on the same app, for the things every deployed service needs that have nothing to do with MCP: a health check, an OAuth callback. @@ -126,7 +126,7 @@ A browser-based client needs two permissions from you: to **send** its MCP reque deliberate: health checks and OAuth callbacks have to be reachable before any token exists. Don't put anything private behind one. -## Recap +## Recap {#recap} * `mcp.streamable_http_app()` returns a Starlette app with one route, `/mcp`. Any ASGI server can run it. * Out of the box the app answers only requests addressed to localhost, and behind a real hostname it rejects everything with a `421` until you pass `transport_security=` an allowlist. **[Deploy & scale](deploy.md)** owns that, and the rest of the road to production. diff --git a/docs/run/authorization.md b/docs/run/authorization.md index b7d731b1e2..f4438e2293 100644 --- a/docs/run/authorization.md +++ b/docs/run/authorization.md @@ -1,4 +1,4 @@ -# Authorization +# Authorization {#authorization} Over Streamable HTTP your MCP server is an ordinary web service, and you protect it the way you protect any web service: with OAuth 2.1 bearer tokens. @@ -6,7 +6,7 @@ In OAuth terms, your server is a **resource server**. It never signs anyone in a This page is the server side. A client that discovers your authorization server and fetches the token is **[OAuth clients](../client/oauth-clients.md)**. -## The three parties +## The three parties {#the-three-parties} * The **authorization server** signs people in and issues access tokens. You don't write this. It's your identity provider (Auth0, Keycloak, Entra, your own). * The **resource server** is your MCP server. It verifies the token on every request. @@ -14,7 +14,7 @@ This page is the server side. A client that discovers your authorization server That's the whole triangle. Everything on this page is the middle bullet. -## A token verifier +## A token verifier {#a-token-verifier} The SDK has no opinion about what a valid token looks like. You tell it, by implementing **`TokenVerifier`**: @@ -36,7 +36,7 @@ The SDK has no opinion about what a valid token looks like. You tell it, by impl `examples/servers/simple-auth/` in the SDK repository has an `IntrospectionTokenVerifier` that calls a real authorization server's [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662) endpoint. It's the shape most production verifiers take. -## What you get over HTTP +## What you get over HTTP {#what-you-get-over-http} Authorization lives in HTTP headers, so it exists only on the HTTP transports. Run it on the one you deploy: `mcp.run(transport="streamable-http")` puts it on `http://127.0.0.1:8000/mcp`, and **[Running your server](index.md)** has the rest. The app now has two routes: @@ -47,7 +47,7 @@ Authorization lives in HTTP headers, so it exists only on the HTTP transports. R You registered one tool. The second route is the SDK's. -### Discovery +### Discovery {#discovery} `GET` that well-known path and you get **[RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) Protected Resource Metadata**, built straight from your `AuthSettings`: @@ -82,7 +82,7 @@ This document is how a client that has never heard of your server finds its way goes for the in-memory `Client(mcp)` you use in tests: it connects straight to the server object and skips the HTTP layer, authorization included. -## The caller's identity +## The caller's identity {#the-callers-identity} Inside any handler, **`get_access_token()`** is the `AccessToken` your verifier returned for the current request: @@ -100,7 +100,7 @@ Call `whoami` with `Authorization: Bearer alice-token` and the model reads: alice (scopes: notes:read) ``` -## The half the SDK doesn't do +## The half the SDK doesn't do {#the-half-the-sdk-doesnt-do} The SDK gives you the resource-server half: verify, advertise, refuse. It does not give you a login page, a consent screen, or a token. @@ -113,7 +113,7 @@ To watch all three parties move, run `examples/servers/simple-auth/` from the SD An authorization server can also accept an enterprise identity provider's signed assertion in place of a user clicking through a consent screen, and the SDK supports both sides of that exchange. The grant, and the client that presents it, is **[Identity assertion](../client/identity-assertion.md)**. -## Recap +## Recap {#recap} * Over Streamable HTTP your server is an OAuth 2.1 **resource server**: it verifies tokens, it never issues them. * `TokenVerifier` is the whole integration surface: one async method, token in, `AccessToken | None` out. diff --git a/docs/run/deploy.md b/docs/run/deploy.md index 24f25c2019..33e0547d02 100644 --- a/docs/run/deploy.md +++ b/docs/run/deploy.md @@ -1,10 +1,10 @@ -# Deploy & scale +# Deploy & scale {#deploy-scale} Your server works. Now it needs a real hostname, and more than one worker behind it. Almost none of that is MCP's business. You bring the ASGI server, the process manager, the load balancer. What this page has is the short list of things that *are* MCP's business: one setting that gates every deployment, and the two places where "more than one worker" changes what the SDK does. -## Before anything else: the Host allowlist +## Before anything else: the Host allowlist {#before-anything-else-the-host-allowlist} `streamable_http_app()` cannot know which hostname it will be served behind, so it assumes the safest answer: localhost. With no `transport_security=`, the app switches on **DNS-rebinding protection** and accepts a request only if its `Host` header is `127.0.0.1:`, `localhost:`, or `[::1]:`. The `Origin` header, when there is one, has to be the `http://` form of the same. On your machine that is exactly right: it stops a malicious web page from driving your local server through a DNS name it rebound to `127.0.0.1`. @@ -42,7 +42,7 @@ Deployed behind a real hostname, that same default rejects **every request** unt deployed server that refuses every connection is a Host allowlist until proven otherwise. **[Troubleshooting](../troubleshooting.md)** starts here too. -## Workers, and who has to be sticky +## Workers, and who has to be sticky {#workers-and-who-has-to-be-sticky} Once the hostname answers, put more than one worker behind it. There is no SDK knob for that; you scale a Starlette app the way you scale any ASGI app, by handing the object to something that knows how to fork: @@ -68,7 +68,7 @@ Sticky sessions and what the legacy leg costs are their own page, **[Serving leg The rest of this page is the two things that being stateless does **not** buy you. -## `requestState` across workers +## `requestState` across workers {#requeststate-across-workers} A **[multi-round-trip](../handlers/multi-round-trip.md)** tool needs something the client has to go get (a confirmation, a choice, a credential), so it returns a question instead of an answer and finishes on the retry. Between the two rounds the client holds an opaque `request_state` token the server minted. On the retry the server has to open that token again. @@ -137,7 +137,7 @@ Everything else about the seal is **[Protecting `requestState`](../handlers/mult and the SDK mints and seals its `request_state` for it. Same default key, same failure across workers, same fix. -## Change notifications across replicas +## Change notifications across replicas {#change-notifications-across-replicas} A client's `subscriptions/listen` stream is one long-lived response, so it is pinned to one replica for its whole life. A `ctx.notify_resource_updated(...)` published on a **different** replica has to reach it. @@ -153,7 +153,7 @@ Nothing about the fan-out cares which server object a stream is attached to. Two * The bus carries four small typed events, never JSON-RPC. Acknowledgment, filtering, and stream lifecycle stay in the SDK, so your bus cannot break the protocol; it can only move events between processes. * Streams are **not** resumable and events are **not** replayed. Losing a replica drops its streams; the clients re-listen and re-fetch. There is no event store to share and nothing else to configure. This is the one place where scaling out is genuinely just more of the same. -## What the SDK does not give you +## What the SDK does not give you {#what-the-sdk-does-not-give-you} An `MCPServer` is a protocol implementation, not an application server. The deployment knobs you go looking for next are missing on purpose: @@ -162,7 +162,7 @@ An `MCPServer` is a protocol implementation, not an application server. The depl * **No production settings object.** There is nowhere on `MCPServer` to write down timeouts, TLS, graceful shutdown, or connection limits, because none of those are its job. They belong to your ASGI server, and you configure them there. **[Running your server](index.md)** covers the handful of settings the constructor *does* take. * **No shipped `EventStore`, and on 2026-07-28 no use for one.** Resumability is a feature of the legacy stateful leg; a modern exchange is one POST, one response, and nothing to resume. -## Recap +## Recap {#recap} * Out of the box the app answers only requests addressed to localhost. `transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])` is the go-live gate: until you pass it, every request behind a real hostname is a `421` and the reason is only in the server's log. * On 2026-07-28 there is no session and nothing for a load balancer to be sticky on. `stateless_http=True` is a legacy-only knob because a modern request is routed and answered before that flag is ever read. diff --git a/docs/run/index.md b/docs/run/index.md index dbea20d0fe..a61aa4e2f8 100644 --- a/docs/run/index.md +++ b/docs/run/index.md @@ -1,10 +1,10 @@ -# Running your server +# Running your server {#running-your-server} `mcp.run()` starts the server. The only decision you make is the **transport**: how the bytes between your server and its client actually move. -## Pick a transport +## Pick a transport {#pick-a-transport} | Transport | What it is | When | |---|---|---| @@ -17,7 +17,7 @@ The only decision you make is the **transport**: how the bytes between your serv `mcp.run(transport="sse")` still works, with its own `sse_path=` and `message_path=` options, but it exists for clients that haven't moved. Don't build anything new on it. -## `mcp.run()` +## `mcp.run()` {#mcprun} ```python title="server.py" hl_lines="12-13" --8<-- "docs_src/run/tutorial001.py" @@ -27,7 +27,7 @@ The only decision you make is the **transport**: how the bytes between your serv * With no argument, the transport is `stdio`. * It sits under `if __name__ == "__main__":` because everything that loads your server (`mcp dev`, `mcp run`, `mcp install`, your tests) **imports** this file. The guard keeps an import from turning into a running server. -### stdio +### stdio {#stdio} There is nothing to configure. The host starts your file as a child process, writes requests to its stdin, and reads responses from its stdout. @@ -41,7 +41,7 @@ Nothing prints, and it doesn't return. It is waiting on stdin for a host to spea That also means stdout **is the wire**. While serving, the SDK moves the wire to a private descriptor and diverts output that is *flushed* to stdout (a subprocess writing to its inherited stdout, a flushed `print()`) to stderr, where it can't corrupt the stream. Output flushed to stdout *before* serving begins (a wrapper script echoing, an unbuffered import-time print) still lands on the wire, and so does a `print()` that stays buffered until the interpreter drains it at exit. For output you actually want, the `logging` module is the right tool: its handler flushes each record to stderr as it happens. That story is in **[Logging](../handlers/logging.md)**. -### Try it +### Try it {#try-it} ```console uv run mcp dev server.py @@ -51,7 +51,7 @@ The Inspector does exactly what a real host does: it launches `server.py` as a s You never gave it a port. There isn't one. -## Streamable HTTP +## Streamable HTTP {#streamable-http} To put the same server on a port instead, name the transport (and its options) in `run()`: @@ -83,7 +83,7 @@ Each transport has its own keyword arguments, all on `run()`: `run()` is the short road. The moment you need more (your server mounted inside an existing app, two servers in one process, CORS for browser clients), you build the ASGI app yourself and hand it to any ASGI host. That is **[Add to an existing app](asgi.md)**. -## Server settings +## Server settings {#server-settings} A couple of things about running are not about the transport. They are constructor arguments: @@ -96,7 +96,7 @@ A couple of things about running are not about the transport. They are construct Both land on `mcp.settings`, which you can read back at runtime. -## The `mcp` command +## The `mcp` command {#the-mcp-command} The `[cli]` extra installs a small command-line tool around all of this. @@ -138,7 +138,7 @@ Claude Desktop is the only host `mcp install` knows. Every other host (Claude Co `mcp dev` and `mcp run` only understand `MCPServer`. If you build with the low-level `Server`, you run it yourself. See **[The low-level Server](../advanced/low-level-server.md)**. -## Recap +## Recap {#recap} * A **transport** is how bytes reach your server: `stdio` for a local subprocess, `streamable-http` for a port. SSE is superseded. * `mcp.run()` picks the transport. With no argument it is `stdio`, and it blocks. diff --git a/docs/run/legacy-clients.md b/docs/run/legacy-clients.md index a1c0f76007..74909b4950 100644 --- a/docs/run/legacy-clients.md +++ b/docs/run/legacy-clients.md @@ -1,4 +1,4 @@ -# Serving legacy clients +# Serving legacy clients {#serving-legacy-clients} MCP has two protocol eras: the `initialize`-handshake era, up to spec version `2025-11-25`, and the modern era, `2026-07-28`. **[Protocol versions](../protocol-versions.md)** is the page on the split itself. @@ -14,7 +14,7 @@ So a legacy client is not something you build *for*. It is something that connec Both eras are always on. The nearest thing to a per-era switch in that signature is `stateless_http`, and it is most of this page. -## One handler, both eras +## One handler, both eras {#one-handler-both-eras} Here is a tool that has to ask the user something, and both eras of client calling it: @@ -41,7 +41,7 @@ It is worth pausing on *how*, because the two clients were asked the same questi ever works on a legacy connection. On a `2026-07-28` one the call fails. If a tool still uses it, the fix is the one you see above, not a version check. -## What a legacy session costs you +## What a legacy session costs you {#what-a-legacy-session-costs-you} The routing is free. The session is not. @@ -56,7 +56,7 @@ On one worker that is invisible. On two, it is the whole problem: a request that events to a client reconnecting to the *same* session), not a session store. It never makes a session reachable from another process. -## The one knob: `stateless_http` +## The one knob: `stateless_http` {#the-one-knob-stateless_http} If stickiness is a cost you refuse to pay, there is exactly one thing you can change. @@ -98,7 +98,7 @@ Two things about it matter more than what it does. So it is a real trade, and it only exists on the legacy leg: **sessionful and sticky, or stateless and one-directional.** If your tools never call back into the client, `stateless_http=True` is free and you should take it. If they do, keep the sessions and keep the routing sticky. -## Where your code actually forks +## Where your code actually forks {#where-your-code-actually-forks} Almost nowhere. @@ -117,7 +117,7 @@ Over HTTP, neither call reaches the other era's clients. To tell everyone, call Two lines, no `if`, no version check, and you are done. That is the entire list of things a handler does differently because a legacy client exists. -## Recap +## Recap {#recap} * One `streamable_http_app()` serves both protocol eras. The SDK routes each request by its `MCP-Protocol-Version` header; there is nothing to configure and no era knob to look for. * A legacy client costs you a session: an in-process `Mcp-Session-Id` record with no distributed store behind it. More than one worker means **sticky routing**, or the wrong worker answers `404 Session not found`. **[Deploy & scale](deploy.md)** has the multi-worker story. diff --git a/docs/run/opentelemetry.md b/docs/run/opentelemetry.md index c36f5ceaa4..efe6c553a7 100644 --- a/docs/run/opentelemetry.md +++ b/docs/run/opentelemetry.md @@ -1,4 +1,4 @@ -# OpenTelemetry +# OpenTelemetry {#opentelemetry} Your server is already traced. You don't have to add anything. @@ -13,7 +13,7 @@ call `MCPServer(...)`. That is a complete, traced server. Call `search_books` and a span is created for it. The same is true for the low-level `Server`: the tracing lives on both. -## What you get +## What you get {#what-you-get} Every inbound message becomes a `SERVER` span named after the method and its target. So a `tools/call` for `search_books` is the span `tools/call search_books`, and a bare `tools/list` @@ -38,7 +38,7 @@ A `prompts/get` span gets `gen_ai.prompt.name` in the same spirit. The list meth Those GenAI attributes are the reason a tracing UI groups your tool calls the way it groups any other agent's. You get that grouping for free, with no extra code. -## It costs nothing until you want it +## It costs nothing until you want it {#it-costs-nothing-until-you-want-it} Here is the part that makes "on by default" a comfortable default. @@ -60,7 +60,7 @@ creating lights up. Your server code does not change. Not one line. configuration for you: `pip install logfire`, `logfire.configure()`, and your MCP spans show up in the live view. It is built on OpenTelemetry, so anything below applies to it too. -## Traces that cross the wire +## Traces that cross the wire {#traces-that-cross-the-wire} A trace is most useful when it follows a request from the client into the server, in one connected picture. @@ -75,7 +75,7 @@ If the inbound message carries no trace context, for example a request from a cl the SDK, the server span simply parents to whatever span is already current on the server, rather than starting a brand-new orphan trace. -## Turning it off +## Turning it off {#turning-it-off} Tracing is a middleware, the first one on your server's list. If you really want a server that emits no spans, take it off: @@ -94,7 +94,7 @@ mcp._lowlevel_server.middleware[:] = [ you should expect to change. You almost never need this: with no exporter installed the spans are free, so the usual answer is to leave them on and not install an exporter. -## Recap +## Recap {#recap} * Every `MCPServer` and every low-level `Server` emits one `SERVER` span per inbound message, out of the box. You write nothing. diff --git a/docs/servers/completions.md b/docs/servers/completions.md index 1d7eca8e2c..0df0570c80 100644 --- a/docs/servers/completions.md +++ b/docs/servers/completions.md @@ -1,10 +1,10 @@ -# Completions +# Completions {#completions} A client building a UI on top of your server wants to autocomplete argument values as the user types: language names, repository names, file paths. **Completions** are how your server supplies those suggestions. -## Something worth completing +## Something worth completing {#something-worth-completing} Completions apply to exactly two things: the arguments of a **prompt** and the parameters of a **resource template**. So start with a server that has one of each: @@ -17,7 +17,7 @@ Nothing here is about completions yet. * `review_code` takes a `language`. A user shouldn't have to guess which spellings you accept. * `github_repo` takes an `owner` and a `repo`. Free-text boxes for both make a bad form. -## The completion handler +## The completion handler {#the-completion-handler} Add **one** function decorated with `@mcp.completion()`: @@ -37,7 +37,7 @@ Add **one** function decorated with `@mcp.completion()`: `argument.value` is the prefix the user has typed. The SDK does **not** filter for you: whatever you put in `values` is what the UI shows. The `startswith` is yours to write. -### Try it +### Try it {#try-it} Drive it with the in-memory `Client` from **[Testing](../get-started/testing.md)**. Call `client.complete()` with `ref=PromptReference(name="review_code")` and @@ -64,7 +64,7 @@ result.completion.values # [] `None` means *"no suggestions"*, never an error. A UI falls back to a plain text box. -## A capability you never declared +## A capability you never declared {#a-capability-you-never-declared} Registering the handler is the declaration. Connect a client and look: @@ -85,7 +85,7 @@ You didn't list `completions` anywhere. The SDK saw the handler and declared the And `client.server_capabilities.completions` is `None`. That's the point of the capability: a well-behaved client checks it and never sends the request you can't answer. -## Dependent arguments +## Dependent arguments {#dependent-arguments} `github://repos/{owner}/{repo}` has two parameters, and the useful values for `repo` depend on which `owner` was picked first. @@ -113,7 +113,7 @@ Drop `context_arguments=` and the same call returns `[]`. The handler can't know `Completion` also takes `total=` and `has_more=`. Set them when `values` is a slice of a longer list, so a UI can show *"and 200 more"*. Most handlers never need them. -## Recap +## Recap {#recap} * Completions are suggestions for **prompt arguments** and **resource template parameters**. Nothing else. * `@mcp.completion()` registers the one handler. It's `async def (ref, argument, context) -> Completion | None`. diff --git a/docs/servers/handling-errors.md b/docs/servers/handling-errors.md index 4262f586a7..d2827c32aa 100644 --- a/docs/servers/handling-errors.md +++ b/docs/servers/handling-errors.md @@ -1,4 +1,4 @@ -# Handling errors +# Handling errors {#handling-errors} A tool can fail in two ways, and the SDK treats them very differently. @@ -6,7 +6,7 @@ Raise an ordinary exception and the **model** sees it. Raise `MCPError` and the This page is about choosing. -## An error the model can fix +## An error the model can fix {#an-error-the-model-can-fix} Take a tool that looks something up, and let the lookup miss: @@ -37,7 +37,7 @@ The model is the one calling your tool. It picked the arguments. So a tool error model (and to every client UI) it looks like the tool worked and that string was the answer. `raise`. The flag is the signal. -## An error the model cannot fix +## An error the model cannot fix {#an-error-the-model-cannot-fix} Now swap `ValueError` for `MCPError`. @@ -68,7 +68,7 @@ Now swap `ValueError` for `MCPError`. The first version handed the model a sentence it could react to. This one hands it nothing. For `get_author` that is strictly worse, which is the point of the next section. -## Which one to raise +## Which one to raise {#which-one-to-raise} The two paths answer two different questions. @@ -84,7 +84,7 @@ By that test, the second version of `get_author` made the wrong choice: a better `data` payload. Whatever you put in them is what the client receives: the SDK forwards a raised `MCPError` verbatim instead of sanitising it. -## A resource that doesn't exist +## A resource that doesn't exist {#a-resource-that-doesnt-exist} Resources draw the same line, and ship one named exception for the common case. @@ -106,7 +106,7 @@ When it can't, raise `ResourceNotFoundError`. The SDK turns it into the protocol Notice there is no `is_error=True` half-result here. A resource read either returns contents or fails: resources have only the protocol path. Templates and everything else about resources live in **[Resources](resources.md)**. -## Errors you never raise +## Errors you never raise {#errors-you-never-raise} A bad argument never reaches your function. @@ -120,7 +120,7 @@ It means a whole class of `raise` statements you don't write: don't re-validate back into a traceback: by the time that flag could act, your exception is already the `is_error=True` result. Assert on the result. **[Testing](../get-started/testing.md)** covers the pattern. -## Recap +## Recap {#recap} * Raise **any exception** in a tool -> the call returns `is_error=True` with your message in `content`. The model reads it and can retry. This is the default. * Raise **`MCPError`** -> the call itself fails with a JSON-RPC error. The model sees nothing; the host deals with it. `code`, `message`, and `data` survive intact. diff --git a/docs/servers/index.md b/docs/servers/index.md index 72eda00a4f..22d49925f4 100644 --- a/docs/servers/index.md +++ b/docs/servers/index.md @@ -1,4 +1,4 @@ -# Servers +# Servers {#servers} An `MCPServer` exposes three primitives to a connected client. They differ by who decides to use them: diff --git a/docs/servers/media.md b/docs/servers/media.md index 8655b77f12..b73e2c1904 100644 --- a/docs/servers/media.md +++ b/docs/servers/media.md @@ -1,10 +1,10 @@ -# Media +# Media {#media} Text is not the only thing a tool can return. The SDK ships two helpers for binary results (**`Image`** and **`Audio`**) and an **`Icon`** type for giving your server, tools, resources, and prompts a face in the client's UI. -## Returning an image +## Returning an image {#returning-an-image} Annotate the return type as `Image`, point it at a file, and return it: @@ -33,7 +33,7 @@ Two things to notice: that a plain `str` result becomes (**[Tools](tools.md)**). A tool result is a list of content blocks; `Image` and `Audio` are the shortest way to produce the two binary kinds. -### Try it +### Try it {#try-it} Drop any PNG next to `server.py`, name it `logo.png`, and run: @@ -43,7 +43,7 @@ uv run mcp dev server.py Open the **Tools** tab and call `logo`. The result is not a string: it is an `image` content block, and the Inspector renders your picture. Everything between the file on disk and the pixels on screen was the SDK. -## Returning audio +## Returning audio {#returning-audio} `Audio` is the same shape. Keep `logo.png` where it was, and put any WAV beside it as `chime.wav`: @@ -60,7 +60,7 @@ result.structured_content # None Same deal: a file on disk in, base64 and a MIME type out, no output schema. -## Bytes or a file +## Bytes or a file {#bytes-or-a-file} Both helpers also accept `data=` (raw bytes) instead of `path=`. That is the mode for bytes that never came from a file of their own — a database column, an HTTP response, something Pillow just drew: @@ -81,7 +81,7 @@ A suffix it doesn't recognise falls back to `application/octet-stream`. `Audio` from MP3 bytes that way and the client is told `mime_type="audio/wav"`, then faithfully fails to decode it. When you pass `data=`, pass `format=`. -## Icons +## Icons {#icons} An `Icon` is metadata, not content. It doesn't carry the image; it points at one with a URI, and a client may fetch it and show it next to your server's name, a tool, a resource, or a prompt. @@ -95,7 +95,7 @@ An `Icon` is metadata, not content. It doesn't carry the image; it points at one The same `icons=[...]` keyword is accepted by `MCPServer(...)`, `@mcp.tool()`, `@mcp.resource()`, and `@mcp.prompt()`. -### Where a client sees them +### Where a client sees them {#where-a-client-sees-them} Icons travel with whatever they decorate. The server's arrive when the client connects, on `client.server_info` (optional on 2026-era connections, so narrow it first): @@ -106,7 +106,7 @@ client.server_info.icons # [Icon(src="https://example.com/brand-kit.png", mime_ A tool's icons are on the `Tool` object from `tools/list`, a resource's on the `Resource` from `resources/list`, a prompt's on the `Prompt` from `prompts/list`. The field is always called `icons`. -## Recap +## Recap {#recap} * Return an `Image` or `Audio` from a tool and the client receives an `ImageContent` / `AudioContent` block: your bytes base64-encoded, with a MIME type. * Build one from a `path=` and let the suffix decide the MIME type, or from in-memory `data=` plus an explicit `format=`. diff --git a/docs/servers/prompts.md b/docs/servers/prompts.md index c49860dfd6..8829535a67 100644 --- a/docs/servers/prompts.md +++ b/docs/servers/prompts.md @@ -1,4 +1,4 @@ -# Prompts +# Prompts {#prompts} A **prompt** is a message template the user picks. @@ -6,7 +6,7 @@ Tools are for the model. A prompt is the opposite: the user chooses one from a m You declare one by putting `@mcp.prompt()` on a function that returns the text. -## Your first prompt +## Your first prompt {#your-first-prompt} ```python title="server.py" hl_lines="6-9" --8<-- "docs_src/prompts/tutorial001.py" @@ -32,7 +32,7 @@ That is what a client gets back from `prompts/list`: There is no JSON Schema here. Prompt arguments are a flat list of **named string values**: a form a person fills in, not a payload a model constructs. -### Rendering it +### Rendering it {#rendering-it} The client renders the template with `prompts/get`, passing the arguments. Your function runs and the `str` you return becomes **one user message**: @@ -65,7 +65,7 @@ That is the entire life of a prompt: listed by name, rendered on demand, dropped There is no tool-style error result to hand back to a model, because no model is in the loop: the call raises. The reason (`Missing required arguments: {'code'}`) lands in your server's log. -### Try it +### Try it {#try-it} Run the server with the MCP Inspector: @@ -75,7 +75,7 @@ uv run mcp dev server.py Open the **Prompts** tab and select `review_code`. The Inspector draws a form with one required `code` field. Fill it in, render it, and you get back exactly the user message above. -## More than one message +## More than one message {#more-than-one-message} A code review is one message. A debugging session is a conversation, and a prompt can seed the whole thing. @@ -107,7 +107,7 @@ Rendering `debug_error` now produces three messages, in order: Notice the last one. Pre-filling an `assistant` turn is how you steer the model's *next* reply without making the user type the steering themselves. -## Titles and argument descriptions +## Titles and argument descriptions {#titles-and-argument-descriptions} `review_code` is a function name, not a label. Give the client something better to put on the button, and describe each argument so the form explains itself: @@ -138,7 +138,7 @@ The `prompts/list` entry now carries everything a client needs to draw a good fo docstring-as-description, same `Annotated`/`Field`. The only things that change are who triggers it (the user) and where the result goes (into the conversation). -## Recap +## Recap {#recap} * `@mcp.prompt()` on a function makes it a prompt. Name from the function, description from the docstring. * Prompts are **user-controlled**: the client lists them, the user picks one and fills in the arguments. diff --git a/docs/servers/resources.md b/docs/servers/resources.md index 407f1d9ae1..672aa49c21 100644 --- a/docs/servers/resources.md +++ b/docs/servers/resources.md @@ -1,4 +1,4 @@ -# Resources +# Resources {#resources} A **resource** is data you expose for the application to read. @@ -6,7 +6,7 @@ That's the split. A tool is something the **model** decides to call. A resource You declare one by putting `@mcp.resource(uri)` on a plain Python function. -## Your first resource +## Your first resource {#your-first-resource} ```python title="server.py" hl_lines="6-8" --8<-- "docs_src/resources/tutorial001.py" @@ -42,7 +42,7 @@ result.contents # [TextResourceContents(uri="config://app", mime_type="text/pla `resources/read`, and only for the URI that was asked for. Expose a thousand resources and you pay for the ones somebody opens. -### Try it +### Try it {#try-it} Run the server with the MCP Inspector: @@ -52,7 +52,7 @@ uv run mcp dev server.py Open the URL it prints and go to the **Resources** tab. `config://app` is in the list with its description. Click it and the Inspector reads it: there are your two lines of config. -## Resource templates +## Resource templates {#resource-templates} One URI per record doesn't scale. Put a **placeholder** in the URI and a matching parameter on the function: @@ -96,7 +96,7 @@ The placeholder syntax is [RFC 6570](https://datatracker.ietf.org/doc/html/rfc65 `get_user_profile` can also take a parameter annotated `Context`. The SDK injects it without ever treating it as a URI parameter, and **[The Context](../handlers/context.md)** page covers what it gives you. -## What you return +## What you return {#what-you-return} You're not limited to `str`. Give each resource a `mime_type` and return whatever fits: @@ -129,7 +129,7 @@ The same rule applies to anything else JSON-serialisable: a list, a Pydantic mod A client can also **subscribe** to a resource and be notified when it changes; that's the client's half of the story and it lives in **[The Client](../client/index.md)**. -## Recap +## Recap {#recap} * `@mcp.resource(uri)` on a function makes it a resource. The URI is the address, the return value is the content, the docstring is the description. * A `{placeholder}` in the URI makes it a **template**: it's listed under `resources/templates/list` and one function serves every URI that matches. diff --git a/docs/servers/structured-output.md b/docs/servers/structured-output.md index a146e01442..81c2ee3ee9 100644 --- a/docs/servers/structured-output.md +++ b/docs/servers/structured-output.md @@ -1,4 +1,4 @@ -# Structured Output +# Structured Output {#structured-output} A tool that returns a plain `str` produces the result twice: as text in `content`, and as `{"result": "..."}` in `structured_content`. @@ -6,7 +6,7 @@ This page is about that second channel: where it comes from, every shape it can The short version: **the return type annotation is the output schema**. You already wrote it. -## The output schema +## The output schema {#the-output-schema} ```python title="server.py" hl_lines="9" --8<-- "docs_src/structured_output/tutorial001.py" @@ -36,7 +36,7 @@ result.structured_content # {"result": 17} Every scalar gets the same wrapper: `str`, `int`, `float`, `bool`, `bytes`, `None`. -## Two channels +## Two channels {#two-channels} Why send the same value twice? @@ -46,7 +46,7 @@ Why send the same value twice? You return one Python value. The SDK fills in all three. -## Return a model +## Return a model {#return-a-model} Declare the shape as a Pydantic `BaseModel` and return an instance: @@ -92,7 +92,7 @@ Notice the `Field(description=...)` on `temperature` and `humidity` landed in th response, serialized and documented for you. The only difference is that here the return annotation is the whole declaration. -## A `TypedDict` +## A `TypedDict` {#a-typeddict} Not every shape deserves a class. A `TypedDict` produces the same schema: @@ -102,7 +102,7 @@ Not every shape deserves a class. A `TypedDict` produces the same schema: A `TypedDict` is a plain `dict` at runtime, so that is what you build and return. The schema, the validation, and `structured_content` are identical to the `BaseModel` version (minus the descriptions, which `TypedDict` has no place for). -## A dataclass +## A dataclass {#a-dataclass} Dataclasses work too, and so does any ordinary class whose attributes have type hints. The SDK builds a Pydantic model out of the annotations behind the scenes. @@ -112,7 +112,7 @@ Dataclasses work too, and so does any ordinary class whose attributes have type Three spellings, one schema. Use whichever your codebase already has. -## Lists +## Lists {#lists} A `list[...]` isn't a JSON object either, so it gets the `{"result": ...}` wrapper, with your item type as a `$defs` reference inside it: @@ -147,7 +147,7 @@ Ask for a two-day forecast and `structured_content` is `{"result": [{...}, {...} `tuple[...]`, unions, and `Optional[...]` are wrapped the same way. -## Dictionaries +## Dictionaries {#dictionaries} `dict[str, ...]` is the one generic that already *is* a JSON object, so it isn't wrapped: @@ -169,7 +169,7 @@ result.structured_content # {"London": 16.2, "Reykjavik": 4.4} The keys must be `str`. A `dict[int, float]` can't be a JSON object, so it falls back to the `{"result": ...}` wrapper. -## Validation +## Validation {#validation} `output_schema` is not documentation. Whatever your function returns is **validated against it** before it leaves the server. @@ -196,7 +196,7 @@ The annotation promises `WeatherData`. The upstream response stopped sending `hu Returning a plain `dict` from a `-> WeatherData` tool is fine, by the way. That's exactly what `json.loads` produced. Validation is on the value, not on the Python type. -## Opting out +## Opting out {#opting-out} Sometimes the return annotation is for your type checker, not for the protocol. Pass `structured_output=False` and the tool is text-only: @@ -208,7 +208,7 @@ No `output_schema`, no wrapping, no validation. `structured_content` is `None` a The opposite, `structured_output=True`, turns the automatic detection into a requirement: a tool whose return type can't produce a schema raises at import time instead of falling back to text. -## A class without type hints +## A class without type hints {#a-class-without-type-hints} There is one way to end up unstructured without asking for it: return a class that has **no annotations on its body**. @@ -234,7 +234,7 @@ There is one way to end up unstructured without asking for it: return a class th Need full control (building the `CallToolResult` yourself, or attaching `_meta` that the application can see but the model can't)? That's **[The low-level Server](../advanced/low-level-server.md)**. -## Recap +## Recap {#recap} * The **return type annotation** is the output schema. It's published in `tools/list` as `output_schema`. * Scalars, lists, tuples and unions are wrapped in `{"result": ...}`. Models, `TypedDict`s, dataclasses, annotated classes and `dict[str, ...]` are objects already and stay as they are. diff --git a/docs/servers/tools.md b/docs/servers/tools.md index 5b728cb782..430b4f182e 100644 --- a/docs/servers/tools.md +++ b/docs/servers/tools.md @@ -1,10 +1,10 @@ -# Tools +# Tools {#tools} A **tool** is a function the model can call. You declare one by putting `@mcp.tool()` on a plain Python function. That's the whole API. -## Your first tool +## Your first tool {#your-first-tool} ```python title="server.py" hl_lines="6-8" --8<-- "docs_src/tools/tutorial001.py" @@ -16,7 +16,7 @@ Look at what you wrote. There are no schemas, no JSON, no protocol, just a funct * The **description** the model sees is the docstring: `Search the catalog by title or author.` * The **arguments** the model is allowed to pass come from the type hints: `query: str` and `limit: int`. -### The input schema +### The input schema {#the-input-schema} From those type hints the SDK generates a JSON Schema and sends it to the client during `tools/list`: @@ -38,7 +38,7 @@ Both arguments are in `required` because neither has a default. You'll fix that Type hints aren't documentation here. They are **the contract**. If a client sends `"limit": "ten"`, the SDK rejects it before your function ever runs. -### What the model gets back +### What the model gets back {#what-the-model-gets-back} Call the tool with `{"query": "dune", "limit": 5}` and the result has two parts: @@ -51,7 +51,7 @@ result.structured_content # {'result': "Found 3 books matching 'dune' (showing Don't worry about `structured_content` yet. Return real Python objects from your tools and the right thing happens; the **[Structured Output](structured-output.md)** page is all about it. -### Try it +### Try it {#try-it} Run the server with the MCP Inspector: @@ -63,7 +63,7 @@ Open the URL it prints, go to the **Tools** tab, and call `search_books`. The Inspector renders a form with a required `query` text field and a required `limit` number field. It built that form from your type hints. So will every other MCP client. -## Optional arguments +## Optional arguments {#optional-arguments} Give a parameter a default value and it stops being required. That's it. It's just Python. @@ -87,7 +87,7 @@ The schema follows: `limit` left `required` and gained `"default": 10`. A client that omits it gets `10`, exactly as Python would. -## Richer schemas with `Field` +## Richer schemas with `Field` {#richer-schemas-with-field} Type hints get you a long way, but sometimes you want to *describe* an argument, or constrain it. @@ -118,7 +118,7 @@ Three new things, all on the parameters: If you've used FastAPI or Pydantic, you already know all of this. It's the same `Field`, the same `Annotated`, the same validation. There is nothing MCP-specific to learn here. -## A model as a parameter +## A model as a parameter {#a-model-as-a-parameter} When a tool takes more than a couple of arguments, group them into a Pydantic model: @@ -130,7 +130,7 @@ The `Book` schema is nested inside the tool's input schema (as a `$defs` referen You can mix and match: plain parameters next to model parameters, nested models, lists of models. It's Pydantic all the way down. -## `async def` +## `async def` {#async-def} If a tool does I/O (calls an API, reads a file, queries a database), declare it `async def` and `await` inside it. The SDK awaits it. @@ -138,7 +138,7 @@ A plain `def` tool works too: the SDK runs it in a thread so it never blocks the There is nothing else to configure. -## Names, titles, and annotations +## Names, titles, and annotations {#names-titles-and-annotations} Everything the SDK infers, you can override in the decorator: @@ -160,7 +160,7 @@ A well-behaved client uses them to decide things like *"do I need to ask the use `name=` and `description=` are also accepted by `@mcp.tool()` if you don't want to derive them from the function name and docstring. Most of the time you do. -## Recap +## Recap {#recap} * `@mcp.tool()` on a function makes it a tool. Name from the function, description from the docstring. * Type hints **are** the input schema. Defaults make arguments optional. diff --git a/docs/servers/uri-templates.md b/docs/servers/uri-templates.md index 406a8fda6a..f944b229eb 100644 --- a/docs/servers/uri-templates.md +++ b/docs/servers/uri-templates.md @@ -1,4 +1,4 @@ -# URI templates and path safety +# URI templates and path safety {#uri-templates-and-path-safety} This is the reference for the URI-template syntax that [`@mcp.resource`](resources.md) accepts, and for the @@ -15,7 +15,7 @@ outside the directory you intend to serve. For the protocol-level details (message formats, lifecycle, pagination) see the [MCP resources specification](https://modelcontextprotocol.io/specification/latest/server/resources). -## The full operator set +## The full operator set {#the-full-operator-set} The plain placeholder, `{user_id}`, is the one **[Resources](resources.md)** introduces. There are four more operator forms; here they are on one server so you can see them next to @@ -28,7 +28,7 @@ each other: Each highlighted decorator is a different way of carving up the URI. The sections below walk them top to bottom. -### Simple expansion: `{name}` +### Simple expansion: `{name}` {#simple-expansion-name} `books://{isbn}` is the plain, everyday form. The placeholder maps to the `isbn` parameter, so a client reading `books://978-0441172719` calls @@ -38,7 +38,7 @@ A plain `{name}` stops at the first `/`. `books://978/extra` does not match because the slash after `978` ends the capture and `/extra` is left over. -### Type conversion +### Type conversion {#type-conversion} Extracted values arrive as strings, but you can declare a more specific type and the SDK will convert. `orders://{order_id}` lands in a function @@ -46,7 +46,7 @@ whose parameter is `order_id: int`, so reading `orders://12345` calls `get_order(12345)`, not `get_order("12345")`. The handler does arithmetic on it (`order_id + 1`) without a cast. -### Multi-segment paths: `{+name}` +### Multi-segment paths: `{+name}` {#multi-segment-paths-name} To capture a value that contains slashes, use `{+name}`. With `manuals://{+path}`: @@ -57,7 +57,7 @@ To capture a value that contains slashes, use `{+name}`. With Reach for `{+name}` whenever the value is hierarchical: filesystem paths, nested object keys, URL paths you're proxying. -### Query parameters: `{?a,b,c}` +### Query parameters: `{?a,b,c}` {#query-parameters-abc} `reviews://{isbn}{?limit,sort}` puts `limit` and `sort` after the `?`. The path identifies *which* book; the query tunes *how* you read it. @@ -67,14 +67,14 @@ ignored, and omitted params fall through to your function defaults. So `reviews://978-0441172719` uses `limit=10, sort="newest"`, and `reviews://978-0441172719?sort=top` overrides only `sort`. -### Path segments as a list: `{/name*}` +### Path segments as a list: `{/name*}` {#path-segments-as-a-list-name} If you want each path segment as a separate list item rather than one string with slashes, use `{/name*}`. With `shelves://browse{/path*}`, a client reading `shelves://browse/fiction/sci-fi` calls `browse_shelf(["fiction", "sci-fi"])`. -### Template reference +### Template reference {#template-reference} The most common patterns: @@ -89,7 +89,7 @@ The most common patterns: | `{?a,b}` | `?a=1&b=2` | `"1"`, `"2"` | | `{/path*}` | `/a/b/c` | `["a", "b", "c"]` | -### What the parser rejects +### What the parser rejects {#what-the-parser-rejects} A few template shapes are caught up front rather than failing on the first request. `@mcp.resource` parses the template when the decorator @@ -119,13 +119,13 @@ first request that omits it. `reviews://{isbn}{?limit,sort}` in the server above is the well-formed version: `limit` and `sort` both carry defaults. -## Security +## Security {#security} Template parameters come from the client. If they flow into filesystem or database operations unchecked, values like `../../etc/passwd` can resolve outside the directory you intended to serve. -### What the SDK checks by default +### What the SDK checks by default {#what-the-sdk-checks-by-default} Before your handler runs, the SDK rejects any parameter that: @@ -153,7 +153,7 @@ regardless of how it was encoded in the URI (`../etc`, `..%2Fetc`, it would for a URI that matches no template at all, and `read_manual` never runs. -### Filesystem handlers: use safe_join +### Filesystem handlers: use safe_join {#filesystem-handlers-use-safe_join} The built-in checks stop the common cases but can't know your sandbox boundary. For filesystem access, use `safe_join` to resolve the path @@ -168,7 +168,7 @@ tricks that a simple string check would miss. If the resolved path escapes `DOCS_ROOT`, it raises `PathEscapeError`, which surfaces to the client as a `ResourceError`. -### When the defaults get in the way +### When the defaults get in the way {#when-the-defaults-get-in-the-way} Sometimes the checks block legitimate values. A catalog-import tool might intentionally receive an absolute path, or a parameter might be a @@ -204,14 +204,14 @@ These checks are a heuristic pre-filter; for filesystem access, error response. See **[Handling errors](handling-errors.md)** for the difference between a protocol error and a tool error. -## Resources on the low-level Server +## Resources on the low-level Server {#resources-on-the-low-level-server} If you're building on the low-level `Server` (see **[The low-level Server](../advanced/low-level-server.md)**), you register handlers for the `resources/list` and `resources/read` protocol methods directly. There's no decorator; you return the protocol types yourself. -### Static resources +### Static resources {#static-resources} For fixed URIs, keep a registry and dispatch on exact match: @@ -223,7 +223,7 @@ The list handler tells clients what's available; the read handler serves the content. Check your registry first, fall through to templates (below) if you have any, then raise for anything else. -### Templates +### Templates {#templates} The template engine `MCPServer` uses lives in `mcp.shared.uri_template` and works on its own. You get the same parsing and matching; you wire @@ -252,7 +252,7 @@ Three things are happening in the highlighted lines: back the original template string, so the listing and the matcher share one source of truth. -## Recap +## Recap {#recap} * `{name}` matches one segment; `{+name}` keeps the slashes; `{?a,b}` pulls from the query string; `{/name*}` splits segments into a list. diff --git a/docs/translations.md b/docs/translations.md new file mode 100644 index 0000000000..694466d6f9 --- /dev/null +++ b/docs/translations.md @@ -0,0 +1,24 @@ +# Translations {#translations} + +This documentation is written in English. To make it useful to more people, we also publish it in a few other languages. Those editions are machine-translated, and this page explains what that means for you and how to help improve them. + +## What's available {#whats-available} + +Translated documentation is currently a **preview**, published in four languages: Simplified Chinese, Japanese, Korean and Brazilian Portuguese. Pick one from the language switcher at the top of any page. + +Every translated page opens with a note saying it was machine-translated and linking to its English original. The API reference is not translated: every language site links to the single English one. + +## English is the source of truth {#english-is-the-source-of-truth} + +If a translated page and its English original disagree, the English page is correct. Two situations are called out on the page itself: + +- A page that hasn't been translated yet shows the English text, with a note saying so. +- A page whose English original changed after it was translated carries a warning that it may be behind, until the translation catches up. + +## How the translations are made {#how-the-translations-are-made} + +Translated pages are 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 {#reporting-a-translation-problem} + +Found a wrong term, an awkward sentence, or a translation that says something the English doesn't? [Open a translation issue](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=translation.yaml) with the language, the page and the passage; reports from native speakers are especially valuable, and maintainers track these with the `translation` label. If you know the fix, propose it directly as a pull request against that language's style guide or glossary under [`i18n/languages/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/i18n/languages) — the correction then shows up on 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/docs/troubleshooting.md b/docs/troubleshooting.md index 75a6652ecc..8b427cd6d8 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,4 +1,4 @@ -# Troubleshooting +# Troubleshooting {#troubleshooting} Every heading on this page is the exact text of an error the SDK produces, followed by what it means and the one-move fix. Find the last line of your traceback (or your server log) here with your browser's find-in-page, and read only that entry. @@ -10,7 +10,7 @@ Several entries run against this one server. One tool and one templated resource The errors this page quotes are real: the SDK's own test suite reproduces every one of them. -## `ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)` +## `ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)` {#exceptiongroup-unhandled-errors-in-a-taskgroup-1-sub-exception} This is not an MCP error. It is anyio noise, and your real error is the **last line** of the paste. @@ -56,7 +56,7 @@ async def main() -> None: down this page) escapes from `async with` itself, so there is no "inside" to catch it in. For those, read the bottom of the group. -## `RuntimeError: Client must be used within an async context manager` +## `RuntimeError: Client must be used within an async context manager` {#runtimeerror-client-must-be-used-within-an-async-context-manager} `Client(...)` only builds the object. Nothing connects until `async with`, so every method refuses: @@ -76,7 +76,7 @@ async def main() -> None: `__aexit__` is the disconnection, which is why there is no `client.close()` to forget. **[Testing](get-started/testing.md)** is built on exactly this pattern. -## `Error executing tool : ` and `Unknown tool: ` +## `Error executing tool : ` and `Unknown tool: ` {#error-executing-tool-name-message-and-unknown-tool-name} You are reading a **result**, not an exception. `call_tool` did not raise, and it never will for a failing tool. @@ -92,7 +92,7 @@ result.structured_content # None The fix is in your client: **check `result.is_error`**. A `try/except` around `call_tool` catches none of these, because there is nothing to catch. This is deliberate, and it is the single most useful thing on this page to internalise: the *model* chose the call, so the model gets the message and a chance to try again. **[Handling errors](servers/handling-errors.md)** is the whole story, including the `MCPError` path that *does* 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` {#typeerror-the-tool-decorator-was-used-incorrectly-did-you-forget-to-call-it-use-tool-instead-of-tool} You wrote `@mcp.tool` instead of `@mcp.tool()`. `tool()` is a decorator *factory*: without the parentheses, Python hands your function to its `name=` parameter. @@ -115,7 +115,7 @@ Add the parentheses. `@mcp.resource(...)` and `@mcp.prompt()` say the same thing tools, has this shape: run `python server.py` yourself and read the traceback. A type checker also catches it: a function is not a valid `name=`. -## `Tool already exists: ` +## `Tool already exists: ` {#tool-already-exists-name} Two registrations used the same tool name. The **first** one wins, the second is silently dropped, and this warning in the *server log* is the only signal: @@ -129,7 +129,7 @@ WARNING mcp.server.mcpserver.tools.tool_manager: Tool already exists: forecast `tools/list` reports one `forecast`, and it is `forecast_today`. Rename one of them. `MCPServer(..., warn_on_duplicate_tools=False)` silences the warning without changing the outcome, so leave it on. Resources and prompts have the same rule and the same log line (`Resource already exists:`, `Prompt already exists:`). -## My host lists zero tools +## My host lists zero tools {#my-host-lists-zero-tools} There is no error string for this, which is exactly why it is hard to search. The SDK never drops a registered tool from `tools/list`, so work outward: @@ -141,7 +141,7 @@ There is no error string for this, which is exactly why it is hard to search. Th An "invalid" tool name is *not* on that list: a non-conforming name logs a warning but the tool is registered and listed anyway. -## `MCPError: Server returned an error response` +## `MCPError: Server returned an error response` {#mcperror-server-returned-an-error-response} The server refused the HTTP request outright, with a body that is not JSON-RPC, so the python `Client` has nothing better to show you than this stand-in. @@ -180,7 +180,7 @@ The fix is `transport_security=`. Allowlist the hostname you actually serve: **[Deploy & scale](run/deploy.md)** covers what each field means, the reverse-proxy case, and everything else that changes at deploy time. And `421 Misdirected Request` / `Invalid Host header`, right below, is the same failure seen from the other side. -## `421 Misdirected Request` / `Invalid Host header` +## `421 Misdirected Request` / `Invalid Host header` {#421-misdirected-request-invalid-host-header} This is `Server returned an error response`, seen from anything that is *not* the python `Client`: curl, a browser's network tab, a reverse proxy's access log, or another SDK. @@ -206,7 +206,7 @@ The fix is the same `transport_security=TransportSecuritySettings(allowed_hosts= **[Deploy & scale](run/deploy.md)** has the full treatment, including when switching the check off is the honest configuration. -## `RuntimeError: Task group is not initialized. Make sure to use run().` +## `RuntimeError: Task group is not initialized. Make sure to use run().` {#runtimeerror-task-group-is-not-initialized-make-sure-to-use-run} Your MCP app is mounted inside another ASGI app, and nothing started its **session manager**. @@ -242,7 +242,7 @@ app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lif * `StreamableHTTPSessionManager .run() can only be called once per instance. Create a new instance if you need to run again.` The manager is single-use; entering the same app's lifespan twice hits it. * `mcp.session_manager` only exists **after** `streamable_http_app()` has been called, so build the routes first and touch the manager only inside the lifespan. -## `MCPError: Session not found` +## `MCPError: Session not found` {#mcperror-session-not-found} The server does not recognise the `Mcp-Session-Id` your client sent, almost always because the server **restarted** (or you were routed to a different instance). Sessions live in that one process's memory. @@ -258,13 +258,13 @@ If it happens *without* a restart, you are running more than one worker without For the server operator, the matching log line is `Rejected request with unknown or expired session ID: `. It is logged at `INFO`, so it is invisible at the usual `WARNING` threshold. Seeing it in bursts right after a deploy is normal; every connected client is reconnecting. -## `MCPError: Method not found` +## `MCPError: Method not found` {#mcperror-method-not-found} One side sent a JSON-RPC request the other has no handler for, and `e.error.data` names the method. The usual cause is an **era mismatch**: a method that exists in one protocol revision and not in the other, sent to a peer on the wrong one, such as a `2025`-era `resources/subscribe` arriving at a `2026-07-28` connection, or a `2026`-only `subscriptions/listen` sent by a client pinned to `mode="legacy"`. **[Protocol versions](protocol-versions.md)** is the map of which side speaks what, and the other honest cause (an optional capability you never registered a handler for) is on **[Completions](servers/completions.md)**. One thing does **not** produce this error, despite being a request the modern protocol removed: a tool calling `ctx.elicit()` on a `2026-07-28` connection. The server refuses to *send* that request at all, so what you get instead is `Cannot send 'elicitation/create': ...`, further down this 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 ''` {#mcperror-client-did-not-declare-the-form-elicitation-capability-required-by-resolver-name} Your server wants to ask the user something, and this client never said it can be asked. @@ -297,13 +297,13 @@ async def main() -> None: speak). A conforming SDK client cannot produce either, so if you see one, look at whatever is rewriting requests between your client and your server. -## `MCPError: Elicitation not supported` +## `MCPError: Elicitation not supported` {#mcperror-elicitation-not-supported} The same gap as `Client did not declare the form elicitation capability ...`, spelled by the paths that don't check up front: the server needed an elicitation answered, and the connected client registered no `elicitation_callback`. You see this one from `ctx.elicit()` on a legacy connection, and on any connection at all from a returned multi-round-trip question (**[Multi-round-trip requests](handlers/multi-round-trip.md)**) that reaches a client with no callback to answer it. The fix is identical: pass `elicitation_callback=` to `Client(...)`. There is no version of "the user wasn't asked" that your tool receives as a `decline`; a client that cannot be asked is a failed call, so design your tools for it. -## `MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests.` +## `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} Your handler tried to reach the client mid-request, on a connection whose call has no channel that can carry a request from the server. There are three server configurations that put a call there. @@ -348,7 +348,7 @@ Same question, same `elicitation_callback` on the client. The difference is unde channel exists there. **[Protocol versions](protocol-versions.md)** is the page on what each version has. -## `MCPError: Invalid or expired requestState` +## `MCPError: Invalid or expired requestState` {#mcperror-invalid-or-expired-requeststate} The server could not verify the `requestState` token your client echoed back, so it refused the round. @@ -395,13 +395,13 @@ mcp = MCPServer("Weather", request_state_security=RequestStateSecurity(keys=[key Do what it says. -## Still stuck? +## Still stuck? {#still-stuck} * If a message the SDK produced is not on this page, that is a documentation bug worth reporting on its own. * Search the [issue tracker](https://github.com/modelcontextprotocol/python-sdk/issues); most error strings appearing there are already someone's write-up. * Found nothing? [Open an issue](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml) with the full traceback, or ask in [#python-sdk-dev on the MCP Contributors Discord](https://discord.gg/6CSzBmMkjX). -## Recap +## Recap {#recap} * `ExceptionGroup: unhandled errors in a TaskGroup` is never the error. Read the **last line**; catching `MCPError` *inside* the `async with Client(...)` block skips the wrapping entirely. * `call_tool` does not raise for a failing tool. `Error executing tool ...` and `Unknown tool: ...` are results: check `result.is_error`. diff --git a/docs/whats-new.md b/docs/whats-new.md index bc1bfd6c56..e0d3d32ec8 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,4 +1,4 @@ -# What's new in v2 +# What's new in v2 {#whats-new-in-v2} Two things happened at once in v2. The **SDK was rebuilt**: a new engine under both the client and the server, a first-class `Client`, and a set of renames that a v1 codebase meets on its first import. And the **protocol moved**: v2 speaks the 2026-07-28 revision of MCP, which removes the connection handshake, the session, and every server-initiated request, without stranding the clients you already have. @@ -9,9 +9,9 @@ This page is the tour of both halves, one section per headline, each ending in t copy-paste install line. If anything in v2 breaks, surprises, or slows you down, [tell us](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml). -## The SDK: v1 to v2 +## The SDK: v1 to v2 {#the-sdk-v1-to-v2} -### `FastMCP` is now `MCPServer` +### `FastMCP` is now `MCPServer` {#fastmcp-is-now-mcpserver} The high-level server class was renamed, and its module with it. This is the first thing every v1 server hits, because the old import path is gone rather than deprecated: @@ -23,7 +23,7 @@ mcp = MCPServer("Demo") # v1: FastMCP("Demo") It is also, for a decorator-built server, most of the port. `@mcp.tool()`, `@mcp.resource()`, and `@mcp.prompt()` accept what they accepted in v1 (`@mcp.resource()` adds one optional `security=` keyword), and the input schema still comes from your type hints. Around the edges: everything under `mcp.server.fastmcp.*` now lives under `mcp.server.mcpserver.*`, `ctx.fastmcp` is `ctx.mcp_server`, `get_context()` is gone (declare a `ctx: Context` parameter instead), and the exception base `FastMCPError` is `MCPServerError`. The **[Migration Guide](migration.md#fastmcp-renamed-to-mcpserver)** has the import table. -### `Resolve`: the new way to ask the user for input +### `Resolve`: the new way to ask the user for input {#resolve-the-new-way-to-ask-the-user-for-input} Not everything a tool needs should come from the model. New in v2, a tool parameter annotated with `Resolve(fn)` is filled by a function you write instead, invisibly to the model, and that function can return `Elicit(...)` to put a question in front of the user. This is the preferred way to get anything from the client mid-call: the SDK carries the question over whichever mechanism the connection supports (a live elicitation request for a legacy client, a multi-round-trip on 2026-07-28), so one tool body serves both eras. **[Dependencies](handlers/dependencies.md)** is the page. @@ -33,7 +33,7 @@ Not everything a tool needs should come from the model. New in v2, a tool parame `InputRequiredResult` itself and drive the rounds by hand, which is also how sampling and roots requests travel at 2026-07-28 (**[Multi-round-trip requests](handlers/multi-round-trip.md)**). -### A first-class `Client` +### A first-class `Client` {#a-first-class-client} v1 handed you three nested layers: a transport context manager yielding raw streams, a `ClientSession` wrapped around them, and a hand-called `await session.initialize()`. v2 has one object: @@ -45,7 +45,7 @@ v1 handed you three nested layers: a transport context manager yielding raw stre **[The Client](client/index.md)** introduces it, **[Client transports](client/transports.md)** covers the three connection forms, **[Client callbacks](client/callbacks.md)** covers the callbacks themselves, and **[Testing](get-started/testing.md)** shows the in-memory pattern that replaces v1's `create_connected_server_and_client_session()` helper. -### The low-level `Server` was rebuilt, not renamed +### The low-level `Server` was rebuilt, not renamed {#the-low-level-server-was-rebuilt-not-renamed} If you work at the JSON-RPC layer, this is the "everything is different" part of v2. Here is the same one-tool server both ways; click the markers for what moved. @@ -112,19 +112,19 @@ Underneath, the v1 `BaseSession` receive loop was replaced by a dispatcher engin **[The low-level Server](advanced/low-level-server.md)** is the page; the **[Migration Guide](migration.md#lowlevel-server-decorator-based-handlers-replaced-with-constructor-on_-params)** walks every removed hook. If you never dropped below `MCPServer`, none of this touches you. -### The wire types moved to `mcp-types`, and every field is snake_case +### The wire types moved to `mcp-types`, and every field is snake_case {#the-wire-types-moved-to-mcp-types-and-every-field-is-snake_case} The protocol types now live in their own distribution, `mcp-types`. It depends on nothing but pydantic and typing-extensions, so a gateway, a proxy, or a code generator can consume MCP's wire shapes without installing an HTTP stack: such a project installs `mcp-types` and imports `mcp_types`. `mcp` itself depends on that package at an exact version and re-exposes it, so code that depends on the SDK keeps writing `import mcp.types as types` and `from mcp.types import Tool` (a permanent alias, every name the same object) and declares only its one real dependency, `mcp`. The rule of thumb: import through whichever package you actually depend on. On those types, every Python attribute is now snake_case: `result.is_error`, `tool.input_schema`, `listing.next_cursor`. The JSON on the wire is camelCase, exactly as before; only the attribute spelling changed. Two stricter defaults ride along: unknown fields are ignored instead of round-tripped (put extras in `_meta`), and both sides validate traffic against the protocol version they negotiated. See the **[Migration Guide](migration.md#field-names-changed-from-camelcase-to-snake_case)** for the rename table. -### Transport configuration moved to `run()` +### Transport configuration moved to `run()` {#transport-configuration-moved-to-run} `MCPServer(...)` is about what your server *is*: its name, its instructions, its lifespan, its auth. How it is *served* now belongs to `run()` and the app builders, which is where `host`, `port`, `stateless_http`, `json_response`, the endpoint paths, and `transport_security` went (`MCPServer("x", port=9000)` is a `TypeError`). The overloads are typed per transport, so your editor tells you which options `stdio` takes and which `streamable-http` takes. One removal worth knowing: `mount_path` is gone; mounting the ASGI app is the supported way to serve under a prefix. **[Running your server](run/index.md)** covers the options; **[Add to an existing app](run/asgi.md)** covers mounting. -### Behavior that changes without an import error +### Behavior that changes without an import error {#behavior-that-changes-without-an-import-error} The renames announce themselves. These do not: @@ -133,11 +133,11 @@ The renames announce themselves. These do not: * **Results are validated before they leave.** A hand-built `Tool` whose `input_schema` is `{}` now fails `tools/list` (the spec requires `"type": "object"`). Servers built on `@mcp.tool()` never see this; the SDK writes their schemas. * **Your client validates what it receives.** `list_tools()` and `call_tool()` check the server's answer against the negotiated protocol version, so a not-quite-valid server that v1's lenient parse tolerated now raises `pydantic.ValidationError`. If you connect to servers you do not control, expect to be the one who finds them; the **[Migration Guide](migration.md#client-validates-inbound-traffic-against-the-protocol-schema)** has the details. * **URI templates are real RFC 6570 now.** `{+path}`, `{?query}` and friends work, matching is exact instead of regex-loose, and path traversal in extracted values is rejected by default. Stricter templates fail at decoration time, not on the first request. **[URI templates](servers/uri-templates.md)**. -* **The streamable HTTP lifespan runs once**, at startup, and its state is shared by every session and request. In v1 it ran once per session, and once per request under `stateless_http=True`. Pools and caches built in a lifespan get dramatically cheaper; anything that acquired a per-connection resource there belongs in the handler body now. **[Lifespan](handlers/lifespan.md)**. +* **The Streamable HTTP lifespan runs once**, at startup, and its state is shared by every session and request. In v1 it ran once per session, and once per request under `stateless_http=True`. Pools and caches built in a lifespan get dramatically cheaper; anything that acquired a per-connection resource there belongs in the handler body now. **[Lifespan](handlers/lifespan.md)**. * **`mcp dev` and `mcp install` pin the environment they spawn** to your installed SDK version. Both commands run your server in a fresh `uv run --with ...` environment, which used to resolve `mcp` to the newest stable release rather than the version you are developing against. **[Migration Guide](migration.md#mcp-dev-and-mcp-install-pin-the-spawned-environment-to-your-sdk-version)**. * **The HTTP client is now `httpx2`, not `httpx`.** The dependency swap changes what your code catches and passes (`httpx2.AsyncClient`, `httpx2.ConnectError`), and it changes how TLS certificates are verified: `httpx2` validates through `truststore` against the operating system trust store instead of certifi's bundled CA list. Most environments never notice; a minimal container with no system CA store, or a private CA that only certifi's bundle knew about, starts failing the TLS handshake. Set `SSL_CERT_FILE`/`SSL_CERT_DIR` or pass `verify=ssl_context` to your client. **[Migration Guide](migration.md#httpx-and-httpx-sse-replaced-by-httpx2)**. -### Removed outright +### Removed outright {#removed-outright} Each of these is a section in the **[Migration Guide](migration.md)**: @@ -148,11 +148,11 @@ Each of these is a section in the **[Migration Guide](migration.md)**: * `McpError`, renamed **`MCPError`** with a direct `(code, message, data)` constructor. * `MCPServer.get_context()`, `mount_path=`, and the lowlevel `Server`'s decorator methods, ContextVar, and handler dicts. -## The protocol: 2025-11-25 to 2026-07-28 +## The protocol: 2025-11-25 to 2026-07-28 {#the-protocol-2025-11-25-to-2026-07-28} v2 implements the 2026-07-28 revision, and it serves **both** revisions at once: the same `streamable_http_app()` (and the same stdio server) answers a 2025-era client's `initialize` and a 2026-era client's requests with nothing to configure, no flag to flip, and no separate deployment. Serving the new revision does not strand a client on the old one. What follows is what the new revision itself changes. -### No handshake, no session +### No handshake, no session {#no-handshake-no-session} A 2026-07-28 client does not open a connection, negotiate, and then talk. Every request carries its protocol version, client info, and client capabilities in `_meta`, and the one discovery call, `server/discover`, is a plain request like any other. `Client` does the right thing by default: it probes `server/discover` once and falls back to the `initialize` handshake if the server is older. @@ -160,7 +160,7 @@ Over Streamable HTTP there is no `Mcp-Session-Id` on the 2026 path, which is the **[Protocol versions](protocol-versions.md)** is the client's side of this, **[Deploy & scale](run/deploy.md)** is the operator's checklist (the Host allowlist, the `request_state` key, notifications across replicas), and **[Serving legacy clients](run/legacy-clients.md)** is the both-eras-at-once story. -### The server cannot call the client: multi-round-trip requests +### The server cannot call the client: multi-round-trip requests {#the-server-cannot-call-the-client-multi-round-trip-requests} Every server-initiated request is gone at 2026-07-28: push elicitation, sampling, `roots/list`. On a 2026 connection there is no channel for them, so `ctx.elicit()` and `ctx.session.create_message()` fail there with `NoBackChannelError` (they still work for legacy clients). @@ -178,7 +178,7 @@ That file is the pitch in one place: one server, one `Resolve`-backed tool, and question into a `Resolve(...)` parameter (era-portable), or pin the test client to `mode="legacy"` if you genuinely want the push behavior. -### Roots, sampling, and protocol logging are deprecated; `ping` is removed +### Roots, sampling, and protocol logging are deprecated; `ping` is removed {#roots-sampling-and-protocol-logging-are-deprecated-ping-is-removed} [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) deprecates three whole *capabilities*, on every protocol version: roots, sampling, and MCP-level logging (`ctx.info()` and friends). That is a separate axis from the missing back-channel above; deprecated is advisory, everything keeps working against 2025-era sessions, and nothing changes on the wire. What you notice is `MCPDeprecationWarning`, which is a `UserWarning`, so it prints by default; expect your first `ctx.info(...)` after the upgrade to say so. @@ -186,13 +186,13 @@ That file is the pitch in one place: one server, one `Resolve`-backed tool, and **[Deprecated features](deprecated.md)** has the full table, the replacement for each, and the one-line filter if you need a quiet log while you serve legacy clients. -### Change notifications become one stream +### Change notifications become one stream {#change-notifications-become-one-stream} At 2026-07-28 the standalone HTTP GET stream and `resources/subscribe` are replaced by `subscriptions/listen`: the client opens one long-lived stream and names the notification kinds it wants. `MCPServer` serves it out of the box; you publish with `await ctx.notify_resource_updated(uri)` (and `notify_tools_changed()`, and so on), a middleware can refuse a listen request per caller, and multi-replica deployments plug in a shared `SubscriptionBus`. On the client, `async with client.listen(...)` opens the stream: the filter goes in as keyword arguments, typed change events come back, and `sub.honored` is the subset the server agreed to deliver. **[Subscriptions](handlers/subscriptions.md)** covers publishing and serving, **[its Clients twin](client/subscriptions.md)** the watching end, and **[Deploy & scale](run/deploy.md)** the bus. -### The rest, quickly +### The rest, quickly {#the-rest-quickly} * **Identity is optional, per-message metadata.** The request-side `clientInfo` `_meta` key is optional (the required pair is `protocolVersion` + `clientCapabilities`), and `serverInfo` moved out of the `server/discover` result body: servers stamp it into every 2026-era result's `_meta` instead ([spec #3002](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3002)). The SDK always stamps; `client.server_info` is `None` when a server does not identify itself (for example, a middleware stripped the key). **[The low-level Server](advanced/low-level-server.md)** shows the stamp on the wire. * **Requests are routable without parsing bodies.** Modern HTTP requests carry `Mcp-Method` (and, for the three tool-ish calls, `Mcp-Name`); a tool input-schema property annotated with `x-mcp-header` is mirrored into an `Mcp-Param-*` header and cross-checked by the server ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243)). Gateways and rate limiters can route on headers alone; the **[Migration Guide](migration.md#servers-validate-mcp-param-headers-against-the-request-body-sep-2243)** has the rules. @@ -202,7 +202,7 @@ At 2026-07-28 the standalone HTTP GET stream and `resources/subscribe` are repla * **Authorization got harder to hold wrong.** The client validates the `iss` returned with the authorization code ([RFC 9207](https://datatracker.ietf.org/doc/html/rfc9207); your `callback_handler` now returns an `AuthorizationCodeResult`), sends `application_type` when it registers, and never replays credentials against a different authorization server. New in the enterprise corner: the [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) identity-assertion flow. The **[Migration Guide](migration.md)** lists every OAuth change; **[OAuth for clients](client/oauth-clients.md)** and **[Identity assertion](client/identity-assertion.md)** are the pages. * **Every server is traceable.** OpenTelemetry ships on by default as middleware: every request gets a server span, at no cost until the process configures an exporter. When both ends run the SDK, the client also propagates W3C trace context in `_meta`, so the traces join up. **[OpenTelemetry](run/opentelemetry.md)**. -## Upgrading from v1? +## Upgrading from v1? {#upgrading-from-v1} * The **[Migration Guide](migration.md)** is the complete, exact list of what to change; this page was the why. * **v1.x is not going anywhere.** It moves to maintenance, keeps getting critical fixes and security patches, and nothing about the 2026-07-28 spec release breaks it; its docs live at [/v1/](https://py.sdk.modelcontextprotocol.io/v1/). If you publish a library that depends on `mcp` and are not ready to migrate, keep an upper bound (for example `mcp>=1.28,<2`) so an unpinned resolve stays on 1.x. diff --git a/i18n/README.md b/i18n/README.md new file mode 100644 index 0000000000..2de97c2a9a --- /dev/null +++ b/i18n/README.md @@ -0,0 +1,59 @@ +# Documentation translations + +The English documentation under `docs/` is the source of truth. Everything in this directory either steers how those pages are machine-translated (human-authored inputs) or is the generated output of that process. The public-facing explanation lives at [`docs/translations.md`](../docs/translations.md); this file is for maintainers. + +## Layout + +- `languages.yml` — the language registry: one entry per language site (directory/URL code, native name, theme language, hreflang, enabled flag), the pages excluded from translation, and the model IDs the tool uses. The build and the tool both read it; nothing else hardcodes the language list. +- `general-prompt.md` — the translation rules shared by every language. Sent with every translation request. +- `banners//` — the notes staged onto pages in each language site: `disclosure.md` (machine-translated notice), `outdated.md` (may be behind the English page), `untranslated.md` (not translated yet), `stale.md` (translation withdrawn while it is refreshed — see below) and `english-only.md` (page deliberately kept in English). The build fills the `{english_url}` and `{translations_page_url}` placeholders; `banners/en/` is the fallback used when a language has no translated banner text. +- `languages//instructions.md` — register, voice, humour policy and typography for one language. Human-authored. +- `languages//glossary.json` — the termbase for one language: terms that stay in English, required renderings, and banned renderings. Human-authored, validated by the tool. +- `languages//pages/` — the generated translations, mirroring the paths under `docs/`. Never edited by hand (see below). +- `languages//state.json` — the generated record of what each translated page was built from. +- `languages//nav.yml` — the generated map from each English sidebar label in `mkdocs.yml` (section titles and explicit `Label: page.md` entries) to its translation, plus the record of what it was built from. The language build swaps these into the sidebar; a label without a rendering stays English. Never edited by hand. + +## The tool + +Translation, validation and housekeeping are handled by the package under `scripts/docs/i18n/`, which provides these commands: + +```bash +uv run --frozen --group docs python scripts/docs/i18n [options] +``` + +- `status [--lang ]` — for each enabled language, list pages that are missing, outdated (English changed since translation, or prompt/glossary fingerprint changed), current, and removable (English page deleted), plus the state of its `nav.yml` label map. No network. +- `translate --lang [--pages a.md b.md] [--nav] [--all-missing] [--all-outdated] [--limit N] [--dry-run] [--no-verify]` — translate the selected pages (network), and with `--nav` (re)generate the language's `nav.yml` label map; `--all-missing`/`--all-outdated` also pick the map up when it is missing or outdated. Nothing is selected unless you pass `--pages`, `--nav` or an `--all-*` flag. `--limit` caps pages only. `--dry-run` prints the assembled prompts (the nav prompt included) and exits without calling the API. +- `check [--lang ] [--pages ...]` — validate the committed translations and each language's `nav.yml`, no network; this is the CI gate for pull requests touching `i18n/`, and it only ever fails for a real integrity problem. Each page is checked as what it is: a *current* page (English source, glossary and instructions unchanged) gets the full structural validation against that English page; an *outdated* page is not measured against the English or the glossary that have since moved on — it only has to be well-formed by itself (front matter closes, every code fence closes, headings pin distinct well-formed anchors) and is reported so the next translation run refreshes it. Glossary rules are deliberately not enforced on an outdated page: a glossary edit is one of the things that makes a page outdated, and enforcing it offline would fail the gate until a fresh translation run landed. A page whose English source is gone is reported for `prune`, and a translation file with no `state.json` record is reported as untracked. For a `nav.yml` map, labels it lacks are merely behind and entries for labels the nav has dropped are reported as stale, neither failing the check (`--pages` narrows the run to pages only). English and glossary edits therefore never fail `check`; staleness is bannered by the build, not blocked here. +- `verify --lang --pages ...` — run the semantic review gate over the given pages (network). `translate` runs it on freshly generated text unless `--no-verify` is passed. +- `prune --lang ` — delete translated pages whose English source no longer exists and drop them from the state file. +- `stage --lang [--site-url URL]` and `languages` — build machinery used by `scripts/docs/build.sh`: `stage` assembles a language's docs tree under `.build/i18n//docs/` (English pages, translations laid over them, status banners stamped in, and links into the API reference pointed at the English one — a language site links to the single English API reference rather than building its own), and `languages` prints the enabled codes. `--site-url` names the URL the site is served from (the build script derives it from `DOCS_SITE_URL`, defaulting to `mkdocs.yml`'s `site_url`), which the banners' English-page links and the API-reference links are built on. A translation whose links no longer resolve against the English tree (its English page has since renamed or dropped a target the translation still links) is withdrawn rather than staged — the English page is served with the `stale` banner and `stage` prints a warning line for it — so an outdated translation can delay a language site but never break its build. + +Exit codes: `0` success, `1` validation or verification failures, `2` usage or configuration errors. The network commands read `ANTHROPIC_API_KEY` (or `ANTHROPIC_AUTH_TOKEN`) from the environment and fail fast if neither is set; the model IDs come from `languages.yml` and can be overridden with `MCP_DOCS_I18N_MODEL` and `MCP_DOCS_I18N_VERIFY_MODEL`. Run any command with `--help` for the full option list. + +## Correcting a translation + +Never edit the files under `languages//pages/` or a language's `nav.yml` — the next translation run overwrites them. Put the correction where the tool will pick it up: + +- A wrong or inconsistent term → add or fix an entry in that language's `glossary.json`. +- A recurring style or register problem (too formal, a joke that lands badly, an awkward loanword) → add a rule to that language's `instructions.md`, ideally with a short good/bad example. +- English text that is ambiguous or hard to translate → fix the English page under `docs/`. + +Editing the instructions or glossary changes their fingerprint, which marks that language's pages and nav map as outdated (see below), so the next `translate --all-outdated` run regenerates them with the fix in place. A wrong sidebar label is corrected the same way — through the glossary or instructions, then a `--nav` (or `--all-outdated`) run. + +Readers report problems through the "Translation problem" issue form (`.github/ISSUE_TEMPLATE/translation.yaml`), which applies the `translation` label; triage each report into one of the three fixes above. + +## How staleness works + +`languages//state.json` records, for every translated page, the git commit and content hash of the English page it was translated from, a hash per content block, the fingerprints of `general-prompt.md`, that language's `instructions.md` and `glossary.json`, and the model and timestamp of the run. A page is a set of blocks: its front matter, the text before the first `##` heading, then one block per `##` section, so an edit to one section only re-translates that section and the untouched blocks are carried over verbatim from the previous translation. + +`status` compares those records against the current English tree: a page is *missing* if it has no translation, *outdated* if any recorded hash or fingerprint no longer matches, *current* otherwise, and *removable* if its English source is gone. The docs build reads the same state to add a "may be behind the English page" note to outdated pages; pages with no translation are served from the English source with a "not translated yet" note. An outdated page whose translation still links a page the English tree has since renamed or removed is a special case: it would break the language site's strict build, so `status` reports it as "links broken by an English change — English shown until refreshed" and the build serves the English page with the `stale` banner until the next translation run rewrites it. Its `state.json` record already marks it outdated, so nothing else is needed — the next `translate --all-outdated` run refreshes it like any other outdated page. + +The nav map follows the same contract at a smaller grain. `languages//nav.yml` records a hash of the ordered English label list plus the same three prompt-input fingerprints, so it is *missing* until first generated, *outdated* when a label is added, removed or reworded in `mkdocs.yml` or when the general prompt, instructions or glossary change, and *current* otherwise. A retranslation carries every label whose prompt inputs are unchanged over from the previous map byte-for-byte, so only new labels come from the model; a glossary or instructions edit re-asks the whole list. A label with no rendering is shown in English rather than failing the build. + +## Adding a language + +1. Add an entry to `languages.yml` (code, native name, theme language, hreflang, `enabled: true`). +2. Create `languages//instructions.md` with these sections in this order: register, voice, humour and idioms, typography, terminology, and a provisional note. Keep it tight; it is sent verbatim with every request. +3. Create `languages//glossary.json`: the `keep_in_source_language` list plus the seed `terms` entries. +4. Optionally add `banners//` with translated banner text; without it the English banners are used. +5. Run `status --lang ` to see what is missing, then `translate --lang --all-missing`. The `pages/` tree, `state.json` and `nav.yml` are generated by that run; commit them. diff --git a/i18n/banners/en/disclosure.md b/i18n/banners/en/disclosure.md new file mode 100644 index 0000000000..0c6567b5d6 --- /dev/null +++ b/i18n/banners/en/disclosure.md @@ -0,0 +1,4 @@ +??? info "This page was machine-translated" + Translations of this documentation are generated automatically from the English pages, and the [English version of this page]({english_url}) is the authoritative one. + + Found a translation problem? See [how the translations work and how to report an issue]({translations_page_url}). diff --git a/i18n/banners/en/english-only.md b/i18n/banners/en/english-only.md new file mode 100644 index 0000000000..039a4ea1d7 --- /dev/null +++ b/i18n/banners/en/english-only.md @@ -0,0 +1,2 @@ +!!! note "Available in English only" + This page isn't part of the translated documentation, so you're reading it in English. [How the translations work]({translations_page_url}). diff --git a/i18n/banners/en/outdated.md b/i18n/banners/en/outdated.md new file mode 100644 index 0000000000..96b2eb043c --- /dev/null +++ b/i18n/banners/en/outdated.md @@ -0,0 +1,2 @@ +!!! warning "This translation may be behind the English page" + The English source changed after this page was last translated, so parts of it may be out of date. When in doubt, read the [English version of this page]({english_url}). diff --git a/i18n/banners/en/stale.md b/i18n/banners/en/stale.md new file mode 100644 index 0000000000..8ed5cfd183 --- /dev/null +++ b/i18n/banners/en/stale.md @@ -0,0 +1,2 @@ +!!! warning "This page's translation is being refreshed" + The English documentation changed under this page's translation, so you're reading the English version until the translation catches up. [How the translations work]({translations_page_url}). diff --git a/i18n/banners/en/untranslated.md b/i18n/banners/en/untranslated.md new file mode 100644 index 0000000000..664f8e9cd4 --- /dev/null +++ b/i18n/banners/en/untranslated.md @@ -0,0 +1,2 @@ +!!! note "Not translated yet" + This page hasn't been translated yet, so you're reading the English version. [How the translations work]({translations_page_url}). diff --git a/i18n/banners/ja/disclosure.md b/i18n/banners/ja/disclosure.md new file mode 100644 index 0000000000..7009ccf823 --- /dev/null +++ b/i18n/banners/ja/disclosure.md @@ -0,0 +1,4 @@ +??? info "このページは機械翻訳です" + このドキュメントの翻訳版は英語版のページから自動的に生成されており、正式な内容は[このページの英語版]({english_url})です。 + + 翻訳におかしな点を見つけましたか?[翻訳の仕組みと問題の報告方法]({translations_page_url})を参照してください。 diff --git a/i18n/banners/ja/english-only.md b/i18n/banners/ja/english-only.md new file mode 100644 index 0000000000..23f548a3c0 --- /dev/null +++ b/i18n/banners/ja/english-only.md @@ -0,0 +1,2 @@ +!!! note "このページは英語版のみです" + このページは翻訳の対象外のため、英語版のみを提供しています。以下は英語の原文です。[このページの英語版]({english_url})も参照してください。 diff --git a/i18n/banners/ja/outdated.md b/i18n/banners/ja/outdated.md new file mode 100644 index 0000000000..77f584c798 --- /dev/null +++ b/i18n/banners/ja/outdated.md @@ -0,0 +1,2 @@ +!!! warning "この翻訳は英語版より古い可能性があります" + このページが翻訳されたあとに英語版の原文が更新されたため、内容の一部が最新でない可能性があります。迷ったときは[このページの英語版]({english_url})を参照してください。 diff --git a/i18n/banners/ja/untranslated.md b/i18n/banners/ja/untranslated.md new file mode 100644 index 0000000000..c8b578d8bf --- /dev/null +++ b/i18n/banners/ja/untranslated.md @@ -0,0 +1,2 @@ +!!! note "このページはまだ翻訳されていません" + このページはまだ翻訳されていないため、英語版をそのまま表示しています。[翻訳の仕組み]({translations_page_url})も参照してください。 diff --git a/i18n/banners/ko/disclosure.md b/i18n/banners/ko/disclosure.md new file mode 100644 index 0000000000..5d91191cf0 --- /dev/null +++ b/i18n/banners/ko/disclosure.md @@ -0,0 +1,4 @@ +??? info "이 페이지는 기계 번역본입니다" + 이 문서의 번역은 영어 페이지를 바탕으로 자동 생성되며, 기준이 되는 것은 [이 페이지의 영어 원문]({english_url})입니다. + + 번역 문제를 발견했다면 [번역이 만들어지는 방식과 문제를 신고하는 방법]({translations_page_url})을 참고하세요. diff --git a/i18n/banners/ko/english-only.md b/i18n/banners/ko/english-only.md new file mode 100644 index 0000000000..fa7854224d --- /dev/null +++ b/i18n/banners/ko/english-only.md @@ -0,0 +1,2 @@ +!!! note "영어로만 제공되는 페이지입니다" + 이 페이지는 번역 대상이 아니므로 영어로만 제공되며, 아래에는 영어 원문이 그대로 표시됩니다. [이 페이지의 영어 원문]({english_url})도 참고하세요. diff --git a/i18n/banners/ko/outdated.md b/i18n/banners/ko/outdated.md new file mode 100644 index 0000000000..7bb50507ec --- /dev/null +++ b/i18n/banners/ko/outdated.md @@ -0,0 +1,2 @@ +!!! warning "이 번역은 영어 페이지보다 오래되었을 수 있습니다" + 이 페이지를 마지막으로 번역한 뒤에 영어 원문이 바뀌었으므로 일부 내용이 최신이 아닐 수 있습니다. 확실하지 않다면 [이 페이지의 영어 원문]({english_url})을 확인하세요. diff --git a/i18n/banners/ko/untranslated.md b/i18n/banners/ko/untranslated.md new file mode 100644 index 0000000000..ad77aa2e19 --- /dev/null +++ b/i18n/banners/ko/untranslated.md @@ -0,0 +1,2 @@ +!!! note "아직 번역되지 않았습니다" + 이 페이지는 아직 번역되지 않아 영어 원문이 그대로 표시됩니다. [번역이 만들어지는 방식]({translations_page_url})을 참고하세요. diff --git a/i18n/banners/pt-BR/disclosure.md b/i18n/banners/pt-BR/disclosure.md new file mode 100644 index 0000000000..87edecf49c --- /dev/null +++ b/i18n/banners/pt-BR/disclosure.md @@ -0,0 +1,4 @@ +??? info "Esta página foi traduzida por máquina" + As traduções desta documentação são geradas automaticamente a partir das páginas em inglês, e a [versão em inglês desta página]({english_url}) é a oficial. + + Encontrou um problema na tradução? Veja [como as traduções funcionam e como reportar um problema]({translations_page_url}). diff --git a/i18n/banners/pt-BR/english-only.md b/i18n/banners/pt-BR/english-only.md new file mode 100644 index 0000000000..68eb177154 --- /dev/null +++ b/i18n/banners/pt-BR/english-only.md @@ -0,0 +1,2 @@ +!!! note "Disponível apenas em inglês" + Esta página não é traduzida e existe apenas em inglês, então você está lendo o original em inglês. Veja também a [versão em inglês desta página]({english_url}). diff --git a/i18n/banners/pt-BR/outdated.md b/i18n/banners/pt-BR/outdated.md new file mode 100644 index 0000000000..5b58e5021a --- /dev/null +++ b/i18n/banners/pt-BR/outdated.md @@ -0,0 +1,2 @@ +!!! warning "Esta tradução pode estar desatualizada em relação à página em inglês" + O original em inglês mudou depois que esta página foi traduzida pela última vez, então partes dela podem estar desatualizadas. Na dúvida, leia a [versão em inglês desta página]({english_url}). diff --git a/i18n/banners/pt-BR/untranslated.md b/i18n/banners/pt-BR/untranslated.md new file mode 100644 index 0000000000..6b2571394f --- /dev/null +++ b/i18n/banners/pt-BR/untranslated.md @@ -0,0 +1,2 @@ +!!! note "Ainda não traduzida" + Esta página ainda não foi traduzida, então você está lendo a versão em inglês. Veja [como as traduções funcionam]({translations_page_url}). diff --git a/i18n/banners/zh-CN/disclosure.md b/i18n/banners/zh-CN/disclosure.md new file mode 100644 index 0000000000..2a6d4df5ec --- /dev/null +++ b/i18n/banners/zh-CN/disclosure.md @@ -0,0 +1,4 @@ +??? info "本页为机器翻译" + 这份文档的其他语言版本由英文页面自动翻译生成,[本页的英文版本]({english_url})是权威版本,如有出入以英文为准。 + + 发现翻译问题?请看[翻译是如何生成的以及如何反馈问题]({translations_page_url})。 diff --git a/i18n/banners/zh-CN/english-only.md b/i18n/banners/zh-CN/english-only.md new file mode 100644 index 0000000000..6847d7556b --- /dev/null +++ b/i18n/banners/zh-CN/english-only.md @@ -0,0 +1,2 @@ +!!! note "本页仅提供英文版" + 这一页不在翻译范围内,只提供英文版本,下面显示的是英文原文。也可以直接查看[本页的英文版本]({english_url})。 diff --git a/i18n/banners/zh-CN/outdated.md b/i18n/banners/zh-CN/outdated.md new file mode 100644 index 0000000000..5bf92a6876 --- /dev/null +++ b/i18n/banners/zh-CN/outdated.md @@ -0,0 +1,2 @@ +!!! warning "本页翻译可能落后于英文页面" + 英文原文在本页上次翻译之后已经修改,部分内容可能已经过时。拿不准时,请查看[本页的英文版本]({english_url})。 diff --git a/i18n/banners/zh-CN/untranslated.md b/i18n/banners/zh-CN/untranslated.md new file mode 100644 index 0000000000..fe94ff105a --- /dev/null +++ b/i18n/banners/zh-CN/untranslated.md @@ -0,0 +1,2 @@ +!!! note "本页尚未翻译" + 这一页还没有翻译,下面显示的是英文原文。想了解翻译是如何进行的,请看[翻译说明]({translations_page_url})。 diff --git a/i18n/general-prompt.md b/i18n/general-prompt.md new file mode 100644 index 0000000000..ddbbd71a9d --- /dev/null +++ b/i18n/general-prompt.md @@ -0,0 +1,59 @@ +# 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 + +Copy the following byte-for-byte from the English source: + +- Fenced code blocks: the fence markers, the info string, and every line inside them, including code comments. +- Inline code spans (text between backticks). +- URLs and link destinations, including `#fragment` anchors, and image paths. +- HTML tags and their attribute values. +- Front matter keys (the `key:` part of each front matter line). +- Snippet-include lines containing `--8<--`. +- Code-annotation markers such as `# (1)!`. +- Heading anchor attributes: the `{#some-id}` at the end of a heading (it may also be written with spaces, `{ #some-id }`; copy it exactly as it appears). +- The syntax markers for admonitions, collapsible blocks and content tabs (`!!!`, `???`, `???+`, `///`, `===`) and the block-type keyword that follows them (`note`, `tip`, `warning`, ...). +- Footnote labels (`[^1]`), abbreviation definitions (`*[HTML]: ...`) and emoji shortcodes (`:smile:`). +- Mermaid diagram source inside `mermaid` fences. + +Do translate the human-language text around those elements: prose, headings, link text, image alt text, table cells, list items, admonition titles (the quoted text after `!!! type`) and bodies, and content-tab labels (the quoted text after `===`). + +## Preserve the structure exactly + +The translation must have the same shape as the English source, block for block: + +- The same headings, at the same levels, in the same order, each ending in the same `{#anchor}` attribute as the source. +- The same number and type of admonitions, collapsible blocks and content-tab groups, in the same order. +- The same tables, with the same number of rows and columns. +- The same lists (same nesting, same number of items) and the same code fences (same count, same info strings, identical contents). +- The same links and images, in the same order. Never add, remove or merge a link. +- The same footnotes, and the same blank lines separating blocks. + +Do not add explanatory notes, translator's remarks or extra examples. + +## Links and anchors + +- Keep every link destination exactly as written in the source, whether it is an absolute URL, a relative path such as `../servers/tools.md`, or a bare `#anchor`. Only the link text is translated. +- Do not add anchors the source does not have, and never rewrite a fragment: heading anchors are pinned in the English source, so the same `#id` is valid on every language site. +- Preserve the link syntax the source uses (Markdown `[text](target)` or HTML ``). + +## Updating an existing translation + +When the request includes a previous translation of the page and marks which sections of the English page changed: + +- Outside the changed sections, reproduce the previous translation verbatim. Do not rephrase, "improve" or re-punctuate text whose English has not changed. +- Inside the changed sections, translate the new English following all of the rules above, and keep terminology, register and tone consistent with the surrounding unchanged text. +- A section is the page's front matter, the text before the first second-level heading, or one second-level heading (`##`) together with everything under it up to the next. + +## Output + +Return only the translated Markdown document, from its first line to its last. Do not add a preamble, a summary or any commentary, and do not wrap the document in a code fence. diff --git a/i18n/languages.yml b/i18n/languages.yml new file mode 100644 index 0000000000..ec652c645f --- /dev/null +++ b/i18n/languages.yml @@ -0,0 +1,45 @@ +# Registry of the translated documentation sites. English at docs/ is the +# source; every entry below is one machine-translated site published next to it +# at //. The docs build and the tool under scripts/docs/i18n/ both read +# this file — nothing else lists the languages. + +languages: + - code: zh-CN # directory under i18n/languages/ and the site's URL prefix + name: 简体中文 # native name shown in the language switcher + theme_language: zh # the theme's `theme.language` (UI strings, search) + hreflang: zh-Hans # value announced in + script: cjk # writing system; non-latin scripts get the untranslated-English check + enabled: true + - code: ja + name: 日本語 + theme_language: ja + hreflang: ja + script: cjk + enabled: true + - code: ko + name: 한국어 + theme_language: ko + hreflang: ko + script: hangul + enabled: true + - code: pt-BR + name: Português (Brasil) + theme_language: pt-BR + hreflang: pt-BR + script: latin + enabled: true + +# Pages that are never translated: the language sites serve their English +# text. Nav paths as written in mkdocs.yml; a trailing /** covers a subtree. +# (The API reference is not a page set here: a language site does not carry +# it at all and links to the English reference instead.) +exclude_pages: + - migration.md + +# Model IDs for the translation and semantic-review passes. Both must be +# publicly documented Claude model IDs; the reviewer is deliberately the +# stronger of the two. MCP_DOCS_I18N_MODEL / MCP_DOCS_I18N_VERIFY_MODEL and the +# CLI flags override them. +models: + translate: claude-opus-5 + verify: claude-fable-5 diff --git a/i18n/languages/ja/glossary.json b/i18n/languages/ja/glossary.json new file mode 100644 index 0000000000..ba38f88367 --- /dev/null +++ b/i18n/languages/ja/glossary.json @@ -0,0 +1,295 @@ +{ + "version": 1, + "keep_in_source_language": [ + "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.", + "avoid": [], + "enforce": false + }, + { + "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": ["資源"], + "enforce": true + }, + { + "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.", + "avoid": [], + "enforce": false + }, + { + "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": ["標本抽出"], + "enforce": true + }, + { + "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": ["ルーツ"], + "enforce": true + }, + { + "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.", + "avoid": [], + "enforce": false + }, + { + "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.", + "avoid": [], + "enforce": false + }, + { + "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": ["輸送"], + "enforce": true + }, + { + "source": "session", + "target": "セッション", + "note": "An MCP session (the negotiated connection state). Standard rendering; never the coinage 会期. `session` objects in code font stay Latin.", + "avoid": ["会期"], + "enforce": false + }, + { + "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.", + "avoid": [], + "enforce": false + }, + { + "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.", + "avoid": [], + "enforce": false + }, + { + "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": ["顧客"], + "enforce": true + }, + { + "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.", + "avoid": [], + "enforce": false + }, + { + "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": ["宿主"], + "enforce": true + }, + { + "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-in-source list and stays Latin in prose (\"The Context\" → Context).", + "avoid": ["コンテクスト"], + "enforce": false + }, + { + "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": ["解決器"], + "enforce": false + }, + { + "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). Not enforced, 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.", + "avoid": ["寿命"], + "enforce": false + }, + { + "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.", + "avoid": ["廃止"], + "enforce": false + }, + { + "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 バックチャンネル.", + "avoid": ["バックチャンネル"], + "enforce": false + }, + { + "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": ["電線", "ワイヤー"], + "enforce": false + }, + { + "source": "era", + "target": "世代", + "note": "\"Protocol era\" (\"a 2025-era client\", \"whatever era the client speaks\") → プロトコルの世代, 2025 年世代のクライアント. Provisional pending native review; not the literal 時代.", + "avoid": ["時代"], + "enforce": false + }, + { + "source": "legacy", + "target": "レガシー", + "note": "\"A legacy connection/client\" = one negotiated at spec version 2025-11-25 or earlier → レガシー接続, レガシークライアント (prenominal loanword). Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "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.", + "avoid": [], + "enforce": false + }, + { + "source": "handshake", + "target": "ハンドシェイク", + "note": "The initialization handshake (\"the classic handshake\" → 従来のハンドシェイク). The established loanword; never the literal 握手. Provisional pending native review.", + "avoid": ["握手"], + "enforce": true + }, + { + "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.", + "avoid": [], + "enforce": false + }, + { + "source": "response", + "target": "レスポンス", + "note": "A JSON-RPC or HTTP response; `Response` types in code font stay Latin. Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "source": "callback", + "target": "コールバック", + "note": "Client callbacks and OAuth redirect callbacks alike; parameter names such as `sampling_callback` stay Latin. Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "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.", + "avoid": [], + "enforce": false + }, + { + "source": "type hint", + "target": "型ヒント", + "note": "Python type hints (\"from your type hints\" → 型ヒントから). Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "source": "argument", + "target": "引数", + "note": "A call argument; the declared parameter is パラメーター (see the katakana rule in instructions.md). Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "source": "return value", + "target": "戻り値", + "note": "A function's return value; the `return` keyword and return annotations are code. Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "source": "exception", + "target": "例外", + "note": "A raised Python exception (\"raises an exception\" → 例外を送出する); exception class names stay Latin. Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "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.", + "avoid": [], + "enforce": false + } + ] +} diff --git a/i18n/languages/ja/instructions.md b/i18n/languages/ja/instructions.md new file mode 100644 index 0000000000..5672755782 --- /dev/null +++ b/i18n/languages/ja/instructions.md @@ -0,0 +1,168 @@ +# 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. When the English + italicises a word that gets translated, drop the emphasis or use 「」; + 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). + +## 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 under + `keep_in_source_language`, 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. +- 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/languages/ja/nav.yml b/i18n/languages/ja/nav.yml new file mode 100644 index 0000000000..52a38c0055 --- /dev/null +++ b/i18n/languages/ja/nav.yml @@ -0,0 +1,60 @@ +# Generated by scripts/docs/i18n (`translate --nav`). Never edit by hand; see i18n/README.md. +version: 1 +labels: + What's new in v2: v2 の新機能 + Get started: はじめに + Installation: インストール + First steps: 最初のステップ + Connect to a real host: 実際のホストに接続する + Testing: テスト + Servers: サーバー + Tools: ツール + Structured Output: 構造化出力 + Resources: リソース + URI templates: URI テンプレート + Prompts: プロンプト + Completions: 補完 + Images, audio & icons: 画像・音声・アイコン + Handling errors: エラーの処理 + Inside your handler: ハンドラーの中で + The Context: Context + Dependencies: 依存関係 + Lifespan: ライフスパン + Elicitation: エリシテーション + Multi-round-trip requests: マルチラウンドトリップリクエスト + Sampling and roots: サンプリングとルート + Progress: 進捗 + Logging: ロギング + Subscriptions: サブスクリプション + Running your server: サーバーの実行 + Add to an existing app: 既存アプリへの追加 + Deploy & scale: デプロイとスケール + Authorization: 認可 + OpenTelemetry: OpenTelemetry + Serving legacy clients: レガシークライアントへの対応 + Clients: クライアント + Callbacks: コールバック + Transports: トランスポート + OAuth: OAuth + Identity assertion: アイデンティティアサーション + Multiple servers: 複数サーバー + Caching: キャッシュ + Protocol versions: プロトコルバージョン + Deprecated features: 非推奨の機能 + Advanced: 応用 + The low-level Server: 低レベル Server + Pagination: ページネーション + Middleware: ミドルウェア + Extensions: 拡張 + MCP Apps: MCP Apps + Troubleshooting: トラブルシューティング + Translations: 翻訳 + Migration Guide: 移行ガイド + API Reference: API リファレンス +state: + labels_hash: 3046f9e5ef09880ff0ab3af257aaee79d0ffb03dc16b11c87e7111760e02a25c + general_prompt_hash: 83aaa955017a24c0c1d3f7b284e61d8441d58e035b60e7fe52a3d816d6cf73c7 + instructions_hash: 50352b99a74d2e23cedcfa31109cfe2992c564e8d2032cc84566cec71c051bce + glossary_hash: cb8bb6c2fda6b12eb8a90401d2041df998189536535c9714355616dcd905b10a + model: claude-opus-5 + translated_at: '2026-07-31T14:32:08Z' diff --git a/i18n/languages/ja/pages/get-started/first-steps.md b/i18n/languages/ja/pages/get-started/first-steps.md new file mode 100644 index 0000000000..c411f2641e --- /dev/null +++ b/i18n/languages/ja/pages/get-started/first-steps.md @@ -0,0 +1,139 @@ +# はじめの一歩 {#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 つの側にはそれぞれ別のインポートパスがあります。`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 つ、*Add two numbers.* と説明された `add` です。フォームには `a` 用の必須の整数フィールドと、`b` 用のフィールドがあります。値を入れて呼び出すと、結果は `3` になります。Inspector はこのフォームを `a: int, b: int` から組み立てました。他のクライアントもすべて同じことをします。 + +**リソース。** *Resources* の一覧は空です。`greeting` は **Resource Templates** の下にあります。`greeting://{name}` にはパラメーターがあるため、誰かが `name` を与えるまで一覧に出せる具体的なリソースが存在しないからです。`World` を渡して読み取ってみてください。 + +```text +Hello, World! +``` + +**プロンプト。** 項目は 1 つ、必須の `text` 引数を 1 つ持つ `summarize` です。適当なテキストを渡して取得すると、`role: user` とレンダリング済みの文字列を内容に持つメッセージが 1 つ返ってきます。プロンプトとはそれだけのものです。メッセージを組み立てる関数です。 + +Inspector はサーバーを **stdio** で実行しました。MCP サーバーが話せるトランスポートのひとつです。どれを使うかはまだ選ばなくて構いません。それは **[サーバーの実行](../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 つで、動いているかどうかを推測する必要はもうなくなります。そのあとはプリミティブごとに 1 ページずつ、モデルが動かすものから始めます。**[ツール](../servers/tools.md)** です。 diff --git a/i18n/languages/ja/pages/handlers/elicitation.md b/i18n/languages/ja/pages/handlers/elicitation.md new file mode 100644 index 0000000000..f00f80e6a3 --- /dev/null +++ b/i18n/languages/ja/pages/handlers/elicitation.md @@ -0,0 +1,185 @@ +# エリシテーション(elicitation) {#elicitation} + +処理の途中まで進んだツールが、答えを 1 つ欠いているからといって失敗する必要はありません。 + +**エリシテーション**を使えば、ツールの側から尋ねられます。ツール呼び出しの途中でユーザーに質問が表示され、その答えが同じ関数呼び出しの中へ戻ってきます。 + +モードは 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)リクエスト](multi-round-trip.md)** を参照してください。 + +尋ねることは、リゾルバーができることの 1 つにすぎません。一般的な仕組み(尋ねずに計算する依存関係、依存関係の依存関係、モデルが供給できるものとできないもの)については、**[依存関係](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" +``` + +* `ctx.elicit` を使えるようにしてくれるのが **`Context`** パラメーターです。どのツールでも受け取れます。このオブジェクトには専用のページがあります:**[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` を受け取ります。`elicitation_id` は、サーバー内でこのエリシテーションを識別できる任意の文字列です。 +* 結果にはアクションしか含まれません。`"accept"` はユーザーが URL を開くことに同意したという意味であり、その先の手続きを完了したという意味では**ありません**。 +* 決済は、ユーザーのブラウザーと決済プロバイダーの間で、プロトコルの外側で行われます。コンテンツが MCP を通って戻ってくることはありません。 + +2 つ目のツールを見てください。プロトコル外のフローが完了したことをサーバーが知ったとき(Webhook やポーリングなど。ここでは 2 つ目のツールとしてモデル化しています)、`ctx.session.send_elicit_complete(...)` が同じ `elicitation_id` を付けて `notifications/elicitation/complete` を送ります。これによってクライアントは「支払いを待っています...」の表示をやめてよいと分かります。これがないと、クライアントは推測するしかありません。 + +## クライアント側 {#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` の union で、`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 で起動し(ワンライナーは **[サーバーの実行](../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` は accept のときだけ存在します。 +* `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/languages/ja/pages/index.md b/i18n/languages/ja/pages/index.md new file mode 100644 index 0000000000..7f51933cb7 --- /dev/null +++ b/i18n/languages/ja/pages/index.md @@ -0,0 +1,97 @@ +# 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 です。これを使うと、次のことができます。 + +* あらゆる 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` を 1 つと、テンプレート化された **リソース** `greeting://{name}` を 1 つ公開しています。 + +### 実行する {#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` に必須の整数フィールド、`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/languages/ja/state.json b/i18n/languages/ja/state.json new file mode 100644 index 0000000000..2393bc8bc3 --- /dev/null +++ b/i18n/languages/ja/state.json @@ -0,0 +1,56 @@ +{ + "version": 1, + "pages": { + "get-started/first-steps.md": { + "source_commit": "a4f4ccd091138771535e17191123f20b30fda68e", + "source_hash": "c53ca01e59b99b5b44901c7217aae06de5db25adbd725f0611531d0e5436ec1d", + "blocks": [ + "27080c63432c6cde9d12ed9e5ee9bf1df3de3dbe899bbebc3fa2074ff29351d5", + "69a332c2d0cf2dc3502603a5308504f1f48d01aee0724faf02b52216979044be", + "311deb43a3377221a9e4a28b5585703ebcdc06f5673252a8b11c03f6719efec7", + "e5d42538a3aec2e034cdb3ec1bdd365a7bc535bc69b1a4ce0885e845c8e17865", + "cb562e1411ff70a409e6de703f2d7182631a17efff8819050e95cde220c668dc", + "4ae6f48ce3321dce8caaefa1294ba6c77df1371db0876a67e7dd47a74401d3a2", + "93dd15119c46d4dd84e6c66d208b1d39d8b35858499c00e5d32f8601d69cfb68" + ], + "general_prompt_hash": "83aaa955017a24c0c1d3f7b284e61d8441d58e035b60e7fe52a3d816d6cf73c7", + "instructions_hash": "50352b99a74d2e23cedcfa31109cfe2992c564e8d2032cc84566cec71c051bce", + "glossary_hash": "cb8bb6c2fda6b12eb8a90401d2041df998189536535c9714355616dcd905b10a", + "model": "claude-opus-5", + "translated_at": "2026-07-31T13:29:17Z" + }, + "handlers/elicitation.md": { + "source_commit": "a4f4ccd091138771535e17191123f20b30fda68e", + "source_hash": "57397f9fc329959cedcc36cf3096987637d31f02a5295b65b3c7bf4b81191387", + "blocks": [ + "cdd870979684f1f1f58c2be986a172f1d61372b8c35be35a7761296dbe482f6e", + "89de7c6e1d9c54aae599fb9d68228de4c88976c8e550f7b27c8901430f058ea8", + "71b40d76fbe23227ea8541e085def2c1643afd8ad69f808b88c518865a96d150", + "e5c2c4402421de493e6b676fce3becb29bc00257c4424bf5fbbfd0b378859e5c", + "975a9aa4360a1e41eb63cd15a583fae1fa7b170f30426dc041cdea3b2783b8cb", + "95af274a2f962a5fbdede98e467f37a95e4982fb363858252243618e0b00d6c5" + ], + "general_prompt_hash": "83aaa955017a24c0c1d3f7b284e61d8441d58e035b60e7fe52a3d816d6cf73c7", + "instructions_hash": "50352b99a74d2e23cedcfa31109cfe2992c564e8d2032cc84566cec71c051bce", + "glossary_hash": "cb8bb6c2fda6b12eb8a90401d2041df998189536535c9714355616dcd905b10a", + "model": "claude-opus-5", + "translated_at": "2026-07-31T13:29:17Z" + }, + "index.md": { + "source_commit": "a4f4ccd091138771535e17191123f20b30fda68e", + "source_hash": "f362cda3cadc999e034be6bfcf91605ac06da78916288fc8e0403c72d6457f71", + "blocks": [ + "0dc21fd9e41faaed6d3607c81c9bfc3dcd58f2e2be1a7935f74c898705ea9a8f", + "10b8effdf0144ac4bab1ef05d18618fa3b5d830fb5e09acf29013c80db64dc61", + "80334dc926af0d3eab82c8eb9691623120678c98ac4c31801e9582563a64e705", + "e08d3202df4e369c06508cbe5a4785a8d9bb283f09db97c33c432a83fb1e9df3", + "d07fcb6ff5f454dcc7c6836abe2f9103dcebac3c40f2a81980e362d75ca089b2" + ], + "general_prompt_hash": "83aaa955017a24c0c1d3f7b284e61d8441d58e035b60e7fe52a3d816d6cf73c7", + "instructions_hash": "50352b99a74d2e23cedcfa31109cfe2992c564e8d2032cc84566cec71c051bce", + "glossary_hash": "cb8bb6c2fda6b12eb8a90401d2041df998189536535c9714355616dcd905b10a", + "model": "claude-opus-5", + "translated_at": "2026-07-31T13:29:17Z" + } + } +} diff --git a/i18n/languages/ko/glossary.json b/i18n/languages/ko/glossary.json new file mode 100644 index 0000000000..9fbdbdc3c8 --- /dev/null +++ b/i18n/languages/ko/glossary.json @@ -0,0 +1,225 @@ +{ + "version": 1, + "keep_in_source_language": [ + "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 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. Provisional pending native review.", + "avoid": ["툴"], + "enforce": false + }, + { + "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.", + "avoid": ["자원"], + "enforce": false + }, + { + "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.", + "avoid": [], + "enforce": false + }, + { + "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": ["표본 추출", "표집"], + "enforce": false + }, + { + "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": ["뿌리"], + "enforce": true + }, + { + "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.", + "avoid": ["도출"], + "enforce": false + }, + { + "source": "capability", + "target": "기능", + "note": "A negotiated protocol feature (\"capability negotiation\" → 기능 협상); 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.", + "avoid": ["역량", "능력"], + "enforce": false + }, + { + "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": ["운송", "수송"], + "enforce": true + }, + { + "source": "session", + "target": "세션", + "note": "Standard loanword; `ctx.session` and other identifiers stay code. Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "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.", + "avoid": ["처리기"], + "enforce": false + }, + { + "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.", + "avoid": ["종속성"], + "enforce": false + }, + { + "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": ["고객"], + "enforce": true + }, + { + "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.", + "avoid": [], + "enforce": false + }, + { + "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": ["주최자"], + "enforce": false + }, + { + "source": "request", + "target": "요청", + "note": "A JSON-RPC or HTTP request; the verb \"to request\" is 요청하다. Provisional pending native review.", + "avoid": ["리퀘스트"], + "enforce": false + }, + { + "source": "response", + "target": "응답", + "note": "A JSON-RPC or HTTP response. Provisional pending native review.", + "avoid": ["리스폰스"], + "enforce": false + }, + { + "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.", + "avoid": [], + "enforce": false + }, + { + "source": "notification", + "target": "알림", + "note": "Protocol notifications (`notifications/...` identifiers stay Latin); \"send a notification\" → 알림을 보내다. Provisional pending native review.", + "avoid": ["통지"], + "enforce": false + }, + { + "source": "resolver", + "target": "리졸버", + "note": "The SDK's `Resolve(...)`-annotated parameter resolvers. Provisional pending native review; 해석기 (parser/interpreter) and 해결자 are avoided.", + "avoid": ["해석기", "해결자"], + "enforce": false + }, + { + "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.", + "avoid": [], + "enforce": false + }, + { + "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.", + "avoid": ["폐기된", "폐기 예정"], + "enforce": false + }, + { + "source": "round-trip", + "target": "왕복", + "note": "A network round trip; \"multi-round-trip requests\" → 다중 왕복 요청. Provisional pending native review.", + "avoid": ["라운드 트립"], + "enforce": false + }, + { + "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\" (생명 주기).", + "avoid": ["수명"], + "enforce": false + } + ] +} diff --git a/i18n/languages/ko/instructions.md b/i18n/languages/ko/instructions.md new file mode 100644 index 0000000000..2bea8d5811 --- /dev/null +++ b/i18n/languages/ko/instructions.md @@ -0,0 +1,140 @@ +# 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 + are left exactly as the shared rules say. +- 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. +- 한글(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/languages/ko/nav.yml b/i18n/languages/ko/nav.yml new file mode 100644 index 0000000000..def9392a6c --- /dev/null +++ b/i18n/languages/ko/nav.yml @@ -0,0 +1,60 @@ +# Generated by scripts/docs/i18n (`translate --nav`). Never edit by hand; see i18n/README.md. +version: 1 +labels: + What's new in v2: v2에서 달라진 점 + Get started: 시작하기 + Installation: 설치 + First steps: 첫 단계 + Connect to a real host: 실제 호스트에 연결하기 + Testing: 테스트 + Servers: 서버 + Tools: 도구 + Structured Output: 구조화된 출력 + Resources: 리소스 + URI templates: URI 템플릿 + Prompts: 프롬프트 + Completions: 자동 완성 + Images, audio & icons: 이미지, 오디오, 아이콘 + Handling errors: 오류 처리 + Inside your handler: 핸들러 내부 + The Context: Context + Dependencies: 의존성 + Lifespan: Lifespan + Elicitation: 엘리시테이션 + Multi-round-trip requests: 다중 왕복 요청 + Sampling and roots: 샘플링과 루트 + Progress: 진행 상황 + Logging: 로깅 + Subscriptions: 구독 + Running your server: 서버 실행 + Add to an existing app: 기존 앱에 추가하기 + Deploy & scale: 배포와 확장 + Authorization: 인가 + OpenTelemetry: OpenTelemetry + Serving legacy clients: 레거시 클라이언트 지원 + Clients: 클라이언트 + Callbacks: 콜백 + Transports: 트랜스포트 + OAuth: OAuth + Identity assertion: 신원 증명 + Multiple servers: 다중 서버 + Caching: 캐싱 + Protocol versions: 프로토콜 버전 + Deprecated features: 지원 중단 예정 기능 + Advanced: 고급 + The low-level Server: 저수준 Server + Pagination: 페이지네이션 + Middleware: 미들웨어 + Extensions: 확장 + MCP Apps: MCP Apps + Troubleshooting: 문제 해결 + Translations: 번역 + Migration Guide: 마이그레이션 가이드 + API Reference: API 레퍼런스 +state: + labels_hash: 3046f9e5ef09880ff0ab3af257aaee79d0ffb03dc16b11c87e7111760e02a25c + general_prompt_hash: 83aaa955017a24c0c1d3f7b284e61d8441d58e035b60e7fe52a3d816d6cf73c7 + instructions_hash: f711c91054421ca22cd6983bec7c2002650a62b40d3f36b22c5920db61a55186 + glossary_hash: 46445e4b8286fd4d96718e81c79be1bfecdaf74d7c66664435ed411a78111edc + model: claude-opus-5 + translated_at: '2026-07-31T14:32:15Z' diff --git a/i18n/languages/ko/pages/get-started/first-steps.md b/i18n/languages/ko/pages/get-started/first-steps.md new file mode 100644 index 0000000000..78e96ba12a --- /dev/null +++ b/i18n/languages/ko/pages/get-started/first-steps.md @@ -0,0 +1,140 @@ +# 첫걸음 {#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! +``` + +**프롬프트.** 항목은 하나, 필수 인자 `text` 하나를 받는 `summarize`입니다. 적당한 텍스트와 함께 가져오면 `role: user`이고 렌더링된 문자열이 내용인 메시지 하나가 돌아옵니다. 프롬프트는 이게 전부입니다. 메시지를 만드는 함수일 뿐입니다. + +Inspector는 서버를 MCP 서버가 사용할 수 있는 트랜스포트 중 하나인 **stdio**로 실행했습니다. 아직 무엇을 쓸지 고를 필요는 없습니다. 그 이야기는 **[서버 실행하기](../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/languages/ko/pages/handlers/elicitation.md b/i18n/languages/ko/pages/handlers/elicitation.md new file mode 100644 index 0000000000..2a29c23c9f --- /dev/null +++ b/i18n/languages/ko/pages/handlers/elicitation.md @@ -0,0 +1,184 @@ +# 엘리시테이션(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`를 받습니다. `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`는 수락했을 때만 존재합니다. +* `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/languages/ko/pages/index.md b/i18n/languages/ko/pages/index.md new file mode 100644 index 0000000000..d4aa76f0bf --- /dev/null +++ b/i18n/languages/ko/pages/index.md @@ -0,0 +1,97 @@ +# 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입니다. 이를 사용하면 다음과 같은 일을 할 수 있습니다. + +* 모든 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 +``` + +이 명령은 서버를 실행하고, 서버를 이리저리 시험해 볼 수 있는 대화형 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` **자체가** 스키마입니다. +* 요청 파싱도, 직렬화도, 검증 코드도 없습니다. +* 프로토콜 처리 코드는 아예 없습니다. + +타입 힌트와 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)**에서 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/languages/ko/state.json b/i18n/languages/ko/state.json new file mode 100644 index 0000000000..f00fd26d4c --- /dev/null +++ b/i18n/languages/ko/state.json @@ -0,0 +1,56 @@ +{ + "version": 1, + "pages": { + "get-started/first-steps.md": { + "source_commit": "a4f4ccd091138771535e17191123f20b30fda68e", + "source_hash": "c53ca01e59b99b5b44901c7217aae06de5db25adbd725f0611531d0e5436ec1d", + "blocks": [ + "957558ad0a913ea40c2188b9b27f0d2ff38f97a2ea6f10d5e3760ce10009de9c", + "9519788175c0e3bf68a8308be2269c331549eb112740700728ccd4de3fccd613", + "794a36150343d73aa9eb58070f2c8477c1bd3f1940ede544fd48468d0f443a5c", + "2396d578442021f67a33dca3154ac87c3524ebc2f1acdeb984c991165efaf4f2", + "eb2317541cc8b73d004e9d19caa654f802baa2d66bdaeb7a30794c4de0e23a26", + "2006150b64b5a716c533fe91d652bfed8ad08fae4715d59ff8d71e3f51efc9a1", + "f829a210ac8caa4ad935144be33d153706eef4c945dda26a9eab0a3e5e5ee20e" + ], + "general_prompt_hash": "83aaa955017a24c0c1d3f7b284e61d8441d58e035b60e7fe52a3d816d6cf73c7", + "instructions_hash": "f711c91054421ca22cd6983bec7c2002650a62b40d3f36b22c5920db61a55186", + "glossary_hash": "46445e4b8286fd4d96718e81c79be1bfecdaf74d7c66664435ed411a78111edc", + "model": "claude-opus-5", + "translated_at": "2026-07-31T13:34:29Z" + }, + "handlers/elicitation.md": { + "source_commit": "a4f4ccd091138771535e17191123f20b30fda68e", + "source_hash": "57397f9fc329959cedcc36cf3096987637d31f02a5295b65b3c7bf4b81191387", + "blocks": [ + "7fff67cac2af08b655f4c9075daa31f02f80a82748a04fe4adfca796aa190d17", + "133271187b5164f943221adcc94782dca4178c23fdcbedd8f1200e007968091e", + "2f0507fd7c210b0514edd33eb80f257c2e07b4def07d90e5fd1e2a90f98d91c7", + "c6ef424264e4872443d26bdd3905d7d67c59c426518c15f297f995ccaef45f68", + "5dcddd4a14d2c6fc8374947cbfcce89ce8f62ad4b81e53d65d36b4cec41af08a", + "b123965879830bc00ecd6536d7ed22036c168d7750c032ca0fb653bcb40b7272" + ], + "general_prompt_hash": "83aaa955017a24c0c1d3f7b284e61d8441d58e035b60e7fe52a3d816d6cf73c7", + "instructions_hash": "f711c91054421ca22cd6983bec7c2002650a62b40d3f36b22c5920db61a55186", + "glossary_hash": "46445e4b8286fd4d96718e81c79be1bfecdaf74d7c66664435ed411a78111edc", + "model": "claude-opus-5", + "translated_at": "2026-07-31T13:34:29Z" + }, + "index.md": { + "source_commit": "a4f4ccd091138771535e17191123f20b30fda68e", + "source_hash": "f362cda3cadc999e034be6bfcf91605ac06da78916288fc8e0403c72d6457f71", + "blocks": [ + "b84dbfa9fd427f69405da08a12833e7fdfe0a8a3fda59e646ecac0f84d158e85", + "4171bd811a04922c15b4a7d4ff7acb16164cc77ab8dee45c872ac22d320bae05", + "23a7a1ea8f41c840f16ce1419612365b24cf705478e4c0d0486391d0607ff328", + "32ed995fc3b03134b0babce862229a341a86dabd21953af523d7ebb478a24e0d", + "a9c73cfc18afc5c54b2837f0065b06aeecbdb9b4561cb3036f9b0d1aaf4345de" + ], + "general_prompt_hash": "83aaa955017a24c0c1d3f7b284e61d8441d58e035b60e7fe52a3d816d6cf73c7", + "instructions_hash": "f711c91054421ca22cd6983bec7c2002650a62b40d3f36b22c5920db61a55186", + "glossary_hash": "46445e4b8286fd4d96718e81c79be1bfecdaf74d7c66664435ed411a78111edc", + "model": "claude-opus-5", + "translated_at": "2026-07-31T13:34:29Z" + } + } +} diff --git a/i18n/languages/pt-BR/glossary.json b/i18n/languages/pt-BR/glossary.json new file mode 100644 index 0000000000..f18caee835 --- /dev/null +++ b/i18n/languages/pt-BR/glossary.json @@ -0,0 +1,232 @@ +{ + "version": 1, + "keep_in_source_language": [ + "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)\". Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "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. Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "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. Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "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 — provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "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`. Masculine plural: os roots. May take the gloss \"roots (diretórios raiz)\" on first mention. Translating it as raízes is the open alternative — provisional pending native review.", + "avoid": ["raízes"], + "enforce": false + }, + { + "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. Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "source": "capability", + "target": "capacidade", + "note": "What client and server declare during initialization (\"negociação de capacidades\"). Note that funcionalidade is the word for a feature, which is a different thing. Provisional pending native review.", + "avoid": ["habilidade"], + "enforce": false + }, + { + "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\"). Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "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 Latin. Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "source": "handler", + "target": "handler", + "note": "The functions you register on a server; kept in English as everyday Brazilian developer usage does. Masculine: o handler / os handlers. \"manipulador\" reads dated in this audience — provisional pending native review.", + "avoid": ["manipulador"], + "enforce": false + }, + { + "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. Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "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 — provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "source": "client", + "target": "cliente", + "note": "Masculine: o cliente. The `Client` class and the `mcp.client` module are code and stay Latin. Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "source": "server", + "target": "servidor", + "note": "Masculine: o servidor. The `MCPServer`, `Server` and `ServerSession` classes are code and stay Latin. Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "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. Provisional pending native review.", + "avoid": ["anfitrião", "hospedeiro"], + "enforce": true + }, + { + "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. Provisional pending native review.", + "avoid": ["pedido"], + "enforce": false + }, + { + "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. Provisional pending native review.", + "avoid": ["ficha"], + "enforce": false + }, + { + "source": "lifespan", + "target": "lifespan", + "note": "The SDK feature and its `lifespan=` parameter; kept in English so the prose matches the parameter name. Masculine: o lifespan. May take the gloss \"lifespan (ciclo de vida do servidor)\" on first mention. Provisional pending native review.", + "avoid": ["expectativa de vida", "vida útil"], + "enforce": true + }, + { + "source": "callback", + "target": "callback", + "note": "Kept in English, covering both OAuth redirect callbacks and function callbacks. Masculine: o callback / os callbacks. Provisional pending native review.", + "avoid": ["retorno de chamada"], + "enforce": false + }, + { + "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. Provisional pending native review.", + "avoid": ["deployar", "deployado"], + "enforce": true + }, + { + "source": "library", + "target": "biblioteca", + "note": "Feminine: a biblioteca. The false friend \"livraria\" means a bookshop and is never right here.", + "avoid": ["livraria"], + "enforce": true + }, + { + "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. Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "source": "file", + "target": "arquivo", + "note": "Masculine: o arquivo. \"ficheiro\" is European Portuguese and is always an error in this Brazilian Portuguese target.", + "avoid": ["ficheiro"], + "enforce": true + }, + { + "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"], + "enforce": true + } + ] +} diff --git a/i18n/languages/pt-BR/instructions.md b/i18n/languages/pt-BR/instructions.md new file mode 100644 index 0000000000..60b74c7484 --- /dev/null +++ b/i18n/languages/pt-BR/instructions.md @@ -0,0 +1,190 @@ +# Brazilian Portuguese (pt-BR) — translation instructions + +Target language: Brazilian Portuguese (Português do Brasil), directory and +URL code `pt-BR`, page language tag `pt-BR`. 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 listed under `keep_in_source_language` 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 `session.list_tools()`. + A glossary term used as a code-font identifier stays Latin 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 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 under `pages/`, which the next translation run overwrites. diff --git a/i18n/languages/pt-BR/nav.yml b/i18n/languages/pt-BR/nav.yml new file mode 100644 index 0000000000..2fbecb5399 --- /dev/null +++ b/i18n/languages/pt-BR/nav.yml @@ -0,0 +1,60 @@ +# Generated by scripts/docs/i18n (`translate --nav`). Never edit by hand; see i18n/README.md. +version: 1 +labels: + What's new in v2: Novidades na v2 + Get started: Comece agora + Installation: Instalação + First steps: Primeiros passos + Connect to a real host: Conecte a um host real + Testing: Testes + Servers: Servidores + Tools: Ferramentas + Structured Output: Saída estruturada + Resources: Recursos + URI templates: Templates de URI + Prompts: Prompts + Completions: Completions + Images, audio & icons: Imagens, áudio e ícones + Handling errors: Tratamento de erros + Inside your handler: Dentro do seu handler + The Context: O Context + Dependencies: Dependências + Lifespan: Lifespan + Elicitation: Elicitação + Multi-round-trip requests: Requisições com múltiplas idas e voltas + Sampling and roots: Amostragem e roots + Progress: Progresso + Logging: Logging + Subscriptions: Assinaturas + Running your server: Executando seu servidor + Add to an existing app: Adicionar a um app existente + Deploy & scale: Deploy e escala + Authorization: Autorização + OpenTelemetry: OpenTelemetry + Serving legacy clients: Atendendo clientes legados + Clients: Clientes + Callbacks: Callbacks + Transports: Transportes + OAuth: OAuth + Identity assertion: Asserção de identidade + Multiple servers: Múltiplos servidores + Caching: Cache + Protocol versions: Versões do protocolo + Deprecated features: Funcionalidades obsoletas + Advanced: Avançado + The low-level Server: O Server de baixo nível + Pagination: Paginação + Middleware: Middleware + Extensions: Extensões + MCP Apps: MCP Apps + Troubleshooting: Solução de problemas + Translations: Traduções + Migration Guide: Guia de migração + API Reference: Referência da API +state: + labels_hash: 3046f9e5ef09880ff0ab3af257aaee79d0ffb03dc16b11c87e7111760e02a25c + general_prompt_hash: 83aaa955017a24c0c1d3f7b284e61d8441d58e035b60e7fe52a3d816d6cf73c7 + instructions_hash: 51b14d84a5a5dab2d873707f559a25f4166c59ded035518f4d4fcfae6c224586 + glossary_hash: 8ce746369c094e13b0891d67ddb08494907a99244d5090806b4fab5921fecd52 + model: claude-opus-5 + translated_at: '2026-07-31T14:32:28Z' diff --git a/i18n/languages/pt-BR/pages/get-started/first-steps.md b/i18n/languages/pt-BR/pages/get-started/first-steps.md new file mode 100644 index 0000000000..2a4898900b --- /dev/null +++ b/i18n/languages/pt-BR/pages/get-started/first-steps.md @@ -0,0 +1,139 @@ +# Primeiros passos {#first-steps} + +A **[página inicial](../index.md)** vai direto ao ponto: escreva um servidor, execute-o, chame 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 agente. É com ele que o usuário conversa. +* Um **cliente** vive dentro do host e fala MCP. O host roda um cliente para cada servidor ao qual está conectado. +* Um **servidor** é o que você constrói com este SDK. Ele expõe coisas para os clientes. Nunca fala diretamente com o modelo. + +Você escreve o servidor. Os hosts são produto de outra pessoa. O SDK também te dá um `Client`. Você vai usá-lo para testar seus servidores, e ele aparece mais adiante nesta página. + +## Os três primitivos {#the-three-primitives} + +Um servidor expõe exatamente três tipos de coisa. O que separa um do outro é **quem decide usá-los**: + +| Primitivo | Controlado por | O que é | Exemplo | +|---------------|-----------------|------------------------------------------------------|---------------------------------------| +| **Ferramentas** | O modelo | Uma função que o modelo chama para executar uma ação | Uma chamada de API, uma escrita em banco | +| **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 | + +"Controlado por" é a razão de ser dessa 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** escolheu usá-lo. + +!!! info + Se você já construiu uma API web, boa parte da intuição já está aí: um **recurso** é um `GET` + (carrega dados e não muda nada) e uma **ferramenta** é um `POST` (faz trabalho e pode ter + efeitos colaterais). Um **prompt** não tem análogo em HTTP; está mais para uma query salva que o + usuário executa pelo nome. + +## Um servidor, os 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 é o registro inteiro: + +* `@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 ele retorna vira uma mensagem do 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 type hints. 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 primitivo; percorra elas na ordem. + +**Tools.** 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. + +**Resources.** A lista *Resources* está vazia. `greeting` está em **Resource Templates**, porque `greeting://{name}` tem um parâmetro: não existe um recurso único para listar até alguém fornecer um `name`. Informe `World` e leia: + +```text +Hello, World! +``` + +**Prompts.** Uma entrada: `summarize`, com um único argumento obrigatório `text`. Busque-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 sobre **stdio**, um dos transportes que um servidor MCP pode falar. Você ainda não precisa escolher um; **[Executando seu servidor](../run/index.md)** é a página para isso. + +## Capacidades {#capabilities} + +Você viu três abas no Inspector. Como ele soube que eram três? + +Quando um cliente se conecta, o servidor declara suas **capacidades**: quais famílias de requisições ele vai atender. O cliente usa essa declaração para decidir o que sequer vale a pena pedir. Você nunca escreveu isso; o `MCPServer` declara por você. + +Veja com seus próprios olhos. 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 são as **capacidades** declaradas 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 os três primitivos, então os três são sempre declarados. + +Repare no que não está 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 está ausente e um cliente bem-comportado não vai pedir. 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 destes docs são testados, e + é assim que você vai testar os seus. Ele tem uma página inteira: **[Testes](testing.md)**. + +## O que você não escreveu {#what-you-did-not-write} + +Releia 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`: todos servidos para 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 aconteceu dentro do `mcp dev` e do `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 primitivo: `@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 são declaradas por você automaticamente, e um cliente só pede aquilo que o servidor declara. +* `Client(mcp)` se conecta ao objeto do servidor em memória: sua bancada de testes desde o primeiro dia. + +O próximo passo é **[Conectar a um host real](real-host.md)**: esse servidor dentro do Claude Desktop ou de uma IDE, para valer. Depois, **[Testes](testing.md)**: uma página, um cliente em memória, e você nunca mais fica no chute sobre se aquilo funciona. Em seguida, cada primitivo ganha sua própria página, começando por aquele que o modelo comanda: **[Ferramentas](../servers/tools.md)**. diff --git a/i18n/languages/pt-BR/pages/handlers/elicitation.md b/i18n/languages/pt-BR/pages/handlers/elicitation.md new file mode 100644 index 0000000000..301fd90a7d --- /dev/null +++ b/i18n/languages/pt-BR/pages/handlers/elicitation.md @@ -0,0 +1,185 @@ +# Elicitação {#elicitation} + +Uma ferramenta que está na metade do trabalho e à qual falta uma resposta não precisa falhar. + +**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 a mesma chamada de função. + +Há dois modos: + +* **Modo formulário**: você precisa de um valor (uma confirmação, uma data, uma quantidade). Você descreve os campos e 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 faz lá passa pelo protocolo. + +E há duas formas de perguntar. A que você deve escolher é um **resolvedor**: você pendura a pergunta em um parâmetro e o SDK pergunta - em qualquer conexão, seja qual for a era do protocolo que o cliente fala. 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 controla a ferramenta inteira - *tem certeza? qual das três contas correspondentes?* - pode sair do corpo da ferramenta e virar um **resolvedor**, e o framework 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 direto 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 próprio argumento `path` da ferramenta, lista a pasta e **só elicita quando é necessário** - uma pasta vazia resolve para `Confirm(ok=True)` sem nenhuma ida e volta até o cliente. +* `delete_folder` anota `ElicitationResult[Confirm]`, então o framework injeta o resultado inteiro e a ferramenta faz `match` em todos os casos: aceitar-e-confirmar, aceitar-mas-manter (`ok=False`), recusar, cancelar. +* O parâmetro `confirm` nunca aparece no schema de entrada da ferramenta - o cliente fornece `path`, o resolvedor fornece `confirm`. + +Anote o modelo sem o invólucro (`Annotated[Confirm, Resolve(confirm_delete)]`) quando a ferramenta não precisar ramificar: ela recebe o modelo no accept e a chamada aborta com um erro no decline ou no cancel. + +Um resolvedor funciona em **todas** as conexões. Para um cliente em uma conexão legada, o SDK envia a pergunta diretamente; em uma conexão **2026-07-28**, o SDK *retorna* a pergunta a partir da chamada, e a próxima tentativa do cliente carrega a resposta. Seu resolvedor nunca percebe a diferença; o que acontece por baixo dos panos é **[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 computam sem perguntar, dependências de dependências, o que o modelo pode e não pode fornecer - está na 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`** é o que dá acesso a `ctx.elicit`; qualquer ferramenta pode receber um. Esse objeto tem uma página só dele: **[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 volta pela própria `book_table`. Uma resposta é 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 a sua mensagem e, ao lado dela, 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 preenche o campo de antemão e o torna opcional. É a mesma maquinaria 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` levanta um erro 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` informa o que o usuário fez, e há 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 no `"accept"`, e é por isso que o exemplo verifica `result.action` primeiro. Seu verificador de tipos garante a ordem: depois de `result.action == "accept"`, `result.data` é um `AlternativeDate`; antes disso, não existe `.data` nenhum. + +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 o seu modelo antes de o seu código vê-la. Um cliente que envia + `"maybe"` para um `bool` não corrompe a sua reserva: a chamada falha com um + erro de incompatibilidade de schema e o seu `if` nunca executa. + +## Envie o usuário para uma URL {#send-the-user-to-a-url} + +Algumas coisas não podem passar pelo modelo nem pelo cliente: credenciais, números de cartão, consentimento OAuth. Para essas você não pede dados; você pede que o usuário 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 ser visitada e um `elicitation_id` que você escolhe: qualquer string que identifique essa elicitação dentro do seu servidor. +* O resultado tem uma ação e nada mais. `"accept"` significa que o usuário concordou em abrir a URL, **não** que ele terminou o que havia do outro lado. +* O pagamento acontece fora de banda, entre o navegador do usuário e o seu provedor de pagamento. Nenhum conteúdo volta pelo MCP. + +Olhe para a segunda ferramenta. Quando o seu servidor descobre que o fluxo fora de banda terminou (um webhook, um polling; aqui isso 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 mostrar *"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 cobre os dois modos. `params` é uma união de `ElicitRequestFormParams` e `ElicitRequestURLParams`; `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 de verdade 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 descobre que esse cliente pode ser questionado. 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 essas só existem em uma + sessão com handshake clássico, e é por isso que este cliente passa `mode="legacy"`. + Em uma conexão **2026-07-28**, uma ferramenta pergunta *retornando* a pergunta a partir da chamada; + esse fluxo é **[Requisições com múltiplas idas e voltas](multi-round-trip.md)**. + +### Experimente {#try-it} + +Inicie o `server.py` de modo formulário com `ctx.elicit` (o do `book_table`) em Streamable HTTP (**[Executando seu servidor](../run/index.md)** tem o comando de uma linha), depois execute o `main()` do cliente e peça uma mesa ao `book_table` para 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 esteve esperando dentro de `await ctx.elicit(...)` esse tempo todo, conclui a reserva: + +```text +Booked a table for 2 on 2025-12-27. +``` + +Agora troque pelo `server.py` de modo URL e aponte o mesmo `main()` para `pay_deposit`: o mesmo callback segue o 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 + de novo. 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á + ninguém a quem perguntar. Sua ferramenta não recebeu um `"decline"`; ela recebeu uma exceção. Projete pensando nisso: toda + elicitação precisa de uma resposta sensata para "e se eu não puder perguntar?". + +## Resumo {#recap} + +* Um parâmetro anotado com `Annotated[T, Resolve(fn)]` é preenchido por um resolvedor, que retorna `Elicit(...)` quando precisa perguntar. Funciona em todas as conexões. +* O schema é um modelo Pydantic plano: só campos primitivos, validados na volta. +* `result.action` é `"accept"`, `"decline"` ou `"cancel"`; `result.data` só existe no accept. +* `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 que não pode passar pelo modelo (`ctx.session.send_elicit_complete(elicitation_id)` avisa que a parte fora de banda terminou). Ambas 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 está por baixo desse retorno (o loop de retry, proteger o `requestState`, conduzir o processo você mesmo) está em **[Requisições com múltiplas idas e voltas](multi-round-trip.md)**. diff --git a/i18n/languages/pt-BR/pages/index.md b/i18n/languages/pt-BR/pages/index.md new file mode 100644 index 0000000000..fcca972715 --- /dev/null +++ b/i18n/languages/pt-BR/pages/index.md @@ -0,0 +1,97 @@ +# MCP Python SDK {#mcp-python-sdk} + +!!! info "Esta documentação cobre a v2, a linha de release estável atual" + Chegou agora na v2 ou está vindo da v1? **[O que há de novo na v2](whats-new.md)** é o tour de cinco minutos sobre o que mudou, e o **[Guia de migração](migration.md)** cobre cada mudança incompatível. + Ainda na v1.x? A documentação dela fica na [documentação da v1.x](https://py.sdk.modelcontextprotocol.io/v1/). + Achou algo confuso ou malfeito? [Conte pra gente](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 tarefa de *fornecer* contexto da interação com o LLM em si. + +Este é o SDK oficial em Python para ele. Com ele você pode: + +* **Construir servidores MCP** que expõem ferramentas, recursos e prompts para qualquer host MCP. +* **Construir clientes MCP** que se conectam a qualquer servidor MCP. +* Falar 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]` te dá o comando `mcp`; você vai querer ele para o desenvolvimento. +Veja [Instalação](get-started/installation.md) para entender 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 cutucar o servidor. Abra a URL que ele imprime. + +!!! note + O Inspector é um app Node.js, então 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 type hints. O Claude vai fazer o mesmo, e todo outro host MCP também. + +Agora vá em **Resources** e leia `greeting://World`: + +```text +Hello, World! +``` + +### Recapitulando {#recap} + +Olhe de novo para o 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. +* Nenhum tratamento de protocolo, nada. + +Você escreveu duas funções Python com type hints e uma docstring. O SDK faz o resto. + +## Para onde ir agora {#where-to-go-next} + +* **[Primeiros passos](get-started/index.md)** leva você da instalação até um servidor funcionando e testado. +* Está 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. +* Caçando uma mensagem de erro específica? **[Solução de problemas](troubleshooting.md)** é indexado pelo texto literal. +* Curioso sobre o que mudou na v2? **[O que há de novo na v2](whats-new.md)** é o tour de cinco minutos. +* Migrando da v1? Comece pelo **[Guia de migração](migration.md)**. +* Procurando 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/languages/pt-BR/state.json b/i18n/languages/pt-BR/state.json new file mode 100644 index 0000000000..d312386c56 --- /dev/null +++ b/i18n/languages/pt-BR/state.json @@ -0,0 +1,56 @@ +{ + "version": 1, + "pages": { + "get-started/first-steps.md": { + "source_commit": "a4f4ccd091138771535e17191123f20b30fda68e", + "source_hash": "c53ca01e59b99b5b44901c7217aae06de5db25adbd725f0611531d0e5436ec1d", + "blocks": [ + "a414d89b04396dae3cd6547fef422d84c69a0a81c6e9b0e1edf8c4fc6ad4dd07", + "35e0e23098e40594a8bf853650bb63157dd6cf0c333295faf205d71df2f95f3f", + "74af6a27b6aac1914852f5e30179fc48ba32028925c2d6fea3adc812931c6672", + "faa9ab2d665d58bcca6bec3ea9193afdb99bc7c592e37e331cf9f77cc4999f25", + "e60053449c06cfb0125bc87f9fae5b40e6a6e17280774bf352dd95ab67b9e835", + "6a9cd5b468a3283e80c12991b9726c989dda206b7dd9fdb607247c771c0a9f77", + "3f76b04b8b109f46c388f1b44b3a1480558608324bcb155bccb72c6808311c3c" + ], + "general_prompt_hash": "83aaa955017a24c0c1d3f7b284e61d8441d58e035b60e7fe52a3d816d6cf73c7", + "instructions_hash": "51b14d84a5a5dab2d873707f559a25f4166c59ded035518f4d4fcfae6c224586", + "glossary_hash": "8ce746369c094e13b0891d67ddb08494907a99244d5090806b4fab5921fecd52", + "model": "claude-opus-5", + "translated_at": "2026-07-31T13:40:52Z" + }, + "handlers/elicitation.md": { + "source_commit": "a4f4ccd091138771535e17191123f20b30fda68e", + "source_hash": "57397f9fc329959cedcc36cf3096987637d31f02a5295b65b3c7bf4b81191387", + "blocks": [ + "b03c276423e71cc4cead3b8a1390b5894a2adb161e831fec3e8df01d00a6bc79", + "eafc4d4de77f386cc28d33b8bc70e86cf79c314ca28864ef4fce3d429cf887d2", + "e50e4d088bbb68e8d4705ab50ceb8c2c78fb95b778515223d4ef71ae92c05158", + "f00bd3f0b8aedf569a09d0b78a0df3187084c234794810fc0c5462b157c29247", + "3593e988a780d9785ec52e0de26386b327dd17c6917ba7ad159698c817ff02f0", + "b05122e044c4952732238849b60417fbb5a5acb39de994b410f615230ce6e291" + ], + "general_prompt_hash": "83aaa955017a24c0c1d3f7b284e61d8441d58e035b60e7fe52a3d816d6cf73c7", + "instructions_hash": "51b14d84a5a5dab2d873707f559a25f4166c59ded035518f4d4fcfae6c224586", + "glossary_hash": "8ce746369c094e13b0891d67ddb08494907a99244d5090806b4fab5921fecd52", + "model": "claude-opus-5", + "translated_at": "2026-07-31T13:40:52Z" + }, + "index.md": { + "source_commit": "a4f4ccd091138771535e17191123f20b30fda68e", + "source_hash": "f362cda3cadc999e034be6bfcf91605ac06da78916288fc8e0403c72d6457f71", + "blocks": [ + "f05d1dc9bee972fa627111825a25872356c4957967dfdafc22d9fa99b4253505", + "37440ce9ae841d4bd04154d1d6dbb7c92662239ad5a4e539061888c2ac2091c7", + "4955f916ba57b525f1ba5df82aa0a1eac73f3e73a82dc171e96d0fdc16d60653", + "67d1463ac79d4c50092ddaa6bd53a39e2164a7fd803b6efeb9aea6bce2c28eaa", + "e92390a953b8b6b5e397b644e6c2415115e112ca3daf5159e3324c032bbff207" + ], + "general_prompt_hash": "83aaa955017a24c0c1d3f7b284e61d8441d58e035b60e7fe52a3d816d6cf73c7", + "instructions_hash": "51b14d84a5a5dab2d873707f559a25f4166c59ded035518f4d4fcfae6c224586", + "glossary_hash": "8ce746369c094e13b0891d67ddb08494907a99244d5090806b4fab5921fecd52", + "model": "claude-opus-5", + "translated_at": "2026-07-31T13:40:52Z" + } + } +} diff --git a/i18n/languages/zh-CN/glossary.json b/i18n/languages/zh-CN/glossary.json new file mode 100644 index 0000000000..95cd2dc157 --- /dev/null +++ b/i18n/languages/zh-CN/glossary.json @@ -0,0 +1,232 @@ +{ + "version": 1, + "keep_in_source_language": [ + "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.", + "avoid": [], + "enforce": false + }, + { + "source": "resource", + "target": "资源", + "note": "MCP protocol noun; \"resource template\" → 资源模板. `resources/read` stays Latin. Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "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.", + "avoid": [], + "enforce": false + }, + { + "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": ["抽样", "取样"], + "enforce": true + }, + { + "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": ["根节点"], + "enforce": false + }, + { + "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.", + "avoid": [], + "enforce": false + }, + { + "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.", + "avoid": ["功能"], + "enforce": false + }, + { + "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.", + "avoid": [], + "enforce": false + }, + { + "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": ["会议"], + "enforce": false + }, + { + "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.", + "avoid": [], + "enforce": false + }, + { + "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.", + "avoid": [], + "enforce": false + }, + { + "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.", + "avoid": [], + "enforce": false + }, + { + "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": ["伺服器", "服务端"], + "enforce": false + }, + { + "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.", + "avoid": ["主机"], + "enforce": false + }, + { + "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.", + "avoid": [], + "enforce": false + }, + { + "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.", + "avoid": ["寿命"], + "enforce": false + }, + { + "source": "callback", + "target": "回调", + "note": "Client callbacks; parameter names such as `sampling_callback` stay Latin. Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "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.", + "avoid": [], + "enforce": false + }, + { + "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.", + "avoid": [], + "enforce": false + }, + { + "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": ["电线"], + "enforce": false + }, + { + "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.", + "avoid": ["废弃", "淘汰"], + "enforce": false + }, + { + "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-in-source list and stay in English in prose (\"The Context\" → Context). Provisional pending native review.", + "avoid": [], + "enforce": false + }, + { + "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.", + "avoid": [], + "enforce": false + }, + { + "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": ["您"], + "enforce": true + } + ] +} diff --git a/i18n/languages/zh-CN/instructions.md b/i18n/languages/zh-CN/instructions.md new file mode 100644 index 0000000000..273c2f3529 --- /dev/null +++ b/i18n/languages/zh-CN/instructions.md @@ -0,0 +1,146 @@ +# Simplified Chinese (zh-CN) — translation instructions + +Target language: Simplified Chinese (简体中文), directory and URL code +`zh-CN`, 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 秒). +- Do not hard-wrap a line between two Han characters (the renderer turns + the line break into a stray space). Keep the source's block layout and + wrap only where the source wraps or after full-width punctuation. + +## 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 listed under `keep_in_source_language`, 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` 能力. +- 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/languages/zh-CN/nav.yml b/i18n/languages/zh-CN/nav.yml new file mode 100644 index 0000000000..7fedba1451 --- /dev/null +++ b/i18n/languages/zh-CN/nav.yml @@ -0,0 +1,60 @@ +# Generated by scripts/docs/i18n (`translate --nav`). Never edit by hand; see i18n/README.md. +version: 1 +labels: + What's new in v2: v2 新特性 + Get started: 快速上手 + Installation: 安装 + First steps: 第一步 + Connect to a real host: 连接真实宿主 + Testing: 测试 + Servers: 服务器 + Tools: 工具 + Structured Output: 结构化输出 + Resources: 资源 + URI templates: URI 模板 + Prompts: 提示词 + Completions: 补全 + Images, audio & icons: 图像、音频与图标 + Handling errors: 错误处理 + Inside your handler: 在处理函数内部 + The Context: Context + Dependencies: 依赖 + Lifespan: 生命周期 + Elicitation: 征询 + Multi-round-trip requests: 多轮往返请求 + Sampling and roots: 采样与根目录 + Progress: 进度 + Logging: 日志 + Subscriptions: 订阅 + Running your server: 运行服务器 + Add to an existing app: 接入已有应用 + Deploy & scale: 部署与扩展 + Authorization: 授权 + OpenTelemetry: OpenTelemetry + Serving legacy clients: 服务旧版客户端 + Clients: 客户端 + Callbacks: 回调 + Transports: 传输方式 + OAuth: OAuth + Identity assertion: 身份断言 + Multiple servers: 多服务器 + Caching: 缓存 + Protocol versions: 协议版本 + Deprecated features: 已弃用特性 + Advanced: 进阶 + The low-level Server: 低层 Server + Pagination: 分页 + Middleware: 中间件 + Extensions: 扩展 + MCP Apps: MCP Apps + Troubleshooting: 故障排查 + Translations: 翻译 + Migration Guide: 迁移指南 + API Reference: API 参考 +state: + labels_hash: 3046f9e5ef09880ff0ab3af257aaee79d0ffb03dc16b11c87e7111760e02a25c + general_prompt_hash: 83aaa955017a24c0c1d3f7b284e61d8441d58e035b60e7fe52a3d816d6cf73c7 + instructions_hash: ef964aaa58a2522a120807382a4ccbf12b72d89231f5246e2cbfb3d72759e0a8 + glossary_hash: 6ceb6f93def96ce498f02440a85fee82a8365a044a681877cedacd25eeffb962 + model: claude-opus-5 + translated_at: '2026-07-31T14:31:45Z' diff --git a/i18n/languages/zh-CN/pages/get-started/first-steps.md b/i18n/languages/zh-CN/pages/get-started/first-steps.md new file mode 100644 index 0000000000..a89ad8af9a --- /dev/null +++ b/i18n/languages/zh-CN/pages/get-started/first-steps.md @@ -0,0 +1,139 @@ +# 第一步 {#first-steps} + +**[首页](../index.md)** 的节奏很快:写一个服务器,运行它,调用一个工具。 + +这一页会慢一些,把服务器能暴露的三样东西都讲一遍,并为沿途的每样东西取个名字。 + +## 宿主、客户端和服务器 {#host-client-and-server} + +从这里开始,每一页都会出现的三个词: + +* **宿主**就是 LLM 应用:Claude、某个 IDE、某个 agent 运行时。它是用户正在对话的那一方。 +* **客户端**住在宿主内部,负责讲 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/languages/zh-CN/pages/handlers/elicitation.md b/i18n/languages/zh-CN/pages/handlers/elicitation.md new file mode 100644 index 0000000000..bd4623514d --- /dev/null +++ b/i18n/languages/zh-CN/pages/handlers/elicitation.md @@ -0,0 +1,181 @@ +# 征询(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)请求](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 回来。 + +看第二个工具。当你的服务器得知带外流程已经结束(通过 webhook、轮询;这里用第二个工具来模拟),`ctx.session.send_elicit_complete(...)` 会带着同一个 `elicitation_id` 发出 `notifications/elicitation/complete`。客户端就是这样知道它可以不再显示“等待支付……”的。没有它,客户端只能靠猜。 + +## 客户端一侧 {#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`:同一个回调走另一条分支,打印出支付链接,工具返回“在浏览器中完成支付。”。一次往返,在调用中途,两个方向都走了一遍。 + +!!! 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/languages/zh-CN/pages/index.md b/i18n/languages/zh-CN/pages/index.md new file mode 100644 index 0000000000..3885d0436c --- /dev/null +++ b/i18n/languages/zh-CN/pages/index.md @@ -0,0 +1,97 @@ +# 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]` 这个 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`。✨ + +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/languages/zh-CN/state.json b/i18n/languages/zh-CN/state.json new file mode 100644 index 0000000000..77339b5f4c --- /dev/null +++ b/i18n/languages/zh-CN/state.json @@ -0,0 +1,56 @@ +{ + "version": 1, + "pages": { + "get-started/first-steps.md": { + "source_commit": "a4f4ccd091138771535e17191123f20b30fda68e", + "source_hash": "c53ca01e59b99b5b44901c7217aae06de5db25adbd725f0611531d0e5436ec1d", + "blocks": [ + "6c1719d1b1d971f210414beeeda7d57dc0ca021733ceb9f00d429e80994e9d41", + "1255f41f964d127ca92bcc1c47943fb0022ddfe6f76441a22b751bad43f4fed4", + "d2809fac4ea901675dccd25d75929faf113f4dfa24b2439c03975cd276222291", + "2e9d80dcc48221ac02d8f33494c4e8db151b6efbf4f5abb415f6b684c18b8261", + "97cc87a3ee1eb3c2e8c797b462a31bf629f67f035eadb4ab12bab7dbafd427fa", + "0e34a29dbcf80e3db79f3262865488955bf974986ceeb6461efd6f03ff16c7d7", + "bc0c853838e355b99003368e842fc75a5dc8438cb90e7497b9f8f6947c45c604" + ], + "general_prompt_hash": "83aaa955017a24c0c1d3f7b284e61d8441d58e035b60e7fe52a3d816d6cf73c7", + "instructions_hash": "ef964aaa58a2522a120807382a4ccbf12b72d89231f5246e2cbfb3d72759e0a8", + "glossary_hash": "6ceb6f93def96ce498f02440a85fee82a8365a044a681877cedacd25eeffb962", + "model": "claude-opus-5", + "translated_at": "2026-07-31T13:24:47Z" + }, + "handlers/elicitation.md": { + "source_commit": "a4f4ccd091138771535e17191123f20b30fda68e", + "source_hash": "57397f9fc329959cedcc36cf3096987637d31f02a5295b65b3c7bf4b81191387", + "blocks": [ + "dab6ff16029e6aa5d3cc5432b04a5dd2b2119fcb571a991b34983bb81316c6ed", + "e4767cbd62d4851af5ca7179f6dd4df97ea00ef7d61cb746df565e98f1b2dfae", + "3863b94814501628ed96fce834124641ff92baa861152838ac917df11ad4108c", + "4bd62d5c1611c3977cfcd87726f94dc75f717245e7a8a3643d72ba7530a22759", + "71ba2ba884fda3a651deaa7bd8305f1266ad393fe2fbdcc8ebb24bdbceb5f48e", + "a0145bafdbf18798fb872a2db86c35424132e916363b2795eefdc54bfe668f83" + ], + "general_prompt_hash": "83aaa955017a24c0c1d3f7b284e61d8441d58e035b60e7fe52a3d816d6cf73c7", + "instructions_hash": "ef964aaa58a2522a120807382a4ccbf12b72d89231f5246e2cbfb3d72759e0a8", + "glossary_hash": "6ceb6f93def96ce498f02440a85fee82a8365a044a681877cedacd25eeffb962", + "model": "claude-opus-5", + "translated_at": "2026-07-31T13:24:47Z" + }, + "index.md": { + "source_commit": "a4f4ccd091138771535e17191123f20b30fda68e", + "source_hash": "f362cda3cadc999e034be6bfcf91605ac06da78916288fc8e0403c72d6457f71", + "blocks": [ + "44b7aeabfbe86cf51f65d8316d7ecff8408c6875bb1af28536aad3a32d09e8e2", + "b9e51ca982b8442dbeddac6d422d62e56cc2d842d69992b6791aa40e368b8706", + "55a87e33f3d8de6b87b88e377e79e417facaa6fc4a312ddec11cbd84850eab79", + "adfd033a5074cf0ed10dbf29de693b0ec88d35509ad88b8853cd6e0d33c6f009", + "7c41fbcc22294afafae716f0765219e5380c09644a1d63d73f7fe17e70ae815a" + ], + "general_prompt_hash": "83aaa955017a24c0c1d3f7b284e61d8441d58e035b60e7fe52a3d816d6cf73c7", + "instructions_hash": "ef964aaa58a2522a120807382a4ccbf12b72d89231f5246e2cbfb3d72759e0a8", + "glossary_hash": "6ceb6f93def96ce498f02440a85fee82a8365a044a681877cedacd25eeffb962", + "model": "claude-opus-5", + "translated_at": "2026-07-31T13:24:47Z" + } + } +} diff --git a/mkdocs.yml b/mkdocs.yml index 06b293f876..f215e692ed 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/ diff --git a/pyproject.toml b/pyproject.toml index 3c814106d1..d6cb23c740 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -102,6 +102,9 @@ docs = [ # 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", + # The docs translation tool (scripts/docs/i18n) calls the Claude API for + # the `translate` and `verify` commands; the rest of the pipeline is offline. + "anthropic>=0.120.2", ] codegen = ["datamodel-code-generator==0.57.0"] @@ -160,6 +163,10 @@ include = [ "examples/servers", "examples/snippets", "examples/clients", + "scripts/docs/i18n", + "scripts/docs/navigation.py", + "scripts/docs/build_config.py", + "scripts/docs/check_anchors.py", ] venvPath = "." venv = ".venv" @@ -172,9 +179,13 @@ extraPaths = ["examples"] # those private functions instead of testing the private functions directly. It makes it easier to maintain the code source # and refactor code that is not public. executionEnvironments = [ + # scripts/docs is the import root of the docs tooling: the translation tool + # package (i18n) imports its sibling build scripts as top-level modules. + { root = "scripts/docs" }, { root = "tests", extraPaths = [ ".", "examples", + "scripts/docs", ], reportUnusedFunction = false, reportPrivateUsage = false }, { root = "examples/stories", extraPaths = [ "examples", @@ -243,6 +254,9 @@ strict-no-cover = { git = "https://github.com/pydantic/strict-no-cover" } [tool.pytest.ini_options] log_cli = true xfail_strict = true +# tests/docs_i18n exercises the translation tool under scripts/docs/, whose +# modules import each other with scripts/docs as the import root. +pythonpath = ["scripts/docs"] markers = [ "requirement(id): links a test to the entry in tests/interaction/_requirements.py it exercises", ] @@ -278,6 +292,11 @@ MD033 = false # no-inline-html Inline HTML MD041 = false # first-line-heading/first-line-h1 MD046 = false # indented-code-blocks MD059 = false # descriptive-link-text +# link-fragments: heading anchors are pinned in the docs source ({#id}, +# scripts/docs/check_anchors.py). The custom-anchor pattern here can't express +# an underscore that isn't word-internal, so those fragments are left to the +# docs build's own link validation. +MD051.ignored_pattern = "(^|-)_|_-" # https://coverage.readthedocs.io/en/latest/config.html#run [tool.coverage.run] diff --git a/scripts/docs/build.sh b/scripts/docs/build.sh index 8f545761bc..71c98f456a 100755 --- a/scripts/docs/build.sh +++ b/scripts/docs/build.sh @@ -1,15 +1,34 @@ #!/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 plus one machine-translated site per enabled +# language under `site//` (see i18n/languages.yml). # -# 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 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/. +# 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 anchor, order-independence and cross-reference +# checks Zensical doesn't do itself), then generate llms.txt and the per-page +# markdown renditions. A language site is the lighter recipe against a staged +# docs tree (English pages overlaid with that language's translations and +# stamped with status banners): it carries no API reference of its own — its +# nav entry and its prose links into `api/` point at the English one — so it +# has no mkdocstrings pass and none of the API generation or render-order +# work. This script is the single owner of both recipes, 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/. +# +# Environment: +# DOCS_LANGUAGES=en-only build only the English site (fast local loop) +# DOCS_LANGUAGES=ja,ko build only these language sites after English +# DOCS_SITE_URL= the URL the built site is served from. Defaults +# to mkdocs.yml's site_url (production); a PR +# preview passes its own host so that every +# absolute link the build bakes — each site's +# site_url, the language switcher, the banners' +# English-page links, the language sites' links +# into the English API reference — resolves on the +# host the reader is actually browsing. # # Usage: # scripts/docs/build.sh @@ -26,21 +45,67 @@ 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. The staged language trees are +# rebuilt from scratch for the same reason. +rm -rf .cache site .build/i18n + +# Fast, no-network gates before anything is generated or built: every prose +# heading carries an explicit anchor (so anchors survive translation and +# rewording), and the translation config plus the committed translations are +# structurally valid, so an invalid page can never reach a site. +uv run --frozen --no-sync python scripts/docs/check_anchors.py +uv run --frozen --no-sync python scripts/docs/i18n check + +# The language sites this build produces: every enabled language, a chosen +# subset (DOCS_LANGUAGES=ja,ko), or none (DOCS_LANGUAGES=en-only). Decided once +# so the English config and every language config get the same switcher list, +# and the switcher can never link a site the build didn't make. +if [[ "${DOCS_LANGUAGES:-}" == "en-only" ]]; then + languages="" +elif [[ -n "${DOCS_LANGUAGES:-}" ]]; then + languages="${DOCS_LANGUAGES//,/ }" +else + languages="$(uv run --frozen --no-sync python scripts/docs/i18n languages | tr '\n' ' ')" +fi +switcher="$(echo $languages | tr ' ' ',')" + +# The URL the site is served from (see DOCS_SITE_URL above), decided once and +# handed to every config and to staging so all baked links agree on it. +site_url="${DOCS_SITE_URL:-}" +if [[ -z "$site_url" ]]; then + site_url="$(uv run --frozen --no-sync python -c 'import yaml; print(yaml.safe_load(open("mkdocs.yml", encoding="utf-8"))["site_url"])')" +fi + +uv run --frozen --no-sync python scripts/docs/build_config.py --site-url "$site_url" --switcher-languages "$switcher" -uv run --frozen --no-sync python scripts/docs/build_config.py uv run --frozen --no-sync zensical build -f mkdocs.gen.yml --strict # The build above renders pages in one arbitrary (filesystem-dependent) # order; prove the API reference renders in hostile orders too — see the -# check's docstring for the failure mode this guards. +# check's docstring for the failure mode this guards. Only the English site +# builds the API reference, so the property is checked only here. uv run --frozen --no-sync python scripts/docs/check_render_order.py # Zensical stays green even under --strict when a cross-reference fails to # resolve (rendered as literal bracket text) or an objects.inv inventory # fails to download (every link through it silently degrades to plain text); # MkDocs strict mode aborted on both. Validate the built site instead. -uv run --frozen --no-sync python scripts/docs/check_crossrefs.py --site-dir site +uv run --frozen --no-sync python scripts/docs/check_crossrefs.py --site-dir site --config mkdocs.gen.yml uv run --frozen --no-sync python scripts/docs/llms_txt.py --site-dir site + +for lang in $languages; do + echo "=== Building language site: ${lang} ===" + # Stage the language's docs tree (English + translations + banners, API + # links pointed at the English reference), generate its config, then + # build it cold and check its cross-references. + uv run --frozen --no-sync python scripts/docs/i18n stage --lang "$lang" --site-url "$site_url" + uv run --frozen --no-sync python scripts/docs/build_config.py --lang "$lang" \ + --site-url "$site_url" --switcher-languages "$switcher" + rm -rf .cache + uv run --frozen --no-sync zensical build -f "mkdocs.${lang}.gen.yml" --strict + uv run --frozen --no-sync python scripts/docs/check_crossrefs.py \ + --site-dir ".build/i18n/${lang}/site" --config "mkdocs.${lang}.gen.yml" + mkdir -p "site/${lang}" + cp -a ".build/i18n/${lang}/site/." "site/${lang}/" +done diff --git a/scripts/docs/build_config.py b/scripts/docs/build_config.py index daba648344..7a5ef5f6f5 100644 --- a/scripts/docs/build_config.py +++ b/scripts/docs/build_config.py @@ -2,46 +2,57 @@ Zensical builds from `mkdocs.yml` directly, but it has no equivalent of mkdocs-literate-nav: the "API Reference" navigation has to be materialised -as explicit entries. This script regenerates the `docs/api/` tree (via +as explicit entries. This script regenerates the API reference tree (via 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 ` it writes `mkdocs..gen.yml` for one translated +site instead: `docs_dir` pointed at that language's staged tree (see +`scripts/docs/i18n`), the theme language switched, the sidebar labels swapped +for the language's generated renderings (a label without one stays English), +and the site published under `//`. A language site does not rebuild +the API reference — its "API Reference" nav entry links to the single English +one — so it runs no mkdocstrings pass. + +Every config, English included, carries the same `extra.alternate` list (the +language switcher and each page's `hreflang` alternates), and that list is the +set of sites the build actually produces: `--switcher-languages` names them +(the build script passes the same value to every config it writes), so a +partial or English-only build never links to a site it did not make. With +the flag omitted the list is every enabled language. + +`--site-url` names the URL the site is served from — production +(`mkdocs.yml`'s `site_url`) unless the build script says otherwise (a PR +preview at its own host) — and everything absolute this build bakes derives +from it: each config's `site_url`, the switcher links' path prefix, and the +API-reference entry a language site's nav points at. + Usage: - python scripts/docs/build_config.py + python scripts/docs/build_config.py [--site-url URL] [--switcher-languages ja,ko] + python scripts/docs/build_config.py --lang [--docs-dir DIR] [--site-url URL] [--switcher-languages ...] """ from __future__ import annotations +import argparse import posixpath -import re from pathlib import Path +from urllib.parse import urlsplit # 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 llms_txt import page_url +from navigation import NavEntry, nav_pages, relabel_nav -ROOT = Path(__file__).parent.parent.parent - -# 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+.-]*:") +from i18n.config import ConfigError, Registry, load_registry, nav_path, staged_docs_dir, staged_site_dir +from i18n.nav import load_nav - -def _nav_pages(nav: list) -> set[str]: - """Collect every local page reference in the nav (external links excluded).""" - pages: set[str] = set() - 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 +ROOT = Path(__file__).parent.parent.parent -def _validate_nav(nav: list, docs_dir: Path) -> None: +def _validate_nav(nav: list[NavEntry], 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 +63,168 @@ 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_pages(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}") 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")) - 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"]: +def _api_nav_entry(nav: list[NavEntry]) -> dict[str, str | list[NavEntry]]: + """The `mkdocs.yml` placeholder nav entry the API reference is spliced into.""" + for entry in nav: if isinstance(entry, dict) and "API Reference" in entry: - entry["API Reference"] = api_nav - break - else: - raise SystemExit("build_config: no 'API Reference' entry found in mkdocs.yml nav") + return entry + raise SystemExit("build_config: no 'API Reference' entry found in mkdocs.yml nav") - _validate_nav(config["nav"], ROOT / "docs") - output = ROOT / "mkdocs.gen.yml" +def _alternate(registry: Registry, codes: list[str] | None, base: str) -> list[dict[str, str]]: + """The language-switcher entries: English at the site root, then each built language site. + + `codes` are the language sites this build produces (None means every + enabled language); the switcher must never offer a site that isn't built. + Each `link` is the site root's path plus the language code (`base` is + the site URL), so the switcher follows whatever host and path the site + is served under. + + Raises: + SystemExit: A named language is not an enabled entry of the registry. + """ + if codes is None: + languages = registry.enabled + else: + try: + languages = [registry.language(code) for code in codes] + except ConfigError as exc: + raise SystemExit(f"build_config: --switcher-languages: {exc}") from exc + if disabled := [language.code for language in languages if not language.enabled]: + raise SystemExit(f"build_config: --switcher-languages names disabled languages: {disabled}") + root = urlsplit(base).path.rstrip("/") + "/" + entries = [{"name": "English", "link": root, "lang": "en"}] + entries += [ + {"name": language.name, "link": f"{root}{language.code}/", "lang": language.hreflang} for language in languages + ] + return entries + + +def _repo_relative(path: Path, what: str) -> str: + """`path` as a repo-root-relative posix path (Zensical resolves config paths against the config file). + + Both sides are resolved before the comparison, so a repository reached + through a symlinked path still yields the same relative form. + + Raises: + SystemExit: `path` does not live inside the repository. + """ + try: + return path.resolve().relative_to(ROOT.resolve()).as_posix() + except ValueError as exc: + raise SystemExit(f"build_config: {what} {path} must live inside the repository") from exc + + +def _translated_labels(lang: str) -> dict[str, str]: + """The language's translated nav labels; empty when it has no nav map yet.""" + try: + nav = load_nav(nav_path(lang)) + except ConfigError as exc: + raise SystemExit(f"build_config: {exc}") from exc + return {} if nav is None else nav.labels + + +def build_config( + lang: str | None = None, + docs_dir: Path | None = None, + site_url: str | None = None, + switcher_languages: list[str] | None = None, +) -> Path: + """Write the concrete build config; returns its path. + + `switcher_languages` are the language sites this build produces (None + means every enabled language); a language config must be one of them. + """ + config = yaml.safe_load((ROOT / "mkdocs.yml").read_text(encoding="utf-8")) + registry = load_registry() + if lang is not None and switcher_languages is not None and lang not in switcher_languages: + raise SystemExit(f"build_config: --switcher-languages must include the site being built ({lang})") + base = (site_url or config["site_url"]).rstrip("/") + + if lang is None: + if docs_dir is not None: + raise SystemExit("build_config: --docs-dir applies only to a language build (pass --lang)") + docs = ROOT / "docs" + 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?") + _api_nav_entry(config["nav"])["API Reference"] = api_nav + else: + docs = docs_dir if docs_dir is not None else staged_docs_dir(lang, ROOT) + # A language site links the one English API reference instead of + # rebuilding it: no mkdocstrings pass, and the nav entry is an + # external link to the reference on the English site (staging points + # prose links into `api/` at the same place). + config["plugins"] = [p for p in config["plugins"] if not (isinstance(p, dict) and "mkdocstrings" in p)] + _api_nav_entry(config["nav"])["API Reference"] = f"{base}/{page_url(gen_ref_pages.ENTRY_PAGE)}" + if not docs.is_dir(): + raise SystemExit(f"build_config: docs directory {docs} does not exist (stage the language first)") + + _validate_nav(config["nav"], docs) + + config.setdefault("extra", {})["alternate"] = _alternate(registry, switcher_languages, base) + if lang is None: + config["site_url"] = f"{base}/" + output = ROOT / "mkdocs.gen.yml" + else: + language = registry.language(lang) + # The sidebar reads in the site's language: every label the language's + # generated nav map covers ("API Reference" included) is swapped for + # its rendering; the rest stay English. Never applied to the English config. + config["nav"] = relabel_nav(config["nav"], _translated_labels(lang)) + # Zensical resolves these against the config file's directory and + # requires them relative to it; an absolute path aborts the build. + config["docs_dir"] = _repo_relative(docs, "docs directory") + config["site_dir"] = _repo_relative(staged_site_dir(lang, ROOT), "site directory") + config["site_url"] = f"{base}/{lang}/" + config["theme"]["language"] = language.theme_language + output = ROOT / f"mkdocs.{lang}.gen.yml" + output.write_text(yaml.safe_dump(config, sort_keys=False, allow_unicode=True), encoding="utf-8") + return output + + +def _language_codes(value: str) -> list[str]: + """The comma-separated language codes of `--switcher-languages` (empty means English only).""" + return [code.strip() for code in value.split(",") if code.strip()] + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--lang", metavar="CODE", help="Build config for this language site.") + parser.add_argument( + "--docs-dir", type=Path, help="Docs tree for a --lang build (default: the language's staged tree)." + ) + parser.add_argument( + "--site-url", + help="URL the site is served from; every absolute link the build bakes derives from it" + " (default: mkdocs.yml site_url).", + ) + parser.add_argument( + "--switcher-languages", + type=_language_codes, + metavar="CODES", + help="Comma-separated codes of the language sites this build produces, listed by the " + "language switcher (empty: English only; default: every enabled language).", + ) + args = parser.parse_args() + print(build_config(args.lang, args.docs_dir, args.site_url, args.switcher_languages)) if __name__ == "__main__": - build_config() + main() diff --git a/scripts/docs/check_anchors.py b/scripts/docs/check_anchors.py new file mode 100644 index 0000000000..1c31364339 --- /dev/null +++ b/scripts/docs/check_anchors.py @@ -0,0 +1,248 @@ +"""Require an explicit anchor on every prose heading; `--fix` pins them. + +Every heading in the hand-written docs carries its own id in the source +(`## Ask with a resolver {#ask-with-a-resolver}`, attr_list syntax) instead +of leaving the theme to slugify the heading text. That decouples the anchor +from the wording — a heading can be reworded or translated without moving +the `#fragment` links that point at it, and the ids are one grep away in the +source rather than a property of a build. The compact `{#id}` spelling is the +one markdownlint's fragment check recognises as a custom anchor. + +Default mode is the check: for every ATX heading under `docs/**/*.md` (the +generated `docs/api/` tree and dot-directories exempt) it fails on a heading +without a trailing `{#anchor}`, on an anchor id using characters outside +letters, digits, `_` and `-`, and on an anchor a page uses twice — the anchor +rules the translation validator also enforces, defined once in +`i18n.markdown` (`anchor_problems`). `--fix` appends `{#}` to +unanchored headings, slugifying and de-duplicating the way the theme's `toc` +extension does. `--fix --from-site ` instead copies the ids the built +site rendered for those headings — the mode used to pin the existing corpus, +since it cannot move an anchor by construction. + +Usage: + python scripts/docs/check_anchors.py + python scripts/docs/check_anchors.py --fix + python scripts/docs/check_anchors.py --fix --from-site site +""" + +from __future__ import annotations + +import argparse +import html +import re +import sys +import unicodedata +from html.parser import HTMLParser +from pathlib import Path + +# The heading/anchor/fence parsing and the anchor rules are the one +# implementation shared with the translation tool (this directory is the +# import root of the docs tooling), so this check, the translation validator +# and the mechanical repairer can never disagree about what an anchor is, how +# it is escaped, or which anchors are wrong. +from i18n.markdown import ( + HEADING_ATTRS, + AnchorProblem, + MalformedAnchor, + MissingAnchor, + UnspacedAnchor, + anchor_problems, + anchor_source_form, + parse_headings, + unescape, +) + +ROOT = Path(__file__).parent.parent.parent +DOCS = ROOT / "docs" + +# Inline markup that is not part of the rendered heading text the theme +# slugifies: images vanish, links keep their label, raw tags are stripped, +# underscore emphasis loses its markers (asterisks vanish in the slug anyway), +# and backslash escapes yield their literal character. +_CODE_SPAN = re.compile(r"(?`+)(?P.+?)(?[^\]]*)\](?:\([^)]*\)|\[[^\]]*\])") +_TAG = re.compile(r"]*>") +_UNDERSCORE_EM = re.compile(r"(?_{1,2})(?=\S)(?P.+?)(?<=\S)(?P=mark)(?!\w)") + +_DEDUPE_SUFFIX = re.compile(r"^(.*)_([0-9]+)$") +_HEADING_TAG = re.compile(r"h[1-6]") + + +def _plain_text(heading_text: str) -> str: + """Reduce inline markdown to the text the rendered heading would carry.""" + codes: list[str] = [] + + def stash(match: re.Match[str]) -> str: + codes.append(match["code"].strip()) + return f"\x00{len(codes) - 1}\x00" + + text = _CODE_SPAN.sub(stash, heading_text) + text = _IMAGE.sub("", text) + text = _LINK.sub(lambda m: m["label"], text) + text = _TAG.sub("", text) + text = _UNDERSCORE_EM.sub(lambda m: m["inner"], text) + text = html.unescape(unescape(text)) + return re.sub(r"\x00(\d+)\x00", lambda m: codes[int(m[1])], text) + + +def slugify(heading_text: str) -> str: + """The id the theme's `toc` extension derives from a heading's text.""" + value = unicodedata.normalize("NFKD", _plain_text(heading_text)) + value = value.encode("ascii", "ignore").decode("ascii") + value = re.sub(r"[^\w\s-]", "", value).strip().lower() + return re.sub(r"[-\s]+", "-", value) + + +def unique(anchor: str, used: set[str]) -> str: + """De-duplicate `anchor` against `used` the way `toc` does: `id`, `id_1`, `id_2`, ...""" + while anchor in used or not anchor: + match = _DEDUPE_SUFFIX.match(anchor) + anchor = f"{match[1]}_{int(match[2]) + 1}" if match else f"{anchor}_1" + used.add(anchor) + return anchor + + +class _HeadingIdParser(HTMLParser): + """Collect the `(level, id)` of every heading inside a page's `
`.""" + + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self._depth = 0 + self.headings: list[tuple[int, str | None]] = [] + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if tag == "article": + self._depth += 1 + elif self._depth and _HEADING_TAG.fullmatch(tag): + self.headings.append((int(tag[1]), dict(attrs).get("id"))) + + def handle_endtag(self, tag: str) -> None: + if tag == "article" and self._depth: + self._depth -= 1 + + +def rendered_ids(site_dir: Path, page: Path) -> list[tuple[int, str | None]]: + """The heading levels and ids the built site rendered for prose `page`.""" + stem = page.relative_to(DOCS).with_suffix("") + html_file = site_dir / (stem.parent if stem.name == "index" else stem) / "index.html" + if not html_file.is_file(): + raise SystemExit(f"check_anchors: no rendered page for {_repo_path(page)} at {html_file} — build first") + parser = _HeadingIdParser() + parser.feed(html_file.read_text(encoding="utf-8")) + return parser.headings + + +def prose_pages(docs: Path = DOCS) -> list[Path]: + """Hand-written pages under `docs/`: the generated `api/` tree and dot-directories aside. + + Only the path *inside* `docs/` decides that: a checkout that itself lives + under a dot-directory (a worktree) still has all its prose pages, and + finding none at all means the docs tree is missing, never that it passed. + + Raises: + SystemExit: There is no prose page under `docs`. + """ + parts = ((page, page.relative_to(docs).parts) for page in docs.rglob("*.md")) + pages = sorted(page for page, rel in parts if rel[0] != "api" and not any(p.startswith(".") for p in rel)) + if not pages: + raise SystemExit(f"check_anchors: found no prose pages under {docs} — is the docs tree in place?") + return pages + + +def _repo_path(path: Path, *, root: Path = ROOT) -> str: + """`path` as a repository-relative posix path, so messages read the same on every platform.""" + return path.relative_to(root).as_posix() + + +def check_pages(pages: list[Path], *, docs: Path = DOCS) -> tuple[int, list[str]]: + """The heading count and every problem found: unanchored, malformed, glued and reused anchors. + + Problems name the page relative to the directory holding `docs`, so they + read as repository paths (`docs/handlers/context.md:14: ...`). + """ + total = 0 + problems: list[str] = [] + for page in pages: + rel = _repo_path(page, root=docs.parent) + headings = parse_headings(page.read_text(encoding="utf-8")) + total += len(headings) + problems += [f"{rel}:{problem.heading.line + 1}: {_describe(problem)}" for problem in anchor_problems(headings)] + return total, problems + + +def _describe(problem: AnchorProblem) -> str: + """One shared anchor problem, worded for a source page (the heading text or id, an earlier line).""" + if isinstance(problem, MissingAnchor): + return f"heading has no {{#anchor}}: {problem.heading.text!r}" + if isinstance(problem, MalformedAnchor): + return f'#{problem.heading.anchor} may only use letters, digits, "_", "-"' + if isinstance(problem, UnspacedAnchor): + return f"{{#{problem.heading.anchor}}} must follow a space, or the block renders as heading text" + return f"#{problem.heading.anchor} already anchors line {problem.first.line + 1}" + + +def _pin(page: Path, site_dir: Path | None) -> int: + """Append `{#id}` to each unanchored heading of `page`; returns how many were pinned.""" + lines = page.read_text(encoding="utf-8").split("\n") + headings = parse_headings("\n".join(lines)) + if all(heading.anchor is not None for heading in headings): + return 0 + rendered = rendered_ids(site_dir, page) if site_dir is not None else None + if rendered is not None and [h.level for h in headings] != [level for level, _ in rendered]: + raise SystemExit( + f"check_anchors: rendered headings of {_repo_path(page)} don't line up with its source" + " — rebuild the site from this tree before pinning from it" + ) + used: set[str] = {heading.anchor for heading in headings if heading.anchor} + pinned = 0 + for index, heading in enumerate(headings): + if heading.anchor is not None: + continue + if rendered is not None: + anchor = rendered[index][1] + if not anchor: + raise SystemExit(f"check_anchors: rendered heading at {_repo_path(page)}:{heading.line + 1} has no id") + else: + anchor = unique(slugify(heading.text), used) + line = lines[heading.line].rstrip() + attrs = HEADING_ATTRS.search(line) + # A heading may already carry classes/attributes (`{ .cls }`) without an id. + lines[heading.line] = ( + f"{line[: attrs.start('body')]}#{anchor_source_form(anchor)} {attrs['body']}{line[attrs.end('body') :]}" + if attrs + else f"{line} {{#{anchor_source_form(anchor)}}}" + ) + pinned += 1 + if pinned: + page.write_text("\n".join(lines), encoding="utf-8") + return pinned + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--fix", action="store_true", help="Append an anchor to every heading that lacks one.") + parser.add_argument( + "--from-site", + metavar="SITE_DIR", + type=Path, + help="With --fix, pin the ids the built site rendered instead of computing slugs.", + ) + args = parser.parse_args() + if args.from_site and not args.fix: + parser.error("--from-site only makes sense with --fix") + + pages = prose_pages() + if args.fix: + pinned = sum(_pin(page, args.from_site) for page in pages) + print(f"check_anchors: pinned {pinned} heading anchors") + total, problems = check_pages(pages) + if problems: + print("error: every prose heading needs an explicit anchor (run with --fix):", file=sys.stderr) + print("\n".join(problems), file=sys.stderr) + raise SystemExit(1) + print(f"check_anchors: {total} headings across {len(pages)} pages all carry anchors") + + +if __name__ == "__main__": + main() diff --git a/scripts/docs/check_crossrefs.py b/scripts/docs/check_crossrefs.py index 39f866a00f..97670bb1d7 100644 --- a/scripts/docs/check_crossrefs.py +++ b/scripts/docs/check_crossrefs.py @@ -14,16 +14,18 @@ disarm the check: an unresolved reference leaves a tell-tale bracket sequence in prose text (code blocks legitimately contain `][`, e.g. dict indexing, so only text outside `
`/`` counts), and every inventory
-declared in `mkdocs.yml` must contribute at least one resolved reference —
-an `autorefs-external` anchor, which hand-authored prose links to the same
-host never carry — to the site (an inventory that contributes none is dead
-config and fails too).
+declared in the site's build config must contribute at least one resolved
+reference — an `autorefs-external` anchor, which hand-authored prose links
+to the same host never carry — to the site (an inventory that contributes
+none is dead config and fails too). A language site declares no inventories
+(it links the English API reference instead of building it), so `--config`
+selects the config the site was built from.
 
 Offline contributors can skip the inventory check by setting
 `DOCS_ALLOW_INVENTORY_FAILURE=1`; CI (`CI=true`) never skips it.
 
 Usage:
-    python scripts/docs/check_crossrefs.py --site-dir site
+    python scripts/docs/check_crossrefs.py --site-dir site [--config mkdocs.gen.yml]
 """
 
 from __future__ import annotations
@@ -113,9 +115,9 @@ def unresolved_refs(html: str) -> list[str]:
     return [fragment.replace("\x00", "") for fragment in _UNRESOLVED.findall("".join(parser.chunks))]
 
 
-def _inventory_origins() -> set[str]:
-    """The scheme+host origins of the inventories declared in mkdocs.yml."""
-    config = yaml.safe_load((ROOT / "mkdocs.yml").read_text(encoding="utf-8"))
+def _inventory_origins(config_path: Path) -> set[str]:
+    """The scheme+host origins of the inventories declared in the build config."""
+    config = yaml.safe_load(config_path.read_text(encoding="utf-8"))
     for plugin in config["plugins"]:
         if isinstance(plugin, dict) and "mkdocstrings" in plugin:
             inventories = plugin["mkdocstrings"]["handlers"]["python"].get("inventories", [])
@@ -131,6 +133,7 @@ def _origin(url: str) -> str:
 def main() -> None:
     parser = argparse.ArgumentParser(description=__doc__)
     parser.add_argument("--site-dir", default=str(ROOT / "site"), help="The built site directory to scan.")
+    parser.add_argument("--config", default=str(ROOT / "mkdocs.yml"), help="The build config the site was built from.")
     args = parser.parse_args()
 
     site_dir = Path(args.site_dir)
@@ -139,7 +142,7 @@ def main() -> None:
     if not site_dir.is_dir():
         raise SystemExit(f"check_crossrefs: {site_dir} not found (run the build first)")
 
-    unlinked = _inventory_origins()
+    unlinked = _inventory_origins(Path(args.config))
     failures: list[str] = []
     for page in sorted(site_dir.rglob("*.html")):
         html = page.read_text(encoding="utf-8")
diff --git a/scripts/docs/gen_ref_pages.py b/scripts/docs/gen_ref_pages.py
index 26916e8c39..93ba8f4594 100644
--- a/scripts/docs/gen_ref_pages.py
+++ b/scripts/docs/gen_ref_pages.py
@@ -17,10 +17,8 @@
 
 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]]"
+# Sibling module (scripts/docs is the import root); owns the nav shape.
+from navigation import NavEntry
 
 ROOT = Path(__file__).parent.parent.parent
 API_DIR = ROOT / "docs" / "api"
@@ -30,6 +28,11 @@
 # it from `src/` would emit the unimportable `mcp-types.mcp_types.*`.
 PACKAGES = (ROOT / "src" / "mcp", ROOT / "src" / "mcp-types" / "mcp_types")
 
+# The reference has no bare `api/` page: it opens on the first package's
+# index (the nav lists packages in name order), so this is the page a link
+# to the API reference as a whole points at.
+ENTRY_PAGE = f"api/{min(package.name for package in PACKAGES)}/index.md"
+
 # Alias packages that mirror another package's namespaces (`mcp.types` mirrors
 # `mcp_types`, `mcp.types.version` mirrors `mcp_types.version`): the mirrored
 # package's pages are the canonical rendering, so an alias, and every module
@@ -55,11 +58,11 @@ def __init__(self) -> None:
     def child(self, name: str) -> _Node:
         return self.children.setdefault(name, _Node())
 
-    def to_nav(self, title: str) -> NavItem:
+    def to_nav(self, title: str) -> NavEntry:
         if not self.children:
             assert self.url is not None
             return {title: self.url}
-        items: list[NavItem] = []
+        items: list[NavEntry] = []
         if self.url is not None:
             items.append(self.url)
         items.extend(self.children[name].to_nav(name) for name in sorted(self.children))
@@ -165,7 +168,7 @@ def _stub(title: str, body: str) -> str:
     return f'---\ntitle: "{title}"\n---\n\n{body.rstrip()}\n'
 
 
-def generate() -> list[NavItem]:
+def generate() -> list[NavEntry]:
     """Write `docs/api/**.md` stubs and return the API-section navigation."""
     if API_DIR.exists():
         shutil.rmtree(API_DIR)
diff --git a/scripts/docs/i18n/__init__.py b/scripts/docs/i18n/__init__.py
new file mode 100644
index 0000000000..a82c37b3d0
--- /dev/null
+++ b/scripts/docs/i18n/__init__.py
@@ -0,0 +1,8 @@
+"""Machine translation of the documentation into the language sites.
+
+The English pages under `docs/` are the source; each language listed in
+`i18n/languages.yml` gets generated translations under
+`i18n/languages//pages/`, steered by that language's `instructions.md`
+and `glossary.json`. `i18n/README.md` documents the workflow; run the tool as
+`python scripts/docs/i18n --help` from the repo root for the commands.
+"""
diff --git a/scripts/docs/i18n/__main__.py b/scripts/docs/i18n/__main__.py
new file mode 100644
index 0000000000..5ec2012ed6
--- /dev/null
+++ b/scripts/docs/i18n/__main__.py
@@ -0,0 +1,18 @@
+"""Entrypoint: `python scripts/docs/i18n ` from the repository root.
+
+Running the package directory puts this directory on `sys.path`; the tool's
+modules and the sibling docs scripts are imported through `scripts/docs`, so
+that directory is added ahead of the imports.
+"""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+from i18n.cli import main
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/scripts/docs/i18n/cli.py b/scripts/docs/i18n/cli.py
new file mode 100644
index 0000000000..7a443907d3
--- /dev/null
+++ b/scripts/docs/i18n/cli.py
@@ -0,0 +1,543 @@
+"""Command line for the documentation translation tool.
+
+Run from the repository root:
+
+    uv run --frozen --group docs python scripts/docs/i18n  [options]
+
+Commands mirror the workflow — `status` shows what each language needs,
+`translate` produces or refreshes pages and the sidebar's label map
+(`--nav`), `check` and `verify` gate them, `prune` drops translations whose
+English source is gone, and `stage` assembles the tree a language site is
+built from. Exit codes: 0 success, 1 validation or verification failures,
+2 usage or configuration errors.
+
+Commands that call the model (`translate`, `verify`) authenticate from the
+environment: set exactly one of ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN
+(setting both, or neither, is a configuration error).
+"""
+
+from __future__ import annotations
+
+import argparse
+import os
+import subprocess
+import sys
+from collections.abc import Mapping, Sequence
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any, cast
+
+import yaml
+
+from i18n.client import AnthropicTranslator, Translator, TranslatorError
+from i18n.config import (
+    BANNER_KINDS,
+    NAV_FILE,
+    ROOT,
+    ConfigError,
+    Language,
+    banner_path,
+    glossary_path,
+    instructions_path,
+    load_registry,
+    nav_path,
+    pages_dir,
+    resolve_models,
+    state_path,
+)
+from i18n.files import write_text_atomically
+from i18n.glossary import load_glossary
+from i18n.nav import save_nav
+from i18n.pages import split_blocks, translatable_pages
+from i18n.stage import StageStatus, stage_language
+from i18n.state import PageState, PageStatus, save_state
+from i18n.status import LanguageStatus, language_status
+from i18n.translate import (
+    Prompt,
+    RunContext,
+    build_nav_prompt,
+    build_prompt,
+    semantic_gate,
+    translate_nav,
+    translate_page,
+)
+from i18n.validate import Finding, validate_nav_labels, validate_page, validate_page_standalone
+
+EXIT_OK, EXIT_FAILURES, EXIT_USAGE = 0, 1, 2
+_STATUSES = ("missing", "outdated", "current", "removable")
+
+
+class _Environment:
+    """The repository the commands operate on."""
+
+    def __init__(self, root: Path) -> None:
+        self.root = root
+        self.docs = root / "docs"
+        self.i18n_dir = root / "i18n"
+        self.registry = load_registry(self.i18n_dir / "languages.yml")
+        self.mkdocs = _mkdocs_config(root / "mkdocs.yml")
+        self.nav: list[Any] = self.mkdocs["nav"]
+
+    @property
+    def site_url(self) -> str:
+        return str(self.mkdocs["site_url"])
+
+    @property
+    def site_name(self) -> str:
+        return str(self.mkdocs["site_name"])
+
+    def languages(self, code: str | None) -> list[Language]:
+        return [self.registry.language(code)] if code else self.registry.enabled
+
+    def status(self, language: Language) -> LanguageStatus:
+        return language_status(
+            language,
+            self.nav,
+            self.registry.exclude_pages,
+            site_name=self.site_name,
+            docs=self.docs,
+            i18n_dir=self.i18n_dir,
+        )
+
+
+def _mkdocs_config(path: Path) -> dict[str, Any]:
+    """The parsed `mkdocs.yml`: the nav, site name and site URL the commands read.
+
+    Raises:
+        ConfigError: The file is missing or unreadable.
+    """
+    try:
+        config: object = yaml.safe_load(path.read_text(encoding="utf-8"))
+    except (OSError, yaml.YAMLError) as exc:
+        raise ConfigError(f"cannot read {path}: {exc}") from exc
+    if not isinstance(config, dict):
+        raise ConfigError(f"{path}: not an mkdocs config mapping")
+    return cast("dict[str, Any]", config)
+
+
+def _positive_int(value: str) -> int:
+    """An argparse type: a whole number of pages, at least one.
+
+    Raises:
+        argparse.ArgumentTypeError: `value` is not an integer of at least one.
+    """
+    try:
+        number = int(value)
+    except ValueError as exc:
+        raise argparse.ArgumentTypeError(f"{value!r} is not a number") from exc
+    if number < 1:
+        raise argparse.ArgumentTypeError(f"must be at least 1, got {number}")
+    return number
+
+
+def _parser() -> argparse.ArgumentParser:
+    parser = argparse.ArgumentParser(
+        prog="i18n", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
+    )
+    parser.add_argument("--repo-root", type=Path, default=ROOT, help="Repository root (default: this repository).")
+    commands = parser.add_subparsers(dest="command", required=True)
+
+    commands.add_parser("languages", help="Print the enabled language codes, one per line.")
+
+    status = commands.add_parser(
+        "status", help="List missing, outdated, current and removable pages, and the nav map's state."
+    )
+    status.add_argument("--lang", metavar="CODE", help="Only this language (default: every enabled one).")
+
+    translate = commands.add_parser("translate", help="Translate the selected pages (calls the model).")
+    translate.add_argument("--lang", metavar="CODE", required=True)
+    translate.add_argument("--pages", nargs="+", metavar="PAGE", default=[], help="Page paths under docs/.")
+    translate.add_argument("--nav", action="store_true", help="Translate (or refresh) the sidebar label map.")
+    translate.add_argument(
+        "--all-missing", action="store_true", help="Add every untranslated page (and the nav map if missing)."
+    )
+    translate.add_argument(
+        "--all-outdated", action="store_true", help="Add every outdated page (and the nav map if outdated)."
+    )
+    translate.add_argument(
+        "--limit", type=_positive_int, metavar="N", help="Translate at most N of the selected pages."
+    )
+    translate.add_argument("--dry-run", action="store_true", help="Print the assembled prompts; call nothing.")
+    translate.add_argument("--no-verify", action="store_true", help="Skip the semantic review gate.")
+    translate.add_argument("--model", help="Translation model (overrides languages.yml and the environment).")
+    translate.add_argument("--verify-model", help="Semantic-review model (same override rules).")
+
+    check = commands.add_parser(
+        "check",
+        help="Validate the committed translations and nav label maps: current pages against the English source,"
+        " outdated ones on their own (no network).",
+    )
+    check.add_argument("--lang", metavar="CODE", help="Only this language (default: every enabled one).")
+    check.add_argument("--pages", nargs="+", metavar="PAGE", default=[], help="Only these pages.")
+
+    verify = commands.add_parser("verify", help="Run the semantic review gate over pages (calls the model).")
+    verify.add_argument("--lang", metavar="CODE", required=True)
+    verify.add_argument("--pages", nargs="+", metavar="PAGE", required=True)
+    verify.add_argument("--verify-model", help="Semantic-review model (overrides languages.yml and env).")
+
+    prune = commands.add_parser("prune", help="Delete translations whose English page is gone.")
+    prune.add_argument("--lang", metavar="CODE", required=True)
+
+    stage = commands.add_parser(
+        "stage", help="Assemble a language's staged docs tree for the build under .build/i18n/CODE/docs."
+    )
+    stage.add_argument("--lang", metavar="CODE", required=True)
+    stage.add_argument(
+        "--site-url",
+        metavar="URL",
+        help="Site URL the staged links point at — banner links and API-reference links (default: mkdocs.yml).",
+    )
+    return parser
+
+
+def main(
+    argv: Sequence[str] | None = None,
+    *,
+    translator: Translator | None = None,
+    environ: Mapping[str, str] | None = None,
+) -> int:
+    """Run one command; returns the process exit code."""
+    args = _parser().parse_args(argv)
+    environ = os.environ if environ is None else environ
+    try:
+        env = _Environment(args.repo_root)
+        if args.command == "languages":
+            print("\n".join(language.code for language in env.registry.enabled))
+            return EXIT_OK
+        if args.command == "status":
+            return _status(env, args)
+        if args.command == "translate":
+            return _translate(env, args, translator, environ)
+        if args.command == "check":
+            return _check(env, args)
+        if args.command == "verify":
+            return _verify(env, args, translator, environ)
+        if args.command == "prune":
+            return _prune(env, args)
+        return _stage(env, args)
+    except ConfigError as exc:
+        print(f"i18n: {exc}", file=sys.stderr)
+        return EXIT_USAGE
+
+
+def _status(env: _Environment, args: argparse.Namespace) -> int:
+    for language in env.languages(args.lang):
+        status = env.status(language)
+        counts = ", ".join(f"{len(status.select(kind))} {kind}" for kind in _STATUSES)
+        print(f"{language.code} ({language.name}): {counts}")
+        print(f"  {status.nav_status.status:<9} {NAV_FILE} (navigation labels){_reasons(status.nav_status.reasons)}")
+        for page in status.pages:
+            print(f"  {page.status:<9} {page.page}{_reasons(page.reasons)}")
+    return EXIT_OK
+
+
+def _reasons(reasons: tuple[str, ...]) -> str:
+    return f"  ({'; '.join(reasons)})" if reasons else ""
+
+
+def _select_pages(status: LanguageStatus, args: argparse.Namespace) -> list[str]:
+    """The pages a `translate` run works on, in nav order, deduplicated."""
+    listed = {page.page for page in status.pages}
+    selected = list(args.pages)
+    if unknown := [page for page in selected if page not in listed]:
+        raise ConfigError(f"not translatable pages (not in the nav, or excluded): {unknown}")
+    if args.all_missing:
+        selected += status.select("missing")
+    if args.all_outdated:
+        selected += status.select("outdated")
+    ordered = [page.page for page in status.pages if page.page in set(selected)]
+    return ordered if args.limit is None else ordered[: args.limit]
+
+
+def _select_nav(status: LanguageStatus, args: argparse.Namespace) -> bool:
+    """Whether a `translate` run (re)writes the nav map: asked for, or picked up as missing/outdated.
+
+    Raises:
+        ConfigError: `--nav` was passed but the nav has no translatable labels.
+    """
+    if args.nav and not status.labels:
+        raise ConfigError("mkdocs.yml has no translatable navigation labels")
+    return bool(
+        args.nav
+        or (args.all_missing and status.nav_status.status == "missing")
+        or (args.all_outdated and status.nav_status.status == "outdated")
+    )
+
+
+def _translate(
+    env: _Environment, args: argparse.Namespace, translator: Translator | None, environ: Mapping[str, str]
+) -> int:
+    language = env.registry.language(args.lang)
+    status = env.status(language)
+    pages = _select_pages(status, args)
+    with_nav = _select_nav(status, args)
+    if not pages and not with_nav:
+        raise ConfigError("nothing selected: pass --pages, --nav, --all-missing or --all-outdated")
+    models = resolve_models(env.registry.models, environ=environ, translate=args.model, verify=args.verify_model)
+    translations = pages_dir(language.code, env.i18n_dir)
+
+    if args.dry_run:
+        if with_nav:
+            _print_prompt(
+                f"{NAV_FILE} (navigation labels)",
+                build_nav_prompt(status.labels, status.inputs, status.nav),
+                models.translate,
+            )
+        for page in pages:
+            _print_prompt(
+                page, build_prompt(_english(env, page), status.inputs, *_previous(env, status, page)), models.translate
+            )
+        return EXIT_OK
+
+    if translator is None:
+        translator = AnthropicTranslator(environ)
+    context = RunContext(
+        translator=translator,
+        model=models.translate,
+        verify_model=models.verify,
+        verify=not args.no_verify,
+        source_commit=_source_commit(env.root),
+        now=datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
+    )
+    failed = False
+    if with_nav:
+        nav_result = translate_nav(status.labels, status.inputs, context, previous=status.nav)
+        if nav_result.nav is None:
+            failed = True
+            print(f"error: {NAV_FILE} failed", file=sys.stderr)
+            _print_findings(nav_result.findings)
+            if nav_result.error:
+                print(f"  {nav_result.error}", file=sys.stderr)
+        else:
+            save_nav(nav_path(language.code, env.i18n_dir), nav_result.nav)
+            print(f"translated: {NAV_FILE} ({len(nav_result.nav.labels)} labels)")
+    for page in pages:
+        previous, previous_state = _previous(env, status, page)
+        result = translate_page(
+            page, _english(env, page), status.inputs, context, previous=previous, previous_state=previous_state
+        )
+        if result.text is None or result.state is None:
+            failed = True
+            print(f"error: {page} failed", file=sys.stderr)
+            _print_findings(result.findings)
+            for blocker in result.blockers:
+                print(
+                    f'  [meaning] "{blocker.source_span}" -> "{blocker.target_span}": {blocker.explanation}',
+                    file=sys.stderr,
+                )
+            if result.error:
+                print(f"  {result.error}", file=sys.stderr)
+            continue
+        write_text_atomically(translations / page, result.text)
+        status.state.pages[page] = result.state
+        save_state(state_path(language.code, env.i18n_dir), status.state)
+        print(f"translated: {page}")
+        for drift in result.drifts:
+            print(f'  [drift] "{drift.source_span}" -> "{drift.target_span}": {drift.explanation}')
+    return EXIT_FAILURES if failed else EXIT_OK
+
+
+def _print_prompt(subject: str, prompt: Prompt, model: str) -> None:
+    """Show one assembled prompt (the `--dry-run` output for a page or the nav map)."""
+    print(f"===== {subject} ({prompt.mode}, model {model}) =====")
+    print("----- system prompt -----")
+    print(prompt.system)
+    for message in prompt.messages:
+        print(f"----- {message.role} message -----")
+        print(message.content)
+
+
+def _check(env: _Environment, args: argparse.Namespace) -> int:
+    """The no-network gate: config, glossaries, nav maps and translated pages are valid.
+
+    Each translated page is checked as what it is. A current page — made
+    from the English page and prompt inputs as they stand — is compared with
+    the English in full. An outdated page is never measured against the
+    English or the glossary that have since moved on (that would fail the
+    gate on every English or glossary edit, when staleness only earns a
+    banner and a refresh): it only has to be well-formed by itself, and is
+    reported so the next translation run refreshes it. A translation whose
+    English page is gone, or with no state record, is reported for
+    `prune`/`translate` and not read at all. Only real integrity failures
+    fail the check.
+
+    A nav map may lag the nav: labels it lacks are not translated yet and
+    labels the nav has dropped are stale entries, so neither fails the check
+    (`status` reports the map outdated); only the renderings it holds are
+    validated. The nav map is skipped when `--pages` narrows the run.
+    """
+    findings: list[Finding] = []
+    for language in env.registry.languages:
+        for path in (instructions_path(language.code, env.i18n_dir), glossary_path(language.code, env.i18n_dir)):
+            if language.enabled and not path.is_file():
+                raise ConfigError(f"{language.code} is enabled but {path} is missing")
+        if glossary_path(language.code, env.i18n_dir).is_file():
+            load_glossary(glossary_path(language.code, env.i18n_dir))
+        for kind in BANNER_KINDS:
+            if not banner_path(kind, language.code, env.i18n_dir).is_file():
+                raise ConfigError(f"missing banner {kind!r} for {language.code} (and no English fallback)")
+    for language in env.languages(args.lang):
+        status = env.status(language)
+        translations = pages_dir(language.code, env.i18n_dir)
+        glossary, script = status.inputs.glossary, language.script
+        checked = _checked_pages(language.code, status, translations, args.pages)
+        for page in checked:
+            name = f"{language.code}:{page.page}"
+            output = (translations / page.page).read_text(encoding="utf-8")
+            if page.status == "current":
+                findings += validate_page(name, _english(env, page.page), output, glossary, script=script)
+            elif page.status == "outdated":
+                findings += validate_page_standalone(name, output)
+        listed = {kind: [p.page for p in checked if p.status == kind] for kind in ("outdated", "removable", "missing")}
+        _note(language.code, "outdated", listed["outdated"], "refreshed by the next translation run")
+        _note(language.code, "removable", listed["removable"], f"gone from docs/; run `prune --lang {language.code}`")
+        _note(
+            language.code, "untracked", listed["missing"], "no state.json record; a translation run replaces the file"
+        )
+        if status.nav is not None and not args.pages:
+            findings += validate_nav_labels(
+                f"{language.code}:{NAV_FILE}",
+                status.labels,
+                status.nav.labels,
+                status.inputs.glossary,
+                exact_keys=False,
+            )
+            if stale := [label for label in status.nav.labels if label not in set(status.labels)]:
+                print(f"note: {language.code}:{NAV_FILE} has stale entries no longer in mkdocs.yml: {stale}")
+    _print_findings(findings)
+    if findings:
+        return EXIT_FAILURES
+    print("i18n check: configuration, nav maps and translated pages are valid")
+    return EXIT_OK
+
+
+def _checked_pages(code: str, status: LanguageStatus, translations: Path, selected: Sequence[str]) -> list[PageStatus]:
+    """The translated pages `check` looks at: every page with a file, or the `--pages` selection.
+
+    Raises:
+        ConfigError: A selected page has no translation file.
+    """
+    present = [page for page in status.pages if (translations / page.page).is_file()]
+    if not selected:
+        return present
+    by_page = {page.page: page for page in present}
+    if missing := [page for page in selected if page not in by_page]:
+        raise ConfigError(f"{code} has no translation of {', '.join(missing)}")
+    return [by_page[page] for page in selected]
+
+
+def _note(code: str, kind: str, pages: Sequence[str], remedy: str) -> None:
+    """Report pages that need no fix here, only a translation or prune run (never a failure)."""
+    if pages:
+        count = f"{len(pages)} page" + ("" if len(pages) == 1 else "s")
+        print(f"note: {code}: {kind} ({count}) — {remedy}: {', '.join(pages)}")
+
+
+def _verify(
+    env: _Environment, args: argparse.Namespace, translator: Translator | None, environ: Mapping[str, str]
+) -> int:
+    language = env.registry.language(args.lang)
+    status = env.status(language)
+    models = resolve_models(env.registry.models, environ=environ, verify=args.verify_model)
+    if translator is None:
+        translator = AnthropicTranslator(environ)
+    failed = False
+    for page in args.pages:
+        translated = pages_dir(language.code, env.i18n_dir) / page
+        if not translated.is_file():
+            raise ConfigError(f"{language.code} has no translation of {page}")
+        english = _english(env, page)
+        output = translated.read_text(encoding="utf-8")
+        if findings := validate_page(page, english, output, status.inputs.glossary, script=language.script):
+            failed = True
+            print(f"error: {page} is not structurally valid (run `check`) — review skipped", file=sys.stderr)
+            _print_findings(findings)
+            continue
+        blocks = list(range(len(split_blocks(english))))
+        try:
+            blockers, drifts = semantic_gate(english, output, blocks, translator=translator, model=models.verify)
+        except TranslatorError as exc:
+            failed = True
+            print(f"error: {page}: {exc}", file=sys.stderr)
+            continue
+        for finding in [*blockers, *drifts]:
+            stream = sys.stderr if finding.severity == "blocker" else sys.stdout
+            quotes = f'"{finding.source_span}" -> "{finding.target_span}"'
+            print(f"{page} [{finding.severity}] {quotes}: {finding.explanation}", file=stream)
+        failed = failed or bool(blockers)
+    print(f"verify: {len(args.pages)} page(s) reviewed with {models.verify}")
+    return EXIT_FAILURES if failed else EXIT_OK
+
+
+def _prune(env: _Environment, args: argparse.Namespace) -> int:
+    language = env.registry.language(args.lang)
+    status = env.status(language)
+    translations = pages_dir(language.code, env.i18n_dir)
+    removable = status.select("removable")
+    tracked = {page.page for page in status.pages}
+    strays = (
+        [p.relative_to(translations).as_posix() for p in translations.rglob("*") if p.is_file()]
+        if translations.is_dir()
+        else []
+    )
+    for page in sorted(set(removable) | {stray for stray in strays if stray not in tracked}):
+        (translations / page).unlink(missing_ok=True)
+        status.state.pages.pop(page, None)
+        print(f"pruned: {page}")
+    save_state(state_path(language.code, env.i18n_dir), status.state)
+    return EXIT_OK
+
+
+def _stage(env: _Environment, args: argparse.Namespace) -> int:
+    language = env.registry.language(args.lang)
+    status = env.status(language)
+    statuses: dict[str, StageStatus] = {}
+    for page in status.pages:
+        if page.status != "removable":
+            statuses[page.page] = page.status
+    # Pages excluded from translation are English by design and carry their
+    # own note ("available in English only"), distinct from "not translated yet".
+    for page in translatable_pages(env.nav, ()):
+        statuses.setdefault(page, "excluded")
+    site_url = args.site_url or env.site_url
+    staged = stage_language(language.code, statuses, english_site_url=site_url, root=env.root)
+    for withdrawn in staged.withdrawn:
+        links = ", ".join(withdrawn.dead_links)
+        print(
+            f"warning: {language.code}: {withdrawn.page} not published — its translation links {links},"
+            " which the English tree no longer has; staged the English page with the stale banner"
+            " (refreshed by the next translation run)",
+            file=sys.stderr,
+        )
+    print(f"staged {language.code} docs at {staged.docs}")
+    return EXIT_OK
+
+
+def _english(env: _Environment, page: str) -> str:
+    try:
+        return (env.docs / page).read_text(encoding="utf-8")
+    except OSError as exc:
+        raise ConfigError(f"cannot read English page {page}: {exc}") from exc
+
+
+def _previous(env: _Environment, status: LanguageStatus, page: str) -> tuple[str | None, PageState | None]:
+    """The prior translation and its state record, if the page was translated before."""
+    record = status.state.pages.get(page)
+    translated = pages_dir(status.inputs.language.code, env.i18n_dir) / page
+    if record is None or not translated.is_file():
+        return None, None
+    return translated.read_text(encoding="utf-8"), record
+
+
+def _print_findings(findings: Sequence[Finding]) -> None:
+    for finding in findings:
+        print(f"  {finding}", file=sys.stderr)
+
+
+def _source_commit(root: Path) -> str:
+    """The current git commit, recorded so a translation can be traced to its source tree."""
+    try:
+        completed = subprocess.run(["git", "rev-parse", "HEAD"], cwd=root, check=True, capture_output=True, text=True)
+    except (OSError, subprocess.CalledProcessError):
+        return "unknown"
+    return completed.stdout.strip()
diff --git a/scripts/docs/i18n/client.py b/scripts/docs/i18n/client.py
new file mode 100644
index 0000000000..43e5bfb72a
--- /dev/null
+++ b/scripts/docs/i18n/client.py
@@ -0,0 +1,144 @@
+"""The model client behind translation and review calls.
+
+The pipeline talks to a `Translator`: one method taking a system prompt, a
+message history and a model, returning the reply text. Tests supply a fake;
+`AnthropicTranslator` is the real implementation, authenticated from the
+environment only and streaming every request so long pages never hit the
+non-streaming request timeout. Everything the API can throw becomes a tool
+error: an unusable reply is a `TranslatorError` (a per-page failure), bad
+credentials are a `ConfigError` (the whole run cannot proceed).
+"""
+
+from __future__ import annotations
+
+from collections.abc import Mapping, Sequence
+from dataclasses import dataclass
+from typing import Literal, Protocol, TypedDict
+
+import anthropic
+from anthropic.types import Message as ApiMessage
+from anthropic.types import MessageParam, TextBlockParam
+
+from i18n.config import ConfigError
+
+Role = Literal["user", "assistant"]
+
+API_KEY_ENV = "ANTHROPIC_API_KEY"
+AUTH_TOKEN_ENV = "ANTHROPIC_AUTH_TOKEN"
+
+
+@dataclass(frozen=True)
+class Message:
+    """One turn of the conversation replayed to the model."""
+
+    role: Role
+    content: str
+
+
+class TranslatorError(Exception):
+    """The model call did not produce a usable reply (kept out of retries the SDK handles)."""
+
+
+class Translator(Protocol):
+    """Anything that can answer a prompt; the tests inject a scripted fake."""
+
+    def complete(self, *, model: str, system: str, messages: Sequence[Message], max_tokens: int) -> str:
+        """Return the model's reply text for this conversation."""
+        ...
+
+
+class Credentials(TypedDict, total=False):
+    """The single credential a client is built with, keyed as the SDK expects it."""
+
+    api_key: str
+    auth_token: str
+
+
+class MessageRequest(TypedDict):
+    """The parameters of one Messages API call."""
+
+    model: str
+    max_tokens: int
+    system: list[TextBlockParam]
+    messages: list[MessageParam]
+
+
+def credentials(environ: Mapping[str, str]) -> Credentials:
+    """The credential to authenticate with, taken from the environment.
+
+    Raises:
+        ConfigError: Neither or both of `ANTHROPIC_API_KEY` and `ANTHROPIC_AUTH_TOKEN` are set.
+    """
+    api_key, auth_token = environ.get(API_KEY_ENV), environ.get(AUTH_TOKEN_ENV)
+    if api_key and auth_token:
+        raise ConfigError(f"both {API_KEY_ENV} and {AUTH_TOKEN_ENV} are set; set exactly one of them")
+    if api_key:
+        return {"api_key": api_key}
+    if auth_token:
+        return {"auth_token": auth_token}
+    raise ConfigError(f"set {API_KEY_ENV} or {AUTH_TOKEN_ENV} to run a command that calls the model")
+
+
+def message_request(*, model: str, system: str, messages: Sequence[Message], max_tokens: int) -> MessageRequest:
+    """The parameters of one call: a cached system block and no sampling parameters.
+
+    The system block is byte-identical across a language's pages, so every
+    page after the first reads it from the prompt cache. Sampling parameters
+    (`temperature` and friends) are never sent — current models reject them,
+    and repeatability comes from the pipeline's carry-forward, not the sampler.
+    """
+    return {
+        "model": model,
+        "max_tokens": max_tokens,
+        "system": [{"type": "text", "text": system, "cache_control": {"type": "ephemeral"}}],
+        "messages": [{"role": message.role, "content": message.content} for message in messages],
+    }
+
+
+def reply_text(reply: ApiMessage, *, max_tokens: int) -> str:
+    """The reply's text, or the reason there is none.
+
+    Raises:
+        TranslatorError: The reply was cut off at `max_tokens`, or the model declined to answer.
+    """
+    if reply.stop_reason == "max_tokens":
+        raise TranslatorError(f"reply truncated at the {max_tokens}-token output limit")
+    if reply.stop_reason == "refusal":
+        raise TranslatorError("model declined to answer this request")
+    return "".join(block.text for block in reply.content if block.type == "text")
+
+
+def api_failure(error: anthropic.APIError) -> ConfigError | TranslatorError:
+    """The tool error an API failure becomes: bad credentials are configuration, the rest are per-call."""
+    if isinstance(error, anthropic.AuthenticationError | anthropic.PermissionDeniedError):
+        return ConfigError(
+            f"the API rejected the credentials ({error.message}); check {API_KEY_ENV} / {AUTH_TOKEN_ENV}"
+        )
+    return TranslatorError(f"API request failed: {error.message}")
+
+
+class AnthropicTranslator:
+    """`Translator` backed by the Claude Messages API."""
+
+    def __init__(self, environ: Mapping[str, str]) -> None:
+        """Authenticate from exactly one of `ANTHROPIC_API_KEY` and `ANTHROPIC_AUTH_TOKEN`.
+
+        Raises:
+            ConfigError: Neither or both credentials are present in the environment.
+        """
+        self._client = anthropic.Anthropic(**credentials(environ))
+
+    def complete(self, *, model: str, system: str, messages: Sequence[Message], max_tokens: int) -> str:
+        """Send the conversation and return the reply text.
+
+        Raises:
+            ConfigError: The API rejected the credentials.
+            TranslatorError: The call failed, the reply was truncated, or the model declined.
+        """
+        request = message_request(model=model, system=system, messages=messages, max_tokens=max_tokens)
+        try:
+            with self._client.messages.stream(**request) as stream:
+                reply = stream.get_final_message()
+        except anthropic.APIError as exc:
+            raise api_failure(exc) from exc
+        return reply_text(reply, max_tokens=max_tokens)
diff --git a/scripts/docs/i18n/config.py b/scripts/docs/i18n/config.py
new file mode 100644
index 0000000000..e30d39d770
--- /dev/null
+++ b/scripts/docs/i18n/config.py
@@ -0,0 +1,243 @@
+"""The translation registry (`i18n/languages.yml`) and the tool's paths.
+
+The registry is the single list of language sites: the docs build reads it to
+know what to build and what the language switcher offers, and this tool reads
+it to know what to translate and with which models. Everything path-shaped
+about a language (its inputs, generated pages, state file, banner snippets)
+derives from its code so a new language is one registry entry plus its
+directory.
+"""
+
+from __future__ import annotations
+
+import re
+from collections.abc import Mapping
+from dataclasses import dataclass, replace
+from pathlib import Path
+from typing import Any, Literal, cast, get_args
+
+import yaml
+
+ROOT = Path(__file__).resolve().parents[3]
+DOCS = ROOT / "docs"
+I18N_DIR = ROOT / "i18n"
+REGISTRY_PATH = I18N_DIR / "languages.yml"
+GENERAL_PROMPT_PATH = I18N_DIR / "general-prompt.md"
+
+MODEL_ENV = "MCP_DOCS_I18N_MODEL"
+VERIFY_MODEL_ENV = "MCP_DOCS_I18N_VERIFY_MODEL"
+
+BANNER_KINDS = ("disclosure", "outdated", "untranslated", "stale", "english-only")
+
+# The generated map of translated sidebar labels, next to `state.json`.
+NAV_FILE = "nav.yml"
+
+# The writing system of a language's prose. The validator keys checks on the
+# target's script (e.g. an untranslated-English scan only makes sense for a
+# non-Latin target), never on the character mix of the output.
+Script = Literal["latin", "cjk", "hangul"]
+SCRIPTS: tuple[Script, ...] = get_args(Script)
+
+_CODE = re.compile(r"^[A-Za-z0-9-]+$")
+_LANGUAGE_KEYS = {"code", "name", "theme_language", "hreflang", "script", "enabled"}
+_TOP_KEYS = {"languages", "exclude_pages", "models"}
+_MODEL_KEYS = {"translate", "verify"}
+
+
+class ConfigError(Exception):
+    """A malformed registry, glossary, or missing input file (CLI exit code 2)."""
+
+
+@dataclass(frozen=True)
+class Language:
+    """One language site."""
+
+    code: str
+    name: str
+    theme_language: str
+    hreflang: str
+    script: Script
+    enabled: bool
+
+
+@dataclass(frozen=True)
+class Models:
+    """The translation model and the (stronger) semantic-review model."""
+
+    translate: str
+    verify: str
+
+
+@dataclass(frozen=True)
+class Registry:
+    """The parsed `languages.yml`."""
+
+    languages: tuple[Language, ...]
+    exclude_pages: tuple[str, ...]
+    models: Models
+
+    @property
+    def enabled(self) -> list[Language]:
+        """The languages that get a site, in registry order."""
+        return [language for language in self.languages if language.enabled]
+
+    def language(self, code: str) -> Language:
+        """The registry entry for `code`.
+
+        Raises:
+            ConfigError: `code` is not a registered language.
+        """
+        for language in self.languages:
+            if language.code == code:
+                return language
+        codes = ", ".join(language.code for language in self.languages)
+        raise ConfigError(f"unknown language {code!r} (registered: {codes})")
+
+
+def _keys(section: str, value: object, expected: set[str]) -> Mapping[str, Any]:
+    """`value` as a mapping whose keys are exactly `expected`."""
+    if not isinstance(value, dict):
+        raise ConfigError(f"languages.yml: {section} must be a mapping")
+    mapping = cast("dict[str, Any]", value)
+    if unknown := sorted(set(mapping) - expected):
+        raise ConfigError(f"languages.yml: {section} has unknown keys {unknown}")
+    if missing := sorted(expected - set(mapping)):
+        raise ConfigError(f"languages.yml: {section} is missing keys {missing}")
+    return mapping
+
+
+def _string(section: str, value: object) -> str:
+    if not isinstance(value, str) or not value:
+        raise ConfigError(f"languages.yml: {section} must be a non-empty string")
+    return value
+
+
+def _strings(section: str, value: object) -> tuple[str, ...]:
+    if not isinstance(value, list) or not all(isinstance(item, str) and item for item in cast("list[object]", value)):
+        raise ConfigError(f"languages.yml: {section} must be a list of non-empty strings")
+    return tuple(cast("list[str]", value))
+
+
+def _script(section: str, value: object) -> Script:
+    for script in SCRIPTS:
+        if value == script:
+            return script
+    raise ConfigError(f"languages.yml: {section} must be one of {list(SCRIPTS)}, found {value!r}")
+
+
+def load_registry(path: Path = REGISTRY_PATH) -> Registry:
+    """Parse and validate the language registry.
+
+    Raises:
+        ConfigError: The file is missing or does not match the schema.
+    """
+    try:
+        raw: object = yaml.safe_load(path.read_text(encoding="utf-8"))
+    except OSError as exc:
+        raise ConfigError(f"cannot read {path}: {exc}") from exc
+    except yaml.YAMLError as exc:
+        raise ConfigError(f"cannot parse {path}: {exc}") from exc
+    top = _keys("top level", raw, _TOP_KEYS)
+
+    entries = top["languages"]
+    if not isinstance(entries, list) or not entries:
+        raise ConfigError("languages.yml: languages must be a non-empty list")
+    languages: list[Language] = []
+    for entry in cast("list[object]", entries):
+        fields = _keys("a languages entry", entry, _LANGUAGE_KEYS)
+        code = _string("languages[].code", fields["code"])
+        if not _CODE.match(code):
+            raise ConfigError(f"languages.yml: language code {code!r} may only use letters, digits and '-'")
+        enabled = fields["enabled"]
+        if not isinstance(enabled, bool):
+            raise ConfigError(f"languages.yml: languages[{code}].enabled must be true or false")
+        languages.append(
+            Language(
+                code=code,
+                name=_string(f"languages[{code}].name", fields["name"]),
+                theme_language=_string(f"languages[{code}].theme_language", fields["theme_language"]),
+                hreflang=_string(f"languages[{code}].hreflang", fields["hreflang"]),
+                script=_script(f"languages[{code}].script", fields["script"]),
+                enabled=enabled,
+            )
+        )
+    codes = [language.code for language in languages]
+    if duplicates := sorted({code for code in codes if codes.count(code) > 1}):
+        raise ConfigError(f"languages.yml: duplicate language codes {duplicates}")
+
+    models = _keys("models", top["models"], _MODEL_KEYS)
+    return Registry(
+        languages=tuple(languages),
+        exclude_pages=_strings("exclude_pages", top["exclude_pages"]),
+        models=Models(
+            translate=_string("models.translate", models["translate"]),
+            verify=_string("models.verify", models["verify"]),
+        ),
+    )
+
+
+def resolve_models(
+    models: Models,
+    *,
+    environ: Mapping[str, str],
+    translate: str | None = None,
+    verify: str | None = None,
+) -> Models:
+    """Apply overrides to the registry's models: CLI flags win over env vars, which win over the file."""
+    return replace(
+        models,
+        translate=translate or environ.get(MODEL_ENV) or models.translate,
+        verify=verify or environ.get(VERIFY_MODEL_ENV) or models.verify,
+    )
+
+
+def language_dir(code: str, i18n_dir: Path = I18N_DIR) -> Path:
+    """The directory holding one language's inputs and generated pages."""
+    return i18n_dir / "languages" / code
+
+
+def instructions_path(code: str, i18n_dir: Path = I18N_DIR) -> Path:
+    return language_dir(code, i18n_dir) / "instructions.md"
+
+
+def glossary_path(code: str, i18n_dir: Path = I18N_DIR) -> Path:
+    return language_dir(code, i18n_dir) / "glossary.json"
+
+
+def pages_dir(code: str, i18n_dir: Path = I18N_DIR) -> Path:
+    """The generated translations, mirroring the page paths under `docs/`."""
+    return language_dir(code, i18n_dir) / "pages"
+
+
+def state_path(code: str, i18n_dir: Path = I18N_DIR) -> Path:
+    return language_dir(code, i18n_dir) / "state.json"
+
+
+def nav_path(code: str, i18n_dir: Path = I18N_DIR) -> Path:
+    """The generated translated navigation labels for one language."""
+    return language_dir(code, i18n_dir) / NAV_FILE
+
+
+def general_prompt_path(i18n_dir: Path = I18N_DIR) -> Path:
+    return i18n_dir / "general-prompt.md"
+
+
+def banner_path(kind: str, code: str, i18n_dir: Path = I18N_DIR) -> Path:
+    """The banner snippet for `code`, falling back to the English snippet when the language has none."""
+    localised = i18n_dir / "banners" / code / f"{kind}.md"
+    return localised if localised.is_file() else i18n_dir / "banners" / "en" / f"{kind}.md"
+
+
+def _build_dir(root: Path) -> Path:
+    """The throwaway directory of a repository root holding staged docs trees and language sites."""
+    return root / ".build" / "i18n"
+
+
+def staged_docs_dir(code: str, root: Path) -> Path:
+    """Where a language's docs tree is assembled (under repository `root`) before its site is built."""
+    return _build_dir(root) / code / "docs"
+
+
+def staged_site_dir(code: str, root: Path) -> Path:
+    """Where a language's site is built (under repository `root`) before being copied into `site//`."""
+    return _build_dir(root) / code / "site"
diff --git a/scripts/docs/i18n/files.py b/scripts/docs/i18n/files.py
new file mode 100644
index 0000000000..bf2b5f650f
--- /dev/null
+++ b/scripts/docs/i18n/files.py
@@ -0,0 +1,30 @@
+"""Atomic writes for the tool's generated files.
+
+A translation run or a `prune` that dies half-way through a plain
+`write_text` would leave a truncated `state.json`, `nav.yml` or translated
+page behind, and the next command would then refuse to parse it. Writing the
+new bytes to a sibling temporary file, syncing it, and renaming it over the
+target makes every generated file either its old content or its new content,
+never a torn one — the rename is atomic on the same filesystem.
+"""
+
+import contextlib
+import os
+import tempfile
+from pathlib import Path
+
+
+def write_text_atomically(path: Path, text: str) -> None:
+    """Write `text` to `path` such that an interruption leaves the old file or the new one, never a torn one."""
+    path.parent.mkdir(parents=True, exist_ok=True)
+    descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent, text=True)
+    try:
+        with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
+            stream.write(text)
+            stream.flush()
+            os.fsync(stream.fileno())
+        os.replace(temporary, path)
+    except BaseException:
+        with contextlib.suppress(OSError):
+            os.unlink(temporary)
+        raise
diff --git a/scripts/docs/i18n/glossary.py b/scripts/docs/i18n/glossary.py
new file mode 100644
index 0000000000..51a2e1a239
--- /dev/null
+++ b/scripts/docs/i18n/glossary.py
@@ -0,0 +1,119 @@
+"""A language's `glossary.json`: schema, loading and the fingerprint it feeds.
+
+The glossary is the machine-checkable half of a language's rules. It rides
+along in every prompt, and the validator enforces two of its lists after the
+fact: terms that must stay in English appear verbatim, and banned renderings
+never appear. The schema is closed — a misspelt key is an error, not a
+silently ignored field.
+"""
+
+from __future__ import annotations
+
+import json
+from collections.abc import Mapping
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any, cast
+
+from i18n.config import ConfigError
+
+SCHEMA_VERSION = 1
+_TOP_KEYS = {"version", "keep_in_source_language", "terms"}
+_TERM_KEYS = {"source", "target", "note", "avoid", "enforce"}
+_REQUIRED_TERM_KEYS = {"source", "target"}
+
+
+@dataclass(frozen=True)
+class Term:
+    """A required rendering (`target`) and banned renderings (`avoid`) for one English term."""
+
+    source: str
+    target: str
+    note: str = ""
+    avoid: tuple[str, ...] = field(default_factory=tuple)
+    enforce: bool = False
+
+
+@dataclass(frozen=True)
+class Glossary:
+    keep_in_source_language: tuple[str, ...]
+    terms: tuple[Term, ...]
+
+    @property
+    def enforced_avoids(self) -> list[tuple[str, str]]:
+        """Every `(source, banned rendering)` pair the validator must reject."""
+        return [(term.source, avoid) for term in self.terms if term.enforce for avoid in term.avoid]
+
+
+def _fail(path: Path, message: str) -> ConfigError:
+    return ConfigError(f"{path}: {message}")
+
+
+def _keys(path: Path, section: str, value: object, allowed: set[str], required: set[str]) -> Mapping[str, Any]:
+    if not isinstance(value, dict):
+        raise _fail(path, f"{section} must be an object")
+    mapping = cast("dict[str, Any]", value)
+    if unknown := sorted(set(mapping) - allowed):
+        raise _fail(path, f"{section} has unknown keys {unknown}")
+    if missing := sorted(required - set(mapping)):
+        raise _fail(path, f"{section} is missing keys {missing}")
+    return mapping
+
+
+def _strings(path: Path, section: str, value: object) -> tuple[str, ...]:
+    if not isinstance(value, list) or not all(isinstance(item, str) and item for item in cast("list[object]", value)):
+        raise _fail(path, f"{section} must be a list of non-empty strings")
+    return tuple(cast("list[str]", value))
+
+
+def load_glossary(path: Path) -> Glossary:
+    """Parse and validate a glossary file.
+
+    Raises:
+        ConfigError: The file is missing, malformed, or off-schema.
+    """
+    try:
+        raw: object = json.loads(path.read_text(encoding="utf-8"))
+    except OSError as exc:
+        raise _fail(path, f"cannot read: {exc}") from exc
+    except json.JSONDecodeError as exc:
+        raise _fail(path, f"invalid JSON: {exc}") from exc
+
+    top = _keys(path, "top level", raw, _TOP_KEYS, _TOP_KEYS)
+    if top["version"] != SCHEMA_VERSION:
+        raise _fail(path, f"version must be {SCHEMA_VERSION}")
+    keep = _strings(path, "keep_in_source_language", top["keep_in_source_language"])
+    entries = top["terms"]
+    if not isinstance(entries, list):
+        raise _fail(path, "terms must be a list")
+
+    terms: list[Term] = []
+    for index, entry in enumerate(cast("list[object]", entries)):
+        fields = _keys(path, f"terms[{index}]", entry, _TERM_KEYS, _REQUIRED_TERM_KEYS)
+        source, target, note = fields["source"], fields["target"], fields.get("note", "")
+        if not all(isinstance(value, str) for value in (source, target, note)) or not source or not target:
+            raise _fail(path, f"terms[{index}]: source, target and note must be strings")
+        avoid = _strings(path, f"terms[{index}].avoid", fields.get("avoid", []))
+        enforce = fields.get("enforce", False)
+        if not isinstance(enforce, bool):
+            raise _fail(path, f"terms[{index}].enforce must be true or false")
+        terms.append(Term(source=source, target=target, note=note, avoid=avoid, enforce=enforce))
+
+    return Glossary(keep_in_source_language=keep, terms=tuple(terms))
+
+
+def glossary_prompt(glossary: Glossary) -> str:
+    """The glossary rendered as the prompt section the model sees."""
+    lines = ["## Glossary", "", "These terms always stay in English, spelled exactly like this:", ""]
+    lines += [f"- {term}" for term in glossary.keep_in_source_language]
+    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)
+    lines.append("")
+    return "\n".join(lines)
diff --git a/scripts/docs/i18n/inputs.py b/scripts/docs/i18n/inputs.py
new file mode 100644
index 0000000000..1deec82fa8
--- /dev/null
+++ b/scripts/docs/i18n/inputs.py
@@ -0,0 +1,85 @@
+"""The prompt inputs of one language and the fingerprints they contribute.
+
+A language's translations are a pure function of four texts — the English
+page, the general prompt, the language's instructions and its glossary — and
+the pipeline version. Hashing all of them into every block hash is what makes
+`status` notice an instructions or glossary edit as an outdated translation
+without any bookkeeping beyond the state file.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+from dataclasses import dataclass
+from pathlib import Path
+
+from i18n.config import ConfigError, Language, general_prompt_path, glossary_path, instructions_path
+from i18n.glossary import Glossary, load_glossary
+from i18n.nav import NavFingerprint, labels_hash
+from i18n.pages import block_hashes, block_salt, sha256, split_blocks
+from i18n.state import Fingerprint
+
+
+@dataclass(frozen=True)
+class LanguageInputs:
+    """Everything a language's prompts are assembled from, plus its fingerprints."""
+
+    language: Language
+    general_prompt: str
+    instructions: str
+    glossary: Glossary
+    general_prompt_hash: str
+    instructions_hash: str
+    glossary_hash: str
+
+    @property
+    def salt(self) -> str:
+        """The salt every block hash for this language mixes in."""
+        return block_salt(self.general_prompt_hash, self.instructions_hash, self.glossary_hash)
+
+    def fingerprint(self, english: str) -> Fingerprint:
+        """What a translation of `english` made right now would record."""
+        return Fingerprint(
+            source_hash=sha256(english),
+            blocks=tuple(block_hashes(split_blocks(english), self.salt)),
+            general_prompt_hash=self.general_prompt_hash,
+            instructions_hash=self.instructions_hash,
+            glossary_hash=self.glossary_hash,
+        )
+
+    def nav_fingerprint(self, labels: Sequence[str]) -> NavFingerprint:
+        """What a translation of the nav `labels` made right now would record."""
+        return NavFingerprint(
+            labels_hash=labels_hash(labels),
+            general_prompt_hash=self.general_prompt_hash,
+            instructions_hash=self.instructions_hash,
+            glossary_hash=self.glossary_hash,
+        )
+
+
+def _read(path: Path) -> str:
+    try:
+        return path.read_text(encoding="utf-8")
+    except OSError as exc:
+        raise ConfigError(f"cannot read {path}: {exc}") from exc
+
+
+def load_inputs(language: Language, i18n_dir: Path) -> LanguageInputs:
+    """Read and fingerprint one language's prompt inputs.
+
+    Raises:
+        ConfigError: A required input file is missing or invalid.
+    """
+    general = _read(general_prompt_path(i18n_dir))
+    instructions = _read(instructions_path(language.code, i18n_dir))
+    glossary_file = glossary_path(language.code, i18n_dir)
+    glossary = load_glossary(glossary_file)
+    return LanguageInputs(
+        language=language,
+        general_prompt=general,
+        instructions=instructions,
+        glossary=glossary,
+        general_prompt_hash=sha256(general),
+        instructions_hash=sha256(instructions),
+        glossary_hash=sha256(_read(glossary_file)),
+    )
diff --git a/scripts/docs/i18n/markdown.py b/scripts/docs/i18n/markdown.py
new file mode 100644
index 0000000000..3161c5014d
--- /dev/null
+++ b/scripts/docs/i18n/markdown.py
@@ -0,0 +1,322 @@
+"""Markdown structure the docs tooling looks at: headings, anchors, fences, spans, links.
+
+This is the one place a heading, an anchor and a code fence are defined, so
+the anchor pinning check, the translation validator and the mechanical
+repairer cannot disagree about what is a heading, what its `{#id}` block pins,
+which anchor rules a heading breaks (`anchor_problems`), or what is code.
+Prose-level scans (glossary terms, untranslated runs) must never look inside
+code, URLs or HTML tags, so `mask` blanks those regions to same-length
+whitespace: positions and line numbers survive, but code can never match a
+prose rule. Structural extraction (fences, links, admonitions, tables) works
+on the same fence rules so every check agrees on what is code.
+"""
+
+import re
+from collections.abc import Callable, Iterator, Sequence
+from dataclasses import dataclass
+from typing import TypeAlias
+
+# A leading YAML front matter block, as MkDocs/Zensical parse it: the closer
+# is a line that is exactly `---` or `...` (a `----` line does not end it),
+# and the match runs through that line's newline. Shared by the page model
+# and the validator so both agree on where the front matter ends.
+FRONTMATTER = re.compile(r"\A---[ \t]*\n(?P.*?)^(?:---|\.\.\.)[ \t]*(?:\n|\Z)", re.MULTILINE | re.DOTALL)
+# An ATX heading, matched the way the docs' markdown parser matches it: one
+# to six hashes at column 0 (no leading indent, no space required after
+# them), an optional closing hash run that is not part of the text, and
+# backslash escapes honoured so an escaped `\#` is text rather than a closer.
+HEADING = re.compile(r"^(?P#{1,6})(?!#)(?P(?:\\.|[^\\\n])*?)#*[ \t]*$")
+# The trailing attr_list block a heading may carry (`Heading {#id .cls}`),
+# read with or without the whitespace before it: attr_list only honours a
+# block that follows whitespace (a glued `Title{#id}` renders as literal
+# text), so the parser sees the id either way and `Heading.attrs_spaced`
+# records whether the block would actually render.
+HEADING_ATTRS = re.compile(r"[ \t]*\{:?[ \t]*(?P[^}\n]*?)[ \t]*\}[ \t]*$")
+# The markdown backslash-escape rule: a backslash before ASCII punctuation
+# yields that character. It is also the anchor escape rule — the inline pass
+# resolves `{#foo\_bar}` to the id `foo_bar` before attr_list reads the block.
+ESCAPE = re.compile(r"\\(?P[!\"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~])")
+# An underscore that is not word-internal must be written escaped inside a
+# `{#...}` block, or the inline emphasis pass consumes it before attr_list
+# sees the block (see `anchor_source_form`).
+_BOUNDARY_UNDERSCORE = re.compile(r"(?`{3,}|~{3,})(?P[^\n]*)$")
+# Inline code span; cannot cross a blank line, so a stray unpaired backtick
+# cannot swallow the paragraphs after it.
+CODE_SPAN = re.compile(r"(?s)(?[^)\s]*)(?P[^)]*)\)")
+# A bare URL runs over printable ASCII only (minus the `< > ) ]` that close
+# a link): non-ASCII text is prose, so CJK written flush against a URL — no
+# space between them in that script — ends the URL instead of being masked
+# with it.
+_URL = re.compile(r"[a-zA-Z][a-zA-Z0-9+.-]*://(?:(?![<>)\]])[!-~])+")
+_TAG = re.compile(r"\n]*>|", flags=re.DOTALL)
+# Every link and image with its label: `!`? `[label](target ...)`.
+_LINK = re.compile(r"(?P!?)\[(?P