# 自定义 MCP 工具审查：一份实用的安全检查清单

自定义 MCP 工具应该接受与一个能够访问工作站、网络和凭据的小型生产集成同等程度的审查。代理通过 MCP 调用工具，并不会降低工具的权限。相反，这往往让草率授予的权限更容易被反复使用。

我见过反复出现的失败模式：开发者读了工具说明，看到 `deploy_preview` 或 `search_docs` 这样的实用名称，觉得它只是本地工具，于是授予访问权限。后来，实现却把模型提供的字符串变成 URL、shell 参数或递归文件读取。真正的安全边界从来不是工具名称，而是实现和凭据路径。

Model Context Protocol 规范把工具描述为服务器向客户端公开、供客户端发现和调用的函数。规范也明确了一个令人不安的事实：工具执行由模型控制。客户端可以把人放进批准环节，但工具作者仍必须假设参数可能出人意料、过于宽泛，或指向错误的目标。在代理有机会自由发挥之前，先审查工具。

## 先画权限地图，不要先读 README

只有当你能画出一条简短而具体的路径，说明代理请求如何产生最终影响时，自定义 MCP 工具才算可以接受。先写清楚工具能读取什么、能把数据发送到哪里、能修改什么，以及哪个凭据或操作系统身份让这些操作成为可能。

在阅读实现细节之前先做这件事。这样你会有一个判断代码的标准，而不会让一段看起来很友好的说明替你设定标准。名为 `get_build_status` 的工具可能会读取本地配置文件、调用托管 API、写入缓存，还会运行命令行辅助程序。每个动作都有不同的失败模式。

可以使用这样一张简短的权限地图：

| 部分 | 记录内容 | 重要原因 |
|---|---|---|
| 代理输入 | 工具字段的确切内容和最大长度 | 显示模型能够影响什么 |
| 本地读取 | 路径、环境变量、配置文件 | 揭示意外的数据收集 |
| 本地写入 | 缓存、工作区、git 状态、临时文件 | 找出持久化副作用 |
| 网络 | 主机名、端口、方法、重定向 | 界定外传和请求风险 |
| 进程 | 可执行文件路径、参数、子进程 | 找出 shell 注入和继承的权限 |
| 凭据 | 凭据名称、范围、存储位置、撤销负责人 | 让撤销成为可能 |
| 结果 | 返回给代理的数据 | 防止秘密通过工具返回 |

不要在网络一栏写“互联网”，也不要在凭据一栏写“开发者凭据”。这些标签说明审查还没有开始。请写出主机、API 路由族、账户或令牌，以及能够撤销它的人或系统。

这项工作还会区分两个经常被团队混淆的概念：工具宣称的用途和它实际拥有的权限。`create_issue` 听起来范围很窄。但如果函数接受任意基础 URL、任意标头和任意请求体，它实际上拥有通用 HTTP 客户端的权限。审查后者，不要只看名称。

## 输入 schema 必须减少选择

MCP 输入 schema 应该把代理限制在你真正想允许的操作范围内，而不是围绕自由格式命令通道装饰一层类型定义。

MCP 规范使用 JSON Schema 描述工具输入 schema。这有助于客户端展示参数，也有助于实现层验证输入，但除非服务器在开始工作前拒绝无效值，否则 JSON Schema 并不是强制措施。把 schema 看作第一道门，把服务器端验证看作第二道门。

下面是一个用于获取已知构建状态、便于审查的 schema：

```json
{
  "name": "get_build_status",
  "description": "Return the status for one build in the approved CI project.",
  "inputSchema": {
    "type": "object",
    "additionalProperties": false,
    "required": ["build_id"],
    "properties": {
      "build_id": {
        "type": "string",
        "pattern": "^[A-Z]{2,8}-[0-9]{1,10}$",
        "maxLength": 20
      },
      "include_logs": {
        "type": "boolean",
        "default": false
      }
    }
  }
}
```

它为代理做出了几项限制。代理不能选择主机，不能附加标头，不能传入 shell 命令，也不能发送未声明的字段，因为 `additionalProperties` 为 false。构建标识符有明确的格式和长度限制，因此下游使用更安全，也更容易记录。

再看看下面这种容易出问题的形状：

```json
{
  "name": "request",
  "inputSchema": {
    "type": "object",
    "properties": {
      "url": {"type": "string"},
      "method": {"type": "string"},
      "headers": {"type": "object"},
      "body": {}
    }
  }
}
```

