从零实现文件读取 MCP Server,深入理解 Client → Server 的完整调用流程
前言
最近 MCP(Model Context Protocol)在 AI 圈火得不行,大家都在讨论如何让 AI 真正"动手干活"。我花了些时间啃了 MCP 的 SDK 源码,手写了一个文件读取的 MCP Server。本文会带着你从零开始,搞懂 MCP 的核心概念、通信流程,以及那些容易踩的坑。
读完本文,你将收获:
- MCP 协议的核心设计思想(用生活化比喻讲透)
- 手写 MCP Server 的完整流程(附详细注释)
- 完整调用链路拆解(从用户提问到 AI 回答,每一步都讲清楚)
- 源码级别的重难点剖析(为什么这么写?)
- 面试高频考点与避坑指南
一、MCP 是什么?先讲个故事
1.1 没有 MCP 的 AI:一个"语言上的巨人"
想象你请了一个超级助理(AI),他能听懂你说的一切,但有个致命的缺陷:他只能动嘴,不能动手。
text
arduino
你:"帮我把 D 盘里的 config.json 读出来看看"
AI:"好的,我建议你双击打开文件,然后..."
你:"???"
这就是传统 AI 的困境------信息孤岛。模型再强大,也摸不到你的文件系统、数据库、API 服务。
1.2 MCP 出场:AI 的"万能 USB 接口"
MCP(Model Context Protocol)要解决的就是这个问题。它定义了一套标准化的协议,让 AI 能够通过统一的接口调用外部工具。
用个接地气的比喻:
| 现实世界 | MCP 世界 |
|---|---|
| 你想买房 | AI 想读取文件 |
| 房产中介 | MCP 协议(中间人) |
| 房源信息 | 文件内容 |
| 房东 | 你的 MCP Server |
MCP 就是一个"中介" ,让 AI(买家)和工具(卖家)不用直接打交道,通过标准化的合同(协议)完成交易。
二、完整代码解读:一个文件读取 MCP Server
2.1 完整源码(带超详细注释)
javascript
javascript
// 1️⃣ 导入 MCP SDK 核心模块
import { Server } from '@modelcontextprotocol/sdk/server';
// 本地 stdio 通信传输层
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
// MCP 协议定义的事件类型
import {
ListToolsRequestSchema, // "列出工具"事件
CallToolRequestSchema // "调用工具"事件
} from '@modelcontextprotocol/sdk/types.js';
// Node.js 文件系统(Promise 版本)
import fs from 'fs/promises';
// 2️⃣ 创建 MCP Server 实例
// ⚠️ 重点:这里不是普通的服务器,而是 MCP 协议的"实现者"
const server = new Server(
{
name: 'simple-read-mcp', // Server 唯一标识
version: '1.0.0' // 语义化版本
},
{
capabilities: { tools: {} } // 声明能力:提供工具
}
);
// 3️⃣ 注册"列出工具"处理器
// 当 Client(AI)询问"你会什么?"时触发
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'read_file', // 工具名称
description: '读取指定路径的本地文件内容', // 工具描述
inputSchema: { // 参数定义(JSON Schema)
type: 'object',
properties: {
path: {
type: 'string',
description: '文件的绝对或相对路径'
}
},
required: ['path'] // 必填参数
}
}
]
};
});
// 4️⃣ 注册"调用工具"处理器
// 当 Client 说"执行 read_file"时触发
server.setRequestHandler(CallToolRequestSchema, async (request) => {
// 🎯 路由分发:从请求中解构出工具名和参数
// 注意:arguments 是保留字,重命名为 args
const { name, arguments: args } = request.params;
// 判断要调用哪个工具(未来可以扩展多个 if-else 或 switch)
if (name === 'read_file') {
try {
// 核心业务逻辑:读取文件
const content = await fs.readFile(args.path, 'utf-8');
// 返回成功结果(上下文会传给 LLM)
return {
content: [
{ type: 'text', text: content }
]
};
} catch (error) {
// 返回错误信息(标记 isError)
return {
isError: true,
content: [
{ type: 'text', text: error.message }
]
};
}
}
// 未知工具 → 抛出错误
throw new Error(`未知工具: ${name}`);
});
// 5️⃣ 启动服务:建立 stdio 通信连接
async function main() {
// 创建 stdio 传输层(通过 stdin/stdout 通信)
const transport = new StdioServerTransport();
// 将 Server 绑定到传输层,开始监听消息
await server.connect(transport);
// ⚠️ 注意:connect 之后进程会阻塞,持续监听
}
main();
2.2 代码结构一览

三、核心难点:完整调用链路拆解
3.1 一个请求的完整生命周期
这是本文的灵魂部分!我们以用户提问"读取 D 盘的 config.json"为例,逐步拆解每一步发生了什么:

