手写一个文件读写 MCP Server:让大模型"触摸"你的硬盘
摘要:MCP 让大模型能够调用外部工具。本文从零搭建一个文件读写 MCP Server,通过 Zod 定义参数、StdioServerTransport 建立通信通道,并加入路径安全限制。读完你会理解 MCP 的完整开发流程。
📑 目录
- MCP Server 是什么?一个为 LLM 提供工具的"服务员"
- 项目初始化:依赖与配置
- 核心代码:注册 read_file 和 write_file 工具
- 我们使用的 MCP SDK:版本与能力
- Zod 的作用:为什么不用手写 JSON Schema?
- StdioServerTransport:MCP 的通信通道
- 安全第一:路径限制与危险操作保护
- 测试与调试:MCP Inspector
- 开发流程总结
- 互动讨论
MCP Server 是什么?一个为 LLM 提供工具的"服务员"
MCP(Model Context Protocol)是 Anthropic 在 2024 年底推出的协议,目标是让 AI 模型能以统一的方式访问外部工具和数据。MCP Server 就是承载这些工具的服务端程序。
想象一下:大模型就像一位"话很多但手很笨"的顾问,它能说会道,但无法操作你的电脑。MCP Server 就是它的"手脚"------当 LLM 需要读取文件、查询数据库、发邮件时,它通过 MCP 协议向 Server 发出请求,Server 执行操作后返回结果。
本次实战的目标:开发一个文件读写 MCP Server,让 LLM 能够读取和写入本地硬盘上的指定文件。
项目初始化:依赖与配置
环境要求
| 工具 | 版本要求 |
|---|---|
| Node.js | >= 20.11.0 |
| npm / pnpm | 最新稳定版 |
安装依赖
bash
perl
mkdir my-filesystem-mcp-server
cd my-filesystem-mcp-server
npm init -y
pnpm i @modelcontextprotocol/sdk zod
pnpm i -D typescript @types/node
npx tsc --init
四个核心依赖各司其职:
@modelcontextprotocol/sdk:MCP 协议的 SDK,封装了 Server 创建、通信等底层细节。zod:数据验证库,用于定义工具参数的 Schema。fs/promises和path:Node.js 内置模块,用于文件读写和路径解析。typescript和@types/node:TypeScript 开发环境,提供类型支持。
MCP SDK:与协议对话的"翻译官"
@modelcontextprotocol/sdk 是 MCP 官方提供的 JavaScript/TypeScript 软件开发工具包。它封装了 MCP 协议的通信细节,让你不用关心 JSON-RPC 消息格式、不用手动处理 stdin/stdout 数据流,只需要聚焦于"注册什么工具"和"工具做什么"。
SDK 提供了两个核心模块:
McpServer:MCP 服务端的"大脑"。负责注册工具、管理生命周期、处理来自 Client 的请求。你的所有工具都通过server.tool()注册到这里。StdioServerTransport:MCP 服务端的"嘴巴和耳朵"。负责实际的数据传输------从 stdin 读取指令,通过 stdout 返回结果。
有了 SDK,你不需要从头实现 MCP 协议解析。你只需要告诉 McpServer "我有一个工具叫 read_file",SDK 会自动将工具声明转换为 MCP 协议能理解的 JSON-RPC 格式,并通过 Transport 发送给 Client。
核心代码:注册 read_file 和 write_file 工具
完整的 server.js 实现了两个工具:read_file 和 write_file。
1. 导入与路径安全设置
javascript
javascript
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import fs from 'fs/promises';
import path from 'path';
const ALLOWED_DIR = path.resolve(process.cwd());
function resolveSafePath(inputPath) {
const resolved = path.resolve(ALLOWED_DIR, inputPath);
if (!resolved.startsWith(ALLOWED_DIR + path.sep) && resolved !== ALLOWED_DIR) {
throw new Error(`安全限制:禁止访问项目目录外的文件!`);
}
return resolved;
}
ALLOWED_DIR 设置为当前工作目录,所有文件操作被限制在这个目录内。resolveSafePath 函数将用户输入的相对路径解析为绝对路径,并检查是否在允许范围内。
fs/promises:让 Node.js 操作文件系统
fs 是 Node.js 内置的文件系统(File System) 模块,提供了与操作系统文件系统交互的全部能力------创建、读取、修改、删除文件和目录。
代码中使用的是 fs/promises,这是 fs 模块的 Promise 版本。对比两种写法:
javascript
javascript
// 传统回调写法(容易陷入回调地狱)
const fs = require('fs');
fs.readFile('/path/to/file', 'utf-8', (err, data) => {
if (err) throw err;
console.log(data);
});
// Promise 版本(可以用 await)
import fs from 'fs/promises';
const content = await fs.readFile('/path/to/file', 'utf-8');
在 MCP Server 中,工具处理函数是异步的(async),使用 await fs.readFile() 和 await fs.writeFile() 可以让代码像同步一样顺序执行,可读性更好。
本次实现用到的两个核心方法:
| 方法 | 作用 | 参数 |
|---|---|---|
fs.readFile(path, encoding) |
读取文件内容 | 文件路径、编码格式(如 'utf-8') |
fs.writeFile(path, content, encoding) |
写入文件内容 | 文件路径、要写入的内容、编码格式 |
path.resolve() 和 path.sep 则来自 path 模块,用于处理不同操作系统下的路径差异(Windows 用 ``,Linux/macOS 用 /),保证跨平台兼容。
2. 创建 MCP Server 实例
javascript
arduino
const server = new McpServer({
name: 'simple-read-mcp',
version: '1.0.0',
});
3. 注册 read_file 工具
javascript
javascript
server.tool(
'read_file',
`读取项目目录下的文件内容(受限:只能读取 ${ALLOWED_DIR} 下的文件)`,
{
path: z.string().describe('相对于项目的文件路径,例如 "server.js"')
},
async ({ path: inputPath }) => {
try {
const safePath = resolveSafePath(inputPath);
const content = await fs.readFile(safePath, 'utf-8');
return { content: [{ type: 'text', text: content }] };
} catch (err) {
return {
isError: true,
content: [{ type: 'text', text: `文件读取失败: ${err.message}` }]
};
}
}
);
server.tool() 接收四个参数:
- 工具名称(
read_file) - 工具描述(LLM 根据描述决定何时调用)
- 参数 Schema(使用 Zod 定义)
- 处理函数(异步,执行实际业务逻辑)
4. 注册 write_file 工具
javascript
javascript
server.tool(
'write_file',
`写入内容到项目目录下的文件(受限:只能写入 ${ALLOWED_DIR} 下的文件)`,
{
path: z.string().describe('相对于项目的文件路径,例如 "output.txt"'),
content: z.string().describe('要写入的文件内容')
},
async ({ path: inputPath, content }) => {
try {
const safePath = resolveSafePath(inputPath);
await fs.writeFile(safePath, content, 'utf-8');
return { content: [{ type: 'text', text: `✅ 文件写入成功: ${safePath}` }] };
} catch (err) {
return {
isError: true,
content: [{ type: 'text', text: `文件写入失败: ${err.message}` }]
};
}
}
);
5. 启动服务器
javascript
javascript
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error(`✅ MCP 文件读写服务已启动(stdio 通道)`);
console.error(`🔒 访问限制目录: ${ALLOWED_DIR}`);
}
main().catch(err => {
console.error('❌ 服务启动失败:', err);
process.exit(1);
});
StdioServerTransport 建立了基于 stdin/stdout 的通信通道------这是 MCP Server 与 Client 之间的"双向水管"。
我们使用的 MCP SDK:版本与能力
本次项目使用的 MCP SDK 是 @modelcontextprotocol/sdk(最新稳定版),它提供了 MCP 协议的完整实现,包含以下核心模块:
| 模块 | 作用 | 本次是否使用 |
|---|---|---|
McpServer |
MCP 服务端核心类,注册工具、管理生命周期 | ✅ 使用 |
StdioServerTransport |
基于 stdin/stdout 的传输层 | ✅ 使用 |
SSEServerTransport |
基于 Server-Sent Events 的远程传输层 | ❌ 未使用(本地场景用 Stdio 即可) |
types |
MCP 协议的类型定义(请求/响应格式) | ✅ 间接使用(SDK 内部依赖) |
SDK 的核心设计原则是 "协议与实现分离" :
- 协议层 (
types)定义了"说什么"------JSON-RPC 消息的格式。 - 服务端层 (
McpServer)定义了"谁在听"------工具注册和请求路由。 - 传输层 (
*Transport)定义了"怎么说"------通过 stdin/stdout 还是 HTTP/SSE。
这种分层设计让你可以在不改变工具实现的情况下,切换不同的传输方式。比如今天用 Stdio 跑在本地,明天想部署为远程服务,只需把 StdioServerTransport 换成 SSEServerTransport,工具代码一行不用改。
Zod 的作用:为什么不用手写 JSON Schema?
在 MCP 中,工具的参数需要通过 JSON Schema 声明,告诉 LLM 这个工具需要什么参数、什么类型、哪些必填。
手写 JSON Schema 非常繁琐:
json
json
{
"type": "object",
"properties": {
"filePath": {
"type": "string",
"description": "文件的绝对路径"
}
},
"required": ["filePath"]
}
而 Zod 的写法简洁直观:
javascript
css
{ path: z.string().describe('相对于项目的文件路径') }
Zod 解决了三个核心问题:
| 痛点 | Zod 的解法 |
|---|---|
| 代码冗余 | 一份 Schema 同时搞定运行时验证 + TypeScript 类型推导 |
| 类型不一致 | z.infer<typeof schema> 自动推导,永远同步 |
| 可读性差 | 链式调用 .describe()、.optional(),一目了然 |
更重要的是,MCP SDK 在幕后使用 zod-to-json-schema 库,在运行时自动把 Zod Schema 转换成标准的 JSON Schema 发送给 Client。你写的 Zod 对象,最终会被转换成 LLM 能理解的 JSON Schema。
对比一下两种方式:
| 对比维度 | 手写 JSON Schema | 使用 Zod |
|---|---|---|
| 代码量 | 多,层层嵌套 | 少,一行搞定 |
| 类型推导 | 手动写 interface,易出错 | z.infer 自动推导 |
| 必填/可选 | 需显式写 "required": [...] |
默认必填,.optional() 即可 |
| 自定义校验 | anyOf/allOf 极其繁琐 |
.refine() 直接写函数 |
StdioServerTransport:MCP 的通信通道
StdioServerTransport 是 MCP SDK 提供的一种传输层实现 ,基于进程的 stdin 和 stdout 进行通信。
text
arduino
Client(如 Cursor) → stdin → MCP Server
MCP Server → stdout → Client
- stdin(标准输入) :Client 向 Server 发送指令(如"调用 read_file")。
- stdout(标准输出) :Server 向 Client 返回结果(如文件内容或错误信息)。
所有日志和调试信息必须通过 console.error 输出,否则会污染 stdout 通道,干扰 MCP 通信。这就是为什么代码中使用了 console.error 而不是 console.log。
Transport 与 SDK 的协作
StdioServerTransport 的工作流程是:
- 监听 stdin:等待 Client 通过标准输入发送 JSON-RPC 请求。
- 解析请求:将收到的 JSON 字符串解析为 MCP 协议消息。
- 交给 McpServer :
McpServer根据消息内容找到对应的工具并执行。 - 返回结果 :将执行结果通过
stdout以 JSON-RPC 格式返回给 Client。
开发者不需要关心第 1、2、4 步------SDK 和 Transport 已经帮你封装好了。你只需要关注第 3 步中的"工具实现"。
javascript
ini
const transport = new StdioServerTransport();
await server.connect(transport);
这两行代码完成了 Transport 的实例化和连接。连接成功后,Server 就处于"伺服状态",随时等待 Client 发来指令。所有的通信细节------JSON 序列化/反序列化、消息路由、错误处理------都由 SDK 在幕后完成。
除了 Stdio,MCP 还支持 SSE(Server-Sent Events) 用于远程 HTTP 通信,但本地进程通信场景下 Stdio 是首选。
安全第一:路径限制与危险操作保护
MCP Server 操作的是本地文件系统,如果不加限制,后果可能是灾难性的。
路径白名单
代码中通过 ALLOWED_DIR 将文件操作限制在当前项目目录内:
javascript
javascript
const ALLOWED_DIR = path.resolve(process.cwd());
function resolveSafePath(inputPath) {
const resolved = path.resolve(ALLOWED_DIR, inputPath);
if (!resolved.startsWith(ALLOWED_DIR + path.sep) && resolved !== ALLOWED_DIR) {
throw new Error(`安全限制:禁止访问项目目录外的文件!`);
}
return resolved;
}
任何试图访问项目目录外的路径都会抛出异常,LLM 无法绕过这个限制。
User Consent
MCP 协议还支持 User Consent(用户确认) 机制。如果工具被标记为"危险操作"(如 delete_file、send_email),在 Host(如 Cursor)中调用时会弹出确认框,等待用户手动批准后才执行。
测试与调试:MCP Inspector
MCP Inspector 是官方提供的调试工具,可以可视化地测试 MCP Server。
bash
bash
# 构建项目
npm run build
# 启动 Inspector
npx @modelcontextprotocol/inspector node dist/index.js
Inspector 提供的核心功能:
| 功能 | 说明 |
|---|---|
| List Tools | 查看 Server 注册了哪些工具及其参数描述 |
| Call Tool | 手动填入参数,实际调用工具并查看返回结果 |
| 查看原始通信 | 观察 Client 和 Server 之间实际传输的 JSON-RPC 消息 |
开发流程总结
一个完整的 MCP Server 开发流程:
text
markdown
1. 初始化项目 → npm init -y
2. 安装依赖 → pnpm i @modelcontextprotocol/sdk zod
3. 配置 TypeScript → npx tsc --init
4. 编写 index.ts → 创建 McpServer,注册工具
5. 实现安全限制 → 路径白名单 + User Consent
6. 启动 Transport → new StdioServerTransport()
7. 构建 → npm run build
8. 测试 → npx @modelcontextprotocol/inspector
核心要点:
- 工具描述要清晰:LLM 根据描述决定调用哪个工具,描述越详细,调用越准确。
- 参数用 Zod 定义:比手写 JSON Schema 简洁得多,且自动生成类型。
- 安全是第一要务:限制操作范围,危险操作需申请用户同意。
- Stdio 是本地通信的首选 :
stdin接收指令,stdout返回结果。
互动讨论
- 为什么 MCP 协议要区分 Tools 和 Resources? 如果
read_file只是读取数据(无副作用),它应该是 Tool 还是 Resource? - 代码中的
resolveSafePath函数是否足够安全? 有没有可能绕过路径限制的攻击方式?(提示:path.resolve对../的处理) - 如果 MCP Server 要部署为远程服务,应该用什么传输方式? 和 Stdio 有什么区别?
- Zod 的
.refine()方法能实现什么自定义校验? 你能为write_file添加文件大小限制吗?
📌 一点心得:MCP Server 的本质是"把函数翻译成 LLM 能理解的语言"。工具描述清晰、参数 Schema 准确、安全限制严格,三者缺一不可。