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
10 changed files with 89737 additions and 54 deletions

View file

@ -0,0 +1,134 @@
---
translation:
sections: [b50152f05c81e786, b302059b22fb7cb4, 85682a1bf561243a, 53fc48838eb6837a, b24190e0842786ec, 85f93e150fc9b240]
tool: 1
---
# Об'єкт Context {#the-context}
Аргументи інструмента надходять від моделі. Усе інше (запит, який ви обслуговуєте, сервер, у якому живе інструмент, спосіб звернутися назад до клієнта) надходить з одного об'єкта: **`Context`**.
Його не потрібно ні створювати, ні налаштовувати. Достатньо попросити.
## Як його попросити {#ask-for-it}
Додайте до будь-якого інструмента параметр з анотацією `Context`:
```python title="server.py" hl_lines="2 8"
--8<-- "docs_src/context/tutorial001.py"
```
* SDK створює новий `Context` для кожного запиту й передає його у функцію.
* **Ім'я параметра не має значення**. `ctx`, `context`, `c`: SDK знаходить його за анотацією.
* Ресурси та промпти теж можуть оголосити такий параметр, у той самий спосіб.
* `ctx.request_id` — ідентифікатор запиту, який ваша функція обслуговує просто зараз.
!!! info
Якщо ви працювали з FastAPI, цей прийом вам знайомий: оголошуєте параметр із власним типом
фреймворку (`Request` там, `Context` тут), і фреймворк його підставляє. Нічого реєструвати,
нічого налаштовувати: анотація типу — це й увесь механізм.
### Невидимий для моделі {#invisible-to-the-model}
Це те, що варто добре засвоїти. Ось схема вхідних даних, яку `tools/list` повідомляє для `search_books`:
```json
{
"type": "object",
"properties": {
"query": {"title": "Query", "type": "string"}
},
"required": ["query"],
"title": "search_booksArguments"
}
```
Одна властивість. `ctx` — не аргумент: він ніколи не з'являється у схемі, моделі про нього ніколи не повідомляють, і жоден клієнт не може його заповнити. Це домовленість між вами та SDK, якої не видно в переданих даних.
### Спробуйте самі {#try-it}
Запустіть сервер у MCP Inspector:
```console
uv run mcp dev server.py
```
Форма для `search_books` має єдине поле `query`. Викличте інструмент зі значенням `dune`:
```text
[request 3] Found 3 books matching 'dune'.
```
Число показує, яким за ліком виявився цей запит. Викличте інструмент ще раз — і воно зміниться: кожен запит отримує власний `Context`.
## Що він дає {#what-it-gives-you}
Впроваджений об'єкт невеликий. Окрім `request_id`:
* `await ctx.read_resource(uri)`: прочитати один із **власних** ресурсів сервера зсередини інструмента. Про це — наступний розділ.
* `await ctx.report_progress(progress, total, message)`: передавати перебіг виконання тому, хто викликав, упродовж тривалого виклику. Докладніше — на сторінці **[Перебіг виконання](progress.md)**.
* `await ctx.elicit(message, schema)` та `await ctx.elicit_url(...)`: призупинити інструмент і поставити користувачеві запитання. Це **[Еліцитація](elicitation.md)** (elicitation).
* `ctx.session`: серверний бік розмови з цим клієнтом. Тут живуть сповіщення, які ви надсилаєте клієнтові; останній розділ ним користується.
* `ctx.headers`: заголовки запиту, які передав транспорт, або `None` для stdio. Прочитати власний заголовок можна так: `(ctx.headers or {}).get("x-...")`. Заголовки — це дані від клієнта: вони годяться для локалі чи прапорця функції, але ніколи — для ідентифікації особи.
* `ctx.request_context`: сирий запис про поточний запит. Поле, яке вам знадобиться, — `lifespan_context`, об'єкт, який ваш код запуску віддав через yield (див. **[Життєвий цикл (lifespan)](lifespan.md)**).
Логування навмисно немає в цьому списку. Сервер пише логи модулем `logging` Python, як і будь-яка інша програма на Python. **[Логування](logging.md)** — коротка сторінка про те, чому саме так.
!!! tip
Впровадження відбувається лише для функції, яку ви зареєстрували. Допоміжна функція, яку
викликає ваш інструмент, не отримує власного `Context`; передавайте `ctx` далі як звичайний
аргумент. Жодного фонового «поточного контексту», який можна було б дістати звідкись іще, немає.
## Читання власних ресурсів {#read-your-own-resources}
Ресурси сервера — не лише для клієнтів. Інструмент теж може їх читати:
```python title="server.py" hl_lines="16"
--8<-- "docs_src/context/tutorial002.py"
```
`ctx.read_resource` розв'язує URI через той самий реєстр, що обслуговує `resources/read`, тож інструмент отримує те саме, що отримав би клієнт: ітерований об'єкт із `ReadResourceContents`, по одному на кожен блок вмісту. Для цього URI він один:
```python
contents.content # 'fiction, non-fiction, poetry'
contents.mime_type # 'text/plain'
```
* `content` — це рівно те, що повернула `genres()`. Одне джерело істини: клієнт переглядає ресурс, ваші інструменти його споживають, ніхто не копіює рядок.
* Єдиний параметр `describe_catalog` — це `Context`, тож його схема вхідних даних **не має жодної властивості**. Модель викликає його з `{}`.
## Сповіщення клієнта про зміну списку {#tell-the-client-the-list-changed}
Те, що пропонує сервер, не зафіксовано на момент імпорту. Зареєструйте інструмент під час виконання, а потім повідомте клієнта:
```python title="server.py" hl_lines="15-16"
--8<-- "docs_src/context/tutorial003.py"
```
* `mcp.add_tool(recommend_book)` реєструє звичайну функцію як інструмент: ім'я, опис і схема виводяться точно так само, як це зробив би `@mcp.tool()`.
* `await ctx.session.send_tool_list_changed()` надсилає `notifications/tools/list_changed`. Клієнт, що його отримав, знову викликає `tools/list` і бачить `recommend_book`.
Споріднені методи — `send_resource_list_changed()`, `send_prompt_list_changed()` і `send_resource_updated(uri)` для зміни одного конкретного ресурсу.
На з'єднанні покоління 2026-07-28 клієнти отримують сповіщення про зміни лише в потоці `subscriptions/listen`, який вони самі відкрили, тож наведені вище методи `send_*` до цих потоків не доходять. Методи публікації `Context` доставляють сповіщення в усі підписані потоки одразу: `await ctx.notify_tools_changed()`, `await ctx.notify_prompts_changed()`, `await ctx.notify_resources_changed()` та `await ctx.notify_resource_updated(uri)`. Докладніше, зокрема про масштабування на кілька реплік, — на сторінці **[Підписки](subscriptions.md)**.
!!! check
Поки ніхто не запустив `enable_recommendations`, обіцяного інструмента не існує. Викличте його
все одно — і результатом буде помилка, яку модель може прочитати:
```text
Unknown tool: recommend_book
```
Запустіть `enable_recommendations` — і той самий виклик спрацює. Список інструментів справді
динамічний: `tools/list` відображає те, що зареєстровано *саме зараз*.
## Підсумки {#recap}
* Анотуйте параметр типом `Context` (в інструменті, ресурсі чи промпті) — і SDK його впровадить. Ім'я обираєте ви.
* Для моделі він невидимий: схема вхідних даних завжди містить лише ваші справжні аргументи.
* `ctx.request_id` ідентифікує запит; `ctx.request_context.lifespan_context` — те, що ваш код запуску віддав через yield.
* `await ctx.read_resource(uri)` дає інструменту змогу читати власні ресурси сервера.
* `ctx.session` — канал назад до клієнта: `send_tool_list_changed()` та споріднені методи кажуть йому заново отримати список, який ви змінили.
* Звітування про перебіг виконання та еліцитація теж починаються з `Context`; кожному присвячено окрему сторінку.
Параметри, яких модель ніколи не бачить і які заповнюють ваші власні функції, — це **[Залежності](dependencies.md)**.

View file

