实战派:从零实现一个文件读取 MCP,搞懂协议、通信与 Agent 集成


为什么你需要关心 MCP?

我们正处在 AI Agent 爆发的前夜。LLM 再强大,如果它只能回答你“脑内”的知识,那它就是个昂贵的百科全书。真正的 Agent 必须能调用工具读取外部数据执行具体操作

但问题来了:每个工具都有自己的 API 格式、鉴权方式、输入输出规范。如果每个 Agent 都要为每个工具单独写适配代码,那将是无尽的重复劳动。于是,MCP(Model Context Protocol) 应运而生——它定义了一套标准化的协议,让 LLM 应用(Client)和工具提供者(Server)之间能够即插即用

MCP 就像 AI 世界的 USB-C 接口——统一了连接标准,让工具和智能体可以自由组合。

今天,我们就从零开始,手写一个 文件读取 MCP Server,让你深刻理解 MCP 的工作流程,并能快速将其落地到自己的 Agent 项目中。


MCP 核心三板斧

在动手之前,我们先快速过一遍 MCP 的基本概念(理论点到即止,实战才是王道)。

MCP 基于 JSON-RPC 2.0 通信,定义了三种核心能力:

  1. Tools(工具) :Server 提供给 Client 可调用的函数,类似 OpenAPI 的接口。
  2. Resources(资源) :Server 暴露给 Client 的数据内容,类似文件或数据库查询结果。
  3. Prompts(提示词模板) :Server 提供的预设 Prompt,方便 Client 快速构造对话。

我们的文件读取 Server 只需要用到 Tools——提供一个 read_file 工具,让 LLM 可以调用它来读取本地文件内容。


实战:从零搭建文件读取 MCP Server

1. 初始化项目

mkdir simple-read-mcp
cd simple-read-mcp
npm init -y
npm install @modelcontextprotocol/sdk
npm install -D @types/node typescript

2. 编写 Server 骨架

直接上完整代码(关键点已注释),你甚至可以复制粘贴直接跑起来。

// server.ts
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
    ListToolsRequestSchema,
    CallToolRequestSchema
} from '@modelcontextprotocol/sdk/types.js';
import fs from 'fs/promises';

// 1. 创建 Server 实例,定义名称和版本
const server = new Server(
    { name: 'simple-read-mcp', version: '1.0.0' },
    { capabilities: { tools: {} } }  // 声明我们支持 tools 能力
);

// 2. 处理工具列表请求(Client 启动时会调用)
server.setRequestHandler(ListToolsRequestSchema, async () => ({
    tools: [
        {
            name: 'read_file',
            description: '读取指定路径的本地文件内容',
            inputSchema: {
                type: 'object',
                properties: {
                    path: {
                        type: 'string',
                        description: '文件的绝对路径或相对路径'
                    }
                },
                required: ['path']
            }
        }
    ]
}));

// 3. 处理工具调用请求(LLM 决定调用工具时触发)
server.setRequestHandler(CallToolRequestSchema, async (request) => {
    const { name, arguments: args } = request.params;
    if (name === 'read_file') {
        try {
            const content = await fs.readFile(args.path, 'utf-8');
            // 返回内容作为上下文给 LLM
            return {
                content: [
                    { type: 'text', text: content }
                ]
            };
        } catch (error) {
            return {
                isError: true,
                content: [
                    { type: 'text', text: `读取文件失败: ${error.message}` }
                ]
            };
        }
    }
    throw new Error(`未知工具: ${name}`);
});

// 4. 启动 Server,通过标准输入输出(stdio)与 Client 通信
async function main() {
    const transport = new StdioServerTransport();
    await server.connect(transport);
    console.error('Server started and waiting for requests...');
}

main();

🔍 代码逐段深度解析

光看代码还不够,我们要把每一块的“为什么”讲透。下面我们把 server.ts 拆成四个核心部分,逐一剖析。

第一块:导入与实例化

import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
    ListToolsRequestSchema,
    CallToolRequestSchema
} from '@modelcontextprotocol/sdk/types.js';
import fs from 'fs/promises';

