# Почему ошибки запуска MCP-серверов выглядят одинаково?

Клиент MCP может сообщить, что сервер «не удалось запустить», даже если операционная система уже запустила процесс, процесс прочитал входные данные, а сервер успел обратиться к чему-то за пределами машины. Это сообщение не является диагнозом. В него сваливают ошибки запуска, протокола, обнаружения и иногда выполнения инструментов.

Считайте запуск последовательностью границ, каждая из которых оставляет доказательства. Если вы не можете сказать, какую границу прошел сервер, нельзя понять, безопасен ли повтор, могли ли использоваться учетные данные или клиент просто не смог показать исправный сервер. Решение не в увеличении тайм-аута. Нужно разделить состояния и сделать каждое наблюдаемым.

## Один красный статус скрывает четыре разные ошибки

Оператору нужны четыре ответа по порядку: запустил ли клиент настроенную команду, завершили ли обе стороны инициализацию MCP, получил ли клиент пригодный список инструментов и достиг ли какой-либо код внешнего канала. Каждый ответ доказывает разные вещи.

Процесс может завершиться еще до того, как появится в обычном смысле. Может отсутствовать исполняемый файл, рабочий каталог, менеджер пакетов может завершиться до вызова вашего кода, а дочерний процесс может сразу остановиться из-за отсутствующей переменной окружения. Назовем это **ошибкой запуска**. MCP-сессии еще нет, и код приложения мог вообще не выполниться.

Процесс может существовать и все равно не пройти обмен протоколом. При stdio дочерний процесс получает stdin и stdout, соединенные с клиентом. Сервер должен читать JSON-RPC из stdin и записывать в stdout только сообщения JSON-RPC. Затем он должен ответить на запрос клиента `initialize` совместимой версией протокола и объявленными возможностями. После этого клиент отправляет `notifications/initialized`. Если последовательность не завершилась, это **ошибка рукопожатия**.

Успешное рукопожатие еще не доказывает, что клиент узнал об инструментах. Сервер может объявить поддержку инструментов, но завершиться с ошибкой при их регистрации, создать недопустимую схему входных данных, вернуть неправильный результат `tools/list` или пустой список, потому что его конфигурация отключила все инструменты. Это **ошибка обнаружения инструментов**. Клиент и сервер могут быть достаточно исправны для обмена сообщениями, но агенту нечего вызывать.

Наконец, сервер может пройти обнаружение и завершиться с ошибкой только при запуске инструмента. Это **ошибка выполнения инструмента**. Ее нужно записывать в отдельный инцидент. Если смешать ее с запуском, кто-нибудь обязательно повторит запуск сервера, который уже отправил HTTP-запрос или открыл SSH-соединение.

Документация Model Context Protocol позволяет увидеть это разделение, даже если многие интерфейсы клиентов его скрывают. В рекомендациях по отладке отдельно рассматриваются проблемы процесса и конфигурации, протокол и журналы, а для локальных stdio-серверов отдельно отмечается необходимость держать обычные журналы вне stdout. Жизненный цикл инициализации протокола и запрос `tools/list` являются разными обменами. Сохраняйте это различие в собственной телеметрии, а не принимайте общий ярлык клиента.

## Ошибка запуска заканчивается до появления MCP

Ошибка запуска означает, что клиент не получил пригодный дочерний процесс с читаемым протокольным потоком. Она не означает, что команда «выглядела правильно» в терминале.

Интерактивная оболочка многое скрывает. В ней есть свой `PATH`, текущий каталог, менеджеры версий языков, учетные данные и dotfiles, которые приложение для рабочего стола или подпроцесс агента может не получить. В macOS клиент может запускать процесс из `/`. Он может использовать ограниченное окружение. Команда может передаваться как исполняемый файл с массивом аргументов, а не через оболочку, поэтому алиасы и перенаправления оболочки не сработают.

До попытки рассуждать о MCP сохраните точную запись запуска:

```text
run_id=run_01JX...
phase=launch
command=/usr/local/bin/node
argv=["/Users/dev/work/acme-mcp/dist/index.js"]
cwd=/
pid=84217
started_at=2026-07-22T14:03:12.417Z
```

После завершения процесса или истечения срока рукопожатия сохраните конечное событие:

```text
run_id=run_01JX...
phase=launch
exit_code=1
signal=null
stderr=Error: ENOENT: no such file or directory, open './config.json'
```

Такая запись быстро разрешает распространенный спор. У сервера не было «проблемы MCP». Он ожидал, что относительный путь разрешится относительно каталога проекта, а клиент запустил его из `/`.

