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

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

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

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

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 ваш сервер — це **сервер ресурсів** (resource server). Він ніколи нікого не автентифікує й ніколи не видає токенів. Він робить одне: дивиться на заголовок `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-шлях — і отримаєте **[RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) Protected Resource Metadata**, побудовані безпосередньо з вашого `AuthSettings`:
```json
{
"resource": "http://127.0.0.1:8000/mcp",
"authorization_servers": ["https://auth.example.com/"],
"scopes_supported": ["notes:read"],
"bearer_methods_supported": ["header"]
}
```
Саме завдяки цьому документу клієнт, який ніколи не чув про ваш сервер, знаходить дорогу: він читає `authorization_servers` і йде туди по токен. Ви не написали з нього жодного рядка.
!!! check
Зверніться до `/mcp` без токена (або з таким, для якого ваш верифікатор повернув `None`) — і запит
зупинять на порозі:
```text
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="Authentication required", resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp"
{"error": "invalid_token", "error_description": "Authentication required"}
```
Нічого не було розібрано, і жоден інструмент не виконався. А вказівник `resource_metadata` у `WWW-Authenticate` —
саме те, що робить виявлення автоматичним: 401 -> документ метаданих -> сервер авторизації -> токен -> повторна спроба.
!!! warning
Ніщо з цього не захищає `stdio`. Канал не має заголовка `Authorization`, тож `token_verifier` там ніколи
не викликається. Межа безпеки `stdio`-сервера — це процес, який його запустив. Те саме
стосується `Client(mcp)` у пам'яті, який ви використовуєте в тестах: він під'єднується безпосередньо до об'єкта сервера
й оминає HTTP-рівень, разом з авторизацією.
## Ідентичність того, хто викликає {#the-callers-identity}
Усередині будь-якого обробника **`get_access_token()`** — це `AccessToken`, який ваш верифікатор повернув для поточного запиту:
```python title="server.py" hl_lines="4 32-35"
--8<-- "docs_src/authorization/tutorial002.py"
```
* Це працює в інструментах, ресурсах і промптах, і нічого нікуди передавати не треба: middleware авторизації зберігає його в контекстній змінній для кожного запиту.
* Повертається **той самий об'єкт, який побудував ваш верифікатор**: `client_id`, `scopes`, `subject`, `expires_at` і будь-які додаткові `claims`, які ви додали. Це й є зачіпка для правил на рівні окремих інструментів: прочитайте scopes і відмовте.
* Поза автентифікованим HTTP-запитом він повертає `None`. У пам'яті й через `stdio` це завжди `None`.
Викличте `whoami` з `Authorization: Bearer alice-token` — і модель прочитає:
```text
alice (scopes: notes:read)
```
## Половина, якої SDK не робить {#the-half-the-sdk-doesnt-do}
SDK дає вам половину сервера ресурсів: перевірити, оголосити, відмовити. Він не дає сторінки входу, екрана згоди чи токена.
Щоб побачити всі три сторони в русі, запустіть `examples/servers/simple-auth/` з репозиторію SDK (невеликий сервер авторизації та сервер ресурсів, налаштований точно як на цій сторінці), а потім спрямуйте на нього `examples/clients/simple-auth-client/`, щоб пройти повний шлях виявлення й отримання токена.
!!! info
Є другий аргумент конструктора, `auth_server_provider=`, який вбудовує повноцінний сервер
авторизації всередину вашого MCP-сервера. Він з'явився ще до розділення AS/RS, навколо якого
побудовано специфікацію авторизації MCP. Новим серверам не слід до нього вдаватися.
Сервер авторизації також може прийняти підписане твердження корпоративного постачальника ідентичності замість того, щоб користувач проходив екран згоди, і SDK підтримує обидва боки цього обміну. Про цей grant і клієнта, що його пред'являє, — на сторінці **[Твердження ідентичності](../client/identity-assertion.md)**.
## Підсумки {#recap}
* Через Streamable HTTP ваш сервер — це **сервер ресурсів** OAuth 2.1: він перевіряє токени й ніколи їх не видає.
* `TokenVerifier` — це вся поверхня інтеграції: один асинхронний метод, на вході токен, на виході `AccessToken | None`.
* `token_verifier=` і `auth=AuthSettings(issuer_url=..., resource_server_url=..., required_scopes=[...])` завжди йдуть разом.
* SDK публікує [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) Protected Resource Metadata за адресою `/.well-known/oauth-protected-resource/...` і відповідає на неавтентифіковані запити кодом 401, чий заголовок `WWW-Authenticate` вказує на них. Оце й уся історія виявлення.
* `get_access_token()` у будь-якому обробнику — це той, хто викликає.
* Авторизація — справа HTTP. `stdio` та клієнт у пам'яті ніколи її не бачать.
Клієнтська половина (виявлення вашого сервера авторизації й отримання токена за вас) — на сторінці **[Клієнти OAuth](../client/oauth-clients.md)**. А клієнт, який *стверджує* ідентичність замість того, щоб запитувати її в користувача, — на сторінці **[Твердження ідентичності](../client/identity-assertion.md)**.

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

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

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

View file

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