Skip to content

mcp-tool-allowlist-server ​

1. 实验目标 ​

用纯 Node(零三方依赖)实现一个最小 MCP 风格 JSON-RPC 2.0 stdio Server,验证理论文档里「MCP 安全边界」的最小可运行骨架:

  • 协议帧:tools/list / tools/call 两个方法,stdio 传输下每行一个 JSON-RPC 帧,响应保留请求 id。
  • 工具白名单:只有登记进白名单的工具才会出现在 tools/list;tools/call 在进入任何执行逻辑前先过白名单闸门,未登记工具直接拒绝。
  • 参数校验:按每个工具声明的 inputSchema 校验必填字段与类型,失败返回 JSON-RPC -32602。
  • 路径边界:read_file 做 path.resolve 规范化 + 工作区根前缀检查 + realpathSync 防符号链接绕过,拦截 ../ 逃逸。
  • 协议纪律:stdout 只允许出现 JSON-RPC 帧,所有日志一律走 stderr。

它刻意不实现 Resources / Prompts、不接真实 LLM、不做人工确认弹窗——只回答一个问题:一个 MCP Server 的安全闸门最少要有哪些层,各层拒绝时分别长什么样。

2. 工程要点 ​

  • 白名单与注册表分离:TOOL_REGISTRY 承载工具实现 + schema,TOOL_ALLOWLIST 决定对外可见性;即使注册表里有更多函数,未登记的名字也调不到。
  • 拒绝分两层,语义不同:
    • JSON-RPC error(负数 code):请求根本不该发生——白名单外工具、参数校验失败、未知方法。
    • result.isError=true:调用合法但执行失败——路径越界、文件不存在。模型据此区分「我不会发这种请求」和「这个工具这次没办成」。
  • 路径校验三步走:resolve 规范化 → 前缀必须落在工作区内 → realpathSync 后再查一次前缀(防符号链接指到工作区外)。
  • 日志全部 process.stderr.write,stdout 只写帧——对应理论文档「调试日志打进 stdout 破坏 JSON-RPC 帧」事故的修法。

3. 关键实现速览 ​

3.1 白名单闸门 ​

为什么看这段:安全控制的关键是「先过闸门再执行」,未登记工具在任何业务逻辑之前就被拒绝。

js
if (method === 'tools/call') {
  const name = params ? params.name : undefined;

  // ① 白名单闸门:未登记的工具在任何执行发生前就被拒绝
  if (!TOOL_ALLOWLIST.includes(name)) {
    return errorReply(
      id,
      -32602,
      `工具「${String(name)}」不在白名单 [${TOOL_ALLOWLIST.join(', ')}] 内,已拒绝执行`
    );
  }
  // ② 参数校验 … ③ 执行 …
}

完整源码:server.js

3.2 参数校验与 isError 分层 ​

为什么看这段:schema 校验失败返回 JSON-RPC 错误;工具执行期失败用 isError=true 结果表达,而不是丢弃响应。

js
const problems = validateArgs(TOOL_REGISTRY[name].inputSchema, params.arguments);
if (problems.length > 0) {
  return errorReply(id, -32602, `参数校验失败:${problems.join(';')}`);
}
const outcome = TOOL_REGISTRY[name].run(params.arguments || {});
return reply(id, {
  content: [{ type: 'text', text: outcome.text }],
  isError: outcome.isError,
});

完整源码:server.js

3.3 路径边界:规范化 + 前缀 + realpath ​

为什么看这段:../ 逃逸是最经典的 MCP 文件类工具漏洞,规范化后做前缀校验是最小修法。

js
const target = path.resolve(WORKSPACE_ROOT, args.path);
if (target !== WORKSPACE_ROOT && !target.startsWith(WORKSPACE_ROOT + path.sep)) {
  return { isError: true, text: `路径越界被拒绝:「${args.path}」解析为 ${target},超出工作区根 ${WORKSPACE_ROOT}` };
}
const real = fs.realpathSync(target);   // 再防一次符号链接绕过

完整源码:server.js

4. 运行方式 ​

一键复演全部场景(spawn server 并依次发送 6 个剧本帧):

bash
cd labs/node/ai-engineering/mcp-tool-allowlist-server
node client.js

或手动与 server 对话(每行一个 JSON-RPC 帧):

bash
cd labs/node/ai-engineering/mcp-tool-allowlist-server
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | node server.js

想验证「stdout 只有 JSON 帧」,把 stderr 丢掉再看输出:

bash
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | node server.js 2>/dev/null

5. 可能输出 ​