Используйте абсолютные пути для исполняемого файла, точки входа, конфигурации и любых файлов, которые читаются при запуске. Руководство по отладке MCP прямо указывает на неопределенный рабочий каталог серверов, запущенных клиентом, и рекомендует абсолютные пути. Это не вопрос стиля. Так устраняется источник ошибок, которые появляются только после установки той же конфигурации на другой машине.

Не объявляйте запуск успешным только потому, что получили PID. PID говорит, что ядро создало процесс. Он ничего не говорит о загрузке программы, целостности канала stdout или о том, не превратился ли процесс уже в зомби, ожидающий обработки.

Полезная машина состояний запуска невелика:

```text
not_requested
  -> spawn_requested
  -> spawned
  -> executable_ready
  -> handshake_pending
```

`spawned` означает, что родитель получил PID дочернего процесса. `executable_ready` означает, что дочерний процесс после загрузки конфигурации и установки обработчика критических ошибок записал в stderr намеренное событие готовности, не относящееся к протоколу. Не отправляйте это событие в stdout. В stdio-сервере stdout не является каналом журналов, который случайно оказался рядом. Это провод протокола.

Событие готовности не должно утверждать, что сервер подключен к API, базе данных или удаленному узлу. Оно должно сообщать только доказанный факт: процесс дошел до настройки транспорта MCP. Строка `ready=true` становится опасной, если команды начинают понимать ее как «инструменты можно безопасно вызывать». Лучше явно назвать этап.

## У рукопожатия узкое определение

Ошибка рукопожатия MCP начинается после появления пригодного процесса и заканчивается до завершения жизненного цикла инициализации. Не называйте ее ошибкой подключения, пока не проверите сообщения.

Для транспорта stdio важны первые байты в stdout. Обычный баннер запуска может повредить поток еще до того, как сервер увидит запрос. То же может сделать зависимость, печатающая уведомление об обновлении, `console.log`, Python `print`, форматировщик исключений или скрипт-обертка, который пишет статус в stdout. Официальные рекомендации MCP по сборке и отладке говорят об этом прямо: для stdio-серверов журналы нужно писать в stderr, потому что stdout несет протокольные сообщения.

Минимальная полезная трассировка исправной инициализации выглядит так:

```json
{"direction":"in","id":1,"method":"initialize"}
{"direction":"out","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{}},"serverInfo":{"name":"acme","version":"1.4.0"}}}
{"direction":"in","method":"notifications/initialized"}
```

Точная версия протокола зависит от версий, которые поддерживают клиент и сервер. Важно, что сервер выбрал принятую клиентом версию, вернул корректный результат и получил уведомление о завершении. Сохраняйте разобранное событие для каждого сообщения, а не необработанные данные, содержащие учетные данные.

Если трассировка начинается так, диагноз меняется:

```text
stdout: Starting Acme MCP server
{"jsonrpc":"2.0","id":1,"method":"initialize",...}
```

Сервер может быть вполне способен ответить, но JSON-парсер клиента уже встретил недопустимый ввод. Тайм-аут после этого не означает, что сервер работал медленно. Он означает, что транспорт был поврежден.

Другая знакомая ошибка выглядит лучше:

```text
phase=handshake
initialize_received=true
initialize_response_sent=false
fatal_error=Cannot read properties of undefined (reading 'tools')
```

Дочерний процесс запустился и получил запрос, но завершился при подготовке ответа. Это ошибка сервера или необработанное предположение о конфигурации, а не неправильная конфигурация клиента.

Делайте границы явными в журналах:

```text
phase=handshake event=initialize_received run_id=run_01JX request_id=1
phase=handshake event=initialize_responded run_id=run_01JX request_id=1 protocol_version=2025-06-18
phase=handshake event=initialized_received run_id=run_01JX
```

Если записывать только `connected=true`, исчезает единственный бит информации, отличающий отправленный ответ от завершенного жизненного цикла инициализации. Клиент может закрыть или перезапустить процесс после получения ответа, но до отправки уведомления. С операционной точки зрения это не то же самое, что ошибка разбора инициализации.

Задайте для рукопожатия отдельный срок. Начинайте отсчет при создании процесса или, если это наблюдаемо, при готовности транспорта. Останавливайте его при получении `notifications/initialized`. При истечении срока сообщайте последнее подтвержденное событие, например `spawned_no_initialize`, `initialize_received_no_response` или `response_sent_no_initialized`. Такие имена подсказывают оператору, какую сторону проверять первой.

## Обнаружение инструментов завершается после того, как сервер уже доступен

Ошибка обнаружения инструментов означает, что клиент и сервер могут говорить по MCP, но клиент не получил пригодный ответ на `tools/list`. Ее часто принимают за ошибку запуска, потому что многие клиенты ищут инструменты сразу после инициализации.

