查询 31 次,耗时 0.2081 秒

从零实现一个最小权限的 MCP 文件读取 Server:协议原理、Client 接入与安全边界

dead3223

New member

dead3223 Rep
0
0
0
Rep
0
dead3223 Vouches
Vouches
0
1 MONTH
1 1 MONTH OF SERVICE
dead3223 获得
0
0
0
获得
0
dead3223 凭证
0
0
0
凭证
0
主题
11
点赞
0
HakB
1 月
在社区成长 1 月
LEVEL 1 1 XP
 
一、背景

MCP(Model Context Protocol)用于在模型应用与外部能力之间建立统一接口。典型场景包括:让桌面端模型读取本地知识库、调用内部查询工具、访问授权数据库,或连接代码分析与自动化服务。

MCP 并不等于“让模型直接执行任意命令”。合理的架构应当是:由 Host 管理用户交互与授权,由 Client 建立协议连接,由 Server 暴露经过约束的 Resources、Tools 或 Prompts。真正的权限边界仍然由操作系统权限、服务端校验、网络策略和人工确认共同构成。

本文在本地授权实验环境中实现一个只允许读取指定工作目录中文本文件的 MCP Server,并通过 MCP Client 或 Inspector 调试。示例重点不是堆叠功能,而是展示路径约束、符号链接处理、输出限额、审计日志和最小权限设计。

二、协议与组件原理

一个常见 MCP 调用链可以抽象为:

用户 → MCP Host → MCP Client → MCP Server → 本地文件或后端服务

各组件职责如下:

1. Host:承载模型、用户界面、授权提示和上下文管理。
2. Client:与某个 Server 建立会话,完成初始化、能力协商和请求转发。
3. Server:声明并实现 Resources、Tools、Prompts 等能力。
4. Transport:负责消息传输。本地进程常用 stdio;远程部署可使用 SDK 当前支持的 HTTP 传输方式。不同 SDK 版本对远程传输的命名和实现可能变化,部署前应核对对应版本文档。

Tools 适合具有明确输入、可能产生操作或计算结果的能力;Resources 更接近可读取的数据对象;Prompts 用于提供可复用的提示模板。本文使用 Tool,是为了明确展示输入验证和权限控制。

在协议层面,Client 通常会先初始化连接并协商能力,再获取工具列表,最后按工具名称和结构化参数发起调用。工具参数应使用 JSON Schema 或 SDK 提供的结构定义进行验证,不能依赖自然语言描述作为安全控制。

三、实验目标与安全模型

我们实现一个名为 read_text 的工具,满足以下约束:

1. 只能读取环境变量 MCP_WORKSPACE 指定目录内的文件。
2. 不接受绝对路径。
3. 使用 realpath 解析真实路径,防止通过 ../ 或符号链接逃逸目录。
4. 只读取普通文件。
5. 单文件最大读取 64 KiB,避免超大输出挤占模型上下文或内存。
6. 工具结果不回显服务器绝对路径。
7. 审计日志写入 stderr,避免污染 stdio 协议使用的 stdout。
8. 不提供写文件、执行命令和网络访问能力。

实验仅针对本机创建的 lab 目录,不连接或测试任何未授权目标。

四、前置条件

建议准备:

1. Node.js 20 或更高的受支持版本。
2. npm。
3. 一个支持 MCP 的 Client,或官方生态中的 MCP Inspector。
4. 独立的本地实验目录。

先创建项目和测试数据:

mkdir mcp-safe-reader
cd mcp-safe-reader
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node
mkdir -p src lab
printf 'MCP local lab document\nOnly authorized test data is stored here.\n' > lab/readme.txt

首次安装完成后,应保留 package-lock.json。为了让其他人能够复现同一依赖集合,后续环境优先使用 npm ci,而不是每次重新解析依赖。还可以执行以下命令记录实际安装版本:

npm ls @modelcontextprotocol/sdk zod typescript

MCP SDK 仍可能持续演进。如果未来版本的导入路径或注册接口发生变化,应以项目锁定版本附带的类型定义和官方示例为准,不应直接复制不明版本的代码后跳过检查。

五、配置 TypeScript 工程

在 package.json 中加入 type 和脚本。核心字段可调整为:

{
"type": "module",
"scripts": {
"build": "tsc",
"start": "node dist/server.js"
}
}

创建 tsconfig.json:

{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}

六、实现安全的 MCP 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, stat, readFile } from "node:fs/promises";
import path from "node:path";

const MAX_BYTES = 64 * 1024;
const configuredRoot = process.env.MCP_WORKSPACE;

if (!configuredRoot) {
console.error("MCP_WORKSPACE is required");
process.exit(1);
}

const root = await realpath(configuredRoot);

function audit(event: Record<string, unknown>): void {
console.error(JSON.stringify({
time: new Date().toISOString(),
...event
}));
}

