MCP协议实战:构建AI可调用的本地文件读取服务

MCP协议实战:构建AI可调用的本地文件读取服务

1. 从“玩具”到“工具”:为什么我们需要MCP

如果你最近在AI编程工具(比如Cursor、Claude Desktop)的社区里混迹,大概率会频繁看到一个词:MCP。它全称是Model Context Protocol,直译过来是“模型上下文协议”。听起来很唬人,但它的核心目标其实非常朴素:让AI助手(大模型)能够安全、可控地访问和使用你电脑上的工具和数据

想象一个典型的开发场景:你想让AI帮你分析一个本地的日志文件。传统做法是,你得手动把文件内容复制粘贴到聊天框里,受限于上下文长度,大文件根本处理不了。或者,你想让它调用一个本地的API服务,你不得不把复杂的curl命令和响应结果来回搬运。这个过程是割裂的、低效的。MCP协议就是为了解决这个“最后一公里”的问题而生的。它定义了一套标准,让开发者可以编写一个个轻量的“服务”(MCP Server),这些服务就像一个个插件,专门负责与特定的资源(如本地文件系统、数据库、API)打交道。而AI客户端(MCP Client,如集成了该协议的编辑器)则通过这个协议,安全地向这些服务发出请求,获取处理后的信息或执行操作。

所以,当标题提到“写一个能读本地文件的极简服务”时,这其实就是MCP最经典、最核心的应用场景之一。它不是一个庞大的后端系统,而是一个聚焦于单一能力(文件读取)的轻量级进程。通过构建这样一个服务,你可以让AI助手直接“看到”并处理你指定目录下的文件,无需再手动搬运数据。这不仅仅是方便,更是一种工作流的质变。接下来,我们就从零开始,拆解如何构建这样一个服务,并深入理解其背后的设计逻辑与实战细节。

2. 环境搭建与核心依赖解析

在动手写代码之前,我们需要把舞台搭好。由于MCP是一个新兴协议,官方和社区提供了多种语言的SDK来降低开发门槛。对于前端或全栈开发者来说,基于Node.js的@modelcontextprotocol/sdk是一个非常好的起点,它封装了协议通信的底层细节,让我们可以专注于业务逻辑。

2.1 初始化项目与安装依赖

首先,创建一个新的项目目录并初始化。这里我推荐使用pnpm,它在管理Monorepo和依赖方面表现更优,但npmyarn也同样适用。

mkdir simple-file-reader-mcp cd simple-file-reader-mcp pnpm init -y

接下来,安装核心的MCP SDK。同时,我们会用到zod这个库,它在MCP生态中至关重要,用于严格定义和验证工具(Tools)的输入输出参数,确保AI客户端和服务器之间传递的数据结构是清晰且类型安全的。

pnpm add @modelcontextprotocol/sdk zod

此外,我们还需要安装TypeScript及相关类型定义,以获得更好的开发体验。虽然这不是强制要求,但对于确保代码质量非常有帮助。

pnpm add -D typescript @types/node npx tsc --init

初始化TypeScript配置后,你可以在生成的tsconfig.json中确保"module": "ESNext""target": "ES2022"等设置,以适应现代Node.js环境。

2.2 理解MCP Server的基本骨架

一个MCP Server的核心生命周期非常简单,可以概括为:初始化 -> 声明能力 -> 处理请求 -> 返回结果。SDK为我们抽象了与Stdio(标准输入输出)传输层通信的复杂性,我们只需要关注三件事:

  1. 定义工具(Tools):告诉客户端我这个服务器能提供哪些“能力”。每个工具都需要一个名字、描述和参数模式(schema)。例如,我们的“读文件”工具,需要定义一个名为read_file的工具,并说明它需要一个path参数。
  2. 实现工具处理逻辑:当客户端调用某个工具时,服务器需要执行相应的操作。对于read_file,就是使用Node.js的fs模块读取指定路径的文件内容。
  3. 处理资源(Resources)(可选):除了主动调用的工具,MCP还支持“资源”概念,可以理解为服务器主动向客户端“推送”的上下文信息。例如,你可以将一个目录下的文件列表定义为一个资源,客户端在初始化时就能获取到。本篇我们聚焦于工具,资源将在后续进阶部分探讨。

