MCP 入门实战:写一个能读本地文件的极简服务

0 阅读8分钟

前几天折腾 AI IDE 的时候,一直有个特别烦人的痛点:大模型只能跟你聊代码逻辑,没法直接读我本地的项目文件。每次想让它帮我看个配置、改个脚本,都得手动复制一大段内容粘贴进去,文件长了特别折腾。

直到我看到有人提 MCP,说能让大模型直接调用本地工具。我寻思不就是读个文件嘛,应该不难,索性自己动手写个最简单的文件读取 MCP 服务。结果真上手才发现,坑全在细节里,折腾了小半天才跑通。今天顺着我当时的思路捋一遍,省得后面有人跟我一样走弯路。

先搞懂:MCP 到底在中间干了啥

说实话,最开始我对 MCP 的概念特别模糊,以为又是啥新的微服务框架。翻了半天才搞明白,它全称是 Model Context Protocol,说白了就是一套「统一传话标准」。

你可以这么理解:以前大模型想调用本地工具,每个工具都得单独写对接代码,读文件是一套接口,调数据库又是一套,客户端得挨个适配。有了 MCP 之后,不管你背后是什么工具,都按同一套协议跟大模型客户端说话。客户端不用管你工具内部怎么实现的,只要符合 MCP 协议,就能直接用。

我当时整理了一条完整的调用链路,画出来大概是这样:

image.png

换成大白话就是:你跟 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 列表里看到我们的服务了,就像这样:

image.png

当时看到这个绿色开关亮起来,名字旁边还打了对勾,我还以为稳了。结果一试,工具调用直接失败,报路径找不到。

第二个坑:路径问题比想象中麻烦

我当时测试的时候,写的是相对路径,让它读同目录下的 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,跑不通或者有别的理解,欢迎留个言,我踩过的坑说不定能帮你省点时间。