手写一个文件读写 MCP Server:让大模型“触摸”你的硬盘

手写一个文件读写 MCP Server:让大模型"触摸"你的硬盘

摘要:MCP 让大模型能够调用外部工具。本文从零搭建一个文件读写 MCP Server,通过 Zod 定义参数、StdioServerTransport 建立通信通道,并加入路径安全限制。读完你会理解 MCP 的完整开发流程。

📑 目录

  • MCP Server 是什么?一个为 LLM 提供工具的"服务员"
  • 项目初始化:依赖与配置
  • 核心代码:注册 read_file 和 write_file 工具
  • 我们使用的 MCP SDK:版本与能力
  • Zod 的作用:为什么不用手写 JSON Schema?
  • StdioServerTransport:MCP 的通信通道
  • 安全第一:路径限制与危险操作保护
  • 测试与调试:MCP Inspector
  • 开发流程总结
  • 互动讨论

MCP Server 是什么?一个为 LLM 提供工具的"服务员"

MCP(Model Context Protocol)是 Anthropic 在 2024 年底推出的协议,目标是让 AI 模型能以统一的方式访问外部工具和数据。MCP Server 就是承载这些工具的服务端程序

想象一下:大模型就像一位"话很多但手很笨"的顾问,它能说会道,但无法操作你的电脑。MCP Server 就是它的"手脚"------当 LLM 需要读取文件、查询数据库、发邮件时,它通过 MCP 协议向 Server 发出请求,Server 执行操作后返回结果。

本次实战的目标:开发一个文件读写 MCP Server,让 LLM 能够读取和写入本地硬盘上的指定文件。

项目初始化:依赖与配置

环境要求

工具 版本要求
Node.js >= 20.11.0
npm / pnpm 最新稳定版

安装依赖

bash

perl 复制代码
mkdir my-filesystem-mcp-server
cd my-filesystem-mcp-server
npm init -y
pnpm i @modelcontextprotocol/sdk zod
pnpm i -D typescript @types/node
npx tsc --init

四个核心依赖各司其职:

  • @modelcontextprotocol/sdk:MCP 协议的 SDK,封装了 Server 创建、通信等底层细节。
  • zod:数据验证库,用于定义工具参数的 Schema。
  • fs/promisespath:Node.js 内置模块,用于文件读写和路径解析。
  • typescript@types/node:TypeScript 开发环境,提供类型支持。

MCP SDK:与协议对话的"翻译官"

@modelcontextprotocol/sdk 是 MCP 官方提供的 JavaScript/TypeScript 软件开发工具包。它封装了 MCP 协议的通信细节,让你不用关心 JSON-RPC 消息格式、不用手动处理 stdin/stdout 数据流,只需要聚焦于"注册什么工具"和"工具做什么"。

SDK 提供了两个核心模块:

  • McpServer :MCP 服务端的"大脑"。负责注册工具、管理生命周期、处理来自 Client 的请求。你的所有工具都通过 server.tool() 注册到这里。
  • StdioServerTransport:MCP 服务端的"嘴巴和耳朵"。负责实际的数据传输------从 stdin 读取指令,通过 stdout 返回结果。

有了 SDK,你不需要从头实现 MCP 协议解析。你只需要告诉 McpServer "我有一个工具叫 read_file",SDK 会自动将工具声明转换为 MCP 协议能理解的 JSON-RPC 格式,并通过 Transport 发送给 Client。

核心代码:注册 read_file 和 write_file 工具

完整的 server.js 实现了两个工具:read_filewrite_file

1. 导入与路径安全设置

javascript

javascript 复制代码
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';
import path from 'path';

const ALLOWED_DIR = path.resolve(process.cwd());

function resolveSafePath(inputPath) {
  const resolved = path.resolve(ALLOWED_DIR, inputPath);
  if (!resolved.startsWith(ALLOWED_DIR + path.sep) && resolved !== ALLOWED_DIR) {
    throw new Error(`安全限制:禁止访问项目目录外的文件!`);
  }
  return resolved;
}

ALLOWED_DIR 设置为当前工作目录,所有文件操作被限制在这个目录内。resolveSafePath 函数将用户输入的相对路径解析为绝对路径,并检查是否在允许范围内。