const server = new Server(
    { name: 'simple-read-mcp', version: '1.0.0' },
    { capabilities: { tools: {} } }
);
  • Server 类:这是 SDK 提供的服务端骨架,封装了 JSON‑RPC 消息的解析、路由和生命周期管理。你不需要自己处理底层协议细节,只需注册对应的处理器。
  • 第一个参数 serverInfo:包含服务名称和版本,会暴露给 Client 用于标识和日志记录。
  • 第二个参数 serverOptionscapabilities 用于声明该 Server 支持哪些 MCP 能力。这里我们只写了 tools: {},表示支持工具调用能力(工具列表后续通过 Handler 动态提供)。如果将来还需要提供 Resources 或 Prompts,也要在这里声明(例如 resources: {})。
  • StdioServerTransport:这是通信传输层的实现,通过标准输入输出(stdin/stdout)与父进程(Client)交换 JSON‑RPC 消息。它是当前最通用的方式,适合与 Claude Desktop、命令行 Agent 等集成。你也可以选择 SSEServerTransport 用于 HTTP/SSE 场景,但 stdio 最简单直接。
  • fs/promises:使用 Promise 版本的 fs API,方便在 async 函数中 await

第二块:注册工具列表处理器(ListTools

server.setRequestHandler(ListToolsRequestSchema, async () => ({
    tools: [
        {
            name: 'read_file',
            description: '读取指定路径的本地文件内容',
            inputSchema: {
                type: 'object',
                properties: {
                    path: {
                        type: 'string',
                        description: '文件的绝对路径或相对路径'
                    }
                },
                required: ['path']
            }
        }
    ]
}));
  • setRequestHandler:Server 注册请求处理器的方法。第一个参数是 RequestSchema,这里使用 ListToolsRequestSchema,它对应 MCP 协议中的 tools/list 请求——即 Client 询问“你有哪些可用工具”时触发。

  • 返回值:必须是一个包含 tools 数组的对象。每个工具对象包含:

    • name:工具的唯一标识符,LLM 在调用时会引用它。
    • description:描述工具的用途,LLM 会根据这段文字判断何时调用该工具,所以一定要写清楚(最好包含使用场景示例)。
    • inputSchema:工具参数的 JSON Schema(遵循 JSON Schema Draft‑07)。LLM 会依据此 Schema 生成符合格式的参数对象。这里我们定义 path 为必填的字符串。
  • 注意:这个处理器仅在 Client 初次连接或主动刷新工具列表时调用,不会在每次工具调用时重复执行。返回的工具列表会被 Client 缓存,并传递给 LLM 作为可用工具的上下文。


第三块:注册工具调用处理器(CallTool

server.setRequestHandler(CallToolRequestSchema, async (request) => {
    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 (error) {
            return {
                isError: true,
                content: [
                    { type: 'text', text: `读取文件失败: ${error.message}` }
                ]
            };
        }
    }
    throw new Error(`未知工具: ${name}`);
});
  • CallToolRequestSchema:对应协议中的 tools/call 请求,当 LLM 决定调用某个工具时,Client 会向 Server 发送此请求。

  • 解构参数request.params 中包含 name(工具名)和 arguments(调用参数)。注意这里使用了 arguments: args 的重命名,避免与 JavaScript 关键字冲突。

  • 工具路由:我们通过 if (name === 'read_file') 来判断要执行哪个工具(实际项目中建议用 Map 或 switch 做路由)。

  • 执行与返回

    • 成功时,返回一个 content 数组,其中包含 type: 'text' 的文本内容。这个内容会成为 LLM 的上下文,LLM 会根据它来生成最终回答。
    • 失败时,返回 isError: true,并附带错误信息。这样 Client 可以明确知道工具执行失败,LLM 也会收到错误提示并告知用户。
  • 重要:如果收到未知工具名,应该抛出一个错误,让 Server 自动返回 JSON‑RPC 错误响应,而不是默默忽略。


第四块:启动 Server 与 stdio 传输

async function main() {
    const transport = new StdioServerTransport();
    await server.connect(transport);
    console.error('Server started and waiting for requests...');
}
main();
  • StdioServerTransport 实例化后,会监听进程的 stdin,并将输出写入 stdout。所有 JSON‑RPC 消息都通过这个管道传输。
  • server.connect(transport) :将 Server 与传输层绑定,启动消息循环。此时 Server 开始接收并处理来自 Client 的请求。
  • console.error:这里使用 stderr 输出日志,因为 stdout 被用于协议通信,如果往 stdout 打日志会干扰协议消息。所有调试信息都应该输出到 stderr,这也是 MCP 官方推荐的做法。

通信流程拆解:从用户提问到拿到文件内容

很多同学会好奇,这个 MCP 到底是怎么和 LLM 配合的?笔记里画了个流程图,我用文字再给你讲透:

  1. 用户提问:比如“请帮我读取 /home/user/data.txt 的内容”。
  2. LLM 分析:LLM 意识到需要调用工具,它会从 Server 提供的 tools 列表中找到 read_file
  3. Client 发起调用:Client(比如 Claude Desktop、自定义 Agent)通过 StdioServerTransport 将调用请求写入子进程的 stdin
  4. Server 处理:我们的 Server 监听到 stdin 的 JSON-RPC 请求,解析后执行 fs.readFile,结果返回至 stdout
  5. Client 接收:Client 读取 stdout,将结果(文件内容)拼接回 LLM 的上下文。
  6. LLM 生成最终答案:LLM 根据文件内容,给出用户想要的回答。

金句:MCP 的 stdio 传输就像一根无形的管道,把 LLM 的“意图”和操作系统的“文件系统”连接了起来。


细节打磨:让 Server 更健壮、更安全

1. 参数验证(引入 Zod)

虽然示例中直接使用了 args.path,但生产环境下我们必须校验参数类型和存在性。@modelcontextprotocol/sdk 并不强制验证,我们可以引入 zod 来增强:

npm install zod

然后在处理函数中加入验证:

import { z } from 'zod';

const ReadFileSchema = z.object({
    path: z.string().min(1, '路径不能为空')
});

// 在 CallTool 中:
try {
    const { path } = ReadFileSchema.parse(args);
    const content = await fs.readFile(path, 'utf-8');
    // ...
} catch (error) {
    // 参数验证失败或文件读取失败
    return { isError: true, content: [...] };
}

2. 错误处理的粒度

我们已经捕获了 readFile 的错误,但还可以区分“文件不存在”、“权限不足”、“路径非法”等情况,返回更友好的错误信息,帮助 LLM 更好地向用户解释。

3. 路径安全限制

直接允许任意路径存在安全隐患。你可以在生产环境中增加白名单目录限制,防止读取敏感系统文件。


如何验证你的 MCP 是否能正常工作?

官方提供了 mcp-inspector 工具,可以交互式测试你的 Server:

npx @modelcontextprotocol/inspector node dist/server.js

它会启动一个 Web 界面,让你可以手动列出工具、调用工具并查看返回结果,是调试的利器。


扩展思考:如何将这个 MCP 集成到你的 Agent?

  • 在 Trae 中配置

    找到 Trae 的mcp配置文件,通常在设置里找,在 mcp 中加入我们的配置:

    json

    {
      "mcpServers": {
        "simple-read-mcp": {
          "command": "node",
          "args": [
            "E:\workspace\lgl_ai\ai\mcp\simple-read-mcp\server.js"
          ]
        }
      }
    }
    

    保存后,LLM 就能直接调用 read_file 读取你本地的文件了。
    注意:路径要替换为你实际编译后的 server.js 绝对路径(如果你用 TypeScript,需先编译成 JS)。

    你会发现,一旦你掌握 MCP 的套路,为你的 Agent 增加新能力就像“插拔 U 盘”一样简单。

你会发现,一旦你掌握 MCP 的套路,为你的 Agent 增加新能力就像“插拔 U 盘”一样简单。


总结与展望

今天我们手写了一个文件读取 MCP Server,收获了:

  • MCP 的核心概念:Tools、Resources、Prompts
  • Server 的编写流程:创建实例、注册工具列表、处理调用、启动 stdio 传输
  • 通信机制:stdin/stdout 管道,JSON-RPC 协议
  • 增强安全性:参数验证与错误处理

MCP 的生态正在飞速发展,除了文件读取,你还可以写数据库查询 MCPAPI 调用 MCP代码执行 MCP……只要你的想象力够丰富,LLM 的能力边界就能无限扩展。

最后一句:不会写 MCP 的 Agent 工程师,不是合格的掘金作者。赶紧动手试试吧,把本地文件变成 LLM 的“外挂大脑”!

 

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