这其实是一个套着友好名称的 HTTP 客户端。如果它拥有 bearer token，就能把令牌或代理控制的提示词数据发送到任意端点。原型阶段这样设计很省时间，所以人们一直保留它。但即使工具名称听起来很具体，它仍然不适合作为生产环境的权限边界。

测试输入处理时，要使用会改变解析方式的值，而不只是看起来格式错误的值。尝试重复的标识符字段、未预期字段、超长字符串、Unicode 形近字符、换行符，以及语法有效但指向预期业务范围之外的值。如果输入会变成路径，就要求它是相对标识符，并把它解析到固定目录下。如果输入会变成 API 过滤条件，应使用结构化参数，而不是拼接查询字符串。

不要因为验证过 schema 就把参数交给 shell。请使用参数数组和固定的可执行文件路径。下面更安全：

```python
subprocess.run(
    ["/usr/local/bin/buildctl", "status", "--id", build_id],
    check=True,
    text=True,
    capture_output=True,
    env={"PATH": "/usr/bin:/bin"}
)
```

下面则属于审查失败：

```python
subprocess.run(f"buildctl status --id {build_id}", shell=True)
```

第一种形式仍然需要输入验证、错误处理和可信的可执行文件，但它不会让 shell 重新解释模型控制的标点符号。

## 每个出站请求都需要固定目标

自定义 MCP 工具应该只拥有一小组目标地址，并在建立连接前拒绝所有其他目标。如果请求代码接受任意 URL，那么只在文档中列出允许的主机名没有意义。

从两个层面审查出站流量。首先检查源码中的 HTTP 库、WebSocket 客户端、DNS 查询、包安装器、遥测 SDK、webhook 库，以及任何能够连接到其他地方的辅助进程。其次观察一次真实运行。静态检查能发现设计中的路径，运行时观察则能发现偷偷联网的依赖，或改变目标的配置值。

安全的客户端会用固定部分构建 URL，只对标识符进行编码：

```python
from urllib.parse import quote

BASE = "https://ci.example.internal/api/builds/"
url = BASE + quote(build_id, safe="")
response = client.get(url, timeout=10, follow_redirects=False)
```

关键控制点不是 `quote`，而是固定的 origin。如果客户端跟随重定向，可信 origin 仍可能返回指向不可信主机的重定向。除非工具会用同一允许列表检查每个重定向目标，否则应禁用重定向。

内部服务也不能忽略这个问题。接受 `http://host/path` 的工具可能被诱导访问本地管理服务或元数据端点，而代理本来无法直接访问这些目标。阻止公共主机并不能解决问题。你需要一份明确批准的 origin 列表，并规定拒绝字面 IP 地址或私有目标，除非工具明确需要它们。

在一次性测试环境中捕获流量。在 macOS 上，`lsof` 可以快速查看当前网络套接字：

```sh
lsof -nP -iTCP -sTCP:ESTABLISHED -c python
```

输出可以显示进程、用户、文件描述符和远程端点：

```text
COMMAND   PID  USER   FD   TYPE             DEVICE SIZE/OFF NODE NAME
python   8421  alex   12u  IPv4 0x...             0t0  TCP 10.0.0.8:51244->203.0.113.20:443 (ESTABLISHED)
```

把 `python` 换成实际的进程名，然后在调用一次工具操作的同时重复执行。这条命令不是完整的流量审计，因为短连接可能在检查前就消失。但它很适合发现意外的长期连接，或你不知道存在的辅助程序。

也要同样仔细地检查请求载荷。工具可能正确调用了一个批准的 API，却把完整的仓库差异、环境变量或代理对话记录塞进查询参数。请在代码中限制出站字段，只用操作所需的命名值构建载荷，不要把代理收到的整个对象序列化后发送。

## 进程身份也是权限的一部分

如果无法确认哪个可执行文件发起了请求，就不能负责任地批准工具使用。仅凭进程名判断很不可靠，因为任何进程都可以选择一个看似熟悉的名称。

记录完整的启动命令、可执行文件路径、版本、工作目录、父进程和用户账户。如果适用 macOS 代码签名，还要检查签名机构。`codesign` 可以显示 macOS 看到的身份声明：

```sh
codesign -dv --verbose=4 /absolute/path/to/mcp-server 2>&1 | grep -E 'Identifier|TeamIdentifier|Authority'
```

常见输出会包含类似字段：

```text
Identifier=com.example.mcpserver
Authority=Developer ID Application: Example Developer
TeamIdentifier=ABCDE12345
```

这些字段只是证据，本身不是权限决定。签名二进制仍可能不是这项工作需要的版本，未签名的内部脚本也不自动意味着恶意。审查要确认路径、所有者、源码和身份是否与预期运行的工具一致。

