用 100 行代码手搓一个 MCP Server,让 LLM 直接读你本地文件
实战派:从零实现一个文件读取 MCP,搞懂协议、通信与 Agent 集成
为什么你需要关心 MCP?
我们正处在 AI Agent 爆发的前夜。LLM 再强大,如果它只能回答你“脑内”的知识,那它就是个昂贵的百科全书。真正的 Agent 必须能调用工具、读取外部数据、执行具体操作。
但问题来了:每个工具都有自己的 API 格式、鉴权方式、输入输出规范。如果每个 Agent 都要为每个工具单独写适配代码,那将是无尽的重复劳动。于是,MCP(Model Context Protocol) 应运而生——它定义了一套标准化的协议,让 LLM 应用(Client)和工具提供者(Server)之间能够即插即用。
MCP 就像 AI 世界的 USB-C 接口——统一了连接标准,让工具和智能体可以自由组合。
今天,我们就从零开始,手写一个 文件读取 MCP Server,让你深刻理解 MCP 的工作流程,并能快速将其落地到自己的 Agent 项目中。
MCP 核心三板斧
在动手之前,我们先快速过一遍 MCP 的基本概念(理论点到即止,实战才是王道)。
MCP 基于 JSON-RPC 2.0 通信,定义了三种核心能力:
- Tools(工具) :Server 提供给 Client 可调用的函数,类似 OpenAPI 的接口。
- Resources(资源) :Server 暴露给 Client 的数据内容,类似文件或数据库查询结果。
- Prompts(提示词模板) :Server 提供的预设 Prompt,方便 Client 快速构造对话。
我们的文件读取 Server 只需要用到 Tools——提供一个 read_file 工具,让 LLM 可以调用它来读取本地文件内容。
实战:从零搭建文件读取 MCP Server
1. 初始化项目
mkdir simple-read-mcp
cd simple-read-mcp
npm init -y
npm install @modelcontextprotocol/sdk
npm install -D @types/node typescript
2. 编写 Server 骨架
直接上完整代码(关键点已注释),你甚至可以复制粘贴直接跑起来。
// server.ts
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
ListToolsRequestSchema,
CallToolRequestSchema
} from '@modelcontextprotocol/sdk/types.js';
import fs from 'fs/promises';
// 1. 创建 Server 实例,定义名称和版本
const server = new Server(
{ name: 'simple-read-mcp', version: '1.0.0' },
{ capabilities: { tools: {} } } // 声明我们支持 tools 能力
);
// 2. 处理工具列表请求(Client 启动时会调用)
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'read_file',
description: '读取指定路径的本地文件内容',
inputSchema: {
type: 'object',
properties: {
path: {
type: 'string',
description: '文件的绝对路径或相对路径'
}
},
required: ['path']
}
}
]
}));
// 3. 处理工具调用请求(LLM 决定调用工具时触发)
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (name === 'read_file') {
try {
const content = await fs.readFile(args.path, 'utf-8');
// 返回内容作为上下文给 LLM
return {
content: [
{ type: 'text', text: content }
]
};
} catch (error) {
return {
isError: true,
content: [
{ type: 'text', text: `读取文件失败: ${error.message}` }
]
};
}
}
throw new Error(`未知工具: ${name}`);
});
// 4. 启动 Server,通过标准输入输出(stdio)与 Client 通信
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('Server started and waiting for requests...');
}
main();
🔍 代码逐段深度解析
光看代码还不够,我们要把每一块的“为什么”讲透。下面我们把 server.ts 拆成四个核心部分,逐一剖析。
第一块:导入与实例化
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
ListToolsRequestSchema,
CallToolRequestSchema
} from '@modelcontextprotocol/sdk/types.js';
import fs from 'fs/promises';
const server = new Server(
{ name: 'simple-read-mcp', version: '1.0.0' },
{ capabilities: { tools: {} } }
);
Server类:这是 SDK 提供的服务端骨架,封装了 JSON‑RPC 消息的解析、路由和生命周期管理。你不需要自己处理底层协议细节,只需注册对应的处理器。- 第一个参数
serverInfo:包含服务名称和版本,会暴露给 Client 用于标识和日志记录。 - 第二个参数
serverOptions:capabilities用于声明该 Server 支持哪些 MCP 能力。这里我们只写了tools: {},表示支持工具调用能力(工具列表后续通过 Handler 动态提供)。如果将来还需要提供 Resources 或 Prompts,也要在这里声明(例如resources: {})。 StdioServerTransport:这是通信传输层的实现,通过标准输入输出(stdin/stdout)与父进程(Client)交换 JSON‑RPC 消息。它是当前最通用的方式,适合与 Claude Desktop、命令行 Agent 等集成。你也可以选择SSEServerTransport用于 HTTP/SSE 场景,但 stdio 最简单直接。fs/promises:使用 Promise 版本的 fs API,方便在 async 函数中await。
第二块:注册工具列表处理器(ListTools)
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'read_file',
description: '读取指定路径的本地文件内容',
inputSchema: {
type: 'object',
properties: {
path: {
type: 'string',
description: '文件的绝对路径或相对路径'
}
},
required: ['path']
}
}
]
}));
-
setRequestHandler:Server 注册请求处理器的方法。第一个参数是RequestSchema,这里使用ListToolsRequestSchema,它对应 MCP 协议中的tools/list请求——即 Client 询问“你有哪些可用工具”时触发。 -
返回值:必须是一个包含
tools数组的对象。每个工具对象包含:name:工具的唯一标识符,LLM 在调用时会引用它。description:描述工具的用途,LLM 会根据这段文字判断何时调用该工具,所以一定要写清楚(最好包含使用场景示例)。inputSchema:工具参数的 JSON Schema(遵循 JSON Schema Draft‑07)。LLM 会依据此 Schema 生成符合格式的参数对象。这里我们定义path为必填的字符串。
-
注意:这个处理器仅在 Client 初次连接或主动刷新工具列表时调用,不会在每次工具调用时重复执行。返回的工具列表会被 Client 缓存,并传递给 LLM 作为可用工具的上下文。
第三块:注册工具调用处理器(CallTool)
server.setRequestHandler(CallToolRequestSchema, async (request) => {
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 (error) {
return {
isError: true,
content: [
{ type: 'text', text: `读取文件失败: ${error.message}` }
]
};
}
}
throw new Error(`未知工具: ${name}`);
});
-
CallToolRequestSchema:对应协议中的tools/call请求,当 LLM 决定调用某个工具时,Client 会向 Server 发送此请求。 -
解构参数:
request.params中包含name(工具名)和arguments(调用参数)。注意这里使用了arguments: args的重命名,避免与 JavaScript 关键字冲突。 -
工具路由:我们通过
if (name === 'read_file')来判断要执行哪个工具(实际项目中建议用 Map 或 switch 做路由)。 -
执行与返回:
- 成功时,返回一个
content数组,其中包含type: 'text'的文本内容。这个内容会成为 LLM 的上下文,LLM 会根据它来生成最终回答。 - 失败时,返回
isError: true,并附带错误信息。这样 Client 可以明确知道工具执行失败,LLM 也会收到错误提示并告知用户。
- 成功时,返回一个
-
重要:如果收到未知工具名,应该抛出一个错误,让 Server 自动返回 JSON‑RPC 错误响应,而不是默默忽略。
第四块:启动 Server 与 stdio 传输
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('Server started and waiting for requests...');
}
main();
StdioServerTransport实例化后,会监听进程的stdin,并将输出写入stdout。所有 JSON‑RPC 消息都通过这个管道传输。server.connect(transport):将 Server 与传输层绑定,启动消息循环。此时 Server 开始接收并处理来自 Client 的请求。console.error:这里使用stderr输出日志,因为stdout被用于协议通信,如果往stdout打日志会干扰协议消息。所有调试信息都应该输出到stderr,这也是 MCP 官方推荐的做法。
通信流程拆解:从用户提问到拿到文件内容
很多同学会好奇,这个 MCP 到底是怎么和 LLM 配合的?笔记里画了个流程图,我用文字再给你讲透:
- 用户提问:比如“请帮我读取
/home/user/data.txt的内容”。 - LLM 分析:LLM 意识到需要调用工具,它会从 Server 提供的
tools列表中找到read_file。 - Client 发起调用:Client(比如 Claude Desktop、自定义 Agent)通过
StdioServerTransport将调用请求写入子进程的stdin。 - Server 处理:我们的 Server 监听到
stdin的 JSON-RPC 请求,解析后执行fs.readFile,结果返回至stdout。 - Client 接收:Client 读取
stdout,将结果(文件内容)拼接回 LLM 的上下文。 - LLM 生成最终答案:LLM 根据文件内容,给出用户想要的回答。
金句:MCP 的 stdio 传输就像一根无形的管道,把 LLM 的“意图”和操作系统的“文件系统”连接了起来。
细节打磨:让 Server 更健壮、更安全
1. 参数验证(引入 Zod)
虽然示例中直接使用了 args.path,但生产环境下我们必须校验参数类型和存在性。@modelcontextprotocol/sdk 并不强制验证,我们可以引入 zod 来增强:
npm install zod
然后在处理函数中加入验证:
import { z } from 'zod';
const ReadFileSchema = z.object({
path: z.string().min(1, '路径不能为空')
});
// 在 CallTool 中:
try {
const { path } = ReadFileSchema.parse(args);
const content = await fs.readFile(path, 'utf-8');
// ...
} catch (error) {
// 参数验证失败或文件读取失败
return { isError: true, content: [...] };
}
2. 错误处理的粒度
我们已经捕获了 readFile 的错误,但还可以区分“文件不存在”、“权限不足”、“路径非法”等情况,返回更友好的错误信息,帮助 LLM 更好地向用户解释。
3. 路径安全限制
直接允许任意路径存在安全隐患。你可以在生产环境中增加白名单目录限制,防止读取敏感系统文件。
如何验证你的 MCP 是否能正常工作?
官方提供了 mcp-inspector 工具,可以交互式测试你的 Server:
npx @modelcontextprotocol/inspector node dist/server.js
它会启动一个 Web 界面,让你可以手动列出工具、调用工具并查看返回结果,是调试的利器。
扩展思考:如何将这个 MCP 集成到你的 Agent?
-
在 Trae 中配置
找到 Trae 的mcp配置文件,通常在设置里找,在
mcp中加入我们的配置:json
{ "mcpServers": { "simple-read-mcp": { "command": "node", "args": [ "E:\workspace\lgl_ai\ai\mcp\simple-read-mcp\server.js" ] } } }保存后,LLM 就能直接调用
read_file读取你本地的文件了。
注意:路径要替换为你实际编译后的server.js绝对路径(如果你用 TypeScript,需先编译成 JS)。你会发现,一旦你掌握 MCP 的套路,为你的 Agent 增加新能力就像“插拔 U 盘”一样简单。
你会发现,一旦你掌握 MCP 的套路,为你的 Agent 增加新能力就像“插拔 U 盘”一样简单。
总结与展望
今天我们手写了一个文件读取 MCP Server,收获了:
- MCP 的核心概念:Tools、Resources、Prompts
- Server 的编写流程:创建实例、注册工具列表、处理调用、启动 stdio 传输
- 通信机制:stdin/stdout 管道,JSON-RPC 协议
- 增强安全性:参数验证与错误处理
MCP 的生态正在飞速发展,除了文件读取,你还可以写数据库查询 MCP、API 调用 MCP、代码执行 MCP……只要你的想象力够丰富,LLM 的能力边界就能无限扩展。
最后一句:不会写 MCP 的 Agent 工程师,不是合格的掘金作者。赶紧动手试试吧,把本地文件变成 LLM 的“外挂大脑”!
更多推荐
所有评论(0)