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}
Кожен результат, який сервер повертає для `tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read` і `server/discover`, у протоколі 2026-07-28 містить два поля: `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"` — теж допустимий ключ, бо результат discovery кешується так само, як будь-який список.
!!! 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 іде в один бік: від клієнта до сервера.
Сервер теж може дещо попросити в **клієнта**: поставити запитання користувачеві, скористатися моделлю користувача для семплювання (sampling), отримати список папок його робочого простору. На ці запити відповідають **колбеки**, які передаються в `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_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,152 @@
---
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)` — єдиний код, який ви пишете. Його очікують (await) один раз на кожен обмін токенів, ніколи під час створення провайдера, і лише *після* того, як метадані сервера авторизації отримано й перевірено, тож неправильно налаштований емітент ніколи не призведе до витоку твердження. Два його аргументи — це два з полів (claims), з якими має бути випущено 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. **Твердження.** Він очікує (await) ваш `assertion_provider`.
3. **Обмін.** Він надсилає грант `jwt-bearer` методом POST на кінцеву точку токена, зберігає `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` і вказують `urn:ietf:params:oauth:grant-profile:id-jag` у `authorization_grant_profiles_supported` — полі, яким розширення оголошує підтримку. (Клієнт цього 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`, жодного отримання protected-resource-metadata.
Єдині запити в мережі — той, що отримав `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` виконує весь обмін і перевіряє (assert), що користувач, якого назвав 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
---
# Клієнт {#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"
}
```
Ця схема — усе, що потрібно UI, щоб показати форму аргументів, і все, що потрібно моделі, щоб сформувати коректні аргументи.
!!! tip
`title` необов'язковий, тож UI, що показує інструменти людині, має обирати: `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,152 @@
---
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`: токен доступу, токен оновлення, термін дії, область дії.
* `get_client_info` / `set_client_info` зберігають `OAuthClientInformationFull`, який сервер авторизації видав, коли провайдер вас зареєстрував, — разом із вашим `client_id`.
Наведена вище версія в пам'яті працює. Але вона все забуває, щойно процес завершується, тож наступний запуск повторює всю процедуру з початку. Збережіть дані у файл або в системне сховище ключів вашої платформи — і наступний запуск пройде тихо.
!!! tip
Зберігайте `client_info`, а не лише токени. Провайдер реєструється динамічно, коли вперше
не знаходить збереженого `client_info`. Викиньте його — і кожен запуск створюватиме нову реєстрацію.
### Два обробники {#the-two-handlers}
Потоку authorization code людина потрібна рівно один раз: хтось має увійти й натиснути «Дозволити».
* **`redirect_handler`** викликається через await із повністю зібраним URL авторизації. `client_id`, `redirect_uri`, `state` і PKCE challenge уже в ньому. Ваше єдине завдання — відкрити його в браузері. Настільний застосунок викликає `webbrowser.open`; цей файл просто друкує його.
* **`callback_handler`** очікується наступним. Він чекає, доки користувач повернеться на ваш `redirect_uri`, і повертає параметри запиту цього перенаправлення як `AuthorizationCodeResult`.
Справжній клієнт замість виклику `input()` запускає невеликий локальний HTTP-сервер на redirect 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)`. Усе, що не є об'єктом сервера чи URL, `Client` передає прямо цьому протоколу.
* Створення `Client` обирає транспорт. `async with` його відкриває.
Щойно транспорт відкрито, обидві сторони мають домовитися про версію протоколу. Зазвичай про це не думаєш; а коли доводиться — є сторінка **[Версії протоколу](../protocol-versions.md)**.