fs/promises:让 Node.js 操作文件系统

fs 是 Node.js 内置的文件系统(File System) 模块,提供了与操作系统文件系统交互的全部能力------创建、读取、修改、删除文件和目录。

代码中使用的是 fs/promises,这是 fs 模块的 Promise 版本。对比两种写法:

javascript

javascript 复制代码
// 传统回调写法(容易陷入回调地狱)
const fs = require('fs');
fs.readFile('/path/to/file', 'utf-8', (err, data) => {
  if (err) throw err;
  console.log(data);
});

// Promise 版本(可以用 await)
import fs from 'fs/promises';
const content = await fs.readFile('/path/to/file', 'utf-8');

在 MCP Server 中,工具处理函数是异步的(async),使用 await fs.readFile()await fs.writeFile() 可以让代码像同步一样顺序执行,可读性更好。

本次实现用到的两个核心方法:

方法 作用 参数
fs.readFile(path, encoding) 读取文件内容 文件路径、编码格式(如 'utf-8'
fs.writeFile(path, content, encoding) 写入文件内容 文件路径、要写入的内容、编码格式

path.resolve()path.sep 则来自 path 模块,用于处理不同操作系统下的路径差异(Windows 用 ``,Linux/macOS 用 /),保证跨平台兼容。

2. 创建 MCP Server 实例

javascript

arduino 复制代码
const server = new McpServer({
  name: 'simple-read-mcp',
  version: '1.0.0',
});

3. 注册 read_file 工具

javascript

javascript 复制代码
server.tool(
  'read_file',
  `读取项目目录下的文件内容(受限:只能读取 ${ALLOWED_DIR} 下的文件)`,
  {
    path: z.string().describe('相对于项目的文件路径,例如 "server.js"')
  },
  async ({ path: inputPath }) => {
    try {
      const safePath = resolveSafePath(inputPath);
      const content = await fs.readFile(safePath, 'utf-8');
      return { content: [{ type: 'text', text: content }] };
    } catch (err) {
      return {
        isError: true,
        content: [{ type: 'text', text: `文件读取失败: ${err.message}` }]
      };
    }
  }
);

server.tool() 接收四个参数:

  1. 工具名称(read_file
  2. 工具描述(LLM 根据描述决定何时调用)
  3. 参数 Schema(使用 Zod 定义)
  4. 处理函数(异步,执行实际业务逻辑)

4. 注册 write_file 工具

javascript

javascript 复制代码
server.tool(
  'write_file',
  `写入内容到项目目录下的文件(受限:只能写入 ${ALLOWED_DIR} 下的文件)`,
  {
    path: z.string().describe('相对于项目的文件路径,例如 "output.txt"'),
    content: z.string().describe('要写入的文件内容')
  },
  async ({ path: inputPath, content }) => {
    try {
      const safePath = resolveSafePath(inputPath);
      await fs.writeFile(safePath, content, 'utf-8');
      return { content: [{ type: 'text', text: `✅ 文件写入成功: ${safePath}` }] };
    } catch (err) {
      return {
        isError: true,
        content: [{ type: 'text', text: `文件写入失败: ${err.message}` }]
      };
    }
  }
);

5. 启动服务器

javascript

javascript 复制代码
async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error(`✅ MCP 文件读写服务已启动(stdio 通道)`);
  console.error(`🔒 访问限制目录: ${ALLOWED_DIR}`);
}

main().catch(err => {
  console.error('❌ 服务启动失败:', err);
  process.exit(1);
});

StdioServerTransport 建立了基于 stdin/stdout 的通信通道------这是 MCP Server 与 Client 之间的"双向水管"。

我们使用的 MCP SDK:版本与能力

本次项目使用的 MCP SDK 是 @modelcontextprotocol/sdk(最新稳定版),它提供了 MCP 协议的完整实现,包含以下核心模块:

模块 作用 本次是否使用
McpServer MCP 服务端核心类,注册工具、管理生命周期 ✅ 使用
StdioServerTransport 基于 stdin/stdout 的传输层 ✅ 使用
SSEServerTransport 基于 Server-Sent Events 的远程传输层 ❌ 未使用(本地场景用 Stdio 即可)
types MCP 协议的类型定义(请求/响应格式) ✅ 间接使用(SDK 内部依赖)

SDK 的核心设计原则是 "协议与实现分离"

  • 协议层types)定义了"说什么"------JSON-RPC 消息的格式。
  • 服务端层McpServer)定义了"谁在听"------工具注册和请求路由。
  • 传输层*Transport)定义了"怎么说"------通过 stdin/stdout 还是 HTTP/SSE。

