Stateless MCP server: що змінилося у revision 2026-07-28
У revision 2026-07-28 MCP вилучено sessions та initialize handshake. Дізнайтеся, як це змінює reverse proxy, health checks, timeouts і auth.
Що таке stateless MCP server
Stateless MCP server не зберігає стан окремого клієнта між запитами. Кожен запит містить версію протоколу, можливості клієнта та облікові дані, потрібні серверу для формування відповіді. Тому будь-який процес на будь-якій машині може обробити будь-який запит. MCP (Model Context Protocol, формат обміну даними, який агенти використовують для доступу до інструментів) зробив це правилом у редакції 2026-07-28. У ній було вилучено handshake initialize і HTTP-сеанс, який його підтримував.
У цьому полягає весь операційний сенс. Сервер, який не зберігає стан окремого клієнта, можна розмістити за звичайним load balancer без session affinity. Його можна перезапустити під час розгортання без розриву з’єднань із клієнтами. Також можна запустити чотири ідентичні процеси замість одного. Session-oriented server не забезпечує жодної з цих властивостей без додаткових компонентів.
Model Context Protocol — це stateless protocol: уся інформація, потрібна для обробки запиту, міститься в самому запиті. Сервер обробляє кожен запит незалежно. Не слід робити висновки про стан із попередніх запитів, навіть якщо вони надходять через те саме з’єднання або stream.
Stateless не означає, що сервер нічого не зберігає. Ваша база даних, черга та cache нікуди не зникають. Це означає, що protocol не зберігає стан у з’єднанні. Тому сервер не повинен сприймати з’єднання, процес або відкритий socket як заміну поняттю «цей клієнт у поточному діалозі».
Що вилучила редакція від 2026-07-28
2026-07-28 — поточна редакція специфікації станом на серпень 2026 року. Порівняно з 2025-11-25 у ній вилучено п’ять елементів, які існували для підтримки сесій.
- Запит
initializeі сповіщенняnotifications/initialized. Жодного handshake більше немає (SEP-2575). - Заголовок
Mcp-Session-Idі завершення сесії через HTTPDELETE(SEP-2567). - Окремий HTTP-потік
GET, у який сервери передавали сповіщення. Його замінено наsubscriptions/listen— звичайний POST, відповідь на який є довготривалим потоком. - Можливість відновлення потоку SSE (server-sent events). Заголовок
Last-Event-IDта ідентифікатори окремих подій вилучено, тому після розриву потоку втрачається запит, що перебував у процесі виконання, а клієнт має повторно надіслати його як новий запит із новим ідентифікатором запиту. ping,logging/setLevelіnotifications/roots/list_changed. Тепер рівень журналювання є полем окремого запиту,io.modelcontextprotocol/logLevelу_meta.
Додано один метод, який має реалізувати кожен сервер. server/discover повертає підтримувані сервером версії протоколу, можливості та ідентифікаційні дані одним викликом. Це найближчий аналог handshake, що залишився, але клієнтам необов’язково його викликати.
Чому протокол із сесіями було складно запускати в production
У 2025-11-25 і попередніх версіях сервер міг створити ідентифікатор сесії під час ініціалізації та повернути його в заголовку Mcp-Session-Id у відповіді на InitializeResult. Після цього клієнт мав надсилати цей заголовок у кожному наступному запиті. Узгоджена версія протоколу та можливості клієнта зберігалися в пам’яті сервера й були прив’язані до цього ідентифікатора. Кожне з цих рішень мало операційні наслідки.
- Після перезапуску таблиця сесій втрачалася. Специфікація вимагала, щоб сервер відповідав
404 Not Foundна будь-який запит із недійсним ідентифікатором сесії, а клієнт мав почати роботу спочатку з новимInitializeRequest. Кожен deploy ставав причиною повторного підключення для всіх підключених клієнтів. - Друга репліка не знала про сесії першої репліки. Масштабування вимагало sticky routing на load balancer або спільного сховища сесій, яке кожна репліка читала під час кожного запиту.
- Таблиця сесій займала пам’ять, і її обсяг зростав разом із кількістю неактивних клієнтів.
DELETEбув необов’язковим, тому клієнти, які закривали з’єднання без його надсилання, залишали записи в таблиці. - Результати списків могли відрізнятися для різних з’єднань, тому кешування перед сервером було небезпечним.
Видалення сесій усуває всі чотири проблеми одночасно. Саме цю зміну потрібно зрозуміти, перш ніж змінювати будь-яку конфігурацію.
Що тепер містить кожен запит
Кожен POST-запит до кінцевої точки MCP є самодостатнім. Версія протоколу та можливості клієнта передаються в тілі запиту в полі _meta, а вибрані поля дублюються в HTTP-заголовках, щоб проміжний вузол міг виконувати маршрутизацію без розбору JSON.
POST /mcp HTTP/1.1
Content-Type: application/json
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: get_weather
Authorization: Bearer <access token>
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "get_weather",
"arguments": {"location": "Seattle, WA"},
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {"name": "ExampleClient", "version": "1.0.0"},
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}io.modelcontextprotocol/protocolVersion і io.modelcontextprotocol/clientCapabilities є обов’язковими в кожному запиті. clientInfo не є обов’язковим, але клієнтам слід його надсилати. Запит без обов’язкового поля є некоректним, тому сервер має відхилити його з помилкою JSON-RPC -32602 і HTTP-кодом 400 Bad Request.
Заголовок Mcp-Method є обов’язковим у кожному запиті. Mcp-Name є обов’язковим для tools/call, resources/read і prompts/get. Значення заголовка має збігатися зі значенням у тілі запиту. Якщо сервер обробляє тіло запиту, він має відхилити невідповідність із 400 Bad Request і кодами помилки -32020, HeaderMismatch. Це правило потрібне тому, що балансувальник, який виконує маршрутизацію за заголовком, і сервер, який виконує запит за його тілом, використовують два різні джерела істини. Якщо ви виконуєте маршрутизацію або обмежуєте частоту запитів за цими заголовками, спочатку перевірте MCP-Protocol-Version: попередні редакції не перевіряли відповідність заголовка тілу, тому в таких версіях значенню заголовка не можна довіряти.
Невідповідність версій тепер є звичайною помилкою окремого запиту, а не помилкою встановлення з’єднання. Сервер, який не підтримує запитану версію, відповідає 400 Bad Request з помилками -32022, UnsupportedProtocolVersion і вказує підтримувані версії в data.supported. Клієнт вибирає одну з них і повторює запит.
Куди подівся стан: токени, курсори, підписки
Стан не зник. Він перемістився в місця, які можна переглядати та записувати в журнал.
Облікові дані передаються в кожному запиті. Немає сеансу, до якого можна прив’язати ідентифікацію, тому токен доступу передається в кожному HTTP-виклику й перевіряється щоразу. Докладніше про це див. у розділі автентифікації нижче.
Курсори мають містити власну позицію. Пагінація в tools/list, resources/list, prompts/list і resources/templates/list використовує непрозорий рядок курсора, і клієнти не повинні його аналізувати або змінювати. На сервері з одним процесом було поширено зберігати зміщення в пам’яті, пов’язуючи його із сеансом. Без сеансу курсор має містити достатньо даних, щоб будь-яка репліка могла продовжити формування списку. Тому закодуйте позицію в курсорі та підпишіть його або зберігайте позицію у сховищі, спільному для всіх реплік. Недійсний курсор має повертати -32602. Підписуйте курсор, оскільки непрозорий курсор усе одно є вхідними даними від клієнта, які ваш код декодує та яким довіряє.
Підписки належать запиту, а не з’єднанню. Клієнт, якому потрібні сповіщення про зміни, надсилає subscriptions/listen із фільтром, у якому зазначено потрібні типи: toolsListChanged, promptsListChanged, resourcesListChanged і resourceSubscriptions. Сервер відповідає через notifications/subscriptions/acknowledged і залишає цей потік відповіді відкритим. Якщо потік розривається, сервер нічого не зберігає, а клієнт повторно надсилає subscriptions/listen, щоб відновити його.
Стан застосунку між викликами стає явним ідентифікатором. Якщо сервер справді має запам’ятовувати щось між викликами, специфікація передбачає ідентифікатор, створений сервером і переданий назад як звичайний аргумент інструмента. Він зазначений у схемі інструмента, може записуватися в журнал і ніколи не визначається неявно на основі з’єднання. Сервер із реальними даними користувачів, наприклад self-hosted MCP-сервер електронної пошти, використовує цей підхід замість сеансу: ідентифікатор поштової скриньки або чернетки передається як аргумент інструмента, тому будь-яка репліка може обробити наступний виклик. Багатьом інструментам ідентифікатор взагалі не потрібен: інструмент пошуку на базі власного екземпляра SearXNG отримує запит і повертає результати, без даних, які потрібно зберігати для продовження наступного виклику, і без потреби враховувати, яка саме репліка відповіла.
Розгортання: reverse proxy, тайм-аути, перевірки працездатності
MCP endpoint — це один шлях, який приймає POST-запити. Більшість трафіку — це короткий запит і відповідь у форматі JSON, з якими впорається будь-який проксі. Винятком є потокова відповідь, для якої стандартні налаштування проксі створюють проблеми. Саме це змінюється під час переходу від демонстрації на ноутбуці до MCP-сервера, що працює на VPS.
location /mcp {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_read_timeout 1h;
proxy_send_timeout 1h;
}proxy_buffering off має значення, оскільки nginx за замовчуванням буферизує відповіді від проксі-сервера. Через це SSE-події затримуються, доки буфер не заповниться або відповідь не завершиться. Специфікація також вимагає, щоб сервери надсилали X-Accel-Buffering: no у SSE-відповідях. nginx враховує цей заголовок, тому коректний сервер сам повідомляє проксі правильну поведінку. Але директиву також потрібно налаштувати, оскільки це частина конфігурації під вашим контролем.
proxy_read_timeout за замовчуванням має значення 60 секунд. Якщо subscriptions/listen-потік залишається без даних довше, nginx закриває його, а не ваш сервер. Тому в журналі видно працездатний процес, а клієнт отримує обірваний потік. Збільшуйте це значення лише для MCP location, а не для всього сервера. Також серверам рекомендується надсилати рядок коментаря SSE — рядок, що починається з двокрапки — як keep-alive у періоди бездіяльності. Це взагалі не дає проміжним компонентам завершити потік через тайм-аут.
Для Caddy потрібно менше налаштувань. За замовчуванням він частково буферизує дані для ефективнішої передачі та негайно виконує flush, якщо відповідь містить Content-Type: text/event-stream. Тому потокова передача працює без додаткових директив.
mcp.example.com {
reverse_proxy 127.0.0.1:8080 {
health_uri /healthz
health_interval 10s
}
}Зверніть увагу, до якого шляху спрямована ця перевірка працездатності. Не спрямовуйте активну перевірку на MCP endpoint із GET, оскільки сервер, який реалізує лише цю редакцію, відповідає 405 Method Not Allowed на GET і DELETE, а стандартний метод перевірки Caddy — GET. У такому разі проксі позначить цілком працездатний бекенд як недоступний. Надайте проксі звичайний шлях, наприклад /healthz, а протокол перевіряйте окремо за допомогою POST.
curl -sS https://mcp.example.com/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: server/discover' \
-d '{"jsonrpc":"2.0","id":"health-1","method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'200 зі списком supportedVersions означає, що процес працює та взаємодіє за протоколом. 404 з помилкою JSON-RPC -32601 означає, що процес працює, але не підтримує server/discover, який має реалізовувати кожен 2026-07-28 сервер. 400 зі значенням -32022 означає, що перевірка запитала версію, яку ця збірка не підтримує. Саме це потрібно виявляти після оновлення залежності. Open source nginx не має активних перевірок працездатності, тому використовуйте пасивні max_fails і fail_timeout на upstream та запускайте перевірку протоколу окремо з вашої системи моніторингу.
Тепер rolling restart призводить лише до втрати запитів, які виконуються, і не спричиняє інших втрат. Виконайте drain, дочекайтеся завершення відкритих POST-запитів, запустіть новий процес, а клієнти повторно надішлють запити, які завершилися помилкою. Єдине, що все одно буде втрачено, — відкритий subscriptions/listen-потік, оскільки цей потік є активним з’єднанням із конкретним процесом. Відсутність стану усунула прив’язку сесій. Але вона не усунула прив’язку з’єднання для потоку, відкритого саме зараз, і жодне правило маршрутизації цього не виправить. Клієнт може розрізнити ці випадки: потік, який завершується порожнім результатом subscriptions/listen, закрито коректно; потік, який завершується без нього, обірвано, і клієнт може сприйняти це як причину для повторного підключення.
Кешування тепер стає можливим уперше. Результати list-методів тепер містять ttlMs і cacheScope, а cacheScope: "public" повідомляє спільним проміжним компонентам, що відповідь можна кешувати. Це безпечно лише тому, що результати list-методів більше не відрізняються залежно від з’єднання. Це безпосередній наслідок видалення сесій.
Як змінюється автентифікація, коли немає сесії
За наявності сесії було спокусливо один раз автентифікуватися за допомогою initialize, а потім вважати ідентифікатор сесії підтвердженням для всіх наступних запитів. Ідентифікатор сесії, використаний у такий спосіб, є bearer-обліковими даними без аудиторії, строку дії та механізму відкликання, які створює ваш власний сервер. Видалення сесій усуває цей спрощений підхід, тому заміна є суворішою.
Захищений MCP-сервер працює як resource server OAuth 2.1. Кожен HTTP-запит від клієнта має містити Authorization: Bearer <access token>, а сервер перевіряє токен у кожному запиті. Перевірка включає аудиторію: сервер має підтвердити, що токен видано саме для нього, відповідно до RFC 8707 (Resource Indicators for OAuth 2.0), і не повинен приймати або передавати токени, призначені для інших ресурсів. Клієнти запитують потрібну аудиторію, передаючи параметр resource із канонічним URI сервера.
Виявлення виконується у відповідь на challenge. Якщо надходить запит без придатного токена, сервер відповідає 401 Unauthorized.
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
scope="files:read"Клієнт читає resource_metadata, отримує цей документ (RFC 9728, OAuth 2.0 Protected Resource Metadata, який MCP-сервери мають реалізовувати), знаходить authorization server і запускає flow. Чинний токен із недостатніми дозволами отримує 403 Forbidden із error="insufficient_scope" та scope, потрібними для цієї операції.
Це має два наслідки для експлуатації. Тепер перевірка токена виконується в кожному запиті, а не один раз за сесію, тому мережевий round trip до endpoint introspection для кожного виклику збільшить затримку: надавайте перевагу токенам, які можна перевірити локально за підписом, аудиторією та строком дії, або кешуйте результат перевірки на короткий період із ключем у вигляді токена. Оскільки сесія більше не зберігає ідентичність, авторизацію потрібно обчислювати з токена під час кожного виклику. Це точніше відображає модель доступу, ніж сесійний підхід, і відповідає ширшій практиці не зберігати облікові дані в процесі агента, описаній у зберігання секретів поза процесом AI-агента.
Що справедливо для цієї редакції, а що — ні
Усе наведене вище описує редакцію 2026-07-28. Це не опис MCP назавжди і не опис сервера, який ви розгорнули минулого року.
Клієнти й сервери на 2025-11-25 та раніших редакціях і далі використовують модель handshake. Специфікація називає ці редакції застарілими, а редакції з метаданими для кожного запиту — сучасними. Сервер, який підтримує лише цю редакцію, під час взаємодії зі старішим клієнтом має повертати 405 Method Not Allowed на GET або DELETE до MCP endpoint, ігнорувати будь-який заголовок Mcp-Session-Id без його створення або повернення у відповіді та ігнорувати Last-Event-ID, оскільки потоки не можна відновити. Сервер, що підтримує обидві епохи, може обслуговувати їх на одному endpoint: запит із сучасним _meta обробляється без збереження стану, а запит initialize активує старішу семантику сесій.
Тому перевірте рядок редакції, перш ніж покладатися на все це. Якщо ваш SDK досі надсилає initialize, сесії все ще мають значення для вашого розгортання, а описані вище проблеми, пов’язані із сесіями, і далі потребують вашого керування. Те саме стосується клієнтської частини: процес агента на вашому власному сервері, наприклад у конфігурації з запуском coding agent на VPS, є stateless у цьому сенсі лише тоді, коли бібліотека, яку він використовує, працює із сучасною редакцією. Прочитайте версію, яку узгоджує ваше runtime-середовище, потім ознайомтеся з відповідною редакцією специфікації та розглядайте цю сторінку як опис однієї конкретної редакції, а не протоколу загалом.
FAQ
Чи означає stateless MCP server, що я не можу нічого зберігати?
Ні. Stateless описує протокол, а не ваш застосунок. Бази даних, черги та кеші працюють як і раніше. Змінюється лише те, що стан, який охоплює кілька викликів, потрібно вказувати через явний ідентифікатор, який клієнт передає в кожному запиті, наприклад через handle, створений сервером, в аргументі інструмента. Не можна визначати контекст за самим з’єднанням: специфікація забороняє серверу покладатися на попередні запити в тому самому з’єднанні для визначення можливостей, версії протоколу або ідентичності клієнта, оскільки кожен запит містить ці дані в _meta.
Чи потрібні мені sticky sessions на балансувальнику навантаження?
Для звичайних запитів — ні. У revision 2026-07-28 кожен POST містить власні версію протоколу, можливості та облікові дані, тому будь-яка репліка може відповісти на будь-який запит, а round-robin підходить. Єдиним довготривалим об’єктом залишається потік відповіді subscriptions/listen, тобто одне відкрите з’єднання з одним процесом. Воно завершується разом із цим процесом, після чого клієнт повторно надсилає subscriptions/listen, щоб відновити потік. Це тривалість з’єднання, а не прив’язка сесії, і жодне правило маршрутизації цьому не перешкоджає.
Що сталося з Mcp-Session-Id і потоком HTTP GET?
Обидва вилучили в revision 2026-07-28 відповідно до SEP-2567 і SEP-2575. Сервер, який реалізує лише цю revision, має повертати 405 Method Not Allowed на GET і DELETE до MCP endpoint, а заголовок Mcp-Session-Id ігнорувати, не повертаючи його у відповіді. Тепер серверні сповіщення про зміни передаються через потік відповіді на запит subscriptions/listen, а не через окремий потік GET. Сервери, які мають і надалі обслуговувати старіших клієнтів, реалізують поведінку попередньої revision паралельно з цією.
Як виконати health check MCP server без handshake?
Використовуйте два рівні. Спрямуйте активну перевірку проксі на звичайний HTTP path, який обслуговує ваш застосунок, оскільки GET до MCP endpoint коректно повертає 405 і позначить справний backend як недоступний. Потім перевірте сам протокол, надіславши POST із server/discover. Кожен 2026-07-28 server має його реалізувати. Перевірте, що відповідь має HTTP-код 200 і містить версію протоколу, яку використовують ваші клієнти. 404 з помилкою JSON-RPC -32601 означає, що процес працює, але не обслуговує цей метод. 400 з -32022 означає, що запитувана версія не підтримується цією збіркою.