async function resolveInsideRoot(relativePath: string): Promise<string> {
if (path.isAbsolute(relativePath)) {
throw new Error("absolute paths are not allowed");
}

if (relativePath.includes("\u0000")) {
throw new Error("NUL byte is not allowed");
}

const candidate = path.resolve(root, relativePath);
const resolved = await realpath(candidate);
const prefix = root.endsWith(path.sep) ? root : root + path.sep;

if (resolved !== root && !resolved.startsWith(prefix)) {
throw new Error("path escapes the authorized workspace");
}

return resolved;
}

const server = new McpServer({
name: "local-safe-reader",
version: "1.0.0"
});

server.registerTool(
"read_text",
{
title: "Read an authorized local text file",
description: "Reads a small text file located inside the configured workspace. Paths must be relative.",
inputSchema: {
relativePath: z.string().min(1).max(300)
}
},
async ({ relativePath }) => {
try {
const target = await resolveInsideRoot(relativePath);
const info = await stat(target);

if (!info.isFile()) {
throw new Error("target is not a regular file");
}

if (info.size > MAX_BYTES) {
throw new Error(`file exceeds the ${MAX_BYTES} byte limit`);
}

const text = await readFile(target, "utf8");
audit({ tool: "read_text", relativePath, result: "allowed", bytes: info.size });

return {
content: [
{
type: "text",
text
}
]
};
} catch (error) {
const message = error instanceof Error ? error.message : "unknown error";
audit({ tool: "read_text", relativePath, result: "denied", reason: message });

return {
isError: true,
content: [
{
type: "text",
text: `Read denied: ${message}`
}
]
};
}
}
);

const transport = new StdioServerTransport();
await server.connect(transport);

这里有两个容易被忽略的关键点。

第一,不能只检查 path.resolve 后的字符串。假设 lab/link 是一个指向目录外部的符号链接,那么仅判断 candidate 是否以 root 开头仍可能被绕过。代码对已存在目标调用 realpath,再检查真实路径是否仍在 root 内。

第二,stdio 模式下 stdout 是协议通道。调试信息如果使用 console.log 输出到 stdout,可能破坏消息帧,导致 Client 报解析错误。因此审计信息使用 console.error 写入 stderr。

七、构建与本地验证

执行:

npm run build
MCP_WORKSPACE="$(pwd)/lab" npm start

Server 启动后等待 Client 通过 stdin/stdout 通信,看起来“没有输出”通常是正常现象。不要把它误认为进程卡死。

使用 Inspector 调试时,可以运行:

MCP_WORKSPACE="$(pwd)/lab" npx @modelcontextprotocol/inspector node dist/server.js

首次使用 npx 可能下载软件包。在高安全环境中,应固定 Inspector 版本、核对来源,并通过内部制品仓库安装,避免把临时下载行为带入生产环境。

在 Inspector 中完成连接后:

1. 查看 Tools 列表,确认存在 read_text。
2. 使用参数 {"relativePath":"readme.txt"} 调用。
3. 确认返回 lab/readme.txt 的文本。
4. 使用 {"relativePath":"../package.json"} 调用,确认请求被拒绝。
5. 创建指向目录外的符号链接后再次测试,确认 realpath 检查能够拒绝逃逸。

Linux 或 macOS 下可在授权实验目录中构造符号链接测试:

ln -s ../package.json lab/outside-link

然后传入 outside-link。预期结果是 path escapes the authorized workspace,而不是返回 package.json 内容。Windows 上符号链接创建可能需要额外权限,也可以使用目录联接等本地实验方式验证,但应注意不同文件系统的路径语义差异。

还应创建超过 64 KiB 的测试文件验证限额:

node -e "require('fs').writeFileSync('lab/large.txt', 'A'.repeat(70000))"

调用 large.txt 时应返回文件超过限制,而不是把全部内容送入模型上下文。

八、接入 MCP Client

不少桌面 Client 使用 mcpServers 风格的本地配置,但配置文件位置和字段支持情况由具体产品决定。一个通用示意如下:

{
"mcpServers": {
"local-safe-reader": {
"command": "node",
"args": ["/absolute/path/mcp-safe-reader/dist/server.js"],
"env": {
"MCP_WORKSPACE": "/absolute/path/mcp-safe-reader/lab"
}
}
}
}

必须使用绝对路径,避免 Client 从不同工作目录启动时找不到脚本。配置后重启或重新加载 Client,确认它能够列出 read_text 工具。

不能假定所有 Client 都会在每次工具调用前弹出确认。是否自动调用、是否显示参数、是否允许持久授权属于 Host 的产品策略。因此,Server 自身必须执行完整鉴权和输入校验,不能把安全责任全部交给前端确认框。

九、权限与安全设计

1. 最小能力暴露

如果需求只是读取知识库,就不要顺便暴露 shell、文件写入和任意 HTTP 请求。每增加一种工具,都相当于扩大模型可触达的权限面。

