# 为什么 MCP 服务器启动失败看起来都一样？

即使操作系统已经启动了进程，进程也读到了输入，服务器甚至已经联系过机器外部的某个对象，MCP 客户端仍可能报告服务器「启动失败」。这不是诊断结果，而是一个把启动失败、协议失败、发现失败，有时还有工具失败混在一起的笼统标签。

应把启动看成一系列能够产生证据的边界。如果你说不清服务器跨过了哪条边界，就无法告诉操作人员重试是否安全、凭据是否可能已经被使用，或者只是客户端没有正确显示一个健康的服务器。解决办法不是把超时时间调得更长，而是拆分这些状态，让每个状态都可观测。

## 一个红色状态会掩盖四种不同的失败

操作人员需要按顺序得到四个答案：客户端是否启动了配置的命令，双方是否完成了 MCP 初始化，客户端是否收到可用的工具列表，以及是否有代码触达了外部通道？每个答案证明的事情都不同。

进程可能在通常意义上还没真正存在时就失败。可执行文件可能不存在，工作目录可能不存在，包运行器可能在调用你的代码前就失败，或者子进程因为缺少必需的环境变量而立即退出。这叫作**启动失败**。此时不存在 MCP 会话，你的应用代码甚至可能完全没有运行。

进程也可能已经存在，却仍然无法完成协议交换。使用 stdio 时，子进程的 stdin 和 stdout 会连接到客户端。服务器必须从 stdin 读取 JSON-RPC，并且只能向 stdout 写入 JSON-RPC 消息。随后，它需要用兼容的协议版本和声明的能力回应客户端的 `initialize` 请求。客户端接着发送 `notifications/initialized`。如果这套流程没有完成，就叫作**握手失败**。

握手成功并不能证明客户端已经发现了工具。服务器可能声明支持工具，却在注册工具时抛出异常，生成无效的输入 schema，返回格式错误的 `tools/list` 结果，或者因为自身配置禁用了所有工具而返回空列表。这叫作**工具发现失败**。客户端和服务器可能都健康到足以交换消息，但代理没有任何可调用的工具。

最后，服务器可能完成工具发现，却只在工具运行时失败。这是**工具执行失败**，应当记录在另一份事件记录中。如果把它归入启动失败，迟早会有人重试一个已经发送 HTTP 请求或打开 SSH 连接的服务器。

Model Context Protocol 文档清楚地展示了这种区分，尽管许多客户端界面并没有体现出来。文档的调试指南将进程和配置问题与协议日志区分开来，并提醒人们，本地 stdio 服务器必须让 stdout 保持干净，不能写入普通日志。协议的初始化生命周期和 `tools/list` 请求也是两次不同的交换。应把这种区别保留在自己的遥测数据中，不要接受客户端的笼统标签。

## 启动失败发生在 MCP 存在之前

启动失败意味着客户端没有获得一个可用的子进程和可读取的协议流。这并不意味着命令在终端里「看起来没问题」。

交互式 shell 会隐藏很多信息。你的 shell 拥有 `PATH`、当前目录、语言版本管理器、凭据和配置文件，而桌面应用或代理子进程可能不会继承这些内容。客户端在 macOS 上启动时，工作目录可能是 `/`。它可能使用受限环境。它也可能把命令作为可执行文件加参数数组传递，而不是通过 shell 执行，这意味着 shell 别名和重定向都不会生效。

在分析 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` 返回了空结果。有些客户端会在 schema 验证失败后隐藏工具，有些会缓存发现结果，还有些只在代理开始执行任务时才延迟请求工具。另一些客户端连接 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"]}}]}}
```

发现记录应包含工具数量，以及规范化 schema 的摘要。摘要能帮助你确认两次运行声明的接口是否相同，而无需保存敏感描述或配置。它也能捕捉到这样的意外变化：工具仍然存在，但必需参数消失了。

不要在 `tools/list` 期间通过联系外部服务来构建工具定义。这样会把发现变成副作用，让客户端刷新看起来像一次执行，并制造最糟糕的事件问题：「列出工具会改变什么吗？」工具注册应尽可能保持本地化和确定性。

服务器可能需要配置来决定是否公布某个工具。应在启动时读取配置并记录结果，但不要让工具发现等待令牌刷新或 SSH 探测。如果工具需要凭据，只验证本地引用是否存在，不要使用它。真正的远程操作应推迟到客户端调用工具时。

