前言
最近 Model Context Protocol(MCP) 彻底带火了「大模型本地工具化」的玩法 ------ 它定义了一套标准化通信协议,让 Claude 等大模型客户端可以直接调用本地服务、读写文件、对接数据库,打破了 LLM 的「信息孤岛」。
很多人上手直接用官方现成的 @modelcontextprotocol/server-filesystem 开箱即用,但很少有人深入理解 MCP 的底层通信逻辑。本文将带你从零手写一个文件读取 MCP 服务:不用封装好的语法糖,基于官方 SDK 原生 API 实现,同时拆解从用户提问到本地服务执行返回的完整链路,彻底搞懂 MCP 的工作原理。
一、MCP 核心认知与架构拆解
1.1 什么是 MCP?
MCP 全称 Model Context Protocol(模型上下文协议) ,是 Anthropic 推出的开源协议。它定义了一套标准的交互规范,让大模型客户端(如 Claude Desktop)和外部工具服务可以标准化通信 ------ 本质就是「大模型的插件协议」。
它的核心价值是解耦:大模型不用关心工具怎么实现,工具也不用适配不同的大模型,只要遵循 MCP 协议,就能无缝对接。
1.2 为什么要手写 MCP 服务?
官方的 server-filesystem 虽然开箱即用,但功能完全固定,无法自定义:
- 不能新增向量检索、文件解析、批量处理等专属工具
- 不能自定义权限校验、日志埋点、访问白名单
- 不能对接自有业务系统、私有数据库
手写 MCP 服务可以完全掌控工具逻辑,灵活拓展 LLM 的本地能力。
1.3 一次完整调用的全链路
本文实现的 MCP 服务,完整调用流程如下:
plaintext
arduino
用户提问 → Claude LLM分析意图 → 匹配工具 → MCP Client
→ StdioServerTransport → 进程stdin → 本地MCP Server
→ 执行业务逻辑 → 结果写入stdout → Stdio传输层回传
→ Claude Client → LLM基于结果生成最终回答
二、项目初始化与环境准备
2.1 技术栈说明
- Node.js(ESM 模块化模式)
@modelcontextprotocol/sdk:官方 MCP 协议 SDK,封装通信层、协议解析fs/promises:Node 原生文件异步 API- Zod:Schema 参数校验(可选,本文先讲原生实现逻辑)
2.2 项目搭建
bash
运行
perl
# 创建项目目录
mkdir simple-read-mcp
cd simple-read-mcp
# 初始化package.json
pnpm init -y
# 安装核心依赖
pnpm add @modelcontextprotocol/sdk zod
2.3 开启 ESM 模式
修改 package.json,新增 "type": "module",让 Node 识别 ES Module 语法:
json
perl
{
"name": "simple-read-mcp",
"version": "1.0.0",
"type": "module",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"zod": "^4.4.3"
}
}
三、核心代码实现:手写文件读取 MCP 服务
我们不用 SDK 封装的 McpServer 语法糖,直接使用原生 Server 类,手动注册请求处理器,彻底理解协议层逻辑。
3.1 导入核心依赖
创建入口文件 server.js,导入所需类和 API:
js
运行
javascript
// MCP服务核心控制器:管理服务生命周期、能力声明、请求分发
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
// Stdio传输层:基于标准输入输出实现进程间通信
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
// 协议请求类型常量:用于匹配不同的请求事件
import {
ListToolsRequestSchema,
CallToolRequestSchema
} from '@modelcontextprotocol/sdk/types.js';
// Node原生文件异步API
import fs from 'fs/promises';
每个模块的职责:
Server:服务核心类,负责协议解析、请求路由、能力校验StdioServerTransport:传输层实现,MCP 支持 stdio、SSE 等多种传输方式,本地服务最常用 stdioListToolsRequestSchema/CallToolRequestSchema:请求类型标识,对应 MCP 协议的tools/list和tools/call两个核心方法
3.2 实例化 MCP 服务
创建服务实例,声明元信息和能力:
js
运行
arduino
const server = new Server(
// 服务元信息:名称、版本
{ name: 'simple-read-mcp', version: '1.0.0' },
// 服务能力声明:告诉客户端本服务支持工具调用
{ capabilities: { tools: {} } }
);
⚠️ 关键注意点:
capabilities.tools必须显式声明,字段是复数 tools,写错会导致服务不识别工具能力,后续注册 handler 直接报错- 这是 MCP 的「能力协商」机制:客户端连接后先获取服务能力,再决定可以调用哪些功能
3.3 注册工具列表处理器
MCP 客户端连接服务后,第一件事就是发送 tools/list 请求,获取服务提供的所有工具列表。
js
运行
php
// 处理工具列表查询请求
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'read_file',
description: '读取指定路径的本地文件内容',
inputSchema: {
type: 'object',
properties: {
path: {
type: 'string',
description: '文件的绝对或相对路径'
}
},
required: ['path']
}
}
]
};
});
返回格式严格遵循 MCP 协议规范:
- 外层返回对象包含
tools数组 - 每个工具包含
name(工具名)、description(工具描述,LLM 靠这个判断是否调用)、inputSchema(参数 JSON Schema) inputSchema是 LLM 生成参数的依据,描述越准确,调用成功率越高
3.4 注册工具调用处理器
当 LLM 决定调用工具时,客户端会发送 tools/call 请求,我们在这里实现文件读取的业务逻辑。
js
运行
javascript
// 处理工具调用请求
server.setRequestHandler(CallToolRequestSchema, async (request) => {
// 解构获取工具名和参数,arguments重命名为args避免和JS关键字冲突
const { name, arguments: args } = request.params;
// 根据工具名分发逻辑
if (name === 'read_file') {
try {
// 执行文件读取
const content = await fs.readFile(args.path, 'utf-8');
// 按协议格式返回结果
return {
content: [
{ type: 'text', text: content }
]
};
} catch (err) {
// 错误场景返回,标记isError
return {
isError: true,
content: [
{ type: 'text', text: `读取失败:${err.message}` }
]
};
}
}
// 未知工具抛出错误
throw new Error(`未知工具:${name}`);
});
核心细节:
arguments是 JS 保留关键字,解构时必须重命名为args,否则语法报错- 返回格式必须是
{ content: [{ type: 'text', text: 'xxx' }] },这是 MCP 协议规定的标准返回结构 - 错误场景要设置
isError: true,客户端会识别为调用失败
3.5 启动服务:绑定传输层
最后创建 Stdio 传输通道,将服务和传输层绑定,启动监听:
js
运行
javascript
async function main() {
// 创建stdio传输实例
const transport = new StdioServerTransport();
// 服务绑定传输通道,开始监听stdin的请求
await server.connect(transport);
// ⚠️ MCP服务日志必须用console.error,因为console.log会输出到stdout,污染协议报文
console.error("✅ simple-read-mcp 文件读取服务已启动");
}
// 启动并捕获全局异常
main().catch((err) => {
console.error("❌ 服务启动失败:", err);
process.exit(1);
});
⚠️ 重中之重:MCP 通过 stdio 通信时,
stdout是协议报文的专属通道,所有业务日志、调试信息必须打印到stderr(也就是console.error)。如果用console.log打印日志,会打乱 JSON 报文结构,直接导致通信崩溃。
四、MCP 全链路通信原理解析
现在我们把完整的调用流程拆解开,看透每一步到底发生了什么:
4.1 阶段 1:连接与能力协商
- Claude Desktop 启动时,根据配置执行
node server.js,创建子进程运行我们的 MCP 服务 - 服务启动后,通过 stdio 通道和客户端完成协议握手,交换服务元信息和能力声明
- 客户端确认服务支持
tools能力,准备后续调用
4.2 阶段 2:获取工具列表
- 客户端发送
tools/list请求,写入子进程的 stdin StdioServerTransport监听到 stdin 数据,解析为 MCP 请求对象Server类根据请求方法匹配到ListToolsRequestSchema的处理器- 处理器返回工具列表,通过 stdout 回传给客户端
- Claude 拿到工具列表,LLM 就知道自己可以调用
read_file工具了
4.3 阶段 3:工具调用全流程
对应最开始的链路:
- 用户提问:比如「帮我读取一下项目里 package.json 的内容」
- LLM 意图分析 :Claude 分析用户需求,判断需要调用
read_file工具 - 参数生成 :LLM 根据
inputSchema生成参数{ "path": "./package.json" } - 客户端发起调用 :Claude Client 发送
tools/call请求,通过 stdio 写入子进程 stdin - 服务执行逻辑 :MCP Server 匹配到
CallToolRequestSchema处理器,执行fs.readFile读取文件 - 结果返回:读取结果按协议格式封装,写入 stdout 回传给客户端
- LLM 生成回答:Claude 拿到文件内容,基于内容生成自然语言回答返回给用户
这就是一次完整的 MCP 工具调用闭环,所有通信都遵循 MCP 协议规范,传输层基于 stdio 实现,业务逻辑完全由我们自定义。
五、Claude 客户端配置与测试
5.1 配置 Claude Desktop
打开 Claude Desktop 的配置文件(Windows 路径:%APPDATA%\Claude\claude_desktop_config.json),新增 MCP 服务配置:
json
json
{
"mcpServers": {
"simple-read-mcp": {
"command": "node",
"args": [
"C:\workSpace\ysw_ai\ai\mcp\simple-read-mcp\server.js"
]
}
}
}
⚠️ Windows 注意点:
- 路径必须用双反斜杠
\转义- 路径必须是绝对路径,指向我们的 server.js 入口文件
5.2 验证效果
- 完全关闭 Claude Desktop 再重新打开
- 打开聊天窗口,输入「读取你当前目录的 package.json 文件」
- 观察 Claude 是否成功调用工具并返回文件内容
如果出现 list tools failed,优先排查:
- 服务代码语法错误,手动执行
node server.js验证 capabilities.tools字段写错(单复数)- ListTools 处理器没有 return 返回值
- 配置文件路径错误
六、拓展优化方向
6.1 增加安全目录限制
当前实现可以读取任意路径文件,存在安全风险,可以增加白名单目录限制:
js
运行
arduino
import path from 'path';
// 允许访问的根目录
const ALLOWED_ROOT = path.resolve('./workspace');
// 读取前校验路径
const fullPath = path.resolve(args.path);
if (!fullPath.startsWith(ALLOWED_ROOT)) {
return {
isError: true,
content: [{ type: 'text', text: '无权访问该目录外的文件' }]
};
}
6.2 新增更多工具
基于同样的模式,可以新增写文件、列出目录、搜索文件等工具,只需要在 tools 数组新增配置,并在调用处理器里增加分支逻辑。
6.3 接入 Zod 参数校验
手动写 JSON Schema 容易出错,可以用 Zod 定义 Schema 再转成 JSON Schema,提升开发效率和参数校验可靠性。
七、总结
本文从零实现了一个极简的文件读取 MCP 服务,核心收获:
- MCP 的本质是一套标准化的「LLM - 工具」通信协议,解耦了大模型和外部工具
- Stdio 是本地 MCP 服务最常用的传输方式,基于进程标准输入输出实现通信
- MCP 服务的核心是「能力声明 + 请求处理器 + 传输层绑定」三段式结构
- 手写 MCP 服务可以完全自定义业务逻辑,灵活拓展 LLM 的本地能力