# 孤儿 MCP 进程仍可能留下未完成工作

客户端崩溃并不能告诉你它的 MCP 服务器是否停止了。它只能说明某个进程退出了，或者至少不再响应。两者的区别很重要，因为 stdio 是一种传输方式，不是对已经跨过进程边界的工作执行终止开关。

我见过团队把一堆旧辅助进程当成日常清理问题，后来才发现其中一个仍然持有云令牌、SSH 控制套接字，或一批没人将其关联回失败运行的工作。清理命令很简单。真正被忽略的是重建谁授权了最后一次操作。

正确的处理有三个独立目标：准确识别残留进程，确认它是否仍有行动路径，并保留能够把最后调用关联到所属会话的记录。按这个顺序执行。先杀进程也许能消除麻烦，却可能抹掉解释事情经过的最佳证据。

## 无父进程是线索，不是结论

孤儿进程是指原始父进程已经退出、操作系统父进程发生变化的子进程，新的父进程通常是 PID 1。这是有用的证据，但不是不安全 MCP 服务器的定义。进程监管器、shell、IDE 和启动服务都可能在正常运行中重新指定健康子进程的父进程。

对于 stdio MCP 服务器，应问一个更具体的问题：这个进程是否仍属于一个活跃的客户端连接，还是在拥有其 stdin 和 stdout 的客户端消失后继续存活？回答需要结合进程关系、打开的描述符、经过的时间和活动记录。

Model Context Protocol 文档将 stdio 描述为一种由本地进程启动的集成方式。客户端启动命令，并通过服务器的标准输入和标准输出交换以换行分隔的 JSON-RPC。对于行为正常的服务器，这种设计提供了一个简单的生命周期结束信号：当输入到达 EOF 时，客户端一侧已经消失。

EOF 说明传输已断开或关闭，但不能说明每个工作进程、子进程、网络连接、计时器或远程任务都停止了。服务器可能接收到工具调用、开始工作，然后在生成 JSON-RPC 响应前失去客户端。如果处理程序启动了子进程，或向远程系统发送了请求，那么这项工作可能拥有自己的生命周期。

人们经常把两种不同的故障混为一谈：

- **操作系统孤儿进程**失去了原始父进程，或脱离了预期的进程树。
- **逻辑孤儿进程**失去了赋予其工作意义的会话，即使它的父 PID 看起来仍然正常。

第一种情况可能浪费内存或占用端口。第二种情况可能造成不想要的外部变化。两者都要检查。

PPID 为 1、运行时间较长且没有打开 stdin 的进程很可疑。仍连接到活动终端的进程也可能有风险，比如其所属客户端已经无声卡死，而进程仍持有凭证或可复用连接。相反，崩溃后留下的进程也可能没有危害，只要它没有权限，无法接触任何操作通道。

不要建立「PPID 为 1 就杀掉」这样的规则。应建立一份记录，说明这个进程为何存在、由哪次运行创建、仍能访问什么，以及你对它采取了什么措施。

## 从一份经得起检查的进程清单开始

在发送任何信号前先拍摄快照。终止进程后，你无法恢复它的命令行、进程组或打开的描述符，而 PID 很快被复用，零散笔记会迅速变成猜测。

在 macOS 上，先使用完整视图，不要一开始就用过滤掉关键信息的巧妙单行命令：

```sh
ps -axo user,pid,ppid,pgid,stat,etime,command
```

查找 MCP 客户端启动的命令。它可能是直接运行的服务器二进制文件、`node` 或 `python` 等解释器、包启动器、SSH 辅助程序，或 `sp mcp` shim。在缩小搜索范围前，把相关行复制到事件记录中。

这些字段回答不同的问题：

- `pid` 只标识本次检查中的进程。
- `ppid` 告诉你直接父进程是否仍存在。
- `pgid` 标识进程组，通常能显示一起启动的同级辅助进程。
- `stat` 可以显示进程处于休眠、停止或不可中断状态。
- `etime` 显示一个本应刚刚启动的运行是否已经持续了数小时或数天。
- `command` 往往是启动所使用的可执行文件和参数的唯一残存记录。

然后逐一直接检查候选进程。替换实际 PID，并把带时间戳的输出保存到事件记录中。

