Skip to content

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:前者永远不放行,后者换一个有批准的环境即可执行。
  • 人工批准的可测试建模: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.mjs

5. 可能输出 ​

以下是 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-demo

6. 预期现象 ​

  • 场景①:握手响应带请求同款 id,protocolVersion / serverInfo / capabilities.tools 齐全;随后的 notifications/initialized 不产生任何回帧。
  • 场景③:rm -rf 从未被执行——它在黑名单处就被拒了,甚至没进人工确认环节。
  • 场景④:同一命令在无批准时返回 isError=true 拒绝文本;MCP_APPROVE=1 后真正执行并返回 stdout。差异只在环境变量,证明「批准与否」是钩子层的独立输入。
  • 场景⑤:../ 逃逸被边界校验拦成 isError=true;场景⑥合法读取返回 isError=false。

7. 观察重点 ​

  1. 拦截发生的层级即安全语义:黑名单 deny(永不放行)、未批准 reject(换个环境可放行)、路径越界(只读也守边界)、-32602(请求本身非法)。排查 MCP 安全事件时,第一件事就是确认拒绝发生在哪一层。
  2. fail-closed 默认:自动化环境没有「人」可确认,钩子默认 Reject 而不是放行——这正是文档强调高危操作必须 Human-in-the-loop 的原因:缺省状态必须是安全的。
  3. 通知与请求的区别:notifications/initialized 没有 id,服务端绝不回帧;如果实现里对通知回了帧,宿主会把它当成某请求的响应而错乱。
  4. 本 demo 未覆盖:真正的阻塞式弹窗交互(需宿主 UI 配合)、Resources/Prompts、Prompt 注入攻防对照——仍是理论文档建议清单中的后续实验。

8. 常见误区 ​

误区实际情况
实现了 tools/list 就算实现了 MCP缺 initialize 握手的 server 无法接入真实宿主;握手是协议第一帧
安全钩子在工具函数内部做检查就够了钩子必须在 handler 分发给任何工具逻辑之前统一裁决,否则新增工具容易漏掉检查
无人批准时干脆挂起等待最安全自动化环境会永久卡死;正确做法是 fail-closed:按 Reject 快速返回明确原因
isError=true 和 JSON-RPC error 可以混用前者表达「调用合法但被拒/失败」,后者表达「请求本身非法」;宿主的重试策略依赖这个区分

9. 对应知识库文档 ​

10. 完整源码 ​

站点构建时间:2026/8/24 23:43:17