基于这个理解,我们先创建一个最简单的服务器入口文件src/server.ts,并搭建起基础结构。

3. 构建极简文件读取工具:从定义到实现

现在,我们进入核心环节:打造那个能让AI助手读取本地文件的工具。这个过程不仅仅是写几行读取文件的代码,更重要的是按照MCP协议的要求,严谨地定义工具契约,并处理好边界情况。

3.1 使用Zod定义工具契约

在MCP中,工具的参数和返回值都需要用JSON Schema来描述。zod库完美地扮演了这个角色。它让我们能用TypeScript风格的方式定义模式,并且能自动推导出TypeScript类型,实现“一处定义,多处使用”。

首先,我们在src/tools目录下创建readFile.ts,定义我们的工具:

// src/tools/readFile.ts import { z } from “zod”; // 1. 定义输入参数的模式 // 我们要求客户端必须传递一个 `path` 参数,它是字符串类型。 // 通过 `.describe()` 方法,我们可以为参数添加人类可读的描述,这会被AI客户端用来理解如何填写参数。 export const ReadFileArgsSchema = z.object({ path: z.string().describe(“The absolute or relative path to the file to read.”), }); // 从Schema推导出TypeScript类型,方便在实现逻辑中使用 export type ReadFileArgs = z.infer<typeof ReadFileArgsSchema>; // 2. 定义工具本身的元数据 // 这包括工具的名称、描述和参数模式。 // 名称是客户端调用时使用的标识符,描述帮助AI理解工具的作用。 export const readFileTool = { name: “read_file”, // 工具名,通常使用蛇形命名 description: “Read the contents of a file from the local filesystem.”, inputSchema: ReadFileArgsSchema, };

这里有一个关键点:path参数是相对路径还是绝对路径?为了服务的可预测性和安全性,我强烈建议在实现时优先处理绝对路径。你可以通过约定一个“根目录”,或者要求客户端传入基于项目根目录的路径,然后在服务器端解析为绝对路径。这避免了因工作目录不同导致的“文件找不到”问题。

3.2 实现文件读取逻辑

定义了契约,接下来就是实现。在src/tools/readFile.ts中继续添加:

// src/tools/readFile.ts (续) import { promises as fs } from “fs”; // 使用Promise-based的fs API import path from “path”; // 这是一个简单的实现函数 export async function executeReadFile(args: ReadFileArgs): Promise<string> { const { path: filePath } = args; // 安全考虑:这里可以添加路径校验逻辑,防止读取系统敏感文件。 // 例如,可以检查 filePath 是否在某个允许的目录范围内。 // const safeBaseDir = process.cwd(); // 例如,限制在当前工作目录 // const resolvedPath = path.resolve(safeBaseDir, filePath); // if (!resolvedPath.startsWith(safeBaseDir)) { // throw new Error(“Access to files outside the allowed directory is prohibited.”); // } // 为了简单起见,我们直接解析传入的路径。 // 注意:如果`filePath`是相对路径,它将相对于服务器进程的当前工作目录(process.cwd())进行解析。 const resolvedPath = path.resolve(filePath); try { // 使用fs.readFile读取文件内容,默认编码为utf-8 const content = await fs.readFile(resolvedPath, “utf-8”); return content; } catch (error: any) { // 错误处理至关重要,需要将Node.js错误转化为对AI客户端友好的信息。 if (error.code === “ENOENT”) { throw new Error(`File not found at path: ${resolvedPath}`); } else if (error.code === “EACCES”) { throw new Error(`Permission denied when reading file: ${resolvedPath}`); } else if (error.code === “EISDIR”) { throw new Error(`The path is a directory, not a file: ${resolvedPath}`); } // 其他未知错误 throw new Error(`Failed to read file: ${error.message}`); } }

实操心得:错误处理是服务健壮性的关键。AI客户端(尤其是大模型)需要清晰的错误信息来理解哪里出错了,并可能尝试其他方案。像ENOENT(文件不存在)、EACCES(权限不足)这类系统错误码,转换成明确的英文描述,能极大提升交互体验。此外,在生产环境中,路径安全校验是必须的,绝不能允许服务随意读取文件系统的任何位置。

