☰
手写一个 MCP Server 有多简单?30 分钟从零搭一个文件系统连接器(TaoToken 配置版)
2026/9/28 6:59:46 网站建设 项目流程

1. 为什么我要手写一个文件系统 MCP Server

MCP 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_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_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 Server

3.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.json

Cline 的 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/workspace"

4.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 用熟,再考虑扩展。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询