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,135 @@
---
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)](elicitation.md)**.
* `ctx.session`: серверная сторона разговора с этим клиентом. Здесь живут уведомления, которые вы отправляете клиенту; последний раздел её использует.
* `ctx.headers`: заголовки запроса, которые передал транспорт, или `None` на stdio. Прочитать нестандартный заголовок можно так: `(ctx.headers or {}).get("x-...")`. Заголовки — это данные от клиента: годятся для локали или флага возможности, но никогда для идентификации.
* `ctx.request_context`: сырая запись о текущем запросе. Поле, к которому вы будете обращаться, — `lifespan_context`, объект, который вернул ваш код запуска (см. **[Жизненный цикл (lifespan)](lifespan.md)**).
Логирования в этом списке нет намеренно. Сервер пишет логи через модуль Python `logging`, как любая другая программа на 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` — то, что вернул ваш код запуска.
* `await ctx.read_resource(uri)` позволяет инструменту читать собственные ресурсы сервера.
* `ctx.session` — канал обратно к клиенту: `send_tool_list_changed()` и родственные методы велят ему заново запросить изменённый список.
* Отчёты о ходе выполнения и элицитация тоже начинаются с `Context`; у каждой темы своя страница.
Параметры, которых модель никогда не видит и которые заполняют ваши собственные функции, — это **[Зависимости](dependencies.md)**.

View file

@ -0,0 +1,167 @@
---
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) со страницы **[Элицитация](elicitation.md)**, запущенный за вас:
```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`).
* Всё, что сказано о вопросах в блоке info выше, применимо без изменений: запрос `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` существует только при accept.
* `await ctx.elicit(message, schema=Model)` спрашивает изнутри тела инструмента, а `await ctx.elicit_url(message, url, elicitation_id)` — для всего, что не должно проходить через модель (`ctx.session.send_elicit_complete(elicitation_id)` сообщает, что внешняя часть завершена). Оба — запросы от сервера к клиенту: клиент должен быть на подключении старого поколения.
* Клиент отвечает одним `elicitation_callback`, ветвясь по типу params; его регистрация и объявляет возможность.
* На подключении 2026-07-28 сервер возвращает вопрос, а не отправляет его сам; тот же колбэк получает вопросы через **[Многораундовые запросы](multi-round-trip.md)**.
Всё, что стоит за этим возвратом (цикл повторных попыток, защита `requestState`, самостоятельное управление процессом), — на странице **[Многораундовые запросы](multi-round-trip.md)**.

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)](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) {#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`), сэмплирует LLM клиента (`Sample`) или получает список его корневых каталогов (roots; `ListRoots`), — и 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), поэтому токен, выпущенный другим сервисом, который случайно использует тот же секрет, всё равно отклоняется. Это утверждение отличительно ровно настолько, насколько отличительно имя, поэтому сервер с явно заданной политикой должен иметь настоящее имя или задать `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`, чьи параметры — `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,123 @@
---
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}
Обработчик может попросить у подключённого клиента ещё две вещи: завершение (completion) от собственной модели клиента — это **сэмплирование** (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, не stateless), а их вызов выдаёт предупреждение об устаревании. Маркеры резолверов, показанные выше, — поддерживаемая форма: они выбирают способ доставки по согласованной версии и не выдают предупреждений.
## Итоги {#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"}}}
```
Обратите внимание, чего в обновлении *нет*: самой доски. Каждый кадр несёт в `_meta` JSON-RPC-идентификатор запроса listen, и этот идентификатор и есть идентификатор подписки. Его выдаёт клиент: `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)**.