Перейти к содержанию

Развёртывание и масштабирование

Машинный перевод

Эта страница переведена с английской документации автоматически, и основной версией остаётся английская страница. Если что-то читается неправильно, на странице Переводы объясняется, как об этом сообщить.

Сервер работает. Теперь ему нужно настоящее доменное имя и больше одного рабочего процесса за ним.

Почти ничего из этого 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, хранится в памяти одного рабочего процесса. Привязка сессий (sticky sessions). Последующий запрос, попавший к другому рабочему процессу, получает 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))"

Одни ключи и одно имя

Развёртывание с несколькими экземплярами должно разделять и то и другое. Если имена по экземплярам для вас важны, дайте всему парку один явный audience: 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"]) — вот и весь ответ, и он никогда не требует аутентификации, даже когда остальной сервер требует. Для liveness-пробы это правильно, для чего угодно приватного — нет. Пример есть на странице Добавление в существующее приложение.
  • Нет объекта настроек для продакшена. В 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=[...]) и одно и то же имя сервера на каждом экземпляре. Имя — это audience claim токена по умолчанию. Одни ключи, одно имя.
  • Уведомления об изменениях пересекают реплики через одну общую SubscriptionBus. Единственная реализация в SDK — внутрипроцессная; Protocol из двух методов поверх собственного pub/sub предстоит написать вам.
  • Нет workers=, нет маршрута работоспособности, нет объекта настроек для продакшена. ASGI-сервер — ваш.

Второе, что нужно настоящему доменному имени перед собой, — это токен: Авторизация.