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

View file

@ -0,0 +1,122 @@
---
translation:
sections: [9e7b9a1710e5aeba, b74ca4c1d2ddddee, fa8714e61bf90c5a, 04db67a886b7271c, 857690fb8f876800]
tool: 1
---
# Подсказки по кэшированию {#caching-hints}
В протоколе 2026-07-28 каждый результат, который сервер возвращает для `tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read` и `server/discover`, несёт два поля: `ttlMs` — сколько миллисекунд клиент может считать результат свежим, и `cacheScope` — можно ли делить закэшированный результат между пользователями (`"public"`) или он принадлежит одному контексту авторизации (`"private"`).
Сам сервер ничего не кэширует. Эти поля — *объявление*: «этот список инструментов одинаков для всех и не изменится в ближайшую минуту». Клиент (или шлюз перед вашим сервером) может тогда обойтись без обращения к серверу. Учитывать подсказки или нет — решает клиент; выдавать их — задача сервера, и SDK делает это за вас.
По умолчанию каждый результат говорит `ttlMs: 0, cacheScope: "private"`: сразу устаревший, никому не передаётся. Это всегда безопасно и всегда соответствует спецификации. Если ваши списки действительно стабильны и одинаковы для всех вызывающих, скажите об этом при создании сервера:
```python title="server.py" hl_lines="5-8"
--8<-- "docs_src/caching/tutorial001.py"
```
* Ключи словаря — **имена методов**, и шесть кэшируемых методов — единственные допустимые ключи. Параметр имеет тип `Mapping[CacheableMethod, CacheHint]`, поэтому редактор подсказывает ключи автодополнением и отмечает опечатку ещё до запуска; всё, что проскользнёт мимо проверки типов, выбрасывает исключение при создании.
* Метод, который вы не упомянули, сохраняет значения по умолчанию. Словарь — это набор переопределений, а не полный перечень.
* `CacheHint(ttl_ms=5_000)` оставил `scope` незаданным, поэтому он остаётся `"private"`: пять секунд свежести, отдельно для каждого вызывающего. Область и TTL — независимые решения.
* `"server/discover"` — тоже допустимый ключ, поскольку результат обнаружения кэшируется так же, как любой список.
!!! warning
`cacheScope: "public"` означает, что ваш закэшированный ответ могут отдать *кому угодно*.
Общий шлюз охотно передаст результат одного пользователя другому, даже если запрос был
аутентифицирован. Помечайте результат как `"public"`, только если он одинаков для каждого
вызывающего, и никогда не используйте `cacheScope` для управления доступом: это метка, а не замок.
## Переопределение в обработчике {#per-handler-override}
В низкоуровневом классе `Server` обработчики собирают результаты вручную, а `ttl_ms` и `cache_scope` — это просто поля моделей результата. Обработчик, который задаёт их явно, всегда берёт верх над словарём из конструктора, поле за полем:
```python title="server.py" hl_lines="10 16"
--8<-- "docs_src/caching/tutorial002.py"
```
Обработчик указал `ttl_ms=1_000` и ничего про область. В передаваемых данных: `ttlMs: 1000` (значение обработчика, а не `60_000` из словаря) и `cacheScope: "public"` (из словаря, потому что обработчик его не задал). Явное значение важнее настроенного, а настроенное важнее значения по умолчанию. Это действует для каждого поля отдельно, так что обработчик может зафиксировать одно поле, а другое оставить общесерверной политике.
Это же и выход для динамики, о которой конструктор знать не может: обработчик, фильтрующий `resources/read` по пользователю, может вернуть `cache_scope="private"` для одного URI на сервере, где всё остальное публично.
Одна оговорка о постраничных списках: протокол требует **одинакового `cacheScope` на каждой странице** одного списка. Словарь конструктора выполняет это по построению, поскольку его ключи — методы, а не страницы. Но обработчик, переопределяющий область сам, сам же и отвечает за согласованность: переопределяйте её на *каждой* странице, а не только когда есть курсор, иначе первая и вторая страницы разойдутся.
## Что видит клиент {#what-the-client-sees}
В сессии 2026-07-28 `Client` учитывает подсказки за вас: у него есть встроенный кэш ответов, включённый по умолчанию. Результат, пришедший с `ttlMs`, сохраняется, и идентичный вызов в пределах этого TTL обслуживается из кэша без обращения к серверу. Результат *без* подсказки не кэшируется: результаты без подсказок получают `CacheConfig.default_ttl_ms`, по умолчанию равный `0` (сразу устаревший), так что сервер, ничего не объявляющий, видит ровно тот же трафик — вызов за вызовом, — что и всегда.
```python title="client.py" hl_lines="33 35 38"
--8<-- "docs_src/caching/tutorial003.py"
```
Четыре вызова, три обращения к серверу. Второй вызов нашёл свежую запись и до сервера не дошёл; перевод (внедрённых) часов за пределы TTL заставил третий снова обратиться к серверу; четвёртый указал `cache_mode="refresh"`. Этот именованный аргумент есть у пяти кэширующих методов (`list_tools`, `list_prompts`, `list_resources`, `list_resource_templates`, `read_resource`):
* `"use"` (по умолчанию) отдаёт свежую запись, если она есть, а если нет — запрашивает и сохраняет результат.
* `"refresh"` никогда не отдаёт из кэша: запрашивает и сохраняет результат, заменяя то, что было закэшировано.
* `"bypass"` обращается к серверу, вообще не трогая кэш: ни чтения, ни записи.
Над `"use"` стоит одно правило: **вызовы с `meta` всегда доходят до сервера.** Запрос с заданным `meta` (токен прогресса, поля трассировки) рассчитывает на настоящий сетевой запрос, поэтому при `cache_mode="use"` он обрабатывается как `"refresh"`: чтение из кэша пропускается, а полученный результат всё равно заменяет закэшированную запись. `"bypass"` и явный `"refresh"` ведут себя как обычно.
Чтобы совсем выключить кэширование, создайте клиент как `Client(server, cache=None)`: каждый вызов снова обращается к серверу, а `cache_mode`, хотя и принимается, ничего не делает.
Область тоже учитывается автоматически: записи `"private"` привязаны к *разделу* (partition) кэша (о нём ниже), тогда как записи `"public"` могут быть разделены шире. И **уведомления важнее TTL** для ровно тех записей, которые они называют: уведомление `list_changed` вытесняет соответствующий закэшированный список, а `resources/updated` вытесняет закэшированное чтение, сохранённое ровно под его URI, какими бы свежими они ни были. На подключении 2026-07-28 эти уведомления приходят по потоку `subscriptions/listen`, который открывается через `client.listen(...)`, и вытеснение завершается раньше, чем наблюдатель увидит событие; подробнее — на странице **[Подписки](subscriptions.md)**.
Одна оговорка о `resources/updated`: вытеснение работает только по точному URI. В контракте хранилища нет операции перечисления или сканирования (как и в эталонной реализации на TypeScript), поэтому уведомление с URI *под*ресурса не вытесняет закэшированное чтение его родителя. Если ваш сервер сигнализирует о подресурсах таким образом, перечитайте родителя с `cache_mode="refresh"`.
### Настройка: `CacheConfig` {#configuring-it-cacheconfig}
```python
from mcp.client import CacheConfig
client = Client("https://api.example.com/mcp", cache=CacheConfig(default_ttl_ms=5_000))
```
* `store`: где живут записи. По умолчанию — свежее хранилище в памяти для каждого клиента; передайте собственную реализацию `ResponseCacheStore` (скажем, на Redis), чтобы разделять кэш между клиентами или процессами. Типы контракта (`ResponseCacheStore`, `CacheKey`, `CacheEntry` и стандартный `InMemoryResponseCacheStore`) импортируются из `mcp.client`. Один поиск может выполнить до двух последовательных `get` к хранилищу (сначала приватная ветвь, затем публичная), так что рассчитывайте ожидания по задержке удалённого хранилища соответственно. Собственное хранилище **требует** явного `partition`.
* `partition`: метка контекста авторизации, не позволяющая отдать записи `"private"` одного принципала другому в общем хранилище.
* `target_id`: явная идентичность сервера, для собственных транспортов и внутрипроцессных серверов (ниже).
* `default_ttl_ms`: TTL, применяемый к результатам без подсказки `ttlMs`. Значение по умолчанию `0` оставляет результаты без подсказок незакэшированными.
* `share_public`: отдавать записи, которые сервер объявил `"public"`, между разделами (ниже). По умолчанию выключено.
* `clock`: источник настенного времени, в секундах эпохи. Внедрите его, как в примере выше, и тестам на истечение не придётся спать.
!!! warning "Раздел = проверенный принципал"
Выводите `partition` из **проверенного удостоверения**, например из субъекта валидированного токена. Никогда не выводите его из данных, пришедших в запросе, и никогда — из URL сервера (идентичность сервера — отдельная ось ключа). SDK — это библиотека без собственной аутентификации: якорь доверия — тот, кто создаёт `CacheConfig`, то есть развёртывание, а не арендатор. Мультиарендный шлюз создаёт по одному `CacheConfig` на каждого аутентифицированного принципала.
Раздел также фиксирован на всё время жизни `Client`. Если контекст авторизации подключения меняется посреди сессии (скажем, повторная аутентификация под другим принципалом), кэш за этим не следует; создайте новый `Client` для нового принципала.
Ключи кэша также несут **идентичность сервера**: строку URL, по которой вы подключились, с убранным userinfo вида `user:pass@`, а в остальном байт в байт. Никакого приведения регистра, никакой перестановки параметров запроса, никакой чистки завершающего слэша. Недостаточная нормализация стоит лишь совместного использования, тогда как избыточная могла бы слить двух арендаторов (`?tenant=a` и `?tenant=b`), поэтому внешне разные URL просто не делят записи. Когда URL нет (внутрипроцессный сервер или экземпляр `Transport`), клиент вместо этого получает случайную идентичность на экземпляр; задайте `CacheConfig.target_id`, чтобы назвать сервер (с собственным хранилищем это обязательно, и создание об этом сообщит). Идентичность хешируется sha256 прежде, чем попасть в материал ключа, так что URL с секретами в строке запроса никогда не появляется в ключах хранилища. И сами не пишите в лог форму до хеширования.
!!! warning "`share_public` доверяет серверу — для всего парка клиентов"
По умолчанию даже записи `"public"` остаются в пределах своего раздела. `share_public=True` отдаёт записи, которые сервер пометил `cacheScope: "public"`, **каждому** разделу, использующему хранилище, доверяя классификации сервера от имени их всех. Сервер, который ставит `"public"` на данные отдельного арендатора (по ошибке или злонамеренно), тогда раскрывает ответ одного арендатора остальным. Флаг намеренно существует только на уровне конструктора: `cache_mode` для отдельного вызова может сузить кэширование, но ничто на уровне вызова не может расширить совместное использование.
### Чего кэш никогда не делает {#what-the-cache-never-does}
* **Вызовы уровня сессии его обходят.** `client.session.list_tools()` и ему подобные всегда обращаются к серверу; кэш живёт в методах `Client`.
* **`server/discover` в него не попадает.** Результат discover доставляется один раз, при подключении, и никогда не входит в кэш ответов, даже если несёт `ttlMs`. Если вы сохраняете его сами, чтобы пропустить пробу при переподключении ([`prior_discover`](../protocol-versions.md#reconnecting-with-prior_discover)), его свежесть — ваша забота: `DiscoverResult` несёт `ttl_ms` и `cache_scope`, уже разобранные, ровно для этого.
* **Страницы продолжения никогда не кэшируются.** Участвуют только вызовы без курсора. Страница продолжения, отклонённая из-за истёкшего курсора, при этом *вытесняет* закэшированный список, потому что список под ней изменился.
* **Многораундовые (multi-round-trip) чтения никогда не кэшируются.** `read_resource`, которому переданы `input_responses`/`request_state`, или тот, что разрешается через раунды ввода, никогда не попадает в кэш (MUST в спецификации).
* **Вытеснению по уведомлениям нужны уведомления.** Вытеснение работает настолько хорошо, насколько транспорт их доставляет, а современный внутрипроцессный путь (`Client(server)` с `mode="auto"` по умолчанию) сегодня не доставляет самостоятельные уведомления.
* **Вытеснение происходит в конечном счёте, а не мгновенно.** Уведомления, пришедшие по сети, диспетчеризуются из порождённых задач, поэтому вызов, состязающийся с приходом уведомления, может ещё раз получить запись до вытеснения; окно ограничено задержкой диспетчеризации, и вытеснение всё равно произойдёт.
* **Нет stale-if-error.** Истёкшая запись никогда не отдаётся из-за того, что повторный запрос завершился ошибкой; ошибка пробрасывается дальше.
* **Нет упреждающего повторного запроса.** Сохранённая запись отдаётся, пока не истечёт её TTL, и следующий вызов после этого платит обращением к серверу; ничего не обновляется в фоне.
* **Нет объединения запросов.** Два одновременных идентичных вызова — это два обращения к серверу.
* **Нет TTL больше 24 часов.** Большее `ttlMs`, присланное сервером или настроенное, урезается при сохранении (`mcp.client.caching.MAX_TTL_MS`), что ограничивает, как долго может отдаваться любая запись, сколь бы щедрой ни была подсказка.
* В **общем хранилище** клиенты состязаются друг с другом. Каждый клиент отбрасывает собственную запись, если вытеснение обогнало запрос в полёте, но клиент-*сосед* всё же может записать обратно запись, удалённую вытеснением, которого он не видел; и сам учёт этих гонок ограничен: после 4096 отслеживаемых ключей первым сбрасывается страж самого старого ключа. Оба окна приняты и закрываются ограничением TTL выше.
* **Нет выдачи между поколениями протокола.** Записи привязаны к согласованной версии протокола: в общем постоянном хранилище сессия никогда не отдаёт запись, сделанную при другой согласованной версии (один и тот же список действительно различается по поколениям, поскольку SDK убирает поля 2026 для более старых сессий). Вытеснение точно так же затрагивает только записи текущего поколения; записи другого поколения просто устаревают по TTL.
### Чтение подсказок вручную {#reading-the-hints-yourself}
Подсказки — это ещё и обычные поля каждого кэшируемого результата (`result.ttl_ms` и `result.cache_scope`, уже разобранные), на случай если захочется надстроить собственный учёт поверх встроенного кэша (или вместо него).
С **более старым сервером** (протокол до 2026) этих полей в передаваемых данных просто нет, и модели показывают консервативные значения по умолчанию: `ttl_ms == 0` и `cache_scope == "private"` — устаревший и неразделяемый, правильное предположение для сервера, который ничего не объявил. Кэш относится к сессии старого поколения так же: подсказки там никогда не учитываются (какие бы ключи ни появились в данных), применяется только `default_ttl_ms`, а его значение по умолчанию `0` ничего не кэширует, так что подключение до 2026 ведёт себя ровно так, как до появления кэша. Если нужно отличить «сервер сказал 0» от «сервер ничего не сказал», проверьте `"ttl_ms" in result.model_fields_set`: оно задано, только когда поле действительно пришло.
## Более старые клиенты {#older-clients}
Клиенты на версиях протокола до 2026 никогда не видят ни одного из этих полей; для таких подключений SDK убирает их при сериализации. Настройте подсказки один раз — ничего зависящего от версии писать не нужно.
## Итоги {#recap}
* Шесть методов несут `ttlMs`/`cacheScope`; SDK по умолчанию ставит `0`/`"private"` — устаревший и неразделяемый, всегда безопасно.
* `cache_hints={method: CacheHint(...)}` при создании (и `MCPServer`, и `Server`) задаёт общесерверные значения по методам.
* Обработчик, задающий поля в своём результате, переопределяет словарь, поле за полем.
* `"public"` — это обещание, что результат одинаков для каждого вызывающего. Это не управление доступом.
* `Client` учитывает подсказки автоматически: его кэш ответов включён по умолчанию, отдаёт свежие записи вместо повторного запроса и ничего не кэширует для серверов (или сессий), не дающих подсказок.
* Для отдельного вызова `cache_mode="refresh"` запрашивает заново, а `"bypass"` обходит кэш; `cache=None` при создании выключает его совсем.

View file

@ -0,0 +1,154 @@
---
translation:
sections: [adf3c545b5be46b6, 916cd3ab1c03f461, e9be7a8d0eb0a456, 565890a636288ecf, 6af7e49db9129ec3, 06b0238c174186af, 90c6043be435fcb0]
tool: 1
---
# Колбэки клиента {#client-callbacks}
Почти все запросы в MCP идут в одну сторону: от клиента к серверу.
Но и сервер может о чём-то попросить **клиент**: задать вопрос пользователю, попросить модель пользователя сгенерировать ответ, получить список папок его рабочего пространства. На такие запросы отвечают **колбэки**, которые передаются в `Client(...)`.
## Сервер, который спрашивает {#a-server-that-asks}
Вот сервер, инструмент которого не может завершиться самостоятельно:
```python title="server.py" hl_lines="16"
--8<-- "docs_src/client_callbacks/tutorial001.py"
```
* `ctx.elicit(...)` отправляет запрос `elicitation/create` **клиенту** и ждёт.
* Инструмент не вернёт результат, пока кто-нибудь (человек через форму или ваш код) не предоставит `name`.
Это серверная половина, и ей посвящена страница **[Элицитация](../handlers/elicitation.md)**. Эта страница — о другом конце соединения.
## Колбэк элицитации {#the-elicitation-callback}
```python title="client.py" hl_lines="6-10 16-17"
--8<-- "docs_src/client_callbacks/tutorial002.py"
```
* Колбэк элицитации (elicitation) — это `async (context, params) -> ElicitResult`.
* `params.message` — это вопрос. `params.requested_schema` — JSON Schema ответа, который нужен серверу. Настоящий клиент строит по ней форму; этот заполняет её автоматически.
* Возвращается `ElicitResult(action="accept", content={...})`, либо `action="decline"`, либо `action="cancel"`. Единственный другой вариант — `ErrorData(...)`: он отклоняет запрос, и весь вызов завершается ошибкой.
* `context` — это `ClientRequestContext`: действующая `session`, `request_id` сервера и `meta`, если сервер что-то приложил.
!!! tip
`params` — объединение двух режимов элицитации. Здесь `params.mode` равен `"form"`; запрос в режиме `"url"`
несёт `params.url` вместо схемы. Один колбэк обрабатывает оба режима; ветвитесь по `params.mode`.
Полный шаблон показан на странице **[Элицитация](../handlers/elicitation.md)**.
### Попробуйте сами {#try-it}
Вызовите `issue_card` и проследите за обоими концами.
Колбэк получает вопрос сервера, уже разобранный:
```python
params.mode # 'form'
params.message # 'What name should go on the card?'
params.requested_schema # {'properties': {'name': {'title': 'Name', 'type': 'string'}},
# 'required': ['name'], 'title': 'CardHolder', 'type': 'object'}
```
Он отвечает, `ctx.elicit(...)` внутри инструмента возобновляется, и инструмент завершается:
```python
result.content # [TextContent(type='text', text='Card issued to Ada Lovelace.')]
```
Один `tools/call` от вас, один встречный `elicitation/create` от сервера, на который ответила ваша функция, — и всё это внутри одного вызова инструмента.
!!! info
`mode="legacy"` в вызове `Client(...)` стоит не просто так. По умолчанию `Client(...)` согласовывает современный
вариант протокола, а в нём нет обратного канала (back-channel) для запросов от сервера к клиенту: `ctx.elicit`
завершается ошибкой ещё до того, как колбэк успевает запуститься. Решает это не транспорт, а согласованный
протокол — и в памяти, и по URL одинаково. Фиксируйте `mode="legacy"` всякий раз, когда клиент должен
отвечать на такие запросы; так делает каждый тест, стоящий за этой страницей. Подробнее — на странице **[Версии протокола](../protocol-versions.md)**.
В сессии 2026-07-28 колбэк не бесполезен — просто данные поступают к нему иначе: когда инструмент возвращает
`InputRequiredResult` с `ElicitRequest` внутри, `Client` передаёт эту запись тому же
`elicitation_callback` и повторяет вызов за вас. Этот сценарий описан на странице **[Многораундовые запросы](../handlers/multi-round-trip.md)** (multi-round-trip).
## Колбэк — это возможность {#a-callback-is-a-capability}
Вы нигде не сообщали серверу, что клиент умеет отвечать на запросы элицитации. Это сделал SDK.
При подключении клиент объявляет свои `capabilities` — зеркальное отражение возможностей сервера. Этот объект не нужно писать вручную. **Регистрация колбэка и есть объявление.**
| вы передаёте | клиент объявляет |
| --- | --- |
| `elicitation_callback=` | `"elicitation": {"form": {}, "url": {}}` |
| `sampling_callback=` | `"sampling": {}` |
| `list_roots_callback=` | `"roots": {"listChanged": true}` |
| ничего из этого | `{}` |
Единственное уточнение — подвозможности сэмплирования (sampling): передайте `sampling_capabilities=SamplingCapability(tools=SamplingToolsCapability())` вместе с `sampling_callback`, если ваш обработчик сэмплирования поддерживает параметры `tools` / `tool_choice`. Сервер может отправлять их, только когда видит объявленную `sampling.tools`.
`logging_callback` и `message_handler` в таблице нет. Они обрабатывают уведомления, а уведомлениям возможность не нужна.
Сервер читает это объявление с помощью `ctx.session.check_client_capability(...)`. Добавьте инструмент, который это делает:
```python title="server.py" hl_lines="23-31"
--8<-- "docs_src/client_callbacks/tutorial003.py"
```
Подключитесь, передав только `elicitation_callback`, и вызовите его:
```python
result.structured_content # {'result': ['elicitation']}
```
Передайте все три колбэка — получите `['elicitation', 'sampling', 'roots']`. Не передавайте ни одного — получите `[]`.
!!! check
Теперь сделайте неправильно: подключитесь **без** `elicitation_callback` и всё равно вызовите `issue_card`.
Запрос `elicitation/create` от сервера всё равно доходит до клиента, и SDK отвечает на него за
вас — ошибкой, потому что вы не заявляли, что умеете его обрабатывать. Эта ошибка губит весь вызов.
`call_tool` не возвращает результат с `is_error`, а выбрасывает исключение:
```text
MCPError: Elicitation not supported
```
Это ошибка протокола (`-32600`, *invalid request*), а не ошибка инструмента: модели нечего
прочитать и повторить. Вот зачем нужен `client_features`: корректно написанный сервер
проверяет, прежде чем спрашивать.
## Устаревшая пара {#the-deprecated-pair}
`sampling_callback` отвечает на `sampling/createMessage`: сервер просит *вашу* модель что-то сгенерировать. `list_roots_callback` отвечает на `roots/list`: сервер спрашивает, в каких каталогах ему можно работать.
Оба работают. Оба подчиняются правилу выше. И оба обслуживают RPC, которые **спецификация 2026-07-28 удаляет**: современный сервер не обращается к клиенту посреди запроса, а возвращает запрос вам как часть результата инструмента (**[Многораундовые запросы](../handlers/multi-round-trip.md)**). Сами колбэки не бесполезны. Когда `InputRequiredResult` несёт `CreateMessageRequest` или `ListRootsRequest`, автоматический цикл `Client` передаёт его тому же `sampling_callback` или `list_roots_callback`, который вы зарегистрировали здесь. Полный список — на странице **[Устаревшие возможности](../deprecated.md)**.
Колбэки по-прежнему нужны, чтобы общаться с серверами, которые ещё не перешли. Сигнатуры:
```python title="client.py"
--8<-- "docs_src/client_callbacks/tutorial004.py"
```
* Колбэк сэмплирования получает полный `CreateMessageRequestParams` (`messages`, `model_preferences`, `max_tokens`) и возвращает `CreateMessageResult`. Модель запускаете *вы* — как угодно; SDK лишь доставляет запрос.
* Колбэк корневых каталогов (roots) вообще не принимает параметров и возвращает `ListRootsResult`.
* Любой из них может вместо этого вернуть `ErrorData(...)`, чтобы отказать.
Передавайте их в `Client(...)` точно так же, как `elicitation_callback`.
## Колбэки уведомлений {#the-notification-callbacks}
Ещё два. Ни один ничего не объявляет.
`logging_callback` получает `notifications/message`, которые отправляет сервер, в виде `LoggingMessageNotificationParams` (`level`, `logger`, `data`). Протокольное логирование само объявлено устаревшим в спецификации 2026-07-28 (что делать вместо него — на странице **[Логирование](../handlers/logging.md)**), так что этот колбэк существует ради серверов, которые всё ещё его отправляют. На подключении поколения 2026 один лишь колбэк ничего не даст, потому что серверы 2026 отправляют сообщения лога только тем запросам, которые явно их запросили: передайте `log_level="info"` (или другой уровень) в `Client(...)`, чтобы проставлять эту отметку на каждом запросе и получать сообщения этого уровня и выше. Серверы до 2026 игнорируют её и сохраняют своё поведение с `logging/setLevel`.
`message_handler` — обработчик на все случаи: до него доходит каждое уведомление сервера, которое сессия пропускает наружу (помимо его специального колбэка), а на транспорте, работающем поверх потока, — ещё и каждое `Exception` транспортного уровня. Два сообщения туда не попадают никогда: `notifications/cancelled` SDK применяет сам, а не пропускает наружу, а подтверждение подписки для активного потока `listen()` поглощает сам этот поток. Аннотируйте параметр типом `IncomingMessage` (`ServerNotification | Exception`, экспортируется из `mcp.client`). Единственный приём, который стоит знать, — `if isinstance(message, Exception): raise message`, чтобы разорванное соединение падало громко, а не исчезало молча.
## Итоги {#recap}
* Сервер может отправлять запросы клиенту. На них отвечают колбэки, переданные в `Client(...)`.
* Колбэк элицитации — актуальный: `async (context, params) -> ElicitResult`, одна функция и для режима формы, и для режима URL.
* **Зарегистрировать колбэк — значит объявить возможность.** Без него SDK отклоняет запрос сервера от вашего имени, и весь вызов завершается ошибкой `MCPError`.
* Сервер узнаёт об этом заранее с помощью `ctx.session.check_client_capability(...)`.
* `sampling_callback` и `list_roots_callback` работают так же, но обслуживают устаревшие возможности; современные серверы вместо этого используют многораундовые запросы.
* `logging_callback` и `message_handler` получают уведомления. Они ничего не объявляют.
Первый аргумент `Client(...)` — объект транспорта. Все их виды описаны на странице **[Транспорты клиента](transports.md)**.

View file

@ -0,0 +1,156 @@
---
translation:
sections: [a91322c46111d16d, 8e6fd6d6f59bb568, e7828fd2729b2c9d, a03ec26bfc678b65, 1034c653c0bcf1b0]
tool: 1
---
# Утверждение идентичности {#identity-assertion}
Обычный OAuth-провайдер (**[OAuth-клиенты](oauth-clients.md)**) начинает с вопроса к MCP-серверу: *какому серверу авторизации тот доверяет?* Он идёт за ответом, куда бы тот ни указывал, а дальше либо человек входит в систему, либо его заменяет заранее выданный общий секрет.
В корпоративной среде ни то ни другое не должно решаться на уровне отдельного сервера. Там уже работает провайдер идентификации (Okta, Microsoft Entra ID, ваш собственный); пользователь уже вошёл в него сегодня утром; и именно там, в одном месте, служба безопасности хочет решать, кому что доступно. [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990), расширение **Enterprise-Managed Authorization**, переносит это решение туда. IdP подписывает короткоживущий JWT — **Identity Assertion JWT Authorization Grant**, или **ID-JAG**: утверждение о том, что *этот пользователь* через *этот клиент* может обращаться к *этому MCP-серверу*. Клиент обменивает его на обычный токен доступа. Ни браузера, ни экрана согласия, ни динамической регистрации.
Эта страница — обе стороны этого обмена. Сам MCP-сервер не меняется вовсе: это всё тот же сервер ресурсов со страницы **[Авторизация](../run/authorization.md)**, который проверяет любой пришедший токен.
## Два запроса токена {#two-token-requests}
Здесь участвуют две разные инстанции, и различать их по именам — это почти всё, что нужно для понимания этой страницы. **Корпоративный IdP** — провайдер идентификации вашей организации: он знает, кто этот сотрудник, в нём живёт политика доступа, и он выпускает ID-JAG. SDK с ним никогда не общается. **Сервер авторизации MCP** — та же сторона, что и на странице **[Авторизация](../run/authorization.md)**: издатель, названный в метаданных MCP-сервера, тот, кто выпускает токены, которые этот MCP-сервер принимает. В обычном OAuth-сценарии обе роли, как правило, играет одна система. Здесь их две, и весь грант сводится к тому, что вторая соглашается доверять первой.
Клиент делает по одному запросу токена к каждой.
1. **К корпоративному IdP.** Клиент обменивает вход пользователя (его ID-токен OpenID Connect) на ID-JAG. Это обмен токенов по [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693), это целиком API вашего IdP, и **SDK этот запрос не делает**. Его делаете вы — внутри одного асинхронного колбэка. Здесь же принимается решение по политике: IdP, который говорит «нет», просто не выпускает ID-JAG, и предъявлять нечего.
2. **К серверу авторизации MCP.** Клиент предъявляет ID-JAG по гранту `jwt-bearer` из [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) (`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`, ID-JAG в параметре `assertion`) и получает токен доступа. **Этот запрос делает SDK**, а приём такого запроса — единственное, что эта страница добавляет к серверу авторизации.
Всё, что ниже, — о втором запросе: о клиенте, который его отправляет, и о сервере авторизации, который на него отвечает.
## Клиент {#the-client}
**`IdentityAssertionOAuthProvider`** находится в модуле `mcp.client.auth.extensions.identity_assertion`. Как и все провайдеры на странице **[OAuth-клиенты](oauth-clients.md)**, это `httpx2.Auth`: создайте экземпляр, передайте его в `auth=`, отдайте `httpx2.AsyncClient` транспорту.
```python title="client.py" hl_lines="49-50 53-61"
--8<-- "docs_src/identity_assertion/tutorial001.py"
```
Читайте снизу вверх.
* `main()` — стандартная функция `main()` OAuth-клиента (**[OAuth-клиенты](oauth-clients.md)**), не изменённая ни в одной строке. В этом и смысл: как только провайдер создан, дальше по цепочке никто не знает, какой грант дал токен.
* Провайдер принимает то, что другие провайдеры не могут обнаружить сами: `client_id` и `client_secret`, которые кто-то **заранее зарегистрировал** на сервере авторизации, `issuer` этого сервера авторизации и `assertion_provider` — асинхронный колбэк, возвращающий свежий ID-JAG по требованию.
* `storage` — тот же протокол `TokenStorage`. Вызываются только два метода для токенов; динамической регистрации здесь нет, так что и запоминать `client_info` незачем.
### Провайдер утверждения {#the-assertion-provider}
`fetch_id_jag(audience, resource)` — единственный код, который вы пишете. Он вызывается один раз на каждый обмен токенов, никогда — при создании провайдера, и только *после* того, как метаданные сервера авторизации получены и проверены, так что неверно настроенный издатель никогда не приведёт к утечке утверждения. Два его аргумента — это два из полей, с которыми должен быть выпущен ID-JAG: `audience` — издатель сервера авторизации (поле `aud` в ID-JAG), а `resource` — канонический идентификатор MCP-сервера (поле `resource` в ID-JAG). Третье у вас уже есть: поле `client_id` в ID-JAG должно указывать тот `client_id`, который вы передали провайдеру, иначе сервер авторизации откажет в обмене.
`idp_issue_id_jag` над ней — **не ваш код**. Эта функция замещает провайдер идентификации и подписывает утверждение прямо в процессе, чтобы файл был самодостаточным и можно было прочитать каждое поле, которое несёт ID-JAG. Настоящая `fetch_id_jag` вместо этого делает первый запрос токена из предыдущего раздела: обмен токенов по [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) с вашим IdP, определённый черновиком Identity Assertion JWT Authorization Grant, профиль которого задаёт [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990). ID-токен вошедшего пользователя передаётся как `subject_token`, `requested_token_type` — это собственный URN ID-JAG (`urn:ietf:params:oauth:token-type:id-jag`), `audience` и `resource` проходят насквозь без изменений, а ответ содержит ID-JAG. Именно этот обмен, под этими именами, и нужно искать в документации вашего IdP.
!!! tip
Свежий ID-JAG запрашивается для каждого обмена, и в этом весь смысл: это одноразовый грант,
живущий считаные минуты, и сервер авторизации на этой странице отказывается принимать один
и тот же дважды. Не кэшируйте его. Повторно используется токен доступа, который вы на него
покупаете.
### Издатель задаётся в конфигурации {#the-issuer-is-configuration}
Вот где всё переворачивается. `OAuthClientProvider` спрашивает сервер ресурсов, какой сервер авторизации использовать, и идёт за ответом, куда бы тот ни указывал. Этот провайдер так не делает: `issuer` обязателен, метаданные [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) запрашиваются по собственному пути well-known этого издателя, конечная точка токенов должна иметь тот же origin, что и издатель, а сервер ресурсов вообще ни о чём не спрашивают.
Расширение этого не требует; это сознательно более строгий выбор. У этого клиента есть две вещи, которые стоит украсть: заранее зарегистрированный секрет и утверждение, привязанное к аудитории, — и клиент, позволивший скомпрометированному MCP-серверу направить себя на сервер авторизации злоумышленника, отправил бы туда и то и другое. Закрепление издателя при создании провайдера исключает этот разговор вовсе.
!!! warning
Настроенный `issuer` сравнивается с полем `issuer` документа метаданных простым сравнением
строк по RFC 8414 §3.3: символ в символ, включая завершающую косую черту, без нормализации.
Не угадывайте его. Запросите `/.well-known/oauth-authorization-server` у своего сервера
авторизации и скопируйте значение `issuer`, которое он вернёт. Для сервера авторизации на этой
странице это `https://auth.example.com/`, с косой чертой, потому что его издатель построен из
URL-объекта pydantic. Несовпадение останавливает процесс на `OAuthFlowError: Authorization server metadata issuer
mismatch` ещё до отправки каких-либо учётных данных или утверждения.
### Конфиденциальный клиент {#a-confidential-client}
`client_secret` обязателен; без него конструктор выбрасывает `ValueError`. Профиль IETF, лежащий в основе [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990), оставляет этот грант только конфиденциальным клиентам, SEP-990 требует, чтобы клиент аутентифицировался, а этот SDK обеспечивает и то и другое, настаивая на общем секрете. `token_endpoint_auth_method` выбирает, где он передаётся: `client_secret_post` (по умолчанию, в теле формы) или `client_secret_basic` (заголовок HTTP Basic). Профиль допускает ещё `private_key_jwt`; этот провайдер его не поддерживает.
!!! tip
Читайте `client_secret` из переменных окружения или менеджера секретов и никогда — из
системы контроля версий.
### Что провайдер делает за вас {#what-the-provider-does-for-you}
Первый запрос уходит без аутентификации, и ответ сервера `401` запускает процесс.
1. **Обнаружение.** Провайдер получает метаданные сервера авторизации по пути well-known [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) настроенного издателя, проверяет, что `issuer` в документе совпадает, и проверяет, что конечная точка токенов имеет тот же origin, что и издатель.
2. **Утверждение.** Он вызывает ваш `assertion_provider` и дожидается результата.
3. **Обмен.** Он отправляет POST-запрос с грантом `jwt-bearer` на конечную точку токенов, сохраняет `OAuthToken` и повторяет ваш исходный запрос с заголовком `Authorization: Bearer ...`.
Ответ `403`, в `WWW-Authenticate` которого указано `insufficient_scope`, повторяет шаги 2 и 3 с объединением вашего `scope` и запрошенного в этом ответе. (`scope` — всегда лишь просьба; сервер авторизации с этой страницы выдаёт то, что сказано в ID-JAG, и ничего больше.) Токена обновления здесь нет нигде: когда токен доступа истекает, следующий `401` приводит к выпуску свежего ID-JAG и новому обмену — и *это* тот рычаг, который держит в руках IdP. Ошибки — те же два исключения, что и на остальной странице **[OAuth-клиенты](oauth-clients.md)**: `OAuthFlowError` для обнаружения и проверки и его подкласс `OAuthTokenError`, когда конечная точка токенов отвечает отказом.
## Сервер авторизации {#the-authorization-server}
Чаще всего на этом можно остановиться. Сервер авторизации MCP — чей-то чужой продукт, приём ID-JAG — настройка, которую нужно включить в нём, а половина [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990), которую реализует SDK, — это описанный выше клиент.
SDK может и сам *быть* сервером авторизации: `create_auth_routes` возвращает маршруты сервера авторизации списком, который может смонтировать любое Starlette-приложение, — именно так его запускает `examples/servers/simple-auth/` в репозитории. SEP-990 добавляет к этой поверхности один флаг и один метод:
```python title="auth_server.py" hl_lines="48-50 105-107"
--8<-- "docs_src/identity_assertion/tutorial002.py"
```
* `identity_assertion_enabled=True` открывает всё остальное. Когда флаг выключен (а по умолчанию это так), `/token` отвечает на этот грант `unsupported_grant_type`, даже если вы реализовали хук, и метаданные о нём не упоминают. Когда включён, в метаданных появляется тип гранта `jwt-bearer`, а в `authorization_grant_profiles_supported` — поле, через которое расширение объявляет о поддержке, — указывается `urn:ietf:params:oauth:grant-profile:id-jag`. (Клиент этого SDK его никогда не читает: он настроен на одного издателя и просто делает запрос.)
* **`exchange_identity_assertion`** — это и есть хук. К моменту его запуска SDK уже аутентифицировал клиент, отклонил публичные клиенты и отклонил клиенты, в регистрации которых этот грант не указан. Вы получаете `IdentityAssertionParams` (сырое `assertion`, запрошенные `scopes` и `resource`) и возвращаете обычный `OAuthToken`.
* Динамическая регистрация клиентов отклоняет этот грант безусловно, поэтому `get_client` здесь отдаёт клиент, заведённый вручную. Клиент ID-JAG не может появиться, зарегистрировав сам себя.
* Половина класса — отказы. `OAuthAuthorizationServerProvider` — это *весь* сервер авторизации, поэтому он требует и сценарий с кодом авторизации; сервер, который ещё и выполняет вход пользователей, реализует эти методы по-настоящему, а у этого ровно одна дверь.
!!! warning
SDK никогда не декодирует утверждение: только ваше развёртывание знает, какому IdP оно
доверяет и какие ключи этот IdP публикует, поэтому на всём, что внутри
`exchange_identity_assertion`, держится безопасность. Проверяйте подпись по опубликованным
ключам IdP (его JWKS; общий секрет здесь — только для демонстрации), а также `iss` и `exp`,
согласно [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) §3. Требуйте, чтобы `typ` в заголовке JWT был
`oauth-id-jag+jwt` — это защита профиля от того, чтобы какой-нибудь другой JWT был повторно
предъявлен как грант. Требуйте, чтобы `aud` был вашим собственным издателем. Требуйте, чтобы
поле `client_id` в ID-JAG совпадало с тем клиентом, что был аутентифицирован обработчиком, а
поле `resource` называло ресурс, который вы действительно обслуживаете. Отслеживайте `jti`
до наступления `exp` утверждения, чтобы оно принималось лишь однажды. И берите выданные
области доступа и, главное, `resource` выпускаемого токена из проверенного ID-JAG, а не из
запроса: `params.resource` — это то, что ввёл клиент. Полные правила обработки — в
[спецификации Enterprise-Managed Authorization](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization).
Некорректное утверждение отклоняйте через `TokenError("invalid_grant", ...)`. Второй код ошибки в этом сценарии — `invalid_target`: им отклоняется ID-JAG, называющий ресурс, который вы не обслуживаете, — именно это не даёт серверу выпускать токены для чужих ресурсов. А выданные области доступа берутся из поля `scope` ID-JAG (утверждение без него тоже отклоняется); ваш сервер может вместо этого отображать группы пользователя.
И обратите внимание, чего в возвращаемом `OAuthToken` нет: токена обновления. IdP решает, как долго пользователь сохраняет доступ, решая, выпускать ли следующий ID-JAG. Выпущенный здесь токен обновления тихо вернул бы это решение обратно.
!!! info
Сервер, который по-прежнему встраивает свой сервер авторизации через `auth_server_provider=`,
приходит к тому же коду через `AuthSettings(identity_assertion_enabled=True)`. На странице
**[Авторизация](../run/authorization.md)** объясняется, почему новым серверам не стоит с этого
начинать.
!!! check
Соедините два файла с этой страницы — и весь грант сведётся к одному `POST /token`:
```text
grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
assertion=eyJhbGciOiJIUzI1NiIsInR5cCI6Im9hdXRoLWlkLWphZytqd3QifQ...
client_id=finance-agent
resource=http://localhost:8001/mcp
scope=notes:read
client_secret=finance-agent-secret
HTTP/1.1 200 OK
{"access_token": "mcp_...", "token_type": "Bearer", "expires_in": 300, "scope": "notes:read"}
```
Ни `/authorize`, ни `/register`, ни запроса метаданных защищённого ресурса. По сети проходят
только запрос, получивший `401`, запрос well-known, этот обмен, а затем обычный MCP-трафик с
приложенным bearer-токеном. А `sub`, который ваш валидатор прочитал из ID-JAG, — ровно то,
что `get_access_token().subject` сообщает внутри инструмента.
### Попробуйте сами {#try-it}
`examples/stories/identity_assertion/` в репозитории SDK — это эта страница в действии: тот же валидатор `exchange_identity_assertion`, MCP-сервер, закрытый его токенами, IdP-заглушка и клиент — в одной самопроверяющейся программе. Команда `uv run python -m stories.identity_assertion.client --http` прогоняет весь обмен и проверяет, что пользователь, которого назвал IdP, — тот же, кого видит инструмент.
## Итоги {#recap}
* [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) позволяет корпоративному провайдеру идентификации, а не конечному пользователю, решать, к каким MCP-серверам может обращаться клиент. IdP закрепляет это решение подписью в **ID-JAG**.
* Получение ID-JAG — это обмен токенов по [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) с *вашим IdP*, и SDK его не делает. Предъявление его серверу авторизации MCP — грант `jwt-bearer` из [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523), и тут SDK реализует обе стороны.
* `IdentityAssertionOAuthProvider` — ещё один `httpx2.Auth`: заранее зарегистрированный конфиденциальный клиент, закреплённый `issuer` и один колбэк `assertion_provider(audience, resource)`. Ни браузера, ни регистрации, ни токена обновления.
* Сервер авторизации никогда не обнаруживается через сервер ресурсов. Задайте `issuer` в точности той строкой, которую отдаёт его документ метаданных; сравнение идёт символ в символ.
* На стороне сервера — `identity_assertion_enabled=True` плюс `exchange_identity_assertion`. SDK аутентифицирует клиент и ограничивает доступ к гранту; проверка ID-JAG целиком на вас, а выпущенный токен привязан к `resource` из ID-JAG, а не из запроса.
Единственная сторона, которой эта страница так и не коснулась, — MCP-сервер. То, что он делает с только что выпущенным вами токеном, он уже делал на странице **[Авторизация](../run/authorization.md)**.

View file

@ -0,0 +1,217 @@
---
translation:
sections: [ebef1e7a0df854f4, a4c687d3d627d516, 8e79141fc2985342, b345dd05b9c3c7ab, 80ce41579825a6fa, 5f0fa90494de8f65, 83d10514eaa62fa5, 9190555aa39a5d28, 84a4c9d8bf14dddb, 927d71cf40b58c30]
tool: 1
---
# Объект Client {#the-client}
Через **`Client`** программа на Python общается с MCP-сервером.
Это один объект с одним жизненным циклом: создать его, войти в `async with`, вызывать методы. Каждая операция протокола (получить список инструментов, вызвать один из них, прочитать ресурс, отрендерить промпт) — это `async`-метод этого объекта, возвращающий типизированный результат.
## Первый клиент {#your-first-client}
```python title="client.py" hl_lines="14-18"
--8<-- "docs_src/client/tutorial001.py"
```
Сервер в начале файла нужен лишь для того, чтобы было к чему подключаться. Клиент — это пять выделенных строк.
* `Client(mcp)` получает **сам объект сервера**. Это транспорт в памяти: ни подпроцесса, ни порта, ни HTTP. Именно так подключается каждый пример на этой странице и каждый тест, который вы напишете.
* `async with` — это **жизненный цикл**. Вход в блок подключает и согласовывает возможности; выход — отключает. Пары `connect()` / `close()` нет, и `Client` нельзя использовать повторно после завершения блока.
* Внутри блока сведения о подключении уже доступны как обычные свойства.
### Что можно передать в `Client` {#what-you-can-pass-to-client}
`Client` принимает один позиционный аргумент и определяет транспорт по его типу:
* Экземпляр `MCPServer` (или низкоуровневого `Server`): подключение **внутри процесса**.
* Строка с URL (`Client("http://localhost:8000/mcp")`): Streamable HTTP, основной вариант для реального развёртывания.
* **Транспорт**: всё, что можно использовать как `async with ... as (read, write)`, например `stdio_client(...)`, оборачивающий подпроцесс.
Всё остальное на этой странице одинаково для всех трёх вариантов. Заголовкам, подпроцессам, тайм-аутам и протоколу `Transport` посвящена отдельная страница: **[Транспорты клиента](transports.md)**.
### Что есть у подключённого клиента {#whats-on-a-connected-client}
Четыре свойства только для чтения, заполняемые в момент входа в блок:
* `client.server_info`: сведения о сервере или `None` для сервера поколения 2026, который их не сообщает (серверы на python-sdk по умолчанию сообщают). Здесь `server_info.name` — `"Bookshop"`, а `server_info.version` — то, что сообщает сервер.
* `client.server_capabilities`: что умеет сервер (`tools`, `resources`, `prompts`, `completions`, ...). Возможность, которой у сервера нет, равна `None`.
* `client.protocol_version`: версия протокола, о которой договорились стороны. Здесь это `"2026-07-28"`.
* `client.instructions`: строка `instructions=` сервера или `None`, если сервер её не задал.
Версию протокола вы нигде не выбирали. По умолчанию `Client` зондирует сервер и на старых серверах переходит к классическому рукопожатию, так что один клиент работает с сервером любого поколения. Если этим нужно управлять, подробнее — на странице **[Версии протокола](../protocol-versions.md)**.
!!! tip
`client.session` — это лежащий в основе `ClientSession`, низкоуровневый запасной выход.
Ни для чего на этой странице он не понадобится.
## Получение списка инструментов {#listing-tools}
```python title="client.py" hl_lines="15-20"
--8<-- "docs_src/client/tutorial002.py"
```
`list_tools()` возвращает `ListToolsResult`; инструменты лежат в `.tools`. Каждый из них — полное определение, которое хост передал бы модели:
```python
tool.name # 'search_books'
tool.title # 'Search the catalog'
tool.description # 'Search the catalog by title or author.'
```
а `tool.input_schema` — это JSON Schema, которую сервер вывел из аннотаций типов функции:
```json
{
"type": "object",
"properties": {
"query": {"title": "Query", "type": "string"},
"limit": {"default": 10, "title": "Limit", "type": "integer"}
},
"required": ["query"],
"title": "search_booksArguments"
}
```
Этой схемы достаточно и интерфейсу, чтобы отрисовать форму аргументов, и модели, чтобы сформировать корректные аргументы.
!!! tip
`title` необязателен, поэтому интерфейсу, показывающему инструменты человеку, приходится выбирать: `title`, если он есть,
иначе `name`. `from mcp.shared.metadata_utils import get_display_name` делает именно это —
для инструментов, ресурсов, шаблонов ресурсов и промптов.
## Вызов инструмента {#calling-a-tool}
`call_tool(name, arguments)` запускает инструмент и возвращает `CallToolResult`.
```python title="client.py" hl_lines="26-33"
--8<-- "docs_src/client/tutorial003.py"
```
Серверный `lookup_book` возвращает Pydantic-модель `Book`. Вот что видит клиент:
```python
result.content # [TextContent(type='text', text='{\n "title": "Dune",\n "author": "Frank Herbert",\n "year": 1965\n}')]
result.structured_content # {'title': 'Dune', 'author': 'Frank Herbert', 'year': 1965}
result.is_error # False
```
Одно возвращаемое значение, три поля для чтения. У каждого свой потребитель.
### `content`: что читает модель {#content-what-the-model-reads}
`content` — это `list` **блоков содержимого**, а блок содержимого — объединение типов: `TextContent`, `ImageContent`, `AudioContent`, `ResourceLink` или `EmbeddedResource`. Инструмент может вернуть несколько блоков, причём разных видов.
Поэтому `main` сужает тип с помощью `isinstance(block, TextContent)`, прежде чем обращаться к `block.text`. Обратите внимание: вне `isinstance` обращения к `.text` нет — проверка типов этого не допустит, потому что у `ImageContent` есть `.data`, а не `.text`. Объединение честно описывает, что инструмент вправе прислать; код должен быть столь же честен.
### `structured_content`: что читает приложение {#structured_content-what-your-application-reads}
`structured_content` — это возвращаемое значение инструмента в виде JSON, соответствующего объявленной `output_schema` инструмента. Никакого разбора строк, никаких догадок.
Когда есть и то и другое, они намеренно говорят одно и то же дважды: `content` — для модели, `structured_content` — для кода. Откуда берётся структурированная часть и как ею управлять — на странице **[Структурированный вывод](../servers/structured-output.md)**.
### `is_error`: завершился ли инструмент ошибкой {#is_error-whether-the-tool-failed}
Инструмент, выбросивший исключение, **не** выбрасывает его в клиенте. Он возвращается обычным результатом с `is_error=True`.
!!! check
Запросите у `lookup_book` `"Solaris"` (название, которого нет в каталоге), и функция выбросит
`ValueError`. Вызов всё равно завершится нормально:
```python
result.is_error # True
result.content # [TextContent(type='text', text="Error executing tool lookup_book: No book titled 'Solaris' in the catalog.")]
result.structured_content # None
```
Сообщение исключения попало в `content`, где **модель** может его прочитать и попробовать снова. Так
и задумано: ошибка инструмента — часть диалога, а не крах. Всегда проверяйте `is_error`,
прежде чем доверять `structured_content`.
!!! warning
`is_error=True` покрывает не только ваш собственный `raise`. Запросите инструмент, которого у сервера вообще нет
(`call_tool("does_not_exist", {})`), — и исключения не будет. Вернётся та же структура:
`is_error=True` с `Unknown tool: does_not_exist` в `content`. Метод `Client` выбрасывает
`MCPError` только тогда, когда сервер отвечает **ошибкой** JSON-RPC вместо результата, а
когда сервер выдаёт одно, а когда другое, описано на странице **[Обработка ошибок](../servers/handling-errors.md)**.
## Ресурсы {#resources}
Операции с ресурсами идут парами: два способа получить список и один способ прочитать.
```python title="client.py" hl_lines="22-31"
--8<-- "docs_src/client/tutorial004.py"
```
* `list_resources()` возвращает **конкретные** ресурсы — те, у которых фиксированный URI. Здесь: `['catalog://genres']`.
* `list_resource_templates()` возвращает **параметризованные**. Здесь: `['catalog://genres/{genre}']`. Это два разных списка, потому что шаблон нельзя прочитать, пока его не заполнить.
* `read_resource(uri)` принимает URI обычной строкой `str` и работает с обоими: передайте `"catalog://genres/poetry"`, и сервер сопоставит его с шаблоном.
`read_resource` возвращает `contents` — список `TextResourceContents` или `BlobResourceContents`. Та же идея, что и с содержимым инструмента: сузьте тип через `isinstance`, затем читайте `.text` (или `.blob`).
Клиент может также узнавать об изменениях ресурса. На подключениях поколения 2025 это `subscribe_resource(uri)` / `unsubscribe_resource(uri)` — пара методов, которую `MCPServer` не реализует, поэтому в протоколе 2026-07-28 (где этих операций больше нет) запрос получает в ответ `-32601`, *Method not found*. Замена в поколении 2026 — поток `subscriptions/listen`, который `MCPServer` *как раз* обслуживает (`server_capabilities.resources.subscribe` там равно `True`), а как читать его через `client.listen(...)` — на странице **[Подписки](subscriptions.md)** этого раздела.
## Промпты {#prompts}
```python title="client.py" hl_lines="15-20"
--8<-- "docs_src/client/tutorial005.py"
```
`list_prompts()` сообщает, что предлагает сервер и что нужно каждому промпту:
```python
prompt.name # 'recommend'
prompt.title # 'Recommend a book'
prompt.arguments # [PromptArgument(name='genre', required=True)]
```
`get_prompt(name, arguments)` рендерит его. Словарь аргументов имеет вид `str -> str`: аргументы промпта всегда строки. Результат — `messages`, список `PromptMessage`, у каждого есть `role` и блок `content`:
```python
message.role # 'user'
message.content # TextContent(type='text', text='Recommend one poetry book from the catalog and say why.')
```
Хост передаёт эти сообщения прямо модели. Вот и вся возможность.
## Автодополнение {#completions}
Сервер с обработчиком автодополнения может дополнять аргументы промптов и шаблонов ресурсов по мере того, как пользователь их вводит.
```python title="client.py" hl_lines="27-31"
--8<-- "docs_src/client/tutorial006.py"
```
* `ref` указывает, *какой* промпт или шаблон заполняется: `PromptReference` или `ResourceTemplateReference`.
* `argument` — это `{"name": ..., "value": ...}`: аргумент и то, что пользователь уже успел набрать.
Ответ лежит в `result.completion.values`. Наберите `"p"` — и сервер вернёт `['poetry']`. Серверная сторона и то, как обработчик использует *другие*, уже заполненные аргументы, чтобы сузить подсказки, — на странице **[Автодополнение](../servers/completions.md)**.
## Пагинация {#pagination}
Каждый метод `list_*` принимает именованный аргумент `cursor=`, а каждый результат содержит `next_cursor`. Когда `next_cursor` равен `None`, у вас есть всё.
```python title="client.py" hl_lines="22-30"
--8<-- "docs_src/client/tutorial007.py"
```
Этот цикл корректен для любого сервера. `MCPServer` возвращает всё одной страницей, так что `next_cursor` равен `None` и цикл выполняется один раз — поэтому в большинстве программ его и не пишут. О серверах, которые действительно разбивают выдачу на страницы, и о правилах, которым подчиняются курсоры, — на странице **[Пагинация](../advanced/pagination.md)**.
## В тестах {#in-tests}
`Client(mcp)` без процесса и без порта — уже готовая тестовая обвязка для сервера.
Для этого есть один специальный флаг конструктора: `Client(mcp, raise_exceptions=True)`. Он действует только на подключениях в памяти, а объясняет его и строит вокруг него весь подход страница **[Тестирование](../get-started/testing.md)**.
## Итоги {#recap}
* `Client(x)` подключается в памяти к объекту сервера, по Streamable HTTP — к строке с URL, а ко всему остальному — через транспорт.
* `async with` — это весь жизненный цикл. Внутри него `server_capabilities` и `protocol_version` уже заполнены; `server_info` и `instructions` — тоже, если сервер их предоставляет.
* `list_tools()` даёт `name`, `title`, `description` и `input_schema` каждого инструмента.
* `call_tool()` возвращает `content` для модели, `structured_content` для кода и `is_error`. Инструмент, выбросивший исключение, — это результат, а не исключение.
* `content` — объединение типов блоков; сужайте тип через `isinstance` перед чтением.
* `list_resources` / `list_resource_templates` / `read_resource`, `list_prompts` / `get_prompt` и `complete` замыкают набор операций.
* Каждый `list_*` принимает `cursor=`; крутите цикл, пока `next_cursor` не станет `None`.
То, о чём сервер может попросить *клиент*, и как на это отвечать, — на странице **[Колбэки клиента](callbacks.md)**.

View file

@ -0,0 +1,154 @@
---
translation:
sections: [c6899d3892bd9fa0, 79372cff3cc48a88, 63878d29e87c3e73, 13175843d3588af4, e7e2b9fd516f77de, 758f06399b513c1f, a05d7278487d610b]
tool: 1
---
# OAuth-клиенты {#oauth-clients}
Некоторые MCP-серверы защищены. Отправьте им запрос без токена — и в ответ придёт `401 Unauthorized`.
**`OAuthClientProvider`** — это способ получить токен. Это вовсе не объект MCP. Это `httpx2.Auth`, стандартный хук httpx2 для задачи «сделать что-то с каждым запросом». Его подключают к `httpx2.AsyncClient`, передают этот клиент транспорту Streamable HTTP — и больше о нём не думают.
Эта страница — о клиентской стороне. Как заставить собственный сервер требовать токен, описано на странице **[Авторизация](../run/authorization.md)**.
## Провайдер {#the-provider}
```python title="client.py" hl_lines="44-54"
--8<-- "docs_src/oauth_clients/tutorial001.py"
```
Ему передают четыре вещи:
* `server_url`: конечная точка MCP, к которой вы подключаетесь. Всё остальное провайдер выясняет по ней сам.
* `client_metadata`: то, что вы ввели бы в форму «зарегистрировать приложение» на сервере авторизации.
* `storage`: место, где токены хранятся между запусками.
* `redirect_handler` и `callback_handler`: два момента, когда участвует человек.
Больше нигде в файле OAuth не упоминается. `main()` токена не видит вовсе.
### Метаданные клиента {#client-metadata}
`OAuthClientMetadata` — это настоящий регистрационный документ из [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591), оформленный как модель Pydantic.
Вы задаёте три поля. Остальное заполняют значения по умолчанию: `grant_types` уже равно `["authorization_code", "refresh_token"]`, а `response_types` — `["code"]`, и это ровно тот сценарий, который выполняет провайдер.
!!! check
Поскольку это модель Pydantic, она проверяется **ещё до того, как хоть один байт уйдёт в сеть**.
Опустите `redirect_uris` — и создание объекта тут же завершится ошибкой `ValidationError`,
в которой названо поле:
```text
redirect_uris
Field required [type=missing, input_value={'client_name': 'Bookshop Agent'}, input_type=dict]
```
Браузер не открылся, и на сервере авторизации не осталось недоделанной регистрации.
### Хранилище токенов {#token-storage}
**`TokenStorage`** — это `Protocol` с четырьмя асинхронными методами. Наследоваться ни от чего не нужно: напишите эти методы — и любой класс станет хранилищем токенов:
* `get_tokens` / `set_tokens` хранят `OAuthToken`: токен доступа, токен обновления, срок действия, область доступа (scope).
* `get_client_info` / `set_client_info` хранят `OAuthClientInformationFull`, который сервер авторизации выдал, когда провайдер вас зарегистрировал, — включая ваш `client_id`.
Версия в памяти из примера выше работает. Но при завершении процесса она всё забывает, так что при следующем запуске вся процедура повторяется с начала. Сохраняйте данные в файл или в связку ключей вашей платформы — и следующий запуск пройдёт тихо.
!!! tip
Сохраняйте `client_info`, а не только токены. Провайдер проходит динамическую регистрацию
в первый раз, когда не находит сохранённого `client_info`. Выбросьте его — и при каждом
запуске будет создаваться новая регистрация.
### Два обработчика {#the-two-handlers}
Сценарию с кодом авторизации человек нужен ровно один раз: кто-то должен войти в систему и нажать «разрешить».
* **`redirect_handler`** асинхронно вызывается с полностью собранным URL авторизации. `client_id`, `redirect_uri`, `state` и PKCE challenge в нём уже есть. Ваша единственная задача — открыть его в браузере. Настольное приложение вызывает `webbrowser.open`; в этом файле URL просто печатается.
* **`callback_handler`** вызывается следующим. Он ждёт, пока пользователь вернётся на ваш `redirect_uri`, и возвращает параметры запроса из этого перенаправления в виде `AuthorizationCodeResult`.
Настоящий клиент вместо вызова `input()` поднимает небольшой локальный HTTP-сервер на URI перенаправления. Схема та же: принять перенаправление, вернуть `code`, `state` и `iss`.
!!! warning
Передавайте `state` и `iss` ровно в том виде, в каком они пришли. Провайдер сравнивает
`state` со значением, которое сгенерировал сам, а `iss` — с издателем, которого обнаружил,
и при несовпадении отказывает. Это защита от CSRF и от подмены сервера (mix-up).
### Подключение к `Client` {#into-the-client}
Посмотрите на `main()`. Провайдер подключается к **клиенту httpx2**, клиент httpx2 передаётся в `streamable_http_client(url, http_client=...)`, а этот транспорт — в `Client`.
У `streamable_http_client` нет именованного параметра `auth=`. Всё, что относится к уровню HTTP (аутентификация, заголовки, тайм-ауты, прокси), настраивается на `httpx2.AsyncClient`, который вы приносите сами. Об этом разделении уровней — на странице **[Транспорты клиента](transports.md)**.
## Что провайдер делает за вас {#what-the-provider-does-for-you}
Когда `Client` отправляет первый запрос, сервер отвечает `401`. Дальше действует провайдер:
1. **Обнаружение.** Он читает заголовок `WWW-Authenticate`, загружает метаданные защищённого ресурса (Protected Resource Metadata) сервера с `/.well-known/oauth-protected-resource`, узнаёт, какой сервер авторизации защищает этот ресурс, и загружает метаданные уже *того* сервера.
2. **Регистрация.** В хранилище пусто? Он динамически регистрирует вас с вашим `OAuthClientMetadata` и сохраняет результат.
3. **Авторизация.** Он генерирует пару PKCE и `state`, собирает URL авторизации, ждёт ваш `redirect_handler`, а затем ждёт от `callback_handler` код.
4. **Обмен.** Он обменивает код на `OAuthToken`, сохраняет его и повторяет исходный запрос уже с `Authorization: Bearer ...`.
После этого он работает незаметно. Токены берутся из хранилища, истёкший токен доступа обновляется с помощью токена обновления, и только когда ничего из этого не срабатывает, сценарий запускается заново.
Ничего из этого вы не писали. Остаются два именованных аргумента (`client_metadata_url` и `validate_resource_url`), и этому файлу не нужен ни один из них. О `client_metadata_url` стоит знать — ему посвящён отдельный раздел ниже.
### Попробуйте сами {#try-it}
Большинство примеров в этой документации можно проверить с `Client(server)` в памяти. Этот — нет: весь смысл сценария в HTTP-ответе `401`, а между клиентом в памяти и его сервером никакого HTTP нет.
В репозитории есть живая версия. `examples/servers/simple-auth/` запускает отдельный сервер авторизации и защищённый MCP-сервер; `examples/clients/simple-auth-client/` — это клиент с этой страницы, выросший в небольшой CLI. В его README — две команды: запустите серверы, запустите клиент, подключив его к ним, — и наблюдайте, как проходят все четыре шага.
## Client ID Metadata Documents {#client-id-metadata-documents}
Ревизия спецификации 2026-07-28 объявляет динамическую регистрацию клиентов устаревшей в пользу **Client ID Metadata Documents** (CIMD). Вместо того чтобы отправлять POST-запросом новую регистрацию каждому встреченному серверу авторизации, клиент публикует один JSON-документ о себе по стабильному HTTPS URL — и этот URL *и есть* его `client_id`. Документ загружает сервер авторизации; провайдер его вообще не трогает.
SDK это уже умеет: передайте URL как `client_metadata_url=` при создании провайдера. Если метаданные сервера авторизации объявляют `client_id_metadata_document_supported: true`, провайдер полностью пропускает запрос `/register`: URL идёт в сценарий как `client_id`, а `client_secret` нет вовсе. Если сервер этого не объявляет (большинство пока не объявляет) или URL не передан, провайдер **молча** откатывается к динамической регистрации, и всё описанное выше работает ровно так, как описано. Сохранённый `client_info` по-прежнему имеет приоритет над обоими вариантами.
URL должен быть HTTPS и с некорневым путём; всё остальное — `ValueError` при создании, до любого обращения к сети. Поставляемый пример `examples/clients/simple-auth-client/` принимает его в переменной окружения `MCP_CLIENT_METADATA_URL`.
## Межмашинное взаимодействие {#machine-to-machine}
Ночное задание, шаг CI, другой сервис. Браузера нет, и нажать «разрешить» некому. Это грант **client credentials**: `client_id` и `client_secret` у вас уже есть, а весь сценарий сводится к конечной точке токенов.
`ClientCredentialsOAuthProvider` — тот же `httpx2.Auth`, только без человека:
```python title="client.py" hl_lines="4 27-33"
--8<-- "docs_src/oauth_clients/tutorial002.py"
```
Что изменилось:
* Нет `OAuthClientMetadata`, нет обработчиков. Вы передаёте `client_id` и `client_secret`; провайдер строит вокруг них минимальную регистрацию `client_credentials` и полностью пропускает динамическую регистрацию.
* `scope` — строка с разделением пробелами, формат OAuth для передачи по сети.
* Всё дальше по цепочке идентично: тот же `TokenStorage`, тот же `httpx2.AsyncClient(auth=...)`, тот же `streamable_http_client`.
По умолчанию секрет передаётся в запросе токена через HTTP Basic auth (`client_secret_basic`). Передайте `token_endpoint_auth_method="client_secret_post"`, чтобы вместо этого поместить его в тело формы. Некоторые серверы авторизации принимают только один из двух способов.
!!! tip
Читайте `client_secret` из окружения или менеджера секретов, никогда не из системы контроля версий.
!!! info
Ещё один провайдер находится в `mcp.client.auth.extensions.client_credentials`:
**`PrivateKeyJWTOAuthProvider`** — для клиентов, которые аутентифицируются с помощью JWT
вместо общего секрета (`private_key_jwt`, вариант с парой ключей и workload identity).
Схема та же: создайте экземпляр и передайте его в `auth=`. В том же модуле есть
`SignedJWTParameters` и `static_assertion_provider` — два вспомогательных средства,
которые собирают для него утверждение (assertion).
Есть ещё одна ситуация без человека: клиент принадлежит организации, чей провайдер удостоверений, а не пользователь, решает, к каким MCP-серверам он может обращаться. Это другой грант со своей моделью доверия и своей страницей — **[Подтверждение идентичности](identity-assertion.md)**.
## Когда возникает ошибка {#when-it-fails}
Когда сценарий OAuth идёт не так, провайдер выбрасывает `OAuthFlowError` из `mcp.client.auth`. У него два подкласса. `OAuthRegistrationError` означает, что регистрация не дала пригодного клиента: сервер авторизации отказал в регистрации или всё же зарегистрировал вас, но с учётными данными, которые этот сценарий использовать не может (например, с методом аутентификации, который он не реализует). `OAuthTokenError` означает, что получить токен не удалось: конечная точка токенов ответила отказом, или в сохранённой записи клиента указан метод аутентификации, который этот клиент применить не может, — об этом сообщается при сборке запроса токена, а не после его отправки. Один `except OAuthFlowError:` охватывает обнаружение, регистрацию, авторизацию и обмен.
Не всё — ошибка сценария. Сеть по-прежнему может подвести; это обычные исключения `httpx2`, и они проходят насквозь без изменений.
## Итоги {#recap}
* `OAuthClientProvider` — это `httpx2.Auth`. Подключите его к `httpx2.AsyncClient`, передайте тот в `streamable_http_client(url, http_client=...)` — и `Client` так и не узнает, что был OAuth.
* От вас нужны четыре вещи: URL сервера, `OAuthClientMetadata`, `TokenStorage` и пара обработчиков redirect/callback.
* `TokenStorage` — это `Protocol`: четыре асинхронных метода, без базового класса. Сохраняйте `client_info` наряду с токенами.
* Обнаружение, регистрация (динамическая или через **Client ID Metadata Document**), PKCE, проверки `state` и `iss` и обновление токенов — забота провайдера, а не ваша.
* `ClientCredentialsOAuthProvider` — версия без человека: `client_id` + `client_secret`, без обработчиков, без браузера.
* Любой сбой OAuth — это `OAuthFlowError`; `OAuthRegistrationError` и `OAuthTokenError` — его подклассы.
Вторая половина этого рукопожатия — как заставить ваш *сервер* требовать токен — на странице **[Авторизация](../run/authorization.md)**.

View file

@ -0,0 +1,87 @@
---
translation:
sections: [09c857a25a9dc37a, 43bc6a76a243a50e, 0a716022a88768df, 4b7f78042bfcfff7, c112662e61b03315, 58974ba1f489a8b4, d18adbdbb835ea73]
tool: 1
---
# Группы сессий {#session-groups}
`Client` подключается к одному серверу. Настоящим приложениям часто нужно несколько (поисковый сервер, сервер базы данных, внутренний API), и в итоге приходится отдельно вести подключение и список инструментов для каждого.
**`ClientSessionGroup`** — это один объект, который держит много подключений и сводит всё, что они предоставляют, в единое представление.
## Два сервера {#two-servers}
Начнём с двух обычных серверов. Они никак не связаны друг с другом, поэтому оба, естественно, назвали свой инструмент `search`:
```python title="library_server.py" hl_lines="7"
--8<-- "docs_src/session_groups/tutorial001.py"
```
```python title="web_server.py" hl_lines="7"
--8<-- "docs_src/session_groups/tutorial002.py"
```
## Одна группа {#one-group}
Создайте `ClientSessionGroup` и вызовите **`connect_to_server`** по одному разу на каждый сервер:
```python title="client.py" hl_lines="10-12"
--8<-- "docs_src/session_groups/tutorial003.py"
```
* `connect_to_server` принимает параметры транспорта, а не объект сервера: `StdioServerParameters` (из `mcp`), чтобы запустить подпроцесс, или `StreamableHttpParameters` / `SseServerParameters` (из `mcp.client.session_group`) для сервера, который уже слушает по URL.
* `group.tools` — это `dict[str, Tool]` с инструментами всех подключённых серверов. `group.resources` и `group.prompts` устроены так же.
* `group.call_tool(name, arguments)` ищет имя, находит сессию, которой оно принадлежит, и перенаправляет вызов. Какой именно сервер — указывать не нужно.
!!! check
Положите `client.py` рядом с двумя серверами и запустите его. Второй вызов `connect_to_server` завершится отказом:
```text
mcp.shared.exceptions.MCPError: {'search'} already exist in group tools.
```
Это `MCPError`, выброшенное ещё до того, как что-либо от второго сервера было зарегистрировано. Имя должно
быть уникальным в пределах **всей** группы, а два сервера, которые вы не контролируете, рано или поздно столкнутся.
## `component_name_hook` {#component_name_hook}
Исправляется это на уровне группы, а не серверов. Передайте функцию от `(name, server_info)`, и группа будет применять её к каждому регистрируемому имени:
```python title="client.py" hl_lines="7-8 15"
--8<-- "docs_src/session_groups/tutorial004.py"
```
Запустите снова. Теперь `print(sorted(group.tools))` показывает оба:
```text
['Library.search', 'Web.search']
```
* **Ключ** — ваш. `by_server` собрала его из `server_info.name` — имени, с которым был создан каждый `MCPServer(...)`.
* `Tool` внутри не тронут: `group.tools["Web.search"].name` по-прежнему `"search"`, и именно это имя `call_tool` передаёт по сети. Префикс никогда не покидает ваш процесс.
* Это касается не только инструментов. Ресурс `hours` библиотеки зарегистрирован как `Library.hours`.
!!! tip
Хук применяется к **каждому** имени от **каждого** сервера, а не только при конфликтах: режима
«префикс при столкновении» нет. Выберите одну схему и пусть она действует везде.
## Добавление и удаление серверов {#adding-and-removing-servers}
`connect_to_server` возвращает открытую им `ClientSession`. Сохраните её, если когда-нибудь захотите убрать этот сервер: `await group.disconnect_from_server(session)` удаляет его инструменты, ресурсы и промпты из группы.
Если уже есть подключённая `ClientSession` (например, `Client.session`), передайте её в `await group.connect_with_session(server_info, session)` вместо того, чтобы открывать новый транспорт. Агрегирование работает так же. Группа никогда не закрывает сессию, которую открыла не она. `server_info` задаёт имя сервера для префиксов компонентов; на подключении поколения 2026 `client.server_info` может быть `None` (идентификация необязательна), так что в этом случае передайте собственный `Implementation(name=..., version=...)`.
## Классическое рукопожатие {#the-classic-handshake}
`ClientSessionGroup` построен на `ClientSession`, а не на `Client`. Каждый вызов `connect_to_server` выполняет классическое рукопожатие `initialize`. Он никогда не отправляет пробный запрос `server/discover`, описанный на странице **[Версии протокола](../protocol-versions.md)**. Это рукопожатие понимает любой MCP-сервер, так что совместимостью вы не жертвуете ни с чем; это лишь значит, что к серверу, который умеет лучше, группа идёт более старым и медленным путём.
## Итоги {#recap}
* `ClientSessionGroup` держит много подключений к серверам и сводит их инструменты, ресурсы и промпты в один `dict` для каждого вида.
* `connect_to_server(params)` — по одному на сервер. Принимает параметры транспорта, а не объект сервера или URL, как `Client`.
* `group.call_tool(name, arguments)` сам направляет вызов на сервер-владелец.
* Имена должны быть уникальны в пределах всей группы; два сервера с инструментом `search` сами по себе ужиться не могут.
* `component_name_hook=` переписывает каждое регистрируемое имя. Меняется ключ словаря, но не имя в передаваемых данных.
* `connect_with_session` добавляет сессию, которая у вас уже есть; `disconnect_from_server` удаляет сессию.
Рукопожатию, на котором говорит группа (и более быстрому, которое предпочитает `Client`), посвящена страница **[Версии протокола](../protocol-versions.md)**.

View file

@ -0,0 +1,91 @@
---
translation:
sections: [8f9558e57f29eee1, a88c587739e0465c, 46ebfd5b325ed041, 4d10b00b57ce4bd9, 2cdb0edd1f59b3e2]
tool: 1
---
# Подписки {#subscriptions}
Каталог сервера не постоянен. Инструменты появляются во время работы, а содержимое по URI ресурса меняется. Клиент узнаёт об этом через `client.listen(...)`: один запрос `subscriptions/listen`, ответ на который и *есть* поток. Он остаётся открытым и несёт те уведомления об изменениях, которые запросил клиент.
Эта страница — о клиентской стороне: как открыть поток, наблюдать за ним рядом с основной логикой и обрабатывать его завершение. Публикация изменений, фильтрация и обслуживание метода — серверная сторона, о ней рассказано на странице **[Подписки](../handlers/subscriptions.md)** в разделе *Внутри обработчика*. Примеры здесь общаются с сервером спринт-доски, построенным там.
## Наблюдение за потоком {#watching-the-stream}
Подписка — это один контекстный менеджер. Вход в него отправляет запрос (именованные аргументы становятся фильтром подписки) и дожидается подтверждения от сервера, так что к началу блока поток уже работает.
```python title="client.py" hl_lines="15 18 28"
--8<-- "docs_src/subscriptions/tutorial003.py"
```
Итерация выдаёт четыре типизированных события: `ToolsListChanged`, `PromptsListChanged`, `ResourcesListChanged` и `ResourceUpdated(uri=...)`.
Событие говорит, *что* изменилось, но никогда — *как*. Поэтому `follow_board` вызывает `read_resource` и `list_tools`: событие — это сигнал запросить данные заново. Читайте `event.uri`, а не предполагайте, какой ресурс изменился: фильтр может перечислять несколько URI, а сервер может сообщить об изменении подресурса одного из них.
Дубликаты событий, ожидающих обработки, схлопываются в одно, а повторный запрос всё равно даёт актуальное состояние. Схлопываются только одинаковые события: два `ResourceUpdated` для разных URI — это два события.
Ещё два свойства дескриптора:
* `sub.honored` — фильтр, который подтвердил сервер: `SubscriptionFilter` с переданными вами полями, доступными как атрибуты (`sub.honored.prompts_list_changed`). `MCPServer` принимает все виды, которые вы запросили, поэтому возвращает запрос как есть. Сервер, поддерживающий меньше видов, подтверждает меньше, а подтверждённый вид всё равно может ни разу не сработать. Сервер может и отклонить запрос целиком вместо подтверждения (см. [Кому разрешено наблюдать](../handlers/subscriptions.md#deciding-who-may-watch) на странице сервера) — это проявится как ошибка запроса.
* `sub.subscription_id` — идентификатор запроса listen, тот самый, что проставлен на каждом кадре этого потока. Одновременно может быть открыто несколько подписок, и каждая демультиплексируется по своему идентификатору.
## Наблюдение без блокировки {#watching-without-blocking}
`follow_board` работает, пока сервер не закроет поток, а этого может не случиться никогда, так что сама по себе она забирает всю программу. Настоящим клиентам наблюдатель нужен *рядом* с основной логикой: агент вызывает инструменты, пока наблюдатель поддерживает актуальность кэша или интерфейса.
Сначала откройте подписку, затем запустите задачу-наблюдатель и занимайтесь своей работой.
=== "asyncio"
```python title="app.py" hl_lines="18 20"
--8<-- "docs_src/subscriptions/tutorial004_asyncio.py"
```
=== "trio"
```python title="app.py" hl_lines="18 21"
--8<-- "docs_src/subscriptions/tutorial004_trio.py"
```
=== "anyio"
```python title="app.py" hl_lines="18 21"
--8<-- "docs_src/subscriptions/tutorial004_anyio.py"
```
!!! note
`app.py` импортирует `BOARD` и `read_board` из первого примера, который в этом репозитории
хранится как `tutorial003.py`. Если вы сохраните показанные файлы рядом под именами `client.py`
и `app.py`, напишите вместо этого `from client import BOARD, read_board`. Пример `watch.py`
ниже импортирует `read_board` так же.
Всё дело в порядке. Ничего не воспроизводится повторно, поэтому событие, опубликованное до появления потока, теряется. Вход в `client.listen(...)` дожидается подтверждения, так что каждое изменение с этого момента доходит до наблюдателя, и снимок, сделанный внутри блока, не может ни одно пропустить.
Запросы свободно выполняются рядом с открытым потоком — из задачи-наблюдателя или любой другой, на том же клиенте. Поскольку *одинаковые* необработанные события объединяются, загруженная основная логика может дать один повторный запрос вместо трёх. Разные события не объединяются: фильтр со многими URI ставит в очередь по одному ожидающему событию на каждый URI.
Чтобы прекратить наблюдение, выйдите из блока: вызова `unsubscribe` нет. Отмена задачи, владеющей блоком, делает это за вас, а SDK отменяет запрос listen так, как того ожидает транспорт: в Streamable HTTP — закрытием потока этого запроса. Наблюдатель, работающий всё время жизни приложения, сам никогда не завершится, поэтому отмените его (или область его группы задач) при завершении работы.
## Потоки заканчиваются {#streams-end}
Поток заканчивается одним из двух способов, и оба — штатный ход выполнения. Корректное закрытие сервером завершает `async for`; резкий обрыв выбрасывает `SubscriptionLost`.
Разница диагностическая, а не в том, что делать дальше: потока больше нет, ничего не воспроизводилось повторно, и наблюдатель, которому это всё ещё нужно, слушает заново и перезапрашивает данные.
```python title="watch.py" hl_lines="16 20"
--8<-- "docs_src/subscriptions/tutorial005.py"
```
Серверы корректно закрывают потоки по своим причинам, в том числе чтобы сбросить подписчика, чья очередь слишком разрослась, поэтому чистое завершение — не сигнал прекратить наблюдение. Перед повторным прослушиванием сделайте паузу.
У `SubscriptionLost` есть и одна локальная причина. Клиент хранит не более 1024 необработанных событий, и потребитель, отставший настолько, теряет подписку, а не растёт без ограничений. Держите тело `async for` коротким, а медленную работу выполняйте в другом месте.
`keep_following` перехватывает только `SubscriptionLost`. Вход в `listen()` может также выбросить `MCPError` (сбой подключения или сервер не обслуживает метод), `TimeoutError` (подтверждение не пришло) и `ListenNotSupportedError` (подключение до поколения 2026). Решите, какие из них наблюдателю стоит повторять: последняя не проходит никогда.
## Итоги {#recap}
* Входите в `async with client.listen(...)`; вход дожидается подтверждения, поэтому ничего из опубликованного после него не теряется.
* Итерируйте с помощью `async for event in sub`. События — сигналы запросить данные заново, а не сами данные.
* Откройте подписку, затем запустите наблюдатель отдельной задачей — и вызовы инструментов продолжают идти рядом.
* Чистое завершение останавливает цикл; обрыв выбрасывает `SubscriptionLost`. В обоих случаях: слушайте заново, перезапросите данные, но сначала сделайте паузу.
* Выход из блока и есть отписка.
Публикация этих событий, сужение фильтра и масштабирование за пределы одного процесса — серверная сторона: **[Подписки](../handlers/subscriptions.md)**. Эти же события помогают клиентскому кэшу оставаться актуальным, и следующая страница — **[Кэширование](caching.md)**.

View file

@ -0,0 +1,128 @@
---
translation:
sections: [9cac816674181eb0, 0700f337babcd4dd, 2bde0dd58cdf00f5, ff7401df479af877, 3d0832f39b0d7059, d4bf7e4479637768, 05e20c0a798860e7]
tool: 1
---
# Клиентские транспорты {#client-transports}
Каждый `Client` общается со своим сервером через **транспорт** — то, что на самом деле переносит сообщения.
Настраивать его отдельно не нужно. `Client` принимает один позиционный аргумент и определяет транспорт по его типу.
*Серверная* сторона каждого из них (то, что делает `mcp.run()` и что вы развёртываете) описана на странице **[Запуск сервера](../run/index.md)**.
## В памяти {#in-memory}
Передайте сам объект сервера:
```python title="client.py" hl_lines="14"
--8<-- "docs_src/client_transports/tutorial001.py"
```
Ни подпроцесса, ни порта, ни байтов в передаваемых данных. Клиент и сервер — два объекта в одном процессе, и вызов всё равно проходит через настоящий протокольный уровень: `search_books` перечисляется, валидируется и вызывается ровно так же, как это было бы по HTTP.
Поэтому это сразу две вещи:
* **Тестовый стенд.** Каждый пример в этой документации проверяется именно так, а страница **[Тестирование](../get-started/testing.md)** строит вокруг этого весь подход.
* **API для встраивания.** Приложению, которое создаёт сервер, не нужен сетевой переход, чтобы вызывать его инструменты.
## Streamable HTTP {#streamable-http}
Передайте строку с URL — и получите **Streamable HTTP**, транспорт, за которым вы развёртываете сервер:
```python title="client.py" hl_lines="5"
--8<-- "docs_src/client_transports/tutorial002.py"
```
Это уже готовый клиент для продакшена. `Client` сам оборачивает URL в `streamable_http_client(...)` поверх `httpx2.AsyncClient`, настроенного так, как нужно MCP: `follow_redirects=True`, таймаут 30 секунд на connect/write/pool и таймаут чтения 300 секунд, потому что сервер может держать поток ответа открытым.
!!! check
Созданный `Client` **не** подключён. Конструктор только выбирает транспорт;
открывает его `async with`. Обратитесь к соединению до входа в блок — и SDK сообщит об этом:
```text
RuntimeError: Client must be used within an async context manager
```
Когда вы написали `Client("http://...")`, ничего не разрешалось, не загружалось и не запускалось. Эта строка ничего не стоит.
### Собственный `httpx2.AsyncClient` {#bring-your-own-httpx2asyncclient}
Как только понадобится заголовок `Authorization`, cookie, прокси, mTLS или другой таймаут, создайте `httpx2.AsyncClient` сами и передайте его в `streamable_http_client`:
```python title="client.py" hl_lines="8-14"
--8<-- "docs_src/client_transports/tutorial003.py"
```
Обратите внимание на две вещи:
* `httpx2.AsyncClient` принадлежит вам, поэтому входите в него и выходите из него **вы**. SDK никогда не закрывает клиент, который он не создавал.
* `streamable_http_client(url, http_client=...)` возвращает транспорт, а `Client(transport)` принимает его, как и всё остальное.
Одно замечание о TLS: `httpx2` проверяет сертификаты по хранилищу доверия операционной системы (через
[`truststore`](https://pypi.org/project/truststore/)), а не по встроенному списку CA. В среде без
пригодного системного хранилища CA (некоторые минимальные контейнеры) задайте стандартные
переменные окружения `SSL_CERT_FILE`/`SSL_CERT_DIR` или передайте явный `verify=ssl_context` в свой `httpx2.AsyncClient`
(подробности в разделе
[`httpx` и `httpx-sse` заменены на `httpx2`](../migration.md#httpx-and-httpx-sse-replaced-by-httpx2)).
!!! warning
Раньше `streamable_http_client` принимал `headers=` и `timeout=` напрямую. Больше не принимает:
его единственные параметры — `url`, `http_client` и `terminate_on_close`. Напишите по привычке `headers=` —
и получите:
```text
TypeError: streamable_http_client() got an unexpected keyword argument 'headers'
```
Всё, что относится к HTTP, теперь живёт в одном `httpx2.AsyncClient`, который вы передаёте.
!!! info
`httpx2` сохраняет привычный API `httpx`, так что, если вы знаете `httpx`, вы уже умеете делать здесь
аутентификацию, прокси, хуки событий, повторные попытки и ограничения соединений. SDK ничего не добавляет сверху
и ничего не убирает. Здесь же подключается OAuth:
`httpx2.AsyncClient(auth=OAuthClientProvider(...))`. Весь этот сценарий — на странице **[OAuth-клиенты](oauth-clients.md)**.
## stdio {#stdio}
Сервер **stdio** — это подпроцесс. Клиент запускает его, пишет JSON-RPC в его stdin и читает JSON-RPC из его stdout. Именно так десктопный хост запускает сервер на вашей машине: хост — это *и есть* этот код плюс UI, а страница **[Подключение к реальному хосту](../get-started/real-host.md)** показывает те же отношения со стороны хоста, в виде файла конфигурации.
Опишите процесс с помощью `StdioServerParameters`, превратите его в транспорт с помощью `stdio_client` и передайте *его* в `Client`:
```python title="client.py" hl_lines="4-8 12"
--8<-- "docs_src/client_transports/tutorial004.py"
```
Сам по себе объект параметров `Client` не принимает. `StdioServerParameters` — это конфигурация; `stdio_client(server)` — транспорт, который умеет запускать по ней процесс. Всегда оборачивайте.
Выход из блока `async with` заодно завершает подпроцесс: закрывает stdin, ждёт, убивает, если тот задерживается. Убирать за ним самостоятельно не нужно.
!!! warning
Дочерний процесс **не** наследует ваше окружение. Он получает минимальный разрешённый список (`HOME`, `LOGNAME`,
`PATH`, `SHELL`, `TERM` и `USER` в POSIX), чтобы ничего чувствительного не утекло в процесс, который,
возможно, написали не вы.
Сервер, которому нужен API-ключ, там его не найдёт. Передайте его явно через `env=`; эти
переменные добавляются поверх разрешённого списка. Именно это делает `BOOKSHOP_API_KEY` выше.
## SSE {#sse}
`sse_client(url)` из `mcp.client.sse` — это HTTP-транспорт, который заменил Streamable HTTP. Оборачивайте его так же, `Client(sse_client("http://localhost:8000/sse"))`, чтобы общаться с сервером, который всё ещё на нём говорит, и не стройте на нём ничего нового.
## Протокол `Transport` {#the-transport-protocol}
Для `Client` всё перечисленное — одно и то же.
**Транспорт** — это любой асинхронный контекстный менеджер, который отдаёт пару потоков сообщений `(read, write)`: формально — протокол `Transport` из `mcp.client`. `Client` разрешает свой аргумент по типу: объект сервера подключается внутри процесса, `str` превращается в `streamable_http_client(url)`, а всё остальное используется как транспорт напрямую. Благодаря последнему правилу `stdio_client(...)`, `streamable_http_client(...)` и `sse_client(...)` подходят в одно и то же место — и вы можете написать свой.
## Итоги {#recap}
* `Client(mcp)` (объект сервера) подключается в памяти. Используйте для тестов и для встраивания.
* `Client("http://.../mcp")` (URL) подключается по Streamable HTTP, транспорту для продакшена.
* Заголовки, аутентификация, прокси и таймауты задаются на `httpx2.AsyncClient`, который передаётся в `streamable_http_client(url, http_client=...)`. Именованного аргумента `headers=` нет.
* stdio — это `Client(stdio_client(StdioServerParameters(...)))`, и никогда не объект параметров сам по себе.
* Подпроцесс получает окружение из разрешённого списка, а не ваше; `env=` добавляет к нему.
* Транспорт — это всё, с чем можно написать `async with x as (read, write)`. `Client` передаёт всё, что не объект сервера и не URL, прямо в этот протокол.
* Создание `Client` выбирает транспорт. `async with` его открывает.
Когда транспорт открыт, двум сторонам нужно договориться о версии протокола. Обычно думать об этом не приходится; когда всё-таки придётся, нужная страница — **[Версии протокола](../protocol-versions.md)**.