GhalebDweikat/mcp-gauntlet

GitHub: GhalebDweikat/mcp-gauntlet

mcp-gauntlet 是一个面向 MCP 服务器的动态回归测试套件,通过实际运行服务器来捕获静态扫描无法发现的工具投毒、定义漂移和健壮性缺陷。

Stars: 0 | Forks: 0

# mcp-gauntlet [![CI](https://static.pigsec.cn/wp-content/uploads/repos/cas/ad/ad5834178f7599af9fdda11629d49cae07f2997beec49821b2920eff5bfd50e7.svg)](https://github.com/GhalebDweikat/mcp-gauntlet/actions/workflows/ci.yml) **一个针对你的 MCP 服务器的回归测试套件。** 在 CI 中运行它,它能捕获你无法通过阅读代码发现的问题:只有在工具被*调用*时才会出现的 payload,两次 `tools/list` 调用之间发生变化的定义,一个默默接受畸形输入的工具,或者一段没有 agent 能够执行的描述。 它基于其**发现**(具有名称和位置的发现结果)来让你的构建失败——而不是基于分数。 它**无法**捕获的内容也已记录在案:安全检查是基于模式的,[docs/known-gaps.md](docs/known-gaps.md) 按类别列出了已被证明可以绕过这些检查的内容——包括最常被报告的真实世界投毒形式。在将一份干净的报告视为安全许可之前,请先阅读它。 ## 快速开始 无需安装、无需 API key、无需克隆——这会运行内置的、刻意设计的恶意演示服务器,并展示描述扫描器无法看到的内容: ``` uvx mcp-gauntlet run "python -m mcp_gauntlet.fixtures.malicious_server" --no-agentic ``` 或者正确安装它: ``` pip install mcp-gauntlet # or: uv tool install mcp-gauntlet # 仅静态 + robustness checks — 无需 API key mcp-gauntlet run "python -m mcp_gauntlet.fixtures.good_server" --no-agentic # 完整 gauntlet,包括 live agent(Groq 的 free tier 即可) export GROQ_API_KEY=gsk_... mcp-gauntlet run "npx -y @modelcontextprotocol/server-everything" ``` 如果你喜欢这种方式,工作目录中的 `.env` 文件也会被读取,以替代 export。 ### 将其指向你自己的服务器 这正是该工具存在的意义,因此值得明确说明。请指定拥有你服务器依赖项的 **解释器**,而不是一个光秃秃的 `python`: ``` # virtualenv 中的 Python server — 显式使用该 venv 的 interpreter mcp-gauntlet run "/path/to/proj/.venv/bin/python -m my_server" --no-agentic mcp-gauntlet run "C:\path\to\proj\.venv\Scripts\python.exe -m my_server" --no-agentic # Node mcp-gauntlet run "node /path/to/proj/dist/index.js" --no-agentic # 包含空格的路径 — 在 spec 中使用单引号。spec 会按照 # POSIX 风格解析,因此双引号会在 tool 接收到它们之前被你的 shell 消耗掉,并且 # 该路径会悄悄地拆分为额外的参数。 mcp-gauntlet run "C:\Tools\python.exe 'C:\Users\Jane Smith\proj\server.py'" --no-agentic # remote server,带有 auth mcp-gauntlet run "https://mcp.example.com/mcp" --header "Authorization: Bearer $TOKEN" ``` 有两个经常会坑到人的问题,在它们让你浪费调试时间之前,值得了解一下: - **光秃秃的 `python` 并不是你的 `python`。** 在 `uvx` 下,gauntlet 自身的环境在 `PATH` 中优先级更高,因此 `python -m my_server` 会运行在*它的*解释器下——该解释器没有你服务器的依赖项,并会抛出一个看起来像是你的 bug 的导入错误。 - **相对路径是从你运行命令的位置开始解析的**,而不是从你服务器所在的目录。使用绝对路径可以避免整个问题。 LLM 后端是与提供商无关的——支持任何兼容 OpenAI 的 endpoint(默认为 Groq;也支持 OpenRouter、Together,或本地的 Ollama / vLLM)。短暂的 429/5xx 响应会在有限的退避时间后重试,因此免费层级的速率限制只会让你短暂等待,而不会中断运行——只有配额真正耗尽(长时间的 Retry-After)才会导致运行结果无法确定。默认情况下,运行是只读的:那些*看起来*像是在修改数据的工具(通过名称/描述或自声明的 MCP `destructiveHint`)会被排除,除非你传入 `--allow-writes`。这种排除是一种尽力而为的启发式方法,而不是保证——对于不受信任的服务器,请将其与只读凭据或一次性环境结合使用。生成的任务集会被缓存,因此分数在不同运行之间是可重现的。 内置的 `good` / `bad` 测试服务器让你可以轻松看出区别: ``` mcp-gauntlet run "python -m mcp_gauntlet.fixtures.bad_server" # capped C — tool poisoning mcp-gauntlet run "python -m mcp_gauntlet.fixtures.good_server" # A ``` 有了 LLM key,这个 bad 测试服务器还会触发 **Response Safety**(响应安全):它的 `status_report` 工具有一个干净的描述,但会投毒其*输出*,因此只有运行时扫描才能捕获它——静态描述扫描无法做到。 ### 查看描述扫描遗漏的内容 第三个测试服务器是一个可运行的、刻意设计的恶意服务器。每个工具都有一个无害的描述,因此读取描述的扫描器什么也找不到——攻击隐藏在显示标题中,隐藏在 `$ref` 后面的*输出* schema 中,隐藏在工具在调用时返回的内容中,隐藏在第一次 `tools/list` 时完全干净而第二次被投毒的工具中,以及隐藏在一个元数据一尘不染但其渲染后的消息带有 payload 的 prompt 中: ``` mcp-gauntlet run "python -m mcp_gauntlet.fixtures.malicious_server" --no-agentic ``` 其中五个里有四个完全不需要 API key 就能被捕获;只有调用时的那个需要运行中的 agent。服务器本身是完全可用的——合法的 schema、可用的工具、100% 的任务成功率——这正是问题的关键:它是因为其*所说和所返回的内容*被标记,而不是因为崩溃。 由于该测试服务器内置在已安装的包中,指向你 `site-packages` 的 MCP 安全扫描器会标记 mcp-gauntlet 本身。这是预期的:这些 payload 是在不触及任何文件系统或网络的测试替身中的惰性字符串,并且它会在启动时向 stderr 打印警告横幅。 挂起的服务器无法拖延运行:每个工具调用都受到 `--tool-timeout`(默认 60 秒)的限制,并作为失败的调用记录在服务器的 Tool Reliability 中,而 `--timeout`(默认 900 秒,`0` 表示禁用)限制了整个评估过程,因此如果在连接或 `tools/list` 期间挂起的服务器仍然无法卡死 CLI。如果服务器确实运行缓慢而不是卡住,请提高 `--tool-timeout`——当由于限制而停止时,报告中会对此进行说明。 ## 为什么 已有许多工具对 MCP 服务器进行**静态**检查:注册表质量评分(Glama, Smithery)、安全扫描器(Snyk Agent Scan, Cisco 的 mcp-scanner)以及交互式测试器(官方的 MCP Inspector, MCPJam)。 学术基准测试(MCP-Universe, MCPMark, MCP-Bench)确实运行了真实的 agent——但它们是在固定的、精心挑选的服务器集合上运行的,而不是你可以指向*你的*服务器的东西。 mcp-gauntlet 是为**维护**服务器的人准备的,其独特的检查在于*动态*检查——那些你无法通过阅读文件了解的内容: - 它会**调用**你的工具并扫描返回的内容,因此一个在列出时干净但在调用时投毒的服务器会被捕获; - 它会询问 `tools/list` **两次**并重新扫描任何发生变化的内容,因此两个答案之间不同的定义会引发其专属的发现; - 它会向每个工具提供**畸形输入**并检查工具是否拒绝它; - 有了 API key,它会驱动一个**运行中的 agent** 执行生成的任务,这是查明你的描述是否足以指导执行的唯一方法。 静态扫描器阅读你的代码。这运行你的服务器。 **它不是什么:** 一个排名系统。它不会告诉你别人的服务器是否比你的好,而且它刻意不发布任何排行榜——当某个阶段被跳过或在不同版本之间时,分数会发生变化,这对于长期观察一个服务器来说没问题,但对于一个排序的表格来说就不行了。请参阅 [分数不代表什么](METHODOLOGY.md)。 ## 评分标准 每次运行都会将 `report.json`、`report.md` 和 `report.html` 写入 `--out`,并在以下几个维度进行评分: - **Schema Health**(架构健康度) —— 合法的 JSON schema,有类型且带有描述的参数。 - **Description Quality**(描述质量) —— agent 能否判断何时以及如何使用每个工具? - **Security Signals**(安全信号) —— 针对**客户端可以放在模型面前的所有由服务器编写的字符串**,在所有三种 MCP 原语中进行工具投毒 / prompt 注入标记和隐藏字符的*静态*扫描:包括服务器的名称、标题和初始化指令;每个工具的描述、显示标题、`_meta`,以及**其输入和输出 schema**(包括嵌套的标题、枚举、默认值、示例、`$defs` 条目和未知的扩展关键字);**prompt** 的元数据、参数以及 `prompts/get` 实际返回的消息;以及 **resource** 和模板的元数据。这个集合中的任何内容都可能携带 payload,因此如果只扫描顶层的 `description` 字段,只需将 payload 嵌套在 `$ref` 后面、隐藏在显示标题中,或者放在 prompt 中(其消息会原封不动地传给模型),就可以轻松绕过。在匹配之前,文本会被折叠成一个兼容性骨架,因此夹带的隐形字符、组合标记和外观相似的字母(如全角、数学粗体)不会破坏关键字匹配。一个严重发现会限制整体评分上限。 - **Agent Task Success**(Agent 任务成功率) —— 一个真实的 LLM agent 仅使用服务器的工具来尝试生成的任务;由 LLM 进行评判并重复执行以得出成功率。 - **Tool-Selection Accuracy**(工具选择准确率) —— agent 是否调用了预期的工具? - **Tool Reliability**(工具可靠性) —— 服务器的工具是否无错误地执行了? - **Response Safety**(响应安全) —— 对工具的实际**输出**进行*动态*扫描,寻找相同的注入/投毒标记,捕获在列出时看起来干净但在调用时投毒的服务器。会被报告(并降低分数)但本身不会作为上限条件,因为 fetch/filesystem 服务器可能会忠实地传递不受信任的内容。 - **Robustness**(健壮性) —— 服务器是否优雅地拒绝了畸形输入?一个完全不发布参数 schema 的工具在这里会得零分,而不是被跳过:一个不声明契约的服务器无法拒绝任何东西,跳过它会让“省略 schema”成为一种获得更高分的手段。 - **Definition drift**(定义漂移) —— 服务器是否在你批准工具后,更改了工具的声明?每次会话中会询问两次 `tools/list` 并对比答案,同时对外观提取指纹并与上一次运行进行比较。这是包签名无法解决的失败点:包没有改变且签名正确,只是运行时提供的文本不同。关键是,*发生改变*的定义随后会按其本身进行扫描,因此仅在第二次列表中出现的 payload 会引发其专属的发现,而不是仅仅抛出一个干瘪的“发生了变化”。变化本身会被报告,但本身永远不会作为上限条件——诚实的服务器会延迟注册工具、根据认证进行拦截(MCP 提供了 `tools.listChanged` 功能正是为了这个目的),并在不更新静态版本的情况下编辑描述。 请注意哪一半在哪里运行。*会话内*检查——通过一个连接调用两次 `tools/list`——总是有效的。*跨运行*检查会与存储在 `.gauntlet/baselines/` 下的指纹进行比较,而全新的 CI 运行器(`actions/checkout` + `uvx`)没有之前的运行记录可供比较,因此在那里一个被静默**删除**的工具是不可见的。如果你希望在 CI 中进行该检查,请缓存或提交 `.gauntlet/baselines/`。 ## 配置 通过 `.env` 文件(复制 [`.env.example`](.env.example) 并填充内容)或真实的环境变量进行配置: | 变量 | 用途 | |----------|---------| | `GROQ_API_KEY` / `GEMINI_API_KEY` / `OPENAI_API_KEY` / `OPENROUTER_API_KEY` | agent 应使用的提供商的 API key(只需一个)。免费的 Groq key:[console.groq.com/keys](https://console.groq.com/keys)。 | | `MCP_GAUNTLET_PROVIDER` | 选择哪个提供商:`groq`(默认)、`gemini`、`openai`、`openrouter` 或 `ollama`(本地)。 | | `MCP_GAUNTLET_MODEL` | 覆盖该提供商的默认模型(例如 `gemini-flash-latest`)。默认为每个提供商合适的模型。 | `--provider` / `--model` CLI 标志会覆盖这些设置,并且 `--base-url` 可以指向任何兼容 OpenAI 的 endpoint——本地的 Ollama / vLLM / LM Studio 或网关。 一个无需密钥的本地 endpoint 根本不需要配置任何 key;如果你的 endpoint 或网关确实需要一个,请传入 `--api-key`(或提供商对应的环境变量): ``` mcp-gauntlet run "npx -y @scope/pkg" --base-url http://localhost:11434/v1 --model llama3.1 ``` ### 需要凭据的服务器 一个与 GitHub、Slack 或数据库交互的服务器需要 token 才能执行任何操作。传入一个 token,而无需将其放入报告或你的 shell 历史记录中: ``` # stdio server:转发一个已加入 allow-list 的 env var(值从你的环境中提取) export GITHUB_TOKEN=ghp_... mcp-gauntlet run "npx -y @modelcontextprotocol/server-github" --env GITHUB_TOKEN # remote server:发送一个 auth header mcp-gauntlet run "https://mcp.example.com/mcp" --header "Authorization: Bearer $TOKEN" ``` `--env`/`--header` 可以重复使用。只有你指定的变量会被转发——子进程除此之外会获得一个最简化的安全环境,而不是你整个 shell 的环境。凭据值将从报告、控制台和任务缓存中进行脱敏处理,因此一个将自己 token 回显的服务器不会将其泄漏到提交的构建产物中。**请将带有凭据的运行指向沙盒或一次性账户:** 只读过滤器信任服务器自身的 `readOnlyHint`/名称,并且是纵深防御的一部分,而不是绝对的保证,因此标记错误的工具仍可能对真实账户进行操作。 **如果 token 错误,运行不会静默通过**——但它失败的方式取决于你是否提供了 token,这是两码事: - **你传入了 `--env`/`--header`,但服务器仍然拒绝了所有调用。** 这是一个判定:一个 HIGH 级别的发现,因此 `--fail-on high` 会使构建失败。你的 token 是错误的、过期的或缺乏相应的权限范围(scope)。 - **你什么都没传,服务器拒绝了一切。** 这不是服务器的错,也不是回归问题,因此在所有 `--fail-on` 级别下它都会**退出并返回代码 3**——*无法评估*。它曾经会返回绿色的 `A 100.0`,但这肯定是唯一一个肯定错误的答案。 一个直接拒绝连接的远程服务器会通过其状态码(而不是“传输未启动”)来表明情况。检测机制读取的是协议(HTTP 状态码、JSON-R 错误代码、机器可读的认证代码)而不是文字描述,因此它适用于用任何语言编写错误信息的服务器;剩余的极端情况见 `docs/known-gaps.md` G13。 ### 没有 API key?静态模式 除了实时 agent 之外的所有操作都可以在没有 LLM 的情况下运行。在未配置任何 key 的情况下执行 `mcp-gauntlet run `,会根据不需要 LLM 的检查报告一个**静态评分**——schema 健康度、描述质量、安全信号和健壮性探测: ``` mcp-gauntlet run "npx -y @modelcontextprotocol/server-everything" --no-agentic ``` 添加 `--no-probe` 可进行纯粹的检查,而不执行服务器的任何工具。 ### 同时扫描多个服务器 如果你维护多个服务器,`scan` 可以运行它们全部,并根据所有服务器中最差的发现来作为门控(gate)。不可达的服务器会被报告,但**不会**导致门控失败——它会退出并返回 3,而不是 1,因为“无法评估此项”与“此项存在问题”是不同的概念。 ``` mcp-gauntlet scan --servers my-servers.json --no-agentic --fail-on high ``` ``` {"servers": [ {"name": "notes", "spec": "python -m notes_server"}, {"name": "billing", "spec": "node dist/billing.js"}, {"name": "search", "spec": "https://search.internal/mcp", "headers": ["Authorization: Bearer $SEARCH_TOKEN"]}, {"name": "warehouse", "spec": "python -m warehouse_server", "env": ["WAREHOUSE_TOKEN"]} ]} ``` `env` 和 `headers` 采用与 `run --env` 和 `--header` 相同的形式:简单的 `"TOKEN"` 会从环境变量中读取值,因此**机密永远不会出现在提交的文件中**;`"NAME=value"` 则是内联一个值。凭据值在每份报告中都会被清理掉。 这个文件不会悄悄处理两件事。未知的 key 会导致一个指出该 key 名称的**错误**,而不是被忽略——一个你以为连接好了却被丢弃的 `"env"` 比加载失败更糟糕。解析为空的凭据会在开始之前导致整个扫描失败(退出码 4),而不是变成一个无法评估的服务器(退出码 3),因为退出码 3 是被告知不要用来让构建失败的代码。这包括*已设置但为空*的变量,这正是 GitHub Actions 在 fork PR 中为 `${{ secrets.TOKEN }}` 提供的内容。如果你确实想要空值,请使用 `"NAME="`。 每个服务器都有其自己的报告目录。这里不会进行任何排名,也不会将分数并排比较——请参阅 [这代表什么以及不代表什么](METHODOLOGY.md)。 **`--out` 默认指向一个固定的目录**(对于 `run` 是 `reports`,对于 `scan` 是 `gauntlet-scan`),因此针对不同服务器的连续运行会互相覆盖。如果你正在比较它们,请为每个服务器指定单独的路径——曾经有测试人员以这种方式将一个版本的测试数据错误地归因于另一个版本。 ## 在 CI 中使用 这就是该工具的价值所在:一个针对你自己的 MCP 服务器的回归测试套件,在每个 pull request 时运行。将 [`examples/gauntlet-ci.yml`](examples/gauntlet-ci.yml) 复制到你的仓库中并命名为 `.github/workflows/gauntlet.yml`,将其指向你的服务器,构建就会基于它的**发现**而失败: ``` - name: Run the gauntlet run: uvx mcp-gauntlet run "python -m your_server" --no-agentic --fail-on high ``` **根据严重性而不是分数来作为门控。** 当存在 HIGH 级别的发现(工具投毒、注入标记、隐藏字符)时,`--fail-on high` 会令构建失败。`medium` 级别还能捕获标准输出污染、薄弱的描述和定义漂移;`low` 级别则会包含未描述的参数。 **在 CI 中使用 `--fail-on high`,并谨慎考虑使用 `medium`。** `medium` 还会对定义漂移进行门控,这会在*你*编辑描述时触发——只触发一次,因为基线是由同一次运行更新的——所以它更像是一个审查信号,而不是构建门控。如果你在 CI 中缓存了 `.gauntlet/baselines/`(见下文)并设置了 `medium` 级别的门控,那么每个修改了 docstring 的 PR 都会报错。你要么保持在 `high` 级别,要么在设置为 `medium` 级别时配合传入 `--no-track-drift` 参数。 **`--fail-on low` 需要使用 `Field(description=...)`,而不是 docstring。** 官方的 Python SDK 不会将 docstring 中的 `Args:` 部分带入 JSON schema 中,因此以这种方式记录文档的服务器会针对每个参数触发一次 LOW 级别发现——内置的 `good_server` 尽管评分是 A,也会触发四次。修复方法只需为每个参数添加一行代码,并且现在就生效: ``` def search(query: Annotated[str, Field(description="The text to search for.")]) -> str: ... ``` 一位测试人员通过这种方式将一个服务器的 10 个 LOW 发现减少到了能够通过 `--fail-on low` 的程度。之前这里的文字将这个级别称为“不可用”,这误导了人们放弃这个实际上起作用的严格门控——问题出在 docstring 上,而不是门控。 `--fail-under` 依然存在,并且是较弱的选择。HIGH 级别的安全发现会将总得分上限锁定在 75 分,因此 `--fail-under 60` 的门控——这也是本 README 过去推荐的设置——**永远无法让一个被投毒的服务器失败**:因为上限充当了门控的底线。分数阈值也需要在评分逻辑发生变化时重新设定基准。严重性门控则没有这两种问题,这也是为什么示例中没有锁定版本(unpinned)的原因:导致失败的是你可以阅读的发现,而不是一个变动的数字。如果你需要冻结判定结果,请锁定它。 ### 退出代码 | 代码 | 含义 | |---|---| | `0` | 通过 | | `1` | **门控失败** —— 针对你服务器的判定。这是唯一一个应该基于质量理由让构建失败的代码。 | | `2` | 使用错误(错误的标志) | | `3` | **无法评估** —— 服务器未启动、超时、传输失败、工具列表不可读,或者要求了 `--agentic` 但 LLM 后端在每次调用中都报错。属于基础设施问题,而非质量问题。 | | `4` | 配置错误 —— 例如在未配置任何 API key 的情况下使用 `--agentic`,或者 `--out` 路径不可写 | | `130` | **在 POSIX 系统上**被中断 (Ctrl-C)。子服务器进程会被清理回收;不会写入任何报告。 | 特意将 `3` 分离出来。如果一个门控将不稳定的 CI 运行器报告为质量回归问题,它在一周内就会被停用,而它本该捕获的所有问题也会随之消失。 `130` 是 POSIX 约定(`128 + SIGINT`),而 Windows 并不遵循此约定:在 Windows 上,Ctrl-C 会以 `0xC000013A` / `-1073741510` 结束进程,这是操作系统的代码,而不是本工具可以选择的。在两种系统上*确实*能保证的是实质性的承诺——子服务器进程会被清理回收,不会留下任何孤儿进程,也不会从半成品的运行中写出报告。如果你的运行器是混合环境的,请不要基于 `130` 进行 CI 分支判断。 静态和健壮性检查不需要 API key。要包含实时 agent 评估,请将 LLM key(例如 `GROQ_API_KEY`)添加为仓库 secret,并**显式**传入 `--agentic` 参数——如果不加该参数,缺失或过期的 secret 会静默降级为静态运行,并依然返回退出码 0,从而在你不知情的情况下丢掉权重最大的评估维度。报告将作为构建产物上传。 ## 开发说明 上述命令用于*使用*该工具。如果要在其上进行开发,请克隆仓库并使用项目自身环境: ``` git clone https://github.com/GhalebDweikat/mcp-gauntlet && cd mcp-gauntlet uv sync --extra dev uv run mcp-gauntlet run "python -m mcp_gauntlet.fixtures.good_server" --no-agentic ./scripts/gates.sh # ruff, format, mypy, the fixture-score snapshot, and pytest ``` `scripts/era_fixture_probe.py` 会在 `mcp` 1.x 和 2.x 的独立环境中构建相同的测试服务器,如果两个版本环境对该服务器的判定不一致,则运行失败。 ## 许可证 MIT © Ghaleb Dweikat
标签:AI智能体, AI风险缓解, C2, LNA, MCP, SOC Prime, 回归测试, 开发工具, 逆向工具