```sh
ps -o pid=,ppid=,pgid=,stat=,etime=,user=,command= -p 48271
lsof -nP -p 48271
```

第一条命令提供简洁的身份记录。第二条命令告诉你进程仍打开了什么。重点查看文件描述符 0、1 和 2。正常的 stdio 服务器通常有用于读取 stdin 的描述符，以及用于写入 stdout 和 stderr 的描述符。如果 stdin 已到达 EOF，进程仍可能显示一个管道描述符，所以不要仅凭它是否存在来判断进程是否活跃。需要查看更完整的情况：对端进程、终端、普通文件、Unix 套接字、TCP 连接和子工作进程。

`lsof` 还可以暴露进程列表隐藏的问题。比如服务器的父进程已经消失，但服务器仍拥有一个连接到内部 API 的已建立 TCP 连接。这不能证明它一定能发起新请求，却提供了一个需要调查的具体能力。如果它持有 SSH 控制套接字，或持有连接到凭证助手的本地 Unix 套接字，应把它当作线索，而不是据此假设安全。

采取行动前先检查进程组：

```sh
ps -axo pid,ppid,pgid,stat,etime,command | awk '$3 == 48271 || $2 == 48271'
```

这里假设 `48271` 是你观察到的组 ID，请替换为实际值。输出可能显示包装器、服务器和辅助子进程，而对单个 PID 执行简单的 `kill` 会留下它们。它也可能证明进程没有其他同级进程，因此更容易隔离。

避免一个常见捷径：只搜索 `node`、`python` 或 `npx`，然后杀掉所有匹配项。这些名称是运行时，不是身份。大范围清理可能破坏编辑器扩展、本地测试、构建任务或无关的自动化作业。应将完整命令行与预期的服务器配置进行匹配，再检查它周围的父进程和进程组。

如果客户端在启动时记录服务器命令，就把这个命令保存在会话记录旁边。之后调查时，你就不必模糊地搜索进程名，而可以直接比较。

## stdio 管道不等于操作边界

编写良好的 stdio 服务器应在 stdin 关闭后停止接受请求。它应取消尚未开始的工作，关闭传输，并在完成有明确上限的清理后退出。官方 TypeScript MCP SDK 指南在关闭部分也强调了同一点：关闭传输并不会自动等待正在执行的工具处理程序完成，然后再让进程退出。

这一点值得更多关注。客户端崩溃时，工具处理程序可能处于以下状态之一：

1. 尚未开始外部工作，可以干净地取消。
2. 已发送请求，但尚未收到响应。
3. 已完成远程变更，却在报告成功前失去了响应路径。
4. 启动了一个本地子进程或远程任务，其生命周期超过了处理程序。
5. 正在等待外部依赖，之后可能恢复。

只有第一种状态可以安全地描述为「什么都没发生」。其余四种都需要从已死亡的客户端进程之外寻找记录。

举个具体例子。服务器收到一个部署构建的工具调用，把请求写入远程构建 API，随后客户端在 API 处理过程中崩溃。服务器看到 stdout 管道断开。如果它立即退出，构建可能仍会继续。如果没有幂等机制就重试，可能启动第二个构建。如果它带着长期有效的凭证继续运行，可能在原客户端消失后轮询、重试或启动后续工作。

问题不在于服务器继续存活，而在于把传输关闭当成关于授权、取消和远程状态的完整结论。

为每个操作通道明确边界：

- 什么事件会阻止新工作进入服务器？
- 什么标识符可以查询或取消已经发出的工作？
- 远程操作是否支持幂等或请求令牌？
- 客户端消失后，哪个进程仍持有凭证？
- 如果无法交付 JSON-RPC 响应，最终结果会记录在哪里？

如果对高影响工具无法回答这些问题，就不能仅因为它使用 stdio 而称其安全。

SSH 也遵循同样的规则。看似交互式的命令仍可能在远程主机上启动后台进程。关闭本地客户端可能关闭本地通道，但从 shell 脱离的远程命令仍会继续。服务器需要一种让远程工作可观察、可取消的命令设计，不能寄希望于终端断开。

## 证明残留进程是否仍能执行操作

进程存在不等于拥有权限。你需要在不触发真实操作的情况下测试行动路径。