3.3 组装MCP服务器

工具定义和实现都有了,现在需要将它们集成到MCP服务器实例中。创建src/server.ts

// src/server.ts import { Server } from “@modelcontextprotocol/sdk/server/index.js”; import { StdioServerTransport } from “@modelcontextprotocol/sdk/server/stdio.js”; import { readFileTool, executeReadFile } from “./tools/readFile.js”; // 注意导入编译后的JS文件,或使用ts-node等 // 1. 创建Server实例 // 需要提供服务器名称和版本,这些信息会告知客户端。 const server = new Server( { name: “simple-file-reader”, version: “0.1.0”, }, { // 可选的服务器能力声明,我们在这里声明工具。 capabilities: { tools: {}, // 工具列表将在后面通过`setRequestHandler`动态关联,这里先留空。 }, } ); // 2. 注册工具请求处理器 // 当客户端调用 `read_file` 工具时,这个函数会被触发。 server.setRequestHandler(“tools/call”, async (request) => { // 根据工具名路由到不同的处理函数 if (request.params.name === readFileTool.name) { // 使用Zod Schema验证客户端传入的参数 const args = readFileTool.inputSchema.parse(request.params.arguments); // 执行我们的业务逻辑 const content = await executeReadFile(args); // 返回结果给客户端。MCP协议要求工具调用返回一个包含`content`的数组。 // 每个content项可以包含`type`(如“text”)和具体的值。 return { content: [ { type: “text”, text: content, }, ], }; } // 如果收到未知的工具调用请求,抛出错误 throw new Error(`Unknown tool: ${request.params.name}`); }); // 3. 启动服务器,使用Stdio传输层 // 这是MCP Server的标准运行方式,通过标准输入输出与父进程(如AI客户端)通信。 async function main() { const transport = new StdioServerTransport(); await server.connect(transport); console.error(“MCP File Reader Server running on stdio…”); } main().catch((error) => { console.error(“Server fatal error:”, error); process.exit(1); });

关键点解析server.setRequestHandler(“tools/call”, …)是核心。它监听着所有工具调用请求。当请求进来时,我们首先根据request.params.name判断是哪个工具,然后用对应的Zod Schema去验证参数。验证通过后,才执行业务函数。最后,按照MCP协议规定的格式返回结果。这种模式使得添加新工具变得非常容易——只需定义新的工具Schema和函数,并在此处添加一个if分支即可。

4. 配置、运行与在Claude Desktop中测试

服务器代码写好了,但它如何被AI客户端发现和调用呢?这就需要配置文件。不同的客户端配置方式略有不同,我们以目前集成度较高的Claude Desktop为例。

4.1 创建MCP服务器配置文件

对于Claude Desktop,它会在特定目录查找MCP服务器的配置。我们需要创建一个JSON文件来告诉Claude:“我有一个叫simple-file-reader的服务器,你可以通过运行这个Node.js脚本来启动它。”

在Claude Desktop的配置目录下(通常是~/Library/Application Support/Claude%APPDATA%\Claude),找到或创建claude_desktop_config.json文件。其结构如下:

{ “mcpServers”: { “simple-file-reader”: { “command”: “node”, “args”: [ “/ABSOLUTE/PATH/TO/YOUR/PROJECT/dist/server.js” ], “env”: { “NODE_ENV”: “production” } } } }

重要提示

  • command:启动服务器的命令,这里是node
  • args:命令的参数,即我们编译后的JavaScript入口文件路径。必须使用绝对路径
  • env:可选项,可以设置环境变量。

为了让这个配置生效,我们需要先将TypeScript代码编译成JavaScript。在package.json中添加构建脚本:

{ “scripts”: { “build”: “tsc”, “start”: “node dist/server.js” } }

然后运行pnpm run build,它会在dist目录下生成编译后的server.js文件。将上述配置中args的路径替换为你本地dist/server.js的绝对路径。

4.2 运行与调试技巧