这种分层设计让你可以在不改变工具实现的情况下,切换不同的传输方式。比如今天用 Stdio 跑在本地,明天想部署为远程服务,只需把 StdioServerTransport 换成 SSEServerTransport,工具代码一行不用改。

Zod 的作用:为什么不用手写 JSON Schema?

在 MCP 中,工具的参数需要通过 JSON Schema 声明,告诉 LLM 这个工具需要什么参数、什么类型、哪些必填。

手写 JSON Schema 非常繁琐:

json

json 复制代码
{
  "type": "object",
  "properties": {
    "filePath": {
      "type": "string",
      "description": "文件的绝对路径"
    }
  },
  "required": ["filePath"]
}

而 Zod 的写法简洁直观:

javascript

css 复制代码
{ path: z.string().describe('相对于项目的文件路径') }

Zod 解决了三个核心问题

痛点 Zod 的解法
代码冗余 一份 Schema 同时搞定运行时验证 + TypeScript 类型推导
类型不一致 z.infer<typeof schema> 自动推导,永远同步
可读性差 链式调用 .describe().optional(),一目了然

更重要的是,MCP SDK 在幕后使用 zod-to-json-schema 库,在运行时自动把 Zod Schema 转换成标准的 JSON Schema 发送给 Client。你写的 Zod 对象,最终会被转换成 LLM 能理解的 JSON Schema。

对比一下两种方式:

对比维度 手写 JSON Schema 使用 Zod
代码量 多,层层嵌套 少,一行搞定
类型推导 手动写 interface,易出错 z.infer 自动推导
必填/可选 需显式写 "required": [...] 默认必填,.optional() 即可
自定义校验 anyOf/allOf 极其繁琐 .refine() 直接写函数

StdioServerTransport:MCP 的通信通道

StdioServerTransport 是 MCP SDK 提供的一种传输层实现 ,基于进程的 stdinstdout 进行通信。

text

arduino 复制代码
Client(如 Cursor)  →  stdin  →  MCP Server
MCP Server           →  stdout →  Client
  • stdin(标准输入) :Client 向 Server 发送指令(如"调用 read_file")。
  • stdout(标准输出) :Server 向 Client 返回结果(如文件内容或错误信息)。

所有日志和调试信息必须通过 console.error 输出,否则会污染 stdout 通道,干扰 MCP 通信。这就是为什么代码中使用了 console.error 而不是 console.log

Transport 与 SDK 的协作

StdioServerTransport 的工作流程是:

  1. 监听 stdin:等待 Client 通过标准输入发送 JSON-RPC 请求。
  2. 解析请求:将收到的 JSON 字符串解析为 MCP 协议消息。
  3. 交给 McpServerMcpServer 根据消息内容找到对应的工具并执行。
  4. 返回结果 :将执行结果通过 stdout 以 JSON-RPC 格式返回给 Client。

开发者不需要关心第 1、2、4 步------SDK 和 Transport 已经帮你封装好了。你只需要关注第 3 步中的"工具实现"。

javascript

ini 复制代码
const transport = new StdioServerTransport();
await server.connect(transport);

这两行代码完成了 Transport 的实例化和连接。连接成功后,Server 就处于"伺服状态",随时等待 Client 发来指令。所有的通信细节------JSON 序列化/反序列化、消息路由、错误处理------都由 SDK 在幕后完成。

除了 Stdio,MCP 还支持 SSE(Server-Sent Events) 用于远程 HTTP 通信,但本地进程通信场景下 Stdio 是首选。

安全第一:路径限制与危险操作保护

MCP Server 操作的是本地文件系统,如果不加限制,后果可能是灾难性的。

路径白名单

代码中通过 ALLOWED_DIR 将文件操作限制在当前项目目录内:

javascript

