从零手写文件读取 MCP 服务:一文吃透 Model Context Protocol 全链路通信原理

前言

最近 Model Context Protocol(MCP) 彻底带火了「大模型本地工具化」的玩法 ------ 它定义了一套标准化通信协议,让 Claude 等大模型客户端可以直接调用本地服务、读写文件、对接数据库,打破了 LLM 的「信息孤岛」。

很多人上手直接用官方现成的 @modelcontextprotocol/server-filesystem 开箱即用,但很少有人深入理解 MCP 的底层通信逻辑。本文将带你从零手写一个文件读取 MCP 服务:不用封装好的语法糖,基于官方 SDK 原生 API 实现,同时拆解从用户提问到本地服务执行返回的完整链路,彻底搞懂 MCP 的工作原理。


一、MCP 核心认知与架构拆解

1.1 什么是 MCP?

MCP 全称 Model Context Protocol(模型上下文协议) ,是 Anthropic 推出的开源协议。它定义了一套标准的交互规范,让大模型客户端(如 Claude Desktop)和外部工具服务可以标准化通信 ------ 本质就是「大模型的插件协议」。

它的核心价值是解耦:大模型不用关心工具怎么实现,工具也不用适配不同的大模型,只要遵循 MCP 协议,就能无缝对接。

1.2 为什么要手写 MCP 服务?

官方的 server-filesystem 虽然开箱即用,但功能完全固定,无法自定义:

  • 不能新增向量检索、文件解析、批量处理等专属工具
  • 不能自定义权限校验、日志埋点、访问白名单
  • 不能对接自有业务系统、私有数据库

手写 MCP 服务可以完全掌控工具逻辑,灵活拓展 LLM 的本地能力。

1.3 一次完整调用的全链路

本文实现的 MCP 服务,完整调用流程如下:

plaintext

arduino 复制代码
用户提问 → Claude LLM分析意图 → 匹配工具 → MCP Client
→ StdioServerTransport → 进程stdin → 本地MCP Server
→ 执行业务逻辑 → 结果写入stdout → Stdio传输层回传
→ Claude Client → LLM基于结果生成最终回答

二、项目初始化与环境准备

2.1 技术栈说明

  • Node.js(ESM 模块化模式)
  • @modelcontextprotocol/sdk:官方 MCP 协议 SDK,封装通信层、协议解析
  • fs/promises:Node 原生文件异步 API
  • Zod:Schema 参数校验(可选,本文先讲原生实现逻辑)

2.2 项目搭建

bash

运行

perl 复制代码
# 创建项目目录
mkdir simple-read-mcp
cd simple-read-mcp

# 初始化package.json
pnpm init -y

# 安装核心依赖
pnpm add @modelcontextprotocol/sdk zod

2.3 开启 ESM 模式

修改 package.json,新增 "type": "module",让 Node 识别 ES Module 语法:

json

perl 复制代码
{
  "name": "simple-read-mcp",
  "version": "1.0.0",
  "type": "module",
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.29.0",
    "zod": "^4.4.3"
  }
}

三、核心代码实现:手写文件读取 MCP 服务

我们不用 SDK 封装的 McpServer 语法糖,直接使用原生 Server 类,手动注册请求处理器,彻底理解协议层逻辑。

3.1 导入核心依赖

创建入口文件 server.js,导入所需类和 API:

js

运行

javascript 复制代码
// MCP服务核心控制器:管理服务生命周期、能力声明、请求分发
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
// Stdio传输层:基于标准输入输出实现进程间通信
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
// 协议请求类型常量:用于匹配不同的请求事件
import {
    ListToolsRequestSchema,
    CallToolRequestSchema
} from '@modelcontextprotocol/sdk/types.js';

// Node原生文件异步API
import fs from 'fs/promises';

每个模块的职责:

  • Server:服务核心类,负责协议解析、请求路由、能力校验
  • StdioServerTransport:传输层实现,MCP 支持 stdio、SSE 等多种传输方式,本地服务最常用 stdio
  • ListToolsRequestSchema/CallToolRequestSchema:请求类型标识,对应 MCP 协议的 tools/listtools/call 两个核心方法

3.2 实例化 MCP 服务

创建服务实例,声明元信息和能力:

js

运行

arduino 复制代码
const server = new Server(
    // 服务元信息:名称、版本
    { name: 'simple-read-mcp', version: '1.0.0' },
    // 服务能力声明:告诉客户端本服务支持工具调用
    { capabilities: { tools: {} } }
);

