Appearance
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/null5. 可能输出
以下是 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/-32601JSON-RPC 错误。 - 场景 ④:服务端从未执行过
sudo rm -rf /data——它在白名单闸门处就被拒了,这正是「最小权限」的第一道防线。 - 所有
[mcp-server]日志都来自 stderr;node server.js 2>/dev/null后终端只剩纯 JSON 帧。
7. 观察重点
- 白名单先于一切:拒绝发生在参数解析、文件 IO 之前。对比「先执行再过滤」的实现,思考哪一层才拦得住提示词注入驱动的 tool call。
- 两种失败的语义差:
error.code=-32602表示请求本身非法;isError=true表示调用合法但执行失败。LLM 宿主对二者的重试策略应当不同。 - 路径校验的完备性:仅做字符串
startsWith(args.path)会被../与符号链接绕过;resolve + 前缀 + realpath三步才是可上线的最小实现。 - 本 demo 未覆盖:人机确认钩子(阻塞等待 Allow/Reject)、Resources/Prompts、真实 LLM 接入——它们是理论文档建议清单中的后续实验。
8. 常见误区
| 误区 | 实际情况 |
|---|---|
tools/list 列出所有实现的工具就是「诚实」 | 恰恰相反:白名单外工具不应暴露能力描述,避免诱导模型尝试调用 |
| 参数校验失败时丢弃响应即可 | 必须回带同 id 的错误帧,否则宿主会挂起等待或把静默当成功 |
startsWith(args.path) 就能防逃逸 | 未规范化的前缀检查挡不住 ../ 与符号链接;要 resolve/realpath 后再比 |
| 调试日志打 stdout 方便排查 | stdio 模式下 stdout 是唯一 JSON-RPC 通道,日志必须走 stderr |