Транспорт STDIO
Большинство MCP-серверов, которыми вы пользуетесь, — это процессы на вашей машине, которые хост запустил сам. Между ними нет сети, портов и сертификатов: есть три потока байтов. Этот урок о том, как устроен такой обмен, какие правила нельзя нарушать и какие ошибки из-за этого чаще всего случаются.
Как это работает
Транспорт stdio описывается одним абзацем спецификации. Клиент запускает сервер как дочерний процесс. Сервер читает JSON-RPC-сообщения из своего стандартного ввода (stdin) и пишет их в стандартный вывод (stdout). Каждое сообщение — одна строка: сообщения разделяются переводом строки и не могут содержать перевод строки внутри. Стандартный поток ошибок (stderr) сервер может использовать для логов как угодно, а клиент может их собирать, показывать или игнорировать — и не должен считать, что вывод в stderr означает ошибку.
Из этого следуют два жёстких запрета. Сервер не должен писать в stdout ничего, что не является валидным сообщением MCP. Клиент не должен писать в stdin сервера ничего, что не является валидным сообщением MCP.
Вот и весь транспорт. Никакого рукопожатия на уровне транспорта, никаких заголовков: всё, что нужно знать о запросе, включая версию протокола и возможности клиента, лежит в _meta самого сообщения.
Почему нельзя печатать в stdout
Это самая частая ошибка в stdio-серверах, и стоит понять её механику, а не просто запомнить правило. Клиент читает stdout построчно и каждую строку разбирает как JSON. Если сервер напишет туда print("загружаю данные"), клиент получит строку загружаю данные, попытается разобрать её как JSON и получит ошибку -32700. В лучшем случае он проигнорирует строку; в худшем — разорвёт соединение, потому что поток испорчен.
Опаснее ситуации, когда печатает не ваш код, а библиотека: прогресс-бары, предупреждения, приветственные баннеры. Всё это обычно идёт в stdout. В stdio-сервере такие библиотеки нужно либо настраивать на тишину, либо перенаправлять их вывод.
Python SDK v2 защищает от этого: пока сервер обслуживает stdio, дескриптор 1 (стандартный вывод) перенаправлен в stderr, так что случайный print уйдёт в логи, а не в протокол. Но небуферизованный вывод в момент завершения процесса всё ещё может испортить поток, и в других языках и старых версиях SDK такой защиты может не быть. Правило остаётся: логи — через logging (в stderr), в stdout — ничего.
Секреты и окружение
У stdio-сервера нет HTTP-заголовков, а значит, нет и Authorization. Спецификация прямо говорит: серверы на stdio не должны использовать OAuth-механизм из раздела авторизации, а должны брать учётные данные из окружения. На практике это переменные окружения, которые хост передаёт при запуске процесса.
Здесь есть неочевидная деталь. Клиент SDK не передаёт дочернему процессу всё своё окружение — только минимальный список безопасных переменных (PATH, HOME и подобные). Токен трекера, ключ базы данных и всё, что нужно серверу, надо передавать явно:
from mcp import Client, StdioServerParameters
server = StdioServerParameters(
command="uv",
args=["run", "--directory", "/opt/mcp/reports", "server.py"],
env={"TRACKER_TOKEN": "..."},
)
async with Client(server) as client:
...В конфигурации хостов это выглядит похоже. Формат отличается от хоста к хосту, но идея одна — команда, аргументы, окружение:
{
"mcpServers": {
"reports": {
"command": "uv",
"args": ["run", "--directory", "/opt/mcp/reports", "server.py"],
"env": { "TRACKER_TOKEN": "..." }
}
}
}Токен в этом файле лежит открытым текстом, и это одна из причин, по которым секреты для локальных серверов стоит хранить в системном хранилище и подставлять при запуске, а не коммитить конфигурацию хоста в репозиторий.
Завершение процесса
Спецификация описывает аккуратную последовательность. Клиент закрывает stdin сервера — это основной и единственный переносимый сигнал «заканчивай». Сервер, увидев конец ввода, должен завершиться сам. Если он не завершился за разумное время, клиент посылает SIGTERM, а затем SIGKILL (на Windows — эквиваленты). Сервер тоже может инициировать завершение: закрыть свой stdout и выйти.
Если сервер упал неожиданно, клиент должен его перезапустить. Поскольку протокол не хранит состояния, запросы, которые были в работе, просто теряются, и клиент может повторить их против нового процесса. Подписки на уведомления (subscriptions/listen) после перезапуска нужно открыть заново.
Для автора сервера это означает: не ловите EOFError на stdin, не делайте бесконечных фоновых задач, которые переживут закрытие ввода, и не рассчитывайте, что процесс живёт вечно.
Отмена и один общий канал
В stdio у каждого запроса нет своего потока: все ответы и уведомления идут одной очередью в stdout, а клиент разбирает их по id и токенам. Поэтому отмена запроса здесь — это сообщение: клиент шлёт уведомление notifications/cancelled с requestId, сервер должен прекратить работу и не отправлять ответ. Это отличие от HTTP, где отменой служит закрытие потока ответа.
И ещё одно правило новой ревизии, которое в stdio особенно наглядно: сервер не пишет в stdout JSON-RPC-запросы. Только результаты, ошибки и уведомления. Sampling и roots вкладываются в результат input_required.
Посмотреть руками
Самый быстрый способ понять stdio — пообщаться с сервером без всякого клиента. Возьмите сервер из любого практикума и отправьте ему строку через конвейер:
echo '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}' | uv run server.pyВ stdout придёт одна строка с результатом server/discover: supportedVersions, capabilities, имя сервера в _meta. После неё процесс завершится, потому что echo закрыл его stdin — та самая последовательность завершения из предыдущего раздела. Если у вас старая версия SDK, метод server/discover окажется неизвестен, и вы получите ошибку -32601; замените запрос на initialize.
Логи от logging при этом появятся в терминале — они идут в stderr. Чтобы убедиться, что в stdout только протокол, добавьте 2>/dev/null — останется одна JSON-строка.
Запуск в SDK
mcp.run() без аргументов — это stdio; так же работает uv run mcp run server.py. Для отладки — uv run mcp dev server.py: Inspector сам запустит сервер как подпроцесс и покажет обмен. Клиент подключается через StdioServerParameters, как в примере выше.
Когда выбирать stdio: сервер работает на той же машине, что и хост, обслуживает одного пользователя и не должен быть доступен по сети. Это большинство инструментов для разработчика — доступ к файлам, локальной базе, git. Как только сервер нужен нескольким людям или должен жить на отдельной машине, переходите на Streamable HTTP — о нём следующий урок.
Попробуйте сами
10–15 мин на рабочем месте- Возьмите свой сервер и намеренно добавьте
print("debug")в инструмент. Запустите под Inspector и посмотрите, что произошло: SDK перенаправил вывод или протокол сломался. Затем замените наlogger.infoи сравните. - Повторите эксперимент с
echo ... | uv run server.pyиз урока, но отправьте две строки подряд (printf '...\n...\n'):server/discoverиtools/list. Убедитесь, что пришли два ответа с разнымиid. - Посмотрите конфигурацию MCP-серверов в своём хосте и найдите, где там переменные окружения. Проверьте, нет ли этого файла в git.
Коротко
- Хост запускает сервер как дочерний процесс; сообщения — по одной строке JSON в
stdin/stdout, без переводов строк внутри. stdoutпринадлежит протоколу: любой посторонний вывод ломает разбор. Логи — вstderrчерезlogging.- Секреты передаются переменными окружения; клиент SDK не наследует окружение автоматически — задавайте
env=. - Завершение: клиент закрывает
stdin→ сервер выходит → при необходимостиSIGTERM,SIGKILL. Упавший сервер перезапускают. - Отмена в stdio — уведомление
notifications/cancelled; в новой ревизии сервер не пишет вstdoutзапросов. mcp.run()по умолчанию — stdio; проверить обмен можно черезecho ... | uv run server.py.
Видеоверсия
Сценарий озвучки · 490 слов, ≈ 4 мин
Большинство серверов, которыми вы пользуетесь, — это процессы на вашей машине, которые хост запустил сам. Между ними нет сети, портов и сертификатов. Есть три потока байтов: ввод, вывод и поток ошибок. Сегодня разбираем, как устроен такой обмен.
Правила умещаются в один абзац. Клиент запускает сервер как дочерний процесс. Сервер читает сообщения из стандартного ввода и пишет их в стандартный вывод. Каждое сообщение — одна строка джейсона; переводов строки внутри быть не может. Поток ошибок сервер использует для логов как хочет, и клиент не должен считать, что вывод туда означает ошибку. Отсюда два запрета. Сервер не пишет в стандартный вывод ничего, кроме валидных сообщений протокола. Клиент не пишет в ввод сервера ничего, кроме валидных сообщений.
Почему нельзя печатать в стандартный вывод — это самая частая ошибка, и стоит понять механику. Клиент читает вывод построчно и каждую строку разбирает как джейсон. Если сервер напечатает «загружаю данные», клиент получит эту строку, попытается разобрать и получит ошибку разбора. В лучшем случае проигнорирует, в худшем — разорвёт соединение. Опаснее, когда печатает не ваш код, а библиотека: прогресс-бары, баннеры, предупреждения. Python SDK второй версии защищает: пока сервер работает, стандартный вывод перенаправлен в поток ошибок, и случайный print уйдёт в логи. Но в других языках и старых версиях такой защиты может не быть. Правило остаётся: логи через логгер, в вывод — ничего.
Про секреты. У такого сервера нет заголовков, а значит, нет и заголовка авторизации. Спецификация говорит прямо: серверы на стандартном вводе-выводе берут учётные данные из окружения. На практике это переменные окружения, которые хост передаёт при запуске. Неочевидная деталь: клиент из SDK не передаёт дочернему процессу всё своё окружение, только короткий безопасный список. Токен трекера или ключ базы нужно передавать явно. В конфигурации хоста это выглядит похоже: команда, аргументы, окружение. И токен там лежит открытым текстом — не коммитьте такой файл.
Завершение процесса. Клиент закрывает ввод сервера — это основной сигнал «заканчивай». Сервер, увидев конец ввода, должен выйти сам. Если не вышел за разумное время, клиент посылает сигнал завершения, потом сигнал убийства. Если сервер упал неожиданно, клиент перезапускает его, а незавершённые запросы просто повторяет: протокол ведь не хранит состояния.
Отмена запроса. В стандартном вводе-выводе у каждого запроса нет своего потока — всё идёт одной очередью. Поэтому отмена здесь — это сообщение: клиент шлёт уведомление «отменено» с идентификатором запроса. Это отличие от эйч-ти-ти-пи, где отменой служит закрытие потока.
Самый быстрый способ всё это прочувствовать — поговорить с сервером без клиента. Берёте сервер из любого практикума и через конвейер отправляете ему одну строку джейсона с запросом «обнаружить сервер» и обязательными метаданными. В ответ в терминале появляется одна строка: поддерживаемые версии, возможности, имя сервера. И процесс завершается, потому что команда echo закрыла его ввод. Логи от логгера при этом тоже видны — они идут в поток ошибок, и если его отключить, останется только чистая строка протокола.
Когда выбирать этот транспорт: сервер работает на той же машине, что и хост, для одного пользователя, и не должен быть доступен по сети. Это большинство инструментов разработчика. Как только сервер нужен нескольким людям или на отдельной машине — переходите на Streamable HTTP. О нём следующий урок.