⚠️ 关键注意点:

  1. capabilities.tools 必须显式声明,字段是复数 tools,写错会导致服务不识别工具能力,后续注册 handler 直接报错
  2. 这是 MCP 的「能力协商」机制:客户端连接后先获取服务能力,再决定可以调用哪些功能

3.3 注册工具列表处理器

MCP 客户端连接服务后,第一件事就是发送 tools/list 请求,获取服务提供的所有工具列表。

js

运行

php 复制代码
// 处理工具列表查询请求
server.setRequestHandler(ListToolsRequestSchema, async () => {
    return {
        tools: [
            {
                name: 'read_file',
                description: '读取指定路径的本地文件内容',
                inputSchema: {
                    type: 'object',
                    properties: {
                        path: {
                            type: 'string',
                            description: '文件的绝对或相对路径'
                        }
                    },
                    required: ['path']
                }
            }
        ]
    };
});

返回格式严格遵循 MCP 协议规范:

  • 外层返回对象包含 tools 数组
  • 每个工具包含 name(工具名)、description(工具描述,LLM 靠这个判断是否调用)、inputSchema(参数 JSON Schema)
  • inputSchema 是 LLM 生成参数的依据,描述越准确,调用成功率越高

3.4 注册工具调用处理器

当 LLM 决定调用工具时,客户端会发送 tools/call 请求,我们在这里实现文件读取的业务逻辑。

js

运行

javascript 复制代码
// 处理工具调用请求
server.setRequestHandler(CallToolRequestSchema, async (request) => {
    // 解构获取工具名和参数,arguments重命名为args避免和JS关键字冲突
    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) {
            // 错误场景返回,标记isError
            return {
                isError: true,
                content: [
                    { type: 'text', text: `读取失败:${err.message}` }
                ]
            };
        }
    }

    // 未知工具抛出错误
    throw new Error(`未知工具:${name}`);
});

核心细节:

  1. arguments 是 JS 保留关键字,解构时必须重命名为 args,否则语法报错
  2. 返回格式必须是 { content: [{ type: 'text', text: 'xxx' }] },这是 MCP 协议规定的标准返回结构
  3. 错误场景要设置 isError: true,客户端会识别为调用失败

3.5 启动服务:绑定传输层

最后创建 Stdio 传输通道,将服务和传输层绑定,启动监听:

js

运行

javascript 复制代码
async function main() {
    // 创建stdio传输实例
    const transport = new StdioServerTransport();
    // 服务绑定传输通道,开始监听stdin的请求
    await server.connect(transport);
    // ⚠️ MCP服务日志必须用console.error,因为console.log会输出到stdout,污染协议报文
    console.error("✅ simple-read-mcp 文件读取服务已启动");
}

// 启动并捕获全局异常
main().catch((err) => {
    console.error("❌ 服务启动失败:", err);
    process.exit(1);
});

⚠️ 重中之重:MCP 通过 stdio 通信时,stdout 是协议报文的专属通道,所有业务日志、调试信息必须打印到 stderr(也就是 console.error)。如果用 console.log 打印日志,会打乱 JSON 报文结构,直接导致通信崩溃。


四、MCP 全链路通信原理解析

现在我们把完整的调用流程拆解开,看透每一步到底发生了什么:

4.1 阶段 1:连接与能力协商

  1. Claude Desktop 启动时,根据配置执行 node server.js,创建子进程运行我们的 MCP 服务
  2. 服务启动后,通过 stdio 通道和客户端完成协议握手,交换服务元信息和能力声明
  3. 客户端确认服务支持 tools 能力,准备后续调用

4.2 阶段 2:获取工具列表

  1. 客户端发送 tools/list 请求,写入子进程的 stdin
  2. StdioServerTransport 监听到 stdin 数据,解析为 MCP 请求对象
  3. Server 类根据请求方法匹配到 ListToolsRequestSchema 的处理器
  4. 处理器返回工具列表,通过 stdout 回传给客户端
  5. Claude 拿到工具列表,LLM 就知道自己可以调用 read_file 工具了

4.3 阶段 3:工具调用全流程

