作者:互联网 时间: 2026-07-07 09:12:11
想象一个场景:你正在用 AI 写代码,想让它帮你重构一个项目。你把代码片段粘贴过去,AI 说"这个函数看起来有点问题,但我需要看一下完整的上下文"。你又粘贴了更多代码,AI 又说"我还需要看一下相关的配置文件"。来来回回粘贴了十几轮,你累得不行,AI 也始终没法获得完整的项目视角。

这不就是"盲人摸象"吗?AI 就像那个摸象的盲人,每次只能摸到一小块,永远没法看到全貌。
有没有一种办法,让 AI 能自己"走过去"看看整个项目?就像给它一双眼睛,让它能自己去读取文件?
答案是:MCP(Model Context Protocol)。
在讲 MCP 之前,先讲一个小故事。
你小时候有没有玩过"传话游戏"?第一个人说一句话,传给第二个人,第二个人再传给第三个人……最后那句话可能已经面目全非。AI 和外部世界的交互,本质上也是一个传话游戏——用户把信息传给 AI,AI 再通过用户去执行操作。
MCP 就是要打破这个传话链。它让 AI 可以直接和工具对话,跳过中间的"传话人"。
MCP 是一个协议,它定义了 AI 如何调用外部工具。简单来说,它解决了三个问题:
┌─────────────────────────────────────────────────────────────────┐│ AI / LLM││┌─────────────┐│││ 思考模块 │ ← 决定是否调用工具││└──────┬──────┘││ │ ││ ▼ ││┌─────────────┐│││ 工具调用 │ ← 生成工具调用请求││└──────┬──────┘││ │ ││ ▼ │├────────┼────────────────────────────────────────────────────────┤││ CC (Client Coordinator)│││ ┌──────────────────────────────────────┐ │││ │ 工具发现 → 参数校验 → 协议转换 → 分发│ │││ └──────────────────────────────────────┘ ││││├────────┼────────────────────────────────────────────────────────┤││││▼││┌─────────────────┐ ┌─────────────────┐ ┌───────────┐│││MCP Server 1 │ │MCP Server 2 │ │...││││(文件读取)│ │(网络搜索)│ │ │││└────────┬────────┘ └────────┬────────┘ └─────┬─────┘││ │ ││ ││ ▼ ▼▼ ││ ┌─────────┐┌─────────┐ ┌─────────┐││ │文件系统││ 网络 │ │ 其他 │││ └─────────┘└─────────┘ └─────────┘│└─────────────────────────────────────────────────────────────────┘
这个架构的精妙之处在于 解耦:
我们今天要拆解的 simple-read-mcp,就是这个架构中的一个"螺丝钉"——一个负责读取本地文件的 MCP Server。它虽小,但五脏俱全,是理解 MCP 协议的绝佳切入点。
在看核心代码之前,先看看项目的基础设施。一个 Node.js 项目就像一栋房子,package.json 是房产证,mcp.json 是门牌。
{"name": "simple-read-mcp","version": "1.0.0","type": "module","dependencies": {"@modelcontextprotocol/sdk": "^1.29.0","zod": "^4.4.3","zod-to-json-schema": "^3.25.2"}}
这里有三个关键依赖:
McpServer 和 StdioServerTransport注意 "type": "module" 这一行,它告诉 Node.js 使用 ESM 模块系统,所以我们能使用 import 语法。
{"mcpServers":{"simple-read-mcp":{"version":"1.0.0","description":"一个简单的读取文件的工具","command":"node","args":["C:UsersDMYXDesktopworkspacelesson_zpaimcpsimple-read-mcpserver.js"]}}}
这个文件是给 CC(Client Coordinator) 看的,它告诉 CC:
simple-read-mcp1.0.0一个简单的读取文件的工具(AI 通过这个描述来理解工具用途)node 命令执行 server.js为什么需要这个文件?
想象一下,你电脑上可能装了很多 MCP 服务——文件读取、网络搜索、数据库查询……CC 需要知道它们在哪里、怎么启动、能做什么。mcp.json 就是这个"服务注册表"。
终于到了最核心的部分。这 40 行代码,浓缩了一个完整 MCP 服务的所有精华。让我们逐行剖析。
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';
这四个 import 各司其职:
| 模块 | 职责 |
|---|---|
McpServer | MCP 服务的核心类,负责注册工具和处理请求 |
StdioServerTransport | 标准输入输出传输层,负责进程间通信 |
z | zod 验证库,定义参数校验规则 |
fs/promises | Node.js 文件系统的 Promise API,异步读取文件 |
为什么选 Stdio?
进程间通信有很多方式:网络 socket、命名管道、共享内存……为什么选最简单的标准输入输出?
const server = new McpServer({name: 'simple-read-mcp',version: '1.0.0'});
这一步很简单,但背后有讲究:
name 和 version 必须和 mcp.json 中的定义一致,否则 CC 无法匹配server.tool("read_file","读取指定路径的本地文件内容",{path: z.string().describe("文件的绝对或相对路径")},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}` }]};}});
这是整个文件最关键的部分,server.tool() 方法接收四个参数:
"read_file")这是工具的唯一标识符,AI 通过这个名称来调用工具。命名建议使用下划线分隔的小写字母,语义清晰。
"读取指定路径的本地文件内容")这是给 AI 看的"使用说明",AI 根据这个描述来判断什么时候该调用这个工具。描述的质量直接影响 AI 的调用决策,写得越好,AI 越知道什么时候该用。
{ path: z.string().describe(...) })这是新版 SDK 的一大亮点——直接用 zod schema 定义参数,不需要手动写 JSON Schema。
{path: z.string().describe("文件的绝对或相对路径")}
z.string() 表示 path 必须是字符串类型。.describe() 方法给参数添加描述,AI 通过这个描述来理解参数的含义。
zod 是怎么工作的?
当 AI 调用工具时,传入的参数会经过 zod 的校验:
path 不是字符串,会自动返回错误path 参数,会自动返回错误async ({ path }) => { ... })这是工具的实际执行逻辑,接收校验后的参数,返回执行结果。
执行流程解析:
AI 请求 → CC 校验参数 → server.tool() 接收 → 执行函数调用││▼▼zod 验证失败fs.readFile()││▼▼返回错误信息 返回文件内容│▼CC 返回给 AI
返回值格式:
成功时返回:
{content: [{ type: "text", text: content }]}
失败时返回:
{isError: true,content: [{ type: "text", text: `读取文件失败:${err.message}` }]}
为什么用 content: [{ type: "text", text: ... }] 这种格式?
这是 MCP 协议定义的统一返回格式,支持多种内容类型:
| 类型 | 用途 | 示例 |
|---|---|---|
"text" | 文本内容 | 文件内容、错误信息 |
"image" | 图片内容 | 图片的 base64 编码 |
"file" | 文件引用 | 文件路径和元信息 |
这种设计让工具可以返回多种类型的结果,AI 可以根据类型进行不同的处理。
async function main() {const transport = new StdioServerTransport();await server.connect(transport);console.error("MCP read_file 服务已启动(stdio模式)");}main().catch(console.error);
这部分代码启动服务并等待请求:
StdioServerTransport 实例 —— 建立标准输入输出通道server.connect(transport) —— 让服务器绑定到这个通道上console.error 输出启动信息 —— 为什么不用 console.log? 因为 console.log 的输出会被 CC 当作协议数据读取,所以日志必须写到 stderr完整数据流图:
┌─────────────────────────────────────────────────────────────────────┐│ 用户提问 ││ "帮我读一下 package.json"│└─────────────────────────────────────────────────────────────────────┘│▼┌─────────────────────────────────────────────────────────────────────┐│ AI / LLM││分析问题 → 决定调用 read_file 工具 → 生成调用请求 ││{ name: "read_file", arguments: { path: "package.json" } } │└─────────────────────────────────────────────────────────────────────┘│▼┌─────────────────────────────────────────────────────────────────────┐│ CC ││读取 mcp.json → 找到 simple-read-mcp → 启动进程 → 发送请求到 stdin │└─────────────────────────────────────────────────────────────────────┘│▼┌─────────────────────────────────────────────────────────────────────┐│ simple-read-mcp││StdioServerTransport 接收请求 → McpServer 处理││→ 执行 read_file 函数 → fs.readFile("package.json")││→ 返回结果到 stdout│└─────────────────────────────────────────────────────────────────────┘│▼┌─────────────────────────────────────────────────────────────────────┐│ CC ││接收 stdout 输出 → 解析结果 → 返回给 AI│└─────────────────────────────────────────────────────────────────────┘│▼┌─────────────────────────────────────────────────────────────────────┐│ AI / LLM││接收工具返回结果 → 整合到回答中 → 返回给用户│└─────────────────────────────────────────────────────────────────────┘
现在我们已经拆解完代码了,但是有一个问题必须严肃讨论:这个工具安全吗?
让我们仔细看看这个工具的实现:
async ({ path }) => {const content = await fs.readFile(path, 'utf-8');return { content: [{ type: "text", text: content }] };};
这里没有任何路径检查,用户(或者说 AI)可以读取系统上的任意文件:
read_file("/etc/passwd") —— 读取系统用户信息read_file("C:WindowsSystem32configSAM") —— 读取 Windows 密码哈希read_file("~/.ssh/id_rsa") —— 读取 SSH 私钥这是一个巨大的安全漏洞! 如果这个工具被恶意使用,后果不堪设想。
一个生产级的文件读取工具应该至少包含以下安全措施:
只允许读取特定目录下的文件:
const ALLOWED_DIRS = [process.cwd(),'/path/to/project'];async ({ path }) => {const resolvedPath = path.resolve(path);const isAllowed = ALLOWED_DIRS.some(dir => resolvedPath.startsWith(path.resolve(dir)));if (!isAllowed) {return {isError: true,content: [{ type: "text", text: `访问被拒绝:${path}` }]};}// ... 后续逻辑};
防止读取超大文件导致内存溢出:
const MAX_FILE_SIZE = 1024 * 1024; // 1MBasync ({ path }) => {const stat = await fs.stat(path);if (stat.size > MAX_FILE_SIZE) {return {isError: true,content: [{ type: "text", text: `文件过大:${stat.size} bytes` }]};}// ... 后续逻辑};
只允许读取特定类型的文件:
const ALLOWED_EXTENSIONS = ['.txt', '.md', '.json', '.js', '.ts'];async ({ path }) => {const ext = path.extname(path);if (!ALLOWED_EXTENSIONS.includes(ext)) {return {isError: true,content: [{ type: "text", text: `不支持的文件类型:${ext}` }]};}// ... 后续逻辑};
这个项目是一个教学示例,它的目的是展示 MCP 的核心概念,而不是提供一个生产级的工具。在实际使用中,你必须根据自己的需求添加安全防护。
如果你想把这个简单的示例变成一个真正可用的工具,可以考虑以下改进方向:
一个文件读取服务不应该只有 read_file:
list_files —— 列出目录内容write_file —— 写入文件(需要更严格的安全控制)read_binary_file —— 读取二进制文件get_file_info —— 获取文件元信息对于大文件,可以返回流式结果,而不是一次性读取整个文件:
async ({ path }) => {const stream = fs.createReadStream(path);return {content: [{ type: "stream", stream }]};};
对于频繁读取的文件,可以添加缓存,减少磁盘 I/O:
const cache = new Map();const CACHE_TTL = 60 * 1000; // 1分钟async ({ path }) => {const now = Date.now();const cached = cache.get(path);if (cached && now - cached.timestamp < CACHE_TTL) {return { content: [{ type: "text", text: cached.content }] };}const content = await fs.readFile(path, 'utf-8');cache.set(path, { content, timestamp: now });return { content: [{ type: "text", text: content }] };};
回到开头那个"盲人摸象"的故事。MCP 就像是给盲人装上了第三只手——不仅能摸,还能"拿"、能"看"、能"操作"。
今天我们用 40 行代码搭建了一个简单的文件读取工具,这只是 MCP 能力的冰山一角。想象一下:
这些服务组合在一起,就能让 AI 拥有无限的能力。唯一的限制就是你的想象力。
最后,留给你一个问题:如果你要为 AI 打造一个"超级工具箱",你会添加哪些工具?为什么?