手把手教你实现一个 MCP 文件读取服务器:从协议到代码的深度解析
用 Node.js 和官方 SDK 打造一个让 AI 能直接读你硬盘文件的 MCP 服务
引言:AI 与本地世界的桥梁
大语言模型(LLM)虽然拥有海量知识,但它的“眼睛”和“耳朵”却无法直接触及你的本地文件系统。当你想让 AI 帮你分析一个本地日志、处理一份 Markdown 笔记,或者读取配置文件时,常规的对话方式束手无策。这时候,MCP(Model Context Protocol) 就登场了——它是一个专为 AI 应用设计的开放协议,让 LLM 能够通过标准化的工具调用,安全地与外部世界交互。
本文我将带着你从零实现一个 手写文件处理 MCP 服务器。它只做一件事:接收一个文件路径,返回文件内容。麻雀虽小,但五脏俱全,我们将深入探讨 MCP 的核心概念、开发流程、通信机制,并梳理出清晰的架构图。读完你不仅能跑起这个服务,更能理解 MCP 的设计哲学,为后续构建更复杂的工具打下基础。
一、整体架构:从用户提问到文件读取的完整链路
我们先从一个全局视角,看看当用户对 AI 说“帮我读一下 /tmp/test.txt”时,背后发生了什么。根据 README 中的笔记,整个流程可以抽象为:
用户 prompt → stdioServerTransport → MCP Server 分析 → 选中 fs 工具客户端 →
StdioTransport 回传 → 大模型生成最终回复
用更通俗的语言拆解:
- 用户输入:用户通过某个支持 MCP 的客户端(比如 Claude Desktop、自定义 Chat UI)发送自然语言请求。
- 客户端与 Server 的通信:客户端通过
stdioServerTransport(标准输入输出流)将请求传递给 MCP Server。 - Server 解析与工具匹配:Server 收到请求后,根据内置的工具列表(我们注册的
read_file)判断用户意图,并提取参数(文件路径)。 - 工具执行:Server 调用
read_file的实际逻辑,通过 Node.js 的fs/promises读取本地文件。 - 结果返回:读取到的内容(或错误信息)通过
StdioTransport原路返回给客户端。 - LLM 生成回复:客户端将工具返回的结果交给大模型,模型结合上下文生成最终的自然语言回答。
这一来一回,AI 就“看到”了你的文件。接下来我们将深入实现每一个环节。
二、技术选型:三剑客让 MCP 开发如此丝滑
在动手编码前,先介绍我们依赖的三个核心库,它们各自扮演了什么角色。
1. @modelcontextprotocol/sdk —— MCP 协议的 Node.js 实现
这是官方提供的 SDK,封装了 MCP 协议的消息格式、请求/响应处理、传输层适配等。我们不再需要手写 JSON-RPC 解析,只需要调用高阶 API 即可快速搭建 Server。
2. zod —— 声明式数据验证
MCP 要求工具的参数以 JSON Schema 形式声明,以便大模型理解应该传什么字段。手动写 JSON Schema 繁琐且容易出错。zod 允许我们用 TypeScript 风格的声明式语法定义 schema,SDK 内部会自动将其转换为标准的 JSON Schema 并注册到协议中。
3. fs/promises —— Node.js 原生文件操作
负责实际的文件读取,异步 API 保证非阻塞性能。
这三者组合,让我们在几分钟内就能写出一个生产可用的 MCP 工具。
三、代码实现:逐行解读 MCP 文件读取服务
现在开始写代码。我们创建一个 server.js,按以下顺序构建:
- 导入依赖
- 实例化 MCP Server
- 注册工具(包括 schema 定义和执行函数)
- 启动服务器,绑定 stdio 传输
下面逐个模块讲解。
3.1 导入模块
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';
- McpServer:新版(较新版本)的服务器类,简化了工具注册和事件处理。
- StdioServerTransport:基于标准输入输出的传输层实现,适合本地命令行场景。
- z:zod 的核心对象,用于定义 schema。
- fs/promises:返回 Promise 版本的文件 API。
3.2 实例化 MCP Server
const server = new McpServer({
name: 'simple-read-mcp',
version: '1.0.0'
});
创建 Server 实例时需要提供 name 和 version,这些信息会在协议握手阶段暴露给客户端,方便识别。
3.3 注册工具 read_file
这是整个服务的核心,我们通过 server.tool() 方法注册一个工具。
server.tool(
"read_file", // 工具名称,大模型通过这个名字调用
"读取指定路径的本地文件内容", // 描述,帮助大模型理解工具用途
{
path: z.string().describe("文件的绝对或相对路径") // 参数 schema
},
async ({ path }) => { // 工具的执行函数
// ... 实现
}
);
细节剖析:
- 工具名称必须具有语义性,大模型会根据用户意图匹配。
- 描述很重要,它会被包含在系统提示中,影响模型是否选择该工具。
- 参数 schema:我们使用
z.object的简写形式(直接传入对象),内部每个字段都调用.describe()添加说明,这些说明最终会生成 JSON Schema 的description字段,指导大模型如何填充参数。 - 执行函数是一个异步函数,接收一个对象(参数已根据 schema 解析并验证通过)。我们在这里实现真正的业务逻辑。
3.4 执行函数:读取文件并返回结果
async ({ path }) => {
try {
const content = await fs.readFile(path, 'utf-8');
return {
content: [{ type: "text", text: content }]
};
} catch (err) {
return {
isError: true,
content: [{ type: "text", text: `读取文件失败:${err.message}` }]
};
}
}
这里需要注意 MCP 的返回格式规范:
- 成功时,返回一个对象,包含
content数组,每个元素是一个{ type: "text", text: ... }结构。这是 MCP 标准的内容块格式,可以支持多种类型(图片、嵌入等),但这里我们只用纯文本。 - 失败时,除了
content外,还需设置isError: true,明确告诉客户端这是一个错误响应,大模型会据此向用户解释失败原因。
错误处理我们包裹了 try/catch,捕获任何文件读取异常(如路径不存在、无权限等),并返回友好提示。
3.5 启动服务器并绑定传输层
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("MCP read_file 服务已启动(stdio模式)");
}
main().catch(console.error);
- StdioServerTransport 实例化后,会监听
process.stdin和process.stdout,所有与客户端的通信都将通过这两个流进行。 - server.connect(transport) 将 Server 与传输层绑定,随后 Server 开始处理入站请求。
- 注意我们使用
console.error输出启动日志,因为console.log会向 stdout 输出,而 stdout 已经被传输层占用,任何额外的输出都会破坏协议消息。因此所有调试日志都应使用stderr。
四、MCP 协议与 stdio 传输:藏在背后的通信细节
很多同学会好奇,这个服务启动后是怎么和客户端对话的?其实 MCP 协议基于 JSON-RPC 2.0 消息格式,通过 stdio 通道收发。客户端(比如 Claude Desktop)会以子进程方式启动我们的 Node.js 脚本,然后向它的 stdin 写入 JSON 请求,我们的 Server 从 stdin 读取并解析,处理完毕后将响应 JSON 写入 stdout,客户端再从 stdout 读取。
整个握手过程包括:
- 初始化:客户端发送
initialize请求,Server 返回自身能力(包括支持的工具列表)。 - 工具列表请求:客户端发送
tools/list,Server 返回我们注册的read_file的完整 JSON Schema。 - 工具调用:当用户触发调用,客户端发送
tools/call,包含工具名和参数,Server 执行并返回结果。
而我们的代码之所以如此简洁,是因为 McpServer 和 StdioServerTransport 帮我们自动处理了所有这些 JSON-RPC 的序列化/反序列化、事件分发和错误处理。server.tool() 方法内部自动注册了 ListToolsRequestSchema 和 CallToolRequestSchema 的事件监听器,我们只需专注于业务逻辑。
五、运行与测试:让 AI 真正读到你的文件
5.1 启动服务
在终端执行:
node server.js
你会看到 stderr 输出启动信息。此时服务处于等待输入状态。
5.2 与客户端集成
如果你使用的是 Claude Desktop,需要在配置文件中添加该 MCP 服务(具体可参考官方文档)。如果是自定义客户端,则可以用子进程启动并模拟 JSON-RPC 请求。
5.3 测试示例
假设我们有一个 test.txt 文件内容为 "Hello MCP!",在客户端输入:
请读取当前目录下的 test.txt 文件
大模型会解析意图,构造工具调用,Server 返回文件内容,最终模型回复:
文件内容为:Hello MCP!
六、深度思考:为什么这样设计?我们能学到什么?
6.1 为什么使用 zod 而不是直接写 JSON Schema?
zod 不仅提供了简洁的声明语法,还自带类型推断能力,在 TypeScript 项目中可获得完整的类型提示。同时,它保证了参数在进入执行函数前已经通过验证,避免在业务代码里做重复的类型检查。
6.2 MCP 的标准化价值
如果没有 MCP,每个 AI 应用都需要自定义插件接口,对开发者、使用者都不友好。MCP 统一了工具的描述格式、调用方式和返回格式,让 AI 模型可以无缝适配不同服务。我们实现的这个简单文件读服务,也可以被任何支持 MCP 的客户端消费,真正实现“一次编写,到处集成”。
6.3 错误处理的思考
我们返回了 isError: true,但并未抛出异常。这是因为 MCP 协议规定工具调用应当总是返回一个有效的响应对象,即使发生错误,也要以错误内容块的形式返回,而不是直接让进程崩溃或抛出未捕获异常。这样客户端(尤其是 LLM)能够优雅地处理错误并反馈给用户。
6.4 扩展方向
当然,真正的生产级文件处理 MCP 还需要考虑:
- 安全限制(禁止读取系统敏感文件)
- 支持二进制文件(如返回 Base64)
- 流式读取大文件
- 更丰富的参数(编码、偏移量等)
但这些都只是在当前框架上添加更多逻辑,核心架构不变。
七、流程图:一次完整的请求处理路径
为了让理解更直观,我用文字描述一下完整的时序流程(配合上文提到的思路图):
[用户] -> (输入自然语言)
-> [客户端] -> (构造 JSON-RPC 请求)
-> [stdin] -> [MCP Server]
-> 解析请求 -> 匹配工具 read_file
-> 执行 fs.readFile
<- 返回结果 (content 或 error)
<- 封装为 JSON-RPC 响应
<- [stdout]
<- [客户端] 解析响应
<- [LLM] 生成最终回答 -> 展示给用户
每个箭头都代表一次数据流动,而我们的 server.js 就是图中“MCP Server”这个方块的完整实现。
结语:从文件读取开始,迈向更广阔的 MCP 世界
通过这个只有几十行代码的项目,我们不仅掌握了一个实用工具的开发,更深刻理解了 MCP 协议的核心思想:标准化工具接口,让 AI 与外部能力解耦。未来你可以为这个 Server 添加更多工具,比如写入文件、列表目录、甚至调用系统命令,但注册方式、参数验证、错误返回都遵循着相同的模式。
MCP 的潜力远不止文件操作,数据库查询、API 调用、设备控制……一切皆可接入。希望这篇文章能为你打开一扇门,让你在自己的 AI 项目中灵活运用 MCP,真正释放大模型的行动力。
现在,不妨亲手运行一下这个服务,感受 AI 读取你硬盘文件时的奇妙瞬间吧!
如果你在实现过程中遇到任何问题,欢迎在评论区留言交流。如果觉得有用,别忘了点赞收藏,我们下期再见!