3.2 逐帧拆解:从 CC Prompt 到 LLM Generate
我们来把上面流程图中最核心的这条链路拆解开:
text
rust
cc prompt -> llm -> 选择 fs client -> stdioServerTransport -> stdin -> server -> 执行返回 ->
stdout -> stdioClientTransport -> cc -> llm -> generate
第一步:cc prompt(用户在 Claude Desktop 输入)
javascript
arduino
// 用户在 Claude Desktop 的输入框中敲下:
"帮我读取 D 盘根目录下的 config.json 文件"
此时 Claude Desktop(Host)只是接收了文本,还没有任何处理。
第二步:llm(发送给 AI 模型)
javascript
arduino
// Claude Desktop 将用户消息封装成 API 请求,发送给 Claude API
// 请求中包含了系统提示词(System Prompt),其中注入了 MCP 工具信息
关键点: 系统提示词中会包含类似这样的内容:
text
lua
你是一个 AI 助手,你可以使用以下工具:
- read_file: 读取指定路径的本地文件内容
参数: path (string) - 文件的绝对或相对路径
当用户需要读取文件时,请调用这个工具。
第三步:选择 fs client(LLM 决定调用工具)
javascript
json
// Claude 模型分析后,决定调用 read_file 工具
// 模型输出的不是自然语言,而是函数调用指令:
{
"name": "read_file",
"arguments": {
"path": "D:\config.json"
}
}
这是关键转折点: AI 从"生成文本"模式切换到"调用工具"模式。
第四步:stdioServerTransport(Client 发送请求)
javascript
arduino
// Claude Desktop 将工具调用指令打包成 JSON-RPC 消息
const request = {
jsonrpc: "2.0",
method: "call_tool",
params: {
name: "read_file",
arguments: { path: "D:\config.json" }
},
id: 1
};
// 通过 stdio 写入子进程的 stdin
process.stdin.write(JSON.stringify(request));
第五步:stdin -> server(Server 接收请求)
javascript
dart
// 你的 MCP Server 一直在监听 stdin
// server.connect(transport) 内部启动了消息监听循环
// 当收到消息后,SDK 自动解析 JSON-RPC,识别出 method 是 "call_tool"
// 触发你注册的 CallToolRequestSchema 处理器
server.setRequestHandler(CallToolRequestSchema, async (request) => {
// request.params.name === 'read_file'
// request.params.arguments === { path: 'D:\config.json' }
// 开始执行业务逻辑...
});
第六步:执行返回(Server 执行业务逻辑)
javascript
css
// 你的代码调用 Node.js 的 fs 模块
const content = await fs.readFile('D:\config.json', 'utf-8');
// content = '{ "name": "my-app", "version": "1.0.0" }'
// 将结果打包成 JSON-RPC 响应
const response = {
jsonrpc: "2.0",
result: {
content: [
{ type: "text", text: '{ "name": "my-app", "version": "1.0.0" }' }
]
},
id: 1
};
第七步:stdout(Server 返回结果)
javascript
lua
// Server 通过 stdout 写入响应
process.stdout.write(JSON.stringify(response));
第八步:stdioClientTransport(Client 接收响应)
javascript
rust
// Claude Desktop 监听子进程的 stdout
// 收到响应后,解析 JSON-RPC,提取 result.content
const result = {
content: '{ "name": "my-app", "version": "1.0.0" }'
};
第九步:cc -> llm(Client 将结果交给 LLM)
javascript
json
// Claude Desktop 将工具执行结果作为上下文,再次调用 Claude API
// 这次请求中包含了工具返回的文件内容
const messages = [
{ role: "user", content: "帮我读取 D 盘根目录下的 config.json 文件" },
{ role: "assistant", content: null, tool_calls: [...] },
{ role: "tool", tool_call_id: 1, content: '{ "name": "my-app", "version": "1.0.0" }' }
];
第十步:llm -> generate(LLM 生成最终回答)
javascript
swift
// Claude 拿到文件内容后,开始生成最终回答
// 这次生成的是自然语言文本
const finalAnswer = "我已经读取了 D:\config.json 文件,内容如下:\n\n```json\n{\n "name": "my-app",\n "version": "1.0.0"\n}\n```";
// 显示在 Claude Desktop 的界面上
3.3 完整数据流图


