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

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

148
i18n/ru/pages/run/asgi.md Normal file
View file

@ -0,0 +1,148 @@
---
translation:
sections: [1062ef792791488a, 4be2b831547184a9, 374b049e770385f2, b72f6947089e6de0, b172c9db7831bb31, 70b9ece244ca1b0c, cba78e052898c3f6, f06bdb541cb0b469, fb82d526320b7cc3]
tool: 1
---
# Добавление в существующее приложение {#add-to-an-existing-app}
`mcp.run("streamable-http")` запускает веб-сервер за вас. Иногда это не то, что нужно: MCP-сервер — лишь часть более крупного веб-приложения, или у вас уже есть развёрнутое ASGI-приложение.
Для таких случаев `mcp.streamable_http_app()` возвращает **приложение Starlette**.
Приложение Starlette — это ASGI-приложение, поэтому разместить MCP-сервер может всё, что умеет запускать ASGI: uvicorn, Hypercorn, другое приложение Starlette, FastAPI.
## Приложение {#the-app}
```python title="server.py" hl_lines="12"
--8<-- "docs_src/asgi/tutorial001.py"
```
`app` — обычное ASGI-приложение. Передайте его любому ASGI-серверу:
```console
uvicorn server:app
```
Конечная точка MCP находится по пути `/mcp`, так что клиент подключается к `http://127.0.0.1:8000/mcp`.
В приложении уже есть две вещи:
* Один маршрут, `/mcp`: конечная точка Streamable HTTP.
* **Жизненный цикл** (lifespan), который запускает `mcp.session_manager` — объект, владеющий фоновой работой всех активных сессий.
Запустите приложение само по себе (`uvicorn server:app`) — и ни о том, ни о другом думать не придётся.
!!! tip
`streamable_http_app()` принимает те же именованные аргументы, что и `mcp.run("streamable-http", ...)`,
кроме `port`: порт принадлежит тому, что обслуживает приложение. `host` по-прежнему принимается,
но здесь ни к чему не привязывается; что он на самом деле контролирует, объясняет страница
**[Развёртывание и масштабирование](deploy.md)**.
Сами параметры описаны на странице **[Запуск сервера](index.md)**.
`mcp.sse_app()` делает то же самое для вытесненного транспорта SSE.
## Только localhost, пока вы не укажете иное {#localhost-only-until-you-say-otherwise}
По умолчанию приложение отвечает **только** на запросы, адресованные localhost. `streamable_http_app()`
не может знать, за каким именем хоста его будут обслуживать, поэтому включает защиту от DNS-rebinding
с самым безопасным из возможных списком разрешённых хостов; на вашей машине это ровно то, что нужно.
При развёртывании за настоящим именем хоста это означает, что **каждый запрос отклоняется с
`421 Misdirected Request`**, пока вы не передадите в `transport_security=` список того, что
действительно обслуживаете. До вашего кода дело даже не доходит. Этот список и всё остальное,
что отделяет работающее приложение от настоящего имени хоста, — на странице
**[Развёртывание и масштабирование](deploy.md)**.
## Монтирование {#mounting-it}
Как только MCP-сервер становится *частью* более крупного приложения, вы помещаете его приложение внутрь `Mount`. И как только вы это делаете, жизненный цикл становится вашей заботой:
```python title="server.py" hl_lines="18-21 25-26"
--8<-- "docs_src/asgi/tutorial002.py"
```
* `Mount("/", ...)` вместе с путём `/mcp` по умолчанию оставляет конечную точку по адресу `/mcp`. Starlette перебирает маршруты по порядку, а `Mount("/")` совпадает с **любым** путём, поэтому ваши собственные маршруты идут в списке *перед* ним. Всё, что после него, недостижимо.
* Функция `lifespan` входит в `mcp.session_manager.run()` на всё время жизни **хост-приложения**. Именно эту строку все забывают.
* `mcp.session_manager` существует только *после* вызова `streamable_http_app()`. Поэтому маршруты строятся на уровне модуля, а к менеджеру обращаются только внутри жизненного цикла.
Маршрут `Host` из Starlette работает так же: замените `Mount("/", ...)` на `Host("mcp.example.com", ...)`, чтобы маршрутизировать по имени хоста, а не по пути. Правило о жизненном цикле не меняется, как и правило о транспортной безопасности. Маршрут `Host("mcp.example.com", ...)` получает только запросы, адресованные этому имени хоста, но собственный список разрешённых значений Host у транспорта (**[Развёртывание и масштабирование](deploy.md)**) всё равно проверяется первым. Если в нём нет `"mcp.example.com"`, этот маршрут отвечает на каждый такой запрос кодом `421`.
!!! warning "Жизненным циклом владеет хост-приложение"
`streamable_http_app()` встраивает `session_manager.run()` в жизненный цикл возвращаемого
приложения Starlette, но **жизненный цикл смонтированного подприложения никогда не выполняется**.
Смонтируйте приложение — и этот встроенный жизненный цикл станет мёртвым кодом. Приложение,
стоящее на вершине вашего ASGI-стека, должно войти в `mcp.session_manager.run()` в своём
собственном жизненном цикле.
!!! check
Удалите строку `lifespan=lifespan` и запустите сервер. Он запускается. Маршрут находится.
А затем первый запрос к `/mcp` падает с ошибкой:
```text
RuntimeError: Task group is not initialized. Make sure to use run().
```
Менеджер сессий не запускает ничто, кроме его метода `run()`.
## Два сервера, одно приложение {#two-servers-one-app}
Каждый `MCPServer` — отдельное приложение со своим менеджером сессий. Монтируйте сколько угодно; входите в каждый менеджер из одного жизненного цикла хост-приложения:
```python title="server.py" hl_lines="27-30 35-36"
--8<-- "docs_src/asgi/tutorial003.py"
```
* `AsyncExitStack` входит в оба менеджера; они запускаются вместе и завершаются в обратном порядке.
* Конечные точки — `/notes/mcp` и `/tasks/mcp`: префикс монтирования плюс путь по умолчанию.
## Изменение пути {#changing-the-path}
Завершающий `/mcp` — это `streamable_http_path`. Задайте ему значение `"/"`, и префикс монтирования станет полным публичным путём:
```python title="server.py" hl_lines="25"
--8<-- "docs_src/asgi/tutorial004.py"
```
Теперь клиенты подключаются к `/notes`, а не к `/notes/mcp`.
## CORS для браузерных клиентов {#cors-for-browser-clients}
Браузерному клиенту нужны от вас два разрешения: **отправлять** свои заголовки MCP-запроса и **читать** тот заголовок, что MCP присылает в ответ. И то и другое — настройка CORS в хост-приложении, и список разрешённых хостов транспортной безопасности, описанный выше, должен с ней согласовываться:
```python title="server.py" hl_lines="27-30 33 35-49"
--8<-- "docs_src/asgi/tutorial005.py"
```
* `allow_headers` — та половина, которую все забывают. Браузер выполняет **предварительный запрос** (preflight) перед каждым MCP-запросом, потому что `Content-Type: application/json` и заголовки запроса `Mcp-*` не входят в безопасный список CORS, а заголовок, не разрешённый предварительным запросом, — это запрос, который браузер никогда не отправит. (`allow_headers=["*"]` тоже работает: Starlette отвечает на предварительный запрос тем, что тот запросил.)
* `expose_headers=["Mcp-Session-Id"]` — половина, отвечающая за чтение. Streamable HTTP возвращает идентификатор сессии в этом заголовке ответа, а браузеры скрывают заголовки ответа от JavaScript, если CORS не раскрывает их поимённо. Без этого клиент никогда не сможет сделать второй запрос.
* `allow_origins` — ваше решение, а не MCP. Будьте точны и продублируйте его в `allowed_origins=` выше: CORS обеспечивает браузер, но сервер сам проверяет `Origin`, и источник, которому транспорт не доверяет, получает `403` даже после успешного предварительного запроса.
* `allow_methods` перечисляет три метода, которые использует Streamable HTTP: `POST` для отправки сообщений, `GET` для открытия потока от сервера к клиенту, `DELETE` для завершения сессии.
## Пользовательские маршруты {#custom-routes}
`@mcp.custom_route()` регистрирует обычную HTTP-точку в том же приложении — для вещей, которые нужны каждому развёрнутому сервису и не имеют отношения к MCP: проверка работоспособности, колбэк OAuth.
```python title="server.py" hl_lines="15-17"
--8<-- "docs_src/asgi/tutorial006.py"
```
* Обработчик — обычный Starlette: `async`-функция из `Request` в `Response`.
* `streamable_http_app()` подхватывает все пользовательские маршруты. Теперь `app.routes` — это `/mcp` и `/health`.
* `GET /health` отвечает `{"status": "ok"}` без всякого MCP.
!!! warning
Пользовательские маршруты **никогда не аутентифицируются**, даже когда остальной сервер защищён.
Это сделано намеренно: проверки работоспособности и колбэки OAuth должны быть доступны до того,
как появится какой-либо токен. Не размещайте за ними ничего приватного.
## Итоги {#recap}
* `mcp.streamable_http_app()` возвращает приложение Starlette с одним маршрутом, `/mcp`. Запустить его может любой ASGI-сервер.
* По умолчанию приложение отвечает только на запросы, адресованные localhost, а за настоящим именем хоста отклоняет всё кодом `421`, пока вы не передадите список разрешённых хостов в `transport_security=`. За это и за остальной путь к продакшену отвечает страница **[Развёртывание и масштабирование](deploy.md)**.
* `Mount` (или `Host`) помещает его внутрь более крупного приложения Starlette или FastAPI.
* **Монтирование отключает встроенный жизненный цикл.** Жизненный цикл хост-приложения должен войти в `mcp.session_manager.run()`, иначе первый запрос завершится ошибкой.
* Несколько серверов в одном приложении — это несколько монтирований и один жизненный цикл, который входит в каждый менеджер сессий.
* `streamable_http_path="/"` переносит конечную точку на сам префикс монтирования.
* Браузерным клиентам нужен CORS: `allow_headers` для заголовков запроса `Mcp-*`, `expose_headers=["Mcp-Session-Id"]` для ответа.
* `@mcp.custom_route()` добавляет обычные HTTP-точки без аутентификации рядом с `/mcp`.
Когда сервер доступен по настоящему URL, **[Клиент](../client/index.md)** подключается к нему по этому URL вместо объекта сервера.

