docs: publish translated docs in twelve languages and the tool that maintains them (#3280)
This commit is contained in:
parent
14aa889992
commit
52ad0a8876
6 changed files with 89737 additions and 54 deletions
148
i18n/ru/pages/run/asgi.md
Normal file
148
i18n/ru/pages/run/asgi.md
Normal 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 вместо объекта сервера.
|
||||
130
i18n/ru/pages/run/authorization.md
Normal file
130
i18n/ru/pages/run/authorization.md
Normal 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
180
i18n/ru/pages/run/deploy.md
Normal 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
156
i18n/ru/pages/run/index.md
Normal 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)**.
|
||||
134
i18n/ru/pages/run/legacy-clients.md
Normal file
134
i18n/ru/pages/run/legacy-clients.md
Normal 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`) переносимо между поколениями по построению. Напишите современный вариант один раз.
|
||||
85
i18n/ru/pages/run/opentelemetry.md
Normal file
85
i18n/ru/pages/run/opentelemetry.md
Normal 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)**.
|
||||
Loading…
Reference in a new issue