四、重难点深度剖析
4.1 难点一:为什么 Server 实例必须用 MCP SDK 创建?
很多新手会问:"我直接用 Express 写个 API 不行吗?为什么非要搞个 new Server()?"
设计者的考量:
MCP 不是普通的 HTTP API,而是一个协议层面的标准。所有 MCP Server 必须遵循相同的通信规范(JSON-RPC 2.0)、握手流程、错误格式。
如果用 Express:
javascript
less
// ❌ 错误示范:普通 HTTP API
app.get('/read-file', (req, res) => {
res.json({ content: fs.readFileSync(req.query.path) });
});
// 问题:
// 1. AI 不知道有这个 API(没有工具发现机制)
// 2. 调用方式不统一(每个 API 都要单独写调用代码)
// 3. 没有权限控制标准
// 4. 不在 MCP 生态内,无法被 AI 自动发现
而使用 MCP SDK:
javascript
arduino
// ✅ 正确示范:MCP Server
const server = new Server(
{ name: 'my-server', version: '1.0.0' },
{ capabilities: { tools: {} } }
);
// 优势:
// 1. AI 通过 list_tools 自动发现
// 2. 统一的 JSON-RPC 调用方式
// 3. 标准的错误处理
// 4. 开箱即用的协议实现
攻克思路:
把 new Server() 理解为"给你的程序注入 MCP 基因"。SDK 内部已经实现了:
- JSON-RPC 消息解析
- 协议版本协商
- 生命周期管理
- 标准错误格式
你只需要关注业务逻辑(read_file 怎么实现)。
4.2 难点二:ListToolsRequestSchema 和 CallToolRequestSchema 的设计模式
这两个 Handler 看起来很简单,背后其实是经典的请求-路由模式。
为什么分两个处理器?
| 处理器 | 触发时机 | 职责 | 类比 |
|---|---|---|---|
ListToolsRequestSchema |
Client 初始化/询问时 | 展示菜单 | 餐厅菜单 |
CallToolRequestSchema |
Client 具体调用时 | 执行点单 | 厨师做菜 |
为什么这么设计?
这是关注点分离原则的体现:
ListToolsRequestSchema负责元数据管理(有什么工具)CallToolRequestSchema负责业务执行(怎么实现)
这样当你的工具从 1 个扩展到 100 个时,Client 的调用逻辑完全不用改。
4.3 难点三:arguments 为什么要重命名为 args?
javascript
csharp
const { name, arguments: args } = request.params;
很多新手会疑惑:为什么要多此一举?
原因有两个:
arguments是 JavaScript 的保留字(虽然在严格模式下可用,但不是好习惯)- 避免混淆 :
arguments在函数中代表类数组对象,重命名后更清晰
面试可能会问: 如果不用重命名会怎样?
javascript
csharp
// ❌ 错误示范(虽然能跑)
const { name, arguments } = request.params; // 语法警告
// ✅ 正确示范
const { name, arguments: args } = request.params;
其实这个细节透露了设计者对代码健壮性的考量------即使将来 JavaScript 严格模式发生变化,这段代码也不会受影响。
4.4 难点四:为什么使用 stdio 而不是 HTTP?
设计考量:
| 特性 | stdio | HTTP |
|---|---|---|
| 进程通信 | 本地进程间直接通信 | 需要网络协议栈 |
| 端口管理 | 无需管理端口 | 需要处理端口冲突 |
| 防火墙 | 不受影响 | 可能需要配置 |
| 跨平台 | 所有平台支持 | 依赖网络配置 |
| 安全性 | 天然隔离 | 需要额外安全措施 |
最适合的场景: AI 应用和工具在同一台机器上运行时,stdio 是最简单、最可靠的选择。
五、避坑指南(新手必看)
坑 1:在 stdio 模式下使用 console.log()
❌ 错误做法:
javascript
javascript
server.setRequestHandler(CallToolRequestSchema, async (request) => {
console.log('收到请求:', request); // ⚠️ 这会破坏通信!
// ...
});
原因: 在 stdio 模式下,stdout 被用作 JSON-RPC 通信通道。任何非 JSON 格式的输出都会导致 Client 解析失败。
✅ 正确做法:
javascript
go
// 使用 stderr 输出调试信息(不影响通信)
console.error('收到请求:', request);
坑 2:忘记处理路径遍历攻击
❌ 危险代码:
javascript
csharp
const content = await fs.readFile(args.path, 'utf-8'); // 任何路径都能读!
AI 可能被诱导读取敏感文件:
../../../../etc/passwdC:\Windows\System32\drivers\etc\hosts~/.ssh/id_rsa
✅ 安全做法:
javascript
ini
import path from 'path';
const ALLOWED_ROOT = process.env.MCP_ROOT_PATH || process.cwd();
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (name === 'read_file') {
// 1. 解析绝对路径
const realPath = path.resolve(args.path);
const realRoot = path.resolve(ALLOWED_ROOT);
// 2. 校验路径是否在允许范围内
if (!realPath.startsWith(realRoot)) {
return {
isError: true,
content: [{
type: 'text',
text: `访问超出允许范围: ${args.path}`
}]
};
}
// 3. 安全读取
const content = await fs.readFile(realPath, 'utf-8');
// ...
}
});
坑 3:进程意外退出
问题: 如果 main() 函数执行完毕,进程会退出,Server 就不可用了。
javascript
csharp
// ❌ 错误:main 函数执行完就退出
function main() {
const transport = new StdioServerTransport();
server.connect(transport);
// 没有 await,进程立即退出
}
✅ 正确做法:
javascript
csharp
// ✅ 正确:用 async/await 保持进程运行
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
// connect 内部会保持进程运行
}
六、面试高频考点
考点 1:MCP 的三个核心作用是什么?
回答要点:
| 作用 | 具体内容 | 类比 |
|---|---|---|
| ① 统一通信格式 | 规定用 JSON-RPC 2.0 作为消息格式 | 规定大家都说普通话,而不是各说各的方言 |
| ② 统一数据类型 | 用 JSON Schema 定义数据结构 | 规定"苹果"就是那种红色的水果,而不是有人叫它"apple"有人叫它"林檎" |
| ③ 统一交互流程 | 定义完整的生命周期:初始化 → 发现工具 → 调用工具 → 关闭 | 规定社交礼仪:见面先握手,再说话,说完再见 |
加分回答: MCP 不是业务逻辑层,也不是界面层,它是协议层------定义了 Client 和 Server 之间如何沟通的"游戏规则"。
考点 2:MCP 和普通 API 的区别是什么?
回答要点:
- 标准化:MCP 是协议层面的标准,所有实现互通;普通 API 是各自为政
- 工具发现 :MCP 有
list_tools机制,AI 自动发现;普通 API 需要人工文档 - 调用方式:MCP 通过 JSON-RPC 统一调用;普通 API 五花八门(REST、gRPC 等)
- AI 原生:MCP 专为 AI 设计,支持自然语言驱动;普通 API 需要人工编码调用
考点 3:ListToolsRequestSchema 和 CallToolRequestSchema 的处理顺序?
回答要点:
- 初始化阶段 :Client 启动时会先调用
list_tools,获取所有工具定义 - 决策阶段:LLM 根据工具列表和用户需求,决定调用哪个工具
- 执行阶段 :Client 发送
call_tool请求,携带工具名和参数 - 结果返回:Server 执行后返回结果,Client 再转交给 LLM
关键理解: list_tools 是元数据查询,call_tool 是实际操作。先发现,后调用。
七、总结:MCP 的三个核心作用
通过上面的完整代码和调用链路分析,我们可以把 MCP 的核心作用归纳为三个"统一":
| 作用 | 具体内容 | 生活类比 |
|---|---|---|
| ① 统一通信格式 | 规定使用 JSON-RPC 2.0 作为消息格式 | 规定大家都说普通话,而不是各说各的方言 |
| ② 统一数据类型 | 用 JSON Schema 定义工具参数结构 | 规定"苹果"就是那种红色的水果,而不是有人叫它"apple"有人叫它"林檎" |
| ③ 统一交互流程 | 定义完整的生命周期:初始化 → 发现工具 → 调用工具 → 关闭 | 规定社交礼仪:见面先握手,再说话,说完再见 |
对应到我们的代码
javascript
go
// ① 统一通信格式
// Server 和 Client 之间传递的是 JSON-RPC 2.0 消息
// 例如 Client 发来的请求:
{
"jsonrpc": "2.0",
"method": "call_tool",
"params": { "name": "read_file", "arguments": { "path": "test.txt" } },
"id": 1
}
// ② 统一数据类型
// 工具参数用 inputSchema(JSON Schema)定义
inputSchema: {
type: 'object',
properties: {
path: { type: 'string' } // 👈 明确告诉对方:path 必须是字符串
},
required: ['path'] // 👈 告诉对方:path 必须传
}
// ③ 统一交互流程
// 先 list_tools(发现) → 再 call_tool(调用)
server.setRequestHandler(ListToolsRequestSchema, ...); // 第一步:展示菜单
server.setRequestHandler(CallToolRequestSchema, ...); // 第二步:执行点单
回到你的理解
"mcp 的作用是在 client 和 server 之间起作用吧"
完全正确! 🎯 更准确地说:
MCP 是 Client 和 Server 之间的"通信标准"
它不负责具体的业务逻辑(那是 Server 的事),也不负责 UI 和 LLM 调用(那是 Host/Client 的事),它只负责一件事:
"让 Client 和 Server 能无障碍地互相理解和协作"
用一张图来总结 MCP 的定位:

记住一句话:MCP 不是业务逻辑层,也不是界面层,它是"协议层"------定义了 Client 和 Server 之间如何沟通的"游戏规则"。
就像 HTTP 之于 Web 浏览器和服务器,MCP 之于 AI 应用和工具服务。