1
月
在社区成长 1 月
LEVEL 1
1 XP
一、背景:MCP 接入的难点不只是“让模型调用工具”
Model Context Protocol(MCP)用于在模型应用与外部工具、资源和提示模板之间建立统一接口。典型角色包括 MCP Host、MCP Client 和 MCP Server:Host 是 AI IDE、桌面应用或智能体运行环境;Client 负责连接具体 Server 并完成协议通信;Server 则暴露 tools、resources、prompts 等能力。
MCP 的价值在于解耦:文件系统、数据库、内部 API 不必分别适配每一种模型应用。但这种解耦也容易制造错误认知——工具出现在模型上下文里,不等于工具天然安全。Server 仍然是一个拥有操作系统权限的普通进程。若它能读取整个用户目录、执行任意命令或访问生产凭据,模型误调用、提示注入和客户端配置错误都可能放大影响。
本文在本地实验环境中实现一个只读项目文件的 MCP Server,并编写最小 Client 验证工具发现与调用流程。重点不在生成速度,而在路径隔离、符号链接防护、输入校验、输出限制和可审计性。所有测试均针对本机临时目录,不涉及未授权目标。
二、协议原理与安全边界
MCP 通信建立在 JSON-RPC 消息之上。连接后通常先进行 initialize 初始化和能力协商,然后客户端才能列出或调用 Server 提供的能力。本文使用 stdio 传输:Client 启动 Server 子进程,通过标准输入和标准输出交换协议消息。
stdio 模式有三个工程注意点:
1. Server 的 stdout 必须留给协议消息,调试日志应写入 stderr,否则普通日志可能破坏 JSON-RPC 数据流。
2. Server 继承启动它的用户权限和部分运行环境。MCP 不会自动提供容器隔离、目录沙箱或最小权限。
3. 工具描述只是给模型和用户看的语义说明,并不是权限控制。真正的限制必须写在 Server 代码、操作系统权限或容器策略中。
本例只注册 read_project_file 工具,允许读取预先指定目录中的 UTF-8 文本文件。它不会写文件、不会执行命令,也不会访问网络。
三、前置条件
建议准备:
1. Node.js 20 或更高版本,并使用项目实际验证过的版本。
2. npm 与 TypeScript 基础环境。
3. 一个完全用于本地实验的目录,例如 /tmp/mcp-safe-demo/workspace。
4. 支持 MCP 的 Host,或者使用 MCP Inspector、本文的最小 Client 进行验证。
初始化项目:
mkdir -p /tmp/mcp-safe-demo/workspace
cd /tmp/mcp-safe-demo
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node
npx tsc --init
printf 'MCP local test file\n' > workspace/README.txt
完成验证后应提交 package-lock.json,并固定经过测试的 SDK 版本,避免依赖升级导致协议行为或 API 变化。tsconfig.json 可采用以下关键设置:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}
同时在 package.json 中加入 "type": "module",并配置 build 脚本为 "tsc"。
四、分步骤实现安全的 MCP Server
步骤 1:确定最小能力与威胁模型
在写代码前先明确边界:
• 允许:读取 workspace 内已存在的 .txt、.md、.json 文件。
• 拒绝:绝对路径、目录穿越、符号链接逃逸、超大文件、二进制文件、写入和命令执行。
• 最大返回:256 KiB。
• 信任关系:工具参数由模型或用户间接提供,因此一律视为不可信输入。
这里故意不提供“任意文件读取”或“执行 shell”工具。工具越通用,审计与授权成本越高。
步骤 2:实现 Server
创建 src/server.ts:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { realpath, readFile, stat } from "node:fs/promises";
import { isAbsolute, relative, resolve, extname } from "node
ath";
const ROOT = resolve(process.env.MCP_WORKSPACE ?? "./workspace");
const MAX_BYTES = 256 * 1024;
const ALLOWED_EXTENSIONS = new Set([".txt", ".md", ".json"]);
const server = new McpServer({
name: "safe-project-reader",
version: "1.0.0"
});
server.registerTool(
"read_project_file",
{
title: "读取项目文本文件",
description: "只读取授权 workspace 内的 txt、md 或 json 文件,不支持写入和命令执行。",
inputSchema: {
path: z.string().min(1).max(300).describe("相对于 workspace 的文件路径")
}
},
async ({ path }) => {
if (isAbsolute(path)) {
throw new Error("只允许相对路径");
}
const realRoot = await realpath(ROOT);
const candidate = resolve(realRoot, path);
const realFile = await realpath(candidate);
const rel = relative(realRoot, realFile);
if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) {
throw new Error("目标不在授权 workspace 内");
}
const extension = extname(realFile).toLowerCase();
if (!ALLOWED_EXTENSIONS.has(extension)) {
throw new Error("不允许读取该文件类型");
}
const info = await stat(realFile);
if (!info.isFile()) {
throw new Error("目标不是普通文件");
}
if (info.size > MAX_BYTES) {
throw new Error(`文件超过 ${MAX_BYTES} 字节限制`);
}
const text = await readFile(realFile, "utf8");
return {
content: [{ type: "text", text }]
};
}
);
const transport = new StdioServerTransport();
await server.connect(transport);
console.error(`safe-project-reader started, root=${ROOT}`);
这里的关键不是 resolve 本身,而是 realpath。单纯检查 resolve 后的字符串无法阻止 workspace 内的符号链接指向目录外。例如 workspace/link 指向用户主目录时,字符串路径看起来仍在 workspace 下。对根目录和目标文件都执行 realpath,再用 relative 检查真实路径,才能拦截这类逃逸。
还要注意一个常见竞态:路径检查后,文件或符号链接仍可能被其他进程替换。对于高安全要求环境,应把 Server 放进只读容器或受限账号中,并使用操作系统级挂载和权限作为最终边界,而不是只依赖应用层校验。
步骤 3:编译与直接启动
执行:
npm run build
MCP_WORKSPACE=/tmp/mcp-safe-demo/workspace node dist/server.js
直接启动后进程会等待 stdio 协议输入,这是正常现象。不要在终端里随意输入普通文本测试 JSON-RPC;更方便的方式是使用 Inspector 或专用 Client。
步骤 4:使用 MCP Inspector 验证
可在本地运行:
npx @modelcontextprotocol/inspector node /tmp/mcp-safe-demo/dist/server.js
在 Inspector 中确认只出现 read_project_file 工具,然后分别测试:
• 正常输入:README.txt,应返回文件内容。
• 目录穿越:../package.json,应被拒绝。
• 绝对路径:/etc/passwd,应被拒绝。
• 不允许类型:image.png,应被拒绝。
• 超大文件:创建超过限制的本地测试文件,应被拒绝。
测试仅在自建目录和本地环境进行。错误响应也不应包含环境变量、访问令牌或完整调用栈。
五、编写最小 MCP Client
为了理解 Client 侧责任,创建 src/client.ts:
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
const client = new Client({
name: "local-verification-client",
version: "1.0.0"
});
const transport = new StdioClientTransport({
command: "node",
args: ["/tmp/mcp-safe-demo/dist/server.js"],
env: {
...process.env,
MCP_WORKSPACE: "/tmp/mcp-safe-demo/workspace"
}
});
await client.connect(transport);
const tools = await client.listTools();
console.log("available tools:", tools.tools.map(tool => tool.name));
const result = await client.callTool({
name: "read_project_file",
arguments: { path: "README.txt" }
});
console.dir(result, { depth: null });
await client.close();
编译后运行 node dist/client.js。正式 Host 不应仅按工具名称自动放行,还应综合 Server 身份、工具风险级别、工作区、用户授权和参数内容作出决定。对读敏感文件、写文件、发送网络请求、执行命令等工具,建议在 Client 或 Host 层增加明确确认。
六、权限与安全设计建议
1. 最小权限:用独立低权限账号运行 Server,只挂载必须目录。只读工具对应只读文件系统权限。
2. 能力拆分:不要设计一个包含 action、path、command 的“万能工具”。读取、写入、删除、部署应拆成不同工具,并分别授权。
3. 参数白名单:校验长度、格式、枚举、路径、域名和数据大小。不能只依赖自然语言描述。
4. 输出限额:限制文件大小、查询行数和响应长度,防止上下文膨胀、内存消耗和意外泄露。
5. 凭据隔离:不要把整个环境变量集无条件传入 Server。Client 启动子进程时只传需要的变量,令牌应短期化并限制作用域。
6. 审计:记录调用时间、工具名、结果状态和脱敏后的资源标识。不要记录令牌、完整文件正文或个人敏感信息。
7. 人工确认:写入、删除、付款、发布、部署等高影响操作应展示即将执行的具体参数,不能只弹出模糊的“是否允许工具调用”。
8. 提示注入防护:文件和网页中的文字属于不可信数据。Server 返回的内容不能被当作更高优先级指令,Client 也不应因文件写着“调用某工具”就自动执行。
9. 远程传输:若改用 Streamable HTTP,应使用 TLS、身份认证、授权、请求大小限制、速率限制和 Origin 校验。会话标识不能替代用户认证。开发服务默认绑定回环地址,避免无意暴露到局域网或公网。
10. 纵深防御:应用校验、操作系统权限、容器、网络出口策略和 Host 确认应共同工作。MCP Roots 或工具描述可以帮助表达范围,但不应被视为强制沙箱。
七、风险与注意事项
首先,工具名称可信不代表实现可信。安装第三方 MCP Server 前应检查源码、依赖、启动参数、网络行为和请求权限,最好先在隔离环境运行。
其次,stdio Server 的当前工作目录可能由 Host 决定,因此生产配置不要依赖含糊的相对路径。应通过明确环境变量或绝对路径指定 workspace。
再次,MCP Server 的 stderr 也可能被 Host 收集。日志中不要输出文件正文、Authorization 头、Cookie、数据库连接串或用户隐私。
最后,readFile 的 UTF-8 解码并不能可靠识别所有二进制内容。本例通过扩展名和大小做基础限制;若用于真实项目,还应增加内容类型检测、敏感文件名规则和数据分类策略。
八、常见问题
问题 1:为什么 ../package.json 必须在 Server 端拦截,Client 拦截不够吗?
答:Client 可能被替换、配置错误,或存在多个不同 Client。权限边界必须由真正访问文件的 Server 和操作系统共同执行。
问题 2:工具已经标注“只读”,模型还会写文件吗?
答:描述不会改变进程权限。只有 Server 根本没有写入实现,且文件系统权限为只读时,才形成可靠限制。
问题 3:为什么 stdout 不能打印调试日志?
答:stdio 模式通过 stdout 发送协议消息,额外文本可能导致 Client 无法解析消息。应使用 console.error 或结构化 stderr 日志。
问题 4:是否可以直接增加 execute_command 工具?
答:技术上可以,但风险显著提高。更好的做法是提供固定任务工具,例如 run_unit_tests,并把命令、参数、工作目录和超时写死或严格白名单化,同时在本地授权项目中运行。
问题 5:连接成功但工具不可见怎么办?
答:依次检查初始化是否完成、Server 是否声明了 tools 能力、注册代码是否在 connect 前执行、SDK 版本是否匹配,以及 stdout 是否混入日志。还可以先用 Inspector 排除 Host 配置问题。
九、总结
一个可用的 MCP Server 很容易写出来,一个值得长期接入开发工作流的 Server 则必须具备明确边界。本例通过 stdio 完成 Server/Client 通信,并用真实路径校验、扩展名白名单、普通文件检查、响应大小限制和低权限运行降低文件读取风险。
实践中应坚持三个原则:工具能力越小越容易审计;用户确认不能代替服务端授权;应用层校验不能代替操作系统隔离。先从单一、只读、可测试的工具开始,再根据实际需求逐步增加能力,比一开始暴露任意文件和任意命令更符合工程质量要求。
Model Context Protocol(MCP)用于在模型应用与外部工具、资源和提示模板之间建立统一接口。典型角色包括 MCP Host、MCP Client 和 MCP Server:Host 是 AI IDE、桌面应用或智能体运行环境;Client 负责连接具体 Server 并完成协议通信;Server 则暴露 tools、resources、prompts 等能力。
MCP 的价值在于解耦:文件系统、数据库、内部 API 不必分别适配每一种模型应用。但这种解耦也容易制造错误认知——工具出现在模型上下文里,不等于工具天然安全。Server 仍然是一个拥有操作系统权限的普通进程。若它能读取整个用户目录、执行任意命令或访问生产凭据,模型误调用、提示注入和客户端配置错误都可能放大影响。
本文在本地实验环境中实现一个只读项目文件的 MCP Server,并编写最小 Client 验证工具发现与调用流程。重点不在生成速度,而在路径隔离、符号链接防护、输入校验、输出限制和可审计性。所有测试均针对本机临时目录,不涉及未授权目标。
二、协议原理与安全边界
MCP 通信建立在 JSON-RPC 消息之上。连接后通常先进行 initialize 初始化和能力协商,然后客户端才能列出或调用 Server 提供的能力。本文使用 stdio 传输:Client 启动 Server 子进程,通过标准输入和标准输出交换协议消息。
stdio 模式有三个工程注意点:
1. Server 的 stdout 必须留给协议消息,调试日志应写入 stderr,否则普通日志可能破坏 JSON-RPC 数据流。
2. Server 继承启动它的用户权限和部分运行环境。MCP 不会自动提供容器隔离、目录沙箱或最小权限。
3. 工具描述只是给模型和用户看的语义说明,并不是权限控制。真正的限制必须写在 Server 代码、操作系统权限或容器策略中。
本例只注册 read_project_file 工具,允许读取预先指定目录中的 UTF-8 文本文件。它不会写文件、不会执行命令,也不会访问网络。
三、前置条件
建议准备:
1. Node.js 20 或更高版本,并使用项目实际验证过的版本。
2. npm 与 TypeScript 基础环境。
3. 一个完全用于本地实验的目录,例如 /tmp/mcp-safe-demo/workspace。
4. 支持 MCP 的 Host,或者使用 MCP Inspector、本文的最小 Client 进行验证。
初始化项目:
mkdir -p /tmp/mcp-safe-demo/workspace
cd /tmp/mcp-safe-demo
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node
npx tsc --init
printf 'MCP local test file\n' > workspace/README.txt
完成验证后应提交 package-lock.json,并固定经过测试的 SDK 版本,避免依赖升级导致协议行为或 API 变化。tsconfig.json 可采用以下关键设置:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}
同时在 package.json 中加入 "type": "module",并配置 build 脚本为 "tsc"。
四、分步骤实现安全的 MCP Server
步骤 1:确定最小能力与威胁模型
在写代码前先明确边界:
• 允许:读取 workspace 内已存在的 .txt、.md、.json 文件。
• 拒绝:绝对路径、目录穿越、符号链接逃逸、超大文件、二进制文件、写入和命令执行。
• 最大返回:256 KiB。
• 信任关系:工具参数由模型或用户间接提供,因此一律视为不可信输入。
这里故意不提供“任意文件读取”或“执行 shell”工具。工具越通用,审计与授权成本越高。
步骤 2:实现 Server
创建 src/server.ts:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { realpath, readFile, stat } from "node:fs/promises";
import { isAbsolute, relative, resolve, extname } from "node
const ROOT = resolve(process.env.MCP_WORKSPACE ?? "./workspace");
const MAX_BYTES = 256 * 1024;
const ALLOWED_EXTENSIONS = new Set([".txt", ".md", ".json"]);
const server = new McpServer({
name: "safe-project-reader",
version: "1.0.0"
});
server.registerTool(
"read_project_file",
{
title: "读取项目文本文件",
description: "只读取授权 workspace 内的 txt、md 或 json 文件,不支持写入和命令执行。",
inputSchema: {
path: z.string().min(1).max(300).describe("相对于 workspace 的文件路径")
}
},
async ({ path }) => {
if (isAbsolute(path)) {
throw new Error("只允许相对路径");
}
const realRoot = await realpath(ROOT);
const candidate = resolve(realRoot, path);
const realFile = await realpath(candidate);
const rel = relative(realRoot, realFile);
if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) {
throw new Error("目标不在授权 workspace 内");
}
const extension = extname(realFile).toLowerCase();
if (!ALLOWED_EXTENSIONS.has(extension)) {
throw new Error("不允许读取该文件类型");
}
const info = await stat(realFile);
if (!info.isFile()) {
throw new Error("目标不是普通文件");
}
if (info.size > MAX_BYTES) {
throw new Error(`文件超过 ${MAX_BYTES} 字节限制`);
}
const text = await readFile(realFile, "utf8");
return {
content: [{ type: "text", text }]
};
}
);
const transport = new StdioServerTransport();
await server.connect(transport);
console.error(`safe-project-reader started, root=${ROOT}`);
这里的关键不是 resolve 本身,而是 realpath。单纯检查 resolve 后的字符串无法阻止 workspace 内的符号链接指向目录外。例如 workspace/link 指向用户主目录时,字符串路径看起来仍在 workspace 下。对根目录和目标文件都执行 realpath,再用 relative 检查真实路径,才能拦截这类逃逸。
还要注意一个常见竞态:路径检查后,文件或符号链接仍可能被其他进程替换。对于高安全要求环境,应把 Server 放进只读容器或受限账号中,并使用操作系统级挂载和权限作为最终边界,而不是只依赖应用层校验。
步骤 3:编译与直接启动
执行:
npm run build
MCP_WORKSPACE=/tmp/mcp-safe-demo/workspace node dist/server.js
直接启动后进程会等待 stdio 协议输入,这是正常现象。不要在终端里随意输入普通文本测试 JSON-RPC;更方便的方式是使用 Inspector 或专用 Client。
步骤 4:使用 MCP Inspector 验证
可在本地运行:
npx @modelcontextprotocol/inspector node /tmp/mcp-safe-demo/dist/server.js
在 Inspector 中确认只出现 read_project_file 工具,然后分别测试:
• 正常输入:README.txt,应返回文件内容。
• 目录穿越:../package.json,应被拒绝。
• 绝对路径:/etc/passwd,应被拒绝。
• 不允许类型:image.png,应被拒绝。
• 超大文件:创建超过限制的本地测试文件,应被拒绝。
测试仅在自建目录和本地环境进行。错误响应也不应包含环境变量、访问令牌或完整调用栈。
五、编写最小 MCP Client
为了理解 Client 侧责任,创建 src/client.ts:
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
const client = new Client({
name: "local-verification-client",
version: "1.0.0"
});
const transport = new StdioClientTransport({
command: "node",
args: ["/tmp/mcp-safe-demo/dist/server.js"],
env: {
...process.env,
MCP_WORKSPACE: "/tmp/mcp-safe-demo/workspace"
}
});
await client.connect(transport);
const tools = await client.listTools();
console.log("available tools:", tools.tools.map(tool => tool.name));
const result = await client.callTool({
name: "read_project_file",
arguments: { path: "README.txt" }
});
console.dir(result, { depth: null });
await client.close();
编译后运行 node dist/client.js。正式 Host 不应仅按工具名称自动放行,还应综合 Server 身份、工具风险级别、工作区、用户授权和参数内容作出决定。对读敏感文件、写文件、发送网络请求、执行命令等工具,建议在 Client 或 Host 层增加明确确认。
六、权限与安全设计建议
1. 最小权限:用独立低权限账号运行 Server,只挂载必须目录。只读工具对应只读文件系统权限。
2. 能力拆分:不要设计一个包含 action、path、command 的“万能工具”。读取、写入、删除、部署应拆成不同工具,并分别授权。
3. 参数白名单:校验长度、格式、枚举、路径、域名和数据大小。不能只依赖自然语言描述。
4. 输出限额:限制文件大小、查询行数和响应长度,防止上下文膨胀、内存消耗和意外泄露。
5. 凭据隔离:不要把整个环境变量集无条件传入 Server。Client 启动子进程时只传需要的变量,令牌应短期化并限制作用域。
6. 审计:记录调用时间、工具名、结果状态和脱敏后的资源标识。不要记录令牌、完整文件正文或个人敏感信息。
7. 人工确认:写入、删除、付款、发布、部署等高影响操作应展示即将执行的具体参数,不能只弹出模糊的“是否允许工具调用”。
8. 提示注入防护:文件和网页中的文字属于不可信数据。Server 返回的内容不能被当作更高优先级指令,Client 也不应因文件写着“调用某工具”就自动执行。
9. 远程传输:若改用 Streamable HTTP,应使用 TLS、身份认证、授权、请求大小限制、速率限制和 Origin 校验。会话标识不能替代用户认证。开发服务默认绑定回环地址,避免无意暴露到局域网或公网。
10. 纵深防御:应用校验、操作系统权限、容器、网络出口策略和 Host 确认应共同工作。MCP Roots 或工具描述可以帮助表达范围,但不应被视为强制沙箱。
七、风险与注意事项
首先,工具名称可信不代表实现可信。安装第三方 MCP Server 前应检查源码、依赖、启动参数、网络行为和请求权限,最好先在隔离环境运行。
其次,stdio Server 的当前工作目录可能由 Host 决定,因此生产配置不要依赖含糊的相对路径。应通过明确环境变量或绝对路径指定 workspace。
再次,MCP Server 的 stderr 也可能被 Host 收集。日志中不要输出文件正文、Authorization 头、Cookie、数据库连接串或用户隐私。
最后,readFile 的 UTF-8 解码并不能可靠识别所有二进制内容。本例通过扩展名和大小做基础限制;若用于真实项目,还应增加内容类型检测、敏感文件名规则和数据分类策略。
八、常见问题
问题 1:为什么 ../package.json 必须在 Server 端拦截,Client 拦截不够吗?
答:Client 可能被替换、配置错误,或存在多个不同 Client。权限边界必须由真正访问文件的 Server 和操作系统共同执行。
问题 2:工具已经标注“只读”,模型还会写文件吗?
答:描述不会改变进程权限。只有 Server 根本没有写入实现,且文件系统权限为只读时,才形成可靠限制。
问题 3:为什么 stdout 不能打印调试日志?
答:stdio 模式通过 stdout 发送协议消息,额外文本可能导致 Client 无法解析消息。应使用 console.error 或结构化 stderr 日志。
问题 4:是否可以直接增加 execute_command 工具?
答:技术上可以,但风险显著提高。更好的做法是提供固定任务工具,例如 run_unit_tests,并把命令、参数、工作目录和超时写死或严格白名单化,同时在本地授权项目中运行。
问题 5:连接成功但工具不可见怎么办?
答:依次检查初始化是否完成、Server 是否声明了 tools 能力、注册代码是否在 connect 前执行、SDK 版本是否匹配,以及 stdout 是否混入日志。还可以先用 Inspector 排除 Host 配置问题。
九、总结
一个可用的 MCP Server 很容易写出来,一个值得长期接入开发工作流的 Server 则必须具备明确边界。本例通过 stdio 完成 Server/Client 通信,并用真实路径校验、扩展名白名单、普通文件检查、响应大小限制和低权限运行降低文件读取风险。
实践中应坚持三个原则:工具能力越小越容易审计;用户确认不能代替服务端授权;应用层校验不能代替操作系统隔离。先从单一、只读、可测试的工具开始,再根据实际需求逐步增加能力,比一开始暴露任意文件和任意命令更符合工程质量要求。