Не считайте пустую панель инструментов доказательством пустого результата `tools/list`. Некоторые клиенты скрывают инструменты после ошибки проверки схемы, кэшируют результаты или запрашивают инструменты только при начале задачи агентом. Другие подключаются к MCP-серверу ради ресурсов или подсказок и вообще не запрашивают инструменты. В доказательствах должны быть и запрос, и ответ.

Исправная трассировка обнаружения имеет такой вид:

```json
{"direction":"in","id":2,"method":"tools/list"}
{"direction":"out","id":2,"result":{"tools":[{"name":"issue_lookup","description":"Fetch one issue by identifier","inputSchema":{"type":"object","properties":{"id":{"type":"string"}},"required":["id"]}}]}}
```

В записи обнаружения указывайте число инструментов и дайджест нормализованных схем. Дайджест помогает понять, был ли в двух запусках один и тот же объявленный интерфейс, не сохраняя чувствительные описания или конфигурацию. Он также выявляет случайные изменения, когда инструмент остался, но обязательные параметры исчезли.

Не создавайте определения инструментов через обращение к внешнему сервису во время `tools/list`. Такой дизайн превращает обнаружение в побочный эффект, делает обновление списка похожим на выполнение и порождает худший вопрос при инциденте: «Изменило ли что-нибудь перечисление инструментов?» По возможности регистрация инструментов должна быть локальной и детерминированной.

Серверу может понадобиться конфигурация, чтобы решить, объявлять ли инструмент. Прочитайте ее при запуске и запишите результат, но не заставляйте обнаружение ждать обновления токена или SSH-проверки. Если инструменту нужны учетные данные, проверьте наличие локальной ссылки на них, не используя их. Реальное удаленное действие отложите до вызова инструмента клиентом.

Это важно для контроля агента. Если агент обращается к Sallyport через `sp mcp`, успешная запись обнаружения MCP показывает только то, что оболочка открыла вызываемые операции, а не то, что Sallyport выполнил HTTP- или SSH-действие.

Есть одна законная причина вернуть пустой список: в текущей конфигурации у сервера нет включенных инструментов. Укажите это в структурированном ответе или журнале для клиента. Не завершайтесь с ошибкой при регистрации, заставляя клиента гадать, был ли набор инструментов намеренно пустым.

```text
phase=discovery event=tools_list_responded run_id=run_01JX tool_count=0 reason=no_enabled_tools
```

Такая запись дает оператору конкретную проблему конфигурации. Общая ошибка запуска оставляет лишь суеверие, которое повторяют снова.

## Для внешней доступности нужно отдельное доказательство

На вопрос «достиг ли процесс внешнего канала?» нельзя ответить по PID, успешному рукопожатию или заполненному списку инструментов. Нужна запись на границе, где код пытается выполнить внешнее действие.

Определяйте внешний канал узко. Сюда входят исходящий HTTP-запрос, запуск SSH-помощника, подключение к базе данных за пределами локального процесса, обновление облачных учетных данных, публикация в очереди сообщений и любой вызов, который может создать эффект или раскрыть информацию за пределами MCP-сессии. Чтение локального файла конфигурации не считается внешним каналом. Само по себе извлечение учетных данных из защищенного локального хранилища тоже не считается. Их отправка в запросе считается.

Записывайте попытку до начала вызова, затем ее результат. Используйте непрозрачный идентификатор действия, который можно связать с ID запроса MCP и ID запуска сервера.

```text
run_id=run_01JX phase=execution event=external_attempt action_id=act_8Qf tool=issue_lookup channel=https host=api.example.test
run_id=run_01JX phase=execution event=external_result action_id=act_8Qf status=200 duration_ms=184
```

Не помещайте в записи заголовки авторизации, bearer-токены, подписанные URL, аргументы команд с секретами или полные тела ответов. Журнал инцидента, раскрывший учетные данные, которые он должен был расследовать, только усугубит ситуацию.

Расположение `external_attempt` имеет практическое значение. Если записать его слишком рано, можно заявить о внешнем вызове, хотя код лишь собрал объект запроса. Если слишком поздно, тайм-аут или падение процесса оставит пробел после того, как байты уже покинули машину. Отправляйте событие сразу перед вызовом библиотеки, который может начать сетевую или SSH-активность.

Пример сбоев показывает, почему это важно. Оператор добавляет MCP-сервер, который читает токен трекера задач при инициализации модуля и вызывает endpoint «кто я», чтобы его проверить. Дочерний процесс запускается, пишет отладочную строку в stdout и повреждает первое сообщение MCP. Клиент показывает «сервер не удалось запустить». Команда дважды перезапускает клиент.

