MCP 入门实战:写一个能读本地文件的极简服务
MCP这个概念刚出来的时候,很多人第一反应是:这又是个什么新框架?但仔细一看,其实没那么玄乎。它全称是Model Context Protocol,说白了就是一套「统一传话标准」。
打个比方:以前大模型想调用本地工具,每个工具都得单独写对接代码——读文件是一套接口,调数据库又是一套,客户端得挨个适配。有了MCP之后,不管你背后是什么工具,都按同一套协议跟大模型客户端说话。客户端不用管你工具内部怎么实现的,只要符合MCP协议,就能直接用。
整理一条完整的调用链路,画出来大概是这样:
换成大白话就是:你跟AI说“帮我看看本地的server.js写了啥”,大模型一琢磨,这事得调用读文件工具,就让客户端给MCP服务发个指令。服务收到指令去读文件,读完把内容传回来,大模型再拿着内容给你解释。整个过程里,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配置起来其实不复杂:找到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服务的工作目录,不是你脚本所在的目录,而是客户端的启动目录。用相对路径的话,它会从客户端的工作目录开始找,自然找不到文件。
解决办法也简单:要么参数一律传绝对路径,要么在代码里把相对路径转成绝对路径。图省事的话,直接在参数描述里备注建议用绝对路径,大模型一般也会传完整路径。
提醒
再挖一层: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,跑不通或者有别的理解,欢迎交流,踩过的坑说不定能帮你省点时间。