配置完成后,重启Claude Desktop。如果一切正常,Claude Desktop会在后台启动我们的MCP服务器进程。如何验证呢?

  1. 查看日志:Claude Desktop通常有日志输出位置。在macOS上,你可以通过Console.app查看。更直接的方式是在我们的服务器代码中,使用console.error输出信息(如上例中的“MCP File Reader Server running on stdio…”),这些信息会输出到标准错误流,可以被客户端捕获并记录。
  2. 在Claude中尝试:打开Claude Desktop,新建一个对话。理论上,Claude现在应该知道它多了一个read_file工具。你可以尝试用自然语言说:“请读取我桌面上的notes.txt文件。” 或者更直接地:“使用read_file工具,路径是/Users/YourName/Desktop/notes.txt。”
  3. 处理常见启动错误
    • 命令未找到:确保node在系统PATH中,或者使用node的绝对路径(如/usr/local/bin/node)。
    • 文件路径错误:确保args中的JS文件路径绝对正确,并且文件存在。
    • 权限问题:确保Claude Desktop有权限执行该Node.js脚本和读取目标文件。

踩坑实录:我第一次配置时,使用了相对路径“./dist/server.js”,这导致了启动失败。因为Claude Desktop启动子进程时,其当前工作目录(CWD)可能不是项目目录。因此,在配置文件中,所有路径都应使用绝对路径,这是最稳妥的做法。另一个坑是,如果服务器代码有语法错误或启动时崩溃,Claude Desktop可能会静默失败。此时,最有效的调试方法是先脱离客户端,直接在终端用node dist/server.js运行服务器,看是否能正常启动并等待输入。

4.3 进阶:添加目录列表工具

一个只能读单个文件的服务有点单薄。让我们快速扩展一下,添加一个list_directory工具,让AI能先“浏览”目录结构,再决定读哪个文件。这更符合真实的使用场景。

创建src/tools/listDirectory.ts

// src/tools/listDirectory.ts import { z } from “zod”; import { promises as fs } from “fs”; import path from “path”; export const ListDirectoryArgsSchema = z.object({ path: z.string().describe(“The path to the directory to list. Defaults to current directory if not provided.”).optional(), }); export type ListDirectoryArgs = z.infer<typeof ListDirectoryArgsSchema>; export const listDirectoryTool = { name: “list_directory”, description: “List files and directories within a specified directory.”, inputSchema: ListDirectoryArgsSchema, }; export async function executeListDirectory(args: ListDirectoryArgs): Promise<string> { const targetPath = args.path ? path.resolve(args.path) : process.cwd(); try { const items = await fs.readdir(targetPath, { withFileTypes: true }); const result = items.map((dirent) => { const type = dirent.isDirectory() ? “[DIR] ” : “[FILE]”; return `${type} ${dirent.name}`; }).join(“\n”); return `Contents of directory: ${targetPath}\n\n${result}`; } catch (error: any) { if (error.code === “ENOENT”) { throw new Error(`Directory not found: ${targetPath}`); } else if (error.code === “ENOTDIR”) { throw new Error(`The path is not a directory: ${targetPath}`); } else if (error.code === “EACCES”) { throw new Error(`Permission denied when accessing directory: ${targetPath}`); } throw new Error(`Failed to list directory: ${error.message}`); } }

然后在src/server.ts中导入并注册这个新工具:

// 在server.ts顶部导入 import { listDirectoryTool, executeListDirectory } from “./tools/listDirectory.js”; // 在 server.setRequestHandler 中增加一个分支 server.setRequestHandler(“tools/call”, async (request) => { if (request.params.name === readFileTool.name) { // … 原有逻辑 … } // 新增list_directory工具处理 if (request.params.name === listDirectoryTool.name) { const args = listDirectoryTool.inputSchema.parse(request.params.arguments); const listing = await executeListDirectory(args); return { content: [ { type: “text”, text: listing, }, ], }; } throw new Error(`Unknown tool: ${request.params.name}`); });

重新构建 (pnpm run build) 并重启Claude Desktop,现在你的AI助手就同时具备了浏览目录和读取文件的能力。你可以这样使用:“先列出我的项目根目录,然后帮我读取src/server.ts文件。”