@ -0,0 +1,168 @@
---
translation:
sections: [b0389403e98d25ad, e2cf58b43b285e86, a363e1a38e1a5971, 6cfac078feb18013, b4535bd61df337e6, e97ed44207f929fd]
tool: 1
---
# Залежності {#dependencies}
Аргументи інструмента надходять від моделі. Деякі значення звідти надходити не повинні ніколи: ціна, знайдена у ваших записах; підтвердження, яке може дати лише людина; усе, що модель могла б зіпсувати, якби вигадала сама.
**Залежності** — це параметри, які заповнюють ваші власні функції. Ви анотуєте параметр, указуєте функцію, а SDK викликає її до того, як запуститься інструмент.
## Оголошення залежності {#declare-one}
Загорніть тип параметра в `Annotated[...]` і додайте `Resolve(fn)`:
```python title="server.py" hl_lines="18-19 23"
--8<-- "docs_src/dependencies/tutorial001.py"
```
* `check_stock` — це **резолвер**: звичайна функція, яку SDK запускає перед `reserve_book` і чиє повернене значення стає аргументом `stock`.
* Її параметр `title` — це власний аргумент `title` інструмента, зіставлений **за ім'ям**. Резолвер бачить рівно те саме валідоване значення, яке побачить тіло інструмента.
* Тіло інструмента починає з уже готового `Stock`. Жодного коду пошуку в інструменті, жодної преамбули на кшталт «а якщо його немає».
!!! info
Якщо ви працювали з FastAPI, це `Depends`. Той самий хід, та сама причина: функція оголошує,
що їй потрібно, фреймворк це надає, а зв'язування живе в анотації типу.
### Невидимий для моделі {#invisible-to-the-model}
Ось вхідна схема, яку `tools/list` повідомляє для `reserve_book`:
```json
{
"type": "object",
"properties": {
"title": {"title": "Title", "type": "string"}
},
"required": ["title"],
"title": "reserve_bookArguments"
}
```
Одна властивість. Як і `Context` на сторінці **[Об'єкт Context](context.md)**, параметр із резолвером — це контракт між вами й SDK: `stock` у схемі немає, моделі про нього ніколи не повідомляють, а клієнта, який усе одно надсилає значення `stock`, ігнорують. Значення від резолвера — єдине, яке може отримати ваш інструмент.
У цьому останньому й суть. Параметр, який модель не може передати, — це параметр, у якому модель не може помилитися.
### Спробуйте самі {#try-it}
Запустіть сервер з MCP Inspector:
```console
uv run mcp dev server.py
```
Форма для `reserve_book` має єдине поле `title`. `stock` на ній ніде немає. Викличте інструмент із `Dune`:
```text
Reserved 'Dune' (6 copies left).
```
Тіло інструмента нічого не шукало: спершу виконався `check_stock`, і повернений ним `Stock` прийшов як аргумент. Спробуйте `Neuromancer` — той самий резолвер передасть інструменту нуль.
!!! tip
Можна було б просто викликати `check_stock(title)` у тілі інструмента. Оголошуйте залежність
тоді, коли значення заслуговує на більше, ніж виклик допоміжної функції: кожен інструмент, якому
потрібен залишок, оголошує той самий параметр, а SDK запускає резолвер щонайбільше один раз на
виклик, незалежно від того, скільки разів його оголошено. Наступні розділи додають решту:
резолвери, що залежать один від одного, і резолвери, що запитують користувача.
## Залежності залежностей {#dependencies-of-dependencies}
Резолвер може оголошувати власні залежності тією самою анотацією:
```python title="server.py" hl_lines="22 29-30"
--8<-- "docs_src/dependencies/tutorial002.py"
```
* `estimate_delivery` залежить від `check_stock`. SDK виконує граф по порядку: спершу залишок, потім оцінка, потім інструмент.
* І `stock`, і `delivery` зрештою потребують `check_stock`, але він виконується **один раз на виклик**. Один пошук у складських залишках, два споживачі.
* Реєструвати нічого не потрібно. Анотації — це *і є* граф.
!!! check
Не вірте в «один раз на виклик» на слово. Додайте `print` у `check_stock` і викличте
`order_book` з Inspector: один рядок на виклик. Два споживачі, один пошук.
SDK аналізує граф під час реєстрації інструмента, а не під час виклику. Параметр, який не вдається класифікувати (не `Context`, не `Resolve(...)`, не ім'я аргументу інструмента), і цикл резолверів однаково викидають `InvalidSignature` під час запуску. Сервер падає ще до того, як під'єднається бодай один клієнт, а в помилці названо проблемний параметр чи резолвер.
Параметри резолвера розв'язуються точно так само, як параметри інструмента: інший `Resolve(...)`, власні аргументи інструмента за ім'ям або `Context` — `ctx.headers`, об'єкт життєвого циклу (lifespan), усе разом.
!!! warning
На HTTP-транспортах `Context` містить `ctx.headers`. Заголовки — це **вхідні дані від клієнта**,
як і будь-який аргумент інструмента: вони годяться для локалі чи прапорця функції, але ніколи —
для ідентичності. Хто саме викликає, визначає ваш шар авторизації
(**[Авторизація](../run/authorization.md)**), а не заголовок, який може встановити будь-хто.
!!! tip
*Один раз на виклик* означає саме це: наступний `tools/call` знову запускає `check_stock`.
Ресурсу, що має пережити запит (пул бази даних, HTTP-клієнт), місце на сторінці
**[Життєвий цикл](lifespan.md)**, а резолвер може дістатися до нього через
`ctx.request_context.lifespan_context`.
## Запитання лише за потреби {#ask-when-you-must}
Резолвер не зобов'язаний знати відповідь. Він може повернути `Elicit(message, Model)`, і SDK запитає користувача — це механізм **[еліцитації](elicitation.md)** (elicitation), запущений за вас:
```python title="server.py" hl_lines="26-32 39"
--8<-- "docs_src/dependencies/tutorial003.py"
```
* Є в наявності: `confirm_backorder` повертає `Backorder` напряму. **Ні запитання, ні зайвого раунду обміну.** Користувача переривають лише тоді, коли його відповідь має значення.
* Немає в наявності: SDK надсилає еліцитацію, валідує відповідь за моделлю `Backorder` і впроваджує її. Ваш резолвер ніколи не торкається протоколу.
* Інструмент читає `backorder.confirm`, як будь-який інший аргумент. Відповідь **ні** — теж відповідь: еліцитацію прийнято з `confirm=False`, інструмент виконується, і замовлення не оформлюється. Запитання стало передумовою, а не службовим кодом у тілі інструмента.
А якщо користувач узагалі не відповість — відхилить запитання або скасує його?
!!! check
Запустіть `order_book` для `Neuromancer` і відхиліть запитання. Якщо анотацію записано як
`Annotated[Backorder, Resolve(...)]`, тіло інструмента не виконується взагалі; виклик
завершується результатом-помилкою, який може прочитати модель:
```text
Error executing tool order_book: Resolver for parameter 'backorder' could not resolve: elicitation was decline
```
Це правильна поведінка за замовчуванням для передумови: немає відповіді — немає замовлення. Коли відмова — це результат, який інструмент хоче обробити (пропустити відкладене замовлення, але все ж запропонувати іншу книжку), анотуйте натомість `ElicitationResult[Backorder]`, і інструмент отримає повний результат accept/decline/cancel, за яким можна розгалузитися. Сторінка **[Еліцитація](elicitation.md)** показує цю форму й усе інше про запитання: правила схеми, три відповіді, бік клієнта в цій розмові.
!!! info
Фреймворк обирає транспорт для запитання за узгодженою версією протоколу; наведений вище код
однаковий для обох. На **2026-07-28** і пізніших запитання їде всередині багатораундового
(multi-round-trip) `tools/call`: сервер повертає його, `elicitation_callback` клієнта
відповідає, а `Client` повторює виклик за вас (**[Багатораундові запити](multi-round-trip.md)**). На
**2025-11-25** і раніших це синхронний запит еліцитації посеред виклику. Кожне запитання
ставиться рівно один раз на виклик — це гарантія щодо запитання, а не резолвера. У
багатораундовій формі будь-який резолвер може виконатися знову щоразу, коли виклик
відновлюється після запитання, тож код перед `return Elicit(...)` виконується в кожному з цих
раундів; записана відповідь тоді задовольняє повторне запитання, не турбуючи користувача ще
раз. До записаної відповіді звертаються лише тоді, коли резолвер запитує; резолвер, що
відповідає *без* запитання, як-от `check_stock`, завжди надає власне обчислене значення.
Оскільки кожна відповідь зіставляється зі своїм запитанням, резолвер з еліцитацією мусить
виводити запитання детерміновано з аргументів інструмента та попередніх відповідей. Значення,
що генерується для кожного виклику (ідентифікатор із `default_factory`, мітка часу),
виводиться заново в кожному раунді й не повинне потрапляти в запитання, до якого має
прив'язатися відповідь. Запитання, побудоване з таких мінливих даних, робить кожну записану
відповідь застарілою на вигляд, тож сервер ставить його знову в кожному раунді, доки обмеження
клієнта на кількість раундів не завершить виклик.
## Запитання до клієнта, а не до користувача {#ask-the-client-not-the-user}
Еліцитація — одне з трьох запитань, які може поставити резолвер, і багатораундовий потік інших не дозволяє. Два інші адресовано **клієнту**, а не користувачу: поверніть `Sample(...)`, щоб виконати виклик LLM через клієнта (запит `sampling/createMessage`), або `ListRoots()`, щоб отримати поточні кореневі каталоги (roots) клієнта. Жодне з них не має результату accept/decline; споживач анотує тип результату напряму — `CreateMessageResult` (`CreateMessageResultWithTools`, коли запит містить `tools` або `tool_choice`) або `ListRootsResult`:
```python title="server.py" hl_lines="10-15 21"
--8<-- "docs_src/dependencies/tutorial004.py"
```
* Фреймворк маршрутизує їх точно так само, як `Elicit`: усередині багатораундового `tools/call` на **2026-07-28**, через окремий запит сервер->клієнт на **2025-11-25**. Неоголошена можливість відхиляє виклик помилкою протоколу `-32021` (`sampling`, `roots`, `elicitation` у режимі форми; `sampling.tools`, коли запит містить `tools` або `tool_choice`).
* Усе, що сказано про запитання в інформаційному блоці вище, застосовується без змін: запит `Sample` зіставляється зі своїм записаним результатом за точним поданням, тож будуйте його детерміновано з аргументів інструмента та попередніх відповідей; тоді клієнт платить за виклик LLM один раз на виклик інструмента, а не один раз на раунд. Записаний результат мандрує в `request_state` до кінця виклику, тому дуже велика відповідь моделі робить кожен подальший раунд обміну важчим.
* Окремі *можливості* семплювання (sampling) та кореневих каталогів оголошено застарілими у 2026-07-28 (SEP-2577). Нові сервери, яким потрібна модель клієнта, запитують через цей носій; сервери, яким вона не потрібна, мають інтегруватися з постачальником LLM напряму. Значення `include_context`, відмінні від `"none"`, самі є застарілими; уникайте їх.
## Підсумки {#recap}
* `Annotated[T, Resolve(fn)]` на параметрі інструмента: SDK запускає `fn` і впроваджує повернене значення.
* Параметр із резолвером невидимий для моделі, і клієнт не може його передати. Значенням, які модель не повинна вигадувати (ціни, ідентичності, дозволи), місце саме тут.
* Параметри резолвера розв'язуються так само: `Context`, інший `Resolve(...)` або аргумент інструмента за ім'ям. Граф запускає кожен резолвер щонайбільше один раз на раунд, хоч скільки в нього споживачів; кожне запитання ставиться рівно один раз, а будь-який резолвер може виконатися знову, коли виклик відновлюється після запитання.
* Хибні графи падають під час реєстрації з `InvalidSignature`, а не посеред виклику.
* Повертайте `Elicit(message, Model)`, щоб запитати користувача, — лише коли без цього ніяк. Анотації без обгортки переривають виклик у разі відмови; `ElicitationResult[T]` дає інструменту змогу розгалузитися.
* Повертайте `Sample(...)` або `ListRoots()`, щоб попросити в клієнта відповідь LLM або список кореневих каталогів; впроваджується сам результат.
Про стан, який сервер будує один раз під час запуску, і про те, як обробник до нього дістається, — сторінка **[Життєвий цикл](lifespan.md)**.

View file

@ -0,0 +1,190 @@
---
translation:
sections: [335ca2a0b266f003, d1ad562d3fe87bc0, 0bb1396c86daeba4, d1cb1235bb9ee267, 833179c09d239c83, e5d6dec2d2e655e8]
tool: 1
---
# Еліцитація {#elicitation}
Інструмент, який уже наполовину виконав свою роботу й не має однієї відповіді, не мусить завершуватися помилкою.
**Еліцитація** (elicitation) дає йому змогу запитати. Посеред виклику інструмента користувач отримує запитання, а його відповідь повертається в той самий виклик функції.
Є два режими:
* **Режим форми**: потрібне значення (підтвердження, дата, кількість). Ви описуєте поля, клієнт відображає форму.
* **Режим URL**: потрібно, щоб користувач перейшов кудись іще (екран згоди OAuth, сторінка оплати). Ніщо з того, що він там робить, не проходить через протокол.
І є два способи запитати. Той, до якого варто звертатися насамперед, — **резолвер**: запитання навішується на параметр, а питає SDK — на будь-якому з'єднанні, хоч би якого покоління протоколу дотримувався клієнт. Прямий спосіб, `await ctx.elicit(...)`, — це запит від *сервера* до *клієнта*, канал, що існує лише для клієнта на з'єднанні старого покоління (версія специфікації 2025-11-25 або раніша). Обидва способи є на цій сторінці; почніть із резолвера.
## Запитання через резолвер {#ask-with-a-resolver}
Запитання, від якого залежить увесь інструмент, — *ви впевнені? який із трьох знайдених облікових записів?* — можна винести з тіла інструмента в **резолвер**, і фреймворк поставить його за вас.
Параметр з анотацією `Annotated[T, Resolve(fn)]` заповнюється запуском `fn` перед тілом інструмента. Резолвер повертає значення безпосередньо, коли вже його знає, або повертає `Elicit(...)`, щоб запитав фреймворк:
```python title="server.py" hl_lines="24-30 35-36"
--8<-- "docs_src/elicitation/tutorial004.py"
```
* `confirm_delete` читає власний аргумент інструмента `path` за іменем, переглядає вміст папки й **питає лише тоді, коли мусить** — порожня папка розв'язується в `Confirm(ok=True)` без жодного раунду обміну з клієнтом.
* `delete_folder` анотує `ElicitationResult[Confirm]`, тож фреймворк впроваджує весь результат цілком, і інструмент через `match` обробляє кожен випадок: прийняти й підтвердити, прийняти, але залишити (`ok=False`), відхилити, скасувати.
* Параметр `confirm` ніколи не з'являється у вхідній схемі інструмента — клієнт надає `path`, резолвер надає `confirm`.
Коли інструменту не потрібне розгалуження, анотуйте натомість розгорнуту модель (`Annotated[Confirm, Resolve(confirm_delete)]`): у разі прийняття він отримує модель, а в разі відхилення чи скасування виклик переривається з помилкою.
Резолвер працює на **кожному** з'єднанні. Клієнтові на з'єднанні старого покоління SDK надсилає запитання напряму; на з'єднанні **2026-07-28** SDK *повертає* запитання з виклику, а наступна спроба клієнта несе відповідь. Резолвер ніколи не помічає різниці; те, що відбувається всередині, описано на сторінці **[Багатораундові запити](multi-round-trip.md)** (multi-round-trip).
Запитувати — лише одне з того, що вміє резолвер. Загальний механізм — залежності, що обчислюються без запитань, залежності залежностей, що модель може й чого не може надати — описано на сторінці **[Залежності](dependencies.md)**.
## Запитання зсередини інструмента {#ask-from-inside-the-tool}
Інструмент також може зупинитися посеред власного тіла й запитати.
!!! warning
`ctx.elicit()` і `ctx.elicit_url()` — це запити від *сервера* до *клієнта*, канал,
що існує лише для клієнта на з'єднанні старого покоління (версія специфікації **2025-11-25**
або раніша). На з'єднанні **2026-07-28** запитів, ініційованих сервером, немає, тож
ці виклики завершуються помилкою. Резолвер працює в обох випадках. Докладніше — на сторінці
**[Версії протоколу](../protocol-versions.md)**.
`await ctx.elicit()` приймає повідомлення й модель Pydantic:
```python title="server.py" hl_lines="9-11 20-23 25"
--8<-- "docs_src/elicitation/tutorial001.py"
```
* Параметр **`Context`** — це те, що дає `ctx.elicit`; будь-який інструмент може його приймати. Цей об'єкт має власну сторінку: **[Об'єкт Context](context.md)**.
* `AlternativeDate` — це **схема** відповіді, яку ви хочете отримати.
* Інструмент оголошено як `async def`. Інакше не можна: він зупиняється посередині й чекає на людину.
* Для будь-якої іншої дати інструмент повертає результат одразу. Він питає лише тоді, коли мусить.
* Дата, яку приймає користувач, знову проходить через сам `book_table`. Відповідь — це такі самі вхідні дані, як і будь-які інші: про альтернативу, яка теж повністю заброньована, запитають знову, а не підтвердять наосліп.
### Що отримує клієнт {#what-the-client-receives}
Клієнт отримує ваше повідомлення, а поруч із ним — JSON Schema, згенеровану з моделі:
```json
{
"properties": {
"accept_alternative": {
"description": "Try another date?",
"title": "Accept Alternative",
"type": "boolean"
},
"date": {
"default": "2025-12-26",
"description": "Alternative date (YYYY-MM-DD)",
"title": "Date",
"type": "string"
}
},
"required": ["accept_alternative"],
"title": "AlternativeDate",
"type": "object"
}
```
Ця схема і є формою. `Field(description=...)` — це підпис; значення за замовчуванням попередньо заповнює поле введення й робить поле необов'язковим. Це той самий механізм перетворення Pydantic на JSON Schema, який сторінка **[Інструменти](../servers/tools.md)** описує для аргументів інструмента.
!!! warning
Схема еліцитації не така виразна, як вхідна схема інструмента. Лише пласкі примітивні поля:
`str`, `int`, `float`, `bool` або `Literal` з рядків (він стає `enum`).
Покладіть модель усередину моделі — і `ctx.elicit` викине виняток ще до того, як щось буде надіслано клієнтові:
```text
TypeError: Elicitation schema field 'address' rendered as {'$ref': '#/$defs/Address'}, which is not a valid PrimitiveSchemaDefinition
```
Ви перериваєте людину посеред справи. Якщо відповідь потребує вкладеності, вона мала б бути
аргументом інструмента.
### Три відповіді {#the-three-answers}
`result.action` повідомляє, що зробив користувач, і можливостей рівно три:
* `"accept"`: він надіслав форму. `result.data` — це екземпляр `AlternativeDate`, уже провалідований.
* `"decline"`: він відмовив.
* `"cancel"`: він закрив запитання, нічого не вибравши.
`result.data` існує лише для `"accept"`, тому приклад спершу перевіряє `result.action`. Засіб перевірки типів стежить за порядком: після `result.action == "accept"` `result.data` — це `AlternativeDate`; до того `.data` немає взагалі.
Відмова — не помилка. Інструмент сам вирішує, що означає відхилення (тут — бронювання немає), і відповідає моделі як звичайно.
!!! tip
Відповідь валідується за вашою моделлю, перш ніж її побачить ваш код. Клієнт, що надсилає
`"maybe"` для `bool`, не зіпсує бронювання: виклик завершується помилкою
невідповідності схемі, а ваш `if` так і не виконується.
## Перенаправлення користувача на URL {#send-the-user-to-a-url}
Деякі речі не повинні проходити ні через модель, ні через клієнта: облікові дані, номери карток, згода OAuth. Для них ви не просите даних; ви просите користувача кудись перейти:
```python title="server.py" hl_lines="10-14 23"
--8<-- "docs_src/elicitation/tutorial002.py"
```
* `ctx.elicit_url()` приймає повідомлення, **URL**, який треба відвідати, і `elicitation_id`, який ви обираєте самі: будь-який рядок, що ідентифікує цю еліцитацію в межах вашого сервера.
* Результат містить дію й більше нічого. `"accept"` означає, що користувач погодився відкрити URL, а **не** те, що він завершив усе на тому боці.
* Оплата відбувається поза протоколом, між браузером користувача й вашим платіжним провайдером. Жоден вміст ніколи не повертається через MCP.
Погляньте на другий інструмент. Коли сервер дізнається, що зовнішній процес завершено (вебхук, опитування; тут це змодельовано як другий інструмент), `ctx.session.send_elicit_complete(...)` надсилає `notifications/elicitation/complete` з тим самим `elicitation_id`. Саме так клієнт дізнається, що можна припинити показувати *«очікуємо оплату...»*. Без цього клієнтові залишається лише здогадуватися.
## На боці клієнта {#the-client-side}
Сервери питають. Клієнти відповідають, передаючи **`elicitation_callback`** у `Client(...)`:
```python title="client.py" hl_lines="6-7 18"
--8<-- "docs_src/elicitation/tutorial003.py"
```
* Один колбек обробляє обидва режими. `params` — це об'єднання `ElicitRequestFormParams` і `ElicitRequestURLParams`; розгалуження робиться через `isinstance`.
* Для URL ви показуєте користувачеві `params.url` і повертаєте дію, яку він обрав. Ніколи жодного `content`.
* Для форми справжній застосунок відображає `params.requested_schema` і повертає введене користувачем як `content`. Цей завжди каже «так» із заготовленою відповіддю — саме такий колбек і потрібен у тесті.
* Передавання колбека — це водночас і **оголошення можливості**: саме так сервер дізнається, що цього клієнта можна питати. Інше, на що клієнт може відповідати серверові, описано на сторінці **[Колбеки клієнта](../client/callbacks.md)**.
!!! info
Еліцитація — це запит від *сервера* до *клієнта*, а такі існують лише
в сесії з класичним рукостисканням, тому цей клієнт передає `mode="legacy"`.
На з'єднанні **2026-07-28** інструмент натомість питає, *повертаючи* запитання з виклику;
цей потік описано на сторінці **[Багатораундові запити](multi-round-trip.md)**.
### Спробуйте самі {#try-it}
Запустіть `server.py` у режимі форми з `ctx.elicit` (той, що з `book_table`) через Streamable HTTP (потрібний однорядковий приклад є на сторінці **[Запуск сервера](../run/index.md)**), потім виконайте `main()` клієнта й викличте `book_table` на день Різдва.
Колбек виводить запитання, яке йому надіслали:
```text
No tables for 2 on 2025-12-25. Would you like to try another date?
```
Він відповідає `{"accept_alternative": True, "date": "2025-12-27"}`, а інструмент, який увесь цей час чекав усередині `await ctx.elicit(...)`, завершує бронювання:
```text
Booked a table for 2 on 2025-12-27.
```
Тепер підставте `server.py` у режимі URL і спрямуйте ту саму `main()` на `pay_deposit`: той самий колбек піде іншою гілкою, виведе посилання на оплату, а інструмент повернеться з *«Complete the payment in your browser.»*. Один раунд обміну, посеред виклику, в обох напрямках.
!!! check
Тепер приберіть `elicitation_callback=` із `Client` і знову викличте `book_table` на день Різдва.
Увесь виклик завершується помилкою протоколу:
```text
Elicitation not supported
```
Клієнт, що не зареєстрував колбека, ніколи не оголошував можливості `elicitation`, тож питати
нікого. Інструмент не отримав `"decline"`; він отримав виняток. Проєктуйте з огляду на це: кожна
еліцитація потребує розумної відповіді на запитання «а що, як запитати не можна?».
## Підсумки {#recap}
* Параметр з анотацією `Annotated[T, Resolve(fn)]` заповнює резолвер, який повертає `Elicit(...)`, коли мусить запитати. Це працює на кожному з'єднанні.
* Схема — пласка модель Pydantic: лише примітивні поля, валідовані на зворотному шляху.
* `result.action` — це `"accept"`, `"decline"` або `"cancel"`; `result.data` існує лише в разі прийняття.
* `await ctx.elicit(message, schema=Model)` питає зсередини тіла інструмента, а `await ctx.elicit_url(message, url, elicitation_id)` — для всього, що не повинно проходити через модель (`ctx.session.send_elicit_complete(elicitation_id)` повідомляє, що зовнішню частину завершено). Обидва — запити від сервера до клієнта: їм потрібен клієнт на з'єднанні старого покоління.
* Клієнт відповідає одним `elicitation_callback`, розгалужуючись за типом params; його реєстрація й оголошує можливість.
* На з'єднанні 2026-07-28 сервер повертає запитання замість того, щоб надсилати його; той самий колбек отримує дані через **[Багатораундові запити](multi-round-trip.md)**.
Усе, що стоїть за цим поверненням (цикл повторних спроб, захист `requestState`, самостійне керування), описано на сторінці **[Багатораундові запити](multi-round-trip.md)**.

