写在前面:之前学了 MCP 的理论------它是 AI 界的 USB-C。今天老师带我们"手写"了一个 MCP Server!当我自己写代码,在 Claude Code 里注册一个"读文件"的工具,然后跟 Claude 说"帮我读一下桌面的 readme.md",它真的去读了文件,然后把内容告诉我------那一刻我整个人都惊了。原来 Cursor 和 Claude Code 能"看"我的文件,背后就是这样一个自定义的 MCP Server。
一、MCP Server 开发前的准备
1.1 需要用到的库
看 package.json:
json
{
"type": "module",
"dependencies": {
"zod": "^4.4.3",
"@modelcontextprotocol/sdk": "^1.29.0"
}
}
两个依赖:
| 库 | 作用 | 比喻 |
|---|---|---|
| @modelcontextprotocol/sdk | MCP 官方 SDK,处理通信协议 | 电话基站,负责信号的收发 |
| zod | 数据验证,确保参数格式正确 | 门禁系统,检查来访者身份 |
zod 是用来做数据验证(Schema 校验)的。 比如你要接收一个 path 参数,zod 可以确保它必须是字符串,不能是数字或 null。
老师说:
"zod------数据验证 Schema。
@modelcontextprotocol/sdk------协议的 SDK,通信部分。"
1.2 开发流程总览
老师说:
"CC(Claude Code)Prompt → LLM → 分析 → 选中 fs client → StdioServerTransport → stdio → Server → 返回执行 → stdout → StdioServerTransport → CC → LLM → Generate。"
整个流程是这样的:
arduino
你问:"帮我读一下桌面的 readme.md"
↓
Claude Code 收到指令,分析需要调用 read-file 工具
↓
Claude Code 通过 MCP Client → 经过 StdioServerTransport(标准输入输出通道)
↓
MCP Server 收到请求,执行 fs.readFile
↓
读取结果通过 StdioServerTransport 传回
↓
Claude Code 拿到结果,LLM 生成回答
↓
"桌面的 readme.md 内容是......"
StdioServerTransport(标准输入输出传输) 是本地通信的通道。MCP Server 通过 stdio(标准输入输出)和 Claude Code 通信。
二、手写 MCP Server:从零到一
2.1 实例化 Server
javascript
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: {} } }
);
new Server() 创建了一个 MCP 服务器实例:
name: 'simple-read-mcp':服务器名字,告诉 Claude Code "我叫 simple-read-mcp"。version: '1.0.0':版本号。capabilities: { tools: {} }:声明这个 MCP 提供"工具"能力。
这就像你开了一家小店,挂了个招牌------"simple-read-mcp,专治各种读文件需求"。
2.2 注册工具列表(ListToolsRequestSchema)
javascript
// 处理 agent 请求------列出所有工具
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'read-file',
description: '读取指定路径的文本本地文件内容',
inputSchema: {
type: 'object',
properties: {
path: {
type: 'string',
description: '文件的绝对或相对路径',
},
},
required: ['path']
}
}
]
}));
这段代码在做什么?
老师说:
"ListToolRequestSchema------MCP 事件之一,列出所有工具。"
当 Claude Code 连接到这个 MCP Server 时,它会先问:"你有什么工具?"
这个 Server 回答:"我只有一个工具------read-file,功能是读取本地文件,需要一个 path 参数。"
inputSchema 就是工具的"说明书":
name: 'read-file':工具的名字。description: '读取指定路径的文本本地文件内容':告诉 LLM 这个工具能干什么。inputSchema:告诉 LLM 调用时需要传什么参数。
2.3 执行工具(CallToolRequestSchema)
javascript
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 (err) {
return {
isError: true,
content: [
{ type: 'text', text: `读取文件失败: ${err.message}` }
]
}
}
}
throw new Error(`未知工具: ${name}`);
});
这段代码才是真正的"干活"部分:
- 接收指令:LLM 说"我要调用 read-file 工具,参数是 path='./readme.md'"。
- 执行 :
fs.readFile(args.path, 'utf-8')------真正的文件读取操作。 - 返回结果:把文件内容包装成 MCP 协议要求的格式返回。
- 错误处理 :如果文件不存在或读取失败,返回
isError: true和错误信息。
返回的格式:
javascript
{
content: [
{ type: 'text', text: '文件的完整内容......' }
]
}
2.4 启动 Server
javascript
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
}
main();
StdioServerTransport 是本地通信的"隧道":
- Server 通过标准输入(stdin)监听 Claude Code 发来的指令。
- 执行完工具后,通过标准输出(stdout)把结果传回去。
老师说:
"连接本地 transport,打通连接隧道。"
这就像你和 Claude Code 之间牵了一根电话线------它通过这根线把指令传给你,你把结果通过这根线传回去。
三、完整流程演示
当你配置好这个 MCP Server 后,在 Claude Code 里输入:
帮我读取桌面上叫 readme.md 的文件
会发生什么?
| 步骤 | 谁在做 | 发生了什么 |
|---|---|---|
| 1 | 你 | 输入指令 |
| 2 | LLM | 分析指令 → 我需要调用 read-file 工具 |
| 3 | MCP Client | 发送 tool_call 请求给 Server |
| 4 | Stdio | 标准输入通道传输数据 |
| 5 | 你的 Server | 执行 fs.readFile('~/Desktop/readme.md') |
| 6 | Stdio | 标准输出通道传回结果 |
| 7 | MCP Client | 把文件内容传给 LLM |
| 8 | LLM | 读取内容 → 生成回答 |
| 9 | 你 | 看到文件内容 |
核心:第 5 步是你自己写的代码在执行! 不是 Claude 在读取文件,是你写的 Server 在读取文件。
四、MCP 的错误处理
看代码中的错误处理:
javascript
try {
const content = await fs.readFile(args.path, 'utf-8');
return { content: [{ type: 'text', text: content }] };
} catch (err) {
return {
isError: true,
content: [{ type: 'text', text: `读取文件失败: ${err.message}` }]
}
}
如果文件存在: 返回文件内容,LLM 帮你分析。
如果文件不存在: 返回错误信息,LLM 会告诉你"这个文件读取失败了"。
五、总结:手写 MCP = 给你的 AI 装一个"自定义器官"
| 知识点 | 说明 |
|---|---|
| MCP SDK | @modelcontextprotocol/sdk,处理通信协议 |
| zod | 数据验证 Schema |
| StdioServerTransport | 标准输入输出传输通道 |
| ListToolsRequestSchema | 列出可用工具 |
| CallToolRequestSchema | 调用指定工具 |
new Server() |
创建 MCP 服务器实例 |
fs.readFile |
真正执行文件操作 |
| 错误处理 | 文件不存在或读写失败时返回 isError |
手写 MCP Server 的本质:把一段 JavaScript 函数,变成 AI 能理解和调用的"工具"。
你说的每一句话,都经过了"你的代码 → Claude Code → LLM → 你的 Server → 你的代码"的完整链路。AI 看起来能"读文件",实际上是你的代码在执行,AI 只是负责"决定什么时候调用"。
写在最后
今天最大的收获,是亲手写了一个 MCP Server。以前觉得 Cursor 能"看"我的文件很神奇,现在知道了------那不是我 AI 的能力,是有人写了一个 MCP Server,把文件操作暴露给了 AI。手写 MCP 就是给 AI 装"自定义器官"------你想让 AI 能做什么,就自己写一个 MCP Server。
下次面试官问你:"MCP Server 是怎么工作的?"
你可以淡定地说:
"MCP Server 通过
@modelcontextprotocol/sdk实现。它注册了两个核心事件处理------ListToolsRequestSchema用于向客户端声明可用的工具列表和参数 Schema,CallToolRequestSchema用于接收客户端发来的工具调用请求并真正执行操作。通过StdioServerTransport建立本地通信隧道。LLM 负责决定何时调用工具,MCP Server 负责真正执行,两者通过标准协议解耦。这种架构让 AI 可以标准化地调用外部能力。"
然后看着面试官满意的表情,心里默念:这波,又稳了。
本文所有代码示例均来自课堂学习资料,真实可运行。