View file

@ -0,0 +1,130 @@
---
translation:
sections: [d62c13457fc4a534, 80e73abaca6e0652, d1dc4c54cd00ec9c, 14ad3bc7904036bb, 5225f127bc1b9c77, fe1626fdd5aad1da, 4556cb7ea1a04a31]
tool: 1
---
# Авторизация {#authorization}
Через Streamable HTTP MCP-сервер — это обычный веб-сервис, и защищается он так же, как любой веб-сервис: с помощью bearer-токенов OAuth 2.1.
В терминах OAuth ваш сервер — это **сервер ресурсов**. Он никого не аутентифицирует и не выдаёт токенов. Он делает ровно одно: смотрит на заголовок `Authorization` каждого запроса и решает, годится ли токен в нём.
Эта страница — о серверной стороне. Клиент, который обнаруживает ваш сервер авторизации и получает токен, описан на странице **[OAuth-клиенты](../client/oauth-clients.md)**.
## Три стороны {#the-three-parties}
* **Сервер авторизации** аутентифицирует пользователей и выдаёт токены доступа. Его вы не пишете. Это ваш провайдер идентификации (Auth0, Keycloak, Entra или ваш собственный).
* **Сервер ресурсов** — это ваш MCP-сервер. Он проверяет токен в каждом запросе.
* **Клиент** выясняет, какому серверу авторизации вы доверяете, получает у него токен и присылает его вам в виде `Authorization: Bearer <token>`.
Вот и весь треугольник. Всё на этой странице — про средний пункт.
## Верификатор токенов {#a-token-verifier}
SDK ничего не предполагает о том, как выглядит действительный токен. Это определяете вы, реализуя **`TokenVerifier`**:
```python title="server.py" hl_lines="12-14 19-24"
--8<-- "docs_src/authorization/tutorial001.py"
```
* `TokenVerifier` — это протокол с одним асинхронным методом. `verify_token` получает сырой токен из заголовка `Authorization` и возвращает **`AccessToken`**, если токен действителен, или `None`, если нет. Больше реализовывать нечего.
* Этот верификатор ищет токен в таблице. Настоящий проверяет подпись JWT или обращается к эндпоинту интроспекции токенов на сервере авторизации. Этот код — ваш; SDK его только вызывает.
* `token_verifier=` и `auth=` всегда идут в паре. Передайте один без другого — и `MCPServer(...)` выбросит `ValueError` ещё до того, как обслужит хоть один запрос.
`AuthSettings` — это публичное лицо вашего сервера ресурсов:
* `issuer_url`: сервер авторизации, который выдаёт ваши токены.
* `resource_server_url`: публичный URL этого MCP-эндпоинта. Он указывает, *для какого* ресурса предназначен токен, и по нему же размещается документ обнаружения.
* `required_scopes`: каждый токен должен содержать их все.
!!! tip
В `examples/servers/simple-auth/` в репозитории SDK есть `IntrospectionTokenVerifier`, который обращается
к эндпоинту [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662) настоящего сервера авторизации. Именно так устроено большинство верификаторов в продакшене.
## Что вы получаете через HTTP {#what-you-get-over-http}
Авторизация живёт в HTTP-заголовках, поэтому существует только на HTTP-транспортах. Запускайте её на том транспорте, который развёртываете: `mcp.run(transport="streamable-http")` поднимает её на `http://127.0.0.1:8000/mcp`, а остальное — на странице **[Запуск сервера](index.md)**. Теперь у приложения два маршрута:
```text
/mcp
/.well-known/oauth-protected-resource/mcp
```
Вы зарегистрировали один инструмент. Второй маршрут добавил SDK.
### Обнаружение {#discovery}
Выполните `GET` по этому well-known-пути — и получите **Protected Resource Metadata по [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728)**, собранные прямо из ваших `AuthSettings`:
```json
{
"resource": "http://127.0.0.1:8000/mcp",
"authorization_servers": ["https://auth.example.com/"],
"scopes_supported": ["notes:read"],
"bearer_methods_supported": ["header"]
}
```
Именно по этому документу клиент, который никогда не слышал о вашем сервере, находит к нему дорогу: читает `authorization_servers` и идёт туда за токеном. Ничего из этого вы не писали.
!!! check
Обратитесь к `/mcp` без токена (или с таким, для которого верификатор вернул `None`) — и запрос
остановят на входе:
```text
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="Authentication required", resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp"
{"error": "invalid_token", "error_description": "Authentication required"}
```
Ничего не разбиралось, ни один инструмент не запускался. А указатель `resource_metadata` в `WWW-Authenticate` —
это то, что делает обнаружение автоматическим: 401 -> документ метаданных -> сервер авторизации -> токен -> повтор.
!!! warning
Ничто из этого не защищает `stdio`. У канала нет заголовка `Authorization`, поэтому к `token_verifier` там никогда
не обращаются. Граница безопасности `stdio`-сервера — это процесс, который его запустил. То же
относится к `Client(mcp)` в памяти, который используется в тестах: он подключается напрямую к объекту сервера
и минует HTTP-уровень вместе с авторизацией.
## Личность вызывающего {#the-callers-identity}
Внутри любого обработчика **`get_access_token()`** — это `AccessToken`, который ваш верификатор вернул для текущего запроса:
```python title="server.py" hl_lines="4 32-35"
--8<-- "docs_src/authorization/tutorial002.py"
```
* Это работает в инструментах, ресурсах и промптах, и ничего передавать не нужно: middleware авторизации сохраняет его в контекстной переменной для каждого запроса.
* Возвращается **тот самый объект, который собрал ваш верификатор**: `client_id`, `scopes`, `subject`, `expires_at` и любые дополнительные `claims`, которые вы прикрепили. Это и есть точка для правил на уровне отдельных инструментов: прочитайте scopes и откажите.
* Вне аутентифицированного HTTP-запроса функция возвращает `None`. В памяти и через `stdio` это всегда `None`.
Вызовите `whoami` с `Authorization: Bearer alice-token` — и модель прочитает:
```text
alice (scopes: notes:read)
```
## Половина, которую SDK не делает {#the-half-the-sdk-doesnt-do}
SDK даёт вам половину сервера ресурсов: проверить, объявить, отказать. Он не даёт страницу входа, экран согласия или токен.
Чтобы увидеть все три стороны в действии, запустите `examples/servers/simple-auth/` из репозитория SDK (небольшой сервер авторизации и сервер ресурсов, настроенный ровно так, как на этой странице), а затем направьте на него `examples/clients/simple-auth-client/` — и пройдите весь путь от обнаружения до токена.
!!! info
Есть второй аргумент конструктора, `auth_server_provider=`, который встраивает полноценный сервер
авторизации внутрь MCP-сервера. Он появился раньше разделения AS/RS, вокруг которого построена спецификация
авторизации MCP. В новых серверах к нему прибегать не следует.
Сервер авторизации также может принять подписанное утверждение от корпоративного провайдера идентификации вместо того, чтобы пользователь проходил через экран согласия, и SDK поддерживает обе стороны этого обмена. Сам грант и клиент, который его предъявляет, описаны на странице **[Утверждение личности](../client/identity-assertion.md)**.
## Итоги {#recap}
* Через Streamable HTTP ваш сервер — это **сервер ресурсов** OAuth 2.1: он проверяет токены, но никогда их не выдаёт.
* `TokenVerifier` — вся поверхность интеграции: один асинхронный метод, токен на входе, `AccessToken | None` на выходе.
* `token_verifier=` и `auth=AuthSettings(issuer_url=..., resource_server_url=..., required_scopes=[...])` всегда идут в паре.
* SDK публикует Protected Resource Metadata по [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) по адресу `/.well-known/oauth-protected-resource/...` и отвечает на неаутентифицированные запросы кодом 401, заголовок `WWW-Authenticate` которого указывает на них. Это и есть вся история обнаружения.
* `get_access_token()` в любом обработчике — это тот, кто вызывает.
* Авторизация — забота HTTP. `stdio` и клиент в памяти её никогда не видят.
Клиентская половина (обнаружение вашего сервера авторизации и получение токена за вас) — на странице **[OAuth-клиенты](../client/oauth-clients.md)**. А клиент, который *утверждает* личность вместо того, чтобы запрашивать её у пользователя, — на странице **[Утверждение личности](../client/identity-assertion.md)**.