先确定权限在哪里。服务器从自身环境变量读取 API 令牌，与每次操作都要求独立代理执行的服务器，风险不同。服务器把 SSH 私钥放在文件中，与通过短期本地助手委托执行的服务器，风险也不同。针对正在审查的具体服务器写下答案。

然后检查进程仍打开并可访问的内容。`lsof` 是起点，但它不会显示每个内存中的凭证，也不会显示每个已认证会话。把它与自己的操作日志以及外部系统的审计轨迹结合起来。

一个实用的调查顺序如下：

1. 记录候选 PID、命令、PPID、进程组，以及打开的网络或 Unix 套接字。
2. 找到与该进程或其会话关联的最近一次外部操作。
3. 检查客户端崩溃时间之后是否发生了新操作。
4. 撤销或禁用该进程的授权路径。
5. 观察进程是否尝试再次操作，以及网关是否拒绝了它。

不要通过要求孤儿服务器执行一个看似无害的写入来测试。许多系统不存在真正无害的写入，测试调用可能改变速率限制、制造审计噪声、轮换状态或触发自动化。优先使用只读健康检查或身份端点。如果设计中有网关或凭证代理，更好的做法是在撤销后验证网关或代理是否拒绝调用。

这里有一个必须分清的区别：仍能访问网络的进程不一定还能执行操作，而没有可见网络连接的进程也可能稍后重新获得操作能力。DNS 解析、代理、本地助手、排队的计时器或子进程都可能重新打开路径。因此，撤销测试比套接字快照更重要。

如果服务器直接持有可复用密钥，清理范围必须包括密钥轮换或撤销。杀掉进程只会从内存中移除其中一份副本，不会改变攻击者、转储出的进程镜像或远程服务使用同一密钥的能力。团队常因轮换会带来额外工作而回避它，这可以理解，但不能作为安全理由。

网关模型会改变调查方式。服务器可以作为不受信任的进程继续存活，却因为从未持有凭证，且不再拥有获批准的活动会话，而无法发起新的受保护调用。Sallyport 将 API 和 SSH 密钥保存在加密保险库中，并由自身执行操作，因此代理不会以明文或替代值的形式接收到密钥。这会降低残留 stdio 进程造成的损害，但仍应撤销可疑运行，不能假设进程崩溃已经替你完成撤销。

实际标准很简单：撤销会话或操作路径后，任何受保护调用都必须被拒绝，并且拒绝结果必须有记录。无法证明这一点，就不能确认已经完成遏制。

## 在清理改变事实之前记录最后调用

事件记录需要稳定标识符，而不是事后凭记忆拼出的叙述。PID 是临时的，可能被复用。进程命令也可能在更新后改变。会话标识符和防篡改审计记录能提供更可靠的锚点。

至少为每个可疑进程建立一行记录，包含以下字段：

```text
Observed at:
Client process PID and command:
MCP server PID and command:
Parent PID and process group:
Session identifier:
Authorization state:
Last successful action time:
Last attempted action time:
Action target and operation:
Result or remote job identifier:
Revocation time and operator:
Termination signal and exit result:
Follow-up required:
```

最重要的一对字段是会话标识符和最后一次尝试的操作。许多团队只记录成功调用，却隐藏了崩溃调查中最能说明问题的时刻：请求已经离开本地机器，却始终没有收到结果。

分开记录三个时间点：客户端变得不可用的时间、服务器最后一次已知操作开始的时间，以及撤销权限的时间。如果只使用一个「事件时间」，就无法判断调用发生在崩溃之前、不确定窗口内，还是在遏制应该生效之后。

还要保存操作目标。「调用了云 API」远远不够。记录账户或端点类别、方法或 SSH 命令类别，以及能够让操作员在远程服务中搜索的请求或任务标识符。不要在普通事件记录中保存敏感请求正文。你需要足够的信息来核对副作用，不需要再保存一份所有密钥和客户记录。

如果网关分别维护会话日志和活动日志，就同时使用两者。会话记录回答谁运行了代理进程，以及这次运行是否仍获授权。活动记录回答具体发生了哪些调用以及顺序如何。这是两个不同的问题，把它们混在一个宽泛的事件流中，会让两者都更难在压力下回答。