然后在调用过程中检查进程树：

```sh
ps -axo pid,ppid,user,command | grep -E 'mcp-server|sp-ssh|node|python'
```

你希望看到一个平淡的结果：代理客户端启动 MCP 服务器，服务器只启动预期的辅助进程。如果服务器启动 shell、包管理器、来自可变项目目录的解释器，或会在会话结束后继续运行的后台进程，就应保持警惕。

一个常见问题在配置文件里看起来很无害：

```json
{
  "command": "npx",
  "args": ["-y", "some-mcp-package"]
}
```

根据本地缓存状态和包解析方式，这可能在启动时下载或更换代码。这样一来，昨天审查过的源码就没有团队想象的那么有意义。请固定可执行文件或包版本，通过受控流程安装，并启动已知的本地路径。如果工具需要更新，应把更新设为明确的审查事件，而不是代理启动时悄悄发生的副作用。

进程身份还包括继承的环境。由开发者 shell 启动的服务器可能继承云令牌、源码控制令牌、代理设置和宽泛的 `PATH`。可以在测试运行中打印经过清理的环境清单，或使用最小环境启动。不要记录秘密值，只记录会影响行为的变量名称，并确认工具不依赖原本不应使用的环境凭据。

## 日志必须还原操作，但不能重复秘密

有用的审计记录应回答：谁运行了工具、使用了哪个进程、带着哪些经过清理的参数、访问了哪个目标，以及结果如何。`tool call succeeded` 这样的日志，无法回答代理把数据发送到意外地点时你需要知道的任何问题。

把运行日志和包含秘密的调试输出分开。操作人员需要足够的信息来调查，但不需要 bearer token、授权标头、私钥、原始代理对话记录，或复制进文本文件的完整响应体。

每次调用可以记录类似以下字段：

```json
{
  "time": "2025-03-08T14:22:11Z",
  "session_id": "run_7c2f",
  "process": "/opt/tools/build-mcp",
  "tool": "get_build_status",
  "argument_summary": {"build_id": "CI-4812", "include_logs": false},
  "destination": "ci.example.internal",
  "decision": "approved",
  "result": "success",
  "request_id": "c4e8..."
}
```

这里有意使用参数摘要。摘要应保留有助于调查的标识符和受限字段，同时对敏感值进行删除或哈希处理。如果操作确实要发送文档，可以记录字节数，并在有助于关联时记录内容摘要。不要为了调试方便就把文档本身放进日志。

即使不采用 OpenTelemetry，它的日志模型也很有参考价值。它区分事件正文和属性，并强调使用结构化字段进行筛选和关联。可以在本地采用这个思路，让目标、操作、结果和进程身份都能被机器读取。第一次需要回答代理是否重复发送了同一个请求十次时，一堆散文式日志就会失去作用。

还要检查错误路径。很多工具会清理成功请求，却在 API 返回错误时打印完整的请求对象。强制制造 401、超时、格式错误的 JSON 和 DNS 查询失败，然后阅读每一行输出。凭据通常就是从调试输出中泄露的。

Sallyport 为代理运行保留 Sessions journal，为单次调用保留 Activity journal；两者都从一个不可写、加密并带哈希链的审计日志中生成。需要在工具作者自己的日志之外保留本地记录时，这种设计很有用，但它不会让权限宽泛的工具变得安全。工具仍然需要狭窄的输入和已知的目标地址。

## 批准提示无法修复宽泛权限

只有当被批准的对象范围清楚易懂时，人工批准才是一道有用的刹车。提示一张“未识别进程想使用凭据”的卡片，会告诉你一件重要的事，但它不会告诉你一个通用请求工具是否会在五秒后把仓库文件发送到模型选择的主机。

让批准单位尽量贴近权限单位。只读状态令牌和生产部署令牌的后果不同，不应放在同一个批准后面。读取发布记录的工具也不应因为两个操作恰好使用同一个 API，就悄悄获得创建发布记录的权限。

Model Context Protocol 规范建议客户端在调用工具前取得用户同意。这是合理建议，但同意流程有一个失败模式：人们不断批准重复且描述不清的提示，直到提示本身不再提供信息。不要通过永久批准整类操作来解决提示疲劳，而应修复产生模糊或过度提示的工具边界。

Sallyport 的决策阶梯会先检查锁定的保险库，默认要求每个会话授权，也可以要求每次使用特定凭据时都重新决定。对于部署凭据或可写 API 凭据等你希望每次都检查的敏感凭据，可以使用逐次调用批准。但不要以此为借口，把该凭据交给通用 HTTP 工具。

