Розгортання та масштабування
Машинний переклад
Цю сторінку перекладено автоматично з англомовної документації, і основною версією є англомовна сторінка. Якщо щось читається неправильно, на сторінці Переклади пояснено, як про це повідомити.
Сервер працює. Тепер йому потрібні справжнє ім'я хоста і більше ніж один робочий процес за ним.
Майже нічого з цього не стосується MCP. ASGI-сервер, менеджер процесів, балансувальник навантаження — усе це приносите ви. На цій сторінці лише короткий список того, що стосується MCP: одне налаштування, від якого залежить кожне розгортання, і два місця, де «більше ніж один робочий процес» змінює поведінку SDK.
Насамперед: список дозволених Host
streamable_http_app() не може знати, за яким іменем хоста його обслуговуватимуть, тому припускає найбезпечнішу відповідь: localhost. Без transport_security= застосунок вмикає захист від DNS rebinding і приймає запит лише тоді, коли його заголовок Host — це 127.0.0.1:<port>, localhost:<port> або [::1]:<port>. Заголовок Origin, якщо він є, має бути http://-формою того самого значення. На вашій машині це саме те, що треба: так зловмисна вебсторінка не зможе керувати локальним сервером через DNS-ім'я, яке вона переприв'язала до 127.0.0.1.
Розгорнутий за справжнім іменем хоста, той самий типовий режим відхиляє кожен запит, доки ви не скажете інакше. Перевірка виконується раніше за будь-що, пов'язане з MCP, тож до вашого коду справа навіть не доходить:
421 Misdirected Request Invalid Host header the Host is not in the allowlist
403 Forbidden Invalid Origin header the Origin is not in the allowlist
Виправлення — transport_security=. Додайте до списку дозволених те, що справді обслуговуєте:
from mcp.server import MCPServer
from mcp.server.transport_security import TransportSecuritySettings
mcp = MCPServer("Notes")
@mcp.tool()
def add_note(text: str) -> str:
"""Save a note."""
return f"Saved: {text}"
security = TransportSecuritySettings(
allowed_hosts=["mcp.example.com", "mcp.example.com:*"],
allowed_origins=["https://app.example.com"],
)
app = mcp.streamable_http_app(transport_security=security)
- Елементи
allowed_hosts— точні рядки:"mcp.example.com"відповідає заголовкуHostбез порту, а"mcp.example.com:*"— будь-якому порту. Укажіть обидва. allowed_originsмає значення лише для браузерів, бо більше ніхто не надсилаєOrigin. Це серверний двійник конфігурації CORS зі сторінки Додавання до наявного застосунку.- За зворотним проксі, який уже контролює заголовок
Host, чесна конфігурація — вимкнути перевірку:TransportSecuritySettings(enable_dns_rebinding_protection=False). - Передати
host=, відмінний від localhost (наприклад,host="mcp.example.com"), не означає додати це ім'я хоста до списку дозволених. Це лише не дає типовому значенню localhost увімкнути захист, через що приймаються будь-які Host і Origin. Натомість скажіть, що маєте на увазі, черезtransport_security=.
Check
Видаліть аргумент transport_security=security і все одно розгорніть застосунок. Він запускається, /mcp
маршрутизується, і на кожен запит (зокрема зі звичайного curl) повертається:
HTTP/1.1 421 Misdirected Request
Invalid Host header
На боці клієнта цих слів ви не знайдете. 421 — це HTTP-відповідь простим текстом, а не
помилка JSON-RPC, тому MCP-клієнт викидає загальну помилку транспорту; ім'я хоста, яке
не сподобалося серверу, з'являється лише в лозі сервера, одним попередженням. Щойно
розгорнутий сервер, який відхиляє кожне з'єднання, — це список дозволених Host, доки не доведено протилежне.
Усунення несправностей теж починається звідси.
Робочі процеси, і для кого потрібні липкі сесії
Щойно ім'я хоста відповідає, поставте за ним більше ніж один робочий процес. Для цього в SDK немає жодного перемикача; Starlette-застосунок масштабують так само, як і будь-який ASGI-застосунок, — передають об'єкт чомусь, що вміє створювати дочірні процеси:
uvicorn server:app --workers 4
Чотири процеси, один сокет. І тепер питання, на яке має відповісти кожне розгортання: чи повинен запит потрапити до того робочого процесу, який бачив попередній?
Для клієнта, що говорить протоколом 2026-07-28, — ні. Сучасний запит — це один самодостатній POST: жодного рукостискання initialize перед ним, жодного Mcp-Session-Id у відповіді, нічого, до чого другий запит мав би повертатися. Спрямовуйте його на будь-який робочий процес.
Це не режим, який вмикають. stateless_http=True має такий вигляд, ніби мав би ним бути, але транспорт маршрутизує за заголовком запиту MCP-Protocol-Version, передає сучасний запит сучасному обробнику і повертає керування. Рядок, який читає stateless_http, стоїть після цього повернення. Річ не в тім, що прапорець ігнорується на шляху 2026-07-28; до нього просто ніколи не доходить. stateless_http — це перемикач лише для гілки старого покоління, а сучасний шлях безсесійний за побудовою.
Для клієнта старого покоління зі специфікацією версії 2025-11-25 або ранішої відповідь залежить від цього прапорця:
| Версія протоколу клієнта | Сесія | Що має робити балансувальник навантаження |
|---|---|---|
| 2026-07-28 | Немає. Mcp-Session-Id ніколи не встановлюється. |
Нічого. Будь-який робочий процес обслуговує будь-який запит. |
| 2025-11-25 і раніші (за замовчуванням) | Mcp-Session-Id, що зберігається в пам'яті одного робочого процесу. |
Липкі сесії. Наступний запит, що потрапив до іншого робочого процесу, отримує 404 «Session not found». |
2025-11-25 і раніші, з stateless_http=True |
Немає. | Нічого. Ціна — зворотний канал (back-channel) від сервера до клієнта: семплювання (sampling), push-еліцитація (elicitation), roots/list, — а також відновлюваність. |
Липким сесіям і ціні гілки старого покоління присвячено окрему сторінку — Обслуговування клієнтів старого покоління; самим двом поколінням — Версії протоколу. Тут важлива форма відповіді: на 2026-07-28 ви вже працюєте без стану, і налаштовувати нічого.
Решта сторінки — про дві речі, яких відсутність стану вам не дає.
requestState між робочими процесами
Інструменту з багатораундовими запитами (multi-round-trip) потрібне щось, по що клієнт має сходити (підтвердження, вибір, облікові дані), тому він повертає запитання замість відповіді й завершує роботу на повторній спробі. Між двома раундами клієнт тримає непрозорий токен request_state, який випустив сервер. На повторній спробі сервер має знову відкрити цей токен.
Запечатаний яким ключем? За замовчуванням — тим, який сервер згенерував через os.urandom(32) під час створення. З --workers 4 це чотири створення в чотирьох процесах: чотири різні ключі, ніде не записані, нікому не передані, втрачені після перезапуску.
Ось інструмент, який запитує, перш ніж діяти, на сервері, що нічого не налаштовує:
from mcp.server.mcpserver import Context, MCPServer
from mcp.types import ElicitRequest, ElicitRequestFormParams, ElicitResult, InputRequiredResult
CONFIRM = ElicitRequest(
params=ElicitRequestFormParams(
message="Issue this refund?",
requested_schema={"type": "object", "properties": {"ok": {"type": "boolean"}}, "required": ["ok"]},
)
)
def make_server() -> MCPServer:
"""Every worker process builds one of these, once, at import."""
mcp = MCPServer("billing")
@mcp.tool()
async def refund(amount: int, ctx: Context) -> str | InputRequiredResult:
"""Refund an amount, once a human has confirmed it."""
if ctx.input_responses is None:
return InputRequiredResult(input_requests={"ok": CONFIRM}, request_state=f"refund:{amount}")
answer = (ctx.input_responses or {}).get("ok")
if not isinstance(answer, ElicitResult) or answer.action != "accept" or not (answer.content or {}).get("ok"):
return "refund cancelled"
return f"refunded ${amount}"
return mcp
Перший раунд потрапляє до робочого процесу A. Процес A запечатує refund:120 своїм ключем і повертає токен. Клієнт показує запитання людині, отримує «так» і повторює спробу. Повторна спроба — це цілком новий HTTP-запит.
Check
Нехай ця повторна спроба потрапить до робочого процесу B. B намагається розпечатати токен, який не випускав, не може —
і відхиляє весь раунд. refund так і не викликається; клієнт отримує помилку JSON-RPC:
{
"code": -32602,
"message": "Invalid or expired requestState",
"data": {"reason": "invalid_request_state"}
}
Це повідомлення незмінне. Прострочений, підроблений, відтворений з іншими аргументами чи (найпоширеніша
причина в реальному розгортанні, з великим відривом) запечатаний сусіднім робочим процесом — клієнтові щоразу
кажуть те саме, тож передані дані ніколи не видають, яка саме перевірка не пройшла. Справжня причина — один
запис WARNING у лозі сервера:
requestState rejected on tools/call: unknown key
Багатораундовий інструмент, який працював з одним робочим процесом і почав збоїти час від часу з двома, — це саме воно. Обидва раунди все ще мають потрапити до того самого процесу, тож збій трапляється рівно так часто, як балансувальник навантаження їх розводить.
Два раунди — це два незалежні HTTP-запити, і розділити їх може кілька буденних речей: проксі, що балансує кожен запит окремо; з'єднання, яке обірвалося між ними; розгортання чи перезапуск; клієнт, який зберіг request_state і відновлює роботу взагалі з іншого процесу (Керування циклом самостійно). Будь-що з цього — «інший робочий процес».
Виправлення — один аргумент. У нього дві половини.
from mcp.server.mcpserver import Context, MCPServer, RequestStateSecurity
from mcp.types import ElicitRequest, ElicitRequestFormParams, ElicitResult, InputRequiredResult
CONFIRM = ElicitRequest(
params=ElicitRequestFormParams(
message="Issue this refund?",
requested_schema={"type": "object", "properties": {"ok": {"type": "boolean"}}, "required": ["ok"]},
)
)
def make_server(key: str) -> MCPServer:
"""Every worker process: the same key, and the same name."""
mcp = MCPServer("billing", request_state_security=RequestStateSecurity(keys=[key]))
@mcp.tool()
async def refund(amount: int, ctx: Context) -> str | InputRequiredResult:
"""Refund an amount, once a human has confirmed it."""
if ctx.input_responses is None:
return InputRequiredResult(input_requests={"ok": CONFIRM}, request_state=f"refund:{amount}")
answer = (ctx.input_responses or {}).get("ok")
if not isinstance(answer, ElicitResult) or answer.action != "accept" or not (answer.content or {}).get("ok"):
return "refund cancelled"
return f"refunded ${amount}"
return mcp
keys=[...]— половина, яку знаходять усі. Дайте кожному екземпляру той самий секрет (щонайменше 32 байти), і кожен екземпляр зможе розпечатати те, що випустив будь-який сусід.keys[0]запечатує, а розпечатує кожен ключ зі списку — це кільце ротації; як прокрутити його без простою, описано в розділі Ротація ключів.- Ім'я сервера — половина, яку не знаходить майже ніхто, і причина, з якої повторні спроби між екземплярами й далі збоять після того, як ви поділилися ключем. Кожен запечатаний токен несе
nameсервера як твердження про аудиторію (audience claim), яке суворо перевіряється на зворотному шляху. Два екземпляри, зібрані з одного коду, мають однакове ім'я і ніколи цього не помічають. Назвіть їх по-різному (MCPServer(f"billing-{POD}")виглядає як хороша гігієна спостережуваності) — і кожна повторна спроба між екземплярами відхиляється точно так, як вище, зі спільним ключем чи без. У лозі замістьunknown keyбудеaudience; клієнт різниці не бачить.
Випустіть секрет один раз і передайте те саме значення кожному екземпляру. Саме цю команду пропонує виконати повідомлення про помилку самого SDK, якщо передати йому менше ніж 32 байти:
python -c "import secrets; print(secrets.token_hex(32))"
Ті самі ключі і те саме ім'я
Багатоекземплярне розгортання має поділяти і те, і інше. Якщо окремі імена екземплярів для вас принципові,
натомість дайте всьому парку одну явну аудиторію: RequestStateSecurity(keys=[...], audience="billing").
Тоді кожен екземпляр випускає і приймає токени під "billing", хай як він називається.
Усе інше про запечатування — у розділі Захист requestState: що саме воно прив'язує, ttl на раунд (600 секунд за замовчуванням), власний кодек, чому неналаштований типовий варіант — саме те, що треба, на stdio. Увесь внесок цієї сторінки — контрольний список із двох пунктів: ті самі ключі, те саме ім'я.
Info
Ви на цьому шляху, навіть якщо ніколи не набирали InputRequiredResult. Інструмент, параметри якого
використовують Resolve(...) (Залежності), — це багатораундовий інструмент,
і SDK випускає та запечатує його request_state за нього. Той самий типовий ключ, той самий збій між
робочими процесами, те саме виправлення.
Сповіщення про зміни між репліками
Потік subscriptions/listen клієнта — це одна довготривала відповідь, тож він прив'язаний до однієї репліки на все своє життя. ctx.notify_resource_updated(...), опублікований на іншій репліці, має до нього дійти.
Шов між ними — SubscriptionBus. Яку б шину ви не дали серверу, саме в неї йде кожна публікація і саме її слухає кожен відкритий потік, тож передайте ту саму шину кожній репліці:
from mcp.server.mcpserver import Context, MCPServer
from mcp.server.subscriptions import SubscriptionBus
NOTES = {"todo": "buy milk"}
def make_server(bus: SubscriptionBus) -> MCPServer:
"""Every replica gets its own server object; all of them hold the same bus."""
mcp = MCPServer("Notebook", subscriptions=bus)
@mcp.resource("note://{name}")
def note(name: str) -> str:
"""One note, by name."""
return NOTES[name]
@mcp.tool()
async def edit_note(name: str, text: str, ctx: Context) -> str:
"""Replace a note's text."""
NOTES[name] = text
await ctx.notify_resource_updated(f"note://{name}")
return "saved"
return mcp
Розсиланню байдуже, до якого об'єкта сервера приєднано потік. Два сервери з одним InMemorySubscriptionBus уже поводяться так: відкрийте потік listen на одному, виконайте edit_note на іншому — і потік про це почує. Ця шина в пам'яті охоплює лише об'єкти серверів у межах одного процесу, тож це модель, а не розгортання:
- Між справжніми процесами SDK не постачає жодної шини, яка могла б допомогти.
SubscriptionBus— цеProtocolіз двох методів (publishіsubscribe), який ви реалізуєте поверх власного pub/sub-бекенда (Redis, NATS, що завгодно, що у вас уже працює) і передаєте якMCPServer(subscriptions=...). Начерк і контракт — на сторінці Підписки. - Шина переносить чотири невеликі типізовані події, ніколи не JSON-RPC. Підтвердження, фільтрація та життєвий цикл потоку залишаються в SDK, тож ваша шина не може зламати протокол; вона може лише переміщувати події між процесами.
- Потоки не відновлювані, а події не відтворюються повторно. Втрата репліки обриває її потоки; клієнти знову підписуються на прослуховування і знову отримують дані. Немає сховища подій, яке треба поділяти, і більше нічого налаштовувати. Це єдине місце, де горизонтальне масштабування — справді просто більше того самого.
Чого SDK вам не дає
MCPServer — це реалізація протоколу, а не сервер застосунків. Перемикачів розгортання, які ви шукатимете далі, немає навмисно:
- Немає
workers=.mcp.run("streamable-http")запускає рівно один процес uvicorn, і нічого більше він ніколи не запустить. Багатопроцесність — цеstreamable_http_app(), переданий тому, чим ви вже розгортаєте ASGI:uvicorn --workers, gunicorn, менеджеру процесів вашої платформи. Ця сторінка свідомо не є підручником з жодного з них; їхня документація краща, ніж була б її копія тут. - Немає маршруту перевірки стану.
@mcp.custom_route("/health", methods=["GET"])— ось і вся відповідь, і він ніколи не вимагає автентифікації, навіть коли решта сервера вимагає. Це правильно для проби життєздатності й неправильно для будь-чого приватного. Приклад є на сторінці Додавання до наявного застосунку. - Немає об'єкта production-налаштувань. На
MCPServerніде записати тайм-аути, TLS, коректне завершення роботи чи ліміти з'єднань, бо нічого з цього не є його роботою. Це справа вашого ASGI-сервера, і налаштовуєте ви це там. Жменьку налаштувань, які конструктор таки приймає, описано на сторінці Запуск сервера. - Немає готового
EventStore, а на 2026-07-28 і потреби в ньому. Відновлюваність — це особливість гілки старого покоління зі станом; сучасний обмін — це один POST, одна відповідь і нічого відновлювати.
Підсумки
- За замовчуванням застосунок відповідає лише на запити, адресовані localhost.
transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])— це ворота виходу в світ: доки його не передати, кожен запит за справжнім іменем хоста — це421, а причина є лише в лозі сервера. - На 2026-07-28 немає сесії і нічого, до чого балансувальник навантаження мав би прив'язуватися.
stateless_http=True— перемикач лише для старого покоління, бо сучасний запит маршрутизується й отримує відповідь ще до того, як цей прапорець узагалі прочитають. - Типовий ключ
requestState— цеos.urandom(32), випущений окремо в кожному процесі. Багатораундова повторна спроба, що потрапила до іншого робочого процесу, збоїть з-32602«Invalid or expired requestState». - Виправлення —
RequestStateSecurity(keys=[...])і те саме ім'я сервера на кожному екземплярі. Ім'я — типове твердження про аудиторію токена. Ті самі ключі, те саме ім'я. - Сповіщення про зміни переходять між репліками через одну спільну
SubscriptionBus. Єдина реалізація в SDK — внутрішньопроцесна;Protocolіз двох методів поверх власного pub/sub писати вам. - Немає
workers=, немає маршруту перевірки стану, немає об'єкта production-налаштувань. ASGI-сервер приносите ви.
Інше, що потрібно перед справжнім іменем хоста, — це токен: Авторизація.