1. 为什么我要手写一个文件系统 MCP ServerMCP Server 这个词最近出现频率很高但很多人第一次听到会以为是什么重型框架。其实把它拆开看就是一个基于 JSON-RPC 的本地服务对外暴露几个函数让 AI 客户端能调用。文件系统连接器是最典型的入门场景读文件、写文件、列目录三个动作就能覆盖大量日常需求。我自己的触发点很具体。手头有一批 Markdown 文档需要批量整理原本想写 Python 脚本硬干后来发现如果做成 MCP Server以后不管换哪个支持 MCP 的客户端都能直接复用。与其每次写一次性脚本不如花 30 分钟搭一个标准接口。这篇文章面向的是有 TypeScript 基础、想快速跑通 MCP Server 端到端连接的开发者。你会拿到可复制的config.toml和settings.json骨架、完整的 Server 启动命令以及一次真实的文件读写验证。整个过程不需要理解 Transport Layer 的底层细节先把东西跑起来再回头补理论。我试过用最少的代码量实现核心功能实测下来 30 分钟足够从零到在 Cline 里完成一次文件读取。下面按步骤来。2. TaoToken 前置统一 Key 与 API 通道在写 Server 之前先把模型侧的接入通道准备好。MCP Server 本身不负责模型调用它只负责暴露工具真正发起对话、决定调用哪个 tool 的是客户端里的模型。所以你需要一个能稳定调用的 API 通道。TaoToken 在这里的角色是统一 Key 和 API 入口。你不需要为每个客户端单独配置不同的密钥一个 Key 就能覆盖模型对话、Coding Plan 等场景。对于 MCP 调试来说这意味着你在 Cline 里配置一次后续换其他支持 MCP 的客户端也能复用同一套凭证。具体操作上先到官网注册并拿到 API Key。地址是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 注册后在控制台创建 Key。API 基础地址是 https://taotoken.net/api 注意这个地址不加 UTM 参数直接用于代码里的 base_url。如果你后续要做长期编码或 Agent 类任务可以关注 Coding Plan 页面它针对高频调用场景做了额度优化。但本篇的重点是 MCP Server 本身Key 拿到后先放着等 Server 跑起来再在客户端里填入。注意MCP Server 和模型 API 是两条独立的链路。Server 负责工具执行模型负责决策。调试时如果工具没被调用先检查 Server 是否正常启动再检查模型侧配置。3. 可复制配置从零搭建 TypeScript MCP Server3.1 初始化项目与依赖先建目录并初始化 npm 项目。这里用 TypeScript 是因为类型提示能帮你在写 tool schema 时少犯错。mkdir my-fs-server cd my-fs-server npm init -y npm install modelcontextprotocol/sdk typescript ts-node types/node注意包名是modelcontextprotocol/sdk这是官方维护的 SDK。安装完成后创建tsconfig.json。这里有个坑SDK 的 mcp 模块只支持 ESM所以 module 必须设成 NodeNext。{ compilerOptions: { module: NodeNext, moduleResolution: NodeNext, target: ES2022, outDir: ./dist, rootDir: ./src, strict: true, esModuleInterop: true }, include: [src/**/*] }同时在package.json里加一行type: module。这两步做完ESM 环境就准备好了。3.2 定义三个核心 Tool在src/tools.ts里定义 tool 的 schema。每个 tool 就是一个 JSON 对象描述名称、用途和输入参数。export const ReadFileSchema { name: read_file, description: 读取指定路径的文件内容, inputSchema: { type: object, properties: { path: { type: string, description: 文件绝对路径 }, }, required: [path], }, } export const WriteFileSchema { name: write_file, description: 写入内容到指定文件, inputSchema: { type: object, properties: { path: { type: string, description: 文件绝对路径 }, content: { type: string, description: 写入的内容 }, }, required: [path, content], }, } export const ListDirSchema { name: list_directory, description: 列出目录下的文件和子目录, inputSchema: { type: object, properties: { path: { type: string, description: 目录绝对路径 }, }, required: [path], }, }这三个 schema 就是 AI 客户端能看到的「能力清单」。tools/list请求返回它们tools/call请求携带具体参数来执行。3.3 实现 Server 主干src/server.ts是核心。用 SDK 的 Server 类注册 tool然后监听端口。import { Server } from modelcontextprotocol/sdk/server/index.js import { StdioServerTransport } from modelcontextprotocol/sdk/server/stdio.js import { readFile, writeFile, readdir, stat } from fs/promises import { join, resolve } from path import { ReadFileSchema, WriteFileSchema, ListDirSchema } from ./tools.js const ALLOWED_ROOT resolve(process.env.FS_ROOT || process.cwd()) function safePath(input: string): string { const p resolve(input) if (!p.startsWith(ALLOWED_ROOT)) { throw new Error(路径越界: ${input}) } return p } const server new Server( { name: my-fs-server, version: 1.0.0 }, { capabilities: { tools: {} } } ) server.setRequestHandler(tools/list, async () ({ tools: [ReadFileSchema, WriteFileSchema, ListDirSchema], })) server.setRequestHandler(tools/call, async (req) { const { name, arguments: args } req.params if (name read_file) { const content await readFile(safePath(args.path), utf-8) return { content: [{ type: text, text: content }] } } if (name write_file) { await writeFile(safePath(args.path), args.content, utf-8) return { content: [{ type: text, text: 已写入 ${args.path} }] } } if (name list_directory) { const entries await readdir(safePath(args.path)) const details await Promise.all( entries.map(async (entry) { const full join(args.path, entry) try { const s await stat(full) return { name: entry, isDirectory: s.isDirectory(), size: s.size } } catch { return { name: entry, isDirectory: false, size: 0 } } }) ) return { content: [{ type: text, text: JSON.stringify(details, null, 2) }] } } throw new Error(未知 tool: ${name}) }) const transport new StdioServerTransport() await server.connect(transport) process.stderr.write(my-fs-server 已启动\n)这里有几个关键点。第一safePath做了路径白名单校验防止../../../etc/passwd这类越界访问。第二日志用process.stderr.write而不是console.log因为 stdio 模式下 stdout 是通信通道混入日志会破坏 JSON-RPC 消息。第三ALLOWED_ROOT通过环境变量注入方便在不同项目里切换根目录。3.4 编译与启动在package.json里加两个脚本{ scripts: { build: tsc, start: node dist/server.js } }执行npm run build编译然后npm start启动。如果看到 stderr 输出「my-fs-server 已启动」说明 Server 已经在 stdio 模式下等待客户端连接。4. 在 Cline 中接入并验证文件读写4.1 配置 settings.jsonCline 的 MCP 配置放在settings.json里。找到 Cline 的 MCP 设置入口添加以下内容{ mcpServers: { my-fs-server: { command: node, args: [/absolute/path/to/my-fs-server/dist/server.js], env: { FS_ROOT: /absolute/path/to/your/workspace } } } }command和args指向编译后的 Server 入口。env里的FS_ROOT决定了这个 Server 能访问的根目录建议设成你的项目目录不要设成系统根目录。如果你用的是支持config.toml的客户端等价配置如下[mcp_servers.my-fs-server] command node args [/absolute/path/to/my-fs-server/dist/server.js] [mcp_servers.my-fs-server.env] FS_ROOT /absolute/path/to/your/workspace4.2 验证请求与成功结果重启 Cline 后在对话里输入「帮我列出 /absolute/path/to/your/workspace 下的所有文件」。如果配置正确Cline 会先调用tools/list发现三个 tool然后选择list_directory执行。你会看到返回的 JSON 数组包含文件名、是否目录、大小。接着输入「读取 package.json 的内容」Cline 会调用read_file并返回文件文本。再试一次写入「在 workspace 下创建 test-mcp.txt内容写 hello mcp」。Cline 调用write_file返回「已写入」。你去文件系统里确认文件确实存在。这一步跑通说明端到端链路完整Cline 发起请求 → 模型决策调用哪个 tool → MCP Server 执行 → 结果返回给模型 → 模型组织语言回复你。4.3 模型侧配置Cline 里的模型 API 配置填入 TaoToken 的 Key 和 base_url。模型对话入口在 https://taotoken.net/api Key 从控制台获取。如果你还没创建 Key去 API Keys 页面生成一个。接入文档里有各客户端的详细配置示例遇到格式问题可以对照检查。5. 本篇常见错排查5.1 ESM 与 CommonJS 冲突报错require() of ES Module not supported或Cannot use import statement outside a module。原因是package.json缺少type: module或者tsconfig.json的 module 不是 NodeNext。两个都检查一遍。另外ESM 模式下 import 本地文件必须带.js后缀即使源文件是.ts。5.2 stdout 被日志污染现象是 Cline 报 JSON 解析错误或者 tool 调用无响应。检查代码里有没有console.log。stdio 模式下stdout 只能输出 JSON-RPC 消息任何额外输出都会破坏协议。把所有调试日志改成process.stderr.write。5.3 路径越界被拦截如果你把FS_ROOT设成了/Users/yourname但想访问/tmp下的文件safePath会抛错。这是预期行为。要么把FS_ROOT调大要么把目标文件移到白名单目录内。生产环境不建议放开白名单。5.4 tool 未被调用模型没有选择调用 tool而是直接回答。可能原因有两个一是 tool 的 description 写得太模糊模型不知道什么时候该用二是模型本身对 MCP 支持不完整。先把 description 写具体比如「读取指定路径的文件内容返回 UTF-8 文本」再试。如果还不调用换一个明确支持 MCP 的客户端验证。5.5 大文件返回超限读取超过几 MB 的文件时MCP 的 response 可能报错。解决办法是在read_file里加一个limit参数默认只读前 1MB超出部分返回截断提示。这样模型知道需要分段读取而不是一次性拿全部内容。6. 下一步从文件系统扩展到更多连接器文件系统 Server 跑通后这套骨架可以直接复用到其他场景。比如把read_file换成 SQLite 查询把write_file换成 GitHub Issues 创建核心结构不变定义 schema、注册 handler、启动 stdio transport。如果你打算长期做编码类任务建议把模型侧切到 Coding Plan它在高频 tool 调用场景下额度更充裕。调试阶段用模型对话入口就够了等 Server 稳定后再迁移。最后提醒一点MCP Server 的能力边界由你写的 tool 决定。不要为了图方便把 Shell 执行直接暴露出去尤其是在共享环境里。文件系统连接器已经能覆盖大部分文档处理需求先把这三个 tool 用熟再考虑扩展。