Без записей этапов команда решает, что запрос не покинул машину, поскольку сервер не появился в интерфейсе клиента. Это неверно. Вызов при инициализации модуля выполнился до отправки клиентом `initialize` и трижды обратился к трекеру задач. Состояние интерфейса ничего не сказало о внешней доступности.

Перенесите проверку личности в намеренно доступный для чтения инструмент или выполняйте ее в рамках первого действия, которому действительно нужен удаленный сервис. Тогда это будет выполнение инструмента. Сервер сможет запуститься, инициализироваться и показать инструменты, не обращаясь к сети. Оператор сможет различать «сервер доступен» и «учетные данные и удаленный сервис работают». Это разные факты, их нужно разделять.

## Журнал этапов превращает расплывчатые инциденты в проверяемые утверждения

Создавайте одну запись журнала на каждый запуск и добавляйте неизменяемые события этапов. Сложный движок правил не нужен. Нужны стабильные имена, временные метки и поля корреляции, достаточные для восстановления событий.

Используйте такую форму:

```json
{
  "run_id": "run_01JX",
  "server_name": "acme",
  "pid": 84217,
  "phase": "discovery",
  "event": "tools_list_responded",
  "request_id": 2,
  "tool_count": 4,
  "at": "2026-07-22T14:03:13.083Z"
}
```

Журнал должен хранить события, а не выводы, вставленные в строку. `phase=handshake` и `event=initialize_received` можно подсчитать, найти запросом и проверить. `message="MCP seems stuck"` нельзя.

Держите модель состояний простой:

1. `spawn_requested`, `spawned`, `executable_ready` и `exited` относятся к запуску.
2. `initialize_received`, `initialize_responded` и `initialized_received` относятся к рукопожатию.
3. `tools_list_received` и `tools_list_responded` относятся к обнаружению.
4. `tool_call_received`, `external_attempt` и `external_result` относятся к выполнению.
5. `revoked`, `terminated` и `client_disconnected` описывают прерывание, а не успех.

Команды часто смешивают **установление сессии и полномочия на действие**. Сервер может установить MCP-сессию, не имея права использовать учетные данные или подключаться к удаленной системе. Если считать это одним состоянием, событие одобрения будет выглядеть как событие подключения, а отклоненное действие как неудачный запуск.

Храните события авторизации рядом с вызовом, которым они управляют. Например, записывайте `authorization_requested` и `authorization_granted` после `tool_call_received`, но до `external_attempt`. Тогда оператор сможет доказательно сказать, что запрос инструмента поступил, человек его отклонил и внешней попытки не было. Это гораздо точнее, чем сказать, что запрос «не завершился».

Используйте ID запуска только для одного дочернего процесса. Не используйте имя сервера как идентификатор корреляции. Клиент может запустить две копии одного сервера, перезапустить одну после тайм-аута и сохранить старые метаданные инструментов. Повторно используемые идентификаторы смешают отдельные попытки в вымышленную историю.

Хешируйте или скрывайте значения, раскрывающие данные пользователя. Обычно нужны имя инструмента, хост endpoint, класс статуса, класс ошибки и время выполнения. Строка запроса, тело запроса или ответ обычно не нужны. Оператору нужно доказать прохождение границы, а не восстановить пользовательские данные из журналов.

## Проверяйте границы, не полагаясь на весь клиент

Полный клиент полезен для интеграционных тестов, но плох как первый источник сведений. Его интерфейс может объединять ошибки, кэшировать возможности, перезапускать дочерние процессы и применять собственный тайм-аут. Сначала проверяйте каждую границу более узким способом.

Начните с точной команды, окружения и рабочего каталога клиента. Не заменяйте настроенную команду на `npm run dev`. Не запускайте процесс из каталога проекта, если клиент запускает его из другого места. Перенаправьте stderr в файл для проверки, но оставьте stdout нетронутым, если через него другой процесс будет говорить по MCP.

Для stdio-сервера первым протокольным тестом используйте официальный MCP Inspector. Документация MCP рекомендует Inspector для проверки серверов через разные транспорты, а проект Inspector умеет напрямую запускать команду stdio. Он позволяет увидеть обмен инициализации и вызвать `tools/list`, не гадая, что настольный клиент сделал с результатом.

Затем сведите тест к трем проверкам:

```text
1. Остается ли настроенная команда работать достаточно долго, чтобы получить initialize?
2. Возвращает ли она корректный ответ initialize и получает ли initialized?
3. Возвращает ли tools/list ожидаемые имена инструментов и схемы?
```

