1. 项目概述:为什么我们需要一个MCP服务器?
最近在和一些做AI应用开发的朋友聊天,发现大家普遍遇到了一个痛点:如何让大语言模型(LLM)安全、高效地访问我们本地的文件系统?无论是想让它帮你分析一份刚下载的财报PDF,还是整理电脑里散乱的代码片段,直接让模型去“看”文件内容,总绕不开安全和权限的坎儿。这时候,模型上下文协议(Model Context Protocol, 简称MCP)就进入了我们的视野。它本质上是一套标准化的“沟通语言”,让AI应用(客户端)能够通过结构化的方式,请求并获取来自各种数据源(服务器)的信息,而文件系统,就是其中最基础、也最实用的数据源之一。
所以,今天我想和你一起动手,从零实现一个MCP文件读取服务器。这个项目听起来有点技术含量,但别担心,我会把它拆解得明明白白。我们不止是写几行代码,更重要的是理解MCP协议的设计哲学,搞清楚服务器与客户端(比如Claude Desktop、Cursor等)之间是如何“握手”和“对话”的。最终,你将获得一个能运行在你本地机器上的服务,它可以安全地让AI助手读取你指定目录下的文本文件(比如.txt,.md,.py),而无需担心隐私泄露或越权访问。这对于提升个人工作效率或构建更智能的本地化AI工具链来说,是一个非常有价值的基建环节。
2. MCP协议核心思想与架构拆解
在动手写代码之前,我们必须先吃透MCP协议的核心。你可以把它想象成AI世界的“USB协议”。不同的AI应用(客户端)就像不同的电脑,而各种数据源(数据库、文件系统、API)就像U盘、移动硬盘等外设。MCP定义了一套标准的“接口形状”和“通信规则”,确保任何符合标准的“外设”都能被任何符合标准的“电脑”即插即用。
2.1 协议基石:资源(Resources)与工具(Tools)
MCP协议的核心抽象只有两个:资源(Resources)和工具(Tools)。这是服务器向客户端暴露能力的两种基本方式。
资源(Resources):代表可供读取的静态或动态数据。每个资源有一个唯一的URI(如
file:///home/user/notes/project_plan.md)和对应的MIME类型(如text/markdown)。客户端可以“列出”可用的资源,也可以“读取”特定资源的内容。在我们的文件服务器里,一个文件就是一个资源。工具(Tools):代表可供调用的函数或操作。每个工具有一个名称、描述、输入参数(JSON Schema定义)。客户端可以“列出”可用的工具,并“调用”它们。比如,一个“搜索文件”工具,接收关键词参数,返回匹配的文件列表。在我们的初版服务器中,我们会先聚焦于实现资源相关的功能,这是更基础的需求。
2.2 通信桥梁:SSE与Stdio传输层
客户端和服务器之间需要一种双向通信机制。MCP主要支持两种传输层:
Server-Sent Events (SSE):这是一种基于HTTP的协议,服务器可以主动向客户端推送事件。在这种模式下,服务器通常作为一个HTTP服务运行,客户端通过HTTP连接与之通信。它更适合于网络环境下的远程调用。
Stdio (标准输入/输出):这是我们将要采用的模式,也是本地集成中最简单、最直接的方式。服务器作为一个独立的进程启动,通过标准输入(stdin)接收来自客户端的JSON-RPC请求,并通过标准输出(stdout)发送JSON-RPC响应和通知。Claude Desktop默认就使用这种方式加载本地MCP服务器。它的好处是隔离性好,部署简单,非常适合本地文件访问这种场景。
我们的项目将采用Stdio传输层,并遵循JSON-RPC 2.0规范进行所有通信。这意味着我们编写的服务器,本质上是一个不断从stdin读JSON、向stdout写JSON的控制台程序。
2.3 会话流程:初始化与能力交换
一个典型的MCP会话,遵循着清晰的“握手-交互”流程:
初始化(Initialize):客户端启动服务器进程后,发送的第一个请求必然是
initialize。这个请求中包含了客户端的名称、版本、协议版本等信息。服务器必须回应,在回应中声明自己支持哪些能力(比如“资源读取”、“工具调用”),并附上一个唯一的服务器实例ID。能力通告(Notifications):初始化完成后,服务器需要立即通过通知(
notifications)告诉客户端自己提供了哪些“资源”和“工具”。对于资源,服务器会发送resources/list通知,列出所有资源的URI和类型。对于工具,则发送tools/list通知。客户端收到这些列表后,就知道自己可以向服务器请求什么了。请求-响应循环:此后,客户端会根据用户的需求,发送具体的请求。例如:
resources/read:请求读取某个URI对应的资源内容。tools/call:请求调用某个工具。 服务器处理这些请求并返回结果。
心跳与关闭:客户端可能会发送
ping请求来检测服务器是否存活。当客户端要关闭时,它会发送shutdown请求,服务器应妥善清理并退出。
理解了这个流程,我们就能勾勒出服务器代码的基本骨架:一个循环,不断解析stdin来的JSON,根据方法名(如initialize,resources/read)路由到对应的处理函数,执行逻辑,然后将结果JSON写入stdout。
3. 项目环境搭建与核心依赖选择
工欲善其事,必先利其器。我们选择Node.js作为实现语言,主要是因为其异步IO特性非常适合这种通信密集型服务,而且生态丰富,JSON处理天然友好。当然,你也可以用Python、Go等任何你熟悉的语言,协议的核心思想是通用的。
3.1 初始化项目与安装依赖
首先,创建一个新的项目目录并初始化:
mkdir mcp-file-server cd mcp-file-server npm init -y接下来,安装我们最核心的依赖。我们不需要任何特定的“MCP SDK”,因为协议本身足够简洁,我们可以用基础库来构建。
npm install zod @types/node typescript ts-node --save-dev这里解释一下选型理由:
- zod:这是一个功能强大、零依赖的TypeScript模式验证库。MCP协议中充满了结构化的JSON数据(请求、响应、参数)。使用Zod来定义和验证这些数据结构,能极大提升代码的健壮性和开发体验,避免因数据格式错误导致的诡异问题。
- typescript & @types/node & ts-node:我们将使用TypeScript来编写代码,以获得更好的类型安全和代码提示。
ts-node用于直接运行.ts文件。
然后,初始化TypeScript配置:
npx tsc --init你可以根据习惯修改生成的tsconfig.json,一个适用于本项目的简单配置如下:
{ "compilerOptions": { "target": "ES2020", "module": "commonjs", "lib": ["ES2020"], "outDir": "./dist", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true }, "include": ["src/**/*"], "exclude": ["node_modules"] }3.2 构建协议类型定义
这是保证我们与客户端“说同一种语言”的关键一步。我们在src目录下创建一个protocol.ts文件,用Zod来定义MCP协议的核心类型。
// src/protocol.ts import { z } from 'zod'; // 1. 定义通用结构 export const URI = z.string().url(); // 资源URI,实际上我们主要用file://协议 export const MimeType = z.string(); // 如 "text/plain", "text/markdown" // 资源模板,用于动态资源路径 export const ResourceTemplate = z.object({ uriTemplate: z.string(), name: z.string(), description: z.string().optional(), mimeType: MimeType, }); // 工具输入参数定义 export const ToolInputSchema = z.record(z.any()); // 简化,实际应为JSON Schema对象 export const Tool = z.object({ name: z.string(), description: z.string(), inputSchema: ToolInputSchema, }); // 2. 定义请求(Requests)和通知(Notifications)的结构 // 初始化请求 export const InitializeRequest = z.object({ method: z.literal('initialize'), params: z.object({ protocolVersion: z.string(), clientInfo: z.object({ name: z.string(), version: z.string(), }).optional(), capabilities: z.record(z.any()).optional(), // 客户端能力,我们初版暂不深究 }), jsonrpc: z.literal('2.0'), id: z.number().or(z.string()), }); // 初始化成功响应 export const InitializeResult = z.object({ protocolVersion: z.string(), serverInfo: z.object({ name: z.string(), version: z.string(), }), capabilities: z.object({ resources: z.object({}).optional(), // 表明支持资源功能 tools: z.object({}).optional(), // 表明支持工具功能 }), instanceId: z.string().uuid(), }); // 读取资源请求 export const ReadResourceRequest = z.object({ method: z.literal('resources/read'), params: z.object({ uri: URI, }), jsonrpc: z.literal('2.0'), id: z.number().or(z.string()), }); // 读取资源成功响应 export const ReadResourceResult = z.object({ contents: z.array(z.object({ uri: URI, mimeType: MimeType, // 注意:文本内容放在 `text` 字段,二进制内容用 `blob` (base64编码) text: z.string().optional(), blob: z.string().optional(), })), }); // 列出资源通知(服务器主动发送) export const ListResourcesNotification = z.object({ method: z.literal('notifications/resources/list'), params: z.object({ resources: z.array(z.object({ uri: URI, name: z.string().optional(), description: z.string().optional(), mimeType: MimeType, })), }), jsonrpc: z.literal('2.0'), }); // 3. 定义错误响应 export const ErrorResponse = z.object({ jsonrpc: z.literal('2.0'), id: z.number().or(z.string()).or(z.null()), error: z.object({ code: z.number(), message: z.string(), data: z.any().optional(), }), }); // 4. 导出类型推断 export type InitializeRequest = z.infer<typeof InitializeRequest>; export type InitializeResult = z.infer<typeof InitializeResult>; export type ReadResourceRequest = z.infer<typeof ReadResourceRequest>; export type ReadResourceResult = z.infer<typeof ReadResourceResult>; export type ListResourcesNotification = z.infer<typeof ListResourcesNotification>; // ... 其他类型推断这个文件是我们的“协议字典”,确保了进出我们服务器的每一条消息都符合MCP的规范。使用Zod的好处是,我们不仅能获得TypeScript类型,还能在运行时进行验证,如果客户端发来了畸形数据,我们能立刻发现并返回友好的错误。
注意:以上是核心类型的精简版。完整的MCP协议还包含
tools/list、tools/call、resources/list(客户端请求)、ping/pong等。为了聚焦主线,我们先实现资源读取相关部分。你可以根据官方协议文档逐步补充。
4. 服务器核心逻辑实现
现在进入最核心的部分:编写服务器主循环。我们在src目录下创建server.ts。
4.1 搭建服务器骨架与主循环
服务器的核心是一个永不结束(直到收到shutdown)的循环,监听process.stdin,解析JSON-RPC消息,路由处理,再写入process.stdout。
// src/server.ts import * as readline from 'readline'; import { InitializeRequest, InitializeResult, ReadResourceRequest, ReadResourceResult, ListResourcesNotification, ErrorResponse, } from './protocol'; // 定义配置接口,例如允许访问的根目录 interface ServerConfig { rootDirectory: string; allowedExtensions: string[]; // 如 ['.txt', '.md', '.py', '.js'] } // 全局状态 let serverInstanceId: string; let config: ServerConfig; // 创建readline接口,用于逐行读取stdin const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false, // 关键!避免readline处理输出,我们直接写stdout }); // 主消息处理循环 rl.on('line', async (line) => { try { const message = JSON.parse(line); await handleMessage(message); } catch (error) { console.error('Failed to parse or handle message:', error); // 发送一个JSON-RPC错误响应 sendError(null, -32700, 'Parse error', { originalLine: line }); } }); // 发送JSON-RPC响应或通知的辅助函数 function sendResponse(id: number | string | null, result: any) { const response = { jsonrpc: '2.0', id, result, }; process.stdout.write(JSON.stringify(response) + '\n'); } function sendError(id: number | string | null, code: number, message: string, data?: any) { const errorResponse: z.infer<typeof ErrorResponse> = { jsonrpc: '2.0', id, error: { code, message, data }, }; process.stdout.write(JSON.stringify(errorResponse) + '\n'); } function sendNotification(method: string, params: any) { const notification = { jsonrpc: '2.0', method, params, }; process.stdout.write(JSON.stringify(notification) + '\n'); } // 核心消息路由器 async function handleMessage(message: any) { // 首先,根据方法名路由 switch (message.method) { case 'initialize': await handleInitialize(message); break; case 'resources/read': await handleReadResource(message); break; case 'shutdown': await handleShutdown(message); break; case 'ping': handlePing(message); break; // 可以在这里添加更多方法,如 `tools/call` default: sendError(message.id, -32601, `Method not found: ${message.method}`); } }这个骨架搭建了基本的I/O和路由机制。readline模块帮助我们以行为单位读取stdin,因为JSON-RPC消息通常以换行符分隔。terminal: false这个设置至关重要,它阻止readline接管stdout的格式化,让我们能直接输出纯净的JSON。
4.2 实现初始化(initialize)处理
初始化是会话的起点,我们必须在这里设置服务器状态,并告知客户端我们的能力。
// 在 server.ts 中继续添加 import { randomUUID } from 'crypto'; // 初始化配置(实际项目中可以从环境变量或配置文件读取) config = { rootDirectory: process.env.MCP_ROOT_DIR || '/Users/yourname/mcp_shared', // 替换为你的目录 allowedExtensions: ['.txt', '.md', '.py', '.js', '.json', '.csv'], }; async function handleInitialize(request: any) { // 1. 用Zod验证请求结构 const parsedRequest = InitializeRequest.safeParse(request); if (!parsedRequest.success) { sendError(request.id, -32600, 'Invalid Request', parsedRequest.error.errors); return; } // 2. 生成服务器实例ID serverInstanceId = randomUUID(); // 3. 构建响应 const result: InitializeResult = { protocolVersion: parsedRequest.data.params.protocolVersion, serverInfo: { name: 'mcp-file-server', version: '0.1.0', }, capabilities: { resources: {}, // 声明支持资源功能 // tools: {}, // 初版暂不声明工具支持 }, instanceId: serverInstanceId, }; sendResponse(request.id, result); // 4. 初始化后,立即发送资源列表通知 await notifyResourceList(); } async function notifyResourceList() { // 扫描配置的根目录,获取文件列表 const resourceList = await scanDirectoryForResources(config.rootDirectory, config.allowedExtensions); const notification: ListResourcesNotification = { method: 'notifications/resources/list', params: { resources: resourceList, }, jsonrpc: '2.0', }; sendNotification(notification.method, notification.params); }handleInitialize函数做了四件事:验证请求格式、生成唯一会话ID、返回服务器信息和支持的能力、最后主动推送当前可用的资源列表。notifyResourceList函数依赖于一个尚未实现的scanDirectoryForResources函数,它的职责是遍历指定目录,将符合条件的文件转换为MCP资源描述。
4.3 实现目录扫描与资源列表构建
这是连接本地文件系统和MCP资源抽象的关键一步。我们需要递归扫描目录,过滤文件,并生成规范的URI。
// src/file-utils.ts import * as fs from 'fs/promises'; import * as path from 'path'; import { MimeType } from './protocol'; // 扩展名到MIME类型的简单映射 const EXTENSION_TO_MIME: Record<string, string> = { '.txt': 'text/plain', '.md': 'text/markdown', '.py': 'text/x-python', '.js': 'text/javascript', '.json': 'application/json', '.csv': 'text/csv', '.html': 'text/html', '.css': 'text/css', }; export interface ResourceDescriptor { uri: string; name?: string; description?: string; mimeType: string; } export async function scanDirectoryForResources( rootDir: string, allowedExtensions: string[] ): Promise<ResourceDescriptor[]> { const resources: ResourceDescriptor[] = []; async function scan(dir: string, relativePath = '') { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); const currentRelativePath = relativePath ? path.join(relativePath, entry.name) : entry.name; if (entry.isDirectory()) { // 递归扫描子目录 await scan(fullPath, currentRelativePath); } else if (entry.isFile()) { const ext = path.extname(entry.name).toLowerCase(); if (allowedExtensions.includes(ext)) { // 构建符合MCP规范的file:// URI // 注意:需要将路径转换为绝对路径,并确保格式正确 const uri = `file://${path.resolve(fullPath)}`; // 简单处理:文件名(不含扩展名)作为资源名 const name = path.basename(entry.name, ext); const mimeType = EXTENSION_TO_MIME[ext] || 'application/octet-stream'; resources.push({ uri, name, mimeType, // 可以在这里添加更多逻辑,例如从文件首行提取description }); } } } } await scan(rootDir); return resources; } // 根据URI读取文件内容 export async function readResourceContent(uri: string): Promise<string> { // 从 file:// URI 中提取本地文件路径 if (!uri.startsWith('file://')) { throw new Error(`Unsupported URI scheme: ${uri}`); } const filePath = uri.slice('file://'.length); // 简单的安全校验:确保请求的文件路径在配置的根目录下(防止目录遍历攻击) const resolvedRoot = path.resolve(config.rootDirectory); const resolvedPath = path.resolve(filePath); if (!resolvedPath.startsWith(resolvedRoot)) { throw new Error(`Access denied: File is outside of allowed root directory.`); } // 读取文件内容 const content = await fs.readFile(filePath, 'utf-8'); return content; }scanDirectoryForResources函数递归遍历目录,为每个允许扩展名的文件创建一个资源描述符。这里有两个关键点:
- URI生成:我们使用
file://协议头加上文件的绝对路径来构造URI。这是MCP客户端识别本地文件资源的通用方式。 - MIME类型映射:根据文件扩展名推断MIME类型,帮助客户端正确理解内容格式。
readResourceContent函数是实际读取文件的函数,它包含了一个至关重要的安全校验:检查请求的文件路径是否在我们声明的根目录之下。这是防止恶意客户端通过构造类似file:///etc/passwd的URI来越权访问系统文件的核心防线。
4.4 实现资源读取(resources/read)处理
现在,我们可以实现最核心的功能:响应客户端的文件读取请求。
// 在 server.ts 中继续添加 import { readResourceContent } from './file-utils'; import { ReadResourceRequest, ReadResourceResult } from './protocol'; async function handleReadResource(request: any) { // 1. 验证请求 const parsedRequest = ReadResourceRequest.safeParse(request); if (!parsedRequest.success) { sendError(request.id, -32600, 'Invalid Request', parsedRequest.error.errors); return; } const { uri } = parsedRequest.data.params; try { // 2. 读取文件内容 const textContent = await readResourceContent(uri); // 3. 构建响应 const result: ReadResourceResult = { contents: [{ uri, mimeType: EXTENSION_TO_MIME[path.extname(new URL(uri).pathname).toLowerCase()] || 'text/plain', text: textContent, // 因为是文本文件,我们使用text字段 }], }; sendResponse(request.id, result); } catch (error: any) { // 4. 错误处理 console.error(`Failed to read resource ${uri}:`, error); let errorCode = -32000; // 默认服务器错误 let errorMessage = 'Server error'; if (error.code === 'ENOENT') { errorCode = -32602; // 类似于 Invalid params, 资源未找到 errorMessage = `Resource not found: ${uri}`; } else if (error.message.includes('Access denied')) { errorCode = -32602; errorMessage = error.message; } sendError(request.id, errorCode, errorMessage, { uri }); } }这个处理函数清晰展示了JSON-RPC服务器的模式:验证、执行业务逻辑、返回结果或错误。错误处理部分特别重要,我们将文件系统错误(如文件不存在)和业务逻辑错误(如越权访问)映射为合适的JSON-RPC错误码,让客户端能理解发生了什么。
4.5 实现其他必要方法
为了让服务器更完整,我们还需要实现shutdown和ping。
// 在 server.ts 中继续添加 async function handleShutdown(request: any) { console.log('Received shutdown request.'); sendResponse(request.id, null); // 先响应 // 可以在这里进行一些清理工作 process.exit(0); // 然后退出进程 } function handlePing(request: any) { // ping请求只需要返回一个pong响应,result通常为null或空对象 sendResponse(request.id, {}); }5. 配置、运行与客户端连接测试
服务器代码已经完成,接下来我们需要让它能运行起来,并连接到真正的MCP客户端(如Claude Desktop)进行测试。
5.1 创建启动脚本与配置
首先,在package.json中添加启动脚本:
{ "scripts": { "start": "ts-node src/server.ts", "build": "tsc", "serve": "node dist/server.js" } }为了更灵活地配置服务器,我们可以创建一个简单的配置文件或使用环境变量。这里我们使用环境变量,在启动脚本前设置:
# 在命令行中设置(Linux/macOS) export MCP_ROOT_DIR="/path/to/your/shared/folder" export MCP_ALLOWED_EXTENSIONS=".txt,.md,.py,.js" npm start # 或者在package.json脚本中嵌入(Windows需调整语法) # "start": "cross-env MCP_ROOT_DIR=/path/to/shared ts-node src/server.ts"同时,我们需要修改server.ts开头的配置部分,使其读取环境变量:
// server.ts 顶部 const config: ServerConfig = { rootDirectory: process.env.MCP_ROOT_DIR || path.join(os.homedir(), 'mcp_shared'), allowedExtensions: process.env.MCP_ALLOWED_EXTENSIONS?.split(',').map(ext => ext.trim()) || ['.txt', '.md', '.py', '.js', '.json'], };5.2 配置Claude Desktop进行连接测试
Claude Desktop是目前体验MCP服务器最方便的工具之一。我们需要为它创建一个服务器配置文件。
找到Claude Desktop的配置目录:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json - Linux:
~/.config/Claude/claude_desktop_config.json
- macOS:
编辑配置文件:如果文件不存在就创建它。添加我们的MCP服务器配置。
{ "mcpServers": { "my-file-server": { "command": "node", "args": [ "/absolute/path/to/your/mcp-file-server/dist/server.js" // 如果是ts-node开发,可以是 "/path/to/ts-node", "/absolute/path/to/src/server.ts" ], "env": { "MCP_ROOT_DIR": "/path/to/your/shared/folder", "MCP_ALLOWED_EXTENSIONS": ".txt,.md,.py" } } } }关键配置解析:
command: 启动服务器的命令,这里是node。args: 传递给命令的参数。如果你用ts-node直接运行TypeScript源码,第一个参数就是ts-node的路径,第二个是server.ts的绝对路径。如果先编译成JS (npm run build),则指向编译后的server.js。env: 设置环境变量,这样我们的服务器就能读到MCP_ROOT_DIR等配置。
- 重启Claude Desktop:保存配置文件后,完全关闭并重新打开Claude Desktop。
5.3 测试与验证
重启后,Claude Desktop会自动启动我们配置的MCP服务器进程。你可以通过以下方式验证:
查看Claude Desktop日志:在Claude Desktop中,通常可以通过
Cmd/Ctrl + Shift + I打开开发者工具,在控制台(Console)里可以看到MCP服务器初始化的日志,比如[MCP] Server initialized: my-file-server。如果启动失败,这里也会有错误信息,是排查问题的第一现场。与Claude对话:在聊天框中,你可以尝试输入:
- “你都能访问哪些文件?” – Claude可能会调用服务器列出的资源列表来回答。
- “请读取
project_plan.md文件并总结要点。” – Claude会发送resources/read请求给我们的服务器,获取文件内容后进行分析。
服务器端日志:为了调试,我们可以在服务器代码的关键位置添加
console.log(输出到stderr,避免干扰JSON-RPC通信),然后在Claude Desktop的开发者工具控制台查看。// 在handleInitialize, handleReadResource等处添加 console.error(`[Debug] Handling ${message.method} request...`);
6. 高级功能拓展与优化建议
一个基础可用的文件服务器已经完成了。但要让它在生产环境中更可靠、更强大,我们还需要考虑很多方面。
6.1 实现工具(Tools)功能
资源是“拉取”模式,工具则是“调用”模式。让我们添加一个简单的文件搜索工具。
首先,在protocol.ts中补充工具相关的类型定义(Tool,ListToolsNotification,CallToolRequest等)。然后在初始化时,在capabilities中声明支持tools,并在初始化后发送notifications/tools/list通知。
接着,实现一个tools/call处理器:
// 在 handleMessage 的 switch 中添加 case 'tools/call': await handleCallTool(message); break; async function handleCallTool(request: any) { // 验证请求... const { name, arguments: args } = request.params; switch (name) { case 'search_files': // 实现搜索逻辑:遍历资源,检查文件名或内容是否包含关键词 const keyword = args.keyword; const allResources = await scanDirectoryForResources(...); const matched = allResources.filter(r => r.name?.includes(keyword) || (await readResourceContent(r.uri)).includes(keyword) ); sendResponse(request.id, { matches: matched.map(r => ({uri: r.uri, name: r.name})) }); break; default: sendError(request.id, -32601, `Tool not found: ${name}`); } }6.2 性能优化:资源列表缓存与增量更新
每次客户端连接都全盘扫描目录,对于文件很多的情况效率低下。我们可以引入缓存机制。
- 缓存资源列表:在服务器启动或目录变化时扫描一次,将结果缓存起来。
notifyResourceList直接返回缓存。 - 监听文件系统变化:使用
fs.watch或更高效的库(如chokidar)监听配置的根目录。当文件增删改时,更新缓存,并主动向客户端发送notifications/resources/list通知,实现资源列表的动态更新。 - 分页与过滤:如果资源数量巨大,可以在
resources/list请求(如果实现)或工具调用中支持分页和过滤参数。
6.3 增强安全性与错误处理
- 更严格的路径校验:我们之前的路径检查是基础。更安全的做法是使用
path.relative()计算相对路径,并检查是否包含..。const relativePath = path.relative(config.rootDirectory, resolvedFilePath); if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) { throw new Error('Access denied: Path traversal attempt detected.'); } - 文件大小限制:防止客户端读取超大文件拖垮服务器。
const stats = await fs.stat(filePath); if (stats.size > 10 * 1024 * 1024) { // 例如10MB throw new Error('File too large.'); } - 更精细的MIME类型处理:可以尝试通过文件内容(魔数)而不仅仅是扩展名来判断类型,更准确。
- 请求限流与超时:防止客户端恶意发送大量请求。
6.4 支持二进制文件与分块传输
目前我们只处理文本文件(text字段)。对于图片、PDF等二进制文件,MCP协议支持通过blob字段(Base64编码)传输。我们需要在readResourceContent中根据MIME类型决定读取方式(utf-8或binary),并转换为Base64。
对于超大文件,可以考虑实现分块读取(range请求),但这需要扩展MCP协议或自定义工具。
6.5 日志与监控
为服务器添加结构化的日志(如使用winston或pino库),记录请求方法、URI、处理时间、错误等信息,便于运维和问题排查。可以将日志输出到文件或标准错误流。
7. 常见问题与排查技巧实录
在实际开发和集成过程中,你肯定会遇到各种问题。以下是我踩过的一些坑和解决方案:
问题1:Claude Desktop连接服务器失败,日志显示"Failed to start server"或"Exited with code 1"。
- 排查思路:
- 路径问题:检查
claude_desktop_config.json中的command和args路径是否是绝对路径?在macOS/Linux上是否可执行?可以尝试在终端中手动运行该命令来验证。 - 权限问题:确保Node.js脚本有执行权限,并且对
MCP_ROOT_DIR指向的目录有读取权限。 - 依赖缺失:如果你的服务器需要
ts-node,确保它在全局或项目本地已安装,并且在args中路径正确。最稳妥的方式是在项目内编译成JS (npm run build),然后直接运行node dist/server.js。 - 立即崩溃:服务器可能在启动阶段就因未捕获的异常而崩溃。在服务器入口点添加
try-catch,并将错误打印到console.error,然后在Claude Desktop控制台查看具体错误信息。
- 路径问题:检查
问题2:Claude能连接,但说“没有可用的资源”或读取文件失败。
- 排查思路:
- 资源列表未发送:确认服务器在
initialize响应后,是否立即发送了notifications/resources/list。检查Claude Desktop控制台网络或MCP日志,看是否有这条出站通知。 - URI格式错误:检查服务器生成的
file://URI是否正确。在scanDirectoryForResources函数中打印出生成的URI看看。注意Windows路径的file://格式通常是file:///C:/Users/...(三个斜杠)。 - MIME类型不匹配:客户端可能根据MIME类型决定如何处理内容。确保你映射的MIME类型是通用的(如
text/plain最安全)。可以尝试将所有文本文件都设为text/plain进行测试。 - 安全校验拦截:确认你要读取的文件确实在
MCP_ROOT_DIR目录下,并且路径没有触发我们的安全规则。可以在readResourceContent函数中打印resolvedPath和resolvedRoot进行比对。
- 资源列表未发送:确认服务器在
问题3:服务器进程僵死或无响应。
- 排查思路:
- 未处理异常:确保所有异步操作都有
.catch或放在try-catch中,避免未处理的Promise拒绝导致进程崩溃。可以在进程顶层添加:process.on('uncaughtException', (err) => { console.error('Uncaught Exception:', err); }); process.on('unhandledRejection', (reason, promise) => { console.error('Unhandled Rejection at:', promise, 'reason:', reason); }); - 输入流阻塞:检查
readline或stdin读取逻辑是否正确。确保在收到shutdown请求后正确退出循环,避免僵尸进程。 - 长时间操作阻塞主线程:文件扫描或大文件读取如果是同步的,会阻塞处理其他请求。确保所有I/O操作都是异步的(使用
fs.promises)。
- 未处理异常:确保所有异步操作都有
问题4:想调试服务器端的详细通信过程。
- 技巧:可以将所有经过
stdin/stdout的JSON消息都记录到日志文件中。在handleMessage开头和sendResponse前,将消息对象用JSON.stringify美化后输出到console.error(或一个日志文件)。这样你就能看到完整的请求-响应对话,对于理解协议流程和定位问题非常有帮助。
实现一个MCP服务器,最有趣的不是代码本身,而是这种“标准化接口”思维。一旦你理解了资源和工具这两个核心抽象,以及JSON-RPC over Stdio的通信模式,你就可以将任何本地能力——数据库、内部API、硬件状态——封装成AI可安全调用的服务。这个文件服务器只是一个起点,你可以在此基础上,轻松扩展出“数据库查询服务器”、“项目管理工具服务器”甚至“智能家居控制服务器”,真正让大模型成为你个人工作流和数字生活的强大副驾驶。