javascript 复制代码
const ALLOWED_DIR = path.resolve(process.cwd());

function resolveSafePath(inputPath) {
  const resolved = path.resolve(ALLOWED_DIR, inputPath);
  if (!resolved.startsWith(ALLOWED_DIR + path.sep) && resolved !== ALLOWED_DIR) {
    throw new Error(`安全限制:禁止访问项目目录外的文件!`);
  }
  return resolved;
}

任何试图访问项目目录外的路径都会抛出异常,LLM 无法绕过这个限制。

MCP 协议还支持 User Consent(用户确认) 机制。如果工具被标记为"危险操作"(如 delete_filesend_email),在 Host(如 Cursor)中调用时会弹出确认框,等待用户手动批准后才执行。

测试与调试:MCP Inspector

MCP Inspector 是官方提供的调试工具,可以可视化地测试 MCP Server。

bash

bash 复制代码
# 构建项目
npm run build

# 启动 Inspector
npx @modelcontextprotocol/inspector node dist/index.js

Inspector 提供的核心功能:

功能 说明
List Tools 查看 Server 注册了哪些工具及其参数描述
Call Tool 手动填入参数,实际调用工具并查看返回结果
查看原始通信 观察 Client 和 Server 之间实际传输的 JSON-RPC 消息

开发流程总结

一个完整的 MCP Server 开发流程:

text

markdown 复制代码
1. 初始化项目 → npm init -y
2. 安装依赖 → pnpm i @modelcontextprotocol/sdk zod
3. 配置 TypeScript → npx tsc --init
4. 编写 index.ts → 创建 McpServer,注册工具
5. 实现安全限制 → 路径白名单 + User Consent
6. 启动 Transport → new StdioServerTransport()
7. 构建 → npm run build
8. 测试 → npx @modelcontextprotocol/inspector

核心要点:

  • 工具描述要清晰:LLM 根据描述决定调用哪个工具,描述越详细,调用越准确。
  • 参数用 Zod 定义:比手写 JSON Schema 简洁得多,且自动生成类型。
  • 安全是第一要务:限制操作范围,危险操作需申请用户同意。
  • Stdio 是本地通信的首选stdin 接收指令,stdout 返回结果。

互动讨论

  1. 为什么 MCP 协议要区分 Tools 和 Resources? 如果 read_file 只是读取数据(无副作用),它应该是 Tool 还是 Resource?
  2. 代码中的 resolveSafePath 函数是否足够安全? 有没有可能绕过路径限制的攻击方式?(提示:path.resolve../ 的处理)
  3. 如果 MCP Server 要部署为远程服务,应该用什么传输方式? 和 Stdio 有什么区别?
  4. Zod 的 .refine() 方法能实现什么自定义校验? 你能为 write_file 添加文件大小限制吗?

📌 一点心得:MCP Server 的本质是"把函数翻译成 LLM 能理解的语言"。工具描述清晰、参数 Schema 准确、安全限制严格,三者缺一不可。

相关推荐
To_OC1 小时前
我写了个 10 行的加法函数,终于搞懂了什么是 Harness Engineering
人工智能·llm·claude
先吃饱再说1 小时前
RAG 实战:给大模型装上“开卷考试”的外挂
llm·ai编程
VIP_CQCRE3 小时前
Codex CLI 如何高效整合 Ace Data Cloud MCP:把开发、生成、搜索和发布放进一个终端
ai工具·开发效率·mcp·codex cli·ace data cloud
love530love16 小时前
WorkBuddy + 本地 ComfyUI MCP:免订阅费的自建方案
人工智能·windows·mcp·comfy cloud
Code_Artist17 小时前
Trae AI 创造力大赛创意作品:AI 数字克隆人——让你有无数个分身!
人工智能·llm·aigc
8Qi817 小时前
HelloAgents:RAG——让 Agent 学会检索知识
人工智能·llm·agent·ai编程·vibecoding
凌涘18 小时前
无状态 LLM 架构深度解析:从 HTTP 协议到上下文工程
llm
Grapes18 小时前
没有魔法,只有循环:从 LLM API 到第一个 Agent
llm·agent
神一样的老师20 小时前
使用WorkBuddy自动发微博教程
人工智能·mcp