Appearance
mcp-protocol-security-demo
1. 实验目标
用纯 Node(零三方依赖)实现一个带完整握手与动态安全钩子的最小 MCP Server,补齐同目录 mcp-tool-allowlist-server 未覆盖的两块:
- 协议握手:实现
initialize/notifications/initialized。客户端必须先握手拿到protocolVersion与serverInfo才能进入能力发现;通知帧(无id)按协议不回帧。 - Security Hook(人机确认钩子) :所有
tools/call在任何执行逻辑之前先过策略引擎裁决:- 高危命令黑名单(
rm/sudo/curl等)→ 直接拒绝,不给批准通道; - 副作用类受控命令(
echo/ls/pwd)→ 必须持有人工批准(环境变量MCP_APPROVE=1模拟 Human-in-the-loop 弹窗点了 Allow),否则按 Reject 处理; - 只读工具
read_file→ 路径规范化 + 工作区根前缀校验,拦截../逃逸。
- 高危命令黑名单(
它刻意不接真实 LLM、不做真正的 GUI 弹窗——只回答一个问题:「破坏性 tool call 被钩子拦截」的完整链路,每一环分别长什么样、拒绝发生在哪一层。
2. 工程要点
- 握手先于一切:真实 MCP 宿主(Claude Desktop、IDE)启动时先发
initialize再发notifications/initialized,之后才tools/list。跳过握手直接调工具的客户端应被视为不合规。 - 三层拦截,语义不同:
- JSON-RPC
-32602:请求本身非法(未注册工具、参数校验失败)——宿主不应重试同样请求; isError=true+ 拒绝文本:调用合法但被安全策略/执行失败拦截——模型可据此改换方案;- 黑名单 deny vs 未批准 reject:前者永远不放行,后者换一个有批准的环境即可执行。
- JSON-RPC
- 人工批准的可测试建模:GUI 弹窗无法进自动化测试,本 demo 用
MCP_APPROVE=1环境变量把「用户点了 Allow」变成可控输入;无人值守(CI、测试进程)下默认 Reject,符合 fail-closed 原则。 - 协议纪律:stdout 只输出 JSON-RPC 帧,日志一律走 stderr——与 allowlist server 相同的纪律。
3. 关键实现速览
3.1 Security Hook:执行前的策略裁决
为什么看这段:安全控制的关键是「先过闸门再执行」,且不同风险等级走不同的拒绝通道。
js
function securityCheck(toolName, args) {
if (toolName === 'run_command') {
const binary = String(args.cmd).trim().split(/\s+/)[0];
if (DANGEROUS_BINARIES.has(binary)) {
return { action: 'deny', reason: `命中高危命令黑名单:「${binary}」被安全策略直接拒绝…` };
}
if (process.env.MCP_APPROVE !== '1') {
return { action: 'deny', reason: `等待人工确认被拒:…未获得 Allow 批准(按 Reject 处理),未执行` };
}
return { action: 'execute', reason: '已获人工批准(Allow)' };
}
// read_file:路径规范化 + 工作区根前缀校验
}完整源码:server.js
3.2 tools/call 主链路:校验 → 钩子 → 执行
为什么看这段:「参数校验失败回 JSON-RPC 错误」与「策略拦截回 isError 结果」两条通道在同一个 handler 里分层。
js
const problems = validateArgs(tool.inputSchema, params.arguments);
if (problems.length > 0) {
return errorReply(id, -32602, `参数校验失败:${problems.join(';')}`);
}
const verdict = securityCheck(name, params.arguments || {});
if (verdict.action === 'deny') {
return reply(id, { content: [{ type: 'text', text: verdict.reason }], isError: true });
}
const outcome = tool.run(params.arguments || {});
return reply(id, { content: [{ type: 'text', text: outcome.text }], isError: outcome.isError });完整源码:server.js
4. 运行方式
一键复演全部场景(默认无批准,场景④会被拒):
bash
cd labs/node/ai-engineering/mcp-protocol-security-demo
node client.js对照「用户点了 Allow」的放行分支:
bash
MCP_APPROVE=1 node client.js运行自动化验证(Node 内置 test runner,node >= 18,零依赖;会先后 spawn 6 个 server 子进程,断言握手、清单与各层拦截):
bash
cd labs/node/ai-engineering/mcp-protocol-security-demo
node --test server.test.mjs5. 可能输出
以下是 node client.js 2>/dev/null 的真实运行摘录(stderr 日志已丢弃,只剩协议帧的解读;workspace 绝对路径因机器而异):
text
=== ① initialize 握手 ===
<- [id=1] 握手成功:protocolVersion=2024-11-05,server=mcp-protocol-security-demo@0.1.0
=== ② tools/list 能力发现 ===
<- [id=2] 工具清单:[read_file, run_command]
=== ③ tools/call run_command「rm -rf /data」:命中高危黑名单,直接拒绝 ===
<- [id=3] isError=true,text:命中高危命令黑名单:「rm」被安全策略直接拒绝,未进入人工确认环节
=== ④ tools/call run_command「echo hello」:副作用类,无人工批准 → 按 Reject 处理 ===
<- [id=4] isError=true,text:等待人工确认被拒:命令「echo hello-from-mcp-demo」属于副作用类操作,未获得 Allow 批准(按 Reject 处理),未执行
=== ⑤ tools/call read_file「../../package.json」:路径逃逸被边界校验拦截 ===
<- [id=5] isError=true,text:路径越界被拒绝:「../../../../package.json」解析为 …/31.02-engineering-review-lab/package.json,超出工作区根 …
=== ⑥ tools/call read_file「README.md」:只读且在工作区内,正常返回 ===
<- [id=6] isError=false,text:# mcp-protocol-security-demo…加 MCP_APPROVE=1 后,场景④变为:
text
<- [id=4] isError=false,text:hello-from-mcp-demo6. 预期现象
- 场景①:握手响应带请求同款
id,protocolVersion/serverInfo/capabilities.tools齐全;随后的notifications/initialized不产生任何回帧。 - 场景③:
rm -rf从未被执行——它在黑名单处就被拒了,甚至没进人工确认环节。 - 场景④:同一命令在无批准时返回
isError=true拒绝文本;MCP_APPROVE=1后真正执行并返回 stdout。差异只在环境变量,证明「批准与否」是钩子层的独立输入。 - 场景⑤:
../逃逸被边界校验拦成isError=true;场景⑥合法读取返回isError=false。
7. 观察重点
- 拦截发生的层级即安全语义:黑名单 deny(永不放行)、未批准 reject(换个环境可放行)、路径越界(只读也守边界)、-32602(请求本身非法)。排查 MCP 安全事件时,第一件事就是确认拒绝发生在哪一层。
- fail-closed 默认:自动化环境没有「人」可确认,钩子默认 Reject 而不是放行——这正是文档强调高危操作必须 Human-in-the-loop 的原因:缺省状态必须是安全的。
- 通知与请求的区别:
notifications/initialized没有id,服务端绝不回帧;如果实现里对通知回了帧,宿主会把它当成某请求的响应而错乱。 - 本 demo 未覆盖:真正的阻塞式弹窗交互(需宿主 UI 配合)、Resources/Prompts、Prompt 注入攻防对照——仍是理论文档建议清单中的后续实验。
8. 常见误区
| 误区 | 实际情况 |
|---|---|
实现了 tools/list 就算实现了 MCP | 缺 initialize 握手的 server 无法接入真实宿主;握手是协议第一帧 |
| 安全钩子在工具函数内部做检查就够了 | 钩子必须在 handler 分发给任何工具逻辑之前统一裁决,否则新增工具容易漏掉检查 |
| 无人批准时干脆挂起等待最安全 | 自动化环境会永久卡死;正确做法是 fail-closed:按 Reject 快速返回明确原因 |
isError=true 和 JSON-RPC error 可以混用 | 前者表达「调用合法但被拒/失败」,后者表达「请求本身非法」;宿主的重试策略依赖这个区分 |
9. 对应知识库文档
- 理论主文档:07. MCP (Model Context Protocol) 开放标准、资源互通与 Agent 安全控制
- 同族实验(静态三闸门:白名单 / 参数校验 / 路径边界):mcp-tool-allowlist-server