Только после этого вызывайте инструмент, который обращается к внешней системе. Выберите безопасное действие только для чтения. Убедитесь, что журнал выполнения содержит один `external_attempt` и один конечный результат. Изменяющие данные операции проверяйте в одноразовом окружении или используйте отдельный режим без обращения к рабочей конечной точке.

Для HTTP-транспортов добавляйте доказательства HTTP, но не смешивайте их с состоянием MCP. Записывайте метод запроса, путь endpoint, статус, идентификатор сессии, если он есть, и то, содержал ли ответ JSON или начинался с потока событий. TCP-соединение или HTTP 200 сами по себе не означают завершения инициализации MCP. После достижения запроса сервером применяйте те же записи жизненного цикла.

Храните в тестах сервер-заглушку, который намеренно завершается на каждой границе. Один экземпляр завершается до чтения входных данных. Другой пишет `hello` в stdout перед ответом. Третий отвечает на `initialize`, а затем возвращает недопустимую схему инструмента. Четвертый показывает один инструмент, обработчик которого записывает внешнюю попытку и возвращает контролируемую ошибку. Если интеграция клиента превращает все четыре случая в одно оповещение, исправьте интеграцию до того, как реальный сервер заставит вас отлаживать вслепую.

## Тайм-ауты и повторы должны принадлежать конкретным этапам

Один тайм-аут запуска подталкивает к неправильному исправлению. Медленная загрузка пакета, ошибка разбора инициализации, исключение схемы и зависание удаленного API выглядят одинаково. Используйте отдельные сроки, потому что каждый принадлежит своему владельцу.

Запускающая сторона отвечает за период от `spawn_requested` до `spawned`. Сервер и транспорт отвечают за период от создания процесса до `initialized_received`. Путь регистрации сервера отвечает за обнаружение. Обработчик инструмента и его удаленная зависимость отвечают за выполнение. Называйте тайм-аут по владельцу и записывайте последнее подтвержденное событие этапа.

```text
error=handshake_timeout last_event=initialize_received run_id=run_01JX
```

Это действие, а не догадка. Оно подсказывает сопровождающему сервер проверить подготовку ответа и stderr, а не удаленный API.

Автоматический повтор безопасен только тогда, когда сбойный этап не создает внешнего эффекта. Повторить неудачный запуск из-за временной недоступности исполняемого файла может быть приемлемо. Повторить запрос обнаружения обычно безопасно, если обнаружение локальное и не меняет состояние. Повторять вызов инструмента после `external_attempt` опасно, если удаленная операция не поддерживает документированный механизм идемпотентности с переданным значением идемпотентности.

Не прячьте повтор за перезапуском сервера. Если код запуска обновляет токен, создает туннель, отправляет телеметрию или проверяет удаленную личность, перезапуск уже является внешним действием. Это еще одна причина держать запуск локальным и переносить удаленную работу в явные инструменты.

Если клиент завершает дочерний процесс после истечения срока, по возможности запишите событие прерывания до завершения. Сервер может не успеть его сбросить. Родительский процесс должен записать запрос на завершение как отдельное событие и указать последнее событие дочернего процесса, которое он увидел. Так сохраняется честная картина: процесс, возможно, собирался ответить, но утверждать, что он ответил, нельзя.

## Сначала сделайте запуск скучным, потом быстрым

Хороший MCP-сервер запускается без сети, использования учетных данных, изменяемых побочных эффектов и неоднозначности в протокольном состоянии. Он загружает локальную конфигурацию, устанавливает транспорт, отвечает на инициализацию и объявляет детерминированный интерфейс. Такое поведение проще сопровождать и безопаснее повторять.

Самая популярная плохая рекомендация звучит так: «проверяйте все при запуске». Она кажется ответственной, потому что ошибки появляются рано. На практике она смешивает локальную конфигурацию, личность, доступность удаленной системы и авторизацию в одном непрозрачном ритуале. Из-за этого клиенты повторяют внешние действия под ярлыком ошибки запуска.

Проверяйте локально все, что можно проверить локально. Отчитывайтесь о доступности удаленной системы через явный инструмент или первую операцию, которой она действительно нужна. Сохраняйте стабильные имена этапов. Для stdio оставляйте stdout только протокольным. Проверяйте намеренно сломанный сервер на каждой границе, которую вы обещаете наблюдать.

Когда следующий клиент сообщит, что MCP-сервер не запустился, по одной записи запуска вы должны ответить на четыре вопроса: запустился ли процесс, завершилась ли инициализация, можно ли было обнаружить инструменты и достигло ли что-либо внешнего мира. Если на все четыре ответить нельзя, статус все еще неоднозначен.
