Кому подойдёт
Тем, кто уже собрал MCP-сервер с парой инструментов и упёрся в вопросы «почему клиент его не видит», «как показать прогресс долгой операции» и «как это держать в проде». Пригодится и тем, кто пишет клиента или подключает чужие серверы к своей системе.
Что понадобится
Node.js 18+ или Python 3.10+ с SDK (@modelcontextprotocol/sdk или mcp), curl и любой MCP-клиент: Claude Code, Claude Desktop, Cursor, VS Code. Честно про доступ: из России сайты и API Anthropic без VPN не открываются, российской картой подписка не оплачивается, а Claude Code требует тарифа Pro/Max либо ключа API с балансом. Сам протокол к Anthropic не привязан — сервер отлаживается локально в MCP Inspector, без единого платного запроса.
Три формы JSON-сообщения и жизненный цикл сессии
MCP не изобретает формат: внутри JSON-RPC 2.0 и три вида сообщений. Запрос — jsonrpc, id, method и необязательный params, на него обязан прийти ответ. Ответ — тот же id и либо result, либо error с полями code, message и необязательным data. Уведомление — method и params без id: отправитель ничего не ждёт и не узнает, дошло ли оно.
Идентификатор уникален в пределах сессии и не бывает null. Ответы приходят не в порядке отправки, сопоставлять их можно только по id — иначе первый же параллельный вызов всё перепутает. Коды ошибок стандартные: -32700 (JSON не распарсился), -32600 (невалидный запрос), -32601 (нет метода), -32602 (кривые параметры), -32603 (внутренняя), а от -32000 и ниже — ваши. Провал инструмента ошибкой протокола при этом не считается: его отдают обычным result с флагом isError и текстом в content, чтобы модель прочитала причину и попробовала иначе.
Сессия открывается запросом initialize: клиент шлёт protocolVersion (версии датируются — 2024-11-05, 2025-03-26, 2025-06-18 и далее), capabilities и clientInfo. Сервер отвечает своей версией и возможностями, клиент подтверждает уведомлением notifications/initialized, и только после него можно вызывать остальное. Объявленные возможности — контракт: не заявил клиент sampling или roots, значит сервер не имеет права их дёргать. Из служебного пригодятся ping и notifications/cancelled с полем requestId для отмены долгого вызова.
Sampling: сервер просит клиента подумать
Обычно модель зовёт инструмент, сервер выполняет и отдаёт данные. Sampling разворачивает направление: сервер сам просит клиента сходить в языковую модель методом sampling/createMessage. В params идут messages (роли user/assistant с содержимым text, image или audio), обязательный maxTokens, systemPrompt, temperature, stopSequences и modelPreferences. Внутри modelPreferences — hints с именем семейства модели и три ползунка от 0 до 1: costPriority, speedPriority, intelligencePriority; выбор модели всё равно за клиентом.
Выгода прямая: сервер получает доступ к модели без своего ключа API — считает клиент, на своём тарифе. Сценарии обычные: сжать длинный документ перед возвратом в контекст, разобрать ответ стороннего API в JSON, сочинить текст коммита по диффу. Поле includeContext со значениями none, thisServer и allServers просит подмешать контекст диалога, а в ответе приходят role, content, model и stopReason.
Две оговорки. Спецификация предполагает человека в цикле: клиент показывает запрос пользователю и даёт отклонить или отредактировать его, поэтому отказ — штатный сценарий, а не исключение. Поддержка вдобавок неровная, часть клиентов sampling не реализует вовсе. Смотрите на capabilities из ответа на initialize, а не на документацию, и держите запасной путь: нет sampling — сервер отдаёт сырые данные с пометкой, что сжатия не было.
Логи и прогресс: как не молчать полторы минуты
Логирование включает серверная возможность logging. Клиент задаёт порог запросом logging/setLevel, сервер шлёт уведомления notifications/message с полями level, необязательным logger (имя подсистемы) и произвольным data. Уровни как в syslog: debug, info, notice, warning, error, critical, alert, emergency. Клиент показывает их в своей панели отладки, поэтому это правильный канал для «что сейчас делает сервер».
Прогресс работает на токенах. Клиент кладёт в params долгого запроса поле _meta.progressToken — строку или число, а сервер шлёт notifications/progress с этим токеном, растущим значением progress, необязательным total и человеческим message вроде «проиндексировано 340 из 1200 файлов». Без total клиент покажет неопределённый индикатор. Вернули result — прогресс по этому токену слать уже нельзя.
Дальше дисциплина. Прогресс — это UX, а не результат: всё, что должна увидеть модель, обязано лежать в финальном result, потому что уведомления в контекст диалога обычно не попадают. Не шлите уведомление на каждый элемент — обновляйте по времени, и несколько раз в секунду уже потолок. Пришло notifications/cancelled — прекращайте работу и замолкайте, иначе клиент получит поток мусора по мёртвому токену.
Roots: где серверу разрешено ходить
Roots — возможность на стороне клиента. Сервер отправляет запрос roots/list и получает массив объектов с полями uri и name: это каталоги, которые пользователь считает рабочей областью. В текущей редакции спецификации uri обязан быть file://-адресом, то есть речь про файловую систему. Открыл пользователь другой проект — клиент шлёт notifications/roots/list_changed, и сервер перезапрашивает список.
Смысл не в удобстве, а в границах. Файловый сервер без roots вынужден спрашивать путь у модели, а модель охотно подставит домашнюю директорию целиком. С roots сервер сам знает, что индексировать, и отказывает внятно: «путь за пределами рабочих каталогов».
Ловушка в том, что roots — подсказка о намерении пользователя, а не механизм безопасности: сообщить клиент их сообщит, но соблюдать не принудит. Проверяет сервер и только после нормализации: разворачивайте относительные пути и симлинки через realpath, сравнивайте по границе каталога, иначе /home/user/proj-secret пройдёт проверку на префикс /home/user/proj. Список может прийти пустым или метод вообще не поддерживаться — тогда работает ваша политика по умолчанию, и она должна быть запретительной.
STDIO: подпроцесс и неприкосновенный stdout
В STDIO клиент сам запускает сервер дочерним процессом: пишет в stdin, читает из stdout. Сообщения разделяются переводом строки, поэтому внутри JSON сырых переносов быть не должно — только компактная однострочная сериализация в UTF-8. Ни порта, ни аутентификации: процесс работает от имени пользователя, секреты приходят переменными окружения из конфига клиента.
Отсюда главное правило и самая частая поломка: stdout принадлежит протоколу. Любой print, console.log, баннер фреймворка или прогресс-бар в stdout превращается в невалидное сообщение, и клиент отваливается на разборе ещё до initialize. Диагностику пишите в stderr, его клиент складывает в свои логи: на Python это file=sys.stderr, на Node — логгер, настроенный на process.stderr.
Завершение тоже регламентировано: клиент закрывает stdin, ждёт нормального выхода, шлёт SIGTERM и только потом SIGKILL. Значит, сервер обязан реагировать на закрытие входного потока — закрывать соединения с БД, дописывать файлы. Один пользователь — один процесс, масштабирования здесь нет и не требуется.
StreamableHTTP: один эндпоинт, два метода
Сетевой транспорт живёт на одном URL вроде https://api.example.com/mcp. POST отправляет одно клиентское сообщение, и в заголовке Accept указывают сразу application/json и text/event-stream: сервер вправе ответить обычным JSON либо открыть поток SSE, слать в него прогресс и логи, а в конце — сам ответ. GET на тот же URL открывает поток сообщений, которые сервер инициирует сам; не поддерживается — сервер честно возвращает 405. Раздельные /sse и /messages из ревизии 2024-11-05 устарели, новую интеграцию на них не начинают.
Сессия держится на заголовке. В ответе на initialize сервер может вернуть Mcp-Session-Id, и дальше клиент прикладывает его к каждому запросу вместе с MCP-Protocol-Version. Протухла сессия — сервер отвечает 404, и правильная реакция клиента не ретрай, а новый initialize без старого идентификатора. Закрывают сессию методом DELETE на тот же эндпоинт, ответ 405 на него означает лишь отсутствие явного закрытия. Обрыв SSE лечится нумерацией событий полем id и заголовком Last-Event-ID при переподключении — события для этого придётся хранить хотя бы в памяти.
Открытый порт меняет модель угроз. Сервер обязан проверять заголовок Origin, иначе вкладка браузера через DNS rebinding достучится до вашего локального сервера; слушать 127.0.0.1, а не 0.0.0.0; не принимать запросы без авторизации. Для удалённых серверов спецификация опирается на OAuth 2.1: неавторизованный запрос получает 401 с заголовком WWW-Authenticate, откуда клиент узнаёт, где брать токен. И не принимайте токены, выписанные не для вас, — сквозная пересылка чужого токена превращает сервер в открытый прокси к чужому API.
Состояние сессии и что с ним делать при масштабировании
Сессия хранит больше, чем кажется: согласованную версию протокола, возможности обеих сторон, уровень логирования, список roots, подписки на ресурсы, живые progressToken'ы, а в прикладном слое — кэш, открытый курсор к базе, авторизованного пользователя. В STDIO всё это спокойно живёт в памяти одного процесса. По HTTP тот же код за балансировщиком ломается на первом же запросе, ушедшем на другой под.
Честных стратегий три. Липкие сессии: балансировщик ведёт один Mcp-Session-Id на тот же экземпляр — просто, но перевыкат убивает все сессии разом. Внешнее хранилище (Redis, база) делает поды взаимозаменяемыми ценой сериализации состояния и блокировок. Третья — не выдавать Mcp-Session-Id вовсе: stateless-API, каждый запрос самодостаточен, при необходимости клиент повторяет initialize; для инструментов, которые просто читают внешние данные, это обычно лучший вариант.
Что бы вы ни выбрали, задайте сессиям TTL и снимайте метрику «сколько их живо сейчас». Без срока жизни это утечка памяти, которая проявится не сразу, а после долгого аптайма. Идентификаторы делайте криптостойкими и непредсказуемыми, а привязку к пользователю храните на сервере: угадываемая сессия — чужой доступ к вашим инструментам.
Отладка и наблюдаемость в проде
Первый инструмент — MCP Inspector: npx @modelcontextprotocol/inspector node build/index.js поднимает локальный веб-интерфейс и печатает в консоль URL вместе с токеном авторизации, открывать нужно ссылку целиком. Внутри видно всё: список инструментов и их схемы, ручной вызов с произвольными аргументами, сырой лог JSON-RPC в обе стороны, переключение уровня логов. Это самый быстрый способ отличить «сервер сломан» от «клиент не так его вызывает», и проходить его стоит до подключения к реальному клиенту.
Сервер не появился в клиенте — идите по короткому списку. Стартует ли процесс той же командой и в том же рабочем каталоге, что прописаны в конфиге: относительные пути ломаются чаще всего остального. Не улетает ли лишнее в stdout. Что в логах клиента: в Claude Code это запуск claude --debug и команда /mcp со статусом каждого сервера, Claude Desktop пишет логи по MCP в свой каталог (на Windows — %APPDATA%\Claude\logs). И переменные окружения: подпроцесс наследует не то же окружение, что ваш терминал, поэтому ключ, который «точно есть», внутри сервера часто пустой.
В проде нужна не отладка, а телеметрия: построчный JSON с id запроса, методом, именем инструмента, длительностью и исходом, а из метрик — p50/p95 на инструмент, доля ошибок, число живых сессий, отмены и таймауты. Отдельно измеряйте размер ответа: тихая деградация выглядит не как падение, а как инструмент, который начал возвращать десятки килобайт JSON и съедать контекст модели. Аргументы и результаты целиком не логируйте — там оказываются токены и персональные данные, храните хэш и длину. И ставьте таймаут на каждый внешний вызов внутри инструмента, возвращая по нему понятный текст в result: молчаливое зависание не понимает никто.
Практика
- Запустите свой сервер (или минимальный, с одним инструментом) в MCP Inspector командой npx @modelcontextprotocol/inspector с вашей командой старта и найдите в сыром логе три первых обмена: initialize, ответ на него, notifications/initialized.
- Сделайте инструмент, который заведомо работает 10–15 секунд, научите его читать _meta.progressToken и слать notifications/progress с progress, total и текстовым message, обновляя состояние по времени, а не на каждом файле. Убедитесь, что финальные данные всё равно приходят в result.
- Объявите возможность logging, добавьте обработчик logging/setLevel, разложите по коду вызовы debug/info/warning/error и проверьте в Inspector, что при переключении с debug на warning поток сообщений поредел.
- Специально сломайте STDIO: добавьте print("debug") в stdout и посмотрите, как клиент падает на разборе, затем перенесите вывод в stderr. Один раз увидев эту ошибку вживую, вы будете узнавать её мгновенно.
- Реализуйте roots/list и подписку на notifications/roots/list_changed, напишите проверку пути через realpath и сравнение по границе каталога и прогоните её тремя входами: путь внутри корня, путь с ../ наружу, симлинк наружу.
- Переведите сервер на StreamableHTTP и подёргайте руками: curl -i -X POST http://127.0.0.1:3000/mcp с заголовком Accept: application/json, text/event-stream и телом initialize, найдите Mcp-Session-Id, повторите с ним tools/list, затем пошлите неверный идентификатор и убедитесь, что приходит 404.
- Добавьте sampling с обязательной веткой отказа: инструмент просит клиента о sampling/createMessage на суммаризацию, а если возможность не объявлена или пользователь отклонил запрос — отдаёт сырые данные с пометкой.
- Заведите в stderr построчный лог вызовов с полями request_id, tool, duration_ms, status, result_bytes, прогоните десяток вызовов, посчитайте p95 и введите лимит с усечением для самого тяжёлого ответа.
Проверьте себя
- Отличаю по сырому JSON-RPC запрос, ответ и уведомление и понимаю, почему провал инструмента возвращается как result с isError, а не как error протокола
- Умею добавить прогресс через _meta.progressToken и логи через logging/setLevel, не путая их с содержимым ответа для модели
- Понимаю, что roots приходят от клиента и ничего не гарантируют, и пишу проверку пути, которая не обходится через ../ и симлинки
- Знаю, чем STDIO отличается от StreamableHTTP, почему в stdout нельзя писать ничего лишнего и что означают Mcp-Session-Id, MCP-Protocol-Version и ответ 404
- Могу выбрать стратегию хранения состояния сессии — липкие сессии, внешнее хранилище или stateless — и объяснить, чем каждая плоха в моём случае
- Знаю, какие логи и метрики снимать с сервера в проде, и локализую проблему через Inspector и логи клиента, а не перебором
Частые вопросы
Sampling не работает: сервер шлёт запрос, а ответа нет. Это баг?
Скорее всего нет. Часть клиентов возможность sampling не реализует, поэтому сервер должен свериться с capabilities из ответа на initialize и не вызывать sampling/createMessage вовсе. Даже там, где поддержка есть, пользователь вправе отклонить запрос: спецификация предполагает подтверждение человеком. Всегда держите ветку, которая отдаёт результат без участия модели.
Что выбрать для своего сервера — STDIO или StreamableHTTP?
STDIO, если сервер работает с локальными данными конкретного человека: файлы, git, локальная база, утилиты командной строки. Он проще, не требует аутентификации и не открывает портов. StreamableHTTP нужен, когда сервер общий: живёт на удалённой машине и обслуживает нескольких пользователей. Вместе с ним приходят обязанности — проверка Origin, OAuth, TLS и жизненный цикл сессий.
Клиент отваливается сразу после запуска сервера, ошибка про невалидный JSON. Куда смотреть?
В stdout. В STDIO этот поток целиком принадлежит протоколу, и любая посторонняя строка ломает разбор: отладочный print, баннер фреймворка, предупреждение зависимости, прогресс-бар. Запустите сервер вручную в терминале и посмотрите, что печатается до первого JSON-сообщения. Всё лишнее переводите в stderr.
Нужно ли платить, чтобы всё это отработать?
Сам протокол бесплатен, а MCP Inspector — локальная программа, которая никуда в облако не ходит, так что практику можно пройти без единого платного запроса. Деньги появляются при подключении сервера к реальному клиенту: Claude Code требует тарифа Pro/Max либо ключа API с балансом. Из России сервисы Anthropic без VPN недоступны, а российской картой они не оплачиваются — на разработку самого сервера это не влияет, но доступ надо продумать заранее.
Сервер иногда возвращает 404 на обычный вызов, хотя раньше на этой сессии всё работало. Что происходит?
Сессия истекла или запрос попал на другой экземпляр сервера, который про ваш Mcp-Session-Id ничего не знает. Ретраить тот же запрос бессмысленно: клиент должен заново пройти initialize и получить новый идентификатор. На стороне сервера это лечится липкими сессиями на балансировщике, общим хранилищем состояния или полным отказом от сессий.
Модель перестала нормально пользоваться инструментом, хотя код не менялся. С чего начать?
Померьте размер того, что инструмент возвращает. Частая причина — данные выросли, ответ раздулся и вытесняет остальной контекст, так что модель видит обрывки. Введите лимит с усечением и пометкой, сколько записей показано из скольких. Вторая по частоте причина — описание и схема параметров, из которых непонятно, когда инструмент вызывать: перепишите описание на язык задач пользователя.
Чтобы пройти практику, нужен рабочий доступ
Подключим подписку на ваш аккаунт: оплата картой РФ, по СБП или криптой, пароль от аккаунта не нужен. Обычно за 15 минут в рабочее время.
Подключить Claude Pro — 2 490 ₽ Все уроки