180
i18n/ru/pages/run/deploy.md Normal file
View file

@ -0,0 +1,180 @@
---
translation:
sections: [28221886b198784f, f88ea1f1614f3a1d, ce926d686730b6d0, 3be24f8ad8bb5ab9, 3fad24032b2224ff, f25a7f860e579ecb, e758745df6fb7b0a]
tool: 1
---
# Развёртывание и масштабирование {#deploy-scale}
Сервер работает. Теперь ему нужно настоящее доменное имя и больше одного рабочего процесса за ним.
Почти ничего из этого MCP не касается. ASGI-сервер, менеджер процессов, балансировщик нагрузки — всё это на вашей стороне. На этой странице собран короткий список того, что MCP *касается*: одна настройка, от которой зависит любое развёртывание, и два места, где «больше одного рабочего процесса» меняет поведение SDK.
## Прежде всего: список разрешённых значений Host {#before-anything-else-the-host-allowlist}
`streamable_http_app()` не может знать, за каким доменным именем его будут отдавать, поэтому предполагает самый безопасный вариант: localhost. Без параметра `transport_security=` приложение включает **защиту от DNS-rebinding** и принимает запрос, только если его заголовок `Host` равен `127.0.0.1:<port>`, `localhost:<port>` или `[::1]:<port>`. Заголовок `Origin`, если он есть, должен быть `http://`-формой того же самого. На вашей машине это ровно то, что нужно: вредоносная веб-страница не сможет управлять локальным сервером через DNS-имя, которое она перепривязала к `127.0.0.1`.
При развёртывании за настоящим доменным именем то же самое поведение по умолчанию отклоняет **каждый запрос**, пока вы не скажете иначе. Проверка выполняется раньше всего, что относится к MCP, так что до написанного вами кода дело даже не доходит:
```text
421 Misdirected Request Invalid Host header the Host is not in the allowlist
403 Forbidden Invalid Origin header the Origin is not in the allowlist
```
Решение — `transport_security=`. Разрешите то, что действительно обслуживаете:
```python title="server.py" hl_lines="2 13-17"
--8<-- "docs_src/deploy/tutorial001.py"
```
* Элементы `allowed_hosts` — точные строки: `"mcp.example.com"` совпадает с заголовком `Host` без порта, а `"mcp.example.com:*"` — с любым портом. Укажите оба.
* `allowed_origins` имеет значение только для браузеров, потому что больше никто не отправляет `Origin`. Это серверный близнец конфигурации CORS со страницы **[Добавление в существующее приложение](asgi.md)**.
* За обратным прокси, который уже контролирует заголовок `Host`, честная конфигурация — отключить проверку: `TransportSecuritySettings(enable_dns_rebinding_protection=False)`.
* Передача `host=`, отличного от localhost (например, `host="mcp.example.com"`), **не** добавляет это имя в список разрешённых. Она лишь не даёт значению localhost по умолчанию включить защиту, в результате чего принимаются любые Host и Origin. Вместо этого скажите прямо, что имеете в виду, через `transport_security=`.
!!! check
Удалите аргумент `transport_security=security` и всё равно разверните приложение. Оно
запускается, маршрут `/mcp` работает, и каждый запрос (включая обычный `curl`) возвращает:
```text
HTTP/1.1 421 Misdirected Request
Invalid Host header
```
На стороне клиента этих слов не найти. `421` — это обычный текстовый HTTP-ответ, а не
ошибка JSON-RPC, поэтому MCP-клиент выбрасывает общее исключение транспорта; доменное имя,
которое не понравилось серверу, появляется только в логе **сервера**, одним предупреждением.
Свежеразвёрнутый сервер, который отклоняет все подключения, — это список разрешённых Host,
пока не доказано обратное. **[Устранение неполадок](../troubleshooting.md)** тоже начинается отсюда.
## Рабочие процессы и кому нужна привязка {#workers-and-who-has-to-be-sticky}
Как только доменное имя отвечает, поставьте за ним больше одного рабочего процесса. В SDK для этого нет никакой ручки; приложение Starlette масштабируется так же, как любое ASGI-приложение: объект передаётся тому, кто умеет порождать процессы:
```console
uvicorn server:app --workers 4
```
Четыре процесса, один сокет. И теперь вопрос, на который должно ответить каждое развёртывание: **должен ли запрос попасть к тому же рабочему процессу, что видел предыдущий?**
Для клиента, говорящего на протоколе **2026-07-28**, — нет. Современный запрос — это один самодостаточный POST: никакого рукопожатия `initialize` перед ним, никакого `Mcp-Session-Id` в ответе, второму запросу просто *некуда* возвращаться. Направляйте его любому рабочему процессу.
Это не режим, который нужно включать. `stateless_http=True` выглядит так, будто им и должен быть, но транспорт маршрутизирует по заголовку запроса `MCP-Protocol-Version`, передаёт современный запрос современному обработчику и **возвращает управление**. Строка, читающая `stateless_http`, идёт *после* этого возврата. Дело не в том, что флаг игнорируется на пути 2026-07-28; до него просто никогда не доходит. `stateless_http` — ручка только для ветки **старого поколения**, а современный путь лишён сессий по построению.
Для клиента старого поколения на версии спецификации 2025-11-25 или более ранней ответ зависит от этого флага:
| Версия протокола клиента | Сессия | Что должен делать балансировщик нагрузки |
| --- | --- | --- |
| **2026-07-28** | Нет. `Mcp-Session-Id` никогда не устанавливается. | Ничего. Любой рабочий процесс обслуживает любой запрос. |
| **2025-11-25 и ранее** (по умолчанию) | `Mcp-Session-Id`, хранится в памяти одного рабочего процесса. | **Привязка сессий (sticky sessions).** Последующий запрос, попавший к другому рабочему процессу, получает `404` *«Session not found»*. |
| **2025-11-25 и ранее**, с `stateless_http=True` | Нет. | Ничего. Цена — обратный канал (back-channel) от сервера к клиенту (сэмплирование (sampling), push-элицитация (elicitation), `roots/list`) и возобновляемость. |
Привязке сессий и цене ветки старого поколения посвящена отдельная страница — **[Обслуживание клиентов старого поколения](legacy-clients.md)**; сами два поколения — **[Версии протокола](../protocol-versions.md)**. Здесь важна форма ответа: *на 2026-07-28 вы уже работаете без состояния, и настраивать нечего.*
Остаток этой страницы — две вещи, которые работа без состояния вам **не** даёт.
## `requestState` между рабочими процессами {#requeststate-across-workers}
**[Многораундовому](../handlers/multi-round-trip.md)** (multi-round-trip) инструменту нужно что-то, за чем клиент должен сходить (подтверждение, выбор, учётные данные), поэтому он возвращает вопрос вместо ответа и завершается при повторе. Между двумя раундами клиент держит непрозрачный токен `request_state`, выпущенный сервером. При повторе сервер должен снова открыть этот токен.
*Запечатанный каким ключом?* По умолчанию — тем, что сервер сгенерировал через `os.urandom(32)` при создании. Под `--workers 4` это четыре создания в четырёх процессах: четыре разных ключа, нигде не записанных, никем не разделяемых и исчезающих при перезапуске.
Вот инструмент, который спрашивает, прежде чем действовать, на сервере, который ничего не настраивает:
```python title="server.py" hl_lines="14 20"
--8<-- "docs_src/deploy/tutorial002.py"
```
Первый раунд попадает к рабочему процессу A. Процесс A запечатывает `refund:120` **своим** ключом и возвращает токен. Клиент показывает вопрос человеку, получает «да» и повторяет запрос. Повтор — это совершенно новый HTTP-запрос.
!!! check
Пусть этот повтор попадёт к рабочему процессу B. B пытается распечатать токен, который не выпускал,
не может и отклоняет весь раунд. `refund` так и не вызывается; клиент получает ошибку JSON-RPC:
```json
{
"code": -32602,
"message": "Invalid or expired requestState",
"data": {"reason": "invalid_request_state"}
}
```
Это сообщение **неизменно**. Истёк срок, подделан, воспроизведён с другими аргументами или
(с большим отрывом самая частая причина в реальном развёртывании) запечатан соседним рабочим
процессом: клиенту каждый раз сообщают одно и то же, так что по сети никогда не видно, какая
проверка не прошла. Настоящая причина — одно сообщение `WARNING` в логе сервера:
```text
requestState rejected on tools/call: unknown key
```
Многораундовый инструмент, который работал с одним рабочим процессом и начал падать *время от
времени* на двух, — это именно оно. Обоим раундам по-прежнему нужно попасть в один процесс,
поэтому он падает ровно настолько часто, насколько балансировщик их разводит.
Два раунда — это два независимых HTTP-запроса, и их разводят вполне обычные вещи: прокси, балансирующий по запросам, соединение, оборвавшееся между ними, развёртывание или перезапуск, клиент, который сохранил `request_state` и возобновляет работу вообще из другого процесса (**[Управление циклом вручную](../handlers/multi-round-trip.md#driving-the-loop-yourself)**). Любое из этого — «другой рабочий процесс».
Решение — один аргумент. У него **две** половины.
```python title="server.py" hl_lines="1 12 14"
--8<-- "docs_src/deploy/tutorial003.py"
```
* **`keys=[...]`** — половина, которую находят все. Дайте каждому экземпляру один и тот же секрет (не меньше 32 байт), и каждый экземпляр сможет распечатать то, что выпустил любой сосед. `keys[0]` запечатывает, а распечатывает любой ключ из списка — это кольцо ротации; как провернуть его без простоя — в разделе **[Ротация ключей](../handlers/multi-round-trip.md#rotating-keys)**.
* **Имя сервера** — половина, которую почти никто не находит, и причина, по которой повторы между экземплярами всё ещё падают после того, как ключ сделан общим. Каждый запечатанный токен несёт `name` сервера как **audience claim**, который строго проверяется на обратном пути. Два экземпляра, собранные из одного кода, имеют одно имя и никогда этого не замечают. Назовите их по-разному (`MCPServer(f"billing-{POD}")` выглядит как хорошая гигиена наблюдаемости) — и каждый повтор между экземплярами отклоняется ровно как выше, с общим ключом или без. В логе вместо `unknown key` будет `audience`; клиент разницы не увидит.
Выпустите секрет один раз и передайте одно и то же значение каждому экземпляру. Это та самая команда, которую собственное сообщение об ошибке SDK предлагает запустить, если передать ему меньше 32 байт:
```console
python -c "import secrets; print(secrets.token_hex(32))"
```
!!! warning "Одни ключи *и* одно имя"
Развёртывание с несколькими экземплярами должно разделять и то и другое. Если имена по
экземплярам для вас важны, дайте всему парку один явный audience:
`RequestStateSecurity(keys=[...], audience="billing")`. Тогда каждый экземпляр выпускает и
принимает токены под `"billing"`, как бы он ни назывался.
Всё остальное о запечатывании — в разделе **[Защита `requestState`](../handlers/multi-round-trip.md#protecting-requeststate)**: что оно связывает, `ttl` на раунд (600 секунд по умолчанию), собственный кодек, почему ненастроенное значение по умолчанию ровно подходит для `stdio`. Весь вклад этой страницы — чек-лист из двух пунктов: *одни ключи, одно имя.*
!!! info
Вы на этом пути, даже если никогда не писали `InputRequiredResult`. Инструмент, чьи параметры
используют `Resolve(...)` (**[Зависимости](../handlers/dependencies.md)**), — многораундовый,
и SDK выпускает и запечатывает его `request_state` за него. Тот же ключ по умолчанию, тот же
сбой между рабочими процессами, то же решение.
## Уведомления об изменениях между репликами {#change-notifications-across-replicas}
Поток `subscriptions/listen` клиента — это один долгоживущий ответ, поэтому он привязан к одной реплике на всю свою жизнь. `ctx.notify_resource_updated(...)`, опубликованное на **другой** реплике, должно до него дойти.
Шов между ними — `SubscriptionBus`. Какую шину вы дадите серверу, в ту и идёт каждая публикация и ту слушает каждый открытый поток, так что передайте одну и ту же шину каждой реплике:
```python title="server.py" hl_lines="2 7 9"
--8<-- "docs_src/deploy/tutorial004.py"
```
Рассылке совершенно всё равно, к какому объекту сервера прикреплён поток. Два сервера с одним `InMemorySubscriptionBus` уже ведут себя так: откройте поток listen на одном, вызовите `edit_note` на другом — и поток об этом услышит. Эта шина в памяти охватывает только объекты серверов внутри одного процесса, так что это модель, а не развёртывание:
* Между настоящими процессами **в SDK нет шины, которая могла бы помочь.** `SubscriptionBus` — это `Protocol` из двух методов (`publish` и `subscribe`), который вы реализуете поверх собственного pub/sub-бэкенда (Redis, NATS, что угодно, что у вас уже работает) и передаёте как `MCPServer(subscriptions=...)`. Набросок и контракт — на странице **[Подписки](../handlers/subscriptions.md#scaling-past-one-process)**.
* Шина переносит четыре небольших типизированных события и никогда — JSON-RPC. Подтверждение, фильтрация и жизненный цикл потоков остаются в SDK, поэтому ваша шина не может сломать протокол; она может только перемещать события между процессами.
* Потоки **не** возобновляемы, и события **не** воспроизводятся повторно. Потеря реплики обрывает её потоки; клиенты заново подписываются и заново запрашивают данные. Нет хранилища событий, которое нужно разделять, и больше нечего настраивать. Это единственное место, где горизонтальное масштабирование — действительно просто больше того же самого.
## Чего SDK не даёт {#what-the-sdk-does-not-give-you}
`MCPServer` — это реализация протокола, а не сервер приложений. Ручки развёртывания, которые вы пойдёте искать следующими, отсутствуют намеренно:
* **Нет `workers=`.** `mcp.run("streamable-http")` запускает ровно один процесс uvicorn, и больше он ничего не запустит никогда. Многопроцессность — это `streamable_http_app()`, переданное тому, чем вы уже развёртываете ASGI: `uvicorn --workers`, gunicorn, менеджер процессов вашей платформы. Эта страница намеренно не учебник ни по одному из них; их документация лучше, чем была бы её копия здесь.
* **Нет маршрута проверки работоспособности.** `@mcp.custom_route("/health", methods=["GET"])` — вот и весь ответ, и он никогда не требует аутентификации, даже когда остальной сервер требует. Для liveness-пробы это правильно, для чего угодно приватного — нет. Пример есть на странице **[Добавление в существующее приложение](asgi.md#custom-routes)**.
* **Нет объекта настроек для продакшена.** В `MCPServer` негде записать таймауты, TLS, плавное завершение или лимиты соединений, потому что ничто из этого не его работа. Всё это принадлежит вашему ASGI-серверу, там и настраивается. Те немногие настройки, что конструктор *всё-таки* принимает, описаны на странице **[Запуск сервера](index.md)**.
* **Нет поставляемого `EventStore`, а на 2026-07-28 он и не нужен.** Возобновляемость — возможность ветки старого поколения с состоянием; современный обмен — это один POST, один ответ, и возобновлять нечего.
## Итоги {#recap}
* По умолчанию приложение отвечает только на запросы, адресованные localhost. `transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])` — это ворота в продакшен: пока вы его не передадите, каждый запрос за настоящим доменным именем получает `421`, а причина есть только в логе сервера.
* На 2026-07-28 нет сессии, и балансировщику не к чему привязываться. `stateless_http=True` — ручка только для старого поколения, потому что современный запрос маршрутизируется и получает ответ раньше, чем этот флаг вообще читается.
* Ключ `requestState` по умолчанию — `os.urandom(32)`, выпускаемый в каждом процессе. Многораундовый повтор, попавший к другому рабочему процессу, падает с `-32602` *«Invalid or expired requestState»*.
* Решение — `RequestStateSecurity(keys=[...])` **и** одно и то же имя сервера на каждом экземпляре. Имя — это audience claim токена по умолчанию. Одни ключи, одно имя.
* Уведомления об изменениях пересекают реплики через одну общую `SubscriptionBus`. Единственная реализация в SDK — внутрипроцессная; `Protocol` из двух методов поверх собственного pub/sub предстоит написать вам.
* Нет `workers=`, нет маршрута работоспособности, нет объекта настроек для продакшена. ASGI-сервер — ваш.
Второе, что нужно настоящему доменному имени перед собой, — это токен: **[Авторизация](authorization.md)**.

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

@ -0,0 +1,156 @@
---
translation:
sections: [fea8d769ff9edeba, ce8e2ad42f29ef71, 0d705efb19cf99c2, 7a53ead3e704a7f0, 9adc400e8c88e854, 318893ad8e2e9924, 6b63ab96b34476c0]
tool: 1
---
# Запуск сервера {#running-your-server}
`mcp.run()` запускает сервер.
Единственное решение, которое нужно принять, — это **транспорт**: как именно байты перемещаются между сервером и его клиентом.
## Выбор транспорта {#pick-a-transport}
| Транспорт | Что это | Когда |
|---|---|---|
| `stdio` | Хост запускает ваш файл как подпроцесс и общается с ним через его stdin и stdout. | Локальные серверы. Вариант по умолчанию. |
| `streamable-http` | Настоящий HTTP-сервер, слушающий порт. | Всё, что вы развёртываете. |
| `sse` | Старый HTTP-транспорт. | Никогда. |
!!! warning
В редакции протокола 2025-03-26 на смену SSE пришёл Streamable HTTP.
`mcp.run(transport="sse")` по-прежнему работает, со своими параметрами `sse_path=` и `message_path=`,
но существует лишь ради клиентов, которые ещё не перешли. Ничего нового на нём не стройте.
## `mcp.run()` {#mcprun}
```python title="server.py" hl_lines="12-13"
--8<-- "docs_src/run/tutorial001.py"
```
* `run()` синхронный. Он блокирует выполнение на всё время жизни сервера.
* Без аргументов транспорт — `stdio`.
* Вызов стоит под `if __name__ == "__main__":`, потому что всё, что загружает ваш сервер (`mcp dev`, `mcp run`, `mcp install`, тесты), **импортирует** этот файл. Эта проверка не даёт импорту превратиться в работающий сервер.
### stdio {#stdio}
Настраивать нечего. Хост запускает ваш файл как дочерний процесс, пишет запросы в его stdin и читает ответы из его stdout.
Запустите его сами — и увидите следствие:
```console
python server.py
```
Ничего не выводится, и управление не возвращается. Процесс ждёт на stdin, пока хост не заговорит первым.
Это также означает, что stdout **и есть канал связи**. Во время обслуживания SDK переносит этот канал на приватный дескриптор, а вывод, который *сбрасывается* (flush) в stdout (подпроцесс, пишущий в унаследованный stdout, `print()` со сбросом буфера), перенаправляет в stderr, где он не может повредить поток. Вывод, сброшенный в stdout *до* начала обслуживания (echo в скрипте-обёртке, небуферизованный print при импорте), всё равно попадает в канал связи — как и `print()`, который остаётся в буфере, пока интерпретатор не сбросит его при выходе. Для вывода, который вам действительно нужен, правильный инструмент — модуль `logging`: его обработчик сбрасывает каждую запись в stderr сразу по мере появления. Подробнее — на странице **[Логирование](../handlers/logging.md)**.
### Попробуйте сами {#try-it}
```console
uv run mcp dev server.py
```
Inspector делает ровно то же, что и настоящий хост: запускает `server.py` как подпроцесс и подключается к нему через stdio.
Порт вы ему не указывали. Его и нет.
## Streamable HTTP {#streamable-http}
Чтобы вместо этого посадить тот же сервер на порт, укажите транспорт (и его параметры) в `run()`:
```python title="server.py" hl_lines="13"
--8<-- "docs_src/run/tutorial002.py"
```
Эта единственная строка собирает приложение Starlette и обслуживает его через uvicorn. Клиенты подключаются к `http://127.0.0.1:3001/mcp`.
У каждого транспорта свои именованные аргументы, и все они передаются в `run()`:
* `host` / `port`: где слушать. По умолчанию `127.0.0.1` и `8000`.
* `streamable_http_path`: где находится конечная точка MCP. По умолчанию `/mcp`.
* `json_response=True`: отвечать на каждый POST одним JSON-телом вместо SSE-потока. В этом теле есть место только для ответа и ничего больше, поэтому инструмент, который обращается к клиенту посреди запроса (`ctx.elicit()`, сэмплирование (sampling)), на этом участке выбрасывает `NoBackChannelError`, а уведомления, привязанные к выполняющемуся вызову (ход выполнения от `ctx.report_progress()`, лог-сообщения отдельного вызова), отбрасываются; отдельный поток `GET` по-прежнему доставляет не связанные с вызовом уведомления.
* `stateless_http=True`: свежий транспорт на каждый запрос, без отслеживания сессий.
* `max_request_body_size`: максимальный принимаемый размер тела POST в байтах. По умолчанию 4 МиБ; более крупные запросы
получают HTTP 413 ещё до разбора и создания сессии. Увеличивайте его, только если легитимные MCP-сообщения
превышают этот размер.
* `event_store`, `retry_interval`, `transport_security`: возобновляемость и защита от DNS-rebinding. Они могут подождать, пока вы не развернётесь где-то кроме localhost; `transport_security` разобран на странице **[Развёртывание и масштабирование](deploy.md)**.
!!! warning
Параметры транспорта передаются в `run()`, а **не** в `MCPServer(...)`. Конструктор описывает, что
ваш сервер собой *представляет*: имя, версию, инструкции. `run()` описывает, как он обслуживается. Перепутайте —
и Python ответит раньше, чем дело вообще дойдёт до MCP:
```text
TypeError: MCPServer.__init__() got an unexpected keyword argument 'port'
```
`run()` — короткий путь. Как только нужно больше (сервер, смонтированный внутри существующего приложения, два сервера в одном процессе, CORS для браузерных клиентов), вы собираете ASGI-приложение сами и отдаёте его любому ASGI-хосту. Об этом — **[Добавление в существующее приложение](asgi.md)**.
## Настройки сервера {#server-settings}
Пара вещей, связанных с запуском, к транспорту не относится. Это аргументы конструктора:
```python title="server.py" hl_lines="3"
--8<-- "docs_src/run/tutorial003.py"
```
* `log_level`: передаётся в `logging.basicConfig()` в момент создания `MCPServer(...)`. Это настраивает **корневой** логгер, так что уровень задаётся и для ваших собственных логгеров, а не только для логгеров SDK. По умолчанию `"INFO"`.
* `debug`: пробрасывается в приложение Starlette, которое собирают HTTP-транспорты. По умолчанию `False`.
Оба попадают в `mcp.settings`, откуда их можно прочитать во время выполнения.
## Команда `mcp` {#the-mcp-command}
Дополнение `[cli]` устанавливает небольшую утилиту командной строки поверх всего этого.
`mcp dev` запускает ваш сервер под **MCP Inspector**:
```console
uv run mcp dev server.py
uv run mcp dev server.py --with pandas --with numpy
uv run mcp dev server.py --with-editable .
```
`--with` добавляет пакеты в создаваемое окружение; `--with-editable` устанавливает в него ваш собственный пакет. Нужен `npx` в `PATH`: Inspector — это приложение на Node.js.
`mcp run` импортирует файл, находит объект сервера (`mcp`, `server` или `app` на уровне модуля) и вызывает у него `run()`:
```console
uv run mcp run server.py
uv run mcp run server.py:bookshop
```
Суффикс после `:` указывает имя объекта, если он называется не `mcp`, `server` и не `app`.
Блок `if __name__ == "__main__":` здесь никогда не выполняется: `mcp run` вызывает `run()` сам, и единственный параметр, который он пробрасывает, — `--transport`.
`mcp install` регистрирует сервер в **Claude Desktop**, чтобы приложение запускало его за вас:
```console
uv run mcp install server.py --name "Bookshop"
uv run mcp install server.py -v API_KEY=abc123 -f .env
```
`-v KEY=VALUE` и `-f .env` записывают в эту запись переменные окружения. Claude Desktop запускает ваш сервер в отдельном процессе. Окружения вашей оболочки там нет.
Claude Desktop — единственный хост, который знает `mcp install`. Все остальные хосты (Claude Code, Cursor, VS Code) принимают ту же команду запуска в собственном конфигурационном файле, и каждый из них описан на странице **[Подключение к настоящему хосту](../get-started/real-host.md)**.
`mcp version` выводит версию установленного SDK.
!!! tip
`mcp dev` и `mcp run` понимают только `MCPServer`. Если вы строите сервер на низкоуровневом `Server`,
запускать его придётся самим. См. **[Низкоуровневый Server](../advanced/low-level-server.md)**.
## Итоги {#recap}
* **Транспорт** — это то, как байты добираются до сервера: `stdio` для локального подпроцесса, `streamable-http` для порта. SSE вытеснен.
* Транспорт выбирает `mcp.run()`. Без аргументов это `stdio`, и вызов блокирует выполнение.
* Любой параметр транспорта (`host`, `port`, `streamable_http_path`, ...) — это аргумент `run()` и никогда не `MCPServer(...)`.
* Держите `run()` под `if __name__ == "__main__":`. Всё, что загружает ваш сервер, сначала импортирует файл.
* `log_level=` и `debug=` — аргументы конструктора; они попадают в `mcp.settings`.
* `mcp dev` — для Inspector, `mcp run` — чтобы выполнить файл, `mcp install` — для Claude Desktop, `mcp version` — узнать версию.
* Транспорт никогда не меняет того, что ваш сервер собой *представляет*: все три файла на этой странице предоставляют один и тот же инструмент.
Когда ограничением становится сам `run()` (сервер внутри уже существующего приложения), нужна страница **[Добавление в существующее приложение](asgi.md)**. Настоящее имя хоста и больше одного воркера — это **[Развёртывание и масштабирование](deploy.md)**. А если часть ваших клиентов всё ещё на версии спецификации 2025-11-25 или более ранней, хорошие новости ждут на странице **[Обслуживание клиентов старого поколения](legacy-clients.md)**.

View file

@ -0,0 +1,134 @@
---
translation:
sections: [3d1663c18edc824c, d4fd37009a13f03d, af9f398a5a8b679a, 470c2dd144294d69, 8e45827e6d24e8c8, 91dfd0ce98ebb03c]
tool: 1
---
# Обслуживание клиентов старого поколения {#serving-legacy-clients}
У MCP два поколения протокола: поколение рукопожатия `initialize` — до версии спецификации `2025-11-25` включительно — и современное поколение, `2026-07-28`. Самому этому разделению посвящена страница **[Версии протокола](../protocol-versions.md)**.
Эта страница — о серверной стороне этого разделения, и ответ умещается в одно предложение: **приложение `streamable_http_app()`, которое вы уже развёртываете, обслуживает оба поколения.**
SDK маршрутизирует каждый запрос по его заголовку `MCP-Protocol-Version`. Запрос, в котором указана `2026-07-28`, попадает в современный обработчик. Запрос с версией поколения рукопожатия или вовсе без заголовка (именно так приходит `initialize` от клиента до 2026 года) уходит в транспорт, которого ждут такие клиенты: рукопожатие `initialize`, сессии и всё остальное. Это происходит для каждого запроса отдельно, до вашего кода, в одном и том же приложении.
Так что клиент старого поколения — не то, *ради* чего вы что-то пишете. Это то, что само подключается *к* уже написанному серверу. Настраивать ничего не нужно.
!!! note
Буквально ничего. Нет параметра `legacy=`, нет списка разрешённых версий, нет способа
отклонить или отключить поколение: ни в `streamable_http_app()`, ни в `run()`, ни в менеджере
сессий. Оба поколения включены всегда. Ближе всего к переключателю поколений в этой сигнатуре
параметр `stateless_http` — ему и посвящена бо́льшая часть страницы.
## Один обработчик, оба поколения {#one-handler-both-eras}
Вот инструмент, которому нужно кое-что спросить у пользователя, и клиенты обоих поколений, которые его вызывают:
```python title="server.py" hl_lines="24 37-38"
--8<-- "docs_src/legacy_clients/tutorial001.py"
```
Инструменту `reserve` нужно одно, чего модель не сообщила: сколько экземпляров. `Annotated[..., Resolve(ask_quantity)]` — так инструмент это объявляет (подробнее — на странице **[Зависимости](../handlers/dependencies.md)**). Ничто в `reserve` не называет версию, не проверяет возможность и не ветвится.
Оба клиента открыты **одновременно**, на одном и том же объекте `mcp`. `mode="legacy"` выполняет рукопожатие `initialize` — ровно такое подключение открывает клиент до 2026 года. Второй клиент берёт значение по умолчанию и оказывается на `2026-07-28`.
```text
2025-11-25 {'result': "Reserved 2 of 'Dune'."}
2026-07-28 {'result': "Reserved 2 of 'Dune'."}
```
Тот же сервер, тот же обработчик, тот же ответ. Вот и весь механизм.
Стоит задержаться на том, *как* это работает, потому что один и тот же вопрос двум клиентам задали по двум совершенно разным каналам. У подключения `2026-07-28` нет канала, по которому сервер мог бы отправить запрос, поэтому `Resolve` вернул вопрос внутри результата инструмента, а клиент повторил вызов уже с ответом (**[Многораундовые запросы (multi-round-trip)](../handlers/multi-round-trip.md)**). У подключения `2025-11-25` ничего подобного нет; там `Resolve` отправил настоящий запрос `elicitation/create` прямо посреди вызова и дождался ответа. Ни того ни другого вы не писали. `Resolve` читает согласованную версию подключения и выбирает сам; тело инструмента в обоих случаях получает `AcceptedElicitation`.
!!! tip
Именно эта переносимость между поколениями — причина, *почему* строить стоит на `Resolve`.
Его старший родственник `ctx.elicit()` (**[Элицитация (elicitation)](../handlers/elicitation.md)**)
умеет отправлять только `elicitation/create`, так что работает только на подключении старого
поколения. На подключении `2026-07-28` вызов завершается ошибкой. Если какой-то инструмент всё
ещё им пользуется, исправление — то, что показано выше, а не проверка версии.
## Во что обходится сессия старого поколения {#what-a-legacy-session-costs-you}
Маршрутизация бесплатна. Сессия — нет.
Подключение `2026-07-28` **бессессионное**: каждый запрос самостоятелен, и современный обработчик никогда не выдаёт `Mcp-Session-Id`. Подключение старого поколения — полная противоположность. Как только клиент до 2026 года отправляет `initialize`, SDK создаёт `Mcp-Session-Id`, возвращает его в заголовке ответа и хранит за ним живую запись, которую будут находить последующие запросы клиента: согласованная версия, открытые потоки, фоновая задача, ведущая сессию.
Эта запись — **обычный `dict` внутри процесса**. Распределённого хранилища сессий нет, и подключить своё невозможно.
На одном рабочем процессе это незаметно. На двух — в этом вся проблема: запрос с `Mcp-Session-Id`, попавший на рабочий процесс, который этот идентификатор не создавал, ничего в словаре не находит, и в ответ приходит `404` (`Session not found`), а не результат инструмента. Поэтому, как только рабочих процессов больше одного, **клиентам старого поколения нужна липкая маршрутизация (sticky routing)**: каждый запрос сессии должен попадать в тот процесс, который её начал. Современным клиентам это не нужно никогда: у них нет сессии, к которой можно было бы привязаться. О привязке и обо всём остальном, что касается запуска нескольких экземпляров, — на странице **[Развёртывание и масштабирование](deploy.md)**.
!!! warning
`event_store=` выглядит как решение, но это не оно. Это **возобновляемость** (повторная
отправка пропущенных SSE-событий клиенту, который переподключается к *той же* сессии), а не
хранилище сессий. Сессию доступной из другого процесса он не делает никогда.
## Единственный переключатель: `stateless_http` {#the-one-knob-stateless_http}
Если привязка — цена, которую вы платить не готовы, изменить можно ровно одно.
```python title="server.py" hl_lines="28"
--8<-- "docs_src/legacy_clients/tutorial002.py"
```
Это сервер из начала страницы плюс один именованный аргумент. С `stateless_http=True` ветка старого поколения вместо этого создаёт одноразовую сессию на каждый запрос: `Mcp-Session-Id` не выдаётся, между запросами ничего не запоминается, так что любой рабочий процесс может обслужить любой запрос, а балансировщик нагрузки волен делать что угодно.
Две вещи о нём важнее того, что он делает.
**Он затрагивает только ветку старого поколения.** Запросы маршрутизируются по заголовку версии *до* того, как читается `stateless_http`, так что современный путь его не видит вовсе. Подключение `2026-07-28` и так бессессионное и ведёт себя совершенно одинаково при любом значении.
**Он стоит обоих каналов от сервера к клиенту на этой ветке.** У сессии, живущей один `POST`, нет потока, по которому сервер мог бы отправить запрос, и нет отдельного потока, по которому он мог бы отправлять уведомления. Каждый запрос по инициативе сервера выбрасывает `NoBackChannelError`: `ctx.elicit()`, отправленные на покой вызовы сэмплирования (sampling) и корневых каталогов (roots) (**[Устаревшие возможности](../deprecated.md)**) и — да — `Resolve`, задающий свой вопрос клиенту *старого поколения*. Уведомления не получают даже ошибки: они молча отбрасываются.
!!! note
`json_response=True` — не тот переключатель, но половину той же цены он берёт с *каждой*
сессии старого поколения: у `POST`, на который отвечают одним JSON-телом, нет потока для
канала, привязанного к запросу, поэтому `ctx.elicit()` посреди запроса выбрасывает ту же
`NoBackChannelError`, а уведомления, связанные с запросом, отбрасываются. Отдельный поток
сессии не затронут: не связанные с запросом уведомления по-прежнему приходят.
!!! check
Сделайте заведомо неправильно. `reserve` — тот самый инструмент, который только что обслужил
оба клиента. Разверните его с `stateless_http=True`, подключите те же два клиента по HTTP и
вызовите его из каждого.
Современный клиент по-прежнему получает `Reserved 2 of 'Dune'.` Современная ветка не изменилась.
Вызов клиента старого поколения не возвращается результатом с `is_error`, который модель
могла бы прочитать. Падает весь запрос — ошибкой протокола верхнего уровня:
```text
mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests.
```
`Resolve` вас не спас. На подключении `2025-11-25` он *обязан* отправить `elicitation/create`,
а нужный ему канал — ровно то, что отдал `stateless_http=True`. Код, переносимый между
поколениями, — это не код без обратного канала (back-channel).
Так что это настоящий компромисс, и существует он только на ветке старого поколения: **с сессиями и привязкой — или без состояния и в одну сторону.** Если ваши инструменты никогда не обращаются обратно к клиенту, `stateless_http=True` ничего не стоит, и его стоит включить. Если обращаются — оставьте сессии и сохраните липкую маршрутизацию.
## Где код действительно ветвится {#where-your-code-actually-forks}
Почти нигде.
Инструменты, ресурсы, промпты, структурированный вывод, прогресс, ошибки — никому из них нет дела до того, какое поколение вызвало. Рукопожатие `initialize`, `Mcp-Session-Id`, отдельный поток, `DELETE`, завершающий сессию, — всем этим владеет SDK, и обработчик ничего из этого не видит. Интерактивный ввод — *то самое* место, где поколения по-настоящему расходятся в передаваемых данных, и `Resolve` существует именно для того, чтобы это было не вашей заботой: вы только что видели, как один инструмент обслужил оба.
Остаётся ровно одно — **уведомления об изменениях**, потому что два поколения слушают разные каналы:
* Клиент `2026-07-28` открывает поток `subscriptions/listen` и читает шину подписок. `ctx.notify_resource_updated()` (а также `notify_tools_changed()`, `notify_prompts_changed()`, `notify_resources_changed()`) публикуют туда, и *только* туда. Подробнее — на странице **[Подписки](../handlers/subscriptions.md)**.
* Клиент старого поколения читает отдельный поток, который держит открытым его сессия. `ctx.session.send_resource_updated()` (а также `send_tool_list_changed()` и остальные) пишут в то *подключение*, по которому пришёл запрос: для сессии старого поколения это её отдельный поток. У современного подключения места для этого нет: по HTTP такого канала не существует, а по stdio четыре вида уведомлений об изменениях ходят только по потокам `subscriptions/listen`, так что на современном подключении уведомление молча отбрасывается.
По HTTP ни один из вызовов не доходит до клиентов другого поколения. Чтобы известить всех, вызывайте оба:
```python title="server.py" hl_lines="19-20"
--8<-- "docs_src/legacy_clients/tutorial003.py"
```
Две строки, никакого `if`, никакой проверки версии — и готово. Это полный список того, что обработчик делает иначе из-за существования клиентов старого поколения.
## Итоги {#recap}
* Одно приложение `streamable_http_app()` обслуживает оба поколения протокола. SDK маршрутизирует каждый запрос по заголовку `MCP-Protocol-Version`; настраивать нечего, и переключателя поколений искать не нужно.
* Клиент старого поколения обходится вам в сессию: запись `Mcp-Session-Id` внутри процесса без распределённого хранилища за ней. Больше одного рабочего процесса — значит **липкая маршрутизация**, иначе не тот процесс ответит `404 Session not found`. Подробнее о нескольких рабочих процессах — на странице **[Развёртывание и масштабирование](deploy.md)**.
* `stateless_http=True` — единственный переключатель, и действует он **только на ветку старого поколения**. Он даёт клиентам старого поколения свободную балансировку нагрузки ценой обоих каналов от сервера к клиенту на этой ветке: запросы по инициативе сервера выбрасывают `NoBackChannelError` (на клиенте — ошибка верхнего уровня, а не результат с `is_error`), а уведомления отбрасываются.
* Подключение `2026-07-28` бессессионное в любом случае. `stateless_http` его никогда не затрагивает.
* Код обработчика ветвится по поколению ровно в одном месте: уведомления об изменениях. `ctx.notify_*` доходит до клиентов `subscriptions/listen`; `ctx.session.send_*` — до сессий старого поколения. Вызывайте оба.
* Всё остальное (включая запрос ввода у пользователя через `Resolve`) переносимо между поколениями по построению. Напишите современный вариант один раз.

View file

@ -0,0 +1,85 @@
---
translation:
sections: [bc0227014724fa49, 15738c2f7fd67d86, a2c17bbe3f707e2f, d0d853376f162c06, b6368643fcc1c8d8, 902e33e17564a607]
tool: 1
---
# OpenTelemetry {#opentelemetry}
Ваш сервер уже трассируется. Добавлять ничего не нужно.
Каждый созданный вами сервер порождает спан [OpenTelemetry](https://opentelemetry.io/) для каждого обработанного сообщения. Вы этого не писали и не импортируете. Это появляется в тот момент, когда вы вызываете `MCPServer(...)`.
```python title="server.py"
--8<-- "docs_src/opentelemetry/tutorial001.py"
```
Это уже готовый сервер с трассировкой. Вызовите `search_books` — и для него будет создан спан. То же самое верно для низкоуровневого `Server`: трассировка есть в обоих.
## Что вы получаете {#what-you-get}
Каждое входящее сообщение становится спаном `SERVER`, названным по методу и его цели. Так, `tools/call` для `search_books` даёт спан `tools/call search_books`, а просто `tools/list` — спан `tools/list`.
Каждый спан несёт несколько атрибутов:
* `mcp.method.name` и `mcp.protocol.version` — на каждом спане.
* `jsonrpc.request.id` — на запросе (у уведомления его нет).
* Обработчик, выбросивший исключение, переводит статус спана в ошибку. То же делает результат инструмента с `is_error=True`.
А поскольку трассировать вызов инструмента хочется особенно часто, спаны `tools/call` следуют [семантическим соглашениям GenAI](https://opentelemetry.io/docs/specs/semconv/gen-ai/) из OpenTelemetry:
* `gen_ai.operation.name` со значением `"execute_tool"`.
* `gen_ai.tool.name` с именем вызываемого инструмента.
Спан `prompts/get` в том же духе получает `gen_ai.prompt.name`. Методы списков не несут ключей `gen_ai.*`, потому что называть там нечего.
!!! tip
Именно благодаря этим атрибутам GenAI интерфейс трассировки группирует ваши вызовы инструментов так же, как вызовы любого другого агента. Эта группировка достаётся даром, без дополнительного кода.
## Это ничего не стоит, пока вам не понадобится {#it-costs-nothing-until-you-want-it}
Вот что делает «включено по умолчанию» комфортным вариантом по умолчанию.
SDK зависит только от `opentelemetry-api` — лёгкой половины OpenTelemetry. Пока не установлены ни SDK OpenTelemetry, ни экспортёр, создание спана ничего не делает. Так что спаны, которые ваш сервер порождает прямо сейчас, почти ничего не стоят, и никто их не собирает.
В тот день, когда захочется их *увидеть*, установите вторую половину и направьте её куда-нибудь:
```console
uv add opentelemetry-sdk opentelemetry-exporter-otlp
```
Настройте экспортёр обычным для OpenTelemetry способом — и все спаны, которые SDK тихо создавал, станут видны. Код сервера не меняется. Ни одной строки.
!!! info
[Pydantic Logfire](https://logfire.pydantic.dev/) — один из таких бэкендов, и он берёт настройку на себя: `pip install logfire`, `logfire.configure()` — и ваши MCP-спаны появляются в живом просмотре. Он построен на OpenTelemetry, поэтому всё сказанное ниже относится и к нему.
## Трассы, пересекающие сеть {#traces-that-cross-the-wire}
Трасса полезнее всего, когда она сопровождает запрос от клиента до сервера в одной связной картине.
Когда и клиент, и сервер работают на SDK, эта связь возникает автоматически. Клиент внедряет в запрос [контекст трассировки W3C](https://www.w3.org/TR/trace-context/), а сервер считывает его обратно, так что серверный спан вкладывается в клиентский в рамках одной трассы. Это [SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414), и вы получаете его, ничего не запрашивая.
Если входящее сообщение не несёт контекста трассировки — например, запрос от клиента, который не является SDK, — серверный спан просто становится дочерним к тому спану, который уже текущий на сервере, а не начинает совершенно новую трассу-сироту.
## Как отключить {#turning-it-off}
Трассировка — это middleware, первый в списке вашего сервера. Если действительно нужен сервер, который не порождает спанов, уберите его:
```python
from mcp.server._otel import OpenTelemetryMiddleware
mcp._lowlevel_server.middleware[:] = [
m for m in mcp._lowlevel_server.middleware if not isinstance(m, OpenTelemetryMiddleware)
]
```
!!! warning
В этом импорте есть ведущее подчёркивание, и это намеренно. Класс предварительный, так же как предварителен [`Server.middleware`](../advanced/middleware.md), поэтому будьте готовы к тому, что путь импорта изменится. Это почти никогда не нужно: без установленного экспортёра спаны бесплатны, так что обычный ответ — оставить их включёнными и не устанавливать экспортёр.
## Итоги {#recap}
* Каждый `MCPServer` и каждый низкоуровневый `Server` по умолчанию порождает один спан `SERVER` на каждое входящее сообщение. Вы ничего не пишете.
* Спаны несут `mcp.method.name` и `mcp.protocol.version`; `tools/call` и `prompts/get` дополнительно несут атрибуты GenAI, так что ваши вызовы инструментов группируются как у любого другого агента.
* Это ничего не стоит, пока вы не установите SDK OpenTelemetry и экспортёр, — а затем всё становится видно без изменений в сервере.
* Контекст трассировки от клиента к серверу распространяется автоматически, когда обе стороны работают на SDK.
Решает, будет ли запрос выполнен вообще, **[Авторизация](authorization.md)**.