好的批准测试背后应该有这样一句话：“这个经过签名、从该路径启动的进程，可以在本次运行中使用这个凭据，从该服务读取状态。”如果你无法诚实地说出这句话，就拒绝请求，回到权限地图重新检查。

## 在信任成功之前先测试失败路径

工具能在正常路径上运行，并不代表通过了安全审查。你需要知道输入错误、凭据缺失、网络指向意外位置或辅助进程失败时，它会怎样处理。

在测试账户或隔离项目中运行工具，并使用实际范围尽可能小的凭据。测试数据应足够接近真实数据，以覆盖序列化和大小限制，但不要为了观察结果就把生产秘密交给未经审查的工具。

按以下五步测试：

1. 用一个有效请求调用工具，记录进程树、出站目标和审计记录。
2. 发送未声明字段、最大长度值、包含换行的值，以及一个看似有效但属于不允许项目的标识符。服务器应在发出请求前拒绝每一项。
3. 通过所有可用的输入和配置路径强制使用未经批准的主机。如果工具使用 HTTP，也要包括重定向。工具应拒绝请求，并在不泄露敏感输入的情况下记录拒绝结果。
4. 在服务器仍运行时删除或撤销凭据，然后重复有效请求。确认下一次操作会失败，而不是从隐藏缓存或继承环境中继续成功。
5. 终止父代理进程，检查服务器或辅助进程是否仍然存在。代理退出后仍保留访问权限的后台工作进程必须有明确理由，并接受单独审查。

将证据与工具版本放在一起保存：清单、源码修订版本或包摘要、使用的命令、观察到的端点、经过清理的示例审计记录，以及接受剩余风险的人。这不是无意义的文书工作。没有版本化证据，后续包更新改变工具后，所有人仍会以为旧审查继续有效。

有一种失败尤其值得注意。假设文档搜索工具接受 `repository_path`，并使用 shell 字符串调用辅助程序。普通请求运行正常。后来代理在工单中收到一条嵌入式指令，要搜索一个包含 shell 标点符号的路径。辅助程序以开发者账户执行了第二条命令，读取凭据文件，工具再把结果发送到原本获准的搜索端点。每个组件都做了作者预期的事，但组合失败了，因为 schema 允许路径，shell 重新解释了它，而出站载荷又接受任意辅助程序输出。测试整条链，而不是只测孤立函数。

## 删除配置项还不够，访问撤销需要完整流程

从代理配置中删除 MCP 服务器，会停止通常的启动路径，但不会撤销复制到缓存中的令牌，不会结束仍在运行的服务器，不会从代理进程中删除 SSH 密钥，也不会让远程服务失效。

在授予访问权限时就规划撤销流程。凭据负责人应知道在哪里撤销凭据，工具应尽可能使用独立凭据，操作人员也应知道要删除哪些进程和本地文件。共享开发者令牌会把简单的删除变成事件，因为你无法分辨哪些使用属于该工具。

对于使用 HTTP 的工具，先在远程服务中撤销或禁用令牌。然后停止 MCP 服务器及其子辅助进程，删除工具的本地凭据引用，并删除启动配置。最后重新运行同一个请求并保留被拒绝的结果。对于 SSH，要从远程账户或仓库中删除相关公钥，终止本地辅助进程，并检查代理配置中是否存在其他身份。

不要把本地保险库锁定和远程撤销混为一谈。锁定后，保险库会阻止未来通过它进行的使用，但无法撤回已经发送的数据、让其他地方保存的令牌失效，也无法停止早先继承了凭据的无关进程。

在发生事件前就让撤销流程可测试。增加一条简短的运行手册记录，列出远程撤销位置、预期的拒绝响应、服务器命令、配置位置，以及能够确认请求失败的日志字段。如果工具负责人无法提供这条记录，说明集成还没有完成。

## 权限狭窄的工具才值得反复信任

通过审查的工具通常都很“无聊”，而这是一件好事。它们接受少量类型明确的字段，连接一个已知服务，需要时运行一个已知的可执行文件，只返回代理需要的结果，并留下日后可以检查的记录。

宽泛工具看起来灵活，因为它们把设计决策推给了运行时的代理。但它们也会把一次批准变成对目标、数据和命令的全面权限，而这些内容可能从未被审查。把灵活性留在你控制的代码中，只向代理公开狭窄的操作。

批准自定义工具前，试着删掉一个输入、一个目标地址、一部分凭据范围或一个子进程。如果没人能解释为什么必须保留它，就删掉。这样审查会更容易，之后的事件响应也会更简单。