Sallyport 从一份加密、哈希链式审计日志中生成这两类日志。捕获相关会话和活动条目后，运行离线完整性检查：

```sh
sp audit verify
```

把命令的完整原始结果与事件记录一起保存。验证不会判断操作是否获授权或是否明智，只会告诉你依赖的审计链是否仍能在不访问保险库的情况下通过验证。当调查人员不应仅为查看历史而获得凭证时，这种分离很有用。

不要等到正式安全事件发生才练习这些记录工作。日常崩溃复盘最容易发现缺少会话 ID、命令含义不清和日志只存在于已死亡机器上的问题。在平静的清理过程中修复这些缺口，成本远低于生产写入后才发现问题。

## 终止进程前先撤销权限

最安全的顺序是：撤销、验证拒绝、然后停止进程。反过来做似乎更快，因为进程会立即消失，但授权记录可能仍然有效，也会让之后的关联更困难。

从影响范围最小但有效的撤销开始。如果系统按代理运行跟踪授权，就撤销这次运行。如果服务器接触过某个凭证，就禁用或轮换该凭证。如果远程任务拥有取消句柄，就单独取消该任务。这些是不同的操作，因为它们对应不同的生命周期。

不要假设服务器退出会取消远程工作。进程可能已经消失，但请求仍在队列中。也不要假设远程取消会停止服务器。只要它仍保有权限，就可能重试或提交另一个请求。遏制要求本地和远程两端都确认这次运行已经结束。

撤销操作路径后，用适合你环境的证据验证状态。可以是网关活动日志中的拒绝记录、对安全身份端点发起的失败认证请求，或远程审计记录中显示令牌已禁用。具体方法会有所不同，但原则不变：证明存活的进程无法发起受保护调用。

然后对已确认的 PID 发送正常终止信号：

```sh
kill -TERM 48271
sleep 2
ps -p 48271 -o pid=,ppid=,stat=,etime=,command=
```

如果最后一条命令没有返回进程行，记录这一结果。如果进程仍在，检查它是否正在关闭、阻塞在 I/O 中，或让子工作继续存活。升级操作前再次检查进程组和子进程。可能需要单独终止已确认的辅助进程，但不要在未确认所有成员都属于同一次失败运行前，盲目杀掉整个进程组。

只有在普通终止失败且证据已经保存后，才使用 `kill -KILL`。SIGKILL 不给进程关闭文件、取消工作、写出最后日志或清理临时状态的机会。有时这是正确的取舍，但应明确它的性质：这是强制遏制，清理可能不完整。

当命令行不足以判断时，macOS 的 Activity Monitor 也能提供帮助。Apple 文档说明了普通 Quit 和 Force Quit 操作，它还可以按层级显示进程。层级视图有助于确认父子关系，但不能替代事件记录，因为 GUI 列表不会保留之后所需的会话和操作证据。

## 把服务器关闭设计成硬性要求

stdio 服务器应把客户端消失视为一等事件，而不是罕见例外。客户端崩溃是普通的软件行为：笔记本会休眠，终端会关闭，IDE 会重启，更新会中断进程，代理也可能在模型出错后中止。

服务器应有明确的关闭路径，并具备四个特征：输入关闭时停止接受新请求；为每个已启动操作记录关联标识符；给活动工作一段有上限的时间来取消或进入已知状态；达到上限后退出，而不是意外变成永久后台服务。

不要把优雅关闭误解为无限等待。收到 EOF 后无限等待外部 API 的服务器，只是一个举止更好的孤儿进程。为清理设置截止时间，记录仍未解决的内容，然后退出。未解决的工作必须能通过外部任务 ID 或操作记录被发现。

子进程管理同样重要。如果工具启动编译器、包管理器、SSH 辅助程序、浏览器驱动或命令包装器，服务器必须知道服务器退出时这些子进程是否也应退出。要有意识地设置进程组，记录子 PID。关闭时只终止属于该请求的子进程，并记录它们是否退出。

除非工具契约明确要求持久后台工作，否则不要通过 shell 把工作放到后台。类似 `some-command &` 的命令会创建第二个生命周期，而 MCP 服务器可能无法观察它。如果确实需要持久工作，就提交给返回任务 ID 的任务系统，再通过明确的工具提供状态查询和取消功能。隐藏的后台工作不是持久性，而是审计缺口。

