前几天折腾 AI IDE 的时候,一直有个特别烦人的痛点:大模型只能跟你聊代码逻辑,没法直接读我本地的项目文件。每次想让它帮我看个配置、改个脚本,都得手动复制一大段内容粘贴进去,文件长了特别折腾。
直到我看到有人提 MCP,说能让大模型直接调用本地工具。我寻思不就是读个文件嘛,应该不难,索性自己动手写个最简单的文件读取 MCP 服务。结果真上手才发现,坑全在细节里,折腾了小半天才跑通。今天顺着我当时的思路捋一遍,省得后面有人跟我一样走弯路。
先搞懂:MCP 到底在中间干了啥
说实话,最开始我对 MCP 的概念特别模糊,以为又是啥新的微服务框架。翻了半天才搞明白,它全称是 Model Context Protocol,说白了就是一套「统一传话标准」。
你可以这么理解:以前大模型想调用本地工具,每个工具都得单独写对接代码,读文件是一套接口,调数据库又是一套,客户端得挨个适配。有了 MCP 之后,不管你背后是什么工具,都按同一套协议跟大模型客户端说话。客户端不用管你工具内部怎么实现的,只要符合 MCP 协议,就能直接用。
我当时整理了一条完整的调用链路,画出来大概是这样:
换成大白话就是:你跟 AI 说 “帮我看看本地的 server.js 写了啥”,大模型一琢磨,这事得调用读文件工具,就让客户端给 MCP 服务发个指令。服务收到指令去读文件,读完把内容传回来,大模型再拿着内容给你解释。整个过程里,MCP 服务就像个专职跑腿的,只负责按规矩收消息、干活、回消息。
动手写:不到 50 行的核心代码
原理搞懂了,写起来其实很快。核心就两个东西:一个是官方的 SDK 帮我们处理协议通信,另一个是 zod 用来做参数校验。
先装依赖
新建个文件夹,初始化 npm,装两个包就行:
npm install @modelcontextprotocol/sdk zod
一个是官方的 MCP SDK,帮我们搞定所有协议层的脏活累活;另一个 zod 是用来定义工具参数的校验规则,保证传进来的参数格式是对的。
服务主体代码
新建个 server.js,一步步来写。
首先引入依赖,创建服务实例:
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';
// 初始化服务,起个名字,标个版本号
const server = new McpServer({
name: 'simple-read-mcp',
version: '1.0.0'
});
然后注册我们的核心工具 ——read_file,用来读取本地文件。这里要定义工具的名字、描述,还有参数的 schema:
server.tool(
'read_file',
'读取指定路径的本地文件内容',
{
// 用zod定义参数:path是字符串类型,加个描述方便大模型理解
path: z.string().describe('文件的绝对或相对路径')
},
async (args) => {
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 }]
};
}
}
);
最后写启动函数,用 stdio 的方式连接服务:
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
}
main();
我踩的第一个坑:返回格式不能乱写
说真的,第一次写的时候我根本没在意返回格式。我寻思不就是返回文件内容嘛,直接 return content 不就完了?结果配置完之后,大模型能看到这个工具,调用也成功了,但就是拿不到内容,一直报解析错误。
我翻了半天 SDK 的类型定义才反应过来,MCP 对返回结果有严格的格式要求。不能直接返回字符串,必须是一个带 content 数组的对象,数组里每一项要声明 type 和对应的内容。错误返回也要加上 isError: true,不然大模型分不清是正常结果还是报错。
错误的写法大概长这样,别学我:
// ❌ 错误写法:直接返回字符串,客户端解析不了
return content;
客户端配置:怎么让 Trae 认出这个服务
代码写完了,直接 node 运行是没用的,它本身就是个靠标准输入输出干活的程序,得配合支持 MCP 的客户端用。我用的是 Trae,配置起来其实不复杂。
找到 Trae 的 MCP 配置文件,加上这么一段:
{
"mcpServers": {
"simple-read-mcp": {
"command": "node",
"args": [
"D:/workspace/xjl_ai/ai/mcp/simple-read-mcp/server.js"
]
}
}
}
意思就是告诉客户端:我有个叫 simple-read-mcp 的服务,你用 node 命令去跑这个 js 文件,它会通过 stdio 跟你通信。
配置完重启一下客户端,正常的话就能在 MCP 列表里看到我们的服务了,就像这样:
当时看到这个绿色开关亮起来,名字旁边还打了对勾,我还以为稳了。结果一试,工具调用直接失败,报路径找不到。
第二个坑:路径问题比想象中麻烦
我当时测试的时候,写的是相对路径,让它读同目录下的 test.txt,结果一直报 ENOENT,说文件不存在。
我愣了半天,明明文件就在那,怎么会找不到?后来才想明白:MCP 服务的工作目录,不是你脚本所在的目录,而是客户端的启动目录。用相对路径的话,它会从客户端的工作目录开始找,自然找不到文件。
解决办法也简单,要么参数一律传绝对路径,要么在代码里把相对路径转成绝对路径。我图省事,直接在参数描述里备注了建议用绝对路径,反正大模型一般也会传完整路径。
提醒如果你的 MCP 服务要处理路径,尽量统一用绝对路径。相对路径的工作目录基准很容易出问题,排查起来特别头疼。
再挖一层:stdio 通信到底是怎么跑的
跑通之后我就好奇,为啥 MCP 默认要用 stdio 通信?起个 HTTP 服务不是更熟悉吗?
后来看了下 StdioServerTransport 的源码,其实特别简单:它就是监听了进程的 stdin,收到消息就按 MCP 协议解析,然后转给服务逻辑;要返回结果的时候,就把消息序列化写到 stdout 里。
说白了,这个 MCP 服务本质上就是个普通的命令行程序。客户端启动它,然后俩人通过命令行的标准输入输出传话,不用占端口,不用跨域,本地跑起来特别轻量。
这么设计的好处也很明显:
- 跨平台,Windows、Mac、Linux 都能用,标准输入输出是系统自带的
- 安全,只能本地调用,不会暴露到网络上
- 简单,开发者不用管网络通信那些事,专心写工具逻辑就行
当然也有局限,就是只能本地用,没法远程调用。不过对于本地工具来说,完全够用了。
还有几个踩过的小坑
除了上面两个大坑,还有几个细节我也踩了,顺手提一嘴。
第一个是配置文件的 JSON 格式,多一个逗号少一个括号都不行。我一开始复制配置的时候,末尾多了个逗号,服务死活加载不出来,控制台也没个明确报错,愣找了十分钟才发现。
第二个是参数描述一定要写清楚。zod 里的 describe 不是写着玩的,大模型就是靠这段描述来理解这个参数该传啥。你写得越清楚,大模型调用的时候越不容易传错参数。
第三个是别在代码里随便 console.log。因为服务是靠 stdout 传消息的,你随便打印的东西会混进协议消息里,导致客户端解析失败。要调试的话,建议写到日志文件里,或者用 stderr 输出。
最后说两句
整个搞下来,核心代码其实不到 50 行,但前前后后踩坑花了不少时间。回头看,最关键的其实就三点:
第一,MCP 不是什么复杂的新技术,它就是一套统一的调用协议,让大模型客户端和各种工具能按同一种规矩说话。
第二,最简单的 MCP 服务,就是注册工具 + stdio 连接,不用搞服务端框架,不用写接口,比写个接口还简单。
第三,返回格式、路径、控制台输出这些细节最容易踩坑,官方文档写得又比较散,新手很容易在这上面卡很久。
当然了,我这个只是最极简的 demo,真要用到生产环境还差得远。比如安全问题,随便让大模型读本地文件风险很高,最好加个路径白名单,只允许读指定目录下的文件;还有权限控制、异常处理这些,都得补全。
但作为入门理解 MCP 的工作原理,这个小 demo 足够了。我一开始把它想得特别高大上,真动手拆解开才发现,核心逻辑朴素得很。
如果你也在折腾 MCP,跑不通或者有别的理解,欢迎留个言,我踩过的坑说不定能帮你省点时间。