View file

@ -0,0 +1,38 @@
---
translation:
sections: [424930166c4bc6f3]
tool: 1
---
# Усередині обробника {#inside-your-handler}
Аргументи обробника надходять від клієнта. Усе *інше*, що він може прочитати,
і все, що він може робити під час виконання, зібрано тут.
Що він може читати:
* **[Об'єкт Context](context.md)** — єдиний додатковий параметр, який може
попросити будь-який обробник: поточний запит, його заголовки, його сесія,
а також методи для звітування про перебіг виконання й сповіщення про зміни.
* **[Залежності](dependencies.md)** — параметри, яких модель ніколи не бачить;
їх заповнюють ваші власні функції через `Resolve`.
* **[Життєвий цикл](lifespan.md)** (lifespan) — стан, який сервер будує один
раз під час запуску, і те, як обробник дістається до нього через `Context`.
Що він може робити під час виконання:
* Запитати в користувача додаткові дані за допомогою
**[Еліцитації](elicitation.md)** (elicitation) та
**[Багатораундових запитів](multi-round-trip.md)** (multi-round-trip) —
патерну редакції 2026-07-28, через який вона працює.
* Попросити в клієнта доповнення від LLM або список папок його робочого
простору — **[Семплювання та кореневі каталоги](sampling-and-roots.md)**
(sampling і roots); ці можливості застарілі, але досі обслуговуються.
* Звітувати про **[Перебіг виконання](progress.md)** повільної операції.
* Писати логи (у стандартний потік помилок, для того, хто експлуатує сервер) —
**[Логування](logging.md)**.
* Повідомляти підписаним клієнтам, що щось змінилося, —
**[Підписки](subscriptions.md)**.
Якщо ви ще не зареєстрували жодного обробника, почніть зі сторінки
**[Інструменти](../servers/tools.md)**. Кожна сторінка тут передбачає, що він
у вас уже є.

View file

@ -0,0 +1,107 @@
---
translation:
sections: [f3ca8ac5f90f2dfa, 85a1ef3588ba0736, 563346d4d5804933, 9e3528340d0bab53]
tool: 1
---
# Життєвий цикл {#lifespan}
Більшість справжніх серверів тримають щось упродовж усього свого життя: пул з'єднань із базою даних, HTTP-клієнт, завантажену модель.
Створювати це під час кожного виклику не хочеться, а от коректно закрити наприкінці — треба. Саме для цього й існує **життєвий цикл (lifespan)**.
## Типізований життєвий цикл {#a-typed-lifespan}
Життєвий цикл — це `@asynccontextmanager`, який отримує сервер і через `yield` віддає **один об'єкт**. Те, що ви віддали, доступне кожному обробнику, поки сервер працює.
```python title="server.py" hl_lines="25-31 34 38 40"
--8<-- "docs_src/lifespan/tutorial001.py"
```
Читайте знизу вгору:
* `app_lifespan` під'єднує `Database` **до** `yield` і від'єднує її **після**, у блоці `finally`. Це і є запуск та зупинка.
* Він віддає `AppContext` — звичайний dataclass, що містить усе налаштоване під час запуску. Сьогодні одне поле, завтра десять.
* `MCPServer("Bookshop", lifespan=app_lifespan)` — оце й усе підключення.
* Усередині інструмента відданий об'єкт доступний як `ctx.request_context.lifespan_context`.
Життєвий цикл виконується **один раз**. Вхід у нього відбувається, коли сервер стартує (до першого запиту), а вихід — коли сервер зупиняється. Усі запити між цими моментами спільно користуються тим самим `AppContext`.
!!! info
Якщо ви вже писали `lifespan` для FastAPI, то все це вам знайоме. Той самий декоратор, той самий `yield`, той самий `finally`.
### Що бачить модель {#what-the-model-sees}
Нічого нового. `ctx` — це параметр **Context**, тож SDK впроваджує його сам, і він ніколи не потрапляє до вхідної схеми:
```json
{
"type": "object",
"properties": {
"genre": {"title": "Genre", "type": "string"}
},
"required": ["genre"],
"title": "count_booksArguments"
}
```
`genre` — єдиний аргумент, який модель може передати. Життєвий цикл — це справа самого сервера.
Функції `@mcp.resource()` і `@mcp.prompt()` теж можуть приймати параметр `ctx`, записаний як простий `Context` — з причини, до якої дійде наступний розділ. Усе, що несе в собі `ctx`, описано на сторінці **[Об'єкт Context](context.md)**.
### Він справді типізований {#it-really-is-typed}
Погляньте на анотацію ще раз: `ctx: Context[AppContext]`.
Саме завдяки цьому одному параметру типу `ctx.request_context.lifespan_context` **є** `AppContext` для вашого засобу перевірки типів. `.db` підставляється автодоповненням; `.dbb` — помилка ще до того, як ви запустите сервер.
Напишіть натомість простий `Context` — і `lifespan_context` отримає тип `dict[str, Any]`: засіб перевірки типів ніяк не може дізнатися, що віддав ваш життєвий цикл. Під час виконання об'єкт нікуди не зникає; ви лише втрачаєте допомогу.
!!! warning
`Context[AppContext]` — запис **лише для інструментів**. Поставте його на функцію `@mcp.resource()` чи
`@mcp.prompt()` — і кожен виклик цього обробника завершиться помилкою. Клієнт отримає помилку у відповідь,
а лог сервера покаже причину:
```text
Context is not available outside of a request
```
У ресурсах і промптах пишіть простий `ctx: Context`. Об'єкт, який віддав ваш життєвий цикл,
під час виконання так само доступний як `ctx.request_context.lifespan_context`; ви відмовляєтеся від параметра типу, а не
від об'єкта.
!!! tip
Життєвий цикл є завжди. Якщо його не передати, типовий варіант від SDK віддає порожній `dict`,
тож `ctx.request_context.lifespan_context` дорівнює `{}` і ніколи не буває `None`. Саме через цей типовий варіант
простий `Context` типізує його як `dict[str, Any]`.
## Подивіться, як це працює {#watch-it-happen}
«Запуск виконується до першого запиту» — речення з тих, які не варто брати на віру.
Скоротіть сервер до самого життєвого циклу: додайте до `Database` прапорець `connected`, перемикайте його в `connect()` і `disconnect()` та додайте інструмент, що повідомляє його стан.
```python title="server.py" hl_lines="11 14 17 25 44"
--8<-- "docs_src/lifespan/tutorial002.py"
```
`database` живе на рівні модуля з однієї причини: щоб на неї можна було глянути *ззовні* сервера.
!!! check
Три моменти — три значення:
* До запуску сервера `database.connected` дорівнює `False`. Імпорт модуля нічого не під'єднав.
* Поки сервер працює, викличте `database_status` — і результатом буде `"connected"`.
* Зупиніть сервер, і виконається блок `finally`: `database.connected` знову `False`.
Робота відбулася саме там, де ви її розмістили: навколо `yield`, а не під час імпорту й не на кожен запит.
## Підсумки {#recap}
* `lifespan=` приймає `@asynccontextmanager`, який отримує сервер і через `yield` віддає один об'єкт.
* Код до `yield` — це запуск. Блок `finally` після нього — зупинка.
* Він виконується один раз, навколо всього життя сервера, а не на кожен запит.
* Усе, що ви віддаєте через `yield`, стає `ctx.request_context.lifespan_context` у кожному інструменті, ресурсі та промпті.
* `ctx: Context[AppContext]` робить цей доступ повністю типізованим в інструментах. Ресурси й промпти приймають простий `Context`.
* Без `lifespan=` буде порожній `dict`, і ніколи не `None`.
Обробник, що зупиняється посеред виклику, аби запитати в користувача щось відоме лише йому, — це **[Еліцитація](elicitation.md)**.

View file

@ -0,0 +1,87 @@
---
translation:
sections: [c93a3e1aefd77955, 7851abd5ec54393b, f49d1ca2f330f9cd, c03764bd9dfeef7b, 4a0391691a674ae4, 2df5cd279eabf9f5]
tool: 1
---
# Логування {#logging}
Пишіть логи з інструмента так само, як із будь-якої іншої функції Python: стандартною бібліотекою.
У MCP є **можливість логування** на рівні протоколу: сервер міг надсилати свої записи логу клієнту як сповіщення через методи об'єкта `Context`. Редакція специфікації 2026-07-28 **оголошує цю можливість застарілою і нічим її не замінює**, тому ця документація її не описує. Повний перелік застарілого і того, що робити натомість, — на сторінці **[Застарілі можливості](../deprecated.md)**.
Натомість робіть те саме, що й у будь-якій іншій програмі на Python: користуйтеся стандартною бібліотекою.
## Інструмент, що пише логи {#a-tool-that-logs}
```python title="server.py" hl_lines="1 5 13"
--8<-- "docs_src/logging/tutorial001.py"
```
* `logging.getLogger(__name__)` повертає логер, названий за вашим модулем. Створіть його один раз, угорі файлу.
* Усередині інструмента викликайте `logger.info(...)`, як у будь-якій іншій функції. Нічого не треба впроваджувати, нічого не треба чекати через `await`, нічого специфічного для MCP.
!!! check
Викличте інструмент і подивіться на весь результат:
```python
result.content # [TextContent(text="Found 3 books matching 'dune'.")]
result.structured_content # {'result': "Found 3 books matching 'dune'."}
```
Рядка логу в ньому ніде немає. Логи — для **вас**, людини, яка керує сервером. Модель
їх ніколи не бачить. Якщо модель має щось прочитати, поверніть це через `return`.
## Куди це потрапляє {#where-it-goes}
Для **stdio**-сервера це питання важливіше, ніж зазвичай. Хост запустив ваш сервер як підпроцес і читає MCP-повідомлення з його **stdout**. Стандартний потік помилок — ваш.
Стандартна бібліотека вже робить усе правильно: за замовчуванням вивід логів іде в `sys.stderr`. Рядки `logger.info(...)` потрапляють у термінал (або туди, куди хост збирає stderr підпроцесу), а потік протоколу лишається чистим.
!!! tip
Не використовуйте `print()` у stdio-сервері. `print` пише в **stdout**, а stdout належить протоколу.
Під час обслуговування SDK перенаправляє в stderr той stdout, який справді *скинуто з буфера*, тож
пошкодити потік протоколу він не може, але `print()` у процесі з блоковою буферизацією зазвичай лежить
нескинутим у буфері `sys.stdout`, доки інтерпретатор не спорожнить його під час виходу — просто
в потік протоколу. Навіть коли його перенаправлено, рядок потрапляє у вивід логів сирим: без рівня,
без імені логера і без можливості його відфільтрувати.
`logger.debug("got here")` — той самий один рядок зусиль, але він іде куди треба.
## Рівень {#the-level}
Викликати `logging.basicConfig()` самостійно не потрібно. Конструктор `MCPServer` уже це зробив: з обробником, спрямованим у стандартний потік помилок, на рівні, який передано як `log_level=`, тож `MCPServer("Bookshop", log_level="DEBUG")` — це все, що потрібно, щоб побачити рядки `logger.debug(...)`.
Типове значення — `"INFO"`.
`logging.basicConfig()` ніколи не замінює обробники, що вже існують. Якщо налаштувати логування самостійно до створення сервера, ваше налаштування має перевагу.
## Спробуйте самі {#try-it}
Запустіть сервер з MCP Inspector:
```console
uv run mcp dev server.py
```
Викличте `search_books` на вкладці **Tools**. Inspector покаже результат: лише повернене значення. Рядок
```text
Searching for 'dune'
```
пішов у стандартний потік помилок: у термінал, а не в потік протоколу.
!!! info
Якщо насправді потрібне *трасування* (кожен запит, скільки він тривав, чи завершився помилкою),
потрібні не рядки логу, а спани. Ваш сервер уже їх генерує: SDK за замовчуванням трасує кожне
повідомлення за допомогою OpenTelemetry. Див. **[OpenTelemetry](../run/opentelemetry.md)**.
## Підсумки {#recap}
* Можливість логування протоколу MCP оголошена застарілою специфікацією 2026-07-28 і нічим не замінена. Не будуйте на ній.
* `logger = logging.getLogger(__name__)` на рівні модуля, `logger.info(...)` в інструменті. Оце й увесь шаблон.
* Вивід логів ніколи не доходить до моделі. Доходить лише значення, яке ви повертаєте через `return`.
* Стандартний потік помилок — ваш; stdout належить протоколу. Під час обслуговування SDK перенаправляє скинутий з буфера сторонній stdout у stderr, але нескинутий `print()` усе ще може вилитися в потік протоколу під час виходу, а перенаправлені рядки приходять без позначок; використовуйте `logging`, чий обробник скидає кожен запис.
* `MCPServer(..., log_level="DEBUG")` задає рівень, а налаштування логування, зроблене раніше, лишається недоторканим.
Про те, як повідомити під'єднаним клієнтам, що на сервері щось змінилося (список інструментів, ресурс), — на сторінці **[Підписки](subscriptions.md)**.

View file

@ -0,0 +1,191 @@
---
translation:
sections: [74011e683045eea9, 9b64cc175c18b6a9, 4b41be4824030397, e3b1502da786ec33, 71e41161f143c6a9, 9ec2c1eeb8c36378, 8dd027377d46448b, f81491125dcbfe8b]
tool: 1
---
# Багатораундові запити {#multi-round-trip-requests}
Інколи інструмент не може завершити роботу за один раунд обміну. Йому потрібне щось, що є лише в користувача: вибір, підтвердження, облікові дані.
До версії 2026-07-28 сервер отримував це через **зворотний** виклик: відкривав власний запит до клієнта — еліцитацію (elicitation) чи виклик семплювання (sampling) — просто посеред обробки початкового запиту. Специфікація 2026-07-28 цей зворотний канал (back-channel) прибирає.
Натомість сервер **повертає результат**.
## Повернення замість зворотного виклику {#return-dont-call-back}
На `tools/call` сервер відповідає **`InputRequiredResult`** замість `CallToolResult`. Усю роботу виконують два його поля:
* **`input_requests`**: те, чого серверу ще бракує, у вигляді словника з ключами-іменами, які обрав сам сервер. Кожне значення — це `ElicitRequest`, `CreateMessageRequest` або `ListRootsRequest`.
* **`request_state`**: непрозорий токен. Під час повторної спроби клієнт повертає його дослівно. Читає його лише ваш сервер.
Клієнт виконує кожен запит, а потім викликає **той самий інструмент ще раз**, передаючи відповіді в `input_responses`, а токен — у `request_state`. Тепер сервер має те, чого йому бракувало, і повертає звичайний `CallToolResult`.
Оце й увесь протокол. Кожен етап — це звичайний запит від клієнта до сервера. У зворотному напрямку не йде нічого.
## Серверна частина {#the-server-side}
З `@mcp.tool()` збирати це вручну доводиться рідко: оголосіть залежність — `Elicit`, щоб запитати користувача, `Sample`, щоб виконати семплювання через LLM клієнта, або `ListRoots`, щоб отримати перелік його кореневих каталогів (roots), — і SDK сам поверне `InputRequiredResult`; цю форму описано на сторінці **[Залежності](dependencies.md)**. Ці дві форми не поєднуються: у виклику є лише один канал `input_responses`/`request_state`, тож інструмент із параметрами `Resolve(...)` не може ще й повертати `InputRequiredResult` зі свого тіла. Оголошений тип повернення `InputRequiredResult` відхиляється під час реєстрації (`InvalidSignature`), а неоголошений провалює виклик під час виконання. Ручна форма — це **низькорівневий** `Server`, чий обробник `on_call_tool` може повертати будь-який із двох типів результату:
```python title="server.py" hl_lines="43-46"
--8<-- "docs_src/mrtr/tutorial001.py"
```
* `on_call_tool` має анотацію `-> CallToolResult | InputRequiredResult`. Повернути другий із них — оце й увесь серверний API.
* Під час першого виклику `params.input_responses` дорівнює `None`, тож спрацьовує перевірка й обробник запитує замість того, щоб відповідати.
* Під час повторної спроби `ElicitResult`, який надіслав клієнт, лежить під **тим самим ключем** (`"region"`), який сервер використав у `input_requests`.
Усе інше в цьому файлі (явна `input_schema`, зібраний вручну `CallToolResult`) — звичайний низькорівневий `Server`, описаний на сторінці **[Низькорівневий Server](../advanced/low-level-server.md)**. Ця сторінка додає лише другий тип повернення.
## Не лише інструменти {#beyond-tools}
`tools/call` нічим не особливий: у версії 2026-07-28 сервер може так само відповідати на `prompts/get` і `resources/read`. У `MCPServer` функція `@mcp.prompt()` — або функція-**шаблон** `@mcp.resource()` — сама повертає `InputRequiredResult` і зчитує відповіді повторної спроби з контексту:
```python title="server.py" hl_lines="20 22 24"
--8<-- "docs_src/mrtr/tutorial004.py"
```
* Перший раунд повертає `InputRequiredResult`. Під час повторної спроби `ctx.input_responses` містить відповіді під тими самими ключами, і функція повертає свій звичайний результат — тут це повідомлення промпту, а для шаблонного ресурсу — вміст ресурсу.
* Заданий вами `request_state` запечатується, перш ніж потрапити в мережу, і перевіряється, коли повертається, як і все інше на сервері; розділ **[Захист `requestState`](#protecting-requeststate)** нижче пояснює, що дає запечатування і коли потрібно налаштовувати ключі.
* Функція `@mcp.tool()` може так само повертати результат напряму, коли форма із залежностями не підходить.
* Статичні функції `@mcp.resource()` участі не беруть: вони не приймають `Context`, тож ніяк не могли б прочитати повторну спробу. Запитувати можуть лише шаблонні ресурси.
* Правила поколінь, наведені нижче, діють без змін: повернення `InputRequiredResult` у сесії, старшій за 2026, дає ту саму помилку `-32603`, яку описує попередження.
## Клієнтська частина {#the-client-side}
`Client` виконує цикл за вас.
Зареєструйте колбеки, які можуть знадобитися серверу (`elicitation_callback`, `sampling_callback`, `list_roots_callback`), і викличте інструмент. Коли надходить `InputRequiredResult`, `Client` передає кожен запис із `input_requests` відповідному колбеку, повторює виклик із відповідями та повернутим `request_state` і продовжує, доки не надійде `CallToolResult`:
```python title="client.py" hl_lines="11 12"
--8<-- "docs_src/mrtr/tutorial003.py"
```
* Цей `elicitation_callback` — той самий, у який влучив би `elicitation/create` зворотного каналу сервера до 2026. Те саме стосується `sampling_callback` для `sampling/createMessage` і `list_roots_callback` для `roots/list`: у версії 2026-07-28 окремих RPC від сервера до клієнта вже немає, але ідентичні корисні навантаження `ElicitRequest` / `CreateMessageRequest` / `ListRootsRequest` їдуть усередині `input_requests` і потрапляють до тих самих трьох колбеків. Один набір колбеків обслуговує обидва покоління.
* `call_tool` повертає звичайний `CallToolResult`. Проміжні раунди для того, хто викликає, невидимі.
* `get_prompt` і `read_resource` запускають той самий цикл.
!!! check
Не зареєструйте колбек — і цикл зламається вже на першому раунді: колбек-заглушка SDK
відповідає на кожну еліцитацію помилкою, а `call_tool` викидає `MCPError` з повідомленням
*«Elicitation not supported»*.
Цикл обмежений. `Client(..., input_required_max_rounds=10)` — це ліміт за замовчуванням; якщо сервер і після нього продовжує повертати `InputRequiredResult`, `call_tool` викидає виняток. Якщо раунд містить лише `request_state` без `input_requests`, `Client` робить коротку паузу (50 мс, що подвоюється до стелі 250 мс) перед повторною спробою, щоб сервер, який лише каже *«ще не готово»*, не засипали безперервними опитуваннями.
### Керування циклом власноруч {#driving-the-loop-yourself}
Автоматичного циклу достатньо для клієнта в одному процесі. Беріть цикл у свої руки, коли:
* Клієнт **розподілений**: процес, що показує запитання користувачеві, — не той процес, що викликав `call_tool`, тож повторну спробу надсилає інший робочий процес. `request_state` — це придатний до зберігання токен, який ви переносите через цю межу у власному сховищі, а `input_responses` — те, що інша сторона надсилає разом із ним.
* Потрібно **перевіряти** кожен раунд: логувати чи аудіювати кожен запис `input_requests`, відхиляти певні види запитів або застосовувати власну затримку між етапами.
* Потрібне обмеження за **реальним часом**, а не за кількістю раундів: обгорніть власний цикл у `anyio.fail_after(...)` замість того, щоб покладатися на `input_required_max_rounds`.
Спустіться до сесії рівнем нижче, де `allow_input_required=True` віддає вам об'єднання типів напряму:
```python title="client.py" hl_lines="12 13 19"
--8<-- "docs_src/mrtr/tutorial002.py"
```
* `client.session.call_tool(..., allow_input_required=True)` розширює тип повернення до `CallToolResult | InputRequiredResult`. Звужує його назад саме `isinstance`.
* `request_state` тепер у ваших руках. Збережіть його між етапами — і розмову можна продовжити з нового процесу.
* Для кожного запису в `input_requests` ви кладете `InputResponse` під **тим самим ключем** в `input_responses`. `fulfil` — місце для вашого UI; тут відповідь жорстко закодована.
* Та сама назва інструмента, ті самі `arguments` на кожному етапі. Повторна спроба — це той самий початковий виклик, виконаний ще раз, а не новий метод.
## Захист `requestState` {#protecting-requeststate}
Усе сказане вище трактує `request_state` як відлуння, і в переданих даних це справді все, чим він є. Але між етапами його тримає клієнт (а зберігати його між процесами — саме те, що схвалив попередній розділ), тож назад приходять **дані, надані клієнтом**: їх могли змінити, вони могли прострочитися або взагалі бути взяті з іншого виклику. Специфікація вимагає, щоб сервери захищали цілісність цього стану й відхиляли раунд, якщо перевірка не пройшла, — щоразу, коли стан може впливати на авторизацію, доступ до ресурсів або бізнес-логіку.
`MCPServer` захищає його за замовчуванням. Кожен сервер запечатує вихідний `requestState` і перевіряє кожне відлуння — і стан резолверів, і стан, зібраний вручну, — ключем, згенерованим під час запуску процесу. Ви нічого не налаштовуєте, пишете відкритий текст і читаєте відкритий текст; мережею завжди передається лише непрозорий зашифрований токен.
Ключ за замовчуванням живе й помирає разом із процесом — і це єдине, що треба знати перед розгортанням поза межами одного процесу:
```python
from mcp.server.mcpserver import MCPServer, RequestStateSecurity
# Multi-instance or restart-surviving: one or more shared secret keys (>= 32 bytes each).
mcp = MCPServer("fleet", request_state_security=RequestStateSecurity(keys=[key]))
```
* **Варіант за замовчуванням (без налаштування)** підходить для одного процесу: stdio або рівно один робочий процес HTTP. Повторна спроба, що потрапляє на інший робочий процес, інший екземпляр за балансувальником навантаження чи на той самий сервер після перезапуску, запечатана ключем, якого цей процес не має, — клієнт отримує незмінну відмову, наведену нижче, і мусить почати процедуру спочатку.
* **`keys=[...]`** обов'язковий щоразу, коли повторна спроба може дістатися **іншого екземпляра** (`uvicorn` із кількома робочими процесами, HTTP за балансувальником) або має переживати перезапуски: кожен екземпляр перевіряє те, що випустив будь-який інший. Той самий механізм, лише ваш секрет замість згенерованого.
* Для власної криптографії, наприклад KMS чи наявного сервісу токенів, передайте `RequestStateSecurity(codec=...)` замість `keys`; контракт описано нижче в розділі **[Власна криптографія](#bring-your-own-crypto)**.
### Що містить запечатаний токен {#what-the-seal-carries}
За замовчуванням чи з налаштуванням, у переданих даних `requestState` — це зашифрований автентифікований токен. Ваш код його ніколи не бачить: обробники й резолвери пишуть відкритий текст і читають відкритий текст (`ctx.request_state`); SDK запечатує на виході й перевіряє на вході. Окрім цілісності, кожен токен прив'язаний до:
* **Часового вікна.** Кожен раунд запечатує заново зі свіжим терміном дії, тож `RequestStateSecurity(ttl=...)` (за замовчуванням 600 секунд) обмежує час на роздуми в межах одного раунду, а не всю процедуру.
* **Автентифікованого принципала.** Коли запит містить токен доступу OAuth, який перевірив SDK, стан прив'язується до клієнта, видавця й суб'єкта токена: стан, випущений для одного користувача, не пройде перевірку в іншого, навіть якщо обидва користувачі мають спільний OAuth-клієнт. Верифікатор, що не надає суб'єкта, послаблює прив'язку до самої лише ідентичності клієнта, яку за URL-ідентифікаторів клієнтів поділяють усі користувачі цього клієнтського ПЗ. Коли автентифікація завершується поза SDK (на проксі попереду) або транспорт не автентифікований, прив'язувати немає до кого, і ця перевірка бездіяльна — хіба що `RequestStateSecurity(bind_principal=...)` надасть принципала з вашого власного сигналу ідентичності. Хай які компоненти надає ваш верифікатор токенів, він має надавати їх послідовно: верифікатор, що додає суб'єкта в одних запитах і пропускає в інших, змінює принципала посеред процедури, і незавершені раунди відхиляються.
* **Початкового запиту.** Метод, назва інструмента чи промпту (або URI ресурсу) і дайджест аргументів. Токен, відтворений для іншого інструмента, інших аргументів чи іншого методу, не проходить перевірку.
* **Точного поставленого запитання.** Кожна відповідь резолвера прикріплена до сформованого запитання, яке показали клієнту, — і в раунді, коли вона щойно надійшла, і коли записану відповідь використовують повторно пізніше. Розгорніть нову версію з переформульованим повідомленням чи зміненою схемою — і сервер перепитає, замість того щоб спожити застарілу відповідь. Та сама прив'язка працює й у зворотний бік: виводьте повідомлення з аргументів інструмента, а не з даних конкретного виклику. Повідомлення, побудоване з мітки часу чи поточного курсу, формується по-різному в кожному раунді, тож кожна записана відповідь здається застарілою, і сервер перепитує, доки ліміт раундів клієнта не завершить виклик.
Усе це — робота SDK, а не ваша і не кодека, якщо ви приносите власний.
### Ротація ключів {#rotating-keys}
`keys[0]` запечатує новий стан; перевіряє кожен ключ зі списку. Ротація без простою — це три фази, кожну з яких повністю розгортають перед наступною:
```python
RequestStateSecurity(keys=[OLD, NEW]) # 1: every instance learns to verify NEW; OLD still mints
RequestStateSecurity(keys=[NEW, OLD]) # 2: NEW mints; in-flight OLD state keeps verifying
RequestStateSecurity(keys=[NEW]) # 3: one ttl after phase 2 is fully out, retire OLD
```
Ніколи не ставте новий ключ на випуск першим кроком: випуск під ключем, який котрийсь екземпляр іще не вміє перевіряти, обриває незавершені раунди посеред розгортання.
Ключі обмежені одним сервісом. Запечатаний конверт також несе назву сервера як твердження про аудиторію (audience claim), тож токен, випущений іншим сервісом, який випадково має той самий секрет, усе одно відхиляється. Твердження розрізняє сервіси рівно настільки, наскільки розрізняються назви, тож сервер з явно заданою політикою мусить мати справжню назву або задати `RequestStateSecurity(audience=...)` — безіменний викидає виняток під час створення. `audience=` також слугує навмисним багатосервісним топологіям, де один сервіс має приймати стан, випущений іншим. (На варіант за замовчуванням без налаштування це не поширюється: його ключ ніколи не покидає процес, тож твердженню про аудиторію нема чого додати.)
### Власна криптографія {#bring-your-own-crypto}
`RequestStateSecurity(codec=...)` приймає будь-що з методами `seal(bytes) -> str` і `unseal(str) -> bytes`, що викидає `InvalidRequestState` для будь-якого токена, якого не випускало. Класична форма — конвертне шифрування з KMS, коли ви один раз розгортаєте ключ даних під час запуску, а криптографію для кожного токена виконуєте локально:
```python title="server.py" hl_lines="12 26-27 34-35 38"
--8<-- "docs_src/mrtr/tutorial005.py"
```
TTL, прив'язка до принципала й прив'язка до запиту — **не** робота кодека: SDK вписує їх у корисне навантаження перед `seal` і перевіряє заново після `unseal` для кожного кодека. Єдині обов'язки кодека — цілісність (підроблено — отже, виняток) і, в ідеалі, конфіденційність.
### Коли перевірка не проходить {#when-verification-fails}
Кожна вхідна невдача — підробка, прострочення, відтворення для іншого запиту чи принципала або запечатування невідомим цьому серверу ключем — отримує ту саму відповідь:
```json
{"code": -32602, "message": "Invalid or expired requestState"}
```
Одне незмінне повідомлення на всі причини, щоб передані дані ніколи не виказували, яка саме перевірка не пройшла; справжня причина потрапляє в лог сервера. Перевіряється кожен вхідний `requestState` у `tools/call`, `prompts/get` і `resources/read`, зокрема й той, що надходить для обробника, який ніколи не випускає стан. Найчастіша відмова на практиці — не зловмисник, а локальний для процесу ключ за замовчуванням, який зустрічає повторну спробу з-перед перезапуску чи з іншого екземпляра; клієнт починає процедуру спочатку, а `keys=[...]` — виправлення на випадок, коли це важливо.
### Стан, зібраний вручну {#hand-built-state}
`request_state`, який ви задаєте самі (повертаючи `InputRequiredResult` з функції інструмента, промпту чи шаблону ресурсу), запечатує й перевіряє той самий механізм, що й стан резолверів, без жодних змін у коді: пишете відкритий текст, читаєте відкритий текст, і всі наведені вище прив'язки діють.
Єдине, що SDK не може закріпити за вас навіть із налаштуванням, — це ідентичність запитання: він не знає, якому з *ваших* запитань належить відповідь у вашому стані. Якщо ви зберігаєте відповіді з ключами-запитаннями, додайте до стану власний ідентифікатор запитання й перевіряйте його під час повторної спроби.
Низькорівневий `Server` — це рівень без батарейок у комплекті: на відміну від `MCPServer`, тут нічого не запечатується, доки ви самі не додасте межу, а до того ваш `request_state` передається мережею рівно так, як написаний. Однорядкове ввімкнення показано на сторінці **[Низькорівневий Server](../advanced/low-level-server.md#the-other-handlers)**.
## Результат версії 2026-07-28 {#a-2026-07-28-result}
`InputRequiredResult` існує лише у версії протоколу **2026-07-28**. `Client(server)` у пам'яті узгоджує її за вас; мережею її виявляє `mode="auto"`. Після під'єднання `client.protocol_version` покаже, що саме ви отримали.
!!! warning
У сесії, старшій за 2026, `InputRequiredResult` просто нікуди покласти. Поверніть його з обробника на
з'єднанні `mode="legacy"` — і виконавець не зможе серіалізувати його в узгоджену версію;
клієнт отримає помилку `-32603` *«Handler returned an invalid result»*. Сервер, що обслуговує
обидва покоління, мусить перевірити `ctx.protocol_version`, перш ніж братися за нього.
!!! info
**Еліцитація в режимі URL** на з'єднанні 2026 їде саме цим механізмом. Запис у
`input_requests` — це `ElicitRequest`, чиї params є `ElicitRequestURLParams`; користувач
завершує позасмугову процедуру, і ваш клієнт повторює виклик. Той самий цикл, жодного нового API.
Половина про високорівневий сервер — на сторінці **[Еліцитація](elicitation.md)**.
## Підсумки {#recap}
* У версії 2026-07-28 сервер, якому посеред виклику потрібні дані, **повертає** `InputRequiredResult`. Він ніколи не відкриває запит до клієнта.
* `input_requests` — це те, що йому потрібно. `request_state` — непрозорий токен відновлення, який читає лише сервер.
* `Client` виконує цикл повторних спроб за вас: зареєструйте `elicitation_callback` / `sampling_callback` / `list_roots_callback` — і `call_tool` повертає звичайний `CallToolResult`. Обмежує його `input_required_max_rounds` (за замовчуванням 10).
* Щоб перевіряти або зберігати раунди, використовуйте `client.session.call_tool(..., allow_input_required=True)` і ведіть цикл `while isinstance(result, InputRequiredResult)` власноруч.
* З `@mcp.tool()` цей результат за вас створює залежність, яка запитує користувача (**[Залежності](dependencies.md)**); ручна форма — **низькорівневий** `Server`.
* Промпти й ресурси теж беруть участь: функція `@mcp.prompt()` або шаблонна `@mcp.resource()` сама повертає `InputRequiredResult` і читає `ctx.input_responses` під час повторної спроби.
* `requestState` повертається як дані, надані клієнтом, тож `MCPServer` за замовчуванням запечатує його — і стан резолверів, і стан, зібраний вручну, — локальним для процесу ключем; у розгортаннях із кількома екземплярами передавайте `RequestStateSecurity(keys=[...])` (або власний кодек), щоб кожен екземпляр міг перевірити те, що випустив інший. Запечатування прив'язує кожен токен до часового вікна, початкового запиту й автентифікованого принципала — коли запит містить автентифікацію, яку перевірив SDK, або коли `bind_principal=` надає ваш власний сигнал ідентичності (**[Захист `requestState`](#protecting-requeststate)**).
Саме цей механізм замінює ініційоване сервером семплювання та решту зворотного каналу в стилі push; див. **[Застарілі можливості](../deprecated.md)**.

View file

@ -0,0 +1,122 @@
---
translation:
sections: [5315262fe26b33e1, 9d8e98840f1b78f0, 0284b215e85366c4, 8534d8dbb4053a70, 2966fac6fe697007]
tool: 1
---
# Перебіг виконання {#progress}
Інструмент, який працює тридцять секунд і всі тридцять секунд мовчить, здається зламаним.
**Сповіщення про перебіг виконання** це виправляють. Інструмент повідомляє, скільки вже зроблено, а клієнт вирішує, що з цього намалювати: смужку, спінер чи рядок у лозі.
## Надсилання з інструмента {#report-it-from-the-tool}
Додайте параметр **`Context`** і викличте `report_progress`:
```python title="server.py" hl_lines="8 11"
--8<-- "docs_src/progress/tutorial001.py"
```
Три аргументи, а їхній зміст визначаєте ви:
* `progress`: скільки вже зроблено. Специфікація вимагає, щоб значення **зростало** з кожним звітом; ніколи не повторюйте значення й не зменшуйте його.
* `total`: скільки роботи всього, якщо це відомо. Необов'язковий.
* `message`: один зрозумілий людині рядок про *цей* крок. Необов'язковий.
`ctx` впроваджується завдяки анотації типів, і модель його ніколи не бачить: у вхідній схемі `import_catalog` є лише одна властивість — `urls`. Сторінка **[Об'єкт Context](context.md)** цілком присвячена цьому об'єкту; звітування про перебіг — лише одна з його функцій.
## Отримання на клієнті {#listen-for-it-from-the-client}
Клієнт підписується **окремо для кожного виклику**, передаючи `progress_callback=` у `call_tool`:
```python title="client.py" hl_lines="7 16"
import anyio
from mcp import Client
from server import mcp
async def show(progress: float, total: float | None, message: str | None) -> None:
print(f"{message} ({progress}/{total})")
async def main() -> None:
async with Client(mcp) as client:
result = await client.call_tool(
"import_catalog",
{"urls": ["https://example.com/a.json", "https://example.com/b.json"]},
progress_callback=show,
)
print(result.structured_content)
anyio.run(main)
```
Колбек — це `async`-функція, яка приймає рівно те, що повідомив сервер: `progress`, `total`, `message`.
!!! info
`Client(mcp)` під'єднується безпосередньо до об'єкта сервера, у пам'яті, — це той самий клієнт, на якому
побудована сторінка **[Тестування](../get-started/testing.md)**. Параметр `progress_callback` однаковий
незалежно від транспорту, який використовує `Client`; а от *хронометраж*, який ви зараз побачите,
властивий саме з'єднанню в пам'яті. Воно запускає колбек одразу на місці, тож кожен звіт надходить до
того, як `call_tool` поверне результат. На справжньому транспорті сповіщення змагаються з результатом,
і повільний колбек може ще виконуватися після того, як `call_tool` уже повернув результат.
### Спробуйте самі {#try-it}
Покладіть `client.py` поруч із `server.py` і запустіть:
```console
python client.py
```
```text
Imported https://example.com/a.json (1/2)
Imported https://example.com/b.json (2/2)
{'result': 'Imported 2 records.'}
```
Кожен `await ctx.report_progress(...)` на сервері перетворився на один виклик `show` на клієнті, у тому самому порядку, і обидва рядки надрукувалися **до** того, як `call_tool` повернув результат. Перебіг не пакується в результат — він надходить потоком, поки інструмент іще працює.
!!! warning
`progress_callback` належить **виклику**, а не `Client`. Аргументу конструктора для нього немає,
бо різним викликам потрібні різні колбеки: один рухає смужку завантаження, наступний — пише
рядок у лог.
!!! check
Тепер видаліть `progress_callback=show` і запустіть знову:
```text
{'result': 'Imported 2 records.'}
```
Ні помилки, ні попередження, той самий результат. `report_progress` **нічого не робить, коли той,
хто викликає, не просив звітів про перебіг**, тож звітуйте безумовно й ніколи не замислюйтеся,
чи хтось слухає.
## Коли загальний обсяг невідомий {#when-you-dont-know-the-total}
`total` — для випадків, коли знаменник відомий. Часто це не так: ви вичерпуєте стрічку, проходите курсором, завантажуєте щось без заголовка довжини.
Просто не вказуйте його:
```python title="server.py" hl_lines="20"
--8<-- "docs_src/progress/tutorial002.py"
```
Колбек отримує `total=None`. Клієнт усе ще може показувати *активність* («уже імпортовано 3...»), але не відсоток. Не вигадуйте загальний обсяг заради гарнішої смужки.
!!! tip
`progress` не мусить рахувати щось конкретне. Байти, рядки, сторінки — оберіть одиницю, яку
впізнає користувач, і обіцяйте лише той `total`, якого зможете дотриматися.
## Підсумки {#recap}
* `await ctx.report_progress(progress, total=None, message=None)` з будь-якого інструмента, що приймає `Context`.
* Клієнт передає `progress_callback=` у `call_tool`: для кожного виклику окремо, ніколи не в `Client`.
* Колбек має вигляд `async (progress, total, message) -> None` і спрацьовує, поки інструмент іще виконується.
* Немає колбека у виклику — `report_progress` нічого не робить. Звітуйте безумовно.
* Не вказуйте `total`, коли він невідомий; колбек отримає `None`.
Перебіг виконання — це те, що інструмент під час роботи показує *користувачеві*. Рядки, які він записує в лог для *вас*, людини, що експлуатує сервер, — це інший канал: **[Логування](logging.md)**.

View file

@ -0,0 +1,51 @@
---
translation:
sections: [5c82b20cbd65ded0, 9dc22632be79a533, 1fb8f452e990c456, 42666ab914ff0cb1, c4e0cb3667fd5ff9]
tool: 1
---
# Семплювання та кореневі каталоги {#sampling-and-roots}
Обробник може попросити в під'єднаного клієнта ще дві речі: завершення від власної моделі клієнта (**семплювання** (sampling)) і робочі теки клієнта (**кореневі каталоги** (roots)).
Обидві можливості досі працюють на кожній версії протоколу, яку підтримує SDK. Але перш ніж будувати на них дизайн, прочитайте попередження:
!!! warning "Оголошено застарілими у специфікації 2026-07-28"
Семплювання та кореневі каталоги є застарілими починаючи з `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2577)). Вони лишаються повністю працездатними й залишатимуться у специфікації щонайменше дванадцять місяців, перш ніж їх можна буде вилучити, але нові реалізації не повинні на них спиратися. Запропоновані шляхи міграції: замість семплювання інтегруйтеся безпосередньо з API вашого постачальника LLM, а замість кореневих каталогів передавайте каталоги через параметри інструментів, URI ресурсів або конфігурацію сервера. Повний перелік для всього SDK — на сторінці **[Застарілі можливості](../deprecated.md)**.
## Семплювання: позичити модель клієнта {#sampling-borrow-the-clients-model}
Резолвер повертає `Sample(...)`, а інструмент отримує завершення — через той самий механізм залежностей, що виконує `Elicit` на сторінці **[Залежності](dependencies.md)**:
```python title="server.py" hl_lines="10-15 19"
--8<-- "docs_src/sampling_and_roots/tutorial001.py"
```
* `Sample(messages, max_tokens=...)` повторює параметри `sampling/createMessage`. Впроваджене значення — це `CreateMessageResult` від клієнта; передайте `tools` або `tool_choice`, і натомість воно стане `CreateMessageResultWithTools`.
* Клієнт мусить оголосити можливість `sampling` (`sampling.tools`, якщо передаєте `tools` або `tool_choice`). Якщо він цього не зробив, виклик завершується помилкою протоколу `-32021`, замість того щоб надсилати запит, який клієнт не здатен обробити. Сесія до 2026 року без зворотного каналу (back-channel) завершується своєю звичною помилкою про відсутність зворотного каналу, бо надсилати нема куди.
* На `2026-07-28` запит доставляється всередині багатораундового потоку (**[Багатораундові запити](multi-round-trip.md)**); на `2025-11-25` це окремий запит до клієнта. Код однаковий в обох випадках, але пам'ятайте правило багатораундових запитів: запит має відтворюватися ідентично в усіх раундах повторних спроб, тож будуйте його лише з аргументів інструмента та інших стабільних даних.
* Не чіпайте `include_context`: значення, відмінні від `"none"`, самі є застарілими (SEP-2596) і потребують можливості, яку майже жоден клієнт не оголошує.
## Кореневі каталоги: куди це покласти? {#roots-where-should-this-go}
Кореневі каталоги — це теки, над якими, за словами клієнта, сервер може працювати. Це довідкова підказка, а не механізм контролю доступу. Резолвер повертає `ListRoots()`:
```python title="server.py" hl_lines="10-11 15"
--8<-- "docs_src/sampling_and_roots/tutorial002.py"
```
* Впроваджений `ListRootsResult` містить список об'єктів `Root`: URI `file://` і необов'язкове відображуване ім'я.
* Перевірка та сама, що й для семплювання: без оголошеної можливості `roots` виклик завершується помилкою `-32021` замість надсилання запиту.
По той бік з'єднання клієнт відповідає на обидва запити колбеками, які в нього вже є: `sampling_callback` і `list_roots_callback`, описаними на сторінці **[Колбеки клієнта](../client/callbacks.md)**.
## На з'єднаннях покоління 2025 {#on-2025-era-connections}
`ctx.session.create_message(...)` і `ctx.session.list_roots()` досі існують для коду, що керує сесією напряму. Вони працюють лише там, де є зворотний канал (з'єднання покоління 2025, не безстанові), а їх виклик викидає попередження про застарілість. Маркери резолверів, описані вище, — це підтримувана форма: вони обирають спосіб доставки за узгодженою версією й не попереджають.
## Підсумки {#recap}
* Повертайте `Sample(...)` або `ListRoots()` з резолвера; інструмент отримує `CreateMessageResult` або `ListRootsResult`, як і будь-яку іншу залежність.
* Клієнт мусить оголосити відповідну можливість, інакше виклик завершується помилкою `-32021` замість надсилання запиту.
* Обидві можливості оголошено застарілими у `2026-07-28`: поки що повністю працездатні, але непридатні для нових проєктів. Надавайте перевагу API постачальника над семплюванням і явним параметрам над кореневими каталогами.
Як повідомляти, наскільки просунувся повільний інструмент: **[Перебіг виконання](progress.md)**.

View file

@ -0,0 +1,152 @@
---
translation:
sections: [60a9de8a0bdaa531, 317bbe7e4355cdcc, a61d660c8029e04a, 8f7e82fcb88df8a9, b165db51249ff8ed, 266f56fb798068a4, 7c0e57030b622139, df18d7c2417a9883]
tool: 1
---
# Підписки {#subscriptions}
Каталог сервера не є незмінним. Інструменти з'являються під час роботи, а вміст за URI ресурсу змінюється.
**Підписки** — це спосіб, у який клієнт про це дізнається. Клієнт надсилає один запит `subscriptions/listen`, і відповідь на цей запит *і є* потоком: він залишається відкритим і несе сповіщення про зміни, про які попросив клієнт.
## Публікація з інструмента {#publish-it-from-the-tool}
Ваша частина роботи — один рядок: опублікувати зміну.
```python title="server.py" hl_lines="20 32"
--8<-- "docs_src/subscriptions/tutorial001.py"
```
* `await ctx.notify_resource_updated("board://sprint")` доходить до кожного відкритого потоку, підписаного на цей URI. І більше ні до кого.
* `await ctx.notify_tools_changed()` доходить до кожного потоку, який попросив про зміни списку інструментів. Клієнт, що його отримав, знову викликає `tools/list` і тепер бачить `sprint_report`.
* Споріднені методи — `notify_prompts_changed()` і `notify_resources_changed()`.
* Немає підписників — немає роботи. Публікація на сервері без слухачів нічого не робить, тож перевіряти, чи хтось слухає, ніколи не потрібно. Ви лише повідомляєте, що змінилося.
`MCPServer` обслуговує `subscriptions/listen` за вас. Зобов'язання на рівні протоколу (підтвердження першим кадром, фільтрація для кожного потоку, ідентифікатор підписки в кожному кадрі) — справа SDK.
!!! check
У переданих даних потік, у фільтрі якого вказано `board://sprint`, після виконання `complete_task` має такий вигляд:
```json
{"method": "notifications/subscriptions/acknowledged",
"params": {"notifications": {"resourceSubscriptions": ["board://sprint"]}, "_meta": {"io.modelcontextprotocol/subscriptionId": "listen-1"}}}
{"method": "notifications/resources/updated",
"params": {"uri": "board://sprint", "_meta": {"io.modelcontextprotocol/subscriptionId": "listen-1"}}}
```
Зверніть увагу, чого оновлення *не* несе: самої дошки. Кожен кадр містить JSON-RPC-ідентифікатор запиту listen у `_meta`, і цей ідентифікатор є ідентифікатором підписки. Його створює клієнт: `Client` на Python використовує рядки на кшталт `"listen-1"`; інші клієнти можуть використовувати цілі числа.
## Лише те, про що попросили {#only-what-was-asked-for}
Фільтр — це контракт. Потік, що запросив зміни списку інструментів і один URI ресурсу, отримує ці два різновиди й нічого більше. Опублікуйте зміну промпту — і цей потік мовчатиме.
`MCPServer` зіставляє URI ресурсів як точні рядки, тож потік, у якому вказано `board://sprint`, нічого не почує про `board://sprint/tasks/1`. Специфікація дозволяє серверу повідомляти про зміну підресурсу URI, на який оформлено підписку; `MCPServer` цього ніколи не робить, але клієнти побудовані так, щоб на це очікувати.
Дві речі, якими потік *не* є:
* **Це не журнал для повторного відтворення.** Обірваний потік зникає, а події, опубліковані, поки ніхто не був під'єднаний, у чергу не ставляться. Клієнти знову починають слухати й заново отримують дані.
* **Це не шлях покоління 2025.** Клієнтів, що викликали `resources/subscribe`, обслуговує `ctx.session.send_resource_updated(uri)`. Методи `notify_*` доходять лише до потоків `subscriptions/listen`.
## Хто має право спостерігати {#deciding-who-may-watch}
За замовчуванням задовольняється кожен запитаний різновид і URI: будь-хто може спостерігати за будь-яким URI, який ви публікуєте. Ваш обробник читання ніхто не викликає, бо ніхто нічого не читає — той, кого ваш обробник `files://{name}` відхилив би, усе одно може відкрити потік на `files://payroll.csv` і дізнатися, що файл змінився і коли. Вмісту він ніколи не дізнається й не може з'ясувати, що існує, бо невідомий URI теж задовольняється й просто ніколи не спрацьовує. Витік вузький, але реальний, тож поставте перевірку доступу, перш ніж публікувати URI окремих користувачів із сервера з кількома орендарями.
Ця перевірка — middleware (проміжний шар). Воно бачить запит `subscriptions/listen` до того, як SDK його підтвердить, і відмовляє, коли хтось просить те, чого не має права читати:
```python title="server.py" hl_lines="19-26 29"
--8<-- "docs_src/subscriptions/tutorial006.py"
```
* `ctx.params` — це сирий запит, тому middleware саме перевіряє його в `SubscriptionsListenRequestParams` і читає фільтр, який запросив клієнт.
* Відмова — це викинутий `MCPError` перед `call_next(ctx)`: клієнт отримує цю помилку й жодного потоку, а з'єднання живе далі. Тримайте повідомлення однаковим, без згадки URI, щоб відмова ніколи не підтверджувала, які саме URI захищені.
* Одна функція `can_access(user, uri)` відповідає на обидва питання. Обробник ресурсу викликає її на `resources/read`; middleware — на `subscriptions/listen`. Замініть таблицю базою даних або своєю системою RBAC, і обидва шляхи залишаться узгодженими.
* Рішення діє протягом усього життя потоку. Повторної перевірки для кожної події немає, тож якщо доступ може минути посеред потоку (токен, строк дії якого спливає), розірвіть з'єднання цього клієнта, коли це станеться.
Повний контракт middleware, зокрема що ще воно обгортає і чому позначене як попереднє, описано на сторінці **[Middleware](../advanced/middleware.md)**.
## Клієнтський бік {#the-client-end}
Ось клієнт на іншому кінці цього потоку, що стежить за дошкою:
```python title="client.py" hl_lines="15"
--8<-- "docs_src/subscriptions/tutorial003.py"
```
Вхід у `client.listen(...)` надсилає запит і чекає на ваше підтвердження, тож на початку блоку потік уже працює, а кожна типізована подія — це сигнал отримати дані заново, а не корисне навантаження. Оце й увесь контракт на одному екрані. Усе інше про клієнтський бік живе на окремій сторінці: спостереження поруч з основним потоком виконання, завершення потоків і повторне прослуховування. Див. **[Підписки](../client/subscriptions.md)** у розділі *Клієнти*.
## Масштабування за межі одного процесу {#scaling-past-one-process}
Публікації йдуть від обробника до відкритих потоків через `SubscriptionBus`. Типова шина живе в пам'яті: один процес і всі потоки в ньому. Це правильна відповідь, доки ви не запускаєте репліки за балансувальником навантаження, бо тоді потік клієнта прив'язаний до однієї репліки, а публікація на іншій репліці має до нього дійти.
Цей стик реалізуєте ви: два методи поверх вашого бекенда pub/sub.
```python
from collections.abc import Callable
from redis.asyncio import Redis
from mcp.server.mcpserver import MCPServer
from mcp.server.subscriptions import ServerEvent # SubscriptionBus is a Protocol: no base class
class RedisSubscriptionBus:
def __init__(self, redis: Redis) -> None:
self._redis = redis
self._listeners: dict[object, Callable[[ServerEvent], None]] = {}
async def publish(self, event: ServerEvent) -> None:
await self._redis.publish("mcp-events", encode(event)) # to every replica
def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]:
token = object()
self._listeners[token] = listener
def unsubscribe() -> None:
self._listeners.pop(token, None)
return unsubscribe
mcp = MCPServer("Sprint Board", subscriptions=RedisSubscriptionBus(redis))
```
`encode` — ваша функція, як і задача-читач на кожній репліці, що декодує вхідні повідомлення й викликає кожного зареєстрованого слухача. Слухачі синхронні, не повинні викидати винятків і виконуються в циклі подій сервера.
Шина несе типізовані значення `ServerEvent` — чотири невеликі dataclass-класи, ніколи не JSON-RPC. Проставлення ідентифікаторів, фільтрація та життєві цикли потоків залишаються в SDK, тож реалізація шини не може зламати протокол. Вона може лише переносити події між процесами.
Щоб публікувати поза запитом, створіть шину самі, аби мати на неї посилання. `MCPServer` будує її всередині, коли ви нічого не передаєте, і не надає до неї доступу.
```python
from mcp.server.subscriptions import InMemorySubscriptionBus, ToolsListChanged
bus = InMemorySubscriptionBus()
mcp = MCPServer("Sprint Board", subscriptions=bus)
async def tools_reloaded() -> None:
await bus.publish(ToolsListChanged()) # from a lifespan task, a webhook, anywhere
```
## Низькорівнева композиція {#the-low-level-composition}
На низькорівневому `Server` нічого заздалегідь не під'єднано, і ті самі частини збираються в три рядки:
```python title="server.py" hl_lines="8-9 47"
--8<-- "docs_src/subscriptions/tutorial002.py"
```
* Шина належить вам, тож ви публікуєте в неї напряму: `await bus.publish(ResourceUpdated(uri=...))`. Розмістіть її там, де до неї дістануться обробники: тут — на рівні модуля, у більшому застосунку — у життєвому циклі (lifespan).
* `ListenHandler(bus)` — той самий обробник, що його реєструє `MCPServer`, а `on_subscriptions_listen=` — звичайний слот обробника. Поставте в цей слот власний викликний об'єкт для іншої семантики, і зобов'язання специфікації переходять до вас: спочатку підтвердити, проставити в кожному кадрі ідентифікатор підписки, не доставляти нічого поза фільтром.
* `ListenHandler.close()` коректно завершує кожен відкритий потік. Кожен отримує результат запиту listen останнім кадром — так специфікація каже, що сервер свідомо завершив підписку. Метод повертається раніше, ніж ці потоки встигнуть усе надіслати, тож дайте їм мить, перш ніж згортати транспорт. Без нього потоки завершуються, коли клієнт від'єднується.
## Підсумки {#recap}
* Клієнт погоджується одним запитом `subscriptions/listen`, і відповідь — це потік. Його обслуговування вбудоване.
* Ви публікуєте через `ctx.notify_*`, а SDK проставляє ідентифікатори, фільтрує й керує життєвим циклом.
* Події — це сигнали, а не корисне навантаження. Обидва кінці отримують дані заново.
* Клієнтський бік — це `async with client.listen(...)`: докладніше — на сторінці **[Підписки](../client/subscriptions.md)** у розділі *Клієнти*.
* На низькорівневому `Server` ті самі частини ви збираєте самі: шина, `ListenHandler(bus)`, слот `on_subscriptions_listen`.
* Горизонтальне масштабування — це реалізація `SubscriptionBus` (два методи) і передавання її як `MCPServer(subscriptions=...)`.
Як запустити сервер, що все це обслуговує, за однією реплікою чи за двадцятьма, — на сторінці **[Розгортання й масштабування](../run/deploy.md)**.