远程服务支持时应使用幂等性。为每次外部写入生成一个由会话和工具调用派生的请求标识符，并在发送请求前记录它。客户端在提交后崩溃时，你可以查询远程系统，而不是猜测是否该重试。如果远程服务不支持幂等，就记录这种不确定性，并要求操作员在重放写入前作出决定。

服务器还应把诊断信息写入 stderr，而不是 stdout。stdout 属于 MCP 的按换行分隔的 JSON-RPC 流。一行多余的调试输出就可能破坏协议、触发客户端失败，并造成你正在清理的崩溃模式。这个细节听起来很小，直到生产服务器在启动时打印出一条库警告。

## 把归属放进操作记录，而不是 shell 历史

shell 历史很方便，但它可能缺失、被截断、被共享，或在进程死亡后才写入。操作进行时，操作记录就必须携带归属信息。

对于每个可能接触外部系统的调用，都附加足够的上下文，以便之后回答四个问题：是哪次代理运行发起的，哪个本地进程提交的，哪项授权决定允许它执行，以及最终产生了哪个外部操作。少了任何一项，客户端崩溃都会留下需要调查人员凭推断填补的空洞。

不要让代码签名身份承担所有归属问题。它能告诉你谁签署了可执行文件，这对决定进程是否值得批准很有用，却不能识别具体运行、导致调用的提示，或远程请求。会话开始时使用代码签名权限判断信任，之后使用会话标识符和逐调用记录追踪实际操作。

命令行也一样。命令可以告诉你启动了 `sp mcp` 或某个服务器可执行文件，却不能可靠地告诉你最后一次工具调用是什么、用户是否批准了它，或远程系统是否接受了它。把进程列表当作辅助证据，而不是审计来源。

发生崩溃时，按以下顺序关联：

1. 找到崩溃时拥有客户端进程的代理运行。
2. 找到该运行启动的服务器进程或进程组。
3. 找到该运行最后的活动条目，并将时间与进程快照比较。
4. 用请求、事务或任务标识符核对任何未完成的外部操作。
5. 将撤销和终止事件记录在最后调用旁边。

这个顺序能避免一个常见错误：发现一个过时 PID 后把它杀掉，之后却因为两个会话使用了同一条服务器命令，把它最后的 API 调用归给错误的代理运行。命令会重复，会话记录不应重复。

如果你的环境目前无法完成这种关联，就应在授予自主工具写权限前补上它。只读实验可以容忍观察不完整，生产变更不能。

## 崩溃演练能在风险较低时暴露缺口

为每个能够进行外部变更的 MCP 服务器执行受控崩溃演练。使用测试账户或只读操作路径，并像处理真实事件一样收集证据。

启动正常客户端会话，并使用已知关联标识符发起一次操作。服务器运行期间，突然终止客户端。然后检查服务器 PID、PPID 和进程组、打开的描述符以及操作日志。撤销会话。确认之后的受保护调用会被拒绝。如果服务器没有在 EOF 后退出，最后终止它，并核对外部操作。

演练应给出答案，而不是一个简单的通过或失败标签。你需要知道 stdin 关闭是否能传到服务器、子进程是否残留、远程任务是否有取消句柄、日志是否能识别所有者，以及撤销是否会立即改变行为。

注意时间点。请求分发前发生的崩溃，与远程服务已经接受请求后发生的崩溃，行为不同。如果服务器提供足够的检测能力，应在这两个时间点分别进行演练。「已发送」与「已确认」之间难看的边界，正是重复写入和错误保证的来源。

为每个服务器写下清晰的清理预期。好的预期应具体说明：输入关闭后，服务器停止接受调用，在配置的超时时间内退出，不留下属于它的辅助进程，并为每个已启动的外部操作创建活动记录。模糊地说「客户端通常会清理」并不是控制措施。正常行为不等于控制。

做过几次之后，过时进程就不再神秘。它们会变成一种有明确处理方式的故障模式：有进程快照、有归属链、有撤销动作，也有清理规则。这才是应达到的标准。进程可能崩溃，但你解释和遏制其最后调用的能力不应随之崩溃。
