Обслуговування клієнтів старого покоління
Машинний переклад
Цю сторінку перекладено автоматично з англомовної документації, і основною версією є англомовна сторінка. Якщо щось читається неправильно, на сторінці Переклади пояснено, як про це повідомити.
У MCP є два покоління протоколу: покоління рукостискання initialize — до версії специфікації 2025-11-25 включно — і сучасне покоління, 2026-07-28. Самому цьому поділу присвячена сторінка Версії протоколу.
Ця сторінка — про серверний бік цього поділу, і відповідь уміщується в одне речення: streamable_http_app(), який ви вже розгортаєте, обслуговує обидва.
SDK маршрутизує кожен запит за його заголовком MCP-Protocol-Version. Запит, що називає 2026-07-28, потрапляє до сучасного обробника. Запит, що називає версію покоління рукостискання або взагалі не має заголовка (саме так приходить initialize від клієнта до 2026 року), потрапляє до транспорту, якого ці клієнти й очікують: з рукостисканням initialize, сесіями й усім іншим. Це відбувається для кожного запиту окремо, ще до вашого коду, в одному й тому самому застосунку.
Тож клієнт старого покоління — це не те, під що ви щось будуєте. Це те, що під'єднується до сервера, який ви вже написали. Налаштовувати нічого не потрібно.
Note
Нічого — буквально. Немає параметра legacy=, немає списку дозволених версій, немає
способу відхилити чи вимкнути покоління: ні в streamable_http_app(), ні в run(), ні в
менеджері сесій. Обидва покоління завжди ввімкнені. Найближче до перемикача за поколіннями
в цій сигнатурі — stateless_http, і йому присвячено більшу частину цієї сторінки.
Один обробник, обидва покоління
Ось інструмент, якому треба дещо запитати в користувача, і клієнти обох поколінь, що його викликають:
from typing import Annotated
from pydantic import BaseModel
from mcp import Client
from mcp.client import ClientRequestContext
from mcp.server import MCPServer
from mcp.server.mcpserver import AcceptedElicitation, Elicit, ElicitationResult, Resolve
from mcp.types import ElicitRequestParams, ElicitResult
mcp = MCPServer("Bookshop")
class Quantity(BaseModel):
copies: int
async def ask_quantity() -> Elicit[Quantity]:
"""Resolver: ask the user how many copies to put aside."""
return Elicit("How many copies?", Quantity)
@mcp.tool()
async def reserve(title: str, quantity: Annotated[ElicitationResult[Quantity], Resolve(ask_quantity)]) -> str:
"""Reserve copies of a book, asking the user how many."""
if isinstance(quantity, AcceptedElicitation):
return f"Reserved {quantity.data.copies} of {title!r}."
return "Nothing reserved."
async def answer(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
return ElicitResult(action="accept", content={"copies": 2})
async def main() -> None:
async with (
Client(mcp, mode="legacy", elicitation_callback=answer) as legacy,
Client(mcp, elicitation_callback=answer) as modern,
):
for client in (legacy, modern):
result = await client.call_tool("reserve", {"title": "Dune"})
print(client.protocol_version, result.structured_content)
reserve потребує однієї речі, якої модель не надала: скільки примірників. Через Annotated[..., Resolve(ask_quantity)] інструмент це й оголошує (докладніше — на сторінці Залежності). Ніщо в reserve не називає версію, не перевіряє можливість і не розгалужується.
Обидва клієнти відкриті одночасно, на тому самому об'єкті mcp. mode="legacy" виконує рукостискання initialize — саме те з'єднання, яке відкриває клієнт до 2026 року. Другий клієнт бере значення за замовчуванням і потрапляє на 2026-07-28.
2025-11-25 {'result': "Reserved 2 of 'Dune'."}
2026-07-28 {'result': "Reserved 2 of 'Dune'."}
Той самий сервер, той самий обробник, та сама відповідь. Оце й увесь механізм.
Варто зупинитися на тому, як саме, бо обом клієнтам поставили те саме запитання двома зовсім різними шляхами передачі. У з'єднанні 2026-07-28 сервер не має каналу, яким міг би надіслати запит, тож Resolve повернув запитання всередині результату інструмента, а клієнт повторив виклик уже з відповіддю (Багатораундові запити (multi-round-trip)). У з'єднанні 2025-11-25 нічого подібного немає; там Resolve надіслав живий запит elicitation/create просто посеред виклику й чекав. Ви не писали ні того, ні іншого. Resolve читає узгоджену версію з'єднання й обирає сам; тіло інструмента в обох випадках отримує AcceptedElicitation.
Tip
Саме ця переносність між поколіннями — причина, чому будувати варто на Resolve. Його
старший родич ctx.elicit() (Еліцитація (elicitation))
завжди надсилає лише elicitation/create, а отже працює лише на з'єднанні старого
покоління. На з'єднанні 2026-07-28 виклик завершується помилкою. Якщо інструмент досі
ним користується, виправлення — те, що показано вище, а не перевірка версії.
Чого коштує сесія старого покоління
Маршрутизація безкоштовна. Сесія — ні.
З'єднання 2026-07-28 без сесій: кожен запит самодостатній, а сучасний обробник ніколи не видає Mcp-Session-Id. З'єднання старого покоління — повна протилежність. Щойно клієнт до 2026 року надсилає initialize, SDK створює Mcp-Session-Id, повертає його в заголовку відповіді й зберігає за ним живий запис, який знайдуть подальші запити клієнта: узгоджену версію, відкриті потоки, фонове завдання, що веде сесію.
Цей запис — звичайний dict у пам'яті процесу. Розподіленого сховища сесій немає, і під'єднати його неможливо.
На одному робочому процесі цього не видно. На двох — у цьому вся проблема: запит із Mcp-Session-Id, що потрапив на робочий процес, який його не видавав, нічого не знайде в тому словнику, і відповіддю буде 404 (Session not found), а не результат інструмента. Тож щойно робочих процесів більше одного, клієнтам старого покоління потрібна липка маршрутизація (sticky routing): кожен запит у межах сесії має дістатися процесу, який її почав. Сучасним клієнтам це не потрібно ніколи: у них немає сесії, до якої треба прилипати. Про липкість і все інше, що стосується запуску кількох таких процесів, — на сторінці Розгортання та масштабування.
Warning
event_store= схожий на вирішення, але ним не є. Це відновлюваність (повторне
надсилання пропущених SSE-подій клієнту, що перепід'єднується до тієї самої сесії), а
не сховище сесій. Він ніколи не робить сесію досяжною з іншого процесу.
Єдиний перемикач: stateless_http
Якщо липкість — ціна, яку ви платити не готові, змінити можна рівно одну річ.
from typing import Annotated
from pydantic import BaseModel
from mcp.server import MCPServer
from mcp.server.mcpserver import AcceptedElicitation, Elicit, ElicitationResult, Resolve
mcp = MCPServer("Bookshop")
class Quantity(BaseModel):
copies: int
async def ask_quantity() -> Elicit[Quantity]:
"""Resolver: ask the user how many copies to put aside."""
return Elicit("How many copies?", Quantity)
@mcp.tool()
async def reserve(title: str, quantity: Annotated[ElicitationResult[Quantity], Resolve(ask_quantity)]) -> str:
"""Reserve copies of a book, asking the user how many."""
if isinstance(quantity, AcceptedElicitation):
return f"Reserved {quantity.data.copies} of {title!r}."
return "Nothing reserved."
app = mcp.streamable_http_app(stateless_http=True)
Це сервер із початку сторінки плюс один іменований аргумент. З stateless_http=True гілка старого покоління натомість створює одноразову сесію на кожен запит: Mcp-Session-Id не видається, між запитами нічого не запам'ятовується, тож будь-який робочий процес може обслужити будь-який запит, а балансувальник навантаження може робити що завгодно.
Дві речі про нього важливіші за те, що саме він робить.
Він зачіпає лише гілку старого покоління. Запити маршрутизуються за заголовком версії до того, як читається stateless_http, тож сучасний шлях його ніколи не бачить. З'єднання 2026-07-28 і так без сесій і поводиться однаково за будь-якого значення.
Він коштує обох каналів від сервера до клієнта на цій гілці. Сесія, що живе один POST, не має потоку, яким сервер міг би надіслати запит, і не має окремого потоку, яким міг би надсилати сповіщення. Кожен запит, ініційований сервером, викидає NoBackChannelError: ctx.elicit(), виведені з ужитку виклики семплювання (sampling) і кореневих каталогів (roots) (Застарілі можливості) і, так, Resolve, що ставить своє запитання клієнтові старого покоління. Сповіщення не отримують навіть помилки — їх мовчки відкидають.
Note
json_response=True — не той перемикач, але він стягує половину тієї самої ціни з
кожної сесії старого покоління: POST, на який відповідають одним JSON-тілом, не має
потоку для каналу, прив'язаного до запиту, тож ctx.elicit() посеред запиту викидає той
самий NoBackChannelError, а сповіщення, пов'язані із запитом, відкидаються. Окремий
потік сесії це не зачіпає: не пов'язані із запитом сповіщення й далі надходять.
Check
Зробіть неправильно. reserve — той самий інструмент, що щойно обслужив обох клієнтів.
Розгорніть його зі stateless_http=True, під'єднайте тих самих двох клієнтів через HTTP
і викличте його з кожного.
Сучасний клієнт, як і раніше, отримує Reserved 2 of 'Dune'. Сучасна гілка не змінилася.
Виклик клієнта старого покоління не повертається результатом is_error, який могла б
прочитати модель. Увесь запит завершується помилкою — протокольною помилкою верхнього
рівня:
mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests.
Resolve вас не врятував. На з'єднанні 2025-11-25 він мусить надіслати
elicitation/create, а потрібний йому канал — саме те, від чого відмовився
stateless_http=True. Код, переносний між поколіннями, — це ще не код без зворотного
каналу (back-channel).
Тож це справжній компроміс, і існує він лише на гілці старого покоління: із сесіями та липкістю або без стану й в один бік. Якщо ваші інструменти ніколи не звертаються назад до клієнта, stateless_http=True нічого не коштує, і його варто ввімкнути. Якщо звертаються — залиште сесії й залиште маршрутизацію липкою.
Де ваш код справді розгалужується
Майже ніде.
Інструменти, ресурси, промпти, структурований вивід, перебіг виконання, помилки — жодному з них не важливо, яке покоління викликало. Рукостискання initialize, Mcp-Session-Id, окремий потік, DELETE, що завершує сесію, — усім цим володіє SDK, і обробник нічого з цього ніколи не бачить. Інтерактивне введення — єдине місце, де покоління справді відрізняються в переданих даних, і Resolve існує саме для того, щоб це не було вашою проблемою: ви щойно бачили, як один інструмент обслужив обидва.
Лишається рівно одне — сповіщення про зміни, бо два покоління слухають різні канали:
- Клієнт
2026-07-28відкриває потікsubscriptions/listenі читає шину підписок.ctx.notify_resource_updated()(а такожnotify_tools_changed(),notify_prompts_changed(),notify_resources_changed()) публікують туди, і лише туди. Докладніше — на сторінці Підписки. - Клієнт старого покоління читає окремий потік, який його сесія тримає відкритим.
ctx.session.send_resource_updated()(а такожsend_tool_list_changed()і подібні) пишуть у з'єднання, яким прийшов запит: для сесії старого покоління це її окремий потік. У сучасному з'єднанні для цього немає місця: через HTTP такого каналу немає, а через stdio чотири види сповіщень про зміни ходять лише потокамиsubscriptions/listen, тож на сучасному з'єднанні сповіщення тихо відкидається.
Через HTTP жоден із викликів не дістається клієнтів іншого покоління. Щоб повідомити всіх, викликайте обидва:
from mcp.server import MCPServer
from mcp.server.mcpserver import Context
mcp = MCPServer("Bookshop")
STOCK = {"Dune": 3}
@mcp.resource("stock://{title}")
def stock(title: str) -> str:
"""How many copies of one book are on the shelf."""
return f"{STOCK[title]} in stock"
@mcp.tool()
async def restock(title: str, copies: int, ctx: Context) -> str:
"""Put copies of a book back on the shelf."""
STOCK[title] = STOCK.get(title, 0) + copies
await ctx.notify_resource_updated(f"stock://{title}")
await ctx.session.send_resource_updated(f"stock://{title}")
return f"{STOCK[title]} in stock"
Два рядки, жодного if, жодної перевірки версії — і готово. Оце й увесь список того, що обробник робить інакше через існування клієнтів старого покоління.
Підсумки
- Один
streamable_http_app()обслуговує обидва покоління протоколу. SDK маршрутизує кожен запит за заголовкомMCP-Protocol-Version; налаштовувати нічого не треба, і перемикача поколінь шукати не варто. - Клієнт старого покоління коштує вам сесії: запису
Mcp-Session-Idу пам'яті процесу без розподіленого сховища за ним. Більше одного робочого процесу означає липку маршрутизацію, інакше не той робочий процес відповість404 Session not found. Докладніше про кілька робочих процесів — на сторінці Розгортання та масштабування. stateless_http=True— єдиний перемикач, і він стосується лише гілки старого покоління. Він купує вільне балансування навантаження для клієнтів старого покоління ціною обох каналів від сервера до клієнта на цій гілці: запити, ініційовані сервером, викидаютьNoBackChannelError(помилка верхнього рівня на боці клієнта, а не результатis_error), а сповіщення відкидаються.- З'єднання
2026-07-28без сесій у будь-якому разі.stateless_httpйого ніколи не зачіпає. - Код обробника розгалужується за поколінням рівно в одному місці: сповіщення про зміни.
ctx.notify_*дістається клієнтівsubscriptions/listen;ctx.session.send_*дістається сесій старого покоління. Викликайте обидва. - Усе інше (зокрема запит введення в користувача через
Resolve) переносне між поколіннями за побудовою. Напишіть сучасний варіант один раз.