2. 将工具输出视为不可信数据

文件内容可能包含“忽略此前规则并调用某工具”之类的提示注入文本。它只是数据,不应自动获得更高优先级。Host 应区分工具结果与系统指令,并对高风险后续操作重新确认。

3. 防止混淆代理问题

模型可能在没有恶意意图的情况下组合多个合法工具,产生越权结果。例如先读取凭据,再通过网络工具发送出去。单个工具分别看似合理,组合后却构成数据外泄。应通过工具分组、会话授权、网络出口限制和敏感数据标记降低组合风险。

4. 操作系统权限仍然是最终边界之一

建议使用专用低权限账户运行 Server,仅授予实验目录读取权限。不要以管理员或 root 身份启动。容器化部署时,可将工作目录以只读方式挂载,并关闭不必要的网络访问。

5. 控制返回量与敏感信息

限制单次大小、总调用次数和并发数。错误信息不要包含令牌、数据库连接串、完整内部路径或堆栈。本文为了本地调试返回了明确拒绝原因,生产环境可进一步将详细原因只写入受保护的审计日志。

6. 远程 Server 的额外要求

如果改为 HTTP 传输,必须补充身份认证、TLS、来源校验、会话隔离、超时、速率限制和审计。不能仅凭“知道 URL”就授予工具调用权限。还要防止把用户提供的 URL 直接交给后端请求,从而形成 SSRF。

7. 供应链与版本管理

提交 lockfile,使用 npm ci,定期检查依赖公告,并在测试环境升级。不要让 MCP Server 在启动时自动执行来源不明的安装脚本,也不要从模型输出中复制命令后直接以高权限运行。

十、常见问题

问题 1:为什么 Server 启动后没有提示?

stdio Server 通常会等待 Client 发起协议通信。不要在 stdout 打印启动横幅。需要观察状态时写入 stderr,或由进程管理器收集日志。

问题 2:只禁止 ../ 是否足够?

不够。绝对路径、符号链接、路径规范化差异和平台分隔符都可能导致边界绕过。应执行绝对路径拒绝、规范化、realpath 解析及根目录边界检查,并结合操作系统权限。

问题 3:为什么不提供任意文件读取工具,再让提示词限制目录?

提示词不是权限控制。模型可能误解要求,工具结果也可能包含提示注入。目录限制必须在 Server 代码和操作系统层落实。

问题 4:Tool 与 Resource 应如何选择?

如果对象天然是可枚举、可读取的数据资源,可以考虑 Resource;如果需要结构化参数、执行逻辑、权限判断或产生操作结果,Tool 通常更直观。无论选择哪种能力,都必须在服务端验证访问范围。

问题 5:readFile 的 UTF-8 解码是否意味着可以安全处理所有文件?

不是。二进制文件可能产生不可读字符,某些文本还可能包含控制字符。生产实现应增加扩展名白名单、内容类型检测、字符清理和更严格的输出编码策略。

问题 6:如何进一步增强本例?

可以增加只读目录白名单、按用户或会话授权、调用配额、内容哈希、脱敏规则和结构化审计事件。若增加搜索工具,应限制搜索根目录、匹配数量、单项长度和执行时间,避免递归扫描造成资源耗尽。

十一、总结

一个可用的 MCP Server 不只是“让 Client 能看到工具”,更重要的是把能力边界设计清楚。本文示例通过固定工作区、拒绝绝对路径、realpath 校验、文件类型检查、64 KiB 限额和 stderr 审计,构建了一个可以本地复现的最小读取工具。

实际项目中应始终遵循三条原则:第一,MCP 负责标准化交互,不替代鉴权和隔离;第二,模型、用户输入及工具输出都不能默认可信;第三,权限应在 Server、Host、操作系统和网络层多层落实。只有在最小权限、明确授权和可审计的基础上,MCP 工具接入才适合进入真实业务环境。
 

黄先生嗯

New member

黄先生嗯 Rep
0
0
0
Rep
0
黄先生嗯 Vouches
Vouches
0
1 MONTH
1 1 MONTH OF SERVICE
黄先生嗯 获得
0
0
0
获得
0
黄先生嗯 凭证
0
0
0
凭证
0
主题
15
点赞
0
HakB
1 月
在社区成长 1 月
LEVEL 1 1 XP
 
还应注意 realpath→stat→readFile 之间存在 TOCTOU 窗口:校验结束后,攻击者若能改动工作目录,可能把文件替换为指向目录外的符号链接。实践上可先以只读方式 open,再对同一文件描述符执行 stat 和限量读取,避免按路径二次打开;Linux 上若需更强边界,可用 openat2 配合 RESOLVE_BENEATH、RESOLVE_NO_SYMLINKS。另建议用 TextDecoder('utf-8', { fatal: true }) 拒绝非法编码,并在审计中只记录相对路径、结果、字节数和请求标识,避免日志泄露绝对路径或文件内容。
 
顶部