Перейти до змісту

Розгортання та масштабування

Машинний переклад

Цю сторінку перекладено автоматично з англомовної документації, і основною версією є англомовна сторінка. Якщо щось читається неправильно, на сторінці Переклади пояснено, як про це повідомити.

Сервер працює. Тепер йому потрібні справжнє ім'я хоста і більше ніж один робочий процес за ним.

Майже нічого з цього не стосується 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=. Додайте до списку дозволених те, що справді обслуговуєте:

server.py
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 це чотири створення в чотирьох процесах: чотири різні ключі, ніде не записані, нікому не передані, втрачені після перезапуску.

Ось інструмент, який запитує, перш ніж діяти, на сервері, що нічого не налаштовує:

server.py
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 і відновлює роботу взагалі з іншого процесу (Керування циклом самостійно). Будь-що з цього — «інший робочий процес».

Виправлення — один аргумент. У нього дві половини.

server.py
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. Яку б шину ви не дали серверу, саме в неї йде кожна публікація і саме її слухає кожен відкритий потік, тож передайте ту саму шину кожній репліці:

server.py
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-сервер приносите ви.

Інше, що потрібно перед справжнім іменем хоста, — це токен: Авторизація.