以下是 node client.js 2>&1 的真实运行摘录(省略部分重复的 [mcp-server] 回显行;workspace 绝对路径因机器而异):

text
=== ① tools/list:客户端询问服务端有哪些工具(白名单内才会出现)
[mcp-server] 工具白名单=[read_file, echo_tool],stdout 只输出 JSON-RPC 帧
[mcp-server] <- {"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}
白名单工具 = [read_file, echo_tool](run_command 等未登记工具不会出现)

=== ② tools/call read_file:合法路径,正常返回
[mcp-server] <- {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"read_file","arguments":{"path":"README.md"}}}
isError=false,内容首行:「# mcp-tool-allowlist-server」…(全文 5153 字符,此处截断展示)

=== ③ tools/call read_file:../ 路径逃逸,被工作区边界校验拦截
[mcp-server] <- {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"read_file","arguments":{"path":"../../../../etc/passwd"}…
isError=true,text:路径越界被拒绝:「../../../../etc/passwd」解析为 /Users/jixiaoyong/dev/workspace/30-39-Labs/31-Codelabs/31.02-engineering-review-lab/etc/passwd,超出工作区根 /Users/jixiaoyong/dev/workspace/30-39-Labs/31-Codelabs/31.02-engineering-review-lab/labs/node/ai-engineering/mcp-tool-allowlist-server

=== ④ tools/call run_command:不在白名单,进入执行前直接拒绝
[mcp-server] <- {"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"run_command","arguments":{"cmd":"sudo rm -rf /data"}}}
JSON-RPC error -32602:工具「run_command」不在白名单 [read_file, echo_tool] 内,已拒绝执行

=== ⑤ tools/call read_file:缺必填参数 path,参数校验失败返回 -32602
[mcp-server] <- {"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"read_file","arguments":{}}}
JSON-RPC error -32602:参数校验失败:缺少必填参数「path」

=== ⑥ 未知方法 shutdown_now:-32601 Method not found
[mcp-server] <- {"jsonrpc":"2.0","id":6,"method":"shutdown_now","params":{}}
JSON-RPC error -32601:未知方法:shutdown_now
[mcp-server] stdin 关闭,server 退出

echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | node server.js 2>/dev/null 的 stdout 实测只有一行纯 JSON 帧:

text
{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"read_file","description":"读取工作区内文本文件(只读)。路径必须落在工作区根目录之内。","inputSchema":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}},{"name":"echo_tool","description":"把传入的 message 转成大写返回(无副作用的示例工具)。","inputSchema":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"]}}]}}

6. 预期现象 ​

  • 场景 ①:tools/list 只返回 read_file 与 echo_tool——run_command 这类高危名字因为不在白名单,连「被发现」的机会都没有。
  • 场景 ②③⑤⑥ 全部拿到响应且 id 与请求一一对应:合法调用返回 isError=false;路径逃逸返回 isError=true 加原因文本;缺参与未知方法返回 -32602 / -32601 JSON-RPC 错误。
  • 场景 ④:服务端从未执行过 sudo rm -rf /data——它在白名单闸门处就被拒了,这正是「最小权限」的第一道防线。
  • 所有 [mcp-server] 日志都来自 stderr;node server.js 2>/dev/null 后终端只剩纯 JSON 帧。

7. 观察重点 ​

  1. 白名单先于一切:拒绝发生在参数解析、文件 IO 之前。对比「先执行再过滤」的实现,思考哪一层才拦得住提示词注入驱动的 tool call。
  2. 两种失败的语义差:error.code=-32602 表示请求本身非法;isError=true 表示调用合法但执行失败。LLM 宿主对二者的重试策略应当不同。
  3. 路径校验的完备性:仅做字符串 startsWith(args.path) 会被 ../ 与符号链接绕过;resolve + 前缀 + realpath 三步才是可上线的最小实现。
  4. 本 demo 未覆盖:人机确认钩子(阻塞等待 Allow/Reject)、Resources/Prompts、真实 LLM 接入——它们是理论文档建议清单中的后续实验。

8. 常见误区 ​

误区实际情况
tools/list 列出所有实现的工具就是「诚实」恰恰相反:白名单外工具不应暴露能力描述,避免诱导模型尝试调用
参数校验失败时丢弃响应即可必须回带同 id 的错误帧,否则宿主会挂起等待或把静默当成功
startsWith(args.path) 就能防逃逸未规范化的前缀检查挡不住 ../ 与符号链接;要 resolve/realpath 后再比
调试日志打 stdout 方便排查stdio 模式下 stdout 是唯一 JSON-RPC 通道,日志必须走 stderr

9. 对应知识库文档 ​

10. 完整源码 ​

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