对应最开始的链路:

  1. 用户提问:比如「帮我读取一下项目里 package.json 的内容」
  2. LLM 意图分析 :Claude 分析用户需求,判断需要调用 read_file 工具
  3. 参数生成 :LLM 根据 inputSchema 生成参数 { "path": "./package.json" }
  4. 客户端发起调用 :Claude Client 发送 tools/call 请求,通过 stdio 写入子进程 stdin
  5. 服务执行逻辑 :MCP Server 匹配到 CallToolRequestSchema 处理器,执行 fs.readFile 读取文件
  6. 结果返回:读取结果按协议格式封装,写入 stdout 回传给客户端
  7. LLM 生成回答:Claude 拿到文件内容,基于内容生成自然语言回答返回给用户

这就是一次完整的 MCP 工具调用闭环,所有通信都遵循 MCP 协议规范,传输层基于 stdio 实现,业务逻辑完全由我们自定义。


五、Claude 客户端配置与测试

5.1 配置 Claude Desktop

打开 Claude Desktop 的配置文件(Windows 路径:%APPDATA%\Claude\claude_desktop_config.json),新增 MCP 服务配置:

json

json 复制代码
{
  "mcpServers": {
    "simple-read-mcp": {
      "command": "node",
      "args": [
        "C:\workSpace\ysw_ai\ai\mcp\simple-read-mcp\server.js"
      ]
    }
  }
}

⚠️ Windows 注意点:

  1. 路径必须用双反斜杠 \ 转义
  2. 路径必须是绝对路径,指向我们的 server.js 入口文件

5.2 验证效果

  1. 完全关闭 Claude Desktop 再重新打开
  2. 打开聊天窗口,输入「读取你当前目录的 package.json 文件」
  3. 观察 Claude 是否成功调用工具并返回文件内容

如果出现 list tools failed,优先排查:

  • 服务代码语法错误,手动执行 node server.js 验证
  • capabilities.tools 字段写错(单复数)
  • ListTools 处理器没有 return 返回值
  • 配置文件路径错误

六、拓展优化方向

6.1 增加安全目录限制

当前实现可以读取任意路径文件,存在安全风险,可以增加白名单目录限制:

js

运行

arduino 复制代码
import path from 'path';
// 允许访问的根目录
const ALLOWED_ROOT = path.resolve('./workspace');

// 读取前校验路径
const fullPath = path.resolve(args.path);
if (!fullPath.startsWith(ALLOWED_ROOT)) {
    return {
        isError: true,
        content: [{ type: 'text', text: '无权访问该目录外的文件' }]
    };
}

6.2 新增更多工具

基于同样的模式,可以新增写文件、列出目录、搜索文件等工具,只需要在 tools 数组新增配置,并在调用处理器里增加分支逻辑。

6.3 接入 Zod 参数校验

手动写 JSON Schema 容易出错,可以用 Zod 定义 Schema 再转成 JSON Schema,提升开发效率和参数校验可靠性。


七、总结

本文从零实现了一个极简的文件读取 MCP 服务,核心收获:

  1. MCP 的本质是一套标准化的「LLM - 工具」通信协议,解耦了大模型和外部工具
  2. Stdio 是本地 MCP 服务最常用的传输方式,基于进程标准输入输出实现通信
  3. MCP 服务的核心是「能力声明 + 请求处理器 + 传输层绑定」三段式结构
  4. 手写 MCP 服务可以完全自定义业务逻辑,灵活拓展 LLM 的本地能力
相关推荐
Imchendiana1 小时前
《狂人日记NO.9》— 前后端一把梭,我的全栈实录
前端·后端
学渣超1 小时前
记一次分布式事务数据不一致的排查之旅:从超时到索引,层层剥茧
java·后端·架构
逍遥运德1 小时前
前端工程化-03:包管理NPM-package.json 和 package-lock.json 详解
前端·前端框架·前端工程化
逗逗豆巴吧1 小时前
WPS 加载项中实现当前文档直接上传
前端·javascript·ecmascript 6
慢功夫1 小时前
💡【调试】按下 F5 后,mini-vscode 到底做了什么
前端·visual studio code
妙码生花1 小时前
从 PHP 到 AI + Golang,程序员自救转型手记(二十一):网络请求封装优化
前端·javascript·vue.js
用户11481867894841 小时前
普通Fetch、Fetch流式、Axios、SSE:谁才是流式输出的正确操作?
前端·javascript
奇奇怪怪的1 小时前
多模态 RAG:图片与表格检索
前端
犇驫聊AI1 小时前
Claude Code 用了大半年才悟出来的 6 个技巧,第 3 个直接省一半 Token
前端·claude