这一点对代理控制很重要。如果代理通过 `sp mcp` 访问 Sallyport，成功的 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 和服务器运行 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 服务器，该服务器在模块初始化时读取问题跟踪系统令牌，并调用「当前身份」接口验证令牌。子进程启动后向 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 会话，却没有权力使用凭据或连接远程系统。如果把两者当成同一状态，批准事件可能看起来像连接事件，而被拒绝的操作可能看起来像启动失败。

将授权事件放在它所管理的调用旁边。例如，在 `tool_call_received` 之后、`external_attempt` 之前记录 `authorization_requested` 和 `authorization_granted`。这样操作人员就能用证据说明：工具请求已到达，人类拒绝了它，并且没有发生外部尝试。这比说请求「没有完成」有力得多。

使用只对一个子进程有效的运行 ID。不要把服务器名称当作关联标识符。客户端可能启动同一服务器的两个副本，在超时后重启其中一个，并保留旧的工具元数据。重复使用标识符会把独立尝试拼成虚假的故事。

对会暴露用户数据的值进行哈希或脱敏。通常只需要工具名称、端点主机、状态类别、错误类别和耗时。很少需要查询字符串、请求正文或响应。操作人员需要证明边界已经跨过，而不是从日志中重放用户数据。

## 不要完全依赖客户端来测试边界

完整客户端适合做集成测试，却不适合作为第一个观察者。它的界面可能压缩错误、缓存能力、重启子进程并应用自己的超时。在归咎于服务器或客户端之前，先通过更窄的路径测试每条边界。

先使用客户端实际采用的命令、环境和工作目录。不要用 `npm run dev` 替代已配置的命令。客户端从其他位置启动时，也不要从项目目录运行它。可以将 stderr 重定向到文件以便检查，但如果另一个进程要通过 stdout 与 MCP 通信，就不要改动 stdout。

对于 stdio 服务器，应先使用官方 MCP Inspector 做协议测试。MCP 文档建议使用 Inspector 测试不同传输方式的服务器，Inspector 项目也能直接启动 stdio 命令。它能让你观察初始化交换并调用 `tools/list`，无需猜测桌面客户端如何处理结果。

然后把测试缩减为三项检查：

```text
1. 配置的命令是否能保持运行足够长的时间，以便接收 initialize？
2. 它是否返回有效的 initialize 响应并接收 initialized？
3. tools/list 是否返回预期的工具名称和 schema？
```

只有这些检查通过后，才应调用会触达外部系统的工具。选择一个面向无害目标的只读操作。确认执行账本中包含一个 `external_attempt` 和一个终止结果。如果调用可能修改数据，应在一次性环境中测试，或提供不会联系生产端点的专用试运行操作。

官方 MCP Inspector 仓库在这里很有用，因为它让传输边界清晰可见。它不是服务器流量的网络拦截代理，而是作为选定服务器的 MCP 客户端，并提供浏览器测试界面。这一差异在调查传输损坏时很重要：Inspector 可以复现客户端一侧的协议，但不能证明另一个生产客户端向管道写入了什么。

对于 HTTP 传输，应增加 HTTP 证据，但不要把它与 MCP 状态混为一谈。记录请求方法、端点路径、状态码、存在时的会话标识符，以及响应是否包含 JSON 或以事件流开头。TCP 连接或 HTTP 200 并不自动意味着 MCP 初始化已经完成。HTTP 请求到达服务器后，也要应用同样的生命周期记录。

在测试套件中保留一个会在每条边界故意失败的固定服务器。一个固定服务器在读取输入前退出，另一个在响应前向 stdout 写入 `hello`，第三个回应 `initialize` 后返回无效工具 schema，第四个列出一个工具，该工具的处理器记录一次外部尝试并返回受控错误。如果客户端集成把这四种情况都压缩成同一个告警，应在真实服务器迫使你盲目排查之前先修复集成。

## 超时和重试需要明确的阶段归属

单一启动超时会鼓励错误的修复方式。它会让缓慢的包下载、初始化解析错误、schema 异常和远程 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 服务器启动失败时，你应该能从一条运行记录中回答四个问题：进程是否启动，初始化是否完成，工具是否可发现，以及是否有任何东西触达了外部世界。如果四个问题不能全部回答，状态仍然是不明确的。
